@fayz-ai/plugin-shop 0.8.0 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/ShopPage-ALBPZAEW.cjs +745 -0
  2. package/dist/ShopPage-ALBPZAEW.cjs.map +1 -0
  3. package/dist/ShopPage-TVNY74FF.js +743 -0
  4. package/dist/ShopPage-TVNY74FF.js.map +1 -0
  5. package/dist/ShopPage.d.ts +23 -1
  6. package/dist/ShopPage.d.ts.map +1 -1
  7. package/dist/data/customerDataProvider.d.ts +5 -0
  8. package/dist/data/customerDataProvider.d.ts.map +1 -0
  9. package/dist/data/discountDataProvider.d.ts +5 -0
  10. package/dist/data/discountDataProvider.d.ts.map +1 -0
  11. package/dist/data/productDataProvider.d.ts +6 -0
  12. package/dist/data/productDataProvider.d.ts.map +1 -0
  13. package/dist/data/shared.d.ts +12 -0
  14. package/dist/data/shared.d.ts.map +1 -0
  15. package/dist/data/shippingZoneDataProvider.d.ts +13 -0
  16. package/dist/data/shippingZoneDataProvider.d.ts.map +1 -0
  17. package/dist/index.cjs +983 -26
  18. package/dist/index.cjs.map +1 -1
  19. package/dist/index.d.ts +2 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +984 -27
  22. package/dist/index.js.map +1 -1
  23. package/dist/views/OrderDetailView.d.ts +10 -0
  24. package/dist/views/OrderDetailView.d.ts.map +1 -0
  25. package/dist/views/ProductImagesTab.d.ts +19 -0
  26. package/dist/views/ProductImagesTab.d.ts.map +1 -0
  27. package/dist/views/customerEntity.d.ts +4 -0
  28. package/dist/views/customerEntity.d.ts.map +1 -0
  29. package/dist/views/customerLookup.d.ts +22 -0
  30. package/dist/views/customerLookup.d.ts.map +1 -0
  31. package/dist/views/discountEntity.d.ts +4 -0
  32. package/dist/views/discountEntity.d.ts.map +1 -0
  33. package/dist/views/productEntity.d.ts +6 -0
  34. package/dist/views/productEntity.d.ts.map +1 -0
  35. package/dist/views/shippingZoneEntity.d.ts +4 -0
  36. package/dist/views/shippingZoneEntity.d.ts.map +1 -0
  37. package/package.json +5 -5
  38. package/src/ShopPage.tsx +156 -54
  39. package/src/data/customerDataProvider.ts +31 -0
  40. package/src/data/discountDataProvider.ts +42 -0
  41. package/src/data/productDataProvider.ts +54 -0
  42. package/src/data/shared.ts +42 -0
  43. package/src/data/shippingZoneDataProvider.ts +49 -0
  44. package/src/index.ts +101 -12
  45. package/src/views/OrderDetailView.tsx +579 -0
  46. package/src/views/ProductImagesTab.tsx +183 -0
  47. package/src/views/customerEntity.tsx +110 -0
  48. package/src/views/customerLookup.ts +31 -0
  49. package/src/views/discountEntity.tsx +137 -0
  50. package/src/views/productEntity.tsx +229 -0
  51. package/src/views/shippingZoneEntity.tsx +123 -0
  52. package/dist/ShopPage-65PPVKBD.js +0 -271
  53. package/dist/ShopPage-65PPVKBD.js.map +0 -1
  54. package/dist/ShopPage-PG5GKOZU.cjs +0 -273
  55. package/dist/ShopPage-PG5GKOZU.cjs.map +0 -1
