@open-mercato/core 0.8.1-develop.7221.1.ece7ca874e → 0.8.1-develop.7227.1.9248c14599

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.
Files changed (102) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.js +166 -0
  3. package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.js.map +7 -0
  4. package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.js +16 -0
  5. package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.js.map +7 -0
  6. package/dist/modules/catalog/backend/catalog/prices/create/page.js +68 -0
  7. package/dist/modules/catalog/backend/catalog/prices/create/page.js.map +7 -0
  8. package/dist/modules/catalog/backend/catalog/prices/create/page.meta.js +25 -0
  9. package/dist/modules/catalog/backend/catalog/prices/create/page.meta.js.map +7 -0
  10. package/dist/modules/catalog/backend/catalog/prices/page.js +11 -0
  11. package/dist/modules/catalog/backend/catalog/prices/page.js.map +7 -0
  12. package/dist/modules/catalog/backend/catalog/prices/page.meta.js +16 -0
  13. package/dist/modules/catalog/backend/catalog/prices/page.meta.js.map +7 -0
  14. package/dist/modules/catalog/components/prices/PriceScopeSelectors.js +201 -0
  15. package/dist/modules/catalog/components/prices/PriceScopeSelectors.js.map +7 -0
  16. package/dist/modules/catalog/components/prices/PricesDataTable.js +273 -0
  17. package/dist/modules/catalog/components/prices/PricesDataTable.js.map +7 -0
  18. package/dist/modules/catalog/components/prices/normalizePriceRecord.js +43 -0
  19. package/dist/modules/catalog/components/prices/normalizePriceRecord.js.map +7 -0
  20. package/dist/modules/catalog/components/prices/priceFormFields.js +195 -0
  21. package/dist/modules/catalog/components/prices/priceFormFields.js.map +7 -0
  22. package/dist/modules/catalog/components/prices/useBatchLabels.js +42 -0
  23. package/dist/modules/catalog/components/prices/useBatchLabels.js.map +7 -0
  24. package/dist/modules/catalog/data/validators.js +21 -2
  25. package/dist/modules/catalog/data/validators.js.map +2 -2
  26. package/dist/modules/catalog/extension-points.js +2 -1
  27. package/dist/modules/catalog/extension-points.js.map +2 -2
  28. package/dist/modules/catalog/lib/pricing.js +37 -8
  29. package/dist/modules/catalog/lib/pricing.js.map +2 -2
  30. package/dist/modules/communication_channels/api/post/channels/[id]/import-history/route.js +5 -1
  31. package/dist/modules/communication_channels/api/post/channels/[id]/import-history/route.js.map +2 -2
  32. package/dist/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.js +88 -0
  33. package/dist/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.js.map +7 -0
  34. package/dist/modules/communication_channels/backend/profile/communication-channels/page.js +17 -19
  35. package/dist/modules/communication_channels/backend/profile/communication-channels/page.js.map +2 -2
  36. package/dist/modules/communication_channels/commands/connect-credential-channel.js +7 -2
  37. package/dist/modules/communication_channels/commands/connect-credential-channel.js.map +2 -2
  38. package/dist/modules/communication_channels/commands/ingest-inbound-message.js +11 -2
  39. package/dist/modules/communication_channels/commands/ingest-inbound-message.js.map +2 -2
  40. package/dist/modules/communication_channels/commands/queue-import-history.js +40 -7
  41. package/dist/modules/communication_channels/commands/queue-import-history.js.map +2 -2
  42. package/dist/modules/communication_channels/extension-points.js +1 -1
  43. package/dist/modules/communication_channels/extension-points.js.map +1 -1
  44. package/dist/modules/communication_channels/lib/connect-channel.js +33 -0
  45. package/dist/modules/communication_channels/lib/connect-channel.js.map +2 -2
  46. package/dist/modules/communication_channels/lib/import-history-limits.js +69 -0
  47. package/dist/modules/communication_channels/lib/import-history-limits.js.map +7 -0
  48. package/dist/modules/communication_channels/workers/channel-import-history.js +36 -2
  49. package/dist/modules/communication_channels/workers/channel-import-history.js.map +2 -2
  50. package/dist/modules/messages/api/route.js +5 -1
  51. package/dist/modules/messages/api/route.js.map +2 -2
  52. package/dist/modules/messages/commands/messages.js +3 -2
  53. package/dist/modules/messages/commands/messages.js.map +2 -2
  54. package/dist/modules/messages/data/validators.js +12 -2
  55. package/dist/modules/messages/data/validators.js.map +2 -2
  56. package/dist/modules/messages/subscribers/message-notification.js +4 -1
  57. package/dist/modules/messages/subscribers/message-notification.js.map +2 -2
  58. package/dist/modules/query_index/lib/engine.js +19 -1
  59. package/dist/modules/query_index/lib/engine.js.map +2 -2
  60. package/package.json +7 -7
  61. package/src/modules/catalog/AGENTS.md +6 -2
  62. package/src/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.ts +12 -0
  63. package/src/modules/catalog/backend/catalog/prices/[id]/edit/page.tsx +198 -0
  64. package/src/modules/catalog/backend/catalog/prices/create/page.meta.ts +23 -0
  65. package/src/modules/catalog/backend/catalog/prices/create/page.tsx +70 -0
  66. package/src/modules/catalog/backend/catalog/prices/page.meta.ts +12 -0
  67. package/src/modules/catalog/backend/catalog/prices/page.tsx +14 -0
  68. package/src/modules/catalog/components/prices/PriceScopeSelectors.tsx +243 -0
  69. package/src/modules/catalog/components/prices/PricesDataTable.tsx +320 -0
  70. package/src/modules/catalog/components/prices/normalizePriceRecord.ts +71 -0
  71. package/src/modules/catalog/components/prices/priceFormFields.tsx +228 -0
  72. package/src/modules/catalog/components/prices/useBatchLabels.ts +57 -0
  73. package/src/modules/catalog/data/validators.ts +43 -2
  74. package/src/modules/catalog/extension-points.ts +1 -0
  75. package/src/modules/catalog/i18n/de.json +72 -0
  76. package/src/modules/catalog/i18n/en.json +72 -0
  77. package/src/modules/catalog/i18n/es.json +72 -0
  78. package/src/modules/catalog/i18n/ko.json +72 -0
  79. package/src/modules/catalog/i18n/pl.json +72 -0
  80. package/src/modules/catalog/lib/pricing.ts +103 -9
  81. package/src/modules/communication_channels/api/post/channels/[id]/import-history/route.ts +6 -1
  82. package/src/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.tsx +120 -0
  83. package/src/modules/communication_channels/backend/profile/communication-channels/page.tsx +20 -17
  84. package/src/modules/communication_channels/commands/connect-credential-channel.ts +9 -0
  85. package/src/modules/communication_channels/commands/ingest-inbound-message.ts +17 -2
  86. package/src/modules/communication_channels/commands/queue-import-history.ts +59 -11
  87. package/src/modules/communication_channels/extension-points.ts +1 -1
  88. package/src/modules/communication_channels/i18n/de.json +4 -3
  89. package/src/modules/communication_channels/i18n/en.json +4 -3
  90. package/src/modules/communication_channels/i18n/es.json +4 -3
  91. package/src/modules/communication_channels/i18n/ko.json +4 -3
  92. package/src/modules/communication_channels/i18n/pl.json +4 -3
  93. package/src/modules/communication_channels/lib/adapter.ts +16 -2
  94. package/src/modules/communication_channels/lib/connect-channel.ts +49 -0
  95. package/src/modules/communication_channels/lib/import-history-limits.ts +109 -0
  96. package/src/modules/communication_channels/workers/channel-import-history.ts +46 -5
  97. package/src/modules/messages/api/route.ts +8 -2
  98. package/src/modules/messages/commands/messages.ts +17 -2
  99. package/src/modules/messages/data/validators.ts +26 -11
  100. package/src/modules/messages/subscribers/message-notification.ts +17 -5
  101. package/src/modules/query_index/lib/engine.ts +23 -1
  102. 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
+ }