@efa-one/sdk 1.20.0 → 1.22.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.
- package/README.md +29 -1
- package/frontend/ui/DataTable.d.ts +14 -2
- package/frontend/ui/DataTable.js +133 -17
- package/frontend/ui/index.d.ts +1 -0
- package/frontend/ui/index.js +1 -0
- package/frontend/ui/tableExport.d.ts +22 -0
- package/frontend/ui/tableExport.js +254 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ sub-paths — this keeps `express` out of the frontend and `react` out of the ba
|
|
|
14
14
|
|---|---|---|
|
|
15
15
|
| `@efa-one/sdk/backend` | Node/Express | Auth + token exchange (`requireAuth`, `createExchangeRouter`, `requireInternalOrAuth` …), health router, service discovery + gateway client (`serviceClient`, `resolveService`), clients for audit/reporting/mail/notifications, permission resolution/registration (`getUserPermissions`, `registerPermissions`, `checkPermission`), capability registry (`registerApiMetadata`), backend i18n |
|
|
16
16
|
| `@efa-one/sdk/frontend` | Browser/React | postMessage IPC (`registerAppInfo`, `sendAtStart`, `navigateToApp`, `notifyRouteChange` …), react-i18next factory (`initI18n`), `DevHeader` |
|
|
17
|
-
| `@efa-one/sdk/frontend/ui` | Browser/React | The efa-one design-system kit: `Button`, `Input`, `Badge`, `Alert`, `Dialog`, `DropdownMenu`, `Tooltip`, `EmptyState`, `Skeleton`, `RecordDialog`, and the **`DataTable`** (sortable/filterable/groupable list with draggable, resizable columns
|
|
17
|
+
| `@efa-one/sdk/frontend/ui` | Browser/React | The efa-one design-system kit: `Button`, `Input`, `Badge`, `Alert`, `Dialog`, `DropdownMenu`, `Tooltip`, `EmptyState`, `Skeleton`, `RecordDialog`, and the **`DataTable`** (sortable/filterable/groupable list with draggable, resizable columns, a per-user persisted view and CSV/Excel export of the current view; reflows to stacked cards below 640px). Also exports `useIsMobile` — the platform-wide 640px breakpoint hook. Ship its companion CSS once: `import '@efa-one/sdk/frontend/ui/styles.css'` |
|
|
18
18
|
| `@efa-one/sdk/frontend/format` | Browser | Platform-wide date/time display — `formatDate` (`01.09.2026`), `formatDateTime` (`01.09.2026, 11:05`), `formatTime`, `formatFileStamp`, `localeForLanguage`. Always use these instead of `toLocaleDateString`/`toLocaleString`: without an options object those render `1.9.2026`, and without a locale argument they follow the browser language (`9/1/2026`) |
|
|
19
19
|
| `@efa-one/sdk/frontend/viewPreferences` | Browser/React | Persistence seam for `DataTable` — `createViewPreferencesClient()` (standard `/api/view-preferences/:listId` adapter) + `useViewPreferences()` |
|
|
20
20
|
|
|
@@ -105,6 +105,11 @@ const columns: ColumnDef<Item>[] = [
|
|
|
105
105
|
<DataTable listId="items.list" rows={items} columns={columns} rowKey={(r) => r.id} persistence={viewPrefs} />
|
|
106
106
|
```
|
|
107
107
|
|
|
108
|
+
Columns added later show up in views users saved before (at their natural position) — no
|
|
109
|
+
need to bump `DEFAULT_VIEW_VERSION` and reset everyone's view. Grouped lists open collapsed;
|
|
110
|
+
pass `groupsDefaultExpanded` (e.g. with `initialPrefs={{ groupBy: ['category'] }}`) to start
|
|
111
|
+
with every group expanded while keeping groups the user collapses closed.
|
|
112
|
+
|
|
108
113
|
## Build your first app
|
|
109
114
|
|
|
110
115
|
This repo ships a ready-to-copy app scaffold under [`template/`](./template) — the fastest
|
|
@@ -211,6 +216,29 @@ chronologically, groups by day and lets the text filter match the displayed form
|
|
|
211
216
|
the app's job: use `formatDate`/`formatDateTime`, never `toLocaleDateString` (that is
|
|
212
217
|
where `7.10.2026` comes from). No backend or kernel change.
|
|
213
218
|
|
|
219
|
+
**1.21.0 — new columns in old views, groups that start open.** A column added to
|
|
220
|
+
`columns` after a user saved their view now appears in that view at its natural position
|
|
221
|
+
(and stays hidden if it has `defaultVisible: false`), and can be dragged like any other —
|
|
222
|
+
before, it was missing until the user reset the view. New prop `groupsDefaultExpanded`:
|
|
223
|
+
groups start expanded; groups the user collapses stay collapsed. No backend or kernel
|
|
224
|
+
change.
|
|
225
|
+
|
|
226
|
+
**1.22.0 — export the current view.** The DataTable's gear menu now has „Excel" and
|
|
227
|
+
„CSV" buttons. Both export exactly what the user sees: filtered and sorted rows (in
|
|
228
|
+
group order when grouped, collapsed groups included), visible columns in their order.
|
|
229
|
+
CSV uses `;` as separator, `,` as decimal mark and a UTF-8 BOM, so German Excel opens
|
|
230
|
+
it by double-click; text that starts like a formula is prefixed with `'`. The .xlsx is
|
|
231
|
+
written without a dependency (stored ZIP): bold, light-grey header row with thin
|
|
232
|
+
borders, frozen, with autofilter; numbers and dates are real Excel values
|
|
233
|
+
(`dd.mm.yyyy`). Values come from `accessor`, not from `cell`; multi-select filter
|
|
234
|
+
codes are exported as their labels. New `ColumnDef` fields `exportValue` (own export
|
|
235
|
+
value) and `exportable: false` (leave out, e.g. action columns), new props
|
|
236
|
+
`exportFileName` (default: `listId`, plus a timestamp) and `exportable={false}` (hide
|
|
237
|
+
the export, e.g. without an export permission). `toCsv`/`toXlsx`/`downloadFile` are
|
|
238
|
+
exported for app-side use. CSV is CSV: Excel turns `01067` into `1067` and long digit
|
|
239
|
+
strings into `1,23E+18`, and shows the `'` in front of `+49 …` — point users to the
|
|
240
|
+
Excel export for such data. No backend or kernel change.
|
|
241
|
+
|
|
214
242
|
## License
|
|
215
243
|
|
|
216
244
|
[Apache-2.0](./LICENSE) — permissive with a patent grant, so customers and partners
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { type ViewPreferencesAdapter } from '../viewPreferences.js';
|
|
3
|
+
import { type ExportCell, type ExportTable } from './tableExport.js';
|
|
3
4
|
export type FilterDef = {
|
|
4
5
|
type: 'text';
|
|
5
6
|
} | {
|
|
@@ -23,6 +24,8 @@ export interface ColumnDef<T> {
|
|
|
23
24
|
sortable?: boolean;
|
|
24
25
|
width?: string;
|
|
25
26
|
defaultVisible?: boolean;
|
|
27
|
+
exportValue?: (row: T) => string | number | boolean | Date | null | undefined;
|
|
28
|
+
exportable?: boolean;
|
|
26
29
|
}
|
|
27
30
|
interface FilterValue {
|
|
28
31
|
text?: string;
|
|
@@ -56,10 +59,18 @@ interface DataTableProps<T, K extends string | number> {
|
|
|
56
59
|
selection?: DataTableSelectionProps<K>;
|
|
57
60
|
initialPrefs?: Partial<ViewPrefs>;
|
|
58
61
|
footer?: React.ReactNode;
|
|
62
|
+
groupsDefaultExpanded?: boolean;
|
|
59
63
|
persistence?: ViewPreferencesAdapter;
|
|
64
|
+
exportable?: boolean;
|
|
65
|
+
exportFileName?: string;
|
|
60
66
|
}
|
|
61
67
|
export declare function defaultPrefsFor<T>(columns: ColumnDef<T>[], overrides?: Partial<ViewPrefs>): ViewPrefs;
|
|
68
|
+
export declare function reconcileOrder<T>(columns: ColumnDef<T>[], order: string[]): string[];
|
|
69
|
+
export declare function isColumnVisible<T>(column: ColumnDef<T>, visibility: Record<string, boolean>): boolean;
|
|
62
70
|
export declare function formatCellValue<T>(col: ColumnDef<T>, row: T): string;
|
|
71
|
+
export declare function exportCellOf<T>(col: ColumnDef<T>, row: T): ExportCell;
|
|
72
|
+
export declare function buildExportTable<T>(rows: T[], visibleColumns: ColumnDef<T>[]): ExportTable;
|
|
73
|
+
export declare function exportFileBase(name: string, now?: Date): string;
|
|
63
74
|
export declare const MIN_COLUMN_WIDTH = 60;
|
|
64
75
|
export declare const MAX_COLUMN_WIDTH = 1600;
|
|
65
76
|
export declare function clampColumnWidth(px: number): number;
|
|
@@ -75,6 +86,7 @@ interface RenderItem<T> {
|
|
|
75
86
|
expanded?: boolean;
|
|
76
87
|
row?: T;
|
|
77
88
|
}
|
|
78
|
-
export declare function buildRenderItems<T>(rows: T[], groupBy: string[], columnsById: Map<string, ColumnDef<T>>, expandedGroups: Set<string>): RenderItem<T>[];
|
|
79
|
-
export declare function
|
|
89
|
+
export declare function buildRenderItems<T>(rows: T[], groupBy: string[], columnsById: Map<string, ColumnDef<T>>, expandedGroups: Set<string> | 'all'): RenderItem<T>[];
|
|
90
|
+
export declare function collectAllGroupKeys<T>(rows: T[], groupBy: string[], columnsById: Map<string, ColumnDef<T>>): string[];
|
|
91
|
+
export declare function DataTable<T, K extends string | number>({ listId, rows, columns, rowKey, onRowClick, selection, initialPrefs, footer, groupsDefaultExpanded, persistence, exportable, exportFileName, }: DataTableProps<T, K>): React.ReactElement;
|
|
80
92
|
export {};
|
package/frontend/ui/DataTable.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import * as DropdownMenu from './DropdownMenu.js';
|
|
4
|
-
import { ChevronDown, ChevronUp, ChevronRight, Settings, EyeOff, Filter, RotateCcw, ArrowUp, ArrowDown, Layers, X, GripVertical } from 'lucide-react';
|
|
4
|
+
import { ChevronDown, ChevronUp, ChevronRight, Settings, EyeOff, Filter, RotateCcw, ArrowUp, ArrowDown, Layers, X, GripVertical, Download } from 'lucide-react';
|
|
5
5
|
import { Button } from './Button.js';
|
|
6
6
|
import { useViewPreferences } from '../viewPreferences.js';
|
|
7
7
|
import { useIsMobile } from '../useIsMobile.js';
|
|
8
|
-
import { formatDate, formatDateTime } from '../format.js';
|
|
8
|
+
import { formatDate, formatDateTime, formatFileStamp } from '../format.js';
|
|
9
|
+
import { downloadFile, toCsv, toXlsx, trimChar, XLSX_MIME } from './tableExport.js';
|
|
9
10
|
const GROUP_KEY_SEP = '\x1f';
|
|
10
11
|
const EMPTY_GROUP_LABEL = '– ohne –';
|
|
11
12
|
export const DEFAULT_VIEW_VERSION = 2;
|
|
@@ -23,6 +24,21 @@ export function defaultPrefsFor(columns, overrides) {
|
|
|
23
24
|
...(overrides ?? {}),
|
|
24
25
|
};
|
|
25
26
|
}
|
|
27
|
+
export function reconcileOrder(columns, order) {
|
|
28
|
+
const known = new Set(columns.map((c) => c.id));
|
|
29
|
+
const result = order.filter((id) => known.has(id));
|
|
30
|
+
const present = new Set(result);
|
|
31
|
+
columns.forEach((c, idx) => {
|
|
32
|
+
if (present.has(c.id))
|
|
33
|
+
return;
|
|
34
|
+
result.splice(Math.min(idx, result.length), 0, c.id);
|
|
35
|
+
present.add(c.id);
|
|
36
|
+
});
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
export function isColumnVisible(column, visibility) {
|
|
40
|
+
return visibility[column.id] ?? column.defaultVisible !== false;
|
|
41
|
+
}
|
|
26
42
|
function isDateColumn(col, value) {
|
|
27
43
|
return col.type === 'date' || col.type === 'datetime' || (col.type == null && value instanceof Date);
|
|
28
44
|
}
|
|
@@ -39,6 +55,47 @@ export function formatCellValue(col, row) {
|
|
|
39
55
|
}
|
|
40
56
|
return String(v ?? '');
|
|
41
57
|
}
|
|
58
|
+
export function exportCellOf(col, row) {
|
|
59
|
+
const v = col.exportValue ? col.exportValue(row) : col.accessor(row);
|
|
60
|
+
if (v == null || v === '')
|
|
61
|
+
return null;
|
|
62
|
+
if (isDateColumn(col, v)) {
|
|
63
|
+
const t = timeOf(v);
|
|
64
|
+
if (t != null)
|
|
65
|
+
return { date: new Date(t), withTime: col.type === 'datetime' };
|
|
66
|
+
return col.exportValue ? String(v) : null;
|
|
67
|
+
}
|
|
68
|
+
const f = col.exportValue ? undefined : col.filter;
|
|
69
|
+
if (typeof v === 'boolean') {
|
|
70
|
+
if (f?.type === 'boolean')
|
|
71
|
+
return v ? (f.labelTrue ?? 'Ja') : (f.labelFalse ?? 'Nein');
|
|
72
|
+
return v ? 'Ja' : 'Nein';
|
|
73
|
+
}
|
|
74
|
+
if (typeof v === 'number') {
|
|
75
|
+
if (!Number.isFinite(v))
|
|
76
|
+
return null;
|
|
77
|
+
const opt = f?.type === 'multi-select' ? f.options.find((o) => o.value === String(v)) : undefined;
|
|
78
|
+
return opt ? opt.label : v;
|
|
79
|
+
}
|
|
80
|
+
const labelOf = (x) => {
|
|
81
|
+
const s = String(x);
|
|
82
|
+
return f?.type === 'multi-select' ? (f.options.find((o) => o.value === s)?.label ?? s) : s;
|
|
83
|
+
};
|
|
84
|
+
if (Array.isArray(v))
|
|
85
|
+
return v.map(labelOf).join(', ');
|
|
86
|
+
return labelOf(v);
|
|
87
|
+
}
|
|
88
|
+
export function buildExportTable(rows, visibleColumns) {
|
|
89
|
+
const cols = visibleColumns.filter((c) => c.exportable !== false);
|
|
90
|
+
return {
|
|
91
|
+
headers: cols.map((c) => c.label),
|
|
92
|
+
rows: rows.map((r) => cols.map((c) => exportCellOf(c, r))),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
export function exportFileBase(name, now = new Date()) {
|
|
96
|
+
const base = trimChar(name.trim().replace(/[\\/:*?"<>|\s]+/g, '_'), '_') || 'export';
|
|
97
|
+
return `${base}_${formatFileStamp(now)}`;
|
|
98
|
+
}
|
|
42
99
|
export const MIN_COLUMN_WIDTH = 60;
|
|
43
100
|
export const MAX_COLUMN_WIDTH = 1600;
|
|
44
101
|
const KEYBOARD_RESIZE_STEP = 16;
|
|
@@ -76,6 +133,12 @@ export function groupLabelOf(value) {
|
|
|
76
133
|
const s = String(value).trim();
|
|
77
134
|
return s;
|
|
78
135
|
}
|
|
136
|
+
function groupBucketLabel(col, raw) {
|
|
137
|
+
const lbl = isDateColumn(col, raw)
|
|
138
|
+
? (timeOf(raw) == null ? '' : formatDate(raw))
|
|
139
|
+
: groupLabelOf(raw);
|
|
140
|
+
return lbl || EMPTY_GROUP_LABEL;
|
|
141
|
+
}
|
|
79
142
|
export function buildRenderItems(rows, groupBy, columnsById, expandedGroups) {
|
|
80
143
|
if (groupBy.length === 0)
|
|
81
144
|
return rows.map((row) => ({ kind: 'row', row }));
|
|
@@ -98,8 +161,7 @@ export function buildRenderItems(rows, groupBy, columnsById, expandedGroups) {
|
|
|
98
161
|
const isDate = isDateColumn(col, raw);
|
|
99
162
|
if (isDate)
|
|
100
163
|
chronological = true;
|
|
101
|
-
const
|
|
102
|
-
const bucketLabel = lbl || EMPTY_GROUP_LABEL;
|
|
164
|
+
const bucketLabel = groupBucketLabel(col, raw);
|
|
103
165
|
const arr = buckets.get(bucketLabel) ?? [];
|
|
104
166
|
arr.push(r);
|
|
105
167
|
buckets.set(bucketLabel, arr);
|
|
@@ -118,7 +180,7 @@ export function buildRenderItems(rows, groupBy, columnsById, expandedGroups) {
|
|
|
118
180
|
for (const label of labels) {
|
|
119
181
|
const childRows = buckets.get(label);
|
|
120
182
|
const key = parentKey ? `${parentKey}${GROUP_KEY_SEP}${label}` : label;
|
|
121
|
-
const expanded = expandedGroups.has(key);
|
|
183
|
+
const expanded = expandedGroups === 'all' || expandedGroups.has(key);
|
|
122
184
|
out.push({
|
|
123
185
|
kind: 'group',
|
|
124
186
|
key,
|
|
@@ -134,7 +196,31 @@ export function buildRenderItems(rows, groupBy, columnsById, expandedGroups) {
|
|
|
134
196
|
recurse(rows, 0, '');
|
|
135
197
|
return out;
|
|
136
198
|
}
|
|
137
|
-
export function
|
|
199
|
+
export function collectAllGroupKeys(rows, groupBy, columnsById) {
|
|
200
|
+
const keys = [];
|
|
201
|
+
const recurse = (subset, depth, parentKey) => {
|
|
202
|
+
if (depth >= groupBy.length)
|
|
203
|
+
return;
|
|
204
|
+
const col = columnsById.get(groupBy[depth]);
|
|
205
|
+
if (!col)
|
|
206
|
+
return;
|
|
207
|
+
const buckets = new Map();
|
|
208
|
+
for (const r of subset) {
|
|
209
|
+
const lbl = groupBucketLabel(col, col.accessor(r));
|
|
210
|
+
const arr = buckets.get(lbl) ?? [];
|
|
211
|
+
arr.push(r);
|
|
212
|
+
buckets.set(lbl, arr);
|
|
213
|
+
}
|
|
214
|
+
for (const [label, childRows] of buckets) {
|
|
215
|
+
const key = parentKey ? `${parentKey}${GROUP_KEY_SEP}${label}` : label;
|
|
216
|
+
keys.push(key);
|
|
217
|
+
recurse(childRows, depth + 1, key);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
recurse(rows, 0, '');
|
|
221
|
+
return keys;
|
|
222
|
+
}
|
|
223
|
+
export function DataTable({ listId, rows, columns, rowKey, onRowClick, selection, initialPrefs, footer, groupsDefaultExpanded, persistence, exportable = true, exportFileName, }) {
|
|
138
224
|
const defaultPrefs = useMemo(() => defaultPrefsFor(columns, initialPrefs), [columns, initialPrefs]);
|
|
139
225
|
const [prefs, setPrefs, reset] = useViewPreferences(listId, defaultPrefs, persistence);
|
|
140
226
|
const isMobile = useIsMobile();
|
|
@@ -155,12 +241,27 @@ export function DataTable({ listId, rows, columns, rowKey, onRowClick, selection
|
|
|
155
241
|
const [dragColumnId, setDragColumnId] = useState(null);
|
|
156
242
|
const [dropTarget, setDropTarget] = useState(null);
|
|
157
243
|
const containerRef = useRef(null);
|
|
244
|
+
const effectiveOrder = useMemo(() => reconcileOrder(columns, prefs.columnOrder), [columns, prefs.columnOrder]);
|
|
245
|
+
const seenGroupKeysRef = useRef(new Set());
|
|
246
|
+
useEffect(() => {
|
|
247
|
+
if (!groupsDefaultExpanded || groupBy.length === 0)
|
|
248
|
+
return;
|
|
249
|
+
const fresh = collectAllGroupKeys(rows, groupBy, columnsById).filter((k) => !seenGroupKeysRef.current.has(k));
|
|
250
|
+
if (fresh.length === 0)
|
|
251
|
+
return;
|
|
252
|
+
fresh.forEach((k) => seenGroupKeysRef.current.add(k));
|
|
253
|
+
setExpandedGroups((prev) => {
|
|
254
|
+
const next = new Set(prev);
|
|
255
|
+
fresh.forEach((k) => next.add(k));
|
|
256
|
+
return next;
|
|
257
|
+
});
|
|
258
|
+
}, [groupsDefaultExpanded, rows, groupBy, columnsById]);
|
|
158
259
|
const visibleColumns = useMemo(() => {
|
|
159
260
|
const byId = new Map(columns.map((c) => [c.id, c]));
|
|
160
|
-
return
|
|
261
|
+
return effectiveOrder
|
|
161
262
|
.map((id) => byId.get(id))
|
|
162
|
-
.filter((c) => Boolean(c) && prefs.columnVisibility
|
|
163
|
-
}, [columns,
|
|
263
|
+
.filter((c) => Boolean(c) && isColumnVisible(c, prefs.columnVisibility));
|
|
264
|
+
}, [columns, effectiveOrder, prefs.columnVisibility]);
|
|
164
265
|
const sortedFilteredRows = useMemo(() => {
|
|
165
266
|
let out = rows;
|
|
166
267
|
for (const col of columns) {
|
|
@@ -251,7 +352,7 @@ export function DataTable({ listId, rows, columns, rowKey, onRowClick, selection
|
|
|
251
352
|
});
|
|
252
353
|
};
|
|
253
354
|
const moveColumn = (columnId, dir) => {
|
|
254
|
-
const order = [...
|
|
355
|
+
const order = [...effectiveOrder];
|
|
255
356
|
const idx = order.indexOf(columnId);
|
|
256
357
|
if (idx < 0)
|
|
257
358
|
return;
|
|
@@ -262,7 +363,7 @@ export function DataTable({ listId, rows, columns, rowKey, onRowClick, selection
|
|
|
262
363
|
setPrefs({ ...prefs, columnOrder: order });
|
|
263
364
|
};
|
|
264
365
|
const dropColumn = (sourceId, targetId, side) => {
|
|
265
|
-
const order = reorderColumns(
|
|
366
|
+
const order = reorderColumns(effectiveOrder, sourceId, targetId, side);
|
|
266
367
|
if (order.join(GROUP_KEY_SEP) !== prefs.columnOrder.join(GROUP_KEY_SEP)) {
|
|
267
368
|
setPrefs({ ...prefs, columnOrder: order });
|
|
268
369
|
}
|
|
@@ -316,7 +417,22 @@ export function DataTable({ listId, rows, columns, rowKey, onRowClick, selection
|
|
|
316
417
|
}
|
|
317
418
|
setPrefs({ ...prefs, filters });
|
|
318
419
|
};
|
|
319
|
-
const
|
|
420
|
+
const runExport = (format) => {
|
|
421
|
+
const exportRows = buildRenderItems(sortedFilteredRows, groupBy, columnsById, 'all')
|
|
422
|
+
.filter((i) => i.kind === 'row')
|
|
423
|
+
.map((i) => i.row);
|
|
424
|
+
const table = buildExportTable(exportRows, visibleColumns);
|
|
425
|
+
const base = exportFileBase(exportFileName ?? listId);
|
|
426
|
+
if (format === 'csv')
|
|
427
|
+
downloadFile(toCsv(table), `${base}.csv`, 'text/csv;charset=utf-8');
|
|
428
|
+
else
|
|
429
|
+
downloadFile(toXlsx(table, exportFileName ?? 'Export'), `${base}.xlsx`, XLSX_MIME);
|
|
430
|
+
};
|
|
431
|
+
const gearButton = (_jsx(ColumnInventoryButton, { exportInfo: exportable ? {
|
|
432
|
+
rowCount: sortedFilteredRows.length,
|
|
433
|
+
hasColumns: visibleColumns.some((c) => c.exportable !== false),
|
|
434
|
+
run: runExport,
|
|
435
|
+
} : undefined, columns: columns, prefs: prefs, setColumnVisible: setColumnVisible, moveColumn: moveColumn, reset: reset, groupBy: groupBy, addGroupBy: addGroupBy, removeGroupBy: removeGroupBy, moveGroupBy: moveGroupBy, clearGroupBy: clearGroupBy }));
|
|
320
436
|
return (_jsxs("div", { ref: containerRef, className: "border border-[var(--color-border)] overflow-x-auto", style: { '--dt-cols': gridTemplate }, children: [selection && selection.selected.size > 0 && (_jsxs("div", { className: "flex items-center gap-2 px-3 py-2 text-sm border-b border-[var(--color-border)]", style: { background: 'var(--color-surface-raised)' }, children: [_jsxs("span", { children: [selection.selected.size, " ausgew\u00E4hlt"] }), _jsx("button", { type: "button", className: "text-xs underline text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)]", onClick: () => selection.onChange(new Set()), children: "Auswahl l\u00F6schen" }), _jsx("div", { className: "flex-1" }), selection.bulkActions] })), isMobile ? (_jsxs("div", { className: "flex items-center gap-3 px-3 py-2 text-xs font-medium border-b border-[var(--color-border)]", style: { background: 'var(--color-surface-raised)' }, children: [selection && (_jsxs("label", { className: "flex items-center gap-2 py-2 pr-2 cursor-pointer", children: [_jsx("input", { type: "checkbox", className: "w-5 h-5", checked: allSelected, onChange: toggleAll, "aria-label": "Alle ausw\u00E4hlen" }), _jsx("span", { className: "text-[var(--color-text-muted)]", children: "Alle" })] })), _jsx("div", { className: "flex-1" }), gearButton] })) : (_jsxs("div", { className: "grid items-center min-w-min px-3 py-2 text-xs font-medium border-b border-[var(--color-border)]", style: { gridTemplateColumns: 'var(--dt-cols)', background: 'var(--color-surface-raised)' }, children: [selection && (_jsx("input", { type: "checkbox", checked: allSelected, onChange: toggleAll, "aria-label": "Alle ausw\u00E4hlen" })), visibleColumns.map((col) => {
|
|
321
437
|
const activeSort = prefs.sort?.columnId === col.id ? prefs.sort.direction : null;
|
|
322
438
|
const filterValue = prefs.filters[col.id];
|
|
@@ -510,12 +626,12 @@ function FilterEditor({ column, value, onChange, }) {
|
|
|
510
626
|
}
|
|
511
627
|
return null;
|
|
512
628
|
}
|
|
513
|
-
function ColumnInventoryButton({ columns, prefs, setColumnVisible, moveColumn, reset, groupBy, addGroupBy, removeGroupBy, moveGroupBy, clearGroupBy, }) {
|
|
629
|
+
function ColumnInventoryButton({ columns, prefs, setColumnVisible, moveColumn, reset, groupBy, addGroupBy, removeGroupBy, moveGroupBy, clearGroupBy, exportInfo, }) {
|
|
514
630
|
const groupableCandidates = columns.filter((c) => !groupBy.includes(c.id));
|
|
515
631
|
const [open, setOpen] = useState(false);
|
|
516
632
|
const orderedColumns = useMemo(() => {
|
|
517
633
|
const byId = new Map(columns.map((c) => [c.id, c]));
|
|
518
|
-
return prefs.columnOrder.map((id) => byId.get(id)).filter(Boolean);
|
|
634
|
+
return reconcileOrder(columns, prefs.columnOrder).map((id) => byId.get(id)).filter(Boolean);
|
|
519
635
|
}, [columns, prefs.columnOrder]);
|
|
520
636
|
return (_jsxs(DropdownMenu.Root, { open: open, onOpenChange: setOpen, children: [_jsx(DropdownMenu.Trigger, { asChild: true, children: _jsx("button", { type: "button", className: "p-2.5 sm:p-1 hover:bg-[var(--color-surface)] text-[var(--color-text-muted)] justify-self-end", title: "Spalten konfigurieren", style: { borderRadius: 'var(--border-radius-md)' }, children: _jsx(Settings, { className: "w-4 h-4" }) }) }), _jsxs(DropdownMenu.Content, { className: "min-w-[260px] p-2", children: [_jsx("div", { className: "text-xs font-semibold mb-2 text-[var(--color-text-muted)] uppercase", children: "Gruppieren nach" }), groupBy.length === 0 ? (_jsx("div", { className: "text-xs text-[var(--color-text-muted)] italic px-1 mb-2", children: "Keine Gruppierung aktiv." })) : (_jsxs("div", { className: "mb-2 space-y-1", onClick: (e) => e.stopPropagation(), children: [groupBy.map((id, idx) => {
|
|
521
637
|
const col = columns.find((c) => c.id === id);
|
|
@@ -526,7 +642,7 @@ function ColumnInventoryButton({ columns, prefs, setColumnVisible, moveColumn, r
|
|
|
526
642
|
addGroupBy(v);
|
|
527
643
|
e.target.value = '';
|
|
528
644
|
}, className: "flex-1 px-1 py-0.5 text-xs border border-[var(--color-border)] bg-[var(--color-surface)]", style: { borderRadius: 'var(--border-radius-md)' }, children: [_jsx("option", { value: "", children: "\u2013 w\u00E4hlen \u2013" }), groupableCandidates.map((c) => (_jsx("option", { value: c.id, children: c.label }, c.id)))] })] })), _jsx("div", { className: "border-t border-[var(--color-border)] mb-2" }), _jsx("div", { className: "text-xs font-semibold mb-2 text-[var(--color-text-muted)] uppercase", children: "Spalten" }), orderedColumns.map((col, idx) => {
|
|
529
|
-
const visible = prefs.columnVisibility
|
|
645
|
+
const visible = isColumnVisible(col, prefs.columnVisibility);
|
|
530
646
|
return (_jsxs("div", { className: "flex items-center gap-2 px-1 py-1 text-sm", onClick: (e) => e.stopPropagation(), children: [_jsx("input", { type: "checkbox", checked: visible, onChange: () => setColumnVisible(col.id, !visible), "aria-label": `Spalte ${col.label} ${visible ? 'ausblenden' : 'einblenden'}` }), _jsx("span", { className: "flex-1 text-xs", children: col.label }), _jsx("button", { type: "button", disabled: idx === 0, onClick: () => moveColumn(col.id, -1), className: "p-2 sm:p-0.5 disabled:opacity-30 hover:bg-[var(--color-surface-raised)]", title: "Nach oben", style: { borderRadius: 'var(--border-radius-md)' }, children: _jsx(ArrowUp, { className: "w-3 h-3" }) }), _jsx("button", { type: "button", disabled: idx === orderedColumns.length - 1, onClick: () => moveColumn(col.id, 1), className: "p-2 sm:p-0.5 disabled:opacity-30 hover:bg-[var(--color-surface-raised)]", title: "Nach unten", style: { borderRadius: 'var(--border-radius-md)' }, children: _jsx(ArrowDown, { className: "w-3 h-3" }) })] }, col.id));
|
|
531
|
-
}), _jsx("div", { className: "border-t border-[var(--color-border)] mt-2 pt-2", children: _jsxs(Button, { variant: "ghost", onClick: () => { reset(); setOpen(false); }, children: [_jsx(RotateCcw, { className: "w-4 h-4" }), "Zur\u00FCcksetzen"] }) })] })] }));
|
|
647
|
+
}), exportInfo && (_jsxs("div", { className: "border-t border-[var(--color-border)] mt-2 pt-2", children: [_jsx("div", { className: "text-xs font-semibold mb-1 text-[var(--color-text-muted)] uppercase", children: "Exportieren" }), _jsxs("div", { className: "text-xs text-[var(--color-text-muted)] px-1 mb-1", children: ["Aktuelle Ansicht \u00B7 ", exportInfo.rowCount, " ", exportInfo.rowCount === 1 ? 'Zeile' : 'Zeilen'] }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsxs(Button, { variant: "ghost", disabled: exportInfo.rowCount === 0 || !exportInfo.hasColumns, onClick: () => { exportInfo.run('xlsx'); setOpen(false); }, children: [_jsx(Download, { className: "w-4 h-4" }), "Excel"] }), _jsxs(Button, { variant: "ghost", disabled: exportInfo.rowCount === 0 || !exportInfo.hasColumns, title: "CSV, Trennzeichen Semikolon", onClick: () => { exportInfo.run('csv'); setOpen(false); }, children: [_jsx(Download, { className: "w-4 h-4" }), "CSV"] })] })] })), _jsx("div", { className: "border-t border-[var(--color-border)] mt-2 pt-2", children: _jsxs(Button, { variant: "ghost", onClick: () => { reset(); setOpen(false); }, children: [_jsx(RotateCcw, { className: "w-4 h-4" }), "Zur\u00FCcksetzen"] }) })] })] }));
|
|
532
648
|
}
|
package/frontend/ui/index.d.ts
CHANGED
|
@@ -10,3 +10,4 @@ export { Skeleton, SkeletonRow } from './Skeleton.js';
|
|
|
10
10
|
export { RecordDialog, type RecordDialogMode } from './RecordDialog.js';
|
|
11
11
|
export { useIsMobile, MOBILE_QUERY } from '../useIsMobile.js';
|
|
12
12
|
export { DataTable, DEFAULT_VIEW_VERSION, type ColumnDef, type FilterDef, type DataTableSelectionProps, } from './DataTable.js';
|
|
13
|
+
export { toCsv, toXlsx, downloadFile, type ExportCell, type ExportTable } from './tableExport.js';
|
package/frontend/ui/index.js
CHANGED
|
@@ -10,3 +10,4 @@ export { Skeleton, SkeletonRow } from './Skeleton.js';
|
|
|
10
10
|
export { RecordDialog } from './RecordDialog.js';
|
|
11
11
|
export { useIsMobile, MOBILE_QUERY } from '../useIsMobile.js';
|
|
12
12
|
export { DataTable, DEFAULT_VIEW_VERSION, } from './DataTable.js';
|
|
13
|
+
export { toCsv, toXlsx, downloadFile } from './tableExport.js';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type ExportCell = null | string | number | {
|
|
2
|
+
date: Date;
|
|
3
|
+
withTime: boolean;
|
|
4
|
+
};
|
|
5
|
+
export interface ExportTable {
|
|
6
|
+
headers: string[];
|
|
7
|
+
rows: ExportCell[][];
|
|
8
|
+
}
|
|
9
|
+
export declare const CSV_SEPARATOR = ";";
|
|
10
|
+
export declare function toCsv(table: ExportTable): string;
|
|
11
|
+
export declare function columnLetter(index: number): string;
|
|
12
|
+
export declare function excelSerial(date: Date, withTime: boolean): number;
|
|
13
|
+
export declare function trimChar(s: string, ch: string): string;
|
|
14
|
+
export declare function sanitizeSheetName(name: string): string;
|
|
15
|
+
export declare function toXlsx(table: ExportTable, sheetName?: string): Uint8Array<ArrayBuffer>;
|
|
16
|
+
export declare function crc32(bytes: Uint8Array): number;
|
|
17
|
+
export declare function zipStore(files: Array<{
|
|
18
|
+
name: string;
|
|
19
|
+
data: string;
|
|
20
|
+
}>): Uint8Array<ArrayBuffer>;
|
|
21
|
+
export declare const XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
|
22
|
+
export declare function downloadFile(data: BlobPart, fileName: string, mime: string): void;
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { formatDate, formatDateTime } from '../format.js';
|
|
2
|
+
export const CSV_SEPARATOR = ';';
|
|
3
|
+
function neutralizeFormula(text) {
|
|
4
|
+
return /^[=+\-@\t\r]/.test(text) ? `'${text}` : text;
|
|
5
|
+
}
|
|
6
|
+
function csvField(cell) {
|
|
7
|
+
let text;
|
|
8
|
+
if (cell == null)
|
|
9
|
+
text = '';
|
|
10
|
+
else if (typeof cell === 'number')
|
|
11
|
+
text = String(cell).replace('.', ',');
|
|
12
|
+
else if (typeof cell === 'string')
|
|
13
|
+
text = neutralizeFormula(cell);
|
|
14
|
+
else
|
|
15
|
+
text = cell.withTime ? formatDateTime(cell.date) : formatDate(cell.date);
|
|
16
|
+
return /[";\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
|
17
|
+
}
|
|
18
|
+
export function toCsv(table) {
|
|
19
|
+
const lines = [table.headers.map(csvField), ...table.rows.map((r) => r.map(csvField))];
|
|
20
|
+
return '' + lines.map((l) => l.join(CSV_SEPARATOR)).join('\r\n') + '\r\n';
|
|
21
|
+
}
|
|
22
|
+
const XLSX_MAX_CELL_CHARS = 32767;
|
|
23
|
+
function xmlEscape(text) {
|
|
24
|
+
return text
|
|
25
|
+
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '')
|
|
26
|
+
.replace(/&/g, '&')
|
|
27
|
+
.replace(/</g, '<')
|
|
28
|
+
.replace(/>/g, '>')
|
|
29
|
+
.replace(/"/g, '"');
|
|
30
|
+
}
|
|
31
|
+
export function columnLetter(index) {
|
|
32
|
+
let n = index + 1;
|
|
33
|
+
let s = '';
|
|
34
|
+
while (n > 0) {
|
|
35
|
+
const r = (n - 1) % 26;
|
|
36
|
+
s = String.fromCharCode(65 + r) + s;
|
|
37
|
+
n = Math.floor((n - 1) / 26);
|
|
38
|
+
}
|
|
39
|
+
return s;
|
|
40
|
+
}
|
|
41
|
+
export function excelSerial(date, withTime) {
|
|
42
|
+
const localMs = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), withTime ? date.getHours() : 0, withTime ? date.getMinutes() : 0, withTime ? date.getSeconds() : 0);
|
|
43
|
+
return localMs / 86400000 + 25569;
|
|
44
|
+
}
|
|
45
|
+
const STYLE_HEADER = 1;
|
|
46
|
+
const STYLE_DATE = 2;
|
|
47
|
+
const STYLE_DATETIME = 3;
|
|
48
|
+
const STYLES_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
49
|
+
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
|
50
|
+
<numFmts count="2"><numFmt numFmtId="164" formatCode="dd.mm.yyyy"/><numFmt numFmtId="165" formatCode="dd.mm.yyyy hh:mm"/></numFmts>
|
|
51
|
+
<fonts count="2"><font><sz val="11"/><name val="Calibri"/><family val="2"/></font><font><b/><sz val="11"/><name val="Calibri"/><family val="2"/></font></fonts>
|
|
52
|
+
<fills count="3"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill><fill><patternFill patternType="solid"><fgColor rgb="FFE7E7E7"/><bgColor indexed="64"/></patternFill></fill></fills>
|
|
53
|
+
<borders count="2"><border><left/><right/><top/><bottom/><diagonal/></border><border><left style="thin"><color rgb="FFBFBFBF"/></left><right style="thin"><color rgb="FFBFBFBF"/></right><top style="thin"><color rgb="FFBFBFBF"/></top><bottom style="medium"><color rgb="FF808080"/></bottom><diagonal/></border></borders>
|
|
54
|
+
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
|
|
55
|
+
<cellXfs count="4">
|
|
56
|
+
<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>
|
|
57
|
+
<xf numFmtId="0" fontId="1" fillId="2" borderId="1" xfId="0" applyFont="1" applyFill="1" applyBorder="1" applyAlignment="1"><alignment vertical="center"/></xf>
|
|
58
|
+
<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>
|
|
59
|
+
<xf numFmtId="165" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>
|
|
60
|
+
</cellXfs>
|
|
61
|
+
<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>
|
|
62
|
+
</styleSheet>`;
|
|
63
|
+
const CONTENT_TYPES_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
64
|
+
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
|
65
|
+
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
|
66
|
+
<Default Extension="xml" ContentType="application/xml"/>
|
|
67
|
+
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
|
|
68
|
+
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
|
|
69
|
+
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
|
|
70
|
+
</Types>`;
|
|
71
|
+
const ROOT_RELS_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
72
|
+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
73
|
+
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
|
|
74
|
+
</Relationships>`;
|
|
75
|
+
const WORKBOOK_RELS_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
76
|
+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
77
|
+
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
|
|
78
|
+
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
|
79
|
+
</Relationships>`;
|
|
80
|
+
export function trimChar(s, ch) {
|
|
81
|
+
let start = 0;
|
|
82
|
+
let end = s.length;
|
|
83
|
+
while (start < end && s[start] === ch)
|
|
84
|
+
start++;
|
|
85
|
+
while (end > start && s[end - 1] === ch)
|
|
86
|
+
end--;
|
|
87
|
+
return s.slice(start, end);
|
|
88
|
+
}
|
|
89
|
+
export function sanitizeSheetName(name) {
|
|
90
|
+
const cleaned = trimChar(name.replace(/[[\]:*?/\\]/g, ' '), "'").trim().slice(0, 31).trim();
|
|
91
|
+
return cleaned || 'Export';
|
|
92
|
+
}
|
|
93
|
+
function textCell(ref, text, style) {
|
|
94
|
+
const s = style != null ? ` s="${style}"` : '';
|
|
95
|
+
return `<c r="${ref}" t="inlineStr"${s}><is><t xml:space="preserve">${xmlEscape(text.slice(0, XLSX_MAX_CELL_CHARS))}</t></is></c>`;
|
|
96
|
+
}
|
|
97
|
+
function dataCell(ref, cell) {
|
|
98
|
+
if (cell == null)
|
|
99
|
+
return '';
|
|
100
|
+
if (typeof cell === 'number')
|
|
101
|
+
return `<c r="${ref}"><v>${cell}</v></c>`;
|
|
102
|
+
if (typeof cell === 'string')
|
|
103
|
+
return cell === '' ? '' : textCell(ref, cell);
|
|
104
|
+
const serial = excelSerial(cell.date, cell.withTime);
|
|
105
|
+
if (serial < 61)
|
|
106
|
+
return textCell(ref, cell.withTime ? formatDateTime(cell.date) : formatDate(cell.date));
|
|
107
|
+
return `<c r="${ref}" s="${cell.withTime ? STYLE_DATETIME : STYLE_DATE}"><v>${serial}</v></c>`;
|
|
108
|
+
}
|
|
109
|
+
function displayLength(cell) {
|
|
110
|
+
if (cell == null)
|
|
111
|
+
return 0;
|
|
112
|
+
if (typeof cell === 'number')
|
|
113
|
+
return String(cell).length;
|
|
114
|
+
if (typeof cell === 'string')
|
|
115
|
+
return Math.max(...cell.split('\n').map((l) => l.length));
|
|
116
|
+
return cell.withTime ? 16 : 10;
|
|
117
|
+
}
|
|
118
|
+
function sheetXml(table) {
|
|
119
|
+
const colCount = Math.max(table.headers.length, 1);
|
|
120
|
+
const lastCol = columnLetter(colCount - 1);
|
|
121
|
+
const lastRow = table.rows.length + 1;
|
|
122
|
+
const widths = table.headers.map((h, ci) => {
|
|
123
|
+
let max = h.length + 3;
|
|
124
|
+
for (const r of table.rows)
|
|
125
|
+
max = Math.max(max, displayLength(r[ci] ?? null));
|
|
126
|
+
return Math.min(60, Math.max(8, max + 2));
|
|
127
|
+
});
|
|
128
|
+
const header = `<row r="1">${table.headers.map((h, ci) => textCell(`${columnLetter(ci)}1`, h, STYLE_HEADER)).join('')}</row>`;
|
|
129
|
+
const body = table.rows.map((r, ri) => {
|
|
130
|
+
const rowNo = ri + 2;
|
|
131
|
+
return `<row r="${rowNo}">${r.map((c, ci) => dataCell(`${columnLetter(ci)}${rowNo}`, c)).join('')}</row>`;
|
|
132
|
+
}).join('');
|
|
133
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
134
|
+
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
|
135
|
+
<dimension ref="A1:${lastCol}${lastRow}"/>
|
|
136
|
+
<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>
|
|
137
|
+
<sheetFormatPr defaultRowHeight="15"/>
|
|
138
|
+
${widths.length > 0 ? `<cols>${widths.map((w, i) => `<col min="${i + 1}" max="${i + 1}" width="${w}" customWidth="1"/>`).join('')}</cols>` : ''}
|
|
139
|
+
<sheetData>${header}${body}</sheetData>
|
|
140
|
+
${table.headers.length > 0 ? `<autoFilter ref="A1:${lastCol}${lastRow}"/>` : ''}
|
|
141
|
+
</worksheet>`;
|
|
142
|
+
}
|
|
143
|
+
function workbookXml(sheetName, table) {
|
|
144
|
+
const lastCol = columnLetter(Math.max(table.headers.length, 1) - 1);
|
|
145
|
+
const quoted = `'${sheetName.replace(/'/g, "''")}'`;
|
|
146
|
+
const filterName = table.headers.length > 0
|
|
147
|
+
? `<definedNames><definedName name="_xlnm._FilterDatabase" localSheetId="0" hidden="1">${xmlEscape(quoted)}!$A$1:$${lastCol}$${table.rows.length + 1}</definedName></definedNames>`
|
|
148
|
+
: '';
|
|
149
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
150
|
+
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
|
151
|
+
<sheets><sheet name="${xmlEscape(sheetName)}" sheetId="1" r:id="rId1"/></sheets>
|
|
152
|
+
${filterName}
|
|
153
|
+
</workbook>`;
|
|
154
|
+
}
|
|
155
|
+
export function toXlsx(table, sheetName = 'Export') {
|
|
156
|
+
const name = sanitizeSheetName(sheetName);
|
|
157
|
+
return zipStore([
|
|
158
|
+
{ name: '[Content_Types].xml', data: CONTENT_TYPES_XML },
|
|
159
|
+
{ name: '_rels/.rels', data: ROOT_RELS_XML },
|
|
160
|
+
{ name: 'xl/workbook.xml', data: workbookXml(name, table) },
|
|
161
|
+
{ name: 'xl/_rels/workbook.xml.rels', data: WORKBOOK_RELS_XML },
|
|
162
|
+
{ name: 'xl/styles.xml', data: STYLES_XML },
|
|
163
|
+
{ name: 'xl/worksheets/sheet1.xml', data: sheetXml(table) },
|
|
164
|
+
]);
|
|
165
|
+
}
|
|
166
|
+
let crcTable = null;
|
|
167
|
+
export function crc32(bytes) {
|
|
168
|
+
if (!crcTable) {
|
|
169
|
+
crcTable = new Uint32Array(256);
|
|
170
|
+
for (let n = 0; n < 256; n++) {
|
|
171
|
+
let c = n;
|
|
172
|
+
for (let k = 0; k < 8; k++)
|
|
173
|
+
c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1;
|
|
174
|
+
crcTable[n] = c >>> 0;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
let crc = 0xFFFFFFFF;
|
|
178
|
+
for (let i = 0; i < bytes.length; i++)
|
|
179
|
+
crc = crcTable[(crc ^ bytes[i]) & 0xFF] ^ (crc >>> 8);
|
|
180
|
+
return (crc ^ 0xFFFFFFFF) >>> 0;
|
|
181
|
+
}
|
|
182
|
+
export function zipStore(files) {
|
|
183
|
+
const enc = new TextEncoder();
|
|
184
|
+
const dosTime = 0;
|
|
185
|
+
const dosDate = ((2026 - 1980) << 9) | (1 << 5) | 1;
|
|
186
|
+
const locals = [];
|
|
187
|
+
const centrals = [];
|
|
188
|
+
let offset = 0;
|
|
189
|
+
for (const f of files) {
|
|
190
|
+
const name = enc.encode(f.name);
|
|
191
|
+
const data = enc.encode(f.data);
|
|
192
|
+
const crc = crc32(data);
|
|
193
|
+
const local = new Uint8Array(30 + name.length + data.length);
|
|
194
|
+
const lv = new DataView(local.buffer);
|
|
195
|
+
lv.setUint32(0, 0x04034B50, true);
|
|
196
|
+
lv.setUint16(4, 20, true);
|
|
197
|
+
lv.setUint16(6, 0x0800, true);
|
|
198
|
+
lv.setUint16(8, 0, true);
|
|
199
|
+
lv.setUint16(10, dosTime, true);
|
|
200
|
+
lv.setUint16(12, dosDate, true);
|
|
201
|
+
lv.setUint32(14, crc, true);
|
|
202
|
+
lv.setUint32(18, data.length, true);
|
|
203
|
+
lv.setUint32(22, data.length, true);
|
|
204
|
+
lv.setUint16(26, name.length, true);
|
|
205
|
+
lv.setUint16(28, 0, true);
|
|
206
|
+
local.set(name, 30);
|
|
207
|
+
local.set(data, 30 + name.length);
|
|
208
|
+
locals.push(local);
|
|
209
|
+
const central = new Uint8Array(46 + name.length);
|
|
210
|
+
const cv = new DataView(central.buffer);
|
|
211
|
+
cv.setUint32(0, 0x02014B50, true);
|
|
212
|
+
cv.setUint16(4, 20, true);
|
|
213
|
+
cv.setUint16(6, 20, true);
|
|
214
|
+
cv.setUint16(8, 0x0800, true);
|
|
215
|
+
cv.setUint16(10, 0, true);
|
|
216
|
+
cv.setUint16(12, dosTime, true);
|
|
217
|
+
cv.setUint16(14, dosDate, true);
|
|
218
|
+
cv.setUint32(16, crc, true);
|
|
219
|
+
cv.setUint32(20, data.length, true);
|
|
220
|
+
cv.setUint32(24, data.length, true);
|
|
221
|
+
cv.setUint16(28, name.length, true);
|
|
222
|
+
cv.setUint32(42, offset, true);
|
|
223
|
+
central.set(name, 46);
|
|
224
|
+
centrals.push(central);
|
|
225
|
+
offset += local.length;
|
|
226
|
+
}
|
|
227
|
+
const centralSize = centrals.reduce((s, c) => s + c.length, 0);
|
|
228
|
+
const end = new Uint8Array(22);
|
|
229
|
+
const ev = new DataView(end.buffer);
|
|
230
|
+
ev.setUint32(0, 0x06054B50, true);
|
|
231
|
+
ev.setUint16(8, files.length, true);
|
|
232
|
+
ev.setUint16(10, files.length, true);
|
|
233
|
+
ev.setUint32(12, centralSize, true);
|
|
234
|
+
ev.setUint32(16, offset, true);
|
|
235
|
+
const out = new Uint8Array(offset + centralSize + end.length);
|
|
236
|
+
let pos = 0;
|
|
237
|
+
for (const part of [...locals, ...centrals, end]) {
|
|
238
|
+
out.set(part, pos);
|
|
239
|
+
pos += part.length;
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
export const XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
|
244
|
+
export function downloadFile(data, fileName, mime) {
|
|
245
|
+
const url = URL.createObjectURL(new Blob([data], { type: mime }));
|
|
246
|
+
const a = document.createElement('a');
|
|
247
|
+
a.href = url;
|
|
248
|
+
a.download = fileName;
|
|
249
|
+
a.style.display = 'none';
|
|
250
|
+
document.body.appendChild(a);
|
|
251
|
+
a.click();
|
|
252
|
+
a.remove();
|
|
253
|
+
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
254
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@efa-one/sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.22.0",
|
|
4
4
|
"description": "Offizielles SDK für efa-one-Apps: Auth/Session, Service-Discovery, Gateway-Client, Audit/Reporting/Mail/Notifications, Permission- und Capability-Registrierung sowie das Frontend-IPC-/i18n-Toolkit.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -122,7 +122,7 @@
|
|
|
122
122
|
"eslint-plugin-react-hooks": "^7.1.1",
|
|
123
123
|
"express": "^4.19.2",
|
|
124
124
|
"i18next": "^23.11.0",
|
|
125
|
-
"i18next-http-backend": "^
|
|
125
|
+
"i18next-http-backend": "^4.0.2",
|
|
126
126
|
"jsonwebtoken": "^9.0.2",
|
|
127
127
|
"lucide-react": "^0.577.0",
|
|
128
128
|
"pg": "^8.12.0",
|