- 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

File diff suppressed because it is too large Load Diff

View File

@@ -15,12 +15,14 @@
"@mui/icons-material": "^7.0.2",
"@mui/material": "^7.0.2",
"axios": "^1.9.0",
"jwt-decode": "^4.0.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.5.3"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@types/jwt-decode": "^2.2.1",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",

View File

@@ -0,0 +1,205 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext';
import authService from '../services/authService';
import type { ChangePasswordRequestDto } from '../models/dtos/ChangePasswordRequestDto';
import axios from 'axios';
import { Modal, Box, Typography, TextField, Button, Alert, CircularProgress, Backdrop } from '@mui/material';
const style = {
position: 'absolute' as 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 400,
bgcolor: 'background.paper',
border: '2px solid #000',
boxShadow: 24,
p: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
};
interface ChangePasswordModalProps {
open: boolean;
onClose: (success: boolean) => void;
isFirstLogin?: boolean;
}
const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({ open, onClose, isFirstLogin }) => {
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmNewPassword, setConfirmNewPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// Ya no necesitamos passwordChangeCompleted ni contextIsFirstLogin aquí
const { user, logout } = useAuth();
useEffect(() => {
if (open) {
setCurrentPassword('');
setNewPassword('');
setConfirmNewPassword('');
setError(null);
setSuccess(null);
setLoading(false); // Asegurarse de resetear loading también
}
}, [open]);
// Esta función se llama al hacer clic en el botón Cancelar
const handleCancelClick = () => {
onClose(false); // Notifica al padre (MainLayout) que se canceló
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
setSuccess(null);
if (newPassword !== confirmNewPassword) {
setError('La nueva contraseña y la confirmación no coinciden.');
return;
}
if (newPassword.length < 6) {
setError('La nueva contraseña debe tener al menos 6 caracteres.');
return;
}
if (user && user.username === newPassword) {
setError('La nueva contraseña no puede ser igual al nombre de usuario.');
return;
}
setLoading(true);
const changePasswordData: ChangePasswordRequestDto = {
currentPassword,
newPassword,
confirmNewPassword,
};
try {
await authService.changePassword(changePasswordData);
setSuccess('Contraseña cambiada exitosamente.');
setTimeout(() => {
onClose(true); // Notifica al padre (MainLayout) que fue exitoso
}, 1500);
} catch (err: any) {
console.error("Change password error:", err);
let errorMessage = 'Ocurrió un error inesperado al cambiar la contraseña.';
if (axios.isAxiosError(err) && err.response) {
errorMessage = err.response.data?.message || errorMessage;
if (err.response.status === 401) {
logout(); // Desloguear si el token es inválido
onClose(false); // Notificar cierre sin éxito
}
}
setError(errorMessage);
setLoading(false); // Asegurarse de quitar loading en caso de error
}
// No poner setLoading(false) en el finally si quieres que el botón siga deshabilitado durante el success
};
return (
<Modal
open={open}
onClose={(_event, reason) => { // onClose del Modal de MUI (para backdrop y Escape)
if (reason === "backdropClick" && isFirstLogin) {
return; // No permitir cerrar con backdrop si es el primer login
}
onClose(false); // Llamar a la prop onClose (que va a handleModalClose en MainLayout)
}}
disableEscapeKeyDown={isFirstLogin} // Deshabilitar Escape si es primer login
aria-labelledby="change-password-modal-title"
aria-describedby="change-password-modal-description"
closeAfterTransition
slots={{ backdrop: Backdrop }}
slotProps={{
backdrop: {
timeout: 500,
sx: { backdropFilter: 'blur(3px)' }
},
}}
>
<Box sx={style}>
<Typography id="change-password-modal-title" variant="h6" component="h2">
Cambiar Contraseña
</Typography>
{isFirstLogin && (
<Alert severity="warning" sx={{ mt: 2, width: '100%' }}>
Por seguridad, debes cambiar tu contraseña inicial.
</Alert>
)}
<Box component="form" onSubmit={handleSubmit} noValidate sx={{ mt: 1, width: '100%' }}>
{/* ... TextFields ... */}
<TextField
margin="normal"
required
fullWidth
name="currentPassword"
label="Contraseña Actual"
type="password"
id="currentPasswordModal"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
disabled={loading || !!success}
autoFocus
/>
<TextField
margin="normal"
required
fullWidth
name="newPassword"
label="Nueva Contraseña"
type="password"
id="newPasswordModal"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={loading || !!success}
/>
<TextField
margin="normal"
required
fullWidth
name="confirmNewPassword"
label="Confirmar Nueva Contraseña"
type="password"
id="confirmNewPasswordModal"
value={confirmNewPassword}
onChange={(e) => setConfirmNewPassword(e.target.value)}
disabled={loading || !!success}
error={newPassword !== confirmNewPassword && confirmNewPassword !== ''}
helperText={newPassword !== confirmNewPassword && confirmNewPassword !== '' ? 'Las contraseñas no coinciden' : ''}
/>
{error && (
<Alert severity="error" sx={{ mt: 2, width: '100%' }}>
{error}
</Alert>
)}
{success && (
<Alert severity="success" sx={{ mt: 2, width: '100%' }}>
{success}
</Alert>
)}
{/* Un solo grupo de botones */}
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
{/* El botón de cancelar llama a handleCancelClick */}
{/* Se podría ocultar si isFirstLogin es true y no queremos que el usuario cancele */}
{/* {!isFirstLogin && ( */}
<Button onClick={handleCancelClick} disabled={loading || !!success} color="secondary">
{isFirstLogin ? "Cancelar y Salir" : "Cancelar"}
</Button>
{/* )} */}
<Button type="submit" variant="contained" disabled={loading || !!success}>
{loading ? <CircularProgress size={24} /> : 'Cambiar Contraseña'}
</Button>
</Box>
</Box>
</Box>
</Modal>
);
};
export default ChangePasswordModal;

