- Backend API:
Autenticación y autorización básicas con JWT implementadas.
Cambio de contraseña funcional.
Módulo "Tipos de Pago" (CRUD completo) implementado en el backend (Controlador, Servicio, Repositorio) usando Dapper, transacciones y con lógica de historial.
Se incluyen permisos en el token JWT.
- Frontend React:
Estructura base con Vite, TypeScript, MUI.
Contexto de autenticación (AuthContext) que maneja el estado del usuario y el token.
Página de Login.
Modal de Cambio de Contraseña (forzado y opcional).
Hook usePermissions para verificar permisos.
Página GestionarTiposPagoPage con tabla, paginación, filtro, modal para crear/editar, y menú de acciones, respetando permisos.
Layout principal (MainLayout) con navegación por Tabs (funcionalidad básica de navegación).
Estructura de enrutamiento (AppRoutes) que maneja rutas públicas, protegidas y anidadas para módulos.
This commit is contained in:
2025-05-07 13:41:18 -03:00
parent da7b544372
commit 5c4b961073
49 changed files with 2552 additions and 491 deletions

View File

@@ -1,23 +0,0 @@
import React from 'react';
import { Typography, Container } from '@mui/material';
// import { useLocation } from 'react-router-dom'; // Para obtener el estado 'firstLogin'
const ChangePasswordPage: React.FC = () => {
// const location = useLocation();
// const isFirstLogin = location.state?.firstLogin;
return (
<Container>
<Typography variant="h4" component="h1" gutterBottom>
Cambiar Contraseña
</Typography>
{/* {isFirstLogin && <Alert severity="warning">Debes cambiar tu contraseña inicial.</Alert>} */}
{/* Aquí irá el formulario de cambio de contraseña */}
<Typography variant="body1">
Formulario de cambio de contraseña irá aquí...
</Typography>
</Container>
);
};
export default ChangePasswordPage;

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { Typography, Container, Button } from '@mui/material';
import { useAuth } from '../contexts/AuthContext';
const ChangePasswordPagePlaceholder: React.FC = () => {
const { setShowForcedPasswordChangeModal } = useAuth();
return (
<Container>
<Typography variant="h4" component="h1" gutterBottom>
Cambiar Contraseña (Página)
</Typography>
<Typography>
La funcionalidad de cambio de contraseña ahora se maneja principalmente a través de un modal.
</Typography>
<Button onClick={() => setShowForcedPasswordChangeModal(true)}>
Abrir Modal de Cambio de Contraseña
</Button>
</Container>
);
};
export default ChangePasswordPagePlaceholder;

View File

@@ -0,0 +1,70 @@
// src/pages/contables/ContablesIndexPage.tsx
import React, { useState, useEffect } from 'react';
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 = [
{ label: 'Tipos de Pago', path: 'tipos-pago' }, // Se convertirá en /contables/tipos-pago
// { label: 'Pagos', path: 'pagos' }, // Ejemplo de otra sub-pestaña futura
// { label: 'Créditos/Débitos', path: 'creditos-debitos' },
];
const ContablesIndexPage: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const [selectedSubTab, setSelectedSubTab] = useState<number | false>(false);
useEffect(() => {
const currentBasePath = '/contables';
const subPath = location.pathname.startsWith(currentBasePath + '/')
? location.pathname.substring(currentBasePath.length + 1)
: (location.pathname === currentBasePath ? contablesSubModules[0]?.path : undefined);
const activeTabIndex = contablesSubModules.findIndex(
(subModule) => subModule.path === subPath
);
if (activeTabIndex !== -1) {
setSelectedSubTab(activeTabIndex);
} else {
if (location.pathname === currentBasePath && contablesSubModules.length > 0) {
navigate(contablesSubModules[0].path, { replace: true });
setSelectedSubTab(0);
} else {
setSelectedSubTab(false);
}
}
}, [location.pathname, navigate]);
const handleSubTabChange = (_event: React.SyntheticEvent, newValue: number) => {
setSelectedSubTab(newValue);
navigate(contablesSubModules[newValue].path);
};
return (
<Box>
<Typography variant="h5" gutterBottom>Módulo Contable</Typography>
<Paper square elevation={1}>
<Tabs
value={selectedSubTab}
onChange={handleSubTabChange}
indicatorColor="primary"
textColor="primary"
variant="scrollable"
scrollButtons="auto"
aria-label="sub-módulos contables"
>
{contablesSubModules.map((subModule) => (
<Tab key={subModule.path} label={subModule.label} />
))}
</Tabs>
</Paper>
<Box sx={{ pt: 2 }}>
<Outlet /> {/* Aquí se renderizarán GestionarTiposPagoPage, etc. */}
</Box>
</Box>
);
};
export default ContablesIndexPage;

