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
|
||||
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Titulares.Api.Services;
|
||||
|
||||
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
|
||||
{
|
||||
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<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
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy("AllowReactApp", builder =>
|
||||
options.AddPolicy("AllowReactApp", policyBuilder =>
|
||||
{
|
||||
builder.WithOrigins("http://localhost:5173")
|
||||
policyBuilder.WithOrigins(allowedOrigins)
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()
|
||||
.AllowCredentials();
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
// backend/src/Titulares.Api/Services/EstadoProcesoService.cs
|
||||
|
||||
namespace Titulares.Api.Services;
|
||||
|
||||
public class EstadoProcesoService
|
||||
{
|
||||
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 bool EstaActivo() => _estaActivo;
|
||||
@@ -14,14 +13,38 @@ public class EstadoProcesoService
|
||||
public void Activar()
|
||||
{
|
||||
_estaActivo = true;
|
||||
// 2. Disparamos el evento para notificar a los suscriptores.
|
||||
OnStateChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void Desactivar()
|
||||
{
|
||||
_estaActivo = false;
|
||||
// 3. Disparamos el evento también al desactivar.
|
||||
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": "*",
|
||||
"AllowedOrigins": "http://localhost:5173",
|
||||
"ConnectionStrings": {
|
||||
"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,
|
||||
"CantidadTitularesAScrapear": 4,
|
||||
"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">
|
||||
<head>
|
||||
<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" />
|
||||
<title>frontend</title>
|
||||
<title>Titulares APP</title>
|
||||
</head>
|
||||
<body>
|
||||
<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 Dashboard from './components/Dashboard';
|
||||
import { NotificationProvider } from './contexts/NotificationContext';
|
||||
|
||||
// Paleta de colores ajustada para coincidir con la nueva imagen (inspirada en Tailwind)
|
||||
const darkTheme = createTheme({
|
||||
@@ -66,7 +67,7 @@ const darkTheme = createTheme({
|
||||
color: '#f59e0b',
|
||||
},
|
||||
// Chip 'Manual'
|
||||
colorInfo: {
|
||||
colorInfo: {
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.2)', // blue-500 con opacidad
|
||||
color: '#60a5fa',
|
||||
},
|
||||
@@ -99,9 +100,11 @@ function App() {
|
||||
return (
|
||||
<ThemeProvider theme={darkTheme}>
|
||||
<CssBaseline />
|
||||
<Layout>
|
||||
<Dashboard />
|
||||
</Layout>
|
||||
<NotificationProvider>
|
||||
<Layout>
|
||||
<Dashboard />
|
||||
</Layout>
|
||||
</NotificationProvider>
|
||||
</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 {
|
||||
Box, Button, Stack, Chip, CircularProgress,
|
||||
Accordion, AccordionSummary, AccordionDetails, Typography
|
||||
} from '@mui/material';
|
||||
import { Box, Button, Stack, Chip, CircularProgress, Accordion, AccordionSummary, AccordionDetails, Typography } from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import SyncIcon from '@mui/icons-material/Sync';
|
||||
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 * as api from '../services/apiService';
|
||||
import { useSignalR } from '../hooks/useSignalR';
|
||||
import { useNotification } from '../hooks/useNotification';
|
||||
import FormularioConfiguracion from './FormularioConfiguracion';
|
||||
import TablaTitulares from './TablaTitulares';
|
||||
import AddTitularModal from './AddTitularModal';
|
||||
import EditarTitularModal from './EditarTitularModal';
|
||||
import { PowerSwitch } from './PowerSwitch';
|
||||
import ConfirmationModal from './ConfirmationModal';
|
||||
import type { ActualizarTitularPayload } from '../services/apiService';
|
||||
import { TableSkeleton } from './TableSkeleton';
|
||||
|
||||
const Dashboard = () => {
|
||||
const [titulares, setTitulares] = useState<Titular[]>([]);
|
||||
const [config, setConfig] = useState<Configuracion | null>(null);
|
||||
const [addModalOpen, setAddModalOpen] = 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 [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const onTitularesActualizados = useCallback((titularesActualizados: Titular[]) => {
|
||||
setTitulares(titularesActualizados);
|
||||
}, []);
|
||||
@@ -32,14 +39,15 @@ const Dashboard = () => {
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// Obtenemos la configuración persistente
|
||||
// 1. Cargamos la configuración persistente
|
||||
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();
|
||||
|
||||
// Cuando ambas promesas se resuelvan, construimos el estado inicial
|
||||
Promise.all([fetchConfig, fetchEstado])
|
||||
.then(([configData, estadoData]) => {
|
||||
// Construimos el estado de la UI para que REFLEJE el estado real del servidor
|
||||
setConfig({
|
||||
...configData,
|
||||
scrapingActivo: estadoData.activo
|
||||
@@ -47,19 +55,47 @@ const Dashboard = () => {
|
||||
})
|
||||
.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>) => {
|
||||
if (!config) return;
|
||||
const isChecked = event.target.checked;
|
||||
setConfig({ ...config, scrapingActivo: isChecked });
|
||||
try {
|
||||
// Llamamos al nuevo endpoint para cambiar solo el estado
|
||||
await api.setEstadoProceso(isChecked);
|
||||
} catch (err) {
|
||||
console.error("Error al cambiar estado del proceso", err);
|
||||
// Revertir en caso de error
|
||||
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) => {
|
||||
try {
|
||||
await api.crearTitularManual(texto);
|
||||
showNotification('Titular manual añadido', 'success');
|
||||
} catch (err) {
|
||||
showNotification('Error al añadir el titular', 'error');
|
||||
console.error("Error al añadir titular:", err);
|
||||
}
|
||||
};
|
||||
@@ -109,21 +137,15 @@ const Dashboard = () => {
|
||||
setIsGeneratingCsv(true);
|
||||
try {
|
||||
await api.generarCsvManual();
|
||||
showNotification('CSV generado manualmente', 'success');
|
||||
} 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 {
|
||||
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 (
|
||||
<>
|
||||
<Box
|
||||
@@ -176,23 +198,34 @@ const Dashboard = () => {
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
<TablaTitulares
|
||||
titulares={titulares}
|
||||
onReorder={handleReorder}
|
||||
onDelete={handleDelete}
|
||||
onEdit={(titular) => setTitularAEditar(titular)}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<TableSkeleton />
|
||||
) : (
|
||||
<TablaTitulares
|
||||
titulares={titulares}
|
||||
onReorder={handleReorder}
|
||||
onDelete={handleDelete}
|
||||
onEdit={(titular) => setTitularAEditar(titular)}
|
||||
onSave={handleSaveEdit}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AddTitularModal
|
||||
open={addModalOpen}
|
||||
onClose={() => setAddModalOpen(false)}
|
||||
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
|
||||
open={titularAEditar !== null}
|
||||
onClose={() => setTitularAEditar(null)}
|
||||
onSave={handleSaveEdit}
|
||||
onSave={(id, texto, viñeta) => handleSaveEdit(id, { texto, viñeta: viñeta || null })}
|
||||
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 * as api from '../services/apiService';
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useDebounce } from '../hooks/useDebounce';
|
||||
|
||||
interface Props {
|
||||
config: Configuracion | null;
|
||||
@@ -9,61 +10,79 @@ interface Props {
|
||||
}
|
||||
|
||||
const FormularioConfiguracion = ({ config, setConfig }: Props) => {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
|
||||
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) {
|
||||
return <CircularProgress />;
|
||||
}
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSaveStatus('idle');
|
||||
const { name, value } = event.target;
|
||||
const numericFields = ['intervaloMinutos', 'cantidadTitularesAScrapear'];
|
||||
|
||||
setConfig(prevConfig => prevConfig ? {
|
||||
...prevConfig,
|
||||
[name]: numericFields.includes(name) ? Number(value) : value
|
||||
} : null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!config) return;
|
||||
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);
|
||||
const getSaveStatusIndicator = () => {
|
||||
if (saveStatus === 'saving') {
|
||||
return <Chip size="small" label="Guardando..." icon={<CircularProgress size={16} />} />;
|
||||
}
|
||||
if (saveStatus === 'saved') {
|
||||
return <Chip size="small" label="Guardado" color="success" />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ padding: 2 }}>
|
||||
<Box component="form" onSubmit={handleSubmit}>
|
||||
<TextField fullWidth name="rutaCsv" label="Ruta del archivo CSV" value={config.rutaCsv} onChange={handleChange} variant="outlined" sx={{ mb: 2 }} disabled={saving} />
|
||||
<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="cantidadTitularesAScrapear" label="Titulares a Capturar por Ciclo" value={config.cantidadTitularesAScrapear} onChange={handleChange} type="number" variant="outlined" sx={{ mb: 2 }} disabled={saving} />
|
||||
<Box component="form">
|
||||
<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" sx={{ mb: 2 }} />
|
||||
<TextField fullWidth name="cantidadTitularesAScrapear" label="Titulares a Capturar por Ciclo" value={config.cantidadTitularesAScrapear} onChange={handleChange} type="number" sx={{ mb: 2 }} />
|
||||
<TextField
|
||||
fullWidth
|
||||
name="viñetaPorDefecto"
|
||||
label="Viñeta por Defecto"
|
||||
value={config.viñetaPorDefecto}
|
||||
onChange={handleChange}
|
||||
variant="outlined"
|
||||
sx={{ mb: 2 }}
|
||||
disabled={saving}
|
||||
fullWidth name="viñetaPorDefecto" label="Viñeta por Defecto" value={config.viñetaPorDefecto}
|
||||
onChange={handleChange} sx={{ mb: 2 }}
|
||||
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' }}>
|
||||
{success && <Typography color="success.main" sx={{ mr: 2 }}>¡Guardado!</Typography>}
|
||||
<Button type="submit" variant="contained" disabled={saving}>
|
||||
{saving ? <CircularProgress size={24} /> : 'Guardar Cambios'}
|
||||
</Button>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', height: '36.5px' }}>
|
||||
{getSaveStatusIndicator()}
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// frontend/src/components/TablaTitulares.tsx
|
||||
|
||||
import {
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Chip, IconButton, Typography, Link
|
||||
} from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Chip, IconButton, Typography, Link, TextField, Tooltip } from '@mui/material'; // <-- Añadir Tooltip
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import DragHandleIcon from '@mui/icons-material/DragHandle';
|
||||
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 { CSS } from '@dnd-kit/utilities';
|
||||
import type { Titular } from '../types';
|
||||
import type { ActualizarTitularPayload } from '../services/apiService';
|
||||
|
||||
interface SortableRowProps {
|
||||
titular: Titular;
|
||||
onDelete: (id: number) => void;
|
||||
onSave: (id: number, payload: ActualizarTitularPayload) => 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 [isEditing, setIsEditing] = useState(false);
|
||||
const [editText, setEditText] = useState(titular.texto);
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
const style = { 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" => {
|
||||
@@ -42,15 +49,43 @@ const SortableRow = ({ titular, onDelete, onEdit }: SortableRowProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow ref={setNodeRef} style={style} {...attributes} sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
|
||||
<TableCell sx={{ cursor: 'grab', verticalAlign: 'middle' }} {...listeners}>
|
||||
<DragHandleIcon sx={{ color: 'text.secondary' }} />
|
||||
<TableRow
|
||||
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' }} />
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell sx={{ verticalAlign: 'middle' }}>{titular.texto}</TableCell>
|
||||
<TableCell sx={{ verticalAlign: 'middle' }}>
|
||||
<TableCell sx={{ padding: '8px 16px', verticalAlign: 'middle' }} onClick={() => setIsEditing(true)}>
|
||||
{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" />
|
||||
</TableCell>
|
||||
<TableCell sx={{ verticalAlign: 'middle' }}>
|
||||
<TableCell sx={{ padding: '8px 16px', verticalAlign: 'middle' }}>
|
||||
{titular.urlFuente ? (
|
||||
<Link href={titular.urlFuente} target="_blank" rel="noopener noreferrer" underline="hover" color="primary.light">
|
||||
{formatFuente(titular.fuente)}
|
||||
@@ -59,13 +94,17 @@ const SortableRow = ({ titular, onDelete, onEdit }: SortableRowProps) => {
|
||||
formatFuente(titular.fuente)
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell sx={{ verticalAlign: 'middle', textAlign: 'right' }}>
|
||||
<IconButton size="small" onClick={(e) => { e.stopPropagation(); onEdit(titular); }}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={(e) => { e.stopPropagation(); onDelete(titular.id); }} sx={{ color: '#ef4444' }}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
<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); }}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Eliminar titular" placement="top">
|
||||
<IconButton size="small" onClick={(e) => { e.stopPropagation(); onDelete(titular.id); }} sx={{ color: '#ef4444' }}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -76,9 +115,10 @@ interface TablaTitularesProps {
|
||||
onReorder: (titulares: Titular[]) => void;
|
||||
onDelete: (id: number) => 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 handleDragEnd = (event: DragEndEvent) => {
|
||||
@@ -105,7 +145,7 @@ const TablaTitulares = ({ titulares, onReorder, onDelete, onEdit }: TablaTitular
|
||||
<SortableContext items={titulares.map(t => t.id)} strategy={verticalListSortingStrategy}>
|
||||
<Table>
|
||||
<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={{ textTransform: 'uppercase', color: 'text.secondary', letterSpacing: '0.05em' }}>Texto del Titular</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>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
<TableBody sx={{ '& .MuiTableRow-root:nth-of-type(odd)': { backgroundColor: 'action.focus' } }}>
|
||||
{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>
|
||||
</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 * 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
|
||||
export type ConnectionStatus = 'Connecting' | 'Connected' | 'Disconnected' | 'Reconnecting';
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
import axios from 'axios';
|
||||
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({
|
||||
baseURL: API_URL,
|
||||
// Construimos la URL completa para las llamadas a la API REST
|
||||
baseURL: `${API_BASE_URL}/api`,
|
||||
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