Compare commits
	
		
			15 Commits
		
	
	
		
			3fbb254ac3
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| d84b5c0853 | |||
| 56819fb1ca | |||
| f96a358227 | |||
| 66fdf8bb3b | |||
| d9196512de | |||
| 5129b517d8 | |||
| 85aa6a38ba | |||
| 7c801b4b73 | |||
| 06d95d32ca | |||
| e354433cd6 | |||
| e41892ef2d | |||
| 66e3a0af99 | |||
| c76a5681ac | |||
| bca56e7722 | |||
| 267aaab91f | 
							
								
								
									
										
											BIN
										
									
								
								Manual Titulares APP.pdf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								Manual Titulares APP.pdf
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										180
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										180
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,180 @@ | |||||||
|  | ## 📰 Titulares APP  | ||||||
|  | --- | ||||||
|  | **Titulares APP** es una aplicación web de dashboard en tiempo real diseñada para extraer titulares de un sitio de noticias, gestionarlos y exportarlos automáticamente a un archivo CSV en una ubicación de red. | ||||||
|  |  | ||||||
|  | El sistema se compone de un **backend RESTful API desarrollado en ASP.NET Core** que gestiona el scraping, la base de datos y la comunicación en tiempo real, y un **frontend interactivo de tipo SPA (Single Page Application) construido con React, TypeScript y Vite**. | ||||||
|  |  | ||||||
|  | --- | ||||||
|  | ## 🚀 Funcionalidades Principales | ||||||
|  |  | ||||||
|  | El sistema está diseñado para la automatización, la gestión centralizada y la visualización en tiempo real de titulares de noticias. | ||||||
|  |  | ||||||
|  | ### 🤖 Motor de Scraping Automatizado | ||||||
|  | - **Worker en Segundo Plano:** Un proceso `BackgroundService` de .NET se ejecuta de forma continua en el servidor. | ||||||
|  | - **Scraping Periódico:** Extrae los últimos titulares del sitio de noticias (`eldia.com`) a intervalos configurables. | ||||||
|  | - **Sincronización Inteligente:** Compara los titulares extraídos con los existentes en la base de datos para evitar duplicados y mantener la lista actualizada, preservando el orden de los titulares manuales. | ||||||
|  | - **Limpieza de Datos:** Procesa el texto de los titulares para eliminar prefijos no deseados (ej: "VIDEO.-") y decodificar entidades HTML. | ||||||
|  |  | ||||||
|  | ### ⚡ Actualizaciones en Tiempo Real con SignalR | ||||||
|  | - **Notificación Instantánea:** Cualquier cambio en la lista de titulares (por scraping, creación manual, edición, reordenamiento o eliminación) es notificado instantáneamente a todos los clientes conectados. | ||||||
|  | - **Dashboard Sincronizado:** La tabla de titulares en la interfaz de todos los usuarios se actualiza en tiempo real sin necesidad de refrescar la página. | ||||||
|  | - **Gestión de Conexión:** El backend detecta cuándo hay clientes activos y puede pausar el proceso de scraping si no hay nadie conectado para ahorrar recursos. | ||||||
|  |  | ||||||
|  | ### 🖐️ Gestión Manual Completa | ||||||
|  | - **Creación de Titulares:** Permite añadir titulares manualmente, los cuales se insertan al principio de la lista. | ||||||
|  | - **Edición Rápida:** | ||||||
|  |     - Edición del texto del titular directamente en la tabla. | ||||||
|  |     - Edición de la viñeta (bullet point) a través de un modal dedicado. | ||||||
|  | - **Reordenamiento Drag & Drop:** Permite cambiar el orden visual de los titulares simplemente arrastrándolos y soltándolos en la tabla. | ||||||
|  | - **Eliminación Segura:** Borrado de titulares con un modal de confirmación para evitar acciones accidentales. | ||||||
|  |  | ||||||
|  | ### ⚙️ Panel de Configuración Dinámico | ||||||
|  | - **Gestión Centralizada:** Una sección en la UI permite configurar todos los parámetros clave del sistema. | ||||||
|  | - **Parámetros Configurables:** | ||||||
|  |     - **Ruta del archivo CSV:** Ubicación de red donde se guardará el archivo. | ||||||
|  |     - **Intervalo de Scraping:** Frecuencia en minutos para la extracción de nuevos titulares. | ||||||
|  |     - **Cantidad de Titulares:** Número máximo de titulares a mantener en la lista. | ||||||
|  |     - **Viñeta por Defecto:** Símbolo a usar cuando un titular no tiene una viñeta específica. | ||||||
|  | - **Auto-Guardado Inteligente:** Los cambios en la configuración se guardan automáticamente en la base de datos después de una breve pausa (`debounce`), proporcionando una experiencia de usuario fluida. | ||||||
|  |  | ||||||
|  | ### 📄 Exportación a CSV Robusta | ||||||
|  | - **Generación Automática:** Después de cada ciclo de scraping exitoso, el sistema regenera automáticamente el archivo `titulares.csv` en la ruta de red configurada. | ||||||
|  | - **Generación Manual:** Un botón en la UI permite forzar la regeneración del archivo CSV en cualquier momento. | ||||||
|  | - **Codificación Específica:** El archivo CSV se genera con codificación `UTF-16 Big Endian con BOM` para máxima compatibilidad con sistemas específicos que puedan consumir el archivo. | ||||||
|  |  | ||||||
|  | ### 🎨 Interfaz de Usuario Moderna | ||||||
|  | - **Diseño Oscuro y Profesional:** Construido con Material-UI, ofreciendo una experiencia visual agradable y consistente. | ||||||
|  | - **Feedback al Usuario:** Notificaciones instantáneas para operaciones de éxito o error. | ||||||
|  | - **Indicadores de Estado:** Muestra el estado de la conexión con el servidor (Conectado, Reconectando, Desconectado) y el estado del proceso de scraping (ON/OFF). | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## 🛠️ Stack Tecnológico | ||||||
|  |  | ||||||
|  | ### Backend (`backend/`) | ||||||
|  | - **Framework:** ASP.NET Core 9 | ||||||
|  | - **Lenguaje:** C# | ||||||
|  | - **Acceso a Datos:** Dapper (Micro ORM) | ||||||
|  | - **Base de Datos:** Microsoft SQL Server | ||||||
|  | - **Scraping:** HtmlAgilityPack | ||||||
|  | - **Comunicación Real-Time:** SignalR | ||||||
|  | - **Generación CSV:** CsvHelper | ||||||
|  |  | ||||||
|  | ### Frontend (`frontend/`) | ||||||
|  | - **Librería:** React 19 | ||||||
|  | - **Lenguaje:** TypeScript | ||||||
|  | - **Componentes UI:** Material-UI (MUI) | ||||||
|  | - **Drag & Drop:** dnd-kit | ||||||
|  | - **Comunicación Real-Time:** Cliente de SignalR (`@microsoft/signalr`) | ||||||
|  | - **Build Tool:** Vite | ||||||
|  |  | ||||||
|  | ### Entorno de Producción | ||||||
|  | - **Contenerización:** Docker & Docker Compose | ||||||
|  | - **Proxy Inverso y Servidor Web:** Nginx (configurado para servir React y actuar como proxy para la API y SignalR/WebSockets) | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## 🚀 Puesta en Marcha (Getting Started) | ||||||
|  |  | ||||||
|  | Siga estos pasos para configurar y ejecutar el proyecto. | ||||||
|  |  | ||||||
|  | ### 1. Entorno de Desarrollo Local | ||||||
|  |  | ||||||
|  | #### Prerrequisitos | ||||||
|  | - **.NET SDK 9.0** o superior. | ||||||
|  | - **Node.js** v20.x o superior. | ||||||
|  | - **Microsoft SQL Server** y una herramienta de gestión como SSMS. | ||||||
|  |  | ||||||
|  | #### Configuración | ||||||
|  | 1.  **Clonar el Repositorio:** | ||||||
|  |     ```bash | ||||||
|  |     git clone <URL_DEL_REPOSITORIO_GITEA> | ||||||
|  |     cd TitularesApp # O el nombre de tu carpeta | ||||||
|  |     ``` | ||||||
|  | 2.  **Base de Datos:** | ||||||
|  |     - Cree una base de datos en su instancia de SQL Server (ej: `TitularesDB`). | ||||||
|  |     - Ejecute los scripts necesarios para crear las tablas `Titulares` y `Configuracion` según los modelos en el proyecto (`Titular.cs`, `ConfiguracionApp.cs`). | ||||||
|  | 3.  **Backend (`backend/src/Titulares.Api`):** | ||||||
|  |     - Renombre o copie `appsettings.Development.json` si es necesario. | ||||||
|  |     - **Modifique la `ConnectionString`** en `appsettings.json` para apuntar a su base de datos. | ||||||
|  |     - Ejecute desde el directorio `backend/src/Titulares.Api`: | ||||||
|  |       ```bash | ||||||
|  |       dotnet restore | ||||||
|  |       dotnet run | ||||||
|  |       ``` | ||||||
|  |     - La API estará escuchando en `http://localhost:5174`. | ||||||
|  | 4.  **Frontend (`frontend/`):** | ||||||
|  |     - Verifique que `frontend/.env.development` contiene `VITE_API_BASE_URL=http://localhost:5174`. | ||||||
|  |     - Ejecute desde el directorio `frontend/`: | ||||||
|  |       ```bash | ||||||
|  |       npm install | ||||||
|  |       npm run dev | ||||||
|  |       ``` | ||||||
|  |     - La aplicación estará disponible en `http://localhost:5173`. | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ### 2. Entorno de Producción con Docker | ||||||
|  |  | ||||||
|  | Esta es la forma recomendada de desplegar la aplicación. | ||||||
|  |  | ||||||
|  | #### Prerrequisitos | ||||||
|  | - **Docker** y **Docker Compose** instalados en el servidor anfitrión (ej: `192.168.5.128`). | ||||||
|  | - Una **carpeta compartida en la red** accesible desde el servidor Docker, donde se guardará el archivo CSV. | ||||||
|  |  | ||||||
|  | #### Configuración del Servidor Anfitrión (Host de Docker) | ||||||
|  | Para asegurar que el contenedor pueda escribir en la carpeta de red de forma persistente y resiliente a reinicios, se recomienda configurar un montaje bajo demanda con `autofs`. | ||||||
|  |  | ||||||
|  | 1.  **Instalar dependencias:** `sudo apt-get install -y autofs cifs-utils`. | ||||||
|  | 2.  **Configurar el mapa maestro `auto.master`:** Añadir la línea `/mnt /etc/auto.cifs --timeout=60`. | ||||||
|  | 3.  **Crear el mapa `auto.cifs`** con los detalles de la carpeta compartida, apuntando a un archivo de credenciales seguro. | ||||||
|  |     ``` | ||||||
|  |     # /etc/auto.cifs | ||||||
|  |     nombre_montaje -fstype=cifs,credentials=/ruta/segura/credenciales.txt,uid=1000,gid=1000,vers=3.0 ://IP_PC_USUARIO/CARPETA_COMPARTIDA | ||||||
|  |     ``` | ||||||
|  | 4.  **Reiniciar el servicio:** `sudo systemctl restart autofs`. | ||||||
|  |  | ||||||
|  | #### Despliegue | ||||||
|  | 1.  **Clonar el repositorio** en el servidor Docker. | ||||||
|  | 2.  **Configurar la red externa de Docker** si es necesario (para la base de datos). | ||||||
|  |     ```bash | ||||||
|  |     docker network create shared-net | ||||||
|  |     ``` | ||||||
|  | 3.  **Ejecutar Docker Compose** desde la raíz del proyecto. | ||||||
|  |     ```bash | ||||||
|  |     docker-compose up --build -d | ||||||
|  |     ``` | ||||||
|  | 4.  **Acceder a la Aplicación:** La aplicación estará disponible en la IP del servidor Docker y el puerto configurado en `docker-compose.yml`. | ||||||
|  |     - **URL:** `http://192.168.5.128:8905` | ||||||
|  |  | ||||||
|  | --- | ||||||
|  | ## 📂 Estructura del Proyecto | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  | . | ||||||
|  | ├── backend/ | ||||||
|  | │   └── src/ | ||||||
|  | │       └── Titulares.Api/        # Proyecto principal de ASP.NET Core | ||||||
|  | │           ├── Controllers/      # Controladores de la API | ||||||
|  | │           ├── Data/             # Repositorios (Dapper) para acceso a datos | ||||||
|  | │           ├── Hubs/             # Hubs de SignalR | ||||||
|  | │           ├── Models/           # Clases de modelo y DTOs | ||||||
|  | │           ├── Services/         # Lógica de negocio (Scraping, CSV, Estado) | ||||||
|  | │           ├── Workers/          # Servicios en segundo plano | ||||||
|  | │           ├── Dockerfile        # Instrucciones para construir la imagen del backend | ||||||
|  | │           └── ... | ||||||
|  | ├── frontend/ | ||||||
|  | │   ├── src/ | ||||||
|  | │   │   ├── components/           # Componentes de React | ||||||
|  | │   │   ├── contexts/           # Proveedores de Contexto (Notificaciones) | ||||||
|  | │   │   ├── hooks/              # Hooks personalizados (useSignalR, useDebounce) | ||||||
|  | │   │   ├── services/           # Lógica de llamadas a la API (axios) | ||||||
|  | │   │   ├── App.tsx             # Componente principal de la aplicación | ||||||
|  | │   │   └── main.tsx            # Punto de entrada de React | ||||||
|  | │   ├── .env.development        # Variables de entorno para desarrollo | ||||||
|  | │   ├── .env.production         # Variables de entorno para producción | ||||||
|  | │   └── Dockerfile              # Instrucciones para construir la imagen del frontend | ||||||
|  | ├── nginx/ | ||||||
|  | │   └── nginx.conf              # Configuración de Nginx como proxy inverso | ||||||
|  | └── docker-compose.yml          # Orquestación de los contenedores | ||||||
|  | ``` | ||||||
							
								
								
									
										26
									
								
								backend/src/Titulares.Api/Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								backend/src/Titulares.Api/Dockerfile
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | |||||||
|  | # Etapa 1: Compilación | ||||||
|  | FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build | ||||||
|  | WORKDIR /src | ||||||
|  |  | ||||||
|  | # 1. Copia solo el archivo .csproj. | ||||||
|  | # La ruta de origen es "Titulares.Api.csproj" porque está en la raíz del contexto. | ||||||
|  | COPY Titulares.Api.csproj . | ||||||
|  |  | ||||||
|  | # 2. Restaura las dependencias. Docker guardará esta capa en caché. | ||||||
|  | RUN dotnet restore "Titulares.Api.csproj" | ||||||
|  |  | ||||||
|  | # 3. Copia el resto de los archivos del proyecto. | ||||||
|  | COPY . . | ||||||
|  |  | ||||||
|  | # 4. Publica la aplicación. | ||||||
|  | # --no-restore se usa porque ya hemos restaurado las dependencias. | ||||||
|  | RUN dotnet publish "Titulares.Api.csproj" -c Release -o /app/publish --no-restore | ||||||
|  |  | ||||||
|  | # Etapa 2: Imagen Final | ||||||
|  | FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final | ||||||
|  | WORKDIR /app | ||||||
|  | COPY --from=build /app/publish . | ||||||
|  |  | ||||||
|  | EXPOSE 8080 | ||||||
|  | ENV ASPNETCORE_ENVIRONMENT=Production | ||||||
|  | ENTRYPOINT ["dotnet", "Titulares.Api.dll"] | ||||||
| @@ -1,12 +1,30 @@ | |||||||
| // backend/src/Titulares.Api/Hubs/TitularesHub.cs | // backend/src/Titulares.Api/Hubs/TitularesHub.cs | ||||||
|  |  | ||||||
| using Microsoft.AspNetCore.SignalR; | using Microsoft.AspNetCore.SignalR; | ||||||
|  | using Titulares.Api.Services; | ||||||
|  |  | ||||||
| namespace Titulares.Api.Hubs; | namespace Titulares.Api.Hubs; | ||||||
|  |  | ||||||
| // Esta clase es el punto de conexión para los clientes de SignalR. |  | ||||||
| // No necesitamos añadirle métodos personalizados porque solo enviaremos |  | ||||||
| // mensajes desde el servidor hacia los clientes. |  | ||||||
| public class TitularesHub : Hub | public class TitularesHub : Hub | ||||||
| { | { | ||||||
|  |   private readonly EstadoProcesoService _estadoService; | ||||||
|  |  | ||||||
|  |   public TitularesHub(EstadoProcesoService estadoService) | ||||||
|  |   { | ||||||
|  |     _estadoService = estadoService; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Este método se ejecuta CADA VEZ que un nuevo cliente (pestaña) se conecta. | ||||||
|  |   public override Task OnConnectedAsync() | ||||||
|  |   { | ||||||
|  |     _estadoService.RegistrarConexion(); | ||||||
|  |     return base.OnConnectedAsync(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Este método se ejecuta CADA VEZ que un cliente (pestaña) se desconecta. | ||||||
|  |   public override Task OnDisconnectedAsync(Exception? exception) | ||||||
|  |   { | ||||||
|  |     _estadoService.RegistrarDesconexionYApagarSiEsElUltimo(); | ||||||
|  |     return base.OnDisconnectedAsync(exception); | ||||||
|  |   } | ||||||
| } | } | ||||||
| @@ -23,12 +23,15 @@ builder.Services.AddScoped<CsvService>(); | |||||||
| builder.Services.AddSingleton<ConfiguracionRepositorio>(); | builder.Services.AddSingleton<ConfiguracionRepositorio>(); | ||||||
| builder.Services.AddSingleton<EstadoProcesoService>(); | builder.Services.AddSingleton<EstadoProcesoService>(); | ||||||
|  |  | ||||||
|  | // Obtener los orígenes permitidos desde la configuración | ||||||
|  | var allowedOrigins = builder.Configuration.GetValue<string>("AllowedOrigins")?.Split(',') ?? new[] { "http://localhost:5173" }; | ||||||
|  |  | ||||||
| // Añadimos la política de CORS | // Añadimos la política de CORS | ||||||
| builder.Services.AddCors(options => | builder.Services.AddCors(options => | ||||||
| { | { | ||||||
|     options.AddPolicy("AllowReactApp", builder => |     options.AddPolicy("AllowReactApp", policyBuilder => | ||||||
|     { |     { | ||||||
|         builder.WithOrigins("http://localhost:5173") |         policyBuilder.WithOrigins(allowedOrigins) | ||||||
|                .AllowAnyHeader() |                .AllowAnyHeader() | ||||||
|                .AllowAnyMethod() |                .AllowAnyMethod() | ||||||
|                .AllowCredentials(); |                .AllowCredentials(); | ||||||
|   | |||||||
| @@ -1,12 +1,11 @@ | |||||||
| // backend/src/Titulares.Api/Services/EstadoProcesoService.cs |  | ||||||
|  |  | ||||||
| namespace Titulares.Api.Services; | namespace Titulares.Api.Services; | ||||||
|  |  | ||||||
| public class EstadoProcesoService | public class EstadoProcesoService | ||||||
| { | { | ||||||
|   private volatile bool _estaActivo = false; |   private volatile bool _estaActivo = false; | ||||||
|  |   private volatile int _connectionCount = 0; | ||||||
|  |   private readonly object _lock = new object(); | ||||||
|  |  | ||||||
|   // 1. Definimos un evento público al que otros servicios pueden suscribirse. |  | ||||||
|   public event Action? OnStateChanged; |   public event Action? OnStateChanged; | ||||||
|  |  | ||||||
|   public bool EstaActivo() => _estaActivo; |   public bool EstaActivo() => _estaActivo; | ||||||
| @@ -14,14 +13,38 @@ public class EstadoProcesoService | |||||||
|   public void Activar() |   public void Activar() | ||||||
|   { |   { | ||||||
|     _estaActivo = true; |     _estaActivo = true; | ||||||
|     // 2. Disparamos el evento para notificar a los suscriptores. |  | ||||||
|     OnStateChanged?.Invoke(); |     OnStateChanged?.Invoke(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   public void Desactivar() |   public void Desactivar() | ||||||
|   { |   { | ||||||
|     _estaActivo = false; |     _estaActivo = false; | ||||||
|     // 3. Disparamos el evento también al desactivar. |  | ||||||
|     OnStateChanged?.Invoke(); |     OnStateChanged?.Invoke(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   public void RegistrarConexion() | ||||||
|  |   { | ||||||
|  |     lock (_lock) | ||||||
|  |     { | ||||||
|  |       _connectionCount++; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   public void RegistrarDesconexionYApagarSiEsElUltimo() | ||||||
|  |   { | ||||||
|  |     lock (_lock) | ||||||
|  |     { | ||||||
|  |       _connectionCount--; | ||||||
|  |       // Si el contador llega a 0, significa que no hay más clientes conectados. | ||||||
|  |       // Apagamos el proceso de forma segura. | ||||||
|  |       if (_connectionCount <= 0) | ||||||
|  |       { | ||||||
|  |         _connectionCount = 0; // Prevenir números negativos | ||||||
|  |         if (_estaActivo) | ||||||
|  |         { | ||||||
|  |           Desactivar(); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
| } | } | ||||||
							
								
								
									
										11
									
								
								backend/src/Titulares.Api/appsettings.Production.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								backend/src/Titulares.Api/appsettings.Production.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | |||||||
|  | { | ||||||
|  |   "ConnectionStrings": { | ||||||
|  |     "DefaultConnection": "Server=db-sqlserver;Database=TitularesDB;User Id=titularesApi;Password=PTP847Titulares;TrustServerCertificate=True;" | ||||||
|  |   }, | ||||||
|  |   "AllowedOrigins": "http://192.168.5.128:8905", | ||||||
|  |   "Logging": { | ||||||
|  |     "LogLevel": { | ||||||
|  |       "Default": "Warning"  | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -6,6 +6,7 @@ | |||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   "AllowedHosts": "*", |   "AllowedHosts": "*", | ||||||
|  |   "AllowedOrigins": "http://localhost:5173", | ||||||
|   "ConnectionStrings": { |   "ConnectionStrings": { | ||||||
|     "DefaultConnection": "Server=TECNICA3;Database=TitularesDB;User Id=titularesApi;Password=PTP847Titulares;Trusted_Connection=True;TrustServerCertificate=True;" |     "DefaultConnection": "Server=TECNICA3;Database=TitularesDB;User Id=titularesApi;Password=PTP847Titulares;Trusted_Connection=True;TrustServerCertificate=True;" | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| { | { | ||||||
|   "RutaCsv": "C:\\temp\\titulares.csv", |   "RutaCsv": "/data/titulares.csv", | ||||||
|   "IntervaloMinutos": 15, |   "IntervaloMinutos": 15, | ||||||
|   "CantidadTitularesAScrapear": 4, |   "CantidadTitularesAScrapear": 4, | ||||||
|   "ScrapingActivo": false |   "ScrapingActivo": false | ||||||
|   | |||||||
							
								
								
									
										46
									
								
								docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | |||||||
|  | services: | ||||||
|  |   # Servicio del Backend | ||||||
|  |   backend: | ||||||
|  |     build: | ||||||
|  |       context: ./backend/src/Titulares.Api | ||||||
|  |       dockerfile: Dockerfile | ||||||
|  |     container_name: titulares-api | ||||||
|  |     # No exponemos puertos al exterior, el proxy se encarga de eso. | ||||||
|  |     environment: | ||||||
|  |       # Le decimos a ASP.NET Core que escuche en el puerto 8080 en todas las interfaces de red | ||||||
|  |       - ASPNETCORE_URLS=http://+:8080 | ||||||
|  |     volumes: | ||||||
|  |       # Mapea la carpeta del host (que será gestionada por autofs)  | ||||||
|  |       # a una carpeta '/data' dentro de este contenedor. | ||||||
|  |       # La aplicación .NET ahora solo necesita escribir en '/data/titulares.csv'. | ||||||
|  |       - /mnt/autofs/titulares_csv_remoto:/data | ||||||
|  |     networks: | ||||||
|  |       - app-net | ||||||
|  |       - shared-net # Conectamos a la red externa para la DB | ||||||
|  |     restart: unless-stopped | ||||||
|  |  | ||||||
|  |   # Servicio de Nginx (Proxy Inverso + Servidor Frontend) | ||||||
|  |   nginx-proxy: | ||||||
|  |     build: | ||||||
|  |       context: ./frontend # Construimos desde la carpeta del frontend... | ||||||
|  |       dockerfile: Dockerfile # ...usando su Dockerfile para generar los estáticos | ||||||
|  |     container_name: titulares-proxy | ||||||
|  |     # Montamos nuestra configuración personalizada de Nginx | ||||||
|  |     volumes: | ||||||
|  |       - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf | ||||||
|  |     ports: | ||||||
|  |       # Mapeamos el puerto 8905 del host al puerto 80 del contenedor Nginx | ||||||
|  |       - "8905:80" | ||||||
|  |     networks: | ||||||
|  |       - app-net | ||||||
|  |     depends_on: | ||||||
|  |       - backend # Aseguramos que el backend se inicie antes que el proxy | ||||||
|  |     restart: unless-stopped | ||||||
|  |  | ||||||
|  | networks: | ||||||
|  |   # Red interna para la comunicación entre el proxy y el backend | ||||||
|  |   app-net: | ||||||
|  |     driver: bridge | ||||||
|  |   # Definimos la red compartida como externa, ya que fue creada por otro stack | ||||||
|  |   shared-net: | ||||||
|  |     external: true | ||||||
							
								
								
									
										1
									
								
								frontend/.env.development
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend/.env.development
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | VITE_API_BASE_URL=http://localhost:5174 | ||||||
							
								
								
									
										1
									
								
								frontend/.env.production
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend/.env.production
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | VITE_API_BASE_URL= | ||||||
							
								
								
									
										30
									
								
								frontend/Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								frontend/Dockerfile
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | |||||||
|  | # Etapa 1: Construcción de la aplicación React | ||||||
|  | # Usamos una imagen de Node.js (versión Alpine por ser ligera) | ||||||
|  | FROM node:20-alpine AS build | ||||||
|  | WORKDIR /app | ||||||
|  |  | ||||||
|  | # Copiamos package.json y package-lock.json para instalar dependencias | ||||||
|  | COPY package*.json ./ | ||||||
|  | RUN npm install | ||||||
|  |  | ||||||
|  | # Copiamos el resto de los archivos del frontend | ||||||
|  | COPY . . | ||||||
|  | # Construimos la aplicación para producción. Los archivos resultantes irán a /app/dist | ||||||
|  | RUN npm run build | ||||||
|  |  | ||||||
|  | # Etapa 2: Servidor Nginx | ||||||
|  | # Usamos la imagen oficial de Nginx (versión Alpine por ser ligera) | ||||||
|  | FROM nginx:stable-alpine | ||||||
|  | WORKDIR /usr/share/nginx/html | ||||||
|  |  | ||||||
|  | # Eliminamos el contenido por defecto de Nginx | ||||||
|  | RUN rm -rf ./* | ||||||
|  |  | ||||||
|  | # Copiamos los archivos estáticos construidos en la etapa anterior | ||||||
|  | COPY --from=build /app/dist . | ||||||
|  |  | ||||||
|  | # Exponemos el puerto 80, que es el puerto por defecto de Nginx | ||||||
|  | EXPOSE 80 | ||||||
|  |  | ||||||
|  | # El comando por defecto de la imagen de Nginx es suficiente para iniciar el servidor | ||||||
|  | # CMD ["nginx", "-g", "daemon off;"] | ||||||
| @@ -2,9 +2,9 @@ | |||||||
| <html lang="en"> | <html lang="en"> | ||||||
|   <head> |   <head> | ||||||
|     <meta charset="UTF-8" /> |     <meta charset="UTF-8" /> | ||||||
|     <link rel="icon" type="image/svg+xml" href="/vite.svg" /> |     <link rel="icon" type="image/svg+xml" href="/elDia.ico" /> | ||||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |     <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||||||
|     <title>frontend</title> |     <title>Titulares APP</title> | ||||||
|   </head> |   </head> | ||||||
|   <body> |   <body> | ||||||
|     <div id="root"></div> |     <div id="root"></div> | ||||||
|   | |||||||
							
								
								
									
										
											BIN
										
									
								
								frontend/public/elDia.ico
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend/public/elDia.ico
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 66 KiB | 
| @@ -2,6 +2,7 @@ | |||||||
|  |  | ||||||
| import { ThemeProvider, createTheme, CssBaseline, AppBar, Toolbar, Typography, Container, Box } from '@mui/material'; | import { ThemeProvider, createTheme, CssBaseline, AppBar, Toolbar, Typography, Container, Box } from '@mui/material'; | ||||||
| import Dashboard from './components/Dashboard'; | import Dashboard from './components/Dashboard'; | ||||||
|  | import { NotificationProvider } from './contexts/NotificationContext'; | ||||||
|  |  | ||||||
| // Paleta de colores ajustada para coincidir con la nueva imagen (inspirada en Tailwind) | // Paleta de colores ajustada para coincidir con la nueva imagen (inspirada en Tailwind) | ||||||
| const darkTheme = createTheme({ | const darkTheme = createTheme({ | ||||||
| @@ -99,9 +100,11 @@ function App() { | |||||||
|   return ( |   return ( | ||||||
|     <ThemeProvider theme={darkTheme}> |     <ThemeProvider theme={darkTheme}> | ||||||
|       <CssBaseline /> |       <CssBaseline /> | ||||||
|  |       <NotificationProvider> | ||||||
|         <Layout> |         <Layout> | ||||||
|           <Dashboard /> |           <Dashboard /> | ||||||
|         </Layout> |         </Layout> | ||||||
|  |       </NotificationProvider> | ||||||
|     </ThemeProvider> |     </ThemeProvider> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										46
									
								
								frontend/src/components/ConfirmationModal.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								frontend/src/components/ConfirmationModal.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | |||||||
|  | // frontend/src/components/ConfirmationModal.tsx | ||||||
|  |  | ||||||
|  | import { Modal, Box, Typography, Button, Stack } from '@mui/material'; | ||||||
|  |  | ||||||
|  | const style = { | ||||||
|  |   position: 'absolute' as 'absolute', | ||||||
|  |   top: '40%', | ||||||
|  |   left: '50%', | ||||||
|  |   transform: 'translate(-50%, -50%)', | ||||||
|  |   width: 400, | ||||||
|  |   bgcolor: 'background.paper', | ||||||
|  |   boxShadow: 24, | ||||||
|  |   p: 4, | ||||||
|  |   borderRadius: 1, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | interface Props { | ||||||
|  |   open: boolean; | ||||||
|  |   onClose: () => void; | ||||||
|  |   onConfirm: () => void; | ||||||
|  |   title: string; | ||||||
|  |   message: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const ConfirmationModal = ({ open, onClose, onConfirm, title, message }: Props) => { | ||||||
|  |   return ( | ||||||
|  |     <Modal open={open} onClose={onClose}> | ||||||
|  |       <Box sx={style}> | ||||||
|  |         <Typography variant="h6" component="h2" gutterBottom> | ||||||
|  |           {title} | ||||||
|  |         </Typography> | ||||||
|  |         <Typography sx={{ mb: 3, color: 'text.secondary' }}> | ||||||
|  |           {message} | ||||||
|  |         </Typography> | ||||||
|  |         <Stack direction="row" spacing={2} justifyContent="flex-end"> | ||||||
|  |           <Button onClick={onClose}>Cancelar</Button> | ||||||
|  |           <Button variant="contained" color="error" onClick={onConfirm}> | ||||||
|  |             Confirmar | ||||||
|  |           </Button> | ||||||
|  |         </Stack> | ||||||
|  |       </Box> | ||||||
|  |     </Modal> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default ConfirmationModal; | ||||||
| @@ -1,8 +1,7 @@ | |||||||
|  | // frontend/src/components/Dashboard.tsx | ||||||
|  |  | ||||||
| import { useEffect, useState, useCallback } from 'react'; | import { useEffect, useState, useCallback } from 'react'; | ||||||
| import { | import { Box, Button, Stack, Chip, CircularProgress, Accordion, AccordionSummary, AccordionDetails, Typography } from '@mui/material'; | ||||||
|   Box, Button, Stack, Chip, CircularProgress, |  | ||||||
|   Accordion, AccordionSummary, AccordionDetails, Typography |  | ||||||
| } from '@mui/material'; |  | ||||||
| import AddIcon from '@mui/icons-material/Add'; | import AddIcon from '@mui/icons-material/Add'; | ||||||
| import SyncIcon from '@mui/icons-material/Sync'; | import SyncIcon from '@mui/icons-material/Sync'; | ||||||
| import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; | import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; | ||||||
| @@ -10,19 +9,27 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; | |||||||
| import type { Titular, Configuracion } from '../types'; | import type { Titular, Configuracion } from '../types'; | ||||||
| import * as api from '../services/apiService'; | import * as api from '../services/apiService'; | ||||||
| import { useSignalR } from '../hooks/useSignalR'; | import { useSignalR } from '../hooks/useSignalR'; | ||||||
|  | import { useNotification } from '../hooks/useNotification'; | ||||||
| import FormularioConfiguracion from './FormularioConfiguracion'; | import FormularioConfiguracion from './FormularioConfiguracion'; | ||||||
| import TablaTitulares from './TablaTitulares'; | import TablaTitulares from './TablaTitulares'; | ||||||
| import AddTitularModal from './AddTitularModal'; | import AddTitularModal from './AddTitularModal'; | ||||||
| import EditarTitularModal from './EditarTitularModal'; | import EditarTitularModal from './EditarTitularModal'; | ||||||
| import { PowerSwitch } from './PowerSwitch'; | import { PowerSwitch } from './PowerSwitch'; | ||||||
|  | import ConfirmationModal from './ConfirmationModal'; | ||||||
|  | import type { ActualizarTitularPayload } from '../services/apiService'; | ||||||
|  | import { TableSkeleton } from './TableSkeleton'; | ||||||
|  |  | ||||||
| const Dashboard = () => { | const Dashboard = () => { | ||||||
|   const [titulares, setTitulares] = useState<Titular[]>([]); |   const [titulares, setTitulares] = useState<Titular[]>([]); | ||||||
|   const [config, setConfig] = useState<Configuracion | null>(null); |   const [config, setConfig] = useState<Configuracion | null>(null); | ||||||
|   const [addModalOpen, setAddModalOpen] = useState(false); |   const [addModalOpen, setAddModalOpen] = useState(false); | ||||||
|   const [isGeneratingCsv, setIsGeneratingCsv] = useState(false); |   const [isGeneratingCsv, setIsGeneratingCsv] = useState(false); | ||||||
|  |   const [confirmState, setConfirmState] = useState<{ open: boolean; onConfirm: (() => void) | null }>({ open: false, onConfirm: null }); | ||||||
|  |   const { showNotification } = useNotification(); | ||||||
|   const [titularAEditar, setTitularAEditar] = useState<Titular | null>(null); |   const [titularAEditar, setTitularAEditar] = useState<Titular | null>(null); | ||||||
|  |  | ||||||
|  |   const [isLoading, setIsLoading] = useState(true); | ||||||
|  |  | ||||||
|   const onTitularesActualizados = useCallback((titularesActualizados: Titular[]) => { |   const onTitularesActualizados = useCallback((titularesActualizados: Titular[]) => { | ||||||
|     setTitulares(titularesActualizados); |     setTitulares(titularesActualizados); | ||||||
|   }, []); |   }, []); | ||||||
| @@ -32,14 +39,15 @@ const Dashboard = () => { | |||||||
|   ]); |   ]); | ||||||
|  |  | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     // Obtenemos la configuración persistente |     // 1. Cargamos la configuración persistente | ||||||
|     const fetchConfig = api.obtenerConfiguracion(); |     const fetchConfig = api.obtenerConfiguracion(); | ||||||
|     // Obtenemos el estado inicial del switch (que siempre será 'false') |  | ||||||
|  |     // 2. Preguntamos al servidor por el estado ACTUAL del proceso | ||||||
|     const fetchEstado = api.getEstadoProceso(); |     const fetchEstado = api.getEstadoProceso(); | ||||||
|  |  | ||||||
|     // Cuando ambas promesas se resuelvan, construimos el estado inicial |  | ||||||
|     Promise.all([fetchConfig, fetchEstado]) |     Promise.all([fetchConfig, fetchEstado]) | ||||||
|       .then(([configData, estadoData]) => { |       .then(([configData, estadoData]) => { | ||||||
|  |         // Construimos el estado de la UI para que REFLEJE el estado real del servidor | ||||||
|         setConfig({ |         setConfig({ | ||||||
|           ...configData, |           ...configData, | ||||||
|           scrapingActivo: estadoData.activo |           scrapingActivo: estadoData.activo | ||||||
| @@ -47,19 +55,47 @@ const Dashboard = () => { | |||||||
|       }) |       }) | ||||||
|       .catch(error => console.error("Error al cargar datos iniciales:", error)); |       .catch(error => console.error("Error al cargar datos iniciales:", error)); | ||||||
|  |  | ||||||
|     api.obtenerTitulares().then(setTitulares); |     // La carga de titulares sigue igual | ||||||
|   }, []); |     api.obtenerTitulares() | ||||||
|  |       .then(setTitulares) | ||||||
|  |       .catch(error => console.error("Error al cargar titulares:", error)) | ||||||
|  |       .finally(() => setIsLoading(false)); | ||||||
|  |  | ||||||
|  |   }, []); // El array vacío asegura que esto solo se ejecute una vez | ||||||
|  |  | ||||||
|  |   const handleDelete = (id: number) => { | ||||||
|  |     const onConfirm = async () => { | ||||||
|  |       try { | ||||||
|  |         await api.eliminarTitular(id); | ||||||
|  |         showNotification('Titular eliminado correctamente', 'success'); | ||||||
|  |       } catch (err) { | ||||||
|  |         showNotification('Error al eliminar el titular', 'error'); | ||||||
|  |         console.error("Error al eliminar:", err); | ||||||
|  |       } finally { | ||||||
|  |         setConfirmState({ open: false, onConfirm: null }); | ||||||
|  |       } | ||||||
|  |     }; | ||||||
|  |     setConfirmState({ open: true, onConfirm }); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleSaveEdit = async (id: number, payload: ActualizarTitularPayload) => { | ||||||
|  |     try { | ||||||
|  |       await api.actualizarTitular(id, payload); | ||||||
|  |       showNotification('Titular actualizado', 'success'); | ||||||
|  |     } catch (err) { | ||||||
|  |       showNotification('Error al guardar los cambios', 'error'); | ||||||
|  |       console.error("Error al guardar cambios:", err); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|   const handleSwitchChange = async (event: React.ChangeEvent<HTMLInputElement>) => { |   const handleSwitchChange = async (event: React.ChangeEvent<HTMLInputElement>) => { | ||||||
|     if (!config) return; |     if (!config) return; | ||||||
|     const isChecked = event.target.checked; |     const isChecked = event.target.checked; | ||||||
|     setConfig({ ...config, scrapingActivo: isChecked }); |     setConfig({ ...config, scrapingActivo: isChecked }); | ||||||
|     try { |     try { | ||||||
|       // Llamamos al nuevo endpoint para cambiar solo el estado |  | ||||||
|       await api.setEstadoProceso(isChecked); |       await api.setEstadoProceso(isChecked); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       console.error("Error al cambiar estado del proceso", err); |       console.error("Error al cambiar estado del proceso", err); | ||||||
|       // Revertir en caso de error |  | ||||||
|       setConfig({ ...config, scrapingActivo: !isChecked }); |       setConfig({ ...config, scrapingActivo: !isChecked }); | ||||||
|     } |     } | ||||||
|   }; |   }; | ||||||
| @@ -75,20 +111,12 @@ const Dashboard = () => { | |||||||
|     } |     } | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   const handleDelete = async (id: number) => { |  | ||||||
|     if (window.confirm('¿Estás seguro de que quieres eliminar este titular?')) { |  | ||||||
|       try { |  | ||||||
|         await api.eliminarTitular(id); |  | ||||||
|       } catch (err) { |  | ||||||
|         console.error("Error al eliminar:", err); |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   }; |  | ||||||
|  |  | ||||||
|   const handleAdd = async (texto: string) => { |   const handleAdd = async (texto: string) => { | ||||||
|     try { |     try { | ||||||
|       await api.crearTitularManual(texto); |       await api.crearTitularManual(texto); | ||||||
|  |       showNotification('Titular manual añadido', 'success'); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|  |       showNotification('Error al añadir el titular', 'error'); | ||||||
|       console.error("Error al añadir titular:", err); |       console.error("Error al añadir titular:", err); | ||||||
|     } |     } | ||||||
|   }; |   }; | ||||||
| @@ -109,21 +137,15 @@ const Dashboard = () => { | |||||||
|     setIsGeneratingCsv(true); |     setIsGeneratingCsv(true); | ||||||
|     try { |     try { | ||||||
|       await api.generarCsvManual(); |       await api.generarCsvManual(); | ||||||
|  |       showNotification('CSV generado manualmente', 'success'); | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|       console.error("Error al generar CSV manually", error); |       showNotification('Error al generar el CSV', 'error'); | ||||||
|  |       console.error("Error al generar CSV manualmente", error); | ||||||
|     } finally { |     } finally { | ||||||
|       setIsGeneratingCsv(false); |       setIsGeneratingCsv(false); | ||||||
|     } |     } | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   const handleSaveEdit = async (id: number, texto: string, viñeta: string) => { |  | ||||||
|     try { |  | ||||||
|       await api.actualizarTitular(id, { texto, viñeta: viñeta || null }); |  | ||||||
|     } catch (err) { |  | ||||||
|       console.error("Error al guardar cambios:", err); |  | ||||||
|     } |  | ||||||
|   }; |  | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <> |     <> | ||||||
|       <Box |       <Box | ||||||
| @@ -176,23 +198,34 @@ const Dashboard = () => { | |||||||
|         </AccordionDetails> |         </AccordionDetails> | ||||||
|       </Accordion> |       </Accordion> | ||||||
|  |  | ||||||
|  |       {isLoading ? ( | ||||||
|  |         <TableSkeleton /> | ||||||
|  |       ) : ( | ||||||
|         <TablaTitulares |         <TablaTitulares | ||||||
|           titulares={titulares} |           titulares={titulares} | ||||||
|           onReorder={handleReorder} |           onReorder={handleReorder} | ||||||
|           onDelete={handleDelete} |           onDelete={handleDelete} | ||||||
|           onEdit={(titular) => setTitularAEditar(titular)} |           onEdit={(titular) => setTitularAEditar(titular)} | ||||||
|  |           onSave={handleSaveEdit} | ||||||
|         /> |         /> | ||||||
|  |       )} | ||||||
|  |  | ||||||
|       <AddTitularModal |       <AddTitularModal | ||||||
|         open={addModalOpen} |         open={addModalOpen} | ||||||
|         onClose={() => setAddModalOpen(false)} |         onClose={() => setAddModalOpen(false)} | ||||||
|         onAdd={handleAdd} |         onAdd={handleAdd} | ||||||
|       /> |       /> | ||||||
|  |       <ConfirmationModal | ||||||
|  |         open={confirmState.open} | ||||||
|  |         onClose={() => setConfirmState({ open: false, onConfirm: null })} | ||||||
|  |         onConfirm={() => confirmState.onConfirm?.()} | ||||||
|  |         title="Confirmar Eliminación" | ||||||
|  |         message="¿Estás seguro de que quieres eliminar este titular? Esta acción no se puede deshacer." | ||||||
|  |       /> | ||||||
|       <EditarTitularModal |       <EditarTitularModal | ||||||
|         open={titularAEditar !== null} |         open={titularAEditar !== null} | ||||||
|         onClose={() => setTitularAEditar(null)} |         onClose={() => setTitularAEditar(null)} | ||||||
|         onSave={handleSaveEdit} |         onSave={(id, texto, viñeta) => handleSaveEdit(id, { texto, viñeta: viñeta || null })} | ||||||
|         titular={titularAEditar} |         titular={titularAEditar} | ||||||
|       /> |       /> | ||||||
|     </> |     </> | ||||||
|   | |||||||
| @@ -1,7 +1,8 @@ | |||||||
| import { Box, TextField, Button, Paper, CircularProgress, Typography } from '@mui/material'; | import { Box, TextField, Paper, CircularProgress, Chip } from '@mui/material'; | ||||||
| import type { Configuracion } from '../types'; | import type { Configuracion } from '../types'; | ||||||
| import * as api from '../services/apiService'; | import * as api from '../services/apiService'; | ||||||
| import { useState } from 'react'; | import { useState, useEffect, useRef } from 'react'; | ||||||
|  | import { useDebounce } from '../hooks/useDebounce'; | ||||||
|  |  | ||||||
| interface Props { | interface Props { | ||||||
|   config: Configuracion | null; |   config: Configuracion | null; | ||||||
| @@ -9,61 +10,79 @@ interface Props { | |||||||
| } | } | ||||||
|  |  | ||||||
| const FormularioConfiguracion = ({ config, setConfig }: Props) => { | const FormularioConfiguracion = ({ config, setConfig }: Props) => { | ||||||
|   const [saving, setSaving] = useState(false); |   const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle'); | ||||||
|   const [success, setSuccess] = useState(false); |   const debouncedConfig = useDebounce(config, 750); | ||||||
|  |    | ||||||
|  |   const isInitialLoad = useRef(true); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     // Solo procedemos si tenemos un objeto de configuración "debounced" | ||||||
|  |     if (debouncedConfig) { | ||||||
|  |       // Si la 'ref' es true, significa que esta es la primera vez que recibimos | ||||||
|  |       // un objeto de configuración válido. Lo ignoramos y marcamos la carga inicial como completada. | ||||||
|  |       if (isInitialLoad.current) { | ||||||
|  |         isInitialLoad.current = false; | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       // Si la 'ref' ya es false, significa que cualquier cambio posterior | ||||||
|  |       // es una modificación real del usuario, por lo que procedemos a guardar. | ||||||
|  |       const saveConfig = async () => { | ||||||
|  |         setSaveStatus('saving'); | ||||||
|  |         try { | ||||||
|  |           await api.guardarConfiguracion(debouncedConfig); | ||||||
|  |           setSaveStatus('saved'); | ||||||
|  |           setTimeout(() => { | ||||||
|  |             setSaveStatus('idle'); | ||||||
|  |           }, 2000); | ||||||
|  |         } catch (err) { | ||||||
|  |           console.error("Error en el auto-guardado:", err); | ||||||
|  |           setSaveStatus('idle'); | ||||||
|  |         } | ||||||
|  |       }; | ||||||
|  |        | ||||||
|  |       saveConfig(); | ||||||
|  |     } | ||||||
|  |   }, [debouncedConfig]); // La dependencia sigue siendo la misma | ||||||
|  |  | ||||||
|  |  | ||||||
|   if (!config) { |   if (!config) { | ||||||
|     return <CircularProgress />; |     return <CircularProgress />; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { |   const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { | ||||||
|  |     setSaveStatus('idle');  | ||||||
|     const { name, value } = event.target; |     const { name, value } = event.target; | ||||||
|     const numericFields = ['intervaloMinutos', 'cantidadTitularesAScrapear']; |     const numericFields = ['intervaloMinutos', 'cantidadTitularesAScrapear']; | ||||||
|  |  | ||||||
|     setConfig(prevConfig => prevConfig ? { |     setConfig(prevConfig => prevConfig ? { | ||||||
|       ...prevConfig, |       ...prevConfig, | ||||||
|       [name]: numericFields.includes(name) ? Number(value) : value |       [name]: numericFields.includes(name) ? Number(value) : value | ||||||
|     } : null); |     } : null); | ||||||
|   }; |   }; | ||||||
|    |    | ||||||
|   const handleSubmit = async (event: React.FormEvent) => { |   const getSaveStatusIndicator = () => { | ||||||
|     event.preventDefault(); |     if (saveStatus === 'saving') { | ||||||
|     if (!config) return; |       return <Chip size="small" label="Guardando..." icon={<CircularProgress size={16} />} />; | ||||||
|     setSaving(true); |  | ||||||
|     setSuccess(false); |  | ||||||
|     try { |  | ||||||
|       await api.guardarConfiguracion(config); |  | ||||||
|       setSuccess(true); |  | ||||||
|       setTimeout(() => setSuccess(false), 2000); |  | ||||||
|     } catch (err) { |  | ||||||
|       console.error("Error al guardar configuración", err); |  | ||||||
|     } finally { |  | ||||||
|       setSaving(false); |  | ||||||
|     } |     } | ||||||
|  |     if (saveStatus === 'saved') { | ||||||
|  |       return <Chip size="small" label="Guardado" color="success" />; | ||||||
|  |     } | ||||||
|  |     return null; | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Paper elevation={0} sx={{ padding: 2 }}> |     <Paper elevation={0} sx={{ padding: 2 }}> | ||||||
|       <Box component="form" onSubmit={handleSubmit}> |       <Box component="form"> | ||||||
|         <TextField fullWidth name="rutaCsv" label="Ruta del archivo CSV" value={config.rutaCsv} onChange={handleChange} variant="outlined" sx={{ mb: 2 }} disabled={saving} /> |         <TextField fullWidth name="rutaCsv" label="Ruta del archivo CSV" value={config.rutaCsv} onChange={handleChange} sx={{ mb: 2 }} /> | ||||||
|         <TextField fullWidth name="intervaloMinutos" label="Intervalo de Actualización (minutos)" value={config.intervaloMinutos} onChange={handleChange} type="number" variant="outlined" sx={{ mb: 2 }} disabled={saving} /> |         <TextField fullWidth name="intervaloMinutos" label="Intervalo de Actualización (minutos)" value={config.intervaloMinutos} onChange={handleChange} type="number" sx={{ mb: 2 }} /> | ||||||
|         <TextField fullWidth name="cantidadTitularesAScrapear" label="Titulares a Capturar por Ciclo" value={config.cantidadTitularesAScrapear} onChange={handleChange} type="number" variant="outlined" sx={{ mb: 2 }} disabled={saving} /> |         <TextField fullWidth name="cantidadTitularesAScrapear" label="Titulares a Capturar por Ciclo" value={config.cantidadTitularesAScrapear} onChange={handleChange} type="number" sx={{ mb: 2 }} /> | ||||||
|         <TextField |         <TextField | ||||||
|           fullWidth |           fullWidth name="viñetaPorDefecto" label="Viñeta por Defecto" value={config.viñetaPorDefecto} | ||||||
|           name="viñetaPorDefecto" |           onChange={handleChange} sx={{ mb: 2 }} | ||||||
|           label="Viñeta por Defecto" |  | ||||||
|           value={config.viñetaPorDefecto} |  | ||||||
|           onChange={handleChange} |  | ||||||
|           variant="outlined" |  | ||||||
|           sx={{ mb: 2 }} |  | ||||||
|           disabled={saving} |  | ||||||
|           helperText="El símbolo a usar si un titular no tiene una viñeta específica." |           helperText="El símbolo a usar si un titular no tiene una viñeta específica." | ||||||
|         /> |         /> | ||||||
|         <Box sx={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center' }}> |         <Box sx={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', height: '36.5px' }}> | ||||||
|           {success && <Typography color="success.main" sx={{ mr: 2 }}>¡Guardado!</Typography>} |           {getSaveStatusIndicator()} | ||||||
|           <Button type="submit" variant="contained" disabled={saving}> |  | ||||||
|             {saving ? <CircularProgress size={24} /> : 'Guardar Cambios'} |  | ||||||
|           </Button> |  | ||||||
|         </Box> |         </Box> | ||||||
|       </Box> |       </Box> | ||||||
|     </Paper> |     </Paper> | ||||||
|   | |||||||
| @@ -1,8 +1,7 @@ | |||||||
| // frontend/src/components/TablaTitulares.tsx | // frontend/src/components/TablaTitulares.tsx | ||||||
|  |  | ||||||
| import { | import { useState } from 'react'; | ||||||
|   Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Chip, IconButton, Typography, Link | import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Chip, IconButton, Typography, Link, TextField, Tooltip } from '@mui/material'; // <-- Añadir Tooltip | ||||||
| } from '@mui/material'; |  | ||||||
| import DeleteIcon from '@mui/icons-material/Delete'; | import DeleteIcon from '@mui/icons-material/Delete'; | ||||||
| import DragHandleIcon from '@mui/icons-material/DragHandle'; | import DragHandleIcon from '@mui/icons-material/DragHandle'; | ||||||
| import EditIcon from '@mui/icons-material/Edit'; | import EditIcon from '@mui/icons-material/Edit'; | ||||||
| @@ -10,19 +9,27 @@ import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, type D | |||||||
| import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; | import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; | ||||||
| import { CSS } from '@dnd-kit/utilities'; | import { CSS } from '@dnd-kit/utilities'; | ||||||
| import type { Titular } from '../types'; | import type { Titular } from '../types'; | ||||||
|  | import type { ActualizarTitularPayload } from '../services/apiService'; | ||||||
|  |  | ||||||
| interface SortableRowProps { | interface SortableRowProps { | ||||||
|   titular: Titular; |   titular: Titular; | ||||||
|   onDelete: (id: number) => void; |   onDelete: (id: number) => void; | ||||||
|  |   onSave: (id: number, payload: ActualizarTitularPayload) => void; | ||||||
|   onEdit: (titular: Titular) => void; |   onEdit: (titular: Titular) => void; | ||||||
| } | } | ||||||
|  |  | ||||||
| const SortableRow = ({ titular, onDelete, onEdit }: SortableRowProps) => { | const SortableRow = ({ titular, onDelete, onSave, onEdit }: SortableRowProps) => { | ||||||
|   const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: titular.id }); |   const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: titular.id }); | ||||||
|  |   const [isEditing, setIsEditing] = useState(false); | ||||||
|  |   const [editText, setEditText] = useState(titular.texto); | ||||||
|  |  | ||||||
|   const style = { |   const style = { transform: CSS.Transform.toString(transform), transition }; | ||||||
|     transform: CSS.Transform.toString(transform), |  | ||||||
|     transition, |   const handleSave = () => { | ||||||
|  |     if (editText.trim() && editText.trim() !== titular.texto) { | ||||||
|  |       onSave(titular.id, { texto: editText.trim(), viñeta: titular.viñeta }); | ||||||
|  |     } | ||||||
|  |     setIsEditing(false); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   const getChipColor = (tipo: Titular['tipo']): "success" | "warning" | "info" => { |   const getChipColor = (tipo: Titular['tipo']): "success" | "warning" | "info" => { | ||||||
| @@ -42,15 +49,43 @@ const SortableRow = ({ titular, onDelete, onEdit }: SortableRowProps) => { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <TableRow ref={setNodeRef} style={style} {...attributes} sx={{ '&:last-child td, &:last-child th': { border: 0 } }}> |     <TableRow | ||||||
|       <TableCell sx={{ cursor: 'grab', verticalAlign: 'middle' }} {...listeners}> |       ref={setNodeRef} | ||||||
|  |       style={style} | ||||||
|  |       {...attributes} | ||||||
|  |       sx={{ | ||||||
|  |         '&:hover': { | ||||||
|  |           backgroundColor: 'action.hover' | ||||||
|  |         }, | ||||||
|  |         // Oculta el borde de la última fila | ||||||
|  |         '&:last-child td, &:last-child th': { border: 0 }, | ||||||
|  |       }} | ||||||
|  |     > | ||||||
|  |       <TableCell sx={{ padding: '8px 16px', cursor: 'grab', verticalAlign: 'middle' }} {...listeners}> | ||||||
|  |         <Tooltip title="Arrastrar para reordenar" placement="top"> | ||||||
|           <DragHandleIcon sx={{ color: 'text.secondary' }} /> |           <DragHandleIcon sx={{ color: 'text.secondary' }} /> | ||||||
|  |         </Tooltip> | ||||||
|       </TableCell> |       </TableCell> | ||||||
|       <TableCell sx={{ verticalAlign: 'middle' }}>{titular.texto}</TableCell> |       <TableCell sx={{ padding: '8px 16px', verticalAlign: 'middle' }} onClick={() => setIsEditing(true)}> | ||||||
|       <TableCell sx={{ verticalAlign: 'middle' }}> |         {isEditing ? ( | ||||||
|  |           <TextField | ||||||
|  |             fullWidth | ||||||
|  |             autoFocus | ||||||
|  |             variant="standard" | ||||||
|  |             value={editText} | ||||||
|  |             onChange={(e) => setEditText(e.target.value)} | ||||||
|  |             onBlur={handleSave} | ||||||
|  |             onKeyDown={(e) => { if (e.key === 'Enter') handleSave(); }} | ||||||
|  |             onClick={(e) => e.stopPropagation()} | ||||||
|  |           /> | ||||||
|  |         ) : ( | ||||||
|  |           <Typography variant="body2">{titular.texto}</Typography> | ||||||
|  |         )} | ||||||
|  |       </TableCell> | ||||||
|  |       <TableCell sx={{ padding: '8px 16px', verticalAlign: 'middle' }}> | ||||||
|         <Chip label={titular.tipo || 'Scraped'} color={getChipColor(titular.tipo)} size="small" /> |         <Chip label={titular.tipo || 'Scraped'} color={getChipColor(titular.tipo)} size="small" /> | ||||||
|       </TableCell> |       </TableCell> | ||||||
|       <TableCell sx={{ verticalAlign: 'middle' }}> |       <TableCell sx={{ padding: '8px 16px', verticalAlign: 'middle' }}> | ||||||
|         {titular.urlFuente ? ( |         {titular.urlFuente ? ( | ||||||
|           <Link href={titular.urlFuente} target="_blank" rel="noopener noreferrer" underline="hover" color="primary.light"> |           <Link href={titular.urlFuente} target="_blank" rel="noopener noreferrer" underline="hover" color="primary.light"> | ||||||
|             {formatFuente(titular.fuente)} |             {formatFuente(titular.fuente)} | ||||||
| @@ -59,13 +94,17 @@ const SortableRow = ({ titular, onDelete, onEdit }: SortableRowProps) => { | |||||||
|           formatFuente(titular.fuente) |           formatFuente(titular.fuente) | ||||||
|         )} |         )} | ||||||
|       </TableCell> |       </TableCell> | ||||||
|       <TableCell sx={{ verticalAlign: 'middle', textAlign: 'right' }}> |       <TableCell sx={{ padding: '8px 16px', verticalAlign: 'middle', textAlign: 'right' }}> | ||||||
|  |         <Tooltip title="Editar viñeta" placement="top"> | ||||||
|           <IconButton size="small" onClick={(e) => { e.stopPropagation(); onEdit(titular); }}> |           <IconButton size="small" onClick={(e) => { e.stopPropagation(); onEdit(titular); }}> | ||||||
|             <EditIcon fontSize="small" /> |             <EditIcon fontSize="small" /> | ||||||
|           </IconButton> |           </IconButton> | ||||||
|  |         </Tooltip> | ||||||
|  |         <Tooltip title="Eliminar titular" placement="top"> | ||||||
|           <IconButton size="small" onClick={(e) => { e.stopPropagation(); onDelete(titular.id); }} sx={{ color: '#ef4444' }}> |           <IconButton size="small" onClick={(e) => { e.stopPropagation(); onDelete(titular.id); }} sx={{ color: '#ef4444' }}> | ||||||
|             <DeleteIcon /> |             <DeleteIcon /> | ||||||
|           </IconButton> |           </IconButton> | ||||||
|  |         </Tooltip> | ||||||
|       </TableCell> |       </TableCell> | ||||||
|     </TableRow> |     </TableRow> | ||||||
|   ); |   ); | ||||||
| @@ -76,9 +115,10 @@ interface TablaTitularesProps { | |||||||
|   onReorder: (titulares: Titular[]) => void; |   onReorder: (titulares: Titular[]) => void; | ||||||
|   onDelete: (id: number) => void; |   onDelete: (id: number) => void; | ||||||
|   onEdit: (titular: Titular) => void; |   onEdit: (titular: Titular) => void; | ||||||
|  |   onSave: (id: number, payload: ActualizarTitularPayload) => void; | ||||||
| } | } | ||||||
|  |  | ||||||
| const TablaTitulares = ({ titulares, onReorder, onDelete, onEdit }: TablaTitularesProps) => { | const TablaTitulares = ({ titulares, onReorder, onDelete, onEdit, onSave }: TablaTitularesProps) => { | ||||||
|   const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } })); |   const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } })); | ||||||
|  |  | ||||||
|   const handleDragEnd = (event: DragEndEvent) => { |   const handleDragEnd = (event: DragEndEvent) => { | ||||||
| @@ -105,7 +145,7 @@ const TablaTitulares = ({ titulares, onReorder, onDelete, onEdit }: TablaTitular | |||||||
|           <SortableContext items={titulares.map(t => t.id)} strategy={verticalListSortingStrategy}> |           <SortableContext items={titulares.map(t => t.id)} strategy={verticalListSortingStrategy}> | ||||||
|             <Table> |             <Table> | ||||||
|               <TableHead> |               <TableHead> | ||||||
|                 <TableRow sx={{ '& .MuiTableCell-root': { borderBottom: '1px solid rgba(255, 255, 255, 0.12)' } }}> |                 <TableRow sx={{ '& .MuiTableCell-root': { padding: '6px 16px', borderBottom: '1px solid rgba(255, 255, 255, 0.12)' } }}> | ||||||
|                   <TableCell sx={{ width: 50 }} /> |                   <TableCell sx={{ width: 50 }} /> | ||||||
|                   <TableCell sx={{ textTransform: 'uppercase', color: 'text.secondary', letterSpacing: '0.05em' }}>Texto del Titular</TableCell> |                   <TableCell sx={{ textTransform: 'uppercase', color: 'text.secondary', letterSpacing: '0.05em' }}>Texto del Titular</TableCell> | ||||||
|                   <TableCell sx={{ textTransform: 'uppercase', color: 'text.secondary', letterSpacing: '0.05em' }}>Tipo</TableCell> |                   <TableCell sx={{ textTransform: 'uppercase', color: 'text.secondary', letterSpacing: '0.05em' }}>Tipo</TableCell> | ||||||
| @@ -113,9 +153,9 @@ const TablaTitulares = ({ titulares, onReorder, onDelete, onEdit }: TablaTitular | |||||||
|                   <TableCell sx={{ textTransform: 'uppercase', color: 'text.secondary', letterSpacing: '0.05em', textAlign: 'right' }}>Acciones</TableCell> |                   <TableCell sx={{ textTransform: 'uppercase', color: 'text.secondary', letterSpacing: '0.05em', textAlign: 'right' }}>Acciones</TableCell> | ||||||
|                 </TableRow> |                 </TableRow> | ||||||
|               </TableHead> |               </TableHead> | ||||||
|               <TableBody> |               <TableBody sx={{ '& .MuiTableRow-root:nth-of-type(odd)': { backgroundColor: 'action.focus' } }}> | ||||||
|                 {titulares.map((titular) => ( |                 {titulares.map((titular) => ( | ||||||
|                   <SortableRow key={titular.id} titular={titular} onDelete={onDelete} onEdit={onEdit} /> |                   <SortableRow key={titular.id} titular={titular} onDelete={onDelete} onEdit={onEdit} onSave={onSave} /> | ||||||
|                 ))} |                 ))} | ||||||
|               </TableBody> |               </TableBody> | ||||||
|             </Table> |             </Table> | ||||||
|   | |||||||
							
								
								
									
										51
									
								
								frontend/src/components/TableSkeleton.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								frontend/src/components/TableSkeleton.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | |||||||
|  | // frontend/src/components/TableSkeleton.tsx | ||||||
|  |  | ||||||
|  | import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Skeleton } from '@mui/material'; | ||||||
|  |  | ||||||
|  | // Un componente para una única fila de esqueleto | ||||||
|  | const SkeletonRow = () => ( | ||||||
|  |   <TableRow> | ||||||
|  |     <TableCell sx={{ padding: '8px 16px', width: 50 }}> | ||||||
|  |       <Skeleton variant="circular" width={24} height={24} /> | ||||||
|  |     </TableCell> | ||||||
|  |     <TableCell sx={{ padding: '8px 16px' }}> | ||||||
|  |       <Skeleton variant="text" sx={{ fontSize: '1rem' }} /> | ||||||
|  |     </TableCell> | ||||||
|  |     <TableCell sx={{ padding: '8px 16px' }}> | ||||||
|  |       <Skeleton variant="rounded" width={60} height={22} /> | ||||||
|  |     </TableCell> | ||||||
|  |     <TableCell sx={{ padding: '8px 16px' }}> | ||||||
|  |       <Skeleton variant="text" width={80} /> | ||||||
|  |     </TableCell> | ||||||
|  |     <TableCell sx={{ padding: '8px 16px' }} align="right"> | ||||||
|  |       <Skeleton variant="text" width={60} /> | ||||||
|  |     </TableCell> | ||||||
|  |   </TableRow> | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | // El componente principal que renderiza la tabla fantasma | ||||||
|  | export const TableSkeleton = () => { | ||||||
|  |   return ( | ||||||
|  |     <Paper elevation={0} sx={{ overflow: 'hidden' }}> | ||||||
|  |       <TableContainer> | ||||||
|  |         <Table> | ||||||
|  |           <TableHead> | ||||||
|  |             <TableRow sx={{ '& .MuiTableCell-root': { padding: '6px 16px', borderBottom: '1px solid rgba(255, 255, 255, 0.12)' } }}> | ||||||
|  |               <TableCell sx={{ width: 50 }} /> | ||||||
|  |               <TableCell sx={{ textTransform: 'uppercase', color: 'text.secondary', letterSpacing: '0.05em' }}>Texto del Titular</TableCell> | ||||||
|  |               <TableCell sx={{ textTransform: 'uppercase', color: 'text.secondary', letterSpacing: '0.05em' }}>Tipo</TableCell> | ||||||
|  |               <TableCell sx={{ textTransform: 'uppercase', color: 'text.secondary', letterSpacing: '0.05em' }}>Fuente</TableCell> | ||||||
|  |               <TableCell sx={{ textTransform: 'uppercase', color: 'text.secondary', letterSpacing: '0.05em', textAlign: 'right' }}>Acciones</TableCell> | ||||||
|  |             </TableRow> | ||||||
|  |           </TableHead> | ||||||
|  |           <TableBody> | ||||||
|  |             {/* Creamos un array de 5 elementos para renderizar 5 filas de esqueleto */} | ||||||
|  |             {[...Array(5)].map((_, index) => ( | ||||||
|  |               <SkeletonRow key={index} /> | ||||||
|  |             ))} | ||||||
|  |           </TableBody> | ||||||
|  |         </Table> | ||||||
|  |       </TableContainer> | ||||||
|  |     </Paper> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
							
								
								
									
										51
									
								
								frontend/src/contexts/NotificationContext.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								frontend/src/contexts/NotificationContext.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | |||||||
|  | // frontend/src/contexts/NotificationContext.tsx | ||||||
|  |  | ||||||
|  | import { createContext, useState, useCallback, type ReactNode } from 'react'; | ||||||
|  | import { Snackbar, Alert, type AlertColor } from '@mui/material'; | ||||||
|  |  | ||||||
|  | // Definimos la forma de la función que nuestro contexto expondrá | ||||||
|  | interface NotificationContextType { | ||||||
|  |   showNotification: (message: string, severity?: AlertColor) => void; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Creamos el contexto con un valor por defecto (una función vacía para evitar errores) | ||||||
|  | export const NotificationContext = createContext<NotificationContextType>({ | ||||||
|  |   showNotification: () => { }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Este es el componente Proveedor que envolverá nuestra aplicación | ||||||
|  | export const NotificationProvider = ({ children }: { children: ReactNode }) => { | ||||||
|  |   const [open, setOpen] = useState(false); | ||||||
|  |   const [message, setMessage] = useState(''); | ||||||
|  |   const [severity, setSeverity] = useState<AlertColor>('info'); | ||||||
|  |  | ||||||
|  |   const handleClose = (_event?: React.SyntheticEvent | Event, reason?: string) => { | ||||||
|  |     if (reason === 'clickaway') { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     setOpen(false); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   // Usamos useCallback para que la referencia a esta función no cambie en cada render | ||||||
|  |   const showNotification = useCallback((msg: string, sev: AlertColor = 'info') => { | ||||||
|  |     setMessage(msg); | ||||||
|  |     setSeverity(sev); | ||||||
|  |     setOpen(true); | ||||||
|  |   }, []); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <NotificationContext.Provider value={{ showNotification }}> | ||||||
|  |       {children} | ||||||
|  |       <Snackbar | ||||||
|  |         open={open} | ||||||
|  |         autoHideDuration={6000} // La notificación desaparece después de 6 segundos | ||||||
|  |         onClose={handleClose} | ||||||
|  |         anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} // Posición | ||||||
|  |       > | ||||||
|  |         <Alert onClose={handleClose} severity={severity} sx={{ width: '100%' }}> | ||||||
|  |           {message} | ||||||
|  |         </Alert> | ||||||
|  |       </Snackbar> | ||||||
|  |     </NotificationContext.Provider> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
							
								
								
									
										27
									
								
								frontend/src/hooks/useDebounce.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								frontend/src/hooks/useDebounce.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | |||||||
|  | // frontend/src/hooks/useDebounce.ts | ||||||
|  |  | ||||||
|  | import { useState, useEffect } from 'react'; | ||||||
|  |  | ||||||
|  | // Este hook toma un valor y un retardo (delay) en milisegundos. | ||||||
|  | // Devuelve una nueva versión del valor que solo se actualiza | ||||||
|  | // después de que el valor original no haya cambiado durante el 'delay' especificado. | ||||||
|  | export function useDebounce<T>(value: T, delay: number): T { | ||||||
|  |   const [debouncedValue, setDebouncedValue] = useState<T>(value); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     // Configura un temporizador para actualizar el valor "debounced" | ||||||
|  |     // después de que pase el tiempo de 'delay'. | ||||||
|  |     const handler = setTimeout(() => { | ||||||
|  |       setDebouncedValue(value); | ||||||
|  |     }, delay); | ||||||
|  |  | ||||||
|  |     // Función de limpieza: Si el 'value' cambia (porque el usuario sigue escribiendo), | ||||||
|  |     // este return se ejecuta primero, limpiando el temporizador anterior. | ||||||
|  |     // Esto previene que el valor se actualice mientras el usuario sigue interactuando. | ||||||
|  |     return () => { | ||||||
|  |       clearTimeout(handler); | ||||||
|  |     }; | ||||||
|  |   }, [value, delay]); // El efecto se vuelve a ejecutar solo si el valor o el retardo cambian. | ||||||
|  |  | ||||||
|  |   return debouncedValue; | ||||||
|  | } | ||||||
							
								
								
									
										8
									
								
								frontend/src/hooks/useNotification.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								frontend/src/hooks/useNotification.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | |||||||
|  | // frontend/src/hooks/useNotification.ts | ||||||
|  |  | ||||||
|  | import { useContext } from 'react'; | ||||||
|  | import { NotificationContext } from '../contexts/NotificationContext'; | ||||||
|  |  | ||||||
|  | export const useNotification = () => { | ||||||
|  |   return useContext(NotificationContext); | ||||||
|  | }; | ||||||
| @@ -3,7 +3,10 @@ | |||||||
| import { useEffect, useRef, useState } from 'react'; | import { useEffect, useRef, useState } from 'react'; | ||||||
| import * as signalR from '@microsoft/signalr'; | import * as signalR from '@microsoft/signalr'; | ||||||
|  |  | ||||||
| const HUB_URL = 'http://localhost:5174/titularesHub'; | // La URL base viene de la variable de entorno | ||||||
|  | const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''; | ||||||
|  | // Construimos la URL completa del Hub | ||||||
|  | const HUB_URL = `${API_BASE_URL}/titularesHub`; | ||||||
|  |  | ||||||
| // Definimos un tipo para el estado de la conexión para más claridad | // Definimos un tipo para el estado de la conexión para más claridad | ||||||
| export type ConnectionStatus = 'Connecting' | 'Connected' | 'Disconnected' | 'Reconnecting'; | export type ConnectionStatus = 'Connecting' | 'Connected' | 'Disconnected' | 'Reconnecting'; | ||||||
|   | |||||||
| @@ -3,10 +3,12 @@ | |||||||
| import axios from 'axios'; | import axios from 'axios'; | ||||||
| import type { Configuracion, Titular } from '../types'; | import type { Configuracion, Titular } from '../types'; | ||||||
|  |  | ||||||
| const API_URL = 'http://localhost:5174/api'; | // La URL base viene de la variable de entorno | ||||||
|  | const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''; | ||||||
|  |  | ||||||
| const apiClient = axios.create({ | const apiClient = axios.create({ | ||||||
|   baseURL: API_URL, |   // Construimos la URL completa para las llamadas a la API REST | ||||||
|  |   baseURL: `${API_BASE_URL}/api`, | ||||||
|   headers: { 'Content-Type': 'application/json' }, |   headers: { 'Content-Type': 'application/json' }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										46
									
								
								nginx/nginx.conf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								nginx/nginx.conf
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | |||||||
|  | # Define un "upstream" que apunta a nuestro servicio de backend. | ||||||
|  | # 'backend' será el nombre del servicio en docker-compose, y 8080 es el puerto | ||||||
|  | # que expusimos en su Dockerfile. | ||||||
|  | upstream backend_api { | ||||||
|  |     server backend:8080; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | server { | ||||||
|  |     # Nginx escuchará en el puerto 80 DENTRO del contenedor. | ||||||
|  |     listen 80; | ||||||
|  |     server_name 192.168.5.128; # Opcional, puedes usar localhost o tu dominio | ||||||
|  |  | ||||||
|  |     # Ubicación raíz: sirve los archivos de la aplicación React. | ||||||
|  |     location / { | ||||||
|  |         root /usr/share/nginx/html; | ||||||
|  |         try_files $uri /index.html; # Clave para Single Page Applications (SPA) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     # Ubicación para la API REST. | ||||||
|  |     # Todas las peticiones a http://.../api/... serán redirigidas al backend. | ||||||
|  |     location /api { | ||||||
|  |         proxy_pass http://backend_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; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     # ----------- CONFIGURACIÓN CRÍTICA PARA SIGNALR ----------- | ||||||
|  |     # Ubicación para el Hub de SignalR. | ||||||
|  |     location /titularesHub { | ||||||
|  |         proxy_pass http://backend_api; | ||||||
|  |  | ||||||
|  |         # Requerido para que la conexión WebSocket funcione | ||||||
|  |         proxy_http_version 1.1; | ||||||
|  |         proxy_set_header Upgrade $http_upgrade; | ||||||
|  |         proxy_set_header Connection "Upgrade"; | ||||||
|  |         proxy_set_header Host $host; | ||||||
|  |          | ||||||
|  |         # Aumenta el timeout para conexiones de larga duración | ||||||
|  |         proxy_read_timeout 90s; | ||||||
|  |  | ||||||
|  |         # Evita que el proxy almacene en caché la negociación de WebSockets | ||||||
|  |         proxy_cache_bypass $http_upgrade; | ||||||
|  |     } | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user