@spree/dashboard-ui 0.13.1 → 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/README.md +1 -1
- package/package.json +15 -9
- package/src/hooks/use-prefers-reduced-motion.ts +26 -0
- package/src/hooks/use-scrolled.ts +34 -14
- package/src/index.ts +39 -1
- package/src/lib/date-range-presets.ts +79 -0
- package/src/lib/same-rich-text.ts +145 -0
- package/src/spree/address-block.tsx +5 -4
- package/src/spree/address-book-row.tsx +96 -0
- package/src/spree/address-map.tsx +62 -0
- package/src/spree/back-button.tsx +14 -9
- package/src/spree/bulk-dialog.tsx +2 -2
- package/src/spree/bulk-price-table.tsx +145 -14
- package/src/spree/bulk-variants-table.tsx +434 -0
- package/src/spree/calculator-summary.tsx +5 -1
- package/src/spree/claim-resolve-dialog.tsx +219 -0
- package/src/spree/confirm-dialog.tsx +1 -1
- package/src/spree/copy-to-clipboard-button.tsx +1 -1
- package/src/spree/data-grid/cells.tsx +199 -25
- package/src/spree/data-grid/context.ts +6 -0
- package/src/spree/data-grid/data-grid.tsx +149 -7
- package/src/spree/data-grid/helpers.ts +6 -2
- package/src/spree/data-grid/index.ts +9 -1
- package/src/spree/data-grid/use-data-grid-keyboard.ts +4 -2
- package/src/spree/delivery-form-dialog.tsx +166 -0
- package/src/spree/drag-handle.tsx +2 -2
- package/src/spree/editable-note-card.tsx +103 -0
- package/src/spree/external-link.tsx +56 -0
- package/src/spree/file-type-icon.tsx +68 -0
- package/src/spree/form-actions.tsx +2 -3
- package/src/spree/form-section.tsx +46 -0
- package/src/spree/fulfill-items-form.tsx +282 -0
- package/src/spree/fulfillment-edit-dialog.tsx +211 -0
- package/src/spree/fulfillment-item-list.tsx +61 -0
- package/src/spree/fulfillment-panel.tsx +53 -0
- package/src/spree/icons.ts +167 -0
- package/src/spree/json-preview-drawer.tsx +4 -4
- package/src/spree/label-upload-dialog.tsx +195 -0
- package/src/spree/language-menu-items.tsx +37 -30
- package/src/spree/metadata/metadata-card.tsx +1 -1
- package/src/spree/order-cancel-dialog.tsx +149 -0
- package/src/spree/post-sale-create-dialogs.tsx +253 -0
- package/src/spree/post-sale-fields.tsx +133 -0
- package/src/spree/product-membership-list.tsx +453 -0
- package/src/spree/products/status-card.tsx +79 -0
- package/src/spree/quantity-tier-editor.tsx +162 -0
- package/src/spree/resource-combobox.tsx +12 -1
- package/src/spree/resource-name-cell.tsx +21 -4
- package/src/spree/return-dialogs.tsx +238 -0
- package/src/spree/route-error-boundary.tsx +1 -1
- package/src/spree/row-actions.tsx +2 -2
- package/src/spree/search-input.tsx +76 -0
- package/src/spree/secret-input.tsx +26 -40
- package/src/spree/tax-identifiers-card.tsx +418 -0
- package/src/spree/theme-toggle.tsx +36 -23
- package/src/spree/wizard.tsx +254 -0
- package/src/styles.css +750 -75
- package/src/ui/accordion.tsx +83 -0
- package/src/ui/alert.tsx +79 -0
- package/src/ui/attachment.tsx +1 -1
- package/src/ui/badge.tsx +198 -56
- package/src/ui/breadcrumb.tsx +1 -1
- package/src/ui/button.tsx +22 -10
- package/src/ui/calendar.tsx +1 -1
- package/src/ui/card.tsx +52 -6
- package/src/ui/checkbox.tsx +13 -5
- package/src/ui/combobox.tsx +125 -6
- package/src/ui/command.tsx +149 -23
- package/src/ui/context-menu.tsx +99 -0
- package/src/ui/data-table.tsx +448 -9
- package/src/ui/date-picker.tsx +18 -3
- package/src/ui/date-range-picker.tsx +47 -60
- package/src/ui/dialog.tsx +6 -6
- package/src/ui/dropdown-menu.tsx +18 -11
- package/src/ui/field.tsx +10 -2
- package/src/ui/input-group.tsx +1 -1
- package/src/ui/input.tsx +1 -1
- package/src/ui/map.tsx +2110 -0
- package/src/ui/media-preview.tsx +105 -0
- package/src/ui/pagination.tsx +19 -11
- package/src/ui/popover.tsx +4 -2
- package/src/ui/progress.tsx +5 -2
- package/src/ui/radio-group.tsx +4 -4
- package/src/ui/rich-text-editor.tsx +214 -88
- package/src/ui/scroll-area.tsx +65 -0
- package/src/ui/select.tsx +17 -10
- package/src/ui/separator.tsx +1 -1
- package/src/ui/sheet.tsx +49 -7
- package/src/ui/sidebar.tsx +138 -37
- package/src/ui/skeleton.tsx +3 -1
- package/src/ui/tabs.tsx +84 -0
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/thumbnail.tsx +80 -0
- package/src/ui/toast.tsx +112 -0
- package/src/ui/tooltip.tsx +3 -1
- package/src/ui/sonner.tsx +0 -47
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import type { UseFormReturn } from 'react-hook-form'
|
|
2
|
+
import { Controller } from 'react-hook-form'
|
|
3
|
+
import { useTranslation } from 'react-i18next'
|
|
4
|
+
import { cn } from '../lib/utils'
|
|
5
|
+
import { Alert, AlertDescription } from '../ui/alert'
|
|
6
|
+
import { Button } from '../ui/button'
|
|
7
|
+
import { Checkbox } from '../ui/checkbox'
|
|
8
|
+
import { Field, FieldLabel } from '../ui/field'
|
|
9
|
+
import { Input } from '../ui/input'
|
|
10
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
|
11
|
+
import { Thumbnail } from '../ui/thumbnail'
|
|
12
|
+
import { PackageIcon, TriangleAlertIcon } from './icons'
|
|
13
|
+
|
|
14
|
+
/** A pickable row: what it looks like and how many units are on offer. */
|
|
15
|
+
export type FulfillableRowData = {
|
|
16
|
+
key: string
|
|
17
|
+
itemId: string
|
|
18
|
+
name: string
|
|
19
|
+
optionsText?: string | null
|
|
20
|
+
thumbnailUrl?: string | null
|
|
21
|
+
quantity: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The form shape both panels bind — see the shared fulfillment schema. */
|
|
25
|
+
export type FulfillItemsValues = {
|
|
26
|
+
items: Array<{ item_id: string; selected: boolean; quantity: number }>
|
|
27
|
+
tracking: string
|
|
28
|
+
tracking_carrier: string
|
|
29
|
+
notify_customer: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function ItemQuantityRow({
|
|
33
|
+
row,
|
|
34
|
+
index,
|
|
35
|
+
selected,
|
|
36
|
+
idPrefix,
|
|
37
|
+
form,
|
|
38
|
+
}: {
|
|
39
|
+
row: FulfillableRowData
|
|
40
|
+
index: number
|
|
41
|
+
selected: boolean
|
|
42
|
+
/** Scopes the field ids, since an order can show several of these forms. */
|
|
43
|
+
idPrefix: string
|
|
44
|
+
form: UseFormReturn<FulfillItemsValues>
|
|
45
|
+
}) {
|
|
46
|
+
const { t } = useTranslation()
|
|
47
|
+
const checkboxId = `${idPrefix}-item-${index}`
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
className={cn(
|
|
52
|
+
'my-2 flex items-center gap-3 rounded-lg py-2 pl-2',
|
|
53
|
+
selected && 'bg-accent-strong',
|
|
54
|
+
)}
|
|
55
|
+
>
|
|
56
|
+
<Controller
|
|
57
|
+
control={form.control}
|
|
58
|
+
name={`items.${index}.selected`}
|
|
59
|
+
render={({ field }) => (
|
|
60
|
+
<Checkbox
|
|
61
|
+
id={checkboxId}
|
|
62
|
+
checked={field.value}
|
|
63
|
+
aria-label={t('admin.orders.fulfill.include_item', { name: row.name })}
|
|
64
|
+
onCheckedChange={(checked) => {
|
|
65
|
+
const include = checked === true
|
|
66
|
+
field.onChange(include)
|
|
67
|
+
// Re-checking restores the full held quantity, so the merchant
|
|
68
|
+
// never has to retype what they just excluded.
|
|
69
|
+
form.setValue(`items.${index}.quantity`, include ? row.quantity : 0, {
|
|
70
|
+
shouldDirty: true,
|
|
71
|
+
})
|
|
72
|
+
}}
|
|
73
|
+
/>
|
|
74
|
+
)}
|
|
75
|
+
/>
|
|
76
|
+
|
|
77
|
+
<Thumbnail src={row.thumbnailUrl} fallback={<PackageIcon />} />
|
|
78
|
+
|
|
79
|
+
<div className="min-w-0 flex-1">
|
|
80
|
+
<div className="truncate font-medium text-sm">{row.name}</div>
|
|
81
|
+
{row.optionsText && (
|
|
82
|
+
<div className="truncate text-muted-foreground text-xs">{row.optionsText}</div>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<Controller
|
|
87
|
+
control={form.control}
|
|
88
|
+
name={`items.${index}.quantity`}
|
|
89
|
+
render={({ field }) => (
|
|
90
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
91
|
+
<Input
|
|
92
|
+
type="number"
|
|
93
|
+
min={0}
|
|
94
|
+
max={row.quantity}
|
|
95
|
+
className="w-20 text-right"
|
|
96
|
+
disabled={!selected}
|
|
97
|
+
aria-label={t('admin.orders.fulfill.quantity_for', { name: row.name })}
|
|
98
|
+
value={field.value}
|
|
99
|
+
onChange={(event) => {
|
|
100
|
+
const parsed = Number(event.target.value)
|
|
101
|
+
const quantity = Number.isFinite(parsed)
|
|
102
|
+
? Math.max(0, Math.min(parsed, row.quantity))
|
|
103
|
+
: 0
|
|
104
|
+
field.onChange(quantity)
|
|
105
|
+
// Typing the quantity down to zero is the same statement as
|
|
106
|
+
// unticking the row, so keep the two in step.
|
|
107
|
+
form.setValue(`items.${index}.selected`, quantity > 0, { shouldDirty: true })
|
|
108
|
+
}}
|
|
109
|
+
onBlur={field.onBlur}
|
|
110
|
+
/>
|
|
111
|
+
<span className="w-16 whitespace-nowrap text-muted-foreground text-sm">
|
|
112
|
+
{t('admin.orders.fulfill.of_units', { count: row.quantity })}
|
|
113
|
+
</span>
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
116
|
+
/>
|
|
117
|
+
</div>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Picks what to ship out of one fulfillment, in place of the card's normal
|
|
123
|
+
* contents. Shipping everything is the default; lowering a quantity or
|
|
124
|
+
* unticking an item splits the chosen units into a new fulfillment that ships,
|
|
125
|
+
* leaving the rest of this one open.
|
|
126
|
+
*
|
|
127
|
+
* Shared by the operator's order page and the seller's. Headless: the caller
|
|
128
|
+
* owns the form instance and the submit, so each panel keeps its own mutation
|
|
129
|
+
* and its own idea of who may ship — this holds the shape and the
|
|
130
|
+
* select/quantity coupling so the two cannot drift.
|
|
131
|
+
*/
|
|
132
|
+
export function FulfillItemsForm({
|
|
133
|
+
form,
|
|
134
|
+
rows,
|
|
135
|
+
idPrefix,
|
|
136
|
+
carrierOptions,
|
|
137
|
+
onSubmit,
|
|
138
|
+
onCancel,
|
|
139
|
+
canShipWholeParcel = false,
|
|
140
|
+
pending = false,
|
|
141
|
+
}: {
|
|
142
|
+
form: UseFormReturn<FulfillItemsValues>
|
|
143
|
+
rows: FulfillableRowData[]
|
|
144
|
+
idPrefix: string
|
|
145
|
+
/** Carriers to choose between; the first is normally "detect from number". */
|
|
146
|
+
carrierOptions: Array<{ value: string; label: string }>
|
|
147
|
+
onSubmit: (values: FulfillItemsValues) => void | Promise<void>
|
|
148
|
+
onCancel: () => void
|
|
149
|
+
/**
|
|
150
|
+
* Whether the parcel can ship with nothing picked.
|
|
151
|
+
*
|
|
152
|
+
* True when it holds items that no row can address — the workflow reads an
|
|
153
|
+
* empty selection as "everything", so blocking submit would leave such a
|
|
154
|
+
* parcel unshippable. Never inferred from `rows` being empty here: that
|
|
155
|
+
* cannot tell an unaddressable parcel from an empty one, and an empty
|
|
156
|
+
* fulfillment must stay unsubmittable.
|
|
157
|
+
*/
|
|
158
|
+
canShipWholeParcel?: boolean
|
|
159
|
+
pending?: boolean
|
|
160
|
+
}) {
|
|
161
|
+
const { t } = useTranslation()
|
|
162
|
+
const { errors } = form.formState
|
|
163
|
+
|
|
164
|
+
const watched = form.watch('items')
|
|
165
|
+
const totalSelected = watched.reduce(
|
|
166
|
+
(sum, item) => sum + (item.selected ? item.quantity || 0 : 0),
|
|
167
|
+
0,
|
|
168
|
+
)
|
|
169
|
+
const totalAvailable = rows.reduce((sum, row) => sum + row.quantity, 0)
|
|
170
|
+
const shipsEverything = totalSelected === totalAvailable
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col">
|
|
174
|
+
{errors.root?.message && (
|
|
175
|
+
<p className="text-destructive text-sm" role="alert">
|
|
176
|
+
{errors.root.message}
|
|
177
|
+
</p>
|
|
178
|
+
)}
|
|
179
|
+
|
|
180
|
+
{rows.length === 0 ? (
|
|
181
|
+
<p className="py-6 text-center text-muted-foreground">{t('admin.orders.fulfill.empty')}</p>
|
|
182
|
+
) : (
|
|
183
|
+
<div className="divide-y">
|
|
184
|
+
{rows.map((row, index) => (
|
|
185
|
+
<ItemQuantityRow
|
|
186
|
+
key={row.key}
|
|
187
|
+
row={row}
|
|
188
|
+
index={index}
|
|
189
|
+
selected={watched[index]?.selected ?? true}
|
|
190
|
+
idPrefix={idPrefix}
|
|
191
|
+
form={form}
|
|
192
|
+
/>
|
|
193
|
+
))}
|
|
194
|
+
</div>
|
|
195
|
+
)}
|
|
196
|
+
|
|
197
|
+
<div className="grid grid-cols-1 gap-4 border-border-subtle border-t py-3 sm:grid-cols-2">
|
|
198
|
+
<Field>
|
|
199
|
+
<FieldLabel htmlFor={`${idPrefix}-tracking`}>
|
|
200
|
+
{t('admin.orders.fulfill.tracking_label')}
|
|
201
|
+
</FieldLabel>
|
|
202
|
+
<Input
|
|
203
|
+
id={`${idPrefix}-tracking`}
|
|
204
|
+
placeholder={t('admin.orders.fulfill.tracking_placeholder')}
|
|
205
|
+
{...form.register('tracking')}
|
|
206
|
+
/>
|
|
207
|
+
</Field>
|
|
208
|
+
|
|
209
|
+
<Field>
|
|
210
|
+
<FieldLabel htmlFor={`${idPrefix}-tracking-carrier`}>
|
|
211
|
+
{t('admin.orders.detail.fulfillments.carrier_label')}
|
|
212
|
+
</FieldLabel>
|
|
213
|
+
<Controller
|
|
214
|
+
control={form.control}
|
|
215
|
+
name="tracking_carrier"
|
|
216
|
+
render={({ field }) => (
|
|
217
|
+
<Select
|
|
218
|
+
items={carrierOptions}
|
|
219
|
+
value={field.value}
|
|
220
|
+
onValueChange={(value) => field.onChange(value ?? '')}
|
|
221
|
+
>
|
|
222
|
+
<SelectTrigger id={`${idPrefix}-tracking-carrier`}>
|
|
223
|
+
<SelectValue />
|
|
224
|
+
</SelectTrigger>
|
|
225
|
+
<SelectContent>
|
|
226
|
+
{carrierOptions.map((option) => (
|
|
227
|
+
<SelectItem key={option.value} value={option.value}>
|
|
228
|
+
{option.label}
|
|
229
|
+
</SelectItem>
|
|
230
|
+
))}
|
|
231
|
+
</SelectContent>
|
|
232
|
+
</Select>
|
|
233
|
+
)}
|
|
234
|
+
/>
|
|
235
|
+
</Field>
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
<Controller
|
|
239
|
+
control={form.control}
|
|
240
|
+
name="notify_customer"
|
|
241
|
+
render={({ field }) => (
|
|
242
|
+
<label
|
|
243
|
+
htmlFor={`${idPrefix}-notify`}
|
|
244
|
+
className="flex cursor-pointer items-center gap-2 py-3 text-sm"
|
|
245
|
+
>
|
|
246
|
+
<Checkbox
|
|
247
|
+
id={`${idPrefix}-notify`}
|
|
248
|
+
checked={field.value}
|
|
249
|
+
onCheckedChange={(checked) => field.onChange(checked === true)}
|
|
250
|
+
/>
|
|
251
|
+
{t('admin.orders.fulfill.notify_customer')}
|
|
252
|
+
</label>
|
|
253
|
+
)}
|
|
254
|
+
/>
|
|
255
|
+
|
|
256
|
+
{!shipsEverything && totalSelected > 0 && (
|
|
257
|
+
<div className="px-3 pb-3">
|
|
258
|
+
<Alert variant="warning">
|
|
259
|
+
<TriangleAlertIcon />
|
|
260
|
+
<AlertDescription>{t('admin.orders.fulfill.partial_hint')}</AlertDescription>
|
|
261
|
+
</Alert>
|
|
262
|
+
</div>
|
|
263
|
+
)}
|
|
264
|
+
|
|
265
|
+
<div className="flex items-center justify-end gap-2 border-border-subtle border-t py-3">
|
|
266
|
+
<span className="mr-auto text-muted-foreground text-sm">
|
|
267
|
+
{t('admin.orders.fulfill.units_selected')}: {totalSelected} / {totalAvailable}
|
|
268
|
+
</span>
|
|
269
|
+
<Button type="button" size="sm" variant="outline" onClick={onCancel}>
|
|
270
|
+
{t('admin.actions.cancel')}
|
|
271
|
+
</Button>
|
|
272
|
+
<Button
|
|
273
|
+
type="submit"
|
|
274
|
+
size="sm"
|
|
275
|
+
disabled={(totalSelected === 0 && !canShipWholeParcel) || pending}
|
|
276
|
+
>
|
|
277
|
+
{pending ? t('admin.actions.saving') : t('admin.orders.fulfill.submit')}
|
|
278
|
+
</Button>
|
|
279
|
+
</div>
|
|
280
|
+
</form>
|
|
281
|
+
)
|
|
282
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
import { Button } from '../ui/button'
|
|
4
|
+
import {
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogBody,
|
|
7
|
+
DialogContent,
|
|
8
|
+
DialogDescription,
|
|
9
|
+
DialogFooter,
|
|
10
|
+
DialogHeader,
|
|
11
|
+
DialogTitle,
|
|
12
|
+
} from '../ui/dialog'
|
|
13
|
+
import { Field, FieldError, FieldLabel } from '../ui/field'
|
|
14
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
|
15
|
+
|
|
16
|
+
/** A shelf a parcel can ship from, already labelled by the caller. */
|
|
17
|
+
export type FulfillmentOriginOption = {
|
|
18
|
+
value: string
|
|
19
|
+
label: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A priced service the parcel can be carried by, already labelled. */
|
|
23
|
+
export type FulfillmentRateOption = {
|
|
24
|
+
value: string
|
|
25
|
+
label: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type FulfillmentEditValues = {
|
|
29
|
+
stockLocationId: string
|
|
30
|
+
selectedDeliveryRateId: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Where a parcel ships from and which priced service carries it.
|
|
35
|
+
*
|
|
36
|
+
* The two fields are coupled: rates are quoted against an origin, so moving
|
|
37
|
+
* the parcel invalidates the list on screen. Rather than offering stale
|
|
38
|
+
* options, a save that changes the origin writes the location first and the
|
|
39
|
+
* method field stands down until the caller re-quotes — which is why
|
|
40
|
+
* `onSubmit` reports *which* fields changed and the caller decides the order
|
|
41
|
+
* of writes.
|
|
42
|
+
*
|
|
43
|
+
* Headless: both panels label their own options (the operator marks shelves
|
|
44
|
+
* that cannot cover the parcel; a seller sees only their own), and both own
|
|
45
|
+
* their mutations. This holds the shape and the coupling rule so the two
|
|
46
|
+
* cannot drift.
|
|
47
|
+
*/
|
|
48
|
+
export function FulfillmentEditDialog({
|
|
49
|
+
open,
|
|
50
|
+
onOpenChange,
|
|
51
|
+
originOptions,
|
|
52
|
+
rateOptions,
|
|
53
|
+
currentOriginId,
|
|
54
|
+
currentRateId,
|
|
55
|
+
onSubmit,
|
|
56
|
+
pending = false,
|
|
57
|
+
errorMessage,
|
|
58
|
+
}: {
|
|
59
|
+
open: boolean
|
|
60
|
+
onOpenChange: (open: boolean) => void
|
|
61
|
+
originOptions: FulfillmentOriginOption[]
|
|
62
|
+
rateOptions: FulfillmentRateOption[]
|
|
63
|
+
currentOriginId: string
|
|
64
|
+
currentRateId: string
|
|
65
|
+
/**
|
|
66
|
+
* Applies the change. Returning `'requote'` keeps the dialog open and
|
|
67
|
+
* re-seeds it from the caller's refreshed record — the origin moved, so the
|
|
68
|
+
* rates on screen described a route the parcel has left.
|
|
69
|
+
*/
|
|
70
|
+
onSubmit: (
|
|
71
|
+
values: FulfillmentEditValues,
|
|
72
|
+
changed: { origin: boolean; rate: boolean },
|
|
73
|
+
) => Promise<'requote' | 'done'>
|
|
74
|
+
pending?: boolean
|
|
75
|
+
errorMessage?: string
|
|
76
|
+
}) {
|
|
77
|
+
const { t } = useTranslation()
|
|
78
|
+
|
|
79
|
+
const [originId, setOriginId] = useState(currentOriginId)
|
|
80
|
+
const [rateId, setRateId] = useState(currentRateId)
|
|
81
|
+
|
|
82
|
+
// A re-quote lands as a changed record: the caller has moved the parcel and
|
|
83
|
+
// the server has re-selected a method for the new origin. Adopt that pick,
|
|
84
|
+
// so the second save confirms a rate that actually applies rather than
|
|
85
|
+
// re-sending the one quoted for the origin the parcel just left.
|
|
86
|
+
const seededOrigin = useRef(currentOriginId)
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (seededOrigin.current === currentOriginId) return
|
|
89
|
+
seededOrigin.current = currentOriginId
|
|
90
|
+
setOriginId(currentOriginId)
|
|
91
|
+
setRateId(currentRateId)
|
|
92
|
+
}, [currentOriginId, currentRateId])
|
|
93
|
+
|
|
94
|
+
// The rates on the record were quoted for the origin it currently has. Once
|
|
95
|
+
// a different one is picked they describe a route no longer on offer, so the
|
|
96
|
+
// method field stands down until the server re-quotes.
|
|
97
|
+
const originMoved = originId !== currentOriginId
|
|
98
|
+
const originMissing = originId.length === 0
|
|
99
|
+
|
|
100
|
+
async function handleSubmit(event: React.FormEvent) {
|
|
101
|
+
// The order page renders its cards inside their own forms — without
|
|
102
|
+
// stopping the bubble the browser submits the outer one.
|
|
103
|
+
event.preventDefault()
|
|
104
|
+
event.stopPropagation()
|
|
105
|
+
if (originMissing) return
|
|
106
|
+
|
|
107
|
+
const outcome = await onSubmit(
|
|
108
|
+
{ stockLocationId: originId, selectedDeliveryRateId: rateId },
|
|
109
|
+
{ origin: originMoved, rate: rateId !== currentRateId },
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if (outcome === 'done') onOpenChange(false)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
117
|
+
<DialogContent>
|
|
118
|
+
<form onSubmit={handleSubmit}>
|
|
119
|
+
<DialogHeader>
|
|
120
|
+
<DialogTitle>{t('admin.orders.detail.fulfillments.edit_title')}</DialogTitle>
|
|
121
|
+
<DialogDescription>
|
|
122
|
+
{t('admin.orders.detail.fulfillments.edit_description')}
|
|
123
|
+
</DialogDescription>
|
|
124
|
+
</DialogHeader>
|
|
125
|
+
|
|
126
|
+
<DialogBody className="flex flex-col gap-4">
|
|
127
|
+
{errorMessage && (
|
|
128
|
+
<p className="text-sm text-destructive" role="alert">
|
|
129
|
+
{errorMessage}
|
|
130
|
+
</p>
|
|
131
|
+
)}
|
|
132
|
+
|
|
133
|
+
<Field>
|
|
134
|
+
<FieldLabel htmlFor="fulfillment-location">
|
|
135
|
+
{t('admin.orders.detail.fulfillments.ships_from')}
|
|
136
|
+
</FieldLabel>
|
|
137
|
+
<Select
|
|
138
|
+
items={originOptions}
|
|
139
|
+
value={originId}
|
|
140
|
+
onValueChange={(value) => setOriginId((value as string) ?? '')}
|
|
141
|
+
>
|
|
142
|
+
<SelectTrigger id="fulfillment-location">
|
|
143
|
+
<SelectValue placeholder={t('admin.common.select_placeholder')} />
|
|
144
|
+
</SelectTrigger>
|
|
145
|
+
<SelectContent>
|
|
146
|
+
{originOptions.map((option) => (
|
|
147
|
+
<SelectItem key={option.value} value={option.value}>
|
|
148
|
+
{option.label}
|
|
149
|
+
</SelectItem>
|
|
150
|
+
))}
|
|
151
|
+
</SelectContent>
|
|
152
|
+
</Select>
|
|
153
|
+
{originMissing && <FieldError>{t('admin.errors.required')}</FieldError>}
|
|
154
|
+
</Field>
|
|
155
|
+
|
|
156
|
+
<Field>
|
|
157
|
+
<FieldLabel htmlFor="fulfillment-rate">
|
|
158
|
+
{t('admin.orders.detail.fulfillments.delivery_method')}
|
|
159
|
+
</FieldLabel>
|
|
160
|
+
<Select
|
|
161
|
+
items={rateOptions}
|
|
162
|
+
value={rateId}
|
|
163
|
+
disabled={originMoved || rateOptions.length === 0}
|
|
164
|
+
onValueChange={(value) => setRateId((value as string) ?? '')}
|
|
165
|
+
>
|
|
166
|
+
<SelectTrigger id="fulfillment-rate">
|
|
167
|
+
<SelectValue placeholder={t('admin.common.select_placeholder')} />
|
|
168
|
+
</SelectTrigger>
|
|
169
|
+
<SelectContent>
|
|
170
|
+
{rateOptions.map((option) => (
|
|
171
|
+
<SelectItem key={option.value} value={option.value}>
|
|
172
|
+
{option.label}
|
|
173
|
+
</SelectItem>
|
|
174
|
+
))}
|
|
175
|
+
</SelectContent>
|
|
176
|
+
</Select>
|
|
177
|
+
{originMoved && (
|
|
178
|
+
<span className="text-muted-foreground text-xs">
|
|
179
|
+
{t('admin.orders.detail.fulfillments.rates_pending_origin')}
|
|
180
|
+
</span>
|
|
181
|
+
)}
|
|
182
|
+
{!originMoved && rateOptions.length === 0 && (
|
|
183
|
+
<span className="text-muted-foreground text-xs">
|
|
184
|
+
{t('admin.orders.detail.fulfillments.no_rates')}
|
|
185
|
+
</span>
|
|
186
|
+
)}
|
|
187
|
+
</Field>
|
|
188
|
+
</DialogBody>
|
|
189
|
+
|
|
190
|
+
<DialogFooter>
|
|
191
|
+
<Button
|
|
192
|
+
type="button"
|
|
193
|
+
variant="outline"
|
|
194
|
+
onClick={() => onOpenChange(false)}
|
|
195
|
+
disabled={pending}
|
|
196
|
+
>
|
|
197
|
+
{t('admin.actions.cancel')}
|
|
198
|
+
</Button>
|
|
199
|
+
<Button type="submit" disabled={pending || originMissing}>
|
|
200
|
+
{pending
|
|
201
|
+
? t('admin.actions.saving')
|
|
202
|
+
: originMoved
|
|
203
|
+
? t('admin.orders.detail.fulfillments.move_and_requote')
|
|
204
|
+
: t('admin.actions.save')}
|
|
205
|
+
</Button>
|
|
206
|
+
</DialogFooter>
|
|
207
|
+
</form>
|
|
208
|
+
</DialogContent>
|
|
209
|
+
</Dialog>
|
|
210
|
+
)
|
|
211
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Thumbnail } from '../ui/thumbnail'
|
|
2
|
+
import { PackageIcon } from './icons'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One row of a fulfillment's item list, already joined to its line item by
|
|
6
|
+
* the caller. The image and the price are optional: a surface that does not
|
|
7
|
+
* expose them renders the name and the count alone.
|
|
8
|
+
*/
|
|
9
|
+
export type FulfillmentItemRowData = {
|
|
10
|
+
key: string
|
|
11
|
+
name: string
|
|
12
|
+
optionsText?: string | null
|
|
13
|
+
thumbnailUrl?: string | null
|
|
14
|
+
displayPrice?: string | null
|
|
15
|
+
quantity: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What a fulfillment holds: what it looks like, what it costs and how many
|
|
20
|
+
* units travel together.
|
|
21
|
+
*
|
|
22
|
+
* Read-only — changing what an order contains after placement belongs to the
|
|
23
|
+
* order edit screen, not here. Shared by the operator's order page and the
|
|
24
|
+
* seller's so a parcel's contents read the same on both.
|
|
25
|
+
*/
|
|
26
|
+
function ItemRow({ row }: { row: FulfillmentItemRowData }) {
|
|
27
|
+
return (
|
|
28
|
+
<div className="flex items-center gap-3 p-3">
|
|
29
|
+
<Thumbnail src={row.thumbnailUrl} fallback={<PackageIcon />} />
|
|
30
|
+
|
|
31
|
+
<div className="min-w-0 flex-1">
|
|
32
|
+
<div className="truncate text-sm font-medium">{row.name}</div>
|
|
33
|
+
{row.optionsText && (
|
|
34
|
+
<div className="truncate text-xs text-muted-foreground">{row.optionsText}</div>
|
|
35
|
+
)}
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div className="shrink-0 whitespace-nowrap text-right text-muted-foreground text-sm">
|
|
39
|
+
{row.displayPrice ? (
|
|
40
|
+
<span>
|
|
41
|
+
{row.displayPrice} × {row.quantity}
|
|
42
|
+
</span>
|
|
43
|
+
) : (
|
|
44
|
+
<span>× {row.quantity}</span>
|
|
45
|
+
)}
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function FulfillmentItemList({ rows }: { rows: FulfillmentItemRowData[] }) {
|
|
52
|
+
if (rows.length === 0) return null
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="divide-y">
|
|
56
|
+
{rows.map((row) => (
|
|
57
|
+
<ItemRow key={row.key} row={row} />
|
|
58
|
+
))}
|
|
59
|
+
</div>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { StatusBadge } from '../ui/badge'
|
|
3
|
+
import { Card, CardAction, CardHeader, CardTitle } from '../ui/card'
|
|
4
|
+
import { MapPinIcon } from './icons'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The nested card one parcel occupies on an order page: what state it is in,
|
|
8
|
+
* where it ships from, and its own menu.
|
|
9
|
+
*
|
|
10
|
+
* Shared by the operator's order page and the seller's, so a parcel reads the
|
|
11
|
+
* same on both sides of a marketplace. Headless — the status label and every
|
|
12
|
+
* action come from the caller.
|
|
13
|
+
*/
|
|
14
|
+
export function FulfillmentPanel({
|
|
15
|
+
status,
|
|
16
|
+
statusLabel,
|
|
17
|
+
location,
|
|
18
|
+
meta,
|
|
19
|
+
actions,
|
|
20
|
+
children,
|
|
21
|
+
}: {
|
|
22
|
+
status: string
|
|
23
|
+
/** Omit to let the badge humanize the status itself. */
|
|
24
|
+
statusLabel?: string
|
|
25
|
+
/** Where it ships from. Absent until a fulfillment exists. */
|
|
26
|
+
location?: string | null
|
|
27
|
+
/** Trailing header detail, e.g. when it shipped. */
|
|
28
|
+
meta?: ReactNode
|
|
29
|
+
/** The panel's own menu. Absent when there is nothing to act on. */
|
|
30
|
+
actions?: ReactNode
|
|
31
|
+
children: ReactNode
|
|
32
|
+
}) {
|
|
33
|
+
return (
|
|
34
|
+
<Card variant="nested">
|
|
35
|
+
<CardHeader>
|
|
36
|
+
<CardTitle className="min-w-0 font-normal text-sm">
|
|
37
|
+
<StatusBadge status={status} label={statusLabel} />
|
|
38
|
+
{location && (
|
|
39
|
+
<div className="flex min-w-0 items-center gap-1.5 text-muted-foreground text-xs">
|
|
40
|
+
<MapPinIcon className="size-3 shrink-0" />
|
|
41
|
+
{/* The card clips its overflow, so a long warehouse name has to
|
|
42
|
+
truncate here or it is simply cut off. */}
|
|
43
|
+
<span className="truncate">{location}</span>
|
|
44
|
+
</div>
|
|
45
|
+
)}
|
|
46
|
+
{meta}
|
|
47
|
+
</CardTitle>
|
|
48
|
+
{actions && <CardAction>{actions}</CardAction>}
|
|
49
|
+
</CardHeader>
|
|
50
|
+
{children}
|
|
51
|
+
</Card>
|
|
52
|
+
)
|
|
53
|
+
}
|