Feat: Implementación de módulos ABM de suscripciones por cliente
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
// Archivo: GestionIntegral.Api/Controllers/Suscripciones/SuscripcionesController.cs
|
||||
|
||||
using GestionIntegral.Api.Dtos.Suscripciones;
|
||||
using GestionIntegral.Api.Services.Suscripciones;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace GestionIntegral.Api.Controllers.Suscripciones
|
||||
{
|
||||
[Route("api/suscripciones")] // Ruta base para acciones sobre una suscripción específica
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class SuscripcionesController : ControllerBase
|
||||
{
|
||||
private readonly ISuscripcionService _suscripcionService;
|
||||
private readonly ILogger<SuscripcionesController> _logger;
|
||||
|
||||
// Permisos (nuevos, a crear en la BD)
|
||||
private const string PermisoGestionarSuscripciones = "SU005";
|
||||
|
||||
public SuscripcionesController(ISuscripcionService suscripcionService, ILogger<SuscripcionesController> logger)
|
||||
{
|
||||
_suscripcionService = suscripcionService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private bool TienePermiso(string codAcc) => User.IsInRole("SuperAdmin") || User.HasClaim(c => c.Type == "permission" && c.Value == codAcc);
|
||||
|
||||
private int? GetCurrentUserId()
|
||||
{
|
||||
if (int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"), out int userId)) return userId;
|
||||
_logger.LogWarning("No se pudo obtener el UserId del token JWT en SuscripcionesController.");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Endpoint anidado para obtener las suscripciones de un suscriptor
|
||||
// GET: api/suscriptores/{idSuscriptor}/suscripciones
|
||||
[HttpGet("~/api/suscriptores/{idSuscriptor:int}/suscripciones")]
|
||||
public async Task<IActionResult> GetBySuscriptor(int idSuscriptor)
|
||||
{
|
||||
// Se podría usar el permiso de ver suscriptores (SU001) o el de gestionar suscripciones (SU005)
|
||||
if (!TienePermiso("SU001")) return Forbid();
|
||||
|
||||
var suscripciones = await _suscripcionService.ObtenerPorSuscriptorId(idSuscriptor);
|
||||
return Ok(suscripciones);
|
||||
}
|
||||
|
||||
// GET: api/suscripciones/{id}
|
||||
[HttpGet("{id:int}", Name = "GetSuscripcionById")]
|
||||
public async Task<IActionResult> GetById(int id)
|
||||
{
|
||||
if (!TienePermiso(PermisoGestionarSuscripciones)) return Forbid();
|
||||
var suscripcion = await _suscripcionService.ObtenerPorId(id);
|
||||
if (suscripcion == null) return NotFound();
|
||||
return Ok(suscripcion);
|
||||
}
|
||||
|
||||
// POST: api/suscripciones
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateSuscripcionDto createDto)
|
||||
{
|
||||
if (!TienePermiso(PermisoGestionarSuscripciones)) return Forbid();
|
||||
if (!ModelState.IsValid) return BadRequest(ModelState);
|
||||
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var (dto, error) = await _suscripcionService.Crear(createDto, userId.Value);
|
||||
|
||||
if (error != null) return BadRequest(new { message = error });
|
||||
if (dto == null) return StatusCode(StatusCodes.Status500InternalServerError, "Error al crear la suscripción.");
|
||||
|
||||
return CreatedAtRoute("GetSuscripcionById", new { id = dto.IdSuscripcion }, dto);
|
||||
}
|
||||
|
||||
// PUT: api/suscripciones/{id}
|
||||
[HttpPut("{id:int}")]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateSuscripcionDto updateDto)
|
||||
{
|
||||
if (!TienePermiso(PermisoGestionarSuscripciones)) return Forbid();
|
||||
if (!ModelState.IsValid) return BadRequest(ModelState);
|
||||
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var (exito, error) = await _suscripcionService.Actualizar(id, updateDto, userId.Value);
|
||||
|
||||
if (!exito)
|
||||
{
|
||||
if (error != null && error.Contains("no encontrada")) return NotFound(new { message = error });
|
||||
return BadRequest(new { message = error });
|
||||
}
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GestionIntegral.Api.Dtos.Suscripciones
|
||||
{
|
||||
public class UpdateSuscripcionDto
|
||||
{
|
||||
// No permitimos cambiar el suscriptor o la publicación una vez creada.
|
||||
// Se debe cancelar y crear una nueva.
|
||||
|
||||
[Required]
|
||||
public DateTime FechaInicio { get; set; }
|
||||
|
||||
public DateTime? FechaFin { get; set; }
|
||||
|
||||
[Required]
|
||||
public string Estado { get; set; } = string.Empty;
|
||||
|
||||
[Required(ErrorMessage = "Debe especificar los días de entrega.")]
|
||||
public List<string> DiasEntrega { get; set; } = new List<string>();
|
||||
|
||||
[StringLength(250)]
|
||||
public string? Observaciones { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,7 @@ builder.Services.AddScoped<IPagoRepository, PagoRepository>();
|
||||
// Servicios
|
||||
builder.Services.AddScoped<IFormaPagoService, FormaPagoService>();
|
||||
builder.Services.AddScoped<ISuscriptorService, SuscriptorService>();
|
||||
builder.Services.AddScoped<ISuscripcionService, SuscripcionService>();
|
||||
|
||||
// --- SERVICIO DE HEALTH CHECKS ---
|
||||
// Añadimos una comprobación específica para SQL Server.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using GestionIntegral.Api.Dtos.Suscripciones;
|
||||
|
||||
namespace GestionIntegral.Api.Services.Suscripciones
|
||||
{
|
||||
public interface ISuscripcionService
|
||||
{
|
||||
Task<IEnumerable<SuscripcionDto>> ObtenerPorSuscriptorId(int idSuscriptor);
|
||||
Task<SuscripcionDto?> ObtenerPorId(int idSuscripcion);
|
||||
Task<(SuscripcionDto? Suscripcion, string? Error)> Crear(CreateSuscripcionDto createDto, int idUsuario);
|
||||
Task<(bool Exito, string? Error)> Actualizar(int idSuscripcion, UpdateSuscripcionDto updateDto, int idUsuario);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using GestionIntegral.Api.Data;
|
||||
using GestionIntegral.Api.Data.Repositories.Distribucion;
|
||||
using GestionIntegral.Api.Data.Repositories.Suscripciones;
|
||||
using GestionIntegral.Api.Dtos.Suscripciones;
|
||||
using GestionIntegral.Api.Models.Suscripciones;
|
||||
using System.Data;
|
||||
|
||||
namespace GestionIntegral.Api.Services.Suscripciones
|
||||
{
|
||||
public class SuscripcionService : ISuscripcionService
|
||||
{
|
||||
private readonly ISuscripcionRepository _suscripcionRepository;
|
||||
private readonly ISuscriptorRepository _suscriptorRepository;
|
||||
private readonly IPublicacionRepository _publicacionRepository;
|
||||
private readonly DbConnectionFactory _connectionFactory;
|
||||
private readonly ILogger<SuscripcionService> _logger;
|
||||
|
||||
public SuscripcionService(
|
||||
ISuscripcionRepository suscripcionRepository,
|
||||
ISuscriptorRepository suscriptorRepository,
|
||||
IPublicacionRepository publicacionRepository,
|
||||
DbConnectionFactory connectionFactory,
|
||||
ILogger<SuscripcionService> logger)
|
||||
{
|
||||
_suscripcionRepository = suscripcionRepository;
|
||||
_suscriptorRepository = suscriptorRepository;
|
||||
_publicacionRepository = publicacionRepository;
|
||||
_connectionFactory = connectionFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private async Task<SuscripcionDto?> MapToDto(Suscripcion suscripcion)
|
||||
{
|
||||
if (suscripcion == null) return null;
|
||||
var publicacion = await _publicacionRepository.GetByIdSimpleAsync(suscripcion.IdPublicacion);
|
||||
return new SuscripcionDto
|
||||
{
|
||||
IdSuscripcion = suscripcion.IdSuscripcion,
|
||||
IdSuscriptor = suscripcion.IdSuscriptor,
|
||||
IdPublicacion = suscripcion.IdPublicacion,
|
||||
NombrePublicacion = publicacion?.Nombre ?? "Desconocida",
|
||||
FechaInicio = suscripcion.FechaInicio.ToString("yyyy-MM-dd"),
|
||||
FechaFin = suscripcion.FechaFin?.ToString("yyyy-MM-dd"),
|
||||
Estado = suscripcion.Estado,
|
||||
DiasEntrega = suscripcion.DiasEntrega,
|
||||
Observaciones = suscripcion.Observaciones
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<SuscripcionDto?> ObtenerPorId(int idSuscripcion)
|
||||
{
|
||||
var suscripcion = await _suscripcionRepository.GetByIdAsync(idSuscripcion);
|
||||
if (suscripcion == null)
|
||||
return null;
|
||||
return await MapToDto(suscripcion);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<SuscripcionDto>> ObtenerPorSuscriptorId(int idSuscriptor)
|
||||
{
|
||||
var suscripciones = await _suscripcionRepository.GetBySuscriptorIdAsync(idSuscriptor);
|
||||
var dtosTasks = suscripciones.Select(s => MapToDto(s));
|
||||
var dtos = await Task.WhenAll(dtosTasks);
|
||||
return dtos.Where(dto => dto != null)!;
|
||||
}
|
||||
|
||||
public async Task<(SuscripcionDto? Suscripcion, string? Error)> Crear(CreateSuscripcionDto createDto, int idUsuario)
|
||||
{
|
||||
if (await _suscriptorRepository.GetByIdAsync(createDto.IdSuscriptor) == null)
|
||||
return (null, "El suscriptor no existe.");
|
||||
if (await _publicacionRepository.GetByIdSimpleAsync(createDto.IdPublicacion) == null)
|
||||
return (null, "La publicación no existe.");
|
||||
if (createDto.FechaFin.HasValue && createDto.FechaFin.Value < createDto.FechaInicio)
|
||||
return (null, "La fecha de fin no puede ser anterior a la fecha de inicio.");
|
||||
|
||||
var nuevaSuscripcion = new Suscripcion
|
||||
{
|
||||
IdSuscriptor = createDto.IdSuscriptor,
|
||||
IdPublicacion = createDto.IdPublicacion,
|
||||
FechaInicio = createDto.FechaInicio,
|
||||
FechaFin = createDto.FechaFin,
|
||||
Estado = createDto.Estado,
|
||||
DiasEntrega = string.Join(",", createDto.DiasEntrega),
|
||||
Observaciones = createDto.Observaciones,
|
||||
IdUsuarioAlta = idUsuario
|
||||
};
|
||||
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
|
||||
using var transaction = connection.BeginTransaction();
|
||||
try
|
||||
{
|
||||
var creada = await _suscripcionRepository.CreateAsync(nuevaSuscripcion, transaction);
|
||||
if (creada == null) throw new DataException("Error al crear la suscripción.");
|
||||
|
||||
transaction.Commit();
|
||||
_logger.LogInformation("Suscripción ID {Id} creada por Usuario ID {UserId}.", creada.IdSuscripcion, idUsuario);
|
||||
return (await MapToDto(creada), null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try { transaction.Rollback(); } catch { }
|
||||
_logger.LogError(ex, "Error al crear suscripción para suscriptor ID {IdSuscriptor}", createDto.IdSuscriptor);
|
||||
return (null, $"Error interno: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(bool Exito, string? Error)> Actualizar(int idSuscripcion, UpdateSuscripcionDto updateDto, int idUsuario)
|
||||
{
|
||||
var existente = await _suscripcionRepository.GetByIdAsync(idSuscripcion);
|
||||
if (existente == null) return (false, "Suscripción no encontrada.");
|
||||
|
||||
if (updateDto.FechaFin.HasValue && updateDto.FechaFin.Value < updateDto.FechaInicio)
|
||||
return (false, "La fecha de fin no puede ser anterior a la fecha de inicio.");
|
||||
|
||||
existente.FechaInicio = updateDto.FechaInicio;
|
||||
existente.FechaFin = updateDto.FechaFin;
|
||||
existente.Estado = updateDto.Estado;
|
||||
existente.DiasEntrega = string.Join(",", updateDto.DiasEntrega);
|
||||
existente.Observaciones = updateDto.Observaciones;
|
||||
existente.IdUsuarioMod = idUsuario;
|
||||
existente.FechaMod = DateTime.Now;
|
||||
|
||||
using var connection = _connectionFactory.CreateConnection();
|
||||
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
|
||||
using var transaction = connection.BeginTransaction();
|
||||
try
|
||||
{
|
||||
var actualizado = await _suscripcionRepository.UpdateAsync(existente, transaction);
|
||||
if (!actualizado) throw new DataException("Error al actualizar la suscripción.");
|
||||
|
||||
transaction.Commit();
|
||||
_logger.LogInformation("Suscripción ID {Id} actualizada por Usuario ID {UserId}.", idSuscripcion, idUsuario);
|
||||
return (true, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try { transaction.Rollback(); } catch { }
|
||||
_logger.LogError(ex, "Error al actualizar suscripción ID: {IdSuscripcion}", idSuscripcion);
|
||||
return (false, $"Error interno: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Modal, Box, Typography, TextField, Button, CircularProgress, Alert,
|
||||
FormControl, InputLabel, Select, MenuItem, FormGroup, FormControlLabel,
|
||||
Checkbox, type SelectChangeEvent, Paper
|
||||
} from '@mui/material';
|
||||
import type { SuscripcionDto } from '../../../models/dtos/Suscripciones/SuscripcionDto';
|
||||
import type { CreateSuscripcionDto } from '../../../models/dtos/Suscripciones/CreateSuscripcionDto';
|
||||
import type { UpdateSuscripcionDto } from '../../../models/dtos/Suscripciones/UpdateSuscripcionDto';
|
||||
import type { PublicacionDropdownDto } from '../../../models/dtos/Distribucion/PublicacionDropdownDto';
|
||||
import publicacionService from '../../../services/Distribucion/publicacionService';
|
||||
|
||||
const modalStyle = {
|
||||
position: 'absolute' as 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: { xs: '95%', sm: '80%', md: '600px' },
|
||||
bgcolor: 'background.paper',
|
||||
border: '2px solid #000',
|
||||
boxShadow: 24,
|
||||
p: 4,
|
||||
maxHeight: '90vh',
|
||||
overflowY: 'auto'
|
||||
};
|
||||
|
||||
const dias = [
|
||||
{ label: 'Lunes', value: 'L' }, { label: 'Martes', value: 'M' },
|
||||
{ label: 'Miércoles', value: 'X' }, { label: 'Jueves', value: 'J' },
|
||||
{ label: 'Viernes', value: 'V' }, { label: 'Sábado', value: 'S' },
|
||||
{ label: 'Domingo', value: 'D' }
|
||||
];
|
||||
|
||||
interface SuscripcionFormModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: CreateSuscripcionDto | UpdateSuscripcionDto, id?: number) => Promise<void>;
|
||||
idSuscriptor: number;
|
||||
initialData?: SuscripcionDto | null;
|
||||
errorMessage?: string | null;
|
||||
clearErrorMessage: () => void;
|
||||
}
|
||||
|
||||
// Usamos una interfaz local que contenga todos los campos posibles del formulario
|
||||
interface FormState {
|
||||
idPublicacion?: number | '';
|
||||
fechaInicio?: string;
|
||||
fechaFin?: string | null;
|
||||
estado?: 'Activa' | 'Pausada' | 'Cancelada';
|
||||
observaciones?: string;
|
||||
}
|
||||
|
||||
const SuscripcionFormModal: React.FC<SuscripcionFormModalProps> = ({ open, onClose, onSubmit, idSuscriptor, initialData, errorMessage, clearErrorMessage }) => {
|
||||
const [formData, setFormData] = useState<FormState>({});
|
||||
const [selectedDays, setSelectedDays] = useState<Set<string>>(new Set());
|
||||
const [publicaciones, setPublicaciones] = useState<PublicacionDropdownDto[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingPubs, setLoadingPubs] = useState(false);
|
||||
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
|
||||
|
||||
const isEditing = Boolean(initialData);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPublicaciones = async () => {
|
||||
setLoadingPubs(true);
|
||||
try {
|
||||
const data = await publicacionService.getPublicacionesForDropdown(true);
|
||||
setPublicaciones(data);
|
||||
} catch (error) {
|
||||
setLocalErrors(prev => ({ ...prev, publicaciones: 'Error al cargar publicaciones.' }));
|
||||
} finally {
|
||||
setLoadingPubs(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (open) {
|
||||
fetchPublicaciones();
|
||||
const diasEntrega = initialData?.diasEntrega ? new Set<string>(initialData.diasEntrega.split(',')) : new Set<string>();
|
||||
setSelectedDays(diasEntrega);
|
||||
setFormData({
|
||||
idPublicacion: initialData?.idPublicacion || '',
|
||||
fechaInicio: initialData?.fechaInicio || '',
|
||||
fechaFin: initialData?.fechaFin || '',
|
||||
estado: initialData?.estado || 'Activa',
|
||||
observaciones: initialData?.observaciones || ''
|
||||
});
|
||||
setLocalErrors({});
|
||||
}
|
||||
}, [open, initialData]);
|
||||
|
||||
const validate = (): boolean => {
|
||||
const errors: { [key: string]: string | null } = {};
|
||||
if (!formData.idPublicacion) errors.idPublicacion = "Debe seleccionar una publicación.";
|
||||
if (!formData.fechaInicio?.trim()) errors.fechaInicio = 'La Fecha de Inicio es obligatoria.';
|
||||
if (formData.fechaFin && formData.fechaInicio && new Date(formData.fechaFin) < new Date(formData.fechaInicio)) {
|
||||
errors.fechaFin = 'La Fecha de Fin no puede ser anterior a la de inicio.';
|
||||
}
|
||||
if (selectedDays.size === 0) errors.diasEntrega = "Debe seleccionar al menos un día de entrega.";
|
||||
setLocalErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleDayChange = (dayValue: string) => {
|
||||
const newSelection = new Set(selectedDays);
|
||||
if (newSelection.has(dayValue)) newSelection.delete(dayValue);
|
||||
else newSelection.add(dayValue);
|
||||
setSelectedDays(newSelection);
|
||||
if (localErrors.diasEntrega) setLocalErrors(prev => ({...prev, diasEntrega: null}));
|
||||
if (errorMessage) clearErrorMessage();
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
|
||||
if (errorMessage) clearErrorMessage();
|
||||
};
|
||||
|
||||
const handleSelectChange = (e: SelectChangeEvent<any>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
|
||||
if (errorMessage) clearErrorMessage();
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
clearErrorMessage();
|
||||
if (!validate()) return;
|
||||
|
||||
setLoading(true);
|
||||
let success = false;
|
||||
try {
|
||||
const dataToSubmit = {
|
||||
...formData,
|
||||
fechaFin: formData.fechaFin || null,
|
||||
diasEntrega: Array.from(selectedDays),
|
||||
};
|
||||
|
||||
if (isEditing && initialData) {
|
||||
await onSubmit(dataToSubmit as UpdateSuscripcionDto, initialData.idSuscripcion);
|
||||
} else {
|
||||
await onSubmit({ ...dataToSubmit, idSuscriptor, idPublicacion: Number(formData.idPublicacion) } as CreateSuscripcionDto);
|
||||
}
|
||||
success = true;
|
||||
} catch (error) {
|
||||
success = false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (success) onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<Box sx={modalStyle}>
|
||||
<Typography variant="h6" component="h2">{isEditing ? 'Editar Suscripción' : 'Nueva Suscripción'}</Typography>
|
||||
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
|
||||
<FormControl fullWidth margin="dense" error={!!localErrors.idPublicacion}>
|
||||
<InputLabel id="pub-label" required>Publicación</InputLabel>
|
||||
<Select name="idPublicacion" labelId="pub-label" value={formData.idPublicacion || ''} onChange={handleSelectChange} label="Publicación" disabled={loading || loadingPubs || isEditing}>
|
||||
{publicaciones.map(p => <MenuItem key={p.idPublicacion} value={p.idPublicacion}>{p.nombre} ({p.nombreEmpresa})</MenuItem>)}
|
||||
</Select>
|
||||
{localErrors.idPublicacion && <Typography color="error" variant="caption">{localErrors.idPublicacion}</Typography>}
|
||||
</FormControl>
|
||||
<Typography sx={{ mt: 2, mb: 1, color: localErrors.diasEntrega ? 'error.main' : 'inherit' }}>Días de Entrega *</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 1, borderColor: localErrors.diasEntrega ? 'error.main' : 'rgba(0, 0, 0, 0.23)' }}>
|
||||
<FormGroup row>
|
||||
{dias.map(d => <FormControlLabel key={d.value} control={<Checkbox checked={selectedDays.has(d.value)} onChange={() => handleDayChange(d.value)} disabled={loading}/>} label={d.label} />)}
|
||||
</FormGroup>
|
||||
</Paper>
|
||||
{localErrors.diasEntrega && <Typography color="error" variant="caption">{localErrors.diasEntrega}</Typography>}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, mt: 1 }}>
|
||||
<TextField name="fechaInicio" label="Fecha Inicio" type="date" value={formData.fechaInicio || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaInicio} helperText={localErrors.fechaInicio} disabled={loading} />
|
||||
<TextField name="fechaFin" label="Fecha Fin (Opcional)" type="date" value={formData.fechaFin || ''} onChange={handleInputChange} fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaFin} helperText={localErrors.fechaFin} disabled={loading} />
|
||||
</Box>
|
||||
<FormControl fullWidth margin="dense">
|
||||
<InputLabel id="estado-label">Estado</InputLabel>
|
||||
<Select name="estado" labelId="estado-label" value={formData.estado || 'Activa'} onChange={handleSelectChange} label="Estado" disabled={loading}>
|
||||
<MenuItem value="Activa">Activa</MenuItem>
|
||||
<MenuItem value="Pausada">Pausada</MenuItem>
|
||||
<MenuItem value="Cancelada">Cancelada</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField name="observaciones" label="Observaciones" value={formData.observaciones || ''} onChange={handleInputChange} multiline rows={2} fullWidth margin="dense" disabled={loading} />
|
||||
|
||||
{errorMessage && <Alert severity="error" sx={{ mt: 2 }}>{errorMessage}</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 || loadingPubs}>
|
||||
{loading ? <CircularProgress size={24} /> : 'Guardar'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SuscripcionFormModal;
|
||||
@@ -0,0 +1,179 @@
|
||||
// Archivo: Frontend/src/components/Modals/Suscripciones/SuscriptorFormModal.tsx
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent } from '@mui/material'; // 1. Importar SelectChangeEvent
|
||||
import type { SuscriptorDto } from '../../../models/dtos/Suscripciones/SuscriptorDto';
|
||||
import type { CreateSuscriptorDto } from '../../../models/dtos/Suscripciones/CreateSuscriptorDto';
|
||||
import type { UpdateSuscriptorDto } from '../../../models/dtos/Suscripciones/UpdateSuscriptorDto';
|
||||
import type { FormaPagoDto } from '../../../models/dtos/Suscripciones/FormaPagoDto';
|
||||
import formaPagoService from '../../../services/Suscripciones/formaPagoService';
|
||||
|
||||
const modalStyle = {
|
||||
position: 'absolute' as 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: { xs: '95%', sm: '80%', md: '600px' },
|
||||
bgcolor: 'background.paper',
|
||||
border: '2px solid #000',
|
||||
boxShadow: 24,
|
||||
p: 4,
|
||||
maxHeight: '90vh',
|
||||
overflowY: 'auto'
|
||||
};
|
||||
|
||||
interface SuscriptorFormModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: CreateSuscriptorDto | UpdateSuscriptorDto, id?: number) => Promise<void>;
|
||||
initialData?: SuscriptorDto | null;
|
||||
errorMessage?: string | null;
|
||||
clearErrorMessage: () => void;
|
||||
}
|
||||
|
||||
const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
|
||||
open, onClose, onSubmit, initialData, errorMessage, clearErrorMessage
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<Partial<CreateSuscriptorDto>>({});
|
||||
const [formasDePago, setFormasDePago] = useState<FormaPagoDto[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingFormasPago, setLoadingFormasPago] = useState(false);
|
||||
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
|
||||
|
||||
const isEditing = Boolean(initialData);
|
||||
const CBURequerido = formasDePago.find(fp => fp.idFormaPago === formData.idFormaPagoPreferida)?.requiereCBU ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFormasDePago = async () => {
|
||||
setLoadingFormasPago(true);
|
||||
try {
|
||||
const data = await formaPagoService.getAllFormasDePago();
|
||||
setFormasDePago(data);
|
||||
} catch (error) {
|
||||
console.error("Error al cargar formas de pago", error);
|
||||
setLocalErrors(prev => ({ ...prev, formasDePago: 'Error al cargar formas de pago.' }));
|
||||
} finally {
|
||||
setLoadingFormasPago(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (open) {
|
||||
fetchFormasDePago();
|
||||
setFormData(initialData || {
|
||||
nombreCompleto: '', tipoDocumento: 'DNI', nroDocumento: '', cbu: ''
|
||||
});
|
||||
setLocalErrors({});
|
||||
}
|
||||
}, [open, initialData]);
|
||||
|
||||
const validate = (): boolean => {
|
||||
const errors: { [key: string]: string | null } = {};
|
||||
if (!formData.nombreCompleto?.trim()) errors.nombreCompleto = 'El nombre es obligatorio.';
|
||||
if (!formData.direccion?.trim()) errors.direccion = 'La dirección es obligatoria.';
|
||||
if (!formData.tipoDocumento) errors.tipoDocumento = 'El tipo de documento es obligatorio.';
|
||||
if (!formData.nroDocumento?.trim()) errors.nroDocumento = 'El número de documento es obligatorio.';
|
||||
if (!formData.idFormaPagoPreferida) errors.idFormaPagoPreferida = 'La forma de pago es obligatoria.';
|
||||
if (CBURequerido && (!formData.cbu || formData.cbu.trim().length !== 22)) {
|
||||
errors.cbu = 'El CBU es obligatorio y debe tener 22 dígitos.';
|
||||
}
|
||||
if (formData.email && !/^\S+@\S+\.\S+$/.test(formData.email)) {
|
||||
errors.email = 'El formato del email no es válido.';
|
||||
}
|
||||
setLocalErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
if (localErrors[name]) {
|
||||
setLocalErrors(prev => ({ ...prev, [name]: null }));
|
||||
}
|
||||
if (errorMessage) clearErrorMessage();
|
||||
};
|
||||
|
||||
// 2. Crear un handler específico para los Select
|
||||
const handleSelectChange = (e: SelectChangeEvent<any>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
if (localErrors[name]) {
|
||||
setLocalErrors(prev => ({ ...prev, [name]: null }));
|
||||
}
|
||||
if (errorMessage) clearErrorMessage();
|
||||
};
|
||||
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
clearErrorMessage();
|
||||
if (!validate()) return;
|
||||
|
||||
setLoading(true);
|
||||
let success = false;
|
||||
try {
|
||||
const dataToSubmit = formData as CreateSuscriptorDto | UpdateSuscriptorDto;
|
||||
await onSubmit(dataToSubmit, initialData?.idSuscriptor);
|
||||
success = true;
|
||||
} catch (error) {
|
||||
success = false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<Box sx={modalStyle}>
|
||||
<Typography variant="h6" component="h2" gutterBottom>
|
||||
{isEditing ? 'Editar Suscriptor' : 'Nuevo Suscriptor'}
|
||||
</Typography>
|
||||
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 1 }}>
|
||||
<TextField name="nombreCompleto" label="Nombre Completo" value={formData.nombreCompleto || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.nombreCompleto} helperText={localErrors.nombreCompleto} disabled={loading} autoFocus />
|
||||
<TextField name="direccion" label="Dirección" value={formData.direccion || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.direccion} helperText={localErrors.direccion} disabled={loading} />
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<TextField name="email" label="Email" type="email" value={formData.email || ''} onChange={handleInputChange} fullWidth margin="dense" sx={{ flex: 1 }} error={!!localErrors.email} helperText={localErrors.email} disabled={loading} />
|
||||
<TextField name="telefono" label="Teléfono" value={formData.telefono || ''} onChange={handleInputChange} fullWidth margin="dense" sx={{ flex: 1 }} disabled={loading} />
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<FormControl margin="dense" sx={{ minWidth: 120 }}>
|
||||
<InputLabel id="tipo-doc-label">Tipo</InputLabel>
|
||||
{/* 3. Aplicar el nuevo handler a los Selects */}
|
||||
<Select labelId="tipo-doc-label" name="tipoDocumento" value={formData.tipoDocumento || 'DNI'} onChange={handleSelectChange} label="Tipo" disabled={loading}>
|
||||
<MenuItem value="DNI">DNI</MenuItem>
|
||||
<MenuItem value="CUIT">CUIT</MenuItem>
|
||||
<MenuItem value="CUIL">CUIL</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField name="nroDocumento" label="Nro Documento" value={formData.nroDocumento || ''} onChange={handleInputChange} required fullWidth margin="dense" sx={{ flex: 2 }} error={!!localErrors.nroDocumento} helperText={localErrors.nroDocumento} disabled={loading} />
|
||||
</Box>
|
||||
<FormControl fullWidth margin="dense" error={!!localErrors.idFormaPagoPreferida}>
|
||||
<InputLabel id="forma-pago-label" required>Forma de Pago</InputLabel>
|
||||
{/* 3. Aplicar el nuevo handler a los Selects */}
|
||||
<Select labelId="forma-pago-label" name="idFormaPagoPreferida" value={formData.idFormaPagoPreferida || ''} onChange={handleSelectChange} label="Forma de Pago" disabled={loading || loadingFormasPago}>
|
||||
{formasDePago.map(fp => <MenuItem key={fp.idFormaPago} value={fp.idFormaPago}>{fp.nombre}</MenuItem>)}
|
||||
</Select>
|
||||
{localErrors.idFormaPagoPreferida && <Typography color="error" variant="caption">{localErrors.idFormaPagoPreferida}</Typography>}
|
||||
</FormControl>
|
||||
{CBURequerido && (
|
||||
<TextField name="cbu" label="CBU" value={formData.cbu || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.cbu} helperText={localErrors.cbu} disabled={loading} inputProps={{ maxLength: 22 }} />
|
||||
)}
|
||||
<TextField name="observaciones" label="Observaciones" value={formData.observaciones || ''} onChange={handleInputChange} multiline rows={2} fullWidth margin="dense" disabled={loading} />
|
||||
|
||||
{errorMessage && <Alert severity="error" sx={{ mt: 2, width: '100%' }}>{errorMessage}</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 || loadingFormasPago}>
|
||||
{loading ? <CircularProgress size={24} /> : (isEditing ? 'Guardar Cambios' : 'Crear Suscriptor')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SuscriptorFormModal;
|
||||
@@ -31,6 +31,7 @@ const getTipoAlertaLabel = (tipoAlerta: string): string => {
|
||||
const allAppModules = [
|
||||
{ label: 'Inicio', path: '/', requiredPermission: null }, // Inicio siempre visible
|
||||
{ label: 'Distribución', path: '/distribucion', requiredPermission: 'SS001' },
|
||||
{ label: 'Suscripciones', path: '/suscripciones', requiredPermission: 'SS007' },
|
||||
{ label: 'Contables', path: '/contables', requiredPermission: 'SS002' },
|
||||
{ label: 'Impresión', path: '/impresion', requiredPermission: 'SS003' },
|
||||
{ label: 'Reportes', path: '/reportes', requiredPermission: 'SS004' },
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface CreateSuscripcionDto {
|
||||
idSuscriptor: number;
|
||||
idPublicacion: number;
|
||||
fechaInicio: string; // "yyyy-MM-dd"
|
||||
fechaFin?: string | null;
|
||||
estado: 'Activa' | 'Pausada' | 'Cancelada';
|
||||
diasEntrega: string[]; // ["L", "M", "X"]
|
||||
observaciones?: string | null;
|
||||
}
|
||||
11
Frontend/src/models/dtos/Suscripciones/SuscripcionDto.ts
Normal file
11
Frontend/src/models/dtos/Suscripciones/SuscripcionDto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export interface SuscripcionDto {
|
||||
idSuscripcion: number;
|
||||
idSuscriptor: number;
|
||||
idPublicacion: number;
|
||||
nombrePublicacion: string;
|
||||
fechaInicio: string; // "yyyy-MM-dd"
|
||||
fechaFin?: string | null;
|
||||
estado: 'Activa' | 'Pausada' | 'Cancelada';
|
||||
diasEntrega: string; // "L,M,X,J,V,S,D"
|
||||
observaciones?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface UpdateSuscripcionDto {
|
||||
fechaInicio: string; // "yyyy-MM-dd"
|
||||
fechaFin?: string | null;
|
||||
estado: 'Activa' | 'Pausada' | 'Cancelada';
|
||||
diasEntrega: string[]; // ["L", "M", "X"]
|
||||
observaciones?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Box, Typography, Button, Paper, IconButton, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, CircularProgress, Alert, Chip, Tooltip } from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import suscripcionService from '../../services/Suscripciones/suscripcionService';
|
||||
import suscriptorService from '../../services/Suscripciones/suscriptorService';
|
||||
import type { SuscripcionDto } from '../../models/dtos/Suscripciones/SuscripcionDto';
|
||||
import type { SuscriptorDto } from '../../models/dtos/Suscripciones/SuscriptorDto';
|
||||
import SuscripcionFormModal from '../../components/Modals/Suscripciones/SuscripcionFormModal';
|
||||
import { usePermissions } from '../../hooks/usePermissions';
|
||||
import axios from 'axios';
|
||||
import type { CreateSuscripcionDto } from '../../models/dtos/Suscripciones/CreateSuscripcionDto';
|
||||
import type { UpdateSuscripcionDto } from '../../models/dtos/Suscripciones/UpdateSuscripcionDto';
|
||||
|
||||
const GestionarSuscripcionesSuscriptorPage: React.FC = () => {
|
||||
const { idSuscriptor: idSuscriptorStr } = useParams<{ idSuscriptor: string }>();
|
||||
const navigate = useNavigate();
|
||||
const idSuscriptor = Number(idSuscriptorStr);
|
||||
|
||||
const [suscriptor, setSuscriptor] = useState<SuscriptorDto | null>(null);
|
||||
const [suscripciones, setSuscripciones] = useState<SuscripcionDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingSuscripcion, setEditingSuscripcion] = useState<SuscripcionDto | null>(null);
|
||||
const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const { tienePermiso, isSuperAdmin } = usePermissions();
|
||||
const puedeVer = isSuperAdmin || tienePermiso("SU001");
|
||||
const puedeGestionar = isSuperAdmin || tienePermiso("SU005");
|
||||
|
||||
const cargarDatos = useCallback(async () => {
|
||||
if (isNaN(idSuscriptor)) {
|
||||
setError("ID de Suscriptor inválido."); setLoading(false); return;
|
||||
}
|
||||
if (!puedeVer) {
|
||||
setError("No tiene permiso para ver esta sección."); setLoading(false); return;
|
||||
}
|
||||
setLoading(true); setError(null); setApiErrorMessage(null);
|
||||
try {
|
||||
const [suscriptorData, suscripcionesData] = await Promise.all([
|
||||
suscriptorService.getSuscriptorById(idSuscriptor),
|
||||
suscripcionService.getSuscripcionesPorSuscriptor(idSuscriptor)
|
||||
]);
|
||||
setSuscriptor(suscriptorData);
|
||||
setSuscripciones(suscripcionesData);
|
||||
} catch (err) {
|
||||
setError('Error al cargar los datos del suscriptor y sus suscripciones.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [idSuscriptor, puedeVer]);
|
||||
|
||||
useEffect(() => { cargarDatos(); }, [cargarDatos]);
|
||||
|
||||
const handleOpenModal = (suscripcion?: SuscripcionDto) => {
|
||||
setEditingSuscripcion(suscripcion || null);
|
||||
setApiErrorMessage(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setModalOpen(false);
|
||||
setEditingSuscripcion(null);
|
||||
};
|
||||
|
||||
const handleSubmitModal = async (data: CreateSuscripcionDto | UpdateSuscripcionDto, id?: number) => {
|
||||
setApiErrorMessage(null);
|
||||
try {
|
||||
if(id && editingSuscripcion) {
|
||||
await suscripcionService.updateSuscripcion(id, data as UpdateSuscripcionDto);
|
||||
} else {
|
||||
await suscripcionService.createSuscripcion(data as CreateSuscripcionDto);
|
||||
}
|
||||
cargarDatos();
|
||||
} catch (err: any) {
|
||||
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al guardar la suscripción.';
|
||||
setApiErrorMessage(message);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString?: string | null) => {
|
||||
if (!dateString) return 'Indefinido';
|
||||
// Asume que la fecha viene como "yyyy-MM-dd"
|
||||
const parts = dateString.split('-');
|
||||
return `${parts[2]}/${parts[1]}/${parts[0]}`;
|
||||
};
|
||||
|
||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
||||
if (error) return <Alert severity="error" sx={{m: 2}}>{error}</Alert>;
|
||||
if (!puedeVer) return <Alert severity="error" sx={{m: 2}}>Acceso Denegado.</Alert>
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/suscripciones/suscriptores')} sx={{ mb: 2 }}>
|
||||
Volver a Suscriptores
|
||||
</Button>
|
||||
<Typography variant="h4" gutterBottom>Suscripciones de: {suscriptor?.nombreCompleto || 'Cargando...'}</Typography>
|
||||
<Typography variant="subtitle1" color="text.secondary" gutterBottom>
|
||||
Documento: {suscriptor?.tipoDocumento} {suscriptor?.nroDocumento} | Dirección: {suscriptor?.direccion}
|
||||
</Typography>
|
||||
|
||||
{puedeGestionar && <Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()} sx={{ my: 2 }}>Nueva Suscripción</Button>}
|
||||
|
||||
{apiErrorMessage && <Alert severity="error" sx={{my: 2}}>{apiErrorMessage}</Alert>}
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{fontWeight: 'bold'}}>Publicación</TableCell>
|
||||
<TableCell sx={{fontWeight: 'bold'}}>Estado</TableCell>
|
||||
<TableCell sx={{fontWeight: 'bold'}}>Días Entrega</TableCell>
|
||||
<TableCell sx={{fontWeight: 'bold'}}>Inicio</TableCell>
|
||||
<TableCell sx={{fontWeight: 'bold'}}>Fin</TableCell>
|
||||
<TableCell sx={{fontWeight: 'bold'}}>Observaciones</TableCell>
|
||||
<TableCell align="right" sx={{fontWeight: 'bold'}}>Acciones</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{suscripciones.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={7} align="center">Este cliente no tiene suscripciones.</TableCell></TableRow>
|
||||
) : (
|
||||
suscripciones.map((s) => (
|
||||
<TableRow key={s.idSuscripcion} hover>
|
||||
<TableCell>{s.nombrePublicacion}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={s.estado}
|
||||
color={s.estado === 'Activa' ? 'success' : s.estado === 'Pausada' ? 'warning' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{s.diasEntrega.split(',').join(', ')}</TableCell>
|
||||
<TableCell>{formatDate(s.fechaInicio)}</TableCell>
|
||||
<TableCell>{formatDate(s.fechaFin)}</TableCell>
|
||||
<TableCell>{s.observaciones || '-'}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Editar Suscripción">
|
||||
<span>
|
||||
<IconButton onClick={() => handleOpenModal(s)} disabled={!puedeGestionar}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{idSuscriptor &&
|
||||
<SuscripcionFormModal
|
||||
open={modalOpen}
|
||||
onClose={handleCloseModal}
|
||||
onSubmit={handleSubmitModal}
|
||||
idSuscriptor={idSuscriptor}
|
||||
initialData={editingSuscripcion}
|
||||
errorMessage={apiErrorMessage}
|
||||
clearErrorMessage={() => setApiErrorMessage(null)}
|
||||
/>
|
||||
}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default GestionarSuscripcionesSuscriptorPage;
|
||||
203
Frontend/src/pages/Suscripciones/GestionarSuscriptoresPage.tsx
Normal file
203
Frontend/src/pages/Suscripciones/GestionarSuscriptoresPage.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
// Archivo: Frontend/src/pages/Suscripciones/GestionarSuscriptoresPage.tsx
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Box, Typography, TextField, Button, Paper, IconButton, Menu, MenuItem, Switch, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination, CircularProgress, Alert, Chip, ListItemIcon, ListItemText, FormControlLabel } from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import ToggleOnIcon from '@mui/icons-material/ToggleOn';
|
||||
import ToggleOffIcon from '@mui/icons-material/ToggleOff';
|
||||
import suscriptorService from '../../services/Suscripciones/suscriptorService';
|
||||
import type { SuscriptorDto } from '../../models/dtos/Suscripciones/SuscriptorDto';
|
||||
import type { CreateSuscriptorDto } from '../../models/dtos/Suscripciones/CreateSuscriptorDto';
|
||||
import type { UpdateSuscriptorDto } from '../../models/dtos/Suscripciones/UpdateSuscriptorDto';
|
||||
import SuscriptorFormModal from '../../components/Modals/Suscripciones/SuscriptorFormModal';
|
||||
import { usePermissions } from '../../hooks/usePermissions';
|
||||
import axios from 'axios';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ArticleIcon from '@mui/icons-material/Article';
|
||||
|
||||
const GestionarSuscriptoresPage: React.FC = () => {
|
||||
const [suscriptores, setSuscriptores] = useState<SuscriptorDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filtroNombre, setFiltroNombre] = useState('');
|
||||
const [filtroNroDoc, setFiltroNroDoc] = useState('');
|
||||
const [filtroSoloActivos, setFiltroSoloActivos] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingSuscriptor, setEditingSuscriptor] = useState<SuscriptorDto | null>(null);
|
||||
const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(15);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [selectedRow, setSelectedRow] = useState<SuscriptorDto | null>(null);
|
||||
|
||||
const { tienePermiso, isSuperAdmin } = usePermissions();
|
||||
|
||||
const puedeVer = isSuperAdmin || tienePermiso("SU001");
|
||||
const puedeCrear = isSuperAdmin || tienePermiso("SU002");
|
||||
const puedeModificar = isSuperAdmin || tienePermiso("SU003");
|
||||
const puedeActivarDesactivar = isSuperAdmin || tienePermiso("SU004");
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const cargarSuscriptores = useCallback(async () => {
|
||||
if (!puedeVer) {
|
||||
setError("No tiene permiso para ver esta sección.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setApiErrorMessage(null);
|
||||
try {
|
||||
const data = await suscriptorService.getAllSuscriptores(filtroNombre, filtroNroDoc, filtroSoloActivos);
|
||||
setSuscriptores(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('Error al cargar los suscriptores.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filtroNombre, filtroNroDoc, filtroSoloActivos, puedeVer]);
|
||||
|
||||
useEffect(() => {
|
||||
cargarSuscriptores();
|
||||
}, [cargarSuscriptores]);
|
||||
|
||||
const handleOpenModal = (suscriptor?: SuscriptorDto) => {
|
||||
setEditingSuscriptor(suscriptor || null);
|
||||
setApiErrorMessage(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setModalOpen(false);
|
||||
setEditingSuscriptor(null);
|
||||
};
|
||||
|
||||
const handleSubmitModal = async (data: CreateSuscriptorDto | UpdateSuscriptorDto, id?: number) => {
|
||||
setApiErrorMessage(null);
|
||||
try {
|
||||
if (id && editingSuscriptor) {
|
||||
await suscriptorService.updateSuscriptor(id, data as UpdateSuscriptorDto);
|
||||
} else {
|
||||
await suscriptorService.createSuscriptor(data as CreateSuscriptorDto);
|
||||
}
|
||||
cargarSuscriptores();
|
||||
} catch (err: any) {
|
||||
const message = axios.isAxiosError(err) && err.response?.data?.message
|
||||
? err.response.data.message
|
||||
: 'Error al guardar el suscriptor.';
|
||||
setApiErrorMessage(message);
|
||||
throw err; // Re-lanzar para que el modal sepa que falló
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleActivo = async (suscriptor: SuscriptorDto) => {
|
||||
const action = suscriptor.activo ? 'desactivar' : 'activar';
|
||||
if (window.confirm(`¿Está seguro de que desea ${action} a ${suscriptor.nombreCompleto}?`)) {
|
||||
setApiErrorMessage(null);
|
||||
try {
|
||||
if (suscriptor.activo) {
|
||||
await suscriptorService.deactivateSuscriptor(suscriptor.idSuscriptor);
|
||||
} else {
|
||||
await suscriptorService.activateSuscriptor(suscriptor.idSuscriptor);
|
||||
}
|
||||
cargarSuscriptores();
|
||||
} catch (err: any) {
|
||||
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : `Error al ${action} el suscriptor.`;
|
||||
setApiErrorMessage(message);
|
||||
}
|
||||
}
|
||||
handleMenuClose();
|
||||
};
|
||||
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, suscriptor: SuscriptorDto) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
setSelectedRow(suscriptor);
|
||||
};
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null);
|
||||
setSelectedRow(null);
|
||||
};
|
||||
|
||||
const handleNavigateToSuscripciones = (idSuscriptor: number) => {
|
||||
navigate(`/suscripciones/suscriptor/${idSuscriptor}/suscripciones`);
|
||||
handleMenuClose();
|
||||
};
|
||||
|
||||
const handleChangePage = (_event: unknown, newPage: number) => setPage(newPage);
|
||||
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setRowsPerPage(parseInt(event.target.value, 10));
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const displayData = suscriptores.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage);
|
||||
|
||||
if (!puedeVer) {
|
||||
return <Box sx={{ p: 2 }}><Alert severity="error">{error || "No tiene permiso para ver esta sección."}</Alert></Box>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="h5" gutterBottom>Gestionar Suscriptores</Typography>
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap' }}>
|
||||
<TextField label="Filtrar por Nombre" variant="outlined" size="small" value={filtroNombre} onChange={(e) => setFiltroNombre(e.target.value)} sx={{ flexGrow: 1 }} />
|
||||
<TextField label="Filtrar por Nro. Doc" variant="outlined" size="small" value={filtroNroDoc} onChange={(e) => setFiltroNroDoc(e.target.value)} sx={{ flexGrow: 1 }} />
|
||||
<FormControlLabel control={<Switch checked={filtroSoloActivos} onChange={(e) => setFiltroSoloActivos(e.target.checked)} />} label="Solo Activos" />
|
||||
</Box>
|
||||
{puedeCrear && <Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()}>Nuevo Suscriptor</Button>}
|
||||
</Paper>
|
||||
|
||||
{loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>}
|
||||
{error && !loading && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>}
|
||||
{apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead><TableRow>
|
||||
<TableCell>Nombre</TableCell><TableCell>Documento</TableCell><TableCell>Dirección</TableCell>
|
||||
<TableCell>Forma de Pago</TableCell><TableCell>Estado</TableCell><TableCell align="right">Acciones</TableCell>
|
||||
</TableRow></TableHead>
|
||||
<TableBody>
|
||||
{displayData.map((s) => (
|
||||
<TableRow key={s.idSuscriptor} hover>
|
||||
<TableCell>{s.nombreCompleto}</TableCell>
|
||||
<TableCell>{s.tipoDocumento} {s.nroDocumento}</TableCell>
|
||||
<TableCell>{s.direccion}</TableCell>
|
||||
<TableCell>{s.nombreFormaPagoPreferida}</TableCell>
|
||||
<TableCell><Chip label={s.activo ? 'Activo' : 'Inactivo'} color={s.activo ? 'success' : 'default'} size="small" /></TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton onClick={(e) => handleMenuOpen(e, s)}><MoreVertIcon /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TablePagination rowsPerPageOptions={[15, 25, 50]} component="div" count={suscriptores.length} rowsPerPage={rowsPerPage} page={page} onPageChange={handleChangePage} onRowsPerPageChange={handleChangeRowsPerPage} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>
|
||||
{selectedRow && puedeModificar && <MenuItem onClick={() => { handleOpenModal(selectedRow); handleMenuClose(); }}><ListItemIcon><EditIcon fontSize="small" /></ListItemIcon><ListItemText>Editar</ListItemText></MenuItem>}
|
||||
{selectedRow && <MenuItem onClick={() => handleNavigateToSuscripciones(selectedRow.idSuscriptor)}><ListItemIcon><ArticleIcon fontSize="small" /></ListItemIcon><ListItemText>Ver Suscripciones</ListItemText></MenuItem>}
|
||||
{selectedRow && puedeActivarDesactivar && (
|
||||
<MenuItem onClick={() => handleToggleActivo(selectedRow)}>
|
||||
{selectedRow.activo ? <ListItemIcon><ToggleOffIcon fontSize="small" /></ListItemIcon> : <ListItemIcon><ToggleOnIcon fontSize="small" /></ListItemIcon>}
|
||||
<ListItemText>{selectedRow.activo ? 'Desactivar' : 'Activar'}</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
|
||||
<SuscriptorFormModal open={modalOpen} onClose={handleCloseModal} onSubmit={handleSubmitModal} initialData={editingSuscriptor} errorMessage={apiErrorMessage} clearErrorMessage={() => setApiErrorMessage(null)} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default GestionarSuscriptoresPage;
|
||||
82
Frontend/src/pages/Suscripciones/SuscripcionesIndexPage.tsx
Normal file
82
Frontend/src/pages/Suscripciones/SuscripcionesIndexPage.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, Tabs, Tab, Paper, Typography } from '@mui/material';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { usePermissions } from '../../hooks/usePermissions';
|
||||
|
||||
// Define las pestañas del módulo. Ajusta los permisos según sea necesario.
|
||||
const suscripcionesSubModules = [
|
||||
{ label: 'Suscriptores', path: 'suscriptores', requiredPermission: 'SU001' },
|
||||
// { label: 'Facturación', path: 'facturacion', requiredPermission: 'SU005' },
|
||||
// { label: 'Promociones', path: 'promociones', requiredPermission: 'SU006' },
|
||||
];
|
||||
|
||||
const SuscripcionesIndexPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { tienePermiso, isSuperAdmin } = usePermissions();
|
||||
const [selectedSubTab, setSelectedSubTab] = useState<number | false>(false);
|
||||
|
||||
// Filtra los sub-módulos a los que el usuario tiene acceso
|
||||
const accessibleSubModules = suscripcionesSubModules.filter(
|
||||
(subModule) => isSuperAdmin || tienePermiso(subModule.requiredPermission)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (accessibleSubModules.length === 0) {
|
||||
// Si no tiene acceso a ningún submódulo, no hacemos nada.
|
||||
// El enrutador principal debería manejar esto.
|
||||
return;
|
||||
}
|
||||
|
||||
const currentBasePath = '/suscripciones';
|
||||
const subPath = location.pathname.startsWith(`${currentBasePath}/`)
|
||||
? location.pathname.substring(currentBasePath.length + 1)
|
||||
: (location.pathname === currentBasePath ? accessibleSubModules[0]?.path : undefined);
|
||||
|
||||
const activeTabIndex = accessibleSubModules.findIndex(
|
||||
(subModule) => subModule.path === subPath
|
||||
);
|
||||
|
||||
if (activeTabIndex !== -1) {
|
||||
setSelectedSubTab(activeTabIndex);
|
||||
} else if (location.pathname === currentBasePath) {
|
||||
navigate(accessibleSubModules[0].path, { replace: true });
|
||||
} else {
|
||||
setSelectedSubTab(false);
|
||||
}
|
||||
}, [location.pathname, navigate, accessibleSubModules]);
|
||||
|
||||
const handleSubTabChange = (_event: React.SyntheticEvent, newValue: number) => {
|
||||
navigate(accessibleSubModules[newValue].path);
|
||||
};
|
||||
|
||||
if (accessibleSubModules.length === 0) {
|
||||
return <Typography sx={{ p: 2 }}>No tiene permisos para acceder a este módulo.</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>Módulo de Suscripciones</Typography>
|
||||
<Paper square elevation={1}>
|
||||
<Tabs
|
||||
value={selectedSubTab}
|
||||
onChange={handleSubTabChange}
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
aria-label="sub-módulos de suscripciones"
|
||||
>
|
||||
{accessibleSubModules.map((subModule) => (
|
||||
<Tab key={subModule.path} label={subModule.label} />
|
||||
))}
|
||||
</Tabs>
|
||||
</Paper>
|
||||
<Box sx={{ pt: 2 }}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SuscripcionesIndexPage;
|
||||
@@ -76,6 +76,11 @@ import GestionarNovedadesCanillaPage from '../pages/Distribucion/GestionarNoveda
|
||||
import ReporteNovedadesCanillasPage from '../pages/Reportes/ReporteNovedadesCanillasPage';
|
||||
import ReporteListadoDistMensualPage from '../pages/Reportes/ReporteListadoDistMensualPage';
|
||||
|
||||
// Suscripciones
|
||||
import SuscripcionesIndexPage from '../pages/Suscripciones/SuscripcionesIndexPage';
|
||||
import GestionarSuscriptoresPage from '../pages/Suscripciones/GestionarSuscriptoresPage';
|
||||
import GestionarSuscripcionesSuscriptorPage from '../pages/Suscripciones/GestionarSuscripcionesSuscriptorPage';
|
||||
|
||||
// Anonalías
|
||||
import AlertasPage from '../pages/Anomalia/AlertasPage';
|
||||
|
||||
@@ -178,6 +183,29 @@ const AppRoutes = () => {
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* --- Módulo de Suscripciones --- */}
|
||||
<Route
|
||||
path="/suscripciones"
|
||||
element={
|
||||
<SectionProtectedRoute requiredPermission="SS007" sectionName="Suscripciones">
|
||||
<SuscripcionesIndexPage />
|
||||
</SectionProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="suscriptores" replace />} />
|
||||
<Route path="suscriptores" element={
|
||||
<SectionProtectedRoute requiredPermission="SU001" sectionName="Suscriptores">
|
||||
<GestionarSuscriptoresPage />
|
||||
</SectionProtectedRoute>
|
||||
} />
|
||||
<Route path="suscriptor/:idSuscriptor" element={
|
||||
<SectionProtectedRoute requiredPermission="SU001" sectionName="Suscripciones del Cliente">
|
||||
<GestionarSuscripcionesSuscriptorPage />
|
||||
</SectionProtectedRoute>
|
||||
} />
|
||||
{/* Aquí irán las otras sub-rutas como 'facturacion', etc. */}
|
||||
</Route>
|
||||
|
||||
{/* Módulo Contable (anidado) */}
|
||||
<Route
|
||||
path="contables"
|
||||
|
||||
33
Frontend/src/services/Suscripciones/suscripcionService.ts
Normal file
33
Frontend/src/services/Suscripciones/suscripcionService.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import apiClient from '../apiClient';
|
||||
import type { SuscripcionDto } from '../../models/dtos/Suscripciones/SuscripcionDto';
|
||||
import type { CreateSuscripcionDto } from '../../models/dtos/Suscripciones/CreateSuscripcionDto';
|
||||
import type { UpdateSuscripcionDto } from '../../models/dtos/Suscripciones/UpdateSuscripcionDto';
|
||||
|
||||
const API_URL_BASE = '/suscripciones';
|
||||
const API_URL_BY_SUSCRIPTOR = '/suscriptores'; // Para la ruta anidada
|
||||
|
||||
const getSuscripcionesPorSuscriptor = async (idSuscriptor: number): Promise<SuscripcionDto[]> => {
|
||||
const response = await apiClient.get<SuscripcionDto[]>(`${API_URL_BY_SUSCRIPTOR}/${idSuscriptor}/suscripciones`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getSuscripcionById = async (id: number): Promise<SuscripcionDto> => {
|
||||
const response = await apiClient.get<SuscripcionDto>(`${API_URL_BASE}/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const createSuscripcion = async (data: CreateSuscripcionDto): Promise<SuscripcionDto> => {
|
||||
const response = await apiClient.post<SuscripcionDto>(API_URL_BASE, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const updateSuscripcion = async (id: number, data: UpdateSuscripcionDto): Promise<void> => {
|
||||
await apiClient.put(`${API_URL_BASE}/${id}`, data);
|
||||
};
|
||||
|
||||
export default {
|
||||
getSuscripcionesPorSuscriptor,
|
||||
getSuscripcionById,
|
||||
createSuscripcion,
|
||||
updateSuscripcion,
|
||||
};
|
||||
Reference in New Issue
Block a user