Compare commits

...

32 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
04f1134be4 README.md 2025-10-07 14:51:18 -03:00
242c1345c0 feat: Implementación de gestión manual y panel de administración
Se introduce una refactorización masiva y se añaden nuevas funcionalidades críticas para la gestión del inventario, incluyendo un panel de administración para la limpieza de datos y un sistema completo para la gestión manual de equipos.

### Nuevas Funcionalidades

*   **Panel de Administración:** Se crea una nueva vista de "Administración" para la gestión de datos maestros. Permite unificar valores inconsistentes (ej: "W10" -> "Windows 10 Pro") y eliminar registros maestros no utilizados (ej: Módulos de RAM) para mantener la base de datos limpia.

*   **Gestión de Sectores (CRUD):** Se añade una vista dedicada para crear, editar y eliminar sectores de la organización.

*   **Diferenciación Manual vs. Automático:** Se introduce una columna `origen` en la base de datos para distinguir entre los datos recopilados automáticamente por el script y los introducidos manualmente por el usuario. La UI ahora refleja visualmente este origen.

*   **CRUD de Equipos Manuales:** Se implementa la capacidad de crear, editar y eliminar equipos de origen "manual" a través de la interfaz de usuario. Se protege la eliminación de equipos automáticos.

*   **Gestión de Componentes Manuales:** Se permite añadir y eliminar componentes (Discos, RAM, Usuarios) a los equipos de origen "manual".

### Mejoras de UI/UX

*   **Refactorización de Estilos:** Se migran todos los estilos en línea del componente `SimpleTable` a un archivo CSS Module (`SimpleTable.module.css`), mejorando la mantenibilidad y el rendimiento.

*   **Notificaciones de Usuario:** Se integra `react-hot-toast` para proporcionar feedback visual inmediato (carga, éxito, error) en todas las operaciones asíncronas, reemplazando los `alert`.

*   **Componentización:** Se extraen todos los modales (`ModalDetallesEquipo`, `ModalAnadirEquipo`, etc.) a sus propios componentes, limpiando y simplificando drásticamente el componente `SimpleTable`.

*   **Paginación en Tabla Principal:** Se implementa paginación completa en la tabla de equipos, con controles para navegar, ir a una página específica y cambiar el número de items por página. Se añade un indicador de carga inicial.

*   **Navegación Mejorada:** Se reemplaza la navegación por botones con un componente `Navbar` estilizado y dedicado, mejorando la estructura visual y de código.

*   **Autocompletado de Datos:** Se introduce un componente `AutocompleteInput` reutilizable para guiar al usuario a usar datos consistentes al rellenar campos como OS, CPU y Motherboard. Se implementa búsqueda dinámica para la asociación de usuarios.

*   **Validación de MAC Address:** Se añade validación de formato en tiempo real y auto-formateo para el campo de MAC Address, reduciendo errores humanos.

*   **Consistencia de Iconos:** Se unifica el icono de eliminación a (🗑️) en toda la aplicación para una experiencia de usuario más coherente.

### Mejoras en el Backend / API

*   **Seguridad de Credenciales:** Las credenciales SSH para la función Wake On Lan se mueven del código fuente a `appsettings.json`.

*   **Nuevo `AdminController`:** Se crea un controlador dedicado para las tareas administrativas, con endpoints para obtener valores únicos de componentes y para ejecutar la lógica de unificación y eliminación.

*   **Endpoints de Gestión Manual:** Se añaden rutas específicas (`/manual/...` y `/asociacion/...`) para la manipulación de datos de origen manual, separando la lógica de la gestión automática.

*   **Protección de Datos Automáticos:** Los endpoints `DELETE` y `PUT` ahora validan el campo `origen` para prevenir la modificación o eliminación no deseada de datos generados automáticamente.

*   **Correcciones y Refinamiento:** Se soluciona el mapeo incorrecto de fechas (`created_at`, `updated_at`), se corrigen errores de compilación y se refinan las consultas SQL para incluir los nuevos campos.
2025-10-07 14:44:16 -03:00
99d98cc588 feat: Implementa lógica completa de sincronización de RAM 2025-10-06 14:59:39 -03:00
3fbc9abf58 feat: Implementa lógica completa de sincronización de discos 2025-10-06 14:55:58 -03:00
e14476ff88 Feat: Migrado de datos de MariaDB a SQL Server y Fix de Tabla 2025-10-04 22:17:05 -03:00
85bd1915e0 feat: Añade endpoints de asociación y stubs para comandos a EquiposController 2025-10-02 15:37:06 -03:00
80210e5d4c feat: Controladores con operaciones CRUD completas 2025-10-02 15:32:23 -03:00
75 changed files with 10928 additions and 104 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

139
README.md Normal file
View File

@@ -0,0 +1,139 @@
# Inventario IT 🖥️
**Inventario IT** es un sistema de gestión de inventario de hardware diseñado para ser poblado tanto por scripts automáticos como por gestión manual. Permite tener un registro centralizado y detallado de todos los equipos informáticos de una organización, sus componentes y su historial de cambios.
El sistema se compone de un **backend RESTful API desarrollado en ASP.NET Core** y un **frontend interactivo de tipo SPA (Single Page Application) construido con React, TypeScript y Vite**.
---
## 🚀 Funcionalidades Principales
El sistema está diseñado en torno a la gestión y visualización de activos de TI, con un fuerte énfasis en la calidad y consistencia de los datos.
### 🔒 Seguridad y Autenticación
- **Autenticación Basada en JWT:** Acceso a la API protegido mediante JSON Web Tokens.
- **Login Simple y Seguro:** Un único par de credenciales (usuario/contraseña) configurables en el backend para acceder a toda la aplicación.
- **Sesión Persistente Opcional:** En la pantalla de login, el usuario puede elegir "Mantener sesión iniciada".
- **Si se marca:** El token se almacena de forma persistente (`localStorage`), sobreviviendo a cierres del navegador. El token tiene una validez de **1 año**.
- **Si no se marca:** El token se almacena en la sesión del navegador (`sessionStorage`), cerrándose automáticamente al finalizar la sesión. El token tiene una validez de **6 horas**.
### 📋 Módulo de Equipos
- **Vista Principal Centralizada:** Una tabla paginada, con capacidad de búsqueda y filtrado por sector, que muestra todos los equipos del inventario.
- **Detalle Completo del Equipo:** Al hacer clic en un equipo, se abre una vista detallada con toda su información:
- **Datos Principales:** Hostname, IP, MAC, OS, etc.
- **Componentes Asociados:** Lista detallada de discos, módulos de RAM, usuarios asociados, CPU, Motherboard, RAM instalada, etc.
- **Estado en Tiempo Real:** Indicador visual que muestra si el equipo está `En línea` o `Sin conexión` mediante un ping.
- **Historial de Cambios:** Un registro cronológico de todas las modificaciones realizadas en el equipo.
- **Acciones Remotas:**
- **Wake On Lan (WOL):** 🚀 Botón para enviar un "Magic Packet" a través de un servidor SSH y encender un equipo de forma remota.
### ✍️ Gestión Manual vs. Automática
- **Origen de Datos:** El sistema diferencia entre datos generados por scripts automáticos (`⚙️ Automático`) y los introducidos manualmente (`⌨️ Manual`).
- **Protección de Datos:** Las entradas automáticas están protegidas contra modificaciones o eliminaciones desde la UI, asegurando la integridad de los datos recopilados en campo.
- **CRUD Completo para Entradas Manuales:**
- Creación de nuevos equipos con sus propiedades básicas.
- Edición de los campos principales de un equipo manual.
- Asociación manual de componentes (Discos, RAM, Usuarios).
- Eliminación segura de equipos y componentes de origen manual.
### 🗂️ Módulo de Administración
- **Gestión de Sectores:** Un CRUD completo para crear, editar y eliminar los sectores o departamentos de la organización.
- **Limpieza de Datos Maestros:** Una potente herramienta para asegurar la consistencia de los datos.
- **Visualización de Valores Únicos:** Permite ver todos los valores distintos existentes para un tipo de componente (ej: `Microsoft Windows 10 Pro`, `w10`, `Windows 10`) y cuántos equipos usan cada uno.
- **Unificación de Valores:** ✏️ Permite reemplazar todas las instancias de un valor incorrecto (ej: `w10`) por uno correcto (ej: `Microsoft Windows 10 Pro`) en toda la base de datos con un solo clic.
- **Eliminación de Registros Maestros:** 🗑️ Permite eliminar componentes maestros (ej: un módulo de RAM) que ya no están en uso por ningún equipo.
### 🎨 UI/UX
- **Interfaz Moderna y Responsiva:** Construida con Vite, React y TypeScript para una experiencia rápida y fluida.
- **Notificaciones en Tiempo Real:** El sistema utiliza `react-hot-toast` para dar feedback instantáneo sobre el estado de las operaciones (Cargando, Éxito, Error).
- **Consistencia de Datos Mejorada:** Uso de campos de autocompletado que sugieren valores existentes para campos como CPU, OS y Motherboard, reduciendo errores humanos.
- **Validación de Entradas:** Validación de formato en tiempo real para campos críticos como la MAC Address, con auto-formateo para ayudar al usuario.
---
## 🛠️ Stack Tecnológico
### Backend (`backend/`)
- **Framework:** ASP.NET Core 9
- **Lenguaje:** C#
- **Acceso a Datos:** Dapper (Micro ORM)
- **Base de Datos:** Microsoft SQL Server
- **Autenticación:** JWT Bearer Token (`Microsoft.AspNetCore.Authentication.JwtBearer`)
- **Comandos Remotos:** Renci.SshNet para ejecución de comandos SSH (Wake On Lan)
### Frontend (`frontend/`)
- **Framework/Librería:** React 19
- **Lenguaje:** TypeScript
- **Gestión de Tabla:** TanStack Table v8
- **Gráficos:** Chart.js con `react-chartjs-2`
- **Notificaciones:** React Hot Toast
- **Tooltips:** React Tooltip
- **Iconos:** Lucide React
- **Build Tool:** Vite
---
## 🚀 Puesta en Marcha (Getting Started)
Siga estos pasos para configurar y ejecutar el proyecto en un entorno de desarrollo local.
### Prerrequisitos
- **.NET SDK 9.0** o superior.
- **Node.js** v20.x o superior (con npm).
- **Microsoft SQL Server** (2019 o superior) y SQL Server Management Studio (SSMS).
- Un editor de código como **Visual Studio Code**.
### 1. Clonar el Repositorio
```bash
git clone <URL_DEL_REPOSITORIO_GITEA>
cd nombre-del-repositorio
```
### 2. Configuración de la Base de Datos
1. Abra SSMS y conecte a su instancia de SQL Server.
2. Cree una nueva base de datos llamada `InventarioDB`.
3. Ejecute el script SQL `InventarioDB.sql` (ubicado en la raíz del proyecto) sobre la base de datos recién creada. Esto creará todas las tablas, relaciones y claves necesarias.
### 3. Configuración del Backend
1. Navegue al directorio del backend: `cd backend`.
2. Abra el archivo `appsettings.Development.json` (o `appsettings.json` para producción).
3. **Modifique la `ConnectionString`** para que apunte a su instancia de SQL Server.
```json
"ConnectionStrings": {
"DefaultConnection": "Server=NOMBRE_SU_SERVIDOR;Database=InventarioDB;User Id=su_usuario_sql;Password=su_contraseña;TrustServerCertificate=True"
}
```
4. **Configure las credenciales de la aplicación y la clave JWT**. Es crucial cambiar los valores por defecto por unos seguros y únicos.
```json
"AuthSettings": {
"Username": "admin",
"Password": "SU_NUEVA_CLAVE_SEGURA_AQUI"
},
"Jwt": {
"Key": "SU_CLAVE_SECRETA_LARGA_Y_COMPLEJA_PARA_JWT",
"Issuer": "InventarioAPI",
"Audience": "InventarioClient"
}
```
5. **Configure las credenciales del servidor SSH** que se usará para la función Wake On Lan en la sección `SshSettings`.
6. Instale las dependencias y ejecute el backend:
```bash
dotnet restore
dotnet run
La API estará disponible en `http://localhost:5198` y la UI de Swagger en la misma URL.
### 4. Configuración del Frontend
1. En una nueva terminal, navegue al directorio del frontend: `cd frontend`.
2. Instale las dependencias:
```bash
npm install
3. **Verificar la configuración del Proxy.** El frontend utiliza un proxy de Vite para redirigir las peticiones de `/api` al backend. Esta configuración se encuentra en `frontend/vite.config.ts`. Si estás ejecutando el backend en el puerto por defecto (`5198`), no necesitas hacer ningún cambio.
4. Ejecute el frontend en modo de desarrollo:
```bash
npm run dev
La aplicación web estará disponible en `http://localhost:5173` (o el puerto que indique Vite).
---

View File

@@ -0,0 +1,204 @@
// backend/Controllers/AdminController.cs
using Dapper;
using Inventario.API.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
namespace Inventario.API.Controllers
{
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class AdminController : ControllerBase
{
private readonly DapperContext _context;
public AdminController(DapperContext context)
{
_context = context;
}
// --- DTOs para los componentes ---
public class ComponenteValorDto
{
public string Valor { get; set; } = "";
public int Conteo { get; set; }
}
public class UnificarComponenteDto
{
public required string ValorNuevo { get; set; }
public required string ValorAntiguo { get; set; }
}
public class RamAgrupadaDto
{
public string? Fabricante { get; set; }
public int Tamano { get; set; }
public int? Velocidad { get; set; }
public int Conteo { get; set; }
}
public class BorrarRamAgrupadaDto
{
public string? Fabricante { get; set; }
public int Tamano { get; set; }
public int? Velocidad { get; set; }
}
[HttpGet("componentes/{tipo}")]
public async Task<IActionResult> GetComponenteValores(string tipo)
{
var allowedTypes = new Dictionary<string, string>
{
{ "os", "Os" },
{ "cpu", "Cpu" },
{ "motherboard", "Motherboard" },
{ "architecture", "Architecture" }
};
if (!allowedTypes.TryGetValue(tipo.ToLower(), out var columnName))
{
return BadRequest("Tipo de componente no válido.");
}
var query = $@"
SELECT {columnName} AS Valor, COUNT(*) AS Conteo
FROM dbo.equipos
WHERE {columnName} IS NOT NULL AND {columnName} != ''
GROUP BY {columnName}
ORDER BY Conteo DESC, Valor ASC;";
using (var connection = _context.CreateConnection())
{
var valores = await connection.QueryAsync<ComponenteValorDto>(query);
return Ok(valores);
}
}
[HttpPut("componentes/{tipo}/unificar")]
public async Task<IActionResult> UnificarComponenteValores(string tipo, [FromBody] UnificarComponenteDto dto)
{
var allowedTypes = new Dictionary<string, string>
{
{ "os", "Os" },
{ "cpu", "Cpu" },
{ "motherboard", "Motherboard" },
{ "architecture", "Architecture" }
};
if (!allowedTypes.TryGetValue(tipo.ToLower(), out var columnName))
{
return BadRequest("Tipo de componente no válido.");
}
if (dto.ValorAntiguo == dto.ValorNuevo)
{
return BadRequest("El valor antiguo y el nuevo no pueden ser iguales.");
}
var query = $@"
UPDATE dbo.equipos
SET {columnName} = @ValorNuevo
WHERE {columnName} = @ValorAntiguo;";
using (var connection = _context.CreateConnection())
{
var filasAfectadas = await connection.ExecuteAsync(query, new { dto.ValorNuevo, dto.ValorAntiguo });
return Ok(new { message = $"Se unificaron {filasAfectadas} registros.", filasAfectadas });
}
}
// --- Devuelve la RAM agrupada ---
[HttpGet("componentes/ram")]
public async Task<IActionResult> GetComponentesRam()
{
var query = @"
SELECT
mr.Fabricante,
mr.Tamano,
mr.Velocidad,
COUNT(emr.memoria_ram_id) as Conteo
FROM
dbo.memorias_ram mr
LEFT JOIN
dbo.equipos_memorias_ram emr ON mr.id = emr.memoria_ram_id
GROUP BY
mr.Fabricante,
mr.Tamano,
mr.Velocidad
ORDER BY
Conteo DESC, mr.Fabricante, mr.Tamano;";
using (var connection = _context.CreateConnection())
{
var valores = await connection.QueryAsync<RamAgrupadaDto>(query);
return Ok(valores);
}
}
// --- Elimina un grupo completo ---
[HttpDelete("componentes/ram")]
public async Task<IActionResult> BorrarComponenteRam([FromBody] BorrarRamAgrupadaDto dto)
{
using (var connection = _context.CreateConnection())
{
// Verificación de seguridad: Asegurarse de que el grupo no esté en uso.
var usageQuery = @"
SELECT COUNT(emr.id)
FROM dbo.memorias_ram mr
LEFT JOIN dbo.equipos_memorias_ram emr ON mr.id = emr.memoria_ram_id
WHERE (mr.Fabricante = @Fabricante OR (mr.Fabricante IS NULL AND @Fabricante IS NULL))
AND mr.Tamano = @Tamano
AND (mr.Velocidad = @Velocidad OR (mr.Velocidad IS NULL AND @Velocidad IS NULL));";
var usageCount = await connection.ExecuteScalarAsync<int>(usageQuery, dto);
if (usageCount > 0)
{
return Conflict(new { message = $"Este grupo de RAM está en uso por {usageCount} equipo(s) y no puede ser eliminado." });
}
// Si no está en uso, proceder con la eliminación de todos los registros maestros que coincidan.
var deleteQuery = @"
DELETE FROM dbo.memorias_ram
WHERE (Fabricante = @Fabricante OR (Fabricante IS NULL AND @Fabricante IS NULL))
AND Tamano = @Tamano
AND (Velocidad = @Velocidad OR (Velocidad IS NULL AND @Velocidad IS NULL));";
await connection.ExecuteAsync(deleteQuery, dto);
return NoContent();
}
}
[HttpDelete("componentes/{tipo}/{valor}")]
public async Task<IActionResult> BorrarComponenteTexto(string tipo, string valor)
{
var allowedTypes = new Dictionary<string, string>
{
{ "os", "Os" },
{ "cpu", "Cpu" },
{ "motherboard", "Motherboard" },
{ "architecture", "Architecture" }
};
if (!allowedTypes.TryGetValue(tipo.ToLower(), out var columnName))
{
return BadRequest("Tipo de componente no válido.");
}
using (var connection = _context.CreateConnection())
{
var usageQuery = $"SELECT COUNT(*) FROM dbo.equipos WHERE {columnName} = @Valor;";
var usageCount = await connection.ExecuteScalarAsync<int>(usageQuery, new { Valor = valor });
if (usageCount > 0)
{
return Conflict(new { message = $"Este valor está en uso por {usageCount} equipo(s) y no puede ser eliminado. Intente unificarlo en su lugar." });
}
return NoContent();
}
}
}
}

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

@@ -0,0 +1,116 @@
using Dapper;
using Inventario.API.Data;
using Inventario.API.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
namespace Inventario.API.Controllers
{
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class DiscosController : ControllerBase
{
private readonly DapperContext _context;
public DiscosController(DapperContext context)
{
_context = context;
}
// --- GET /api/discos ---
[HttpGet]
public async Task<IActionResult> Consultar()
{
var query = "SELECT Id, Mediatype, Size FROM dbo.discos;";
using (var connection = _context.CreateConnection())
{
var discos = await connection.QueryAsync<Disco>(query);
return Ok(discos);
}
}
// --- GET /api/discos/{id} ---
[HttpGet("{id}")]
public async Task<IActionResult> ConsultarDetalle(int id)
{
var query = "SELECT Id, Mediatype, Size FROM dbo.discos WHERE Id = @Id;";
using (var connection = _context.CreateConnection())
{
var disco = await connection.QuerySingleOrDefaultAsync<Disco>(query, new { Id = id });
if (disco == null)
{
return NotFound("Disco no encontrado.");
}
return Ok(disco);
}
}
// --- POST /api/discos ---
// Replica la lógica de recibir uno o varios discos y crear solo los que no existen.
[HttpPost]
public async Task<IActionResult> Ingresar([FromBody] List<Disco> discos)
{
var queryCheck = "SELECT * FROM dbo.discos WHERE Mediatype = @Mediatype AND Size = @Size;";
var queryInsert = "INSERT INTO dbo.discos (Mediatype, Size) VALUES (@Mediatype, @Size); SELECT CAST(SCOPE_IDENTITY() as int);";
var resultados = new List<object>();
using (var connection = _context.CreateConnection())
{
foreach (var disco in discos)
{
var existente = await connection.QuerySingleOrDefaultAsync<Disco>(queryCheck, new { disco.Mediatype, disco.Size });
if (existente == null)
{
var nuevoId = await connection.ExecuteScalarAsync<int>(queryInsert, new { disco.Mediatype, disco.Size });
var nuevoDisco = new Disco { Id = nuevoId, Mediatype = disco.Mediatype, Size = disco.Size };
resultados.Add(new { action = "created", registro = nuevoDisco });
}
else
{
// Opcional: podrías añadirlo si quieres saber cuáles ya existían
// resultados.Add(new { action = "exists", registro = existente });
}
}
}
// Devolvemos HTTP 200 OK con la lista de los discos que se crearon.
return Ok(resultados);
}
// --- PUT /api/discos/{id} ---
[HttpPut("{id}")]
public async Task<IActionResult> Actualizar(int id, [FromBody] Disco disco)
{
var query = "UPDATE dbo.discos SET Mediatype = @Mediatype, Size = @Size WHERE Id = @Id;";
using (var connection = _context.CreateConnection())
{
var filasAfectadas = await connection.ExecuteAsync(query, new { disco.Mediatype, disco.Size, Id = id });
if (filasAfectadas == 0)
{
return NotFound("Disco no encontrado.");
}
var discoActualizado = new Disco { Id = id, Mediatype = disco.Mediatype, Size = disco.Size };
return Ok(discoActualizado);
}
}
// --- DELETE /api/discos/{id} ---
[HttpDelete("{id}")]
public async Task<IActionResult> Borrar(int id)
{
var query = "DELETE FROM dbo.discos WHERE Id = @Id;";
using (var connection = _context.CreateConnection())
{
var filasAfectadas = await connection.ExecuteAsync(query, new { Id = id });
if (filasAfectadas == 0)
{
return NotFound("Disco no encontrado.");
}
return NoContent();
}
}
}
}

View File

