Feat: Edición y Manejo de Titulares, entre otros.
This commit is contained in:
@@ -47,9 +47,13 @@ public class TitularesController : ControllerBase
|
|||||||
[HttpPut("{id}")]
|
[HttpPut("{id}")]
|
||||||
public async Task<IActionResult> Actualizar(int id, [FromBody] ActualizarTitularDto titularDto)
|
public async Task<IActionResult> Actualizar(int id, [FromBody] ActualizarTitularDto titularDto)
|
||||||
{
|
{
|
||||||
var resultado = await _repositorio.ActualizarTextoAsync(id, titularDto);
|
if (!ModelState.IsValid)
|
||||||
|
{
|
||||||
|
return BadRequest(ModelState);
|
||||||
|
}
|
||||||
|
var resultado = await _repositorio.ActualizarTitularAsync(id, titularDto);
|
||||||
if (!resultado) return NotFound();
|
if (!resultado) return NotFound();
|
||||||
await NotificarCambios(); // Notificamos después de actualizar
|
await NotificarCambios();
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,15 +39,25 @@ public class TitularRepositorio
|
|||||||
return await connection.ExecuteScalarAsync<int>(sql, new { titularDto.Texto, OrdenVisual = nuevoOrden });
|
return await connection.ExecuteScalarAsync<int>(sql, new { titularDto.Texto, OrdenVisual = nuevoOrden });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> ActualizarTextoAsync(int id, ActualizarTitularDto titularDto)
|
public async Task<bool> ActualizarTitularAsync(int id, ActualizarTitularDto titularDto)
|
||||||
{
|
{
|
||||||
using var connection = CreateConnection();
|
using var connection = CreateConnection();
|
||||||
|
// La consulta SQL actualiza Texto, Viñeta y las banderas de estado
|
||||||
var sql = @"
|
var sql = @"
|
||||||
UPDATE Titulares
|
UPDATE Titulares
|
||||||
SET Texto = @Texto, ModificadoPorUsuario = 1, Tipo = 'Edited'
|
SET
|
||||||
|
Texto = @Texto,
|
||||||
|
Viñeta = @Viñeta,
|
||||||
|
ModificadoPorUsuario = 1,
|
||||||
|
Tipo = 'Edited'
|
||||||
WHERE Id = @Id;
|
WHERE Id = @Id;
|
||||||
";
|
";
|
||||||
var affectedRows = await connection.ExecuteAsync(sql, new { titularDto.Texto, Id = id });
|
var affectedRows = await connection.ExecuteAsync(sql, new
|
||||||
|
{
|
||||||
|
titularDto.Texto,
|
||||||
|
titularDto.Viñeta,
|
||||||
|
Id = id
|
||||||
|
});
|
||||||
return affectedRows > 0;
|
return affectedRows > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +93,7 @@ public class TitularRepositorio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SincronizarDesdeScraping(List<ArticuloScrapeado> articulosScrapeados, int limiteTotal)
|
public async Task SincronizarDesdeScraping(List<ArticuloScrapeado> articulosScrapeados, int cantidadALimitar)
|
||||||
{
|
{
|
||||||
using var connection = CreateConnection();
|
using var connection = CreateConnection();
|
||||||
await connection.OpenAsync();
|
await connection.OpenAsync();
|
||||||
@@ -91,12 +101,11 @@ public class TitularRepositorio
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 1. Obtener las URLs que ya tenemos en la base de datos
|
// --- PARTE 1: AÑADIR NUEVOS TITULARES ---
|
||||||
var urlsEnDb = (await connection.QueryAsync<string>(
|
var urlsEnDb = (await connection.QueryAsync<string>(
|
||||||
"SELECT UrlFuente FROM Titulares WHERE UrlFuente IS NOT NULL", transaction: transaction))
|
"SELECT UrlFuente FROM Titulares WHERE UrlFuente IS NOT NULL", transaction: transaction))
|
||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
|
|
||||||
// 2. Filtrar para quedarnos solo con los artículos que son realmente nuevos
|
|
||||||
var articulosNuevos = articulosScrapeados
|
var articulosNuevos = articulosScrapeados
|
||||||
.Where(a => !urlsEnDb.Contains(a.UrlFuente))
|
.Where(a => !urlsEnDb.Contains(a.UrlFuente))
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -104,48 +113,45 @@ public class TitularRepositorio
|
|||||||
if (articulosNuevos.Any())
|
if (articulosNuevos.Any())
|
||||||
{
|
{
|
||||||
var cantidadNuevos = articulosNuevos.Count;
|
var cantidadNuevos = articulosNuevos.Count;
|
||||||
|
|
||||||
// 3. Hacer espacio para los nuevos: empujamos todos los existentes hacia abajo
|
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
"UPDATE Titulares SET OrdenVisual = OrdenVisual + @cantidadNuevos",
|
"UPDATE Titulares SET OrdenVisual = OrdenVisual + @cantidadNuevos",
|
||||||
new { cantidadNuevos },
|
new { cantidadNuevos },
|
||||||
transaction: transaction);
|
transaction: transaction);
|
||||||
|
|
||||||
// 4. Insertar los nuevos artículos al principio (OrdenVisual 0, 1, 2...)
|
|
||||||
for (int i = 0; i < cantidadNuevos; i++)
|
for (int i = 0; i < cantidadNuevos; i++)
|
||||||
{
|
{
|
||||||
var articulo = articulosNuevos[i];
|
var articulo = articulosNuevos[i];
|
||||||
var sqlInsert = @"
|
var sqlInsert = @"
|
||||||
INSERT INTO Titulares (Texto, UrlFuente, ModificadoPorUsuario, EsEntradaManual, OrdenVisual, Tipo, Fuente)
|
INSERT INTO Titulares (Texto, UrlFuente, OrdenVisual, Tipo, Fuente)
|
||||||
VALUES (@Texto, @UrlFuente, 0, 0, @OrdenVisual, 'Scraped', 'eldia.com');
|
VALUES (@Texto, @UrlFuente, @OrdenVisual, 'Scraped', 'eldia.com');
|
||||||
";
|
";
|
||||||
await connection.ExecuteAsync(sqlInsert, new
|
await connection.ExecuteAsync(sqlInsert, new { articulo.Texto, articulo.UrlFuente, OrdenVisual = i }, transaction: transaction);
|
||||||
{
|
}
|
||||||
articulo.Texto,
|
|
||||||
articulo.UrlFuente,
|
|
||||||
OrdenVisual = i
|
|
||||||
}, transaction: transaction);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Purgar los más antiguos si superamos el límite, ignorando los manuales
|
// --- PARTE 2: PURGAR LOS SOBRANTES ---
|
||||||
var sqlDelete = @"
|
var sqlDelete = @"
|
||||||
DELETE FROM Titulares
|
DELETE FROM Titulares
|
||||||
WHERE Id IN (
|
WHERE Id IN (
|
||||||
SELECT Id FROM Titulares
|
-- 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
|
WHERE EsEntradaManual = 0
|
||||||
ORDER BY OrdenVisual DESC
|
ORDER BY OrdenVisual ASC
|
||||||
OFFSET @limiteTotal ROWS
|
OFFSET @Limite ROWS
|
||||||
);
|
);
|
||||||
";
|
";
|
||||||
await connection.ExecuteAsync(sqlDelete, new { limiteTotal }, transaction: transaction);
|
// Usamos cantidadALimitar como el límite de cuántos conservar.
|
||||||
}
|
await connection.ExecuteAsync(sqlDelete, new { Limite = cantidadALimitar }, transaction: transaction);
|
||||||
|
|
||||||
transaction.Commit();
|
transaction.Commit();
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
transaction.Rollback();
|
transaction.Rollback();
|
||||||
throw; // Relanzamos la excepción para que el worker sepa que algo falló
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,5 +7,5 @@ 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 int LimiteTotalEnDb { get; set; } = 50;
|
||||||
}
|
}
|
||||||
@@ -27,6 +27,7 @@ public class CrearTitularDto
|
|||||||
public class ActualizarTitularDto
|
public class ActualizarTitularDto
|
||||||
{
|
{
|
||||||
public required string Texto { get; set; }
|
public required string Texto { get; set; }
|
||||||
|
public string? Viñeta { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// DTO para el reordenamiento
|
// DTO para el reordenamiento
|
||||||
|
|||||||
@@ -26,53 +26,35 @@ public class CsvService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// La cláusula Where asegura que los Encabezados no sean nulos o vacíos.
|
var directorio = Path.GetDirectoryName(rutaArchivo);
|
||||||
var grupos = titulares
|
if (directorio != null)
|
||||||
.Where(t => !string.IsNullOrEmpty(t.Encabezado))
|
{
|
||||||
.GroupBy(t => t.Encabezado);
|
Directory.CreateDirectory(directorio);
|
||||||
|
}
|
||||||
|
|
||||||
var config = new CsvConfiguration(CultureInfo.InvariantCulture)
|
var config = new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||||
{
|
{
|
||||||
Delimiter = ";",
|
Delimiter = ";",
|
||||||
|
HasHeaderRecord = false,
|
||||||
|
// Esta linea es para que nunca se ponga comillas en ningún campo.
|
||||||
|
ShouldQuote = args => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
await using var writer = new StreamWriter(rutaArchivo);
|
await using var writer = new StreamWriter(rutaArchivo);
|
||||||
await using var csv = new CsvWriter(writer, config);
|
await using var csv = new CsvWriter(writer, config);
|
||||||
|
|
||||||
// Escribimos la cabecera principal
|
|
||||||
csv.WriteField("Text");
|
csv.WriteField("Text");
|
||||||
csv.WriteField("Heading");
|
|
||||||
csv.WriteField("Value");
|
|
||||||
csv.WriteField("Up");
|
|
||||||
csv.WriteField("Down");
|
|
||||||
csv.WriteField("Change");
|
|
||||||
csv.WriteField("Bullet");
|
csv.WriteField("Bullet");
|
||||||
await csv.NextRecordAsync();
|
await csv.NextRecordAsync();
|
||||||
|
|
||||||
foreach (var grupo in grupos)
|
foreach (var titular in titulares)
|
||||||
{
|
|
||||||
csv.WriteField("");
|
|
||||||
csv.WriteField($" {grupo.Key!.ToUpper()} ");
|
|
||||||
csv.WriteField("");
|
|
||||||
await csv.NextRecordAsync();
|
|
||||||
|
|
||||||
await csv.NextRecordAsync();
|
|
||||||
|
|
||||||
foreach (var titular in grupo)
|
|
||||||
{
|
{
|
||||||
csv.WriteField(titular.Texto);
|
csv.WriteField(titular.Texto);
|
||||||
csv.WriteField(""); // Heading
|
|
||||||
csv.WriteField(""); // Value
|
|
||||||
csv.WriteField(""); // Up
|
|
||||||
csv.WriteField(""); // Down
|
|
||||||
csv.WriteField(""); // Change
|
|
||||||
csv.WriteField(titular.Viñeta ?? "•");
|
csv.WriteField(titular.Viñeta ?? "•");
|
||||||
await csv.NextRecordAsync();
|
await csv.NextRecordAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
await csv.NextRecordAsync();
|
_logger.LogInformation("CSV generado exitosamente con formato simplificado.");
|
||||||
}
|
|
||||||
_logger.LogInformation("CSV generado exitosamente.");
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,17 +9,27 @@ using Titulares.Api.Models;
|
|||||||
|
|
||||||
namespace Titulares.Api.Workers;
|
namespace Titulares.Api.Workers;
|
||||||
|
|
||||||
public class ProcesoScrapingWorker : BackgroundService
|
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 IOptionsMonitor<ConfiguracionApp> _configuracion;
|
||||||
|
private readonly IDisposable? _optionsReloadToken;
|
||||||
|
|
||||||
|
private CancellationTokenSource? _delayCts;
|
||||||
|
|
||||||
public ProcesoScrapingWorker(ILogger<ProcesoScrapingWorker> logger, IServiceProvider serviceProvider, IOptionsMonitor<ConfiguracionApp> configuracion)
|
public ProcesoScrapingWorker(ILogger<ProcesoScrapingWorker> logger, IServiceProvider serviceProvider, IOptionsMonitor<ConfiguracionApp> configuracion)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_serviceProvider = serviceProvider;
|
_serviceProvider = serviceProvider;
|
||||||
_configuracion = configuracion;
|
_configuracion = configuracion;
|
||||||
|
_optionsReloadToken = _configuracion.OnChange(OnConfigurationChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnConfigurationChanged(ConfiguracionApp newConfig)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("La configuración ha cambiado. Interrumpiendo la espera actual para aplicar los nuevos ajustes.");
|
||||||
|
_delayCts?.Cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
@@ -27,43 +37,27 @@ public class ProcesoScrapingWorker : BackgroundService
|
|||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
var configActual = _configuracion.CurrentValue;
|
var configActual = _configuracion.CurrentValue;
|
||||||
|
|
||||||
_logger.LogInformation("Iniciando ciclo de scraping con cantidad: {cantidad}", configActual.CantidadTitularesAScrapear);
|
_logger.LogInformation("Iniciando ciclo de scraping con cantidad: {cantidad}", configActual.CantidadTitularesAScrapear);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Creamos un 'scope' para obtener instancias 'scoped' de nuestros servicios.
|
|
||||||
// Es la práctica correcta en servicios de larga duración.
|
|
||||||
using (var scope = _serviceProvider.CreateScope())
|
using (var scope = _serviceProvider.CreateScope())
|
||||||
{
|
{
|
||||||
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);
|
||||||
|
|
||||||
// Obtener estos valores desde la configuración
|
await repositorio.SincronizarDesdeScraping(articulosScrapeados, cantidadTitulares);
|
||||||
int cantidadAObtener = configActual.CantidadTitularesAScrapear;
|
|
||||||
int limiteTotalEnDb = configActual.LimiteTotalEnDb;
|
|
||||||
|
|
||||||
// 1. Obtener los últimos titulares de la web
|
|
||||||
var articulosScrapeados = await scrapingService.ObtenerUltimosTitulares(cantidadAObtener);
|
|
||||||
|
|
||||||
if (articulosScrapeados.Any())
|
|
||||||
{
|
|
||||||
await repositorio.SincronizarDesdeScraping(articulosScrapeados, limiteTotalEnDb);
|
|
||||||
_logger.LogInformation("Sincronización con la base de datos completada.");
|
_logger.LogInformation("Sincronización con la base de datos completada.");
|
||||||
|
|
||||||
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.");
|
_logger.LogInformation("Notificación enviada a los clientes.");
|
||||||
|
|
||||||
await csvService.GenerarCsvAsync(titularesActualizados);
|
await csvService.GenerarCsvAsync(titularesActualizados);
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogWarning("No se encontraron artículos en el scraping.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -72,7 +66,29 @@ public class ProcesoScrapingWorker : BackgroundService
|
|||||||
|
|
||||||
var intervaloEnMinutos = configActual.IntervaloMinutos;
|
var intervaloEnMinutos = configActual.IntervaloMinutos;
|
||||||
_logger.LogInformation("Proceso en espera por {minutos} minutos.", intervaloEnMinutos);
|
_logger.LogInformation("Proceso en espera por {minutos} minutos.", intervaloEnMinutos);
|
||||||
await Task.Delay(TimeSpan.FromMinutes(intervaloEnMinutos), stoppingToken);
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override void Dispose()
|
||||||
|
{
|
||||||
|
_optionsReloadToken?.Dispose();
|
||||||
|
_delayCts?.Dispose();
|
||||||
|
base.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"RutaCsv": "C:\\temp\\titulares.csv",
|
"RutaCsv": "C:\\temp\\titulares.csv",
|
||||||
"IntervaloMinutos": 5,
|
"IntervaloMinutos": 5,
|
||||||
"CantidadTitularesAScrapear": 5,
|
"CantidadTitularesAScrapear": 5
|
||||||
"LimiteTotalEnDb": 50
|
|
||||||
}
|
}
|
||||||
@@ -11,11 +11,13 @@ 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';
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const [titulares, setTitulares] = useState<Titular[]>([]);
|
const [titulares, setTitulares] = useState<Titular[]>([]);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [isGeneratingCsv, setIsGeneratingCsv] = useState(false);
|
const [isGeneratingCsv, setIsGeneratingCsv] = useState(false);
|
||||||
|
const [titularAEditar, setTitularAEditar] = useState<Titular | null>(null);
|
||||||
|
|
||||||
// Usamos useCallback para que la función de callback no se recree en cada render,
|
// Usamos useCallback para que la función de callback no se recree en cada render,
|
||||||
// evitando que el useEffect del hook se ejecute innecesariamente.
|
// evitando que el useEffect del hook se ejecute innecesariamente.
|
||||||
@@ -95,6 +97,15 @@ const Dashboard = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = async (id: number, texto: string, viñeta: string) => {
|
||||||
|
try {
|
||||||
|
await api.actualizarTitular(id, { texto, viñeta: viñeta || null });
|
||||||
|
// SignalR se encargará de actualizar la UI
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error al guardar cambios:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
@@ -120,8 +131,25 @@ const Dashboard = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<FormularioConfiguracion />
|
<FormularioConfiguracion />
|
||||||
<TablaTitulares titulares={titulares} onReorder={handleReorder} onDelete={handleDelete} />
|
<TablaTitulares
|
||||||
<AddTitularModal open={modalOpen} onClose={() => setModalOpen(false)} onAdd={handleAdd} />
|
titulares={titulares}
|
||||||
|
onReorder={handleReorder}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onEdit={(titular) => setTitularAEditar(titular)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddTitularModal
|
||||||
|
open={modalOpen}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
onAdd={handleAdd}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EditarTitularModal
|
||||||
|
open={titularAEditar !== null}
|
||||||
|
onClose={() => setTitularAEditar(null)}
|
||||||
|
onSave={handleSaveEdit}
|
||||||
|
titular={titularAEditar}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
66
frontend/src/components/EditarTitularModal.tsx
Normal file
66
frontend/src/components/EditarTitularModal.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// frontend/src/components/EditarTitularModal.tsx
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Modal, Box, Typography, TextField, Button } from '@mui/material';
|
||||||
|
import type { Titular } from '../types';
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
position: 'absolute' as 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
width: 400,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
boxShadow: 24,
|
||||||
|
p: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (id: number, texto: string, viñeta: string) => void;
|
||||||
|
titular: Titular | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditarTitularModal = ({ open, onClose, onSave, titular }: Props) => {
|
||||||
|
const [texto, setTexto] = useState('');
|
||||||
|
const [viñeta, setViñeta] = useState('');
|
||||||
|
|
||||||
|
// Este efecto actualiza el estado del formulario cuando se selecciona un titular para editar
|
||||||
|
useEffect(() => {
|
||||||
|
if (titular) {
|
||||||
|
setTexto(titular.texto);
|
||||||
|
setViñeta(titular.viñeta ?? '•'); // Default a '•' si es nulo
|
||||||
|
}
|
||||||
|
}, [titular]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (titular && texto.trim()) {
|
||||||
|
onSave(titular.id, texto.trim(), viñeta.trim());
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose}>
|
||||||
|
<Box sx={style}>
|
||||||
|
<Typography variant="h6" component="h2">Editar Titular</Typography>
|
||||||
|
<TextField
|
||||||
|
fullWidth autoFocus margin="normal" label="Texto del titular"
|
||||||
|
value={texto} onChange={(e) => setTexto(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth margin="normal" label="Viñeta (ej: •, !, o dejar vacío)"
|
||||||
|
value={viñeta} onChange={(e) => setViñeta(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
|
||||||
|
/>
|
||||||
|
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Button onClick={onClose} sx={{ mr: 1 }}>Cancelar</Button>
|
||||||
|
<Button variant="contained" onClick={handleSave}>Guardar Cambios</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditarTitularModal;
|
||||||
@@ -9,14 +9,16 @@ import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, type D
|
|||||||
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
|
// 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;
|
||||||
|
onEdit: (titular: Titular) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SortableRow = ({ titular, onDelete }: SortableRowProps) => {
|
const SortableRow = ({ titular, onDelete, onEdit }: SortableRowProps) => {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: titular.id });
|
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: titular.id });
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
@@ -42,7 +44,9 @@ const SortableRow = ({ titular, onDelete }: SortableRowProps) => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{titular.fuente}</TableCell>
|
<TableCell>{titular.fuente}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{/* Usamos un stopPropagation para que el clic no active el arrastre */}
|
<IconButton size="small" onClick={(e) => { e.stopPropagation(); onEdit(titular); }}>
|
||||||
|
<EditIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
<IconButton size="small" onClick={(e) => { e.stopPropagation(); onDelete(titular.id); }}>
|
<IconButton size="small" onClick={(e) => { e.stopPropagation(); onDelete(titular.id); }}>
|
||||||
<DeleteIcon />
|
<DeleteIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -55,9 +59,10 @@ interface TablaTitularesProps {
|
|||||||
titulares: Titular[];
|
titulares: Titular[];
|
||||||
onReorder: (titulares: Titular[]) => void;
|
onReorder: (titulares: Titular[]) => void;
|
||||||
onDelete: (id: number) => void;
|
onDelete: (id: number) => void;
|
||||||
|
onEdit: (titular: Titular) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TablaTitulares = ({ titulares, onReorder, onDelete }: 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: 5 } })); // Evita activar el drag con un simple clic
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
@@ -95,7 +100,7 @@ const TablaTitulares = ({ titulares, onReorder, onDelete }: TablaTitularesProps)
|
|||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{titulares.map((titular) => (
|
{titulares.map((titular) => (
|
||||||
<SortableRow key={titular.id} titular={titular} onDelete={onDelete} />
|
<SortableRow key={titular.id} titular={titular} onDelete={onDelete} onEdit={onEdit} />
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ const apiClient = axios.create({
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export interface ActualizarTitularPayload {
|
||||||
|
texto: string;
|
||||||
|
viñeta: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export const obtenerTitulares = async (): Promise<Titular[]> => {
|
export const obtenerTitulares = async (): Promise<Titular[]> => {
|
||||||
const response = await apiClient.get('/titulares');
|
const response = await apiClient.get('/titulares');
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -44,3 +49,7 @@ export const guardarConfiguracion = async (config: Configuracion): Promise<void>
|
|||||||
export const generarCsvManual = async (): Promise<void> => {
|
export const generarCsvManual = async (): Promise<void> => {
|
||||||
await apiClient.post('/acciones/generar-csv');
|
await apiClient.post('/acciones/generar-csv');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const actualizarTitular = async (id: number, payload: ActualizarTitularPayload): Promise<void> => {
|
||||||
|
await apiClient.put(`/titulares/${id}`, payload);
|
||||||
|
};
|
||||||
@@ -10,11 +10,12 @@ export interface Titular {
|
|||||||
fechaCreacion: string;
|
fechaCreacion: string;
|
||||||
tipo: 'Scraped' | 'Edited' | 'Manual' | null;
|
tipo: 'Scraped' | 'Edited' | 'Manual' | null;
|
||||||
fuente: string | null;
|
fuente: string | null;
|
||||||
|
viñeta: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Configuracion {
|
export interface Configuracion {
|
||||||
rutaCsv: string;
|
rutaCsv: string;
|
||||||
intervaloMinutos: number;
|
intervaloMinutos: number;
|
||||||
cantidadTitularesAScrapear: number;
|
cantidadTitularesAScrapear: number;
|
||||||
limiteTotalEnDb: number;
|
//limiteTotalEnDb: number;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user