@fayz-ai/plugin-shop 0.8.1 → 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.
- package/dist/{ShopPage-HCLXMN7Q.js → ShopPage-JP27MZS2.js} +232 -285
- package/dist/ShopPage-JP27MZS2.js.map +1 -0
- package/dist/{ShopPage-JF2SQBKO.cjs → ShopPage-NK6FSPJ5.cjs} +230 -283
- package/dist/ShopPage-NK6FSPJ5.cjs.map +1 -0
- package/dist/index.cjs +253 -25
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +248 -20
- package/dist/index.js.map +1 -1
- package/dist/views/ChannelsPage.d.ts +7 -0
- package/dist/views/ChannelsPage.d.ts.map +1 -0
- package/dist/views/OrderDetailView.d.ts +1 -1
- package/dist/views/OrderDetailView.d.ts.map +1 -1
- package/dist/views/discountEntity.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/ShopPage.tsx +1 -1
- package/src/index.ts +47 -2
- package/src/views/ChannelsPage.tsx +274 -0
- package/src/views/OrderDetailView.tsx +205 -461
- package/src/views/discountEntity.tsx +7 -0
- package/dist/ShopPage-HCLXMN7Q.js.map +0 -1
- package/dist/ShopPage-JF2SQBKO.cjs.map +0 -1
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import React, { useEffect, useState } from 'react'
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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" />
|
|
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>()
|
|
165
93
|
|
|
166
|
-
|
|
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
|
-
}: {
|
|
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(
|
|
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
|
-
|
|
227
|
-
|
|
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,16 +120,32 @@ 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
|
-
|
|
242
|
-
|
|
243
|
-
.
|
|
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
|
|
|
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).
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
let cancelled = false
|
|
133
|
+
const db = getSupabaseClientOptional() as unknown as { from: (t: string) => any } | null
|
|
134
|
+
if (!db) return
|
|
135
|
+
if (shopReceivableCache.has(orderId)) { setReceivable(shopReceivableCache.get(orderId) ?? null); return () => { cancelled = true } } // cache hit → sem request
|
|
136
|
+
void (async () => {
|
|
137
|
+
const [balance, bills] = await Promise.all([
|
|
138
|
+
db.from('v_invoice_balances').select('*').eq('invoice_id', orderId).maybeSingle(),
|
|
139
|
+
db.from('plg_financial_movements').select('*').eq('invoice_id', orderId).eq('movement_kind', 'bill').order('installment_number', { ascending: true }),
|
|
140
|
+
])
|
|
141
|
+
if (cancelled || !balance?.data) return
|
|
142
|
+
const rec = { balance: balance.data, bills: bills?.data ?? [] }
|
|
143
|
+
shopReceivableCache.set(orderId, rec)
|
|
144
|
+
setReceivable(rec)
|
|
145
|
+
})()
|
|
146
|
+
return () => { cancelled = true }
|
|
147
|
+
}, [orderId, confirming])
|
|
148
|
+
|
|
251
149
|
if (loading) return <OrderDetailSkeleton />
|
|
252
150
|
if (error) {
|
|
253
151
|
return (
|
|
@@ -259,316 +157,162 @@ export function OrderDetailView({
|
|
|
259
157
|
}
|
|
260
158
|
if (!order) return <div className="py-12 text-center text-sm text-muted-foreground">Pedido não encontrado.</div>
|
|
261
159
|
|
|
160
|
+
const cur = order.currency
|
|
262
161
|
const delivery = deliveryFromNotes(order.notes)
|
|
263
162
|
const addr = order.shippingAddress
|
|
264
163
|
|
|
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
164
|
async function registerReceipt() {
|
|
299
165
|
if (!order) return
|
|
300
|
-
|
|
301
|
-
setConfirmError(null)
|
|
166
|
+
shopReceivableCache.delete(order.id) // pagamento muda o recebível → força refetch
|
|
167
|
+
setConfirming(true); setConfirmError(null)
|
|
302
168
|
try {
|
|
303
|
-
const
|
|
304
|
-
if (
|
|
305
|
-
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
|
|
306
171
|
} catch (err: unknown) {
|
|
307
172
|
setConfirmError(err instanceof Error ? err.message : String(err))
|
|
308
|
-
} finally {
|
|
309
|
-
setConfirming(false)
|
|
310
|
-
}
|
|
173
|
+
} finally { setConfirming(false) }
|
|
311
174
|
}
|
|
312
175
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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>}
|
|
324
209
|
</div>
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
{
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
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}` : ''}
|
|
376
260
|
</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
|
-
)}
|
|
261
|
+
{(addr.recipient || addr.phone) && <p className="text-xs text-muted-foreground">{[addr.recipient, addr.phone].filter(Boolean).join(' · ')}</p>}
|
|
458
262
|
</div>
|
|
263
|
+
) : (
|
|
264
|
+
<p className={delivery ? '' : 'text-muted-foreground'}>{delivery ?? 'Nenhum endereço registrado neste pedido.'}</p>
|
|
459
265
|
)}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
/>
|
|
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>
|
|
476
270
|
) : (
|
|
477
|
-
<
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
)}
|
|
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
|
+
))}
|
|
503
290
|
</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
291
|
)}
|
|
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
292
|
</div>
|
|
561
|
-
</
|
|
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
|
+
}
|
|
562
314
|
|
|
563
|
-
|
|
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
|
-
)
|
|
315
|
+
return <SharedOrderDetailView {...data} />
|
|
572
316
|
}
|
|
573
317
|
|
|
574
318
|
export const orderDetailPath = (id: string) => `/shop/orders/${id}`
|