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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
235
Frontend/src/components/Modals/Contables/NuevoCierreModal.tsx
Normal file
235
Frontend/src/components/Modals/Contables/NuevoCierreModal.tsx
Normal 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;
|
||||
@@ -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 } = {};
|
||||
|
||||
140
Frontend/src/components/Modals/Contables/ReabrirCierreModal.tsx
Normal file
140
Frontend/src/components/Modals/Contables/ReabrirCierreModal.tsx
Normal 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;
|
||||
@@ -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 } = {};
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
6
Frontend/src/models/dtos/Contables/CrearCierreDto.ts
Normal file
6
Frontend/src/models/dtos/Contables/CrearCierreDto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface CrearCierreDto {
|
||||
idDistribuidor: number;
|
||||
idEmpresa: number;
|
||||
fechaCorte: string; // "yyyy-MM-dd"
|
||||
justificacion?: string | null;
|
||||
}
|
||||
4
Frontend/src/models/dtos/Contables/ReabrirCierreDto.ts
Normal file
4
Frontend/src/models/dtos/Contables/ReabrirCierreDto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface ReabrirCierreDto {
|
||||
// Min 10, max 500 caracteres (validado en backend con DataAnnotations).
|
||||
justificacion: string;
|
||||
}
|
||||
6
Frontend/src/models/dtos/Contables/UltimoCierreDto.ts
Normal file
6
Frontend/src/models/dtos/Contables/UltimoCierreDto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface UltimoCierreDto {
|
||||
idCierre: number;
|
||||
fechaCorte: string; // "yyyy-MM-dd"
|
||||
saldoCierre: number;
|
||||
estado: 'Activo' | 'Anulado';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export interface SaldoDto {
|
||||
monto: number;
|
||||
}
|
||||
@@ -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([]);
|
||||
|
||||
448
Frontend/src/pages/Contables/CierresCuentaCorrientePage.tsx
Normal file
448
Frontend/src/pages/Contables/CierresCuentaCorrientePage.tsx
Normal 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;
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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) */}
|
||||
|
||||
@@ -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;
|
||||
75
Frontend/src/services/Contables/cierresCcService.ts
Normal file
75
Frontend/src/services/Contables/cierresCcService.ts
Normal 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;
|
||||
49
Frontend/src/utils/apiErrorParser.ts
Normal file
49
Frontend/src/utils/apiErrorParser.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user