View File

@@ -0,0 +1,242 @@
// src/pages/configuracion/GestionarTiposPagoPage.tsx
import React, { useState, useEffect, useCallback } from 'react';
import {
Box, Typography, TextField, Button, Paper, IconButton, Menu, MenuItem,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination,
CircularProgress, Alert
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import tipoPagoService from '../../services/tipoPagoService';
import type { TipoPago } from '../../models/Entities/TipoPago';
import type { CreateTipoPagoDto } from '../../models/dtos/tiposPago/CreateTipoPagoDto';
import type { UpdateTipoPagoDto } from '../../models/dtos/tiposPago/UpdateTipoPagoDto';
import TipoPagoFormModal from '../../components/Modals/TipoPagoFormModal';
import axios from 'axios';
import { usePermissions } from '../../hooks/usePermissions';
const GestionarTiposPagoPage: React.FC = () => {
const [tiposPago, setTiposPago] = useState<TipoPago[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filtroNombre, setFiltroNombre] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [editingTipoPago, setEditingTipoPago] = useState<TipoPago | null>(null);
const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null);
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(5);
// Para el menú contextual de cada fila
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [selectedTipoPagoRow, setSelectedTipoPagoRow] = useState<TipoPago | null>(null);
const { tienePermiso, isSuperAdmin } = usePermissions(); // Obtener también isSuperAdmin
const puedeCrear = isSuperAdmin || tienePermiso("CT002");
const puedeModificar = isSuperAdmin || tienePermiso("CT003");
const puedeEliminar = isSuperAdmin || tienePermiso("CT004");
const cargarTiposPago = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await tipoPagoService.getAllTiposPago(filtroNombre);
setTiposPago(data);
} catch (err) {
console.error(err);
setError('Error al cargar los tipos de pago.');
} finally {
setLoading(false);
}
}, [filtroNombre]);
useEffect(() => {
cargarTiposPago();
}, [cargarTiposPago]);
const handleOpenModal = (tipoPago?: TipoPago) => {
setEditingTipoPago(tipoPago || null);
setApiErrorMessage(null);
setModalOpen(true);
};
const handleCloseModal = () => {
setModalOpen(false);
setEditingTipoPago(null);
};
const handleSubmitModal = async (data: CreateTipoPagoDto | UpdateTipoPagoDto) => {
setApiErrorMessage(null); // Limpiar error previo
try {
if (editingTipoPago && 'idTipoPago' in data) { // Es Update
await tipoPagoService.updateTipoPago(editingTipoPago.idTipoPago, data as UpdateTipoPagoDto);
} else { // Es Create
await tipoPagoService.createTipoPago(data as CreateTipoPagoDto);
}
cargarTiposPago(); // Recargar lista
// onClose se llama desde el modal en caso de éxito
} catch (err: any) {
console.error("Error en submit modal (padre):", err);
if (axios.isAxiosError(err) && err.response) {
setApiErrorMessage(err.response.data?.message || 'Error al guardar.');
} else {
setApiErrorMessage('Ocurrió un error inesperado al guardar.');
}
throw err; // Re-lanzar para que el modal sepa que hubo error y no se cierre
}
};
const handleDelete = async (id: number) => {
if (window.confirm('¿Está seguro de que desea eliminar este tipo de pago?')) {
setApiErrorMessage(null);
try {
await tipoPagoService.deleteTipoPago(id);
cargarTiposPago();
} catch (err: any) {
console.error(err);
if (axios.isAxiosError(err) && err.response) {
setApiErrorMessage(err.response.data?.message || 'Error al eliminar.');
} else {
setApiErrorMessage('Ocurrió un error inesperado al eliminar.');
}
}
}
handleMenuClose();
};
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, tipoPago: TipoPago) => {
setAnchorEl(event.currentTarget);
setSelectedTipoPagoRow(tipoPago);
};
const handleMenuClose = () => {
setAnchorEl(null);
setSelectedTipoPagoRow(null);
};
const handleChangePage = (_event: unknown, newPage: number) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0);
};
const displayData = tiposPago.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage);
return (
<Box sx={{ p: 2 }}>
<Typography variant="h4" gutterBottom>
Gestionar Tipos de Pago
</Typography>
<Paper sx={{ p: 2, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<TextField
label="Filtrar por Nombre"
variant="outlined"
size="small"
value={filtroNombre}
onChange={(e) => setFiltroNombre(e.target.value)}
// sx={{ flexGrow: 1 }} // Opcional, para que ocupe más espacio
/>
{/* El botón de búsqueda se activa al cambiar el texto, pero puedes añadir uno explícito */}
{/* <Button variant="contained" onClick={cargarTiposPago}>Buscar</Button> */}
</Box>
{puedeCrear && (
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => handleOpenModal()}
sx={{ mb: 2 }}
>
Agregar Nuevo Tipo
</Button>
)}
</Paper>
{loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>}
{error && !loading && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>}
{apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>}
{!loading && !error && (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Nombre</TableCell>
<TableCell>Detalle</TableCell>
<TableCell align="right">Acciones</TableCell>
</TableRow>
</TableHead>
<TableBody>
{displayData.length === 0 && !loading ? (
<TableRow><TableCell colSpan={3} align="center">No se encontraron tipos de pago.</TableCell></TableRow>
) : (
displayData.map((tipo) => (
<TableRow key={tipo.idTipoPago}>
<TableCell>{tipo.nombre}</TableCell>
<TableCell>{tipo.detalle || '-'}</TableCell>
<TableCell align="right">
<IconButton
onClick={(e) => handleMenuOpen(e, tipo)}
disabled={!puedeModificar && !puedeEliminar}
>
<MoreVertIcon />
</IconButton>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
<TablePagination
rowsPerPageOptions={[5, 10, 25]}
component="div"
count={tiposPago.length}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
labelRowsPerPage="Filas por página:"
/>
</TableContainer>
)}
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
>
{puedeModificar && (
<MenuItem onClick={() => { handleOpenModal(selectedTipoPagoRow!); handleMenuClose(); }}>
Modificar
</MenuItem>
)}
{puedeEliminar && (
<MenuItem onClick={() => handleDelete(selectedTipoPagoRow!.idTipoPago)}>
Eliminar
</MenuItem>
)}
{/* Si no tiene ningún permiso, el menú podría estar vacío o no mostrarse */}
{(!puedeModificar && !puedeEliminar) && <MenuItem disabled>Sin acciones</MenuItem>}
</Menu>
<TipoPagoFormModal
open={modalOpen}
onClose={handleCloseModal}
onSubmit={handleSubmitModal}
initialData={editingTipoPago}
errorMessage={apiErrorMessage}
clearErrorMessage={() => setApiErrorMessage(null)}
/>
</Box>
);
};
export default GestionarTiposPagoPage;

View File

@@ -0,0 +1,7 @@
import React from 'react';
import { Typography } from '@mui/material';
const CanillasPage: React.FC = () => {
return <Typography variant="h6">Página de Gestión de Canillas</Typography>;
};
export default CanillasPage;

View File

@@ -0,0 +1,7 @@
import React from 'react';
import { Typography } from '@mui/material';
const CtrlDevolucionesPage: React.FC = () => {
return <Typography variant="h6">Página de Gestión del Control de Devoluciones</Typography>;
};
export default CtrlDevolucionesPage;

View File

@@ -0,0 +1,88 @@
// src/pages/distribucion/DistribucionIndexPage.tsx
import React, { useState, useEffect } from 'react';
import { Box, Tabs, Tab, Paper, Typography } from '@mui/material';
import { Outlet, useNavigate, useLocation, Link as RouterLink } from 'react-router-dom';
// Define las sub-pestañas del módulo Distribución
// El path es relativo a la ruta base del módulo (ej: /distribucion)
const distribucionSubModules = [
{ label: 'E/S Canillas', path: 'es-canillas' }, // Se convertirá en /distribucion/es-canillas
{ label: 'Ctrl. Devoluciones', path: 'control-devoluciones' },
{ label: 'E/S Distribuidores', path: 'es-distribuidores' },
{ label: 'Salidas Otros Dest.', path: 'salidas-otros-destinos' },
{ label: 'Canillas', path: 'canillas' },
{ label: 'Distribuidores', path: 'distribuidores' },
{ label: 'Publicaciones', path: 'publicaciones' },
{ label: 'Otros Destinos', path: 'otros-destinos' },
{ label: 'Zonas', path: 'zonas' },
{ label: 'Empresas', path: 'empresas' },
];
const DistribucionIndexPage: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const [selectedSubTab, setSelectedSubTab] = useState<number | false>(false);
// Sincronizar el sub-tab con la URL actual
useEffect(() => {
// location.pathname será algo como /distribucion/canillas
// Necesitamos extraer la última parte para compararla con los paths de subSubModules
const currentBasePath = '/distribucion'; // Ruta base de este módulo
const subPath = location.pathname.startsWith(currentBasePath + '/')
? location.pathname.substring(currentBasePath.length + 1)
: (location.pathname === currentBasePath ? distribucionSubModules[0]?.path : undefined); // Si es /distribucion, selecciona el primero
const activeTabIndex = distribucionSubModules.findIndex(
(subModule) => subModule.path === subPath
);
if (activeTabIndex !== -1) {
setSelectedSubTab(activeTabIndex);
} else {
// Si no coincide ninguna sub-ruta, pero estamos en /distribucion, ir al primer tab
if (location.pathname === currentBasePath && distribucionSubModules.length > 0) {
navigate(distribucionSubModules[0].path, { replace: true }); // Navegar a la primera sub-ruta
setSelectedSubTab(0);
} else {
setSelectedSubTab(false); // Ningún sub-tab activo
}
}
}, [location.pathname, navigate]);
const handleSubTabChange = (_event: React.SyntheticEvent, newValue: number) => {
setSelectedSubTab(newValue);
navigate(distribucionSubModules[newValue].path); // Navega a la sub-ruta (ej: 'canillas')
};
return (
<Box>
<Typography variant="h5" gutterBottom>Módulo de Distribución</Typography>
<Paper square elevation={1}>
<Tabs
value={selectedSubTab}
onChange={handleSubTabChange}
indicatorColor="primary"
textColor="primary"
variant="scrollable"
scrollButtons="auto"
aria-label="sub-módulos de distribución"
>
{distribucionSubModules.map((subModule) => (
// Usar RouterLink para que el tab se comporte como un enlace y actualice la URL
// La navegación real la manejamos con navigate en handleSubTabChange
// para poder actualizar el estado del tab seleccionado.
// Podríamos usar `component={RouterLink} to={subModule.path}` también,
// pero manejarlo con navigate da más control sobre el estado.
<Tab key={subModule.path} label={subModule.label} />
))}
</Tabs>
</Paper>
<Box sx={{ pt: 2 }}> {/* Padding para el contenido de la sub-pestaña */}
{/* Outlet renderizará el componente de la sub-ruta activa (ej: CanillasPage) */}
<Outlet />
</Box>
</Box>
);
};
export default DistribucionIndexPage;

View File

@@ -0,0 +1,7 @@
import React from 'react';
import { Typography } from '@mui/material';
const DistribuidoresPage: React.FC = () => {
return <Typography variant="h6">Página de Gestión de Distribuidores</Typography>;
};
export default DistribuidoresPage;

View File

@@ -0,0 +1,7 @@
import React from 'react';
import { Typography } from '@mui/material';
const ESCanillasPage: React.FC = () => {
return <Typography variant="h6">Página de Gestión de E/S de Canillas</Typography>;
};
export default ESCanillasPage;

View File

@@ -0,0 +1,7 @@
import React from 'react';
import { Typography } from '@mui/material';
const ESDistribuidoresPage: React.FC = () => {
return <Typography variant="h6">Página de Gestión de E/S de Distribuidores</Typography>;
};
export default ESDistribuidoresPage;

View File

@@ -0,0 +1,7 @@
import React from 'react';
import { Typography } from '@mui/material';
const EmpresasPage: React.FC = () => {
return <Typography variant="h6">Página de Gestión de Empresas</Typography>;
};
export default EmpresasPage;

View File

@@ -0,0 +1,7 @@
import React from 'react';
import { Typography } from '@mui/material';
const OtrosDestinosPage: React.FC = () => {
return <Typography variant="h6">Página de Gestión de Otros Destinos</Typography>;
};
export default OtrosDestinosPage;

View File

@@ -0,0 +1,7 @@
import React from 'react';
import { Typography } from '@mui/material';
const PublicacionesPage: React.FC = () => {
return <Typography variant="h6">Página de Gestión de Publicaciones</Typography>;
};
export default PublicacionesPage;

View File

@@ -0,0 +1,7 @@
import React from 'react';
import { Typography } from '@mui/material';
const SalidastrosDestinosPage: React.FC = () => {
return <Typography variant="h6">Página de Gestión de Salidas a Otros Destinos</Typography>;
};
export default SalidastrosDestinosPage;

View File

@@ -0,0 +1,7 @@
import React from 'react';
import { Typography } from '@mui/material';
const ZonasPage: React.FC = () => {
return <Typography variant="h6">Página de Gestión de Zonas</Typography>;
};
export default ZonasPage;

View File

@@ -1,13 +1,11 @@
import React, { useState } from 'react';
import axios from 'axios'; // Importar axios
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import apiClient from '../services/apiClient'; // Nuestro cliente axios
import type { LoginRequestDto } from '../models/dtos/LoginRequestDto'; // Usar type
import type { LoginResponseDto } from '../models/dtos/LoginResponseDto'; // Usar type
// Importaciones de Material UI
import { Container, TextField, Button, Typography, Box, Alert } from '@mui/material';
import authService from '../services/authService';
const LoginPage: React.FC = () => {
const [username, setUsername] = useState('');
@@ -15,7 +13,6 @@ const LoginPage: React.FC = () => {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -25,25 +22,17 @@ const LoginPage: React.FC = () => {
const loginData: LoginRequestDto = { Username: username, Password: password };
try {
const response = await apiClient.post<LoginResponseDto>('/auth/login', loginData);
login(response.data); // Guardar token y estado de usuario en el contexto
// TODO: Verificar si response.data.DebeCambiarClave es true y redirigir
// a '/change-password' si es necesario.
// if (response.data.DebeCambiarClave) {
// navigate('/change-password', { state: { firstLogin: true } }); // Pasar estado si es necesario
// } else {
navigate('/'); // Redirigir a la página principal
// }
const response = await authService.login(loginData);
login(response);
} catch (err: any) {
console.error("Login error:", err);
if (axios.isAxiosError(err) && err.response) {
// Intenta obtener el mensaje de error de la API, si no, usa uno genérico
setError(err.response.data?.message || 'Error al iniciar sesión. Verifique sus credenciales.');
} else {
setError('Ocurrió un error inesperado.');
}
// Importante: NO llamar a navigate('/') aquí en el catch,
// porque el estado isAuthenticated no habrá cambiado a true
} finally {
setLoading(false);
}