@efa-one/sdk 1.24.0 → 1.26.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
@@ -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`), platform-admin check (`isPlatformAdmin` — top permission `efa-all`, legacy `converge-admin` accepted until Q1-2027; `requireAdmin`/`requireAdminOrPermission` use it), capability registry (`registerApiMetadata`), backend i18n |
16
16
  | `@efa-one/sdk/frontend` | Browser/React | postMessage IPC (`registerAppInfo`, `sendAtStart`, `navigateToApp`, `notifyRouteChange` …), react-i18next factory (`initI18n`), platform-admin check (`isPlatformAdmin`, `PLATFORM_ADMIN_PERMISSIONS`), `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, 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'` |
17
+ | `@efa-one/sdk/frontend/ui` | Browser/React | The efa-one design-system kit: `Button`, `Input`, `Switch` (on/off settings — checkboxes only for row selection and confirmations), `Badge`, `Alert`, `Dialog`, `DropdownMenu`, `Tooltip`, `EmptyState`, `Skeleton`, `RecordDialog` with `useRecords` (ready-made list/create/edit/delete flow) and `ConfirmDeleteDialog`, 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
 
@@ -269,6 +269,34 @@ Excel export for such data. No backend or kernel change.
269
269
  - After the bump every dialog without `size` is 768 px wide. Give short confirmations
270
270
  ("Wirklich löschen?") `size="sm"`.
271
271
 
272
+ **1.25.0 — dismissible banners, top scrollbar.** No kernel or backend change.
273
+ - Every `Alert` shows a close „×" top right by default. With `onDismiss` nothing
274
+ changes. Without it the banner hides itself and shows again when its text changes.
275
+ `dismissible={false}` removes the „×" for notices that must stay.
276
+ - Messages from app state (errors, results) need `onDismiss`: without it, a closed
277
+ banner stays hidden when the app sets the same text again in one handler or when
278
+ the content is JSX. After the bump, add `onDismiss` (and `onErrorDismiss` on
279
+ `RecordDialog`) wherever such banners lack it.
280
+ - `DataTable`: when the grid is wider than its container, a second horizontal
281
+ scrollbar sits above the table and scrolls in sync with the bottom one. Not on
282
+ mobile. The border moved to an outer wrapper.
283
+
284
+ **1.26.0 — `useRecords`, `ConfirmDeleteDialog`, `Switch`.** No kernel or backend change.
285
+ - `useRecords({ endpoint, apiBase, emptyForm, toForm, toPayload?, required?, validate? })`
286
+ runs the whole records flow: load the list, open for create (`openCreate`) or read
287
+ (`openRecord`), save (`POST <endpoint>` or `PUT <endpoint>/:id`, `updateMethod: 'PATCH'`
288
+ optional), delete after confirmation (`DELETE <endpoint>/:id`), reload after each write,
289
+ server messages `{ "error" }` as banner. Spread `dialogProps` into `RecordDialog` and
290
+ `deleteDialogProps` into `ConfirmDeleteDialog`. The logic itself is
291
+ `createRecordsController` (no React, for tests or other frameworks).
292
+ - Use it instead of hand-written `save()` functions: one found in a generated app returned
293
+ early for `{ id: 'new' }`, so „Erstellen" never sent anything and no type check noticed.
294
+ - `ConfirmDeleteDialog`: delete confirmation, `size="sm"`, error banner.
295
+ - `RecordDialog` takes `readOnly` (no pencil, no „Bearbeiten“, e.g. without write
296
+ permission); `RecordDialogProps` is exported.
297
+ - `Switch`/`SwitchControl` for on/off values (checkboxes only for row selection and
298
+ confirmations).
299
+
272
300
  **Next minor (planned 1.23.0, not yet released) — exchange, cookie and S2S hardening.**
273
301
  Needs a kernel with efaware/efa_kernel#106 and #107 first (gateway exchange token
274
302
  `gw: 'app'`, provenance `jti`, cookie filter). Then:
@@ -4,7 +4,10 @@ interface AlertProps {
4
4
  variant?: AlertVariant;
5
5
  children: React.ReactNode;
6
6
  onDismiss?: () => void;
7
+ dismissible?: boolean;
7
8
  className?: string;
8
9
  }
9
- export declare function Alert({ variant, children, onDismiss, className }: AlertProps): React.JSX.Element;
10
+ export declare function alertContentKey(children: React.ReactNode): string | null;
11
+ export declare function isAlertHidden(dismissedKey: string | null | undefined, contentKey: string | null): boolean;
12
+ export declare function Alert({ variant, children, onDismiss, dismissible, className }: AlertProps): React.JSX.Element | null;
10
13
  export {};
@@ -1,5 +1,16 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
2
3
  import { X } from 'lucide-react';
