@xcelsior/ui-spreadsheets 1.2.1 → 1.3.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 (99) hide show
  1. package/.omc/state/agent-replay-0cead415-b3bd-40fd-b199-47371946c4db.jsonl +25 -0
  2. package/.omc/state/idle-notif-cooldown.json +3 -0
  3. package/.omc/state/last-tool-error.json +7 -0
  4. package/.omc/state/mission-state.json +179 -0
  5. package/.omc/state/subagent-tracking.json +116 -0
  6. package/.turbo/turbo-build.log +28 -28
  7. package/.turbo/turbo-lint.log +140 -0
  8. package/dist/index.d.mts +94 -4
  9. package/dist/index.d.ts +94 -4
  10. package/dist/index.js +2133 -1155
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.mjs +2023 -1047
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/styles/globals.css +159 -16
  15. package/dist/styles/globals.css.map +1 -1
  16. package/package.json +1 -1
  17. package/plans/20260330-1230-spreadsheet-features/phase-01-types-and-duplicates-hook.md +73 -0
  18. package/plans/20260330-1230-spreadsheet-features/phase-02-filter-dropdown-portal.md +90 -0
  19. package/plans/20260330-1230-spreadsheet-features/phase-03-header-overflow-menu.md +101 -0
  20. package/plans/20260330-1230-spreadsheet-features/phase-04-integration.md +193 -0
  21. package/plans/20260330-1230-spreadsheet-features/plan.md +59 -0
  22. package/src/components/ColorPickerPopover.tsx +77 -32
  23. package/src/components/ColumnHeaderActions.tsx +241 -1
  24. package/src/components/RowIndexColumnHeader.tsx +13 -16
  25. package/src/components/SelectionSummaryBar.tsx +103 -0
  26. package/src/components/Spreadsheet.stories.tsx +396 -0
  27. package/src/components/Spreadsheet.tsx +233 -187
  28. package/src/components/SpreadsheetCell.tsx +280 -42
  29. package/src/components/SpreadsheetFilterDropdown.tsx +178 -13
  30. package/src/components/SpreadsheetHeader.tsx +79 -24
  31. package/src/components/SpreadsheetSettingsModal.tsx +4 -0
  32. package/src/hooks/useSpreadsheetColumnResize.ts +143 -0
  33. package/src/hooks/useSpreadsheetDuplicates.ts +149 -0
  34. package/src/hooks/useSpreadsheetFiltering.ts +18 -1
  35. package/src/hooks/useSpreadsheetHighlighting.ts +23 -3
  36. package/src/hooks/useSpreadsheetKeyboardShortcuts.ts +16 -0
  37. package/src/hooks/useSpreadsheetPinning.ts +148 -134
  38. package/src/hooks/useSpreadsheetSelection.ts +10 -22
  39. package/src/hooks/useSpreadsheetSummary.ts +68 -0
  40. package/src/index.ts +4 -1
  41. package/src/styles/globals.css +51 -0
  42. package/src/types.ts +50 -2
  43. package/storybook-static/assets/Color-YHDXOIA2-CtQurLnT.js +1 -0
  44. package/storybook-static/assets/DocsRenderer-CFRXHY34-oxrW8Hvo.js +575 -0
  45. package/storybook-static/assets/Spreadsheet.stories-DvhhzuK4.js +1357 -0
  46. package/storybook-static/assets/chunk-XP5HYGXS-BpfKkqn7.js +1 -0
  47. package/storybook-static/assets/entry-preview-CkBGHCAN.js +2 -0
  48. package/storybook-static/assets/entry-preview-docs-ugJb6pa8.js +46 -0
  49. package/storybook-static/assets/iframe-CPp2u3vg.js +211 -0
  50. package/storybook-static/assets/index-BB9bPxRC.js +24 -0
  51. package/storybook-static/assets/index-BQFlzFLk.js +9 -0
  52. package/storybook-static/assets/index-CtvPRVHf.js +9 -0
  53. package/storybook-static/assets/index-DgH-xKnr.js +11 -0
  54. package/storybook-static/assets/index-DrFu-skq.js +6 -0
  55. package/storybook-static/assets/index-DrdPSA1J.js +240 -0
  56. package/storybook-static/assets/index-DzFBShOR.js +20 -0
  57. package/storybook-static/assets/index-v-1boR4t.js +1 -0
  58. package/storybook-static/assets/preview-B8lJiyuQ.js +34 -0
  59. package/storybook-static/assets/preview-BBWR9nbA.js +1 -0
  60. package/storybook-static/assets/preview-BWzBA1C2.js +396 -0
  61. package/storybook-static/assets/preview-Bm0S-uxO.css +1 -0
  62. package/storybook-static/assets/preview-CvbIS5ZJ.js +1 -0
  63. package/storybook-static/assets/preview-DD_OYowb.js +1 -0
  64. package/storybook-static/assets/preview-DGUiP6tS.js +7 -0
  65. package/storybook-static/assets/preview-DHQbi4pV.js +1 -0
  66. package/storybook-static/assets/preview-DwI0w3cI.js +1 -0
  67. package/storybook-static/assets/preview-DyR7iiFG.js +1 -0
  68. package/storybook-static/assets/preview-zxZ6Be2V.js +2 -0
  69. package/storybook-static/assets/react-18-Pj8skaX9.js +1 -0
  70. package/storybook-static/assets/test-utils-quxJ1Z79.js +9 -0
  71. package/storybook-static/favicon.svg +1 -0
  72. package/storybook-static/iframe.html +666 -0
  73. package/storybook-static/index.html +177 -0
  74. package/storybook-static/index.json +1 -0
  75. package/storybook-static/nunito-sans-bold-italic.woff2 +0 -0
  76. package/storybook-static/nunito-sans-bold.woff2 +0 -0
  77. package/storybook-static/nunito-sans-italic.woff2 +0 -0
  78. package/storybook-static/nunito-sans-regular.woff2 +0 -0
  79. package/storybook-static/project.json +1 -0
  80. package/storybook-static/sb-addons/essentials-actions-3/manager-bundle.js +3 -0
  81. package/storybook-static/sb-addons/essentials-backgrounds-5/manager-bundle.js +12 -0
  82. package/storybook-static/sb-addons/essentials-controls-2/manager-bundle.js +405 -0
  83. package/storybook-static/sb-addons/essentials-docs-4/manager-bundle.js +245 -0
  84. package/storybook-static/sb-addons/essentials-measure-8/manager-bundle.js +3 -0
  85. package/storybook-static/sb-addons/essentials-outline-9/manager-bundle.js +3 -0
  86. package/storybook-static/sb-addons/essentials-toolbars-7/manager-bundle.js +3 -0
  87. package/storybook-static/sb-addons/essentials-viewport-6/manager-bundle.js +3 -0
  88. package/storybook-static/sb-addons/interactions-10/manager-bundle.js +222 -0
  89. package/storybook-static/sb-addons/links-1/manager-bundle.js +3 -0
  90. package/storybook-static/sb-addons/storybook-core-core-server-presets-0/common-manager-bundle.js +3 -0
  91. package/storybook-static/sb-common-assets/favicon.svg +1 -0
  92. package/storybook-static/sb-common-assets/nunito-sans-bold-italic.woff2 +0 -0
  93. package/storybook-static/sb-common-assets/nunito-sans-bold.woff2 +0 -0
  94. package/storybook-static/sb-common-assets/nunito-sans-italic.woff2 +0 -0
  95. package/storybook-static/sb-common-assets/nunito-sans-regular.woff2 +0 -0
  96. package/storybook-static/sb-manager/globals-module-info.js +1052 -0
  97. package/storybook-static/sb-manager/globals-runtime.js +42127 -0
  98. package/storybook-static/sb-manager/globals.js +48 -0
  99. package/storybook-static/sb-manager/runtime.js +12048 -0
