@asteby/metacore-runtime-react 18.26.0 → 18.28.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @asteby/metacore-runtime-react
2
2
 
3
+ ## 18.28.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d3b7060: feat(line-items): a PrefillSpec can `lock` item-field columns — locked dynamic_select cells render as a resolved, read-only NAME (eager option fetch, never the raw id) instead of an editable picker. Used for receive-goods/partial-reception lines whose product is dictated by the source document; the create flow (no prefill) stays fully editable.
8
+
9
+ ## 18.27.0
10
+
11
+ ### Minor Changes
12
+
13
+ - b71dcc0: feat(action-modal): line-items fields can prefill their rows from the acted-on record. A field whose `default` is a `PrefillSpec` (`{$prefillFromRecord, map, remaining}`) seeds one row per record array entry, copying mapped keys and computing a remaining quantity (`of - minus`), dropping fully-satisfied rows. Enables receive-goods/partial-reception modals (e.g. inventory transfers) to open pre-loaded with the pending lines instead of empty. Decoupled: the SDK only projects a record array into the field's item_fields.
14
+
3
15
  ## 18.26.0
4
16
 
5
17
  ### Minor Changes
@@ -1 +1 @@
1
- {"version":3,"file":"action-modal-dispatcher.d.ts","sourceRoot":"","sources":["../src/action-modal-dispatcher.tsx"],"names":[],"mappings":"AA+CA,OAAO,EACH,KAAK,cAAc,EACnB,KAAK,gBAAgB,EAExB,MAAM,sBAAsB,CAAA;AAE7B,YAAY,EAAE,cAAc,EAAE,gBAAgB,EAAE,CAAA;AAEhD,wBAAgB,qBAAqB,CAAC,EAClC,IAAI,EACJ,YAAY,EACZ,MAAM,EACN,KAAK,EACL,MAAM,EACN,QAAQ,EACR,SAAS,GACZ,EAAE,gBAAgB,sCAiDlB"}
1
+ {"version":3,"file":"action-modal-dispatcher.d.ts","sourceRoot":"","sources":["../src/action-modal-dispatcher.tsx"],"names":[],"mappings":"AA+CA,OAAO,EACH,KAAK,cAAc,EACnB,KAAK,gBAAgB,EAExB,MAAM,sBAAsB,CAAA;AAE7B,YAAY,EAAE,cAAc,EAAE,gBAAgB,EAAE,CAAA;AA0FhD,wBAAgB,qBAAqB,CAAC,EAClC,IAAI,EACJ,YAAY,EACZ,MAAM,EACN,KAAK,EACL,MAAM,EACN,QAAQ,EACR,SAAS,GACZ,EAAE,gBAAgB,sCAiDlB"}
@@ -21,6 +21,65 @@ import { UploadField } from './upload-field';
21
21
  import { isLineItemsField, resolveWidget, resolveDependsValue, getDependsOn } from './dynamic-form-schema';
22
22
  // Canonical registry lives in @asteby/metacore-sdk
23
23
  import { getActionComponent, } from '@asteby/metacore-sdk';
