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 1fa6f74..cfcae09 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' +}