@object-ui/plugin-grid 3.0.3 → 3.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 (44) hide show
  1. package/.turbo/turbo-build.log +12 -6
  2. package/dist/index.js +2169 -922
  3. package/dist/index.umd.cjs +9 -3
  4. package/dist/plugin-grid/src/FormulaBar.d.ts +29 -0
  5. package/dist/plugin-grid/src/GroupRow.d.ts +23 -0
  6. package/dist/plugin-grid/src/ImportWizard.d.ts +29 -0
  7. package/dist/plugin-grid/src/ObjectGrid.d.ts +1 -0
  8. package/dist/plugin-grid/src/SplitPaneGrid.d.ts +22 -0
  9. package/dist/plugin-grid/src/components/BulkActionBar.d.ts +12 -0
  10. package/dist/plugin-grid/src/components/RowActionMenu.d.ts +23 -0
  11. package/dist/plugin-grid/src/index.d.ts +22 -2
  12. package/dist/plugin-grid/src/useCellClipboard.d.ts +47 -0
  13. package/dist/plugin-grid/src/useColumnSummary.d.ts +25 -0
  14. package/dist/plugin-grid/src/useGradientColor.d.ts +37 -0
  15. package/dist/plugin-grid/src/useGroupReorder.d.ts +34 -0
  16. package/dist/plugin-grid/src/useGroupedData.d.ts +24 -3
  17. package/package.json +10 -10
  18. package/src/FormulaBar.tsx +151 -0
  19. package/src/GroupRow.tsx +69 -0
  20. package/src/ImportWizard.tsx +412 -0
  21. package/src/ListColumnExtensions.test.tsx +4 -5
  22. package/src/ObjectGrid.tsx +994 -139
  23. package/src/SplitPaneGrid.tsx +120 -0
  24. package/src/VirtualGrid.tsx +2 -2
  25. package/src/__tests__/GroupRow.test.tsx +206 -0
  26. package/src/__tests__/ImportPreview.test.tsx +171 -0
  27. package/src/__tests__/accessorKey-inference.test.tsx +132 -0
  28. package/src/__tests__/airtable-style.test.tsx +508 -0
  29. package/src/__tests__/column-features.test.tsx +490 -0
  30. package/src/__tests__/grid-export.test.tsx +121 -0
  31. package/src/__tests__/mobile-card-view.test.tsx +355 -0
  32. package/src/__tests__/objectdef-enrichment.test.tsx +566 -0
  33. package/src/__tests__/phase11-features.test.tsx +418 -0
  34. package/src/__tests__/row-bulk-actions.test.tsx +413 -0
  35. package/src/__tests__/row-height.test.tsx +160 -0
  36. package/src/__tests__/useGroupedData.test.ts +165 -0
  37. package/src/components/BulkActionBar.tsx +66 -0
  38. package/src/components/RowActionMenu.tsx +91 -0
  39. package/src/index.tsx +46 -2
  40. package/src/useCellClipboard.ts +136 -0
  41. package/src/useColumnSummary.ts +128 -0
  42. package/src/useGradientColor.ts +103 -0
  43. package/src/useGroupReorder.ts +123 -0
  44. package/src/useGroupedData.ts +69 -4
@@ -21,21 +21,74 @@
21
21
  * - Inline editing support
22
22
  */
23
23
 
24
- import React, { useEffect, useState, useCallback } from 'react';
24
+ import React, { useEffect, useState, useCallback, useMemo } from 'react';
25
25
  import type { ObjectGridSchema, DataSource, ListColumn, ViewData } from '@object-ui/types';
26
- import { SchemaRenderer, useDataScope, useNavigationOverlay, useAction } from '@object-ui/react';
27
- import { getCellRenderer } from '@object-ui/fields';
28
- import { Button, NavigationOverlay } from '@object-ui/components';
29
- import { usePullToRefresh } from '@object-ui/mobile';
26
+ import { SchemaRenderer, useDataScope, useNavigationOverlay, useAction, useObjectTranslation, useSafeFieldLabel } from '@object-ui/react';
27
+ import { getCellRenderer, formatCurrency, formatCompactCurrency, formatDate, formatPercent, humanizeLabel } from '@object-ui/fields';
30
28
  import {
31
- DropdownMenu,
32
- DropdownMenuContent,
33
- DropdownMenuItem,
34
- DropdownMenuTrigger,
29
+ Badge, Button, NavigationOverlay,
30
+ Popover, PopoverContent, PopoverTrigger,
35
31
  } from '@object-ui/components';
36
- import { Edit, Trash2, MoreVertical, ChevronRight, ChevronDown } from 'lucide-react';
32
+ import { usePullToRefresh } from '@object-ui/mobile';
33
+ import { evaluatePlainCondition, buildExpandFields } from '@object-ui/core';
34
+ import { ChevronRight, ChevronDown, Download, Rows2, Rows3, Rows4, AlignJustify, Type, Hash, Calendar, CheckSquare, User, Tag, Clock } from 'lucide-react';
37
35
  import { useRowColor } from './useRowColor';
38
36
  import { useGroupedData } from './useGroupedData';
37
+ import { GroupRow } from './GroupRow';
38
+ import { useColumnSummary } from './useColumnSummary';
39
+ import { RowActionMenu, formatActionLabel } from './components/RowActionMenu';
40
+ import { BulkActionBar } from './components/BulkActionBar';
41
+
42
+ // Default English fallback translations for the grid
43
+ const GRID_DEFAULT_TRANSLATIONS: Record<string, string> = {
44
+ 'grid.actions': 'Actions',
45
+ 'grid.edit': 'Edit',
46
+ 'grid.delete': 'Delete',
47
+ 'grid.export': 'Export',
48
+ 'grid.exportAs': 'Export as {{format}}',
49
+ 'grid.loading': 'Loading grid...',
50
+ 'grid.errorLoading': 'Error loading grid',
51
+ 'grid.pullToRefresh': 'Pull to refresh',
52
+ 'grid.refreshing': 'Refreshing…',
53
+ 'grid.openRecord': 'Open record',
54
+ };
55
+
56
+ /**
57
+ * Safe wrapper for useObjectTranslation that falls back to English defaults
58
+ * when I18nProvider is not available (e.g., standalone usage).
59
+ */
60
+ function useGridTranslation() {
61
+ try {
62
+ const result = useObjectTranslation();
63
+ const testValue = result.t('grid.actions');
64
+ if (testValue === 'grid.actions') {
65
+ return {
66
+ t: (key: string, options?: Record<string, unknown>) => {
67
+ let value = GRID_DEFAULT_TRANSLATIONS[key] || key;
68
+ if (options) {
69
+ for (const [k, v] of Object.entries(options)) {
70
+ value = value.replace(`{{${k}}}`, String(v));
71
+ }
72
+ }
73
+ return value;
74
+ },
75
+ };
76
+ }
77
+ return { t: result.t };
78
+ } catch {
79
+ return {
80
+ t: (key: string, options?: Record<string, unknown>) => {
81
+ let value = GRID_DEFAULT_TRANSLATIONS[key] || key;
82
+ if (options) {
83
+ for (const [k, v] of Object.entries(options)) {
84
+ value = value.replace(`{{${k}}}`, String(v));
85
+ }
86
+ }
87
+ return value;
88
+ },
89
+ };
90
+ }
91
+ }
39
92
 
