feat(contables): cierre mensual de cuenta corriente de distribuidor

Permite congelar el saldo de un distribuidor por empresa a una fecha de
corte y bloquear modificaciones retroactivas sobre el período cerrado.
El saldo se calcula sumando movimientos en rango (sin tocar cue_Saldos).
Incluye reapertura controlada exclusivamente por SuperAdmin, reporte con
saldo inicial, atajo "Desde último cierre", y auditoría del ciclo de
vida _H. Permisos CC001/CC002/CC003. Middleware global mapea bloqueos
por período cerrado a HTTP 409.
This commit is contained in:
2026-05-07 12:03:26 -03:00
parent 7e274ef114
commit 24eaf18fd9
62 changed files with 2813 additions and 162 deletions

View File

@@ -36,6 +36,7 @@ const AjusteSaldoModal: React.FC<AjusteSaldoModalProps> = ({
}) => {
const [montoAjuste, setMontoAjuste] = useState<string>('');
const [justificacion, setJustificacion] = useState('');
const [fechaOperacion, setFechaOperacion] = useState<string>(new Date().toISOString().split('T')[0]);
const [loading, setLoading] = useState(false);
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
@@ -43,10 +44,10 @@ const AjusteSaldoModal: React.FC<AjusteSaldoModalProps> = ({
if (open) {
setMontoAjuste('');
setJustificacion('');
setFechaOperacion(new Date().toISOString().split('T')[0]);
setLocalErrors({});
clearErrorMessage();
}
}, [open, clearErrorMessage]);
}, [open]);
const validate = (): boolean => {
const errors: { [key: string]: string | null } = {};
@@ -65,11 +66,16 @@ const AjusteSaldoModal: React.FC<AjusteSaldoModalProps> = ({
} else if (justificacion.trim().length < 5 || justificacion.trim().length > 250) {
errors.justificacion = 'La justificación debe tener entre 5 y 250 caracteres.';
}
if (!fechaOperacion) {
errors.fechaOperacion = 'La fecha de operación es obligatoria.';
}
setLocalErrors(errors);
return Object.keys(errors).length === 0;
};
const handleInputChange = (fieldName: 'montoAjuste' | 'justificacion') => {
const handleInputChange = (fieldName: 'montoAjuste' | 'justificacion' | 'fechaOperacion') => {
if (localErrors[fieldName]) setLocalErrors(prev => ({ ...prev, [fieldName]: null }));
if (errorMessage) clearErrorMessage();
};
@@ -87,6 +93,7 @@ const AjusteSaldoModal: React.FC<AjusteSaldoModalProps> = ({
idEmpresa: saldoParaAjustar.idEmpresa,
montoAjuste: parseFloat(montoAjuste),
justificacion,
fechaOperacion,
};
await onSubmit(dataToSubmit);
onClose(); // Cerrar en éxito (el padre recargará)
@@ -117,6 +124,19 @@ const AjusteSaldoModal: React.FC<AjusteSaldoModalProps> = ({
</Typography>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
<TextField
label="Fecha de Operación"
type="date"
fullWidth
required
value={fechaOperacion}
onChange={(e) => { setFechaOperacion(e.target.value); handleInputChange('fechaOperacion'); }}
margin="normal"
error={!!localErrors.fechaOperacion}
helperText={localErrors.fechaOperacion || ''}
disabled={loading}
InputLabelProps={{ shrink: true }}
/>
<TextField
label="Monto de Ajuste (+/-)"
type="number"

View File

@@ -114,9 +114,8 @@ const NotaCreditoDebitoFormModal: React.FC<NotaCreditoDebitoFormModalProps> = ({
setObservaciones(initialData?.observaciones || '');
setIdEmpresa(initialData?.idEmpresa || '');
setLocalErrors({});
clearErrorMessage();
}
}, [open, initialData, clearErrorMessage, fetchEmpresas, fetchDestinatarios]);
}, [open, initialData, fetchEmpresas, fetchDestinatarios]);
useEffect(() => {
if(open && !isEditing) { // Solo cambiar destinatarios si es creación y cambia el tipo de Destino

View File

@@ -0,0 +1,235 @@
import React, { useState, useEffect } from 'react';
import {
Modal, Box, Typography, TextField, Button, CircularProgress, Alert,
FormControl, InputLabel, Select, MenuItem
} from '@mui/material';
import type { CrearCierreDto } from '../../../models/dtos/Contables/CrearCierreDto';
import type { CierreCuentaCorrienteDto } from '../../../models/dtos/Contables/CierreCuentaCorrienteDto';
import type { DistribuidorDropdownDto } from '../../../models/dtos/Distribucion/DistribuidorDropdownDto';
import type { EmpresaDropdownDto } from '../../../models/dtos/Distribucion/EmpresaDropdownDto';
import distribuidorService from '../../../services/Distribucion/distribuidorService';
import empresaService from '../../../services/Distribucion/empresaService';
const modalStyle = {
position: 'absolute' as const,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: { xs: '90%', sm: 600 },
bgcolor: 'background.paper',
border: '2px solid #000',
boxShadow: 24,
p: 4,
maxHeight: '90vh',
overflowY: 'auto'
};
interface NuevoCierreModalProps {
open: boolean;
onClose: () => void;
onSubmit: (data: CrearCierreDto) => Promise<CierreCuentaCorrienteDto>;
initialIdDistribuidor?: number | null;
initialIdEmpresa?: number | null;
errorMessage?: string | null;
clearErrorMessage: () => void;
}
const todayIso = () => new Date().toISOString().split('T')[0];
const NuevoCierreModal: React.FC<NuevoCierreModalProps> = ({
open,
onClose,
onSubmit,
initialIdDistribuidor,
initialIdEmpresa,
errorMessage,
clearErrorMessage
}) => {
const [idDistribuidor, setIdDistribuidor] = useState<number | string>('');
const [idEmpresa, setIdEmpresa] = useState<number | string>('');
const [fechaCorte, setFechaCorte] = useState<string>(todayIso());
const [justificacion, setJustificacion] = useState<string>('');
const [distribuidores, setDistribuidores] = useState<DistribuidorDropdownDto[]>([]);
const [empresas, setEmpresas] = useState<EmpresaDropdownDto[]>([]);
const [loading, setLoading] = useState(false);
const [loadingDropdowns, setLoadingDropdowns] = useState(false);
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
useEffect(() => {
const fetchDropdownData = async () => {
setLoadingDropdowns(true);
try {
const [distData, empData] = await Promise.all([
distribuidorService.getAllDistribuidoresDropdown(),
empresaService.getEmpresasDropdown()
]);
setDistribuidores(distData);
setEmpresas(empData);
} catch (err) {
console.error('Error al cargar dropdowns', err);
setLocalErrors(prev => ({ ...prev, dropdowns: 'Error al cargar distribuidores o empresas.' }));
} finally {
setLoadingDropdowns(false);
}
};
if (open) {
fetchDropdownData();
setIdDistribuidor(initialIdDistribuidor ?? '');
setIdEmpresa(initialIdEmpresa ?? '');
setFechaCorte(todayIso());
setJustificacion('');
setLocalErrors({});
clearErrorMessage();
}
}, [open, initialIdDistribuidor, initialIdEmpresa, clearErrorMessage]);
const validate = (): boolean => {
const errors: { [key: string]: string | null } = {};
if (!idDistribuidor) errors.idDistribuidor = 'Seleccione un distribuidor.';
if (!idEmpresa) errors.idEmpresa = 'Seleccione una empresa.';
if (!fechaCorte) {
errors.fechaCorte = 'La fecha de corte es obligatoria.';
} else if (new Date(fechaCorte) > new Date(todayIso())) {
errors.fechaCorte = 'La fecha de corte no puede ser futura.';
}
if (justificacion.length > 500) {
errors.justificacion = 'La justificación no puede superar los 500 caracteres.';
}
setLocalErrors(errors);
return Object.keys(errors).length === 0;
};
const handleInputChange = (fieldName: string) => {
if (localErrors[fieldName]) setLocalErrors(prev => ({ ...prev, [fieldName]: null }));
if (errorMessage) clearErrorMessage();
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
clearErrorMessage();
if (!validate()) return;
const distSeleccionado = distribuidores.find(d => d.idDistribuidor === Number(idDistribuidor));
const empSeleccionada = empresas.find(e => e.idEmpresa === Number(idEmpresa));
const confirmMsg = `Estás por cerrar el período hasta ${fechaCorte} para "${distSeleccionado?.nombre ?? ''}" en "${empSeleccionada?.nombre ?? ''}".\n\n` +
`Después de cerrar no se podrán registrar movimientos, pagos, notas ni ajustes con fecha menor o igual a esta. ¿Continuar?`;
if (!window.confirm(confirmMsg)) return;
setLoading(true);
try {
const dto: CrearCierreDto = {
idDistribuidor: Number(idDistribuidor),
idEmpresa: Number(idEmpresa),
fechaCorte,
justificacion: justificacion.trim() || null
};
await onSubmit(dto);
onClose();
} catch (err) {
console.error('Error al crear cierre:', err);
} finally {
setLoading(false);
}
};
return (
<Modal open={open} onClose={onClose}>
<Box sx={modalStyle}>
<Typography variant="h6" component="h2" gutterBottom>
Nuevo Cierre de Cuenta Corriente
</Typography>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 1 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<FormControl fullWidth margin="dense" error={!!localErrors.idDistribuidor} required>
<InputLabel id="distribuidor-cierre-label">Distribuidor</InputLabel>
<Select
labelId="distribuidor-cierre-label"
label="Distribuidor"
value={idDistribuidor}
onChange={(e) => { setIdDistribuidor(e.target.value as number); handleInputChange('idDistribuidor'); }}
disabled={loading || loadingDropdowns}
>
<MenuItem value="" disabled><em>Seleccione</em></MenuItem>
{distribuidores.map((d) => (
<MenuItem key={d.idDistribuidor} value={d.idDistribuidor}>{d.nombre}</MenuItem>
))}
</Select>
{localErrors.idDistribuidor && <Typography color="error" variant="caption">{localErrors.idDistribuidor}</Typography>}
</FormControl>
<FormControl fullWidth margin="dense" error={!!localErrors.idEmpresa} required>
<InputLabel id="empresa-cierre-label">Empresa</InputLabel>
<Select
labelId="empresa-cierre-label"
label="Empresa"
value={idEmpresa}
onChange={(e) => { setIdEmpresa(e.target.value as number); handleInputChange('idEmpresa'); }}
disabled={loading || loadingDropdowns}
>
<MenuItem value="" disabled><em>Seleccione</em></MenuItem>
{empresas.map((e) => (
<MenuItem key={e.idEmpresa} value={e.idEmpresa}>{e.nombre}</MenuItem>
))}
</Select>
{localErrors.idEmpresa && <Typography color="error" variant="caption">{localErrors.idEmpresa}</Typography>}
</FormControl>
<TextField
label="Fecha de Corte"
type="date"
value={fechaCorte}
required
onChange={(e) => { setFechaCorte(e.target.value); handleInputChange('fechaCorte'); }}
margin="dense"
fullWidth
error={!!localErrors.fechaCorte}
helperText={localErrors.fechaCorte || 'No puede ser una fecha futura.'}
disabled={loading}
InputLabelProps={{ shrink: true }}
inputProps={{ max: todayIso() }}
/>
<Alert severity="info" sx={{ mt: 0.5 }}>
La fecha de corte es inclusive: los movimientos con fecha igual a la seleccionada se
incluyen en el cálculo del saldo de cierre.
</Alert>
<TextField
label="Justificación (opcional)"
value={justificacion}
onChange={(e) => { setJustificacion(e.target.value); handleInputChange('justificacion'); }}
margin="dense"
fullWidth
multiline
rows={3}
disabled={loading}
error={!!localErrors.justificacion}
helperText={localErrors.justificacion || `${justificacion.length}/500 caracteres`}
inputProps={{ maxLength: 500 }}
/>
</Box>
<Alert severity="warning" sx={{ mt: 2 }}>
Una vez creado el cierre, no podrán registrarse, modificarse ni eliminarse pagos, notas, ajustes
o movimientos cuya fecha de operación sea menor o igual a la fecha de corte.
</Alert>
{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} /> : 'Crear Cierre'}
</Button>
</Box>
</Box>
</Box>
</Modal>
);
};
export default NuevoCierreModal;

View File

@@ -95,9 +95,8 @@ const PagoDistribuidorFormModal: React.FC<PagoDistribuidorFormModalProps> = ({
setDetalle(initialData?.detalle || '');
setIdEmpresa(initialData?.idEmpresa || '');
setLocalErrors({});
clearErrorMessage();
}
}, [open, initialData, clearErrorMessage]);
}, [open, initialData]);
const validate = (): boolean => {
const errors: { [key: string]: string | null } = {};

View File

@@ -0,0 +1,140 @@
import React, { useState, useEffect } from 'react';
import {
Modal, Box, Typography, TextField, Button, CircularProgress, Alert
} from '@mui/material';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import type { ReabrirCierreDto } from '../../../models/dtos/Contables/ReabrirCierreDto';
import type { CierreCuentaCorrienteDto } from '../../../models/dtos/Contables/CierreCuentaCorrienteDto';
const modalStyle = {
position: 'absolute' as const,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: { xs: '90%', sm: 560 },
bgcolor: 'background.paper',
border: '2px solid #000',
boxShadow: 24,
p: 4,
maxHeight: '90vh',
overflowY: 'auto'
};
const MIN_LEN = 10;
const MAX_LEN = 500;
interface ReabrirCierreModalProps {
open: boolean;
onClose: () => void;
onSubmit: (idCierre: number, data: ReabrirCierreDto) => Promise<CierreCuentaCorrienteDto>;
cierre: CierreCuentaCorrienteDto | null;
errorMessage?: string | null;
clearErrorMessage: () => void;
}
const ReabrirCierreModal: React.FC<ReabrirCierreModalProps> = ({
open,
onClose,
onSubmit,
cierre,
errorMessage,
clearErrorMessage
}) => {
const [justificacion, setJustificacion] = useState<string>('');
const [loading, setLoading] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
useEffect(() => {
if (open) {
setJustificacion('');
setLocalError(null);
clearErrorMessage();
}
}, [open, clearErrorMessage]);
const justifTrimLen = justificacion.trim().length;
const submitDisabled = loading || justifTrimLen < MIN_LEN || justifTrimLen > MAX_LEN;
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!cierre) return;
if (justifTrimLen < MIN_LEN) {
setLocalError(`La justificación debe tener al menos ${MIN_LEN} caracteres.`);
return;
}
if (justifTrimLen > MAX_LEN) {
setLocalError(`La justificación no puede superar ${MAX_LEN} caracteres.`);
return;
}
setLocalError(null);
clearErrorMessage();
setLoading(true);
try {
await onSubmit(cierre.idCierre, { justificacion: justificacion.trim() });
onClose();
} catch (err) {
console.error('Error al reabrir cierre:', err);
} finally {
setLoading(false);
}
};
return (
<Modal open={open} onClose={onClose}>
<Box sx={modalStyle}>
<Typography variant="h6" component="h2" gutterBottom>
Reabrir Cierre de Cuenta Corriente
</Typography>
<Alert severity="warning" icon={<WarningAmberIcon />} sx={{ mb: 2 }}>
<strong>ATENCIÓN:</strong> estás por reabrir un cierre. Esta acción quedará registrada en auditoría.
Solo se puede reabrir el último cierre vigente. Si hay cierres posteriores, primero hay que reabrirlos a ellos.
</Alert>
{cierre && (
<Box sx={{ mb: 2, p: 1.5, bgcolor: 'grey.100', borderRadius: 1 }}>
<Typography variant="body2"><strong>Distribuidor:</strong> {cierre.nombreDistribuidor}</Typography>
<Typography variant="body2"><strong>Empresa:</strong> {cierre.nombreEmpresa}</Typography>
<Typography variant="body2"><strong>Fecha de Corte:</strong> {cierre.fechaCorte}</Typography>
<Typography variant="body2">
<strong>Saldo del Cierre:</strong>{' '}
{cierre.saldoCierre.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}
</Typography>
</Box>
)}
<Box component="form" onSubmit={handleSubmit}>
<TextField
label="Justificación de la reapertura"
value={justificacion}
onChange={(e) => { setJustificacion(e.target.value); if (localError) setLocalError(null); }}
required
multiline
rows={4}
fullWidth
margin="dense"
error={!!localError}
helperText={
localError ||
`Mínimo ${MIN_LEN} caracteres — ${justifTrimLen}/${MAX_LEN}`
}
inputProps={{ maxLength: MAX_LEN }}
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" color="warning" disabled={submitDisabled}>
{loading ? <CircularProgress size={24} /> : 'Confirmar Reapertura'}
</Button>
</Box>
</Box>
</Box>
</Modal>
);
};
export default ReabrirCierreModal;

