@asteby/metacore-runtime-react 18.25.0 → 18.27.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,29 @@
1
1
  # @asteby/metacore-runtime-react
2
2
 
3
+ ## 18.27.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 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.
8
+
9
+ ## 18.26.0
10
+
11
+ ### Minor Changes
12
+
13
+ - d0056f1: Activity log (record history) renders jsonb line items as a table and localizes
14
+ relation field labels.
15
+ - **Line-items render as the shared `CollectionCell` mini-table** instead of raw
16
+ `JSON.stringify`. A jsonb array-of-objects value (e.g. a transfer's `items`,
17
+ directly or JSON-string-encoded) now shows a localized mini-table with
18
+ resolved relation chips (when the backend injects the `{value,label,image}`
19
+ siblings into the snapshot) — matching the detail view. Uses the column's
20
+ declared `item_fields` when present.
21
+ - **Relation field labels localize.** `resolveColumn` now matches the `*_id`
22
+ twin of a resolved relation key (`destination_warehouse` →
23
+ `destination_warehouse_id`), so the diff "Campo" uses the localized column
24
+ label ("Almacén destino") instead of humanizing the key in English
25
+ ("Destination Warehouse").
26
+
3
27
  ## 18.25.0
4
28
 
5
29
  ### 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;AAkEhD,wBAAgB,qBAAqB,CAAC,EAClC,IAAI,EACJ,YAAY,EACZ,MAAM,EACN,KAAK,EACL,MAAM,EACN,QAAQ,EACR,SAAS,GACZ,EAAE,gBAAgB,sCAiDlB"}
@@ -21,6 +21,47 @@ 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
+ // buildPrefillRows projects record[spec.$prefillFromRecord] into modal rows.
42
+ function buildPrefillRows(spec, record) {
43
+ const src = record?.[spec.$prefillFromRecord];
44
+ if (!Array.isArray(src))
45
+ return [];
46
+ const rows = [];
47
+ for (const item of src) {
48
+ if (!item || typeof item !== 'object')
49
+ continue;
50
+ const row = {};
51
+ if (spec.map) {
52
+ for (const [target, from] of Object.entries(spec.map))
53
+ row[target] = item[from];
54
+ }
55
+ if (spec.remaining) {
56
+ const remaining = toNum(item[spec.remaining.of]) - (spec.remaining.minus ? toNum(item[spec.remaining.minus]) : 0);
57
+ if (remaining <= 0)
58
+ continue; // line already fully satisfied → omit
59
+ row[spec.remaining.target] = remaining;
60
+ }
61
+ rows.push(row);
62
+ }
63
+ return rows;
64
+ }
24
65
  export function ActionModalDispatcher({ open, onOpenChange, action, model, record, endpoint, onSuccess, }) {
25
66
  const CustomComponent = useMemo(() => getActionComponent(model, action.key), [model, action.key]);
26
67
  if (CustomComponent) {
@@ -83,14 +124,19 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
83
124
  const defaults = {};
84
125
  for (const field of action.fields) {
85
126
  if (isLineItemsField(field)) {
86
- defaults[field.key] = field.defaultValue ?? [];
127
+ const dv = lineItemsDefault(field);
128
+ defaults[field.key] = isPrefillSpec(dv)
129
+ ? buildPrefillRows(dv, record)
130
+ : Array.isArray(dv)
131
+ ? dv
132
+ : [];
87
133
  continue;
88
134
  }
89
135
  defaults[field.key] = field.defaultValue ?? (field.type === 'boolean' ? false : '');
90
136
  }
91
137
  setFormData(defaults);
92
138
  }
93
- }, [open, action.fields]);
139
+ }, [open, action.fields, record]);
94
140
  const updateField = (key, value) => setFormData((prev) => ({ ...prev, [key]: value }));