View File

@@ -0,0 +1,129 @@
import React, { useState, useEffect } from 'react';
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert } from '@mui/material';
import type { TipoPago } from '../../models/Entities/TipoPago';
import type { CreateTipoPagoDto } from '../../models/dtos/tiposPago/CreateTipoPagoDto';
const modalStyle = {
position: 'absolute' as 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 400,
bgcolor: 'background.paper',
border: '2px solid #000',
boxShadow: 24,
p: 4,
};
interface TipoPagoFormModalProps {
open: boolean;
onClose: () => void;
onSubmit: (data: CreateTipoPagoDto | (CreateTipoPagoDto & { idTipoPago: number })) => Promise<void>; // Puede ser para crear o actualizar
initialData?: TipoPago | null; // Datos para editar
errorMessage?: string | null;
clearErrorMessage: () => void;
}
const TipoPagoFormModal: React.FC<TipoPagoFormModalProps> = ({
open,
onClose,
onSubmit,
initialData,
errorMessage,
clearErrorMessage
}) => {
const [nombre, setNombre] = useState('');
const [detalle, setDetalle] = useState('');
const [loading, setLoading] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
const isEditing = Boolean(initialData);
useEffect(() => {
if (open) {
setNombre(initialData?.nombre || '');
setDetalle(initialData?.detalle || '');
setLocalError(null); // Limpiar errores locales al abrir
clearErrorMessage(); // Limpiar errores del padre
}
}, [open, initialData, clearErrorMessage]);
const handleInputChange = () => {
if (localError) setLocalError(null);
if (errorMessage) clearErrorMessage();
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setLocalError(null);
clearErrorMessage();
if (!nombre.trim()) {
setLocalError('El nombre es obligatorio.');
return;
}
setLoading(true);
try {
const dataToSubmit: CreateTipoPagoDto = { nombre, detalle: detalle || undefined };
if (isEditing && initialData) {
await onSubmit({ ...dataToSubmit, idTipoPago: initialData.idTipoPago });
} else {
await onSubmit(dataToSubmit);
}
onClose(); // Cerrar modal en éxito
} catch (error: any) {
// El error de la API ya se debería manejar en el componente padre
// y pasarse a través de 'errorMessage', pero podemos loggear aquí si es un error inesperado
console.error("Error en submit de TipoPagoFormModal:", error);
// No seteamos localError aquí si el padre maneja 'errorMessage'
} finally {
setLoading(false);
}
};
return (
<Modal open={open} onClose={onClose}>
<Box sx={modalStyle}>
<Typography variant="h6" component="h2">
{isEditing ? 'Editar Tipo de Pago' : 'Agregar Nuevo Tipo de Pago'}
</Typography>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
<TextField
label="Nombre"
fullWidth
required
value={nombre}
onChange={(e) => { setNombre(e.target.value); handleInputChange(); }}
margin="normal"
error={!!localError && nombre.trim() === ''}
helperText={localError && nombre.trim() === '' ? localError : ''}
disabled={loading}
/>
<TextField
label="Detalle (Opcional)"
fullWidth
value={detalle}
onChange={(e) => { setDetalle(e.target.value); handleInputChange();}}
margin="normal"
multiline
rows={3}
disabled={loading}
/>
{errorMessage && <Alert severity="error" sx={{ mt: 1 }}>{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" disabled={loading}>
{loading ? <CircularProgress size={24} /> : (isEditing ? 'Guardar Cambios' : 'Agregar')}
</Button>
</Box>
</Box>
</Box>
</Modal>
);
};
export default TipoPagoFormModal;

View File

@@ -1,13 +1,41 @@
import React, { createContext, useState, useContext, useEffect } from 'react';
import type { ReactNode } from 'react'; // Importar como tipo
import React, { createContext, useState, useContext, type ReactNode, useEffect } from 'react';
import type { LoginResponseDto } from '../models/dtos/LoginResponseDto';
import { jwtDecode } from 'jwt-decode';
// Interfaz para los datos del usuario que guardaremos en el contexto
export interface UserContextData {
userId: number;
username: string;
nombreCompleto: string;
esSuperAdmin: boolean;
debeCambiarClave: boolean;
idPerfil: number;
permissions: string[]; // Guardamos los codAcc
}
// Interfaz para el payload decodificado del JWT
interface DecodedJwtPayload {
sub: string; // User ID (viene como string)
name: string; // Username
given_name?: string; // Nombre (estándar, pero verifica tu token)
family_name?: string; // Apellido (estándar, pero verifica tu token)
role: string | string[]; // Puede ser uno o varios roles
idPerfil: string; // (viene como string)
debeCambiarClave: string; // (viene como string "True" o "False")
permission?: string | string[]; // Nuestros claims de permiso (codAcc)
[key: string]: any; // Para otros claims
}
interface AuthContextType {
isAuthenticated: boolean;
user: LoginResponseDto | null;
user: UserContextData | null; // Usar el tipo extendido
token: string | null;
isLoading: boolean; // Para saber si aún está verificando el token inicial
login: (userData: LoginResponseDto) => void;
isLoading: boolean;
showForcedPasswordChangeModal: boolean;
isPasswordChangeForced: boolean;
setShowForcedPasswordChangeModal: (show: boolean) => void;
passwordChangeCompleted: () => void;
login: (apiLoginResponse: LoginResponseDto) => void; // Recibe el DTO de la API
logout: () => void;
}
@@ -15,37 +43,68 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [user, setUser] = useState<LoginResponseDto | null>(null);
const [user, setUser] = useState<UserContextData | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true); // Empieza cargando
const [isLoading, setIsLoading] = useState<boolean>(true);
const [showForcedPasswordChangeModal, setShowForcedPasswordChangeModal] = useState<boolean>(false);
const [isPasswordChangeForced, setIsPasswordChangeForced] = useState<boolean>(false);
// Efecto para verificar token al cargar la app
useEffect(() => {
const storedToken = localStorage.getItem('authToken');
const storedUser = localStorage.getItem('authUser'); // Guardamos el usuario también
const processTokenAndSetUser = (jwtToken: string) => {
try {
const decodedToken = jwtDecode<DecodedJwtPayload>(jwtToken);
if (storedToken && storedUser) {
try {
// Aquí podrías añadir lógica para validar si el token aún es válido (ej: decodificarlo)
// Por ahora, simplemente asumimos que si está, es válido.
const parsedUser: LoginResponseDto = JSON.parse(storedUser);
setToken(storedToken);
setUser(parsedUser);
setIsAuthenticated(true);
} catch (error) {
console.error("Error parsing stored user data", error);
logout(); // Limpia si hay error al parsear
// Verificar expiración (opcional, pero buena práctica aquí también)
const currentTime = Date.now() / 1000;
if (decodedToken.exp && decodedToken.exp < currentTime) {
console.warn("Token expirado al procesar.");
logout(); // Llama a logout que limpia todo
return;
}
let permissions: string[] = [];
if (decodedToken.permission) {
permissions = Array.isArray(decodedToken.permission) ? decodedToken.permission : [decodedToken.permission];
}
const userForContext: UserContextData = {
userId: parseInt(decodedToken.sub, 10),
username: decodedToken.name,
nombreCompleto: `${decodedToken.given_name || ''} ${decodedToken.family_name || ''}`.trim(),
esSuperAdmin: decodedToken.role === "SuperAdmin" || (Array.isArray(decodedToken.role) && decodedToken.role.includes("SuperAdmin")),
debeCambiarClave: decodedToken.debeCambiarClave?.toLowerCase() === 'true',
idPerfil: decodedToken.idPerfil ? parseInt(decodedToken.idPerfil, 10) : 0,
permissions: permissions,
};
setToken(jwtToken);
setUser(userForContext);
setIsAuthenticated(true);
localStorage.setItem('authToken', jwtToken);
localStorage.setItem('authUser', JSON.stringify(userForContext)); // Guardar el usuario procesado
// Lógica para el modal de cambio de clave
if (userForContext.debeCambiarClave) {
setShowForcedPasswordChangeModal(true);
setIsPasswordChangeForced(true);
}
} catch (error) {
console.error("Error al decodificar o procesar token:", error);
logout(); // Limpiar estado si el token es inválido
}
setIsLoading(false); // Termina la carga inicial
};
useEffect(() => {
setIsLoading(true);
const storedToken = localStorage.getItem('authToken');
if (storedToken) {
processTokenAndSetUser(storedToken);
}
setIsLoading(false);
}, []);
const login = (userData: LoginResponseDto) => {
localStorage.setItem('authToken', userData.Token);
localStorage.setItem('authUser', JSON.stringify(userData)); // Guardar datos de usuario
setToken(userData.Token);
setUser(userData);
setIsAuthenticated(true);
const login = (apiLoginResponse: LoginResponseDto) => {
processTokenAndSetUser(apiLoginResponse.token); // Procesar el token recibido
};
const logout = () => {
@@ -54,16 +113,36 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setToken(null);
setUser(null);
setIsAuthenticated(false);
setShowForcedPasswordChangeModal(false);
setIsPasswordChangeForced(false);
};
const passwordChangeCompleted = () => {
setShowForcedPasswordChangeModal(false);
setIsPasswordChangeForced(false);
// Importante: Si el cambio de clave afecta el claim "debeCambiarClave" en el token,
// idealmente el backend debería devolver un *nuevo token* después del cambio de clave.
// Si no lo hace, el token actual aún dirá que debe cambiar clave.
// Una solución simple es actualizar el estado local del usuario:
if (user) {
const updatedUser = { ...user, debeCambiarClave: false };
setUser(updatedUser);
localStorage.setItem('authUser', JSON.stringify(updatedUser));
}
};
return (
<AuthContext.Provider value={{ isAuthenticated, user, token, isLoading, login, logout }}>
<AuthContext.Provider value={{
isAuthenticated, user, token, isLoading,
showForcedPasswordChangeModal, isPasswordChangeForced,
setShowForcedPasswordChangeModal, passwordChangeCompleted,
login, logout
}}>
{children}
</AuthContext.Provider>
);
};
// Hook personalizado para usar el contexto fácilmente
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (context === undefined) {

View File

@@ -0,0 +1,26 @@
// src/hooks/usePermissions.ts
import { useAuth } from '../contexts/AuthContext';
export const usePermissions = () => {
const { user } = useAuth(); // user aquí es de tipo UserContextData | null
const tienePermiso = (codigoPermisoRequerido: string): boolean => {
if (!user) { // Si no hay usuario logueado
return false;
}
if (user.esSuperAdmin) { // SuperAdmin tiene todos los permisos
return true;
}
// Verificar si la lista de permisos del usuario incluye el código requerido
return user.permissions?.includes(codigoPermisoRequerido) ?? false;
};
// También puede exportar el objeto user completo si se necesita en otros lugares
// o propiedades específicas como idPerfil, esSuperAdmin.
return {
tienePermiso,
isSuperAdmin: user?.esSuperAdmin ?? false,
idPerfil: user?.idPerfil ?? 0,
currentUser: user
};
};

View File

@@ -1,46 +1,152 @@
import React from 'react';
import type { ReactNode } from 'react'; // Importar como tipo
import { Box, AppBar, Toolbar, Typography, Button } from '@mui/material';
import React, { type ReactNode, useState, useEffect } from 'react';
import { Box, AppBar, Toolbar, Typography, Button, Tabs, Tab, Paper } from '@mui/material';
import { useAuth } from '../contexts/AuthContext';
import ChangePasswordModal from '../components/ChangePasswordModal';
import { useNavigate, useLocation } from 'react-router-dom'; // Para manejar la navegación y la ruta actual
interface MainLayoutProps {
children: ReactNode; // Para renderizar las páginas hijas
children: ReactNode; // Esto será el <Outlet /> que renderiza las páginas del módulo
}
// Definir los módulos y sus rutas base
const modules = [
{ label: 'Inicio', path: '/' },
{ label: 'Distribución', path: '/distribucion' }, // Asumiremos rutas base como /distribucion, /contables, etc.
{ label: 'Contables', path: '/contables' },
{ label: 'Impresión', path: '/impresion' },
{ label: 'Reportes', path: '/reportes' },
{ label: 'Radios', path: '/radios' },
{ label: 'Usuarios', path: '/usuarios' },
];
const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
const { user, logout } = useAuth();
const {
user,
logout,
showForcedPasswordChangeModal,
isPasswordChangeForced,
passwordChangeCompleted,
setShowForcedPasswordChangeModal,
isAuthenticated
} = useAuth();
const navigate = useNavigate();
const location = useLocation(); // Para obtener la ruta actual
// Estado para el tab seleccionado
const [selectedTab, setSelectedTab] = useState<number | false>(false);
// Efecto para sincronizar el tab seleccionado con la ruta actual
useEffect(() => {
const currentModulePath = modules.findIndex(module =>
location.pathname === module.path || (module.path !== '/' && location.pathname.startsWith(module.path + '/'))
);
if (currentModulePath !== -1) {
setSelectedTab(currentModulePath);
} else if (location.pathname === '/') {
setSelectedTab(0); // Seleccionar "Inicio" si es la raíz
} else {
setSelectedTab(false); // Ningún tab coincide (podría ser una sub-ruta no principal)
}
}, [location.pathname]);
const handleModalClose = (passwordChangedSuccessfully: boolean) => {
// ... (lógica de handleModalClose existente) ...
if (passwordChangedSuccessfully) {
passwordChangeCompleted();
} else {
if (isPasswordChangeForced) {
logout();
} else {
setShowForcedPasswordChangeModal(false);
}
}
};
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
setSelectedTab(newValue);
navigate(modules[newValue].path); // Navegar a la ruta base del módulo
};
// Si el modal de cambio de clave forzado está activo, no mostramos la navegación principal aún.
// El modal se superpone.
if (showForcedPasswordChangeModal && isPasswordChangeForced) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<ChangePasswordModal
open={showForcedPasswordChangeModal}
onClose={handleModalClose}
isFirstLogin={isPasswordChangeForced}
/>
{/* Podrías querer un fondo o layout mínimo aquí si el modal no es pantalla completa */}
</Box>
);
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<AppBar position="static">
<Toolbar>
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
Gestión Integral
Sistema de Gestión - El Día
</Typography>
{user && <Typography sx={{ mr: 2 }}>Hola, {user.Username}</Typography> }
{user && <Typography sx={{ mr: 2 }}>Hola, {user.nombreCompleto}</Typography>}
{isAuthenticated && !isPasswordChangeForced && (
<Button
color="inherit"
onClick={() => setShowForcedPasswordChangeModal(true)} // Ahora abre el modal
>
Cambiar Contraseña
</Button>
)}
<Button color="inherit" onClick={logout}>Cerrar Sesión</Button>
</Toolbar>
{/* Aquí iría el MaterialTabControl o similar para la navegación principal */}
{/* Navegación Principal por Módulos */}
<Paper square elevation={0} > {/* Usamos Paper para un fondo consistente para los Tabs */}
<Tabs
value={selectedTab}
onChange={handleTabChange}
indicatorColor="secondary" // O "primary"
textColor="inherit" // O "primary" / "secondary"
variant="scrollable" // Permite scroll si hay muchos tabs
scrollButtons="auto" // Muestra botones de scroll si es necesario
aria-label="módulos principales"
sx={{ backgroundColor: 'primary.main', color: 'white' }} // Color de fondo para los tabs
>
{modules.map((module) => (
<Tab key={module.path} label={module.label} />
))}
</Tabs>
</Paper>
</AppBar>
{/* Contenido del Módulo (renderizado por <Outlet /> en AppRoutes) */}
<Box
component="main"
sx={{
flexGrow: 1,
p: 3, // Padding
// Puedes añadir color de fondo si lo deseas
// backgroundColor: (theme) => theme.palette.background.default,
// overflowY: 'auto' // Si el contenido del módulo es muy largo
}}
>
{/* El contenido de la página actual se renderizará aquí */}
{children}
</Box>
{/* Aquí podría ir un Footer o StatusStrip */}
<Box component="footer" sx={{ p: 1, mt: 'auto', backgroundColor: 'primary.dark', color: 'white', textAlign: 'center' }}>
<Box component="footer" sx={{ p: 1, mt: 'auto', backgroundColor: 'primary.dark', color: 'white', textAlign: 'center' }}>
<Typography variant="body2">
{/* Replicar info del StatusStrip original */}
Usuario: {user?.Username} | Acceso: {user?.EsSuperAdmin ? 'Super Admin' : 'Perfil...'} | Versión: {/** Obtener versión **/}
Usuario: {user?.username} | Acceso: {user?.esSuperAdmin ? 'Super Admin' : `Perfil ID ${user?.userId}`} | Versión: {/* TODO: Obtener versión */}
</Typography>
</Box>
{/* Modal para cambio de clave opcional (no forzado) */}
{/* Si showForcedPasswordChangeModal es true pero isPasswordChangeForced es false,
se mostrará aquí también. */}
<ChangePasswordModal
open={showForcedPasswordChangeModal}
onClose={handleModalClose}
isFirstLogin={isPasswordChangeForced} // Esto controla el comportamiento del modal
/>
</Box>
);
};

View File

@@ -0,0 +1,5 @@
export interface TipoPago {
idTipoPago: number;
nombre: string;
detalle?: string; // El detalle es opcional
}

View File

@@ -0,0 +1,6 @@
// src/models/dtos/ChangePasswordRequestDto.ts
export interface ChangePasswordRequestDto {
currentPassword: string;
newPassword: string;
confirmNewPassword: string;
}

View File

@@ -1,10 +1,10 @@
// src/models/dtos/LoginResponseDto.ts
export interface LoginResponseDto {
Token: string;
UserId: number;
Username: string;
NombreCompleto: string;
EsSuperAdmin: boolean;
DebeCambiarClave: boolean;
token: string;
userId: number;
username: string;
nombreCompleto: string;
esSuperAdmin: boolean;
debeCambiarClave: boolean;
// Añade otros campos si los definiste en el DTO C#
}

View File

@@ -0,0 +1,4 @@
export interface CreateTipoPagoDto {
nombre: string;
detalle?: string;
}

View File

@@ -0,0 +1,4 @@
export interface UpdateTipoPagoDto {
nombre: string;
detalle?: string;
}

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);
}

