@object-ui/fields 6.2.3 → 7.1.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.
Files changed (97) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/dist/complex-widgets.test.d.ts +1 -0
  3. package/dist/currency.d.ts +13 -0
  4. package/dist/datetime-widgets.test.d.ts +1 -0
  5. package/dist/field-type-coverage.test.d.ts +1 -0
  6. package/dist/index.cjs +4 -3
  7. package/dist/index.d.ts +300 -2
  8. package/dist/index.js +2460 -1274
  9. package/dist/standard-widgets.test.d.ts +0 -0
  10. package/dist/{packages/fields/src/widgets → widgets}/AddressField.d.ts +2 -1
  11. package/dist/{packages/fields/src/widgets → widgets}/AutoNumberField.d.ts +2 -1
  12. package/dist/{packages/fields/src/widgets → widgets}/AvatarField.d.ts +2 -1
  13. package/dist/{packages/fields/src/widgets → widgets}/BooleanField.d.ts +2 -1
  14. package/dist/widgets/CheckboxesField.d.ts +8 -0
  15. package/dist/{packages/fields/src/widgets → widgets}/CodeField.d.ts +2 -1
  16. package/dist/{packages/fields/src/widgets → widgets}/ColorField.d.ts +2 -1
  17. package/dist/{packages/fields/src/widgets → widgets}/CurrencyField.d.ts +2 -1
  18. package/dist/{packages/fields/src/widgets → widgets}/DateField.d.ts +2 -1
  19. package/dist/{packages/fields/src/widgets → widgets}/DateTimeField.d.ts +2 -1
  20. package/dist/{packages/fields/src/widgets → widgets}/EmailField.d.ts +2 -1
  21. package/dist/{packages/fields/src/widgets → widgets}/FileField.d.ts +2 -1
  22. package/dist/{packages/fields/src/widgets → widgets}/FormulaField.d.ts +2 -1
  23. package/dist/{packages/fields/src/widgets → widgets}/GeolocationField.d.ts +2 -1
  24. package/dist/widgets/GridField.d.ts +123 -0
  25. package/dist/widgets/GridField.test.d.ts +1 -0
  26. package/dist/{packages/fields/src/widgets → widgets}/ImageField.d.ts +2 -1
  27. package/dist/{packages/fields/src/widgets → widgets}/LocationField.d.ts +2 -1
  28. package/dist/{packages/fields/src/widgets → widgets}/LookupField.d.ts +2 -1
  29. package/dist/widgets/MarkdownContent.d.ts +9 -0
  30. package/dist/{packages/fields/src/widgets → widgets}/MasterDetailField.d.ts +2 -1
  31. package/dist/widgets/MultiSelectField.d.ts +9 -0
  32. package/dist/{packages/fields/src/widgets → widgets}/NumberField.d.ts +2 -1
  33. package/dist/{packages/fields/src/widgets → widgets}/ObjectField.d.ts +2 -1
  34. package/dist/{packages/fields/src/widgets → widgets}/PasswordField.d.ts +2 -1
  35. package/dist/{packages/fields/src/widgets → widgets}/PercentField.d.ts +2 -1
  36. package/dist/{packages/fields/src/widgets → widgets}/PhoneField.d.ts +2 -1
  37. package/dist/{packages/fields/src/widgets → widgets}/QRCodeField.d.ts +2 -1
  38. package/dist/widgets/RadioField.d.ts +8 -0
  39. package/dist/{packages/fields/src/widgets → widgets}/RatingField.d.ts +2 -1
  40. package/dist/{packages/fields/src/widgets → widgets}/RecordPickerDialog.d.ts +8 -2
  41. package/dist/{packages/fields/src/widgets → widgets}/RichTextField.d.ts +2 -1
  42. package/dist/{packages/fields/src/widgets → widgets}/SelectField.d.ts +2 -1
  43. package/dist/{packages/fields/src/widgets → widgets}/SignatureField.d.ts +2 -1
  44. package/dist/{packages/fields/src/widgets → widgets}/SliderField.d.ts +2 -1
  45. package/dist/{packages/fields/src/widgets → widgets}/SummaryField.d.ts +2 -1
  46. package/dist/widgets/TagsField.d.ts +8 -0
  47. package/dist/{packages/fields/src/widgets → widgets}/TextAreaField.d.ts +2 -1
  48. package/dist/{packages/fields/src/widgets → widgets}/TextField.d.ts +2 -1
  49. package/dist/{packages/fields/src/widgets → widgets}/TimeField.d.ts +2 -1
  50. package/dist/{packages/fields/src/widgets → widgets}/UrlField.d.ts +2 -1
  51. package/dist/{packages/fields/src/widgets → widgets}/UserField.d.ts +2 -1
  52. package/dist/{packages/fields/src/widgets → widgets}/VectorField.d.ts +2 -1
  53. package/dist/widgets/deriveLookupColumns.d.ts +36 -0
  54. package/dist/widgets/deriveLookupColumns.test.d.ts +1 -0
  55. package/dist/widgets/recentLookups.d.ts +13 -0
  56. package/dist/widgets/recentLookups.test.d.ts +1 -0
  57. package/dist/{packages/fields/src/widgets → widgets}/types.d.ts +1 -1
  58. package/package.json +14 -11
  59. package/src/__tests__/registry-collision.test.ts +91 -0
  60. package/src/__tests__/resolveFieldCurrency.test.ts +20 -0
  61. package/src/coerce-safe-value.test.ts +34 -0
  62. package/src/complex-widgets.test.tsx +58 -7
  63. package/src/currency.ts +14 -0
  64. package/src/field-type-coverage.test.ts +65 -0
  65. package/src/index.tsx +372 -57
  66. package/src/widgets/CheckboxesField.tsx +53 -0
  67. package/src/widgets/CurrencyField.tsx +5 -1
  68. package/src/widgets/GridField.test.tsx +309 -0
  69. package/src/widgets/GridField.tsx +907 -33
  70. package/src/widgets/LookupField.tsx +258 -107
  71. package/src/widgets/MarkdownContent.tsx +19 -0
  72. package/src/widgets/MultiSelectField.tsx +59 -0
  73. package/src/widgets/PercentField.tsx +19 -7
  74. package/src/widgets/RadioField.tsx +41 -0
  75. package/src/widgets/RecordPickerDialog.tsx +22 -18
  76. package/src/widgets/SelectField.tsx +23 -2
  77. package/src/widgets/TagsField.tsx +67 -0
  78. package/src/widgets/deriveLookupColumns.test.ts +74 -0
  79. package/src/widgets/deriveLookupColumns.ts +104 -0
  80. package/src/widgets/recentLookups.test.ts +45 -0
  81. package/src/widgets/recentLookups.ts +47 -0
  82. package/src/widgets/useFieldTranslation.ts +30 -0
  83. package/dist/packages/fields/src/fields-edge-cases.stories.d.ts +0 -45
  84. package/dist/packages/fields/src/fields.stories.d.ts +0 -56
  85. package/dist/packages/fields/src/index.d.ts +0 -286
  86. package/dist/packages/fields/src/record-picker-dialog.stories.d.ts +0 -25
  87. package/dist/packages/fields/src/widgets/GridField.d.ts +0 -7
  88. package/src/fields-edge-cases.stories.tsx +0 -407
  89. package/src/fields.stories.tsx +0 -566
  90. package/src/record-picker-dialog.stories.tsx +0 -160
  91. /package/dist/{packages/fields/src/__tests__/validation-feedback.test.d.ts → __tests__/registry-collision.test.d.ts} +0 -0
  92. /package/dist/{packages/fields/src/complex-widgets.test.d.ts → __tests__/resolveFieldCurrency.test.d.ts} +0 -0
  93. /package/dist/{packages/fields/src/standard-widgets.test.d.ts → __tests__/validation-feedback.test.d.ts} +0 -0
  94. /package/dist/{packages/fields/src/datetime-widgets.test.d.ts → coerce-safe-value.test.d.ts} +0 -0
  95. /package/dist/{packages/fields/src/widgets → widgets}/ImageCropperDialog.d.ts +0 -0
  96. /package/dist/{packages/fields/src/widgets → widgets}/_cell-renderer-bridge.d.ts +0 -0
  97. /package/dist/{packages/fields/src/widgets → widgets}/useFieldTranslation.d.ts +0 -0
