@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,434 @@
|
|
|
1
|
+
import type { ColumnDef } from '@tanstack/react-table'
|
|
2
|
+
import { useMemo } from 'react'
|
|
3
|
+
import { DataGrid, DecimalCell, ReadOnlyCell, SelectCell, SwitchCell, TextCell } from './data-grid'
|
|
4
|
+
|
|
5
|
+
export interface BulkVariantsRow {
|
|
6
|
+
id: string
|
|
7
|
+
variantLabel?: string | null
|
|
8
|
+
sku?: string | null
|
|
9
|
+
barcode?: string | null
|
|
10
|
+
// Display strings — decimal weight/dimension values, or null for unset.
|
|
11
|
+
weight?: string | null
|
|
12
|
+
weightUnit?: string | null
|
|
13
|
+
height?: string | null
|
|
14
|
+
width?: string | null
|
|
15
|
+
depth?: string | null
|
|
16
|
+
dimensionsUnit?: string | null
|
|
17
|
+
preorderable?: boolean
|
|
18
|
+
backorderLimit?: string | null
|
|
19
|
+
taxCategoryId?: string | null
|
|
20
|
+
hsCode?: string | null
|
|
21
|
+
/** ISO 3166-1 alpha-2 code, never a country record id. */
|
|
22
|
+
countryOfOrigin?: string | null
|
|
23
|
+
customsDescription?: string | null
|
|
24
|
+
unitsPerCarton?: string | null
|
|
25
|
+
cartonPackageTypeId?: string | null
|
|
26
|
+
cartonWeight?: string | null
|
|
27
|
+
cartonsPerPallet?: string | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type BulkVariantsField =
|
|
31
|
+
| 'sku'
|
|
32
|
+
| 'barcode'
|
|
33
|
+
| 'weight'
|
|
34
|
+
| 'weightUnit'
|
|
35
|
+
| 'height'
|
|
36
|
+
| 'width'
|
|
37
|
+
| 'depth'
|
|
38
|
+
| 'dimensionsUnit'
|
|
39
|
+
| 'preorderable'
|
|
40
|
+
| 'backorderLimit'
|
|
41
|
+
| 'taxCategoryId'
|
|
42
|
+
| 'hsCode'
|
|
43
|
+
| 'countryOfOrigin'
|
|
44
|
+
| 'customsDescription'
|
|
45
|
+
| 'unitsPerCarton'
|
|
46
|
+
| 'cartonPackageTypeId'
|
|
47
|
+
| 'cartonWeight'
|
|
48
|
+
| 'cartonsPerPallet'
|
|
49
|
+
|
|
50
|
+
export type BulkVariantsChange =
|
|
51
|
+
| { field: Exclude<BulkVariantsField, 'preorderable'>; value: string | null }
|
|
52
|
+
| { field: 'preorderable'; value: boolean }
|
|
53
|
+
|
|
54
|
+
export interface BulkVariantsTableLabels {
|
|
55
|
+
/** Column headers. */
|
|
56
|
+
variant: string
|
|
57
|
+
sku: string
|
|
58
|
+
barcode: string
|
|
59
|
+
weight: string
|
|
60
|
+
weightUnit: string
|
|
61
|
+
height: string
|
|
62
|
+
width: string
|
|
63
|
+
depth: string
|
|
64
|
+
dimensionsUnit: string
|
|
65
|
+
preorderable: string
|
|
66
|
+
backorderLimit: string
|
|
67
|
+
taxCategory: string
|
|
68
|
+
hsCode: string
|
|
69
|
+
countryOfOrigin: string
|
|
70
|
+
customsDescription: string
|
|
71
|
+
unitsPerCarton: string
|
|
72
|
+
cartonPackageType: string
|
|
73
|
+
cartonWeight: string
|
|
74
|
+
cartonsPerPallet: string
|
|
75
|
+
/** Placeholder for a variant row with no options text (e.g. "Default"). */
|
|
76
|
+
variantDefault: string
|
|
77
|
+
/** Shown in unit cells with no explicit value ("inherit the default"). */
|
|
78
|
+
unitDefault: string
|
|
79
|
+
/** Shown in the tax-category cell when none is set. */
|
|
80
|
+
taxCategoryNone: string
|
|
81
|
+
/** Shown in the country-of-origin cell when none is set. */
|
|
82
|
+
countryOfOriginNone: string
|
|
83
|
+
/** Shown in the carton cell when the variant is packed into none. */
|
|
84
|
+
cartonPackageTypeNone: string
|
|
85
|
+
/** Aria label on the DataGrid for screen readers. */
|
|
86
|
+
gridAriaLabel?: string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface BulkVariantsTableProps {
|
|
90
|
+
rows: BulkVariantsRow[]
|
|
91
|
+
/** Localized strings — the caller owns translations. */
|
|
92
|
+
labels: BulkVariantsTableLabels
|
|
93
|
+
weightUnitOptions: ReadonlyArray<{ value: string; label: string }>
|
|
94
|
+
dimensionUnitOptions: ReadonlyArray<{ value: string; label: string }>
|
|
95
|
+
/** Omit to hide the tax-category column (no tax categories configured). */
|
|
96
|
+
taxCategoryOptions?: ReadonlyArray<{ value: string; label: string }>
|
|
97
|
+
/**
|
|
98
|
+
* The store's cartons. Omit to hide the packing columns entirely. The
|
|
99
|
+
* optional `hint` carries each carton's size, so two similarly named ones
|
|
100
|
+
* are told apart without leaving the sheet.
|
|
101
|
+
*/
|
|
102
|
+
cartonOptions?: ReadonlyArray<{ value: string; label: string; hint?: string }>
|
|
103
|
+
/**
|
|
104
|
+
* Countries as `{ value: ISO, label: name }`. Omit to hide the customs
|
|
105
|
+
* columns entirely (e.g. the country list has not loaded yet). Values are
|
|
106
|
+
* ISO codes so the stored data survives the countries table going away.
|
|
107
|
+
*/
|
|
108
|
+
countryOptions?: ReadonlyArray<{ value: string; label: string }>
|
|
109
|
+
/** Called when the user commits a value to a cell. The id is the row id. */
|
|
110
|
+
onChange: (rowId: string, change: BulkVariantsChange) => void
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Decimal columns in grid order, paired with their fixed keyboard column
|
|
114
|
+
// coordinate (see the coordinate map in the component body).
|
|
115
|
+
const DECIMAL_COLUMNS: ReadonlyArray<{
|
|
116
|
+
field: 'weight' | 'height' | 'width' | 'depth'
|
|
117
|
+
col: number
|
|
118
|
+
}> = [
|
|
119
|
+
{ field: 'weight', col: 3 },
|
|
120
|
+
{ field: 'height', col: 5 },
|
|
121
|
+
{ field: 'width', col: 6 },
|
|
122
|
+
{ field: 'depth', col: 7 },
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Presentational primitive for a "spreadsheet of variant fields" UI: SKU,
|
|
127
|
+
* barcode, shipping measurements + units, availability, tax category.
|
|
128
|
+
* Selection, copy/paste, and drag-fill come from the DataGrid. Owns NO data
|
|
129
|
+
* fetching, edit tracking, or save logic — the caller projects its rows into
|
|
130
|
+
* BulkVariantsRow shape and writes edits back through `onChange`.
|
|
131
|
+
*/
|
|
132
|
+
export function BulkVariantsTable({
|
|
133
|
+
rows,
|
|
134
|
+
labels,
|
|
135
|
+
weightUnitOptions,
|
|
136
|
+
dimensionUnitOptions,
|
|
137
|
+
taxCategoryOptions,
|
|
138
|
+
countryOptions,
|
|
139
|
+
cartonOptions,
|
|
140
|
+
onChange,
|
|
141
|
+
}: BulkVariantsTableProps) {
|
|
142
|
+
const columns = useMemo<ColumnDef<BulkVariantsRow>[]>(() => {
|
|
143
|
+
// Keyboard column coordinates, fixed per column: variant label is
|
|
144
|
+
// read-only (unregistered), then sku=1, barcode=2, weight=3, unit=4,
|
|
145
|
+
// height=5, width=6, depth=7, unit=8, preorderable=9, backorder=10,
|
|
146
|
+
// tax=11, hs code=12, origin=13, customs description=14, carton=15,
|
|
147
|
+
// carton weight=16, cartons per pallet=17, units per carton=18.
|
|
148
|
+
const numericHeader = (label: string) => () => <span className="block text-right">{label}</span>
|
|
149
|
+
const rowLabel = (r: BulkVariantsRow) => r.variantLabel ?? labels.variantDefault
|
|
150
|
+
|
|
151
|
+
const defs: ColumnDef<BulkVariantsRow>[] = [
|
|
152
|
+
{
|
|
153
|
+
id: 'variant',
|
|
154
|
+
header: labels.variant,
|
|
155
|
+
cell: ({ row }) => (
|
|
156
|
+
<ReadOnlyCell className="whitespace-nowrap text-muted-foreground">
|
|
157
|
+
{rowLabel(row.original)}
|
|
158
|
+
</ReadOnlyCell>
|
|
159
|
+
),
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
id: 'sku',
|
|
163
|
+
header: labels.sku,
|
|
164
|
+
cell: ({ row }) => (
|
|
165
|
+
<TextCell
|
|
166
|
+
coords={{ row: row.index, col: 1 }}
|
|
167
|
+
value={row.original.sku ?? null}
|
|
168
|
+
onChange={(next) => onChange(row.original.id, { field: 'sku', value: next })}
|
|
169
|
+
ariaLabel={`${labels.sku} — ${rowLabel(row.original)}`}
|
|
170
|
+
/>
|
|
171
|
+
),
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
id: 'barcode',
|
|
175
|
+
header: labels.barcode,
|
|
176
|
+
cell: ({ row }) => (
|
|
177
|
+
<TextCell
|
|
178
|
+
coords={{ row: row.index, col: 2 }}
|
|
179
|
+
value={row.original.barcode ?? null}
|
|
180
|
+
onChange={(next) => onChange(row.original.id, { field: 'barcode', value: next })}
|
|
181
|
+
ariaLabel={`${labels.barcode} — ${rowLabel(row.original)}`}
|
|
182
|
+
/>
|
|
183
|
+
),
|
|
184
|
+
},
|
|
185
|
+
...DECIMAL_COLUMNS.map<ColumnDef<BulkVariantsRow>>(({ field, col }) => ({
|
|
186
|
+
id: field,
|
|
187
|
+
header: numericHeader(labels[field]),
|
|
188
|
+
cell: ({ row }) => (
|
|
189
|
+
<DecimalCell
|
|
190
|
+
coords={{ row: row.index, col }}
|
|
191
|
+
value={row.original[field] ?? null}
|
|
192
|
+
onChange={(next) => onChange(row.original.id, { field, value: next })}
|
|
193
|
+
ariaLabel={`${labels[field]} — ${rowLabel(row.original)}`}
|
|
194
|
+
/>
|
|
195
|
+
),
|
|
196
|
+
})),
|
|
197
|
+
{
|
|
198
|
+
id: 'weight_unit',
|
|
199
|
+
header: labels.weightUnit,
|
|
200
|
+
cell: ({ row }) => (
|
|
201
|
+
<SelectCell
|
|
202
|
+
coords={{ row: row.index, col: 4 }}
|
|
203
|
+
value={row.original.weightUnit ?? null}
|
|
204
|
+
options={weightUnitOptions}
|
|
205
|
+
nullLabel={labels.unitDefault}
|
|
206
|
+
onChange={(next) => onChange(row.original.id, { field: 'weightUnit', value: next })}
|
|
207
|
+
ariaLabel={`${labels.weightUnit} — ${rowLabel(row.original)}`}
|
|
208
|
+
/>
|
|
209
|
+
),
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
id: 'dimensions_unit',
|
|
213
|
+
header: labels.dimensionsUnit,
|
|
214
|
+
cell: ({ row }) => (
|
|
215
|
+
<SelectCell
|
|
216
|
+
coords={{ row: row.index, col: 8 }}
|
|
217
|
+
value={row.original.dimensionsUnit ?? null}
|
|
218
|
+
options={dimensionUnitOptions}
|
|
219
|
+
nullLabel={labels.unitDefault}
|
|
220
|
+
onChange={(next) => onChange(row.original.id, { field: 'dimensionsUnit', value: next })}
|
|
221
|
+
ariaLabel={`${labels.dimensionsUnit} — ${rowLabel(row.original)}`}
|
|
222
|
+
/>
|
|
223
|
+
),
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
id: 'preorderable',
|
|
227
|
+
header: labels.preorderable,
|
|
228
|
+
cell: ({ row }) => (
|
|
229
|
+
<SwitchCell
|
|
230
|
+
coords={{ row: row.index, col: 9 }}
|
|
231
|
+
value={row.original.preorderable ?? false}
|
|
232
|
+
onChange={(next) => onChange(row.original.id, { field: 'preorderable', value: next })}
|
|
233
|
+
ariaLabel={`${labels.preorderable} — ${rowLabel(row.original)}`}
|
|
234
|
+
/>
|
|
235
|
+
),
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
id: 'backorder_limit',
|
|
239
|
+
header: numericHeader(labels.backorderLimit),
|
|
240
|
+
cell: ({ row }) => (
|
|
241
|
+
<DecimalCell
|
|
242
|
+
coords={{ row: row.index, col: 10 }}
|
|
243
|
+
value={row.original.backorderLimit ?? null}
|
|
244
|
+
onChange={(next) => onChange(row.original.id, { field: 'backorderLimit', value: next })}
|
|
245
|
+
ariaLabel={`${labels.backorderLimit} — ${rowLabel(row.original)}`}
|
|
246
|
+
/>
|
|
247
|
+
),
|
|
248
|
+
},
|
|
249
|
+
]
|
|
250
|
+
|
|
251
|
+
if (taxCategoryOptions) {
|
|
252
|
+
defs.push({
|
|
253
|
+
id: 'tax_category',
|
|
254
|
+
header: labels.taxCategory,
|
|
255
|
+
cell: ({ row }) => (
|
|
256
|
+
<SelectCell
|
|
257
|
+
coords={{ row: row.index, col: 11 }}
|
|
258
|
+
value={row.original.taxCategoryId ?? null}
|
|
259
|
+
options={taxCategoryOptions}
|
|
260
|
+
nullLabel={labels.taxCategoryNone}
|
|
261
|
+
onChange={(next) => onChange(row.original.id, { field: 'taxCategoryId', value: next })}
|
|
262
|
+
ariaLabel={`${labels.taxCategory} — ${rowLabel(row.original)}`}
|
|
263
|
+
/>
|
|
264
|
+
),
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Customs columns travel together — classification is only meaningful
|
|
269
|
+
// alongside the origin it is declared with.
|
|
270
|
+
if (countryOptions) {
|
|
271
|
+
defs.push(
|
|
272
|
+
{
|
|
273
|
+
id: 'hs_code',
|
|
274
|
+
header: labels.hsCode,
|
|
275
|
+
cell: ({ row }) => (
|
|
276
|
+
<TextCell
|
|
277
|
+
coords={{ row: row.index, col: 12 }}
|
|
278
|
+
value={row.original.hsCode ?? null}
|
|
279
|
+
onChange={(next) => onChange(row.original.id, { field: 'hsCode', value: next })}
|
|
280
|
+
ariaLabel={`${labels.hsCode} — ${rowLabel(row.original)}`}
|
|
281
|
+
/>
|
|
282
|
+
),
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
id: 'country_of_origin',
|
|
286
|
+
header: labels.countryOfOrigin,
|
|
287
|
+
cell: ({ row }) => (
|
|
288
|
+
<SelectCell
|
|
289
|
+
coords={{ row: row.index, col: 13 }}
|
|
290
|
+
value={row.original.countryOfOrigin ?? null}
|
|
291
|
+
options={countryOptions}
|
|
292
|
+
nullLabel={labels.countryOfOriginNone}
|
|
293
|
+
onChange={(next) =>
|
|
294
|
+
onChange(row.original.id, { field: 'countryOfOrigin', value: next })
|
|
295
|
+
}
|
|
296
|
+
ariaLabel={`${labels.countryOfOrigin} — ${rowLabel(row.original)}`}
|
|
297
|
+
/>
|
|
298
|
+
),
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
id: 'customs_description',
|
|
302
|
+
header: labels.customsDescription,
|
|
303
|
+
cell: ({ row }) => (
|
|
304
|
+
<TextCell
|
|
305
|
+
coords={{ row: row.index, col: 14 }}
|
|
306
|
+
value={row.original.customsDescription ?? null}
|
|
307
|
+
onChange={(next) =>
|
|
308
|
+
onChange(row.original.id, { field: 'customsDescription', value: next })
|
|
309
|
+
}
|
|
310
|
+
ariaLabel={`${labels.customsDescription} — ${rowLabel(row.original)}`}
|
|
311
|
+
/>
|
|
312
|
+
),
|
|
313
|
+
},
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// The packing chain travels together: a carton weight or a pallet count
|
|
318
|
+
// means nothing without the carton they describe.
|
|
319
|
+
if (cartonOptions) {
|
|
320
|
+
defs.push(
|
|
321
|
+
{
|
|
322
|
+
id: 'units_per_carton',
|
|
323
|
+
header: numericHeader(labels.unitsPerCarton),
|
|
324
|
+
cell: ({ row }) => (
|
|
325
|
+
<DecimalCell
|
|
326
|
+
coords={{ row: row.index, col: 18 }}
|
|
327
|
+
value={row.original.unitsPerCarton ?? null}
|
|
328
|
+
onChange={(next) =>
|
|
329
|
+
onChange(row.original.id, { field: 'unitsPerCarton', value: next })
|
|
330
|
+
}
|
|
331
|
+
ariaLabel={`${labels.unitsPerCarton} — ${rowLabel(row.original)}`}
|
|
332
|
+
/>
|
|
333
|
+
),
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
id: 'carton_package_type',
|
|
337
|
+
header: labels.cartonPackageType,
|
|
338
|
+
cell: ({ row }) => (
|
|
339
|
+
<SelectCell
|
|
340
|
+
coords={{ row: row.index, col: 15 }}
|
|
341
|
+
value={row.original.cartonPackageTypeId ?? null}
|
|
342
|
+
options={cartonOptions}
|
|
343
|
+
nullLabel={labels.cartonPackageTypeNone}
|
|
344
|
+
onChange={(next) =>
|
|
345
|
+
onChange(row.original.id, { field: 'cartonPackageTypeId', value: next })
|
|
346
|
+
}
|
|
347
|
+
ariaLabel={`${labels.cartonPackageType} — ${rowLabel(row.original)}`}
|
|
348
|
+
/>
|
|
349
|
+
),
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
id: 'carton_weight',
|
|
353
|
+
header: numericHeader(labels.cartonWeight),
|
|
354
|
+
cell: ({ row }) => (
|
|
355
|
+
<DecimalCell
|
|
356
|
+
coords={{ row: row.index, col: 16 }}
|
|
357
|
+
value={row.original.cartonWeight ?? null}
|
|
358
|
+
onChange={(next) => onChange(row.original.id, { field: 'cartonWeight', value: next })}
|
|
359
|
+
ariaLabel={`${labels.cartonWeight} — ${rowLabel(row.original)}`}
|
|
360
|
+
/>
|
|
361
|
+
),
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
id: 'cartons_per_pallet',
|
|
365
|
+
header: numericHeader(labels.cartonsPerPallet),
|
|
366
|
+
cell: ({ row }) => (
|
|
367
|
+
<DecimalCell
|
|
368
|
+
coords={{ row: row.index, col: 17 }}
|
|
369
|
+
value={row.original.cartonsPerPallet ?? null}
|
|
370
|
+
onChange={(next) =>
|
|
371
|
+
onChange(row.original.id, { field: 'cartonsPerPallet', value: next })
|
|
372
|
+
}
|
|
373
|
+
ariaLabel={`${labels.cartonsPerPallet} — ${rowLabel(row.original)}`}
|
|
374
|
+
/>
|
|
375
|
+
),
|
|
376
|
+
},
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Reorder to visual sequence: the coordinate map above is stable, but
|
|
381
|
+
// the defs array built column groups out of order for concision.
|
|
382
|
+
const order = [
|
|
383
|
+
'variant',
|
|
384
|
+
'sku',
|
|
385
|
+
'barcode',
|
|
386
|
+
'weight',
|
|
387
|
+
'weight_unit',
|
|
388
|
+
'height',
|
|
389
|
+
'width',
|
|
390
|
+
'depth',
|
|
391
|
+
'dimensions_unit',
|
|
392
|
+
'preorderable',
|
|
393
|
+
'backorder_limit',
|
|
394
|
+
'tax_category',
|
|
395
|
+
'hs_code',
|
|
396
|
+
'country_of_origin',
|
|
397
|
+
'customs_description',
|
|
398
|
+
'units_per_carton',
|
|
399
|
+
'carton_package_type',
|
|
400
|
+
'carton_weight',
|
|
401
|
+
'cartons_per_pallet',
|
|
402
|
+
]
|
|
403
|
+
return defs.sort((a, b) => order.indexOf(a.id as string) - order.indexOf(b.id as string))
|
|
404
|
+
}, [
|
|
405
|
+
onChange,
|
|
406
|
+
labels,
|
|
407
|
+
weightUnitOptions,
|
|
408
|
+
dimensionUnitOptions,
|
|
409
|
+
taxCategoryOptions,
|
|
410
|
+
countryOptions,
|
|
411
|
+
cartonOptions,
|
|
412
|
+
])
|
|
413
|
+
|
|
414
|
+
// Roughly 130px a column, which is what the widest headers here ("Available
|
|
415
|
+
// for pre-order", "Customs description") need before they wrap to two lines.
|
|
416
|
+
// The grid scrolls sideways past that rather than squeezing them.
|
|
417
|
+
const minWidth = cartonOptions
|
|
418
|
+
? 'min-w-[2470px]'
|
|
419
|
+
: countryOptions
|
|
420
|
+
? 'min-w-[1950px]'
|
|
421
|
+
: 'min-w-[1560px]'
|
|
422
|
+
|
|
423
|
+
return (
|
|
424
|
+
<DataGrid<BulkVariantsRow>
|
|
425
|
+
rows={rows}
|
|
426
|
+
columns={columns}
|
|
427
|
+
getRowId={(row) => row.id}
|
|
428
|
+
className={minWidth}
|
|
429
|
+
aria-label={labels.gridAriaLabel}
|
|
430
|
+
// Sets a min-width well past any viewport, so it has to scroll itself.
|
|
431
|
+
scrollable
|
|
432
|
+
/>
|
|
433
|
+
)
|
|
434
|
+
}
|
|
@@ -13,7 +13,11 @@
|
|
|
13
13
|
* draft edits update the row immediately, before any save.
|
|
14
14
|
*/
|
|
15
15
|
export interface CalculatorPayload {
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Display name for the calculator, already localized by the caller — this
|
|
18
|
+
* package is headless and does not resolve translations itself. Optional;
|
|
19
|
+
* if missing the type is demodulized.
|
|
20
|
+
*/
|
|
17
21
|
label?: string | null
|
|
18
22
|
/** STI class name (e.g. "Spree::Calculator::FlatRate"). Used as a label fallback. */
|
|
19
23
|
type?: string | null
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
import { Button } from '../ui/button'
|
|
4
|
+
import {
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogBody,
|
|
7
|
+
DialogContent,
|
|
8
|
+
DialogFooter,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
} from '../ui/dialog'
|
|
12
|
+
import { Field, FieldLabel } from '../ui/field'
|
|
13
|
+
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from '../ui/input-group'
|
|
14
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
|
15
|
+
import { Switch } from '../ui/switch'
|
|
16
|
+
import type { RefundMethod } from './return-dialogs'
|
|
17
|
+
|
|
18
|
+
/** How a claim is settled. */
|
|
19
|
+
export type ClaimResolution = 'refund' | 'replacement' | 'refund_and_replacement'
|
|
20
|
+
|
|
21
|
+
/** One claimed line, as the resolve form needs it. */
|
|
22
|
+
export type ClaimLineForResolve = {
|
|
23
|
+
id: string
|
|
24
|
+
label: string
|
|
25
|
+
quantity: number
|
|
26
|
+
/** Whether the claim was opened asking for this line to be replaced. */
|
|
27
|
+
sendReplacement: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Settles a claim: money back, a replacement shipment, or both.
|
|
32
|
+
*
|
|
33
|
+
* The resolution is chosen here rather than at claim creation, so the
|
|
34
|
+
* merchant decides how to make things right once they have the facts. The
|
|
35
|
+
* currency symbol comes from the caller — the operator reads it from the
|
|
36
|
+
* store, and a seller has no currency of their own.
|
|
37
|
+
*
|
|
38
|
+
* Shared by the operator's order page and the seller's.
|
|
39
|
+
*/
|
|
40
|
+
export function ClaimResolveDialog({
|
|
41
|
+
lines,
|
|
42
|
+
defaultAmount,
|
|
43
|
+
currencySymbol,
|
|
44
|
+
onClose,
|
|
45
|
+
onSubmit,
|
|
46
|
+
pending = false,
|
|
47
|
+
}: {
|
|
48
|
+
lines: ClaimLineForResolve[]
|
|
49
|
+
/** What to offer as the refund, already resolved by the caller. */
|
|
50
|
+
defaultAmount: string
|
|
51
|
+
currencySymbol: string
|
|
52
|
+
onClose: () => void
|
|
53
|
+
onSubmit: (params: {
|
|
54
|
+
resolution: ClaimResolution
|
|
55
|
+
refundMethod: RefundMethod
|
|
56
|
+
amount?: string
|
|
57
|
+
replacementLineItemIds: string[]
|
|
58
|
+
}) => void
|
|
59
|
+
pending?: boolean
|
|
60
|
+
}) {
|
|
61
|
+
const { t } = useTranslation()
|
|
62
|
+
|
|
63
|
+
const [resolution, setResolution] = useState<ClaimResolution>('refund')
|
|
64
|
+
const [refundMethod, setRefundMethod] = useState<RefundMethod>('store_credit')
|
|
65
|
+
const [amount, setAmount] = useState(defaultAmount)
|
|
66
|
+
const [replacing, setReplacing] = useState<Record<string, boolean>>(() =>
|
|
67
|
+
Object.fromEntries(lines.map((line) => [line.id, line.sendReplacement])),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
const refunding = resolution.includes('refund')
|
|
71
|
+
const sendingReplacement = resolution.includes('replacement')
|
|
72
|
+
const chosenReplacements = Object.entries(replacing)
|
|
73
|
+
.filter(([, on]) => on)
|
|
74
|
+
.map(([id]) => id)
|
|
75
|
+
|
|
76
|
+
const resolutionOptions = (['refund', 'replacement', 'refund_and_replacement'] as const).map(
|
|
77
|
+
(value) => ({
|
|
78
|
+
value,
|
|
79
|
+
label: t(`admin.pages.orders.detail.claims.resolutions.${value}`),
|
|
80
|
+
}),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
const methodOptions = [
|
|
84
|
+
{
|
|
85
|
+
value: 'store_credit',
|
|
86
|
+
label: t('admin.pages.orders.detail.returns.refund_methods.store_credit'),
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
value: 'original_payment',
|
|
90
|
+
label: t('admin.pages.orders.detail.returns.refund_methods.original_payment'),
|
|
91
|
+
},
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
// Refunding nothing, or replacing nothing, is what the server rejects —
|
|
95
|
+
// say so here instead of letting the request fail.
|
|
96
|
+
const ready =
|
|
97
|
+
(!refunding || Number(amount) > 0) && (!sendingReplacement || chosenReplacements.length > 0)
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
|
101
|
+
<DialogContent>
|
|
102
|
+
<DialogHeader>
|
|
103
|
+
<DialogTitle>{t('admin.pages.orders.detail.claims.resolve_title')}</DialogTitle>
|
|
104
|
+
</DialogHeader>
|
|
105
|
+
<DialogBody className="flex flex-col gap-4">
|
|
106
|
+
<Field>
|
|
107
|
+
<FieldLabel htmlFor="claim-resolution">
|
|
108
|
+
{t('admin.pages.orders.detail.claims.resolution')}
|
|
109
|
+
</FieldLabel>
|
|
110
|
+
<Select
|
|
111
|
+
items={resolutionOptions}
|
|
112
|
+
value={resolution}
|
|
113
|
+
onValueChange={(value) => setResolution(value as ClaimResolution)}
|
|
114
|
+
>
|
|
115
|
+
<SelectTrigger id="claim-resolution">
|
|
116
|
+
<SelectValue />
|
|
117
|
+
</SelectTrigger>
|
|
118
|
+
<SelectContent>
|
|
119
|
+
{resolutionOptions.map((option) => (
|
|
120
|
+
<SelectItem key={option.value} value={option.value}>
|
|
121
|
+
{option.label}
|
|
122
|
+
</SelectItem>
|
|
123
|
+
))}
|
|
124
|
+
</SelectContent>
|
|
125
|
+
</Select>
|
|
126
|
+
</Field>
|
|
127
|
+
|
|
128
|
+
{refunding && (
|
|
129
|
+
<>
|
|
130
|
+
<Field>
|
|
131
|
+
<FieldLabel htmlFor="claim-resolve-amount">
|
|
132
|
+
{t('admin.pages.orders.detail.claims.refund_amount')}
|
|
133
|
+
</FieldLabel>
|
|
134
|
+
<InputGroup>
|
|
135
|
+
<InputGroupAddon>
|
|
136
|
+
<InputGroupText>{currencySymbol}</InputGroupText>
|
|
137
|
+
</InputGroupAddon>
|
|
138
|
+
<InputGroupInput
|
|
139
|
+
id="claim-resolve-amount"
|
|
140
|
+
type="number"
|
|
141
|
+
step="0.01"
|
|
142
|
+
min="0"
|
|
143
|
+
value={amount}
|
|
144
|
+
onChange={(event) => setAmount(event.target.value)}
|
|
145
|
+
/>
|
|
146
|
+
</InputGroup>
|
|
147
|
+
</Field>
|
|
148
|
+
<Field>
|
|
149
|
+
<FieldLabel htmlFor="claim-refund-method">
|
|
150
|
+
{t('admin.pages.orders.detail.returns.refund_method')}
|
|
151
|
+
</FieldLabel>
|
|
152
|
+
<Select
|
|
153
|
+
items={methodOptions}
|
|
154
|
+
value={refundMethod}
|
|
155
|
+
onValueChange={(value) => setRefundMethod(value as RefundMethod)}
|
|
156
|
+
>
|
|
157
|
+
<SelectTrigger id="claim-refund-method">
|
|
158
|
+
<SelectValue />
|
|
159
|
+
</SelectTrigger>
|
|
160
|
+
<SelectContent>
|
|
161
|
+
{methodOptions.map((option) => (
|
|
162
|
+
<SelectItem key={option.value} value={option.value}>
|
|
163
|
+
{option.label}
|
|
164
|
+
</SelectItem>
|
|
165
|
+
))}
|
|
166
|
+
</SelectContent>
|
|
167
|
+
</Select>
|
|
168
|
+
</Field>
|
|
169
|
+
</>
|
|
170
|
+
)}
|
|
171
|
+
|
|
172
|
+
{sendingReplacement && (
|
|
173
|
+
<div className="flex flex-col gap-2">
|
|
174
|
+
<span className="font-medium text-sm">
|
|
175
|
+
{t('admin.pages.orders.detail.claims.replacement_items')}
|
|
176
|
+
</span>
|
|
177
|
+
{lines.map((line) => (
|
|
178
|
+
<div
|
|
179
|
+
key={line.id}
|
|
180
|
+
className="flex items-center justify-between gap-4 rounded-lg border border-border-subtle p-3"
|
|
181
|
+
>
|
|
182
|
+
<span className="truncate text-sm">
|
|
183
|
+
{line.label}
|
|
184
|
+
<span className="text-muted-foreground"> ×{line.quantity}</span>
|
|
185
|
+
</span>
|
|
186
|
+
<Switch
|
|
187
|
+
checked={replacing[line.id] ?? false}
|
|
188
|
+
onCheckedChange={(checked) =>
|
|
189
|
+
setReplacing({ ...replacing, [line.id]: checked })
|
|
190
|
+
}
|
|
191
|
+
/>
|
|
192
|
+
</div>
|
|
193
|
+
))}
|
|
194
|
+
</div>
|
|
195
|
+
)}
|
|
196
|
+
</DialogBody>
|
|
197
|
+
<DialogFooter>
|
|
198
|
+
<Button type="button" variant="outline" onClick={onClose} disabled={pending}>
|
|
199
|
+
{t('admin.actions.cancel')}
|
|
200
|
+
</Button>
|
|
201
|
+
<Button
|
|
202
|
+
type="button"
|
|
203
|
+
disabled={!ready || pending}
|
|
204
|
+
onClick={() =>
|
|
205
|
+
onSubmit({
|
|
206
|
+
resolution,
|
|
207
|
+
refundMethod,
|
|
208
|
+
amount: refunding ? amount : undefined,
|
|
209
|
+
replacementLineItemIds: chosenReplacements,
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
>
|
|
213
|
+
{t('admin.pages.orders.detail.claims.actions.resolve')}
|
|
214
|
+
</Button>
|
|
215
|
+
</DialogFooter>
|
|
216
|
+
</DialogContent>
|
|
217
|
+
</Dialog>
|
|
218
|
+
)
|
|
219
|
+
}
|
|
@@ -64,7 +64,7 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
|
|
|
64
64
|
<DialogTitle>{options.title ?? t('admin.components.confirm_dialog.title')}</DialogTitle>
|
|
65
65
|
<DialogDescription>{options.message}</DialogDescription>
|
|
66
66
|
</DialogHeader>
|
|
67
|
-
<DialogFooter>
|
|
67
|
+
<DialogFooter className="border-0">
|
|
68
68
|
<Button variant="outline" onClick={() => handleClose(false)}>
|
|
69
69
|
{options.cancelLabel ?? t('admin.actions.cancel')}
|
|
70
70
|
</Button>
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { CheckIcon, CopyIcon } from 'lucide-react'
|
|
2
1
|
import type { ComponentProps, ReactNode } from 'react'
|
|
3
2
|
import { useCopyToClipboard } from '../hooks/use-copy-to-clipboard'
|
|
4
3
|
import { cn } from '../lib/utils'
|
|
5
4
|
import { Button } from '../ui/button'
|
|
5
|
+
import { CheckIcon, CopyIcon } from './icons'
|
|
6
6
|
|
|
7
7
|
type ButtonProps = ComponentProps<typeof Button>
|
|
8
8
|
|