@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.
Files changed (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +31 -0
  3. package/dist/vite-plugin/index.js +51 -0
  4. package/package.json +64 -0
  5. package/src/api-client.ts +203 -0
  6. package/src/components/centered-message.tsx +8 -0
  7. package/src/components/finance/balance-summary.tsx +69 -0
  8. package/src/components/onboarding-nav-badge.tsx +38 -0
  9. package/src/components/orders/fulfillment-deliveries.tsx +116 -0
  10. package/src/components/orders/fulfillment-delivery-dialog.tsx +82 -0
  11. package/src/components/orders/fulfillment-edit-dialog.tsx +95 -0
  12. package/src/components/orders/fulfillment-fulfill-form.tsx +132 -0
  13. package/src/components/orders/fulfillment-label-upload-dialog.tsx +95 -0
  14. package/src/components/orders/fulfillment-split-dialog.tsx +142 -0
  15. package/src/components/orders/fulfillments-card.tsx +289 -0
  16. package/src/components/orders/line-label.ts +28 -0
  17. package/src/components/orders/order-cancel-dialog.tsx +74 -0
  18. package/src/components/orders/order-cards.tsx +297 -0
  19. package/src/components/orders/order-earnings-card.tsx +99 -0
  20. package/src/components/orders/order-notes-cards.tsx +83 -0
  21. package/src/components/orders/order-payment-card.tsx +94 -0
  22. package/src/components/orders/post-sale-cards.tsx +347 -0
  23. package/src/components/orders/post-sale-create-dialogs.tsx +201 -0
  24. package/src/components/orders/returns-card.tsx +258 -0
  25. package/src/components/orders/shipping-label-row.tsx +124 -0
  26. package/src/components/product-status-card.tsx +111 -0
  27. package/src/components/read-row.tsx +16 -0
  28. package/src/components/retryable-error.tsx +23 -0
  29. package/src/components/seller-address-card.tsx +123 -0
  30. package/src/components/seller-business-card.tsx +152 -0
  31. package/src/components/seller-returns-location-card.tsx +151 -0
  32. package/src/components/seller-switcher.tsx +124 -0
  33. package/src/components/seller-tax-identifiers-card.tsx +63 -0
  34. package/src/create-router.ts +32 -0
  35. package/src/hooks/use-fulfillments.ts +155 -0
  36. package/src/hooks/use-ledger.ts +46 -0
  37. package/src/hooks/use-order.ts +62 -0
  38. package/src/hooks/use-post-sale.ts +202 -0
  39. package/src/hooks/use-reasons.ts +34 -0
  40. package/src/hooks/use-stock-locations.ts +23 -0
  41. package/src/hooks/use-store-timezone.ts +26 -0
  42. package/src/i18n.ts +26 -0
  43. package/src/index.ts +27 -0
  44. package/src/lib/packing-slip.ts +109 -0
  45. package/src/locales/en.json +841 -0
  46. package/src/nav/default.ts +108 -0
  47. package/src/nav/settings.ts +114 -0
  48. package/src/pages/accept-invitation.tsx +127 -0
  49. package/src/pages/delivery-methods.tsx +647 -0
  50. package/src/pages/earnings.tsx +44 -0
  51. package/src/pages/forgot-password.tsx +100 -0
  52. package/src/pages/home.tsx +132 -0
  53. package/src/pages/login.tsx +94 -0
  54. package/src/pages/onboarding.tsx +539 -0
  55. package/src/pages/order.tsx +119 -0
  56. package/src/pages/orders.tsx +33 -0
  57. package/src/pages/payout.tsx +154 -0
  58. package/src/pages/payouts.tsx +32 -0
  59. package/src/pages/policies.tsx +292 -0
  60. package/src/pages/product.tsx +250 -0
  61. package/src/pages/products.tsx +228 -0
  62. package/src/pages/profile.tsx +389 -0
  63. package/src/pages/reset-password.tsx +105 -0
  64. package/src/pages/seller-picker.tsx +46 -0
  65. package/src/pages/team.tsx +468 -0
  66. package/src/panel-chrome.tsx +36 -0
  67. package/src/routeTree.gen.ts +590 -0
  68. package/src/routes/__root.tsx +15 -0
  69. package/src/routes/_authenticated/$sellerId/earnings/index.tsx +15 -0
  70. package/src/routes/_authenticated/$sellerId/index.tsx +6 -0
  71. package/src/routes/_authenticated/$sellerId/onboarding.tsx +6 -0
  72. package/src/routes/_authenticated/$sellerId/orders/$orderId.tsx +6 -0
  73. package/src/routes/_authenticated/$sellerId/orders/index.tsx +15 -0
  74. package/src/routes/_authenticated/$sellerId/payouts/$payoutId.tsx +7 -0
  75. package/src/routes/_authenticated/$sellerId/payouts/index.tsx +15 -0
  76. package/src/routes/_authenticated/$sellerId/products/$productId.tsx +6 -0
  77. package/src/routes/_authenticated/$sellerId/products/index.tsx +23 -0
  78. package/src/routes/_authenticated/$sellerId/products/new.tsx +6 -0
  79. package/src/routes/_authenticated/$sellerId/profile.tsx +6 -0
  80. package/src/routes/_authenticated/$sellerId/settings/delivery-methods.tsx +14 -0
  81. package/src/routes/_authenticated/$sellerId/settings/index.tsx +18 -0
  82. package/src/routes/_authenticated/$sellerId/settings/package-types.tsx +21 -0
  83. package/src/routes/_authenticated/$sellerId/settings/policies.tsx +14 -0
  84. package/src/routes/_authenticated/$sellerId/settings/stock-locations.tsx +22 -0
  85. package/src/routes/_authenticated/$sellerId/settings/team.tsx +6 -0
  86. package/src/routes/_authenticated/$sellerId/settings.tsx +18 -0
  87. package/src/routes/_authenticated/$sellerId.tsx +49 -0
  88. package/src/routes/_authenticated/index.tsx +48 -0
  89. package/src/routes/_authenticated.tsx +13 -0
  90. package/src/routes/accept-invitation.$invitationId.tsx +19 -0
  91. package/src/routes/forgot-password.tsx +15 -0
  92. package/src/routes/login.tsx +15 -0
  93. package/src/routes/reset-password.tsx +21 -0
  94. package/src/schemas/fulfillment.ts +31 -0
  95. package/src/seller-dashboard.tsx +50 -0
  96. package/src/styles.css +18 -0
  97. package/src/tables/delivery-methods.tsx +68 -0
  98. package/src/tables/orders.tsx +116 -0
  99. package/src/tables/payouts.tsx +87 -0
  100. package/src/tables/policies.tsx +37 -0
  101. package/src/tables/products.tsx +56 -0
  102. package/src/tables/transfers.tsx +118 -0
  103. 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
+ }