@spree/dashboard-core 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.
Files changed (125) hide show
  1. package/README.md +4 -0
  2. package/dist/vite/index.js +17 -3
  3. package/package.json +7 -7
  4. package/src/api-client.ts +643 -0
  5. package/src/components/address-form-dialog.test.ts +25 -0
  6. package/src/components/address-form-dialog.tsx +203 -79
  7. package/src/components/app-shell.tsx +171 -0
  8. package/src/components/app-sidebar.tsx +184 -27
  9. package/src/components/bulk-action-bar.tsx +89 -157
  10. package/src/components/country-combobox.tsx +69 -34
  11. package/src/components/country-state-fields.tsx +43 -11
  12. package/src/components/currency-select.tsx +81 -66
  13. package/src/components/export-button.tsx +62 -5
  14. package/src/components/file-upload-field.tsx +32 -10
  15. package/src/components/import-button.tsx +52 -27
  16. package/src/components/import-wizard-dialog.tsx +710 -0
  17. package/src/components/locale-select.tsx +70 -57
  18. package/src/components/media-picker-sheet.tsx +307 -0
  19. package/src/components/mobile-top-bar.tsx +75 -0
  20. package/src/components/nav-main.tsx +197 -27
  21. package/src/components/page-header.tsx +203 -61
  22. package/src/components/page-tabs.tsx +4 -1
  23. package/src/components/preferences-form.tsx +98 -4
  24. package/src/components/resource-combobox.tsx +52 -4
  25. package/src/components/resource-picker-sheet.tsx +209 -73
  26. package/src/components/resource-table.tsx +383 -127
  27. package/src/components/route-announcer.tsx +64 -0
  28. package/src/components/section-heading.tsx +95 -0
  29. package/src/components/settings-sidebar.tsx +290 -60
  30. package/src/components/sheet-top-bar.tsx +44 -0
  31. package/src/components/sidebar-search.tsx +83 -0
  32. package/src/components/sidebar-user.tsx +213 -0
  33. package/src/components/skip-link.tsx +36 -0
  34. package/src/components/store-date-picker.tsx +6 -2
  35. package/src/components/store-switcher.tsx +19 -14
  36. package/src/components/table-toolbar.tsx +1357 -440
  37. package/src/components/tag-combobox.tsx +12 -4
  38. package/src/components/top-bar-breadcrumbs.tsx +235 -0
  39. package/src/components/wizard-close-guard.test.ts +54 -0
  40. package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
  41. package/src/hooks/use-command-palette.tsx +13 -0
  42. package/src/hooks/use-countries.test.ts +56 -0
  43. package/src/hooks/use-countries.ts +51 -5
  44. package/src/hooks/use-direct-upload.ts +16 -3
  45. package/src/hooks/use-export.ts +51 -16
  46. package/src/hooks/use-import.ts +241 -16
  47. package/src/hooks/use-package-types.ts +132 -0
  48. package/src/hooks/use-resource-mutation.ts +74 -13
  49. package/src/hooks/use-stock-locations.ts +131 -0
  50. package/src/hooks/use-switch-admin-locale.ts +2 -2
  51. package/src/hooks/use-visual-viewport-offset.ts +61 -0
  52. package/src/index.ts +45 -1
  53. package/src/lib/cancel-order-form.ts +27 -0
  54. package/src/lib/create-action-registry.ts +278 -0
  55. package/src/lib/date-presets.test.ts +86 -0
  56. package/src/lib/date-presets.ts +98 -0
  57. package/src/lib/delivery-form.ts +15 -0
  58. package/src/lib/docs.ts +32 -0
  59. package/src/lib/download.ts +13 -1
  60. package/src/lib/filters-to-ransack.test.ts +113 -0
  61. package/src/lib/filters-to-ransack.ts +22 -1
  62. package/src/lib/form-errors.ts +122 -10
  63. package/src/lib/form-mappers.ts +76 -0
  64. package/src/lib/fulfillment-items.test.ts +213 -0
  65. package/src/lib/fulfillment-items.ts +171 -0
  66. package/src/lib/i18n.ts +19 -11
  67. package/src/lib/import-types.ts +25 -0
  68. package/src/lib/label-upload-form.ts +22 -0
  69. package/src/lib/nav-registry.ts +71 -3
  70. package/src/lib/permissions.ts +46 -1
  71. package/src/lib/progress.ts +18 -0
  72. package/src/lib/query-keys.ts +13 -13
  73. package/src/lib/sample-csv.ts +9 -6
  74. package/src/lib/search-registry.ts +6 -2
  75. package/src/lib/settings-nav-registry.ts +104 -11
  76. package/src/lib/table-registry.ts +50 -0
  77. package/src/lib/type-labels.test.ts +68 -0
  78. package/src/lib/type-labels.ts +78 -0
  79. package/src/locales/ar.json +1889 -517
  80. package/src/locales/de.json +1884 -517
  81. package/src/locales/en.json +1886 -519
  82. package/src/locales/fr.json +1884 -517
  83. package/src/locales/pl.json +1913 -517
  84. package/src/locales/zh-CN.json +1883 -517
  85. package/src/pages/package-types.tsx +484 -0
  86. package/src/pages/settings-index.tsx +112 -0
  87. package/src/pages/stock-locations.tsx +674 -0
  88. package/src/plugin.ts +29 -0
  89. package/src/products/add-video-dialog.tsx +100 -0
  90. package/src/products/bulk-variants-dialog.tsx +301 -0
  91. package/src/products/carton-size.test.ts +51 -0
  92. package/src/products/carton-size.ts +20 -0
  93. package/src/products/currency-parts.ts +21 -0
  94. package/src/products/inventory-section.tsx +259 -0
  95. package/src/products/media-edit-sheet.tsx +336 -0
  96. package/src/products/normalize-customs.test.ts +39 -0
  97. package/src/products/normalize-customs.ts +27 -0
  98. package/src/products/normalize-money.test.ts +58 -0
  99. package/src/products/normalize-money.ts +73 -0
  100. package/src/products/normalize-quantity.test.ts +32 -0
  101. package/src/products/normalize-quantity.ts +31 -0
  102. package/src/products/product-bulk-price-editor.tsx +165 -0
  103. package/src/products/product-form-cards.tsx +1167 -0
  104. package/src/products/product-form-mapping.ts +212 -0
  105. package/src/products/product-schema.ts +318 -0
  106. package/src/products/product-types.ts +130 -0
  107. package/src/products/use-product-form-data.ts +273 -0
  108. package/src/products/variant-edit-sheet.tsx +725 -0
  109. package/src/products/variants-matrix.test.ts +154 -0
  110. package/src/products/variants-matrix.ts +244 -0
  111. package/src/products/variants-options-builder.tsx +571 -0
  112. package/src/products/variants-section.tsx +535 -0
  113. package/src/products/video-url.test.ts +71 -0
  114. package/src/products/video-url.ts +83 -0
  115. package/src/providers/auth-provider.tsx +70 -12
  116. package/src/providers/permission-provider.tsx +68 -28
  117. package/src/providers/sticky-header-provider.tsx +134 -0
  118. package/src/providers/store-provider.tsx +36 -3
  119. package/src/providers/tenant-provider.tsx +33 -0
  120. package/src/schemas/package-type.ts +95 -0
  121. package/src/schemas/stock-location.ts +126 -0
  122. package/src/tables/package-types.tsx +74 -0
  123. package/src/tables/stock-locations.tsx +124 -0
  124. package/src/vite/index.ts +40 -7
  125. package/src/components/top-bar.tsx +0 -209
