@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,320 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import Link from 'next/link'
|
|
5
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
6
|
+
import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
|
|
7
|
+
import { extensionPoints } from '@open-mercato/core/modules/catalog/extension-points'
|
|
8
|
+
import { DataTable } from '@open-mercato/ui/backend/DataTable'
|
|
9
|
+
import { ListEmptyState } from '@open-mercato/ui/backend/filters/ListEmptyState'
|
|
10
|
+
import { RowActions } from '@open-mercato/ui/backend/RowActions'
|
|
11
|
+
import { Button } from '@open-mercato/ui/primitives/button'
|
|
12
|
+
import { Tag } from '@open-mercato/ui/primitives/tag'
|
|
13
|
+
import { apiCall, apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
|
|
14
|
+
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
15
|
+
import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
|
|
16
|
+
import { useT, type TranslateFn } from '@open-mercato/shared/lib/i18n/context'
|
|
17
|
+
import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
|
|
18
|
+
import { useBatchLabels } from './useBatchLabels'
|
|
19
|
+
import { normalizePriceRecord, type NormalizedPriceRecord } from './normalizePriceRecord'
|
|
20
|
+
|
|
21
|
+
type PriceRow = NormalizedPriceRecord
|
|
22
|
+
|
|
23
|
+
type PricesResponse = {
|
|
24
|
+
items: PriceRow[]
|
|
25
|
+
total: number
|
|
26
|
+
page: number
|
|
27
|
+
pageSize: number
|
|
28
|
+
totalPages: number
|
|
29
|
+
totalIsCapped?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const PAGE_SIZE = 50
|
|
33
|
+
|
|
34
|
+
const KIND_TAG_VARIANT: Record<string, 'brand' | 'warning' | 'info' | 'neutral'> = {
|
|
35
|
+
custom: 'brand',
|
|
36
|
+
promotion: 'warning',
|
|
37
|
+
tier: 'info',
|
|
38
|
+
regular: 'neutral',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function formatAmount(row: PriceRow): string {
|
|
42
|
+
const amount = row.unitPriceNet ?? row.unitPriceGross
|
|
43
|
+
if (amount == null) return '—'
|
|
44
|
+
return row.currencyCode ? `${amount} ${row.currencyCode}` : amount
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function formatQuantityRange(row: PriceRow, t: TranslateFn): string {
|
|
48
|
+
if (row.maxQuantity != null) {
|
|
49
|
+
return `${row.minQuantity}–${row.maxQuantity}`
|
|
50
|
+
}
|
|
51
|
+
return t('catalog.prices.list.quantity.min', '{{min}}+', { min: row.minQuantity })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export default function PricesDataTable({ productId }: { productId?: string } = {}) {
|
|
55
|
+
const t = useT()
|
|
56
|
+
const { confirm, ConfirmDialogElement } = useConfirmDialog()
|
|
57
|
+
const queryClient = useQueryClient()
|
|
58
|
+
const scopeVersion = useOrganizationScopeVersion()
|
|
59
|
+
const [page, setPage] = React.useState(1)
|
|
60
|
+
const [search, setSearch] = React.useState('')
|
|
61
|
+
const [canManage, setCanManage] = React.useState(false)
|
|
62
|
+
|
|
63
|
+
React.useEffect(() => {
|
|
64
|
+
let cancelled = false
|
|
65
|
+
async function load() {
|
|
66
|
+
try {
|
|
67
|
+
const call = await apiCall<{ granted?: string[]; ok?: boolean }>('/api/auth/feature-check', {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { 'content-type': 'application/json' },
|
|
70
|
+
body: JSON.stringify({ features: ['catalog.pricing.manage'] }),
|
|
71
|
+
})
|
|
72
|
+
if (!cancelled) {
|
|
73
|
+
const granted = Array.isArray(call.result?.granted) ? call.result.granted : []
|
|
74
|
+
setCanManage(call.result?.ok === true || granted.includes('catalog.pricing.manage'))
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
if (!cancelled) setCanManage(false)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
load()
|
|
81
|
+
return () => {
|
|
82
|
+
cancelled = true
|
|
83
|
+
}
|
|
84
|
+
}, [])
|
|
85
|
+
|
|
86
|
+
const queryParams = React.useMemo(() => {
|
|
87
|
+
const params = new URLSearchParams()
|
|
88
|
+
params.set('page', String(page))
|
|
89
|
+
params.set('pageSize', String(PAGE_SIZE))
|
|
90
|
+
if (productId) params.set('productId', productId)
|
|
91
|
+
if (search) params.set('search', search)
|
|
92
|
+
return params.toString()
|
|
93
|
+
}, [page, productId, search])
|
|
94
|
+
|
|
95
|
+
const { data, isLoading } = useQuery<PricesResponse>({
|
|
96
|
+
queryKey: ['catalog-prices', queryParams, scopeVersion],
|
|
97
|
+
queryFn: async () => {
|
|
98
|
+
const payload = await readApiResultOrThrow<{
|
|
99
|
+
items?: Array<Record<string, unknown>>
|
|
100
|
+
total?: number
|
|
101
|
+
page?: number
|
|
102
|
+
pageSize?: number
|
|
103
|
+
totalPages?: number
|
|
104
|
+
totalIsCapped?: boolean
|
|
105
|
+
}>(
|
|
106
|
+
`/api/catalog/prices?${queryParams}`,
|
|
107
|
+
undefined,
|
|
108
|
+
{ errorMessage: t('catalog.prices.list.error.load', 'Failed to load price rules') },
|
|
109
|
+
)
|
|
110
|
+
const rawItems = Array.isArray(payload.items) ? payload.items : []
|
|
111
|
+
return {
|
|
112
|
+
items: rawItems.map(normalizePriceRecord),
|
|
113
|
+
total: typeof payload.total === 'number' ? payload.total : 0,
|
|
114
|
+
page: typeof payload.page === 'number' ? payload.page : 1,
|
|
115
|
+
pageSize: typeof payload.pageSize === 'number' ? payload.pageSize : PAGE_SIZE,
|
|
116
|
+
totalPages: typeof payload.totalPages === 'number' ? payload.totalPages : 1,
|
|
117
|
+
totalIsCapped: payload.totalIsCapped === true,
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const rows = data?.items ?? []
|
|
123
|
+
const total = data?.total ?? 0
|
|
124
|
+
const totalPages = data?.totalPages ?? 0
|
|
125
|
+
const totalIsCapped = data?.totalIsCapped === true
|
|
126
|
+
|
|
127
|
+
const productIds = React.useMemo(() => rows.map((row) => row.variantId ?? row.productId).filter((id): id is string => !!id), [rows])
|
|
128
|
+
const mapProduct = React.useCallback((item: Record<string, unknown>) => {
|
|
129
|
+
const id = typeof item.id === 'string' ? item.id : ''
|
|
130
|
+
if (!id) return null
|
|
131
|
+
const label = typeof item.title === 'string' ? item.title : (typeof item.name === 'string' ? item.name : id)
|
|
132
|
+
return { id, label }
|
|
133
|
+
}, [])
|
|
134
|
+
const productLabels = useBatchLabels('/api/catalog/products', rows.filter((r) => !r.variantId).map((r) => r.productId ?? ''), mapProduct)
|
|
135
|
+
const variantLabels = useBatchLabels('/api/catalog/variants', rows.filter((r) => r.variantId).map((r) => r.variantId ?? ''), mapProduct)
|
|
136
|
+
|
|
137
|
+
const customerIds = React.useMemo(() => rows.map((row) => row.customerId).filter((id): id is string => !!id), [rows])
|
|
138
|
+
const mapCustomer = React.useCallback((item: Record<string, unknown>) => {
|
|
139
|
+
const id = typeof item.id === 'string' ? item.id : ''
|
|
140
|
+
if (!id) return null
|
|
141
|
+
const label = typeof item.displayName === 'string' ? item.displayName : (typeof item.display_name === 'string' ? item.display_name : id)
|
|
142
|
+
return { id, label }
|
|
143
|
+
}, [])
|
|
144
|
+
const customerLabels = useBatchLabels('/api/customers/people', customerIds, mapCustomer)
|
|
145
|
+
|
|
146
|
+
const channelIds = React.useMemo(() => rows.map((row) => row.channelId).filter((id): id is string => !!id), [rows])
|
|
147
|
+
const mapChannel = React.useCallback((item: Record<string, unknown>) => {
|
|
148
|
+
const id = typeof item.id === 'string' ? item.id : ''
|
|
149
|
+
if (!id) return null
|
|
150
|
+
const label = typeof item.name === 'string' ? item.name : id
|
|
151
|
+
return { id, label }
|
|
152
|
+
}, [])
|
|
153
|
+
const channelLabels = useBatchLabels('/api/sales/channels', channelIds, mapChannel)
|
|
154
|
+
|
|
155
|
+
const handleDelete = React.useCallback(async (row: PriceRow) => {
|
|
156
|
+
const confirmed = await confirm({
|
|
157
|
+
title: t('catalog.prices.list.confirmDelete', 'Delete this price rule?'),
|
|
158
|
+
variant: 'destructive',
|
|
159
|
+
})
|
|
160
|
+
if (!confirmed) return
|
|
161
|
+
try {
|
|
162
|
+
// optimistic-lock-exempt: delete-only mutation — no field-level lost-update
|
|
163
|
+
await apiCallOrThrow(
|
|
164
|
+
`/api/catalog/prices?id=${encodeURIComponent(row.id)}`,
|
|
165
|
+
{ method: 'DELETE' },
|
|
166
|
+
{ errorMessage: t('catalog.prices.list.error.delete', 'Failed to delete price rule') },
|
|
167
|
+
)
|
|
168
|
+
await queryClient.invalidateQueries({ queryKey: ['catalog-prices'] })
|
|
169
|
+
flash(t('catalog.prices.flash.deleted', 'Price rule deleted'), 'success')
|
|
170
|
+
} catch (err: unknown) {
|
|
171
|
+
const fallback = t('catalog.prices.list.error.delete', 'Failed to delete price rule')
|
|
172
|
+
const message = err instanceof Error ? err.message : fallback
|
|
173
|
+
flash(message, 'error')
|
|
174
|
+
}
|
|
175
|
+
}, [confirm, queryClient, t])
|
|
176
|
+
|
|
177
|
+
const columns = React.useMemo<ColumnDef<PriceRow>[]>(() => [
|
|
178
|
+
{
|
|
179
|
+
id: 'product',
|
|
180
|
+
header: t('catalog.prices.list.columns.product', 'Product'),
|
|
181
|
+
meta: { priority: 1 },
|
|
182
|
+
cell: ({ row }) => {
|
|
183
|
+
const price = row.original
|
|
184
|
+
const label = price.variantId
|
|
185
|
+
? (variantLabels[price.variantId] ?? price.variantId)
|
|
186
|
+
: (price.productId ? (productLabels[price.productId] ?? price.productId) : '—')
|
|
187
|
+
return <span className="text-sm font-medium text-foreground">{label}</span>
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
id: 'kind',
|
|
192
|
+
header: t('catalog.prices.list.columns.kind', 'Kind'),
|
|
193
|
+
meta: { priority: 2 },
|
|
194
|
+
cell: ({ row }) => {
|
|
195
|
+
const kind = row.original.kind || 'regular'
|
|
196
|
+
return <Tag variant={KIND_TAG_VARIANT[kind] ?? 'neutral'}>{kind}</Tag>
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
id: 'amount',
|
|
201
|
+
header: t('catalog.prices.list.columns.amount', 'Amount'),
|
|
202
|
+
meta: { priority: 1 },
|
|
203
|
+
cell: ({ row }) => <span className="text-sm">{formatAmount(row.original)}</span>,
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
id: 'scope',
|
|
207
|
+
header: t('catalog.prices.list.columns.scope', 'Scope'),
|
|
208
|
+
meta: { priority: 3 },
|
|
209
|
+
cell: ({ row }) => {
|
|
210
|
+
const price = row.original
|
|
211
|
+
const chips: React.ReactNode[] = []
|
|
212
|
+
if (price.customerId) {
|
|
213
|
+
chips.push(
|
|
214
|
+
<Tag key="customer" variant="info" dot>
|
|
215
|
+
{customerLabels[price.customerId] ?? t('catalog.prices.list.scope.customer', 'Customer')}
|
|
216
|
+
</Tag>,
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
if (price.customerGroupId) {
|
|
220
|
+
chips.push(
|
|
221
|
+
<Tag key="customerGroup" variant="info" dot>
|
|
222
|
+
{t('catalog.prices.list.scope.customerGroup', 'Customer group')}
|
|
223
|
+
</Tag>,
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
if (price.channelId) {
|
|
227
|
+
chips.push(
|
|
228
|
+
<Tag key="channel" variant="neutral" dot>
|
|
229
|
+
{channelLabels[price.channelId] ?? t('catalog.prices.list.scope.channel', 'Channel')}
|
|
230
|
+
</Tag>,
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
if (!chips.length) return <span className="text-xs text-muted-foreground">—</span>
|
|
234
|
+
return <div className="flex flex-wrap gap-1">{chips}</div>
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
id: 'quantity',
|
|
239
|
+
header: t('catalog.prices.list.columns.quantity', 'Quantity'),
|
|
240
|
+
meta: { priority: 4 },
|
|
241
|
+
cell: ({ row }) => <span className="text-sm">{formatQuantityRange(row.original, t)}</span>,
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
id: 'validity',
|
|
245
|
+
header: t('catalog.prices.list.columns.validity', 'Validity'),
|
|
246
|
+
meta: { priority: 5 },
|
|
247
|
+
cell: ({ row }) => {
|
|
248
|
+
const price = row.original
|
|
249
|
+
const isExpired = Boolean(price.endsAt && new Date(price.endsAt) < new Date())
|
|
250
|
+
if (!price.startsAt && !price.endsAt) {
|
|
251
|
+
return <span className="text-sm text-muted-foreground">{t('catalog.prices.list.validity.always', 'Always')}</span>
|
|
252
|
+
}
|
|
253
|
+
const from = price.startsAt ? new Date(price.startsAt).toLocaleDateString() : null
|
|
254
|
+
const until = price.endsAt ? new Date(price.endsAt).toLocaleDateString() : null
|
|
255
|
+
const label = from && until
|
|
256
|
+
? t('catalog.prices.list.validity.range', '{{from}} – {{until}}', { from, until })
|
|
257
|
+
: from
|
|
258
|
+
? t('catalog.prices.list.validity.from', 'From {{date}}', { date: from })
|
|
259
|
+
: t('catalog.prices.list.validity.until', 'Until {{date}}', { date: until ?? '' })
|
|
260
|
+
return (
|
|
261
|
+
<div className="flex items-center gap-2">
|
|
262
|
+
<span className="text-sm">{label}</span>
|
|
263
|
+
{isExpired ? <Tag variant="error" dot>{t('catalog.prices.list.badge.expired', 'Expired')}</Tag> : null}
|
|
264
|
+
</div>
|
|
265
|
+
)
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
], [t, variantLabels, productLabels, customerLabels, channelLabels])
|
|
269
|
+
|
|
270
|
+
return (
|
|
271
|
+
<>
|
|
272
|
+
<DataTable
|
|
273
|
+
title={t('catalog.prices.list.title', 'Price rules')}
|
|
274
|
+
titleHeadingLevel={1}
|
|
275
|
+
actions={canManage ? (
|
|
276
|
+
<Button asChild>
|
|
277
|
+
<Link href="/backend/catalog/prices/create">
|
|
278
|
+
{t('catalog.prices.list.actions.create', 'Create')}
|
|
279
|
+
</Link>
|
|
280
|
+
</Button>
|
|
281
|
+
) : undefined}
|
|
282
|
+
columns={columns}
|
|
283
|
+
data={rows}
|
|
284
|
+
emptyState={(
|
|
285
|
+
<ListEmptyState
|
|
286
|
+
entityName={t('catalog.prices.list.title', 'Price rules')}
|
|
287
|
+
description={t('catalog.prices.list.empty', 'No price rules yet — add one to price this product beyond its base price.')}
|
|
288
|
+
createHref={canManage ? '/backend/catalog/prices/create' : undefined}
|
|
289
|
+
createLabel={canManage ? t('catalog.prices.list.actions.create', 'Create') : undefined}
|
|
290
|
+
/>
|
|
291
|
+
)}
|
|
292
|
+
searchValue={search}
|
|
293
|
+
searchPlaceholder={t('catalog.prices.list.searchPlaceholder', 'Search price rules')}
|
|
294
|
+
onSearchChange={(value) => { setSearch(value); setPage(1) }}
|
|
295
|
+
sortable={false}
|
|
296
|
+
perspective={{ tableId: extensionPoints.hosts.pricesTable.tableId }}
|
|
297
|
+
rowActions={(row) => (
|
|
298
|
+
canManage ? (
|
|
299
|
+
<RowActions
|
|
300
|
+
items={[
|
|
301
|
+
{ id: 'edit', label: t('catalog.prices.list.actions.edit', 'Edit'), href: `/backend/catalog/prices/${row.id}/edit` },
|
|
302
|
+
{ id: 'delete', label: t('catalog.prices.list.actions.delete', 'Delete'), destructive: true, onSelect: () => handleDelete(row) },
|
|
303
|
+
]}
|
|
304
|
+
/>
|
|
305
|
+
) : null
|
|
306
|
+
)}
|
|
307
|
+
pagination={{
|
|
308
|
+
page,
|
|
309
|
+
pageSize: PAGE_SIZE,
|
|
310
|
+
total,
|
|
311
|
+
totalPages,
|
|
312
|
+
totalIsCapped,
|
|
313
|
+
onPageChange: setPage,
|
|
314
|
+
}}
|
|
315
|
+
isLoading={isLoading}
|
|
316
|
+
/>
|
|
317
|
+
{ConfirmDialogElement}
|
|
318
|
+
</>
|
|
319
|
+
)
|
|
320
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// `/api/catalog/prices` list/detail responses serialize snake_case column
|
|
2
|
+
// names (verified against the live route by
|
|
3
|
+
// `__integration__/TC-CAT-CRUDFORM-002.spec.ts`: `variant_id`,
|
|
4
|
+
// `unit_price_gross`, `min_quantity`, ...) — this normalizes either shape
|
|
5
|
+
// into one camelCase record so UI code never has to guess which one it got.
|
|
6
|
+
|
|
7
|
+
export type NormalizedPriceRecord = {
|
|
8
|
+
id: string
|
|
9
|
+
productId: string | null
|
|
10
|
+
variantId: string | null
|
|
11
|
+
priceKindId: string | null
|
|
12
|
+
currencyCode: string | null
|
|
13
|
+
kind: string
|
|
14
|
+
unitPriceNet: string | null
|
|
15
|
+
unitPriceGross: string | null
|
|
16
|
+
taxRate: string | null
|
|
17
|
+
minQuantity: number
|
|
18
|
+
maxQuantity: number | null
|
|
19
|
+
startsAt: string | null
|
|
20
|
+
endsAt: string | null
|
|
21
|
+
customerId: string | null
|
|
22
|
+
customerGroupId: string | null
|
|
23
|
+
channelId: string | null
|
|
24
|
+
offerId: string | null
|
|
25
|
+
userId: string | null
|
|
26
|
+
userGroupId: string | null
|
|
27
|
+
updatedAt: string | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function str(record: Record<string, unknown>, camelKey: string, snakeKey: string): string | null {
|
|
31
|
+
const value = record[camelKey] ?? record[snakeKey]
|
|
32
|
+
return typeof value === 'string' && value.length ? value : null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function num(record: Record<string, unknown>, camelKey: string, snakeKey: string, fallback: number): number {
|
|
36
|
+
const value = record[camelKey] ?? record[snakeKey]
|
|
37
|
+
const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN
|
|
38
|
+
return Number.isFinite(parsed) ? parsed : fallback
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function numOrNull(record: Record<string, unknown>, camelKey: string, snakeKey: string): number | null {
|
|
42
|
+
const value = record[camelKey] ?? record[snakeKey]
|
|
43
|
+
if (value == null) return null
|
|
44
|
+
const parsed = typeof value === 'number' ? value : Number(value)
|
|
45
|
+
return Number.isFinite(parsed) ? parsed : null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function normalizePriceRecord(record: Record<string, unknown>): NormalizedPriceRecord {
|
|
49
|
+
return {
|
|
50
|
+
id: str(record, 'id', 'id') ?? '',
|
|
51
|
+
productId: str(record, 'productId', 'product_id'),
|
|
52
|
+
variantId: str(record, 'variantId', 'variant_id'),
|
|
53
|
+
priceKindId: str(record, 'priceKindId', 'price_kind_id'),
|
|
54
|
+
currencyCode: str(record, 'currencyCode', 'currency_code'),
|
|
55
|
+
kind: str(record, 'kind', 'kind') ?? 'regular',
|
|
56
|
+
unitPriceNet: str(record, 'unitPriceNet', 'unit_price_net'),
|
|
57
|
+
unitPriceGross: str(record, 'unitPriceGross', 'unit_price_gross'),
|
|
58
|
+
taxRate: str(record, 'taxRate', 'tax_rate'),
|
|
59
|
+
minQuantity: num(record, 'minQuantity', 'min_quantity', 1),
|
|
60
|
+
maxQuantity: numOrNull(record, 'maxQuantity', 'max_quantity'),
|
|
61
|
+
startsAt: str(record, 'startsAt', 'starts_at'),
|
|
62
|
+
endsAt: str(record, 'endsAt', 'ends_at'),
|
|
63
|
+
customerId: str(record, 'customerId', 'customer_id'),
|
|
64
|
+
customerGroupId: str(record, 'customerGroupId', 'customer_group_id'),
|
|
65
|
+
channelId: str(record, 'channelId', 'channel_id'),
|
|
66
|
+
offerId: str(record, 'offerId', 'offer_id'),
|
|
67
|
+
userId: str(record, 'userId', 'user_id'),
|
|
68
|
+
userGroupId: str(record, 'userGroupId', 'user_group_id'),
|
|
69
|
+
updatedAt: str(record, 'updatedAt', 'updated_at'),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import type { CrudField, CrudFormGroup } from '@open-mercato/ui/backend/CrudForm'
|
|
5
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
|
+
import {
|
|
7
|
+
PriceChannelSelect,
|
|
8
|
+
PriceCurrencySelect,
|
|
9
|
+
PriceCustomerSelect,
|
|
10
|
+
PricePriceKindSelect,
|
|
11
|
+
PriceProductSelect,
|
|
12
|
+
PriceVariantSelect,
|
|
13
|
+
} from './PriceScopeSelectors'
|
|
14
|
+
|
|
15
|
+
export type PriceFormValues = {
|
|
16
|
+
id?: string
|
|
17
|
+
productId?: string
|
|
18
|
+
variantId?: string
|
|
19
|
+
priceKindId?: string
|
|
20
|
+
currencyCode?: string
|
|
21
|
+
unitPriceNet?: string
|
|
22
|
+
unitPriceGross?: string
|
|
23
|
+
taxRate?: string
|
|
24
|
+
minQuantity?: number
|
|
25
|
+
maxQuantity?: number
|
|
26
|
+
startsAt?: string
|
|
27
|
+
endsAt?: string
|
|
28
|
+
customerId?: string
|
|
29
|
+
customerGroupId?: string
|
|
30
|
+
channelId?: string
|
|
31
|
+
offerId?: string
|
|
32
|
+
userId?: string
|
|
33
|
+
userGroupId?: string
|
|
34
|
+
updatedAt?: string | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const PRICE_FORM_INITIAL_VALUES: PriceFormValues = {
|
|
38
|
+
productId: '',
|
|
39
|
+
variantId: '',
|
|
40
|
+
priceKindId: '',
|
|
41
|
+
currencyCode: '',
|
|
42
|
+
unitPriceNet: '',
|
|
43
|
+
unitPriceGross: '',
|
|
44
|
+
taxRate: '',
|
|
45
|
+
minQuantity: 1,
|
|
46
|
+
maxQuantity: undefined,
|
|
47
|
+
startsAt: '',
|
|
48
|
+
endsAt: '',
|
|
49
|
+
customerId: '',
|
|
50
|
+
customerGroupId: '',
|
|
51
|
+
channelId: '',
|
|
52
|
+
offerId: '',
|
|
53
|
+
userId: '',
|
|
54
|
+
userGroupId: '',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Shared field/group config for the price-rule create and edit CrudForms. */
|
|
58
|
+
export function usePriceFormFields(): { fields: CrudField[]; groups: CrudFormGroup[] } {
|
|
59
|
+
const t = useT()
|
|
60
|
+
|
|
61
|
+
const fields = React.useMemo<CrudField[]>(() => [
|
|
62
|
+
{
|
|
63
|
+
id: 'productId',
|
|
64
|
+
label: t('catalog.prices.form.field.product', 'Product'),
|
|
65
|
+
type: 'custom',
|
|
66
|
+
component: ({ value, setValue }) => (
|
|
67
|
+
<PriceProductSelect value={typeof value === 'string' ? value : ''} onChange={setValue} />
|
|
68
|
+
),
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: 'variantId',
|
|
72
|
+
label: t('catalog.prices.form.field.variant', 'Variant'),
|
|
73
|
+
type: 'custom',
|
|
74
|
+
component: ({ value, setValue, values }) => (
|
|
75
|
+
<PriceVariantSelect
|
|
76
|
+
productId={typeof values?.productId === 'string' ? values.productId : ''}
|
|
77
|
+
value={typeof value === 'string' ? value : ''}
|
|
78
|
+
onChange={setValue}
|
|
79
|
+
/>
|
|
80
|
+
),
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: 'priceKindId',
|
|
84
|
+
label: t('catalog.prices.form.field.priceKind', 'Price kind'),
|
|
85
|
+
type: 'custom',
|
|
86
|
+
required: true,
|
|
87
|
+
component: ({ value, setValue }) => (
|
|
88
|
+
<PricePriceKindSelect value={typeof value === 'string' ? value : ''} onChange={setValue} />
|
|
89
|
+
),
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: 'currencyCode',
|
|
93
|
+
label: t('catalog.prices.form.field.currency', 'Currency'),
|
|
94
|
+
type: 'custom',
|
|
95
|
+
required: true,
|
|
96
|
+
component: ({ value, setValue }) => (
|
|
97
|
+
<PriceCurrencySelect value={typeof value === 'string' ? value : ''} onChange={setValue} />
|
|
98
|
+
),
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: 'unitPriceNet',
|
|
102
|
+
label: t('catalog.prices.form.field.unitPriceNet', 'Unit price (net)'),
|
|
103
|
+
type: 'text',
|
|
104
|
+
layout: 'half',
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: 'unitPriceGross',
|
|
108
|
+
label: t('catalog.prices.form.field.unitPriceGross', 'Unit price (gross)'),
|
|
109
|
+
type: 'text',
|
|
110
|
+
layout: 'half',
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: 'taxRate',
|
|
114
|
+
label: t('catalog.prices.form.field.taxRate', 'Tax rate'),
|
|
115
|
+
type: 'text',
|
|
116
|
+
layout: 'half',
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
id: 'minQuantity',
|
|
120
|
+
label: t('catalog.prices.form.field.minQuantity', 'Minimum quantity'),
|
|
121
|
+
type: 'number',
|
|
122
|
+
layout: 'half',
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
id: 'maxQuantity',
|
|
126
|
+
label: t('catalog.prices.form.field.maxQuantity', 'Maximum quantity'),
|
|
127
|
+
type: 'number',
|
|
128
|
+
layout: 'half',
|
|
129
|
+
description: t('catalog.prices.form.field.maxQuantityHint', 'Leave empty for "and above"'),
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: 'startsAt',
|
|
133
|
+
label: t('catalog.prices.form.field.startsAt', 'Starts at'),
|
|
134
|
+
type: 'datepicker',
|
|
135
|
+
layout: 'half',
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
id: 'endsAt',
|
|
139
|
+
label: t('catalog.prices.form.field.endsAt', 'Ends at'),
|
|
140
|
+
type: 'datepicker',
|
|
141
|
+
layout: 'half',
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
id: 'customerId',
|
|
145
|
+
label: t('catalog.prices.form.field.customer', 'Customer'),
|
|
146
|
+
type: 'custom',
|
|
147
|
+
component: ({ value, setValue }) => (
|
|
148
|
+
<PriceCustomerSelect value={typeof value === 'string' ? value : ''} onChange={setValue} />
|
|
149
|
+
),
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: 'customerGroupId',
|
|
153
|
+
label: t('catalog.prices.form.field.customerGroupId', 'Customer group ID'),
|
|
154
|
+
type: 'text',
|
|
155
|
+
description: t(
|
|
156
|
+
'catalog.prices.form.field.customerGroupIdHint',
|
|
157
|
+
'Raw customer-group identifier — no customer-group directory yet.',
|
|
158
|
+
),
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
id: 'channelId',
|
|
162
|
+
label: t('catalog.prices.form.field.channel', 'Channel'),
|
|
163
|
+
type: 'custom',
|
|
164
|
+
component: ({ value, setValue }) => (
|
|
165
|
+
<PriceChannelSelect value={typeof value === 'string' ? value : ''} onChange={setValue} />
|
|
166
|
+
),
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
id: 'offerId',
|
|
170
|
+
label: t('catalog.prices.form.field.offer', 'Offer'),
|
|
171
|
+
type: 'text',
|
|
172
|
+
layout: 'half',
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
id: 'userId',
|
|
176
|
+
label: t('catalog.prices.form.field.userId', 'User ID'),
|
|
177
|
+
type: 'text',
|
|
178
|
+
layout: 'half',
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
id: 'userGroupId',
|
|
182
|
+
label: t('catalog.prices.form.field.userGroupId', 'User group ID'),
|
|
183
|
+
type: 'text',
|
|
184
|
+
layout: 'half',
|
|
185
|
+
},
|
|
186
|
+
], [t])
|
|
187
|
+
|
|
188
|
+
const groups = React.useMemo<CrudFormGroup[]>(() => [
|
|
189
|
+
{
|
|
190
|
+
id: 'product',
|
|
191
|
+
title: t('catalog.prices.form.group.product', 'Product'),
|
|
192
|
+
column: 1,
|
|
193
|
+
fields: ['productId', 'variantId'],
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
id: 'pricing',
|
|
197
|
+
title: t('catalog.prices.form.group.pricing', 'Pricing'),
|
|
198
|
+
column: 1,
|
|
199
|
+
fields: ['priceKindId', 'currencyCode', 'unitPriceNet', 'unitPriceGross', 'taxRate'],
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
id: 'quantity',
|
|
203
|
+
title: t('catalog.prices.form.group.quantity', 'Quantity tier'),
|
|
204
|
+
column: 1,
|
|
205
|
+
fields: ['minQuantity', 'maxQuantity'],
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
id: 'validity',
|
|
209
|
+
title: t('catalog.prices.form.group.validity', 'Validity window'),
|
|
210
|
+
column: 1,
|
|
211
|
+
fields: ['startsAt', 'endsAt'],
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
id: 'scope',
|
|
215
|
+
title: t('catalog.prices.form.group.scope', 'Scope'),
|
|
216
|
+
column: 2,
|
|
217
|
+
fields: ['customerId', 'customerGroupId', 'channelId'],
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
id: 'advanced',
|
|
221
|
+
title: t('catalog.prices.form.group.advanced', 'Advanced scope'),
|
|
222
|
+
column: 2,
|
|
223
|
+
fields: ['offerId', 'userId', 'userGroupId'],
|
|
224
|
+
},
|
|
225
|
+
], [t])
|
|
226
|
+
|
|
227
|
+
return { fields, groups }
|
|
228
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
|
|
5
|
+
|
|
6
|
+
type LabelMap = Record<string, string>
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolves display names for a page's worth of foreign ids in one batched
|
|
10
|
+
* request via the CRUD factory's generic `?ids=` narrowing (see
|
|
11
|
+
* `packages/core/AGENTS.md` § API Interceptors) instead of one lookup per row.
|
|
12
|
+
*/
|
|
13
|
+
export function useBatchLabels(
|
|
14
|
+
path: string,
|
|
15
|
+
ids: string[],
|
|
16
|
+
mapItem: (item: Record<string, unknown>) => { id: string; label: string } | null,
|
|
17
|
+
): LabelMap {
|
|
18
|
+
const [labels, setLabels] = React.useState<LabelMap>({})
|
|
19
|
+
const key = React.useMemo(
|
|
20
|
+
() =>
|
|
21
|
+
Array.from(new Set(ids.filter((id): id is string => typeof id === 'string' && id.length > 0)))
|
|
22
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
|
23
|
+
.join(','),
|
|
24
|
+
[ids],
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
React.useEffect(() => {
|
|
28
|
+
if (!key) return
|
|
29
|
+
let cancelled = false
|
|
30
|
+
async function load() {
|
|
31
|
+
try {
|
|
32
|
+
const idCount = key.split(',').length
|
|
33
|
+
const payload = await readApiResultOrThrow<{ items?: Array<Record<string, unknown>> }>(
|
|
34
|
+
`${path}?ids=${encodeURIComponent(key)}&pageSize=${Math.min(100, idCount)}`,
|
|
35
|
+
undefined,
|
|
36
|
+
{ fallback: { items: [] } },
|
|
37
|
+
)
|
|
38
|
+
if (cancelled) return
|
|
39
|
+
const items = Array.isArray(payload.items) ? payload.items : []
|
|
40
|
+
const next: LabelMap = {}
|
|
41
|
+
for (const item of items) {
|
|
42
|
+
const mapped = mapItem(item)
|
|
43
|
+
if (mapped) next[mapped.id] = mapped.label
|
|
44
|
+
}
|
|
45
|
+
setLabels((prev) => ({ ...prev, ...next }))
|
|
46
|
+
} catch {
|
|
47
|
+
// Best-effort: callers fall back to the raw id when a label is missing.
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
void load()
|
|
51
|
+
return () => {
|
|
52
|
+
cancelled = true
|
|
53
|
+
}
|
|
54
|
+
}, [key, path, mapItem])
|
|
55
|
+
|
|
56
|
+
return labels
|
|
57
|
+
}
|