@@ -1,63 +1,937 @@
1
- import React from 'react';
1
+ import React, { useCallback, useRef } from 'react';
2
2
  import { FieldWidgetProps } from './types';
3
- import { cn, EmptyValue } from '@object-ui/components';
3
+ import {
4
+ cn,
5
+ Button,
6
+ Input,
7
+ Select,
8
+ SelectContent,
9
+ SelectItem,
10
+ SelectTrigger,
11
+ SelectValue,
12
+ Popover,
13
+ PopoverTrigger,
14
+ PopoverContent,
15
+ Checkbox,
16
+ Label,
17
+ } from '@object-ui/components';
18
+ import { Plus, Trash2, SlidersHorizontal, Maximize2, Copy, GripVertical } from 'lucide-react';
19
+ import { resolveFieldRuleState } from '@object-ui/core';
20
+ import { LookupField } from './LookupField';
4
21
 
5
22
  /**
6
- * GridField - Sub-table/grid data display
7
- * Shows tabular data in a simplified format
8
- * For full editing capabilities, use the grid plugin
23
+ * GridField / LineItemsField — editable child-grid ("line items") widget.
24
+ *
25
+ * A controlled component: `value` is an array of row objects, `onChange`
26
+ * receives the next array. It renders one editable cell per configured
27
+ * column, supports add / delete row, and shows a running total of a numeric
28
+ * column. This is the renderer for the `field:grid` widget and the cell
29
+ * engine behind the master-detail subform (see ADR-0001).
30
+ *
31
+ * Column config (a subset of `GridColumnDefinition`):
32
+ * { field, label?, type?, options?, width?, required?, prefix?, step? }
33
+ * type ∈ 'text' | 'number' | 'currency' | 'date' | 'select'
34
+ *
35
+ * Field-level config (from `GridFieldMetadata`):
36
+ * columns, min_rows, max_rows, allow_add, allow_delete, total_field
9
37
  */
