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
16 changes: 16 additions & 0 deletions src/features/inventory/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ interface ApiStock {
quantity: number
warehouse_id: number
warehouse: ApiWarehouse
stock_status: StockStatus
}

interface ApiProductSummary {
Expand All @@ -57,8 +58,13 @@ interface ApiProduct {
sku: string
name: string
description: string | null
category: string | null
weight: number
dimensions: string | null
total_stock: number
stock_status: StockStatus
in_transit_quantity: number
in_transit_by_warehouse: { warehouse_id: number; name: string; quantity: number }[]
updated_at: string
stocks: ApiStock[]
}
Expand All @@ -84,14 +90,24 @@ function toProduct(product: ApiProduct, version: string | null = null): Product
sku: product.sku,
name: product.name,
description: product.description,
category: (product.category ?? null) as Product['category'],
weight: product.weight,
dimensions: product.dimensions,
totalStock: product.total_stock,
stockStatus: product.stock_status,
inTransitQuantity: product.in_transit_quantity,
inTransitByWarehouse: (product.in_transit_by_warehouse ?? []).map((transit) => ({
warehouseId: transit.warehouse_id,
name: transit.name,
quantity: transit.quantity,
})),
updatedAt: product.updated_at,
version,
stocks: (product.stocks ?? []).map((stock) => ({
warehouseId: stock.warehouse_id,
quantity: stock.quantity,
warehouse: toWarehouse(stock.warehouse),
stockStatus: stock.stock_status,
})),
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,17 @@ function product(overrides: Partial<Product> = {}): Product {
description: 'Rollo de 305 metros',
weight: 12.4,
dimensions: '45x30x20',
category: null,
totalStock: 10,
stockStatus: 'low',
inTransitQuantity: 0,
inTransitByWarehouse: [],
stocks: [
{
warehouseId: 1,
quantity: 10,
warehouse: { id: 1, name: 'CD Ezeiza', address: 'Autopista Riccheri km 33' },
stockStatus: 'low',
},
],
updatedAt: '2026-08-30T12:00:00.000Z',
Expand Down
21 changes: 10 additions & 11 deletions src/features/inventory/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export const inventoryCopy = {
`${length} × ${width} × ${height} cm`,
/** Marca de "sin dato" del DS, la misma que usa el dashboard. */
unknown: '—',
pendingBackend: 'Categoría, empaque y norma técnica todavía no existen en la API.',
pendingBackend: 'Empaque y norma técnica todavía no existen en la API.',
},
master: {
title: 'Stock maestro',
Expand All @@ -114,9 +114,10 @@ export const inventoryCopy = {
},
// Cubre las dos piezas del diseño que la API no puede alimentar: la barra
// de capacidad ("71 % del umbral máximo de 6.000 unidades"), que necesita
// un techo por producto, y el desglose por estado de reserva.
// un techo por producto, y el desglose por reserva. Y aclara que el en
// tránsito, a diferencia del diseño, no es una porción del total.
pending:
'El porcentaje de capacidad y el desglose por reserva esperan datos que la API todavía no expone.',
'El en tránsito no forma parte del total. La capacidad y las reservas esperan datos que la API todavía no expone.',
edit: 'Editar stock',
},
distribution: {
Expand All @@ -131,16 +132,14 @@ export const inventoryCopy = {
onHand: 'En depósito',
status: 'Estado',
},
// Cuenta también los depósitos que sólo esperan unidades en camino.
footer: (count: number) =>
count === 1 ? '1 depósito con stock asignado' : `${count} depósitos con stock asignado`,
count === 1
? '1 depósito con stock o en camino'
: `${count} depósitos con stock o en camino`,
empty: 'Este producto no tiene stock asignado en ningún depósito.',
pending: 'Comprometido y en tránsito esperan que la API los modele.',
},
status: {
available: 'Disponible',
low: 'Stock bajo',
critical: 'Crítico',
out: 'Sin stock',
pending:
'En tránsito son las unidades que vienen hacia cada depósito. Comprometido espera que la API modele las reservas.',
},
notFound: 'No encontramos el producto que buscabas.',
backToCatalog: 'Volver al catálogo',
Expand Down
121 changes: 120 additions & 1 deletion src/features/inventory/pages/ProductDetailPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'

Expand All @@ -21,13 +21,67 @@ const PRODUCT: Product = {
sku: 'NOR-007',
name: 'Cable UTP Cat6 100m',
description: null,
category: null,
weight: 4.5,
dimensions: null,
stocks: [],
totalStock: 0,
stockStatus: 'out_of_stock',
inTransitQuantity: 0,
inTransitByWarehouse: [],
updatedAt: '2026-09-01T10:00:00Z',
version: 'W/"1"',
}

// NOR-003 de los seeds: 100 en Central + 30 en Satélite = 130. El catálogo lo
// muestra «Disponible» (130 > 100); el detalle lo mostraba «Crítico» porque
// calculaba el badge con umbrales propios (≤ 200).
const SEEDED_MOUSE: Product = {
...PRODUCT,
sku: 'NOR-003',
name: 'Mouse Inalámbrico Logitech',
category: 'Electronics',
stocks: [
{
warehouseId: 1,
quantity: 100,
warehouse: { id: 1, name: 'Depósito Central', address: 'Av. 7 N° 1234' },
stockStatus: 'low',
},
{
warehouseId: 2,
quantity: 30,
warehouse: { id: 2, name: 'Depósito Satélite Norte', address: 'Calle 25 N° 456' },
stockStatus: 'low',
},
],
totalStock: 130,
stockStatus: 'available',
inTransitQuantity: 12,
inTransitByWarehouse: [
{ warehouseId: 2, name: 'Depósito Satélite Norte', quantity: 5 },
{ warehouseId: 3, name: 'Depósito Sur', quantity: 7 },
],
}

function showProduct(product: Product) {
vi.mocked(useProduct).mockReturnValue({
data: product,
isPending: false,
isError: false,
isFetching: false,
refetch: vi.fn(),
} as never)
}

/** La fila de la tabla de distribución que nombra a un depósito. */
function distributionRow(warehouse: string) {
const cell = screen.getByText(warehouse)
const row = cell.closest('tr')
if (row === null) throw new Error(`no row for ${warehouse}`)
return within(row)
}

const editForm = () => screen.queryByText(`Editar producto: ${PRODUCT.name}`)

// Lo que quedó en `history.state`, que es donde vive `location.state`. Es lo que
Expand Down Expand Up @@ -104,4 +158,69 @@ describe('ProductDetailPage', () => {

await waitFor(() => expect(editForm()).not.toBeInTheDocument())
})

describe('the data that comes from the backend', () => {
it('shows the badge the catalog shows for the same product', () => {
showProduct(SEEDED_MOUSE)
renderDetail()

expect(screen.getAllByText('Disponible').length).toBeGreaterThan(0)
expect(screen.queryByText('Crítico')).not.toBeInTheDocument()
})

it('shows the status the backend sent for each warehouse', () => {
showProduct(SEEDED_MOUSE)
renderDetail()

expect(distributionRow('Depósito Central').getByText('Stock bajo')).toBeInTheDocument()
expect(distributionRow('Depósito Satélite Norte').getByText('Stock bajo')).toBeInTheDocument()
})

it('shows the category of the product', () => {
showProduct(SEEDED_MOUSE)
renderDetail()

expect(screen.getByText('Electronics')).toBeInTheDocument()
})

it('shows the no-data mark for a product with no category', () => {
renderDetail()

expect(screen.getByText('Categoría').parentElement).toHaveTextContent('—')
})

it('takes the master total from the API instead of adding the rows', () => {
showProduct({ ...SEEDED_MOUSE, totalStock: 131 })
renderDetail()

expect(screen.getByText('131')).toBeInTheDocument()
})

// El en tránsito queda fuera del total: 130 sigue siendo 130.
it('shows the units in flight apart from the total', () => {
showProduct(SEEDED_MOUSE)
renderDetail()

expect(
screen.getByText('En tránsito', { selector: 'p, span' }).parentElement?.parentElement,
).toHaveTextContent('12')
expect(screen.getByText('130')).toBeInTheDocument()
})

it('puts the incoming units on the row of their destination', () => {
showProduct(SEEDED_MOUSE)
renderDetail()

expect(distributionRow('Depósito Satélite Norte').getByText('5')).toBeInTheDocument()
})

// Depósito Sur no tiene fila en `stocks`: la nace al recibir la transferencia.
it('lists a warehouse that only waits for units in flight', () => {
showProduct(SEEDED_MOUSE)
renderDetail()

expect(distributionRow('Depósito Sur').getByText('7')).toBeInTheDocument()
expect(distributionRow('Depósito Sur').getByText('Sin stock')).toBeInTheDocument()
})
})
})
78 changes: 40 additions & 38 deletions src/features/inventory/pages/ProductDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@ import type { Product, UpdateProductPayload } from '../types'
import { describeConflict } from '../utils/conflict'
import { parseDimensions } from '../utils/dimensions'
import { formatSpecTimestamp, formatUnits, formatWeight } from '../utils/format'
import { STOCK_LEVEL_STATUS, sortByQuantityDesc, stockLevel, totalOnHand } from '../utils/stock'
import { distributionPositions } from '../utils/stock'
import { stockLabel, stockRowTone, stockVariant } from '../utils/stockStatus'

