@open-mercato/core 0.8.1-develop.7228.1.b7ea09d2d3 → 0.8.1-develop.7238.1.3abe669ea5

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 (173) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/generated/entities/availability_policy/index.js +41 -0
  3. package/dist/generated/entities/availability_policy/index.js.map +7 -0
  4. package/dist/generated/entities.ids.generated.js +4 -0
  5. package/dist/generated/entities.ids.generated.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +21 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/modules/attachments/data/entities.js +3 -3
  9. package/dist/modules/attachments/data/entities.js.map +2 -2
  10. package/dist/modules/availability/acl.js +16 -0
  11. package/dist/modules/availability/acl.js.map +7 -0
  12. package/dist/modules/availability/api/check/route.js +95 -0
  13. package/dist/modules/availability/api/check/route.js.map +7 -0
  14. package/dist/modules/availability/api/openapi.js +27 -0
  15. package/dist/modules/availability/api/openapi.js.map +7 -0
  16. package/dist/modules/availability/api/policies/resolve-preview/route.js +58 -0
  17. package/dist/modules/availability/api/policies/resolve-preview/route.js.map +7 -0
  18. package/dist/modules/availability/api/policies/route.js +188 -0
  19. package/dist/modules/availability/api/policies/route.js.map +7 -0
  20. package/dist/modules/availability/backend/availability/check/page.js +185 -0
  21. package/dist/modules/availability/backend/availability/check/page.js.map +7 -0
  22. package/dist/modules/availability/backend/availability/check/page.meta.js +16 -0
  23. package/dist/modules/availability/backend/availability/check/page.meta.js.map +7 -0
  24. package/dist/modules/availability/backend/availability/policies/ResolutionPreviewPanel.js +79 -0
  25. package/dist/modules/availability/backend/availability/policies/ResolutionPreviewPanel.js.map +7 -0
  26. package/dist/modules/availability/backend/availability/policies/ScopeFields.js +53 -0
  27. package/dist/modules/availability/backend/availability/policies/ScopeFields.js.map +7 -0
  28. package/dist/modules/availability/backend/availability/policies/[id]/page.js +152 -0
  29. package/dist/modules/availability/backend/availability/policies/[id]/page.js.map +7 -0
  30. package/dist/modules/availability/backend/availability/policies/[id]/page.meta.js +12 -0
  31. package/dist/modules/availability/backend/availability/policies/[id]/page.meta.js.map +7 -0
  32. package/dist/modules/availability/backend/availability/policies/create/page.js +94 -0
  33. package/dist/modules/availability/backend/availability/policies/create/page.js.map +7 -0
  34. package/dist/modules/availability/backend/availability/policies/create/page.meta.js +12 -0
  35. package/dist/modules/availability/backend/availability/policies/create/page.meta.js.map +7 -0
  36. package/dist/modules/availability/backend/availability/policies/formGroups.js +75 -0
  37. package/dist/modules/availability/backend/availability/policies/formGroups.js.map +7 -0
  38. package/dist/modules/availability/backend/availability/policies/page.js +176 -0
  39. package/dist/modules/availability/backend/availability/policies/page.js.map +7 -0
  40. package/dist/modules/availability/backend/availability/policies/page.meta.js +16 -0
  41. package/dist/modules/availability/backend/availability/policies/page.meta.js.map +7 -0
  42. package/dist/modules/availability/commands/policies.js +312 -0
  43. package/dist/modules/availability/commands/policies.js.map +7 -0
  44. package/dist/modules/availability/commands/scope.js +19 -0
  45. package/dist/modules/availability/commands/scope.js.map +7 -0
  46. package/dist/modules/availability/data/entities.js +95 -0
  47. package/dist/modules/availability/data/entities.js.map +7 -0
  48. package/dist/modules/availability/data/validators.js +53 -0
  49. package/dist/modules/availability/data/validators.js.map +7 -0
  50. package/dist/modules/availability/di.js +41 -0
  51. package/dist/modules/availability/di.js.map +7 -0
  52. package/dist/modules/availability/events.js +18 -0
  53. package/dist/modules/availability/events.js.map +7 -0
  54. package/dist/modules/availability/index.js +15 -0
  55. package/dist/modules/availability/index.js.map +7 -0
  56. package/dist/modules/availability/lib/policyResolution.js +155 -0
  57. package/dist/modules/availability/lib/policyResolution.js.map +7 -0
  58. package/dist/modules/availability/lib/tryResolve.js +11 -0
  59. package/dist/modules/availability/lib/tryResolve.js.map +7 -0
  60. package/dist/modules/availability/migrations/Migration20260922095844_availability.js +16 -0
  61. package/dist/modules/availability/migrations/Migration20260922095844_availability.js.map +7 -0
  62. package/dist/modules/availability/setup.js +12 -0
  63. package/dist/modules/availability/setup.js.map +7 -0
  64. package/dist/modules/entities/cli.js +33 -12
  65. package/dist/modules/entities/cli.js.map +2 -2
  66. package/dist/modules/inbox_ops/api/settings/route.js +7 -32
  67. package/dist/modules/inbox_ops/api/settings/route.js.map +2 -2
  68. package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.js +2 -2
  69. package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.js.map +2 -2
  70. package/dist/modules/inbox_ops/components/proposals/ActionCard.js +1 -1
  71. package/dist/modules/inbox_ops/components/proposals/ActionCard.js.map +2 -2
  72. package/dist/modules/inbox_ops/lib/ensure-settings.js +47 -0
  73. package/dist/modules/inbox_ops/lib/ensure-settings.js.map +7 -0
  74. package/dist/modules/inbox_ops/lib/executionEngine.js.map +1 -1
  75. package/dist/modules/inbox_ops/lib/executionHelpers.js +0 -23
  76. package/dist/modules/inbox_ops/lib/executionHelpers.js.map +2 -2
  77. package/dist/modules/inbox_ops/lib/payloadEnrichment.js +0 -3
  78. package/dist/modules/inbox_ops/lib/payloadEnrichment.js.map +2 -2
  79. package/dist/modules/inbox_ops/setup.js +2 -21
  80. package/dist/modules/inbox_ops/setup.js.map +2 -2
  81. package/dist/modules/inbox_ops/subscribers/extractionWorker.js.map +1 -1
  82. package/dist/modules/sales/components/documents/SalesDocumentForm.js +7 -0
  83. package/dist/modules/sales/components/documents/SalesDocumentForm.js.map +2 -2
  84. package/dist/modules/sales/data/entities.js +3 -1
  85. package/dist/modules/sales/data/entities.js.map +2 -2
  86. package/dist/modules/sales/inbox-actions.js +0 -12
  87. package/dist/modules/sales/inbox-actions.js.map +2 -2
  88. package/dist/modules/sales/migrations/Migration20260916120000_sales_payment_allocation_order_idx.js +20 -0
  89. package/dist/modules/sales/migrations/Migration20260916120000_sales_payment_allocation_order_idx.js.map +7 -0
  90. package/dist/modules/wms/di.js +3 -0
  91. package/dist/modules/wms/di.js.map +2 -2
  92. package/dist/modules/wms/lib/availabilityCache.js +85 -0
  93. package/dist/modules/wms/lib/availabilityCache.js.map +7 -0
  94. package/dist/modules/wms/lib/availabilityCalculation.js +178 -0
  95. package/dist/modules/wms/lib/availabilityCalculation.js.map +7 -0
  96. package/dist/modules/wms/lib/availabilityProvider.js +14 -0
  97. package/dist/modules/wms/lib/availabilityProvider.js.map +7 -0
  98. package/dist/modules/wms/lib/sqlInClause.js +9 -0
  99. package/dist/modules/wms/lib/sqlInClause.js.map +7 -0
  100. package/dist/modules/wms/lib/tryResolve.js +11 -0
  101. package/dist/modules/wms/lib/tryResolve.js.map +7 -0
  102. package/dist/modules/wms/subscribers/invalidate-availability-cache.js +16 -0
  103. package/dist/modules/wms/subscribers/invalidate-availability-cache.js.map +7 -0
  104. package/generated/entities/availability_policy/index.ts +19 -0
  105. package/generated/entities.ids.generated.ts +4 -0
  106. package/generated/entity-fields-registry.ts +21 -0
  107. package/package.json +7 -7
  108. package/src/modules/attachments/data/entities.ts +5 -3
  109. package/src/modules/auth/i18n/de.json +3 -0
  110. package/src/modules/auth/i18n/en.json +3 -0
  111. package/src/modules/auth/i18n/es.json +3 -0
  112. package/src/modules/auth/i18n/ko.json +3 -0
  113. package/src/modules/auth/i18n/pl.json +3 -0
  114. package/src/modules/availability/acl.ts +12 -0
  115. package/src/modules/availability/api/check/route.ts +114 -0
  116. package/src/modules/availability/api/openapi.ts +28 -0
  117. package/src/modules/availability/api/policies/resolve-preview/route.ts +67 -0
  118. package/src/modules/availability/api/policies/route.ts +224 -0
  119. package/src/modules/availability/backend/availability/check/page.meta.ts +12 -0
  120. package/src/modules/availability/backend/availability/check/page.tsx +223 -0
  121. package/src/modules/availability/backend/availability/policies/ResolutionPreviewPanel.tsx +106 -0
  122. package/src/modules/availability/backend/availability/policies/ScopeFields.tsx +55 -0
  123. package/src/modules/availability/backend/availability/policies/[id]/page.meta.ts +8 -0
  124. package/src/modules/availability/backend/availability/policies/[id]/page.tsx +182 -0
  125. package/src/modules/availability/backend/availability/policies/create/page.meta.ts +8 -0
  126. package/src/modules/availability/backend/availability/policies/create/page.tsx +105 -0
  127. package/src/modules/availability/backend/availability/policies/formGroups.ts +77 -0
  128. package/src/modules/availability/backend/availability/policies/page.meta.ts +12 -0
  129. package/src/modules/availability/backend/availability/policies/page.tsx +196 -0
  130. package/src/modules/availability/commands/policies.ts +358 -0
  131. package/src/modules/availability/commands/scope.ts +26 -0
  132. package/src/modules/availability/data/entities.ts +92 -0
  133. package/src/modules/availability/data/validators.ts +70 -0
  134. package/src/modules/availability/di.ts +54 -0
  135. package/src/modules/availability/events.ts +17 -0
  136. package/src/modules/availability/i18n/de.json +81 -0
  137. package/src/modules/availability/i18n/en.json +81 -0
  138. package/src/modules/availability/i18n/es.json +81 -0
  139. package/src/modules/availability/i18n/ko.json +81 -0
  140. package/src/modules/availability/i18n/pl.json +81 -0
  141. package/src/modules/availability/index.ts +13 -0
  142. package/src/modules/availability/lib/policyResolution.ts +264 -0
  143. package/src/modules/availability/lib/tryResolve.ts +8 -0
  144. package/src/modules/availability/migrations/.snapshot-open-mercato.json +375 -0
  145. package/src/modules/availability/migrations/Migration20260922095844_availability.ts +13 -0
  146. package/src/modules/availability/setup.ts +10 -0
  147. package/src/modules/entities/cli.ts +53 -13
  148. package/src/modules/inbox_ops/api/settings/route.ts +9 -32
  149. package/src/modules/inbox_ops/backend/inbox-ops/settings/page.tsx +2 -2
  150. package/src/modules/inbox_ops/components/proposals/ActionCard.tsx +1 -1
  151. package/src/modules/inbox_ops/i18n/de.json +1 -1
  152. package/src/modules/inbox_ops/i18n/en.json +1 -1
  153. package/src/modules/inbox_ops/i18n/es.json +1 -1
  154. package/src/modules/inbox_ops/i18n/ko.json +1 -1
  155. package/src/modules/inbox_ops/i18n/pl.json +1 -1
  156. package/src/modules/inbox_ops/lib/ensure-settings.ts +77 -0
  157. package/src/modules/inbox_ops/lib/executionEngine.ts +1 -1
  158. package/src/modules/inbox_ops/lib/executionHelpers.ts +0 -27
  159. package/src/modules/inbox_ops/lib/payloadEnrichment.ts +1 -5
  160. package/src/modules/inbox_ops/setup.ts +2 -21
  161. package/src/modules/inbox_ops/subscribers/extractionWorker.ts +1 -1
  162. package/src/modules/sales/components/documents/SalesDocumentForm.tsx +13 -0
  163. package/src/modules/sales/data/entities.ts +2 -0
  164. package/src/modules/sales/inbox-actions.ts +0 -20
  165. package/src/modules/sales/migrations/.snapshot-open-mercato.json +20 -0
  166. package/src/modules/sales/migrations/Migration20260916120000_sales_payment_allocation_order_idx.ts +74 -0
  167. package/src/modules/wms/di.ts +6 -0
  168. package/src/modules/wms/lib/availabilityCache.ts +136 -0
  169. package/src/modules/wms/lib/availabilityCalculation.ts +289 -0
  170. package/src/modules/wms/lib/availabilityProvider.ts +29 -0
  171. package/src/modules/wms/lib/sqlInClause.ts +21 -0
  172. package/src/modules/wms/lib/tryResolve.ts +8 -0
  173. package/src/modules/wms/subscribers/invalidate-availability-cache.ts +30 -0
