From 293fe7663a37991d5c7fb33b4eb10341db71dc93 Mon Sep 17 00:00:00 2001 From: LauAubert Date: Fri, 2 Oct 2026 01:34:56 -0300 Subject: [PATCH] feat: [TESIS-999011] choose the category of a product when creating or editing it The API stores and validates the category since TESIS-102 and exposes its vocabulary at GET /products/categories, but both product modals still showed a disabled field saying the backend had no category. No product could get a category from the app, so the catalog column and filter had nothing to show. Both modals now offer the categories of the API plus "no category", through a shared controlled CategoryField. An empty choice travels as null. A category the product already has but the list does not include is still offered, so saving never clears it by accident. Co-Authored-By: Claude Opus 5.5 --- docs/guidelines/architecture.md | 1 + src/features/inventory/api.ts | 13 +++++ .../CategoryField/CategoryField.tsx | 47 +++++++++++++++++ .../CategoryField/CategoryField.types.ts | 11 ++++ .../components/CategoryField/index.ts | 2 + .../CreateProductModal.schema.ts | 6 ++- .../CreateProductModal/CreateProductModal.tsx | 22 ++++++-- .../CreateProductModal.types.ts | 2 + .../EditProductModal.schema.ts | 7 +-- .../EditProductModal.test.tsx | 51 +++++++++++++++++++ .../EditProductModal/EditProductModal.tsx | 22 ++++++-- .../EditProductModal.types.ts | 2 + src/features/inventory/content.ts | 6 ++- src/features/inventory/hooks/useInventory.ts | 13 +++++ .../inventory/pages/InventoryPage.test.tsx | 3 ++ .../inventory/pages/InventoryPage.tsx | 3 ++ .../pages/ProductDetailPage.test.tsx | 5 +- .../inventory/pages/ProductDetailPage.tsx | 10 +++- src/features/inventory/queryKeys.ts | 1 + src/features/inventory/types.ts | 5 ++ src/features/inventory/utils/conflict.test.ts | 1 + src/features/inventory/utils/payload.test.ts | 20 ++++++++ src/features/inventory/utils/payload.ts | 10 ++++ 23 files changed, 246 insertions(+), 17 deletions(-) create mode 100644 src/features/inventory/components/CategoryField/CategoryField.tsx create mode 100644 src/features/inventory/components/CategoryField/CategoryField.types.ts create mode 100644 src/features/inventory/components/CategoryField/index.ts diff --git a/docs/guidelines/architecture.md b/docs/guidelines/architecture.md index 02c5c52..cc483db 100644 --- a/docs/guidelines/architecture.md +++ b/docs/guidelines/architecture.md @@ -367,6 +367,7 @@ features/[nombre]/ | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `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/CategoryField/` | El select de categoría de los dos modales. Controlado (vía `Controller`) porque el de edición se rellena con `reset` al abrir. Las opciones salen de `GET /products/categories` (`useCategories`); `''` es «sin categoría» y viaja como `null` | | `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 | diff --git a/src/features/inventory/api.ts b/src/features/inventory/api.ts index fedcc04..2f29a8d 100644 --- a/src/features/inventory/api.ts +++ b/src/features/inventory/api.ts @@ -54,6 +54,7 @@ interface ApiProduct { sku: string name: string description: string | null + category: string | null weight: number dimensions: string | null updated_at: string @@ -81,6 +82,7 @@ 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, updatedAt: product.updated_at, @@ -207,3 +209,14 @@ export async function updateProduct( export async function deleteProduct(id: number): Promise { await client.delete(`/products/${id}`) } + +/** + * El vocabulario de categorías (`Product::CATEGORIES`). Sale del backend y no + * de una lista en el front: sumar una categoría es una línea en el modelo, y + * los selects de los modales la muestran sin tocar esta app. + */ +export async function fetchCategories(): Promise { + const { data } = await client.get<{ data: string[] }>('/products/categories') + + return data.data +} diff --git a/src/features/inventory/components/CategoryField/CategoryField.tsx b/src/features/inventory/components/CategoryField/CategoryField.tsx new file mode 100644 index 0000000..27409a0 --- /dev/null +++ b/src/features/inventory/components/CategoryField/CategoryField.tsx @@ -0,0 +1,47 @@ +import { MenuItem, TextField } from '@mui/material' +import { LabeledField } from 'shared/components' + +import type { CategoryFieldProps } from './CategoryField.types' + +/** + * El select de categoría de los dos modales de producto (Regla de Dos). + * + * Controlado y no registrado: el modal de edición se rellena con `reset` al + * abrir, y un select no controlado seguiría mostrando lo que tenía antes. + * + * Las opciones salen de `GET /products/categories`. Si la API todavía no + * respondió, o el producto trae una categoría que ya no está en el + * vocabulario, ese valor se ofrece igual: sin eso el select lo mostraría vacío + * y guardar lo borraría sin que nadie lo haya pedido. + */ +export function CategoryField({ + value, + onChange, + categories, + label, + helperText, + noneLabel, +}: CategoryFieldProps) { + const options = value !== '' && !categories.includes(value) ? [value, ...categories] : categories + + return ( + + onChange(event.target.value)} + fullWidth + // El ` + + ) +} diff --git a/src/features/inventory/components/CategoryField/CategoryField.types.ts b/src/features/inventory/components/CategoryField/CategoryField.types.ts new file mode 100644 index 0000000..5a8bfd8 --- /dev/null +++ b/src/features/inventory/components/CategoryField/CategoryField.types.ts @@ -0,0 +1,11 @@ +export interface CategoryFieldProps { + /** Valor actual (`''` = sin categoría). Controlado por el formulario. */ + value: string + onChange: (value: string) => void + /** El vocabulario de la API. */ + categories: string[] + label: string + helperText?: string + /** Rótulo de la opción vacía. */ + noneLabel: string +} diff --git a/src/features/inventory/components/CategoryField/index.ts b/src/features/inventory/components/CategoryField/index.ts new file mode 100644 index 0000000..6a1a87f --- /dev/null +++ b/src/features/inventory/components/CategoryField/index.ts @@ -0,0 +1,2 @@ +export { CategoryField } from './CategoryField' +export type { CategoryFieldProps } from './CategoryField.types' diff --git a/src/features/inventory/components/CreateProductModal/CreateProductModal.schema.ts b/src/features/inventory/components/CreateProductModal/CreateProductModal.schema.ts index 6a9c189..af75289 100644 --- a/src/features/inventory/components/CreateProductModal/CreateProductModal.schema.ts +++ b/src/features/inventory/components/CreateProductModal/CreateProductModal.schema.ts @@ -21,8 +21,9 @@ const stockRowSchema = z.object({ /** * Fuente única de verdad del alta: de acá salen la validación y el tipo. * - * La categoría queda fuera a propósito — `products` no tiene esa columna, así - * que el campo se pinta pero no se edita ni se envía. + * La categoría es un string y no un enum: el vocabulario llega de la API + * (`GET /products/categories`) y el select sólo ofrece esos valores. `''` es + * «sin categoría», que es válido: la columna es opcional. * * `description` tampoco está: el frame del alta no tiene ese campo, así que no * hay nada que validar. Viaja igual en el payload, en `null` explícito y no por @@ -32,6 +33,7 @@ const stockRowSchema = z.object({ export const createProductSchema = z.object({ name: z.string().trim().min(1, validation.nameRequired), sku: z.string().trim().min(1, validation.skuRequired), + category: z.string(), weight: nonNegativeNumber, length: nonNegativeNumber, width: nonNegativeNumber, diff --git a/src/features/inventory/components/CreateProductModal/CreateProductModal.tsx b/src/features/inventory/components/CreateProductModal/CreateProductModal.tsx index b781b5f..cb1c054 100644 --- a/src/features/inventory/components/CreateProductModal/CreateProductModal.tsx +++ b/src/features/inventory/components/CreateProductModal/CreateProductModal.tsx @@ -5,7 +5,7 @@ import StraightenOutlinedIcon from '@mui/icons-material/StraightenOutlined' import WarehouseOutlinedIcon from '@mui/icons-material/WarehouseOutlined' import { Alert, Button, Divider, MenuItem, TextField, Typography } from '@mui/material' import { useEffect } from 'react' -import { useFieldArray, useForm } from 'react-hook-form' +import { Controller, useFieldArray, useForm } from 'react-hook-form' import { LabeledField, ModalBody, @@ -17,6 +17,7 @@ import { import { inventoryCopy } from '../../content' import { buildCreatePayload } from '../../utils/payload' +import { CategoryField } from '../CategoryField' import { createProductSchema } from './CreateProductModal.schema' import type { CreateProductFormData } from './CreateProductModal.schema' @@ -51,6 +52,7 @@ function isSkuConflict(message: string) { const EMPTY_FORM: CreateProductFormData = { name: '', sku: '', + category: '', weight: 0, length: 0, width: 0, @@ -68,6 +70,7 @@ const EMPTY_FORM: CreateProductFormData = { export function CreateProductModal({ open, warehouses, + categories = [], onSubmit, onClose, submitting = false, @@ -154,9 +157,20 @@ export function CreateProductModal({ /> - - - + ( + + )} + /> diff --git a/src/features/inventory/components/CreateProductModal/CreateProductModal.types.ts b/src/features/inventory/components/CreateProductModal/CreateProductModal.types.ts index 41139a9..500d3bf 100644 --- a/src/features/inventory/components/CreateProductModal/CreateProductModal.types.ts +++ b/src/features/inventory/components/CreateProductModal/CreateProductModal.types.ts @@ -6,6 +6,8 @@ export interface CreateProductModalProps { open: boolean /** Depósitos de la empresa — alimentan los selectores de cada fila. */ warehouses: Warehouse[] + /** Vocabulario de categorías (`GET /products/categories`). */ + categories?: string[] /** Recibe el cuerpo ya armado para `POST /api/v1/products`. */ onSubmit: (payload: CreateProductPayload) => void onClose: () => void diff --git a/src/features/inventory/components/EditProductModal/EditProductModal.schema.ts b/src/features/inventory/components/EditProductModal/EditProductModal.schema.ts index 3700e3e..53ff260 100644 --- a/src/features/inventory/components/EditProductModal/EditProductModal.schema.ts +++ b/src/features/inventory/components/EditProductModal/EditProductModal.schema.ts @@ -20,12 +20,13 @@ const stockFieldSchema = z.object({ * Fuente única de verdad del formulario: de acá sale la validación y también el * tipo (`EditProductFormData`). Nunca declarar la interfaz por separado (ADR-006). * - * SKU y categoría quedan fuera del schema a propósito: el SKU es de solo lectura - * por diseño y la categoría todavía no existe en el backend. Son campos que se - * pintan pero no se editan ni se envían, así que no tienen nada que validar. + * El SKU queda fuera del schema a propósito: es de solo lectura por diseño, se + * pinta pero no se edita ni se envía. La categoría sí se edita: `''` es «sin + * categoría» (ver `CreateProductModal.schema.ts`). */ export const editProductSchema = z.object({ name: z.string().trim().min(1, validation.nameRequired), + category: z.string(), weight: nonNegativeNumber, length: nonNegativeNumber, width: nonNegativeNumber, diff --git a/src/features/inventory/components/EditProductModal/EditProductModal.test.tsx b/src/features/inventory/components/EditProductModal/EditProductModal.test.tsx index 0a19cc5..a0ba401 100644 --- a/src/features/inventory/components/EditProductModal/EditProductModal.test.tsx +++ b/src/features/inventory/components/EditProductModal/EditProductModal.test.tsx @@ -14,6 +14,7 @@ function product(overrides: Partial = {}): Product { description: 'Rollo de 305 metros', weight: 12.4, dimensions: '45x30x20', + category: null, stocks: [ { warehouseId: 1, @@ -31,6 +32,7 @@ function renderModal(initial: Product) { const props = { open: true, warehouses: [], + categories: ['Electronics', 'Cabling', 'Power'], onSubmit: vi.fn(), onClose: vi.fn(), } @@ -178,4 +180,53 @@ describe('EditProductModal', () => { expect(screen.getAllByRole('button', { name: /guardar/i })).toHaveLength(1) }) }) + + describe('the category', () => { + const categorySelect = () => screen.getByRole('combobox', { name: 'Categoría' }) + + async function choose(option: string) { + fireEvent.mouseDown(categorySelect()) + fireEvent.click(await screen.findByRole('option', { name: option })) + } + + async function submittedCategory(props: { onSubmit: ReturnType }) { + fireEvent.click(screen.getByRole('button', { name: 'Guardar cambios' })) + await waitFor(() => expect(props.onSubmit).toHaveBeenCalled()) + return (props.onSubmit.mock.calls[0]?.[0] as { product: { category: string | null } }).product + .category + } + + it('opens with the category of the product', () => { + renderModal(product({ category: 'Cabling' })) + + expect(categorySelect()).toHaveTextContent('Cabling') + }) + + it('offers the vocabulary of the API plus no category', () => { + renderModal(product()) + + fireEvent.mouseDown(categorySelect()) + + expect(screen.getAllByRole('option').map((option) => option.textContent)).toEqual([ + 'Sin categoría', + 'Electronics', + 'Cabling', + 'Power', + ]) + }) + + it('sends the category the user chose', async () => { + const { props } = renderModal(product({ category: 'Cabling' })) + await choose('Power') + + expect(await submittedCategory(props)).toBe('Power') + }) + + it('lets the user clear it', async () => { + const { props } = renderModal(product({ category: 'Cabling' })) + await choose('Sin categoría') + + expect(await submittedCategory(props)).toBeNull() + }) + }) }) diff --git a/src/features/inventory/components/EditProductModal/EditProductModal.tsx b/src/features/inventory/components/EditProductModal/EditProductModal.tsx index 60752d1..5bd68a2 100644 --- a/src/features/inventory/components/EditProductModal/EditProductModal.tsx +++ b/src/features/inventory/components/EditProductModal/EditProductModal.tsx @@ -2,7 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import AddIcon from '@mui/icons-material/Add' import { Alert, AlertTitle, Button, Menu, MenuItem, TextField, Typography } from '@mui/material' import { useEffect, useMemo, useRef, useState } from 'react' -import { useFieldArray, useForm } from 'react-hook-form' +import { Controller, useFieldArray, useForm } from 'react-hook-form' import { LabeledField, ModalBody, @@ -18,6 +18,7 @@ import { inventoryCopy } from '../../content' import type { Product } from '../../types' import { parseDimensions } from '../../utils/dimensions' import { buildUpdatePayload } from '../../utils/payload' +import { CategoryField } from '../CategoryField' import { editProductSchema } from './EditProductModal.schema' import type { EditProductFormData } from './EditProductModal.schema' @@ -42,6 +43,7 @@ function buildDefaults(product: Product): EditProductFormData { return { name: product.name, + category: product.category ?? '', weight: product.weight, length, width, @@ -66,6 +68,7 @@ export function EditProductModal({ open, product, warehouses, + categories = [], onSubmit, onClose, submitting = false, @@ -159,9 +162,20 @@ export function EditProductModal({ - - - + ( + + )} + /> diff --git a/src/features/inventory/components/EditProductModal/EditProductModal.types.ts b/src/features/inventory/components/EditProductModal/EditProductModal.types.ts index 46e5a57..95ca20e 100644 --- a/src/features/inventory/components/EditProductModal/EditProductModal.types.ts +++ b/src/features/inventory/components/EditProductModal/EditProductModal.types.ts @@ -9,6 +9,8 @@ export interface EditProductModalProps { product: Product /** Depósitos de la empresa — alimentan el botón "Agregar depósito". */ warehouses: Warehouse[] + /** Vocabulario de categorías (`GET /products/categories`). */ + categories?: string[] /** Recibe el cuerpo ya armado para `PUT /api/v1/products/:id`. */ onSubmit: (payload: UpdateProductPayload) => void onClose: () => void diff --git a/src/features/inventory/content.ts b/src/features/inventory/content.ts index 9319f40..e04d92d 100644 --- a/src/features/inventory/content.ts +++ b/src/features/inventory/content.ts @@ -186,7 +186,8 @@ export const inventoryCopy = { available: 'Disponible', }, skuHelper: 'El SKU identifica al producto y no se edita.', - categoryHelper: 'Pendiente de backend: el producto todavía no tiene categoría.', + categoryHelper: 'Se usa para agrupar y filtrar el catálogo.', + noCategory: 'Sin categoría', addWarehouse: 'Agregar depósito', removeWarehouse: (warehouseName: string) => `Quitar ${warehouseName}`, noWarehouses: 'Este producto no tiene stock asignado en ningún depósito.', @@ -223,7 +224,8 @@ export const inventoryCopy = { width: 'A', height: 'H', }, - categoryHelper: 'Pendiente de backend: el producto todavía no tiene categoría.', + categoryHelper: 'Se usa para agrupar y filtrar el catálogo.', + noCategory: 'Sin categoría', addWarehouse: 'Agregar depósito', removeRow: (position: number) => `Quitar la fila ${position}`, noWarehouses: 'La empresa todavía no tiene depósitos cargados.', diff --git a/src/features/inventory/hooks/useInventory.ts b/src/features/inventory/hooks/useInventory.ts index 7e2c47e..0a874e7 100644 --- a/src/features/inventory/hooks/useInventory.ts +++ b/src/features/inventory/hooks/useInventory.ts @@ -9,6 +9,7 @@ import type { ApiRequestError } from 'shared/api/types' import { createProduct, + fetchCategories, fetchProduct, deleteProduct, fetchProductCount, @@ -85,6 +86,18 @@ export function useProduct(id: number | undefined) { }) } +/** + * Las categorías del catálogo, para los selects de alta y edición. No cambian + * en la vida de una sesión: se piden una vez. + */ +export function useCategories() { + return useQuery({ + queryKey: inventoryKeys.categories(), + queryFn: fetchCategories, + staleTime: Number.POSITIVE_INFINITY, + }) +} + /** Depósitos de la empresa — alimentan "Agregar depósito" en el modal. */ export function useWarehouses() { return useQuery({ diff --git a/src/features/inventory/pages/InventoryPage.test.tsx b/src/features/inventory/pages/InventoryPage.test.tsx index 3b72d72..268a25b 100644 --- a/src/features/inventory/pages/InventoryPage.test.tsx +++ b/src/features/inventory/pages/InventoryPage.test.tsx @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { renderWithTheme } from '../../../test/renderWithTheme' import type * as inventoryHooks from '../hooks/useInventory' import { + useCategories, useCreateProduct, useDeleteProduct, useProductCounts, @@ -21,6 +22,7 @@ vi.mock('../hooks/useInventory', async (importOriginal) => ({ useProductPage: vi.fn(), useProductCounts: vi.fn(), useWarehouses: vi.fn(), + useCategories: vi.fn(), useCreateProduct: vi.fn(), useDeleteProduct: vi.fn(), })) @@ -53,6 +55,7 @@ beforeEach(() => { isError: false, } as never) vi.mocked(useProductCounts).mockReturnValue([0, 0, 0, 0] as never) + vi.mocked(useCategories).mockReturnValue({ data: ['Electronics', 'Power'] } as never) vi.mocked(useWarehouses).mockReturnValue({ data: [], isPending: false, diff --git a/src/features/inventory/pages/InventoryPage.tsx b/src/features/inventory/pages/InventoryPage.tsx index fc0ac6b..0f87a35 100644 --- a/src/features/inventory/pages/InventoryPage.tsx +++ b/src/features/inventory/pages/InventoryPage.tsx @@ -15,6 +15,7 @@ import { formatUnits, inventoryCopy } from '../content' import { CATALOG_TABS, RESTRICTED_STATUS, + useCategories, useCreateProduct, useDeleteProduct, useProductCounts, @@ -81,6 +82,7 @@ export function InventoryPage() { }) const counts = useProductCounts(debouncedSearch) const warehouses = useWarehouses() + const categories = useCategories() const createMutation = useCreateProduct() const deleteMutation = useDeleteProduct() @@ -232,6 +234,7 @@ export function InventoryPage() { ({ ...(await importOriginal()), useProduct: vi.fn(), useWarehouses: vi.fn(), + useCategories: vi.fn(), useUpdateProduct: vi.fn(), })) @@ -23,6 +24,7 @@ const PRODUCT: Product = { description: null, weight: 4.5, dimensions: null, + category: null, stocks: [], updatedAt: '2026-09-01T10:00:00Z', version: 'W/"1"', @@ -58,6 +60,7 @@ beforeEach(() => { isFetching: false, refetch: vi.fn(), } as never) + vi.mocked(useCategories).mockReturnValue({ data: ['Electronics', 'Power'] } as never) vi.mocked(useWarehouses).mockReturnValue({ data: [], isPending: false, diff --git a/src/features/inventory/pages/ProductDetailPage.tsx b/src/features/inventory/pages/ProductDetailPage.tsx index fb3b4f2..4798532 100644 --- a/src/features/inventory/pages/ProductDetailPage.tsx +++ b/src/features/inventory/pages/ProductDetailPage.tsx @@ -16,7 +16,13 @@ import type { ProductSpec } from '../components/ProductSpecsCard' import { WarehouseDistributionCard } from '../components/WarehouseDistributionCard' import type { WarehouseDistributionRow } from '../components/WarehouseDistributionCard' import { inventoryCopy } from '../content' -import { CONFLICT_STATUS, useProduct, useUpdateProduct, useWarehouses } from '../hooks/useInventory' +import { + CONFLICT_STATUS, + useCategories, + useProduct, + useUpdateProduct, + useWarehouses, +} from '../hooks/useInventory' import type { Product, UpdateProductPayload } from '../types' import { describeConflict } from '../utils/conflict' import { parseDimensions } from '../utils/dimensions' @@ -184,6 +190,7 @@ export function ProductDetailPage() { const product = useProduct(id) const warehouses = useWarehouses() + const categories = useCategories() const updateMutation = useUpdateProduct(id, product.data?.version ?? null) // El 412 llega con la versión ya invalidada: React Query refetchea el detalle @@ -293,6 +300,7 @@ export function ProductDetailPage() { open={editing} product={product.data} warehouses={warehouses.data} + categories={categories.data} submitting={updateMutation.isPending} conflict={conflict} onClose={() => { diff --git a/src/features/inventory/queryKeys.ts b/src/features/inventory/queryKeys.ts index 4c486ad..d829bdc 100644 --- a/src/features/inventory/queryKeys.ts +++ b/src/features/inventory/queryKeys.ts @@ -14,4 +14,5 @@ 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, + categories: () => [...inventoryKeys.all, 'categories'] as const, } diff --git a/src/features/inventory/types.ts b/src/features/inventory/types.ts index 58aa2ff..d72cdef 100644 --- a/src/features/inventory/types.ts +++ b/src/features/inventory/types.ts @@ -88,6 +88,8 @@ export interface Product { description: string | null weight: number dimensions: string | null + /** Opcional: los productos anteriores a TESIS-102 no tienen ninguna. */ + category: ProductCategory | null stocks: ProductStock[] updatedAt: string /** @@ -115,6 +117,8 @@ export interface CreateProductPayload { sku: string name: string description: string | null + /** `null` = sin categoría. El vocabulario es `GET /products/categories`. */ + category: string | null weight: number dimensions: string | null stocks: { warehouse_id: number; quantity: number }[] @@ -132,6 +136,7 @@ export interface UpdateProductPayload { product: { name: string description: string | null + category: string | null weight: number dimensions: string | null stocks: { warehouse_id: number; quantity: number }[] diff --git a/src/features/inventory/utils/conflict.test.ts b/src/features/inventory/utils/conflict.test.ts index bde062a..2380b00 100644 --- a/src/features/inventory/utils/conflict.test.ts +++ b/src/features/inventory/utils/conflict.test.ts @@ -29,6 +29,7 @@ function product(overrides: Partial = {}): Product { description: 'Rollo de 305 metros', weight: 12.4, dimensions: '45x30x20', + category: null, stocks: [stock(1, 10, 'CD Ezeiza')], updatedAt: '2026-08-30T12:00:00.000Z', version: '"abc"', diff --git a/src/features/inventory/utils/payload.test.ts b/src/features/inventory/utils/payload.test.ts index efa363a..67d046a 100644 --- a/src/features/inventory/utils/payload.test.ts +++ b/src/features/inventory/utils/payload.test.ts @@ -10,6 +10,7 @@ function createForm(overrides: Partial = {}): CreateProdu return { name: 'Cable UTP Cat6', sku: 'CAB-6-305', + category: '', weight: 12.4, length: 45, width: 30, @@ -22,6 +23,7 @@ function createForm(overrides: Partial = {}): CreateProdu function editForm(overrides: Partial = {}): EditProductFormData { return { name: 'Cable UTP Cat6', + category: 'Cabling', weight: 12.4, length: 45, width: 30, @@ -46,6 +48,7 @@ function product(overrides: Partial = {}): Product { description: 'Rollo de 305 metros', weight: 12.4, dimensions: '45x30x20', + category: null, stocks: [ { warehouseId: 1, @@ -68,6 +71,7 @@ describe('buildCreatePayload', () => { sku: 'CAB-6-305', name: 'Cable UTP Cat6', description: null, + category: null, weight: 12.4, dimensions: '45x30x20', stocks: [{ warehouse_id: 1, quantity: 10 }], @@ -103,6 +107,7 @@ describe('buildUpdatePayload', () => { product: { name: 'Cable UTP Cat6', description: 'Rollo de 305 metros', + category: 'Cabling', weight: 12.4, dimensions: '45x30x20', stocks: [{ warehouse_id: 1, quantity: 10 }], @@ -159,3 +164,18 @@ describe('buildUpdatePayload', () => { expect(buildUpdatePayload(stored, editForm()).product.dimensions).toBe('45x30x20') }) }) + +describe('the category', () => { + // Un `` no tiene + * `null`); la API la espera en `null`. + */ +function categoryOf(value: string): string | null { + return value === '' ? null : value +} + /** * Traduce el formulario de alta al cuerpo de `POST /api/v1/products`. * @@ -19,6 +27,7 @@ export function buildCreatePayload(data: CreateProductFormData): CreateProductPa sku: data.sku, name: data.name, description: null, + category: categoryOf(data.category), weight: data.weight, dimensions: formatDimensions({ length: data.length, @@ -72,6 +81,7 @@ export function buildUpdatePayload( product: { name: data.name, description: product.description, + category: categoryOf(data.category), weight: data.weight, dimensions, stocks: [