Compare commits

...

25 Commits

Author SHA1 Message Date
06be3a10da Fix PS1 Credenciales de Acceso 2025-10-21 13:53:03 -03:00
840df270cf Fix RESEED InventarioDB.sql 2025-10-13 11:22:53 -03:00
7c2c328032 Add InventarioDB.sql 2025-10-13 11:21:32 -03:00
7effa58844 Actualizar README.md 2025-10-13 14:19:04 +00:00
bc9a9906c3 feat: Sistema de autenticación por JWT
ste commit introduce un sistema completo de autenticación basado en JSON Web Tokens (JWT) para proteger los endpoints de la API y gestionar el acceso de los usuarios a la aplicación.

**Cambios en el Backend (ASP.NET Core):**

-   Se ha creado un nuevo `AuthController` con un endpoint `POST /api/auth/login` para validar las credenciales del usuario.
-   Implementada la generación de tokens JWT con una clave secreta y emisor/audiencia configurables desde `appsettings.json`.
-   Se ha añadido una lógica de expiración dinámica para los tokens:
    -   **6 horas** para sesiones temporales (si el usuario no marca "Mantener sesión").
    -   **1 año** para sesiones persistentes.
-   Se han protegido todos los controladores existentes (`EquiposController`, `SectoresController`, etc.) con el atributo `[Authorize]`, requiriendo un token válido para su acceso.
-   Actualizada la configuración de Swagger para incluir un campo de autorización "Bearer Token", facilitando las pruebas de los endpoints protegidos desde la UI.

**Cambios en el Frontend (React):**

-   Se ha creado un componente `Login.tsx` que actúa como la puerta de entrada a la aplicación.
-   Implementado un `AuthContext` para gestionar el estado global de autenticación (`isAuthenticated`, `token`, `isLoading`).
-   Añadida la funcionalidad "Mantener sesión iniciada" a través de un checkbox en el formulario de login.
    -   Si está marcado, el token se guarda en `localStorage`.
    -   Si está desmarcado, el token se guarda en `sessionStorage` (la sesión se cierra al cerrar el navegador/pestaña).
-   La función `request` en `apiService.ts` ha sido refactorizada para inyectar automáticamente el `Authorization: Bearer <token>` en todas las peticiones a la API.
-   Se ha añadido un botón de "Cerrar Sesión" en la barra de navegación que limpia el token y redirige al login.
-   Corregido un bug que provocaba un bucle de recarga infinito después de un inicio de sesión exitoso debido a una condición de carrera.
2025-10-13 10:40:20 -03:00
acf2f9a35c Fix Modal Unificar Modo Oscuro 2025-10-13 09:43:24 -03:00
a32f0467ef feat: Mejoras de usabilidad, filtros y consistencia visual
*   **Filtrado Insensible a Acentos y Mayúsculas:**
    *   Se implementa un filtrado global en todas las tablas (`Equipos`, `Componentes`, `Sectores`) que ignora las tildes y no distingue entre mayúsculas y minúsculas.
    *   Se crea una función de utilidad `accentInsensitiveFilter` para normalizar el texto antes de la comparación.
    *   Se añade la ampliación de módulo (`tanstack-table.d.ts`) para integrar de forma segura el nuevo tipo de filtro con TypeScript.

*   **Refactor y Unificación de la Vista de Sectores:**
    *   Se refactoriza por completo la vista "Gestión de Sectores" para utilizar `React Table` (`@tanstack/react-table`).
    *   Ahora cuenta con las mismas funcionalidades que las otras vistas: ordenación por columnas, filtrado de texto, estado de carga con `TableSkeleton` y un diseño de controles unificado.

*   **Ajustes de Diseño y Layout:**
    *   Se corrige el layout de las tablas en "Gestión de Componentes" y "Gestión de Sectores" para que la columna "Acciones" (y "Nº de Equipos") ocupe solo el espacio mínimo necesario, permitiendo que la columna principal se expanda.
    *   Se eliminan estilos en línea en favor de clases de módulo CSS, asegurando que el diseño sea consistente y respete el sistema de temas (claro/oscuro).

*   **Corrección del Botón "Scroll to Top":**
    *   Se soluciona definitivamente el problema del botón para volver arriba en la tabla de equipos.
    *   Se ajusta la estructura JSX y se corrige el `useEffect` para que el listener de scroll se asigne correctamente después de que los datos hayan cargado, asegurando que el botón aparezca y se posicione de forma fija sobre el contenedor de la tabla.
2025-10-10 20:37:50 -03:00
2b2cc873e5 Feat Tabla
- Selector de Columnas
- Control de Ancho de Columnas
- Botón Volver Arriba
- Refinamiento de Modo Oscuro
2025-10-10 20:02:11 -03:00
d9da1c82c9 Feat Modo Oscuro y Otras Estéticas 2025-10-09 17:03:53 -03:00
5f72f30931 feat: Implementa Dashboard de estadísticas visuales del inventario
Se añade una nueva sección "Dashboard" para proporcionar una visión general y analítica del inventario de IT. Esta vista transforma los datos brutos en gráficos interactivos, facilitando la toma de decisiones y la comprensión del estado del parque informático.