40
93
  export interface ObjectGridProps {
41
94
  schema: ObjectGridSchema;
@@ -49,6 +102,7 @@ export interface ObjectGridProps {
49
102
  onRowSave?: (rowIndex: number, changes: Record<string, any>, row: any) => void | Promise<void>;
50
103
  onBatchSave?: (changes: Array<{ rowIndex: number; changes: Record<string, any>; row: any }>) => void | Promise<void>;
51
104
  onRowSelect?: (selectedRows: any[]) => void;
105
+ onAddRecord?: () => void;
52
106
  }
53
107
 
54
108
  /**
@@ -117,14 +171,48 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
117
171
  onCellChange,
118
172
  onRowSave,
119
173
  onBatchSave,
174
+ onAddRecord,
120
175
  ...rest
121
176
  }) => {
122
177
  const [data, setData] = useState<any[]>([]);
123
178
  const [loading, setLoading] = useState(true);
124
179
  const [error, setError] = useState<Error | null>(null);
180
+ const { t } = useGridTranslation();
181
+ const { fieldLabel: resolveFieldLabel } = useSafeFieldLabel();
125
182
  const [objectSchema, setObjectSchema] = useState<any>(null);
126
183
  const [useCardView, setUseCardView] = useState(false);
127
184
  const [refreshKey, setRefreshKey] = useState(0);
185
+ const [showExport, setShowExport] = useState(false);
186
+ const [rowHeightMode, setRowHeightMode] = useState<'compact' | 'short' | 'medium' | 'tall' | 'extra_tall'>(schema.rowHeight ?? 'compact');
187
+ const [selectedRows, setSelectedRows] = useState<any[]>([]);
188
+
189
+ // Column state persistence (order and widths)
190
+ const columnStorageKey = React.useMemo(() => {
191
+ return schema.id
192
+ ? `grid-columns-${schema.objectName}-${schema.id}`
193
+ : `grid-columns-${schema.objectName}`;
194
+ }, [schema.objectName, schema.id]);
195
+
196
+ const [columnState, setColumnState] = useState<{
197
+ order?: string[];
198
+ widths?: Record<string, number>;
199
+ }>(() => {
200
+ try {
201
+ const saved = localStorage.getItem(columnStorageKey);
202
+ return saved ? JSON.parse(saved) : {};
203
+ } catch {
204
+ return {};
205
+ }
206
+ });
207
+
208
+ const saveColumnState = useCallback((state: typeof columnState) => {
209
+ setColumnState(state);
210
+ try {
211
+ localStorage.setItem(columnStorageKey, JSON.stringify(state));
212
+ } catch (e) {
213
+ console.warn('Failed to persist column state:', e);
214
+ }
215
+ }, [columnStorageKey]);
128
216
 
129
217
  const handlePullRefresh = useCallback(async () => {
130
218
  setRefreshKey(k => k + 1);
@@ -200,6 +288,34 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
200
288
  }
201
289
  }, [hasInlineData, dataConfig]);
202
290
 
291
+ // --- Inline data: still fetch objectSchema for type-aware rendering ---
292
+ // When data is inline (provider: 'value'), we skip the data fetch but still need
293
+ // the object schema to resolve field types (lookup, select, currency, etc.) and
294
+ // enable proper CellRenderer selection.
295
+ useEffect(() => {
296
+ if (!hasInlineData) return;
297
+ if (!objectName || !dataSource) return;
298
+
299
+ let cancelled = false;
300
+
301
+ const fetchSchema = async () => {
302
+ try {
303
+ const schemaData = await dataSource.getObjectSchema(objectName);
304
+ if (!cancelled) {
305
+ setObjectSchema(schemaData);
306
+ }
307
+ } catch (err) {
308
+ // Schema fetch failure for inline data is non-fatal; columns will
309
+ // still fall back to heuristic inference.
310
+ console.warn(`[ObjectGrid] Failed to fetch objectSchema for inline data (objectName: ${objectName}):`, err);
311
+ }
312
+ };
313
+
314
+ fetchSchema();
315
+
316
+ return () => { cancelled = true; };
317
+ }, [hasInlineData, objectName, dataSource]);
318
+
203
319
  // --- Unified async data loading effect ---
204
320
  // Combines schema fetch + data fetch into a single async flow with AbortController.
205
321
  // This avoids the fragile "chained effects" pattern where Effect 1 sets objectSchema,
@@ -218,14 +334,14 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
218
334
  let resolvedSchema: any = null;
219
335
  const cols = normalizeColumns(schemaColumns) || schemaFields;
220
336
 
221
- if (cols && objectName) {
222
- // We have explicit columns use a minimal schema stub
223
- resolvedSchema = { name: objectName, fields: {} };
224
- } else if (objectName && dataSource) {
225
- // Fetch full schema from DataSource
337
+ if (objectName && dataSource) {
338
+ // Always fetch full schema for field type metadata (enables rich type-aware rendering)
226
339
  const schemaData = await dataSource.getObjectSchema(objectName);
227
340
  if (cancelled) return;
228
341
  resolvedSchema = schemaData;
342
+ } else if (cols && objectName) {
343
+ // Fallback: minimal schema stub when no dataSource available
344
+ resolvedSchema = { name: objectName, fields: {} };
229
345
  } else if (!objectName) {
230
346
  throw new Error('Object name required for data fetching');
231
347
  } else {
@@ -273,6 +389,12 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
273
389
  params.$orderby = `${(schema.defaultSort as any).field} ${(schema.defaultSort as any).order}`;
274
390
  }
275
391
 
392
+ // Auto-inject $expand for lookup/master_detail fields
393
+ const expand = buildExpandFields(resolvedSchema?.fields, schemaColumns ?? schemaFields);
394
+ if (expand.length > 0) {
395
+ params.$expand = expand;
396
+ }
397
+
276
398
  const result = await dataSource.find(objectName, params);
277
399
  if (cancelled) return;
278
400
  setData(result.data || []);
@@ -310,10 +432,154 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
310
432
  // --- Row color support ---
311
433
  const getRowClassName = useRowColor(schema.rowColor);
312
434
 
435
+ // --- Conditional formatting support ---
436
+ const getRowStyle = useCallback((row: Record<string, unknown>): React.CSSProperties | undefined => {
437
+ const rules = schema.conditionalFormatting;
438
+ if (!rules || rules.length === 0) return undefined;
439
+ for (const rule of rules) {
440
+ let match = false;
441
+ const expression =
442
+ ('condition' in rule ? (rule as any).condition : undefined)
443
+ || ('expression' in rule ? (rule as any).expression : undefined)
444
+ || undefined;
445
+ if (expression) {
446
+ match = evaluatePlainCondition(expression, row as Record<string, any>);
447
+ } else if ('field' in rule && 'operator' in rule && (rule as any).field && (rule as any).operator) {
448
+ const r = rule as any;
449
+ const fieldValue = row[r.field];
450
+ switch (r.operator) {
451
+ case 'equals': match = fieldValue === r.value; break;
452
+ case 'not_equals': match = fieldValue !== r.value; break;
453
+ case 'contains': match = typeof fieldValue === 'string' && typeof r.value === 'string' && fieldValue.includes(r.value); break;
454
+ case 'greater_than': match = typeof fieldValue === 'number' && typeof r.value === 'number' && fieldValue > r.value; break;
455
+ case 'less_than': match = typeof fieldValue === 'number' && typeof r.value === 'number' && fieldValue < r.value; break;
456
+ case 'in': match = Array.isArray(r.value) && r.value.includes(fieldValue); break;
457
+ }
458
+ }
459
+ if (match) {
460
+ const style: React.CSSProperties = {};
461
+ if ('style' in rule && (rule as any).style) Object.assign(style, (rule as any).style);
462
+ if ('backgroundColor' in rule && (rule as any).backgroundColor) style.backgroundColor = (rule as any).backgroundColor;
463
+ if ('textColor' in rule && (rule as any).textColor) style.color = (rule as any).textColor;
464
+ if ('borderColor' in rule && (rule as any).borderColor) style.borderColor = (rule as any).borderColor;
465
+ return style;
466
+ }
467
+ }
468
+ return undefined;
469
+ }, [schema.conditionalFormatting]);
470
+
313
471
  // --- Grouping support ---
314
472
  const { groups, isGrouped, toggleGroup } = useGroupedData(schema.grouping, data);
315
473
 
474
+ // --- Column summary support ---
475
+ const summaryColumns = React.useMemo(() => {
476
+ const cols = normalizeColumns(schema.columns);
477
+ if (cols && cols.length > 0 && typeof cols[0] === 'object') {
478
+ return cols as ListColumn[];
479
+ }
480
+ return undefined;
481
+ }, [schema.columns]);
482
+ const { summaries, hasSummary } = useColumnSummary(summaryColumns, data);
483
+
316
484
  const generateColumns = useCallback(() => {
485
+ // Map field type to column header icon (Airtable-style)
486
+ const getTypeIcon = (fieldType: string | null): React.ReactNode => {
487
+ if (!fieldType) return <Type className="h-3.5 w-3.5" />;
488
+ const iconMap: Record<string, React.ReactNode> = {
489
+ text: <Type className="h-3.5 w-3.5" />,
490
+ number: <Hash className="h-3.5 w-3.5" />,
491
+ currency: <Hash className="h-3.5 w-3.5" />,
492
+ percent: <Hash className="h-3.5 w-3.5" />,
493
+ date: <Calendar className="h-3.5 w-3.5" />,
494
+ datetime: <Clock className="h-3.5 w-3.5" />,
495
+ boolean: <CheckSquare className="h-3.5 w-3.5" />,
496
+ user: <User className="h-3.5 w-3.5" />,
497
+ select: <Tag className="h-3.5 w-3.5" />,
498
+ };
499
+ return iconMap[fieldType] || <Type className="h-3.5 w-3.5" />;
500
+ };
501
+
502
+ // Auto-infer column type from field name and data values (Airtable-style)
503
+ const inferColumnType = (col: ListColumn): string | null => {
504
+ if (col.type) return col.type; // Explicit type takes priority
505
+
506
+ const fieldLower = col.field.toLowerCase();
507
+
508
+ // Infer boolean fields
509
+ const booleanFields = ['completed', 'is_completed', 'done', 'active', 'enabled', 'archived'];
510
+ if (booleanFields.some(f => fieldLower === f || fieldLower === `is_${f}`)) {
511
+ return 'boolean';
512
+ }
513
+
514
+ // Infer datetime fields (fields with time component: created_time, modified_time, *_at patterns)
515
+ const datetimePatterns = ['created_time', 'modified_time', 'updated_time', 'created_at', 'updated_at', 'modified_at', 'last_login', 'logged_at'];
516
+ if (datetimePatterns.some(p => fieldLower === p || fieldLower.endsWith(`_${p}`))) {
517
+ return 'datetime';
518
+ }
519
+
520
+ // Infer date fields from name patterns
521
+ const datePatterns = ['date', 'due', 'created', 'updated', 'deadline', 'start', 'end', 'expires'];
522
+ if (datePatterns.some(p => fieldLower.includes(p))) {
523
+ // Verify with data: check if sample values look like dates
524
+ if (data.length > 0) {
525
+ const sample = data.find(row => row[col.field] != null)?.[col.field];
526
+ if (typeof sample === 'string' && !isNaN(Date.parse(sample))) {
527
+ return 'date';
528
+ }
529
+ }
530
+ return 'date';
531
+ }
532
+
533
+ // Infer percent fields from name patterns
534
+ const percentFields = ['probability', 'percent', 'percentage', 'completion', 'progress', 'rate'];
535
+ if (percentFields.some(f => fieldLower.includes(f))) {
536
+ if (data.length > 0) {
537
+ const sample = data.find(row => row[col.field] != null)?.[col.field];
538
+ if (typeof sample === 'number') {
539
+ return 'percent';
540
+ }
541
+ }
542
+ }
543
+
544
+ // Infer select/badge fields (status, priority, category, etc.)
545
+ const selectFields = ['status', 'priority', 'category', 'stage', 'type', 'severity', 'level'];
546
+ if (selectFields.some(f => fieldLower.includes(f))) {
547
+ if (data.length > 0) {
548
+ const uniqueValues = new Set(data.map(row => row[col.field]).filter(Boolean));
549
+ if (uniqueValues.size > 0 && uniqueValues.size <= 10) {
550
+ return 'select';
551
+ }
552
+ }
553
+ }
554
+
555
+ // Infer user/assignee fields
556
+ const userFields = ['assignee', 'owner', 'author', 'reporter', 'creator', 'user'];
557
+ if (userFields.some(f => fieldLower.includes(f))) {
558
+ return 'user';
559
+ }
560
+
561
+ // Infer currency/amount fields
562
+ const currencyFields = ['amount', 'price', 'total', 'revenue', 'cost', 'budget', 'salary'];
563
+ if (currencyFields.some(f => fieldLower.includes(f))) {
564
+ if (data.length > 0) {
565
+ const sample = data.find(row => row[col.field] != null)?.[col.field];
566
+ if (typeof sample === 'number') {
567
+ return 'currency';
568
+ }
569
+ }
570
+ }
571
+
572
+ // Fallback: detect ISO date strings in data values (catch-all for unmatched field names)
573
+ if (data.length > 0) {
574
+ const sample = data.find(row => row[col.field] != null)?.[col.field];
575
+ if (typeof sample === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(sample)) {
576
+ return 'datetime';
577
+ }
578
+ }
579
+
580
+ return null;
581
+ };
582
+
317
583
  // Use normalized columns (support both new and legacy)
318
584
  const cols = normalizeColumns(schemaColumns);
319
585
 
@@ -323,9 +589,29 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
323
589
  if (cols.length > 0 && typeof cols[0] === 'object' && cols[0] !== null) {
324
590
  const firstCol = cols[0] as any;
325
591
 
326
- // Already in data-table format - use as-is
592
+ // Already in data-table format - apply type inference for columns without custom cell renderers
327
593
  if ('accessorKey' in firstCol) {
328
- return cols;
594
+ return (cols as any[]).map((col) => {
595
+ if (col.cell) return col; // already has custom renderer
596
+
597
+ const syntheticCol: ListColumn = { field: col.accessorKey, label: col.header, type: col.type };
598
+ const inferredType = inferColumnType(syntheticCol);
599
+ if (!inferredType) return col;
600
+
601
+ const CellRenderer = getCellRenderer(inferredType);
602
+ const fieldMeta: Record<string, any> = { name: col.accessorKey, type: inferredType };
603
+
604
+ if (inferredType === 'select') {
605
+ const uniqueValues = Array.from(new Set(data.map(row => row[col.accessorKey]).filter(Boolean)));
606
+ fieldMeta.options = uniqueValues.map((v: any) => ({ value: v, label: humanizeLabel(String(v)) }));
607
+ }
608
+
609
+ return {
610
+ ...col,
611
+ headerIcon: getTypeIcon(inferredType),
612
+ cell: (value: any) => <CellRenderer value={value} field={fieldMeta as any} />,
613
+ };
614
+ });
329
615
  }
330
616
 
331
617
  // ListColumn format - convert to data-table format with full feature support
@@ -333,24 +619,51 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
333
619
  return (cols as ListColumn[])
334
620
  .filter((col) => col?.field && typeof col.field === 'string' && !col.hidden)
335
621
  .map((col, colIndex) => {
336
- const header = col.label || col.field.charAt(0).toUpperCase() + col.field.slice(1).replace(/_/g, ' ');
622
+ const rawHeader = col.label || col.field.charAt(0).toUpperCase() + col.field.slice(1).replace(/_/g, ' ');
623
+ const header = schema.objectName ? resolveFieldLabel(schema.objectName, col.field, rawHeader) : rawHeader;
337
624
 
338
625
  // Build custom cell renderer based on column configuration
339
626
  let cellRenderer: ((value: any, row: any) => React.ReactNode) | undefined;
340
627
 
341
- // Type-based cell renderer (e.g., "currency", "date", "boolean")
342
- const CellRenderer = col.type ? getCellRenderer(col.type) : null;
628
+ // Type-based cell renderer: explicit col type > objectDef type > heuristic inference
629
+ const objectDefField = objectSchema?.fields?.[col.field];
630
+ const inferredType = col.type || objectDefField?.type || inferColumnType({ field: col.field }) || null;
631
+ const CellRenderer = inferredType ? getCellRenderer(inferredType) : null;
343
632
 
344
- if (col.link && col.action) {
633
+ // Build field metadata for cell renderers with objectDef enrichment
634
+ const fieldMeta: Record<string, any> = { name: col.field, type: inferredType || 'text' };
635
+ // Merge objectDef field properties (options with colors, currency, precision, etc.)
636
+ if (objectDefField) {
637
+ if (objectDefField.label) fieldMeta.label = objectDefField.label;
638
+ if (objectDefField.currency) fieldMeta.currency = objectDefField.currency;
639
+ if (objectDefField.precision !== undefined) fieldMeta.precision = objectDefField.precision;
640
+ if (objectDefField.format) fieldMeta.format = objectDefField.format;
641
+ if (objectDefField.options) fieldMeta.options = objectDefField.options;
642
+ }
643
+ // Auto-generate options from data for inferred select without existing options
644
+ if (inferredType === 'select' && !fieldMeta.options) {
645
+ const uniqueValues = Array.from(new Set(data.map(row => row[col.field]).filter(Boolean)));
646
+ fieldMeta.options = uniqueValues.map(v => ({ value: v, label: humanizeLabel(String(v)) }));
647
+ }
648
+ if ((col as any).options) {
649
+ fieldMeta.options = (col as any).options;
650
+ }
651
+
652
+ // Auto-link primary field (first column) to record detail (Airtable-style)
653
+ const isPrimaryField = colIndex === 0 && !col.link && !col.action;
654
+ const isLinked = col.link || isPrimaryField;
655
+
656
+ if ((col.link && col.action) || (isPrimaryField && col.action)) {
345
657
  // Both link and action: link takes priority for navigation, action executes on secondary interaction
346
658
  cellRenderer = (value: any, row: any) => {
347
659
  const displayContent = CellRenderer
348
- ? <CellRenderer value={value} field={{ name: col.field, type: col.type || 'text' } as any} />
349
- : (value != null && value !== '' ? String(value) : <span className="text-muted-foreground">-</span>);
660
+ ? <CellRenderer value={value} field={fieldMeta as any} />
661
+ : (value != null && value !== '' ? String(value) : <span className="text-muted-foreground/50 text-xs italic">—</span>);
350
662
  return (
351
663
  <button
352
664
  type="button"
353
665
  className="text-primary font-medium underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
666
+ data-testid={isPrimaryField ? 'primary-field-link' : 'link-cell'}
354
667
  onClick={(e) => {
355
668
  e.stopPropagation();
356
669
  navigation.handleClick(row);
@@ -360,16 +673,17 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
360
673
  </button>
361
674
  );
362
675
  };
363
- } else if (col.link) {
676
+ } else if (isLinked) {
364
677
  // Link column: clicking navigates to the record detail
365
678
  cellRenderer = (value: any, row: any) => {
366
679
  const displayContent = CellRenderer
367
- ? <CellRenderer value={value} field={{ name: col.field, type: col.type || 'text' } as any} />
368
- : (value != null && value !== '' ? String(value) : <span className="text-muted-foreground">-</span>);
680
+ ? <CellRenderer value={value} field={fieldMeta as any} />
681
+ : (value != null && value !== '' ? String(value) : <span className="text-muted-foreground/50 text-xs italic">—</span>);
369
682
  return (
370
683
  <button
371
684
  type="button"
372
685
  className="text-primary font-medium underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
686
+ data-testid={isPrimaryField ? 'primary-field-link' : 'link-cell'}
373
687
  onClick={(e) => {
374
688
  e.stopPropagation();
375
689
  navigation.handleClick(row);
@@ -380,15 +694,14 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
380
694
  );
381
695
  };
382
696
  } else if (col.action) {
383
- // Action column: clicking executes the registered action
697
+ // Action column: render as action button
384
698
  cellRenderer = (value: any, row: any) => {
385
- const displayContent = CellRenderer
386
- ? <CellRenderer value={value} field={{ name: col.field, type: col.type || 'text' } as any} />
387
- : (value != null && value !== '' ? String(value) : <span className="text-muted-foreground">-</span>);
388
699
  return (
389
- <button
390
- type="button"
391
- className="text-primary underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
700
+ <Button
701
+ variant="outline"
702
+ size="sm"
703
+ className="h-7 text-xs"
704
+ data-testid="action-cell"
392
705
  onClick={(e) => {
393
706
  e.stopPropagation();
394
707
  executeAction({
@@ -397,27 +710,49 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
397
710
  });
398
711
  }}
399
712
  >
400
- {displayContent}
401
- </button>
713
+ {formatActionLabel(col.action!)}
714
+ </Button>
402
715
  );
403
716
  };
404
717
  } else if (CellRenderer) {
405
718
  // Type-only cell renderer (no link/action)
406
719
  cellRenderer = (value: any) => (
407
- <CellRenderer value={value} field={{ name: col.field, type: col.type || 'text' } as any} />
720
+ <CellRenderer value={value} field={fieldMeta as any} />
408
721
  );
409
722
  } else {
410
723
  // Default renderer with empty value handling
411
724
  cellRenderer = (value: any) => (
412
725
  value != null && value !== ''
413
726
  ? <span>{String(value)}</span>
414
- : <span className="text-muted-foreground">-</span>
727
+ : <span className="text-muted-foreground/50 text-xs italic">—</span>
415
728
  );
416
729
  }
417
730
 
731
+ // Wrap with prefix compound cell renderer (Airtable-style: [Badge] Text in same cell)
732
+ const prefixConfig = (col as any).prefix;
733
+ if (prefixConfig?.field) {
734
+ const baseCellRenderer = cellRenderer;
735
+ const PrefixRenderer = prefixConfig.type === 'badge' ? getCellRenderer('select') : null;
736
+ cellRenderer = (value: any, row: any) => {
737
+ const prefixValue = row[prefixConfig.field];
738
+ const prefixEl = prefixValue != null && prefixValue !== ''
739
+ ? PrefixRenderer
740
+ ? <PrefixRenderer value={prefixValue} field={{ name: prefixConfig.field, type: 'select' } as any} />
741
+ : <span className="text-muted-foreground text-xs mr-1.5">{String(prefixValue)}</span>
742
+ : null;
743
+ return (
744
+ <span className="flex items-center gap-1.5">
745
+ {prefixEl}
746
+ {baseCellRenderer(value, row)}
747
+ </span>
748
+ );
749
+ };
750
+ }
751
+
418
752
  // Auto-infer alignment from field type if not explicitly set
419
753
  const numericTypes = ['number', 'currency', 'percent'];
420
- const inferredAlign = col.align || (col.type && numericTypes.includes(col.type) ? 'right' as const : undefined);
754
+ const effectiveType = inferredType || col.type;
755
+ const inferredAlign = col.align || (effectiveType && numericTypes.includes(effectiveType) ? 'right' as const : undefined);
421
756
 
422
757
  // Determine if column should be hidden on mobile
423
758
  const isEssential = colIndex === 0 || (col as any).essential === true;
@@ -425,6 +760,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
425
760
  return {
426
761
  header,
427
762
  accessorKey: col.field,
763
+ headerIcon: getTypeIcon(inferredType),
428
764
  ...(!isEssential && { className: 'hidden sm:table-cell' }),
429
765
  ...(col.width && { width: col.width }),
430
766
  ...(inferredAlign && { align: inferredAlign }),
@@ -432,19 +768,83 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
432
768
  ...(col.resizable !== undefined && { resizable: col.resizable }),
433
769
  ...(col.wrap !== undefined && { wrap: col.wrap }),
434
770
  ...(cellRenderer && { cell: cellRenderer }),
771
+ ...(col.pinned && { pinned: col.pinned }),
435
772
  };
436
773
  });
437
774
  }
438
775
  }
439
776
 
440
- // String array format - filter out invalid entries
777
+ // String array format - enrich with objectDef field metadata for type-aware rendering
441
778
  return (cols as string[])
442
779
  .filter((fieldName) => typeof fieldName === 'string' && fieldName.trim().length > 0)
443
- .map((fieldName) => {
444
- const fieldLabel = objectSchema?.fields?.[fieldName]?.label;
780
+ .map((fieldName, colIndex) => {
781
+ const fieldDef = objectSchema?.fields?.[fieldName];
782
+ const rawFieldLabel = fieldDef?.label;
783
+ const rawHeader = rawFieldLabel || fieldName.charAt(0).toUpperCase() + fieldName.slice(1).replace(/_/g, ' ');
784
+ const header = schema.objectName ? resolveFieldLabel(schema.objectName, fieldName, rawHeader) : rawHeader;
785
+
786
+ // Resolve type: objectDef type > heuristic inference (consistent with ListColumn path)
787
+ const resolvedType = fieldDef?.type || inferColumnType({ field: fieldName }) || null;
788
+ const CellRenderer = resolvedType ? getCellRenderer(resolvedType) : null;
789
+
790
+ // Build field metadata with objectDef enrichment
791
+ const fieldMeta: Record<string, any> = { name: fieldName, type: resolvedType || 'text' };
792
+ if (fieldDef) {
793
+ if (fieldDef.label) fieldMeta.label = fieldDef.label;
794
+ if (fieldDef.currency) fieldMeta.currency = fieldDef.currency;
795
+ if (fieldDef.precision !== undefined) fieldMeta.precision = fieldDef.precision;
796
+ if (fieldDef.format) fieldMeta.format = fieldDef.format;
797
+ if (fieldDef.options) fieldMeta.options = fieldDef.options;
798
+ }
799
+ // Auto-generate select options from data when no options defined
800
+ if (resolvedType === 'select' && !fieldMeta.options) {
801
+ const uniqueValues = Array.from(new Set(data.map(row => row[fieldName]).filter(Boolean)));
802
+ fieldMeta.options = uniqueValues.map((v: any) => ({ value: v, label: humanizeLabel(String(v)) }));
803
+ }
804
+
805
+ const numericTypes = ['number', 'currency', 'percent'];
806
+ const inferredAlign = resolvedType && numericTypes.includes(resolvedType) ? 'right' as const : undefined;
807
+
808
+ // Auto-link primary field (first column) to record detail
809
+ const isPrimaryField = colIndex === 0;
810
+ let cellRenderer: ((value: any, row?: any) => React.ReactNode) | undefined;
811
+
812
+ if (isPrimaryField && CellRenderer) {
813
+ cellRenderer = (value: any, row: any) => {
814
+ const displayContent = <CellRenderer value={value} field={fieldMeta as any} />;
815
+ return (
816
+ <button
817
+ type="button"
818
+ className="text-primary font-medium underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
819
+ data-testid="primary-field-link"
820
+ onClick={(e) => { e.stopPropagation(); navigation.handleClick(row); }}
821
+ >
822
+ {displayContent}
823
+ </button>
824
+ );
825
+ };
826
+ } else if (isPrimaryField) {
827
+ cellRenderer = (value: any, row: any) => (
828
+ <button
829
+ type="button"
830
+ className="text-primary font-medium underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
831
+ data-testid="primary-field-link"
832
+ onClick={(e) => { e.stopPropagation(); navigation.handleClick(row); }}
833
+ >
834
+ {value != null && value !== '' ? String(value) : <span className="text-muted-foreground/50 text-xs italic">—</span>}
835
+ </button>
836
+ );
837
+ } else if (CellRenderer) {
838
+ cellRenderer = (value: any) => <CellRenderer value={value} field={fieldMeta as any} />;
839
+ }
840
+
445
841
  return {
446
- header: fieldLabel || fieldName.charAt(0).toUpperCase() + fieldName.slice(1).replace(/_/g, ' '),
842
+ header,
447
843
  accessorKey: fieldName,
844
+ ...(resolvedType && { headerIcon: getTypeIcon(resolvedType) }),
845
+ ...(inferredAlign && { align: inferredAlign }),
846
+ ...(cellRenderer && { cell: cellRenderer }),
847
+ sortable: fieldDef?.sortable !== false,
448
848
  };
449
849
  });
450
850
  }
@@ -454,10 +854,39 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
454
854
  const inlineData = dataConfig?.provider === 'value' ? dataConfig.items as any[] : [];
455
855
  if (inlineData.length > 0) {
456
856
  const fieldsToShow = schemaFields || Object.keys(inlineData[0]);
457
- return fieldsToShow.map((fieldName) => ({
458
- header: fieldName.charAt(0).toUpperCase() + fieldName.slice(1).replace(/_/g, ' '),
459
- accessorKey: fieldName,
460
- }));
857
+ return fieldsToShow.map((fieldName) => {
858
+ const fieldDef = objectSchema?.fields?.[fieldName];
859
+ const resolvedType = fieldDef?.type || inferColumnType({ field: fieldName }) || null;
860
+ const CellRenderer = resolvedType ? getCellRenderer(resolvedType) : null;
861
+ const header = fieldDef?.label || fieldName.charAt(0).toUpperCase() + fieldName.slice(1).replace(/_/g, ' ');
862
+
863
+ // Build field metadata with objectDef enrichment
864
+ const fieldMeta: Record<string, any> = { name: fieldName, type: resolvedType || 'text' };
865
+ if (fieldDef) {
866
+ if (fieldDef.label) fieldMeta.label = fieldDef.label;
867
+ if (fieldDef.currency) fieldMeta.currency = fieldDef.currency;
868
+ if (fieldDef.precision !== undefined) fieldMeta.precision = fieldDef.precision;
869
+ if (fieldDef.format) fieldMeta.format = fieldDef.format;
870
+ if (fieldDef.options) fieldMeta.options = fieldDef.options;
871
+ }
872
+ // Auto-generate select options from data when no options defined
873
+ if (resolvedType === 'select' && !fieldMeta.options) {
874
+ const uniqueValues = Array.from(new Set(data.map(row => row[fieldName]).filter(Boolean)));
875
+ fieldMeta.options = uniqueValues.map((v: any) => ({ value: v, label: humanizeLabel(String(v)) }));
876
+ }
877
+
878
+ const numericTypes = ['number', 'currency', 'percent'];
879
+ const inferredAlign = resolvedType && numericTypes.includes(resolvedType) ? 'right' as const : undefined;
880
+
881
+ return {
882
+ header,
883
+ accessorKey: fieldName,
884
+ ...(resolvedType && { headerIcon: getTypeIcon(resolvedType) }),
885
+ ...(inferredAlign && { align: inferredAlign }),
886
+ ...(CellRenderer && { cell: (value: any) => <CellRenderer value={value} field={fieldMeta as any} /> }),
887
+ sortable: fieldDef?.sortable !== false,
888
+ };
889
+ });
461
890
  }
462
891
  }
463
892
 
@@ -475,7 +904,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
475
904
  const CellRenderer = getCellRenderer(field.type);
476
905
  const numericTypes = ['number', 'currency', 'percent'];
477
906
  generatedColumns.push({
478
- header: field.label || fieldName,
907
+ header: schema.objectName ? resolveFieldLabel(schema.objectName, fieldName, field.label || fieldName) : field.label || fieldName,
479
908
  accessorKey: fieldName,
480
909
  ...(numericTypes.includes(field.type) && { align: 'right' }),
481
910
  cell: (value: any) => <CellRenderer value={value} field={field} />,
@@ -484,70 +913,170 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
484
913
  });
485
914
 
486
915
  return generatedColumns;
487
- }, [objectSchema, schemaFields, schemaColumns, dataConfig, hasInlineData, navigation.handleClick, executeAction]);
916
+ }, [objectSchema, schemaFields, schemaColumns, dataConfig, hasInlineData, navigation.handleClick, executeAction, data, resolveFieldLabel, schema.objectName]);
917
+
918
+ const handleExport = useCallback((format: 'csv' | 'xlsx' | 'json' | 'pdf') => {
919
+ const exportConfig = schema.exportOptions;
920
+ const maxRecords = exportConfig?.maxRecords || 0;
921
+ const includeHeaders = exportConfig?.includeHeaders !== false;
922
+ const prefix = exportConfig?.fileNamePrefix || schema.objectName || 'export';
923
+ const exportData = maxRecords > 0 ? data.slice(0, maxRecords) : data;
924
+
925
+ const downloadFile = (blob: Blob, filename: string) => {
926
+ const url = URL.createObjectURL(blob);
927
+ const a = document.createElement('a');
928
+ a.href = url;
929
+ a.download = filename;
930
+ a.click();
931
+ URL.revokeObjectURL(url);
932
+ };
933
+
934
+ const escapeCsvValue = (val: any): string => {
935
+ const str = val == null ? '' : String(val);
936
+ return str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')
937
+ ? `"${str.replace(/"/g, '""')}"`
938
+ : str;
939
+ };
940
+
941
+ if (format === 'csv') {
942
+ const cols = generateColumns().filter((c: any) => c.accessorKey !== '_actions');
943
+ const fields = cols.map((c: any) => c.accessorKey);
944
+ const headers = cols.map((c: any) => c.header);
945
+ const rows: string[] = [];
946
+ if (includeHeaders) {
947
+ rows.push(headers.join(','));
948
+ }
949
+ exportData.forEach(record => {
950
+ rows.push(fields.map((f: string) => escapeCsvValue(record[f])).join(','));
951
+ });
952
+ downloadFile(new Blob([rows.join('\n')], { type: 'text/csv;charset=utf-8;' }), `${prefix}.csv`);
953
+ } else if (format === 'json') {
954
+ downloadFile(new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }), `${prefix}.json`);
955
+ }
956
+ setShowExport(false);
957
+ }, [data, schema.exportOptions, schema.objectName, generateColumns]);
488
958
 
489
959
  if (error) {
490
960
  return (
491
961
  <div className="p-3 sm:p-4 border border-red-300 bg-red-50 rounded-md">
492
- <h3 className="text-red-800 font-semibold">Error loading grid</h3>
962
+ <h3 className="text-red-800 font-semibold">{t('grid.errorLoading')}</h3>
493
963
  <p className="text-red-600 text-sm mt-1">{error.message}</p>
494
964
  </div>
495
965
  );
496
966
  }
497
967
 
498
968
  if (loading && data.length === 0) {
969
+ if (useCardView) {
970
+ return (
971
+ <div className="space-y-2 p-2">
972
+ {[1, 2, 3].map((i) => (
973
+ <div key={i} className="border rounded-lg p-3 bg-card animate-pulse">
974
+ <div className="h-5 bg-muted rounded w-3/4 mb-3" />
975
+ <div className="flex items-center justify-between mb-2">
976
+ <div className="h-4 bg-muted rounded w-1/4" />
977
+ <div className="h-5 bg-muted rounded-full w-20" />
978
+ </div>
979
+ <div className="h-3 bg-muted rounded w-1/3" />
980
+ </div>
981
+ ))}
982
+ </div>
983
+ );
984
+ }
499
985
  return (
500
986
  <div className="p-4 sm:p-8 text-center">
501
- <div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
502
- <p className="mt-2 text-sm text-gray-600">Loading grid...</p>
987
+ <div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-foreground"></div>
988
+ <p className="mt-2 text-sm text-muted-foreground">{t('grid.loading')}</p>
503
989
  </div>
504
990
  );
505
991
  }
506
992
 
507
993
  const columns = generateColumns();
994
+
995
+ // Apply persisted column order and widths
996
+ let persistedColumns = [...columns];
997
+
998
+ // Apply saved widths
999
+ if (columnState.widths) {
1000
+ persistedColumns = persistedColumns.map((col: any) => {
1001
+ const savedWidth = columnState.widths?.[col.accessorKey];
1002
+ if (savedWidth) {
1003
+ return { ...col, size: savedWidth };
1004
+ }
1005
+ return col;
1006
+ });
1007
+ }
1008
+
1009
+ // Apply saved order
1010
+ if (columnState.order && columnState.order.length > 0) {
1011
+ const orderMap = new Map(columnState.order.map((key: string, i: number) => [key, i]));
1012
+ persistedColumns.sort((a: any, b: any) => {
1013
+ const orderA = orderMap.get(a.accessorKey) ?? Infinity;
1014
+ const orderB = orderMap.get(b.accessorKey) ?? Infinity;
1015
+ return orderA - orderB;
1016
+ });
1017
+ }
1018
+
508
1019
  const operations = 'operations' in schema ? schema.operations : undefined;
509
1020
  const hasActions = operations && (operations.update || operations.delete);
1021
+ const hasRowActions = schema.rowActions && schema.rowActions.length > 0;
510
1022
 
511
- const columnsWithActions = hasActions ? [
512
- ...columns,
1023
+ const columnsWithActions = (hasActions || hasRowActions) ? [
1024
+ ...persistedColumns,
513
1025
  {
514
- header: 'Actions',
1026
+ header: t('grid.actions'),
515
1027
  accessorKey: '_actions',
516
1028
  cell: (_value: any, row: any) => (
517
- <DropdownMenu>
518
- <DropdownMenuTrigger asChild>
519
- <Button variant="ghost" size="icon" className="h-8 w-8 min-h-[44px] min-w-[44px] sm:min-h-0 sm:min-w-0">
520
- <MoreVertical className="h-4 w-4" />
521
- <span className="sr-only">Open menu</span>
522
- </Button>
523
- </DropdownMenuTrigger>
524
- <DropdownMenuContent align="end">
525
- {operations?.update && onEdit && (
526
- <DropdownMenuItem onClick={() => onEdit(row)}>
527
- <Edit className="mr-2 h-4 w-4" />
528
- Edit
529
- </DropdownMenuItem>
530
- )}
531
- {operations?.delete && onDelete && (
532
- <DropdownMenuItem onClick={() => onDelete(row)}>
533
- <Trash2 className="mr-2 h-4 w-4" />
534
- Delete
535
- </DropdownMenuItem>
536
- )}
537
- </DropdownMenuContent>
538
- </DropdownMenu>
1029
+ <RowActionMenu
1030
+ row={row}
1031
+ rowActions={schema.rowActions}
1032
+ canEdit={!!(operations?.update && onEdit)}
1033
+ canDelete={!!(operations?.delete && onDelete)}
1034
+ onEdit={onEdit}
1035
+ onDelete={onDelete}
1036
+ onAction={(action, r) => executeAction({ type: action, params: { record: r } })}
1037
+ />
539
1038
  ),
540
1039
  sortable: false,
541
1040
  },
542
- ] : columns;
1041
+ ] : persistedColumns;
1042
+
1043
+ // --- Pinned column reordering ---
1044
+ // Reorder: pinned:'left' first, unpinned middle, pinned:'right' last
1045
+ const pinnedLeftCols = columnsWithActions.filter((c: any) => c.pinned === 'left');
1046
+ const pinnedRightCols = columnsWithActions.filter((c: any) => c.pinned === 'right');
1047
+ const unpinnedCols = columnsWithActions.filter((c: any) => !c.pinned);
1048
+ const hasPinnedColumns = pinnedLeftCols.length > 0 || pinnedRightCols.length > 0;
1049
+ const rightPinnedClasses = 'sticky right-0 z-10 bg-background border-l border-border';
1050
+ const orderedColumns = hasPinnedColumns
1051
+ ? [
1052
+ ...pinnedLeftCols,
1053
+ ...unpinnedCols,
1054
+ ...pinnedRightCols.map((col: any) => ({
1055
+ ...col,
1056
+ className: [col.className, rightPinnedClasses].filter(Boolean).join(' '),
1057
+ cellClassName: [col.cellClassName, rightPinnedClasses].filter(Boolean).join(' '),
1058
+ })),
1059
+ ]
1060
+ : columnsWithActions;
1061
+
1062
+ // Calculate frozenColumns: if pinned columns exist, use left-pinned count; otherwise use schema default
1063
+ const effectiveFrozenColumns = hasPinnedColumns
1064
+ ? pinnedLeftCols.length
1065
+ : (schema.frozenColumns ?? 1);
543
1066
 
544
1067
  // Determine selection mode (support both new and legacy formats)
1068
+ // Auto-enable 'multiple' selection when bulk actions are defined
1069
+ const effectiveBulkActions = schema.batchActions ?? (schema as any).bulkActions;
1070
+ const hasBulkActions = effectiveBulkActions && effectiveBulkActions.length > 0;
545
1071
  let selectionMode: 'none' | 'single' | 'multiple' | boolean = false;
546
1072
  if (schema.selection?.type) {
547
1073
  selectionMode = schema.selection.type === 'none' ? false : schema.selection.type;
548
1074
  } else if (schema.selectable !== undefined) {
549
1075
  // Legacy support
550
1076
  selectionMode = schema.selectable;
1077
+ } else if (hasBulkActions) {
1078
+ // Auto-enable multi-select when bulk actions exist
1079
+ selectionMode = 'multiple';
551
1080
  }
552
1081
 
553
1082
  // Determine pagination settings (support both new and legacy formats)
@@ -567,7 +1096,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
567
1096
  const dataTableSchema: any = {
568
1097
  type: 'data-table',
569
1098
  caption: schema.label || schema.title,
570
- columns: columnsWithActions,
1099
+ columns: orderedColumns,
571
1100
  data,
572
1101
  pagination: paginationEnabled,
573
1102
  pageSize: pageSize,
@@ -579,14 +1108,43 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
579
1108
  resizableColumns: schema.resizable ?? schema.resizableColumns ?? true,
580
1109
  reorderableColumns: schema.reorderableColumns ?? false,
581
1110
  editable: schema.editable ?? false,
1111
+ singleClickEdit: schema.singleClickEdit ?? true,
582
1112
  className: schema.className,
583
- cellClassName: 'px-2 py-1.5 sm:px-3 sm:py-2 md:px-4 md:py-2.5',
1113
+ cellClassName: rowHeightMode === 'compact'
1114
+ ? 'px-3 py-1 text-[13px] leading-tight'
1115
+ : rowHeightMode === 'short'
1116
+ ? 'px-3 py-1 text-[13px] leading-normal'
1117
+ : rowHeightMode === 'tall'
1118
+ ? 'px-3 py-2.5 text-sm'
1119
+ : rowHeightMode === 'extra_tall'
1120
+ ? 'px-3 py-3.5 text-sm leading-relaxed'
1121
+ : 'px-3 py-1.5 text-[13px] leading-normal',
1122
+ showRowNumbers: true,
1123
+ showAddRow: !!operations?.create,
1124
+ onAddRecord: onAddRecord,
584
1125
  rowClassName: schema.rowColor ? (row: any, _idx: number) => getRowClassName(row) : undefined,
585
- onSelectionChange: onRowSelect,
1126
+ rowStyle: schema.conditionalFormatting?.length ? (row: any, _idx: number) => getRowStyle(row) : undefined,
1127
+ frozenColumns: effectiveFrozenColumns,
1128
+ onSelectionChange: (rows: any[]) => {
1129
+ setSelectedRows(rows);
1130
+ onRowSelect?.(rows);
1131
+ },
586
1132
  onRowClick: navigation.handleClick,
587
1133
  onCellChange: onCellChange,
588
1134
  onRowSave: onRowSave,
589
1135
  onBatchSave: onBatchSave,
1136
+ onColumnResize: (columnKey: string, width: number) => {
1137
+ saveColumnState({
1138
+ ...columnState,
1139
+ widths: { ...columnState.widths, [columnKey]: width },
1140
+ });
1141
+ },
1142
+ onColumnReorder: (newOrder: string[]) => {
1143
+ saveColumnState({
1144
+ ...columnState,
1145
+ order: newOrder,
1146
+ });
1147
+ },
590
1148
  };
591
1149
 
592
1150
  /** Build a per-group data-table schema (inherits everything except data & pagination). */
@@ -608,25 +1166,163 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
608
1166
  // Mobile card-view fallback for screens below 480px
609
1167
  if (useCardView && data.length > 0 && !isGrouped) {
610
1168
  const displayColumns = generateColumns().filter((c: any) => c.accessorKey !== '_actions');
1169
+
1170
+ // Build a lookup of column metadata for smart rendering
1171
+ const colMap = new Map<string, any>();
1172
+ displayColumns.forEach((col: any) => colMap.set(col.accessorKey, col));
1173
+
1174
+ // Identify special columns by inferred type for visual hierarchy
1175
+ const titleCol = displayColumns[0]; // First column is always the title
1176
+ const amountKeys = ['amount', 'price', 'total', 'revenue', 'cost', 'value', 'budget', 'salary'];
1177
+ const stageKeys = ['stage', 'status', 'priority', 'category', 'severity', 'level'];
1178
+ const dateKeys = ['date', 'due', 'created', 'updated', 'deadline', 'start', 'end', 'expires'];
1179
+ const percentKeys = ['probability', 'percent', 'rate', 'ratio', 'confidence', 'score'];
1180
+
1181
+ // Stage badge color mapping for common pipeline stages
1182
+ const stageBadgeColor = (value: string): string => {
1183
+ const v = (value || '').toLowerCase();
1184
+ if (v.includes('won') || v.includes('completed') || v.includes('done') || v.includes('active'))
1185
+ return 'bg-green-100 text-green-800 border-green-300';
1186
+ if (v.includes('lost') || v.includes('cancelled') || v.includes('rejected') || v.includes('closed lost'))
1187
+ return 'bg-red-100 text-red-800 border-red-300';
1188
+ if (v.includes('negotiation') || v.includes('review') || v.includes('in progress'))
1189
+ return 'bg-yellow-100 text-yellow-800 border-yellow-300';
1190
+ if (v.includes('proposal') || v.includes('pending'))
1191
+ return 'bg-blue-100 text-blue-800 border-blue-300';
1192
+ if (v.includes('qualification') || v.includes('qualified'))
1193
+ return 'bg-indigo-100 text-indigo-800 border-indigo-300';
1194
+ if (v.includes('prospecting') || v.includes('new') || v.includes('open'))
1195
+ return 'bg-purple-100 text-purple-800 border-purple-300';
1196
+ return 'bg-muted text-muted-foreground border-border';
1197
+ };
1198
+
1199
+ // Left border color for card accent based on stage
1200
+ const stageBorderLeft = (value: string): string => {
1201
+ const v = (value || '').toLowerCase();
1202
+ if (v.includes('won') || v.includes('completed') || v.includes('done') || v.includes('active'))
1203
+ return 'border-l-green-500';
1204
+ if (v.includes('lost') || v.includes('cancelled') || v.includes('rejected'))
1205
+ return 'border-l-red-500';
1206
+ if (v.includes('negotiation') || v.includes('review') || v.includes('in progress'))
1207
+ return 'border-l-yellow-500';
1208
+ if (v.includes('proposal') || v.includes('pending'))
1209
+ return 'border-l-blue-500';
1210
+ if (v.includes('qualification') || v.includes('qualified'))
1211
+ return 'border-l-indigo-500';
1212
+ if (v.includes('prospecting') || v.includes('new') || v.includes('open'))
1213
+ return 'border-l-purple-500';
1214
+ return 'border-l-gray-300';
1215
+ };
1216
+
1217
+ const classify = (key: string): 'amount' | 'stage' | 'date' | 'percent' | 'other' => {
1218
+ const k = key.toLowerCase();
1219
+ if (amountKeys.some(p => k.includes(p))) return 'amount';
1220
+ if (stageKeys.some(p => k.includes(p))) return 'stage';
1221
+ if (dateKeys.some(p => k.includes(p))) return 'date';
1222
+ if (percentKeys.some(p => k.includes(p))) return 'percent';
1223
+ return 'other';
1224
+ };
1225
+
611
1226
  return (
612
1227
  <>
613
1228
  <div className="space-y-2 p-2">
614
- {data.map((row, idx) => (
615
- <div
616
- key={row.id || row._id || idx}
617
- className="border rounded-lg p-3 bg-card hover:bg-accent/50 cursor-pointer transition-colors touch-manipulation"
618
- onClick={() => navigation.handleClick(row)}
619
- >
620
- {displayColumns.slice(0, 4).map((col: any) => (
621
- <div key={col.accessorKey} className="flex justify-between items-center py-1">
622
- <span className="text-xs text-muted-foreground">{col.header}</span>
623
- <span className="text-sm font-medium truncate ml-2 text-right">
624
- {col.cell ? col.cell(row[col.accessorKey], row) : String(row[col.accessorKey] ?? '—')}
625
- </span>
626
- </div>
627
- ))}
628
- </div>
629
- ))}
1229
+ {data.map((row, idx) => {
1230
+ // Collect secondary fields (skip the title column)
1231
+ const secondaryCols = displayColumns.slice(1, 5);
1232
+ const amountCol = secondaryCols.find((c: any) => classify(c.accessorKey) === 'amount');
1233
+ const stageCol = secondaryCols.find((c: any) => classify(c.accessorKey) === 'stage');
1234
+ const dateCols = secondaryCols.filter((c: any) => classify(c.accessorKey) === 'date');
1235
+ const percentCols = secondaryCols.filter((c: any) => classify(c.accessorKey) === 'percent');
1236
+ const otherCols = secondaryCols.filter(
1237
+ (c: any) => c !== amountCol && c !== stageCol && !dateCols.includes(c) && !percentCols.includes(c)
1238
+ );
1239
+
1240
+ // Determine left border accent color from stage value
1241
+ const stageValue = stageCol ? String(row[stageCol.accessorKey] ?? '') : '';
1242
+ const leftBorderClass = stageValue ? stageBorderLeft(stageValue) : '';
1243
+ const cardClassName = [
1244
+ 'border rounded-lg p-2.5 bg-card hover:bg-accent/50 cursor-pointer transition-colors touch-manipulation',
1245
+ leftBorderClass ? `border-l-[3px] ${leftBorderClass}` : '',
1246
+ ].filter(Boolean).join(' ');
1247
+
1248
+ return (
1249
+ <div
1250
+ key={row.id || row._id || idx}
1251
+ className={cardClassName}
1252
+ onClick={() => navigation.handleClick(row)}
1253
+ >
1254
+ {/* Title row - Name as bold prominent title */}
1255
+ {titleCol && (
1256
+ <div className="font-semibold text-sm truncate mb-1">
1257
+ {row[titleCol.accessorKey] ?? '—'}
1258
+ </div>
1259
+ )}
1260
+
1261
+ {/* Amount + Stage row - side by side for compact display */}
1262
+ {(amountCol || stageCol) && (
1263
+ <div className="flex items-center justify-between gap-2 mb-1">
1264
+ {amountCol && (
1265
+ <span className="text-sm tabular-nums font-medium">
1266
+ {typeof row[amountCol.accessorKey] === 'number'
1267
+ ? formatCompactCurrency(row[amountCol.accessorKey])
1268
+ : row[amountCol.accessorKey] ?? '—'}
1269
+ </span>
1270
+ )}
1271
+ {stageCol && row[stageCol.accessorKey] && (
1272
+ <Badge
1273
+ variant="outline"
1274
+ className={`text-xs shrink-0 max-w-[140px] truncate ${stageBadgeColor(String(row[stageCol.accessorKey]))}`}
1275
+ >
1276
+ {row[stageCol.accessorKey]}
1277
+ </Badge>
1278
+ )}
1279
+ </div>
1280
+ )}
1281
+
1282
+ {/* Date + Percent combined row for density */}
1283
+ {(dateCols.length > 0 || percentCols.length > 0) && (
1284
+ <div className="flex items-center justify-between py-0.5 text-xs text-muted-foreground">
1285
+ {dateCols[0] && (
1286
+ <span className="tabular-nums">
1287
+ {row[dateCols[0].accessorKey]
1288
+ ? formatDate(row[dateCols[0].accessorKey], 'short')
1289
+ : '—'}
1290
+ </span>
1291
+ )}
1292
+ {percentCols[0] && row[percentCols[0].accessorKey] != null && (
1293
+ <span className="tabular-nums">
1294
+ {formatPercent(Number(row[percentCols[0].accessorKey]))}
1295
+ </span>
1296
+ )}
1297
+ </div>
1298
+ )}
1299
+
1300
+ {/* Additional date fields beyond the first */}
1301
+ {dateCols.slice(1).map((col: any) => (
1302
+ <div key={col.accessorKey} className="flex justify-between items-center py-0.5">
1303
+ <span className="text-xs text-muted-foreground">{col.header}</span>
1304
+ <span className="text-xs text-muted-foreground tabular-nums">
1305
+ {row[col.accessorKey] ? formatDate(row[col.accessorKey], 'short') : '—'}
1306
+ </span>
1307
+ </div>
1308
+ ))}
1309
+
1310
+ {/* Other fields - hide empty values on mobile */}
1311
+ {otherCols.map((col: any) => {
1312
+ const val = row[col.accessorKey];
1313
+ if (val == null || val === '') return null;
1314
+ return (
1315
+ <div key={col.accessorKey} className="flex justify-between items-center py-0.5">
1316
+ <span className="text-xs text-muted-foreground">{col.header}</span>
1317
+ <span className="text-xs font-medium truncate ml-2 text-right">
1318
+ {col.cell ? col.cell(val, row) : String(val)}
1319
+ </span>
1320
+ </div>
1321
+ );
1322
+ })}
1323
+ </div>
1324
+ );
1325
+ })}
630
1326
  </div>
631
1327
  {navigation.isOverlay && (
632
1328
  <NavigationOverlay {...navigation} title={detailTitle}>
@@ -648,30 +1344,193 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
648
1344
  );
649
1345
  }
650
1346
 
1347
+ // Row height cycle handler (plain function, not hook — after early returns)
1348
+ const cycleRowHeight = () => {
1349
+ setRowHeightMode(prev => {
1350
+ if (prev === 'compact') return 'short';
1351
+ if (prev === 'short') return 'medium';
1352
+ if (prev === 'medium') return 'tall';
1353
+ if (prev === 'tall') return 'extra_tall';
1354
+ return 'compact';
1355
+ });
1356
+ };
1357
+
1358
+ const rowHeightIcons = { compact: Rows4, short: Rows3, medium: Rows2, tall: AlignJustify, extra_tall: AlignJustify };
1359
+ const RowHeightIcon = rowHeightIcons[rowHeightMode];
1360
+
1361
+ // Grid toolbar (row height toggle + export)
1362
+ const showRowHeightToggle = schema.rowHeight !== undefined;
1363
+ const hasToolbar = schema.exportOptions || showRowHeightToggle;
1364
+ const gridToolbar = hasToolbar ? (
1365
+ <div className="flex items-center justify-end gap-1 px-2 py-1">
1366
+ {/* Row height toggle */}
1367
+ {showRowHeightToggle && (
1368
+ <Button
1369
+ variant="ghost"
1370
+ size="sm"
1371
+ className="h-7 px-2 text-muted-foreground hover:text-primary text-xs"
1372
+ onClick={cycleRowHeight}
1373
+ title={`Row height: ${rowHeightMode}`}
1374
+ >
1375
+ <RowHeightIcon className="h-3.5 w-3.5 mr-1.5" />
1376
+ <span className="hidden sm:inline capitalize">{rowHeightMode}</span>
1377
+ </Button>
1378
+ )}
1379
+
1380
+ {/* Export */}
1381
+ {schema.exportOptions && (
1382
+ <Popover open={showExport} onOpenChange={setShowExport}>
1383
+ <PopoverTrigger asChild>
1384
+ <Button
1385
+ variant="ghost"
1386
+ size="sm"
1387
+ className="h-7 px-2 text-muted-foreground hover:text-primary text-xs"
1388
+ >
1389
+ <Download className="h-3.5 w-3.5 mr-1.5" />
1390
+ <span className="hidden sm:inline">{t('grid.export')}</span>
1391
+ </Button>
1392
+ </PopoverTrigger>
1393
+ <PopoverContent align="end" className="w-48 p-2">
1394
+ <div className="space-y-1">
1395
+ {(schema.exportOptions.formats || ['csv', 'json']).map(format => (
1396
+ <Button
1397
+ key={format}
1398
+ variant="ghost"
1399
+ size="sm"
1400
+ className="w-full justify-start h-8 text-xs"
1401
+ onClick={() => handleExport(format)}
1402
+ >
1403
+ <Download className="h-3.5 w-3.5 mr-2" />
1404
+ {t('grid.exportAs', { format: format.toUpperCase() })}
1405
+ </Button>
1406
+ ))}
1407
+ </div>
1408
+ </PopoverContent>
1409
+ </Popover>
1410
+ )}
1411
+ </div>
1412
+ ) : null;
1413
+
1414
+ // Form-based record detail renderer (replaces simple key-value dump)
1415
+ const renderRecordDetail = (record: any) => {
1416
+ const systemFields = ['_id', 'id', 'created_at', 'updated_at', 'created_by', 'updated_by'];
1417
+ const entries = Object.entries(record);
1418
+ const regularFields = entries.filter(([key]) => !systemFields.includes(key));
1419
+ const metaFields = entries.filter(([key]) => systemFields.includes(key) && key !== '_id' && key !== 'id');
1420
+
1421
+ const formatFieldLabel = (key: string): string =>
1422
+ key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, ' ');
1423
+
1424
+ const renderFieldValue = (key: string, value: any): React.ReactNode => {
1425
+ if (value == null || value === '') {
1426
+ return <span className="text-muted-foreground/50 text-sm italic">Empty</span>;
1427
+ }
1428
+
1429
+ // Use objectSchema field type for type-aware rendering
1430
+ const fieldDef = objectSchema?.fields?.[key];
1431
+ if (fieldDef?.type) {
1432
+ const CellRenderer = getCellRenderer(fieldDef.type);
1433
+ if (CellRenderer) {
1434
+ return <CellRenderer value={value} field={fieldDef} />;
1435
+ }
1436
+ }
1437
+
1438
+ // Fallback: infer from value and key name
1439
+ if (typeof value === 'boolean') {
1440
+ return <Badge variant={value ? 'default' : 'outline'}>{value ? 'Yes' : 'No'}</Badge>;
1441
+ }
1442
+ // Detect date-like values
1443
+ if (typeof value === 'string' && !isNaN(Date.parse(value)) && (key.includes('date') || key.includes('_at') || key.includes('time'))) {
1444
+ return <span className="text-sm tabular-nums">{formatDate(value)}</span>;
1445
+ }
1446
+ // Detect currency-like fields by name
1447
+ const currencyFields = ['amount', 'price', 'total', 'revenue', 'cost', 'value', 'budget', 'salary'];
1448
+ if (typeof value === 'number' && currencyFields.some(f => key.toLowerCase().includes(f))) {
1449
+ return <span className="text-sm tabular-nums font-medium">{formatCurrency(value)}</span>;
1450
+ }
1451
+ return <span className="text-sm break-words">{String(value)}</span>;
1452
+ };
1453
+
1454
+ return (
1455
+ <div className="space-y-4" data-testid="record-detail-panel">
1456
+ {/* Regular fields in form-like layout */}
1457
+ <div className="rounded-lg border bg-card">
1458
+ <div className="divide-y">
1459
+ {regularFields.map(([key, value]) => (
1460
+ <div key={key} className="flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4 px-4 py-3">
1461
+ <span className="text-xs font-medium text-muted-foreground sm:w-1/3 sm:text-right sm:pt-0.5 uppercase tracking-wide shrink-0">
1462
+ {formatFieldLabel(key)}
1463
+ </span>
1464
+ <div className="flex-1 min-w-0">
1465
+ {renderFieldValue(key, value)}
1466
+ </div>
1467
+ </div>
1468
+ ))}
1469
+ </div>
1470
+ </div>
1471
+
1472
+ {/* System/meta fields */}
1473
+ {metaFields.length > 0 && (
1474
+ <div className="rounded-lg border bg-muted/30">
1475
+ <div className="px-4 py-2 border-b">
1476
+ <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">System</span>
1477
+ </div>
1478
+ <div className="divide-y divide-border/50">
1479
+ {metaFields.map(([key, value]) => (
1480
+ <div key={key} className="flex items-center gap-4 px-4 py-2">
1481
+ <span className="text-xs text-muted-foreground w-1/3 text-right shrink-0">
1482
+ {formatFieldLabel(key)}
1483
+ </span>
1484
+ <span className="text-xs text-muted-foreground flex-1 min-w-0 break-words">{String(value ?? '')}</span>
1485
+ </div>
1486
+ ))}
1487
+ </div>
1488
+ </div>
1489
+ )}
1490
+ </div>
1491
+ );
1492
+ };
1493
+
1494
+ // Summary footer row
1495
+ const summaryFooter = hasSummary ? (
1496
+ <div className="border-t bg-muted/30 px-2 py-1.5" data-testid="column-summary-footer">
1497
+ <div className="flex gap-4 text-xs text-muted-foreground font-medium">
1498
+ {orderedColumns
1499
+ .filter((col: any) => summaries.has(col.accessorKey))
1500
+ .map((col: any) => {
1501
+ const summary = summaries.get(col.accessorKey)!;
1502
+ return (
1503
+ <span key={col.accessorKey} data-testid={`summary-${col.accessorKey}`}>
1504
+ {col.header}: {summary.label}
1505
+ </span>
1506
+ );
1507
+ })}
1508
+ </div>
1509
+ </div>
1510
+ ) : null;
1511
+
651
1512
  // Render grid content: grouped (multiple tables with headers) or flat (single table)
652
1513
  const gridContent = isGrouped ? (
653
1514
  <div className="space-y-2">
654
1515
  {groups.map((group) => (
655
- <div key={group.key} className="border rounded-md">
656
- <button
657
- type="button"
658
- className="flex w-full items-center gap-2 px-3 py-2 text-sm font-medium text-left bg-muted/50 hover:bg-muted transition-colors"
659
- onClick={() => toggleGroup(group.key)}
660
- >
661
- {group.collapsed
662
- ? <ChevronRight className="h-4 w-4 shrink-0" />
663
- : <ChevronDown className="h-4 w-4 shrink-0" />}
664
- <span>{group.label}</span>
665
- <span className="ml-auto text-xs text-muted-foreground">{group.rows.length}</span>
666
- </button>
667
- {!group.collapsed && (
668
- <SchemaRenderer schema={buildGroupTableSchema(group.rows)} />
669
- )}
670
- </div>
1516
+ <GroupRow
1517
+ key={group.key}
1518
+ groupKey={group.key}
1519
+ label={group.label}
1520
+ count={group.rows.length}
1521
+ collapsed={group.collapsed}
1522
+ aggregations={group.aggregations}
1523
+ onToggle={toggleGroup}
1524
+ >
1525
+ <SchemaRenderer schema={buildGroupTableSchema(group.rows)} />
1526
+ </GroupRow>
671
1527
  ))}
672
1528
  </div>
673
1529
  ) : (
674
- <SchemaRenderer schema={dataTableSchema} />
1530
+ <>
1531
+ <SchemaRenderer schema={dataTableSchema} />
1532
+ {summaryFooter}
1533
+ </>
675
1534
  );
676
1535
 
677
1536
  // For split mode, wrap the grid in the ResizablePanelGroup
@@ -680,20 +1539,20 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
680
1539
  <NavigationOverlay
681
1540
  {...navigation}
682
1541
  title={detailTitle}
683
- mainContent={gridContent}
1542
+ mainContent={
1543
+ <>
1544
+ {gridToolbar}
1545
+ {gridContent}
1546
+ <BulkActionBar
1547
+ selectedRows={selectedRows}
1548
+ actions={effectiveBulkActions ?? []}
1549
+ onAction={(action, rows) => executeAction({ type: action, params: { records: rows } })}
1550
+ onClearSelection={() => setSelectedRows([])}
1551
+ />
1552
+ </>
1553
+ }
684
1554
  >
685
- {(record) => (
686
- <div className="space-y-3">
687
- {Object.entries(record).map(([key, value]) => (
688
- <div key={key} className="flex flex-col">
689
- <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
690
- {key.replace(/_/g, ' ')}
691
- </span>
692
- <span className="text-sm">{String(value ?? '—')}</span>
693
- </div>
694
- ))}
695
- </div>
696
- )}
1555
+ {(record) => renderRecordDetail(record)}
697
1556
  </NavigationOverlay>
698
1557
  );
699
1558
  }
