@open-mercato/core 0.8.1-develop.7221.1.ece7ca874e → 0.8.1-develop.7225.1.73190a4a95
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/.turbo/turbo-build.log +1 -1
- package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.js +166 -0
- package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.js +16 -0
- package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/prices/create/page.js +68 -0
- package/dist/modules/catalog/backend/catalog/prices/create/page.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/prices/create/page.meta.js +25 -0
- package/dist/modules/catalog/backend/catalog/prices/create/page.meta.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/prices/page.js +11 -0
- package/dist/modules/catalog/backend/catalog/prices/page.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/prices/page.meta.js +16 -0
- package/dist/modules/catalog/backend/catalog/prices/page.meta.js.map +7 -0
- package/dist/modules/catalog/components/prices/PriceScopeSelectors.js +201 -0
- package/dist/modules/catalog/components/prices/PriceScopeSelectors.js.map +7 -0
- package/dist/modules/catalog/components/prices/PricesDataTable.js +273 -0
- package/dist/modules/catalog/components/prices/PricesDataTable.js.map +7 -0
- package/dist/modules/catalog/components/prices/normalizePriceRecord.js +43 -0
- package/dist/modules/catalog/components/prices/normalizePriceRecord.js.map +7 -0
- package/dist/modules/catalog/components/prices/priceFormFields.js +195 -0
- package/dist/modules/catalog/components/prices/priceFormFields.js.map +7 -0
- package/dist/modules/catalog/components/prices/useBatchLabels.js +42 -0
- package/dist/modules/catalog/components/prices/useBatchLabels.js.map +7 -0
- package/dist/modules/catalog/data/validators.js +21 -2
- package/dist/modules/catalog/data/validators.js.map +2 -2
- package/dist/modules/catalog/extension-points.js +2 -1
- package/dist/modules/catalog/extension-points.js.map +2 -2
- package/dist/modules/catalog/lib/pricing.js +37 -8
- package/dist/modules/catalog/lib/pricing.js.map +2 -2
- package/dist/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.js +88 -0
- package/dist/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.js.map +7 -0
- package/dist/modules/communication_channels/backend/profile/communication-channels/page.js +6 -12
- package/dist/modules/communication_channels/backend/profile/communication-channels/page.js.map +2 -2
- package/dist/modules/communication_channels/commands/connect-credential-channel.js +7 -2
- package/dist/modules/communication_channels/commands/connect-credential-channel.js.map +2 -2
- package/dist/modules/communication_channels/commands/ingest-inbound-message.js +11 -2
- package/dist/modules/communication_channels/commands/ingest-inbound-message.js.map +2 -2
- package/dist/modules/communication_channels/extension-points.js +1 -1
- package/dist/modules/communication_channels/extension-points.js.map +1 -1
- package/dist/modules/communication_channels/lib/connect-channel.js +33 -0
- package/dist/modules/communication_channels/lib/connect-channel.js.map +2 -2
- package/dist/modules/messages/api/route.js +5 -1
- package/dist/modules/messages/api/route.js.map +2 -2
- package/dist/modules/messages/commands/messages.js +3 -2
- package/dist/modules/messages/commands/messages.js.map +2 -2
- package/dist/modules/messages/data/validators.js +12 -2
- package/dist/modules/messages/data/validators.js.map +2 -2
- package/dist/modules/messages/subscribers/message-notification.js +4 -1
- package/dist/modules/messages/subscribers/message-notification.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/catalog/AGENTS.md +6 -2
- package/src/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.ts +12 -0
- package/src/modules/catalog/backend/catalog/prices/[id]/edit/page.tsx +198 -0
- package/src/modules/catalog/backend/catalog/prices/create/page.meta.ts +23 -0
- package/src/modules/catalog/backend/catalog/prices/create/page.tsx +70 -0
- package/src/modules/catalog/backend/catalog/prices/page.meta.ts +12 -0
- package/src/modules/catalog/backend/catalog/prices/page.tsx +14 -0
- package/src/modules/catalog/components/prices/PriceScopeSelectors.tsx +243 -0
- package/src/modules/catalog/components/prices/PricesDataTable.tsx +320 -0
- package/src/modules/catalog/components/prices/normalizePriceRecord.ts +71 -0
- package/src/modules/catalog/components/prices/priceFormFields.tsx +228 -0
- package/src/modules/catalog/components/prices/useBatchLabels.ts +57 -0
- package/src/modules/catalog/data/validators.ts +43 -2
- package/src/modules/catalog/extension-points.ts +1 -0
- package/src/modules/catalog/i18n/de.json +72 -0
- package/src/modules/catalog/i18n/en.json +72 -0
- package/src/modules/catalog/i18n/es.json +72 -0
- package/src/modules/catalog/i18n/ko.json +72 -0
- package/src/modules/catalog/i18n/pl.json +72 -0
- package/src/modules/catalog/lib/pricing.ts +103 -9
- package/src/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.tsx +120 -0
- package/src/modules/communication_channels/backend/profile/communication-channels/page.tsx +9 -10
- package/src/modules/communication_channels/commands/connect-credential-channel.ts +9 -0
- package/src/modules/communication_channels/commands/ingest-inbound-message.ts +17 -2
- package/src/modules/communication_channels/extension-points.ts +1 -1
- package/src/modules/communication_channels/i18n/de.json +2 -1
- package/src/modules/communication_channels/i18n/en.json +2 -1
- package/src/modules/communication_channels/i18n/es.json +2 -1
- package/src/modules/communication_channels/i18n/ko.json +2 -1
- package/src/modules/communication_channels/i18n/pl.json +2 -1
- package/src/modules/communication_channels/lib/adapter.ts +8 -0
- package/src/modules/communication_channels/lib/connect-channel.ts +49 -0
- package/src/modules/messages/api/route.ts +8 -2
- package/src/modules/messages/commands/messages.ts +17 -2
- package/src/modules/messages/data/validators.ts +26 -11
- package/src/modules/messages/subscribers/message-notification.ts +17 -5
- package/src/modules/sales/AGENTS.md +2 -3
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Page, PageBody } from '@open-mercato/ui/backend/Page'
|
|
5
|
+
import { CrudForm } from '@open-mercato/ui/backend/CrudForm'
|
|
6
|
+
import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
|
|
7
|
+
import { updateCrud, deleteCrud } from '@open-mercato/ui/backend/utils/crud'
|
|
8
|
+
import { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'
|
|
9
|
+
import { ErrorMessage, RecordNotFoundState } from '@open-mercato/ui/backend/detail'
|
|
10
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
11
|
+
import { E } from '#generated/entities.ids.generated'
|
|
12
|
+
import { usePriceFormFields, type PriceFormValues } from '../../../../../components/prices/priceFormFields'
|
|
13
|
+
import { normalizePriceRecord } from '../../../../../components/prices/normalizePriceRecord'
|
|
14
|
+
|
|
15
|
+
type PriceListResponse = {
|
|
16
|
+
items?: Array<Record<string, unknown>>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function toDateInputValue(value: string | null | undefined): string {
|
|
20
|
+
if (!value) return ''
|
|
21
|
+
const date = new Date(value)
|
|
22
|
+
if (Number.isNaN(date.getTime())) return ''
|
|
23
|
+
return date.toISOString().slice(0, 10)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildUpdatePayload(
|
|
27
|
+
id: string,
|
|
28
|
+
values: PriceFormValues,
|
|
29
|
+
t: (key: string, fallback?: string) => string,
|
|
30
|
+
): Record<string, unknown> {
|
|
31
|
+
const productId = values.productId?.trim() || undefined
|
|
32
|
+
const variantId = values.variantId?.trim() || undefined
|
|
33
|
+
if (!productId && !variantId) {
|
|
34
|
+
const message = t('catalog.prices.form.errors.productOrVariantRequired', 'Choose a product or a variant.')
|
|
35
|
+
throw createCrudFormError(message, { productId: message })
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
id,
|
|
39
|
+
productId,
|
|
40
|
+
variantId,
|
|
41
|
+
priceKindId: values.priceKindId?.trim() || undefined,
|
|
42
|
+
currencyCode: values.currencyCode?.trim() || undefined,
|
|
43
|
+
unitPriceNet: values.unitPriceNet?.trim() || undefined,
|
|
44
|
+
unitPriceGross: values.unitPriceGross?.trim() || undefined,
|
|
45
|
+
taxRate: values.taxRate?.trim() || undefined,
|
|
46
|
+
minQuantity: values.minQuantity ?? undefined,
|
|
47
|
+
maxQuantity: values.maxQuantity ?? undefined,
|
|
48
|
+
startsAt: values.startsAt?.trim() || undefined,
|
|
49
|
+
endsAt: values.endsAt?.trim() || undefined,
|
|
50
|
+
customerId: values.customerId?.trim() || undefined,
|
|
51
|
+
customerGroupId: values.customerGroupId?.trim() || undefined,
|
|
52
|
+
channelId: values.channelId?.trim() || undefined,
|
|
53
|
+
offerId: values.offerId?.trim() || undefined,
|
|
54
|
+
userId: values.userId?.trim() || undefined,
|
|
55
|
+
userGroupId: values.userGroupId?.trim() || undefined,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default function EditCatalogPricePage({ params }: { params?: { id?: string } }) {
|
|
60
|
+
const priceId = params?.id ?? ''
|
|
61
|
+
const t = useT()
|
|
62
|
+
const { fields, groups } = usePriceFormFields()
|
|
63
|
+
const [initialValues, setInitialValues] = React.useState<PriceFormValues | null>(null)
|
|
64
|
+
const [loading, setLoading] = React.useState(true)
|
|
65
|
+
const [error, setError] = React.useState<string | null>(null)
|
|
66
|
+
const [isNotFound, setIsNotFound] = React.useState(false)
|
|
67
|
+
|
|
68
|
+
React.useEffect(() => {
|
|
69
|
+
if (!priceId) return
|
|
70
|
+
let cancelled = false
|
|
71
|
+
async function load() {
|
|
72
|
+
setLoading(true)
|
|
73
|
+
setError(null)
|
|
74
|
+
setIsNotFound(false)
|
|
75
|
+
try {
|
|
76
|
+
const { ok, status, result } = await apiCall<PriceListResponse>(
|
|
77
|
+
`/api/catalog/prices?ids=${encodeURIComponent(priceId)}&pageSize=1`,
|
|
78
|
+
)
|
|
79
|
+
if (!ok) {
|
|
80
|
+
if (status === 404) {
|
|
81
|
+
if (!cancelled) setIsNotFound(true)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
throw new Error(t('catalog.prices.form.errors.load', 'Failed to load price rule'))
|
|
85
|
+
}
|
|
86
|
+
const raw = Array.isArray(result?.items) ? result.items[0] : null
|
|
87
|
+
if (!raw) {
|
|
88
|
+
if (!cancelled) setIsNotFound(true)
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
if (cancelled) return
|
|
92
|
+
const record = normalizePriceRecord(raw)
|
|
93
|
+
setInitialValues({
|
|
94
|
+
id: record.id,
|
|
95
|
+
productId: record.productId ?? '',
|
|
96
|
+
variantId: record.variantId ?? '',
|
|
97
|
+
priceKindId: record.priceKindId ?? '',
|
|
98
|
+
currencyCode: record.currencyCode ?? '',
|
|
99
|
+
unitPriceNet: record.unitPriceNet ?? '',
|
|
100
|
+
unitPriceGross: record.unitPriceGross ?? '',
|
|
101
|
+
taxRate: record.taxRate ?? '',
|
|
102
|
+
minQuantity: record.minQuantity ?? 1,
|
|
103
|
+
maxQuantity: record.maxQuantity ?? undefined,
|
|
104
|
+
startsAt: toDateInputValue(record.startsAt),
|
|
105
|
+
endsAt: toDateInputValue(record.endsAt),
|
|
106
|
+
customerId: record.customerId ?? '',
|
|
107
|
+
customerGroupId: record.customerGroupId ?? '',
|
|
108
|
+
channelId: record.channelId ?? '',
|
|
109
|
+
offerId: record.offerId ?? '',
|
|
110
|
+
userId: record.userId ?? '',
|
|
111
|
+
userGroupId: record.userGroupId ?? '',
|
|
112
|
+
updatedAt: record.updatedAt ?? null,
|
|
113
|
+
})
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (!cancelled) {
|
|
116
|
+
const fallback = t('catalog.prices.form.errors.load', 'Failed to load price rule')
|
|
117
|
+
const message = err instanceof Error ? err.message : fallback
|
|
118
|
+
setError(message)
|
|
119
|
+
}
|
|
120
|
+
} finally {
|
|
121
|
+
if (!cancelled) setLoading(false)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
load()
|
|
125
|
+
return () => {
|
|
126
|
+
cancelled = true
|
|
127
|
+
}
|
|
128
|
+
}, [priceId, t])
|
|
129
|
+
|
|
130
|
+
if (!priceId) {
|
|
131
|
+
return (
|
|
132
|
+
<Page>
|
|
133
|
+
<PageBody>
|
|
134
|
+
<p className="text-sm text-destructive">
|
|
135
|
+
{t('catalog.prices.form.errors.idRequired', 'Price rule identifier is required.')}
|
|
136
|
+
</p>
|
|
137
|
+
</PageBody>
|
|
138
|
+
</Page>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (isNotFound) {
|
|
143
|
+
return (
|
|
144
|
+
<Page>
|
|
145
|
+
<PageBody>
|
|
146
|
+
<RecordNotFoundState
|
|
147
|
+
label={t('catalog.prices.form.errors.notFound', 'Price rule not found')}
|
|
148
|
+
backHref="/backend/catalog/prices"
|
|
149
|
+
backLabel={t('catalog.prices.form.actions.backToList', 'Back to price rules')}
|
|
150
|
+
/>
|
|
151
|
+
</PageBody>
|
|
152
|
+
</Page>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (error && !loading) {
|
|
157
|
+
return (
|
|
158
|
+
<Page>
|
|
159
|
+
<PageBody>
|
|
160
|
+
<ErrorMessage label={error} />
|
|
161
|
+
</PageBody>
|
|
162
|
+
</Page>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<Page>
|
|
168
|
+
<PageBody>
|
|
169
|
+
<CrudForm<PriceFormValues>
|
|
170
|
+
title={t('catalog.prices.form.editTitle', 'Edit price rule')}
|
|
171
|
+
titleHeadingLevel={1}
|
|
172
|
+
backHref="/backend/catalog/prices"
|
|
173
|
+
fields={fields}
|
|
174
|
+
groups={groups}
|
|
175
|
+
collapsibleGroups
|
|
176
|
+
entityId={E.catalog.catalog_product_price}
|
|
177
|
+
initialValues={initialValues ?? { id: priceId }}
|
|
178
|
+
optimisticLockUpdatedAt={initialValues?.updatedAt}
|
|
179
|
+
isLoading={loading}
|
|
180
|
+
loadingMessage={t('catalog.prices.form.loading', 'Loading price rule...')}
|
|
181
|
+
submitLabel={t('catalog.prices.form.action.save', 'Save')}
|
|
182
|
+
cancelHref="/backend/catalog/prices"
|
|
183
|
+
successRedirect={`/backend/catalog/prices?flash=${encodeURIComponent(t('catalog.prices.flash.updated', 'Price rule updated'))}&type=success`}
|
|
184
|
+
onSubmit={async (values) => {
|
|
185
|
+
const payload = buildUpdatePayload(priceId, values, t)
|
|
186
|
+
await updateCrud('catalog/prices', payload)
|
|
187
|
+
}}
|
|
188
|
+
onDelete={async () => {
|
|
189
|
+
await deleteCrud('catalog/prices', priceId, {
|
|
190
|
+
errorMessage: t('catalog.prices.form.errors.delete', 'Failed to delete price rule'),
|
|
191
|
+
})
|
|
192
|
+
}}
|
|
193
|
+
deleteRedirect={`/backend/catalog/prices?flash=${encodeURIComponent(t('catalog.prices.flash.deleted', 'Price rule deleted'))}&type=success`}
|
|
194
|
+
/>
|
|
195
|
+
</PageBody>
|
|
196
|
+
</Page>
|
|
197
|
+
)
|
|
198
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
const createPageIcon = React.createElement(
|
|
4
|
+
'svg',
|
|
5
|
+
{ width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2 },
|
|
6
|
+
React.createElement('path', { d: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }),
|
|
7
|
+
React.createElement('polyline', { points: '14 2 14 8 20 8' }),
|
|
8
|
+
React.createElement('path', { d: 'M12 11v6M9 14h6' }),
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
export const metadata = {
|
|
12
|
+
requireAuth: true,
|
|
13
|
+
requireFeatures: ['catalog.pricing.manage'],
|
|
14
|
+
pageTitle: 'Create price rule',
|
|
15
|
+
pageTitleKey: 'catalog.prices.form.createTitle',
|
|
16
|
+
pageGroup: 'Catalog',
|
|
17
|
+
pageGroupKey: 'catalog.nav.group',
|
|
18
|
+
icon: createPageIcon,
|
|
19
|
+
breadcrumb: [
|
|
20
|
+
{ label: 'Price rules', labelKey: 'catalog.prices.page.title', href: '/backend/catalog/prices' },
|
|
21
|
+
{ label: 'Create', labelKey: 'common.create' },
|
|
22
|
+
],
|
|
23
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { Page, PageBody } from '@open-mercato/ui/backend/Page'
|
|
4
|
+
import { CrudForm } from '@open-mercato/ui/backend/CrudForm'
|
|
5
|
+
import { createCrud } from '@open-mercato/ui/backend/utils/crud'
|
|
6
|
+
import { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'
|
|
7
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
8
|
+
import { E } from '#generated/entities.ids.generated'
|
|
9
|
+
import { PRICE_FORM_INITIAL_VALUES, usePriceFormFields, type PriceFormValues } from '../../../../components/prices/priceFormFields'
|
|
10
|
+
|
|
11
|
+
function buildPricePayload(values: PriceFormValues, t: (key: string, fallback?: string) => string): Record<string, unknown> {
|
|
12
|
+
const productId = values.productId?.trim() || undefined
|
|
13
|
+
const variantId = values.variantId?.trim() || undefined
|
|
14
|
+
if (!productId && !variantId) {
|
|
15
|
+
const message = t('catalog.prices.form.errors.productOrVariantRequired', 'Choose a product or a variant.')
|
|
16
|
+
throw createCrudFormError(message, { productId: message })
|
|
17
|
+
}
|
|
18
|
+
const priceKindId = values.priceKindId?.trim() || undefined
|
|
19
|
+
const currencyCode = values.currencyCode?.trim() || undefined
|
|
20
|
+
const payload: Record<string, unknown> = {
|
|
21
|
+
productId,
|
|
22
|
+
variantId,
|
|
23
|
+
priceKindId,
|
|
24
|
+
currencyCode,
|
|
25
|
+
unitPriceNet: values.unitPriceNet?.trim() || undefined,
|
|
26
|
+
unitPriceGross: values.unitPriceGross?.trim() || undefined,
|
|
27
|
+
taxRate: values.taxRate?.trim() || undefined,
|
|
28
|
+
minQuantity: values.minQuantity ?? undefined,
|
|
29
|
+
maxQuantity: values.maxQuantity ?? undefined,
|
|
30
|
+
startsAt: values.startsAt?.trim() || undefined,
|
|
31
|
+
endsAt: values.endsAt?.trim() || undefined,
|
|
32
|
+
customerId: values.customerId?.trim() || undefined,
|
|
33
|
+
customerGroupId: values.customerGroupId?.trim() || undefined,
|
|
34
|
+
channelId: values.channelId?.trim() || undefined,
|
|
35
|
+
offerId: values.offerId?.trim() || undefined,
|
|
36
|
+
userId: values.userId?.trim() || undefined,
|
|
37
|
+
userGroupId: values.userGroupId?.trim() || undefined,
|
|
38
|
+
}
|
|
39
|
+
return payload
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default function CreateCatalogPricePage() {
|
|
43
|
+
const t = useT()
|
|
44
|
+
const { fields, groups } = usePriceFormFields()
|
|
45
|
+
const successMessage = encodeURIComponent(t('catalog.prices.flash.created', 'Price rule created'))
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<Page>
|
|
49
|
+
<PageBody>
|
|
50
|
+
<CrudForm<PriceFormValues>
|
|
51
|
+
title={t('catalog.prices.form.createTitle', 'Create price rule')}
|
|
52
|
+
titleHeadingLevel={1}
|
|
53
|
+
backHref="/backend/catalog/prices"
|
|
54
|
+
fields={fields}
|
|
55
|
+
groups={groups}
|
|
56
|
+
collapsibleGroups
|
|
57
|
+
entityId={E.catalog.catalog_product_price}
|
|
58
|
+
initialValues={PRICE_FORM_INITIAL_VALUES}
|
|
59
|
+
submitLabel={t('catalog.prices.form.action.save', 'Save')}
|
|
60
|
+
cancelHref="/backend/catalog/prices"
|
|
61
|
+
successRedirect={`/backend/catalog/prices?flash=${successMessage}&type=success`}
|
|
62
|
+
onSubmit={async (values) => {
|
|
63
|
+
const payload = buildPricePayload(values, t)
|
|
64
|
+
await createCrud('catalog/prices', payload)
|
|
65
|
+
}}
|
|
66
|
+
/>
|
|
67
|
+
</PageBody>
|
|
68
|
+
</Page>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const metadata = {
|
|
2
|
+
requireAuth: true,
|
|
3
|
+
requireFeatures: ['catalog.products.view'],
|
|
4
|
+
pageTitle: 'Price rules',
|
|
5
|
+
pageTitleKey: 'catalog.prices.page.title',
|
|
6
|
+
pageGroup: 'Catalog',
|
|
7
|
+
pageGroupKey: 'catalog.nav.group',
|
|
8
|
+
pagePriority: 30,
|
|
9
|
+
pageOrder: 110,
|
|
10
|
+
icon: 'tag',
|
|
11
|
+
breadcrumb: [{ label: 'Price rules', labelKey: 'catalog.prices.page.title' }],
|
|
12
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { Page, PageBody } from '@open-mercato/ui/backend/Page'
|
|
4
|
+
import PricesDataTable from '../../../components/prices/PricesDataTable'
|
|
5
|
+
|
|
6
|
+
export default function CatalogPricesPage() {
|
|
7
|
+
return (
|
|
8
|
+
<Page>
|
|
9
|
+
<PageBody>
|
|
10
|
+
<PricesDataTable />
|
|
11
|
+
</PageBody>
|
|
12
|
+
</Page>
|
|
13
|
+
)
|
|
14
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { ComboboxInput, type ComboboxOption } from '@open-mercato/ui/backend/inputs/ComboboxInput'
|
|
5
|
+
import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
|
|
6
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
7
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
8
|
+
|
|
9
|
+
const logger = createLogger('catalog').child({ component: 'PriceScopeSelectors' })
|
|
10
|
+
|
|
11
|
+
type RemoteItem = Record<string, unknown>
|
|
12
|
+
|
|
13
|
+
function pickString(item: RemoteItem, ...keys: string[]): string {
|
|
14
|
+
for (const key of keys) {
|
|
15
|
+
const value = item[key]
|
|
16
|
+
if (typeof value === 'string' && value.length) return value
|
|
17
|
+
}
|
|
18
|
+
return ''
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function loadOptions(
|
|
22
|
+
path: string,
|
|
23
|
+
params: Record<string, string>,
|
|
24
|
+
mapItem: (item: RemoteItem) => ComboboxOption | null,
|
|
25
|
+
): Promise<ComboboxOption[]> {
|
|
26
|
+
try {
|
|
27
|
+
const search = new URLSearchParams(params)
|
|
28
|
+
const payload = await readApiResultOrThrow<{ items?: RemoteItem[] }>(
|
|
29
|
+
`${path}?${search.toString()}`,
|
|
30
|
+
undefined,
|
|
31
|
+
{ fallback: { items: [] } },
|
|
32
|
+
)
|
|
33
|
+
const items = Array.isArray(payload.items) ? payload.items : []
|
|
34
|
+
return items.map(mapItem).filter((option): option is ComboboxOption => option !== null)
|
|
35
|
+
} catch (err) {
|
|
36
|
+
logger.error('catalog.prices.scopeSelect.load', { err, path })
|
|
37
|
+
return []
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function resolveOne(
|
|
42
|
+
path: string,
|
|
43
|
+
idParam: string,
|
|
44
|
+
id: string,
|
|
45
|
+
mapItem: (item: RemoteItem) => ComboboxOption | null,
|
|
46
|
+
): Promise<string> {
|
|
47
|
+
const options = await loadOptions(path, { [idParam]: id, pageSize: '1' }, mapItem)
|
|
48
|
+
return options[0]?.label ?? id
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A typed query that is itself a record id (a pasted UUID) is searched via
|
|
55
|
+
* the generic `ids=` narrowing instead of the entity's free-text `search=`
|
|
56
|
+
* filter — free-text search matches title/name/sku columns, never the id
|
|
57
|
+
* column, so pasting an exact id would otherwise return zero suggestions.
|
|
58
|
+
*/
|
|
59
|
+
function buildLookupParams(query: string | undefined, pageSize: string): Record<string, string> {
|
|
60
|
+
const trimmed = (query ?? '').trim()
|
|
61
|
+
if (UUID_PATTERN.test(trimmed)) return { ids: trimmed, pageSize: '1' }
|
|
62
|
+
return { search: trimmed, pageSize }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function PriceProductSelect({
|
|
66
|
+
value,
|
|
67
|
+
onChange,
|
|
68
|
+
disabled,
|
|
69
|
+
}: {
|
|
70
|
+
value: string
|
|
71
|
+
onChange: (next: string) => void
|
|
72
|
+
disabled?: boolean
|
|
73
|
+
}) {
|
|
74
|
+
const t = useT()
|
|
75
|
+
const mapItem = React.useCallback((item: RemoteItem): ComboboxOption | null => {
|
|
76
|
+
const id = pickString(item, 'id')
|
|
77
|
+
if (!id) return null
|
|
78
|
+
const title = pickString(item, 'title', 'name') || id
|
|
79
|
+
const sku = pickString(item, 'sku')
|
|
80
|
+
return { value: id, label: sku ? `${title} (${sku})` : title }
|
|
81
|
+
}, [])
|
|
82
|
+
return (
|
|
83
|
+
<ComboboxInput
|
|
84
|
+
value={value}
|
|
85
|
+
onChange={onChange}
|
|
86
|
+
disabled={disabled}
|
|
87
|
+
allowCustomValues={false}
|
|
88
|
+
clearable
|
|
89
|
+
placeholder={t('catalog.prices.select.product.placeholder', 'Search products…')}
|
|
90
|
+
loadSuggestions={(query) => loadOptions('/api/catalog/products', buildLookupParams(query, '10'), mapItem)}
|
|
91
|
+
resolveLabel={(id) => resolveOne('/api/catalog/products', 'id', id, mapItem)}
|
|
92
|
+
/>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function PriceVariantSelect({
|
|
97
|
+
productId,
|
|
98
|
+
value,
|
|
99
|
+
onChange,
|
|
100
|
+
}: {
|
|
101
|
+
productId: string
|
|
102
|
+
value: string
|
|
103
|
+
onChange: (next: string) => void
|
|
104
|
+
}) {
|
|
105
|
+
const t = useT()
|
|
106
|
+
const mapItem = React.useCallback((item: RemoteItem): ComboboxOption | null => {
|
|
107
|
+
const id = pickString(item, 'id')
|
|
108
|
+
if (!id) return null
|
|
109
|
+
const name = pickString(item, 'name') || id
|
|
110
|
+
const sku = pickString(item, 'sku')
|
|
111
|
+
return { value: id, label: sku ? `${name} (${sku})` : name }
|
|
112
|
+
}, [])
|
|
113
|
+
const disabled = !productId
|
|
114
|
+
return (
|
|
115
|
+
<ComboboxInput
|
|
116
|
+
value={value}
|
|
117
|
+
onChange={onChange}
|
|
118
|
+
disabled={disabled}
|
|
119
|
+
allowCustomValues={false}
|
|
120
|
+
clearable
|
|
121
|
+
placeholder={
|
|
122
|
+
disabled
|
|
123
|
+
? t('catalog.prices.form.field.product', 'Product')
|
|
124
|
+
: t('catalog.prices.select.product.placeholder', 'Search products…')
|
|
125
|
+
}
|
|
126
|
+
loadSuggestions={(query) =>
|
|
127
|
+
disabled
|
|
128
|
+
? Promise.resolve([])
|
|
129
|
+
: loadOptions('/api/catalog/variants', { ...buildLookupParams(query, '10'), productId }, mapItem)
|
|
130
|
+
}
|
|
131
|
+
resolveLabel={(id) => resolveOne('/api/catalog/variants', 'id', id, mapItem)}
|
|
132
|
+
/>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function PriceCustomerSelect({
|
|
137
|
+
value,
|
|
138
|
+
onChange,
|
|
139
|
+
}: {
|
|
140
|
+
value: string
|
|
141
|
+
onChange: (next: string) => void
|
|
142
|
+
}) {
|
|
143
|
+
const t = useT()
|
|
144
|
+
const mapItem = React.useCallback((item: RemoteItem): ComboboxOption | null => {
|
|
145
|
+
const id = pickString(item, 'id')
|
|
146
|
+
if (!id) return null
|
|
147
|
+
const name = pickString(item, 'displayName', 'display_name', 'name') || id
|
|
148
|
+
const email = pickString(item, 'primaryEmail', 'primary_email')
|
|
149
|
+
return { value: id, label: name, description: email || null }
|
|
150
|
+
}, [])
|
|
151
|
+
return (
|
|
152
|
+
<ComboboxInput
|
|
153
|
+
value={value}
|
|
154
|
+
onChange={onChange}
|
|
155
|
+
allowCustomValues={false}
|
|
156
|
+
clearable
|
|
157
|
+
placeholder={t('catalog.prices.select.customer.placeholder', 'Search customers…')}
|
|
158
|
+
loadSuggestions={(query) => loadOptions('/api/customers/people', buildLookupParams(query, '10'), mapItem)}
|
|
159
|
+
resolveLabel={(id) => resolveOne('/api/customers/people', 'id', id, mapItem)}
|
|
160
|
+
/>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function PriceChannelSelect({
|
|
165
|
+
value,
|
|
166
|
+
onChange,
|
|
167
|
+
}: {
|
|
168
|
+
value: string
|
|
169
|
+
onChange: (next: string) => void
|
|
170
|
+
}) {
|
|
171
|
+
const t = useT()
|
|
172
|
+
const mapItem = React.useCallback((item: RemoteItem): ComboboxOption | null => {
|
|
173
|
+
const id = pickString(item, 'id')
|
|
174
|
+
if (!id) return null
|
|
175
|
+
const name = pickString(item, 'name') || id
|
|
176
|
+
const code = pickString(item, 'code')
|
|
177
|
+
return { value: id, label: code ? `${name} (${code})` : name }
|
|
178
|
+
}, [])
|
|
179
|
+
return (
|
|
180
|
+
<ComboboxInput
|
|
181
|
+
value={value}
|
|
182
|
+
onChange={onChange}
|
|
183
|
+
allowCustomValues={false}
|
|
184
|
+
clearable
|
|
185
|
+
placeholder={t('catalog.prices.select.channel.placeholder', 'Select a channel…')}
|
|
186
|
+
loadSuggestions={(query) => loadOptions('/api/sales/channels', buildLookupParams(query, '50'), mapItem)}
|
|
187
|
+
resolveLabel={(id) => resolveOne('/api/sales/channels', 'id', id, mapItem)}
|
|
188
|
+
/>
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function PricePriceKindSelect({
|
|
193
|
+
value,
|
|
194
|
+
onChange,
|
|
195
|
+
}: {
|
|
196
|
+
value: string
|
|
197
|
+
onChange: (next: string) => void
|
|
198
|
+
}) {
|
|
199
|
+
const t = useT()
|
|
200
|
+
const mapItem = React.useCallback((item: RemoteItem): ComboboxOption | null => {
|
|
201
|
+
const id = pickString(item, 'id')
|
|
202
|
+
if (!id) return null
|
|
203
|
+
const title = pickString(item, 'title') || id
|
|
204
|
+
const code = pickString(item, 'code')
|
|
205
|
+
return { value: id, label: code ? `${title} (${code})` : title }
|
|
206
|
+
}, [])
|
|
207
|
+
return (
|
|
208
|
+
<ComboboxInput
|
|
209
|
+
value={value}
|
|
210
|
+
onChange={onChange}
|
|
211
|
+
allowCustomValues={false}
|
|
212
|
+
placeholder={t('catalog.prices.select.priceKind.placeholder', 'Select a price kind…')}
|
|
213
|
+
loadSuggestions={(query) => loadOptions('/api/catalog/price-kinds', buildLookupParams(query, '20'), mapItem)}
|
|
214
|
+
resolveLabel={(id) => resolveOne('/api/catalog/price-kinds', 'id', id, mapItem)}
|
|
215
|
+
/>
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function PriceCurrencySelect({
|
|
220
|
+
value,
|
|
221
|
+
onChange,
|
|
222
|
+
}: {
|
|
223
|
+
value: string
|
|
224
|
+
onChange: (next: string) => void
|
|
225
|
+
}) {
|
|
226
|
+
const t = useT()
|
|
227
|
+
const mapItem = React.useCallback((item: RemoteItem): ComboboxOption | null => {
|
|
228
|
+
const code = pickString(item, 'value')
|
|
229
|
+
if (!code) return null
|
|
230
|
+
const label = pickString(item, 'label') || code
|
|
231
|
+
return { value: code, label: label === code ? code : `${code} — ${label}` }
|
|
232
|
+
}, [])
|
|
233
|
+
return (
|
|
234
|
+
<ComboboxInput
|
|
235
|
+
value={value}
|
|
236
|
+
onChange={onChange}
|
|
237
|
+
allowCustomValues={false}
|
|
238
|
+
placeholder={t('catalog.prices.select.currency.placeholder', 'Select a currency…')}
|
|
239
|
+
loadSuggestions={(query) => loadOptions('/api/currencies/currencies/options', { search: query ?? '', limit: '20' }, mapItem)}
|
|
240
|
+
resolveLabel={(id) => resolveOne('/api/currencies/currencies/options', 'search', id, mapItem)}
|
|
241
|
+
/>
|
|
242
|
+
)
|
|
243
|
+
}
|