@@ -0,0 +1,725 @@
1
+ import type { ProductFormValues, VariantFormValues } from '@spree/dashboard-core'
2
+ import { CountryCombobox, StoreDatePicker } from '@spree/dashboard-core'
3
+ import {
4
+ Alert,
5
+ AlertDescription,
6
+ Button,
7
+ Field,
8
+ FieldDescription,
9
+ FieldError,
10
+ FieldLabel,
11
+ FormSection,
12
+ Input,
13
+ InputGroup,
14
+ InputGroupAddon,
15
+ InputGroupInput,
16
+ InputGroupText,
17
+ Select,
18
+ SelectContent,
19
+ SelectItem,
20
+ SelectTrigger,
21
+ SelectValue,
22
+ Sheet,
23
+ SheetContent,
24
+ SheetDescription,
25
+ SheetFooter,
26
+ SheetHeader,
27
+ SheetTitle,
28
+ Switch,
29
+ } from '@spree/dashboard-ui'
30
+ import { Link } from '@tanstack/react-router'
31
+ import { InfoIcon } from 'lucide-react'
32
+ import { useEffect, useRef } from 'react'
33
+ import { Controller, type UseFormReturn } from 'react-hook-form'
34
+ import { Trans, useTranslation } from 'react-i18next'
35
+ import { useTenantId } from '../providers/tenant-provider'
36
+ import { cartonSize } from './carton-size'
37
+ import { normalizeCustomsDescription, normalizeHsCode } from './normalize-customs'
38
+ import { PURCHASE_UNITS } from './normalize-quantity'
39
+ import {
40
+ useFormCartonPackageTypes as useCartonPackageTypes,
41
+ useFormOptionTypes as useOptionTypes,
42
+ useFormTaxCategories as useTaxCategories,
43
+ } from './use-product-form-data'
44
+ import { variantDisplayLabel } from './variants-matrix'
45
+
46
+ const WEIGHT_UNITS = ['g', 'kg', 'lb', 'oz'] as const
47
+ const DIMENSION_UNITS = ['mm', 'cm', 'in'] as const
48
+
49
+ interface Props {
50
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
51
+ form: UseFormReturn<ProductFormValues, any, any>
52
+ variantIndex: number
53
+ open: boolean
54
+ onOpenChange: (open: boolean) => void
55
+ }
56
+
57
+ // Per-variant detail editor. Binds to variants.${i}.* on the parent product
58
+ // form — no own form state. Save closes the sheet (changes are already in
59
+ // form state; the product Save button commits them). Cancel restores the
60
+ // snapshot captured when the sheet opened.
61
+ export function VariantEditSheet({ form, variantIndex, open, onOpenChange }: Props) {
62
+ const { t } = useTranslation()
63
+ const { data: taxCategoriesResponse } = useTaxCategories()
64
+ const taxCategories = taxCategoriesResponse?.data ?? []
65
+ const hasTaxCategories = taxCategories.length > 0
66
+ const { data: optionTypesData } = useOptionTypes()
67
+ const optionTypes = optionTypesData?.data ?? []
68
+ const tenantId = useTenantId()
69
+ // A panel that does not manage package types at all (a seller's) has no
70
+ // packing to ask about, so the section stays hidden there. An operator with
71
+ // none created yet still sees it, disabled, saying where to make one —
72
+ // hiding it outright left no trace of a feature they had not set up.
73
+ const {
74
+ data: cartonTypesResponse,
75
+ isPending: cartonTypesPending,
76
+ supported: cartonTypesSupported,
77
+ } = useCartonPackageTypes()
78
+ const cartonTypes = cartonTypesResponse?.data ?? []
79
+ const hasCartonTypes = cartonTypes.length > 0
80
+ // Only once the list has actually answered. While it is in flight the empty
81
+ // array below is not yet evidence of an empty store, and telling a merchant
82
+ // who has cartons to go and make one is worse than saying nothing.
83
+ const knownEmpty = cartonTypesSupported && !cartonTypesPending && !hasCartonTypes
84
+ // A packed carton is weighed on the same scale as the goods inside it, so
85
+ // this follows the variant's own unit rather than offering a second one.
86
+ const cartonWeightUnit = form.watch(`variants.${variantIndex}.weight_unit`) || ''
87
+
88
+ // Snapshot the variant when the sheet opens so Cancel can restore it.
89
+ // Re-snapshot if the user switches between variant rows without closing
90
+ // (variantIndex changes while open) so the next Cancel restores the right row.
91
+ //
92
+ // Deep-clone via structuredClone — `form.getValues` returns a reference
93
+ // to RHF's internal field state, and the inline cell editors mutate
94
+ // nested arrays (prices, stock_levels) in place. Without the clone, Cancel
95
+ // would write back the already-edited object.
96
+ //
97
+ // We also stash the variant's `id` (for persisted rows). The sheet is
98
+ // modal so it's hard but not impossible for the matrix array to reorder
99
+ // underneath (e.g. keyboard reorder while focus is in the sheet). On
100
+ // Cancel, re-resolve the index by id and only restore if the snapshot
101
+ // still maps to a unique row — otherwise drop the restore rather than
102
+ // overwriting a different variant's values.
103
+ const snapshotRef = useRef<{ value: VariantFormValues; id: string | undefined } | null>(null)
104
+ useEffect(() => {
105
+ if (!open) {
106
+ snapshotRef.current = null
107
+ return
108
+ }
109
+ const current = form.getValues(`variants.${variantIndex}`)
110
+ snapshotRef.current = current
111
+ ? { value: structuredClone(current) as VariantFormValues, id: current.id }
112
+ : null
113
+ }, [open, variantIndex, form])
114
+
115
+ const variant = form.watch(`variants.${variantIndex}`)
116
+ if (!variant) return null
117
+
118
+ const label = variantDisplayLabel(
119
+ variant,
120
+ t('admin.products.variants.default_variant'),
121
+ optionTypes,
122
+ )
123
+
124
+ const handleCancel = () => {
125
+ const snap = snapshotRef.current
126
+ if (snap) {
127
+ // Resolve the target index by stable id when we have one — the array
128
+ // may have reordered while the sheet was open. For unsaved rows
129
+ // (no id) trust the current index.
130
+ let targetIndex = variantIndex
131
+ if (snap.id) {
132
+ const all = form.getValues('variants') ?? []
133
+ const found = all.findIndex((v) => v.id === snap.id)
134
+ if (found === -1) return onOpenChange(false)
135
+ targetIndex = found
136
+ }
137
+ // Restore the snapshot via resetField so the variant's dirty bit is
138
+ // cleared too — `setValue` with `shouldDirty: true` would leave the
139
+ // form falsely dirty after a no-op cancel (same trap MediaEditSheet
140
+ // hit). `resetField` re-baselines just this subtree, leaving sibling
141
+ // dirty fields elsewhere on the form untouched.
142
+ form.resetField(`variants.${targetIndex}`, { defaultValue: snap.value })
143
+ }
144
+ onOpenChange(false)
145
+ }
146
+
147
+ const handleDone = () => {
148
+ onOpenChange(false)
149
+ }
150
+
151
+ return (
152
+ <Sheet open={open} onOpenChange={(o) => (o ? onOpenChange(o) : handleCancel())}>
153
+ <SheetContent side="right" className="flex flex-col">
154
+ <SheetHeader>
155
+ <SheetTitle>{t('admin.products.variants.edit_variant', { name: label })}</SheetTitle>
156
+ {/* Nothing here is written until the product itself is saved, and a
157
+ sheet that closes with a tick invites the opposite reading. */}
158
+ <SheetDescription>{t('admin.products.variants.sheet.pending_save')}</SheetDescription>
159
+ </SheetHeader>
160
+
161
+ <div className="flex-1 overflow-y-auto p-4 flex flex-col gap-6">
162
+ <FormSection title={t('admin.products.variants.sheet.identity')}>
163
+ <Field>
164
+ <FieldLabel htmlFor={`variant-${variantIndex}-sku`}>
165
+ {t('admin.fields.variant.sku.label')}
166
+ </FieldLabel>
167
+ {/*
168
+ Use Controller instead of register here: the matrix row in
169
+ VariantsSection also registers `variants.${i}.sku`, and two
170
+ `register` calls on the same field path share a single ref
171
+ slot — the second mount wins, so typing in one input never
172
+ updates the other's display. A Controller subscribes to the
173
+ field via useController and renders a controlled input, so
174
+ both surfaces stay in sync whichever one the merchant edits.
175
+ */}
176
+ <Controller
177
+ name={`variants.${variantIndex}.sku`}
178
+ control={form.control}
179
+ render={({ field }) => (
180
+ <Input
181
+ id={`variant-${variantIndex}-sku`}
182
+ placeholder={t('admin.fields.variant.sku.placeholder')}
183
+ value={field.value ?? ''}
184
+ onChange={(e) => field.onChange(e.target.value)}
185
+ onBlur={field.onBlur}
186
+ />
187
+ )}
188
+ />
189
+ <FieldError errors={[form.formState.errors.variants?.[variantIndex]?.sku]} />
190
+ </Field>
191
+ <Field>
192
+ <FieldLabel htmlFor={`variant-${variantIndex}-barcode`}>
193
+ {t('admin.fields.variant.barcode.label')}
194
+ </FieldLabel>
195
+ <Input
196
+ id={`variant-${variantIndex}-barcode`}
197
+ {...form.register(`variants.${variantIndex}.barcode`)}
198
+ />
199
+ <FieldError errors={[form.formState.errors.variants?.[variantIndex]?.barcode]} />
200
+ </Field>
201
+ </FormSection>
202
+
203
+ <FormSection title={t('admin.fields.shipping.label')}>
204
+ <div className="grid grid-cols-[1fr_120px] gap-3">
205
+ <Field>
206
+ <FieldLabel htmlFor={`variant-${variantIndex}-weight`}>
207
+ {t('admin.fields.variant.weight.label')}
208
+ </FieldLabel>
209
+ <Input
210
+ id={`variant-${variantIndex}-weight`}
211
+ type="number"
212
+ step="0.01"
213
+ min="0"
214
+ {...form.register(`variants.${variantIndex}.weight`)}
215
+ />
216
+ </Field>
217
+ <Field>
218
+ <FieldLabel htmlFor={`variant-${variantIndex}-weight-unit`}>
219
+ {t('admin.fields.variant.weight_unit.label')}
220
+ </FieldLabel>
221
+ <Controller
222
+ name={`variants.${variantIndex}.weight_unit`}
223
+ control={form.control}
224
+ render={({ field }) => (
225
+ <Select
226
+ value={field.value ?? ''}
227
+ onValueChange={(v) => field.onChange(v || null)}
228
+ >
229
+ <SelectTrigger id={`variant-${variantIndex}-weight-unit`} className="w-full">
230
+ <SelectValue>{(v) => (v as string) || '—'}</SelectValue>
231
+ </SelectTrigger>
232
+ <SelectContent>
233
+ {WEIGHT_UNITS.map((u) => (
234
+ <SelectItem key={u} value={u}>
235
+ {u}
236
+ </SelectItem>
237
+ ))}
238
+ </SelectContent>
239
+ </Select>
240
+ )}
241
+ />
242
+ </Field>
243
+ </div>
244
+
245
+ <div className="grid grid-cols-[1fr_1fr_1fr_120px] gap-3">
246
+ <Field>
247
+ <FieldLabel htmlFor={`variant-${variantIndex}-height`}>
248
+ {t('admin.fields.variant.height.label')}
249
+ </FieldLabel>
250
+ <Input
251
+ id={`variant-${variantIndex}-height`}
252
+ type="number"
253
+ step="0.01"
254
+ min="0"
255
+ {...form.register(`variants.${variantIndex}.height`)}
256
+ />
257
+ </Field>
258
+ <Field>
259
+ <FieldLabel htmlFor={`variant-${variantIndex}-width`}>
260
+ {t('admin.fields.variant.width.label')}
261
+ </FieldLabel>
262
+ <Input
263
+ id={`variant-${variantIndex}-width`}
264
+ type="number"
265
+ step="0.01"
266
+ min="0"
267
+ {...form.register(`variants.${variantIndex}.width`)}
268
+ />
269
+ </Field>
270
+ <Field>
271
+ <FieldLabel htmlFor={`variant-${variantIndex}-depth`}>
272
+ {t('admin.fields.variant.depth.label')}
273
+ </FieldLabel>
274
+ <Input
275
+ id={`variant-${variantIndex}-depth`}
276
+ type="number"
277
+ step="0.01"
278
+ min="0"
279
+ {...form.register(`variants.${variantIndex}.depth`)}
280
+ />
281
+ </Field>
282
+ <Field>
283
+ <FieldLabel htmlFor={`variant-${variantIndex}-dim-unit`}>
284
+ {t('admin.fields.variant.dimensions_unit.label')}
285
+ </FieldLabel>
286
+ <Controller
287
+ name={`variants.${variantIndex}.dimensions_unit`}
288
+ control={form.control}
289
+ render={({ field }) => (
290
+ <Select
291
+ value={field.value ?? ''}
292
+ onValueChange={(v) => field.onChange(v || null)}
293
+ >
294
+ <SelectTrigger id={`variant-${variantIndex}-dim-unit`} className="w-full">
295
+ <SelectValue>{(v) => (v as string) || '—'}</SelectValue>
296
+ </SelectTrigger>
297
+ <SelectContent>
298
+ {DIMENSION_UNITS.map((u) => (
299
+ <SelectItem key={u} value={u}>
300
+ {u}
301
+ </SelectItem>
302
+ ))}
303
+ </SelectContent>
304
+ </Select>
305
+ )}
306
+ />
307
+ </Field>
308
+ </div>
309
+ </FormSection>
310
+
311
+ {cartonTypesSupported && (
312
+ <FormSection title={t('admin.products.variants.sheet.packing')}>
313
+ <p className="text-sm text-muted-foreground">
314
+ {t('admin.products.variants.sheet.packing_help')}
315
+ </p>
316
+ {knownEmpty && (
317
+ <Alert variant="info">
318
+ <InfoIcon />
319
+ <AlertDescription>
320
+ <Trans
321
+ i18nKey="admin.products.variants.sheet.no_carton_types"
322
+ components={{
323
+ settingsLink: (
324
+ <Link
325
+ to={`/${tenantId}/settings/package-types` as never}
326
+ className="underline underline-offset-3"
327
+ />
328
+ ),
329
+ }}
330
+ />
331
+ </AlertDescription>
332
+ </Alert>
333
+ )}
334
+ <Field>
335
+ <FieldLabel htmlFor={`variant-${variantIndex}-carton`}>
336
+ {t('admin.fields.variant.carton_package_type_id.label')}
337
+ </FieldLabel>
338
+ <Controller
339
+ name={`variants.${variantIndex}.carton_package_type_id`}
340
+ control={form.control}
341
+ render={({ field }) => (
342
+ <Select
343
+ value={field.value ?? ''}
344
+ onValueChange={(value) => field.onChange(value || null)}
345
+ >
346
+ <SelectTrigger
347
+ id={`variant-${variantIndex}-carton`}
348
+ className="w-full"
349
+ disabled={!hasCartonTypes}
350
+ >
351
+ <SelectValue
352
+ placeholder={t('admin.products.variants.sheet.carton_placeholder')}
353
+ >
354
+ {(value) =>
355
+ cartonTypes.find((carton) => carton.id === value)?.name ??
356
+ t('admin.products.variants.sheet.carton_placeholder')
357
+ }
358
+ </SelectValue>
359
+ </SelectTrigger>
360
+ <SelectContent>
361
+ {cartonTypes.map((carton) => (
362
+ <SelectItem key={carton.id} value={carton.id}>
363
+ <span className="flex w-full items-baseline justify-between gap-4">
364
+ <span>{carton.name}</span>
365
+ {/* Two cartons named alike are told apart by their
366
+ size, and the merchant is about to state what a
367
+ packed one weighs. */}
368
+ {cartonSize(carton) && (
369
+ <span className="text-xs text-muted-foreground tabular-nums">
370
+ {cartonSize(carton)}
371
+ </span>
372
+ )}
373
+ </span>
374
+ </SelectItem>
375
+ ))}
376
+ </SelectContent>
377
+ </Select>
378
+ )}
379
+ />
380
+ <FieldDescription>
381
+ {t('admin.fields.variant.carton_package_type_id.help')}
382
+ </FieldDescription>
383
+ </Field>
384
+ <div className="grid grid-cols-2 gap-3">
385
+ <Field>
386
+ <FieldLabel htmlFor={`variant-${variantIndex}-carton-weight`}>
387
+ {t('admin.fields.variant.carton_weight.label')}
388
+ </FieldLabel>
389
+ {/* Cleared means unmeasured, not zero — and a zero carton
390
+ weight is refused, so coercing a blank to 0 would leave
391
+ the merchant unable to undo their own entry. */}
392
+ <Controller
393
+ name={`variants.${variantIndex}.carton_weight`}
394
+ control={form.control}
395
+ render={({ field }) => (
396
+ <InputGroup>
397
+ <InputGroupInput
398
+ id={`variant-${variantIndex}-carton-weight`}
399
+ type="number"
400
+ step="0.01"
401
+ min="0"
402
+ disabled={!hasCartonTypes}
403
+ value={field.value ?? ''}
404
+ onChange={(event) => {
405
+ const parsed = Number(event.target.value)
406
+ field.onChange(
407
+ event.target.value === '' || Number.isNaN(parsed) ? null : parsed,
408
+ )
409
+ }}
410
+ />
411
+ {/* The same scale the variant's own weight is in —
412
+ there is one weight unit per variant, so saying
413
+ which beats offering a second selector. */}
414
+ <InputGroupAddon align="inline-end">
415
+ <InputGroupText>{cartonWeightUnit}</InputGroupText>
416
+ </InputGroupAddon>
417
+ </InputGroup>
418
+ )}
419
+ />
420
+ <FieldDescription>
421
+ {t('admin.fields.variant.carton_weight.help')}
422
+ </FieldDescription>
423
+ </Field>
424
+ <Field>
425
+ <FieldLabel htmlFor={`variant-${variantIndex}-cartons-per-pallet`}>
426
+ {t('admin.fields.variant.cartons_per_pallet.label')}
427
+ </FieldLabel>
428
+ <Input
429
+ id={`variant-${variantIndex}-cartons-per-pallet`}
430
+ type="number"
431
+ min="1"
432
+ step="1"
433
+ disabled={!hasCartonTypes}
434
+ {...form.register(`variants.${variantIndex}.cartons_per_pallet`)}
435
+ />
436
+ <FieldError
437
+ errors={[form.formState.errors.variants?.[variantIndex]?.cartons_per_pallet]}
438
+ />
439
+ </Field>
440
+ </div>
441
+ </FormSection>
442
+ )}
443
+
444
+ <FormSection title={t('admin.products.variants.sheet.customs')}>
445
+ <p className="text-sm text-muted-foreground">
446
+ {t('admin.products.variants.sheet.customs_help')}
447
+ </p>
448
+ <div className="grid grid-cols-2 gap-3">
449
+ <Field>
450
+ <FieldLabel htmlFor={`variant-${variantIndex}-hs-code`}>
451
+ {t('admin.fields.variant.hs_code.label')}
452
+ </FieldLabel>
453
+ <Input
454
+ id={`variant-${variantIndex}-hs-code`}
455
+ inputMode="numeric"
456
+ placeholder={t('admin.fields.variant.hs_code.placeholder')}
457
+ {...form.register(`variants.${variantIndex}.hs_code`, {
458
+ setValueAs: normalizeHsCode,
459
+ })}
460
+ />
461
+ <FieldError errors={[form.formState.errors.variants?.[variantIndex]?.hs_code]} />
462
+ </Field>
463
+ <Field>
464
+ <FieldLabel htmlFor={`variant-${variantIndex}-country-of-origin`}>
465
+ {t('admin.fields.variant.country_of_origin.label')}
466
+ </FieldLabel>
467
+ <Controller
468
+ name={`variants.${variantIndex}.country_of_origin`}
469
+ control={form.control}
470
+ render={({ field }) => (
471
+ <CountryCombobox
472
+ id={`variant-${variantIndex}-country-of-origin`}
473
+ value={field.value ?? null}
474
+ onValueChange={(iso) => field.onChange(iso || null)}
475
+ />
476
+ )}
477
+ />
478
+ <FieldError
479
+ errors={[form.formState.errors.variants?.[variantIndex]?.country_of_origin]}
480
+ />
481
+ </Field>
482
+ </div>
483
+ <Field>
484
+ <FieldLabel htmlFor={`variant-${variantIndex}-customs-description`}>
485
+ {t('admin.fields.variant.customs_description.label')}
486
+ </FieldLabel>
487
+ <Input
488
+ id={`variant-${variantIndex}-customs-description`}
489
+ placeholder={t('admin.fields.variant.customs_description.placeholder')}
490
+ {...form.register(`variants.${variantIndex}.customs_description`, {
491
+ setValueAs: normalizeCustomsDescription,
492
+ })}
493
+ />
494
+ <FieldError
495
+ errors={[form.formState.errors.variants?.[variantIndex]?.customs_description]}
496
+ />
497
+ </Field>
498
+ </FormSection>
499
+
500
+ <FormSection title={t('admin.products.variants.sheet.ordering')}>
501
+ <p className="text-sm text-muted-foreground">
502
+ {t('admin.products.variants.sheet.ordering_help')}
503
+ </p>
504
+ <div className="grid grid-cols-2 gap-3">
505
+ <Field>
506
+ <FieldLabel htmlFor={`variant-${variantIndex}-moq`}>
507
+ {t('admin.fields.variant.minimum_order_quantity.label')}
508
+ </FieldLabel>
509
+ <Input
510
+ id={`variant-${variantIndex}-moq`}
511
+ type="number"
512
+ min="1"
513
+ step="1"
514
+ placeholder={t('admin.fields.variant.minimum_order_quantity.placeholder')}
515
+ {...form.register(`variants.${variantIndex}.minimum_order_quantity`)}
516
+ />
517
+ <FieldError
518
+ errors={[form.formState.errors.variants?.[variantIndex]?.minimum_order_quantity]}
519
+ />
520
+ </Field>
521
+ <Field>
522
+ <FieldLabel htmlFor={`variant-${variantIndex}-order-multiple`}>
523
+ {t('admin.fields.variant.order_multiple.label')}
524
+ </FieldLabel>
525
+ <Input
526
+ id={`variant-${variantIndex}-order-multiple`}
527
+ type="number"
528
+ min="1"
529
+ step="1"
530
+ placeholder={t('admin.fields.variant.order_multiple.placeholder')}
531
+ {...form.register(`variants.${variantIndex}.order_multiple`)}
532
+ />
533
+ <FieldError
534
+ errors={[form.formState.errors.variants?.[variantIndex]?.order_multiple]}
535
+ />
536
+ </Field>
537
+ <Field>
538
+ <FieldLabel htmlFor={`variant-${variantIndex}-purchase-unit`}>
539
+ {t('admin.fields.variant.purchase_unit.label')}
540
+ </FieldLabel>
541
+ <Controller
542
+ name={`variants.${variantIndex}.purchase_unit`}
543
+ control={form.control}
544
+ render={({ field }) => (
545
+ <Select
546
+ value={field.value ?? ''}
547
+ onValueChange={(v) => field.onChange(v || null)}
548
+ >
549
+ <SelectTrigger
550
+ id={`variant-${variantIndex}-purchase-unit`}
551
+ className="w-full"
552
+ >
553
+ <SelectValue>
554
+ {(v) =>
555
+ v
556
+ ? t(`admin.fields.variant.purchase_unit.options.${v as string}`)
557
+ : t('admin.fields.variant.purchase_unit.options.unit')
558
+ }
559
+ </SelectValue>
560
+ </SelectTrigger>
561
+ <SelectContent>
562
+ {PURCHASE_UNITS.map((unit) => (
563
+ <SelectItem key={unit} value={unit}>
564
+ {t(`admin.fields.variant.purchase_unit.options.${unit}`)}
565
+ </SelectItem>
566
+ ))}
567
+ </SelectContent>
568
+ </Select>
569
+ )}
570
+ />
571
+ </Field>
572
+ <Field>
573
+ <FieldLabel htmlFor={`variant-${variantIndex}-units-per-carton`}>
574
+ {t('admin.fields.variant.units_per_carton.label')}
575
+ </FieldLabel>
576
+ <Input
577
+ id={`variant-${variantIndex}-units-per-carton`}
578
+ type="number"
579
+ min="1"
580
+ step="1"
581
+ {...form.register(`variants.${variantIndex}.units_per_carton`)}
582
+ />
583
+ <FieldError
584
+ errors={[form.formState.errors.variants?.[variantIndex]?.units_per_carton]}
585
+ />
586
+ </Field>
587
+ </div>
588
+ </FormSection>
589
+
590
+ <FormSection title={t('admin.products.variants.sheet.availability')}>
591
+ <Field>
592
+ <div className="flex items-start justify-between gap-4">
593
+ <div className="flex flex-col">
594
+ <FieldLabel
595
+ htmlFor={`variant-${variantIndex}-preorderable`}
596
+ className="cursor-pointer"
597
+ >
598
+ {t('admin.fields.variant.preorderable.label')}
599
+ </FieldLabel>
600
+ <span className="text-xs text-muted-foreground">
601
+ {t('admin.fields.variant.preorderable.help')}
602
+ </span>
603
+ </div>
604
+ <Controller
605
+ name={`variants.${variantIndex}.preorderable`}
606
+ control={form.control}
607
+ render={({ field }) => (
608
+ <Switch
609
+ id={`variant-${variantIndex}-preorderable`}
610
+ checked={!!field.value}
611
+ onCheckedChange={field.onChange}
612
+ />
613
+ )}
614
+ />
615
+ </div>
616
+ </Field>
617
+
618
+ {variant.preorderable && (
619
+ <Field>
620
+ <FieldLabel>{t('admin.fields.variant.preorder_ships_at.label')}</FieldLabel>
621
+ <Controller
622
+ control={form.control}
623
+ name={`variants.${variantIndex}.preorder_ships_at`}
624
+ render={({ field }) => (
625
+ <StoreDatePicker
626
+ value={field.value ?? null}
627
+ onChange={(next) => field.onChange(next ?? null)}
628
+ placeholder={t('admin.fields.variant.preorder_ships_at.placeholder')}
629
+ includeTime
630
+ inline
631
+ />
632
+ )}
633
+ />
634
+ <span className="text-xs text-muted-foreground">
635
+ {t('admin.fields.variant.preorder_ships_at.help')}
636
+ </span>
637
+ </Field>
638
+ )}
639
+
640
+ <Field>
641
+ <FieldLabel htmlFor={`variant-${variantIndex}-backorder-limit`}>
642
+ {t('admin.fields.variant.backorder_limit.label')}
643
+ </FieldLabel>
644
+ <Controller
645
+ control={form.control}
646
+ name={`variants.${variantIndex}.backorder_limit`}
647
+ render={({ field }) => (
648
+ <Input
649
+ id={`variant-${variantIndex}-backorder-limit`}
650
+ type="number"
651
+ min="0"
652
+ step="1"
653
+ placeholder={t('admin.fields.variant.backorder_limit.placeholder')}
654
+ value={field.value ?? ''}
655
+ onChange={(event) => {
656
+ const parsed = Number(event.target.value)
657
+ field.onChange(
658
+ event.target.value === '' || Number.isNaN(parsed)
659
+ ? null
660
+ : Math.max(0, Math.trunc(parsed)),
661
+ )
662
+ }}
663
+ />
664
+ )}
665
+ />
666
+ <span className="text-xs text-muted-foreground">
667
+ {t('admin.fields.variant.backorder_limit.help')}
668
+ </span>
669
+ <FieldError
670
+ errors={[form.formState.errors.variants?.[variantIndex]?.backorder_limit]}
671
+ />
672
+ </Field>
673
+ </FormSection>
674
+
675
+ {hasTaxCategories && (
676
+ <FormSection title={t('admin.fields.tax.label')}>
677
+ <Field>
678
+ <FieldLabel htmlFor={`variant-${variantIndex}-tax-cat`}>
679
+ {t('admin.fields.tax_category_id.label')}
680
+ </FieldLabel>
681
+ <Controller
682
+ name={`variants.${variantIndex}.tax_category_id`}
683
+ control={form.control}
684
+ render={({ field }) => (
685
+ <Select
686
+ value={field.value ?? ''}
687
+ onValueChange={(v) => field.onChange(v || null)}
688
+ >
689
+ <SelectTrigger id={`variant-${variantIndex}-tax-cat`} className="w-full">
690
+ <SelectValue
691
+ placeholder={t('admin.products.variants.sheet.tax_category_placeholder')}
692
+ >
693
+ {(v) =>
694
+ taxCategories.find((c) => c.id === v)?.name ??
695
+ t('admin.products.variants.sheet.tax_category_placeholder')
696
+ }
697
+ </SelectValue>
698
+ </SelectTrigger>
699
+ <SelectContent>
700
+ {taxCategories.map((cat) => (
701
+ <SelectItem key={cat.id} value={cat.id}>
702
+ {cat.name}
703
+ </SelectItem>
704
+ ))}
705
+ </SelectContent>
706
+ </Select>
707
+ )}
708
+ />
709
+ </Field>
710
+ </FormSection>
711
+ )}
712
+ </div>
713
+
714
+ <SheetFooter>
715
+ <Button type="button" variant="outline" onClick={handleCancel}>
716
+ {t('admin.actions.cancel')}
717
+ </Button>
718
+ <Button type="button" onClick={handleDone}>
719
+ {t('admin.actions.done')}
720
+ </Button>
721
+ </SheetFooter>
722
+ </SheetContent>
723
+ </Sheet>
724
+ )
725
+ }