@@ -705,27 +1564,23 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
705
1564
  className="flex items-center justify-center text-xs text-muted-foreground"
706
1565
  style={{ height: pullDistance }}
707
1566
  >
708
- {isRefreshing ? 'Refreshing…' : 'Pull to refresh'}
1567
+ {isRefreshing ? t('grid.refreshing') : t('grid.pullToRefresh')}
709
1568
  </div>
710
1569
  )}
1570
+ {gridToolbar}
711
1571
  {gridContent}
1572
+ <BulkActionBar
1573
+ selectedRows={selectedRows}
1574
+ actions={effectiveBulkActions ?? []}
1575
+ onAction={(action, rows) => executeAction({ type: action, params: { records: rows } })}
1576
+ onClearSelection={() => setSelectedRows([])}
1577
+ />
712
1578
  {navigation.isOverlay && (
713
1579
  <NavigationOverlay
714
1580
  {...navigation}
715
1581
  title={detailTitle}
716
1582
  >
717
- {(record) => (
718
- <div className="space-y-3">
719
- {Object.entries(record).map(([key, value]) => (
720
- <div key={key} className="flex flex-col">
721
- <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
722
- {key.replace(/_/g, ' ')}
723
- </span>
724
- <span className="text-sm">{String(value ?? '—')}</span>
725
- </div>
726
- ))}
727
- </div>
728
- )}
1583
+ {(record) => renderRecordDetail(record)}
729
1584
  </NavigationOverlay>
730
1585
  )}
731
1586
  </div>