@fayz-ai/plugin-shop 0.8.3 → 0.8.4

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.
@@ -1,7 +1,9 @@
1
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'
2
+ import {
3
+ adminNavigateTo, PersonLink,
4
+ OrderDetailView as SharedOrderDetailView, OrderDetailSkeleton, OrderDetailBlock, OrderDetailRow,
5
+ type OrderDetailData, type OrderDetailInstallment, type OrderDetailTone,
6
+ } from '@fayz-ai/saas'
5
7
  import { shopCustomerLookup } from './customerLookup'
6
8
  import type { Order, ShopProvider } from '@fayz-ai/shop'
7
9
  import { getSupabaseClientOptional } from '@fayz-ai/core'
@@ -19,11 +21,6 @@ type Fulfillment = {
19
21
  delivered_at: string | null
20
22
  }
21
23
 
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
24
  type Bill = {
28
25
  id: string
29
26
  amount: number
@@ -37,199 +34,84 @@ type Receivable = {
37
34
  bills: Bill[]
38
35
  }
39
36
 
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
37
  const money = (v: number, currency = 'BRL') =>
67
38
  new Intl.NumberFormat('pt-BR', { style: 'currency', currency }).format(Number(v) || 0)
68
-
69
39
  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
- }
40
+ new Intl.DateTimeFormat('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(iso))
41
+ const dateOnly = (iso: string) => new Date(iso).toLocaleDateString('pt-BR')
88
42
 
89
43
  const PAYMENT_LABEL: Record<string, string> = {
90
44
  pix: 'Pix', credit_card: 'Cartão de crédito', debit_card: 'Cartão de débito',
91
45
  boleto: 'Boleto', cash: 'Dinheiro', other: 'Outro',
92
46
  }
93
-
94
47
  const LABEL: Record<string, string> = {
95
48
  paid: 'Pago', pending: 'Aguardando pagamento', refunded: 'Estornado',
96
49
  partially_refunded: 'Estorno parcial', voided: 'Cancelado',
97
50
  fulfilled: 'Enviado', unfulfilled: 'Não enviado', partially_fulfilled: 'Envio parcial',
98
51
  open: 'Aberto', cancelled: 'Cancelado', archived: 'Arquivado',
99
52
  }
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
- )
53
+ const RECEIVABLE_LABEL: Record<string, string> = {
54
+ open: 'Em aberto', partial: 'Parcialmente recebido', paid: 'Recebido', overdue: 'Vencido', cancelled: 'Cancelado',
55
+ }
56
+ const SHIPMENT_LABEL: Record<string, string> = {
57
+ pending: 'Aguardando coleta', in_transit: 'Em trânsito', delivered: 'Entregue',
58
+ failed: 'Falhou', returned: 'Devolvido', cancelled: 'Cancelado',
107
59
  }
108
60
 
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
- )
61
+ /** Map a shop/receivable status string to the shared component's tone. */
62
+ function toneOf(value: string): OrderDetailTone {
63
+ switch (value) {
64
+ case 'paid': case 'fulfilled': case 'delivered': return 'success'
65
+ case 'pending': case 'partial': case 'partially_fulfilled': case 'open': case 'in_transit': return 'warning'
66
+ case 'refunded': case 'partially_refunded': case 'voided': case 'cancelled': case 'overdue': case 'failed': case 'returned': return 'destructive'
67
+ default: return 'muted'
68
+ }
119
69
  }