10
- export function GridField({ value, field, readonly, ...props }: FieldWidgetProps<any[]>) {
11
- const gridField = (field || (props as any).schema) as any;
12
- const columns = gridField?.columns || [];
13
38
 
14
- if (!value || !Array.isArray(value)) {
15
- return <EmptyValue />;
39
+ export interface GridColumn {
40
+ field: string;
41
+ label?: string;
42
+ type?: 'text' | 'number' | 'currency' | 'date' | 'select' | 'lookup';
43
+ options?: Array<{ label: string; value: string }>;
44
+ width?: number;
45
+ required?: boolean;
46
+ prefix?: string;
47
+ step?: number;
48
+ /** For `type: 'lookup'` — the referenced object and label/id fields. */
49
+ reference?: string;
50
+ displayField?: string;
51
+ idField?: string;
52
+ multiple?: boolean;
53
+ /**
54
+ * Hidden from the grid by default but revealable via the column chooser.
55
+ * Set by `deriveColumns` for fields beyond the default-visible budget — the
56
+ * data is NOT dropped (it's just collapsed, like Odoo's `optional` columns /
57
+ * Salesforce column personalization), so business-critical fields stay
58
+ * reachable. Required columns are never default-hidden.
59
+ */
60
+ defaultHidden?: boolean;
61
+ /**
62
+ * A computed (read-only) column whose value is derived live from sibling
63
+ * cells via {@link expr} — e.g. an invoice line's `amount = quantity *
64
+ * unit_price`. The grid renders it read-only, recomputes it as the row's
65
+ * inputs change, and writes the result back into the row so it persists
66
+ * (and any running total reflects it). The classic spreadsheet pattern used
67
+ * by QuickBooks / Stripe / NetSuite line grids — nobody types the amount.
68
+ */
69
+ computed?: boolean;
70
+ /** Arithmetic expression for a {@link computed} column. Supports `+ - * / %`,
71
+ * parentheses, numeric literals and field refs (`record.qty` or bare `qty`). */
72
+ expr?: string;
73
+ /** Decimal places to round a computed numeric/currency result to. */
74
+ scale?: number;
75
+ /** For `type: 'lookup'` — when a record is picked, copy its fields into any
76
+ * sibling columns of the same name (e.g. a product's unit_price/description).
77
+ * On by default for lookup columns; set `false` to disable the auto-fill. */
78
+ autofill?: boolean;
79
+ /**
80
+ * CEL predicate: when TRUE for this row, the cell is **read-only** (B2 field
81
+ * rules, generalized to grid cells). Evaluated per row against the row as
82
+ * `record` plus the header as `parent` (so a line locks when
83
+ * `parent.status == 'paid'` *or* on an intra-row condition like
84
+ * `record.kind == 'auto'`). Client-side UX; fails open (stays editable).
85
+ */
86
+ readonlyWhen?: string | { dialect?: string; source: string };
87
+ /**
88
+ * CEL predicate: when TRUE for this row, the cell is **required** (flagged
89
+ * inline-invalid while empty). Same `record` + `parent` scope as
90
+ * {@link readonlyWhen}.
91
+ */
92
+ requiredWhen?: string | { dialect?: string; source: string };
93
+ }
94
+
95
+ type Row = Record<string, any>;
96
+
97
+ const isNumeric = (t?: string) => t === 'number' || t === 'currency';
98
+
99
+ /** Comfortable minimum widths per column type so cells never get crushed; the
100
+ * grid scrolls horizontally instead. Authors can still pin a `width`. */
101
+ const MIN_WIDTH_BY_TYPE: Record<string, number> = {
102
+ text: 160,
103
+ select: 132,
104
+ lookup: 168,
105
+ number: 104,
106
+ currency: 116,
107
+ date: 150,
108
+ };
109
+ const minWidthFor = (c: GridColumn): number => c.width ?? MIN_WIDTH_BY_TYPE[c.type ?? 'text'] ?? 132;
110
+
111
+ /**
112
+ * Per-column sizing. Text columns flex to absorb slack (so the description
113
+ * never gets crushed), while numeric / select / date / lookup columns get a
114
+ * fixed sensible width — the role-based sizing every enterprise line grid uses
115
+ * (Qty stays narrow, Description stays wide). Authors can still pin `width`.
116
+ */
117
+ function widthStyle(c: GridColumn): React.CSSProperties {
118
+ if (c.width) return { width: c.width, minWidth: c.width };
119
+ if ((c.type ?? 'text') === 'text') return { minWidth: 180 }; // flex, no fixed width
120
+ const w = MIN_WIDTH_BY_TYPE[c.type ?? 'text'] ?? 132;
121
+ return { width: w, minWidth: w };
122
+ }
123
+
124
+ // ── Safe arithmetic evaluator for computed columns ──────────────────────────
125
+ // A tiny recursive-descent parser over `+ - * / %`, parentheses, numeric
126
+ // literals and field references (`record.qty` or bare `qty`). Deliberately NOT
127
+ // eval()/Function() — only arithmetic, no code execution. Returns a finite
128
+ // number, or null when the expression is unparseable or any referenced cell is
129
+ // blank/non-numeric (so a computed amount reads "—" until its inputs exist).
130
+ type Tok = { t: 'num' | 'id' | 'op' | 'lp' | 'rp'; v: string };
131
+
132
+ function tokenize(s: string): Tok[] | null {
133
+ const toks: Tok[] = [];
134
+ let i = 0;
135
+ while (i < s.length) {
136
+ const c = s[i];
137
+ if (c === ' ' || c === '\t' || c === '\n') { i++; continue; }
138
+ if ((c >= '0' && c <= '9') || c === '.') {
139
+ let j = i + 1;
140
+ while (j < s.length && ((s[j] >= '0' && s[j] <= '9') || s[j] === '.')) j++;
141
+ toks.push({ t: 'num', v: s.slice(i, j) }); i = j; continue;
142
+ }
143
+ if (/[a-zA-Z_]/.test(c)) {
144
+ let j = i + 1;
145
+ while (j < s.length && /[a-zA-Z0-9_.]/.test(s[j])) j++;
146
+ toks.push({ t: 'id', v: s.slice(i, j) }); i = j; continue;
147
+ }
148
+ if (c === '+' || c === '-' || c === '*' || c === '/' || c === '%') { toks.push({ t: 'op', v: c }); i++; continue; }
149
+ if (c === '(') { toks.push({ t: 'lp', v: c }); i++; continue; }
150
+ if (c === ')') { toks.push({ t: 'rp', v: c }); i++; continue; }
151
+ return null; // unsupported character → bail (treat as non-computable)
152
+ }
153
+ return toks;
154
+ }
155
+
156
+ /** Resolve an identifier (`record.quantity` / `quantity`) to a numeric cell. */
157
+ function resolveRef(id: string, row: Row): number | null {
158
+ const field = id.startsWith('record.') ? id.slice('record.'.length) : id;
159
+ const raw = row?.[field];
160
+ if (raw === null || raw === undefined || raw === '') return null;
161
+ const n = Number(raw);
162
+ return Number.isFinite(n) ? n : null;
163
+ }
164
+
165
+ /** Evaluate an arithmetic `expr` against `row`. null when blank/unparseable. */
166
+ export function evalArith(expr: string, row: Row): number | null {
167
+ const toks = tokenize(expr);
168
+ if (!toks || toks.length === 0) return null;
169
+ let pos = 0;
170
+ let bad = false;
171
+ const peek = () => toks[pos];
172
+ const next = () => toks[pos++];
173
+
174
+ function factor(): number {
175
+ const tk = peek();
176
+ if (!tk) { bad = true; return NaN; }
177
+ if (tk.t === 'op' && tk.v === '-') { next(); return -factor(); }
178
+ if (tk.t === 'op' && tk.v === '+') { next(); return factor(); }
179
+ if (tk.t === 'num') { next(); return Number(tk.v); }
180
+ if (tk.t === 'id') { next(); const v = resolveRef(tk.v, row); if (v === null) { bad = true; return NaN; } return v; }
181
+ if (tk.t === 'lp') {
182
+ next();
183
+ const v = expression();
184
+ const close = next();
185
+ if (!close || close.t !== 'rp') bad = true;
186
+ return v;
187
+ }
188
+ bad = true; return NaN;
189
+ }
190
+ function term(): number {
191
+ let v = factor();
192
+ while (peek() && peek().t === 'op' && (peek().v === '*' || peek().v === '/' || peek().v === '%')) {
193
+ const op = next().v;
194
+ const r = factor();
195
+ v = op === '*' ? v * r : op === '/' ? v / r : v % r;
196
+ }
197
+ return v;
16
198
  }
199
+ function expression(): number {
200
+ let v = term();
201
+ while (peek() && peek().t === 'op' && (peek().v === '+' || peek().v === '-')) {
202
+ const op = next().v;
203
+ const r = term();
204
+ v = op === '+' ? v + r : v - r;
205
+ }
206
+ return v;
207
+ }
208
+ const result = expression();
209
+ if (bad || pos !== toks.length || !Number.isFinite(result)) return null;
210
+ return result;
211
+ }
212
+
213
+ /**
214
+ * Recompute every {@link GridColumn.computed} cell in `row` from its sibling
215
+ * inputs, returning a new row. Called after each edit so computed columns and
216
+ * the running total stay live, and so the computed value persists in the batch.
217
+ */
218
+ /**
219
+ * Build the row patch for a lookup-cell selection: set the FK column to the
220
+ * chosen record's id, and (unless the column opts out with `autofill: false`)
221
+ * copy any of the record's fields whose names match a sibling column — the
222
+ * catalog-typeahead behaviour (pick a product → its unit_price/description fill
223
+ * in). Skips the lookup column itself and computed columns. Pure + exported so
224
+ * it is unit-testable independent of the picker UI.
225
+ */
226
+ export function lookupAutofillPatch(columns: GridColumn[], col: GridColumn, record: any): Row {
227
+ const patch: Row = { [col.field]: record?.value ?? record?.id ?? record?._id };
228
+ if (col.autofill !== false && record && typeof record === 'object') {
229
+ for (const other of columns) {
230
+ if (other.field === col.field || other.computed || other.type === 'lookup') continue;
231
+ const v = record[other.field];
232
+ if (v !== undefined && v !== null && v !== '') patch[other.field] = v;
233
+ }
234
+ }
235
+ return patch;
236
+ }
237
+
238
+ export function computeRow(columns: GridColumn[], row: Row): Row {
239
+ const computedCols = columns.filter((c) => c.computed && c.expr);
240
+ if (computedCols.length === 0) return row;
241
+ const next = { ...row };
242
+ for (const c of computedCols) {
243
+ const v = evalArith(c.expr!, next);
244
+ if (v === null) { next[c.field] = null; continue; }
245
+ const scale = c.scale ?? (c.type === 'currency' ? 2 : undefined);
246
+ next[c.field] = scale != null ? Number(v.toFixed(scale)) : v;
247
+ }
248
+ return next;
249
+ }
250
+
251
+ /** Read-only display text for a cell in list mode (select → option label,
252
+ * currency/number → formatted, empty → em dash). Lookups render separately. */
253
+ function displayText(c: GridColumn, value: any): string {
254
+ if (value === null || value === undefined || value === '') return '—';
255
+ if (c.type === 'select' && Array.isArray(c.options)) {
256
+ const opt = c.options.find((o) => String(o.value) === String(value));
257
+ return opt ? opt.label : String(value);
258
+ }
259
+ if (isNumeric(c.type)) {
260
+ const n = Number(value);
261
+ if (Number.isFinite(n)) return c.type === 'currency' ? `${c.prefix || '¥'}${n.toLocaleString()}` : n.toLocaleString();
262
+ }
263
+ if (Array.isArray(value)) return value.join(', ');
264
+ return String(value);
265
+ }
266
+
267
+ function coerce(type: string | undefined, raw: string): any {
268
+ if (isNumeric(type)) {
269
+ if (raw === '' || raw == null) return null;
270
+ const n = Number(raw);
271
+ return Number.isNaN(n) ? raw : n;
272
+ }
273
+ return raw;
274
+ }
275
+
276
+ /** Sum a numeric column across rows (ignoring blanks/NaN). */
277
+ export function sumColumn(rows: Row[], field: string): number {
278
+ if (!Array.isArray(rows)) return 0;
279
+ return rows.reduce((acc, r) => {
280
+ const v = Number(r?.[field]);
281
+ return acc + (Number.isFinite(v) ? v : 0);
282
+ }, 0);
283
+ }
284
+
285
+ export function GridField({
286
+ value,
287
+ onChange,
288
+ field,
289
+ readonly,
290
+ disabled,
291
+ className,
292
+ onRowExpand,
293
+ displayMode,
294
+ onAdd,
295
+ ...props
296
+ }: FieldWidgetProps<Row[]> & {
297
+ /** When provided, each row shows an "expand" button that opens the row in a
298
+ * full form (the host — e.g. MasterDetailForm — renders the drawer/modal and
299
+ * writes the edited values back). Lets a "fat" child be edited in a real form
300
+ * while the grid stays a quick at-a-glance editor. */
301
+ onRowExpand?: (rowIndex: number) => void;
302
+ /** 'grid' (default) = editable cells; 'list' = read-only rows whose primary
303
+ * action is per-row edit (via `onRowExpand`) and whose Add opens a new row
304
+ * in the full form (via `onAdd`). The form-factor for "fat" children. */
305
+ displayMode?: 'grid' | 'list';
306
+ /** In 'list' mode, "Add" calls this (host opens the full form for a new row)
307
+ * instead of inserting a blank inline row. */
308
+ onAdd?: () => void;
309
+ /** The header/parent record, bound as `parent` when evaluating a column's
310
+ * `readonlyWhen` / `requiredWhen` CEL predicate — so a line cell can react to
311
+ * the header (`parent.status == 'paid'`). Supplied by MasterDetailForm. */
312
+ contextRecord?: Record<string, unknown>;
313
+ }) {
314
+ const cfg = (field || (props as any).schema || {}) as any;
315
+ const allColumns: GridColumn[] = cfg.columns || [];
316
+ const rows: Row[] = Array.isArray(value) ? value : [];
317
+ const contextRecord = (props as any).contextRecord as Record<string, unknown> | undefined;
318
+
319
+ // Per-cell CEL rule state (B2 in grids). A column with no readonlyWhen/
320
+ // requiredWhen resolves to its static flags (cheap fast-path — no engine
321
+ // call). Otherwise evaluate against the row (`record`) + header (`parent`).
322
+ const cellRules = useCallback(
323
+ (c: GridColumn, row: Row): { readonly: boolean; required: boolean } => {
324
+ if (!c.readonlyWhen && !c.requiredWhen) {
325
+ return { readonly: false, required: !!c.required };
326
+ }
327
+ const s = resolveFieldRuleState(
328
+ { readonlyWhen: c.readonlyWhen, requiredWhen: c.requiredWhen },
329
+ (row || {}) as Record<string, unknown>,
330
+ { required: !!c.required, readonly: false },
331
+ undefined,
332
+ contextRecord ? { parent: contextRecord } : undefined,
333
+ );
334
+ return { readonly: s.readonly, required: s.required };
335
+ },
336
+ [contextRecord],
337
+ );
338
+ // List mode: rows are read-only at-a-glance; editing happens in the full form.
339
+ const isList = displayMode === 'list' && !readonly;
340
+
341
+ // Column visibility — a curated default-visible set with the rest revealable
342
+ // via the column chooser (mainstream "personalize columns" pattern). Required
343
+ // columns are always visible; nothing is ever silently dropped.
344
+ const [extraShown, setExtraShown] = React.useState<Set<string>>(() => new Set());
345
+ const optionalColumns = allColumns.filter((c) => c.defaultHidden && !c.required);
346
+ const columns: GridColumn[] = allColumns.filter(
347
+ (c) => !c.defaultHidden || c.required || extraShown.has(c.field),
348
+ );
349
+ const toggleColumn = useCallback((fieldName: string) => {
350
+ setExtraShown((prev) => {
351
+ const next = new Set(prev);
352
+ if (next.has(fieldName)) next.delete(fieldName);
353
+ else next.add(fieldName);
354
+ return next;
355
+ });
356
+ }, []);
357
+
358
+ const allowAdd = cfg.allow_add !== false && !readonly && !disabled;
359
+ const allowDelete = cfg.allow_delete !== false && !readonly && !disabled;
360
+ const allowDuplicate = cfg.allow_duplicate !== false && allowAdd;
361
+ // Per-row "expand to full form" (mainstream hybrid: quick grid + rich form).
362
+ const showExpand = typeof onRowExpand === 'function' && !readonly;
363
+ // Enterprise line grids (NetSuite/SAP/Salesforce) show a line-number column.
364
+ const showLineNumbers = cfg.show_line_numbers !== false;
365
+ const minRows: number = cfg.min_rows ?? 0;
366
+ const maxRows: number | undefined = cfg.max_rows;
367
+ const totalField: string | undefined =
368
+ cfg.total_field || cfg.amount_field || cfg.amountField;
369
+ // When set, the row's order is persisted by stamping `row[sortField] = index`
370
+ // on every change — so drag-reorder survives a reload (the app adds a numeric
371
+ // position field and lists sort by it). Without it, reorder is order-of-entry.
372
+ const sortField: string | undefined = cfg.sort_field;
373
+ // Drag-to-reorder is on for editable grids (off in read-only / list mode).
374
+ const allowReorder = cfg.reorderable !== false && !readonly && !disabled;
375
+
376
+ const emit = useCallback(
377
+ (next: Row[]) => {
378
+ onChange?.(sortField ? next.map((r, i) => ({ ...r, [sortField]: i })) : next);
379
+ },
380
+ [onChange, sortField],
381
+ );
382
+
383
+ const blankRow = useCallback((): Row => {
384
+ const blank: Row = {};
385
+ for (const c of columns) blank[c.field] = null;
386
+ return blank;
387
+ }, [columns]);
388
+
389
+ /**
390
+ * Merge a field patch into a row. `rowIdx === rows.length` targets the
391
+ * trailing "ghost" row (always-present empty line) — editing it materialises
392
+ * a new real row, so the user never has to click "Add line" to keep entering.
393
+ * Computed columns are recomputed for the touched row on every change.
394
+ */
395
+ const applyPatch = useCallback(
396
+ (rowIdx: number, patch: Row) => {
397
+ const isGhost = rowIdx >= rows.length;
398
+ if (isGhost) {
399
+ if (maxRows != null && rows.length >= maxRows) return;
400
+ emit([...rows, computeRow(columns, { ...blankRow(), ...patch })]);
401
+ return;
402
+ }
403
+ emit(rows.map((r, i) => (i === rowIdx ? computeRow(columns, { ...r, ...patch }) : r)));
404
+ },
405
+ [rows, columns, maxRows, blankRow, emit],
406
+ );
407
+
408
+ const applyCell = useCallback(
409
+ (rowIdx: number, field: string, value: any) => applyPatch(rowIdx, { [field]: value }),
410
+ [applyPatch],
411
+ );
17
412
 
413
+ /**
414
+ * A lookup cell selection — set the FK id and auto-fill any sibling cells
415
+ * whose column name matches a field on the chosen record (and isn't itself a
416
+ * lookup/computed column). The catalog-typeahead behaviour every invoicing
417
+ * tool has: pick a product → its unit_price / description drop into the row.
418
+ * Opt out per column with `autofill: false`.
419
+ */
420
+ const applyLookupSelection = useCallback(
421
+ (rowIdx: number, col: GridColumn, record: any) => {
422
+ applyPatch(rowIdx, lookupAutofillPatch(columns, col, record));
423
+ },
424
+ [columns, applyPatch],
425
+ );
426
+
427
+ /** Set a cell to an already-typed value (lookup ids, etc.) without coercion. */
428
+ const setCellValue = useCallback(
429
+ (rowIdx: number, field: string, value: any) => applyCell(rowIdx, field, value),
430
+ [applyCell],
431
+ );
432
+
433
+ const setCell = useCallback(
434
+ (rowIdx: number, col: GridColumn, raw: string) => {
435
+ applyCell(rowIdx, col.field, coerce(col.type, raw));
436
+ },
437
+ [applyCell],
438
+ );
439
+
440
+ const addRow = useCallback(() => {
441
+ if (maxRows != null && rows.length >= maxRows) return;
442
+ emit([...rows, blankRow()]);
443
+ }, [rows, blankRow, maxRows, emit]);
444
+
445
+ // Keyboard navigation across cells (spreadsheet-style): the table is a focus
446
+ // grid. Enter / ArrowUp / ArrowDown move between rows in the same column;
447
+ // Tab / Shift-Tab fall through to the browser's natural row-major order
448
+ // (and into the ever-present ghost row, so tabbing past the last cell starts
449
+ // a new line). Cells carry data-cell="row-col" so we can target neighbours.
450
+ const gridRef = useRef<HTMLTableElement>(null);
451
+ const focusCell = useCallback((rowIdx: number, colIdx: number) => {
452
+ const el = gridRef.current?.querySelector<HTMLElement>(`[data-cell="${rowIdx}-${colIdx}"]`);
453
+ if (el) {
454
+ el.focus();
455
+ if (el instanceof HTMLInputElement) el.select();
456
+ }
457
+ }, []);
458
+ const onCellKeyDown = useCallback(
459
+ (e: React.KeyboardEvent, rowIdx: number, colIdx: number) => {
460
+ if (e.key === 'ArrowDown' || e.key === 'Enter') {
461
+ e.preventDefault();
462
+ focusCell(rowIdx + 1, colIdx);
463
+ } else if (e.key === 'ArrowUp') {
464
+ e.preventDefault();
465
+ if (rowIdx > 0) focusCell(rowIdx - 1, colIdx);
466
+ }
467
+ },
468
+ [focusCell],
469
+ );
470
+
471
+ const removeRow = useCallback(
472
+ (rowIdx: number) => {
473
+ if (rows.length <= minRows) return;
474
+ emit(rows.filter((_, i) => i !== rowIdx));
475
+ },
476
+ [rows, minRows, emit],
477
+ );
478
+
479
+ /** Duplicate a row (id stripped so the copy persists as a new record),
480
+ * inserted directly below the original — handy for near-identical lines. */
481
+ const duplicateRow = useCallback(
482
+ (rowIdx: number) => {
483
+ if (maxRows != null && rows.length >= maxRows) return;
484
+ const src = rows[rowIdx];
485
+ if (!src) return;
486
+ const { id: _i, _id: _i2, recordId: _i3, ...copy } = src as any;
487
+ const next = [...rows];
488
+ next.splice(rowIdx + 1, 0, { ...copy });
489
+ emit(next);
490
+ },
491
+ [rows, maxRows, emit],
492
+ );
493
+
494
+ /** Move a row from one position to another (drag-to-reorder). */
495
+ const moveRow = useCallback(
496
+ (from: number, to: number) => {
497
+ if (from === to || from < 0 || to < 0 || from >= rows.length || to >= rows.length) return;
498
+ const next = [...rows];
499
+ const [moved] = next.splice(from, 1);
500
+ next.splice(to, 0, moved);
501
+ emit(next);
502
+ },
503
+ [rows, emit],
504
+ );
505
+ const dragIndex = useRef<number | null>(null);
506
+
507
+ const hasRowActions = showExpand || allowDelete || allowDuplicate;
508
+ const actionColWidth = ((showExpand ? 1 : 0) + (allowDuplicate ? 1 : 0) + (allowDelete ? 1 : 0)) * 34 + 12;
509
+
510
+ const showTotal = !!totalField;
511
+ const total = showTotal ? sumColumn(rows, totalField!) : 0;
512
+ // Align the running total under the column it sums (not blindly under the
513
+ // last column). The label sits right-aligned immediately to its left.
514
+ const totalColIndex = showTotal ? Math.max(0, columns.findIndex((c) => c.field === totalField)) : -1;
515
+
516
+ // Column chooser — reveal/hide the optional (default-hidden) columns. Only
517
+ // rendered when there are optional columns to manage.
518
+ const columnChooser = optionalColumns.length > 0 ? (
519
+ <Popover>
520
+ <PopoverTrigger asChild>
521
+ <Button
522
+ type="button"
523
+ variant="ghost"
524
+ size="sm"
525
+ className="h-7 gap-1.5 text-xs text-muted-foreground"
526
+ data-testid="line-items-columns"
527
+ >
528
+ <SlidersHorizontal className="h-3.5 w-3.5" />
529
+ Columns
530
+ {extraShown.size > 0 && (
531
+ <span className="rounded-full bg-primary/10 px-1.5 text-[10px] font-medium text-primary">
532
+ +{extraShown.size}
533
+ </span>
534
+ )}
535
+ </Button>
536
+ </PopoverTrigger>
537
+ <PopoverContent align="end" className="w-56 p-2">
538
+ <div className="px-1 pb-1.5 text-xs font-medium text-muted-foreground">Optional columns</div>
539
+ <div className="max-h-64 space-y-0.5 overflow-y-auto">
540
+ {optionalColumns.map((c) => {
541
+ const id = `col-toggle-${c.field}`;
542
+ return (
543
+ <Label
544
+ key={c.field}
545
+ htmlFor={id}
546
+ className="flex cursor-pointer items-center gap-2 rounded-md px-1.5 py-1.5 text-sm font-normal hover:bg-muted"
547
+ >
548
+ <Checkbox
549
+ id={id}
550
+ checked={extraShown.has(c.field)}
551
+ onCheckedChange={() => toggleColumn(c.field)}
552
+ />
553
+ <span className="truncate">{c.label || c.field}</span>
554
+ </Label>
555
+ );
556
+ })}
557
+ </div>
558
+ </PopoverContent>
559
+ </Popover>
560
+ ) : null;
561
+
562
+ // ── Read-only / view rendering ────────────────────────────────────────────
18
563
  if (readonly) {
19
564
  return (
20
- <div className={cn("text-sm", props.className)}>
21
- <span className="text-foreground">{value.length} rows</span>
565
+ <div className={cn('space-y-2', className)}>
566
+ {columnChooser && <div className="flex justify-end">{columnChooser}</div>}
567
+ <div className="border border-border rounded-lg overflow-x-auto" data-testid="line-items-readonly">
568
+ <table className="w-full text-sm">
569
+ <thead className="bg-muted border-b border-border">
570
+ <tr>
571
+ {showLineNumbers && (
572
+ <th className="w-10 px-2 py-2 text-right text-xs font-medium text-muted-foreground">#</th>
573
+ )}
574
+ {columns.map((c) => (
575
+ <th
576
+ key={c.field}
577
+ style={{ minWidth: minWidthFor(c) }}
578
+ className={cn(
579
+ 'px-3 py-2 text-xs font-medium text-muted-foreground whitespace-nowrap',
580
+ isNumeric(c.type) ? 'text-right' : 'text-left',
581
+ )}
582
+ >
583
+ {c.label || c.field}
584
+ </th>
585
+ ))}
586
+ </tr>
587
+ </thead>
588
+ <tbody className="divide-y divide-border">
589
+ {rows.length === 0 ? (
590
+ <tr>
591
+ <td
592
+ colSpan={Math.max(columns.length + (showLineNumbers ? 1 : 0), 1)}
593
+ className="px-3 py-6 text-center text-muted-foreground"
594
+ >
595
+ No items
596
+ </td>
597
+ </tr>
598
+ ) : (
599
+ rows.map((row, rowIdx) => (
600
+ <tr key={rowIdx}>
601
+ {showLineNumbers && (
602
+ <td className="px-2 py-2 text-right text-muted-foreground tabular-nums">{rowIdx + 1}</td>
603
+ )}
604
+ {columns.map((c) => (
605
+ <td
606
+ key={c.field}
607
+ className={cn('px-3 py-2 text-foreground', isNumeric(c.type) && 'text-right tabular-nums')}
608
+ >
609
+ {c.type === 'lookup' && row[c.field] != null && row[c.field] !== '' ? (
610
+ <LookupField
611
+ value={row[c.field]}
612
+ onChange={() => {}}
613
+ readonly
614
+ field={{ reference: c.reference, display_field: c.displayField, id_field: c.idField } as any}
615
+ />
616
+ ) : row[c.field] != null && row[c.field] !== '' ? (
617
+ String(row[c.field])
618
+ ) : (
619
+ '—'
620
+ )}
621
+ </td>
622
+ ))}
623
+ </tr>
624
+ ))
625
+ )}
626
+ </tbody>
627
+ {showTotal && (
628
+ <tfoot className="border-t border-border bg-muted/40">
629
+ <tr>
630
+ <td
631
+ colSpan={Math.max((showLineNumbers ? 1 : 0) + totalColIndex, 1)}
632
+ className="px-3 py-2 text-right text-xs font-medium text-muted-foreground"
633
+ >
634
+ Total
635
+ </td>
636
+ <td className="px-3 py-2 text-right font-semibold text-foreground tabular-nums">
637
+ {total.toLocaleString()}
638
+ </td>
639
+ {columns.length - totalColIndex - 1 > 0 && (
640
+ <td colSpan={columns.length - totalColIndex - 1} />
641
+ )}
642
+ </tr>
643
+ </tfoot>
644
+ )}
645
+ </table>
646
+ </div>
22
647
  </div>
23
648
  );
24
649
  }
25
650
 
26
- // Simple read-only table view
651
+ // ── Editable rendering ──────────────────────────────────────────────────────
652
+ // A trailing "ghost" empty row is always present in grid mode (start-with-one
653
+ // + auto-append): editing it materialises a real row, so the user keeps
654
+ // entering lines without clicking "Add". Index-based keys keep the input
655
+ // mounted across materialisation, so focus/caret survive the transition.
656
+ const hasGhost = !isList && allowAdd && (maxRows == null || rows.length < maxRows);
657
+ const displayRows: Row[] = hasGhost ? [...rows, blankRow()] : rows;
658
+
659
+ /** Cell content: read-only display (list mode / computed columns) or an
660
+ * editable borderless control (spreadsheet feel). */
661
+ const renderCellInput = (c: GridColumn, colIdx: number, rowIdx: number, row: Row) => {
662
+ const val = row?.[c.field];
663
+ // A readonlyWhen-TRUE cell is locked: treat like the form-wide `disabled`.
664
+ const locked = disabled || cellRules(c, row).readonly;
665
+ // List (form-factor) mode → read-only at-a-glance display.
666
+ if (isList) {
667
+ if (c.type === 'lookup' && val != null && val !== '') {
668
+ return (
669
+ <LookupField value={val} onChange={() => {}} readonly
670
+ field={{ reference: c.reference, display_field: c.displayField, id_field: c.idField } as any} />
671
+ );
672
+ }
673
+ return (
674
+ <span className={cn('px-2 text-sm text-foreground', isNumeric(c.type) && 'tabular-nums', (val == null || val === '') && 'text-muted-foreground')}>
675
+ {displayText(c, val)}
676
+ </span>
677
+ );
678
+ }
679
+ // Computed column → read-only, recomputed live, formatted.
680
+ if (c.computed) {
681
+ return (
682
+ <span
683
+ className={cn('block px-2 text-sm tabular-nums', isNumeric(c.type) ? 'text-right' : 'text-left', (val == null || val === '') ? 'text-muted-foreground' : 'text-foreground')}
684
+ title="Computed"
685
+ data-computed={c.field}
686
+ >
687
+ {displayText(c, val)}
688
+ </span>
689
+ );
690
+ }
691
+ if (c.type === 'lookup') {
692
+ return (
693
+ <LookupField
694
+ value={val}
695
+ onChange={(v: any) => setCellValue(rowIdx, c.field, v)}
696
+ onSelectRecord={(rec: any) => applyLookupSelection(rowIdx, c, rec)}
697
+ compact
698
+ field={{ reference: c.reference, display_field: c.displayField, id_field: c.idField, multiple: c.multiple, options: c.options, placeholder: '—' } as any}
699
+ disabled={locked}
700
+ />
701
+ );
702
+ }
703
+ if (c.type === 'select') {
704
+ return (
705
+ <Select value={val != null ? String(val) : ''} onValueChange={(v) => setCell(rowIdx, c, v)} disabled={locked}>
706
+ <SelectTrigger className="h-8 rounded-none border-0 bg-transparent px-2 shadow-none focus:ring-1 focus:ring-ring/60" aria-label={c.label || c.field}>
707
+ <SelectValue placeholder="—" />
708
+ </SelectTrigger>
709
+ <SelectContent>
710
+ {(c.options || []).map((o) => (
711
+ <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
712
+ ))}
713
+ </SelectContent>
714
+ </Select>
715
+ );
716
+ }
717
+ return (
718
+ <div className="relative">
719
+ {c.type === 'currency' && (
720
+ <span className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">{c.prefix || '¥'}</span>
721
+ )}
722
+ <Input
723
+ data-cell={`${rowIdx}-${colIdx}`}
724
+ onKeyDown={(e) => onCellKeyDown(e, rowIdx, colIdx)}
725
+ className={cn(
726
+ 'h-8 rounded-none border-0 bg-transparent px-2 shadow-none focus-visible:ring-1 focus-visible:ring-ring/60',
727
+ c.type === 'currency' && 'pl-6',
728
+ isNumeric(c.type) && 'text-right tabular-nums',
729
+ )}
730
+ type={c.type === 'date' ? 'date' : isNumeric(c.type) ? 'number' : 'text'}
731
+ step={isNumeric(c.type) ? c.step ?? 'any' : undefined}
732
+ aria-label={c.label || c.field}
733
+ value={val != null ? String(val) : ''}
734
+ onChange={(e) => setCell(rowIdx, c, e.target.value)}
735
+ disabled={locked}
736
+ />
737
+ </div>
738
+ );
739
+ };
740
+
27
741
  return (
28
- <div className={cn("border border-border rounded-lg overflow-hidden", props.className)}>
29
- <div className="overflow-auto max-h-60">
30
- <table className="w-full text-sm">
31
- <thead className="bg-muted border-b border-border">
742
+ <div className={cn('space-y-2', className)} data-testid="line-items">
743
+ {columnChooser && <div className="flex justify-end">{columnChooser}</div>}
744
+ <div className="border border-border rounded-lg overflow-x-auto">
745
+ <table ref={gridRef} className="w-full text-sm">
746
+ <thead className="bg-muted/60 border-b border-border">
32
747
  <tr>
33
- {columns.map((col: any, idx: number) => (
748
+ {showLineNumbers && (
749
+ <th className={cn('px-2 py-2 text-right text-xs font-medium text-muted-foreground', allowReorder ? 'w-14' : 'w-10')}>#</th>
750
+ )}
751
+ {columns.map((c) => (
34
752
  <th
35
- key={idx}
36
- className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"
753
+ key={c.field}
754
+ className={cn(
755
+ 'px-2 py-2 text-xs font-medium text-muted-foreground whitespace-nowrap',
756
+ isNumeric(c.type) ? 'text-right' : 'text-left',
757
+ )}
758
+ style={widthStyle(c)}
37
759
  >
38
- {col.label || col.name}
760
+ {c.label || c.field}
761
+ {c.required && !c.computed && <span className="text-destructive"> *</span>}
39
762
  </th>
40
763
  ))}
764
+ {hasRowActions && <th style={{ width: actionColWidth }} />}
41
765
  </tr>
42
766
  </thead>
43
767
  <tbody className="divide-y divide-border">
44
- {value.slice(0, 5).map((row: any, rowIdx: number) => (
45
- <tr key={rowIdx} className="hover:bg-muted/50 transition-colors">
46
- {columns.map((col: any, colIdx: number) => (
47
- <td key={colIdx} className="px-3 py-2 text-foreground">
48
- {row[col.name] != null ? String(row[col.name]) : <EmptyValue />}
49
- </td>
50
- ))}
768
+ {isList && rows.length === 0 ? (
769
+ <tr>
770
+ <td
771
+ colSpan={columns.length + (hasRowActions ? 1 : 0) + (showLineNumbers ? 1 : 0)}
772
+ className="px-3 py-6 text-center text-muted-foreground"
773
+ >
774
+ No items yet — click “{cfg.add_label || 'Add'}” to begin.
775
+ </td>
51
776
  </tr>
52
- ))}
777
+ ) : (
778
+ displayRows.map((row, rowIdx) => {
779
+ const isGhost = hasGhost && rowIdx === rows.length;
780
+ const reorderable = allowReorder && !isGhost && !isList;
781
+ return (
782
+ <tr
783
+ key={rowIdx}
784
+ className={cn('group', !isGhost && 'hover:bg-muted/30')}
785
+ {...(allowReorder && !isGhost
786
+ ? {
787
+ onDragOver: (e: React.DragEvent) => { if (dragIndex.current != null) e.preventDefault(); },
788
+ onDrop: (e: React.DragEvent) => {
789
+ e.preventDefault();
790
+ if (dragIndex.current != null) { moveRow(dragIndex.current, rowIdx); dragIndex.current = null; }
791
+ },
792
+ }
793
+ : {})}
794
+ >
795
+ {showLineNumbers && (
796
+ <td className="px-1 py-1 text-right align-middle text-xs text-muted-foreground tabular-nums">
797
+ <span className="inline-flex items-center justify-end gap-0.5">
798
+ {reorderable && (
799
+ <span
800
+ draggable
801
+ onDragStart={() => { dragIndex.current = rowIdx; }}
802
+ onDragEnd={() => { dragIndex.current = null; }}
803
+ className="cursor-grab text-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100"
804
+ title="Drag to reorder"
805
+ aria-label="Drag to reorder"
806
+ data-testid={`line-items-drag-${rowIdx}`}
807
+ >
808
+ <GripVertical className="h-3.5 w-3.5" />
809
+ </span>
810
+ )}
811
+ <span className={cn(isGhost && 'opacity-30')}>{rowIdx + 1}</span>
812
+ </span>
813
+ </td>
814
+ )}
815
+ {columns.map((c, colIdx) => {
816
+ // Inline validation: a required, non-computed cell that's
817
+ // empty on a real (non-ghost) row flags red in place. The
818
+ // "required" verdict honors a column's `requiredWhen` CEL
819
+ // rule (B2), evaluated against the row + parent header.
820
+ const required = cellRules(c, row).required;
821
+ const invalid = !isGhost && !isList && required && !c.computed && (row[c.field] == null || row[c.field] === '');
822
+ return (
823
+ <td
824
+ key={c.field}
825
+ aria-invalid={invalid || undefined}
826
+ title={invalid ? `${c.label || c.field} is required` : undefined}
827
+ data-testid={invalid ? `line-items-invalid-${rowIdx}-${c.field}` : undefined}
828
+ className={cn(
829
+ 'border-r border-border/40 px-1 py-0.5 align-middle last:border-r-0',
830
+ isList && 'px-2 py-1.5',
831
+ invalid && 'bg-destructive/5 ring-1 ring-inset ring-destructive/50',
832
+ )}
833
+ >
834
+ {renderCellInput(c, colIdx, rowIdx, row)}
835
+ </td>
836
+ );
837
+ })}
838
+ {hasRowActions && (
839
+ <td className="px-1 py-0.5 align-middle whitespace-nowrap">
840
+ <div className="flex items-center justify-center gap-0.5">
841
+ {!isGhost && showExpand && (
842
+ <Button
843
+ type="button"
844
+ variant="ghost"
845
+ size="icon"
846
+ className="h-8 w-8 text-muted-foreground hover:text-foreground"
847
+ aria-label="Open row"
848
+ title="Open full form"
849
+ data-testid={`line-items-expand-${rowIdx}`}
850
+ onClick={() => onRowExpand!(rowIdx)}
851
+ >
852
+ <Maximize2 className="h-3.5 w-3.5" />
853
+ </Button>
854
+ )}
855
+ {!isGhost && allowDuplicate && (
856
+ <Button
857
+ type="button"
858
+ variant="ghost"
859
+ size="icon"
860
+ className={cn(
861
+ 'h-8 w-8 text-muted-foreground hover:text-foreground',
862
+ !isList && 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100',
863
+ )}
864
+ aria-label="Duplicate row"
865
+ title="Duplicate line"
866
+ data-testid={`line-items-duplicate-${rowIdx}`}
867
+ onClick={() => duplicateRow(rowIdx)}
868
+ disabled={disabled || (maxRows != null && rows.length >= maxRows)}
869
+ >
870
+ <Copy className="h-3.5 w-3.5" />
871
+ </Button>
872
+ )}
873
+ {!isGhost && allowDelete && (
874
+ <Button
875
+ type="button"
876
+ variant="ghost"
877
+ size="icon"
878
+ className={cn(
879
+ 'h-8 w-8 text-muted-foreground hover:text-destructive',
880
+ !isList && 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100',
881
+ )}
882
+ aria-label="Remove row"
883
+ data-testid={`line-items-remove-${rowIdx}`}
884
+ onClick={() => removeRow(rowIdx)}
885
+ disabled={disabled || rows.length <= minRows}
886
+ >
887
+ <Trash2 className="h-4 w-4" />
888
+ </Button>
889
+ )}
890
+ </div>
891
+ </td>
892
+ )}
893
+ </tr>
894
+ );
895
+ })
896
+ )}
53
897
  </tbody>
898
+ {showTotal && (
899
+ <tfoot className="border-t border-border bg-muted/40">
900
+ <tr>
901
+ <td
902
+ colSpan={Math.max((showLineNumbers ? 1 : 0) + totalColIndex, 1)}
903
+ className="px-3 py-2 text-right text-xs font-medium text-muted-foreground"
904
+ >
905
+ Total
906
+ </td>
907
+ <td className="px-3 py-2 text-right font-semibold text-foreground tabular-nums" data-testid="line-items-total">
908
+ {total.toLocaleString()}
909
+ </td>
910
+ {(columns.length - totalColIndex - 1 + (hasRowActions ? 1 : 0)) > 0 && (
911
+ <td colSpan={columns.length - totalColIndex - 1 + (hasRowActions ? 1 : 0)} />
912
+ )}
913
+ </tr>
914
+ </tfoot>
915
+ )}
54
916
  </table>
55
917
  </div>
56
- {value.length > 5 && (
57
- <div className="bg-muted px-3 py-2 text-xs text-muted-foreground border-t border-border">
58
- Showing 5 of {value.length} rows
59
- </div>
918
+
919
+ {allowAdd && (
920
+ <Button
921
+ type="button"
922
+ variant="outline"
923
+ size="sm"
924
+ onClick={isList && onAdd ? onAdd : addRow}
925
+ disabled={maxRows != null && rows.length >= maxRows}
926
+ data-testid="line-items-add"
927
+ >
928
+ <Plus className="mr-1.5 h-4 w-4" />
929
+ {cfg.add_label || 'Add line'}
930
+ </Button>
60
931
  )}
61
932
  </div>
62
933
  );
63
934
  }
935
+
936
+ /** Semantic alias — the master-detail subform's child grid. */
937
+ export const LineItemsField = GridField;