diff --git a/backend/src/Titulares.Api/Controllers/AccionesController.cs b/backend/src/Titulares.Api/Controllers/AccionesController.cs new file mode 100644 index 0000000..36e2977 --- /dev/null +++ b/backend/src/Titulares.Api/Controllers/AccionesController.cs @@ -0,0 +1,36 @@ +// backend/src/Titulares.Api/Controllers/AccionesController.cs + +using Microsoft.AspNetCore.Mvc; +using Titulares.Api.Data; +using Titulares.Api.Services; + +namespace Titulares.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class AccionesController : ControllerBase +{ + private readonly TitularRepositorio _repositorio; + private readonly CsvService _csvService; + + public AccionesController(TitularRepositorio repositorio, CsvService csvService) + { + _repositorio = repositorio; + _csvService = csvService; + } + + [HttpPost("generar-csv")] + public async Task GenerarCsvManual() + { + try + { + var titulares = await _repositorio.ObtenerTodosAsync(); + await _csvService.GenerarCsvAsync(titulares); + return Ok(new { message = "CSV generado manualmente con éxito." }); + } + catch (Exception ex) + { + return StatusCode(500, $"Error al generar el CSV: {ex.Message}"); + } + } +} \ No newline at end of file diff --git a/backend/src/Titulares.Api/Models/Titular.cs b/backend/src/Titulares.Api/Models/Titular.cs index 8d42d90..b1ee2c6 100644 --- a/backend/src/Titulares.Api/Models/Titular.cs +++ b/backend/src/Titulares.Api/Models/Titular.cs @@ -14,6 +14,7 @@ public class Titular public string? Encabezado { get; set; } public string? Tipo { get; set; } public string? Fuente { get; set; } + public string? Viñeta { get; set; } } // DTO (Data Transfer Object) para la creación de un titular manual diff --git a/backend/src/Titulares.Api/Program.cs b/backend/src/Titulares.Api/Program.cs index cb82ba5..d4fd8e1 100644 --- a/backend/src/Titulares.Api/Program.cs +++ b/backend/src/Titulares.Api/Program.cs @@ -19,6 +19,7 @@ builder.Services.Configure(builder.Configuration); // Añadimos nuestro repositorio personalizado builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // 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 new file mode 100644 index 0000000..b9e9115 --- /dev/null +++ b/backend/src/Titulares.Api/Services/CsvService.cs @@ -0,0 +1,83 @@ +// backend/src/Titulares.Api/Services/CsvService.cs + +using CsvHelper; +using CsvHelper.Configuration; +using Microsoft.Extensions.Options; +using System.Globalization; +using Titulares.Api.Models; + +namespace Titulares.Api.Services; + +public class CsvService +{ + private readonly ILogger _logger; + private readonly IOptionsMonitor _configuracion; + + public CsvService(ILogger logger, IOptionsMonitor configuracion) + { + _logger = logger; + _configuracion = configuracion; + } + + public async Task GenerarCsvAsync(IEnumerable titulares) + { + var rutaArchivo = _configuracion.CurrentValue.RutaCsv; + _logger.LogInformation("Iniciando generación de CSV en: {Ruta}", rutaArchivo); + + try + { + // La cláusula Where asegura que los Encabezados no sean nulos o vacíos. + var grupos = titulares + .Where(t => !string.IsNullOrEmpty(t.Encabezado)) + .GroupBy(t => t.Encabezado); + + var config = new CsvConfiguration(CultureInfo.InvariantCulture) + { + Delimiter = ";", + }; + + await using var writer = new StreamWriter(rutaArchivo); + await using var csv = new CsvWriter(writer, config); + + // Escribimos la cabecera principal + csv.WriteField("Text"); + csv.WriteField("Heading"); + csv.WriteField("Value"); + csv.WriteField("Up"); + csv.WriteField("Down"); + csv.WriteField("Change"); + csv.WriteField("Bullet"); + await csv.NextRecordAsync(); + + foreach (var grupo in grupos) + { + csv.WriteField(""); + csv.WriteField($" {grupo.Key!.ToUpper()} "); + csv.WriteField(""); + await csv.NextRecordAsync(); + + await csv.NextRecordAsync(); + + foreach (var titular in grupo) + { + csv.WriteField(titular.Texto); + csv.WriteField(""); // Heading + csv.WriteField(""); // Value + csv.WriteField(""); // Up + csv.WriteField(""); // Down + csv.WriteField(""); // Change + csv.WriteField(titular.Viñeta ?? "•"); + await csv.NextRecordAsync(); + } + + await csv.NextRecordAsync(); + } + _logger.LogInformation("CSV generado exitosamente."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error al generar el archivo CSV."); + throw; + } + } +} \ 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 83d786b..d803a3d 100644 --- a/backend/src/Titulares.Api/Workers/ProcesoScrapingWorker.cs +++ b/backend/src/Titulares.Api/Workers/ProcesoScrapingWorker.cs @@ -39,6 +39,7 @@ public class ProcesoScrapingWorker : BackgroundService var repositorio = scope.ServiceProvider.GetRequiredService(); var scrapingService = scope.ServiceProvider.GetRequiredService(); var hubContext = scope.ServiceProvider.GetRequiredService>(); + var csvService = scope.ServiceProvider.GetRequiredService(); // Obtener estos valores desde la configuración int cantidadAObtener = configActual.CantidadTitularesAScrapear; @@ -49,14 +50,14 @@ public class ProcesoScrapingWorker : BackgroundService if (articulosScrapeados.Any()) { - // 2. Sincronizar con la base de datos await repositorio.SincronizarDesdeScraping(articulosScrapeados, limiteTotalEnDb); _logger.LogInformation("Sincronización con la base de datos completada."); - // 3. Notificar a todos los clientes a través de SignalR 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); } else { diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index 57e9e67..a21203f 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,7 +1,7 @@ // frontend/src/components/Dashboard.tsx import { useEffect, useState, useCallback } from 'react'; -import { Box, Button, Typography, Stack, Chip } from '@mui/material'; +import { Box, Button, Typography, Stack, Chip, CircularProgress } from '@mui/material'; import AddIcon from '@mui/icons-material/Add'; import SyncIcon from '@mui/icons-material/Sync'; @@ -15,6 +15,7 @@ import AddTitularModal from './AddTitularModal'; const Dashboard = () => { const [titulares, setTitulares] = useState([]); const [modalOpen, setModalOpen] = useState(false); + const [isGeneratingCsv, setIsGeneratingCsv] = useState(false); // Usamos useCallback para que la función de callback no se recree en cada render, // evitando que el useEffect del hook se ejecute innecesariamente. @@ -81,6 +82,19 @@ const Dashboard = () => { } } + const handleGenerateCsv = async () => { + 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 + } finally { + setIsGeneratingCsv(false); + } + }; + return ( <> @@ -91,8 +105,13 @@ const Dashboard = () => { {getStatusChip()} -