120
70
 
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
- )
71
+ /** Small inline badge for the shop-specific blocks (Entrega/Histórico). */
72
+ function InlineBadge({ value }: { value: string }) {
73
+ const tone = toneOf(value)
74
+ const cls: Record<OrderDetailTone, string> = {
75
+ success: 'bg-emerald-100 text-emerald-800', warning: 'bg-amber-100 text-amber-800',
76
+ destructive: 'bg-red-100 text-red-700', info: 'bg-sky-100 text-sky-800', muted: 'bg-muted text-muted-foreground',
77
+ }
78
+ return <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${cls[tone]}`}>{LABEL[value] ?? value}</span>
128
79
  }
129
80
 
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
81
  function deliveryFromNotes(notes: string | null): string | null {
137
82
  if (!notes) return null
138
83
  const match = notes.match(/entrega:\s*(.+)/i)
139
84
  return (match?.[1] ?? '').trim() || null
140
85
  }
141
86
 
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
- }
87
+ // Cache por id (fora do componente) — trocar de aba remonta a view; sem cache
88
+ // isso refaz o getOrder (skeleton + request) toda vez. Cache-first evita o refetch;
89
+ // "Registrar recebimento" atualiza o cache.
90
+ const shopOrderCache = new Map<string, Order>()
91
+ const shopShipmentsCache = new Map<string, Fulfillment[]>()
92
+ const shopReceivableCache = new Map<string, Receivable | null>()
206
93
 
207
- export function OrderDetailView({
208
- orderId,
209
- provider,
210
- onBack,
211
- }: {
94
+ export function OrderDetailView({ orderId, provider, onBack }: {
212
95
  orderId: string
213
96
  provider: () => ShopProvider
214
97
  onBack: () => void
215
98
  }) {
216
- const [order, setOrder] = useState<Order | null>(null)
217
- const [shipments, setShipments] = useState<Fulfillment[]>([])
218
- const [loading, setLoading] = useState(true)
99
+ const [order, setOrder] = useState<Order | null>(() => shopOrderCache.get(orderId) ?? null)
100
+ const [shipments, setShipments] = useState<Fulfillment[]>(() => shopShipmentsCache.get(orderId) ?? [])
101
+ const [loading, setLoading] = useState(() => !shopOrderCache.has(orderId))
219
102
  const [error, setError] = useState<string | null>(null)
220
103
  const [confirming, setConfirming] = useState(false)
221
104
  const [confirmError, setConfirmError] = useState<string | null>(null)
222
- const [receivable, setReceivable] = useState<Receivable | null>(null)
105
+ const [receivable, setReceivable] = useState<Receivable | null>(() => shopReceivableCache.get(orderId) ?? null)
223
106
 
224
107
  useEffect(() => {
225
108
  let cancelled = false
226
- setLoading(true)
227
- setError(null)
109
+ const cached = shopOrderCache.get(orderId)
110
+ if (cached) { setOrder(cached); setLoading(false); return () => { cancelled = true } } // cache hit → sem request
111
+ setLoading(true); setError(null)
228
112
  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
- })
113
+ .then((o) => { if (!cancelled) { if (o) shopOrderCache.set(orderId, o); setOrder(o) } })
114
+ .catch((err: unknown) => { if (!cancelled) setError(err instanceof Error ? err.message : String(err)) })
233
115
  .finally(() => { if (!cancelled) setLoading(false) })
234
116
  return () => { cancelled = true }
235
117
  }, [orderId, provider])
@@ -238,41 +120,28 @@ export function OrderDetailView({
238
120
  let cancelled = false
239
121
  const db = getSupabaseClientOptional() as unknown as { from: (t: string) => any } | null
240
122
  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
- })
123
+ if (shopShipmentsCache.has(orderId)) return () => { cancelled = true } // cache hit → sem request
124
+ db.from('fulfillments').select('*').eq('order_id', orderId).order('created_at', { ascending: false })
125
+ .then(({ data }: { data: Fulfillment[] | null }) => { if (!cancelled) { shopShipmentsCache.set(orderId, data ?? []); setShipments(data ?? []) } })
248
126
  return () => { cancelled = true }
249
127
  }, [orderId])
250
128
 
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
- */
129
+ // The order does not decide whether it was paid — the receivable does. Keep this
130
+ // hook ABOVE the early returns (hook order must be stable).
263
131
  useEffect(() => {
264
132
  let cancelled = false
265
133
  const db = getSupabaseClientOptional() as unknown as { from: (t: string) => any } | null
266
134
  if (!db) return
135
+ if (shopReceivableCache.has(orderId)) { setReceivable(shopReceivableCache.get(orderId) ?? null); return () => { cancelled = true } } // cache hit → sem request
267
136
  void (async () => {
268
137
  const [balance, bills] = await Promise.all([
269
138
  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 }),
139
+ db.from('plg_financial_movements').select('*').eq('invoice_id', orderId).eq('movement_kind', 'bill').order('installment_number', { ascending: true }),
273
140
  ])
274
141
  if (cancelled || !balance?.data) return
275
- setReceivable({ balance: balance.data, bills: bills?.data ?? [] })
142
+ const rec = { balance: balance.data, bills: bills?.data ?? [] }
143
+ shopReceivableCache.set(orderId, rec)
144
+ setReceivable(rec)
276
145
  })()
277
146
  return () => { cancelled = true }
278
147
  }, [orderId, confirming])
@@ -288,291 +157,162 @@ export function OrderDetailView({
288
157
  }
289
158
  if (!order) return <div className="py-12 text-center text-sm text-muted-foreground">Pedido não encontrado.</div>
290
159
 
160
+ const cur = order.currency
291
161
  const delivery = deliveryFromNotes(order.notes)
292
162
  const addr = order.shippingAddress
293
163
 
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
164
  async function registerReceipt() {
303
165
  if (!order) return
304
- setConfirming(true)
305
- setConfirmError(null)
166
+ shopReceivableCache.delete(order.id) // pagamento muda o recebível → força refetch
167
+ setConfirming(true); setConfirmError(null)
306
168
  try {
307
- const updated = await provider().confirmPayment?.(order.id)
308
- if (updated) setOrder(updated)
309
- else setOrder(await provider().getOrder(order.id))
169
+ const fresh = (await provider().confirmPayment?.(order.id)) ?? (await provider().getOrder(order.id))
170
+ if (fresh) { shopOrderCache.set(order.id, fresh); setOrder(fresh) } // atualiza o cache
310
171
  } catch (err: unknown) {
311
172
  setConfirmError(err instanceof Error ? err.message : String(err))
312
- } finally {
313
- setConfirming(false)
314
- }
173
+ } finally { setConfirming(false) }
315
174
  }
316
175
 
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>
176
+ const installments: OrderDetailInstallment[] = (receivable && receivable.bills.length > 1)
177
+ ? receivable.bills.map((b) => ({
178
+ number: b.installment_number ?? 0,
179
+ amount: `${money(Number(b.paid_amount ?? 0), cur)} / ${money(Number(b.amount), cur)}`,
180
+ status: RECEIVABLE_LABEL[b.status] ?? b.status,
181
+ statusTone: toneOf(b.status),
182
+ dueDate: `vence ${dateOnly(b.due_date)}`,
183
+ }))
184
+ : []
185
+
186
+ const data: OrderDetailData = {
187
+ title: `Pedido #${order.orderNumber}`,
188
+ subtitle: dateTime(order.createdAt),
189
+ badges: [
190
+ { label: LABEL[order.financialStatus] ?? order.financialStatus, tone: toneOf(order.financialStatus) },
191
+ { label: LABEL[order.fulfillmentStatus] ?? order.fulfillmentStatus, tone: toneOf(order.fulfillmentStatus) },
192
+ ],
193
+ customer: { name: order.customerName, email: order.customerEmail },
194
+ customerNode: (
195
+ <div className="space-y-1 text-sm">
196
+ {order.customerId ? (
197
+ <PersonLink
198
+ personId={order.customerId}
199
+ name={order.customerName ?? order.customerEmail ?? 'Cliente'}
200
+ lookup={shopCustomerLookup(provider)}
201
+ profileHref={`#/customers/${order.customerId}`}
202
+ className="font-medium"
203
+ />
204
+ ) : (
205
+ <p className="font-medium">{order.customerName ?? '—'}</p>
206
+ )}
207
+ {order.customerEmail && <p className="text-muted-foreground">{order.customerEmail}</p>}
208
+ {!order.customerId && <p className="text-xs text-muted-foreground">Compra sem cadastro — não há ficha de cliente para abrir.</p>}
328
209
  </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.
210
+ ),
211
+ items: order.items.map((it) => ({
212
+ id: it.id, name: it.name, description: it.sku, imageUrl: it.imageUrl,
213
+ quantity: it.quantity, unitPrice: money(it.unitPrice, cur), total: money(it.total, cur),
214
+ })),
215
+ totals: [
216
+ { label: 'Subtotal', value: money(order.subtotal, cur) },
217
+ ...(order.discountTotal > 0 ? [{ label: `Desconto${order.discountCode ? ` (${order.discountCode})` : ''}`, value: `− ${money(order.discountTotal, cur)}`, muted: true }] : []),
218
+ { label: 'Entrega', value: money(order.shippingTotal, cur) },
219
+ { label: 'Impostos', value: money(order.taxTotal, cur) },
220
+ { label: 'Total', value: money(order.total, cur), strong: true },
221
+ ],
222
+ paymentMethod: order.paymentMethodKind ? (PAYMENT_LABEL[order.paymentMethodKind] ?? order.paymentMethodKind) : undefined,
223
+ paidAt: order.paidAt ? dateTime(order.paidAt) : undefined,
224
+ receivable: receivable ? {
225
+ referenceNumber: receivable.balance.reference_number ?? undefined,
226
+ statusBadge: { label: RECEIVABLE_LABEL[receivable.balance.status] ?? receivable.balance.status, tone: toneOf(receivable.balance.status) },
227
+ rows: [
228
+ { label: 'Faturado', value: money(receivable.balance.amount, cur) },
229
+ { label: 'Recebido', value: money(receivable.balance.paid, cur) },
230
+ { label: 'Saldo', value: money(receivable.balance.balance, cur), strong: true },
231
+ ],
232
+ installments,
233
+ actions: (
234
+ <>
235
+ {receivable.balance.balance > 0 && receivable.balance.status !== 'cancelled' && (
236
+ <button type="button" onClick={registerReceipt} disabled={confirming} data-testid="order-register-receipt"
237
+ className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition hover:opacity-90 disabled:opacity-60">
238
+ {confirming ? 'Registrando…' : 'Registrar recebimento'}
239
+ </button>
240
+ )}
241
+ <button type="button" onClick={() => adminNavigateTo('/financial')}
242
+ className="rounded-md border px-3 py-1.5 text-sm font-medium transition hover:bg-muted">
243
+ Abrir no Financeiro
244
+ </button>
245
+ {confirmError && <p className="mt-2 w-full rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-800">{confirmError}</p>}
246
+ </>
247
+ ),
248
+ emptyLabel: 'Sem conta a receber para este pedido.',
249
+ } : { rows: [], installments: [], emptyLabel: 'Sem conta a receber para este pedido.' },
250
+ extraSections: (
251
+ <>
252
+ <OrderDetailBlock title="Entrega" hint="O que a logística precisa">
253
+ <div className="space-y-2 text-sm">
254
+ {addr ? (
255
+ <div className="space-y-0.5">
256
+ <p>{[addr.street, addr.number].filter(Boolean).join(', ')}{addr.complement ? ` — ${addr.complement}` : ''}</p>
257
+ {addr.district && <p className="text-muted-foreground">{addr.district}</p>}
258
+ <p className="text-muted-foreground">
259
+ {[addr.city, addr.state].filter((v) => v && v !== '—').join('/')}{addr.postal_code ? ` · ${addr.postal_code}` : ''}
380
260
  </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
- )}
261
+ {(addr.recipient || addr.phone) && <p className="text-xs text-muted-foreground">{[addr.recipient, addr.phone].filter(Boolean).join(' · ')}</p>}
462
262
  </div>
263
+ ) : (
264
+ <p className={delivery ? '' : 'text-muted-foreground'}>{delivery ?? 'Nenhum endereço registrado neste pedido.'}</p>
463
265
  )}
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
- />
266
+ <div className="border-t pt-2">
267
+ <OrderDetailRow label="Status" value={<InlineBadge value={order.fulfillmentStatus} />} />
268
+ {shipments.length === 0 ? (
269
+ <p className="mt-1 text-xs text-muted-foreground">Nenhum envio registrado.</p>
480
270
  ) : (
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
- )}
271
+ <div className="mt-2 space-y-2">
272
+ {shipments.map((s) => (
273
+ <div key={s.id} className="rounded-md border px-2 py-1.5">
274
+ <div className="flex items-center justify-between gap-2">
275
+ <span className="text-sm font-medium">{[s.carrier, s.service].filter(Boolean).join(' · ') || 'Envio'}</span>
276
+ <span className="text-[10px] text-muted-foreground">{SHIPMENT_LABEL[s.status] ?? s.status}</span>
277
+ </div>
278
+ {s.tracking_code && (
279
+ <p className="mt-0.5 font-mono text-xs">
280
+ {s.tracking_url ? <a href={s.tracking_url} target="_blank" rel="noreferrer" className="text-primary hover:underline">{s.tracking_code}</a> : s.tracking_code}
281
+ </p>
282
+ )}
283
+ <div className="mt-1 space-y-0.5 text-[11px] text-muted-foreground">
284
+ {s.shipped_at && <p>Despachado em {dateTime(s.shipped_at)}</p>}
285
+ {s.estimated_delivery_at && <p>Previsão: {dateTime(s.estimated_delivery_at)}</p>}
286
+ {s.delivered_at && <p>Entregue em {dateTime(s.delivered_at)}</p>}
287
+ </div>
288
+ </div>
289
+ ))}
507
290
  </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
291
  )}
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
292
  </div>
565
- </Block>
293
+ </div>
294
+ </OrderDetailBlock>
295
+
296
+ <OrderDetailBlock title="Histórico">
297
+ <div className="space-y-1 text-sm">
298
+ <OrderDetailRow label="Criado" value={dateTime(order.createdAt)} />
299
+ <OrderDetailRow label="Atualizado" value={dateTime(order.updatedAt)} />
300
+ <OrderDetailRow label="Situação" value={<InlineBadge value={order.status} />} />
301
+ </div>
302
+ </OrderDetailBlock>
303
+
304
+ {order.notes && (
305
+ <OrderDetailBlock title="Observações">
306
+ <p className="whitespace-pre-wrap text-sm text-muted-foreground">{order.notes}</p>
307
+ </OrderDetailBlock>
308
+ )}
309
+ </>
310
+ ),
311
+ onBack,
312
+ backLabel: 'Pedidos',
313
+ }
566
314
 
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
- )
315
+ return <SharedOrderDetailView {...data} />
576
316
  }
577
317
 
578
318
  export const orderDetailPath = (id: string) => `/shop/orders/${id}`