24
+ function isPrefillSpec(v) {
25
+ return (typeof v === 'object' &&
26
+ v !== null &&
27
+ typeof v.$prefillFromRecord === 'string');
28
+ }
29
+ // lineItemsDefault reads the field's declared default tolerating BOTH the
30
+ // camelCase `defaultValue` the host serves and the snake/legacy `default` the
31
+ // raw manifest carries (the kernel maps action-field `default` through without
32
+ // renaming it to `defaultValue`).
33
+ function lineItemsDefault(field) {
34
+ const f = field;
35
+ return f.defaultValue ?? f.default;
36
+ }
37
+ function toNum(v) {
38
+ const n = typeof v === 'number' ? v : parseFloat(String(v ?? ''));
39
+ return Number.isFinite(n) ? n : 0;
40
+ }
41
+ // applyPrefillLock marks the item-field columns named in a line-items field's
42
+ // PrefillSpec.lock as read-only, so the prefilled cells (e.g. the product of a
43
+ // receive line) render as a resolved, non-editable name. Returns the field
44
+ // untouched when there is no prefill spec or no lock list (the create flow,
45
+ // which carries no prefill, stays fully editable). The readonly flag is set on
46
+ // BOTH itemFields aliases the renderers tolerate.
47
+ function applyPrefillLock(field) {
48
+ const spec = lineItemsDefault(field);
49
+ if (!isPrefillSpec(spec) || !spec.lock || spec.lock.length === 0)
50
+ return field;
51
+ const lock = new Set(spec.lock);
52
+ const f = field;
53
+ const items = f.itemFields ?? f.item_fields;
54
+ if (!Array.isArray(items))
55
+ return field;
56
+ const patched = items.map((c) => (c && c.key && lock.has(c.key) ? { ...c, readonly: true } : c));
57
+ return { ...field, itemFields: patched, item_fields: patched };
58
+ }
59
+ // buildPrefillRows projects record[spec.$prefillFromRecord] into modal rows.
60
+ function buildPrefillRows(spec, record) {
61
+ const src = record?.[spec.$prefillFromRecord];
62
+ if (!Array.isArray(src))
63
+ return [];
64
+ const rows = [];
65
+ for (const item of src) {
66
+ if (!item || typeof item !== 'object')
67
+ continue;
68
+ const row = {};
69
+ if (spec.map) {
70
+ for (const [target, from] of Object.entries(spec.map))
71
+ row[target] = item[from];
72
+ }
73
+ if (spec.remaining) {
74
+ const remaining = toNum(item[spec.remaining.of]) - (spec.remaining.minus ? toNum(item[spec.remaining.minus]) : 0);
75
+ if (remaining <= 0)
76
+ continue; // line already fully satisfied → omit
77
+ row[spec.remaining.target] = remaining;
78
+ }
79
+ rows.push(row);
80
+ }
81
+ return rows;
82
+ }
24
83
  export function ActionModalDispatcher({ open, onOpenChange, action, model, record, endpoint, onSuccess, }) {
25
84
  const CustomComponent = useMemo(() => getActionComponent(model, action.key), [model, action.key]);
26
85
  if (CustomComponent) {
@@ -83,14 +142,19 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
83
142
  const defaults = {};
84
143
  for (const field of action.fields) {
85
144
  if (isLineItemsField(field)) {
86
- defaults[field.key] = field.defaultValue ?? [];
145
+ const dv = lineItemsDefault(field);
146
+ defaults[field.key] = isPrefillSpec(dv)
147
+ ? buildPrefillRows(dv, record)
148
+ : Array.isArray(dv)
149
+ ? dv
150
+ : [];
87
151
  continue;
88
152
  }
89
153
  defaults[field.key] = field.defaultValue ?? (field.type === 'boolean' ? false : '');
90
154
  }
91
155
  setFormData(defaults);
92
156
  }
93
- }, [open, action.fields]);
157
+ }, [open, action.fields, record]);
94
158
  const updateField = (key, value) => setFormData((prev) => ({ ...prev, [key]: value }));
