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.
### 🔒 Seguridad y Autenticación
- **Autenticación Basada en JWT:** Acceso a la API protegido mediante JSON Web Tokens.
- **Login Simple y Seguro:** Un único par de credenciales (usuario/contraseña) configurables en el backend para acceder a toda la aplicación.
- **Sesión Persistente Opcional:** En la pantalla de login, el usuario puede elegir "Mantener sesión iniciada".
- **Si se marca:** El token se almacena de forma persistente (`localStorage`), sobreviviendo a cierres del navegador. El token tiene una validez de **1 año**.
- **Si no se marca:** El token se almacena en la sesión del navegador (`sessionStorage`), cerrándose automáticamente al finalizar la sesión. El token tiene una validez de **6 horas**.
### 📋 Módulo de Equipos
- **Vista Principal Centralizada:** Una tabla paginada, con capacidad de búsqueda y filtrado por sector, que muestra todos los equipos del inventario.
- **Detalle Completo del Equipo:** Al hacer clic en un equipo, se abre una vista detallada con toda su información:
- **Datos Principales:** Hostname, IP, MAC, CPU, Motherboard, RAM instalada, OS, etc.
- **Componentes Asociados:** Lista detallada de discos, módulos de RAM y usuarios asociados.
- **Datos Principales:** Hostname, IP, MAC, OS, etc.
- **Componentes Asociados:** Lista detallada de discos, módulos de RAM, usuarios asociados, CPU, Motherboard, RAM instalada, etc.
- **Estado en Tiempo Real:** Indicador visual que muestra si el equipo está `En línea` o `Sin conexión` mediante un ping.
- **Historial de Cambios:** Un registro cronológico de todas las modificaciones realizadas en el equipo.
- **Acciones Remotas:**
@@ -50,14 +57,17 @@ El sistema está diseñado en torno a la gestión y visualización de activos de
- **Lenguaje:** C#
- **Acceso a Datos:** Dapper (Micro ORM)
- **Base de Datos:** Microsoft SQL Server
- **Autenticación:** JWT Bearer Token (`Microsoft.AspNetCore.Authentication.JwtBearer`)
- **Comandos Remotos:** Renci.SshNet para ejecución de comandos SSH (Wake On Lan)
### Frontend (`frontend/`)
- **Framework/Librería:** React 19
- **Lenguaje:** TypeScript
- **Gestión de Tabla:** TanStack Table v8
- **Gráficos:** Chart.js con `react-chartjs-2`
- **Notificaciones:** React Hot Toast
- **Tooltips:** React Tooltip
- **Iconos:** Lucide React
- **Build Tool:** Vite
---
@@ -81,46 +91,49 @@ cd nombre-del-repositorio
### 2. Configuración de la Base de Datos
1. Abra SSMS y conecte a su instancia de SQL Server.
2. Cree una nueva base de datos llamada `InventarioDB`.
3. Ejecute el script `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
1. Navegue al directorio del backend: `cd backend`.
2. Abra el archivo `appsettings.json`.
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`.
2. Abra el archivo `appsettings.Development.json` (o `appsettings.json` para producción).
3. **Modifique la `ConnectionString`** para que apunte a su instancia de SQL Server.
```json
"ConnectionStrings": {
"DefaultConnection": "Server=NOMBRE_SU_SERVIDOR;Database=InventarioDB;User Id=su_usuario_sql;Password=su_contraseña;TrustServerCertificate=True"
}
```
4. 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
"SshSettings": {
"Host": "",
"Port": ,
"User": "",
"Password": ""
"AuthSettings": {
"Username": "admin",
"Password": "SU_NUEVA_CLAVE_SEGURA_AQUI"
},
"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
dotnet restore
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
1. En una nueva terminal, navegue al directorio del frontend: `cd frontend`.
2. Instale las dependencias:
```bash
npm install
```
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`.
3. **Verificar la configuración del Proxy.** El frontend utiliza un proxy de Vite para redirigir las peticiones de `/api` al backend. Esta configuración se encuentra en `frontend/vite.config.ts`. Si estás ejecutando el backend en el puerto por defecto (`5198`), no necesitas hacer ningún cambio.
4. Ejecute el frontend en modo de desarrollo:
```bash
npm run dev
```
La aplicación web estará disponible en `http://localhost:5173` (o el puerto que indique Vite).
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 Inventario.API.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
namespace Inventario.API.Controllers
{
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class AdminController : ControllerBase
@@ -16,12 +18,33 @@ namespace Inventario.API.Controllers
_context = context;
}
// DTO para devolver los valores y su conteo
// --- DTOs para los componentes ---
public class ComponenteValorDto
{
public string Valor { get; set; } = "";
public int Conteo { get; set; }
}
public class UnificarComponenteDto
{
public required string ValorNuevo { get; set; }
public required string ValorAntiguo { get; set; }
}
public class RamAgrupadaDto
{
public string? Fabricante { get; set; }
public int Tamano { get; set; }
public int? Velocidad { get; set; }
public int Conteo { get; set; }
}
public class BorrarRamAgrupadaDto
{
public string? Fabricante { get; set; }
public int Tamano { get; set; }
public int? Velocidad { get; set; }
}
[HttpGet("componentes/{tipo}")]
public async Task<IActionResult> GetComponenteValores(string tipo)
@@ -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")]
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
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; }
}
// --- Devuelve la RAM agrupada ---
[HttpGet("componentes/ram")]
public async Task<IActionResult> GetComponentesRam()
{
var query = @"
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
FROM
dbo.memorias_ram mr
LEFT JOIN
dbo.equipos_memorias_ram emr ON mr.id = emr.memoria_ram_id
GROUP BY
mr.Id, mr.part_number, mr.Fabricante, mr.Tamano, mr.Velocidad
mr.Fabricante,
mr.Tamano,
mr.Velocidad
ORDER BY
Conteo DESC, mr.Fabricante, mr.Tamano;";
using (var connection = _context.CreateConnection())
{
var valores = await connection.QueryAsync<RamMaestraDto>(query);
var valores = await connection.QueryAsync<RamAgrupadaDto>(query);
return Ok(valores);
}
}
[HttpDelete("componentes/ram/{id}")]
public async Task<IActionResult> BorrarComponenteRam(int id)
// --- Elimina un grupo completo ---
[HttpDelete("componentes/ram")]
public async Task<IActionResult> BorrarComponenteRam([FromBody] BorrarRamAgrupadaDto dto)
{
using (var connection = _context.CreateConnection())
{
// 1. Verificación de seguridad: Asegurarse de que el módulo no esté en uso.
var usageQuery = "SELECT COUNT(*) FROM dbo.equipos_memorias_ram WHERE memoria_ram_id = @Id;";
var usageCount = await connection.ExecuteScalarAsync<int>(usageQuery, new { Id = id });
// Verificación de seguridad: Asegurarse de que el grupo no esté en uso.
var usageQuery = @"
SELECT COUNT(emr.id)
FROM dbo.memorias_ram mr
LEFT JOIN dbo.equipos_memorias_ram emr ON mr.id = emr.memoria_ram_id
WHERE (mr.Fabricante = @Fabricante OR (mr.Fabricante IS NULL AND @Fabricante IS NULL))
AND mr.Tamano = @Tamano
AND (mr.Velocidad = @Velocidad OR (mr.Velocidad IS NULL AND @Velocidad IS NULL));";
var usageCount = await connection.ExecuteScalarAsync<int>(usageQuery, dto);
if (usageCount > 0)
{
return Conflict($"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.
var deleteQuery = "DELETE FROM dbo.memorias_ram WHERE Id = @Id;";
var filasAfectadas = await connection.ExecuteAsync(deleteQuery, new { Id = id });
if (filasAfectadas == 0)
{
return NotFound("Módulo de RAM no encontrado.");
}
// Si no está en uso, proceder con la eliminación de todos los registros maestros que coincidan.
var deleteQuery = @"
DELETE FROM dbo.memorias_ram
WHERE (Fabricante = @Fabricante OR (Fabricante IS NULL AND @Fabricante IS NULL))
AND Tamano = @Tamano
AND (Velocidad = @Velocidad OR (Velocidad IS NULL AND @Velocidad IS NULL));";
await connection.ExecuteAsync(deleteQuery, dto);
return NoContent();
}
}
@@ -172,19 +189,14 @@ namespace Inventario.API.Controllers
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 usageCount = await connection.ExecuteScalarAsync<int>(usageQuery, new { Valor = valor });
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();
}
}

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.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
namespace Inventario.API.Controllers
{
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class DiscosController : ControllerBase

View File

@@ -9,9 +9,11 @@ using System.Net.NetworkInformation;
using Microsoft.Data.SqlClient;
using Renci.SshNet;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Authorization;
namespace Inventario.API.Controllers
{
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class EquiposController : ControllerBase
@@ -33,7 +35,7 @@ namespace Inventario.API.Controllers
{
var query = @"
SELECT
e.Id, e.Hostname, e.Ip, e.Mac, e.Motherboard, e.Cpu, e.Ram_installed, e.Ram_slots, e.Os, e.Architecture, e.created_at, e.updated_at, e.Origen,
e.Id, e.Hostname, e.Ip, e.Mac, e.Motherboard, e.Cpu, e.Ram_installed, e.Ram_slots, e.Os, e.Architecture, e.created_at, e.updated_at, e.Origen, e.sector_id,
s.Id as Id, s.Nombre,
u.Id as Id, u.Username, u.Password, ue.Origen as Origen,
d.Id as Id, d.Mediatype, d.Size, ed.Origen as Origen, ed.Id as EquipoDiscoId,
@@ -51,7 +53,6 @@ namespace Inventario.API.Controllers
{
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>(
query, (equipo, sector, usuario, disco, memoria) =>
{
@@ -61,12 +62,11 @@ namespace Inventario.API.Controllers
equipoActual.Sector = sector;
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))
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);
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);
return equipoActual;
@@ -148,7 +148,7 @@ namespace Inventario.API.Controllers
else
{
// 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
if (equipoData.Ip != equipoExistente.Ip) cambios["ip"] = (equipoExistente.Ip, equipoData.Ip);
@@ -191,15 +191,13 @@ namespace Inventario.API.Controllers
[HttpDelete("{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())
{
var filasAfectadas = await connection.ExecuteAsync(query, new { Id = id });
if (filasAfectadas == 0)
{
// Puede que no se haya borrado porque no existe o porque es automático.
// 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 NotFound("Equipo no encontrado.");
}
return NoContent();
}
@@ -240,23 +238,20 @@ namespace Inventario.API.Controllers
public async Task<IActionResult> AsociarUsuario(string hostname, [FromBody] AsociacionUsuarioDto dto)
{
var query = @"
INSERT INTO dbo.usuarios_equipos (equipo_id, usuario_id, origen)
SELECT e.id, u.id, 'automatica'
FROM dbo.equipos e, dbo.usuarios u
WHERE e.Hostname = @Hostname AND u.Username = @Username;";
INSERT INTO dbo.usuarios_equipos (equipo_id, usuario_id, origen)
SELECT e.id, u.id, 'automatica'
FROM dbo.equipos e, dbo.usuarios u
WHERE e.Hostname = @Hostname AND u.Username = @Username
AND NOT EXISTS (
SELECT 1
FROM dbo.usuarios_equipos ue
WHERE ue.equipo_id = e.id AND ue.usuario_id = u.id
);";
using (var connection = _context.CreateConnection())
{
try
{
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.");
}
await connection.ExecuteAsync(query, new { Hostname = hostname, dto.Username });
return Ok(new { success = true, message = "Asociación asegurada." });
}
}
@@ -279,7 +274,6 @@ namespace Inventario.API.Controllers
[HttpPost("{hostname}/asociardiscos")]
public async Task<IActionResult> AsociarDiscos(string hostname, [FromBody] List<Disco> discosDesdeCliente)
{
// 1. OBTENER EL EQUIPO
var equipoQuery = "SELECT * FROM dbo.equipos WHERE Hostname = @Hostname;";
using var connection = _context.CreateConnection();
connection.Open();
@@ -290,21 +284,16 @@ namespace Inventario.API.Controllers
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();
try
{
// 2. OBTENER ASOCIACIONES Y DISCOS ACTUALES DE LA BD
var discosActualesQuery = @"
SELECT d.Id, d.Mediatype, d.Size, ed.Id as EquipoDiscoId
FROM dbo.equipos_discos ed
JOIN dbo.discos d ON ed.disco_id = d.id
WHERE ed.equipo_id = @EquipoId;";
SELECT d.Id, d.Mediatype, d.Size, ed.Id as EquipoDiscoId
FROM dbo.equipos_discos ed
JOIN dbo.discos d ON ed.disco_id = d.id
WHERE ed.equipo_id = @EquipoId;";
var discosEnDb = (await connection.QueryAsync<DiscoAsociado>(discosActualesQuery, new { EquipoId = equipo.Id }, transaction)).ToList();
// 3. AGRUPAR Y CONTAR DISCOS (del cliente y de la BD)
// Crea un diccionario estilo: {"SSD_256": 2, "HDD_1024": 1}
var discosClienteContados = discosDesdeCliente
.GroupBy(d => $"{d.Mediatype}_{d.Size}")
.ToDictionary(g => g.Key, g => g.Count());
@@ -313,28 +302,23 @@ namespace Inventario.API.Controllers
.GroupBy(d => $"{d.Mediatype}_{d.Size}")
.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>();
foreach (var discoDb in discosEnDb)
{
var key = $"{discoDb.Mediatype}_{discoDb.Size}";
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]--;
}
else
{
// Este disco ya no está en el cliente, marcamos su asociación para eliminar.
discosAEliminar.Add(discoDb.EquipoDiscoId);
// Registrar para el historial
var nombreDisco = $"Disco {discoDb.Mediatype} {discoDb.Size}GB";
var anterior = discosDbContados.GetValueOrDefault(key, 0);
if (!cambios.ContainsKey(nombreDisco)) cambios[nombreDisco] = (anterior.ToString(), (anterior - 1).ToString());
else cambios[nombreDisco] = (anterior.ToString(), (int.Parse(cambios[nombreDisco].nuevo) - 1).ToString());
else cambios[nombreDisco] = (anterior.ToString(), (int.Parse(cambios[nombreDisco].nuevo!) - 1).ToString());
}
}
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);
}
// 5. CALCULAR Y EJECUTAR INSERCIONES
foreach (var discoCliente in discosDesdeCliente)
{
var key = $"{discoCliente.Mediatype}_{discoCliente.Size}";
if (discosDbContados.TryGetValue(key, out int count) && count > 0)
{
// Este disco ya existía, decrementamos para no volver a añadirlo.
discosDbContados[key]--;
}
else
{
// Este es un disco nuevo que hay que asociar.
var disco = await connection.QuerySingleOrDefaultAsync<Disco>("SELECT * FROM dbo.discos WHERE Mediatype = @Mediatype AND Size = @Size;", discoCliente, transaction);
var disco = await connection.QueryFirstOrDefaultAsync<Disco>("SELECT * FROM dbo.discos WHERE Mediatype = @Mediatype AND Size = @Size;", discoCliente, transaction);
if (disco == null) continue;
await connection.ExecuteAsync("INSERT INTO dbo.equipos_discos (equipo_id, disco_id, origen) VALUES (@EquipoId, @DiscoId, 'automatica');", new { EquipoId = equipo.Id, DiscoId = disco.Id }, transaction);
// Registrar para el historial
var nombreDisco = $"Disco {disco.Mediatype} {disco.Size}GB";
var anterior = discosDbContados.GetValueOrDefault(key, 0);
if (!cambios.ContainsKey(nombreDisco)) cambios[nombreDisco] = (anterior.ToString(), (anterior + 1).ToString());
else cambios[nombreDisco] = (anterior.ToString(), (int.Parse(cambios[nombreDisco].nuevo) + 1).ToString());
else cambios[nombreDisco] = (anterior.ToString(), (int.Parse(cambios[nombreDisco].nuevo!) + 1).ToString());
}
}
// 6. REGISTRAR CAMBIOS Y CONFIRMAR TRANSACCIÓN
if (cambios.Count > 0)
{
// Formateamos los valores para el historial
var cambiosFormateados = cambios.ToDictionary(
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);
}
@@ -385,7 +363,6 @@ namespace Inventario.API.Controllers
catch (Exception ex)
{
transaction.Rollback();
// Loggear el error en el servidor
Console.WriteLine($"Error al asociar discos para {hostname}: {ex.Message}");
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 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 =>
{
var parts = new List<string?> { ram.Fabricante, $"{ram.Tamano}GB", ram.PartNumber, ram.Velocidad?.ToString() + "MHz" };
@@ -585,13 +563,13 @@ namespace Inventario.API.Controllers
{
var findQuery = "SELECT Id FROM dbo.equipos WHERE Hostname = @Hostname;";
var insertQuery = @"
INSERT INTO dbo.equipos (Hostname, Ip, Motherboard, Cpu, Os, Sector_id, Origen, Ram_installed, Architecture)
VALUES (@Hostname, @Ip, @Motherboard, @Cpu, @Os, @Sector_id, 'manual', 0, '');
SELECT CAST(SCOPE_IDENTITY() as int);";
INSERT INTO dbo.equipos (Hostname, Ip, Motherboard, Cpu, Os, Sector_id, Origen, Ram_installed, Architecture)
VALUES (@Hostname, @Ip, @Motherboard, @Cpu, @Os, @Sector_id, 'manual', 0, '');
SELECT CAST(SCOPE_IDENTITY() as int);";
using (var connection = _context.CreateConnection())
{
var existente = await connection.QuerySingleOrDefaultAsync<int?>(findQuery, new { equipoDto.Hostname });
var existente = await connection.QueryFirstOrDefaultAsync<int?>(findQuery, new { equipoDto.Hostname });
if (existente.HasValue)
{
return Conflict($"El hostname '{equipoDto.Hostname}' ya existe.");
@@ -599,14 +577,13 @@ namespace Inventario.API.Controllers
var nuevoId = await connection.ExecuteScalarAsync<int>(insertQuery, equipoDto);
// Devolvemos el objeto completo para que el frontend pueda actualizar su estado
var nuevoEquipo = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT * FROM dbo.equipos WHERE Id = @Id", new { Id = nuevoId });
await HistorialHelper.RegistrarCambioUnico(_context, nuevoId, "Equipo", null, "Equipo creado manualmente");
var nuevoEquipo = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT * FROM dbo.equipos WHERE Id = @Id", new { Id = nuevoId });
if (nuevoEquipo == null)
{
return StatusCode(500, "No se pudo recuperar el equipo después de crearlo.");
}
return CreatedAtAction(nameof(ConsultarDetalle), new { hostname = nuevoEquipo.Hostname }, nuevoEquipo);
}
}
@@ -616,14 +593,28 @@ namespace Inventario.API.Controllers
[HttpDelete("asociacion/disco/{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())
{
var filasAfectadas = await connection.ExecuteAsync(query, new { EquipoDiscoId = equipoDiscoId });
if (filasAfectadas == 0)
var infoQuery = @"
SELECT ed.equipo_id, d.Mediatype, d.Size
FROM dbo.equipos_discos ed
JOIN dbo.discos d ON ed.disco_id = d.id
WHERE ed.Id = @EquipoDiscoId AND ed.Origen = 'manual'";
var info = await connection.QueryFirstOrDefaultAsync<(int equipo_id, string Mediatype, int Size)>(infoQuery, new { EquipoDiscoId = equipoDiscoId });
if (info == default)
{
return NotFound("Asociación de disco no encontrada o no 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();
}
}
@@ -631,14 +622,39 @@ namespace Inventario.API.Controllers
[HttpDelete("asociacion/ram/{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())
{
var filasAfectadas = await connection.ExecuteAsync(query, new { EquipoMemoriaRamId = equipoMemoriaRamId });
if (filasAfectadas == 0)
var infoQuery = @"
SELECT emr.equipo_id, emr.Slot, mr.Fabricante, mr.Tamano, mr.Velocidad
FROM dbo.equipos_memorias_ram emr
JOIN dbo.memorias_ram mr ON emr.memoria_ram_id = mr.id
WHERE emr.Id = @Id AND emr.Origen = 'manual'";
var info = await connection.QueryFirstOrDefaultAsync<(int equipo_id, string Slot, string? Fabricante, int Tamano, int? Velocidad)>(infoQuery, new { Id = equipoMemoriaRamId });
if (info == default)
{
return NotFound("Asociación de RAM no encontrada o no 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();
}
}
@@ -646,14 +662,27 @@ namespace Inventario.API.Controllers
[HttpDelete("asociacion/usuario/{equipoId}/{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())
{
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)
{
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();
}
}
@@ -663,7 +692,6 @@ namespace Inventario.API.Controllers
{
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 });
if (equipoActual == null)
{
@@ -674,7 +702,6 @@ namespace Inventario.API.Controllers
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)
{
var hostExistente = await connection.QuerySingleOrDefaultAsync<int?>("SELECT Id FROM dbo.equipos WHERE Hostname = @Hostname AND Id != @Id", new { equipoDto.Hostname, Id = id });
@@ -684,19 +711,46 @@ namespace Inventario.API.Controllers
}
}
// 3. Construir y ejecutar la consulta de actualización
var updateQuery = @"UPDATE dbo.equipos SET
Hostname = @Hostname,
Ip = @Ip,
Mac = @Mac,
Motherboard = @Motherboard,
Cpu = @Cpu,
Os = @Os,
Sector_id = @Sector_id,
updated_at = GETDATE()
WHERE Id = @Id AND Origen = 'manual';";
var allSectores = await connection.QueryAsync<Sector>("SELECT Id, Nombre FROM dbo.sectores;");
var sectorMap = allSectores.ToDictionary(s => s.Id, s => s.Nombre);
var filasAfectadas = await connection.ExecuteAsync(updateQuery, new
var cambios = new Dictionary<string, (string? anterior, string? nuevo)>();
if (equipoActual.Hostname != equipoDto.Hostname) cambios["Hostname"] = (equipoActual.Hostname, equipoDto.Hostname);
if (equipoActual.Ip != equipoDto.Ip) cambios["IP"] = (equipoActual.Ip, equipoDto.Ip);
if (equipoActual.Mac != equipoDto.Mac) cambios["MAC Address"] = (equipoActual.Mac ?? "N/A", equipoDto.Mac ?? "N/A");
if (equipoActual.Motherboard != equipoDto.Motherboard) cambios["Motherboard"] = (equipoActual.Motherboard ?? "N/A", equipoDto.Motherboard ?? "N/A");
if (equipoActual.Cpu != equipoDto.Cpu) cambios["CPU"] = (equipoActual.Cpu ?? "N/A", equipoDto.Cpu ?? "N/A");
if (equipoActual.Os != equipoDto.Os) cambios["Sistema Operativo"] = (equipoActual.Os ?? "N/A", equipoDto.Os ?? "N/A");
if (equipoActual.Architecture != equipoDto.Architecture) cambios["Arquitectura"] = (equipoActual.Architecture ?? "N/A", equipoDto.Architecture ?? "N/A");
if (equipoActual.Ram_slots != equipoDto.Ram_slots) cambios["Slots RAM"] = (equipoActual.Ram_slots?.ToString() ?? "N/A", equipoDto.Ram_slots?.ToString() ?? "N/A");
if (equipoActual.Sector_id != equipoDto.Sector_id)
{
string nombreAnterior = equipoActual.Sector_id.HasValue && sectorMap.TryGetValue(equipoActual.Sector_id.Value, out var oldName)
? oldName
: "Ninguno";
string nombreNuevo = equipoDto.Sector_id.HasValue && sectorMap.TryGetValue(equipoDto.Sector_id.Value, out var newName)
? newName
: "Ninguno";
cambios["Sector"] = (nombreAnterior, nombreNuevo);
}
var updateQuery = @"UPDATE dbo.equipos SET
Hostname = @Hostname,
Ip = @Ip,
Mac = @Mac,
Motherboard = @Motherboard,
Cpu = @Cpu,
Os = @Os,
Sector_id = @Sector_id,
Ram_slots = @Ram_slots,
Architecture = @Architecture, -- Campo añadido a la actualización
updated_at = GETDATE()
OUTPUT INSERTED.*
WHERE Id = @Id AND Origen = 'manual';";
var equipoActualizado = await connection.QuerySingleOrDefaultAsync<Equipo>(updateQuery, new
{
equipoDto.Hostname,
equipoDto.Ip,
@@ -705,16 +759,24 @@ namespace Inventario.API.Controllers
equipoDto.Cpu,
equipoDto.Os,
equipoDto.Sector_id,
equipoDto.Ram_slots,
equipoDto.Architecture,
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 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())
{
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.");
// Buscar o crear el disco maestro
var discoMaestro = await connection.QuerySingleOrDefaultAsync<Disco>("SELECT * FROM dbo.discos WHERE Mediatype = @Mediatype AND Size = @Size", dto);
var discoMaestro = await connection.QueryFirstOrDefaultAsync<Disco>("SELECT * FROM dbo.discos WHERE Mediatype = @Mediatype AND Size = @Size", dto);
int discoId;
if (discoMaestro == null)
{
@@ -738,10 +799,14 @@ namespace Inventario.API.Controllers
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 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 });
}
}
@@ -751,25 +816,41 @@ namespace Inventario.API.Controllers
{
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.");
// Lógica similar a la de discos para buscar/crear el módulo maestro
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)
{
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
{
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 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 });
}
}
@@ -779,12 +860,11 @@ namespace Inventario.API.Controllers
{
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.");
// Buscar o crear el usuario maestro
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)
{
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;
}
// Crear la asociación manual
try
{
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.");
}
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." });
}
}
@@ -866,6 +950,8 @@ namespace Inventario.API.Controllers
public string? Cpu { get; set; }
public string? Os { get; set; }
public int? Sector_id { get; set; }
public int? Ram_slots { get; set; }
public string? Architecture { get; set; }
}
public class AsociarDiscoManualDto
@@ -880,6 +966,7 @@ namespace Inventario.API.Controllers
public int Tamano { get; set; }
public string? Fabricante { get; set; }
public int? Velocidad { get; set; }
public string? PartNumber { get; set; }
}
public class AsociarUsuarioManualDto

View File

@@ -1,10 +1,14 @@
// backend/Controllers/MemoriasRamController.cs
using Dapper;
using Inventario.API.Data;
using Inventario.API.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
namespace Inventario.API.Controllers
{
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class MemoriasRamController : ControllerBase
@@ -16,11 +20,27 @@ namespace Inventario.API.Controllers
_context = context;
}
// --- GET /api/memoriasram ---
[HttpGet]
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())
{
var memorias = await connection.QueryAsync<MemoriaRam>(query);
@@ -28,7 +48,6 @@ namespace Inventario.API.Controllers
}
}
// --- GET /api/memoriasram/{id} ---
[HttpGet("{id}")]
public async Task<IActionResult> ConsultarDetalle(int id)
{
@@ -44,19 +63,17 @@ namespace Inventario.API.Controllers
}
}
// --- POST /api/memoriasram ---
[HttpPost]
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
(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
tamano = @Tamano AND
(velocidad = @Velocidad OR (velocidad IS NULL AND @Velocidad IS NULL));";
var queryInsert = @"INSERT INTO dbo.memorias_ram (part_number, fabricante, tamano, velocidad)
VALUES (@Part_number, @Fabricante, @Tamano, @Velocidad);
VALUES (@PartNumber, @Fabricante, @Tamano, @Velocidad);
SELECT CAST(SCOPE_IDENTITY() as int);";
var resultados = new List<object>();
@@ -70,15 +87,8 @@ namespace Inventario.API.Controllers
if (existente == null)
{
var nuevoId = await connection.ExecuteScalarAsync<int>(queryInsert, memoria);
var nuevaMemoria = new MemoriaRam
{
Id = nuevoId,
Part_number = memoria.Part_number,
Fabricante = memoria.Fabricante,
Tamano = memoria.Tamano,
Velocidad = memoria.Velocidad
};
resultados.Add(new { action = "created", registro = nuevaMemoria });
memoria.Id = nuevoId;
resultados.Add(new { action = "created", registro = memoria });
}
else
{
@@ -89,19 +99,18 @@ namespace Inventario.API.Controllers
return Ok(resultados);
}
// --- PUT /api/memoriasram/{id} ---
[HttpPut("{id}")]
public async Task<IActionResult> Actualizar(int id, [FromBody] MemoriaRam memoria)
{
var query = @"UPDATE dbo.memorias_ram SET
part_number = @Part_number,
part_number = @PartNumber,
fabricante = @Fabricante,
tamano = @Tamano,
velocidad = @Velocidad
WHERE Id = @Id;";
using (var connection = _context.CreateConnection())
{
var filasAfectadas = await connection.ExecuteAsync(query, new { memoria.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)
{
return NotFound("Módulo de memoria RAM no encontrado.");
@@ -112,7 +121,6 @@ namespace Inventario.API.Controllers
}
}
// --- DELETE /api/memoriasram/{id} ---
[HttpDelete("{id}")]
public async Task<IActionResult> Borrar(int id)
{
@@ -126,30 +134,38 @@ namespace Inventario.API.Controllers
{
try
{
// Primero eliminamos las asociaciones en la tabla intermedia
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);
if (filasAfectadas == 0)
{
// Si no se borró nada, hacemos rollback y devolvemos NotFound.
transaction.Rollback();
return NotFound("Módulo de memoria RAM no encontrado.");
}
// Si todo salió bien, confirmamos la transacción.
transaction.Commit();
return NoContent();
}
catch
{
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 Inventario.API.Data;
using Inventario.API.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
namespace Inventario.API.Controllers
{
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class SectoresController : ControllerBase
@@ -105,18 +109,29 @@ namespace Inventario.API.Controllers
[HttpDelete("{id}")]
public async Task<IActionResult> BorrarSector(int id)
{
var query = "DELETE FROM dbo.sectores WHERE Id = @Id;";
using (var connection = _context.CreateConnection())
{
var filasAfectadas = await connection.ExecuteAsync(query, new { Id = id });
if (filasAfectadas == 0)
using (var connection = _context.CreateConnection())
{
return NotFound();
}
// 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 });
return NoContent();
}
if (usageCount > 0)
{
// 2. DEVOLVER HTTP 409 CONFLICT SI ESTÁ EN USO
return Conflict(new { message = $"No se puede eliminar. Hay {usageCount} equipo(s) asignados a este sector." });
}
// 3. SI NO ESTÁ EN USO, PROCEDER CON LA ELIMINACIÓN
var deleteQuery = "DELETE FROM dbo.sectores WHERE Id = @Id;";
var filasAfectadas = await connection.ExecuteAsync(deleteQuery, new { Id = id });
if (filasAfectadas == 0)
{
return NotFound();
}
return NoContent();
}
}
}
}

View File

@@ -2,9 +2,11 @@ using Dapper;
using Inventario.API.Data;
using Inventario.API.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
namespace Inventario.API.Controllers
{
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class UsuariosController : ControllerBase
@@ -56,11 +58,16 @@ namespace Inventario.API.Controllers
if (usuarioExistente != null)
{
// El usuario ya existe, lo actualizamos (solo la contraseña si viene)
var updateQuery = "UPDATE dbo.usuarios SET Password = @Password WHERE Id = @Id;";
await connection.ExecuteAsync(updateQuery, new { usuario.Password, Id = usuarioExistente.Id });
// El usuario ya existe.
// SOLO actualizamos la contraseña si se proporciona una nueva en el body de la petición.
if (!string.IsNullOrEmpty(usuario.Password))
{
var updateQuery = "UPDATE dbo.usuarios SET Password = @Password, updated_at = GETDATE() WHERE Id = @Id;";
await connection.ExecuteAsync(updateQuery, new { usuario.Password, Id = usuarioExistente.Id });
}
// Si no se envía contraseña, simplemente no hacemos nada y el valor en la BD se conserva.
// Devolvemos el usuario actualizado
// Devolvemos el usuario que ya existe (con o sin la contraseña actualizada)
var usuarioActualizado = await connection.QuerySingleOrDefaultAsync<Usuario>(findQuery, new { usuario.Username });
return Ok(usuarioActualizado);
}

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 Inventario.API.Data;
using Inventario.API.Models;
namespace Inventario.API.Helpers
{
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)
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>
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.9" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.5" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.9">

View File

@@ -18,28 +18,26 @@ namespace Inventario.API.Models
public int? Sector_id { get; set; }
public string Origen { get; set; } = "automatica";
// Propiedades de navegación actualizadas
public Sector? Sector { get; set; }
public List<UsuarioEquipoDetalle> Usuarios { get; set; } = new(); // Tipo actualizado
public List<DiscoDetalle> Discos { get; set; } = new(); // Tipo actualizado
public List<MemoriaRamEquipoDetalle> MemoriasRam { get; set; } = new(); // Tipo actualizado
public List<UsuarioEquipoDetalle> Usuarios { get; set; } = new();
public List<DiscoDetalle> Discos { get; set; } = new();
public List<MemoriaRamEquipoDetalle> MemoriasRam { get; set; } = new();
public List<HistorialEquipo> Historial { get; set; } = new();
}
// Nuevo modelo para discos con su origen
public class DiscoDetalle : Disco
{
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 string Slot { get; set; } = string.Empty;
public string Origen { get; set; } = "manual";
public int EquipoMemoriaRamId { get; set; }
}
// Nuevo modelo para usuarios con su origen
public class UsuarioEquipoDetalle : Usuario
{
public string Origen { get; set; } = "manual";

View File

@@ -4,7 +4,7 @@ namespace Inventario.API.Models
public class MemoriaRam
{
public int Id { get; set; }
public string? Part_number { get; set; }
public string? PartNumber { get; set; }
public string? Fabricante { get; set; }
public int Tamano { get; set; }
public int? Velocidad { get; set; }

View File

@@ -1,13 +1,67 @@
// backend/Program.cs
using Inventario.API.Data;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
using Microsoft.OpenApi.Models;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!))
};
});
// Add services to the container.
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// --- 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
var MyAllowSpecificOrigins = "_myAllowSpecificOrigins";
@@ -42,11 +96,14 @@ if (app.Environment.IsDevelopment())
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)
app.UseCors(MyAllowSpecificOrigins);
// ----------------------------------------
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();

View File

@@ -5,6 +5,18 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AuthSettings": {
"Username": "admin",
"Password": "PTP847Equipos"
},
"Jwt": {
"Key": "badb1a38d221c9e23bcf70958840ca7f5a5dc54f2047dadf7ce45b578b5bc3e2",
"Issuer": "InventarioAPI",
"Audience": "InventarioClient"
},
"ConnectionStrings": {
"DefaultConnection": "Server=TECNICA3;Database=InventarioDB;User Id=apiequipos;Password=@Apiequipos513@;TrustServerCertificate=True"
},
"SshSettings": {
"Host": "192.168.10.1",
"Port": 22110,

View File

@@ -6,8 +6,17 @@
}
},
"AllowedHosts": "*",
"AuthSettings": {
"Username": "admin",
"Password": "PTP847Equipos"
},
"Jwt": {
"Key": "badb1a38d221c9e23bcf70958840ca7f5a5dc54f2047dadf7ce45b578b5bc3e2",
"Issuer": "InventarioAPI",
"Audience": "InventarioClient"
},
"ConnectionStrings": {
"DefaultConnection": "Server=TECNICA3;Database=InventarioDB;User Id=apiequipos;Password=@Apiequipos513@;TrustServerCertificate=True"
"DefaultConnection": "Server=db-sqlserver;Database=InventarioDB;User Id=apiequipos;Password=@Apiequipos513@;TrustServerCertificate=True"
},
"SshSettings": {
"Host": "192.168.10.1",

View File

@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Inventario.API")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+99d98cc588b3922b6aa3ab9045fcee9cb31de1f3")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+acf2f9a35c8a559db55e21ce6dd2066c30a01669")]
[assembly: System.Reflection.AssemblyProductAttribute("Inventario.API")]
[assembly: System.Reflection.AssemblyTitleAttribute("Inventario.API")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

View File

@@ -54,6 +54,10 @@
"target": "Package",
"version": "[2.1.66, )"
},
"Microsoft.AspNetCore.Authentication.JwtBearer": {
"target": "Package",
"version": "[9.0.9, )"
},
"Microsoft.AspNetCore.OpenApi": {
"target": "Package",
"version": "[9.0.5, )"

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": {
"type": "package",
"dependencies": {
@@ -897,96 +916,96 @@
}
}
},
"Microsoft.IdentityModel.Abstractions/7.7.1": {
"Microsoft.IdentityModel.Abstractions/8.0.1": {
"type": "package",
"compile": {
"lib/net8.0/Microsoft.IdentityModel.Abstractions.dll": {
"lib/net9.0/Microsoft.IdentityModel.Abstractions.dll": {
"related": ".xml"
}
},
"runtime": {
"lib/net8.0/Microsoft.IdentityModel.Abstractions.dll": {
"lib/net9.0/Microsoft.IdentityModel.Abstractions.dll": {
"related": ".xml"
}
}
},
"Microsoft.IdentityModel.JsonWebTokens/7.7.1": {
"Microsoft.IdentityModel.JsonWebTokens/8.0.1": {
"type": "package",
"dependencies": {
"Microsoft.IdentityModel.Tokens": "7.7.1"
"Microsoft.IdentityModel.Tokens": "8.0.1"
},
"compile": {
"lib/net8.0/Microsoft.IdentityModel.JsonWebTokens.dll": {
"lib/net9.0/Microsoft.IdentityModel.JsonWebTokens.dll": {
"related": ".xml"
}
},
"runtime": {
"lib/net8.0/Microsoft.IdentityModel.JsonWebTokens.dll": {
"lib/net9.0/Microsoft.IdentityModel.JsonWebTokens.dll": {
"related": ".xml"
}
}
},
"Microsoft.IdentityModel.Logging/7.7.1": {
"Microsoft.IdentityModel.Logging/8.0.1": {
"type": "package",
"dependencies": {
"Microsoft.IdentityModel.Abstractions": "7.7.1"
"Microsoft.IdentityModel.Abstractions": "8.0.1"
},
"compile": {
"lib/net8.0/Microsoft.IdentityModel.Logging.dll": {
"lib/net9.0/Microsoft.IdentityModel.Logging.dll": {
"related": ".xml"
}
},
"runtime": {
"lib/net8.0/Microsoft.IdentityModel.Logging.dll": {
"lib/net9.0/Microsoft.IdentityModel.Logging.dll": {
"related": ".xml"
}
}
},
"Microsoft.IdentityModel.Protocols/7.7.1": {
"Microsoft.IdentityModel.Protocols/8.0.1": {
"type": "package",
"dependencies": {
"Microsoft.IdentityModel.Tokens": "7.7.1"
"Microsoft.IdentityModel.Tokens": "8.0.1"
},
"compile": {
"lib/net8.0/Microsoft.IdentityModel.Protocols.dll": {
"lib/net9.0/Microsoft.IdentityModel.Protocols.dll": {
"related": ".xml"
}
},
"runtime": {
"lib/net8.0/Microsoft.IdentityModel.Protocols.dll": {
"lib/net9.0/Microsoft.IdentityModel.Protocols.dll": {
"related": ".xml"
}
}
},
"Microsoft.IdentityModel.Protocols.OpenIdConnect/7.7.1": {
"Microsoft.IdentityModel.Protocols.OpenIdConnect/8.0.1": {
"type": "package",
"dependencies": {
"Microsoft.IdentityModel.Protocols": "7.7.1",
"System.IdentityModel.Tokens.Jwt": "7.7.1"
"Microsoft.IdentityModel.Protocols": "8.0.1",
"System.IdentityModel.Tokens.Jwt": "8.0.1"
},
"compile": {
"lib/net8.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll": {
"lib/net9.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll": {
"related": ".xml"
}
},
"runtime": {
"lib/net8.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll": {
"lib/net9.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll": {
"related": ".xml"
}
}
},
"Microsoft.IdentityModel.Tokens/7.7.1": {
"Microsoft.IdentityModel.Tokens/8.0.1": {
"type": "package",
"dependencies": {
"Microsoft.IdentityModel.Logging": "7.7.1"
"Microsoft.IdentityModel.Logging": "8.0.1"
},
"compile": {
"lib/net8.0/Microsoft.IdentityModel.Tokens.dll": {
"lib/net9.0/Microsoft.IdentityModel.Tokens.dll": {
"related": ".xml"
}
},
"runtime": {
"lib/net8.0/Microsoft.IdentityModel.Tokens.dll": {
"lib/net9.0/Microsoft.IdentityModel.Tokens.dll": {
"related": ".xml"
}
}
@@ -1355,19 +1374,19 @@
"buildTransitive/net8.0/_._": {}
}
},
"System.IdentityModel.Tokens.Jwt/7.7.1": {
"System.IdentityModel.Tokens.Jwt/8.0.1": {
"type": "package",
"dependencies": {
"Microsoft.IdentityModel.JsonWebTokens": "7.7.1",
"Microsoft.IdentityModel.Tokens": "7.7.1"
"Microsoft.IdentityModel.JsonWebTokens": "8.0.1",
"Microsoft.IdentityModel.Tokens": "8.0.1"
},
"compile": {
"lib/net8.0/System.IdentityModel.Tokens.Jwt.dll": {
"lib/net9.0/System.IdentityModel.Tokens.Jwt.dll": {
"related": ".xml"
}
},
"runtime": {
"lib/net8.0/System.IdentityModel.Tokens.Jwt.dll": {
"lib/net9.0/System.IdentityModel.Tokens.Jwt.dll": {
"related": ".xml"
}
}
@@ -1618,6 +1637,22 @@
"logo.png"
]
},
"Microsoft.AspNetCore.Authentication.JwtBearer/9.0.9": {
"sha512": "U5gW2DS/yAE9X0Ko63/O2lNApAzI/jhx4IT1Th6W0RShKv6XAVVgLGN3zqnmcd6DtAnp5FYs+4HZrxsTl0anLA==",
"type": "package",
"path": "microsoft.aspnetcore.authentication.jwtbearer/9.0.9",
"files": [
".nupkg.metadata",
".signature.p7s",
"Icon.png",
"PACKAGE.md",
"THIRD-PARTY-NOTICES.TXT",
"lib/net9.0/Microsoft.AspNetCore.Authentication.JwtBearer.dll",
"lib/net9.0/Microsoft.AspNetCore.Authentication.JwtBearer.xml",
"microsoft.aspnetcore.authentication.jwtbearer.9.0.9.nupkg.sha512",
"microsoft.aspnetcore.authentication.jwtbearer.nuspec"
]
},
"Microsoft.AspNetCore.OpenApi/9.0.5": {
"sha512": "yZLOciYlpaOO/mHPOpgeSZTv8Lc7fOOVX40eWJJoGs/S9Ny9CymDuKKQofGE9stXGGM9EEnnuPeq0fhR8kdFfg==",
"type": "package",
@@ -3469,15 +3504,13 @@
"microsoft.identity.client.extensions.msal.nuspec"
]
},
"Microsoft.IdentityModel.Abstractions/7.7.1": {
"sha512": "S7sHg6gLg7oFqNGLwN1qSbJDI+QcRRj8SuJ1jHyCmKSipnF6ZQL+tFV2NzVfGj/xmGT9TykQdQiBN+p5Idl4TA==",
"Microsoft.IdentityModel.Abstractions/8.0.1": {
"sha512": "OtlIWcyX01olfdevPKZdIPfBEvbcioDyBiE/Z2lHsopsMD7twcKtlN9kMevHmI5IIPhFpfwCIiR6qHQz1WHUIw==",
"type": "package",
"path": "microsoft.identitymodel.abstractions/7.7.1",
"path": "microsoft.identitymodel.abstractions/8.0.1",
"files": [
".nupkg.metadata",
".signature.p7s",
"lib/net461/Microsoft.IdentityModel.Abstractions.dll",
"lib/net461/Microsoft.IdentityModel.Abstractions.xml",
"lib/net462/Microsoft.IdentityModel.Abstractions.dll",
"lib/net462/Microsoft.IdentityModel.Abstractions.xml",
"lib/net472/Microsoft.IdentityModel.Abstractions.dll",
@@ -3486,21 +3519,21 @@
"lib/net6.0/Microsoft.IdentityModel.Abstractions.xml",
"lib/net8.0/Microsoft.IdentityModel.Abstractions.dll",
"lib/net8.0/Microsoft.IdentityModel.Abstractions.xml",
"lib/net9.0/Microsoft.IdentityModel.Abstractions.dll",
"lib/net9.0/Microsoft.IdentityModel.Abstractions.xml",
"lib/netstandard2.0/Microsoft.IdentityModel.Abstractions.dll",
"lib/netstandard2.0/Microsoft.IdentityModel.Abstractions.xml",
"microsoft.identitymodel.abstractions.7.7.1.nupkg.sha512",
"microsoft.identitymodel.abstractions.8.0.1.nupkg.sha512",
"microsoft.identitymodel.abstractions.nuspec"
]
},
"Microsoft.IdentityModel.JsonWebTokens/7.7.1": {
"sha512": "3Izi75UCUssvo8LPx3OVnEeZay58qaFicrtSnbtUt7q8qQi0gy46gh4V8VUTkMVMKXV6VMyjBVmeNNgeCUJuIw==",
"Microsoft.IdentityModel.JsonWebTokens/8.0.1": {
"sha512": "s6++gF9x0rQApQzOBbSyp4jUaAlwm+DroKfL8gdOHxs83k8SJfUXhuc46rDB3rNXBQ1MVRxqKUrqFhO/M0E97g==",
"type": "package",
"path": "microsoft.identitymodel.jsonwebtokens/7.7.1",
"path": "microsoft.identitymodel.jsonwebtokens/8.0.1",
"files": [
".nupkg.metadata",
".signature.p7s",
"lib/net461/Microsoft.IdentityModel.JsonWebTokens.dll",
"lib/net461/Microsoft.IdentityModel.JsonWebTokens.xml",
"lib/net462/Microsoft.IdentityModel.JsonWebTokens.dll",
"lib/net462/Microsoft.IdentityModel.JsonWebTokens.xml",
"lib/net472/Microsoft.IdentityModel.JsonWebTokens.dll",
@@ -3509,21 +3542,21 @@
"lib/net6.0/Microsoft.IdentityModel.JsonWebTokens.xml",
"lib/net8.0/Microsoft.IdentityModel.JsonWebTokens.dll",
"lib/net8.0/Microsoft.IdentityModel.JsonWebTokens.xml",
"lib/net9.0/Microsoft.IdentityModel.JsonWebTokens.dll",
"lib/net9.0/Microsoft.IdentityModel.JsonWebTokens.xml",
"lib/netstandard2.0/Microsoft.IdentityModel.JsonWebTokens.dll",
"lib/netstandard2.0/Microsoft.IdentityModel.JsonWebTokens.xml",
"microsoft.identitymodel.jsonwebtokens.7.7.1.nupkg.sha512",
"microsoft.identitymodel.jsonwebtokens.8.0.1.nupkg.sha512",
"microsoft.identitymodel.jsonwebtokens.nuspec"
]
},
"Microsoft.IdentityModel.Logging/7.7.1": {
"sha512": "BZNgSq/o8gsKExdYoBKPR65fdsxW0cTF8PsdqB8y011AGUJJW300S/ZIsEUD0+sOmGc003Gwv3FYbjrVjvsLNQ==",
"Microsoft.IdentityModel.Logging/8.0.1": {
"sha512": "UCPF2exZqBXe7v/6sGNiM6zCQOUXXQ9+v5VTb9gPB8ZSUPnX53BxlN78v2jsbIvK9Dq4GovQxo23x8JgWvm/Qg==",
"type": "package",
"path": "microsoft.identitymodel.logging/7.7.1",
"path": "microsoft.identitymodel.logging/8.0.1",
"files": [
".nupkg.metadata",
".signature.p7s",
"lib/net461/Microsoft.IdentityModel.Logging.dll",
"lib/net461/Microsoft.IdentityModel.Logging.xml",
"lib/net462/Microsoft.IdentityModel.Logging.dll",
"lib/net462/Microsoft.IdentityModel.Logging.xml",
"lib/net472/Microsoft.IdentityModel.Logging.dll",
@@ -3532,21 +3565,21 @@
"lib/net6.0/Microsoft.IdentityModel.Logging.xml",
"lib/net8.0/Microsoft.IdentityModel.Logging.dll",
"lib/net8.0/Microsoft.IdentityModel.Logging.xml",
"lib/net9.0/Microsoft.IdentityModel.Logging.dll",
"lib/net9.0/Microsoft.IdentityModel.Logging.xml",
"lib/netstandard2.0/Microsoft.IdentityModel.Logging.dll",
"lib/netstandard2.0/Microsoft.IdentityModel.Logging.xml",
"microsoft.identitymodel.logging.7.7.1.nupkg.sha512",
"microsoft.identitymodel.logging.8.0.1.nupkg.sha512",
"microsoft.identitymodel.logging.nuspec"
]
},
"Microsoft.IdentityModel.Protocols/7.7.1": {
"sha512": "h+fHHBGokepmCX+QZXJk4Ij8OApCb2n2ktoDkNX5CXteXsOxTHMNgjPGpAwdJMFvAL7TtGarUnk3o97NmBq2QQ==",
"Microsoft.IdentityModel.Protocols/8.0.1": {
"sha512": "uA2vpKqU3I2mBBEaeJAWPTjT9v1TZrGWKdgK6G5qJd03CLx83kdiqO9cmiK8/n1erkHzFBwU/RphP83aAe3i3g==",
"type": "package",
"path": "microsoft.identitymodel.protocols/7.7.1",
"path": "microsoft.identitymodel.protocols/8.0.1",
"files": [
".nupkg.metadata",
".signature.p7s",
"lib/net461/Microsoft.IdentityModel.Protocols.dll",
"lib/net461/Microsoft.IdentityModel.Protocols.xml",
"lib/net462/Microsoft.IdentityModel.Protocols.dll",
"lib/net462/Microsoft.IdentityModel.Protocols.xml",
"lib/net472/Microsoft.IdentityModel.Protocols.dll",
@@ -3555,21 +3588,21 @@
"lib/net6.0/Microsoft.IdentityModel.Protocols.xml",
"lib/net8.0/Microsoft.IdentityModel.Protocols.dll",
"lib/net8.0/Microsoft.IdentityModel.Protocols.xml",
"lib/net9.0/Microsoft.IdentityModel.Protocols.dll",
"lib/net9.0/Microsoft.IdentityModel.Protocols.xml",
"lib/netstandard2.0/Microsoft.IdentityModel.Protocols.dll",
"lib/netstandard2.0/Microsoft.IdentityModel.Protocols.xml",
"microsoft.identitymodel.protocols.7.7.1.nupkg.sha512",
"microsoft.identitymodel.protocols.8.0.1.nupkg.sha512",
"microsoft.identitymodel.protocols.nuspec"
]
},
"Microsoft.IdentityModel.Protocols.OpenIdConnect/7.7.1": {
"sha512": "yT2Hdj8LpPbcT9C9KlLVxXl09C8zjFaVSaApdOwuecMuoV4s6Sof/mnTDz/+F/lILPIBvrWugR9CC7iRVZgbfQ==",
"Microsoft.IdentityModel.Protocols.OpenIdConnect/8.0.1": {
"sha512": "AQDbfpL+yzuuGhO/mQhKNsp44pm5Jv8/BI4KiFXR7beVGZoSH35zMV3PrmcfvSTsyI6qrcR898NzUauD6SRigg==",
"type": "package",
"path": "microsoft.identitymodel.protocols.openidconnect/7.7.1",
"path": "microsoft.identitymodel.protocols.openidconnect/8.0.1",
"files": [
".nupkg.metadata",
".signature.p7s",
"lib/net461/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll",
"lib/net461/Microsoft.IdentityModel.Protocols.OpenIdConnect.xml",
"lib/net462/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll",
"lib/net462/Microsoft.IdentityModel.Protocols.OpenIdConnect.xml",
"lib/net472/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll",
@@ -3578,21 +3611,21 @@
"lib/net6.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.xml",
"lib/net8.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll",
"lib/net8.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.xml",
"lib/net9.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll",
"lib/net9.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.xml",
"lib/netstandard2.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll",
"lib/netstandard2.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.xml",
"microsoft.identitymodel.protocols.openidconnect.7.7.1.nupkg.sha512",
"microsoft.identitymodel.protocols.openidconnect.8.0.1.nupkg.sha512",
"microsoft.identitymodel.protocols.openidconnect.nuspec"
]
},
"Microsoft.IdentityModel.Tokens/7.7.1": {
"sha512": "fQ0VVCba75lknUHGldi3iTKAYUQqbzp1Un8+d9cm9nON0Gs8NAkXddNg8iaUB0qi/ybtAmNWizTR4avdkCJ9pQ==",
"Microsoft.IdentityModel.Tokens/8.0.1": {
"sha512": "kDimB6Dkd3nkW2oZPDkMkVHfQt3IDqO5gL0oa8WVy3OP4uE8Ij+8TXnqg9TOd9ufjsY3IDiGz7pCUbnfL18tjg==",
"type": "package",
"path": "microsoft.identitymodel.tokens/7.7.1",
"path": "microsoft.identitymodel.tokens/8.0.1",
"files": [
".nupkg.metadata",
".signature.p7s",
"lib/net461/Microsoft.IdentityModel.Tokens.dll",
"lib/net461/Microsoft.IdentityModel.Tokens.xml",
"lib/net462/Microsoft.IdentityModel.Tokens.dll",
"lib/net462/Microsoft.IdentityModel.Tokens.xml",
"lib/net472/Microsoft.IdentityModel.Tokens.dll",
@@ -3601,9 +3634,11 @@
"lib/net6.0/Microsoft.IdentityModel.Tokens.xml",
"lib/net8.0/Microsoft.IdentityModel.Tokens.dll",
"lib/net8.0/Microsoft.IdentityModel.Tokens.xml",
"lib/net9.0/Microsoft.IdentityModel.Tokens.dll",
"lib/net9.0/Microsoft.IdentityModel.Tokens.xml",
"lib/netstandard2.0/Microsoft.IdentityModel.Tokens.dll",
"lib/netstandard2.0/Microsoft.IdentityModel.Tokens.xml",
"microsoft.identitymodel.tokens.7.7.1.nupkg.sha512",
"microsoft.identitymodel.tokens.8.0.1.nupkg.sha512",
"microsoft.identitymodel.tokens.nuspec"
]
},
@@ -4090,15 +4125,13 @@
"useSharedDesignerContext.txt"
]
},
"System.IdentityModel.Tokens.Jwt/7.7.1": {
"sha512": "rQkO1YbAjLwnDJSMpRhRtrc6XwIcEOcUvoEcge+evurpzSZM3UNK+MZfD3sKyTlYsvknZ6eJjSBfnmXqwOsT9Q==",
"System.IdentityModel.Tokens.Jwt/8.0.1": {
"sha512": "GJw3bYkWpOgvN3tJo5X4lYUeIFA2HD293FPUhKmp7qxS+g5ywAb34Dnd3cDAFLkcMohy5XTpoaZ4uAHuw0uSPQ==",
"type": "package",
"path": "system.identitymodel.tokens.jwt/7.7.1",
"path": "system.identitymodel.tokens.jwt/8.0.1",
"files": [
".nupkg.metadata",
".signature.p7s",
"lib/net461/System.IdentityModel.Tokens.Jwt.dll",
"lib/net461/System.IdentityModel.Tokens.Jwt.xml",
"lib/net462/System.IdentityModel.Tokens.Jwt.dll",
"lib/net462/System.IdentityModel.Tokens.Jwt.xml",
"lib/net472/System.IdentityModel.Tokens.Jwt.dll",
@@ -4107,9 +4140,11 @@
"lib/net6.0/System.IdentityModel.Tokens.Jwt.xml",
"lib/net8.0/System.IdentityModel.Tokens.Jwt.dll",
"lib/net8.0/System.IdentityModel.Tokens.Jwt.xml",
"lib/net9.0/System.IdentityModel.Tokens.Jwt.dll",
"lib/net9.0/System.IdentityModel.Tokens.Jwt.xml",
"lib/netstandard2.0/System.IdentityModel.Tokens.Jwt.dll",
"lib/netstandard2.0/System.IdentityModel.Tokens.Jwt.xml",
"system.identitymodel.tokens.jwt.7.7.1.nupkg.sha512",
"system.identitymodel.tokens.jwt.8.0.1.nupkg.sha512",
"system.identitymodel.tokens.jwt.nuspec"
]
},
@@ -4417,6 +4452,7 @@
"projectFileDependencyGroups": {
"net9.0": [
"Dapper >= 2.1.66",
"Microsoft.AspNetCore.Authentication.JwtBearer >= 9.0.9",
"Microsoft.AspNetCore.OpenApi >= 9.0.5",
"Microsoft.Data.SqlClient >= 6.1.1",
"Microsoft.EntityFrameworkCore.Design >= 9.0.9",
@@ -4479,6 +4515,10 @@
"target": "Package",
"version": "[2.1.66, )"
},
"Microsoft.AspNetCore.Authentication.JwtBearer": {
"target": "Package",
"version": "[9.0.9, )"
},
"Microsoft.AspNetCore.OpenApi": {
"target": "Package",
"version": "[9.0.5, )"

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",
"dependencies": {
"@tanstack/react-table": "^8.21.3",
"chart.js": "^4.5.0",
"lucide-react": "^0.545.0",
"react": "^19.1.1",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.1.1",
"react-hot-toast": "^2.6.0",
"react-tooltip": "^5.29.1"
@@ -1034,6 +1037,12 @@
"@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": {
"version": "2.1.5",
"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"
}
},
"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": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
@@ -2726,6 +2747,15 @@
"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": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -2985,6 +3015,16 @@
"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": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",

View File

@@ -11,7 +11,10 @@
},
"dependencies": {
"@tanstack/react-table": "^8.21.3",
"chart.js": "^4.5.0",
"lucide-react": "^0.545.0",
"react": "^19.1.1",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.1.1",
"react-hot-toast": "^2.6.0",
"react-tooltip": "^5.29.1"

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;
}
/* Estilos para la nueva Barra de Navegación */
.navbar {
background-color: #343a40; /* Un color oscuro para el fondo */
background-color: var(--color-navbar-bg);
padding: 0 2rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--color-border); /* Borde sutil */
}
.nav-links {
@@ -21,27 +21,27 @@ main {
.nav-link {
background: none;
border: none;
color: #adb5bd; /* Color de texto gris claro */
color: var(--color-navbar-text);
padding: 1rem 1.5rem;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
text-decoration: none;
transition: color 0.2s ease-in-out, background-color 0.2s ease-in-out;
border-bottom: 3px solid transparent; /* Borde inferior para el indicador activo */
transition: color 0.2s ease-in-out, background-color 0.2s ease-in-out, border-color 0.2s ease-in-out;
border-bottom: 3px solid transparent;
}
.nav-link:hover {
color: #ffffff; /* Texto blanco al pasar el ratón */
color: var(--color-navbar-text-hover);
}
.nav-link-active {
color: #ffffff;
border-bottom: 3px solid #007bff; /* Indicador azul para la vista activa */
color: var(--color-navbar-text-hover);
border-bottom: 3px solid var(--color-primary);
}
.app-title {
font-size: 1.5rem;
color: #ffffff;
color: var(--color-navbar-text-hover);
font-weight: bold;
}

View File

@@ -2,13 +2,26 @@ import { useState } from 'react';
import SimpleTable from "./components/SimpleTable";
import GestionSectores from "./components/GestionSectores";
import GestionComponentes from './components/GestionComponentes';
import Dashboard from './components/Dashboard';
import Navbar from './components/Navbar';
import { useAuth } from './context/AuthContext';
import Login from './components/Login';
import './App.css';
export type View = 'equipos' | 'sectores' | 'admin';
export type View = 'equipos' | 'sectores' | 'admin' | 'dashboard';
function App() {
const [currentView, setCurrentView] = useState<View>('equipos');
const { isAuthenticated, isLoading } = useAuth();
// Muestra un loader mientras se verifica la sesión
if (isLoading) {
return <div>Cargando...</div>;
}
if (!isAuthenticated) {
return <Login />;
}
return (
<>
@@ -18,6 +31,7 @@ function App() {
{currentView === 'equipos' && <SimpleTable />}
{currentView === 'sectores' && <GestionSectores />}
{currentView === 'admin' && <GestionComponentes />}
{currentView === 'dashboard' && <Dashboard />}
</main>
</>
);

View File

@@ -1,46 +1,55 @@
import React, { useState, useEffect } from 'react';
interface AutocompleteInputProps {
// --- Interfaces de Props más robustas usando una unión discriminada ---
type AutocompleteInputProps = {
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
name: string;
placeholder?: string;
// CAMBIO: La función ahora recibe el término de búsqueda
fetchSuggestions: (query: string) => Promise<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> = ({
value,
onChange,
name,
placeholder,
fetchSuggestions,
className
}) => {
const AutocompleteInput: React.FC<AutocompleteInputProps> = (props) => {
const { value, onChange, name, placeholder, className } = props;
const [suggestions, setSuggestions] = useState<string[]>([]);
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(() => {
// No buscar si el input está vacío o es muy corto
if (value.length < 2) {
setSuggestions([]);
return;
}
// Configura un temporizador para esperar 300ms después de la última pulsación
const handler = setTimeout(() => {
fetchSuggestions(value)
if (props.mode === 'static') {
props.fetchSuggestions()
.then(setSuggestions)
.catch(err => console.error(`Error fetching suggestions for ${name}:`, err));
}, 300);
.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]);
// Limpia el temporizador si el usuario sigue escribiendo
return () => {
clearTimeout(handler);
};
}, [value, fetchSuggestions, name]);
// --- Lógica para el modo DINÁMICO ---
// Se ejecuta cada vez que el usuario escribe, con un debounce
useEffect(() => {
if (props.mode === 'dynamic') {
if (value.length < 2) {
setSuggestions([]);
return;
}
const handler = setTimeout(() => {
props.fetchSuggestions(value)
.then(setSuggestions)
.catch(err => console.error(`Error fetching dynamic suggestions for ${name}:`, err));
}, 300);
return () => clearTimeout(handler);
}
}, [value, props.mode, props.fetchSuggestions, name]);
return (
<>
@@ -52,7 +61,7 @@ const AutocompleteInput: React.FC<AutocompleteInputProps> = ({
placeholder={placeholder}
className={className}
list={dataListId}
autoComplete="off" // Importante para que no interfiera el autocompletado del navegador
autoComplete="off"
/>
<datalist id={dataListId}>
{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 {
useReactTable,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
flexRender,
type SortingState,
} from '@tanstack/react-table';
import { Pencil, Trash2 } from 'lucide-react';
import styles from './SimpleTable.module.css';
import { adminService } from '../services/apiService';
import TableSkeleton from './TableSkeleton';
import { accentInsensitiveFilter } from '../utils/filtering';
const BASE_URL = 'http://localhost:5198/api';
// Interfaces para los diferentes tipos de datos
// Interfaces
interface TextValue {
valor: string;
conteo: number;
}
interface RamValue {
id: number;
fabricante?: string;
tamano: number;
velocidad?: number;
partNumber?: string;
conteo: number;
}
type ComponentValue = TextValue | RamValue;
const GestionComponentes = () => {
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 [isModalOpen, setIsModalOpen] = useState(false);
const [valorAntiguo, setValorAntiguo] = useState('');
const [valorNuevo, setValorNuevo] = useState('');
const [globalFilter, setGlobalFilter] = useState('');
const [sorting, setSorting] = useState<SortingState>([]);
useEffect(() => {
setIsLoading(true);
const endpoint = componentType === 'ram' ? `${BASE_URL}/admin/componentes/ram` : `${BASE_URL}/admin/componentes/${componentType}`;
fetch(endpoint)
.then(res => res.json())
adminService.getComponentValues(componentType)
.then(data => {
setValores(data);
})
@@ -42,30 +50,18 @@ const GestionComponentes = () => {
.finally(() => setIsLoading(false));
}, [componentType]);
const handleOpenModal = (valor: string) => {
const handleOpenModal = useCallback((valor: string) => {
setValorAntiguo(valor);
setValorNuevo(valor);
setIsModalOpen(true);
};
}, []);
const handleUnificar = async () => {
const toastId = toast.loading('Unificando valores...');
try {
const response = await fetch(`${BASE_URL}/admin/componentes/${componentType}/unificar`, {
method: 'PUT',
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();
await adminService.unifyComponentValues(componentType, valorAntiguo, valorNuevo);
const refreshedData = await adminService.getComponentValues(componentType);
setValores(refreshedData);
toast.success('Valores unificados correctamente.', { id: toastId });
setIsModalOpen(false);
} catch (error) {
@@ -73,145 +69,200 @@ const GestionComponentes = () => {
}
};
const handleDeleteRam = async (ramId: number) => {
if (!window.confirm("¿Estás seguro de eliminar este módulo de RAM de la base de datos maestra? Esta acción es irreversible.")) {
return;
}
const toastId = toast.loading('Eliminando módulo...');
const handleDeleteRam = useCallback(async (ramGroup: RamValue) => {
if (!window.confirm("¿Estás seguro de eliminar todas las entradas maestras para este tipo de RAM? Esta acción es irreversible.")) return;
const toastId = toast.loading('Eliminando grupo de módulos...');
try {
const response = await fetch(`${BASE_URL}/admin/componentes/ram/${ramId}`, { 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 RamValue).id !== ramId));
toast.success("Módulo de RAM eliminado.", { id: toastId });
await adminService.deleteRamComponent({ fabricante: ramGroup.fabricante, tamano: ramGroup.tamano, velocidad: ramGroup.velocidad });
setValores(prev => prev.filter(v => {
const currentRam = v as RamValue;
return !(currentRam.fabricante === ramGroup.fabricante && currentRam.tamano === ramGroup.tamano && currentRam.velocidad === ramGroup.velocidad);
}));
toast.success("Grupo de módulos de RAM eliminado.", { id: toastId });
} catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
};
}, []);
const handleDeleteTexto = 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...');
try {
await adminService.deleteTextComponent(componentType, valor);
setValores(prev => prev.filter(v => (v as TextValue).valor !== valor));
toast.success("Valor eliminado.", { id: toastId });
} catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
}, [componentType]);
const toastId = toast.loading('Eliminando valor...');
try {
// La API necesita el valor codificado para manejar caracteres especiales como '/'
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));
toast.success("Valor eliminado/confirmado como no existente.", { id: toastId });
} catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
};
const renderValor = (item: TextValue | RamValue) => {
const renderValor = useCallback((item: ComponentValue) => {
if (componentType === 'ram') {
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;
};
}, [componentType]);
const columns = useMemo(() => [
{
header: 'Valor Registrado',
id: 'valor',
accessorFn: (row: ComponentValue) => renderValor(row),
},
{
header: 'Nº de Equipos',
accessorKey: 'conteo',
},
{
header: 'Acciones',
id: 'acciones',
cell: ({ row }: { row: { original: ComponentValue } }) => (
<div style={{ display: 'flex', gap: '5px' }}>
{componentType === 'ram' ? (
<button
onClick={() => handleDeleteRam(row.original as RamValue)}
className={styles.deleteUserButton}
style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.9em', border: '1px solid', borderRadius: '4px', padding: '4px 8px' }}
disabled={row.original.conteo > 0}
title={row.original.conteo > 0 ? `En uso por ${row.original.conteo} equipo(s)` : 'Eliminar este grupo de módulos maestros'}
>
<Trash2 size={14} /> Eliminar
</button>
) : (
<>
<button onClick={() => handleOpenModal((row.original as TextValue).valor)} className={styles.tableButton} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<Pencil size={14} /> Unificar
</button>
<button
onClick={() => handleDeleteTexto((row.original as TextValue).valor)}
className={styles.deleteUserButton}
style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.9em', border: '1px solid', borderRadius: '4px', padding: '4px 8px' }}
disabled={row.original.conteo > 0}
title={row.original.conteo > 0 ? `En uso por ${row.original.conteo} equipo(s). Unifique primero.` : 'Eliminar este valor'}
>
<Trash2 size={14} /> Eliminar
</button>
</>
)}
</div>
)
}
], [componentType, renderValor, handleDeleteRam, handleDeleteTexto, handleOpenModal]);
const table = useReactTable({
data: valores,
columns,
state: { sorting, globalFilter },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
filterFns: {
accentInsensitive: accentInsensitiveFilter,
},
globalFilterFn: 'accentInsensitive',
});
return (
<div>
<h2>Gestión de Componentes Maestros</h2>
<p>Unifica valores inconsistentes y elimina registros no utilizados.</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<h2>Gestión de Componentes Maestros ({table.getFilteredRowModel().rows.length})</h2>
<p>Unifica valores inconsistentes y elimina registros no utilizados.</p>
<p style={{ fontSize: '12px', fontWeight: 'bold' }}>** La tabla permite ordenar por múltiples columnas manteniendo shift al hacer click en la cabecera. **</p>
<div style={{ marginBottom: '1.5rem' }}>
<label><strong>Selecciona un tipo de componente:</strong></label>
<select value={componentType} onChange={e => setComponentType(e.target.value)} className={styles.sectorSelect} style={{marginLeft: '10px'}}>
<option value="os">Sistema Operativo</option>
<option value="cpu">CPU</option>
<option value="motherboard">Motherboard</option>
<option value="architecture">Arquitectura</option>
<option value="ram">Memorias RAM</option>
</select>
</div>
{isLoading ? (
<p>Cargando...</p>
) : (
<table className={styles.table}>
<thead>
<tr>
<th className={styles.th}>Valor Registrado</th>
<th className={styles.th} style={{width: '150px'}}> de Equipos</th>
<th className={styles.th} style={{width: '200px'}}>Acciones</th>
</tr>
</thead>
<tbody>
{valores.map((item) => (
<tr key={componentType === 'ram' ? (item as RamValue).id : (item as TextValue).valor} className={styles.tr}>
<td className={styles.td}>{renderValor(item)}</td>
<td className={styles.td}>{item.conteo}</td>
<td className={styles.td}>
<div style={{display: 'flex', gap: '5px'}}>
{componentType === 'ram' ? (
// Lógica solo para RAM (no tiene sentido "unificar" un objeto complejo)
<button
onClick={() => handleDeleteRam((item as RamValue).id)}
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
</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>
</tr>
))}
</tbody>
</table>
)}
<div className={styles.controlsContainer}>
<input
type="text"
placeholder="Filtrar registros..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className={styles.searchInput}
style={{ width: '300px' }}
/>
<label><strong>Tipo de componente:</strong></label>
<select value={componentType} onChange={e => setComponentType(e.target.value)} className={styles.sectorSelect}>
<option value="os">Sistema Operativo</option>
<option value="cpu">CPU</option>
<option value="motherboard">Motherboard</option>
<option value="architecture">Arquitectura</option>
<option value="ram">Memorias RAM</option>
</select>
</div>
{isModalOpen && (
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<h3>Unificar Valor</h3>
<p>Se reemplazarán todas las instancias de:</p>
<strong style={{ display: 'block', marginBottom: '1rem', background: '#e9ecef', padding: '8px', borderRadius: '4px' }}>{valorAntiguo}</strong>
<label>Por el nuevo valor:</label>
<input type="text" value={valorNuevo} onChange={e => setValorNuevo(e.target.value)} className={styles.modalInput} />
<div className={styles.modalActions}>
<button onClick={handleUnificar} className={`${styles.btn} ${styles.btnPrimary}`} disabled={!valorNuevo.trim() || valorNuevo === valorAntiguo}>Unificar</button>
<button onClick={() => setIsModalOpen(false)} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button>
</div>
</div>
</div>
)}
{isLoading ? (
<div className={styles.tableContainer}>
<TableSkeleton rows={6} columns={3} />
</div>
);
) : (
<div className={styles.tableContainer}>
<table className={styles.table}>
<thead>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => {
const classNames = [styles.th];
if (header.id === 'conteo') classNames.push(styles.thNumeric);
if (header.id === 'acciones') classNames.push(styles.thActions);
return (
<th
key={header.id}
className={classNames.join(' ')}
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getIsSorted() && (
<span className={styles.sortIndicator}>
{header.column.getIsSorted() === 'asc' ? '⇑' : '⇓'}
</span>
)}
</th>
)
})}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id} className={styles.tr}>
{row.getVisibleCells().map(cell => {
const classNames = [styles.td];
if (cell.column.id === 'conteo') classNames.push(styles.tdNumeric);
if (cell.column.id === 'acciones') classNames.push(styles.tdActions);
return (
<td
key={cell.id}
className={classNames.join(' ')}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
)}
{isModalOpen && (
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<h3>Unificar Valor</h3>
<p>Se reemplazarán todas las instancias de:</p>
<strong className={styles.highlightBox}>{valorAntiguo}</strong>
<label>Por el nuevo valor:</label>
<input type="text" value={valorNuevo} onChange={e => setValorNuevo(e.target.value)} className={styles.modalInput} />
<div className={styles.modalActions}>
<button onClick={handleUnificar} className={`${styles.btn} ${styles.btnPrimary}`} disabled={!valorNuevo.trim() || valorNuevo === valorAntiguo}>Unificar</button>
<button onClick={() => setIsModalOpen(false)} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button>
</div>
</div>
</div>
)}
</div>
);
};
export default GestionComponentes;

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 {
useReactTable,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
flexRender,
type SortingState,
type ColumnDef,
} from '@tanstack/react-table';
import { PlusCircle, Pencil, Trash2 } from 'lucide-react';
import { accentInsensitiveFilter } from '../utils/filtering';
import type { Sector } from '../types/interfaces';
import styles from './SimpleTable.module.css';
import ModalSector from './ModalSector';
const BASE_URL = 'http://localhost:5198/api';
import TableSkeleton from './TableSkeleton'; // <-- 1. Importar el esqueleto
import { sectorService } from '../services/apiService';
const GestionSectores = () => {
const [sectores, setSectores] = useState<Sector[]>([]);
@@ -12,121 +26,183 @@ const GestionSectores = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingSector, setEditingSector] = useState<Sector | null>(null);
// --- 2. Estados para filtro y ordenación ---
const [globalFilter, setGlobalFilter] = useState('');
const [sorting, setSorting] = useState<SortingState>([]);
useEffect(() => {
fetch(`${BASE_URL}/sectores`)
.then(res => res.json())
.then((data: Sector[]) => {
setSectores(data);
setIsLoading(false);
setIsLoading(true); // Aseguramos que se muestre el esqueleto al cargar
sectorService.getAll()
.then(data => {
// Ordenar alfabéticamente por defecto
const sectoresOrdenados = [...data].sort((a, b) => a.nombre.localeCompare(b.nombre, 'es', { sensitivity: 'base' }));
setSectores(sectoresOrdenados);
})
.catch(err => {
toast.error("No se pudieron cargar los sectores.");
console.error(err);
})
.finally(() => {
setIsLoading(false);
});
}, []);
const handleOpenCreateModal = () => {
setEditingSector(null); // Poner en modo 'crear'
setEditingSector(null);
setIsModalOpen(true);
};
const handleOpenEditModal = (sector: Sector) => {
setEditingSector(sector); // Poner en modo 'editar' con los datos del sector
const handleOpenEditModal = useCallback((sector: Sector) => {
setEditingSector(sector);
setIsModalOpen(true);
};
}, []);
const handleSave = async (id: number | null, nombre: string) => {
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...');
try {
const response = await fetch(url, {
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ó.');
}
let refreshedData;
if (isEditing) {
// Actualizar el sector en la lista local
setSectores(prev => prev.map(s => s.id === id ? { ...s, nombre } : s));
await sectorService.update(id, nombre);
refreshedData = await sectorService.getAll();
toast.success('Sector actualizado.', { id: toastId });
} else {
// Añadir el nuevo sector a la lista local
const nuevoSector = await response.json();
setSectores(prev => [...prev, nuevoSector]);
await sectorService.create(nombre);
refreshedData = await sectorService.getAll();
toast.success('Sector creado.', { id: toastId });
}
setIsModalOpen(false); // Cerrar el modal
const sectoresOrdenados = [...refreshedData].sort((a, b) => a.nombre.localeCompare(b.nombre, 'es', { sensitivity: 'base' }));
setSectores(sectoresOrdenados);
setIsModalOpen(false);
} catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
};
const handleDelete = 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.")) {
return;
}
const toastId = toast.loading('Eliminando...');
try {
const response = await fetch(`${BASE_URL}/sectores/${id}`, { method: 'DELETE' });
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.");
}
await sectorService.delete(id);
setSectores(prev => prev.filter(s => s.id !== id));
toast.success("Sector eliminado.", { id: toastId });
} catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
};
}, []);
if (isLoading) {
return <div>Cargando sectores...</div>;
}
// --- 3. Definición de columnas para React Table ---
const columns = useMemo<ColumnDef<Sector>[]>(() => [
{
header: 'Nombre del Sector',
accessorKey: 'nombre',
},
{
header: 'Acciones',
id: 'acciones',
cell: ({ row }) => (
<div style={{ display: 'flex', gap: '10px' }}>
<button onClick={() => handleOpenEditModal(row.original)} className={styles.tableButton} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}><Pencil size={16} /> Editar</button>
<button onClick={() => handleDelete(row.original.id)} className={styles.deleteUserButton} style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '1em', padding: '0.375rem 0.75rem', border: '1px solid', borderRadius: '4px' }}>
<Trash2 size={16} /> Eliminar
</button>
</div>
)
}
], [handleOpenEditModal, handleDelete]);
// --- 4. Instancia de la tabla ---
const table = useReactTable({
data: sectores,
columns,
state: { sorting, globalFilter },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
filterFns: {
accentInsensitive: accentInsensitiveFilter,
},
globalFilterFn: 'accentInsensitive',
});
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h2>Gestión de Sectores</h2>
<button onClick={handleOpenCreateModal} className={`${styles.btn} ${styles.btnPrimary}`}>
+ Añadir Sector
<h2>Gestión de Sectores ({table.getFilteredRowModel().rows.length})</h2>
<p>Crea, edita y elimina los sectores de la organización.</p>
<div className={styles.controlsContainer}>
<input
type="text"
placeholder="Filtrar sectores..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className={styles.searchInput}
style={{ width: '300px' }}
/>
<button onClick={handleOpenCreateModal} className={`${styles.btn} ${styles.btnPrimary}`} style={{ display: 'flex', alignItems: 'center', gap: '8px', marginLeft: 'auto' }}>
<PlusCircle size={18} /> Añadir Sector
</button>
</div>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.th}>Nombre del Sector</th>
<th className={styles.th} style={{ width: '200px' }}>Acciones</th>
</tr>
</thead>
<tbody>
{sectores.map(sector => (
<tr key={sector.id} className={styles.tr}>
<td className={styles.td}>{sector.nombre}</td>
<td className={styles.td}>
<div style={{ display: 'flex', gap: '10px' }}>
<button onClick={() => handleOpenEditModal(sector)} className={styles.tableButton}> Editar</button>
<button onClick={() => handleDelete(sector.id)} className={styles.deleteUserButton} style={{fontSize: '1em', padding: '0.375rem 0.75rem', border: '1px solid #dc3545', borderRadius: '4px'}}>
🗑 Eliminar
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{isLoading ? (
<div className={styles.tableContainer}>
<TableSkeleton rows={6} columns={2} />
</div>
) : (
<div className={styles.tableContainer}>
<table className={styles.table}>
<thead>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => {
const classNames = [styles.th];
if (header.id === 'acciones') {
classNames.push(styles.thActions);
}
return (
<th
key={header.id}
className={classNames.join(' ')}
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getIsSorted() && (
<span className={styles.sortIndicator}>
{header.column.getIsSorted() === 'asc' ? '⇑' : '⇓'}
</span>
)}
</th>
);
})}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id} className={styles.tr}>
{row.getVisibleCells().map(cell => {
const classNames = [styles.td];
if (cell.column.id === 'acciones') {
classNames.push(styles.tdActions);
}
return (
<td key={cell.id} className={classNames.join(' ')}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)}
{isModalOpen && (
<ModalSector

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 (
<div className={styles.modalOverlay}>
<div className={`${styles.modalOverlay} ${styles['modalOverlay--nested']}`}>
<div className={styles.modal}>
<h3>Añadir Disco Manualmente</h3>
<label>Tipo de Disco</label>

View File

@@ -1,5 +1,6 @@
// 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 AutocompleteInput from './AutocompleteInput';
import styles from './SimpleTable.module.css';
@@ -10,7 +11,7 @@ interface ModalAnadirEquipoProps {
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 [nuevoEquipo, setNuevoEquipo] = useState({
@@ -31,12 +32,17 @@ const ModalAnadirEquipo: React.FC<ModalAnadirEquipoProps> = ({ sectores, onClose
};
const handleSaveClick = () => {
// La UI pasará un objeto compatible con el DTO del backend
onSave(nuevoEquipo as any);
};
const isFormValid = nuevoEquipo.hostname.trim() !== '' && nuevoEquipo.ip.trim() !== '';
// --- 2. Memorizar las funciones con useCallback ---
// El array vacío `[]` al final asegura que la función NUNCA se vuelva a crear.
const fetchMotherboardSuggestions = useCallback(() => fetch(`${BASE_URL}/equipos/distinct/motherboard`).then(res => res.json()), []);
const fetchCpuSuggestions = useCallback(() => fetch(`${BASE_URL}/equipos/distinct/cpu`).then(res => res.json()), []);
const fetchOsSuggestions = useCallback(() => fetch(`${BASE_URL}/equipos/distinct/os`).then(res => res.json()), []);
return (
<div className={styles.modalOverlay}>
<div className={styles.modal} style={{ minWidth: '500px' }}>
@@ -49,7 +55,8 @@ const ModalAnadirEquipo: React.FC<ModalAnadirEquipoProps> = ({ sectores, onClose
value={nuevoEquipo.hostname}
onChange={handleChange}
className={styles.modalInput}
placeholder="Ej: CONTABILIDAD-01"
placeholder="Ej: TECNICA10"
autoComplete="off"
/>
<label>Dirección IP (Requerido)</label>
@@ -59,7 +66,8 @@ const ModalAnadirEquipo: React.FC<ModalAnadirEquipoProps> = ({ sectores, onClose
value={nuevoEquipo.ip}
onChange={handleChange}
className={styles.modalInput}
placeholder="Ej: 192.168.1.50"
placeholder="Ej: 192.168.10.50"
autoComplete="off"
/>
<label>Sector</label>
@@ -75,31 +83,35 @@ const ModalAnadirEquipo: React.FC<ModalAnadirEquipoProps> = ({ sectores, onClose
))}
</select>
{/* --- 3. Usar las funciones memorizadas --- */}
<label>Motherboard (Opcional)</label>
<AutocompleteInput
mode="static"
name="motherboard"
value={nuevoEquipo.motherboard}
onChange={handleChange}
className={styles.modalInput}
fetchSuggestions={() => fetch(`${BASE_URL}/equipos/distinct/motherboard`).then(res => res.json())}
fetchSuggestions={fetchMotherboardSuggestions}
/>
<label>CPU (Opcional)</label>
<AutocompleteInput
mode="static"
name="cpu"
value={nuevoEquipo.cpu}
onChange={handleChange}
className={styles.modalInput}
fetchSuggestions={() => fetch(`${BASE_URL}/equipos/distinct/cpu`).then(res => res.json())}
fetchSuggestions={fetchCpuSuggestions}
/>
<label>Sistema Operativo (Opcional)</label>
<AutocompleteInput
mode="static"
name="os"
value={nuevoEquipo.os}
onChange={handleChange}
className={styles.modalInput}
fetchSuggestions={() => fetch(`${BASE_URL}/equipos/distinct/os`).then(res => res.json())}
fetchSuggestions={fetchOsSuggestions}
/>
<div className={styles.modalActions}>

View File

@@ -1,40 +1,100 @@
// frontend/src/components/ModalAnadirRam.tsx
import React, { useState } from 'react';
import React, { useState, useCallback, useEffect } from 'react';
import styles from './SimpleTable.module.css';
import AutocompleteInput from './AutocompleteInput';
import { memoriaRamService } from '../services/apiService';
import type { MemoriaRam } from '../types/interfaces';
interface Props {
onClose: () => void;
onSave: (ram: { slot: string, tamano: number, fabricante?: string, velocidad?: number }) => void;
onSave: (ram: { slot: string, tamano: number, fabricante?: string, velocidad?: number, partNumber?: string }) => void;
}
const ModalAnadirRam: React.FC<Props> = ({ onClose, onSave }) => {
const [ram, setRam] = useState({ slot: '', tamano: '', fabricante: '', velocidad: '' });
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>) => {
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 = () => {
onSave({
slot: ram.slot,
tamano: parseInt(ram.tamano, 10),
fabricante: ram.fabricante || undefined,
velocidad: ram.velocidad ? parseInt(ram.velocidad, 10) : undefined,
partNumber: ram.partNumber || undefined,
});
};
return (
<div className={styles.modalOverlay}>
<div className={`${styles.modalOverlay} ${styles['modalOverlay--nested']}`}>
<div className={styles.modal}>
<h3>Añadir Módulo de RAM</h3>
<label>Slot (Requerido)</label>
<input type="text" name="slot" value={ram.slot} onChange={handleChange} className={styles.modalInput} placeholder="Ej: DIMM0" />
<label>Buscar Módulo Existente (Opcional)</label>
<AutocompleteInput
mode="static"
name="partNumber"
value={ram.partNumber}
onChange={handleChange}
className={styles.modalInput}
fetchSuggestions={fetchRamSuggestions}
placeholder="Clic para ver todos o escribe para filtrar"
/>
<label>Tamaño (GB) (Requerido)</label>
<input type="number" name="tamano" value={ram.tamano} onChange={handleChange} className={styles.modalInput} placeholder="Ej: 8" />
<label>Fabricante (Opcional)</label>
<label>Fabricante</label>
<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} />
<div className={styles.modalActions}>
<button onClick={handleSave} className={`${styles.btn} ${styles.btnPrimary}`} disabled={!ram.slot || !ram.tamano}>Guardar</button>
<button onClick={onClose} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button>

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 AutocompleteInput from './AutocompleteInput';
import { usuarioService } from '../services/apiService';
interface Props {
onClose: () => void;
onSave: (usuario: { username: string }) => void;
}
const BASE_URL = 'http://localhost:5198/api';
const ModalAnadirUsuario: React.FC<Props> = ({ onClose, onSave }) => {
const [username, setUsername] = useState('');
const fetchUserSuggestions = async (query: string): Promise<string[]> => {
const fetchUserSuggestions = useCallback(async (query: string): Promise<string[]> => {
if (!query) return [];
const response = await fetch(`${BASE_URL}/usuarios/buscar/${query}`);
if (!response.ok) return [];
return response.json();
};
try {
return await usuarioService.search(query);
} catch (error) {
console.error("Error buscando usuarios", error);
return [];
}
}, []);
return (
<div className={styles.modalOverlay}>
<div className={`${styles.modalOverlay} ${styles['modalOverlay--nested']}`}>
<div className={styles.modal}>
<h3>Añadir Usuario Manualmente</h3>
<label>Nombre de Usuario</label>
<AutocompleteInput
mode="dynamic"
name="username"
value={username}
onChange={e => setUsername(e.target.value)}

View File

@@ -1,17 +1,19 @@
// 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 { Tooltip } from 'react-tooltip';
import styles from './SimpleTable.module.css';
import toast from 'react-hot-toast';
import AutocompleteInput from './AutocompleteInput';
import { equipoService } from '../services/apiService';
import { X, Pencil, HardDrive, MemoryStick, UserPlus, Trash2, Power, Info, Component, Keyboard, Cog, Zap, History } from 'lucide-react';
// Interfaces actualizadas para las props
interface ModalDetallesEquipoProps {
equipo: Equipo;
isOnline: boolean;
historial: HistorialEquipo[];
sectores: Sector[];
isChildModalOpen: boolean;
onClose: () => void;
onDelete: (id: number) => Promise<boolean>;
onRemoveAssociation: (type: 'disco' | 'ram' | 'usuario', id: any) => void;
@@ -19,10 +21,9 @@ interface ModalDetallesEquipoProps {
onAddComponent: (type: 'disco' | 'ram' | 'usuario') => void;
}
const BASE_URL = 'http://localhost:5198/api';
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 [editableEquipo, setEditableEquipo] = useState({ ...equipo });
@@ -75,6 +76,11 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
setIsEditing(false);
};
const handleEditClick = () => {
setEditableEquipo({ ...equipo });
setIsEditing(true);
};
const handleWolClick = async () => {
if (!equipo.mac || !equipo.ip) {
toast.error("Este equipo no tiene MAC o IP para encenderlo.");
@@ -82,12 +88,7 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
}
const toastId = toast.loading('Enviando paquete WOL...');
try {
const response = await fetch(`${BASE_URL}/equipos/wake-on-lan`, {
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.");
await equipoService.wakeOnLan(equipo.mac, equipo.ip);
toast.success('Solicitud de encendido enviada.', { id: toastId });
} catch (error) {
toast.error('Error al enviar la solicitud.', { id: toastId });
@@ -102,16 +103,25 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
const formatDate = (dateString: string | undefined | null) => {
if (!dateString || dateString.startsWith('0001-01-01')) return 'No registrado';
return new Date(dateString).toLocaleString('es-ES', {
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'
const utcDate = new Date(dateString.replace(' ', 'T') + 'Z');
return utcDate.toLocaleString('es-ES', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit'
});
};
const isSaveDisabled = !editableEquipo.hostname || !editableEquipo.ip || !isMacValid;
const fetchOsSuggestions = useCallback(() => equipoService.getDistinctValues('os'), []);
const fetchMotherboardSuggestions = useCallback(() => equipoService.getDistinctValues('motherboard'), []);
const fetchCpuSuggestions = useCallback(() => equipoService.getDistinctValues('cpu'), []);
return (
<div className={styles.modalLarge}>
<button onClick={onClose} className={styles.closeButton}>×</button>
<button onClick={onClose} className={styles.closeButton} disabled={isChildModalOpen}>
<X size={20} />
</button>
<div className={styles.modalLargeContent}>
<div className={styles.modalLargeHeader}>
<h2>Detalles del equipo: <strong>{equipo.hostname}</strong></h2>
@@ -123,65 +133,123 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
<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 className={styles.modalBodyColumns}>
{/* COLUMNA PRINCIPAL */}
<div className={styles.mainColumn}>
{/* SECCIÓN DE DATOS PRINCIPALES */}
<div className={styles.section}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 className={styles.sectionTitle} style={{ border: 'none', padding: 0 }}>🔗 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>)}
<h3 className={styles.sectionTitle} style={{ border: 'none', padding: 0 }}><Info size={20} /> Datos Principales</h3>
{equipo.origen === 'manual' && (
<div style={{ display: 'flex', gap: '5px' }}>
<button onClick={() => onAddComponent('disco')} className={styles.tableButtonMas} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}><HardDrive size={16} /> Disco</button>
<button onClick={() => onAddComponent('ram')} className={styles.tableButtonMas} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}><MemoryStick size={16} /> RAM</button>
<button onClick={() => onAddComponent('usuario')} className={styles.tableButtonMas} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}><UserPlus size={16} /> Usuario</button>
</div>
)}
</div>
<div className={styles.componentsGrid}>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Hostname:</strong>{isEditing ? <input type="text" name="hostname" value={editableEquipo.hostname} onChange={handleChange} className={styles.modalInput} /> : <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}>Hostname:</strong>{isEditing ? <input type="text" name="hostname" value={editableEquipo.hostname} onChange={handleChange} className={styles.modalInput} autoComplete="off" /> : <span className={styles.detailValue}>{equipo.hostname}</span>}</div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>IP:</strong>{isEditing ? <input type="text" name="ip" value={editableEquipo.ip} onChange={handleChange} className={styles.modalInput} autoComplete="off" /> : <span className={styles.detailValue}>{equipo.ip}</span>}</div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>MAC Address:</strong>{isEditing ? (<div><input type="text" name="mac" value={editableEquipo.mac || ''} onChange={handleChange} onBlur={handleMacBlur} className={`${styles.modalInput} ${!isMacValid ? styles.inputError : ''}`} placeholder="FC:AA:14:92:12:99" />{!isMacValid && <small className={styles.errorMessage}>Formato inválido.</small>}</div>) : (<span className={styles.detailValue}>{equipo.mac || 'N/A'}</span>)}</div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Sistema Operativo:</strong>{isEditing ? <AutocompleteInput 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}>Creación:</strong><span className={styles.detailValue}>{formatDate(equipo.created_at)}</span></div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Última Actualización:</strong><span className={styles.detailValue}>{formatDate(equipo.updated_at)}</span></div>
<div className={styles.detailItemFull}><strong className={styles.detailLabel}>Usuarios:</strong><span className={styles.detailValue}>{equipo.usuarios?.length > 0 ? equipo.usuarios.map(u => (<div key={u.id} className={styles.componentItem}><div><span title={`Origen: ${u.origen}`}>{u.origen === 'manual' ? '⌨️' : '⚙️'}</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>
{/* SECCIÓN DE COMPONENTES */}
<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.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}>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}>Motherboard:</strong>{isEditing ? <AutocompleteInput mode="static" name="motherboard" value={editableEquipo.motherboard} onChange={handleChange} className={styles.modalInput} fetchSuggestions={fetchMotherboardSuggestions} /> : <span className={styles.detailValue}>{equipo.motherboard || 'N/A'}</span>}</div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>CPU:</strong>{isEditing ? <AutocompleteInput mode="static" name="cpu" value={editableEquipo.cpu} onChange={handleChange} className={styles.modalInput} fetchSuggestions={fetchCpuSuggestions} /> : <span className={styles.detailValue}>{equipo.cpu || 'N/A'}</span>}</div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>RAM Instalada:</strong><span className={styles.detailValue}>{equipo.ram_installed} GB</span></div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Arquitectura:</strong><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' ? '⌨️' : '⚙️'}</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>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Slots RAM:</strong><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' ? '⌨️' : '⚙️'}</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>
<div className={styles.detailItem}>
<strong className={styles.detailLabel}>Arquitectura:</strong>
{isEditing ? (
<select
name="architecture"
value={editableEquipo.architecture || ''}
onChange={handleChange}
className={styles.modalInput}
>
<option value="">- Seleccionar -</option>
<option value="64 bits">64 bits</option>
<option value="32 bits">32 bits</option>
</select>
) : (
<span className={styles.detailValue}>{equipo.architecture || 'N/A'}</span>
)}
</div>
<div className={styles.detailItemFull}><strong className={styles.detailLabel}>Discos:</strong><span className={styles.detailValue}>{equipo.discos?.length > 0 ? equipo.discos.map(d => (<div key={d.equipoDiscoId} className={styles.componentItem}><div><span title={`Origen: ${d.origen}`}>{d.origen === 'manual' ? <Keyboard size={16} /> : <Cog size={16} />}</span>{` ${d.mediatype} ${d.size}GB`}</div>{d.origen === 'manual' && (<button onClick={() => onRemoveAssociation('disco', d.equipoDiscoId)} className={styles.deleteUserButton} title="Eliminar este disco"><Trash2 size={16} /></button>)}</div>)) : 'N/A'}</span></div>
<div className={styles.detailItem}>
<strong className={styles.detailLabel}>Total Slots RAM:</strong>
{isEditing ? (
<input
type="number"
name="ram_slots"
value={editableEquipo.ram_slots || ''}
onChange={handleChange}
className={styles.modalInput}
placeholder="Ej: 4"
/>
) : (
<span className={styles.detailValue}>{equipo.ram_slots || 'N/A'}</span>
)}
</div>
<div className={styles.detailItemFull}><strong className={styles.detailLabel}>Módulos RAM:</strong><span className={styles.detailValue}>{equipo.memoriasRam?.length > 0 ? equipo.memoriasRam.map(m => (<div key={m.equipoMemoriaRamId} className={styles.componentItem}><div><span title={`Origen: ${m.origen}`}>{m.origen === 'manual' ? <Keyboard size={16} /> : <Cog size={16} />}</span>{` Slot ${m.slot}: ${m.partNumber || 'N/P'} ${m.tamano}GB ${m.velocidad || ''}MHz`}</div>{m.origen === 'manual' && (<button onClick={() => onRemoveAssociation('ram', m.equipoMemoriaRamId)} className={styles.deleteUserButton} title="Eliminar este módulo"><Trash2 size={16} /></button>)}</div>)) : 'N/A'}</span></div>
</div>
</div>
</div>
{/* COLUMNA LATERAL */}
<div className={styles.sidebarColumn}>
{/* SECCIÓN DE ACCIONES */}
<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.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>
{/* SECCIÓN DE HISTORIAL (FUERA DE LAS COLUMNAS) */}
<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}>
<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>
@@ -189,7 +257,6 @@ const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
</table>
</div>
</div>
</div>
</div>
);

View File

@@ -1,7 +1,10 @@
// frontend/src/components/Navbar.tsx
import React from 'react';
import type { View } from '../App'; // Importaremos el tipo desde App.tsx
import '../App.css'; // Usaremos los estilos globales que acabamos de crear
import type { View } from '../App';
import ThemeToggle from './ThemeToggle';
import { useAuth } from '../context/AuthContext';
import { LogOut } from 'lucide-react';
import '../App.css';
interface NavbarProps {
currentView: View;
@@ -9,6 +12,7 @@ interface NavbarProps {
}
const Navbar: React.FC<NavbarProps> = ({ currentView, setCurrentView }) => {
const { logout } = useAuth();
return (
<header className="navbar">
<div className="app-title">
@@ -33,6 +37,22 @@ const Navbar: React.FC<NavbarProps> = ({ currentView, setCurrentView }) => {
>
Administración
</button>
<button
className={`nav-link ${currentView === 'dashboard' ? 'nav-link-active' : ''}`}
onClick={() => setCurrentView('dashboard')}
>
Dashboard
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginLeft: '1rem' }}>
<ThemeToggle />
<button
onClick={logout}
className="theme-toggle-button"
title="Cerrar sesión"
>
<LogOut size={20} />
</button>
</div>
</nav>
</header>
);

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 */
.header {
margin-bottom: 0.75rem;
}
.header h2 {
margin-top: 5px;
margin-bottom: 1rem;
font-size: 1.25rem;
}
.controlsContainer {
display: flex;
gap: 20px;
margin-bottom: 10px;
margin-bottom: 0.5rem;
align-items: center;
}
.searchInput, .sectorSelect {
padding: 8px 12px;
border-radius: 6px;
border: 1px solid #ced4da;
border: 1px solid var(--color-border);
font-size: 14px;
background-color: var(--color-surface);
color: var(--color-text-primary);
}
.paginationControls {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
}
.tableContainer {
overflow: auto; /* Cambiado a 'auto' para ambos scrolls */
border: 1px solid var(--color-border);
border-radius: 8px;
position: relative; /* Necesario para posicionar el botón de scroll */
}
.tableContainer::-webkit-scrollbar {
height: 8px;
width: 8px;
background-color: var(--scrollbar-bg);
}
.tableContainer::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-thumb);
border-radius: 4px;
}
/* Estilos de la tabla */
.table {
border-collapse: collapse;
font-family: system-ui, -apple-system, sans-serif;
font-size: 0.875rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
width: 100%;
min-width: 1200px; /* Ancho mínimo para forzar el scroll horizontal si es necesario */
min-width: 100%;
}
.th {
color: #212529;
color: var(--color-text-primary);
font-weight: 600;
padding: 0.75rem 1rem;
border-bottom: 2px solid #dee2e6;
border-bottom: 2px solid var(--color-border);
text-align: left;
cursor: pointer;
user-select: none;
white-space: nowrap;
background-color: var(--color-background);
overflow: hidden;
text-overflow: ellipsis;
position: sticky;
top: 0; /* Mantiene la posición sticky en la parte superior del viewport */
top: 0;
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 {
margin-left: 0.5rem;
font-size: 1.2em;
display: inline-block;
transform: translateY(-1px);
color: #007bff;
color: var(--color-primary);
min-width: 20px;
}
.tooltip{
.tooltip {
z-index: 9999;
}
@@ -56,81 +123,152 @@
}
.tr:hover {
background-color: #f1f3f5;
background-color: var(--color-surface-hover);
}
.td {
padding: 0.75rem 1rem;
border-bottom: 1px solid #e9ecef;
color: #495057;
background-color: white;
border-bottom: 1px solid var(--color-border-subtle);
color: var(--color-text-secondary);
background-color: var(--color-surface);
word-break: break-word;
overflow: hidden;
text-overflow: ellipsis;
}
/* Estilos de botones dentro de la tabla */
.hostnameButton {
background: none;
border: none;
color: #007bff;
color: var(--color-primary);
cursor: pointer;
text-decoration: underline;
padding: 0;
font-size: inherit;
font-family: inherit;
transition: color 0.2s ease;
}
.hostnameButton:hover {
color: var(--color-primary-hover);
}
.tableButton {
padding: 0.375rem 0.75rem;
border-radius: 4px;
border: 1px solid #dee2e6;
border: 1px solid var(--color-border);
background-color: transparent;
color: #212529;
color: var(--color-text-primary);
cursor: pointer;
transition: all 0.2s ease;
}
.tableButton:hover {
background-color: #e9ecef;
border-color: #adb5bd;
background-color: var(--color-surface-hover);
border-color: var(--color-text-muted);
}
.tableButtonMas {
padding: 0.375rem 0.75rem;
border-radius: 4px;
border: 1px solid var(--color-primary);
background-color: var(--color-primary);
color: var(--color-navbar-text-hover);
cursor: pointer;
transition: all 0.2s ease;
}
.tableButtonMas:hover {
background-color: var(--color-primary-hover);
border-color: var(--color-primary-hover);
}
.deleteUserButton {
background: none;
border: none;
cursor: pointer;
color: #dc3545;
color: var(--color-danger);
font-size: 1rem;
padding: 0 5px;
opacity: 0.7;
transition: opacity 0.3s ease, color 0.3s ease;
transition: opacity 0.3s ease, color 0.3s ease, background-color 0.3s ease;
line-height: 1;
}
.deleteUserButton:hover {
opacity: 1;
color: #a4202e;
.deleteUserButton:hover:not(:disabled) {
color: var(--color-danger-hover);
}
.deleteUserButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Estilo para el botón de scroll-to-top */
.scrollToTop {
position: fixed;
bottom: 60px;
right: 20px;
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #007bff;
color: white;
border: none;
cursor: pointer;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
font-size: 20px;
position: absolute;
top: 6px;
right: 20px; /* Suficiente espacio para no quedar debajo de la scrollbar */
z-index: 30; /* Un valor alto para asegurar que esté por encima de la tabla y su cabecera (z-index: 2) */
width: 36px;
height: 36px;
background-color: var(--color-surface);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
border-radius: 8%;
/* Contenido y transiciones */
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.3s, transform 0.3s;
z-index: 1002;
cursor: pointer;
transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out, background-color 0.2s, color 0.2s;
/* Animación de entrada/salida */
animation: pop-in 0.3s ease-out forwards;
}
.scrollToTop:hover {
transform: translateY(-3px);
background-color: #0056b3;
background-color: var(--color-primary);
color: white;
transform: scale(1.1);
}
/* ===== INICIO DE CAMBIOS PARA MODALES Y ANIMACIONES ===== */
/* Keyframes para la animación de entrada */
@keyframes pop-in {
from {
opacity: 0;
transform: scale(0.5);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Estilos genéricos para modales */
@@ -145,23 +283,27 @@
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease-out;
/* Aplicamos animación */
}
.modal {
background-color: #ffffff;
background-color: var(--color-surface);
border-radius: 12px;
padding: 2rem;
box-shadow: 0px 8px 30px rgba(0, 0, 0, 0.12);
z-index: 1000;
min-width: 400px;
max-width: 90%;
border: 1px solid #e0e0e0;
border: 1px solid var(--color-border);
font-family: 'Segoe UI', sans-serif;
animation: scaleIn 0.2s ease-out;
/* Aplicamos animación */
}
.modal h3 {
margin: 0 0 1.5rem;
color: #2d3436;
color: var(--color-text-primary);
}
.modal label {
@@ -173,18 +315,32 @@
.modalInput {
padding: 10px;
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%;
box-sizing: border-box;
margin-top: 4px; /* Separado del label */
margin-bottom: 4px; /* Espacio antes del siguiente elemento */
margin-top: 4px;
/* Separado del label */
margin-bottom: 4px;
/* Espacio antes del siguiente elemento */
transition: border-color 0.2s ease, box-shadow 0.2s ease;
/* Transición para el foco */
}
.modalInput:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 25%, transparent);
}
.modalActions {
display: flex;
gap: 10px;
margin-top: 1.5rem;
justify-content: flex-end; /* Alinea los botones a la derecha por defecto */
justify-content: flex-end;
/* Alinea los botones a la derecha por defecto */
}
/* Estilos de botones para modales */
@@ -199,27 +355,27 @@
}
.btnPrimary {
background-color: #007bff;
background-color: var(--color-primary);
color: white;
}
.btnPrimary:hover {
background-color: #0056b3;
background-color: var(--color-primary-hover);
}
.btnPrimary:disabled {
background-color: #e9ecef;
color: #6c757d;
background-color: var(--color-surface-hover);
color: var(--color-text-muted);
cursor: not-allowed;
}
.btnSecondary {
background-color: #6c757d;
background-color: var(--color-text-muted);
color: white;
}
.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 {
position: fixed;
@@ -227,19 +383,24 @@
left: 0;
width: 100vw;
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;
overflow-y: auto;
display: flex;
flex-direction: column;
padding: 2rem;
box-sizing: border-box;
animation: fadeIn 0.3s ease-out;
/* Animación ligeramente más lenta para pantalla completa */
}
.modalLargeContent {
max-width: 1400px; /* Ancho máximo del contenido */
max-width: 1400px;
/* Ancho máximo del contenido */
width: 100%;
margin: 0 auto; /* Centrar el contenido */
margin: 0 auto;
/* Centrar el contenido */
}
.modalLargeHeader {
@@ -253,7 +414,7 @@
.modalLargeHeader h2 {
font-weight: 400;
font-size: 1.5rem;
color: #343a40;
color: var(--color-text-primary);
}
.closeButton {
@@ -269,12 +430,13 @@
align-items: center;
justify-content: center;
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;
position: fixed;
right: 30px;
top: 30px;
}
.closeButton:hover {
transform: scale(1.1);
background-color: #333;
@@ -300,19 +462,19 @@
}
.section {
background-color: #ffffff;
border: 1px solid #dee2e6;
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.sectionTitle {
font-size: 1.25rem;
margin: 0 0 1rem 0;
padding-bottom: 0.75rem;
border-bottom: 1px solid #e9ecef;
color: #2d3436;
border-bottom: 1px solid var(--color-border-subtle);
color: var(--color-text-primary);
font-weight: 600;
display: flex;
align-items: center;
@@ -325,7 +487,6 @@
gap: 1rem;
}
/* CAMBIO: Se aplica el mismo estilo de grid a componentsGrid para que se vea igual que detailsGrid */
.componentsGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
@@ -338,24 +499,25 @@
gap: 1rem;
}
.detailItem, .detailItemFull {
.detailItem,
.detailItemFull {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 10px;
background-color: #f8f9fa;
border-radius: 4px;
border: 1px solid #e9ecef;
background-color: var(--color-background);
border: 1px solid var(--color-border-subtle);
}
.detailLabel {
color: #6c757d;
color: var(--color-text-muted);
font-size: 0.8rem;
font-weight: 700;
}
.detailValue {
color: #495057;
color: var(--color-text-secondary);
font-size: 0.9rem;
line-height: 1.4;
word-break: break-word;
@@ -369,9 +531,10 @@
padding: 2px 0;
}
.powerButton, .deleteButton {
.powerButton,
.deleteButton {
background: none;
border: 1px solid #dee2e6;
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 8px;
cursor: pointer;
@@ -383,10 +546,14 @@
justify-content: center;
}
.powerButton {
color: var(--color-text-secondary);
}
.powerButton:hover {
border-color: #007bff;
background-color: #e7f1ff;
color: #0056b3;
border-color: var(--color-primary);
background-color: color-mix(in srgb, var(--color-primary) 15%, transparent);
color: var(--color-primary-hover);
}
.powerIcon {
@@ -395,13 +562,16 @@
}
.deleteButton {
color: #dc3545;
color: var(--color-danger);
transition: all 0.2s ease;
}
.deleteButton:hover {
border-color: #dc3545;
background-color: #fbebee;
color: #a4202e;
border-color: var(--color-danger);
background-color: var(--color-danger-background);
color: var(--color-danger-hover);
}
.deleteButton:disabled {
color: #6c757d;
background-color: #e9ecef;
@@ -411,7 +581,7 @@
.historyContainer {
max-height: 400px;
overflow-y: auto;
border: 1px solid #dee2e6;
border: 1px solid var(--color-border);
border-radius: 4px;
}
@@ -421,7 +591,7 @@
}
.historyTh {
background-color: #f8f9fa;
background-color: var(--color-background);
padding: 12px;
text-align: left;
font-size: 0.875rem;
@@ -431,16 +601,15 @@
.historyTd {
padding: 12px;
color: #495057;
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 {
border-bottom: none;
}
/* CAMBIO: Nueva clase para dar espacio a la sección de historial */
.historySectionFullWidth {
margin-top: 2rem;
}
@@ -479,12 +648,134 @@
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 { 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; }
.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: #212529; font-style: normal; }
.sectorNameUnassigned { color: #6c757d; font-style: italic; }
.userList {
min-width: 240px;
}
.userItem {
display: flex;
align-items: center;
justify-content: space-between;
margin: 4px 0;
padding: 6px;
background-color: var(--color-background);
border-radius: 4px;
position: relative;
}
.userInfo {
color: var(--color-text-secondary);
}
.userActions {
display: flex;
gap: 4px;
align-items: center;
}
.sectorContainer {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: 0.5rem;
}
.sectorName {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sectorNameAssigned {
color: var(--color-text-secondary);
font-style: normal;
}
.sectorNameUnassigned {
color: var(--color-text-muted);
font-style: italic;
}
.modalOverlay--nested {
z-index: 1005;
}
.modalOverlay--nested .modal {
z-index: 1006;
}
.closeButton:disabled {
cursor: not-allowed;
opacity: 0.5;
background-color: #6c757d;
}
.columnToggleContainer {
position: relative;
}
.columnToggleDropdown {
position: absolute;
top: 100%;
right: 0;
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.5rem;
z-index: 10;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
min-width: 200px;
}
.columnToggleItem {
display: flex;
align-items: center;
padding: 0.5rem;
cursor: pointer;
border-radius: 4px;
}
.columnToggleItem:hover {
background-color: var(--color-surface-hover);
}
.columnToggleItem input {
margin-right: 0.5rem;
}
.columnToggleItem label {
white-space: nowrap;
}
/* --- ESTILOS PARA GESTIÓN DE COMPONENTES --- */
/* Estilos para la columna numérica (Nº de Equipos) */
.thNumeric,
.tdNumeric {
text-align: Center; /* Es buena práctica alinear números a la derecha */
padding-right: 2rem; /* Un poco más de espacio para que no se pegue a las acciones */
width: 1%;
white-space: nowrap;
}
/* Estilos para la columna de acciones */
.thActions,
.tdActions {
text-align: center; /* Centramos el título 'Acciones' y los botones */
width: 1%;
white-space: nowrap;
}
/* --- MODAL DE UNIFICAR --- */
.highlightBox {
display: block;
margin-bottom: 1rem;
padding: 8px;
border-radius: 4px;
background-color: var(--color-border-subtle);
color: var(--color-text-secondary);
border: 1px solid var(--color-border); /* Un borde sutil para definirlo */
}

View File

@@ -1,18 +1,19 @@
// frontend/src/components/SimpleTable.tsx
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import {
useReactTable,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
getPaginationRowModel,
flexRender,
type CellContext
useReactTable, getCoreRowModel, getFilteredRowModel, getSortedRowModel,
getPaginationRowModel, flexRender, type CellContext,
type ColumnDef, type VisibilityState, type ColumnSizingState
} from '@tanstack/react-table';
import { Tooltip } from 'react-tooltip';
import toast from 'react-hot-toast';
import type { Equipo, Sector, Usuario, UsuarioEquipoDetalle } from '../types/interfaces';
import styles from './SimpleTable.module.css';
import skeletonStyles from './Skeleton.module.css';
import { accentInsensitiveFilter } from '../utils/filtering';
import { equipoService, sectorService, usuarioService } from '../services/apiService';
import { PlusCircle, KeyRound, UserX, Pencil, ArrowUp, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Columns3 } from 'lucide-react';
import ModalAnadirEquipo from './ModalAnadirEquipo';
import ModalEditarSector from './ModalEditarSector';
@@ -21,6 +22,7 @@ import ModalDetallesEquipo from './ModalDetallesEquipo';
import ModalAnadirDisco from './ModalAnadirDisco';
import ModalAnadirRam from './ModalAnadirRam';
import ModalAnadirUsuario from './ModalAnadirUsuario';
import TableSkeleton from './TableSkeleton';
const SimpleTable = () => {
const [data, setData] = useState<Equipo[]>([]);
@@ -37,11 +39,53 @@ const SimpleTable = () => {
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [addingComponent, setAddingComponent] = useState<'disco' | 'ram' | 'usuario' | null>(null);
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(() => {
const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth;
if (selectedEquipo || modalData || modalPasswordData) {
if (selectedEquipo || modalData || modalPasswordData || isAddModalOpen) {
document.body.classList.add('scroll-lock');
document.body.style.paddingRight = `${scrollBarWidth}px`;
} else {
@@ -52,7 +96,7 @@ const SimpleTable = () => {
document.body.classList.remove('scroll-lock');
document.body.style.paddingRight = '0';
};
}, [selectedEquipo, modalData, modalPasswordData]);
}, [selectedEquipo, modalData, modalPasswordData, isAddModalOpen]);
useEffect(() => {
if (!selectedEquipo) return;
@@ -60,17 +104,7 @@ const SimpleTable = () => {
const checkPing = async () => {
if (!selectedEquipo.ip) return;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch(`${BASE_URL}/equipos/ping`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ip: selectedEquipo.ip }),
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) throw new Error('Error en la respuesta');
const data = await response.json();
const data = await equipoService.ping(selectedEquipo.ip);
if (isMounted) setIsOnline(data.isAlive);
} catch (error) {
if (isMounted) setIsOnline(false);
@@ -79,51 +113,53 @@ const SimpleTable = () => {
};
checkPing();
const interval = setInterval(checkPing, 10000);
return () => {
isMounted = false;
clearInterval(interval);
setIsOnline(false);
};
return () => { isMounted = false; clearInterval(interval); setIsOnline(false); };
}, [selectedEquipo]);
const handleCloseModal = () => {
if (addingComponent) {
toast.error("Debes cerrar la ventana de añadir componente primero.");
return;
}
setSelectedEquipo(null);
setIsOnline(false);
};
useEffect(() => {
if (selectedEquipo) {
fetch(`${BASE_URL}/equipos/${selectedEquipo.hostname}/historial`)
.then(response => response.json())
equipoService.getHistory(selectedEquipo.hostname)
.then(data => setHistorial(data.historial))
.catch(error => console.error('Error fetching history:', error));
}
}, [selectedEquipo]);
useEffect(() => {
const handleScroll = () => setShowScrollButton(window.scrollY > 200);
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const tableElement = tableContainerRef.current;
const handleScroll = () => {
if (tableElement) {
setShowScrollButton(tableElement.scrollTop > 200);
}
};
tableElement?.addEventListener('scroll', handleScroll);
return () => {
tableElement?.removeEventListener('scroll', handleScroll);
};
}, [isLoading]);
useEffect(() => {
setIsLoading(true);
Promise.all([
fetch(`${BASE_URL}/equipos`).then(res => res.json()),
fetch(`${BASE_URL}/sectores`).then(res => res.json())
equipoService.getAll(),
sectorService.getAll()
]).then(([equiposData, sectoresData]) => {
setData(equiposData);
setFilteredData(equiposData);
const sectoresOrdenados = [...sectoresData].sort((a, b) =>
a.nombre.localeCompare(b.nombre, 'es', { sensitivity: 'base' })
);
const sectoresOrdenados = [...sectoresData].sort((a, b) => a.nombre.localeCompare(b.nombre, 'es', { sensitivity: 'base' }));
setSectores(sectoresOrdenados);
}).catch(error => {
toast.error("No se pudieron cargar los datos iniciales.");
console.error("Error al cargar datos:", error);
}).finally(() => {
setIsLoading(false);
});
}).finally(() => setIsLoading(false));
}, []);
const handleSectorChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
@@ -135,19 +171,22 @@ const SimpleTable = () => {
};
const handleSave = async () => {
if (!modalData || !modalData.sector) return;
if (!modalData) return;
const toastId = toast.loading('Guardando...');
try {
const response = await fetch(`${BASE_URL}/equipos/${modalData.id}/sector/${modalData.sector.id}`, { method: 'PATCH' });
if (!response.ok) throw new Error('Error al asociar el sector');
const updatedData = data.map(e => e.id === modalData.id ? { ...e, sector: modalData.sector } : e);
setData(updatedData);
setFilteredData(updatedData);
const sectorId = modalData.sector?.id ?? 0;
await equipoService.updateSector(modalData.id, sectorId);
const equipoActualizado = { ...modalData, sector_id: modalData.sector?.id };
const updateFunc = (prev: Equipo[]) => prev.map(e => e.id === modalData.id ? equipoActualizado : e);
setData(updateFunc);
setFilteredData(updateFunc);
if (selectedEquipo && selectedEquipo.id === modalData.id) {
setSelectedEquipo(equipoActualizado);
}
toast.success('Sector actualizado.', { id: toastId });
setModalData(null);
} catch (error) {
toast.error('No se pudo actualizar.', { id: toastId });
console.error(error);
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
};
@@ -155,39 +194,57 @@ const SimpleTable = () => {
if (!modalPasswordData) return;
const toastId = toast.loading('Actualizando...');
try {
const response = await fetch(`${BASE_URL}/usuarios/${modalPasswordData.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
await usuarioService.updatePassword(modalPasswordData.id, password);
const usernameToUpdate = modalPasswordData.username;
const newData = data.map(equipo => {
if (!equipo.usuarios.some(u => u.username === usernameToUpdate)) {
return equipo;
}
const updatedUsers = equipo.usuarios.map(user =>
user.username === usernameToUpdate ? { ...user, password: password } : user
);
return { ...equipo, usuarios: updatedUsers };
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.error || 'Error al actualizar');
setData(newData);
if (selectedSector === 'Todos') setFilteredData(newData);
else if (selectedSector === 'Asignar') setFilteredData(newData.filter(i => !i.sector));
else setFilteredData(newData.filter(i => i.sector?.nombre === selectedSector));
if (selectedEquipo) {
const updatedSelectedEquipo = newData.find(e => e.id === selectedEquipo.id);
if (updatedSelectedEquipo) {
setSelectedEquipo(updatedSelectedEquipo);
}
}
const updatedUser = await response.json();
const updatedData = data.map(equipo => ({
...equipo,
usuarios: equipo.usuarios?.map(user => user.id === updatedUser.id ? { ...user, password } : user)
}));
setData(updatedData);
setFilteredData(updatedData);
toast.success(`Contraseña actualizada.`, { id: toastId });
toast.success(`Contraseña para '${usernameToUpdate}' actualizada en todos sus equipos.`, { id: toastId });
setModalPasswordData(null);
} catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
};
const handleRemoveUser = async (hostname: string, username: string) => {
if (!window.confirm(`¿Quitar a ${username} de este equipo?`)) return;
const toastId = toast.loading(`Quitando a ${username}...`);
try {
const response = await fetch(`${BASE_URL}/equipos/${hostname}/usuarios/${username}`, { method: 'DELETE' });
const result = await response.json();
if (!response.ok) throw new Error(result.error || 'Error al desasociar');
const updateFunc = (prev: Equipo[]) => prev.map(e => e.hostname === hostname ? { ...e, usuarios: e.usuarios.filter(u => u.username !== username) } : e);
await usuarioService.removeUserFromEquipo(hostname, username);
let equipoActualizado: Equipo | undefined;
const updateFunc = (prev: Equipo[]) => prev.map(e => {
if (e.hostname === hostname) {
equipoActualizado = { ...e, usuarios: e.usuarios.filter(u => u.username !== username) };
return equipoActualizado;
}
return e;
});
setData(updateFunc);
setFilteredData(updateFunc);
if (selectedEquipo && equipoActualizado && selectedEquipo.id === equipoActualizado.id) {
setSelectedEquipo(equipoActualizado);
}
toast.success(`${username} quitado.`, { id: toastId });
} catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId });
@@ -198,57 +255,36 @@ const SimpleTable = () => {
if (!window.confirm('¿Eliminar este equipo y sus relaciones?')) return false;
const toastId = toast.loading('Eliminando equipo...');
try {
const response = await fetch(`${BASE_URL}/equipos/${id}`, { method: 'DELETE' });
if (response.status === 204) {
setData(prev => prev.filter(e => e.id !== id));
setFilteredData(prev => prev.filter(e => e.id !== id));
toast.success('Equipo eliminado.', { id: toastId });
return true;
}
const errorText = await response.text();
throw new Error(errorText || 'Error desconocido');
await equipoService.deleteManual(id);
setData(prev => prev.filter(e => e.id !== id));
setFilteredData(prev => prev.filter(e => e.id !== id));
toast.success('Equipo eliminado.', { id: toastId });
return true;
} catch (error) {
if (error instanceof Error) toast.error(`Error: ${error.message}`, { id: toastId });
if (error instanceof Error) toast.error(error.message, { id: toastId });
return false;
}
};
const handleRemoveAssociation = async (
type: 'disco' | 'ram' | 'usuario',
associationId: number | { equipoId: number, usuarioId: number }
) => {
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
}
const handleRemoveAssociation = async (type: 'disco' | 'ram' | 'usuario', associationId: number | { equipoId: number, usuarioId: number }) => {
if (!window.confirm('¿Estás seguro de que quieres eliminar esta asociación manual?')) return;
const toastId = toast.loading('Eliminando asociación...');
try {
const response = await fetch(url, { method: 'DELETE' });
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `Error al eliminar la asociación.`);
let successMessage = '';
if (type === 'disco' && typeof associationId === 'number') {
await equipoService.removeDiscoAssociation(associationId);
successMessage = 'Disco desasociado del equipo.';
} else if (type === 'ram' && typeof associationId === 'number') {
await equipoService.removeRamAssociation(associationId);
successMessage = 'Módulo de RAM desasociado.';
} else if (type === 'usuario' && typeof associationId === 'object') {
await equipoService.removeUserAssociation(associationId.equipoId, associationId.usuarioId);
successMessage = 'Usuario desasociado del equipo.';
} else {
throw new Error('Tipo de asociación no válido');
}
// Actualizar el estado local para reflejar el cambio inmediatamente
const updateState = (prevEquipos: Equipo[]) => prevEquipos.map(equipo => {
if (equipo.id !== selectedEquipo?.id) return equipo;
let updatedEquipo = { ...equipo };
if (type === 'disco' && typeof associationId === 'number') {
updatedEquipo.discos = equipo.discos.filter(d => d.equipoDiscoId !== associationId);
@@ -259,132 +295,92 @@ const SimpleTable = () => {
}
return updatedEquipo;
});
setData(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 });
} catch (error) {
if (error instanceof Error) {
toast.error(error.message, { id: toastId });
}
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
};
const handleCreateEquipo = async (nuevoEquipo: Omit<Equipo, 'id' | 'created_at' | 'updated_at'>) => {
const toastId = toast.loading('Creando nuevo equipo...');
try {
const response = await fetch(`${BASE_URL}/equipos/manual`, {
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
const equipoCreado = await equipoService.createManual(nuevoEquipo);
setData(prev => [...prev, equipoCreado]);
setFilteredData(prev => [...prev, equipoCreado]);
toast.success(`Equipo '${equipoCreado.hostname}' creado.`, { id: toastId });
setIsAddModalOpen(false); // Cerramos el modal
} 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
setIsAddModalOpen(false);
} catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
};
const columns = [
{ header: "ID", accessorKey: "id", enableHiding: true },
const handleEditEquipo = async (id: number, equipoEditado: any) => {
const toastId = toast.loading('Guardando cambios...');
try {
const equipoActualizadoDesdeBackend = await equipoService.updateManual(id, equipoEditado);
const updateState = (prev: Equipo[]) => prev.map(e => e.id === id ? equipoActualizadoDesdeBackend : e);
setData(updateState);
setFilteredData(updateState);
setSelectedEquipo(equipoActualizadoDesdeBackend);
toast.success('Equipo actualizado.', { id: toastId });
return true;
} catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId });
return false;
}
};
const handleAddComponent = async (type: 'disco' | 'ram' | 'usuario', componentData: any) => {
if (!selectedEquipo) return;
const toastId = toast.loading(`Añadiendo ${type}...`);
try {
let serviceCall;
switch (type) {
case 'disco': serviceCall = equipoService.addDisco(selectedEquipo.id, componentData); break;
case 'ram': serviceCall = equipoService.addRam(selectedEquipo.id, componentData); break;
case 'usuario': serviceCall = equipoService.addUsuario(selectedEquipo.id, componentData); break;
default: throw new Error('Tipo de componente no válido');
}
await serviceCall;
const refreshedEquipo = await equipoService.getAll().then(equipos => equipos.find(e => e.id === selectedEquipo.id));
if (!refreshedEquipo) throw new Error("No se pudo recargar el equipo");
const updateState = (prev: Equipo[]) => prev.map(e => e.id === selectedEquipo.id ? refreshedEquipo : e);
setData(updateState);
setFilteredData(updateState);
setSelectedEquipo(refreshedEquipo);
await refreshHistory(selectedEquipo.hostname);
toast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} añadido.`, { id: toastId });
setAddingComponent(null);
} catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
};
const columns: ColumnDef<Equipo>[] = [
{ header: "ID", accessorKey: "id", enableHiding: true, enableResizing: false },
{
header: "Nombre", accessorKey: "hostname",
cell: ({ 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: "MAC", accessorKey: "mac", enableHiding: true },
{ header: "IP", accessorKey: "ip", id: 'ip' },
{ header: "MAC", accessorKey: "mac" },
{ header: "Motherboard", accessorKey: "motherboard" },
{ 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: "OS", accessorKey: "os" },
{ header: "Arquitectura", accessorKey: "architecture" },
{ header: "Arquitectura", accessorKey: "architecture", id: 'arch' },
{
header: "Usuarios y Claves",
cell: ({ row }: CellContext<Equipo, any>) => {
id: 'usuarios',
cell: (info: CellContext<Equipo, any>) => {
const { row } = info;
const usuarios = row.original.usuarios || [];
return (
<div className={styles.userList}>
@@ -400,7 +396,7 @@ const SimpleTable = () => {
className={styles.tableButton}
data-tooltip-id={`edit-${u.id}`}
>
<KeyRound size={16} />
<Tooltip id={`edit-${u.id}`} className={styles.tooltip} place="top">Cambiar Clave</Tooltip>
</button>
@@ -409,7 +405,7 @@ const SimpleTable = () => {
className={styles.deleteUserButton}
data-tooltip-id={`remove-${u.id}`}
>
🗑
<UserX size={16} />
<Tooltip id={`remove-${u.id}`} className={styles.tooltip} place="top">Eliminar Usuario</Tooltip>
</button>
</div>
@@ -421,12 +417,13 @@ const SimpleTable = () => {
},
{
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;
return (
<div className={styles.sectorContainer}>
<span className={`${styles.sectorName} ${sector ? styles.sectorNameAssigned : styles.sectorNameUnassigned}`}>{sector?.nombre || 'Asignar'}</span>
<button onClick={() => setModalData(row.original)} className={styles.tableButton} data-tooltip-id={`editSector-${row.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>
);
}
@@ -436,48 +433,66 @@ const SimpleTable = () => {
const table = useReactTable({
data: filteredData,
columns,
columnResizeMode: 'onChange',
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onColumnSizingChange: setColumnSizing,
filterFns: {
accentInsensitive: accentInsensitiveFilter,
},
globalFilterFn: 'accentInsensitive',
initialState: {
sorting: [
{ id: 'sector', desc: false },
{ id: 'hostname', desc: false }
],
columnVisibility: { id: false, mac: false },
pagination: {
pageSize: 15, // Mostrar 15 filas por página por defecto
pageSize: 15,
},
},
state: {
globalFilter,
columnVisibility,
columnSizing,
},
onGlobalFilterChange: setGlobalFilter,
});
if (isLoading) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<h2>Cargando Equipos...</h2>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2>Equipos (...)</h2>
<div className={`${skeletonStyles.skeleton}`} style={{ height: '40px', width: '160px' }}></div>
</div>
<div className={styles.controlsContainer}>
<div className={`${skeletonStyles.skeleton}`} style={{ height: '38px', width: '300px' }}></div>
<div className={`${skeletonStyles.skeleton}`} style={{ height: '38px', width: '200px' }}></div>
</div>
<div><p style={{ fontSize: '12px', fontWeight: 'bold' }}>** La tabla permite ordenar por múltiples columnas manteniendo shift al hacer click en la cabecera. **</p></div>
<div className={`${skeletonStyles.skeleton}`} style={{ height: '54px', marginBottom: '1rem' }}></div>
<TableSkeleton rows={15} columns={11} />
</div>
);
}
const PaginacionControles = (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1rem 0' }}>
<div className={styles.paginationControls}>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
<button onClick={() => table.setPageIndex(0)} disabled={!table.getCanPreviousPage()} className={styles.tableButton}>
{'<<'}
<ChevronsLeft size={18} />
</button>
<button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} className={styles.tableButton}>
{'<'}
<ChevronLeft size={18} />
</button>
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} className={styles.tableButton}>
{'>'}
<ChevronRight size={18} />
</button>
<button onClick={() => table.setPageIndex(table.getPageCount() - 1)} disabled={!table.getCanNextPage()} className={styles.tableButton}>
{'>>'}
<ChevronsRight size={18} />
</button>
</div>
<span>
@@ -517,78 +532,105 @@ const SimpleTable = () => {
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2>Equipos ({table.getFilteredRowModel().rows.length})</h2>
<button
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={() => setIsAddModalOpen(true)}
>
+ Añadir Equipo
</button>
</div>
<div className={styles.controlsContainer}>
<input type="text" placeholder="Buscar en todos los campos..." value={globalFilter} onChange={(e) => setGlobalFilter(e.target.value)} className={styles.searchInput} style={{ width: '300px' }} />
<b>Sector:</b>
<select value={selectedSector} onChange={handleSectorChange} className={styles.sectorSelect}>
<option value="Todos">-Todos-</option>
<option value="Asignar">-Asignar-</option>
{sectores.map(s => (<option key={s.id} value={s.nombre}>{s.nombre}</option>))}
</select>
<div className={styles.header}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2>Equipos ({table.getFilteredRowModel().rows.length})</h2>
<button
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={() => setIsAddModalOpen(true)}
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
>
<PlusCircle size={18} /> Añadir Equipo
</button>
</div>
<div className={styles.controlsContainer}>
<input type="text" placeholder="Buscar en todos los campos..." value={globalFilter} onChange={(e) => setGlobalFilter(e.target.value)} className={styles.searchInput} style={{ width: '300px' }} />
<b>Sector:</b>
<select value={selectedSector} onChange={handleSectorChange} className={styles.sectorSelect}>
<option value="Todos">-Todos-</option>
<option value="Asignar">-Asignar-</option>
{sectores.map(s => (<option key={s.id} value={s.nombre}>{s.nombre}</option>))}
</select>
<div ref={columnToggleRef} className={styles.columnToggleContainer}>
<button onClick={() => setIsColumnToggleOpen(prev => !prev)} className={styles.tableButton} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Columns3 size={16} /> Columnas
</button>
{isColumnToggleOpen && (
<div className={styles.columnToggleDropdown}>
{table.getAllLeafColumns().map(column => {
if (column.id === 'id') return null;
return (
<div key={column.id} className={styles.columnToggleItem}>
<input
type="checkbox"
checked={column.getIsVisible()}
onChange={column.getToggleVisibilityHandler()}
id={`col-toggle-${column.id}`}
/>
<label htmlFor={`col-toggle-${column.id}`}>
{typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id}
</label>
</div>
)
})}
</div>
)}
</div>
</div>
<div><p style={{ fontSize: '12px', fontWeight: 'bold' }}>** La tabla permite ordenar por múltiples columnas manteniendo shift al hacer click en la cabecera. **</p></div>
</div>
<div style={{ position: 'relative' }}>
<div ref={tableContainerRef} className={styles.tableContainer} style={{ maxHeight: '70vh' }}>
<table className={styles.table} style={{ width: table.getTotalSize() }}>
<thead>
{table.getHeaderGroups().map(hg => (
<tr key={hg.id}>
{hg.headers.map(h => (
<th key={h.id} style={{ width: h.getSize() }} className={styles.th}>
<div onClick={h.column.getToggleSortingHandler()} className={styles.headerContent}>
{flexRender(h.column.columnDef.header, h.getContext())}
{h.column.getIsSorted() && (<span className={styles.sortIndicator}>{h.column.getIsSorted() === 'asc' ? '⇑' : '⇓'}</span>)}
</div>
<div
onMouseDown={h.getResizeHandler()}
onTouchStart={h.getResizeHandler()}
className={`${styles.resizer} ${h.column.getIsResizing() ? styles.isResizing : ''}`}
/>
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id} className={styles.tr}>
{row.getVisibleCells().map(cell => (
<td key={cell.id} style={{ width: cell.column.getSize() }} className={styles.td}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{showScrollButton && (
<button
onClick={() => tableContainerRef.current?.scrollTo({ top: 0, behavior: 'smooth' })}
className={styles.scrollToTop}
title="Volver al inicio"
>
<ArrowUp size={20} />
</button>
)}
</div>
{/* --- 2. Renderizar los controles ANTES de la tabla --- */}
{PaginacionControles}
<div style={{ overflowX: 'auto', maxHeight: '70vh', border: '1px solid #dee2e6', borderRadius: '8px' }}>
<table className={styles.table}>
<thead>
{table.getHeaderGroups().map(hg => (
<tr key={hg.id}>
{hg.headers.map(h => (
<th key={h.id} className={styles.th}>
{flexRender(h.column.columnDef.header, h.getContext())}
{h.column.getIsSorted() && (<span className={styles.sortIndicator}>{h.column.getIsSorted() === 'asc' ? '⇑' : '⇓'}</span>)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id} className={styles.tr}>
{row.getVisibleCells().map(cell => (
<td key={cell.id} className={styles.td}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{modalData && <ModalEditarSector modalData={modalData} setModalData={setModalData} sectores={sectores} onClose={() => setModalData(null)} onSave={handleSave} />}
{/* --- 3. Renderizar los controles DESPUÉS de la tabla --- */}
{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}
/>
)}
{modalPasswordData && (
<ModalCambiarClave
usuario={modalPasswordData}
onClose={() => setModalPasswordData(null)}
onSave={handleSavePassword}
/>
)}
{modalPasswordData && <ModalCambiarClave usuario={modalPasswordData} onClose={() => setModalPasswordData(null)} onSave={handleSavePassword} />}
{selectedEquipo && (
<ModalDetallesEquipo
@@ -601,20 +643,17 @@ const SimpleTable = () => {
onEdit={handleEditEquipo}
sectores={sectores}
onAddComponent={type => setAddingComponent(type)}
isChildModalOpen={addingComponent !== null}
/>
)}
{isAddModalOpen && (
<ModalAnadirEquipo
sectores={sectores}
onClose={() => setIsAddModalOpen(false)}
onSave={handleCreateEquipo}
/>
)}
{isAddModalOpen && <ModalAnadirEquipo sectores={sectores} onClose={() => setIsAddModalOpen(false)} onSave={handleCreateEquipo} />}
{addingComponent === 'disco' && <ModalAnadirDisco onClose={() => setAddingComponent(null)} onSave={(data) => 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 === 'disco' && <ModalAnadirDisco onClose={() => setAddingComponent(null)} onSave={(data: { mediatype: string, size: number }) => handleAddComponent('disco', data)} />}
{addingComponent === 'ram' && <ModalAnadirRam onClose={() => setAddingComponent(null)} onSave={(data: { slot: string, tamano: number, fabricante?: string, velocidad?: number }) => handleAddComponent('ram', data)} />}
{addingComponent === 'usuario' && <ModalAnadirUsuario onClose={() => setAddingComponent(null)} onSave={(data: { username: string }) => handleAddComponent('usuario', data)} />}
</div>
);
};

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 {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
background-color: #f8f9fa;
color: #212529;
background-color: var(--color-background);
color: var(--color-text-primary);
transition: background-color 0.2s ease, color 0.2s ease; /* Transición suave al cambiar de tema */
}
/* Estilos de la scrollbar que estaban en index.html */
body::-webkit-scrollbar {
width: 8px;
background-color: #f1f1f1;
background-color: var(--scrollbar-bg);
}
body::-webkit-scrollbar-thumb {
background-color: #888;
background-color: var(--scrollbar-thumb);
border-radius: 4px;
}
/* Clase para bloquear el scroll cuando un modal está abierto */
body.scroll-lock {
padding-right: 8px !important;
overflow: hidden !important;

View File

@@ -3,28 +3,33 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
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(
<React.StrictMode>
<App />
<Toaster
position="bottom-right" // Posición de las notificaciones
toastOptions={{
// Estilos por defecto para las notificaciones
success: {
style: {
background: '#28a745',
color: 'white',
},
},
error: {
style: {
background: '#dc3545',
color: 'white',
},
},
}}
/>
<AuthProvider>
<ThemeProvider>
<App />
<Toaster
position="bottom-right"
toastOptions={{
success: {
style: {
background: '#28a745',
color: 'white',
},
},
error: {
style: {
background: '#dc3545',
color: 'white',
},
},
}}
/>
</ThemeProvider>
</AuthProvider>
</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

@@ -80,4 +80,23 @@ export interface Equipo {
discos: DiscoDetalle[];
memoriasRam: MemoriaRamEquipoDetalle[];
historial: HistorialEquipo[];
}
// --- Interfaces para el Dashboard ---
export interface EquipoFilter {
field: string; // 'os', 'cpu', 'sector', etc.
value: string; // El valor específico del filtro
}
export interface StatItem {
label: string;
count: number;
}
export interface DashboardStats {
osStats: StatItem[];
sectorStats: StatItem[];
cpuStats: StatItem[];
ramStats: StatItem[];
}

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/
export default defineConfig({
plugins: [react()],
})
// --- AÑADIR ESTA SECCIÓN COMPLETA ---
server: {
proxy: {
// Cualquier petición que empiece con '/api' será redirigida.
'/api': {
// Redirige al servidor de backend que corre en local.
target: 'http://localhost:5198',
// Necesario para evitar problemas de CORS y de origen.
changeOrigin: true,
},
},
},
})

174
getDatosPost.ps1 Normal file
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 {}
}
}