@efa-one/sdk 1.25.0 → 1.27.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
 
@@ -281,6 +281,29 @@ Excel export for such data. No backend or kernel change.
281
281
  scrollbar sits above the table and scrolls in sync with the bottom one. Not on
282
282
  mobile. The border moved to an outer wrapper.
283
283
 
284
+ **1.27.0 — `useRecords`: edit first, read-only without permission.** No kernel or backend change.
285
+ - `editable` (default `true`): `false` opens records read-only, no pencil, no PUT
286
+ (`editable: can(user, 'write')`).
287
+ - `openIn: 'edit'`: a click on a record opens the form directly (with `editable`); default
288
+ `'read'` keeps read → pencil → edit.
289
+ - `dialogProps` now carries `mode`, `onModeChange` and `readOnly`; do not pass them yourself.
290
+
291
+ **1.26.0 — `useRecords`, `ConfirmDeleteDialog`, `Switch`.** No kernel or backend change.
292
+ - `useRecords({ endpoint, apiBase, emptyForm, toForm, toPayload?, required?, validate? })`
293
+ runs the whole records flow: load the list, open for create (`openCreate`) or read
294
+ (`openRecord`), save (`POST <endpoint>` or `PUT <endpoint>/:id`, `updateMethod: 'PATCH'`
295
+ optional), delete after confirmation (`DELETE <endpoint>/:id`), reload after each write,
296
+ server messages `{ "error" }` as banner. Spread `dialogProps` into `RecordDialog` and
297
+ `deleteDialogProps` into `ConfirmDeleteDialog`. The logic itself is
298
+ `createRecordsController` (no React, for tests or other frameworks).
299
+ - Use it instead of hand-written `save()` functions: one found in a generated app returned
300
+ early for `{ id: 'new' }`, so „Erstellen" never sent anything and no type check noticed.
301
+ - `ConfirmDeleteDialog`: delete confirmation, `size="sm"`, error banner.
302
+ - `RecordDialog` takes `readOnly` (no pencil, no „Bearbeiten“, e.g. without write
303
+ permission); `RecordDialogProps` is exported.
304
+ - `Switch`/`SwitchControl` for on/off values (checkboxes only for row selection and
305
+ confirmations).
306
+
284
307
  **Next minor (planned 1.23.0, not yet released) — exchange, cookie and S2S hardening.**
285
308
  Needs a kernel with efaware/efa_kernel#106 and #107 first (gateway exchange token
286
309
  `gw: 'app'`, provenance `jti`, cookie filter). Then:
