diff --git a/backend/src/Titulares.Api/Controllers/AccionesController.cs b/backend/src/Titulares.Api/Controllers/AccionesController.cs index 36e2977..57ef12c 100644 --- a/backend/src/Titulares.Api/Controllers/AccionesController.cs +++ b/backend/src/Titulares.Api/Controllers/AccionesController.cs @@ -10,12 +10,17 @@ namespace Titulares.Api.Controllers; [Route("api/[controller]")] public class AccionesController : ControllerBase { - private readonly TitularRepositorio _repositorio; + private readonly TitularRepositorio _titularRepositorio; + private readonly ConfiguracionRepositorio _configRepositorio; private readonly CsvService _csvService; - public AccionesController(TitularRepositorio repositorio, CsvService csvService) + public AccionesController( + TitularRepositorio titularRepositorio, + ConfiguracionRepositorio configRepositorio, + CsvService csvService) { - _repositorio = repositorio; + _titularRepositorio = titularRepositorio; + _configRepositorio = configRepositorio; _csvService = csvService; } @@ -24,8 +29,13 @@ public class AccionesController : ControllerBase { try { - var titulares = await _repositorio.ObtenerTodosAsync(); - await _csvService.GenerarCsvAsync(titulares); + // Obtenemos tanto los titulares como la configuración más reciente de la DB + var titulares = await _titularRepositorio.ObtenerTodosAsync(); + var config = await _configRepositorio.ObtenerAsync(); + + // Pasamos ambos al servicio + await _csvService.GenerarCsvAsync(titulares, config); + return Ok(new { message = "CSV generado manualmente con éxito." }); } catch (Exception ex) diff --git a/backend/src/Titulares.Api/Controllers/ConfiguracionController.cs b/backend/src/Titulares.Api/Controllers/ConfiguracionController.cs index 3250abb..a8c6b45 100644 --- a/backend/src/Titulares.Api/Controllers/ConfiguracionController.cs +++ b/backend/src/Titulares.Api/Controllers/ConfiguracionController.cs @@ -1,48 +1,31 @@ // backend/src/Titulares.Api/Controllers/ConfiguracionController.cs + using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; -using System.Text.Json; +using Titulares.Api.Data; using Titulares.Api.Models; [ApiController] [Route("api/[controller]")] public class ConfiguracionController : ControllerBase { - private readonly IOptionsMonitor _configuracionMonitor; - private readonly string _configFilePath = "configuracion.json"; + private readonly ConfiguracionRepositorio _repositorio; - public ConfiguracionController(IOptionsMonitor configuracionMonitor) + public ConfiguracionController(ConfiguracionRepositorio repositorio) { - _configuracionMonitor = configuracionMonitor; + _repositorio = repositorio; } [HttpGet] - public IActionResult ObtenerConfiguracion() + public async Task ObtenerConfiguracion() { - // IOptionsMonitor siempre nos da el valor más reciente. - return Ok(_configuracionMonitor.CurrentValue); + var config = await _repositorio.ObtenerAsync(); + return Ok(config); } [HttpPost] public async Task GuardarConfiguracion([FromBody] ConfiguracionApp nuevaConfiguracion) { - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - - try - { - var options = new JsonSerializerOptions { WriteIndented = true }; - var jsonString = JsonSerializer.Serialize(nuevaConfiguracion, options); - await System.IO.File.WriteAllTextAsync(_configFilePath, jsonString); - - // No es necesario reiniciar la app, gracias a `reloadOnChange: true` y IOptionsMonitor. - return Ok(new { message = "Configuración guardada correctamente." }); - } - catch (Exception ex) - { - return StatusCode(500, $"Error al guardar el archivo de configuración: {ex.Message}"); - } + await _repositorio.ActualizarAsync(nuevaConfiguracion); + return Ok(new { message = "Configuración guardada correctamente." }); } } \ No newline at end of file diff --git a/backend/src/Titulares.Api/Controllers/EstadoProcesoController.cs b/backend/src/Titulares.Api/Controllers/EstadoProcesoController.cs new file mode 100644 index 0000000..2020831 --- /dev/null +++ b/backend/src/Titulares.Api/Controllers/EstadoProcesoController.cs @@ -0,0 +1,35 @@ +// backend/src/Titulares.Api/Controllers/EstadoProcesoController.cs + +using Microsoft.AspNetCore.Mvc; +using Titulares.Api.Services; + +[ApiController] +[Route("api/estado-proceso")] +public class EstadoProcesoController : ControllerBase +{ + private readonly EstadoProcesoService _estadoService; + + public EstadoProcesoController(EstadoProcesoService estadoService) + { + _estadoService = estadoService; + } + + [HttpGet] + public IActionResult ObtenerEstado() + { + return Ok(new { activo = _estadoService.EstaActivo() }); + } + + [HttpPost] + public IActionResult CambiarEstado([FromBody] CambiarEstadoDto dto) + { + if (dto.Activo) + _estadoService.Activar(); + else + _estadoService.Desactivar(); + + return Ok(new { message = "Estado cambiado." }); + } + + public class CambiarEstadoDto { public bool Activo { get; set; } } +} \ No newline at end of file diff --git a/backend/src/Titulares.Api/Data/ConfiguracionRepositorio.cs b/backend/src/Titulares.Api/Data/ConfiguracionRepositorio.cs new file mode 100644 index 0000000..a79d9c5 --- /dev/null +++ b/backend/src/Titulares.Api/Data/ConfiguracionRepositorio.cs @@ -0,0 +1,40 @@ +// backend/src/Titulares.Api/Data/ConfiguracionRepositorio.cs + +using Dapper; +using Microsoft.Data.SqlClient; +using Titulares.Api.Models; + +namespace Titulares.Api.Data; + +public class ConfiguracionRepositorio +{ + private readonly string _connectionString; + + public ConfiguracionRepositorio(IConfiguration configuration) + { + _connectionString = configuration.GetConnectionString("DefaultConnection")!; + } + + public async Task ObtenerAsync() + { + using var connection = new SqlConnection(_connectionString); + // Siempre obtenemos la fila con Id = 1 + return await connection.QuerySingleAsync("SELECT * FROM Configuracion WHERE Id = 1"); + } + + public async Task ActualizarAsync(ConfiguracionApp config) + { + using var connection = new SqlConnection(_connectionString); + var sql = @" + UPDATE Configuracion + SET + IntervaloMinutos = @IntervaloMinutos, + CantidadTitularesAScrapear = @CantidadTitularesAScrapear, + RutaCsv = @RutaCsv, + ViñetaPorDefecto = @ViñetaPorDefecto + WHERE Id = 1; + "; + var affectedRows = await connection.ExecuteAsync(sql, config); + return affectedRows > 0; + } +} \ No newline at end of file diff --git a/backend/src/Titulares.Api/Data/TitularRepositorio.cs b/backend/src/Titulares.Api/Data/TitularRepositorio.cs index f33e2aa..ae2396b 100644 --- a/backend/src/Titulares.Api/Data/TitularRepositorio.cs +++ b/backend/src/Titulares.Api/Data/TitularRepositorio.cs @@ -101,50 +101,90 @@ public class TitularRepositorio try { - // --- PARTE 1: AÑADIR NUEVOS TITULARES --- - var urlsEnDb = (await connection.QueryAsync( - "SELECT UrlFuente FROM Titulares WHERE UrlFuente IS NOT NULL", transaction: transaction)) - .ToHashSet(); - - var articulosNuevos = articulosScrapeados - .Where(a => !urlsEnDb.Contains(a.UrlFuente)) + // 1. Obtenemos TODOS los titulares actuales en memoria, ordenados correctamente. + var titularesActuales = (await connection.QueryAsync( + "SELECT * FROM Titulares ORDER BY OrdenVisual ASC", transaction: transaction)) .ToList(); - if (articulosNuevos.Any()) - { - var cantidadNuevos = articulosNuevos.Count; - await connection.ExecuteAsync( - "UPDATE Titulares SET OrdenVisual = OrdenVisual + @cantidadNuevos", - new { cantidadNuevos }, - transaction: transaction); + var urlsEnDb = titularesActuales + .Where(t => t.UrlFuente != null) + .Select(t => t.UrlFuente!) + .ToHashSet(); - for (int i = 0; i < cantidadNuevos; i++) + // 2. Identificamos los nuevos artículos y los titulares de scraping existentes. + var articulosNuevos = articulosScrapeados.Where(a => !urlsEnDb.Contains(a.UrlFuente)).ToList(); + var titularesScrapingExistentes = titularesActuales.Where(t => !t.EsEntradaManual).ToList(); + + // 3. Creamos una nueva lista combinada en C# + var listaCombinada = new List(); + + // Añadimos los nuevos artículos scrapeados al principio de la lista de scraping + foreach (var articuloNuevo in articulosNuevos) + { + listaCombinada.Add(new Titular { - var articulo = articulosNuevos[i]; - var sqlInsert = @" - INSERT INTO Titulares (Texto, UrlFuente, OrdenVisual, Tipo, Fuente) - VALUES (@Texto, @UrlFuente, @OrdenVisual, 'Scraped', 'eldia.com'); - "; - await connection.ExecuteAsync(sqlInsert, new { articulo.Texto, articulo.UrlFuente, OrdenVisual = i }, transaction: transaction); + Texto = articuloNuevo.Texto, + UrlFuente = articuloNuevo.UrlFuente, + EsEntradaManual = false, + Tipo = "Scraped", + Fuente = "eldia.com" + }); + } + // Añadimos los viejos artículos de scraping después de los nuevos + listaCombinada.AddRange(titularesScrapingExistentes); + + // 4. Tomamos solo la cantidad de titulares de scraping que necesitamos. + var scrapingFinal = listaCombinada.Take(cantidadALimitar).ToList(); + + // 5. Re-insertamos los titulares manuales en la lista final, preservando su orden relativo. + var listaFinal = new List(); + var manuales = titularesActuales.Where(t => t.EsEntradaManual).OrderBy(t => t.OrdenVisual).ToList(); + var scraping = scrapingFinal; + + // Reconstruimos la lista completa, titular por titular, para preservar el orden + int punteroManual = 0; + int punteroScraping = 0; + for (int i = 0; i < titularesActuales.Count; i++) + { + var titularOriginal = titularesActuales[i]; + if (titularOriginal.EsEntradaManual) + { + if (punteroManual < manuales.Count) + { + listaFinal.Add(manuales[punteroManual]); + punteroManual++; + } + } + else + { + if (punteroScraping < scraping.Count) + { + listaFinal.Add(scraping[punteroScraping]); + punteroScraping++; + } } } - // --- PARTE 2: PURGAR LOS SOBRANTES --- - var sqlDelete = @" - DELETE FROM Titulares - WHERE Id IN ( - -- Seleccionamos los IDs de los titulares que queremos borrar. - -- Son aquellos que no son manuales, ordenados por antigüedad (los más nuevos primero), - -- y nos saltamos los primeros 'N' que queremos conservar. - SELECT Id - FROM Titulares - WHERE EsEntradaManual = 0 - ORDER BY OrdenVisual ASC - OFFSET @Limite ROWS - ); - "; - // Usamos cantidadALimitar como el límite de cuántos conservar. - await connection.ExecuteAsync(sqlDelete, new { Limite = cantidadALimitar }, transaction: transaction); + // Si sobraron manuales o de scraping (por cambio de cantidad), los añadimos al final. + while (punteroScraping < scraping.Count) { listaFinal.Add(scraping[punteroScraping++]); } + while (punteroManual < manuales.Count) { listaFinal.Add(manuales[punteroManual++]); } + + + // 6. Borramos TODOS los titulares actuales de la DB. + await connection.ExecuteAsync("DELETE FROM Titulares", transaction: transaction); + + // 7. Re-insertamos la lista final y perfectamente ordenada, asignando el nuevo OrdenVisual. + for (int i = 0; i < listaFinal.Count; i++) + { + var titular = listaFinal[i]; + titular.OrdenVisual = i; // Asignamos el orden correcto y contiguo. + + var sqlInsert = @" + INSERT INTO Titulares (Texto, UrlFuente, ModificadoPorUsuario, EsEntradaManual, OrdenVisual, Tipo, Fuente, Viñeta) + VALUES (@Texto, @UrlFuente, @ModificadoPorUsuario, @EsEntradaManual, @OrdenVisual, @Tipo, @Fuente, @Viñeta); + "; + await connection.ExecuteAsync(sqlInsert, titular, transaction: transaction); + } transaction.Commit(); } diff --git a/backend/src/Titulares.Api/Models/ConfiguracionApp.cs b/backend/src/Titulares.Api/Models/ConfiguracionApp.cs index 1cf79ed..9a98032 100644 --- a/backend/src/Titulares.Api/Models/ConfiguracionApp.cs +++ b/backend/src/Titulares.Api/Models/ConfiguracionApp.cs @@ -7,5 +7,6 @@ public class ConfiguracionApp public string RutaCsv { get; set; } = "C:\\temp\\titulares.csv"; public int IntervaloMinutos { get; set; } = 5; public int CantidadTitularesAScrapear { get; set; } = 20; - //public int LimiteTotalEnDb { get; set; } = 50; + public bool ScrapingActivo { get; set; } = false; + public string ViñetaPorDefecto { get; set; } = "•"; } \ No newline at end of file diff --git a/backend/src/Titulares.Api/Program.cs b/backend/src/Titulares.Api/Program.cs index d4fd8e1..dfbd62c 100644 --- a/backend/src/Titulares.Api/Program.cs +++ b/backend/src/Titulares.Api/Program.cs @@ -20,6 +20,8 @@ builder.Services.Configure(builder.Configuration); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // Añadimos la política de CORS builder.Services.AddCors(options => diff --git a/backend/src/Titulares.Api/Services/CsvService.cs b/backend/src/Titulares.Api/Services/CsvService.cs index 773883e..a5f77a7 100644 --- a/backend/src/Titulares.Api/Services/CsvService.cs +++ b/backend/src/Titulares.Api/Services/CsvService.cs @@ -2,8 +2,8 @@ using CsvHelper; using CsvHelper.Configuration; -using Microsoft.Extensions.Options; using System.Globalization; +using System.Text; using Titulares.Api.Models; namespace Titulares.Api.Services; @@ -11,17 +11,13 @@ namespace Titulares.Api.Services; public class CsvService { private readonly ILogger _logger; - private readonly IOptionsMonitor _configuracion; - - public CsvService(ILogger logger, IOptionsMonitor configuracion) + public CsvService(ILogger logger) { _logger = logger; - _configuracion = configuracion; } - - public async Task GenerarCsvAsync(IEnumerable titulares) + public async Task GenerarCsvAsync(IEnumerable titulares, ConfiguracionApp config) { - var rutaArchivo = _configuracion.CurrentValue.RutaCsv; + var rutaArchivo = config.RutaCsv; _logger.LogInformation("Iniciando generación de CSV en: {Ruta}", rutaArchivo); try @@ -32,16 +28,15 @@ public class CsvService Directory.CreateDirectory(directorio); } - var config = new CsvConfiguration(CultureInfo.InvariantCulture) + var csvConfig = new CsvConfiguration(CultureInfo.InvariantCulture) { Delimiter = ";", HasHeaderRecord = false, - // Esta linea es para que nunca se ponga comillas en ningún campo. ShouldQuote = args => false, }; - await using var writer = new StreamWriter(rutaArchivo); - await using var csv = new CsvWriter(writer, config); + await using var writer = new StreamWriter(rutaArchivo, false, Encoding.BigEndianUnicode); + await using var csv = new CsvWriter(writer, csvConfig); csv.WriteField("Text"); csv.WriteField("Bullet"); @@ -50,11 +45,11 @@ public class CsvService foreach (var titular in titulares) { csv.WriteField(titular.Texto); - csv.WriteField(titular.Viñeta ?? "•"); + csv.WriteField(titular.Viñeta ?? config.ViñetaPorDefecto); await csv.NextRecordAsync(); } - _logger.LogInformation("CSV generado exitosamente con formato simplificado."); + _logger.LogInformation("CSV generado exitosamente con codificación UTF-16 BE BOM."); } catch (Exception ex) { diff --git a/backend/src/Titulares.Api/Services/EstadoProcesoService.cs b/backend/src/Titulares.Api/Services/EstadoProcesoService.cs new file mode 100644 index 0000000..1a5152a --- /dev/null +++ b/backend/src/Titulares.Api/Services/EstadoProcesoService.cs @@ -0,0 +1,27 @@ +// backend/src/Titulares.Api/Services/EstadoProcesoService.cs + +namespace Titulares.Api.Services; + +public class EstadoProcesoService +{ + private volatile bool _estaActivo = false; + + // 1. Definimos un evento público al que otros servicios pueden suscribirse. + public event Action? OnStateChanged; + + public bool EstaActivo() => _estaActivo; + + 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(); + } +} \ No newline at end of file diff --git a/backend/src/Titulares.Api/Workers/ProcesoScrapingWorker.cs b/backend/src/Titulares.Api/Workers/ProcesoScrapingWorker.cs index 77fa872..713d1ad 100644 --- a/backend/src/Titulares.Api/Workers/ProcesoScrapingWorker.cs +++ b/backend/src/Titulares.Api/Workers/ProcesoScrapingWorker.cs @@ -4,8 +4,6 @@ using Microsoft.AspNetCore.SignalR; using Titulares.Api.Data; using Titulares.Api.Hubs; using Titulares.Api.Services; -using Microsoft.Extensions.Options; -using Titulares.Api.Models; namespace Titulares.Api.Workers; @@ -13,22 +11,23 @@ public class ProcesoScrapingWorker : BackgroundService, IDisposable { private readonly ILogger _logger; private readonly IServiceProvider _serviceProvider; - private readonly IOptionsMonitor _configuracion; - private readonly IDisposable? _optionsReloadToken; - + private readonly EstadoProcesoService _estadoService; private CancellationTokenSource? _delayCts; - public ProcesoScrapingWorker(ILogger logger, IServiceProvider serviceProvider, IOptionsMonitor configuracion) + public ProcesoScrapingWorker(ILogger logger, IServiceProvider serviceProvider, EstadoProcesoService estadoService) { _logger = logger; _serviceProvider = serviceProvider; - _configuracion = configuracion; - _optionsReloadToken = _configuracion.OnChange(OnConfigurationChanged); + _estadoService = estadoService; + + // Nos suscribimos al evento del servicio de estado + _estadoService.OnStateChanged += OnEstadoProcesoChanged; } - private void OnConfigurationChanged(ConfiguracionApp newConfig) + // Este método se ejecutará cuando el evento OnStateChanged se dispare + private void OnEstadoProcesoChanged() { - _logger.LogInformation("La configuración ha cambiado. Interrumpiendo la espera actual para aplicar los nuevos ajustes."); + _logger.LogInformation("El estado del proceso ha cambiado. Interrumpiendo la espera actual."); _delayCts?.Cancel(); } @@ -36,58 +35,68 @@ public class ProcesoScrapingWorker : BackgroundService, IDisposable { while (!stoppingToken.IsCancellationRequested) { - var configActual = _configuracion.CurrentValue; - _logger.LogInformation("Iniciando ciclo de scraping con cantidad: {cantidad}", configActual.CantidadTitularesAScrapear); - - try + using (var scope = _serviceProvider.CreateScope()) { - using (var scope = _serviceProvider.CreateScope()) + var configRepositorio = scope.ServiceProvider.GetRequiredService(); + var config = await configRepositorio.ObtenerAsync(); + + if (!_estadoService.EstaActivo()) + { + _logger.LogInformation("El scraping está desactivado. Entrando en modo de espera."); + await EsperarIntervaloAsync(config.IntervaloMinutos, stoppingToken); + continue; + } + + _logger.LogInformation("Iniciando ciclo de scraping con cantidad: {cantidad}", config.CantidadTitularesAScrapear); + try { var repositorio = scope.ServiceProvider.GetRequiredService(); var scrapingService = scope.ServiceProvider.GetRequiredService(); var hubContext = scope.ServiceProvider.GetRequiredService>(); var csvService = scope.ServiceProvider.GetRequiredService(); - int cantidadTitulares = configActual.CantidadTitularesAScrapear; - var articulosScrapeados = await scrapingService.ObtenerUltimosTitulares(cantidadTitulares); - await repositorio.SincronizarDesdeScraping(articulosScrapeados, cantidadTitulares); - _logger.LogInformation("Sincronización con la base de datos completada."); + var articulosScrapeados = await scrapingService.ObtenerUltimosTitulares(config.CantidadTitularesAScrapear); + await repositorio.SincronizarDesdeScraping(articulosScrapeados, config.CantidadTitularesAScrapear); var titularesActualizados = await repositorio.ObtenerTodosAsync(); await hubContext.Clients.All.SendAsync("TitularesActualizados", titularesActualizados, stoppingToken); - _logger.LogInformation("Notificación enviada a los clientes."); - await csvService.GenerarCsvAsync(titularesActualizados); + + await csvService.GenerarCsvAsync(titularesActualizados, config); + } + catch (Exception ex) + { + _logger.LogError(ex, "Ocurrió un error durante el proceso de scraping."); } - } - catch (Exception ex) - { - _logger.LogError(ex, "Ocurrió un error durante el proceso de scraping."); - } - var intervaloEnMinutos = configActual.IntervaloMinutos; - _logger.LogInformation("Proceso en espera por {minutos} minutos.", intervaloEnMinutos); - - try - { - _delayCts = new CancellationTokenSource(); - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, _delayCts.Token); - await Task.Delay(TimeSpan.FromMinutes(intervaloEnMinutos), linkedCts.Token); - } - catch (TaskCanceledException) - { - _logger.LogInformation("La espera fue interrumpida. Reiniciando el ciclo."); - } - finally - { - _delayCts?.Dispose(); - _delayCts = null; + await EsperarIntervaloAsync(config.IntervaloMinutos, stoppingToken); } } } + private async Task EsperarIntervaloAsync(int minutos, CancellationToken stoppingToken) + { + _logger.LogInformation("Proceso en espera por {minutos} minutos.", minutos); + try + { + _delayCts = new CancellationTokenSource(); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, _delayCts.Token); + await Task.Delay(TimeSpan.FromMinutes(minutos), linkedCts.Token); + } + catch (TaskCanceledException) + { + _logger.LogInformation("La espera fue interrumpida. Reiniciando el ciclo."); + } + finally + { + _delayCts?.Dispose(); + _delayCts = null; + } + } + + // Es crucial desuscribirse del evento para evitar fugas de memoria public override void Dispose() { - _optionsReloadToken?.Dispose(); + _estadoService.OnStateChanged -= OnEstadoProcesoChanged; _delayCts?.Dispose(); base.Dispose(); } diff --git a/backend/src/Titulares.Api/configuracion.json b/backend/src/Titulares.Api/configuracion.json index a04d7db..2d728f9 100644 --- a/backend/src/Titulares.Api/configuracion.json +++ b/backend/src/Titulares.Api/configuracion.json @@ -1,5 +1,6 @@ { "RutaCsv": "C:\\temp\\titulares.csv", - "IntervaloMinutos": 5, - "CantidadTitularesAScrapear": 5 + "IntervaloMinutos": 15, + "CantidadTitularesAScrapear": 4, + "ScrapingActivo": false } \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e7eaa42..c029535 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,28 +1,107 @@ // frontend/src/App.tsx -import { ThemeProvider, createTheme, CssBaseline, Container } from '@mui/material'; +import { ThemeProvider, createTheme, CssBaseline, AppBar, Toolbar, Typography, Container, Box } from '@mui/material'; import Dashboard from './components/Dashboard'; +// Paleta de colores ajustada para coincidir con la nueva imagen (inspirada en Tailwind) const darkTheme = createTheme({ palette: { mode: 'dark', primary: { - main: '#90caf9', // Un azul más claro para mejor contraste en modo oscuro + main: '#3b82f6', // blue-500 + }, + secondary: { + main: '#4f46e5', // indigo-600 para el botón de guardar + }, + success: { + main: '#22c55e', // green-500 + }, + warning: { + main: '#f59e0b', // amber-500 + }, + info: { + main: '#3b82f6', // blue-500 }, background: { - default: '#121212', - paper: '#1e1e1e', + default: '#111827', // gray-900 + paper: '#1F2937', // gray-800 }, + text: { + primary: '#e5e7eb', // gray-200 + secondary: '#9ca3af', // gray-400 + } }, + components: { + MuiAppBar: { + styleOverrides: { + root: { + backgroundColor: '#1F2937', // gray-800 + boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)', + } + } + }, + MuiTextField: { + defaultProps: { + variant: 'filled', + }, + styleOverrides: { + root: { + '& .MuiFilledInput-root': { + backgroundColor: '#374151', // gray-700 + '&:hover': { + backgroundColor: '#4b5563', // gray-600 + }, + '&.Mui-focused': { + backgroundColor: '#4b5563', // gray-600 + }, + }, + }, + }, + }, + MuiChip: { + styleOverrides: { + // Ejemplo para el chip 'Edited' + colorWarning: { + backgroundColor: 'rgba(245, 158, 11, 0.2)', // amber-500 con opacidad + color: '#f59e0b', + }, + // Chip 'Manual' + colorInfo: { + backgroundColor: 'rgba(59, 130, 246, 0.2)', // blue-500 con opacidad + color: '#60a5fa', + }, + // Chip 'Scraped' + colorSuccess: { + backgroundColor: 'rgba(34, 197, 94, 0.2)', // green-500 con opacidad + color: '#4ade80', + }, + } + } + } }); +const Layout = ({ children }: { children: React.ReactNode }) => ( + + + + + Titulares Dashboard + + + + + {children} + + +); + function App() { return ( - + - + ); } diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index 827d9b2..0c1270c 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,53 +1,76 @@ -// frontend/src/components/Dashboard.tsx - import { useEffect, useState, useCallback } from 'react'; -import { Box, Button, Typography, Stack, Chip, CircularProgress } 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'; -import type { Titular } from '../types'; +import type { Titular, Configuracion } from '../types'; import * as api from '../services/apiService'; import { useSignalR } from '../hooks/useSignalR'; import FormularioConfiguracion from './FormularioConfiguracion'; import TablaTitulares from './TablaTitulares'; import AddTitularModal from './AddTitularModal'; import EditarTitularModal from './EditarTitularModal'; +import { PowerSwitch } from './PowerSwitch'; const Dashboard = () => { const [titulares, setTitulares] = useState([]); - const [modalOpen, setModalOpen] = useState(false); + const [config, setConfig] = useState(null); + const [addModalOpen, setAddModalOpen] = useState(false); const [isGeneratingCsv, setIsGeneratingCsv] = useState(false); const [titularAEditar, setTitularAEditar] = useState(null); - // Usamos useCallback para que la función de callback no se recree en cada render, - // evitando que el useEffect del hook se ejecute innecesariamente. const onTitularesActualizados = useCallback((titularesActualizados: Titular[]) => { - console.log("Datos recibidos desde SignalR:", titularesActualizados); setTitulares(titularesActualizados); - }, []); // El array vacío significa que esta función nunca cambiará + }, []); - // Usamos nuestro hook y le pasamos el evento que nos interesa escuchar const { connectionStatus } = useSignalR([ { eventName: 'TitularesActualizados', callback: onTitularesActualizados } ]); - // La carga inicial de datos sigue siendo necesaria por si el componente se monta - // antes de que llegue la primera notificación de SignalR. useEffect(() => { - api.obtenerTitulares() - .then(setTitulares) - .catch(error => console.error("Error al cargar titulares:", error)); + // Obtenemos la configuración persistente + const fetchConfig = api.obtenerConfiguracion(); + // Obtenemos el estado inicial del switch (que siempre será 'false') + const fetchEstado = api.getEstadoProceso(); + + // Cuando ambas promesas se resuelvan, construimos el estado inicial + Promise.all([fetchConfig, fetchEstado]) + .then(([configData, estadoData]) => { + setConfig({ + ...configData, + scrapingActivo: estadoData.activo + }); + }) + .catch(error => console.error("Error al cargar datos iniciales:", error)); + + api.obtenerTitulares().then(setTitulares); }, []); + const handleSwitchChange = async (event: React.ChangeEvent) => { + 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 }); + } + }; + const handleReorder = async (titularesReordenados: Titular[]) => { setTitulares(titularesReordenados); const payload = titularesReordenados.map((item, index) => ({ id: item.id, nuevoOrden: index })); try { await api.actualizarOrdenTitulares(payload); - // Ya no necesitamos hacer nada más, SignalR notificará a todos los clientes. } catch (err) { console.error("Error al reordenar:", err); - // En caso de error, volvemos a pedir los datos para no tener un estado inconsistente. api.obtenerTitulares().then(setTitulares); } }; @@ -56,7 +79,6 @@ const Dashboard = () => { if (window.confirm('¿Estás seguro de que quieres eliminar este titular?')) { try { await api.eliminarTitular(id); - // SignalR se encargará de actualizar el estado. } catch (err) { console.error("Error al eliminar:", err); } @@ -66,7 +88,6 @@ const Dashboard = () => { const handleAdd = async (texto: string) => { try { await api.crearTitularManual(texto); - // SignalR se encargará de actualizar el estado. } catch (err) { console.error("Error al añadir titular:", err); } @@ -88,10 +109,8 @@ const Dashboard = () => { setIsGeneratingCsv(true); try { await api.generarCsvManual(); - // Opcional: mostrar una notificación de éxito } catch (error) { - console.error("Error al generar CSV manualmente", error); - // Opcional: mostrar una notificación de error + console.error("Error al generar CSV manually", error); } finally { setIsGeneratingCsv(false); } @@ -100,7 +119,6 @@ const Dashboard = () => { const handleSaveEdit = async (id: number, texto: string, viñeta: string) => { try { await api.actualizarTitular(id, { texto, viñeta: viñeta || null }); - // SignalR se encargará de actualizar la UI } catch (err) { console.error("Error al guardar cambios:", err); } @@ -108,29 +126,56 @@ const Dashboard = () => { return ( <> - + - - Titulares Dashboard + + Estado del Servidor {getStatusChip()} - {getStatusChip()} + {config ? ( + + ) : } - + + - - + + }> + Configuración + + + + + + { /> setModalOpen(false)} + open={addModalOpen} + onClose={() => setAddModalOpen(false)} onAdd={handleAdd} /> diff --git a/frontend/src/components/EditarTitularModal.tsx b/frontend/src/components/EditarTitularModal.tsx index 9f9c4bd..14d52d8 100644 --- a/frontend/src/components/EditarTitularModal.tsx +++ b/frontend/src/components/EditarTitularModal.tsx @@ -1,5 +1,3 @@ -// frontend/src/components/EditarTitularModal.tsx - import { useEffect, useState } from 'react'; import { Modal, Box, Typography, TextField, Button } from '@mui/material'; import type { Titular } from '../types'; @@ -26,17 +24,20 @@ const EditarTitularModal = ({ open, onClose, onSave, titular }: Props) => { const [texto, setTexto] = useState(''); const [viñeta, setViñeta] = useState(''); - // Este efecto actualiza el estado del formulario cuando se selecciona un titular para editar useEffect(() => { if (titular) { setTexto(titular.texto); - setViñeta(titular.viñeta ?? '•'); // Default a '•' si es nulo + // Usamos el valor real, incluso si es solo espacios o una cadena vacía. + setViñeta(titular.viñeta ?? ''); } }, [titular]); const handleSave = () => { + // Verificamos que el titular exista y que el texto principal no esté vacío. if (titular && texto.trim()) { - onSave(titular.id, texto.trim(), viñeta.trim()); + const textoLimpio = texto.trim(); + const viñetaSinLimpiar = viñeta; + onSave(titular.id, textoLimpio, viñetaSinLimpiar); onClose(); } }; diff --git a/frontend/src/components/FormularioConfiguracion.tsx b/frontend/src/components/FormularioConfiguracion.tsx index 53d63f0..92022fa 100644 --- a/frontend/src/components/FormularioConfiguracion.tsx +++ b/frontend/src/components/FormularioConfiguracion.tsx @@ -1,43 +1,40 @@ -// frontend/src/components/FormularioConfiguracion.tsx -import { useEffect, useState } from 'react'; -import { Box, TextField, Button, Paper, Typography, CircularProgress } from '@mui/material'; +import { Box, TextField, Button, Paper, CircularProgress, Typography } from '@mui/material'; import type { Configuracion } from '../types'; import * as api from '../services/apiService'; +import { useState } from 'react'; -const FormularioConfiguracion = () => { - const [config, setConfig] = useState(null); - const [loading, setLoading] = useState(true); +interface Props { + config: Configuracion | null; + setConfig: React.Dispatch>; +} + +const FormularioConfiguracion = ({ config, setConfig }: Props) => { const [saving, setSaving] = useState(false); const [success, setSuccess] = useState(false); - useEffect(() => { - api.obtenerConfiguracion() - .then(data => { - setConfig(data); - setLoading(false); - }) - .catch(err => console.error("Error al cargar configuración", err)); - }, []); + if (!config) { + return ; + } const handleChange = (event: React.ChangeEvent) => { - if (!config) return; const { name, value } = event.target; - setConfig({ - ...config, - [name]: name === 'rutaCsv' ? value : Number(value) // Convertir a número si no es la ruta - }); + 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); // El mensaje de éxito desaparece después de 2s + setTimeout(() => setSuccess(false), 2000); } catch (err) { console.error("Error al guardar configuración", err); } finally { @@ -45,32 +42,22 @@ const FormularioConfiguracion = () => { } }; - if (loading) { - return ; - } - - if (!config) { - return No se pudo cargar la configuración. - } - return ( - - Configuración + + + + - - {success && ¡Guardado!} diff --git a/frontend/src/components/PowerSwitch.tsx b/frontend/src/components/PowerSwitch.tsx new file mode 100644 index 0000000..e988226 --- /dev/null +++ b/frontend/src/components/PowerSwitch.tsx @@ -0,0 +1,57 @@ +// frontend/src/components/PowerSwitch.tsx + +import { styled } from '@mui/material/styles'; +import Switch from '@mui/material/Switch'; +import FormControlLabel from '@mui/material/FormControlLabel'; + +const StyledSwitch = styled(Switch)(({ theme }) => ({ + width: 62, + height: 34, + padding: 7, + '& .MuiSwitch-switchBase': { + margin: 1, + padding: 0, + transform: 'translateX(6px)', + '&.Mui-checked': { + color: '#fff', + transform: 'translateX(22px)', + // Estilo para el thumb (el círculo) cuando está ON (verde) + '& .MuiSwitch-thumb': { + backgroundColor: theme.palette.success.main, + }, + // Estilo para el track (la base) cuando está ON (verde claro) + '& + .MuiSwitch-track': { + opacity: 1, + backgroundColor: theme.palette.success.light, + }, + }, + }, + // Estilo para el thumb cuando está OFF (rojo) + '& .MuiSwitch-thumb': { + backgroundColor: theme.palette.error.main, + width: 32, + height: 32, + }, + // Estilo para el track cuando está OFF (gris) + '& .MuiSwitch-track': { + opacity: 1, + backgroundColor: theme.palette.mode === 'dark' ? '#8796A5' : '#aab4be', + borderRadius: 20 / 2, + }, +})); + +interface PowerSwitchProps { + checked: boolean; + onChange: (event: React.ChangeEvent) => void; + label: string; +} + +// Componente funcional que envuelve nuestro switch estilizado +export const PowerSwitch = ({ checked, onChange, label }: PowerSwitchProps) => { + return ( + } + label={label} + /> + ); +}; \ No newline at end of file diff --git a/frontend/src/components/TablaTitulares.tsx b/frontend/src/components/TablaTitulares.tsx index 489cb0b..f903278 100644 --- a/frontend/src/components/TablaTitulares.tsx +++ b/frontend/src/components/TablaTitulares.tsx @@ -1,17 +1,16 @@ // frontend/src/components/TablaTitulares.tsx import { - Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Chip, IconButton, Typography + Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Chip, IconButton, Typography, Link } from '@mui/material'; import DeleteIcon from '@mui/icons-material/Delete'; -import DragHandleIcon from '@mui/icons-material/DragHandle'; // Importar el ícono +import DragHandleIcon from '@mui/icons-material/DragHandle'; +import EditIcon from '@mui/icons-material/Edit'; import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core'; import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import type { Titular } from '../types'; -import EditIcon from '@mui/icons-material/Edit'; -// La prop `onDelete` se añade para comunicar el evento al componente padre interface SortableRowProps { titular: Titular; onDelete: (id: number) => void; @@ -26,28 +25,45 @@ const SortableRow = ({ titular, onDelete, onEdit }: SortableRowProps) => { transition, }; - const getChipColor = (tipo: Titular['tipo']) => { + const getChipColor = (tipo: Titular['tipo']): "success" | "warning" | "info" => { if (tipo === 'Edited') return 'warning'; if (tipo === 'Manual') return 'info'; return 'success'; }; + + const formatFuente = (fuente: string | null) => { + if (!fuente) return 'N/A'; + try { + const url = new URL(fuente); + return url.hostname.replace('www.', ''); + } catch { + return fuente; + } + } return ( - - {/* El handle de arrastre ahora es un ícono */} - - + + + - {titular.texto} - + {titular.texto} + - {titular.fuente} - + + {titular.urlFuente ? ( + + {formatFuente(titular.fuente)} + + ) : ( + formatFuente(titular.fuente) + )} + + { e.stopPropagation(); onEdit(titular); }}> - { e.stopPropagation(); onDelete(titular.id); }}> + { e.stopPropagation(); onDelete(titular.id); }} sx={{ color: '#ef4444' }}> @@ -63,39 +79,38 @@ interface TablaTitularesProps { } const TablaTitulares = ({ titulares, onReorder, onDelete, onEdit }: TablaTitularesProps) => { - const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })); // Evita activar el drag con un simple clic + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } })); const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (over && active.id !== over.id) { const oldIndex = titulares.findIndex((item) => item.id === active.id); const newIndex = titulares.findIndex((item) => item.id === over.id); - const newArray = arrayMove(titulares, oldIndex, newIndex); - onReorder(newArray); // Pasamos el nuevo array al padre para que gestione el estado y la llamada a la API + onReorder(arrayMove(titulares, oldIndex, newIndex)); } }; if (titulares.length === 0) { return ( - + No hay titulares para mostrar. ); } return ( - + t.id)} strategy={verticalListSortingStrategy}> - - - Texto del Titular - Tipo - Fuente - Acciones + + + Texto del Titular + Tipo + Fuente + Acciones diff --git a/frontend/src/services/apiService.ts b/frontend/src/services/apiService.ts index c1c2033..07c56e2 100644 --- a/frontend/src/services/apiService.ts +++ b/frontend/src/services/apiService.ts @@ -42,10 +42,19 @@ export const obtenerConfiguracion = async (): Promise => { return response.data; }; -export const guardarConfiguracion = async (config: Configuracion): Promise => { +export const guardarConfiguracion = async (config: Omit): Promise => { await apiClient.post('/configuracion', config); }; +export const getEstadoProceso = async (): Promise<{ activo: boolean }> => { + const response = await apiClient.get('/estado-proceso'); + return response.data; +}; + +export const setEstadoProceso = async (activo: boolean): Promise => { + await apiClient.post('/estado-proceso', { activo }); +}; + export const generarCsvManual = async (): Promise => { await apiClient.post('/acciones/generar-csv'); }; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 53fb9af..fa3347f 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -17,5 +17,6 @@ export interface Configuracion { rutaCsv: string; intervaloMinutos: number; cantidadTitularesAScrapear: number; - //limiteTotalEnDb: number; + scrapingActivo: boolean; + viñetaPorDefecto: string; } \ No newline at end of file