Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions docs/guidelines/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,8 @@ index.html → src/main.tsx → <Providers><App /></Providers>

**Router — registro único de rutas:**

- `routes.tsx`: **fuente única de verdad** de las rutas. Exporta `appRoutes` (array de `{ path, element, nav?, layout?, feature? }`, con las páginas cargadas vía `lazy()`), `navRoutesFor(features)` (las que tienen `nav` **y** cuya feature está habilitada para el tenant activo, ya angostadas para el Sidebar) y la partición `shellRoutes` / `bareRoutes` según `layout`. Sumar una ruta = agregar **una** entrada acá.
- **Rutas por feature flag (`feature`):** una ruta con `feature` sólo aparece en el Sidebar si la config del tenant la trae encendida, y `FeatureGate` la reemplaza por una pantalla de "no disponible" si se navega directo. Un flag ausente es un flag apagado.
- `routes.tsx`: **fuente única de verdad** de las rutas. Exporta `appRoutes` (array de `{ path, element, nav?, layout? }`, con las páginas cargadas vía `lazy()`), `navRoutes` (las que tienen `nav`, ya angostadas para el Sidebar) y la partición `shellRoutes` / `bareRoutes` según `layout`. Sumar una ruta = agregar **una** entrada acá. La raíz `/` es el panel de operación: es lo primero que se ve al iniciar sesión.
- **Feature flags del tenant:** hoy ninguna ruta depende de un flag. La última que lo hacía era Integraciones, que se sacó en TESIS-140 porque las conexiones las administra el equipo y no la empresa; con ella se fueron el campo `feature` de la ruta y el `FeatureGate` que la cubría. El flag sigue vivo y sigue decidiendo contenido **dentro** de una pantalla: `useTenantFeature('integrations')` muestra u oculta la tarjeta de canales de venta del detalle de producto. Un flag ausente es un flag apagado.
- `AppRouter.tsx`: monta `bareRoutes` sueltas y públicas — cada una con su propio `ErrorBoundary` — y `shellRoutes` detrás de `<ProtectedRoute>` y dentro de `<AppLayout>`. Todo envuelto en `<Suspense fallback={<LoadingSpinner fullScreen />}>`. El catch-all vive dentro del guard, así una URL desconocida sin sesión también termina en el login.
- El Sidebar y el Router se derivan del mismo `routes.tsx`, por lo que no pueden divergir.
- **Rutas sin shell (`layout: 'bare'`):** públicas y full-bleed — hoy `/login`. No pasan por el guard ni renderizan Header/Sidebar. Como el `ErrorBoundary` de `AppLayout` sólo envuelve al `Outlet` privado, cada ruta bare lleva el suyo.
Expand All @@ -167,7 +167,7 @@ index.html → src/main.tsx → <Providers><App /></Providers>

- `AppLayout.tsx`: flex `Header + Sidebar + <Outlet>`. El margen izquierdo responde a `sidebarOpen` del `uiStore` (drawer width: 240px). El `<Outlet>` va envuelto en `<ErrorBoundary key={pathname}>`: los errores de render (o de carga de un chunk lazy) muestran el `ErrorFallback` en el área de contenido sin tumbar Header ni Sidebar, y se limpian al navegar.
- `Header.tsx`: único punto de cableado del `TopNavBar` (`shared/components`) — lee `themeMode`/`toggleTheme`/`toggleSidebar` de `useUiStore` y el email + `logout` de `useAuthStore`, todo con selectores individuales, y se los pasa por props. El componente en sí es presentacional — sin datos ni `uiStore`; su único acople es el `Link` de react-router (brand y engranaje), correcto para esta app (ver tabla de `shared/` más abajo). El logout no navega: limpia la sesión y el guard hace el redirect.
- `Sidebar.tsx`: Drawer persistente. Los ítems salen de `navRoutesFor(features)` con los feature flags del tenant activo. Usa `NavLink` con clase `active` que resalta en `primary.main`.
- `Sidebar.tsx`: Drawer persistente. Los ítems salen de `navRoutes`. Usa `NavLink` con clase `active` que resalta en `primary.main`.