@@ -0,0 +1,12 @@
1
+ export const metadata = {
2
+ requireAuth: true,
3
+ requireFeatures: ['availability.check'],
4
+ pageTitle: 'Availability Check',
5
+ pageTitleKey: 'availability.check.nav.title',
6
+ pageGroup: 'Availability',
7
+ pageGroupKey: 'availability.nav.group',
8
+ pageOrder: 20,
9
+ pageContext: 'settings' as const,
10
+ icon: 'search',
11
+ breadcrumb: [{ label: 'Availability Check', labelKey: 'availability.check.nav.title' }],
12
+ }
@@ -0,0 +1,223 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
4
+ import { Input } from '@open-mercato/ui/primitives/input'
5
+ import { Label } from '@open-mercato/ui/primitives/label'
6
+ import { Button } from '@open-mercato/ui/primitives/button'
7
+ import { StatusBadge, type StatusBadgeVariant } from '@open-mercato/ui/primitives/status-badge'
8
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
9
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
10
+
11
+ type AvailabilityState = 'in_stock' | 'low_stock' | 'out_of_stock' | 'backorder' | 'preorder' | 'not_tracked'
12
+
13
+ type AvailabilityItemResult = {
14
+ state: AvailabilityState
15
+ availableQuantity: number | null
16
+ canFulfil: boolean
17
+ leadTimeDays: number | null
18
+ releaseAt: string | null
19
+ isAuthoritative: boolean
20
+ policySourceId: string | null
21
+ }
22
+
23
+ type FieldTrace = { value: unknown; policySourceId: string | null }
24
+ type PolicyTrace = Record<string, FieldTrace>
25
+
26
+ type CheckResponse = {
27
+ availability?: AvailabilityItemResult | null
28
+ policyTrace?: PolicyTrace | null
29
+ error?: string
30
+ }
31
+
32
+ const STATE_VARIANTS: Record<AvailabilityState, StatusBadgeVariant> = {
33
+ in_stock: 'success',
34
+ low_stock: 'warning',
35
+ out_of_stock: 'error',
36
+ backorder: 'info',
37
+ preorder: 'info',
38
+ not_tracked: 'neutral',
39
+ }
40
+
41
+ const TRACE_FIELDS = [
42
+ 'isStockManaged',
43
+ 'allowBackorder',
44
+ 'backorderLeadTimeDays',
45
+ 'preorderReleaseAt',
46
+ 'lowStockThreshold',
47
+ 'minOrderQuantity',
48
+ 'maxOrderQuantity',
49
+ 'quantityIncrement',
50
+ 'hideWhenOutOfStock',
51
+ 'isActive',
52
+ ]
53
+
54
+ function formatTraceValue(value: unknown, t: (key: string) => string): string {
55
+ if (value === null || value === undefined) return t('availability.policies.form.preview.notSet')
56
+ if (typeof value === 'boolean') return value ? t('common.yes') : t('common.no')
57
+ return String(value)
58
+ }
59
+
60
+ export default function AvailabilityCheckPage() {
61
+ const t = useT()
62
+ const [productId, setProductId] = React.useState('')
63
+ const [variantId, setVariantId] = React.useState('')
64
+ const [storeId, setStoreId] = React.useState('')
65
+ const [quantity, setQuantity] = React.useState('1')
66
+ const [isRunning, setIsRunning] = React.useState(false)
67
+ const [error, setError] = React.useState<string | null>(null)
68
+ const [result, setResult] = React.useState<CheckResponse | null>(null)
69
+
70
+ const runCheck = React.useCallback(async () => {
71
+ if (!productId.trim()) {
72
+ setError(t('availability.check.errors.productRequired'))
73
+ return
74
+ }
75
+ setIsRunning(true)
76
+ setError(null)
77
+ setResult(null)
78
+ try {
79
+ const call = await apiCall<CheckResponse>(
80
+ '/api/availability/check',
81
+ {
82
+ method: 'POST',
83
+ body: JSON.stringify({
84
+ productId: productId.trim(),
85
+ variantId: variantId.trim() || null,
86
+ storeId: storeId.trim() || null,
87
+ quantity: Number(quantity) > 0 ? Number(quantity) : 1,
88
+ }),
89
+ },
90
+ { fallback: null },
91
+ )
92
+ if (!call.ok) {
93
+ const errorPayload = call.result as { error?: string } | undefined
94
+ setError(typeof errorPayload?.error === 'string' ? errorPayload.error : t('availability.check.errors.failed'))
95
+ return
96
+ }
97
+ setResult(call.result ?? null)
98
+ } catch (e) {
99
+ setError(e instanceof Error ? e.message : t('availability.check.errors.failed'))
100
+ } finally {
101
+ setIsRunning(false)
102
+ }
103
+ }, [productId, variantId, storeId, quantity, t])
104
+
105
+ const availability = result?.availability ?? null
106
+ const policyTrace = result?.policyTrace ?? null
107
+
108
+ return (
109
+ <Page>
110
+ <PageBody>
111
+ <div
112
+ className="space-y-6"
113
+ onKeyDown={(event) => {
114
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
115
+ event.preventDefault()
116
+ void runCheck()
117
+ }
118
+ }}
119
+ >
120
+ <h1 className="text-xl font-semibold">{t('availability.check.title')}</h1>
121
+ <div className="grid grid-cols-1 gap-4 rounded-md border p-4 sm:grid-cols-4">
122
+ <div className="space-y-1.5">
123
+ <Label htmlFor="availability-check-product-id">{t('availability.policies.form.field.productId')}</Label>
124
+ <Input
125
+ id="availability-check-product-id"
126
+ value={productId}
127
+ onChange={(e) => setProductId(e.target.value)}
128
+ autoFocus
129
+ />
130
+ </div>
131
+ <div className="space-y-1.5">
132
+ <Label htmlFor="availability-check-variant-id">{t('availability.policies.form.field.variantId')}</Label>
133
+ <Input id="availability-check-variant-id" value={variantId} onChange={(e) => setVariantId(e.target.value)} />
134
+ </div>
135
+ <div className="space-y-1.5">
136
+ <Label htmlFor="availability-check-store-id">{t('availability.policies.form.field.storeId')}</Label>
137
+ <Input id="availability-check-store-id" value={storeId} onChange={(e) => setStoreId(e.target.value)} />
138
+ </div>
139
+ <div className="space-y-1.5">
140
+ <Label htmlFor="availability-check-quantity">{t('availability.check.field.quantity')}</Label>
141
+ <Input
142
+ id="availability-check-quantity"
143
+ type="number"
144
+ min={1}
145
+ value={quantity}
146
+ onChange={(e) => setQuantity(e.target.value)}
147
+ />
148
+ </div>
149
+ </div>
150
+ <Button onClick={() => void runCheck()} disabled={isRunning}>
151
+ {isRunning ? t('availability.check.action.running') : t('availability.check.action.run')}
152
+ </Button>
153
+
154
+ {error && (
155
+ <div className="rounded-md border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive" data-testid="availability-check-error">
156
+ {error}
157
+ </div>
158
+ )}
159
+
160
+ {availability && (
161
+ <div className="space-y-4 rounded-md border p-4" data-testid="availability-check-result">
162
+ <div className="flex items-center gap-3">
163
+ <StatusBadge variant={STATE_VARIANTS[availability.state]}>{t(`availability.states.${availability.state}`)}</StatusBadge>
164
+ {!availability.isAuthoritative && (
165
+ <span className="text-xs text-muted-foreground">{t('availability.check.advisoryNote')}</span>
166
+ )}
167
+ </div>
168
+ <dl className="grid grid-cols-1 gap-2 sm:grid-cols-3">
169
+ <div>
170
+ <dt className="text-xs text-muted-foreground">{t('availability.check.result.availableQuantity')}</dt>
171
+ <dd className="text-sm">{availability.availableQuantity ?? t('availability.policies.form.preview.notSet')}</dd>
172
+ </div>
173
+ <div>
174
+ <dt className="text-xs text-muted-foreground">{t('availability.check.result.canFulfil')}</dt>
175
+ <dd className="text-sm">{availability.canFulfil ? t('common.yes') : t('common.no')}</dd>
176
+ </div>
177
+ <div>
178
+ <dt className="text-xs text-muted-foreground">{t('availability.check.result.policySourceId')}</dt>
179
+ <dd className="text-sm">{availability.policySourceId ?? t('availability.policies.form.preview.moduleDefault')}</dd>
180
+ </div>
181
+ {availability.leadTimeDays != null && (
182
+ <div>
183
+ <dt className="text-xs text-muted-foreground">{t('availability.policies.form.field.backorderLeadTimeDays')}</dt>
184
+ <dd className="text-sm">{availability.leadTimeDays}</dd>
185
+ </div>
186
+ )}
187
+ {availability.releaseAt && (
188
+ <div>
189
+ <dt className="text-xs text-muted-foreground">{t('availability.policies.form.field.preorderReleaseAt')}</dt>
190
+ <dd className="text-sm">{availability.releaseAt}</dd>
191
+ </div>
192
+ )}
193
+ </dl>
194
+ {availability.state === 'not_tracked' && (
195
+ <p className="text-xs text-muted-foreground">{t('availability.check.notTrackedExplanation')}</p>
196
+ )}
197
+ </div>
198
+ )}
199
+
200
+ {policyTrace && (
201
+ <div className="rounded-md border bg-muted p-4" data-testid="availability-check-policy-trace">
202
+ <div className="mb-2 text-sm font-medium">{t('availability.policies.form.preview.title')}</div>
203
+ <dl className="grid grid-cols-1 gap-2 sm:grid-cols-2">
204
+ {TRACE_FIELDS.map((key) => {
205
+ const field = policyTrace[key]
206
+ const source = field?.policySourceId ?? t('availability.policies.form.preview.moduleDefault')
207
+ return (
208
+ <div key={key}>
209
+ <dt className="text-xs text-muted-foreground">{t(`availability.policies.form.field.${key}`)}</dt>
210
+ <dd className="text-xs">
211
+ {formatTraceValue(field?.value, t)} <span className="text-muted-foreground">({source})</span>
212
+ </dd>
213
+ </div>
214
+ )
215
+ })}
216
+ </dl>
217
+ </div>
218
+ )}
219
+ </div>
220
+ </PageBody>
221
+ </Page>
222
+ )
223
+ }
@@ -0,0 +1,106 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
4
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
5
+
6
+ type FieldTrace = { value: unknown; policySourceId: string | null }
7
+ type PolicyTrace = Record<string, FieldTrace>
8
+
9
+ type ResponsePayload = { policyTrace?: PolicyTrace }
10
+
11
+ const TRACE_FIELDS: Array<{ key: string; labelKey: string }> = [
12
+ { key: 'isStockManaged', labelKey: 'availability.policies.form.field.isStockManaged' },
13
+ { key: 'allowBackorder', labelKey: 'availability.policies.form.field.allowBackorder' },
14
+ { key: 'backorderLeadTimeDays', labelKey: 'availability.policies.form.field.backorderLeadTimeDays' },
15
+ { key: 'preorderReleaseAt', labelKey: 'availability.policies.form.field.preorderReleaseAt' },
16
+ { key: 'lowStockThreshold', labelKey: 'availability.policies.form.field.lowStockThreshold' },
17
+ { key: 'minOrderQuantity', labelKey: 'availability.policies.form.field.minOrderQuantity' },
18
+ { key: 'maxOrderQuantity', labelKey: 'availability.policies.form.field.maxOrderQuantity' },
19
+ { key: 'quantityIncrement', labelKey: 'availability.policies.form.field.quantityIncrement' },
20
+ { key: 'hideWhenOutOfStock', labelKey: 'availability.policies.form.field.hideWhenOutOfStock' },
21
+ { key: 'isActive', labelKey: 'availability.policies.form.field.isActive' },
22
+ ]
23
+
24
+ function formatValue(value: unknown, t: (key: string) => string): string {
25
+ if (value === null || value === undefined) return t('availability.policies.form.preview.notSet')
26
+ if (typeof value === 'boolean') return value ? t('common.yes') : t('common.no')
27
+ return String(value)
28
+ }
29
+
30
+ /**
31
+ * Live "resolution-chain preview" — US-A2. Shows the currently PERSISTED
32
+ * resolution for the (product, variant, store) target the admin is editing,
33
+ * refetched (debounced) whenever the target changes. Does not simulate the
34
+ * in-progress, unsaved field edits — see PLAN.md § Key design decisions.
35
+ */
36
+ export function ResolutionPreviewPanel({
37
+ productId,
38
+ variantId,
39
+ storeId,
40
+ }: {
41
+ productId: string
42
+ variantId: string | null
43
+ storeId: string | null
44
+ }) {
45
+ const t = useT()
46
+ const [trace, setTrace] = React.useState<PolicyTrace | null>(null)
47
+ const [isLoading, setIsLoading] = React.useState(false)
48
+
49
+ React.useEffect(() => {
50
+ if (!productId) {
51
+ setTrace(null)
52
+ return
53
+ }
54
+ let cancelled = false
55
+ const handle = setTimeout(() => {
56
+ setIsLoading(true)
57
+ const params = new URLSearchParams({ productId })
58
+ if (variantId) params.set('variantId', variantId)
59
+ if (storeId) params.set('storeId', storeId)
60
+ apiCall<ResponsePayload>(`/api/availability/policies/resolve-preview?${params.toString()}`, undefined, { fallback: null })
61
+ .then((call) => {
62
+ if (!cancelled && call.ok) setTrace(call.result?.policyTrace ?? null)
63
+ })
64
+ .finally(() => {
65
+ if (!cancelled) setIsLoading(false)
66
+ })
67
+ }, 300)
68
+ return () => {
69
+ cancelled = true
70
+ clearTimeout(handle)
71
+ }
72
+ }, [productId, variantId, storeId])
73
+
74
+ if (!productId) {
75
+ return (
76
+ <div className="rounded-md border bg-muted p-4 text-sm text-muted-foreground" data-testid="availability-resolution-preview">
77
+ {t('availability.policies.form.preview.enterProduct')}
78
+ </div>
79
+ )
80
+ }
81
+
82
+ return (
83
+ <div className="rounded-md border bg-muted p-4 text-sm" data-testid="availability-resolution-preview">
84
+ <div className="mb-2 font-medium">{t('availability.policies.form.preview.title')}</div>
85
+ {isLoading && !trace ? (
86
+ <div className="text-muted-foreground">{t('common.loading')}</div>
87
+ ) : (
88
+ <dl className="grid grid-cols-1 gap-2 sm:grid-cols-2">
89
+ {TRACE_FIELDS.map(({ key, labelKey }) => {
90
+ const field = trace?.[key]
91
+ const source = field?.policySourceId ?? t('availability.policies.form.preview.moduleDefault')
92
+ return (
93
+ <div key={key}>
94
+ <dt className="text-xs text-muted-foreground">{t(labelKey)}</dt>
95
+ <dd className="text-xs">
96
+ {formatValue(field?.value, t)}{' '}
97
+ <span className="text-muted-foreground">({source})</span>
98
+ </dd>
99
+ </div>
100
+ )
101
+ })}
102
+ </dl>
103
+ )}
104
+ </div>
105
+ )
106
+ }
@@ -0,0 +1,55 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import { Input } from '@open-mercato/ui/primitives/input'
4
+ import { Label } from '@open-mercato/ui/primitives/label'
5
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+
7
+ export type PolicyScope = {
8
+ productId: string
9
+ variantId: string
10
+ storeId: string
11
+ }
12
+
13
+ /**
14
+ * Product/variant/store target fields, kept as controlled local state
15
+ * (outside `CrudForm`'s own field list) so a value change can drive the live
16
+ * resolution-chain preview immediately — `CrudForm` does not expose a
17
+ * live-values callback. Rendered via `CrudForm`'s `contentHeader` slot.
18
+ */
19
+ export function ScopeFields({ value, onChange, disabled }: { value: PolicyScope; onChange: (next: PolicyScope) => void; disabled?: boolean }) {
20
+ const t = useT()
21
+ return (
22
+ <div className="grid grid-cols-1 gap-4 rounded-md border p-4 sm:grid-cols-3">
23
+ <div className="space-y-1.5">
24
+ <Label htmlFor="availability-policy-product-id">{t('availability.policies.form.field.productId')}</Label>
25
+ <Input
26
+ id="availability-policy-product-id"
27
+ value={value.productId}
28
+ disabled={disabled}
29
+ onChange={(e) => onChange({ ...value, productId: e.target.value.trim() })}
30
+ placeholder={t('availability.policies.form.field.productId.placeholder')}
31
+ />
32
+ </div>
33
+ <div className="space-y-1.5">
34
+ <Label htmlFor="availability-policy-variant-id">{t('availability.policies.form.field.variantId')}</Label>
35
+ <Input
36
+ id="availability-policy-variant-id"
37
+ value={value.variantId}
38
+ disabled={disabled}
39
+ onChange={(e) => onChange({ ...value, variantId: e.target.value.trim() })}
40
+ placeholder={t('availability.policies.form.field.variantId.placeholder')}
41
+ />
42
+ </div>
43
+ <div className="space-y-1.5">
44
+ <Label htmlFor="availability-policy-store-id">{t('availability.policies.form.field.storeId')}</Label>
45
+ <Input
46
+ id="availability-policy-store-id"
47
+ value={value.storeId}
48
+ disabled={disabled}
49
+ onChange={(e) => onChange({ ...value, storeId: e.target.value.trim() })}
50
+ placeholder={t('availability.policies.form.field.storeId.placeholder')}
51
+ />
52
+ </div>
53
+ </div>
54
+ )
55
+ }
@@ -0,0 +1,8 @@
1
+ export const metadata = {
2
+ requireAuth: true,
3
+ requireFeatures: ['availability.policies.view'],
4
+ breadcrumb: [
5
+ { label: 'Availability Policies', labelKey: 'availability.policies.nav.title', href: '/backend/availability/policies' },
6
+ { label: 'Edit', labelKey: 'availability.policies.edit.title' },
7
+ ],
8
+ }
@@ -0,0 +1,182 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import { useRouter } from 'next/navigation'
4
+ import { CrudForm } from '@open-mercato/ui/backend/CrudForm'
5
+ import { updateCrud, deleteCrud } from '@open-mercato/ui/backend/utils/crud'
6
+ import { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'
7
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
8
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
9
+ import { LoadingMessage, ErrorMessage } from '@open-mercato/ui/backend/detail'
10
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
11
+ import { useBackendChrome } from '@open-mercato/ui/backend/BackendChromeProvider'
12
+ import { hasFeature } from '@open-mercato/shared/security/features'
13
+ import { ScopeFields, type PolicyScope } from '../ScopeFields'
14
+ import { ResolutionPreviewPanel } from '../ResolutionPreviewPanel'
15
+ import { buildPolicyFieldGroups } from '../formGroups'
16
+
17
+ type PolicyRecord = {
18
+ id: string
19
+ storeId: string | null
20
+ productId: string | null
21
+ variantId: string | null
22
+ isStockManaged: boolean
23
+ allowBackorder: boolean
24
+ backorderLeadTimeDays: number | null
25
+ preorderReleaseAt: string | null
26
+ lowStockThreshold: number | null
27
+ minOrderQuantity: number | null
28
+ maxOrderQuantity: number | null
29
+ quantityIncrement: number | null
30
+ hideWhenOutOfStock: boolean
31
+ isActive: boolean
32
+ updatedAt: string | null
33
+ }
34
+
35
+ type FormValues = {
36
+ isStockManaged?: boolean
37
+ allowBackorder?: boolean
38
+ backorderLeadTimeDays?: number | string | null
39
+ preorderReleaseAt?: string | null
40
+ lowStockThreshold?: number | string | null
41
+ minOrderQuantity?: number | string | null
42
+ maxOrderQuantity?: number | string | null
43
+ quantityIncrement?: number | string | null
44
+ hideWhenOutOfStock?: boolean
45
+ isActive?: boolean
46
+ }
47
+
48
+ function toNullableInt(value: number | string | null | undefined): number | null {
49
+ if (value === null || value === undefined || value === '') return null
50
+ const parsed = typeof value === 'number' ? value : parseInt(value, 10)
51
+ return Number.isFinite(parsed) ? parsed : null
52
+ }
53
+
54
+ export default function AvailabilityPolicyEditPage({ params }: { params?: { id?: string } }) {
55
+ const t = useT()
56
+ const router = useRouter()
57
+ const id = params?.id ?? ''
58
+ const { payload } = useBackendChrome()
59
+ const canManage = hasFeature(payload?.grantedFeatures, 'availability.policies.manage')
60
+
61
+ const [policy, setPolicy] = React.useState<PolicyRecord | null>(null)
62
+ const [isLoading, setIsLoading] = React.useState(true)
63
+ const [error, setError] = React.useState<string | null>(null)
64
+ const [scope, setScope] = React.useState<PolicyScope>({ productId: '', variantId: '', storeId: '' })
65
+
66
+ React.useEffect(() => {
67
+ if (!id) return
68
+ let cancelled = false
69
+ async function load() {
70
+ setIsLoading(true)
71
+ setError(null)
72
+ try {
73
+ const call = await apiCall<{ items: PolicyRecord[] }>(`/api/availability/policies?id=${encodeURIComponent(id)}`, undefined, {
74
+ fallback: { items: [] },
75
+ })
76
+ if (cancelled) return
77
+ const record = call.ok ? call.result?.items?.[0] ?? null : null
78
+ if (!record) {
79
+ setError(t('availability.policies.edit.notFound'))
80
+ return
81
+ }
82
+ setPolicy(record)
83
+ setScope({ productId: record.productId ?? '', variantId: record.variantId ?? '', storeId: record.storeId ?? '' })
84
+ } catch (e) {
85
+ if (!cancelled) setError(e instanceof Error ? e.message : t('availability.policies.list.error.loadFailed'))
86
+ } finally {
87
+ if (!cancelled) setIsLoading(false)
88
+ }
89
+ }
90
+ load()
91
+ return () => { cancelled = true }
92
+ }, [id, t])
93
+
94
+ const groups = React.useMemo(() => buildPolicyFieldGroups(t), [t])
95
+
96
+ if (isLoading) return <LoadingMessage label={t('common.loading')} />
97
+ if (error || !policy) return <ErrorMessage label={error ?? t('availability.policies.edit.notFound')} />
98
+
99
+ return (
100
+ <CrudForm<FormValues>
101
+ title={t('availability.policies.edit.title')}
102
+ titleHeadingLevel={1}
103
+ backHref="/backend/availability/policies"
104
+ fields={[]}
105
+ groups={groups}
106
+ readOnly={!canManage}
107
+ submitLabel={t('availability.policies.form.action.save')}
108
+ cancelHref="/backend/availability/policies"
109
+ optimisticLockUpdatedAt={policy.updatedAt ?? null}
110
+ initialValues={{
111
+ isStockManaged: policy.isStockManaged,
112
+ allowBackorder: policy.allowBackorder,
113
+ backorderLeadTimeDays: policy.backorderLeadTimeDays,
114
+ preorderReleaseAt: policy.preorderReleaseAt,
115
+ lowStockThreshold: policy.lowStockThreshold,
116
+ minOrderQuantity: policy.minOrderQuantity,
117
+ maxOrderQuantity: policy.maxOrderQuantity,
118
+ quantityIncrement: policy.quantityIncrement,
119
+ hideWhenOutOfStock: policy.hideWhenOutOfStock,
120
+ isActive: policy.isActive,
121
+ }}
122
+ contentHeader={(
123
+ <div className="space-y-4">
124
+ <ScopeFields value={scope} onChange={setScope} disabled={!canManage} />
125
+ <ResolutionPreviewPanel
126
+ productId={scope.productId}
127
+ variantId={scope.variantId || null}
128
+ storeId={scope.storeId || null}
129
+ />
130
+ </div>
131
+ )}
132
+ deleteVisible={canManage}
133
+ onDelete={async () => {
134
+ await deleteCrud('availability/policies', id)
135
+ flash(t('availability.policies.flash.deleted'), 'success')
136
+ router.push('/backend/availability/policies')
137
+ }}
138
+ onSubmit={async (values) => {
139
+ if (scope.variantId && !scope.productId) {
140
+ throw createCrudFormError(
141
+ t('availability.policies.errors.variantRequiresProduct'),
142
+ { productId: t('availability.policies.errors.variantRequiresProduct') },
143
+ )
144
+ }
145
+ if (values.allowBackorder && toNullableInt(values.backorderLeadTimeDays) == null) {
146
+ throw createCrudFormError(
147
+ t('availability.policies.errors.backorderRequiresLeadTime'),
148
+ { backorderLeadTimeDays: t('availability.policies.errors.backorderRequiresLeadTime') },
149
+ )
150
+ }
151
+ const min = toNullableInt(values.minOrderQuantity)
152
+ const max = toNullableInt(values.maxOrderQuantity)
153
+ if (min != null && max != null && max < min) {
154
+ throw createCrudFormError(
155
+ t('availability.policies.errors.maxBelowMin'),
156
+ { maxOrderQuantity: t('availability.policies.errors.maxBelowMin') },
157
+ )
158
+ }
159
+
160
+ const payloadBody = {
161
+ id,
162
+ productId: scope.productId || null,
163
+ variantId: scope.variantId || null,
164
+ storeId: scope.storeId || null,
165
+ isStockManaged: !!values.isStockManaged,
166
+ allowBackorder: !!values.allowBackorder,
167
+ backorderLeadTimeDays: toNullableInt(values.backorderLeadTimeDays),
168
+ preorderReleaseAt: values.preorderReleaseAt || null,
169
+ lowStockThreshold: toNullableInt(values.lowStockThreshold),
170
+ minOrderQuantity: min,
171
+ maxOrderQuantity: max,
172
+ quantityIncrement: toNullableInt(values.quantityIncrement),
173
+ hideWhenOutOfStock: !!values.hideWhenOutOfStock,
174
+ isActive: values.isActive !== false,
175
+ }
176
+ await updateCrud('availability/policies', payloadBody)
177
+ flash(t('availability.policies.flash.updated'), 'success')
178
+ router.push('/backend/availability/policies')
179
+ }}
180
+ />
181
+ )
182
+ }
@@ -0,0 +1,8 @@
1
+ export const metadata = {
2
+ requireAuth: true,
3
+ requireFeatures: ['availability.policies.manage'],
4
+ breadcrumb: [
5
+ { label: 'Availability Policies', labelKey: 'availability.policies.nav.title', href: '/backend/availability/policies' },
6
+ { label: 'Create', labelKey: 'availability.policies.create.title' },
7
+ ],
8
+ }