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}
+
+
+ }
+ onClick={() => openForm({ mode: 'create' })}
+ sx={{ ml: { md: 'auto' }, flexShrink: 0 }}
+ >
+ {page.create}
+
+
+
+ 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'
+}