95
159
  const execute = async () => {
96
160
  if (action.fields) {
@@ -161,7 +225,7 @@ formValues) {
161
225
  // Repeatable line-items group → row grid (value is an array of row objects).
162
226
  // The header form values flow in so a cell can depend on a header field.
163
227
  if (isLineItemsField(field)) {
164
- return _jsx(DynamicLineItems, { field: field, value: value, onChange: onChange, formValues: formValues });
228
+ return _jsx(DynamicLineItems, { field: applyPrefillLock(field), value: value, onChange: onChange, formValues: formValues });
165
229
  }
166
230
  // Resolve the widget the same way DynamicForm does (explicit widget wins,
167
231
  // else inferred from type) so action modals and the standalone form stay in
@@ -91,7 +91,9 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues
91
91
  // Async searchable picker per row cell — e.g. the account_id column of a
92
92
  // journal entry's debit/credit lines. Same widget as the flat form.
93
93
  if (widget === 'dynamic_select') {
94
- return _jsx(DynamicSelectField, { field: field, value: value, onChange: onChange, dependsValue: dependsValue });
94
+ const ro = !!field.readonly ||
95
+ !!field.read_only;
96
+ return _jsx(DynamicSelectField, { field: field, value: value, onChange: onChange, dependsValue: dependsValue, readonly: ro });
95
97
  }
96
98
  if (widget === 'select' && (field.ref || getOptionsConfig(field)?.source)) {
97
99
  return (_jsx(RefCell, { field: field, value: value, onChange: onChange, disabled: disabled, formValues: formValues, rowValues: rowValues }));
@@ -47,7 +47,15 @@ export interface DynamicSelectFieldProps {
47
47
  dependsValue?: string;
48
48
  /** Overrides the disabled-state hint shown while `dependsValue` is empty. */
49
49
  dependsHint?: string;
50
+ /**
51
+ * Renders the picker as a locked, non-interactive display of the current
52
+ * value's resolved label (no popover, no inline-create). Used when the value
53
+ * is fixed by context — e.g. the product of a receive-goods line is dictated
54
+ * by the source document, not chosen. Options are fetched eagerly (not just
55
+ * on open) so the label resolves to the NAME instead of showing the raw id.
56
+ */
57
+ readonly?: boolean;
50
58
  }
51
- export declare function DynamicSelectField({ field, value, onChange, seedOption, dependsValue, dependsHint, }: DynamicSelectFieldProps): import("react").JSX.Element;
59
+ export declare function DynamicSelectField({ field, value, onChange, seedOption, dependsValue, dependsHint, readonly, }: DynamicSelectFieldProps): import("react").JSX.Element;
52
60
  export default DynamicSelectField;
53
61
  //# sourceMappingURL=dynamic-select-field.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-select-field.d.ts","sourceRoot":"","sources":["../src/dynamic-select-field.tsx"],"names":[],"mappings":"AAuCA,OAAO,EAAsB,KAAK,cAAc,EAAE,MAAM,wBAAwB,CAAA;AAEhF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAE7C;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,gDAAgD,CAAA;AAEjF;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,EAAE,KAAK,EAAE,IAAS,EAAE,EAAE;IAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,+BA4BzF;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,EACvB,MAAM,EACN,IAAS,GACZ,EAAE;IACC,MAAM,CAAC,EAAE,IAAI,CAAC,cAAc,EAAE,OAAO,GAAG,OAAO,GAAG,MAAM,CAAC,GAAG,IAAI,CAAA;IAChE,IAAI,CAAC,EAAE,MAAM,CAAA;CAChB,sCAwBA;AAqBD,MAAM,WAAW,uBAAuB;IACpC,KAAK,EAAE,cAAc,CAAA;IACrB,KAAK,EAAE,GAAG,CAAA;IACV,QAAQ,EAAE,CAAC,CAAC,EAAE,GAAG,KAAK,IAAI,CAAA;IAC1B;;;;;OAKG;IACH,UAAU,CAAC,EAAE,cAAc,GAAG,IAAI,CAAA;IAClC;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,6EAA6E;IAC7E,WAAW,CAAC,EAAE,MAAM,CAAA;CACvB;AAED,wBAAgB,kBAAkB,CAAC,EAC/B,KAAK,EACL,KAAK,EACL,QAAQ,EACR,UAAU,EACV,YAAY,EACZ,WAAW,GACd,EAAE,uBAAuB,+BA4MzB;AAED,eAAe,kBAAkB,CAAA"}
1
+ {"version":3,"file":"dynamic-select-field.d.ts","sourceRoot":"","sources":["../src/dynamic-select-field.tsx"],"names":[],"mappings":"AAuCA,OAAO,EAAsB,KAAK,cAAc,EAAE,MAAM,wBAAwB,CAAA;AAEhF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAE7C;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,gDAAgD,CAAA;AAEjF;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,EAAE,KAAK,EAAE,IAAS,EAAE,EAAE;IAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,+BA4BzF;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,EACvB,MAAM,EACN,IAAS,GACZ,EAAE;IACC,MAAM,CAAC,EAAE,IAAI,CAAC,cAAc,EAAE,OAAO,GAAG,OAAO,GAAG,MAAM,CAAC,GAAG,IAAI,CAAA;IAChE,IAAI,CAAC,EAAE,MAAM,CAAA;CAChB,sCAwBA;AAqBD,MAAM,WAAW,uBAAuB;IACpC,KAAK,EAAE,cAAc,CAAA;IACrB,KAAK,EAAE,GAAG,CAAA;IACV,QAAQ,EAAE,CAAC,CAAC,EAAE,GAAG,KAAK,IAAI,CAAA;IAC1B;;;;;OAKG;IACH,UAAU,CAAC,EAAE,cAAc,GAAG,IAAI,CAAA;IAClC;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,6EAA6E;IAC7E,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAA;CACrB;AAED,wBAAgB,kBAAkB,CAAC,EAC/B,KAAK,EACL,KAAK,EACL,QAAQ,EACR,UAAU,EACV,YAAY,EACZ,WAAW,EACX,QAAgB,GACnB,EAAE,uBAAuB,+BAsOzB;AAED,eAAe,kBAAkB,CAAA"}
@@ -85,7 +85,7 @@ function useDebounced(value, ms) {
85
85
  }, [value, ms]);
86
86
  return debounced;
87
87
  }
88
- export function DynamicSelectField({ field, value, onChange, seedOption, dependsValue, dependsHint, }) {
88
+ export function DynamicSelectField({ field, value, onChange, seedOption, dependsValue, dependsHint, readonly = false, }) {
89
89
  const [open, setOpen] = useState(false);
90
90
  const [search, setSearch] = useState('');
91
91
  const debounced = useDebounced(search, 250);
@@ -120,8 +120,9 @@ export function DynamicSelectField({ field, value, onChange, seedOption, depends
120
120
  filterValue: dependsOn ? scope : undefined,
121
121
  // Don't fetch until the popover opens (and keep fetching as the query
122
122
  // changes while open). A picker blocked by an unset dependency never
123
- // fetches.
124
- enabled: open && !blockedByDependency,
123
+ // fetches. A readonly cell fetches eagerly so its value's label resolves
124
+ // to the name without the user ever opening it.
125
+ enabled: (open || readonly) && !blockedByDependency,
125
126
  });
126
127
  // When the depended-on value changes, the previously-picked option no longer
127
128
  // belongs to the new scope, so clear the selection (skip the initial mount).
@@ -175,6 +176,13 @@ export function DynamicSelectField({ field, value, onChange, seedOption, depends
175
176
  },
176
177
  }));
177
178
  };