@@ -0,0 +1,977 @@
using Dapper;
using Inventario.API.Data;
using Inventario.API.DTOs;
using Inventario.API.Helpers;
using Inventario.API.Models;
using Microsoft.AspNetCore.Mvc;
using System.Data;
using System.Net.NetworkInformation;
using Microsoft.Data.SqlClient;
using Renci.SshNet;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Authorization;
namespace Inventario.API.Controllers
{
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class EquiposController : ControllerBase
{
private readonly DapperContext _context;
private readonly IConfiguration _configuration; // 1. Añadimos el campo para la configuración
// 2. Modificamos el constructor para inyectar IConfiguration
public EquiposController(DapperContext context, IConfiguration configuration)
{
_context = context;
_configuration = configuration; // Asignamos la configuración inyectada
}
// --- MÉTODOS CRUD BÁSICOS ---
// GET /api/equipos
[HttpGet]
public async Task<IActionResult> Consultar()
{
var query = @"
SELECT
e.Id, e.Hostname, e.Ip, e.Mac, e.Motherboard, e.Cpu, e.Ram_installed, e.Ram_slots, e.Os, e.Architecture, e.created_at, e.updated_at, e.Origen, e.sector_id,
s.Id as Id, s.Nombre,
u.Id as Id, u.Username, u.Password, ue.Origen as Origen,
d.Id as Id, d.Mediatype, d.Size, ed.Origen as Origen, ed.Id as EquipoDiscoId,
mr.Id as Id, mr.part_number as PartNumber, mr.Fabricante, mr.Tamano, mr.Velocidad, emr.Slot, emr.Origen as Origen, emr.Id as EquipoMemoriaRamId
FROM dbo.equipos e
LEFT JOIN dbo.sectores s ON e.sector_id = s.id
LEFT JOIN dbo.usuarios_equipos ue ON e.id = ue.equipo_id
LEFT JOIN dbo.usuarios u ON ue.usuario_id = u.id
LEFT JOIN dbo.equipos_discos ed ON e.id = ed.equipo_id
LEFT JOIN dbo.discos d ON ed.disco_id = d.id
LEFT JOIN dbo.equipos_memorias_ram emr ON e.id = emr.equipo_id
LEFT JOIN dbo.memorias_ram mr ON emr.memoria_ram_id = mr.id;";
using (var connection = _context.CreateConnection())
{
var equipoDict = new Dictionary<int, Equipo>();
await connection.QueryAsync<Equipo, Sector, UsuarioEquipoDetalle, DiscoDetalle, MemoriaRamEquipoDetalle, Equipo>(
query, (equipo, sector, usuario, disco, memoria) =>
{
if (!equipoDict.TryGetValue(equipo.Id, out var equipoActual))
{
equipoActual = equipo;
equipoActual.Sector = sector;
equipoDict.Add(equipoActual.Id, equipoActual);
}
if (usuario != null && !equipoActual.Usuarios.Any(u => u.Id == usuario.Id))
equipoActual.Usuarios.Add(usuario);
if (disco != null && !equipoActual.Discos.Any(d => d.EquipoDiscoId == disco.EquipoDiscoId))
equipoActual.Discos.Add(disco);
if (memoria != null && !equipoActual.MemoriasRam.Any(m => m.EquipoMemoriaRamId == memoria.EquipoMemoriaRamId))
equipoActual.MemoriasRam.Add(memoria);
return equipoActual;
},
splitOn: "Id,Id,Id,Id"
);
return Ok(equipoDict.Values.OrderBy(e => e.Sector?.Nombre).ThenBy(e => e.Hostname));
}
}
// --- GET /api/equipos/{hostname} ---
[HttpGet("{hostname}")]
public async Task<IActionResult> ConsultarDetalle(string hostname)
{
var query = @"SELECT
e.*,
s.Id as SectorId, s.Nombre as SectorNombre,
u.Id as UsuarioId, u.Username, u.Password, ue.Origen as Origen
FROM dbo.equipos e
LEFT JOIN dbo.sectores s ON e.sector_id = s.id
LEFT JOIN dbo.usuarios_equipos ue ON e.id = ue.equipo_id
LEFT JOIN dbo.usuarios u ON ue.usuario_id = u.id
WHERE e.Hostname = @Hostname;";
using (var connection = _context.CreateConnection())
{
var equipoDict = new Dictionary<int, Equipo>();
var equipo = (await connection.QueryAsync<Equipo, Sector, UsuarioEquipoDetalle, Equipo>(
query, (e, sector, usuario) =>
{
if (!equipoDict.TryGetValue(e.Id, out var equipoActual))
{
equipoActual = e;
equipoActual.Sector = sector;
equipoDict.Add(equipoActual.Id, equipoActual);
}
if (usuario != null && !equipoActual.Usuarios.Any(u => u.Id == usuario.Id))
equipoActual.Usuarios.Add(usuario);
return equipoActual;
},
new { Hostname = hostname },
splitOn: "SectorId,UsuarioId"
)).FirstOrDefault();
if (equipo == null) return NotFound("Equipo no encontrado.");
var discosQuery = "SELECT d.*, ed.Origen, ed.Id as EquipoDiscoId FROM dbo.discos d JOIN dbo.equipos_discos ed ON d.Id = ed.disco_id WHERE ed.equipo_id = @Id";
equipo.Discos = (await connection.QueryAsync<DiscoDetalle>(discosQuery, new { equipo.Id })).ToList();
var ramQuery = "SELECT mr.*, emr.Slot, emr.Origen, emr.Id as EquipoMemoriaRamId FROM dbo.memorias_ram mr JOIN dbo.equipos_memorias_ram emr ON mr.Id = emr.memoria_ram_id WHERE emr.equipo_id = @Id";
equipo.MemoriasRam = (await connection.QueryAsync<MemoriaRamEquipoDetalle>(ramQuery, new { equipo.Id })).ToList();
return Ok(equipo);
}
}
// --- POST /api/equipos/{hostname} ---
[HttpPost("{hostname}")]
public async Task<IActionResult> Ingresar(string hostname, [FromBody] Equipo equipoData)
{
var findQuery = "SELECT * FROM dbo.equipos WHERE Hostname = @Hostname;";
using (var connection = _context.CreateConnection())
{
var equipoExistente = await connection.QuerySingleOrDefaultAsync<Equipo>(findQuery, new { Hostname = hostname });
if (equipoExistente == null)
{
// Crear
var insertQuery = @"INSERT INTO dbo.equipos (Hostname, Ip, Mac, Motherboard, Cpu, Ram_installed, Ram_slots, Os, Architecture, Origen)
VALUES (@Hostname, @Ip, @Mac, @Motherboard, @Cpu, @Ram_installed, @Ram_slots, @Os, @Architecture, 'automatica');
SELECT CAST(SCOPE_IDENTITY() as int);";
var nuevoId = await connection.ExecuteScalarAsync<int>(insertQuery, equipoData);
equipoData.Id = nuevoId;
return CreatedAtAction(nameof(ConsultarDetalle), new { hostname = equipoData.Hostname }, equipoData);
}
else
{
// Actualizar y registrar historial
var cambios = new Dictionary<string, (string? anterior, string? nuevo)>();
// Comparamos campos para registrar en historial
if (equipoData.Ip != equipoExistente.Ip) cambios["ip"] = (equipoExistente.Ip, equipoData.Ip);
if (equipoData.Mac != equipoExistente.Mac) cambios["mac"] = (equipoExistente.Mac ?? "", equipoData.Mac ?? "");
var updateQuery = @"UPDATE dbo.equipos SET Ip = @Ip, Mac = @Mac, Motherboard = @Motherboard,
Cpu = @Cpu, Ram_installed = @Ram_installed, Ram_slots = @Ram_slots, Os = @Os, Architecture = @Architecture
WHERE Hostname = @Hostname;";
await connection.ExecuteAsync(updateQuery, equipoData);
if (cambios.Count > 0)
{
await HistorialHelper.RegistrarCambios(_context, equipoExistente.Id, cambios);
}
return Ok(equipoData);
}
}
}
// --- PUT /api/equipos/{id} ---
[HttpPut("{id}")]
public async Task<IActionResult> Actualizar(int id, [FromBody] Equipo equipoData)
{
var updateQuery = @"UPDATE dbo.equipos SET Hostname = @Hostname, Ip = @Ip, Mac = @Mac, Motherboard = @Motherboard,
Cpu = @Cpu, Ram_installed = @Ram_installed, Ram_slots = @Ram_slots, Os = @Os, Architecture = @Architecture
WHERE Id = @Id;";
using (var connection = _context.CreateConnection())
{
// Asignamos el ID del parámetro de la ruta al objeto que recibimos.
equipoData.Id = id;
// Ahora pasamos el objeto completo a Dapper.
var filasAfectadas = await connection.ExecuteAsync(updateQuery, equipoData);
if (filasAfectadas == 0) return NotFound("Equipo no encontrado.");
return NoContent();
}
}
// --- DELETE /api/equipos/{id} ---
[HttpDelete("{id}")]
public async Task<IActionResult> Borrar(int id)
{
var query = "DELETE FROM dbo.equipos WHERE Id = @Id;";
using (var connection = _context.CreateConnection())
{
var filasAfectadas = await connection.ExecuteAsync(query, new { Id = id });
if (filasAfectadas == 0)
{
return NotFound("Equipo no encontrado.");
}
return NoContent();
}
}
// --- GET /api/equipos/{hostname}/historial ---
[HttpGet("{hostname}/historial")]
public async Task<IActionResult> ConsultarHistorial(string hostname)
{
var query = @"SELECT h.* FROM dbo.historial_equipos h
JOIN dbo.equipos e ON h.equipo_id = e.id
WHERE e.Hostname = @Hostname
ORDER BY h.fecha_cambio DESC;";
using (var connection = _context.CreateConnection())
{
var equipo = await connection.QueryFirstOrDefaultAsync<Equipo>("SELECT Id FROM dbo.equipos WHERE Hostname = @Hostname", new { Hostname = hostname });
if (equipo == null) return NotFound("Equipo no encontrado.");
var historial = await connection.QueryAsync<HistorialEquipo>(query, new { Hostname = hostname });
return Ok(new { equipo = hostname, historial });
}
}
// --- MÉTODOS DE ASOCIACIÓN Y COMANDOS ---
[HttpPatch("{id_equipo}/sector/{id_sector}")]
public async Task<IActionResult> AsociarSector(int id_equipo, int id_sector)
{
var query = "UPDATE dbo.equipos SET sector_id = @IdSector WHERE Id = @IdEquipo;";
using (var connection = _context.CreateConnection())
{
var filasAfectadas = await connection.ExecuteAsync(query, new { IdSector = id_sector, IdEquipo = id_equipo });
if (filasAfectadas == 0) return NotFound("Equipo o sector no encontrado.");
return Ok(new { success = true }); // Devolvemos una respuesta simple de éxito
}
}
[HttpPost("{hostname}/asociarusuario")]
public async Task<IActionResult> AsociarUsuario(string hostname, [FromBody] AsociacionUsuarioDto dto)
{
var query = @"
INSERT INTO dbo.usuarios_equipos (equipo_id, usuario_id, origen)
SELECT e.id, u.id, 'automatica'
FROM dbo.equipos e, dbo.usuarios u
WHERE e.Hostname = @Hostname AND u.Username = @Username
AND NOT EXISTS (
SELECT 1
FROM dbo.usuarios_equipos ue
WHERE ue.equipo_id = e.id AND ue.usuario_id = u.id
);";
using (var connection = _context.CreateConnection())
{
await connection.ExecuteAsync(query, new { Hostname = hostname, dto.Username });
return Ok(new { success = true, message = "Asociación asegurada." });
}
}
[HttpDelete("{hostname}/usuarios/{username}")]
public async Task<IActionResult> DesasociarUsuario(string hostname, string username)
{
var query = @"
DELETE FROM dbo.usuarios_equipos
WHERE equipo_id = (SELECT id FROM dbo.equipos WHERE Hostname = @Hostname)
AND usuario_id = (SELECT id FROM dbo.usuarios WHERE Username = @Username);";
using (var connection = _context.CreateConnection())
{
var filasAfectadas = await connection.ExecuteAsync(query, new { Hostname = hostname, Username = username });
if (filasAfectadas == 0) return NotFound("Asociación no encontrada.");
return Ok(new { success = true });
}
}
[HttpPost("{hostname}/asociardiscos")]
public async Task<IActionResult> AsociarDiscos(string hostname, [FromBody] List<Disco> discosDesdeCliente)
{
var equipoQuery = "SELECT * FROM dbo.equipos WHERE Hostname = @Hostname;";
using var connection = _context.CreateConnection();
connection.Open();
var equipo = await connection.QuerySingleOrDefaultAsync<Equipo>(equipoQuery, new { Hostname = hostname });
if (equipo == null)
{
return NotFound("Equipo no encontrado.");
}
using var transaction = connection.BeginTransaction();
try
{
var discosActualesQuery = @"
SELECT d.Id, d.Mediatype, d.Size, ed.Id as EquipoDiscoId
FROM dbo.equipos_discos ed
JOIN dbo.discos d ON ed.disco_id = d.id
WHERE ed.equipo_id = @EquipoId;";
var discosEnDb = (await connection.QueryAsync<DiscoAsociado>(discosActualesQuery, new { EquipoId = equipo.Id }, transaction)).ToList();
var discosClienteContados = discosDesdeCliente
.GroupBy(d => $"{d.Mediatype}_{d.Size}")
.ToDictionary(g => g.Key, g => g.Count());
var discosDbContados = discosEnDb
.GroupBy(d => $"{d.Mediatype}_{d.Size}")
.ToDictionary(g => g.Key, g => g.Count());
var cambios = new Dictionary<string, (string? anterior, string? nuevo)>();
var discosAEliminar = new List<int>();
foreach (var discoDb in discosEnDb)
{
var key = $"{discoDb.Mediatype}_{discoDb.Size}";
if (discosClienteContados.TryGetValue(key, out int count) && count > 0)
{
discosClienteContados[key]--;
}
else
{
discosAEliminar.Add(discoDb.EquipoDiscoId);
var nombreDisco = $"Disco {discoDb.Mediatype} {discoDb.Size}GB";
var anterior = discosDbContados.GetValueOrDefault(key, 0);
if (!cambios.ContainsKey(nombreDisco)) cambios[nombreDisco] = (anterior.ToString(), (anterior - 1).ToString());
else cambios[nombreDisco] = (anterior.ToString(), (int.Parse(cambios[nombreDisco].nuevo!) - 1).ToString());
}
}
if (discosAEliminar.Any())
{
await connection.ExecuteAsync("DELETE FROM dbo.equipos_discos WHERE Id IN @Ids;", new { Ids = discosAEliminar }, transaction);
}
foreach (var discoCliente in discosDesdeCliente)
{
var key = $"{discoCliente.Mediatype}_{discoCliente.Size}";
if (discosDbContados.TryGetValue(key, out int count) && count > 0)
{
discosDbContados[key]--;
}
else
{
var disco = await connection.QueryFirstOrDefaultAsync<Disco>("SELECT * FROM dbo.discos WHERE Mediatype = @Mediatype AND Size = @Size;", discoCliente, transaction);
if (disco == null) continue;
await connection.ExecuteAsync("INSERT INTO dbo.equipos_discos (equipo_id, disco_id, origen) VALUES (@EquipoId, @DiscoId, 'automatica');", new { EquipoId = equipo.Id, DiscoId = disco.Id }, transaction);
var nombreDisco = $"Disco {disco.Mediatype} {disco.Size}GB";
var anterior = discosDbContados.GetValueOrDefault(key, 0);
if (!cambios.ContainsKey(nombreDisco)) cambios[nombreDisco] = (anterior.ToString(), (anterior + 1).ToString());
else cambios[nombreDisco] = (anterior.ToString(), (int.Parse(cambios[nombreDisco].nuevo!) + 1).ToString());
}
}
if (cambios.Count > 0)
{
var cambiosFormateados = cambios.ToDictionary(
kvp => kvp.Key,
kvp => ((string?)$"{kvp.Value.anterior} Instalados", (string?)$"{kvp.Value.nuevo} Instalados")
);
await HistorialHelper.RegistrarCambios(_context, equipo.Id, cambiosFormateados);
}
transaction.Commit();
return Ok(new { message = "Discos sincronizados correctamente." });
}
catch (Exception ex)
{
transaction.Rollback();
Console.WriteLine($"Error al asociar discos para {hostname}: {ex.Message}");
return StatusCode(500, "Ocurrió un error interno al procesar la solicitud.");
}
}
[HttpPost("{hostname}/ram")]
public async Task<IActionResult> AsociarRam(string hostname, [FromBody] List<MemoriaRamEquipoDetalle> memoriasDesdeCliente)
{
var equipoQuery = "SELECT * FROM dbo.equipos WHERE Hostname = @Hostname;";
using var connection = _context.CreateConnection();
connection.Open();
var equipo = await connection.QuerySingleOrDefaultAsync<Equipo>(equipoQuery, new { Hostname = hostname });
if (equipo == null) return NotFound("Equipo no encontrado.");
using var transaction = connection.BeginTransaction();
try
{
var ramActualQuery = @"
SELECT emr.Id as EquipoMemoriaRamId, emr.Slot, mr.Id, mr.part_number as PartNumber, mr.Fabricante, mr.Tamano, mr.Velocidad
FROM dbo.equipos_memorias_ram emr
JOIN dbo.memorias_ram mr ON emr.memoria_ram_id = mr.id
WHERE emr.equipo_id = @EquipoId;";
var ramEnDb = (await connection.QueryAsync<dynamic>(ramActualQuery, new { EquipoId = equipo.Id }, transaction)).ToList();
Func<dynamic, string> crearHuella = ram => $"{ram.Slot}_{ram.PartNumber ?? ""}_{ram.Tamano}_{ram.Velocidad ?? 0}";
var huellasCliente = new HashSet<string>(memoriasDesdeCliente.Select(crearHuella));
var huellasDb = new HashSet<string>(ramEnDb.Select(crearHuella));
var cambios = new Dictionary<string, (string? anterior, string? nuevo)>();
Func<dynamic, string> formatRamDetails = ram =>
{
var parts = new List<string?> { ram.Fabricante, $"{ram.Tamano}GB", ram.PartNumber, ram.Velocidad?.ToString() + "MHz" };
return string.Join(" ", parts.Where(p => !string.IsNullOrEmpty(p)));
};
var modulosEliminados = ramEnDb.Where(ramDb => !huellasCliente.Contains(crearHuella(ramDb))).ToList();
foreach (var modulo in modulosEliminados)
{
var campo = $"RAM Slot {modulo.Slot}";
cambios[campo] = (formatRamDetails(modulo), "Vacio");
}
var modulosInsertados = memoriasDesdeCliente.Where(ramCliente => !huellasDb.Contains(crearHuella(ramCliente))).ToList();
foreach (var modulo in modulosInsertados)
{
var campo = $"RAM Slot {modulo.Slot}";
var valorNuevo = formatRamDetails(modulo);
if (cambios.ContainsKey(campo))
{
cambios[campo] = (cambios[campo].anterior, valorNuevo);
}
else
{
cambios[campo] = ("Vacio", valorNuevo);
}
}
var asociacionesAEliminar = modulosEliminados.Select(ramDb => (int)ramDb.EquipoMemoriaRamId).ToList();
if (asociacionesAEliminar.Any())
{
await connection.ExecuteAsync("DELETE FROM dbo.equipos_memorias_ram WHERE Id IN @Ids;", new { Ids = asociacionesAEliminar }, transaction);
}
foreach (var memInfo in modulosInsertados)
{
var findRamQuery = @"SELECT * FROM dbo.memorias_ram WHERE (part_number = @PartNumber OR (part_number IS NULL AND @PartNumber IS NULL)) AND tamano = @Tamano AND (velocidad = @Velocidad OR (velocidad IS NULL AND @Velocidad IS NULL));";
var memoriaMaestra = await connection.QuerySingleOrDefaultAsync<MemoriaRam>(findRamQuery, memInfo, transaction);
int memoriaMaestraId;
if (memoriaMaestra == null)
{
var insertRamQuery = @"INSERT INTO dbo.memorias_ram (part_number, fabricante, tamano, velocidad) VALUES (@PartNumber, @Fabricante, @Tamano, @Velocidad); SELECT CAST(SCOPE_IDENTITY() as int);";
memoriaMaestraId = await connection.ExecuteScalarAsync<int>(insertRamQuery, memInfo, transaction);
}
else
{
memoriaMaestraId = memoriaMaestra.Id;
}
// Crear la asociación en la tabla intermedia
var insertAsociacionQuery = "INSERT INTO dbo.equipos_memorias_ram (equipo_id, memoria_ram_id, slot, origen) VALUES (@EquipoId, @MemoriaRamId, @Slot, 'automatica');";
await connection.ExecuteAsync(insertAsociacionQuery, new { EquipoId = equipo.Id, MemoriaRamId = memoriaMaestraId, memInfo.Slot }, transaction);
}
if (cambios.Count > 0)
{
await HistorialHelper.RegistrarCambios(_context, equipo.Id, cambios);
}
transaction.Commit();
return Ok(new { message = "Módulos de RAM sincronizados correctamente." });
}
catch (Exception ex)
{
transaction.Rollback();
Console.WriteLine($"Error al asociar RAM para {hostname}: {ex.Message}");
return StatusCode(500, "Ocurrió un error interno al procesar la solicitud de RAM.");
}
}
[HttpPost("ping")]
public async Task<IActionResult> EnviarPing([FromBody] PingRequestDto request)
{
if (string.IsNullOrWhiteSpace(request.Ip))
return BadRequest("La dirección IP es requerida.");
try
{
using (var ping = new Ping())
{
var reply = await ping.SendPingAsync(request.Ip, 2000);
bool isAlive = reply.Status == IPStatus.Success;
if (!isAlive)
{
reply = await ping.SendPingAsync(request.Ip, 2000);
isAlive = reply.Status == IPStatus.Success;
}
return Ok(new { isAlive, latency = isAlive ? reply.RoundtripTime : (long?)null });
}
}
catch (PingException ex)
{
Console.WriteLine($"Error de Ping para {request.Ip}: {ex.Message}");
return Ok(new { isAlive = false, error = "Host no alcanzable o desconocido." });
}
catch (Exception ex)
{
Console.WriteLine($"Error interno al hacer ping a {request.Ip}: {ex.Message}");
return StatusCode(500, "Error interno del servidor al realizar el ping.");
}
}
[HttpPost("wake-on-lan")]
public IActionResult EnviarWol([FromBody] WolRequestDto request)
{
var mac = request.Mac;
var ip = request.Ip;
if (string.IsNullOrWhiteSpace(mac) || !Regex.IsMatch(mac, "^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$"))
{
return BadRequest("Formato de dirección MAC inválido.");
}
if (string.IsNullOrWhiteSpace(ip) || !Regex.IsMatch(ip, @"^(\d{1,3}\.){3}\d{1,3}$"))
{
return BadRequest("Formato de dirección IP inválido.");
}
var octetos = ip.Split('.');
if (octetos.Length != 4)
{
return BadRequest("Formato de dirección IP incorrecto.");
}
var vlanNumber = octetos[2];
var interfaceName = $"vlan{vlanNumber}";
// 3. Leemos los valores desde la configuración en lugar de hardcodearlos
var sshHost = _configuration.GetValue<string>("SshSettings:Host");
var sshPort = _configuration.GetValue<int>("SshSettings:Port");
var sshUser = _configuration.GetValue<string>("SshSettings:User");
var sshPass = _configuration.GetValue<string>("SshSettings:Password");
if (string.IsNullOrEmpty(sshHost) || string.IsNullOrEmpty(sshUser) || string.IsNullOrEmpty(sshPass))
{
Console.WriteLine("Error: La configuración SSH no está completa en appsettings.json.");
return StatusCode(500, "La configuración del servidor SSH está incompleta.");
}
try
{
using (var client = new SshClient(sshHost, sshPort, sshUser, sshPass))
{
client.Connect();
if (client.IsConnected)
{
var command = $"/usr/sbin/etherwake -b -i {interfaceName} {mac}";
var sshCommand = client.CreateCommand(command);
sshCommand.Execute();
Console.WriteLine($"Comando WOL ejecutado: {sshCommand.CommandText}");
client.Disconnect();
}
else
{
Console.WriteLine("Error: No se pudo conectar al servidor SSH.");
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Error al ejecutar comando WOL: {ex.Message}");
}
return NoContent();
}
[HttpPost("manual")]
public async Task<IActionResult> CrearEquipoManual([FromBody] CrearEquipoManualDto equipoDto)
{
var findQuery = "SELECT Id FROM dbo.equipos WHERE Hostname = @Hostname;";
var insertQuery = @"
INSERT INTO dbo.equipos (Hostname, Ip, Motherboard, Cpu, Os, Sector_id, Origen, Ram_installed, Architecture)
VALUES (@Hostname, @Ip, @Motherboard, @Cpu, @Os, @Sector_id, 'manual', 0, '');
SELECT CAST(SCOPE_IDENTITY() as int);";
using (var connection = _context.CreateConnection())
{
var existente = await connection.QueryFirstOrDefaultAsync<int?>(findQuery, new { equipoDto.Hostname });
if (existente.HasValue)
{
return Conflict($"El hostname '{equipoDto.Hostname}' ya existe.");
}
var nuevoId = await connection.ExecuteScalarAsync<int>(insertQuery, equipoDto);
await HistorialHelper.RegistrarCambioUnico(_context, nuevoId, "Equipo", null, "Equipo creado manualmente");
var nuevoEquipo = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT * FROM dbo.equipos WHERE Id = @Id", new { Id = nuevoId });
if (nuevoEquipo == null)
{
return StatusCode(500, "No se pudo recuperar el equipo después de crearlo.");
}
return CreatedAtAction(nameof(ConsultarDetalle), new { hostname = nuevoEquipo.Hostname }, nuevoEquipo);
}
}
// --- ENDPOINTS PARA BORRADO MANUAL DE ASOCIACIONES ---
[HttpDelete("asociacion/disco/{equipoDiscoId}")]
public async Task<IActionResult> BorrarAsociacionDisco(int equipoDiscoId)
{
using (var connection = _context.CreateConnection())
{
var infoQuery = @"
SELECT ed.equipo_id, d.Mediatype, d.Size
FROM dbo.equipos_discos ed
JOIN dbo.discos d ON ed.disco_id = d.id
WHERE ed.Id = @EquipoDiscoId AND ed.Origen = 'manual'";
var info = await connection.QueryFirstOrDefaultAsync<(int equipo_id, string Mediatype, int Size)>(infoQuery, new { EquipoDiscoId = equipoDiscoId });
if (info == default)
{
return NotFound("Asociación de disco no encontrada o no es manual.");
}
var deleteQuery = "DELETE FROM dbo.equipos_discos WHERE Id = @EquipoDiscoId;";
await connection.ExecuteAsync(deleteQuery, new { EquipoDiscoId = equipoDiscoId });
var descripcion = $"Disco {info.Mediatype} {info.Size}GB";
await HistorialHelper.RegistrarCambioUnico(_context, info.equipo_id, "Componente", descripcion, "Eliminado");
await connection.ExecuteAsync("UPDATE dbo.equipos SET updated_at = GETDATE() WHERE Id = @Id", new { Id = info.equipo_id });
return NoContent();
}
}
[HttpDelete("asociacion/ram/{equipoMemoriaRamId}")]
public async Task<IActionResult> BorrarAsociacionRam(int equipoMemoriaRamId)
{
using (var connection = _context.CreateConnection())
{
var infoQuery = @"
SELECT emr.equipo_id, emr.Slot, mr.Fabricante, mr.Tamano, mr.Velocidad
FROM dbo.equipos_memorias_ram emr
JOIN dbo.memorias_ram mr ON emr.memoria_ram_id = mr.id
WHERE emr.Id = @Id AND emr.Origen = 'manual'";
var info = await connection.QueryFirstOrDefaultAsync<(int equipo_id, string Slot, string? Fabricante, int Tamano, int? Velocidad)>(infoQuery, new { Id = equipoMemoriaRamId });
if (info == default)
{
return NotFound("Asociación de RAM no encontrada o no es manual.");
}
var deleteQuery = "DELETE FROM dbo.equipos_memorias_ram WHERE Id = @EquipoMemoriaRamId;";
await connection.ExecuteAsync(deleteQuery, new { EquipoMemoriaRamId = equipoMemoriaRamId });
var descripcion = $"Módulo RAM: Slot {info.Slot} - {info.Fabricante ?? ""} {info.Tamano}GB {info.Velocidad?.ToString() ?? ""}MHz";
await HistorialHelper.RegistrarCambioUnico(_context, info.equipo_id, "Componente", descripcion, "Eliminado");
var updateQuery = @"
UPDATE e
SET
e.ram_installed = ISNULL((SELECT SUM(mr.Tamano)
FROM dbo.equipos_memorias_ram emr
JOIN dbo.memorias_ram mr ON emr.memoria_ram_id = mr.id
WHERE emr.equipo_id = @Id), 0),
e.updated_at = GETDATE()
FROM dbo.equipos e
WHERE e.Id = @Id;";
await connection.ExecuteAsync(updateQuery, new { Id = info.equipo_id });
return NoContent();
}
}
[HttpDelete("asociacion/usuario/{equipoId}/{usuarioId}")]
public async Task<IActionResult> BorrarAsociacionUsuario(int equipoId, int usuarioId)
{
using (var connection = _context.CreateConnection())
{
var username = await connection.QuerySingleOrDefaultAsync<string>("SELECT Username FROM dbo.usuarios WHERE Id = @UsuarioId", new { UsuarioId = usuarioId });
if (username == null)
{
return NotFound("Usuario no encontrado.");
}
var deleteQuery = "DELETE FROM dbo.usuarios_equipos WHERE equipo_id = @EquipoId AND usuario_id = @UsuarioId AND Origen = 'manual';";
var filasAfectadas = await connection.ExecuteAsync(deleteQuery, new { EquipoId = equipoId, UsuarioId = usuarioId });
if (filasAfectadas == 0)
{
return NotFound("Asociación de usuario no encontrada o no es manual.");
}
var descripcion = $"Usuario {username}";
await HistorialHelper.RegistrarCambioUnico(_context, equipoId, "Componente", descripcion, "Eliminado");
await connection.ExecuteAsync("UPDATE dbo.equipos SET updated_at = GETDATE() WHERE Id = @Id", new { Id = equipoId });
return NoContent();
}
}
[HttpPut("manual/{id}")]
public async Task<IActionResult> ActualizarEquipoManual(int id, [FromBody] EditarEquipoManualDto equipoDto)
{
using (var connection = _context.CreateConnection())
{
var equipoActual = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT * FROM dbo.equipos WHERE Id = @Id", new { Id = id });
if (equipoActual == null)
{
return NotFound("El equipo no existe.");
}
if (equipoActual.Origen != "manual")
{
return Forbid("No se puede modificar un equipo generado automáticamente.");
}
if (equipoActual.Hostname != equipoDto.Hostname)
{
var hostExistente = await connection.QuerySingleOrDefaultAsync<int?>("SELECT Id FROM dbo.equipos WHERE Hostname = @Hostname AND Id != @Id", new { equipoDto.Hostname, Id = id });
if (hostExistente.HasValue)
{
return Conflict($"El hostname '{equipoDto.Hostname}' ya está en uso por otro equipo.");
}
}
var allSectores = await connection.QueryAsync<Sector>("SELECT Id, Nombre FROM dbo.sectores;");
var sectorMap = allSectores.ToDictionary(s => s.Id, s => s.Nombre);
var cambios = new Dictionary<string, (string? anterior, string? nuevo)>();
if (equipoActual.Hostname != equipoDto.Hostname) cambios["Hostname"] = (equipoActual.Hostname, equipoDto.Hostname);
if (equipoActual.Ip != equipoDto.Ip) cambios["IP"] = (equipoActual.Ip, equipoDto.Ip);
if (equipoActual.Mac != equipoDto.Mac) cambios["MAC Address"] = (equipoActual.Mac ?? "N/A", equipoDto.Mac ?? "N/A");
if (equipoActual.Motherboard != equipoDto.Motherboard) cambios["Motherboard"] = (equipoActual.Motherboard ?? "N/A", equipoDto.Motherboard ?? "N/A");
if (equipoActual.Cpu != equipoDto.Cpu) cambios["CPU"] = (equipoActual.Cpu ?? "N/A", equipoDto.Cpu ?? "N/A");
if (equipoActual.Os != equipoDto.Os) cambios["Sistema Operativo"] = (equipoActual.Os ?? "N/A", equipoDto.Os ?? "N/A");
if (equipoActual.Architecture != equipoDto.Architecture) cambios["Arquitectura"] = (equipoActual.Architecture ?? "N/A", equipoDto.Architecture ?? "N/A");
if (equipoActual.Ram_slots != equipoDto.Ram_slots) cambios["Slots RAM"] = (equipoActual.Ram_slots?.ToString() ?? "N/A", equipoDto.Ram_slots?.ToString() ?? "N/A");
if (equipoActual.Sector_id != equipoDto.Sector_id)
{
string nombreAnterior = equipoActual.Sector_id.HasValue && sectorMap.TryGetValue(equipoActual.Sector_id.Value, out var oldName)
? oldName
: "Ninguno";
string nombreNuevo = equipoDto.Sector_id.HasValue && sectorMap.TryGetValue(equipoDto.Sector_id.Value, out var newName)
? newName
: "Ninguno";
cambios["Sector"] = (nombreAnterior, nombreNuevo);
}
var updateQuery = @"UPDATE dbo.equipos SET
Hostname = @Hostname,
Ip = @Ip,
Mac = @Mac,
Motherboard = @Motherboard,
Cpu = @Cpu,
Os = @Os,
Sector_id = @Sector_id,
Ram_slots = @Ram_slots,
Architecture = @Architecture, -- Campo añadido a la actualización
updated_at = GETDATE()
OUTPUT INSERTED.*
WHERE Id = @Id AND Origen = 'manual';";
var equipoActualizado = await connection.QuerySingleOrDefaultAsync<Equipo>(updateQuery, new
{
equipoDto.Hostname,
equipoDto.Ip,
mac = equipoDto.Mac,
equipoDto.Motherboard,
equipoDto.Cpu,
equipoDto.Os,
equipoDto.Sector_id,
equipoDto.Ram_slots,
equipoDto.Architecture,
Id = id
});
if (equipoActualizado == null)
{
return StatusCode(500, "No se pudo actualizar el equipo.");
}
if (cambios.Count > 0)
{
await HistorialHelper.RegistrarCambios(_context, id, cambios);
}
var equipoCompleto = await ConsultarDetalle(equipoActualizado.Hostname);
return equipoCompleto;
}
}
[HttpPost("manual/{equipoId}/disco")]
public async Task<IActionResult> AsociarDiscoManual(int equipoId, [FromBody] AsociarDiscoManualDto dto)
{
using (var connection = _context.CreateConnection())
{
var equipo = await connection.QueryFirstOrDefaultAsync<Equipo>("SELECT Origen FROM dbo.equipos WHERE Id = @Id", new { Id = equipoId });
if (equipo == null || equipo.Origen != "manual") return Forbid("Solo se pueden añadir componentes a equipos manuales.");
var discoMaestro = await connection.QueryFirstOrDefaultAsync<Disco>("SELECT * FROM dbo.discos WHERE Mediatype = @Mediatype AND Size = @Size", dto);
int discoId;
if (discoMaestro == null)
{
discoId = await connection.ExecuteScalarAsync<int>("INSERT INTO dbo.discos (Mediatype, Size) VALUES (@Mediatype, @Size); SELECT CAST(SCOPE_IDENTITY() as int);", dto);
}
else
{
discoId = discoMaestro.Id;
}
var asociacionQuery = "INSERT INTO dbo.equipos_discos (equipo_id, disco_id, origen) VALUES (@EquipoId, @DiscoId, 'manual'); SELECT CAST(SCOPE_IDENTITY() as int);";
var nuevaAsociacionId = await connection.ExecuteScalarAsync<int>(asociacionQuery, new { EquipoId = equipoId, DiscoId = discoId });
var descripcion = $"Disco {dto.Mediatype} {dto.Size}GB";
await HistorialHelper.RegistrarCambioUnico(_context, equipoId, "Componente", null, $"Añadido: {descripcion}");
await connection.ExecuteAsync("UPDATE dbo.equipos SET updated_at = GETDATE() WHERE Id = @Id", new { Id = equipoId });
return Ok(new { message = "Disco asociado manualmente.", equipoDiscoId = nuevaAsociacionId });
}
}
[HttpPost("manual/{equipoId}/ram")]
public async Task<IActionResult> AsociarRamManual(int equipoId, [FromBody] AsociarRamManualDto dto)
{
using (var connection = _context.CreateConnection())
{
var equipo = await connection.QueryFirstOrDefaultAsync<Equipo>("SELECT Origen FROM dbo.equipos WHERE Id = @Id", new { Id = equipoId });
if (equipo == null || equipo.Origen != "manual") return Forbid("Solo se pueden añadir componentes a equipos manuales.");
int ramId;
var ramMaestra = await connection.QueryFirstOrDefaultAsync<MemoriaRam>(
"SELECT * FROM dbo.memorias_ram WHERE (Fabricante = @Fabricante OR (Fabricante IS NULL AND @Fabricante IS NULL)) AND Tamano = @Tamano AND (Velocidad = @Velocidad OR (Velocidad IS NULL AND @Velocidad IS NULL))", dto);
if (ramMaestra == null)
{
var insertQuery = @"INSERT INTO dbo.memorias_ram (part_number, fabricante, tamano, velocidad)
VALUES (@PartNumber, @Fabricante, @Tamano, @Velocidad);
SELECT CAST(SCOPE_IDENTITY() as int);";
ramId = await connection.ExecuteScalarAsync<int>(insertQuery, dto);
}
else
{
ramId = ramMaestra.Id;
}
var asociacionQuery = "INSERT INTO dbo.equipos_memorias_ram (equipo_id, memoria_ram_id, slot, origen) VALUES (@EquipoId, @RamId, @Slot, 'manual'); SELECT CAST(SCOPE_IDENTITY() as int);";
var nuevaAsociacionId = await connection.ExecuteScalarAsync<int>(asociacionQuery, new { EquipoId = equipoId, RamId = ramId, dto.Slot });
var descripcion = $"Módulo RAM: Slot {dto.Slot} - {dto.Fabricante ?? ""} {dto.Tamano}GB {dto.Velocidad?.ToString() ?? ""}MHz";
await HistorialHelper.RegistrarCambioUnico(_context, equipoId, "Componente", null, $"Añadido: {descripcion}");
var updateQuery = @"
UPDATE e SET
e.ram_installed = ISNULL((SELECT SUM(mr.Tamano)
FROM dbo.equipos_memorias_ram emr
JOIN dbo.memorias_ram mr ON emr.memoria_ram_id = mr.id
WHERE emr.equipo_id = @Id), 0),
e.updated_at = GETDATE()
FROM dbo.equipos e
WHERE e.Id = @Id;";
await connection.ExecuteAsync(updateQuery, new { Id = equipoId });
return Ok(new { message = "RAM asociada manualmente.", equipoMemoriaRamId = nuevaAsociacionId });
}
}
[HttpPost("manual/{equipoId}/usuario")]
public async Task<IActionResult> AsociarUsuarioManual(int equipoId, [FromBody] AsociarUsuarioManualDto dto)
{
using (var connection = _context.CreateConnection())
{
var equipo = await connection.QueryFirstOrDefaultAsync<Equipo>("SELECT Origen FROM dbo.equipos WHERE Id = @Id", new { Id = equipoId });
if (equipo == null || equipo.Origen != "manual") return Forbid("Solo se pueden añadir componentes a equipos manuales.");
int usuarioId;
var usuario = await connection.QueryFirstOrDefaultAsync<Usuario>("SELECT * FROM dbo.usuarios WHERE Username = @Username", dto);
if (usuario == null)
{
usuarioId = await connection.ExecuteScalarAsync<int>("INSERT INTO dbo.usuarios (Username) VALUES (@Username); SELECT CAST(SCOPE_IDENTITY() as int);", dto);
}
else
{
usuarioId = usuario.Id;
}
try
{
var asociacionQuery = "INSERT INTO dbo.usuarios_equipos (equipo_id, usuario_id, origen) VALUES (@EquipoId, @UsuarioId, 'manual');";
await connection.ExecuteAsync(asociacionQuery, new { EquipoId = equipoId, UsuarioId = usuarioId });
}
catch (SqlException ex) when (ex.Number == 2627)
{
return Conflict("El usuario ya está asociado a este equipo.");
}
var descripcion = $"Usuario {dto.Username}";
await HistorialHelper.RegistrarCambioUnico(_context, equipoId, "Componente", null, $"Añadido: {descripcion}");
await connection.ExecuteAsync("UPDATE dbo.equipos SET updated_at = GETDATE() WHERE Id = @Id", new { Id = equipoId });
return Ok(new { message = "Usuario asociado manualmente." });
}
}
[HttpGet("distinct/{fieldName}")]
public async Task<IActionResult> GetDistinctFieldValues(string fieldName)
{
// 1. Lista blanca de campos permitidos para evitar inyección SQL y exposición de datos.
var allowedFields = new List<string> { "os", "cpu", "motherboard", "architecture" };
if (!allowedFields.Contains(fieldName.ToLower()))
{
return BadRequest("El campo especificado no es válido o no está permitido.");
}
// 2. Construir la consulta de forma segura
var query = $"SELECT DISTINCT {fieldName} FROM dbo.equipos WHERE {fieldName} IS NOT NULL AND {fieldName} != '' ORDER BY {fieldName};";
using (var connection = _context.CreateConnection())
{
var values = await connection.QueryAsync<string>(query);
return Ok(values);
}
}
// DTOs locales para las peticiones
public class PingRequestDto { public string? Ip { get; set; } }
public class WolRequestDto
{
public string? Mac { get; set; }
public string? Ip { get; set; }
}
class DiscoAsociado
{
public int Id { get; set; }
public string Mediatype { get; set; } = "";
public int Size { get; set; }
public int EquipoDiscoId { get; set; }
}
public class CrearEquipoManualDto
{
public required string Hostname { get; set; }
public required string Ip { get; set; }
public string? Motherboard { get; set; }
public string? Cpu { get; set; }
public string? Os { get; set; }
public int? Sector_id { get; set; }
}
public class EditarEquipoManualDto
{
public required string Hostname { get; set; }
public required string Ip { get; set; }
public string? Mac { get; set; }
public string? Motherboard { get; set; }
public string? Cpu { get; set; }
public string? Os { get; set; }
public int? Sector_id { get; set; }
public int? Ram_slots { get; set; }
public string? Architecture { get; set; }
}
public class AsociarDiscoManualDto
{
public required string Mediatype { get; set; }
public int Size { get; set; }
}
public class AsociarRamManualDto
{
public required string Slot { get; set; }
public int Tamano { get; set; }
public string? Fabricante { get; set; }
public int? Velocidad { get; set; }
public string? PartNumber { get; set; }
}
public class AsociarUsuarioManualDto
{
public required string Username { get; set; }
}
}
}

View File

@@ -0,0 +1,171 @@
// backend/Controllers/MemoriasRamController.cs
using Dapper;
using Inventario.API.Data;
using Inventario.API.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
namespace Inventario.API.Controllers
{
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class MemoriasRamController : ControllerBase
{
private readonly DapperContext _context;
public MemoriasRamController(DapperContext context)
{
_context = context;
}
[HttpGet]
public async Task<IActionResult> Consultar()
{
var query = @"
SELECT
MIN(Id) as Id,
MIN(part_number) as PartNumber,
Fabricante,
Tamano,
Velocidad
FROM
dbo.memorias_ram
WHERE
Fabricante IS NOT NULL AND Fabricante != ''
GROUP BY
Fabricante,
Tamano,
Velocidad
ORDER BY
Fabricante, Tamano, Velocidad;";
using (var connection = _context.CreateConnection())
{
var memorias = await connection.QueryAsync<MemoriaRam>(query);
return Ok(memorias);
}
}
[HttpGet("{id}")]
public async Task<IActionResult> ConsultarDetalle(int id)
{
var query = "SELECT Id, part_number as PartNumber, Fabricante, Tamano, Velocidad FROM dbo.memorias_ram WHERE Id = @Id;";
using (var connection = _context.CreateConnection())
{
var memoria = await connection.QuerySingleOrDefaultAsync<MemoriaRam>(query, new { Id = id });
if (memoria == null)
{
return NotFound("Módulo de memoria RAM no encontrado.");
}
return Ok(memoria);
}
}
[HttpPost]
public async Task<IActionResult> Ingresar([FromBody] List<MemoriaRam> memorias)
{
var queryCheck = @"SELECT * FROM dbo.memorias_ram WHERE
(part_number = @PartNumber OR (part_number IS NULL AND @PartNumber IS NULL)) AND
(fabricante = @Fabricante OR (fabricante IS NULL AND @Fabricante IS NULL)) AND
tamano = @Tamano AND
(velocidad = @Velocidad OR (velocidad IS NULL AND @Velocidad IS NULL));";
var queryInsert = @"INSERT INTO dbo.memorias_ram (part_number, fabricante, tamano, velocidad)
VALUES (@PartNumber, @Fabricante, @Tamano, @Velocidad);
SELECT CAST(SCOPE_IDENTITY() as int);";
var resultados = new List<object>();
using (var connection = _context.CreateConnection())
{
foreach (var memoria in memorias)
{
var existente = await connection.QuerySingleOrDefaultAsync<MemoriaRam>(queryCheck, memoria);
if (existente == null)
{
var nuevoId = await connection.ExecuteScalarAsync<int>(queryInsert, memoria);
memoria.Id = nuevoId;
resultados.Add(new { action = "created", registro = memoria });
}
else
{
resultados.Add(new { action = "exists", registro = existente });
}
}
}
return Ok(resultados);
}
[HttpPut("{id}")]
public async Task<IActionResult> Actualizar(int id, [FromBody] MemoriaRam memoria)
{
var query = @"UPDATE dbo.memorias_ram SET
part_number = @PartNumber,
fabricante = @Fabricante,
tamano = @Tamano,
velocidad = @Velocidad
WHERE Id = @Id;";
using (var connection = _context.CreateConnection())
{
var filasAfectadas = await connection.ExecuteAsync(query, new { memoria.PartNumber, memoria.Fabricante, memoria.Tamano, memoria.Velocidad, Id = id });
if (filasAfectadas == 0)
{
return NotFound("Módulo de memoria RAM no encontrado.");
}
memoria.Id = id;
return Ok(memoria);
}
}
[HttpDelete("{id}")]
public async Task<IActionResult> Borrar(int id)
{
var deleteAssociationsQuery = "DELETE FROM dbo.equipos_memorias_ram WHERE memoria_ram_id = @Id;";
var deleteRamQuery = "DELETE FROM dbo.memorias_ram WHERE Id = @Id;";
using (var connection = _context.CreateConnection())
{
connection.Open();
using (var transaction = connection.BeginTransaction())
{
try
{
await connection.ExecuteAsync(deleteAssociationsQuery, new { Id = id }, transaction: transaction);
var filasAfectadas = await connection.ExecuteAsync(deleteRamQuery, new { Id = id }, transaction: transaction);
if (filasAfectadas == 0)
{
transaction.Rollback();
return NotFound("Módulo de memoria RAM no encontrado.");
}
transaction.Commit();
return NoContent();
}
catch
{
transaction.Rollback();
throw;
}
}
}
}
[HttpGet("buscar/{termino}")]
public async Task<IActionResult> BuscarMemoriasRam(string termino)
{
var query = @"SELECT Id, part_number as PartNumber, Fabricante, Tamano, Velocidad
FROM dbo.memorias_ram
WHERE Fabricante LIKE @SearchTerm OR part_number LIKE @SearchTerm
ORDER BY Fabricante, Tamano;";
using (var connection = _context.CreateConnection())
{
var memorias = await connection.QueryAsync<MemoriaRam>(query, new { SearchTerm = $"%{termino}%" });
return Ok(memorias);
}
}
}
}

View File

@@ -1,44 +1,137 @@
// backend/Controllers/SectoresController.cs
using Dapper;
using Inventario.API.Data;
using Inventario.API.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
namespace Inventario.API.Controllers
{
// [ApiController] habilita comportamientos estándar de API como la validación automática.
[Authorize]
[ApiController]
// [Route("api/[controller]")] define la URL base para este controlador.
// "[controller]" se reemplaza por el nombre de la clase sin "Controller", es decir, "api/sectores".
[Route("api/[controller]")]
public class SectoresController : ControllerBase
{
// Campo privado para guardar la referencia a nuestro contexto de Dapper.
private readonly DapperContext _context;
// El constructor recibe el DapperContext a través de la inyección de dependencias que configuramos en Program.cs.
public SectoresController(DapperContext context)
{
_context = context;
}
// [HttpGet] marca este método para que responda a peticiones GET a la ruta base ("api/sectores").
// --- GET /api/sectores ---
// Método para obtener todos los sectores.
[HttpGet]
public async Task<IActionResult> ConsultarSectores()
{
// Definimos nuestra consulta SQL. Es buena práctica listar las columnas explícitamente.
var query = "SELECT Id, Nombre FROM dbo.sectores ORDER BY Nombre;";
// Creamos una conexión a la base de datos. El 'using' asegura que la conexión se cierre y se libere correctamente, incluso si hay errores.
using (var connection = _context.CreateConnection())
{
// Usamos el método QueryAsync de Dapper.
// <Sector> le dice a Dapper que mapee cada fila del resultado a un objeto de nuestra clase Sector.
// 'await' espera a que la base de datos responda sin bloquear el servidor.
var sectores = await connection.QueryAsync<Sector>(query);
// Ok() crea una respuesta HTTP 200 OK y serializa la lista de sectores a formato JSON.
return Ok(sectores);
}
}
// --- GET /api/sectores/{id} ---
// Método para obtener un único sector por su ID.
[HttpGet("{id}")]
public async Task<IActionResult> ConsultarSectorDetalle(int id)
{
var query = "SELECT Id, Nombre FROM dbo.sectores WHERE Id = @Id;";
using (var connection = _context.CreateConnection())
{
// QuerySingleOrDefaultAsync es perfecto para obtener un solo registro.
// Devuelve el objeto si lo encuentra, o null si no existe.
var sector = await connection.QuerySingleOrDefaultAsync<Sector>(query, new { Id = id });
if (sector == null)
{
// Si no se encuentra, devolvemos un error HTTP 404 Not Found.
return NotFound();
}
return Ok(sector);
}
}
// --- POST /api/sectores ---
// Método para crear un nuevo sector.
// Nota: Cambiado de /:nombre a un POST estándar que recibe el objeto en el body. Es más RESTful.
[HttpPost]
public async Task<IActionResult> IngresarSector([FromBody] Sector sector)
{
var checkQuery = "SELECT COUNT(1) FROM dbo.sectores WHERE Nombre = @Nombre;";
var insertQuery = "INSERT INTO dbo.sectores (Nombre) VALUES (@Nombre); SELECT CAST(SCOPE_IDENTITY() as int);";
using (var connection = _context.CreateConnection())
{
// Primero, verificamos si ya existe un sector con ese nombre.
var existe = await connection.ExecuteScalarAsync<bool>(checkQuery, new { sector.Nombre });
if (existe)
{
// Si ya existe, devolvemos un error HTTP 409 Conflict.
return Conflict($"Ya existe un sector con el nombre '{sector.Nombre}'");
}
// ExecuteScalarAsync ejecuta la consulta y devuelve el primer valor de la primera fila (el nuevo ID).
var nuevoId = await connection.ExecuteScalarAsync<int>(insertQuery, new { sector.Nombre });
var nuevoSector = new Sector { Id = nuevoId, Nombre = sector.Nombre };
// Devolvemos una respuesta HTTP 201 Created, con la ubicación del nuevo recurso y el objeto creado.
return CreatedAtAction(nameof(ConsultarSectorDetalle), new { id = nuevoId }, nuevoSector);
}
}
// --- PUT /api/sectores/{id} ---
// Método para actualizar un sector existente.
[HttpPut("{id}")]
public async Task<IActionResult> ActualizarSector(int id, [FromBody] Sector sector)
{
var query = "UPDATE dbo.sectores SET Nombre = @Nombre WHERE Id = @Id;";
using (var connection = _context.CreateConnection())
{
// ExecuteAsync devuelve el número de filas afectadas.
var filasAfectadas = await connection.ExecuteAsync(query, new { Nombre = sector.Nombre, Id = id });
if (filasAfectadas == 0)
{
// Si no se afectó ninguna fila, significa que el ID no existía.
return NotFound();
}
// Devolvemos HTTP 204 No Content para indicar que la actualización fue exitosa pero no hay nada que devolver.
return NoContent();
}
}
// --- DELETE /api/sectores/{id} ---
// Método para eliminar un sector.
[HttpDelete("{id}")]
public async Task<IActionResult> BorrarSector(int id)
{
using (var connection = _context.CreateConnection())
{
// 1. VERIFICAR SI EL SECTOR ESTÁ EN USO
var usageQuery = "SELECT COUNT(1) FROM dbo.equipos WHERE sector_id = @Id;";
var usageCount = await connection.ExecuteScalarAsync<int>(usageQuery, new { Id = id });
if (usageCount > 0)
{
// 2. DEVOLVER HTTP 409 CONFLICT SI ESTÁ EN USO
return Conflict(new { message = $"No se puede eliminar. Hay {usageCount} equipo(s) asignados a este sector." });
}
// 3. SI NO ESTÁ EN USO, PROCEDER CON LA ELIMINACIÓN
var deleteQuery = "DELETE FROM dbo.sectores WHERE Id = @Id;";
var filasAfectadas = await connection.ExecuteAsync(deleteQuery, new { Id = id });
if (filasAfectadas == 0)
{
return NotFound();
}
return NoContent();
}
}
}
}

View File

@@ -0,0 +1,143 @@
using Dapper;
using Inventario.API.Data;
using Inventario.API.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
namespace Inventario.API.Controllers
{
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class UsuariosController : ControllerBase
{
private readonly DapperContext _context;
public UsuariosController(DapperContext context)
{
_context = context;
}
// --- GET /api/usuarios ---
[HttpGet]
public async Task<IActionResult> Consultar()
{
var query = "SELECT Id, Username, Password, Created_at, Updated_at FROM dbo.usuarios ORDER BY Username;";
using (var connection = _context.CreateConnection())
{
var usuarios = await connection.QueryAsync<Usuario>(query);
return Ok(usuarios);
}
}
// --- GET /api/usuarios/{id} ---
[HttpGet("{id}")]
public async Task<IActionResult> ConsultarDetalle(int id)
{
var query = "SELECT Id, Username, Password, Created_at, Updated_at FROM dbo.usuarios WHERE Id = @Id;";
using (var connection = _context.CreateConnection())
{
var usuario = await connection.QuerySingleOrDefaultAsync<Usuario>(query, new { Id = id });
if (usuario == null)
{
return NotFound("Usuario no encontrado.");
}
return Ok(usuario);
}
}
// --- POST /api/usuarios ---
// Este endpoint replica la lógica "upsert" del original: si el usuario existe, lo actualiza; si no, lo crea.
[HttpPost]
public async Task<IActionResult> Ingresar([FromBody] Usuario usuario)
{
var findQuery = "SELECT * FROM dbo.usuarios WHERE Username = @Username;";
using (var connection = _context.CreateConnection())
{
var usuarioExistente = await connection.QuerySingleOrDefaultAsync<Usuario>(findQuery, new { usuario.Username });
if (usuarioExistente != null)
{
// El usuario ya existe.
// SOLO actualizamos la contraseña si se proporciona una nueva en el body de la petición.
if (!string.IsNullOrEmpty(usuario.Password))
{
var updateQuery = "UPDATE dbo.usuarios SET Password = @Password, updated_at = GETDATE() WHERE Id = @Id;";
await connection.ExecuteAsync(updateQuery, new { usuario.Password, Id = usuarioExistente.Id });
}
// Si no se envía contraseña, simplemente no hacemos nada y el valor en la BD se conserva.
// Devolvemos el usuario que ya existe (con o sin la contraseña actualizada)
var usuarioActualizado = await connection.QuerySingleOrDefaultAsync<Usuario>(findQuery, new { usuario.Username });
return Ok(usuarioActualizado);
}
else
{
// El usuario no existe, lo creamos
var insertQuery = "INSERT INTO dbo.usuarios (Username, Password) VALUES (@Username, @Password); SELECT CAST(SCOPE_IDENTITY() as int);";
var nuevoId = await connection.ExecuteScalarAsync<int>(insertQuery, new { usuario.Username, usuario.Password });
var nuevoUsuario = new Usuario
{
Id = nuevoId,
Username = usuario.Username,
Password = usuario.Password
};
return CreatedAtAction(nameof(ConsultarDetalle), new { id = nuevoId }, nuevoUsuario);
}
}
}
// --- PUT /api/usuarios/{id} ---
// Endpoint específico para actualizar la contraseña, como en el original.
[HttpPut("{id}")]
public async Task<IActionResult> Actualizar(int id, [FromBody] Usuario data)
{
var updateQuery = "UPDATE dbo.usuarios SET Password = @Password WHERE Id = @Id;";
using (var connection = _context.CreateConnection())
{
var filasAfectadas = await connection.ExecuteAsync(updateQuery, new { data.Password, Id = id });
if (filasAfectadas == 0)
{
return NotFound("Usuario no encontrado.");
}
// Para replicar la respuesta del backend original, volvemos a consultar el usuario (sin la contraseña).
var selectQuery = "SELECT Id, Username FROM dbo.usuarios WHERE Id = @Id;";
var usuarioActualizado = await connection.QuerySingleOrDefaultAsync(selectQuery, new { Id = id });
return Ok(usuarioActualizado);
}
}
// --- DELETE /api/usuarios/{id} ---
[HttpDelete("{id}")]
public async Task<IActionResult> Borrar(int id)
{
var query = "DELETE FROM dbo.usuarios WHERE Id = @Id;";
using (var connection = _context.CreateConnection())
{
var filasAfectadas = await connection.ExecuteAsync(query, new { Id = id });
if (filasAfectadas == 0)
{
return NotFound("Usuario no encontrado.");
}
return NoContent(); // Respuesta HTTP 204 No Content
}
}
// --- GET /api/usuarios/buscar/{termino} ---
[HttpGet("buscar/{termino}")]
public async Task<IActionResult> BuscarUsuarios(string termino)
{
// Usamos LIKE para una búsqueda flexible. El '%' son comodines.
var query = "SELECT Username FROM dbo.usuarios WHERE Username LIKE @SearchTerm ORDER BY Username;";
using (var connection = _context.CreateConnection())
{
var usuarios = await connection.QueryAsync<string>(query, new { SearchTerm = $"%{termino}%" });
return Ok(usuarios);
}
}
}
}

View File

@@ -0,0 +1,8 @@
namespace Inventario.API.DTOs
{
public class AsociacionUsuarioDto
{
// Usamos 'required' para asegurar que este campo nunca sea nulo al recibirlo.
public required string Username { get; set; }
}
}

25
backend/Dockerfile Normal file
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

@@ -0,0 +1,38 @@
// backend/Helpers/HistorialHelper.cs
using Dapper;
using Inventario.API.Data;
namespace Inventario.API.Helpers
{
public static class HistorialHelper
{
public static async Task RegistrarCambios(DapperContext context, int equipoId, Dictionary<string, (string? anterior, string? nuevo)> cambios)
{
var query = @"INSERT INTO dbo.historial_equipos (equipo_id, campo_modificado, valor_anterior, valor_nuevo)
VALUES (@EquipoId, @CampoModificado, @ValorAnterior, @ValorNuevo);";
using (var connection = context.CreateConnection())
{
foreach (var cambio in cambios)
{
await connection.ExecuteAsync(query, new
{
EquipoId = equipoId,
CampoModificado = cambio.Key,
ValorAnterior = cambio.Value.anterior,
ValorNuevo = cambio.Value.nuevo
});
}
}
}
public static async Task RegistrarCambioUnico(DapperContext context, int equipoId, string campo, string? valorAnterior, string? valorNuevo)
{
var cambio = new Dictionary<string, (string?, string?)>
{
{ campo, (valorAnterior, valorNuevo) }
};
await RegistrarCambios(context, equipoId, cambio);
}
}
}

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">
@@ -15,6 +16,7 @@
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.9" />
<PackageReference Include="SSH.NET" Version="2025.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
</ItemGroup>

View File

@@ -1,3 +1,4 @@
// backend/Models/Equipo.cs
namespace Inventario.API.Models
{
public class Equipo
@@ -5,22 +6,40 @@ namespace Inventario.API.Models
public int Id { get; set; }
public string Hostname { get; set; } = string.Empty;
public string Ip { get; set; } = string.Empty;
public string? Mac { get; set; } // Mac puede ser nulo, así que usamos string?
public string? Mac { get; set; }
public string Motherboard { get; set; } = string.Empty;
public string Cpu { get; set; } = string.Empty;
public int Ram_installed { get; set; }
public int? Ram_slots { get; set; } // Puede ser nulo
public int? Ram_slots { get; set; }
public string Os { get; set; } = string.Empty;
public string Architecture { get; set; } = string.Empty;
public DateTime Created_at { get; set; }
public DateTime Updated_at { get; set; }
public int? Sector_id { get; set; } // Puede ser nulo
public int? Sector_id { get; set; }
public string Origen { get; set; } = "automatica";
// Propiedades de navegación (no mapeadas directamente a la BD)
public Sector? Sector { get; set; }
public List<Usuario> Usuarios { get; set; } = new();
public List<Disco> Discos { get; set; } = new();
public List<MemoriaRamDetalle> MemoriasRam { get; set; } = new();
public List<UsuarioEquipoDetalle> Usuarios { get; set; } = new();
public List<DiscoDetalle> Discos { get; set; } = new();
public List<MemoriaRamEquipoDetalle> MemoriasRam { get; set; } = new();
public List<HistorialEquipo> Historial { get; set; } = new();
}
public class DiscoDetalle : Disco
{
public string Origen { get; set; } = "manual";
public int EquipoDiscoId { get; set; }
}
public class MemoriaRamEquipoDetalle : MemoriaRam
{
public string Slot { get; set; } = string.Empty;
public string Origen { get; set; } = "manual";
public int EquipoMemoriaRamId { get; set; }
}
public class UsuarioEquipoDetalle : Usuario
{
public string Origen { get; set; } = "manual";
}
}

View File

@@ -1,18 +1,12 @@
// Este modelo representa la tabla memorias_ram
// backend/Models/MemoriaRam.cs
namespace Inventario.API.Models
{
public class MemoriaRam
{
public int Id { get; set; }
public string? Part_number { get; set; }
public string? PartNumber { get; set; }
public string? Fabricante { get; set; }
public int Tamano { get; set; }
public int? Velocidad { get; set; }
}
// Este es un modelo combinado para devolver la información completa al frontend
public class MemoriaRamDetalle : MemoriaRam
{
public string Slot { get; set; } = string.Empty;
}
}

View File

@@ -1,11 +1,84 @@
// backend/Program.cs
using Inventario.API.Data;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
using Microsoft.OpenApi.Models;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!))
};
});
// Add services to the container.
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// CONFIGURACIÓN DE SWAGGER
builder.Services.AddSwaggerGen(options =>
{
// 1. Definir el esquema de seguridad (JWT Bearer)
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Description = "Autenticación JWT usando el esquema Bearer. " +
"Introduce 'Bearer' [espacio] y luego tu token en el campo de abajo. " +
"Ejemplo: 'Bearer 12345abcdef'",
Name = "Authorization", // El nombre del header
In = ParameterLocation.Header, // Dónde se envía (en la cabecera)
Type = SecuritySchemeType.ApiKey, // Tipo de esquema
Scheme = "Bearer"
});
// 2. Aplicar el requisito de seguridad globalmente a todos los endpoints
options.AddSecurityRequirement(new OpenApiSecurityRequirement()
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer" // Debe coincidir con el Id de AddSecurityDefinition
},
Scheme = "oauth2",
Name = "Bearer",
In = ParameterLocation.Header,
},
new List<string>()
}
});
});
// --- DEFINIR LA POLÍTICA CORS ---
// Definimos un nombre para nuestra política
var MyAllowSpecificOrigins = "_myAllowSpecificOrigins";
// Añadimos el servicio de CORS y configuramos la política
builder.Services.AddCors(options =>
{
options.AddPolicy(name: MyAllowSpecificOrigins,
policy =>
{
// Permitimos explícitamente el origen de tu frontend (Vite)
policy.WithOrigins("http://localhost:5173")
.AllowAnyHeader() // Permitir cualquier encabezado
.AllowAnyMethod(); // Permitir GET, POST, PUT, DELETE, etc.
});
});
// -----------------------------------
builder.Services.AddSingleton<DapperContext>();
var app = builder.Build();
@@ -22,5 +95,15 @@ if (app.Environment.IsDevelopment())
}
app.UseHttpsRedirection();
// --- ACTIVAR EL MIDDLEWARE DE CORS ---
// ¡IMPORTANTE! Debe ir ANTES de MapControllers y DESPUÉS de UseHttpsRedirection (si se usa)
app.UseCors(MyAllowSpecificOrigins);
// ----------------------------------------
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();