View File

@@ -1,63 +1,125 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
// src/routes/AppRoutes.tsx
import React, { type JSX } from 'react';
import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom';
import LoginPage from '../pages/LoginPage';
import HomePage from '../pages/HomePage'; // Crearemos esta página simple
import HomePage from '../pages/HomePage';
import { useAuth } from '../contexts/AuthContext';
import ChangePasswordPage from '../pages/ChangePasswordPage'; // Crearemos esta
import MainLayout from '../layouts/MainLayout'; // Crearemos este
import MainLayout from '../layouts/MainLayout';
import { Typography } from '@mui/material';
// Componente para proteger rutas
// Distribución
import DistribucionIndexPage from '../pages/Distribucion/DistribucionIndexPage';
import ESCanillasPage from '../pages/Distribucion/ESCanillasPage';
import ControlDevolucionesPage from '../pages/Distribucion/ControlDevolucionesPage';
import ESDistribuidoresPage from '../pages/Distribucion/ESDistribuidoresPage';
import SalidasOtrosDestinosPage from '../pages/Distribucion/SalidasOtrosDestinosPage';
import CanillasPage from '../pages/Distribucion/CanillasPage';
import DistribuidoresPage from '../pages/Distribucion/DistribuidoresPage';
import PublicacionesPage from '../pages/Distribucion/PublicacionesPage';
import OtrosDestinosPage from '../pages/Distribucion/OtrosDestinosPage';
import ZonasPage from '../pages/Distribucion/ZonasPage';
import EmpresasPage from '../pages/Distribucion/EmpresasPage';
// Contables
import ContablesIndexPage from '../pages/Contables/ContablesIndexPage';
import GestionarTiposPagoPage from '../pages/Contables/GestionarTiposPagoPage'; // Asumiendo que lo moviste aquí
// --- ProtectedRoute y PublicRoute SIN CAMBIOS ---
const ProtectedRoute: React.FC<{ children: JSX.Element }> = ({ children }) => {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
// Muestra algo mientras verifica el token (ej: un spinner)
return <div>Cargando...</div>;
}
return isAuthenticated ? children : <Navigate to="/login" replace />;
const { isAuthenticated, isLoading } = useAuth();
// console.log("ProtectedRoute Check:", { path: window.location.pathname, isAuthenticated, isLoading });
if (isLoading) return null;
if (!isAuthenticated) {
// console.log("ProtectedRoute: Not authenticated, redirecting to /login");
return <Navigate to="/login" replace />;
}
// console.log("ProtectedRoute: Authenticated, rendering children");
return children;
};
// Componente para rutas públicas (redirige si ya está logueado)
const PublicRoute: React.FC<{ children: JSX.Element }> = ({ children }) => {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return <div>Cargando...</div>;
// console.log("PublicRoute Check:", { path: window.location.pathname, isAuthenticated, isLoading });
if (isLoading) return null;
if (isAuthenticated) {
// console.log("PublicRoute: Authenticated, redirecting to /");
return <Navigate to="/" replace />;
}
return !isAuthenticated ? children : <Navigate to="/" replace />;
// console.log("PublicRoute: Not authenticated, rendering children");
return children;
};
// --- Fin Protected/Public ---
const MainLayoutWrapper: React.FC = () => (
<MainLayout>
<Outlet />
</MainLayout>
);
// Placeholder simple
const PlaceholderPage: React.FC<{ moduleName: string }> = ({ moduleName }) => (
<Typography variant="h5" sx={{ p:2 }}>Página Principal del Módulo: {moduleName}</Typography>
);
const AppRoutes = () => {
return (
<BrowserRouter>
<Routes>
{/* Rutas Públicas */}
<Route path="/login" element={<PublicRoute><LoginPage /></PublicRoute>} />
<Route path="/change-password" element={<ProtectedRoute><ChangePasswordPage /></ProtectedRoute>} /> {/* Asumimos que se accede logueado */}
return (
<BrowserRouter>
<Routes> {/* Un solo <Routes> de nivel superior */}
<Route path="/login" element={<PublicRoute><LoginPage /></PublicRoute>} />
{/* Rutas Protegidas dentro del Layout Principal */}
<Route
path="/*" // Captura todas las demás rutas
element={
<ProtectedRoute>
<MainLayout> {/* Layout que tendrá la navegación principal */}
{/* Aquí irán las rutas de los módulos */}
<Routes>
<Route index element={<HomePage />} /> {/* Página por defecto al loguearse */}
{/* <Route path="/usuarios" element={<GestionUsuariosPage />} /> */}
{/* <Route path="/zonas" element={<GestionZonasPage />} /> */}
{/* ... otras rutas de módulos ... */}
<Route path="*" element={<Navigate to="/" replace />} /> {/* Redirige rutas no encontradas al home */}
</Routes>
</MainLayout>
</ProtectedRoute>
}
/>
</Routes>
</BrowserRouter>
);
{/* Rutas Protegidas que usan el MainLayout */}
<Route
path="/" // La ruta padre para todas las secciones protegidas
element={
<ProtectedRoute>
<MainLayoutWrapper/>
</ProtectedRoute>
}
>
{/* Rutas hijas que se renderizarán en el Outlet de MainLayoutWrapper */}
<Route index element={<HomePage />} /> {/* Para la ruta exacta "/" */}
{/* Módulo de Distribución (anidado) */}
<Route path="distribucion" element={<DistribucionIndexPage />}>
<Route index element={<Navigate to="es-canillas" replace />} />
<Route path="es-canillas" element={<ESCanillasPage />} />
<Route path="control-devoluciones" element={<ControlDevolucionesPage />} />
<Route path="es-distribuidores" element={<ESDistribuidoresPage />} />
<Route path="salidas-otros-destinos" element={<SalidasOtrosDestinosPage />} />
<Route path="canillas" element={<CanillasPage />} />
<Route path="distribuidores" element={<DistribuidoresPage />} />
<Route path="publicaciones" element={<PublicacionesPage />} />
<Route path="otros-destinos" element={<OtrosDestinosPage />} />
<Route path="zonas" element={<ZonasPage />} />
<Route path="empresas" element={<EmpresasPage />} />
</Route>
{/* Módulo Contable (anidado) */}
<Route path="contables" element={<ContablesIndexPage />}>
<Route index element={<Navigate to="tipos-pago" replace />} />
<Route path="tipos-pago" element={<GestionarTiposPagoPage />} />
{/* Futuras sub-rutas de contables aquí */}
</Route>
{/* Otros Módulos Principales (estos son "finales", no tienen más hijos) */}
<Route path="impresion" element={<PlaceholderPage moduleName="Impresión" />} />
<Route path="reportes" element={<PlaceholderPage moduleName="Reportes" />} />
<Route path="radios" element={<PlaceholderPage moduleName="Radios" />} />
{/* <Route path="usuarios" element={<PlaceholderPage moduleName="Usuarios" />} /> */}
{/* Ruta catch-all DENTRO del layout protegido */}
<Route path="*" element={<Navigate to="/" replace />} />
</Route> {/* Cierre de la ruta padre "/" */}
{/* Podrías tener un catch-all global aquí si una ruta no coincide EN ABSOLUTO,
pero el path="*" dentro de la ruta "/" ya debería manejar la mayoría de los casos
después de un login exitoso.
Si un usuario no autenticado intenta una ruta inválida, ProtectedRoute lo manda a /login.
*/}
{/* <Route path="*" element={<Navigate to="/login" replace />} /> */}
</Routes>
</BrowserRouter>
);
};
export default AppRoutes;

