@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
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
import { zodResolver } from '@hookform/resolvers/zod'
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
Field,
|
|
5
|
+
FieldError,
|
|
6
|
+
FieldLabel,
|
|
7
|
+
FormSection,
|
|
8
|
+
Input,
|
|
9
|
+
RowActions,
|
|
10
|
+
Select,
|
|
11
|
+
SelectContent,
|
|
12
|
+
SelectItem,
|
|
13
|
+
SelectTrigger,
|
|
14
|
+
SelectValue,
|
|
15
|
+
Sheet,
|
|
16
|
+
SheetContent,
|
|
17
|
+
SheetDescription,
|
|
18
|
+
SheetFooter,
|
|
19
|
+
SheetHeader,
|
|
20
|
+
SheetTitle,
|
|
21
|
+
Switch,
|
|
22
|
+
Textarea,
|
|
23
|
+
useConfirm,
|
|
24
|
+
useRowClickBridge,
|
|
25
|
+
} from '@spree/dashboard-ui'
|
|
26
|
+
import { PlusIcon } from '@spree/dashboard-ui/icons'
|
|
27
|
+
import { useNavigate } from '@tanstack/react-router'
|
|
28
|
+
import { type ComponentType, useEffect } from 'react'
|
|
29
|
+
import { Controller, type UseFormReturn, useForm } from 'react-hook-form'
|
|
30
|
+
import { useTranslation } from 'react-i18next'
|
|
31
|
+
import { z } from 'zod/v4'
|
|
32
|
+
import type { PanelStockLocation } from '../api-client'
|
|
33
|
+
import { Can } from '../components/can'
|
|
34
|
+
import { CountryCombobox } from '../components/country-combobox'
|
|
35
|
+
import { StateCombobox, useCountryStates } from '../components/country-state-fields'
|
|
36
|
+
import { ResourceTable, resourceSearchSchema } from '../components/resource-table'
|
|
37
|
+
import {
|
|
38
|
+
canDeleteStockLocations,
|
|
39
|
+
listStockLocations,
|
|
40
|
+
useCreateStockLocation,
|
|
41
|
+
useDeleteStockLocation,
|
|
42
|
+
useStockLocation,
|
|
43
|
+
useUpdateStockLocation,
|
|
44
|
+
} from '../hooks/use-stock-locations'
|
|
45
|
+
import { mapSpreeErrorsToForm } from '../lib/form-errors'
|
|
46
|
+
import { Subject } from '../lib/permissions'
|
|
47
|
+
import { usePermissions } from '../providers/permission-provider'
|
|
48
|
+
import {
|
|
49
|
+
formValuesToParams,
|
|
50
|
+
PICKUP_STOCK_POLICIES,
|
|
51
|
+
STOCK_LOCATION_DEFAULTS,
|
|
52
|
+
STOCK_LOCATION_KINDS,
|
|
53
|
+
type StockLocationFormValues,
|
|
54
|
+
stockLocationFormSchema,
|
|
55
|
+
stockLocationToFormValues,
|
|
56
|
+
} from '../schemas/stock-location'
|
|
57
|
+
import '../tables/stock-locations'
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Adds `?edit=<id>` and `?new=1` on top of the standard table search schema,
|
|
61
|
+
* so the create and edit sheets can be deep-linked.
|
|
62
|
+
*
|
|
63
|
+
* Exported because each app owns its own route file — the route path differs
|
|
64
|
+
* per panel (`/$storeId/settings/...` against `/$sellerId/settings/...`) and
|
|
65
|
+
* TanStack's file routes are generated per app — while everything the page
|
|
66
|
+
* *does* lives here.
|
|
67
|
+
*/
|
|
68
|
+
export const stockLocationsSearchSchema = resourceSearchSchema.extend({
|
|
69
|
+
edit: z.string().optional(),
|
|
70
|
+
new: z.coerce.boolean().optional(),
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
export type StockLocationsSearch = z.infer<typeof stockLocationsSearchSchema>
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Manage the stock locations this panel can see.
|
|
77
|
+
*
|
|
78
|
+
* Shared by the operator's dashboard and the marketplace seller panel. Neither
|
|
79
|
+
* owns the records differently — the scoping is server-side, so the operator's
|
|
80
|
+
* API answers with every location in the store and a seller's with only their
|
|
81
|
+
* own, and this page renders whatever it is given.
|
|
82
|
+
*
|
|
83
|
+
* Three things a panel supplies rather than this page assuming them: the API
|
|
84
|
+
* resource, registered through `setApiClient`, and the two injected panels —
|
|
85
|
+
* the on-hand editor and the movement history, both of which read the Admin
|
|
86
|
+
* API and link to the operator's product pages, so a seller's panel simply
|
|
87
|
+
* passes nothing and gets a page without them.
|
|
88
|
+
*/
|
|
89
|
+
export function StockLocationsPage({
|
|
90
|
+
search,
|
|
91
|
+
stockLevelsPanel: StockLevelsPanel,
|
|
92
|
+
activityPanel: ActivityPanel,
|
|
93
|
+
}: {
|
|
94
|
+
search: StockLocationsSearch
|
|
95
|
+
/** Rendered inside the edit sheet, below the form. Optional — see above. */
|
|
96
|
+
stockLevelsPanel?: ComponentType<{ stockLocationId: string }>
|
|
97
|
+
/** What changed in this warehouse, for reconciliation. Optional. */
|
|
98
|
+
activityPanel?: ComponentType<{ stockLocationId: string }>
|
|
99
|
+
}) {
|
|
100
|
+
const { t } = useTranslation()
|
|
101
|
+
const navigate = useNavigate()
|
|
102
|
+
const confirm = useConfirm()
|
|
103
|
+
const deleteMutation = useDeleteStockLocation()
|
|
104
|
+
const { permissions } = usePermissions()
|
|
105
|
+
|
|
106
|
+
const editId = search.edit
|
|
107
|
+
const isCreating = !!search.new
|
|
108
|
+
|
|
109
|
+
function closeSheet() {
|
|
110
|
+
navigate({
|
|
111
|
+
search: (prev: Record<string, unknown>) => {
|
|
112
|
+
const { edit: _e, new: _n, ...rest } = prev
|
|
113
|
+
return rest as never
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function openCreate() {
|
|
119
|
+
navigate({ search: (prev: Record<string, unknown>) => ({ ...prev, new: true }) as never })
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function openEdit(id: string) {
|
|
123
|
+
navigate({ search: (prev: Record<string, unknown>) => ({ ...prev, edit: id }) as never })
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
useRowClickBridge('data-stock-location-id', openEdit)
|
|
127
|
+
|
|
128
|
+
async function handleDelete(location: PanelStockLocation) {
|
|
129
|
+
const ok = await confirm({
|
|
130
|
+
title: t('admin.stock_locations.delete_confirm.title'),
|
|
131
|
+
message: t('admin.stock_locations.delete_confirm.message', { name: location.name ?? '' }),
|
|
132
|
+
variant: 'destructive',
|
|
133
|
+
confirmLabel: t('admin.actions.delete'),
|
|
134
|
+
})
|
|
135
|
+
if (!ok) return
|
|
136
|
+
await deleteMutation.mutateAsync(location.id).catch(() => undefined)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<>
|
|
141
|
+
<ResourceTable<PanelStockLocation>
|
|
142
|
+
tableKey="stock-locations"
|
|
143
|
+
queryKey="stock-locations"
|
|
144
|
+
queryFn={(params) => listStockLocations(params)}
|
|
145
|
+
searchParams={search}
|
|
146
|
+
rowActions={(location) => (
|
|
147
|
+
<RowActions
|
|
148
|
+
actions={[
|
|
149
|
+
{ key: 'edit', onSelect: () => openEdit(location.id) },
|
|
150
|
+
{
|
|
151
|
+
key: 'delete',
|
|
152
|
+
destructive: true,
|
|
153
|
+
// Both must hold: the role allows it, and this panel's API
|
|
154
|
+
// offers it at all — a seller's does not.
|
|
155
|
+
visible:
|
|
156
|
+
canDeleteStockLocations() && permissions.can('destroy', Subject.StockLocation),
|
|
157
|
+
disabled: deleteMutation.isPending,
|
|
158
|
+
onSelect: () => handleDelete(location),
|
|
159
|
+
},
|
|
160
|
+
]}
|
|
161
|
+
/>
|
|
162
|
+
)}
|
|
163
|
+
actions={
|
|
164
|
+
<Can I="create" a={Subject.StockLocation}>
|
|
165
|
+
<Button size="sm" className="h-[2.125rem]" onClick={openCreate}>
|
|
166
|
+
<PlusIcon className="size-4" />
|
|
167
|
+
{t('admin.stock_locations.add_cta')}
|
|
168
|
+
</Button>
|
|
169
|
+
</Can>
|
|
170
|
+
}
|
|
171
|
+
/>
|
|
172
|
+
|
|
173
|
+
{isCreating && <CreateStockLocationSheet open onOpenChange={(o) => !o && closeSheet()} />}
|
|
174
|
+
{editId && (
|
|
175
|
+
<EditStockLocationSheet
|
|
176
|
+
id={editId}
|
|
177
|
+
open
|
|
178
|
+
onOpenChange={(o) => !o && closeSheet()}
|
|
179
|
+
stockLevelsPanel={StockLevelsPanel}
|
|
180
|
+
activityPanel={ActivityPanel}
|
|
181
|
+
/>
|
|
182
|
+
)}
|
|
183
|
+
</>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ============================================================================
|
|
188
|
+
// Create Sheet
|
|
189
|
+
// ============================================================================
|
|
190
|
+
|
|
191
|
+
function CreateStockLocationSheet({
|
|
192
|
+
open,
|
|
193
|
+
onOpenChange,
|
|
194
|
+
}: {
|
|
195
|
+
open: boolean
|
|
196
|
+
onOpenChange: (open: boolean) => void
|
|
197
|
+
}) {
|
|
198
|
+
const { t } = useTranslation()
|
|
199
|
+
const createMutation = useCreateStockLocation()
|
|
200
|
+
|
|
201
|
+
const form = useForm<StockLocationFormValues>({
|
|
202
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
203
|
+
resolver: zodResolver(stockLocationFormSchema) as any,
|
|
204
|
+
defaultValues: STOCK_LOCATION_DEFAULTS,
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
async function onSubmit(values: StockLocationFormValues) {
|
|
208
|
+
try {
|
|
209
|
+
const params = formValuesToParams(values)
|
|
210
|
+
await createMutation.mutateAsync(params)
|
|
211
|
+
form.reset(STOCK_LOCATION_DEFAULTS)
|
|
212
|
+
onOpenChange(false)
|
|
213
|
+
} catch (err) {
|
|
214
|
+
if (!mapSpreeErrorsToForm(err, form.setError)) throw err
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<Sheet
|
|
220
|
+
open={open}
|
|
221
|
+
onOpenChange={(next) => {
|
|
222
|
+
if (!next) form.reset(STOCK_LOCATION_DEFAULTS)
|
|
223
|
+
onOpenChange(next)
|
|
224
|
+
}}
|
|
225
|
+
>
|
|
226
|
+
<SheetContent>
|
|
227
|
+
<SheetHeader>
|
|
228
|
+
<SheetTitle>{t('admin.pages.settings.stock_locations.add_sheet_title')}</SheetTitle>
|
|
229
|
+
<SheetDescription>{t('admin.stock_locations.create_description')}</SheetDescription>
|
|
230
|
+
</SheetHeader>
|
|
231
|
+
<form onSubmit={form.handleSubmit(onSubmit)} className="flex min-h-0 flex-1 flex-col">
|
|
232
|
+
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
|
|
233
|
+
<StockLocationFormFields form={form} />
|
|
234
|
+
</div>
|
|
235
|
+
<SheetFooter>
|
|
236
|
+
<Button
|
|
237
|
+
type="button"
|
|
238
|
+
variant="outline"
|
|
239
|
+
onClick={() => onOpenChange(false)}
|
|
240
|
+
disabled={form.formState.isSubmitting}
|
|
241
|
+
>
|
|
242
|
+
{t('admin.actions.cancel')}
|
|
243
|
+
</Button>
|
|
244
|
+
<Button type="submit" disabled={form.formState.isSubmitting}>
|
|
245
|
+
{form.formState.isSubmitting
|
|
246
|
+
? t('admin.actions.creating')
|
|
247
|
+
: t('admin.stock_locations.create_label')}
|
|
248
|
+
</Button>
|
|
249
|
+
</SheetFooter>
|
|
250
|
+
</form>
|
|
251
|
+
</SheetContent>
|
|
252
|
+
</Sheet>
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ============================================================================
|
|
257
|
+
// Edit Sheet
|
|
258
|
+
// ============================================================================
|
|
259
|
+
|
|
260
|
+
function EditStockLocationSheet({
|
|
261
|
+
id,
|
|
262
|
+
open,
|
|
263
|
+
onOpenChange,
|
|
264
|
+
stockLevelsPanel: StockLevelsPanel,
|
|
265
|
+
activityPanel: ActivityPanel,
|
|
266
|
+
}: {
|
|
267
|
+
id: string
|
|
268
|
+
open: boolean
|
|
269
|
+
onOpenChange: (open: boolean) => void
|
|
270
|
+
stockLevelsPanel?: ComponentType<{ stockLocationId: string }>
|
|
271
|
+
activityPanel?: ComponentType<{ stockLocationId: string }>
|
|
272
|
+
}) {
|
|
273
|
+
const { t } = useTranslation()
|
|
274
|
+
const { data: stockLocation, isLoading } = useStockLocation(id)
|
|
275
|
+
const updateMutation = useUpdateStockLocation(id)
|
|
276
|
+
|
|
277
|
+
const form = useForm<StockLocationFormValues>({
|
|
278
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
279
|
+
resolver: zodResolver(stockLocationFormSchema) as any,
|
|
280
|
+
defaultValues: STOCK_LOCATION_DEFAULTS,
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
// Reset form when the loaded resource arrives — keeps the inputs in sync
|
|
284
|
+
// with whatever the server last persisted (including external edits).
|
|
285
|
+
useEffect(() => {
|
|
286
|
+
if (stockLocation) {
|
|
287
|
+
form.reset(stockLocationToFormValues(stockLocation))
|
|
288
|
+
}
|
|
289
|
+
}, [stockLocation, form])
|
|
290
|
+
|
|
291
|
+
async function onSubmit(values: StockLocationFormValues) {
|
|
292
|
+
try {
|
|
293
|
+
const params = formValuesToParams(values)
|
|
294
|
+
await updateMutation.mutateAsync(params)
|
|
295
|
+
form.reset(values)
|
|
296
|
+
onOpenChange(false)
|
|
297
|
+
} catch (err) {
|
|
298
|
+
if (!mapSpreeErrorsToForm(err, form.setError)) throw err
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return (
|
|
303
|
+
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
304
|
+
<SheetContent>
|
|
305
|
+
<SheetHeader>
|
|
306
|
+
<SheetTitle>
|
|
307
|
+
{stockLocation?.name ?? t('admin.pages.settings.stock_locations.edit_sheet_title')}
|
|
308
|
+
</SheetTitle>
|
|
309
|
+
<SheetDescription>{t('admin.stock_locations.edit_description')}</SheetDescription>
|
|
310
|
+
</SheetHeader>
|
|
311
|
+
{isLoading ? (
|
|
312
|
+
<div className="p-4 text-sm text-muted-foreground">{t('admin.common.loading')}</div>
|
|
313
|
+
) : (
|
|
314
|
+
<form onSubmit={form.handleSubmit(onSubmit)} className="flex min-h-0 flex-1 flex-col">
|
|
315
|
+
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
|
|
316
|
+
<StockLocationFormFields form={form} />
|
|
317
|
+
{StockLevelsPanel && <StockLevelsPanel stockLocationId={id} />}
|
|
318
|
+
{ActivityPanel && <ActivityPanel stockLocationId={id} />}
|
|
319
|
+
</div>
|
|
320
|
+
<SheetFooter>
|
|
321
|
+
<Button
|
|
322
|
+
type="button"
|
|
323
|
+
variant="outline"
|
|
324
|
+
onClick={() => onOpenChange(false)}
|
|
325
|
+
disabled={form.formState.isSubmitting}
|
|
326
|
+
>
|
|
327
|
+
{t('admin.actions.cancel')}
|
|
328
|
+
</Button>
|
|
329
|
+
<Button
|
|
330
|
+
type="submit"
|
|
331
|
+
disabled={form.formState.isSubmitting || !form.formState.isDirty}
|
|
332
|
+
>
|
|
333
|
+
{form.formState.isSubmitting ? t('admin.actions.saving') : t('admin.actions.save')}
|
|
334
|
+
</Button>
|
|
335
|
+
</SheetFooter>
|
|
336
|
+
</form>
|
|
337
|
+
)}
|
|
338
|
+
</SheetContent>
|
|
339
|
+
</Sheet>
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ============================================================================
|
|
344
|
+
// Shared form fields
|
|
345
|
+
// ============================================================================
|
|
346
|
+
|
|
347
|
+
function StockLocationFormFields({ form }: { form: UseFormReturn<StockLocationFormValues> }) {
|
|
348
|
+
const { t } = useTranslation()
|
|
349
|
+
const { errors } = form.formState
|
|
350
|
+
const countryCode = form.watch('country_code')
|
|
351
|
+
const { states } = useCountryStates(countryCode)
|
|
352
|
+
const pickupEnabled = form.watch('pickup_enabled')
|
|
353
|
+
|
|
354
|
+
return (
|
|
355
|
+
// Sections are siblings: each is its own fieldset, so nesting them inside
|
|
356
|
+
// one group would make every field a member of a group it does not belong
|
|
357
|
+
// to as well as its own.
|
|
358
|
+
<div className="flex flex-col gap-6">
|
|
359
|
+
{errors.root?.message && (
|
|
360
|
+
<p className="text-sm text-destructive" role="alert">
|
|
361
|
+
{errors.root.message}
|
|
362
|
+
</p>
|
|
363
|
+
)}
|
|
364
|
+
|
|
365
|
+
<FormSection title={t('admin.stock_locations.section_details')}>
|
|
366
|
+
<Field>
|
|
367
|
+
<FieldLabel htmlFor="name">{t('admin.fields.name.label')}</FieldLabel>
|
|
368
|
+
<Input
|
|
369
|
+
id="name"
|
|
370
|
+
autoFocus
|
|
371
|
+
placeholder={t('admin.fields.stock_location.name.placeholder')}
|
|
372
|
+
aria-invalid={!!errors.name || undefined}
|
|
373
|
+
{...form.register('name')}
|
|
374
|
+
/>
|
|
375
|
+
<FieldError errors={[errors.name]} />
|
|
376
|
+
</Field>
|
|
377
|
+
|
|
378
|
+
<Field>
|
|
379
|
+
<FieldLabel htmlFor="admin-name">
|
|
380
|
+
{t('admin.fields.stock_location.admin_name.label')}
|
|
381
|
+
</FieldLabel>
|
|
382
|
+
<Input
|
|
383
|
+
id="admin-name"
|
|
384
|
+
placeholder={t('admin.fields.stock_location.admin_name.placeholder')}
|
|
385
|
+
aria-invalid={!!errors.admin_name || undefined}
|
|
386
|
+
{...form.register('admin_name')}
|
|
387
|
+
/>
|
|
388
|
+
<FieldError errors={[errors.admin_name]} />
|
|
389
|
+
</Field>
|
|
390
|
+
|
|
391
|
+
<Field>
|
|
392
|
+
<FieldLabel htmlFor="kind">{t('admin.fields.stock_location.kind.label')}</FieldLabel>
|
|
393
|
+
<Controller
|
|
394
|
+
name="kind"
|
|
395
|
+
control={form.control}
|
|
396
|
+
render={({ field }) => {
|
|
397
|
+
const items = STOCK_LOCATION_KINDS.map((value) => ({
|
|
398
|
+
value,
|
|
399
|
+
label: t(`admin.stock_locations.kinds.${value}`),
|
|
400
|
+
}))
|
|
401
|
+
return (
|
|
402
|
+
<Select items={items as never} value={field.value} onValueChange={field.onChange}>
|
|
403
|
+
<SelectTrigger id="kind">
|
|
404
|
+
<SelectValue />
|
|
405
|
+
</SelectTrigger>
|
|
406
|
+
<SelectContent>
|
|
407
|
+
{items.map((o) => (
|
|
408
|
+
<SelectItem key={o.value} value={o.value}>
|
|
409
|
+
{o.label}
|
|
410
|
+
</SelectItem>
|
|
411
|
+
))}
|
|
412
|
+
</SelectContent>
|
|
413
|
+
</Select>
|
|
414
|
+
)
|
|
415
|
+
}}
|
|
416
|
+
/>
|
|
417
|
+
<FieldError errors={[errors.kind]} />
|
|
418
|
+
</Field>
|
|
419
|
+
</FormSection>
|
|
420
|
+
|
|
421
|
+
<FormSection title={t('admin.stock_locations.section_behavior')}>
|
|
422
|
+
<BooleanRow
|
|
423
|
+
id="active"
|
|
424
|
+
label={t('admin.fields.stock_location.active.label')}
|
|
425
|
+
help={t('admin.fields.stock_location.active.help')}
|
|
426
|
+
form={form}
|
|
427
|
+
name="active"
|
|
428
|
+
/>
|
|
429
|
+
<BooleanRow
|
|
430
|
+
id="default"
|
|
431
|
+
label={t('admin.fields.stock_location.default.label')}
|
|
432
|
+
help={t('admin.fields.stock_location.default.help')}
|
|
433
|
+
form={form}
|
|
434
|
+
name="default"
|
|
435
|
+
/>
|
|
436
|
+
<BooleanRow
|
|
437
|
+
id="backorderable-default"
|
|
438
|
+
label={t('admin.fields.stock_location.backorderable_default.label')}
|
|
439
|
+
help={t('admin.fields.stock_location.backorderable_default.help')}
|
|
440
|
+
form={form}
|
|
441
|
+
name="backorderable_default"
|
|
442
|
+
/>
|
|
443
|
+
<BooleanRow
|
|
444
|
+
id="propagate-all-variants"
|
|
445
|
+
label={t('admin.fields.stock_location.propagate_all_variants.label')}
|
|
446
|
+
help={t('admin.fields.stock_location.propagate_all_variants.help')}
|
|
447
|
+
form={form}
|
|
448
|
+
name="propagate_all_variants"
|
|
449
|
+
/>
|
|
450
|
+
</FormSection>
|
|
451
|
+
|
|
452
|
+
<FormSection title={t('admin.fields.address.address1.label')}>
|
|
453
|
+
<Field>
|
|
454
|
+
<FieldLabel htmlFor="address1">{t('admin.fields.address1.label')}</FieldLabel>
|
|
455
|
+
<Input
|
|
456
|
+
id="address1"
|
|
457
|
+
aria-invalid={!!errors.address1 || undefined}
|
|
458
|
+
{...form.register('address1')}
|
|
459
|
+
/>
|
|
460
|
+
<FieldError errors={[errors.address1]} />
|
|
461
|
+
</Field>
|
|
462
|
+
<Field>
|
|
463
|
+
<FieldLabel htmlFor="address2">{t('admin.fields.address2.label')}</FieldLabel>
|
|
464
|
+
<Input
|
|
465
|
+
id="address2"
|
|
466
|
+
aria-invalid={!!errors.address2 || undefined}
|
|
467
|
+
{...form.register('address2')}
|
|
468
|
+
/>
|
|
469
|
+
<FieldError errors={[errors.address2]} />
|
|
470
|
+
</Field>
|
|
471
|
+
<div className="grid grid-cols-2 gap-3">
|
|
472
|
+
<Field>
|
|
473
|
+
<FieldLabel htmlFor="city">{t('admin.fields.city.label')}</FieldLabel>
|
|
474
|
+
<Input id="city" aria-invalid={!!errors.city || undefined} {...form.register('city')} />
|
|
475
|
+
<FieldError errors={[errors.city]} />
|
|
476
|
+
</Field>
|
|
477
|
+
<Field>
|
|
478
|
+
<FieldLabel htmlFor="zipcode">{t('admin.fields.zipcode.label')}</FieldLabel>
|
|
479
|
+
<Input
|
|
480
|
+
id="zipcode"
|
|
481
|
+
aria-invalid={!!errors.zipcode || undefined}
|
|
482
|
+
{...form.register('zipcode')}
|
|
483
|
+
/>
|
|
484
|
+
<FieldError errors={[errors.zipcode]} />
|
|
485
|
+
</Field>
|
|
486
|
+
</div>
|
|
487
|
+
<Field>
|
|
488
|
+
<FieldLabel>{t('admin.fields.country_code.label')}</FieldLabel>
|
|
489
|
+
<Controller
|
|
490
|
+
name="country_code"
|
|
491
|
+
control={form.control}
|
|
492
|
+
render={({ field }) => (
|
|
493
|
+
<CountryCombobox
|
|
494
|
+
value={field.value}
|
|
495
|
+
onValueChange={(iso) => {
|
|
496
|
+
field.onChange(iso)
|
|
497
|
+
form.setValue('state_code', '', { shouldDirty: true })
|
|
498
|
+
form.setValue('state_name', '', { shouldDirty: true })
|
|
499
|
+
}}
|
|
500
|
+
/>
|
|
501
|
+
)}
|
|
502
|
+
/>
|
|
503
|
+
<FieldError errors={[errors.country_code]} />
|
|
504
|
+
</Field>
|
|
505
|
+
{states.length > 0 ? (
|
|
506
|
+
<Field>
|
|
507
|
+
<FieldLabel>{t('admin.fields.state_code.label')}</FieldLabel>
|
|
508
|
+
<Controller
|
|
509
|
+
name="state_code"
|
|
510
|
+
control={form.control}
|
|
511
|
+
render={({ field }) => (
|
|
512
|
+
<StateCombobox
|
|
513
|
+
countryCode={countryCode}
|
|
514
|
+
states={states}
|
|
515
|
+
value={field.value}
|
|
516
|
+
onValueChange={field.onChange}
|
|
517
|
+
/>
|
|
518
|
+
)}
|
|
519
|
+
/>
|
|
520
|
+
<FieldError errors={[errors.state_code]} />
|
|
521
|
+
</Field>
|
|
522
|
+
) : (
|
|
523
|
+
<Field>
|
|
524
|
+
<FieldLabel htmlFor="state-name">{t('admin.fields.state_name.label')}</FieldLabel>
|
|
525
|
+
<Input
|
|
526
|
+
id="state-name"
|
|
527
|
+
aria-invalid={!!errors.state_name || undefined}
|
|
528
|
+
{...form.register('state_name')}
|
|
529
|
+
/>
|
|
530
|
+
<FieldError errors={[errors.state_name]} />
|
|
531
|
+
</Field>
|
|
532
|
+
)}
|
|
533
|
+
<Field>
|
|
534
|
+
<FieldLabel htmlFor="phone">{t('admin.fields.phone.label')}</FieldLabel>
|
|
535
|
+
<Input
|
|
536
|
+
id="phone"
|
|
537
|
+
aria-invalid={!!errors.phone || undefined}
|
|
538
|
+
{...form.register('phone')}
|
|
539
|
+
/>
|
|
540
|
+
<FieldError errors={[errors.phone]} />
|
|
541
|
+
</Field>
|
|
542
|
+
<Field>
|
|
543
|
+
<FieldLabel htmlFor="company">{t('admin.fields.company.label')}</FieldLabel>
|
|
544
|
+
<Input
|
|
545
|
+
id="company"
|
|
546
|
+
aria-invalid={!!errors.company || undefined}
|
|
547
|
+
{...form.register('company')}
|
|
548
|
+
/>
|
|
549
|
+
<FieldError errors={[errors.company]} />
|
|
550
|
+
</Field>
|
|
551
|
+
</FormSection>
|
|
552
|
+
|
|
553
|
+
<FormSection title={t('admin.stock_locations.section_returns')}>
|
|
554
|
+
<BooleanRow
|
|
555
|
+
id="returns-enabled"
|
|
556
|
+
label={t('admin.fields.stock_location.returns_enabled.label')}
|
|
557
|
+
help={t('admin.fields.stock_location.returns_enabled.help')}
|
|
558
|
+
form={form}
|
|
559
|
+
name="returns_enabled"
|
|
560
|
+
/>
|
|
561
|
+
</FormSection>
|
|
562
|
+
|
|
563
|
+
<FormSection title={t('admin.stock_locations.section_pickup')}>
|
|
564
|
+
<BooleanRow
|
|
565
|
+
id="pickup-enabled"
|
|
566
|
+
label={t('admin.fields.stock_location.pickup_enabled.label')}
|
|
567
|
+
help={t('admin.fields.stock_location.pickup_enabled.help')}
|
|
568
|
+
form={form}
|
|
569
|
+
name="pickup_enabled"
|
|
570
|
+
/>
|
|
571
|
+
{pickupEnabled && (
|
|
572
|
+
<>
|
|
573
|
+
<Field>
|
|
574
|
+
<FieldLabel htmlFor="pickup-stock-policy">
|
|
575
|
+
{t('admin.fields.stock_location.pickup_stock_policy.label')}
|
|
576
|
+
</FieldLabel>
|
|
577
|
+
<Controller
|
|
578
|
+
name="pickup_stock_policy"
|
|
579
|
+
control={form.control}
|
|
580
|
+
render={({ field }) => {
|
|
581
|
+
const items = PICKUP_STOCK_POLICIES.map((value) => ({
|
|
582
|
+
value,
|
|
583
|
+
label: t(`admin.stock_locations.pickup_stock_policies.${value}`),
|
|
584
|
+
}))
|
|
585
|
+
return (
|
|
586
|
+
<Select
|
|
587
|
+
items={items as never}
|
|
588
|
+
value={field.value}
|
|
589
|
+
onValueChange={field.onChange}
|
|
590
|
+
>
|
|
591
|
+
<SelectTrigger id="pickup-stock-policy">
|
|
592
|
+
<SelectValue />
|
|
593
|
+
</SelectTrigger>
|
|
594
|
+
<SelectContent>
|
|
595
|
+
{items.map((o) => (
|
|
596
|
+
<SelectItem key={o.value} value={o.value}>
|
|
597
|
+
{o.label}
|
|
598
|
+
</SelectItem>
|
|
599
|
+
))}
|
|
600
|
+
</SelectContent>
|
|
601
|
+
</Select>
|
|
602
|
+
)
|
|
603
|
+
}}
|
|
604
|
+
/>
|
|
605
|
+
<FieldError errors={[errors.pickup_stock_policy]} />
|
|
606
|
+
</Field>
|
|
607
|
+
<Field>
|
|
608
|
+
<FieldLabel htmlFor="pickup-ready-in-minutes">
|
|
609
|
+
{t('admin.fields.stock_location.pickup_ready_in_minutes.label')}
|
|
610
|
+
</FieldLabel>
|
|
611
|
+
<Input
|
|
612
|
+
id="pickup-ready-in-minutes"
|
|
613
|
+
type="number"
|
|
614
|
+
min={0}
|
|
615
|
+
step={5}
|
|
616
|
+
placeholder={t('admin.fields.stock_location.pickup_ready_in_minutes.placeholder')}
|
|
617
|
+
aria-invalid={!!errors.pickup_ready_in_minutes || undefined}
|
|
618
|
+
{...form.register('pickup_ready_in_minutes')}
|
|
619
|
+
/>
|
|
620
|
+
<FieldError errors={[errors.pickup_ready_in_minutes]} />
|
|
621
|
+
</Field>
|
|
622
|
+
<Field>
|
|
623
|
+
<FieldLabel htmlFor="pickup-instructions">
|
|
624
|
+
{t('admin.fields.stock_location.pickup_instructions.label')}
|
|
625
|
+
</FieldLabel>
|
|
626
|
+
<Textarea
|
|
627
|
+
id="pickup-instructions"
|
|
628
|
+
rows={3}
|
|
629
|
+
placeholder={t('admin.fields.stock_location.pickup_instructions.placeholder')}
|
|
630
|
+
aria-invalid={!!errors.pickup_instructions || undefined}
|
|
631
|
+
{...form.register('pickup_instructions')}
|
|
632
|
+
/>
|
|
633
|
+
<FieldError errors={[errors.pickup_instructions]} />
|
|
634
|
+
</Field>
|
|
635
|
+
</>
|
|
636
|
+
)}
|
|
637
|
+
</FormSection>
|
|
638
|
+
</div>
|
|
639
|
+
)
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function BooleanRow({
|
|
643
|
+
id,
|
|
644
|
+
label,
|
|
645
|
+
help,
|
|
646
|
+
form,
|
|
647
|
+
name,
|
|
648
|
+
}: {
|
|
649
|
+
id: string
|
|
650
|
+
label: string
|
|
651
|
+
help?: string
|
|
652
|
+
form: UseFormReturn<StockLocationFormValues>
|
|
653
|
+
name: keyof StockLocationFormValues
|
|
654
|
+
}) {
|
|
655
|
+
return (
|
|
656
|
+
<Field>
|
|
657
|
+
<div className="flex items-start justify-between gap-4">
|
|
658
|
+
<div className="flex flex-col">
|
|
659
|
+
<FieldLabel htmlFor={id} className="cursor-pointer">
|
|
660
|
+
{label}
|
|
661
|
+
</FieldLabel>
|
|
662
|
+
{help && <span className="text-xs text-muted-foreground">{help}</span>}
|
|
663
|
+
</div>
|
|
664
|
+
<Controller
|
|
665
|
+
name={name as never}
|
|
666
|
+
control={form.control}
|
|
667
|
+
render={({ field }) => (
|
|
668
|
+
<Switch id={id} checked={!!field.value} onCheckedChange={field.onChange} />
|
|
669
|
+
)}
|
|
670
|
+
/>
|
|
671
|
+
</div>
|
|
672
|
+
</Field>
|
|
673
|
+
)
|
|
674
|
+
}
|