@opencorestack/opengridx 0.1.5 → 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.
- package/CHANGELOG.md +27 -0
- package/README.md +9 -0
- package/dist/index.d.ts +6 -0
- package/dist/opengridx.css +1 -1
- package/dist/opengridx.es.js +319 -319
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +7 -7
- package/dist/opengridx.umd.js.map +1 -1
- package/docs/API_REFERENCE.md +177 -0
- package/docs/README.md +60 -0
- package/docs/components/cell.md +44 -0
- package/docs/components/column-group-header.md +57 -0
- package/docs/components/column-resize.md +63 -0
- package/docs/components/column-visibility.md +61 -0
- package/docs/components/datagrid.md +70 -0
- package/docs/components/filter-panel.md +26 -0
- package/docs/components/header.md +39 -0
- package/docs/components/pagination.md +34 -0
- package/docs/components/row.md +31 -0
- package/docs/components/toolbar.md +40 -0
- package/docs/components/tooltip.md +68 -0
- package/docs/customization/slots-api.md +227 -0
- package/docs/customization/theming.md +293 -0
- package/docs/features/aggregation-pivot.md +79 -0
- package/docs/features/cell-spanning.md +520 -0
- package/docs/features/clipboard.md +76 -0
- package/docs/features/custom-pagination.md +143 -0
- package/docs/features/data-source.md +72 -0
- package/docs/features/editing-reordering.md +99 -0
- package/docs/features/export-guide.md +434 -0
- package/docs/features/filtering.md +83 -0
- package/docs/features/infinite-scroll.md +87 -0
- package/docs/features/list-view.md +65 -0
- package/docs/features/loading-states.md +68 -0
- package/docs/features/master-detail.md +46 -0
- package/docs/features/pinning.md +67 -0
- package/docs/features/selection.md +70 -0
- package/docs/features/sorting-pagination.md +66 -0
- package/docs/features/state-persistence.md +67 -0
- package/docs/features/tree-data-grouping.md +67 -0
- package/docs/features/virtualization.md +188 -0
- package/docs/roadmap.md +71 -0
- package/lib/components/Cell/Cell.css +119 -0
- package/lib/components/Cell/Cell.tsx +264 -0
- package/lib/components/Cell/GridEditInputCell.css +62 -0
- package/lib/components/Cell/GridEditInputCell.tsx +152 -0
- package/lib/components/ColumnGroupHeader/ColumnGroupHeader.css +67 -0
- package/lib/components/ColumnGroupHeader/ColumnGroupHeader.tsx +220 -0
- package/lib/components/ColumnResizeHandle/ColumnResizeHandle.css +31 -0
- package/lib/components/ColumnResizeHandle/ColumnResizeHandle.tsx +85 -0
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.css +137 -0
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +190 -0
- package/lib/components/DataGrid/DataGrid.css +224 -0
- package/lib/components/DataGrid/DataGrid.tsx +2253 -0
- package/lib/components/DetailPanel/DetailPanel.css +36 -0
- package/lib/components/DetailPanel/DetailPanel.tsx +34 -0
- package/lib/components/FilterPanel/FilterPanel.css +196 -0
- package/lib/components/FilterPanel/FilterPanel.tsx +183 -0
- package/lib/components/FilterPanel/index.ts +1 -0
- package/lib/components/Header/ColumnMenu.css +68 -0
- package/lib/components/Header/ColumnMenu.tsx +249 -0
- package/lib/components/Header/Header.css +224 -0
- package/lib/components/Header/Header.tsx +516 -0
- package/lib/components/ListView/ListView.css +112 -0
- package/lib/components/ListView/ListViewRow.tsx +63 -0
- package/lib/components/Pagination/Pagination.css +91 -0
- package/lib/components/Pagination/Pagination.tsx +151 -0
- package/lib/components/QuickFilter/QuickFilter.css +23 -0
- package/lib/components/QuickFilter/QuickFilter.tsx +79 -0
- package/lib/components/Row/Row.css +126 -0
- package/lib/components/Row/Row.tsx +377 -0
- package/lib/components/Skeleton/Skeleton.css +55 -0
- package/lib/components/Skeleton/Skeleton.tsx +22 -0
- package/lib/components/SkeletonRow/SkeletonRow.css +32 -0
- package/lib/components/SkeletonRow/SkeletonRow.tsx +89 -0
- package/lib/components/SkeletonRow/index.ts +1 -0
- package/lib/components/Toolbar/GlobalSearch.css +113 -0
- package/lib/components/Toolbar/GlobalSearch.tsx +120 -0
- package/lib/components/Toolbar/GridToolbar.css +204 -0
- package/lib/components/Toolbar/GridToolbar.tsx +647 -0
- package/lib/components/Toolbar/PivotPanel.css +212 -0
- package/lib/components/Toolbar/PivotPanel.tsx +239 -0
- package/lib/components/Tooltip/Tooltip.css +59 -0
- package/lib/components/Tooltip/Tooltip.tsx +106 -0
- package/lib/components/ui/Button.css +89 -0
- package/lib/components/ui/Button.tsx +39 -0
- package/lib/components/ui/Checkbox.css +71 -0
- package/lib/components/ui/Checkbox.tsx +66 -0
- package/lib/components/ui/DragHandleIcon.css +19 -0
- package/lib/components/ui/DragHandleIcon.tsx +18 -0
- package/lib/components/ui/ExpandIcon.css +38 -0
- package/lib/components/ui/ExpandIcon.tsx +75 -0
- package/lib/components/ui/Input.css +59 -0
- package/lib/components/ui/Input.tsx +45 -0
- package/lib/components/ui/index.ts +15 -0
- package/lib/hooks/core/useDataGrid.ts +392 -0
- package/lib/hooks/core/useGridApiRef.ts +10 -0
- package/lib/hooks/features/useAggregation.ts +143 -0
- package/lib/hooks/features/useGridClipboard.ts +133 -0
- package/lib/hooks/features/useGridDataSource.ts +190 -0
- package/lib/hooks/features/useGridEditing.ts +100 -0
- package/lib/hooks/features/useGridSpanning.ts +349 -0
- package/lib/hooks/features/usePivot.ts +149 -0
- package/lib/hooks/useColumnReorder.ts +114 -0
- package/lib/hooks/useRowGrouping.ts +302 -0
- package/lib/hooks/useRowReorder.ts +83 -0
- package/lib/hooks/useTreeData.ts +323 -0
- package/lib/index.ts +106 -0
- package/lib/state/index.ts +17 -0
- package/lib/state/types.ts +48 -0
- package/lib/state/useGridStateStorage.ts +113 -0
- package/lib/styles/base/reset.css +21 -0
- package/lib/styles/base/variables.css +161 -0
- package/lib/styles/opengridx.css +27 -0
- package/lib/styles/opengridx.css.d.ts +4 -0
- package/lib/theme/DataGridThemeProvider.tsx +276 -0
- package/lib/theme/defaultStyles.ts +0 -0
- package/lib/theme/index.ts +21 -0
- package/lib/theme/presets.ts +148 -0
- package/lib/theme/types.ts +155 -0
- package/lib/types/index.ts +764 -0
- package/lib/utils/export/exportToExcelAdvanced.ts +516 -0
- package/lib/utils/export/index.ts +550 -0
- package/lib/utils/filtering/index.ts +240 -0
- package/lib/utils/pinning/index.ts +282 -0
- package/lib/utils/sorting/index.ts +55 -0
- package/lib/utils/virtualization/index.ts +106 -0
- package/lib/vite-env.d.ts +1 -0
- package/llms.txt +12 -2
- 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
|
+
}
|