From 8dfd1cb3063e456b8c85798d81f2c39e99c078d9 Mon Sep 17 00:00:00 2001 From: LauAubert Date: Fri, 2 Oct 2026 01:02:58 -0300 Subject: [PATCH] feat: [TESIS-999004] manage the company warehouses from their own screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scope commits every company to register and manage its physical warehouses (RF-03, module B), and the API has had the full CRUD for a while, but the front had no screen: warehouses could only come from the seeds or the backoffice. The new Depósitos section lists them with their address, postal code and stored units, and creates, edits and deletes them. The form validates an Argentine postal code, since every shipment quote starts from it. A 409 on delete is translated to its concrete reason and the confirmation goes away, because retrying would fail the same way. Co-Authored-By: Claude Opus 5.5 --- docs/guidelines/architecture.md | 1 + src/app/router/routes.test.tsx | 9 +- src/app/router/routes.tsx | 9 + src/features/warehouses/api.test.ts | 11 ++ src/features/warehouses/api.ts | 72 +++++++ .../DeleteWarehouseDialog.tsx | 47 +++++ .../DeleteWarehouseDialog.types.ts | 13 ++ .../components/DeleteWarehouseDialog/index.ts | 2 + .../WarehouseFormModal.schema.ts | 24 +++ .../WarehouseFormModal.test.tsx | 75 +++++++ .../WarehouseFormModal/WarehouseFormModal.tsx | 116 +++++++++++ .../WarehouseFormModal.types.ts | 12 ++ .../components/WarehouseFormModal/index.ts | 2 + src/features/warehouses/content.ts | 78 ++++++++ .../warehouses/hooks/useWarehouses.ts | 58 ++++++ src/features/warehouses/index.ts | 2 + .../warehouses/pages/WarehousesPage.test.tsx | 128 ++++++++++++ .../warehouses/pages/WarehousesPage.tsx | 184 ++++++++++++++++++ src/features/warehouses/queryKeys.ts | 6 + src/features/warehouses/types.ts | 31 +++ .../warehouses/utils/deleteBlocker.test.ts | 29 +++ .../warehouses/utils/deleteBlocker.ts | 19 ++ 22 files changed, 927 insertions(+), 1 deletion(-) create mode 100644 src/features/warehouses/api.test.ts create mode 100644 src/features/warehouses/api.ts create mode 100644 src/features/warehouses/components/DeleteWarehouseDialog/DeleteWarehouseDialog.tsx create mode 100644 src/features/warehouses/components/DeleteWarehouseDialog/DeleteWarehouseDialog.types.ts create mode 100644 src/features/warehouses/components/DeleteWarehouseDialog/index.ts create mode 100644 src/features/warehouses/components/WarehouseFormModal/WarehouseFormModal.schema.ts create mode 100644 src/features/warehouses/components/WarehouseFormModal/WarehouseFormModal.test.tsx create mode 100644 src/features/warehouses/components/WarehouseFormModal/WarehouseFormModal.tsx create mode 100644 src/features/warehouses/components/WarehouseFormModal/WarehouseFormModal.types.ts create mode 100644 src/features/warehouses/components/WarehouseFormModal/index.ts create mode 100644 src/features/warehouses/content.ts create mode 100644 src/features/warehouses/hooks/useWarehouses.ts create mode 100644 src/features/warehouses/index.ts create mode 100644 src/features/warehouses/pages/WarehousesPage.test.tsx create mode 100644 src/features/warehouses/pages/WarehousesPage.tsx create mode 100644 src/features/warehouses/queryKeys.ts create mode 100644 src/features/warehouses/types.ts create mode 100644 src/features/warehouses/utils/deleteBlocker.test.ts create mode 100644 src/features/warehouses/utils/deleteBlocker.ts diff --git a/docs/guidelines/architecture.md b/docs/guidelines/architecture.md index 02c5c52..660a1a7 100644 --- a/docs/guidelines/architecture.md +++ b/docs/guidelines/architecture.md @@ -314,6 +314,7 @@ features/[nombre]/ | `design-system` | Catálogo de tokens y componentes del DS (`/design-system`). No tiene datos ni hooks | | `home` | Landing de la app (`/`) | | `inventory` | Catálogo de productos y stock por depósito (`/inventory`). Hoy el alta y la edición de producto (`CreateProductModal`, `EditProductModal`) sobre la API real; la vista real del catálogo la construye TESIS-62, que reemplaza el cuerpo de `InventoryPage` | +| `warehouses` | Depósitos de la empresa (`/warehouses`, RF-03): listado, alta, edición y baja sobre `/api/v1/warehouses`. Sin maqueta: tabla, modal y confirmación del DS. Sus mutaciones invalidan todas las queries, porque los depósitos llenan selects de otras features cuyas claves esta no puede importar | | `orders` | Listado global de órdenes (`/orders`), el detalle de una orden con el ciclo de vida de su envío, desde el que se despacha un envío que quedó pendiente (`/orders/:orderId`), su modificación (`/orders/edit/:orderId`) y los tres pasos del alta manual (`/orders/new`: cliente y productos, con el buscador filtrando contra el `search` del backend; `/orders/new/shipping`: origen y destino; `/orders/new/carrier`: cotización y confirmación) | | `reports` | Reportes — analítica de la operación (`/reports`, S14): métricas del período, curva de despacho, nivel de servicio por operador y anomalías recientes. **Sin endpoint de agregados todavía**: la pantalla muestra el dataset de muestra del diseño y lo dice junto al título (ver `api.ts`) | diff --git a/src/app/router/routes.test.tsx b/src/app/router/routes.test.tsx index 7438c93..0702781 100644 --- a/src/app/router/routes.test.tsx +++ b/src/app/router/routes.test.tsx @@ -24,7 +24,14 @@ describe('navRoutesFor', () => { }) it('keeps the sections that belong to the product for every tenant', () => { - expect(navPaths({})).toEqual(['/', '/dashboard', '/orders', '/inventory', '/reports']) + expect(navPaths({})).toEqual([ + '/', + '/dashboard', + '/orders', + '/inventory', + '/warehouses', + '/reports', + ]) }) it('never lists a route that has no place in the sidebar', () => { diff --git a/src/app/router/routes.tsx b/src/app/router/routes.tsx index 4225bcb..052524f 100644 --- a/src/app/router/routes.tsx +++ b/src/app/router/routes.tsx @@ -4,6 +4,7 @@ import HubOutlinedIcon from '@mui/icons-material/HubOutlined' import InsightsOutlinedIcon from '@mui/icons-material/InsightsOutlined' import Inventory2Icon from '@mui/icons-material/Inventory2' import ReceiptLongOutlinedIcon from '@mui/icons-material/ReceiptLongOutlined' +import WarehouseOutlinedIcon from '@mui/icons-material/WarehouseOutlined' import { lazy } from 'react' import type { ReactNode } from 'react' import { isFeatureEnabled } from 'shared/api' @@ -42,6 +43,9 @@ const InventoryPage = lazy(() => const ProductDetailPage = lazy(() => import('features/inventory').then((m) => ({ default: m.ProductDetailPage })), ) +const WarehousesPage = lazy(() => + import('features/warehouses').then((m) => ({ default: m.WarehousesPage })), +) const ReportsPage = lazy(() => import('features/reports').then((m) => ({ default: m.ReportsPage }))) const LoginPage = lazy(() => import('features/auth').then((m) => ({ default: m.LoginPage }))) @@ -142,6 +146,11 @@ export const appRoutes: AppRoute[] = [ element: , // Sin `nav`: se llega desde el catálogo, no desde el Sidebar. }, + { + path: '/warehouses', + element: , + nav: { label: 'Depósitos', icon: }, + }, { path: '/reports', element: , diff --git a/src/features/warehouses/api.test.ts b/src/features/warehouses/api.test.ts new file mode 100644 index 0000000..53d5e82 --- /dev/null +++ b/src/features/warehouses/api.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest' + +import { toPayload } from './api' + +describe('toPayload', () => { + it('sends the body Rails expects, trimmed and with the CPA in upper case', () => { + expect(toPayload({ name: ' Central ', address: ' Av. 7 ', zipCode: ' b1900abc ' })).toEqual({ + warehouse: { name: 'Central', address: 'Av. 7', zip_code: 'B1900ABC' }, + }) + }) +}) diff --git a/src/features/warehouses/api.ts b/src/features/warehouses/api.ts new file mode 100644 index 0000000..e358eb0 --- /dev/null +++ b/src/features/warehouses/api.ts @@ -0,0 +1,72 @@ +import { client } from 'shared/api/client' + +import type { Warehouse, WarehouseInput, WarehousePayload } from './types' + +// Frontera con la API Rails para los depósitos. Ningún componente ve el +// snake_case. +// +// El listado no pagina de verdad: la API devuelve hasta 100 depósitos de una +// vez (`WHOLE_LIST_PER_PAGE`) porque los usa para llenar selects. Una empresa +// con más de cien depósitos no es un caso del MVP. + +interface ApiWarehouse { + id: number + name: string + address: string + zip_code: string + stored_units?: number +} + +interface ApiList { + data: T[] + meta: { page: number; per_page: number; total: number } +} + +function toWarehouse(warehouse: ApiWarehouse): Warehouse { + return { + id: warehouse.id, + name: warehouse.name, + address: warehouse.address, + zipCode: warehouse.zip_code, + // El alta y la edición responden con el mismo serializer, pero un depósito + // recién creado guarda cero unidades: el `?? 0` es para no depender de eso. + storedUnits: warehouse.stored_units ?? 0, + } +} + +export function toPayload(input: WarehouseInput): WarehousePayload { + return { + warehouse: { + name: input.name.trim(), + address: input.address.trim(), + zip_code: input.zipCode.trim().toUpperCase(), + }, + } +} + +export async function fetchWarehouses(): Promise { + const { data } = await client.get>('/warehouses') + + return data.data.map(toWarehouse) +} + +export async function createWarehouse(input: WarehouseInput): Promise { + const { data } = await client.post('/warehouses', toPayload(input)) + + return toWarehouse(data) +} + +export async function updateWarehouse(id: number, input: WarehouseInput): Promise { + const { data } = await client.put(`/warehouses/${id}`, toPayload(input)) + + return toWarehouse(data) +} + +/** + * Baja de un depósito. La API responde **409** si tiene stock, si salieron + * ventas de él o si una transferencia lo tiene como origen o destino: son + * registros que un DELETE no puede evaporar. + */ +export async function deleteWarehouse(id: number): Promise { + await client.delete(`/warehouses/${id}`) +} diff --git a/src/features/warehouses/components/DeleteWarehouseDialog/DeleteWarehouseDialog.tsx b/src/features/warehouses/components/DeleteWarehouseDialog/DeleteWarehouseDialog.tsx new file mode 100644 index 0000000..ab1528b --- /dev/null +++ b/src/features/warehouses/components/DeleteWarehouseDialog/DeleteWarehouseDialog.tsx @@ -0,0 +1,47 @@ +import { Alert } from '@mui/material' +import { useState } from 'react' +import { ConfirmDialog } from 'shared/components' + +import { warehousesCopy } from '../../content' + +import type { DeleteWarehouseDialogProps } from './DeleteWarehouseDialog.types' + +const { remove } = warehousesCopy + +/** + * Confirmación de baja de un depósito. Mismo criterio que la baja de producto: + * el rechazo se explica acá adentro y, si es el 409, la confirmación + * desaparece porque reintentar va a fallar igual. + */ +export function DeleteWarehouseDialog({ + warehouse, + deleting = false, + blocker, + failed = false, + onConfirm, + onClose, +}: DeleteWarehouseDialogProps) { + // Durante el fundido de cierre `warehouse` ya es `undefined`: se recuerda el + // último para que el texto no desaparezca mientras el diálogo se va. + const [shown, setShown] = useState(warehouse) + if (warehouse !== undefined && warehouse !== shown) setShown(warehouse) + + return ( + + {blocker === undefined ? null : {remove.blocked[blocker]}} + {failed && blocker === undefined ? {remove.failed} : null} + + ) +} diff --git a/src/features/warehouses/components/DeleteWarehouseDialog/DeleteWarehouseDialog.types.ts b/src/features/warehouses/components/DeleteWarehouseDialog/DeleteWarehouseDialog.types.ts new file mode 100644 index 0000000..049e6d9 --- /dev/null +++ b/src/features/warehouses/components/DeleteWarehouseDialog/DeleteWarehouseDialog.types.ts @@ -0,0 +1,13 @@ +import type { DeleteBlocker, Warehouse } from '../../types' + +export interface DeleteWarehouseDialogProps { + /** El depósito a borrar; sin él, el diálogo está cerrado. */ + warehouse?: Warehouse + deleting?: boolean + /** Motivo del 409, si la API ya rechazó la baja. */ + blocker?: DeleteBlocker + /** Un fallo que no es el 409 (red, 500): se puede reintentar. */ + failed?: boolean + onConfirm: () => void + onClose: () => void +} diff --git a/src/features/warehouses/components/DeleteWarehouseDialog/index.ts b/src/features/warehouses/components/DeleteWarehouseDialog/index.ts new file mode 100644 index 0000000..3e6a7b5 --- /dev/null +++ b/src/features/warehouses/components/DeleteWarehouseDialog/index.ts @@ -0,0 +1,2 @@ +export { DeleteWarehouseDialog } from './DeleteWarehouseDialog' +export type { DeleteWarehouseDialogProps } from './DeleteWarehouseDialog.types' diff --git a/src/features/warehouses/components/WarehouseFormModal/WarehouseFormModal.schema.ts b/src/features/warehouses/components/WarehouseFormModal/WarehouseFormModal.schema.ts new file mode 100644 index 0000000..ea5ffab --- /dev/null +++ b/src/features/warehouses/components/WarehouseFormModal/WarehouseFormModal.schema.ts @@ -0,0 +1,24 @@ +import { z } from 'zod' + +import { warehousesCopy } from '../../content' + +const { validation } = warehousesCopy.form + +// Código postal argentino: el de 4 dígitos (1900) o el CPA de 8 caracteres +// (B1900ABC). La API sólo exige que esté, pero de este número sale la +// cotización de cada envío: uno mal escrito falla recién al cotizar, lejos de +// donde se cargó. +const ZIP_CODE = /^(\d{4}|[A-Za-z]\d{4}[A-Za-z]{3})$/ + +/** Fuente única del formulario y de su tipo (ADR-006). */ +export const warehouseFormSchema = z.object({ + name: z.string().trim().min(1, validation.nameRequired), + address: z.string().trim().min(1, validation.addressRequired), + zipCode: z + .string() + .trim() + .min(1, validation.zipCodeRequired) + .regex(ZIP_CODE, validation.zipCodeFormat), +}) + +export type WarehouseFormData = z.infer diff --git a/src/features/warehouses/components/WarehouseFormModal/WarehouseFormModal.test.tsx b/src/features/warehouses/components/WarehouseFormModal/WarehouseFormModal.test.tsx new file mode 100644 index 0000000..18964ef --- /dev/null +++ b/src/features/warehouses/components/WarehouseFormModal/WarehouseFormModal.test.tsx @@ -0,0 +1,75 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { renderWithTheme } from '../../../../test/renderWithTheme' +import type { Warehouse } from '../../types' + +import { WarehouseFormModal } from './WarehouseFormModal' + +const CENTRAL: Warehouse = { + id: 1, + name: 'Depósito Central', + address: 'Av. 7 N° 1234', + zipCode: '1900', + storedUnits: 230, +} + +function renderModal(warehouse?: Warehouse) { + const onSubmit = vi.fn() + renderWithTheme( + , + ) + return onSubmit +} + +const field = (name: string) => screen.getByRole('textbox', { name: new RegExp(`^${name}`) }) + +function fill(name: string, value: string) { + fireEvent.change(field(name), { target: { value } }) +} + +describe('WarehouseFormModal', () => { + it('creates a warehouse with the three fields', async () => { + const onSubmit = renderModal() + fill('Nombre', 'Depósito Sur') + fill('Dirección', 'Av. Colón 789') + fill('Código postal', 'B8000ABC') + fireEvent.click(screen.getByRole('button', { name: 'Crear depósito' })) + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + { name: 'Depósito Sur', address: 'Av. Colón 789', zipCode: 'B8000ABC' }, + expect.anything(), + ), + ) + }) + + it('opens the edition with the current data', () => { + renderModal(CENTRAL) + + expect(screen.getByText('Editar depósito: Depósito Central')).toBeInTheDocument() + expect(field('Código postal')).toHaveValue('1900') + }) + + it('asks for every field before sending', async () => { + const onSubmit = renderModal() + fireEvent.click(screen.getByRole('button', { name: 'Crear depósito' })) + + expect(await screen.findByText('Ingresá el nombre del depósito.')).toBeInTheDocument() + expect(screen.getByText('Ingresá la dirección.')).toBeInTheDocument() + expect(screen.getByText('Ingresá el código postal.')).toBeInTheDocument() + expect(onSubmit).not.toHaveBeenCalled() + }) + + // De este número sale la cotización de cada envío que sale del depósito. + it('refuses a postal code that is not Argentine', async () => { + const onSubmit = renderModal(CENTRAL) + fill('Código postal', '190') + fireEvent.click(screen.getByRole('button', { name: 'Guardar cambios' })) + + expect( + await screen.findByText('Usá 4 dígitos (1900) o el CPA completo (B1900ABC).'), + ).toBeInTheDocument() + expect(onSubmit).not.toHaveBeenCalled() + }) +}) diff --git a/src/features/warehouses/components/WarehouseFormModal/WarehouseFormModal.tsx b/src/features/warehouses/components/WarehouseFormModal/WarehouseFormModal.tsx new file mode 100644 index 0000000..687f98b --- /dev/null +++ b/src/features/warehouses/components/WarehouseFormModal/WarehouseFormModal.tsx @@ -0,0 +1,116 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { Alert, Button, TextField } from '@mui/material' +import { useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { + LabeledField, + ModalBody, + ModalFooter, + ModalFooterActions, + ModalForm, + ModalFrame, +} from 'shared/components' + +import { warehousesCopy } from '../../content' +import type { Warehouse } from '../../types' + +import { warehouseFormSchema } from './WarehouseFormModal.schema' +import type { WarehouseFormData } from './WarehouseFormModal.schema' +import type { WarehouseFormModalProps } from './WarehouseFormModal.types' + +const copy = warehousesCopy.form + +function defaultsFor(warehouse: Warehouse | undefined): WarehouseFormData { + return { + name: warehouse?.name ?? '', + address: warehouse?.address ?? '', + zipCode: warehouse?.zipCode ?? '', + } +} + +/** + * Alta y edición de un depósito. Presentacional: entrega en `onSubmit` los + * datos validados y quien lo monta decide a qué endpoint van. + */ +export function WarehouseFormModal({ + open, + warehouse, + submitting = false, + error, + onSubmit, + onClose, +}: WarehouseFormModalProps) { + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(warehouseFormSchema), + defaultValues: defaultsFor(warehouse), + }) + + // Cada apertura parte de lo que hay en el servidor (o de vacío en un alta): + // lo tipeado y cancelado la vez anterior se descarta a propósito. + useEffect(() => { + if (open) reset(defaultsFor(warehouse)) + }, [open, warehouse, reset]) + + const editing = warehouse !== undefined + + return ( + + + + {error === undefined ? null : {error}} + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/features/warehouses/components/WarehouseFormModal/WarehouseFormModal.types.ts b/src/features/warehouses/components/WarehouseFormModal/WarehouseFormModal.types.ts new file mode 100644 index 0000000..7071c4c --- /dev/null +++ b/src/features/warehouses/components/WarehouseFormModal/WarehouseFormModal.types.ts @@ -0,0 +1,12 @@ +import type { Warehouse, WarehouseInput } from '../../types' + +export interface WarehouseFormModalProps { + open: boolean + /** El depósito a editar; sin él, el modal es de alta. */ + warehouse?: Warehouse + submitting?: boolean + /** Rechazo de la API, ya traducido. Se muestra dentro del modal. */ + error?: string + onSubmit: (input: WarehouseInput) => void + onClose: () => void +} diff --git a/src/features/warehouses/components/WarehouseFormModal/index.ts b/src/features/warehouses/components/WarehouseFormModal/index.ts new file mode 100644 index 0000000..13a0c89 --- /dev/null +++ b/src/features/warehouses/components/WarehouseFormModal/index.ts @@ -0,0 +1,2 @@ +export { WarehouseFormModal } from './WarehouseFormModal' +export type { WarehouseFormModalProps } from './WarehouseFormModal.types' diff --git a/src/features/warehouses/content.ts b/src/features/warehouses/content.ts new file mode 100644 index 0000000..ae72aa2 --- /dev/null +++ b/src/features/warehouses/content.ts @@ -0,0 +1,78 @@ +// Copy centralizado de la feature — sin literales sueltos en el JSX. + +const numberFormat = new Intl.NumberFormat('es-AR') + +export function formatUnits(value: number): string { + return numberFormat.format(value) +} + +export const warehousesCopy = { + page: { + title: 'Depósitos', + subtitle: 'Los depósitos físicos de la empresa. Su código postal es el origen de los envíos.', + create: 'Nuevo depósito', + tableLabel: 'Depósitos de la empresa', + empty: 'Todavía no hay depósitos. Cargá el primero para poder asignarle stock.', + footer: (count: number) => (count === 1 ? '1 depósito' : `${count} depósitos`), + created: (name: string) => `${name} creado.`, + saved: (name: string) => `${name} actualizado.`, + deleted: (name: string) => `${name} eliminado.`, + }, + columns: { + name: 'Nombre', + address: 'Dirección', + zipCode: 'Código postal', + storedUnits: 'Unidades guardadas', + }, + actions: { + header: 'Acciones', + edit: 'Editar', + delete: 'Eliminar', + menuFor: (name: string) => `Acciones del depósito ${name}`, + }, + form: { + createTitle: 'Nuevo depósito', + editTitle: (name: string) => `Editar depósito: ${name}`, + subtitle: 'El código postal se usa para cotizar los envíos que salen de acá.', + fields: { + name: 'Nombre', + address: 'Dirección', + zipCode: 'Código postal', + }, + placeholders: { + name: 'Depósito Central', + address: 'Av. 7 N° 1234, La Plata', + zipCode: '1900 o B1900ABC', + }, + cancel: 'Cancelar', + submitCreate: 'Crear depósito', + submitEdit: 'Guardar cambios', + close: 'Cerrar', + failed: 'No pudimos guardar el depósito.', + validation: { + nameRequired: 'Ingresá el nombre del depósito.', + addressRequired: 'Ingresá la dirección.', + zipCodeRequired: 'Ingresá el código postal.', + zipCodeFormat: 'Usá 4 dígitos (1900) o el CPA completo (B1900ABC).', + }, + }, + remove: { + title: 'Eliminar depósito', + body: (name: string) => `Se va a eliminar ${name}. Esta acción no se puede deshacer.`, + confirm: 'Eliminar', + cancel: 'Cancelar', + close: 'Cerrar', + /** El motivo del 409, por caso. */ + blocked: { + // La API bloquea por la fila de stock, no por la cantidad: un producto + // asignado con cero unidades también lo impide. + stock: + 'No se puede eliminar: hay productos asignados a este depósito, aunque tengan cero unidades.', + orders: 'No se puede eliminar: salieron ventas de este depósito y las órdenes lo recuerdan.', + transfers: 'No se puede eliminar: tiene transferencias de stock que salen o llegan a él.', + unknown: 'No se puede eliminar: el depósito tiene registros asociados.', + }, + failed: 'No pudimos eliminar el depósito.', + }, + loadError: 'No pudimos cargar los depósitos.', +} diff --git a/src/features/warehouses/hooks/useWarehouses.ts b/src/features/warehouses/hooks/useWarehouses.ts new file mode 100644 index 0000000..50dc464 --- /dev/null +++ b/src/features/warehouses/hooks/useWarehouses.ts @@ -0,0 +1,58 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import type { ApiRequestError } from 'shared/api/types' + +import { createWarehouse, deleteWarehouse, fetchWarehouses, updateWarehouse } from '../api' +import { warehouseKeys } from '../queryKeys' +import type { Warehouse, WarehouseInput } from '../types' + +/** Status con el que la API rechaza borrar un depósito con historia. */ +export const RESTRICTED_STATUS = 409 + +export function useWarehouseList() { + return useQuery({ + queryKey: warehouseKeys.list(), + queryFn: fetchWarehouses, + }) +} + +/** + * Invalida **todas** las queries y no sólo las de esta feature. + * + * Los depósitos llenan selects y tarjetas de otras pantallas —el modal de + * producto, el alta manual de órdenes, la carga por depósito del panel—, y cada + * una tiene su propia clave en su feature, que esta no puede importar + * (feature-structure.md). Dar de alta o renombrar un depósito es poco + * frecuente: refrescar de más es más barato que mostrar un nombre viejo. + */ +function useInvalidateEverything() { + const queryClient = useQueryClient() + + return () => queryClient.invalidateQueries() +} + +export function useCreateWarehouse() { + const invalidate = useInvalidateEverything() + + return useMutation({ + mutationFn: createWarehouse, + onSuccess: invalidate, + }) +} + +export function useUpdateWarehouse() { + const invalidate = useInvalidateEverything() + + return useMutation({ + mutationFn: ({ id, input }) => updateWarehouse(id, input), + onSuccess: invalidate, + }) +} + +export function useDeleteWarehouse() { + const invalidate = useInvalidateEverything() + + return useMutation({ + mutationFn: deleteWarehouse, + onSuccess: invalidate, + }) +} diff --git a/src/features/warehouses/index.ts b/src/features/warehouses/index.ts new file mode 100644 index 0000000..1bf384a --- /dev/null +++ b/src/features/warehouses/index.ts @@ -0,0 +1,2 @@ +export { WarehousesPage } from './pages/WarehousesPage' +export type { Warehouse } from './types' diff --git a/src/features/warehouses/pages/WarehousesPage.test.tsx b/src/features/warehouses/pages/WarehousesPage.test.tsx new file mode 100644 index 0000000..2691b35 --- /dev/null +++ b/src/features/warehouses/pages/WarehousesPage.test.tsx @@ -0,0 +1,128 @@ +import { fireEvent, screen, within } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { renderWithTheme } from '../../../test/renderWithTheme' +import type * as warehouseHooks from '../hooks/useWarehouses' +import { + useCreateWarehouse, + useDeleteWarehouse, + useUpdateWarehouse, + useWarehouseList, +} from '../hooks/useWarehouses' +import type { Warehouse } from '../types' + +import { WarehousesPage } from './WarehousesPage' + +vi.mock('../hooks/useWarehouses', async (importOriginal) => ({ + ...(await importOriginal()), + useWarehouseList: vi.fn(), + useCreateWarehouse: vi.fn(), + useUpdateWarehouse: vi.fn(), + useDeleteWarehouse: vi.fn(), +})) + +const WAREHOUSES: Warehouse[] = [ + { id: 1, name: 'Depósito Central', address: 'Av. 7 N° 1234', zipCode: '1900', storedUnits: 1230 }, + { id: 2, name: 'Depósito Satélite', address: 'Calle 25 N° 456', zipCode: '1602', storedUnits: 0 }, +] + +function idleMutation(overrides: object = {}) { + return { + mutate: vi.fn(), + reset: vi.fn(), + isPending: false, + isError: false, + error: null, + ...overrides, + } +} + +beforeEach(() => { + vi.mocked(useWarehouseList).mockReturnValue({ + data: WAREHOUSES, + isPending: false, + isError: false, + } as never) + vi.mocked(useCreateWarehouse).mockReturnValue(idleMutation() as never) + vi.mocked(useUpdateWarehouse).mockReturnValue(idleMutation() as never) + vi.mocked(useDeleteWarehouse).mockReturnValue(idleMutation() as never) +}) + +const row = (name: string) => { + const cell = screen.getByText(name) + const tableRow = cell.closest('tr') + if (tableRow === null) throw new Error(`no row for ${name}`) + return within(tableRow) +} + +function openRowAction(name: string, action: string) { + fireEvent.click(row(name).getByRole('button', { name: `Acciones del depósito ${name}` })) + fireEvent.click(screen.getByRole('menuitem', { name: action })) +} + +describe('WarehousesPage', () => { + it('lists the warehouses with their address, postal code and units', () => { + renderWithTheme() + + expect(row('Depósito Central').getByText('Av. 7 N° 1234')).toBeInTheDocument() + expect(row('Depósito Central').getByText('1900')).toBeInTheDocument() + expect(row('Depósito Central').getByText('1.230')).toBeInTheDocument() + }) + + it('opens the creation form', () => { + renderWithTheme() + + fireEvent.click(screen.getByRole('button', { name: 'Nuevo depósito' })) + + expect(screen.getByRole('button', { name: 'Crear depósito' })).toBeInTheDocument() + }) + + it('opens the edition of a row with its data', () => { + renderWithTheme() + + openRowAction('Depósito Satélite', 'Editar') + + expect(screen.getByText('Editar depósito: Depósito Satélite')).toBeInTheDocument() + }) + + it('asks before deleting and then deletes', () => { + const mutate = vi.fn() + vi.mocked(useDeleteWarehouse).mockReturnValue(idleMutation({ mutate }) as never) + renderWithTheme() + + openRowAction('Depósito Satélite', 'Eliminar') + fireEvent.click( + within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Eliminar' }), + ) + + expect(mutate).toHaveBeenCalledWith(2, expect.anything()) + }) + + // El 409 dice por qué y la confirmación desaparece: reintentar falla igual. + it('explains why the API refused the deletion and stops offering it', () => { + vi.mocked(useDeleteWarehouse).mockReturnValue( + idleMutation({ + isError: true, + error: { status: 409, message: 'Cannot delete warehouse with existing stock' }, + }) as never, + ) + renderWithTheme() + + openRowAction('Depósito Central', 'Eliminar') + const dialog = within(screen.getByRole('alertdialog')) + + expect(dialog.getByText(/hay productos asignados a este depósito/)).toBeInTheDocument() + expect(dialog.queryByRole('button', { name: 'Eliminar' })).not.toBeInTheDocument() + }) + + it('says so when the company has no warehouses yet', () => { + vi.mocked(useWarehouseList).mockReturnValue({ + data: [], + isPending: false, + isError: false, + } as never) + renderWithTheme() + + expect(screen.getByText(/Todavía no hay depósitos/)).toBeInTheDocument() + }) +}) diff --git a/src/features/warehouses/pages/WarehousesPage.tsx b/src/features/warehouses/pages/WarehousesPage.tsx new file mode 100644 index 0000000..0bd9d69 --- /dev/null +++ b/src/features/warehouses/pages/WarehousesPage.tsx @@ -0,0 +1,184 @@ +import AddIcon from '@mui/icons-material/Add' +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline' +import EditOutlinedIcon from '@mui/icons-material/EditOutlined' +import { Box, Button, Stack, Typography } from '@mui/material' +import { useState } from 'react' +import { DataTable, ErrorFallback, LoadingSpinner, PageWrapper } from 'shared/components' +import type { DataTableAction, DataTableColumn } from 'shared/components' +import { notify } from 'shared/store' + +import { DeleteWarehouseDialog } from '../components/DeleteWarehouseDialog' +import { WarehouseFormModal } from '../components/WarehouseFormModal' +import { formatUnits, warehousesCopy } from '../content' +import { + RESTRICTED_STATUS, + useCreateWarehouse, + useDeleteWarehouse, + useUpdateWarehouse, + useWarehouseList, +} from '../hooks/useWarehouses' +import type { Warehouse, WarehouseInput } from '../types' +import { deleteBlockerFrom } from '../utils/deleteBlocker' + +const { page, columns, actions: actionCopy, form } = warehousesCopy + +const COLUMNS: DataTableColumn[] = [ + { id: 'name', header: columns.name, render: (warehouse) => warehouse.name }, + { id: 'address', header: columns.address, render: (warehouse) => warehouse.address }, + { + id: 'zipCode', + header: columns.zipCode, + width: 140, + render: (warehouse) => warehouse.zipCode, + }, + { + id: 'storedUnits', + header: columns.storedUnits, + align: 'right', + width: 180, + render: (warehouse) => formatUnits(warehouse.storedUnits), + }, +] + +/** El modal abierto: alta, edición de un depósito, o ninguno. */ +type FormState = { mode: 'closed' } | { mode: 'create' } | { mode: 'edit'; warehouse: Warehouse } + +/** + * Depósitos de la empresa (RF-03): listado, alta, edición y baja contra + * `/api/v1/warehouses`. + * + * No tiene maqueta en `docs/design/`: se arma con la tabla, el modal y el + * diálogo de confirmación del DS, con la cabecera de las demás pantallas. + */ +export function WarehousesPage() { + const warehouses = useWarehouseList() + const createMutation = useCreateWarehouse() + const updateMutation = useUpdateWarehouse() + const deleteMutation = useDeleteWarehouse() + const [formState, setFormState] = useState({ mode: 'closed' }) + const [removing, setRemoving] = useState(undefined) + + const activeMutation = formState.mode === 'edit' ? updateMutation : createMutation + + function openForm(next: FormState) { + createMutation.reset() + updateMutation.reset() + setFormState(next) + } + + function save(input: WarehouseInput) { + const done = (saved: Warehouse) => { + notify( + formState.mode === 'edit' ? page.saved(saved.name) : page.created(saved.name), + 'success', + ) + setFormState({ mode: 'closed' }) + } + + if (formState.mode === 'edit') { + updateMutation.mutate({ id: formState.warehouse.id, input }, { onSuccess: done }) + } else { + createMutation.mutate(input, { onSuccess: done }) + } + } + + function askToRemove(warehouse: Warehouse) { + deleteMutation.reset() + setRemoving(warehouse) + } + + function confirmRemove() { + if (removing === undefined) return + + const { name } = removing + deleteMutation.mutate(removing.id, { + onSuccess: () => { + notify(page.deleted(name), 'success') + setRemoving(undefined) + }, + }) + } + + const blocked = deleteMutation.error?.status === RESTRICTED_STATUS + + const rowActions: DataTableAction[] = [ + { + id: 'edit', + label: actionCopy.edit, + icon: , + onSelect: (warehouse) => openForm({ mode: 'edit', warehouse }), + }, + { + id: 'delete', + label: actionCopy.delete, + icon: , + tone: 'danger', + onSelect: askToRemove, + }, + ] + + if (warehouses.isPending) return + + if (warehouses.isError) { + return void warehouses.refetch()} /> + } + + return ( + + + + + {page.title} + + + {page.subtitle} + + + + + + warehouse.id} + label={page.tableLabel} + actions={rowActions} + actionsHeader={actionCopy.header} + getActionsLabel={(warehouse) => actionCopy.menuFor(warehouse.name)} + emptyMessage={page.empty} + footer={page.footer(warehouses.data.length)} + /> + + setFormState({ mode: 'closed' })} + /> + + setRemoving(undefined)} + /> + + ) +} diff --git a/src/features/warehouses/queryKeys.ts b/src/features/warehouses/queryKeys.ts new file mode 100644 index 0000000..213693c --- /dev/null +++ b/src/features/warehouses/queryKeys.ts @@ -0,0 +1,6 @@ +// Factory de query keys de la feature — nunca literales sueltos en los hooks. + +export const warehouseKeys = { + all: ['warehouses'] as const, + list: () => [...warehouseKeys.all, 'list'] as const, +} diff --git a/src/features/warehouses/types.ts b/src/features/warehouses/types.ts new file mode 100644 index 0000000..864ce16 --- /dev/null +++ b/src/features/warehouses/types.ts @@ -0,0 +1,31 @@ +// Tipos de la feature de depósitos, en camelCase. La traducción desde el +// snake_case de Rails vive en `api.ts`. + +/** Depósito físico de la empresa. Espejo de `GET /api/v1/warehouses`. */ +export interface Warehouse { + id: number + name: string + address: string + zipCode: string + /** Unidades guardadas sumando todos los productos. Cero es un dato, nunca null. */ + storedUnits: number +} + +/** Lo que se edita de un depósito. */ +export interface WarehouseInput { + name: string + address: string + zipCode: string +} + +/** Cuerpo de `POST`/`PUT /api/v1/warehouses`, en el snake_case que espera Rails. */ +export interface WarehousePayload { + warehouse: { + name: string + address: string + zip_code: string + } +} + +/** Por qué la API se niega a borrar un depósito (409). */ +export type DeleteBlocker = 'stock' | 'orders' | 'transfers' | 'unknown' diff --git a/src/features/warehouses/utils/deleteBlocker.test.ts b/src/features/warehouses/utils/deleteBlocker.test.ts new file mode 100644 index 0000000..9ca203a --- /dev/null +++ b/src/features/warehouses/utils/deleteBlocker.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' + +import { deleteBlockerFrom } from './deleteBlocker' + +// Los tres textos que hoy responde `WarehousesController#blocking_reason`. +describe('deleteBlockerFrom', () => { + it('recognizes a warehouse with stock rows', () => { + expect(deleteBlockerFrom('Cannot delete warehouse with existing stock')).toBe('stock') + }) + + it('recognizes a warehouse that order lines came from', () => { + expect(deleteBlockerFrom('Cannot delete warehouse with order lines taken from it')).toBe( + 'orders', + ) + }) + + // «stock transfers» también contiene «stock»: el orden de los fragmentos es + // lo que evita leerlo como el caso de stock. + it('recognizes a warehouse with transfers before mistaking it for stock', () => { + expect(deleteBlockerFrom('Cannot delete warehouse with stock transfers from or to it')).toBe( + 'transfers', + ) + }) + + it('falls back to a generic reason for a text it does not know', () => { + expect(deleteBlockerFrom('Something else')).toBe('unknown') + expect(deleteBlockerFrom(undefined)).toBe('unknown') + }) +}) diff --git a/src/features/warehouses/utils/deleteBlocker.ts b/src/features/warehouses/utils/deleteBlocker.ts new file mode 100644 index 0000000..e28d7be --- /dev/null +++ b/src/features/warehouses/utils/deleteBlocker.ts @@ -0,0 +1,19 @@ +import type { DeleteBlocker } from '../types' + +// El 409 de la baja trae el motivo en inglés y en el texto, no en un código: +// `Cannot delete warehouse with existing stock` / `... order lines taken from +// it` / `... stock transfers from or to it` (`WarehousesController# +// blocking_reason`). Se reconoce por fragmentos y no por la frase entera, para +// que un ajuste de redacción en el backend no lo rompa del todo; lo que no se +// reconoce cae en un motivo genérico, nunca en el texto crudo. +const FRAGMENTS: [fragment: string, blocker: DeleteBlocker][] = [ + ['transfer', 'transfers'], + ['order', 'orders'], + ['stock', 'stock'], +] + +export function deleteBlockerFrom(message: string | undefined): DeleteBlocker { + const text = (message ?? '').toLowerCase() + + return FRAGMENTS.find(([fragment]) => text.includes(fragment))?.[1] ?? 'unknown' +}