Compare commits
25 Commits
04f1134be4
...
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 |
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
|
||||||
|
|
||||||
45
README.md
45
README.md
@@ -9,11 +9,18 @@ El sistema se compone de un **backend RESTful API desarrollado en ASP.NET Core**
|
|||||||
|
|
||||||
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.
|
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
|
### 📋 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.
|
- **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:
|
- **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, CPU, Motherboard, RAM instalada, OS, etc.
|
- **Datos Principales:** Hostname, IP, MAC, OS, etc.
|
||||||
- **Componentes Asociados:** Lista detallada de discos, módulos de RAM y usuarios asociados.
|
- **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.
|
- **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.
|
- **Historial de Cambios:** Un registro cronológico de todas las modificaciones realizadas en el equipo.
|
||||||
- **Acciones Remotas:**
|
- **Acciones Remotas:**
|
||||||
@@ -50,14 +57,17 @@ El sistema está diseñado en torno a la gestión y visualización de activos de
|
|||||||
- **Lenguaje:** C#
|
- **Lenguaje:** C#
|
||||||
- **Acceso a Datos:** Dapper (Micro ORM)
|
- **Acceso a Datos:** Dapper (Micro ORM)
|
||||||
- **Base de Datos:** Microsoft SQL Server
|
- **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)
|
- **Comandos Remotos:** Renci.SshNet para ejecución de comandos SSH (Wake On Lan)
|
||||||
|
|
||||||
### Frontend (`frontend/`)
|
### Frontend (`frontend/`)
|
||||||
- **Framework/Librería:** React 19
|
- **Framework/Librería:** React 19
|
||||||
- **Lenguaje:** TypeScript
|
- **Lenguaje:** TypeScript
|
||||||
- **Gestión de Tabla:** TanStack Table v8
|
- **Gestión de Tabla:** TanStack Table v8
|
||||||
|
- **Gráficos:** Chart.js con `react-chartjs-2`
|
||||||
- **Notificaciones:** React Hot Toast
|
- **Notificaciones:** React Hot Toast
|
||||||
- **Tooltips:** React Tooltip
|
- **Tooltips:** React Tooltip
|
||||||
|
- **Iconos:** Lucide React
|
||||||
- **Build Tool:** Vite
|
- **Build Tool:** Vite
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -81,33 +91,37 @@ cd nombre-del-repositorio
|
|||||||
### 2. Configuración de la Base de Datos
|
### 2. Configuración de la Base de Datos
|
||||||
1. Abra SSMS y conecte a su instancia de SQL Server.
|
1. Abra SSMS y conecte a su instancia de SQL Server.
|
||||||
2. Cree una nueva base de datos llamada `InventarioDB`.
|
2. Cree una nueva base de datos llamada `InventarioDB`.
|
||||||
3. Ejecute el script `dboInventarioDB.txt` (ubicado en la raíz del proyecto o en la carpeta `backend/`) sobre la base de datos recién creada. Esto creará todas las tablas, relaciones y claves necesarias.
|
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
|
### 3. Configuración del Backend
|
||||||
1. Navegue al directorio del backend: `cd backend`.
|
1. Navegue al directorio del backend: `cd backend`.
|
||||||
2. Abra el archivo `appsettings.json`.
|
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, asegurándose de que el usuario (`User Id` y `Password`) tenga permisos sobre la base de datos `InventarioDB`.
|
3. **Modifique la `ConnectionString`** para que apunte a su instancia de SQL Server.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": "Server=NOMBRE_SU_SERVIDOR;Database=InventarioDB;User Id=su_usuario_sql;Password=su_contraseña;TrustServerCertificate=True"
|
"DefaultConnection": "Server=NOMBRE_SU_SERVIDOR;Database=InventarioDB;User Id=su_usuario_sql;Password=su_contraseña;TrustServerCertificate=True"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
4. En el mismo archivo, configure las credenciales del servidor SSH que se usará para la función Wake On Lan en la sección `SshSettings`.
|
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
|
```json
|
||||||
"SshSettings": {
|
"AuthSettings": {
|
||||||
"Host": "",
|
"Username": "admin",
|
||||||
"Port": ,
|
"Password": "SU_NUEVA_CLAVE_SEGURA_AQUI"
|
||||||
"User": "",
|
},
|
||||||
"Password": ""
|
"Jwt": {
|
||||||
|
"Key": "SU_CLAVE_SECRETA_LARGA_Y_COMPLEJA_PARA_JWT",
|
||||||
|
"Issuer": "InventarioAPI",
|
||||||
|
"Audience": "InventarioClient"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
5. Instale las dependencias y ejecute el backend:
|
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
|
```bash
|
||||||
dotnet restore
|
dotnet restore
|
||||||
dotnet run
|
dotnet run
|
||||||
```
|
|
||||||
La API estará disponible en `http://localhost:5198` y la UI de Swagger en la misma URL.
|
La API estará disponible en `http://localhost:5198` y la UI de Swagger en la misma URL.
|
||||||
|
|
||||||
### 4. Configuración del Frontend
|
### 4. Configuración del Frontend
|
||||||
@@ -115,12 +129,11 @@ cd nombre-del-repositorio
|
|||||||
2. Instale las dependencias:
|
2. Instale las dependencias:
|
||||||
```bash
|
```bash
|
||||||
npm install
|
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.
|
||||||
3. El frontend ya está configurado para apuntar a la URL del backend (`http://localhost:5198`) en el archivo `SimpleTable.tsx`. Si ha cambiado el puerto del backend, deberá actualizar esta constante `BASE_URL`.
|
|
||||||
4. Ejecute el frontend en modo de desarrollo:
|
4. Ejecute el frontend en modo de desarrollo:
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
|
||||||
La aplicación web estará disponible en `http://localhost:5173` (o el puerto que indique Vite).
|
La aplicación web estará disponible en `http://localhost:5173` (o el puerto que indique Vite).
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -2,9 +2,11 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using Inventario.API.Data;
|
using Inventario.API.Data;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
namespace Inventario.API.Controllers
|
namespace Inventario.API.Controllers
|
||||||
{
|
{
|
||||||
|
[Authorize]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class AdminController : ControllerBase
|
public class AdminController : ControllerBase
|
||||||
@@ -16,12 +18,33 @@ namespace Inventario.API.Controllers
|
|||||||
_context = context;
|
_context = context;
|
||||||
}
|
}
|
||||||
|
|
||||||
// DTO para devolver los valores y su conteo
|
// --- DTOs para los componentes ---
|
||||||
public class ComponenteValorDto
|
public class ComponenteValorDto
|
||||||
{
|
{
|
||||||
public string Valor { get; set; } = "";
|
public string Valor { get; set; } = "";
|
||||||
public int Conteo { 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}")]
|
[HttpGet("componentes/{tipo}")]
|
||||||
public async Task<IActionResult> GetComponenteValores(string tipo)
|
public async Task<IActionResult> GetComponenteValores(string tipo)
|
||||||
@@ -53,13 +76,6 @@ namespace Inventario.API.Controllers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DTO para la petición de unificación
|
|
||||||
public class UnificarComponenteDto
|
|
||||||
{
|
|
||||||
public required string ValorNuevo { get; set; }
|
|
||||||
public required string ValorAntiguo { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPut("componentes/{tipo}/unificar")]
|
[HttpPut("componentes/{tipo}/unificar")]
|
||||||
public async Task<IActionResult> UnificarComponenteValores(string tipo, [FromBody] UnificarComponenteDto dto)
|
public async Task<IActionResult> UnificarComponenteValores(string tipo, [FromBody] UnificarComponenteDto dto)
|
||||||
{
|
{
|
||||||
@@ -93,63 +109,64 @@ namespace Inventario.API.Controllers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DTO para devolver los valores de RAM y su conteo
|
// --- Devuelve la RAM agrupada ---
|
||||||
public class RamMaestraDto
|
|
||||||
{
|
|
||||||
public int Id { get; set; }
|
|
||||||
public string? Part_number { get; set; }
|
|
||||||
public string? Fabricante { get; set; }
|
|
||||||
public int Tamano { get; set; }
|
|
||||||
public int? Velocidad { get; set; }
|
|
||||||
public int Conteo { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("componentes/ram")]
|
[HttpGet("componentes/ram")]
|
||||||
public async Task<IActionResult> GetComponentesRam()
|
public async Task<IActionResult> GetComponentesRam()
|
||||||
{
|
{
|
||||||
var query = @"
|
var query = @"
|
||||||
SELECT
|
SELECT
|
||||||
mr.Id, mr.part_number as PartNumber, mr.Fabricante, mr.Tamano, mr.Velocidad,
|
mr.Fabricante,
|
||||||
|
mr.Tamano,
|
||||||
|
mr.Velocidad,
|
||||||
COUNT(emr.memoria_ram_id) as Conteo
|
COUNT(emr.memoria_ram_id) as Conteo
|
||||||
FROM
|
FROM
|
||||||
dbo.memorias_ram mr
|
dbo.memorias_ram mr
|
||||||
LEFT JOIN
|
LEFT JOIN
|
||||||
dbo.equipos_memorias_ram emr ON mr.id = emr.memoria_ram_id
|
dbo.equipos_memorias_ram emr ON mr.id = emr.memoria_ram_id
|
||||||
GROUP BY
|
GROUP BY
|
||||||
mr.Id, mr.part_number, mr.Fabricante, mr.Tamano, mr.Velocidad
|
mr.Fabricante,
|
||||||
|
mr.Tamano,
|
||||||
|
mr.Velocidad
|
||||||
ORDER BY
|
ORDER BY
|
||||||
Conteo DESC, mr.Fabricante, mr.Tamano;";
|
Conteo DESC, mr.Fabricante, mr.Tamano;";
|
||||||
|
|
||||||
using (var connection = _context.CreateConnection())
|
using (var connection = _context.CreateConnection())
|
||||||
{
|
{
|
||||||
var valores = await connection.QueryAsync<RamMaestraDto>(query);
|
var valores = await connection.QueryAsync<RamAgrupadaDto>(query);
|
||||||
return Ok(valores);
|
return Ok(valores);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("componentes/ram/{id}")]
|
// --- Elimina un grupo completo ---
|
||||||
public async Task<IActionResult> BorrarComponenteRam(int id)
|
[HttpDelete("componentes/ram")]
|
||||||
|
public async Task<IActionResult> BorrarComponenteRam([FromBody] BorrarRamAgrupadaDto dto)
|
||||||
{
|
{
|
||||||
using (var connection = _context.CreateConnection())
|
using (var connection = _context.CreateConnection())
|
||||||
{
|
{
|
||||||
// 1. Verificación de seguridad: Asegurarse de que el módulo no esté en uso.
|
// Verificación de seguridad: Asegurarse de que el grupo no esté en uso.
|
||||||
var usageQuery = "SELECT COUNT(*) FROM dbo.equipos_memorias_ram WHERE memoria_ram_id = @Id;";
|
var usageQuery = @"
|
||||||
var usageCount = await connection.ExecuteScalarAsync<int>(usageQuery, new { Id = id });
|
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)
|
if (usageCount > 0)
|
||||||
{
|
{
|
||||||
return Conflict($"Este módulo de RAM está en uso por {usageCount} equipo(s) y no puede ser eliminado.");
|
return Conflict(new { message = $"Este grupo de RAM está en uso por {usageCount} equipo(s) y no puede ser eliminado." });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Si no está en uso, proceder con la eliminación.
|
// 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 Id = @Id;";
|
var deleteQuery = @"
|
||||||
var filasAfectadas = await connection.ExecuteAsync(deleteQuery, new { Id = id });
|
DELETE FROM dbo.memorias_ram
|
||||||
|
WHERE (Fabricante = @Fabricante OR (Fabricante IS NULL AND @Fabricante IS NULL))
|
||||||
if (filasAfectadas == 0)
|
AND Tamano = @Tamano
|
||||||
{
|
AND (Velocidad = @Velocidad OR (Velocidad IS NULL AND @Velocidad IS NULL));";
|
||||||
return NotFound("Módulo de RAM no encontrado.");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(deleteQuery, dto);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,19 +189,14 @@ namespace Inventario.API.Controllers
|
|||||||
|
|
||||||
using (var connection = _context.CreateConnection())
|
using (var connection = _context.CreateConnection())
|
||||||
{
|
{
|
||||||
// 1. Verificación de seguridad: Asegurarse de que el valor no esté en uso.
|
|
||||||
var usageQuery = $"SELECT COUNT(*) FROM dbo.equipos WHERE {columnName} = @Valor;";
|
var usageQuery = $"SELECT COUNT(*) FROM dbo.equipos WHERE {columnName} = @Valor;";
|
||||||
var usageCount = await connection.ExecuteScalarAsync<int>(usageQuery, new { Valor = valor });
|
var usageCount = await connection.ExecuteScalarAsync<int>(usageQuery, new { Valor = valor });
|
||||||
|
|
||||||
if (usageCount > 0)
|
if (usageCount > 0)
|
||||||
{
|
{
|
||||||
return Conflict($"Este valor está en uso por {usageCount} equipo(s) y no puede ser eliminado. Intente unificarlo en su lugar.");
|
return Conflict(new { message = $"Este valor está en uso por {usageCount} equipo(s) y no puede ser eliminado. Intente unificarlo en su lugar." });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Esta parte es más conceptual. Un componente de texto no existe en una tabla maestra,
|
|
||||||
// por lo que no hay nada que "eliminar". El hecho de que el conteo sea 0 significa
|
|
||||||
// que ya no existe en la práctica. Devolvemos éxito para confirmar esto.
|
|
||||||
// Si tuviéramos tablas maestras (ej: dbo.sistemas_operativos), aquí iría la consulta DELETE.
|
|
||||||
return NoContent();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,11 @@ using Dapper;
|
|||||||
using Inventario.API.Data;
|
using Inventario.API.Data;
|
||||||
using Inventario.API.Models;
|
using Inventario.API.Models;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
namespace Inventario.API.Controllers
|
namespace Inventario.API.Controllers
|
||||||
{
|
{
|
||||||
|
[Authorize]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class DiscosController : ControllerBase
|
public class DiscosController : ControllerBase
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ using System.Net.NetworkInformation;
|
|||||||
using Microsoft.Data.SqlClient;
|
using Microsoft.Data.SqlClient;
|
||||||
using Renci.SshNet;
|
using Renci.SshNet;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
namespace Inventario.API.Controllers
|
namespace Inventario.API.Controllers
|
||||||
{
|
{
|
||||||
|
[Authorize]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class EquiposController : ControllerBase
|
public class EquiposController : ControllerBase
|
||||||
@@ -33,7 +35,7 @@ namespace Inventario.API.Controllers
|
|||||||
{
|
{
|
||||||
var query = @"
|
var query = @"
|
||||||
SELECT
|
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.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,
|
s.Id as Id, s.Nombre,
|
||||||
u.Id as Id, u.Username, u.Password, ue.Origen as Origen,
|
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,
|
d.Id as Id, d.Mediatype, d.Size, ed.Origen as Origen, ed.Id as EquipoDiscoId,
|
||||||
@@ -51,7 +53,6 @@ namespace Inventario.API.Controllers
|
|||||||
{
|
{
|
||||||
var equipoDict = new Dictionary<int, Equipo>();
|
var equipoDict = new Dictionary<int, Equipo>();
|
||||||
|
|
||||||
// CAMBIO: Se actualizan los tipos en la función de mapeo de Dapper
|
|
||||||
await connection.QueryAsync<Equipo, Sector, UsuarioEquipoDetalle, DiscoDetalle, MemoriaRamEquipoDetalle, Equipo>(
|
await connection.QueryAsync<Equipo, Sector, UsuarioEquipoDetalle, DiscoDetalle, MemoriaRamEquipoDetalle, Equipo>(
|
||||||
query, (equipo, sector, usuario, disco, memoria) =>
|
query, (equipo, sector, usuario, disco, memoria) =>
|
||||||
{
|
{
|
||||||
@@ -61,12 +62,11 @@ namespace Inventario.API.Controllers
|
|||||||
equipoActual.Sector = sector;
|
equipoActual.Sector = sector;
|
||||||
equipoDict.Add(equipoActual.Id, equipoActual);
|
equipoDict.Add(equipoActual.Id, equipoActual);
|
||||||
}
|
}
|
||||||
// CAMBIO: Se ajusta la lógica para evitar duplicados en los nuevos tipos detallados
|
|
||||||
if (usuario != null && !equipoActual.Usuarios.Any(u => u.Id == usuario.Id))
|
if (usuario != null && !equipoActual.Usuarios.Any(u => u.Id == usuario.Id))
|
||||||
equipoActual.Usuarios.Add(usuario);
|
equipoActual.Usuarios.Add(usuario);
|
||||||
if (disco != null && !equipoActual.Discos.Any(d => d.Id == disco.Id))
|
if (disco != null && !equipoActual.Discos.Any(d => d.EquipoDiscoId == disco.EquipoDiscoId))
|
||||||
equipoActual.Discos.Add(disco);
|
equipoActual.Discos.Add(disco);
|
||||||
if (memoria != null && !equipoActual.MemoriasRam.Any(m => m.Id == memoria.Id && m.Slot == memoria.Slot))
|
if (memoria != null && !equipoActual.MemoriasRam.Any(m => m.EquipoMemoriaRamId == memoria.EquipoMemoriaRamId))
|
||||||
equipoActual.MemoriasRam.Add(memoria);
|
equipoActual.MemoriasRam.Add(memoria);
|
||||||
|
|
||||||
return equipoActual;
|
return equipoActual;
|
||||||
@@ -148,7 +148,7 @@ namespace Inventario.API.Controllers
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Actualizar y registrar historial
|
// Actualizar y registrar historial
|
||||||
var cambios = new Dictionary<string, (string anterior, string nuevo)>();
|
var cambios = new Dictionary<string, (string? anterior, string? nuevo)>();
|
||||||
|
|
||||||
// Comparamos campos para registrar en historial
|
// Comparamos campos para registrar en historial
|
||||||
if (equipoData.Ip != equipoExistente.Ip) cambios["ip"] = (equipoExistente.Ip, equipoData.Ip);
|
if (equipoData.Ip != equipoExistente.Ip) cambios["ip"] = (equipoExistente.Ip, equipoData.Ip);
|
||||||
@@ -191,15 +191,13 @@ namespace Inventario.API.Controllers
|
|||||||
[HttpDelete("{id}")]
|
[HttpDelete("{id}")]
|
||||||
public async Task<IActionResult> Borrar(int id)
|
public async Task<IActionResult> Borrar(int id)
|
||||||
{
|
{
|
||||||
var query = "DELETE FROM dbo.equipos WHERE Id = @Id AND Origen = 'manual';";
|
var query = "DELETE FROM dbo.equipos WHERE Id = @Id;";
|
||||||
using (var connection = _context.CreateConnection())
|
using (var connection = _context.CreateConnection())
|
||||||
{
|
{
|
||||||
var filasAfectadas = await connection.ExecuteAsync(query, new { Id = id });
|
var filasAfectadas = await connection.ExecuteAsync(query, new { Id = id });
|
||||||
if (filasAfectadas == 0)
|
if (filasAfectadas == 0)
|
||||||
{
|
{
|
||||||
// Puede que no se haya borrado porque no existe o porque es automático.
|
return NotFound("Equipo no encontrado.");
|
||||||
// Damos un mensaje de error genérico pero informativo.
|
|
||||||
return NotFound("Equipo no encontrado o no se puede eliminar porque fue generado automáticamente.");
|
|
||||||
}
|
}
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
@@ -243,20 +241,17 @@ namespace Inventario.API.Controllers
|
|||||||
INSERT INTO dbo.usuarios_equipos (equipo_id, usuario_id, origen)
|
INSERT INTO dbo.usuarios_equipos (equipo_id, usuario_id, origen)
|
||||||
SELECT e.id, u.id, 'automatica'
|
SELECT e.id, u.id, 'automatica'
|
||||||
FROM dbo.equipos e, dbo.usuarios u
|
FROM dbo.equipos e, dbo.usuarios u
|
||||||
WHERE e.Hostname = @Hostname AND u.Username = @Username;";
|
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())
|
using (var connection = _context.CreateConnection())
|
||||||
{
|
{
|
||||||
try
|
await connection.ExecuteAsync(query, new { Hostname = hostname, dto.Username });
|
||||||
{
|
return Ok(new { success = true, message = "Asociación asegurada." });
|
||||||
var filasAfectadas = await connection.ExecuteAsync(query, new { Hostname = hostname, dto.Username });
|
|
||||||
if (filasAfectadas == 0) return NotFound("Equipo o usuario no encontrado.");
|
|
||||||
return Ok(new { success = true });
|
|
||||||
}
|
|
||||||
catch (SqlException ex) when (ex.Number == 2627) // Error de clave primaria duplicada
|
|
||||||
{
|
|
||||||
return Conflict("El usuario ya está asociado a este equipo.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,7 +274,6 @@ namespace Inventario.API.Controllers
|
|||||||
[HttpPost("{hostname}/asociardiscos")]
|
[HttpPost("{hostname}/asociardiscos")]
|
||||||
public async Task<IActionResult> AsociarDiscos(string hostname, [FromBody] List<Disco> discosDesdeCliente)
|
public async Task<IActionResult> AsociarDiscos(string hostname, [FromBody] List<Disco> discosDesdeCliente)
|
||||||
{
|
{
|
||||||
// 1. OBTENER EL EQUIPO
|
|
||||||
var equipoQuery = "SELECT * FROM dbo.equipos WHERE Hostname = @Hostname;";
|
var equipoQuery = "SELECT * FROM dbo.equipos WHERE Hostname = @Hostname;";
|
||||||
using var connection = _context.CreateConnection();
|
using var connection = _context.CreateConnection();
|
||||||
connection.Open();
|
connection.Open();
|
||||||
@@ -290,21 +284,16 @@ namespace Inventario.API.Controllers
|
|||||||
return NotFound("Equipo no encontrado.");
|
return NotFound("Equipo no encontrado.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Iniciar una transacción para asegurar que todas las operaciones se completen o ninguna lo haga.
|
|
||||||
using var transaction = connection.BeginTransaction();
|
using var transaction = connection.BeginTransaction();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 2. OBTENER ASOCIACIONES Y DISCOS ACTUALES DE LA BD
|
|
||||||
var discosActualesQuery = @"
|
var discosActualesQuery = @"
|
||||||
SELECT d.Id, d.Mediatype, d.Size, ed.Id as EquipoDiscoId
|
SELECT d.Id, d.Mediatype, d.Size, ed.Id as EquipoDiscoId
|
||||||
FROM dbo.equipos_discos ed
|
FROM dbo.equipos_discos ed
|
||||||
JOIN dbo.discos d ON ed.disco_id = d.id
|
JOIN dbo.discos d ON ed.disco_id = d.id
|
||||||
WHERE ed.equipo_id = @EquipoId;";
|
WHERE ed.equipo_id = @EquipoId;";
|
||||||
|
|
||||||
var discosEnDb = (await connection.QueryAsync<DiscoAsociado>(discosActualesQuery, new { EquipoId = equipo.Id }, transaction)).ToList();
|
var discosEnDb = (await connection.QueryAsync<DiscoAsociado>(discosActualesQuery, new { EquipoId = equipo.Id }, transaction)).ToList();
|
||||||
|
|
||||||
// 3. AGRUPAR Y CONTAR DISCOS (del cliente y de la BD)
|
|
||||||
// Crea un diccionario estilo: {"SSD_256": 2, "HDD_1024": 1}
|
|
||||||
var discosClienteContados = discosDesdeCliente
|
var discosClienteContados = discosDesdeCliente
|
||||||
.GroupBy(d => $"{d.Mediatype}_{d.Size}")
|
.GroupBy(d => $"{d.Mediatype}_{d.Size}")
|
||||||
.ToDictionary(g => g.Key, g => g.Count());
|
.ToDictionary(g => g.Key, g => g.Count());
|
||||||
@@ -313,28 +302,23 @@ namespace Inventario.API.Controllers
|
|||||||
.GroupBy(d => $"{d.Mediatype}_{d.Size}")
|
.GroupBy(d => $"{d.Mediatype}_{d.Size}")
|
||||||
.ToDictionary(g => g.Key, g => g.Count());
|
.ToDictionary(g => g.Key, g => g.Count());
|
||||||
|
|
||||||
var cambios = new Dictionary<string, (string anterior, string nuevo)>();
|
var cambios = new Dictionary<string, (string? anterior, string? nuevo)>();
|
||||||
|
|
||||||
// 4. CALCULAR Y EJECUTAR ELIMINACIONES
|
|
||||||
var discosAEliminar = new List<int>();
|
var discosAEliminar = new List<int>();
|
||||||
foreach (var discoDb in discosEnDb)
|
foreach (var discoDb in discosEnDb)
|
||||||
{
|
{
|
||||||
var key = $"{discoDb.Mediatype}_{discoDb.Size}";
|
var key = $"{discoDb.Mediatype}_{discoDb.Size}";
|
||||||
if (discosClienteContados.TryGetValue(key, out int count) && count > 0)
|
if (discosClienteContados.TryGetValue(key, out int count) && count > 0)
|
||||||
{
|
{
|
||||||
// Este disco todavía existe en el cliente, decrementamos el contador y lo saltamos.
|
|
||||||
discosClienteContados[key]--;
|
discosClienteContados[key]--;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Este disco ya no está en el cliente, marcamos su asociación para eliminar.
|
|
||||||
discosAEliminar.Add(discoDb.EquipoDiscoId);
|
discosAEliminar.Add(discoDb.EquipoDiscoId);
|
||||||
|
|
||||||
// Registrar para el historial
|
|
||||||
var nombreDisco = $"Disco {discoDb.Mediatype} {discoDb.Size}GB";
|
var nombreDisco = $"Disco {discoDb.Mediatype} {discoDb.Size}GB";
|
||||||
var anterior = discosDbContados.GetValueOrDefault(key, 0);
|
var anterior = discosDbContados.GetValueOrDefault(key, 0);
|
||||||
if (!cambios.ContainsKey(nombreDisco)) cambios[nombreDisco] = (anterior.ToString(), (anterior - 1).ToString());
|
if (!cambios.ContainsKey(nombreDisco)) cambios[nombreDisco] = (anterior.ToString(), (anterior - 1).ToString());
|
||||||
else cambios[nombreDisco] = (anterior.ToString(), (int.Parse(cambios[nombreDisco].nuevo) - 1).ToString());
|
else cambios[nombreDisco] = (anterior.ToString(), (int.Parse(cambios[nombreDisco].nuevo!) - 1).ToString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (discosAEliminar.Any())
|
if (discosAEliminar.Any())
|
||||||
@@ -342,39 +326,33 @@ namespace Inventario.API.Controllers
|
|||||||
await connection.ExecuteAsync("DELETE FROM dbo.equipos_discos WHERE Id IN @Ids;", new { Ids = discosAEliminar }, transaction);
|
await connection.ExecuteAsync("DELETE FROM dbo.equipos_discos WHERE Id IN @Ids;", new { Ids = discosAEliminar }, transaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. CALCULAR Y EJECUTAR INSERCIONES
|
|
||||||
foreach (var discoCliente in discosDesdeCliente)
|
foreach (var discoCliente in discosDesdeCliente)
|
||||||
{
|
{
|
||||||
var key = $"{discoCliente.Mediatype}_{discoCliente.Size}";
|
var key = $"{discoCliente.Mediatype}_{discoCliente.Size}";
|
||||||
if (discosDbContados.TryGetValue(key, out int count) && count > 0)
|
if (discosDbContados.TryGetValue(key, out int count) && count > 0)
|
||||||
{
|
{
|
||||||
// Este disco ya existía, decrementamos para no volver a añadirlo.
|
|
||||||
discosDbContados[key]--;
|
discosDbContados[key]--;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Este es un disco nuevo que hay que asociar.
|
var disco = await connection.QueryFirstOrDefaultAsync<Disco>("SELECT * FROM dbo.discos WHERE Mediatype = @Mediatype AND Size = @Size;", discoCliente, transaction);
|
||||||
var disco = await connection.QuerySingleOrDefaultAsync<Disco>("SELECT * FROM dbo.discos WHERE Mediatype = @Mediatype AND Size = @Size;", discoCliente, transaction);
|
|
||||||
if (disco == null) continue;
|
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);
|
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);
|
||||||
|
|
||||||
// Registrar para el historial
|
|
||||||
var nombreDisco = $"Disco {disco.Mediatype} {disco.Size}GB";
|
var nombreDisco = $"Disco {disco.Mediatype} {disco.Size}GB";
|
||||||
var anterior = discosDbContados.GetValueOrDefault(key, 0);
|
var anterior = discosDbContados.GetValueOrDefault(key, 0);
|
||||||
if (!cambios.ContainsKey(nombreDisco)) cambios[nombreDisco] = (anterior.ToString(), (anterior + 1).ToString());
|
if (!cambios.ContainsKey(nombreDisco)) cambios[nombreDisco] = (anterior.ToString(), (anterior + 1).ToString());
|
||||||
else cambios[nombreDisco] = (anterior.ToString(), (int.Parse(cambios[nombreDisco].nuevo) + 1).ToString());
|
else cambios[nombreDisco] = (anterior.ToString(), (int.Parse(cambios[nombreDisco].nuevo!) + 1).ToString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. REGISTRAR CAMBIOS Y CONFIRMAR TRANSACCIÓN
|
|
||||||
if (cambios.Count > 0)
|
if (cambios.Count > 0)
|
||||||
{
|
{
|
||||||
// Formateamos los valores para el historial
|
|
||||||
var cambiosFormateados = cambios.ToDictionary(
|
var cambiosFormateados = cambios.ToDictionary(
|
||||||
kvp => kvp.Key,
|
kvp => kvp.Key,
|
||||||
kvp => ($"{kvp.Value.anterior} Instalados", $"{kvp.Value.nuevo} Instalados")
|
kvp => ((string?)$"{kvp.Value.anterior} Instalados", (string?)$"{kvp.Value.nuevo} Instalados")
|
||||||
);
|
);
|
||||||
|
|
||||||
await HistorialHelper.RegistrarCambios(_context, equipo.Id, cambiosFormateados);
|
await HistorialHelper.RegistrarCambios(_context, equipo.Id, cambiosFormateados);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,7 +363,6 @@ namespace Inventario.API.Controllers
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
transaction.Rollback();
|
transaction.Rollback();
|
||||||
// Loggear el error en el servidor
|
|
||||||
Console.WriteLine($"Error al asociar discos para {hostname}: {ex.Message}");
|
Console.WriteLine($"Error al asociar discos para {hostname}: {ex.Message}");
|
||||||
return StatusCode(500, "Ocurrió un error interno al procesar la solicitud.");
|
return StatusCode(500, "Ocurrió un error interno al procesar la solicitud.");
|
||||||
}
|
}
|
||||||
@@ -413,7 +390,8 @@ namespace Inventario.API.Controllers
|
|||||||
var huellasCliente = new HashSet<string>(memoriasDesdeCliente.Select(crearHuella));
|
var huellasCliente = new HashSet<string>(memoriasDesdeCliente.Select(crearHuella));
|
||||||
var huellasDb = new HashSet<string>(ramEnDb.Select(crearHuella));
|
var huellasDb = new HashSet<string>(ramEnDb.Select(crearHuella));
|
||||||
|
|
||||||
var cambios = new Dictionary<string, (string anterior, string nuevo)>();
|
var cambios = new Dictionary<string, (string? anterior, string? nuevo)>();
|
||||||
|
|
||||||
Func<dynamic, string> formatRamDetails = ram =>
|
Func<dynamic, string> formatRamDetails = ram =>
|
||||||
{
|
{
|
||||||
var parts = new List<string?> { ram.Fabricante, $"{ram.Tamano}GB", ram.PartNumber, ram.Velocidad?.ToString() + "MHz" };
|
var parts = new List<string?> { ram.Fabricante, $"{ram.Tamano}GB", ram.PartNumber, ram.Velocidad?.ToString() + "MHz" };
|
||||||
@@ -591,7 +569,7 @@ namespace Inventario.API.Controllers
|
|||||||
|
|
||||||
using (var connection = _context.CreateConnection())
|
using (var connection = _context.CreateConnection())
|
||||||
{
|
{
|
||||||
var existente = await connection.QuerySingleOrDefaultAsync<int?>(findQuery, new { equipoDto.Hostname });
|
var existente = await connection.QueryFirstOrDefaultAsync<int?>(findQuery, new { equipoDto.Hostname });
|
||||||
if (existente.HasValue)
|
if (existente.HasValue)
|
||||||
{
|
{
|
||||||
return Conflict($"El hostname '{equipoDto.Hostname}' ya existe.");
|
return Conflict($"El hostname '{equipoDto.Hostname}' ya existe.");
|
||||||
@@ -599,14 +577,13 @@ namespace Inventario.API.Controllers
|
|||||||
|
|
||||||
var nuevoId = await connection.ExecuteScalarAsync<int>(insertQuery, equipoDto);
|
var nuevoId = await connection.ExecuteScalarAsync<int>(insertQuery, equipoDto);
|
||||||
|
|
||||||
// Devolvemos el objeto completo para que el frontend pueda actualizar su estado
|
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 });
|
|
||||||
|
|
||||||
|
var nuevoEquipo = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT * FROM dbo.equipos WHERE Id = @Id", new { Id = nuevoId });
|
||||||
if (nuevoEquipo == null)
|
if (nuevoEquipo == null)
|
||||||
{
|
{
|
||||||
return StatusCode(500, "No se pudo recuperar el equipo después de crearlo.");
|
return StatusCode(500, "No se pudo recuperar el equipo después de crearlo.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return CreatedAtAction(nameof(ConsultarDetalle), new { hostname = nuevoEquipo.Hostname }, nuevoEquipo);
|
return CreatedAtAction(nameof(ConsultarDetalle), new { hostname = nuevoEquipo.Hostname }, nuevoEquipo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -616,14 +593,28 @@ namespace Inventario.API.Controllers
|
|||||||
[HttpDelete("asociacion/disco/{equipoDiscoId}")]
|
[HttpDelete("asociacion/disco/{equipoDiscoId}")]
|
||||||
public async Task<IActionResult> BorrarAsociacionDisco(int equipoDiscoId)
|
public async Task<IActionResult> BorrarAsociacionDisco(int equipoDiscoId)
|
||||||
{
|
{
|
||||||
var query = "DELETE FROM dbo.equipos_discos WHERE Id = @EquipoDiscoId AND Origen = 'manual';";
|
|
||||||
using (var connection = _context.CreateConnection())
|
using (var connection = _context.CreateConnection())
|
||||||
{
|
{
|
||||||
var filasAfectadas = await connection.ExecuteAsync(query, new { EquipoDiscoId = equipoDiscoId });
|
var infoQuery = @"
|
||||||
if (filasAfectadas == 0)
|
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 se puede eliminar porque es automática.");
|
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();
|
return NoContent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -631,14 +622,39 @@ namespace Inventario.API.Controllers
|
|||||||
[HttpDelete("asociacion/ram/{equipoMemoriaRamId}")]
|
[HttpDelete("asociacion/ram/{equipoMemoriaRamId}")]
|
||||||
public async Task<IActionResult> BorrarAsociacionRam(int equipoMemoriaRamId)
|
public async Task<IActionResult> BorrarAsociacionRam(int equipoMemoriaRamId)
|
||||||
{
|
{
|
||||||
var query = "DELETE FROM dbo.equipos_memorias_ram WHERE Id = @EquipoMemoriaRamId AND Origen = 'manual';";
|
|
||||||
using (var connection = _context.CreateConnection())
|
using (var connection = _context.CreateConnection())
|
||||||
{
|
{
|
||||||
var filasAfectadas = await connection.ExecuteAsync(query, new { EquipoMemoriaRamId = equipoMemoriaRamId });
|
var infoQuery = @"
|
||||||
if (filasAfectadas == 0)
|
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 se puede eliminar porque es automática.");
|
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();
|
return NoContent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -646,14 +662,27 @@ namespace Inventario.API.Controllers
|
|||||||
[HttpDelete("asociacion/usuario/{equipoId}/{usuarioId}")]
|
[HttpDelete("asociacion/usuario/{equipoId}/{usuarioId}")]
|
||||||
public async Task<IActionResult> BorrarAsociacionUsuario(int equipoId, int usuarioId)
|
public async Task<IActionResult> BorrarAsociacionUsuario(int equipoId, int usuarioId)
|
||||||
{
|
{
|
||||||
var query = "DELETE FROM dbo.usuarios_equipos WHERE equipo_id = @EquipoId AND usuario_id = @UsuarioId AND Origen = 'manual';";
|
|
||||||
using (var connection = _context.CreateConnection())
|
using (var connection = _context.CreateConnection())
|
||||||
{
|
{
|
||||||
var filasAfectadas = await connection.ExecuteAsync(query, new { EquipoId = equipoId, UsuarioId = usuarioId });
|
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)
|
if (filasAfectadas == 0)
|
||||||
{
|
{
|
||||||
return NotFound("Asociación de usuario no encontrada o no se puede eliminar porque es automática.");
|
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();
|
return NoContent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -663,7 +692,6 @@ namespace Inventario.API.Controllers
|
|||||||
{
|
{
|
||||||
using (var connection = _context.CreateConnection())
|
using (var connection = _context.CreateConnection())
|
||||||
{
|
{
|
||||||
// 1. Verificar que el equipo existe y es manual
|
|
||||||
var equipoActual = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT * FROM dbo.equipos WHERE Id = @Id", new { Id = id });
|
var equipoActual = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT * FROM dbo.equipos WHERE Id = @Id", new { Id = id });
|
||||||
if (equipoActual == null)
|
if (equipoActual == null)
|
||||||
{
|
{
|
||||||
@@ -674,7 +702,6 @@ namespace Inventario.API.Controllers
|
|||||||
return Forbid("No se puede modificar un equipo generado automáticamente.");
|
return Forbid("No se puede modificar un equipo generado automáticamente.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. (Opcional pero recomendado) Verificar que el nuevo hostname no exista ya en otro equipo
|
|
||||||
if (equipoActual.Hostname != equipoDto.Hostname)
|
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 });
|
var hostExistente = await connection.QuerySingleOrDefaultAsync<int?>("SELECT Id FROM dbo.equipos WHERE Hostname = @Hostname AND Id != @Id", new { equipoDto.Hostname, Id = id });
|
||||||
@@ -684,7 +711,31 @@ namespace Inventario.API.Controllers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Construir y ejecutar la consulta de actualización
|
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
|
var updateQuery = @"UPDATE dbo.equipos SET
|
||||||
Hostname = @Hostname,
|
Hostname = @Hostname,
|
||||||
Ip = @Ip,
|
Ip = @Ip,
|
||||||
@@ -693,10 +744,13 @@ namespace Inventario.API.Controllers
|
|||||||
Cpu = @Cpu,
|
Cpu = @Cpu,
|
||||||
Os = @Os,
|
Os = @Os,
|
||||||
Sector_id = @Sector_id,
|
Sector_id = @Sector_id,
|
||||||
|
Ram_slots = @Ram_slots,
|
||||||
|
Architecture = @Architecture, -- Campo añadido a la actualización
|
||||||
updated_at = GETDATE()
|
updated_at = GETDATE()
|
||||||
|
OUTPUT INSERTED.*
|
||||||
WHERE Id = @Id AND Origen = 'manual';";
|
WHERE Id = @Id AND Origen = 'manual';";
|
||||||
|
|
||||||
var filasAfectadas = await connection.ExecuteAsync(updateQuery, new
|
var equipoActualizado = await connection.QuerySingleOrDefaultAsync<Equipo>(updateQuery, new
|
||||||
{
|
{
|
||||||
equipoDto.Hostname,
|
equipoDto.Hostname,
|
||||||
equipoDto.Ip,
|
equipoDto.Ip,
|
||||||
@@ -705,16 +759,24 @@ namespace Inventario.API.Controllers
|
|||||||
equipoDto.Cpu,
|
equipoDto.Cpu,
|
||||||
equipoDto.Os,
|
equipoDto.Os,
|
||||||
equipoDto.Sector_id,
|
equipoDto.Sector_id,
|
||||||
|
equipoDto.Ram_slots,
|
||||||
|
equipoDto.Architecture,
|
||||||
Id = id
|
Id = id
|
||||||
});
|
});
|
||||||
|
|
||||||
if (filasAfectadas == 0)
|
if (equipoActualizado == null)
|
||||||
{
|
{
|
||||||
// Esto no debería pasar si las primeras verificaciones pasaron, pero es una salvaguarda
|
|
||||||
return StatusCode(500, "No se pudo actualizar el equipo.");
|
return StatusCode(500, "No se pudo actualizar el equipo.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return NoContent(); // Éxito en la actualización
|
if (cambios.Count > 0)
|
||||||
|
{
|
||||||
|
await HistorialHelper.RegistrarCambios(_context, id, cambios);
|
||||||
|
}
|
||||||
|
|
||||||
|
var equipoCompleto = await ConsultarDetalle(equipoActualizado.Hostname);
|
||||||
|
|
||||||
|
return equipoCompleto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -723,11 +785,10 @@ namespace Inventario.API.Controllers
|
|||||||
{
|
{
|
||||||
using (var connection = _context.CreateConnection())
|
using (var connection = _context.CreateConnection())
|
||||||
{
|
{
|
||||||
var equipo = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT Origen FROM dbo.equipos WHERE Id = @Id", new { Id = equipoId });
|
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.");
|
if (equipo == null || equipo.Origen != "manual") return Forbid("Solo se pueden añadir componentes a equipos manuales.");
|
||||||
|
|
||||||
// Buscar o crear el disco maestro
|
var discoMaestro = await connection.QueryFirstOrDefaultAsync<Disco>("SELECT * FROM dbo.discos WHERE Mediatype = @Mediatype AND Size = @Size", dto);
|
||||||
var discoMaestro = await connection.QuerySingleOrDefaultAsync<Disco>("SELECT * FROM dbo.discos WHERE Mediatype = @Mediatype AND Size = @Size", dto);
|
|
||||||
int discoId;
|
int discoId;
|
||||||
if (discoMaestro == null)
|
if (discoMaestro == null)
|
||||||
{
|
{
|
||||||
@@ -738,10 +799,14 @@ namespace Inventario.API.Controllers
|
|||||||
discoId = discoMaestro.Id;
|
discoId = discoMaestro.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Crear la asociación manual
|
|
||||||
var asociacionQuery = "INSERT INTO dbo.equipos_discos (equipo_id, disco_id, origen) VALUES (@EquipoId, @DiscoId, 'manual'); SELECT CAST(SCOPE_IDENTITY() as int);";
|
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 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 });
|
return Ok(new { message = "Disco asociado manualmente.", equipoDiscoId = nuevaAsociacionId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -751,25 +816,41 @@ namespace Inventario.API.Controllers
|
|||||||
{
|
{
|
||||||
using (var connection = _context.CreateConnection())
|
using (var connection = _context.CreateConnection())
|
||||||
{
|
{
|
||||||
var equipo = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT Origen FROM dbo.equipos WHERE Id = @Id", new { Id = equipoId });
|
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.");
|
if (equipo == null || equipo.Origen != "manual") return Forbid("Solo se pueden añadir componentes a equipos manuales.");
|
||||||
|
|
||||||
// Lógica similar a la de discos para buscar/crear el módulo maestro
|
|
||||||
int ramId;
|
int ramId;
|
||||||
var ramMaestra = await connection.QuerySingleOrDefaultAsync<MemoriaRam>("SELECT * FROM dbo.memorias_ram WHERE Tamano = @Tamano AND Fabricante = @Fabricante AND Velocidad = @Velocidad", dto);
|
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)
|
if (ramMaestra == null)
|
||||||
{
|
{
|
||||||
ramId = await connection.ExecuteScalarAsync<int>("INSERT INTO dbo.memorias_ram (Tamano, Fabricante, Velocidad) VALUES (@Tamano, @Fabricante, @Velocidad); SELECT CAST(SCOPE_IDENTITY() as int);", dto);
|
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
|
else
|
||||||
{
|
{
|
||||||
ramId = ramMaestra.Id;
|
ramId = ramMaestra.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Crear la asociación manual
|
|
||||||
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 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 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 });
|
return Ok(new { message = "RAM asociada manualmente.", equipoMemoriaRamId = nuevaAsociacionId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -779,12 +860,11 @@ namespace Inventario.API.Controllers
|
|||||||
{
|
{
|
||||||
using (var connection = _context.CreateConnection())
|
using (var connection = _context.CreateConnection())
|
||||||
{
|
{
|
||||||
var equipo = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT Origen FROM dbo.equipos WHERE Id = @Id", new { Id = equipoId });
|
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.");
|
if (equipo == null || equipo.Origen != "manual") return Forbid("Solo se pueden añadir componentes a equipos manuales.");
|
||||||
|
|
||||||
// Buscar o crear el usuario maestro
|
|
||||||
int usuarioId;
|
int usuarioId;
|
||||||
var usuario = await connection.QuerySingleOrDefaultAsync<Usuario>("SELECT * FROM dbo.usuarios WHERE Username = @Username", dto);
|
var usuario = await connection.QueryFirstOrDefaultAsync<Usuario>("SELECT * FROM dbo.usuarios WHERE Username = @Username", dto);
|
||||||
if (usuario == null)
|
if (usuario == null)
|
||||||
{
|
{
|
||||||
usuarioId = await connection.ExecuteScalarAsync<int>("INSERT INTO dbo.usuarios (Username) VALUES (@Username); SELECT CAST(SCOPE_IDENTITY() as int);", dto);
|
usuarioId = await connection.ExecuteScalarAsync<int>("INSERT INTO dbo.usuarios (Username) VALUES (@Username); SELECT CAST(SCOPE_IDENTITY() as int);", dto);
|
||||||
@@ -794,7 +874,6 @@ namespace Inventario.API.Controllers
|
|||||||
usuarioId = usuario.Id;
|
usuarioId = usuario.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Crear la asociación manual
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var asociacionQuery = "INSERT INTO dbo.usuarios_equipos (equipo_id, usuario_id, origen) VALUES (@EquipoId, @UsuarioId, 'manual');";
|
var asociacionQuery = "INSERT INTO dbo.usuarios_equipos (equipo_id, usuario_id, origen) VALUES (@EquipoId, @UsuarioId, 'manual');";
|
||||||
@@ -805,6 +884,11 @@ namespace Inventario.API.Controllers
|
|||||||
return Conflict("El usuario ya está asociado a este equipo.");
|
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." });
|
return Ok(new { message = "Usuario asociado manualmente." });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -866,6 +950,8 @@ namespace Inventario.API.Controllers
|
|||||||
public string? Cpu { get; set; }
|
public string? Cpu { get; set; }
|
||||||
public string? Os { get; set; }
|
public string? Os { get; set; }
|
||||||
public int? Sector_id { get; set; }
|
public int? Sector_id { get; set; }
|
||||||
|
public int? Ram_slots { get; set; }
|
||||||
|
public string? Architecture { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AsociarDiscoManualDto
|
public class AsociarDiscoManualDto
|
||||||
@@ -880,6 +966,7 @@ namespace Inventario.API.Controllers
|
|||||||
public int Tamano { get; set; }
|
public int Tamano { get; set; }
|
||||||
public string? Fabricante { get; set; }
|
public string? Fabricante { get; set; }
|
||||||
public int? Velocidad { get; set; }
|
public int? Velocidad { get; set; }
|
||||||
|
public string? PartNumber { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AsociarUsuarioManualDto
|
public class AsociarUsuarioManualDto
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
|
// backend/Controllers/MemoriasRamController.cs
|
||||||
|
|
||||||
using Dapper;
|
using Dapper;
|
||||||
using Inventario.API.Data;
|
using Inventario.API.Data;
|
||||||
using Inventario.API.Models;
|
using Inventario.API.Models;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
namespace Inventario.API.Controllers
|
namespace Inventario.API.Controllers
|
||||||
{
|
{
|
||||||
|
[Authorize]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class MemoriasRamController : ControllerBase
|
public class MemoriasRamController : ControllerBase
|
||||||
@@ -16,11 +20,27 @@ namespace Inventario.API.Controllers
|
|||||||
_context = context;
|
_context = context;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- GET /api/memoriasram ---
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> Consultar()
|
public async Task<IActionResult> Consultar()
|
||||||
{
|
{
|
||||||
var query = "SELECT Id, part_number as PartNumber, Fabricante, Tamano, Velocidad FROM dbo.memorias_ram;";
|
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())
|
using (var connection = _context.CreateConnection())
|
||||||
{
|
{
|
||||||
var memorias = await connection.QueryAsync<MemoriaRam>(query);
|
var memorias = await connection.QueryAsync<MemoriaRam>(query);
|
||||||
@@ -28,7 +48,6 @@ namespace Inventario.API.Controllers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- GET /api/memoriasram/{id} ---
|
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
public async Task<IActionResult> ConsultarDetalle(int id)
|
public async Task<IActionResult> ConsultarDetalle(int id)
|
||||||
{
|
{
|
||||||
@@ -44,19 +63,17 @@ namespace Inventario.API.Controllers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- POST /api/memoriasram ---
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> Ingresar([FromBody] List<MemoriaRam> memorias)
|
public async Task<IActionResult> Ingresar([FromBody] List<MemoriaRam> memorias)
|
||||||
{
|
{
|
||||||
// Consulta para verificar la existencia. Maneja correctamente los valores nulos.
|
|
||||||
var queryCheck = @"SELECT * FROM dbo.memorias_ram WHERE
|
var queryCheck = @"SELECT * FROM dbo.memorias_ram WHERE
|
||||||
(part_number = @Part_number OR (part_number IS NULL AND @Part_number IS NULL)) AND
|
(part_number = @PartNumber OR (part_number IS NULL AND @PartNumber IS NULL)) AND
|
||||||
(fabricante = @Fabricante OR (fabricante IS NULL AND @Fabricante IS NULL)) AND
|
(fabricante = @Fabricante OR (fabricante IS NULL AND @Fabricante IS NULL)) AND
|
||||||
tamano = @Tamano AND
|
tamano = @Tamano AND
|
||||||
(velocidad = @Velocidad OR (velocidad IS NULL AND @Velocidad IS NULL));";
|
(velocidad = @Velocidad OR (velocidad IS NULL AND @Velocidad IS NULL));";
|
||||||
|
|
||||||
var queryInsert = @"INSERT INTO dbo.memorias_ram (part_number, fabricante, tamano, velocidad)
|
var queryInsert = @"INSERT INTO dbo.memorias_ram (part_number, fabricante, tamano, velocidad)
|
||||||
VALUES (@Part_number, @Fabricante, @Tamano, @Velocidad);
|
VALUES (@PartNumber, @Fabricante, @Tamano, @Velocidad);
|
||||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||||
|
|
||||||
var resultados = new List<object>();
|
var resultados = new List<object>();
|
||||||
@@ -70,15 +87,8 @@ namespace Inventario.API.Controllers
|
|||||||
if (existente == null)
|
if (existente == null)
|
||||||
{
|
{
|
||||||
var nuevoId = await connection.ExecuteScalarAsync<int>(queryInsert, memoria);
|
var nuevoId = await connection.ExecuteScalarAsync<int>(queryInsert, memoria);
|
||||||
var nuevaMemoria = new MemoriaRam
|
memoria.Id = nuevoId;
|
||||||
{
|
resultados.Add(new { action = "created", registro = memoria });
|
||||||
Id = nuevoId,
|
|
||||||
Part_number = memoria.Part_number,
|
|
||||||
Fabricante = memoria.Fabricante,
|
|
||||||
Tamano = memoria.Tamano,
|
|
||||||
Velocidad = memoria.Velocidad
|
|
||||||
};
|
|
||||||
resultados.Add(new { action = "created", registro = nuevaMemoria });
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -89,19 +99,18 @@ namespace Inventario.API.Controllers
|
|||||||
return Ok(resultados);
|
return Ok(resultados);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- PUT /api/memoriasram/{id} ---
|
|
||||||
[HttpPut("{id}")]
|
[HttpPut("{id}")]
|
||||||
public async Task<IActionResult> Actualizar(int id, [FromBody] MemoriaRam memoria)
|
public async Task<IActionResult> Actualizar(int id, [FromBody] MemoriaRam memoria)
|
||||||
{
|
{
|
||||||
var query = @"UPDATE dbo.memorias_ram SET
|
var query = @"UPDATE dbo.memorias_ram SET
|
||||||
part_number = @Part_number,
|
part_number = @PartNumber,
|
||||||
fabricante = @Fabricante,
|
fabricante = @Fabricante,
|
||||||
tamano = @Tamano,
|
tamano = @Tamano,
|
||||||
velocidad = @Velocidad
|
velocidad = @Velocidad
|
||||||
WHERE Id = @Id;";
|
WHERE Id = @Id;";
|
||||||
using (var connection = _context.CreateConnection())
|
using (var connection = _context.CreateConnection())
|
||||||
{
|
{
|
||||||
var filasAfectadas = await connection.ExecuteAsync(query, new { memoria.Part_number, memoria.Fabricante, memoria.Tamano, memoria.Velocidad, Id = id });
|
var filasAfectadas = await connection.ExecuteAsync(query, new { memoria.PartNumber, memoria.Fabricante, memoria.Tamano, memoria.Velocidad, Id = id });
|
||||||
if (filasAfectadas == 0)
|
if (filasAfectadas == 0)
|
||||||
{
|
{
|
||||||
return NotFound("Módulo de memoria RAM no encontrado.");
|
return NotFound("Módulo de memoria RAM no encontrado.");
|
||||||
@@ -112,7 +121,6 @@ namespace Inventario.API.Controllers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- DELETE /api/memoriasram/{id} ---
|
|
||||||
[HttpDelete("{id}")]
|
[HttpDelete("{id}")]
|
||||||
public async Task<IActionResult> Borrar(int id)
|
public async Task<IActionResult> Borrar(int id)
|
||||||
{
|
{
|
||||||
@@ -126,30 +134,38 @@ namespace Inventario.API.Controllers
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Primero eliminamos las asociaciones en la tabla intermedia
|
|
||||||
await connection.ExecuteAsync(deleteAssociationsQuery, new { Id = id }, transaction: transaction);
|
await connection.ExecuteAsync(deleteAssociationsQuery, new { Id = id }, transaction: transaction);
|
||||||
|
|
||||||
// Luego eliminamos el módulo de RAM
|
|
||||||
var filasAfectadas = await connection.ExecuteAsync(deleteRamQuery, new { Id = id }, transaction: transaction);
|
var filasAfectadas = await connection.ExecuteAsync(deleteRamQuery, new { Id = id }, transaction: transaction);
|
||||||
|
|
||||||
if (filasAfectadas == 0)
|
if (filasAfectadas == 0)
|
||||||
{
|
{
|
||||||
// Si no se borró nada, hacemos rollback y devolvemos NotFound.
|
|
||||||
transaction.Rollback();
|
transaction.Rollback();
|
||||||
return NotFound("Módulo de memoria RAM no encontrado.");
|
return NotFound("Módulo de memoria RAM no encontrado.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si todo salió bien, confirmamos la transacción.
|
|
||||||
transaction.Commit();
|
transaction.Commit();
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
transaction.Rollback();
|
transaction.Rollback();
|
||||||
throw; // Relanza la excepción para que sea manejada por el middleware de errores de ASP.NET Core
|
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,10 +1,14 @@
|
|||||||
|
// backend/Controllers/SectoresController.cs
|
||||||
|
|
||||||
using Dapper;
|
using Dapper;
|
||||||
using Inventario.API.Data;
|
using Inventario.API.Data;
|
||||||
using Inventario.API.Models;
|
using Inventario.API.Models;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
namespace Inventario.API.Controllers
|
namespace Inventario.API.Controllers
|
||||||
{
|
{
|
||||||
|
[Authorize]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class SectoresController : ControllerBase
|
public class SectoresController : ControllerBase
|
||||||
@@ -105,10 +109,21 @@ namespace Inventario.API.Controllers
|
|||||||
[HttpDelete("{id}")]
|
[HttpDelete("{id}")]
|
||||||
public async Task<IActionResult> BorrarSector(int id)
|
public async Task<IActionResult> BorrarSector(int id)
|
||||||
{
|
{
|
||||||
var query = "DELETE FROM dbo.sectores WHERE Id = @Id;";
|
|
||||||
using (var connection = _context.CreateConnection())
|
using (var connection = _context.CreateConnection())
|
||||||
{
|
{
|
||||||
var filasAfectadas = await connection.ExecuteAsync(query, new { Id = id });
|
// 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)
|
if (filasAfectadas == 0)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ using Dapper;
|
|||||||
using Inventario.API.Data;
|
using Inventario.API.Data;
|
||||||
using Inventario.API.Models;
|
using Inventario.API.Models;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
namespace Inventario.API.Controllers
|
namespace Inventario.API.Controllers
|
||||||
{
|
{
|
||||||
|
[Authorize]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class UsuariosController : ControllerBase
|
public class UsuariosController : ControllerBase
|
||||||
@@ -56,11 +58,16 @@ namespace Inventario.API.Controllers
|
|||||||
|
|
||||||
if (usuarioExistente != null)
|
if (usuarioExistente != null)
|
||||||
{
|
{
|
||||||
// El usuario ya existe, lo actualizamos (solo la contraseña si viene)
|
// El usuario ya existe.
|
||||||
var updateQuery = "UPDATE dbo.usuarios SET Password = @Password WHERE Id = @Id;";
|
// 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 });
|
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 actualizado
|
// Devolvemos el usuario que ya existe (con o sin la contraseña actualizada)
|
||||||
var usuarioActualizado = await connection.QuerySingleOrDefaultAsync<Usuario>(findQuery, new { usuario.Username });
|
var usuarioActualizado = await connection.QuerySingleOrDefaultAsync<Usuario>(findQuery, new { usuario.Username });
|
||||||
return Ok(usuarioActualizado);
|
return Ok(usuarioActualizado);
|
||||||
}
|
}
|
||||||
|
|||||||
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"]
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
|
// backend/Helpers/HistorialHelper.cs
|
||||||
using Dapper;
|
using Dapper;
|
||||||
using Inventario.API.Data;
|
using Inventario.API.Data;
|
||||||
using Inventario.API.Models;
|
|
||||||
|
|
||||||
namespace Inventario.API.Helpers
|
namespace Inventario.API.Helpers
|
||||||
{
|
{
|
||||||
public static class HistorialHelper
|
public static class HistorialHelper
|
||||||
{
|
{
|
||||||
public static async Task RegistrarCambios(DapperContext context, int equipoId, Dictionary<string, (string anterior, string nuevo)> cambios)
|
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)
|
var query = @"INSERT INTO dbo.historial_equipos (equipo_id, campo_modificado, valor_anterior, valor_nuevo)
|
||||||
VALUES (@EquipoId, @CampoModificado, @ValorAnterior, @ValorNuevo);";
|
VALUES (@EquipoId, @CampoModificado, @ValorAnterior, @ValorNuevo);";
|
||||||
@@ -25,5 +25,14 @@ namespace Inventario.API.Helpers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
<ItemGroup>
|
||||||
<PackageReference Include="Dapper" Version="2.1.66" />
|
<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.AspNetCore.OpenApi" Version="9.0.5" />
|
||||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.1" />
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.1" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.9">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.9">
|
||||||
|
|||||||
@@ -18,28 +18,26 @@ namespace Inventario.API.Models
|
|||||||
public int? Sector_id { get; set; }
|
public int? Sector_id { get; set; }
|
||||||
public string Origen { get; set; } = "automatica";
|
public string Origen { get; set; } = "automatica";
|
||||||
|
|
||||||
// Propiedades de navegación actualizadas
|
|
||||||
public Sector? Sector { get; set; }
|
public Sector? Sector { get; set; }
|
||||||
public List<UsuarioEquipoDetalle> Usuarios { get; set; } = new(); // Tipo actualizado
|
public List<UsuarioEquipoDetalle> Usuarios { get; set; } = new();
|
||||||
public List<DiscoDetalle> Discos { get; set; } = new(); // Tipo actualizado
|
public List<DiscoDetalle> Discos { get; set; } = new();
|
||||||
public List<MemoriaRamEquipoDetalle> MemoriasRam { get; set; } = new(); // Tipo actualizado
|
public List<MemoriaRamEquipoDetalle> MemoriasRam { get; set; } = new();
|
||||||
public List<HistorialEquipo> Historial { get; set; } = new();
|
public List<HistorialEquipo> Historial { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nuevo modelo para discos con su origen
|
|
||||||
public class DiscoDetalle : Disco
|
public class DiscoDetalle : Disco
|
||||||
{
|
{
|
||||||
public string Origen { get; set; } = "manual";
|
public string Origen { get; set; } = "manual";
|
||||||
|
public int EquipoDiscoId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nuevo modelo para memorias RAM con su origen y slot
|
|
||||||
public class MemoriaRamEquipoDetalle : MemoriaRam
|
public class MemoriaRamEquipoDetalle : MemoriaRam
|
||||||
{
|
{
|
||||||
public string Slot { get; set; } = string.Empty;
|
public string Slot { get; set; } = string.Empty;
|
||||||
public string Origen { get; set; } = "manual";
|
public string Origen { get; set; } = "manual";
|
||||||
|
public int EquipoMemoriaRamId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nuevo modelo para usuarios con su origen
|
|
||||||
public class UsuarioEquipoDetalle : Usuario
|
public class UsuarioEquipoDetalle : Usuario
|
||||||
{
|
{
|
||||||
public string Origen { get; set; } = "manual";
|
public string Origen { get; set; } = "manual";
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace Inventario.API.Models
|
|||||||
public class MemoriaRam
|
public class MemoriaRam
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string? Part_number { get; set; }
|
public string? PartNumber { get; set; }
|
||||||
public string? Fabricante { get; set; }
|
public string? Fabricante { get; set; }
|
||||||
public int Tamano { get; set; }
|
public int Tamano { get; set; }
|
||||||
public int? Velocidad { get; set; }
|
public int? Velocidad { get; set; }
|
||||||
|
|||||||
@@ -1,13 +1,67 @@
|
|||||||
|
// backend/Program.cs
|
||||||
using Inventario.API.Data;
|
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);
|
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.
|
// Add services to the container.
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen();
|
|
||||||
|
|
||||||
// --- 1. DEFINIR LA POLÍTICA CORS ---
|
// 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
|
// Definimos un nombre para nuestra política
|
||||||
var MyAllowSpecificOrigins = "_myAllowSpecificOrigins";
|
var MyAllowSpecificOrigins = "_myAllowSpecificOrigins";
|
||||||
|
|
||||||
@@ -42,11 +96,14 @@ if (app.Environment.IsDevelopment())
|
|||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
// --- 2. ACTIVAR EL MIDDLEWARE DE CORS ---
|
// --- ACTIVAR EL MIDDLEWARE DE CORS ---
|
||||||
// ¡IMPORTANTE! Debe ir ANTES de MapControllers y DESPUÉS de UseHttpsRedirection (si se usa)
|
// ¡IMPORTANTE! Debe ir ANTES de MapControllers y DESPUÉS de UseHttpsRedirection (si se usa)
|
||||||
app.UseCors(MyAllowSpecificOrigins);
|
app.UseCors(MyAllowSpecificOrigins);
|
||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
@@ -5,6 +5,18 @@
|
|||||||
"Microsoft.AspNetCore": "Warning"
|
"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": {
|
"SshSettings": {
|
||||||
"Host": "192.168.10.1",
|
"Host": "192.168.10.1",
|
||||||
"Port": 22110,
|
"Port": 22110,
|
||||||
|
|||||||
@@ -6,8 +6,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
|
"AuthSettings": {
|
||||||
|
"Username": "admin",
|
||||||
|
"Password": "PTP847Equipos"
|
||||||
|
},
|
||||||
|
"Jwt": {
|
||||||
|
"Key": "badb1a38d221c9e23bcf70958840ca7f5a5dc54f2047dadf7ce45b578b5bc3e2",
|
||||||
|
"Issuer": "InventarioAPI",
|
||||||
|
"Audience": "InventarioClient"
|
||||||
|
},
|
||||||
"ConnectionStrings": {
|
"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": {
|
"SshSettings": {
|
||||||
"Host": "192.168.10.1",
|
"Host": "192.168.10.1",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ using System.Reflection;
|
|||||||
[assembly: System.Reflection.AssemblyCompanyAttribute("Inventario.API")]
|
[assembly: System.Reflection.AssemblyCompanyAttribute("Inventario.API")]
|
||||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+99d98cc588b3922b6aa3ab9045fcee9cb31de1f3")]
|
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+acf2f9a35c8a559db55e21ce6dd2066c30a01669")]
|
||||||
[assembly: System.Reflection.AssemblyProductAttribute("Inventario.API")]
|
[assembly: System.Reflection.AssemblyProductAttribute("Inventario.API")]
|
||||||
[assembly: System.Reflection.AssemblyTitleAttribute("Inventario.API")]
|
[assembly: System.Reflection.AssemblyTitleAttribute("Inventario.API")]
|
||||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||||
|
|||||||
@@ -54,6 +54,10 @@
|
|||||||
"target": "Package",
|
"target": "Package",
|
||||||
"version": "[2.1.66, )"
|
"version": "[2.1.66, )"
|
||||||
},
|
},
|
||||||
|
"Microsoft.AspNetCore.Authentication.JwtBearer": {
|
||||||
|
"target": "Package",
|
||||||
|
"version": "[9.0.9, )"
|
||||||
|
},
|
||||||
"Microsoft.AspNetCore.OpenApi": {
|
"Microsoft.AspNetCore.OpenApi": {
|
||||||
"target": "Package",
|
"target": "Package",
|
||||||
"version": "[9.0.5, )"
|
"version": "[9.0.5, )"
|
||||||
|
|||||||
@@ -78,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": {
|
"Microsoft.AspNetCore.OpenApi/9.0.5": {
|
||||||
"type": "package",
|
"type": "package",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -897,96 +916,96 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.IdentityModel.Abstractions/7.7.1": {
|
"Microsoft.IdentityModel.Abstractions/8.0.1": {
|
||||||
"type": "package",
|
"type": "package",
|
||||||
"compile": {
|
"compile": {
|
||||||
"lib/net8.0/Microsoft.IdentityModel.Abstractions.dll": {
|
"lib/net9.0/Microsoft.IdentityModel.Abstractions.dll": {
|
||||||
"related": ".xml"
|
"related": ".xml"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"runtime": {
|
"runtime": {
|
||||||
"lib/net8.0/Microsoft.IdentityModel.Abstractions.dll": {
|
"lib/net9.0/Microsoft.IdentityModel.Abstractions.dll": {
|
||||||
"related": ".xml"
|
"related": ".xml"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.IdentityModel.JsonWebTokens/7.7.1": {
|
"Microsoft.IdentityModel.JsonWebTokens/8.0.1": {
|
||||||
"type": "package",
|
"type": "package",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.IdentityModel.Tokens": "7.7.1"
|
"Microsoft.IdentityModel.Tokens": "8.0.1"
|
||||||
},
|
},
|
||||||
"compile": {
|
"compile": {
|
||||||
"lib/net8.0/Microsoft.IdentityModel.JsonWebTokens.dll": {
|
"lib/net9.0/Microsoft.IdentityModel.JsonWebTokens.dll": {
|
||||||
"related": ".xml"
|
"related": ".xml"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"runtime": {
|
"runtime": {
|
||||||
"lib/net8.0/Microsoft.IdentityModel.JsonWebTokens.dll": {
|
"lib/net9.0/Microsoft.IdentityModel.JsonWebTokens.dll": {
|
||||||
"related": ".xml"
|
"related": ".xml"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.IdentityModel.Logging/7.7.1": {
|
"Microsoft.IdentityModel.Logging/8.0.1": {
|
||||||
"type": "package",
|
"type": "package",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.IdentityModel.Abstractions": "7.7.1"
|
"Microsoft.IdentityModel.Abstractions": "8.0.1"
|
||||||
},
|
},
|
||||||
"compile": {
|
"compile": {
|
||||||
"lib/net8.0/Microsoft.IdentityModel.Logging.dll": {
|
"lib/net9.0/Microsoft.IdentityModel.Logging.dll": {
|
||||||
"related": ".xml"
|
"related": ".xml"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"runtime": {
|
"runtime": {
|
||||||
"lib/net8.0/Microsoft.IdentityModel.Logging.dll": {
|
"lib/net9.0/Microsoft.IdentityModel.Logging.dll": {
|
||||||
"related": ".xml"
|
"related": ".xml"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.IdentityModel.Protocols/7.7.1": {
|
"Microsoft.IdentityModel.Protocols/8.0.1": {
|
||||||
"type": "package",
|
"type": "package",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.IdentityModel.Tokens": "7.7.1"
|
"Microsoft.IdentityModel.Tokens": "8.0.1"
|
||||||
},
|
},
|
||||||
"compile": {
|
"compile": {
|
||||||
"lib/net8.0/Microsoft.IdentityModel.Protocols.dll": {
|
"lib/net9.0/Microsoft.IdentityModel.Protocols.dll": {
|
||||||
"related": ".xml"
|
"related": ".xml"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"runtime": {
|
"runtime": {
|
||||||
"lib/net8.0/Microsoft.IdentityModel.Protocols.dll": {
|
"lib/net9.0/Microsoft.IdentityModel.Protocols.dll": {
|
||||||
"related": ".xml"
|
"related": ".xml"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.IdentityModel.Protocols.OpenIdConnect/7.7.1": {
|
"Microsoft.IdentityModel.Protocols.OpenIdConnect/8.0.1": {
|
||||||
"type": "package",
|
"type": "package",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.IdentityModel.Protocols": "7.7.1",
|
"Microsoft.IdentityModel.Protocols": "8.0.1",
|
||||||
"System.IdentityModel.Tokens.Jwt": "7.7.1"
|
"System.IdentityModel.Tokens.Jwt": "8.0.1"
|
||||||
},
|
},
|
||||||
"compile": {
|
"compile": {
|
||||||
"lib/net8.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll": {
|
"lib/net9.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll": {
|
||||||
"related": ".xml"
|
"related": ".xml"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"runtime": {
|
"runtime": {
|
||||||
"lib/net8.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll": {
|
"lib/net9.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll": {
|
||||||
"related": ".xml"
|
"related": ".xml"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.IdentityModel.Tokens/7.7.1": {
|
"Microsoft.IdentityModel.Tokens/8.0.1": {
|
||||||
"type": "package",
|
"type": "package",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.IdentityModel.Logging": "7.7.1"
|
"Microsoft.IdentityModel.Logging": "8.0.1"
|
||||||
},
|
},
|
||||||
"compile": {
|
"compile": {
|
||||||
"lib/net8.0/Microsoft.IdentityModel.Tokens.dll": {
|
"lib/net9.0/Microsoft.IdentityModel.Tokens.dll": {
|
||||||
"related": ".xml"
|
"related": ".xml"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"runtime": {
|
"runtime": {
|
||||||
"lib/net8.0/Microsoft.IdentityModel.Tokens.dll": {
|
"lib/net9.0/Microsoft.IdentityModel.Tokens.dll": {
|
||||||
"related": ".xml"
|
"related": ".xml"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1355,19 +1374,19 @@
|
|||||||
"buildTransitive/net8.0/_._": {}
|
"buildTransitive/net8.0/_._": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"System.IdentityModel.Tokens.Jwt/7.7.1": {
|
"System.IdentityModel.Tokens.Jwt/8.0.1": {
|
||||||
"type": "package",
|
"type": "package",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.IdentityModel.JsonWebTokens": "7.7.1",
|
"Microsoft.IdentityModel.JsonWebTokens": "8.0.1",
|
||||||
"Microsoft.IdentityModel.Tokens": "7.7.1"
|
"Microsoft.IdentityModel.Tokens": "8.0.1"
|
||||||
},
|
},
|
||||||
"compile": {
|
"compile": {
|
||||||
"lib/net8.0/System.IdentityModel.Tokens.Jwt.dll": {
|
"lib/net9.0/System.IdentityModel.Tokens.Jwt.dll": {
|
||||||
"related": ".xml"
|
"related": ".xml"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"runtime": {
|
"runtime": {
|
||||||
"lib/net8.0/System.IdentityModel.Tokens.Jwt.dll": {
|
"lib/net9.0/System.IdentityModel.Tokens.Jwt.dll": {
|
||||||
"related": ".xml"
|
"related": ".xml"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1618,6 +1637,22 @@
|
|||||||
"logo.png"
|
"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": {
|
"Microsoft.AspNetCore.OpenApi/9.0.5": {
|
||||||
"sha512": "yZLOciYlpaOO/mHPOpgeSZTv8Lc7fOOVX40eWJJoGs/S9Ny9CymDuKKQofGE9stXGGM9EEnnuPeq0fhR8kdFfg==",
|
"sha512": "yZLOciYlpaOO/mHPOpgeSZTv8Lc7fOOVX40eWJJoGs/S9Ny9CymDuKKQofGE9stXGGM9EEnnuPeq0fhR8kdFfg==",
|
||||||
"type": "package",
|
"type": "package",
|
||||||
@@ -3469,15 +3504,13 @@
|
|||||||
"microsoft.identity.client.extensions.msal.nuspec"
|
"microsoft.identity.client.extensions.msal.nuspec"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Microsoft.IdentityModel.Abstractions/7.7.1": {
|
"Microsoft.IdentityModel.Abstractions/8.0.1": {
|
||||||
"sha512": "S7sHg6gLg7oFqNGLwN1qSbJDI+QcRRj8SuJ1jHyCmKSipnF6ZQL+tFV2NzVfGj/xmGT9TykQdQiBN+p5Idl4TA==",
|
"sha512": "OtlIWcyX01olfdevPKZdIPfBEvbcioDyBiE/Z2lHsopsMD7twcKtlN9kMevHmI5IIPhFpfwCIiR6qHQz1WHUIw==",
|
||||||
"type": "package",
|
"type": "package",
|
||||||
"path": "microsoft.identitymodel.abstractions/7.7.1",
|
"path": "microsoft.identitymodel.abstractions/8.0.1",
|
||||||
"files": [
|
"files": [
|
||||||
".nupkg.metadata",
|
".nupkg.metadata",
|
||||||
".signature.p7s",
|
".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.dll",
|
||||||
"lib/net462/Microsoft.IdentityModel.Abstractions.xml",
|
"lib/net462/Microsoft.IdentityModel.Abstractions.xml",
|
||||||
"lib/net472/Microsoft.IdentityModel.Abstractions.dll",
|
"lib/net472/Microsoft.IdentityModel.Abstractions.dll",
|
||||||
@@ -3486,21 +3519,21 @@
|
|||||||
"lib/net6.0/Microsoft.IdentityModel.Abstractions.xml",
|
"lib/net6.0/Microsoft.IdentityModel.Abstractions.xml",
|
||||||
"lib/net8.0/Microsoft.IdentityModel.Abstractions.dll",
|
"lib/net8.0/Microsoft.IdentityModel.Abstractions.dll",
|
||||||
"lib/net8.0/Microsoft.IdentityModel.Abstractions.xml",
|
"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.dll",
|
||||||
"lib/netstandard2.0/Microsoft.IdentityModel.Abstractions.xml",
|
"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.abstractions.nuspec"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Microsoft.IdentityModel.JsonWebTokens/7.7.1": {
|
"Microsoft.IdentityModel.JsonWebTokens/8.0.1": {
|
||||||
"sha512": "3Izi75UCUssvo8LPx3OVnEeZay58qaFicrtSnbtUt7q8qQi0gy46gh4V8VUTkMVMKXV6VMyjBVmeNNgeCUJuIw==",
|
"sha512": "s6++gF9x0rQApQzOBbSyp4jUaAlwm+DroKfL8gdOHxs83k8SJfUXhuc46rDB3rNXBQ1MVRxqKUrqFhO/M0E97g==",
|
||||||
"type": "package",
|
"type": "package",
|
||||||
"path": "microsoft.identitymodel.jsonwebtokens/7.7.1",
|
"path": "microsoft.identitymodel.jsonwebtokens/8.0.1",
|
||||||
"files": [
|
"files": [
|
||||||
".nupkg.metadata",
|
".nupkg.metadata",
|
||||||
".signature.p7s",
|
".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.dll",
|
||||||
"lib/net462/Microsoft.IdentityModel.JsonWebTokens.xml",
|
"lib/net462/Microsoft.IdentityModel.JsonWebTokens.xml",
|
||||||
"lib/net472/Microsoft.IdentityModel.JsonWebTokens.dll",
|
"lib/net472/Microsoft.IdentityModel.JsonWebTokens.dll",
|
||||||
@@ -3509,21 +3542,21 @@
|
|||||||
"lib/net6.0/Microsoft.IdentityModel.JsonWebTokens.xml",
|
"lib/net6.0/Microsoft.IdentityModel.JsonWebTokens.xml",
|
||||||
"lib/net8.0/Microsoft.IdentityModel.JsonWebTokens.dll",
|
"lib/net8.0/Microsoft.IdentityModel.JsonWebTokens.dll",
|
||||||
"lib/net8.0/Microsoft.IdentityModel.JsonWebTokens.xml",
|
"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.dll",
|
||||||
"lib/netstandard2.0/Microsoft.IdentityModel.JsonWebTokens.xml",
|
"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.jsonwebtokens.nuspec"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Microsoft.IdentityModel.Logging/7.7.1": {
|
"Microsoft.IdentityModel.Logging/8.0.1": {
|
||||||
"sha512": "BZNgSq/o8gsKExdYoBKPR65fdsxW0cTF8PsdqB8y011AGUJJW300S/ZIsEUD0+sOmGc003Gwv3FYbjrVjvsLNQ==",
|
"sha512": "UCPF2exZqBXe7v/6sGNiM6zCQOUXXQ9+v5VTb9gPB8ZSUPnX53BxlN78v2jsbIvK9Dq4GovQxo23x8JgWvm/Qg==",
|
||||||
"type": "package",
|
"type": "package",
|
||||||
"path": "microsoft.identitymodel.logging/7.7.1",
|
"path": "microsoft.identitymodel.logging/8.0.1",
|
||||||
"files": [
|
"files": [
|
||||||
".nupkg.metadata",
|
".nupkg.metadata",
|
||||||
".signature.p7s",
|
".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.dll",
|
||||||
"lib/net462/Microsoft.IdentityModel.Logging.xml",
|
"lib/net462/Microsoft.IdentityModel.Logging.xml",
|
||||||
"lib/net472/Microsoft.IdentityModel.Logging.dll",
|
"lib/net472/Microsoft.IdentityModel.Logging.dll",
|
||||||
@@ -3532,21 +3565,21 @@
|
|||||||
"lib/net6.0/Microsoft.IdentityModel.Logging.xml",
|
"lib/net6.0/Microsoft.IdentityModel.Logging.xml",
|
||||||
"lib/net8.0/Microsoft.IdentityModel.Logging.dll",
|
"lib/net8.0/Microsoft.IdentityModel.Logging.dll",
|
||||||
"lib/net8.0/Microsoft.IdentityModel.Logging.xml",
|
"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.dll",
|
||||||
"lib/netstandard2.0/Microsoft.IdentityModel.Logging.xml",
|
"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.logging.nuspec"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Microsoft.IdentityModel.Protocols/7.7.1": {
|
"Microsoft.IdentityModel.Protocols/8.0.1": {
|
||||||
"sha512": "h+fHHBGokepmCX+QZXJk4Ij8OApCb2n2ktoDkNX5CXteXsOxTHMNgjPGpAwdJMFvAL7TtGarUnk3o97NmBq2QQ==",
|
"sha512": "uA2vpKqU3I2mBBEaeJAWPTjT9v1TZrGWKdgK6G5qJd03CLx83kdiqO9cmiK8/n1erkHzFBwU/RphP83aAe3i3g==",
|
||||||
"type": "package",
|
"type": "package",
|
||||||
"path": "microsoft.identitymodel.protocols/7.7.1",
|
"path": "microsoft.identitymodel.protocols/8.0.1",
|
||||||
"files": [
|
"files": [
|
||||||
".nupkg.metadata",
|
".nupkg.metadata",
|
||||||
".signature.p7s",
|
".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.dll",
|
||||||
"lib/net462/Microsoft.IdentityModel.Protocols.xml",
|
"lib/net462/Microsoft.IdentityModel.Protocols.xml",
|
||||||
"lib/net472/Microsoft.IdentityModel.Protocols.dll",
|
"lib/net472/Microsoft.IdentityModel.Protocols.dll",
|
||||||
@@ -3555,21 +3588,21 @@
|
|||||||
"lib/net6.0/Microsoft.IdentityModel.Protocols.xml",
|
"lib/net6.0/Microsoft.IdentityModel.Protocols.xml",
|
||||||
"lib/net8.0/Microsoft.IdentityModel.Protocols.dll",
|
"lib/net8.0/Microsoft.IdentityModel.Protocols.dll",
|
||||||
"lib/net8.0/Microsoft.IdentityModel.Protocols.xml",
|
"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.dll",
|
||||||
"lib/netstandard2.0/Microsoft.IdentityModel.Protocols.xml",
|
"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.nuspec"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Microsoft.IdentityModel.Protocols.OpenIdConnect/7.7.1": {
|
"Microsoft.IdentityModel.Protocols.OpenIdConnect/8.0.1": {
|
||||||
"sha512": "yT2Hdj8LpPbcT9C9KlLVxXl09C8zjFaVSaApdOwuecMuoV4s6Sof/mnTDz/+F/lILPIBvrWugR9CC7iRVZgbfQ==",
|
"sha512": "AQDbfpL+yzuuGhO/mQhKNsp44pm5Jv8/BI4KiFXR7beVGZoSH35zMV3PrmcfvSTsyI6qrcR898NzUauD6SRigg==",
|
||||||
"type": "package",
|
"type": "package",
|
||||||
"path": "microsoft.identitymodel.protocols.openidconnect/7.7.1",
|
"path": "microsoft.identitymodel.protocols.openidconnect/8.0.1",
|
||||||
"files": [
|
"files": [
|
||||||
".nupkg.metadata",
|
".nupkg.metadata",
|
||||||
".signature.p7s",
|
".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.dll",
|
||||||
"lib/net462/Microsoft.IdentityModel.Protocols.OpenIdConnect.xml",
|
"lib/net462/Microsoft.IdentityModel.Protocols.OpenIdConnect.xml",
|
||||||
"lib/net472/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll",
|
"lib/net472/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll",
|
||||||
@@ -3578,21 +3611,21 @@
|
|||||||
"lib/net6.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.xml",
|
"lib/net6.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.xml",
|
||||||
"lib/net8.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll",
|
"lib/net8.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll",
|
||||||
"lib/net8.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.xml",
|
"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.dll",
|
||||||
"lib/netstandard2.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.xml",
|
"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.protocols.openidconnect.nuspec"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Microsoft.IdentityModel.Tokens/7.7.1": {
|
"Microsoft.IdentityModel.Tokens/8.0.1": {
|
||||||
"sha512": "fQ0VVCba75lknUHGldi3iTKAYUQqbzp1Un8+d9cm9nON0Gs8NAkXddNg8iaUB0qi/ybtAmNWizTR4avdkCJ9pQ==",
|
"sha512": "kDimB6Dkd3nkW2oZPDkMkVHfQt3IDqO5gL0oa8WVy3OP4uE8Ij+8TXnqg9TOd9ufjsY3IDiGz7pCUbnfL18tjg==",
|
||||||
"type": "package",
|
"type": "package",
|
||||||
"path": "microsoft.identitymodel.tokens/7.7.1",
|
"path": "microsoft.identitymodel.tokens/8.0.1",
|
||||||
"files": [
|
"files": [
|
||||||
".nupkg.metadata",
|
".nupkg.metadata",
|
||||||
".signature.p7s",
|
".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.dll",
|
||||||
"lib/net462/Microsoft.IdentityModel.Tokens.xml",
|
"lib/net462/Microsoft.IdentityModel.Tokens.xml",
|
||||||
"lib/net472/Microsoft.IdentityModel.Tokens.dll",
|
"lib/net472/Microsoft.IdentityModel.Tokens.dll",
|
||||||
@@ -3601,9 +3634,11 @@
|
|||||||
"lib/net6.0/Microsoft.IdentityModel.Tokens.xml",
|
"lib/net6.0/Microsoft.IdentityModel.Tokens.xml",
|
||||||
"lib/net8.0/Microsoft.IdentityModel.Tokens.dll",
|
"lib/net8.0/Microsoft.IdentityModel.Tokens.dll",
|
||||||
"lib/net8.0/Microsoft.IdentityModel.Tokens.xml",
|
"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.dll",
|
||||||
"lib/netstandard2.0/Microsoft.IdentityModel.Tokens.xml",
|
"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"
|
"microsoft.identitymodel.tokens.nuspec"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -4090,15 +4125,13 @@
|
|||||||
"useSharedDesignerContext.txt"
|
"useSharedDesignerContext.txt"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"System.IdentityModel.Tokens.Jwt/7.7.1": {
|
"System.IdentityModel.Tokens.Jwt/8.0.1": {
|
||||||
"sha512": "rQkO1YbAjLwnDJSMpRhRtrc6XwIcEOcUvoEcge+evurpzSZM3UNK+MZfD3sKyTlYsvknZ6eJjSBfnmXqwOsT9Q==",
|
"sha512": "GJw3bYkWpOgvN3tJo5X4lYUeIFA2HD293FPUhKmp7qxS+g5ywAb34Dnd3cDAFLkcMohy5XTpoaZ4uAHuw0uSPQ==",
|
||||||
"type": "package",
|
"type": "package",
|
||||||
"path": "system.identitymodel.tokens.jwt/7.7.1",
|
"path": "system.identitymodel.tokens.jwt/8.0.1",
|
||||||
"files": [
|
"files": [
|
||||||
".nupkg.metadata",
|
".nupkg.metadata",
|
||||||
".signature.p7s",
|
".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.dll",
|
||||||
"lib/net462/System.IdentityModel.Tokens.Jwt.xml",
|
"lib/net462/System.IdentityModel.Tokens.Jwt.xml",
|
||||||
"lib/net472/System.IdentityModel.Tokens.Jwt.dll",
|
"lib/net472/System.IdentityModel.Tokens.Jwt.dll",
|
||||||
@@ -4107,9 +4140,11 @@
|
|||||||
"lib/net6.0/System.IdentityModel.Tokens.Jwt.xml",
|
"lib/net6.0/System.IdentityModel.Tokens.Jwt.xml",
|
||||||
"lib/net8.0/System.IdentityModel.Tokens.Jwt.dll",
|
"lib/net8.0/System.IdentityModel.Tokens.Jwt.dll",
|
||||||
"lib/net8.0/System.IdentityModel.Tokens.Jwt.xml",
|
"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.dll",
|
||||||
"lib/netstandard2.0/System.IdentityModel.Tokens.Jwt.xml",
|
"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"
|
"system.identitymodel.tokens.jwt.nuspec"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -4417,6 +4452,7 @@
|
|||||||
"projectFileDependencyGroups": {
|
"projectFileDependencyGroups": {
|
||||||
"net9.0": [
|
"net9.0": [
|
||||||
"Dapper >= 2.1.66",
|
"Dapper >= 2.1.66",
|
||||||
|
"Microsoft.AspNetCore.Authentication.JwtBearer >= 9.0.9",
|
||||||
"Microsoft.AspNetCore.OpenApi >= 9.0.5",
|
"Microsoft.AspNetCore.OpenApi >= 9.0.5",
|
||||||
"Microsoft.Data.SqlClient >= 6.1.1",
|
"Microsoft.Data.SqlClient >= 6.1.1",
|
||||||
"Microsoft.EntityFrameworkCore.Design >= 9.0.9",
|
"Microsoft.EntityFrameworkCore.Design >= 9.0.9",
|
||||||
@@ -4479,6 +4515,10 @@
|
|||||||
"target": "Package",
|
"target": "Package",
|
||||||
"version": "[2.1.66, )"
|
"version": "[2.1.66, )"
|
||||||
},
|
},
|
||||||
|
"Microsoft.AspNetCore.Authentication.JwtBearer": {
|
||||||
|
"target": "Package",
|
||||||
|
"version": "[9.0.9, )"
|
||||||
|
},
|
||||||
"Microsoft.AspNetCore.OpenApi": {
|
"Microsoft.AspNetCore.OpenApi": {
|
||||||
"target": "Package",
|
"target": "Package",
|
||||||
"version": "[9.0.5, )"
|
"version": "[9.0.5, )"
|
||||||
|
|||||||
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
|
||||||
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;"]
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
40
frontend/package-lock.json
generated
40
frontend/package-lock.json
generated
@@ -9,7 +9,10 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"chart.js": "^4.5.0",
|
||||||
|
"lucide-react": "^0.545.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
|
"react-chartjs-2": "^5.3.0",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-tooltip": "^5.29.1"
|
"react-tooltip": "^5.29.1"
|
||||||
@@ -1034,6 +1037,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@kurkle/color": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@nodelib/fs.scandir": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@@ -1987,6 +1996,18 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chart.js": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@kurkle/color": "^0.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"pnpm": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/classnames": {
|
"node_modules/classnames": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||||
@@ -2726,6 +2747,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "0.545.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.545.0.tgz",
|
||||||
|
"integrity": "sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/merge2": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
@@ -2985,6 +3015,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-chartjs-2": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"chart.js": "^4.1.1",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.2.0",
|
"version": "19.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||||
|
|||||||
@@ -11,7 +11,10 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"chart.js": "^4.5.0",
|
||||||
|
"lucide-react": "^0.545.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
|
"react-chartjs-2": "^5.3.0",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-tooltip": "^5.29.1"
|
"react-tooltip": "^5.29.1"
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
@@ -4,14 +4,14 @@ main {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Estilos para la nueva Barra de Navegación */
|
|
||||||
.navbar {
|
.navbar {
|
||||||
background-color: #343a40; /* Un color oscuro para el fondo */
|
background-color: var(--color-navbar-bg);
|
||||||
padding: 0 2rem;
|
padding: 0 2rem;
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--color-border); /* Borde sutil */
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-links {
|
.nav-links {
|
||||||
@@ -21,27 +21,27 @@ main {
|
|||||||
.nav-link {
|
.nav-link {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: #adb5bd; /* Color de texto gris claro */
|
color: var(--color-navbar-text);
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: color 0.2s ease-in-out, background-color 0.2s ease-in-out;
|
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; /* Borde inferior para el indicador activo */
|
border-bottom: 3px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link:hover {
|
.nav-link:hover {
|
||||||
color: #ffffff; /* Texto blanco al pasar el ratón */
|
color: var(--color-navbar-text-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link-active {
|
.nav-link-active {
|
||||||
color: #ffffff;
|
color: var(--color-navbar-text-hover);
|
||||||
border-bottom: 3px solid #007bff; /* Indicador azul para la vista activa */
|
border-bottom: 3px solid var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-title {
|
.app-title {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
color: #ffffff;
|
color: var(--color-navbar-text-hover);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
@@ -2,13 +2,26 @@ import { useState } from 'react';
|
|||||||
import SimpleTable from "./components/SimpleTable";
|
import SimpleTable from "./components/SimpleTable";
|
||||||
import GestionSectores from "./components/GestionSectores";
|
import GestionSectores from "./components/GestionSectores";
|
||||||
import GestionComponentes from './components/GestionComponentes';
|
import GestionComponentes from './components/GestionComponentes';
|
||||||
|
import Dashboard from './components/Dashboard';
|
||||||
import Navbar from './components/Navbar';
|
import Navbar from './components/Navbar';
|
||||||
|
import { useAuth } from './context/AuthContext';
|
||||||
|
import Login from './components/Login';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
export type View = 'equipos' | 'sectores' | 'admin';
|
export type View = 'equipos' | 'sectores' | 'admin' | 'dashboard';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [currentView, setCurrentView] = useState<View>('equipos');
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -18,6 +31,7 @@ function App() {
|
|||||||
{currentView === 'equipos' && <SimpleTable />}
|
{currentView === 'equipos' && <SimpleTable />}
|
||||||
{currentView === 'sectores' && <GestionSectores />}
|
{currentView === 'sectores' && <GestionSectores />}
|
||||||
{currentView === 'admin' && <GestionComponentes />}
|
{currentView === 'admin' && <GestionComponentes />}
|
||||||
|
{currentView === 'dashboard' && <Dashboard />}
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,46 +1,55 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
interface AutocompleteInputProps {
|
// --- Interfaces de Props más robustas usando una unión discriminada ---
|
||||||
|
type AutocompleteInputProps = {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
name: string;
|
name: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
// CAMBIO: La función ahora recibe el término de búsqueda
|
|
||||||
fetchSuggestions: (query: string) => Promise<string[]>;
|
|
||||||
className?: 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> = ({
|
const AutocompleteInput: React.FC<AutocompleteInputProps> = (props) => {
|
||||||
value,
|
const { value, onChange, name, placeholder, className } = props;
|
||||||
onChange,
|
|
||||||
name,
|
|
||||||
placeholder,
|
|
||||||
fetchSuggestions,
|
|
||||||
className
|
|
||||||
}) => {
|
|
||||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||||
const dataListId = `suggestions-for-${name}`;
|
const dataListId = `suggestions-for-${name}`;
|
||||||
|
|
||||||
// CAMBIO: Lógica de "debouncing" para buscar mientras se escribe
|
// --- Lógica para el modo ESTÁTICO ---
|
||||||
|
// Se ejecuta UNA SOLA VEZ cuando el componente se monta
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// No buscar si el input está vacío o es muy corto
|
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) {
|
if (value.length < 2) {
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configura un temporizador para esperar 300ms después de la última pulsación
|
|
||||||
const handler = setTimeout(() => {
|
const handler = setTimeout(() => {
|
||||||
fetchSuggestions(value)
|
props.fetchSuggestions(value)
|
||||||
.then(setSuggestions)
|
.then(setSuggestions)
|
||||||
.catch(err => console.error(`Error fetching suggestions for ${name}:`, err));
|
.catch(err => console.error(`Error fetching dynamic suggestions for ${name}:`, err));
|
||||||
}, 300);
|
}, 300);
|
||||||
|
return () => clearTimeout(handler);
|
||||||
// Limpia el temporizador si el usuario sigue escribiendo
|
}
|
||||||
return () => {
|
}, [value, props.mode, props.fetchSuggestions, name]);
|
||||||
clearTimeout(handler);
|
|
||||||
};
|
|
||||||
}, [value, fetchSuggestions, name]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -52,7 +61,7 @@ const AutocompleteInput: React.FC<AutocompleteInputProps> = ({
|
|||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className={className}
|
className={className}
|
||||||
list={dataListId}
|
list={dataListId}
|
||||||
autoComplete="off" // Importante para que no interfiera el autocompletado del navegador
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
<datalist id={dataListId}>
|
<datalist id={dataListId}>
|
||||||
{suggestions.map((suggestion, index) => (
|
{suggestions.map((suggestion, index) => (
|
||||||
|
|||||||
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;
|
||||||
@@ -1,38 +1,46 @@
|
|||||||
import { useState, useEffect } from 'react';
|
// frontend/src/components/GestionComponentes.tsx
|
||||||
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import toast from 'react-hot-toast';
|
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 styles from './SimpleTable.module.css';
|
||||||
|
import { adminService } from '../services/apiService';
|
||||||
|
import TableSkeleton from './TableSkeleton';
|
||||||
|
import { accentInsensitiveFilter } from '../utils/filtering';
|
||||||
|
|
||||||
const BASE_URL = 'http://localhost:5198/api';
|
// Interfaces
|
||||||
|
|
||||||
// Interfaces para los diferentes tipos de datos
|
|
||||||
interface TextValue {
|
interface TextValue {
|
||||||
valor: string;
|
valor: string;
|
||||||
conteo: number;
|
conteo: number;
|
||||||
}
|
}
|
||||||
interface RamValue {
|
interface RamValue {
|
||||||
id: number;
|
|
||||||
fabricante?: string;
|
fabricante?: string;
|
||||||
tamano: number;
|
tamano: number;
|
||||||
velocidad?: number;
|
velocidad?: number;
|
||||||
partNumber?: string;
|
|
||||||
conteo: number;
|
conteo: number;
|
||||||
}
|
}
|
||||||
|
type ComponentValue = TextValue | RamValue;
|
||||||
|
|
||||||
const GestionComponentes = () => {
|
const GestionComponentes = () => {
|
||||||
const [componentType, setComponentType] = useState('os');
|
const [componentType, setComponentType] = useState('os');
|
||||||
const [valores, setValores] = useState<(TextValue | RamValue)[]>([]); // Estado que acepta ambos tipos
|
const [valores, setValores] = useState<ComponentValue[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [valorAntiguo, setValorAntiguo] = useState('');
|
const [valorAntiguo, setValorAntiguo] = useState('');
|
||||||
const [valorNuevo, setValorNuevo] = useState('');
|
const [valorNuevo, setValorNuevo] = useState('');
|
||||||
|
const [globalFilter, setGlobalFilter] = useState('');
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const endpoint = componentType === 'ram' ? `${BASE_URL}/admin/componentes/ram` : `${BASE_URL}/admin/componentes/${componentType}`;
|
adminService.getComponentValues(componentType)
|
||||||
|
|
||||||
fetch(endpoint)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
setValores(data);
|
setValores(data);
|
||||||
})
|
})
|
||||||
@@ -42,30 +50,18 @@ const GestionComponentes = () => {
|
|||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
}, [componentType]);
|
}, [componentType]);
|
||||||
|
|
||||||
const handleOpenModal = (valor: string) => {
|
const handleOpenModal = useCallback((valor: string) => {
|
||||||
setValorAntiguo(valor);
|
setValorAntiguo(valor);
|
||||||
setValorNuevo(valor);
|
setValorNuevo(valor);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleUnificar = async () => {
|
const handleUnificar = async () => {
|
||||||
const toastId = toast.loading('Unificando valores...');
|
const toastId = toast.loading('Unificando valores...');
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${BASE_URL}/admin/componentes/${componentType}/unificar`, {
|
await adminService.unifyComponentValues(componentType, valorAntiguo, valorNuevo);
|
||||||
method: 'PUT',
|
const refreshedData = await adminService.getComponentValues(componentType);
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ valorAntiguo, valorNuevo }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.message || 'La unificación falló.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refrescar la lista para ver el resultado
|
|
||||||
const refreshedData = await (await fetch(`${BASE_URL}/admin/componentes/${componentType}`)).json();
|
|
||||||
setValores(refreshedData);
|
setValores(refreshedData);
|
||||||
|
|
||||||
toast.success('Valores unificados correctamente.', { id: toastId });
|
toast.success('Valores unificados correctamente.', { id: toastId });
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -73,66 +69,119 @@ const GestionComponentes = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteRam = async (ramId: number) => {
|
const handleDeleteRam = useCallback(async (ramGroup: RamValue) => {
|
||||||
if (!window.confirm("¿Estás seguro de eliminar este módulo de RAM de la base de datos maestra? Esta acción es irreversible.")) {
|
if (!window.confirm("¿Estás seguro de eliminar todas las entradas maestras para este tipo de RAM? Esta acción es irreversible.")) return;
|
||||||
return;
|
const toastId = toast.loading('Eliminando grupo de módulos...');
|
||||||
}
|
|
||||||
|
|
||||||
const toastId = toast.loading('Eliminando módulo...');
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${BASE_URL}/admin/componentes/ram/${ramId}`, { method: 'DELETE' });
|
await adminService.deleteRamComponent({ fabricante: ramGroup.fabricante, tamano: ramGroup.tamano, velocidad: ramGroup.velocidad });
|
||||||
|
setValores(prev => prev.filter(v => {
|
||||||
if (!response.ok) {
|
const currentRam = v as RamValue;
|
||||||
const error = await response.json();
|
return !(currentRam.fabricante === ramGroup.fabricante && currentRam.tamano === ramGroup.tamano && currentRam.velocidad === ramGroup.velocidad);
|
||||||
throw new Error(error.message || 'No se pudo eliminar.');
|
}));
|
||||||
}
|
toast.success("Grupo de módulos de RAM eliminado.", { id: toastId });
|
||||||
|
|
||||||
setValores(prev => prev.filter(v => (v as RamValue).id !== ramId));
|
|
||||||
toast.success("Módulo de RAM eliminado.", { id: toastId });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleDeleteTexto = async (valor: string) => {
|
|
||||||
if (!window.confirm(`Este valor ya no está en uso. ¿Quieres intentar eliminarlo de la base de datos maestra? (Si no existe una tabla maestra, esta acción solo confirmará que no hay usos)`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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...');
|
const toastId = toast.loading('Eliminando valor...');
|
||||||
try {
|
try {
|
||||||
// La API necesita el valor codificado para manejar caracteres especiales como '/'
|
await adminService.deleteTextComponent(componentType, valor);
|
||||||
const encodedValue = encodeURIComponent(valor);
|
|
||||||
const response = await fetch(`${BASE_URL}/admin/componentes/${componentType}/${encodedValue}`, { method: 'DELETE' });
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.message || 'No se pudo eliminar.');
|
|
||||||
}
|
|
||||||
|
|
||||||
setValores(prev => prev.filter(v => (v as TextValue).valor !== valor));
|
setValores(prev => prev.filter(v => (v as TextValue).valor !== valor));
|
||||||
toast.success("Valor eliminado/confirmado como no existente.", { id: toastId });
|
toast.success("Valor eliminado.", { id: toastId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
||||||
}
|
}
|
||||||
};
|
}, [componentType]);
|
||||||
|
|
||||||
const renderValor = (item: TextValue | RamValue) => {
|
const renderValor = useCallback((item: ComponentValue) => {
|
||||||
if (componentType === 'ram') {
|
if (componentType === 'ram') {
|
||||||
const ram = item as RamValue;
|
const ram = item as RamValue;
|
||||||
return `${ram.fabricante || ''} ${ram.tamano}GB ${ram.velocidad ? ram.velocidad + 'MHz' : ''} (${ram.partNumber || 'N/P'})`;
|
return `${ram.fabricante || 'Desconocido'} ${ram.tamano}GB ${ram.velocidad ? ram.velocidad + 'MHz' : ''}`;
|
||||||
}
|
}
|
||||||
return (item as TextValue).valor;
|
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 (
|
return (
|
||||||
<div>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||||
<h2>Gestión de Componentes Maestros</h2>
|
<h2>Gestión de Componentes Maestros ({table.getFilteredRowModel().rows.length})</h2>
|
||||||
<p>Unifica valores inconsistentes y elimina registros no utilizados.</p>
|
<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 style={{ marginBottom: '1.5rem' }}>
|
<div className={styles.controlsContainer}>
|
||||||
<label><strong>Selecciona un tipo de componente:</strong></label>
|
<input
|
||||||
<select value={componentType} onChange={e => setComponentType(e.target.value)} className={styles.sectorSelect} style={{marginLeft: '10px'}}>
|
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="os">Sistema Operativo</option>
|
||||||
<option value="cpu">CPU</option>
|
<option value="cpu">CPU</option>
|
||||||
<option value="motherboard">Motherboard</option>
|
<option value="motherboard">Motherboard</option>
|
||||||
@@ -142,57 +191,58 @@ const GestionComponentes = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p>Cargando...</p>
|
<div className={styles.tableContainer}>
|
||||||
|
<TableSkeleton rows={6} columns={3} />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<div className={styles.tableContainer}>
|
||||||
<table className={styles.table}>
|
<table className={styles.table}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
{table.getHeaderGroups().map(headerGroup => (
|
||||||
<th className={styles.th}>Valor Registrado</th>
|
<tr key={headerGroup.id}>
|
||||||
<th className={styles.th} style={{width: '150px'}}>Nº de Equipos</th>
|
{headerGroup.headers.map(header => {
|
||||||
<th className={styles.th} style={{width: '200px'}}>Acciones</th>
|
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>
|
</tr>
|
||||||
|
))}
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{valores.map((item) => (
|
{table.getRowModel().rows.map(row => (
|
||||||
<tr key={componentType === 'ram' ? (item as RamValue).id : (item as TextValue).valor} className={styles.tr}>
|
<tr key={row.id} className={styles.tr}>
|
||||||
<td className={styles.td}>{renderValor(item)}</td>
|
{row.getVisibleCells().map(cell => {
|
||||||
<td className={styles.td}>{item.conteo}</td>
|
const classNames = [styles.td];
|
||||||
<td className={styles.td}>
|
if (cell.column.id === 'conteo') classNames.push(styles.tdNumeric);
|
||||||
<div style={{display: 'flex', gap: '5px'}}>
|
if (cell.column.id === 'acciones') classNames.push(styles.tdActions);
|
||||||
{componentType === 'ram' ? (
|
return (
|
||||||
// Lógica solo para RAM (no tiene sentido "unificar" un objeto complejo)
|
<td
|
||||||
<button
|
key={cell.id}
|
||||||
onClick={() => handleDeleteRam((item as RamValue).id)}
|
className={classNames.join(' ')}
|
||||||
className={styles.deleteUserButton}
|
|
||||||
style={{fontSize: '0.9em', border: '1px solid', borderRadius: '4px', padding: '4px 8px', cursor: item.conteo > 0 ? 'not-allowed' : 'pointer', opacity: item.conteo > 0 ? 0.5 : 1}}
|
|
||||||
disabled={item.conteo > 0}
|
|
||||||
title={item.conteo > 0 ? `En uso por ${item.conteo} equipo(s)` : 'Eliminar este módulo maestro'}
|
|
||||||
>
|
>
|
||||||
🗑️ Eliminar
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
// Lógica para todos los demás tipos de componentes (texto)
|
|
||||||
<>
|
|
||||||
<button onClick={() => handleOpenModal((item as TextValue).valor)} className={styles.tableButton}>
|
|
||||||
✏️ Unificar
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteTexto((item as TextValue).valor)}
|
|
||||||
className={styles.deleteUserButton}
|
|
||||||
style={{fontSize: '0.9em', border: '1px solid', borderRadius: '4px', padding: '4px 8px', cursor: item.conteo > 0 ? 'not-allowed' : 'pointer', opacity: item.conteo > 0 ? 0.5 : 1}}
|
|
||||||
disabled={item.conteo > 0}
|
|
||||||
title={item.conteo > 0 ? `En uso por ${item.conteo} equipo(s). Unifique primero.` : 'Eliminar este valor'}
|
|
||||||
>
|
|
||||||
🗑️ Eliminar
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
@@ -200,7 +250,8 @@ const GestionComponentes = () => {
|
|||||||
<div className={styles.modal}>
|
<div className={styles.modal}>
|
||||||
<h3>Unificar Valor</h3>
|
<h3>Unificar Valor</h3>
|
||||||
<p>Se reemplazarán todas las instancias de:</p>
|
<p>Se reemplazarán todas las instancias de:</p>
|
||||||
<strong style={{ display: 'block', marginBottom: '1rem', background: '#e9ecef', padding: '8px', borderRadius: '4px' }}>{valorAntiguo}</strong>
|
<strong className={styles.highlightBox}>{valorAntiguo}</strong>
|
||||||
|
|
||||||
<label>Por el nuevo valor:</label>
|
<label>Por el nuevo valor:</label>
|
||||||
<input type="text" value={valorNuevo} onChange={e => setValorNuevo(e.target.value)} className={styles.modalInput} />
|
<input type="text" value={valorNuevo} onChange={e => setValorNuevo(e.target.value)} className={styles.modalInput} />
|
||||||
<div className={styles.modalActions}>
|
<div className={styles.modalActions}>
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
import { useState, useEffect } from 'react';
|
// frontend/src/components/GestionSectores.tsx
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import toast from 'react-hot-toast';
|
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 type { Sector } from '../types/interfaces';
|
||||||
import styles from './SimpleTable.module.css';
|
import styles from './SimpleTable.module.css';
|
||||||
import ModalSector from './ModalSector';
|
import ModalSector from './ModalSector';
|
||||||
|
import TableSkeleton from './TableSkeleton'; // <-- 1. Importar el esqueleto
|
||||||
const BASE_URL = 'http://localhost:5198/api';
|
import { sectorService } from '../services/apiService';
|
||||||
|
|
||||||
const GestionSectores = () => {
|
const GestionSectores = () => {
|
||||||
const [sectores, setSectores] = useState<Sector[]>([]);
|
const [sectores, setSectores] = useState<Sector[]>([]);
|
||||||
@@ -12,121 +26,183 @@ const GestionSectores = () => {
|
|||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [editingSector, setEditingSector] = useState<Sector | null>(null);
|
const [editingSector, setEditingSector] = useState<Sector | null>(null);
|
||||||
|
|
||||||
|
// --- 2. Estados para filtro y ordenación ---
|
||||||
|
const [globalFilter, setGlobalFilter] = useState('');
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${BASE_URL}/sectores`)
|
setIsLoading(true); // Aseguramos que se muestre el esqueleto al cargar
|
||||||
.then(res => res.json())
|
sectorService.getAll()
|
||||||
.then((data: Sector[]) => {
|
.then(data => {
|
||||||
setSectores(data);
|
// Ordenar alfabéticamente por defecto
|
||||||
setIsLoading(false);
|
const sectoresOrdenados = [...data].sort((a, b) => a.nombre.localeCompare(b.nombre, 'es', { sensitivity: 'base' }));
|
||||||
|
setSectores(sectoresOrdenados);
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
toast.error("No se pudieron cargar los sectores.");
|
toast.error("No se pudieron cargar los sectores.");
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleOpenCreateModal = () => {
|
const handleOpenCreateModal = () => {
|
||||||
setEditingSector(null); // Poner en modo 'crear'
|
setEditingSector(null);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenEditModal = (sector: Sector) => {
|
const handleOpenEditModal = useCallback((sector: Sector) => {
|
||||||
setEditingSector(sector); // Poner en modo 'editar' con los datos del sector
|
setEditingSector(sector);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleSave = async (id: number | null, nombre: string) => {
|
const handleSave = async (id: number | null, nombre: string) => {
|
||||||
const isEditing = id !== null;
|
const isEditing = id !== null;
|
||||||
const url = isEditing ? `${BASE_URL}/sectores/${id}` : `${BASE_URL}/sectores`;
|
|
||||||
const method = isEditing ? 'PUT' : 'POST';
|
|
||||||
const toastId = toast.loading(isEditing ? 'Actualizando...' : 'Creando...');
|
const toastId = toast.loading(isEditing ? 'Actualizando...' : 'Creando...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
let refreshedData;
|
||||||
method,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ nombre }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.message || 'La operación falló.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
// Actualizar el sector en la lista local
|
await sectorService.update(id, nombre);
|
||||||
setSectores(prev => prev.map(s => s.id === id ? { ...s, nombre } : s));
|
refreshedData = await sectorService.getAll();
|
||||||
toast.success('Sector actualizado.', { id: toastId });
|
toast.success('Sector actualizado.', { id: toastId });
|
||||||
} else {
|
} else {
|
||||||
// Añadir el nuevo sector a la lista local
|
await sectorService.create(nombre);
|
||||||
const nuevoSector = await response.json();
|
refreshedData = await sectorService.getAll();
|
||||||
setSectores(prev => [...prev, nuevoSector]);
|
|
||||||
toast.success('Sector creado.', { id: toastId });
|
toast.success('Sector creado.', { id: toastId });
|
||||||
}
|
}
|
||||||
|
const sectoresOrdenados = [...refreshedData].sort((a, b) => a.nombre.localeCompare(b.nombre, 'es', { sensitivity: 'base' }));
|
||||||
setIsModalOpen(false); // Cerrar el modal
|
setSectores(sectoresOrdenados);
|
||||||
|
setIsModalOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = useCallback(async (id: number) => {
|
||||||
if (!window.confirm("¿Estás seguro de eliminar este sector? Los equipos asociados quedarán sin sector.")) {
|
if (!window.confirm("¿Estás seguro de eliminar este sector? Los equipos asociados quedarán sin sector.")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const toastId = toast.loading('Eliminando...');
|
const toastId = toast.loading('Eliminando...');
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${BASE_URL}/sectores/${id}`, { method: 'DELETE' });
|
await sectorService.delete(id);
|
||||||
if (response.status === 409) {
|
|
||||||
throw new Error("No se puede eliminar. Hay equipos asignados a este sector.");
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("El sector no se pudo eliminar.");
|
|
||||||
}
|
|
||||||
setSectores(prev => prev.filter(s => s.id !== id));
|
setSectores(prev => prev.filter(s => s.id !== id));
|
||||||
toast.success("Sector eliminado.", { id: toastId });
|
toast.success("Sector eliminado.", { id: toastId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
if (isLoading) {
|
// --- 3. Definición de columnas para React Table ---
|
||||||
return <div>Cargando sectores...</div>;
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
<h2>Gestión de Sectores ({table.getFilteredRowModel().rows.length})</h2>
|
||||||
<h2>Gestión de Sectores</h2>
|
<p>Crea, edita y elimina los sectores de la organización.</p>
|
||||||
<button onClick={handleOpenCreateModal} className={`${styles.btn} ${styles.btnPrimary}`}>
|
<div className={styles.controlsContainer}>
|
||||||
+ Añadir Sector
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className={styles.tableContainer}>
|
||||||
|
<TableSkeleton rows={6} columns={2} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.tableContainer}>
|
||||||
<table className={styles.table}>
|
<table className={styles.table}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
{table.getHeaderGroups().map(headerGroup => (
|
||||||
<th className={styles.th}>Nombre del Sector</th>
|
<tr key={headerGroup.id}>
|
||||||
<th className={styles.th} style={{ width: '200px' }}>Acciones</th>
|
{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>
|
</tr>
|
||||||
|
))}
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{sectores.map(sector => (
|
{table.getRowModel().rows.map(row => (
|
||||||
<tr key={sector.id} className={styles.tr}>
|
<tr key={row.id} className={styles.tr}>
|
||||||
<td className={styles.td}>{sector.nombre}</td>
|
{row.getVisibleCells().map(cell => {
|
||||||
<td className={styles.td}>
|
const classNames = [styles.td];
|
||||||
<div style={{ display: 'flex', gap: '10px' }}>
|
if (cell.column.id === 'acciones') {
|
||||||
<button onClick={() => handleOpenEditModal(sector)} className={styles.tableButton}>✏️ Editar</button>
|
classNames.push(styles.tdActions);
|
||||||
<button onClick={() => handleDelete(sector.id)} className={styles.deleteUserButton} style={{fontSize: '1em', padding: '0.375rem 0.75rem', border: '1px solid #dc3545', borderRadius: '4px'}}>
|
}
|
||||||
🗑️ Eliminar
|
|
||||||
</button>
|
return (
|
||||||
</div>
|
<td key={cell.id} className={classNames.join(' ')}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
</td>
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
<ModalSector
|
<ModalSector
|
||||||
|
|||||||
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;
|
||||||
@@ -18,7 +18,7 @@ const ModalAnadirDisco: React.FC<Props> = ({ onClose, onSave }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.modalOverlay}>
|
<div className={`${styles.modalOverlay} ${styles['modalOverlay--nested']}`}>
|
||||||
<div className={styles.modal}>
|
<div className={styles.modal}>
|
||||||
<h3>Añadir Disco Manualmente</h3>
|
<h3>Añadir Disco Manualmente</h3>
|
||||||
<label>Tipo de Disco</label>
|
<label>Tipo de Disco</label>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// frontend/src/components/ModalAnadirEquipo.tsx
|
// frontend/src/components/ModalAnadirEquipo.tsx
|
||||||
import React, { useState } from 'react';
|
|
||||||
|
import React, { useState, useCallback } from 'react'; // <-- 1. Importar useCallback
|
||||||
import type { Sector, Equipo } from '../types/interfaces';
|
import type { Sector, Equipo } from '../types/interfaces';
|
||||||
import AutocompleteInput from './AutocompleteInput';
|
import AutocompleteInput from './AutocompleteInput';
|
||||||
import styles from './SimpleTable.module.css';
|
import styles from './SimpleTable.module.css';
|
||||||
@@ -10,7 +11,7 @@ interface ModalAnadirEquipoProps {
|
|||||||
onSave: (nuevoEquipo: Omit<Equipo, 'id' | 'created_at' | 'updated_at'>) => void;
|
onSave: (nuevoEquipo: Omit<Equipo, 'id' | 'created_at' | 'updated_at'>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE_URL = 'http://localhost:5198/api';
|
const BASE_URL = '/api';
|
||||||
|
|
||||||
const ModalAnadirEquipo: React.FC<ModalAnadirEquipoProps> = ({ sectores, onClose, onSave }) => {
|
const ModalAnadirEquipo: React.FC<ModalAnadirEquipoProps> = ({ sectores, onClose, onSave }) => {
|
||||||
const [nuevoEquipo, setNuevoEquipo] = useState({
|
const [nuevoEquipo, setNuevoEquipo] = useState({
|
||||||
@@ -31,12 +32,17 @@ const ModalAnadirEquipo: React.FC<ModalAnadirEquipoProps> = ({ sectores, onClose
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveClick = () => {
|
const handleSaveClick = () => {
|
||||||
// La UI pasará un objeto compatible con el DTO del backend
|
|
||||||
onSave(nuevoEquipo as any);
|
onSave(nuevoEquipo as any);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isFormValid = nuevoEquipo.hostname.trim() !== '' && nuevoEquipo.ip.trim() !== '';
|
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 (
|
return (
|
||||||
<div className={styles.modalOverlay}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} style={{ minWidth: '500px' }}>
|
<div className={styles.modal} style={{ minWidth: '500px' }}>
|
||||||
@@ -49,7 +55,8 @@ const ModalAnadirEquipo: React.FC<ModalAnadirEquipoProps> = ({ sectores, onClose
|
|||||||
value={nuevoEquipo.hostname}
|
value={nuevoEquipo.hostname}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className={styles.modalInput}
|
className={styles.modalInput}
|
||||||
placeholder="Ej: CONTABILIDAD-01"
|
placeholder="Ej: TECNICA10"
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label>Dirección IP (Requerido)</label>
|
<label>Dirección IP (Requerido)</label>
|
||||||
@@ -59,7 +66,8 @@ const ModalAnadirEquipo: React.FC<ModalAnadirEquipoProps> = ({ sectores, onClose
|
|||||||
value={nuevoEquipo.ip}
|
value={nuevoEquipo.ip}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className={styles.modalInput}
|
className={styles.modalInput}
|
||||||
placeholder="Ej: 192.168.1.50"
|
placeholder="Ej: 192.168.10.50"
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label>Sector</label>
|
<label>Sector</label>
|
||||||
@@ -75,31 +83,35 @@ const ModalAnadirEquipo: React.FC<ModalAnadirEquipoProps> = ({ sectores, onClose
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
{/* --- 3. Usar las funciones memorizadas --- */}
|
||||||
<label>Motherboard (Opcional)</label>
|
<label>Motherboard (Opcional)</label>
|
||||||
<AutocompleteInput
|
<AutocompleteInput
|
||||||
|
mode="static"
|
||||||
name="motherboard"
|
name="motherboard"
|
||||||
value={nuevoEquipo.motherboard}
|
value={nuevoEquipo.motherboard}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className={styles.modalInput}
|
className={styles.modalInput}
|
||||||
fetchSuggestions={() => fetch(`${BASE_URL}/equipos/distinct/motherboard`).then(res => res.json())}
|
fetchSuggestions={fetchMotherboardSuggestions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label>CPU (Opcional)</label>
|
<label>CPU (Opcional)</label>
|
||||||
<AutocompleteInput
|
<AutocompleteInput
|
||||||
|
mode="static"
|
||||||
name="cpu"
|
name="cpu"
|
||||||
value={nuevoEquipo.cpu}
|
value={nuevoEquipo.cpu}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className={styles.modalInput}
|
className={styles.modalInput}
|
||||||
fetchSuggestions={() => fetch(`${BASE_URL}/equipos/distinct/cpu`).then(res => res.json())}
|
fetchSuggestions={fetchCpuSuggestions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label>Sistema Operativo (Opcional)</label>
|
<label>Sistema Operativo (Opcional)</label>
|
||||||
<AutocompleteInput
|
<AutocompleteInput
|
||||||
|
mode="static"
|
||||||
name="os"
|
name="os"
|
||||||
value={nuevoEquipo.os}
|
value={nuevoEquipo.os}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className={styles.modalInput}
|
className={styles.modalInput}
|
||||||
fetchSuggestions={() => fetch(`${BASE_URL}/equipos/distinct/os`).then(res => res.json())}
|
fetchSuggestions={fetchOsSuggestions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={styles.modalActions}>
|
<div className={styles.modalActions}>
|
||||||
|
|||||||
@@ -1,40 +1,100 @@
|
|||||||
// frontend/src/components/ModalAnadirRam.tsx
|
// frontend/src/components/ModalAnadirRam.tsx
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
import styles from './SimpleTable.module.css';
|
import styles from './SimpleTable.module.css';
|
||||||
|
import AutocompleteInput from './AutocompleteInput';
|
||||||
|
import { memoriaRamService } from '../services/apiService';
|
||||||
|
import type { MemoriaRam } from '../types/interfaces';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (ram: { slot: string, tamano: number, fabricante?: string, velocidad?: number }) => void;
|
onSave: (ram: { slot: string, tamano: number, fabricante?: string, velocidad?: number, partNumber?: string }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ModalAnadirRam: React.FC<Props> = ({ onClose, onSave }) => {
|
const ModalAnadirRam: React.FC<Props> = ({ onClose, onSave }) => {
|
||||||
const [ram, setRam] = useState({ slot: '', tamano: '', fabricante: '', velocidad: '' });
|
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 handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setRam(prev => ({ ...prev, [e.target.name]: e.target.value }));
|
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 = () => {
|
const handleSave = () => {
|
||||||
onSave({
|
onSave({
|
||||||
slot: ram.slot,
|
slot: ram.slot,
|
||||||
tamano: parseInt(ram.tamano, 10),
|
tamano: parseInt(ram.tamano, 10),
|
||||||
fabricante: ram.fabricante || undefined,
|
fabricante: ram.fabricante || undefined,
|
||||||
velocidad: ram.velocidad ? parseInt(ram.velocidad, 10) : undefined,
|
velocidad: ram.velocidad ? parseInt(ram.velocidad, 10) : undefined,
|
||||||
|
partNumber: ram.partNumber || undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.modalOverlay}>
|
<div className={`${styles.modalOverlay} ${styles['modalOverlay--nested']}`}>
|
||||||
<div className={styles.modal}>
|
<div className={styles.modal}>
|
||||||
<h3>Añadir Módulo de RAM</h3>
|
<h3>Añadir Módulo de RAM</h3>
|
||||||
|
|
||||||
<label>Slot (Requerido)</label>
|
<label>Slot (Requerido)</label>
|
||||||
<input type="text" name="slot" value={ram.slot} onChange={handleChange} className={styles.modalInput} placeholder="Ej: DIMM0" />
|
<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>
|
<label>Tamaño (GB) (Requerido)</label>
|
||||||
<input type="number" name="tamano" value={ram.tamano} onChange={handleChange} className={styles.modalInput} placeholder="Ej: 8" />
|
<input type="number" name="tamano" value={ram.tamano} onChange={handleChange} className={styles.modalInput} placeholder="Ej: 8" />
|
||||||
<label>Fabricante (Opcional)</label>
|
|
||||||
|
<label>Fabricante</label>
|
||||||
<input type="text" name="fabricante" value={ram.fabricante} onChange={handleChange} className={styles.modalInput} />
|
<input type="text" name="fabricante" value={ram.fabricante} onChange={handleChange} className={styles.modalInput} />
|
||||||
<label>Velocidad (MHz) (Opcional)</label>
|
|
||||||
|
<label>Velocidad (MHz)</label>
|
||||||
<input type="number" name="velocidad" value={ram.velocidad} onChange={handleChange} className={styles.modalInput} />
|
<input type="number" name="velocidad" value={ram.velocidad} onChange={handleChange} className={styles.modalInput} />
|
||||||
|
|
||||||
<div className={styles.modalActions}>
|
<div className={styles.modalActions}>
|
||||||
<button onClick={handleSave} className={`${styles.btn} ${styles.btnPrimary}`} disabled={!ram.slot || !ram.tamano}>Guardar</button>
|
<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>
|
<button onClick={onClose} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button>
|
||||||
|
|||||||
@@ -1,30 +1,34 @@
|
|||||||
import React, { useState } from 'react';
|
// frontend/src/components/ModalAnadirUsuario.tsx
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
import styles from './SimpleTable.module.css';
|
import styles from './SimpleTable.module.css';
|
||||||
import AutocompleteInput from './AutocompleteInput';
|
import AutocompleteInput from './AutocompleteInput';
|
||||||
|
import { usuarioService } from '../services/apiService';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (usuario: { username: string }) => void;
|
onSave: (usuario: { username: string }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE_URL = 'http://localhost:5198/api';
|
|
||||||
|
|
||||||
const ModalAnadirUsuario: React.FC<Props> = ({ onClose, onSave }) => {
|
const ModalAnadirUsuario: React.FC<Props> = ({ onClose, onSave }) => {
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
|
|
||||||
const fetchUserSuggestions = async (query: string): Promise<string[]> => {
|
const fetchUserSuggestions = useCallback(async (query: string): Promise<string[]> => {
|
||||||
if (!query) return [];
|
if (!query) return [];
|
||||||
const response = await fetch(`${BASE_URL}/usuarios/buscar/${query}`);
|
try {
|
||||||
if (!response.ok) return [];
|
return await usuarioService.search(query);
|
||||||
return response.json();
|
} catch (error) {
|
||||||
};
|
console.error("Error buscando usuarios", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.modalOverlay}>
|
<div className={`${styles.modalOverlay} ${styles['modalOverlay--nested']}`}>
|
||||||
<div className={styles.modal}>
|
<div className={styles.modal}>
|
||||||
<h3>Añadir Usuario Manualmente</h3>
|
<h3>Añadir Usuario Manualmente</h3>
|
||||||
<label>Nombre de Usuario</label>
|
<label>Nombre de Usuario</label>
|
||||||
<AutocompleteInput
|
<AutocompleteInput
|
||||||
|
mode="dynamic"
|
||||||
name="username"
|
name="username"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={e => setUsername(e.target.value)}
|
onChange={e => setUsername(e.target.value)}
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
// frontend/src/components/ModalDetallesEquipo.tsx
|
// frontend/src/components/ModalDetallesEquipo.tsx
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import type { Equipo, HistorialEquipo, Sector } from '../types/interfaces';
|
import type { Equipo, HistorialEquipo, Sector } from '../types/interfaces';
|
||||||
import { Tooltip } from 'react-tooltip';
|
import { Tooltip } from 'react-tooltip';
|
||||||
import styles from './SimpleTable.module.css';
|
import styles from './SimpleTable.module.css';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import AutocompleteInput from './AutocompleteInput';
|
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';
|
||||||
|
|
||||||
// Interfaces actualizadas para las props
|
|
||||||
interface ModalDetallesEquipoProps {
|
interface ModalDetallesEquipoProps {
|
||||||
equipo: Equipo;
|
equipo: Equipo;
|
||||||
isOnline: boolean;
|
isOnline: boolean;
|
||||||
historial: HistorialEquipo[];
|
historial: HistorialEquipo[];
|
||||||
sectores: Sector[];
|
sectores: Sector[];
|
||||||
|
isChildModalOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDelete: (id: number) => Promise<boolean>;
|
onDelete: (id: number) => Promise<boolean>;
|
||||||
onRemoveAssociation: (type: 'disco' | 'ram' | 'usuario', id: any) => void;
|
onRemoveAssociation: (type: 'disco' | 'ram' | 'usuario', id: any) => void;
|
||||||
@@ -19,10 +21,9 @@ interface ModalDetallesEquipoProps {
|
|||||||
onAddComponent: (type: 'disco' | 'ram' | 'usuario') => void;
|
onAddComponent: (type: 'disco' | 'ram' | 'usuario') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE_URL = 'http://localhost:5198/api';
|
|
||||||
|
|
||||||
const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
|
const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
|
||||||
equipo, isOnline, historial, sectores, onClose, onDelete, onRemoveAssociation, onEdit, onAddComponent
|
equipo, isOnline, historial, sectores, isChildModalOpen,
|
||||||
|
onClose, onDelete, onRemoveAssociation, onEdit, onAddComponent
|
||||||
}) => {
|
}) => {
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [editableEquipo, setEditableEquipo] = useState({ ...equipo });
|
const [editableEquipo, setEditableEquipo] = useState({ ...equipo });
|
||||||
@@ -75,6 +76,11 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
|
|||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditClick = () => {
|
||||||
|
setEditableEquipo({ ...equipo });
|
||||||
|
setIsEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
const handleWolClick = async () => {
|
const handleWolClick = async () => {
|
||||||
if (!equipo.mac || !equipo.ip) {
|
if (!equipo.mac || !equipo.ip) {
|
||||||
toast.error("Este equipo no tiene MAC o IP para encenderlo.");
|
toast.error("Este equipo no tiene MAC o IP para encenderlo.");
|
||||||
@@ -82,12 +88,7 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
|
|||||||
}
|
}
|
||||||
const toastId = toast.loading('Enviando paquete WOL...');
|
const toastId = toast.loading('Enviando paquete WOL...');
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${BASE_URL}/equipos/wake-on-lan`, {
|
await equipoService.wakeOnLan(equipo.mac, equipo.ip);
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ mac: equipo.mac, ip: equipo.ip })
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error("La respuesta del servidor no fue exitosa.");
|
|
||||||
toast.success('Solicitud de encendido enviada.', { id: toastId });
|
toast.success('Solicitud de encendido enviada.', { id: toastId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Error al enviar la solicitud.', { id: toastId });
|
toast.error('Error al enviar la solicitud.', { id: toastId });
|
||||||
@@ -102,16 +103,25 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
|
|||||||
|
|
||||||
const formatDate = (dateString: string | undefined | null) => {
|
const formatDate = (dateString: string | undefined | null) => {
|
||||||
if (!dateString || dateString.startsWith('0001-01-01')) return 'No registrado';
|
if (!dateString || dateString.startsWith('0001-01-01')) return 'No registrado';
|
||||||
return new Date(dateString).toLocaleString('es-ES', {
|
const utcDate = new Date(dateString.replace(' ', 'T') + 'Z');
|
||||||
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'
|
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 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 (
|
return (
|
||||||
<div className={styles.modalLarge}>
|
<div className={styles.modalLarge}>
|
||||||
<button onClick={onClose} className={styles.closeButton}>×</button>
|
<button onClick={onClose} className={styles.closeButton} disabled={isChildModalOpen}>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className={styles.modalLargeContent}>
|
<div className={styles.modalLargeContent}>
|
||||||
<div className={styles.modalLargeHeader}>
|
<div className={styles.modalLargeHeader}>
|
||||||
<h2>Detalles del equipo: <strong>{equipo.hostname}</strong></h2>
|
<h2>Detalles del equipo: <strong>{equipo.hostname}</strong></h2>
|
||||||
@@ -123,65 +133,123 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
|
|||||||
<button onClick={handleCancel} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button>
|
<button onClick={handleCancel} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<button onClick={() => setIsEditing(true)} className={`${styles.btn} ${styles.btnPrimary}`}>✏️ Editar</button>
|
<button onClick={handleEditClick} className={`${styles.btn} ${styles.btnPrimary}`} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}><Pencil size={16} /> Editar</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.modalBodyColumns}>
|
<div className={styles.modalBodyColumns}>
|
||||||
{/* COLUMNA PRINCIPAL */}
|
|
||||||
<div className={styles.mainColumn}>
|
<div className={styles.mainColumn}>
|
||||||
{/* SECCIÓN DE DATOS PRINCIPALES */}
|
|
||||||
<div className={styles.section}>
|
<div className={styles.section}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<h3 className={styles.sectionTitle} style={{ border: 'none', padding: 0 }}>🔗 Datos Principales</h3>
|
<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.tableButton}>+ Disco</button><button onClick={() => onAddComponent('ram')} className={styles.tableButton}>+ RAM</button><button onClick={() => onAddComponent('usuario')} className={styles.tableButton}>+ Usuario</button></div>)}
|
{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>
|
||||||
<div className={styles.componentsGrid}>
|
<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} /> : <span className={styles.detailValue}>{equipo.hostname}</span>}</div>
|
<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} /> : <span className={styles.detailValue}>{equipo.ip}</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}>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 name="os" value={editableEquipo.os} onChange={handleChange} className={styles.modalInput} fetchSuggestions={() => fetch(`${BASE_URL}/equipos/distinct/os`).then(res => res.json())} /> : <span className={styles.detailValue}>{equipo.os || '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}>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}>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.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' ? '⌨️' : '⚙️'}</span>{` ${u.username}`}</div>{u.origen === 'manual' && (<button onClick={() => onRemoveAssociation('usuario', { equipoId: equipo.id, usuarioId: u.id })} className={styles.deleteUserButton} title="Quitar este usuario">🗑️</button>)}</div>)) : 'N/A'}</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>
|
</div>
|
||||||
|
|
||||||
{/* SECCIÓN DE COMPONENTES */}
|
|
||||||
<div className={styles.section}>
|
<div className={styles.section}>
|
||||||
<h3 className={styles.sectionTitle}>💻 Componentes</h3>
|
<h3 className={styles.sectionTitle}><Component size={20} /> Componentes</h3>
|
||||||
<div className={styles.detailsGrid}>
|
<div className={styles.detailsGrid}>
|
||||||
<div className={styles.detailItem}><strong className={styles.detailLabel}>Motherboard:</strong>{isEditing ? <AutocompleteInput name="motherboard" value={editableEquipo.motherboard} onChange={handleChange} className={styles.modalInput} fetchSuggestions={() => fetch(`${BASE_URL}/equipos/distinct/motherboard`).then(res => res.json())} /> : <span className={styles.detailValue}>{equipo.motherboard || 'N/A'}</span>}</div>
|
<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 name="cpu" value={editableEquipo.cpu} onChange={handleChange} className={styles.modalInput} fetchSuggestions={() => fetch(`${BASE_URL}/equipos/distinct/cpu`).then(res => res.json())} /> : <span className={styles.detailValue}>{equipo.cpu || '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}>RAM Instalada:</strong><span className={styles.detailValue}>{equipo.ram_installed} GB</span></div>
|
||||||
<div className={styles.detailItem}><strong className={styles.detailLabel}>Arquitectura:</strong><span className={styles.detailValue}>{equipo.architecture || 'N/A'}</span></div>
|
<div className={styles.detailItem}>
|
||||||
<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' ? '⌨️' : '⚙️'}</span>{` ${d.mediatype} ${d.size}GB`}</div>{d.origen === 'manual' && (<button onClick={() => onRemoveAssociation('disco', d.equipoDiscoId)} className={styles.deleteUserButton} title="Eliminar este disco">🗑️</button>)}</div>)) : 'N/A'}</span></div>
|
<strong className={styles.detailLabel}>Arquitectura:</strong>
|
||||||
<div className={styles.detailItem}><strong className={styles.detailLabel}>Slots RAM:</strong><span className={styles.detailValue}>{equipo.ram_slots || 'N/A'}</span></div>
|
{isEditing ? (
|
||||||
<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' ? '⌨️' : '⚙️'}</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">🗑️</button>)}</div>)) : 'N/A'}</span></div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* COLUMNA LATERAL */}
|
|
||||||
<div className={styles.sidebarColumn}>
|
<div className={styles.sidebarColumn}>
|
||||||
{/* SECCIÓN DE ACCIONES */}
|
|
||||||
<div className={styles.section}>
|
<div className={styles.section}>
|
||||||
<h3 className={styles.sectionTitle}>⚡ Acciones y Estado</h3>
|
<h3 className={styles.sectionTitle}><Zap size={20} /> Acciones y Estado</h3>
|
||||||
<div className={styles.actionsGrid}>
|
<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}>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"><img src="/img/power.png" alt="Encender equipo" className={styles.powerIcon} />Encender (WOL)</button><Tooltip id="modal-power-tooltip" place="top">Encender equipo remotamente</Tooltip></div>
|
|
||||||
<div className={styles.detailItem}><strong className={styles.detailLabel}>Eliminar Equipo:</strong><button onClick={handleDeleteClick} className={styles.deleteButton} disabled={equipo.origen !== 'manual'} style={{ cursor: equipo.origen !== 'manual' ? 'not-allowed' : 'pointer' }} data-tooltip-id="modal-delete-tooltip">🗑️ Eliminar</button><Tooltip id="modal-delete-tooltip" place="top">{equipo.origen === 'manual' ? 'Eliminar equipo permanentemente' : 'No se puede eliminar un equipo cargado automáticamente'}</Tooltip></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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SECCIÓN DE HISTORIAL (FUERA DE LAS COLUMNAS) */}
|
|
||||||
<div className={`${styles.section} ${styles.historySectionFullWidth}`}>
|
<div className={`${styles.section} ${styles.historySectionFullWidth}`}>
|
||||||
<h3 className={styles.sectionTitle}>📜 Historial de cambios</h3>
|
<h3 className={styles.sectionTitle}><History size={20} /> Historial de cambios</h3>
|
||||||
<div className={styles.historyContainer}>
|
<div className={styles.historyContainer}>
|
||||||
<table className={styles.historyTable}>
|
<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>
|
<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>
|
||||||
@@ -189,7 +257,6 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
// frontend/src/components/Navbar.tsx
|
// frontend/src/components/Navbar.tsx
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { View } from '../App'; // Importaremos el tipo desde App.tsx
|
import type { View } from '../App';
|
||||||
import '../App.css'; // Usaremos los estilos globales que acabamos de crear
|
import ThemeToggle from './ThemeToggle';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { LogOut } from 'lucide-react';
|
||||||
|
import '../App.css';
|
||||||
|
|
||||||
interface NavbarProps {
|
interface NavbarProps {
|
||||||
currentView: View;
|
currentView: View;
|
||||||
@@ -9,6 +12,7 @@ interface NavbarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Navbar: React.FC<NavbarProps> = ({ currentView, setCurrentView }) => {
|
const Navbar: React.FC<NavbarProps> = ({ currentView, setCurrentView }) => {
|
||||||
|
const { logout } = useAuth();
|
||||||
return (
|
return (
|
||||||
<header className="navbar">
|
<header className="navbar">
|
||||||
<div className="app-title">
|
<div className="app-title">
|
||||||
@@ -33,6 +37,22 @@ const Navbar: React.FC<NavbarProps> = ({ currentView, setCurrentView }) => {
|
|||||||
>
|
>
|
||||||
Administración
|
Administración
|
||||||
</button>
|
</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>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
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;
|
||||||
@@ -1,49 +1,116 @@
|
|||||||
|
/* frontend/src/components/SimpleTable.module.css */
|
||||||
|
|
||||||
/* Estilos para el contenedor principal y controles */
|
/* 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 {
|
.controlsContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 0.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchInput, .sectorSelect {
|
.searchInput, .sectorSelect {
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid #ced4da;
|
border: 1px solid var(--color-border);
|
||||||
font-size: 14px;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Estilos de la tabla */
|
|
||||||
.table {
|
.table {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||||
width: 100%;
|
min-width: 100%;
|
||||||
min-width: 1200px; /* Ancho mínimo para forzar el scroll horizontal si es necesario */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.th {
|
.th {
|
||||||
color: #212529;
|
color: var(--color-text-primary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-bottom: 2px solid #dee2e6;
|
border-bottom: 2px solid var(--color-border);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
background-color: var(--color-background);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0; /* Mantiene la posición sticky en la parte superior del viewport */
|
top: 0;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
background-color: #f8f9fa; /* Es crucial tener un fondo sólido */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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 {
|
.sortIndicator {
|
||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
font-size: 1.2em;
|
font-size: 1.2em;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
color: #007bff;
|
color: var(--color-primary);
|
||||||
min-width: 20px;
|
min-width: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,81 +123,152 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tr:hover {
|
.tr:hover {
|
||||||
background-color: #f1f3f5;
|
background-color: var(--color-surface-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.td {
|
.td {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-bottom: 1px solid #e9ecef;
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
color: #495057;
|
color: var(--color-text-secondary);
|
||||||
background-color: white;
|
background-color: var(--color-surface);
|
||||||
|
word-break: break-word;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Estilos de botones dentro de la tabla */
|
/* Estilos de botones dentro de la tabla */
|
||||||
.hostnameButton {
|
.hostnameButton {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: #007bff;
|
color: var(--color-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
transition: color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hostnameButton:hover {
|
||||||
|
color: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.tableButton {
|
.tableButton {
|
||||||
padding: 0.375rem 0.75rem;
|
padding: 0.375rem 0.75rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid var(--color-border);
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: #212529;
|
color: var(--color-text-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableButton:hover {
|
.tableButton:hover {
|
||||||
background-color: #e9ecef;
|
background-color: var(--color-surface-hover);
|
||||||
border-color: #adb5bd;
|
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 {
|
.deleteUserButton {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #dc3545;
|
color: var(--color-danger);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
padding: 0 5px;
|
padding: 0 5px;
|
||||||
opacity: 0.7;
|
transition: opacity 0.3s ease, color 0.3s ease, background-color 0.3s ease;
|
||||||
transition: opacity 0.3s ease, color 0.3s ease;
|
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
.deleteUserButton:hover {
|
|
||||||
opacity: 1;
|
.deleteUserButton:hover:not(:disabled) {
|
||||||
color: #a4202e;
|
color: var(--color-danger-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteUserButton:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Estilo para el botón de scroll-to-top */
|
/* Estilo para el botón de scroll-to-top */
|
||||||
.scrollToTop {
|
.scrollToTop {
|
||||||
position: fixed;
|
position: absolute;
|
||||||
bottom: 60px;
|
top: 6px;
|
||||||
right: 20px;
|
right: 20px; /* Suficiente espacio para no quedar debajo de la scrollbar */
|
||||||
width: 40px;
|
z-index: 30; /* Un valor alto para asegurar que esté por encima de la tabla y su cabecera (z-index: 2) */
|
||||||
height: 40px;
|
width: 36px;
|
||||||
border-radius: 50%;
|
height: 36px;
|
||||||
background-color: #007bff;
|
background-color: var(--color-surface);
|
||||||
color: white;
|
color: var(--color-text-primary);
|
||||||
border: none;
|
border: 1px solid var(--color-border);
|
||||||
cursor: pointer;
|
border-radius: 8%;
|
||||||
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
|
||||||
font-size: 20px;
|
/* Contenido y transiciones */
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: opacity 0.3s, transform 0.3s;
|
cursor: pointer;
|
||||||
z-index: 1002;
|
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 {
|
.scrollToTop:hover {
|
||||||
transform: translateY(-3px);
|
background-color: var(--color-primary);
|
||||||
background-color: #0056b3;
|
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 */
|
/* Estilos genéricos para modales */
|
||||||
@@ -145,23 +283,27 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
/* Aplicamos animación */
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
background-color: #ffffff;
|
background-color: var(--color-surface);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
box-shadow: 0px 8px 30px rgba(0, 0, 0, 0.12);
|
box-shadow: 0px 8px 30px rgba(0, 0, 0, 0.12);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
min-width: 400px;
|
min-width: 400px;
|
||||||
max-width: 90%;
|
max-width: 90%;
|
||||||
border: 1px solid #e0e0e0;
|
border: 1px solid var(--color-border);
|
||||||
font-family: 'Segoe UI', sans-serif;
|
font-family: 'Segoe UI', sans-serif;
|
||||||
|
animation: scaleIn 0.2s ease-out;
|
||||||
|
/* Aplicamos animación */
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal h3 {
|
.modal h3 {
|
||||||
margin: 0 0 1.5rem;
|
margin: 0 0 1.5rem;
|
||||||
color: #2d3436;
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal label {
|
.modal label {
|
||||||
@@ -173,18 +315,32 @@
|
|||||||
.modalInput {
|
.modalInput {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid #ced4da;
|
border: 1px solid var(--color-border);
|
||||||
|
background-color: var(--color-background); /* Ligeramente diferente para contraste */
|
||||||
|
color: var(--color-text-primary);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin-top: 4px; /* Separado del label */
|
margin-top: 4px;
|
||||||
margin-bottom: 4px; /* Espacio antes del siguiente elemento */
|
/* 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 {
|
.modalActions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
justify-content: flex-end; /* Alinea los botones a la derecha por defecto */
|
justify-content: flex-end;
|
||||||
|
/* Alinea los botones a la derecha por defecto */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Estilos de botones para modales */
|
/* Estilos de botones para modales */
|
||||||
@@ -199,27 +355,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btnPrimary {
|
.btnPrimary {
|
||||||
background-color: #007bff;
|
background-color: var(--color-primary);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
.btnPrimary:hover {
|
.btnPrimary:hover {
|
||||||
background-color: #0056b3;
|
background-color: var(--color-primary-hover);
|
||||||
}
|
}
|
||||||
.btnPrimary:disabled {
|
.btnPrimary:disabled {
|
||||||
background-color: #e9ecef;
|
background-color: var(--color-surface-hover);
|
||||||
color: #6c757d;
|
color: var(--color-text-muted);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btnSecondary {
|
.btnSecondary {
|
||||||
background-color: #6c757d;
|
background-color: var(--color-text-muted);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
.btnSecondary:hover {
|
.btnSecondary:hover {
|
||||||
background-color: #5a6268;
|
background-color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== NUEVOS ESTILOS PARA EL MODAL DE DETALLES ===== */
|
/* ===== ESTILOS PARA EL MODAL DE DETALLES ===== */
|
||||||
|
|
||||||
.modalLarge {
|
.modalLarge {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -227,19 +383,24 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background-color: #f8f9fa; /* Un fondo ligeramente gris para el modal */
|
background-color: var(--color-background);
|
||||||
|
/* Un fondo ligeramente gris para el modal */
|
||||||
z-index: 1003;
|
z-index: 1003;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
/* Animación ligeramente más lenta para pantalla completa */
|
||||||
}
|
}
|
||||||
|
|
||||||
.modalLargeContent {
|
.modalLargeContent {
|
||||||
max-width: 1400px; /* Ancho máximo del contenido */
|
max-width: 1400px;
|
||||||
|
/* Ancho máximo del contenido */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 auto; /* Centrar el contenido */
|
margin: 0 auto;
|
||||||
|
/* Centrar el contenido */
|
||||||
}
|
}
|
||||||
|
|
||||||
.modalLargeHeader {
|
.modalLargeHeader {
|
||||||
@@ -253,7 +414,7 @@
|
|||||||
.modalLargeHeader h2 {
|
.modalLargeHeader h2 {
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
color: #343a40;
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.closeButton {
|
.closeButton {
|
||||||
@@ -275,6 +436,7 @@
|
|||||||
right: 30px;
|
right: 30px;
|
||||||
top: 30px;
|
top: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.closeButton:hover {
|
.closeButton:hover {
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
background-color: #333;
|
background-color: #333;
|
||||||
@@ -300,8 +462,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
background-color: #ffffff;
|
background-color: var(--color-surface);
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||||
@@ -311,8 +473,8 @@
|
|||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
padding-bottom: 0.75rem;
|
padding-bottom: 0.75rem;
|
||||||
border-bottom: 1px solid #e9ecef;
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
color: #2d3436;
|
color: var(--color-text-primary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -325,7 +487,6 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* CAMBIO: Se aplica el mismo estilo de grid a componentsGrid para que se vea igual que detailsGrid */
|
|
||||||
.componentsGrid {
|
.componentsGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
@@ -338,24 +499,25 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detailItem, .detailItemFull {
|
.detailItem,
|
||||||
|
.detailItemFull {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background-color: #f8f9fa;
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid #e9ecef;
|
background-color: var(--color-background);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detailLabel {
|
.detailLabel {
|
||||||
color: #6c757d;
|
color: var(--color-text-muted);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detailValue {
|
.detailValue {
|
||||||
color: #495057;
|
color: var(--color-text-secondary);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
@@ -369,9 +531,10 @@
|
|||||||
padding: 2px 0;
|
padding: 2px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.powerButton, .deleteButton {
|
.powerButton,
|
||||||
|
.deleteButton {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -383,10 +546,14 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.powerButton {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.powerButton:hover {
|
.powerButton:hover {
|
||||||
border-color: #007bff;
|
border-color: var(--color-primary);
|
||||||
background-color: #e7f1ff;
|
background-color: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||||
color: #0056b3;
|
color: var(--color-primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.powerIcon {
|
.powerIcon {
|
||||||
@@ -395,13 +562,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.deleteButton {
|
.deleteButton {
|
||||||
color: #dc3545;
|
color: var(--color-danger);
|
||||||
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.deleteButton:hover {
|
.deleteButton:hover {
|
||||||
border-color: #dc3545;
|
border-color: var(--color-danger);
|
||||||
background-color: #fbebee;
|
background-color: var(--color-danger-background);
|
||||||
color: #a4202e;
|
color: var(--color-danger-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.deleteButton:disabled {
|
.deleteButton:disabled {
|
||||||
color: #6c757d;
|
color: #6c757d;
|
||||||
background-color: #e9ecef;
|
background-color: #e9ecef;
|
||||||
@@ -411,7 +581,7 @@
|
|||||||
.historyContainer {
|
.historyContainer {
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,7 +591,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.historyTh {
|
.historyTh {
|
||||||
background-color: #f8f9fa;
|
background-color: var(--color-background);
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
@@ -431,16 +601,15 @@
|
|||||||
|
|
||||||
.historyTd {
|
.historyTd {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
color: #495057;
|
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
border-bottom: 1px solid #dee2e6;
|
color: var(--color-text-secondary);
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.historyTr:last-child .historyTd {
|
.historyTr:last-child .historyTd {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* CAMBIO: Nueva clase para dar espacio a la sección de historial */
|
|
||||||
.historySectionFullWidth {
|
.historySectionFullWidth {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
}
|
}
|
||||||
@@ -479,12 +648,134 @@
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Clases para la sección de usuarios y claves - No se usan en el nuevo modal pero se mantienen por si acaso */
|
.userList {
|
||||||
.userList { min-width: 240px; }
|
min-width: 240px;
|
||||||
.userItem { display: flex; align-items: center; justify-content: space-between; margin: 4px 0; padding: 6px; background-color: #f8f9fa; border-radius: 4px; position: relative; }
|
}
|
||||||
.userInfo { color: #495057; }
|
|
||||||
.userActions { display: flex; gap: 4px; align-items: center; }
|
.userItem {
|
||||||
.sectorContainer { display: flex; justify-content: space-between; align-items: center; width: 100%; gap: 0.5rem; }
|
display: flex;
|
||||||
.sectorName { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
align-items: center;
|
||||||
.sectorNameAssigned { color: #212529; font-style: normal; }
|
justify-content: space-between;
|
||||||
.sectorNameUnassigned { color: #6c757d; font-style: italic; }
|
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 */
|
||||||
|
}
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
// frontend/src/components/SimpleTable.tsx
|
// frontend/src/components/SimpleTable.tsx
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
useReactTable,
|
useReactTable, getCoreRowModel, getFilteredRowModel, getSortedRowModel,
|
||||||
getCoreRowModel,
|
getPaginationRowModel, flexRender, type CellContext,
|
||||||
getFilteredRowModel,
|
type ColumnDef, type VisibilityState, type ColumnSizingState
|
||||||
getSortedRowModel,
|
|
||||||
getPaginationRowModel,
|
|
||||||
flexRender,
|
|
||||||
type CellContext
|
|
||||||
} from '@tanstack/react-table';
|
} from '@tanstack/react-table';
|
||||||
import { Tooltip } from 'react-tooltip';
|
import { Tooltip } from 'react-tooltip';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import type { Equipo, Sector, Usuario, UsuarioEquipoDetalle } from '../types/interfaces';
|
import type { Equipo, Sector, Usuario, UsuarioEquipoDetalle } from '../types/interfaces';
|
||||||
import styles from './SimpleTable.module.css';
|
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 ModalAnadirEquipo from './ModalAnadirEquipo';
|
||||||
import ModalEditarSector from './ModalEditarSector';
|
import ModalEditarSector from './ModalEditarSector';
|
||||||
@@ -21,6 +22,7 @@ import ModalDetallesEquipo from './ModalDetallesEquipo';
|
|||||||
import ModalAnadirDisco from './ModalAnadirDisco';
|
import ModalAnadirDisco from './ModalAnadirDisco';
|
||||||
import ModalAnadirRam from './ModalAnadirRam';
|
import ModalAnadirRam from './ModalAnadirRam';
|
||||||
import ModalAnadirUsuario from './ModalAnadirUsuario';
|
import ModalAnadirUsuario from './ModalAnadirUsuario';
|
||||||
|
import TableSkeleton from './TableSkeleton';
|
||||||
|
|
||||||
const SimpleTable = () => {
|
const SimpleTable = () => {
|
||||||
const [data, setData] = useState<Equipo[]>([]);
|
const [data, setData] = useState<Equipo[]>([]);
|
||||||
@@ -37,11 +39,53 @@ const SimpleTable = () => {
|
|||||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||||
const [addingComponent, setAddingComponent] = useState<'disco' | 'ram' | 'usuario' | null>(null);
|
const [addingComponent, setAddingComponent] = useState<'disco' | 'ram' | 'usuario' | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const BASE_URL = 'http://localhost:5198/api';
|
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(() => {
|
useEffect(() => {
|
||||||
const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth;
|
const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth;
|
||||||
if (selectedEquipo || modalData || modalPasswordData) {
|
if (selectedEquipo || modalData || modalPasswordData || isAddModalOpen) {
|
||||||
document.body.classList.add('scroll-lock');
|
document.body.classList.add('scroll-lock');
|
||||||
document.body.style.paddingRight = `${scrollBarWidth}px`;
|
document.body.style.paddingRight = `${scrollBarWidth}px`;
|
||||||
} else {
|
} else {
|
||||||
@@ -52,7 +96,7 @@ const SimpleTable = () => {
|
|||||||
document.body.classList.remove('scroll-lock');
|
document.body.classList.remove('scroll-lock');
|
||||||
document.body.style.paddingRight = '0';
|
document.body.style.paddingRight = '0';
|
||||||
};
|
};
|
||||||
}, [selectedEquipo, modalData, modalPasswordData]);
|
}, [selectedEquipo, modalData, modalPasswordData, isAddModalOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedEquipo) return;
|
if (!selectedEquipo) return;
|
||||||
@@ -60,17 +104,7 @@ const SimpleTable = () => {
|
|||||||
const checkPing = async () => {
|
const checkPing = async () => {
|
||||||
if (!selectedEquipo.ip) return;
|
if (!selectedEquipo.ip) return;
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const data = await equipoService.ping(selectedEquipo.ip);
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
||||||
const response = await fetch(`${BASE_URL}/equipos/ping`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ ip: selectedEquipo.ip }),
|
|
||||||
signal: controller.signal
|
|
||||||
});
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
if (!response.ok) throw new Error('Error en la respuesta');
|
|
||||||
const data = await response.json();
|
|
||||||
if (isMounted) setIsOnline(data.isAlive);
|
if (isMounted) setIsOnline(data.isAlive);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isMounted) setIsOnline(false);
|
if (isMounted) setIsOnline(false);
|
||||||
@@ -79,51 +113,53 @@ const SimpleTable = () => {
|
|||||||
};
|
};
|
||||||
checkPing();
|
checkPing();
|
||||||
const interval = setInterval(checkPing, 10000);
|
const interval = setInterval(checkPing, 10000);
|
||||||
return () => {
|
return () => { isMounted = false; clearInterval(interval); setIsOnline(false); };
|
||||||
isMounted = false;
|
|
||||||
clearInterval(interval);
|
|
||||||
setIsOnline(false);
|
|
||||||
};
|
|
||||||
}, [selectedEquipo]);
|
}, [selectedEquipo]);
|
||||||
|
|
||||||
const handleCloseModal = () => {
|
const handleCloseModal = () => {
|
||||||
|
if (addingComponent) {
|
||||||
|
toast.error("Debes cerrar la ventana de añadir componente primero.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSelectedEquipo(null);
|
setSelectedEquipo(null);
|
||||||
setIsOnline(false);
|
setIsOnline(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedEquipo) {
|
if (selectedEquipo) {
|
||||||
fetch(`${BASE_URL}/equipos/${selectedEquipo.hostname}/historial`)
|
equipoService.getHistory(selectedEquipo.hostname)
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => setHistorial(data.historial))
|
.then(data => setHistorial(data.historial))
|
||||||
.catch(error => console.error('Error fetching history:', error));
|
.catch(error => console.error('Error fetching history:', error));
|
||||||
}
|
}
|
||||||
}, [selectedEquipo]);
|
}, [selectedEquipo]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleScroll = () => setShowScrollButton(window.scrollY > 200);
|
const tableElement = tableContainerRef.current;
|
||||||
window.addEventListener('scroll', handleScroll);
|
const handleScroll = () => {
|
||||||
return () => window.removeEventListener('scroll', handleScroll);
|
if (tableElement) {
|
||||||
}, []);
|
setShowScrollButton(tableElement.scrollTop > 200);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tableElement?.addEventListener('scroll', handleScroll);
|
||||||
|
return () => {
|
||||||
|
tableElement?.removeEventListener('scroll', handleScroll);
|
||||||
|
};
|
||||||
|
}, [isLoading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
Promise.all([
|
Promise.all([
|
||||||
fetch(`${BASE_URL}/equipos`).then(res => res.json()),
|
equipoService.getAll(),
|
||||||
fetch(`${BASE_URL}/sectores`).then(res => res.json())
|
sectorService.getAll()
|
||||||
]).then(([equiposData, sectoresData]) => {
|
]).then(([equiposData, sectoresData]) => {
|
||||||
setData(equiposData);
|
setData(equiposData);
|
||||||
setFilteredData(equiposData);
|
setFilteredData(equiposData);
|
||||||
const sectoresOrdenados = [...sectoresData].sort((a, b) =>
|
const sectoresOrdenados = [...sectoresData].sort((a, b) => a.nombre.localeCompare(b.nombre, 'es', { sensitivity: 'base' }));
|
||||||
a.nombre.localeCompare(b.nombre, 'es', { sensitivity: 'base' })
|
|
||||||
);
|
|
||||||
setSectores(sectoresOrdenados);
|
setSectores(sectoresOrdenados);
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
toast.error("No se pudieron cargar los datos iniciales.");
|
toast.error("No se pudieron cargar los datos iniciales.");
|
||||||
console.error("Error al cargar datos:", error);
|
console.error("Error al cargar datos:", error);
|
||||||
}).finally(() => {
|
}).finally(() => setIsLoading(false));
|
||||||
setIsLoading(false);
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSectorChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
const handleSectorChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
@@ -135,19 +171,22 @@ const SimpleTable = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!modalData || !modalData.sector) return;
|
if (!modalData) return;
|
||||||
const toastId = toast.loading('Guardando...');
|
const toastId = toast.loading('Guardando...');
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${BASE_URL}/equipos/${modalData.id}/sector/${modalData.sector.id}`, { method: 'PATCH' });
|
const sectorId = modalData.sector?.id ?? 0;
|
||||||
if (!response.ok) throw new Error('Error al asociar el sector');
|
await equipoService.updateSector(modalData.id, sectorId);
|
||||||
const updatedData = data.map(e => e.id === modalData.id ? { ...e, sector: modalData.sector } : e);
|
const equipoActualizado = { ...modalData, sector_id: modalData.sector?.id };
|
||||||
setData(updatedData);
|
const updateFunc = (prev: Equipo[]) => prev.map(e => e.id === modalData.id ? equipoActualizado : e);
|
||||||
setFilteredData(updatedData);
|
setData(updateFunc);
|
||||||
|
setFilteredData(updateFunc);
|
||||||
|
if (selectedEquipo && selectedEquipo.id === modalData.id) {
|
||||||
|
setSelectedEquipo(equipoActualizado);
|
||||||
|
}
|
||||||
toast.success('Sector actualizado.', { id: toastId });
|
toast.success('Sector actualizado.', { id: toastId });
|
||||||
setModalData(null);
|
setModalData(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('No se pudo actualizar.', { id: toastId });
|
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
||||||
console.error(error);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -155,39 +194,57 @@ const SimpleTable = () => {
|
|||||||
if (!modalPasswordData) return;
|
if (!modalPasswordData) return;
|
||||||
const toastId = toast.loading('Actualizando...');
|
const toastId = toast.loading('Actualizando...');
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${BASE_URL}/usuarios/${modalPasswordData.id}`, {
|
await usuarioService.updatePassword(modalPasswordData.id, password);
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const usernameToUpdate = modalPasswordData.username;
|
||||||
body: JSON.stringify({ password }),
|
|
||||||
});
|
const newData = data.map(equipo => {
|
||||||
if (!response.ok) {
|
if (!equipo.usuarios.some(u => u.username === usernameToUpdate)) {
|
||||||
const err = await response.json();
|
return equipo;
|
||||||
throw new Error(err.error || 'Error al actualizar');
|
|
||||||
}
|
}
|
||||||
const updatedUser = await response.json();
|
const updatedUsers = equipo.usuarios.map(user =>
|
||||||
const updatedData = data.map(equipo => ({
|
user.username === usernameToUpdate ? { ...user, password: password } : user
|
||||||
...equipo,
|
);
|
||||||
usuarios: equipo.usuarios?.map(user => user.id === updatedUser.id ? { ...user, password } : user)
|
return { ...equipo, usuarios: updatedUsers };
|
||||||
}));
|
});
|
||||||
setData(updatedData);
|
setData(newData);
|
||||||
setFilteredData(updatedData);
|
if (selectedSector === 'Todos') setFilteredData(newData);
|
||||||
toast.success(`Contraseña actualizada.`, { id: toastId });
|
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);
|
setModalPasswordData(null);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const handleRemoveUser = async (hostname: string, username: string) => {
|
const handleRemoveUser = async (hostname: string, username: string) => {
|
||||||
if (!window.confirm(`¿Quitar a ${username} de este equipo?`)) return;
|
if (!window.confirm(`¿Quitar a ${username} de este equipo?`)) return;
|
||||||
const toastId = toast.loading(`Quitando a ${username}...`);
|
const toastId = toast.loading(`Quitando a ${username}...`);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${BASE_URL}/equipos/${hostname}/usuarios/${username}`, { method: 'DELETE' });
|
await usuarioService.removeUserFromEquipo(hostname, username);
|
||||||
const result = await response.json();
|
let equipoActualizado: Equipo | undefined;
|
||||||
if (!response.ok) throw new Error(result.error || 'Error al desasociar');
|
const updateFunc = (prev: Equipo[]) => prev.map(e => {
|
||||||
const updateFunc = (prev: Equipo[]) => prev.map(e => e.hostname === hostname ? { ...e, usuarios: e.usuarios.filter(u => u.username !== username) } : e);
|
if (e.hostname === hostname) {
|
||||||
|
equipoActualizado = { ...e, usuarios: e.usuarios.filter(u => u.username !== username) };
|
||||||
|
return equipoActualizado;
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
});
|
||||||
setData(updateFunc);
|
setData(updateFunc);
|
||||||
setFilteredData(updateFunc);
|
setFilteredData(updateFunc);
|
||||||
|
if (selectedEquipo && equipoActualizado && selectedEquipo.id === equipoActualizado.id) {
|
||||||
|
setSelectedEquipo(equipoActualizado);
|
||||||
|
}
|
||||||
toast.success(`${username} quitado.`, { id: toastId });
|
toast.success(`${username} quitado.`, { id: toastId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
||||||
@@ -198,57 +255,36 @@ const SimpleTable = () => {
|
|||||||
if (!window.confirm('¿Eliminar este equipo y sus relaciones?')) return false;
|
if (!window.confirm('¿Eliminar este equipo y sus relaciones?')) return false;
|
||||||
const toastId = toast.loading('Eliminando equipo...');
|
const toastId = toast.loading('Eliminando equipo...');
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${BASE_URL}/equipos/${id}`, { method: 'DELETE' });
|
await equipoService.deleteManual(id);
|
||||||
if (response.status === 204) {
|
|
||||||
setData(prev => prev.filter(e => e.id !== id));
|
setData(prev => prev.filter(e => e.id !== id));
|
||||||
setFilteredData(prev => prev.filter(e => e.id !== id));
|
setFilteredData(prev => prev.filter(e => e.id !== id));
|
||||||
toast.success('Equipo eliminado.', { id: toastId });
|
toast.success('Equipo eliminado.', { id: toastId });
|
||||||
return true;
|
return true;
|
||||||
}
|
|
||||||
const errorText = await response.text();
|
|
||||||
throw new Error(errorText || 'Error desconocido');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) toast.error(`Error: ${error.message}`, { id: toastId });
|
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveAssociation = async (
|
const handleRemoveAssociation = async (type: 'disco' | 'ram' | 'usuario', associationId: number | { equipoId: number, usuarioId: number }) => {
|
||||||
type: 'disco' | 'ram' | 'usuario',
|
|
||||||
associationId: number | { equipoId: number, usuarioId: number }
|
|
||||||
) => {
|
|
||||||
|
|
||||||
let url = '';
|
|
||||||
let successMessage = '';
|
|
||||||
|
|
||||||
if (type === 'disco' && typeof associationId === 'number') {
|
|
||||||
url = `${BASE_URL}/equipos/asociacion/disco/${associationId}`;
|
|
||||||
successMessage = 'Disco desasociado del equipo.';
|
|
||||||
} else if (type === 'ram' && typeof associationId === 'number') {
|
|
||||||
url = `${BASE_URL}/equipos/asociacion/ram/${associationId}`;
|
|
||||||
successMessage = 'Módulo de RAM desasociado.';
|
|
||||||
} else if (type === 'usuario' && typeof associationId === 'object') {
|
|
||||||
url = `${BASE_URL}/equipos/asociacion/usuario/${associationId.equipoId}/${associationId.usuarioId}`;
|
|
||||||
successMessage = 'Usuario desasociado del equipo.';
|
|
||||||
} else {
|
|
||||||
return; // No hacer nada si los parámetros son incorrectos
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!window.confirm('¿Estás seguro de que quieres eliminar esta asociación manual?')) return;
|
if (!window.confirm('¿Estás seguro de que quieres eliminar esta asociación manual?')) return;
|
||||||
|
|
||||||
const toastId = toast.loading('Eliminando asociación...');
|
const toastId = toast.loading('Eliminando asociación...');
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, { method: 'DELETE' });
|
let successMessage = '';
|
||||||
|
if (type === 'disco' && typeof associationId === 'number') {
|
||||||
if (!response.ok) {
|
await equipoService.removeDiscoAssociation(associationId);
|
||||||
const errorData = await response.json();
|
successMessage = 'Disco desasociado del equipo.';
|
||||||
throw new Error(errorData.message || `Error al eliminar la asociación.`);
|
} 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');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Actualizar el estado local para reflejar el cambio inmediatamente
|
|
||||||
const updateState = (prevEquipos: Equipo[]) => prevEquipos.map(equipo => {
|
const updateState = (prevEquipos: Equipo[]) => prevEquipos.map(equipo => {
|
||||||
if (equipo.id !== selectedEquipo?.id) return equipo;
|
if (equipo.id !== selectedEquipo?.id) return equipo;
|
||||||
|
|
||||||
let updatedEquipo = { ...equipo };
|
let updatedEquipo = { ...equipo };
|
||||||
if (type === 'disco' && typeof associationId === 'number') {
|
if (type === 'disco' && typeof associationId === 'number') {
|
||||||
updatedEquipo.discos = equipo.discos.filter(d => d.equipoDiscoId !== associationId);
|
updatedEquipo.discos = equipo.discos.filter(d => d.equipoDiscoId !== associationId);
|
||||||
@@ -259,132 +295,92 @@ const SimpleTable = () => {
|
|||||||
}
|
}
|
||||||
return updatedEquipo;
|
return updatedEquipo;
|
||||||
});
|
});
|
||||||
|
|
||||||
setData(updateState);
|
setData(updateState);
|
||||||
setFilteredData(updateState);
|
setFilteredData(updateState);
|
||||||
setSelectedEquipo(prev => prev ? updateState([prev])[0] : null); // Actualiza también el modal
|
setSelectedEquipo(prev => prev ? updateState([prev])[0] : null);
|
||||||
|
if (selectedEquipo) {
|
||||||
|
await refreshHistory(selectedEquipo.hostname);
|
||||||
|
}
|
||||||
toast.success(successMessage, { id: toastId });
|
toast.success(successMessage, { id: toastId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
||||||
toast.error(error.message, { id: toastId });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateEquipo = async (nuevoEquipo: Omit<Equipo, 'id' | 'created_at' | 'updated_at'>) => {
|
const handleCreateEquipo = async (nuevoEquipo: Omit<Equipo, 'id' | 'created_at' | 'updated_at'>) => {
|
||||||
const toastId = toast.loading('Creando nuevo equipo...');
|
const toastId = toast.loading('Creando nuevo equipo...');
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${BASE_URL}/equipos/manual`, {
|
const equipoCreado = await equipoService.createManual(nuevoEquipo);
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(nuevoEquipo),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.message || 'Error al crear el equipo.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const equipoCreado = await response.json();
|
|
||||||
|
|
||||||
// Actualizamos el estado local para ver el nuevo equipo inmediatamente
|
|
||||||
setData(prev => [...prev, equipoCreado]);
|
setData(prev => [...prev, equipoCreado]);
|
||||||
setFilteredData(prev => [...prev, equipoCreado]);
|
setFilteredData(prev => [...prev, equipoCreado]);
|
||||||
|
|
||||||
toast.success(`Equipo '${equipoCreado.hostname}' creado.`, { id: toastId });
|
toast.success(`Equipo '${equipoCreado.hostname}' creado.`, { id: toastId });
|
||||||
setIsAddModalOpen(false); // Cerramos el modal
|
setIsAddModalOpen(false);
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
toast.error(error.message, { id: toastId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditEquipo = async (id: number, equipoEditado: Omit<Equipo, 'id' | 'created_at' | 'updated_at'>) => {
|
|
||||||
const toastId = toast.loading('Guardando cambios...');
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${BASE_URL}/equipos/manual/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(equipoEditado),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.message || 'Error al actualizar el equipo.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Actualizar el estado local para reflejar los cambios
|
|
||||||
const updateState = (prevEquipos: Equipo[]) => prevEquipos.map(equipo => {
|
|
||||||
if (equipo.id === id) {
|
|
||||||
return { ...equipo, ...equipoEditado };
|
|
||||||
}
|
|
||||||
return equipo;
|
|
||||||
});
|
|
||||||
|
|
||||||
setData(updateState);
|
|
||||||
setFilteredData(updateState);
|
|
||||||
setSelectedEquipo(prev => prev ? updateState([prev])[0] : null);
|
|
||||||
|
|
||||||
toast.success('Equipo actualizado.', { id: toastId });
|
|
||||||
return true; // Indica que el guardado fue exitoso
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
toast.error(error.message, { id: toastId });
|
|
||||||
}
|
|
||||||
return false; // Indica que el guardado falló
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddComponent = async (type: 'disco' | 'ram' | 'usuario', data: any) => {
|
|
||||||
if (!selectedEquipo) return;
|
|
||||||
|
|
||||||
const toastId = toast.loading(`Añadiendo ${type}...`);
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${BASE_URL}/equipos/manual/${selectedEquipo.id}/${type}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.message || `Error al añadir ${type}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refrescar los datos del equipo para ver el cambio
|
|
||||||
const refreshedEquipo = await (await fetch(`${BASE_URL}/equipos/${selectedEquipo.hostname}`)).json();
|
|
||||||
|
|
||||||
const updateState = (prev: Equipo[]) => prev.map(e => e.id === selectedEquipo.id ? refreshedEquipo : e);
|
|
||||||
setData(updateState);
|
|
||||||
setFilteredData(updateState);
|
|
||||||
setSelectedEquipo(refreshedEquipo);
|
|
||||||
|
|
||||||
toast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} añadido.`, { id: toastId });
|
|
||||||
setAddingComponent(null); // Cerrar modal
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = [
|
const handleEditEquipo = async (id: number, equipoEditado: any) => {
|
||||||
{ header: "ID", accessorKey: "id", enableHiding: true },
|
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",
|
header: "Nombre", accessorKey: "hostname",
|
||||||
cell: ({ row }: CellContext<Equipo, any>) => (<button onClick={() => setSelectedEquipo(row.original)} className={styles.hostnameButton}>{row.original.hostname}</button>)
|
cell: (info: CellContext<Equipo, any>) => (<button onClick={() => setSelectedEquipo(info.row.original)} className={styles.hostnameButton}>{info.row.original.hostname}</button>)
|
||||||
},
|
},
|
||||||
{ header: "IP", accessorKey: "ip" },
|
{ header: "IP", accessorKey: "ip", id: 'ip' },
|
||||||
{ header: "MAC", accessorKey: "mac", enableHiding: true },
|
{ header: "MAC", accessorKey: "mac" },
|
||||||
{ header: "Motherboard", accessorKey: "motherboard" },
|
{ header: "Motherboard", accessorKey: "motherboard" },
|
||||||
{ header: "CPU", accessorKey: "cpu" },
|
{ header: "CPU", accessorKey: "cpu" },
|
||||||
{ header: "RAM", accessorKey: "ram_installed" },
|
{ 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: "Discos", accessorFn: (row: Equipo) => row.discos?.map(d => `${d.mediatype} ${d.size}GB`).join(" | ") || "Sin discos" },
|
||||||
{ header: "OS", accessorKey: "os" },
|
{ header: "OS", accessorKey: "os" },
|
||||||
{ header: "Arquitectura", accessorKey: "architecture" },
|
{ header: "Arquitectura", accessorKey: "architecture", id: 'arch' },
|
||||||
{
|
{
|
||||||
header: "Usuarios y Claves",
|
header: "Usuarios y Claves",
|
||||||
cell: ({ row }: CellContext<Equipo, any>) => {
|
id: 'usuarios',
|
||||||
|
cell: (info: CellContext<Equipo, any>) => {
|
||||||
|
const { row } = info;
|
||||||
const usuarios = row.original.usuarios || [];
|
const usuarios = row.original.usuarios || [];
|
||||||
return (
|
return (
|
||||||
<div className={styles.userList}>
|
<div className={styles.userList}>
|
||||||
@@ -400,7 +396,7 @@ const SimpleTable = () => {
|
|||||||
className={styles.tableButton}
|
className={styles.tableButton}
|
||||||
data-tooltip-id={`edit-${u.id}`}
|
data-tooltip-id={`edit-${u.id}`}
|
||||||
>
|
>
|
||||||
✏️
|
<KeyRound size={16} />
|
||||||
<Tooltip id={`edit-${u.id}`} className={styles.tooltip} place="top">Cambiar Clave</Tooltip>
|
<Tooltip id={`edit-${u.id}`} className={styles.tooltip} place="top">Cambiar Clave</Tooltip>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -409,7 +405,7 @@ const SimpleTable = () => {
|
|||||||
className={styles.deleteUserButton}
|
className={styles.deleteUserButton}
|
||||||
data-tooltip-id={`remove-${u.id}`}
|
data-tooltip-id={`remove-${u.id}`}
|
||||||
>
|
>
|
||||||
🗑️
|
<UserX size={16} />
|
||||||
<Tooltip id={`remove-${u.id}`} className={styles.tooltip} place="top">Eliminar Usuario</Tooltip>
|
<Tooltip id={`remove-${u.id}`} className={styles.tooltip} place="top">Eliminar Usuario</Tooltip>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -421,12 +417,13 @@ const SimpleTable = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "Sector", id: 'sector', accessorFn: (row: Equipo) => row.sector?.nombre || 'Asignar',
|
header: "Sector", id: 'sector', accessorFn: (row: Equipo) => row.sector?.nombre || 'Asignar',
|
||||||
cell: ({ row }: CellContext<Equipo, any>) => {
|
cell: (info: CellContext<Equipo, any>) => {
|
||||||
|
const { row } = info;
|
||||||
const sector = row.original.sector;
|
const sector = row.original.sector;
|
||||||
return (
|
return (
|
||||||
<div className={styles.sectorContainer}>
|
<div className={styles.sectorContainer}>
|
||||||
<span className={`${styles.sectorName} ${sector ? styles.sectorNameAssigned : styles.sectorNameUnassigned}`}>{sector?.nombre || 'Asignar'}</span>
|
<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.id}`}>✏️<Tooltip id={`editSector-${row.id}`} className={styles.tooltip} place="top">Cambiar Sector</Tooltip></button>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -436,48 +433,66 @@ const SimpleTable = () => {
|
|||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: filteredData,
|
data: filteredData,
|
||||||
columns,
|
columns,
|
||||||
|
columnResizeMode: 'onChange',
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
onColumnSizingChange: setColumnSizing,
|
||||||
|
filterFns: {
|
||||||
|
accentInsensitive: accentInsensitiveFilter,
|
||||||
|
},
|
||||||
|
globalFilterFn: 'accentInsensitive',
|
||||||
initialState: {
|
initialState: {
|
||||||
sorting: [
|
sorting: [
|
||||||
{ id: 'sector', desc: false },
|
{ id: 'sector', desc: false },
|
||||||
{ id: 'hostname', desc: false }
|
{ id: 'hostname', desc: false }
|
||||||
],
|
],
|
||||||
columnVisibility: { id: false, mac: false },
|
|
||||||
pagination: {
|
pagination: {
|
||||||
pageSize: 15, // Mostrar 15 filas por página por defecto
|
pageSize: 15,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
state: {
|
state: {
|
||||||
globalFilter,
|
globalFilter,
|
||||||
|
columnVisibility,
|
||||||
|
columnSizing,
|
||||||
},
|
},
|
||||||
onGlobalFilterChange: setGlobalFilter,
|
onGlobalFilterChange: setGlobalFilter,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
<div>
|
||||||
<h2>Cargando Equipos...</h2>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const PaginacionControles = (
|
const PaginacionControles = (
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1rem 0' }}>
|
<div className={styles.paginationControls}>
|
||||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
||||||
<button onClick={() => table.setPageIndex(0)} disabled={!table.getCanPreviousPage()} className={styles.tableButton}>
|
<button onClick={() => table.setPageIndex(0)} disabled={!table.getCanPreviousPage()} className={styles.tableButton}>
|
||||||
{'<<'}
|
<ChevronsLeft size={18} />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} className={styles.tableButton}>
|
<button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} className={styles.tableButton}>
|
||||||
{'<'}
|
<ChevronLeft size={18} />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} className={styles.tableButton}>
|
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} className={styles.tableButton}>
|
||||||
{'>'}
|
<ChevronRight size={18} />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => table.setPageIndex(table.getPageCount() - 1)} disabled={!table.getCanNextPage()} className={styles.tableButton}>
|
<button onClick={() => table.setPageIndex(table.getPageCount() - 1)} disabled={!table.getCanNextPage()} className={styles.tableButton}>
|
||||||
{'>>'}
|
<ChevronsRight size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span>
|
<span>
|
||||||
@@ -517,13 +532,15 @@ const SimpleTable = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<div className={styles.header}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<h2>Equipos ({table.getFilteredRowModel().rows.length})</h2>
|
<h2>Equipos ({table.getFilteredRowModel().rows.length})</h2>
|
||||||
<button
|
<button
|
||||||
className={`${styles.btn} ${styles.btnPrimary}`}
|
className={`${styles.btn} ${styles.btnPrimary}`}
|
||||||
onClick={() => setIsAddModalOpen(true)}
|
onClick={() => setIsAddModalOpen(true)}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
|
||||||
>
|
>
|
||||||
+ Añadir Equipo
|
<PlusCircle size={18} /> Añadir Equipo
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.controlsContainer}>
|
<div className={styles.controlsContainer}>
|
||||||
@@ -534,20 +551,52 @@ const SimpleTable = () => {
|
|||||||
<option value="Asignar">-Asignar-</option>
|
<option value="Asignar">-Asignar-</option>
|
||||||
{sectores.map(s => (<option key={s.id} value={s.nombre}>{s.nombre}</option>))}
|
{sectores.map(s => (<option key={s.id} value={s.nombre}>{s.nombre}</option>))}
|
||||||
</select>
|
</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>
|
||||||
|
|
||||||
{/* --- 2. Renderizar los controles ANTES de la tabla --- */}
|
<div style={{ position: 'relative' }}>
|
||||||
{PaginacionControles}
|
<div ref={tableContainerRef} className={styles.tableContainer} style={{ maxHeight: '70vh' }}>
|
||||||
|
<table className={styles.table} style={{ width: table.getTotalSize() }}>
|
||||||
<div style={{ overflowX: 'auto', maxHeight: '70vh', border: '1px solid #dee2e6', borderRadius: '8px' }}>
|
|
||||||
<table className={styles.table}>
|
|
||||||
<thead>
|
<thead>
|
||||||
{table.getHeaderGroups().map(hg => (
|
{table.getHeaderGroups().map(hg => (
|
||||||
<tr key={hg.id}>
|
<tr key={hg.id}>
|
||||||
{hg.headers.map(h => (
|
{hg.headers.map(h => (
|
||||||
<th key={h.id} className={styles.th}>
|
<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())}
|
{flexRender(h.column.columnDef.header, h.getContext())}
|
||||||
{h.column.getIsSorted() && (<span className={styles.sortIndicator}>{h.column.getIsSorted() === 'asc' ? '⇑' : '⇓'}</span>)}
|
{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>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -557,7 +606,7 @@ const SimpleTable = () => {
|
|||||||
{table.getRowModel().rows.map(row => (
|
{table.getRowModel().rows.map(row => (
|
||||||
<tr key={row.id} className={styles.tr}>
|
<tr key={row.id} className={styles.tr}>
|
||||||
{row.getVisibleCells().map(cell => (
|
{row.getVisibleCells().map(cell => (
|
||||||
<td key={cell.id} className={styles.td}>
|
<td key={cell.id} style={{ width: cell.column.getSize() }} className={styles.td}>
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
@@ -566,29 +615,22 @@ const SimpleTable = () => {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{showScrollButton && (
|
||||||
|
<button
|
||||||
|
onClick={() => tableContainerRef.current?.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||||
|
className={styles.scrollToTop}
|
||||||
|
title="Volver al inicio"
|
||||||
|
>
|
||||||
|
<ArrowUp size={20} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* --- 3. Renderizar los controles DESPUÉS de la tabla --- */}
|
|
||||||
{PaginacionControles}
|
{PaginacionControles}
|
||||||
|
|
||||||
{showScrollButton && (<button onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })} className={styles.scrollToTop} title="Volver arriba">↑</button>)}
|
{modalData && <ModalEditarSector modalData={modalData} setModalData={setModalData} sectores={sectores} onClose={() => setModalData(null)} onSave={handleSave} />}
|
||||||
|
|
||||||
{modalData && (
|
{modalPasswordData && <ModalCambiarClave usuario={modalPasswordData} onClose={() => setModalPasswordData(null)} onSave={handleSavePassword} />}
|
||||||
<ModalEditarSector
|
|
||||||
modalData={modalData}
|
|
||||||
setModalData={setModalData}
|
|
||||||
sectores={sectores}
|
|
||||||
onClose={() => setModalData(null)}
|
|
||||||
onSave={handleSave}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{modalPasswordData && (
|
|
||||||
<ModalCambiarClave
|
|
||||||
usuario={modalPasswordData}
|
|
||||||
onClose={() => setModalPasswordData(null)}
|
|
||||||
onSave={handleSavePassword}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedEquipo && (
|
{selectedEquipo && (
|
||||||
<ModalDetallesEquipo
|
<ModalDetallesEquipo
|
||||||
@@ -601,20 +643,17 @@ const SimpleTable = () => {
|
|||||||
onEdit={handleEditEquipo}
|
onEdit={handleEditEquipo}
|
||||||
sectores={sectores}
|
sectores={sectores}
|
||||||
onAddComponent={type => setAddingComponent(type)}
|
onAddComponent={type => setAddingComponent(type)}
|
||||||
|
isChildModalOpen={addingComponent !== null}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAddModalOpen && (
|
{isAddModalOpen && <ModalAnadirEquipo sectores={sectores} onClose={() => setIsAddModalOpen(false)} onSave={handleCreateEquipo} />}
|
||||||
<ModalAnadirEquipo
|
|
||||||
sectores={sectores}
|
|
||||||
onClose={() => setIsAddModalOpen(false)}
|
|
||||||
onSave={handleCreateEquipo}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{addingComponent === 'disco' && <ModalAnadirDisco onClose={() => setAddingComponent(null)} onSave={(data) => handleAddComponent('disco', data)} />}
|
{addingComponent === 'disco' && <ModalAnadirDisco onClose={() => setAddingComponent(null)} onSave={(data: { mediatype: string, size: number }) => handleAddComponent('disco', data)} />}
|
||||||
{addingComponent === 'ram' && <ModalAnadirRam onClose={() => setAddingComponent(null)} onSave={(data) => handleAddComponent('ram', data)} />}
|
|
||||||
{addingComponent === 'usuario' && <ModalAnadirUsuario onClose={() => setAddingComponent(null)} onSave={(data) => handleAddComponent('usuario', 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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
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;
|
||||||
|
};
|
||||||
@@ -1,23 +1,77 @@
|
|||||||
/* Limpieza básica y configuración de fuente */
|
/* 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 {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||||
background-color: #f8f9fa;
|
background-color: var(--color-background);
|
||||||
color: #212529;
|
color: var(--color-text-primary);
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease; /* Transición suave al cambiar de tema */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Estilos de la scrollbar que estaban en index.html */
|
|
||||||
body::-webkit-scrollbar {
|
body::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
background-color: #f1f1f1;
|
background-color: var(--scrollbar-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
body::-webkit-scrollbar-thumb {
|
body::-webkit-scrollbar-thumb {
|
||||||
background-color: #888;
|
background-color: var(--scrollbar-thumb);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Clase para bloquear el scroll cuando un modal está abierto */
|
|
||||||
body.scroll-lock {
|
body.scroll-lock {
|
||||||
padding-right: 8px !important;
|
padding-right: 8px !important;
|
||||||
overflow: hidden !important;
|
overflow: hidden !important;
|
||||||
|
|||||||
@@ -3,15 +3,18 @@ import React from 'react'
|
|||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import { Toaster } from 'react-hot-toast' // Importamos el Toaster
|
import { Toaster } from 'react-hot-toast'
|
||||||
|
import { ThemeProvider } from './context/ThemeContext';
|
||||||
|
import { AuthProvider } from './context/AuthContext';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<AuthProvider>
|
||||||
|
<ThemeProvider>
|
||||||
<App />
|
<App />
|
||||||
<Toaster
|
<Toaster
|
||||||
position="bottom-right" // Posición de las notificaciones
|
position="bottom-right"
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
// Estilos por defecto para las notificaciones
|
|
||||||
success: {
|
success: {
|
||||||
style: {
|
style: {
|
||||||
background: '#28a745',
|
background: '#28a745',
|
||||||
@@ -26,5 +29,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</ThemeProvider>
|
||||||
|
</AuthProvider>
|
||||||
</React.StrictMode>,
|
</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>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,3 +81,22 @@ export interface Equipo {
|
|||||||
memoriasRam: MemoriaRamEquipoDetalle[];
|
memoriasRam: MemoriaRamEquipoDetalle[];
|
||||||
historial: HistorialEquipo[];
|
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);
|
||||||
|
};
|
||||||
@@ -4,4 +4,16 @@ import react from '@vitejs/plugin-react'
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
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