View File

@@ -86,9 +86,8 @@ const EntradaSalidaDistFormModal: React.FC<EntradaSalidaDistFormModalProps> = ({
setRemito(initialData?.remito?.toString() || '');
setObservacion(initialData?.observacion || '');
setLocalErrors({});
clearErrorMessage();
}
}, [open, initialData, clearErrorMessage]);
}, [open, initialData]);
const validate = (): boolean => {
const errors: { [key: string]: string | null } = {};

View File

@@ -41,7 +41,8 @@ const getModuloConceptualDelPermiso = (permisoModulo: string): string => {
}
if (moduloLower.includes("cuentas pagos") ||
moduloLower.includes("cuentas notas") ||
moduloLower.includes("cuentas tipos pagos")) {
moduloLower.includes("cuentas tipos pagos") ||
moduloLower.includes("cuentas cierres")) {
return "Contables";
}
if (moduloLower.includes("impresión tiradas") ||

View File

@@ -0,0 +1,24 @@
// Refleja DTO C# Dtos.Auditoria.CierreCuentaCorrienteHistorialDto.
// La tabla _H y este DTO usan PascalCase con underscores; el JSON serializer
// default de ASP.NET Core sólo aplica camelCase a la primera letra,
// preservando los underscores subsiguientes.
export interface CierreCuentaCorrienteHistorialDto {
id_Historial: number;
id_Cierre: number;
id_Distribuidor: number;
id_Empresa: number;
fechaCorte: string; // ISO datetime
fechaCierre: string;
saldoCierre: number;
estado: 'Activo' | 'Anulado';
justificacion?: string | null;
id_Usuario_Cierre: number;
id_Usuario_Anula?: number | null;
fechaAnulacion?: string | null;
justificacion_Anulacion?: string | null;
tipoMod: 'Creacion' | 'Reapertura' | 'Modificacion';
id_Usuario_Mod: number;
nombreUsuarioModifico: string;
fechaMod: string; // ISO datetime
}

View File

@@ -4,4 +4,7 @@ export interface AjusteSaldoRequestDto {
idEmpresa: number;
montoAjuste: number;
justificacion: string;
// Fecha lógica de la operación contable (no la del ajuste físico).
// Se valida contra períodos cerrados del distribuidor+empresa.
fechaOperacion: string; // "yyyy-MM-dd"
}

View File

@@ -0,0 +1,19 @@
export interface CierreCuentaCorrienteDto {
idCierre: number;
idDistribuidor: number;
nombreDistribuidor: string;
idEmpresa: number;
nombreEmpresa: string;
fechaCorte: string; // "yyyy-MM-dd"
fechaCierre: string; // ISO datetime
saldoCierre: number;
estado: 'Activo' | 'Anulado';
justificacion?: string | null;
idUsuarioCierre: number;
nombreUsuarioCierre: string;
idUsuarioAnula?: number | null;
nombreUsuarioAnula?: string | null;
fechaAnulacion?: string | null; // ISO datetime
justificacionAnulacion?: string | null;
esUltimoVigente: boolean;
}

View File

@@ -0,0 +1,6 @@
export interface CrearCierreDto {
idDistribuidor: number;
idEmpresa: number;
fechaCorte: string; // "yyyy-MM-dd"
justificacion?: string | null;
}

View File

@@ -0,0 +1,4 @@
export interface ReabrirCierreDto {
// Min 10, max 500 caracteres (validado en backend con DataAnnotations).
justificacion: string;
}

View File

@@ -0,0 +1,6 @@
export interface UltimoCierreDto {
idCierre: number;
fechaCorte: string; // "yyyy-MM-dd"
saldoCierre: number;
estado: 'Activo' | 'Anulado';
}

View File

@@ -1,14 +1,14 @@
import type { BalanceCuentaDebCredDto } from "./BalanceCuentaDebCredDto";
import type { BalanceCuentaDistDto } from "./BalanceCuentaDistDto";
import type { BalanceCuentaPagosDto } from "./BalanceCuentaPagosDto";
import type { SaldoDto } from "./SaldoDto";
export interface ReporteCuentasDistribuidorResponseDto {
entradasSalidas: BalanceCuentaDistDto[];
debitosCreditos: BalanceCuentaDebCredDto[];
pagos: BalanceCuentaPagosDto[];
saldos: SaldoDto[]; // Aunque SP_BalanceCuentSaldos devuelve una lista, para un distribuidor/empresa debería ser 1 solo.
// Se podría ajustar el DTO o el servicio para devolver solo el primer saldo.
nombreDistribuidor?: string; // Para el título del reporte
nombreEmpresa?: string; // Para el título del reporte
}
// Saldo inicial del período: snapshot del último cierre + movimientos netos hasta fechaDesde-1.
// 0 si no hay cierre previo.
saldoInicial: number;
nombreDistribuidor?: string;
nombreEmpresa?: string;
}

View File

@@ -1,13 +1,12 @@
import type { BalanceCuentaDebCredDto } from "./BalanceCuentaDebCredDto";
import type { BalanceCuentaDistDto } from "./BalanceCuentaDistDto";
import type { BalanceCuentaPagosDto } from "./BalanceCuentaPagosDto";
import type { SaldoDto } from "./SaldoDto";
export interface ReporteCuentasDistribuidorResponseDto {
entradasSalidas: BalanceCuentaDistDto[];
debitosCreditos: BalanceCuentaDebCredDto[];
pagos: BalanceCuentaPagosDto[];
saldos: SaldoDto[];
saldoInicial: number;
nombreDistribuidor?: string;
nombreEmpresa?: string;
}
}