@@ -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
+ }
@@ -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, type RecordsDialogMode, } 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,54 @@
1
+ export type RecordsSaveMode = 'create' | 'edit';
2
+ export type RecordsDialogMode = 'read' | 'edit' | 'create';
3
+ export interface RecordsOptions<T, F> {
4
+ endpoint: string;
5
+ apiBase: string | (() => string);
6
+ emptyForm: () => F;
7
+ toForm: (row: T) => F;
8
+ toPayload?: (form: F) => unknown;
9
+ required?: ReadonlyArray<keyof F>;
10
+ validate?: (form: F, mode: RecordsSaveMode) => string | null | undefined;
11
+ getId?: (row: T) => string;
12
+ editable?: boolean;
13
+ openIn?: 'read' | 'edit';
14
+ updateMethod?: 'PUT' | 'PATCH';
15
+ fetchImpl?: typeof fetch;
16
+ }
17
+ export interface RecordsState<T, F> {
18
+ rows: T[];
19
+ loading: boolean;
20
+ loadError: string;
21
+ open: {
22
+ id: string;
23
+ item?: T;
24
+ } | null;
25
+ mode: RecordsDialogMode;
26
+ form: F;
27
+ saving: boolean;
28
+ saveError: string;
29
+ deleteTarget: T | null;
30
+ deleting: boolean;
31
+ deleteError: string;
32
+ }
33
+ export interface RecordsController<T, F> {
34
+ getState(): RecordsState<T, F>;
35
+ subscribe(listener: () => void): () => void;
36
+ setOptions(options: RecordsOptions<T, F>): void;
37
+ load(): Promise<void>;
38
+ dismissLoadError(): void;
39
+ openCreate(): void;
40
+ openRecord(row: T): void;
41
+ close(): void;
42
+ setMode(mode: RecordsDialogMode): void;
43
+ setForm(next: F | ((prev: F) => F)): void;
44
+ setField<K extends keyof F>(key: K, value: F[K]): void;
45
+ isComplete(): boolean;
46
+ save(mode: RecordsSaveMode): Promise<void>;
47
+ dismissSaveError(): void;
48
+ requestDelete(row: T): void;
49
+ cancelDelete(): void;
50
+ confirmDelete(): Promise<void>;
51
+ dismissDeleteError(): void;
52
+ }
53
+ export declare function requiredFilled<F>(form: F, required: ReadonlyArray<keyof F> | undefined): boolean;
54
+ export declare function createRecordsController<T, F>(initial: RecordsOptions<T, F>): RecordsController<T, F>;
@@ -0,0 +1,248 @@
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
+ mode: 'read',
34
+ form: initial.emptyForm(),
35
+ saving: false,
36
+ saveError: '',
37
+ deleteTarget: null,
38
+ deleting: false,
39
+ deleteError: '',
40
+ };
41
+ const listeners = new Set();
42
+ let loadSeq = 0;
43
+ let loadedOnce = false;
44
+ function set(patch) {
45
+ state = { ...state, ...patch };
46
+ for (const l of listeners)
47
+ l();
48
+ }
49
+ function base() {
50
+ const b = options.apiBase;
51
+ return typeof b === 'function' ? b() : (b ?? '');
52
+ }
53
+ function tryId(row) {
54
+ const id = options.getId ? options.getId(row) : row.id;
55
+ return id === undefined || id === null || String(id) === '' ? null : String(id);
56
+ }
57
+ const NO_ID = 'Eintrag ohne ID: Die Liste liefert kein Feld „id“ (in useRecords getId angeben).';
58
+ function url(id) {
59
+ const root = `${base()}${options.endpoint}`;
60
+ return id === undefined ? root : `${root.replace(/\/$/, '')}/${encodeURIComponent(id)}`;
61
+ }
62
+ function request(target, method, body) {
63
+ const f = options.fetchImpl ?? fetch;
64
+ return f(target, {
65
+ method,
66
+ credentials: 'include',
67
+ headers: body === undefined ? { Accept: 'application/json' } : { 'Content-Type': 'application/json', Accept: 'application/json' },
68
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
69
+ });
70
+ }
71
+ function isComplete() {
72
+ return requiredFilled(state.form, options.required);
73
+ }
74
+ async function load() {
75
+ const seq = ++loadSeq;
76
+ set({ loading: !loadedOnce, loadError: '' });
77
+ try {
78
+ const res = await request(url(), 'GET');
79
+ if (!res.ok) {
80
+ const msg = await errorMessage(res, 'Liste konnte nicht geladen werden');
81
+ if (seq === loadSeq)
82
+ set({ loading: false, loadError: msg });
83
+ return;
84
+ }
85
+ let data;
86
+ try {
87
+ data = await res.json();
88
+ }
89
+ catch {
90
+ if (seq === loadSeq)
91
+ set({ loading: false, loadError: 'Liste konnte nicht geladen werden (Antwort ist kein JSON, stimmen apiBase und endpoint?)' });
92
+ return;
93
+ }
94
+ if (seq !== loadSeq)
95
+ return;
96
+ if (!Array.isArray(data)) {
97
+ set({ loading: false, loadError: 'Liste konnte nicht geladen werden (Antwort ist keine Liste)' });
98
+ return;
99
+ }
100
+ loadedOnce = true;
101
+ set({ rows: data, loading: false });
102
+ }
103
+ catch {
104
+ if (seq === loadSeq)
105
+ set({ loading: false, loadError: 'Server nicht erreichbar' });
106
+ }
107
+ }
108
+ async function save(mode) {
109
+ if (state.saving)
110
+ return;
111
+ const open = state.open;
112
+ if (!open)
113
+ return;
114
+ if (mode === 'edit' && options.editable === false) {
115
+ set({ saveError: 'Keine Berechtigung zum Ändern.' });
116
+ return;
117
+ }
118
+ if (!isComplete()) {
119
+ set({ saveError: 'Bitte alle Pflichtfelder ausfüllen.' });
120
+ return;
121
+ }
122
+ const invalid = options.validate?.(state.form, mode);
123
+ if (invalid) {
124
+ set({ saveError: invalid });
125
+ return;
126
+ }
127
+ let target;
128
+ let method;
129
+ if (mode === 'create') {
130
+ target = url();
131
+ method = 'POST';
132
+ }
133
+ else {
134
+ const id = open.item ? tryId(open.item) : null;
135
+ if (id === null) {
136
+ set({ saveError: open.item ? NO_ID : 'Kein Eintrag zum Speichern geöffnet.' });
137
+ return;
138
+ }
139
+ target = url(id);
140
+ method = options.updateMethod ?? 'PUT';
141
+ }
142
+ const payload = options.toPayload ? options.toPayload(state.form) : state.form;
143
+ set({ saving: true, saveError: '' });
144
+ try {
145
+ const res = await request(target, method, payload);
146
+ if (!res.ok) {
147
+ set({ saving: false, saveError: await errorMessage(res, 'Speichern fehlgeschlagen') });
148
+ return;
149
+ }
150
+ }
151
+ catch {
152
+ set({ saving: false, saveError: 'Server nicht erreichbar' });
153
+ return;
154
+ }
155
+ if (state.open === open)
156
+ set({ saving: false, open: null, form: options.emptyForm() });
157
+ else
158
+ set({ saving: false });
159
+ await load();
160
+ }
161
+ async function confirmDelete() {
162
+ const row = state.deleteTarget;
163
+ if (!row || state.deleting)
164
+ return;
165
+ const id = tryId(row);
166
+ if (id === null) {
167
+ set({ deleteError: NO_ID });
168
+ return;
169
+ }
170
+ set({ deleting: true, deleteError: '' });
171
+ try {
172
+ const res = await request(url(id), 'DELETE');
173
+ if (!res.ok) {
174
+ set({ deleting: false, deleteError: await errorMessage(res, 'Löschen fehlgeschlagen') });
175
+ return;
176
+ }
177
+ }
178
+ catch {
179
+ set({ deleting: false, deleteError: 'Server nicht erreichbar' });
180
+ return;
181
+ }
182
+ const openItem = state.open?.item;
183
+ const closeDialog = openItem !== undefined && tryId(openItem) === id;
184
+ set({ deleting: false, deleteTarget: null, ...(closeDialog ? { open: null } : {}) });
185
+ await load();
186
+ }
187
+ return {
188
+ getState: () => state,
189
+ subscribe(listener) {
190
+ listeners.add(listener);
191
+ return () => {
192
+ listeners.delete(listener);
193
+ };
194
+ },
195
+ setOptions(next) {
196
+ options = next;
197
+ },
198
+ load,
199
+ dismissLoadError() {
200
+ set({ loadError: '' });
201
+ },
202
+ openCreate() {
203
+ set({ open: { id: NEW_ID }, mode: 'create', form: options.emptyForm(), saveError: '' });
204
+ },
205
+ openRecord(row) {
206
+ const id = tryId(row);
207
+ if (id === null) {
208
+ console.error('useRecords: Eintrag ohne ID (getId angeben)', row);
209
+ set({ loadError: NO_ID });
210
+ return;
211
+ }
212
+ const mode = options.openIn === 'edit' && options.editable !== false ? 'edit' : 'read';
213
+ set({ open: { id, item: row }, mode, form: options.toForm(row), saveError: '' });
214
+ },
215
+ close() {
216
+ set({ open: null, saveError: '' });
217
+ },
218
+ setMode(mode) {
219
+ if (mode !== 'edit' || options.editable === false || !state.open?.item || state.mode === 'edit')
220
+ return;
221
+ set({ mode: 'edit' });
222
+ },
223
+ setForm(next) {
224
+ const form = typeof next === 'function' ? next(state.form) : next;
225
+ set({ form });
226
+ },
227
+ setField(key, value) {
228
+ set({ form: { ...state.form, [key]: value } });
229
+ },
230
+ isComplete,
231
+ save,
232
+ dismissSaveError() {
233
+ set({ saveError: '' });
234
+ },
235
+ requestDelete(row) {
236
+ set({ deleteTarget: row, deleteError: '' });
237
+ },
238
+ cancelDelete() {
239
+ if (state.deleting)
240
+ return;
241
+ set({ deleteTarget: null, deleteError: '' });
242
+ },
243
+ confirmDelete,
244
+ dismissDeleteError() {
245
+ set({ deleteError: '' });
246
+ },
247
+ };
248
+ }
@@ -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' | 'mode' | 'onModeChange' | 'readOnly' | '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,53 @@
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
+ const readOnly = options.editable === false;
14
+ return useMemo(() => ({
15
+ rows: state.rows,
16
+ loading: state.loading,
17
+ loadError: state.loadError,
18
+ dismissLoadError: controller.dismissLoadError,
19
+ reload: controller.load,
20
+ form: state.form,
21
+ setForm: controller.setForm,
22
+ setField: controller.setField,
23
+ current: state.open?.item ?? null,
24
+ openCreate: controller.openCreate,
25
+ openRecord: controller.openRecord,
26
+ close: controller.close,
27
+ dialogProps: {
28
+ open: state.open,
29
+ onOpenChange: (next) => {
30
+ if (!next)
31
+ controller.close();
32
+ },
33
+ mode: readOnly && state.mode === 'edit' ? 'read' : state.mode,
34
+ onModeChange: controller.setMode,
35
+ readOnly,
36
+ onSave: controller.save,
37
+ saving: state.saving,
38
+ saveDisabled: !complete,
39
+ error: state.saveError,
40
+ onErrorDismiss: controller.dismissSaveError,
41
+ },
42
+ requestDelete: controller.requestDelete,
43
+ deleteTarget: state.deleteTarget,
44
+ deleteDialogProps: {
45
+ open: state.deleteTarget !== null,
46
+ deleting: state.deleting,
47
+ error: state.deleteError,
48
+ onConfirm: controller.confirmDelete,
49
+ onCancel: controller.cancelDelete,
50
+ onErrorDismiss: controller.dismissDeleteError,
51
+ },
52
+ }), [controller, state, complete, readOnly]);
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@efa-one/sdk",
3
- "version": "1.25.0",
3
+ "version": "1.27.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",