@efa-one/sdk 1.21.0 → 1.23.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.
@@ -1,9 +1,23 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.serviceClient = exports.ServiceClient = void 0;
3
+ exports.serviceClient = exports.ServiceClient = exports.ServiceClientError = void 0;
4
4
  function log(level, msg, extra) {
5
5
  console.log(JSON.stringify({ level, msg, timestamp: new Date().toISOString(), ...extra }));
6
6
  }
7
+ class ServiceClientError extends Error {
8
+ constructor(message, serviceKey, status, timedOut = false) {
9
+ super(message);
10
+ this.name = 'ServiceClientError';
11
+ this.serviceKey = serviceKey;
12
+ this.status = status;
13
+ this.timedOut = timedOut;
14
+ }
15
+ }
16
+ exports.ServiceClientError = ServiceClientError;
17
+ function isAbortError(err) {
18
+ const name = err?.name;
19
+ return name === 'AbortError' || name === 'TimeoutError';
20
+ }
7
21
  class ServiceClient {
8
22
  constructor(options) {
9
23
  this.gatewayUrl = (options?.gatewayUrl ?? process.env.CONVERGE_GATEWAY_URL ?? '').replace(/\/$/, '');
@@ -12,7 +26,7 @@ class ServiceClient {
12
26
  this.retries = options?.retries ?? 2;
13
27
  this.retryDelay = options?.retryDelayMs ?? 1000;
14
28
  }
15
- async call(serviceKey, method, path, body) {
29
+ async call(serviceKey, method, path, body, options = {}) {
16
30
  if (!this.gatewayUrl) {
17
31
  throw new Error('ServiceClient: CONVERGE_GATEWAY_URL nicht gesetzt');
18
32
  }
@@ -29,44 +43,63 @@ class ServiceClient {
29
43
  if (body !== undefined && method !== 'GET' && method !== 'HEAD') {
30
44
  fetchOptions.body = JSON.stringify(body);
31
45
  }
46
+ if (options.timeoutMs !== undefined && options.timeoutMs > 0) {
47
+ fetchOptions.signal = AbortSignal.timeout(options.timeoutMs);
48
+ }
49
+ const timedOut = () => new ServiceClientError(`ServiceClient: ${serviceKey} ${method} ${path} → Timeout nach ${options.timeoutMs} ms`, serviceKey, null, true);
32
50
  for (let attempt = 1; attempt <= this.retries + 1; attempt++) {
51
+ let res;
33
52
  try {
34
- const res = await this.fetchImpl(url, fetchOptions);
35
- if (res.status === 204) {
36
- return undefined;
53
+ res = await this.fetchImpl(url, fetchOptions);
54
+ }
55
+ catch (err) {
56
+ if (isAbortError(err) || fetchOptions.signal?.aborted)
57
+ throw timedOut();
58
+ if (attempt <= this.retries) {
59
+ log('warn', 'ServiceClient: Netzwerkfehler, Retry', {
60
+ serviceKey, method, path, err: String(err), attempt,
61
+ });
62
+ await new Promise((r) => setTimeout(r, this.retryDelay));
63
+ if (fetchOptions.signal?.aborted)
64
+ throw timedOut();
65
+ continue;
37
66
  }
38
- if (!res.ok) {
39
- let errorMessage = `ServiceClient: ${serviceKey} ${method} ${path} → ${res.status}`;
40
- try {
41
- const errBody = await res.json();
42
- if (errBody.error)
43
- errorMessage = errBody.error;
44
- }
45
- catch { }
46
- if (res.status >= 400 && res.status < 500) {
47
- throw new Error(errorMessage);
48
- }
49
- if (attempt <= this.retries) {
50
- log('warn', 'ServiceClient: Retry nach Server-Fehler', {
51
- serviceKey, method, path, status: res.status, attempt,
52
- });
53
- await new Promise((r) => setTimeout(r, this.retryDelay));
54
- continue;
55
- }
56
- throw new Error(errorMessage);
67
+ throw err;
68
+ }
69
+ if (res.status === 204) {
70
+ return undefined;
71
+ }
72
+ if (!res.ok) {
73
+ let errorMessage = `ServiceClient: ${serviceKey} ${method} ${path} → ${res.status}`;
74
+ try {
75
+ const errBody = await res.json();
76
+ if (errBody.error)
77
+ errorMessage = errBody.error;
78
+ }
79
+ catch (err) {
80
+ if (isAbortError(err))
81
+ throw timedOut();
82
+ }
83
+ if (res.status >= 400 && res.status < 500) {
84
+ throw new ServiceClientError(errorMessage, serviceKey, res.status);
85
+ }
86
+ if (attempt <= this.retries) {
87
+ log('warn', 'ServiceClient: Retry nach Server-Fehler', {
88
+ serviceKey, method, path, status: res.status, attempt,
89
+ });
90
+ await new Promise((r) => setTimeout(r, this.retryDelay));
91
+ if (fetchOptions.signal?.aborted)
92
+ throw timedOut();
93
+ continue;
57
94
  }
58
- return res.json();
95
+ throw new ServiceClientError(errorMessage, serviceKey, res.status);
96
+ }
97
+ try {
98
+ return await res.json();
59
99
  }
60
100
  catch (err) {
61
- if (err instanceof Error && !err.message.startsWith('ServiceClient:')) {
62
- if (attempt <= this.retries) {
63
- log('warn', 'ServiceClient: Netzwerkfehler, Retry', {
64
- serviceKey, method, path, err: String(err), attempt,
65
- });
66
- await new Promise((r) => setTimeout(r, this.retryDelay));
67
- continue;
68
- }
69
- }
101
+ if (isAbortError(err))
102
+ throw timedOut();
70
103
  throw err;
71
104
  }
72
105
  }
@@ -0,0 +1,31 @@
1
+ import type { Request } from 'express';
2
+ export declare const PROVENANCE_ISSUER = "converge-gateway";
3
+ export declare const PROVENANCE_MAX_AGE_SEC = 60;
4
+ export declare const SERVICE_KEY_RE: RegExp;
5
+ export declare const PROVENANCE_REPLAY_CACHE_MAX = 20000;
6
+ export interface ProvenanceGuardOptions {
7
+ allowedCallers?: readonly string[];
8
+ }
9
+ export type ProvenanceResult = {
10
+ ok: true;
11
+ caller: string;
12
+ } | {
13
+ ok: false;
14
+ reason: 'missing';
15
+ } | {
16
+ ok: false;
17
+ reason: 'invalid';
18
+ } | {
19
+ ok: false;
20
+ reason: 'replayed';
21
+ } | {
22
+ ok: false;
23
+ reason: 'caller_not_allowed';
24
+ caller: string;
25
+ };
26
+ export declare function getPlatformPublicKey(): string;
27
+ export declare function isTruthyEnv(value: string | undefined): boolean;
28
+ export declare function resetProvenanceReplayCache(max?: number): void;
29
+ export declare function provenanceReplayCacheSize(): number;
30
+ export declare function verifyServiceProvenance(req: Request, options?: ProvenanceGuardOptions): ProvenanceResult;
31
+ export declare function assertProvenanceOptions(options: ProvenanceGuardOptions): void;
@@ -0,0 +1,137 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.PROVENANCE_REPLAY_CACHE_MAX = exports.SERVICE_KEY_RE = exports.PROVENANCE_MAX_AGE_SEC = exports.PROVENANCE_ISSUER = void 0;
7
+ exports.getPlatformPublicKey = getPlatformPublicKey;
8
+ exports.isTruthyEnv = isTruthyEnv;
9
+ exports.resetProvenanceReplayCache = resetProvenanceReplayCache;
10
+ exports.provenanceReplayCacheSize = provenanceReplayCacheSize;
11
+ exports.verifyServiceProvenance = verifyServiceProvenance;
12
+ exports.assertProvenanceOptions = assertProvenanceOptions;
13
+ const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
14
+ exports.PROVENANCE_ISSUER = 'converge-gateway';
15
+ exports.PROVENANCE_MAX_AGE_SEC = 60;
16
+ const CLOCK_TOLERANCE_SEC = 5;
17
+ exports.SERVICE_KEY_RE = /^[a-z][a-z0-9_]{1,63}$/;
18
+ exports.PROVENANCE_REPLAY_CACHE_MAX = 20000;
19
+ let decodedKey = null;
20
+ function getPlatformPublicKey() {
21
+ const s = process.env.JWT_PUBLIC_KEY;
22
+ if (!s)
23
+ throw new Error('JWT_PUBLIC_KEY environment variable is required');
24
+ if (decodedKey?.b64 !== s)
25
+ decodedKey = { b64: s, pem: Buffer.from(s, 'base64').toString('utf8') };
26
+ return decodedKey.pem;
27
+ }
28
+ function isTruthyEnv(value) {
29
+ return !!value && ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
30
+ }
31
+ const usedJtis = new Map();
32
+ const SWEEP_INTERVAL_MS = 5000;
33
+ let lastSweep = 0;
34
+ let capacityWarned = false;
35
+ let replayCacheMax = exports.PROVENANCE_REPLAY_CACHE_MAX;
36
+ function consumeJti(jti, expSec) {
37
+ const now = Date.now();
38
+ if (now - lastSweep >= SWEEP_INTERVAL_MS) {
39
+ lastSweep = now;
40
+ for (const [k, exp] of usedJtis) {
41
+ if (exp >= now)
42
+ break;
43
+ usedJtis.delete(k);
44
+ }
45
+ }
46
+ if (usedJtis.has(jti))
47
+ return false;
48
+ while (usedJtis.size >= replayCacheMax) {
49
+ const oldest = usedJtis.keys().next().value;
50
+ usedJtis.delete(oldest);
51
+ if (!capacityWarned) {
52
+ capacityWarned = true;
53
+ console.warn(JSON.stringify({
54
+ level: 'warn',
55
+ msg: 'Provenance-Replay-Liste voll – älteste Einträge fallen heraus',
56
+ max: replayCacheMax,
57
+ }));
58
+ }
59
+ }
60
+ usedJtis.set(jti, (expSec + CLOCK_TOLERANCE_SEC) * 1000);
61
+ return true;
62
+ }
63
+ function resetProvenanceReplayCache(max = exports.PROVENANCE_REPLAY_CACHE_MAX) {
64
+ usedJtis.clear();
65
+ lastSweep = 0;
66
+ capacityWarned = false;
67
+ replayCacheMax = max;
68
+ }
69
+ function provenanceReplayCacheSize() {
70
+ return usedJtis.size;
71
+ }
72
+ const verifiedInRequest = new WeakMap();
73
+ function readToken(req) {
74
+ const raw = req.headers?.['x-service-token'];
75
+ const token = Array.isArray(raw) ? raw[0] : raw;
76
+ return typeof token === 'string' && token ? token : undefined;
77
+ }
78
+ function verifyToken(req) {
79
+ const token = readToken(req);
80
+ if (!token)
81
+ return { ok: false, reason: 'missing' };
82
+ const memo = verifiedInRequest.get(req);
83
+ if (memo && memo.token === token)
84
+ return { ok: true, caller: memo.caller };
85
+ const audience = process.env.SERVICE_KEY;
86
+ if (!audience)
87
+ return { ok: false, reason: 'invalid' };
88
+ let payload;
89
+ try {
90
+ payload = jsonwebtoken_1.default.verify(token, getPlatformPublicKey(), {
91
+ algorithms: ['RS256'],
92
+ issuer: exports.PROVENANCE_ISSUER,
93
+ audience,
94
+ maxAge: exports.PROVENANCE_MAX_AGE_SEC,
95
+ clockTolerance: CLOCK_TOLERANCE_SEC,
96
+ });
97
+ }
98
+ catch {
99
+ return { ok: false, reason: 'invalid' };
100
+ }
101
+ const caller = payload.sub;
102
+ if (typeof caller !== 'string' || !exports.SERVICE_KEY_RE.test(caller))
103
+ return { ok: false, reason: 'invalid' };
104
+ if (payload.jti !== undefined) {
105
+ if (typeof payload.jti !== 'string' || !payload.jti || typeof payload.exp !== 'number') {
106
+ return { ok: false, reason: 'invalid' };
107
+ }
108
+ if (!consumeJti(payload.jti, payload.exp))
109
+ return { ok: false, reason: 'replayed' };
110
+ }
111
+ verifiedInRequest.set(req, { token, caller });
112
+ return { ok: true, caller };
113
+ }
114
+ function verifyServiceProvenance(req, options = {}) {
115
+ const result = verifyToken(req);
116
+ if (!result.ok)
117
+ return result;
118
+ if (options.allowedCallers && !options.allowedCallers.includes(result.caller)) {
119
+ delete req.callerService;
120
+ return { ok: false, reason: 'caller_not_allowed', caller: result.caller };
121
+ }
122
+ req.callerService = result.caller;
123
+ return result;
124
+ }
125
+ function assertProvenanceOptions(options) {
126
+ const callers = options.allowedCallers;
127
+ if (callers === undefined)
128
+ return;
129
+ if (!Array.isArray(callers) || callers.length === 0) {
130
+ throw new Error('allowedCallers: mindestens ein Service-Key angeben (leere Liste sperrt jeden Aufrufer)');
131
+ }
132
+ for (const c of callers) {
133
+ if (typeof c !== 'string' || !exports.SERVICE_KEY_RE.test(c)) {
134
+ throw new Error(`allowedCallers: ungültiger Service-Key ${JSON.stringify(c)}`);
135
+ }
136
+ }
137
+ }
@@ -1,5 +1,6 @@
1
1
  export * from './ipc.js';
2
2
  export * from './format.js';
3
+ export * from './platformAdmin.js';
3
4
  export { useIsMobile, MOBILE_QUERY } from './useIsMobile.js';
4
5
  export * from './i18n.js';
5
6
  export { default as DevHeader } from './DevHeader.js';
package/frontend/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './ipc.js';
2
2
  export * from './format.js';
3
+ export * from './platformAdmin.js';
3
4
  export { useIsMobile, MOBILE_QUERY } from './useIsMobile.js';
4
5
  export * from './i18n.js';
5
6
  export { default as DevHeader } from './DevHeader.js';
package/frontend/ipc.js CHANGED
@@ -15,7 +15,9 @@ export function getParentOrigin() {
15
15
  }
16
16
  }
17
17
  export function isFromPlatformParent(event) {
18
- return event.source === window.parent;
18
+ if (event.source !== window.parent)
19
+ return false;
20
+ return event.origin === window.location.origin;
19
21
  }
20
22
  export function sendAtStart() {
21
23
  if (!isEmbedded())
@@ -0,0 +1,4 @@
1
+ export declare const PLATFORM_ADMIN_PERMISSION = "efa-all";
2
+ export declare const PLATFORM_ADMIN_PERMISSIONS: readonly string[];
3
+ export declare function isPlatformAdminPermission(key: string): boolean;
4
+ export declare function isPlatformAdmin(permissionKeys: readonly string[] | null | undefined): boolean;
@@ -0,0 +1,11 @@
1
+ export const PLATFORM_ADMIN_PERMISSION = 'efa-all';
2
+ export const PLATFORM_ADMIN_PERMISSIONS = Object.freeze([
3
+ PLATFORM_ADMIN_PERMISSION,
4
+ 'converge-admin',
5
+ ]);
6
+ export function isPlatformAdminPermission(key) {
7
+ return PLATFORM_ADMIN_PERMISSIONS.includes(key);
8
+ }
9
+ export function isPlatformAdmin(permissionKeys) {
10
+ return !!permissionKeys && permissionKeys.some(isPlatformAdminPermission);
11
+ }
@@ -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;
@@ -58,11 +61,16 @@ interface DataTableProps<T, K extends string | number> {
58
61
  footer?: React.ReactNode;
59
62
  groupsDefaultExpanded?: boolean;
60
63
  persistence?: ViewPreferencesAdapter;
64
+ exportable?: boolean;
65
+ exportFileName?: string;
61
66
  }
62
67
  export declare function defaultPrefsFor<T>(columns: ColumnDef<T>[], overrides?: Partial<ViewPrefs>): ViewPrefs;
63
68
  export declare function reconcileOrder<T>(columns: ColumnDef<T>[], order: string[]): string[];
64
69
  export declare function isColumnVisible<T>(column: ColumnDef<T>, visibility: Record<string, boolean>): boolean;
65
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;
66
74
  export declare const MIN_COLUMN_WIDTH = 60;
67
75
  export declare const MAX_COLUMN_WIDTH = 1600;
68
76
  export declare function clampColumnWidth(px: number): number;
@@ -78,7 +86,7 @@ interface RenderItem<T> {
78
86
  expanded?: boolean;
79
87
  row?: T;
80
88
  }
81
- export declare function buildRenderItems<T>(rows: T[], groupBy: string[], columnsById: Map<string, ColumnDef<T>>, expandedGroups: Set<string>): RenderItem<T>[];
89
+ export declare function buildRenderItems<T>(rows: T[], groupBy: string[], columnsById: Map<string, ColumnDef<T>>, expandedGroups: Set<string> | 'all'): RenderItem<T>[];
82
90
  export declare function collectAllGroupKeys<T>(rows: T[], groupBy: string[], columnsById: Map<string, ColumnDef<T>>): string[];
83
- export declare function DataTable<T, K extends string | number>({ listId, rows, columns, rowKey, onRowClick, selection, initialPrefs, footer, groupsDefaultExpanded, persistence, }: DataTableProps<T, K>): React.ReactElement;
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;
84
92
  export {};
@@ -1,11 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
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;
@@ -54,6 +55,47 @@ export function formatCellValue(col, row) {
54
55
  }
55
56
  return String(v ?? '');
56
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
+ }
57
99
  export const MIN_COLUMN_WIDTH = 60;
58
100
  export const MAX_COLUMN_WIDTH = 1600;
59
101
  const KEYBOARD_RESIZE_STEP = 16;
@@ -138,7 +180,7 @@ export function buildRenderItems(rows, groupBy, columnsById, expandedGroups) {
138
180
  for (const label of labels) {
139
181
  const childRows = buckets.get(label);
140
182
  const key = parentKey ? `${parentKey}${GROUP_KEY_SEP}${label}` : label;
141
- const expanded = expandedGroups.has(key);
183
+ const expanded = expandedGroups === 'all' || expandedGroups.has(key);
142
184
  out.push({
143
185
  kind: 'group',
144
186
  key,
@@ -178,7 +220,7 @@ export function collectAllGroupKeys(rows, groupBy, columnsById) {
178
220
  recurse(rows, 0, '');
179
221
  return keys;
180
222
  }
181
- export function DataTable({ listId, rows, columns, rowKey, onRowClick, selection, initialPrefs, footer, groupsDefaultExpanded, persistence, }) {
223
+ export function DataTable({ listId, rows, columns, rowKey, onRowClick, selection, initialPrefs, footer, groupsDefaultExpanded, persistence, exportable = true, exportFileName, }) {
182
224
  const defaultPrefs = useMemo(() => defaultPrefsFor(columns, initialPrefs), [columns, initialPrefs]);
183
225
  const [prefs, setPrefs, reset] = useViewPreferences(listId, defaultPrefs, persistence);
184
226
  const isMobile = useIsMobile();
@@ -375,8 +417,23 @@ export function DataTable({ listId, rows, columns, rowKey, onRowClick, selection
375
417
  }
376
418
  setPrefs({ ...prefs, filters });
377
419
  };
378
- const gearButton = (_jsx(ColumnInventoryButton, { columns: columns, prefs: prefs, setColumnVisible: setColumnVisible, moveColumn: moveColumn, reset: reset, groupBy: groupBy, addGroupBy: addGroupBy, removeGroupBy: removeGroupBy, moveGroupBy: moveGroupBy, clearGroupBy: clearGroupBy }));
379
- 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) => {
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 }));
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 aufheben" }), _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) => {
380
437
  const activeSort = prefs.sort?.columnId === col.id ? prefs.sort.direction : null;
381
438
  const filterValue = prefs.filters[col.id];
382
439
  const isFiltered = filterValue != null;
@@ -569,7 +626,7 @@ function FilterEditor({ column, value, onChange, }) {
569
626
  }
570
627
  return null;
571
628
  }
572
- 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, }) {
573
630
  const groupableCandidates = columns.filter((c) => !groupBy.includes(c.id));
574
631
  const [open, setOpen] = useState(false);
575
632
  const orderedColumns = useMemo(() => {
@@ -587,5 +644,5 @@ function ColumnInventoryButton({ columns, prefs, setColumnVisible, moveColumn, r
587
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) => {
588
645
  const visible = isColumnVisible(col, prefs.columnVisibility);
589
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));
590
- }), _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"] }) })] })] }));
591
648
  }
@@ -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';
@@ -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;