**Tema** (`createAppTheme(branding)`):

Expand Down Expand Up @@ -310,9 +310,8 @@ features/[nombre]/

| Feature | Contenido |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `dashboard` | Panel de operación (`/dashboard`) y la sección de Integraciones (`/integrations`, detrás del feature flag `integrations` del tenant). Comparten `useInfraHealth` e `IntegrationNodeList`. El panel suma la tarjeta de alertas de inventario, la carga por depósito y la tabla de órdenes recientes (`useInventoryAlerts`, `WarehouseLoadCard`, `useRecentOrders`, `RecentOrdersTable`) |
| `dashboard` | Panel de operación y pantalla de entrada de la app (`/`, S03). Salud de los nodos de integración (`useInfraHealth`, `IntegrationNodeList`), alertas de inventario, carga por depósito y órdenes recientes (`useInventoryAlerts`, `WarehouseLoadCard`, `useRecentOrders`, `RecentOrdersTable`) |
| `design-system` | Catálogo de tokens y componentes del DS (`/design-system`). No tiene datos ni hooks |
| `home` | Landing de la app (`/`) |
| `inventory` | Catálogo de productos y stock por depósito (`/inventory`). Hoy el alta y la edición de producto (`CreateProductModal`, `EditProductModal`) sobre la API real; la vista real del catálogo la construye TESIS-62, que reemplaza el cuerpo de `InventoryPage` |
| `warehouses` | Depósitos de la empresa (`/warehouses`, RF-03): listado, alta, edición y baja sobre `/api/v1/warehouses`. Sin maqueta: tabla, modal y confirmación del DS. Sus mutaciones invalidan todas las queries, porque los depósitos llenan selects de otras features cuyas claves esta no puede importar |
| `failed-events` | Cola de eventos fallidos (`/failed-events`, RF-16): lo que el motor de reintentos todavía intenta, lo agotado y lo resuelto, con reintento y descarte manuales. Las acciones de cada fila salen de `utils/actions.ts`, espejo de los estados que la API acepta |
Expand Down
9 changes: 2 additions & 7 deletions src/app/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,14 @@ import {
ListItemText,
Toolbar,
} from '@mui/material'
import { navRoutesFor } from 'app/router/routes'
import { useMemo } from 'react'
import { navRoutes } from 'app/router/routes'
import { NavLink } from 'react-router-dom'
import { useTenantStore, useUiStore } from 'shared/store'
import { useUiStore } from 'shared/store'

const DRAWER_WIDTH = 240

