@fayz-ai/plugin-shop 0.8.0 → 0.8.1
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.
- package/dist/ShopPage-HCLXMN7Q.js +743 -0
- package/dist/ShopPage-HCLXMN7Q.js.map +1 -0
- package/dist/ShopPage-JF2SQBKO.cjs +745 -0
- package/dist/ShopPage-JF2SQBKO.cjs.map +1 -0
- package/dist/ShopPage.d.ts +23 -1
- package/dist/ShopPage.d.ts.map +1 -1
- package/dist/data/customerDataProvider.d.ts +5 -0
- package/dist/data/customerDataProvider.d.ts.map +1 -0
- package/dist/data/discountDataProvider.d.ts +5 -0
- package/dist/data/discountDataProvider.d.ts.map +1 -0
- package/dist/data/productDataProvider.d.ts +6 -0
- package/dist/data/productDataProvider.d.ts.map +1 -0
- package/dist/data/shared.d.ts +12 -0
- package/dist/data/shared.d.ts.map +1 -0
- package/dist/data/shippingZoneDataProvider.d.ts +13 -0
- package/dist/data/shippingZoneDataProvider.d.ts.map +1 -0
- package/dist/index.cjs +983 -26
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +984 -27
- package/dist/index.js.map +1 -1
- package/dist/views/OrderDetailView.d.ts +10 -0
- package/dist/views/OrderDetailView.d.ts.map +1 -0
- package/dist/views/ProductImagesTab.d.ts +19 -0
- package/dist/views/ProductImagesTab.d.ts.map +1 -0
- package/dist/views/customerEntity.d.ts +4 -0
- package/dist/views/customerEntity.d.ts.map +1 -0
- package/dist/views/customerLookup.d.ts +22 -0
- package/dist/views/customerLookup.d.ts.map +1 -0
- package/dist/views/discountEntity.d.ts +4 -0
- package/dist/views/discountEntity.d.ts.map +1 -0
- package/dist/views/productEntity.d.ts +6 -0
- package/dist/views/productEntity.d.ts.map +1 -0
- package/dist/views/shippingZoneEntity.d.ts +4 -0
- package/dist/views/shippingZoneEntity.d.ts.map +1 -0
- package/package.json +5 -5
- package/src/ShopPage.tsx +156 -54
- package/src/data/customerDataProvider.ts +31 -0
- package/src/data/discountDataProvider.ts +42 -0
- package/src/data/productDataProvider.ts +54 -0
- package/src/data/shared.ts +42 -0
- package/src/data/shippingZoneDataProvider.ts +49 -0
- package/src/index.ts +101 -12
- package/src/views/OrderDetailView.tsx +575 -0
- package/src/views/ProductImagesTab.tsx +183 -0
- package/src/views/customerEntity.tsx +110 -0
- package/src/views/customerLookup.ts +31 -0
- package/src/views/discountEntity.tsx +137 -0
- package/src/views/productEntity.tsx +229 -0
- package/src/views/shippingZoneEntity.tsx +123 -0
- package/dist/ShopPage-65PPVKBD.js +0 -271
- package/dist/ShopPage-65PPVKBD.js.map +0 -1
- package/dist/ShopPage-PG5GKOZU.cjs +0 -273
- package/dist/ShopPage-PG5GKOZU.cjs.map +0 -1
|
@@ -0,0 +1,575 @@
|
|
|
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
|
+
if (loading) return <OrderDetailSkeleton />
|
|
252
|
+
if (error) {
|
|
253
|
+
return (
|
|
254
|
+
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
|
255
|
+
<p className="font-medium">Não foi possível carregar o pedido.</p>
|
|
256
|
+
<p className="mt-1 text-red-700">{error}</p>
|
|
257
|
+
</div>
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
if (!order) return <div className="py-12 text-center text-sm text-muted-foreground">Pedido não encontrado.</div>
|
|
261
|
+
|
|
262
|
+
const delivery = deliveryFromNotes(order.notes)
|
|
263
|
+
const addr = order.shippingAddress
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Load the receivable this order raised.
|
|
267
|
+
*
|
|
268
|
+
* The order does not decide whether it was paid — the receivable does. It is
|
|
269
|
+
* created by the financial plugin's own fn_invoice_from_order the moment the
|
|
270
|
+
* order is placed, and plg_shop_orders.financial_status is derived from it, so
|
|
271
|
+
* this panel is the money and the badge above is a consequence of it.
|
|
272
|
+
*/
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
let cancelled = false
|
|
275
|
+
const db = getSupabaseClientOptional() as unknown as { from: (t: string) => any } | null
|
|
276
|
+
if (!db) return
|
|
277
|
+
void (async () => {
|
|
278
|
+
const [balance, bills] = await Promise.all([
|
|
279
|
+
db.from('v_invoice_balances').select('*').eq('invoice_id', orderId).maybeSingle(),
|
|
280
|
+
db.from('plg_financial_movements').select('*')
|
|
281
|
+
.eq('invoice_id', orderId).eq('movement_kind', 'bill')
|
|
282
|
+
.order('installment_number', { ascending: true }),
|
|
283
|
+
])
|
|
284
|
+
if (cancelled || !balance?.data) return
|
|
285
|
+
setReceivable({ balance: balance.data, bills: bills?.data ?? [] })
|
|
286
|
+
})()
|
|
287
|
+
return () => { cancelled = true }
|
|
288
|
+
}, [orderId, confirming])
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Register the receipt. This used to be "Marcar como pago" — a button that
|
|
292
|
+
* flipped an enum, which is an assertion about money with no counterpart
|
|
293
|
+
* anywhere: no receivable, no due date, no instalment, no bank, no fee.
|
|
294
|
+
*
|
|
295
|
+
* It now writes `payment` movements against the open instalments, which is
|
|
296
|
+
* what the Financeiro reads, and the order's status follows from that.
|
|
297
|
+
*/
|
|
298
|
+
async function registerReceipt() {
|
|
299
|
+
if (!order) return
|
|
300
|
+
setConfirming(true)
|
|
301
|
+
setConfirmError(null)
|
|
302
|
+
try {
|
|
303
|
+
const updated = await provider().confirmPayment?.(order.id)
|
|
304
|
+
if (updated) setOrder(updated)
|
|
305
|
+
else setOrder(await provider().getOrder(order.id))
|
|
306
|
+
} catch (err: unknown) {
|
|
307
|
+
setConfirmError(err instanceof Error ? err.message : String(err))
|
|
308
|
+
} finally {
|
|
309
|
+
setConfirming(false)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return (
|
|
314
|
+
<div className="space-y-4">
|
|
315
|
+
<button onClick={onBack} className="text-sm text-muted-foreground hover:text-foreground">
|
|
316
|
+
← Pedidos
|
|
317
|
+
</button>
|
|
318
|
+
|
|
319
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
320
|
+
<h1 className="text-2xl font-semibold">Pedido #{order.orderNumber}</h1>
|
|
321
|
+
<Badge value={order.financialStatus} />
|
|
322
|
+
<Badge value={order.fulfillmentStatus} />
|
|
323
|
+
<span className="text-sm text-muted-foreground">{dateTime(order.createdAt)}</span>
|
|
324
|
+
</div>
|
|
325
|
+
{confirmError && (
|
|
326
|
+
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800">
|
|
327
|
+
Não foi possível confirmar o pagamento: {confirmError}
|
|
328
|
+
</p>
|
|
329
|
+
)}
|
|
330
|
+
|
|
331
|
+
<div className="grid gap-4 lg:grid-cols-3">
|
|
332
|
+
<div className="space-y-4 lg:col-span-2">
|
|
333
|
+
<Block title="Itens" hint={`${order.items.length} item(ns)`}>
|
|
334
|
+
<div className="divide-y">
|
|
335
|
+
{order.items.map((item) => (
|
|
336
|
+
<div key={item.id} className="flex items-center gap-3 py-2">
|
|
337
|
+
{item.imageUrl
|
|
338
|
+
? <img src={item.imageUrl} alt="" className="h-10 w-10 shrink-0 rounded object-cover" />
|
|
339
|
+
: <div className="h-10 w-10 shrink-0 rounded bg-muted" />}
|
|
340
|
+
<div className="min-w-0 flex-1">
|
|
341
|
+
<p className="truncate text-sm font-medium">{item.name}</p>
|
|
342
|
+
{item.sku && <p className="text-xs text-muted-foreground">{item.sku}</p>}
|
|
343
|
+
</div>
|
|
344
|
+
<span className="text-sm text-muted-foreground">
|
|
345
|
+
{item.quantity} × {money(item.unitPrice, order.currency)}
|
|
346
|
+
</span>
|
|
347
|
+
<span className="w-24 text-right text-sm font-medium tabular-nums">
|
|
348
|
+
{money(item.total, order.currency)}
|
|
349
|
+
</span>
|
|
350
|
+
</div>
|
|
351
|
+
))}
|
|
352
|
+
</div>
|
|
353
|
+
</Block>
|
|
354
|
+
|
|
355
|
+
<Block title="Pagamento" hint="O que o financeiro precisa conciliar">
|
|
356
|
+
<div className="text-sm">
|
|
357
|
+
<Row label="Subtotal" value={money(order.subtotal, order.currency)} />
|
|
358
|
+
{order.discountTotal > 0 && (
|
|
359
|
+
<Row
|
|
360
|
+
label={`Desconto${order.discountCode ? ` (${order.discountCode})` : ''}`}
|
|
361
|
+
value={`− ${money(order.discountTotal, order.currency)}`}
|
|
362
|
+
/>
|
|
363
|
+
)}
|
|
364
|
+
<Row label="Entrega" value={money(order.shippingTotal, order.currency)} />
|
|
365
|
+
<Row label="Impostos" value={money(order.taxTotal, order.currency)} />
|
|
366
|
+
<div className="mt-2 border-t pt-2">
|
|
367
|
+
<Row label="Total" value={money(order.total, order.currency)} strong />
|
|
368
|
+
</div>
|
|
369
|
+
<div className="mt-3 border-t pt-2">
|
|
370
|
+
<Row label="Forma de pagamento" value={PAYMENT_LABEL[order.paymentMethodKind ?? ''] ?? '—'} />
|
|
371
|
+
{order.paidAt && <Row label="Pago em" value={dateTime(order.paidAt)} />}
|
|
372
|
+
</div>
|
|
373
|
+
{!order.paymentMethodKind && (
|
|
374
|
+
<p className="mt-2 text-xs text-muted-foreground">
|
|
375
|
+
Este pedido é anterior ao registro de forma de pagamento.
|
|
376
|
+
</p>
|
|
377
|
+
)}
|
|
378
|
+
</div>
|
|
379
|
+
</Block>
|
|
380
|
+
|
|
381
|
+
{/*
|
|
382
|
+
The receivable, which is where the money actually lives. This block
|
|
383
|
+
replaced a "Marcar como pago" button: that flipped an enum and left
|
|
384
|
+
no receivable, no due date, no instalment and nothing for the
|
|
385
|
+
Financeiro to reconcile. financial_status above is now DERIVED from
|
|
386
|
+
what is shown here, so the two cannot disagree.
|
|
387
|
+
*/}
|
|
388
|
+
<Block
|
|
389
|
+
title="Contas a receber"
|
|
390
|
+
hint="Criado automaticamente quando o pedido foi feito — o mesmo registro que o Financeiro lê"
|
|
391
|
+
>
|
|
392
|
+
{!receivable ? (
|
|
393
|
+
<p className="text-sm text-muted-foreground">
|
|
394
|
+
Sem conta a receber para este pedido.
|
|
395
|
+
</p>
|
|
396
|
+
) : (
|
|
397
|
+
<div className="text-sm">
|
|
398
|
+
<div className="mb-3 flex flex-wrap items-center gap-2">
|
|
399
|
+
<span className="font-mono font-semibold">
|
|
400
|
+
{receivable.balance.reference_number ?? '—'}
|
|
401
|
+
</span>
|
|
402
|
+
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
|
|
403
|
+
RECEIVABLE_TONE[receivable.balance.status] ?? RECEIVABLE_TONE.open}`}>
|
|
404
|
+
{RECEIVABLE_LABEL[receivable.balance.status] ?? receivable.balance.status}
|
|
405
|
+
</span>
|
|
406
|
+
</div>
|
|
407
|
+
|
|
408
|
+
<Row label="Faturado" value={money(receivable.balance.amount, order.currency)} />
|
|
409
|
+
<Row label="Recebido" value={money(receivable.balance.paid, order.currency)} />
|
|
410
|
+
<div className="mt-2 border-t pt-2">
|
|
411
|
+
<Row label="Saldo" value={money(receivable.balance.balance, order.currency)} strong />
|
|
412
|
+
</div>
|
|
413
|
+
|
|
414
|
+
{receivable.bills.length > 1 && (
|
|
415
|
+
<ul className="mt-3 space-y-1 border-t pt-2 text-xs">
|
|
416
|
+
{receivable.bills.map((bill) => (
|
|
417
|
+
<li key={bill.id} className="flex items-baseline justify-between gap-3">
|
|
418
|
+
<span className="text-muted-foreground">
|
|
419
|
+
Parcela {bill.installment_number} · vence {new Date(bill.due_date).toLocaleDateString('pt-BR')}
|
|
420
|
+
</span>
|
|
421
|
+
<span className="tabular-nums">
|
|
422
|
+
{money(Number(bill.paid_amount ?? 0), order.currency)} / {money(Number(bill.amount), order.currency)}
|
|
423
|
+
</span>
|
|
424
|
+
</li>
|
|
425
|
+
))}
|
|
426
|
+
</ul>
|
|
427
|
+
)}
|
|
428
|
+
|
|
429
|
+
<div className="mt-4 flex flex-wrap gap-2">
|
|
430
|
+
{receivable.balance.balance > 0 && receivable.balance.status !== 'cancelled' && (
|
|
431
|
+
<button
|
|
432
|
+
type="button"
|
|
433
|
+
onClick={registerReceipt}
|
|
434
|
+
disabled={confirming}
|
|
435
|
+
data-testid="order-register-receipt"
|
|
436
|
+
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition hover:opacity-90 disabled:opacity-60"
|
|
437
|
+
>
|
|
438
|
+
{confirming ? 'Registrando…' : 'Registrar recebimento'}
|
|
439
|
+
</button>
|
|
440
|
+
)}
|
|
441
|
+
{/* The full flow — banco, maquininha, taxa, caixa — is the
|
|
442
|
+
Financeiro's own screen. Duplicating it here would be a
|
|
443
|
+
second answer to the same question. */}
|
|
444
|
+
<button
|
|
445
|
+
type="button"
|
|
446
|
+
onClick={() => adminNavigateTo('/financial')}
|
|
447
|
+
className="rounded-md border px-3 py-1.5 text-sm font-medium transition hover:bg-muted"
|
|
448
|
+
>
|
|
449
|
+
Abrir no Financeiro
|
|
450
|
+
</button>
|
|
451
|
+
</div>
|
|
452
|
+
|
|
453
|
+
{confirmError && (
|
|
454
|
+
<p className="mt-2 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-800">
|
|
455
|
+
{confirmError}
|
|
456
|
+
</p>
|
|
457
|
+
)}
|
|
458
|
+
</div>
|
|
459
|
+
)}
|
|
460
|
+
</Block>
|
|
461
|
+
</div>
|
|
462
|
+
|
|
463
|
+
<div className="space-y-4">
|
|
464
|
+
<Block title="Cliente">
|
|
465
|
+
<div className="space-y-1 text-sm">
|
|
466
|
+
{/* Links to the customer record so the operator can see history,
|
|
467
|
+
contact details and other orders without leaving the flow. */}
|
|
468
|
+
{order.customerId ? (
|
|
469
|
+
<PersonLink
|
|
470
|
+
personId={order.customerId}
|
|
471
|
+
name={order.customerName ?? order.customerEmail ?? 'Cliente'}
|
|
472
|
+
lookup={shopCustomerLookup(provider)}
|
|
473
|
+
profileHref={`#/shop/customers/${order.customerId}`}
|
|
474
|
+
className="font-medium"
|
|
475
|
+
/>
|
|
476
|
+
) : (
|
|
477
|
+
<p className="font-medium">{order.customerName ?? '—'}</p>
|
|
478
|
+
)}
|
|
479
|
+
{order.customerEmail && <p className="text-muted-foreground">{order.customerEmail}</p>}
|
|
480
|
+
{!order.customerId && (
|
|
481
|
+
<p className="text-xs text-muted-foreground">
|
|
482
|
+
Compra sem cadastro — não há ficha de cliente para abrir.
|
|
483
|
+
</p>
|
|
484
|
+
)}
|
|
485
|
+
</div>
|
|
486
|
+
</Block>
|
|
487
|
+
|
|
488
|
+
<Block title="Entrega" hint="O que a logística precisa">
|
|
489
|
+
<div className="space-y-2 text-sm">
|
|
490
|
+
{addr ? (
|
|
491
|
+
<div className="space-y-0.5">
|
|
492
|
+
<p>{[addr.street, addr.number].filter(Boolean).join(', ')}{addr.complement ? ` — ${addr.complement}` : ''}</p>
|
|
493
|
+
{addr.district && <p className="text-muted-foreground">{addr.district}</p>}
|
|
494
|
+
<p className="text-muted-foreground">
|
|
495
|
+
{[addr.city, addr.state].filter((v) => v && v !== '—').join('/')}
|
|
496
|
+
{addr.postal_code ? ` · ${addr.postal_code}` : ''}
|
|
497
|
+
</p>
|
|
498
|
+
{(addr.recipient || addr.phone) && (
|
|
499
|
+
<p className="text-xs text-muted-foreground">
|
|
500
|
+
{[addr.recipient, addr.phone].filter(Boolean).join(' · ')}
|
|
501
|
+
</p>
|
|
502
|
+
)}
|
|
503
|
+
</div>
|
|
504
|
+
) : (
|
|
505
|
+
<>
|
|
506
|
+
<p className={delivery ? '' : 'text-muted-foreground'}>
|
|
507
|
+
{delivery ?? 'Nenhum endereço registrado neste pedido.'}
|
|
508
|
+
</p>
|
|
509
|
+
<p className="text-xs text-muted-foreground">
|
|
510
|
+
Pedido anterior ao endereço estruturado — só existe o texto livre das
|
|
511
|
+
observações, sem CEP, número ou bairro separáveis.
|
|
512
|
+
</p>
|
|
513
|
+
</>
|
|
514
|
+
)}
|
|
515
|
+
<div className="border-t pt-2">
|
|
516
|
+
<Row label="Status" value={<Badge value={order.fulfillmentStatus} />} />
|
|
517
|
+
|
|
518
|
+
{shipments.length === 0 ? (
|
|
519
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
520
|
+
Nenhum envio registrado. Ao despachar, informe transportadora e código
|
|
521
|
+
de rastreio — o status do pedido é calculado a partir dos envios.
|
|
522
|
+
</p>
|
|
523
|
+
) : (
|
|
524
|
+
<div className="mt-2 space-y-2">
|
|
525
|
+
{shipments.map((s) => (
|
|
526
|
+
<div key={s.id} className="rounded-md border px-2 py-1.5">
|
|
527
|
+
<div className="flex items-center justify-between gap-2">
|
|
528
|
+
<span className="text-sm font-medium">
|
|
529
|
+
{[s.carrier, s.service].filter(Boolean).join(' · ') || 'Envio'}
|
|
530
|
+
</span>
|
|
531
|
+
<span className="text-[10px] text-muted-foreground">
|
|
532
|
+
{SHIPMENT_LABEL[s.status] ?? s.status}
|
|
533
|
+
</span>
|
|
534
|
+
</div>
|
|
535
|
+
{s.tracking_code && (
|
|
536
|
+
<p className="mt-0.5 font-mono text-xs">
|
|
537
|
+
{s.tracking_url
|
|
538
|
+
? <a href={s.tracking_url} target="_blank" rel="noreferrer" className="text-primary hover:underline">{s.tracking_code}</a>
|
|
539
|
+
: s.tracking_code}
|
|
540
|
+
</p>
|
|
541
|
+
)}
|
|
542
|
+
<div className="mt-1 space-y-0.5 text-[11px] text-muted-foreground">
|
|
543
|
+
{s.shipped_at && <p>Despachado em {dateTime(s.shipped_at)}</p>}
|
|
544
|
+
{s.estimated_delivery_at && <p>Previsão: {dateTime(s.estimated_delivery_at)}</p>}
|
|
545
|
+
{s.delivered_at && <p>Entregue em {dateTime(s.delivered_at)}</p>}
|
|
546
|
+
</div>
|
|
547
|
+
</div>
|
|
548
|
+
))}
|
|
549
|
+
</div>
|
|
550
|
+
)}
|
|
551
|
+
</div>
|
|
552
|
+
</div>
|
|
553
|
+
</Block>
|
|
554
|
+
|
|
555
|
+
<Block title="Histórico">
|
|
556
|
+
<div className="space-y-1 text-sm">
|
|
557
|
+
<Row label="Criado" value={dateTime(order.createdAt)} />
|
|
558
|
+
<Row label="Atualizado" value={dateTime(order.updatedAt)} />
|
|
559
|
+
<Row label="Situação" value={<Badge value={order.status} />} />
|
|
560
|
+
</div>
|
|
561
|
+
</Block>
|
|
562
|
+
|
|
563
|
+
{order.notes && (
|
|
564
|
+
<Block title="Observações">
|
|
565
|
+
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{order.notes}</p>
|
|
566
|
+
</Block>
|
|
567
|
+
)}
|
|
568
|
+
</div>
|
|
569
|
+
</div>
|
|
570
|
+
</div>
|
|
571
|
+
)
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
export const orderDetailPath = (id: string) => `/shop/orders/${id}`
|
|
575
|
+
export const goToOrder = (id: string) => adminNavigateTo(orderDetailPath(id))
|