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
22 changes: 12 additions & 10 deletions docs/guidelines/architecture.md

Large diffs are not rendered by default.

15 changes: 13 additions & 2 deletions src/app/layout/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { TopNavBar } from 'shared/components'
import { useState } from 'react'
import { ActivityPanel, TopNavBar } from 'shared/components'
import type { TopNavUser } from 'shared/components'
import { useAuthStore, useTenantName, useUiStore } from 'shared/store'
import { useThemeMode } from 'shared/store/uiStore'

// Las rutas de destino del panel se declaran acá y no se importan del router:
// `shared/components` no puede depender de `app/`. El de eventos fallidos
// todavía no existe (TESIS-147); sin él la fila se muestra y no enlaza.
const ACTIVITY_PATHS = { order: (id: number) => `/orders/${id}` }

export function Header() {
// Selectores individuales (no el store completo): Header está en todas las
// pantallas vía TopNavBar, así que solo re-renderiza cuando cambia una de
Expand All @@ -13,6 +19,8 @@ export function Header() {
const sessionUser = useAuthStore((state) => state.user)
const logout = useAuthStore((state) => state.logout)
const organization = useTenantName()
// El botón que abrió el panel: el Popover se ancla a él, y `null` lo cierra.
const [bell, setBell] = useState<HTMLElement | null>(null)

// `sessionUser` es null hasta que `GET /me` contesta, también al recargar la
// página con sesión abierta. El menú de cuenta cae a su nombre genérico
Expand All @@ -30,6 +38,9 @@ export function Header() {
onToggleTheme={toggleTheme}
user={user}
onLogout={logout}
/>
onNotificationsClick={setBell}
>
<ActivityPanel anchorEl={bell} onClose={() => setBell(null)} paths={ACTIVITY_PATHS} />
</TopNavBar>
)
}
1 change: 1 addition & 0 deletions src/app/router/routes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ describe('navRoutes', () => {
expect(paths).toEqual([
'/',
'/orders',
'/shipments',
'/inventory',
'/warehouses',
'/failed-events',
Expand Down
9 changes: 9 additions & 0 deletions src/app/router/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import BarChartOutlinedIcon from '@mui/icons-material/BarChartOutlined'
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
import InsightsOutlinedIcon from '@mui/icons-material/InsightsOutlined'
import Inventory2Icon from '@mui/icons-material/Inventory2'
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined'
import ReceiptLongOutlinedIcon from '@mui/icons-material/ReceiptLongOutlined'
import WarehouseOutlinedIcon from '@mui/icons-material/WarehouseOutlined'
import { lazy } from 'react'
Expand Down Expand Up @@ -36,6 +37,9 @@ const InventoryPage = lazy(() =>
const ProductDetailPage = lazy(() =>
import('features/inventory').then((m) => ({ default: m.ProductDetailPage })),
)
const ShipmentsPage = lazy(() =>
import('features/shipments').then((m) => ({ default: m.ShipmentsPage })),
)
const WarehousesPage = lazy(() =>
import('features/warehouses').then((m) => ({ default: m.WarehousesPage })),
)
Expand Down Expand Up @@ -117,6 +121,11 @@ export const appRoutes: AppRoute[] = [
element: <OrderDetailPage />,
// Sin `nav`: se llega desde el listado, no desde el Sidebar.
},
{
path: '/shipments',
element: <ShipmentsPage />,
nav: { label: 'Envíos', icon: <LocalShippingOutlinedIcon /> },
},
{
path: '/inventory',
element: <InventoryPage />,
Expand Down
93 changes: 93 additions & 0 deletions src/features/dashboard/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import type { AxiosResponse } from 'axios'
import { client } from 'shared/api/client'
import { beforeEach, describe, expect, it, vi } from 'vitest'

import { fetchRecentShipments, fetchWarehouseLoads, RECENT_SHIPMENTS } from './api'

function respond(data: unknown): AxiosResponse {
return { data, headers: {} } as AxiosResponse
}

/** Los query params con los que se llamó al endpoint. */
function capture(body: unknown) {
const sent: Record<string, unknown>[] = []
vi.spyOn(client, 'get').mockImplementation((_url: string, config?: unknown) => {
sent.push((config as { params?: Record<string, unknown> })?.params ?? {})

return Promise.resolve(respond(body))
})

return sent
}

const SHIPMENT = {
id: 31,
order_id: 8829,
status: 'in_transit',
tracking_number: 'AND-9920-X8829-Z',
courier: { id: 4, service_id: 7, name: 'Andreani' },
created_at: '2026-08-24T12:14:00Z',
}

beforeEach(() => {
vi.restoreAllMocks()
})

describe('fetchRecentShipments', () => {
it('asks for one page of the size the panel shows', async () => {
const sent = capture({ data: [] })
await fetchRecentShipments()

expect(sent[0]).toMatchObject({ page: 1, per_page: RECENT_SHIPMENTS })
})

it('translates the shipment to the domain', async () => {
capture({ data: [SHIPMENT] })

expect(await fetchRecentShipments()).toEqual([
{
id: 31,
orderId: 8829,
status: 'in_transit',
trackingNumber: 'AND-9920-X8829-Z',
courier: 'Andreani',
createdAt: '2026-08-24T12:14:00Z',
},
])
})

// El operador se asigna al confirmar el despacho (TESIS-47): hasta entonces
// la fila tiene que tolerar que no haya ninguno.
it('leaves the courier null for a shipment that has none', async () => {
capture({ data: [{ ...SHIPMENT, courier: null, tracking_number: null }] })
const [shipment] = await fetchRecentShipments()

expect(shipment).toMatchObject({ courier: null, trackingNumber: null })
})
})

describe('fetchWarehouseLoads', () => {
it('translates each warehouse to the domain', async () => {
capture({ data: [{ id: 1, name: 'CD Norte', stored_units: 200, capacity: 1000 }] })

expect(await fetchWarehouseLoads()).toEqual([
{ id: 1, name: 'CD Norte', storedUnits: 200, capacity: 1000 },
])
})

// Si el front se despliega antes que el backend que agrega el campo,
// `undefined` pasa el `!== null` de `shareOf` y la barra sale en «NaN %».
it('reads a missing capacity as one that was never declared', async () => {
capture({ data: [{ id: 1, name: 'CD Norte', stored_units: 200 }] })
const [warehouse] = await fetchWarehouseLoads()

expect(warehouse.capacity).toBeNull()
})

it('reads missing stored units as zero instead of NaN', async () => {
capture({ data: [{ id: 1, name: 'CD Norte', capacity: 1000 }] })
const [warehouse] = await fetchWarehouseLoads()

expect(warehouse.storedUnits).toBe(0)
})
})
43 changes: 41 additions & 2 deletions src/features/dashboard/api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { client } from 'shared/api/client'
import { fetchCount } from 'shared/api/count'

import type { RecentOrder, WarehouseLoad } from './types'
import type { DispatchedShipment, RecentOrder, WarehouseLoad } from './types'

// Frontera con Rails de los KPIs de órdenes y envíos (TESIS-53). Único lugar de
// la feature que conoce los endpoints y el vocabulario de estados del backend.
Expand Down Expand Up @@ -77,6 +77,7 @@ interface ApiWarehouse {
id: number
name: string
stored_units: number
capacity: number | null
}

/**
Expand All @@ -92,7 +93,45 @@ export async function fetchWarehouseLoads(): Promise<WarehouseLoad[]> {
return data.data.map((warehouse) => ({
id: warehouse.id,
name: warehouse.name,
storedUnits: warehouse.stored_units,
// `?? null` y no el valor crudo: si el front se despliega antes que el
// backend que agrega el campo, `undefined` pasa el `!== null` de
// `shareOf` y la barra sale en NaN %.
storedUnits: warehouse.stored_units ?? 0,
capacity: warehouse.capacity ?? null,
}))
}

/** Cuántos envíos muestra la tarjeta del panel. */
export const RECENT_SHIPMENTS = 5

interface ApiDispatchedShipment {
id: number
order_id: number
status: DispatchedShipment['status']
tracking_number: string | null
courier: { id: number; service_id: number; name: string } | null
created_at: string
}

/**
* Los últimos envíos de la empresa (`GET /api/v1/shipments?page=1&per_page=5`).
*
* Igual que las órdenes recientes: el backend los devuelve del más nuevo al más
* viejo, así que la primera página *es* «los últimos cinco» y no se reordena de
* este lado.
*/
export async function fetchRecentShipments(): Promise<DispatchedShipment[]> {
const { data } = await client.get<{ data: ApiDispatchedShipment[] }>('/shipments', {
params: { page: 1, per_page: RECENT_SHIPMENTS },
})

return data.data.map((shipment) => ({
id: shipment.id,
orderId: shipment.order_id,
status: shipment.status,
trackingNumber: shipment.tracking_number,
courier: shipment.courier?.name ?? null,
createdAt: shipment.created_at,
}))
}

Expand Down

This file was deleted.

Loading
Loading