@xcelsior/ui-spreadsheets 1.2.2 → 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 -1156
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.mjs +2023 -1048
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/styles/globals.css +156 -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 -17
  25. package/src/components/SelectionSummaryBar.tsx +103 -0
  26. package/src/components/Spreadsheet.stories.tsx +254 -0
  27. package/src/components/Spreadsheet.tsx +234 -189
  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,24 +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 in the group header row - no content, just maintains alignment
44
- // This should NOT be sticky vertically (no top: 0) - only the actual column header row should be sticky
44
+ // Group header row placeholder - always sticky left
45
45
  if (isSecondRow) {
46
46
  return (
47
47
  <th
48
48
  className={cn(
49
- 'border border-gray-200 text-center font-bold text-gray-700',
50
- compactMode ? 'text-[10px]' : 'text-xs',
49
+ 'border border-gray-200 text-center font-bold text-gray-700 sticky',
50
+ compactMode ? 'text-xs' : 'text-sm',
51
51
  cellPadding,
52
- isPinned ? 'z-30' : 'z-20',
53
52
  className
54
53
  )}
55
54
  style={{
56
55
  minWidth: `${ROW_INDEX_COLUMN_WIDTH}px`,
57
56
  width: `${ROW_INDEX_COLUMN_WIDTH}px`,
58
- // Only sticky horizontally if pinned, NOT vertically
59
- position: isPinned ? 'sticky' : undefined,
60
- left: isPinned ? 0 : undefined,
57
+ left: 0,
58
+ zIndex: 50,
61
59
  backgroundColor: highlightColor || 'rgb(243 244 246)',
62
60
  }}
63
61
  />
@@ -67,18 +65,16 @@ export function RowIndexColumnHeader({
67
65
  return (
68
66
  <th
69
67
  className={cn(
70
- 'border border-gray-200 text-center font-bold text-gray-700 group',
71
- 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',
72
70
  cellPadding,
73
- isPinned ? 'z-30' : 'z-20',
74
71
  className
75
72
  )}
76
73
  style={{
77
74
  minWidth: `${ROW_INDEX_COLUMN_WIDTH}px`,
78
75
  width: `${ROW_INDEX_COLUMN_WIDTH}px`,
79
- position: 'sticky',
80
- top: 0,
81
- left: isPinned ? 0 : undefined,
76
+ left: 0,
77
+ zIndex: 50,
82
78
  backgroundColor: highlightColor || 'rgb(243 244 246)',
83
79
  }}
84
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';
@@ -2115,6 +2115,260 @@ export const PinnedColumnsWithGroups: Story = {
2115
2115
  };
2116
2116
 
2117
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
+
2118
2372
  export const WithRightPinnedColumns: Story = {
2119
2373
  render: () => {
2120
2374
  const [data, setData] = useState(sampleUsers);