3
- export function Alert({ variant = 'error', children, onDismiss, className = '' }) {
4
- return (_jsxs("div", { role: "alert", className: `shrink-0 flex items-start gap-2 border rounded-md px-4 py-3 text-sm font-medium alert-${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" }) }))] }));
4
+ export function alertContentKey(children) {
5
+ return typeof children === 'string' || typeof children === 'number' ? String(children) : null;
6
+ }
7
+ export function isAlertHidden(dismissedKey, contentKey) {
8
+ return dismissedKey !== undefined && dismissedKey === contentKey;
9
+ }
10
+ export function Alert({ variant = 'error', children, onDismiss, dismissible = true, className = '' }) {
11
+ const [dismissedKey, setDismissedKey] = useState(undefined);
12
+ const contentKey = alertContentKey(children);
13
+ if (!onDismiss && isAlertHidden(dismissedKey, contentKey))
14
+ return null;
15
+ return (_jsxs("div", { role: "alert", className: `shrink-0 flex items-start gap-2 border rounded-md px-4 py-3 text-sm font-medium alert-${variant} ${className}`, children: [_jsx("span", { className: "flex-1 min-w-0", children: children }), dismissible && (_jsx("button", { type: "button", onClick: onDismiss ?? (() => setDismissedKey(contentKey)), "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" }) }))] }));
5
16
  }
@@ -0,0 +1,17 @@
1
+ import React from 'react';
2
+ export interface ConfirmDeleteDialogProps {
3
+ open: boolean;
4
+ itemName?: string;
5
+ title?: string;
6
+ description?: string;
7
+ deleting?: boolean;
8
+ error?: string;
9
+ onErrorDismiss?: () => void;
10
+ onConfirm(): void | Promise<void>;
11
+ onCancel(): void;
12
+ labels?: {
13
+ cancel?: string;
14
+ confirm?: string;
15
+ };
16
+ }
17
+ export declare function ConfirmDeleteDialog({ open, itemName, title, description, deleting, error, onErrorDismiss, onConfirm, onCancel, labels, }: ConfirmDeleteDialogProps): React.JSX.Element;
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Dialog } from './Dialog.js';
3
+ import { Button } from './Button.js';
4
+ import { Alert } from './Alert.js';
5
+ export function ConfirmDeleteDialog({ open, itemName, title, description, deleting, error, onErrorDismiss, onConfirm, onCancel, labels, }) {
6
+ const text = description ?? (itemName ? `„${itemName}“ wird endgültig gelöscht.` : 'Der Eintrag wird endgültig gelöscht.');
7
+ return (_jsx(Dialog, { open: open, onOpenChange: (o) => {
8
+ if (!o)
9
+ onCancel();
10
+ }, title: title ?? 'Eintrag löschen?', size: "sm", banner: error ? _jsx(Alert, { variant: "error", onDismiss: onErrorDismiss, children: error }) : undefined, footer: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", onClick: onCancel, disabled: !!deleting, children: labels?.cancel ?? 'Abbrechen' }), _jsx(Button, { variant: "danger", onClick: () => void onConfirm(), loading: !!deleting, children: labels?.confirm ?? 'Löschen' })] }), children: _jsx("p", { className: "text-sm text-[var(--color-text-secondary)]", children: text }) }));
11
+ }
@@ -89,4 +89,5 @@ interface RenderItem<T> {
89
89
  export declare function buildRenderItems<T>(rows: T[], groupBy: string[], columnsById: Map<string, ColumnDef<T>>, expandedGroups: Set<string> | 'all'): RenderItem<T>[];
90
90
  export declare function collectAllGroupKeys<T>(rows: T[], groupBy: string[], columnsById: Map<string, ColumnDef<T>>): string[];
91
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;
92
+ export declare function topScrollbarWidth(gridWidth: number, viewportWidth: number): number | null;
92
93
  export {};
@@ -241,6 +241,7 @@ export function DataTable({ listId, rows, columns, rowKey, onRowClick, selection
241
241
  const [dragColumnId, setDragColumnId] = useState(null);
242
242
  const [dropTarget, setDropTarget] = useState(null);
243
243
  const containerRef = useRef(null);
244
+ const headerRef = useRef(null);
244
245
  const effectiveOrder = useMemo(() => reconcileOrder(columns, prefs.columnOrder), [columns, prefs.columnOrder]);
245
246
  const seenGroupKeysRef = useRef(new Set());
246
247
  useEffect(() => {
@@ -433,42 +434,85 @@ export function DataTable({ listId, rows, columns, rowKey, onRowClick, selection
433
434
  hasColumns: visibleColumns.some((c) => c.exportable !== false),
434
435
  run: runExport,
435
436
  } : 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) => {
437
- const activeSort = prefs.sort?.columnId === col.id ? prefs.sort.direction : null;
438
- const filterValue = prefs.filters[col.id];
439
- const isFiltered = filterValue != null;
440
- return (_jsx(HeaderCell, { columnId: col.id, label: col.label, dragColumnId: dragColumnId, dropSide: dropTarget?.id === col.id ? dropTarget.side : null, onDragStart: () => setDragColumnId(col.id), onDragEnd: () => { setDragColumnId(null); setDropTarget(null); }, onDragOverSide: (side) => {
441
- if (dropTarget?.id !== col.id || dropTarget.side !== side)
442
- setDropTarget({ id: col.id, side });
443
- }, onDrop: (sourceId, side) => {
444
- dropColumn(sourceId, col.id, side);
445
- setDragColumnId(null);
446
- setDropTarget(null);
447
- }, onResizePreview: (px) => previewColumnWidth(col.id, px), onResizeCommit: (px) => setColumnWidth(col.id, px), onResizeCancel: () => containerRef.current?.style.setProperty('--dt-cols', gridTemplate), hasCustomWidth: columnWidths[col.id] != null, children: _jsxs(DropdownMenu.Root, { children: [_jsx(DropdownMenu.Trigger, { asChild: true, children: _jsxs("button", { type: "button", className: "flex items-center gap-1 min-w-0 px-1 py-0.5 hover:bg-[var(--color-surface)] text-left", style: { borderRadius: 'var(--border-radius-md)' }, children: [_jsx("span", { className: "min-w-0 truncate", 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 signal-text-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) })), columnWidths[col.id] != null && (_jsxs(DropdownMenu.Item, { onSelect: () => setColumnWidth(col.id, null), children: [_jsx(RotateCcw, { className: "w-4 h-4" }), "Standardbreite"] })), _jsxs(DropdownMenu.Item, { onSelect: () => setColumnVisible(col.id, false), variant: "danger", children: [_jsx(EyeOff, { className: "w-4 h-4" }), "Spalte ausblenden"] })] })] }) }, col.id));
448
- }), gearButton] })), 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) => {
449
- if (item.kind === 'group') {
450
- const level = item.level ?? 0;
451
- const groupColLabel = columnsById.get(groupBy[level])?.label ?? '';
452
- 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: {
453
- background: 'var(--color-surface-raised)',
454
- paddingLeft: 12 + level * 18,
455
- }, onClick: () => toggleGroup(item.key), title: item.expanded ? 'Gruppe einklappen' : 'Gruppe ausklappen', children: _jsxs("div", { className: "flex items-center gap-2", children: [item.expanded
456
- ? _jsx(ChevronDown, { className: "w-3.5 h-3.5 text-[var(--color-text-muted)]" })
457
- : _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}`));
458
- }
459
- const row = item.row;
460
- const key = rowKey(row);
461
- const isChecked = selection?.selected.has(key) ?? false;
462
- const rowIndent = groupBy.length > 0 ? 12 + groupBy.length * 18 : undefined;
463
- if (isMobile) {
464
- return (_jsxs("div", { className: `flex flex-col gap-1.5 px-3 py-3 border-b border-[var(--color-border)] ${onRowClick ? 'cursor-pointer hover:bg-[var(--color-surface-raised)]' : ''}`, style: { background: isChecked ? 'rgba(99,102,241,0.07)' : undefined, paddingLeft: rowIndent }, onClick: () => onRowClick?.(row), children: [selection && (_jsxs("label", { className: "flex items-center gap-2 self-start py-1 pr-2 cursor-pointer", onClick: (e) => e.stopPropagation(), children: [_jsx("input", { type: "checkbox", className: "w-5 h-5", checked: isChecked, onChange: () => toggleOne(key), "aria-label": "Zeile ausw\u00E4hlen" }), _jsx("span", { className: "text-xs text-[var(--color-text-muted)]", children: "Ausw\u00E4hlen" })] })), visibleColumns.map((col) => (_jsxs("div", { className: "flex flex-col gap-0.5", children: [_jsx("span", { className: "text-xs font-medium uppercase tracking-wide text-[var(--color-text-muted)]", children: col.label }), _jsx("div", { className: "min-w-0 break-words text-sm", children: col.cell ? col.cell(row) : formatCellValue(col, row) })] }, col.id)))] }, `r:${String(key)}:${idx}`));
465
- }
466
- return (_jsxs("div", { className: `grid items-center min-w-min px-3 py-2 text-sm border-b border-[var(--color-border)] ${onRowClick ? 'cursor-pointer hover:bg-[var(--color-surface-raised)]' : ''}`, style: {
467
- gridTemplateColumns: 'var(--dt-cols)',
468
- background: isChecked ? 'rgba(99,102,241,0.07)' : undefined,
469
- paddingLeft: rowIndent,
470
- }, 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) : formatCellValue(col, row) }, col.id))), _jsx("div", {})] }, `r:${String(key)}:${idx}`));
471
- })), footer && (_jsx("div", { className: "px-3 py-2 text-xs text-[var(--color-text-muted)] border-t border-[var(--color-border)]", children: footer }))] }));
437
+ return (_jsxs("div", { className: "border border-[var(--color-border)]", children: [_jsx(TopScrollbar, { targetRef: containerRef, measureRef: headerRef, enabled: !isMobile }), _jsxs("div", { ref: containerRef, className: "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", { ref: headerRef, 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) => {
438
+ const activeSort = prefs.sort?.columnId === col.id ? prefs.sort.direction : null;
439
+ const filterValue = prefs.filters[col.id];
440
+ const isFiltered = filterValue != null;
441
+ return (_jsx(HeaderCell, { columnId: col.id, label: col.label, dragColumnId: dragColumnId, dropSide: dropTarget?.id === col.id ? dropTarget.side : null, onDragStart: () => setDragColumnId(col.id), onDragEnd: () => { setDragColumnId(null); setDropTarget(null); }, onDragOverSide: (side) => {
442
+ if (dropTarget?.id !== col.id || dropTarget.side !== side)
443
+ setDropTarget({ id: col.id, side });
444
+ }, onDrop: (sourceId, side) => {
445
+ dropColumn(sourceId, col.id, side);
446
+ setDragColumnId(null);
447
+ setDropTarget(null);
448
+ }, onResizePreview: (px) => previewColumnWidth(col.id, px), onResizeCommit: (px) => setColumnWidth(col.id, px), onResizeCancel: () => containerRef.current?.style.setProperty('--dt-cols', gridTemplate), hasCustomWidth: columnWidths[col.id] != null, children: _jsxs(DropdownMenu.Root, { children: [_jsx(DropdownMenu.Trigger, { asChild: true, children: _jsxs("button", { type: "button", className: "flex items-center gap-1 min-w-0 px-1 py-0.5 hover:bg-[var(--color-surface)] text-left", style: { borderRadius: 'var(--border-radius-md)' }, children: [_jsx("span", { className: "min-w-0 truncate", 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 signal-text-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) })), columnWidths[col.id] != null && (_jsxs(DropdownMenu.Item, { onSelect: () => setColumnWidth(col.id, null), children: [_jsx(RotateCcw, { className: "w-4 h-4" }), "Standardbreite"] })), _jsxs(DropdownMenu.Item, { onSelect: () => setColumnVisible(col.id, false), variant: "danger", children: [_jsx(EyeOff, { className: "w-4 h-4" }), "Spalte ausblenden"] })] })] }) }, col.id));
449
+ }), gearButton] })), 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) => {
450
+ if (item.kind === 'group') {
451
+ const level = item.level ?? 0;
452
+ const groupColLabel = columnsById.get(groupBy[level])?.label ?? '';
453
+ 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: {
454
+ background: 'var(--color-surface-raised)',
455
+ paddingLeft: 12 + level * 18,
456
+ }, onClick: () => toggleGroup(item.key), title: item.expanded ? 'Gruppe einklappen' : 'Gruppe ausklappen', children: _jsxs("div", { className: "flex items-center gap-2", children: [item.expanded
457
+ ? _jsx(ChevronDown, { className: "w-3.5 h-3.5 text-[var(--color-text-muted)]" })
458
+ : _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}`));
459
+ }
460
+ const row = item.row;
461
+ const key = rowKey(row);
462
+ const isChecked = selection?.selected.has(key) ?? false;
463
+ const rowIndent = groupBy.length > 0 ? 12 + groupBy.length * 18 : undefined;
464
+ if (isMobile) {
465
+ return (_jsxs("div", { className: `flex flex-col gap-1.5 px-3 py-3 border-b border-[var(--color-border)] ${onRowClick ? 'cursor-pointer hover:bg-[var(--color-surface-raised)]' : ''}`, style: { background: isChecked ? 'rgba(99,102,241,0.07)' : undefined, paddingLeft: rowIndent }, onClick: () => onRowClick?.(row), children: [selection && (_jsxs("label", { className: "flex items-center gap-2 self-start py-1 pr-2 cursor-pointer", onClick: (e) => e.stopPropagation(), children: [_jsx("input", { type: "checkbox", className: "w-5 h-5", checked: isChecked, onChange: () => toggleOne(key), "aria-label": "Zeile ausw\u00E4hlen" }), _jsx("span", { className: "text-xs text-[var(--color-text-muted)]", children: "Ausw\u00E4hlen" })] })), visibleColumns.map((col) => (_jsxs("div", { className: "flex flex-col gap-0.5", children: [_jsx("span", { className: "text-xs font-medium uppercase tracking-wide text-[var(--color-text-muted)]", children: col.label }), _jsx("div", { className: "min-w-0 break-words text-sm", children: col.cell ? col.cell(row) : formatCellValue(col, row) })] }, col.id)))] }, `r:${String(key)}:${idx}`));
466
+ }
467
+ return (_jsxs("div", { className: `grid items-center min-w-min px-3 py-2 text-sm border-b border-[var(--color-border)] ${onRowClick ? 'cursor-pointer hover:bg-[var(--color-surface-raised)]' : ''}`, style: {
468
+ gridTemplateColumns: 'var(--dt-cols)',
469
+ background: isChecked ? 'rgba(99,102,241,0.07)' : undefined,
470
+ paddingLeft: rowIndent,
471
+ }, 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) : formatCellValue(col, row) }, col.id))), _jsx("div", {})] }, `r:${String(key)}:${idx}`));
472
+ })), footer && (_jsx("div", { className: "px-3 py-2 text-xs text-[var(--color-text-muted)] border-t border-[var(--color-border)]", children: footer }))] })] }));
473
+ }
474
+ export function topScrollbarWidth(gridWidth, viewportWidth) {
475
+ return gridWidth > viewportWidth + 1 ? gridWidth : null;
476
+ }
477
+ function syncScroll(from, to) {
478
+ if (Math.abs(to.scrollLeft - from.scrollLeft) > 1)
479
+ to.scrollLeft = from.scrollLeft;
480
+ }
481
+ function TopScrollbar({ targetRef, measureRef, enabled }) {
482
+ const barRef = useRef(null);
483
+ const [width, setWidth] = useState(null);
484
+ useEffect(() => {
485
+ const target = targetRef.current;
486
+ const measured = measureRef.current;
487
+ if (!enabled || !target || !measured || typeof ResizeObserver === 'undefined') {
488
+ setWidth(null);
489
+ return;
490
+ }
491
+ const update = () => setWidth(topScrollbarWidth(target.scrollWidth, target.clientWidth));
492
+ const observer = new ResizeObserver(update);
493
+ observer.observe(target);
494
+ observer.observe(measured);
495
+ update();
496
+ const follow = () => {
497
+ if (barRef.current)
498
+ syncScroll(target, barRef.current);
499
+ };
500
+ target.addEventListener('scroll', follow, { passive: true });
501
+ return () => {
502
+ observer.disconnect();
503
+ target.removeEventListener('scroll', follow);
504
+ };
505
+ }, [enabled, targetRef, measureRef]);
506
+ useEffect(() => {
507
+ if (barRef.current && targetRef.current)
508
+ barRef.current.scrollLeft = targetRef.current.scrollLeft;
509
+ }, [width, targetRef]);
510
+ if (width == null)
511
+ return null;
512
+ return (_jsx("div", { ref: barRef, "aria-hidden": "true", tabIndex: -1, style: { overflowX: 'auto', overflowY: 'hidden', minHeight: 12 }, onScroll: () => {
513
+ if (targetRef.current && barRef.current)
514
+ syncScroll(barRef.current, targetRef.current);
515
+ }, children: _jsx("div", { style: { width, height: 1 } }) }));
472
516
  }
473
517
  const DRAG_MIME = 'application/x-efa-datatable-column';
474
518
  function HeaderCell({ columnId, label, dragColumnId, dropSide, hasCustomWidth, onDragStart, onDragEnd, onDragOverSide, onDrop, onResizePreview, onResizeCommit, onResizeCancel, children, }) {
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
2
  import { type DialogSize } from './Dialog.js';
3
3
  export type RecordDialogMode = 'read' | 'edit' | 'create';
4
- interface RecordDialogProps {
4
+ export interface RecordDialogProps {
5
5
  open: {
6
6
  id: string;
7
7
  item?: any;
@@ -20,6 +20,7 @@ interface RecordDialogProps {
20
20
  saveDisabled?: boolean;
21
21
  error?: string;
22
22
  onErrorDismiss?: () => void;
23
+ readOnly?: boolean;
23
24
  size?: DialogSize;
24
25
  labels?: {
25
26
  close?: string;
@@ -29,5 +30,4 @@ interface RecordDialogProps {
29
30
  edit?: string;
30
31
  };
31
32
  }
32
- export declare function RecordDialog({ open, onOpenChange, mode: controlledMode, onModeChange, title, readContent, editContent, onSave, saving, saveDisabled, error, onErrorDismiss, size, labels, }: RecordDialogProps): React.JSX.Element;
33
- export {};
33
+ export declare function RecordDialog({ open, onOpenChange, mode: controlledMode, onModeChange, title, readContent, editContent, onSave, saving, saveDisabled, error, onErrorDismiss, readOnly, size, labels, }: RecordDialogProps): React.JSX.Element;
@@ -4,8 +4,8 @@ import { Pencil } from 'lucide-react';
4
4
  import { Dialog, DialogClose } from './Dialog.js';
5
5
  import { Button } from './Button.js';
6
6
  import { Alert } from './Alert.js';
7
- export function RecordDialog({ open, onOpenChange, mode: controlledMode, onModeChange, title, readContent, editContent, onSave, saving, saveDisabled, error, onErrorDismiss, size, labels, }) {
8
- const [internalMode, setInternalMode] = useState('read');
7
+ export function RecordDialog({ open, onOpenChange, mode: controlledMode, onModeChange, title, readContent, editContent, onSave, saving, saveDisabled, error, onErrorDismiss, readOnly, size, labels, }) {
8
+ const [internalMode, setInternalMode] = useState(() => (open?.id === 'new' ? 'create' : 'read'));
9
9
  const mode = controlledMode ?? internalMode;
10
10
  useEffect(() => {
11
11
  if (!open)
@@ -24,7 +24,7 @@ export function RecordDialog({ open, onOpenChange, mode: controlledMode, onModeC
24
24
  onOpenChange(null);
25
25
  }
26
26
  async function handleSave() {
27
- if (mode === 'read')
27
+ if (mode === 'read' || (readOnly && mode === 'edit'))
28
28
  return;
29
29
  await onSave(mode);
30
30
  }
@@ -36,5 +36,5 @@ export function RecordDialog({ open, onOpenChange, mode: controlledMode, onModeC
36
36
  return (_jsx(Dialog, { open: open !== null, onOpenChange: (o) => { if (!o)
37
37
  handleClose(); }, title: title, size: size, banner: mode !== 'read' && error
38
38
  ? _jsx(Alert, { variant: "error", onDismiss: onErrorDismiss, children: error })
39
- : undefined, 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-text)] hover:bg-[var(--color-surface-raised)] transition-colors", title: editLabel, "aria-label": editLabel, children: _jsx(Pencil, { className: "w-4 h-4" }) }) }), readContent] })) : (_jsx("div", { className: "flex flex-col gap-4", children: editContent })) }));
39
+ : undefined, footer: mode === 'read' ? (_jsxs(_Fragment, { children: [_jsx(DialogClose, { asChild: true, children: _jsx(Button, { variant: "secondary", children: closeLabel }) }), !readOnly && (_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: [!readOnly && (_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-text)] hover:bg-[var(--color-surface-raised)] transition-colors", title: editLabel, "aria-label": editLabel, children: _jsx(Pencil, { className: "w-4 h-4" }) }) })), readContent] })) : (_jsx("div", { className: "flex flex-col gap-4", children: editContent })) }));
40
40
  }
@@ -0,0 +1,22 @@
1
+ import React from 'react';
2
+ export type SwitchTone = 'primary' | 'danger';
3
+ export interface SwitchControlProps {
4
+ id?: string;
5
+ checked: boolean;
6
+ onCheckedChange?: (checked: boolean) => void;
7
+ disabled?: boolean;
8
+ tone?: SwitchTone;
9
+ className?: string;
10
+ name?: string;
11
+ 'aria-label'?: string;
12
+ 'aria-labelledby'?: string;
13
+ 'aria-describedby'?: string;
14
+ }
15
+ export declare function SwitchControl({ id, checked, onCheckedChange, disabled, tone, className, name, ...aria }: SwitchControlProps): React.JSX.Element;
16
+ export interface SwitchProps extends Omit<SwitchControlProps, 'aria-label' | 'aria-labelledby' | 'aria-describedby' | 'className'> {
17
+ label: React.ReactNode;
18
+ description?: React.ReactNode;
19
+ title?: string;
20
+ className?: string;
21
+ }
22
+ export declare function Switch({ label, description, title, className, id, ...control }: SwitchProps): React.JSX.Element;
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId } from 'react';
3
+ export function SwitchControl({ id, checked, onCheckedChange, disabled = false, tone = 'primary', className = '', name, ...aria }) {
4
+ const button = (_jsx("button", { type: "button", role: "switch", id: id, "aria-checked": checked, "data-state": checked ? 'checked' : 'unchecked', disabled: disabled, onClick: () => onCheckedChange?.(!checked), className: `switch switch-${tone} ${className}`, ...aria, children: _jsx("span", { className: "switch-thumb", "aria-hidden": "true" }) }));
5
+ if (!name)
6
+ return button;
7
+ return (_jsxs(_Fragment, { children: [button, _jsx("input", { type: "hidden", name: name, value: checked ? 'true' : 'false', disabled: disabled })] }));
8
+ }
9
+ export function Switch({ label, description, title, className = '', id, ...control }) {
10
+ const autoId = useId();
11
+ const switchId = id ?? `switch-${autoId}`;
12
+ const descId = description ? `${switchId}-desc` : undefined;
13
+ return (_jsxs("div", { className: `flex items-start gap-3 ${className}`, title: title, children: [_jsx("span", { className: "pt-0.5 flex", children: _jsx(SwitchControl, { id: switchId, "aria-describedby": descId, ...control }) }), _jsxs("span", { className: "min-w-0", children: [_jsx("label", { htmlFor: switchId, className: `block text-sm text-[var(--color-text-primary)] ${control.disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`, children: label }), description && (_jsx("span", { id: descId, className: "block text-xs text-[var(--color-text-muted)] mt-0.5", children: description }))] })] }));
14
+ }
@@ -1,5 +1,6 @@
1
1
  export { Button } from './Button.js';
2
2
  export { Input } from './Input.js';
3
+ export { Switch, SwitchControl, type SwitchProps, type SwitchControlProps, type SwitchTone } from './Switch.js';
3
4
  export { Badge } from './Badge.js';
4
5
  export { Alert, type AlertVariant } from './Alert.js';
5
6
  export { Dialog, DialogClose, DEFAULT_DIALOG_SIZE, type DialogSize } from './Dialog.js';
@@ -7,7 +8,10 @@ export * as DropdownMenu from './DropdownMenu.js';
7
8
  export { Tooltip, TooltipProvider } from './Tooltip.js';
8
9
  export { EmptyState } from './EmptyState.js';
9
10
  export { Skeleton, SkeletonRow } from './Skeleton.js';
10
- export { RecordDialog, type RecordDialogMode } from './RecordDialog.js';
11
+ export { RecordDialog, type RecordDialogMode, type RecordDialogProps } from './RecordDialog.js';
12
+ export { ConfirmDeleteDialog, type ConfirmDeleteDialogProps } from './ConfirmDeleteDialog.js';
13
+ export { useRecords, type UseRecordsResult } from './useRecords.js';
14
+ export { createRecordsController, type RecordsController, type RecordsOptions, type RecordsState, type RecordsSaveMode, } from './records.js';
11
15
  export { useIsMobile, MOBILE_QUERY } from '../useIsMobile.js';
12
16
  export { DataTable, DEFAULT_VIEW_VERSION, type ColumnDef, type FilterDef, type DataTableSelectionProps, } from './DataTable.js';
13
17
  export { toCsv, toXlsx, downloadFile, type ExportCell, type ExportTable } from './tableExport.js';
@@ -1,5 +1,6 @@
1
1
  export { Button } from './Button.js';
2
2
  export { Input } from './Input.js';
3
+ export { Switch, SwitchControl } from './Switch.js';
3
4
  export { Badge } from './Badge.js';
4
5
  export { Alert } from './Alert.js';
5
6
  export { Dialog, DialogClose, DEFAULT_DIALOG_SIZE } from './Dialog.js';
@@ -8,6 +9,9 @@ export { Tooltip, TooltipProvider } from './Tooltip.js';
8
9
  export { EmptyState } from './EmptyState.js';
9
10
  export { Skeleton, SkeletonRow } from './Skeleton.js';
10
11
  export { RecordDialog } from './RecordDialog.js';
12
+ export { ConfirmDeleteDialog } from './ConfirmDeleteDialog.js';
13
+ export { useRecords } from './useRecords.js';
14
+ export { createRecordsController, } from './records.js';
11
15
  export { useIsMobile, MOBILE_QUERY } from '../useIsMobile.js';
12
16
  export { DataTable, DEFAULT_VIEW_VERSION, } from './DataTable.js';
13
17
  export { toCsv, toXlsx, downloadFile } from './tableExport.js';
@@ -0,0 +1,49 @@
1
+ export type RecordsSaveMode = 'create' | 'edit';
2
+ export interface RecordsOptions<T, F> {
3
+ endpoint: string;
4
+ apiBase: string | (() => string);
5
+ emptyForm: () => F;
6
+ toForm: (row: T) => F;
7
+ toPayload?: (form: F) => unknown;
8
+ required?: ReadonlyArray<keyof F>;
9
+ validate?: (form: F, mode: RecordsSaveMode) => string | null | undefined;
10
+ getId?: (row: T) => string;
11
+ updateMethod?: 'PUT' | 'PATCH';
12
+ fetchImpl?: typeof fetch;
13
+ }
14
+ export interface RecordsState<T, F> {
15
+ rows: T[];
16
+ loading: boolean;
17
+ loadError: string;
18
+ open: {
19
+ id: string;
20
+ item?: T;
21
+ } | null;
22
+ form: F;
23
+ saving: boolean;
24
+ saveError: string;
25
+ deleteTarget: T | null;
26
+ deleting: boolean;
27
+ deleteError: string;
28
+ }
29
+ export interface RecordsController<T, F> {
30
+ getState(): RecordsState<T, F>;
31
+ subscribe(listener: () => void): () => void;
32
+ setOptions(options: RecordsOptions<T, F>): void;
33
+ load(): Promise<void>;
34
+ dismissLoadError(): void;
35
+ openCreate(): void;
36
+ openRecord(row: T): void;
37
+ close(): void;
38
+ setForm(next: F | ((prev: F) => F)): void;
39
+ setField<K extends keyof F>(key: K, value: F[K]): void;
40
+ isComplete(): boolean;
41
+ save(mode: RecordsSaveMode): Promise<void>;
42
+ dismissSaveError(): void;
43
+ requestDelete(row: T): void;
44
+ cancelDelete(): void;
45
+ confirmDelete(): Promise<void>;
46
+ dismissDeleteError(): void;
47
+ }
48
+ export declare function requiredFilled<F>(form: F, required: ReadonlyArray<keyof F> | undefined): boolean;
49
+ export declare function createRecordsController<T, F>(initial: RecordsOptions<T, F>): RecordsController<T, F>;
@@ -0,0 +1,237 @@
1
+ const NEW_ID = 'new';
2
+ export function requiredFilled(form, required) {
3
+ return (required ?? []).every((k) => !isEmpty(form[k]));
4
+ }
5
+ function isEmpty(value) {
6
+ if (value === null || value === undefined)
7
+ return true;
8
+ if (typeof value === 'string')
9
+ return value.trim() === '';
10
+ if (Array.isArray(value))
11
+ return value.length === 0;
12
+ return false;
13
+ }
14
+ async function errorMessage(res, fallback) {
15
+ try {
16
+ const body = (await res.json());
17
+ if (typeof body?.error === 'string' && body.error.trim())
18
+ return body.error;
19
+ if (typeof body?.message === 'string' && body.message.trim())
20
+ return body.message;
21
+ }
22
+ catch {
23
+ }
24
+ return `${fallback} (HTTP ${res.status})`;
25
+ }
26
+ export function createRecordsController(initial) {
27
+ let options = initial;
28
+ let state = {
29
+ rows: [],
30
+ loading: true,
31
+ loadError: '',
32
+ open: null,
33
+ form: initial.emptyForm(),
34
+ saving: false,
35
+ saveError: '',
36
+ deleteTarget: null,
37
+ deleting: false,
38
+ deleteError: '',
39
+ };
40
+ const listeners = new Set();
41
+ let loadSeq = 0;
42
+ let loadedOnce = false;
43
+ function set(patch) {
44
+ state = { ...state, ...patch };
45
+ for (const l of listeners)
46
+ l();
47
+ }
48
+ function base() {
49
+ const b = options.apiBase;
50
+ return typeof b === 'function' ? b() : (b ?? '');
51
+ }
52
+ function tryId(row) {
53
+ const id = options.getId ? options.getId(row) : row.id;
54
+ return id === undefined || id === null || String(id) === '' ? null : String(id);
55
+ }
56
+ const NO_ID = 'Eintrag ohne ID: Die Liste liefert kein Feld „id“ (in useRecords getId angeben).';
57
+ function url(id) {
58
+ const root = `${base()}${options.endpoint}`;
59
+ return id === undefined ? root : `${root.replace(/\/$/, '')}/${encodeURIComponent(id)}`;
60
+ }
61
+ function request(target, method, body) {
62
+ const f = options.fetchImpl ?? fetch;
63
+ return f(target, {
64
+ method,
65
+ credentials: 'include',
66
+ headers: body === undefined ? { Accept: 'application/json' } : { 'Content-Type': 'application/json', Accept: 'application/json' },
67
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
68
+ });
69
+ }
70
+ function isComplete() {
71
+ return requiredFilled(state.form, options.required);
72
+ }
73
+ async function load() {
74
+ const seq = ++loadSeq;
75
+ set({ loading: !loadedOnce, loadError: '' });
76
+ try {
77
+ const res = await request(url(), 'GET');
78
+ if (!res.ok) {
79
+ const msg = await errorMessage(res, 'Liste konnte nicht geladen werden');
80
+ if (seq === loadSeq)
81
+ set({ loading: false, loadError: msg });
82
+ return;
83
+ }
84
+ let data;
85
+ try {
86
+ data = await res.json();
87
+ }
88
+ catch {
89
+ if (seq === loadSeq)
90
+ set({ loading: false, loadError: 'Liste konnte nicht geladen werden (Antwort ist kein JSON, stimmen apiBase und endpoint?)' });
91
+ return;
92
+ }
93
+ if (seq !== loadSeq)
94
+ return;
95
+ if (!Array.isArray(data)) {
96
+ set({ loading: false, loadError: 'Liste konnte nicht geladen werden (Antwort ist keine Liste)' });
97
+ return;
98
+ }
99
+ loadedOnce = true;
100
+ set({ rows: data, loading: false });
101
+ }
102
+ catch {
103
+ if (seq === loadSeq)
104
+ set({ loading: false, loadError: 'Server nicht erreichbar' });
105
+ }
106
+ }
107
+ async function save(mode) {
108
+ if (state.saving)
109
+ return;
110
+ const open = state.open;
111
+ if (!open)
112
+ return;
113
+ if (!isComplete()) {
114
+ set({ saveError: 'Bitte alle Pflichtfelder ausfüllen.' });
115
+ return;
116
+ }
117
+ const invalid = options.validate?.(state.form, mode);
118
+ if (invalid) {
119
+ set({ saveError: invalid });
120
+ return;
121
+ }
122
+ let target;
123
+ let method;
124
+ if (mode === 'create') {
125
+ target = url();
126
+ method = 'POST';
127
+ }
128
+ else {
129
+ const id = open.item ? tryId(open.item) : null;
130
+ if (id === null) {
131
+ set({ saveError: open.item ? NO_ID : 'Kein Eintrag zum Speichern geöffnet.' });
132
+ return;
133
+ }
134
+ target = url(id);
135
+ method = options.updateMethod ?? 'PUT';
136
+ }
137
+ const payload = options.toPayload ? options.toPayload(state.form) : state.form;
138
+ set({ saving: true, saveError: '' });
139
+ try {
140
+ const res = await request(target, method, payload);
141
+ if (!res.ok) {
142
+ set({ saving: false, saveError: await errorMessage(res, 'Speichern fehlgeschlagen') });
143
+ return;
144
+ }
145
+ }
146
+ catch {
147
+ set({ saving: false, saveError: 'Server nicht erreichbar' });
148
+ return;
149
+ }
150
+ if (state.open === open)
151
+ set({ saving: false, open: null, form: options.emptyForm() });
152
+ else
153
+ set({ saving: false });
154
+ await load();
155
+ }
156
+ async function confirmDelete() {
157
+ const row = state.deleteTarget;
158
+ if (!row || state.deleting)
159
+ return;
160
+ const id = tryId(row);
161
+ if (id === null) {
162
+ set({ deleteError: NO_ID });
163
+ return;
164
+ }
165
+ set({ deleting: true, deleteError: '' });
166
+ try {
167
+ const res = await request(url(id), 'DELETE');
168
+ if (!res.ok) {
169
+ set({ deleting: false, deleteError: await errorMessage(res, 'Löschen fehlgeschlagen') });
170
+ return;
171
+ }
172
+ }
173
+ catch {
174
+ set({ deleting: false, deleteError: 'Server nicht erreichbar' });
175
+ return;
176
+ }
177
+ const openItem = state.open?.item;
178
+ const closeDialog = openItem !== undefined && tryId(openItem) === id;
179
+ set({ deleting: false, deleteTarget: null, ...(closeDialog ? { open: null } : {}) });
180
+ await load();
181
+ }
182
+ return {
183
+ getState: () => state,
184
+ subscribe(listener) {
185
+ listeners.add(listener);
186
+ return () => {
187
+ listeners.delete(listener);
188
+ };
189
+ },
190
+ setOptions(next) {
191
+ options = next;
192
+ },
193
+ load,
194
+ dismissLoadError() {
195
+ set({ loadError: '' });
196
+ },
197
+ openCreate() {
198
+ set({ open: { id: NEW_ID }, form: options.emptyForm(), saveError: '' });
199
+ },
200
+ openRecord(row) {
201
+ const id = tryId(row);
202
+ if (id === null) {
203
+ console.error('useRecords: Eintrag ohne ID (getId angeben)', row);
204
+ set({ loadError: NO_ID });
205
+ return;
206
+ }
207
+ set({ open: { id, item: row }, form: options.toForm(row), saveError: '' });
208
+ },
209
+ close() {
210
+ set({ open: null, saveError: '' });
211
+ },
212
+ setForm(next) {
213
+ const form = typeof next === 'function' ? next(state.form) : next;
214
+ set({ form });
215
+ },
216
+ setField(key, value) {
217
+ set({ form: { ...state.form, [key]: value } });
218
+ },
219
+ isComplete,
220
+ save,
221
+ dismissSaveError() {
222
+ set({ saveError: '' });
223
+ },
224
+ requestDelete(row) {
225
+ set({ deleteTarget: row, deleteError: '' });
226
+ },
227
+ cancelDelete() {
228
+ if (state.deleting)
229
+ return;
230
+ set({ deleteTarget: null, deleteError: '' });
231
+ },
232
+ confirmDelete,
233
+ dismissDeleteError() {
234
+ set({ deleteError: '' });
235
+ },
236
+ };
237
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @efa-one/sdk/frontend/ui/styles.css
3
3
  *
4
- * Globale Hilfsklassen, die `Badge` und `Skeleton` aus dem SDK-ui-Kit benötigen
4
+ * Globale Hilfsklassen, die `Badge`, `Skeleton`, `Alert` und `Switch` aus dem SDK-ui-Kit benötigen
5
5
  * (sie sind CSS-Klassen-basiert, nicht Tailwind-Arbitrary). Einmal pro App
6
6
  * importieren: import '@efa-one/sdk/frontend/ui/styles.css';
7
7
  *
@@ -110,3 +110,62 @@
110
110
  from { background-position: 200% 0; }
111
111
  to { background-position: -200% 0; }
112
112
  }
113
+
114
+ /* ── Switch (Schalter für Ein/Aus-Werte) ─────────────────────────
115
+ * Spur 36 × 20 px, Knopf 16 px. Aus-Zustand bewusst dunkler als
116
+ * `--color-border`: auf weißer Fläche wäre eine reine Rahmenfarbe kaum zu
117
+ * erkennen (WCAG 1.4.11 verlangt 3 : 1 für Bedienelemente). Die Mischung
118
+ * Richtung `--color-text-primary` wird im dunklen Theme automatisch heller.
119
+ * Gegen alle Built-in-Themes nachgerechnet (Spur auf surface/surface-raised,
120
+ * Knopf auf Spur): test/switch.test.ts. An-Zustand: Signalfarbe des Tons
121
+ * (`primary` bzw. `danger`) mit weißem Knopf — dieselbe Paarung wie der
122
+ * Primär-Button; der Zustand ist zusätzlich an der Knopf-Position erkennbar. */
123
+ .switch {
124
+ position: relative;
125
+ display: inline-flex;
126
+ flex-shrink: 0;
127
+ align-items: center;
128
+ width: 36px;
129
+ height: 20px;
130
+ padding: 0;
131
+ border: 2px solid transparent;
132
+ border-radius: 9999px;
133
+ background: color-mix(in srgb, var(--color-text-primary) 45%, var(--color-border));
134
+ cursor: pointer;
135
+ transition: background-color 150ms ease;
136
+ }
137
+ .switch-primary[data-state='checked'] { background: var(--color-primary); }
138
+ .switch-danger[data-state='checked'] { background: var(--color-danger); }
139
+ .switch:disabled {
140
+ opacity: 0.5;
141
+ cursor: not-allowed;
142
+ }
143
+ .switch:focus-visible {
144
+ outline: none;
145
+ box-shadow: 0 0 0 2px var(--color-surface), 0 0 0 4px var(--color-primary);
146
+ }
147
+ .switch-thumb {
148
+ display: block;
149
+ width: 16px;
150
+ height: 16px;
151
+ border-radius: 9999px;
152
+ background: #fff;
153
+ box-shadow: 0 1px 2px rgb(0 0 0 / 0.25);
154
+ transform: translateX(0);
155
+ transition: transform 150ms ease;
156
+ pointer-events: none;
157
+ }
158
+ .switch[data-state='checked'] .switch-thumb { transform: translateX(16px); }
159
+ @media (prefers-reduced-motion: reduce) {
160
+ .switch, .switch-thumb { transition: none; }
161
+ }
162
+ /* Windows-Kontrastmodus ersetzt Hintergrundfarben durch Systemfarben — ohne
163
+ * diesen Block wären Spur und Knopf gleich und der Zustand unsichtbar. */
164
+ @media (forced-colors: active) {
165
+ .switch { border-color: ButtonText; background: Canvas; }
166
+ .switch-thumb { background: ButtonText; box-shadow: none; }
167
+ .switch[data-state='checked'] { background: Highlight; }
168
+ .switch[data-state='checked'] .switch-thumb { background: HighlightText; }
169
+ .switch:disabled { border-color: GrayText; }
170
+ .switch:disabled .switch-thumb { background: GrayText; }
171
+ }
@@ -0,0 +1,22 @@
1
+ import type { RecordDialogProps } from './RecordDialog.js';
2
+ import type { ConfirmDeleteDialogProps } from './ConfirmDeleteDialog.js';
3
+ import { type RecordsOptions } from './records.js';
4
+ export interface UseRecordsResult<T, F> {
5
+ rows: T[];
6
+ loading: boolean;
7
+ loadError: string;
8
+ dismissLoadError(): void;
9
+ reload(): Promise<void>;
10
+ form: F;
11
+ setForm(next: F | ((prev: F) => F)): void;
12
+ setField<K extends keyof F>(key: K, value: F[K]): void;
13
+ current: T | null;
14
+ openCreate(): void;
15
+ openRecord(row: T): void;
16
+ close(): void;
17
+ dialogProps: Pick<RecordDialogProps, 'open' | 'onOpenChange' | 'onSave' | 'saving' | 'saveDisabled' | 'error' | 'onErrorDismiss'>;
18
+ requestDelete(row: T): void;
19
+ deleteTarget: T | null;
20
+ deleteDialogProps: Pick<ConfirmDeleteDialogProps, 'open' | 'deleting' | 'error' | 'onConfirm' | 'onCancel' | 'onErrorDismiss'>;
21
+ }
22
+ export declare function useRecords<T, F>(options: RecordsOptions<T, F>): UseRecordsResult<T, F>;
@@ -0,0 +1,49 @@
1
+ import { useEffect, useMemo, useState, useSyncExternalStore } from 'react';
2
+ import { createRecordsController, requiredFilled, } from './records.js';
3
+ export function useRecords(options) {
4
+ const [controller] = useState(() => createRecordsController(options));
5
+ useEffect(() => {
6
+ controller.setOptions(options);
7
+ });
8
+ useEffect(() => {
9
+ void controller.load();
10
+ }, [controller, options.endpoint]);
11
+ const state = useSyncExternalStore(controller.subscribe, controller.getState, controller.getState);
12
+ const complete = requiredFilled(state.form, options.required);
13
+ return useMemo(() => ({
14
+ rows: state.rows,
15
+ loading: state.loading,
16
+ loadError: state.loadError,
17
+ dismissLoadError: controller.dismissLoadError,
18
+ reload: controller.load,
19
+ form: state.form,
20
+ setForm: controller.setForm,
21
+ setField: controller.setField,
22
+ current: state.open?.item ?? null,
23
+ openCreate: controller.openCreate,
24
+ openRecord: controller.openRecord,
25
+ close: controller.close,
26
+ dialogProps: {
27
+ open: state.open,
28
+ onOpenChange: (next) => {
29
+ if (!next)
30
+ controller.close();
31
+ },
32
+ onSave: controller.save,
33
+ saving: state.saving,
34
+ saveDisabled: !complete,
35
+ error: state.saveError,
36
+ onErrorDismiss: controller.dismissSaveError,
37
+ },
38
+ requestDelete: controller.requestDelete,
39
+ deleteTarget: state.deleteTarget,
40
+ deleteDialogProps: {
41
+ open: state.deleteTarget !== null,
42
+ deleting: state.deleting,
43
+ error: state.deleteError,
44
+ onConfirm: controller.confirmDelete,
45
+ onCancel: controller.cancelDelete,
46
+ onErrorDismiss: controller.dismissDeleteError,
47
+ },
48
+ }), [controller, state, complete]);
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@efa-one/sdk",
3
- "version": "1.24.0",
3
+ "version": "1.26.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",