Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/guidelines/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |

Expand Down
9 changes: 8 additions & 1 deletion src/app/router/routes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
9 changes: 9 additions & 0 deletions src/app/router/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 })))

Expand Down Expand Up @@ -142,6 +146,11 @@ export const appRoutes: AppRoute[] = [
element: <ProductDetailPage />,
// Sin `nav`: se llega desde el catálogo, no desde el Sidebar.
},
{
path: '/warehouses',
element: <WarehousesPage />,
nav: { label: 'Depósitos', icon: <WarehouseOutlinedIcon /> },
},
{
path: '/reports',
element: <ReportsPage />,
Expand Down
11 changes: 11 additions & 0 deletions src/features/warehouses/api.test.ts
Original file line number Diff line number Diff line change
@@ -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' },
})
})
})
72 changes: 72 additions & 0 deletions src/features/warehouses/api.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
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<Warehouse[]> {
const { data } = await client.get<ApiList<ApiWarehouse>>('/warehouses')

return data.data.map(toWarehouse)
}

export async function createWarehouse(input: WarehouseInput): Promise<Warehouse> {
const { data } = await client.post<ApiWarehouse>('/warehouses', toPayload(input))

return toWarehouse(data)
}

export async function updateWarehouse(id: number, input: WarehouseInput): Promise<Warehouse> {
const { data } = await client.put<ApiWarehouse>(`/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<void> {
await client.delete(`/warehouses/${id}`)
}
Original file line number Diff line number Diff line change
@@ -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 (
<ConfirmDialog
open={warehouse !== undefined}
tone="destructive"
title={remove.title}
description={shown === undefined ? undefined : remove.body(shown.name)}
confirmLabel={remove.confirm}
cancelLabel={remove.cancel}
closeLabel={remove.close}
busy={deleting}
canConfirm={blocker === undefined}
onConfirm={onConfirm}
onClose={onClose}
>
{blocker === undefined ? null : <Alert severity="error">{remove.blocked[blocker]}</Alert>}
{failed && blocker === undefined ? <Alert severity="error">{remove.failed}</Alert> : null}
</ConfirmDialog>
)
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { DeleteWarehouseDialog } from './DeleteWarehouseDialog'
export type { DeleteWarehouseDialogProps } from './DeleteWarehouseDialog.types'
Original file line number Diff line number Diff line change
@@ -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<typeof warehouseFormSchema>
Original file line number Diff line number Diff line change
@@ -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(
<WarehouseFormModal open warehouse={warehouse} onSubmit={onSubmit} onClose={vi.fn()} />,
)
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()
})
})
Loading
Loading