@spree/seller-dashboard 1.0.0-beta.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/LICENSE +21 -0
- package/README.md +31 -0
- package/dist/vite-plugin/index.js +51 -0
- package/package.json +64 -0
- package/src/api-client.ts +203 -0
- package/src/components/centered-message.tsx +8 -0
- package/src/components/finance/balance-summary.tsx +69 -0
- package/src/components/onboarding-nav-badge.tsx +38 -0
- package/src/components/orders/fulfillment-deliveries.tsx +116 -0
- package/src/components/orders/fulfillment-delivery-dialog.tsx +82 -0
- package/src/components/orders/fulfillment-edit-dialog.tsx +95 -0
- package/src/components/orders/fulfillment-fulfill-form.tsx +132 -0
- package/src/components/orders/fulfillment-label-upload-dialog.tsx +95 -0
- package/src/components/orders/fulfillment-split-dialog.tsx +142 -0
- package/src/components/orders/fulfillments-card.tsx +289 -0
- package/src/components/orders/line-label.ts +28 -0
- package/src/components/orders/order-cancel-dialog.tsx +74 -0
- package/src/components/orders/order-cards.tsx +297 -0
- package/src/components/orders/order-earnings-card.tsx +99 -0
- package/src/components/orders/order-notes-cards.tsx +83 -0
- package/src/components/orders/order-payment-card.tsx +94 -0
- package/src/components/orders/post-sale-cards.tsx +347 -0
- package/src/components/orders/post-sale-create-dialogs.tsx +201 -0
- package/src/components/orders/returns-card.tsx +258 -0
- package/src/components/orders/shipping-label-row.tsx +124 -0
- package/src/components/product-status-card.tsx +111 -0
- package/src/components/read-row.tsx +16 -0
- package/src/components/retryable-error.tsx +23 -0
- package/src/components/seller-address-card.tsx +123 -0
- package/src/components/seller-business-card.tsx +152 -0
- package/src/components/seller-returns-location-card.tsx +151 -0
- package/src/components/seller-switcher.tsx +124 -0
- package/src/components/seller-tax-identifiers-card.tsx +63 -0
- package/src/create-router.ts +32 -0
- package/src/hooks/use-fulfillments.ts +155 -0
- package/src/hooks/use-ledger.ts +46 -0
- package/src/hooks/use-order.ts +62 -0
- package/src/hooks/use-post-sale.ts +202 -0
- package/src/hooks/use-reasons.ts +34 -0
- package/src/hooks/use-stock-locations.ts +23 -0
- package/src/hooks/use-store-timezone.ts +26 -0
- package/src/i18n.ts +26 -0
- package/src/index.ts +27 -0
- package/src/lib/packing-slip.ts +109 -0
- package/src/locales/en.json +841 -0
- package/src/nav/default.ts +108 -0
- package/src/nav/settings.ts +114 -0
- package/src/pages/accept-invitation.tsx +127 -0
- package/src/pages/delivery-methods.tsx +647 -0
- package/src/pages/earnings.tsx +44 -0
- package/src/pages/forgot-password.tsx +100 -0
- package/src/pages/home.tsx +132 -0
- package/src/pages/login.tsx +94 -0
- package/src/pages/onboarding.tsx +539 -0
- package/src/pages/order.tsx +119 -0
- package/src/pages/orders.tsx +33 -0
- package/src/pages/payout.tsx +154 -0
- package/src/pages/payouts.tsx +32 -0
- package/src/pages/policies.tsx +292 -0
- package/src/pages/product.tsx +250 -0
- package/src/pages/products.tsx +228 -0
- package/src/pages/profile.tsx +389 -0
- package/src/pages/reset-password.tsx +105 -0
- package/src/pages/seller-picker.tsx +46 -0
- package/src/pages/team.tsx +468 -0
- package/src/panel-chrome.tsx +36 -0
- package/src/routeTree.gen.ts +590 -0
- package/src/routes/__root.tsx +15 -0
- package/src/routes/_authenticated/$sellerId/earnings/index.tsx +15 -0
- package/src/routes/_authenticated/$sellerId/index.tsx +6 -0
- package/src/routes/_authenticated/$sellerId/onboarding.tsx +6 -0
- package/src/routes/_authenticated/$sellerId/orders/$orderId.tsx +6 -0
- package/src/routes/_authenticated/$sellerId/orders/index.tsx +15 -0
- package/src/routes/_authenticated/$sellerId/payouts/$payoutId.tsx +7 -0
- package/src/routes/_authenticated/$sellerId/payouts/index.tsx +15 -0
- package/src/routes/_authenticated/$sellerId/products/$productId.tsx +6 -0
- package/src/routes/_authenticated/$sellerId/products/index.tsx +23 -0
- package/src/routes/_authenticated/$sellerId/products/new.tsx +6 -0
- package/src/routes/_authenticated/$sellerId/profile.tsx +6 -0
- package/src/routes/_authenticated/$sellerId/settings/delivery-methods.tsx +14 -0
- package/src/routes/_authenticated/$sellerId/settings/index.tsx +18 -0
- package/src/routes/_authenticated/$sellerId/settings/package-types.tsx +21 -0
- package/src/routes/_authenticated/$sellerId/settings/policies.tsx +14 -0
- package/src/routes/_authenticated/$sellerId/settings/stock-locations.tsx +22 -0
- package/src/routes/_authenticated/$sellerId/settings/team.tsx +6 -0
- package/src/routes/_authenticated/$sellerId/settings.tsx +18 -0
- package/src/routes/_authenticated/$sellerId.tsx +49 -0
- package/src/routes/_authenticated/index.tsx +48 -0
- package/src/routes/_authenticated.tsx +13 -0
- package/src/routes/accept-invitation.$invitationId.tsx +19 -0
- package/src/routes/forgot-password.tsx +15 -0
- package/src/routes/login.tsx +15 -0
- package/src/routes/reset-password.tsx +21 -0
- package/src/schemas/fulfillment.ts +31 -0
- package/src/seller-dashboard.tsx +50 -0
- package/src/styles.css +18 -0
- package/src/tables/delivery-methods.tsx +68 -0
- package/src/tables/orders.tsx +116 -0
- package/src/tables/payouts.tsx +87 -0
- package/src/tables/policies.tsx +37 -0
- package/src/tables/products.tsx +56 -0
- package/src/tables/transfers.tsx +118 -0
- package/src/vite/index.ts +87 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { currencyParts } from '@spree/dashboard-core'
|
|
2
|
+
import {
|
|
3
|
+
Field,
|
|
4
|
+
FieldLabel,
|
|
5
|
+
ReasonField,
|
|
6
|
+
Select,
|
|
7
|
+
SelectContent,
|
|
8
|
+
SelectItem,
|
|
9
|
+
SelectTrigger,
|
|
10
|
+
SelectValue,
|
|
11
|
+
CreateClaimDialog as SharedCreateClaimDialog,
|
|
12
|
+
CreateReturnDialog as SharedCreateReturnDialog,
|
|
13
|
+
} from '@spree/dashboard-ui'
|
|
14
|
+
import type { Order } from '@spree/seller-sdk'
|
|
15
|
+
import i18n from 'i18next'
|
|
16
|
+
import { useState } from 'react'
|
|
17
|
+
import { useTranslation } from 'react-i18next'
|
|
18
|
+
import { useClaimActions, useReturnActions } from '../../hooks/use-post-sale'
|
|
19
|
+
import { useReasons } from '../../hooks/use-reasons'
|
|
20
|
+
import { useStockLocations } from '../../hooks/use-stock-locations'
|
|
21
|
+
import { unitLabel } from './line-label'
|
|
22
|
+
|
|
23
|
+
/** Units that actually went out, which is all that can come back. */
|
|
24
|
+
export function fulfilledUnits(order: Order) {
|
|
25
|
+
return (order.fulfillments ?? []).flatMap((fulfillment) =>
|
|
26
|
+
(fulfillment.fulfillment_items ?? []).map((item) => ({
|
|
27
|
+
id: item.id,
|
|
28
|
+
label: unitLabel(item),
|
|
29
|
+
quantity: item.quantity,
|
|
30
|
+
})),
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Takes goods back on one of this seller's orders.
|
|
36
|
+
*
|
|
37
|
+
* The shelf list is the seller's own, so a marketplace warehouse never
|
|
38
|
+
* appears — and the server refuses one anyway. Shelves that accept returns
|
|
39
|
+
* lead, the rest stay selectable, since a one-off return has to be routable
|
|
40
|
+
* somewhere even when none is flagged.
|
|
41
|
+
*/
|
|
42
|
+
export function CreateReturnDialog({
|
|
43
|
+
order,
|
|
44
|
+
open,
|
|
45
|
+
onOpenChange,
|
|
46
|
+
}: {
|
|
47
|
+
order: Order
|
|
48
|
+
open: boolean
|
|
49
|
+
onOpenChange: (open: boolean) => void
|
|
50
|
+
}) {
|
|
51
|
+
const { t } = useTranslation()
|
|
52
|
+
const { create } = useReturnActions(order.id)
|
|
53
|
+
const [reasonId, setReasonId] = useState('')
|
|
54
|
+
const [stockLocationId, setStockLocationId] = useState('')
|
|
55
|
+
const { data: locationsData } = useStockLocations(open)
|
|
56
|
+
|
|
57
|
+
const locationOptions = (locationsData?.data ?? [])
|
|
58
|
+
.filter((location) => location.active)
|
|
59
|
+
.sort((a, b) => Number(b.returns_enabled) - Number(a.returns_enabled))
|
|
60
|
+
.map((location) => ({
|
|
61
|
+
value: location.id,
|
|
62
|
+
label: location.returns_enabled
|
|
63
|
+
? location.name
|
|
64
|
+
: t('admin.pages.orders.detail.returns.location_no_returns', { name: location.name }),
|
|
65
|
+
}))
|
|
66
|
+
|
|
67
|
+
if (!open) return null
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<SharedCreateReturnDialog
|
|
71
|
+
units={fulfilledUnits(order)}
|
|
72
|
+
reasonField={<ReturnReasonField value={reasonId} onChange={setReasonId} />}
|
|
73
|
+
extraFields={
|
|
74
|
+
<Field>
|
|
75
|
+
<FieldLabel htmlFor="return-location">
|
|
76
|
+
{t('admin.pages.orders.detail.returns.location')}
|
|
77
|
+
</FieldLabel>
|
|
78
|
+
<Select
|
|
79
|
+
items={locationOptions}
|
|
80
|
+
value={stockLocationId}
|
|
81
|
+
onValueChange={(value) => setStockLocationId((value as string) ?? '')}
|
|
82
|
+
>
|
|
83
|
+
<SelectTrigger id="return-location">
|
|
84
|
+
<SelectValue placeholder={t('admin.pages.orders.detail.returns.location_default')} />
|
|
85
|
+
</SelectTrigger>
|
|
86
|
+
<SelectContent>
|
|
87
|
+
{locationOptions.map((option) => (
|
|
88
|
+
<SelectItem key={option.value} value={option.value}>
|
|
89
|
+
{option.label}
|
|
90
|
+
</SelectItem>
|
|
91
|
+
))}
|
|
92
|
+
</SelectContent>
|
|
93
|
+
</Select>
|
|
94
|
+
</Field>
|
|
95
|
+
}
|
|
96
|
+
onClose={() => onOpenChange(false)}
|
|
97
|
+
pending={create.isPending}
|
|
98
|
+
onSubmit={({ items, memo }) => {
|
|
99
|
+
create
|
|
100
|
+
.mutateAsync({
|
|
101
|
+
items,
|
|
102
|
+
memo,
|
|
103
|
+
reason_id: reasonId || undefined,
|
|
104
|
+
stock_location_id: stockLocationId || undefined,
|
|
105
|
+
})
|
|
106
|
+
.then(() => onOpenChange(false))
|
|
107
|
+
.catch(() => undefined)
|
|
108
|
+
}}
|
|
109
|
+
/>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Reports a problem with a delivery on one of this seller's orders.
|
|
115
|
+
*
|
|
116
|
+
* The lines come from the order rather than its parcels: a claim is about
|
|
117
|
+
* what was bought, and goods that never arrived are exactly what one is for.
|
|
118
|
+
*/
|
|
119
|
+
export function CreateClaimDialog({
|
|
120
|
+
order,
|
|
121
|
+
open,
|
|
122
|
+
onOpenChange,
|
|
123
|
+
}: {
|
|
124
|
+
order: Order
|
|
125
|
+
open: boolean
|
|
126
|
+
onOpenChange: (open: boolean) => void
|
|
127
|
+
}) {
|
|
128
|
+
const { create } = useClaimActions(order.id)
|
|
129
|
+
const [reasonId, setReasonId] = useState('')
|
|
130
|
+
const { symbol: currencySymbol } = currencyParts(order.currency, i18n.language)
|
|
131
|
+
|
|
132
|
+
if (!open) return null
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<SharedCreateClaimDialog
|
|
136
|
+
lines={(order.items ?? []).map((item) => ({
|
|
137
|
+
id: item.id,
|
|
138
|
+
label: unitLabel(item),
|
|
139
|
+
quantity: item.quantity,
|
|
140
|
+
price: item.price,
|
|
141
|
+
}))}
|
|
142
|
+
currencySymbol={currencySymbol}
|
|
143
|
+
reasonField={<ClaimReasonField value={reasonId} onChange={setReasonId} />}
|
|
144
|
+
onClose={() => onOpenChange(false)}
|
|
145
|
+
pending={create.isPending}
|
|
146
|
+
onSubmit={({ items, memo }) => {
|
|
147
|
+
create
|
|
148
|
+
.mutateAsync({ items, memo, reason_id: reasonId || undefined })
|
|
149
|
+
.then(() => onOpenChange(false))
|
|
150
|
+
.catch(() => undefined)
|
|
151
|
+
}}
|
|
152
|
+
/>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The seller picks from the marketplace's vocabulary — the operator decides
|
|
158
|
+
* what the reasons are, so this reads the store's list rather than one of the
|
|
159
|
+
* seller's own.
|
|
160
|
+
*/
|
|
161
|
+
function ReturnReasonField({
|
|
162
|
+
value,
|
|
163
|
+
onChange,
|
|
164
|
+
}: {
|
|
165
|
+
value: string
|
|
166
|
+
onChange: (value: string) => void
|
|
167
|
+
}) {
|
|
168
|
+
const { t } = useTranslation()
|
|
169
|
+
const { data } = useReasons('return-reasons')
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<ReasonField
|
|
173
|
+
id="return"
|
|
174
|
+
reasons={(data?.data ?? []).filter((reason) => reason.active)}
|
|
175
|
+
value={value}
|
|
176
|
+
onChange={onChange}
|
|
177
|
+
label={t('orders.post_sale.reason')}
|
|
178
|
+
/>
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function ClaimReasonField({
|
|
183
|
+
value,
|
|
184
|
+
onChange,
|
|
185
|
+
}: {
|
|
186
|
+
value: string
|
|
187
|
+
onChange: (value: string) => void
|
|
188
|
+
}) {
|
|
189
|
+
const { t } = useTranslation()
|
|
190
|
+
const { data } = useReasons('claim-reasons')
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<ReasonField
|
|
194
|
+
id="claim"
|
|
195
|
+
reasons={(data?.data ?? []).filter((reason) => reason.active)}
|
|
196
|
+
value={value}
|
|
197
|
+
onChange={onChange}
|
|
198
|
+
label={t('orders.post_sale.reason')}
|
|
199
|
+
/>
|
|
200
|
+
)
|
|
201
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { currencyParts } from '@spree/dashboard-core'
|
|
2
|
+
import {
|
|
3
|
+
Badge,
|
|
4
|
+
Button,
|
|
5
|
+
Card,
|
|
6
|
+
CardAction,
|
|
7
|
+
CardContent,
|
|
8
|
+
CardHeader,
|
|
9
|
+
CardTitle,
|
|
10
|
+
DropdownMenu,
|
|
11
|
+
DropdownMenuContent,
|
|
12
|
+
DropdownMenuItem,
|
|
13
|
+
DropdownMenuSeparator,
|
|
14
|
+
DropdownMenuTrigger,
|
|
15
|
+
ReturnReceiveDialog,
|
|
16
|
+
ReturnRefundDialog,
|
|
17
|
+
StatusBadge,
|
|
18
|
+
useConfirm,
|
|
19
|
+
} from '@spree/dashboard-ui'
|
|
20
|
+
import { EllipsisVerticalIcon, PlusIcon, RotateCcwIcon } from '@spree/dashboard-ui/icons'
|
|
21
|
+
import type { Order, Return, ReturnLineItem } from '@spree/seller-sdk'
|
|
22
|
+
import i18n from 'i18next'
|
|
23
|
+
import { useState } from 'react'
|
|
24
|
+
import { useTranslation } from 'react-i18next'
|
|
25
|
+
import { useOrderReturns, useReturnActions } from '../../hooks/use-post-sale'
|
|
26
|
+
import { lineLabel } from './line-label'
|
|
27
|
+
import { CreateReturnDialog, fulfilledUnits } from './post-sale-create-dialogs'
|
|
28
|
+
|
|
29
|
+
// Refunded and canceled returns are finished; offering a menu on them would
|
|
30
|
+
// open onto nothing.
|
|
31
|
+
const ACTIONABLE = ['requested', 'approved', 'received']
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Goods coming back on this order.
|
|
35
|
+
*
|
|
36
|
+
* Each status change is its own action rather than an editable field, because
|
|
37
|
+
* each carries different arguments — receiving records what actually arrived,
|
|
38
|
+
* refunding decides where the money goes.
|
|
39
|
+
*/
|
|
40
|
+
export function ReturnsCard({ order }: { order: Order }) {
|
|
41
|
+
const { t } = useTranslation()
|
|
42
|
+
const confirm = useConfirm()
|
|
43
|
+
const { data } = useOrderReturns(order.id)
|
|
44
|
+
const { approve, cancel } = useReturnActions(order.id)
|
|
45
|
+
|
|
46
|
+
const [createOpen, setCreateOpen] = useState(false)
|
|
47
|
+
const [receiving, setReceiving] = useState<Return | null>(null)
|
|
48
|
+
const [refunding, setRefunding] = useState<Return | null>(null)
|
|
49
|
+
|
|
50
|
+
const returns = data?.data ?? []
|
|
51
|
+
const canCreate = fulfilledUnits(order).length > 0
|
|
52
|
+
|
|
53
|
+
async function handleCancel(returnRecord: Return) {
|
|
54
|
+
const ok = await confirm({
|
|
55
|
+
title: t('orders.post_sale.returns.cancel_title'),
|
|
56
|
+
message: t('orders.post_sale.returns.cancel_message'),
|
|
57
|
+
variant: 'destructive',
|
|
58
|
+
confirmLabel: t('orders.post_sale.cancel'),
|
|
59
|
+
})
|
|
60
|
+
if (!ok) return
|
|
61
|
+
cancel.mutate(returnRecord.id)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<Card variant="container">
|
|
66
|
+
<CardHeader className="items-center">
|
|
67
|
+
<CardTitle>
|
|
68
|
+
<RotateCcwIcon className="size-4" />
|
|
69
|
+
{t('orders.post_sale.returns.title')}
|
|
70
|
+
</CardTitle>
|
|
71
|
+
{canCreate && (
|
|
72
|
+
<CardAction>
|
|
73
|
+
<Button variant="outline" size="sm" onClick={() => setCreateOpen(true)}>
|
|
74
|
+
<PlusIcon className="size-4" />
|
|
75
|
+
{t('orders.post_sale.returns.create')}
|
|
76
|
+
</Button>
|
|
77
|
+
</CardAction>
|
|
78
|
+
)}
|
|
79
|
+
</CardHeader>
|
|
80
|
+
|
|
81
|
+
<CardContent className="flex flex-col gap-4">
|
|
82
|
+
{returns.length === 0 ? (
|
|
83
|
+
<p className="text-muted-foreground text-sm">{t('orders.post_sale.returns.empty')}</p>
|
|
84
|
+
) : (
|
|
85
|
+
returns.map((returnRecord) => (
|
|
86
|
+
<Card key={returnRecord.id} variant="nested">
|
|
87
|
+
<CardHeader>
|
|
88
|
+
<CardTitle className="text-sm font-medium">{returnRecord.number}</CardTitle>
|
|
89
|
+
<CardAction className="flex items-center gap-2">
|
|
90
|
+
<StatusBadge
|
|
91
|
+
status={returnRecord.status}
|
|
92
|
+
label={t(`orders.post_sale.statuses.${returnRecord.status}`, {
|
|
93
|
+
defaultValue: returnRecord.status,
|
|
94
|
+
})}
|
|
95
|
+
/>
|
|
96
|
+
{ACTIONABLE.includes(returnRecord.status) && (
|
|
97
|
+
<DropdownMenu>
|
|
98
|
+
<DropdownMenuTrigger asChild>
|
|
99
|
+
<Button variant="ghost" size="icon-xs">
|
|
100
|
+
<EllipsisVerticalIcon className="size-4" />
|
|
101
|
+
<span className="sr-only">{t('admin.actions.actions_menu')}</span>
|
|
102
|
+
</Button>
|
|
103
|
+
</DropdownMenuTrigger>
|
|
104
|
+
<DropdownMenuContent align="end">
|
|
105
|
+
{returnRecord.status === 'requested' && (
|
|
106
|
+
<DropdownMenuItem onClick={() => approve.mutate(returnRecord.id)}>
|
|
107
|
+
{t('orders.post_sale.approve')}
|
|
108
|
+
</DropdownMenuItem>
|
|
109
|
+
)}
|
|
110
|
+
{returnRecord.status === 'approved' && (
|
|
111
|
+
<DropdownMenuItem onClick={() => setReceiving(returnRecord)}>
|
|
112
|
+
{t('orders.post_sale.returns.receive')}
|
|
113
|
+
</DropdownMenuItem>
|
|
114
|
+
)}
|
|
115
|
+
{returnRecord.status === 'received' && (
|
|
116
|
+
<DropdownMenuItem onClick={() => setRefunding(returnRecord)}>
|
|
117
|
+
{t('orders.post_sale.returns.refund')}
|
|
118
|
+
</DropdownMenuItem>
|
|
119
|
+
)}
|
|
120
|
+
<DropdownMenuSeparator />
|
|
121
|
+
<DropdownMenuItem
|
|
122
|
+
variant="destructive"
|
|
123
|
+
onClick={() => handleCancel(returnRecord)}
|
|
124
|
+
>
|
|
125
|
+
{t('orders.post_sale.cancel')}
|
|
126
|
+
</DropdownMenuItem>
|
|
127
|
+
</DropdownMenuContent>
|
|
128
|
+
</DropdownMenu>
|
|
129
|
+
)}
|
|
130
|
+
</CardAction>
|
|
131
|
+
</CardHeader>
|
|
132
|
+
|
|
133
|
+
<CardContent className="flex flex-col gap-1.5">
|
|
134
|
+
{returnRecord.return_line_items?.map((line) => (
|
|
135
|
+
<ReturnLineRow key={line.id} line={line} />
|
|
136
|
+
))}
|
|
137
|
+
<p className="text-muted-foreground text-sm">
|
|
138
|
+
{t('orders.post_sale.returns.refund_total', {
|
|
139
|
+
amount: returnRecord.display_refund_total,
|
|
140
|
+
})}
|
|
141
|
+
</p>
|
|
142
|
+
</CardContent>
|
|
143
|
+
</Card>
|
|
144
|
+
))
|
|
145
|
+
)}
|
|
146
|
+
</CardContent>
|
|
147
|
+
|
|
148
|
+
<CreateReturnDialog order={order} open={createOpen} onOpenChange={setCreateOpen} />
|
|
149
|
+
{receiving && (
|
|
150
|
+
<ReceiveDialog
|
|
151
|
+
orderId={order.id}
|
|
152
|
+
returnRecord={receiving}
|
|
153
|
+
onOpenChange={() => setReceiving(null)}
|
|
154
|
+
/>
|
|
155
|
+
)}
|
|
156
|
+
{refunding && (
|
|
157
|
+
<RefundDialog
|
|
158
|
+
orderId={order.id}
|
|
159
|
+
order={order}
|
|
160
|
+
returnRecord={refunding}
|
|
161
|
+
onOpenChange={() => setRefunding(null)}
|
|
162
|
+
/>
|
|
163
|
+
)}
|
|
164
|
+
</Card>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function ReturnLineRow({ line }: { line: ReturnLineItem }) {
|
|
169
|
+
const { t } = useTranslation()
|
|
170
|
+
const label = lineLabel(line.name, line.variant, line.variant_id)
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<div className="flex items-center justify-between gap-3 text-sm">
|
|
174
|
+
<span className="truncate">{label}</span>
|
|
175
|
+
<span className="flex shrink-0 items-center gap-2 text-muted-foreground">
|
|
176
|
+
{line.received_quantity > 0 && line.received_quantity < line.quantity && (
|
|
177
|
+
<Badge variant="outline">
|
|
178
|
+
{t('orders.post_sale.returns.partially_received', {
|
|
179
|
+
received: line.received_quantity,
|
|
180
|
+
quantity: line.quantity,
|
|
181
|
+
})}
|
|
182
|
+
</Badge>
|
|
183
|
+
)}
|
|
184
|
+
{!line.resellable && (
|
|
185
|
+
<Badge variant="outline">{t('orders.post_sale.returns.not_resellable')}</Badge>
|
|
186
|
+
)}
|
|
187
|
+
× {line.quantity}
|
|
188
|
+
</span>
|
|
189
|
+
</div>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** What actually turned up, and whether it can go back on the shelf. */
|
|
194
|
+
|
|
195
|
+
/** What the seller records as having arrived back. */
|
|
196
|
+
function ReceiveDialog({
|
|
197
|
+
orderId,
|
|
198
|
+
returnRecord,
|
|
199
|
+
onOpenChange,
|
|
200
|
+
}: {
|
|
201
|
+
orderId: string
|
|
202
|
+
returnRecord: Return
|
|
203
|
+
onOpenChange: (open: boolean) => void
|
|
204
|
+
}) {
|
|
205
|
+
const { receive } = useReturnActions(orderId)
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<ReturnReceiveDialog
|
|
209
|
+
lines={(returnRecord.return_line_items ?? []).map((line) => ({
|
|
210
|
+
id: line.id,
|
|
211
|
+
label: lineLabel(line.name, line.variant, line.variant_id),
|
|
212
|
+
quantity: line.quantity,
|
|
213
|
+
}))}
|
|
214
|
+
onClose={() => onOpenChange(false)}
|
|
215
|
+
pending={receive.isPending}
|
|
216
|
+
onSubmit={(items) => {
|
|
217
|
+
receive
|
|
218
|
+
.mutateAsync({ returnId: returnRecord.id, items })
|
|
219
|
+
.then(() => onOpenChange(false))
|
|
220
|
+
.catch(() => undefined)
|
|
221
|
+
}}
|
|
222
|
+
/>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The seller took the money for their own child order, so giving it back is
|
|
228
|
+
* theirs. The currency is the order's — a seller has none of their own.
|
|
229
|
+
*/
|
|
230
|
+
function RefundDialog({
|
|
231
|
+
orderId,
|
|
232
|
+
order,
|
|
233
|
+
returnRecord,
|
|
234
|
+
onOpenChange,
|
|
235
|
+
}: {
|
|
236
|
+
orderId: string
|
|
237
|
+
order: Order
|
|
238
|
+
returnRecord: Return
|
|
239
|
+
onOpenChange: (open: boolean) => void
|
|
240
|
+
}) {
|
|
241
|
+
const { refund } = useReturnActions(orderId)
|
|
242
|
+
const { symbol: currencySymbol } = currencyParts(order.currency, i18n.language)
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<ReturnRefundDialog
|
|
246
|
+
refundableTotal={returnRecord.refundable_total}
|
|
247
|
+
currencySymbol={currencySymbol}
|
|
248
|
+
onClose={() => onOpenChange(false)}
|
|
249
|
+
pending={refund.isPending}
|
|
250
|
+
onSubmit={({ refundMethod, amount }) => {
|
|
251
|
+
refund
|
|
252
|
+
.mutateAsync({ returnId: returnRecord.id, refundMethod, amount })
|
|
253
|
+
.then(() => onOpenChange(false))
|
|
254
|
+
.catch(() => undefined)
|
|
255
|
+
}}
|
|
256
|
+
/>
|
|
257
|
+
)
|
|
258
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { downloadFromApi, getApiClient, useAuth } from '@spree/dashboard-core'
|
|
2
|
+
import {
|
|
3
|
+
Badge,
|
|
4
|
+
Button,
|
|
5
|
+
CardContent,
|
|
6
|
+
DropdownMenu,
|
|
7
|
+
DropdownMenuContent,
|
|
8
|
+
DropdownMenuItem,
|
|
9
|
+
DropdownMenuTrigger,
|
|
10
|
+
toastManager,
|
|
11
|
+
useConfirm,
|
|
12
|
+
} from '@spree/dashboard-ui'
|
|
13
|
+
import {
|
|
14
|
+
EllipsisVerticalIcon,
|
|
15
|
+
PrinterIcon,
|
|
16
|
+
ReceiptTextIcon,
|
|
17
|
+
TrashIcon,
|
|
18
|
+
} from '@spree/dashboard-ui/icons'
|
|
19
|
+
import type { ShippingLabel } from '@spree/seller-sdk'
|
|
20
|
+
import { useTranslation } from 'react-i18next'
|
|
21
|
+
import { useFulfillmentActions } from '../../hooks/use-fulfillments'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The label bound to a parcel: what it cost and how to print it.
|
|
25
|
+
*
|
|
26
|
+
* Refunding is absent by design — postage bought through the marketplace's
|
|
27
|
+
* carrier account is the operator's to reverse, and the endpoint refuses it.
|
|
28
|
+
* Only a label the seller uploaded can be removed, since that is the only one
|
|
29
|
+
* no carrier holds a record of.
|
|
30
|
+
*/
|
|
31
|
+
export function ShippingLabelRow({
|
|
32
|
+
orderId,
|
|
33
|
+
fulfillmentId,
|
|
34
|
+
label,
|
|
35
|
+
}: {
|
|
36
|
+
orderId: string
|
|
37
|
+
fulfillmentId: string
|
|
38
|
+
label: ShippingLabel
|
|
39
|
+
}) {
|
|
40
|
+
const { t } = useTranslation()
|
|
41
|
+
const { token } = useAuth()
|
|
42
|
+
const confirm = useConfirm()
|
|
43
|
+
const { deleteLabel } = useFulfillmentActions(orderId)
|
|
44
|
+
|
|
45
|
+
const deletable = label.source === 'uploaded'
|
|
46
|
+
|
|
47
|
+
async function print() {
|
|
48
|
+
if (!label.download_url) return
|
|
49
|
+
|
|
50
|
+
// A rejected download inside a click handler is otherwise unhandled, so
|
|
51
|
+
// the file silently never arrives.
|
|
52
|
+
try {
|
|
53
|
+
await downloadFromApi(
|
|
54
|
+
token,
|
|
55
|
+
label.download_url,
|
|
56
|
+
`${label.tracking_number ?? 'label'}.${label.format ?? 'pdf'}`,
|
|
57
|
+
getApiClient().downloadHeaders?.() ?? {},
|
|
58
|
+
)
|
|
59
|
+
} catch {
|
|
60
|
+
// Deliberately not the thrown message: `downloadFromApi` reports the
|
|
61
|
+
// transport in English ("Download failed: 404"), which would land
|
|
62
|
+
// untranslated in a localized panel and tells the merchant nothing they
|
|
63
|
+
// can act on.
|
|
64
|
+
toastManager.add({ type: 'error', title: t('admin.errors.label_download_failed') })
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function handleDelete() {
|
|
69
|
+
const ok = await confirm({
|
|
70
|
+
message: t('orders.fulfillments.confirm_delete_label'),
|
|
71
|
+
variant: 'destructive',
|
|
72
|
+
confirmLabel: t('common.delete'),
|
|
73
|
+
})
|
|
74
|
+
if (!ok) return
|
|
75
|
+
deleteLabel.mutate({ fulfillmentId, labelId: label.id })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<CardContent className="flex flex-wrap items-center justify-between gap-2 border-border-subtle border-b py-3 text-sm">
|
|
80
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
81
|
+
<ReceiptTextIcon className="size-4 text-muted-foreground" />
|
|
82
|
+
<span className="text-muted-foreground">{t('orders.fulfillments.label_title')}</span>
|
|
83
|
+
{label.source === 'uploaded' && (
|
|
84
|
+
<Badge variant="secondary">{t('orders.fulfillments.label_uploaded')}</Badge>
|
|
85
|
+
)}
|
|
86
|
+
{label.display_cost && (
|
|
87
|
+
<span className="text-muted-foreground">
|
|
88
|
+
{t('orders.fulfillments.label_cost', { amount: label.display_cost })}
|
|
89
|
+
</span>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<div className="flex items-center gap-2">
|
|
94
|
+
{label.download_url && (
|
|
95
|
+
<Button type="button" variant="outline" size="sm" onClick={print}>
|
|
96
|
+
<PrinterIcon data-icon="inline-start" />
|
|
97
|
+
{t('orders.fulfillments.print_label')}
|
|
98
|
+
</Button>
|
|
99
|
+
)}
|
|
100
|
+
|
|
101
|
+
{deletable && (
|
|
102
|
+
<DropdownMenu>
|
|
103
|
+
<DropdownMenuTrigger asChild>
|
|
104
|
+
<Button variant="ghost" size="icon-xs">
|
|
105
|
+
<EllipsisVerticalIcon className="size-4" />
|
|
106
|
+
<span className="sr-only">{t('admin.actions.actions_menu')}</span>
|
|
107
|
+
</Button>
|
|
108
|
+
</DropdownMenuTrigger>
|
|
109
|
+
<DropdownMenuContent align="end">
|
|
110
|
+
<DropdownMenuItem
|
|
111
|
+
variant="destructive"
|
|
112
|
+
disabled={deleteLabel.isPending}
|
|
113
|
+
onClick={handleDelete}
|
|
114
|
+
>
|
|
115
|
+
<TrashIcon className="size-4" />
|
|
116
|
+
{t('orders.fulfillments.delete_label')}
|
|
117
|
+
</DropdownMenuItem>
|
|
118
|
+
</DropdownMenuContent>
|
|
119
|
+
</DropdownMenu>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
</CardContent>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Button,
|
|
3
|
+
Card,
|
|
4
|
+
CardContent,
|
|
5
|
+
CardHeader,
|
|
6
|
+
CardTitle,
|
|
7
|
+
StatusBadge,
|
|
8
|
+
toastManager,
|
|
9
|
+
useConfirm,
|
|
10
|
+
} from '@spree/dashboard-ui'
|
|
11
|
+
import type { Product } from '@spree/seller-sdk'
|
|
12
|
+
import { useMutation } from '@tanstack/react-query'
|
|
13
|
+
import { useTranslation } from 'react-i18next'
|
|
14
|
+
import { sellerClient } from '../api-client'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Where a product stands with the marketplace, and the one move available
|
|
18
|
+
* from here.
|
|
19
|
+
*
|
|
20
|
+
* Not a status dropdown: a seller cannot choose to be on sale. They submit
|
|
21
|
+
* and the operator decides, so each status offers only what it can actually
|
|
22
|
+
* do next (docs/plans/6.0-seller-product-submission.md).
|
|
23
|
+
*/
|
|
24
|
+
export function ProductStatusCard({ product, onDone }: { product: Product; onDone: () => void }) {
|
|
25
|
+
const { t } = useTranslation()
|
|
26
|
+
const confirm = useConfirm()
|
|
27
|
+
|
|
28
|
+
const move = useMutation({
|
|
29
|
+
mutationFn: (action: 'submit' | 'draft' | 'archive') =>
|
|
30
|
+
sellerClient().products[action](product.id),
|
|
31
|
+
onSuccess: () => {
|
|
32
|
+
toastManager.add({ type: 'success', title: t('products.status_updated') })
|
|
33
|
+
onDone()
|
|
34
|
+
},
|
|
35
|
+
onError: (err) =>
|
|
36
|
+
toastManager.add({
|
|
37
|
+
type: 'error',
|
|
38
|
+
title: err instanceof Error ? err.message : t('common.error'),
|
|
39
|
+
}),
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const status = product.status
|
|
43
|
+
const canSubmit = status === 'draft' || status === 'rejected'
|
|
44
|
+
const canTakeDown = status === 'active' || status === 'proposed'
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<Card>
|
|
48
|
+
<CardHeader>
|
|
49
|
+
<CardTitle>{t('products.fields.status')}</CardTitle>
|
|
50
|
+
</CardHeader>
|
|
51
|
+
<CardContent className="flex flex-col items-start gap-3">
|
|
52
|
+
<StatusBadge status={status} label={t(`products.statuses.${status}`)} />
|
|
53
|
+
|
|
54
|
+
<p className="text-muted-foreground text-sm">{t(`products.status_help.${status}`)}</p>
|
|
55
|
+
|
|
56
|
+
{/* Why the marketplace turned it down, so the seller knows what to
|
|
57
|
+
fix. It lives on the submission rather than the product: a note
|
|
58
|
+
the seller could overwrite by saving their own product was no
|
|
59
|
+
record of anything. */}
|
|
60
|
+
{status === 'rejected' && product.submission?.review_note && (
|
|
61
|
+
<p className="text-sm">
|
|
62
|
+
{t('products.rejection_reason', { reason: product.submission.review_note })}
|
|
63
|
+
</p>
|
|
64
|
+
)}
|
|
65
|
+
|
|
66
|
+
<div className="flex flex-wrap gap-2">
|
|
67
|
+
{canSubmit && (
|
|
68
|
+
<Button disabled={move.isPending} onClick={() => move.mutate('submit')}>
|
|
69
|
+
{t('products.submit_for_review')}
|
|
70
|
+
</Button>
|
|
71
|
+
)}
|
|
72
|
+
|
|
73
|
+
{canTakeDown && (
|
|
74
|
+
<Button
|
|
75
|
+
variant="outline"
|
|
76
|
+
disabled={move.isPending}
|
|
77
|
+
onClick={async () => {
|
|
78
|
+
const confirmed = await confirm({
|
|
79
|
+
title: t('products.take_down_confirm_title'),
|
|
80
|
+
message: t('products.take_down_confirm_description'),
|
|
81
|
+
confirmLabel: t('products.take_down'),
|
|
82
|
+
})
|
|
83
|
+
if (confirmed) move.mutate('draft')
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
{t('products.take_down')}
|
|
87
|
+
</Button>
|
|
88
|
+
)}
|
|
89
|
+
|
|
90
|
+
{status !== 'archived' && (
|
|
91
|
+
<Button
|
|
92
|
+
variant="outline"
|
|
93
|
+
disabled={move.isPending}
|
|
94
|
+
onClick={async () => {
|
|
95
|
+
const confirmed = await confirm({
|
|
96
|
+
title: t('products.archive_confirm_title'),
|
|
97
|
+
message: t('products.archive_confirm_description'),
|
|
98
|
+
confirmLabel: t('products.archive'),
|
|
99
|
+
variant: 'destructive',
|
|
100
|
+
})
|
|
101
|
+
if (confirmed) move.mutate('archive')
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
{t('products.archive')}
|
|
105
|
+
</Button>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
</CardContent>
|
|
109
|
+
</Card>
|
|
110
|
+
)
|
|
111
|
+
}
|