diff --git a/docs/guidelines/architecture.md b/docs/guidelines/architecture.md index 1f33565..531b040 100644 --- a/docs/guidelines/architecture.md +++ b/docs/guidelines/architecture.md @@ -263,7 +263,7 @@ const { slug, config, setConfig } = useTenantStore() - `authStore` — persiste **sólo** el token (clave `'auth-store'`); un token vencido o corrupto se descarta antes de arrancar. `user` es la identidad que devuelve `GET /me` (ver ADR-002) y no se persiste. El logout vacía también lo del usuario que se va: la cache de React Query y el borrador de la orden. `followSessionAcrossTabs()`, registrado en `main.tsx`, mantiene de acuerdo a las pestañas: si otra cierra sesión o entra con otra cuenta, ésta relee la sesión guardada y vacía lo del usuario anterior. Expone `getAuthToken()` y `clearSession()` para consumidores fuera de React, como el interceptor HTTP. - `tenantStore` — `slug` (resuelto del host, no lo cambia la app) y `config` del tenant (clave `'tenant-store'`). La config persistida se restaura **sólo** si es del mismo slug, y se valida con el mismo schema que la respuesta del backend. Expone `useTenantName()` y `useTenantFeature(feature)` como selectores, y `setTenantConfig()` para escribirla desde afuera de React. - `notificationStore` — cola de notificaciones con `notify(mensaje, severidad)`, también invocable fuera de React. La renderiza `NotificationHost`, montado una vez en los providers. -- `orderDraftStore` — borrador del alta manual de una orden (cliente, líneas, depósito de origen y domicilio de entrega), compartido por los tres pasos del asistente (`/orders/new`, S05 → S07). Persiste en **`sessionStorage`** (clave `'order-draft-store'`): un reload entre pasos no pierde lo cargado, pero un borrador a medias no reaparece días después en otra pestaña. `clearDraft()` al cancelar, al confirmar y al cerrar sesión (el logout lo vacía junto con la cache de React Query). +- `orderDraftStore` — borrador del alta manual de una orden (cliente, líneas, depósito de origen y domicilio de entrega), compartido por los tres pasos del asistente (`/orders/new`, S05 → S07). Persiste en **`sessionStorage`** (clave `'order-draft-store'`): un reload entre pasos no pierde lo cargado, pero un borrador a medias no reaparece días después en otra pestaña. `clearDraft()` al cancelar, al cerrar sesión (el logout lo vacía junto con la cache de React Query) y al confirmar apenas la orden existe —antes del despacho—: así un reload después de un despacho fallido no puede crear la misma venta dos veces. **Conteos sobre listados paginados** (`shared/api/count.ts`): @@ -314,7 +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` | -| `orders` | Listado global de órdenes (`/orders`), el detalle de una orden con el ciclo de vida de su envío (`/orders/:orderId`), su modificación (`/orders/edit/:orderId`) y los pasos 1 y 2 del alta manual (`/orders/new`: cliente y productos; `/orders/new/shipping`: origen y destino; el paso 3 es TESIS-59) | +| `orders` | Listado global de órdenes (`/orders`), el detalle de una orden con el ciclo de vida de su envío (`/orders/:orderId`), su modificación (`/orders/edit/:orderId`) y los tres pasos del alta manual (`/orders/new`: cliente y productos; `/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`) | **`orders` — piezas y por qué:** @@ -328,8 +328,13 @@ features/[nombre]/ | `components/ShipmentStateMessage.tsx` | Lo que muestran los paneles del envío cuando no hay uno que dibujar (sin envío, envío duplicado, cargando, error). Lo comparten el ciclo de vida y los datos del envío, así los dos dicen lo mismo | | `utils/draft.ts` | Reglas del paso 1 del alta manual: el filtro del buscador (SKU o nombre, en memoria), qué línea es válida, los totales del borrador y cuándo se habilita «Siguiente» (`canProceed`). Puras, para probarlas sin montar la pantalla | | `components/ProductPicker/` | Buscador + cantidad + precio unitario para sumar un SKU al borrador. `GET /products` no busca, así que trae una página (100, el máximo) y filtra del lado del cliente. No hay precio de lista en `products`: el precio se carga a mano | -| `utils/shipping.ts` | Reglas del paso 2: qué depósito cubre el borrador entero (`warehouseCoverage`) y los dos requests que hará el paso 3 con lo elegido (`toCreateOrderPayload`, `toQuotePayload`). La orden sale de un solo depósito: el `warehouse_id` es el mismo en todas las líneas | +| `utils/shipping.ts` | Reglas del envío en el alta: qué depósito cubre el borrador entero (`warehouseCoverage`, paso 2) y los requests del paso 3: cotizar el borrador (`toDraftQuotePayload`), crear la orden (`toCreateOrderPayload`), despachar la opción elegida (`toDispatchPayload`) y el total con envío (`totalWithShipping`). La orden sale de un solo depósito: el `warehouse_id` es el mismo en todas las líneas | | `components/OriginWarehousePicker/` | Los depósitos como tarjetas elegibles (grupo de radios), cada una con su nivel de stock para el borrador. Un depósito que no cubre la orden entera queda deshabilitado, con el motivo en su descripción accesible. El stock por depósito sale de `GET /products/:id`, uno por línea: el listado del catálogo no trae el desglose | +| `components/SelectableCard/` | La tarjeta elegible de los grupos de radios del alta: el depósito de origen (paso 2) y el operador logístico (paso 3). Se extrajo cuando apareció el segundo | +| `components/QuoteOptionList/` | Las opciones cotizadas como tarjetas elegibles, una por fila: courier, plazo y tarifa. Marca la más económica, que llega primera; no inventa los otros badges de S07, porque la API no los informa | +| `components/OrderConfirmCard/` | «Resumen de la orden» del paso 3: productos, envío elegido, total final y los botones del asistente. Sin opción elegida el envío dice que falta elegirla, no que cuesta 0 | +| `hooks/useDraftQuotes.ts` | La cotización del borrador (`POST /quotes`, TESIS-131), sin crear la orden. Su clave (`quoteKeys`) no cuelga de `orders`: confirmar invalida las órdenes y no tiene que volver a cotizar | +| `hooks/useConfirmDraftOrder.ts` | «Confirmar orden»: alta, envío y despacho encadenados. No son atómicos —el despacho llama a un courier externo—, así que un reintento retoma desde donde falló en vez de crear la orden otra vez. Mientras el despacho no sale, el paso 3 es el único lugar desde el que se puede despachar esa orden: la pantalla lo dice y el navegador pregunta antes de recargar o cerrar la pestaña | | `components/DestinationFieldsCard/` | Domicilio de entrega: calle, ciudad, provincia y código postal (TESIS-128). La provincia se elige de `GET /orders/provinces`, no de una lista escrita en el front: tiene que coincidir con la que valida el backend | | `utils/edit.ts` | Reglas de la modificación: qué líneas quedan fijas (las anteriores a TESIS-126 no saben de qué depósito salieron), cuáles piden más stock del libre —con la misma cuenta neta que el backend, que devuelve antes de descontar—, si las líneas cambiaron (sin cambios, el `PUT` no manda `items` y no toca stock) y el body del `PUT` | | `components/OrderEditForm/` | El formulario de S09. Dos formularios de React Hook Form (datos de la orden y domicilio) y las líneas en estado local, guardados juntos en un `PUT` con `If-Match`. La versión que viaja en `If-Match` es la que se leyó al montar, congelada: un refetch en segundo plano no la puede reemplazar sin que el guardado deje de detectar el cambio de otro operador. El formulario no se remonta cuando cambia la versión (guardar la cambia, y remontar descartaría el callback que lleva al detalle); sólo cuando el operador recarga después de un 412. La regla de cuándo una orden no se edita (cancelada, o con el envío ya salido) es la de `Orders::UpdateOrder` | diff --git a/src/app/router/routes.tsx b/src/app/router/routes.tsx index 9025eb3..4225bcb 100644 --- a/src/app/router/routes.tsx +++ b/src/app/router/routes.tsx @@ -27,6 +27,9 @@ const NewOrderPage = lazy(() => const ShippingStepPage = lazy(() => import('features/orders').then((m) => ({ default: m.ShippingStepPage })), ) +const CarrierStepPage = lazy(() => + import('features/orders').then((m) => ({ default: m.CarrierStepPage })), +) const OrderEditPage = lazy(() => import('features/orders').then((m) => ({ default: m.OrderEditPage })), ) @@ -106,6 +109,11 @@ export const appRoutes: AppRoute[] = [ element: , // Paso 2 del alta manual: origen y destino. Se llega desde el paso 1. }, + { + path: '/orders/new/carrier', + element: , + // Paso 3 del alta manual: cotización y confirmación. Se llega desde el paso 2. + }, { path: '/orders/edit/:orderId', element: , diff --git a/src/features/orders/api.test.ts b/src/features/orders/api.test.ts index bbcc39e..d4d9883 100644 --- a/src/features/orders/api.test.ts +++ b/src/features/orders/api.test.ts @@ -3,11 +3,15 @@ import { client } from 'shared/api/client' import { describe, expect, it, vi } from 'vitest' import { + createOrder, + createOrderShipment, + dispatchShipment, fetchOrder, fetchOrderShipment, fetchProductStocks, fetchProvinces, fetchWarehouses, + quoteDraft, updateOrder, } from './api' @@ -241,3 +245,133 @@ describe('fetchProvinces', () => { expect(get).toHaveBeenCalledWith('/orders/provinces') }) }) + +describe('quoteDraft', () => { + const payload = { + quote: { + origin_warehouse_id: 3, + destination_zip_code: '1193', + destination_address: 'Av. Corrientes 3247', + items: [{ product_id: 12, quantity: 4 }], + }, + } + + it('quotes the draft against the endpoint that needs no order', async () => { + const post = vi.spyOn(client, 'post').mockResolvedValueOnce(respond({ data: [] })) + + await quoteDraft(payload) + + expect(post).toHaveBeenCalledWith('/quotes', payload) + }) + + // `shipping_cost` es un BigDecimal de Rails y el JSON lo manda como string. + it('turns each option into the domain, with the cost as a number', async () => { + vi.spyOn(client, 'post').mockResolvedValueOnce( + respond({ + data: [ + { + company_integration_id: 7, + dispatch_integration_id: 4, + provider_name: 'Andreani', + shipping_cost: '58300.0', + estimated_days: null, + }, + ], + }), + ) + + expect(await quoteDraft(payload)).toEqual([ + { + quoteIntegrationId: 7, + dispatchIntegrationId: 4, + providerName: 'Andreani', + shippingCost: 58300, + estimatedDays: null, + }, + ]) + }) +}) + +// Lo que elige el operador viaja al despacho y queda en `decimal(10,2)`: la +// pantalla tiene que mostrar lo mismo que después guarda el envío. +describe('the cost of a quote with more than two decimals', () => { + async function quotedCost(cost: string | number) { + vi.spyOn(client, 'post').mockResolvedValueOnce( + respond({ + data: [ + { + company_integration_id: 7, + dispatch_integration_id: 4, + provider_name: 'Andreani', + shipping_cost: cost, + estimated_days: null, + }, + ], + }), + ) + const [quote] = await quoteDraft({ + quote: { + origin_warehouse_id: 3, + destination_zip_code: '1193', + destination_address: 'Av. Corrientes 3247', + items: [{ product_id: 12, quantity: 1 }], + }, + }) + return quote?.shippingCost + } + + it.each([ + ['1.005', 1.01], + ['41200.555', 41200.56], + ['41200.5', 41200.5], + ['99.994', 99.99], + [2500.125, 2500.13], + ])('rounds %s to the cents the shipment keeps', async (cost, expected) => { + expect(await quotedCost(cost)).toBe(expected) + }) +}) + +describe('the confirmation of a manual order', () => { + it('creates the order with the payload of the wizard', async () => { + const post = vi + .spyOn(client, 'post') + .mockResolvedValueOnce(respond({ ...ORDER, order_items: [] })) + const payload = { + order: { + customer_name: 'Global Tech', + customer_document: '30-71234567-8', + customer_address: 'Av. Corrientes 3247', + customer_city: 'CABA', + customer_province: 'Ciudad Autónoma de Buenos Aires', + customer_zip_code: '1193', + items: [{ product_id: 12, warehouse_id: 3, quantity: 4, unit_price: 120000 }], + }, + } + + const order = await createOrder(payload) + + expect(post).toHaveBeenCalledWith('/orders', payload) + expect(order.id).toBe(ORDER.id) + }) + + it('opens the shipment of the order', async () => { + const post = vi.spyOn(client, 'post').mockResolvedValueOnce(respond(SHIPMENT)) + + const shipment = await createOrderShipment(8829) + + expect(post).toHaveBeenCalledWith('/orders/8829/shipment') + expect(shipment.id).toBe(31) + }) + + it('dispatches the shipment with the chosen option', async () => { + const post = vi.spyOn(client, 'post').mockResolvedValueOnce(respond(SHIPMENT)) + const payload = { + dispatch: { company_integration_id: 4, origin_warehouse_id: 3, shipping_cost: 58300 }, + } + + const shipment = await dispatchShipment(31, payload) + + expect(post).toHaveBeenCalledWith('/shipments/31/dispatch', payload) + expect(shipment.trackingNumber).toBe('AND-9920-X8829-Z') + }) +}) diff --git a/src/features/orders/api.ts b/src/features/orders/api.ts index ebfa99a..95b334a 100644 --- a/src/features/orders/api.ts +++ b/src/features/orders/api.ts @@ -14,8 +14,10 @@ import type { ProductStockByWarehouse, Shipment, ShipmentStatus, + ShippingQuote, UpdateOrderPayload, } from './types' +import type { CreateOrderPayload, DispatchPayload, DraftQuotePayload } from './utils/shipping' // Frontera con la API Rails. Lo que entra en snake_case se traduce acá y sale // como el dominio en camelCase. @@ -115,6 +117,16 @@ interface ApiShipment { events: ApiShipmentEvent[] } +// Una opción de la cotización. `shipping_cost` es un BigDecimal del lado de +// Rails, que el JSON serializa como string ("2500.0"): se convierte acá. +interface ApiShippingQuote { + company_integration_id: number + dispatch_integration_id: number + provider_name: string + shipping_cost: string | number + estimated_days: number | null +} + // La fila del listado de envíos: sólo se lee para saber cuántos hay y cuál es. interface ApiShipmentList { data: { id: number }[] @@ -364,3 +376,61 @@ export async function fetchProvinces(): Promise { return data.data } + +/** + * El costo cotizado, redondeado a centavos como lo va a guardar el envío + * (`decimal(10,2)`, que redondea la mitad hacia arriba). Un courier puede + * contestar con más decimales, y el costo elegido viaja al despacho: sin esto + * la pantalla mostraba un total y el detalle de la orden otro. Con + * `"1.005"`, `Math.round(1.005 * 100)` da 100 —el float es 1.00499…— y el + * backend guarda 1.01. Correr la coma en el texto (`"1.005e2"` es 100.5 + * exacto) redondea el decimal que mandó el backend, no su aproximación binaria. + */ +function toCostInCents(cost: string | number): number { + return Math.round(Number(`${cost}e2`)) / 100 +} + +function toShippingQuote(quote: ApiShippingQuote): ShippingQuote { + return { + quoteIntegrationId: quote.company_integration_id, + dispatchIntegrationId: quote.dispatch_integration_id, + providerName: quote.provider_name, + shippingCost: toCostInCents(quote.shipping_cost), + estimatedDays: quote.estimated_days, + } +} + +/** + * Las opciones de envío para el borrador del alta manual (TESIS-131), antes de + * que la orden exista. Una lista vacía no es un error: quiere decir que ningún + * operador contestó a tiempo, y la pantalla lo muestra distinto de un fallo. + */ +export async function quoteDraft(payload: DraftQuotePayload): Promise { + const { data } = await client.post<{ data: ApiShippingQuote[] }>('/quotes', payload) + + return data.data.map(toShippingQuote) +} + +/** El alta de la orden (TESIS-42). Es lo que descuenta el stock. */ +export async function createOrder(payload: CreateOrderPayload): Promise { + const response = await client.post('/orders', payload) + + return toOrderDetail(response.data, readVersion(response.headers.etag)) +} + +/** Abre el envío de la orden, `pending` y sin courier (TESIS-105). */ +export async function createOrderShipment(orderId: number): Promise { + const { data } = await client.post(`/orders/${orderId}/shipment`) + + return toShipment(data) +} + +/** Despacha el envío con el operador elegido: pide la etiqueta (TESIS-47). */ +export async function dispatchShipment( + shipmentId: number, + payload: DispatchPayload, +): Promise { + const { data } = await client.post(`/shipments/${shipmentId}/dispatch`, payload) + + return toShipment(data) +} diff --git a/src/features/orders/components/OrderConfirmCard/OrderConfirmCard.styles.ts b/src/features/orders/components/OrderConfirmCard/OrderConfirmCard.styles.ts new file mode 100644 index 0000000..620eee1 --- /dev/null +++ b/src/features/orders/components/OrderConfirmCard/OrderConfirmCard.styles.ts @@ -0,0 +1,31 @@ +import { Box, Card, Typography } from '@mui/material' +import { styled } from '@mui/material/styles' +import type { ElementType } from 'react' + +// Ver `InfoPanel.styles.ts`: `component` en un `styled(Card)` necesita el tipo. +interface AsProp { + component?: ElementType +} + +export const ConfirmCardRoot = styled(Card)(({ theme }) => ({ + padding: theme.spacing(3), + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), +})) + +export const SummaryLine = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'baseline', + gap: theme.spacing(2), + '& > :last-child': { marginLeft: 'auto', textAlign: 'right' }, +})) + +// El total final de S07: el número más grande de la pantalla, en el color de +// acción y en la familia de los datos, como el subtotal del paso 1. +export const TotalValue = styled(Typography)(({ theme }) => ({ + ...theme.typography.displaySm, + fontFamily: theme.typography.dataMono.fontFamily, + color: theme.vars.palette.primary.main, + whiteSpace: 'nowrap', +})) diff --git a/src/features/orders/components/OrderConfirmCard/OrderConfirmCard.tsx b/src/features/orders/components/OrderConfirmCard/OrderConfirmCard.tsx new file mode 100644 index 0000000..9c86943 --- /dev/null +++ b/src/features/orders/components/OrderConfirmCard/OrderConfirmCard.tsx @@ -0,0 +1,117 @@ +import ArrowBackIcon from '@mui/icons-material/ArrowBack' +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline' +import { Alert, Button, Divider, Stack, Typography } from '@mui/material' +import { useId } from 'react' + +import { ordersCopy } from '../../content' +import { formatMoney, formatWeight } from '../../utils/format' +import { totalWithShipping } from '../../utils/shipping' + +import { ConfirmCardRoot, SummaryLine, TotalValue } from './OrderConfirmCard.styles' +import type { OrderConfirmCardProps } from './OrderConfirmCard.types' + +const { summary: copy } = ordersCopy.carrier + +/** + * «Resumen de la orden» del paso 3 (S07): lo que se va a crear, el envío + * elegido, el total final y los dos botones del asistente. + * + * El total suma el envío apenas se elige una opción, que es lo que pide la card + * («el costo de envío se suma al total general de forma transparente»). Sin + * opción elegida el envío no cuesta 0: dice que falta elegirlo, y el total es + * lo que se sabe. + */ +export function OrderConfirmCard({ + subtotal, + shippingCost, + weight, + originName, + destinationLabel, + canConfirm, + confirming, + confirmLabel, + onConfirm, + onBack, + status, +}: OrderConfirmCardProps) { + const titleId = useId() + + return ( + + + {copy.title} + + + + + + + + + + + + + + + {copy.total} + + + + {formatMoney(totalWithShipping(subtotal, shippingCost))} + + + {copy.currency} + + + + + {status} + + + {onBack === undefined ? null : ( + + )} + + + {copy.notice} + + + ) +} + +function Line({ label, value, muted = false }: { label: string; value: string; muted?: boolean }) { + return ( + + {/* El rótulo no se parte: el que cede espacio es el valor, que puede ser + largo (el destino completo). */} + + {label} + + + {value} + + + ) +} diff --git a/src/features/orders/components/OrderConfirmCard/OrderConfirmCard.types.ts b/src/features/orders/components/OrderConfirmCard/OrderConfirmCard.types.ts new file mode 100644 index 0000000..f546886 --- /dev/null +++ b/src/features/orders/components/OrderConfirmCard/OrderConfirmCard.types.ts @@ -0,0 +1,24 @@ +import type { ReactNode } from 'react' + +export interface OrderConfirmCardProps { + /** Lo que suman los productos del borrador. */ + subtotal: number + /** El costo de la opción elegida; null mientras no se eligió ninguna. */ + shippingCost: number | null + /** Peso estimado del paquete, en kg. */ + weight: number + /** De dónde sale: el nombre del depósito de origen. */ + originName: string + /** A dónde va: "CABA, Ciudad Autónoma de Buenos Aires · CP 1193". */ + destinationLabel: string + /** Si «Confirmar orden» se puede apretar: hay una opción elegida y no se está confirmando. */ + canConfirm: boolean + confirming: boolean + /** El rótulo del botón principal: «Confirmar orden», o reintentar el despacho. */ + confirmLabel: string + onConfirm: () => void + /** Ausente cuando ya no se puede volver: la orden existe. */ + onBack?: () => void + /** Lo que salió mal al confirmar, arriba de los botones. */ + status?: ReactNode +} diff --git a/src/features/orders/components/OrderConfirmCard/index.ts b/src/features/orders/components/OrderConfirmCard/index.ts new file mode 100644 index 0000000..9f03f26 --- /dev/null +++ b/src/features/orders/components/OrderConfirmCard/index.ts @@ -0,0 +1,2 @@ +export { OrderConfirmCard } from './OrderConfirmCard' +export type { OrderConfirmCardProps } from './OrderConfirmCard.types' diff --git a/src/features/orders/components/OriginWarehousePicker/OriginWarehousePicker.styles.ts b/src/features/orders/components/OriginWarehousePicker/OriginWarehousePicker.styles.ts index af89d02..18ed6c1 100644 --- a/src/features/orders/components/OriginWarehousePicker/OriginWarehousePicker.styles.ts +++ b/src/features/orders/components/OriginWarehousePicker/OriginWarehousePicker.styles.ts @@ -1,11 +1,6 @@ -import { Box, ButtonBase } from '@mui/material' +import { Box } from '@mui/material' import { styled } from '@mui/material/styles' -// Radio de la tarjeta (px): `radius.md` del DS. No se lee de `tokens.ts` porque -// una feature no puede importar de `app/` (architecture.md §3.2); mismo -// criterio que `EditProductModal.styles.ts`. -const OPTION_RADIUS = 12 - // Tres columnas en el diseño; dos o una cuando no entran. export const OptionsGrid = styled(Box)(({ theme }) => ({ display: 'grid', @@ -15,37 +10,6 @@ export const OptionsGrid = styled(Box)(({ theme }) => ({ [theme.breakpoints.down('sm')]: { gridTemplateColumns: 'minmax(0, 1fr)' }, })) -interface OptionProps { - selected: boolean -} - -const TRANSIENT_PROPS = new Set(['selected']) - -// La tarjeta de depósito de S06. La elegida se resalta con el contenedor y el -// borde del color de acción, en los dos modos: la card pide que la opción -// elegida se distinga claramente, y el color no va solo — el ícono de check -// acompaña. -export const Option = styled(ButtonBase, { - shouldForwardProp: (prop) => !TRANSIENT_PROPS.has(prop as string), -})(({ theme, selected }) => ({ - display: 'flex', - flexDirection: 'column', - alignItems: 'stretch', - gap: theme.spacing(1.5), - padding: theme.spacing(2), - textAlign: 'left', - borderRadius: OPTION_RADIUS, - border: `1px solid ${selected ? theme.vars.palette.primary.main : theme.vars.palette.divider}`, - backgroundColor: selected - ? theme.vars.palette.primary.container - : theme.vars.palette.background.default, - transition: theme.transitions.create(['border-color', 'background-color'], { - duration: theme.transitions.duration.shorter, - }), - '&:hover:not(.Mui-disabled)': { borderColor: theme.vars.palette.primary.main }, - '&.Mui-disabled': { opacity: 0.6 }, -})) - export const OptionHeader = styled(Box)(({ theme }) => ({ display: 'flex', alignItems: 'flex-start', diff --git a/src/features/orders/components/OriginWarehousePicker/OriginWarehousePicker.tsx b/src/features/orders/components/OriginWarehousePicker/OriginWarehousePicker.tsx index 5cb2a93..67c3113 100644 --- a/src/features/orders/components/OriginWarehousePicker/OriginWarehousePicker.tsx +++ b/src/features/orders/components/OriginWarehousePicker/OriginWarehousePicker.tsx @@ -6,9 +6,9 @@ import type { StatusVariant } from 'shared/components' import { ordersCopy } from '../../content' import type { CoverageLevel, WarehouseCoverage } from '../../utils/shipping' +import { SelectableCard } from '../SelectableCard' import { - Option, OptionHeader, OptionTitle, OptionsGrid, @@ -55,7 +55,7 @@ export function OriginWarehousePicker({ const detailId = `origin-warehouse-${warehouse.id}-detail` return ( - + ) })} diff --git a/src/features/orders/components/QuoteOptionList/QuoteOptionList.styles.ts b/src/features/orders/components/QuoteOptionList/QuoteOptionList.styles.ts new file mode 100644 index 0000000..ce6e4d0 --- /dev/null +++ b/src/features/orders/components/QuoteOptionList/QuoteOptionList.styles.ts @@ -0,0 +1,59 @@ +import { Box } from '@mui/material' +import { styled } from '@mui/material/styles' + +// Una opción por fila, como en S07: se comparan de arriba a abajo por precio. +export const OptionsColumn = styled(Box)(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), +})) + +export const OptionRow = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(2), + [theme.breakpoints.down('sm')]: { flexWrap: 'wrap' }, +})) + +// El recuadro del logo del diseño. No hay logos de los operadores en el +// proyecto, así que lleva el nombre en mayúsculas, que es lo que dibuja S07. +// Repite el nombre que va al lado, así que es lo primero que cede cuando la +// columna se angosta (con el resumen al costado, por debajo de `lg`). +export const CarrierMark = styled(Box)(({ theme }) => ({ + [theme.breakpoints.down('lg')]: { display: 'none' }, + width: 88, + height: 48, + flex: 'none', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: theme.spacing(0, 1), + borderRadius: 8, + backgroundColor: theme.vars.palette.action.hover, + textAlign: 'center', +})) + +export const CarrierText = styled(Box)(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.5), + minWidth: 0, + flex: '1 1 auto', +})) + +export const PriceColumn = styled(Box)({ + marginLeft: 'auto', + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-end', +}) + +// El precio de S07: grande y en la familia de los datos, como los totales del +// paso 1 (`DraftSummaryCard`). Un `span` y no un `Typography`: vive dentro del +// ` + )} + + + ) : null + + return ( + + + + + + } + title={carrier.options.groupLabel} + > + setSelectedId(quote.dispatchIntegrationId)} + onReview={() => void navigate(SHIPPING_STEP_PATH)} + /> + + + void navigate(SHIPPING_STEP_PATH) : undefined} + status={status} + /> + + + + ) +} + +interface QuotesContentProps { + quotes: ReturnType + selectedId: number | null + disabled: boolean + onSelect: Parameters[0]['onSelect'] + onReview: () => void +} + +// Los estados de la cotización, fuera del cuerpo de la página para que éste se +// lea de un vistazo. +function QuotesContent({ quotes, selectedId, disabled, onSelect, onReview }: QuotesContentProps) { + const { options: copy } = carrier + + // Consultar a los operadores puede tardar lo que tarda el más lento: la + // pantalla lo dice en vez de quedarse en blanco. + if (quotes.isPending) { + return ( + + + + {copy.loading} + + + ) + } + + // Un fallo y una lista vacía son dos cosas distintas: el primero es nuestro o + // de la red, la segunda es que ningún operador contestó a tiempo. Las dos + // dejan reintentar o volver a revisar origen y destino. + if (quotes.isError || quotes.data.length === 0) { + return ( + + + + + } + > + {quotes.isError ? copy.error : copy.empty} + + ) + } + + return ( + + ) +} + +/** + * Pide confirmación al recargar o cerrar la pestaña mientras `active`. Es lo que + * el navegador permite: el texto del diálogo es el suyo, no uno propio. + */ +function useLeaveWarning(active: boolean) { + useEffect(() => { + if (!active) return undefined + + const warn = (event: BeforeUnloadEvent) => { + event.preventDefault() + } + window.addEventListener('beforeunload', warn) + return () => window.removeEventListener('beforeunload', warn) + }, [active]) +} diff --git a/src/features/orders/queryKeys.ts b/src/features/orders/queryKeys.ts index d28b1ea..540fdfc 100644 --- a/src/features/orders/queryKeys.ts +++ b/src/features/orders/queryKeys.ts @@ -1,4 +1,5 @@ import type { OrderFilters, OrderStatus } from './types' +import type { DraftQuotePayload } from './utils/shipping' // Factory de query keys de la feature — nunca literales sueltos en los hooks, // así las invalidaciones no se desincronizan cuando aparezcan las mutaciones @@ -20,6 +21,23 @@ export const orderKeys = { shipment: (orderId: number) => [...orderKeys.all, 'shipment', orderId] as const, } +/** + * La cotización del alta manual (TESIS-59). + * + * Tiene su propia raíz y no cuelga de `orders` a propósito: confirmar la orden + * invalida `orderKeys.all`, y si la cotización colgara de ahí se volvería a + * pedir a todos los couriers apenas se crea la orden —una llamada de más por + * operador y, si el despacho falló, una lista que cambia mientras el operador + * decide si reintentar—. + * + * El borrador entero entra en la clave: volver al paso 2 y cambiar el depósito + * o una cantidad es otra cotización, no la misma con datos viejos. + */ +export const quoteKeys = { + all: ['quotes'] as const, + draft: (payload: DraftQuotePayload) => [...quoteKeys.all, 'draft', payload] as const, +} + /** * El catálogo que consulta el buscador del alta manual. * diff --git a/src/features/orders/types.ts b/src/features/orders/types.ts index af8d806..9bab6f8 100644 --- a/src/features/orders/types.ts +++ b/src/features/orders/types.ts @@ -112,6 +112,23 @@ export interface ShipmentEvent { occurredAt: string } +/** + * Una opción de envío cotizada (TESIS-46, TESIS-131). La lista llega ordenada + * por precio, de la más barata a la más cara, y ya sin las opciones que no se + * podrían despachar. + */ +export interface ShippingQuote { + /** La integración que contestó la tarifa. */ + quoteIntegrationId: number + /** La que emite la etiqueta: es la que se le manda al despacho. */ + dispatchIntegrationId: number + /** El courier ("Andreani"), no el nombre de su plantilla de cotización. */ + providerName: string + shippingCost: number + /** Días de entrega que promete el courier, o null si no lo informa. */ + estimatedDays: number | null +} + /** Detalle del envío (`GET /api/v1/shipments/:id`), con su bitácora. */ export interface Shipment { id: number diff --git a/src/features/orders/utils/shipping.test.ts b/src/features/orders/utils/shipping.test.ts index 1814c28..97a4a62 100644 --- a/src/features/orders/utils/shipping.test.ts +++ b/src/features/orders/utils/shipping.test.ts @@ -1,9 +1,15 @@ import type { OrderDraftItem } from 'shared/store' import { describe, expect, it } from 'vitest' -import type { ProductStockByWarehouse } from '../types' +import type { ProductStockByWarehouse, ShippingQuote } from '../types' -import { toCreateOrderPayload, toQuotePayload, warehouseCoverage } from './shipping' +import { + toCreateOrderPayload, + toDispatchPayload, + toDraftQuotePayload, + totalWithShipping, + warehouseCoverage, +} from './shipping' function item(overrides: Partial = {}): OrderDraftItem { return { @@ -105,10 +111,69 @@ describe('toCreateOrderPayload', () => { }) }) -describe('toQuotePayload', () => { - it('only tells the quote where the package leaves from', () => { - expect(toQuotePayload({ warehouseId: 3, name: 'CD Ezeiza' })).toEqual({ - quote: { origin_warehouse_id: 3 }, +describe('toDraftQuotePayload', () => { + const origin = { warehouseId: 3, name: 'CD Ezeiza' } + const destination = { + address: ' Av. Corrientes 3247, piso 5 ', + city: 'CABA', + province: 'Ciudad Autónoma de Buenos Aires', + zipCode: '1193 ', + } + + it('tells the quote where the parcel leaves from and where it goes', () => { + expect(toDraftQuotePayload([ROUTER], origin, destination).quote).toMatchObject({ + origin_warehouse_id: 3, + destination_zip_code: '1193', + destination_address: 'Av. Corrientes 3247, piso 5', }) }) + + // El peso no viaja: lo calcula el backend con el de cada producto. + it('says what the parcel carries, not how much it weighs', () => { + expect(toDraftQuotePayload([ROUTER, SENSOR], origin, destination).quote.items).toEqual([ + { product_id: 12, quantity: 4 }, + { product_id: 13, quantity: 10 }, + ]) + }) +}) + +describe('toDispatchPayload', () => { + const quote: ShippingQuote = { + quoteIntegrationId: 7, + dispatchIntegrationId: 4, + providerName: 'Andreani', + shippingCost: 58300, + estimatedDays: 2, + } + + // La cotización la contesta una plantilla y la etiqueta la emite otra: el + // despacho rechaza la integración de la cotización. + it('dispatches with the integration that dispatches, not with the one that quoted', () => { + expect( + toDispatchPayload(quote, { warehouseId: 3, name: 'CD Ezeiza' }).dispatch + .company_integration_id, + ).toBe(4) + }) + + it('carries the origin and the cost that was confirmed', () => { + expect(toDispatchPayload(quote, { warehouseId: 3, name: 'CD Ezeiza' }).dispatch).toMatchObject({ + origin_warehouse_id: 3, + shipping_cost: 58300, + }) + }) +}) + +describe('totalWithShipping', () => { + it('adds the chosen shipping to the products', () => { + expect(totalWithShipping(1420000, 58300)).toBe(1478300) + }) + + it('adds cents without floating point leftovers', () => { + expect(totalWithShipping(0.1, 0.2)).toBe(0.3) + }) + + // Sin opción elegida el envío no cuesta 0: el total es lo que se sabe. + it('is the products alone while no shipping was chosen', () => { + expect(totalWithShipping(1420000, null)).toBe(1420000) + }) }) diff --git a/src/features/orders/utils/shipping.ts b/src/features/orders/utils/shipping.ts index 2674918..59867e3 100644 --- a/src/features/orders/utils/shipping.ts +++ b/src/features/orders/utils/shipping.ts @@ -5,11 +5,12 @@ import type { OrderDraftOrigin, } from 'shared/store' -import type { ProductStockByWarehouse } from '../types' +import type { ProductStockByWarehouse, ShippingQuote } from '../types' -// Las reglas del paso 2 del alta manual (S06), fuera de los componentes para -// probarlas sin montar la pantalla: qué depósito cubre el borrador y cómo se -// arman los dos requests que va a hacer el paso 3 con lo que se eligió acá. +// Las reglas del envío en el alta manual, fuera de los componentes para +// probarlas sin montar las pantallas: qué depósito cubre el borrador (paso 2, +// S06) y cómo se arman los requests del paso 3 (S07): cotizar el borrador, +// crear la orden y despachar la opción elegida. /** Cómo cubre un depósito las líneas del borrador. */ export type CoverageLevel = 'full' | 'partial' | 'none' @@ -106,11 +107,69 @@ export function toCreateOrderPayload( } } +/** Lo que el paso 3 manda a `POST /api/v1/quotes` para cotizar el borrador. */ +export interface DraftQuotePayload { + quote: { + origin_warehouse_id: number + destination_zip_code: string + destination_address: string + items: { product_id: number; quantity: number }[] + } +} + +/** + * La cotización del borrador, antes de que la orden exista (TESIS-131). + * + * Viaja qué lleva el paquete y no cuánto pesa: el peso lo calcula el backend con + * el de cada producto, que es su dato. Así cotizar no crea la orden ni descuenta + * stock; eso pasa una sola vez, cuando el operador confirma. + */ +export function toDraftQuotePayload( + items: OrderDraftItem[], + origin: OrderDraftOrigin, + destination: OrderDraftDestination, +): DraftQuotePayload { + return { + quote: { + origin_warehouse_id: origin.warehouseId, + destination_zip_code: destination.zipCode.trim(), + destination_address: destination.address.trim(), + items: items.map((item) => ({ product_id: item.productId, quantity: item.quantity })), + }, + } +} + +/** Lo que el paso 3 manda a `POST /api/v1/shipments/:id/dispatch`. */ +export interface DispatchPayload { + dispatch: { + company_integration_id: number + origin_warehouse_id: number + shipping_cost: number + } +} + +/** + * El despacho de la opción elegida. La integración es la que **despacha** + * (`dispatchIntegrationId`), no la que contestó la tarifa: son dos plantillas + * del mismo courier, y el despacho rechaza la de cotización. El costo viaja + * para que quede en el envío, que es de donde lo lee el detalle de la orden. + */ +export function toDispatchPayload(quote: ShippingQuote, origin: OrderDraftOrigin): DispatchPayload { + return { + dispatch: { + company_integration_id: quote.dispatchIntegrationId, + origin_warehouse_id: origin.warehouseId, + shipping_cost: quote.shippingCost, + }, + } +} + /** - * Lo que el paso 3 manda a `POST /api/v1/orders/:id/quotes`, una vez creada la - * orden. El destino no viaja: la cotización lo lee de la orden (código postal y - * dirección), así que lo único que falta decirle es de dónde sale el paquete. + * El total final del paso 3: productos más el envío elegido. En centavos, como + * el resto de las cuentas de la feature. Sin envío elegido todavía, el total es + * lo que se sabe: los productos. */ -export function toQuotePayload(origin: OrderDraftOrigin) { - return { quote: { origin_warehouse_id: origin.warehouseId } } +export function totalWithShipping(subtotal: number, shippingCost: number | null): number { + const shippingCents = shippingCost === null ? 0 : Math.round(shippingCost * 100) + return (Math.round(subtotal * 100) + shippingCents) / 100 }