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

**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:
2025-07-23 14:05:58 -03:00
parent 052141a45b
commit 28c1b88a92
14 changed files with 884 additions and 320 deletions

View File

@@ -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<void>;
errorMessage?: string | null;
clearErrorMessage: () => void;
open: boolean;
onClose: () => 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> = ({
open,
onClose,
onSubmit,
errorMessage,
clearErrorMessage
open,
onClose,
onSubmit,
errorMessage,
clearErrorMessage,
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 isEditMode = !!tiradaToEdit;
const [publicaciones, setPublicaciones] = useState<PublicacionDto[]>([]);
const [plantas, setPlantas] = useState<PlantaDto[]>([]);
const [seccionesPublicacion, setSeccionesPublicacion] = useState<PubliSeccionDto[]>([]);
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>('');
const [seccionesDeTirada, setSeccionesDeTirada] = useState<DetalleSeccionFormState[]>([]);
const [loading, setLoading] = useState(false);
const [loadingDropdowns, setLoadingDropdowns] = useState(false);
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
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 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<HTMLFormElement>) => {
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 (
<Modal open={open} onClose={onClose}>
<Box sx={modalStyle}>
<Typography variant="h6" component="h2" gutterBottom>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}
setLocalErrors(errors);
return Object.keys(errors).length === 0;
};
const handleAddSeccion = () => {
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) => {
setSeccionesDeTirada(seccionesDeTirada.filter((_, i) => i !== index));
};
const handleSeccionChange = (index: number, field: 'idSeccion' | 'cantPag', value: string | number) => {
const nuevasSecciones = [...seccionesDeTirada];
const targetSeccion = nuevasSecciones[index];
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 }));
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
clearErrorMessage();
if (!validate()) return;
setLoading(true);
try {
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),
secciones: seccionesSubmit
};
}
await onSubmit(dataToSubmit, isEditMode); // Informar al padre si es edición
onClose();
} catch (error: any) {
console.error("Error en submit de TiradaFormModal:", error);
} finally {
setLoading(false);
}
};
return (
<Modal open={open} onClose={onClose}>
<Box sx={modalStyle}>
<Typography variant="h6" component="h2" gutterBottom>
{isEditMode ? 'Modificar Tirada' : 'Registrar Nueva Tirada'}
</Typography>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 1 }}>
<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 || 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>))}
</Select>
{localErrors.idPublicacion && <Typography color="error" variant="caption" sx={{ ml: 1.5 }}>{localErrors.idPublicacion}</Typography>}
</FormControl>
<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 || 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 }}>
<FormControl fullWidth margin="dense" error={!!localErrors.idPlanta} sx={{ flex: 1, minWidth: 200 }}>
<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 || 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>))}
</Select>
{localErrors.idPlanta && <Typography color="error" variant="caption" sx={{ ml: 1.5 }}>{localErrors.idPlanta}</Typography>}
</FormControl>
<TextField label="Total Ejemplares" type="number" value={ejemplares} required
onChange={(e) => { 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 }}
/>
</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={{
mb: 2,
display: 'flex',
flexDirection: 'column',
maxHeight: '300px'
}}
>
<MenuItem value="" disabled><em>Seleccione</em></MenuItem>
{publicaciones.map((p) => (<MenuItem key={p.idPublicacion} value={p.idPublicacion}>{p.nombre} ({p.nombreEmpresa})</MenuItem>))}
</Select>
{localErrors.idPublicacion && <Typography color="error" variant="caption" sx={{ml:1.5}}>{localErrors.idPublicacion}</Typography>}
</FormControl>
<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}}
/>
<TableContainer ref={tableContainerRef} sx={{ flex: 1, overflowY: 'auto' }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 'bold', minWidth: 200 }}>Sección</TableCell>
<TableCell sx={{ fontWeight: 'bold', width: '150px' }}>Cant. Páginas</TableCell>
<TableCell align="right" sx={{ width: '50px' }}></TableCell>
</TableRow>
</TableHead>
<TableBody>
{seccionesDeTirada.map((sec, index) => (
<TableRow key={sec.idTemporal || index}>
<TableCell sx={{ py: 0.5 }}>
<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
>
<MenuItem value="" disabled><em>Seleccionar</em></MenuItem>
{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 }}>
<TextField type="number" size="small" fullWidth value={sec.cantPag}
onChange={(e) => handleSeccionChange(index, 'cantPag', e.target.value)}
error={!!localErrors[`seccion_${index}_pag`]}
helperText={localErrors[`seccion_${index}_pag`] || ''}
disabled={loading}
inputProps={{ min: 1 }}
/>
</TableCell>
<TableCell align="right" sx={{ py: 0.5 }}>
<IconButton onClick={() => handleRemoveSeccion(index)} size="small" color="error" disabled={loading}>
<DeleteOutlineIcon />
</IconButton>
</TableCell>
</TableRow>
))}
{seccionesDeTirada.length === 0 && (
<TableRow>
<TableCell colSpan={3} align="center">
<Typography variant="caption" color="textSecondary">
Agregue secciones a la tirada.
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
{/* 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>}
{localErrors.dropdowns && <Alert severity="warning" sx={{ mt: 1 }}>{localErrors.dropdowns}</Alert>}
<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} /> : (isEditMode ? 'Guardar Cambios' : 'Registrar Tirada')}
</Button>
</Box>
</Box>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mb: 2 }}>
<FormControl fullWidth margin="dense" error={!!localErrors.idPlanta} sx={{flex:1, minWidth: 200}}>
<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}
>
<MenuItem value="" disabled><em>Seleccione</em></MenuItem>
{plantas.map((p) => (<MenuItem key={p.idPlanta} value={p.idPlanta}>{p.nombre}</MenuItem>))}
</Select>
{localErrors.idPlanta && <Typography color="error" variant="caption" sx={{ml:1.5}}>{localErrors.idPlanta}</Typography>}
</FormControl>
<TextField label="Total Ejemplares" type="number" value={ejemplares} required
onChange={(e) => {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}}
/>
</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 */}
<TableHead>
<TableRow>
<TableCell sx={{fontWeight:'bold', minWidth: 200}}>Sección</TableCell>
<TableCell sx={{fontWeight:'bold', width: '150px'}}>Cant. Páginas</TableCell>
<TableCell align="right" sx={{width: '50px'}}></TableCell>
</TableRow>
</TableHead>
<TableBody>
{seccionesDeTirada.map((sec, index) => (
<TableRow key={sec.idTemporal || index}> {/* Usar idTemporal para key */}
<TableCell sx={{py:0.5}}>
<FormControl fullWidth size="small" error={!!localErrors[`seccion_${index}_id`]}>
<Select value={sec.idSeccion} // Ahora idSeccion es number | ''
onChange={(e) => handleSeccionChange(index, 'idSeccion', e.target.value as number | '')}
disabled={loading || loadingDropdowns || seccionesPublicacion.length === 0}
displayEmpty
>
<MenuItem value="" disabled><em>Seleccionar</em></MenuItem>
{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>}
</FormControl>
</TableCell>
<TableCell sx={{py:0.5}}>
<TextField type="number" size="small" fullWidth value={sec.cantPag}
onChange={(e) => handleSeccionChange(index, 'cantPag', e.target.value)}
error={!!localErrors[`seccion_${index}_pag`]}
helperText={localErrors[`seccion_${index}_pag`] || ''}
disabled={loading}
inputProps={{min:1}}
/>
</TableCell>
<TableCell align="right" sx={{py:0.5}}>
<IconButton onClick={() => handleRemoveSeccion(index)} size="small" color="error" disabled={loading}>
<DeleteOutlineIcon />
</IconButton>
</TableCell>
</TableRow>
))}
{seccionesDeTirada.length === 0 && (
<TableRow>
<TableCell colSpan={3} align="center">
<Typography variant="caption" color="textSecondary">
Agregue secciones a la tirada.
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<Button startIcon={<AddCircleOutlineIcon />} onClick={handleAddSeccion} sx={{mt:1}} size="small" disabled={loading || !idPublicacion}>
Agregar Sección
</Button>
</Paper>
{errorMessage && <Alert severity="error" sx={{ mt: 2, width: '100%' }}>{errorMessage}</Alert>}
{localErrors.dropdowns && <Alert severity="warning" sx={{ mt: 1 }}>{localErrors.dropdowns}</Alert>}
<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'}
</Button>
</Box>
</Box>
</Box>
</Modal>
);
</Modal>
);
};
export default TiradaFormModal;

View File

@@ -0,0 +1,5 @@
export interface UpdateDetalleSeccionTiradaDto {
idRegPublicacionSeccion: number; // Será 0 para las nuevas secciones
idSeccion: number;
cantPag: number;
}

View File

@@ -0,0 +1,6 @@
import type { UpdateDetalleSeccionTiradaDto } from "./UpdateDetalleSeccionTiradaDto";
export interface UpdateTiradaRequestDto {
ejemplares: number;
secciones: UpdateDetalleSeccionTiradaDto[];
}

View File

@@ -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,43 +239,90 @@ 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>
<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>
<Table size="small">
<TableHead>
<TableRow>
<TableCell><strong>Sección</strong></TableCell>
<TableCell align="right"><strong>Páginas</strong></TableCell>
</TableRow>
</TableHead>
<TableBody>
{tirada.seccionesImpresas.map(sec => (
<TableRow key={sec.idRegPublicacionSeccion}>
<TableCell>{sec.nombreSeccion}</TableCell>
<TableCell align="right">{sec.cantPag}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</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>
</AccordionSummary>
<AccordionDetails>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead><TableRow>
<TableCell><strong>Sección</strong></TableCell>
<TableCell align="right"><strong>Páginas</strong></TableCell>
</TableRow></TableHead>
<TableBody>
{tirada.seccionesImpresas.map(sec => (
<TableRow key={sec.idRegPublicacionSeccion}>
<TableCell>{sec.nombreSeccion}</TableCell>
<TableCell align="right">{sec.cantPag}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</AccordionDetails>
</Accordion>
</Box>
</Paper>
))
)}
</Box>
@@ -223,6 +335,7 @@ const GestionarTiradasPage: React.FC = () => {
onSubmit={handleSubmitModal}
errorMessage={apiErrorMessage}
clearErrorMessage={() => setApiErrorMessage(null)}
tiradaToEdit={tiradaAEditar}
/>
</Box>
);

View File

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