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:
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;
|
||||
Reference in New Issue
Block a user