View File

@@ -0,0 +1,21 @@
import apiClient from './apiClient';
import type { LoginRequestDto } from '../models/dtos/LoginRequestDto';
import type { LoginResponseDto } from '../models/dtos/LoginResponseDto';
import type { ChangePasswordRequestDto } from '../models/dtos/ChangePasswordRequestDto'; // Importar DTO
const login = async (credentials: LoginRequestDto): Promise<LoginResponseDto> => {
const response = await apiClient.post<LoginResponseDto>('/auth/login', credentials);
return response.data;
};
const changePassword = async (data: ChangePasswordRequestDto): Promise<void> => {
// No esperamos datos de vuelta, solo éxito (204) o error
await apiClient.post('/auth/change-password', data);
};
const authService = {
login,
changePassword, // Exportar la nueva función
};
export default authService;

View File

@@ -0,0 +1,43 @@
import apiClient from './apiClient';
import type { TipoPago } from '../models/Entities/TipoPago';
import type { CreateTipoPagoDto } from '../models/dtos/tiposPago/CreateTipoPagoDto';
import type { UpdateTipoPagoDto } from '../models/dtos/tiposPago/UpdateTipoPagoDto';
const getAllTiposPago = async (nombreFilter?: string): Promise<TipoPago[]> => {
const params: Record<string, string> = {};
if (nombreFilter) {
params.nombre = nombreFilter;
}
const response = await apiClient.get<TipoPago[]>('/tipospago', { params });
return response.data;
};
const getTipoPagoById = async (id: number): Promise<TipoPago> => {
const response = await apiClient.get<TipoPago>(`/tipospago/${id}`);
return response.data;
};
const createTipoPago = async (data: CreateTipoPagoDto): Promise<TipoPago> => {
const response = await apiClient.post<TipoPago>('/tipospago', data);
return response.data; // La API devuelve el objeto creado (201 Created)
};
const updateTipoPago = async (id: number, data: UpdateTipoPagoDto): Promise<void> => {
// PUT no suele devolver contenido en éxito (204 No Content)
await apiClient.put(`/tipospago/${id}`, data);
};
const deleteTipoPago = async (id: number): Promise<void> => {
// DELETE no suele devolver contenido en éxito (204 No Content)
await apiClient.delete(`/tipospago/${id}`);
};
const tipoPagoService = {
getAllTiposPago,
getTipoPagoById,
createTipoPago,
updateTipoPago,
deleteTipoPago,
};
export default tipoPagoService;