@efa-one/sdk 1.11.0 → 1.12.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 CHANGED
@@ -13,11 +13,17 @@ sub-paths — this keeps `express` out of the frontend and `react` out of the ba
13
13
  | Import | Runs in | Contains |
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
- | `@efa-one/sdk/frontend` | Browser/React | postMessage IPC (`sendDeclareAppInfo`, `sendAtStart`, `navigateToApp`, `notifyRouteChange` …), react-i18next factory (`initI18n`), `DevHeader` |
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 per-user persisted view). Ship its companion CSS once: `import '@efa-one/sdk/frontend/ui/styles.css'` |
18
+ | `@efa-one/sdk/frontend/viewPreferences` | Browser/React | Persistence seam for `DataTable` — `createViewPreferencesClient()` (standard `/api/view-preferences/:listId` adapter) + `useViewPreferences()` |
17
19
 
18
20
  Every individual module is also reachable directly, e.g.
19
21
  `@efa-one/sdk/backend/auth` or `@efa-one/sdk/frontend/ipc`.
20
22
 
23
+ The `frontend/ui` kit renders against the platform design tokens (`--color-*`,
24
+ `--border-radius-*`) and expects Tailwind in the consuming app; `lucide-react` and
25
+ the `@radix-ui/*` primitives it uses are optional peer dependencies.
26
+
21
27
  > **Note on legacy prefixes:** Some platform-internal identifiers (postMessage
22
28
  > message types, environment variable names, JWT `iss`) still carry technical
23
29
  > legacy prefixes from an earlier naming. These are part of the wire protocol
@@ -49,9 +55,24 @@ app.get('/api/items', requireAuth, handler);
49
55
 
50
56
  ```tsx
51
57
  // Frontend
52
- import { initI18n, sendDeclareAppInfo, DevHeader } from '@efa-one/sdk/frontend';
58
+ import { initI18n, registerAppInfo, DevHeader } from '@efa-one/sdk/frontend';
59
+
60
+ registerAppInfo({ appName: 'efa-chat', version: __APP_VERSION__ });
61
+ ```
53
62
 
54
- sendDeclareAppInfo({ appName: 'efa-chat', version: __APP_VERSION__ });
63
+ ```tsx
64
+ // Frontend — a fully-featured list in a few lines
65
+ import { DataTable, type ColumnDef } from '@efa-one/sdk/frontend/ui';
66
+ import { createViewPreferencesClient } from '@efa-one/sdk/frontend/viewPreferences';
67
+ import '@efa-one/sdk/frontend/ui/styles.css';
68
+
69
+ const viewPrefs = createViewPreferencesClient({ apiBase: getApiBase }); // create once
70
+ const columns: ColumnDef<Item>[] = [
71
+ { id: 'name', label: 'Name', accessor: (r) => r.name, filter: { type: 'text' } },
72
+ { id: 'status', label: 'Status', accessor: (r) => r.status },
73
+ ];
74
+
75
+ <DataTable listId="items.list" rows={items} columns={columns} rowKey={(r) => r.id} persistence={viewPrefs} />
55
76
  ```
56
77
 