179
+ // Locked display: the value is fixed by context, so render the resolved
180
+ // label (name) as a disabled control — no popover, no inline-create. While
181
+ // the eager fetch is in flight the label falls back to the seed/raw value,
182
+ // then snaps to the name once options arrive.
183
+ if (readonly) {
184
+ return (_jsx(Button, { type: "button", variant: "outline", role: "combobox", id: field.key, disabled: true, "aria-readonly": "true", className: "w-full min-w-0 cursor-default justify-start font-normal opacity-100", children: _jsxs("span", { className: "flex min-w-0 flex-1 items-center gap-2 text-left", children: [hasVisual && value ? _jsx(OptionLead, { option: selectedOption, size: 20 }) : null, _jsx("span", { className: 'min-w-0 flex-1 truncate ' + (selectedLabel ? '' : 'text-muted-foreground'), children: selectedLabel || (loading ? 'Cargando…' : field.placeholder || '—') })] }) }));
185
+ }
178
186
  // w-full + min-w-0: as a grid cell child, the row must be allowed to shrink
179
187
  // to the cell. Without min-w-0 the combobox+button row sizes to its content
180
188
  // (the long empty-state placeholder) and overflows the column, pushing the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "18.26.0",
3
+ "version": "18.28.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -53,6 +53,94 @@ import {
53
53
 
54
54
  export type { ActionMetadata, ActionModalProps }
55
55
 
