@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.
- 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 +52 -27
- 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 +383 -127
- 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 +19 -14
- package/src/components/table-toolbar.tsx +1357 -440
- 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 +241 -16
- 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 +9 -6
- package/src/lib/search-registry.ts +6 -2
- package/src/lib/settings-nav-registry.ts +104 -11
- package/src/lib/table-registry.ts +50 -0
- package/src/lib/type-labels.test.ts +68 -0
- package/src/lib/type-labels.ts +78 -0
- package/src/locales/ar.json +1889 -517
- package/src/locales/de.json +1884 -517
- package/src/locales/en.json +1886 -519
- package/src/locales/fr.json +1884 -517
- package/src/locales/pl.json +1913 -517
- package/src/locales/zh-CN.json +1883 -517
- 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 -209
package/src/plugin.ts
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
// ],
|
|
32
32
|
// })
|
|
33
33
|
|
|
34
|
+
import i18n from 'i18next'
|
|
34
35
|
import type { CustomFieldComponent } from './lib/custom-field-components'
|
|
35
36
|
import { customFieldComponents } from './lib/custom-field-components'
|
|
36
37
|
import type { FormFieldRegistration } from './lib/form-fields-registry'
|
|
@@ -126,6 +127,21 @@ export interface DashboardPluginConfig {
|
|
|
126
127
|
* custom-fields card renders it.
|
|
127
128
|
*/
|
|
128
129
|
customFieldComponents?: Record<string, CustomFieldComponent>
|
|
130
|
+
/**
|
|
131
|
+
* Translations for the plugin's own copy, keyed by language tag:
|
|
132
|
+
*
|
|
133
|
+
* locales: {
|
|
134
|
+
* en: { admin: { types: { promotion_rule: { wishlist: { name: 'Wishlist' } } } } },
|
|
135
|
+
* de: { admin: { types: { promotion_rule: { wishlist: { name: 'Wunschliste' } } } } },
|
|
136
|
+
* }
|
|
137
|
+
*
|
|
138
|
+
* Merged into the base namespace, so a plugin adds keys without dropping
|
|
139
|
+
* the dashboard's own. This is where a gem that registers a Ruby rule,
|
|
140
|
+
* calculator or requirement kind puts the name merchants read: without it
|
|
141
|
+
* the dashboard falls back to the English label the API sends, which is
|
|
142
|
+
* resolved in the store's locale rather than the admin's.
|
|
143
|
+
*/
|
|
144
|
+
locales?: Record<string, Record<string, unknown>>
|
|
129
145
|
}
|
|
130
146
|
|
|
131
147
|
/**
|
|
@@ -156,6 +172,19 @@ export function defineDashboardPlugin(config: DashboardPluginConfig): void {
|
|
|
156
172
|
}
|
|
157
173
|
}
|
|
158
174
|
|
|
175
|
+
// Before anything else: a nav entry or table column registered below may
|
|
176
|
+
// read a key this plugin ships.
|
|
177
|
+
if (config.locales) {
|
|
178
|
+
for (const [language, resources] of Object.entries(config.locales)) {
|
|
179
|
+
safely(
|
|
180
|
+
(lng: string, bundle: Record<string, unknown>) =>
|
|
181
|
+
i18n.addResourceBundle(lng, 'translation', bundle, true, true),
|
|
182
|
+
language,
|
|
183
|
+
resources,
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
159
188
|
if (config.nav) {
|
|
160
189
|
const m: NavMutations = Array.isArray(config.nav) ? { add: config.nav } : config.nav
|
|
161
190
|
// add before addChildren so a plugin can add a parent then nest under it.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { isSupportedVideoUrl } from '@spree/dashboard-core'
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
Dialog,
|
|
5
|
+
DialogBody,
|
|
6
|
+
DialogContent,
|
|
7
|
+
DialogFooter,
|
|
8
|
+
DialogHeader,
|
|
9
|
+
DialogTitle,
|
|
10
|
+
Field,
|
|
11
|
+
FieldDescription,
|
|
12
|
+
FieldError,
|
|
13
|
+
FieldLabel,
|
|
14
|
+
Input,
|
|
15
|
+
} from '@spree/dashboard-ui'
|
|
16
|
+
import { type FormEvent, useState } from 'react'
|
|
17
|
+
import { useTranslation } from 'react-i18next'
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
open: boolean
|
|
21
|
+
onOpenChange: (open: boolean) => void
|
|
22
|
+
onAdd: (url: string) => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Collects a YouTube or Vimeo link. The same check runs again on the server,
|
|
26
|
+
// so this is here to tell the merchant straight away rather than after a save.
|
|
27
|
+
export function AddVideoDialog({ open, onOpenChange, onAdd }: Props) {
|
|
28
|
+
const { t } = useTranslation()
|
|
29
|
+
const [url, setUrl] = useState('')
|
|
30
|
+
const [touched, setTouched] = useState(false)
|
|
31
|
+
|
|
32
|
+
const invalid = touched && url.trim().length > 0 && !isSupportedVideoUrl(url)
|
|
33
|
+
|
|
34
|
+
const close = () => {
|
|
35
|
+
setUrl('')
|
|
36
|
+
setTouched(false)
|
|
37
|
+
onOpenChange(false)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// This dialog renders inside the product form, so the submit must be stopped
|
|
41
|
+
// here — letting it bubble would save the whole product.
|
|
42
|
+
const submit = (event: FormEvent<HTMLFormElement>) => {
|
|
43
|
+
event.preventDefault()
|
|
44
|
+
event.stopPropagation()
|
|
45
|
+
setTouched(true)
|
|
46
|
+
if (!isSupportedVideoUrl(url)) return
|
|
47
|
+
onAdd(url.trim())
|
|
48
|
+
close()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<Dialog
|
|
53
|
+
open={open}
|
|
54
|
+
onOpenChange={(next) => {
|
|
55
|
+
if (!next) close()
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
<DialogContent>
|
|
59
|
+
<DialogHeader>
|
|
60
|
+
<DialogTitle>{t('admin.products.media.add_video_title')}</DialogTitle>
|
|
61
|
+
</DialogHeader>
|
|
62
|
+
|
|
63
|
+
<form onSubmit={submit} className="contents">
|
|
64
|
+
<DialogBody>
|
|
65
|
+
<Field>
|
|
66
|
+
<FieldLabel htmlFor="external-video-url">
|
|
67
|
+
{t('admin.fields.media.external_video_url.label')}
|
|
68
|
+
</FieldLabel>
|
|
69
|
+
<Input
|
|
70
|
+
id="external-video-url"
|
|
71
|
+
value={url}
|
|
72
|
+
autoFocus
|
|
73
|
+
aria-invalid={invalid || undefined}
|
|
74
|
+
placeholder={t('admin.fields.media.external_video_url.placeholder')}
|
|
75
|
+
onChange={(e) => setUrl(e.target.value)}
|
|
76
|
+
onBlur={() => setTouched(true)}
|
|
77
|
+
/>
|
|
78
|
+
{invalid ? (
|
|
79
|
+
<FieldError>{t('admin.products.media.video_url_invalid')}</FieldError>
|
|
80
|
+
) : (
|
|
81
|
+
<FieldDescription>
|
|
82
|
+
{t('admin.fields.media.external_video_url.help')}
|
|
83
|
+
</FieldDescription>
|
|
84
|
+
)}
|
|
85
|
+
</Field>
|
|
86
|
+
</DialogBody>
|
|
87
|
+
|
|
88
|
+
<DialogFooter>
|
|
89
|
+
<Button type="button" variant="outline" onClick={close}>
|
|
90
|
+
{t('admin.actions.cancel')}
|
|
91
|
+
</Button>
|
|
92
|
+
<Button type="submit" disabled={url.trim().length === 0}>
|
|
93
|
+
{t('admin.products.media.add_video_submit')}
|
|
94
|
+
</Button>
|
|
95
|
+
</DialogFooter>
|
|
96
|
+
</form>
|
|
97
|
+
</DialogContent>
|
|
98
|
+
</Dialog>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import type { ProductFormValues, VariantFormValues } from '@spree/dashboard-core'
|
|
2
|
+
import { useCountries, useCountryDisplayName } from '@spree/dashboard-core'
|
|
3
|
+
import {
|
|
4
|
+
type BulkVariantsChange,
|
|
5
|
+
type BulkVariantsRow,
|
|
6
|
+
BulkVariantsTable,
|
|
7
|
+
Button,
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogBody,
|
|
10
|
+
DialogContent,
|
|
11
|
+
DialogHeader,
|
|
12
|
+
DialogTitle,
|
|
13
|
+
} from '@spree/dashboard-ui'
|
|
14
|
+
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
|
15
|
+
import { type UseFormReturn, useWatch } from 'react-hook-form'
|
|
16
|
+
import { useTranslation } from 'react-i18next'
|
|
17
|
+
import { cartonSize } from './carton-size'
|
|
18
|
+
import { normalizeCustomsDescription, normalizeHsCode } from './normalize-customs'
|
|
19
|
+
import {
|
|
20
|
+
useFormCartonPackageTypes as useCartonPackageTypes,
|
|
21
|
+
useFormOptionTypes as useOptionTypes,
|
|
22
|
+
useFormTaxCategories as useTaxCategories,
|
|
23
|
+
} from './use-product-form-data'
|
|
24
|
+
import { composeOptionsText } from './variants-matrix'
|
|
25
|
+
|
|
26
|
+
const WEIGHT_UNITS = ['g', 'kg', 'lb', 'oz'] as const
|
|
27
|
+
const DIMENSION_UNITS = ['mm', 'cm', 'in'] as const
|
|
28
|
+
|
|
29
|
+
interface Props {
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
31
|
+
form: UseFormReturn<ProductFormValues, any, any>
|
|
32
|
+
open: boolean
|
|
33
|
+
onOpenChange: (open: boolean) => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Full-page bulk variant editor: a spreadsheet of every per-variant field
|
|
38
|
+
* from the variant edit sheet (SKU, barcode, shipping measurements + units,
|
|
39
|
+
* availability, tax category) across all of a product's variants at once.
|
|
40
|
+
* Selection, copy/paste, and drag-fill come from the shared DataGrid.
|
|
41
|
+
*
|
|
42
|
+
* Form-backed like the edit sheet: cells read from and write to the parent
|
|
43
|
+
* product form's `variants[]`, so Done keeps the edits in form state (the
|
|
44
|
+
* product Save button commits them) and Cancel restores the snapshot taken
|
|
45
|
+
* when the dialog opened. Closing via Esc/overlay/X cancels, mirroring the
|
|
46
|
+
* sheet.
|
|
47
|
+
*/
|
|
48
|
+
export function BulkVariantsDialog({ form, open, onOpenChange }: Props) {
|
|
49
|
+
const { t } = useTranslation()
|
|
50
|
+
const variants = useWatch({ control: form.control, name: 'variants' }) ?? []
|
|
51
|
+
const { data: optionTypesData } = useOptionTypes()
|
|
52
|
+
const optionTypes = useMemo(() => optionTypesData?.data ?? [], [optionTypesData])
|
|
53
|
+
const { data: taxCategoriesResponse } = useTaxCategories()
|
|
54
|
+
const taxCategories = taxCategoriesResponse?.data ?? []
|
|
55
|
+
const { data: cartonTypesResponse } = useCartonPackageTypes()
|
|
56
|
+
const cartonTypes = cartonTypesResponse?.data ?? []
|
|
57
|
+
const { countries } = useCountries()
|
|
58
|
+
const countryName = useCountryDisplayName()
|
|
59
|
+
|
|
60
|
+
// ISO codes as values — customs data must survive the countries table, and
|
|
61
|
+
// a country record id would not.
|
|
62
|
+
const countryOptions = useMemo(
|
|
63
|
+
() =>
|
|
64
|
+
countries.map((country) => ({
|
|
65
|
+
value: country.iso,
|
|
66
|
+
label: countryName({ iso: country.iso, iso3: country.iso3, name: country.name }),
|
|
67
|
+
})),
|
|
68
|
+
[countries, countryName],
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
// Snapshot the variants when the dialog opens so Cancel can restore them.
|
|
72
|
+
// Deep-clone — `form.getValues` returns references into RHF's internal
|
|
73
|
+
// state, and cell edits mutate them in place (same trap the edit sheet
|
|
74
|
+
// documents).
|
|
75
|
+
const snapshotRef = useRef<VariantFormValues[] | null>(null)
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!open) return
|
|
78
|
+
snapshotRef.current = structuredClone(form.getValues('variants') ?? []) as VariantFormValues[]
|
|
79
|
+
}, [open, form])
|
|
80
|
+
|
|
81
|
+
const handleCancel = useCallback(() => {
|
|
82
|
+
const snapshot = snapshotRef.current
|
|
83
|
+
if (snapshot) {
|
|
84
|
+
// Restore element-by-element via resetField so each variant's dirty
|
|
85
|
+
// bit clears too (the edit sheet's Cancel does the same). The bulk
|
|
86
|
+
// editor never adds or removes rows, so indices are stable.
|
|
87
|
+
snapshot.forEach((value, index) => {
|
|
88
|
+
form.resetField(`variants.${index}`, { defaultValue: value })
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
snapshotRef.current = null
|
|
92
|
+
onOpenChange(false)
|
|
93
|
+
}, [form, onOpenChange])
|
|
94
|
+
|
|
95
|
+
const handleDone = useCallback(() => {
|
|
96
|
+
snapshotRef.current = null
|
|
97
|
+
onOpenChange(false)
|
|
98
|
+
}, [onOpenChange])
|
|
99
|
+
|
|
100
|
+
const rows = useMemo<BulkVariantsRow[]>(
|
|
101
|
+
() =>
|
|
102
|
+
variants.map((v, idx) => ({
|
|
103
|
+
id: `variant:${idx}`,
|
|
104
|
+
variantLabel: v.options.length > 0 ? composeOptionsText(v.options, optionTypes) : null,
|
|
105
|
+
sku: v.sku ?? null,
|
|
106
|
+
barcode: v.barcode ?? null,
|
|
107
|
+
weight: v.weight != null ? String(v.weight) : null,
|
|
108
|
+
weightUnit: v.weight_unit ?? null,
|
|
109
|
+
height: v.height != null ? String(v.height) : null,
|
|
110
|
+
width: v.width != null ? String(v.width) : null,
|
|
111
|
+
depth: v.depth != null ? String(v.depth) : null,
|
|
112
|
+
dimensionsUnit: v.dimensions_unit ?? null,
|
|
113
|
+
preorderable: !!v.preorderable,
|
|
114
|
+
backorderLimit: v.backorder_limit != null ? String(v.backorder_limit) : null,
|
|
115
|
+
taxCategoryId: v.tax_category_id ?? null,
|
|
116
|
+
hsCode: v.hs_code ?? null,
|
|
117
|
+
countryOfOrigin: v.country_of_origin ?? null,
|
|
118
|
+
customsDescription: v.customs_description ?? null,
|
|
119
|
+
unitsPerCarton: v.units_per_carton ?? null,
|
|
120
|
+
cartonPackageTypeId: v.carton_package_type_id ?? null,
|
|
121
|
+
cartonWeight: v.carton_weight != null ? String(v.carton_weight) : null,
|
|
122
|
+
cartonsPerPallet: v.cartons_per_pallet != null ? String(v.cartons_per_pallet) : null,
|
|
123
|
+
})),
|
|
124
|
+
[variants, optionTypes],
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
const handleChange = useCallback(
|
|
128
|
+
(rowId: string, change: BulkVariantsChange) => {
|
|
129
|
+
if (!rowId.startsWith('variant:')) return
|
|
130
|
+
const idx = Number.parseInt(rowId.slice('variant:'.length), 10)
|
|
131
|
+
if (Number.isNaN(idx)) return
|
|
132
|
+
const set = (path: Parameters<typeof form.setValue>[0], value: unknown) =>
|
|
133
|
+
form.setValue(path, value as never, { shouldDirty: true })
|
|
134
|
+
|
|
135
|
+
switch (change.field) {
|
|
136
|
+
case 'sku':
|
|
137
|
+
set(`variants.${idx}.sku`, change.value)
|
|
138
|
+
break
|
|
139
|
+
case 'barcode':
|
|
140
|
+
set(`variants.${idx}.barcode`, change.value)
|
|
141
|
+
break
|
|
142
|
+
case 'weightUnit':
|
|
143
|
+
set(`variants.${idx}.weight_unit`, change.value)
|
|
144
|
+
break
|
|
145
|
+
case 'dimensionsUnit':
|
|
146
|
+
set(`variants.${idx}.dimensions_unit`, change.value)
|
|
147
|
+
break
|
|
148
|
+
case 'taxCategoryId':
|
|
149
|
+
set(`variants.${idx}.tax_category_id`, change.value)
|
|
150
|
+
break
|
|
151
|
+
case 'hsCode':
|
|
152
|
+
set(`variants.${idx}.hs_code`, normalizeHsCode(change.value))
|
|
153
|
+
break
|
|
154
|
+
case 'countryOfOrigin':
|
|
155
|
+
// Already an ISO code — SelectCell resolves pasted country names
|
|
156
|
+
// against the option list before committing.
|
|
157
|
+
set(`variants.${idx}.country_of_origin`, change.value)
|
|
158
|
+
break
|
|
159
|
+
case 'customsDescription':
|
|
160
|
+
set(`variants.${idx}.customs_description`, normalizeCustomsDescription(change.value))
|
|
161
|
+
break
|
|
162
|
+
case 'cartonPackageTypeId':
|
|
163
|
+
set(`variants.${idx}.carton_package_type_id`, change.value)
|
|
164
|
+
break
|
|
165
|
+
// Counts are held as typed strings so an unusable entry can be
|
|
166
|
+
// reported rather than silently coerced away.
|
|
167
|
+
case 'unitsPerCarton':
|
|
168
|
+
set(`variants.${idx}.units_per_carton`, change.value)
|
|
169
|
+
break
|
|
170
|
+
case 'cartonsPerPallet':
|
|
171
|
+
set(`variants.${idx}.cartons_per_pallet`, change.value)
|
|
172
|
+
break
|
|
173
|
+
case 'cartonWeight': {
|
|
174
|
+
if (change.value == null || change.value.trim() === '') {
|
|
175
|
+
set(`variants.${idx}.carton_weight`, null)
|
|
176
|
+
break
|
|
177
|
+
}
|
|
178
|
+
const parsed = Number(change.value.trim().replace(',', '.'))
|
|
179
|
+
if (!Number.isFinite(parsed) || parsed < 0) break
|
|
180
|
+
set(`variants.${idx}.carton_weight`, parsed)
|
|
181
|
+
break
|
|
182
|
+
}
|
|
183
|
+
case 'preorderable':
|
|
184
|
+
set(`variants.${idx}.preorderable`, change.value)
|
|
185
|
+
break
|
|
186
|
+
case 'backorderLimit': {
|
|
187
|
+
if (change.value == null || change.value.trim() === '') {
|
|
188
|
+
set(`variants.${idx}.backorder_limit`, null)
|
|
189
|
+
break
|
|
190
|
+
}
|
|
191
|
+
const parsed = Number(change.value.trim().replace(',', '.'))
|
|
192
|
+
if (!Number.isFinite(parsed) || parsed < 0) break
|
|
193
|
+
set(`variants.${idx}.backorder_limit`, Math.trunc(parsed))
|
|
194
|
+
break
|
|
195
|
+
}
|
|
196
|
+
default: {
|
|
197
|
+
// weight / height / width / depth — decimals. Accept a comma
|
|
198
|
+
// decimal (the cell ships raw input); ignore anything that doesn't
|
|
199
|
+
// parse to a non-negative number rather than mangling it.
|
|
200
|
+
if (change.value == null || change.value.trim() === '') {
|
|
201
|
+
set(`variants.${idx}.${change.field}`, null)
|
|
202
|
+
break
|
|
203
|
+
}
|
|
204
|
+
const parsed = Number(change.value.trim().replace(',', '.'))
|
|
205
|
+
if (!Number.isFinite(parsed) || parsed < 0) break
|
|
206
|
+
set(`variants.${idx}.${change.field}`, parsed)
|
|
207
|
+
break
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
[form],
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
const unitOptions = (units: readonly string[]) => units.map((u) => ({ value: u, label: u }))
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : handleCancel())} modal>
|
|
218
|
+
<DialogContent
|
|
219
|
+
// Edge-to-edge minus a 3-unit gutter, same overrides as the bulk
|
|
220
|
+
// price dialog (see BulkPriceEditorDialog for why each is needed).
|
|
221
|
+
className="!inset-3 !w-auto !max-w-none !translate-x-0 !translate-y-0 flex flex-col p-0"
|
|
222
|
+
style={{ maxHeight: 'none' }}
|
|
223
|
+
showCloseButton={false}
|
|
224
|
+
>
|
|
225
|
+
<DialogHeader className="flex flex-row items-center justify-between gap-3 space-y-0 border-b p-3">
|
|
226
|
+
<DialogTitle className="truncate">
|
|
227
|
+
{t('admin.products.variants.bulk_edit.title')}
|
|
228
|
+
</DialogTitle>
|
|
229
|
+
<div className="flex items-center gap-2">
|
|
230
|
+
<Button type="button" size="sm" variant="outline" onClick={handleCancel}>
|
|
231
|
+
{t('admin.actions.cancel')}
|
|
232
|
+
</Button>
|
|
233
|
+
<Button type="button" size="sm" onClick={handleDone}>
|
|
234
|
+
{t('admin.actions.done')}
|
|
235
|
+
</Button>
|
|
236
|
+
</div>
|
|
237
|
+
</DialogHeader>
|
|
238
|
+
{/* No gutter: the body holds nothing but the grid, so padding only
|
|
239
|
+
narrows the space the columns have to fit in and leaves the
|
|
240
|
+
table's own border floating inside the dialog's.
|
|
241
|
+
|
|
242
|
+
`DialogBody` already scrolls on y and bounds its own height, so
|
|
243
|
+
the grid inside it has something to size against and stays the one
|
|
244
|
+
element that scrolls sideways. Do not add an overflow utility
|
|
245
|
+
here: the base class sets the `overflow-y` longhand, which a bare
|
|
246
|
+
`overflow-hidden` does not override at equal specificity. */}
|
|
247
|
+
<DialogBody className="min-h-0 flex-1 p-0">
|
|
248
|
+
<BulkVariantsTable
|
|
249
|
+
rows={rows}
|
|
250
|
+
onChange={handleChange}
|
|
251
|
+
weightUnitOptions={unitOptions(WEIGHT_UNITS)}
|
|
252
|
+
dimensionUnitOptions={unitOptions(DIMENSION_UNITS)}
|
|
253
|
+
taxCategoryOptions={
|
|
254
|
+
taxCategories.length > 0
|
|
255
|
+
? taxCategories.map((c) => ({ value: c.id, label: c.name }))
|
|
256
|
+
: undefined
|
|
257
|
+
}
|
|
258
|
+
countryOptions={countryOptions.length > 0 ? countryOptions : undefined}
|
|
259
|
+
cartonOptions={
|
|
260
|
+
cartonTypes.length > 0
|
|
261
|
+
? cartonTypes.map((carton) => ({
|
|
262
|
+
value: carton.id,
|
|
263
|
+
// The name stays the label: a pasted column matches on it.
|
|
264
|
+
label: carton.name,
|
|
265
|
+
hint: cartonSize(carton) ?? undefined,
|
|
266
|
+
}))
|
|
267
|
+
: undefined
|
|
268
|
+
}
|
|
269
|
+
labels={{
|
|
270
|
+
variant: t('admin.pages.products.price_lists.edit_prices.columns.variant'),
|
|
271
|
+
sku: t('admin.fields.variant.sku.label'),
|
|
272
|
+
barcode: t('admin.fields.variant.barcode.label'),
|
|
273
|
+
weight: t('admin.fields.variant.weight.label'),
|
|
274
|
+
weightUnit: t('admin.fields.variant.weight_unit.label'),
|
|
275
|
+
height: t('admin.fields.variant.height.label'),
|
|
276
|
+
width: t('admin.fields.variant.width.label'),
|
|
277
|
+
depth: t('admin.fields.variant.depth.label'),
|
|
278
|
+
dimensionsUnit: t('admin.fields.variant.dimensions_unit.label'),
|
|
279
|
+
preorderable: t('admin.fields.variant.preorderable.label'),
|
|
280
|
+
backorderLimit: t('admin.fields.variant.backorder_limit.label'),
|
|
281
|
+
taxCategory: t('admin.fields.tax_category_id.label'),
|
|
282
|
+
hsCode: t('admin.fields.variant.hs_code.label'),
|
|
283
|
+
countryOfOrigin: t('admin.fields.variant.country_of_origin.label'),
|
|
284
|
+
customsDescription: t('admin.fields.variant.customs_description.label'),
|
|
285
|
+
unitsPerCarton: t('admin.fields.variant.units_per_carton.label'),
|
|
286
|
+
cartonPackageType: t('admin.fields.variant.carton_package_type_id.label'),
|
|
287
|
+
cartonWeight: t('admin.fields.variant.carton_weight.label'),
|
|
288
|
+
cartonsPerPallet: t('admin.fields.variant.cartons_per_pallet.label'),
|
|
289
|
+
variantDefault: t('admin.products.variants.default_variant'),
|
|
290
|
+
unitDefault: t('admin.products.variants.bulk_edit.unset'),
|
|
291
|
+
taxCategoryNone: t('admin.products.variants.sheet.tax_category_placeholder'),
|
|
292
|
+
countryOfOriginNone: t('admin.products.variants.bulk_edit.unset'),
|
|
293
|
+
cartonPackageTypeNone: t('admin.products.variants.bulk_edit.unset'),
|
|
294
|
+
gridAriaLabel: t('admin.products.variants.bulk_edit.grid_aria'),
|
|
295
|
+
}}
|
|
296
|
+
/>
|
|
297
|
+
</DialogBody>
|
|
298
|
+
</DialogContent>
|
|
299
|
+
</Dialog>
|
|
300
|
+
)
|
|
301
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { cartonSize } from './carton-size'
|
|
3
|
+
|
|
4
|
+
describe('cartonSize', () => {
|
|
5
|
+
it('reads back the way a merchant would write it', () => {
|
|
6
|
+
expect(
|
|
7
|
+
cartonSize({
|
|
8
|
+
id: '1',
|
|
9
|
+
name: 'Large',
|
|
10
|
+
length: '60.0',
|
|
11
|
+
width: '40.0',
|
|
12
|
+
height: '40.0',
|
|
13
|
+
dimensions_unit: 'cm',
|
|
14
|
+
}),
|
|
15
|
+
).toBe('60 × 40 × 40 cm')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('keeps a real decimal', () => {
|
|
19
|
+
expect(
|
|
20
|
+
cartonSize({
|
|
21
|
+
id: '1',
|
|
22
|
+
name: 'Small',
|
|
23
|
+
length: '12.5',
|
|
24
|
+
width: '9.0',
|
|
25
|
+
height: '4.25',
|
|
26
|
+
dimensions_unit: 'in',
|
|
27
|
+
}),
|
|
28
|
+
).toBe('12.5 × 9 × 4.25 in')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// Half a box is not a size — better to say nothing than "60 × × cm".
|
|
32
|
+
it('says nothing when a measurement is missing', () => {
|
|
33
|
+
expect(
|
|
34
|
+
cartonSize({
|
|
35
|
+
id: '1',
|
|
36
|
+
name: 'Partial',
|
|
37
|
+
length: '60.0',
|
|
38
|
+
width: null,
|
|
39
|
+
height: '40.0',
|
|
40
|
+
dimensions_unit: 'cm',
|
|
41
|
+
}),
|
|
42
|
+
).toBeNull()
|
|
43
|
+
expect(cartonSize({ id: '1', name: 'Bare' })).toBeNull()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('omits an absent unit rather than trailing a space', () => {
|
|
47
|
+
expect(cartonSize({ id: '1', name: 'Unitless', length: '10', width: '10', height: '10' })).toBe(
|
|
48
|
+
'10 × 10 × 10',
|
|
49
|
+
)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { PanelPackageType } from '../api-client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A carton's size, as the merchant recorded it — for telling two similarly
|
|
5
|
+
* named cartons apart wherever one is chosen.
|
|
6
|
+
*
|
|
7
|
+
* Null unless all three measurements are there: half a box is not a size. The
|
|
8
|
+
* unit travels with the numbers because the same three read as inches rather
|
|
9
|
+
* than centimeters describe a box sixteen times the volume.
|
|
10
|
+
*/
|
|
11
|
+
export function cartonSize(carton: PanelPackageType): string | null {
|
|
12
|
+
const { length, width, height, dimensions_unit } = carton
|
|
13
|
+
if (length == null || width == null || height == null) return null
|
|
14
|
+
|
|
15
|
+
// Decimals arrive as strings, so `60.0` would read back as a precision the
|
|
16
|
+
// merchant never typed.
|
|
17
|
+
const trim = (value: string) => String(Number(value))
|
|
18
|
+
|
|
19
|
+
return `${trim(length)} × ${trim(width)} × ${trim(height)} ${dimensions_unit ?? ''}`.trim()
|
|
20
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Resolve a currency code into its symbol + decimal separator under the
|
|
2
|
+
// current locale, with a safe fallback for unknown/invalid currencies.
|
|
3
|
+
// Shared between the server-backed BulkPriceEditor and the form-backed
|
|
4
|
+
// ProductBulkPriceEditor.
|
|
5
|
+
export function currencyParts(
|
|
6
|
+
currencyCode: string,
|
|
7
|
+
locale: string,
|
|
8
|
+
): { symbol: string; decimal: string } {
|
|
9
|
+
try {
|
|
10
|
+
const parts = new Intl.NumberFormat(locale, {
|
|
11
|
+
style: 'currency',
|
|
12
|
+
currency: currencyCode,
|
|
13
|
+
}).formatToParts(1234.56)
|
|
14
|
+
return {
|
|
15
|
+
symbol: parts.find((p) => p.type === 'currency')?.value ?? currencyCode,
|
|
16
|
+
decimal: parts.find((p) => p.type === 'decimal')?.value ?? '.',
|
|
17
|
+
}
|
|
18
|
+
} catch {
|
|
19
|
+
return { symbol: currencyCode, decimal: '.' }
|
|
20
|
+
}
|
|
21
|
+
}
|