95
141
  const execute = async () => {
96
142
  if (action.fields) {
@@ -1 +1 @@
1
- {"version":3,"file":"activity-diff.d.ts","sourceRoot":"","sources":["../src/activity-diff.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAI9B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAO/C;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC1B,EAAE,EAAE,MAAM,CAAA;IACV,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,+EAA+E;IAC/E,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC5B,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,MAAM,CAAA;IACjB,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAA;IACvC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAA;IACtC;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,OAAO,CAAC;QAAC,EAAE,EAAE,OAAO,CAAA;KAAE,CAAC,GAAG,IAAI,CAAA;IAC/D,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,WAAW,EAAE,MAAM,CAAA;CACtB;AAED,MAAM,WAAW,iBAAiB;IAC9B,oCAAoC;IACpC,KAAK,EAAE,aAAa,CAAA;IACpB;;;;OAIG;IACH,OAAO,CAAC,EAAE,gBAAgB,EAAE,CAAA;IAC5B,qDAAqD;IACrD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,sDAAsD;IACtD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,uCAAuC;IACvC,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,yCAAyC;IACzC,SAAS,CAAC,EAAE,MAAM,CAAA;CACrB;AAuGD;;;;GAIG;AACH,eAAO,MAAM,YAAY,EAAE,KAAK,CAAC,EAAE,CAAC,iBAAiB,CA4KpD,CAAA"}
1
+ {"version":3,"file":"activity-diff.d.ts","sourceRoot":"","sources":["../src/activity-diff.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAI9B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAO/C;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC1B,EAAE,EAAE,MAAM,CAAA;IACV,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,+EAA+E;IAC/E,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC5B,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,MAAM,CAAA;IACjB,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAA;IACvC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAA;IACtC;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,OAAO,CAAC;QAAC,EAAE,EAAE,OAAO,CAAA;KAAE,CAAC,GAAG,IAAI,CAAA;IAC/D,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,WAAW,EAAE,MAAM,CAAA;CACtB;AAED,MAAM,WAAW,iBAAiB;IAC9B,oCAAoC;IACpC,KAAK,EAAE,aAAa,CAAA;IACpB;;;;OAIG;IACH,OAAO,CAAC,EAAE,gBAAgB,EAAE,CAAA;IAC5B,qDAAqD;IACrD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,sDAAsD;IACtD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,uCAAuC;IACvC,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,yCAAyC;IACzC,SAAS,CAAC,EAAE,MAAM,CAAA;CACrB;AA8GD;;;;GAIG;AACH,eAAO,MAAM,YAAY,EAAE,KAAK,CAAC,EAAE,CAAC,iBAAiB,CA4KpD,CAAA"}
@@ -81,6 +81,14 @@ function resolveColumn(key, columns) {
81
81
  const exact = columns.find((c) => c.key === key);
82
82
  if (exact)
83
83
  return exact;
84
+ // A resolved relation key is the FK column minus `_id` (the backend injects a
85
+ // `destination_warehouse` sibling next to `destination_warehouse_id`). The
86
+ // served metadata carries the LOCALIZED label on the `*_id` column, so match
87
+ // it — else the label falls back to humanizing the key in English
88
+ // ("Destination Warehouse" instead of "Almacén destino").
89
+ const fk = columns.find((c) => c.key === `${key}_id`);
90
+ if (fk)
91
+ return fk;
84
92
  // A diff key is the physical column (created_by); the served metadata may
85
93
  // only carry the dotted display column for it (created_by.avatar). Match on
86
94
  // the base segment so the diff cell inherits its label and rich renderer.
@@ -1 +1 @@
1
- {"version":3,"file":"activity-value-renderer.d.ts","sourceRoot":"","sources":["../src/activity-value-renderer.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAU9B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AA0E/C,MAAM,WAAW,0BAA0B;IACvC,4DAA4D;IAC5D,KAAK,EAAE,OAAO,CAAA;IACd,+EAA+E;IAC/E,GAAG,CAAC,EAAE,gBAAgB,CAAA;IACtB,qDAAqD;IACrD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,yCAAyC;IACzC,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,6DAA6D;IAC7D,MAAM,CAAC,EAAE,MAAM,CAAA;CAClB;AAED;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,EAAE,KAAK,CAAC,EAAE,CAAC,0BAA0B,CAiRtE,CAAA"}
1
+ {"version":3,"file":"activity-value-renderer.d.ts","sourceRoot":"","sources":["../src/activity-value-renderer.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAU9B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAqG/C,MAAM,WAAW,0BAA0B;IACvC,4DAA4D;IAC5D,KAAK,EAAE,OAAO,CAAA;IACd,+EAA+E;IAC/E,GAAG,CAAC,EAAE,gBAAgB,CAAA;IACtB,qDAAqD;IACrD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,yCAAyC;IACzC,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,6DAA6D;IAC7D,MAAM,CAAC,EAAE,MAAM,CAAA;CAClB;AAED;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,EAAE,KAAK,CAAC,EAAE,CAAC,0BAA0B,CAqStE,CAAA"}
@@ -17,6 +17,32 @@ import { Badge, Avatar, AvatarImage, AvatarFallback, } from '@asteby/metacore-ui
17
17
  import { generateBadgeStyles, getInitials, optionColor, relationChipStyles } from '@asteby/metacore-ui/lib';
18
18
  import { formatDateCell } from './dynamic-columns';
19
19
  import { humanizeToken } from './dynamic-columns-helpers';
20
+ import { CollectionCell } from './collection-cell';
21
+ /**
22
+ * Parses a value into a jsonb line-items array (array of plain objects) when it
23
+ * is one — directly, or from a JSON-encoded string. Returns null otherwise, so
24
+ * scalars / resolved-entity objects keep their existing renderers.
25
+ */
26
+ function asLineItemsArray(value) {
27
+ let arr = value;
28
+ if (typeof value === 'string') {
29
+ const t = value.trim();
30
+ if (!t.startsWith('['))
31
+ return null;
32
+ try {
33
+ arr = JSON.parse(t);
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ if (Array.isArray(arr) &&
40
+ arr.length > 0 &&
41
+ arr.every((v) => v !== null && typeof v === 'object' && !Array.isArray(v))) {
42
+ return arr;
43
+ }
44
+ return null;
45
+ }
20
46
  // ---------------------------------------------------------------------------
21
47
  // Internal helpers (mirror dynamic-columns.tsx private helpers)
22
48
  // ---------------------------------------------------------------------------
@@ -81,6 +107,16 @@ export const ActivityValueRenderer = ({ value, col, timeZone, currency, locale =
81
107
  if (value === null || value === undefined || value === '') {
82
108
  return _jsx("span", { className: "text-muted-foreground", children: "\u2014" });
83
109
  }
110
+ // jsonb line-items (array of objects, e.g. a transfer's `items`) → the shared
111
+ // CollectionCell mini-table (localized headers + resolved ref chips) instead
112
+ // of raw JSON. Uses the column's declared item_fields when present.
113
+ const lineItems = asLineItemsArray(value);
114
+ if (lineItems) {
115
+ return (_jsx(CollectionCell, { value: lineItems, variant: "inline", itemFields: col
116
+ ?.itemFields ??
117
+ col
118
+ ?.item_fields, locale: locale }));
119
+ }
84
120
  // No column metadata → entity chip when the value is a resolved object,
85
121
  // plain string otherwise.
86
122
  if (!col) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "18.25.0",
3
+ "version": "18.27.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -34,8 +34,8 @@
34
34
  "react-i18next": ">=13",
35
35
  "sonner": ">=1.7",
36
36
  "zustand": ">=5",
37
- "@asteby/metacore-ui": "^2.5.2",
38
- "@asteby/metacore-sdk": "^3.2.0"
37
+ "@asteby/metacore-sdk": "^3.2.0",
38
+ "@asteby/metacore-ui": "^2.5.2"
39
39
  },
40
40
  "peerDependenciesMeta": {
41
41
  "@tanstack/react-router": {
@@ -53,6 +53,70 @@ 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
+
77
+ function isPrefillSpec(v: unknown): v is PrefillSpec {
78
+ return (
79
+ typeof v === 'object' &&
80
+ v !== null &&
81
+ typeof (v as { $prefillFromRecord?: unknown }).$prefillFromRecord === 'string'
82
+ )
83
+ }
84
+
85
+ // lineItemsDefault reads the field's declared default tolerating BOTH the
86
+ // camelCase `defaultValue` the host serves and the snake/legacy `default` the
87
+ // raw manifest carries (the kernel maps action-field `default` through without
88
+ // renaming it to `defaultValue`).
89
+ function lineItemsDefault(field: ActionFieldDef): unknown {
90
+ const f = field as { defaultValue?: unknown; default?: unknown }
91
+ return f.defaultValue ?? f.default
92
+ }
93
+
94
+ function toNum(v: unknown): number {
95
+ const n = typeof v === 'number' ? v : parseFloat(String(v ?? ''))
96
+ return Number.isFinite(n) ? n : 0
97
+ }
98
+
99
+ // buildPrefillRows projects record[spec.$prefillFromRecord] into modal rows.
100
+ function buildPrefillRows(spec: PrefillSpec, record: any): Array<Record<string, any>> {
101
+ const src = record?.[spec.$prefillFromRecord]
102
+ if (!Array.isArray(src)) return []
103
+ const rows: Array<Record<string, any>> = []
104
+ for (const item of src) {
105
+ if (!item || typeof item !== 'object') continue
106
+ const row: Record<string, any> = {}
107
+ if (spec.map) {
108
+ for (const [target, from] of Object.entries(spec.map)) row[target] = item[from]
109
+ }
110
+ if (spec.remaining) {
111
+ const remaining = toNum(item[spec.remaining.of]) - (spec.remaining.minus ? toNum(item[spec.remaining.minus]) : 0)
112
+ if (remaining <= 0) continue // line already fully satisfied → omit
113
+ row[spec.remaining.target] = remaining
114
+ }
115
+ rows.push(row)
116
+ }
117
+ return rows
118
+ }
119
+
56
120
  export function ActionModalDispatcher({
57
121
  open,
58
122
  onOpenChange,
@@ -188,14 +252,19 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
188
252
  const defaults: Record<string, any> = {}
189
253
  for (const field of action.fields) {
190
254
  if (isLineItemsField(field)) {
191
- defaults[field.key] = field.defaultValue ?? []
255
+ const dv = lineItemsDefault(field)
256
+ defaults[field.key] = isPrefillSpec(dv)
257
+ ? buildPrefillRows(dv, record)
258
+ : Array.isArray(dv)
259
+ ? dv
260
+ : []
192
261
  continue
193
262
  }
194
263
  defaults[field.key] = field.defaultValue ?? (field.type === 'boolean' ? false : '')
195
264
  }
196
265
  setFormData(defaults)
197
266
  }
198
- }, [open, action.fields])
267
+ }, [open, action.fields, record])
199
268
 
200
269
  const updateField = (key: string, value: any) => setFormData((prev: Record<string, any>) => ({ ...prev, [key]: value }))
201
270
 
@@ -132,6 +132,13 @@ function resolveColumn(key: string, columns?: ColumnDefinition[]): ColumnDefinit
132
132
  if (!columns?.length) return undefined
133
133
  const exact = columns.find((c) => c.key === key)
134
134
  if (exact) return exact
135
+ // A resolved relation key is the FK column minus `_id` (the backend injects a
136
+ // `destination_warehouse` sibling next to `destination_warehouse_id`). The
137
+ // served metadata carries the LOCALIZED label on the `*_id` column, so match
138
+ // it — else the label falls back to humanizing the key in English
139
+ // ("Destination Warehouse" instead of "Almacén destino").
140
+ const fk = columns.find((c) => c.key === `${key}_id`)
141
+ if (fk) return fk
135
142
  // A diff key is the physical column (created_by); the served metadata may
136
143
  // only carry the dotted display column for it (created_by.avatar). Match on
137
144
  // the base segment so the diff cell inherits its label and rich renderer.
@@ -23,6 +23,33 @@ import { generateBadgeStyles, getInitials, optionColor, relationChipStyles } fro
23
23
  import type { ColumnDefinition } from './types'
24
24
  import { formatDateCell } from './dynamic-columns'
25
25
  import { humanizeToken } from './dynamic-columns-helpers'
26
+ import { CollectionCell, type ItemField } from './collection-cell'
27
+
28
+ /**
29
+ * Parses a value into a jsonb line-items array (array of plain objects) when it
30
+ * is one — directly, or from a JSON-encoded string. Returns null otherwise, so
31
+ * scalars / resolved-entity objects keep their existing renderers.
32
+ */
33
+ function asLineItemsArray(value: unknown): Record<string, unknown>[] | null {
34
+ let arr: unknown = value
35
+ if (typeof value === 'string') {
36
+ const t = value.trim()
37
+ if (!t.startsWith('[')) return null
38
+ try {
39
+ arr = JSON.parse(t)
40
+ } catch {
41
+ return null
42
+ }
43
+ }
44
+ if (
45
+ Array.isArray(arr) &&
46
+ arr.length > 0 &&
47
+ arr.every((v) => v !== null && typeof v === 'object' && !Array.isArray(v))
48
+ ) {
49
+ return arr as Record<string, unknown>[]
50
+ }
51
+ return null
52
+ }
26
53
 
27
54
  // ---------------------------------------------------------------------------
28
55
  // Internal helpers (mirror dynamic-columns.tsx private helpers)
@@ -128,6 +155,26 @@ export const ActivityValueRenderer: React.FC<ActivityValueRendererProps> = ({
128
155
  return <span className="text-muted-foreground">—</span>
129
156
  }
130
157
 
158
+ // jsonb line-items (array of objects, e.g. a transfer's `items`) → the shared
159
+ // CollectionCell mini-table (localized headers + resolved ref chips) instead
160
+ // of raw JSON. Uses the column's declared item_fields when present.
161
+ const lineItems = asLineItemsArray(value)
162
+ if (lineItems) {
163
+ return (
164
+ <CollectionCell
165
+ value={lineItems}
166
+ variant="inline"
167
+ itemFields={
168
+ (col as { itemFields?: ItemField[]; item_fields?: ItemField[] })
169
+ ?.itemFields ??
170
+ (col as { itemFields?: ItemField[]; item_fields?: ItemField[] })
171
+ ?.item_fields
172
+ }
173
+ locale={locale}
174
+ />
175
+ )
176
+ }
177
+
131
178
  // No column metadata → entity chip when the value is a resolved object,
132
179
  // plain string otherwise.
133
180
  if (!col) {