diff --git a/docs/guidelines/architecture.md b/docs/guidelines/architecture.md index 8e64f02..991dc8f 100644 --- a/docs/guidelines/architecture.md +++ b/docs/guidelines/architecture.md @@ -327,6 +327,7 @@ features/[nombre]/ | `utils/payment.ts` | Las cuentas del detalle, en centavos para que sumar importes no deje restos de punto flotante. El subtotal es el `total_amount` persistido (TESIS-114), no la suma de las líneas recalculada | | `components/InfoPanel/` | Panel lateral de pares rótulo-valor, compartido por los datos del cliente y los del envío. Se extrajo al aparecer el **segundo** consumidor, no antes — `feature-structure.md` §6 | | `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 | +| `components/ShippingLabelField.tsx` | La etiqueta del panel de envío (RF-23): el rótulo imprimible que emite el courier al despachar. Tres estados y ningún hueco — sin despachar avisa que se emite ahí, despachado sin etiqueta dice que el operador no la devolvió, y con etiqueta el enlace, que abre aparte y con `rel="noopener noreferrer"` porque el destino es el sitio del courier | | `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 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 | diff --git a/src/features/orders/api.test.ts b/src/features/orders/api.test.ts index 0d51dc5..4a58c24 100644 --- a/src/features/orders/api.test.ts +++ b/src/features/orders/api.test.ts @@ -32,6 +32,7 @@ const SHIPMENT = { status: 'in_transit', tracking_number: 'AND-9920-X8829-Z', shipping_cost: 58300, + shipping_label_url: 'https://andreani.test/etiquetas/AND-9920.pdf', courier: { id: 4, service_id: 7, name: 'Andreani' }, events: [ { @@ -125,6 +126,7 @@ describe('fetchOrderShipment', () => { status: 'in_transit', trackingNumber: 'AND-9920-X8829-Z', shippingCost: 58300, + labelUrl: 'https://andreani.test/etiquetas/AND-9920.pdf', courier: { id: 4, serviceId: 7, name: 'Andreani' }, events: [ { diff --git a/src/features/orders/api.ts b/src/features/orders/api.ts index 1fa26bc..bb34e4b 100644 --- a/src/features/orders/api.ts +++ b/src/features/orders/api.ts @@ -18,6 +18,7 @@ import type { UpdateOrderPayload, } from './types' import type { OrderQuotePayload } from './utils/dispatch' +import { printableLabel } from './utils/shipment' import type { CreateOrderPayload, DispatchPayload, DraftQuotePayload } from './utils/shipping' // Frontera con la API Rails. Lo que entra en snake_case se traduce acá y sale @@ -114,6 +115,7 @@ interface ApiShipment { status: ShipmentStatus tracking_number: string | null shipping_cost: number | null + shipping_label_url: string | null courier: ApiCourier | null events: ApiShipmentEvent[] } @@ -249,6 +251,9 @@ function toShipment(shipment: ApiShipment): Shipment { status: shipment.status, trackingNumber: shipment.tracking_number, shippingCost: shipment.shipping_cost, + // Viene de la respuesta del courier, así que se acota a un enlace que se + // pueda abrir (ver `printableLabel`). La API lo guarda tal cual lo mandó. + labelUrl: printableLabel(shipment.shipping_label_url), courier: toCourier(shipment.courier), // El backend ya los ordena por `occurred_at` (con desempate por id): no se // reordenan acá para no tener dos definiciones del mismo orden. diff --git a/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.test.tsx b/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.test.tsx index f3f2995..e31bd2d 100644 --- a/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.test.tsx +++ b/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.test.tsx @@ -13,6 +13,7 @@ const SHIPMENT: Shipment = { trackingNumber: 'AND-9920-X8829-Z', shippingCost: 58300, courier: { id: 4, serviceId: 7, name: 'Andreani' }, + labelUrl: null, events: [ { id: 1, diff --git a/src/features/orders/components/ShippingLabelField.test.tsx b/src/features/orders/components/ShippingLabelField.test.tsx new file mode 100644 index 0000000..6ea8255 --- /dev/null +++ b/src/features/orders/components/ShippingLabelField.test.tsx @@ -0,0 +1,70 @@ +import { screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { renderWithTheme } from '../../../test/renderWithTheme' +import type { Shipment } from '../types' + +import { ShippingLabelField } from './ShippingLabelField' + +const DISPATCHED: Shipment = { + id: 31, + orderId: 8829, + status: 'in_transit', + trackingNumber: 'AND-9920-X8829-Z', + shippingCost: 58300, + courier: { id: 4, serviceId: 7, name: 'Andreani' }, + labelUrl: 'https://andreani.test/etiquetas/AND-9920.pdf', + events: [], +} + +const PENDING: Shipment = { + ...DISPATCHED, + status: 'pending', + trackingNumber: null, + shippingCost: null, + courier: null, + labelUrl: null, +} + +const printLink = () => screen.queryByRole('link', { name: 'Imprimir etiqueta' }) + +describe('ShippingLabelField', () => { + it('links to the label the courier issued', () => { + renderWithTheme() + + expect(printLink()).toHaveAttribute('href', 'https://andreani.test/etiquetas/AND-9920.pdf') + }) + + // El destino es el sitio del courier, no una ruta de la app: se abre aparte y + // sin entregarle la referencia a esta ventana. + it('opens it in another tab without handing over this window', () => { + renderWithTheme() + + expect(printLink()).toHaveAttribute('target', '_blank') + expect(printLink()).toHaveAttribute('rel', 'noopener noreferrer') + }) + + // La emite el courier al confirmar el despacho: antes de eso no existe, y un + // campo vacío se leería como un dato perdido. + it('says it is pending while the shipment was not dispatched', () => { + renderWithTheme() + + expect(screen.getByText('Se emite al despachar.')).toBeInTheDocument() + expect(printLink()).not.toBeInTheDocument() + }) + + it('says the same while there is no shipment at all', () => { + renderWithTheme() + + expect(screen.getByText('Se emite al despachar.')).toBeInTheDocument() + }) + + // Despachado y sin etiqueta es otra cosa: el courier no la devolvió, y + // decirlo evita que el operador la espere. + it('says the courier did not return one when the shipment already went out', () => { + renderWithTheme() + + expect(screen.getByText('El operador no devolvió la etiqueta.')).toBeInTheDocument() + expect(printLink()).not.toBeInTheDocument() + }) +}) diff --git a/src/features/orders/components/ShippingLabelField.tsx b/src/features/orders/components/ShippingLabelField.tsx new file mode 100644 index 0000000..85c3e0e --- /dev/null +++ b/src/features/orders/components/ShippingLabelField.tsx @@ -0,0 +1,59 @@ +import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined' +import { Box, Button, Typography } from '@mui/material' + +import { ordersCopy } from '../content' +import type { Shipment } from '../types' + +const labelCopy = ordersCopy.detail.shipping.label + +interface ShippingLabelFieldProps { + /** El envío resuelto, o null mientras no hay uno que mostrar. */ + shipment: Shipment | null +} + +/** + * Etiqueta de envío del panel de S08 (RF-23). + * + * Es el rótulo que emite el courier al confirmar el despacho: el PDF con el + * código de barras que se imprime y se pega al paquete. El backend lo guarda + * desde TESIS-47 (`shipments.shipping_label_url`) y hasta esta card no se veía + * en ninguna pantalla. + * + * Tres estados y ningún hueco: sin despachar dice que está pendiente, porque la + * etiqueta la emite el courier; despachado sin etiqueta lo dice también, en vez + * de dejar el campo vacío como si se hubiera perdido; y con etiqueta, el enlace. + * + * Abre en una pestaña nueva porque el destino es el sitio del courier y no una + * ruta de la app, con `rel="noopener noreferrer"` para no entregarle la + * referencia a esta ventana. + */ +export function ShippingLabelField({ shipment }: ShippingLabelFieldProps) { + const dispatched = shipment !== null && shipment.trackingNumber !== null + + return ( + + + {labelCopy.title} + + + {shipment !== null && shipment.labelUrl !== null ? ( + + ) : ( + + {dispatched ? labelCopy.missing : labelCopy.pending} + + )} + + ) +} diff --git a/src/features/orders/content.ts b/src/features/orders/content.ts index bf22b2a..76c21fa 100644 --- a/src/features/orders/content.ts +++ b/src/features/orders/content.ts @@ -164,6 +164,15 @@ export const ordersCopy = { copied: 'Número de seguimiento copiado.', copyFailed: 'No se pudo copiar. Seleccioná el número y copialo a mano.', }, + /** El rótulo que emite el courier al despachar (RF-23). */ + label: { + title: 'Etiqueta de envío', + action: 'Imprimir etiqueta', + /** Sin despachar no hay etiqueta: la emite el courier (TESIS-47). */ + pending: 'Se emite al despachar.', + /** Despachado y sin etiqueta: el courier no la devolvió. */ + missing: 'El operador no devolvió la etiqueta.', + }, fields: { serviceType: 'Tipo de servicio', origin: 'Depósito de origen', diff --git a/src/features/orders/pages/OrderDetailPage.test.tsx b/src/features/orders/pages/OrderDetailPage.test.tsx index f520f2e..3a13a28 100644 --- a/src/features/orders/pages/OrderDetailPage.test.tsx +++ b/src/features/orders/pages/OrderDetailPage.test.tsx @@ -83,6 +83,7 @@ const SHIPMENT: Shipment = { trackingNumber: 'AND-9920-X8829-Z', shippingCost: 58300, courier: { id: 4, serviceId: 7, name: 'Andreani' }, + labelUrl: null, events: [], } @@ -257,6 +258,7 @@ describe('OrderDetailPage', () => { trackingNumber: null, shippingCost: null, courier: null, + labelUrl: null, } const dispatchButton = () => screen.queryByRole('button', { name: 'Despachar' }) diff --git a/src/features/orders/pages/OrderDetailPage.tsx b/src/features/orders/pages/OrderDetailPage.tsx index 3a3cc6c..11a6967 100644 --- a/src/features/orders/pages/OrderDetailPage.tsx +++ b/src/features/orders/pages/OrderDetailPage.tsx @@ -19,6 +19,7 @@ import { OrderMetrics } from '../components/OrderMetrics' import type { OrderMetric } from '../components/OrderMetrics' import { PaymentSummaryCard } from '../components/PaymentSummaryCard' import { ShipmentLifecycleCard } from '../components/ShipmentLifecycleCard' +import { ShippingLabelField } from '../components/ShippingLabelField' import { TrackingNumberField } from '../components/TrackingNumberField' import { formatCount, ordersCopy } from '../content' import { useCreateOrderShipment } from '../hooks/useCreateOrderShipment' @@ -294,8 +295,10 @@ export function OrderDetailPage() { > {/* Sin envío resuelto el tracking tampoco existe: el panel dice «Pendiente de despacho», que es lo mismo que ve el operador - mientras el courier no confirma. */} + mientras el courier no confirma. La etiqueta sigue el mismo + criterio: la emite el courier al despachar (RF-23). */} + { trackingNumber: 'AND-1', shippingCost: 58300, courier: { id: 4, serviceId: 7, name: 'Andreani' }, + labelUrl: null, events: [], }, }, diff --git a/src/features/orders/types.ts b/src/features/orders/types.ts index 2e3be6e..360b78f 100644 --- a/src/features/orders/types.ts +++ b/src/features/orders/types.ts @@ -140,6 +140,12 @@ export interface Shipment { shippingCost: number | null /** Null hasta que se asigna el courier al confirmar el despacho. */ courier: Courier | null + /** + * El rótulo imprimible que emite el courier al despachar (RF-23): el PDF con + * el código de barras que se pega al paquete. Null hasta el despacho, y + * también después si el courier no lo devolvió. + */ + labelUrl: string | null /** Ordenados por `occurredAt`, del más viejo al más nuevo. */ events: ShipmentEvent[] } diff --git a/src/features/orders/utils/dispatch.test.ts b/src/features/orders/utils/dispatch.test.ts index 731c0b3..666847a 100644 --- a/src/features/orders/utils/dispatch.test.ts +++ b/src/features/orders/utils/dispatch.test.ts @@ -16,6 +16,7 @@ const PENDING: Shipment = { trackingNumber: null, shippingCost: null, courier: null, + labelUrl: null, events: [], } diff --git a/src/features/orders/utils/shipment.test.ts b/src/features/orders/utils/shipment.test.ts index 9fe650b..2d9dc90 100644 --- a/src/features/orders/utils/shipment.test.ts +++ b/src/features/orders/utils/shipment.test.ts @@ -9,6 +9,7 @@ import { headerStatus, lifecycleProgress, SHIPMENT_STAGES, + printableLabel, } from './shipment' let nextEventId = 1 @@ -33,6 +34,7 @@ function shipment(status: ShipmentStatus, events: ShipmentEvent[] = []): Shipmen trackingNumber: null, shippingCost: null, courier: null, + labelUrl: null, events, } } @@ -152,3 +154,33 @@ describe('eventsNewestFirst', () => { expect(source.events.map((item) => item.internalStatus)).toEqual(['pending', 'in_transit']) }) }) + +describe('printableLabel', () => { + it('keeps the https link the courier returned', () => { + expect(printableLabel('https://andreani.test/etiquetas/AND-9920.pdf')).toBe( + 'https://andreani.test/etiquetas/AND-9920.pdf', + ) + }) + + it('keeps an http one too: some carriers still serve the pdf without tls', () => { + expect(printableLabel('http://oca.test/label/123')).toBe('http://oca.test/label/123') + }) + + it('has nothing to show without a label', () => { + expect(printableLabel(null)).toBeNull() + expect(printableLabel(' ')).toBeNull() + }) + + // La URL la devuelve el courier y la plantilla la mapea tal cual: un `href` + // con `javascript:` corre al hacer clic, en el navegador del operador. + it('refuses a link that is not a web address', () => { + expect(printableLabel('javascript:alert(document.cookie)')).toBeNull() + expect(printableLabel('data:text/html,')).toBeNull() + }) + + // Una relativa apuntaría a nuestro sitio, y el destino es el del courier. + it('refuses something that is not an absolute url', () => { + expect(printableLabel('/etiquetas/AND-9920.pdf')).toBeNull() + expect(printableLabel('no es una url')).toBeNull() + }) +}) diff --git a/src/features/orders/utils/shipment.ts b/src/features/orders/utils/shipment.ts index 603d4e7..18f4188 100644 --- a/src/features/orders/utils/shipment.ts +++ b/src/features/orders/utils/shipment.ts @@ -8,6 +8,37 @@ import { statusLabel, statusVariant } from './status' // Reglas del ciclo de vida del envío que muestra el detalle de la orden. Viven // acá y no en los componentes para poder probarlas sin montar la pantalla. +/** + * Los esquemas que puede tener el enlace de la etiqueta para que la pantalla lo + * abra. Es una lista blanca y no una lista negra: lo que no sea una dirección + * web no se pinta como enlace. + */ +const PRINTABLE_SCHEMES = ['https:', 'http:'] + +/** + * El enlace a la etiqueta, o null si no hay uno que se pueda abrir. + * + * La URL no la escribe el OMS: la devuelve el courier y la plantilla la mapea + * tal cual (`'etiqueta.url' => 'shipping_label_url'`). Ponerla en un `href` sin + * mirarla deja que un proveedor —o alguien que le entre la respuesta— decida + * qué ejecuta el navegador del operador: `javascript:` en un `href` corre al + * hacer clic. Acotarlo a http(s) cierra eso sin pedirle nada al backend. + * + * Un valor que no se puede abrir no se muestra roto: el campo queda como si no + * hubiera etiqueta, que es la verdad desde donde está parado el operador. + */ +export function printableLabel(url: string | null | undefined): string | null { + if (url === null || url === undefined || url.trim() === '') return null + + try { + return PRINTABLE_SCHEMES.includes(new URL(url).protocol) ? url : null + } catch { + // No es una URL absoluta: una relativa no sirve, porque el destino es el + // sitio del courier y no el nuestro. + return null + } +} + /** Las etapas del ciclo, en orden. Es el mismo vocabulario que `Shipment::STATUSES`. */ export const SHIPMENT_STAGES: readonly ShipmentStatus[] = [ 'pending',