@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,82 @@
1
+ import { zodResolver } from '@hookform/resolvers/zod'
2
+ import {
3
+ type DeliveryFormValues,
4
+ deliveryFormSchema,
5
+ mapSpreeErrorsToForm,
6
+ } from '@spree/dashboard-core'
7
+ import { DeliveryFormDialog } from '@spree/dashboard-ui'
8
+ import type { Delivery } from '@spree/seller-sdk'
9
+ import { useForm } from 'react-hook-form'
10
+ import { useFulfillmentActions } from '../../hooks/use-fulfillments'
11
+ import { useTrackingCarriers } from '../../hooks/use-reasons'
12
+
13
+ /** What the seller records against one consignment of a parcel they ship themselves. */
14
+ export function FulfillmentDeliveryDialog({
15
+ orderId,
16
+ fulfillmentId,
17
+ delivery,
18
+ open,
19
+ onOpenChange,
20
+ }: {
21
+ orderId: string
22
+ fulfillmentId: string
23
+ delivery?: Delivery
24
+ open: boolean
25
+ onOpenChange: (open: boolean) => void
26
+ }) {
27
+ const { createDelivery, updateDelivery } = useFulfillmentActions(orderId)
28
+ const { data: carriersData } = useTrackingCarriers(open)
29
+
30
+ const carriers = carriersData?.data ?? []
31
+ const carrierKeys = carriers.map((carrier) => carrier.id)
32
+ const carrierLabel = (value: string) =>
33
+ carriers.find((carrier) => carrier.id === value)?.name ?? value
34
+
35
+ const form = useForm<DeliveryFormValues>({
36
+ resolver: zodResolver(deliveryFormSchema),
37
+ defaultValues: {
38
+ tracking_number: delivery?.tracking_number ?? '',
39
+ carrier: delivery?.carrier ?? '',
40
+ },
41
+ })
42
+
43
+ async function onSubmit(values: DeliveryFormValues) {
44
+ try {
45
+ const tracking_number = values.tracking_number.trim()
46
+ const carrier = values.carrier.trim()
47
+
48
+ if (delivery) {
49
+ // Carrier is sent even when empty: clearing it is how a merchant asks
50
+ // for it to be detected from the number again.
51
+ await updateDelivery.mutateAsync({
52
+ fulfillmentId,
53
+ deliveryId: delivery.id,
54
+ tracking_number,
55
+ carrier,
56
+ })
57
+ } else {
58
+ await createDelivery.mutateAsync({
59
+ fulfillmentId,
60
+ tracking_number,
61
+ carrier: carrier || undefined,
62
+ })
63
+ }
64
+ onOpenChange(false)
65
+ } catch (err) {
66
+ if (!mapSpreeErrorsToForm(err, form.setError)) throw err
67
+ }
68
+ }
69
+
70
+ return (
71
+ <DeliveryFormDialog
72
+ form={form}
73
+ open={open}
74
+ onOpenChange={onOpenChange}
75
+ editing={!!delivery}
76
+ carrierKeys={carrierKeys}
77
+ carrierLabel={carrierLabel}
78
+ onSubmit={onSubmit}
79
+ pending={createDelivery.isPending || updateDelivery.isPending}
80
+ />
81
+ )
82
+ }
@@ -0,0 +1,95 @@
1
+ import { FulfillmentEditDialog as SharedFulfillmentEditDialog } from '@spree/dashboard-ui'
2
+ import { type Fulfillment, SpreeError } from '@spree/seller-sdk'
3
+ import { useState } from 'react'
4
+ import { useTranslation } from 'react-i18next'
5
+ import { useFulfillmentActions } from '../../hooks/use-fulfillments'
6
+ import { useOrder } from '../../hooks/use-order'
7
+ import { useStockLocations } from '../../hooks/use-stock-locations'
8
+
9
+ /**
10
+ * Where the parcel ships from and which quoted service carries it.
11
+ *
12
+ * The dialog is shared with the operator's order page; this supplies the
13
+ * seller's view of it. The shelves come from the seller's own endpoint, so the
14
+ * marketplace's warehouses never appear — and the server refuses one anyway.
15
+ */
16
+ export function FulfillmentEditDialog({
17
+ orderId,
18
+ fulfillment,
19
+ open,
20
+ onOpenChange,
21
+ }: {
22
+ orderId: string
23
+ fulfillment: Fulfillment
24
+ open: boolean
25
+ onOpenChange: (open: boolean) => void
26
+ }) {
27
+ const { t } = useTranslation()
28
+ const { update } = useFulfillmentActions(orderId)
29
+ const { data: locationsData } = useStockLocations(open)
30
+ const { refetch: refetchOrder } = useOrder(orderId)
31
+ const [errorMessage, setErrorMessage] = useState<string>()
32
+
33
+ const originOptions = (locationsData?.data ?? []).map((location) => ({
34
+ value: location.id,
35
+ label: location.name,
36
+ }))
37
+
38
+ const rateOptions = (fulfillment.delivery_rates ?? []).map((rate) => ({
39
+ value: rate.id,
40
+ label: `${rate.name} — ${
41
+ Number.parseFloat(rate.cost) === 0 ? t('orders.fulfillments.free') : rate.display_cost
42
+ }`,
43
+ }))
44
+
45
+ async function handleSubmit(
46
+ values: { stockLocationId: string; selectedDeliveryRateId: string },
47
+ changed: { origin: boolean; rate: boolean },
48
+ ): Promise<'requote' | 'done'> {
49
+ setErrorMessage(undefined)
50
+
51
+ try {
52
+ if (changed.origin) {
53
+ await update.mutateAsync({
54
+ fulfillmentId: fulfillment.id,
55
+ stock_location_id: values.stockLocationId,
56
+ })
57
+
58
+ // Moving the parcel re-quotes it: the rates on screen were priced from
59
+ // the shelf it just left. Pull the fresh parcel back so the seller
60
+ // confirms a service that actually applies.
61
+ await refetchOrder()
62
+ return 'requote'
63
+ }
64
+
65
+ if (changed.rate) {
66
+ await update.mutateAsync({
67
+ fulfillmentId: fulfillment.id,
68
+ selected_delivery_rate_id: values.selectedDeliveryRateId,
69
+ })
70
+ }
71
+
72
+ return 'done'
73
+ } catch (err) {
74
+ // A refused save keeps the dialog open with the server's reason on it;
75
+ // anything else is a real fault and belongs to the caller's toast.
76
+ if (!(err instanceof SpreeError)) throw err
77
+ setErrorMessage(err.message)
78
+ return 'requote'
79
+ }
80
+ }
81
+
82
+ return (
83
+ <SharedFulfillmentEditDialog
84
+ open={open}
85
+ onOpenChange={onOpenChange}
86
+ originOptions={originOptions}
87
+ rateOptions={rateOptions}
88
+ currentOriginId={fulfillment.stock_location_id ?? ''}
89
+ currentRateId={fulfillment.selected_delivery_rate_id ?? ''}
90
+ onSubmit={handleSubmit}
91
+ pending={update.isPending}
92
+ errorMessage={errorMessage}
93
+ />
94
+ )
95
+ }
@@ -0,0 +1,132 @@
1
+ import { zodResolver } from '@hookform/resolvers/zod'
2
+ import {
3
+ type FulfillmentItemRow,
4
+ fulfillmentItemRows,
5
+ mapSpreeErrorsToForm,
6
+ } from '@spree/dashboard-core'
7
+ import {
8
+ type FulfillableRowData,
9
+ FulfillItemsForm,
10
+ type FulfillItemsValues,
11
+ } from '@spree/dashboard-ui'
12
+ import type { Fulfillment, Order } from '@spree/seller-sdk'
13
+ import { useEffect, useRef } from 'react'
14
+ import { useForm } from 'react-hook-form'
15
+ import { useTranslation } from 'react-i18next'
16
+ import { useFulfillmentActions } from '../../hooks/use-fulfillments'
17
+ import { useTrackingCarriers } from '../../hooks/use-reasons'
18
+ import { type FulfillItemsFormValues, fulfillItemsFormSchema } from '../../schemas/fulfillment'
19
+
20
+ /**
21
+ * Rows whose fulfillment item no longer maps to a line item are dropped: the
22
+ * fulfill endpoint addresses items by line item, so there is nothing to send
23
+ * for them. They still ship — selecting everything else sends no `items` at
24
+ * all, which ships the whole parcel including them.
25
+ */
26
+ function fulfillableRows(rows: FulfillmentItemRow[]): FulfillableRowData[] {
27
+ return rows.flatMap((row) => (row.lineItem ? [{ ...row, itemId: row.lineItem.id }] : []))
28
+ }
29
+
30
+ /** What the seller sends out of one parcel. */
31
+ export function FulfillmentFulfillForm({
32
+ order,
33
+ fulfillment,
34
+ onDone,
35
+ }: {
36
+ order: Order
37
+ fulfillment: Fulfillment
38
+ onDone: () => void
39
+ }) {
40
+ const { t } = useTranslation()
41
+ const { fulfill } = useFulfillmentActions(order.id)
42
+ const { data: carriersData } = useTrackingCarriers()
43
+
44
+ const carrierOptions = [
45
+ { value: '', label: t('admin.orders.detail.fulfillments.carrier_auto') },
46
+ ...(carriersData?.data ?? []).map((carrier) => ({ value: carrier.id, label: carrier.name })),
47
+ ]
48
+
49
+ const idPrefix = `fulfill-${fulfillment.id}`
50
+ const rows = fulfillableRows(fulfillmentItemRows(fulfillment, order.items ?? []))
51
+ // The parcel holds units, but none of them can be named by line item — the
52
+ // only case where shipping with nothing picked is right. An empty parcel
53
+ // has nothing to ship and stays unsubmittable.
54
+ const canShipWholeParcel = rows.length === 0 && (fulfillment.fulfillment_items?.length ?? 0) > 0
55
+
56
+ const form = useForm<FulfillItemsFormValues>({
57
+ resolver: zodResolver(fulfillItemsFormSchema),
58
+ defaultValues: {
59
+ items: rows.map((row) => ({ item_id: row.itemId, selected: true, quantity: row.quantity })),
60
+ tracking: fulfillment.tracking ?? '',
61
+ // Detected from the number, or picked in the tracking dialog once the
62
+ // parcel has one.
63
+ tracking_carrier: '',
64
+ notify_customer: true,
65
+ },
66
+ })
67
+
68
+ // A refetch re-renders this form with fresh rows, so the values it mirrors
69
+ // have to follow. The effect keys on the parcel's composition rather than on
70
+ // the rows array, so a refetch returning the same units does not wipe what
71
+ // the seller typed.
72
+ const rowsRef = useRef(rows)
73
+ rowsRef.current = rows
74
+ const rowsKey = rows.map((row) => `${row.itemId}:${row.quantity}`).join('|')
75
+ const seededKey = useRef(rowsKey)
76
+
77
+ useEffect(() => {
78
+ if (seededKey.current === rowsKey) return
79
+ seededKey.current = rowsKey
80
+ form.reset({
81
+ ...form.getValues(),
82
+ items: rowsRef.current.map((row) => ({
83
+ item_id: row.itemId,
84
+ selected: true,
85
+ quantity: row.quantity,
86
+ })),
87
+ })
88
+ }, [rowsKey, form])
89
+
90
+ const totalAvailable = rows.reduce((sum, row) => sum + row.quantity, 0)
91
+
92
+ async function onSubmit(values: FulfillItemsValues) {
93
+ const items = values.items
94
+ .filter((item) => item.selected && item.quantity > 0)
95
+ .map((item) => ({ item_id: item.item_id, quantity: item.quantity }))
96
+ // An omitted `items` ships the whole parcel, which is only right when
97
+ // nothing was addressable. A partial pick that came out empty, or an
98
+ // empty parcel, is a no-op worth refusing.
99
+ if (items.length === 0 && !canShipWholeParcel) return
100
+
101
+ const everything = items.reduce((sum, item) => sum + item.quantity, 0) === totalAvailable
102
+ const tracking = values.tracking.trim()
103
+
104
+ try {
105
+ // Omitting `items` ships the whole parcel, avoiding a pointless split
106
+ // when every unit was selected anyway.
107
+ await fulfill.mutateAsync({
108
+ fulfillmentId: fulfillment.id,
109
+ items: everything ? undefined : items,
110
+ tracking: tracking || undefined,
111
+ tracking_carrier: values.tracking_carrier || undefined,
112
+ notify_customer: values.notify_customer,
113
+ })
114
+ onDone()
115
+ } catch (err) {
116
+ if (!mapSpreeErrorsToForm(err, form.setError)) throw err
117
+ }
118
+ }
119
+
120
+ return (
121
+ <FulfillItemsForm
122
+ form={form}
123
+ rows={rows}
124
+ idPrefix={idPrefix}
125
+ carrierOptions={carrierOptions}
126
+ onSubmit={onSubmit}
127
+ onCancel={onDone}
128
+ canShipWholeParcel={canShipWholeParcel}
129
+ pending={fulfill.isPending}
130
+ />
131
+ )
132
+ }
@@ -0,0 +1,95 @@
1
+ import { zodResolver } from '@hookform/resolvers/zod'
2
+ import {
3
+ EMPTY_FILE_UPLOAD_VALUE,
4
+ FileUploadField,
5
+ type FileUploadValue,
6
+ LABEL_ACCEPT,
7
+ type LabelUploadFormValues,
8
+ labelUploadFormSchema,
9
+ mapSpreeErrorsToForm,
10
+ } from '@spree/dashboard-core'
11
+ import { LabelUploadDialog } from '@spree/dashboard-ui'
12
+ import { useState } from 'react'
13
+ import { useForm } from 'react-hook-form'
14
+ import { useTranslation } from 'react-i18next'
15
+ import { useFulfillmentActions } from '../../hooks/use-fulfillments'
16
+ import { useTrackingCarriers } from '../../hooks/use-reasons'
17
+
18
+ /** Records postage the seller bought elsewhere. Buying and refunding are not here: both run through the marketplace's carrier account, and the endpoint refuses them. */
19
+ export function FulfillmentLabelUploadDialog({
20
+ orderId,
21
+ fulfillmentId,
22
+ currency,
23
+ open,
24
+ onOpenChange,
25
+ }: {
26
+ orderId: string
27
+ fulfillmentId: string
28
+ currency: string
29
+ open: boolean
30
+ onOpenChange: (open: boolean) => void
31
+ }) {
32
+ const { t } = useTranslation()
33
+ const { uploadLabel } = useFulfillmentActions(orderId)
34
+ const { data: carriersData } = useTrackingCarriers(open)
35
+
36
+ const carriers = carriersData?.data ?? []
37
+ const carrierKeys = carriers.map((carrier) => carrier.id)
38
+ const carrierLabel = (value: string) =>
39
+ carriers.find((carrier) => carrier.id === value)?.name ?? value
40
+
41
+ const [file, setFile] = useState<FileUploadValue>(EMPTY_FILE_UPLOAD_VALUE)
42
+ // The signed id does not exist until the upload resolves, so saving during
43
+ // one would silently drop the file.
44
+ const [uploading, setUploading] = useState(false)
45
+
46
+ const form = useForm<LabelUploadFormValues>({
47
+ resolver: zodResolver(labelUploadFormSchema),
48
+ defaultValues: { tracking_number: '', carrier: '', cost: '' },
49
+ })
50
+
51
+ async function onSubmit(values: LabelUploadFormValues) {
52
+ // Guarded here as well as on the server: submitting without one would
53
+ // record a label with no file behind it.
54
+ if (!file.signedId) {
55
+ form.setError('root', { message: t('admin.orders.detail.fulfillments.upload_label_file') })
56
+ return
57
+ }
58
+
59
+ try {
60
+ await uploadLabel.mutateAsync({
61
+ fulfillmentId,
62
+ file: file.signedId,
63
+ tracking_number: values.tracking_number.trim(),
64
+ carrier: values.carrier.trim() || undefined,
65
+ cost: values.cost.trim() || undefined,
66
+ currency: values.cost.trim() ? currency : undefined,
67
+ })
68
+ onOpenChange(false)
69
+ } catch (err) {
70
+ if (!mapSpreeErrorsToForm(err, form.setError)) throw err
71
+ }
72
+ }
73
+
74
+ return (
75
+ <LabelUploadDialog
76
+ form={form}
77
+ open={open}
78
+ onOpenChange={onOpenChange}
79
+ fileField={
80
+ <FileUploadField
81
+ accept={LABEL_ACCEPT}
82
+ value={file}
83
+ onChange={setFile}
84
+ onUploadingChange={setUploading}
85
+ private
86
+ />
87
+ }
88
+ carrierKeys={carrierKeys}
89
+ carrierLabel={carrierLabel}
90
+ currency={currency}
91
+ onSubmit={onSubmit}
92
+ pending={uploadLabel.isPending || uploading}
93
+ />
94
+ )
95
+ }
@@ -0,0 +1,142 @@
1
+ import {
2
+ Button,
3
+ Dialog,
4
+ DialogBody,
5
+ DialogContent,
6
+ DialogDescription,
7
+ DialogFooter,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ Field,
11
+ FieldError,
12
+ FieldLabel,
13
+ Input,
14
+ Select,
15
+ SelectContent,
16
+ SelectItem,
17
+ SelectTrigger,
18
+ SelectValue,
19
+ useConfirm,
20
+ } from '@spree/dashboard-ui'
21
+ import type { Fulfillment } from '@spree/seller-sdk'
22
+ import { useState } from 'react'
23
+ import { useTranslation } from 'react-i18next'
24
+ import { useFulfillmentActions } from '../../hooks/use-fulfillments'
25
+ import { unitLabel } from './line-label'
26
+
27
+ /**
28
+ * Moves part of a parcel onto one of its own, for goods leaving separately.
29
+ *
30
+ * One variant at a time, which is the granularity the transfer works at —
31
+ * splitting several apart is several splits.
32
+ */
33
+ export function FulfillmentSplitDialog({
34
+ orderId,
35
+ fulfillment,
36
+ open,
37
+ onOpenChange,
38
+ }: {
39
+ orderId: string
40
+ fulfillment: Fulfillment
41
+ open: boolean
42
+ onOpenChange: (open: boolean) => void
43
+ }) {
44
+ const { t } = useTranslation()
45
+ const confirm = useConfirm()
46
+ const { split } = useFulfillmentActions(orderId)
47
+
48
+ // The endpoint addresses what to move by variant, so a row without one is
49
+ // not splittable — offering it would 404 on a unit the seller just picked
50
+ // out of their own parcel.
51
+ const units = (fulfillment.fulfillment_items ?? []).flatMap((item) =>
52
+ item.variant_id
53
+ ? [{ value: item.variant_id, label: unitLabel(item), quantity: item.quantity }]
54
+ : [],
55
+ )
56
+
57
+ const [variantId, setVariantId] = useState(units[0]?.value ?? '')
58
+ const [quantity, setQuantity] = useState(1)
59
+
60
+ const selected = units.find((unit) => unit.value === variantId)
61
+ const totalUnits = units.reduce((total, unit) => total + unit.quantity, 0)
62
+ // Moving everything is not a split — it empties the parcel and leaves an
63
+ // orphan behind, which is never what was meant.
64
+ const wouldEmpty = quantity >= totalUnits
65
+ const invalid = !variantId || quantity < 1 || wouldEmpty || quantity > (selected?.quantity ?? 0)
66
+
67
+ async function handleSplit() {
68
+ const ok = await confirm({
69
+ title: t('orders.fulfillments.split_title'),
70
+ message: t('orders.fulfillments.confirm_split'),
71
+ confirmLabel: t('orders.fulfillments.split'),
72
+ })
73
+ if (!ok) return
74
+
75
+ await split
76
+ .mutateAsync({ fulfillmentId: fulfillment.id, variant_id: variantId, quantity })
77
+ .then(() => onOpenChange(false))
78
+ .catch(() => undefined)
79
+ }
80
+
81
+ return (
82
+ <Dialog open={open} onOpenChange={onOpenChange}>
83
+ <DialogContent>
84
+ <DialogHeader>
85
+ <DialogTitle>{t('orders.fulfillments.split_title')}</DialogTitle>
86
+ <DialogDescription>{t('orders.fulfillments.split_description')}</DialogDescription>
87
+ </DialogHeader>
88
+
89
+ <DialogBody>
90
+ <div className="flex flex-col gap-4">
91
+ <Field>
92
+ <FieldLabel htmlFor="split-variant">{t('orders.fulfillments.split_item')}</FieldLabel>
93
+ <Select
94
+ items={units}
95
+ value={variantId}
96
+ onValueChange={(value) => setVariantId((value as string) ?? '')}
97
+ >
98
+ <SelectTrigger id="split-variant">
99
+ <SelectValue />
100
+ </SelectTrigger>
101
+ <SelectContent>
102
+ {units.map((unit) => (
103
+ <SelectItem key={unit.value} value={unit.value}>
104
+ {unit.label}
105
+ </SelectItem>
106
+ ))}
107
+ </SelectContent>
108
+ </Select>
109
+ </Field>
110
+
111
+ <Field>
112
+ <FieldLabel htmlFor="split-quantity">
113
+ {t('orders.fulfillments.split_quantity')}
114
+ </FieldLabel>
115
+ <Input
116
+ id="split-quantity"
117
+ type="number"
118
+ min={1}
119
+ max={selected?.quantity ?? 1}
120
+ value={quantity}
121
+ // Clearing the field yields NaN, which fails every comparison
122
+ // below — so `invalid` would read false and the split would
123
+ // send a null quantity.
124
+ onChange={(event) => setQuantity(Number(event.target.value) || 0)}
125
+ />
126
+ {wouldEmpty && <FieldError>{t('orders.fulfillments.split_would_empty')}</FieldError>}
127
+ </Field>
128
+ </div>
129
+ </DialogBody>
130
+
131
+ <DialogFooter>
132
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
133
+ {t('common.cancel')}
134
+ </Button>
135
+ <Button type="button" disabled={invalid || split.isPending} onClick={handleSplit}>
136
+ {split.isPending ? t('orders.fulfillments.splitting') : t('orders.fulfillments.split')}
137
+ </Button>
138
+ </DialogFooter>
139
+ </DialogContent>
140
+ </Dialog>
141
+ )
142
+ }