export function Sidebar() {
const { sidebarOpen } = useUiStore()
// La navegación sale de la config del tenant: una feature que la empresa no
// tiene no se lista (TESIS-121).
const features = useTenantStore((state) => state.config?.features)
const navRoutes = useMemo(() => navRoutesFor(features), [features])

return (
<Drawer
Expand Down
13 changes: 1 addition & 12 deletions src/app/router/AppRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { Suspense } from 'react'
import { Navigate, Route, Routes } from 'react-router-dom'
import { ErrorBoundary, LoadingSpinner } from 'shared/components'

import { FeatureGate } from './FeatureGate'
import { ProtectedRoute } from './ProtectedRoute'
import { bareRoutes, shellRoutes } from './routes'

Expand Down Expand Up @@ -33,17 +32,7 @@ export function AppRouter() {
<Route element={<ProtectedRoute />}>
<Route element={<AppLayout />}>
{shellRoutes.map((route) => (
<Route
key={route.path}
path={route.path}
element={
route.feature ? (
<FeatureGate feature={route.feature}>{route.element}</FeatureGate>
) : (
route.element
)
}
/>
<Route key={route.path} path={route.path} element={route.element} />
))}
<Route path="*" element={<NotFoundRedirect />} />
</Route>
Expand Down
41 changes: 0 additions & 41 deletions src/app/router/FeatureGate.tsx

This file was deleted.

40 changes: 16 additions & 24 deletions src/app/router/routes.test.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,19 @@
import type { TenantFeatureFlags } from 'shared/api'
import { describe, expect, it } from 'vitest'

import { navRoutesFor } from './routes'
import { navRoutes } from './routes'

function navPaths(features: TenantFeatureFlags | undefined) {
return navRoutesFor(features).map((route) => route.path)
}
const paths = navRoutes.map((route) => route.path)

describe('navRoutesFor', () => {
it('lists the sections of a feature the tenant has enabled', () => {
expect(navPaths({ integrations: true })).toContain('/integrations')
describe('navRoutes', () => {
// El panel es la pantalla de entrada desde TESIS-140: antes `/` era una
// pantalla de Inicio sin datos y el panel vivía en `/dashboard`.
it('opens the sidebar with the dashboard, which is the root of the app', () => {
expect(navRoutes[0]).toMatchObject({ path: '/', nav: { label: 'Dashboard' } })
})

it('hides them for a tenant that has the feature off', () => {
expect(navPaths({ integrations: false })).not.toContain('/integrations')
})

// Un flag ausente es un flag apagado: la empresa que no compró la feature no
// la tiene declarada, y el default no puede ser mostrarla.
it('hides them when the config does not mention the feature at all', () => {
expect(navPaths({})).not.toContain('/integrations')
expect(navPaths(undefined)).not.toContain('/integrations')
})

it('keeps the sections that belong to the product for every tenant', () => {
expect(navPaths({})).toEqual([
it('lists the sections of the product, and only those', () => {
expect(paths).toEqual([
'/',
'/dashboard',
'/orders',
'/inventory',
'/warehouses',
Expand All @@ -35,10 +22,15 @@ describe('navRoutesFor', () => {
])
})

it('never lists a route that has no place in the sidebar', () => {
const paths = navPaths({ integrations: true })
// Integraciones se sacó en TESIS-140: las conexiones las administra el
// equipo desde el backoffice, no la empresa desde la app.
it('does not offer a section for the integrations of the company', () => {
expect(paths).not.toContain('/integrations')
})

it('never lists a route that has no place in the sidebar', () => {
expect(paths).not.toContain('/login')
expect(paths).not.toContain('/design-system')
expect(paths).not.toContain('/orders/new')
})
})
55 changes: 18 additions & 37 deletions src/app/router/routes.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,16 @@
import BarChartOutlinedIcon from '@mui/icons-material/BarChartOutlined'
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
import HomeIcon from '@mui/icons-material/Home'
import HubOutlinedIcon from '@mui/icons-material/HubOutlined'
import InsightsOutlinedIcon from '@mui/icons-material/InsightsOutlined'
import Inventory2Icon from '@mui/icons-material/Inventory2'
import ReceiptLongOutlinedIcon from '@mui/icons-material/ReceiptLongOutlined'
import WarehouseOutlinedIcon from '@mui/icons-material/WarehouseOutlined'
import { lazy } from 'react'
import type { ReactNode } from 'react'
import { isFeatureEnabled } from 'shared/api'
import type { TenantFeature, TenantFeatureFlags } from 'shared/api'

// Páginas cargadas de forma diferida (code-splitting por ruta).
const HomePage = lazy(() => import('features/home').then((m) => ({ default: m.HomePage })))
const DashboardPage = lazy(() =>
import('features/dashboard').then((m) => ({ default: m.DashboardPage })),
)
const IntegrationsPage = lazy(() =>
import('features/integrations').then((m) => ({ default: m.IntegrationsPage })),
)
const DesignSystemPage = lazy(() =>
import('features/design-system').then((m) => ({ default: m.DesignSystemPage })),
)
Expand Down Expand Up @@ -70,12 +62,6 @@ export interface AppRoute {
* Ver docs/guidelines/architecture.md §4.1.
*/
layout?: 'app' | 'bare'
/**
* Feature flag del tenant que habilita la ruta. Sin flag la ruta es del
* producto y la ve todo el mundo; con flag, sólo las empresas que la tienen
* encendida en su config (TESIS-121).
*/
feature?: TenantFeature
}

/** Ruta ya angostada para el Sidebar: `nav` garantizado. */
Expand All @@ -92,12 +78,11 @@ export const appRoutes: AppRoute[] = [
layout: 'bare',
},
{
// El panel es la pantalla de entrada: es lo primero que se ve al iniciar
// sesión (LoginPage navega a `from ?? '/'`). Antes acá vivía una pantalla
// de Inicio que no mostraba datos y anunciaba el panel como algo que
// llegaba más adelante (TESIS-111); el panel existe desde TESIS-53.
path: '/',
element: <HomePage />,
nav: { label: 'Inicio', icon: <HomeIcon /> },
},
{
path: '/dashboard',
element: <DashboardPage />,
nav: { label: 'Dashboard', icon: <InsightsOutlinedIcon /> },
},
Expand Down Expand Up @@ -132,14 +117,6 @@ export const appRoutes: AppRoute[] = [
element: <OrderDetailPage />,
// Sin `nav`: se llega desde el listado, no desde el Sidebar.
},
{
path: '/integrations',
element: <IntegrationsPage />,
nav: { label: 'Integraciones', icon: <HubOutlinedIcon /> },
// La feature que diferencia a las dos empresas de la demo: Norte la tiene
// encendida y Sur no (§2 del contrato).
feature: 'integrations',
},
{
path: '/inventory',
element: <InventoryPage />,
Expand Down Expand Up @@ -173,18 +150,22 @@ export const appRoutes: AppRoute[] = [
]

/**
* Rutas que muestra el Sidebar para la config de tenant activa.
* Rutas que lista el Sidebar.
*
* Era `navRoutesFor(features)`, porque Integraciones se mostraba sólo a los
* tenants con ese flag encendido. Esa sección se sacó en TESIS-140 —la
* administra el equipo, no la empresa— y con ella la última ruta con flag, así
* que hoy la navegación es la misma para todos y una función que recibiera la
* config y no la mirara diría algo falso.
*
* Es una función y no una constante porque la navegación depende de los feature
* flags de la empresa: la diferencia entre dos clientes es su config, nunca una
* rama del código.
* El flag del tenant no desaparece: sigue decidiendo si el detalle de producto
* muestra la tarjeta de canales de venta (`useTenantFeature('integrations')`).
* La diferencia entre dos clientes sigue estando en su config y no en una rama
* del código; lo que cambió es dónde se nota.
*/
export function navRoutesFor(features: TenantFeatureFlags | undefined): NavRoute[] {
return appRoutes.filter(
(route): route is NavRoute =>
Boolean(route.nav) && (!route.feature || isFeatureEnabled(features, route.feature)),
)
}
export const navRoutes: NavRoute[] = appRoutes.filter((route): route is NavRoute =>
Boolean(route.nav),
)

// Partición por layout — AppRouter monta `shellRoutes` detrás del guard y dentro
// de `AppLayout`, y `bareRoutes` sueltas, públicas y sin shell.
Expand Down
4 changes: 2 additions & 2 deletions src/features/dashboard/pages/DashboardPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ const alertCard = () => screen.getByRole('link', { name: /Alertas de inventario/

function renderPage() {
return renderWithTheme(
<MemoryRouter initialEntries={['/dashboard']}>
<MemoryRouter initialEntries={['/']}>
<Routes>
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/" element={<DashboardPage />} />
<Route path="/inventory" element={<h1>Inventario</h1>} />
</Routes>
</MemoryRouter>,
Expand Down
26 changes: 0 additions & 26 deletions src/features/home/content.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/features/home/index.ts

This file was deleted.

Loading
Loading