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'