diff --git a/docs/guidelines/architecture.md b/docs/guidelines/architecture.md index 1e009b9..79d7c05 100644 --- a/docs/guidelines/architecture.md +++ b/docs/guidelines/architecture.md @@ -369,6 +369,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 f6230c8..7cc129d 100644 --- a/src/features/inventory/api.ts +++ b/src/features/inventory/api.ts @@ -227,6 +227,17 @@ 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 +} + interface ApiProductMapping { id: number company_integration_id: number 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 01fc1fe..de7d145 100644 --- a/src/features/inventory/components/EditProductModal/EditProductModal.test.tsx +++ b/src/features/inventory/components/EditProductModal/EditProductModal.test.tsx @@ -37,6 +37,7 @@ function renderModal(initial: Product) { const props = { open: true, warehouses: [], + categories: ['Electronics', 'Cabling', 'Power'], onSubmit: vi.fn(), onClose: vi.fn(), } @@ -184,4 +185,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 17eff29..7b8f25e 100644 --- a/src/features/inventory/content.ts +++ b/src/features/inventory/content.ts @@ -185,7 +185,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.', @@ -252,7 +253,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(), })) @@ -112,6 +113,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 84b16a5..67b06b6 100644 --- a/src/features/inventory/pages/ProductDetailPage.tsx +++ b/src/features/inventory/pages/ProductDetailPage.tsx @@ -17,7 +17,13 @@ import { SalesChannelsCard } from '../components/SalesChannelsCard' 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' @@ -194,6 +200,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 @@ -306,6 +313,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 317f8d6..8ef18c4 100644 --- a/src/features/inventory/queryKeys.ts +++ b/src/features/inventory/queryKeys.ts @@ -14,5 +14,6 @@ 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, mappings: (productId: number) => [...inventoryKeys.product(productId), 'mappings'] as const, } diff --git a/src/features/inventory/types.ts b/src/features/inventory/types.ts index ae0a96a..573926b 100644 --- a/src/features/inventory/types.ts +++ b/src/features/inventory/types.ts @@ -145,6 +145,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 }[] @@ -162,6 +164,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/payload.test.ts b/src/features/inventory/utils/payload.test.ts index 46fc47c..a4236a9 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, @@ -74,6 +76,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 }], @@ -109,6 +112,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 }], @@ -166,3 +170,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: [