56
+ // ---- line-items prefill from the acted-on record ----------------------------
57
+ //
58
+ // A line-items action field can seed its rows from the record being acted on,
59
+ // instead of opening empty. The manifest declares this by setting the field's
60
+ // `default`/`defaultValue` to a PrefillSpec object: the modal reads the record's
61
+ // `$prefillFromRecord` array, copies the mapped keys, and (for a receive-style
62
+ // flow) computes a `remaining` quantity = of - minus, dropping fully-satisfied
63
+ // rows. Decoupled + generic: the SDK knows nothing about transfers, only how to
64
+ // project a record array into the field's item_fields. Example (receive goods):
65
+ //
66
+ // "default": {
67
+ // "$prefillFromRecord": "items",
68
+ // "map": { "product_id": "product_id" },
69
+ // "remaining": { "target": "qty_received", "of": "quantity", "minus": "received" }
70
+ // }
71
+ interface PrefillSpec {
72
+ $prefillFromRecord: string
73
+ map?: Record<string, string>
74
+ remaining?: { target: string; of: string; minus?: string }
75
+ /**
76
+ * Item-field keys to lock (render read-only) in the prefilled rows — e.g. the
77
+ * product of a receive-goods line is dictated by the source document, so it
78
+ * is shown as a resolved name but cannot be changed. Editable in the create
79
+ * flow (which carries no prefill spec); only the prefilled action locks them.
80
+ */
81
+ lock?: string[]
82
+ }
83
+
84
+ function isPrefillSpec(v: unknown): v is PrefillSpec {
85
+ return (
86
+ typeof v === 'object' &&
87
+ v !== null &&
88
+ typeof (v as { $prefillFromRecord?: unknown }).$prefillFromRecord === 'string'
89
+ )
90
+ }
91
+
92
+ // lineItemsDefault reads the field's declared default tolerating BOTH the
93
+ // camelCase `defaultValue` the host serves and the snake/legacy `default` the
94
+ // raw manifest carries (the kernel maps action-field `default` through without
95
+ // renaming it to `defaultValue`).
96
+ function lineItemsDefault(field: ActionFieldDef): unknown {
97
+ const f = field as { defaultValue?: unknown; default?: unknown }
98
+ return f.defaultValue ?? f.default
99
+ }
100
+
101
+ function toNum(v: unknown): number {
102
+ const n = typeof v === 'number' ? v : parseFloat(String(v ?? ''))
103
+ return Number.isFinite(n) ? n : 0
104
+ }
105
+
106
+ // applyPrefillLock marks the item-field columns named in a line-items field's
107
+ // PrefillSpec.lock as read-only, so the prefilled cells (e.g. the product of a
108
+ // receive line) render as a resolved, non-editable name. Returns the field
109
+ // untouched when there is no prefill spec or no lock list (the create flow,
110
+ // which carries no prefill, stays fully editable). The readonly flag is set on
111
+ // BOTH itemFields aliases the renderers tolerate.
112
+ function applyPrefillLock(field: ActionFieldDef): ActionFieldDef {
113
+ const spec = lineItemsDefault(field)
114
+ if (!isPrefillSpec(spec) || !spec.lock || spec.lock.length === 0) return field
115
+ const lock = new Set(spec.lock)
116
+ const f = field as ActionFieldDef & { itemFields?: any[]; item_fields?: any[] }
117
+ const items: any[] | undefined = f.itemFields ?? f.item_fields
118
+ if (!Array.isArray(items)) return field
119
+ const patched: any[] = items.map((c) => (c && c.key && lock.has(c.key) ? { ...c, readonly: true } : c))
120
+ return { ...(field as any), itemFields: patched, item_fields: patched } as ActionFieldDef
121
+ }
122
+
123
+ // buildPrefillRows projects record[spec.$prefillFromRecord] into modal rows.
124
+ function buildPrefillRows(spec: PrefillSpec, record: any): Array<Record<string, any>> {
125
+ const src = record?.[spec.$prefillFromRecord]
126
+ if (!Array.isArray(src)) return []
127
+ const rows: Array<Record<string, any>> = []
128
+ for (const item of src) {
129
+ if (!item || typeof item !== 'object') continue
130
+ const row: Record<string, any> = {}
131
+ if (spec.map) {
132
+ for (const [target, from] of Object.entries(spec.map)) row[target] = item[from]
133
+ }
134
+ if (spec.remaining) {
135
+ const remaining = toNum(item[spec.remaining.of]) - (spec.remaining.minus ? toNum(item[spec.remaining.minus]) : 0)
136
+ if (remaining <= 0) continue // line already fully satisfied → omit
137
+ row[spec.remaining.target] = remaining
138
+ }
139
+ rows.push(row)
140
+ }
141
+ return rows
142
+ }
143
+
56
144
  export function ActionModalDispatcher({
57
145
  open,
58
146
  onOpenChange,
@@ -188,14 +276,19 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
188
276
  const defaults: Record<string, any> = {}
189
277
  for (const field of action.fields) {
190
278
  if (isLineItemsField(field)) {
191
- defaults[field.key] = field.defaultValue ?? []
279
+ const dv = lineItemsDefault(field)
280
+ defaults[field.key] = isPrefillSpec(dv)
281
+ ? buildPrefillRows(dv, record)
282
+ : Array.isArray(dv)
283
+ ? dv
284
+ : []
192
285
  continue
193
286
  }
194
287
  defaults[field.key] = field.defaultValue ?? (field.type === 'boolean' ? false : '')
195
288
  }
196
289
  setFormData(defaults)
197
290
  }
198
- }, [open, action.fields])
291
+ }, [open, action.fields, record])
199
292
 
200
293
  const updateField = (key: string, value: any) => setFormData((prev: Record<string, any>) => ({ ...prev, [key]: value }))
201
294
 