const { detail, page } = inventoryCopy
const { specs: specsCopy, master: masterCopy, distribution: distributionCopy, status } = detail
const { specs: specsCopy, master: masterCopy, distribution: distributionCopy } = detail

// Ruta del catálogo. Las rutas se registran en `app/router/routes.tsx`, capa que
// una feature no puede importar (ver architecture.md §3.2), así que el destino
Expand All @@ -50,11 +51,13 @@ function buildSpecs(product: Product): ProductSpec[] {
const hasDimensions = product.dimensions !== null && product.dimensions !== ''

return [
// `products` no tiene columna de categoría (las cuatro de Product::CATEGORIES
// viven en el backend pero no llegan en el serializer), ni empaque, ni norma
// técnica. Se muestran los rótulos del diseño sin valor, en vez de omitir las
// celdas: así la ficha dice qué falta en lugar de disimularlo.
{ id: 'category', label: specsCopy.fields.category, value: specsCopy.unknown, unknown: true },
// La categoría es opcional: los productos anteriores a TESIS-102 no tienen.
{
id: 'category',
label: specsCopy.fields.category,
value: product.category ?? specsCopy.unknown,
unknown: product.category === null,
},
{
id: 'weight',
label: specsCopy.fields.weight,
Expand All @@ -74,6 +77,9 @@ function buildSpecs(product: Product): ProductSpec[] {
mono: true,
unknown: !hasDimensions,
},
// Ni empaque ni norma técnica existen en el modelo. Se muestran los rótulos
// del diseño sin valor, en vez de omitir las celdas: así la ficha dice qué
// falta en lugar de disimularlo.
{ id: 'packaging', label: specsCopy.fields.packaging, value: specsCopy.unknown, unknown: true },
]
}
Expand All @@ -94,13 +100,14 @@ function buildSecondarySpecs(product: Product): ProductSpec[] {
}

/**
* Desglose por estado de reserva.
* Desglose del stock maestro.
*
* Las tres cubetas van sin dato: la API devuelve un solo número por depósito
* (las unidades en depósito) y no modela reservas ni mercadería en tránsito.
* Ver el encabezado de `utils/stock.ts`.
* Comprometido y disponible para prometer van sin dato: la API no modela
* reservas. El en tránsito sí existe, pero **no es una porción del total**:
* son unidades que salieron de un depósito y no llegaron a otro, y el backend
* las deja fuera de `total_stock`. Ver el encabezado de `utils/stock.ts`.
*/
function buildBuckets(): StockBucket[] {
function buildBuckets(product: Product): StockBucket[] {
return [
{
id: 'committed',
Expand All @@ -112,9 +119,8 @@ function buildBuckets(): StockBucket[] {
{
id: 'inTransit',
label: masterCopy.buckets.inTransit,
icon: <LocalShippingOutlinedIcon fontSize="small" color="disabled" />,
value: specsCopy.unknown,
unknown: true,
icon: <LocalShippingOutlinedIcon fontSize="small" color="action" />,
value: formatUnits(product.inTransitQuantity),
},
{
id: 'availableToPromise',
Expand All @@ -127,23 +133,22 @@ function buildBuckets(): StockBucket[] {
]
}

/** Filas de la tabla, de mayor a menor cantidad como en el diseño. */
/**
* Filas de la tabla, de mayor a menor cantidad como en el diseño. El estado de
* cada fila lo manda el backend; el resalte sigue el criterio del catálogo.
*/
function buildRows(product: Product): WarehouseDistributionRow[] {
return sortByQuantityDesc(product.stocks).map((stock) => {
const level = stockLevel(stock.quantity)

return {
id: stock.warehouseId,
name: stock.warehouse.name,
location: stock.warehouse.address,
committed: specsCopy.unknown,
inTransit: specsCopy.unknown,
onHand: formatUnits(stock.quantity),
statusLabel: status[level],
statusVariant: STOCK_LEVEL_STATUS[level],
critical: level === 'critical' || level === 'out',
}
})
return distributionPositions(product).map((position) => ({
id: position.warehouseId,
name: position.name,
location: position.location ?? specsCopy.unknown,
committed: specsCopy.unknown,
inTransit: formatUnits(position.incoming),
onHand: formatUnits(position.quantity),
statusLabel: stockLabel(position.stockStatus),
statusVariant: stockVariant(position.stockStatus),
critical: stockRowTone(position.stockStatus) === 'critical',
}))
}

/**
Expand Down Expand Up @@ -247,17 +252,14 @@ export function ProductDetailPage() {
)
}

const total = totalOnHand(product.data.stocks)
const level = stockLevel(total)

return (
<PageWrapper>
<Stack spacing={2}>
<ProductDetailHeader
sku={product.data.sku}
name={product.data.name}
statusLabel={status[level]}
statusVariant={STOCK_LEVEL_STATUS[level]}
statusLabel={stockLabel(product.data.stockStatus)}
statusVariant={stockVariant(product.data.stockStatus)}
catalogPath={CATALOG_PATH}
onEdit={() => setEditing(true)}
/>
Expand All @@ -270,9 +272,9 @@ export function ProductDetailPage() {

<Box sx={CONTENT_GRID}>
<MasterStockCard
totalLabel={formatUnits(total)}
totalLabel={formatUnits(product.data.totalStock)}
caption={masterCopy.warehouseCount(product.data.stocks.length)}
buckets={buildBuckets()}
buckets={buildBuckets(product.data)}
footnote={masterCopy.pending}
onEditStock={() => setEditing(true)}
/>
Expand Down
Loading
Loading