24 KiB
US-FUND-007: Navegación y Routing
Epic: MAI-001 - Fundamentos del Sistema Story Points: 5 Prioridad: Baja Dependencias:
- US-FUND-004 (Infraestructura Base)
- US-FUND-001 (Autenticación JWT)
Estado: Pendiente Asignado a: Frontend Lead
📋 Historia de Usuario
Como usuario del sistema Quiero navegar de forma fluida entre las diferentes secciones de la aplicación Para acceder rápidamente a las funcionalidades que necesito según mi rol.
🎯 Contexto y Objetivos
Contexto
Este documento define la estructura de navegación y routing de la aplicación frontend. Incluye:
- Estructura de rutas por módulo
- Layouts reutilizables (Auth, Dashboard)
- Rutas protegidas (autenticación requerida)
- Guards por rol (acceso basado en permisos)
- Navegación lateral (sidebar con menú dinámico)
- Breadcrumbs para orientación del usuario
- 404 y manejo de errores de navegación
Objetivos
- ✅ Rutas organizadas por módulo de negocio
- ✅ Protección de rutas que requieren autenticación
- ✅ Restricción de rutas por rol (RBAC)
- ✅ Sidebar dinámico según rol del usuario
- ✅ Breadcrumbs actualizados automáticamente
- ✅ Deep linking funcional (URLs compartibles)
- ✅ 404 page para rutas inexistentes
✅ Criterios de Aceptación
CA-1: Estructura de Rutas
Dado la aplicación frontend Cuando se examinan las rutas configuradas Entonces:
-
✅ Rutas públicas (no requieren auth):
/login/register/forgot-password/reset-password/:token
-
✅ Rutas protegidas (requieren auth):
/dashboard/profile/projects/projects/:id/budgets/purchases/hr(solo HR)/finance(solo Finance)/post-sales(solo Post-Sales)
CA-2: Rutas Protegidas por Autenticación
Dado un usuario no autenticado
Cuando intenta acceder a /dashboard
Entonces:
- ✅ Es redirigido a
/login - ✅ URL de destino se guarda en query param:
/login?redirect=/dashboard - ✅ Después de login, es redirigido a
/dashboard
CA-3: Rutas Protegidas por Rol
Dado un usuario con rol resident
Cuando intenta acceder a /budgets (solo Director/Engineer)
Entonces:
- ✅ Es redirigido a
/dashboard - ✅ Toast muestra: "No tienes permisos para acceder a esta sección"
- ✅ No se muestra contenido de la ruta restringida
CA-4: Sidebar Dinámico
Dado un usuario autenticado Cuando visualiza el sidebar Entonces:
- ✅ Solo muestra secciones permitidas para su rol
- ✅ Sección activa está resaltada
- ✅ Iconos representativos para cada sección
- ✅ Sidebar colapsable en pantallas pequeñas
Ejemplo para rol engineer:
- ✅ Dashboard ✔️
- ✅ Proyectos ✔️
- ✅ Presupuestos ✔️
- ✅ Compras ✔️
- ❌ Finanzas (oculto)
- ❌ RRHH (oculto)
- ❌ Post-venta (oculto)
CA-5: Breadcrumbs
Dado un usuario en /projects/123e4567-e89b-12d3-a456-426614174000
Cuando visualiza los breadcrumbs
Entonces:
- ✅ Muestra:
Dashboard > Proyectos > Residencial Las Palmas - ✅ Cada nivel es clickeable (excepto el actual)
- ✅ Click en "Proyectos" navega a
/projects - ✅ Click en "Dashboard" navega a
/dashboard
CA-6: Deep Linking
Dado un usuario autenticado
Cuando accede directamente a /projects/123e4567-e89b-12d3-a456-426614174000 (URL copiada)
Entonces:
- ✅ La página carga correctamente
- ✅ Sidebar muestra "Proyectos" como activo
- ✅ Breadcrumbs muestra la ruta completa
- ✅ Datos del proyecto se cargan desde la API
CA-7: 404 Not Found
Dado un usuario autenticado
Cuando accede a /ruta-inexistente
Entonces:
- ✅ Se muestra página 404 personalizada
- ✅ Mensaje: "La página que buscas no existe"
- ✅ Botón "Ir al Dashboard" redirige a
/dashboard - ✅ URL en el navegador sigue siendo
/ruta-inexistente
🔧 Especificación Técnica Detallada
1. Estructura de Rutas
Archivo: apps/frontend/src/routes/routes.tsx
import { Routes, Route, Navigate } from 'react-router-dom';
// Layouts
import { AuthLayout } from '@/components/layout/AuthLayout';
import { DashboardLayout } from '@/components/layout/DashboardLayout';
// Guards
import { ProtectedRoute } from '@/components/guards/ProtectedRoute';
import { RoleGuard } from '@/components/guards/RoleGuard';
// Pages - Auth
import { LoginPage } from '@/features/auth/pages/LoginPage';
import { RegisterPage } from '@/features/auth/pages/RegisterPage';
import { ForgotPasswordPage } from '@/features/auth/pages/ForgotPasswordPage';
import { ResetPasswordPage } from '@/features/auth/pages/ResetPasswordPage';
// Pages - Dashboard
import { DashboardPage } from '@/features/dashboard/pages/DashboardPage';
import { ProfilePage } from '@/features/profile/pages/ProfilePage';
// Pages - Projects
import { ProjectsListPage } from '@/features/projects/pages/ProjectsListPage';
import { ProjectDetailPage } from '@/features/projects/pages/ProjectDetailPage';
import { CreateProjectPage } from '@/features/projects/pages/CreateProjectPage';
// Pages - Budgets
import { BudgetsListPage } from '@/features/budgets/pages/BudgetsListPage';
import { BudgetDetailPage } from '@/features/budgets/pages/BudgetDetailPage';
// Pages - Purchases
import { PurchasesListPage } from '@/features/purchases/pages/PurchasesListPage';
import { SuppliersPage } from '@/features/purchases/pages/SuppliersPage';
// Pages - HR
import { HrDashboardPage } from '@/features/hr/pages/HrDashboardPage';
import { EmployeesPage } from '@/features/hr/pages/EmployeesPage';
import { AttendancePage } from '@/features/hr/pages/AttendancePage';
// Pages - Finance
import { FinanceDashboardPage } from '@/features/finance/pages/FinanceDashboardPage';
import { CashFlowPage } from '@/features/finance/pages/CashFlowPage';
// Pages - Post-Sales
import { PostSalesDashboardPage } from '@/features/post-sales/pages/PostSalesDashboardPage';
import { WarrantiesPage } from '@/features/post-sales/pages/WarrantiesPage';
// Pages - Errors
import { NotFoundPage } from '@/features/errors/pages/NotFoundPage';
export function AppRoutes() {
return (
<Routes>
{/* ==================== PUBLIC ROUTES ==================== */}
<Route element={<AuthLayout />}>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password/:token" element={<ResetPasswordPage />} />
</Route>
{/* ==================== PROTECTED ROUTES ==================== */}
<Route
element={
<ProtectedRoute>
<DashboardLayout />
</ProtectedRoute>
}
>
{/* Dashboard - Todos los roles */}
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/profile" element={<ProfilePage />} />
{/* Projects - Director, Engineer, Resident */}
<Route
path="/projects"
element={
<RoleGuard allowedRoles={['director', 'engineer', 'resident']}>
<ProjectsListPage />
</RoleGuard>
}
/>
<Route
path="/projects/new"
element={
<RoleGuard allowedRoles={['director']}>
<CreateProjectPage />
</RoleGuard>
}
/>
<Route
path="/projects/:id"
element={
<RoleGuard allowedRoles={['director', 'engineer', 'resident']}>
<ProjectDetailPage />
</RoleGuard>
}
/>
{/* Budgets - Director, Engineer */}
<Route
path="/budgets"
element={
<RoleGuard allowedRoles={['director', 'engineer']}>
<BudgetsListPage />
</RoleGuard>
}
/>
<Route
path="/budgets/:id"
element={
<RoleGuard allowedRoles={['director', 'engineer']}>
<BudgetDetailPage />
</RoleGuard>
}
/>
{/* Purchases - Director, Engineer, Purchases */}
<Route
path="/purchases"
element={
<RoleGuard allowedRoles={['director', 'engineer', 'purchases']}>
<PurchasesListPage />
</RoleGuard>
}
/>
<Route
path="/suppliers"
element={
<RoleGuard allowedRoles={['director', 'purchases']}>
<SuppliersPage />
</RoleGuard>
}
/>
{/* HR - Director, HR */}
<Route
path="/hr"
element={
<RoleGuard allowedRoles={['director', 'hr']}>
<HrDashboardPage />
</RoleGuard>
}
/>
<Route
path="/hr/employees"
element={
<RoleGuard allowedRoles={['director', 'hr']}>
<EmployeesPage />
</RoleGuard>
}
/>
<Route
path="/hr/attendance"
element={
<RoleGuard allowedRoles={['director', 'hr']}>
<AttendancePage />
</RoleGuard>
}
/>
{/* Finance - Director, Finance */}
<Route
path="/finance"
element={
<RoleGuard allowedRoles={['director', 'finance']}>
<FinanceDashboardPage />
</RoleGuard>
}
/>
<Route
path="/finance/cash-flow"
element={
<RoleGuard allowedRoles={['director', 'finance']}>
<CashFlowPage />
</RoleGuard>
}
/>
{/* Post-Sales - Director, Post-Sales */}
<Route
path="/post-sales"
element={
<RoleGuard allowedRoles={['director', 'post_sales']}>
<PostSalesDashboardPage />
</RoleGuard>
}
/>
<Route
path="/post-sales/warranties"
element={
<RoleGuard allowedRoles={['director', 'post_sales']}>
<WarrantiesPage />
</RoleGuard>
}
/>
</Route>
{/* ==================== REDIRECTS ==================== */}
<Route path="/" element={<Navigate to="/dashboard" replace />} />
{/* ==================== 404 NOT FOUND ==================== */}
<Route path="*" element={<NotFoundPage />} />
</Routes>
);
}
2. Protected Route Guard
Archivo: apps/frontend/src/components/guards/ProtectedRoute.tsx
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '@/stores/useAuthStore';
interface ProtectedRouteProps {
children: React.ReactNode;
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated } = useAuthStore();
const location = useLocation();
if (!isAuthenticated) {
// Redirigir a login con URL de retorno
return <Navigate to={`/login?redirect=${location.pathname}`} replace />;
}
return <>{children}</>;
}
3. Role Guard
Archivo: apps/frontend/src/components/guards/RoleGuard.tsx
import { Navigate } from 'react-router-dom';
import { toast } from 'sonner';
import { useEffect } from 'react';
import { useAuthStore } from '@/stores/useAuthStore';
interface RoleGuardProps {
allowedRoles: string[];
children: React.ReactNode;
}
export function RoleGuard({ allowedRoles, children }: RoleGuardProps) {
const { user } = useAuthStore();
useEffect(() => {
if (user && !allowedRoles.includes(user.role)) {
toast.error('No tienes permisos para acceder a esta sección');
}
}, [user, allowedRoles]);
if (!user || !allowedRoles.includes(user.role)) {
return <Navigate to="/dashboard" replace />;
}
return <>{children}</>;
}
4. Dashboard Layout con Sidebar
Archivo: apps/frontend/src/components/layout/DashboardLayout.tsx
import { Outlet } from 'react-router-dom';
import { Sidebar } from './Sidebar';
import { Header } from './Header';
import { Breadcrumbs } from './Breadcrumbs';
export function DashboardLayout() {
return (
<div className="flex h-screen bg-gray-50">
{/* Sidebar */}
<Sidebar />
{/* Main Content */}
<div className="flex flex-1 flex-col overflow-hidden">
{/* Header */}
<Header />
{/* Breadcrumbs */}
<div className="border-b bg-white px-6 py-3">
<Breadcrumbs />
</div>
{/* Page Content */}
<main className="flex-1 overflow-y-auto p-6">
<Outlet />
</main>
</div>
</div>
);
}
5. Sidebar con Menú Dinámico
Archivo: apps/frontend/src/components/layout/Sidebar.tsx
import { NavLink } from 'react-router-dom';
import {
LayoutDashboard,
FolderKanban,
FileText,
ShoppingCart,
Users,
DollarSign,
Headphones,
} from 'lucide-react';
import { useAuthStore } from '@/stores/useAuthStore';
import { cn } from '@/lib/utils';
interface MenuItem {
label: string;
path: string;
icon: React.ElementType;
allowedRoles: string[];
}
const menuItems: MenuItem[] = [
{
label: 'Dashboard',
path: '/dashboard',
icon: LayoutDashboard,
allowedRoles: ['director', 'engineer', 'resident', 'purchases', 'finance', 'hr', 'post_sales'],
},
{
label: 'Proyectos',
path: '/projects',
icon: FolderKanban,
allowedRoles: ['director', 'engineer', 'resident'],
},
{
label: 'Presupuestos',
path: '/budgets',
icon: FileText,
allowedRoles: ['director', 'engineer'],
},
{
label: 'Compras',
path: '/purchases',
icon: ShoppingCart,
allowedRoles: ['director', 'engineer', 'purchases'],
},
{
label: 'RRHH',
path: '/hr',
icon: Users,
allowedRoles: ['director', 'hr'],
},
{
label: 'Finanzas',
path: '/finance',
icon: DollarSign,
allowedRoles: ['director', 'finance'],
},
{
label: 'Post-venta',
path: '/post-sales',
icon: Headphones,
allowedRoles: ['director', 'post_sales'],
},
];
export function Sidebar() {
const { user } = useAuthStore();
// Filtrar menú según rol
const visibleItems = menuItems.filter((item) =>
item.allowedRoles.includes(user?.role || ''),
);
return (
<aside className="w-64 border-r bg-white">
{/* Logo */}
<div className="flex h-16 items-center border-b px-6">
<h1 className="text-xl font-bold text-primary">Gestión de Obra</h1>
</div>
{/* Navigation */}
<nav className="space-y-1 p-4">
{visibleItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
)
}
>
<item.icon className="h-5 w-5" />
{item.label}
</NavLink>
))}
</nav>
</aside>
);
}
6. Breadcrumbs Component
Archivo: apps/frontend/src/components/layout/Breadcrumbs.tsx
import { Link, useLocation } from 'react-router-dom';
import { ChevronRight, Home } from 'lucide-react';
import { useMemo } from 'react';
interface BreadcrumbItem {
label: string;
path: string;
}
// Mapa de rutas a labels
const routeLabels: Record<string, string> = {
dashboard: 'Dashboard',
projects: 'Proyectos',
budgets: 'Presupuestos',
purchases: 'Compras',
suppliers: 'Proveedores',
hr: 'RRHH',
employees: 'Empleados',
attendance: 'Asistencias',
finance: 'Finanzas',
'cash-flow': 'Flujo de Caja',
'post-sales': 'Post-venta',
warranties: 'Garantías',
profile: 'Mi Perfil',
new: 'Nuevo',
};
export function Breadcrumbs() {
const location = useLocation();
const breadcrumbs = useMemo(() => {
const paths = location.pathname.split('/').filter(Boolean);
const items: BreadcrumbItem[] = [];
let currentPath = '';
paths.forEach((segment, index) => {
currentPath += `/${segment}`;
// Si es un UUID, obtener nombre del recurso (requiere data del contexto)
const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
segment,
);
if (isUUID) {
// TODO: Obtener nombre real del recurso desde el store o API
items.push({
label: 'Detalles',
path: currentPath,
});
} else {
items.push({
label: routeLabels[segment] || segment,
path: currentPath,
});
}
});
return items;
}, [location.pathname]);
return (
<nav className="flex items-center gap-2 text-sm">
<Link
to="/dashboard"
className="flex items-center gap-1 text-muted-foreground hover:text-foreground"
>
<Home className="h-4 w-4" />
<span>Inicio</span>
</Link>
{breadcrumbs.map((item, index) => {
const isLast = index === breadcrumbs.length - 1;
return (
<div key={item.path} className="flex items-center gap-2">
<ChevronRight className="h-4 w-4 text-muted-foreground" />
{isLast ? (
<span className="font-medium text-foreground">{item.label}</span>
) : (
<Link
to={item.path}
className="text-muted-foreground hover:text-foreground"
>
{item.label}
</Link>
)}
</div>
);
})}
</nav>
);
}
7. 404 Not Found Page
Archivo: apps/frontend/src/features/errors/pages/NotFoundPage.tsx
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Home, ArrowLeft } from 'lucide-react';
export function NotFoundPage() {
const navigate = useNavigate();
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<div className="text-center">
<h1 className="text-9xl font-bold text-primary">404</h1>
<p className="mt-4 text-2xl font-semibold text-gray-800">
Página no encontrada
</p>
<p className="mt-2 text-gray-600">
Lo sentimos, la página que buscas no existe o ha sido movida.
</p>
<div className="mt-8 flex justify-center gap-4">
<Button variant="outline" onClick={() => navigate(-1)}>
<ArrowLeft className="mr-2 h-4 w-4" />
Volver atrás
</Button>
<Button onClick={() => navigate('/dashboard')}>
<Home className="mr-2 h-4 w-4" />
Ir al Dashboard
</Button>
</div>
</div>
</div>
);
}
8. Login Redirect After Auth
Archivo: apps/frontend/src/features/auth/pages/LoginPage.tsx (fragmento)
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuthStore } from '@/stores/useAuthStore';
export function LoginPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { setTokens } = useAuthStore();
const handleLogin = async (credentials: LoginDto) => {
try {
const response = await apiService.post<AuthResponse>('/auth/login', credentials);
setTokens(response.accessToken, response.refreshToken);
// Redirigir a la URL original o al dashboard
const redirectUrl = searchParams.get('redirect') || '/dashboard';
navigate(redirectUrl);
toast.success('Inicio de sesión exitoso');
} catch (error) {
toast.error('Credenciales inválidas');
}
};
return (
// ... form
);
}
🧪 Test Cases
TC-NAV-001: Redirección a Login
Pre-condiciones:
- Usuario no autenticado
Pasos:
- Navegar a
/dashboard
Resultado esperado:
- ✅ Redirige a
/login?redirect=/dashboard - ✅ No se muestra contenido del dashboard
TC-NAV-002: Login con Redirect
Pre-condiciones:
- Usuario en
/login?redirect=/projects/123
Pasos:
- Completar login exitoso
Resultado esperado:
- ✅ Redirige a
/projects/123 - ✅ Página de proyecto carga correctamente
TC-NAV-003: Restricción por Rol
Pre-condiciones:
- Usuario con rol
residentautenticado
Pasos:
- Navegar a
/budgets(solo Director/Engineer)
Resultado esperado:
- ✅ Redirige a
/dashboard - ✅ Toast: "No tienes permisos para acceder a esta sección"
TC-NAV-004: Sidebar Dinámico
Pre-condiciones:
- Usuario con rol
engineerautenticado
Pasos:
- Observar el sidebar
Resultado esperado:
- ✅ Visible: Dashboard, Proyectos, Presupuestos, Compras
- ❌ Oculto: RRHH, Finanzas, Post-venta
TC-NAV-005: Navegación Activa
Pre-condiciones:
- Usuario en
/projects
Pasos:
- Observar el sidebar
Resultado esperado:
- ✅ Item "Proyectos" resaltado con bg-primary
- ✅ Otros items con estilo normal
TC-NAV-006: Breadcrumbs
Pre-condiciones:
- Usuario en
/projects/123/budgets/456
Pasos:
- Observar breadcrumbs
Resultado esperado:
- ✅ Muestra:
Inicio > Proyectos > Detalles > Presupuestos > Detalles - ✅ "Inicio" clickeable → navega a
/dashboard - ✅ "Proyectos" clickeable → navega a
/projects - ✅ Último elemento NO clickeable (activo)
TC-NAV-007: 404 Page
Pre-condiciones:
- Usuario autenticado
Pasos:
- Navegar a
/ruta-que-no-existe
Resultado esperado:
- ✅ Se muestra página 404
- ✅ Título: "404"
- ✅ Botón "Volver atrás" navega a página anterior
- ✅ Botón "Ir al Dashboard" navega a
/dashboard
📋 Tareas de Implementación
Frontend
-
NAV-FE-001: Configurar React Router v6
- Estimado: 1h
-
NAV-FE-002: Crear archivo de rutas (routes.tsx)
- Estimado: 2h
-
NAV-FE-003: Implementar ProtectedRoute guard
- Estimado: 1h
-
NAV-FE-004: Implementar RoleGuard
- Estimado: 1.5h
-
NAV-FE-005: Crear DashboardLayout con sidebar
- Estimado: 2h
-
NAV-FE-006: Crear AuthLayout
- Estimado: 1h
-
NAV-FE-007: Implementar Sidebar con menú dinámico
- Estimado: 2h
-
NAV-FE-008: Implementar Breadcrumbs component
- Estimado: 2h
-
NAV-FE-009: Crear NotFoundPage
- Estimado: 1h
-
NAV-FE-010: Implementar redirect después de login
- Estimado: 1h
Testing
-
NAV-TEST-001: Unit tests para guards
- Estimado: 2h
-
NAV-TEST-002: E2E tests para navegación
- Estimado: 2h
Total estimado: ~18.5 horas
🔗 Dependencias
Depende de
- ✅ US-FUND-004 (Infraestructura Base)
- ✅ US-FUND-001 (Autenticación JWT)
Bloqueante para
- Todas las páginas y features del sistema
- UX completa
📊 Definición de Hecho (DoD)
- ✅ React Router v6 configurado
- ✅ Rutas públicas y protegidas definidas
- ✅ ProtectedRoute guard funcional
- ✅ RoleGuard funcional
- ✅ Sidebar muestra menú dinámico según rol
- ✅ Breadcrumbs actualizados automáticamente
- ✅ 404 page implementada
- ✅ Deep linking funcional
- ✅ Redirect después de login funcional
- ✅ Todos los test cases (TC-NAV-001 a TC-NAV-007) pasan
- ✅ Navegación fluida sin flickering
📝 Notas Adicionales
Mobile Responsive
- ✅ Sidebar colapsable en pantallas < 768px
- ✅ Menú hamburguesa en mobile
- ✅ Breadcrumbs ocultos en mobile (opcional)
Accesibilidad
- ✅ Navegación con teclado (Tab, Enter)
- ✅ ARIA labels en links
- ✅ Focus visible en elementos
Performance
- ✅ Lazy loading de páginas con React.lazy()
- ✅ Code splitting por ruta
- ✅ Suspense boundaries para cargas asíncronas
Fecha de creación: 2025-11-17 Última actualización: 2025-11-17 Versión: 1.0