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

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