La implementación se realizó de forma iterativa, refinando tanto la obtención de datos como la presentación visual para una mejor experiencia de usuario.

**Principales Cambios:**

**1. Backend:**
- Se crea un nuevo `DashboardController` con el endpoint `GET /api/dashboard/stats`.
- Se implementan consultas SQL agregadas para obtener estadísticas por:
  - Sistema Operativo.
  - Cantidad de equipos por Sector (mostrando todos los sectores).
  - Modelos de CPU (mostrando todos los modelos).
  - Cantidad de Memoria RAM instalada (GB).

**2. Frontend:**
- Se integran las librerías `chart.js` y `react-chartjs-2` para la visualización de datos.
- Se añade una nueva vista "Dashboard" accesible desde la barra de navegación principal.
- Se crean componentes reutilizables para cada tipo de gráfico: `OsChart`, `RamChart`, `CpuChart` y `SectorChart`.
- Se diseña un layout responsivo en formato de grilla 2x2 para una visualización equilibrada de los cuatro gráficos.

**3. Mejoras de Experiencia de Usuario (UX):**
- Se utilizan gráficos de barras horizontales (`indexAxis: 'y'`) para mejorar la legibilidad de etiquetas largas, como los modelos de CPU y nombres de sectores.
- Se implementan contenedores con scroll vertical (`overflow-y: auto`) para los gráficos de CPU y Sectores. Esto permite mostrar la totalidad de los datos sin comprometer el diseño ni crear gráficos excesivamente grandes.
- Se calcula una altura dinámica para los gráficos de barras para que se adapten a la cantidad de datos que contienen, mejorando la presentación.
2025-10-09 13:29:29 -03:00
8162d59331 Fix Ortografía 2025-10-09 11:36:27 -03:00
6dd3c672d8 Fix Min Font Size 2025-10-09 11:32:56 -03:00
3893f917fc feat: Mejora reactividad de la UI y funcionalidad de tablas
- **Corrección de Sincronización de Estado:** Se soluciona un problema crítico donde las modificaciones de datos (ej. cambiar una contraseña o eliminar una asociación) no se reflejaban visualmente en la tabla hasta que se recargaba la página. Se ha refactorizado el manejo del estado para garantizar que todos los componentes se actualicen instantáneamente después de una acción.

- **Actualización Global de Contraseñas:** Se mejora la lógica de actualización de contraseñas. Ahora, al cambiar la clave de un usuario, el cambio se refleja en **todos** los equipos a los que dicho usuario está asociado en la tabla, no solo en la fila desde donde se inició la acción.

- **Mejoras en Gestión de Componentes:**
  - Se implementa la librería `@tanstack/react-table` en el componente `GestionComponentes.tsx`.
  - La tabla de "Administración" ahora cuenta con filtrado global de registros y ordenamiento por columnas, mejorando su usabilidad y manteniendo consistencia con la tabla principal de equipos.

- **Corrección de Tipos en TypeScript:** Se resuelven errores de tipo (`Property 'id' does not exist on type 'never'`) en la definición de las columnas de la tabla (`SimpleTable.tsx`) mediante el tipado explícito con `CellContext`, mejorando la robustez y la experiencia de desarrollo.
2025-10-09 11:29:41 -03:00
bb3144a71b Actualizar README.md 2025-10-09 13:46:57 +00:00
ffd17f0cde Fix Usuario Clave 2025-10-09 10:30:49 -03:00
f50658c2d8 Fix ps1 2025-10-08 15:49:22 -03:00
15d44aaf58 Fix AsociarUsuario 2025-10-08 14:46:27 -03:00
bcd682484b Fix UTC Time 2025-10-08 14:23:42 -03:00
9a6d4f0437 Fix: Ram y PS1 2025-10-08 14:03:40 -03:00
afd378712c Feat: PS1 Añadido - Fix: Eliminación de Equipos 2025-10-08 13:51:27 -03:00
268c1c2bf9 Mejoras integrales en UI, lógica de negocio y auditoría
Este commit introduce una serie de mejoras significativas en toda la aplicación, abordando la experiencia de usuario, la consistencia de los datos, la robustez del backend y la implementación de un historial de cambios completo.

 **Funcionalidades y Mejoras (Features & Enhancements)**

*   **Historial de Auditoría Completo:**
    *   Se implementa el registro en el historial para todas las acciones CRUD manuales: creación de equipos, adición y eliminación de discos, RAM y usuarios.
    *   Los cambios de campos simples (IP, Hostname, etc.) ahora también se registran detalladamente.

*   **Consistencia de Datos Mejorada:**
    *   **RAM:** La selección de RAM en el modal de "Añadir RAM" y la vista de "Administración" ahora agrupan los módulos por especificaciones (Fabricante, Tamaño, Velocidad), eliminando las entradas duplicadas causadas por diferentes `part_number`.
    *   **Arquitectura:** El campo de edición para la arquitectura del equipo se ha cambiado de un input de texto a un selector con las opciones fijas "32 bits" y "64 bits".

