Versión 1.0: Aplicación funcionalmente completa con todas las características principales implementadas.

This commit is contained in:
2025-10-29 11:36:20 -03:00
parent 5b3dede4d5
commit 3fbb254ac3
19 changed files with 587 additions and 250 deletions

View File

@@ -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)

View File

@@ -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 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." }); return Ok(new { message = "Configuración guardada correctamente." });
} }
catch (Exception ex)
{
return StatusCode(500, $"Error al guardar el archivo de configuración: {ex.Message}");
}
}
} }

View File

@@ -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; } }
}

View 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;
}
}

View File

@@ -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)
{ {
var articulo = articulosNuevos[i]; listaCombinada.Add(new Titular
{
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<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++;
}
}
}
// 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 = @" var sqlInsert = @"
INSERT INTO Titulares (Texto, UrlFuente, OrdenVisual, Tipo, Fuente) INSERT INTO Titulares (Texto, UrlFuente, ModificadoPorUsuario, EsEntradaManual, OrdenVisual, Tipo, Fuente, Viñeta)
VALUES (@Texto, @UrlFuente, @OrdenVisual, 'Scraped', 'eldia.com'); VALUES (@Texto, @UrlFuente, @ModificadoPorUsuario, @EsEntradaManual, @OrdenVisual, @Tipo, @Fuente, @Viñeta);
"; ";
await connection.ExecuteAsync(sqlInsert, new { articulo.Texto, articulo.UrlFuente, OrdenVisual = i }, transaction: transaction); await connection.ExecuteAsync(sqlInsert, titular, transaction: transaction);
} }
}
// --- 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);
transaction.Commit(); transaction.Commit();
} }

View File

@@ -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; } = "•";
} }

View File

@@ -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 =>

View File

@@ -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)
{ {

View 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();
}
}

View File

@@ -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,65 +11,76 @@ 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();
} }
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
while (!stoppingToken.IsCancellationRequested) 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<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) catch (Exception ex)
{ {
_logger.LogError(ex, "Ocurrió un error durante el proceso de scraping."); _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); }
}
}
private async Task EsperarIntervaloAsync(int minutos, CancellationToken stoppingToken)
{
_logger.LogInformation("Proceso en espera por {minutos} minutos.", minutos);
try try
{ {
_delayCts = new CancellationTokenSource(); _delayCts = new CancellationTokenSource();
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, _delayCts.Token); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, _delayCts.Token);
await Task.Delay(TimeSpan.FromMinutes(intervaloEnMinutos), linkedCts.Token); await Task.Delay(TimeSpan.FromMinutes(minutos), linkedCts.Token);
} }
catch (TaskCanceledException) catch (TaskCanceledException)
{ {
@@ -83,11 +92,11 @@ public class ProcesoScrapingWorker : BackgroundService, IDisposable
_delayCts = null; _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();
} }

View File

@@ -1,5 +1,6 @@
{ {
"RutaCsv": "C:\\temp\\titulares.csv", "RutaCsv": "C:\\temp\\titulares.csv",
"IntervaloMinutos": 5, "IntervaloMinutos": 15,
"CantidadTitularesAScrapear": 5 "CantidadTitularesAScrapear": 4,
"ScrapingActivo": false
} }

View File

@@ -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>
); );
} }

View File

@@ -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
<Stack direction="row" spacing={2} alignItems="center"> sx={{
<Typography variant="h4" component="h1"> display: 'flex',
Titulares Dashboard flexDirection: { xs: 'column', sm: 'row' },
</Typography> justifyContent: 'space-between',
{getStatusChip()} alignItems: 'center',
</Stack> gap: 2,
<Stack direction="row" spacing={2}> mb: 3,
<Button }}
variant="outlined"
startIcon={isGeneratingCsv ? <CircularProgress size={20} /> : <SyncIcon />}
onClick={handleGenerateCsv}
disabled={isGeneratingCsv}
> >
{isGeneratingCsv ? 'Generando...' : 'Generate CSV'} <Stack direction="row" spacing={2} alignItems="center">
<Typography variant="h5" component="h2" sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
Estado del Servidor {getStatusChip()}
</Typography>
{config ? (
<PowerSwitch
checked={config.scrapingActivo}
onChange={handleSwitchChange}
label={config.scrapingActivo ? "Proceso ON" : "Proceso OFF"}
/>
) : <CircularProgress size={24} />}
</Stack>
<Stack direction="row" spacing={2} sx={{ width: { xs: '100%', sm: 'auto' } }}>
<Button
variant="contained" color="success"
startIcon={isGeneratingCsv ? <CircularProgress size={20} color="inherit" /> : <SyncIcon />}
onClick={handleGenerateCsv} disabled={isGeneratingCsv}
>
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}
/> />

View File

@@ -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();
} }
}; };

View File

@@ -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>}

View 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}
/>
);
};

View File

@@ -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>

View File

@@ -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');
}; };

View File

@@ -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;
} }