@opencorestack/opengridx 0.1.6 → 0.1.7

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 (129) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +9 -0
  3. package/dist/index.d.ts +6 -0
  4. package/dist/opengridx.es.js +6 -6
  5. package/dist/opengridx.es.js.map +1 -1
  6. package/dist/opengridx.umd.js +4 -4
  7. package/dist/opengridx.umd.js.map +1 -1
  8. package/docs/API_REFERENCE.md +177 -0
  9. package/docs/README.md +60 -0
  10. package/docs/components/cell.md +44 -0
  11. package/docs/components/column-group-header.md +57 -0
  12. package/docs/components/column-resize.md +63 -0
  13. package/docs/components/column-visibility.md +61 -0
  14. package/docs/components/datagrid.md +70 -0
  15. package/docs/components/filter-panel.md +26 -0
  16. package/docs/components/header.md +39 -0
  17. package/docs/components/pagination.md +34 -0
  18. package/docs/components/row.md +31 -0
  19. package/docs/components/toolbar.md +40 -0
  20. package/docs/components/tooltip.md +68 -0
  21. package/docs/customization/slots-api.md +227 -0
  22. package/docs/customization/theming.md +293 -0
  23. package/docs/features/aggregation-pivot.md +79 -0
  24. package/docs/features/cell-spanning.md +520 -0
  25. package/docs/features/clipboard.md +76 -0
  26. package/docs/features/custom-pagination.md +143 -0
  27. package/docs/features/data-source.md +72 -0
  28. package/docs/features/editing-reordering.md +99 -0
  29. package/docs/features/export-guide.md +434 -0
  30. package/docs/features/filtering.md +83 -0
  31. package/docs/features/infinite-scroll.md +87 -0
  32. package/docs/features/list-view.md +65 -0
  33. package/docs/features/loading-states.md +68 -0
  34. package/docs/features/master-detail.md +46 -0
  35. package/docs/features/pinning.md +67 -0
  36. package/docs/features/selection.md +70 -0
  37. package/docs/features/sorting-pagination.md +66 -0
  38. package/docs/features/state-persistence.md +67 -0
  39. package/docs/features/tree-data-grouping.md +67 -0
  40. package/docs/features/virtualization.md +188 -0
  41. package/docs/roadmap.md +71 -0
  42. package/lib/components/Cell/Cell.css +119 -0
  43. package/lib/components/Cell/Cell.tsx +264 -0
  44. package/lib/components/Cell/GridEditInputCell.css +62 -0
  45. package/lib/components/Cell/GridEditInputCell.tsx +152 -0
  46. package/lib/components/ColumnGroupHeader/ColumnGroupHeader.css +67 -0
  47. package/lib/components/ColumnGroupHeader/ColumnGroupHeader.tsx +220 -0
  48. package/lib/components/ColumnResizeHandle/ColumnResizeHandle.css +31 -0
  49. package/lib/components/ColumnResizeHandle/ColumnResizeHandle.tsx +85 -0
  50. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.css +137 -0
  51. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +190 -0
  52. package/lib/components/DataGrid/DataGrid.css +224 -0
  53. package/lib/components/DataGrid/DataGrid.tsx +2253 -0
  54. package/lib/components/DetailPanel/DetailPanel.css +36 -0
  55. package/lib/components/DetailPanel/DetailPanel.tsx +34 -0
  56. package/lib/components/FilterPanel/FilterPanel.css +196 -0
  57. package/lib/components/FilterPanel/FilterPanel.tsx +183 -0
  58. package/lib/components/FilterPanel/index.ts +1 -0
  59. package/lib/components/Header/ColumnMenu.css +68 -0
  60. package/lib/components/Header/ColumnMenu.tsx +249 -0
  61. package/lib/components/Header/Header.css +224 -0
  62. package/lib/components/Header/Header.tsx +516 -0
  63. package/lib/components/ListView/ListView.css +112 -0
  64. package/lib/components/ListView/ListViewRow.tsx +63 -0
  65. package/lib/components/Pagination/Pagination.css +91 -0
  66. package/lib/components/Pagination/Pagination.tsx +151 -0
  67. package/lib/components/QuickFilter/QuickFilter.css +23 -0
  68. package/lib/components/QuickFilter/QuickFilter.tsx +79 -0
  69. package/lib/components/Row/Row.css +126 -0
  70. package/lib/components/Row/Row.tsx +377 -0
  71. package/lib/components/Skeleton/Skeleton.css +55 -0
  72. package/lib/components/Skeleton/Skeleton.tsx +22 -0
  73. package/lib/components/SkeletonRow/SkeletonRow.css +32 -0
  74. package/lib/components/SkeletonRow/SkeletonRow.tsx +89 -0
  75. package/lib/components/SkeletonRow/index.ts +1 -0
  76. package/lib/components/Toolbar/GlobalSearch.css +113 -0
  77. package/lib/components/Toolbar/GlobalSearch.tsx +120 -0
  78. package/lib/components/Toolbar/GridToolbar.css +204 -0
  79. package/lib/components/Toolbar/GridToolbar.tsx +647 -0
  80. package/lib/components/Toolbar/PivotPanel.css +212 -0
  81. package/lib/components/Toolbar/PivotPanel.tsx +239 -0
  82. package/lib/components/Tooltip/Tooltip.css +59 -0
  83. package/lib/components/Tooltip/Tooltip.tsx +106 -0
  84. package/lib/components/ui/Button.css +89 -0
  85. package/lib/components/ui/Button.tsx +39 -0
  86. package/lib/components/ui/Checkbox.css +71 -0
  87. package/lib/components/ui/Checkbox.tsx +66 -0
  88. package/lib/components/ui/DragHandleIcon.css +19 -0
  89. package/lib/components/ui/DragHandleIcon.tsx +18 -0
  90. package/lib/components/ui/ExpandIcon.css +38 -0
  91. package/lib/components/ui/ExpandIcon.tsx +75 -0
  92. package/lib/components/ui/Input.css +59 -0
  93. package/lib/components/ui/Input.tsx +45 -0
  94. package/lib/components/ui/index.ts +15 -0
  95. package/lib/hooks/core/useDataGrid.ts +392 -0
  96. package/lib/hooks/core/useGridApiRef.ts +10 -0
  97. package/lib/hooks/features/useAggregation.ts +143 -0
  98. package/lib/hooks/features/useGridClipboard.ts +133 -0
  99. package/lib/hooks/features/useGridDataSource.ts +190 -0
  100. package/lib/hooks/features/useGridEditing.ts +100 -0
  101. package/lib/hooks/features/useGridSpanning.ts +349 -0
  102. package/lib/hooks/features/usePivot.ts +149 -0
  103. package/lib/hooks/useColumnReorder.ts +114 -0
  104. package/lib/hooks/useRowGrouping.ts +302 -0
  105. package/lib/hooks/useRowReorder.ts +83 -0
  106. package/lib/hooks/useTreeData.ts +323 -0
  107. package/lib/index.ts +106 -0
  108. package/lib/state/index.ts +17 -0
  109. package/lib/state/types.ts +48 -0
  110. package/lib/state/useGridStateStorage.ts +113 -0
  111. package/lib/styles/base/reset.css +21 -0
  112. package/lib/styles/base/variables.css +161 -0
  113. package/lib/styles/opengridx.css +27 -0
  114. package/lib/styles/opengridx.css.d.ts +4 -0
  115. package/lib/theme/DataGridThemeProvider.tsx +276 -0
  116. package/lib/theme/defaultStyles.ts +0 -0
  117. package/lib/theme/index.ts +21 -0
  118. package/lib/theme/presets.ts +148 -0
  119. package/lib/theme/types.ts +155 -0
  120. package/lib/types/index.ts +764 -0
  121. package/lib/utils/export/exportToExcelAdvanced.ts +516 -0
  122. package/lib/utils/export/index.ts +550 -0
  123. package/lib/utils/filtering/index.ts +240 -0
  124. package/lib/utils/pinning/index.ts +282 -0
  125. package/lib/utils/sorting/index.ts +55 -0
  126. package/lib/utils/virtualization/index.ts +106 -0
  127. package/lib/vite-env.d.ts +1 -0
  128. package/llms.txt +2 -1
  129. package/package.json +13 -4
