@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 +12 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +67 -3
- package/dist/dynamic-line-items.js +3 -1
- package/dist/dynamic-select-field.d.ts +9 -1
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +11 -3
- package/package.json +1 -1
- package/src/action-modal-dispatcher.tsx +96 -3
- package/src/dynamic-line-items.tsx +3 -1
- package/src/dynamic-select-field.tsx +37 -2
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;
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|