@object-ui/plugin-grid 3.0.3 → 3.1.1

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