57
78
  ## Build your first app
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ export type AlertVariant = 'error' | 'success' | 'warning' | 'info';
3
+ interface AlertProps {
4
+ variant?: AlertVariant;
5
+ children: React.ReactNode;
6
+ onDismiss?: () => void;
7
+ className?: string;
8
+ }
9
+ export declare function Alert({ variant, children, onDismiss, className }: AlertProps): React.JSX.Element;
10
+ export {};
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { X } from 'lucide-react';
3
+ const VARIANT = {
4
+ error: 'bg-[color-mix(in_srgb,var(--color-danger)_10%,transparent)] border-[var(--color-danger)] text-[var(--color-danger)]',
5
+ success: 'bg-[color-mix(in_srgb,var(--color-success)_10%,transparent)] border-[var(--color-success)] text-[var(--color-success)]',
6
+ warning: 'bg-[color-mix(in_srgb,var(--color-warning)_10%,transparent)] border-[var(--color-warning)] text-[var(--color-warning)]',
7
+ info: 'bg-[color-mix(in_srgb,var(--color-primary)_10%,transparent)] border-[var(--color-primary)] text-[var(--color-primary)]',
8
+ };
9
+ export function Alert({ variant = 'error', children, onDismiss, className = '' }) {
10
+ return (_jsxs("div", { role: "alert", className: `shrink-0 flex items-start gap-2 border rounded-md px-4 py-3 text-sm font-medium ${VARIANT[variant]} ${className}`, children: [_jsx("span", { className: "flex-1", children: children }), onDismiss && (_jsx("button", { type: "button", onClick: onDismiss, "aria-label": "Meldung schlie\u00DFen", className: "shrink-0 -mr-1 p-0.5 rounded hover:bg-current/10 transition-colors", children: _jsx(X, { className: "w-4 h-4" }) }))] }));
11
+ }
@@ -0,0 +1,9 @@
1
+ import React from 'react';
2
+ type BadgeVariant = 'success' | 'warning' | 'danger' | 'neutral';
3
+ interface BadgeProps {
4
+ variant?: BadgeVariant;
5
+ children: React.ReactNode;
6
+ className?: string;
7
+ }
8
+ export declare function Badge({ variant, children, className }: BadgeProps): React.JSX.Element;
9
+ export {};
@@ -0,0 +1,4 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ export function Badge({ variant = 'neutral', children, className = '' }) {
3
+ return (_jsx("span", { className: `badge badge-${variant} ${className}`, children: children }));
4
+ }
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
3
+ type ButtonSize = 'sm' | 'md';
4
+ interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
5
+ variant?: ButtonVariant;
6
+ size?: ButtonSize;
7
+ loading?: boolean;
8
+ }
9
+ export declare function Button({ variant, size, loading, disabled, children, className, ...props }: ButtonProps): React.JSX.Element;
10
+ export {};
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Loader2 } from 'lucide-react';
3
+ const variantClasses = {
4
+ primary: 'bg-[var(--color-primary)] text-white border-transparent hover:bg-[var(--color-primary-hover)]',
5
+ secondary: 'bg-[var(--color-surface)] text-[var(--color-text-primary)] border-[var(--color-border)] hover:bg-[var(--color-surface-raised)]',
6
+ danger: 'bg-[var(--color-danger)] text-white border-transparent hover:opacity-90',
7
+ ghost: 'bg-transparent text-[var(--color-text-secondary)] border-transparent hover:bg-[var(--color-surface-raised)]',
8
+ };
9
+ const sizeClasses = {
10
+ sm: 'text-xs px-3 py-1.5 gap-1.5',
11
+ md: 'text-sm px-4 py-2 gap-2',
12
+ };
13
+ export function Button({ variant = 'primary', size = 'md', loading = false, disabled, children, className = '', ...props }) {
14
+ return (_jsxs("button", { disabled: disabled || loading, className: `inline-flex items-center justify-center font-medium rounded-md border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${variantClasses[variant]} ${sizeClasses[size]} ${className}`, ...props, children: [loading && _jsx(Loader2, { className: "w-4 h-4 animate-spin" }), children] }));
15
+ }
@@ -0,0 +1,72 @@
1
+ import React from 'react';
2
+ import { type ViewPreferencesAdapter } from '../viewPreferences';
3
+ export type FilterDef = {
4
+ type: 'text';
5
+ } | {
6
+ type: 'multi-select';
7
+ options: Array<{
8
+ value: string;
9
+ label: string;
10
+ }>;
11
+ } | {
12
+ type: 'boolean';
13
+ labelTrue?: string;
14
+ labelFalse?: string;
15
+ };
16
+ export interface ColumnDef<T> {
17
+ id: string;
18
+ label: string;
19
+ accessor: (row: T) => unknown;
20
+ cell?: (row: T) => React.ReactNode;
21
+ filter?: FilterDef;
22
+ sortable?: boolean;
23
+ width?: string;
24
+ defaultVisible?: boolean;
25
+ }
26
+ interface FilterValue {
27
+ text?: string;
28
+ selected?: string[];
29
+ bool?: 'true' | 'false' | null;
30
+ }
31
+ export declare const DEFAULT_VIEW_VERSION = 2;
32
+ interface ViewPrefs {
33
+ columnVisibility: Record<string, boolean>;
34
+ columnOrder: string[];
35
+ sort: {
36
+ columnId: string;
37
+ direction: 'asc' | 'desc';
38
+ } | null;
39
+ filters: Record<string, FilterValue>;
40
+ groupBy: string[];
41
+ version?: number;
42
+ }
43
+ export interface DataTableSelectionProps<K> {
44
+ selected: Set<K>;
45
+ onChange: (next: Set<K>) => void;
46
+ bulkActions?: React.ReactNode;
47
+ }
48
+ interface DataTableProps<T, K extends string | number> {
49
+ listId: string;
50
+ rows: T[];
51
+ columns: ColumnDef<T>[];
52
+ rowKey: (row: T) => K;
53
+ onRowClick?: (row: T) => void;
54
+ selection?: DataTableSelectionProps<K>;
55
+ initialPrefs?: Partial<ViewPrefs>;
56
+ footer?: React.ReactNode;
57
+ persistence?: ViewPreferencesAdapter;
58
+ }
59
+ export declare function defaultPrefsFor<T>(columns: ColumnDef<T>[], overrides?: Partial<ViewPrefs>): ViewPrefs;
60
+ export declare function groupLabelOf(value: unknown): string;
61
+ interface RenderItem<T> {
62
+ kind: 'group' | 'row';
63
+ key?: string;
64
+ level?: number;
65
+ label?: string;
66
+ count?: number;
67
+ expanded?: boolean;
68
+ row?: T;
69
+ }
70
+ export declare function buildRenderItems<T>(rows: T[], groupBy: string[], columnsById: Map<string, ColumnDef<T>>, expandedGroups: Set<string>): RenderItem<T>[];
71
+ export declare function DataTable<T, K extends string | number>({ listId, rows, columns, rowKey, onRowClick, selection, initialPrefs, footer, persistence, }: DataTableProps<T, K>): React.ReactElement;
72
+ export {};
@@ -0,0 +1,321 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo, useState } from 'react';
3
+ import * as DropdownMenu from './DropdownMenu';
4
+ import { ChevronDown, ChevronUp, ChevronRight, Settings, EyeOff, Filter, RotateCcw, ArrowUp, ArrowDown, Layers, X } from 'lucide-react';
5
+ import { Button } from './Button';
6
+ import { useViewPreferences } from '../viewPreferences';
7
+ const GROUP_KEY_SEP = '\x1f';
8
+ const EMPTY_GROUP_LABEL = '– ohne –';
9
+ export const DEFAULT_VIEW_VERSION = 2;
10
+ export function defaultPrefsFor(columns, overrides) {
11
+ const visibility = {};
12
+ columns.forEach((c) => { visibility[c.id] = c.defaultVisible !== false; });
13
+ return {
14
+ columnVisibility: visibility,
15
+ columnOrder: columns.map((c) => c.id),
16
+ sort: null,
17
+ filters: {},
18
+ groupBy: [],
19
+ version: DEFAULT_VIEW_VERSION,
20
+ ...(overrides ?? {}),
21
+ };
22
+ }
23
+ export function groupLabelOf(value) {
24
+ if (value == null)
25
+ return '';
26
+ if (Array.isArray(value))
27
+ return value.map(String).join(', ');
28
+ const s = String(value).trim();
29
+ return s;
30
+ }
31
+ export function buildRenderItems(rows, groupBy, columnsById, expandedGroups) {
32
+ if (groupBy.length === 0)
33
+ return rows.map((row) => ({ kind: 'row', row }));
34
+ const out = [];
35
+ const recurse = (subset, depth, parentKey) => {
36
+ if (depth >= groupBy.length) {
37
+ subset.forEach((row) => out.push({ kind: 'row', row }));
38
+ return;
39
+ }
40
+ const col = columnsById.get(groupBy[depth]);
41
+ if (!col) {
42
+ subset.forEach((row) => out.push({ kind: 'row', row }));
43
+ return;
44
+ }
45
+ const buckets = new Map();
46
+ for (const r of subset) {
47
+ const lbl = groupLabelOf(col.accessor(r));
48
+ const bucketLabel = lbl || EMPTY_GROUP_LABEL;
49
+ const arr = buckets.get(bucketLabel) ?? [];
50
+ arr.push(r);
51
+ buckets.set(bucketLabel, arr);
52
+ }
53
+ const labels = Array.from(buckets.keys()).sort((a, b) => {
54
+ if (a === EMPTY_GROUP_LABEL)
55
+ return 1;
56
+ if (b === EMPTY_GROUP_LABEL)
57
+ return -1;
58
+ return a.localeCompare(b, 'de');
59
+ });
60
+ for (const label of labels) {
61
+ const childRows = buckets.get(label);
62
+ const key = parentKey ? `${parentKey}${GROUP_KEY_SEP}${label}` : label;
63
+ const expanded = expandedGroups.has(key);
64
+ out.push({
65
+ kind: 'group',
66
+ key,
67
+ level: depth,
68
+ label,
69
+ count: childRows.length,
70
+ expanded,
71
+ });
72
+ if (expanded)
73
+ recurse(childRows, depth + 1, key);
74
+ }
75
+ };
76
+ recurse(rows, 0, '');
77
+ return out;
78
+ }
79
+ export function DataTable({ listId, rows, columns, rowKey, onRowClick, selection, initialPrefs, footer, persistence, }) {
80
+ const defaultPrefs = useMemo(() => defaultPrefsFor(columns, initialPrefs), [columns, initialPrefs]);
81
+ const [prefs, setPrefs, reset] = useViewPreferences(listId, defaultPrefs, persistence);
82
+ const [expandedGroups, setExpandedGroups] = useState(new Set());
83
+ const toggleGroup = (key) => {
84
+ setExpandedGroups((prev) => {
85
+ const next = new Set(prev);
86
+ if (next.has(key))
87
+ next.delete(key);
88
+ else
89
+ next.add(key);
90
+ return next;
91
+ });
92
+ };
93
+ const columnsById = useMemo(() => new Map(columns.map((c) => [c.id, c])), [columns]);
94
+ const groupBy = prefs.groupBy ?? [];
95
+ const visibleColumns = useMemo(() => {
96
+ const byId = new Map(columns.map((c) => [c.id, c]));
97
+ return prefs.columnOrder
98
+ .map((id) => byId.get(id))
99
+ .filter((c) => Boolean(c) && prefs.columnVisibility[c.id] !== false);
100
+ }, [columns, prefs.columnOrder, prefs.columnVisibility]);
101
+ const sortedFilteredRows = useMemo(() => {
102
+ let out = rows;
103
+ for (const col of columns) {
104
+ const f = prefs.filters[col.id];
105
+ if (!f || !col.filter)
106
+ continue;
107
+ if (col.filter.type === 'text' && f.text) {
108
+ const q = f.text.toLowerCase();
109
+ out = out.filter((r) => {
110
+ const v = col.accessor(r);
111
+ return v != null && String(v).toLowerCase().includes(q);
112
+ });
113
+ }
114
+ else if (col.filter.type === 'multi-select' && f.selected && f.selected.length > 0) {
115
+ const set = new Set(f.selected);
116
+ out = out.filter((r) => {
117
+ const v = col.accessor(r);
118
+ if (Array.isArray(v))
119
+ return v.some((x) => set.has(String(x)));
120
+ return v != null && set.has(String(v));
121
+ });
122
+ }
123
+ else if (col.filter.type === 'boolean' && f.bool != null) {
124
+ out = out.filter((r) => String(Boolean(col.accessor(r))) === f.bool);
125
+ }
126
+ }
127
+ if (prefs.sort) {
128
+ const col = columns.find((c) => c.id === prefs.sort.columnId);
129
+ if (col) {
130
+ const dir = prefs.sort.direction === 'asc' ? 1 : -1;
131
+ out = [...out].sort((a, b) => {
132
+ const va = col.accessor(a);
133
+ const vb = col.accessor(b);
134
+ if (va == null && vb == null)
135
+ return 0;
136
+ if (va == null)
137
+ return 1 * dir;
138
+ if (vb == null)
139
+ return -1 * dir;
140
+ if (typeof va === 'number' && typeof vb === 'number')
141
+ return (va - vb) * dir;
142
+ return String(va).localeCompare(String(vb), 'de') * dir;
143
+ });
144
+ }
145
+ }
146
+ return out;
147
+ }, [rows, columns, prefs.filters, prefs.sort]);
148
+ const allSelected = !!selection && sortedFilteredRows.length > 0
149
+ && sortedFilteredRows.every((r) => selection.selected.has(rowKey(r)));
150
+ const toggleAll = () => {
151
+ if (!selection)
152
+ return;
153
+ const next = new Set(selection.selected);
154
+ if (allSelected)
155
+ sortedFilteredRows.forEach((r) => next.delete(rowKey(r)));
156
+ else
157
+ sortedFilteredRows.forEach((r) => next.add(rowKey(r)));
158
+ selection.onChange(next);
159
+ };
160
+ const toggleOne = (key) => {
161
+ if (!selection)
162
+ return;
163
+ const next = new Set(selection.selected);
164
+ if (next.has(key))
165
+ next.delete(key);
166
+ else
167
+ next.add(key);
168
+ selection.onChange(next);
169
+ };
170
+ const gridTemplate = useMemo(() => {
171
+ const cols = [];
172
+ if (selection)
173
+ cols.push('36px');
174
+ visibleColumns.forEach((c) => cols.push(c.width ?? '1fr'));
175
+ cols.push('40px');
176
+ return cols.join(' ');
177
+ }, [selection, visibleColumns]);
178
+ const setSort = (columnId, direction) => {
179
+ setPrefs({ ...prefs, sort: { columnId, direction } });
180
+ };
181
+ const clearSort = () => {
182
+ setPrefs({ ...prefs, sort: null });
183
+ };
184
+ const setColumnVisible = (columnId, visible) => {
185
+ setPrefs({
186
+ ...prefs,
187
+ columnVisibility: { ...prefs.columnVisibility, [columnId]: visible },
188
+ });
189
+ };
190
+ const moveColumn = (columnId, dir) => {
191
+ const order = [...prefs.columnOrder];
192
+ const idx = order.indexOf(columnId);
193
+ if (idx < 0)
194
+ return;
195
+ const newIdx = idx + dir;
196
+ if (newIdx < 0 || newIdx >= order.length)
197
+ return;
198
+ [order[idx], order[newIdx]] = [order[newIdx], order[idx]];
199
+ setPrefs({ ...prefs, columnOrder: order });
200
+ };
201
+ const addGroupBy = (columnId) => {
202
+ if (groupBy.includes(columnId))
203
+ return;
204
+ setPrefs({ ...prefs, groupBy: [...groupBy, columnId] });
205
+ setExpandedGroups(new Set());
206
+ };
207
+ const removeGroupBy = (columnId) => {
208
+ setPrefs({ ...prefs, groupBy: groupBy.filter((id) => id !== columnId) });
209
+ setExpandedGroups(new Set());
210
+ };
211
+ const moveGroupBy = (columnId, dir) => {
212
+ const order = [...groupBy];
213
+ const idx = order.indexOf(columnId);
214
+ if (idx < 0)
215
+ return;
216
+ const newIdx = idx + dir;
217
+ if (newIdx < 0 || newIdx >= order.length)
218
+ return;
219
+ [order[idx], order[newIdx]] = [order[newIdx], order[idx]];
220
+ setPrefs({ ...prefs, groupBy: order });
221
+ setExpandedGroups(new Set());
222
+ };
223
+ const clearGroupBy = () => {
224
+ setPrefs({ ...prefs, groupBy: [] });
225
+ setExpandedGroups(new Set());
226
+ };
227
+ const setFilter = (columnId, value) => {
228
+ const filters = { ...prefs.filters };
229
+ if (value == null || ((value.text == null || value.text === '') &&
230
+ (value.selected == null || value.selected.length === 0) &&
231
+ (value.bool == null))) {
232
+ delete filters[columnId];
233
+ }
234
+ else {
235
+ filters[columnId] = value;
236
+ }
237
+ setPrefs({ ...prefs, filters });
238
+ };
239
+ return (_jsxs("div", { className: "border border-[var(--color-border)]", 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] })), _jsxs("div", { className: "grid items-center px-3 py-2 text-xs font-medium border-b border-[var(--color-border)]", style: { gridTemplateColumns: gridTemplate, background: 'var(--color-surface-raised)' }, children: [selection && (_jsx("input", { type: "checkbox", checked: allSelected, onChange: toggleAll, "aria-label": "Alle ausw\u00E4hlen" })), visibleColumns.map((col) => {
240
+ const activeSort = prefs.sort?.columnId === col.id ? prefs.sort.direction : null;
241
+ const filterValue = prefs.filters[col.id];
242
+ const isFiltered = filterValue != null;
243
+ return (_jsxs(DropdownMenu.Root, { children: [_jsx(DropdownMenu.Trigger, { asChild: true, children: _jsxs("button", { type: "button", className: "flex items-center gap-1 px-1 py-0.5 hover:bg-[var(--color-surface)] text-left", style: { borderRadius: 'var(--border-radius-md)' }, children: [_jsx("span", { children: col.label }), activeSort === 'asc' && _jsx(ChevronUp, { className: "w-3 h-3" }), activeSort === 'desc' && _jsx(ChevronDown, { className: "w-3 h-3" }), isFiltered && _jsx(Filter, { className: "w-3 h-3 text-[var(--color-primary)]" })] }) }), _jsxs(DropdownMenu.Content, { className: "min-w-[220px] p-1", children: [col.sortable !== false && (_jsxs(_Fragment, { children: [_jsxs(DropdownMenu.Item, { onSelect: () => setSort(col.id, 'asc'), children: [_jsx(ArrowUp, { className: "w-4 h-4" }), "Sortieren A\u2192Z"] }), _jsxs(DropdownMenu.Item, { onSelect: () => setSort(col.id, 'desc'), children: [_jsx(ArrowDown, { className: "w-4 h-4" }), "Sortieren Z\u2192A"] }), activeSort && (_jsx(DropdownMenu.Item, { onSelect: clearSort, children: "Sortierung zur\u00FCcksetzen" })), _jsx(DropdownMenu.Separator, {})] })), groupBy.includes(col.id) ? (_jsxs(DropdownMenu.Item, { onSelect: () => removeGroupBy(col.id), children: [_jsx(Layers, { className: "w-4 h-4" }), "Gruppierung aufheben"] })) : (_jsxs(DropdownMenu.Item, { onSelect: () => addGroupBy(col.id), children: [_jsx(Layers, { className: "w-4 h-4" }), "Nach dieser Spalte gruppieren"] })), _jsx(DropdownMenu.Separator, {}), col.filter && (_jsx(FilterEditor, { column: col, value: filterValue, onChange: (v) => setFilter(col.id, v) })), _jsxs(DropdownMenu.Item, { onSelect: () => setColumnVisible(col.id, false), variant: "danger", children: [_jsx(EyeOff, { className: "w-4 h-4" }), "Spalte ausblenden"] })] })] }, col.id));
244
+ }), _jsx(ColumnInventoryButton, { columns: columns, prefs: prefs, setColumnVisible: setColumnVisible, moveColumn: moveColumn, reset: reset, groupBy: groupBy, addGroupBy: addGroupBy, removeGroupBy: removeGroupBy, moveGroupBy: moveGroupBy, clearGroupBy: clearGroupBy })] }), sortedFilteredRows.length === 0 ? (_jsx("div", { className: "px-3 py-8 text-center text-sm text-[var(--color-text-muted)]", children: "Keine Eintr\u00E4ge" })) : (buildRenderItems(sortedFilteredRows, groupBy, columnsById, expandedGroups).map((item, idx) => {
245
+ if (item.kind === 'group') {
246
+ const level = item.level ?? 0;
247
+ const groupColLabel = columnsById.get(groupBy[level])?.label ?? '';
248
+ return (_jsx("div", { className: "px-3 py-1.5 text-xs border-b border-[var(--color-border)] cursor-pointer select-none hover:bg-[var(--color-surface-raised)]", style: {
249
+ background: 'var(--color-surface-raised)',
250
+ paddingLeft: 12 + level * 18,
251
+ }, onClick: () => toggleGroup(item.key), title: item.expanded ? 'Gruppe einklappen' : 'Gruppe ausklappen', children: _jsxs("div", { className: "flex items-center gap-2", children: [item.expanded
252
+ ? _jsx(ChevronDown, { className: "w-3.5 h-3.5 text-[var(--color-text-muted)]" })
253
+ : _jsx(ChevronRight, { className: "w-3.5 h-3.5 text-[var(--color-text-muted)]" }), _jsx("span", { className: "text-[var(--color-text-muted)] uppercase text-[10px]", children: groupColLabel }), _jsx("span", { className: "font-medium text-[var(--color-text-primary)]", children: item.label }), _jsxs("span", { className: "text-[var(--color-text-muted)]", children: ["(", item.count, ")"] })] }) }, `g:${item.key}`));
254
+ }
255
+ const row = item.row;
256
+ const key = rowKey(row);
257
+ const isChecked = selection?.selected.has(key) ?? false;
258
+ const rowIndent = groupBy.length > 0 ? 12 + groupBy.length * 18 : undefined;
259
+ return (_jsxs("div", { className: `grid items-center px-3 py-2 text-sm border-b border-[var(--color-border)] ${onRowClick ? 'cursor-pointer hover:bg-[var(--color-surface-raised)]' : ''}`, style: {
260
+ gridTemplateColumns: gridTemplate,
261
+ background: isChecked ? 'rgba(99,102,241,0.07)' : undefined,
262
+ paddingLeft: rowIndent,
263
+ }, onClick: () => onRowClick?.(row), children: [selection && (_jsx("input", { type: "checkbox", checked: isChecked, onChange: () => toggleOne(key), onClick: (e) => e.stopPropagation(), "aria-label": "Zeile ausw\u00E4hlen" })), visibleColumns.map((col) => (_jsx("div", { className: "min-w-0 truncate", children: col.cell ? col.cell(row) : String(col.accessor(row) ?? '') }, col.id))), _jsx("div", {})] }, `r:${String(key)}:${idx}`));
264
+ })), footer && (_jsx("div", { className: "px-3 py-2 text-xs text-[var(--color-text-muted)] border-t border-[var(--color-border)]", children: footer }))] }));
265
+ }
266
+ function FilterEditor({ column, value, onChange, }) {
267
+ if (!column.filter)
268
+ return null;
269
+ const f = column.filter;
270
+ if (f.type === 'text') {
271
+ return (_jsxs("div", { className: "px-2 py-1.5 space-y-1", children: [_jsx("label", { className: "block text-xs text-[var(--color-text-muted)]", children: "Enth\u00E4lt" }), _jsx("input", { type: "text", value: value?.text ?? '', onChange: (e) => onChange({ text: e.target.value }), onClick: (e) => e.stopPropagation(), onKeyDown: (e) => e.stopPropagation(), className: "w-full px-2 py-1 text-xs border border-[var(--color-border)] bg-[var(--color-surface)]", style: { borderRadius: 'var(--border-radius-md)' } }), value?.text && (_jsx("button", { type: "button", className: "text-xs underline text-[var(--color-text-muted)]", onClick: () => onChange(undefined), children: "Filter entfernen" }))] }));
272
+ }
273
+ if (f.type === 'multi-select') {
274
+ const selected = new Set(value?.selected ?? []);
275
+ return (_jsxs("div", { className: "px-2 py-1.5 space-y-1 max-h-[300px] overflow-auto", children: [_jsxs("div", { className: "flex items-center justify-between text-xs text-[var(--color-text-muted)]", children: [_jsx("button", { type: "button", onClick: (e) => { e.preventDefault(); onChange(undefined); }, className: "hover:text-[var(--color-text-primary)] underline", children: "Alle" }), _jsx("span", { children: selected.size > 0 ? `${selected.size} aktiv` : '' })] }), f.options.map((opt) => {
276
+ const checked = selected.has(opt.value);
277
+ return (_jsxs("button", { type: "button", onClick: (e) => {
278
+ e.preventDefault();
279
+ const next = new Set(selected);
280
+ if (checked)
281
+ next.delete(opt.value);
282
+ else
283
+ next.add(opt.value);
284
+ onChange(next.size === 0 ? undefined : { selected: Array.from(next) });
285
+ }, className: "flex items-center gap-2 w-full px-1 py-1 text-left text-xs hover:bg-[var(--color-surface-raised)]", children: [_jsx("span", { className: "inline-flex items-center justify-center w-4 h-4 border", style: {
286
+ borderColor: 'var(--color-border)',
287
+ background: checked ? 'var(--color-primary)' : 'var(--color-surface)',
288
+ color: 'white',
289
+ borderRadius: 'var(--border-radius-md)',
290
+ fontSize: 10,
291
+ }, children: checked ? '✓' : '' }), _jsx("span", { children: opt.label })] }, opt.value));
292
+ })] }));
293
+ }
294
+ if (f.type === 'boolean') {
295
+ return (_jsxs("div", { className: "px-2 py-1.5 space-y-1", children: [_jsx("label", { className: "block text-xs text-[var(--color-text-muted)]", children: "Filter" }), _jsxs("select", { value: value?.bool ?? '', onChange: (e) => {
296
+ const v = e.target.value;
297
+ onChange(v === '' ? undefined : { bool: v });
298
+ }, onClick: (e) => e.stopPropagation(), className: "w-full px-2 py-1 text-xs border border-[var(--color-border)] bg-[var(--color-surface)]", style: { borderRadius: 'var(--border-radius-md)' }, children: [_jsx("option", { value: "", children: "\u2013 alle \u2013" }), _jsx("option", { value: "true", children: f.labelTrue ?? 'Ja' }), _jsx("option", { value: "false", children: f.labelFalse ?? 'Nein' })] })] }));
299
+ }
300
+ return null;
301
+ }
302
+ function ColumnInventoryButton({ columns, prefs, setColumnVisible, moveColumn, reset, groupBy, addGroupBy, removeGroupBy, moveGroupBy, clearGroupBy, }) {
303
+ const groupableCandidates = columns.filter((c) => !groupBy.includes(c.id));
304
+ const [open, setOpen] = useState(false);
305
+ const orderedColumns = useMemo(() => {
306
+ const byId = new Map(columns.map((c) => [c.id, c]));
307
+ return prefs.columnOrder.map((id) => byId.get(id)).filter(Boolean);
308
+ }, [columns, prefs.columnOrder]);
309
+ return (_jsxs(DropdownMenu.Root, { open: open, onOpenChange: setOpen, children: [_jsx(DropdownMenu.Trigger, { asChild: true, children: _jsx("button", { type: "button", className: "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) => {
310
+ const col = columns.find((c) => c.id === id);
311
+ return (_jsxs("div", { className: "flex items-center gap-2 px-1 py-1 text-xs", children: [_jsxs("span", { className: "text-[var(--color-text-muted)] w-4", children: [idx + 1, "."] }), _jsx("span", { className: "flex-1 truncate", children: col?.label ?? id }), _jsx("button", { type: "button", disabled: idx === 0, onClick: () => moveGroupBy(id, -1), className: "p-0.5 disabled:opacity-30 hover:bg-[var(--color-surface-raised)]", title: "Ebene hoch", style: { borderRadius: 'var(--border-radius-md)' }, children: _jsx(ArrowUp, { className: "w-3 h-3" }) }), _jsx("button", { type: "button", disabled: idx === groupBy.length - 1, onClick: () => moveGroupBy(id, 1), className: "p-0.5 disabled:opacity-30 hover:bg-[var(--color-surface-raised)]", title: "Ebene runter", style: { borderRadius: 'var(--border-radius-md)' }, children: _jsx(ArrowDown, { className: "w-3 h-3" }) }), _jsx("button", { type: "button", onClick: () => removeGroupBy(id), className: "p-0.5 text-[var(--color-text-muted)] hover:text-[var(--color-danger)]", title: "Diese Ebene entfernen", style: { borderRadius: 'var(--border-radius-md)' }, children: _jsx(X, { className: "w-3 h-3" }) })] }, id));
312
+ }), _jsx("button", { type: "button", onClick: clearGroupBy, className: "text-xs underline text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] px-1", children: "Alle aufheben" })] })), groupableCandidates.length > 0 && (_jsxs("div", { className: "mb-2 flex items-center gap-2 px-1", onClick: (e) => e.stopPropagation(), children: [_jsx("span", { className: "text-xs text-[var(--color-text-muted)]", children: "Ebene hinzuf\u00FCgen:" }), _jsxs("select", { defaultValue: "", onChange: (e) => {
313
+ const v = e.target.value;
314
+ if (v)
315
+ addGroupBy(v);
316
+ e.target.value = '';
317
+ }, 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) => {
318
+ const visible = prefs.columnVisibility[col.id] !== false;
319
+ 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-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-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));
320
+ }), _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"] }) })] })] }));
321
+ }
@@ -0,0 +1,14 @@
1
+ import React from 'react';
2
+ import * as RadixDialog from '@radix-ui/react-dialog';
3
+ interface DialogProps {
4
+ open?: boolean;
5
+ onOpenChange?: (open: boolean) => void;
6
+ trigger?: React.ReactNode;
7
+ title: string;
8
+ description?: string;
9
+ children: React.ReactNode;
10
+ footer?: React.ReactNode;
11
+ }
12
+ export declare function Dialog({ open, onOpenChange, trigger, title, description, children, footer }: DialogProps): React.JSX.Element;
13
+ export declare const DialogClose: React.ForwardRefExoticComponent<RadixDialog.DialogCloseProps & React.RefAttributes<HTMLButtonElement>>;
14
+ export {};
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as RadixDialog from '@radix-ui/react-dialog';
3
+ import { X } from 'lucide-react';
4
+ export function Dialog({ open, onOpenChange, trigger, title, description, children, footer }) {
5
+ return (_jsxs(RadixDialog.Root, { open: open, onOpenChange: onOpenChange, children: [trigger && _jsx(RadixDialog.Trigger, { asChild: true, children: trigger }), _jsxs(RadixDialog.Portal, { children: [_jsx(RadixDialog.Overlay, { className: "fixed inset-0 bg-black/40 z-40 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" }), _jsxs(RadixDialog.Content, { className: "fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-md rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] shadow-lg focus:outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", children: [_jsxs("div", { className: "flex items-start justify-between p-5 border-b border-[var(--color-border)]", children: [_jsxs("div", { children: [_jsx(RadixDialog.Title, { className: "text-base font-semibold text-[var(--color-text-primary)]", children: title }), description && (_jsx(RadixDialog.Description, { className: "text-sm text-[var(--color-text-muted)] mt-0.5", children: description }))] }), _jsx(RadixDialog.Close, { className: "ml-4 p-1 rounded-md text-[var(--color-text-muted)] hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text-primary)] transition-colors", children: _jsx(X, { className: "w-4 h-4" }) })] }), _jsx("div", { className: "p-5", children: children }), footer && (_jsx("div", { className: "flex items-center justify-end gap-2 px-5 pb-5", children: footer }))] })] })] }));
6
+ }
7
+ export const DialogClose = RadixDialog.Close;
@@ -0,0 +1,13 @@
1
+ import React from 'react';
2
+ import * as Radix from '@radix-ui/react-dropdown-menu';
3
+ export declare const Root: React.FC<Radix.DropdownMenuProps>;
4
+ export declare const Trigger: React.ForwardRefExoticComponent<Radix.DropdownMenuTriggerProps & React.RefAttributes<HTMLButtonElement>>;
5
+ export declare const Portal: React.FC<Radix.DropdownMenuPortalProps>;
6
+ export declare const Content: React.ForwardRefExoticComponent<Omit<Radix.DropdownMenuContentProps & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
7
+ interface ItemProps extends React.ComponentPropsWithoutRef<typeof Radix.Item> {
8
+ variant?: 'default' | 'danger';
9
+ }
10
+ export declare const Item: React.ForwardRefExoticComponent<ItemProps & React.RefAttributes<HTMLDivElement>>;
11
+ export declare const Separator: React.ForwardRefExoticComponent<Omit<Radix.DropdownMenuSeparatorProps & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
12
+ export declare const Label: React.ForwardRefExoticComponent<Omit<Radix.DropdownMenuLabelProps & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
13
+ export {};
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import * as Radix from '@radix-ui/react-dropdown-menu';
4
+ export const Root = Radix.Root;
5
+ export const Trigger = Radix.Trigger;
6
+ export const Portal = Radix.Portal;
7
+ export const Content = React.forwardRef(({ className = '', sideOffset = 4, ...props }, ref) => (_jsx(Radix.Portal, { children: _jsx(Radix.Content, { ref: ref, sideOffset: sideOffset, className: `z-50 min-w-[160px] rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-1 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 ${className}`, ...props }) })));
8
+ Content.displayName = 'DropdownMenu.Content';
9
+ export const Item = React.forwardRef(({ className = '', variant = 'default', ...props }, ref) => (_jsx(Radix.Item, { ref: ref, className: `flex items-center gap-2 px-3 py-1.5 text-sm rounded-md cursor-pointer outline-none select-none transition-colors data-[disabled]:opacity-50 data-[disabled]:pointer-events-none ${variant === 'danger'
10
+ ? 'text-[var(--color-danger)] data-[highlighted]:bg-[var(--color-surface-raised)]'
11
+ : 'text-[var(--color-text-primary)] data-[highlighted]:bg-[var(--color-surface-raised)]'} ${className}`, ...props })));
12
+ Item.displayName = 'DropdownMenu.Item';
13
+ export const Separator = React.forwardRef(({ className = '', ...props }, ref) => (_jsx(Radix.Separator, { ref: ref, className: `my-1 h-px bg-[var(--color-border)] ${className}`, ...props })));
14
+ Separator.displayName = 'DropdownMenu.Separator';
15
+ export const Label = React.forwardRef(({ className = '', ...props }, ref) => (_jsx(Radix.Label, { ref: ref, className: `px-3 py-1 text-xs font-semibold text-[var(--color-text-muted)] uppercase tracking-wide ${className}`, ...props })));
16
+ Label.displayName = 'DropdownMenu.Label';
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ import type { LucideIcon } from 'lucide-react';
3
+ interface EmptyStateProps {
4
+ icon: LucideIcon;
5
+ title: string;
6
+ description?: string;
7
+ action?: React.ReactNode;
8
+ }
9
+ export declare function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps): React.JSX.Element;
10
+ export {};
@@ -0,0 +1,4 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ export function EmptyState({ icon: Icon, title, description, action }) {
3
+ return (_jsxs("div", { className: "flex flex-col items-center justify-center text-center py-12 px-6", children: [_jsx("div", { className: "text-[var(--color-text-muted)] mb-3", children: _jsx(Icon, { className: "w-10 h-10 mx-auto" }) }), _jsx("p", { className: "text-base font-semibold text-[var(--color-text-primary)]", children: title }), description && (_jsx("p", { className: "text-sm text-[var(--color-text-secondary)] mt-1 max-w-xs", children: description })), action && _jsx("div", { className: "mt-4", children: action })] }));
4
+ }
@@ -0,0 +1,5 @@
1
+ import React from 'react';
2
+ interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
3
+ }
4
+ export declare function Input({ className, ...props }: InputProps): React.JSX.Element;
5
+ export {};
@@ -0,0 +1,4 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ export function Input({ className = '', ...props }) {
3
+ return (_jsx("input", { className: `w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent bg-[var(--color-surface)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)] transition-colors ${className}`, ...props }));
4
+ }
@@ -0,0 +1,31 @@
1
+ import React from 'react';
2
+ export type RecordDialogMode = 'read' | 'edit' | 'create';
3
+ interface RecordDialogProps {
4
+ open: {
5
+ id: string;
6
+ item?: any;
7
+ } | null;
8
+ onOpenChange(open: {
9
+ id: string;
10
+ item?: any;
11
+ } | null): void;
12
+ mode?: RecordDialogMode;
13
+ onModeChange?(mode: RecordDialogMode): void;
14
+ title: string;
15
+ readContent: React.ReactNode;
16
+ editContent: React.ReactNode;
17
+ onSave(mode: 'edit' | 'create'): Promise<void> | void;
18
+ saving?: boolean;
19
+ saveDisabled?: boolean;
20
+ error?: string;
21
+ onErrorDismiss?: () => void;
22
+ labels?: {
23
+ close?: string;
24
+ cancel?: string;
25
+ save?: string;
26
+ create?: string;
27
+ edit?: string;
28
+ };
29
+ }
30
+ export declare function RecordDialog({ open, onOpenChange, mode: controlledMode, onModeChange, title, readContent, editContent, onSave, saving, saveDisabled, error, onErrorDismiss, labels, }: RecordDialogProps): React.JSX.Element;
31
+ export {};
@@ -0,0 +1,38 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { Pencil } from 'lucide-react';
4
+ import { Dialog, DialogClose } from './Dialog';
5
+ import { Button } from './Button';
6
+ import { Alert } from './Alert';
7
+ export function RecordDialog({ open, onOpenChange, mode: controlledMode, onModeChange, title, readContent, editContent, onSave, saving, saveDisabled, error, onErrorDismiss, labels, }) {
8
+ const [internalMode, setInternalMode] = useState('read');
9
+ const mode = controlledMode ?? internalMode;
10
+ useEffect(() => {
11
+ if (!open)
12
+ return;
13
+ const initial = open.id === 'new' ? 'create' : 'read';
14
+ if (controlledMode === undefined)
15
+ setInternalMode(initial);
16
+ onModeChange?.(initial);
17
+ }, [open?.id]);
18
+ function setMode(next) {
19
+ if (controlledMode === undefined)
20
+ setInternalMode(next);
21
+ onModeChange?.(next);
22
+ }
23
+ function handleClose() {
24
+ onOpenChange(null);
25
+ }
26
+ async function handleSave() {
27
+ if (mode === 'read')
28
+ return;
29
+ await onSave(mode);
30
+ }
31
+ const closeLabel = labels?.close ?? 'Schließen';
32
+ const cancelLabel = labels?.cancel ?? 'Abbrechen';
33
+ const saveLabel = labels?.save ?? 'Speichern';
34
+ const createLabel = labels?.create ?? 'Erstellen';
35
+ const editLabel = labels?.edit ?? 'Bearbeiten';
36
+ return (_jsx(Dialog, { open: open !== null, onOpenChange: (o) => { if (!o)
37
+ handleClose(); }, title: title, footer: mode === 'read' ? (_jsxs(_Fragment, { children: [_jsx(DialogClose, { asChild: true, children: _jsx(Button, { variant: "secondary", children: closeLabel }) }), _jsxs(Button, { onClick: () => setMode('edit'), children: [_jsx(Pencil, { className: "w-4 h-4" }), editLabel] })] })) : (_jsxs(_Fragment, { children: [_jsx(DialogClose, { asChild: true, children: _jsx(Button, { variant: "secondary", children: cancelLabel }) }), _jsx(Button, { onClick: handleSave, loading: !!saving, disabled: !!saveDisabled, children: mode === 'create' ? createLabel : saveLabel })] })), children: mode === 'read' ? (_jsxs("div", { children: [_jsx("div", { className: "flex justify-end mb-2 -mt-1", children: _jsx("button", { onClick: () => setMode('edit'), className: "p-1.5 rounded-md text-[var(--color-text-muted)] hover:text-[var(--color-primary)] hover:bg-[var(--color-surface-raised)] transition-colors", title: editLabel, "aria-label": editLabel, children: _jsx(Pencil, { className: "w-4 h-4" }) }) }), readContent] })) : (_jsxs("div", { className: "flex flex-col gap-4", children: [error && (_jsx(Alert, { variant: "error", onDismiss: onErrorDismiss, children: error })), editContent] })) }));
38
+ }
@@ -0,0 +1,9 @@
1
+ import React from 'react';
2
+ interface SkeletonProps {
3
+ className?: string;
4
+ width?: string | number;
5
+ height?: string | number;
6
+ }
7
+ export declare function Skeleton({ className, width, height }: SkeletonProps): React.JSX.Element;
8
+ export declare function SkeletonRow(): React.JSX.Element;
9
+ export {};
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ export function Skeleton({ className = '', width, height }) {
3
+ return (_jsx("div", { className: `skeleton ${className}`, style: { width, height } }));
4
+ }
5
+ export function SkeletonRow() {
6
+ return (_jsxs("div", { className: "flex items-center gap-3 py-2", children: [_jsx(Skeleton, { width: 120, height: 14 }), _jsx(Skeleton, { width: 80, height: 14 })] }));
7
+ }
@@ -0,0 +1,12 @@
1
+ import React from 'react';
2
+ export declare function TooltipProvider({ children }: {
3
+ children: React.ReactNode;
4
+ }): React.JSX.Element;
5
+ interface TooltipProps {
6
+ content: React.ReactNode;
7
+ children: React.ReactNode;
8
+ side?: 'top' | 'right' | 'bottom' | 'left';
9
+ delayDuration?: number;
10
+ }
11
+ export declare function Tooltip({ content, children, side, delayDuration }: TooltipProps): React.JSX.Element;
12
+ export {};
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as RadixTooltip from '@radix-ui/react-tooltip';
3
+ export function TooltipProvider({ children }) {
4
+ return _jsx(RadixTooltip.Provider, { children: children });
5
+ }
6
+ export function Tooltip({ content, children, side = 'top', delayDuration = 300 }) {
7
+ return (_jsxs(RadixTooltip.Root, { delayDuration: delayDuration, children: [_jsx(RadixTooltip.Trigger, { asChild: true, children: children }), _jsx(RadixTooltip.Portal, { children: _jsxs(RadixTooltip.Content, { side: side, sideOffset: 6, className: "z-50 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text-primary)] shadow-md animate-in fade-in-0 zoom-in-95", children: [content, _jsx(RadixTooltip.Arrow, { className: "fill-[var(--color-border)]" })] }) })] }));
8
+ }
@@ -0,0 +1,11 @@
1
+ export { Button } from './Button';
2
+ export { Input } from './Input';
3
+ export { Badge } from './Badge';
4
+ export { Alert, type AlertVariant } from './Alert';
5
+ export { Dialog, DialogClose } from './Dialog';
6
+ export * as DropdownMenu from './DropdownMenu';
7
+ export { Tooltip, TooltipProvider } from './Tooltip';
8
+ export { EmptyState } from './EmptyState';
9
+ export { Skeleton, SkeletonRow } from './Skeleton';
10
+ export { RecordDialog, type RecordDialogMode } from './RecordDialog';
11
+ export { DataTable, DEFAULT_VIEW_VERSION, type ColumnDef, type FilterDef, type DataTableSelectionProps, } from './DataTable';
@@ -0,0 +1,11 @@
1
+ export { Button } from './Button';
2
+ export { Input } from './Input';
3
+ export { Badge } from './Badge';
4
+ export { Alert } from './Alert';
5
+ export { Dialog, DialogClose } from './Dialog';
6
+ export * as DropdownMenu from './DropdownMenu';
7
+ export { Tooltip, TooltipProvider } from './Tooltip';
8
+ export { EmptyState } from './EmptyState';
9
+ export { Skeleton, SkeletonRow } from './Skeleton';
10
+ export { RecordDialog } from './RecordDialog';
11
+ export { DataTable, DEFAULT_VIEW_VERSION, } from './DataTable';
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @efa-one/sdk/frontend/ui/styles.css
3
+ *
4
+ * Globale Hilfsklassen, die `Badge` und `Skeleton` aus dem SDK-ui-Kit benötigen
5
+ * (sie sind CSS-Klassen-basiert, nicht Tailwind-Arbitrary). Einmal pro App
6
+ * importieren: import '@efa-one/sdk/frontend/ui/styles.css';
7
+ *
8
+ * Die Design-Tokens (`--color-*`, `--border-radius-*`) definiert die App selbst
9
+ * (Kernel-Theme-überschrieben zur Laufzeit) — hier bewusst NICHT dupliziert.
10
+ */
11
+
12
+ /* ── Badge utilities ─────────────────────────────────────────── */
13
+ .badge {
14
+ display: inline-flex;
15
+ align-items: center;
16
+ padding: 2px 8px;
17
+ border-radius: var(--border-radius-sm);
18
+ font-size: 12px;
19
+ font-weight: 500;
20
+ }
21
+ .badge-success {
22
+ background: color-mix(in srgb, var(--color-success) 15%, transparent);
23
+ color: var(--color-success);
24
+ }
25
+ .badge-warning {
26
+ background: color-mix(in srgb, var(--color-warning) 15%, transparent);
27
+ color: var(--color-warning);
28
+ }
29
+ .badge-danger {
30
+ background: color-mix(in srgb, var(--color-danger) 15%, transparent);
31
+ color: var(--color-danger);
32
+ }
33
+ .badge-neutral {
34
+ background: var(--color-surface-raised);
35
+ color: var(--color-text-secondary);
36
+ }
37
+
38
+ /* ── Skeleton loading ────────────────────────────────────────── */
39
+ .skeleton {
40
+ background: linear-gradient(
41
+ 90deg,
42
+ var(--color-surface-raised) 25%,
43
+ var(--color-border) 50%,
44
+ var(--color-surface-raised) 75%
45
+ );
46
+ background-size: 200% 100%;
47
+ animation: shimmer 1.5s infinite;
48
+ border-radius: var(--border-radius-md);
49
+ }
50
+ @keyframes shimmer {
51
+ from { background-position: 200% 0; }
52
+ to { background-position: -200% 0; }
53
+ }
@@ -0,0 +1,11 @@
1
+ export interface ViewPreferencesAdapter {
2
+ get<T = unknown>(listId: string): Promise<T | null>;
3
+ put(listId: string, prefs: unknown): void | Promise<void>;
4
+ reset(listId: string): void | Promise<void>;
5
+ }
6
+ export interface ViewPreferencesClientOptions {
7
+ apiBase?: string | (() => string);
8
+ fetchImpl?: typeof fetch;
9
+ }
10
+ export declare function createViewPreferencesClient(opts?: ViewPreferencesClientOptions): ViewPreferencesAdapter;
11
+ export declare function useViewPreferences<T extends object>(listId: string, defaultPrefs: T, adapter?: ViewPreferencesAdapter): [T, (next: T) => void, () => Promise<void>, boolean];
@@ -0,0 +1,79 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ export function createViewPreferencesClient(opts = {}) {
3
+ const resolveBase = typeof opts.apiBase === 'function'
4
+ ? opts.apiBase
5
+ : () => opts.apiBase ?? '';
6
+ const f = opts.fetchImpl ?? fetch;
7
+ const url = (listId) => `${resolveBase()}/api/view-preferences/${encodeURIComponent(listId)}`;
8
+ return {
9
+ async get(listId) {
10
+ const res = await f(url(listId), { credentials: 'include' });
11
+ if (res.status === 204 || !res.ok)
12
+ return null;
13
+ return res.json();
14
+ },
15
+ async put(listId, prefs) {
16
+ await f(url(listId), {
17
+ method: 'PUT',
18
+ credentials: 'include',
19
+ headers: { 'Content-Type': 'application/json' },
20
+ body: JSON.stringify(prefs),
21
+ });
22
+ },
23
+ async reset(listId) {
24
+ await f(url(listId), { method: 'DELETE', credentials: 'include' });
25
+ },
26
+ };
27
+ }
28
+ export function useViewPreferences(listId, defaultPrefs, adapter) {
29
+ const [prefs, setPrefsState] = useState(defaultPrefs);
30
+ const [loaded, setLoaded] = useState(!adapter);
31
+ const saveTimer = useRef(null);
32
+ useEffect(() => {
33
+ if (!adapter)
34
+ return;
35
+ let cancelled = false;
36
+ Promise.resolve(adapter.get(listId))
37
+ .then((remote) => {
38
+ if (cancelled)
39
+ return;
40
+ const hasRemote = !!remote && typeof remote === 'object';
41
+ const dv = defaultPrefs.version;
42
+ const rv = hasRemote ? remote.version : undefined;
43
+ if (hasRemote && rv === dv) {
44
+ setPrefsState({ ...defaultPrefs, ...remote });
45
+ }
46
+ else {
47
+ setPrefsState(defaultPrefs);
48
+ if (hasRemote)
49
+ Promise.resolve(adapter.reset(listId)).catch(() => { });
50
+ }
51
+ setLoaded(true);
52
+ })
53
+ .catch(() => setLoaded(true));
54
+ return () => { cancelled = true; };
55
+ }, [listId]);
56
+ const setPrefs = useCallback((next) => {
57
+ setPrefsState(next);
58
+ if (!adapter || !loaded)
59
+ return;
60
+ if (saveTimer.current != null)
61
+ window.clearTimeout(saveTimer.current);
62
+ saveTimer.current = window.setTimeout(() => {
63
+ Promise.resolve(adapter.put(listId, next)).catch((err) => {
64
+ console.warn('view-preferences save failed', err);
65
+ });
66
+ }, 600);
67
+ }, [listId, loaded, adapter]);
68
+ const reset = useCallback(async () => {
69
+ setPrefsState(defaultPrefs);
70
+ if (!adapter)
71
+ return;
72
+ try {
73
+ await adapter.reset(listId);
74
+ }
75
+ catch {
76
+ }
77
+ }, [listId, defaultPrefs, adapter]);
78
+ return [prefs, setPrefs, reset, loaded];
79
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@efa-one/sdk",
3
- "version": "1.11.0",
3
+ "version": "1.12.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",
@@ -20,6 +20,12 @@
20
20
  "import": "./frontend/index.js",
21
21
  "default": "./frontend/index.js"
22
22
  },
23
+ "./frontend/ui": {
24
+ "types": "./frontend/ui/index.d.ts",
25
+ "import": "./frontend/ui/index.js",
26
+ "default": "./frontend/ui/index.js"
27
+ },
28
+ "./frontend/ui/styles.css": "./frontend/ui/styles.css",
23
29
  "./frontend/*": {
24
30
  "types": "./frontend/*.d.ts",
25
31
  "import": "./frontend/*.js",
@@ -35,7 +41,7 @@
35
41
  "scripts": {
36
42
  "clean": "rm -rf backend frontend",
37
43
  "build:backend": "tsc -p tsconfig.backend.json",
38
- "build:frontend": "tsc -p tsconfig.frontend.json && node -e \"require('fs').writeFileSync('frontend/package.json', JSON.stringify({type:'module'}))\"",
44
+ "build:frontend": "tsc -p tsconfig.frontend.json && node -e \"const fs=require('fs');fs.writeFileSync('frontend/package.json', JSON.stringify({type:'module'}));fs.copyFileSync('src/frontend/ui/styles.css','frontend/ui/styles.css')\"",
39
45
  "build": "npm run clean && npm run build:backend && npm run build:frontend",
40
46
  "typecheck": "tsc -p tsconfig.backend.json --noEmit && tsc -p tsconfig.frontend.json --noEmit",
41
47
  "test": "vitest run",
@@ -45,12 +51,17 @@
45
51
  "access": "public"
46
52
  },
47
53
  "peerDependencies": {
54
+ "@radix-ui/react-dialog": ">=1",
55
+ "@radix-ui/react-dropdown-menu": ">=1",
56
+ "@radix-ui/react-tooltip": ">=1",
48
57
  "express": ">=4",
49
58
  "i18next": ">=23",
50
59
  "i18next-http-backend": ">=2",
51
60
  "jsonwebtoken": ">=9",
61
+ "lucide-react": ">=0.400.0",
52
62
  "pg": ">=8",
53
63
  "react": ">=18",
64
+ "react-dom": ">=18",
54
65
  "react-i18next": ">=13"
55
66
  },
56
67
  "peerDependenciesMeta": {
@@ -66,6 +77,9 @@
66
77
  "react": {
67
78
  "optional": true
68
79
  },
80
+ "react-dom": {
81
+ "optional": true
82
+ },
69
83
  "i18next": {
70
84
  "optional": true
71
85
  },
@@ -74,12 +88,28 @@
74
88
  },
75
89
  "react-i18next": {
76
90
  "optional": true
91
+ },
92
+ "@radix-ui/react-dialog": {
93
+ "optional": true
94
+ },
95
+ "@radix-ui/react-dropdown-menu": {
96
+ "optional": true
97
+ },
98
+ "@radix-ui/react-tooltip": {
99
+ "optional": true
100
+ },
101
+ "lucide-react": {
102
+ "optional": true
77
103
  }
78
104
  },
79
105
  "devDependencies": {
106
+ "@radix-ui/react-dialog": "^1.1.4",
107
+ "@radix-ui/react-dropdown-menu": "^2.1.4",
108
+ "@radix-ui/react-tooltip": "^1.1.6",
80
109
  "@types/express": "^4.17.21",
81
110
  "@types/jsonwebtoken": "^9.0.6",
82
111
  "cookie-parser": "^1.4.6",
112
+ "lucide-react": "^0.468.0",
83
113
  "@types/node": "^20.14.0",
84
114
  "@types/pg": "^8.11.0",
85
115
  "@types/react": "^18.2.46",
@@ -90,6 +120,8 @@
90
120
  "jsonwebtoken": "^9.0.2",
91
121
  "pg": "^8.12.0",
92
122
  "react": "^18.2.0",
123
+ "react-dom": "^18.2.0",
124
+ "@types/react-dom": "^18.2.0",
93
125
  "react-i18next": "^13.5.0",
94
126
  "supertest": "^7.2.2",
95
127
  "typescript": "7.0.2",