@@ -0,0 +1,579 @@
1
+ import React, { useEffect, useState } from 'react'
2
+ import { Card, CardContent, Skeleton } from '@fayz-ai/ui'
3
+ import { adminNavigateTo } from '@fayz-ai/saas'
4
+ import { PersonLink } from '@fayz-ai/saas'
5
+ import { shopCustomerLookup } from './customerLookup'
6
+ import type { Order, ShopProvider } from '@fayz-ai/shop'
7
+ import { getSupabaseClientOptional } from '@fayz-ai/core'
8
+
9
+ /** A shipment as stored in public.fulfillments (0018). */
10
+ type Fulfillment = {
11
+ id: string
12
+ status: string
13
+ carrier: string | null
14
+ service: string | null
15
+ tracking_code: string | null
16
+ tracking_url: string | null
17
+ shipped_at: string | null
18
+ estimated_delivery_at: string | null
19
+ delivered_at: string | null
20
+ }
21
+
22
+ /**
23
+ * The receivable this order raised, as the financial plugin models it: bills are
24
+ * obligations (plg_financial_movements, movement_kind='bill') and
25
+ * v_invoice_balances derives what is billed, paid and still owed.
26
+ */
27
+ type Bill = {
28
+ id: string
29
+ amount: number
30
+ paid_amount: number | null
31
+ status: string
32
+ due_date: string
33
+ installment_number: number | null
34
+ }
35
+ type Receivable = {
36
+ balance: { amount: number; paid: number; balance: number; status: string; reference_number: string | null }
37
+ bills: Bill[]
38
+ }
39
+
40
+ const RECEIVABLE_LABEL: Record<string, string> = {
41
+ open: 'Em aberto', partial: 'Parcialmente recebido', paid: 'Recebido',
42
+ overdue: 'Vencido', cancelled: 'Cancelado',
43
+ }
44
+ const RECEIVABLE_TONE: Record<string, string> = {
45
+ open: 'bg-blue-100 text-blue-800', partial: 'bg-yellow-100 text-yellow-800',
46
+ paid: 'bg-emerald-100 text-emerald-800', overdue: 'bg-red-100 text-red-700',
47
+ cancelled: 'bg-gray-100 text-gray-700',
48
+ }
49
+
50
+ const SHIPMENT_LABEL: Record<string, string> = {
51
+ pending: 'Aguardando coleta', in_transit: 'Em trânsito', delivered: 'Entregue',
52
+ failed: 'Falhou', returned: 'Devolvido', cancelled: 'Cancelado',
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Order detail — modelled on what a merchant actually needs to act on an order:
57
+ // what was bought, whether the money arrived, and where it has to go. The three
58
+ // blocks mirror the three downstream systems (catalogue, financial, logistics)
59
+ // so the page doubles as the integration surface.
60
+ //
61
+ // Fields the schema cannot supply yet are shown as explicit gaps rather than
62
+ // omitted, because "no tracking code" and "tracking code not modelled" are very
63
+ // different problems for whoever picks this up.
64
+ // ---------------------------------------------------------------------------
65
+
66
+ const money = (v: number, currency = 'BRL') =>
67
+ new Intl.NumberFormat('pt-BR', { style: 'currency', currency }).format(Number(v) || 0)
68
+
69
+ const dateTime = (iso: string) =>
70
+ new Intl.DateTimeFormat('pt-BR', {
71
+ day: '2-digit', month: '2-digit', year: 'numeric',
72
+ hour: '2-digit', minute: '2-digit',
73
+ }).format(new Date(iso))
74
+
75
+ const TONE: Record<string, string> = {
76
+ paid: 'bg-emerald-100 text-emerald-800',
77
+ pending: 'bg-yellow-100 text-yellow-800',
78
+ refunded: 'bg-orange-100 text-orange-700',
79
+ partially_refunded: 'bg-orange-100 text-orange-700',
80
+ voided: 'bg-gray-100 text-gray-700',
81
+ fulfilled: 'bg-emerald-100 text-emerald-800',
82
+ unfulfilled: 'bg-gray-100 text-gray-700',
83
+ partially_fulfilled: 'bg-blue-100 text-blue-800',
84
+ open: 'bg-blue-100 text-blue-800',
85
+ cancelled: 'bg-red-100 text-red-700',
86
+ archived: 'bg-gray-100 text-gray-700',
87
+ }
88
+
89
+ const PAYMENT_LABEL: Record<string, string> = {
90
+ pix: 'Pix', credit_card: 'Cartão de crédito', debit_card: 'Cartão de débito',
91
+ boleto: 'Boleto', cash: 'Dinheiro', other: 'Outro',
92
+ }
93
+
94
+ const LABEL: Record<string, string> = {
95
+ paid: 'Pago', pending: 'Aguardando pagamento', refunded: 'Estornado',
96
+ partially_refunded: 'Estorno parcial', voided: 'Cancelado',
97
+ fulfilled: 'Enviado', unfulfilled: 'Não enviado', partially_fulfilled: 'Envio parcial',
98
+ open: 'Aberto', cancelled: 'Cancelado', archived: 'Arquivado',
99
+ }
100
+
101
+ function Badge({ value }: { value: string }) {
102
+ return (
103
+ <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${TONE[value] ?? TONE.open}`}>
104
+ {LABEL[value] ?? value}
105
+ </span>
106
+ )
107
+ }
108
+
109
+ function Block({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
110
+ return (
111
+ <Card>
112
+ <CardContent className="pt-4">
113
+ <h3 className="text-sm font-semibold">{title}</h3>
114
+ {hint && <p className="mt-0.5 text-xs text-muted-foreground">{hint}</p>}
115
+ <div className="mt-3">{children}</div>
116
+ </CardContent>
117
+ </Card>
118
+ )
119
+ }
120
+
121
+ function Row({ label, value, strong }: { label: string; value: React.ReactNode; strong?: boolean }) {
122
+ return (
123
+ <div className={`flex items-baseline justify-between gap-4 py-1 ${strong ? 'font-semibold' : ''}`}>
124
+ <span className={strong ? '' : 'text-muted-foreground'}>{label}</span>
125
+ <span className="tabular-nums">{value}</span>
126
+ </div>
127
+ )
128
+ }
129
+
130
+ /**
131
+ * The buyer's address currently travels as free text in `notes`, prefixed
132
+ * "Entrega:" by the storefront checkout (workflows/checkout.ts). There is no
133
+ * structured address column on plg_shop_orders, so logistics has to read this
134
+ * string. Parsed here so the merchant at least sees it as an address.
135
+ */
136
+ function deliveryFromNotes(notes: string | null): string | null {
137
+ if (!notes) return null
138
+ const match = notes.match(/entrega:\s*(.+)/i)
139
+ return (match?.[1] ?? '').trim() || null
140
+ }
141
+
142
+ /**
143
+ * Mirrors the loaded layout (header, two-column grid, same block order) so the
144
+ * page doesn't reflow when the order arrives.
145
+ */
146
+ function BlockSkeleton({ rows }: { rows: number }) {
147
+ return (
148
+ <Card>
149
+ <CardContent className="pt-4">
150
+ <Skeleton className="h-4 w-28" />
151
+ <div className="mt-4 space-y-2.5">
152
+ {Array.from({ length: rows }).map((_, i) => (
153
+ <Skeleton key={i} className={`h-3 ${i % 3 === 2 ? 'w-1/2' : i % 3 === 1 ? 'w-2/3' : 'w-full'}`} />
154
+ ))}
155
+ </div>
156
+ </CardContent>
157
+ </Card>
158
+ )
159
+ }
160
+
161
+ function OrderDetailSkeleton() {
162
+ return (
163
+ <div className="space-y-4" aria-busy="true" aria-live="polite">
164
+ <Skeleton className="h-4 w-20" />
165
+
166
+ <div className="flex flex-wrap items-center gap-3">
167
+ <Skeleton className="h-8 w-48" />
168
+ <Skeleton className="h-5 w-20 rounded-full" />
169
+ <Skeleton className="h-5 w-24 rounded-full" />
170
+ <Skeleton className="h-4 w-32" />
171
+ </div>
172
+
173
+ <div className="grid gap-4 lg:grid-cols-3">
174
+ <div className="space-y-4 lg:col-span-2">
175
+ <Card>
176
+ <CardContent className="pt-4">
177
+ <Skeleton className="h-4 w-16" />
178
+ <Skeleton className="mt-1.5 h-3 w-24" />
179
+ <div className="mt-3 divide-y">
180
+ {Array.from({ length: 3 }).map((_, i) => (
181
+ <div key={i} className="flex items-center gap-3 py-2">
182
+ <Skeleton className="h-10 w-10 shrink-0 rounded" />
183
+ <div className="min-w-0 flex-1 space-y-1.5">
184
+ <Skeleton className="h-3.5 w-2/5" />
185
+ <Skeleton className="h-2.5 w-20" />
186
+ </div>
187
+ <Skeleton className="h-3 w-24" />
188
+ <Skeleton className="h-3 w-16" />
189
+ </div>
190
+ ))}
191
+ </div>
192
+ </CardContent>
193
+ </Card>
194
+ <BlockSkeleton rows={6} />
195
+ </div>
196
+
197
+ <div className="space-y-4">
198
+ <BlockSkeleton rows={2} />
199
+ <BlockSkeleton rows={4} />
200
+ <BlockSkeleton rows={3} />
201
+ </div>
202
+ </div>
203
+ </div>
204
+ )
205
+ }
206
+
207
+ export function OrderDetailView({
208
+ orderId,
209
+ provider,
210
+ onBack,
211
+ }: {
212
+ orderId: string
213
+ provider: () => ShopProvider
214
+ onBack: () => void
215
+ }) {
216
+ const [order, setOrder] = useState<Order | null>(null)
217
+ const [shipments, setShipments] = useState<Fulfillment[]>([])
218
+ const [loading, setLoading] = useState(true)
219
+ const [error, setError] = useState<string | null>(null)
220
+ const [confirming, setConfirming] = useState(false)
221
+ const [confirmError, setConfirmError] = useState<string | null>(null)
222
+ const [receivable, setReceivable] = useState<Receivable | null>(null)
223
+
224
+ useEffect(() => {
225
+ let cancelled = false
226
+ setLoading(true)
227
+ setError(null)
228
+ provider().getOrder(orderId)
229
+ .then((o) => { if (!cancelled) setOrder(o) })
230
+ .catch((err: unknown) => {
231
+ if (!cancelled) setError(err instanceof Error ? err.message : String(err))
232
+ })
233
+ .finally(() => { if (!cancelled) setLoading(false) })
234
+ return () => { cancelled = true }
235
+ }, [orderId, provider])
236
+
237
+ useEffect(() => {
238
+ let cancelled = false
239
+ const db = getSupabaseClientOptional() as unknown as { from: (t: string) => any } | null
240
+ if (!db) return
241
+ db.from('fulfillments')
242
+ .select('*')
243
+ .eq('order_id', orderId)
244
+ .order('created_at', { ascending: false })
245
+ .then(({ data }: { data: Fulfillment[] | null }) => {
246
+ if (!cancelled) setShipments(data ?? [])
247
+ })
248
+ return () => { cancelled = true }
249
+ }, [orderId])
250
+
251
+ /**
252
+ * Load the receivable this order raised.
253
+ *
254
+ * The order does not decide whether it was paid — the receivable does. It is
255
+ * created by the financial plugin's own fn_invoice_from_order the moment the
256
+ * order is placed, and plg_shop_orders.financial_status is derived from it, so
257
+ * this panel is the money and the badge above is a consequence of it.
258
+ *
259
+ * NOTE: this hook must stay ABOVE the early returns below (loading/error/!order)
260
+ * — a hook placed after a conditional return changes the hook order between
261
+ * renders (React "change in the order of Hooks" error).
262
+ */
263
+ useEffect(() => {
264
+ let cancelled = false
265
+ const db = getSupabaseClientOptional() as unknown as { from: (t: string) => any } | null
266
+ if (!db) return
267
+ void (async () => {
268
+ const [balance, bills] = await Promise.all([
269
+ db.from('v_invoice_balances').select('*').eq('invoice_id', orderId).maybeSingle(),
270
+ db.from('plg_financial_movements').select('*')
271
+ .eq('invoice_id', orderId).eq('movement_kind', 'bill')
272
+ .order('installment_number', { ascending: true }),
273
+ ])
274
+ if (cancelled || !balance?.data) return
275
+ setReceivable({ balance: balance.data, bills: bills?.data ?? [] })
276
+ })()
277
+ return () => { cancelled = true }
278
+ }, [orderId, confirming])
279
+
280
+ if (loading) return <OrderDetailSkeleton />
281
+ if (error) {
282
+ return (
283
+ <div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
284
+ <p className="font-medium">Não foi possível carregar o pedido.</p>
285
+ <p className="mt-1 text-red-700">{error}</p>
286
+ </div>
287
+ )
288
+ }
289
+ if (!order) return <div className="py-12 text-center text-sm text-muted-foreground">Pedido não encontrado.</div>
290
+
291
+ const delivery = deliveryFromNotes(order.notes)
292
+ const addr = order.shippingAddress
293
+
294
+ /**
295
+ * Register the receipt. This used to be "Marcar como pago" — a button that
296
+ * flipped an enum, which is an assertion about money with no counterpart
297
+ * anywhere: no receivable, no due date, no instalment, no bank, no fee.
298
+ *
299
+ * It now writes `payment` movements against the open instalments, which is
300
+ * what the Financeiro reads, and the order's status follows from that.
301
+ */
302
+ async function registerReceipt() {
303
+ if (!order) return
304
+ setConfirming(true)
305
+ setConfirmError(null)
306
+ try {
307
+ const updated = await provider().confirmPayment?.(order.id)
308
+ if (updated) setOrder(updated)
309
+ else setOrder(await provider().getOrder(order.id))
310
+ } catch (err: unknown) {
311
+ setConfirmError(err instanceof Error ? err.message : String(err))
312
+ } finally {
313
+ setConfirming(false)
314
+ }
315
+ }
316
+
317
+ return (
318
+ <div className="space-y-4">
319
+ <button onClick={onBack} className="text-sm text-muted-foreground hover:text-foreground">
320
+ ← Pedidos
321
+ </button>
322
+
323
+ <div className="flex flex-wrap items-center gap-3">
324
+ <h1 className="text-2xl font-semibold">Pedido #{order.orderNumber}</h1>
325
+ <Badge value={order.financialStatus} />
326
+ <Badge value={order.fulfillmentStatus} />
327
+ <span className="text-sm text-muted-foreground">{dateTime(order.createdAt)}</span>
328
+ </div>
329
+ {confirmError && (
330
+ <p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800">
331
+ Não foi possível confirmar o pagamento: {confirmError}
332
+ </p>
333
+ )}
334
+
335
+ <div className="grid gap-4 lg:grid-cols-3">
336
+ <div className="space-y-4 lg:col-span-2">
337
+ <Block title="Itens" hint={`${order.items.length} item(ns)`}>
338
+ <div className="divide-y">
339
+ {order.items.map((item) => (
340
+ <div key={item.id} className="flex items-center gap-3 py-2">
341
+ {item.imageUrl
342
+ ? <img src={item.imageUrl} alt="" className="h-10 w-10 shrink-0 rounded object-cover" />
343
+ : <div className="h-10 w-10 shrink-0 rounded bg-muted" />}
344
+ <div className="min-w-0 flex-1">
345
+ <p className="truncate text-sm font-medium">{item.name}</p>
346
+ {item.sku && <p className="text-xs text-muted-foreground">{item.sku}</p>}
347
+ </div>
348
+ <span className="text-sm text-muted-foreground">
349
+ {item.quantity} × {money(item.unitPrice, order.currency)}
350
+ </span>
351
+ <span className="w-24 text-right text-sm font-medium tabular-nums">
352
+ {money(item.total, order.currency)}
353
+ </span>
354
+ </div>
355
+ ))}
356
+ </div>
357
+ </Block>
358
+
359
+ <Block title="Pagamento" hint="O que o financeiro precisa conciliar">
360
+ <div className="text-sm">
361
+ <Row label="Subtotal" value={money(order.subtotal, order.currency)} />
362
+ {order.discountTotal > 0 && (
363
+ <Row
364
+ label={`Desconto${order.discountCode ? ` (${order.discountCode})` : ''}`}
365
+ value={`− ${money(order.discountTotal, order.currency)}`}
366
+ />
367
+ )}
368
+ <Row label="Entrega" value={money(order.shippingTotal, order.currency)} />
369
+ <Row label="Impostos" value={money(order.taxTotal, order.currency)} />
370
+ <div className="mt-2 border-t pt-2">
371
+ <Row label="Total" value={money(order.total, order.currency)} strong />
372
+ </div>
373
+ <div className="mt-3 border-t pt-2">
374
+ <Row label="Forma de pagamento" value={PAYMENT_LABEL[order.paymentMethodKind ?? ''] ?? '—'} />
375
+ {order.paidAt && <Row label="Pago em" value={dateTime(order.paidAt)} />}
376
+ </div>
377
+ {!order.paymentMethodKind && (
378
+ <p className="mt-2 text-xs text-muted-foreground">
379
+ Este pedido é anterior ao registro de forma de pagamento.
380
+ </p>
381
+ )}
382
+ </div>
383
+ </Block>
384
+
385
+ {/*
386
+ The receivable, which is where the money actually lives. This block
387
+ replaced a "Marcar como pago" button: that flipped an enum and left
388
+ no receivable, no due date, no instalment and nothing for the
389
+ Financeiro to reconcile. financial_status above is now DERIVED from
390
+ what is shown here, so the two cannot disagree.
391
+ */}
392
+ <Block
393
+ title="Contas a receber"
394
+ hint="Criado automaticamente quando o pedido foi feito — o mesmo registro que o Financeiro lê"
395
+ >
396
+ {!receivable ? (
397
+ <p className="text-sm text-muted-foreground">
398
+ Sem conta a receber para este pedido.
399
+ </p>
400
+ ) : (
401
+ <div className="text-sm">
402
+ <div className="mb-3 flex flex-wrap items-center gap-2">
403
+ <span className="font-mono font-semibold">
404
+ {receivable.balance.reference_number ?? '—'}
405
+ </span>
406
+ <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
407
+ RECEIVABLE_TONE[receivable.balance.status] ?? RECEIVABLE_TONE.open}`}>
408
+ {RECEIVABLE_LABEL[receivable.balance.status] ?? receivable.balance.status}
409
+ </span>
410
+ </div>
411
+
412
+ <Row label="Faturado" value={money(receivable.balance.amount, order.currency)} />
413
+ <Row label="Recebido" value={money(receivable.balance.paid, order.currency)} />
414
+ <div className="mt-2 border-t pt-2">
415
+ <Row label="Saldo" value={money(receivable.balance.balance, order.currency)} strong />
416
+ </div>
417
+
418
+ {receivable.bills.length > 1 && (
419
+ <ul className="mt-3 space-y-1 border-t pt-2 text-xs">
420
+ {receivable.bills.map((bill) => (
421
+ <li key={bill.id} className="flex items-baseline justify-between gap-3">
422
+ <span className="text-muted-foreground">
423
+ Parcela {bill.installment_number} · vence {new Date(bill.due_date).toLocaleDateString('pt-BR')}
424
+ </span>
425
+ <span className="tabular-nums">
426
+ {money(Number(bill.paid_amount ?? 0), order.currency)} / {money(Number(bill.amount), order.currency)}
427
+ </span>
428
+ </li>
429
+ ))}
430
+ </ul>
431
+ )}
432
+
433
+ <div className="mt-4 flex flex-wrap gap-2">
434
+ {receivable.balance.balance > 0 && receivable.balance.status !== 'cancelled' && (
435
+ <button
436
+ type="button"
437
+ onClick={registerReceipt}
438
+ disabled={confirming}
439
+ data-testid="order-register-receipt"
440
+ className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition hover:opacity-90 disabled:opacity-60"
441
+ >
442
+ {confirming ? 'Registrando…' : 'Registrar recebimento'}
443
+ </button>
444
+ )}
445
+ {/* The full flow — banco, maquininha, taxa, caixa — is the
446
+ Financeiro's own screen. Duplicating it here would be a
447
+ second answer to the same question. */}
448
+ <button
449
+ type="button"
450
+ onClick={() => adminNavigateTo('/financial')}
451
+ className="rounded-md border px-3 py-1.5 text-sm font-medium transition hover:bg-muted"
452
+ >
453
+ Abrir no Financeiro
454
+ </button>
455
+ </div>
456
+
457
+ {confirmError && (
458
+ <p className="mt-2 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-800">
459
+ {confirmError}
460
+ </p>
461
+ )}
462
+ </div>
463
+ )}
464
+ </Block>
465
+ </div>
466
+
467
+ <div className="space-y-4">
468
+ <Block title="Cliente">
469
+ <div className="space-y-1 text-sm">
470
+ {/* Links to the customer record so the operator can see history,
471
+ contact details and other orders without leaving the flow. */}
472
+ {order.customerId ? (
473
+ <PersonLink
474
+ personId={order.customerId}
475
+ name={order.customerName ?? order.customerEmail ?? 'Cliente'}
476
+ lookup={shopCustomerLookup(provider)}
477
+ profileHref={`#/shop/customers/${order.customerId}`}
478
+ className="font-medium"
479
+ />
480
+ ) : (
481
+ <p className="font-medium">{order.customerName ?? '—'}</p>
482
+ )}
483
+ {order.customerEmail && <p className="text-muted-foreground">{order.customerEmail}</p>}
484
+ {!order.customerId && (
485
+ <p className="text-xs text-muted-foreground">
486
+ Compra sem cadastro — não há ficha de cliente para abrir.
487
+ </p>
488
+ )}
489
+ </div>
490
+ </Block>
491
+
492
+ <Block title="Entrega" hint="O que a logística precisa">
493
+ <div className="space-y-2 text-sm">
494
+ {addr ? (
495
+ <div className="space-y-0.5">
496
+ <p>{[addr.street, addr.number].filter(Boolean).join(', ')}{addr.complement ? ` — ${addr.complement}` : ''}</p>
497
+ {addr.district && <p className="text-muted-foreground">{addr.district}</p>}
498
+ <p className="text-muted-foreground">
499
+ {[addr.city, addr.state].filter((v) => v && v !== '—').join('/')}
500
+ {addr.postal_code ? ` · ${addr.postal_code}` : ''}
501
+ </p>
502
+ {(addr.recipient || addr.phone) && (
503
+ <p className="text-xs text-muted-foreground">
504
+ {[addr.recipient, addr.phone].filter(Boolean).join(' · ')}
505
+ </p>
506
+ )}
507
+ </div>
508
+ ) : (
509
+ <>
510
+ <p className={delivery ? '' : 'text-muted-foreground'}>
511
+ {delivery ?? 'Nenhum endereço registrado neste pedido.'}
512
+ </p>
513
+ <p className="text-xs text-muted-foreground">
514
+ Pedido anterior ao endereço estruturado — só existe o texto livre das
515
+ observações, sem CEP, número ou bairro separáveis.
516
+ </p>
517
+ </>
518
+ )}
519
+ <div className="border-t pt-2">
520
+ <Row label="Status" value={<Badge value={order.fulfillmentStatus} />} />
521
+
522
+ {shipments.length === 0 ? (
523
+ <p className="mt-1 text-xs text-muted-foreground">
524
+ Nenhum envio registrado. Ao despachar, informe transportadora e código
525
+ de rastreio — o status do pedido é calculado a partir dos envios.
526
+ </p>
527
+ ) : (
528
+ <div className="mt-2 space-y-2">
529
+ {shipments.map((s) => (
530
+ <div key={s.id} className="rounded-md border px-2 py-1.5">
531
+ <div className="flex items-center justify-between gap-2">
532
+ <span className="text-sm font-medium">
533
+ {[s.carrier, s.service].filter(Boolean).join(' · ') || 'Envio'}
534
+ </span>
535
+ <span className="text-[10px] text-muted-foreground">
536
+ {SHIPMENT_LABEL[s.status] ?? s.status}
537
+ </span>
538
+ </div>
539
+ {s.tracking_code && (
540
+ <p className="mt-0.5 font-mono text-xs">
541
+ {s.tracking_url
542
+ ? <a href={s.tracking_url} target="_blank" rel="noreferrer" className="text-primary hover:underline">{s.tracking_code}</a>
543
+ : s.tracking_code}
544
+ </p>
545
+ )}
546
+ <div className="mt-1 space-y-0.5 text-[11px] text-muted-foreground">
547
+ {s.shipped_at && <p>Despachado em {dateTime(s.shipped_at)}</p>}
548
+ {s.estimated_delivery_at && <p>Previsão: {dateTime(s.estimated_delivery_at)}</p>}
549
+ {s.delivered_at && <p>Entregue em {dateTime(s.delivered_at)}</p>}
550
+ </div>
551
+ </div>
552
+ ))}
553
+ </div>
554
+ )}
555
+ </div>
556
+ </div>
557
+ </Block>
558
+
559
+ <Block title="Histórico">
560
+ <div className="space-y-1 text-sm">
561
+ <Row label="Criado" value={dateTime(order.createdAt)} />
562
+ <Row label="Atualizado" value={dateTime(order.updatedAt)} />
563
+ <Row label="Situação" value={<Badge value={order.status} />} />
564
+ </div>
565
+ </Block>
566
+
567
+ {order.notes && (
568
+ <Block title="Observações">
569
+ <p className="whitespace-pre-wrap text-sm text-muted-foreground">{order.notes}</p>
570
+ </Block>
571
+ )}
572
+ </div>
573
+ </div>
574
+ </div>
575
+ )
576
+ }
577
+
578
+ export const orderDetailPath = (id: string) => `/shop/orders/${id}`
579
+ export const goToOrder = (id: string) => adminNavigateTo(orderDetailPath(id))