@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,99 @@
|
|
|
1
|
+
import { formatStoreDateTime } from '@spree/dashboard-core'
|
|
2
|
+
import {
|
|
3
|
+
Card,
|
|
4
|
+
CardContent,
|
|
5
|
+
CardHeader,
|
|
6
|
+
CardTitle,
|
|
7
|
+
StatusBadge,
|
|
8
|
+
Table,
|
|
9
|
+
TableBody,
|
|
10
|
+
TableCell,
|
|
11
|
+
TableHead,
|
|
12
|
+
TableHeader,
|
|
13
|
+
TableRow,
|
|
14
|
+
} from '@spree/dashboard-ui'
|
|
15
|
+
import { BanknoteIcon } from '@spree/dashboard-ui/icons'
|
|
16
|
+
import type { Order } from '@spree/seller-sdk'
|
|
17
|
+
import { Link, useParams } from '@tanstack/react-router'
|
|
18
|
+
import { useTranslation } from 'react-i18next'
|
|
19
|
+
import { useTransfers } from '../../hooks/use-ledger'
|
|
20
|
+
import { useStoreTimezone } from '../../hooks/use-store-timezone'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* What this order earned the seller: the sale less the marketplace's
|
|
24
|
+
* commission, credited once the goods went out, and a reversal row for
|
|
25
|
+
* anything refunded since.
|
|
26
|
+
*
|
|
27
|
+
* Renders nothing until there is something to show — an order is not earned
|
|
28
|
+
* until it is fulfilled, and an empty card on every open order would only be
|
|
29
|
+
* noise.
|
|
30
|
+
*/
|
|
31
|
+
export function OrderEarningsCard({ order }: { order: Order }) {
|
|
32
|
+
const { t } = useTranslation()
|
|
33
|
+
const { sellerId } = useParams({ from: '/_authenticated/$sellerId' })
|
|
34
|
+
const timezone = useStoreTimezone()
|
|
35
|
+
const { data } = useTransfers({ order_id_eq: order.id })
|
|
36
|
+
|
|
37
|
+
const transfers = data?.data ?? []
|
|
38
|
+
if (transfers.length === 0) return null
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<Card>
|
|
42
|
+
<CardHeader>
|
|
43
|
+
<CardTitle>
|
|
44
|
+
<BanknoteIcon className="size-4" />
|
|
45
|
+
{t('orders.earnings.title')}
|
|
46
|
+
</CardTitle>
|
|
47
|
+
</CardHeader>
|
|
48
|
+
|
|
49
|
+
<CardContent className="p-0">
|
|
50
|
+
<Table>
|
|
51
|
+
<TableHeader>
|
|
52
|
+
<TableRow>
|
|
53
|
+
<TableHead>{t('earnings.columns.date')}</TableHead>
|
|
54
|
+
<TableHead>{t('earnings.columns.kind')}</TableHead>
|
|
55
|
+
<TableHead>{t('earnings.columns.status')}</TableHead>
|
|
56
|
+
<TableHead>{t('orders.earnings.payout')}</TableHead>
|
|
57
|
+
<TableHead className="text-right">{t('earnings.columns.amount')}</TableHead>
|
|
58
|
+
</TableRow>
|
|
59
|
+
</TableHeader>
|
|
60
|
+
<TableBody>
|
|
61
|
+
{transfers.map((transfer) => (
|
|
62
|
+
<TableRow key={transfer.id}>
|
|
63
|
+
<TableCell className="whitespace-nowrap">
|
|
64
|
+
{formatStoreDateTime(transfer.created_at, timezone)}
|
|
65
|
+
</TableCell>
|
|
66
|
+
<TableCell>
|
|
67
|
+
{t(`earnings.kinds.${transfer.kind}`, { defaultValue: transfer.kind })}
|
|
68
|
+
</TableCell>
|
|
69
|
+
<TableCell>
|
|
70
|
+
<StatusBadge
|
|
71
|
+
status={transfer.status}
|
|
72
|
+
label={t(`earnings.statuses.${transfer.status}`, {
|
|
73
|
+
defaultValue: transfer.status,
|
|
74
|
+
})}
|
|
75
|
+
/>
|
|
76
|
+
</TableCell>
|
|
77
|
+
<TableCell>
|
|
78
|
+
{transfer.payout_id ? (
|
|
79
|
+
<Link
|
|
80
|
+
to="/$sellerId/payouts/$payoutId"
|
|
81
|
+
params={{ sellerId, payoutId: transfer.payout_id }}
|
|
82
|
+
>
|
|
83
|
+
{t('orders.earnings.view_payout')}
|
|
84
|
+
</Link>
|
|
85
|
+
) : (
|
|
86
|
+
<span className="text-muted-foreground">
|
|
87
|
+
{t('orders.earnings.not_settled')}
|
|
88
|
+
</span>
|
|
89
|
+
)}
|
|
90
|
+
</TableCell>
|
|
91
|
+
<TableCell className="text-right tabular-nums">{transfer.display_amount}</TableCell>
|
|
92
|
+
</TableRow>
|
|
93
|
+
))}
|
|
94
|
+
</TableBody>
|
|
95
|
+
</Table>
|
|
96
|
+
</CardContent>
|
|
97
|
+
</Card>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { EditableNoteCard, NoteHtml, NoteText, RichTextEditor, Textarea } from '@spree/dashboard-ui'
|
|
2
|
+
import type { Order } from '@spree/seller-sdk'
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { useTranslation } from 'react-i18next'
|
|
5
|
+
import { useUpdateOrderNotes } from '../../hooks/use-order'
|
|
6
|
+
|
|
7
|
+
/** What the buyer asked for when they placed the order. */
|
|
8
|
+
export function SpecialInstructionsCard({ order }: { order: Order }) {
|
|
9
|
+
const { t } = useTranslation()
|
|
10
|
+
|
|
11
|
+
const [editing, setEditing] = useState(false)
|
|
12
|
+
const [note, setNote] = useState(order.customer_note ?? '')
|
|
13
|
+
const serverNote = order.customer_note ?? ''
|
|
14
|
+
|
|
15
|
+
// Track the server value only while the editor is closed: any order
|
|
16
|
+
// mutation refetches this record, and adopting the incoming value mid-edit
|
|
17
|
+
// would throw away whatever the seller is part-way through typing.
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (!editing) setNote(serverNote)
|
|
20
|
+
}, [editing, serverNote])
|
|
21
|
+
|
|
22
|
+
const mutation = useUpdateOrderNotes(order.id)
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<EditableNoteCard
|
|
26
|
+
title={t('admin.orders.detail.section_customer_note')}
|
|
27
|
+
editing={editing}
|
|
28
|
+
onEditingChange={(next) => {
|
|
29
|
+
if (!next) setNote(serverNote)
|
|
30
|
+
setEditing(next)
|
|
31
|
+
}}
|
|
32
|
+
editor={<Textarea value={note} onChange={(event) => setNote(event.target.value)} />}
|
|
33
|
+
pending={mutation.isPending}
|
|
34
|
+
onSave={() =>
|
|
35
|
+
mutation.mutate({ customer_note: note }, { onSuccess: () => setEditing(false) })
|
|
36
|
+
}
|
|
37
|
+
>
|
|
38
|
+
{serverNote ? <NoteText value={serverNote} /> : null}
|
|
39
|
+
</EditableNoteCard>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The seller's own working note.
|
|
45
|
+
*
|
|
46
|
+
* A marketplace basket splits into one order per seller, so this note is
|
|
47
|
+
* theirs alone rather than the operator's note about the whole sale.
|
|
48
|
+
*/
|
|
49
|
+
export function InternalNoteCard({ order }: { order: Order }) {
|
|
50
|
+
const { t } = useTranslation()
|
|
51
|
+
|
|
52
|
+
const [editing, setEditing] = useState(false)
|
|
53
|
+
// Edit the HTML, not the plain-text projection: `internal_note` has its
|
|
54
|
+
// markup stripped, so round-tripping it through the `internal_note_html`
|
|
55
|
+
// write would flatten an existing note on a save that changed nothing.
|
|
56
|
+
const [note, setNote] = useState(order.internal_note_html ?? '')
|
|
57
|
+
const serverNote = order.internal_note_html ?? ''
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!editing) setNote(serverNote)
|
|
61
|
+
}, [editing, serverNote])
|
|
62
|
+
|
|
63
|
+
const mutation = useUpdateOrderNotes(order.id)
|
|
64
|
+
const title = t('admin.orders.detail.section_internal_note')
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<EditableNoteCard
|
|
68
|
+
title={title}
|
|
69
|
+
editing={editing}
|
|
70
|
+
onEditingChange={(next) => {
|
|
71
|
+
if (!next) setNote(serverNote)
|
|
72
|
+
setEditing(next)
|
|
73
|
+
}}
|
|
74
|
+
editor={<RichTextEditor ariaLabel={title} value={note} onChange={setNote} />}
|
|
75
|
+
pending={mutation.isPending}
|
|
76
|
+
onSave={() =>
|
|
77
|
+
mutation.mutate({ internal_note: note }, { onSuccess: () => setEditing(false) })
|
|
78
|
+
}
|
|
79
|
+
>
|
|
80
|
+
{serverNote ? <NoteHtml html={serverNote} /> : null}
|
|
81
|
+
</EditableNoteCard>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Card,
|
|
3
|
+
CardAction,
|
|
4
|
+
CardContent,
|
|
5
|
+
CardHeader,
|
|
6
|
+
CardTitle,
|
|
7
|
+
Separator,
|
|
8
|
+
StatusBadge,
|
|
9
|
+
Table,
|
|
10
|
+
TableBody,
|
|
11
|
+
TableCell,
|
|
12
|
+
TableHead,
|
|
13
|
+
TableHeader,
|
|
14
|
+
TableRow,
|
|
15
|
+
} from '@spree/dashboard-ui'
|
|
16
|
+
import { CreditCardIcon } from '@spree/dashboard-ui/icons'
|
|
17
|
+
import type { Order } from '@spree/seller-sdk'
|
|
18
|
+
import { useTranslation } from 'react-i18next'
|
|
19
|
+
import { ReadRow } from '../read-row'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What the buyer paid for this order.
|
|
23
|
+
*
|
|
24
|
+
* On a marketplace the customer pays once for a basket that may span several
|
|
25
|
+
* sellers, so what this order has is a share of that payment rather than a
|
|
26
|
+
* payment of its own — the shares are what the table lists. How the buyer
|
|
27
|
+
* paid, and to which gateway, is the marketplace's business and is not here.
|
|
28
|
+
*
|
|
29
|
+
* An order placed with this seller alone does not split, so it has no shares
|
|
30
|
+
* and only the totals above are shown.
|
|
31
|
+
*/
|
|
32
|
+
export function OrderPaymentCard({ order }: { order: Order }) {
|
|
33
|
+
const { t } = useTranslation()
|
|
34
|
+
const splits = order.payment_splits ?? []
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Card>
|
|
38
|
+
<CardHeader>
|
|
39
|
+
<CardTitle>
|
|
40
|
+
<CreditCardIcon className="size-4" />
|
|
41
|
+
{t('orders.payment.title')}
|
|
42
|
+
</CardTitle>
|
|
43
|
+
{order.payment_status && (
|
|
44
|
+
<CardAction>
|
|
45
|
+
<StatusBadge
|
|
46
|
+
status={order.payment_status}
|
|
47
|
+
label={t(`orders.payment_statuses.${order.payment_status}`, {
|
|
48
|
+
defaultValue: order.payment_status,
|
|
49
|
+
})}
|
|
50
|
+
/>
|
|
51
|
+
</CardAction>
|
|
52
|
+
)}
|
|
53
|
+
</CardHeader>
|
|
54
|
+
|
|
55
|
+
<CardContent className="flex flex-col gap-3">
|
|
56
|
+
<ReadRow label={t('orders.summary.payment_total')}>{order.display_payment_total}</ReadRow>
|
|
57
|
+
<ReadRow label={t('orders.summary.outstanding_balance')}>
|
|
58
|
+
{order.display_amount_due}
|
|
59
|
+
</ReadRow>
|
|
60
|
+
</CardContent>
|
|
61
|
+
|
|
62
|
+
{splits.length > 0 && (
|
|
63
|
+
<>
|
|
64
|
+
<Separator />
|
|
65
|
+
<p className="px-6 pt-4 text-sm text-muted-foreground">
|
|
66
|
+
{t('orders.payment.share_help')}
|
|
67
|
+
</p>
|
|
68
|
+
<Table>
|
|
69
|
+
<TableHeader>
|
|
70
|
+
<TableRow>
|
|
71
|
+
<TableHead>{t('orders.payment.authorized')}</TableHead>
|
|
72
|
+
<TableHead>{t('orders.payment.captured')}</TableHead>
|
|
73
|
+
<TableHead>{t('orders.payment.refunded')}</TableHead>
|
|
74
|
+
<TableHead className="text-right">{t('orders.payment.refundable')}</TableHead>
|
|
75
|
+
</TableRow>
|
|
76
|
+
</TableHeader>
|
|
77
|
+
<TableBody>
|
|
78
|
+
{splits.map((split) => (
|
|
79
|
+
<TableRow key={split.id}>
|
|
80
|
+
<TableCell className="tabular-nums">{split.display_authorized_amount}</TableCell>
|
|
81
|
+
<TableCell className="tabular-nums">{split.display_captured_amount}</TableCell>
|
|
82
|
+
<TableCell className="tabular-nums">{split.display_refunded_amount}</TableCell>
|
|
83
|
+
<TableCell className="text-right tabular-nums">
|
|
84
|
+
{split.display_refundable_amount}
|
|
85
|
+
</TableCell>
|
|
86
|
+
</TableRow>
|
|
87
|
+
))}
|
|
88
|
+
</TableBody>
|
|
89
|
+
</Table>
|
|
90
|
+
</>
|
|
91
|
+
)}
|
|
92
|
+
</Card>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { currencyParts } from '@spree/dashboard-core'
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
Card,
|
|
5
|
+
CardAction,
|
|
6
|
+
CardContent,
|
|
7
|
+
CardHeader,
|
|
8
|
+
CardTitle,
|
|
9
|
+
ClaimResolveDialog,
|
|
10
|
+
DropdownMenu,
|
|
11
|
+
DropdownMenuContent,
|
|
12
|
+
DropdownMenuItem,
|
|
13
|
+
DropdownMenuSeparator,
|
|
14
|
+
DropdownMenuTrigger,
|
|
15
|
+
StatusBadge,
|
|
16
|
+
useConfirm,
|
|
17
|
+
} from '@spree/dashboard-ui'
|
|
18
|
+
import {
|
|
19
|
+
ArrowLeftRightIcon,
|
|
20
|
+
BanIcon,
|
|
21
|
+
EllipsisVerticalIcon,
|
|
22
|
+
PlusIcon,
|
|
23
|
+
ShieldAlertIcon,
|
|
24
|
+
} from '@spree/dashboard-ui/icons'
|
|
25
|
+
import type { Claim, Order } from '@spree/seller-sdk'
|
|
26
|
+
import i18n from 'i18next'
|
|
27
|
+
import { useState } from 'react'
|
|
28
|
+
import { useTranslation } from 'react-i18next'
|
|
29
|
+
import {
|
|
30
|
+
useClaimActions,
|
|
31
|
+
useExchangeActions,
|
|
32
|
+
useOrderClaims,
|
|
33
|
+
useOrderExchanges,
|
|
34
|
+
} from '../../hooks/use-post-sale'
|
|
35
|
+
import { lineLabel } from './line-label'
|
|
36
|
+
import { CreateClaimDialog } from './post-sale-create-dialogs'
|
|
37
|
+
|
|
38
|
+
const EXCHANGE_ACTIONABLE = ['requested', 'approved', 'received']
|
|
39
|
+
const CLAIM_ACTIONABLE = ['open', 'approved']
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Goods swapped for different ones.
|
|
43
|
+
*
|
|
44
|
+
* Read plus the status moves; opening one needs a replacement variant picker,
|
|
45
|
+
* which the seller's own catalogue search will bring — until then an exchange
|
|
46
|
+
* is opened by the operator or the customer and worked here.
|
|
47
|
+
*/
|
|
48
|
+
export function ExchangesCard({ order }: { order: Order }) {
|
|
49
|
+
const { t } = useTranslation()
|
|
50
|
+
const confirm = useConfirm()
|
|
51
|
+
const { data } = useOrderExchanges(order.id)
|
|
52
|
+
const { approve, receive, fulfill, cancel } = useExchangeActions(order.id)
|
|
53
|
+
|
|
54
|
+
const exchanges = data?.data ?? []
|
|
55
|
+
if (exchanges.length === 0) return null
|
|
56
|
+
|
|
57
|
+
async function handleCancel(id: string) {
|
|
58
|
+
const ok = await confirm({
|
|
59
|
+
title: t('orders.post_sale.exchanges.cancel_title'),
|
|
60
|
+
message: t('orders.post_sale.exchanges.cancel_message'),
|
|
61
|
+
variant: 'destructive',
|
|
62
|
+
confirmLabel: t('orders.post_sale.cancel'),
|
|
63
|
+
})
|
|
64
|
+
if (!ok) return
|
|
65
|
+
cancel.mutate(id)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<Card variant="container">
|
|
70
|
+
<CardHeader>
|
|
71
|
+
<CardTitle>
|
|
72
|
+
<ArrowLeftRightIcon className="size-4" />
|
|
73
|
+
{t('orders.post_sale.exchanges.title')}
|
|
74
|
+
</CardTitle>
|
|
75
|
+
</CardHeader>
|
|
76
|
+
|
|
77
|
+
<CardContent className="flex flex-col gap-4">
|
|
78
|
+
{exchanges.map((exchange) => (
|
|
79
|
+
<Card key={exchange.id} variant="nested">
|
|
80
|
+
<CardHeader>
|
|
81
|
+
<CardTitle className="text-sm font-medium">{exchange.number}</CardTitle>
|
|
82
|
+
<CardAction className="flex items-center gap-2">
|
|
83
|
+
<StatusBadge
|
|
84
|
+
status={exchange.status}
|
|
85
|
+
label={t(`orders.post_sale.statuses.${exchange.status}`, {
|
|
86
|
+
defaultValue: exchange.status,
|
|
87
|
+
})}
|
|
88
|
+
/>
|
|
89
|
+
{EXCHANGE_ACTIONABLE.includes(exchange.status) && (
|
|
90
|
+
<DropdownMenu>
|
|
91
|
+
<DropdownMenuTrigger asChild>
|
|
92
|
+
<Button variant="ghost" size="icon-xs">
|
|
93
|
+
<EllipsisVerticalIcon className="size-4" />
|
|
94
|
+
<span className="sr-only">{t('admin.actions.actions_menu')}</span>
|
|
95
|
+
</Button>
|
|
96
|
+
</DropdownMenuTrigger>
|
|
97
|
+
<DropdownMenuContent align="end">
|
|
98
|
+
{exchange.status === 'requested' && (
|
|
99
|
+
<DropdownMenuItem onClick={() => approve.mutate(exchange.id)}>
|
|
100
|
+
{t('orders.post_sale.approve')}
|
|
101
|
+
</DropdownMenuItem>
|
|
102
|
+
)}
|
|
103
|
+
{exchange.status === 'approved' && (
|
|
104
|
+
<DropdownMenuItem
|
|
105
|
+
onClick={() => receive.mutate({ exchangeId: exchange.id })}
|
|
106
|
+
>
|
|
107
|
+
{t('orders.post_sale.exchanges.receive')}
|
|
108
|
+
</DropdownMenuItem>
|
|
109
|
+
)}
|
|
110
|
+
{exchange.status === 'received' && (
|
|
111
|
+
<DropdownMenuItem
|
|
112
|
+
onClick={() => fulfill.mutate({ exchangeId: exchange.id })}
|
|
113
|
+
>
|
|
114
|
+
{t('orders.post_sale.exchanges.fulfill')}
|
|
115
|
+
</DropdownMenuItem>
|
|
116
|
+
)}
|
|
117
|
+
<DropdownMenuSeparator />
|
|
118
|
+
<DropdownMenuItem
|
|
119
|
+
variant="destructive"
|
|
120
|
+
onClick={() => handleCancel(exchange.id)}
|
|
121
|
+
>
|
|
122
|
+
{t('orders.post_sale.cancel')}
|
|
123
|
+
</DropdownMenuItem>
|
|
124
|
+
</DropdownMenuContent>
|
|
125
|
+
</DropdownMenu>
|
|
126
|
+
)}
|
|
127
|
+
</CardAction>
|
|
128
|
+
</CardHeader>
|
|
129
|
+
|
|
130
|
+
<CardContent className="flex flex-col gap-1.5">
|
|
131
|
+
{exchange.exchange_line_items?.map((line) => (
|
|
132
|
+
<div key={line.id} className="flex items-center justify-between gap-3 text-sm">
|
|
133
|
+
<span className="truncate">
|
|
134
|
+
{lineLabel(line.name, line.original_variant, line.original_variant_id)} →{' '}
|
|
135
|
+
{lineLabel(line.new_variant_name, line.new_variant, line.new_variant_id)}
|
|
136
|
+
</span>
|
|
137
|
+
<span className="shrink-0 text-muted-foreground">× {line.quantity}</span>
|
|
138
|
+
</div>
|
|
139
|
+
))}
|
|
140
|
+
<p className="text-muted-foreground text-sm">
|
|
141
|
+
{t('orders.post_sale.exchanges.price_difference', {
|
|
142
|
+
amount: exchange.display_price_difference,
|
|
143
|
+
})}
|
|
144
|
+
</p>
|
|
145
|
+
</CardContent>
|
|
146
|
+
</Card>
|
|
147
|
+
))}
|
|
148
|
+
</CardContent>
|
|
149
|
+
</Card>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Something went wrong with a delivery, and the seller puts it right. */
|
|
154
|
+
export function ClaimsCard({ order }: { order: Order }) {
|
|
155
|
+
const { t } = useTranslation()
|
|
156
|
+
const confirm = useConfirm()
|
|
157
|
+
const { data } = useOrderClaims(order.id)
|
|
158
|
+
const { approve, deny, cancel } = useClaimActions(order.id)
|
|
159
|
+
|
|
160
|
+
const [createOpen, setCreateOpen] = useState(false)
|
|
161
|
+
const [resolving, setResolving] = useState<Claim | null>(null)
|
|
162
|
+
|
|
163
|
+
const claims = data?.data ?? []
|
|
164
|
+
const canCreate = (order.items ?? []).length > 0
|
|
165
|
+
|
|
166
|
+
async function handleCancel(id: string) {
|
|
167
|
+
const ok = await confirm({
|
|
168
|
+
title: t('orders.post_sale.claims.cancel_title'),
|
|
169
|
+
message: t('orders.post_sale.claims.cancel_message'),
|
|
170
|
+
variant: 'destructive',
|
|
171
|
+
confirmLabel: t('orders.post_sale.cancel'),
|
|
172
|
+
})
|
|
173
|
+
if (!ok) return
|
|
174
|
+
cancel.mutate(id)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function handleDeny(id: string) {
|
|
178
|
+
const ok = await confirm({
|
|
179
|
+
title: t('orders.post_sale.claims.deny_title'),
|
|
180
|
+
message: t('orders.post_sale.claims.deny_message'),
|
|
181
|
+
variant: 'destructive',
|
|
182
|
+
confirmLabel: t('orders.post_sale.claims.deny'),
|
|
183
|
+
})
|
|
184
|
+
if (!ok) return
|
|
185
|
+
deny.mutate(id)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
<Card variant="container">
|
|
190
|
+
<CardHeader>
|
|
191
|
+
<CardTitle>
|
|
192
|
+
<ShieldAlertIcon className="size-4" />
|
|
193
|
+
{t('orders.post_sale.claims.title')}
|
|
194
|
+
</CardTitle>
|
|
195
|
+
{canCreate && (
|
|
196
|
+
<CardAction>
|
|
197
|
+
<Button variant="outline" size="sm" onClick={() => setCreateOpen(true)}>
|
|
198
|
+
<PlusIcon className="size-4" />
|
|
199
|
+
{t('orders.post_sale.claims.create')}
|
|
200
|
+
</Button>
|
|
201
|
+
</CardAction>
|
|
202
|
+
)}
|
|
203
|
+
</CardHeader>
|
|
204
|
+
|
|
205
|
+
<CardContent className="flex flex-col gap-4">
|
|
206
|
+
{claims.length === 0 ? (
|
|
207
|
+
<p className="text-muted-foreground text-sm">{t('orders.post_sale.claims.empty')}</p>
|
|
208
|
+
) : (
|
|
209
|
+
claims.map((claim) => (
|
|
210
|
+
<Card key={claim.id} variant="nested">
|
|
211
|
+
<CardHeader>
|
|
212
|
+
<CardTitle className="text-sm font-medium">{claim.number}</CardTitle>
|
|
213
|
+
<CardAction className="flex items-center gap-2">
|
|
214
|
+
<StatusBadge
|
|
215
|
+
status={claim.status}
|
|
216
|
+
label={t(`orders.post_sale.statuses.${claim.status}`, {
|
|
217
|
+
defaultValue: claim.status,
|
|
218
|
+
})}
|
|
219
|
+
/>
|
|
220
|
+
{CLAIM_ACTIONABLE.includes(claim.status) && (
|
|
221
|
+
<DropdownMenu>
|
|
222
|
+
<DropdownMenuTrigger asChild>
|
|
223
|
+
<Button variant="ghost" size="icon-xs">
|
|
224
|
+
<EllipsisVerticalIcon className="size-4" />
|
|
225
|
+
<span className="sr-only">{t('admin.actions.actions_menu')}</span>
|
|
226
|
+
</Button>
|
|
227
|
+
</DropdownMenuTrigger>
|
|
228
|
+
<DropdownMenuContent align="end">
|
|
229
|
+
{claim.status === 'open' && (
|
|
230
|
+
<DropdownMenuItem onClick={() => approve.mutate(claim.id)}>
|
|
231
|
+
{t('orders.post_sale.approve')}
|
|
232
|
+
</DropdownMenuItem>
|
|
233
|
+
)}
|
|
234
|
+
{claim.status === 'approved' && (
|
|
235
|
+
<DropdownMenuItem onClick={() => setResolving(claim)}>
|
|
236
|
+
{t('orders.post_sale.claims.resolve')}
|
|
237
|
+
</DropdownMenuItem>
|
|
238
|
+
)}
|
|
239
|
+
<DropdownMenuItem onClick={() => handleDeny(claim.id)}>
|
|
240
|
+
<BanIcon className="size-4" />
|
|
241
|
+
{t('orders.post_sale.claims.deny')}
|
|
242
|
+
</DropdownMenuItem>
|
|
243
|
+
<DropdownMenuSeparator />
|
|
244
|
+
<DropdownMenuItem
|
|
245
|
+
variant="destructive"
|
|
246
|
+
onClick={() => handleCancel(claim.id)}
|
|
247
|
+
>
|
|
248
|
+
{t('orders.post_sale.cancel')}
|
|
249
|
+
</DropdownMenuItem>
|
|
250
|
+
</DropdownMenuContent>
|
|
251
|
+
</DropdownMenu>
|
|
252
|
+
)}
|
|
253
|
+
</CardAction>
|
|
254
|
+
</CardHeader>
|
|
255
|
+
|
|
256
|
+
<CardContent className="flex flex-col gap-1.5">
|
|
257
|
+
{claim.claim_line_items?.map((line) => (
|
|
258
|
+
<div key={line.id} className="flex items-center justify-between gap-3 text-sm">
|
|
259
|
+
<span className="min-w-0 truncate">
|
|
260
|
+
{lineLabel(line.name, line.variant, line.variant_id)}
|
|
261
|
+
{line.description && (
|
|
262
|
+
<span className="text-muted-foreground"> — {line.description}</span>
|
|
263
|
+
)}
|
|
264
|
+
</span>
|
|
265
|
+
<span className="shrink-0 text-muted-foreground">× {line.quantity}</span>
|
|
266
|
+
</div>
|
|
267
|
+
))}
|
|
268
|
+
</CardContent>
|
|
269
|
+
</Card>
|
|
270
|
+
))
|
|
271
|
+
)}
|
|
272
|
+
</CardContent>
|
|
273
|
+
|
|
274
|
+
<CreateClaimDialog order={order} open={createOpen} onOpenChange={setCreateOpen} />
|
|
275
|
+
{resolving && (
|
|
276
|
+
<ResolveClaimDialog
|
|
277
|
+
orderId={order.id}
|
|
278
|
+
order={order}
|
|
279
|
+
claim={resolving}
|
|
280
|
+
onOpenChange={() => setResolving(null)}
|
|
281
|
+
/>
|
|
282
|
+
)}
|
|
283
|
+
</Card>
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Money back, a replacement, or both. */
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* How the seller puts a delivery right: money back, a replacement, or both.
|
|
291
|
+
*
|
|
292
|
+
* The currency is the order's — a seller has none of their own.
|
|
293
|
+
*/
|
|
294
|
+
function ResolveClaimDialog({
|
|
295
|
+
orderId,
|
|
296
|
+
order,
|
|
297
|
+
claim,
|
|
298
|
+
onOpenChange,
|
|
299
|
+
}: {
|
|
300
|
+
orderId: string
|
|
301
|
+
order: Order
|
|
302
|
+
claim: Claim
|
|
303
|
+
onOpenChange: (open: boolean) => void
|
|
304
|
+
}) {
|
|
305
|
+
const { resolve } = useClaimActions(orderId)
|
|
306
|
+
const lines = claim.claim_line_items ?? []
|
|
307
|
+
const { symbol: currencySymbol } = currencyParts(order.currency, i18n.language)
|
|
308
|
+
|
|
309
|
+
// A claim opened without per-item amounts has a refund_total of zero, and
|
|
310
|
+
// the workflow refuses to refund nothing — offer what the customer paid for
|
|
311
|
+
// the claimed items instead, which is also the ceiling it enforces.
|
|
312
|
+
const recorded = Number(claim.refund_total)
|
|
313
|
+
const paid = lines.reduce((sum, line) => sum + Number(line.paid_amount ?? 0), 0)
|
|
314
|
+
const defaultAmount =
|
|
315
|
+
Number.isFinite(recorded) && recorded > 0
|
|
316
|
+
? claim.refund_total
|
|
317
|
+
: paid > 0
|
|
318
|
+
? paid.toFixed(2)
|
|
319
|
+
: claim.refund_total
|
|
320
|
+
|
|
321
|
+
return (
|
|
322
|
+
<ClaimResolveDialog
|
|
323
|
+
lines={lines.map((line) => ({
|
|
324
|
+
id: line.id,
|
|
325
|
+
label: lineLabel(line.name, line.variant, line.variant_id),
|
|
326
|
+
quantity: line.quantity,
|
|
327
|
+
sendReplacement: line.send_replacement,
|
|
328
|
+
}))}
|
|
329
|
+
defaultAmount={defaultAmount}
|
|
330
|
+
currencySymbol={currencySymbol}
|
|
331
|
+
onClose={() => onOpenChange(false)}
|
|
332
|
+
pending={resolve.isPending}
|
|
333
|
+
onSubmit={({ resolution, refundMethod, amount, replacementLineItemIds }) => {
|
|
334
|
+
resolve
|
|
335
|
+
.mutateAsync({
|
|
336
|
+
claimId: claim.id,
|
|
337
|
+
resolution,
|
|
338
|
+
refundMethod,
|
|
339
|
+
amount,
|
|
340
|
+
replacementLineItemIds,
|
|
341
|
+
})
|
|
342
|
+
.then(() => onOpenChange(false))
|
|
343
|
+
.catch(() => undefined)
|
|
344
|
+
}}
|
|
345
|
+
/>
|
|
346
|
+
)
|
|
347
|
+
}
|