@@ -328,7 +421,7 @@ function renderField(
328
421
  // Repeatable line-items group → row grid (value is an array of row objects).
329
422
  // The header form values flow in so a cell can depend on a header field.
330
423
  if (isLineItemsField(field)) {
331
- return <DynamicLineItems field={field} value={value} onChange={onChange} formValues={formValues} />
424
+ return <DynamicLineItems field={applyPrefillLock(field)} value={value} onChange={onChange} formValues={formValues} />
332
425
  }
333
426
  // Resolve the widget the same way DynamicForm does (explicit widget wins,
334
427
  // else inferred from type) so action modals and the standalone form stay in
@@ -267,7 +267,9 @@ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues
267
267
  // Async searchable picker per row cell — e.g. the account_id column of a
268
268
  // journal entry's debit/credit lines. Same widget as the flat form.
269
269
  if (widget === 'dynamic_select') {
270
- return <DynamicSelectField field={field} value={value} onChange={onChange} dependsValue={dependsValue} />
270
+ const ro = !!(field as { readonly?: boolean; read_only?: boolean }).readonly ||
271
+ !!(field as { readonly?: boolean; read_only?: boolean }).read_only
272
+ return <DynamicSelectField field={field} value={value} onChange={onChange} dependsValue={dependsValue} readonly={ro} />
271
273
  }
272
274
  if (widget === 'select' && (field.ref || getOptionsConfig(field)?.source)) {
273
275
  return (
@@ -162,6 +162,14 @@ export interface DynamicSelectFieldProps {
162
162
  dependsValue?: string
163
163
  /** Overrides the disabled-state hint shown while `dependsValue` is empty. */
164
164
  dependsHint?: string
165
+ /**
166
+ * Renders the picker as a locked, non-interactive display of the current
167
+ * value's resolved label (no popover, no inline-create). Used when the value
168
+ * is fixed by context — e.g. the product of a receive-goods line is dictated
169
+ * by the source document, not chosen. Options are fetched eagerly (not just
170
+ * on open) so the label resolves to the NAME instead of showing the raw id.
171
+ */
172
+ readonly?: boolean
165
173
  }
166
174
 
167
175
  export function DynamicSelectField({
@@ -171,6 +179,7 @@ export function DynamicSelectField({
171
179
  seedOption,
172
180
  dependsValue,
173
181
  dependsHint,
182
+ readonly = false,
174
183
  }: DynamicSelectFieldProps) {
175
184
  const [open, setOpen] = useState(false)
176
185
  const [search, setSearch] = useState('')
@@ -210,8 +219,9 @@ export function DynamicSelectField({
210
219
  filterValue: dependsOn ? scope : undefined,
211
220
  // Don't fetch until the popover opens (and keep fetching as the query
212
221
  // changes while open). A picker blocked by an unset dependency never
213
- // fetches.
214
- enabled: open && !blockedByDependency,
222
+ // fetches. A readonly cell fetches eagerly so its value's label resolves
223
+ // to the name without the user ever opening it.
224
+ enabled: (open || readonly) && !blockedByDependency,
215
225
  })
216
226
 
217
227
  // When the depended-on value changes, the previously-picked option no longer
@@ -272,6 +282,31 @@ export function DynamicSelectField({
272
282
  )
273
283
  }
274
284
 
285
+ // Locked display: the value is fixed by context, so render the resolved
286
+ // label (name) as a disabled control — no popover, no inline-create. While
287
+ // the eager fetch is in flight the label falls back to the seed/raw value,
288
+ // then snaps to the name once options arrive.
289
+ if (readonly) {
290
+ return (
291
+ <Button
292
+ type="button"
293
+ variant="outline"
294
+ role="combobox"
295
+ id={field.key}
296
+ disabled
297
+ aria-readonly="true"
298
+ className="w-full min-w-0 cursor-default justify-start font-normal opacity-100"
299
+ >
300
+ <span className="flex min-w-0 flex-1 items-center gap-2 text-left">
301
+ {hasVisual && value ? <OptionLead option={selectedOption} size={20} /> : null}
302
+ <span className={'min-w-0 flex-1 truncate ' + (selectedLabel ? '' : 'text-muted-foreground')}>
303
+ {selectedLabel || (loading ? 'Cargando…' : field.placeholder || '—')}
304
+ </span>
305
+ </span>
306
+ </Button>
307
+ )
308
+ }
309
+
275
310
  // w-full + min-w-0: as a grid cell child, the row must be allowed to shrink
276
311
  // to the cell. Without min-w-0 the combobox+button row sizes to its content
277
312
  // (the long empty-state placeholder) and overflows the column, pushing the