Versión 1.0: Aplicación funcionalmente completa con todas las características principales implementadas.
This commit is contained in:
@@ -10,12 +10,17 @@ namespace Titulares.Api.Controllers;
|
|||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class AccionesController : ControllerBase
|
public class AccionesController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly TitularRepositorio _repositorio;
|
private readonly TitularRepositorio _titularRepositorio;
|
||||||
|
private readonly ConfiguracionRepositorio _configRepositorio;
|
||||||
private readonly CsvService _csvService;
|
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;
|
_csvService = csvService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,8 +29,13 @@ public class AccionesController : ControllerBase
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var titulares = await _repositorio.ObtenerTodosAsync();
|
// Obtenemos tanto los titulares como la configuración más reciente de la DB
|
||||||
await _csvService.GenerarCsvAsync(titulares);
|
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." });
|
return Ok(new { message = "CSV generado manualmente con éxito." });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -1,48 +1,31 @@
|
|||||||
// backend/src/Titulares.Api/Controllers/ConfiguracionController.cs
|
// backend/src/Titulares.Api/Controllers/ConfiguracionController.cs
|
||||||
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Options;
|
using Titulares.Api.Data;
|
||||||
using System.Text.Json;
|
|
||||||
using Titulares.Api.Models;
|
using Titulares.Api.Models;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class ConfiguracionController : ControllerBase
|
public class ConfiguracionController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IOptionsMonitor<ConfiguracionApp> _configuracionMonitor;
|
private readonly ConfiguracionRepositorio _repositorio;
|
||||||
private readonly string _configFilePath = "configuracion.json";
|
|
||||||
|
|
||||||
public ConfiguracionController(IOptionsMonitor<ConfiguracionApp> configuracionMonitor)
|
public ConfiguracionController(ConfiguracionRepositorio repositorio)
|
||||||
{
|
{
|
||||||
_configuracionMonitor = configuracionMonitor;
|
_repositorio = repositorio;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public IActionResult ObtenerConfiguracion()
|
public async Task<IActionResult> ObtenerConfiguracion()
|
||||||
{
|
{
|
||||||
// IOptionsMonitor siempre nos da el valor más reciente.
|
var config = await _repositorio.ObtenerAsync();
|
||||||
return Ok(_configuracionMonitor.CurrentValue);
|
return Ok(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> GuardarConfiguracion([FromBody] ConfiguracionApp nuevaConfiguracion)
|
public async Task<IActionResult> GuardarConfiguracion([FromBody] ConfiguracionApp nuevaConfiguracion)
|
||||||
{
|
{
|
||||||
if (!ModelState.IsValid)
|
await _repositorio.ActualizarAsync(nuevaConfiguracion);
|
||||||
{
|
return Ok(new { message = "Configuración guardada correctamente." });
|
||||||
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}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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; } }
|
||||||
|
}
|
||||||
40
backend/src/Titulares.Api/Data/ConfiguracionRepositorio.cs
Normal file
40
backend/src/Titulares.Api/Data/ConfiguracionRepositorio.cs
Normal file
@@ -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<ConfiguracionApp> ObtenerAsync()
|
||||||
|
{
|
||||||
|
using var connection = new SqlConnection(_connectionString);
|
||||||
|
// Siempre obtenemos la fila con Id = 1
|
||||||
|
return await connection.QuerySingleAsync<ConfiguracionApp>("SELECT * FROM Configuracion WHERE Id = 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -101,50 +101,90 @@ public class TitularRepositorio
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// --- PARTE 1: AÑADIR NUEVOS TITULARES ---
|
// 1. Obtenemos TODOS los titulares actuales en memoria, ordenados correctamente.
|
||||||
var urlsEnDb = (await connection.QueryAsync<string>(
|
var titularesActuales = (await connection.QueryAsync<Titular>(
|
||||||
"SELECT UrlFuente FROM Titulares WHERE UrlFuente IS NOT NULL", transaction: transaction))
|
"SELECT * FROM Titulares ORDER BY OrdenVisual ASC", transaction: transaction))
|
||||||
.ToHashSet();
|
|
||||||
|
|
||||||
var articulosNuevos = articulosScrapeados
|
|
||||||
.Where(a => !urlsEnDb.Contains(a.UrlFuente))
|
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
if (articulosNuevos.Any())
|
var urlsEnDb = titularesActuales
|
||||||
{
|
.Where(t => t.UrlFuente != null)
|
||||||
var cantidadNuevos = articulosNuevos.Count;
|
.Select(t => t.UrlFuente!)
|
||||||
await connection.ExecuteAsync(
|
.ToHashSet();
|
||||||
"UPDATE Titulares SET OrdenVisual = OrdenVisual + @cantidadNuevos",
|
|
||||||
new { cantidadNuevos },
|
|
||||||
transaction: transaction);
|
|
||||||
|
|
||||||
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<Titular>();
|
||||||
|
|
||||||
|
// 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];
|
Texto = articuloNuevo.Texto,
|
||||||
var sqlInsert = @"
|
UrlFuente = articuloNuevo.UrlFuente,
|
||||||
INSERT INTO Titulares (Texto, UrlFuente, OrdenVisual, Tipo, Fuente)
|
EsEntradaManual = false,
|
||||||
VALUES (@Texto, @UrlFuente, @OrdenVisual, 'Scraped', 'eldia.com');
|
Tipo = "Scraped",
|
||||||
";
|
Fuente = "eldia.com"
|
||||||
await connection.ExecuteAsync(sqlInsert, new { articulo.Texto, articulo.UrlFuente, OrdenVisual = i }, transaction: transaction);
|
});
|
||||||
|
}
|
||||||
|
// 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<Titular>();
|
||||||
|
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 ---
|
// Si sobraron manuales o de scraping (por cambio de cantidad), los añadimos al final.
|
||||||
var sqlDelete = @"
|
while (punteroScraping < scraping.Count) { listaFinal.Add(scraping[punteroScraping++]); }
|
||||||
DELETE FROM Titulares
|
while (punteroManual < manuales.Count) { listaFinal.Add(manuales[punteroManual++]); }
|
||||||
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),
|
// 6. Borramos TODOS los titulares actuales de la DB.
|
||||||
-- y nos saltamos los primeros 'N' que queremos conservar.
|
await connection.ExecuteAsync("DELETE FROM Titulares", transaction: transaction);
|
||||||
SELECT Id
|
|
||||||
FROM Titulares
|
// 7. Re-insertamos la lista final y perfectamente ordenada, asignando el nuevo OrdenVisual.
|
||||||
WHERE EsEntradaManual = 0
|
for (int i = 0; i < listaFinal.Count; i++)
|
||||||
ORDER BY OrdenVisual ASC
|
{
|
||||||
OFFSET @Limite ROWS
|
var titular = listaFinal[i];
|
||||||
);
|
titular.OrdenVisual = i; // Asignamos el orden correcto y contiguo.
|
||||||
";
|
|
||||||
// Usamos cantidadALimitar como el límite de cuántos conservar.
|
var sqlInsert = @"
|
||||||
await connection.ExecuteAsync(sqlDelete, new { Limite = cantidadALimitar }, transaction: transaction);
|
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();
|
transaction.Commit();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ public class ConfiguracionApp
|
|||||||
public string RutaCsv { get; set; } = "C:\\temp\\titulares.csv";
|
public string RutaCsv { get; set; } = "C:\\temp\\titulares.csv";
|
||||||
public int IntervaloMinutos { get; set; } = 5;
|
public int IntervaloMinutos { get; set; } = 5;
|
||||||
public int CantidadTitularesAScrapear { get; set; } = 20;
|
public int CantidadTitularesAScrapear { get; set; } = 20;
|
||||||
//public int LimiteTotalEnDb { get; set; } = 50;
|
public bool ScrapingActivo { get; set; } = false;
|
||||||
|
public string ViñetaPorDefecto { get; set; } = "•";
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,8 @@ builder.Services.Configure<ConfiguracionApp>(builder.Configuration);
|
|||||||
builder.Services.AddSingleton<TitularRepositorio>();
|
builder.Services.AddSingleton<TitularRepositorio>();
|
||||||
builder.Services.AddScoped<ScrapingService>();
|
builder.Services.AddScoped<ScrapingService>();
|
||||||
builder.Services.AddScoped<CsvService>();
|
builder.Services.AddScoped<CsvService>();
|
||||||
|
builder.Services.AddSingleton<ConfiguracionRepositorio>();
|
||||||
|
builder.Services.AddSingleton<EstadoProcesoService>();
|
||||||
|
|
||||||
// Añadimos la política de CORS
|
// Añadimos la política de CORS
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
using CsvHelper;
|
using CsvHelper;
|
||||||
using CsvHelper.Configuration;
|
using CsvHelper.Configuration;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
using Titulares.Api.Models;
|
using Titulares.Api.Models;
|
||||||
|
|
||||||
namespace Titulares.Api.Services;
|
namespace Titulares.Api.Services;
|
||||||
@@ -11,17 +11,13 @@ namespace Titulares.Api.Services;
|
|||||||
public class CsvService
|
public class CsvService
|
||||||
{
|
{
|
||||||
private readonly ILogger<CsvService> _logger;
|
private readonly ILogger<CsvService> _logger;
|
||||||
private readonly IOptionsMonitor<ConfiguracionApp> _configuracion;
|
public CsvService(ILogger<CsvService> logger)
|
||||||
|
|
||||||
public CsvService(ILogger<CsvService> logger, IOptionsMonitor<ConfiguracionApp> configuracion)
|
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_configuracion = configuracion;
|
|
||||||
}
|
}
|
||||||
|
public async Task GenerarCsvAsync(IEnumerable<Titular> titulares, ConfiguracionApp config)
|
||||||
public async Task GenerarCsvAsync(IEnumerable<Titular> titulares)
|
|
||||||
{
|
{
|
||||||
var rutaArchivo = _configuracion.CurrentValue.RutaCsv;
|
var rutaArchivo = config.RutaCsv;
|
||||||
_logger.LogInformation("Iniciando generación de CSV en: {Ruta}", rutaArchivo);
|
_logger.LogInformation("Iniciando generación de CSV en: {Ruta}", rutaArchivo);
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -32,16 +28,15 @@ public class CsvService
|
|||||||
Directory.CreateDirectory(directorio);
|
Directory.CreateDirectory(directorio);
|
||||||
}
|
}
|
||||||
|
|
||||||
var config = new CsvConfiguration(CultureInfo.InvariantCulture)
|
var csvConfig = new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||||
{
|
{
|
||||||
Delimiter = ";",
|
Delimiter = ";",
|
||||||
HasHeaderRecord = false,
|
HasHeaderRecord = false,
|
||||||
// Esta linea es para que nunca se ponga comillas en ningún campo.
|
|
||||||
ShouldQuote = args => false,
|
ShouldQuote = args => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
await using var writer = new StreamWriter(rutaArchivo);
|
await using var writer = new StreamWriter(rutaArchivo, false, Encoding.BigEndianUnicode);
|
||||||
await using var csv = new CsvWriter(writer, config);
|
await using var csv = new CsvWriter(writer, csvConfig);
|
||||||
|
|
||||||
csv.WriteField("Text");
|
csv.WriteField("Text");
|
||||||
csv.WriteField("Bullet");
|
csv.WriteField("Bullet");
|
||||||
@@ -50,11 +45,11 @@ public class CsvService
|
|||||||
foreach (var titular in titulares)
|
foreach (var titular in titulares)
|
||||||
{
|
{
|
||||||
csv.WriteField(titular.Texto);
|
csv.WriteField(titular.Texto);
|
||||||
csv.WriteField(titular.Viñeta ?? "•");
|
csv.WriteField(titular.Viñeta ?? config.ViñetaPorDefecto);
|
||||||
await csv.NextRecordAsync();
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
27
backend/src/Titulares.Api/Services/EstadoProcesoService.cs
Normal file
27
backend/src/Titulares.Api/Services/EstadoProcesoService.cs
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,6 @@ using Microsoft.AspNetCore.SignalR;
|
|||||||
using Titulares.Api.Data;
|
using Titulares.Api.Data;
|
||||||
using Titulares.Api.Hubs;
|
using Titulares.Api.Hubs;
|
||||||
using Titulares.Api.Services;
|
using Titulares.Api.Services;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using Titulares.Api.Models;
|
|
||||||
|
|
||||||
namespace Titulares.Api.Workers;
|
namespace Titulares.Api.Workers;
|
||||||
|
|
||||||
@@ -13,22 +11,23 @@ public class ProcesoScrapingWorker : BackgroundService, IDisposable
|
|||||||
{
|
{
|
||||||
private readonly ILogger<ProcesoScrapingWorker> _logger;
|
private readonly ILogger<ProcesoScrapingWorker> _logger;
|
||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private readonly IOptionsMonitor<ConfiguracionApp> _configuracion;
|
private readonly EstadoProcesoService _estadoService;
|
||||||
private readonly IDisposable? _optionsReloadToken;
|
|
||||||
|
|
||||||
private CancellationTokenSource? _delayCts;
|
private CancellationTokenSource? _delayCts;
|
||||||
|
|
||||||
public ProcesoScrapingWorker(ILogger<ProcesoScrapingWorker> logger, IServiceProvider serviceProvider, IOptionsMonitor<ConfiguracionApp> configuracion)
|
public ProcesoScrapingWorker(ILogger<ProcesoScrapingWorker> logger, IServiceProvider serviceProvider, EstadoProcesoService estadoService)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_serviceProvider = serviceProvider;
|
_serviceProvider = serviceProvider;
|
||||||
_configuracion = configuracion;
|
_estadoService = estadoService;
|
||||||
_optionsReloadToken = _configuracion.OnChange(OnConfigurationChanged);
|
|
||||||
|
// 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();
|
_delayCts?.Cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,58 +35,68 @@ public class ProcesoScrapingWorker : BackgroundService, IDisposable
|
|||||||
{
|
{
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
var configActual = _configuracion.CurrentValue;
|
using (var scope = _serviceProvider.CreateScope())
|
||||||
_logger.LogInformation("Iniciando ciclo de scraping con cantidad: {cantidad}", configActual.CantidadTitularesAScrapear);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
using (var scope = _serviceProvider.CreateScope())
|
var configRepositorio = scope.ServiceProvider.GetRequiredService<ConfiguracionRepositorio>();
|
||||||
|
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<TitularRepositorio>();
|
var repositorio = scope.ServiceProvider.GetRequiredService<TitularRepositorio>();
|
||||||
var scrapingService = scope.ServiceProvider.GetRequiredService<ScrapingService>();
|
var scrapingService = scope.ServiceProvider.GetRequiredService<ScrapingService>();
|
||||||
var hubContext = scope.ServiceProvider.GetRequiredService<IHubContext<TitularesHub>>();
|
var hubContext = scope.ServiceProvider.GetRequiredService<IHubContext<TitularesHub>>();
|
||||||
var csvService = scope.ServiceProvider.GetRequiredService<CsvService>();
|
var csvService = scope.ServiceProvider.GetRequiredService<CsvService>();
|
||||||
int cantidadTitulares = configActual.CantidadTitularesAScrapear;
|
|
||||||
var articulosScrapeados = await scrapingService.ObtenerUltimosTitulares(cantidadTitulares);
|
|
||||||
|
|
||||||
await repositorio.SincronizarDesdeScraping(articulosScrapeados, cantidadTitulares);
|
var articulosScrapeados = await scrapingService.ObtenerUltimosTitulares(config.CantidadTitularesAScrapear);
|
||||||
_logger.LogInformation("Sincronización con la base de datos completada.");
|
await repositorio.SincronizarDesdeScraping(articulosScrapeados, config.CantidadTitularesAScrapear);
|
||||||
|
|
||||||
var titularesActualizados = await repositorio.ObtenerTodosAsync();
|
var titularesActualizados = await repositorio.ObtenerTodosAsync();
|
||||||
await hubContext.Clients.All.SendAsync("TitularesActualizados", titularesActualizados, stoppingToken);
|
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;
|
await EsperarIntervaloAsync(config.IntervaloMinutos, stoppingToken);
|
||||||
_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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
public override void Dispose()
|
||||||
{
|
{
|
||||||
_optionsReloadToken?.Dispose();
|
_estadoService.OnStateChanged -= OnEstadoProcesoChanged;
|
||||||
_delayCts?.Dispose();
|
_delayCts?.Dispose();
|
||||||
base.Dispose();
|
base.Dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"RutaCsv": "C:\\temp\\titulares.csv",
|
"RutaCsv": "C:\\temp\\titulares.csv",
|
||||||
"IntervaloMinutos": 5,
|
"IntervaloMinutos": 15,
|
||||||
"CantidadTitularesAScrapear": 5
|
"CantidadTitularesAScrapear": 4,
|
||||||
|
"ScrapingActivo": false
|
||||||
}
|
}
|
||||||
@@ -1,28 +1,107 @@
|
|||||||
// frontend/src/App.tsx
|
// 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';
|
import Dashboard from './components/Dashboard';
|
||||||
|
|
||||||
|
// Paleta de colores ajustada para coincidir con la nueva imagen (inspirada en Tailwind)
|
||||||
const darkTheme = createTheme({
|
const darkTheme = createTheme({
|
||||||
palette: {
|
palette: {
|
||||||
mode: 'dark',
|
mode: 'dark',
|
||||||
primary: {
|
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: {
|
background: {
|
||||||
default: '#121212',
|
default: '#111827', // gray-900
|
||||||
paper: '#1e1e1e',
|
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 }) => (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||||
|
<AppBar position="sticky">
|
||||||
|
<Toolbar>
|
||||||
|
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
||||||
|
Titulares Dashboard
|
||||||
|
</Typography>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
<Container maxWidth="xl" component="main" sx={{ flexGrow: 1, py: 4 }}>
|
||||||
|
{children}
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={darkTheme}>
|
<ThemeProvider theme={darkTheme}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
<Layout>
|
||||||
<Dashboard />
|
<Dashboard />
|
||||||
</Container>
|
</Layout>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,76 @@
|
|||||||
// frontend/src/components/Dashboard.tsx
|
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
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 AddIcon from '@mui/icons-material/Add';
|
||||||
import SyncIcon from '@mui/icons-material/Sync';
|
import SyncIcon from '@mui/icons-material/Sync';
|
||||||
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
|
|
||||||
import type { Titular } from '../types';
|
import type { Titular, Configuracion } from '../types';
|
||||||
import * as api from '../services/apiService';
|
import * as api from '../services/apiService';
|
||||||
import { useSignalR } from '../hooks/useSignalR';
|
import { useSignalR } from '../hooks/useSignalR';
|
||||||
import FormularioConfiguracion from './FormularioConfiguracion';
|
import FormularioConfiguracion from './FormularioConfiguracion';
|
||||||
import TablaTitulares from './TablaTitulares';
|
import TablaTitulares from './TablaTitulares';
|
||||||
import AddTitularModal from './AddTitularModal';
|
import AddTitularModal from './AddTitularModal';
|
||||||
import EditarTitularModal from './EditarTitularModal';
|
import EditarTitularModal from './EditarTitularModal';
|
||||||
|
import { PowerSwitch } from './PowerSwitch';
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const [titulares, setTitulares] = useState<Titular[]>([]);
|
const [titulares, setTitulares] = useState<Titular[]>([]);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [config, setConfig] = useState<Configuracion | null>(null);
|
||||||
|
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||||
const [isGeneratingCsv, setIsGeneratingCsv] = useState(false);
|
const [isGeneratingCsv, setIsGeneratingCsv] = useState(false);
|
||||||
const [titularAEditar, setTitularAEditar] = useState<Titular | null>(null);
|
const [titularAEditar, setTitularAEditar] = useState<Titular | null>(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[]) => {
|
const onTitularesActualizados = useCallback((titularesActualizados: Titular[]) => {
|
||||||
console.log("Datos recibidos desde SignalR:", titularesActualizados);
|
|
||||||
setTitulares(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([
|
const { connectionStatus } = useSignalR([
|
||||||
{ eventName: 'TitularesActualizados', callback: onTitularesActualizados }
|
{ 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(() => {
|
useEffect(() => {
|
||||||
api.obtenerTitulares()
|
// Obtenemos la configuración persistente
|
||||||
.then(setTitulares)
|
const fetchConfig = api.obtenerConfiguracion();
|
||||||
.catch(error => console.error("Error al cargar titulares:", error));
|
// 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<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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleReorder = async (titularesReordenados: Titular[]) => {
|
const handleReorder = async (titularesReordenados: Titular[]) => {
|
||||||
setTitulares(titularesReordenados);
|
setTitulares(titularesReordenados);
|
||||||
const payload = titularesReordenados.map((item, index) => ({ id: item.id, nuevoOrden: index }));
|
const payload = titularesReordenados.map((item, index) => ({ id: item.id, nuevoOrden: index }));
|
||||||
try {
|
try {
|
||||||
await api.actualizarOrdenTitulares(payload);
|
await api.actualizarOrdenTitulares(payload);
|
||||||
// Ya no necesitamos hacer nada más, SignalR notificará a todos los clientes.
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error al reordenar:", 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);
|
api.obtenerTitulares().then(setTitulares);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -56,7 +79,6 @@ const Dashboard = () => {
|
|||||||
if (window.confirm('¿Estás seguro de que quieres eliminar este titular?')) {
|
if (window.confirm('¿Estás seguro de que quieres eliminar este titular?')) {
|
||||||
try {
|
try {
|
||||||
await api.eliminarTitular(id);
|
await api.eliminarTitular(id);
|
||||||
// SignalR se encargará de actualizar el estado.
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error al eliminar:", err);
|
console.error("Error al eliminar:", err);
|
||||||
}
|
}
|
||||||
@@ -66,7 +88,6 @@ const Dashboard = () => {
|
|||||||
const handleAdd = async (texto: string) => {
|
const handleAdd = async (texto: string) => {
|
||||||
try {
|
try {
|
||||||
await api.crearTitularManual(texto);
|
await api.crearTitularManual(texto);
|
||||||
// SignalR se encargará de actualizar el estado.
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error al añadir titular:", err);
|
console.error("Error al añadir titular:", err);
|
||||||
}
|
}
|
||||||
@@ -88,10 +109,8 @@ const Dashboard = () => {
|
|||||||
setIsGeneratingCsv(true);
|
setIsGeneratingCsv(true);
|
||||||
try {
|
try {
|
||||||
await api.generarCsvManual();
|
await api.generarCsvManual();
|
||||||
// Opcional: mostrar una notificación de éxito
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error al generar CSV manualmente", error);
|
console.error("Error al generar CSV manually", error);
|
||||||
// Opcional: mostrar una notificación de error
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsGeneratingCsv(false);
|
setIsGeneratingCsv(false);
|
||||||
}
|
}
|
||||||
@@ -100,7 +119,6 @@ const Dashboard = () => {
|
|||||||
const handleSaveEdit = async (id: number, texto: string, viñeta: string) => {
|
const handleSaveEdit = async (id: number, texto: string, viñeta: string) => {
|
||||||
try {
|
try {
|
||||||
await api.actualizarTitular(id, { texto, viñeta: viñeta || null });
|
await api.actualizarTitular(id, { texto, viñeta: viñeta || null });
|
||||||
// SignalR se encargará de actualizar la UI
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error al guardar cambios:", err);
|
console.error("Error al guardar cambios:", err);
|
||||||
}
|
}
|
||||||
@@ -108,29 +126,56 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: { xs: 'column', sm: 'row' },
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2,
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Stack direction="row" spacing={2} alignItems="center">
|
<Stack direction="row" spacing={2} alignItems="center">
|
||||||
<Typography variant="h4" component="h1">
|
<Typography variant="h5" component="h2" sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
Titulares Dashboard
|
Estado del Servidor {getStatusChip()}
|
||||||
</Typography>
|
</Typography>
|
||||||
{getStatusChip()}
|
{config ? (
|
||||||
|
<PowerSwitch
|
||||||
|
checked={config.scrapingActivo}
|
||||||
|
onChange={handleSwitchChange}
|
||||||
|
label={config.scrapingActivo ? "Proceso ON" : "Proceso OFF"}
|
||||||
|
/>
|
||||||
|
) : <CircularProgress size={24} />}
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack direction="row" spacing={2}>
|
|
||||||
|
<Stack direction="row" spacing={2} sx={{ width: { xs: '100%', sm: 'auto' } }}>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="contained" color="success"
|
||||||
startIcon={isGeneratingCsv ? <CircularProgress size={20} /> : <SyncIcon />}
|
startIcon={isGeneratingCsv ? <CircularProgress size={20} color="inherit" /> : <SyncIcon />}
|
||||||
onClick={handleGenerateCsv}
|
onClick={handleGenerateCsv} disabled={isGeneratingCsv}
|
||||||
disabled={isGeneratingCsv}
|
|
||||||
>
|
>
|
||||||
{isGeneratingCsv ? 'Generando...' : 'Generate CSV'}
|
Regenerar CSV
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setModalOpen(true)}>
|
<Button
|
||||||
Add Manual
|
variant="contained" color="primary"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={() => setAddModalOpen(true)}
|
||||||
|
>
|
||||||
|
Titular Manual
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<FormularioConfiguracion />
|
<Accordion defaultExpanded={false} sx={{ mb: 3 }}>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
|
<Typography variant="h6">Configuración</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<FormularioConfiguracion config={config} setConfig={setConfig} />
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
<TablaTitulares
|
<TablaTitulares
|
||||||
titulares={titulares}
|
titulares={titulares}
|
||||||
onReorder={handleReorder}
|
onReorder={handleReorder}
|
||||||
@@ -139,8 +184,8 @@ const Dashboard = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<AddTitularModal
|
<AddTitularModal
|
||||||
open={modalOpen}
|
open={addModalOpen}
|
||||||
onClose={() => setModalOpen(false)}
|
onClose={() => setAddModalOpen(false)}
|
||||||
onAdd={handleAdd}
|
onAdd={handleAdd}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
// frontend/src/components/EditarTitularModal.tsx
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Modal, Box, Typography, TextField, Button } from '@mui/material';
|
import { Modal, Box, Typography, TextField, Button } from '@mui/material';
|
||||||
import type { Titular } from '../types';
|
import type { Titular } from '../types';
|
||||||
@@ -26,17 +24,20 @@ const EditarTitularModal = ({ open, onClose, onSave, titular }: Props) => {
|
|||||||
const [texto, setTexto] = useState('');
|
const [texto, setTexto] = useState('');
|
||||||
const [viñeta, setViñeta] = useState('');
|
const [viñeta, setViñeta] = useState('');
|
||||||
|
|
||||||
// Este efecto actualiza el estado del formulario cuando se selecciona un titular para editar
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (titular) {
|
if (titular) {
|
||||||
setTexto(titular.texto);
|
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]);
|
}, [titular]);
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
|
// Verificamos que el titular exista y que el texto principal no esté vacío.
|
||||||
if (titular && texto.trim()) {
|
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();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,43 +1,40 @@
|
|||||||
// frontend/src/components/FormularioConfiguracion.tsx
|
import { Box, TextField, Button, Paper, CircularProgress, Typography } from '@mui/material';
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { Box, TextField, Button, Paper, Typography, CircularProgress } from '@mui/material';
|
|
||||||
import type { Configuracion } from '../types';
|
import type { Configuracion } from '../types';
|
||||||
import * as api from '../services/apiService';
|
import * as api from '../services/apiService';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
const FormularioConfiguracion = () => {
|
interface Props {
|
||||||
const [config, setConfig] = useState<Configuracion | null>(null);
|
config: Configuracion | null;
|
||||||
const [loading, setLoading] = useState(true);
|
setConfig: React.Dispatch<React.SetStateAction<Configuracion | null>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormularioConfiguracion = ({ config, setConfig }: Props) => {
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
if (!config) {
|
||||||
api.obtenerConfiguracion()
|
return <CircularProgress />;
|
||||||
.then(data => {
|
}
|
||||||
setConfig(data);
|
|
||||||
setLoading(false);
|
|
||||||
})
|
|
||||||
.catch(err => console.error("Error al cargar configuración", err));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!config) return;
|
|
||||||
const { name, value } = event.target;
|
const { name, value } = event.target;
|
||||||
setConfig({
|
const numericFields = ['intervaloMinutos', 'cantidadTitularesAScrapear'];
|
||||||
...config,
|
|
||||||
[name]: name === 'rutaCsv' ? value : Number(value) // Convertir a número si no es la ruta
|
setConfig(prevConfig => prevConfig ? {
|
||||||
});
|
...prevConfig,
|
||||||
|
[name]: numericFields.includes(name) ? Number(value) : value
|
||||||
|
} : null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (event: React.FormEvent) => {
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!config) return;
|
if (!config) return;
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSuccess(false);
|
setSuccess(false);
|
||||||
try {
|
try {
|
||||||
await api.guardarConfiguracion(config);
|
await api.guardarConfiguracion(config);
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
setTimeout(() => setSuccess(false), 2000); // El mensaje de éxito desaparece después de 2s
|
setTimeout(() => setSuccess(false), 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error al guardar configuración", err);
|
console.error("Error al guardar configuración", err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -45,32 +42,22 @@ const FormularioConfiguracion = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <CircularProgress />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!config) {
|
|
||||||
return <Typography color="error">No se pudo cargar la configuración.</Typography>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper elevation={3} sx={{ padding: 2, marginBottom: 3 }}>
|
<Paper elevation={0} sx={{ padding: 2 }}>
|
||||||
<Typography variant="h6" gutterBottom>Configuración</Typography>
|
|
||||||
<Box component="form" onSubmit={handleSubmit}>
|
<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} />
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth name="rutaCsv" label="Ruta del archivo CSV"
|
fullWidth
|
||||||
value={config.rutaCsv} onChange={handleChange}
|
name="viñetaPorDefecto"
|
||||||
variant="outlined" sx={{ marginBottom: 2 }} disabled={saving}
|
label="Viñeta por Defecto"
|
||||||
/>
|
value={config.viñetaPorDefecto}
|
||||||
<TextField
|
onChange={handleChange}
|
||||||
fullWidth name="intervaloMinutos" label="Intervalo de Actualización (minutos)"
|
variant="outlined"
|
||||||
value={config.intervaloMinutos} onChange={handleChange}
|
sx={{ mb: 2 }}
|
||||||
type="number" variant="outlined" sx={{ marginBottom: 2 }} disabled={saving}
|
disabled={saving}
|
||||||
/>
|
helperText="El símbolo a usar si un titular no tiene una viñeta específica."
|
||||||
<TextField
|
|
||||||
fullWidth name="cantidadTitularesAScrapear" label="Titulares a Capturar por Ciclo"
|
|
||||||
value={config.cantidadTitularesAScrapear} onChange={handleChange}
|
|
||||||
type="number" variant="outlined" sx={{ marginBottom: 2 }} disabled={saving}
|
|
||||||
/>
|
/>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center' }}>
|
||||||
{success && <Typography color="success.main" sx={{ mr: 2 }}>¡Guardado!</Typography>}
|
{success && <Typography color="success.main" sx={{ mr: 2 }}>¡Guardado!</Typography>}
|
||||||
|
|||||||
57
frontend/src/components/PowerSwitch.tsx
Normal file
57
frontend/src/components/PowerSwitch.tsx
Normal file
@@ -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<HTMLInputElement>) => void;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Componente funcional que envuelve nuestro switch estilizado
|
||||||
|
export const PowerSwitch = ({ checked, onChange, label }: PowerSwitchProps) => {
|
||||||
|
return (
|
||||||
|
<FormControlLabel
|
||||||
|
control={<StyledSwitch checked={checked} onChange={onChange} />}
|
||||||
|
label={label}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
// frontend/src/components/TablaTitulares.tsx
|
// frontend/src/components/TablaTitulares.tsx
|
||||||
|
|
||||||
import {
|
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';
|
} from '@mui/material';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
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 { DndContext, closestCenter, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core';
|
||||||
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import type { Titular } from '../types';
|
import type { Titular } from '../types';
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
|
||||||
|
|
||||||
// La prop `onDelete` se añade para comunicar el evento al componente padre
|
|
||||||
interface SortableRowProps {
|
interface SortableRowProps {
|
||||||
titular: Titular;
|
titular: Titular;
|
||||||
onDelete: (id: number) => void;
|
onDelete: (id: number) => void;
|
||||||
@@ -26,28 +25,45 @@ const SortableRow = ({ titular, onDelete, onEdit }: SortableRowProps) => {
|
|||||||
transition,
|
transition,
|
||||||
};
|
};
|
||||||
|
|
||||||
const getChipColor = (tipo: Titular['tipo']) => {
|
const getChipColor = (tipo: Titular['tipo']): "success" | "warning" | "info" => {
|
||||||
if (tipo === 'Edited') return 'warning';
|
if (tipo === 'Edited') return 'warning';
|
||||||
if (tipo === 'Manual') return 'info';
|
if (tipo === 'Manual') return 'info';
|
||||||
return 'success';
|
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 (
|
return (
|
||||||
<TableRow ref={setNodeRef} style={style} {...attributes} >
|
<TableRow ref={setNodeRef} style={style} {...attributes} sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
|
||||||
{/* El handle de arrastre ahora es un ícono */}
|
<TableCell sx={{ cursor: 'grab', verticalAlign: 'middle' }} {...listeners}>
|
||||||
<TableCell sx={{ cursor: 'grab' }} {...listeners}>
|
<DragHandleIcon sx={{ color: 'text.secondary' }} />
|
||||||
<DragHandleIcon />
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{titular.texto}</TableCell>
|
<TableCell sx={{ verticalAlign: 'middle' }}>{titular.texto}</TableCell>
|
||||||
<TableCell>
|
<TableCell sx={{ verticalAlign: 'middle' }}>
|
||||||
<Chip label={titular.tipo || 'Scraped'} color={getChipColor(titular.tipo)} size="small" />
|
<Chip label={titular.tipo || 'Scraped'} color={getChipColor(titular.tipo)} size="small" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{titular.fuente}</TableCell>
|
<TableCell sx={{ verticalAlign: 'middle' }}>
|
||||||
<TableCell>
|
{titular.urlFuente ? (
|
||||||
|
<Link href={titular.urlFuente} target="_blank" rel="noopener noreferrer" underline="hover" color="primary.light">
|
||||||
|
{formatFuente(titular.fuente)}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
formatFuente(titular.fuente)
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ verticalAlign: 'middle', textAlign: 'right' }}>
|
||||||
<IconButton size="small" onClick={(e) => { e.stopPropagation(); onEdit(titular); }}>
|
<IconButton size="small" onClick={(e) => { e.stopPropagation(); onEdit(titular); }}>
|
||||||
<EditIcon fontSize="small" />
|
<EditIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton size="small" onClick={(e) => { e.stopPropagation(); onDelete(titular.id); }}>
|
<IconButton size="small" onClick={(e) => { e.stopPropagation(); onDelete(titular.id); }} sx={{ color: '#ef4444' }}>
|
||||||
<DeleteIcon />
|
<DeleteIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -63,39 +79,38 @@ interface TablaTitularesProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TablaTitulares = ({ titulares, onReorder, onDelete, onEdit }: 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 handleDragEnd = (event: DragEndEvent) => {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
if (over && active.id !== over.id) {
|
if (over && active.id !== over.id) {
|
||||||
const oldIndex = titulares.findIndex((item) => item.id === active.id);
|
const oldIndex = titulares.findIndex((item) => item.id === active.id);
|
||||||
const newIndex = titulares.findIndex((item) => item.id === over.id);
|
const newIndex = titulares.findIndex((item) => item.id === over.id);
|
||||||
const newArray = arrayMove(titulares, oldIndex, newIndex);
|
onReorder(arrayMove(titulares, oldIndex, newIndex));
|
||||||
onReorder(newArray); // Pasamos el nuevo array al padre para que gestione el estado y la llamada a la API
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (titulares.length === 0) {
|
if (titulares.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Paper elevation={3} sx={{ padding: 3, textAlign: 'center' }}>
|
<Paper elevation={0} sx={{ p: 3, textAlign: 'center' }}>
|
||||||
<Typography>No hay titulares para mostrar.</Typography>
|
<Typography>No hay titulares para mostrar.</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper elevation={3}>
|
<Paper elevation={0} sx={{ overflow: 'hidden' }}>
|
||||||
<TableContainer>
|
<TableContainer>
|
||||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
<SortableContext items={titulares.map(t => t.id)} strategy={verticalListSortingStrategy}>
|
<SortableContext items={titulares.map(t => t.id)} strategy={verticalListSortingStrategy}>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow sx={{ '& .MuiTableCell-root': { borderBottom: '1px solid rgba(255, 255, 255, 0.12)' } }}>
|
||||||
<TableCell style={{ width: 50 }}></TableCell>
|
<TableCell sx={{ width: 50 }} />
|
||||||
<TableCell>Texto del Titular</TableCell>
|
<TableCell sx={{ textTransform: 'uppercase', color: 'text.secondary', letterSpacing: '0.05em' }}>Texto del Titular</TableCell>
|
||||||
<TableCell>Tipo</TableCell>
|
<TableCell sx={{ textTransform: 'uppercase', color: 'text.secondary', letterSpacing: '0.05em' }}>Tipo</TableCell>
|
||||||
<TableCell>Fuente</TableCell>
|
<TableCell sx={{ textTransform: 'uppercase', color: 'text.secondary', letterSpacing: '0.05em' }}>Fuente</TableCell>
|
||||||
<TableCell>Acciones</TableCell>
|
<TableCell sx={{ textTransform: 'uppercase', color: 'text.secondary', letterSpacing: '0.05em', textAlign: 'right' }}>Acciones</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|||||||
@@ -42,10 +42,19 @@ export const obtenerConfiguracion = async (): Promise<Configuracion> => {
|
|||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const guardarConfiguracion = async (config: Configuracion): Promise<void> => {
|
export const guardarConfiguracion = async (config: Omit<Configuracion, 'scrapingActivo'>): Promise<void> => {
|
||||||
await apiClient.post('/configuracion', config);
|
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<void> => {
|
||||||
|
await apiClient.post('/estado-proceso', { activo });
|
||||||
|
};
|
||||||
|
|
||||||
export const generarCsvManual = async (): Promise<void> => {
|
export const generarCsvManual = async (): Promise<void> => {
|
||||||
await apiClient.post('/acciones/generar-csv');
|
await apiClient.post('/acciones/generar-csv');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,5 +17,6 @@ export interface Configuracion {
|
|||||||
rutaCsv: string;
|
rutaCsv: string;
|
||||||
intervaloMinutos: number;
|
intervaloMinutos: number;
|
||||||
cantidadTitularesAScrapear: number;
|
cantidadTitularesAScrapear: number;
|
||||||
//limiteTotalEnDb: number;
|
scrapingActivo: boolean;
|
||||||
|
viñetaPorDefecto: string;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user