From 90f06bf6ec92a4ed6924fbdb8dd74ec0c8ec826d Mon Sep 17 00:00:00 2001 From: LauAubert Date: Fri, 2 Oct 2026 00:58:46 -0300 Subject: [PATCH] feat: [TESIS-999003] move stock between warehouses from the product detail The stock transfers API (TESIS-103) had no screen: units could only be moved from the console or the backoffice. The product detail now lists the transfers in flight of the product and lets the operator create one, receive it or cancel it. The transfer modal only offers warehouses holding units as origin, leaves the origin out of the destinations and caps the quantity at what the origin holds. A 422 (the units were moved meanwhile) or a 409 (another operation holds the stock) is explained inside the modal. Receiving and cancelling ask first, and a 409 on them refreshes the list because someone else already settled it. Co-Authored-By: Claude Opus 5.5 --- docs/guidelines/architecture.md | 17 +- src/features/inventory/api.ts | 58 +++++ .../ProductTransfers.styles.ts | 57 +++++ .../ProductTransfers.test.tsx | 133 +++++++++++ .../ProductTransfers/ProductTransfers.tsx | 225 ++++++++++++++++++ .../ProductTransfers.types.ts | 7 + .../components/ProductTransfers/index.ts | 2 + .../TransferStockModal.schema.ts | 44 ++++ .../TransferStockModal.test.tsx | 137 +++++++++++ .../TransferStockModal/TransferStockModal.tsx | 179 ++++++++++++++ .../TransferStockModal.types.ts | 13 + .../components/TransferStockModal/index.ts | 2 + src/features/inventory/content.ts | 66 +++++ src/features/inventory/hooks/useInventory.ts | 45 ++++ .../pages/ProductDetailPage.test.tsx | 21 +- .../inventory/pages/ProductDetailPage.tsx | 3 + src/features/inventory/queryKeys.ts | 3 + src/features/inventory/types.ts | 36 +++ 18 files changed, 1040 insertions(+), 8 deletions(-) create mode 100644 src/features/inventory/components/ProductTransfers/ProductTransfers.styles.ts create mode 100644 src/features/inventory/components/ProductTransfers/ProductTransfers.test.tsx create mode 100644 src/features/inventory/components/ProductTransfers/ProductTransfers.tsx create mode 100644 src/features/inventory/components/ProductTransfers/ProductTransfers.types.ts create mode 100644 src/features/inventory/components/ProductTransfers/index.ts create mode 100644 src/features/inventory/components/TransferStockModal/TransferStockModal.schema.ts create mode 100644 src/features/inventory/components/TransferStockModal/TransferStockModal.test.tsx create mode 100644 src/features/inventory/components/TransferStockModal/TransferStockModal.tsx create mode 100644 src/features/inventory/components/TransferStockModal/TransferStockModal.types.ts create mode 100644 src/features/inventory/components/TransferStockModal/index.ts diff --git a/docs/guidelines/architecture.md b/docs/guidelines/architecture.md index 02c5c52..a29bb43 100644 --- a/docs/guidelines/architecture.md +++ b/docs/guidelines/architecture.md @@ -363,13 +363,16 @@ features/[nombre]/ **`inventory` — piezas y por qué:** -| Archivo | Rol | -| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `components/EditProductModal/` | Modal de edición: datos básicos, medidas y asignación de stock. **Presentacional** — recibe `product` y `warehouses` ya resueltos y devuelve en `onSubmit` el cuerpo de `PUT /api/v1/products/:id`. Quien lo monta decide de dónde salen los datos | -| `components/CreateProductModal/` | Modal de alta, mismo contrato: devuelve el cuerpo de `POST /api/v1/products`. Valida depósitos repetidos en el schema y resalta el campo `sku` cuando la API rechaza el alta por SKU duplicado | -| `api.ts` | Frontera con Rails y único lugar que conoce el `snake_case`. También documenta que el index envuelve en `{ data, meta }` y usa `ProductListSerializer` (sin `stocks`), mientras `show`/`update` devuelven el objeto pelado | -| `utils/dimensions.ts` | `products.dimensions` es un único `string` en la API y el diseño lo edita en tres ejes. Define el formato canónico (`"45x30x30"`, cm) y es el único lugar que lo conoce | -| `utils/payload.ts` | Traduce el formulario al cuerpo de la API. Funciones puras, aparte del componente, porque concentran las reglas no obvias (ver abajo) y así se pueden verificar solas | +| Archivo | Rol | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `components/EditProductModal/` | Modal de edición: datos básicos, medidas y asignación de stock. **Presentacional** — recibe `product` y `warehouses` ya resueltos y devuelve en `onSubmit` el cuerpo de `PUT /api/v1/products/:id`. Quien lo monta decide de dónde salen los datos | +| `components/CreateProductModal/` | Modal de alta, mismo contrato: devuelve el cuerpo de `POST /api/v1/products`. Valida depósitos repetidos en el schema y resalta el campo `sku` cuando la API rechaza el alta por SKU duplicado | +| `components/TransferStockModal/` | Modal de transferencia entre depósitos. **Presentacional**, mismo contrato que los de producto: devuelve el cuerpo de `POST /api/v1/stock-transfers`. El origen sólo ofrece depósitos con unidades y el tope de cantidad sale del origen elegido (el schema se arma con esas cantidades) | +| `components/ProductTransfers/` | Las transferencias en vuelo del detalle (S12, sin maqueta), con su alta, recepción y cancelación. Monta el modal y un `ConfirmDialog`. Recibir y cancelar invalidan el dominio también al fallar: un 409 es una transferencia que otro ya liquidó | +| `utils/stock.ts` | Une las filas de `stocks` con el entrante por depósito (`distributionPositions`). No calcula estados: el `stock_status` de cada fila lo manda el backend | +| `api.ts` | Frontera con Rails y único lugar que conoce el `snake_case`. También documenta que el index envuelve en `{ data, meta }` y usa `ProductListSerializer` (sin `stocks`), mientras `show`/`update` devuelven el objeto pelado | +| `utils/dimensions.ts` | `products.dimensions` es un único `string` en la API y el diseño lo edita en tres ejes. Define el formato canónico (`"45x30x30"`, cm) y es el único lugar que lo conoce | +| `utils/payload.ts` | Traduce el formulario al cuerpo de la API. Funciones puras, aparte del componente, porque concentran las reglas no obvias (ver abajo) y así se pueden verificar solas | > El diálogo en sí se tematizó en `app/theme/components/dialog.ts` (`MuiDialog` en el nivel 3 de la escala de elevación + `MuiBackdrop` con scrim y blur), no dentro del modal: es un componente base de MUI y sus estilos van al tema — ver `component-structure.md` §3.1. diff --git a/src/features/inventory/api.ts b/src/features/inventory/api.ts index a581c86..4e8c84e 100644 --- a/src/features/inventory/api.ts +++ b/src/features/inventory/api.ts @@ -2,11 +2,15 @@ import { client } from 'shared/api/client' import type { CreateProductPayload, + CreateTransferPayload, Product, ProductFilters, ProductPage, ProductSummary, StockStatus, + StockTransfer, + TransferOutcome, + TransferStatus, UpdateProductPayload, Warehouse, } from './types' @@ -223,3 +227,57 @@ export async function updateProduct( export async function deleteProduct(id: number): Promise { await client.delete(`/products/${id}`) } + +interface ApiStockTransfer { + id: number + quantity: number + status: TransferStatus + dispatched_at: string + origin_warehouse: { id: number; name: string } + destination_warehouse: { id: number; name: string } +} + +function toTransfer(transfer: ApiStockTransfer): StockTransfer { + return { + id: transfer.id, + quantity: transfer.quantity, + status: transfer.status, + dispatchedAt: transfer.dispatched_at, + origin: transfer.origin_warehouse, + destination: transfer.destination_warehouse, + } +} + +/** + * Transferencias de un producto que todavía están en vuelo. + * + * `per_page` al techo de la API (100): la tarjeta del detalle no pagina, y un + * producto con más de cien transferencias abiertas a la vez no es un caso del + * negocio. Si pasara, `meta.total` lo diría; acá no se muestra. + */ +export async function fetchTransfers(productId: number): Promise { + const { data } = await client.get>('/stock-transfers', { + params: { product_id: productId, status: 'in_transit', per_page: 100 }, + }) + + return data.data.map(toTransfer) +} + +/** + * Despacha una transferencia: el backend descuenta el origen en la misma + * operación. Responde **422** si el origen no tiene esas unidades (alguien las + * movió mientras el modal estaba abierto) y **409** si otra operación tiene + * tomado el stock del producto en ese momento. + */ +export async function createTransfer(payload: CreateTransferPayload): Promise { + const { data } = await client.post('/stock-transfers', payload) + + return toTransfer(data) +} + +/** Recibe o cancela. Responde **409** si la transferencia ya se liquidó. */ +export async function settleTransfer(id: number, outcome: TransferOutcome): Promise { + const { data } = await client.post(`/stock-transfers/${id}/${outcome}`) + + return toTransfer(data) +} diff --git a/src/features/inventory/components/ProductTransfers/ProductTransfers.styles.ts b/src/features/inventory/components/ProductTransfers/ProductTransfers.styles.ts new file mode 100644 index 0000000..a61e09d --- /dev/null +++ b/src/features/inventory/components/ProductTransfers/ProductTransfers.styles.ts @@ -0,0 +1,57 @@ +import { Box, Card, List, ListItem } from '@mui/material' +import { styled } from '@mui/material/styles' + +export const TransfersCard = styled(Card)({ + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', +}) + +// Mismo alto y separador que la cabecera de la distribución por depósito. +export const CardHeader = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap', + gap: theme.spacing(2), + padding: theme.spacing(1, 2), + minHeight: 48, + borderBottom: `1px solid ${theme.vars.palette.divider}`, +})) + +export const HeaderText = styled(Box)({ + display: 'flex', + flexDirection: 'column', + flex: '1 1 240px', + minWidth: 0, +}) + +export const TransferList = styled(List)({ + padding: 0, +}) + +export const TransferRow = styled(ListItem)(({ theme }) => ({ + display: 'flex', + flexWrap: 'wrap', + alignItems: 'center', + gap: theme.spacing(2), + padding: theme.spacing(1.5, 2), + borderBottom: `1px solid ${theme.vars.palette.divider}`, + '&:last-of-type': { borderBottom: 'none' }, +})) + +export const TransferText = styled(Box)({ + display: 'flex', + flexDirection: 'column', + flex: '1 1 220px', + minWidth: 0, +}) + +export const TransferActions = styled(Box)(({ theme }) => ({ + display: 'flex', + gap: theme.spacing(1), + marginLeft: 'auto', +})) + +export const EmptyState = styled(Box)(({ theme }) => ({ + padding: theme.spacing(3, 2), +})) diff --git a/src/features/inventory/components/ProductTransfers/ProductTransfers.test.tsx b/src/features/inventory/components/ProductTransfers/ProductTransfers.test.tsx new file mode 100644 index 0000000..7fc3a3a --- /dev/null +++ b/src/features/inventory/components/ProductTransfers/ProductTransfers.test.tsx @@ -0,0 +1,133 @@ +import { fireEvent, screen, within } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { renderWithTheme } from '../../../../test/renderWithTheme' +import type * as inventoryHooks from '../../hooks/useInventory' +import { useCreateTransfer, useProductTransfers, useSettleTransfer } from '../../hooks/useInventory' +import type { Product, StockTransfer, Warehouse } from '../../types' + +import { ProductTransfers } from './ProductTransfers' + +vi.mock('../../hooks/useInventory', async (importOriginal) => ({ + ...(await importOriginal()), + useProductTransfers: vi.fn(), + useCreateTransfer: vi.fn(), + useSettleTransfer: vi.fn(), +})) + +const CENTRAL: Warehouse = { id: 1, name: 'Central', address: 'Av. 7' } +const SUR: Warehouse = { id: 3, name: 'Sur', address: 'Av. Colón' } + +const WAREHOUSES: Warehouse[] = [CENTRAL, SUR] + +const PRODUCT: Product = { + id: 7, + sku: 'NOR-003', + name: 'Mouse', + description: null, + category: null, + weight: 0.1, + dimensions: null, + stocks: [{ warehouseId: 1, quantity: 100, warehouse: CENTRAL, stockStatus: 'low' }], + totalStock: 100, + stockStatus: 'low', + inTransitQuantity: 4, + inTransitByWarehouse: [{ warehouseId: 3, name: 'Sur', quantity: 4 }], + updatedAt: '2026-09-01T10:00:00Z', + version: null, +} + +const TRANSFER: StockTransfer = { + id: 55, + quantity: 4, + status: 'in_transit', + dispatchedAt: '2026-09-30T10:00:00Z', + origin: { id: 1, name: 'Central' }, + destination: { id: 3, name: 'Sur' }, +} + +const settle = vi.fn() + +function listTransfers(data: StockTransfer[]) { + vi.mocked(useProductTransfers).mockReturnValue({ + data, + isPending: false, + isError: false, + } as never) +} + +beforeEach(() => { + settle.mockReset() + listTransfers([TRANSFER]) + vi.mocked(useCreateTransfer).mockReturnValue({ + mutate: vi.fn(), + reset: vi.fn(), + isPending: false, + isError: false, + error: null, + } as never) + vi.mocked(useSettleTransfer).mockReturnValue({ mutate: settle, isPending: false } as never) +}) + +const renderSection = (product: Product = PRODUCT, warehouses: Warehouse[] = WAREHOUSES) => + renderWithTheme() + +const transferRow = () => + within(screen.getByRole('list', { name: 'Transferencias en curso' })).getByRole('listitem') + +describe('ProductTransfers', () => { + it('lists the transfers in flight with their route and units', () => { + renderSection() + + expect(transferRow()).toHaveTextContent('Central → Sur') + expect(transferRow()).toHaveTextContent('4 unidades') + }) + + it('says so when nothing is in flight', () => { + listTransfers([]) + renderSection() + + expect(screen.getByText('No hay transferencias en curso para este producto.')).toBeVisible() + }) + + it('asks before receiving and then receives', () => { + renderSection() + + fireEvent.click(within(transferRow()).getByRole('button', { name: 'Recibir' })) + expect(screen.getByText('Se van a sumar 4 unidades al stock de Sur.')).toBeInTheDocument() + fireEvent.click( + within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Recibir' }), + ) + + expect(settle).toHaveBeenCalledWith({ id: 55, outcome: 'receive' }, expect.anything()) + }) + + it('asks before cancelling, as a destructive action', () => { + renderSection() + + fireEvent.click(within(transferRow()).getByRole('button', { name: 'Cancelar' })) + fireEvent.click(screen.getByRole('button', { name: 'Cancelar transferencia' })) + + expect(settle).toHaveBeenCalledWith({ id: 55, outcome: 'cancel' }, expect.anything()) + }) + + it('opens the transfer form', () => { + renderSection() + + fireEvent.click(screen.getByRole('button', { name: 'Transferir stock' })) + + expect(screen.getByRole('combobox', { name: 'Depósito de origen' })).toBeInTheDocument() + }) + + it('turns the transfer off when there is no other warehouse', () => { + renderSection(PRODUCT, [CENTRAL]) + + expect(screen.getByRole('button', { name: 'Transferir stock' })).toBeDisabled() + }) + + it('turns the transfer off when no warehouse holds units', () => { + renderSection({ ...PRODUCT, stocks: [] }) + + expect(screen.getByRole('button', { name: 'Transferir stock' })).toBeDisabled() + }) +}) diff --git a/src/features/inventory/components/ProductTransfers/ProductTransfers.tsx b/src/features/inventory/components/ProductTransfers/ProductTransfers.tsx new file mode 100644 index 0000000..7005b11 --- /dev/null +++ b/src/features/inventory/components/ProductTransfers/ProductTransfers.tsx @@ -0,0 +1,225 @@ +import SwapHorizIcon from '@mui/icons-material/SwapHoriz' +import { Alert, Button, Tooltip, Typography } from '@mui/material' +import { useMemo, useState } from 'react' +import { ConfirmDialog, LoadingSpinner } from 'shared/components' +import { notify } from 'shared/store' +import { formatRelativeTime } from 'shared/utils' + +import { inventoryCopy } from '../../content' +import { + CONFLICT_STATUS_SETTLED, + useCreateTransfer, + useProductTransfers, + useSettleTransfer, +} from '../../hooks/useInventory' +import type { CreateTransferPayload, StockTransfer, TransferOutcome } from '../../types' +import { TransferStockModal } from '../TransferStockModal' + +import { + CardHeader, + EmptyState, + HeaderText, + TransferActions, + TransferList, + TransferRow, + TransfersCard, + TransferText, +} from './ProductTransfers.styles' +import type { ProductTransfersProps } from './ProductTransfers.types' + +const copy = inventoryCopy.detail.transfers +const modalCopy = inventoryCopy.detail.transferModal + +// Status con los que la API rechaza despachar: el origen ya no tiene esas +// unidades (422) o el stock del producto está tomado por otra operación (409). +const INSUFFICIENT_STATUS = 422 +const LOCKED_STATUS = 409 + +interface PendingSettlement { + transfer: StockTransfer + outcome: TransferOutcome +} + +function createErrorMessage(status: number | undefined): string { + if (status === INSUFFICIENT_STATUS) return modalCopy.insufficient + if (status === LOCKED_STATUS) return modalCopy.busy + return modalCopy.failed +} + +/** + * Transferencias en vuelo de un producto, con el alta y la liquidación. + * + * Vive en el detalle (S12) aunque el diseño no la tiene: la API de + * transferencias existe desde TESIS-103 y era la única forma de mover unidades + * entre depósitos que no tenía pantalla. + */ +export function ProductTransfers({ product, warehouses }: ProductTransfersProps) { + const transfers = useProductTransfers(product.id) + const create = useCreateTransfer() + const settle = useSettleTransfer() + const [creating, setCreating] = useState(false) + const [pending, setPending] = useState(undefined) + + const canCreate = product.stocks.some((stock) => stock.quantity > 0) && warehouses.length > 1 + + // Con `useMemo` el "hace 2 horas" no salta por un re-render del padre: mismo + // criterio que el pie del modal de edición. + const rows = useMemo( + () => + (transfers.data ?? []).map((transfer) => ({ + transfer, + dispatched: formatRelativeTime(transfer.dispatchedAt), + })), + [transfers.data], + ) + + function submit(payload: CreateTransferPayload) { + create.mutate(payload, { + onSuccess: () => { + notify(modalCopy.created, 'success') + setCreating(false) + }, + }) + } + + function confirmSettlement() { + if (pending === undefined) return + + settle.mutate( + { id: pending.transfer.id, outcome: pending.outcome }, + { + onSuccess: () => + notify(pending.outcome === 'receive' ? copy.received : copy.cancelled, 'success'), + // Un 409 quiere decir que otra persona ya la liquidó: la invalidación + // del hook la saca de la lista y el aviso dice por qué desapareció. + onError: (error) => + notify( + error.status === CONFLICT_STATUS_SETTLED ? copy.alreadySettled : copy.settleFailed, + error.status === CONFLICT_STATUS_SETTLED ? 'info' : 'error', + ), + onSettled: () => setPending(undefined), + }, + ) + } + + const createButton = ( + + ) + + return ( + + + + + {copy.title} + + + {copy.subtitle} + + + {/* El botón apagado dice por qué: un `span` porque MUI no muestra el + tooltip sobre un botón deshabilitado. */} + {canCreate ? ( + createButton + ) : ( + + {createButton} + + )} + + + {transfers.isPending ? ( + + + + ) : transfers.isError ? ( + + {copy.error} + + ) : rows.length === 0 ? ( + + + {copy.empty} + + + ) : ( + + {rows.map(({ transfer, dispatched }) => ( + + + + {copy.route(transfer.origin.name, transfer.destination.name)} + + + {copy.units(transfer.quantity)} + {dispatched === null ? '' : ` · ${copy.dispatchedAt(dispatched)}`} + + + + + + + + ))} + + )} + + setCreating(false)} + /> + + setPending(undefined)} + /> + + ) +} diff --git a/src/features/inventory/components/ProductTransfers/ProductTransfers.types.ts b/src/features/inventory/components/ProductTransfers/ProductTransfers.types.ts new file mode 100644 index 0000000..3c3dacc --- /dev/null +++ b/src/features/inventory/components/ProductTransfers/ProductTransfers.types.ts @@ -0,0 +1,7 @@ +import type { Product, Warehouse } from '../../types' + +export interface ProductTransfersProps { + product: Product + /** Todos los depósitos de la empresa, para el destino de una transferencia nueva. */ + warehouses: Warehouse[] +} diff --git a/src/features/inventory/components/ProductTransfers/index.ts b/src/features/inventory/components/ProductTransfers/index.ts new file mode 100644 index 0000000..e3cda0f --- /dev/null +++ b/src/features/inventory/components/ProductTransfers/index.ts @@ -0,0 +1,2 @@ +export { ProductTransfers } from './ProductTransfers' +export type { ProductTransfersProps } from './ProductTransfers.types' diff --git a/src/features/inventory/components/TransferStockModal/TransferStockModal.schema.ts b/src/features/inventory/components/TransferStockModal/TransferStockModal.schema.ts new file mode 100644 index 0000000..a8f9ce9 --- /dev/null +++ b/src/features/inventory/components/TransferStockModal/TransferStockModal.schema.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' + +import { inventoryCopy } from '../../content' + +const { validation } = inventoryCopy.detail.transferModal + +/** + * Fuente única del formulario (ADR-006). El tope de cantidad depende del + * depósito elegido, así que el schema se arma con las unidades de cada origen. + * + * Los selects arrancan vacíos: `0` no es un id válido y el mensaje es el de + * "elegí", no uno de tipo. + */ +export function transferStockSchema(unitsByWarehouse: ReadonlyMap) { + return z + .object({ + originId: z.number().int().positive(validation.originRequired), + destinationId: z.number().int().positive(validation.destinationRequired), + quantity: z + .number({ error: validation.quantityRequired }) + .int(validation.quantityInteger) + .positive(validation.quantityPositive), + }) + .superRefine((data, context) => { + if (data.originId > 0 && data.originId === data.destinationId) { + context.addIssue({ + code: 'custom', + path: ['destinationId'], + message: validation.sameWarehouse, + }) + } + + const available = unitsByWarehouse.get(data.originId) + if (available !== undefined && data.quantity > available) { + context.addIssue({ + code: 'custom', + path: ['quantity'], + message: validation.quantityTooHigh(available), + }) + } + }) +} + +export type TransferStockFormData = z.infer> diff --git a/src/features/inventory/components/TransferStockModal/TransferStockModal.test.tsx b/src/features/inventory/components/TransferStockModal/TransferStockModal.test.tsx new file mode 100644 index 0000000..ea0df42 --- /dev/null +++ b/src/features/inventory/components/TransferStockModal/TransferStockModal.test.tsx @@ -0,0 +1,137 @@ +import { fireEvent, screen, waitFor, within } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { renderWithTheme } from '../../../../test/renderWithTheme' +import type { Product, Warehouse } from '../../types' + +import { TransferStockModal } from './TransferStockModal' + +const CENTRAL: Warehouse = { id: 1, name: 'Central', address: 'Av. 7' } +const SATELITE: Warehouse = { id: 2, name: 'Satélite', address: 'Calle 25' } +const SUR: Warehouse = { id: 3, name: 'Sur', address: 'Av. Colón' } + +const WAREHOUSES: Warehouse[] = [CENTRAL, SATELITE, SUR] + +const PRODUCT: Product = { + id: 7, + sku: 'NOR-003', + name: 'Mouse', + description: null, + category: null, + weight: 0.1, + dimensions: null, + stocks: [ + { warehouseId: 1, quantity: 100, warehouse: CENTRAL, stockStatus: 'low' }, + // Sin unidades: no puede ser origen. + { warehouseId: 2, quantity: 0, warehouse: SATELITE, stockStatus: 'out_of_stock' }, + ], + totalStock: 100, + stockStatus: 'low', + inTransitQuantity: 0, + inTransitByWarehouse: [], + updatedAt: '2026-09-01T10:00:00Z', + version: null, +} + +function renderModal(props: Partial[0]> = {}) { + const onSubmit = vi.fn() + renderWithTheme( + , + ) + return onSubmit +} + +async function choose(field: string, option: RegExp) { + fireEvent.mouseDown(screen.getByRole('combobox', { name: field })) + fireEvent.click(await screen.findByRole('option', { name: option })) +} + +function optionsOf(field: string) { + fireEvent.mouseDown(screen.getByRole('combobox', { name: field })) + return within(screen.getByRole('listbox')) + .getAllByRole('option') + .map((option) => option.textContent) +} + +function typeQuantity(value: string) { + fireEvent.change(screen.getByRole('spinbutton', { name: /^Cantidad/ }), { target: { value } }) +} + +const submit = () => fireEvent.click(screen.getByRole('button', { name: 'Transferir' })) + +describe('TransferStockModal', () => { + it('offers as origin only the warehouses that hold units', () => { + renderModal() + + expect(optionsOf('Depósito de origen')).toEqual(['—', 'Central · 100 u.']) + }) + + it('leaves the chosen origin out of the destinations', async () => { + renderModal() + await choose('Depósito de origen', /Central/) + + expect(optionsOf('Depósito de destino')).toEqual(['—', 'Satélite', 'Sur']) + }) + + it('sends the body the API expects', async () => { + const onSubmit = renderModal() + await choose('Depósito de origen', /Central/) + await choose('Depósito de destino', /Sur/) + typeQuantity('30') + submit() + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith({ + stock_transfer: { + product_id: 7, + origin_warehouse_id: 1, + destination_warehouse_id: 3, + quantity: 30, + }, + }), + ) + }) + + it('refuses more units than the origin holds', async () => { + const onSubmit = renderModal() + await choose('Depósito de origen', /Central/) + await choose('Depósito de destino', /Sur/) + typeQuantity('101') + submit() + + expect(await screen.findByText('El origen tiene 100 unidades.')).toBeInTheDocument() + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('refuses zero units', async () => { + renderModal() + await choose('Depósito de origen', /Central/) + await choose('Depósito de destino', /Sur/) + typeQuantity('0') + submit() + + expect(await screen.findByText('La cantidad tiene que ser mayor a cero.')).toBeInTheDocument() + }) + + it('asks for both warehouses before sending', async () => { + renderModal() + typeQuantity('5') + submit() + + expect(await screen.findByText('Elegí el depósito de origen.')).toBeInTheDocument() + expect(screen.getByText('Elegí el depósito de destino.')).toBeInTheDocument() + }) + + it('shows the rejection of the API inside the modal', () => { + renderModal({ error: 'El depósito de origen ya no tiene esas unidades.' }) + + expect(screen.getByRole('alert')).toHaveTextContent('ya no tiene esas unidades') + }) +}) diff --git a/src/features/inventory/components/TransferStockModal/TransferStockModal.tsx b/src/features/inventory/components/TransferStockModal/TransferStockModal.tsx new file mode 100644 index 0000000..0e2bd19 --- /dev/null +++ b/src/features/inventory/components/TransferStockModal/TransferStockModal.tsx @@ -0,0 +1,179 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { Alert, Button, MenuItem, TextField } from '@mui/material' +import { useEffect, useMemo } from 'react' +import { useForm, useWatch } from 'react-hook-form' +import { + LabeledField, + ModalBody, + ModalFooter, + ModalFooterActions, + ModalFooterNote, + ModalForm, + ModalFrame, +} from 'shared/components' + +import { inventoryCopy } from '../../content' + +import { transferStockSchema } from './TransferStockModal.schema' +import type { TransferStockFormData } from './TransferStockModal.schema' +import type { TransferStockModalProps } from './TransferStockModal.types' + +const copy = inventoryCopy.detail.transferModal + +const EMPTY_FORM: TransferStockFormData = { originId: 0, destinationId: 0, quantity: Number.NaN } + +/** + * Modal para mover unidades de un producto entre dos depósitos. + * + * Presentacional, como el de edición: recibe el producto y los depósitos y + * entrega en `onSubmit` el cuerpo listo para `POST /api/v1/stock-transfers`. + * + * El origen sólo ofrece depósitos con unidades del producto: transferir desde + * uno vacío siempre termina en 422. El destino ofrece cualquier otro depósito + * de la empresa, tenga o no stock del producto. + */ +export function TransferStockModal({ + open, + product, + warehouses, + submitting = false, + error, + onSubmit, + onClose, +}: TransferStockModalProps) { + const origins = useMemo( + () => product.stocks.filter((stock) => stock.quantity > 0), + [product.stocks], + ) + const unitsByWarehouse = useMemo( + () => new Map(origins.map((stock) => [stock.warehouseId, stock.quantity])), + [origins], + ) + + const { + control, + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(transferStockSchema(unitsByWarehouse)), + defaultValues: EMPTY_FORM, + }) + + // Cada apertura arranca de cero: un formulario a medio cargar de la vez + // anterior podría despachar unidades que el usuario ya no quería mover. + useEffect(() => { + if (open) reset(EMPTY_FORM) + }, [open, reset]) + + const originId = useWatch({ control, name: 'originId' }) + const available = unitsByWarehouse.get(originId) + const destinations = warehouses.filter((warehouse) => warehouse.id !== originId) + + const submit = handleSubmit((data) => + onSubmit({ + stock_transfer: { + product_id: product.id, + origin_warehouse_id: data.originId, + destination_warehouse_id: data.destinationId, + quantity: data.quantity, + }, + }), + ) + + return ( + + + + {error === undefined ? null : {error}} + + + ` de LabeledField no nombra al combobox: el Select de + // MUI lo dibuja en un `div`, que no es un control etiquetable. + slotProps={{ select: { SelectDisplayProps: { 'aria-label': copy.fields.origin } } }} + > + + — + + {origins.map((stock) => ( + + {copy.originOption(stock.warehouse.name, stock.quantity)} + + ))} + + + + + ` de LabeledField no nombra al combobox: el Select de + // MUI lo dibuja en un `div`, que no es un control etiquetable. + slotProps={{ + select: { SelectDisplayProps: { 'aria-label': copy.fields.destination } }, + }} + > + + — + + {destinations.map((warehouse) => ( + + {warehouse.name} + + ))} + + + + + + + + + + {copy.note} + + + + + + + + ) +} diff --git a/src/features/inventory/components/TransferStockModal/TransferStockModal.types.ts b/src/features/inventory/components/TransferStockModal/TransferStockModal.types.ts new file mode 100644 index 0000000..9900578 --- /dev/null +++ b/src/features/inventory/components/TransferStockModal/TransferStockModal.types.ts @@ -0,0 +1,13 @@ +import type { CreateTransferPayload, Product, Warehouse } from '../../types' + +export interface TransferStockModalProps { + open: boolean + product: Product + /** Todos los depósitos de la empresa: el destino puede no tener stock del producto. */ + warehouses: Warehouse[] + submitting?: boolean + /** Mensaje del rechazo de la API, ya traducido. Se muestra dentro del modal. */ + error?: string + onSubmit: (payload: CreateTransferPayload) => void + onClose: () => void +} diff --git a/src/features/inventory/components/TransferStockModal/index.ts b/src/features/inventory/components/TransferStockModal/index.ts new file mode 100644 index 0000000..9ed2289 --- /dev/null +++ b/src/features/inventory/components/TransferStockModal/index.ts @@ -0,0 +1,2 @@ +export { TransferStockModal } from './TransferStockModal' +export type { TransferStockModalProps } from './TransferStockModal.types' diff --git a/src/features/inventory/content.ts b/src/features/inventory/content.ts index 0a54305..ee496b2 100644 --- a/src/features/inventory/content.ts +++ b/src/features/inventory/content.ts @@ -141,6 +141,72 @@ export const inventoryCopy = { pending: 'En tránsito son las unidades que vienen hacia cada depósito. Comprometido espera que la API modele las reservas.', }, + transfers: { + title: 'Transferencias en curso', + subtitle: 'Unidades que salieron de un depósito y todavía no llegaron al otro.', + create: 'Transferir stock', + /** Por qué el botón está apagado: sin origen con unidades o sin otro depósito. */ + createDisabled: 'Hace falta un depósito con unidades y otro depósito de destino.', + empty: 'No hay transferencias en curso para este producto.', + error: 'No pudimos cargar las transferencias.', + route: (origin: string, destination: string) => `${origin} → ${destination}`, + units: (quantity: number) => + quantity === 1 ? '1 unidad' : `${formatUnits(quantity)} unidades`, + dispatchedAt: (when: string) => `Despachada ${when}`, + receive: 'Recibir', + cancel: 'Cancelar', + listLabel: 'Transferencias en curso', + receiveConfirm: { + title: 'Recibir transferencia', + body: (quantity: number, destination: string) => + `Se van a sumar ${quantity === 1 ? '1 unidad' : `${formatUnits(quantity)} unidades`} al stock de ${destination}.`, + confirm: 'Recibir', + }, + cancelConfirm: { + title: 'Cancelar transferencia', + body: (quantity: number, origin: string) => + `Las ${quantity === 1 ? 'unidad vuelve' : `${formatUnits(quantity)} unidades vuelven`} a ${origin}. Esta acción no se puede deshacer.`, + confirm: 'Cancelar transferencia', + }, + keep: 'Volver', + close: 'Cerrar', + received: 'Transferencia recibida.', + cancelled: 'Transferencia cancelada.', + /** 409: otra pestaña o persona ya la recibió o la canceló. */ + alreadySettled: 'Esa transferencia ya se había liquidado. La lista está actualizada.', + settleFailed: 'No pudimos actualizar la transferencia. Probá de nuevo.', + }, + transferModal: { + title: 'Transferir stock', + subtitle: (sku: string) => `Mover unidades del SKU ${sku} entre depósitos.`, + fields: { + origin: 'Depósito de origen', + destination: 'Depósito de destino', + quantity: 'Cantidad', + }, + originOption: (name: string, quantity: number) => `${name} · ${formatUnits(quantity)} u.`, + quantityHelper: (max: number) => `Hasta ${formatUnits(max)} unidades.`, + note: 'Las unidades se descuentan del origen al confirmar y llegan al destino cuando se recibe.', + cancel: 'Cancelar', + submit: 'Transferir', + close: 'Cerrar', + created: 'Transferencia creada.', + validation: { + originRequired: 'Elegí el depósito de origen.', + destinationRequired: 'Elegí el depósito de destino.', + sameWarehouse: 'El destino tiene que ser otro depósito.', + quantityRequired: 'Ingresá una cantidad.', + quantityInteger: 'La cantidad tiene que ser un número entero.', + quantityPositive: 'La cantidad tiene que ser mayor a cero.', + quantityTooHigh: (max: number) => `El origen tiene ${formatUnits(max)} unidades.`, + }, + /** 422: el origen ya no tiene esas unidades (alguien las movió en el medio). */ + insufficient: + 'El depósito de origen ya no tiene esas unidades. Revisá el stock y probá de nuevo.', + /** 409: el stock del producto está tomado por otra operación en ese momento. */ + busy: 'Otra operación está moviendo stock de este producto. Probá de nuevo en unos segundos.', + failed: 'No pudimos crear la transferencia.', + }, notFound: 'No encontramos el producto que buscabas.', backToCatalog: 'Volver al catálogo', }, diff --git a/src/features/inventory/hooks/useInventory.ts b/src/features/inventory/hooks/useInventory.ts index 7e2c47e..94fdeed 100644 --- a/src/features/inventory/hooks/useInventory.ts +++ b/src/features/inventory/hooks/useInventory.ts @@ -9,20 +9,26 @@ import type { ApiRequestError } from 'shared/api/types' import { createProduct, + createTransfer, fetchProduct, deleteProduct, fetchProductCount, fetchProductPage, + fetchTransfers, fetchWarehouses, + settleTransfer, updateProduct, } from '../api' import { inventoryKeys } from '../queryKeys' import type { CreateProductPayload, + CreateTransferPayload, Product, ProductFilters, ProductPage, StockStatus, + StockTransfer, + TransferOutcome, UpdateProductPayload, Warehouse, } from '../types' @@ -141,3 +147,42 @@ export function useDeleteProduct() { onSuccess: () => queryClient.invalidateQueries({ queryKey: inventoryKeys.all }), }) } + +/** Status con el que la API rechaza liquidar una transferencia que ya no está en vuelo. */ +export const CONFLICT_STATUS_SETTLED = 409 + +/** Transferencias en vuelo de un producto, para la tarjeta del detalle. */ +export function useProductTransfers(productId: number | undefined) { + return useQuery({ + queryKey: inventoryKeys.transfers(productId ?? 0), + queryFn: () => fetchTransfers(productId ?? 0), + enabled: productId !== undefined, + }) +} + +/** + * Despacho de una transferencia. Invalida todo el dominio: cambian el stock del + * origen, el `total_stock` del listado, el en tránsito y esta misma lista. + */ +export function useCreateTransfer() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: createTransfer, + onSuccess: () => queryClient.invalidateQueries({ queryKey: inventoryKeys.all }), + }) +} + +/** + * Recepción o cancelación. Invalida también al fallar: el caso real de error es + * el 409 de una transferencia que ya se liquidó en otra pestaña, y la lista + * tiene que dejar de ofrecerla. + */ +export function useSettleTransfer() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ id, outcome }) => settleTransfer(id, outcome), + onSettled: () => queryClient.invalidateQueries({ queryKey: inventoryKeys.all }), + }) +} diff --git a/src/features/inventory/pages/ProductDetailPage.test.tsx b/src/features/inventory/pages/ProductDetailPage.test.tsx index da828d7..b25ff03 100644 --- a/src/features/inventory/pages/ProductDetailPage.test.tsx +++ b/src/features/inventory/pages/ProductDetailPage.test.tsx @@ -4,7 +4,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderWithTheme } from '../../../test/renderWithTheme' import type * as inventoryHooks from '../hooks/useInventory' -import { useProduct, useUpdateProduct, useWarehouses } from '../hooks/useInventory' +import { + useCreateTransfer, + useProduct, + useProductTransfers, + useSettleTransfer, + useUpdateProduct, + useWarehouses, +} from '../hooks/useInventory' import type { Product } from '../types' import { ProductDetailPage } from './ProductDetailPage' @@ -14,6 +21,9 @@ vi.mock('../hooks/useInventory', async (importOriginal) => ({ useProduct: vi.fn(), useWarehouses: vi.fn(), useUpdateProduct: vi.fn(), + useProductTransfers: vi.fn(), + useCreateTransfer: vi.fn(), + useSettleTransfer: vi.fn(), })) const PRODUCT: Product = { @@ -124,6 +134,15 @@ beforeEach(() => { isPending: false, error: null, } as never) + // La sección de transferencias tiene sus propios tests: acá sólo se monta. + vi.mocked(useProductTransfers).mockReturnValue({ + data: [], + isPending: false, + isError: false, + } as never) + const idle = { mutate: vi.fn(), reset: vi.fn(), isPending: false, isError: false, error: null } + vi.mocked(useCreateTransfer).mockReturnValue(idle as never) + vi.mocked(useSettleTransfer).mockReturnValue(idle as never) }) describe('ProductDetailPage', () => { diff --git a/src/features/inventory/pages/ProductDetailPage.tsx b/src/features/inventory/pages/ProductDetailPage.tsx index f6d737c..959e639 100644 --- a/src/features/inventory/pages/ProductDetailPage.tsx +++ b/src/features/inventory/pages/ProductDetailPage.tsx @@ -13,6 +13,7 @@ import type { StockBucket } from '../components/MasterStockCard' import { ProductDetailHeader } from '../components/ProductDetailHeader' import { ProductSpecsCard } from '../components/ProductSpecsCard' import type { ProductSpec } from '../components/ProductSpecsCard' +import { ProductTransfers } from '../components/ProductTransfers' import { WarehouseDistributionCard } from '../components/WarehouseDistributionCard' import type { WarehouseDistributionRow } from '../components/WarehouseDistributionCard' import { inventoryCopy } from '../content' @@ -279,6 +280,8 @@ export function ProductDetailPage() { /> + + {/* El 412 no es un error a mostrar acá: lo explica el propio modal, que queda abierto con lo que el usuario cargó. La condición mira `isConflict` y no `conflict`, que es `undefined` también mientras se diff --git a/src/features/inventory/queryKeys.ts b/src/features/inventory/queryKeys.ts index 4c486ad..81d937b 100644 --- a/src/features/inventory/queryKeys.ts +++ b/src/features/inventory/queryKeys.ts @@ -14,4 +14,7 @@ export const inventoryKeys = { [...inventoryKeys.counts(), status ?? 'all', search] as const, product: (id: number) => [...inventoryKeys.products(), 'detail', id] as const, warehouses: () => [...inventoryKeys.all, 'warehouses'] as const, + // Cuelga de `all` y no de `product(id)`: las mutaciones de stock invalidan + // `all`, y una transferencia nueva tiene que aparecer en esta lista también. + transfers: (productId: number) => [...inventoryKeys.all, 'transfers', productId] as const, } diff --git a/src/features/inventory/types.ts b/src/features/inventory/types.ts index 64632ed..5e68ce9 100644 --- a/src/features/inventory/types.ts +++ b/src/features/inventory/types.ts @@ -167,3 +167,39 @@ export interface UpdateProductPayload { stocks: { warehouse_id: number; quantity: number }[] } } + +/** Estados de una transferencia (`StockTransfer::STATUSES`). */ +export type TransferStatus = 'in_transit' | 'received' | 'cancelled' + +/** Un depósito tal como lo nombra una transferencia: sólo su identidad. */ +export interface TransferWarehouse { + id: number + name: string +} + +/** + * Unidades de un producto que salieron de un depósito hacia otro + * (`GET /api/v1/stock-transfers`). Crearla ES despacharla: el origen ya se + * descontó. Recibirla suma al destino; cancelarla devuelve al origen. + */ +export interface StockTransfer { + id: number + quantity: number + status: TransferStatus + dispatchedAt: string + origin: TransferWarehouse + destination: TransferWarehouse +} + +/** Cuerpo de `POST /api/v1/stock-transfers`, en el snake_case que espera Rails. */ +export interface CreateTransferPayload { + stock_transfer: { + product_id: number + origin_warehouse_id: number + destination_warehouse_id: number + quantity: number + } +} + +/** Cómo se liquida una transferencia en vuelo: cada una es su propio endpoint. */ +export type TransferOutcome = 'receive' | 'cancel'