@efa-one/sdk 1.10.2 → 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 +73 -70
- package/frontend/ipc.d.ts +15 -0
- package/frontend/ipc.js +22 -0
- package/frontend/ui/Alert.d.ts +10 -0
- package/frontend/ui/Alert.js +11 -0
- package/frontend/ui/Badge.d.ts +9 -0
- package/frontend/ui/Badge.js +4 -0
- package/frontend/ui/Button.d.ts +10 -0
- package/frontend/ui/Button.js +15 -0
- package/frontend/ui/DataTable.d.ts +72 -0
- package/frontend/ui/DataTable.js +321 -0
- package/frontend/ui/Dialog.d.ts +14 -0
- package/frontend/ui/Dialog.js +7 -0
- package/frontend/ui/DropdownMenu.d.ts +13 -0
- package/frontend/ui/DropdownMenu.js +16 -0
- package/frontend/ui/EmptyState.d.ts +10 -0
- package/frontend/ui/EmptyState.js +4 -0
- package/frontend/ui/Input.d.ts +5 -0
- package/frontend/ui/Input.js +4 -0
- package/frontend/ui/RecordDialog.d.ts +31 -0
- package/frontend/ui/RecordDialog.js +38 -0
- package/frontend/ui/Skeleton.d.ts +9 -0
- package/frontend/ui/Skeleton.js +7 -0
- package/frontend/ui/Tooltip.d.ts +12 -0
- package/frontend/ui/Tooltip.js +8 -0
- package/frontend/ui/index.d.ts +11 -0
- package/frontend/ui/index.js +11 -0
- package/frontend/ui/styles.css +53 -0
- package/frontend/viewPreferences.d.ts +11 -0
- package/frontend/viewPreferences.js +79 -0
- package/package.json +34 -2
package/README.md
CHANGED
|
@@ -1,43 +1,49 @@
|
|
|
1
1
|
# @efa-one/sdk
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
Official SDK for building **efa-one apps**. It is the integration layer every app
|
|
4
|
+
embeds to become a first-class citizen of the platform: authentication against the
|
|
5
|
+
kernel, clients for the platform services, and the frontend IPC / i18n toolkit for
|
|
6
|
+
iframe embedding.
|
|
7
7
|
|
|
8
|
-
##
|
|
8
|
+
## Two entry points
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
The SDK is a mix of server- and browser-side code, so it is split into two
|
|
11
|
+
sub-paths — this keeps `express` out of the frontend and `react` out of the backend:
|
|
12
12
|
|
|
13
|
-
| Import |
|
|
13
|
+
| Import | Runs in | Contains |
|
|
14
14
|
|---|---|---|
|
|
15
|
-
| `@efa-one/sdk/backend` | Node/Express | Auth +
|
|
16
|
-
| `@efa-one/sdk/frontend` | Browser/React | postMessage
|
|
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 (`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
|
-
|
|
19
|
-
`@efa-one/sdk/backend/auth`
|
|
20
|
+
Every individual module is also reachable directly, e.g.
|
|
21
|
+
`@efa-one/sdk/backend/auth` or `@efa-one/sdk/frontend/ipc`.
|
|
20
22
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
>
|
|
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
|
+
|
|
27
|
+
> **Note on legacy prefixes:** Some platform-internal identifiers (postMessage
|
|
28
|
+
> message types, environment variable names, JWT `iss`) still carry technical
|
|
29
|
+
> legacy prefixes from an earlier naming. These are part of the wire protocol
|
|
30
|
+
> between app and kernel and are migrated together with the kernel in one
|
|
31
|
+
> coordinated step — not unilaterally in the SDK.
|
|
26
32
|
|
|
27
33
|
## Installation
|
|
28
34
|
|
|
29
35
|
```bash
|
|
30
|
-
#
|
|
36
|
+
# in the app's backend project
|
|
31
37
|
npm install @efa-one/sdk express pg jsonwebtoken
|
|
32
38
|
|
|
33
|
-
#
|
|
39
|
+
# in the app's frontend project
|
|
34
40
|
npm install @efa-one/sdk react i18next i18next-http-backend react-i18next
|
|
35
41
|
```
|
|
36
42
|
|
|
37
|
-
|
|
38
|
-
|
|
43
|
+
The runtime libraries are **optional peer dependencies** — install only the ones
|
|
44
|
+
matching the entry point you use. The SDK itself does not bundle them.
|
|
39
45
|
|
|
40
|
-
##
|
|
46
|
+
## Usage
|
|
41
47
|
|
|
42
48
|
```ts
|
|
43
49
|
// Backend
|
|
@@ -49,71 +55,68 @@ app.get('/api/items', requireAuth, handler);
|
|
|
49
55
|
|
|
50
56
|
```tsx
|
|
51
57
|
// Frontend
|
|
52
|
-
import { initI18n,
|
|
58
|
+
import { initI18n, registerAppInfo, DevHeader } from '@efa-one/sdk/frontend';
|
|
53
59
|
|
|
54
|
-
|
|
60
|
+
registerAppInfo({ appName: 'efa-chat', version: __APP_VERSION__ });
|
|
55
61
|
```
|
|
56
62
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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} />
|
|
65
76
|
```
|
|
66
77
|
|
|
67
|
-
|
|
68
|
-
- `frontend/` — ESM (`module: esnext`, `moduleResolution: bundler`), via
|
|
69
|
-
`frontend/package.json` als `{"type":"module"}` markiert; App-Bundler (Vite)
|
|
70
|
-
konsumieren es direkt.
|
|
71
|
-
|
|
72
|
-
Das flache Output-Layout (`backend/` + `frontend/` im Paket-Root) ist bewusst so
|
|
73
|
-
gewählt, damit Consumer mit `moduleResolution: node` (node10) die Subpath-Importe
|
|
74
|
-
physisch auflösen — ohne dass sie ihre tsconfig anpassen müssen.
|
|
75
|
-
|
|
76
|
-
## Publish
|
|
77
|
-
|
|
78
|
-
Public auf npmjs unter dem Scope `@efa-one` (`publishConfig.access = "public"` ist
|
|
79
|
-
gesetzt).
|
|
78
|
+
## Build your first app
|
|
80
79
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
This repo ships a ready-to-copy app scaffold under [`template/`](./template) — the fastest
|
|
81
|
+
path to a working efa-app. It already wires up auth, health, service discovery,
|
|
82
|
+
audit/reporting, IPC and i18n against this SDK, so you write business logic only. No extra
|
|
83
|
+
tooling (and no Claude Code) required — clone the repo, then:
|
|
85
84
|
|
|
86
85
|
```bash
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
cp -R template efa-myapp
|
|
87
|
+
cd efa-myapp && rm -rf .git && git init
|
|
88
|
+
npm --prefix backend install && npm --prefix frontend install
|
|
89
|
+
docker compose up -d --build
|
|
89
90
|
```
|
|
90
91
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
See [`template/README.md`](./template/README.md) for the full walkthrough — stack variants
|
|
93
|
+
(3-container/PostgreSQL vs. single-container/SQLite), the intake questions, permission
|
|
94
|
+
registration, and the README chapters every app must keep.
|
|
94
95
|
|
|
95
|
-
|
|
96
|
+
## Build the SDK itself
|
|
97
|
+
|
|
98
|
+
Plain `tsc`, no bundler. Two targets:
|
|
96
99
|
|
|
97
100
|
```bash
|
|
98
|
-
npm
|
|
99
|
-
npm
|
|
101
|
+
npm install # devDeps + peer libs for the typecheck
|
|
102
|
+
npm run build # backend/ (CJS) + frontend/ (ESM), each with .d.ts
|
|
103
|
+
npm run typecheck # type check only, no emit
|
|
100
104
|
```
|
|
101
105
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
+
- `backend/` — CommonJS (`module: commonjs`)
|
|
107
|
+
- `frontend/` — ESM (`module: esnext`, `moduleResolution: bundler`), marked via
|
|
108
|
+
`frontend/package.json` as `{"type":"module"}`; app bundlers (Vite) consume it
|
|
109
|
+
directly.
|
|
106
110
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
111
|
+
The flat output layout (`backend/` + `frontend/` at the package root) is a
|
|
112
|
+
deliberate choice so that consumers using `moduleResolution: node` (node10) can
|
|
113
|
+
resolve the subpath imports physically — without having to change their tsconfig.
|
|
110
114
|
|
|
111
|
-
##
|
|
115
|
+
## Versioning
|
|
112
116
|
|
|
113
|
-
SemVer.
|
|
114
|
-
`npm update @efa-one/sdk`.
|
|
117
|
+
SemVer. Consuming apps pull new versions with `npm update @efa-one/sdk`.
|
|
115
118
|
|
|
116
|
-
##
|
|
119
|
+
## License
|
|
117
120
|
|
|
118
|
-
[Apache-2.0](./LICENSE) —
|
|
119
|
-
|
|
121
|
+
[Apache-2.0](./LICENSE) — permissive with a patent grant, so customers and partners
|
|
122
|
+
can build efa-one apps without friction.
|
package/frontend/ipc.d.ts
CHANGED
|
@@ -5,6 +5,21 @@ export declare function sendDeclareAppInfo(payload: {
|
|
|
5
5
|
appName: string;
|
|
6
6
|
version: string;
|
|
7
7
|
}): void;
|
|
8
|
+
export declare function appInfoDeclareFromAuth(info: {
|
|
9
|
+
appName: string;
|
|
10
|
+
version: string;
|
|
11
|
+
}, data: unknown): {
|
|
12
|
+
type: 'CONVERGE_DECLARE_APP_INFO';
|
|
13
|
+
payload: {
|
|
14
|
+
appName: string;
|
|
15
|
+
version: string;
|
|
16
|
+
serviceKey: string;
|
|
17
|
+
};
|
|
18
|
+
} | null;
|
|
19
|
+
export declare function registerAppInfo(info: {
|
|
20
|
+
appName: string;
|
|
21
|
+
version: string;
|
|
22
|
+
}): () => void;
|
|
8
23
|
export declare function navigateToApp(serviceKey: string, options?: {
|
|
9
24
|
path?: string;
|
|
10
25
|
}): void;
|
package/frontend/ipc.js
CHANGED
|
@@ -27,6 +27,28 @@ export function sendDeclareAppInfo(payload) {
|
|
|
27
27
|
return;
|
|
28
28
|
window.parent.postMessage({ type: 'CONVERGE_DECLARE_APP_INFO', payload }, getParentOrigin());
|
|
29
29
|
}
|
|
30
|
+
export function appInfoDeclareFromAuth(info, data) {
|
|
31
|
+
const d = data;
|
|
32
|
+
if (!d || d.type !== 'CONVERGE_AUTH' || typeof d.serviceKey !== 'string')
|
|
33
|
+
return null;
|
|
34
|
+
return {
|
|
35
|
+
type: 'CONVERGE_DECLARE_APP_INFO',
|
|
36
|
+
payload: { appName: info.appName, version: info.version, serviceKey: d.serviceKey },
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function registerAppInfo(info) {
|
|
40
|
+
if (!isEmbedded())
|
|
41
|
+
return () => { };
|
|
42
|
+
const handler = (event) => {
|
|
43
|
+
if (!isFromPlatformParent(event))
|
|
44
|
+
return;
|
|
45
|
+
const message = appInfoDeclareFromAuth(info, event.data);
|
|
46
|
+
if (message)
|
|
47
|
+
window.parent.postMessage(message, getParentOrigin());
|
|
48
|
+
};
|
|
49
|
+
window.addEventListener('message', handler);
|
|
50
|
+
return () => window.removeEventListener('message', handler);
|
|
51
|
+
}
|
|
30
52
|
export function navigateToApp(serviceKey, options) {
|
|
31
53
|
if (!isEmbedded())
|
|
32
54
|
return;
|
|
@@ -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,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,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.
|
|
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",
|