@@ -0,0 +1,516 @@
1
+
2
+ import type { GridColDef, GridRowModel, GridAggregationModel } from '../../types';
3
+ import { formatAggregationValue } from '../../hooks/features/useAggregation';
4
+
5
+ // ─── Types ────────────────────────────────────────────────────────────────────
6
+
7
+ export interface ExcelColumnStyle {
8
+ /** Numeric format string, e.g. '$#,##0.00', '0.00%', 'yyyy-mm-dd' */
9
+ numFmt?: string;
10
+ /** Override column width (characters). Defaults to colDef.width / 7 */
11
+ width?: number;
12
+ /** Horizontal alignment override */
13
+ alignment?: 'left' | 'center' | 'right';
14
+ /**
15
+ * If true, the cell value (expected to be an image URL string) will be
16
+ * fetched and embedded as an inline image in the cell.
17
+ * Requires the image server to serve CORS headers. Falls back to the
18
+ * raw URL string if the fetch fails (a warning is logged to the console).
19
+ */
20
+ embedImage?: boolean;
21
+ /** Width of the embedded image in pixels (default: 40) */
22
+ imageWidth?: number;
23
+ /** Height of the embedded image in pixels (default: 40) */
24
+ imageHeight?: number;
25
+ }
26
+
27
+ export interface ExcelSheetDefinition {
28
+ /** Sheet tab name (max 31 chars) */
29
+ name: string;
30
+ /** Which rows to include. Default: 'all' */
31
+ rows?: 'all' | 'selected';
32
+ /** Include header row. Default: true */
33
+ includeHeaders?: boolean;
34
+ /** Include aggregation totals row at the bottom. Default: false */
35
+ includeSummary?: boolean;
36
+ /** Auto-filter dropdowns on header row. Default: true */
37
+ autoFilter?: boolean;
38
+ /** Freeze the header row. Default: true */
39
+ frozenHeader?: boolean;
40
+ /** Alternate row fill (#hex). Pass false to disable. Default: '#f8fafc' */
41
+ alternateRowColor?: string | false;
42
+ }
43
+
44
+ export interface ExcelAdvancedExportOptions {
45
+ /** Output filename (default: 'export.xlsx') */
46
+ fileName?: string;
47
+ /**
48
+ * Sheet definitions. If omitted, a single 'Data' sheet is created
49
+ * with all rows.
50
+ *
51
+ * Built-in special sheet type: `{ type: 'summary' }` renders a
52
+ * standalone aggregation sheet.
53
+ */
54
+ sheets?: (ExcelSheetDefinition | { type: 'summary'; name?: string })[];
55
+ /** Per-column style overrides, keyed by field name */
56
+ columnStyles?: Record<string, ExcelColumnStyle>;
57
+ /** Header row fill color (default: '#f1f5f9') */
58
+ headerFillColor?: string;
59
+ /** Header row text color (default: '#334155') */
60
+ headerTextColor?: string;
61
+ /** Header row font size (default: 10) */
62
+ headerFontSize?: number;
63
+ /** Body font size (default: 10) */
64
+ bodyFontSize?: number;
65
+ /** Current aggregation result */
66
+ aggregationResult?: Record<string, any> | null;
67
+ /** Current aggregation model */
68
+ aggregationModel?: GridAggregationModel | null;
69
+ /** Currently selected row IDs */
70
+ selectedRows?: (string | number)[];
71
+ }
72
+
73
+ // ─── Cell type inference ──────────────────────────────────────────────────────
74
+
75
+ function inferCellType(colDef: GridColDef<any>, value: any): 'number' | 'boolean' | 'date' | 'string' {
76
+ if (colDef.type === 'number') return 'number';
77
+ if (colDef.type === 'boolean') return 'boolean';
78
+ if (colDef.type === 'date') return 'date';
79
+ if (typeof value === 'number') return 'number';
80
+ if (typeof value === 'boolean') return 'boolean';
81
+ if (value instanceof Date) return 'date';
82
+ return 'string';
83
+ }
84
+
85
+ // ─── Default numFmt per column type ──────────────────────────────────────────
86
+
87
+ function defaultNumFmt(colDef: GridColDef<any>): string | undefined {
88
+ if (colDef.type === 'number') return '#,##0.##';
89
+ if (colDef.type === 'date') return 'yyyy-mm-dd';
90
+ return undefined;
91
+ }
92
+
93
+ // ─── Column display width (chars) ────────────────────────────────────────────
94
+
95
+ function colWidth(colDef: GridColDef<any>, override?: number): number {
96
+ if (override != null) return override;
97
+ const px = (colDef.width ?? 120) as number;
98
+ return Math.min(60, Math.max(8, (px - 5) / 7));
99
+ }
100
+
101
+ // ─── Image helpers ────────────────────────────────────────────────────────────
102
+
103
+ type SupportedImageExt = 'png' | 'jpeg' | 'gif';
104
+
105
+ function imageExtension(url: string, contentType?: string | null): SupportedImageExt {
106
+ const ct = contentType ?? '';
107
+ if (ct.includes('jpeg') || ct.includes('jpg')) return 'jpeg';
108
+ if (ct.includes('gif')) return 'gif';
109
+ const path = url.split('?')[0].toLowerCase();
110
+ if (path.endsWith('.jpg') || path.endsWith('.jpeg')) return 'jpeg';
111
+ if (path.endsWith('.gif')) return 'gif';
112
+ return 'png';
113
+ }
114
+
115
+ /**
116
+ * Fetch a list of image URLs in parallel.
117
+ * CORS failures are logged and skipped — raw URL text is written to the cell
118
+ * instead so data is never silently lost.
119
+ */
120
+ async function fetchImages(
121
+ urls: string[]
122
+ ): Promise<Map<string, { buffer: ArrayBuffer; extension: SupportedImageExt }>> {
123
+ const settled = await Promise.allSettled(
124
+ urls.map(async (url) => {
125
+ // ── data: URI — decode base64 directly, no network needed ──────────
126
+ if (url.startsWith('data:')) {
127
+ const commaIdx = url.indexOf(',');
128
+ const header = url.slice(0, commaIdx);
129
+ const b64 = url.slice(commaIdx + 1);
130
+ const binary = atob(b64);
131
+ const uint8 = new Uint8Array(binary.length);
132
+ for (let i = 0; i < binary.length; i++) uint8[i] = binary.charCodeAt(i);
133
+ const mimeMatch = header.match(/data:([^;,]+)/);
134
+ const mime = mimeMatch?.[1] ?? 'image/png';
135
+ let ext: SupportedImageExt = 'png';
136
+ if (mime.includes('jpeg') || mime.includes('jpg')) ext = 'jpeg';
137
+ else if (mime.includes('gif')) ext = 'gif';
138
+ return { url, buffer: uint8.buffer as ArrayBuffer, ext };
139
+ }
140
+
141
+ // ── Remote URL — fetch with CORS ──────────────────────────────────
142
+ const res = await fetch(url, { mode: 'cors' });
143
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
144
+ const buffer = await res.arrayBuffer();
145
+ const ext = imageExtension(url, res.headers.get('content-type'));
146
+ return { url, buffer, ext };
147
+ })
148
+ );
149
+ const map = new Map<string, { buffer: ArrayBuffer; extension: SupportedImageExt }>();
150
+ settled.forEach((result, i) => {
151
+ if (result.status === 'fulfilled') {
152
+ map.set(result.value.url, { buffer: result.value.buffer, extension: result.value.ext });
153
+ } else {
154
+ console.warn(
155
+ `[exportToExcelAdvanced] Cannot embed image "${urls[i]}" ` +
156
+ `(CORS/network error — raw URL written as fallback):`,
157
+ result.reason
158
+ );
159
+ }
160
+ });
161
+ return map;
162
+ }
163
+
164
+ // ─── Main export function ─────────────────────────────────────────────────────
165
+
166
+ /**
167
+ * Export the grid data to a real XLSX file using ExcelJS.
168
+ * Supports: typed cells, styled headers, column widths, frozen rows,
169
+ * auto-filter, alternating row colors, aggregation totals, and multi-sheet.
170
+ *
171
+ * ExcelJS is loaded lazily so it does not affect bundle size for apps
172
+ * that only use CSV/JSON export.
173
+ */
174
+ export async function exportToExcelAdvanced<R extends GridRowModel>(
175
+ rows: R[],
176
+ columns: GridColDef<R>[],
177
+ options: ExcelAdvancedExportOptions = {}
178
+ ): Promise<void> {
179
+ const {
180
+ fileName = 'export.xlsx',
181
+ sheets: sheetDefs,
182
+ columnStyles = {},
183
+ headerFillColor = '#f1f5f9',
184
+ headerTextColor = '#334155',
185
+ headerFontSize = 10,
186
+ bodyFontSize = 10,
187
+ aggregationResult = null,
188
+ aggregationModel = null,
189
+ selectedRows,
190
+ } = options;
191
+
192
+ // Lazy-load ExcelJS to keep initial bundle lean
193
+ const ExcelJS = (await import('exceljs')).default;
194
+ const workbook = new ExcelJS.Workbook();
195
+ workbook.creator = 'OpenGridX';
196
+ workbook.created = new Date();
197
+
198
+ // Filter system columns — cast to GridColDef<any> to avoid generic covariance issues
199
+ const exportColumns: GridColDef<any>[] = (columns as GridColDef<any>[]).filter(col =>
200
+ col.exportable !== false &&
201
+ col.field !== '__check__' &&
202
+ col.field !== '__actions__' &&
203
+ !(col as any).isSpacer
204
+ );
205
+
206
+ const allRows: any[] = rows as any[];
207
+ const sRows: any[] = (selectedRows && selectedRows.length > 0
208
+ ? rows.filter(r => selectedRows.includes(r.id))
209
+ : rows) as any[];
210
+
211
+ // Normalise sheet definitions
212
+ const resolvedSheets: (ExcelSheetDefinition | { type: 'summary'; name?: string })[] =
213
+ sheetDefs ?? [{ name: 'Data', rows: 'all', includeHeaders: true, includeSummary: false }];
214
+
215
+ // Workbook-level image cache — one fetch per URL, reused across multiple sheets
216
+ const imageCache = new Map<string, number>();
217
+
218
+ // ─── Build each sheet ────────────────────────────────────────────────────
219
+
220
+ for (const sheetDef of resolvedSheets) {
221
+
222
+ // ── Summary-only sheet ───────────────────────────────────────────────
223
+ if ('type' in sheetDef && sheetDef.type === 'summary') {
224
+ if (!aggregationResult || !aggregationModel) continue;
225
+ const ws = workbook.addWorksheet(sheetDef.name ?? 'Summary');
226
+
227
+ // Title row
228
+ ws.addRow(['Summary / Aggregation Totals']);
229
+ ws.getRow(1).font = { bold: true, size: headerFontSize + 2, color: { argb: argb(headerTextColor) } };
230
+ ws.addRow([]);
231
+
232
+ // Header: Field | Function | Value
233
+ const hdr = ws.addRow(['Column', 'Function', 'Value']);
234
+ hdr.eachCell(cell => {
235
+ cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: argb(headerFillColor) } };
236
+ cell.font = { bold: true, size: headerFontSize, color: { argb: argb(headerTextColor) } };
237
+ cell.border = bottomBorder();
238
+ });
239
+
240
+ exportColumns.forEach(col => {
241
+ const fn = aggregationModel![col.field];
242
+ const val = aggregationResult![col.field];
243
+ if (fn == null || val == null) return;
244
+ let formatted = val;
245
+ if (col.valueFormatter) {
246
+ formatted = col.valueFormatter({ value: val, row: {} as R, field: col.field });
247
+ } else {
248
+ formatted = formatAggregationValue(val, fn);
249
+ }
250
+ const row = ws.addRow([col.headerName ?? col.field, fn.toUpperCase(), formatted]);
251
+ row.getCell(3).alignment = { horizontal: 'right' };
252
+ row.getCell(3).font = { bold: true, size: bodyFontSize };
253
+ });
254
+
255
+ ws.getColumn(1).width = 20;
256
+ ws.getColumn(2).width = 12;
257
+ ws.getColumn(3).width = 18;
258
+ continue;
259
+ }
260
+
261
+ // ── Data sheet ───────────────────────────────────────────────────────
262
+ const def = sheetDef as ExcelSheetDefinition;
263
+ const {
264
+ name = 'Data',
265
+ rows: rowScope = 'all',
266
+ includeHeaders = true,
267
+ includeSummary = false,
268
+ autoFilter = true,
269
+ frozenHeader = true,
270
+ alternateRowColor = '#f8fafc',
271
+ } = def;
272
+
273
+ const rowsToExport = rowScope === 'selected' ? sRows : allRows;
274
+
275
+ // ─ Pre-fetch images for this sheet ──────────────────────────────────────────
276
+ const imageColumns = exportColumns.filter(col => columnStyles[col.field]?.embedImage);
277
+ if (imageColumns.length > 0) {
278
+ const urlsNeeded: string[] = [];
279
+ const seen = new Set<string>();
280
+ rowsToExport.forEach(row => {
281
+ imageColumns.forEach(col => {
282
+ const val = (row as any)[col.field];
283
+ if (typeof val === 'string' && val && !imageCache.has(val) && !seen.has(val)) {
284
+ urlsNeeded.push(val);
285
+ seen.add(val);
286
+ }
287
+ });
288
+ });
289
+ if (urlsNeeded.length > 0) {
290
+ const fetched = await fetchImages(urlsNeeded);
291
+ for (const [url, { buffer, extension }] of fetched) {
292
+ const imgId = workbook.addImage({ buffer, extension });
293
+ imageCache.set(url, imgId);
294
+ }
295
+ }
296
+ }
297
+
298
+ const ws = workbook.addWorksheet(name);
299
+
300
+ // ── Set column definitions ───────────────────────────────────────────
301
+ ws.columns = exportColumns.map(col => ({
302
+ key: col.field,
303
+ width: colWidth(col, columnStyles[col.field]?.width),
304
+ style: {
305
+ numFmt: columnStyles[col.field]?.numFmt ?? defaultNumFmt(col),
306
+ }
307
+ }));
308
+
309
+ // ── Header row ───────────────────────────────────────────────────────
310
+ if (includeHeaders) {
311
+ const headerValues = exportColumns.map(col => col.headerName ?? col.field);
312
+ const headerRow = ws.addRow(headerValues);
313
+ headerRow.height = 20;
314
+
315
+ headerRow.eachCell((cell, colIndex) => {
316
+ const colDef = exportColumns[colIndex - 1];
317
+ cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: argb(headerFillColor) } };
318
+ cell.font = { bold: true, size: headerFontSize, color: { argb: argb(headerTextColor) } };
319
+ cell.alignment = {
320
+ horizontal: columnStyles[colDef?.field]?.alignment ??
321
+ (colDef?.type === 'number' ? 'right' : 'left'),
322
+ vertical: 'middle'
323
+ };
324
+ cell.border = bottomBorder();
325
+ });
326
+
327
+ if (autoFilter) {
328
+ ws.autoFilter = {
329
+ from: { row: 1, column: 1 },
330
+ to: { row: 1, column: exportColumns.length }
331
+ };
332
+ }
333
+
334
+ if (frozenHeader) {
335
+ ws.views = [{ state: 'frozen', ySplit: 1, showGridLines: false }];
336
+ } else {
337
+ ws.views = [{ showGridLines: false }];
338
+ }
339
+ }
340
+
341
+ // ── Data rows ────────────────────────────────────────────────────────
342
+ // ExcelJS addImage tl.row is 0-based.
343
+ // Header occupies index 0 when present; first data row is at index 1.
344
+ let excelRowIdx = includeHeaders ? 1 : 0;
345
+
346
+ rowsToExport.forEach((row, rowIdx) => {
347
+ const cellValues = exportColumns.map(col => {
348
+ // Image columns: write empty string; image placed below
349
+ if (columnStyles[col.field]?.embedImage) return '';
350
+
351
+ let value: any = (row as any)[col.field];
352
+ if (col.valueGetter) {
353
+ value = col.valueGetter({ row, field: col.field, value });
354
+ }
355
+ const cellType = inferCellType(col, value);
356
+ if (cellType === 'string' && col.valueFormatter && value != null) {
357
+ value = col.valueFormatter({ value, row, field: col.field });
358
+ }
359
+ return value;
360
+ });
361
+
362
+ const dataRow = ws.addRow(cellValues);
363
+ dataRow.font = { size: bodyFontSize };
364
+
365
+ // Alternate row background
366
+ if (alternateRowColor && rowIdx % 2 === 1) {
367
+ dataRow.eachCell(cell => {
368
+ cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: argb(alternateRowColor) } };
369
+ });
370
+ }
371
+
372
+ // Per-cell alignment and explicit web-like borders
373
+ dataRow.eachCell((cell, colIndex) => {
374
+ const colDef = exportColumns[colIndex - 1];
375
+ if (!colDef) return;
376
+ cell.alignment = {
377
+ horizontal: columnStyles[colDef.field]?.alignment ?? (colDef.type === 'number' ? 'right' : 'left'),
378
+ vertical: 'middle',
379
+ };
380
+ cell.border = { bottom: { style: 'thin', color: { argb: 'FFf1f5f9' } } }; // Slate 50
381
+ });
382
+
383
+ // ── Embed images for this row ─────────────────────────────────────
384
+ //
385
+ // ExcelJS images are floating objects anchored at tl.{col,row}.
386
+ // Both values accept fractions (0–1 represents one cell length).
387
+ // Adding an offset fraction shifts the anchor rightward / downward,
388
+ // letting us centre the image inside its cell.
389
+
390
+ if (imageColumns.length > 0) {
391
+ // Pre-compute the final row height so vertical centring is exact.
392
+ const maxImgH = imageColumns.reduce((max, col) => {
393
+ return Math.max(max, columnStyles[col.field]?.imageHeight ?? 40);
394
+ }, 0);
395
+ // ExcelJS row height = points; 1 pt ≈ 1.333 px at 96 dpi
396
+ const rowHeightPt = Math.ceil(maxImgH / 1.333);
397
+ const rowHeightPx = rowHeightPt * 1.333;
398
+ dataRow.height = rowHeightPt;
399
+
400
+ imageColumns.forEach(col => {
401
+ const colIdx = exportColumns.indexOf(col);
402
+ const url = (row as any)[col.field];
403
+ const imgId = typeof url === 'string' ? imageCache.get(url) : undefined;
404
+
405
+ if (imgId === undefined) {
406
+ // Fetch failed — write URL text so data isn't lost
407
+ dataRow.getCell(colIdx + 1).value = typeof url === 'string' ? url : '';
408
+ return;
409
+ }
410
+
411
+ const style = columnStyles[col.field];
412
+ const imgW = style?.imageWidth ?? 40;
413
+ const imgH = style?.imageHeight ?? 40;
414
+
415
+ // ── Horizontal centre within the column ───────────────────
416
+ // ws.getColumn uses 1-based index; width is in Excel "chars".
417
+ // Excel column width in pixels for Calibri 11 (MaxDigitWidth=7, Padding=5).
418
+ const colWidthChars = (ws.getColumn(colIdx + 1).width ?? 8) as number;
419
+ const colWidthPx = colWidthChars * 7 + 5;
420
+ const hGapPx = Math.max(0, colWidthPx - imgW) / 2;
421
+
422
+ // ── Vertical centre within the row ────────────────────────
423
+ const vGapPx = Math.max(0, rowHeightPx - imgH) / 2;
424
+
425
+ // ExcelJS native offsets are in EMUs (1 pixel = 9525 EMUs).
426
+ // This provides absolute pixel precision regardless of cell size.
427
+ ws.addImage(imgId, {
428
+ tl: {
429
+ col: colIdx,
430
+ row: excelRowIdx,
431
+ nativeColOff: Math.floor(hGapPx * 9525),
432
+ nativeRowOff: Math.floor(vGapPx * 9525),
433
+ },
434
+ ext: { width: imgW, height: imgH },
435
+ editAs: 'oneCell',
436
+ } as any);
437
+ });
438
+ } else {
439
+ dataRow.height = 16;
440
+ }
441
+
442
+ excelRowIdx++;
443
+ });
444
+
445
+ // ── Aggregation totals row ────────────────────────────────────────────
446
+ if (includeSummary && aggregationResult && aggregationModel) {
447
+ // Label row
448
+ const labelValues = exportColumns.map(col => {
449
+ const fn = aggregationModel![col.field];
450
+ return fn ? fn.toUpperCase() : '';
451
+ });
452
+ const labelRow = ws.addRow(labelValues);
453
+ labelRow.height = 16;
454
+ labelRow.font = { size: bodyFontSize - 1, italic: true, color: { argb: 'FF94a3b8' } };
455
+ labelRow.eachCell(cell => {
456
+ cell.alignment = { horizontal: 'right', vertical: 'middle' };
457
+ cell.border = { top: { style: 'thin', color: { argb: 'FFcbd5e1' } } };
458
+ });
459
+
460
+ // Value row
461
+ const totalValues = exportColumns.map(col => {
462
+ const fn = aggregationModel![col.field];
463
+ const val = aggregationResult![col.field];
464
+ if (fn == null || val == null) return '';
465
+ if (col.valueFormatter) {
466
+ return col.valueFormatter({ value: val, row: {} as R, field: col.field });
467
+ }
468
+ return formatAggregationValue(val, fn);
469
+ });
470
+ const totalRow = ws.addRow(totalValues);
471
+ totalRow.height = 18;
472
+ totalRow.font = { bold: true, size: bodyFontSize, color: { argb: argb(headerTextColor) } };
473
+ totalRow.eachCell(cell => {
474
+ cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: argb(headerFillColor) } };
475
+ cell.alignment = { horizontal: 'right', vertical: 'middle' };
476
+ cell.border = topBorder();
477
+ });
478
+ }
479
+ }
480
+
481
+ // ─── Write & download ────────────────────────────────────────────────────
482
+ const buffer = await workbook.xlsx.writeBuffer();
483
+ const blob = new Blob([buffer], {
484
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
485
+ });
486
+ const url = URL.createObjectURL(blob);
487
+ const a = document.createElement('a');
488
+ a.href = url;
489
+ a.download = fileName.endsWith('.xlsx') ? fileName : `${fileName}.xlsx`;
490
+ document.body.appendChild(a);
491
+ a.click();
492
+ document.body.removeChild(a);
493
+ URL.revokeObjectURL(url);
494
+ }
495
+
496
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
497
+
498
+ /** Convert '#rrggbb' or '#rgb' to ExcelJS ARGB 'FFrrggbb' */
499
+ function argb(hex: string): string {
500
+ const clean = hex.replace('#', '');
501
+ if (clean.length === 3) {
502
+ return 'FF' + clean.split('').map(c => c + c).join('');
503
+ }
504
+ if (clean.length === 6) {
505
+ return 'FF' + clean;
506
+ }
507
+ return 'FF' + clean.padEnd(6, '0');
508
+ }
509
+
510
+ function bottomBorder(): Partial<import('exceljs').Borders> {
511
+ return { bottom: { style: 'medium', color: { argb: 'FFcbd5e1' } } };
512
+ }
513
+
514
+ function topBorder(): Partial<import('exceljs').Borders> {
515
+ return { top: { style: 'medium', color: { argb: 'FFcbd5e1' } } };
516
+ }