Feat: Implementar modificación de Tiradas y mejorar UX/UI
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 12m54s
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 12m54s
**Backend:** - Se ha añadido el endpoint `PUT /api/tiradas` para manejar la modificación de una Tirada, identificada por su clave única (fecha, idPublicacion, idPlanta). - Se implementó un mecanismo de actualización granular para las secciones de la tirada (`bob_RegPublicaciones`), reemplazando la estrategia anterior de "eliminar todo y recrear". - La nueva lógica reconcilia el estado entrante con el de la base de datos, realizando operaciones individuales de `INSERT`, `UPDATE` y `DELETE` para cada sección. - Esto mejora significativamente el rendimiento y proporciona un historial de auditoría mucho más preciso. - Se añadieron los DTOs `UpdateTiradaRequestDto` y `UpdateDetalleSeccionTiradaDto` para soportar el nuevo payload de modificación. - Se expandieron los repositorios `IRegPublicacionSeccionRepository` y `IPubliSeccionRepository` con métodos para operaciones granulares (`UpdateAsync`, `DeleteByIdAsync`, `GetByIdsAndPublicacionAsync`). **Frontend:** - El componente `TiradaFormModal` ha sido refactorizado para funcionar tanto en modo "Crear" como en modo "Editar", recibiendo una prop `tiradaToEdit`. - Se implementó una lógica de carga asíncrona robusta que obtiene los datos completos de una tirada antes de abrir el modal en modo edición. **Mejoras de UI/UX:** - Se ha rediseñado el layout de la lista de tiradas en `GestionarTiradasPage`: - Los botones de acción (Editar, Borrar) y los datos clave (chips de ejemplares y páginas) ahora se encuentran en una cabecera estática. - Estos elementos permanecen fijos en la parte superior y no se desplazan al expandir el acordeón, mejorando la consistencia visual. - Se ha mejorado la tabla de secciones dentro del `TiradaFormModal`: - El botón "+ AGREGAR SECCIÓN" ahora está fijo en la parte inferior de la tabla, permaneciendo siempre visible incluso cuando la lista de secciones tiene scroll. - Al agregar una nueva sección, la lista se desplaza automáticamente hacia abajo para mostrar la nueva fila.
This commit is contained in:
@@ -22,7 +22,8 @@ namespace GestionIntegral.Api.Controllers.Impresion
|
||||
// Permisos para Tiradas (IT001 a IT003)
|
||||
private const string PermisoVerTiradas = "IT001";
|
||||
private const string PermisoRegistrarTirada = "IT002";
|
||||
private const string PermisoEliminarTirada = "IT003"; // Asumo que se refiere a eliminar una tirada completa (cabecera y detalles)
|
||||
private const string PermisoEliminarTirada = "IT003";
|
||||
private const string PermisoModificarTirada = "IT004";
|
||||
|
||||
public TiradasController(ITiradaService tiradaService, ILogger<TiradasController> logger)
|
||||
{
|
||||
@@ -83,6 +84,43 @@ namespace GestionIntegral.Api.Controllers.Impresion
|
||||
return StatusCode(StatusCodes.Status201Created, tiradaCreada);
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
[ProducesResponseType(typeof(TiradaDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> ModificarTirada(
|
||||
[FromQuery, BindRequired] DateTime fecha,
|
||||
[FromQuery, BindRequired] int idPublicacion,
|
||||
[FromQuery, BindRequired] int idPlanta,
|
||||
[FromBody] UpdateTiradaRequestDto updateDto)
|
||||
{
|
||||
if (!TienePermiso(PermisoModificarTirada)) return Forbid();
|
||||
if (!ModelState.IsValid) return BadRequest(ModelState);
|
||||
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var (tiradaActualizada, error) = await _tiradaService.ModificarTiradaCompletaAsync(fecha, idPublicacion, idPlanta, updateDto, userId.Value);
|
||||
|
||||
if (error != null)
|
||||
{
|
||||
// Chequear si el error es porque no se encontró la tirada.
|
||||
if (error.StartsWith("No se encontró la tirada"))
|
||||
{
|
||||
return NotFound(new { message = error });
|
||||
}
|
||||
return BadRequest(new { message = error });
|
||||
}
|
||||
|
||||
if (tiradaActualizada == null)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, "Error interno al modificar la tirada.");
|
||||
}
|
||||
|
||||
return Ok(tiradaActualizada);
|
||||
}
|
||||
|
||||
// DELETE: api/tiradas
|
||||
// Se identifica la tirada a eliminar por su combinación única de Fecha, IdPublicacion, IdPlanta
|
||||
[HttpDelete]
|
||||
|
||||
@@ -9,10 +9,11 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
|
||||
{
|
||||
Task<IEnumerable<PubliSeccion>> GetByPublicacionIdAsync(int idPublicacion, bool? soloActivas = null);
|
||||
Task<PubliSeccion?> GetByIdAsync(int idSeccion);
|
||||
Task<IEnumerable<PubliSeccion>> GetByIdsAndPublicacionAsync(IEnumerable<int> idsSeccion, int idPublicacion, bool? soloActivas = null);
|
||||
Task<PubliSeccion?> CreateAsync(PubliSeccion nuevaSeccion, int idUsuario, IDbTransaction transaction);
|
||||
Task<bool> UpdateAsync(PubliSeccion seccionAActualizar, int idUsuario, IDbTransaction transaction);
|
||||
Task<bool> DeleteAsync(int idSeccion, int idUsuario, IDbTransaction transaction);
|
||||
Task<bool> DeleteByPublicacionIdAsync(int idPublicacion, int idUsuarioAuditoria, IDbTransaction transaction); // Ya existe
|
||||
Task<bool> DeleteByPublicacionIdAsync(int idPublicacion, int idUsuarioAuditoria, IDbTransaction transaction);
|
||||
Task<bool> ExistsByNameInPublicacionAsync(string nombre, int idPublicacion, int? excludeIdSeccion = null);
|
||||
Task<bool> IsInUseAsync(int idSeccion); // Verificar en bob_RegPublicaciones, bob_StockBobinas
|
||||
Task<IEnumerable<(PubliSeccionHistorico Historial, string NombreUsuarioModifico)>> GetHistorialAsync(
|
||||
|
||||
@@ -169,6 +169,32 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
|
||||
return rowsAffected == 1;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<PubliSeccion>> GetByIdsAndPublicacionAsync(IEnumerable<int> idsSeccion, int idPublicacion, bool? soloActivas = null)
|
||||
{
|
||||
if (idsSeccion == null || !idsSeccion.Any())
|
||||
{
|
||||
return Enumerable.Empty<PubliSeccion>();
|
||||
}
|
||||
|
||||
var sqlBuilder = new StringBuilder(@"
|
||||
SELECT Id_Seccion AS IdSeccion, Id_Publicacion AS IdPublicacion, Nombre, Estado
|
||||
FROM dbo.dist_dtPubliSecciones
|
||||
WHERE Id_Publicacion = @IdPublicacionParam AND Id_Seccion IN @IdsSeccionParam");
|
||||
|
||||
var parameters = new DynamicParameters();
|
||||
parameters.Add("IdPublicacionParam", idPublicacion);
|
||||
parameters.Add("IdsSeccionParam", idsSeccion);
|
||||
|
||||
if (soloActivas.HasValue)
|
||||
{
|
||||
sqlBuilder.Append(" AND Estado = @EstadoParam");
|
||||
parameters.Add("EstadoParam", soloActivas.Value);
|
||||
}
|
||||
|
||||
using var connection = _cf.CreateConnection();
|
||||
return await connection.QueryAsync<PubliSeccion>(sqlBuilder.ToString(), parameters);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(int idSeccion, int idUsuario, IDbTransaction transaction)
|
||||
{
|
||||
var actual = await transaction.Connection!.QuerySingleOrDefaultAsync<PubliSeccion>(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// --- FICHERO MODIFICADO: IRegTiradaRepository.cs ---
|
||||
|
||||
using GestionIntegral.Api.Models.Impresion;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
@@ -6,11 +8,12 @@ using System.Threading.Tasks;
|
||||
|
||||
namespace GestionIntegral.Api.Data.Repositories.Impresion
|
||||
{
|
||||
public interface IRegTiradaRepository // Para bob_RegTiradas
|
||||
public interface IRegTiradaRepository
|
||||
{
|
||||
Task<RegTirada?> GetByIdAsync(int idRegistro);
|
||||
Task<IEnumerable<RegTirada>> GetByCriteriaAsync(DateTime? fecha, int? idPublicacion, int? idPlanta);
|
||||
Task<RegTirada?> CreateAsync(RegTirada nuevaTirada, int idUsuario, IDbTransaction transaction);
|
||||
Task<bool> UpdateAsync(RegTirada tiradaAActualizar, int idUsuario, IDbTransaction transaction);
|
||||
Task<bool> DeleteAsync(int idRegistro, int idUsuario, IDbTransaction transaction); // Si se borra el registro principal
|
||||
Task<bool> DeleteByFechaPublicacionPlantaAsync(DateTime fecha, int idPublicacion, int idPlanta, int idUsuario, IDbTransaction transaction);
|
||||
Task<RegTirada?> GetByFechaPublicacionPlantaAsync(DateTime fecha, int idPublicacion, int idPlanta, IDbTransaction? transaction = null);
|
||||
@@ -27,9 +30,11 @@ namespace GestionIntegral.Api.Data.Repositories.Impresion
|
||||
|
||||
public interface IRegPublicacionSeccionRepository // Para bob_RegPublicaciones
|
||||
{
|
||||
Task<RegPublicacionSeccion?> GetByIdAsync(int idTirada);
|
||||
Task<IEnumerable<RegPublicacionSeccion>> GetByFechaPublicacionPlantaAsync(DateTime fecha, int idPublicacion, int idPlanta);
|
||||
Task<RegPublicacionSeccion?> CreateAsync(RegPublicacionSeccion nuevaSeccionTirada, int idUsuario, IDbTransaction transaction);
|
||||
Task<bool> UpdateAsync(RegPublicacionSeccion seccionAActualizar, int idUsuario, IDbTransaction transaction);
|
||||
Task<bool> DeleteByIdAsync(int idTirada, int idUsuario, IDbTransaction transaction);
|
||||
Task<bool> DeleteByFechaPublicacionPlantaAsync(DateTime fecha, int idPublicacion, int idPlanta, int idUsuario, IDbTransaction transaction);
|
||||
// Podría tener un DeleteByIdAsync si se permite borrar secciones individuales de una tirada
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,42 @@ namespace GestionIntegral.Api.Data.Repositories.Impresion
|
||||
return inserted;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateAsync(RegTirada tiradaAActualizar, int idUsuario, IDbTransaction transaction)
|
||||
{
|
||||
// 1. Obtener el estado actual para guardarlo en el historial
|
||||
const string sqlSelectActual = "SELECT * FROM dbo.bob_RegTiradas WHERE Id_Registro = @IdRegistro";
|
||||
var estadoActual = await transaction.Connection!.QuerySingleOrDefaultAsync<RegTirada>(sqlSelectActual, new { IdRegistro = tiradaAActualizar.IdRegistro }, transaction);
|
||||
|
||||
if (estadoActual == null)
|
||||
{
|
||||
throw new KeyNotFoundException("No se encontró el registro de tirada a actualizar para generar el historial.");
|
||||
}
|
||||
|
||||
// 2. Guardar el estado PREVIO en el historial
|
||||
const string sqlHistorico = @"INSERT INTO dbo.bob_RegTiradas_H (Id_Registro, Ejemplares, Id_Publicacion, Fecha, Id_Planta, Id_Usuario, FechaMod, TipoMod)
|
||||
VALUES (@IdRegistroParam, @EjemplaresParam, @IdPublicacionParam, @FechaParam, @IdPlantaParam, @IdUsuarioParam, @FechaModParam, @TipoModParam);";
|
||||
await transaction.Connection!.ExecuteAsync(sqlHistorico, new
|
||||
{
|
||||
IdRegistroParam = estadoActual.IdRegistro,
|
||||
EjemplaresParam = estadoActual.Ejemplares,
|
||||
IdPublicacionParam = estadoActual.IdPublicacion,
|
||||
FechaParam = estadoActual.Fecha,
|
||||
IdPlantaParam = estadoActual.IdPlanta,
|
||||
IdUsuarioParam = idUsuario,
|
||||
FechaModParam = DateTime.Now,
|
||||
TipoModParam = "Modificado"
|
||||
}, transaction);
|
||||
|
||||
// 3. Actualizar el registro principal
|
||||
const string sqlUpdate = @"
|
||||
UPDATE dbo.bob_RegTiradas
|
||||
SET Ejemplares = @Ejemplares
|
||||
WHERE Id_Registro = @IdRegistro;";
|
||||
|
||||
var rowsAffected = await transaction.Connection!.ExecuteAsync(sqlUpdate, tiradaAActualizar, transaction);
|
||||
return rowsAffected == 1;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(int idRegistro, int idUsuario, IDbTransaction transaction)
|
||||
{
|
||||
var actual = await GetByIdAsync(idRegistro); // No necesita TX aquí ya que es solo para historial
|
||||
@@ -276,6 +312,66 @@ namespace GestionIntegral.Api.Data.Repositories.Impresion
|
||||
return inserted;
|
||||
}
|
||||
|
||||
public async Task<RegPublicacionSeccion?> GetByIdAsync(int idTirada)
|
||||
{
|
||||
const string sql = @"SELECT Id_Tirada AS IdTirada, Id_Publicacion AS IdPublicacion, Id_Seccion AS IdSeccion, CantPag, Fecha, Id_Planta AS IdPlanta
|
||||
FROM dbo.bob_RegPublicaciones WHERE Id_Tirada = @IdTiradaParam";
|
||||
using var connection = _cf.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<RegPublicacionSeccion>(sql, new { IdTiradaParam = idTirada });
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateAsync(RegPublicacionSeccion seccionAActualizar, int idUsuario, IDbTransaction transaction)
|
||||
{
|
||||
// Obtener estado PREVIO para historial
|
||||
var actual = await GetByIdAsync(seccionAActualizar.IdTirada);
|
||||
if (actual == null) throw new KeyNotFoundException("No se encontró la sección de tirada a actualizar.");
|
||||
|
||||
// Insertar en historial con tipo "Modificado"
|
||||
const string sqlHistorico = @"INSERT INTO dbo.bob_RegPublicaciones_H (Id_Tirada, Id_Publicacion, Id_Seccion, CantPag, Fecha, Id_Planta, Id_Usuario, FechaMod, TipoMod)
|
||||
VALUES (@IdTirada, @IdPublicacion, @IdSeccion, @CantPag, @Fecha, @IdPlanta, @IdUsuario, GETDATE(), 'Modificado');";
|
||||
await transaction.Connection!.ExecuteAsync(sqlHistorico, new
|
||||
{
|
||||
actual.IdTirada,
|
||||
actual.IdPublicacion,
|
||||
actual.IdSeccion,
|
||||
actual.CantPag,
|
||||
actual.Fecha,
|
||||
actual.IdPlanta,
|
||||
IdUsuario = idUsuario
|
||||
}, transaction);
|
||||
|
||||
// Actualizar el registro
|
||||
const string sqlUpdate = "UPDATE dbo.bob_RegPublicaciones SET CantPag = @CantPag WHERE Id_Tirada = @IdTirada";
|
||||
var rowsAffected = await transaction.Connection!.ExecuteAsync(sqlUpdate, seccionAActualizar, transaction);
|
||||
return rowsAffected == 1;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteByIdAsync(int idTirada, int idUsuario, IDbTransaction transaction)
|
||||
{
|
||||
// Obtener estado PREVIO para historial
|
||||
var actual = await GetByIdAsync(idTirada);
|
||||
if (actual == null) return false; // Ya no existe, no hacemos nada.
|
||||
|
||||
// Insertar en historial con tipo "Eliminado"
|
||||
const string sqlHistorico = @"INSERT INTO dbo.bob_RegPublicaciones_H (Id_Tirada, Id_Publicacion, Id_Seccion, CantPag, Fecha, Id_Planta, Id_Usuario, FechaMod, TipoMod)
|
||||
VALUES (@IdTirada, @IdPublicacion, @IdSeccion, @CantPag, @Fecha, @IdPlanta, @IdUsuario, GETDATE(), 'Eliminado');";
|
||||
await transaction.Connection!.ExecuteAsync(sqlHistorico, new
|
||||
{
|
||||
actual.IdTirada,
|
||||
actual.IdPublicacion,
|
||||
actual.IdSeccion,
|
||||
actual.CantPag,
|
||||
actual.Fecha,
|
||||
actual.IdPlanta,
|
||||
IdUsuario = idUsuario
|
||||
}, transaction);
|
||||
|
||||
// Eliminar el registro
|
||||
const string sqlDelete = "DELETE FROM dbo.bob_RegPublicaciones WHERE Id_Tirada = @IdTirada";
|
||||
var rowsAffected = await transaction.Connection!.ExecuteAsync(sqlDelete, new { IdTirada = idTirada }, transaction);
|
||||
return rowsAffected == 1;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteByFechaPublicacionPlantaAsync(DateTime fecha, int idPublicacion, int idPlanta, int idUsuario, IDbTransaction transaction)
|
||||
{
|
||||
var seccionesAEliminar = await GetByFechaPublicacionPlantaAsync(fecha.Date, idPublicacion, idPlanta); // No necesita TX, es SELECT
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GestionIntegral.Api.Dtos.Impresion
|
||||
{
|
||||
public class UpdateDetalleSeccionTiradaDto
|
||||
{
|
||||
// ID del registro en bob_RegPublicaciones.
|
||||
// Si es 0 o null, es una nueva sección a añadir.
|
||||
public int IdRegPublicacionSeccion { get; set; }
|
||||
|
||||
[Required]
|
||||
public int IdSeccion { get; set; }
|
||||
|
||||
[Required, Range(1, 1000)]
|
||||
public int CantPag { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GestionIntegral.Api.Dtos.Impresion
|
||||
{
|
||||
public class UpdateTiradaRequestDto
|
||||
{
|
||||
[Required, Range(1, int.MaxValue)]
|
||||
public int Ejemplares { get; set; }
|
||||
|
||||
[Required]
|
||||
// No necesitamos MinLength(1), ya que una tirada podría quedar sin secciones si el usuario las borra todas.
|
||||
// Por ahora lo quitamos para más flexibilidad.
|
||||
public List<UpdateDetalleSeccionTiradaDto> Secciones { get; set; } = new List<UpdateDetalleSeccionTiradaDto>();
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ namespace GestionIntegral.Api.Services.Impresion
|
||||
{
|
||||
Task<IEnumerable<TiradaDto>> ObtenerTiradasAsync(DateTime? fecha, int? idPublicacion, int? idPlanta);
|
||||
Task<(TiradaDto? TiradaCreada, string? Error)> RegistrarTiradaCompletaAsync(CreateTiradaRequestDto createDto, int idUsuario);
|
||||
Task<(TiradaDto? TiradaActualizada, string? Error)> ModificarTiradaCompletaAsync(DateTime fecha, int idPublicacion, int idPlanta, UpdateTiradaRequestDto updateDto, int idUsuario); // <-- AÑADIDO
|
||||
Task<(bool Exito, string? Error)> EliminarTiradaCompletaAsync(DateTime fecha, int idPublicacion, int idPlanta, int idUsuario);
|
||||
Task<IEnumerable<RegTiradaHistorialDto>> ObtenerRegTiradasHistorialAsync(
|
||||
DateTime? fechaDesde, DateTime? fechaHasta,
|
||||
|
||||
@@ -182,6 +182,104 @@ namespace GestionIntegral.Api.Services.Impresion
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(TiradaDto? TiradaActualizada, string? Error)> ModificarTiradaCompletaAsync(DateTime fecha, int idPublicacion, int idPlanta, UpdateTiradaRequestDto updateDto, int idUsuario)
|
||||
{
|
||||
// 1. Validar que la tirada a modificar exista.
|
||||
var tiradaExistente = await _regTiradaRepository.GetByFechaPublicacionPlantaAsync(fecha.Date, idPublicacion, idPlanta);
|
||||
if (tiradaExistente == null)
|
||||
{
|
||||
return (null, "No se encontró la tirada que intenta modificar.");
|
||||
}
|
||||
|
||||
// 2. Validaciones de DTO (secciones válidas, etc.)
|
||||
var idsSeccionesUnicasDto = updateDto.Secciones.Select(s => s.IdSeccion).Distinct();
|
||||
if (idsSeccionesUnicasDto.Any()) // Solo validar si se enviaron secciones
|
||||
{
|
||||
var seccionesValidasDb = await _publiSeccionRepository.GetByIdsAndPublicacionAsync(idsSeccionesUnicasDto, idPublicacion, soloActivas: true);
|
||||
|
||||
if (seccionesValidasDb.Count() != idsSeccionesUnicasDto.Count())
|
||||
{
|
||||
var idsEncontrados = seccionesValidasDb.Select(s => s.IdSeccion).ToHashSet();
|
||||
var idsFaltantes = string.Join(", ", idsSeccionesUnicasDto.Where(id => !idsEncontrados.Contains(id)));
|
||||
return (null, $"Las siguientes secciones no son válidas, no pertenecen a la publicación o no están activas: {idsFaltantes}.");
|
||||
}
|
||||
}
|
||||
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
if (connection is System.Data.Common.DbConnection dbConn) await dbConn.OpenAsync(); else connection.Open();
|
||||
using var transaction = connection.BeginTransaction();
|
||||
|
||||
try
|
||||
{
|
||||
// 3. Actualizar registro principal (bob_RegTiradas) si cambió el número de ejemplares
|
||||
if (tiradaExistente.Ejemplares != updateDto.Ejemplares)
|
||||
{
|
||||
tiradaExistente.Ejemplares = updateDto.Ejemplares;
|
||||
bool actualizado = await _regTiradaRepository.UpdateAsync(tiradaExistente, idUsuario, transaction);
|
||||
if (!actualizado) throw new DataException("No se pudo actualizar el registro principal de la tirada.");
|
||||
}
|
||||
|
||||
// 4. Lógica de reconciliación de secciones
|
||||
// 4.1. Obtener estado actual de las secciones en la BBDD
|
||||
var seccionesActualesDb = await _regPublicacionSeccionRepository.GetByFechaPublicacionPlantaAsync(fecha.Date, idPublicacion, idPlanta);
|
||||
|
||||
// 4.2. Procesar Adiciones y Actualizaciones
|
||||
foreach (var seccionDto in updateDto.Secciones)
|
||||
{
|
||||
if (seccionDto.IdRegPublicacionSeccion == 0) // Es una nueva sección
|
||||
{
|
||||
var nuevaRegPubSeccion = new RegPublicacionSeccion
|
||||
{
|
||||
IdPublicacion = idPublicacion,
|
||||
IdSeccion = seccionDto.IdSeccion,
|
||||
CantPag = seccionDto.CantPag,
|
||||
Fecha = fecha.Date,
|
||||
IdPlanta = idPlanta
|
||||
};
|
||||
await _regPublicacionSeccionRepository.CreateAsync(nuevaRegPubSeccion, idUsuario, transaction);
|
||||
}
|
||||
else // Es una sección existente, verificar si hay cambios
|
||||
{
|
||||
var seccionDb = seccionesActualesDb.FirstOrDefault(s => s.IdTirada == seccionDto.IdRegPublicacionSeccion);
|
||||
if (seccionDb == null) throw new InvalidOperationException($"La sección con ID de registro {seccionDto.IdRegPublicacionSeccion} no pertenece a esta tirada.");
|
||||
|
||||
if (seccionDb.CantPag != seccionDto.CantPag) // Solo actualizamos si cambió la cantidad de páginas
|
||||
{
|
||||
seccionDb.CantPag = seccionDto.CantPag;
|
||||
await _regPublicacionSeccionRepository.UpdateAsync(seccionDb, idUsuario, transaction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4.3. Procesar Eliminaciones
|
||||
var idsSeccionesEntrantes = updateDto.Secciones
|
||||
.Where(s => s.IdRegPublicacionSeccion != 0)
|
||||
.Select(s => s.IdRegPublicacionSeccion)
|
||||
.ToHashSet();
|
||||
|
||||
var seccionesAEliminar = seccionesActualesDb
|
||||
.Where(s => !idsSeccionesEntrantes.Contains(s.IdTirada));
|
||||
|
||||
foreach (var seccionParaBorrar in seccionesAEliminar)
|
||||
{
|
||||
await _regPublicacionSeccionRepository.DeleteByIdAsync(seccionParaBorrar.IdTirada, idUsuario, transaction);
|
||||
}
|
||||
|
||||
transaction.Commit();
|
||||
_logger.LogInformation("Tirada completa modificada (granular) para Pub ID {IdPub}, Fecha {Fecha}, por Usuario ID {UserId}.", idPublicacion, fecha.Date, idUsuario);
|
||||
|
||||
// 5. Devolver el DTO actualizado (tendríamos que volver a consultar o construirlo)
|
||||
var tiradaActualizadaResult = await ObtenerTiradasAsync(fecha.Date, idPublicacion, idPlanta);
|
||||
return (tiradaActualizadaResult.FirstOrDefault(), null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try { transaction.Rollback(); } catch { }
|
||||
_logger.LogError(ex, "Error en ModificarTiradaCompletaAsync (granular) para Pub ID {IdPub}, Fecha {Fecha}", idPublicacion, fecha);
|
||||
return (null, $"Error interno al modificar la tirada: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(bool Exito, string? Error)> EliminarTiradaCompletaAsync(DateTime fecha, int idPublicacion, int idPlanta, int idUsuario)
|
||||
{
|
||||
// Verificar que la tirada principal exista
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/components/Modals/TiradaFormModal.tsx
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Modal, Box, Typography, TextField, Button, CircularProgress, Alert,
|
||||
FormControl, InputLabel, Select, MenuItem, IconButton, Paper,
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
} from '@mui/material';
|
||||
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import type { TiradaDto } from '../../../models/dtos/Impresion/TiradaDto';
|
||||
import type { CreateTiradaRequestDto } from '../../../models/dtos/Impresion/CreateTiradaRequestDto';
|
||||
import type { UpdateTiradaRequestDto } from '../../../models/dtos/Impresion/UpdateTiradaRequestDto';
|
||||
import type { PublicacionDto } from '../../../models/dtos/Distribucion/PublicacionDto';
|
||||
import type { PlantaDto } from '../../../models/dtos/Impresion/PlantaDto';
|
||||
import type { PubliSeccionDto } from '../../../models/dtos/Distribucion/PubliSeccionDto';
|
||||
@@ -30,22 +32,21 @@ const modalStyle = { /* ... (mismo estilo) ... */
|
||||
overflowY: 'auto'
|
||||
};
|
||||
|
||||
// CORREGIDO: Ajustar el tipo para los inputs. Usaremos string para los inputs,
|
||||
// y convertiremos a number al hacer submit o al validar donde sea necesario.
|
||||
interface DetalleSeccionFormState {
|
||||
idSeccion: number | ''; // Permitir string vacío para el Select no seleccionado
|
||||
idRegPublicacionSeccion: number;
|
||||
idSeccion: number | '';
|
||||
nombreSeccion?: string;
|
||||
cantPag: string; // TextField de cantPag siempre es string
|
||||
idTemporal: string; // Para la key de React
|
||||
cantPag: string;
|
||||
idTemporal: string;
|
||||
}
|
||||
|
||||
|
||||
interface TiradaFormModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: CreateTiradaRequestDto) => Promise<void>;
|
||||
onSubmit: (data: any, isEdit: boolean) => Promise<void>; // Any para flexibilidad, luego tipar bien
|
||||
errorMessage?: string | null;
|
||||
clearErrorMessage: () => void;
|
||||
tiradaToEdit?: TiradaDto | null;
|
||||
}
|
||||
|
||||
const TiradaFormModal: React.FC<TiradaFormModalProps> = ({
|
||||
@@ -53,33 +54,39 @@ const TiradaFormModal: React.FC<TiradaFormModalProps> = ({
|
||||
onClose,
|
||||
onSubmit,
|
||||
errorMessage,
|
||||
clearErrorMessage
|
||||
clearErrorMessage,
|
||||
tiradaToEdit
|
||||
}) => {
|
||||
const isEditMode = !!tiradaToEdit;
|
||||
|
||||
const [idPublicacion, setIdPublicacion] = useState<number | string>('');
|
||||
const [fecha, setFecha] = useState<string>(new Date().toISOString().split('T')[0]);
|
||||
const [idPlanta, setIdPlanta] = useState<number | string>('');
|
||||
const [ejemplares, setEjemplares] = useState<string>('');
|
||||
// CORREGIDO: Usar el nuevo tipo para el estado del formulario de secciones
|
||||
const [seccionesDeTirada, setSeccionesDeTirada] = useState<DetalleSeccionFormState[]>([]);
|
||||
|
||||
const [publicaciones, setPublicaciones] = useState<PublicacionDto[]>([]);
|
||||
const [plantas, setPlantas] = useState<PlantaDto[]>([]);
|
||||
const [seccionesPublicacion, setSeccionesPublicacion] = useState<PubliSeccionDto[]>([]);
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
const prevSeccionesLength = useRef(seccionesDeTirada.length);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingDropdowns, setLoadingDropdowns] = useState(false);
|
||||
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
|
||||
|
||||
const resetForm = () => {
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setIdPublicacion('');
|
||||
setFecha(new Date().toISOString().split('T')[0]);
|
||||
setIdPlanta('');
|
||||
setEjemplares('');
|
||||
setSeccionesDeTirada([]);
|
||||
setSeccionesPublicacion([]);
|
||||
setSeccionesPublicacion([]); // Asegurarse de limpiar esto también
|
||||
setLocalErrors({});
|
||||
clearErrorMessage();
|
||||
};
|
||||
}, [clearErrorMessage]);
|
||||
|
||||
|
||||
const fetchInitialDropdowns = useCallback(async () => {
|
||||
setLoadingDropdowns(true);
|
||||
@@ -94,62 +101,136 @@ const TiradaFormModal: React.FC<TiradaFormModalProps> = ({
|
||||
console.error("Error al cargar publicaciones/plantas", error);
|
||||
setLocalErrors(prev => ({ ...prev, dropdowns: 'Error al cargar datos iniciales.' }));
|
||||
} finally {
|
||||
setLoadingDropdowns(false);
|
||||
// No establecer a false aquí todavía, la carga de secciones es lo que realmente importa.
|
||||
// setLoadingDropdowns(false); // Eliminado o movido
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
resetForm(); // Llama a resetForm aquí
|
||||
fetchInitialDropdowns();
|
||||
}
|
||||
}, [open, fetchInitialDropdowns]); // resetForm no necesita estar en las dependencias si su contenido no cambia basado en props/estado que también estén en las dependencias.
|
||||
|
||||
const fetchSeccionesDePublicacion = useCallback(async (pubId: number) => {
|
||||
if (!pubId) {
|
||||
setSeccionesPublicacion([]);
|
||||
setSeccionesDeTirada([]);
|
||||
return;
|
||||
return []; // Retorna un array vacío para coherencia
|
||||
}
|
||||
setLoadingDropdowns(true);
|
||||
setLoadingDropdowns(true); // Mantenerlo loading hasta que esto termine
|
||||
try {
|
||||
const data = await publiSeccionService.getSeccionesPorPublicacion(pubId, true);
|
||||
setSeccionesPublicacion(data);
|
||||
setSeccionesDeTirada([]);
|
||||
return data; // Retornar la data para usarla inmediatamente
|
||||
} catch (error) {
|
||||
console.error("Error al cargar secciones de la publicación", error);
|
||||
setLocalErrors(prev => ({ ...prev, secciones: 'Error al cargar secciones.' }));
|
||||
return []; // Retornar array vacío en caso de error
|
||||
} finally {
|
||||
setLoadingDropdowns(false);
|
||||
setLoadingDropdowns(false); // Aquí se puede poner a false, ya que todas las dependencias están cargadas
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (idPublicacion) {
|
||||
fetchSeccionesDePublicacion(Number(idPublicacion));
|
||||
// Comprobar si se ha añadido una nueva fila
|
||||
if (seccionesDeTirada.length > prevSeccionesLength.current) {
|
||||
const container = tableContainerRef.current;
|
||||
if (container) {
|
||||
// Desplazar el contenedor hasta el final
|
||||
// Esto se ejecuta después del render, por lo que scrollHeight ya está actualizado
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}
|
||||
// Actualizar la longitud anterior para la próxima comparación
|
||||
prevSeccionesLength.current = seccionesDeTirada.length;
|
||||
}, [seccionesDeTirada]); // Este efecto depende del array de secciones
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
resetForm();
|
||||
return;
|
||||
}
|
||||
|
||||
clearErrorMessage();
|
||||
setLocalErrors({});
|
||||
setLoadingDropdowns(true); // Siempre iniciar con loading true cuando el modal abre
|
||||
|
||||
const loadData = async () => {
|
||||
// 1. Cargar datos base (Publicaciones y Plantas)
|
||||
await fetchInitialDropdowns(); // Esto actualiza el estado de `publicaciones` y `plantas`
|
||||
|
||||
if (isEditMode && tiradaToEdit) {
|
||||
// Modo Edición:
|
||||
setIdPublicacion(tiradaToEdit.idPublicacion);
|
||||
setFecha(tiradaToEdit.fecha);
|
||||
setIdPlanta(tiradaToEdit.idPlanta);
|
||||
setEjemplares(String(tiradaToEdit.ejemplares));
|
||||
|
||||
// 2. Cargar secciones de la publicación específica y esperar su finalización
|
||||
// `fetchSeccionesDePublicacion` ahora retorna la data.
|
||||
const loadedPubSections = await fetchSeccionesDePublicacion(tiradaToEdit.idPublicacion);
|
||||
|
||||
// 3. Ahora que `seccionesPublicacion` (options para el Select) está lleno,
|
||||
// poblar `seccionesDeTirada` (values para el Select)
|
||||
const seccionesMapeadas = tiradaToEdit.seccionesImpresas.map(sec => ({
|
||||
idRegPublicacionSeccion: sec.idRegPublicacionSeccion,
|
||||
idSeccion: sec.idSeccion,
|
||||
// Asegurarse de que el nombre de la sección se obtenga de las secciones cargadas
|
||||
// para evitar "Sección Desconocida" en la UI.
|
||||
nombreSeccion: loadedPubSections.find(ps => ps.idSeccion === sec.idSeccion)?.nombre || sec.nombreSeccion || '',
|
||||
cantPag: String(sec.cantPag),
|
||||
idTemporal: crypto.randomUUID()
|
||||
}));
|
||||
setSeccionesDeTirada(seccionesMapeadas);
|
||||
} else {
|
||||
// Modo Creación:
|
||||
resetForm(); // Ya hace fetchInitialDropdowns
|
||||
// Publicaciones y plantas se cargan.
|
||||
// idPublicacion aún es '', así que fetchSeccionesDePublicacion no se dispara.
|
||||
// Si el usuario selecciona una publicación, el otro useEffect lo manejará.
|
||||
}
|
||||
setLoadingDropdowns(false); // Todas las cargas asíncronas han terminado.
|
||||
};
|
||||
|
||||
if (open) {
|
||||
loadData();
|
||||
}
|
||||
// Asegurarse de que fetchInitialDropdowns y resetForm estén en las dependencias
|
||||
}, [open, isEditMode, tiradaToEdit, fetchInitialDropdowns, fetchSeccionesDePublicacion, resetForm, clearErrorMessage]);
|
||||
|
||||
// Este useEffect se mantiene, se encargará de reaccionar si el usuario cambia la publicación
|
||||
// en modo creación o si el idPublicacion se carga en el modo edición.
|
||||
useEffect(() => {
|
||||
if (idPublicacion && !isEditMode) { // Solo si no es modo edición y se selecciona una publicación
|
||||
fetchSeccionesDePublicacion(Number(idPublicacion));
|
||||
// Aquí no limpiamos seccionesDeTirada si no es editMode, se asume que
|
||||
// si cambian la publicación, la tabla de secciones debe resetearse.
|
||||
// Esto ya lo hace fetchSeccionesDePublicacion si idPublicacion es 0 o no tiene valor.
|
||||
} else if (!isEditMode && !idPublicacion) {
|
||||
setSeccionesPublicacion([]);
|
||||
setSeccionesDeTirada([]);
|
||||
}
|
||||
}, [idPublicacion, fetchSeccionesDePublicacion]);
|
||||
}, [idPublicacion, isEditMode, fetchSeccionesDePublicacion]);
|
||||
|
||||
|
||||
const validate = (): boolean => {
|
||||
const errors: { [key: string]: string | null } = {};
|
||||
if (!idPublicacion) errors.idPublicacion = 'Seleccione una publicación.';
|
||||
if (!fecha.trim()) errors.fecha = 'La fecha es obligatoria.';
|
||||
else if (!/^\d{4}-\d{2}-\d{2}$/.test(fecha)) errors.fecha = 'Formato de fecha inválido.';
|
||||
if (!idPlanta) errors.idPlanta = 'Seleccione una planta.';
|
||||
if (!idPublicacion && !isEditMode) errors.idPublicacion = 'Seleccione una publicación.'; // Solo requerido en creación
|
||||
if (!fecha.trim() && !isEditMode) errors.fecha = 'La fecha es obligatoria.'; // Solo requerido en creación
|
||||
else if (!isEditMode && !/^\d{4}-\d{2}-\d{2}$/.test(fecha)) errors.fecha = 'Formato de fecha inválido.';
|
||||
if (!idPlanta && !isEditMode) errors.idPlanta = 'Seleccione una planta.'; // Solo requerido en creación
|
||||
if (!ejemplares.trim() || isNaN(parseInt(ejemplares)) || parseInt(ejemplares) <= 0) errors.ejemplares = 'Ejemplares debe ser un número positivo.';
|
||||
|
||||
if (seccionesDeTirada.length === 0) {
|
||||
// Si no hay secciones en modo edición, podría ser válido si el negocio lo permite.
|
||||
// Si la lista está vacía y no es edición, sí es un error.
|
||||
if (seccionesDeTirada.length === 0 && !isEditMode) {
|
||||
errors.seccionesArray = 'Debe agregar al menos una sección a la tirada.';
|
||||
} else {
|
||||
seccionesDeTirada.forEach((sec, index) => {
|
||||
// Seccion ID es requerido siempre
|
||||
if (sec.idSeccion === '') errors[`seccion_${index}_id`] = `Fila ${index + 1}: Debe seleccionar una sección.`;
|
||||
if (!sec.cantPag.trim() || isNaN(Number(sec.cantPag)) || Number(sec.cantPag) <= 0) {
|
||||
errors[`seccion_${index}_pag`] = `Fila ${index + 1}: Cant. Páginas debe ser un número positivo.`;
|
||||
}
|
||||
// Validar duplicados de secciones
|
||||
const otherSections = seccionesDeTirada.filter((_, i) => i !== index);
|
||||
if (otherSections.some(s => s.idSeccion === sec.idSeccion && s.idSeccion !== '')) {
|
||||
errors[`seccion_${index}_duplicate`] = `Fila ${index + 1}: Sección duplicada.`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -158,7 +239,13 @@ const TiradaFormModal: React.FC<TiradaFormModalProps> = ({
|
||||
};
|
||||
|
||||
const handleAddSeccion = () => {
|
||||
setSeccionesDeTirada([...seccionesDeTirada, { idSeccion: '', cantPag: '', nombreSeccion: '', idTemporal: crypto.randomUUID() }]);
|
||||
setSeccionesDeTirada([...seccionesDeTirada, {
|
||||
idRegPublicacionSeccion: 0, // <-- Las nuevas secciones tienen ID de registro 0
|
||||
idSeccion: '',
|
||||
cantPag: '',
|
||||
nombreSeccion: '',
|
||||
idTemporal: crypto.randomUUID()
|
||||
}]);
|
||||
if (localErrors.seccionesArray) setLocalErrors(prev => ({ ...prev, seccionesArray: null }));
|
||||
};
|
||||
const handleRemoveSeccion = (index: number) => {
|
||||
@@ -190,18 +277,35 @@ const TiradaFormModal: React.FC<TiradaFormModalProps> = ({
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const dataToSubmit: CreateTiradaRequestDto = {
|
||||
let dataToSubmit: CreateTiradaRequestDto | UpdateTiradaRequestDto;
|
||||
|
||||
if (isEditMode) {
|
||||
// Modo Edición: usar UpdateTiradaRequestDto y el DTO de sección detallado
|
||||
const seccionesSubmit = seccionesDeTirada.map(s => ({
|
||||
idRegPublicacionSeccion: s.idRegPublicacionSeccion, // Pasar el ID de registro
|
||||
idSeccion: Number(s.idSeccion),
|
||||
cantPag: Number(s.cantPag)
|
||||
}));
|
||||
dataToSubmit = {
|
||||
ejemplares: parseInt(ejemplares, 10),
|
||||
secciones: seccionesSubmit
|
||||
};
|
||||
} else {
|
||||
// Modo Creación: usar CreateTiradaRequestDto
|
||||
const seccionesSubmit = seccionesDeTirada.map(s => ({
|
||||
idSeccion: Number(s.idSeccion),
|
||||
cantPag: Number(s.cantPag)
|
||||
}));
|
||||
dataToSubmit = {
|
||||
idPublicacion: Number(idPublicacion),
|
||||
fecha,
|
||||
idPlanta: Number(idPlanta),
|
||||
ejemplares: parseInt(ejemplares, 10),
|
||||
// CORREGIDO: Asegurar que los datos de secciones sean números
|
||||
secciones: seccionesDeTirada.map(s => ({
|
||||
idSeccion: Number(s.idSeccion), // Convertir a número aquí
|
||||
cantPag: Number(s.cantPag) // Convertir a número aquí
|
||||
}))
|
||||
secciones: seccionesSubmit
|
||||
};
|
||||
await onSubmit(dataToSubmit);
|
||||
}
|
||||
|
||||
await onSubmit(dataToSubmit, isEditMode); // Informar al padre si es edición
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
console.error("Error en submit de TiradaFormModal:", error);
|
||||
@@ -213,15 +317,16 @@ const TiradaFormModal: React.FC<TiradaFormModalProps> = ({
|
||||
return (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<Box sx={modalStyle}>
|
||||
<Typography variant="h6" component="h2" gutterBottom>Registrar Nueva Tirada</Typography>
|
||||
<Typography variant="h6" component="h2" gutterBottom>
|
||||
{isEditMode ? 'Modificar Tirada' : 'Registrar Nueva Tirada'}
|
||||
</Typography>
|
||||
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 1 }}>
|
||||
{/* ... (campos de Publicacion, Fecha, Planta, Ejemplares sin cambios) ... */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mb: 2 }}>
|
||||
<FormControl fullWidth margin="dense" error={!!localErrors.idPublicacion} sx={{ flex: 1, minWidth: 200 }}>
|
||||
<InputLabel id="publicacion-tirada-select-label" required>Publicación</InputLabel>
|
||||
<Select labelId="publicacion-tirada-select-label" label="Publicación" value={idPublicacion}
|
||||
onChange={(e) => { setIdPublicacion(e.target.value as number); setLocalErrors(p => ({ ...p, idPublicacion: null })); }}
|
||||
disabled={loading || loadingDropdowns}
|
||||
disabled={loading || loadingDropdowns || isEditMode} // Publicación siempre deshabilitada en edición
|
||||
>
|
||||
<MenuItem value="" disabled><em>Seleccione</em></MenuItem>
|
||||
{publicaciones.map((p) => (<MenuItem key={p.idPublicacion} value={p.idPublicacion}>{p.nombre} ({p.nombreEmpresa})</MenuItem>))}
|
||||
@@ -231,7 +336,8 @@ const TiradaFormModal: React.FC<TiradaFormModalProps> = ({
|
||||
<TextField label="Fecha Tirada" type="date" value={fecha} required
|
||||
onChange={(e) => { setFecha(e.target.value); setLocalErrors(p => ({ ...p, fecha: null })); }}
|
||||
margin="dense" error={!!localErrors.fecha} helperText={localErrors.fecha || ''}
|
||||
disabled={loading} InputLabelProps={{ shrink: true }} sx={{flex:1, minWidth: 160}}
|
||||
disabled={loading || isEditMode} // Fecha siempre deshabilitada en edición
|
||||
InputLabelProps={{ shrink: true }} sx={{ flex: 1, minWidth: 160 }}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mb: 2 }}>
|
||||
@@ -239,7 +345,7 @@ const TiradaFormModal: React.FC<TiradaFormModalProps> = ({
|
||||
<InputLabel id="planta-tirada-select-label" required>Planta</InputLabel>
|
||||
<Select labelId="planta-tirada-select-label" label="Planta" value={idPlanta}
|
||||
onChange={(e) => { setIdPlanta(e.target.value as number); setLocalErrors(p => ({ ...p, idPlanta: null })); }}
|
||||
disabled={loading || loadingDropdowns}
|
||||
disabled={loading || loadingDropdowns || isEditMode} // Planta siempre deshabilitada en edición
|
||||
>
|
||||
<MenuItem value="" disabled><em>Seleccione</em></MenuItem>
|
||||
{plantas.map((p) => (<MenuItem key={p.idPlanta} value={p.idPlanta}>{p.nombre}</MenuItem>))}
|
||||
@@ -254,12 +360,20 @@ const TiradaFormModal: React.FC<TiradaFormModalProps> = ({
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
<Typography variant="subtitle1" sx={{ mt: 2, mb: 1 }}>Detalle de Secciones Impresas:</Typography>
|
||||
{localErrors.seccionesArray && <Alert severity="error" sx={{ mb: 1 }}>{localErrors.seccionesArray}</Alert>}
|
||||
<Paper variant="outlined" sx={{p:1, mb:2, maxHeight: '250px', overflowY: 'auto'}}> {/* Permitir scroll en tabla de secciones */}
|
||||
<TableContainer>
|
||||
<Table size="small" stickyHeader> {/* stickyHeader para que cabecera quede fija */}
|
||||
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
mb: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxHeight: '300px'
|
||||
}}
|
||||
>
|
||||
<TableContainer ref={tableContainerRef} sx={{ flex: 1, overflowY: 'auto' }}>
|
||||
<Table size="small" stickyHeader>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 'bold', minWidth: 200 }}>Sección</TableCell>
|
||||
@@ -269,10 +383,10 @@ const TiradaFormModal: React.FC<TiradaFormModalProps> = ({
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{seccionesDeTirada.map((sec, index) => (
|
||||
<TableRow key={sec.idTemporal || index}> {/* Usar idTemporal para key */}
|
||||
<TableRow key={sec.idTemporal || index}>
|
||||
<TableCell sx={{ py: 0.5 }}>
|
||||
<FormControl fullWidth size="small" error={!!localErrors[`seccion_${index}_id`]}>
|
||||
<Select value={sec.idSeccion} // Ahora idSeccion es number | ''
|
||||
<FormControl fullWidth size="small" error={!!localErrors[`seccion_${index}_id`] || !!localErrors[`seccion_${index}_duplicate`]}>
|
||||
<Select value={sec.idSeccion}
|
||||
onChange={(e) => handleSeccionChange(index, 'idSeccion', e.target.value as number | '')}
|
||||
disabled={loading || loadingDropdowns || seccionesPublicacion.length === 0}
|
||||
displayEmpty
|
||||
@@ -281,6 +395,7 @@ const TiradaFormModal: React.FC<TiradaFormModalProps> = ({
|
||||
{seccionesPublicacion.map(s => <MenuItem key={s.idSeccion} value={s.idSeccion}>{s.nombre}</MenuItem>)}
|
||||
</Select>
|
||||
{localErrors[`seccion_${index}_id`] && <Typography color="error" variant="caption">{localErrors[`seccion_${index}_id`]}</Typography>}
|
||||
{localErrors[`seccion_${index}_duplicate`] && <Typography color="error" variant="caption">{localErrors[`seccion_${index}_duplicate`]}</Typography>}
|
||||
</FormControl>
|
||||
</TableCell>
|
||||
<TableCell sx={{ py: 0.5 }}>
|
||||
@@ -311,9 +426,24 @@ const TiradaFormModal: React.FC<TiradaFormModalProps> = ({
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Button startIcon={<AddCircleOutlineIcon />} onClick={handleAddSeccion} sx={{mt:1}} size="small" disabled={loading || !idPublicacion}>
|
||||
|
||||
{/* Contenedor para el botón que actúa como un pie de página fijo */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 1,
|
||||
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
flexShrink: 0
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
startIcon={<AddCircleOutlineIcon />}
|
||||
onClick={handleAddSeccion}
|
||||
size="small"
|
||||
disabled={loading || !idPublicacion}
|
||||
>
|
||||
Agregar Sección
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{errorMessage && <Alert severity="error" sx={{ mt: 2, width: '100%' }}>{errorMessage}</Alert>}
|
||||
@@ -322,7 +452,7 @@ const TiradaFormModal: React.FC<TiradaFormModalProps> = ({
|
||||
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
<Button onClick={onClose} color="secondary" disabled={loading}>Cancelar</Button>
|
||||
<Button type="submit" variant="contained" disabled={loading || loadingDropdowns}>
|
||||
{loading ? <CircularProgress size={24} /> : 'Registrar Tirada'}
|
||||
{loading ? <CircularProgress size={24} /> : (isEditMode ? 'Guardar Cambios' : 'Registrar Tirada')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface UpdateDetalleSeccionTiradaDto {
|
||||
idRegPublicacionSeccion: number; // Será 0 para las nuevas secciones
|
||||
idSeccion: number;
|
||||
cantPag: number;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { UpdateDetalleSeccionTiradaDto } from "./UpdateDetalleSeccionTiradaDto";
|
||||
|
||||
export interface UpdateTiradaRequestDto {
|
||||
ejemplares: number;
|
||||
secciones: UpdateDetalleSeccionTiradaDto[];
|
||||
}
|
||||
@@ -5,9 +5,11 @@ import {
|
||||
CircularProgress, Alert, Accordion, AccordionSummary, AccordionDetails, Chip,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select
|
||||
Select,
|
||||
Backdrop
|
||||
} from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import FilterListIcon from '@mui/icons-material/FilterList';
|
||||
@@ -18,6 +20,7 @@ import plantaService from '../../services/Impresion/plantaService'; // Para filt
|
||||
|
||||
import type { TiradaDto } from '../../models/dtos/Impresion/TiradaDto';
|
||||
import type { CreateTiradaRequestDto } from '../../models/dtos/Impresion/CreateTiradaRequestDto';
|
||||
import type { UpdateTiradaRequestDto } from '../../models/dtos/Impresion/UpdateTiradaRequestDto';
|
||||
import type { PublicacionDropdownDto } from '../../models/dtos/Distribucion/PublicacionDropdownDto';
|
||||
import type { PlantaDropdownDto } from '../../models/dtos/Impresion/PlantaDropdownDto';
|
||||
|
||||
@@ -48,12 +51,14 @@ const GestionarTiradasPage: React.FC = () => {
|
||||
const [loadingFiltersDropdown, setLoadingFiltersDropdown] = useState(false);
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
// No hay "editing" para tiradas por ahora, solo crear y borrar.
|
||||
const [tiradaAEditar, setTiradaAEditar] = useState<TiradaDto | null>(null);
|
||||
const [loadingEditData, setLoadingEditData] = useState(false);
|
||||
|
||||
const { tienePermiso, isSuperAdmin } = usePermissions();
|
||||
const puedeVer = isSuperAdmin || tienePermiso("IT001");
|
||||
const puedeRegistrar = isSuperAdmin || tienePermiso("IT002");
|
||||
const puedeEliminar = isSuperAdmin || tienePermiso("IT003");
|
||||
const puedeModificar = isSuperAdmin || tienePermiso("IT004");
|
||||
|
||||
const fetchFiltersDropdownData = useCallback(async () => {
|
||||
setLoadingFiltersDropdown(true);
|
||||
@@ -98,17 +103,70 @@ const GestionarTiradasPage: React.FC = () => {
|
||||
cargarTiradas();
|
||||
}, [cargarTiradas]);
|
||||
|
||||
const handleOpenModal = () => { setApiErrorMessage(null); setModalOpen(true); };
|
||||
const handleCloseModal = () => setModalOpen(false);
|
||||
const handleOpenModal = async (tirada: TiradaDto | null = null) => {
|
||||
setApiErrorMessage(null);
|
||||
|
||||
const handleSubmitModal = async (data: CreateTiradaRequestDto) => {
|
||||
if (tirada) { // Modo Edición
|
||||
setLoadingEditData(true);
|
||||
try {
|
||||
const params = {
|
||||
fecha: tirada.fecha,
|
||||
idPublicacion: tirada.idPublicacion,
|
||||
idPlanta: tirada.idPlanta
|
||||
};
|
||||
const tiradasActualizadas = await tiradaService.getTiradas(params);
|
||||
|
||||
if (tiradasActualizadas.length > 0) {
|
||||
// *** PASO CLAVE: Actualizar el estado de los datos PRIMERO ***
|
||||
setTiradaAEditar(tiradasActualizadas[0]);
|
||||
// *** Y LUEGO, abrir el modal. ***
|
||||
// Esto asegura que cuando el modal se renderice por primera vez
|
||||
// debido a `open=true`, la prop `tiradaToEdit` ya tendrá el valor correcto.
|
||||
setModalOpen(true);
|
||||
} else {
|
||||
setError('La tirada que intenta editar ya no existe. Refrescando lista...');
|
||||
cargarTiradas();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error al cargar datos para editar:", err);
|
||||
setError('No se pudieron cargar los datos para la edición.');
|
||||
} finally {
|
||||
setLoadingEditData(false);
|
||||
}
|
||||
} else { // Modo Creación
|
||||
// En modo creación, no hay datos que cargar, así que es seguro hacerlo en cualquier orden.
|
||||
setTiradaAEditar(null);
|
||||
setModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setModalOpen(false);
|
||||
setTiradaAEditar(null);
|
||||
};
|
||||
|
||||
const handleSubmitModal = async (data: CreateTiradaRequestDto | UpdateTiradaRequestDto, isEdit: boolean) => {
|
||||
setApiErrorMessage(null);
|
||||
try {
|
||||
await tiradaService.registrarTirada(data);
|
||||
cargarTiradas();
|
||||
if (isEdit && tiradaAEditar) {
|
||||
// Modo Edición
|
||||
await tiradaService.modificarTirada(
|
||||
tiradaAEditar.fecha,
|
||||
tiradaAEditar.idPublicacion,
|
||||
tiradaAEditar.idPlanta,
|
||||
data as UpdateTiradaRequestDto
|
||||
);
|
||||
} else {
|
||||
// Modo Creación
|
||||
await tiradaService.registrarTirada(data as CreateTiradaRequestDto);
|
||||
}
|
||||
cargarTiradas(); // Recargar la lista en ambos casos
|
||||
} catch (err: any) {
|
||||
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al registrar la tirada.';
|
||||
setApiErrorMessage(message); throw err;
|
||||
const message = axios.isAxiosError(err) && err.response?.data?.message
|
||||
? err.response.data.message
|
||||
: `Error al ${isEdit ? 'modificar' : 'registrar'} la tirada.`;
|
||||
setApiErrorMessage(message);
|
||||
throw err; // Re-lanzar para que el modal no se cierre
|
||||
}
|
||||
};
|
||||
|
||||
@@ -135,6 +193,13 @@ const GestionarTiradasPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 1 }}>
|
||||
{/* Añadimos un Backdrop para el feedback visual durante la carga de los datos de edición */}
|
||||
<Backdrop
|
||||
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
||||
open={loadingEditData}
|
||||
>
|
||||
<CircularProgress color="inherit" />
|
||||
</Backdrop>
|
||||
<Typography variant="h5" gutterBottom>Gestión de Tiradas</Typography>
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>Filtros <FilterListIcon fontSize="small" /></Typography>
|
||||
@@ -164,7 +229,7 @@ const GestionarTiradasPage: React.FC = () => {
|
||||
</FormControl>
|
||||
{/* <Button variant="outlined" onClick={cargarTiradas} size="small">Buscar</Button> */}
|
||||
</Box>
|
||||
{puedeRegistrar && (<Button variant="contained" startIcon={<AddIcon />} onClick={handleOpenModal}>Registrar Nueva Tirada</Button>)}
|
||||
{puedeRegistrar && (<Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()}>Registrar Nueva Tirada</Button>)}
|
||||
</Paper>
|
||||
|
||||
{loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>}
|
||||
@@ -174,31 +239,42 @@ const GestionarTiradasPage: React.FC = () => {
|
||||
{!loading && !error && puedeVer && (
|
||||
<Box>
|
||||
{tiradas.length === 0 ? (
|
||||
<Typography sx={{ mt: 2, textAlign: 'center' }}>No se encontraron tiradas con los filtros aplicados.</Typography>
|
||||
<Typography sx={{ mt: 2, textAlign: 'center' }}>
|
||||
No se encontraron tiradas con los filtros aplicados.
|
||||
</Typography>
|
||||
) : (
|
||||
tiradas.map((tirada) => (
|
||||
<Accordion key={tirada.idRegistroTirada} sx={{ mb: 1 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', width: '100%', alignItems: 'center' }}>
|
||||
<Typography sx={{ fontWeight: 'bold' }}>{formatDate(tirada.fecha)} - {tirada.nombrePublicacion} ({tirada.nombrePlanta})</Typography>
|
||||
<Box>
|
||||
<Chip label={`${tirada.ejemplares} ej.`} color="primary" size="small" sx={{ mr: 1 }} />
|
||||
<Chip label={`${tirada.totalPaginasSumadas} pág.`} size="small" />
|
||||
{puedeEliminar && (
|
||||
<IconButton size="small" onClick={(e) => { e.stopPropagation(); handleDeleteTirada(tirada); }} sx={{ ml: 1 }}>
|
||||
<DeleteIcon color="error" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Paper
|
||||
key={tirada.idRegistroTirada}
|
||||
sx={{ mb: 1.5, overflow: 'hidden' }}
|
||||
variant="outlined"
|
||||
>
|
||||
{/* 1. Contenedor principal con alineación al inicio (arriba) */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start' }}>
|
||||
<Accordion sx={{
|
||||
flexGrow: 1,
|
||||
boxShadow: 'none',
|
||||
'&:before': { display: 'none' },
|
||||
}}>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
aria-controls={`panel${tirada.idRegistroTirada}-content`}
|
||||
id={`panel${tirada.idRegistroTirada}-header`}
|
||||
>
|
||||
{/* El contenido del summary ahora es solo el título principal */}
|
||||
<Typography sx={{ fontWeight: 'bold' }}>
|
||||
{formatDate(tirada.fecha)} - {tirada.nombrePublicacion} ({tirada.nombrePlanta})
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead><TableRow>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell><strong>Sección</strong></TableCell>
|
||||
<TableCell align="right"><strong>Páginas</strong></TableCell>
|
||||
</TableRow></TableHead>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{tirada.seccionesImpresas.map(sec => (
|
||||
<TableRow key={sec.idRegPublicacionSeccion}>
|
||||
@@ -211,6 +287,42 @@ const GestionarTiradasPage: React.FC = () => {
|
||||
</TableContainer>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Contenedor de acciones con Chips y Botones */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
// 3. Añadimos padding para alinear verticalmente con el texto del summary
|
||||
// y un poco a la derecha para separarlo del acordeón.
|
||||
// 48px es la altura mínima del AccordionSummary, esto asegura una alineación perfecta.
|
||||
height: '48px',
|
||||
px: 2
|
||||
}}>
|
||||
<Chip label={`${tirada.ejemplares} ej.`} color="primary" size="small" sx={{ mr: 1 }} />
|
||||
<Chip label={`${tirada.totalPaginasSumadas} pág.`} size="small" sx={{ mr: 1 }} />
|
||||
|
||||
{puedeModificar && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleOpenModal(tirada)}
|
||||
title="Editar Tirada"
|
||||
>
|
||||
<EditIcon color="info" />
|
||||
</IconButton>
|
||||
)}
|
||||
{puedeEliminar && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleDeleteTirada(tirada)}
|
||||
title="Eliminar Tirada"
|
||||
>
|
||||
<DeleteIcon color="error" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
@@ -223,6 +335,7 @@ const GestionarTiradasPage: React.FC = () => {
|
||||
onSubmit={handleSubmitModal}
|
||||
errorMessage={apiErrorMessage}
|
||||
clearErrorMessage={() => setApiErrorMessage(null)}
|
||||
tiradaToEdit={tiradaAEditar}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import apiClient from '../apiClient';
|
||||
import type { TiradaDto } from '../../models/dtos/Impresion/TiradaDto';
|
||||
import type { CreateTiradaRequestDto } from '../../models/dtos/Impresion/CreateTiradaRequestDto';
|
||||
import type { UpdateTiradaRequestDto } from '../../models/dtos/Impresion/UpdateTiradaRequestDto';
|
||||
|
||||
interface GetTiradasParams {
|
||||
fecha?: string | null; // "yyyy-MM-dd"
|
||||
@@ -23,6 +24,17 @@ const registrarTirada = async (data: CreateTiradaRequestDto): Promise<TiradaDto>
|
||||
return response.data; // El backend devuelve la tirada creada
|
||||
};
|
||||
|
||||
const modificarTirada = async (fecha: string, idPublicacion: number, idPlanta: number, data: UpdateTiradaRequestDto): Promise<TiradaDto> => {
|
||||
const response = await apiClient.put<TiradaDto>('/tiradas', data, {
|
||||
params: {
|
||||
fecha,
|
||||
idPublicacion,
|
||||
idPlanta
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const deleteTiradaCompleta = async (fecha: string, idPublicacion: number, idPlanta: number): Promise<void> => {
|
||||
// Los parámetros van en la query string para este DELETE
|
||||
await apiClient.delete('/tiradas', {
|
||||
@@ -37,6 +49,7 @@ const deleteTiradaCompleta = async (fecha: string, idPublicacion: number, idPlan
|
||||
const tiradaService = {
|
||||
getTiradas,
|
||||
registrarTirada,
|
||||
modificarTirada,
|
||||
deleteTiradaCompleta,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user