View File

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

View File

@@ -6,7 +6,22 @@
}
},
"AllowedHosts": "*",
"AuthSettings": {
"Username": "admin",
"Password": "PTP847Equipos"
},
"Jwt": {
"Key": "badb1a38d221c9e23bcf70958840ca7f5a5dc54f2047dadf7ce45b578b5bc3e2",
"Issuer": "InventarioAPI",
"Audience": "InventarioClient"
},
"ConnectionStrings": {
"DefaultConnection": "Server=TECNICA3;Database=InventarioDB;User Id=apiequipos;Password=@Apiequipos513@;TrustServerCertificate=True"
"DefaultConnection": "Server=db-sqlserver;Database=InventarioDB;User Id=apiequipos;Password=@Apiequipos513@;TrustServerCertificate=True"
},
"SshSettings": {
"Host": "192.168.10.1",
"Port": 22110,
"User": "root",
"Password": "PTP.847eld23"
}
}

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+80eeac45d8b90ed69a6479e9d3dcadde5e317a90")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+acf2f9a35c8a559db55e21ce6dd2066c30a01669")]
[assembly: System.Reflection.AssemblyProductAttribute("Inventario.API")]
[assembly: System.Reflection.AssemblyTitleAttribute("Inventario.API")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

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, )"
@@ -72,6 +76,10 @@
"target": "Package",
"version": "[9.0.9, )"
},
"SSH.NET": {
"target": "Package",
"version": "[2025.0.0, )"
},
"Swashbuckle.AspNetCore": {
"target": "Package",
"version": "[9.0.6, )"

View File

@@ -39,6 +39,19 @@
}
}
},
"BouncyCastle.Cryptography/2.5.1": {
"type": "package",
"compile": {
"lib/net6.0/BouncyCastle.Cryptography.dll": {
"related": ".xml"
}
},
"runtime": {
"lib/net6.0/BouncyCastle.Cryptography.dll": {
"related": ".xml"
}
}
},
"Dapper/2.1.66": {
"type": "package",
"compile": {
@@ -65,6 +78,25 @@
}
}
},
"Microsoft.AspNetCore.Authentication.JwtBearer/9.0.9": {
"type": "package",
"dependencies": {
"Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.0.1"
},
"compile": {
"lib/net9.0/Microsoft.AspNetCore.Authentication.JwtBearer.dll": {
"related": ".xml"
}
},
"runtime": {
"lib/net9.0/Microsoft.AspNetCore.Authentication.JwtBearer.dll": {
"related": ".xml"
}
},
"frameworkReferences": [
"Microsoft.AspNetCore.App"
]
},
"Microsoft.AspNetCore.OpenApi/9.0.5": {
"type": "package",
"dependencies": {
@@ -884,96 +916,96 @@
}
}
},
"Microsoft.IdentityModel.Abstractions/7.7.1": {
"Microsoft.IdentityModel.Abstractions/8.0.1": {
"type": "package",
"compile": {
"lib/net8.0/Microsoft.IdentityModel.Abstractions.dll": {
"lib/net9.0/Microsoft.IdentityModel.Abstractions.dll": {
"related": ".xml"
}
},
"runtime": {
"lib/net8.0/Microsoft.IdentityModel.Abstractions.dll": {
"lib/net9.0/Microsoft.IdentityModel.Abstractions.dll": {
"related": ".xml"
}
}
},
"Microsoft.IdentityModel.JsonWebTokens/7.7.1": {
"Microsoft.IdentityModel.JsonWebTokens/8.0.1": {
"type": "package",
"dependencies": {
"Microsoft.IdentityModel.Tokens": "7.7.1"
"Microsoft.IdentityModel.Tokens": "8.0.1"
},
"compile": {
"lib/net8.0/Microsoft.IdentityModel.JsonWebTokens.dll": {
"lib/net9.0/Microsoft.IdentityModel.JsonWebTokens.dll": {
"related": ".xml"
}
},
"runtime": {
"lib/net8.0/Microsoft.IdentityModel.JsonWebTokens.dll": {
"lib/net9.0/Microsoft.IdentityModel.JsonWebTokens.dll": {
"related": ".xml"
}
}
},
"Microsoft.IdentityModel.Logging/7.7.1": {
"Microsoft.IdentityModel.Logging/8.0.1": {
"type": "package",
"dependencies": {
"Microsoft.IdentityModel.Abstractions": "7.7.1"
"Microsoft.IdentityModel.Abstractions": "8.0.1"
},
"compile": {
"lib/net8.0/Microsoft.IdentityModel.Logging.dll": {
"lib/net9.0/Microsoft.IdentityModel.Logging.dll": {
"related": ".xml"
}
},
"runtime": {
"lib/net8.0/Microsoft.IdentityModel.Logging.dll": {
"lib/net9.0/Microsoft.IdentityModel.Logging.dll": {
"related": ".xml"
}
}
},
"Microsoft.IdentityModel.Protocols/7.7.1": {
"Microsoft.IdentityModel.Protocols/8.0.1": {
"type": "package",
"dependencies": {
"Microsoft.IdentityModel.Tokens": "7.7.1"
"Microsoft.IdentityModel.Tokens": "8.0.1"
},
"compile": {
"lib/net8.0/Microsoft.IdentityModel.Protocols.dll": {
"lib/net9.0/Microsoft.IdentityModel.Protocols.dll": {
"related": ".xml"
}
},
"runtime": {
"lib/net8.0/Microsoft.IdentityModel.Protocols.dll": {
"lib/net9.0/Microsoft.IdentityModel.Protocols.dll": {
"related": ".xml"
}
}
},
"Microsoft.IdentityModel.Protocols.OpenIdConnect/7.7.1": {
"Microsoft.IdentityModel.Protocols.OpenIdConnect/8.0.1": {
"type": "package",
"dependencies": {
"Microsoft.IdentityModel.Protocols": "7.7.1",
"System.IdentityModel.Tokens.Jwt": "7.7.1"
"Microsoft.IdentityModel.Protocols": "8.0.1",
"System.IdentityModel.Tokens.Jwt": "8.0.1"
},
"compile": {
"lib/net8.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll": {
"lib/net9.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll": {
"related": ".xml"
}
},
"runtime": {
"lib/net8.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll": {
"lib/net9.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll": {
"related": ".xml"
}
}
},
"Microsoft.IdentityModel.Tokens/7.7.1": {
"Microsoft.IdentityModel.Tokens/8.0.1": {
"type": "package",
"dependencies": {
"Microsoft.IdentityModel.Logging": "7.7.1"
"Microsoft.IdentityModel.Logging": "8.0.1"
},
"compile": {
"lib/net8.0/Microsoft.IdentityModel.Tokens.dll": {
"lib/net9.0/Microsoft.IdentityModel.Tokens.dll": {
"related": ".xml"
}
},
"runtime": {
"lib/net8.0/Microsoft.IdentityModel.Tokens.dll": {
"lib/net9.0/Microsoft.IdentityModel.Tokens.dll": {
"related": ".xml"
}
}
@@ -1019,6 +1051,23 @@
"buildTransitive/Mono.TextTemplating.targets": {}
}
},
"SSH.NET/2025.0.0": {
"type": "package",
"dependencies": {
"BouncyCastle.Cryptography": "2.5.1",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
},
"compile": {
"lib/net9.0/Renci.SshNet.dll": {
"related": ".xml"
}
},
"runtime": {
"lib/net9.0/Renci.SshNet.dll": {
"related": ".xml"
}
}
},
"Swashbuckle.AspNetCore/9.0.6": {
"type": "package",
"dependencies": {
@@ -1325,19 +1374,19 @@
"buildTransitive/net8.0/_._": {}
}
},
"System.IdentityModel.Tokens.Jwt/7.7.1": {
"System.IdentityModel.Tokens.Jwt/8.0.1": {
"type": "package",
"dependencies": {
"Microsoft.IdentityModel.JsonWebTokens": "7.7.1",
"Microsoft.IdentityModel.Tokens": "7.7.1"
"Microsoft.IdentityModel.JsonWebTokens": "8.0.1",
"Microsoft.IdentityModel.Tokens": "8.0.1"
},
"compile": {
"lib/net8.0/System.IdentityModel.Tokens.Jwt.dll": {
"lib/net9.0/System.IdentityModel.Tokens.Jwt.dll": {
"related": ".xml"
}
},
"runtime": {
"lib/net8.0/System.IdentityModel.Tokens.Jwt.dll": {
"lib/net9.0/System.IdentityModel.Tokens.Jwt.dll": {
"related": ".xml"
}
}
@@ -1531,6 +1580,26 @@
"lib/netstandard2.0/Azure.Identity.xml"
]
},
"BouncyCastle.Cryptography/2.5.1": {
"sha512": "zy8TMeTP+1FH2NrLaNZtdRbBdq7u5MI+NFZQOBSM69u5RFkciinwzV2eveY6Kjf5MzgsYvvl6kTStsj3JrXqkg==",
"type": "package",
"path": "bouncycastle.cryptography/2.5.1",
"files": [
".nupkg.metadata",
".signature.p7s",
"LICENSE.md",
"README.md",
"bouncycastle.cryptography.2.5.1.nupkg.sha512",
"bouncycastle.cryptography.nuspec",
"lib/net461/BouncyCastle.Cryptography.dll",
"lib/net461/BouncyCastle.Cryptography.xml",
"lib/net6.0/BouncyCastle.Cryptography.dll",
"lib/net6.0/BouncyCastle.Cryptography.xml",
"lib/netstandard2.0/BouncyCastle.Cryptography.dll",
"lib/netstandard2.0/BouncyCastle.Cryptography.xml",
"packageIcon.png"
]
},
"Dapper/2.1.66": {
"sha512": "/q77jUgDOS+bzkmk3Vy9SiWMaetTw+NOoPAV0xPBsGVAyljd5S6P+4RUW7R3ZUGGr9lDRyPKgAMj2UAOwvqZYw==",
"type": "package",
@@ -1568,6 +1637,22 @@
"logo.png"
]
},
"Microsoft.AspNetCore.Authentication.JwtBearer/9.0.9": {
"sha512": "U5gW2DS/yAE9X0Ko63/O2lNApAzI/jhx4IT1Th6W0RShKv6XAVVgLGN3zqnmcd6DtAnp5FYs+4HZrxsTl0anLA==",
"type": "package",
"path": "microsoft.aspnetcore.authentication.jwtbearer/9.0.9",
"files": [
".nupkg.metadata",
".signature.p7s",
"Icon.png",
"PACKAGE.md",
"THIRD-PARTY-NOTICES.TXT",
"lib/net9.0/Microsoft.AspNetCore.Authentication.JwtBearer.dll",
"lib/net9.0/Microsoft.AspNetCore.Authentication.JwtBearer.xml",
"microsoft.aspnetcore.authentication.jwtbearer.9.0.9.nupkg.sha512",
"microsoft.aspnetcore.authentication.jwtbearer.nuspec"
]
},
"Microsoft.AspNetCore.OpenApi/9.0.5": {
"sha512": "yZLOciYlpaOO/mHPOpgeSZTv8Lc7fOOVX40eWJJoGs/S9Ny9CymDuKKQofGE9stXGGM9EEnnuPeq0fhR8kdFfg==",
"type": "package",
@@ -3419,15 +3504,13 @@
"microsoft.identity.client.extensions.msal.nuspec"
]
},
"Microsoft.IdentityModel.Abstractions/7.7.1": {
"sha512": "S7sHg6gLg7oFqNGLwN1qSbJDI+QcRRj8SuJ1jHyCmKSipnF6ZQL+tFV2NzVfGj/xmGT9TykQdQiBN+p5Idl4TA==",
"Microsoft.IdentityModel.Abstractions/8.0.1": {
"sha512": "OtlIWcyX01olfdevPKZdIPfBEvbcioDyBiE/Z2lHsopsMD7twcKtlN9kMevHmI5IIPhFpfwCIiR6qHQz1WHUIw==",
"type": "package",
"path": "microsoft.identitymodel.abstractions/7.7.1",
"path": "microsoft.identitymodel.abstractions/8.0.1",
"files": [
".nupkg.metadata",
".signature.p7s",
"lib/net461/Microsoft.IdentityModel.Abstractions.dll",
"lib/net461/Microsoft.IdentityModel.Abstractions.xml",
"lib/net462/Microsoft.IdentityModel.Abstractions.dll",
"lib/net462/Microsoft.IdentityModel.Abstractions.xml",
"lib/net472/Microsoft.IdentityModel.Abstractions.dll",
@@ -3436,21 +3519,21 @@
"lib/net6.0/Microsoft.IdentityModel.Abstractions.xml",
"lib/net8.0/Microsoft.IdentityModel.Abstractions.dll",
"lib/net8.0/Microsoft.IdentityModel.Abstractions.xml",
"lib/net9.0/Microsoft.IdentityModel.Abstractions.dll",
"lib/net9.0/Microsoft.IdentityModel.Abstractions.xml",
"lib/netstandard2.0/Microsoft.IdentityModel.Abstractions.dll",
"lib/netstandard2.0/Microsoft.IdentityModel.Abstractions.xml",
"microsoft.identitymodel.abstractions.7.7.1.nupkg.sha512",
"microsoft.identitymodel.abstractions.8.0.1.nupkg.sha512",
"microsoft.identitymodel.abstractions.nuspec"
]
},
"Microsoft.IdentityModel.JsonWebTokens/7.7.1": {
"sha512": "3Izi75UCUssvo8LPx3OVnEeZay58qaFicrtSnbtUt7q8qQi0gy46gh4V8VUTkMVMKXV6VMyjBVmeNNgeCUJuIw==",
"Microsoft.IdentityModel.JsonWebTokens/8.0.1": {
"sha512": "s6++gF9x0rQApQzOBbSyp4jUaAlwm+DroKfL8gdOHxs83k8SJfUXhuc46rDB3rNXBQ1MVRxqKUrqFhO/M0E97g==",
"type": "package",
"path": "microsoft.identitymodel.jsonwebtokens/7.7.1",
"path": "microsoft.identitymodel.jsonwebtokens/8.0.1",
"files": [
".nupkg.metadata",
".signature.p7s",
"lib/net461/Microsoft.IdentityModel.JsonWebTokens.dll",
"lib/net461/Microsoft.IdentityModel.JsonWebTokens.xml",
"lib/net462/Microsoft.IdentityModel.JsonWebTokens.dll",
"lib/net462/Microsoft.IdentityModel.JsonWebTokens.xml",
"lib/net472/Microsoft.IdentityModel.JsonWebTokens.dll",
@@ -3459,21 +3542,21 @@
"lib/net6.0/Microsoft.IdentityModel.JsonWebTokens.xml",
"lib/net8.0/Microsoft.IdentityModel.JsonWebTokens.dll",
"lib/net8.0/Microsoft.IdentityModel.JsonWebTokens.xml",
"lib/net9.0/Microsoft.IdentityModel.JsonWebTokens.dll",
"lib/net9.0/Microsoft.IdentityModel.JsonWebTokens.xml",
"lib/netstandard2.0/Microsoft.IdentityModel.JsonWebTokens.dll",
"lib/netstandard2.0/Microsoft.IdentityModel.JsonWebTokens.xml",
"microsoft.identitymodel.jsonwebtokens.7.7.1.nupkg.sha512",
"microsoft.identitymodel.jsonwebtokens.8.0.1.nupkg.sha512",
"microsoft.identitymodel.jsonwebtokens.nuspec"
]
},
"Microsoft.IdentityModel.Logging/7.7.1": {
"sha512": "BZNgSq/o8gsKExdYoBKPR65fdsxW0cTF8PsdqB8y011AGUJJW300S/ZIsEUD0+sOmGc003Gwv3FYbjrVjvsLNQ==",
"Microsoft.IdentityModel.Logging/8.0.1": {
"sha512": "UCPF2exZqBXe7v/6sGNiM6zCQOUXXQ9+v5VTb9gPB8ZSUPnX53BxlN78v2jsbIvK9Dq4GovQxo23x8JgWvm/Qg==",
"type": "package",
"path": "microsoft.identitymodel.logging/7.7.1",
"path": "microsoft.identitymodel.logging/8.0.1",
"files": [
".nupkg.metadata",
".signature.p7s",
"lib/net461/Microsoft.IdentityModel.Logging.dll",
"lib/net461/Microsoft.IdentityModel.Logging.xml",
"lib/net462/Microsoft.IdentityModel.Logging.dll",
"lib/net462/Microsoft.IdentityModel.Logging.xml",
"lib/net472/Microsoft.IdentityModel.Logging.dll",
@@ -3482,21 +3565,21 @@
"lib/net6.0/Microsoft.IdentityModel.Logging.xml",
"lib/net8.0/Microsoft.IdentityModel.Logging.dll",
"lib/net8.0/Microsoft.IdentityModel.Logging.xml",
"lib/net9.0/Microsoft.IdentityModel.Logging.dll",
"lib/net9.0/Microsoft.IdentityModel.Logging.xml",
"lib/netstandard2.0/Microsoft.IdentityModel.Logging.dll",
"lib/netstandard2.0/Microsoft.IdentityModel.Logging.xml",
"microsoft.identitymodel.logging.7.7.1.nupkg.sha512",
"microsoft.identitymodel.logging.8.0.1.nupkg.sha512",
"microsoft.identitymodel.logging.nuspec"
]
},
"Microsoft.IdentityModel.Protocols/7.7.1": {
"sha512": "h+fHHBGokepmCX+QZXJk4Ij8OApCb2n2ktoDkNX5CXteXsOxTHMNgjPGpAwdJMFvAL7TtGarUnk3o97NmBq2QQ==",
"Microsoft.IdentityModel.Protocols/8.0.1": {
"sha512": "uA2vpKqU3I2mBBEaeJAWPTjT9v1TZrGWKdgK6G5qJd03CLx83kdiqO9cmiK8/n1erkHzFBwU/RphP83aAe3i3g==",
"type": "package",
"path": "microsoft.identitymodel.protocols/7.7.1",
"path": "microsoft.identitymodel.protocols/8.0.1",
"files": [
".nupkg.metadata",
".signature.p7s",
"lib/net461/Microsoft.IdentityModel.Protocols.dll",
"lib/net461/Microsoft.IdentityModel.Protocols.xml",
"lib/net462/Microsoft.IdentityModel.Protocols.dll",
"lib/net462/Microsoft.IdentityModel.Protocols.xml",
"lib/net472/Microsoft.IdentityModel.Protocols.dll",
@@ -3505,21 +3588,21 @@
"lib/net6.0/Microsoft.IdentityModel.Protocols.xml",
"lib/net8.0/Microsoft.IdentityModel.Protocols.dll",
"lib/net8.0/Microsoft.IdentityModel.Protocols.xml",
"lib/net9.0/Microsoft.IdentityModel.Protocols.dll",
"lib/net9.0/Microsoft.IdentityModel.Protocols.xml",
"lib/netstandard2.0/Microsoft.IdentityModel.Protocols.dll",
"lib/netstandard2.0/Microsoft.IdentityModel.Protocols.xml",
"microsoft.identitymodel.protocols.7.7.1.nupkg.sha512",
"microsoft.identitymodel.protocols.8.0.1.nupkg.sha512",
"microsoft.identitymodel.protocols.nuspec"
]
},
"Microsoft.IdentityModel.Protocols.OpenIdConnect/7.7.1": {
"sha512": "yT2Hdj8LpPbcT9C9KlLVxXl09C8zjFaVSaApdOwuecMuoV4s6Sof/mnTDz/+F/lILPIBvrWugR9CC7iRVZgbfQ==",
"Microsoft.IdentityModel.Protocols.OpenIdConnect/8.0.1": {
"sha512": "AQDbfpL+yzuuGhO/mQhKNsp44pm5Jv8/BI4KiFXR7beVGZoSH35zMV3PrmcfvSTsyI6qrcR898NzUauD6SRigg==",
"type": "package",
"path": "microsoft.identitymodel.protocols.openidconnect/7.7.1",
"path": "microsoft.identitymodel.protocols.openidconnect/8.0.1",
"files": [
".nupkg.metadata",
".signature.p7s",
"lib/net461/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll",
"lib/net461/Microsoft.IdentityModel.Protocols.OpenIdConnect.xml",
"lib/net462/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll",
"lib/net462/Microsoft.IdentityModel.Protocols.OpenIdConnect.xml",
"lib/net472/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll",
@@ -3528,21 +3611,21 @@
"lib/net6.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.xml",
"lib/net8.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll",
"lib/net8.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.xml",
"lib/net9.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll",
"lib/net9.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.xml",
"lib/netstandard2.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll",
"lib/netstandard2.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.xml",
"microsoft.identitymodel.protocols.openidconnect.7.7.1.nupkg.sha512",
"microsoft.identitymodel.protocols.openidconnect.8.0.1.nupkg.sha512",
"microsoft.identitymodel.protocols.openidconnect.nuspec"
]
},
"Microsoft.IdentityModel.Tokens/7.7.1": {
"sha512": "fQ0VVCba75lknUHGldi3iTKAYUQqbzp1Un8+d9cm9nON0Gs8NAkXddNg8iaUB0qi/ybtAmNWizTR4avdkCJ9pQ==",
"Microsoft.IdentityModel.Tokens/8.0.1": {
"sha512": "kDimB6Dkd3nkW2oZPDkMkVHfQt3IDqO5gL0oa8WVy3OP4uE8Ij+8TXnqg9TOd9ufjsY3IDiGz7pCUbnfL18tjg==",
"type": "package",
"path": "microsoft.identitymodel.tokens/7.7.1",
"path": "microsoft.identitymodel.tokens/8.0.1",
"files": [
".nupkg.metadata",
".signature.p7s",
"lib/net461/Microsoft.IdentityModel.Tokens.dll",
"lib/net461/Microsoft.IdentityModel.Tokens.xml",
"lib/net462/Microsoft.IdentityModel.Tokens.dll",
"lib/net462/Microsoft.IdentityModel.Tokens.xml",
"lib/net472/Microsoft.IdentityModel.Tokens.dll",
@@ -3551,9 +3634,11 @@
"lib/net6.0/Microsoft.IdentityModel.Tokens.xml",
"lib/net8.0/Microsoft.IdentityModel.Tokens.dll",
"lib/net8.0/Microsoft.IdentityModel.Tokens.xml",
"lib/net9.0/Microsoft.IdentityModel.Tokens.dll",
"lib/net9.0/Microsoft.IdentityModel.Tokens.xml",
"lib/netstandard2.0/Microsoft.IdentityModel.Tokens.dll",
"lib/netstandard2.0/Microsoft.IdentityModel.Tokens.xml",
"microsoft.identitymodel.tokens.7.7.1.nupkg.sha512",
"microsoft.identitymodel.tokens.8.0.1.nupkg.sha512",
"microsoft.identitymodel.tokens.nuspec"
]
},
@@ -3607,6 +3692,29 @@
"readme.md"
]
},
"SSH.NET/2025.0.0": {
"sha512": "AKYbB+q2zFkNQbBFx5gXdv+Wje0baBtADQ35WnMKi4bg1ka74wTQtWoPd+fOWcydohdfsD0nfT8ErMOAPxtSfA==",
"type": "package",
"path": "ssh.net/2025.0.0",
"files": [
".nupkg.metadata",
".signature.p7s",
"README.md",
"SS-NET-icon-h500.png",
"lib/net462/Renci.SshNet.dll",
"lib/net462/Renci.SshNet.xml",
"lib/net8.0/Renci.SshNet.dll",
"lib/net8.0/Renci.SshNet.xml",
"lib/net9.0/Renci.SshNet.dll",
"lib/net9.0/Renci.SshNet.xml",
"lib/netstandard2.0/Renci.SshNet.dll",
"lib/netstandard2.0/Renci.SshNet.xml",
"lib/netstandard2.1/Renci.SshNet.dll",
"lib/netstandard2.1/Renci.SshNet.xml",
"ssh.net.2025.0.0.nupkg.sha512",
"ssh.net.nuspec"
]
},
"Swashbuckle.AspNetCore/9.0.6": {
"sha512": "q/UfEAgrk6qQyjHXgsW9ILw0YZLfmPtWUY4wYijliX6supozC+TkzU0G6FTnn/dPYxnChjM8g8lHjWHF6VKy+A==",
"type": "package",
@@ -4017,15 +4125,13 @@
"useSharedDesignerContext.txt"
]
},
"System.IdentityModel.Tokens.Jwt/7.7.1": {
"sha512": "rQkO1YbAjLwnDJSMpRhRtrc6XwIcEOcUvoEcge+evurpzSZM3UNK+MZfD3sKyTlYsvknZ6eJjSBfnmXqwOsT9Q==",
"System.IdentityModel.Tokens.Jwt/8.0.1": {
"sha512": "GJw3bYkWpOgvN3tJo5X4lYUeIFA2HD293FPUhKmp7qxS+g5ywAb34Dnd3cDAFLkcMohy5XTpoaZ4uAHuw0uSPQ==",
"type": "package",
"path": "system.identitymodel.tokens.jwt/7.7.1",
"path": "system.identitymodel.tokens.jwt/8.0.1",
"files": [
".nupkg.metadata",
".signature.p7s",
"lib/net461/System.IdentityModel.Tokens.Jwt.dll",
"lib/net461/System.IdentityModel.Tokens.Jwt.xml",
"lib/net462/System.IdentityModel.Tokens.Jwt.dll",
"lib/net462/System.IdentityModel.Tokens.Jwt.xml",
"lib/net472/System.IdentityModel.Tokens.Jwt.dll",
@@ -4034,9 +4140,11 @@
"lib/net6.0/System.IdentityModel.Tokens.Jwt.xml",
"lib/net8.0/System.IdentityModel.Tokens.Jwt.dll",
"lib/net8.0/System.IdentityModel.Tokens.Jwt.xml",
"lib/net9.0/System.IdentityModel.Tokens.Jwt.dll",
"lib/net9.0/System.IdentityModel.Tokens.Jwt.xml",
"lib/netstandard2.0/System.IdentityModel.Tokens.Jwt.dll",
"lib/netstandard2.0/System.IdentityModel.Tokens.Jwt.xml",
"system.identitymodel.tokens.jwt.7.7.1.nupkg.sha512",
"system.identitymodel.tokens.jwt.8.0.1.nupkg.sha512",
"system.identitymodel.tokens.jwt.nuspec"
]
},
@@ -4344,10 +4452,12 @@
"projectFileDependencyGroups": {
"net9.0": [
"Dapper >= 2.1.66",
"Microsoft.AspNetCore.Authentication.JwtBearer >= 9.0.9",
"Microsoft.AspNetCore.OpenApi >= 9.0.5",
"Microsoft.Data.SqlClient >= 6.1.1",
"Microsoft.EntityFrameworkCore.Design >= 9.0.9",
"Microsoft.EntityFrameworkCore.SqlServer >= 9.0.9",
"SSH.NET >= 2025.0.0",
"Swashbuckle.AspNetCore >= 9.0.6"
]
},
@@ -4405,6 +4515,10 @@
"target": "Package",
"version": "[2.1.66, )"
},
"Microsoft.AspNetCore.Authentication.JwtBearer": {
"target": "Package",
"version": "[9.0.9, )"
},
"Microsoft.AspNetCore.OpenApi": {
"target": "Package",
"version": "[9.0.5, )"
@@ -4423,6 +4537,10 @@
"target": "Package",
"version": "[9.0.9, )"
},
"SSH.NET": {
"target": "Package",
"version": "[2025.0.0, )"
},
"Swashbuckle.AspNetCore": {
"target": "Package",
"version": "[9.0.6, )"

46
docker-compose.yml Normal file
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

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

19
frontend/Dockerfile Normal file
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;"]

73
frontend/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

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

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Equipos</title>
<link rel="icon" type="image/x-icon" href="/eldia.svg">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3569
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
frontend/package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-table": "^8.21.3",
"chart.js": "^4.5.0",
"lucide-react": "^0.545.0",
"react": "^19.1.1",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.1.1",
"react-hot-toast": "^2.6.0",
"react-tooltip": "^5.29.1"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@types/node": "^24.6.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.4",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "^7.1.7"
}
}

21
frontend/proxy/nginx.conf Normal file
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

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="89" height="69">
<path d="M0 0 C29.37 0 58.74 0 89 0 C89 22.77 89 45.54 89 69 C59.63 69 30.26 69 0 69 C0 46.23 0 23.46 0 0 Z " fill="#008FBD" transform="translate(0,0)"/>
<path d="M0 0 C3.3 0 6.6 0 10 0 C13.04822999 3.04822999 12.29337257 6.08805307 12.32226562 10.25390625 C12.31904297 11.32511719 12.31582031 12.39632812 12.3125 13.5 C12.32861328 14.57121094 12.34472656 15.64242187 12.36132812 16.74609375 C12.36197266 17.76832031 12.36261719 18.79054688 12.36328125 19.84375 C12.36775269 21.25430664 12.36775269 21.25430664 12.37231445 22.69335938 C12.1880188 23.83514648 12.1880188 23.83514648 12 25 C9 27 9 27 0 27 C0 18.09 0 9.18 0 0 Z " fill="#000000" transform="translate(0,21)"/>
<path d="M0 0 C5.61 0 11.22 0 17 0 C17 3.3 17 6.6 17 10 C25.58 10 34.16 10 43 10 C43 10.99 43 11.98 43 13 C34.42 13 25.84 13 17 13 C17 17.29 17 21.58 17 26 C11.39 26 5.78 26 0 26 C0 24.68 0 23.36 0 22 C4.62 22 9.24 22 14 22 C14 16.06 14 10.12 14 4 C9.38 4 4.76 4 0 4 C0 2.68 0 1.36 0 0 Z " fill="#000000" transform="translate(46,21)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
frontend/public/power.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

47
frontend/src/App.css Normal file
View File

@@ -0,0 +1,47 @@
main {
padding: 0rem 2rem;
max-width: 1600px;
margin: 0 auto;
}
.navbar {
background-color: var(--color-navbar-bg);
padding: 0 2rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--color-border); /* Borde sutil */
}
.nav-links {
display: flex;
}
.nav-link {
background: none;
border: none;
color: var(--color-navbar-text);
padding: 1rem 1.5rem;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
text-decoration: none;
transition: color 0.2s ease-in-out, background-color 0.2s ease-in-out, border-color 0.2s ease-in-out;
border-bottom: 3px solid transparent;
}
.nav-link:hover {
color: var(--color-navbar-text-hover);
}
.nav-link-active {
color: var(--color-navbar-text-hover);
border-bottom: 3px solid var(--color-primary);
}
.app-title {
font-size: 1.5rem;
color: var(--color-navbar-text-hover);
font-weight: bold;
}

40
frontend/src/App.tsx Normal file
View File

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

View File

@@ -0,0 +1,75 @@
import React, { useState, useEffect } from 'react';
// --- Interfaces de Props más robustas usando una unión discriminada ---
type AutocompleteInputProps = {
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
name: string;
placeholder?: string;
className?: string;
} & ( // Esto crea una unión: o es estático o es dinámico
| {
mode: 'static';
fetchSuggestions: () => Promise<string[]>; // No necesita 'query'
}
| {
mode: 'dynamic';
fetchSuggestions: (query: string) => Promise<string[]>; // Necesita 'query'
}
);
const AutocompleteInput: React.FC<AutocompleteInputProps> = (props) => {
const { value, onChange, name, placeholder, className } = props;
const [suggestions, setSuggestions] = useState<string[]>([]);
const dataListId = `suggestions-for-${name}`;
// --- Lógica para el modo ESTÁTICO ---
// Se ejecuta UNA SOLA VEZ cuando el componente se monta
useEffect(() => {
if (props.mode === 'static') {
props.fetchSuggestions()
.then(setSuggestions)
.catch(err => console.error(`Error fetching static suggestions for ${name}:`, err));
}
// La lista de dependencias asegura que solo se ejecute si estas props cambian (lo cual no harán)
}, [props.mode, props.fetchSuggestions, name]);
// --- Lógica para el modo DINÁMICO ---
// Se ejecuta cada vez que el usuario escribe, con un debounce
useEffect(() => {
if (props.mode === 'dynamic') {
if (value.length < 2) {
setSuggestions([]);
return;
}
const handler = setTimeout(() => {
props.fetchSuggestions(value)
.then(setSuggestions)
.catch(err => console.error(`Error fetching dynamic suggestions for ${name}:`, err));
}, 300);
return () => clearTimeout(handler);
}
}, [value, props.mode, props.fetchSuggestions, name]);
return (
<>
<input
type="text"
name={name}
value={value}
onChange={onChange}
placeholder={placeholder}
className={className}
list={dataListId}
autoComplete="off"
/>
<datalist id={dataListId}>
{suggestions.map((suggestion, index) => (
<option key={index} value={suggestion} />
))}
</datalist>
</>
);
};
export default AutocompleteInput;

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

@@ -0,0 +1,268 @@
// frontend/src/components/GestionComponentes.tsx
import { useState, useEffect, useMemo, useCallback } from 'react';
import toast from 'react-hot-toast';
import {
useReactTable,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
flexRender,
type SortingState,
} from '@tanstack/react-table';
import { Pencil, Trash2 } from 'lucide-react';
import styles from './SimpleTable.module.css';
import { adminService } from '../services/apiService';
import TableSkeleton from './TableSkeleton';
import { accentInsensitiveFilter } from '../utils/filtering';
// Interfaces
interface TextValue {
valor: string;
conteo: number;
}
interface RamValue {
fabricante?: string;
tamano: number;
velocidad?: number;
conteo: number;
}
type ComponentValue = TextValue | RamValue;
const GestionComponentes = () => {
const [componentType, setComponentType] = useState('os');
const [valores, setValores] = useState<ComponentValue[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [valorAntiguo, setValorAntiguo] = useState('');
const [valorNuevo, setValorNuevo] = useState('');
const [globalFilter, setGlobalFilter] = useState('');
const [sorting, setSorting] = useState<SortingState>([]);
useEffect(() => {
setIsLoading(true);
adminService.getComponentValues(componentType)
.then(data => {
setValores(data);
})
.catch(_err => {
toast.error(`No se pudieron cargar los datos de ${componentType}.`);
})
.finally(() => setIsLoading(false));
}, [componentType]);
const handleOpenModal = useCallback((valor: string) => {
setValorAntiguo(valor);
setValorNuevo(valor);
setIsModalOpen(true);
}, []);
const handleUnificar = async () => {
const toastId = toast.loading('Unificando valores...');
try {
await adminService.unifyComponentValues(componentType, valorAntiguo, valorNuevo);
const refreshedData = await adminService.getComponentValues(componentType);
setValores(refreshedData);
toast.success('Valores unificados correctamente.', { id: toastId });
setIsModalOpen(false);
} catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
};
const handleDeleteRam = useCallback(async (ramGroup: RamValue) => {
if (!window.confirm("¿Estás seguro de eliminar todas las entradas maestras para este tipo de RAM? Esta acción es irreversible.")) return;
const toastId = toast.loading('Eliminando grupo de módulos...');
try {
await adminService.deleteRamComponent({ fabricante: ramGroup.fabricante, tamano: ramGroup.tamano, velocidad: ramGroup.velocidad });
setValores(prev => prev.filter(v => {
const currentRam = v as RamValue;
return !(currentRam.fabricante === ramGroup.fabricante && currentRam.tamano === ramGroup.tamano && currentRam.velocidad === ramGroup.velocidad);
}));
toast.success("Grupo de módulos de RAM eliminado.", { id: toastId });
} catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
}, []);
const handleDeleteTexto = useCallback(async (valor: string) => {
if (!window.confirm(`Este valor ya no está en uso. ¿Quieres eliminarlo de la base de datos maestra?`)) return;
const toastId = toast.loading('Eliminando valor...');
try {
await adminService.deleteTextComponent(componentType, valor);
setValores(prev => prev.filter(v => (v as TextValue).valor !== valor));
toast.success("Valor eliminado.", { id: toastId });
} catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
}, [componentType]);
const renderValor = useCallback((item: ComponentValue) => {
if (componentType === 'ram') {
const ram = item as RamValue;
return `${ram.fabricante || 'Desconocido'} ${ram.tamano}GB ${ram.velocidad ? ram.velocidad + 'MHz' : ''}`;
}
return (item as TextValue).valor;
}, [componentType]);
const columns = useMemo(() => [
{
header: 'Valor Registrado',
id: 'valor',
accessorFn: (row: ComponentValue) => renderValor(row),
},
{
header: 'Nº de Equipos',
accessorKey: 'conteo',
},
{
header: 'Acciones',
id: 'acciones',
cell: ({ row }: { row: { original: ComponentValue } }) => (
<div style={{ display: 'flex', gap: '5px' }}>
{componentType === 'ram' ? (
<button
onClick={() => handleDeleteRam(row.original as RamValue)}
className={styles.deleteUserButton}
style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.9em', border: '1px solid', borderRadius: '4px', padding: '4px 8px' }}
disabled={row.original.conteo > 0}
title={row.original.conteo > 0 ? `En uso por ${row.original.conteo} equipo(s)` : 'Eliminar este grupo de módulos maestros'}
>
<Trash2 size={14} /> Eliminar
</button>
) : (
<>
<button onClick={() => handleOpenModal((row.original as TextValue).valor)} className={styles.tableButton} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<Pencil size={14} /> Unificar
</button>
<button
onClick={() => handleDeleteTexto((row.original as TextValue).valor)}
className={styles.deleteUserButton}
style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.9em', border: '1px solid', borderRadius: '4px', padding: '4px 8px' }}
disabled={row.original.conteo > 0}
title={row.original.conteo > 0 ? `En uso por ${row.original.conteo} equipo(s). Unifique primero.` : 'Eliminar este valor'}
>
<Trash2 size={14} /> Eliminar
</button>
</>
)}
</div>
)
}
], [componentType, renderValor, handleDeleteRam, handleDeleteTexto, handleOpenModal]);
const table = useReactTable({
data: valores,
columns,
state: { sorting, globalFilter },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
filterFns: {
accentInsensitive: accentInsensitiveFilter,
},
globalFilterFn: 'accentInsensitive',
});
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<h2>Gestión de Componentes Maestros ({table.getFilteredRowModel().rows.length})</h2>
<p>Unifica valores inconsistentes y elimina registros no utilizados.</p>
<p style={{ fontSize: '12px', fontWeight: 'bold' }}>** La tabla permite ordenar por múltiples columnas manteniendo shift al hacer click en la cabecera. **</p>
<div className={styles.controlsContainer}>
<input
type="text"
placeholder="Filtrar registros..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className={styles.searchInput}
style={{ width: '300px' }}
/>
<label><strong>Tipo de componente:</strong></label>
<select value={componentType} onChange={e => setComponentType(e.target.value)} className={styles.sectorSelect}>
<option value="os">Sistema Operativo</option>
<option value="cpu">CPU</option>
<option value="motherboard">Motherboard</option>
<option value="architecture">Arquitectura</option>
<option value="ram">Memorias RAM</option>
</select>
</div>
{isLoading ? (
<div className={styles.tableContainer}>
<TableSkeleton rows={6} columns={3} />
</div>
) : (
<div className={styles.tableContainer}>
<table className={styles.table}>
<thead>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => {
const classNames = [styles.th];
if (header.id === 'conteo') classNames.push(styles.thNumeric);
if (header.id === 'acciones') classNames.push(styles.thActions);
return (
<th
key={header.id}
className={classNames.join(' ')}
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getIsSorted() && (
<span className={styles.sortIndicator}>
{header.column.getIsSorted() === 'asc' ? '⇑' : '⇓'}
</span>
)}
</th>
)
})}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id} className={styles.tr}>
{row.getVisibleCells().map(cell => {
const classNames = [styles.td];
if (cell.column.id === 'conteo') classNames.push(styles.tdNumeric);
if (cell.column.id === 'acciones') classNames.push(styles.tdActions);
return (
<td
key={cell.id}
className={classNames.join(' ')}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
)}
{isModalOpen && (
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<h3>Unificar Valor</h3>
<p>Se reemplazarán todas las instancias de:</p>
<strong className={styles.highlightBox}>{valorAntiguo}</strong>
<label>Por el nuevo valor:</label>
<input type="text" value={valorNuevo} onChange={e => setValorNuevo(e.target.value)} className={styles.modalInput} />
<div className={styles.modalActions}>
<button onClick={handleUnificar} className={`${styles.btn} ${styles.btnPrimary}`} disabled={!valorNuevo.trim() || valorNuevo === valorAntiguo}>Unificar</button>
<button onClick={() => setIsModalOpen(false)} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button>
</div>
</div>
</div>
)}
</div>
);
};
export default GestionComponentes;

View File

@@ -0,0 +1,218 @@
// frontend/src/components/GestionSectores.tsx
import { useState, useEffect, useMemo, useCallback } from 'react';
import toast from 'react-hot-toast';
import {
useReactTable,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
flexRender,
type SortingState,
type ColumnDef,
} from '@tanstack/react-table';
import { PlusCircle, Pencil, Trash2 } from 'lucide-react';
import { accentInsensitiveFilter } from '../utils/filtering';
import type { Sector } from '../types/interfaces';
import styles from './SimpleTable.module.css';
import ModalSector from './ModalSector';
import TableSkeleton from './TableSkeleton'; // <-- 1. Importar el esqueleto
import { sectorService } from '../services/apiService';
const GestionSectores = () => {
const [sectores, setSectores] = useState<Sector[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingSector, setEditingSector] = useState<Sector | null>(null);
// --- 2. Estados para filtro y ordenación ---
const [globalFilter, setGlobalFilter] = useState('');
const [sorting, setSorting] = useState<SortingState>([]);
useEffect(() => {
setIsLoading(true); // Aseguramos que se muestre el esqueleto al cargar
sectorService.getAll()
.then(data => {
// Ordenar alfabéticamente por defecto
const sectoresOrdenados = [...data].sort((a, b) => a.nombre.localeCompare(b.nombre, 'es', { sensitivity: 'base' }));
setSectores(sectoresOrdenados);
})
.catch(err => {
toast.error("No se pudieron cargar los sectores.");
console.error(err);
})
.finally(() => {
setIsLoading(false);
});
}, []);
const handleOpenCreateModal = () => {
setEditingSector(null);
setIsModalOpen(true);
};
const handleOpenEditModal = useCallback((sector: Sector) => {
setEditingSector(sector);
setIsModalOpen(true);
}, []);
const handleSave = async (id: number | null, nombre: string) => {
const isEditing = id !== null;
const toastId = toast.loading(isEditing ? 'Actualizando...' : 'Creando...');
try {
let refreshedData;
if (isEditing) {
await sectorService.update(id, nombre);
refreshedData = await sectorService.getAll();
toast.success('Sector actualizado.', { id: toastId });
} else {
await sectorService.create(nombre);
refreshedData = await sectorService.getAll();
toast.success('Sector creado.', { id: toastId });
}
const sectoresOrdenados = [...refreshedData].sort((a, b) => a.nombre.localeCompare(b.nombre, 'es', { sensitivity: 'base' }));
setSectores(sectoresOrdenados);
setIsModalOpen(false);
} catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
};
const handleDelete = useCallback(async (id: number) => {
if (!window.confirm("¿Estás seguro de eliminar este sector? Los equipos asociados quedarán sin sector.")) {
return;
}
const toastId = toast.loading('Eliminando...');
try {
await sectorService.delete(id);
setSectores(prev => prev.filter(s => s.id !== id));
toast.success("Sector eliminado.", { id: toastId });
} catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
}, []);
// --- 3. Definición de columnas para React Table ---
const columns = useMemo<ColumnDef<Sector>[]>(() => [
{
header: 'Nombre del Sector',
accessorKey: 'nombre',
},
{
header: 'Acciones',
id: 'acciones',
cell: ({ row }) => (
<div style={{ display: 'flex', gap: '10px' }}>
<button onClick={() => handleOpenEditModal(row.original)} className={styles.tableButton} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}><Pencil size={16} /> Editar</button>
<button onClick={() => handleDelete(row.original.id)} className={styles.deleteUserButton} style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '1em', padding: '0.375rem 0.75rem', border: '1px solid', borderRadius: '4px' }}>
<Trash2 size={16} /> Eliminar
</button>
</div>
)
}
], [handleOpenEditModal, handleDelete]);
// --- 4. Instancia de la tabla ---
const table = useReactTable({
data: sectores,
columns,
state: { sorting, globalFilter },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
filterFns: {
accentInsensitive: accentInsensitiveFilter,
},
globalFilterFn: 'accentInsensitive',
});
return (
<div>
<h2>Gestión de Sectores ({table.getFilteredRowModel().rows.length})</h2>
<p>Crea, edita y elimina los sectores de la organización.</p>
<div className={styles.controlsContainer}>
<input
type="text"
placeholder="Filtrar sectores..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className={styles.searchInput}
style={{ width: '300px' }}
/>
<button onClick={handleOpenCreateModal} className={`${styles.btn} ${styles.btnPrimary}`} style={{ display: 'flex', alignItems: 'center', gap: '8px', marginLeft: 'auto' }}>
<PlusCircle size={18} /> Añadir Sector
</button>
</div>
{isLoading ? (
<div className={styles.tableContainer}>
<TableSkeleton rows={6} columns={2} />
</div>
) : (
<div className={styles.tableContainer}>
<table className={styles.table}>
<thead>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => {
const classNames = [styles.th];
if (header.id === 'acciones') {
classNames.push(styles.thActions);
}
return (
<th
key={header.id}
className={classNames.join(' ')}
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getIsSorted() && (
<span className={styles.sortIndicator}>
{header.column.getIsSorted() === 'asc' ? '⇑' : '⇓'}
</span>
)}
</th>
);
})}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id} className={styles.tr}>
{row.getVisibleCells().map(cell => {
const classNames = [styles.td];
if (cell.column.id === 'acciones') {
classNames.push(styles.tdActions);
}
return (
<td key={cell.id} className={classNames.join(' ')}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)}
{isModalOpen && (
<ModalSector
sector={editingSector}
onClose={() => setIsModalOpen(false)}
onSave={handleSave}
/>
)}
</div>
);
};
export default GestionSectores;

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

@@ -0,0 +1,46 @@
// frontend/src/components/ModalAnadirDisco.tsx
import React, { useState } from 'react';
import styles from './SimpleTable.module.css';
interface Props {
onClose: () => void;
onSave: (disco: { mediatype: string, size: number }) => void;
}
const ModalAnadirDisco: React.FC<Props> = ({ onClose, onSave }) => {
const [mediatype, setMediatype] = useState('SSD');
const [size, setSize] = useState('');
const handleSave = () => {
if (size && parseInt(size, 10) > 0) {
onSave({ mediatype, size: parseInt(size, 10) });
}
};
return (
<div className={`${styles.modalOverlay} ${styles['modalOverlay--nested']}`}>
<div className={styles.modal}>
<h3>Añadir Disco Manualmente</h3>
<label>Tipo de Disco</label>
<select value={mediatype} onChange={e => setMediatype(e.target.value)} className={styles.modalInput}>
<option value="SSD">SSD</option>
<option value="HDD">HDD</option>
</select>
<label>Tamaño (GB)</label>
<input
type="number"
value={size}
onChange={e => setSize(e.target.value)}
className={styles.modalInput}
placeholder="Ej: 500"
/>
<div className={styles.modalActions}>
<button onClick={handleSave} className={`${styles.btn} ${styles.btnPrimary}`} disabled={!size}>Guardar</button>
<button onClick={onClose} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button>
</div>
</div>
</div>
);
};
export default ModalAnadirDisco;

View File

@@ -0,0 +1,137 @@
// frontend/src/components/ModalAnadirEquipo.tsx
import React, { useState, useCallback } from 'react'; // <-- 1. Importar useCallback
import type { Sector, Equipo } from '../types/interfaces';
import AutocompleteInput from './AutocompleteInput';
import styles from './SimpleTable.module.css';
interface ModalAnadirEquipoProps {
sectores: Sector[];
onClose: () => void;
onSave: (nuevoEquipo: Omit<Equipo, 'id' | 'created_at' | 'updated_at'>) => void;
}
const BASE_URL = '/api';
const ModalAnadirEquipo: React.FC<ModalAnadirEquipoProps> = ({ sectores, onClose, onSave }) => {
const [nuevoEquipo, setNuevoEquipo] = useState({
hostname: '',
ip: '',
motherboard: '',
cpu: '',
os: '',
sector_id: undefined as number | undefined,
});
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setNuevoEquipo(prev => ({
...prev,
[name]: name === 'sector_id' ? (value ? parseInt(value, 10) : undefined) : value,
}));
};
const handleSaveClick = () => {
onSave(nuevoEquipo as any);
};
const isFormValid = nuevoEquipo.hostname.trim() !== '' && nuevoEquipo.ip.trim() !== '';
// --- 2. Memorizar las funciones con useCallback ---
// El array vacío `[]` al final asegura que la función NUNCA se vuelva a crear.
const fetchMotherboardSuggestions = useCallback(() => fetch(`${BASE_URL}/equipos/distinct/motherboard`).then(res => res.json()), []);
const fetchCpuSuggestions = useCallback(() => fetch(`${BASE_URL}/equipos/distinct/cpu`).then(res => res.json()), []);
const fetchOsSuggestions = useCallback(() => fetch(`${BASE_URL}/equipos/distinct/os`).then(res => res.json()), []);
return (
<div className={styles.modalOverlay}>
<div className={styles.modal} style={{ minWidth: '500px' }}>
<h3>Añadir Nuevo Equipo Manualmente</h3>
<label>Hostname (Requerido)</label>
<input
type="text"
name="hostname"
value={nuevoEquipo.hostname}
onChange={handleChange}
className={styles.modalInput}
placeholder="Ej: TECNICA10"
autoComplete="off"
/>
<label>Dirección IP (Requerido)</label>
<input
type="text"
name="ip"
value={nuevoEquipo.ip}
onChange={handleChange}
className={styles.modalInput}
placeholder="Ej: 192.168.10.50"
autoComplete="off"
/>
<label>Sector</label>
<select
name="sector_id"
value={nuevoEquipo.sector_id || ''}
onChange={handleChange}
className={styles.modalInput}
>
<option value="">- Sin Asignar -</option>
{sectores.map(s => (
<option key={s.id} value={s.id}>{s.nombre}</option>
))}
</select>
{/* --- 3. Usar las funciones memorizadas --- */}
<label>Motherboard (Opcional)</label>
<AutocompleteInput
mode="static"
name="motherboard"
value={nuevoEquipo.motherboard}
onChange={handleChange}
className={styles.modalInput}
fetchSuggestions={fetchMotherboardSuggestions}
/>
<label>CPU (Opcional)</label>
<AutocompleteInput
mode="static"
name="cpu"
value={nuevoEquipo.cpu}
onChange={handleChange}
className={styles.modalInput}
fetchSuggestions={fetchCpuSuggestions}
/>
<label>Sistema Operativo (Opcional)</label>
<AutocompleteInput
mode="static"
name="os"
value={nuevoEquipo.os}
onChange={handleChange}
className={styles.modalInput}
fetchSuggestions={fetchOsSuggestions}
/>
<div className={styles.modalActions}>
<button
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={handleSaveClick}
disabled={!isFormValid}
>
Guardar Equipo
</button>
<button
className={`${styles.btn} ${styles.btnSecondary}`}
onClick={onClose}
>
Cancelar
</button>
</div>
</div>
</div>
);
};
export default ModalAnadirEquipo;

View File

@@ -0,0 +1,107 @@
// frontend/src/components/ModalAnadirRam.tsx
import React, { useState, useCallback, useEffect } from 'react';
import styles from './SimpleTable.module.css';
import AutocompleteInput from './AutocompleteInput';
import { memoriaRamService } from '../services/apiService';
import type { MemoriaRam } from '../types/interfaces';
interface Props {
onClose: () => void;
onSave: (ram: { slot: string, tamano: number, fabricante?: string, velocidad?: number, partNumber?: string }) => void;
}
const ModalAnadirRam: React.FC<Props> = ({ onClose, onSave }) => {
const [ram, setRam] = useState({
slot: '',
tamano: '',
fabricante: '',
velocidad: '',
partNumber: ''
});
const [allRamModules, setAllRamModules] = useState<MemoriaRam[]>([]);
useEffect(() => {
memoriaRamService.getAll()
.then(setAllRamModules)
.catch(err => console.error("No se pudieron cargar los módulos de RAM", err));
}, []);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setRam(prev => ({ ...prev, [name]: value }));
};
const fetchRamSuggestions = useCallback(async () => {
return allRamModules.map(r =>
`${r.fabricante || 'Desconocido'} | ${r.tamano}GB | ${r.velocidad ? r.velocidad + 'MHz' : 'N/A'}`
);
}, [allRamModules]);
useEffect(() => {
const selectedSuggestion = ram.partNumber;
const match = allRamModules.find(s =>
`${s.fabricante || 'Desconocido'} | ${s.tamano}GB | ${s.velocidad ? s.velocidad + 'MHz' : 'N/A'}` === selectedSuggestion
);
if (match) {
setRam(prev => ({
...prev,
fabricante: match.fabricante || '',
tamano: match.tamano.toString(),
velocidad: match.velocidad?.toString() || '',
partNumber: match.partNumber || ''
}));
}
}, [ram.partNumber, allRamModules]);
const handleSave = () => {
onSave({
slot: ram.slot,
tamano: parseInt(ram.tamano, 10),
fabricante: ram.fabricante || undefined,
velocidad: ram.velocidad ? parseInt(ram.velocidad, 10) : undefined,
partNumber: ram.partNumber || undefined,
});
};
return (
<div className={`${styles.modalOverlay} ${styles['modalOverlay--nested']}`}>
<div className={styles.modal}>
<h3>Añadir Módulo de RAM</h3>
<label>Slot (Requerido)</label>
<input type="text" name="slot" value={ram.slot} onChange={handleChange} className={styles.modalInput} placeholder="Ej: DIMM0" />
<label>Buscar Módulo Existente (Opcional)</label>
<AutocompleteInput
mode="static"
name="partNumber"
value={ram.partNumber}
onChange={handleChange}
className={styles.modalInput}
fetchSuggestions={fetchRamSuggestions}
placeholder="Clic para ver todos o escribe para filtrar"
/>
<label>Tamaño (GB) (Requerido)</label>
<input type="number" name="tamano" value={ram.tamano} onChange={handleChange} className={styles.modalInput} placeholder="Ej: 8" />
<label>Fabricante</label>
<input type="text" name="fabricante" value={ram.fabricante} onChange={handleChange} className={styles.modalInput} />
<label>Velocidad (MHz)</label>
<input type="number" name="velocidad" value={ram.velocidad} onChange={handleChange} className={styles.modalInput} />
<div className={styles.modalActions}>
<button onClick={handleSave} className={`${styles.btn} ${styles.btnPrimary}`} disabled={!ram.slot || !ram.tamano}>Guardar</button>
<button onClick={onClose} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button>
</div>
</div>
</div>
);
};
export default ModalAnadirRam;

View File

@@ -0,0 +1,48 @@
// frontend/src/components/ModalAnadirUsuario.tsx
import React, { useState, useCallback } from 'react';
import styles from './SimpleTable.module.css';
import AutocompleteInput from './AutocompleteInput';
import { usuarioService } from '../services/apiService';
interface Props {
onClose: () => void;
onSave: (usuario: { username: string }) => void;
}
const ModalAnadirUsuario: React.FC<Props> = ({ onClose, onSave }) => {
const [username, setUsername] = useState('');
const fetchUserSuggestions = useCallback(async (query: string): Promise<string[]> => {
if (!query) return [];
try {
return await usuarioService.search(query);
} catch (error) {
console.error("Error buscando usuarios", error);
return [];
}
}, []);
return (
<div className={`${styles.modalOverlay} ${styles['modalOverlay--nested']}`}>
<div className={styles.modal}>
<h3>Añadir Usuario Manualmente</h3>
<label>Nombre de Usuario</label>
<AutocompleteInput
mode="dynamic"
name="username"
value={username}
onChange={e => setUsername(e.target.value)}
className={styles.modalInput}
fetchSuggestions={fetchUserSuggestions}
placeholder="Escribe para buscar o crear un nuevo usuario"
/>
<div className={styles.modalActions}>
<button onClick={() => onSave({ username })} className={`${styles.btn} ${styles.btnPrimary}`} disabled={!username.trim()}>Guardar</button>
<button onClick={onClose} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button>
</div>
</div>
</div>
);
};
export default ModalAnadirUsuario;

View File

@@ -0,0 +1,65 @@
// frontend/src/components/ModalCambiarClave.tsx
import React, { useState, useEffect, useRef } from 'react';
import type { Usuario } from '../types/interfaces';
import styles from './SimpleTable.module.css';
interface ModalCambiarClaveProps {
usuario: Usuario; // El componente padre asegura que esto no sea nulo
onClose: () => void;
onSave: (password: string) => void;
}
const ModalCambiarClave: React.FC<ModalCambiarClaveProps> = ({ usuario, onClose, onSave }) => {
const [newPassword, setNewPassword] = useState('');
const passwordInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// Enfocar el input cuando el modal se abre
setTimeout(() => passwordInputRef.current?.focus(), 100);
}, []);
const handleSaveClick = () => {
if (newPassword.trim()) {
onSave(newPassword);
}
};
return (
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<h3>
Cambiar contraseña para {usuario.username}
</h3>
<label>
Nueva contraseña:
<input
type="text"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className={styles.modalInput}
placeholder="Ingrese la nueva contraseña"
ref={passwordInputRef}
onKeyDown={(e) => e.key === 'Enter' && handleSaveClick()}
/>
</label>
<div className={styles.modalActions}>
<button
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={handleSaveClick}
disabled={!newPassword.trim()}
>
Guardar
</button>
<button
className={`${styles.btn} ${styles.btnSecondary}`}
onClick={onClose}
>
Cancelar
</button>
</div>
</div>
</div>
);
};
export default ModalCambiarClave;

View File

@@ -0,0 +1,265 @@
// frontend/src/components/ModalDetallesEquipo.tsx
import React, { useState, useEffect, useCallback } from 'react';
import type { Equipo, HistorialEquipo, Sector } from '../types/interfaces';
import { Tooltip } from 'react-tooltip';
import styles from './SimpleTable.module.css';
import toast from 'react-hot-toast';
import AutocompleteInput from './AutocompleteInput';
import { equipoService } from '../services/apiService';
import { X, Pencil, HardDrive, MemoryStick, UserPlus, Trash2, Power, Info, Component, Keyboard, Cog, Zap, History } from 'lucide-react';
interface ModalDetallesEquipoProps {
equipo: Equipo;
isOnline: boolean;
historial: HistorialEquipo[];
sectores: Sector[];
isChildModalOpen: boolean;
onClose: () => void;
onDelete: (id: number) => Promise<boolean>;
onRemoveAssociation: (type: 'disco' | 'ram' | 'usuario', id: any) => void;
onEdit: (id: number, equipoEditado: any) => Promise<boolean>;
onAddComponent: (type: 'disco' | 'ram' | 'usuario') => void;
}
const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
equipo, isOnline, historial, sectores, isChildModalOpen,
onClose, onDelete, onRemoveAssociation, onEdit, onAddComponent
}) => {
const [isEditing, setIsEditing] = useState(false);
const [editableEquipo, setEditableEquipo] = useState({ ...equipo });
const [isMacValid, setIsMacValid] = useState(true);
const macRegex = /^([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})$/;
useEffect(() => {
if (editableEquipo.mac && editableEquipo.mac.length > 0) {
setIsMacValid(macRegex.test(editableEquipo.mac));
} else {
setIsMacValid(true);
}
}, [editableEquipo.mac]);
useEffect(() => {
setEditableEquipo({ ...equipo });
}, [equipo]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setEditableEquipo(prev => ({
...prev,
[name]: name === 'sector_id' ? (value ? parseInt(value, 10) : null) : value,
}));
};
const handleMacBlur = (e: React.FocusEvent<HTMLInputElement>) => {
let value = e.target.value;
let cleaned = value.replace(/[^0-9A-Fa-f]/gi, '').toUpperCase().substring(0, 12);
if (cleaned.length === 12) {
value = cleaned.match(/.{1,2}/g)?.join(':') || '';
} else {
value = cleaned;
}
setEditableEquipo(prev => ({ ...prev, mac: value }));
};
const handleSave = async () => {
if (!isMacValid) {
toast.error("El formato de la MAC Address es incorrecto.");
return;
}
const success = await onEdit(equipo.id, editableEquipo);
if (success) setIsEditing(false);
};
const handleCancel = () => {
setEditableEquipo({ ...equipo });
setIsMacValid(true);
setIsEditing(false);
};
const handleEditClick = () => {
setEditableEquipo({ ...equipo });
setIsEditing(true);
};
const handleWolClick = async () => {
if (!equipo.mac || !equipo.ip) {
toast.error("Este equipo no tiene MAC o IP para encenderlo.");
return;
}
const toastId = toast.loading('Enviando paquete WOL...');
try {
await equipoService.wakeOnLan(equipo.mac, equipo.ip);
toast.success('Solicitud de encendido enviada.', { id: toastId });
} catch (error) {
toast.error('Error al enviar la solicitud.', { id: toastId });
console.error('Error al enviar la solicitud WOL:', error);
}
};
const handleDeleteClick = async () => {
const success = await onDelete(equipo.id);
if (success) onClose();
};
const formatDate = (dateString: string | undefined | null) => {
if (!dateString || dateString.startsWith('0001-01-01')) return 'No registrado';
const utcDate = new Date(dateString.replace(' ', 'T') + 'Z');
return utcDate.toLocaleString('es-ES', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit'
});
};
const isSaveDisabled = !editableEquipo.hostname || !editableEquipo.ip || !isMacValid;
const fetchOsSuggestions = useCallback(() => equipoService.getDistinctValues('os'), []);
const fetchMotherboardSuggestions = useCallback(() => equipoService.getDistinctValues('motherboard'), []);
const fetchCpuSuggestions = useCallback(() => equipoService.getDistinctValues('cpu'), []);
return (
<div className={styles.modalLarge}>
<button onClick={onClose} className={styles.closeButton} disabled={isChildModalOpen}>
<X size={20} />
</button>
<div className={styles.modalLargeContent}>
<div className={styles.modalLargeHeader}>
<h2>Detalles del equipo: <strong>{equipo.hostname}</strong></h2>
{equipo.origen === 'manual' && (
<div style={{ display: 'flex', gap: '10px' }}>
{isEditing ? (
<>
<button onClick={handleSave} className={`${styles.btn} ${styles.btnPrimary}`} disabled={isSaveDisabled}>Guardar Cambios</button>
<button onClick={handleCancel} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button>
</>
) : (
<button onClick={handleEditClick} className={`${styles.btn} ${styles.btnPrimary}`} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}><Pencil size={16} /> Editar</button>
)}
</div>
)}
</div>
<div className={styles.modalBodyColumns}>
<div className={styles.mainColumn}>
<div className={styles.section}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 className={styles.sectionTitle} style={{ border: 'none', padding: 0 }}><Info size={20} /> Datos Principales</h3>
{equipo.origen === 'manual' && (
<div style={{ display: 'flex', gap: '5px' }}>
<button onClick={() => onAddComponent('disco')} className={styles.tableButtonMas} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}><HardDrive size={16} /> Disco</button>
<button onClick={() => onAddComponent('ram')} className={styles.tableButtonMas} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}><MemoryStick size={16} /> RAM</button>
<button onClick={() => onAddComponent('usuario')} className={styles.tableButtonMas} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}><UserPlus size={16} /> Usuario</button>
</div>
)}
</div>
<div className={styles.componentsGrid}>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Hostname:</strong>{isEditing ? <input type="text" name="hostname" value={editableEquipo.hostname} onChange={handleChange} className={styles.modalInput} autoComplete="off" /> : <span className={styles.detailValue}>{equipo.hostname}</span>}</div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>IP:</strong>{isEditing ? <input type="text" name="ip" value={editableEquipo.ip} onChange={handleChange} className={styles.modalInput} autoComplete="off" /> : <span className={styles.detailValue}>{equipo.ip}</span>}</div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>MAC Address:</strong>{isEditing ? (<div><input type="text" name="mac" value={editableEquipo.mac || ''} onChange={handleChange} onBlur={handleMacBlur} className={`${styles.modalInput} ${!isMacValid ? styles.inputError : ''}`} placeholder="FC:AA:14:92:12:99" />{!isMacValid && <small className={styles.errorMessage}>Formato inválido.</small>}</div>) : (<span className={styles.detailValue}>{equipo.mac || 'N/A'}</span>)}</div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Sistema Operativo:</strong>{isEditing ? <AutocompleteInput mode="static" name="os" value={editableEquipo.os} onChange={handleChange} className={styles.modalInput} fetchSuggestions={fetchOsSuggestions} /> : <span className={styles.detailValue}>{equipo.os || 'N/A'}</span>}</div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Sector:</strong>{isEditing ? <select name="sector_id" value={editableEquipo.sector_id || ''} onChange={handleChange} className={styles.modalInput}><option value="">- Sin Asignar -</option>{sectores.map(s => <option key={s.id} value={s.id}>{s.nombre}</option>)}</select> : <span className={styles.detailValue}>{equipo.sector?.nombre || 'No asignado'}</span>}</div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Creación:</strong><span className={styles.detailValue}>{formatDate(equipo.created_at)}</span></div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Última Actualización:</strong><span className={styles.detailValue}>{formatDate(equipo.updated_at)}</span></div>
<div className={styles.detailItemFull}><strong className={styles.detailLabel}>Usuarios:</strong><span className={styles.detailValue}>{equipo.usuarios?.length > 0 ? equipo.usuarios.map(u => (<div key={u.id} className={styles.componentItem}><div><span title={`Origen: ${u.origen}`}>{u.origen === 'manual' ? <Keyboard size={16} /> : <Cog size={16} />}</span>{` ${u.username}`}</div>{u.origen === 'manual' && (<button onClick={() => onRemoveAssociation('usuario', { equipoId: equipo.id, usuarioId: u.id })} className={styles.deleteUserButton} title="Quitar este usuario"><Trash2 size={16} /></button>)}</div>)) : 'N/A'}</span></div>
</div>
</div>
<div className={styles.section}>
<h3 className={styles.sectionTitle}><Component size={20} /> Componentes</h3>
<div className={styles.detailsGrid}>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Motherboard:</strong>{isEditing ? <AutocompleteInput mode="static" name="motherboard" value={editableEquipo.motherboard} onChange={handleChange} className={styles.modalInput} fetchSuggestions={fetchMotherboardSuggestions} /> : <span className={styles.detailValue}>{equipo.motherboard || 'N/A'}</span>}</div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>CPU:</strong>{isEditing ? <AutocompleteInput mode="static" name="cpu" value={editableEquipo.cpu} onChange={handleChange} className={styles.modalInput} fetchSuggestions={fetchCpuSuggestions} /> : <span className={styles.detailValue}>{equipo.cpu || 'N/A'}</span>}</div>
<div className={styles.detailItem}><strong className={styles.detailLabel}>RAM Instalada:</strong><span className={styles.detailValue}>{equipo.ram_installed} GB</span></div>
<div className={styles.detailItem}>
<strong className={styles.detailLabel}>Arquitectura:</strong>
{isEditing ? (
<select
name="architecture"
value={editableEquipo.architecture || ''}
onChange={handleChange}
className={styles.modalInput}
>
<option value="">- Seleccionar -</option>
<option value="64 bits">64 bits</option>
<option value="32 bits">32 bits</option>
</select>
) : (
<span className={styles.detailValue}>{equipo.architecture || 'N/A'}</span>
)}
</div>
<div className={styles.detailItemFull}><strong className={styles.detailLabel}>Discos:</strong><span className={styles.detailValue}>{equipo.discos?.length > 0 ? equipo.discos.map(d => (<div key={d.equipoDiscoId} className={styles.componentItem}><div><span title={`Origen: ${d.origen}`}>{d.origen === 'manual' ? <Keyboard size={16} /> : <Cog size={16} />}</span>{` ${d.mediatype} ${d.size}GB`}</div>{d.origen === 'manual' && (<button onClick={() => onRemoveAssociation('disco', d.equipoDiscoId)} className={styles.deleteUserButton} title="Eliminar este disco"><Trash2 size={16} /></button>)}</div>)) : 'N/A'}</span></div>
<div className={styles.detailItem}>
<strong className={styles.detailLabel}>Total Slots RAM:</strong>
{isEditing ? (
<input
type="number"
name="ram_slots"
value={editableEquipo.ram_slots || ''}
onChange={handleChange}
className={styles.modalInput}
placeholder="Ej: 4"
/>
) : (
<span className={styles.detailValue}>{equipo.ram_slots || 'N/A'}</span>
)}
</div>
<div className={styles.detailItemFull}><strong className={styles.detailLabel}>Módulos RAM:</strong><span className={styles.detailValue}>{equipo.memoriasRam?.length > 0 ? equipo.memoriasRam.map(m => (<div key={m.equipoMemoriaRamId} className={styles.componentItem}><div><span title={`Origen: ${m.origen}`}>{m.origen === 'manual' ? <Keyboard size={16} /> : <Cog size={16} />}</span>{` Slot ${m.slot}: ${m.partNumber || 'N/P'} ${m.tamano}GB ${m.velocidad || ''}MHz`}</div>{m.origen === 'manual' && (<button onClick={() => onRemoveAssociation('ram', m.equipoMemoriaRamId)} className={styles.deleteUserButton} title="Eliminar este módulo"><Trash2 size={16} /></button>)}</div>)) : 'N/A'}</span></div>
</div>
</div>
</div>
<div className={styles.sidebarColumn}>
<div className={styles.section}>
<h3 className={styles.sectionTitle}><Zap size={20} /> Acciones y Estado</h3>
<div className={styles.actionsGrid}>
<div className={styles.detailItem}><strong className={styles.detailLabel}>Estado:</strong><div className={styles.statusIndicator}><div className={`${styles.statusDot} ${isOnline ? styles.statusOnline : styles.statusOffline}`} /><span>{isOnline ? 'En línea' : 'Sin conexión'}</span></div></div>
<div className={styles.detailItem}>
<strong className={styles.detailLabel}>Wake On Lan:</strong>
<button
onClick={handleWolClick}
className={styles.powerButton}
data-tooltip-id="modal-power-tooltip"
disabled={!equipo.mac}
>
<Power size={18} />
Encender (WOL)
</button>
<Tooltip id="modal-power-tooltip" place="top">
{equipo.mac ? 'Encender equipo remotamente' : 'Se requiere una dirección MAC para esta acción'}
</Tooltip>
</div>
<div className={styles.detailItem}>
<strong className={styles.detailLabel}>Eliminar Equipo:</strong>
<button
onClick={handleDeleteClick}
className={styles.deleteButton}
data-tooltip-id="modal-delete-tooltip"
>
<Trash2 size={16} /> Eliminar
</button>
<Tooltip id="modal-delete-tooltip" place="top">
Eliminar este equipo permanentemente del inventario
</Tooltip>
</div>
</div>
</div>
</div>
</div>
<div className={`${styles.section} ${styles.historySectionFullWidth}`}>
<h3 className={styles.sectionTitle}><History size={20} /> Historial de cambios</h3>
<div className={styles.historyContainer}>
<table className={styles.historyTable}>
<thead><tr><th className={styles.historyTh}>Fecha</th><th className={styles.historyTh}>Campo</th><th className={styles.historyTh}>Valor anterior</th><th className={styles.historyTh}>Valor nuevo</th></tr></thead>
<tbody>{historial.sort((a, b) => new Date(b.fecha_cambio).getTime() - new Date(a.fecha_cambio).getTime()).map((cambio, index) => (<tr key={index} className={styles.historyTr}><td className={styles.historyTd}>{formatDate(cambio.fecha_cambio)}</td><td className={styles.historyTd}>{cambio.campo_modificado}</td><td className={styles.historyTd}>{cambio.valor_anterior}</td><td className={styles.historyTd}>{cambio.valor_nuevo}</td></tr>))}</tbody>
</table>
</div>
</div>
</div>
</div>
);
};
export default ModalDetallesEquipo;

View File

@@ -0,0 +1,57 @@
// frontend/src/components/ModalEditarSector.tsx
import React from 'react';
import type { Equipo, Sector } from '../types/interfaces';
import styles from './SimpleTable.module.css';
interface ModalEditarSectorProps {
modalData: Equipo; // El componente padre asegura que esto no sea nulo
setModalData: (data: Equipo) => void;
sectores: Sector[];
onClose: () => void;
onSave: () => void;
}
const ModalEditarSector: React.FC<ModalEditarSectorProps> = ({ modalData, setModalData, sectores, onClose, onSave }) => {
return (
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<h3>Editar Sector para {modalData.hostname}</h3>
<label>
Sector:
<select
className={styles.modalInput}
value={modalData.sector?.id || ""}
onChange={(e) => {
const selectedId = e.target.value;
const nuevoSector = selectedId === "" ? undefined : sectores.find(s => s.id === Number(selectedId));
setModalData({ ...modalData, sector: nuevoSector });
}}
>
<option value="">Asignar</option>
{sectores.map(sector => (
<option key={sector.id} value={sector.id}>{sector.nombre}</option>
))}
</select>
</label>
<div className={styles.modalActions}>
<button
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={onSave}
disabled={!modalData.sector}
>
Guardar cambios
</button>
<button
className={`${styles.btn} ${styles.btnSecondary}`}
onClick={onClose}
>
Cancelar
</button>
</div>
</div>
</div>
);
};
export default ModalEditarSector;

View File

@@ -0,0 +1,59 @@
import React, { useState, useEffect, useRef } from 'react';
import type { Sector } from '../types/interfaces';
import styles from './SimpleTable.module.css';
interface Props {
// Si 'sector' es nulo, es para crear. Si tiene datos, es para editar.
sector: Sector | null;
onClose: () => void;
onSave: (id: number | null, nombre: string) => void;
}
const ModalSector: React.FC<Props> = ({ sector, onClose, onSave }) => {
const [nombre, setNombre] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const isEditing = sector !== null;
useEffect(() => {
// Si estamos editando, rellenamos el campo con el nombre actual
if (isEditing) {
setNombre(sector.nombre);
}
// Enfocar el input al abrir el modal
setTimeout(() => inputRef.current?.focus(), 100);
}, [sector, isEditing]);
const handleSave = () => {
if (nombre.trim()) {
onSave(isEditing ? sector.id : null, nombre.trim());
}
};
return (
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<h3>{isEditing ? 'Editar Sector' : 'Añadir Nuevo Sector'}</h3>
<label>Nombre del Sector</label>
<input
ref={inputRef}
type="text"
value={nombre}
onChange={e => setNombre(e.target.value)}
className={styles.modalInput}
onKeyDown={e => e.key === 'Enter' && handleSave()}
/>
<div className={styles.modalActions}>
<button onClick={handleSave} className={`${styles.btn} ${styles.btnPrimary}`} disabled={!nombre.trim()}>
Guardar
</button>
<button onClick={onClose} className={`${styles.btn} ${styles.btnSecondary}`}>
Cancelar
</button>
</div>
</div>
</div>
);
};
export default ModalSector;

View File

@@ -0,0 +1,61 @@
// frontend/src/components/Navbar.tsx
import React from 'react';
import type { View } from '../App';
import ThemeToggle from './ThemeToggle';
import { useAuth } from '../context/AuthContext';
import { LogOut } from 'lucide-react';
import '../App.css';
interface NavbarProps {
currentView: View;
setCurrentView: (view: View) => void;
}
const Navbar: React.FC<NavbarProps> = ({ currentView, setCurrentView }) => {
const { logout } = useAuth();
return (
<header className="navbar">
<div className="app-title">
Inventario IT
</div>
<nav className="nav-links">
<button
className={`nav-link ${currentView === 'equipos' ? 'nav-link-active' : ''}`}
onClick={() => setCurrentView('equipos')}
>
Equipos
</button>
<button
className={`nav-link ${currentView === 'sectores' ? 'nav-link-active' : ''}`}
onClick={() => setCurrentView('sectores')}
>
Sectores
</button>
<button
className={`nav-link ${currentView === 'admin' ? 'nav-link-active' : ''}`}
onClick={() => setCurrentView('admin')}
>
Administración
</button>
<button
className={`nav-link ${currentView === 'dashboard' ? 'nav-link-active' : ''}`}
onClick={() => setCurrentView('dashboard')}
>
Dashboard
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginLeft: '1rem' }}>
<ThemeToggle />
<button
onClick={logout}
className="theme-toggle-button"
title="Cerrar sesión"
>
<LogOut size={20} />
</button>
</div>
</nav>
</header>
);
};
export default Navbar;

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

@@ -0,0 +1,781 @@
/* frontend/src/components/SimpleTable.module.css */
/* Estilos para el contenedor principal y controles */
.header {
margin-bottom: 0.75rem;
}
.header h2 {
margin-top: 5px;
margin-bottom: 1rem;
font-size: 1.25rem;
}
.controlsContainer {
display: flex;
gap: 20px;
margin-bottom: 0.5rem;
align-items: center;
}
.searchInput, .sectorSelect {
padding: 8px 12px;
border-radius: 6px;
border: 1px solid var(--color-border);
font-size: 14px;
background-color: var(--color-surface);
color: var(--color-text-primary);
}
.paginationControls {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
}
.tableContainer {
overflow: auto; /* Cambiado a 'auto' para ambos scrolls */
border: 1px solid var(--color-border);
border-radius: 8px;
position: relative; /* Necesario para posicionar el botón de scroll */
}
.tableContainer::-webkit-scrollbar {
height: 8px;
width: 8px;
background-color: var(--scrollbar-bg);
}
.tableContainer::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-thumb);
border-radius: 4px;
}
.table {
border-collapse: collapse;
font-family: system-ui, -apple-system, sans-serif;
font-size: 0.875rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
min-width: 100%;
}
.th {
color: var(--color-text-primary);
font-weight: 600;
padding: 0.75rem 1rem;
border-bottom: 2px solid var(--color-border);
text-align: left;
user-select: none;
white-space: nowrap;
background-color: var(--color-background);
overflow: hidden;
text-overflow: ellipsis;
position: sticky;
top: 0;
z-index: 2;
}
.headerContent {
cursor: pointer;
}
.resizer {
position: absolute;
right: 0;
top: 0;
height: 100%;
width: 5px;
background: var(--scrollbar-thumb);
cursor: col-resize;
user-select: none;
touch-action: none;
opacity: 0.25;
transition: opacity 0.2s ease-in-out;
z-index: 3; /* Asegura que el resizer esté sobre el contenido de la cabecera */
}
.th:hover .resizer {
opacity: 1;
}
.isResizing {
background: var(--color-text-muted);
opacity: 1;
}
.sortIndicator {
margin-left: 0.5rem;
font-size: 1.2em;
display: inline-block;
transform: translateY(-1px);
color: var(--color-primary);
min-width: 20px;
}
.tooltip {
z-index: 9999;
}
.tr {
transition: background-color 0.2s ease;
}
.tr:hover {
background-color: var(--color-surface-hover);
}
.td {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border-subtle);
color: var(--color-text-secondary);
background-color: var(--color-surface);
word-break: break-word;
overflow: hidden;
text-overflow: ellipsis;
}
/* Estilos de botones dentro de la tabla */
.hostnameButton {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
text-decoration: underline;
padding: 0;
font-size: inherit;
font-family: inherit;
transition: color 0.2s ease;
}
.hostnameButton:hover {
color: var(--color-primary-hover);
}
.tableButton {
padding: 0.375rem 0.75rem;
border-radius: 4px;
border: 1px solid var(--color-border);
background-color: transparent;
color: var(--color-text-primary);
cursor: pointer;
transition: all 0.2s ease;
}
.tableButton:hover {
background-color: var(--color-surface-hover);
border-color: var(--color-text-muted);
}
.tableButtonMas {
padding: 0.375rem 0.75rem;
border-radius: 4px;
border: 1px solid var(--color-primary);
background-color: var(--color-primary);
color: var(--color-navbar-text-hover);
cursor: pointer;
transition: all 0.2s ease;
}
.tableButtonMas:hover {
background-color: var(--color-primary-hover);
border-color: var(--color-primary-hover);
}
.deleteUserButton {
background: none;
border: none;
cursor: pointer;
color: var(--color-danger);
font-size: 1rem;
padding: 0 5px;
transition: opacity 0.3s ease, color 0.3s ease, background-color 0.3s ease;
line-height: 1;
}
.deleteUserButton:hover:not(:disabled) {
color: var(--color-danger-hover);
}
.deleteUserButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Estilo para el botón de scroll-to-top */
.scrollToTop {
position: absolute;
top: 6px;
right: 20px; /* Suficiente espacio para no quedar debajo de la scrollbar */
z-index: 30; /* Un valor alto para asegurar que esté por encima de la tabla y su cabecera (z-index: 2) */
width: 36px;
height: 36px;
background-color: var(--color-surface);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
border-radius: 8%;
/* Contenido y transiciones */
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out, background-color 0.2s, color 0.2s;
/* Animación de entrada/salida */
animation: pop-in 0.3s ease-out forwards;
}
.scrollToTop:hover {
background-color: var(--color-primary);
color: white;
transform: scale(1.1);
}
/* ===== INICIO DE CAMBIOS PARA MODALES Y ANIMACIONES ===== */
/* Keyframes para la animación de entrada */
@keyframes pop-in {
from {
opacity: 0;
transform: scale(0.5);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Estilos genéricos para modales */
.modalOverlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.4);
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease-out;
/* Aplicamos animación */
}
.modal {
background-color: var(--color-surface);
border-radius: 12px;
padding: 2rem;
box-shadow: 0px 8px 30px rgba(0, 0, 0, 0.12);
z-index: 1000;
min-width: 400px;
max-width: 90%;
border: 1px solid var(--color-border);
font-family: 'Segoe UI', sans-serif;
animation: scaleIn 0.2s ease-out;
/* Aplicamos animación */
}
.modal h3 {
margin: 0 0 1.5rem;
color: var(--color-text-primary);
}
.modal label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.modalInput {
padding: 10px;
border-radius: 6px;
border: 1px solid var(--color-border);
background-color: var(--color-background); /* Ligeramente diferente para contraste */
color: var(--color-text-primary);
width: 100%;
box-sizing: border-box;
margin-top: 4px;
/* Separado del label */
margin-bottom: 4px;
/* Espacio antes del siguiente elemento */
transition: border-color 0.2s ease, box-shadow 0.2s ease;
/* Transición para el foco */
}
.modalInput:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 25%, transparent);
}
.modalActions {
display: flex;
gap: 10px;
margin-top: 1.5rem;
justify-content: flex-end;
/* Alinea los botones a la derecha por defecto */
}
/* Estilos de botones para modales */
.btn {
padding: 8px 20px;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
font-size: 14px;
}
.btnPrimary {
background-color: var(--color-primary);
color: white;
}
.btnPrimary:hover {
background-color: var(--color-primary-hover);
}
.btnPrimary:disabled {
background-color: var(--color-surface-hover);
color: var(--color-text-muted);
cursor: not-allowed;
}
.btnSecondary {
background-color: var(--color-text-muted);
color: white;
}
.btnSecondary:hover {
background-color: var(--color-text-secondary);
}
/* ===== ESTILOS PARA EL MODAL DE DETALLES ===== */
.modalLarge {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: var(--color-background);
/* Un fondo ligeramente gris para el modal */
z-index: 1003;
overflow-y: auto;
display: flex;
flex-direction: column;
padding: 2rem;
box-sizing: border-box;
animation: fadeIn 0.3s ease-out;
/* Animación ligeramente más lenta para pantalla completa */
}
.modalLargeContent {
max-width: 1400px;
/* Ancho máximo del contenido */
width: 100%;
margin: 0 auto;
/* Centrar el contenido */
}
.modalLargeHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.05rem;
padding-bottom: 0.05rem;
}
.modalLargeHeader h2 {
font-weight: 400;
font-size: 1.5rem;
color: var(--color-text-primary);
}
.closeButton {
background: black;
color: white;
border: none;
border-radius: 50%;
width: 30px;
height: 30px;
cursor: pointer;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
z-index: 1004;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
transition: transform 0.2s, background-color 0.2s;
position: fixed;
right: 30px;
top: 30px;
}
.closeButton:hover {
transform: scale(1.1);
background-color: #333;
}
.modalBodyColumns {
display: flex;
gap: 2rem;
}
.mainColumn {
flex: 3;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.sidebarColumn {
flex: 1;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.section {
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.sectionTitle {
font-size: 1.25rem;
margin: 0 0 1rem 0;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--color-border-subtle);
color: var(--color-text-primary);
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.detailsGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.componentsGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.actionsGrid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
.detailItem,
.detailItemFull {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 10px;
border-radius: 4px;
background-color: var(--color-background);
border: 1px solid var(--color-border-subtle);
}
.detailLabel {
color: var(--color-text-muted);
font-size: 0.8rem;
font-weight: 700;
}
.detailValue {
color: var(--color-text-secondary);
font-size: 0.9rem;
line-height: 1.4;
word-break: break-word;
}
.componentItem {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 2px 0;
}
.powerButton,
.deleteButton {
background: none;
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 8px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.2s ease;
width: 100%;
justify-content: center;
}
.powerButton {
color: var(--color-text-secondary);
}
.powerButton:hover {
border-color: var(--color-primary);
background-color: color-mix(in srgb, var(--color-primary) 15%, transparent);
color: var(--color-primary-hover);
}
.powerIcon {
width: 20px;
height: 20px;
}
.deleteButton {
color: var(--color-danger);
transition: all 0.2s ease;
}
.deleteButton:hover {
border-color: var(--color-danger);
background-color: var(--color-danger-background);
color: var(--color-danger-hover);
}
.deleteButton:disabled {
color: #6c757d;
background-color: #e9ecef;
border-color: #dee2e6;
}
.historyContainer {
max-height: 400px;
overflow-y: auto;
border: 1px solid var(--color-border);
border-radius: 4px;
}
.historyTable {
width: 100%;
border-collapse: collapse;
}
.historyTh {
background-color: var(--color-background);
padding: 12px;
text-align: left;
font-size: 0.875rem;
position: sticky;
top: 0;
}
.historyTd {
padding: 12px;
font-size: 0.8125rem;
color: var(--color-text-secondary);
border-bottom: 1px solid var(--color-border-subtle);
}
.historyTr:last-child .historyTd {
border-bottom: none;
}
.historySectionFullWidth {
margin-top: 2rem;
}
.statusIndicator {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
}
.statusDot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.statusOnline {
background-color: #28a745;
box-shadow: 0 0 8px #28a74580;
}
.statusOffline {
background-color: #dc3545;
box-shadow: 0 0 8px #dc354580;
}
.inputError {
border-color: #dc3545;
box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);
}
.errorMessage {
color: #dc3545;
font-size: 0.8rem;
margin-top: 4px;
}
.userList {
min-width: 240px;
}
.userItem {
display: flex;
align-items: center;
justify-content: space-between;
margin: 4px 0;
padding: 6px;
background-color: var(--color-background);
border-radius: 4px;
position: relative;
}
.userInfo {
color: var(--color-text-secondary);
}
.userActions {
display: flex;
gap: 4px;
align-items: center;
}
.sectorContainer {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: 0.5rem;
}
.sectorName {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sectorNameAssigned {
color: var(--color-text-secondary);
font-style: normal;
}
.sectorNameUnassigned {
color: var(--color-text-muted);
font-style: italic;
}
.modalOverlay--nested {
z-index: 1005;
}
.modalOverlay--nested .modal {
z-index: 1006;
}
.closeButton:disabled {
cursor: not-allowed;
opacity: 0.5;
background-color: #6c757d;
}
.columnToggleContainer {
position: relative;
}
.columnToggleDropdown {
position: absolute;
top: 100%;
right: 0;
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.5rem;
z-index: 10;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
min-width: 200px;
}
.columnToggleItem {
display: flex;
align-items: center;
padding: 0.5rem;
cursor: pointer;
border-radius: 4px;
}
.columnToggleItem:hover {
background-color: var(--color-surface-hover);
}
.columnToggleItem input {
margin-right: 0.5rem;
}
.columnToggleItem label {
white-space: nowrap;
}
/* --- ESTILOS PARA GESTIÓN DE COMPONENTES --- */
/* Estilos para la columna numérica (Nº de Equipos) */
.thNumeric,
.tdNumeric {
text-align: Center; /* Es buena práctica alinear números a la derecha */
padding-right: 2rem; /* Un poco más de espacio para que no se pegue a las acciones */
width: 1%;
white-space: nowrap;
}
/* Estilos para la columna de acciones */
.thActions,
.tdActions {
text-align: center; /* Centramos el título 'Acciones' y los botones */
width: 1%;
white-space: nowrap;
}
/* --- MODAL DE UNIFICAR --- */
.highlightBox {
display: block;
margin-bottom: 1rem;
padding: 8px;
border-radius: 4px;
background-color: var(--color-border-subtle);
color: var(--color-text-secondary);
border: 1px solid var(--color-border); /* Un borde sutil para definirlo */
}

View File

@@ -0,0 +1,661 @@
// frontend/src/components/SimpleTable.tsx
import React, { useEffect, useState, useRef } from 'react';
import {
useReactTable, getCoreRowModel, getFilteredRowModel, getSortedRowModel,
getPaginationRowModel, flexRender, type CellContext,
type ColumnDef, type VisibilityState, type ColumnSizingState
} from '@tanstack/react-table';
import { Tooltip } from 'react-tooltip';
import toast from 'react-hot-toast';
import type { Equipo, Sector, Usuario, UsuarioEquipoDetalle } from '../types/interfaces';
import styles from './SimpleTable.module.css';
import skeletonStyles from './Skeleton.module.css';
import { accentInsensitiveFilter } from '../utils/filtering';
import { equipoService, sectorService, usuarioService } from '../services/apiService';
import { PlusCircle, KeyRound, UserX, Pencil, ArrowUp, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Columns3 } from 'lucide-react';
import ModalAnadirEquipo from './ModalAnadirEquipo';
import ModalEditarSector from './ModalEditarSector';
import ModalCambiarClave from './ModalCambiarClave';
import ModalDetallesEquipo from './ModalDetallesEquipo';
import ModalAnadirDisco from './ModalAnadirDisco';
import ModalAnadirRam from './ModalAnadirRam';
import ModalAnadirUsuario from './ModalAnadirUsuario';
import TableSkeleton from './TableSkeleton';
const SimpleTable = () => {
const [data, setData] = useState<Equipo[]>([]);
const [filteredData, setFilteredData] = useState<Equipo[]>([]);
const [globalFilter, setGlobalFilter] = useState('');
const [selectedSector, setSelectedSector] = useState('Todos');
const [modalData, setModalData] = useState<Equipo | null>(null);
const [sectores, setSectores] = useState<Sector[]>([]);
const [modalPasswordData, setModalPasswordData] = useState<Usuario | null>(null);
const [showScrollButton, setShowScrollButton] = useState(false);
const [selectedEquipo, setSelectedEquipo] = useState<Equipo | null>(null);
const [historial, setHistorial] = useState<any[]>([]);
const [isOnline, setIsOnline] = useState(false);
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [addingComponent, setAddingComponent] = useState<'disco' | 'ram' | 'usuario' | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isColumnToggleOpen, setIsColumnToggleOpen] = useState(false);
const columnToggleRef = useRef<HTMLDivElement>(null);
const tableContainerRef = useRef<HTMLDivElement>(null);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(() => {
const storedVisibility = localStorage.getItem('table-column-visibility');
return storedVisibility ? JSON.parse(storedVisibility) : { id: false, mac: false, os: false, arch: false };
});
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>(() => {
const storedSizing = localStorage.getItem('table-column-sizing');
return storedSizing ? JSON.parse(storedSizing) : {};
});
useEffect(() => {
localStorage.setItem('table-column-visibility', JSON.stringify(columnVisibility));
}, [columnVisibility]);
useEffect(() => {
localStorage.setItem('table-column-sizing', JSON.stringify(columnSizing));
}, [columnSizing]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (columnToggleRef.current && !columnToggleRef.current.contains(event.target as Node)) {
setIsColumnToggleOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const refreshHistory = async (hostname: string) => {
try {
const data = await equipoService.getHistory(hostname);
setHistorial(data.historial);
} catch (error) {
console.error('Error refreshing history:', error);
}
};
useEffect(() => {
const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth;
if (selectedEquipo || modalData || modalPasswordData || isAddModalOpen) {
document.body.classList.add('scroll-lock');
document.body.style.paddingRight = `${scrollBarWidth}px`;
} else {
document.body.classList.remove('scroll-lock');
document.body.style.paddingRight = '0';
}
return () => {
document.body.classList.remove('scroll-lock');
document.body.style.paddingRight = '0';
};
}, [selectedEquipo, modalData, modalPasswordData, isAddModalOpen]);
useEffect(() => {
if (!selectedEquipo) return;
let isMounted = true;
const checkPing = async () => {
if (!selectedEquipo.ip) return;
try {
const data = await equipoService.ping(selectedEquipo.ip);
if (isMounted) setIsOnline(data.isAlive);
} catch (error) {
if (isMounted) setIsOnline(false);
console.error('Error checking ping:', error);
}
};
checkPing();
const interval = setInterval(checkPing, 10000);
return () => { isMounted = false; clearInterval(interval); setIsOnline(false); };
}, [selectedEquipo]);
const handleCloseModal = () => {
if (addingComponent) {
toast.error("Debes cerrar la ventana de añadir componente primero.");
return;
}
setSelectedEquipo(null);
setIsOnline(false);
};
useEffect(() => {
if (selectedEquipo) {
equipoService.getHistory(selectedEquipo.hostname)
.then(data => setHistorial(data.historial))
.catch(error => console.error('Error fetching history:', error));
}
}, [selectedEquipo]);
useEffect(() => {
const tableElement = tableContainerRef.current;
const handleScroll = () => {
if (tableElement) {
setShowScrollButton(tableElement.scrollTop > 200);
}
};
tableElement?.addEventListener('scroll', handleScroll);
return () => {
tableElement?.removeEventListener('scroll', handleScroll);
};
}, [isLoading]);
useEffect(() => {
setIsLoading(true);
Promise.all([
equipoService.getAll(),
sectorService.getAll()
]).then(([equiposData, sectoresData]) => {
setData(equiposData);
setFilteredData(equiposData);
const sectoresOrdenados = [...sectoresData].sort((a, b) => a.nombre.localeCompare(b.nombre, 'es', { sensitivity: 'base' }));
setSectores(sectoresOrdenados);
}).catch(error => {
toast.error("No se pudieron cargar los datos iniciales.");
console.error("Error al cargar datos:", error);
}).finally(() => setIsLoading(false));
}, []);
const handleSectorChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
setSelectedSector(value);
if (value === 'Todos') setFilteredData(data);
else if (value === 'Asignar') setFilteredData(data.filter(i => !i.sector));
else setFilteredData(data.filter(i => i.sector?.nombre === value));
};
const handleSave = async () => {
if (!modalData) return;
const toastId = toast.loading('Guardando...');
try {
const sectorId = modalData.sector?.id ?? 0;
await equipoService.updateSector(modalData.id, sectorId);
const equipoActualizado = { ...modalData, sector_id: modalData.sector?.id };
const updateFunc = (prev: Equipo[]) => prev.map(e => e.id === modalData.id ? equipoActualizado : e);
setData(updateFunc);
setFilteredData(updateFunc);
if (selectedEquipo && selectedEquipo.id === modalData.id) {
setSelectedEquipo(equipoActualizado);
}
toast.success('Sector actualizado.', { id: toastId });
setModalData(null);
} catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
};
const handleSavePassword = async (password: string) => {
if (!modalPasswordData) return;
const toastId = toast.loading('Actualizando...');
try {
await usuarioService.updatePassword(modalPasswordData.id, password);
const usernameToUpdate = modalPasswordData.username;
const newData = data.map(equipo => {
if (!equipo.usuarios.some(u => u.username === usernameToUpdate)) {
return equipo;
}
const updatedUsers = equipo.usuarios.map(user =>
user.username === usernameToUpdate ? { ...user, password: password } : user
);
return { ...equipo, usuarios: updatedUsers };
});
setData(newData);
if (selectedSector === 'Todos') setFilteredData(newData);
else if (selectedSector === 'Asignar') setFilteredData(newData.filter(i => !i.sector));
else setFilteredData(newData.filter(i => i.sector?.nombre === selectedSector));
if (selectedEquipo) {
const updatedSelectedEquipo = newData.find(e => e.id === selectedEquipo.id);
if (updatedSelectedEquipo) {
setSelectedEquipo(updatedSelectedEquipo);
}
}
toast.success(`Contraseña para '${usernameToUpdate}' actualizada en todos sus equipos.`, { id: toastId });
setModalPasswordData(null);
} catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
};
const handleRemoveUser = async (hostname: string, username: string) => {
if (!window.confirm(`¿Quitar a ${username} de este equipo?`)) return;
const toastId = toast.loading(`Quitando a ${username}...`);
try {
await usuarioService.removeUserFromEquipo(hostname, username);
let equipoActualizado: Equipo | undefined;
const updateFunc = (prev: Equipo[]) => prev.map(e => {
if (e.hostname === hostname) {
equipoActualizado = { ...e, usuarios: e.usuarios.filter(u => u.username !== username) };
return equipoActualizado;
}
return e;
});
setData(updateFunc);
setFilteredData(updateFunc);
if (selectedEquipo && equipoActualizado && selectedEquipo.id === equipoActualizado.id) {
setSelectedEquipo(equipoActualizado);
}
toast.success(`${username} quitado.`, { id: toastId });
} catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
};
const handleDelete = async (id: number) => {
if (!window.confirm('¿Eliminar este equipo y sus relaciones?')) return false;
const toastId = toast.loading('Eliminando equipo...');
try {
await equipoService.deleteManual(id);
setData(prev => prev.filter(e => e.id !== id));
setFilteredData(prev => prev.filter(e => e.id !== id));
toast.success('Equipo eliminado.', { id: toastId });
return true;
} catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId });
return false;
}
};
const handleRemoveAssociation = async (type: 'disco' | 'ram' | 'usuario', associationId: number | { equipoId: number, usuarioId: number }) => {
if (!window.confirm('¿Estás seguro de que quieres eliminar esta asociación manual?')) return;
const toastId = toast.loading('Eliminando asociación...');
try {
let successMessage = '';
if (type === 'disco' && typeof associationId === 'number') {
await equipoService.removeDiscoAssociation(associationId);
successMessage = 'Disco desasociado del equipo.';
} else if (type === 'ram' && typeof associationId === 'number') {
await equipoService.removeRamAssociation(associationId);
successMessage = 'Módulo de RAM desasociado.';
} else if (type === 'usuario' && typeof associationId === 'object') {
await equipoService.removeUserAssociation(associationId.equipoId, associationId.usuarioId);
successMessage = 'Usuario desasociado del equipo.';
} else {
throw new Error('Tipo de asociación no válido');
}
const updateState = (prevEquipos: Equipo[]) => prevEquipos.map(equipo => {
if (equipo.id !== selectedEquipo?.id) return equipo;
let updatedEquipo = { ...equipo };
if (type === 'disco' && typeof associationId === 'number') {
updatedEquipo.discos = equipo.discos.filter(d => d.equipoDiscoId !== associationId);
} else if (type === 'ram' && typeof associationId === 'number') {
updatedEquipo.memoriasRam = equipo.memoriasRam.filter(m => m.equipoMemoriaRamId !== associationId);
} else if (type === 'usuario' && typeof associationId === 'object') {
updatedEquipo.usuarios = equipo.usuarios.filter(u => u.id !== associationId.usuarioId);
}
return updatedEquipo;
});
setData(updateState);
setFilteredData(updateState);
setSelectedEquipo(prev => prev ? updateState([prev])[0] : null);
if (selectedEquipo) {
await refreshHistory(selectedEquipo.hostname);
}
toast.success(successMessage, { id: toastId });
} catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
};
const handleCreateEquipo = async (nuevoEquipo: Omit<Equipo, 'id' | 'created_at' | 'updated_at'>) => {
const toastId = toast.loading('Creando nuevo equipo...');
try {
const equipoCreado = await equipoService.createManual(nuevoEquipo);
setData(prev => [...prev, equipoCreado]);
setFilteredData(prev => [...prev, equipoCreado]);
toast.success(`Equipo '${equipoCreado.hostname}' creado.`, { id: toastId });
setIsAddModalOpen(false);
} catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
};
const handleEditEquipo = async (id: number, equipoEditado: any) => {
const toastId = toast.loading('Guardando cambios...');
try {
const equipoActualizadoDesdeBackend = await equipoService.updateManual(id, equipoEditado);
const updateState = (prev: Equipo[]) => prev.map(e => e.id === id ? equipoActualizadoDesdeBackend : e);
setData(updateState);
setFilteredData(updateState);
setSelectedEquipo(equipoActualizadoDesdeBackend);
toast.success('Equipo actualizado.', { id: toastId });
return true;
} catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId });
return false;
}
};
const handleAddComponent = async (type: 'disco' | 'ram' | 'usuario', componentData: any) => {
if (!selectedEquipo) return;
const toastId = toast.loading(`Añadiendo ${type}...`);
try {
let serviceCall;
switch (type) {
case 'disco': serviceCall = equipoService.addDisco(selectedEquipo.id, componentData); break;
case 'ram': serviceCall = equipoService.addRam(selectedEquipo.id, componentData); break;
case 'usuario': serviceCall = equipoService.addUsuario(selectedEquipo.id, componentData); break;
default: throw new Error('Tipo de componente no válido');
}
await serviceCall;
const refreshedEquipo = await equipoService.getAll().then(equipos => equipos.find(e => e.id === selectedEquipo.id));
if (!refreshedEquipo) throw new Error("No se pudo recargar el equipo");
const updateState = (prev: Equipo[]) => prev.map(e => e.id === selectedEquipo.id ? refreshedEquipo : e);
setData(updateState);
setFilteredData(updateState);
setSelectedEquipo(refreshedEquipo);
await refreshHistory(selectedEquipo.hostname);
toast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} añadido.`, { id: toastId });
setAddingComponent(null);
} catch (error) {
if (error instanceof Error) toast.error(error.message, { id: toastId });
}
};
const columns: ColumnDef<Equipo>[] = [
{ header: "ID", accessorKey: "id", enableHiding: true, enableResizing: false },
{
header: "Nombre", accessorKey: "hostname",
cell: (info: CellContext<Equipo, any>) => (<button onClick={() => setSelectedEquipo(info.row.original)} className={styles.hostnameButton}>{info.row.original.hostname}</button>)
},
{ header: "IP", accessorKey: "ip", id: 'ip' },
{ header: "MAC", accessorKey: "mac" },
{ header: "Motherboard", accessorKey: "motherboard" },
{ header: "CPU", accessorKey: "cpu" },
{ header: "RAM", accessorKey: "ram_installed", id: 'ram' },
{ header: "Discos", accessorFn: (row: Equipo) => row.discos?.map(d => `${d.mediatype} ${d.size}GB`).join(" | ") || "Sin discos" },
{ header: "OS", accessorKey: "os" },
{ header: "Arquitectura", accessorKey: "architecture", id: 'arch' },
{
header: "Usuarios y Claves",
id: 'usuarios',
cell: (info: CellContext<Equipo, any>) => {
const { row } = info;
const usuarios = row.original.usuarios || [];
return (
<div className={styles.userList}>
{usuarios.map((u: UsuarioEquipoDetalle) => (
<div key={u.id} className={styles.userItem}>
<span className={styles.userInfo}>
U: {u.username} - C: {u.password || 'N/A'}
</span>
<div className={styles.userActions}>
<button
onClick={() => setModalPasswordData(u)}
className={styles.tableButton}
data-tooltip-id={`edit-${u.id}`}
>
<KeyRound size={16} />
<Tooltip id={`edit-${u.id}`} className={styles.tooltip} place="top">Cambiar Clave</Tooltip>
</button>
<button
onClick={() => handleRemoveUser(row.original.hostname, u.username)}
className={styles.deleteUserButton}
data-tooltip-id={`remove-${u.id}`}
>
<UserX size={16} />
<Tooltip id={`remove-${u.id}`} className={styles.tooltip} place="top">Eliminar Usuario</Tooltip>
</button>
</div>
</div>
))}
</div>
);
}
},
{
header: "Sector", id: 'sector', accessorFn: (row: Equipo) => row.sector?.nombre || 'Asignar',
cell: (info: CellContext<Equipo, any>) => {
const { row } = info;
const sector = row.original.sector;
return (
<div className={styles.sectorContainer}>
<span className={`${styles.sectorName} ${sector ? styles.sectorNameAssigned : styles.sectorNameUnassigned}`}>{sector?.nombre || 'Asignar'}</span>
<button onClick={() => setModalData(row.original)} className={styles.tableButton} data-tooltip-id={`editSector-${row.original.id}`}><Pencil size={16} /><Tooltip id={`editSector-${row.original.id}`} className={styles.tooltip} place="top">Cambiar Sector</Tooltip></button>
</div>
);
}
}
];
const table = useReactTable({
data: filteredData,
columns,
columnResizeMode: 'onChange',
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onColumnSizingChange: setColumnSizing,
filterFns: {
accentInsensitive: accentInsensitiveFilter,
},
globalFilterFn: 'accentInsensitive',
initialState: {
sorting: [
{ id: 'sector', desc: false },
{ id: 'hostname', desc: false }
],
pagination: {
pageSize: 15,
},
},
state: {
globalFilter,
columnVisibility,
columnSizing,
},
onGlobalFilterChange: setGlobalFilter,
});
if (isLoading) {
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2>Equipos (...)</h2>
<div className={`${skeletonStyles.skeleton}`} style={{ height: '40px', width: '160px' }}></div>
</div>
<div className={styles.controlsContainer}>
<div className={`${skeletonStyles.skeleton}`} style={{ height: '38px', width: '300px' }}></div>
<div className={`${skeletonStyles.skeleton}`} style={{ height: '38px', width: '200px' }}></div>
</div>
<div><p style={{ fontSize: '12px', fontWeight: 'bold' }}>** La tabla permite ordenar por múltiples columnas manteniendo shift al hacer click en la cabecera. **</p></div>
<div className={`${skeletonStyles.skeleton}`} style={{ height: '54px', marginBottom: '1rem' }}></div>
<TableSkeleton rows={15} columns={11} />
</div>
);
}
const PaginacionControles = (
<div className={styles.paginationControls}>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
<button onClick={() => table.setPageIndex(0)} disabled={!table.getCanPreviousPage()} className={styles.tableButton}>
<ChevronsLeft size={18} />
</button>
<button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} className={styles.tableButton}>
<ChevronLeft size={18} />
</button>
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} className={styles.tableButton}>
<ChevronRight size={18} />
</button>
<button onClick={() => table.setPageIndex(table.getPageCount() - 1)} disabled={!table.getCanNextPage()} className={styles.tableButton}>
<ChevronsRight size={18} />
</button>
</div>
<span>
Página{' '}
<strong>
{table.getState().pagination.pageIndex + 1} de {table.getPageCount()}
</strong>
</span>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
<span>| Ir a pág:</span>
<input
type="number"
defaultValue={table.getState().pagination.pageIndex + 1}
onChange={e => {
const page = e.target.value ? Number(e.target.value) - 1 : 0;
table.setPageIndex(page);
}}
style={{ width: '60px' }}
className={styles.searchInput}
/>
<select
value={table.getState().pagination.pageSize}
onChange={e => {
table.setPageSize(Number(e.target.value));
}}
className={styles.sectorSelect}
>
{[10, 15, 25, 50, 100].map(pageSize => (
<option key={pageSize} value={pageSize}>
Mostrar {pageSize}
</option>
))}
</select>
</div>
</div>
);
return (
<div>
<div className={styles.header}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2>Equipos ({table.getFilteredRowModel().rows.length})</h2>
<button
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={() => setIsAddModalOpen(true)}
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
>
<PlusCircle size={18} /> Añadir Equipo
</button>
</div>
<div className={styles.controlsContainer}>
<input type="text" placeholder="Buscar en todos los campos..." value={globalFilter} onChange={(e) => setGlobalFilter(e.target.value)} className={styles.searchInput} style={{ width: '300px' }} />
<b>Sector:</b>
<select value={selectedSector} onChange={handleSectorChange} className={styles.sectorSelect}>
<option value="Todos">-Todos-</option>
<option value="Asignar">-Asignar-</option>
{sectores.map(s => (<option key={s.id} value={s.nombre}>{s.nombre}</option>))}
</select>
<div ref={columnToggleRef} className={styles.columnToggleContainer}>
<button onClick={() => setIsColumnToggleOpen(prev => !prev)} className={styles.tableButton} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Columns3 size={16} /> Columnas
</button>
{isColumnToggleOpen && (
<div className={styles.columnToggleDropdown}>
{table.getAllLeafColumns().map(column => {
if (column.id === 'id') return null;
return (
<div key={column.id} className={styles.columnToggleItem}>
<input
type="checkbox"
checked={column.getIsVisible()}
onChange={column.getToggleVisibilityHandler()}
id={`col-toggle-${column.id}`}
/>
<label htmlFor={`col-toggle-${column.id}`}>
{typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id}
</label>
</div>
)
})}
</div>
)}
</div>
</div>
<div><p style={{ fontSize: '12px', fontWeight: 'bold' }}>** La tabla permite ordenar por múltiples columnas manteniendo shift al hacer click en la cabecera. **</p></div>
</div>
<div style={{ position: 'relative' }}>
<div ref={tableContainerRef} className={styles.tableContainer} style={{ maxHeight: '70vh' }}>
<table className={styles.table} style={{ width: table.getTotalSize() }}>
<thead>
{table.getHeaderGroups().map(hg => (
<tr key={hg.id}>
{hg.headers.map(h => (
<th key={h.id} style={{ width: h.getSize() }} className={styles.th}>
<div onClick={h.column.getToggleSortingHandler()} className={styles.headerContent}>
{flexRender(h.column.columnDef.header, h.getContext())}
{h.column.getIsSorted() && (<span className={styles.sortIndicator}>{h.column.getIsSorted() === 'asc' ? '⇑' : '⇓'}</span>)}
</div>
<div
onMouseDown={h.getResizeHandler()}
onTouchStart={h.getResizeHandler()}
className={`${styles.resizer} ${h.column.getIsResizing() ? styles.isResizing : ''}`}
/>
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id} className={styles.tr}>
{row.getVisibleCells().map(cell => (
<td key={cell.id} style={{ width: cell.column.getSize() }} className={styles.td}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{showScrollButton && (
<button
onClick={() => tableContainerRef.current?.scrollTo({ top: 0, behavior: 'smooth' })}
className={styles.scrollToTop}
title="Volver al inicio"
>
<ArrowUp size={20} />
</button>
)}
</div>
{PaginacionControles}
{modalData && <ModalEditarSector modalData={modalData} setModalData={setModalData} sectores={sectores} onClose={() => setModalData(null)} onSave={handleSave} />}
{modalPasswordData && <ModalCambiarClave usuario={modalPasswordData} onClose={() => setModalPasswordData(null)} onSave={handleSavePassword} />}
{selectedEquipo && (
<ModalDetallesEquipo
equipo={selectedEquipo}
isOnline={isOnline}
historial={historial}
onClose={handleCloseModal}
onDelete={handleDelete}
onRemoveAssociation={handleRemoveAssociation}
onEdit={handleEditEquipo}
sectores={sectores}
onAddComponent={type => setAddingComponent(type)}
isChildModalOpen={addingComponent !== null}
/>
)}
{isAddModalOpen && <ModalAnadirEquipo sectores={sectores} onClose={() => setIsAddModalOpen(false)} onSave={handleCreateEquipo} />}
{addingComponent === 'disco' && <ModalAnadirDisco onClose={() => setAddingComponent(null)} onSave={(data: { mediatype: string, size: number }) => handleAddComponent('disco', data)} />}
{addingComponent === 'ram' && <ModalAnadirRam onClose={() => setAddingComponent(null)} onSave={(data: { slot: string, tamano: number, fabricante?: string, velocidad?: number }) => handleAddComponent('ram', data)} />}
{addingComponent === 'usuario' && <ModalAnadirUsuario onClose={() => setAddingComponent(null)} onSave={(data: { username: string }) => handleAddComponent('usuario', data)} />}
</div>
);
};
export default SimpleTable;

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

78
frontend/src/index.css Normal file
View File

@@ -0,0 +1,78 @@
/* frontend/src/index.css */
/* 1. Definición de variables de color para el tema claro (por defecto) */
:root {
--color-background: #f8f9fa;
--color-text-primary: #212529;
--color-text-secondary: #495057;
--color-text-muted: #6c757d;
--color-surface: #ffffff; /* Para tarjetas, modales, etc. */
--color-surface-hover: #f1f3f5;
--color-border: #dee2e6;
--color-border-subtle: #e9ecef;
--color-primary: #007bff;
--color-primary-hover: #0056b3;
--color-danger: #dc3545;
--color-danger-hover: #a4202e;
--color-danger-background: #fbebee;
--color-navbar-bg: #343a40;
--color-navbar-text: #adb5bd;
--color-navbar-text-hover: #ffffff;
--scrollbar-bg: #f1f1f1;
--scrollbar-thumb: #888;
}
/* 2. Sobrescribir variables para el tema oscuro */
[data-theme='dark'] {
--color-background: #121212;
--color-text-primary: #e0e0e0;
--color-text-secondary: #b0b0b0;
--color-text-muted: #888;
--color-surface: #1e1e1e;
--color-surface-hover: #2a2a2a;
--color-border: #333;
--color-border-subtle: #2c2c2c;
--color-primary: #3a97ff;
--color-primary-hover: #63b0ff;
--color-danger: #ff5252;
--color-danger-hover: #ff8a80;
--color-danger-background: #4d2323;
--color-navbar-bg: #1e1e1e;
--color-navbar-text: #888;
--color-navbar-text-hover: #ffffff;
--scrollbar-bg: #2c2c2c;
--scrollbar-thumb: #555;
}
/* 3. Aplicar las variables a los estilos base */
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
background-color: var(--color-background);
color: var(--color-text-primary);
transition: background-color 0.2s ease, color 0.2s ease; /* Transición suave al cambiar de tema */
}
body::-webkit-scrollbar {
width: 8px;
background-color: var(--scrollbar-bg);
}
body::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-thumb);
border-radius: 4px;
}
body.scroll-lock {
padding-right: 8px !important;
overflow: hidden !important;
}

35
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,35 @@
// frontend/src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
import { Toaster } from 'react-hot-toast'
import { ThemeProvider } from './context/ThemeContext';
import { AuthProvider } from './context/AuthContext';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<AuthProvider>
<ThemeProvider>
<App />
<Toaster
position="bottom-right"
toastOptions={{
success: {
style: {
background: '#28a745',
color: 'white',
},
},
error: {
style: {
background: '#dc3545',
color: 'white',
},
},
}}
/>
</ThemeProvider>
</AuthProvider>
</React.StrictMode>,
)

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

@@ -0,0 +1,102 @@
// frontend/src/types/interfaces.ts
// Corresponde al modelo 'Sector'
export interface Sector {
id: number;
nombre: string;
}
// Corresponde al modelo 'Usuario'
export interface Usuario {
id: number;
username: string;
password?: string; // Es opcional ya que no siempre lo enviaremos
}
// CAMBIO: Añadimos el origen y el ID de la asociación
export interface UsuarioEquipoDetalle extends Usuario {
origen: 'manual' | 'automatica';
}
// Corresponde al modelo 'Disco'
export interface Disco {
id: number;
mediatype: string;
size: number;
}
// CAMBIO: Añadimos el origen y el ID de la asociación
export interface DiscoDetalle extends Disco {
equipoDiscoId: number; // El ID de la tabla equipos_discos
origen: 'manual' | 'automatica';
}
// Corresponde al modelo 'MemoriaRam'
export interface MemoriaRam {
id: number;
partNumber?: string;
fabricante?: string;
tamano: number;
velocidad?: number;
}
// CCorresponde al modelo 'MemoriaRamEquipoDetalle'
export interface MemoriaRamEquipoDetalle extends MemoriaRam {
equipoMemoriaRamId: number; // El ID de la tabla equipos_memorias_ram
slot: string;
origen: 'manual' | 'automatica';
}
// Corresponde al modelo 'HistorialEquipo'
export interface HistorialEquipo {
id: number;
equipo_id: number;
campo_modificado: string;
valor_anterior?: string;
valor_nuevo?: string;
fecha_cambio: string;
}
// Corresponde al modelo principal 'Equipo' y sus relaciones
export interface Equipo {
id: number;
hostname: string;
ip: string;
mac?: string;
motherboard: string;
cpu: string;
ram_installed: number;
ram_slots?: number;
os: string;
architecture: string;
created_at: string;
updated_at: string;
sector_id?: number;
origen: 'manual' | 'automatica'; // Campo de origen para el equipo
// Propiedades de navegación que vienen de las relaciones (JOINs)
sector?: Sector;
usuarios: UsuarioEquipoDetalle[];
discos: DiscoDetalle[];
memoriasRam: MemoriaRamEquipoDetalle[];
historial: HistorialEquipo[];
}
// --- Interfaces para el Dashboard ---
export interface EquipoFilter {
field: string; // 'os', 'cpu', 'sector', etc.
value: string; // El valor específico del filtro
}
export interface StatItem {
label: string;
count: number;
}
export interface DashboardStats {
osStats: StatItem[];
sectorStats: StatItem[];
cpuStats: StatItem[];
ramStats: StatItem[];
}

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

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

19
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
// --- AÑADIR ESTA SECCIÓN COMPLETA ---
server: {
proxy: {
// Cualquier petición que empiece con '/api' será redirigida.
'/api': {
// Redirige al servidor de backend que corre en local.
target: 'http://localhost:5198',
// Necesario para evitar problemas de CORS y de origen.
changeOrigin: true,
},
},
},
})

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