@spree/dashboard-core 0.13.0 → 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 +4 -0
- package/dist/vite/index.js +17 -3
- package/package.json +7 -7
- package/src/api-client.ts +643 -0
- package/src/components/address-form-dialog.test.ts +25 -0
- package/src/components/address-form-dialog.tsx +203 -79
- package/src/components/app-shell.tsx +171 -0
- package/src/components/app-sidebar.tsx +184 -27
- package/src/components/bulk-action-bar.tsx +89 -157
- package/src/components/country-combobox.tsx +69 -34
- package/src/components/country-state-fields.tsx +43 -11
- package/src/components/currency-select.tsx +81 -66
- package/src/components/export-button.tsx +62 -5
- package/src/components/file-upload-field.tsx +32 -10
- package/src/components/import-button.tsx +97 -36
- package/src/components/import-wizard-dialog.tsx +710 -0
- package/src/components/locale-select.tsx +70 -57
- package/src/components/media-picker-sheet.tsx +307 -0
- package/src/components/mobile-top-bar.tsx +75 -0
- package/src/components/nav-main.tsx +197 -27
- package/src/components/page-header.tsx +203 -61
- package/src/components/page-tabs.tsx +4 -1
- package/src/components/preferences-form.tsx +98 -4
- package/src/components/resource-combobox.tsx +52 -4
- package/src/components/resource-picker-sheet.tsx +209 -73
- package/src/components/resource-table.tsx +425 -131
- package/src/components/route-announcer.tsx +64 -0
- package/src/components/section-heading.tsx +95 -0
- package/src/components/settings-sidebar.tsx +290 -60
- package/src/components/sheet-top-bar.tsx +44 -0
- package/src/components/sidebar-search.tsx +83 -0
- package/src/components/sidebar-user.tsx +213 -0
- package/src/components/skip-link.tsx +36 -0
- package/src/components/store-date-picker.tsx +6 -2
- package/src/components/store-switcher.tsx +63 -22
- package/src/components/table-toolbar.tsx +1373 -442
- package/src/components/tag-combobox.tsx +12 -4
- package/src/components/top-bar-breadcrumbs.tsx +235 -0
- package/src/components/wizard-close-guard.test.ts +54 -0
- package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
- package/src/hooks/use-command-palette.tsx +13 -0
- package/src/hooks/use-countries.test.ts +56 -0
- package/src/hooks/use-countries.ts +51 -5
- package/src/hooks/use-direct-upload.ts +16 -3
- package/src/hooks/use-export.ts +51 -16
- package/src/hooks/use-import.ts +261 -14
- package/src/hooks/use-package-types.ts +132 -0
- package/src/hooks/use-resource-mutation.ts +74 -13
- package/src/hooks/use-stock-locations.ts +131 -0
- package/src/hooks/use-switch-admin-locale.ts +2 -2
- package/src/hooks/use-visual-viewport-offset.ts +61 -0
- package/src/index.ts +45 -1
- package/src/lib/cancel-order-form.ts +27 -0
- package/src/lib/create-action-registry.ts +278 -0
- package/src/lib/date-presets.test.ts +86 -0
- package/src/lib/date-presets.ts +98 -0
- package/src/lib/delivery-form.ts +15 -0
- package/src/lib/docs.ts +32 -0
- package/src/lib/download.ts +13 -1
- package/src/lib/filters-to-ransack.test.ts +113 -0
- package/src/lib/filters-to-ransack.ts +22 -1
- package/src/lib/form-errors.ts +122 -10
- package/src/lib/form-mappers.ts +76 -0
- package/src/lib/fulfillment-items.test.ts +213 -0
- package/src/lib/fulfillment-items.ts +171 -0
- package/src/lib/i18n.ts +19 -11
- package/src/lib/import-types.ts +25 -0
- package/src/lib/label-upload-form.ts +22 -0
- package/src/lib/nav-registry.ts +71 -3
- package/src/lib/permissions.ts +46 -1
- package/src/lib/progress.ts +18 -0
- package/src/lib/query-keys.ts +13 -13
- package/src/lib/sample-csv.ts +21 -0
- package/src/lib/search-registry.ts +6 -2
- package/src/lib/settings-nav-registry.ts +104 -11
- package/src/lib/storefront.ts +19 -0
- package/src/lib/table-registry.ts +56 -0
- package/src/lib/type-labels.test.ts +68 -0
- package/src/lib/type-labels.ts +78 -0
- package/src/locales/ar.json +1889 -516
- package/src/locales/de.json +1883 -515
- package/src/locales/en.json +1886 -516
- package/src/locales/fr.json +1884 -516
- package/src/locales/pl.json +1913 -516
- package/src/locales/zh-CN.json +1883 -516
- package/src/pages/package-types.tsx +484 -0
- package/src/pages/settings-index.tsx +112 -0
- package/src/pages/stock-locations.tsx +674 -0
- package/src/plugin.ts +29 -0
- package/src/products/add-video-dialog.tsx +100 -0
- package/src/products/bulk-variants-dialog.tsx +301 -0
- package/src/products/carton-size.test.ts +51 -0
- package/src/products/carton-size.ts +20 -0
- package/src/products/currency-parts.ts +21 -0
- package/src/products/inventory-section.tsx +259 -0
- package/src/products/media-edit-sheet.tsx +336 -0
- package/src/products/normalize-customs.test.ts +39 -0
- package/src/products/normalize-customs.ts +27 -0
- package/src/products/normalize-money.test.ts +58 -0
- package/src/products/normalize-money.ts +73 -0
- package/src/products/normalize-quantity.test.ts +32 -0
- package/src/products/normalize-quantity.ts +31 -0
- package/src/products/product-bulk-price-editor.tsx +165 -0
- package/src/products/product-form-cards.tsx +1167 -0
- package/src/products/product-form-mapping.ts +212 -0
- package/src/products/product-schema.ts +318 -0
- package/src/products/product-types.ts +130 -0
- package/src/products/use-product-form-data.ts +273 -0
- package/src/products/variant-edit-sheet.tsx +725 -0
- package/src/products/variants-matrix.test.ts +154 -0
- package/src/products/variants-matrix.ts +244 -0
- package/src/products/variants-options-builder.tsx +571 -0
- package/src/products/variants-section.tsx +535 -0
- package/src/products/video-url.test.ts +71 -0
- package/src/products/video-url.ts +83 -0
- package/src/providers/auth-provider.tsx +70 -12
- package/src/providers/permission-provider.tsx +68 -28
- package/src/providers/sticky-header-provider.tsx +134 -0
- package/src/providers/store-provider.tsx +36 -3
- package/src/providers/tenant-provider.tsx +33 -0
- package/src/schemas/package-type.ts +95 -0
- package/src/schemas/stock-location.ts +126 -0
- package/src/tables/package-types.tsx +74 -0
- package/src/tables/stock-locations.tsx +124 -0
- package/src/vite/index.ts +40 -7
- package/src/components/top-bar.tsx +0 -210
|
@@ -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
|
+
}
|