*   **Experiencia de Usuario (UX) Optimizada:**
    *   El botón de "Wake On Lan" (WOL) ahora se deshabilita visualmente si el equipo no tiene una dirección MAC registrada.
    *   Se corrige el apilamiento de modales: los sub-modales (Añadir Disco/RAM/Usuario) ahora siempre aparecen por encima del modal principal de detalles y bloquean su cierre.
    *   El historial de cambios se actualiza en tiempo real en la interfaz después de añadir o eliminar un componente, sin necesidad de cerrar y reabrir el modal.

🐛 **Correcciones (Bug Fixes)**

*   **Actualización de Estado en Vivo:** Al añadir/eliminar un módulo de RAM, los campos "RAM Instalada" y "Última Actualización" ahora se recalculan en el backend y se actualizan instantáneamente en el frontend.
*   **Historial de Sectores Legible:** Se corrige el registro del historial para que al cambiar un sector se guarde el *nombre* del sector (ej. "Técnica") en lugar de su ID numérico.
*   **Formulario de Edición:** El dropdown de "Sector" en el modo de edición ahora preselecciona correctamente el sector asignado actualmente al equipo.
*   **Error Crítico al Añadir RAM:** Se soluciona un error del servidor (`Sequence contains more than one element`) que ocurría al añadir manualmente un tipo de RAM que ya existía con múltiples `part_number`. Se reemplazó `QuerySingleOrDefaultAsync` por `QueryFirstOrDefaultAsync` para mayor robustez.
*   **Eliminación Segura:** Se impide la eliminación de un sector si este tiene equipos asignados, protegiendo la integridad de los datos.

♻️ **Refactorización (Refactoring)**

*   **Servicio de API Centralizado:** Toda la lógica de llamadas `fetch` del frontend ha sido extraída de los componentes y centralizada en un único servicio (`apiService.ts`), mejorando drásticamente la mantenibilidad y organización del código.
*   **Optimización de Renders:** Se ha optimizado el rendimiento de los modales mediante el uso del hook `useCallback` para memorizar funciones que se pasan como props.
*   **Nulabilidad en C#:** Se han resuelto múltiples advertencias de compilación (`CS8620`) en el backend al especificar explícitamente los tipos de referencia anulables (`string?`), mejorando la seguridad de tipos del código.
2025-10-08 13:27:44 -03:00
177ad55962 Fix Ruta nginx.conf 2025-10-07 15:36:36 -03:00
248f5baa60 Feat .dockerignore 2025-10-07 15:30:25 -03:00
3b3ab53ac7 Fix Dockerfiles 2025-10-07 15:24:18 -03:00
be7b6a732d Deat Dockerfiles y Fix Base_URL 2025-10-07 15:18:11 -03:00
64 changed files with 3805 additions and 1043 deletions

13
.dockerignore Normal file
View 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
View 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

View File

@@ -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,46 +91,49 @@ 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
1. En una nueva terminal, navegue al directorio del frontend: `cd frontend`. 1. En una nueva terminal, navegue al directorio del frontend: `cd frontend`.
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).
--- ---

View File

@@ -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();
} }
} }

View 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);
}
}
}

View 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);
}
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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);
}
}
} }
} }

View File

@@ -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)
{ {

View File

@@ -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
View 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"]

View File

@@ -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);
}
} }
} }

View File

@@ -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">

View File

@@ -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";

View File

@@ -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; }

View File

@@ -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();

View File

@@ -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,

View File

@@ -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",

View File

@@ -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")]

View File

@@ -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, )"

View File

@@ -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
View 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
View 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;"]

View 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

View File

@@ -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",

View File

@@ -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
View 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;
}
}

View File

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -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;
} }

View File

@@ -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>
</> </>
); );

View File

@@ -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) => (

View 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;

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

View 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;

View File

@@ -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'}}> 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}>

View File

@@ -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

View 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;

View File

@@ -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>

View File

@@ -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}>

View File

@@ -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>

View File

@@ -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)}

View File

@@ -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>
); );

View File

@@ -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>
); );

View 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;

View 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;

View 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;

View File

@@ -1,53 +1,120 @@
/* 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;
} }
.tooltip{ .tooltip {
z-index: 9999; z-index: 9999;
} }
@@ -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 {
@@ -269,12 +430,13 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1004; z-index: 1004;
box-shadow: 0 2px 5px rgba(0,0,0,0.2); box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
transition: transform 0.2s, background-color 0.2s; transition: transform 0.2s, background-color 0.2s;
position: fixed; position: fixed;
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,19 +462,19 @@
} }
.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);
} }
.sectionTitle { .sectionTitle {
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 */
}

View File

@@ -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>
); );
}; };

View 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%);
}
}

View 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;

View 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);
}

View 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;

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

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

View File

@@ -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;

View File

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

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

View File

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

View 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);
};

View File

@@ -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
View 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 {}
}
}