View File

@@ -1,3 +0,0 @@
export interface SaldoDto {
monto: number;
}

View File

@@ -62,6 +62,7 @@ const TIPOS_ENTIDAD_AUDITABLES = [
{ value: "PermisoMaestro", label: "Permisos (Definición) (gral_Permisos_H)" },
{ value: "PermisosPerfiles", label: "Asignación de Permisos a Perfiles (gral_PermisosPerfiles_H)" },
{ value: "CambioParada", label: "Cambios de Parada (dist_CambiosParadasCanillas_H)" },
{ value: "CierreCC", label: "Cierres de Cuenta Corriente (cue_CierresCuentaCorriente_H)" },
].sort((a, b) => a.label.localeCompare(b.label));
const TIPOS_MODIFICACION = [
@@ -653,6 +654,29 @@ const AuditoriaGeneralPage: React.FC = () => {
{ field: 'vigenciaH', headerName: 'Vig. Hasta', width: 120, type: 'date', valueFormatter: (value) => formatDate(value as string) },
];
break;
case "CierreCC":
const cierreCcHist = await auditoriaService.getHistorialCierresCC({
...commonParams,
// "ID Entidad Afectada" filtra por Id_Cierre puntual.
idCierreAfectado: filtroIdEntidad ? Number(filtroIdEntidad) : undefined
});
rawData = cierreCcHist;
cols = [
...getCommonAuditColumns(),
{ field: 'id_Cierre', headerName: 'ID Cierre', width: 100, align: 'center', headerAlign: 'center' },
{ field: 'id_Distribuidor', headerName: 'ID Dist.', width: 100, align: 'center', headerAlign: 'center' },
{ field: 'id_Empresa', headerName: 'ID Emp.', width: 100, align: 'center', headerAlign: 'center' },
{ field: 'fechaCorte', headerName: 'Fecha Corte', width: 120, type: 'date', valueFormatter: (value) => formatDate(value as string) },
{ field: 'fechaCierre', headerName: 'Fecha Cierre', width: 170, valueFormatter: (value) => formatDate(value as string) },
{ field: 'saldoCierre', headerName: 'Saldo Cierre', width: 140, type: 'number', valueFormatter: (v) => currencyFormatter(v as number) },
{ field: 'estado', headerName: 'Estado', width: 100 },
{ field: 'justificacion', headerName: 'Justif. Cierre', width: 200, renderCell: (params) => (<Tooltip title={params.value || ''}><Typography noWrap variant="body2">{params.value || '-'}</Typography></Tooltip>) },
{ field: 'id_Usuario_Cierre', headerName: 'Usr. Cierre', width: 100, align: 'center', headerAlign: 'center' },
{ field: 'id_Usuario_Anula', headerName: 'Usr. Anula', width: 100, align: 'center', headerAlign: 'center', renderCell: (p) => p.value ?? '-' },
{ field: 'fechaAnulacion', headerName: 'Fecha Anul.', width: 170, valueFormatter: (v) => v ? formatDate(v as string) : '-' },
{ field: 'justificacion_Anulacion', headerName: 'Justif. Anul.', flex: 1, minWidth: 200, renderCell: (params) => (<Tooltip title={params.value || ''}><Typography noWrap variant="body2">{params.value || '-'}</Typography></Tooltip>) },
];
break;
default:
setError(`La vista de auditoría para '${filtroTipoEntidad}' aún no está implementada.`);
setDatosAuditoria([]);

View File

@@ -0,0 +1,448 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Box, Typography, Button, Paper, IconButton, Chip, Alert, CircularProgress,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination,
FormControl, InputLabel, Select, MenuItem, Tabs, Tab, Tooltip
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import LockOpenIcon from '@mui/icons-material/LockOpen';
import HistoryIcon from '@mui/icons-material/History';
import FilterListIcon from '@mui/icons-material/FilterList';
import cierresCcService from '../../services/Contables/cierresCcService';
import distribuidorService from '../../services/Distribucion/distribuidorService';
import empresaService from '../../services/Distribucion/empresaService';
import type { CierreCuentaCorrienteDto } from '../../models/dtos/Contables/CierreCuentaCorrienteDto';
import type { CrearCierreDto } from '../../models/dtos/Contables/CrearCierreDto';
import type { ReabrirCierreDto } from '../../models/dtos/Contables/ReabrirCierreDto';
import type { CierreCuentaCorrienteHistorialDto } from '../../models/dtos/Auditoria/CierreCuentaCorrienteHistorialDto';
import type { DistribuidorDropdownDto } from '../../models/dtos/Distribucion/DistribuidorDropdownDto';
import type { EmpresaDropdownDto } from '../../models/dtos/Distribucion/EmpresaDropdownDto';
import NuevoCierreModal from '../../components/Modals/Contables/NuevoCierreModal';
import ReabrirCierreModal from '../../components/Modals/Contables/ReabrirCierreModal';
import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios';
const formatDate = (dateString?: string | null): string => {
if (!dateString) return '-';
const datePart = dateString.split('T')[0];
const parts = datePart.split('-');
if (parts.length === 3) return `${parts[2]}/${parts[1]}/${parts[0]}`;
return datePart;
};
const formatDateTime = (dateString?: string | null): string => {
if (!dateString) return '-';
const d = new Date(dateString);
if (isNaN(d.getTime())) return dateString;
return d.toLocaleString('es-AR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
};
// Extrae el codigo de error del backend si viene en el response (PERIODO_CERRADO_BLOQUEO_OPERACION,
// CIERRE_FECHA_FUTURA, etc.) y arma un mensaje legible. Si no hay codigo, usa message generico.
const parseApiError = (err: unknown, fallback: string): string => {
if (axios.isAxiosError(err)) {
const data = err.response?.data;
if (data && typeof data === 'object') {
const codigo = (data as { codigo?: string }).codigo;
const mensaje = (data as { mensaje?: string; message?: string }).mensaje
|| (data as { mensaje?: string; message?: string }).message;
if (mensaje) return codigo ? `[${codigo}] ${mensaje}` : mensaje;
if (codigo) return `Error: ${codigo}`;
}
}
return fallback;
};
const CierresCuentaCorrientePage: React.FC = () => {
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeVer = isSuperAdmin || tienePermiso('CC003');
const puedeCrear = isSuperAdmin || tienePermiso('CC001');
// Reapertura: exclusivo SuperAdmin (CC002 no se valida — no es asignable a perfiles).
const puedeReabrir = isSuperAdmin;
const [filtroIdDistribuidor, setFiltroIdDistribuidor] = useState<number | string>('');
const [filtroIdEmpresa, setFiltroIdEmpresa] = useState<number | string>('');
const [distribuidores, setDistribuidores] = useState<DistribuidorDropdownDto[]>([]);
const [empresas, setEmpresas] = useState<EmpresaDropdownDto[]>([]);
const [loadingDropdowns, setLoadingDropdowns] = useState(false);
const [cierres, setCierres] = useState<CierreCuentaCorrienteDto[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pageApiErrorMessage, setPageApiErrorMessage] = useState<string | null>(null);
const [modalApiErrorMessage, setModalApiErrorMessage] = useState<string | null>(null);
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(25);
const [tabIndex, setTabIndex] = useState(0);
const [cierreSeleccionadoHistorial, setCierreSeleccionadoHistorial] = useState<CierreCuentaCorrienteDto | null>(null);
const [historial, setHistorial] = useState<CierreCuentaCorrienteHistorialDto[]>([]);
const [loadingHistorial, setLoadingHistorial] = useState(false);
const [nuevoModalOpen, setNuevoModalOpen] = useState(false);
const [reabrirModalOpen, setReabrirModalOpen] = useState(false);
const [cierreParaReabrir, setCierreParaReabrir] = useState<CierreCuentaCorrienteDto | null>(null);
// Carga dropdowns una sola vez al montar
useEffect(() => {
const fetchDropdowns = async () => {
setLoadingDropdowns(true);
try {
const [distData, empData] = await Promise.all([
distribuidorService.getAllDistribuidoresDropdown(),
empresaService.getEmpresasDropdown()
]);
setDistribuidores(distData);
setEmpresas(empData);
} catch (err) {
console.error(err);
setError('Error al cargar opciones de filtro.');
} finally {
setLoadingDropdowns(false);
}
};
fetchDropdowns();
}, []);
const cargarCierres = useCallback(async () => {
if (!puedeVer) {
setError('No tiene permiso.');
return;
}
if (!filtroIdDistribuidor || !filtroIdEmpresa) {
setCierres([]);
return;
}
setLoading(true);
setError(null);
setPageApiErrorMessage(null);
try {
const data = await cierresCcService.getAllCierres(
Number(filtroIdDistribuidor),
Number(filtroIdEmpresa)
);
setCierres(data);
setPage(0);
} catch (err) {
console.error(err);
setPageApiErrorMessage(parseApiError(err, 'Error al cargar los cierres.'));
} finally {
setLoading(false);
}
}, [puedeVer, filtroIdDistribuidor, filtroIdEmpresa]);
useEffect(() => { cargarCierres(); }, [cargarCierres]);
const cargarHistorial = useCallback(async (idCierre: number) => {
setLoadingHistorial(true);
try {
const data = await cierresCcService.getHistorialCierre(idCierre);
setHistorial(data);
} catch (err) {
console.error(err);
setPageApiErrorMessage(parseApiError(err, 'Error al cargar el historial.'));
} finally {
setLoadingHistorial(false);
}
}, []);
const handleSelectCierreHistorial = (cierre: CierreCuentaCorrienteDto) => {
setCierreSeleccionadoHistorial(cierre);
setTabIndex(1);
cargarHistorial(cierre.idCierre);
};
const clearModalApiErrorMessage = useCallback(() => setModalApiErrorMessage(null), []);
const handleSubmitNuevoCierre = async (data: CrearCierreDto) => {
setModalApiErrorMessage(null);
try {
const creado = await cierresCcService.crearCierre(data);
cargarCierres();
return creado;
} catch (err) {
const msg = parseApiError(err, 'Error al crear el cierre.');
setModalApiErrorMessage(msg);
throw err;
}
};
const handleOpenReabrir = (cierre: CierreCuentaCorrienteDto) => {
setCierreParaReabrir(cierre);
setReabrirModalOpen(true);
};
const handleSubmitReabrir = async (idCierre: number, data: ReabrirCierreDto) => {
setModalApiErrorMessage(null);
try {
const result = await cierresCcService.reabrirCierre(idCierre, data);
cargarCierres();
if (cierreSeleccionadoHistorial?.idCierre === idCierre) {
cargarHistorial(idCierre);
}
return result;
} catch (err) {
const msg = parseApiError(err, 'Error al reabrir el cierre.');
setModalApiErrorMessage(msg);
throw err;
}
};
const handleChangePage = (_e: unknown, newPage: number) => setPage(newPage);
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0);
};
const displayData = cierres.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage);
if (!puedeVer) {
return (
<Box sx={{ p: 2 }}>
<Alert severity="error">No tiene permiso para acceder a esta sección.</Alert>
</Box>
);
}
return (
<Box sx={{ p: 1 }}>
<Typography variant="h5" gutterBottom>Cierres de Cuenta Corriente</Typography>
<Paper sx={{ p: 2, mb: 2 }}>
<Typography variant="h6" gutterBottom>Filtros <FilterListIcon fontSize="small" /></Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, alignItems: 'center', mb: 2 }}>
<FormControl size="small" sx={{ minWidth: 220, flexGrow: 1 }} disabled={loadingDropdowns}>
<InputLabel>Distribuidor</InputLabel>
<Select
value={filtroIdDistribuidor}
label="Distribuidor"
onChange={(e) => setFiltroIdDistribuidor(e.target.value as number | string)}
>
<MenuItem value=""><em>Seleccione</em></MenuItem>
{distribuidores.map(d => <MenuItem key={d.idDistribuidor} value={d.idDistribuidor}>{d.nombre}</MenuItem>)}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 200, flexGrow: 1 }} disabled={loadingDropdowns}>
<InputLabel>Empresa</InputLabel>
<Select
value={filtroIdEmpresa}
label="Empresa"
onChange={(e) => setFiltroIdEmpresa(e.target.value as number | string)}
>
<MenuItem value=""><em>Seleccione</em></MenuItem>
{empresas.map(e => <MenuItem key={e.idEmpresa} value={e.idEmpresa}>{e.nombre}</MenuItem>)}
</Select>
</FormControl>
</Box>
{puedeCrear && (
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => setNuevoModalOpen(true)}
disabled={loadingDropdowns}
>
Nuevo Cierre
</Button>
)}
</Paper>
<Paper sx={{ mb: 2 }}>
<Tabs value={tabIndex} onChange={(_e, v) => setTabIndex(v)}>
<Tab label="Cierres" />
<Tab
label={cierreSeleccionadoHistorial
? `Historial #${cierreSeleccionadoHistorial.idCierre}`
: 'Historial'}
disabled={!cierreSeleccionadoHistorial}
icon={<HistoryIcon fontSize="small" />}
iconPosition="start"
/>
</Tabs>
</Paper>
{error && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>}
{pageApiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{pageApiErrorMessage}</Alert>}
{tabIndex === 0 && (
<>
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}>
<CircularProgress />
</Box>
)}
{!loading && (!filtroIdDistribuidor || !filtroIdEmpresa) && (
<Alert severity="info">Seleccioná un distribuidor y una empresa para ver los cierres.</Alert>
)}
{!loading && filtroIdDistribuidor && filtroIdEmpresa && (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Fecha de Corte</TableCell>
<TableCell>Fecha de Cierre</TableCell>
<TableCell align="right">Saldo del Cierre</TableCell>
<TableCell align="center">Estado</TableCell>
<TableCell>Justificación</TableCell>
<TableCell>Usuario</TableCell>
<TableCell align="right">Acciones</TableCell>
</TableRow>
</TableHead>
<TableBody>
{displayData.length === 0 ? (
<TableRow><TableCell colSpan={7} align="center">No hay cierres registrados.</TableCell></TableRow>
) : (
displayData.map((c) => (
<TableRow key={c.idCierre} hover>
<TableCell>{formatDate(c.fechaCorte)}</TableCell>
<TableCell>{formatDateTime(c.fechaCierre)}</TableCell>
<TableCell align="right">
{c.saldoCierre.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}
</TableCell>
<TableCell align="center">
<Chip
label={c.estado === 'Activo' && c.esUltimoVigente ? 'Activo (último)' : c.estado}
color={c.estado === 'Activo' ? 'success' : 'default'}
size="small"
/>
</TableCell>
<TableCell>
<Tooltip title={c.justificacion || ''}>
<Box sx={{ maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{c.justificacion || '-'}
</Box>
</Tooltip>
</TableCell>
<TableCell>{c.nombreUsuarioCierre}</TableCell>
<TableCell align="right">
<Tooltip title="Ver historial">
<IconButton size="small" onClick={() => handleSelectCierreHistorial(c)}>
<HistoryIcon fontSize="small" />
</IconButton>
</Tooltip>
{puedeReabrir && c.estado === 'Activo' && c.esUltimoVigente && (
<Tooltip title="Reabrir cierre">
<IconButton
size="small"
color="warning"
onClick={() => handleOpenReabrir(c)}
>
<LockOpenIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
<TablePagination
rowsPerPageOptions={[25, 50, 100]}
component="div"
count={cierres.length}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
labelRowsPerPage="Filas por página:"
/>
</TableContainer>
)}
</>
)}
{tabIndex === 1 && cierreSeleccionadoHistorial && (
<>
<Paper sx={{ p: 2, mb: 2 }}>
<Typography variant="body2">
Historial del cierre <strong>#{cierreSeleccionadoHistorial.idCierre}</strong>
{' — '}{cierreSeleccionadoHistorial.nombreDistribuidor} / {cierreSeleccionadoHistorial.nombreEmpresa}
{' — '}corte: {formatDate(cierreSeleccionadoHistorial.fechaCorte)}
</Typography>
</Paper>
{loadingHistorial && (
<Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}>
<CircularProgress />
</Box>
)}
{!loadingHistorial && (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Tipo Modificación</TableCell>
<TableCell>Fecha Modificación</TableCell>
<TableCell>Usuario</TableCell>
<TableCell align="right">Saldo</TableCell>
<TableCell align="center">Estado</TableCell>
<TableCell>Justificación</TableCell>
</TableRow>
</TableHead>
<TableBody>
{historial.length === 0 ? (
<TableRow><TableCell colSpan={6} align="center">No hay registros de auditoría.</TableCell></TableRow>
) : (
historial.map((h) => (
<TableRow key={h.id_Historial}>
<TableCell>{h.tipoMod}</TableCell>
<TableCell>{formatDateTime(h.fechaMod)}</TableCell>
<TableCell>{h.nombreUsuarioModifico}</TableCell>
<TableCell align="right">
{h.saldoCierre.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}
</TableCell>
<TableCell align="center">
<Chip label={h.estado} color={h.estado === 'Activo' ? 'success' : 'default'} size="small" />
</TableCell>
<TableCell>
<Tooltip title={h.justificacion_Anulacion || h.justificacion || ''}>
<Box sx={{ maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{h.justificacion_Anulacion || h.justificacion || '-'}
</Box>
</Tooltip>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
)}
</>
)}
<NuevoCierreModal
open={nuevoModalOpen}
onClose={() => setNuevoModalOpen(false)}
onSubmit={handleSubmitNuevoCierre}
initialIdDistribuidor={filtroIdDistribuidor ? Number(filtroIdDistribuidor) : null}
initialIdEmpresa={filtroIdEmpresa ? Number(filtroIdEmpresa) : null}
errorMessage={modalApiErrorMessage}
clearErrorMessage={clearModalApiErrorMessage}
/>
<ReabrirCierreModal
open={reabrirModalOpen}
onClose={() => { setReabrirModalOpen(false); setCierreParaReabrir(null); }}
onSubmit={handleSubmitReabrir}
cierre={cierreParaReabrir}
errorMessage={modalApiErrorMessage}
clearErrorMessage={clearModalApiErrorMessage}
/>
</Box>
);
};
export default CierresCuentaCorrientePage;

View File

@@ -4,11 +4,12 @@ import { Box, Tabs, Tab, Paper, Typography } from '@mui/material';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
// Define las sub-pestañas del módulo Contables
const contablesSubModules = [
const contablesSubModules = [
{ label: 'Pagos Distribuidores', path: 'pagos-distribuidores' },
{ label: 'Notas Crédito/Débito', path: 'notas-cd' },
{ label: 'Gestión de Saldos', path: 'gestion-saldos' },
{ label: 'Tipos de Pago', path: 'tipos-pago' },
{ label: 'Tipos de Pago', path: 'tipos-pago' },
{ label: 'Cierres CC', path: 'cierres-cc' },
];
const ContablesIndexPage: React.FC = () => {

View File

@@ -24,7 +24,7 @@ import type { EmpresaDto } from '../../models/dtos/Distribucion/EmpresaDto';
import NotaCreditoDebitoFormModal from '../../components/Modals/Contables/NotaCreditoDebitoFormModal';
import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios';
import { parseApiError } from '../../utils/apiErrorParser';
type DestinoFiltroType = 'Distribuidores' | 'Canillas' | '';
type TipoNotaFiltroType = 'Credito' | 'Debito' | '';
@@ -124,7 +124,7 @@ const GestionarNotasCDPage: React.FC = () => {
}
cargarNotas();
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al guardar la nota.';
const { message } = parseApiError(err, 'Error al guardar la nota.');
setApiErrorMessage(message); throw err;
}
};
@@ -133,7 +133,7 @@ const GestionarNotasCDPage: React.FC = () => {
if (window.confirm(`¿Seguro de eliminar esta nota (ID: ${idNota})? Esta acción revertirá el impacto en el saldo.`)) {
setApiErrorMessage(null);
try { await notaCreditoDebitoService.deleteNota(idNota); cargarNotas(); }
catch (err: any) { const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al eliminar.'; setApiErrorMessage(msg); }
catch (err: any) { const { message } = parseApiError(err, 'Error al eliminar.'); setApiErrorMessage(message); }
}
handleMenuClose();
};

View File

@@ -22,7 +22,7 @@ import type { EmpresaDto } from '../../models/dtos/Distribucion/EmpresaDto';
import PagoDistribuidorFormModal from '../../components/Modals/Contables/PagoDistribuidorFormModal';
import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios';
import { parseApiError } from '../../utils/apiErrorParser';
const GestionarPagosDistribuidorPage: React.FC = () => {
const [pagos, setPagos] = useState<PagoDistribuidorDto[]>([]);
@@ -110,7 +110,7 @@ const GestionarPagosDistribuidorPage: React.FC = () => {
}
cargarPagos();
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al guardar el pago.';
const { message } = parseApiError(err, 'Error al guardar el pago.');
setModalApiErrorMessage(message);
throw err;
}
@@ -121,8 +121,8 @@ const GestionarPagosDistribuidorPage: React.FC = () => {
setPageApiErrorMessage(null);
try { await pagoDistribuidorService.deletePagoDistribuidor(idPago); cargarPagos(); }
catch (err: any) {
const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al eliminar.';
setPageApiErrorMessage(msg);
const { message } = parseApiError(err, 'Error al eliminar.');
setPageApiErrorMessage(message);
}
}
handleMenuClose();

View File

@@ -20,7 +20,7 @@ import type { CanillaDto } from '../../models/dtos/Distribucion/CanillaDto';
import AjusteSaldoModal from '../../components/Modals/Contables/AjusteSaldoModal';
import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios';
import { parseApiError } from '../../utils/apiErrorParser';
type TipoDestinoFiltro = 'Distribuidores' | 'Canillas' | '';
@@ -48,7 +48,7 @@ const GestionarSaldosPage: React.FC = () => {
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeVerSaldos = isSuperAdmin || tienePermiso("CS001"); // Permiso para ver
const puedeAjustarSaldos = isSuperAdmin || tienePermiso("CS002"); // Permiso para ajustar
const puedeAjustarSaldos = isSuperAdmin; // Ajuste manual: exclusivo SuperAdmin (no se valida CS002)
const fetchDropdownData = useCallback(async () => {
@@ -128,10 +128,8 @@ const GestionarSaldosPage: React.FC = () => {
await saldoService.ajustarSaldo(data);
cargarSaldos(); // Recargar lista para ver el saldo actualizado
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message
? err.response.data.message
: 'Error al aplicar el ajuste de saldo.';
setApiErrorMessage(message);
const { message } = parseApiError(err, 'Error al aplicar el ajuste de saldo.');
setApiErrorMessage(message);
throw err; // Para que el modal sepa que hubo error
}
};

View File

@@ -22,7 +22,7 @@ import type { DistribuidorDropdownDto } from '../../models/dtos/Distribucion/Dis
import EntradaSalidaDistFormModal from '../../components/Modals/Distribucion/EntradaSalidaDistFormModal';
import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios';
import { parseApiError } from '../../utils/apiErrorParser';
const GestionarEntradasSalidasDistPage: React.FC = () => {
const [movimientos, setMovimientos] = useState<EntradaSalidaDistDto[]>([]);
@@ -115,7 +115,7 @@ const GestionarEntradasSalidasDistPage: React.FC = () => {
}
cargarMovimientos();
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al guardar.';
const { message } = parseApiError(err, 'Error al guardar.');
setApiErrorMessage(message); throw err;
}
};
@@ -124,7 +124,7 @@ const GestionarEntradasSalidasDistPage: React.FC = () => {
if (window.confirm(`¿Seguro (ID: ${idParte})? Esto revertirá el saldo.`)) {
setApiErrorMessage(null);
try { await entradaSalidaDistService.deleteEntradaSalidaDist(idParte); cargarMovimientos(); }
catch (err: any) { const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al eliminar.'; setApiErrorMessage(msg); }
catch (err: any) { const { message } = parseApiError(err, 'Error al eliminar.'); setApiErrorMessage(message); }
}
handleMenuClose();
};

View File

@@ -63,8 +63,9 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => {
});
};
const movs = procesarLista(data.entradasSalidas, 'mov', 0);
const ultimoMov = movs.length ? movs[movs.length - 1].saldoAcumulado : 0;
const baseInicial = data.saldoInicial ?? 0;
const movs = procesarLista(data.entradasSalidas, 'mov', baseInicial);
const ultimoMov = movs.length ? movs[movs.length - 1].saldoAcumulado : baseInicial;
const notas = procesarLista(data.debitosCreditos, 'nota', ultimoMov);
const ultimoNota = notas.length ? notas[notas.length - 1].saldoAcumulado : ultimoMov;
const pagos = procesarLista(data.pagos, 'pago', ultimoNota);
@@ -210,7 +211,7 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => {
<GridFooter sx={{ borderTop: 'none' }} />
<Box sx={{ p: 1, fontWeight: 'bold' }}>
<Typography variant="subtitle2" component="span" sx={{ mr: 2 }}>
TOTA DEBE: <strong>{totalDebe.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}</strong>
TOTAL DEBE: <strong>{totalDebe.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}</strong>
</Typography>
<Typography variant="subtitle2" component="span">
TOTAL HABER: <strong>{totalHaber.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}</strong>
@@ -284,39 +285,44 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => {
}, []);
const handleExportToExcel = useCallback(() => {
if (
!originalReportData ||
(movimientosConSaldo.length === 0 &&
notasConSaldo.length === 0 &&
pagosConSaldo.length === 0)
) {
alert("No hay datos para exportar."); // O un mensaje más amigable
return;
if (!originalReportData) {
alert('No hay datos para exportar.');
return;
}
const wb = XLSX.utils.book_new();// Se crea un nuevo libro
const sumDebe = (rows: Array<{ debe?: number }>) => rows.reduce((s, r) => s + (r.debe || 0), 0);
const sumHaber = (rows: Array<{ haber?: number }>) => rows.reduce((s, r) => s + (r.haber || 0), 0);
// Movimientos
if (movimientosConSaldo.length) { // <--- CHEQUEO 1
// Si movimientosConSaldo está vacío, esta hoja no se añade
const totalDebeAll = sumDebe(movimientosConSaldo) + sumDebe(notasConSaldo) + sumDebe(pagosConSaldo);
const totalHaberAll = sumHaber(movimientosConSaldo) + sumHaber(notasConSaldo) + sumHaber(pagosConSaldo);
const saldoInicialExp = originalReportData.saldoInicial ?? 0;
const saldoFinalExp = saldoInicialExp + totalDebeAll - totalHaberAll;
const wb = XLSX.utils.book_new();
// Hoja Resumen — siempre presente, con saldo inicial y final aunque no haya movimientos
const resumenRows = [
{ Concepto: 'Saldo Inicial', Monto: saldoInicialExp },
{ Concepto: 'Total Debe', Monto: totalDebeAll },
{ Concepto: 'Total Haber', Monto: totalHaberAll },
{ Concepto: 'Saldo Final', Monto: saldoFinalExp },
];
const wsResumen = XLSX.utils.json_to_sheet(resumenRows);
XLSX.utils.book_append_sheet(wb, wsResumen, 'Resumen');
if (movimientosConSaldo.length) {
const ws = XLSX.utils.json_to_sheet(movimientosConSaldo.map(({ id, ...rest }) => rest));
XLSX.utils.book_append_sheet(wb, ws, 'Movimientos');
}
// Notas
if (notasConSaldo.length) { // <--- CHEQUEO 2
// Si notasConSaldo está vacío, esta hoja no se añade
if (notasConSaldo.length) {
const ws = XLSX.utils.json_to_sheet(notasConSaldo.map(({ id, ...rest }) => rest));
XLSX.utils.book_append_sheet(wb, ws, 'Notas');
}
// Pagos
if (pagosConSaldo.length) { // <--- CHEQUEO 3
// Si pagosConSaldo está vacío, esta hoja no se añade
if (pagosConSaldo.length) {
const ws = XLSX.utils.json_to_sheet(pagosConSaldo.map(({ id, ...rest }) => rest));
XLSX.utils.book_append_sheet(wb, ws, 'Pagos');
}
// Si ninguno de los arrays tiene datos, el libro 'wb' quedará vacío.
// Y la siguiente línea dará el error:
XLSX.writeFile(wb, `Reporte_${Date.now()}.xlsx`);
}, [originalReportData, movimientosConSaldo, notasConSaldo, pagosConSaldo]);
@@ -355,7 +361,8 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => {
const totalMov = movimientosConSaldo.reduce((sum, r) => sum + ((r.debe || 0) - (r.haber || 0)), 0);
const totalNot = notasConSaldo.reduce((sum, r) => sum + ((r.debe || 0) - (r.haber || 0)), 0);
const totalPag = pagosConSaldo.reduce((sum, r) => sum + ((r.debe || 0) - (r.haber || 0)), 0);
const saldoInicial = originalReportData?.saldos?.[0]?.monto || 0;
const saldoInicial = originalReportData?.saldoInicial ?? 0;
const saldoFinal = saldoInicial + totalMov + totalNot + totalPag;
const cols = generarColumns();
@@ -385,7 +392,7 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => {
<Typography>Distribuidor: <strong>{currentParams?.nombreDistribuidor}</strong></Typography>
<Typography>Empresa: <strong>{currentParams?.nombreEmpresa}</strong></Typography>
<Typography>Período: <strong>{currentParams?.fechaDesde}</strong> al <strong>{currentParams?.fechaHasta}</strong></Typography>
<Typography>Saldo a la Fecha {new Date().toLocaleDateString('es-AR')}: <strong>{saldoInicial.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}</strong></Typography>
<Typography>Saldo Inicial del Período: <strong>{saldoInicial.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}</strong></Typography>
</Paper>
<Typography variant="h6" sx={{ mt: 2 }}>Movimientos de Entrada / Salida</Typography>
@@ -399,11 +406,12 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => {
<Paper sx={{ p: 2, mt: 3 }}>
<Typography variant="h6">Resumen Final</Typography>
<Typography>Saldo Inicial: <strong>{saldoInicial.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}</strong></Typography>
<Typography>Movimientos (Debe - Haber): {totalMov.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}</Typography>
<Typography>Notas C/D (Debe - Haber): {totalNot.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}</Typography>
<Typography>Pagos (Debe - Haber): {totalPag.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mt: 1 }}>
Saldo Final del Período: {(totalMov + totalNot + totalPag).toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}
Saldo Final: {saldoFinal.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}
</Typography>
</Paper>
</Box>

View File

@@ -1,12 +1,14 @@
import React, { useState, useEffect } from 'react';
import {
Box, Typography, TextField, Button, CircularProgress, Alert,
FormControl, InputLabel, Select, MenuItem
FormControl, InputLabel, Select, MenuItem, Tooltip
} from '@mui/material';
import EventAvailableIcon from '@mui/icons-material/EventAvailable';
import type { DistribuidorDropdownDto } from '../../models/dtos/Distribucion/DistribuidorDropdownDto';
import distribuidorService from '../../services/Distribucion/distribuidorService';
import type { EmpresaDropdownDto } from '../../models/dtos/Distribucion/EmpresaDropdownDto';
import empresaService from '../../services/Distribucion/empresaService';
import cierresCcService from '../../services/Contables/cierresCcService';
interface SeleccionaReporteCuentasDistribuidoresProps {
onGenerarReporte: (params: {
@@ -20,6 +22,18 @@ interface SeleccionaReporteCuentasDistribuidoresProps {
apiErrorMessage?: string | null;
}
// Suma 1 día a una fecha en formato yyyy-MM-dd y devuelve la fecha resultante
// también en yyyy-MM-dd. Usado por el atajo "Desde último cierre" para arrancar
// el reporte el día siguiente al cierre.
const addOneDay = (yyyyMmDd: string): string => {
const d = new Date(yyyyMmDd + 'T00:00:00');
d.setDate(d.getDate() + 1);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const SeleccionaReporteCuentasDistribuidores: React.FC<SeleccionaReporteCuentasDistribuidoresProps> = ({
onGenerarReporte,
isLoading,
@@ -35,15 +49,19 @@ const SeleccionaReporteCuentasDistribuidores: React.FC<SeleccionaReporteCuentasD
const [loadingDropdowns, setLoadingDropdowns] = useState(false);
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
const [loadingUltimoCierre, setLoadingUltimoCierre] = useState(false);
const [hayCierrePrevio, setHayCierrePrevio] = useState<boolean | null>(null);
const [infoCierre, setInfoCierre] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
setLoadingDropdowns(true);
try {
const [distData, empData] = await Promise.all([
distribuidorService.getAllDistribuidoresDropdown(), // Asume que este servicio existe
empresaService.getEmpresasDropdown() // Asume que este servicio existe
distribuidorService.getAllDistribuidoresDropdown(),
empresaService.getEmpresasDropdown()
]);
setDistribuidores(distData.map(d => d)); // El servicio devuelve tupla
setDistribuidores(distData.map(d => d));
setEmpresas(empData);
} catch (error) {
console.error("Error al cargar datos:", error);
@@ -55,6 +73,13 @@ const SeleccionaReporteCuentasDistribuidores: React.FC<SeleccionaReporteCuentasD
fetchData();
}, []);
// Reset del estado del atajo cuando cambian distribuidor/empresa: el "ultimo cierre"
// depende del par (distribuidor, empresa), si alguno cambia hay que volver a chequear.
useEffect(() => {
setHayCierrePrevio(null);
setInfoCierre(null);
}, [idDistribuidor, idEmpresa]);
const validate = (): boolean => {
const errors: { [key: string]: string | null } = {};
if (!idDistribuidor) errors.idDistribuidor = 'Debe seleccionar un distribuidor.';
@@ -78,6 +103,37 @@ const SeleccionaReporteCuentasDistribuidores: React.FC<SeleccionaReporteCuentasD
});
};
const handleDesdeUltimoCierre = async () => {
if (!idDistribuidor || !idEmpresa) return;
setLoadingUltimoCierre(true);
setInfoCierre(null);
try {
const ultimo = await cierresCcService.getUltimoCierre(Number(idDistribuidor), Number(idEmpresa));
if (ultimo === null) {
setHayCierrePrevio(false);
setInfoCierre('Sin cierres previos para este distribuidor y empresa.');
} else {
const nuevaFechaDesde = addOneDay(ultimo.fechaCorte);
setFechaDesde(nuevaFechaDesde);
setLocalErrors(p => ({ ...p, fechaDesde: null, fechaHasta: null }));
setHayCierrePrevio(true);
setInfoCierre(`Último cierre: ${ultimo.fechaCorte}. Fecha Desde ajustada al día siguiente.`);
}
} catch (err) {
console.error('Error al obtener último cierre:', err);
setInfoCierre('Error al consultar el último cierre.');
} finally {
setLoadingUltimoCierre(false);
}
};
const atajoDisabled = !idDistribuidor || !idEmpresa || loadingUltimoCierre || isLoading || hayCierrePrevio === false;
const atajoTooltip = !idDistribuidor || !idEmpresa
? 'Seleccioná distribuidor y empresa primero.'
: hayCierrePrevio === false
? 'Sin cierres previos.'
: 'Autocompleta Fecha Desde con el día siguiente al último cierre.';
return (
<Box sx={{ p: 2, border: '1px solid #ccc', borderRadius: '4px', minWidth: 380 }}>
<Typography variant="h6" gutterBottom>
@@ -115,19 +171,42 @@ const SeleccionaReporteCuentasDistribuidores: React.FC<SeleccionaReporteCuentasD
{localErrors.idEmpresa && <Typography color="error" variant="caption" sx={{ml:1.5}}>{localErrors.idEmpresa}</Typography>}
</FormControl>
<TextField
label="Fecha Desde"
type="date"
value={fechaDesde}
onChange={(e) => { setFechaDesde(e.target.value); setLocalErrors(p => ({ ...p, fechaDesde: null, fechaHasta: null })); }}
margin="normal"
fullWidth
required
error={!!localErrors.fechaDesde}
helperText={localErrors.fechaDesde}
disabled={isLoading}
InputLabelProps={{ shrink: true }}
/>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
<TextField
label="Fecha Desde"
type="date"
value={fechaDesde}
onChange={(e) => { setFechaDesde(e.target.value); setLocalErrors(p => ({ ...p, fechaDesde: null, fechaHasta: null })); }}
margin="normal"
fullWidth
required
error={!!localErrors.fechaDesde}
helperText={localErrors.fechaDesde}
disabled={isLoading}
InputLabelProps={{ shrink: true }}
/>
<Tooltip title={atajoTooltip}>
<span>
<Button
variant="outlined"
size="small"
startIcon={loadingUltimoCierre ? <CircularProgress size={16} /> : <EventAvailableIcon />}
onClick={handleDesdeUltimoCierre}
disabled={atajoDisabled}
sx={{ mt: 2, whiteSpace: 'nowrap' }}
>
Desde último cierre
</Button>
</span>
</Tooltip>
</Box>
{infoCierre && (
<Alert severity={hayCierrePrevio ? 'success' : 'info'} sx={{ mt: 1 }}>
{infoCierre}
</Alert>
)}
<TextField
label="Fecha Hasta"
type="date"
@@ -154,4 +233,4 @@ const SeleccionaReporteCuentasDistribuidores: React.FC<SeleccionaReporteCuentasD
);
};
export default SeleccionaReporteCuentasDistribuidores;
export default SeleccionaReporteCuentasDistribuidores;

View File

@@ -14,6 +14,10 @@ import PermisosChecklist from '../../components/Modals/Usuarios/PermisosChecklis
const SECCION_PERMISSIONS_PREFIX = "SS";
// Permisos exclusivos de SuperAdmin: no se asignan a perfiles ni se muestran en la UI de asignación.
// CS002 = Ajuste manual de saldo. CC002 = Reapertura de cierres de cuenta corriente.
const PERMISOS_SOLO_SUPERADMIN: ReadonlySet<string> = new Set(["CS002", "CC002"]);
const getModuloFromSeccionCodAcc = (codAcc: string): string | null => {
if (codAcc === "SS001") return "Distribución";
if (codAcc === "SS007") return "Suscripciones";
@@ -44,7 +48,8 @@ const getModuloConceptualDelPermiso = (permisoModulo: string): string => {
}
if (moduloLower.includes("cuentas pagos") ||
moduloLower.includes("cuentas notas") ||
moduloLower.includes("cuentas tipos pagos")) {
moduloLower.includes("cuentas tipos pagos") ||
moduloLower.includes("cuentas cierres")) {
return "Contables";
}
if (moduloLower.includes("impresión tiradas") ||
@@ -104,9 +109,12 @@ const AsignarPermisosAPerfilPage: React.FC = () => {
perfilService.getPerfilById(idPerfilNum),
perfilService.getPermisosPorPerfil(idPerfilNum) // Esto devuelve todos los permisos con su estado 'asignado'
]);
// Filtrar permisos exclusivos de SuperAdmin (CS002, CC002) para que no aparezcan asignables.
// No se altera la asignación existente en la DB — solo se ocultan de esta UI.
const permisosVisibles = permisosData.filter(p => !PERMISOS_SOLO_SUPERADMIN.has(p.codAcc));
setPerfil(perfilData);
setPermisosDisponibles(permisosData);
setPermisosSeleccionados(new Set(permisosData.filter(p => p.asignado).map(p => p.id)));
setPermisosDisponibles(permisosVisibles);
setPermisosSeleccionados(new Set(permisosVisibles.filter(p => p.asignado).map(p => p.id)));
} catch (err) {
console.error(err);
setError('Error al cargar datos del perfil o permisos.');

View File

@@ -39,6 +39,7 @@ import GestionarTiposPagoPage from '../pages/Contables/GestionarTiposPagoPage';
import GestionarPagosDistribuidorPage from '../pages/Contables/GestionarPagosDistribuidorPage';
import GestionarNotasCDPage from '../pages/Contables/GestionarNotasCDPage';
import GestionarSaldosPage from '../pages/Contables/GestionarSaldosPage';
import CierresCuentaCorrientePage from '../pages/Contables/CierresCuentaCorrientePage';
// Usuarios
import UsuariosIndexPage from '../pages/Usuarios/UsuariosIndexPage'; // Crear este componente
@@ -241,6 +242,14 @@ const AppRoutes = () => {
<Route path="pagos-distribuidores" element={<GestionarPagosDistribuidorPage />} />
<Route path="notas-cd" element={<GestionarNotasCDPage />} />
<Route path="gestion-saldos" element={<GestionarSaldosPage />} />
<Route
path="cierres-cc"
element={
<SectionProtectedRoute requiredPermission="CC003" sectionName="Cierres de Cuenta Corriente">
<CierresCuentaCorrientePage />
</SectionProtectedRoute>
}
/>
</Route>
{/* Módulo de Impresión (anidado) */}

View File

@@ -29,6 +29,7 @@ import type { PerfilHistorialDto } from '../../models/dtos/Auditoria/PerfilHisto
import type { PermisoHistorialDto } from '../../models/dtos/Auditoria/PermisoHistorialDto';
import type { PermisosPerfilesHistorialDto } from '../../models/dtos/Auditoria/PermisosPerfilesHistorialDto';
import type { CambioParadaHistorialDto } from '../../models/dtos/Auditoria/CambioParadaHistorialDto';
import type { CierreCuentaCorrienteHistorialDto } from '../../models/dtos/Auditoria/CierreCuentaCorrienteHistorialDto';
interface HistorialParamsComunes {
fechaDesde?: string; // "yyyy-MM-dd"
@@ -41,6 +42,10 @@ interface HistorialCambiosParadaParams extends HistorialParamsComunes {
idCanillaAfectado?: number;
}
interface HistorialCierresCCParams extends HistorialParamsComunes {
idCierreAfectado?: number;
}
interface HistorialPermisosPerfilesParams extends HistorialParamsComunes {
idPerfilAfectado?: number;
idPermisoAfectado?: number;
@@ -462,11 +467,20 @@ const getHistorialCambiosParada = async (params: HistorialCambiosParadaParams):
const queryParams: any = { ...params };
if (params.idUsuarioModificador) queryParams.idUsuarioModifico = params.idUsuarioModificador;
delete queryParams.idUsuarioModificador;
const response = await apiClient.get<CambioParadaHistorialDto[]>('/auditoria/cambios-parada-canilla', { params: queryParams });
return response.data;
};
const getHistorialCierresCC = async (params: HistorialCierresCCParams): Promise<CierreCuentaCorrienteHistorialDto[]> => {
const queryParams: any = { ...params };
if (params.idUsuarioModificador) queryParams.idUsuarioModifico = params.idUsuarioModificador;
delete queryParams.idUsuarioModificador;
const response = await apiClient.get<CierreCuentaCorrienteHistorialDto[]>('/auditoria/cierres-cuenta-corriente', { params: queryParams });
return response.data;
};
const auditoriaService = {
getHistorialUsuarios,
getHistorialPagosDistribuidor,
@@ -498,6 +512,7 @@ const auditoriaService = {
getHistorialPermisosMaestro,
getHistorialPermisosPerfiles,
getHistorialCambiosParada,
getHistorialCierresCC,
};
export default auditoriaService;

View File

@@ -0,0 +1,75 @@
import apiClient from '../apiClient';
import type { CierreCuentaCorrienteDto } from '../../models/dtos/Contables/CierreCuentaCorrienteDto';
import type { CrearCierreDto } from '../../models/dtos/Contables/CrearCierreDto';
import type { ReabrirCierreDto } from '../../models/dtos/Contables/ReabrirCierreDto';
import type { UltimoCierreDto } from '../../models/dtos/Contables/UltimoCierreDto';
import type { CierreCuentaCorrienteHistorialDto } from '../../models/dtos/Auditoria/CierreCuentaCorrienteHistorialDto';
const getAllCierres = async (
idDistribuidor: number,
idEmpresa: number,
): Promise<CierreCuentaCorrienteDto[]> => {
const response = await apiClient.get<CierreCuentaCorrienteDto[]>('/cierres-cc', {
params: { idDistribuidor, idEmpresa },
});
return response.data;
};
// Devuelve null si no hay cierre vigente (backend responde 404 — lo capturamos acá
// para que las páginas no tengan que manejar el error).
const getUltimoCierre = async (
idDistribuidor: number,
idEmpresa: number,
): Promise<UltimoCierreDto | null> => {
try {
const response = await apiClient.get<UltimoCierreDto>('/cierres-cc/ultimo', {
params: { idDistribuidor, idEmpresa },
});
return response.data;
} catch (error: unknown) {
if (
typeof error === 'object' &&
error !== null &&
'response' in error &&
(error as { response?: { status?: number } }).response?.status === 404
) {
return null;
}
throw error;
}
};
const crearCierre = async (data: CrearCierreDto): Promise<CierreCuentaCorrienteDto> => {
const response = await apiClient.post<CierreCuentaCorrienteDto>('/cierres-cc', data);
return response.data;
};
const reabrirCierre = async (
idCierre: number,
data: ReabrirCierreDto,
): Promise<CierreCuentaCorrienteDto> => {
const response = await apiClient.post<CierreCuentaCorrienteDto>(
`/cierres-cc/${idCierre}/reabrir`,
data,
);
return response.data;
};
const getHistorialCierre = async (
idCierre: number,
): Promise<CierreCuentaCorrienteHistorialDto[]> => {
const response = await apiClient.get<CierreCuentaCorrienteHistorialDto[]>(
`/cierres-cc/${idCierre}/historial`
);
return response.data;
};
const cierresCcService = {
getAllCierres,
getUltimoCierre,
crearCierre,
reabrirCierre,
getHistorialCierre,
};
export default cierresCcService;

View File

@@ -0,0 +1,49 @@
import axios from 'axios';
export interface ParsedApiError {
/** Mensaje listo para mostrar al usuario. */
message: string;
/** True si el error es un bloqueo por período cerrado (codigo PERIODO_CERRADO_BLOQUEO_OPERACION). */
isPeriodoCerrado: boolean;
/** Código semántico del backend, si vino. */
codigo?: string;
}
/**
* Parsea el body de un error de Axios siguiendo los shapes que devuelve la API:
* - ExceptionHandlerMiddleware: { codigo, mensaje, idCierre?, fechaCorte? } (period-cerrado)
* - Service tuple wrapped: { message } (legacy, inglés)
* - Service tuple nuevo: { mensaje } (español)
* Si nada matchea, devuelve el fallback recibido.
*/
export function parseApiError(err: unknown, fallback = 'Ocurrió un error inesperado.'): ParsedApiError {
if (!axios.isAxiosError(err) || !err.response?.data) {
return { message: fallback, isPeriodoCerrado: false };
}
const data = err.response.data as Record<string, unknown>;
const codigo = typeof data.codigo === 'string' ? data.codigo : undefined;
if (codigo === 'PERIODO_CERRADO_BLOQUEO_OPERACION') {
const mensajeBackend = typeof data.mensaje === 'string' ? data.mensaje : '';
const fechaCorteRaw = typeof data.fechaCorte === 'string' ? data.fechaCorte : '';
const fechaFormateada = fechaCorteRaw
? new Date(fechaCorteRaw).toLocaleDateString('es-AR')
: '';
const fallbackPeriodo = fechaFormateada
? `El período está cerrado al ${fechaFormateada}. No se permiten modificaciones sobre fechas anteriores o iguales a la fecha de corte.`
: 'El período está cerrado. No se permiten modificaciones sobre la fecha indicada.';
return {
message: mensajeBackend || fallbackPeriodo,
isPeriodoCerrado: true,
codigo,
};
}
const mensaje =
(typeof data.mensaje === 'string' && data.mensaje) ||
(typeof data.message === 'string' && data.message) ||
fallback;
return { message: mensaje, isPeriodoCerrado: false, codigo };
}