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 ? (
+ }
+ sx={{ alignSelf: 'flex-start' }}
+ >
+ {labelCopy.action}
+
+ ) : (
+
+ {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',