@open-mercato/core 0.6.8-develop.7031.1.005201cd70 → 0.6.8-develop.7037.1.ea0277b01e

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.6.8-develop.7031.1.005201cd70",
3
+ "version": "0.6.8-develop.7037.1.ea0277b01e",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -252,16 +252,16 @@
252
252
  "zod": "^4.4.3"
253
253
  },
254
254
  "peerDependencies": {
255
- "@open-mercato/ai-assistant": "0.6.8-develop.7031.1.005201cd70",
256
- "@open-mercato/shared": "0.6.8-develop.7031.1.005201cd70",
257
- "@open-mercato/ui": "0.6.8-develop.7031.1.005201cd70",
255
+ "@open-mercato/ai-assistant": "0.6.8-develop.7037.1.ea0277b01e",
256
+ "@open-mercato/shared": "0.6.8-develop.7037.1.ea0277b01e",
257
+ "@open-mercato/ui": "0.6.8-develop.7037.1.ea0277b01e",
258
258
  "react": "^19.0.0",
259
259
  "react-dom": "^19.0.0"
260
260
  },
261
261
  "devDependencies": {
262
- "@open-mercato/ai-assistant": "0.6.8-develop.7031.1.005201cd70",
263
- "@open-mercato/shared": "0.6.8-develop.7031.1.005201cd70",
264
- "@open-mercato/ui": "0.6.8-develop.7031.1.005201cd70",
262
+ "@open-mercato/ai-assistant": "0.6.8-develop.7037.1.ea0277b01e",
263
+ "@open-mercato/shared": "0.6.8-develop.7037.1.ea0277b01e",
264
+ "@open-mercato/ui": "0.6.8-develop.7037.1.ea0277b01e",
265
265
  "@testing-library/dom": "^10.4.1",
266
266
  "@testing-library/jest-dom": "^7.0.0",
267
267
  "@testing-library/react": "^16.3.1",
@@ -1,7 +1,7 @@
1
1
  import type { ComparisonOperator, LogicalOperator } from '../data/validators'
2
2
  import { testLinearRegex } from '@open-mercato/shared/lib/regex/linear'
3
3
  import { getNestedValue, resolveSpecialValue } from './value-resolver'
4
- import { createLogger } from '@open-mercato/shared/lib/logger'
4
+ import { createLogger, isLevelEnabled } from '@open-mercato/shared/lib/logger'
5
5
 
6
6
  const logger = createLogger('business_rules').child({ component: 'expression-evaluator' })
7
7
 
@@ -86,14 +86,14 @@ function evaluateSimpleCondition(
86
86
 
87
87
  const result = applyOperator(leftValue, condition.operator, rightValue)
88
88
 
89
- logger.debug('Simple condition evaluated', {
90
- field: condition.field,
91
- operator: condition.operator,
92
- expectedValue: rightValue,
93
- actualValue: leftValue,
94
- actualValueType: typeof leftValue,
95
- passed: result,
96
- })
89
+ if (isLevelEnabled('debug')) {
90
+ logger.debug('Simple condition evaluated', {
91
+ field: condition.field,
92
+ operator: condition.operator,
93
+ actualValueType: typeof leftValue,
94
+ passed: result,
95
+ })
96
+ }
97
97
 
98
98
  return result
99
99
  }
@@ -1,6 +1,6 @@
1
1
  import type { BusinessRule } from '../data/entities'
2
2
  import { evaluateExpression, type EvaluationContext, type ConditionExpression } from './expression-evaluator'
3
- import { createLogger } from '@open-mercato/shared/lib/logger'
3
+ import { createLogger, isLevelEnabled } from '@open-mercato/shared/lib/logger'
4
4
 
5
5
  const logger = createLogger('business_rules').child({ component: 'rule-evaluator' })
6
6
 
@@ -105,14 +105,15 @@ export async function evaluateSingleRule(
105
105
  ): Promise<SingleRuleResult> {
106
106
  const startTime = Date.now()
107
107
 
108
- logger.debug('Evaluating rule', {
109
- ruleId: rule.ruleId,
110
- ruleName: rule.ruleName,
111
- ruleType: rule.ruleType,
112
- entityType: rule.entityType,
113
- eventType: rule.eventType,
114
- conditions: rule.conditionExpression,
115
- })
108
+ if (isLevelEnabled('debug')) {
109
+ logger.debug('Evaluating rule', {
110
+ ruleId: rule.ruleId,
111
+ ruleName: rule.ruleName,
112
+ ruleType: rule.ruleType,
113
+ entityType: rule.entityType,
114
+ eventType: rule.eventType,
115
+ })
116
+ }
116
117
 
117
118
  try {
118
119
  // Check if rule is enabled
@@ -192,14 +192,29 @@ const analyzeDealsToolDefinition: CustomersAiToolDefinition<AnalyzeDealsInput, A
192
192
  // has no encrypted columns, but the linked CustomerEntity.display_name is
193
193
  // encrypted, so resolve names via findWithDecryption rather than
194
194
  // populating the relation through raw em.find.
195
+ // Defense-in-depth: `customer_deal_people` carries no tenant/org columns,
196
+ // so DB-level scoping is via the tenant-filtered `dealIds` set. Filter the
197
+ // returned rows against the caller-scoped deals map so a future refactor
198
+ // that widens `dealIds` cannot surface cross-tenant links.
199
+ const dealById = new Map<string, CustomerDeal>(deals.map((deal) => [deal.id, deal]))
200
+ const scopedDealIds = Array.from(dealById.keys())
195
201
  const personLinkWhere: Record<string, unknown> = {
196
- deal: { $in: dealIds },
202
+ deal: { $in: scopedDealIds },
197
203
  }
198
- const personLinks = await em.find(
204
+ const personLinksRaw = await em.find(
199
205
  CustomerDealPersonLink,
200
206
  personLinkWhere as any,
201
207
  { limit: 500 },
202
208
  )
209
+ const personLinks = personLinksRaw.filter((link) => {
210
+ const dealRefId = refIdOf((link as { deal?: unknown }).deal)
211
+ if (!dealRefId) return false
212
+ const scopedDeal = dealById.get(dealRefId)
213
+ if (!scopedDeal) return false
214
+ if (scopedDeal.tenantId !== tenantId) return false
215
+ if (ctx.organizationId && scopedDeal.organizationId !== ctx.organizationId) return false
216
+ return true
217
+ })
203
218
 
204
219
  const linksByDeal = new Map<string, string>() // dealId → personId (first link wins)
205
220
  for (const link of personLinks) {
@@ -79,6 +79,10 @@ const crud = makeCrudRoute({
79
79
  }
80
80
  return filters
81
81
  },
82
+ // Zones had no decorator before this route gained custom fields, so there is no
83
+ // legacy consumer reading top-level `cf_*`/`cf:*` — take the canonical single-shape
84
+ // response (#1769) rather than emitting both forms.
85
+ decorateCustomFields: { entityIds: [E.wms.warehouse_zone], stripPrefixedKeys: true },
82
86
  },
83
87
  hooks: {
84
88
  afterList: async (payload, ctx) => {
@@ -122,6 +126,16 @@ export const POST = crud.POST
122
126
  export const PUT = crud.PUT
123
127
  export const DELETE = crud.DELETE
124
128
 
129
+ // Mirrors `CustomFieldDisplayEntry` from @open-mercato/shared/lib/crud/custom-fields, so
130
+ // the generated OpenAPI documents how to read an entry instead of an opaque record.
131
+ const customFieldDisplayEntrySchema = z.object({
132
+ key: z.string(),
133
+ label: z.string().nullable(),
134
+ value: z.unknown(),
135
+ kind: z.string().nullable(),
136
+ multi: z.boolean(),
137
+ })
138
+
125
139
  const zoneListItemSchema = z.object({
126
140
  id: z.string().uuid().nullable().optional(),
127
141
  organization_id: z.string().uuid().nullable().optional(),
@@ -134,6 +148,8 @@ const zoneListItemSchema = z.object({
134
148
  priority: z.number().nullable().optional(),
135
149
  created_at: z.string().nullable().optional(),
136
150
  updated_at: z.string().nullable().optional(),
151
+ customValues: z.record(z.string(), z.unknown()).nullable().optional(),
152
+ customFields: z.array(customFieldDisplayEntrySchema).optional(),
137
153
  })
138
154
 
139
155
  export const openApi = createWmsCrudOpenApi({
@@ -33,11 +33,20 @@ import { registerCommand } from '@open-mercato/shared/lib/commands'
33
33
  import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
34
34
  import type { EntityManager } from '@mikro-orm/postgresql'
35
35
  import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
36
+ import {
37
+ emitCrudSideEffects,
38
+ emitCrudUndoSideEffects,
39
+ parseWithCustomFields,
40
+ setCustomFieldsIfAny,
41
+ } from '@open-mercato/shared/lib/commands/helpers'
42
+ import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
43
+ import { buildCustomFieldResetMap, loadCustomFieldSnapshot } from '@open-mercato/shared/lib/commands/customFieldSnapshots'
36
44
  import { CrudHttpError, isUniqueViolation } from '@open-mercato/shared/lib/crud/errors'
37
45
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
38
46
  import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
39
47
  import { extractUndoPayload } from '@open-mercato/shared/lib/commands/undo'
40
48
  import type { JsonValue } from '@open-mercato/shared/lib/json'
49
+ import { E } from '#generated/entities.ids.generated'
41
50
  import {
42
51
  InventoryLot,
43
52
  type InventoryLotStatus,
@@ -76,6 +85,7 @@ import {
76
85
  normalizeOptionalString,
77
86
  requireId,
78
87
  toNumericString,
88
+ warehouseZoneCrudIndexer,
79
89
  } from './shared'
80
90
  import { emitWmsEvent } from '../events'
81
91
 
@@ -196,6 +206,7 @@ type WarehouseZoneSnapshot = {
196
206
  name: string
197
207
  priority: number
198
208
  metadata: JsonValue | null
209
+ custom?: Record<string, unknown>
199
210
  createdAt: string
200
211
  updatedAt: string
201
212
  }
@@ -331,7 +342,67 @@ async function loadWarehouseZoneSnapshot(
331
342
  id: string,
332
343
  ): Promise<WarehouseZoneSnapshot | null> {
333
344
  const record = await findOneWithDecryption(em, WarehouseZone, { id }, undefined, resolveScope(ctx))
334
- return record ? snapshotWarehouseZone(record) : null
345
+ if (!record) return null
346
+ const snapshot = snapshotWarehouseZone(record)
347
+ // The snapshot always carries custom values because undo has to restore them, and
348
+ // every caller (prepare on update/delete, captureAfter on create/update) feeds undo.
349
+ // For the common tenant that defined no zone custom fields this costs a single
350
+ // `custom_field_values` lookup: `loadCustomFieldValues` returns early on an empty
351
+ // result and never reaches the `custom_field_defs` query.
352
+ snapshot.custom = await loadCustomFieldSnapshot(em, {
353
+ entityId: E.wms.warehouse_zone,
354
+ recordId: record.id,
355
+ tenantId: record.tenantId,
356
+ organizationId: record.organizationId,
357
+ })
358
+ return snapshot
359
+ }
360
+
361
+ async function writeZoneCustomFields(
362
+ ctx: CommandRuntimeContext,
363
+ zone: { id: string; organizationId: string; tenantId: string },
364
+ values: Record<string, unknown>,
365
+ ): Promise<void> {
366
+ if (!values || !Object.keys(values).length) return
367
+ await setCustomFieldsIfAny({
368
+ dataEngine: ctx.container.resolve('dataEngine') as DataEngine,
369
+ entityId: E.wms.warehouse_zone,
370
+ recordId: zone.id,
371
+ organizationId: zone.organizationId,
372
+ tenantId: zone.tenantId,
373
+ values,
374
+ })
375
+ }
376
+
377
+ // Zone reads go through the hybrid query engine, which projects `cf:*` from
378
+ // `entity_indexes`. Emitting the CRUD side effect is therefore what makes a
379
+ // custom field value written above observable through the zones list API — without
380
+ // it the value sits in `custom_field_values` that no read path consults (#5239).
381
+ // `events` is deliberately omitted: the module already emits `wms.zone.*` itself,
382
+ // and adding an events config here would introduce a second, undeclared
383
+ // `wms.warehouse_zone.*` event id for the same write.
384
+ async function emitZoneCrudSideEffects(
385
+ ctx: CommandRuntimeContext,
386
+ action: 'created' | 'updated' | 'deleted',
387
+ zone: WarehouseZone,
388
+ origin: 'write' | 'undo' = 'write',
389
+ ): Promise<void> {
390
+ const options = {
391
+ dataEngine: ctx.container.resolve('dataEngine') as DataEngine,
392
+ action,
393
+ entity: zone,
394
+ identifiers: {
395
+ id: zone.id,
396
+ organizationId: zone.organizationId,
397
+ tenantId: zone.tenantId,
398
+ },
399
+ indexer: warehouseZoneCrudIndexer,
400
+ }
401
+ if (origin === 'undo') {
402
+ await emitCrudUndoSideEffects(options)
403
+ return
404
+ }
405
+ await emitCrudSideEffects(options)
335
406
  }
336
407
 
337
408
  function snapshotWarehouseLocation(record: WarehouseLocation): WarehouseLocationSnapshot {
@@ -989,7 +1060,7 @@ const deleteWarehouseCommand: CommandHandler<{ id?: string }, { warehouseId: str
989
1060
  },
990
1061
  }
991
1062
 
992
- async function restoreZoneFromSnapshot(em: EntityManager, before: WarehouseZoneSnapshot): Promise<void> {
1063
+ async function restoreZoneFromSnapshot(em: EntityManager, before: WarehouseZoneSnapshot): Promise<WarehouseZone> {
993
1064
  const scope = { tenantId: before.tenantId, organizationId: before.organizationId }
994
1065
  const warehouseRef = await findOneWithDecryption(em, Warehouse, { id: before.warehouseId }, undefined, scope)
995
1066
  if (!warehouseRef) {
@@ -1018,12 +1089,13 @@ async function restoreZoneFromSnapshot(em: EntityManager, before: WarehouseZoneS
1018
1089
  record.metadata = before.metadata
1019
1090
  record.deletedAt = null
1020
1091
  }
1092
+ return record
1021
1093
  }
1022
1094
 
1023
1095
  const createWarehouseZoneCommand: CommandHandler<WarehouseZoneCreateInput, { zoneId: string }> = {
1024
1096
  id: 'wms.zones.create',
1025
1097
  async execute(rawInput, ctx) {
1026
- const parsed = warehouseZoneCreateSchema.parse(rawInput ?? {})
1098
+ const { parsed, custom } = parseWithCustomFields(warehouseZoneCreateSchema, rawInput ?? {})
1027
1099
  ensureTenantScope(ctx, parsed.tenantId)
1028
1100
  ensureOrganizationScope(ctx, parsed.organizationId)
1029
1101
  const em = resolveEm(ctx)
@@ -1039,6 +1111,8 @@ const createWarehouseZoneCommand: CommandHandler<WarehouseZoneCreateInput, { zon
1039
1111
  metadata: toJsonValue(parsed.metadata),
1040
1112
  })
1041
1113
  await em.persist(zone).flush()
1114
+ await writeZoneCustomFields(ctx, zone, custom)
1115
+ await emitZoneCrudSideEffects(ctx, 'created', zone)
1042
1116
  void emitWmsEvent('wms.zone.created', {
1043
1117
  id: zone.id,
1044
1118
  zoneId: zone.id,
@@ -1074,6 +1148,8 @@ const createWarehouseZoneCommand: CommandHandler<WarehouseZoneCreateInput, { zon
1074
1148
  ensureOrganizationScope(ctx, record.organizationId)
1075
1149
  record.deletedAt = new Date()
1076
1150
  await em.flush()
1151
+ await writeZoneCustomFields(ctx, record, buildCustomFieldResetMap(undefined, after.custom))
1152
+ await emitZoneCrudSideEffects(ctx, 'deleted', record, 'undo')
1077
1153
  },
1078
1154
  }
1079
1155
 
@@ -1086,7 +1162,7 @@ const updateWarehouseZoneCommand: CommandHandler<WarehouseZoneUpdateInput, { zon
1086
1162
  return before ? { before } : {}
1087
1163
  },
1088
1164
  async execute(rawInput, ctx) {
1089
- const parsed = warehouseZoneUpdateSchema.parse(rawInput ?? {})
1165
+ const { parsed, custom } = parseWithCustomFields(warehouseZoneUpdateSchema, rawInput ?? {})
1090
1166
  const em = resolveEm(ctx)
1091
1167
  const zone = await loadZone(em, ctx, parsed.id)
1092
1168
  if (parsed.warehouseId !== undefined) {
@@ -1102,6 +1178,8 @@ const updateWarehouseZoneCommand: CommandHandler<WarehouseZoneUpdateInput, { zon
1102
1178
  if (parsed.priority !== undefined) zone.priority = parsed.priority
1103
1179
  if (parsed.metadata !== undefined) zone.metadata = toJsonValue(parsed.metadata)
1104
1180
  await em.flush()
1181
+ await writeZoneCustomFields(ctx, zone, custom)
1182
+ await emitZoneCrudSideEffects(ctx, 'updated', zone)
1105
1183
  void emitWmsEvent('wms.zone.updated', {
1106
1184
  id: zone.id,
1107
1185
  zoneId: zone.id,
@@ -1128,8 +1206,10 @@ const updateWarehouseZoneCommand: CommandHandler<WarehouseZoneUpdateInput, { zon
1128
1206
  const em = resolveEm(ctx)
1129
1207
  ensureTenantScope(ctx, before.tenantId)
1130
1208
  ensureOrganizationScope(ctx, before.organizationId)
1131
- await restoreZoneFromSnapshot(em, before)
1209
+ const record = await restoreZoneFromSnapshot(em, before)
1132
1210
  await em.flush()
1211
+ await writeZoneCustomFields(ctx, record, buildCustomFieldResetMap(before.custom, payload?.after?.custom))
1212
+ await emitZoneCrudSideEffects(ctx, 'updated', record, 'undo')
1133
1213
  },
1134
1214
  }
1135
1215
 
@@ -1147,6 +1227,7 @@ const deleteWarehouseZoneCommand: CommandHandler<{ id?: string }, { zoneId: stri
1147
1227
  const zone = await loadZone(em, ctx, zoneId)
1148
1228
  zone.deletedAt = new Date()
1149
1229
  await em.flush()
1230
+ await emitZoneCrudSideEffects(ctx, 'deleted', zone)
1150
1231
  return { zoneId: zone.id }
1151
1232
  },
1152
1233
  buildLog: async ({ input, result, ctx, snapshots }) => {
@@ -1161,8 +1242,10 @@ const deleteWarehouseZoneCommand: CommandHandler<{ id?: string }, { zoneId: stri
1161
1242
  const em = resolveEm(ctx)
1162
1243
  ensureTenantScope(ctx, before.tenantId)
1163
1244
  ensureOrganizationScope(ctx, before.organizationId)
1164
- await restoreZoneFromSnapshot(em, before)
1245
+ const record = await restoreZoneFromSnapshot(em, before)
1165
1246
  await em.flush()
1247
+ await writeZoneCustomFields(ctx, record, buildCustomFieldResetMap(before.custom, undefined))
1248
+ await emitZoneCrudSideEffects(ctx, 'created', record, 'undo')
1166
1249
  },
1167
1250
  }
1168
1251
 
@@ -7,6 +7,7 @@ import {
7
7
  InventoryBalance,
8
8
  InventoryMovement,
9
9
  InventoryReservation,
10
+ WarehouseZone,
10
11
  } from '../data/entities'
11
12
 
12
13
  export function ensureTenantScope(ctx: CommandRuntimeContext, tenantId: string): void {
@@ -48,6 +49,13 @@ export const inventoryMovementCrudIndexer: CrudIndexerConfig<InventoryMovement>
48
49
  entityType: E.wms.inventory_movement,
49
50
  }
50
51
 
52
+ // Zones are read through the hybrid query engine, which projects `cf:*` out of
53
+ // `entity_indexes` rather than joining `custom_field_values`. Without this indexer the
54
+ // zone commands write custom field values that no read path can ever see (#5239).
55
+ export const warehouseZoneCrudIndexer: CrudIndexerConfig<WarehouseZone> = {
56
+ entityType: E.wms.warehouse_zone,
57
+ }
58
+
51
59
  export const inventoryBalanceCrudEvents: CrudEventsConfig<InventoryBalance> = {
52
60
  module: 'wms',
53
61
  entity: 'inventory_balance',
@@ -12,10 +12,12 @@ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
12
12
  import { DataTable } from '@open-mercato/ui/backend/DataTable'
13
13
  import { EmptyState } from '@open-mercato/ui/backend/EmptyState'
14
14
  import { CrudForm, type CrudField, type CrudFieldOption } from '@open-mercato/ui/backend/CrudForm'
15
+ import { extractCustomFieldEntries } from '@open-mercato/shared/lib/crud/custom-fields-client'
15
16
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
16
17
  import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
17
18
  import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
18
19
  import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
20
+ import { collectCustomFieldValues } from '@open-mercato/ui/backend/utils/customFieldValues'
19
21
  import { raiseCrudError } from '@open-mercato/ui/backend/utils/serverErrors'
20
22
  import { Button } from '@open-mercato/ui/primitives/button'
21
23
  import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@open-mercato/ui/primitives/dialog'
@@ -80,6 +82,11 @@ type ZoneRow = {
80
82
  code?: string | null
81
83
  name?: string | null
82
84
  priority?: number | null
85
+ updated_at?: string | null
86
+ // The zones list route decorates with `stripPrefixedKeys: true`, so custom values
87
+ // arrive only under this canonical key — never as top-level `cf_*` / `cf:*`.
88
+ customValues?: Record<string, unknown> | null
89
+ customFields?: Array<{ key: string; label: string | null; value: unknown; kind: string | null; multi: boolean }>
83
90
  }
84
91
 
85
92
  type InventoryProfileRow = {
@@ -109,12 +116,17 @@ type WarehouseFormValues = {
109
116
  isPrimary: boolean
110
117
  }
111
118
 
119
+ // `id` / `updatedAt` are carried in edit mode so CrudForm recognises the form as an edit
120
+ // and derives the optimistic-lock header; the `cf_` index signature holds the values
121
+ // collected from the injected custom-field inputs.
112
122
  type ZoneFormValues = {
113
123
  warehouseId: string
114
124
  code: string
115
125
  name: string
116
126
  priority?: number
117
- }
127
+ id?: string
128
+ updatedAt?: string | null
129
+ } & { [key: `cf_${string}`]: unknown }
118
130
 
119
131
  type InventoryProfileFormValues = {
120
132
  catalogProductId: string
@@ -142,12 +154,15 @@ const warehouseFormSchema = z.object({
142
154
  isPrimary: z.boolean().default(false),
143
155
  })
144
156
 
157
+ // passthrough so CrudForm's schema.safeParse keeps the `cf_*` custom field values
158
+ // it collected from the injected custom-field inputs — a plain z.object strips them
159
+ // before onSubmit ever sees them.
145
160
  const zoneFormSchema = z.object({
146
161
  warehouseId: z.string().uuid(),
147
162
  code: z.string().trim().min(1).max(80),
148
163
  name: z.string().trim().min(1).max(200),
149
164
  priority: z.coerce.number().int().min(0).optional(),
150
- })
165
+ }).passthrough()
151
166
 
152
167
  function buildInventoryProfileFormSchema(fefoRequiredMsg: string) {
153
168
  return z.object({
@@ -598,19 +613,54 @@ export function ZoneSection({ viewAllHref }: ConfigSectionOptions = {}) {
598
613
  },
599
614
  })
600
615
 
616
+ // Warehouse options for the dialog's combobox. Loaded whenever the dialog opens so a tenant
617
+ // with a single warehouse gets it pre-selected instead of an empty "type to search" input,
618
+ // and so the edit dialog can label the currently linked warehouse without a lookup round-trip.
619
+ const warehouseOptionsQuery = useQuery({
620
+ queryKey: ['wms-config', 'zones', 'warehouse-options'],
621
+ queryFn: () => loadWarehouseOptions(),
622
+ enabled: dialog !== null,
623
+ staleTime: 30_000,
624
+ })
625
+ const warehouseOptions = React.useMemo(
626
+ () => warehouseOptionsQuery.data ?? [],
627
+ [warehouseOptionsQuery.data],
628
+ )
629
+ const soleWarehouseId = warehouseOptions.length === 1 ? warehouseOptions[0].value : null
630
+
631
+ const warehouseSeedOptions = React.useMemo<CrudFieldOption[] | undefined>(() => {
632
+ if (dialog?.mode === 'edit') {
633
+ const warehouseId = dialog.row.warehouse_id
634
+ if (!warehouseId) return undefined
635
+ const label = dialog.row.warehouse_name?.trim() || dialog.row.warehouse_code?.trim() || ''
636
+ return label ? [{ value: warehouseId, label }] : undefined
637
+ }
638
+ return soleWarehouseId ? warehouseOptions : undefined
639
+ }, [dialog, soleWarehouseId, warehouseOptions])
640
+
641
+ // The dialog already holds the first page of warehouses; serve the combobox's initial
642
+ // (unsearched) open from that cache instead of repeating the same request, and only go
643
+ // back to the network once the user actually types a term the cached page cannot answer.
644
+ const loadZoneWarehouseOptions = React.useCallback(async (query?: string) => {
645
+ const term = query?.trim()
646
+ if (!term) return warehouseOptionsQuery.data ?? loadWarehouseOptions()
647
+ return loadWarehouseOptions(term)
648
+ }, [warehouseOptionsQuery.data])
649
+
601
650
  const fields = React.useMemo<CrudField[]>(() => [
602
651
  {
603
652
  id: 'warehouseId',
604
653
  type: 'combobox',
605
654
  label: t('wms.backend.config.zones.form.warehouse', 'Warehouse'),
606
655
  required: true,
607
- loadOptions: loadWarehouseOptions,
656
+ loadOptions: loadZoneWarehouseOptions,
608
657
  allowCustomValues: false,
658
+ seedOptions: warehouseSeedOptions,
609
659
  },
610
660
  { id: 'code', type: 'text', label: t('wms.backend.config.zones.form.code', 'Code'), required: true },
611
661
  { id: 'name', type: 'text', label: t('wms.backend.config.zones.form.name', 'Name'), required: true },
612
662
  { id: 'priority', type: 'number', label: t('wms.backend.config.zones.form.priority', 'Priority') },
613
- ], [t])
663
+ ], [t, loadZoneWarehouseOptions, warehouseSeedOptions])
614
664
 
615
665
  const columns = React.useMemo<ColumnDef<ZoneRow>[]>(() => [
616
666
  {
@@ -641,19 +691,22 @@ export function ZoneSection({ viewAllHref }: ConfigSectionOptions = {}) {
641
691
  const initialValues = React.useMemo<ZoneFormValues>(() => {
642
692
  if (dialog?.mode === 'edit') {
643
693
  return {
694
+ id: dialog.row.id,
695
+ updatedAt: dialog.row.updated_at ?? null,
644
696
  warehouseId: dialog.row.warehouse_id || '',
645
697
  code: dialog.row.code || '',
646
698
  name: dialog.row.name || '',
647
699
  priority: dialog.row.priority == null ? undefined : Number(dialog.row.priority),
700
+ ...extractCustomFieldEntries(dialog.row),
648
701
  }
649
702
  }
650
703
  return {
651
- warehouseId: '',
704
+ warehouseId: soleWarehouseId ?? '',
652
705
  code: '',
653
706
  name: '',
654
707
  priority: undefined,
655
708
  }
656
- }, [dialog])
709
+ }, [dialog, soleWarehouseId])
657
710
 
658
711
  const closeDialog = React.useCallback(() => {
659
712
  setDialog(null)
@@ -669,10 +722,14 @@ export function ZoneSection({ viewAllHref }: ConfigSectionOptions = {}) {
669
722
  const submitMode = dialog.mode
670
723
  setSubmitting(true)
671
724
  try {
672
- const payload = {
673
- ...values,
725
+ const customFields = collectCustomFieldValues(values)
726
+ const payload: Record<string, unknown> = {
727
+ warehouseId: values.warehouseId,
728
+ code: values.code,
729
+ name: values.name,
674
730
  priority: values.priority === undefined || Number.isNaN(values.priority) ? undefined : Number(values.priority),
675
731
  }
732
+ if (Object.keys(customFields).length) payload.customFields = customFields
676
733
  await runMutation({
677
734
  operation: async () => {
678
735
  const call = await apiCall(