@@ -2,8 +2,8 @@ import { cn } from '../utils';
2
2
  import { ColumnHeaderActions } from './ColumnHeaderActions';
3
3
  import { ROW_INDEX_COLUMN_WIDTH } from '../hooks/useSpreadsheetPinning';
4
4
 
5
- const cellPaddingCompact = 'px-1 py-0.5';
6
- const cellPaddingNormal = 'px-2 py-1.5';
5
+ const cellPaddingCompact = 'px-1.5 py-1';
6
+ const cellPaddingNormal = 'px-2 py-2';
7
7
 
8
8
  export interface RowIndexColumnHeaderProps {
9
9
  /** Whether highlighting is enabled */
@@ -16,7 +16,7 @@ export interface RowIndexColumnHeaderProps {
16
16
  onHighlightClick?: () => void;
17
17
  /** Callback when pin button is clicked */
18
18
  onPinClick?: () => void;
19
- /** Whether this is the empty placeholder cell in the second header row (column headers row) */
19
+ /** Whether this is the empty placeholder cell in the group header row */
20
20
  isSecondRow?: boolean;
21
21
  /** Whether compact mode is enabled */
22
22
  compactMode?: boolean;
@@ -27,6 +27,7 @@ export interface RowIndexColumnHeaderProps {
27
27
  /**
28
28
  * Row index column header (#) with highlight and pin actions.
29
29
  * Uses the same ColumnHeaderActions component as regular columns for consistency.
30
+ * Vertical sticking is handled by the parent <thead>, this only handles horizontal sticking when pinned.
30
31
  */
31
32
  export function RowIndexColumnHeader({
32
33
  enableHighlighting = false,
@@ -40,23 +41,21 @@ export function RowIndexColumnHeader({
40
41
  }: RowIndexColumnHeaderProps) {
41
42
  const cellPadding = compactMode ? cellPaddingCompact : cellPaddingNormal;
42
43
 
43
- // Second row is an empty placeholder cell - no content, just maintains alignment
44
+ // Group header row placeholder - always sticky left
44
45
  if (isSecondRow) {
45
46
  return (
46
47
  <th
47
48
  className={cn(
48
- 'border border-gray-200 text-center font-bold text-gray-700',
49
- compactMode ? 'text-[10px]' : 'text-xs',
49
+ 'border border-gray-200 text-center font-bold text-gray-700 sticky',
50
+ compactMode ? 'text-xs' : 'text-sm',
50
51
  cellPadding,
51
- isPinned ? 'z-30' : 'z-20',
52
52
  className
53
53
  )}
54
54
  style={{
55
55
  minWidth: `${ROW_INDEX_COLUMN_WIDTH}px`,
56
56
  width: `${ROW_INDEX_COLUMN_WIDTH}px`,
57
- position: 'sticky',
58
- top: 0,
59
- left: isPinned ? 0 : undefined,
57
+ left: 0,
58
+ zIndex: 50,
60
59
  backgroundColor: highlightColor || 'rgb(243 244 246)',
61
60
  }}
62
61
  />
@@ -66,18 +65,16 @@ export function RowIndexColumnHeader({
66
65
  return (
67
66
  <th
68
67
  className={cn(
69
- 'border border-gray-200 text-center font-bold text-gray-700 group',
70
- compactMode ? 'text-[10px]' : 'text-xs',
68
+ 'border border-gray-200 text-center font-bold text-gray-700 group sticky',
69
+ compactMode ? 'text-xs' : 'text-sm',
71
70
  cellPadding,
72
- isPinned ? 'z-30' : 'z-20',
73
71
  className
74
72
  )}
75
73
  style={{
76
74
  minWidth: `${ROW_INDEX_COLUMN_WIDTH}px`,
77
75
  width: `${ROW_INDEX_COLUMN_WIDTH}px`,
78
- position: 'sticky',
79
- top: 0,
80
- left: isPinned ? 0 : undefined,
76
+ left: 0,
77
+ zIndex: 50,
81
78
  backgroundColor: highlightColor || 'rgb(243 244 246)',
82
79
  }}
83
80
  >
@@ -0,0 +1,103 @@
1
+ import type { SelectionSummary } from '../hooks/useSpreadsheetSummary';
2
+ import type { CellPosition, SpreadsheetColumn } from '../types';
3
+
4
+ export interface SelectionSummaryBarProps<T> {
5
+ /** Computed summary for numeric selections */
6
+ summary: SelectionSummary | null;
7
+ /** Currently focused cell */
8
+ focusedCell: CellPosition | null;
9
+ /** Column definitions */
10
+ columns: SpreadsheetColumn<T>[];
11
+ /** Current page's data */
12
+ data: T[];
13
+ /** Function to get row ID */
14
+ getRowId: (row: T) => string | number;
15
+ /** Current page number */
16
+ currentPage: number;
17
+ /** Page size */
18
+ pageSize: number;
19
+ }
20
+
21
+ /**
22
+ * Combined status bar showing cell address (left) and selection summary (right).
23
+ * Similar to Excel's bottom status bar.
24
+ */
25
+ export function SelectionSummaryBar<T extends Record<string, any>>({
26
+ summary,
27
+ focusedCell,
28
+ columns,
29
+ data,
30
+ getRowId,
31
+ currentPage,
32
+ pageSize,
33
+ }: SelectionSummaryBarProps<T>) {
34
+ // Compute address info
35
+ let addressDisplay: string | null = null;
36
+ let valueDisplay: string | null = null;
37
+
38
+ if (focusedCell) {
39
+ const column = columns.find((c) => c.id === focusedCell.columnId);
40
+ const rowIndex = data.findIndex((r) => getRowId(r) === focusedCell.rowId);
41
+ const row = rowIndex !== -1 ? data[rowIndex] : null;
42
+
43
+ if (column && row) {
44
+ const displayRowIndex = rowIndex + 1 + (currentPage - 1) * pageSize;
45
+ addressDisplay = `Row ${displayRowIndex} / ${column.label}`;
46
+ const value = column.getValue ? column.getValue(row) : row[focusedCell.columnId];
47
+ if (value !== null && value !== undefined && value !== '') {
48
+ valueDisplay = String(value);
49
+ }
50
+ }
51
+ }
52
+
53
+ // Don't render if nothing to show
54
+ if (!addressDisplay && !summary) return null;
55
+
56
+ return (
57
+ <div className="flex items-center justify-between px-3 py-1.5 bg-gray-50 border-t border-gray-200 text-xs text-gray-600">
58
+ {/* Left: Cell address + value */}
59
+ <div className="flex items-center gap-2 min-w-0">
60
+ {addressDisplay && (
61
+ <>
62
+ <span className="font-medium text-gray-500 bg-white px-2 py-0.5 rounded border border-gray-200 shrink-0">
63
+ {addressDisplay}
64
+ </span>
65
+ {valueDisplay && (
66
+ <span className="text-gray-700 truncate" title={valueDisplay}>
67
+ {valueDisplay}
68
+ </span>
69
+ )}
70
+ </>
71
+ )}
72
+ </div>
73
+
74
+ {/* Right: Selection summary */}
75
+ {summary && (
76
+ <div className="flex items-center gap-4 shrink-0">
77
+ <span>
78
+ <span className="text-gray-400 mr-1">Count:</span>
79
+ <span className="font-medium text-gray-700">{summary.numericCount}</span>
80
+ </span>
81
+ <span>
82
+ <span className="text-gray-400 mr-1">Sum:</span>
83
+ <span className="font-medium text-gray-700">{summary.sum.toLocaleString()}</span>
84
+ </span>
85
+ <span>
86
+ <span className="text-gray-400 mr-1">Avg:</span>
87
+ <span className="font-medium text-gray-700">{summary.avg.toLocaleString()}</span>
88
+ </span>
89
+ <span>
90
+ <span className="text-gray-400 mr-1">Min:</span>
91
+ <span className="font-medium text-gray-700">{summary.min.toLocaleString()}</span>
92
+ </span>
93
+ <span>
94
+ <span className="text-gray-400 mr-1">Max:</span>
95
+ <span className="font-medium text-gray-700">{summary.max.toLocaleString()}</span>
96
+ </span>
97
+ </div>
98
+ )}
99
+ </div>
100
+ );
101
+ }
102
+
103
+ SelectionSummaryBar.displayName = 'SelectionSummaryBar';
@@ -368,6 +368,148 @@ export const WithColumnGroups: Story = {
368
368
  },
369
369
  };
370
370
 
371
+ // With column groups and vertical scrolling to test sticky headers
372
+ export const WithColumnGroupsScrolling: Story = {
373
+ render: () => {
374
+ // Generate more data to ensure we need to scroll
375
+ const largeUserData = Array.from({ length: 200 }, (_, i) => ({
376
+ id: i + 1,
377
+ name: `User ${i + 1}`,
378
+ email: `user${i + 1}@example.com`,
379
+ department: ['Engineering', 'Marketing', 'Sales', 'HR', 'Finance'][i % 5],
380
+ role: ['Admin', 'Manager', 'Developer', 'Designer', 'Analyst'][i % 5],
381
+ status: ['Active', 'Inactive', 'Pending'][i % 3],
382
+ salary: 50000 + Math.floor(Math.random() * 100000),
383
+ startDate: new Date(2020 + (i % 5), i % 12, (i % 28) + 1).toISOString().split('T')[0],
384
+ isActive: i % 3 !== 1,
385
+ // Additional work fields
386
+ manager: `Manager ${(i % 10) + 1}`,
387
+ team: ['Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon'][i % 5],
388
+ location: ['New York', 'San Francisco', 'London', 'Tokyo', 'Sydney'][i % 5],
389
+ level: ['Junior', 'Mid', 'Senior', 'Lead', 'Principal'][i % 5],
390
+ yearsExp: (i % 15) + 1,
391
+ }));
392
+
393
+ // Extended columns including additional work fields
394
+ const extendedColumns: SpreadsheetColumn<any>[] = [
395
+ { id: 'id', label: 'ID', width: 60, sortable: true, align: 'center' },
396
+ { id: 'name', label: 'Name', width: 150, sortable: true, filterable: true },
397
+ { id: 'email', label: 'Email', width: 220, sortable: true, filterable: true },
398
+ { id: 'department', label: 'Department', width: 120, sortable: true, filterable: true },
399
+ { id: 'role', label: 'Role', width: 120, sortable: true, filterable: true },
400
+ { id: 'status', label: 'Status', width: 100, sortable: true, filterable: true },
401
+ { id: 'manager', label: 'Manager', width: 120, sortable: true, filterable: true },
402
+ { id: 'team', label: 'Team', width: 100, sortable: true, filterable: true },
403
+ { id: 'location', label: 'Location', width: 120, sortable: true, filterable: true },
404
+ { id: 'level', label: 'Level', width: 100, sortable: true, filterable: true },
405
+ {
406
+ id: 'yearsExp',
407
+ label: 'Years Exp',
408
+ width: 90,
409
+ sortable: true,
410
+ type: 'number',
411
+ align: 'right',
412
+ },
413
+ {
414
+ id: 'salary',
415
+ label: 'Salary',
416
+ width: 120,
417
+ sortable: true,
418
+ type: 'number',
419
+ align: 'right',
420
+ render: (value) => `$${value.toLocaleString()}`,
421
+ },
422
+ { id: 'startDate', label: 'Start Date', width: 120, sortable: true, type: 'date' },
423
+ { id: 'isActive', label: 'Active', width: 80, type: 'boolean', align: 'center' },
424
+ ];
425
+
426
+ // Column groups with long names - work group has many columns so when collapsed the label wraps
427
+ const longNameColumnGroups: SpreadsheetColumnGroup[] = [
428
+ {
429
+ id: 'personal',
430
+ label: 'Personal Information Details',
431
+ columns: ['name', 'email'],
432
+ collapsible: true,
433
+ headerColor: '#e0f2fe',
434
+ },
435
+ {
436
+ id: 'work',
437
+ label: 'Work & Employment Information & Organization Structure',
438
+ columns: [
439
+ 'department',
440
+ 'role',
441
+ 'status',
442
+ 'manager',
443
+ 'team',
444
+ 'location',
445
+ 'level',
446
+ 'yearsExp',
447
+ ],
448
+ collapsible: true,
449
+ headerColor: '#fef3c7',
450
+ },
451
+ {
452
+ id: 'compensation',
453
+ label: 'Compensation & Benefits Package',
454
+ columns: ['salary', 'startDate'],
455
+ collapsible: true,
456
+ headerColor: '#d1fae5',
457
+ },
458
+ ];
459
+
460
+ return (
461
+ <div className="p-4">
462
+ <div className="mb-4 p-4 bg-amber-50 rounded-lg border border-amber-200">
463
+ <h3 className="font-semibold text-amber-900 mb-2">
464
+ Column Groups with Sticky Header Test
465
+ </h3>
466
+ <p className="text-sm text-amber-700 mb-3">
467
+ This story tests the sticky header behavior when using column groups. Scroll
468
+ down to verify that:
469
+ </p>
470
+ <ul className="text-sm text-amber-700 space-y-1 ml-4 list-disc">
471
+ <li>
472
+ The column group headers (Personal Info, Work Info, Compensation) stay
473
+ fixed at the top
474
+ </li>
475
+ <li>
476
+ The column headers (Name, Email, etc.) stay fixed below the group
477
+ headers
478
+ </li>
479
+ <li>The row index column stays visible</li>
480
+ <li>Group collapse/expand still works while scrolled</li>
481
+ </ul>
482
+ <p className="text-sm text-red-600 mt-3 font-medium">
483
+ Bug to reproduce: Collapse the "Work & Employment Information & Organization
484
+ Structure" group - the long header text wraps to 2 lines, which causes
485
+ scrolling issues with the sticky header.
486
+ </p>
487
+ </div>
488
+
489
+ <div
490
+ style={{
491
+ height: '500px',
492
+ overflow: 'auto',
493
+ border: '1px solid #e5e7eb',
494
+ borderRadius: '8px',
495
+ }}
496
+ >
497
+ <Spreadsheet
498
+ data={largeUserData}
499
+ columns={extendedColumns}
500
+ columnGroups={longNameColumnGroups}
501
+ getRowId={(row) => row.id}
502
+ showToolbar={false}
503
+ showPagination={false}
504
+ enableRowSelection
505
+ enableCellEditing={false}
506
+ />
507
+ </div>
508
+ </div>
509
+ );
510
+ },
511
+ };
512
+
371
513
  // Products spreadsheet
372
514
  export const ProductsSpreadsheet: Story = {
373
515
  args: {
@@ -1973,6 +2115,260 @@ export const PinnedColumnsWithGroups: Story = {
1973
2115
  };
1974
2116
 
1975
2117
  // With Right-Pinned Columns
2118
+ /**
2119
+ * Comprehensive test story that simulates a real-world logistics spreadsheet.
2120
+ * 30+ columns across 5 groups, editable cells, 200 rows — enough to test:
2121
+ * - Horizontal scrolling with many columns
2122
+ * - Double-click to edit
2123
+ * - Drag-to-select
2124
+ * - Ctrl/Cmd+A select all
2125
+ * - Zebra striping
2126
+ * - Sticky row index
2127
+ * - Enter to submit filter
2128
+ * - Compact vs normal mode toggle
2129
+ */
2130
+ interface DocketRecord {
2131
+ id: number;
2132
+ rego: string;
2133
+ driver: string;
2134
+ type: string;
2135
+ date: string;
2136
+ job: string;
2137
+ customer: string;
2138
+ custInv: string;
2139
+ from: string;
2140
+ tipSite: string;
2141
+ comment: string;
2142
+ docket: string;
2143
+ custDocket: string;
2144
+ uom: string;
2145
+ qty: number;
2146
+ rate: number;
2147
+ totalPre: number;
2148
+ totalPost: number;
2149
+ tipName: string;
2150
+ tipUom: string;
2151
+ tipQty: number;
2152
+ tipRate: number;
2153
+ tipPre: number;
2154
+ tipPost: number;
2155
+ tipInvoice: string;
2156
+ truckPre: number;
2157
+ truckPost: number;
2158
+ subRate: number;
2159
+ subPre: number;
2160
+ subPost: number;
2161
+ subInvoice: string;
2162
+ weekEnding: string;
2163
+ profit: number;
2164
+ }
2165
+
2166
+ const docketData: DocketRecord[] = Array.from({ length: 200 }, (_, i) => {
2167
+ const qty = Math.round((1 + Math.random() * 50) * 100) / 100;
2168
+ const rate = Math.round((10 + Math.random() * 90) * 100) / 100;
2169
+ const totalPre = Math.round(qty * rate * 100) / 100;
2170
+ const tipQty = Math.round((1 + Math.random() * 30) * 100) / 100;
2171
+ const tipRate = Math.round((5 + Math.random() * 40) * 100) / 100;
2172
+ const tipPre = Math.round(tipQty * tipRate * 100) / 100;
2173
+ const subRate = Math.round((20 + Math.random() * 60) * 100) / 100;
2174
+ const subPre = Math.round(qty * subRate * 100) / 100;
2175
+ const profit = Math.round((totalPre - tipPre - subPre) * 100) / 100;
2176
+
2177
+ return {
2178
+ id: i + 1,
2179
+ rego: ['SPR4RO', 'XO78SR', 'KO04KW', 'CL24SS', 'XP60LQ'][i % 5],
2180
+ driver: ['Donald Strathaam', 'James Urewis', 'Shaun Davis', 'Matthew Maddick', 'Steven Ayers'][i % 5],
2181
+ type: ['LNG', 'SUBBY'][i % 2],
2182
+ date: `2026-03-${String((i % 28) + 1).padStart(2, '0')}`,
2183
+ job: ['BORAL ALLOCATION', 'HOLCIM LYNWOOD - PEJAR', 'EARTHWORK FIVEDOCK', 'GAMUDA ECOR'][i % 4],
2184
+ customer: ['Boral Transport Ltd', 'Holcim (Australia) Pty Ltd', 'Earthworks Group', 'Gamuda Engineering'][i % 4],
2185
+ custInv: `INV-${String(i + 1).padStart(5, '0')}`,
2186
+ from: ['Unnamed Road, Marulan NSW 2579', 'Burrows Road South, Saint Peters NSW 2044', '25 Burrows Road South'][i % 3],
2187
+ tipSite: ['Goulburn Road, Pejar NSW 2583', 'Ada Avenue, Brookvale NSW 2100', '52 Bay Road, Taren Point'][i % 3],
2188
+ comment: i % 7 === 0 ? 'Check weight docket' : '',
2189
+ docket: `E0100${String(i + 24).padStart(2, '0')}`,
2190
+ custDocket: i % 3 === 0 ? `CD-${i + 100}` : '',
2191
+ uom: ['Tonne', 'Load', 'Hour'][i % 3],
2192
+ qty,
2193
+ rate,
2194
+ totalPre,
2195
+ totalPost: Math.round(totalPre * 1.1 * 100) / 100,
2196
+ tipName: ['Pejar Tip', 'Brookvale Tip', 'Taren Point Tip'][i % 3],
2197
+ tipUom: ['Tonne', 'Load'][i % 2],
2198
+ tipQty,
2199
+ tipRate,
2200
+ tipPre,
2201
+ tipPost: Math.round(tipPre * 1.1 * 100) / 100,
2202
+ tipInvoice: i % 4 === 0 ? `TIP-${i + 200}` : '',
2203
+ truckPre: Math.round((500 + Math.random() * 1500) * 100) / 100,
2204
+ truckPost: Math.round((550 + Math.random() * 1650) * 100) / 100,
2205
+ subRate,
2206
+ subPre,
2207
+ subPost: Math.round(subPre * 1.1 * 100) / 100,
2208
+ subInvoice: i % 5 === 0 ? `SUB-${i + 300}` : '',
2209
+ weekEnding: `2026-03-${String(Math.min(28, ((Math.floor(i / 7) + 1) * 7))).padStart(2, '0')}`,
2210
+ profit,
2211
+ };
2212
+ });
2213
+
2214
+ const docketColumns: SpreadsheetColumn<DocketRecord>[] = [
2215
+ { id: 'rego', label: 'Rego', width: 85, sortable: true, filterable: true, editable: true },
2216
+ { id: 'driver', label: 'Driver', width: 160, sortable: true, filterable: true },
2217
+ { id: 'type', label: 'Type', width: 70, sortable: true, filterable: true, editable: true, type: 'select', options: ['LNG', 'SUBBY'] },
2218
+ { id: 'date', label: 'Date', width: 100, sortable: true, filterable: true, type: 'date' },
2219
+ { id: 'job', label: 'Job', width: 180, sortable: true, filterable: true },
2220
+ { id: 'customer', label: 'Customer', width: 180, sortable: true, filterable: true },
2221
+ { id: 'custInv', label: 'Cust Inv#', width: 100, sortable: true, filterable: true, editable: true },
2222
+ { id: 'from', label: 'From', width: 200, sortable: true, filterable: true, editable: true },
2223
+ { id: 'tipSite', label: 'Tip Site', width: 200, sortable: true, filterable: true, editable: true },
2224
+ { id: 'comment', label: 'Comment', width: 150, sortable: true, filterable: true, editable: true },
2225
+ { id: 'docket', label: 'Docket', width: 90, sortable: true, filterable: true },
2226
+ { id: 'custDocket', label: 'Cust Docket', width: 100, sortable: true, filterable: true, editable: true },
2227
+ { id: 'uom', label: 'UoM', width: 70, editable: true, type: 'select', options: ['Tonne', 'Load', 'Hour'] },
2228
+ { id: 'qty', label: 'Qty', width: 70, sortable: true, type: 'number', align: 'right', editable: true },
2229
+ { id: 'rate', label: 'Rate', width: 70, sortable: true, type: 'number', align: 'right', editable: true },
2230
+ { id: 'totalPre', label: 'Total Pre', width: 85, type: 'number', align: 'right', render: (v) => `$${Number(v).toFixed(2)}` },
2231
+ { id: 'totalPost', label: 'Total Post', width: 85, type: 'number', align: 'right', render: (v) => `$${Number(v).toFixed(2)}` },
2232
+ { id: 'tipName', label: 'Tip Name', width: 140, sortable: true, filterable: true, editable: true },
2233
+ { id: 'tipUom', label: 'Tip UoM', width: 70, editable: true, type: 'select', options: ['Tonne', 'Load'] },
2234
+ { id: 'tipQty', label: 'Tip Qty', width: 70, sortable: true, filterable: true, type: 'number', align: 'right', editable: true },
2235
+ { id: 'tipRate', label: 'Tip Rate', width: 70, sortable: true, filterable: true, type: 'number', align: 'right', editable: true },
2236
+ { id: 'tipPre', label: 'Tip Pre', width: 80, type: 'number', align: 'right', render: (v) => `$${Number(v).toFixed(2)}` },
2237
+ { id: 'tipPost', label: 'Tip Post', width: 80, type: 'number', align: 'right', render: (v) => `$${Number(v).toFixed(2)}` },
2238
+ { id: 'tipInvoice', label: 'Tip Invoice', width: 100, sortable: true, filterable: true, editable: true },
2239
+ { id: 'truckPre', label: 'Truck Pre', width: 85, type: 'number', align: 'right', render: (v) => `$${Number(v).toFixed(2)}` },
2240
+ { id: 'truckPost', label: 'Truck Post', width: 85, type: 'number', align: 'right', render: (v) => `$${Number(v).toFixed(2)}` },
2241
+ { id: 'subRate', label: 'Sub Rate', width: 80, sortable: true, filterable: true, type: 'number', align: 'right', editable: true },
2242
+ { id: 'subPre', label: 'Sub Pre', width: 80, type: 'number', align: 'right', render: (v) => `$${Number(v).toFixed(2)}` },
2243
+ { id: 'subPost', label: 'Sub Post', width: 80, type: 'number', align: 'right', render: (v) => `$${Number(v).toFixed(2)}` },
2244
+ { id: 'subInvoice', label: 'Sub Invoice', width: 100, sortable: true, filterable: true, editable: true },
2245
+ { id: 'weekEnding', label: 'Week Ending', width: 100, sortable: true },
2246
+ {
2247
+ id: 'profit',
2248
+ label: 'Profit',
2249
+ width: 90,
2250
+ type: 'number',
2251
+ align: 'right',
2252
+ sortable: true,
2253
+ render: (v) => {
2254
+ const num = Number(v);
2255
+ const color = num < 0 ? 'text-red-600' : 'text-green-600';
2256
+ return <span className={`font-medium ${color}`}>${num.toFixed(2)}</span>;
2257
+ },
2258
+ },
2259
+ ];
2260
+
2261
+ const docketColumnGroups: SpreadsheetColumnGroup[] = [
2262
+ {
2263
+ id: 'customer-billing',
2264
+ label: 'Customer & Billing',
2265
+ columns: ['rego', 'driver', 'type', 'date', 'job', 'customer', 'custInv', 'from', 'tipSite', 'comment', 'docket', 'custDocket', 'uom', 'qty', 'rate', 'totalPre', 'totalPost'],
2266
+ headerColor: '#93C5FD',
2267
+ collapsible: true,
2268
+ },
2269
+ {
2270
+ id: 'tip-site',
2271
+ label: 'Tip Site',
2272
+ columns: ['tipName', 'tipUom', 'tipQty', 'tipRate', 'tipPre', 'tipPost', 'tipInvoice'],
2273
+ headerColor: '#86EFAC',
2274
+ collapsible: true,
2275
+ },
2276
+ {
2277
+ id: 'truck-hire',
2278
+ label: 'Truck Hire',
2279
+ columns: ['truckPre', 'truckPost'],
2280
+ headerColor: '#FDBA74',
2281
+ collapsible: true,
2282
+ },
2283
+ {
2284
+ id: 'subcontractor',
2285
+ label: 'Subcontractor',
2286
+ columns: ['subRate', 'subPre', 'subPost', 'subInvoice'],
2287
+ headerColor: '#D8B4FE',
2288
+ collapsible: true,
2289
+ },
2290
+ {
2291
+ id: 'summary',
2292
+ label: 'Summary',
2293
+ columns: ['weekEnding', 'profit'],
2294
+ headerColor: '#D1D5DB',
2295
+ collapsible: true,
2296
+ },
2297
+ ];
2298
+
2299
+ const COMPREHENSIVE_STORAGE_KEY = 'storybook-comprehensive-test-settings';
2300
+
2301
+ const getStoredComprehensiveSettings = () => {
2302
+ try {
2303
+ const stored = localStorage.getItem(COMPREHENSIVE_STORAGE_KEY);
2304
+ return stored ? JSON.parse(stored) : null;
2305
+ } catch { return null; }
2306
+ };
2307
+
2308
+ export const ComprehensiveTest: Story = {
2309
+ render: () => {
2310
+ const [data, setData] = useState(docketData);
2311
+ const storedSettings = getStoredComprehensiveSettings();
2312
+
2313
+ const handleCellsEdit = (edits: CellEdit[]) => {
2314
+ setData((prev) =>
2315
+ prev.map((row) => {
2316
+ const editsForRow = edits.filter((e) => e.rowId === row.id);
2317
+ if (editsForRow.length > 0) {
2318
+ const updates = editsForRow.reduce(
2319
+ (acc, edit) => ({ ...acc, [edit.columnId]: edit.value }),
2320
+ {}
2321
+ );
2322
+ return { ...row, ...updates };
2323
+ }
2324
+ return row;
2325
+ })
2326
+ );
2327
+ };
2328
+
2329
+ return (
2330
+ <div className="flex flex-col h-screen">
2331
+ <div className="p-3 bg-blue-50 border-b border-blue-200 shrink-0">
2332
+ <h3 className="font-semibold text-blue-900 mb-1">
2333
+ Comprehensive Test — Docket Management
2334
+ </h3>
2335
+ <p className="text-xs text-blue-700">
2336
+ 32 columns, 5 groups, 200 rows. Double-click to edit | Shift+Click for range | Cmd+A select all | Click header to select column | Sort via icon | Enter to submit filter | Resize columns by dragging header edges
2337
+ </p>
2338
+ </div>
2339
+
2340
+ <div className="flex-1 min-h-0 p-2">
2341
+ <Spreadsheet
2342
+ data={data}
2343
+ columns={docketColumns}
2344
+ columnGroups={docketColumnGroups}
2345
+ getRowId={(row) => row.id}
2346
+ onCellsEdit={handleCellsEdit}
2347
+ showToolbar
2348
+ showPagination
2349
+ enableRowSelection
2350
+ enableCellEditing
2351
+ enableHighlighting
2352
+ enableComments
2353
+ enableUndoRedo
2354
+ settings={{
2355
+ defaultPinnedColumns: storedSettings?.defaultPinnedColumns ?? ['__row_index__'],
2356
+ defaultPageSize: storedSettings?.defaultPageSize ?? 100,
2357
+ defaultZoom: storedSettings?.defaultZoom ?? 100,
2358
+ autoSave: storedSettings?.autoSave ?? true,
2359
+ compactView: storedSettings?.compactView ?? false,
2360
+ columnWidths: storedSettings?.columnWidths,
2361
+ }}
2362
+ onSettingsChange={(settings) => {
2363
+ localStorage.setItem(COMPREHENSIVE_STORAGE_KEY, JSON.stringify(settings));
2364
+ }}
2365
+ />
2366
+ </div>
2367
+ </div>
2368
+ );
2369
+ },
2370
+ };
2371
+
1976
2372
  export const WithRightPinnedColumns: Story = {
1977
2373
  render: () => {
1978
2374
  const [data, setData] = useState(sampleUsers);