From 28c1b88a92e8ff79fb81a3c3d4bf5eedc1ca2305 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 23 Jul 2025 14:05:58 -0300 Subject: [PATCH] =?UTF-8?q?Feat:=20Implementar=20modificaci=C3=B3n=20de=20?= =?UTF-8?q?Tiradas=20y=20mejorar=20UX/UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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. --- .../Impresion/TiradasController.cs | 40 +- .../Distribucion/IPubliSeccionRepository.cs | 3 +- .../Distribucion/PubliSeccionRepository.cs | 26 + .../Impresion/IRegTiradaRepository.cs | 9 +- .../Impresion/RegTiradaRepository.cs | 96 +++ .../UpdateDetalleSeccionTiradaDto.cs | 17 + .../Dtos/Impresion/UpdateTiradaRequestDto.cs | 15 + .../Services/Impresion/ITiradaService.cs | 1 + .../Services/Impresion/TiradaService.cs | 98 +++ .../Modals/Impresion/TiradaFormModal.tsx | 674 +++++++++++------- .../UpdateDetalleSeccionTiradaDto.ts | 5 + .../dtos/Impresion/UpdateTiradaRequestDto.ts | 6 + .../pages/Impresion/GestionarTiradasPage.tsx | 201 ++++-- .../src/services/Impresion/tiradaService.ts | 13 + 14 files changed, 884 insertions(+), 320 deletions(-) create mode 100644 Backend/GestionIntegral.Api/Models/Dtos/Impresion/UpdateDetalleSeccionTiradaDto.cs create mode 100644 Backend/GestionIntegral.Api/Models/Dtos/Impresion/UpdateTiradaRequestDto.cs create mode 100644 Frontend/src/models/dtos/Impresion/UpdateDetalleSeccionTiradaDto.ts create mode 100644 Frontend/src/models/dtos/Impresion/UpdateTiradaRequestDto.ts diff --git a/Backend/GestionIntegral.Api/Controllers/Impresion/TiradasController.cs b/Backend/GestionIntegral.Api/Controllers/Impresion/TiradasController.cs index 0b4866f..45f6843 100644 --- a/Backend/GestionIntegral.Api/Controllers/Impresion/TiradasController.cs +++ b/Backend/GestionIntegral.Api/Controllers/Impresion/TiradasController.cs @@ -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 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 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] diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Distribucion/IPubliSeccionRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Distribucion/IPubliSeccionRepository.cs index 749e309..e2633b1 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Distribucion/IPubliSeccionRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Distribucion/IPubliSeccionRepository.cs @@ -9,10 +9,11 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion { Task> GetByPublicacionIdAsync(int idPublicacion, bool? soloActivas = null); Task GetByIdAsync(int idSeccion); + Task> GetByIdsAndPublicacionAsync(IEnumerable idsSeccion, int idPublicacion, bool? soloActivas = null); Task CreateAsync(PubliSeccion nuevaSeccion, int idUsuario, IDbTransaction transaction); Task UpdateAsync(PubliSeccion seccionAActualizar, int idUsuario, IDbTransaction transaction); Task DeleteAsync(int idSeccion, int idUsuario, IDbTransaction transaction); - Task DeleteByPublicacionIdAsync(int idPublicacion, int idUsuarioAuditoria, IDbTransaction transaction); // Ya existe + Task DeleteByPublicacionIdAsync(int idPublicacion, int idUsuarioAuditoria, IDbTransaction transaction); Task ExistsByNameInPublicacionAsync(string nombre, int idPublicacion, int? excludeIdSeccion = null); Task IsInUseAsync(int idSeccion); // Verificar en bob_RegPublicaciones, bob_StockBobinas Task> GetHistorialAsync( diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Distribucion/PubliSeccionRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Distribucion/PubliSeccionRepository.cs index 99b38b3..da2b359 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Distribucion/PubliSeccionRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Distribucion/PubliSeccionRepository.cs @@ -169,6 +169,32 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion return rowsAffected == 1; } + public async Task> GetByIdsAndPublicacionAsync(IEnumerable idsSeccion, int idPublicacion, bool? soloActivas = null) + { + if (idsSeccion == null || !idsSeccion.Any()) + { + return Enumerable.Empty(); + } + + 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(sqlBuilder.ToString(), parameters); + } + public async Task DeleteAsync(int idSeccion, int idUsuario, IDbTransaction transaction) { var actual = await transaction.Connection!.QuerySingleOrDefaultAsync( diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Impresion/IRegTiradaRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Impresion/IRegTiradaRepository.cs index 2f7b4e5..6b39985 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Impresion/IRegTiradaRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Impresion/IRegTiradaRepository.cs @@ -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 GetByIdAsync(int idRegistro); Task> GetByCriteriaAsync(DateTime? fecha, int? idPublicacion, int? idPlanta); Task CreateAsync(RegTirada nuevaTirada, int idUsuario, IDbTransaction transaction); + Task UpdateAsync(RegTirada tiradaAActualizar, int idUsuario, IDbTransaction transaction); Task DeleteAsync(int idRegistro, int idUsuario, IDbTransaction transaction); // Si se borra el registro principal Task DeleteByFechaPublicacionPlantaAsync(DateTime fecha, int idPublicacion, int idPlanta, int idUsuario, IDbTransaction transaction); Task 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 GetByIdAsync(int idTirada); Task> GetByFechaPublicacionPlantaAsync(DateTime fecha, int idPublicacion, int idPlanta); Task CreateAsync(RegPublicacionSeccion nuevaSeccionTirada, int idUsuario, IDbTransaction transaction); + Task UpdateAsync(RegPublicacionSeccion seccionAActualizar, int idUsuario, IDbTransaction transaction); + Task DeleteByIdAsync(int idTirada, int idUsuario, IDbTransaction transaction); Task 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 } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Impresion/RegTiradaRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Impresion/RegTiradaRepository.cs index eb7dbe3..6773a44 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Impresion/RegTiradaRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Impresion/RegTiradaRepository.cs @@ -83,6 +83,42 @@ namespace GestionIntegral.Api.Data.Repositories.Impresion return inserted; } + public async Task 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(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 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 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(sql, new { IdTiradaParam = idTirada }); + } + + public async Task 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 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 DeleteByFechaPublicacionPlantaAsync(DateTime fecha, int idPublicacion, int idPlanta, int idUsuario, IDbTransaction transaction) { var seccionesAEliminar = await GetByFechaPublicacionPlantaAsync(fecha.Date, idPublicacion, idPlanta); // No necesita TX, es SELECT diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Impresion/UpdateDetalleSeccionTiradaDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Impresion/UpdateDetalleSeccionTiradaDto.cs new file mode 100644 index 0000000..88eec03 --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Dtos/Impresion/UpdateDetalleSeccionTiradaDto.cs @@ -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; } + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Impresion/UpdateTiradaRequestDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Impresion/UpdateTiradaRequestDto.cs new file mode 100644 index 0000000..dbcde56 --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Dtos/Impresion/UpdateTiradaRequestDto.cs @@ -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 Secciones { get; set; } = new List(); + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Impresion/ITiradaService.cs b/Backend/GestionIntegral.Api/Services/Impresion/ITiradaService.cs index 00a91c2..acbb632 100644 --- a/Backend/GestionIntegral.Api/Services/Impresion/ITiradaService.cs +++ b/Backend/GestionIntegral.Api/Services/Impresion/ITiradaService.cs @@ -10,6 +10,7 @@ namespace GestionIntegral.Api.Services.Impresion { Task> 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> ObtenerRegTiradasHistorialAsync( DateTime? fechaDesde, DateTime? fechaHasta, diff --git a/Backend/GestionIntegral.Api/Services/Impresion/TiradaService.cs b/Backend/GestionIntegral.Api/Services/Impresion/TiradaService.cs index 5f58376..d9ff883 100644 --- a/Backend/GestionIntegral.Api/Services/Impresion/TiradaService.cs +++ b/Backend/GestionIntegral.Api/Services/Impresion/TiradaService.cs @@ -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 diff --git a/Frontend/src/components/Modals/Impresion/TiradaFormModal.tsx b/Frontend/src/components/Modals/Impresion/TiradaFormModal.tsx index 56d20e1..81d5e60 100644 --- a/Frontend/src/components/Modals/Impresion/TiradaFormModal.tsx +++ b/Frontend/src/components/Modals/Impresion/TiradaFormModal.tsx @@ -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,305 +32,433 @@ 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; - errorMessage?: string | null; - clearErrorMessage: () => void; + open: boolean; + onClose: () => void; + onSubmit: (data: any, isEdit: boolean) => Promise; // Any para flexibilidad, luego tipar bien + errorMessage?: string | null; + clearErrorMessage: () => void; + tiradaToEdit?: TiradaDto | null; } const TiradaFormModal: React.FC = ({ - open, - onClose, - onSubmit, - errorMessage, - clearErrorMessage + open, + onClose, + onSubmit, + errorMessage, + clearErrorMessage, + tiradaToEdit }) => { - const [idPublicacion, setIdPublicacion] = useState(''); - const [fecha, setFecha] = useState(new Date().toISOString().split('T')[0]); - const [idPlanta, setIdPlanta] = useState(''); - const [ejemplares, setEjemplares] = useState(''); - // CORREGIDO: Usar el nuevo tipo para el estado del formulario de secciones - const [seccionesDeTirada, setSeccionesDeTirada] = useState([]); + const isEditMode = !!tiradaToEdit; - const [publicaciones, setPublicaciones] = useState([]); - const [plantas, setPlantas] = useState([]); - const [seccionesPublicacion, setSeccionesPublicacion] = useState([]); + const [idPublicacion, setIdPublicacion] = useState(''); + const [fecha, setFecha] = useState(new Date().toISOString().split('T')[0]); + const [idPlanta, setIdPlanta] = useState(''); + const [ejemplares, setEjemplares] = useState(''); + const [seccionesDeTirada, setSeccionesDeTirada] = useState([]); - const [loading, setLoading] = useState(false); - const [loadingDropdowns, setLoadingDropdowns] = useState(false); - const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); + const [publicaciones, setPublicaciones] = useState([]); + const [plantas, setPlantas] = useState([]); + const [seccionesPublicacion, setSeccionesPublicacion] = useState([]); + const tableContainerRef = useRef(null); + const prevSeccionesLength = useRef(seccionesDeTirada.length); - const resetForm = () => { - setIdPublicacion(''); - setFecha(new Date().toISOString().split('T')[0]); - setIdPlanta(''); - setEjemplares(''); - setSeccionesDeTirada([]); - setSeccionesPublicacion([]); - setLocalErrors({}); - clearErrorMessage(); - }; + const [loading, setLoading] = useState(false); + const [loadingDropdowns, setLoadingDropdowns] = useState(false); + const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); - const fetchInitialDropdowns = useCallback(async () => { - setLoadingDropdowns(true); - try { - const [pubsData, plantasData] = await Promise.all([ - publicacionService.getAllPublicaciones(undefined, undefined, true), - plantaService.getAllPlantas() - ]); - setPublicaciones(pubsData); - setPlantas(plantasData); - } catch (error) { - console.error("Error al cargar publicaciones/plantas", error); - setLocalErrors(prev => ({...prev, dropdowns: 'Error al cargar datos iniciales.'})); - } finally { - setLoadingDropdowns(false); - } - }, []); - 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([]); + const resetForm = useCallback(() => { + setIdPublicacion(''); + setFecha(new Date().toISOString().split('T')[0]); + setIdPlanta(''); + setEjemplares(''); setSeccionesDeTirada([]); - return; - } - setLoadingDropdowns(true); - try { - const data = await publiSeccionService.getSeccionesPorPublicacion(pubId, true); - setSeccionesPublicacion(data); - setSeccionesDeTirada([]); - } catch (error) { - console.error("Error al cargar secciones de la publicación", error); - setLocalErrors(prev => ({...prev, secciones: 'Error al cargar secciones.'})); - } finally { - setLoadingDropdowns(false); - } - }, []); - - useEffect(() => { - if (idPublicacion) { - fetchSeccionesDePublicacion(Number(idPublicacion)); - } else { - setSeccionesPublicacion([]); - setSeccionesDeTirada([]); - } - }, [idPublicacion, fetchSeccionesDePublicacion]); + setSeccionesPublicacion([]); // Asegurarse de limpiar esto también + setLocalErrors({}); + clearErrorMessage(); + }, [clearErrorMessage]); - 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 (!ejemplares.trim() || isNaN(parseInt(ejemplares)) || parseInt(ejemplares) <= 0) errors.ejemplares = 'Ejemplares debe ser un número positivo.'; - - if (seccionesDeTirada.length === 0) { - errors.seccionesArray = 'Debe agregar al menos una sección a la tirada.'; - } else { - seccionesDeTirada.forEach((sec, index) => { - 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.`; + const fetchInitialDropdowns = useCallback(async () => { + setLoadingDropdowns(true); + try { + const [pubsData, plantasData] = await Promise.all([ + publicacionService.getAllPublicaciones(undefined, undefined, true), + plantaService.getAllPlantas() + ]); + setPublicaciones(pubsData); + setPlantas(plantasData); + } catch (error) { + console.error("Error al cargar publicaciones/plantas", error); + setLocalErrors(prev => ({ ...prev, dropdowns: 'Error al cargar datos iniciales.' })); + } finally { + // No establecer a false aquí todavía, la carga de secciones es lo que realmente importa. + // setLoadingDropdowns(false); // Eliminado o movido + } + }, []); + + const fetchSeccionesDePublicacion = useCallback(async (pubId: number) => { + if (!pubId) { + setSeccionesPublicacion([]); + return []; // Retorna un array vacío para coherencia + } + setLoadingDropdowns(true); // Mantenerlo loading hasta que esto termine + try { + const data = await publiSeccionService.getSeccionesPorPublicacion(pubId, true); + setSeccionesPublicacion(data); + 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); // Aquí se puede poner a false, ya que todas las dependencias están cargadas + } + }, []); + + useEffect(() => { + // 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 - setLocalErrors(errors); - return Object.keys(errors).length === 0; - }; + useEffect(() => { + if (!open) { + resetForm(); + return; + } - const handleAddSeccion = () => { - setSeccionesDeTirada([...seccionesDeTirada, { idSeccion: '', cantPag: '', nombreSeccion: '', idTemporal: crypto.randomUUID() }]); - if (localErrors.seccionesArray) setLocalErrors(prev => ({ ...prev, seccionesArray: null })); - }; - const handleRemoveSeccion = (index: number) => { - setSeccionesDeTirada(seccionesDeTirada.filter((_, i) => i !== index)); - }; + clearErrorMessage(); + setLocalErrors({}); + setLoadingDropdowns(true); // Siempre iniciar con loading true cuando el modal abre - const handleSeccionChange = (index: number, field: 'idSeccion' | 'cantPag', value: string | number) => { - const nuevasSecciones = [...seccionesDeTirada]; - const targetSeccion = nuevasSecciones[index]; + const loadData = async () => { + // 1. Cargar datos base (Publicaciones y Plantas) + await fetchInitialDropdowns(); // Esto actualiza el estado de `publicaciones` y `plantas` - if (field === 'idSeccion') { - const numValue = Number(value); // El valor del Select es string, pero lo guardamos como number | '' - targetSeccion.idSeccion = numValue === 0 ? '' : numValue; // Si es 0 (placeholder), guardar '' - const seccionSeleccionada = seccionesPublicacion.find(s => s.idSeccion === numValue); - targetSeccion.nombreSeccion = seccionSeleccionada?.nombre || ''; - } else { // cantPag - targetSeccion.cantPag = value as string; // Guardar como string, validar como número después - } - setSeccionesDeTirada(nuevasSecciones); - if (localErrors[`seccion_${index}_id`]) setLocalErrors(prev => ({ ...prev, [`seccion_${index}_id`]: null })); - if (localErrors[`seccion_${index}_pag`]) setLocalErrors(prev => ({ ...prev, [`seccion_${index}_pag`]: null })); - }; + 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, isEditMode, fetchSeccionesDePublicacion]); - const handleSubmit = async (event: React.FormEvent) => { - event.preventDefault(); - clearErrorMessage(); - if (!validate()) return; + const validate = (): boolean => { + const errors: { [key: string]: string | null } = {}; + 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.'; - setLoading(true); - try { - const dataToSubmit: CreateTiradaRequestDto = { - 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í - })) - }; - await onSubmit(dataToSubmit); - onClose(); - } catch (error: any) { - console.error("Error en submit de TiradaFormModal:", error); - } finally { - setLoading(false); - } - }; + // 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.`; + } + }); + } - return ( - - - Registrar Nueva Tirada - - {/* ... (campos de Publicacion, Fecha, Planta, Ejemplares sin cambios) ... */} - - - Publicación - { setIdPublicacion(e.target.value as number); setLocalErrors(p => ({ ...p, idPublicacion: null })); }} + disabled={loading || loadingDropdowns || isEditMode} // Publicación siempre deshabilitada en edición + > + Seleccione + {publicaciones.map((p) => ({p.nombre} ({p.nombreEmpresa})))} + + {localErrors.idPublicacion && {localErrors.idPublicacion}} + + { setFecha(e.target.value); setLocalErrors(p => ({ ...p, fecha: null })); }} + margin="dense" error={!!localErrors.fecha} helperText={localErrors.fecha || ''} + disabled={loading || isEditMode} // Fecha siempre deshabilitada en edición + InputLabelProps={{ shrink: true }} sx={{ flex: 1, minWidth: 160 }} + /> + + + + Planta + + {localErrors.idPlanta && {localErrors.idPlanta}} + + { setEjemplares(e.target.value); setLocalErrors(p => ({ ...p, ejemplares: null })); }} + margin="dense" error={!!localErrors.ejemplares} helperText={localErrors.ejemplares || ''} + disabled={loading} sx={{ flex: 1, minWidth: 150 }} + inputProps={{ min: 1 }} + /> + + + Detalle de Secciones Impresas: + {localErrors.seccionesArray && {localErrors.seccionesArray}} + + - Seleccione - {publicaciones.map((p) => ({p.nombre} ({p.nombreEmpresa})))} - - {localErrors.idPublicacion && {localErrors.idPublicacion}} - - {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}} - /> + + + + + Sección + Cant. Páginas + + + + + {seccionesDeTirada.map((sec, index) => ( + + + + + {localErrors[`seccion_${index}_id`] && {localErrors[`seccion_${index}_id`]}} + {localErrors[`seccion_${index}_duplicate`] && {localErrors[`seccion_${index}_duplicate`]}} + + + + handleSeccionChange(index, 'cantPag', e.target.value)} + error={!!localErrors[`seccion_${index}_pag`]} + helperText={localErrors[`seccion_${index}_pag`] || ''} + disabled={loading} + inputProps={{ min: 1 }} + /> + + + handleRemoveSeccion(index)} size="small" color="error" disabled={loading}> + + + + + ))} + {seccionesDeTirada.length === 0 && ( + + + + Agregue secciones a la tirada. + + + + )} + +
+
+ + {/* Contenedor para el botón que actúa como un pie de página fijo */} + `1px solid ${theme.palette.divider}`, + flexShrink: 0 + }} + > + + +
+ + {errorMessage && {errorMessage}} + {localErrors.dropdowns && {localErrors.dropdowns}} + + + + + +
- - - Planta - - {localErrors.idPlanta && {localErrors.idPlanta}} - - {setEjemplares(e.target.value); setLocalErrors(p => ({...p, ejemplares: null}));}} - margin="dense" error={!!localErrors.ejemplares} helperText={localErrors.ejemplares || ''} - disabled={loading} sx={{flex:1, minWidth: 150}} - inputProps={{min:1}} - /> - - - - Detalle de Secciones Impresas: - {localErrors.seccionesArray && {localErrors.seccionesArray}} - {/* Permitir scroll en tabla de secciones */} - - {/* stickyHeader para que cabecera quede fija */} - - - Sección - Cant. Páginas - - - - - {seccionesDeTirada.map((sec, index) => ( - {/* Usar idTemporal para key */} - - - - {localErrors[`seccion_${index}_id`] && {localErrors[`seccion_${index}_id`]}} - - - - handleSeccionChange(index, 'cantPag', e.target.value)} - error={!!localErrors[`seccion_${index}_pag`]} - helperText={localErrors[`seccion_${index}_pag`] || ''} - disabled={loading} - inputProps={{min:1}} - /> - - - handleRemoveSeccion(index)} size="small" color="error" disabled={loading}> - - - - - ))} - {seccionesDeTirada.length === 0 && ( - - - - Agregue secciones a la tirada. - - - - )} - -
-
- -
- - {errorMessage && {errorMessage}} - {localErrors.dropdowns && {localErrors.dropdowns}} - - - - - - - -
- ); + + ); }; export default TiradaFormModal; \ No newline at end of file diff --git a/Frontend/src/models/dtos/Impresion/UpdateDetalleSeccionTiradaDto.ts b/Frontend/src/models/dtos/Impresion/UpdateDetalleSeccionTiradaDto.ts new file mode 100644 index 0000000..f5be530 --- /dev/null +++ b/Frontend/src/models/dtos/Impresion/UpdateDetalleSeccionTiradaDto.ts @@ -0,0 +1,5 @@ +export interface UpdateDetalleSeccionTiradaDto { + idRegPublicacionSeccion: number; // Será 0 para las nuevas secciones + idSeccion: number; + cantPag: number; +} \ No newline at end of file diff --git a/Frontend/src/models/dtos/Impresion/UpdateTiradaRequestDto.ts b/Frontend/src/models/dtos/Impresion/UpdateTiradaRequestDto.ts new file mode 100644 index 0000000..91e480e --- /dev/null +++ b/Frontend/src/models/dtos/Impresion/UpdateTiradaRequestDto.ts @@ -0,0 +1,6 @@ +import type { UpdateDetalleSeccionTiradaDto } from "./UpdateDetalleSeccionTiradaDto"; + +export interface UpdateTiradaRequestDto { + ejemplares: number; + secciones: UpdateDetalleSeccionTiradaDto[]; +} \ No newline at end of file diff --git a/Frontend/src/pages/Impresion/GestionarTiradasPage.tsx b/Frontend/src/pages/Impresion/GestionarTiradasPage.tsx index fe55fe9..2d07bc3 100644 --- a/Frontend/src/pages/Impresion/GestionarTiradasPage.tsx +++ b/Frontend/src/pages/Impresion/GestionarTiradasPage.tsx @@ -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(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 ( + {/* Añadimos un Backdrop para el feedback visual durante la carga de los datos de edición */} + theme.zIndex.drawer + 1 }} + open={loadingEditData} + > + + Gestión de Tiradas Filtros @@ -164,7 +229,7 @@ const GestionarTiradasPage: React.FC = () => { {/* */} - {puedeRegistrar && ()} + {puedeRegistrar && ()} {loading && } @@ -174,43 +239,90 @@ const GestionarTiradasPage: React.FC = () => { {!loading && !error && puedeVer && ( {tiradas.length === 0 ? ( - No se encontraron tiradas con los filtros aplicados. + + No se encontraron tiradas con los filtros aplicados. + ) : ( tiradas.map((tirada) => ( - - }> - - {formatDate(tirada.fecha)} - {tirada.nombrePublicacion} ({tirada.nombrePlanta}) - - - - {puedeEliminar && ( - { e.stopPropagation(); handleDeleteTirada(tirada); }} sx={{ ml: 1 }}> - - - )} - + + {/* 1. Contenedor principal con alineación al inicio (arriba) */} + + + } + aria-controls={`panel${tirada.idRegistroTirada}-content`} + id={`panel${tirada.idRegistroTirada}-header`} + > + {/* El contenido del summary ahora es solo el título principal */} + + {formatDate(tirada.fecha)} - {tirada.nombrePublicacion} ({tirada.nombrePlanta}) + + + + + + + + Sección + Páginas + + + + {tirada.seccionesImpresas.map(sec => ( + + {sec.nombreSeccion} + {sec.cantPag} + + ))} + +
+
+
+
+ + {/* Contenedor de acciones con Chips y Botones */} + + + + + {puedeModificar && ( + handleOpenModal(tirada)} + title="Editar Tirada" + > + + + )} + {puedeEliminar && ( + handleDeleteTirada(tirada)} + title="Eliminar Tirada" + > + + + )} -
- - - - - Sección - Páginas - - - {tirada.seccionesImpresas.map(sec => ( - - {sec.nombreSeccion} - {sec.cantPag} - - ))} - -
-
-
-
+
+ )) )} @@ -223,6 +335,7 @@ const GestionarTiradasPage: React.FC = () => { onSubmit={handleSubmitModal} errorMessage={apiErrorMessage} clearErrorMessage={() => setApiErrorMessage(null)} + tiradaToEdit={tiradaAEditar} /> ); diff --git a/Frontend/src/services/Impresion/tiradaService.ts b/Frontend/src/services/Impresion/tiradaService.ts index ced7b4b..5e0e498 100644 --- a/Frontend/src/services/Impresion/tiradaService.ts +++ b/Frontend/src/services/Impresion/tiradaService.ts @@ -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 return response.data; // El backend devuelve la tirada creada }; +const modificarTirada = async (fecha: string, idPublicacion: number, idPlanta: number, data: UpdateTiradaRequestDto): Promise => { + const response = await apiClient.put('/tiradas', data, { + params: { + fecha, + idPublicacion, + idPlanta + } + }); + return response.data; +}; + const deleteTiradaCompleta = async (fecha: string, idPublicacion: number, idPlanta: number): Promise => { // 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, };