int #6
202 changed files with 12595 additions and 12981 deletions
|
|
@ -75,7 +75,6 @@ Die folgende Tabelle ist die **Checkliste pro Modul**. Pro Zeile: **was** verkau
|
||||||
| `trustee` | Treuhand | | | ☐ ja ☐ nein | | |
|
| `trustee` | Treuhand | | | ☐ ja ☐ nein | | |
|
||||||
| `realestate` | Immobilien | | | ☐ ja ☐ nein | | |
|
| `realestate` | Immobilien | | | ☐ ja ☐ nein | | |
|
||||||
| `chatbot` | Chatbot | | | ☐ ja ☐ nein | | |
|
| `chatbot` | Chatbot | | | ☐ ja ☐ nein | | |
|
||||||
| `chatworkflow` | Workflow | | | ☐ ja ☐ nein | | |
|
|
||||||
| `automation` | Automatisierung | | | ☐ ja ☐ nein | | |
|
| `automation` | Automatisierung | | | ☐ ja ☐ nein | | |
|
||||||
| `teamsbot` | Teams Bot | | | ☐ ja ☐ nein | | |
|
| `teamsbot` | Teams Bot | | | ☐ ja ☐ nein | | |
|
||||||
| `neutralization` | Neutralisierung | | | ☐ ja ☐ nein | | |
|
| `neutralization` | Neutralisierung | | | ☐ ja ☐ nein | | |
|
||||||
|
|
@ -144,12 +143,6 @@ Viele **Views** sind Kandidaten für „Basic / Pro“ oder Add-ons (technisch:
|
||||||
- [ ] `dashboard` — …
|
- [ ] `dashboard` — …
|
||||||
- [ ] `instance-roles` (adminOnly) — …
|
- [ ] `instance-roles` (adminOnly) — …
|
||||||
|
|
||||||
### `chatworkflow`
|
|
||||||
|
|
||||||
- [ ] `dashboard` — …
|
|
||||||
- [ ] `runs` — …
|
|
||||||
- [ ] `files` — …
|
|
||||||
|
|
||||||
**Paket-Entscheid (freies Feld):**
|
**Paket-Entscheid (freies Feld):**
|
||||||
|
|
||||||
| Paketname | Enthaltene `featureCode`s | Enthaltene Views / Ausnahmen | Limits (Instanzen, Nutzer, Speicher, Credits) |
|
| Paketname | Enthaltene `featureCode`s | Enthaltene Views / Ausnahmen | Limits (Instanzen, Nutzer, Speicher, Credits) |
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ Transparenz: Verbrauch lässt sich nach **Feature**, **Instanz**, **Provider/Mod
|
||||||
| Treuhand (`trustee`) | Dokumente, Positionen, Import/Scan, Buchhaltung |
|
| Treuhand (`trustee`) | Dokumente, Positionen, Import/Scan, Buchhaltung |
|
||||||
| Immobilien (`realestate`) | Karte / Mandantenfähigkeit |
|
| Immobilien (`realestate`) | Karte / Mandantenfähigkeit |
|
||||||
| Chatbot (`chatbot`) | Konversationen, Konfiguration |
|
| Chatbot (`chatbot`) | Konversationen, Konfiguration |
|
||||||
| Workflow (`chatworkflow`) | Überblicke, Runs, Dateien |
|
| Workflow-Automation (Systemkomponente) | Workflows, Editor, Durchläufe |
|
||||||
| Automatisierung (`automation`) | Definitionen, Vorlagen, Logs |
|
| Automatisierung (`automation`) | Definitionen, Vorlagen, Logs |
|
||||||
| Teams Bot (`teamsbot`) | Dashboard, Sessions, Settings |
|
| Teams Bot (`teamsbot`) | Dashboard, Sessions, Settings |
|
||||||
| Neutralisierung (`neutralization`) | Playground, Config, Attribute |
|
| Neutralisierung (`neutralization`) | Playground, Config, Attribute |
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ import { GDPRPage } from './pages/GDPR';
|
||||||
import StorePage from './pages/Store';
|
import StorePage from './pages/Store';
|
||||||
import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage';
|
import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage';
|
||||||
import { FeatureViewPage } from './pages/FeatureView';
|
import { FeatureViewPage } from './pages/FeatureView';
|
||||||
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage } from './pages/admin';
|
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage, AdminSessionsPage } from './pages/admin';
|
||||||
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
|
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
|
||||||
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
||||||
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
||||||
|
|
@ -220,6 +220,7 @@ function App() {
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="subscriptions" element={<AdminSubscriptionsPage />} />
|
<Route path="subscriptions" element={<AdminSubscriptionsPage />} />
|
||||||
<Route path="logs" element={<AdminLogsPage />} />
|
<Route path="logs" element={<AdminLogsPage />} />
|
||||||
|
<Route path="sessions" element={<AdminSessionsPage />} />
|
||||||
<Route path="languages" element={null} />
|
<Route path="languages" element={null} />
|
||||||
<Route path="database-health" element={null} />
|
<Route path="database-health" element={null} />
|
||||||
<Route path="demo-config" element={<AdminDemoConfigPage />} />
|
<Route path="demo-config" element={<AdminDemoConfigPage />} />
|
||||||
|
|
|
||||||
68
src/api.ts
68
src/api.ts
|
|
@ -106,18 +106,27 @@ api.interceptors.request.use(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add a response interceptor to handle token expiration
|
// Silent refresh: attempt token renewal before forcing re-login
|
||||||
|
let _isRefreshing = false;
|
||||||
|
let _refreshSubscribers: Array<(success: boolean) => void> = [];
|
||||||
|
|
||||||
|
function _onRefreshDone(success: boolean) {
|
||||||
|
_refreshSubscribers.forEach(cb => cb(success));
|
||||||
|
_refreshSubscribers = [];
|
||||||
|
}
|
||||||
|
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
async (error) => {
|
async (error) => {
|
||||||
if (error.response?.status === 401) {
|
const originalRequest = error.config;
|
||||||
// Don't redirect to login if the request was to a login endpoint
|
|
||||||
const isLoginEndpoint = error.config?.url?.includes('/login') ||
|
if (error.response?.status === 401) {
|
||||||
error.config?.url?.includes('/api/local/login') ||
|
const isAuthEndpoint = originalRequest?.url?.includes('/login') ||
|
||||||
error.config?.url?.includes('/api/msft/auth/login') ||
|
originalRequest?.url?.includes('/api/local/login') ||
|
||||||
error.config?.url?.includes('/api/google/auth/login');
|
originalRequest?.url?.includes('/api/local/refresh') ||
|
||||||
|
originalRequest?.url?.includes('/api/msft/auth/login') ||
|
||||||
|
originalRequest?.url?.includes('/api/google/auth/login');
|
||||||
|
|
||||||
// Don't redirect if we're on a public auth page (prevents redirect loops and allows public pages to work)
|
|
||||||
const pathname = window.location.pathname;
|
const pathname = window.location.pathname;
|
||||||
const isOnPublicAuthPage = pathname === '/login' ||
|
const isOnPublicAuthPage = pathname === '/login' ||
|
||||||
pathname.startsWith('/login') ||
|
pathname.startsWith('/login') ||
|
||||||
|
|
@ -129,19 +138,52 @@ api.interceptors.response.use(
|
||||||
pathname.startsWith('/password-reset-request') ||
|
pathname.startsWith('/password-reset-request') ||
|
||||||
pathname.startsWith('/invite');
|
pathname.startsWith('/invite');
|
||||||
|
|
||||||
if (!isLoginEndpoint && !isOnPublicAuthPage) {
|
if (isAuthEndpoint || isOnPublicAuthPage) {
|
||||||
// Clear local auth data (httpOnly cookies are cleared by backend)
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt silent refresh (only once per request)
|
||||||
|
if (!originalRequest._retryAfterRefresh) {
|
||||||
|
originalRequest._retryAfterRefresh = true;
|
||||||
|
|
||||||
|
if (!_isRefreshing) {
|
||||||
|
_isRefreshing = true;
|
||||||
|
try {
|
||||||
|
await api.post('/api/local/refresh');
|
||||||
|
_isRefreshing = false;
|
||||||
|
_onRefreshDone(true);
|
||||||
|
return api(originalRequest);
|
||||||
|
} catch {
|
||||||
|
_isRefreshing = false;
|
||||||
|
_onRefreshDone(false);
|
||||||
sessionStorage.removeItem('auth_authority');
|
sessionStorage.removeItem('auth_authority');
|
||||||
clearUserDataCache();
|
clearUserDataCache();
|
||||||
// Redirect to login
|
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Another request is already refreshing; queue this one
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
_refreshSubscribers.push((success: boolean) => {
|
||||||
|
if (success) {
|
||||||
|
resolve(api(originalRequest));
|
||||||
|
} else {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle rate limiting (429) - don't throw, just log and return error
|
// Refresh already failed for this request
|
||||||
|
sessionStorage.removeItem('auth_authority');
|
||||||
|
clearUserDataCache();
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle rate limiting (429)
|
||||||
if (error.response?.status === 429) {
|
if (error.response?.status === 429) {
|
||||||
console.warn('Rate limit exceeded (429). Please wait before making more requests.');
|
console.warn('Rate limit exceeded (429). Please wait before making more requests.');
|
||||||
// Don't cause cascading errors by throwing here
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
|
|
|
||||||
|
|
@ -38,29 +38,6 @@ export interface BillingTransaction {
|
||||||
userName?: string;
|
userName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pagination request for GET /api/billing/transactions with `pagination` JSON (table + grouping). */
|
|
||||||
export interface BillingTransactionsPaginationParams {
|
|
||||||
page?: number;
|
|
||||||
pageSize?: number;
|
|
||||||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
|
||||||
filters?: Record<string, any>;
|
|
||||||
search?: string;
|
|
||||||
viewKey?: string;
|
|
||||||
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BillingTransactionsPaginatedResponse {
|
|
||||||
items: BillingTransaction[];
|
|
||||||
pagination?: {
|
|
||||||
currentPage: number;
|
|
||||||
pageSize: number;
|
|
||||||
totalItems: number;
|
|
||||||
totalPages: number;
|
|
||||||
};
|
|
||||||
groupLayout?: import('./connectionApi').GroupLayout;
|
|
||||||
appliedView?: { viewKey?: string; displayName?: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BillingSettings {
|
export interface BillingSettings {
|
||||||
id: string;
|
id: string;
|
||||||
mandateId: string;
|
mandateId: string;
|
||||||
|
|
@ -159,46 +136,6 @@ export async function fetchBalanceForMandate(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch transaction history (table UI: pagination, filters, sort, saved views, grouping).
|
|
||||||
* Endpoint: GET /api/billing/transactions?pagination=...
|
|
||||||
*/
|
|
||||||
export async function fetchTransactionsPaginated(
|
|
||||||
request: ApiRequestFunction,
|
|
||||||
params?: BillingTransactionsPaginationParams
|
|
||||||
): Promise<BillingTransactionsPaginatedResponse> {
|
|
||||||
const paginationObj: Record<string, unknown> = {};
|
|
||||||
if (params?.page !== undefined) paginationObj.page = params.page;
|
|
||||||
if (params?.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
|
|
||||||
if (params?.sort?.length) paginationObj.sort = params.sort;
|
|
||||||
if (params?.filters && Object.keys(params.filters).length > 0) paginationObj.filters = params.filters;
|
|
||||||
if (params?.search) paginationObj.search = params.search;
|
|
||||||
if (params?.viewKey) paginationObj.viewKey = params.viewKey;
|
|
||||||
if (params?.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
|
|
||||||
|
|
||||||
return await request({
|
|
||||||
url: '/api/billing/transactions',
|
|
||||||
method: 'get',
|
|
||||||
params: { pagination: JSON.stringify(paginationObj) },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch transaction history (legacy array window)
|
|
||||||
* Endpoint: GET /api/billing/transactions
|
|
||||||
*/
|
|
||||||
export async function fetchTransactions(
|
|
||||||
request: ApiRequestFunction,
|
|
||||||
limit: number = 50,
|
|
||||||
offset: number = 0
|
|
||||||
): Promise<BillingTransaction[]> {
|
|
||||||
return await request({
|
|
||||||
url: '/api/billing/transactions',
|
|
||||||
method: 'get',
|
|
||||||
params: { limit, offset }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch usage statistics for an explicit date range.
|
* Fetch usage statistics for an explicit date range.
|
||||||
* Endpoint: GET /api/billing/statistics
|
* Endpoint: GET /api/billing/statistics
|
||||||
|
|
@ -406,51 +343,7 @@ export async function fetchMandateViewTransactions(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// USER VIEW TYPES & API FUNCTIONS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface UserBalance {
|
|
||||||
accountId: string;
|
|
||||||
mandateId: string;
|
|
||||||
mandateName: string;
|
|
||||||
userId: string;
|
|
||||||
userName: string;
|
|
||||||
balance: number;
|
|
||||||
warningThreshold: number;
|
|
||||||
isWarning: boolean;
|
|
||||||
enabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserTransaction extends BillingTransaction {
|
export interface UserTransaction extends BillingTransaction {
|
||||||
userId?: string;
|
userId?: string;
|
||||||
userName?: string;
|
userName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch user-level balances (RBAC-based)
|
|
||||||
* Endpoint: GET /api/billing/view/users/balances
|
|
||||||
*/
|
|
||||||
export async function fetchUserViewBalances(
|
|
||||||
request: ApiRequestFunction
|
|
||||||
): Promise<UserBalance[]> {
|
|
||||||
return await request({
|
|
||||||
url: '/api/billing/view/users/balances',
|
|
||||||
method: 'get'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch user-level transactions (RBAC-based)
|
|
||||||
* Endpoint: GET /api/billing/view/users/transactions
|
|
||||||
*/
|
|
||||||
export async function fetchUserViewTransactions(
|
|
||||||
request: ApiRequestFunction,
|
|
||||||
limit: number = 100
|
|
||||||
): Promise<UserTransaction[]> {
|
|
||||||
return await request({
|
|
||||||
url: '/api/billing/view/users/transactions',
|
|
||||||
method: 'get',
|
|
||||||
params: { limit }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import api from '../api';
|
||||||
import type {
|
import type {
|
||||||
FeaturesMyResponse,
|
FeaturesMyResponse,
|
||||||
Mandate,
|
Mandate,
|
||||||
MandateFeature,
|
|
||||||
FeatureInstance,
|
FeatureInstance,
|
||||||
InstancePermissions,
|
InstancePermissions,
|
||||||
AccessLevel,
|
AccessLevel,
|
||||||
|
|
@ -56,18 +55,6 @@ const MOCK_CUSTOMER_PERMISSIONS: InstancePermissions = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const MOCK_WORKFLOW_PERMISSIONS: InstancePermissions = {
|
|
||||||
tables: {
|
|
||||||
WorkflowRun: { view: true, read: 'g', create: 'g', update: 'm', delete: 'n' },
|
|
||||||
WorkflowFile: { view: true, read: 'g', create: 'g', update: 'm', delete: 'm' },
|
|
||||||
},
|
|
||||||
views: {
|
|
||||||
'chatworkflow-dashboard': true,
|
|
||||||
'chatworkflow-runs': true,
|
|
||||||
'chatworkflow-files': true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const MOCK_RESPONSE: FeaturesMyResponse = {
|
const MOCK_RESPONSE: FeaturesMyResponse = {
|
||||||
mandates: [
|
mandates: [
|
||||||
{
|
{
|
||||||
|
|
@ -101,22 +88,6 @@ const MOCK_RESPONSE: FeaturesMyResponse = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
code: 'chatworkflow',
|
|
||||||
label: 'Workflow',
|
|
||||||
icon: 'play_circle',
|
|
||||||
instances: [
|
|
||||||
{
|
|
||||||
id: 'inst-soha-workflow',
|
|
||||||
featureCode: 'chatworkflow',
|
|
||||||
mandateId: 'mand-soha',
|
|
||||||
mandateName: 'Soha Treuhand',
|
|
||||||
instanceLabel: 'Beratung Dynamic',
|
|
||||||
userRoles: ['user'],
|
|
||||||
permissions: MOCK_WORKFLOW_PERMISSIONS,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -184,23 +155,6 @@ export async function fetchMyFeatures(): Promise<FeaturesMyResponse> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Lädt die verfügbaren Features (für Admin - Feature-Instanz erstellen)
|
|
||||||
*
|
|
||||||
* Endpoint: GET /api/features/available
|
|
||||||
*/
|
|
||||||
export async function fetchAvailableFeatures(): Promise<MandateFeature[]> {
|
|
||||||
if (USE_MOCK) {
|
|
||||||
return [
|
|
||||||
{ code: 'trustee', label: 'Treuhand', icon: 'briefcase', instances: [] },
|
|
||||||
{ code: 'chatworkflow', label: 'Workflow', icon: 'play_circle', instances: [] },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await api.get<MandateFeature[]>('/api/features/available');
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// TYPE GUARDS
|
// TYPE GUARDS
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
|
import { FloatingPortal } from '../../UiComponents/FloatingPortal';
|
||||||
import {
|
import {
|
||||||
FaPlay,
|
FaPlay,
|
||||||
FaSpinner,
|
FaSpinner,
|
||||||
|
|
@ -146,13 +147,13 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
||||||
const badge = statusBadge[currentStatus] || statusBadge.draft;
|
const badge = statusBadge[currentStatus] || statusBadge.draft;
|
||||||
|
|
||||||
const [newMenuOpen, setNewMenuOpen] = useState(false);
|
const [newMenuOpen, setNewMenuOpen] = useState(false);
|
||||||
const newMenuRef = useRef<HTMLDivElement>(null);
|
const newMenuAnchorRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
|
const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
|
||||||
const templateMenuRef = useRef<HTMLDivElement>(null);
|
const templateMenuAnchorRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [zoomMenuOpen, setZoomMenuOpen] = useState(false);
|
const [zoomMenuOpen, setZoomMenuOpen] = useState(false);
|
||||||
const zoomMenuRef = useRef<HTMLDivElement>(null);
|
const zoomMenuAnchorRef = useRef<HTMLButtonElement>(null);
|
||||||
const [zoomInputDraft, setZoomInputDraft] = useState('');
|
const [zoomInputDraft, setZoomInputDraft] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -160,16 +161,6 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
||||||
if (zp !== undefined) setZoomInputDraft(String(zp));
|
if (zp !== undefined) setZoomInputDraft(String(zp));
|
||||||
}, [canvasEdit?.zoomPercent]);
|
}, [canvasEdit?.zoomPercent]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const _handleClickOutside = (e: MouseEvent) => {
|
|
||||||
if (newMenuRef.current && !newMenuRef.current.contains(e.target as Node)) setNewMenuOpen(false);
|
|
||||||
if (templateMenuRef.current && !templateMenuRef.current.contains(e.target as Node)) setTemplateMenuOpen(false);
|
|
||||||
if (zoomMenuRef.current && !zoomMenuRef.current.contains(e.target as Node)) setZoomMenuOpen(false);
|
|
||||||
};
|
|
||||||
document.addEventListener('mousedown', _handleClickOutside);
|
|
||||||
return () => document.removeEventListener('mousedown', _handleClickOutside);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const scopeLabels = useMemo(
|
const scopeLabels = useMemo(
|
||||||
() =>
|
() =>
|
||||||
({
|
({
|
||||||
|
|
@ -237,7 +228,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
||||||
aria-label={_panelOpen ? t('Workspace-Panel ausblenden') : t('Workspace-Panel öffnen')}
|
aria-label={_panelOpen ? t('Workspace-Panel ausblenden') : t('Workspace-Panel öffnen')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div ref={newMenuRef} className={styles.canvasHeaderNewSplit}>
|
<div className={styles.canvasHeaderNewSplit}>
|
||||||
<div className={styles.canvasHeaderSplitPair}>
|
<div className={styles.canvasHeaderSplitPair}>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -250,6 +241,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
||||||
aria-label={t('Neuer leerer Workflow')}
|
aria-label={t('Neuer leerer Workflow')}
|
||||||
/>
|
/>
|
||||||
{onNewFromTemplate && (
|
{onNewFromTemplate && (
|
||||||
|
<div ref={newMenuAnchorRef}>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant={_tb}
|
variant={_tb}
|
||||||
|
|
@ -262,9 +254,16 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={newMenuOpen}
|
aria-expanded={newMenuOpen}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{newMenuOpen && onNewFromTemplate && (
|
{onNewFromTemplate && (
|
||||||
|
<FloatingPortal
|
||||||
|
open={newMenuOpen}
|
||||||
|
anchorRef={newMenuAnchorRef}
|
||||||
|
onClose={() => setNewMenuOpen(false)}
|
||||||
|
placement="bottom"
|
||||||
|
>
|
||||||
<div className={styles.canvasHeaderMenuDropdown} role="menu">
|
<div className={styles.canvasHeaderMenuDropdown} role="menu">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -278,6 +277,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
||||||
{t('Aus Vorlage…')}
|
{t('Aus Vorlage…')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</FloatingPortal>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
|
|
@ -329,7 +329,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
||||||
title={_runTitle}
|
title={_runTitle}
|
||||||
/>
|
/>
|
||||||
{currentWorkflowId && onSaveAsTemplate && (
|
{currentWorkflowId && onSaveAsTemplate && (
|
||||||
<div ref={templateMenuRef} className={styles.canvasHeaderNewSplit}>
|
<div ref={templateMenuAnchorRef} className={styles.canvasHeaderNewSplit}>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant={_tb}
|
variant={_tb}
|
||||||
|
|
@ -344,7 +344,12 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
||||||
>
|
>
|
||||||
{t('Als Vorlage')}
|
{t('Als Vorlage')}
|
||||||
</Button>
|
</Button>
|
||||||
{templateMenuOpen && (
|
<FloatingPortal
|
||||||
|
open={templateMenuOpen}
|
||||||
|
anchorRef={templateMenuAnchorRef}
|
||||||
|
onClose={() => setTemplateMenuOpen(false)}
|
||||||
|
placement="bottom"
|
||||||
|
>
|
||||||
<div className={styles.canvasHeaderMenuDropdown} role="menu">
|
<div className={styles.canvasHeaderMenuDropdown} role="menu">
|
||||||
{(['user', 'instance', 'mandate'] as const).map((s) => (
|
{(['user', 'instance', 'mandate'] as const).map((s) => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -361,7 +366,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</FloatingPortal>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -387,7 +392,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
||||||
role="toolbar"
|
role="toolbar"
|
||||||
aria-label={t('Canvas bearbeiten')}
|
aria-label={t('Canvas bearbeiten')}
|
||||||
>
|
>
|
||||||
<div ref={zoomMenuRef} className={styles.canvasHeaderZoomCombo}>
|
<div className={styles.canvasHeaderZoomCombo}>
|
||||||
<div className={styles.canvasHeaderZoomInputWrap}>
|
<div className={styles.canvasHeaderZoomInputWrap}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -410,6 +415,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
ref={zoomMenuAnchorRef}
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.canvasHeaderZoomChevronBtn}
|
className={styles.canvasHeaderZoomChevronBtn}
|
||||||
onClick={() => setZoomMenuOpen((p) => !p)}
|
onClick={() => setZoomMenuOpen((p) => !p)}
|
||||||
|
|
@ -420,7 +426,13 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
||||||
>
|
>
|
||||||
<FaCaretDown aria-hidden />
|
<FaCaretDown aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
{zoomMenuOpen && (
|
<FloatingPortal
|
||||||
|
open={zoomMenuOpen}
|
||||||
|
anchorRef={zoomMenuAnchorRef}
|
||||||
|
onClose={() => setZoomMenuOpen(false)}
|
||||||
|
placement="bottom"
|
||||||
|
align="end"
|
||||||
|
>
|
||||||
<div className={styles.canvasHeaderMenuDropdown} role="menu">
|
<div className={styles.canvasHeaderMenuDropdown} role="menu">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -459,7 +471,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</FloatingPortal>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -604,16 +604,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvasHeaderMenuDropdown {
|
.canvasHeaderMenuDropdown {
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
z-index: 100;
|
|
||||||
background: var(--bg-primary, #fff);
|
background: var(--bg-primary, #fff);
|
||||||
border: 1px solid var(--border-color, #e0e0e0);
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||||
min-width: 11rem;
|
min-width: 11rem;
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvasHeaderMenuItem {
|
.canvasHeaderMenuItem {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,10 @@
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-family: var(--font-family);
|
font-family: var(--font-family);
|
||||||
min-height: 0;
|
/* Floor for the bounded chain: below this the table stops shrinking and the
|
||||||
|
nearest scroll ancestor (.viewContent / .outletShell) shows a scrollbar
|
||||||
|
instead of squeezing the table to invisibility on short viewports. */
|
||||||
|
min-height: var(--table-min-height, 280px);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
@ -325,17 +328,12 @@
|
||||||
|
|
||||||
/* Filter dropdown */
|
/* Filter dropdown */
|
||||||
.filterDropdown {
|
.filterDropdown {
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
border: 1px solid var(--color-border, #e2e8f0);
|
border: 1px solid var(--color-border, #e2e8f0);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||||
z-index: 1000;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filterDropdownHeader {
|
.filterDropdownHeader {
|
||||||
|
|
@ -960,6 +958,26 @@ tbody .actionsColumn {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* scrollMode: document — table grows to natural height, page scrolls */
|
||||||
|
:global(html[data-scroll-mode="document"]) .formGeneratorTable {
|
||||||
|
height: auto;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html[data-scroll-mode="document"]) .tableWrapper {
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html[data-scroll-mode="document"]) .tableContainer {
|
||||||
|
overflow: visible;
|
||||||
|
max-height: none;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.formGeneratorTable {
|
.formGeneratorTable {
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@
|
||||||
*
|
*
|
||||||
* See useOrgUsers / AdminUsersPage for a full reference implementation.
|
* See useOrgUsers / AdminUsersPage for a full reference implementation.
|
||||||
*/
|
*/
|
||||||
import React, { useState, useMemo, useRef, useEffect, useLayoutEffect, useCallback } from 'react';
|
import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react';
|
||||||
import type { IconType } from 'react-icons';
|
import type { IconType } from 'react-icons';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import styles from './FormGeneratorTable.module.css';
|
import styles from './FormGeneratorTable.module.css';
|
||||||
|
|
@ -73,6 +73,7 @@ import { applyFrontendFormat } from '../../../utils/applyFrontendFormat';
|
||||||
import { FormGeneratorControls } from '../FormGeneratorControls';
|
import { FormGeneratorControls } from '../FormGeneratorControls';
|
||||||
import { FilterSearchInput } from '../FilterSearchInput';
|
import { FilterSearchInput } from '../FilterSearchInput';
|
||||||
import { CopyableTruncatedValue } from '../../UiComponents/CopyableTruncatedValue';
|
import { CopyableTruncatedValue } from '../../UiComponents/CopyableTruncatedValue';
|
||||||
|
import { FloatingPortal } from '../../UiComponents/FloatingPortal';
|
||||||
import {
|
import {
|
||||||
isDateTimeType,
|
isDateTimeType,
|
||||||
isCheckboxType,
|
isCheckboxType,
|
||||||
|
|
@ -93,6 +94,12 @@ import {
|
||||||
type TableListViewRow,
|
type TableListViewRow,
|
||||||
type TableViewConfig,
|
type TableViewConfig,
|
||||||
} from '../../../api/tableViewApi';
|
} from '../../../api/tableViewApi';
|
||||||
|
import { useVisibilityRemeasure } from '../../../hooks/useVisibilityRemeasure';
|
||||||
|
import {
|
||||||
|
buildTableFilterStorageKey,
|
||||||
|
loadTableFilterState,
|
||||||
|
saveTableFilterState,
|
||||||
|
} from '../../../utils/tableFilterPersistence';
|
||||||
|
|
||||||
function groupLevelsFromViewConfig(raw: unknown): GroupByLevelSpec[] {
|
function groupLevelsFromViewConfig(raw: unknown): GroupByLevelSpec[] {
|
||||||
if (!Array.isArray(raw)) return [];
|
if (!Array.isArray(raw)) return [];
|
||||||
|
|
@ -335,6 +342,11 @@ export interface FormGeneratorTableProps<T = any> {
|
||||||
* and sends `viewKey` in list pagination. Examples: `files/list`, `connections`, `prompts`.
|
* and sends `viewKey` in list pagination. Examples: `files/list`, `connections`, `prompts`.
|
||||||
*/
|
*/
|
||||||
tableContextKey?: string;
|
tableContextKey?: string;
|
||||||
|
/**
|
||||||
|
* Mandate/instance scope for filter+search localStorage persistence (L10).
|
||||||
|
* Fail-closed: when unset, filters are not persisted across sessions.
|
||||||
|
*/
|
||||||
|
filterScopeKey?: string;
|
||||||
/**
|
/**
|
||||||
* `sections`: one level of grouping → one paginated table per group below the category header
|
* `sections`: one level of grouping → one paginated table per group below the category header
|
||||||
* (requires hookData.fetchGroupSectionSummaries + refetchForSection).
|
* (requires hookData.fetchGroupSectionSummaries + refetchForSection).
|
||||||
|
|
@ -708,6 +720,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
onRowDragStart,
|
onRowDragStart,
|
||||||
compact = false,
|
compact = false,
|
||||||
tableContextKey,
|
tableContextKey,
|
||||||
|
filterScopeKey,
|
||||||
tableGroupLayoutMode = 'inline',
|
tableGroupLayoutMode = 'inline',
|
||||||
localDataMode = false,
|
localDataMode = false,
|
||||||
viewKeyForQueries,
|
viewKeyForQueries,
|
||||||
|
|
@ -933,6 +946,34 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
// Multi-column sorting: array of sort configs in order of priority
|
// Multi-column sorting: array of sort configs in order of priority
|
||||||
const [sortConfigs, setSortConfigs] = useState<Array<{ key: string; direction: 'asc' | 'desc' }>>(initialSort ?? []);
|
const [sortConfigs, setSortConfigs] = useState<Array<{ key: string; direction: 'asc' | 'desc' }>>(initialSort ?? []);
|
||||||
const [filters, setFilters] = useState<Record<string, any>>(initialFilters || {});
|
const [filters, setFilters] = useState<Record<string, any>>(initialFilters || {});
|
||||||
|
const filterPersistKey = useMemo(() => {
|
||||||
|
if (!filterScopeKey) return null;
|
||||||
|
const tableId = tableContextKey ?? apiEndpoint;
|
||||||
|
if (!tableId) return null;
|
||||||
|
return buildTableFilterStorageKey(filterScopeKey, tableId);
|
||||||
|
}, [filterScopeKey, tableContextKey, apiEndpoint]);
|
||||||
|
const filterPersistLoadedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!filterPersistKey || filterPersistLoadedRef.current) return;
|
||||||
|
const stored = loadTableFilterState(filterPersistKey);
|
||||||
|
if (stored) {
|
||||||
|
setFilters((prev) => ({ ...stored.filters, ...prev, ...(initialFilters || {}) }));
|
||||||
|
if (stored.searchTerm) {
|
||||||
|
setSearchTerm(stored.searchTerm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filterPersistLoadedRef.current = true;
|
||||||
|
}, [filterPersistKey, initialFilters]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!filterPersistKey || !filterPersistLoadedRef.current) return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
saveTableFilterState(filterPersistKey, { filters, searchTerm });
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [filterPersistKey, filters, searchTerm]);
|
||||||
|
|
||||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||||
// Actions column width - resizable, default based on number of buttons
|
// Actions column width - resizable, default based on number of buttons
|
||||||
const [actionsColumnWidth, setActionsColumnWidth] = useState<number | null>(null);
|
const [actionsColumnWidth, setActionsColumnWidth] = useState<number | null>(null);
|
||||||
|
|
@ -944,7 +985,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [currentPageSize, setCurrentPageSize] = useState(pageSize);
|
const [currentPageSize, setCurrentPageSize] = useState(pageSize);
|
||||||
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null);
|
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null);
|
||||||
const filterDropdownRef = useRef<HTMLDivElement>(null);
|
const filterAnchorRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
const reloadViews = useCallback(async () => {
|
const reloadViews = useCallback(async () => {
|
||||||
if (!tableContextKey) return;
|
if (!tableContextKey) return;
|
||||||
|
|
@ -1151,41 +1192,6 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
[detectedColumns],
|
[detectedColumns],
|
||||||
);
|
);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!openFilterColumn) return;
|
|
||||||
const dd = filterDropdownRef.current;
|
|
||||||
if (!dd) return;
|
|
||||||
const positionDropdown = () => {
|
|
||||||
const th = dd.closest('th');
|
|
||||||
if (!th) return;
|
|
||||||
const r = th.getBoundingClientRect();
|
|
||||||
const margin = 8;
|
|
||||||
const maxW = 320;
|
|
||||||
const w = Math.min(Math.max(dd.offsetWidth || maxW, 200), maxW, window.innerWidth - 2 * margin);
|
|
||||||
let left = r.left;
|
|
||||||
if (left + w > window.innerWidth - margin) {
|
|
||||||
left = window.innerWidth - margin - w;
|
|
||||||
}
|
|
||||||
if (left < margin) left = margin;
|
|
||||||
const approxH = dd.offsetHeight || 280;
|
|
||||||
let top = r.bottom + 4;
|
|
||||||
if (top + approxH > window.innerHeight - margin) {
|
|
||||||
top = Math.max(margin, r.top - 4 - approxH);
|
|
||||||
}
|
|
||||||
dd.style.position = 'fixed';
|
|
||||||
dd.style.left = `${left}px`;
|
|
||||||
dd.style.top = `${top}px`;
|
|
||||||
dd.style.right = 'auto';
|
|
||||||
dd.style.bottom = 'auto';
|
|
||||||
dd.style.width = `${w}px`;
|
|
||||||
dd.style.maxWidth = `${maxW}px`;
|
|
||||||
dd.style.zIndex = '2000';
|
|
||||||
};
|
|
||||||
positionDropdown();
|
|
||||||
const id = requestAnimationFrame(() => positionDropdown());
|
|
||||||
return () => cancelAnimationFrame(id);
|
|
||||||
}, [openFilterColumn]);
|
|
||||||
|
|
||||||
// Expanded groups for client-side groupBy rendering
|
// Expanded groups for client-side groupBy rendering
|
||||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||||
const [groupsInitialized, setGroupsInitialized] = useState(false);
|
const [groupsInitialized, setGroupsInitialized] = useState(false);
|
||||||
|
|
@ -1416,24 +1422,27 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
// Check if actions column exceeds 20% of container width (enable wrapping)
|
// Check if actions column exceeds 20% of container width (enable wrapping)
|
||||||
const shouldWrapActionButtons = containerWidth > 0 && currentActionsWidth > containerWidth * 0.20;
|
const shouldWrapActionButtons = containerWidth > 0 && currentActionsWidth > containerWidth * 0.20;
|
||||||
|
|
||||||
|
const _updateContainerWidth = useCallback(() => {
|
||||||
|
const container = tableContainerRef.current;
|
||||||
|
if (container) {
|
||||||
|
setContainerWidth(container.clientWidth);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Track container width changes
|
// Track container width changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = tableContainerRef.current;
|
const container = tableContainerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const updateContainerWidth = () => {
|
_updateContainerWidth();
|
||||||
setContainerWidth(container.clientWidth);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initial measurement
|
const resizeObserver = new ResizeObserver(_updateContainerWidth);
|
||||||
updateContainerWidth();
|
|
||||||
|
|
||||||
// Observe resize
|
|
||||||
const resizeObserver = new ResizeObserver(updateContainerWidth);
|
|
||||||
resizeObserver.observe(container);
|
resizeObserver.observe(container);
|
||||||
|
|
||||||
return () => resizeObserver.disconnect();
|
return () => resizeObserver.disconnect();
|
||||||
}, []);
|
}, [_updateContainerWidth]);
|
||||||
|
|
||||||
|
useVisibilityRemeasure(tableContainerRef, _updateContainerWidth);
|
||||||
const resizingColumn = useRef<string | null>(null);
|
const resizingColumn = useRef<string | null>(null);
|
||||||
const startX = useRef<number>(0);
|
const startX = useRef<number>(0);
|
||||||
const startWidth = useRef<number>(0);
|
const startWidth = useRef<number>(0);
|
||||||
|
|
@ -1771,20 +1780,6 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
return [];
|
return [];
|
||||||
}, [detectedColumns, asyncFilterValues, apiEndpoint, hookData, data, supportsBackendPagination]);
|
}, [detectedColumns, asyncFilterValues, apiEndpoint, hookData, data, supportsBackendPagination]);
|
||||||
|
|
||||||
// Close filter dropdown when clicking outside
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (filterDropdownRef.current && !filterDropdownRef.current.contains(event.target as Node)) {
|
|
||||||
setOpenFilterColumn(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (openFilterColumn) {
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
}
|
|
||||||
}, [openFilterColumn]);
|
|
||||||
|
|
||||||
// Toggle filter dropdown
|
// Toggle filter dropdown
|
||||||
const toggleFilterDropdown = useCallback((columnKey: string, event: React.MouseEvent) => {
|
const toggleFilterDropdown = useCallback((columnKey: string, event: React.MouseEvent) => {
|
||||||
event.stopPropagation(); // Prevent sort from triggering
|
event.stopPropagation(); // Prevent sort from triggering
|
||||||
|
|
@ -2933,6 +2928,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
{/* Filter icon */}
|
{/* Filter icon */}
|
||||||
{filterable && column.filterable !== false && (
|
{filterable && column.filterable !== false && (
|
||||||
<button
|
<button
|
||||||
|
ref={openFilterColumn === column.key ? filterAnchorRef : undefined}
|
||||||
className={`${styles.filterIcon} ${column.key in filters ? styles.filterActive : ''}`}
|
className={`${styles.filterIcon} ${column.key in filters ? styles.filterActive : ''}`}
|
||||||
onClick={(e) => toggleFilterDropdown(column.key, e)}
|
onClick={(e) => toggleFilterDropdown(column.key, e)}
|
||||||
title={column.key in filters
|
title={column.key in filters
|
||||||
|
|
@ -2980,8 +2976,13 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
|
|
||||||
{/* Filter dropdown */}
|
{/* Filter dropdown */}
|
||||||
{openFilterColumn === column.key && (
|
{openFilterColumn === column.key && (
|
||||||
|
<FloatingPortal
|
||||||
|
open
|
||||||
|
anchorRef={filterAnchorRef}
|
||||||
|
onClose={() => setOpenFilterColumn(null)}
|
||||||
|
placement="auto"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
ref={filterDropdownRef}
|
|
||||||
className={styles.filterDropdown}
|
className={styles.filterDropdown}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
|
@ -3152,6 +3153,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</FloatingPortal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{resizable && (
|
{resizable && (
|
||||||
|
|
@ -3330,7 +3332,9 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
colSpan={(selectable ? 1 : 0) + (hasActionColumn ? 1 : 0) + detectedColumns.length}
|
colSpan={(selectable ? 1 : 0) + (hasActionColumn ? 1 : 0) + detectedColumns.length}
|
||||||
style={{ textAlign: 'center', padding: '40px 16px', color: 'var(--text-muted, #64748b)' }}
|
style={{ textAlign: 'center', padding: '40px 16px', color: 'var(--text-muted, #64748b)' }}
|
||||||
>
|
>
|
||||||
{emptyMessage || t('Keine Daten verfügbar')}
|
{supportsBackendPagination && !hookDataProp?.pagination
|
||||||
|
? t('Lade Daten...')
|
||||||
|
: (emptyMessage || t('Keine Daten verfügbar'))}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -543,6 +543,14 @@
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Collapsed section: take only header height instead of claiming flex space
|
||||||
|
from sibling trees in the same panel. */
|
||||||
|
.collapsedRoot {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
height: auto;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Embedded workflow / compact pickers — fixed height so flex children (treeWrapper) get a real viewport */
|
/* Embedded workflow / compact pickers — fixed height so flex children (treeWrapper) get a real viewport */
|
||||||
.embeddedPicker {
|
.embeddedPicker {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -1206,6 +1206,7 @@ export function FormGeneratorTree<T = any>({
|
||||||
styles.formGeneratorTree,
|
styles.formGeneratorTree,
|
||||||
compact && styles.compactMode,
|
compact && styles.compactMode,
|
||||||
embedMaxHeight != null && styles.embeddedPicker,
|
embedMaxHeight != null && styles.embeddedPicker,
|
||||||
|
collapsible && sectionCollapsed && styles.collapsedRoot,
|
||||||
className,
|
className,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
|
||||||
|
|
@ -42,10 +42,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.popover {
|
.popover {
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 6px);
|
|
||||||
left: 0;
|
|
||||||
z-index: 4200;
|
|
||||||
min-width: min(360px, calc(100vw - 24px));
|
min-width: min(360px, calc(100vw - 24px));
|
||||||
padding: 14px 14px 12px;
|
padding: 14px 14px 12px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { FaLayerGroup, FaTrash } from 'react-icons/fa';
|
||||||
import { TbLayoutList, TbLayoutRows } from 'react-icons/tb';
|
import { TbLayoutList, TbLayoutRows } from 'react-icons/tb';
|
||||||
import { FiChevronsDown, FiChevronsUp } from 'react-icons/fi';
|
import { FiChevronsDown, FiChevronsUp } from 'react-icons/fi';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import { FloatingPortal } from '../../UiComponents/FloatingPortal';
|
||||||
import styles from './TableViewsBar.module.css';
|
import styles from './TableViewsBar.module.css';
|
||||||
|
|
||||||
export interface TableViewOption {
|
export interface TableViewOption {
|
||||||
|
|
@ -93,26 +94,18 @@ export function TableViewsBar({
|
||||||
}: TableViewsBarProps) {
|
}: TableViewsBarProps) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [groupMenuOpen, setGroupMenuOpen] = useState(false);
|
const [groupMenuOpen, setGroupMenuOpen] = useState(false);
|
||||||
const wrapRef = useRef<HTMLDivElement>(null);
|
const groupTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const [saveOpen, setSaveOpen] = useState(false);
|
const [saveOpen, setSaveOpen] = useState(false);
|
||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!groupMenuOpen) return;
|
if (!groupMenuOpen) return;
|
||||||
const onDoc = (e: MouseEvent) => {
|
|
||||||
const el = wrapRef.current;
|
|
||||||
if (el && !el.contains(e.target as Node)) setGroupMenuOpen(false);
|
|
||||||
};
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') setGroupMenuOpen(false);
|
if (e.key === 'Escape') setGroupMenuOpen(false);
|
||||||
};
|
};
|
||||||
document.addEventListener('mousedown', onDoc);
|
|
||||||
document.addEventListener('keydown', onKey);
|
document.addEventListener('keydown', onKey);
|
||||||
return () => {
|
return () => document.removeEventListener('keydown', onKey);
|
||||||
document.removeEventListener('mousedown', onDoc);
|
|
||||||
document.removeEventListener('keydown', onKey);
|
|
||||||
};
|
|
||||||
}, [groupMenuOpen]);
|
}, [groupMenuOpen]);
|
||||||
|
|
||||||
const levelsForUi = useMemo(
|
const levelsForUi = useMemo(
|
||||||
|
|
@ -196,8 +189,9 @@ export function TableViewsBar({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.toolbar}>
|
<div className={styles.toolbar}>
|
||||||
<div ref={wrapRef} className={styles.popoverAnchor}>
|
<div className={styles.popoverAnchor}>
|
||||||
<button
|
<button
|
||||||
|
ref={groupTriggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
className={`${styles.groupTrigger} ${groupMenuOpen ? styles.groupTriggerOpen : ''}`}
|
className={`${styles.groupTrigger} ${groupMenuOpen ? styles.groupTriggerOpen : ''}`}
|
||||||
onClick={() => setGroupMenuOpen((o) => !o)}
|
onClick={() => setGroupMenuOpen((o) => !o)}
|
||||||
|
|
@ -207,7 +201,12 @@ export function TableViewsBar({
|
||||||
>
|
>
|
||||||
<FaLayerGroup className={styles.groupIcon} aria-hidden />
|
<FaLayerGroup className={styles.groupIcon} aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
{groupMenuOpen && (
|
<FloatingPortal
|
||||||
|
open={groupMenuOpen}
|
||||||
|
anchorRef={groupTriggerRef}
|
||||||
|
onClose={() => setGroupMenuOpen(false)}
|
||||||
|
placement="bottom"
|
||||||
|
>
|
||||||
<div className={styles.popover} role="dialog" aria-label={t('Gruppieren nach')}>
|
<div className={styles.popover} role="dialog" aria-label={t('Gruppieren nach')}>
|
||||||
<div className={styles.popoverTitle}>{t('Gruppieren nach')}</div>
|
<div className={styles.popoverTitle}>{t('Gruppieren nach')}</div>
|
||||||
<p className={styles.popoverHint}>{t('Wählen Sie eine Spalte und die Reihenfolge der Gruppen.')}</p>
|
<p className={styles.popoverHint}>{t('Wählen Sie eine Spalte und die Reihenfolge der Gruppen.')}</p>
|
||||||
|
|
@ -256,7 +255,7 @@ export function TableViewsBar({
|
||||||
{t('+ Weitere Ebene')}
|
{t('+ Weitere Ebene')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</FloatingPortal>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className={styles.activeSummary} title={summary}>
|
<span className={styles.activeSummary} title={summary}>
|
||||||
|
|
|
||||||
325
src/components/Layout/LayoutTabs.module.css
Normal file
325
src/components/Layout/LayoutTabs.module.css
Normal file
|
|
@ -0,0 +1,325 @@
|
||||||
|
/* Copyright (c) 2026 PowerOn AG */
|
||||||
|
/* All rights reserved. */
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Tab bar ---------- */
|
||||||
|
|
||||||
|
.tabBar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 0;
|
||||||
|
border-bottom: 2px solid var(--border-color, #e0e0e0);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabBar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Group ---------- */
|
||||||
|
|
||||||
|
.group {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupLabel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupSeparator {
|
||||||
|
width: 1px;
|
||||||
|
margin: 0.5rem 0.25rem;
|
||||||
|
background: var(--border-color, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Tab button ---------- */
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -2px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
background: var(--surface-color, rgba(0, 0, 0, 0.025));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:focus-visible {
|
||||||
|
outline: 2px solid var(--primary-color, #007bff);
|
||||||
|
outline-offset: -2px;
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabActive {
|
||||||
|
color: var(--primary-color, #007bff);
|
||||||
|
border-bottom-color: var(--primary-color, #007bff);
|
||||||
|
background: var(--primary-light, rgba(37, 99, 235, 0.08));
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab[aria-disabled="true"] {
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Tab inner layout ---------- */
|
||||||
|
|
||||||
|
.tabIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabLabel {
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Grouped layout (each group on its own row) ---------- */
|
||||||
|
|
||||||
|
.tabBarGrouped {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
overflow-x: visible;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Group label on its own row, tabs wrap below it. */
|
||||||
|
.tabBarGrouped .group {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: stretch;
|
||||||
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabBarGrouped .group:last-child {
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabBarGrouped .groupLabel {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.375rem 0 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs within a grouped row wrap naturally */
|
||||||
|
.tabBarGrouped .group .tab {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabBarGrouped .group .tab.tabActive {
|
||||||
|
background: var(--primary-light, rgba(37, 99, 235, 0.1));
|
||||||
|
border-left: 3px solid var(--primary-color, #007bff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Tab bar row (holds tab bar + toggle) ---------- */
|
||||||
|
|
||||||
|
.tabBarRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabBarRowCollapsed {
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 2px solid var(--border-color, #e0e0e0);
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsedLabel {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Collapsible toggle ---------- */
|
||||||
|
|
||||||
|
.toggleBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border: 1px solid var(--border-color, #d0d0d0);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-primary, #fff);
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabBarRowCollapsed .toggleBtn {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleBtn:hover {
|
||||||
|
background: var(--primary-light, #e0e7ff);
|
||||||
|
border-color: var(--primary-color, #2563eb);
|
||||||
|
color: var(--primary-color, #2563eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleIcon {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Tab panel ---------- */
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only in fill mode: tab content stretches to the bounded panel height. */
|
||||||
|
.container:not(.containerNatural) .panel > * {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Natural-height mode (fill=false) ----------
|
||||||
|
For use inside a scrolling page (StackLayout variant="scroll"): the tabs and
|
||||||
|
their content keep their natural height so the page scroll container handles
|
||||||
|
overflow instead of compressing the regions. */
|
||||||
|
.containerNatural,
|
||||||
|
.containerNatural .panel {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.containerNatural .panel {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Dark theme ---------- */
|
||||||
|
|
||||||
|
:global(.dark-theme) .tabBar {
|
||||||
|
border-bottom-color: var(--border-color, #3a3a3a);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .groupLabel {
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .groupSeparator {
|
||||||
|
background: var(--border-color, #3a3a3a);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .tab {
|
||||||
|
color: var(--text-secondary, #aaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .tab:hover {
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
background: var(--surface-color, rgba(255, 255, 255, 0.04));
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .tabActive {
|
||||||
|
color: var(--primary-color, #4da3ff);
|
||||||
|
border-bottom-color: var(--primary-color, #4da3ff);
|
||||||
|
background: rgba(77, 163, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .tabBarGrouped .group .tab.tabActive {
|
||||||
|
background: rgba(77, 163, 255, 0.12);
|
||||||
|
border-left-color: var(--primary-color, #4da3ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .tab[aria-disabled="true"] {
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .tabSubtitle {
|
||||||
|
color: var(--text-secondary, #777);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .tabBarGrouped .group {
|
||||||
|
border-bottom-color: var(--border-color, #3a3a3a);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .tabBarRowCollapsed {
|
||||||
|
border-bottom-color: var(--border-color, #3a3a3a);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .collapsedLabel {
|
||||||
|
color: var(--text-secondary, #aaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .toggleBtn {
|
||||||
|
background: var(--bg-dark, #121212);
|
||||||
|
border-color: var(--border-dark, #444);
|
||||||
|
color: var(--text-secondary-dark, #aaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .toggleBtn:hover {
|
||||||
|
background: var(--primary-dark-bg, #1e3a5f);
|
||||||
|
border-color: var(--primary-color, #4da3ff);
|
||||||
|
color: var(--primary-light, #93c5fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Responsive ---------- */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.tabBar {
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabBarGrouped .groupLabel {
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
327
src/components/Layout/LayoutTabs.tsx
Normal file
327
src/components/Layout/LayoutTabs.tsx
Normal file
|
|
@ -0,0 +1,327 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
|
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type KeyboardEvent,
|
||||||
|
} from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { FaChevronDown, FaChevronUp } from 'react-icons/fa';
|
||||||
|
import type { LayoutTabItem, LayoutTabsProps } from './types';
|
||||||
|
import { _createLocalStorageAdapter } from './persistence';
|
||||||
|
import styles from './LayoutTabs.module.css';
|
||||||
|
|
||||||
|
const _collapsePersistence = _createLocalStorageAdapter('layoutTabsCollapse');
|
||||||
|
|
||||||
|
function _resolveAlias(
|
||||||
|
raw: string | null,
|
||||||
|
aliasMap: Record<string, string> | undefined,
|
||||||
|
): string | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
return aliasMap?.[raw] ?? raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _findItem(
|
||||||
|
items: LayoutTabItem[],
|
||||||
|
id: string | null,
|
||||||
|
): LayoutTabItem | undefined {
|
||||||
|
if (!id) return undefined;
|
||||||
|
return items.find((item) => item.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _enabledItems(items: LayoutTabItem[]): LayoutTabItem[] {
|
||||||
|
return items.filter((item) => !item.disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface _Group {
|
||||||
|
key: string;
|
||||||
|
label: string | undefined;
|
||||||
|
items: LayoutTabItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildGroups(items: LayoutTabItem[]): _Group[] {
|
||||||
|
const groups: _Group[] = [];
|
||||||
|
let current: _Group | null = null;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const groupKey = item.group ?? '';
|
||||||
|
if (!current || current.key !== groupKey) {
|
||||||
|
current = { key: groupKey, label: item.group, items: [] };
|
||||||
|
groups.push(current);
|
||||||
|
}
|
||||||
|
current.items.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LayoutTabs({
|
||||||
|
items,
|
||||||
|
urlParam,
|
||||||
|
defaultTab,
|
||||||
|
preserveSearchParams = true,
|
||||||
|
aliasMap,
|
||||||
|
syncUrl,
|
||||||
|
lazy = false,
|
||||||
|
onTabChange,
|
||||||
|
className,
|
||||||
|
collapsible = false,
|
||||||
|
collapseKey,
|
||||||
|
defaultCollapsed = false,
|
||||||
|
fill = true,
|
||||||
|
}: LayoutTabsProps) {
|
||||||
|
const shouldSyncUrl = syncUrl ?? !!urlParam;
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const _initialTab = useMemo(() => {
|
||||||
|
if (urlParam) {
|
||||||
|
const raw = searchParams.get(urlParam);
|
||||||
|
const resolved = _resolveAlias(raw, aliasMap);
|
||||||
|
const matched = _findItem(items, resolved);
|
||||||
|
if (matched && !matched.disabled) return matched.id;
|
||||||
|
}
|
||||||
|
if (defaultTab) {
|
||||||
|
const matched = _findItem(items, defaultTab);
|
||||||
|
if (matched && !matched.disabled) return matched.id;
|
||||||
|
}
|
||||||
|
return _enabledItems(items)[0]?.id ?? items[0]?.id ?? '';
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [activeId, setActiveId] = useState(_initialTab);
|
||||||
|
const [mountedIds, setMountedIds] = useState<Set<string>>(
|
||||||
|
() => new Set([_initialTab]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Collapse state for the tab bar
|
||||||
|
const [tabBarCollapsed, setTabBarCollapsed] = useState(() => {
|
||||||
|
if (!collapsible) return false;
|
||||||
|
if (collapseKey) {
|
||||||
|
const stored = _collapsePersistence.load<boolean>(collapseKey);
|
||||||
|
if (stored !== null) return stored;
|
||||||
|
}
|
||||||
|
return defaultCollapsed;
|
||||||
|
});
|
||||||
|
|
||||||
|
const _toggleTabBar = useCallback(() => {
|
||||||
|
setTabBarCollapsed((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
if (collapseKey) _collapsePersistence.save(collapseKey, next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [collapseKey]);
|
||||||
|
|
||||||
|
const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||||
|
|
||||||
|
const _setTab = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
setActiveId(id);
|
||||||
|
|
||||||
|
if (lazy) {
|
||||||
|
setMountedIds((prev) => {
|
||||||
|
if (prev.has(id)) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldSyncUrl && urlParam) {
|
||||||
|
setSearchParams(
|
||||||
|
(prev) => {
|
||||||
|
const next = preserveSearchParams
|
||||||
|
? new URLSearchParams(prev)
|
||||||
|
: new URLSearchParams();
|
||||||
|
next.set(urlParam, id);
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onTabChange?.(id);
|
||||||
|
},
|
||||||
|
[shouldSyncUrl, urlParam, preserveSearchParams, setSearchParams, onTabChange, lazy],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!urlParam || !shouldSyncUrl) return;
|
||||||
|
const raw = searchParams.get(urlParam);
|
||||||
|
const resolved = _resolveAlias(raw, aliasMap);
|
||||||
|
const matched = _findItem(items, resolved);
|
||||||
|
if (matched && !matched.disabled && matched.id !== activeId) {
|
||||||
|
setActiveId(matched.id);
|
||||||
|
if (lazy) {
|
||||||
|
setMountedIds((prev) => {
|
||||||
|
if (prev.has(matched.id)) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(matched.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [searchParams, urlParam, aliasMap, items, shouldSyncUrl, activeId, lazy]);
|
||||||
|
|
||||||
|
const enabled = useMemo(() => _enabledItems(items), [items]);
|
||||||
|
|
||||||
|
const _handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent<HTMLButtonElement>) => {
|
||||||
|
const idx = enabled.findIndex((item) => item.id === activeId);
|
||||||
|
let target: LayoutTabItem | undefined;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowRight':
|
||||||
|
target = enabled[(idx + 1) % enabled.length];
|
||||||
|
break;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
target = enabled[(idx - 1 + enabled.length) % enabled.length];
|
||||||
|
break;
|
||||||
|
case 'Home':
|
||||||
|
target = enabled[0];
|
||||||
|
break;
|
||||||
|
case 'End':
|
||||||
|
target = enabled[enabled.length - 1];
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
e.preventDefault();
|
||||||
|
_setTab(target.id);
|
||||||
|
tabRefs.current.get(target.id)?.focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[enabled, activeId, _setTab],
|
||||||
|
);
|
||||||
|
|
||||||
|
const groups = useMemo(() => _buildGroups(items), [items]);
|
||||||
|
const hasGroups = groups.some((g) => !!g.label);
|
||||||
|
const panelId = `tabpanel-${activeId}`;
|
||||||
|
const activeItem = _findItem(items, activeId) ?? items[0];
|
||||||
|
|
||||||
|
if (!items.length) return null;
|
||||||
|
|
||||||
|
const showCollapsed = collapsible && tabBarCollapsed;
|
||||||
|
|
||||||
|
const _toggleButton = collapsible ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.toggleBtn}
|
||||||
|
aria-label={showCollapsed ? 'Tab-Auswahl einblenden' : 'Tab-Auswahl einklappen'}
|
||||||
|
aria-expanded={!showCollapsed}
|
||||||
|
onClick={_toggleTabBar}
|
||||||
|
>
|
||||||
|
{showCollapsed
|
||||||
|
? <FaChevronDown className={styles.toggleIcon} aria-hidden />
|
||||||
|
: <FaChevronUp className={styles.toggleIcon} aria-hidden />}
|
||||||
|
</button>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={[styles.container, !fill && styles.containerNatural, className].filter(Boolean).join(' ')}>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
styles.tabBarRow,
|
||||||
|
showCollapsed && styles.tabBarRowCollapsed,
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
|
>
|
||||||
|
{!showCollapsed ? (
|
||||||
|
<div
|
||||||
|
className={[styles.tabBar, hasGroups && styles.tabBarGrouped]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
role="tablist"
|
||||||
|
>
|
||||||
|
{groups.map((group, gi) => (
|
||||||
|
<div key={group.key || gi} className={styles.group}>
|
||||||
|
{gi > 0 && !hasGroups && (
|
||||||
|
<div className={styles.groupSeparator} aria-hidden />
|
||||||
|
)}
|
||||||
|
{group.label && (
|
||||||
|
<span className={styles.groupLabel} aria-hidden>
|
||||||
|
{group.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{group.items.map((item) => {
|
||||||
|
const isActive = item.id === activeId;
|
||||||
|
const tabId = `tab-${item.id}`;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) tabRefs.current.set(item.id, el);
|
||||||
|
else tabRefs.current.delete(item.id);
|
||||||
|
}}
|
||||||
|
id={tabId}
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
aria-selected={isActive}
|
||||||
|
aria-controls={panelId}
|
||||||
|
aria-disabled={item.disabled || undefined}
|
||||||
|
tabIndex={isActive ? 0 : -1}
|
||||||
|
className={[styles.tab, isActive && styles.tabActive]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
onClick={() => {
|
||||||
|
if (!item.disabled) _setTab(item.id);
|
||||||
|
}}
|
||||||
|
onKeyDown={_handleKeyDown}
|
||||||
|
>
|
||||||
|
{item.icon && (
|
||||||
|
<span className={styles.tabIcon} aria-hidden>
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={styles.tabLabel}>{item.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className={styles.collapsedLabel}>{activeItem?.label}</span>
|
||||||
|
)}
|
||||||
|
{_toggleButton}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id={panelId}
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby={`tab-${activeId}`}
|
||||||
|
className={styles.panel}
|
||||||
|
>
|
||||||
|
{lazy
|
||||||
|
? items.map((item) =>
|
||||||
|
mountedIds.has(item.id) ? (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
style={
|
||||||
|
item.id === activeId
|
||||||
|
? fill
|
||||||
|
? {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column' as const,
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
}
|
||||||
|
: { display: 'block' }
|
||||||
|
: { display: 'none' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.render()}
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
)
|
||||||
|
: activeItem?.render()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LayoutTabs;
|
||||||
193
src/components/Layout/Panel.module.css
Normal file
193
src/components/Layout/Panel.module.css
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
/** Panel — typed region container with optional collapsible header. */
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.12));
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-primary, #fff);
|
||||||
|
overflow: clip;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card regions in bounded flex hosts (sidebar, PanelLayout pane): body fills and scrolls. */
|
||||||
|
.panel[data-variant="card"] .body:not(.bodyHidden) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Variant: table — fills available height, bounded scroll --- */
|
||||||
|
.panel[data-variant="table"] {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel[data-variant="table"] .body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Variant: dashboard — natural height, grid-friendly --- */
|
||||||
|
.panel[data-variant="dashboard"] .body {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Variant: toolbar — compact, no border-radius, minimal chrome --- */
|
||||||
|
.panel[data-variant="toolbar"] {
|
||||||
|
border-radius: 0;
|
||||||
|
border-left: none;
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel[data-variant="toolbar"] .body {
|
||||||
|
padding: 8px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbars are chrome (filter/action bars), not collapsible content regions.
|
||||||
|
title/id are still required for identification + a11y, but no visible header
|
||||||
|
bar is rendered to avoid duplicate headers above existing toolbar content. */
|
||||||
|
.panel[data-variant="toolbar"] .header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Generic fill — any variant can grow to fill a bounded region --- */
|
||||||
|
.panel[data-fill="true"] {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel[data-fill="true"] .body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Variant: editor — full height, no body padding --- */
|
||||||
|
.panel[data-variant="editor"] {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel[data-variant="editor"] .body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Variant: wizard — step container --- */
|
||||||
|
.panel[data-variant="wizard"] .body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--bg-secondary, rgba(0, 0, 0, 0.02));
|
||||||
|
border-bottom: 1px solid var(--border-color, rgba(0, 0, 0, 0.08));
|
||||||
|
min-height: 40px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerCollapsible {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
/* Reserve space so action icons never overlap the collapse chevron */
|
||||||
|
padding-right: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerCollapsible:hover {
|
||||||
|
background: var(--bg-hover, rgba(0, 0, 0, 0.04));
|
||||||
|
}
|
||||||
|
|
||||||
|
.titles {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #1a1a1a);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapse chevron: always rightmost, above action icons (never covered). */
|
||||||
|
.chevron {
|
||||||
|
position: absolute;
|
||||||
|
right: 14px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary, #888);
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bodyHidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme */
|
||||||
|
:global(.dark-theme) .panel {
|
||||||
|
border-color: var(--border-color, rgba(255, 255, 255, 0.1));
|
||||||
|
background: var(--bg-primary, #1e1e1e);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .header {
|
||||||
|
background: var(--bg-secondary, rgba(255, 255, 255, 0.03));
|
||||||
|
border-bottom-color: var(--border-color, rgba(255, 255, 255, 0.06));
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .headerCollapsible:hover {
|
||||||
|
background: var(--bg-hover, rgba(255, 255, 255, 0.05));
|
||||||
|
}
|
||||||
90
src/components/Layout/Panel.tsx
Normal file
90
src/components/Layout/Panel.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
|
import { type FC, useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { FaChevronDown, FaChevronRight } from 'react-icons/fa';
|
||||||
|
import type { PanelProps } from './types';
|
||||||
|
import styles from './Panel.module.css';
|
||||||
|
|
||||||
|
function _loadCollapsed(key: string, fallback: boolean): boolean {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(`panel-collapse:${key}`);
|
||||||
|
if (stored !== null) return stored === '1';
|
||||||
|
} catch { /* noop */ }
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _saveCollapsed(key: string, value: boolean): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(`panel-collapse:${key}`, value ? '1' : '0');
|
||||||
|
} catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Panel: FC<PanelProps> = ({
|
||||||
|
variant = 'card',
|
||||||
|
title,
|
||||||
|
id,
|
||||||
|
subtitle,
|
||||||
|
actions,
|
||||||
|
collapsible = true,
|
||||||
|
defaultCollapsed = false,
|
||||||
|
collapseKey,
|
||||||
|
className = '',
|
||||||
|
style,
|
||||||
|
fill = false,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const persistKey = collapseKey ?? `${pathname}:${id}`;
|
||||||
|
const [collapsed, setCollapsed] = useState(() => _loadCollapsed(persistKey, defaultCollapsed));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
_saveCollapsed(persistKey, collapsed);
|
||||||
|
}, [persistKey, collapsed]);
|
||||||
|
|
||||||
|
const _toggleCollapsed = useCallback(() => {
|
||||||
|
if (collapsible) setCollapsed((prev) => !prev);
|
||||||
|
}, [collapsible]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.panel} ${collapsed ? styles.panelCollapsed : ''} ${className}`}
|
||||||
|
data-variant={variant}
|
||||||
|
data-fill={fill ? 'true' : undefined}
|
||||||
|
data-panel-id={id}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`${styles.header} ${collapsible ? styles.headerCollapsible : ''}`}
|
||||||
|
role={collapsible ? 'button' : undefined}
|
||||||
|
tabIndex={collapsible ? 0 : undefined}
|
||||||
|
aria-expanded={collapsible ? !collapsed : undefined}
|
||||||
|
onClick={_toggleCollapsed}
|
||||||
|
onKeyDown={
|
||||||
|
collapsible
|
||||||
|
? (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
_toggleCollapsed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles.titles}>
|
||||||
|
<span className={styles.title}>{title}</span>
|
||||||
|
{subtitle && <span className={styles.subtitle}>{subtitle}</span>}
|
||||||
|
</div>
|
||||||
|
{collapsible && (
|
||||||
|
<span className={styles.chevron} aria-hidden>
|
||||||
|
{collapsed ? <FaChevronRight /> : <FaChevronDown />}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{actions && <div className={styles.actions} onClick={(e) => e.stopPropagation()}>{actions}</div>}
|
||||||
|
</div>
|
||||||
|
<div className={`${styles.body} ${collapsed ? styles.bodyHidden : ''}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
98
src/components/Layout/PanelLayout.module.css
Normal file
98
src/components/Layout/PanelLayout.module.css
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root[data-direction="vertical"] {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root[data-direction="horizontal"] .pane:not(.paneCollapsed) {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paneCollapsed {
|
||||||
|
flex: 0 0 auto !important;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paneBody {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Generic region-fill: a pane hosts a single region object, which should
|
||||||
|
always grow to fill the pane (height + width) so tables/trees/etc. are
|
||||||
|
usable instead of clipped. */
|
||||||
|
.paneBody > * {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paneBodyHidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapseToggle {
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: stretch;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
min-width: 28px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-right: 1px solid var(--border-color, rgba(0, 0, 0, 0.12));
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--bg-secondary, #f5f5f5);
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paneCollapsed .collapseToggle {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border-color, rgba(0, 0, 0, 0.12));
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapseToggle:hover {
|
||||||
|
background: var(--bg-hover, #ebebeb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--border-color, rgba(0, 0, 0, 0.12));
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dividerHorizontal {
|
||||||
|
width: 4px;
|
||||||
|
cursor: col-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dividerVertical {
|
||||||
|
height: 4px;
|
||||||
|
cursor: row-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dividerDragging {
|
||||||
|
background: var(--primary-color, #2563eb);
|
||||||
|
}
|
||||||
298
src/components/Layout/PanelLayout.tsx
Normal file
298
src/components/Layout/PanelLayout.tsx
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
|
/**
|
||||||
|
* PanelLayout — config-driven horizontal/vertical split layout (MVP).
|
||||||
|
*
|
||||||
|
* Supports 2+ resizable panes with optional collapse and localStorage persistence.
|
||||||
|
* Nested split trees can be composed by nesting PanelLayout instances in pane content.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type CSSProperties,
|
||||||
|
type FC,
|
||||||
|
type MouseEvent as ReactMouseEvent,
|
||||||
|
} from 'react';
|
||||||
|
import { FaChevronLeft, FaChevronRight, FaChevronUp, FaChevronDown } from 'react-icons/fa';
|
||||||
|
import type { PanelLayoutPaneConfig, PanelLayoutProps } from './types';
|
||||||
|
import { useVisibilityRemeasure } from '../../hooks/useVisibilityRemeasure';
|
||||||
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import styles from './PanelLayout.module.css';
|
||||||
|
|
||||||
|
const STORAGE_PREFIX = 'po_panel_layout:';
|
||||||
|
|
||||||
|
function _loadCollapsed(key: string | undefined, fallback: boolean): boolean {
|
||||||
|
if (!key) return fallback;
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(`panel-collapse:${key}`);
|
||||||
|
if (stored !== null) return stored === '1';
|
||||||
|
} catch { /* noop */ }
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _saveCollapsed(key: string | undefined, value: boolean): void {
|
||||||
|
if (!key) return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(`panel-collapse:${key}`, value ? '1' : '0');
|
||||||
|
} catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _normalizeSizes(sizes: number[]): number[] {
|
||||||
|
const total = sizes.reduce((sum, s) => sum + s, 0);
|
||||||
|
if (total <= 0) return sizes.map(() => 100 / sizes.length);
|
||||||
|
return sizes.map((s) => (s / total) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _loadSizes(persistenceKey: string, panes: PanelLayoutPaneConfig[]): number[] {
|
||||||
|
const defaults = panes.map((p) => p.defaultSize ?? 100 / panes.length);
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(`${STORAGE_PREFIX}${persistenceKey}`);
|
||||||
|
if (!raw) return _normalizeSizes(defaults);
|
||||||
|
const parsed = JSON.parse(raw) as number[];
|
||||||
|
if (!Array.isArray(parsed) || parsed.length !== panes.length) {
|
||||||
|
return _normalizeSizes(defaults);
|
||||||
|
}
|
||||||
|
return _normalizeSizes(parsed);
|
||||||
|
} catch {
|
||||||
|
return _normalizeSizes(defaults);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _saveSizes(persistenceKey: string, sizes: number[]): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(`${STORAGE_PREFIX}${persistenceKey}`, JSON.stringify(sizes));
|
||||||
|
} catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _clampPaneSize(
|
||||||
|
pane: PanelLayoutPaneConfig,
|
||||||
|
size: number,
|
||||||
|
): number {
|
||||||
|
const min = pane.minSize ?? 10;
|
||||||
|
const max = pane.maxSize ?? 80;
|
||||||
|
return Math.max(min, Math.min(max, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PanelLayout: FC<PanelLayoutProps> = ({
|
||||||
|
persistenceKey,
|
||||||
|
direction = 'horizontal',
|
||||||
|
panes,
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [sizes, setSizes] = useState<number[]>(() => _loadSizes(persistenceKey, panes));
|
||||||
|
const [collapsedById, setCollapsedById] = useState<Record<string, boolean>>(() => {
|
||||||
|
const initial: Record<string, boolean> = {};
|
||||||
|
for (const pane of panes) {
|
||||||
|
initial[pane.id] = _loadCollapsed(pane.collapseKey, pane.defaultCollapsed ?? false);
|
||||||
|
}
|
||||||
|
return initial;
|
||||||
|
});
|
||||||
|
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
||||||
|
const dragRef = useRef<{ index: number; startPos: number; startSizes: number[]; containerSize: number } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSizes(_loadSizes(persistenceKey, panes));
|
||||||
|
}, [persistenceKey, panes.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (draggingIndex === null) {
|
||||||
|
_saveSizes(persistenceKey, sizes);
|
||||||
|
}
|
||||||
|
}, [sizes, persistenceKey, draggingIndex]);
|
||||||
|
|
||||||
|
const _toggleCollapsed = useCallback((pane: PanelLayoutPaneConfig) => {
|
||||||
|
setCollapsedById((prev) => {
|
||||||
|
const next = !prev[pane.id];
|
||||||
|
_saveCollapsed(pane.collapseKey, next);
|
||||||
|
return { ...prev, [pane.id]: next };
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _handleDividerMouseDown = useCallback((index: number, e: ReactMouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const containerSize = direction === 'horizontal' ? rect.width : rect.height;
|
||||||
|
const startPos = direction === 'horizontal' ? e.clientX : e.clientY;
|
||||||
|
|
||||||
|
dragRef.current = { index, startPos, startSizes: [...sizes], containerSize };
|
||||||
|
setDraggingIndex(index);
|
||||||
|
}, [direction, sizes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (draggingIndex === null) return;
|
||||||
|
|
||||||
|
const _onMouseMove = (e: MouseEvent) => {
|
||||||
|
const drag = dragRef.current;
|
||||||
|
if (!drag) return;
|
||||||
|
|
||||||
|
const currentPos = direction === 'horizontal' ? e.clientX : e.clientY;
|
||||||
|
const deltaPercent = ((currentPos - drag.startPos) / drag.containerSize) * 100;
|
||||||
|
const next = [...drag.startSizes];
|
||||||
|
const leftPane = panes[drag.index];
|
||||||
|
const rightPane = panes[drag.index + 1];
|
||||||
|
|
||||||
|
let leftSize = next[drag.index] + deltaPercent;
|
||||||
|
let rightSize = next[drag.index + 1] - deltaPercent;
|
||||||
|
|
||||||
|
leftSize = _clampPaneSize(leftPane, leftSize);
|
||||||
|
rightSize = _clampPaneSize(rightPane, rightSize);
|
||||||
|
|
||||||
|
const pairTotal = drag.startSizes[drag.index] + drag.startSizes[drag.index + 1];
|
||||||
|
const adjustedTotal = leftSize + rightSize;
|
||||||
|
if (Math.abs(adjustedTotal - pairTotal) > 0.01) {
|
||||||
|
const scale = pairTotal / adjustedTotal;
|
||||||
|
leftSize *= scale;
|
||||||
|
rightSize *= scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
next[drag.index] = leftSize;
|
||||||
|
next[drag.index + 1] = rightSize;
|
||||||
|
setSizes(_normalizeSizes(next));
|
||||||
|
};
|
||||||
|
|
||||||
|
const _onMouseUp = () => {
|
||||||
|
dragRef.current = null;
|
||||||
|
setDraggingIndex(null);
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', _onMouseMove);
|
||||||
|
document.addEventListener('mouseup', _onMouseUp);
|
||||||
|
document.body.style.cursor = direction === 'horizontal' ? 'col-resize' : 'row-resize';
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', _onMouseMove);
|
||||||
|
document.removeEventListener('mouseup', _onMouseUp);
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
};
|
||||||
|
}, [draggingIndex, direction, panes]);
|
||||||
|
|
||||||
|
const _remeasure = useCallback(() => {
|
||||||
|
containerRef.current?.dispatchEvent(new Event('panel-layout-remeasure'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useVisibilityRemeasure(containerRef, _remeasure);
|
||||||
|
|
||||||
|
const paneStyle = useCallback((pane: PanelLayoutPaneConfig, index: number): CSSProperties => {
|
||||||
|
const collapsed = collapsedById[pane.id] && pane.collapsible;
|
||||||
|
if (collapsed) {
|
||||||
|
const collapsedPx = pane.collapsedSize ?? 40;
|
||||||
|
return direction === 'horizontal'
|
||||||
|
? { flex: `0 0 ${collapsedPx}px`, width: collapsedPx }
|
||||||
|
: { flex: `0 0 ${collapsedPx}px`, height: collapsedPx };
|
||||||
|
}
|
||||||
|
const percent = sizes[index] ?? 100 / panes.length;
|
||||||
|
return { flex: `${percent} 1 0`, minWidth: 0, minHeight: 0 };
|
||||||
|
}, [collapsedById, direction, panes.length, sizes]);
|
||||||
|
|
||||||
|
const dividerClass = useMemo(
|
||||||
|
() => `${styles.divider} ${direction === 'horizontal' ? styles.dividerHorizontal : styles.dividerVertical}`,
|
||||||
|
[direction],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (panes.length < 2) {
|
||||||
|
throw new Error('PanelLayout requires at least 2 panes');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`${styles.root} ${className}`}
|
||||||
|
data-direction={direction}
|
||||||
|
role="group"
|
||||||
|
>
|
||||||
|
{panes.map((pane, index) => (
|
||||||
|
<PaneSlot
|
||||||
|
key={pane.id}
|
||||||
|
pane={pane}
|
||||||
|
style={paneStyle(pane, index)}
|
||||||
|
collapsed={!!collapsedById[pane.id] && !!pane.collapsible}
|
||||||
|
onToggleCollapse={() => _toggleCollapsed(pane)}
|
||||||
|
collapseLabel={t('Panel einklappen')}
|
||||||
|
expandLabel={t('Panel ausklappen')}
|
||||||
|
direction={direction}
|
||||||
|
showDivider={index < panes.length - 1}
|
||||||
|
dividerClass={`${dividerClass} ${draggingIndex === index ? styles.dividerDragging : ''}`}
|
||||||
|
onDividerMouseDown={(e) => _handleDividerMouseDown(index, e)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PaneSlotProps {
|
||||||
|
pane: PanelLayoutPaneConfig;
|
||||||
|
style: CSSProperties;
|
||||||
|
collapsed: boolean;
|
||||||
|
onToggleCollapse: () => void;
|
||||||
|
collapseLabel: string;
|
||||||
|
expandLabel: string;
|
||||||
|
direction: 'horizontal' | 'vertical';
|
||||||
|
showDivider: boolean;
|
||||||
|
dividerClass: string;
|
||||||
|
onDividerMouseDown: (e: ReactMouseEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PaneSlot: FC<PaneSlotProps> = ({
|
||||||
|
pane,
|
||||||
|
style,
|
||||||
|
collapsed,
|
||||||
|
onToggleCollapse,
|
||||||
|
collapseLabel,
|
||||||
|
expandLabel,
|
||||||
|
direction,
|
||||||
|
showDivider,
|
||||||
|
dividerClass,
|
||||||
|
onDividerMouseDown,
|
||||||
|
}) => {
|
||||||
|
const _collapseIcon = direction === 'horizontal'
|
||||||
|
? (collapsed ? <FaChevronRight aria-hidden /> : <FaChevronLeft aria-hidden />)
|
||||||
|
: (collapsed ? <FaChevronDown aria-hidden /> : <FaChevronUp aria-hidden />);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`${styles.pane} ${collapsed ? styles.paneCollapsed : ''}`}
|
||||||
|
style={style}
|
||||||
|
data-pane-id={pane.id}
|
||||||
|
>
|
||||||
|
{pane.collapsible && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.collapseToggle}
|
||||||
|
onClick={onToggleCollapse}
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
aria-label={collapsed ? expandLabel : collapseLabel}
|
||||||
|
>
|
||||||
|
{_collapseIcon}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className={`${styles.paneBody} ${collapsed ? styles.paneBodyHidden : ''}`}>
|
||||||
|
{pane.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showDivider && (
|
||||||
|
<div
|
||||||
|
className={dividerClass}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
onMouseDown={onDividerMouseDown}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PanelLayout;
|
||||||
110
src/components/Layout/StackLayout.module.css
Normal file
110
src/components/Layout/StackLayout.module.css
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
/** StackLayout — structural flex-column container with named regions. */
|
||||||
|
|
||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Regions */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
.header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Variant overrides on body */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
.bodyTable {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bodyScroll {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bodyForm {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scroll/form layouts: regions keep their natural height and the body scrolls,
|
||||||
|
instead of flex-shrinking children below their content (which clips data). */
|
||||||
|
.bodyScroll > *,
|
||||||
|
.bodyForm > * {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bodyDashboard {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
overflow: visible;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard root keeps its bounded height but scrolls its own content */
|
||||||
|
.root[data-variant="dashboard"] {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Document scroll-mode: body becomes overflow:visible */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
:global(html[data-scroll-mode="document"]) .root {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html[data-scroll-mode="document"]) .bodyTable,
|
||||||
|
:global(html[data-scroll-mode="document"]) .bodyScroll,
|
||||||
|
:global(html[data-scroll-mode="document"]) .bodyForm,
|
||||||
|
:global(html[data-scroll-mode="document"]) .bodyDashboard {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Dark theme */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
:global(.dark-theme) .root {
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Responsive: tighten padding on small screens */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.bodyForm {
|
||||||
|
padding: 12px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bodyDashboard {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
105
src/components/Layout/StackLayout.tsx
Normal file
105
src/components/Layout/StackLayout.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
|
import React, { type FC, type ReactNode, Children, isValidElement, cloneElement, useRef } from 'react';
|
||||||
|
import type { StackLayoutProps, StackLayoutVariant } from './types';
|
||||||
|
import { useScrollMode } from '../../hooks/useScrollMode';
|
||||||
|
import { useScrollRestoration } from '../../hooks/useScrollRestoration';
|
||||||
|
import styles from './StackLayout.module.css';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sub-components (compound component pattern)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface SlotProps {
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Header: FC<SlotProps> = ({ className = '', children }) => (
|
||||||
|
<div className={`${styles.header} ${className}`}>{children}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Toolbar: FC<SlotProps> = ({ className = '', children }) => (
|
||||||
|
<div className={`${styles.toolbar} ${className}`}>{children}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Tabs: FC<SlotProps> = ({ className = '', children }) => (
|
||||||
|
<div className={`${styles.tabs} ${className}`}>{children}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Body: FC<SlotProps> = ({ className = '', children }) => (
|
||||||
|
<div className={className}>{children}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Footer: FC<SlotProps> = ({ className = '', children }) => (
|
||||||
|
<div className={`${styles.footer} ${className}`}>{children}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Variant → CSS class mapping
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const _variantBodyClass: Record<StackLayoutVariant, string> = {
|
||||||
|
table: styles.bodyTable,
|
||||||
|
scroll: styles.bodyScroll,
|
||||||
|
form: styles.bodyForm,
|
||||||
|
dashboard: styles.bodyDashboard,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Root component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface StackLayoutComponent extends FC<StackLayoutProps> {
|
||||||
|
Header: FC<SlotProps>;
|
||||||
|
Toolbar: FC<SlotProps>;
|
||||||
|
Tabs: FC<SlotProps>;
|
||||||
|
Body: FC<SlotProps>;
|
||||||
|
Footer: FC<SlotProps>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _StackLayoutRoot: FC<StackLayoutProps> = ({
|
||||||
|
variant = 'scroll',
|
||||||
|
className = '',
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const scrollMode = useScrollMode();
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
useScrollRestoration(rootRef);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
className={`${styles.root} ${className}`}
|
||||||
|
data-scroll-mode={scrollMode}
|
||||||
|
data-variant={variant}
|
||||||
|
>
|
||||||
|
{_processChildren(children, variant)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function _processChildren(children: ReactNode, variant: StackLayoutVariant): ReactNode {
|
||||||
|
const bodyClass = `${styles.body} ${_variantBodyClass[variant]}`;
|
||||||
|
|
||||||
|
return Children.map(children, (child: ReactNode) => {
|
||||||
|
if (!isValidElement(child)) return child;
|
||||||
|
if (child.type === Body) {
|
||||||
|
return cloneElement(child as React.ReactElement<SlotProps>, {
|
||||||
|
className: `${bodyClass} ${(child.props as SlotProps).className ?? ''}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Assemble compound component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const StackLayout = _StackLayoutRoot as StackLayoutComponent;
|
||||||
|
StackLayout.Header = Header;
|
||||||
|
StackLayout.Toolbar = Toolbar;
|
||||||
|
StackLayout.Tabs = Tabs;
|
||||||
|
StackLayout.Body = Body;
|
||||||
|
StackLayout.Footer = Footer;
|
||||||
110
src/components/Layout/ViewStack.module.css
Normal file
110
src/components/Layout/ViewStack.module.css
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
.viewStack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backButton {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backButton:hover {
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
border-color: var(--primary-color, #007bff);
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backButton:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backArrow {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailTitle {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #1a1a1a);
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .detailHeader {
|
||||||
|
border-bottom-color: var(--border-color, #333);
|
||||||
|
background: var(--bg-primary, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .backButton {
|
||||||
|
color: var(--text-secondary, #aaa);
|
||||||
|
border-color: var(--border-color, #444);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .backButton:hover {
|
||||||
|
color: var(--text-primary, #eee);
|
||||||
|
border-color: var(--primary-color, #4da3ff);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .detailTitle {
|
||||||
|
color: var(--text-primary, #f0f0f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.detailHeader {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backButton {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailTitle {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
197
src/components/Layout/ViewStack.tsx
Normal file
197
src/components/Layout/ViewStack.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
|
|
||||||
|
import React, { type ReactElement, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import type { ViewMode, ViewStackProps, ViewProps } from './types';
|
||||||
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import styles from './ViewStack.module.css';
|
||||||
|
|
||||||
|
const VALID_VIEW_MODES: ViewMode[] = ['list', 'catalog', 'detail'];
|
||||||
|
|
||||||
|
interface ViewResolution {
|
||||||
|
activeView: ViewMode;
|
||||||
|
sanitized: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _collectChildIds(children: React.ReactNode): ViewMode[] {
|
||||||
|
const ids: ViewMode[] = [];
|
||||||
|
React.Children.forEach(children, (child) => {
|
||||||
|
if (!React.isValidElement<ViewProps>(child)) return;
|
||||||
|
ids.push(child.props.id);
|
||||||
|
});
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _resolveActiveView(
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
viewParam: string,
|
||||||
|
entityParam: string | undefined,
|
||||||
|
defaultView: ViewMode,
|
||||||
|
registeredViews: ViewMode[],
|
||||||
|
): ViewResolution {
|
||||||
|
const rawView = searchParams.get(viewParam) as ViewMode | null;
|
||||||
|
const entityId = entityParam ? searchParams.get(entityParam) : null;
|
||||||
|
|
||||||
|
let sanitized = false;
|
||||||
|
|
||||||
|
if (rawView && !VALID_VIEW_MODES.includes(rawView)) {
|
||||||
|
sanitized = true;
|
||||||
|
} else if (rawView && !registeredViews.includes(rawView)) {
|
||||||
|
sanitized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolved: ViewMode = rawView && registeredViews.includes(rawView) ? rawView : defaultView;
|
||||||
|
|
||||||
|
if (entityId && resolved === 'list' && registeredViews.includes('detail')) {
|
||||||
|
resolved = 'detail';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolved === 'detail') {
|
||||||
|
if (!registeredViews.includes('detail')) {
|
||||||
|
sanitized = true;
|
||||||
|
resolved = defaultView;
|
||||||
|
} else if (entityParam && !entityId) {
|
||||||
|
sanitized = true;
|
||||||
|
resolved = defaultView;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entityId && !registeredViews.includes('detail')) {
|
||||||
|
sanitized = true;
|
||||||
|
resolved = defaultView;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { activeView: resolved, sanitized };
|
||||||
|
}
|
||||||
|
|
||||||
|
function _findActiveChild(
|
||||||
|
children: React.ReactNode,
|
||||||
|
activeView: ViewMode,
|
||||||
|
): ReactElement<ViewProps> | null {
|
||||||
|
let match: ReactElement<ViewProps> | null = null;
|
||||||
|
|
||||||
|
React.Children.forEach(children, (child) => {
|
||||||
|
if (!React.isValidElement<ViewProps>(child)) return;
|
||||||
|
if (child.props.id === activeView) {
|
||||||
|
match = child;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildBackParams(
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
viewParam: string,
|
||||||
|
entityParam: string | undefined,
|
||||||
|
defaultView: ViewMode,
|
||||||
|
): URLSearchParams {
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
|
||||||
|
if (entityParam) {
|
||||||
|
next.delete(entityParam);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defaultView === 'list') {
|
||||||
|
next.delete(viewParam);
|
||||||
|
} else {
|
||||||
|
next.set(viewParam, 'list');
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildSanitizedParams(
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
viewParam: string,
|
||||||
|
entityParam: string | undefined,
|
||||||
|
defaultView: ViewMode,
|
||||||
|
): URLSearchParams {
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
next.delete(viewParam);
|
||||||
|
if (entityParam) {
|
||||||
|
next.delete(entityParam);
|
||||||
|
}
|
||||||
|
if (defaultView !== 'list') {
|
||||||
|
next.set(viewParam, defaultView);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function View({ children }: ViewProps) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ViewStack({
|
||||||
|
viewParam = 'view',
|
||||||
|
entityParam,
|
||||||
|
defaultView = 'list',
|
||||||
|
children,
|
||||||
|
}: ViewStackProps) {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const { showWarning } = useToast();
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const toastShownRef = useRef(false);
|
||||||
|
|
||||||
|
const registeredViews = useMemo(() => _collectChildIds(children), [children]);
|
||||||
|
|
||||||
|
const { activeView, sanitized } = useMemo(
|
||||||
|
() => _resolveActiveView(searchParams, viewParam, entityParam, defaultView, registeredViews),
|
||||||
|
[searchParams, viewParam, entityParam, defaultView, registeredViews],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sanitized || toastShownRef.current) return;
|
||||||
|
toastShownRef.current = true;
|
||||||
|
showWarning(t('Ungültige Ansicht'), t('Die angeforderte Ansicht ist nicht verfügbar.'));
|
||||||
|
setSearchParams(
|
||||||
|
_buildSanitizedParams(searchParams, viewParam, entityParam, defaultView),
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
}, [sanitized, showWarning, t, setSearchParams, searchParams, viewParam, entityParam, defaultView]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sanitized) {
|
||||||
|
toastShownRef.current = false;
|
||||||
|
}
|
||||||
|
}, [sanitized]);
|
||||||
|
|
||||||
|
const activeChild = _findActiveChild(children, activeView);
|
||||||
|
|
||||||
|
if (!activeChild) return null;
|
||||||
|
|
||||||
|
const { title, backLabel, actions } = activeChild.props;
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
const nextParams = _buildBackParams(searchParams, viewParam, entityParam, defaultView);
|
||||||
|
setSearchParams(nextParams, { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.viewStack}>
|
||||||
|
{activeView === 'detail' && (
|
||||||
|
<div className={styles.detailHeader}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.backButton}
|
||||||
|
onClick={handleBack}
|
||||||
|
>
|
||||||
|
<span className={styles.backArrow}>←</span>
|
||||||
|
{backLabel && <span>{backLabel}</span>}
|
||||||
|
</button>
|
||||||
|
{title && <h2 className={styles.detailTitle}>{title}</h2>}
|
||||||
|
{actions && <div className={styles.detailActions}>{actions}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.viewContent}>
|
||||||
|
{activeChild}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ViewStack.View = View;
|
||||||
|
|
||||||
|
export default ViewStack;
|
||||||
28
src/components/Layout/index.ts
Normal file
28
src/components/Layout/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
|
/**
|
||||||
|
* Layout component system — barrel export.
|
||||||
|
*
|
||||||
|
* Provides: StackLayout, LayoutTabs, ViewStack, Panel, persistence adapters.
|
||||||
|
* Replaces: Admin.module.css layout classes, UiComponents/Tabs, inline layouts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type {
|
||||||
|
ScrollMode,
|
||||||
|
LayoutTabItem,
|
||||||
|
LayoutTabsProps,
|
||||||
|
ViewMode,
|
||||||
|
ViewStackProps,
|
||||||
|
ViewProps,
|
||||||
|
PanelProps,
|
||||||
|
StackLayoutVariant,
|
||||||
|
StackLayoutProps,
|
||||||
|
LayoutPersistenceAdapter,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export { _createLocalStorageAdapter } from './persistence';
|
||||||
|
export { default as ViewStack } from './ViewStack';
|
||||||
|
export { LayoutTabs } from './LayoutTabs';
|
||||||
|
export { Panel } from './Panel';
|
||||||
|
export { StackLayout } from './StackLayout';
|
||||||
|
export { PanelLayout } from './PanelLayout';
|
||||||
39
src/components/Layout/persistence.ts
Normal file
39
src/components/Layout/persistence.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
|
/**
|
||||||
|
* LayoutPersistenceAdapter — pluggable persistence for layout state.
|
||||||
|
*
|
||||||
|
* Default implementation uses localStorage.
|
||||||
|
* NOT used for navigation state (URL is source-of-truth) or settings values (DB).
|
||||||
|
* Use for: panel widths, collapse state, user UI preferences.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { LayoutPersistenceAdapter } from './types';
|
||||||
|
|
||||||
|
const PREFIX = 'po_layout_';
|
||||||
|
|
||||||
|
function _buildKey(scope: string, key: string): string {
|
||||||
|
return `${PREFIX}${scope}:${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _createLocalStorageAdapter(scope: string): LayoutPersistenceAdapter {
|
||||||
|
return {
|
||||||
|
scope,
|
||||||
|
load<T>(key: string): T | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(_buildKey(scope, key));
|
||||||
|
if (raw === null) return null;
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
save<T>(key: string, value: T): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(_buildKey(scope, key), JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
// localStorage full or unavailable — silently ignore for UI preferences
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
153
src/components/Layout/types.ts
Normal file
153
src/components/Layout/types.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
|
/**
|
||||||
|
* Shared types for the Layout component system.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CSSProperties, ReactNode } from 'react';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ScrollMode (from useScrollMode hook)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type ScrollMode = 'bounded' | 'document';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// LayoutTabs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface LayoutTabItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
group?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
render: () => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayoutTabsProps {
|
||||||
|
items: LayoutTabItem[];
|
||||||
|
urlParam?: string;
|
||||||
|
defaultTab?: string;
|
||||||
|
preserveSearchParams?: boolean;
|
||||||
|
aliasMap?: Record<string, string>;
|
||||||
|
syncUrl?: boolean;
|
||||||
|
lazy?: boolean;
|
||||||
|
onTabChange?: (tabId: string) => void;
|
||||||
|
className?: string;
|
||||||
|
/** Allow the tab bar to be collapsed into a single-line summary. */
|
||||||
|
collapsible?: boolean;
|
||||||
|
/** Persist collapse state under this key (localStorage). */
|
||||||
|
collapseKey?: string;
|
||||||
|
/** Start collapsed when no persisted state exists. */
|
||||||
|
defaultCollapsed?: boolean;
|
||||||
|
/**
|
||||||
|
* Fill the available height (default `true`): the active tab panel becomes a
|
||||||
|
* bounded flex column so a `table`/`editor` Panel inside it can scroll
|
||||||
|
* internally. Set `false` inside a `StackLayout variant="scroll"` page so the
|
||||||
|
* tab content keeps its natural height and the page scrolls instead of
|
||||||
|
* compressing the regions.
|
||||||
|
*/
|
||||||
|
fill?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ViewStack
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type ViewMode = 'list' | 'catalog' | 'detail';
|
||||||
|
|
||||||
|
export interface ViewStackProps {
|
||||||
|
viewParam?: string;
|
||||||
|
entityParam?: string;
|
||||||
|
defaultView?: ViewMode;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViewProps {
|
||||||
|
id: ViewMode;
|
||||||
|
title?: string | ReactNode;
|
||||||
|
backLabel?: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
presentation?: 'inline' | 'modal';
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Panel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type PanelVariant = 'card' | 'table' | 'dashboard' | 'toolbar' | 'editor' | 'wizard';
|
||||||
|
|
||||||
|
export interface PanelProps {
|
||||||
|
variant?: PanelVariant;
|
||||||
|
/** Region title (required). Rendered in the header; use t() for i18n. */
|
||||||
|
title: string | ReactNode;
|
||||||
|
/** Stable, non-i18n region id (required). Used for collapse persistence. */
|
||||||
|
id: string;
|
||||||
|
subtitle?: string | ReactNode;
|
||||||
|
actions?: ReactNode;
|
||||||
|
/** Collapse/expand toggle. Default true (opt-out for chat/editor regions). */
|
||||||
|
collapsible?: boolean;
|
||||||
|
defaultCollapsed?: boolean;
|
||||||
|
/** Explicit persistence key. Defaults to `{pathname}:{id}`. */
|
||||||
|
collapseKey?: string;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
/**
|
||||||
|
* Fill the available height of the parent flex container and let the body
|
||||||
|
* own its scroll. Use when a `card` (or any non-table/editor) Panel is placed
|
||||||
|
* in a bounded region (split pane, StackLayout body) and should grow to fill.
|
||||||
|
*/
|
||||||
|
fill?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// StackLayout
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type StackLayoutVariant = 'table' | 'scroll' | 'form' | 'dashboard';
|
||||||
|
|
||||||
|
export interface StackLayoutProps {
|
||||||
|
variant?: StackLayoutVariant;
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Persistence
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface LayoutPersistenceAdapter {
|
||||||
|
scope: string;
|
||||||
|
load: <T>(key: string) => T | null;
|
||||||
|
save: <T>(key: string, value: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PanelLayout (split tree MVP)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type PanelLayoutDirection = 'horizontal' | 'vertical';
|
||||||
|
|
||||||
|
export interface PanelLayoutPaneConfig {
|
||||||
|
id: string;
|
||||||
|
content: ReactNode;
|
||||||
|
/** Default share in percent (all panes normalized to 100). */
|
||||||
|
defaultSize?: number;
|
||||||
|
minSize?: number;
|
||||||
|
maxSize?: number;
|
||||||
|
collapsible?: boolean;
|
||||||
|
collapseKey?: string;
|
||||||
|
defaultCollapsed?: boolean;
|
||||||
|
/** Collapsed strip size in px. Default: 40 */
|
||||||
|
collapsedSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PanelLayoutProps {
|
||||||
|
persistenceKey: string;
|
||||||
|
direction?: PanelLayoutDirection;
|
||||||
|
panes: PanelLayoutPaneConfig[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
@ -39,6 +39,7 @@ import { usePrompt } from '../../hooks/usePrompt';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import { useSidebar } from '../../layouts/SidebarContext';
|
||||||
import styles from './MandateNavigation.module.css';
|
import styles from './MandateNavigation.module.css';
|
||||||
|
|
||||||
type NavTranslateFn = (key: string, params?: Record<string, string | number>) => string;
|
type NavTranslateFn = (key: string, params?: Record<string, string | number>) => string;
|
||||||
|
|
@ -210,6 +211,7 @@ const EmptyState: React.FC = () => {
|
||||||
|
|
||||||
export const MandateNavigation: React.FC = () => {
|
export const MandateNavigation: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const { collapsed } = useSidebar();
|
||||||
const { blocks, loading, refresh } = useNavigation();
|
const { blocks, loading, refresh } = useNavigation();
|
||||||
const { prompt, PromptDialog } = usePrompt();
|
const { prompt, PromptDialog } = usePrompt();
|
||||||
const { showWarning } = useToast();
|
const { showWarning } = useToast();
|
||||||
|
|
@ -332,6 +334,7 @@ export const MandateNavigation: React.FC = () => {
|
||||||
<TreeNavigation
|
<TreeNavigation
|
||||||
items={navigationItems}
|
items={navigationItems}
|
||||||
autoExpandActive={true}
|
autoExpandActive={true}
|
||||||
|
collapsed={collapsed}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState />
|
<EmptyState />
|
||||||
|
|
|
||||||
|
|
@ -345,3 +345,82 @@
|
||||||
background: var(--primary-color, #2563eb);
|
background: var(--primary-color, #2563eb);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================ */
|
||||||
|
/* COLLAPSED ICON RAIL */
|
||||||
|
/* ============================================ */
|
||||||
|
|
||||||
|
.treeNavigationCollapsed {
|
||||||
|
padding: 0.25rem 0.375rem;
|
||||||
|
gap: 0.25rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsedNavItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-secondary, #64748b);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsedNavItem:hover {
|
||||||
|
background: var(--hover-bg, rgba(0, 0, 0, 0.04));
|
||||||
|
color: var(--text-primary, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsedNavItemActive {
|
||||||
|
background: var(--primary-light, #e0e7ff);
|
||||||
|
color: var(--primary-color, #2563eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsedNavIcon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsedNavLetter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface-color, #f0f0f0);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsedNavItemActive .collapsedNavLetter {
|
||||||
|
background: var(--primary-color, #2563eb);
|
||||||
|
color: var(--text-on-primary, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .collapsedNavItem {
|
||||||
|
color: var(--text-secondary-dark, #aaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .collapsedNavItem:hover {
|
||||||
|
background: var(--hover-bg-dark, rgba(255, 255, 255, 0.06));
|
||||||
|
color: var(--text-primary-dark, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .collapsedNavItemActive {
|
||||||
|
background: var(--primary-dark-bg, #1e3a5f);
|
||||||
|
color: var(--primary-light, #93c5fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .collapsedNavLetter {
|
||||||
|
background: var(--surface-dark, #2a2a2a);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .collapsedNavItemActive .collapsedNavLetter {
|
||||||
|
background: var(--primary-color, #2563eb);
|
||||||
|
color: var(--text-on-primary, #ffffff);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,8 @@ export interface TreeNavigationProps {
|
||||||
items: TreeItem[];
|
items: TreeItem[];
|
||||||
/** Whether to auto-expand nodes when their path is active */
|
/** Whether to auto-expand nodes when their path is active */
|
||||||
autoExpandActive?: boolean;
|
autoExpandActive?: boolean;
|
||||||
|
/** Icon-only rail mode for collapsed sidebar */
|
||||||
|
collapsed?: boolean;
|
||||||
/** Callback when a node is clicked */
|
/** Callback when a node is clicked */
|
||||||
onNodeClick?: (node: TreeNodeItem) => void;
|
onNodeClick?: (node: TreeNodeItem) => void;
|
||||||
/** Maximum depth to render (0 = unlimited) */
|
/** Maximum depth to render (0 = unlimited) */
|
||||||
|
|
@ -122,6 +124,34 @@ function isTreeSeparator(item: TreeItem): item is TreeSeparatorItem {
|
||||||
return 'type' in item && item.type === 'separator';
|
return 'type' in item && item.type === 'separator';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _collectNavLinksFromNodes(nodes: TreeNodeItem[], result: TreeNodeItem[]): void {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.path) {
|
||||||
|
result.push(node);
|
||||||
|
}
|
||||||
|
if (node.children) {
|
||||||
|
_collectNavLinksFromNodes(node.children, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _collectNavLinks(items: TreeItem[]): TreeNodeItem[] {
|
||||||
|
const result: TreeNodeItem[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
if (isTreeSeparator(item)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isTreeSection(item)) {
|
||||||
|
_collectNavLinksFromNodes(item.children, result);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isTreeNode(item)) {
|
||||||
|
_collectNavLinksFromNodes([item], result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// TREE NODE COMPONENT
|
// TREE NODE COMPONENT
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -344,6 +374,45 @@ const TreeSection: React.FC<TreeSectionProps> = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// COLLAPSED ICON RAIL
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface CollapsedNavItemProps {
|
||||||
|
node: TreeNodeItem;
|
||||||
|
currentPath: string;
|
||||||
|
onNodeClick?: (node: TreeNodeItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CollapsedNavItem: React.FC<CollapsedNavItemProps> = ({ node, currentPath, onNodeClick }) => {
|
||||||
|
const isActive = node.path
|
||||||
|
? currentPath === node.path || currentPath.startsWith(`${node.path}/`)
|
||||||
|
: false;
|
||||||
|
const letterFallback = node.label.trim().charAt(0).toLocaleUpperCase() || '?';
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (onNodeClick) {
|
||||||
|
onNodeClick(node);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
to={node.path!}
|
||||||
|
className={`${styles.collapsedNavItem} ${isActive ? styles.collapsedNavItemActive : ''}`}
|
||||||
|
title={node.label}
|
||||||
|
onClick={handleClick}
|
||||||
|
data-id={node.dataId}
|
||||||
|
>
|
||||||
|
{node.icon ? (
|
||||||
|
<span className={styles.collapsedNavIcon}>{node.icon}</span>
|
||||||
|
) : (
|
||||||
|
<span className={styles.collapsedNavLetter}>{letterFallback}</span>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// MAIN COMPONENT
|
// MAIN COMPONENT
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -351,6 +420,7 @@ const TreeSection: React.FC<TreeSectionProps> = ({
|
||||||
export const TreeNavigation: React.FC<TreeNavigationProps> = ({
|
export const TreeNavigation: React.FC<TreeNavigationProps> = ({
|
||||||
items,
|
items,
|
||||||
autoExpandActive = true,
|
autoExpandActive = true,
|
||||||
|
collapsed = false,
|
||||||
onNodeClick,
|
onNodeClick,
|
||||||
maxDepth = 0,
|
maxDepth = 0,
|
||||||
className = '',
|
className = '',
|
||||||
|
|
@ -358,6 +428,22 @@ export const TreeNavigation: React.FC<TreeNavigationProps> = ({
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const currentPath = location.pathname;
|
const currentPath = location.pathname;
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
|
const navLinks = _collectNavLinks(items);
|
||||||
|
return (
|
||||||
|
<nav className={`${styles.treeNavigation} ${styles.treeNavigationCollapsed} ${className}`}>
|
||||||
|
{navLinks.map((node, index) => (
|
||||||
|
<CollapsedNavItem
|
||||||
|
key={node.id || `collapsed-${index}`}
|
||||||
|
node={node}
|
||||||
|
currentPath={currentPath}
|
||||||
|
onNodeClick={onNodeClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className={`${styles.treeNavigation} ${className}`}>
|
<nav className={`${styles.treeNavigation} ${className}`}>
|
||||||
{items.map((item, index) => {
|
{items.map((item, index) => {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,14 @@
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
padding-bottom: max(0.5rem, env(safe-area-inset-bottom));
|
padding-bottom: max(0.5rem, env(safe-area-inset-bottom));
|
||||||
border-top: 1px solid var(--border-color, #e0e0e0);
|
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userSectionCollapsed {
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.5rem 0.25rem;
|
||||||
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Notification Bell */
|
/* Notification Bell */
|
||||||
|
|
@ -36,6 +44,13 @@
|
||||||
background: var(--hover-bg, rgba(0, 0, 0, 0.05));
|
background: var(--hover-bg, rgba(0, 0, 0, 0.05));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.userButtonCollapsed {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.375rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
|
|
@ -82,23 +97,20 @@
|
||||||
|
|
||||||
/* Menu */
|
/* Menu */
|
||||||
.menu {
|
.menu {
|
||||||
position: absolute;
|
|
||||||
bottom: 100%;
|
|
||||||
left: 0.5rem;
|
|
||||||
right: 0.5rem;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
padding: 0.25rem;
|
padding: 0.25rem;
|
||||||
background: var(--bg-primary, #ffffff);
|
background: var(--bg-primary, #ffffff);
|
||||||
border: 1px solid var(--border-color, #e0e0e0);
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
z-index: 100;
|
min-width: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuCollapsed {
|
||||||
|
min-width: 12rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.menu {
|
.menu {
|
||||||
left: 0.25rem;
|
|
||||||
right: 0.25rem;
|
|
||||||
max-height: min(60dvh, 420px);
|
max-height: min(60dvh, 420px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,20 @@
|
||||||
* Zeigt Benutzerinformationen und Logout-Button in der Sidebar.
|
* Zeigt Benutzerinformationen und Logout-Button in der Sidebar.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useCurrentUser } from '../../hooks/useUsers';
|
import { useCurrentUser } from '../../hooks/useUsers';
|
||||||
import { NotificationBell } from '../NotificationBell';
|
import { NotificationBell } from '../NotificationBell';
|
||||||
import { _isOnboardingHidden, _showOnboarding } from '../OnboardingAssistant';
|
import { _isOnboardingHidden, _showOnboarding } from '../OnboardingAssistant';
|
||||||
|
import { FloatingPortal } from '../UiComponents/FloatingPortal';
|
||||||
import styles from './UserSection.module.css';
|
import styles from './UserSection.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import { useSidebar } from '../../layouts/SidebarContext';
|
||||||
|
|
||||||
export const UserSection: React.FC = () => {
|
export const UserSection: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const { collapsed } = useSidebar();
|
||||||
|
|
||||||
const { user, logout } = useCurrentUser();
|
const { user, logout } = useCurrentUser();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -24,6 +27,7 @@ export const UserSection: React.FC = () => {
|
||||||
const [showMenu, setShowMenu] = useState(false);
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
const [showLegalModal, setShowLegalModal] = useState(false);
|
const [showLegalModal, setShowLegalModal] = useState(false);
|
||||||
const [onboardingHidden, setOnboardingHidden] = useState(() => _isOnboardingHidden());
|
const [onboardingHidden, setOnboardingHidden] = useState(() => _isOnboardingHidden());
|
||||||
|
const userButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
setIsLoggingOut(true);
|
setIsLoggingOut(true);
|
||||||
|
|
@ -70,18 +74,23 @@ export const UserSection: React.FC = () => {
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.userSection}>
|
<div className={`${styles.userSection} ${collapsed ? styles.userSectionCollapsed : ''}`}>
|
||||||
{/* Notification Bell */}
|
{!collapsed && (
|
||||||
<NotificationBell className={styles.notificationBell} />
|
<NotificationBell className={styles.notificationBell} />
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={styles.userButton}
|
ref={userButtonRef}
|
||||||
|
className={`${styles.userButton} ${collapsed ? styles.userButtonCollapsed : ''}`}
|
||||||
onClick={() => { setShowMenu(!showMenu); setOnboardingHidden(_isOnboardingHidden()); }}
|
onClick={() => { setShowMenu(!showMenu); setOnboardingHidden(_isOnboardingHidden()); }}
|
||||||
aria-expanded={showMenu}
|
aria-expanded={showMenu}
|
||||||
|
title={collapsed ? (user.fullName || user.username) : undefined}
|
||||||
>
|
>
|
||||||
<div className={styles.avatar}>
|
<div className={styles.avatar}>
|
||||||
{initials}
|
{initials}
|
||||||
</div>
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
|
<>
|
||||||
<div className={styles.userInfo}>
|
<div className={styles.userInfo}>
|
||||||
<span className={styles.userName}>{user.fullName || user.username}</span>
|
<span className={styles.userName}>{user.fullName || user.username}</span>
|
||||||
<span className={styles.userEmail}>{user.email}</span>
|
<span className={styles.userEmail}>{user.email}</span>
|
||||||
|
|
@ -89,10 +98,18 @@ export const UserSection: React.FC = () => {
|
||||||
<span className={styles.chevron}>
|
<span className={styles.chevron}>
|
||||||
{showMenu ? '▲' : '▼'}
|
{showMenu ? '▲' : '▼'}
|
||||||
</span>
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showMenu && (
|
<FloatingPortal
|
||||||
<div className={styles.menu}>
|
open={showMenu}
|
||||||
|
anchorRef={userButtonRef}
|
||||||
|
onClose={() => setShowMenu(false)}
|
||||||
|
placement="top"
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<div className={`${styles.menu} ${collapsed ? styles.menuCollapsed : ''}`}>
|
||||||
<button
|
<button
|
||||||
className={styles.menuItem}
|
className={styles.menuItem}
|
||||||
onClick={handleBilling}
|
onClick={handleBilling}
|
||||||
|
|
@ -138,7 +155,7 @@ export const UserSection: React.FC = () => {
|
||||||
{isLoggingOut ? t('Abmelden...') : t('Abmelden')}
|
{isLoggingOut ? t('Abmelden...') : t('Abmelden')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</FloatingPortal>
|
||||||
|
|
||||||
{/* Legal Modal */}
|
{/* Legal Modal */}
|
||||||
{showLegalModal && (
|
{showLegalModal && (
|
||||||
|
|
|
||||||
|
|
@ -52,16 +52,13 @@
|
||||||
|
|
||||||
/* Dropdown */
|
/* Dropdown */
|
||||||
.dropdown {
|
.dropdown {
|
||||||
position: fixed;
|
|
||||||
bottom: 80px;
|
|
||||||
left: 290px;
|
|
||||||
width: 360px;
|
width: 360px;
|
||||||
max-height: 480px;
|
max-width: min(360px, calc(100vw - 16px));
|
||||||
|
max-height: min(480px, 70vh);
|
||||||
background: var(--card-bg, white);
|
background: var(--card-bg, white);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
z-index: 9999;
|
|
||||||
animation: slideIn 0.2s ease;
|
animation: slideIn 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -367,12 +364,3 @@
|
||||||
background: var(--text-muted, #999);
|
background: var(--text-muted, #999);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
.dropdown {
|
|
||||||
left: 0.75rem;
|
|
||||||
right: 0.75rem;
|
|
||||||
width: auto;
|
|
||||||
bottom: calc(76px + env(safe-area-inset-bottom));
|
|
||||||
max-height: min(70dvh, 520px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,16 @@
|
||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { FaBell, FaCheck, FaTimes, FaEnvelope, FaCog, FaExclamationTriangle, FaCheckCircle } from 'react-icons/fa';
|
import { FaBell, FaCheck, FaTimes, FaEnvelope, FaCog, FaExclamationTriangle, FaCheckCircle } from 'react-icons/fa';
|
||||||
import { useNotifications, UserNotification } from '../../hooks/useNotifications';
|
import { useNotifications, UserNotification } from '../../hooks/useNotifications';
|
||||||
|
import { FloatingPortal } from '../UiComponents/FloatingPortal';
|
||||||
import styles from './NotificationBell.module.css';
|
import styles from './NotificationBell.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
|
||||||
// Icon mapping for notification types
|
|
||||||
const typeIcons: Record<string, React.ReactNode> = {
|
const typeIcons: Record<string, React.ReactNode> = {
|
||||||
invitation: <FaEnvelope />,
|
invitation: <FaEnvelope />,
|
||||||
system: <FaCog />,
|
system: <FaCog />,
|
||||||
workflow: <FaCog />,
|
workflow: <FaCog />,
|
||||||
mention: <FaExclamationTriangle />
|
mention: <FaExclamationTriangle />,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface NotificationBellProps {
|
interface NotificationBellProps {
|
||||||
|
|
@ -28,6 +28,7 @@ interface NotificationBellProps {
|
||||||
|
|
||||||
export const NotificationBell: React.FC<NotificationBellProps> = ({ className }) => {
|
export const NotificationBell: React.FC<NotificationBellProps> = ({ className }) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const bellButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
const formatRelativeTime = useCallback((timestamp: number): string => {
|
const formatRelativeTime = useCallback((timestamp: number): string => {
|
||||||
if (!Number.isFinite(timestamp) || timestamp <= 0) {
|
if (!Number.isFinite(timestamp) || timestamp <= 0) {
|
||||||
|
|
@ -59,46 +60,28 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
|
||||||
executeAction,
|
executeAction,
|
||||||
dismissNotification,
|
dismissNotification,
|
||||||
startPolling,
|
startPolling,
|
||||||
stopPolling
|
stopPolling,
|
||||||
} = useNotifications();
|
} = useNotifications();
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
|
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Start polling on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
startPolling(30000); // Poll every 30 seconds
|
startPolling(30000);
|
||||||
return () => stopPolling();
|
return () => stopPolling();
|
||||||
}, [startPolling, stopPolling]);
|
}, [startPolling, stopPolling]);
|
||||||
|
|
||||||
// Fetch notifications when dropdown opens
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
fetchNotifications({ limit: 10 });
|
fetchNotifications({ limit: 10 });
|
||||||
}
|
}
|
||||||
}, [isOpen, fetchNotifications]);
|
}, [isOpen, fetchNotifications]);
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
|
||||||
useEffect(() => {
|
|
||||||
function handleClickOutside(event: MouseEvent) {
|
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isOpen) {
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
// Handle action button click
|
|
||||||
const handleAction = useCallback(async (
|
const handleAction = useCallback(async (
|
||||||
notification: UserNotification,
|
notification: UserNotification,
|
||||||
actionId: string,
|
actionId: string,
|
||||||
event: React.MouseEvent
|
event: React.MouseEvent,
|
||||||
) => {
|
) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
setActionLoading(`${notification.id}-${actionId}`);
|
setActionLoading(`${notification.id}-${actionId}`);
|
||||||
|
|
@ -109,11 +92,9 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
setActionSuccess(notification.id);
|
setActionSuccess(notification.id);
|
||||||
// Reload sidebar when accepting an invitation (grants new mandate/feature access)
|
|
||||||
if (actionId === 'accept' && notification.referenceType === 'Invitation') {
|
if (actionId === 'accept' && notification.referenceType === 'Invitation') {
|
||||||
window.dispatchEvent(new CustomEvent('features-changed'));
|
window.dispatchEvent(new CustomEvent('features-changed'));
|
||||||
}
|
}
|
||||||
// Clear success state after animation
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setActionSuccess(null);
|
setActionSuccess(null);
|
||||||
fetchNotifications({ limit: 10 });
|
fetchNotifications({ limit: 10 });
|
||||||
|
|
@ -121,31 +102,30 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
|
||||||
}
|
}
|
||||||
}, [executeAction, fetchNotifications]);
|
}, [executeAction, fetchNotifications]);
|
||||||
|
|
||||||
// Handle dismiss
|
|
||||||
const handleDismiss = useCallback(async (
|
const handleDismiss = useCallback(async (
|
||||||
notification: UserNotification,
|
notification: UserNotification,
|
||||||
event: React.MouseEvent
|
event: React.MouseEvent,
|
||||||
) => {
|
) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
await dismissNotification(notification.id);
|
await dismissNotification(notification.id);
|
||||||
}, [dismissNotification]);
|
}, [dismissNotification]);
|
||||||
|
|
||||||
// Handle notification click (mark as read)
|
|
||||||
const handleNotificationClick = useCallback(async (notification: UserNotification) => {
|
const handleNotificationClick = useCallback(async (notification: UserNotification) => {
|
||||||
if (notification.status === 'unread') {
|
if (notification.status === 'unread') {
|
||||||
await markAsRead(notification.id);
|
await markAsRead(notification.id);
|
||||||
}
|
}
|
||||||
}, [markAsRead]);
|
}, [markAsRead]);
|
||||||
|
|
||||||
// Filter out dismissed notifications
|
|
||||||
const visibleNotifications = notifications.filter(n => n.status !== 'dismissed');
|
const visibleNotifications = notifications.filter(n => n.status !== 'dismissed');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.notificationBell} ${className || ''}`} ref={dropdownRef}>
|
<div className={`${styles.notificationBell} ${className || ''}`}>
|
||||||
{/* Bell Button */}
|
|
||||||
<button
|
<button
|
||||||
|
ref={bellButtonRef}
|
||||||
|
type="button"
|
||||||
className={styles.bellButton}
|
className={styles.bellButton}
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(v => !v)}
|
||||||
|
aria-expanded={isOpen}
|
||||||
aria-label={unreadCount > 0 ? t('Benachrichtigungen ({count} ungelesen)', { count: String(unreadCount) }) : t('Benachrichtigungen')}
|
aria-label={unreadCount > 0 ? t('Benachrichtigungen ({count} ungelesen)', { count: String(unreadCount) }) : t('Benachrichtigungen')}
|
||||||
>
|
>
|
||||||
<FaBell className={styles.bellIcon} />
|
<FaBell className={styles.bellIcon} />
|
||||||
|
|
@ -156,14 +136,19 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Dropdown */}
|
<FloatingPortal
|
||||||
{isOpen && (
|
open={isOpen}
|
||||||
|
anchorRef={bellButtonRef}
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
|
placement="auto"
|
||||||
|
align="end"
|
||||||
|
>
|
||||||
<div className={styles.dropdown}>
|
<div className={styles.dropdown}>
|
||||||
{/* Header */}
|
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<h3>{t('Benachrichtigungen')}</h3>
|
<h3>{t('Benachrichtigungen')}</h3>
|
||||||
{visibleNotifications.some(n => n.status === 'unread') && (
|
{visibleNotifications.some(n => n.status === 'unread') && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className={styles.markAllRead}
|
className={styles.markAllRead}
|
||||||
onClick={() => markAllAsRead()}
|
onClick={() => markAllAsRead()}
|
||||||
>
|
>
|
||||||
|
|
@ -172,7 +157,6 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
{loading && visibleNotifications.length === 0 && (
|
{loading && visibleNotifications.length === 0 && (
|
||||||
<div className={styles.loading}>{t('Lade')}</div>
|
<div className={styles.loading}>{t('Lade')}</div>
|
||||||
|
|
@ -199,7 +183,6 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
|
||||||
`}
|
`}
|
||||||
onClick={() => handleNotificationClick(notification)}
|
onClick={() => handleNotificationClick(notification)}
|
||||||
>
|
>
|
||||||
{/* Success overlay */}
|
|
||||||
{actionSuccess === notification.id && (
|
{actionSuccess === notification.id && (
|
||||||
<div className={styles.successOverlay}>
|
<div className={styles.successOverlay}>
|
||||||
<FaCheckCircle />
|
<FaCheckCircle />
|
||||||
|
|
@ -207,23 +190,21 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Icon */}
|
|
||||||
<div className={`${styles.icon} ${styles[`icon_${notification.type}`]}`}>
|
<div className={`${styles.icon} ${styles[`icon_${notification.type}`]}`}>
|
||||||
{typeIcons[notification.type] || <FaBell />}
|
{typeIcons[notification.type] || <FaBell />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className={styles.notificationContent}>
|
<div className={styles.notificationContent}>
|
||||||
<div className={styles.title}>{notification.title}</div>
|
<div className={styles.title}>{notification.title}</div>
|
||||||
<div className={styles.message}>{notification.message}</div>
|
<div className={styles.message}>{notification.message}</div>
|
||||||
<div className={styles.time}>{formatRelativeTime(notification.createdAt)}</div>
|
<div className={styles.time}>{formatRelativeTime(notification.createdAt)}</div>
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
{notification.actions && notification.status !== 'actioned' && (
|
{notification.actions && notification.status !== 'actioned' && (
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
{notification.actions.map(action => (
|
{notification.actions.map(action => (
|
||||||
<button
|
<button
|
||||||
key={action.actionId}
|
key={action.actionId}
|
||||||
|
type="button"
|
||||||
className={`${styles.actionButton} ${styles[`action_${action.style}`]}`}
|
className={`${styles.actionButton} ${styles[`action_${action.style}`]}`}
|
||||||
onClick={(e) => handleAction(notification, action.actionId, e)}
|
onClick={(e) => handleAction(notification, action.actionId, e)}
|
||||||
disabled={actionLoading === `${notification.id}-${action.actionId}`}
|
disabled={actionLoading === `${notification.id}-${action.actionId}`}
|
||||||
|
|
@ -242,7 +223,6 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action result */}
|
|
||||||
{notification.actionTaken && (
|
{notification.actionTaken && (
|
||||||
<div className={styles.actionResult}>
|
<div className={styles.actionResult}>
|
||||||
{notification.actionResult}
|
{notification.actionResult}
|
||||||
|
|
@ -250,9 +230,9 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dismiss button */}
|
|
||||||
{notification.status !== 'actioned' && (
|
{notification.status !== 'actioned' && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className={styles.dismissButton}
|
className={styles.dismissButton}
|
||||||
onClick={(e) => handleDismiss(notification, e)}
|
onClick={(e) => handleDismiss(notification, e)}
|
||||||
aria-label={t('Schließen')}
|
aria-label={t('Schließen')}
|
||||||
|
|
@ -264,7 +244,7 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</FloatingPortal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -51,10 +51,6 @@
|
||||||
/* ---------- Popover ---------- */
|
/* ---------- Popover ---------- */
|
||||||
|
|
||||||
.popover {
|
.popover {
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 6px);
|
|
||||||
left: 0;
|
|
||||||
z-index: 1000;
|
|
||||||
background: var(--bg-primary, #ffffff);
|
background: var(--bg-primary, #ffffff);
|
||||||
color: var(--text-primary, #1A202C);
|
color: var(--text-primary, #1A202C);
|
||||||
border: 1px solid var(--border-color, #E2E8F0);
|
border: 1px solid var(--border-color, #E2E8F0);
|
||||||
|
|
@ -67,11 +63,6 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.popover.alignRight {
|
|
||||||
left: auto;
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.body {
|
.body {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 200px 240px 1fr;
|
grid-template-columns: 200px 240px 1fr;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import { FloatingPortal } from '../UiComponents/FloatingPortal';
|
||||||
import PeriodPickerPopover from './PeriodPickerPopover';
|
import PeriodPickerPopover from './PeriodPickerPopover';
|
||||||
import {
|
import {
|
||||||
formatIsoDateDe,
|
formatIsoDateDe,
|
||||||
|
|
@ -108,20 +109,7 @@ export const PeriodPicker: React.FC<PeriodPickerProps> = (props) => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const wrapRef = useRef<HTMLDivElement>(null);
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
// Outside click via mousedown (see file header).
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const _onDown = (e: MouseEvent) => {
|
|
||||||
const target = e.target as HTMLElement | null;
|
|
||||||
if (!target) return;
|
|
||||||
if (wrapRef.current && wrapRef.current.contains(target)) return;
|
|
||||||
setOpen(false);
|
|
||||||
};
|
|
||||||
window.addEventListener('mousedown', _onDown);
|
|
||||||
return () => window.removeEventListener('mousedown', _onDown);
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const _initialDraft: PeriodValue = useMemo(() => {
|
const _initialDraft: PeriodValue = useMemo(() => {
|
||||||
if (resolvedValue) return resolvedValue;
|
if (resolvedValue) return resolvedValue;
|
||||||
|
|
@ -155,8 +143,9 @@ export const PeriodPicker: React.FC<PeriodPickerProps> = (props) => {
|
||||||
if (open) triggerCls.push(styles.open);
|
if (open) triggerCls.push(styles.open);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={wrapRef} className={`${styles.wrapper}${className ? ` ${className}` : ''}`}>
|
<div className={`${styles.wrapper}${className ? ` ${className}` : ''}`}>
|
||||||
<button
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
className={triggerCls.join(' ')}
|
className={triggerCls.join(' ')}
|
||||||
onClick={() => setOpen((o) => !o)}
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
|
@ -169,14 +158,19 @@ export const PeriodPicker: React.FC<PeriodPickerProps> = (props) => {
|
||||||
<span className={styles.triggerChev} aria-hidden>▾</span>
|
<span className={styles.triggerChev} aria-hidden>▾</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
<FloatingPortal
|
||||||
|
open={open}
|
||||||
|
anchorRef={triggerRef}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
placement="auto"
|
||||||
|
>
|
||||||
<PeriodPickerPopover
|
<PeriodPickerPopover
|
||||||
initialValue={_initialDraft}
|
initialValue={_initialDraft}
|
||||||
constraints={constraints}
|
constraints={constraints}
|
||||||
onApply={_handleApply}
|
onApply={_handleApply}
|
||||||
onCancel={_handleCancel}
|
onCancel={_handleCancel}
|
||||||
/>
|
/>
|
||||||
)}
|
</FloatingPortal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
* actual commit to the parent via `onApply` / `onCancel`.
|
* actual commit to the parent via `onApply` / `onCancel`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
import PeriodPickerCalendar from './PeriodPickerCalendar';
|
import PeriodPickerCalendar from './PeriodPickerCalendar';
|
||||||
import {
|
import {
|
||||||
|
|
@ -192,7 +192,6 @@ const PeriodPickerPopover: React.FC<PeriodPickerPopoverProps> = (props) => {
|
||||||
const footerMax = clampIsoDate(undefined, constraints, 'max');
|
const footerMax = clampIsoDate(undefined, constraints, 'max');
|
||||||
|
|
||||||
// Keyboard: Esc cancels, Enter applies
|
// Keyboard: Esc cancels, Enter applies
|
||||||
const popRef = useRef<HTMLDivElement>(null);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const _onKey = (e: KeyboardEvent) => {
|
const _onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') { e.preventDefault(); onCancel(); }
|
if (e.key === 'Escape') { e.preventDefault(); onCancel(); }
|
||||||
|
|
@ -202,38 +201,8 @@ const PeriodPickerPopover: React.FC<PeriodPickerPopoverProps> = (props) => {
|
||||||
return () => window.removeEventListener('keydown', _onKey);
|
return () => window.removeEventListener('keydown', _onKey);
|
||||||
}, [draft, onApply, onCancel]);
|
}, [draft, onApply, onCancel]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const pop = popRef.current;
|
|
||||||
if (!pop) return;
|
|
||||||
const _clamp = () => {
|
|
||||||
const parent = pop.parentElement;
|
|
||||||
if (!parent) return;
|
|
||||||
const pRect = parent.getBoundingClientRect();
|
|
||||||
const margin = 8;
|
|
||||||
const popW = pop.offsetWidth || 720;
|
|
||||||
const popH = pop.offsetHeight || 400;
|
|
||||||
let left = pRect.left;
|
|
||||||
let top = pRect.bottom + 6;
|
|
||||||
if (left + popW > window.innerWidth - margin) {
|
|
||||||
left = window.innerWidth - margin - popW;
|
|
||||||
}
|
|
||||||
if (left < margin) left = margin;
|
|
||||||
if (top + popH > window.innerHeight - margin) {
|
|
||||||
top = Math.max(margin, pRect.top - 6 - popH);
|
|
||||||
}
|
|
||||||
pop.style.position = 'fixed';
|
|
||||||
pop.style.left = `${left}px`;
|
|
||||||
pop.style.top = `${top}px`;
|
|
||||||
pop.style.right = 'auto';
|
|
||||||
pop.style.zIndex = '2001';
|
|
||||||
};
|
|
||||||
_clamp();
|
|
||||||
const id = requestAnimationFrame(() => _clamp());
|
|
||||||
return () => cancelAnimationFrame(id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={popRef} className={styles.popover}>
|
<div className={styles.popover}>
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
{/* Column 1: Presets */}
|
{/* Column 1: Presets */}
|
||||||
<div className={styles.colPresets}>
|
<div className={styles.colPresets}>
|
||||||
|
|
|
||||||
|
|
@ -75,18 +75,13 @@
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dropdown Content - opens upward */
|
/* Dropdown content — positioned by FloatingPortal */
|
||||||
.dropdownContent {
|
.dropdownContent {
|
||||||
position: absolute;
|
|
||||||
bottom: calc(100% + 4px);
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
z-index: 1000;
|
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
background: var(--surface-color, #ffffff);
|
background: var(--surface-color, #ffffff);
|
||||||
border: 1px solid var(--border-color, #e0e0e0);
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.12);
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12);
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,9 @@
|
||||||
* resolveProviders(selection, allowedProviders) liefert die konkrete Liste.
|
* resolveProviders(selection, allowedProviders) liefert die konkrete Liste.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react';
|
import React, { useEffect, useMemo, useState, useRef } from 'react';
|
||||||
import { useBilling } from '../../hooks/useBilling';
|
import { useBilling } from '../../hooks/useBilling';
|
||||||
|
import { FloatingPortal } from '../UiComponents/FloatingPortal';
|
||||||
import styles from './ProviderSelector.module.css';
|
import styles from './ProviderSelector.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
|
@ -190,7 +191,7 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
||||||
const resolvedLabel = label ?? t('AI-Provider');
|
const resolvedLabel = label ?? t('AI-Provider');
|
||||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||||
const [initialExcludeApplied, setInitialExcludeApplied] = useState(false);
|
const [initialExcludeApplied, setInitialExcludeApplied] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const { allowedProviders, loadAllowedProviders, loading } = useBilling();
|
const { allowedProviders, loadAllowedProviders, loading } = useBilling();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -212,19 +213,6 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
||||||
}
|
}
|
||||||
}, [allowedProviders, excludeByDefault, initialExcludeApplied, selection, onChange]);
|
}, [allowedProviders, excludeByDefault, initialExcludeApplied, selection, onChange]);
|
||||||
|
|
||||||
const _handleClickOutside = useCallback((event: MouseEvent) => {
|
|
||||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
||||||
setIsExpanded(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isExpanded) {
|
|
||||||
document.addEventListener('mousedown', _handleClickOutside);
|
|
||||||
return () => document.removeEventListener('mousedown', _handleClickOutside);
|
|
||||||
}
|
|
||||||
}, [isExpanded, _handleClickOutside]);
|
|
||||||
|
|
||||||
const effectiveSelection = useMemo(
|
const effectiveSelection = useMemo(
|
||||||
() => _resolveProviders(selection, allowedProviders),
|
() => _resolveProviders(selection, allowedProviders),
|
||||||
[selection, allowedProviders],
|
[selection, allowedProviders],
|
||||||
|
|
@ -282,20 +270,27 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
|
||||||
className={`${styles.providerMultiSelect} ${className || ''} ${isExpanded ? styles.expanded : styles.collapsed}`}
|
className={`${styles.providerMultiSelect} ${className || ''} ${isExpanded ? styles.expanded : styles.collapsed}`}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.triggerButton}
|
className={styles.triggerButton}
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
title={t('Provider auswählen')}
|
title={t('Provider auswählen')}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
>
|
>
|
||||||
<span className={styles.buttonIcon}>{summaryIcon}</span>
|
<span className={styles.buttonIcon}>{summaryIcon}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isExpanded && (
|
<FloatingPortal
|
||||||
|
open={isExpanded}
|
||||||
|
anchorRef={triggerRef}
|
||||||
|
onClose={() => setIsExpanded(false)}
|
||||||
|
placement="top"
|
||||||
|
align="center"
|
||||||
|
>
|
||||||
<div className={styles.dropdownContent}>
|
<div className={styles.dropdownContent}>
|
||||||
{showLabel && <div className={styles.dropdownHeader}>{resolvedLabel}</div>}
|
{showLabel && <div className={styles.dropdownHeader}>{resolvedLabel}</div>}
|
||||||
|
|
||||||
|
|
@ -336,7 +331,7 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
||||||
|
|
||||||
<div className={styles.hint}>{summaryHint}</div>
|
<div className={styles.hint}>{summaryHint}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</FloatingPortal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -65,10 +65,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown {
|
.dropdown {
|
||||||
position: absolute;
|
|
||||||
bottom: 100%;
|
|
||||||
right: 0;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
background: var(--card-bg, #fff);
|
background: var(--card-bg, #fff);
|
||||||
border: 1px solid var(--border-color, #e0e0e0);
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
import { useApiRequest } from '../../hooks/useApi';
|
import { useApiRequest } from '../../hooks/useApi';
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import { FloatingPortal } from '../UiComponents/FloatingPortal';
|
||||||
import styles from './RagRunningBadge.module.css';
|
import styles from './RagRunningBadge.module.css';
|
||||||
|
|
||||||
interface _RagJob {
|
interface _RagJob {
|
||||||
|
|
@ -25,6 +26,7 @@ export const RagRunningBadge: React.FC = () => {
|
||||||
const [jobs, setJobs] = useState<_RagJob[]>([]);
|
const [jobs, setJobs] = useState<_RagJob[]>([]);
|
||||||
const [justFinished, setJustFinished] = useState(false);
|
const [justFinished, setJustFinished] = useState(false);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const badgeButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const previousJobCount = useRef(0);
|
const previousJobCount = useRef(0);
|
||||||
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
@ -79,6 +81,7 @@ export const RagRunningBadge: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<div className={styles.badgeContainer}>
|
<div className={styles.badgeContainer}>
|
||||||
<button
|
<button
|
||||||
|
ref={badgeButtonRef}
|
||||||
className={styles.badge}
|
className={styles.badge}
|
||||||
onClick={() => setExpanded(prev => !prev)}
|
onClick={() => setExpanded(prev => !prev)}
|
||||||
title={t('RAG-Indexierung aktiv')}
|
title={t('RAG-Indexierung aktiv')}
|
||||||
|
|
@ -89,7 +92,13 @@ export const RagRunningBadge: React.FC = () => {
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{expanded && (
|
<FloatingPortal
|
||||||
|
open={expanded}
|
||||||
|
anchorRef={badgeButtonRef}
|
||||||
|
onClose={() => setExpanded(false)}
|
||||||
|
placement="top"
|
||||||
|
align="end"
|
||||||
|
>
|
||||||
<div className={styles.dropdown}>
|
<div className={styles.dropdown}>
|
||||||
<div className={styles.dropdownHeader}>
|
<div className={styles.dropdownHeader}>
|
||||||
{t('Aktive RAG-Jobs')}
|
{t('Aktive RAG-Jobs')}
|
||||||
|
|
@ -103,7 +112,7 @@ export const RagRunningBadge: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</FloatingPortal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestionsWrapper {
|
.suggestionsWrapper {
|
||||||
position: absolute;
|
width: 100%;
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
margin-top: 4px;
|
|
||||||
z-index: 1000;
|
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import TextField from '../TextField/TextField';
|
import TextField from '../TextField/TextField';
|
||||||
import { BaseTextFieldProps } from '../TextField/TextFieldTypes';
|
import { BaseTextFieldProps } from '../TextField/TextFieldTypes';
|
||||||
import { autocompleteAddress, AddressSuggestion } from '../../../api/realEstateApi';
|
import { autocompleteAddress, AddressSuggestion } from '../../../api/realEstateApi';
|
||||||
|
import { FloatingPortal } from '../FloatingPortal';
|
||||||
import styles from './AddressAutocomplete.module.css';
|
import styles from './AddressAutocomplete.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
|
@ -45,7 +46,7 @@ const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({ value = '',
|
||||||
const [query, setQuery] = useState(value);
|
const [query, setQuery] = useState(value);
|
||||||
const [autocompleteError, setAutocompleteError] = useState<string | null>(null);
|
const [autocompleteError, setAutocompleteError] = useState<string | null>(null);
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const anchorRef = useRef<HTMLDivElement>(null);
|
||||||
const suggestionsRef = useRef<HTMLUListElement>(null);
|
const suggestionsRef = useRef<HTMLUListElement>(null);
|
||||||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
|
@ -194,23 +195,6 @@ const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({ value = '',
|
||||||
}
|
}
|
||||||
}, [showSuggestions, suggestions, selectedIndex, handleSelectSuggestion, onKeyDown]);
|
}, [showSuggestions, suggestions, selectedIndex, handleSelectSuggestion, onKeyDown]);
|
||||||
|
|
||||||
// Click outside handler
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
||||||
setShowSuggestions(false);
|
|
||||||
setSelectedIndex(-1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (showSuggestions) {
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [showSuggestions]);
|
|
||||||
|
|
||||||
// Scroll selected item into view
|
// Scroll selected item into view
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedIndex >= 0 && suggestionsRef.current) {
|
if (selectedIndex >= 0 && suggestionsRef.current) {
|
||||||
|
|
@ -254,7 +238,7 @@ const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({ value = '',
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className={`${styles.autocompleteContainer} ${className}`}>
|
<div ref={anchorRef} className={`${styles.autocompleteContainer} ${className}`}>
|
||||||
<TextField
|
<TextField
|
||||||
value={query}
|
value={query}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
|
|
@ -273,7 +257,15 @@ const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({ value = '',
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showSuggestions && (
|
<FloatingPortal
|
||||||
|
open={showSuggestions}
|
||||||
|
anchorRef={anchorRef}
|
||||||
|
onClose={() => {
|
||||||
|
setShowSuggestions(false);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
}}
|
||||||
|
placement="bottom"
|
||||||
|
>
|
||||||
<div className={styles.suggestionsWrapper}>
|
<div className={styles.suggestionsWrapper}>
|
||||||
<ul ref={suggestionsRef} className={styles.suggestionsList}>
|
<ul ref={suggestionsRef} className={styles.suggestionsList}>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
|
|
@ -307,7 +299,7 @@ const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({ value = '',
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</FloatingPortal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -161,17 +161,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdownMenu {
|
.dropdownMenu {
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 4px);
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
background-color: var(--color-bg);
|
background-color: var(--color-bg);
|
||||||
border: 1px solid var(--color-border, #E2E8F0);
|
border: 1px solid var(--color-border, #E2E8F0);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
z-index: 1000;
|
min-width: 180px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdownHeader {
|
.dropdownHeader {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
// Copyright (c) 2026 PowerOn AG
|
// Copyright (c) 2026 PowerOn AG
|
||||||
// All rights reserved.
|
// All rights reserved.
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import { IconType } from 'react-icons';
|
import { IconType } from 'react-icons';
|
||||||
import { IoChevronDown, IoClose } from 'react-icons/io5';
|
import { IoChevronDown, IoClose } from 'react-icons/io5';
|
||||||
|
import { FloatingPortal } from '../FloatingPortal';
|
||||||
import styles from './DropdownSelect.module.css';
|
import styles from './DropdownSelect.module.css';
|
||||||
import { ButtonVariant, ButtonSize } from '../Button/ButtonTypes';
|
import { ButtonVariant, ButtonSize } from '../Button/ButtonTypes';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
|
@ -61,24 +62,7 @@ function DropdownSelect<T = any>({
|
||||||
const resolvedPlaceholder = placeholder ?? t('Element auswählen');
|
const resolvedPlaceholder = placeholder ?? t('Element auswählen');
|
||||||
const resolvedEmptyMessage = emptyMessage ?? t('Keine Einträge verfügbar');
|
const resolvedEmptyMessage = emptyMessage ?? t('Keine Einträge verfügbar');
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isOpen) {
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
};
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
// Find selected item
|
// Find selected item
|
||||||
const selectedItem = selectedItemId !== null && selectedItemId !== undefined
|
const selectedItem = selectedItemId !== null && selectedItemId !== undefined
|
||||||
|
|
@ -174,8 +158,7 @@ function DropdownSelect<T = any>({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={dropdownRef} className={styles.dropdownContainer} style={{ minWidth }}>
|
<div className={styles.dropdownContainer} style={{ minWidth }}>
|
||||||
{/* Show clear button if item is selected and showClearButton is enabled */}
|
|
||||||
{selectedItem && showClearButton ? (
|
{selectedItem && showClearButton ? (
|
||||||
renderClearButtonContent()
|
renderClearButtonContent()
|
||||||
) : renderButton ? (
|
) : renderButton ? (
|
||||||
|
|
@ -184,17 +167,25 @@ function DropdownSelect<T = any>({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
className={buttonClasses}
|
className={buttonClasses}
|
||||||
onClick={toggleDropdown}
|
onClick={toggleDropdown}
|
||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
|
aria-expanded={isOpen}
|
||||||
>
|
>
|
||||||
{renderDefaultButton()}
|
{renderDefaultButton()}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isOpen && (
|
<FloatingPortal
|
||||||
<div className={styles.dropdownMenu} style={{ maxHeight }}>
|
open={isOpen}
|
||||||
|
anchorRef={triggerRef}
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
|
placement="bottom"
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<div className={styles.dropdownMenu} style={{ maxHeight, minWidth }}>
|
||||||
{headerText && (
|
{headerText && (
|
||||||
<div className={styles.dropdownHeader}>
|
<div className={styles.dropdownHeader}>
|
||||||
{headerText}
|
{headerText}
|
||||||
|
|
@ -237,7 +228,7 @@ function DropdownSelect<T = any>({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</FloatingPortal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
.layer {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 3000;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
168
src/components/UiComponents/FloatingPortal/FloatingPortal.tsx
Normal file
168
src/components/UiComponents/FloatingPortal/FloatingPortal.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
|
/**
|
||||||
|
* FloatingPortal — renders floating UI on document.body with fixed positioning
|
||||||
|
* relative to an anchor element. Escapes ancestor overflow clipping.
|
||||||
|
*/
|
||||||
|
import React, { useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import styles from './FloatingPortal.module.css';
|
||||||
|
|
||||||
|
export type FloatingPlacement = 'top' | 'bottom' | 'auto';
|
||||||
|
|
||||||
|
export interface FloatingPortalProps {
|
||||||
|
open: boolean;
|
||||||
|
anchorRef: React.RefObject<HTMLElement | null>;
|
||||||
|
onClose?: () => void;
|
||||||
|
placement?: FloatingPlacement;
|
||||||
|
offset?: number;
|
||||||
|
align?: 'start' | 'center' | 'end';
|
||||||
|
/** Keep children mounted while closed (avoids reload lag on reopen). */
|
||||||
|
keepMounted?: boolean;
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FloatingCoords {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
minWidth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _resolvePlacement(
|
||||||
|
preferred: FloatingPlacement,
|
||||||
|
anchorRect: DOMRect,
|
||||||
|
popRect: { width: number; height: number },
|
||||||
|
offset: number,
|
||||||
|
): 'top' | 'bottom' {
|
||||||
|
if (preferred === 'top' || preferred === 'bottom') return preferred;
|
||||||
|
const spaceBelow = window.innerHeight - anchorRect.bottom - offset;
|
||||||
|
const spaceAbove = anchorRect.top - offset;
|
||||||
|
if (spaceBelow >= popRect.height) return 'bottom';
|
||||||
|
if (spaceAbove >= popRect.height) return 'top';
|
||||||
|
return spaceBelow >= spaceAbove ? 'bottom' : 'top';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _computeCoords(
|
||||||
|
anchor: HTMLElement,
|
||||||
|
popEl: HTMLElement,
|
||||||
|
placement: FloatingPlacement,
|
||||||
|
offset: number,
|
||||||
|
align: 'start' | 'center' | 'end',
|
||||||
|
): FloatingCoords {
|
||||||
|
const margin = 8;
|
||||||
|
const anchorRect = anchor.getBoundingClientRect();
|
||||||
|
const popW = popEl.offsetWidth || 220;
|
||||||
|
const popH = popEl.offsetHeight || 200;
|
||||||
|
const resolved = _resolvePlacement(placement, anchorRect, { width: popW, height: popH }, offset);
|
||||||
|
|
||||||
|
let top = resolved === 'bottom'
|
||||||
|
? anchorRect.bottom + offset
|
||||||
|
: anchorRect.top - offset - popH;
|
||||||
|
|
||||||
|
let left = anchorRect.left;
|
||||||
|
if (align === 'center') {
|
||||||
|
left = anchorRect.left + anchorRect.width / 2 - popW / 2;
|
||||||
|
} else if (align === 'end') {
|
||||||
|
left = anchorRect.right - popW;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left + popW > window.innerWidth - margin) {
|
||||||
|
left = window.innerWidth - margin - popW;
|
||||||
|
}
|
||||||
|
if (left < margin) left = margin;
|
||||||
|
|
||||||
|
if (top + popH > window.innerHeight - margin) {
|
||||||
|
top = Math.max(margin, window.innerHeight - margin - popH);
|
||||||
|
}
|
||||||
|
if (top < margin) top = margin;
|
||||||
|
|
||||||
|
return {
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
minWidth: Math.max(anchorRect.width, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FloatingPortal: React.FC<FloatingPortalProps> = ({
|
||||||
|
open,
|
||||||
|
anchorRef,
|
||||||
|
onClose,
|
||||||
|
placement = 'auto',
|
||||||
|
offset = 4,
|
||||||
|
align = 'start',
|
||||||
|
keepMounted = false,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const layerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [coords, setCoords] = useState<FloatingCoords | null>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!open && !keepMounted) {
|
||||||
|
setCoords(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!open) return;
|
||||||
|
const anchor = anchorRef.current;
|
||||||
|
const layer = layerRef.current;
|
||||||
|
if (!anchor || !layer) return;
|
||||||
|
|
||||||
|
const _update = () => {
|
||||||
|
const next = _computeCoords(anchor, layer, placement, offset, align);
|
||||||
|
setCoords(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
_update();
|
||||||
|
const rafId = requestAnimationFrame(_update);
|
||||||
|
window.addEventListener('resize', _update);
|
||||||
|
window.addEventListener('scroll', _update, true);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
window.removeEventListener('resize', _update);
|
||||||
|
window.removeEventListener('scroll', _update, true);
|
||||||
|
};
|
||||||
|
}, [open, keepMounted, anchorRef, placement, offset, align, children]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!open || !onClose) return;
|
||||||
|
const _onPointerDown = (event: MouseEvent) => {
|
||||||
|
const layer = layerRef.current;
|
||||||
|
const anchor = anchorRef.current;
|
||||||
|
const target = event.target as Node;
|
||||||
|
if (layer?.contains(target) || anchor?.contains(target)) return;
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', _onPointerDown);
|
||||||
|
return () => document.removeEventListener('mousedown', _onPointerDown);
|
||||||
|
}, [open, onClose, anchorRef]);
|
||||||
|
|
||||||
|
if (!open && !keepMounted) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
ref={layerRef}
|
||||||
|
className={[styles.layer, className].filter(Boolean).join(' ')}
|
||||||
|
style={!open ? {
|
||||||
|
top: -9999,
|
||||||
|
left: -9999,
|
||||||
|
visibility: 'hidden',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
} : coords ? {
|
||||||
|
top: coords.top,
|
||||||
|
left: coords.left,
|
||||||
|
minWidth: coords.minWidth,
|
||||||
|
visibility: 'visible',
|
||||||
|
} : {
|
||||||
|
top: -9999,
|
||||||
|
left: -9999,
|
||||||
|
visibility: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FloatingPortal;
|
||||||
4
src/components/UiComponents/FloatingPortal/index.ts
Normal file
4
src/components/UiComponents/FloatingPortal/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
|
export { FloatingPortal, default } from './FloatingPortal';
|
||||||
|
export type { FloatingPortalProps, FloatingPlacement } from './FloatingPortal';
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
.tabsContainer {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabsHeader {
|
|
||||||
display: flex;
|
|
||||||
gap: 0;
|
|
||||||
border-bottom: 2px solid var(--color-border, #e0e0e0);
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabButton {
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text, #666);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
margin-bottom: -2px;
|
|
||||||
font-family: var(--font-family);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabButton:hover {
|
|
||||||
color: var(--color-text, #333);
|
|
||||||
background: var(--color-bg-hover, rgba(0, 0, 0, 0.02));
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabButtonActive {
|
|
||||||
color: var(--color-secondary, #007bff);
|
|
||||||
border-bottom-color: var(--color-primary, #007bff);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabsContent {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
// Copyright (c) 2026 PowerOn AG
|
|
||||||
// All rights reserved.
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import styles from './Tabs.module.css';
|
|
||||||
|
|
||||||
export interface Tab {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
content: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TabsProps {
|
|
||||||
tabs: Tab[];
|
|
||||||
defaultTabId?: string;
|
|
||||||
/** Controlled active tab. When provided, internal state is ignored. */
|
|
||||||
activeTabId?: string;
|
|
||||||
onTabChange?: (tabId: string) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Tabs({ tabs, defaultTabId, activeTabId: controlledTabId, onTabChange, className = '' }: TabsProps) {
|
|
||||||
const [internalTabId, setInternalTabId] = useState<string>(
|
|
||||||
defaultTabId || tabs[0]?.id || ''
|
|
||||||
);
|
|
||||||
|
|
||||||
const activeTabId = controlledTabId ?? internalTabId;
|
|
||||||
|
|
||||||
const handleTabClick = (tabId: string) => {
|
|
||||||
if (!controlledTabId) setInternalTabId(tabId);
|
|
||||||
onTabChange?.(tabId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const activeTab = tabs.find(tab => tab.id === activeTabId) || tabs[0];
|
|
||||||
|
|
||||||
if (!tabs || tabs.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`${styles.tabsContainer} ${className}`}>
|
|
||||||
<div className={styles.tabsHeader}>
|
|
||||||
{tabs.map(tab => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
className={`${styles.tabButton} ${activeTabId === tab.id ? styles.tabButtonActive : ''}`}
|
|
||||||
onClick={() => handleTabClick(tab.id)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className={styles.tabsContent}>
|
|
||||||
{activeTab && activeTab.content}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Tabs;
|
|
||||||
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
// Copyright (c) 2026 PowerOn AG
|
|
||||||
// All rights reserved.
|
|
||||||
export { Tabs, default } from './Tabs';
|
|
||||||
export type { TabsProps, Tab } from './Tabs';
|
|
||||||
|
|
||||||
|
|
@ -19,9 +19,8 @@ export type { LogMessageProps } from './Log/LogMessage';
|
||||||
export { WorkflowStatus } from './WorkflowStatus';
|
export { WorkflowStatus } from './WorkflowStatus';
|
||||||
export type { WorkflowStatusProps } from './WorkflowStatus/WorkflowStatusTypes';
|
export type { WorkflowStatusProps } from './WorkflowStatus/WorkflowStatusTypes';
|
||||||
export * from './AutoScroll';
|
export * from './AutoScroll';
|
||||||
export * from './Tabs';
|
|
||||||
export type { TabsProps, Tab } from './Tabs';
|
|
||||||
export * from './AccordionList';
|
export * from './AccordionList';
|
||||||
export * from './Toast';
|
export * from './Toast';
|
||||||
export * from './VoiceLanguageSelect';
|
export * from './VoiceLanguageSelect';
|
||||||
export * from './Modal';
|
export * from './Modal';
|
||||||
|
export * from './FloatingPortal';
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
.chatsTab {
|
.chatsTab {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,6 +102,9 @@
|
||||||
.tree {
|
.tree {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatItem {
|
.chatItem {
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ type ChatFilter = 'active' | 'archived';
|
||||||
|
|
||||||
interface ChatsTabProps {
|
interface ChatsTabProps {
|
||||||
context: UdbContext;
|
context: UdbContext;
|
||||||
onSelectChat?: (chatId: string, featureInstanceId: string) => void;
|
onSelectChat?: (chatId: string, featureInstanceId: string, label?: string) => void;
|
||||||
onDragStart?: (chatId: string, event: React.DragEvent) => void;
|
onDragStart?: (chatId: string, event: React.DragEvent) => void;
|
||||||
activeWorkflowId?: string;
|
activeWorkflowId?: string;
|
||||||
chatListRefreshKey?: number;
|
chatListRefreshKey?: number;
|
||||||
|
|
@ -288,7 +288,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
||||||
key={chat.id}
|
key={chat.id}
|
||||||
className={itemClassName}
|
className={itemClassName}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!isEditing) onSelectChat?.(chat.id, featureInstanceId);
|
if (!isEditing) onSelectChat?.(chat.id, featureInstanceId, chat.label);
|
||||||
}}
|
}}
|
||||||
draggable={!!onDragStart && !isEditing}
|
draggable={!!onDragStart && !isEditing}
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
.filesTab {
|
.filesTab {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
22
src/components/UnifiedDataBar/SourcesTab.module.css
Normal file
22
src/components/UnifiedDataBar/SourcesTab.module.css
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
.sourcesTab {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.treeHost {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ import { createUdbSourcesProvider } from './UdbSourcesProvider';
|
||||||
import type { UdbBackendNode } from './UdbSourcesProvider';
|
import type { UdbBackendNode } from './UdbSourcesProvider';
|
||||||
import { DataSourceSettingsModal } from './DataSourceSettingsModal';
|
import { DataSourceSettingsModal } from './DataSourceSettingsModal';
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import styles from './SourcesTab.module.css';
|
||||||
|
|
||||||
interface SourcesTabProps {
|
interface SourcesTabProps {
|
||||||
context: UdbContext;
|
context: UdbContext;
|
||||||
|
|
@ -84,15 +85,15 @@ const SourcesTab: React.FC<SourcesTabProps> = ({
|
||||||
|
|
||||||
if (!instanceId || !provider) {
|
if (!instanceId || !provider) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 12, fontSize: 12, color: '#888' }}>
|
<div className={styles.empty}>
|
||||||
{t('Keine Workspace-Instanz aktiv.')}
|
{t('Keine Workspace-Instanz aktiv.')}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
<div className={styles.sourcesTab}>
|
||||||
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
|
<div className={styles.treeHost}>
|
||||||
<FormGeneratorTree
|
<FormGeneratorTree
|
||||||
provider={provider}
|
provider={provider}
|
||||||
ownership="own"
|
ownership="own"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
.udb {
|
.udb {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
@ -38,7 +40,10 @@
|
||||||
|
|
||||||
.tabContent {
|
.tabContent {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ interface UnifiedDataBarProps {
|
||||||
activeTab?: UdbTab;
|
activeTab?: UdbTab;
|
||||||
onTabChange?: (tab: UdbTab) => void;
|
onTabChange?: (tab: UdbTab) => void;
|
||||||
hideTabs?: UdbTab[];
|
hideTabs?: UdbTab[];
|
||||||
onSelectChat?: (chatId: string, featureInstanceId: string) => void;
|
onSelectChat?: (chatId: string, featureInstanceId: string, label?: string) => void;
|
||||||
activeWorkflowId?: string;
|
activeWorkflowId?: string;
|
||||||
onRenameChat?: (chatId: string, newName: string) => void;
|
onRenameChat?: (chatId: string, newName: string) => void;
|
||||||
chatListRefreshKey?: number;
|
chatListRefreshKey?: number;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
// Copyright (c) 2026 PowerOn AG
|
// Copyright (c) 2026 PowerOn AG
|
||||||
// All rights reserved.
|
// All rights reserved.
|
||||||
export { default as UnifiedDataBar } from './UnifiedDataBar';
|
export { default as UnifiedDataBar } from './UnifiedDataBar';
|
||||||
|
export { default as ChatsTab } from './ChatsTab';
|
||||||
export type { UdbContext, UdbTab, AddToChat_FileItem, AddToChat_FeatureSource } from './UnifiedDataBar';
|
export type { UdbContext, UdbTab, AddToChat_FileItem, AddToChat_FeatureSource } from './UnifiedDataBar';
|
||||||
export { useUdlContext } from './useUdlContext';
|
export { useUdlContext } from './useUdlContext';
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import type { KeepAliveEntry } from '../types/keepAlive.types';
|
||||||
import { AdminDatabaseHealthPage } from '../pages/admin/AdminDatabaseHealthPage';
|
import { AdminDatabaseHealthPage } from '../pages/admin/AdminDatabaseHealthPage';
|
||||||
import { AdminLanguagesPage } from '../pages/admin/AdminLanguagesPage';
|
import { AdminLanguagesPage } from '../pages/admin/AdminLanguagesPage';
|
||||||
import { CommcoachSessionView } from '../pages/views/commcoach';
|
import { CommcoachSessionView } from '../pages/views/commcoach';
|
||||||
|
import { TeamsbotSessionView } from '../pages/views/teamsbot/TeamsbotSessionView';
|
||||||
|
import { RedmineBrowserView } from '../pages/views/redmine/RedmineBrowserView';
|
||||||
import { WorkspacePage } from '../pages/views/workspace/WorkspacePage';
|
import { WorkspacePage } from '../pages/views/workspace/WorkspacePage';
|
||||||
import { WorkflowAutomationPage } from '../pages/workflowAutomation/WorkflowAutomationHubPage';
|
import { WorkflowAutomationPage } from '../pages/workflowAutomation/WorkflowAutomationHubPage';
|
||||||
|
|
||||||
|
|
@ -31,6 +33,19 @@ export const KEEP_ALIVE_ROUTES: KeepAliveEntry[] = [
|
||||||
shellOverflowHidden: false,
|
shellOverflowHidden: false,
|
||||||
render: ({ scopeKey }) => <CommcoachSessionView key={scopeKey} />,
|
render: ({ scopeKey }) => <CommcoachSessionView key={scopeKey} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'teamsbot-session',
|
||||||
|
pathRegex: /\/mandates\/[^/]+\/teamsbot\/[^/]+\/sessions/,
|
||||||
|
scopeRegex: /\/mandates\/([^/]+)\/teamsbot\/([^/]+)\/sessions/,
|
||||||
|
shellOverflowHidden: false,
|
||||||
|
render: ({ scopeKey }) => <TeamsbotSessionView key={scopeKey} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'redmine-browser',
|
||||||
|
pathRegex: /\/mandates\/[^/]+\/redmine\/[^/]+\/browser/,
|
||||||
|
scopeRegex: /\/mandates\/([^/]+)\/redmine\/([^/]+)\/browser/,
|
||||||
|
render: ({ scopeKey }) => <RedmineBrowserView key={scopeKey} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'admin-languages',
|
id: 'admin-languages',
|
||||||
pathRegex: /\/admin\/languages(?:$|\/)/,
|
pathRegex: /\/admin\/languages(?:$|\/)/,
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,6 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
||||||
'feature.neutralization': <FaShieldAlt />,
|
'feature.neutralization': <FaShieldAlt />,
|
||||||
'feature.trustee': <FaBriefcase />,
|
'feature.trustee': <FaBriefcase />,
|
||||||
'feature.realestate': <FaBuilding />,
|
'feature.realestate': <FaBuilding />,
|
||||||
'feature.chatworkflow': <FaPlay />,
|
|
||||||
'feature.teamsbot': <FaHeadset />,
|
'feature.teamsbot': <FaHeadset />,
|
||||||
|
|
||||||
// Feature pages - Workspace
|
// Feature pages - Workspace
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,6 @@ import { useApiRequest } from './useApi';
|
||||||
import {
|
import {
|
||||||
fetchBalances,
|
fetchBalances,
|
||||||
fetchBalanceForMandate,
|
fetchBalanceForMandate,
|
||||||
fetchTransactions,
|
|
||||||
fetchTransactionsPaginated,
|
|
||||||
fetchStatistics,
|
fetchStatistics,
|
||||||
fetchAllowedProviders,
|
fetchAllowedProviders,
|
||||||
fetchSettingsAdmin,
|
fetchSettingsAdmin,
|
||||||
|
|
@ -34,9 +32,7 @@ import {
|
||||||
type MandateUserSummary,
|
type MandateUserSummary,
|
||||||
type StatisticsRangeRequest,
|
type StatisticsRangeRequest,
|
||||||
type BillingBucketSize,
|
type BillingBucketSize,
|
||||||
type BillingTransactionsPaginationParams,
|
|
||||||
} from '../api/billingApi';
|
} from '../api/billingApi';
|
||||||
import type { GroupLayout } from '../api/connectionApi';
|
|
||||||
|
|
||||||
// Re-export types
|
// Re-export types
|
||||||
export type {
|
export type {
|
||||||
|
|
@ -52,25 +48,13 @@ export type {
|
||||||
BillingBucketSize,
|
BillingBucketSize,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type { TransactionType, ReferenceType, BillingTransactionsPaginationParams } from '../api/billingApi';
|
export type { TransactionType, ReferenceType } from '../api/billingApi';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for user billing operations
|
* Hook for user billing operations
|
||||||
*/
|
*/
|
||||||
export function useBilling() {
|
export function useBilling() {
|
||||||
const [balances, setBalances] = useState<BillingBalance[]>([]);
|
const [balances, setBalances] = useState<BillingBalance[]>([]);
|
||||||
const [transactions, setTransactions] = useState<BillingTransaction[]>([]);
|
|
||||||
const [transactionsPagination, setTransactionsPagination] = useState<{
|
|
||||||
currentPage: number;
|
|
||||||
pageSize: number;
|
|
||||||
totalItems: number;
|
|
||||||
totalPages: number;
|
|
||||||
} | null>(null);
|
|
||||||
const [transactionsGroupLayout, setTransactionsGroupLayout] = useState<GroupLayout | null>(null);
|
|
||||||
const [transactionsAppliedView, setTransactionsAppliedView] = useState<{
|
|
||||||
viewKey?: string;
|
|
||||||
displayName?: string;
|
|
||||||
} | null>(null);
|
|
||||||
const [statistics, setStatistics] = useState<UsageReport | null>(null);
|
const [statistics, setStatistics] = useState<UsageReport | null>(null);
|
||||||
const [allowedProviders, setAllowedProviders] = useState<string[]>([]);
|
const [allowedProviders, setAllowedProviders] = useState<string[]>([]);
|
||||||
const { request, isLoading: loading, error } = useApiRequest();
|
const { request, isLoading: loading, error } = useApiRequest();
|
||||||
|
|
@ -98,43 +82,6 @@ export function useBilling() {
|
||||||
}
|
}
|
||||||
}, [request]);
|
}, [request]);
|
||||||
|
|
||||||
// Fetch transactions
|
|
||||||
const loadTransactions = useCallback(async (limit: number = 50, offset: number = 0) => {
|
|
||||||
try {
|
|
||||||
const data = await fetchTransactions(request, limit, offset);
|
|
||||||
setTransactions(Array.isArray(data) ? data : []);
|
|
||||||
setTransactionsPagination(null);
|
|
||||||
setTransactionsGroupLayout(null);
|
|
||||||
setTransactionsAppliedView(null);
|
|
||||||
return data;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error loading transactions:', err);
|
|
||||||
setTransactions([]);
|
|
||||||
setTransactionsPagination(null);
|
|
||||||
setTransactionsGroupLayout(null);
|
|
||||||
setTransactionsAppliedView(null);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}, [request]);
|
|
||||||
|
|
||||||
const refetchTransactions = useCallback(async (params?: BillingTransactionsPaginationParams) => {
|
|
||||||
try {
|
|
||||||
const data = await fetchTransactionsPaginated(request, params);
|
|
||||||
setTransactions(Array.isArray(data.items) ? data.items : []);
|
|
||||||
setTransactionsPagination(data.pagination ?? null);
|
|
||||||
setTransactionsGroupLayout(data.groupLayout ?? null);
|
|
||||||
setTransactionsAppliedView(data.appliedView ?? null);
|
|
||||||
return data;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error loading transactions:', err);
|
|
||||||
setTransactions([]);
|
|
||||||
setTransactionsPagination(null);
|
|
||||||
setTransactionsGroupLayout(null);
|
|
||||||
setTransactionsAppliedView(null);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, [request]);
|
|
||||||
|
|
||||||
const loadStatistics = useCallback(async (range: StatisticsRangeRequest) => {
|
const loadStatistics = useCallback(async (range: StatisticsRangeRequest) => {
|
||||||
try {
|
try {
|
||||||
const data = await fetchStatistics(request, range);
|
const data = await fetchStatistics(request, range);
|
||||||
|
|
@ -168,18 +115,12 @@ export function useBilling() {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
balances,
|
balances,
|
||||||
transactions,
|
|
||||||
transactionsPagination,
|
|
||||||
transactionsGroupLayout,
|
|
||||||
transactionsAppliedView,
|
|
||||||
statistics,
|
statistics,
|
||||||
allowedProviders,
|
allowedProviders,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
loadBalances,
|
loadBalances,
|
||||||
loadBalanceForMandate,
|
loadBalanceForMandate,
|
||||||
loadTransactions,
|
|
||||||
refetchTransactions,
|
|
||||||
loadStatistics,
|
loadStatistics,
|
||||||
loadAllowedProviders,
|
loadAllowedProviders,
|
||||||
refetch: loadBalances,
|
refetch: loadBalances,
|
||||||
|
|
|
||||||
37
src/hooks/useDocumentTitle.ts
Normal file
37
src/hooks/useDocumentTitle.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
|
/**
|
||||||
|
* useDocumentTitle — sets `${appName} - ${pageTitle}` on document.title.
|
||||||
|
*
|
||||||
|
* Pass `isActive=false` (or a failing routeMatch) on Keep-Alive pages so a
|
||||||
|
* hidden instance does not overwrite the visible route's title.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { getAppName } from '../../config/config';
|
||||||
|
|
||||||
|
export type RouteMatchFn = (pathname: string, search: string) => boolean;
|
||||||
|
|
||||||
|
export interface UseDocumentTitleOptions {
|
||||||
|
/** When false, the title is not updated. Default: true. */
|
||||||
|
isActive?: boolean;
|
||||||
|
/** When set, title updates only while this matcher returns true. */
|
||||||
|
routeMatch?: RouteMatchFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDocumentTitle(
|
||||||
|
pageTitle: string,
|
||||||
|
options: UseDocumentTitleOptions = {},
|
||||||
|
): void {
|
||||||
|
const location = useLocation();
|
||||||
|
const { isActive = true, routeMatch } = options;
|
||||||
|
|
||||||
|
const routeMatches = !routeMatch || routeMatch(location.pathname, location.search);
|
||||||
|
const shouldSet = isActive && routeMatches && pageTitle.trim().length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldSet) return;
|
||||||
|
document.title = `${getAppName()} - ${pageTitle}`;
|
||||||
|
}, [pageTitle, shouldSet]);
|
||||||
|
}
|
||||||
|
|
@ -125,61 +125,102 @@ function isDynamicBlock(block: NavigationBlock): block is DynamicBlock {
|
||||||
return block.type === 'dynamic';
|
return block.type === 'dynamic';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// SHARED CACHE (single in-flight request for all hook consumers)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
type NavigationListener = () => void;
|
||||||
|
|
||||||
|
let sharedBlocks: NavigationBlock[] = [];
|
||||||
|
let sharedLoading = false;
|
||||||
|
let sharedError: string | null = null;
|
||||||
|
let inFlightFetch: Promise<void> | null = null;
|
||||||
|
const listeners = new Set<NavigationListener>();
|
||||||
|
let changeEventsBound = false;
|
||||||
|
|
||||||
|
function _notifyNavigationListeners() {
|
||||||
|
listeners.forEach((listener) => listener());
|
||||||
|
}
|
||||||
|
|
||||||
|
function _parseNavigationError(err: unknown): string {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
return err.message;
|
||||||
|
}
|
||||||
|
return (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|
||||||
|
|| 'Fehler beim Laden der Navigation';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _fetchNavigationShared(force = false): Promise<void> {
|
||||||
|
if (inFlightFetch && !force) {
|
||||||
|
return inFlightFetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
sharedLoading = true;
|
||||||
|
sharedError = null;
|
||||||
|
_notifyNavigationListeners();
|
||||||
|
|
||||||
|
const fetchPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get<NavigationResponse>('/api/navigation');
|
||||||
|
sharedBlocks = response.data.blocks || [];
|
||||||
|
} catch (err: unknown) {
|
||||||
|
sharedError = _parseNavigationError(err);
|
||||||
|
sharedBlocks = [];
|
||||||
|
} finally {
|
||||||
|
sharedLoading = false;
|
||||||
|
inFlightFetch = null;
|
||||||
|
_notifyNavigationListeners();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
inFlightFetch = fetchPromise;
|
||||||
|
return fetchPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _ensureNavigationChangeListeners() {
|
||||||
|
if (changeEventsBound) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
changeEventsBound = true;
|
||||||
|
|
||||||
|
const onNavigationChanged = () => {
|
||||||
|
void _fetchNavigationShared(true);
|
||||||
|
};
|
||||||
|
window.addEventListener('features-changed', onNavigationChanged);
|
||||||
|
window.addEventListener('userInfoUpdated', onNavigationChanged);
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// HOOK
|
// HOOK
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export function useNavigation(): UseNavigationReturn {
|
export function useNavigation(): UseNavigationReturn {
|
||||||
const [blocks, setBlocks] = useState<NavigationBlock[]>([]);
|
const [, setRevision] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const fetchNavigation = useCallback(async () => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
_ensureNavigationChangeListeners();
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
const listener = () => setRevision((revision) => revision + 1);
|
||||||
const response = await api.get<NavigationResponse>('/api/navigation');
|
listeners.add(listener);
|
||||||
setBlocks(response.data.blocks || []);
|
void _fetchNavigationShared();
|
||||||
} catch (err: unknown) {
|
|
||||||
const errorMsg = err instanceof Error
|
return () => {
|
||||||
? err.message
|
listeners.delete(listener);
|
||||||
: (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|
};
|
||||||
|| 'Fehler beim Laden der Navigation';
|
|
||||||
setError(errorMsg);
|
|
||||||
setBlocks([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const refresh = useCallback(() => _fetchNavigationShared(true), []);
|
||||||
fetchNavigation();
|
|
||||||
}, [fetchNavigation]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const staticBlocks = sharedBlocks.filter(isStaticBlock);
|
||||||
const onFeaturesChanged = () => {
|
const dynamicBlock = sharedBlocks.find(isDynamicBlock) || null;
|
||||||
fetchNavigation();
|
|
||||||
};
|
|
||||||
window.addEventListener('features-changed', onFeaturesChanged);
|
|
||||||
window.addEventListener('userInfoUpdated', onFeaturesChanged);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('features-changed', onFeaturesChanged);
|
|
||||||
window.removeEventListener('userInfoUpdated', onFeaturesChanged);
|
|
||||||
};
|
|
||||||
}, [fetchNavigation]);
|
|
||||||
|
|
||||||
// Derive static and dynamic blocks
|
|
||||||
const staticBlocks = blocks.filter(isStaticBlock);
|
|
||||||
const dynamicBlock = blocks.find(isDynamicBlock) || null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
blocks,
|
blocks: sharedBlocks,
|
||||||
staticBlocks,
|
staticBlocks,
|
||||||
dynamicBlock,
|
dynamicBlock,
|
||||||
loading,
|
loading: sharedLoading,
|
||||||
error,
|
error: sharedError,
|
||||||
refresh: fetchNavigation,
|
refresh,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
54
src/hooks/useScrollMode.ts
Normal file
54
src/hooks/useScrollMode.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
|
/**
|
||||||
|
* useScrollMode Hook
|
||||||
|
*
|
||||||
|
* Determines the layout scroll mode for the current viewport:
|
||||||
|
* - "bounded": Desktop with sufficient height. Content areas use overflow:hidden
|
||||||
|
* chains and tables scroll internally.
|
||||||
|
* - "document": Mobile or very short viewports. The overflow:hidden chain is
|
||||||
|
* broken so the page scrolls as a document, letting the header scroll away.
|
||||||
|
*
|
||||||
|
* Sets `data-scroll-mode` on <html> so every CSS module can react via
|
||||||
|
* `:global(html[data-scroll-mode="document"])`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
export type ScrollMode = 'bounded' | 'document';
|
||||||
|
|
||||||
|
const DOCUMENT_MODE_QUERY = '(max-width: 1024px), (max-height: 500px)';
|
||||||
|
|
||||||
|
function _evaluateScrollMode(): ScrollMode {
|
||||||
|
return window.matchMedia(DOCUMENT_MODE_QUERY).matches ? 'document' : 'bounded';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScrollMode(): ScrollMode {
|
||||||
|
const [mode, setMode] = useState<ScrollMode>(_evaluateScrollMode);
|
||||||
|
|
||||||
|
const _syncAttribute = useCallback((m: ScrollMode) => {
|
||||||
|
document.documentElement.dataset.scrollMode = m;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
_syncAttribute(mode);
|
||||||
|
}, [mode, _syncAttribute]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mql = window.matchMedia(DOCUMENT_MODE_QUERY);
|
||||||
|
const _handleChange = () => {
|
||||||
|
const next = _evaluateScrollMode();
|
||||||
|
setMode(next);
|
||||||
|
};
|
||||||
|
mql.addEventListener('change', _handleChange);
|
||||||
|
return () => mql.removeEventListener('change', _handleChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
delete document.documentElement.dataset.scrollMode;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
85
src/hooks/useScrollRestoration.ts
Normal file
85
src/hooks/useScrollRestoration.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
|
/**
|
||||||
|
* useScrollRestoration — remembers scroll position per route (pathname + search).
|
||||||
|
* Restores on return; scrolls to top when the saved offset exceeds scroll height.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useLayoutEffect, useRef, type RefObject } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { useScrollMode } from './useScrollMode';
|
||||||
|
|
||||||
|
const STORAGE_PREFIX = 'po_scroll:';
|
||||||
|
|
||||||
|
function _routeKey(pathname: string, search: string): string {
|
||||||
|
return `${pathname}${search}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _findScrollContainer(anchor: HTMLElement | null, scrollMode: 'bounded' | 'document'): HTMLElement {
|
||||||
|
if (scrollMode === 'document') {
|
||||||
|
const content = document.querySelector('[data-scroll-mode="document"].content') as HTMLElement | null;
|
||||||
|
if (content && content.scrollHeight > content.clientHeight) return content;
|
||||||
|
return document.documentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
let node: HTMLElement | null = anchor;
|
||||||
|
while (node) {
|
||||||
|
const { overflowY } = getComputedStyle(node);
|
||||||
|
if (overflowY === 'auto' || overflowY === 'scroll') {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
node = node.parentElement;
|
||||||
|
}
|
||||||
|
return document.documentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _readPosition(key: string): number | null {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(`${STORAGE_PREFIX}${key}`);
|
||||||
|
if (raw === null) return null;
|
||||||
|
const value = Number(raw);
|
||||||
|
return Number.isFinite(value) ? value : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _writePosition(key: string, top: number): void {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(`${STORAGE_PREFIX}${key}`, String(top));
|
||||||
|
} catch {
|
||||||
|
// sessionStorage unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScrollRestoration(anchorRef: RefObject<HTMLElement | null>): void {
|
||||||
|
const location = useLocation();
|
||||||
|
const scrollMode = useScrollMode();
|
||||||
|
const prevKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const routeKey = _routeKey(location.pathname, location.search);
|
||||||
|
const container = _findScrollContainer(anchorRef.current, scrollMode);
|
||||||
|
const saved = _readPosition(routeKey);
|
||||||
|
|
||||||
|
if (saved !== null && saved > 0) {
|
||||||
|
const maxScroll = Math.max(0, container.scrollHeight - container.clientHeight);
|
||||||
|
container.scrollTop = saved <= maxScroll ? saved : 0;
|
||||||
|
} else {
|
||||||
|
container.scrollTop = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
prevKeyRef.current = routeKey;
|
||||||
|
}, [location.pathname, location.search, scrollMode, anchorRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const routeKey = _routeKey(location.pathname, location.search);
|
||||||
|
const container = _findScrollContainer(anchorRef.current, scrollMode);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (prevKeyRef.current === routeKey) {
|
||||||
|
_writePosition(routeKey, container.scrollTop);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [location.pathname, location.search, scrollMode, anchorRef]);
|
||||||
|
}
|
||||||
55
src/hooks/useVisibilityRemeasure.ts
Normal file
55
src/hooks/useVisibilityRemeasure.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
|
/**
|
||||||
|
* useVisibilityRemeasure — re-runs a callback when a hidden element becomes visible.
|
||||||
|
*
|
||||||
|
* Keep-Alive routes use display:none; ResizeObserver reports 0×0 until shown again.
|
||||||
|
* FormGeneratorTable and split panels use this to re-clamp height/width.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, type RefObject } from 'react';
|
||||||
|
|
||||||
|
export function useVisibilityRemeasure(
|
||||||
|
elementRef: RefObject<HTMLElement | null>,
|
||||||
|
onRemeasure: () => void,
|
||||||
|
): void {
|
||||||
|
const wasVisibleRef = useRef(false);
|
||||||
|
const onRemeasureRef = useRef(onRemeasure);
|
||||||
|
onRemeasureRef.current = onRemeasure;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = elementRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const _triggerRemeasure = () => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => onRemeasureRef.current());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const _check = () => {
|
||||||
|
const visible = el.clientWidth > 0 && el.clientHeight > 0;
|
||||||
|
if (!wasVisibleRef.current && visible) {
|
||||||
|
_triggerRemeasure();
|
||||||
|
}
|
||||||
|
wasVisibleRef.current = visible;
|
||||||
|
};
|
||||||
|
|
||||||
|
_check();
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(_check);
|
||||||
|
resizeObserver.observe(el);
|
||||||
|
|
||||||
|
const parent = el.parentElement;
|
||||||
|
let parentObserver: MutationObserver | null = null;
|
||||||
|
if (parent) {
|
||||||
|
parentObserver = new MutationObserver(_check);
|
||||||
|
parentObserver.observe(parent, { attributes: true, attributeFilter: ['style'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
parentObserver?.disconnect();
|
||||||
|
};
|
||||||
|
}, [elementRef]);
|
||||||
|
}
|
||||||
|
|
@ -1,708 +0,0 @@
|
||||||
// Copyright (c) 2026 PowerOn AG
|
|
||||||
// All rights reserved.
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { useApiRequest } from './useApi';
|
|
||||||
import { fetchAttributes as fetchAttributesApi } from '../api/attributesApi';
|
|
||||||
import type { AttributeDefinition, ApiRequestFunction } from '../api/attributesApi';
|
|
||||||
import {
|
|
||||||
fetchWorkflows as fetchWorkflowsFromApi,
|
|
||||||
fetchWorkflow as fetchWorkflowFromApi,
|
|
||||||
deleteWorkflow as deleteWorkflowFromApi,
|
|
||||||
updateWorkflow as updateWorkflowFromApi,
|
|
||||||
} from '../api/workflowAutomationApi';
|
|
||||||
import { useWorkflowSelection } from '../contexts/WorkflowSelectionContext';
|
|
||||||
import { usePermissions, type UserPermissions } from './usePermissions';
|
|
||||||
|
|
||||||
export type StartWorkflowRequest = Record<string, unknown>;
|
|
||||||
|
|
||||||
function _isValidApiBaseUrl(apiBaseUrl: string | undefined): boolean {
|
|
||||||
return apiBaseUrl === '/api/workflow-automation';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _deleteWorkflowsSequential(
|
|
||||||
request: ApiRequestFunction,
|
|
||||||
workflowIds: string[],
|
|
||||||
) {
|
|
||||||
for (const id of workflowIds) {
|
|
||||||
await deleteWorkflowFromApi(request, id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startWorkflowApi(
|
|
||||||
request: ApiRequestFunction,
|
|
||||||
_instanceId: string,
|
|
||||||
workflowData: StartWorkflowRequest,
|
|
||||||
options?: { workflowId?: string; workflowMode?: 'Dynamic' | 'Automation' },
|
|
||||||
) {
|
|
||||||
const wfId = options?.workflowId ?? (workflowData as { workflowId?: string }).workflowId;
|
|
||||||
return await request({
|
|
||||||
url: `/api/workflow-automation/execute`,
|
|
||||||
method: 'post',
|
|
||||||
data: {
|
|
||||||
workflowId: wfId,
|
|
||||||
payload: workflowData,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stopWorkflowApi(request: ApiRequestFunction, instanceId: string, workflowId: string) {
|
|
||||||
await request({
|
|
||||||
url: `/api/workspace/${instanceId}/${workflowId}/stop`,
|
|
||||||
method: 'post',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteMessageApi(
|
|
||||||
request: ApiRequestFunction,
|
|
||||||
instanceId: string,
|
|
||||||
workflowId: string,
|
|
||||||
messageId: string,
|
|
||||||
) {
|
|
||||||
await request({
|
|
||||||
url: `/api/workspace/${instanceId}/workflows/${workflowId}/messages/${messageId}`,
|
|
||||||
method: 'delete',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteFileFromMessageApi(
|
|
||||||
request: ApiRequestFunction,
|
|
||||||
instanceId: string,
|
|
||||||
workflowId: string,
|
|
||||||
messageId: string,
|
|
||||||
fileId: string,
|
|
||||||
) {
|
|
||||||
await request({
|
|
||||||
url: `/api/workspace/${instanceId}/workflows/${workflowId}/messages/${messageId}/files/${fileId}`,
|
|
||||||
method: 'delete',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Workflow interface matching backend
|
|
||||||
export interface UserWorkflow {
|
|
||||||
id: string;
|
|
||||||
mandateId: string;
|
|
||||||
status: string;
|
|
||||||
name?: string;
|
|
||||||
workflowMode?: string;
|
|
||||||
[key: string]: any; // Allow additional properties
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export AttributeDefinition from attributesApi
|
|
||||||
export type { AttributeDefinition } from '../api/attributesApi';
|
|
||||||
|
|
||||||
// Attribute option interface (from backend)
|
|
||||||
export interface AttributeOption {
|
|
||||||
value: string | number;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pagination parameters
|
|
||||||
export interface PaginationParams {
|
|
||||||
page?: number;
|
|
||||||
pageSize?: number;
|
|
||||||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
|
||||||
filters?: Record<string, any>;
|
|
||||||
search?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get apiBaseUrl for workflow APIs (mandate-scoped) */
|
|
||||||
export function getWorkflowApiBaseUrl(_instanceId: string | undefined, featureCode: string | undefined): string | undefined {
|
|
||||||
if (!featureCode) return undefined;
|
|
||||||
if (featureCode === 'workflowAutomation') return `/api/workflow-automation`;
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Workflows list hook - pass instanceId and featureCode when in feature context for feature-scoped API
|
|
||||||
export function useUserWorkflows(options?: { instanceId?: string; featureCode?: string }) {
|
|
||||||
const apiBaseUrl = getWorkflowApiBaseUrl(options?.instanceId, options?.featureCode);
|
|
||||||
const [workflows, setWorkflows] = useState<UserWorkflow[]>([]);
|
|
||||||
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
|
|
||||||
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
|
|
||||||
const [pagination, setPagination] = useState<{
|
|
||||||
currentPage: number;
|
|
||||||
pageSize: number;
|
|
||||||
totalItems: number;
|
|
||||||
totalPages: number;
|
|
||||||
} | null>(null);
|
|
||||||
const { request, isLoading: loading, error } = useApiRequest<null, unknown>();
|
|
||||||
const { checkPermission } = usePermissions();
|
|
||||||
|
|
||||||
// Fetch attributes from backend
|
|
||||||
const fetchAttributes = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const attrs = await fetchAttributesApi(request, 'ChatWorkflow');
|
|
||||||
setAttributes(attrs);
|
|
||||||
return attrs;
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error fetching attributes:', error);
|
|
||||||
setAttributes([]);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}, [request]);
|
|
||||||
|
|
||||||
// Fetch permissions from backend
|
|
||||||
const fetchPermissions = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const perms = await checkPermission('DATA', 'ChatWorkflow');
|
|
||||||
setPermissions(perms);
|
|
||||||
return perms;
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error fetching permissions:', error);
|
|
||||||
const defaultPerms: UserPermissions = {
|
|
||||||
view: false,
|
|
||||||
read: 'n',
|
|
||||||
create: 'n',
|
|
||||||
update: 'n',
|
|
||||||
delete: 'n',
|
|
||||||
};
|
|
||||||
setPermissions(defaultPerms);
|
|
||||||
return defaultPerms;
|
|
||||||
}
|
|
||||||
}, [checkPermission]);
|
|
||||||
|
|
||||||
const fetchWorkflowsData = useCallback(async (params?: PaginationParams) => {
|
|
||||||
try {
|
|
||||||
if (!apiBaseUrl || !_isValidApiBaseUrl(apiBaseUrl)) {
|
|
||||||
console.error('useUserWorkflows: apiBaseUrl is required (missing featureCode)');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let listParams: { pagination?: Record<string, unknown> } | undefined = undefined;
|
|
||||||
if (params) {
|
|
||||||
const paginationObj: Record<string, unknown> = {};
|
|
||||||
if (params.page !== undefined) paginationObj.page = params.page;
|
|
||||||
if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
|
|
||||||
if (params.sort) paginationObj.sort = params.sort;
|
|
||||||
if (params.filters) paginationObj.filters = params.filters;
|
|
||||||
if (params.search) paginationObj.search = params.search;
|
|
||||||
if (Object.keys(paginationObj).length > 0) {
|
|
||||||
listParams = { pagination: paginationObj };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const data: unknown = await fetchWorkflowsFromApi(request, listParams ? { pagination: listParams.pagination } : undefined);
|
|
||||||
|
|
||||||
// Handle paginated response
|
|
||||||
if (data && typeof data === 'object' && data !== null && 'items' in data) {
|
|
||||||
const d = data as { items?: unknown; pagination?: unknown };
|
|
||||||
const items = Array.isArray(d.items) ? d.items : [];
|
|
||||||
// Map API response to our frontend model
|
|
||||||
const mappedWorkflows = items.map((apiWorkflow: any): UserWorkflow => {
|
|
||||||
return {
|
|
||||||
id: apiWorkflow.id,
|
|
||||||
mandateId: apiWorkflow.mandateId || '',
|
|
||||||
status: apiWorkflow.status || 'unknown',
|
|
||||||
name: apiWorkflow.name,
|
|
||||||
workflowMode: apiWorkflow.workflowMode,
|
|
||||||
...apiWorkflow // Include any additional properties
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setWorkflows(mappedWorkflows);
|
|
||||||
if (d.pagination && typeof d.pagination === 'object') {
|
|
||||||
setPagination(d.pagination as any);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Handle non-paginated response (backward compatibility)
|
|
||||||
const items = Array.isArray(data) ? data : [];
|
|
||||||
const mappedWorkflows = items.map((apiWorkflow: any): UserWorkflow => {
|
|
||||||
return {
|
|
||||||
id: apiWorkflow.id,
|
|
||||||
mandateId: apiWorkflow.mandateId || '',
|
|
||||||
status: apiWorkflow.status || 'unknown',
|
|
||||||
name: apiWorkflow.name,
|
|
||||||
workflowMode: apiWorkflow.workflowMode,
|
|
||||||
...apiWorkflow
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setWorkflows(mappedWorkflows);
|
|
||||||
setPagination(null);
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
// Error is already handled by useApiRequest
|
|
||||||
setWorkflows([]);
|
|
||||||
setPagination(null);
|
|
||||||
}
|
|
||||||
}, [request, apiBaseUrl]);
|
|
||||||
|
|
||||||
// Optimistically remove a workflow from the local state
|
|
||||||
const removeOptimistically = (workflowId: string) => {
|
|
||||||
setWorkflows(prevWorkflows => prevWorkflows.filter(workflow => workflow.id !== workflowId));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Optimistically update a workflow in the local state
|
|
||||||
const updateOptimistically = (workflowId: string, updateData: Partial<UserWorkflow>) => {
|
|
||||||
setWorkflows(prevWorkflows =>
|
|
||||||
prevWorkflows.map(workflow =>
|
|
||||||
workflow.id === workflowId
|
|
||||||
? { ...workflow, ...updateData }
|
|
||||||
: workflow
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch a single workflow by ID
|
|
||||||
const fetchWorkflowById = useCallback(async (workflowId: string): Promise<UserWorkflow | null> => {
|
|
||||||
try {
|
|
||||||
if (!_isValidApiBaseUrl(apiBaseUrl)) return null;
|
|
||||||
const workflow = await fetchWorkflowFromApi(request, workflowId);
|
|
||||||
return workflow as unknown as UserWorkflow | null;
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error fetching workflow by ID:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, [request, apiBaseUrl]);
|
|
||||||
|
|
||||||
// Generate edit fields from attributes dynamically
|
|
||||||
const generateEditFieldsFromAttributes = useCallback((): Array<{
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
|
|
||||||
editable?: boolean;
|
|
||||||
required?: boolean;
|
|
||||||
validator?: (value: any) => string | null;
|
|
||||||
minRows?: number;
|
|
||||||
maxRows?: number;
|
|
||||||
options?: Array<{ value: string | number; label: string }>;
|
|
||||||
optionsReference?: string; // For options that need to be fetched (e.g., "user.role")
|
|
||||||
}> => {
|
|
||||||
if (!attributes || attributes.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const editableFields = attributes
|
|
||||||
.filter(attr => {
|
|
||||||
// Filter out non-editable fields based on readonly/editable flags
|
|
||||||
if (attr.readonly === true || attr.editable === false) {
|
|
||||||
return false; // Don't show readonly fields in edit form
|
|
||||||
}
|
|
||||||
// Also filter out common non-editable fields
|
|
||||||
const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete'];
|
|
||||||
return !nonEditableFields.includes(attr.name);
|
|
||||||
})
|
|
||||||
.map(attr => {
|
|
||||||
// Map backend attribute type to form field type
|
|
||||||
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string';
|
|
||||||
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
|
|
||||||
let optionsReference: string | undefined = undefined;
|
|
||||||
|
|
||||||
// Map backend types to form field types
|
|
||||||
if (attr.type === 'checkbox') {
|
|
||||||
fieldType = 'boolean';
|
|
||||||
} else if (attr.type === 'email') {
|
|
||||||
fieldType = 'email';
|
|
||||||
} else if (attr.type === 'date') {
|
|
||||||
fieldType = 'date';
|
|
||||||
} else if (attr.type === 'select') {
|
|
||||||
fieldType = 'enum';
|
|
||||||
// Handle options - can be array or string reference
|
|
||||||
if (Array.isArray(attr.options)) {
|
|
||||||
options = attr.options.map(opt => ({
|
|
||||||
value: opt.value,
|
|
||||||
label: opt.label || String(opt.value)
|
|
||||||
}));
|
|
||||||
} else if (typeof attr.options === 'string') {
|
|
||||||
optionsReference = attr.options;
|
|
||||||
}
|
|
||||||
} else if (attr.type === 'multiselect') {
|
|
||||||
fieldType = 'multiselect';
|
|
||||||
// Handle options - can be array or string reference
|
|
||||||
if (Array.isArray(attr.options)) {
|
|
||||||
options = attr.options.map(opt => ({
|
|
||||||
value: opt.value,
|
|
||||||
label: opt.label || String(opt.value)
|
|
||||||
}));
|
|
||||||
} else if (typeof attr.options === 'string') {
|
|
||||||
optionsReference = attr.options;
|
|
||||||
}
|
|
||||||
} else if (attr.type === 'textarea') {
|
|
||||||
fieldType = 'textarea';
|
|
||||||
} else if (attr.type === 'text') {
|
|
||||||
fieldType = (attr as any).multiline === true ? 'textarea' : 'string';
|
|
||||||
}
|
|
||||||
// Note: Legacy 'boolean' and 'enum' types are not in the AttributeDefinition type union
|
|
||||||
// If needed, they should be handled via type casting: (attr as any).type === 'boolean'
|
|
||||||
|
|
||||||
// Define validators and required fields
|
|
||||||
let required = attr.required === true;
|
|
||||||
let validator: ((value: any) => string | null) | undefined = undefined;
|
|
||||||
let minRows: number | undefined = undefined;
|
|
||||||
let maxRows: number | undefined = undefined;
|
|
||||||
|
|
||||||
if (attr.name === 'name') {
|
|
||||||
required = true;
|
|
||||||
validator = (value: any) => {
|
|
||||||
if (!value || (typeof value === 'string' && value.trim() === '')) {
|
|
||||||
return 'Workflow name cannot be empty';
|
|
||||||
}
|
|
||||||
if (typeof value === 'string' && value.length > 100) {
|
|
||||||
return 'Workflow name cannot exceed 100 characters';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
} else if (fieldType === 'textarea') {
|
|
||||||
minRows = 4;
|
|
||||||
maxRows = 8;
|
|
||||||
}
|
|
||||||
// Multiselect validation
|
|
||||||
else if (fieldType === 'multiselect' && required) {
|
|
||||||
validator = (value: any[]) => {
|
|
||||||
if (!value || !Array.isArray(value) || value.length === 0) {
|
|
||||||
return `${attr.label} is required`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: attr.name,
|
|
||||||
label: attr.label || attr.name,
|
|
||||||
type: fieldType,
|
|
||||||
editable: attr.editable !== false && attr.readonly !== true,
|
|
||||||
required,
|
|
||||||
validator,
|
|
||||||
minRows,
|
|
||||||
maxRows,
|
|
||||||
options,
|
|
||||||
optionsReference
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return editableFields;
|
|
||||||
}, [attributes]);
|
|
||||||
|
|
||||||
// Ensure attributes are loaded - can be called by EditActionButton
|
|
||||||
const ensureAttributesLoaded = useCallback(async () => {
|
|
||||||
// If attributes are already loaded, return them
|
|
||||||
if (attributes && attributes.length > 0) {
|
|
||||||
return attributes;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, fetch them and return the result
|
|
||||||
const fetchedAttributes = await fetchAttributes();
|
|
||||||
return fetchedAttributes;
|
|
||||||
}, [attributes, fetchAttributes]);
|
|
||||||
|
|
||||||
// Fetch attributes and permissions on mount
|
|
||||||
// Note: Do NOT fetch workflows here - let the table component control pagination
|
|
||||||
useEffect(() => {
|
|
||||||
fetchAttributes();
|
|
||||||
fetchPermissions();
|
|
||||||
}, [fetchAttributes, fetchPermissions]);
|
|
||||||
|
|
||||||
// Listen for workflow creation events to refetch workflows list
|
|
||||||
useEffect(() => {
|
|
||||||
const handleWorkflowCreated = (_event: CustomEvent<{ workflow: UserWorkflow }>) => {
|
|
||||||
// Refetch to ensure we have the latest data
|
|
||||||
fetchWorkflowsData();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('workflowCreated', handleWorkflowCreated as EventListener);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('workflowCreated', handleWorkflowCreated as EventListener);
|
|
||||||
};
|
|
||||||
}, [fetchWorkflowsData]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: workflows,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
refetch: fetchWorkflowsData,
|
|
||||||
removeOptimistically,
|
|
||||||
updateOptimistically,
|
|
||||||
attributes,
|
|
||||||
permissions,
|
|
||||||
pagination,
|
|
||||||
fetchWorkflowById,
|
|
||||||
generateEditFieldsFromAttributes,
|
|
||||||
ensureAttributesLoaded
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Workflow operations hook - pass instanceId and featureCode when in feature context for feature-scoped API
|
|
||||||
export function useWorkflowOperations(options?: { instanceId?: string; featureCode?: string }) {
|
|
||||||
const instanceId = options?.instanceId;
|
|
||||||
const [startingWorkflow, setStartingWorkflow] = useState(false);
|
|
||||||
const [stoppingWorkflows, setStoppingWorkflows] = useState<Set<string>>(new Set());
|
|
||||||
const [deletingWorkflows, setDeletingWorkflows] = useState<Set<string>>(new Set());
|
|
||||||
const [editingWorkflows, setEditingWorkflows] = useState<Set<string>>(new Set());
|
|
||||||
const [deletingMessages, setDeletingMessages] = useState<Set<string>>(new Set());
|
|
||||||
const [deletingFiles, setDeletingFiles] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
const [startError, setStartError] = useState<string | null>(null);
|
|
||||||
const [stopError, setStopError] = useState<string | null>(null);
|
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
||||||
const [updateError, setUpdateError] = useState<string | null>(null);
|
|
||||||
const [deleteMessageError, setDeleteMessageError] = useState<string | null>(null);
|
|
||||||
const [deleteFileError, setDeleteFileError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Workflow selection context - to clear selection if deleted workflow is selected
|
|
||||||
const { selectedWorkflowId, clearWorkflow } = useWorkflowSelection();
|
|
||||||
|
|
||||||
const { request } = useApiRequest();
|
|
||||||
|
|
||||||
// Generic delete operation handler
|
|
||||||
const handleDeleteOperation = async <T>(
|
|
||||||
operationKey: string,
|
|
||||||
setLoadingSet: React.Dispatch<React.SetStateAction<Set<string>>>,
|
|
||||||
setErrorState: React.Dispatch<React.SetStateAction<string | null>>,
|
|
||||||
operation: () => Promise<T>,
|
|
||||||
errorMessages: { default: string; notFound: string; forbidden: string }
|
|
||||||
): Promise<{ success: boolean; error?: string }> => {
|
|
||||||
setErrorState(null);
|
|
||||||
setLoadingSet(prev => new Set(prev).add(operationKey));
|
|
||||||
|
|
||||||
try {
|
|
||||||
await operation();
|
|
||||||
return { success: true };
|
|
||||||
} catch (error: any) {
|
|
||||||
let errorMessage = error.message || errorMessages.default;
|
|
||||||
|
|
||||||
if (error.response?.status === 404) {
|
|
||||||
errorMessage = errorMessages.notFound;
|
|
||||||
return { success: true };
|
|
||||||
} else if (error.response?.status === 403) {
|
|
||||||
errorMessage = errorMessages.forbidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrorState(errorMessage);
|
|
||||||
return { success: false, error: errorMessage };
|
|
||||||
} finally {
|
|
||||||
setLoadingSet(prev => {
|
|
||||||
const newSet = new Set(prev);
|
|
||||||
newSet.delete(operationKey);
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const startWorkflow = async (
|
|
||||||
instanceId: string,
|
|
||||||
workflowData: StartWorkflowRequest,
|
|
||||||
options?: { workflowId?: string; workflowMode?: 'Dynamic' | 'Automation' }
|
|
||||||
) => {
|
|
||||||
setStartError(null);
|
|
||||||
setStartingWorkflow(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await startWorkflowApi(request, instanceId, workflowData, options);
|
|
||||||
return { success: true, data: response };
|
|
||||||
} catch (error: any) {
|
|
||||||
const errorMessage = error.message || 'Failed to start workflow';
|
|
||||||
setStartError(errorMessage);
|
|
||||||
return { success: false, error: errorMessage };
|
|
||||||
} finally {
|
|
||||||
setStartingWorkflow(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopWorkflow = async (instanceId: string, workflowId: string) => {
|
|
||||||
setStopError(null);
|
|
||||||
setStoppingWorkflows(prev => new Set(prev).add(workflowId));
|
|
||||||
|
|
||||||
try {
|
|
||||||
await stopWorkflowApi(request, instanceId, workflowId);
|
|
||||||
return { success: true };
|
|
||||||
} catch (error: any) {
|
|
||||||
const errorMessage = error.message || 'Failed to stop workflow';
|
|
||||||
setStopError(errorMessage);
|
|
||||||
return { success: false, error: errorMessage };
|
|
||||||
} finally {
|
|
||||||
setStoppingWorkflows(prev => {
|
|
||||||
const newSet = new Set(prev);
|
|
||||||
newSet.delete(workflowId);
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleWorkflowDelete = async (workflowId: string) => {
|
|
||||||
const result = await handleDeleteOperation(
|
|
||||||
workflowId,
|
|
||||||
setDeletingWorkflows,
|
|
||||||
setDeleteError,
|
|
||||||
() => {
|
|
||||||
return deleteWorkflowFromApi(request, workflowId);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
default: 'Failed to delete workflow',
|
|
||||||
notFound: 'Workflow not found or has already been deleted.',
|
|
||||||
forbidden: 'No permission to delete this workflow.'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
// Add a small delay to ensure backend has time to process
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 300));
|
|
||||||
|
|
||||||
// Dispatch event to notify other components (e.g., dashboard dropdown)
|
|
||||||
window.dispatchEvent(new CustomEvent('workflowDeleted', {
|
|
||||||
detail: { workflowIds: [workflowId] }
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Clear workflow selection if the deleted workflow was selected
|
|
||||||
if (selectedWorkflowId === workflowId) {
|
|
||||||
clearWorkflow();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.success;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleWorkflowDeleteMultiple = async (workflowIds: string[]) => {
|
|
||||||
setDeleteError(null);
|
|
||||||
setDeletingWorkflows(prev => {
|
|
||||||
const newSet = new Set(prev);
|
|
||||||
workflowIds.forEach(id => newSet.add(id));
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await _deleteWorkflowsSequential(request, workflowIds);
|
|
||||||
|
|
||||||
// Add a small delay to ensure backend has time to process
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 300));
|
|
||||||
|
|
||||||
// Dispatch event to notify other components (e.g., dashboard dropdown)
|
|
||||||
window.dispatchEvent(new CustomEvent('workflowDeleted', {
|
|
||||||
detail: { workflowIds }
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Clear workflow selection if the selected workflow was deleted
|
|
||||||
if (selectedWorkflowId && workflowIds.includes(selectedWorkflowId)) {
|
|
||||||
clearWorkflow();
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(`❌ Bulk delete failed:`, error);
|
|
||||||
setDeleteError(error.message || 'Bulk delete failed');
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
setDeletingWorkflows(prev => {
|
|
||||||
const newSet = new Set(prev);
|
|
||||||
workflowIds.forEach(id => newSet.delete(id));
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteMessage = async (workflowId: string, messageId: string) => {
|
|
||||||
const operationKey = `${workflowId}:${messageId}`;
|
|
||||||
return handleDeleteOperation(
|
|
||||||
operationKey,
|
|
||||||
setDeletingMessages,
|
|
||||||
setDeleteMessageError,
|
|
||||||
() => {
|
|
||||||
if (!instanceId) throw new Error('instanceId required');
|
|
||||||
return deleteMessageApi(request, instanceId, workflowId, messageId);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
default: 'Failed to delete message',
|
|
||||||
notFound: 'Message not found or has already been deleted.',
|
|
||||||
forbidden: 'No permission to delete this message.'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteFileFromMessage = async (
|
|
||||||
workflowId: string,
|
|
||||||
messageId: string,
|
|
||||||
fileId: string
|
|
||||||
) => {
|
|
||||||
const operationKey = `${workflowId}:${messageId}:${fileId}`;
|
|
||||||
return handleDeleteOperation(
|
|
||||||
operationKey,
|
|
||||||
setDeletingFiles,
|
|
||||||
setDeleteFileError,
|
|
||||||
() => {
|
|
||||||
if (!instanceId) throw new Error('instanceId required');
|
|
||||||
return deleteFileFromMessageApi(request, instanceId, workflowId, messageId, fileId);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
default: 'Failed to delete file',
|
|
||||||
notFound: 'File not found or has already been deleted.',
|
|
||||||
forbidden: 'No permission to delete this file.'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleWorkflowUpdate = async (workflowId: string, updateData: Partial<{ name: string; description?: string; tags?: string[] }>, _originalWorkflowData?: any) => {
|
|
||||||
setUpdateError(null);
|
|
||||||
setEditingWorkflows(prev => new Set(prev).add(workflowId));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updatedWorkflow = await updateWorkflowFromApi(request, workflowId, {
|
|
||||||
label: updateData.name,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true, workflowData: updatedWorkflow };
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(`Update failed for workflow ID ${workflowId}:`, error);
|
|
||||||
|
|
||||||
const errorMessage = error.response?.data?.message || error.message || 'Failed to update workflow';
|
|
||||||
const statusCode = error.response?.status;
|
|
||||||
|
|
||||||
setUpdateError(errorMessage);
|
|
||||||
|
|
||||||
// Return detailed error information for proper handling
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: errorMessage,
|
|
||||||
statusCode,
|
|
||||||
isPermissionError: statusCode === 403,
|
|
||||||
isValidationError: statusCode === 400
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
setEditingWorkflows(prev => {
|
|
||||||
const newSet = new Set(prev);
|
|
||||||
newSet.delete(workflowId);
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Generic inline update handler for FormGeneratorTable
|
|
||||||
// Must merge changes with existing row data because backend requires full object
|
|
||||||
const handleInlineUpdate = async (workflowId: string, changes: Partial<UserWorkflow>, existingRow?: any) => {
|
|
||||||
if (!existingRow) {
|
|
||||||
throw new Error(`Existing row data required for inline update`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge changes with existing row data
|
|
||||||
const mergedData = {
|
|
||||||
name: existingRow.name,
|
|
||||||
...changes
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await handleWorkflowUpdate(workflowId, mergedData);
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.error || 'Failed to update');
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
// Loading states
|
|
||||||
startingWorkflow,
|
|
||||||
stoppingWorkflows,
|
|
||||||
deletingWorkflows,
|
|
||||||
editingWorkflows,
|
|
||||||
deletingMessages,
|
|
||||||
deletingFiles,
|
|
||||||
// Error states
|
|
||||||
startError,
|
|
||||||
stopError,
|
|
||||||
deleteError,
|
|
||||||
updateError,
|
|
||||||
deleteMessageError,
|
|
||||||
deleteFileError,
|
|
||||||
// Operations
|
|
||||||
startWorkflow,
|
|
||||||
stopWorkflow,
|
|
||||||
handleWorkflowDelete,
|
|
||||||
handleWorkflowDeleteMultiple,
|
|
||||||
handleWorkflowUpdate,
|
|
||||||
handleInlineUpdate,
|
|
||||||
deleteMessage,
|
|
||||||
deleteFileFromMessage
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -135,13 +135,10 @@
|
||||||
letter-spacing: 0.025em;
|
letter-spacing: 0.025em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Feature Content */
|
/* Feature Content — viewContent owns padding, not featureContent */
|
||||||
.featureContent {
|
.featureContent {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
/* Let child components handle their own scrolling for sticky headers */
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 1.5rem;
|
|
||||||
/* Maintain flex chain for child components */
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|
@ -180,10 +177,35 @@
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.featureHeader {
|
.featureHeader {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.featureContent {
|
.breadcrumb {
|
||||||
padding: 1rem;
|
gap: 0.25rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mandateName {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mandateName + .separator {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleBadge {
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* scrollMode: document — layout grows with content, header scrolls away */
|
||||||
|
:global(html[data-scroll-mode="document"]) .featureLayout {
|
||||||
|
height: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html[data-scroll-mode="document"]) .featureContent {
|
||||||
|
overflow: visible;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -91,12 +91,10 @@ export const FeatureLayout: React.FC = () => {
|
||||||
};
|
};
|
||||||
}, [dynamicBlock, mandateId, featureCode, instanceId]);
|
}, [dynamicBlock, mandateId, featureCode, instanceId]);
|
||||||
|
|
||||||
// Warten bis Features geladen sind
|
|
||||||
if (!initialized || loading || isLoading) {
|
if (!initialized || loading || isLoading) {
|
||||||
return <LoadingScreen />;
|
return <LoadingScreen />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prüfen ob Instanz existiert und gültig ist
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
console.warn('FeatureLayout: Invalid instance context', {
|
console.warn('FeatureLayout: Invalid instance context', {
|
||||||
path: location.pathname,
|
path: location.pathname,
|
||||||
|
|
@ -112,10 +110,8 @@ export const FeatureLayout: React.FC = () => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alles OK - rendere Content
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.featureLayout}>
|
<div className={styles.featureLayout}>
|
||||||
{/* Header mit Instanz-Info */}
|
|
||||||
<header className={styles.featureHeader}>
|
<header className={styles.featureHeader}>
|
||||||
<div className={styles.breadcrumb}>
|
<div className={styles.breadcrumb}>
|
||||||
<span className={styles.mandateName}>
|
<span className={styles.mandateName}>
|
||||||
|
|
@ -131,7 +127,6 @@ export const FeatureLayout: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Content Area */}
|
|
||||||
<main className={styles.featureContent}>
|
<main className={styles.featureContent}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -148,10 +143,6 @@ interface ProtectedFeatureRouteProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrapper für geschützte Feature-Routes
|
|
||||||
* Prüft zusätzlich View-Berechtigungen
|
|
||||||
*/
|
|
||||||
export const ProtectedFeatureRoute: React.FC<ProtectedFeatureRouteProps> = ({
|
export const ProtectedFeatureRoute: React.FC<ProtectedFeatureRouteProps> = ({
|
||||||
requiredView,
|
requiredView,
|
||||||
children,
|
children,
|
||||||
|
|
@ -163,7 +154,6 @@ export const ProtectedFeatureRoute: React.FC<ProtectedFeatureRouteProps> = ({
|
||||||
return <Navigate to="/" replace />;
|
return <Navigate to="/" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prüfe View-Berechtigung wenn erforderlich
|
|
||||||
if (requiredView) {
|
if (requiredView) {
|
||||||
const hasViewAccess = instance?.permissions?.views?.[requiredView] ?? false;
|
const hasViewAccess = instance?.permissions?.views?.[requiredView] ?? false;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@
|
||||||
.sidebar {
|
.sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
width: 280px;
|
width: 280px;
|
||||||
min-width: 280px;
|
min-width: 280px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
@ -31,13 +33,25 @@
|
||||||
z-index: 1200;
|
z-index: 1200;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebarCollapsed {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
/* Logo */
|
/* Logo */
|
||||||
.logoContainer {
|
.logoContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
padding: 1.25rem 1rem;
|
padding: 1.25rem 1rem;
|
||||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarCollapsed .logoContainer {
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0.75rem 0.5rem;
|
||||||
|
gap: 0.375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logoImage {
|
.logoImage {
|
||||||
|
|
@ -46,6 +60,55 @@
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logoIcon {
|
||||||
|
height: 28px;
|
||||||
|
width: 28px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapseToggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapseToggle:hover {
|
||||||
|
background: var(--hover-bg, rgba(0, 0, 0, 0.04));
|
||||||
|
color: var(--text-primary, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarCollapsed .collapseToggle {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizeHandle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 4px;
|
||||||
|
height: 100%;
|
||||||
|
cursor: col-resize;
|
||||||
|
z-index: 2;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizeHandle:hover,
|
||||||
|
.resizeHandle:active {
|
||||||
|
background: var(--primary-color, #2563eb);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
.logoText {
|
.logoText {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|
@ -94,6 +157,7 @@
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
--mobile-topbar-height: 0px;
|
--mobile-topbar-height: 0px;
|
||||||
|
--content-inset: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
/* Let child components handle their own scrolling for sticky headers */
|
/* Let child components handle their own scrolling for sticky headers */
|
||||||
|
|
@ -113,6 +177,18 @@
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding-left: var(--content-inset, 16px);
|
||||||
|
padding-right: var(--content-inset, 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* scrollMode: document — .content becomes the scroll container, outletShell is transparent */
|
||||||
|
.content[data-scroll-mode="document"] {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content[data-scroll-mode="document"] .outletShell {
|
||||||
|
overflow: visible;
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobileTopBar {
|
.mobileTopBar {
|
||||||
|
|
@ -159,6 +235,17 @@
|
||||||
filter: brightness(0) invert(1);
|
filter: brightness(0) invert(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .collapseToggle {
|
||||||
|
border-color: var(--border-dark, #333);
|
||||||
|
background: var(--surface-dark, #1a1a1a);
|
||||||
|
color: var(--text-secondary-dark, #aaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .collapseToggle:hover {
|
||||||
|
background: var(--hover-bg-dark, rgba(255, 255, 255, 0.06));
|
||||||
|
color: var(--text-primary-dark, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
:global(.dark-theme) .logoPower {
|
:global(.dark-theme) .logoPower {
|
||||||
color: var(--text-primary-dark, #ffffff);
|
color: var(--text-primary-dark, #ffffff);
|
||||||
}
|
}
|
||||||
|
|
@ -214,13 +301,20 @@
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
width: 280px;
|
||||||
|
min-width: 280px;
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
transform: translateX(-100%);
|
transform: translateX(-100%);
|
||||||
transition: transform 0.2s ease-in-out;
|
transition: transform 0.25s ease;
|
||||||
box-shadow: 0 18px 32px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 18px 32px rgba(0, 0, 0, 0.2);
|
||||||
border-right: 1px solid var(--border-color, #e0e0e0);
|
border-right: 1px solid var(--border-color, #e0e0e0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.collapseToggle,
|
||||||
|
.resizeHandle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
@supports not (height: 100dvh) {
|
@supports not (height: 100dvh) {
|
||||||
.sidebar {
|
.sidebar {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
|
@ -249,6 +343,7 @@
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
--mobile-topbar-height: 57px;
|
--mobile-topbar-height: 57px;
|
||||||
|
--content-inset: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobileBackdrop {
|
.mobileBackdrop {
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,9 @@
|
||||||
* Enthält den FeatureProvider für das Multi-Tenant-System.
|
* Enthält den FeatureProvider für das Multi-Tenant-System.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Outlet, useLocation } from 'react-router-dom';
|
import { Outlet, useLocation } from 'react-router-dom';
|
||||||
|
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
|
||||||
import { FeatureProvider, useFeatureStore } from '../stores/featureStore';
|
import { FeatureProvider, useFeatureStore } from '../stores/featureStore';
|
||||||
import { MandateNavigation } from '../components/Navigation/MandateNavigation';
|
import { MandateNavigation } from '../components/Navigation/MandateNavigation';
|
||||||
import { UserSection } from '../components/Navigation/UserSection';
|
import { UserSection } from '../components/Navigation/UserSection';
|
||||||
|
|
@ -16,17 +17,51 @@ import { RagRunningBadge } from '../components/RagRunningBadge/RagRunningBadge';
|
||||||
import { KEEP_ALIVE_ROUTES, hideFeatureOutlet } from '../config/keepAliveRoutes';
|
import { KEEP_ALIVE_ROUTES, hideFeatureOutlet } from '../config/keepAliveRoutes';
|
||||||
import type { KeepAliveEntry, KeepAliveScopedEntry, KeepAliveUnscopedEntry } from '../types/keepAlive.types';
|
import type { KeepAliveEntry, KeepAliveScopedEntry, KeepAliveUnscopedEntry } from '../types/keepAlive.types';
|
||||||
import { isKeepAliveScoped } from '../types/keepAlive.types';
|
import { isKeepAliveScoped } from '../types/keepAlive.types';
|
||||||
|
import { useScrollMode } from '../hooks/useScrollMode';
|
||||||
|
import { SidebarContext } from './SidebarContext';
|
||||||
import styles from './MainLayout.module.css';
|
import styles from './MainLayout.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
|
|
||||||
|
const SIDEBAR_WIDTH_KEY = 'sidebar-width';
|
||||||
|
const SIDEBAR_COLLAPSED_KEY = 'sidebar-collapsed';
|
||||||
|
const SIDEBAR_WIDTH_DEFAULT = 280;
|
||||||
|
const SIDEBAR_WIDTH_MIN = 180;
|
||||||
|
const SIDEBAR_WIDTH_MAX = 400;
|
||||||
|
const SIDEBAR_COLLAPSED_WIDTH = 60;
|
||||||
|
const DESKTOP_BREAKPOINT = 1024;
|
||||||
|
|
||||||
|
function _readSidebarWidth(): number {
|
||||||
|
try {
|
||||||
|
const value = parseInt(localStorage.getItem(SIDEBAR_WIDTH_KEY) ?? '', 10);
|
||||||
|
if (value >= SIDEBAR_WIDTH_MIN && value <= SIDEBAR_WIDTH_MAX) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return SIDEBAR_WIDTH_DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _readSidebarCollapsed(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _isDesktopViewport(): boolean {
|
||||||
|
return window.innerWidth > DESKTOP_BREAKPOINT;
|
||||||
|
}
|
||||||
|
|
||||||
const keepAliveShellStyle = (isVisible: boolean, shellOverflowHidden: boolean): React.CSSProperties => ({
|
const keepAliveShellStyle = (isVisible: boolean, shellOverflowHidden: boolean): React.CSSProperties => ({
|
||||||
display: isVisible ? 'flex' : 'none',
|
display: isVisible ? 'flex' : 'none',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 'var(--mobile-topbar-height, 0px)',
|
top: 'var(--mobile-topbar-height, 0px)',
|
||||||
left: 0,
|
left: 'var(--content-inset, 16px)',
|
||||||
right: 0,
|
right: 'var(--content-inset, 16px)',
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
...(shellOverflowHidden ? { overflow: 'hidden' as const } : {}),
|
...(shellOverflowHidden ? { overflow: 'hidden' as const } : {}),
|
||||||
});
|
});
|
||||||
|
|
@ -105,12 +140,47 @@ const RoutedKeepAliveSlot: React.FC<{ entry: KeepAliveEntry; pathname: string; s
|
||||||
|
|
||||||
const MainLayoutInner: React.FC = () => {
|
const MainLayoutInner: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const scrollMode = useScrollMode();
|
||||||
|
|
||||||
const { loadFeatures, initialized, loading, error } = useFeatureStore();
|
const { loadFeatures, initialized, loading, error } = useFeatureStore();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
||||||
|
const [isDesktop, setIsDesktop] = useState(_isDesktopViewport);
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(_readSidebarCollapsed);
|
||||||
|
const [sidebarWidth, setSidebarWidth] = useState(_readSidebarWidth);
|
||||||
|
const resizingRef = useRef<{ startX: number; startW: number } | null>(null);
|
||||||
|
const sidebarRef = useRef<HTMLElement>(null);
|
||||||
|
const animFrameRef = useRef<number>(0);
|
||||||
const hideOutletShell = hideFeatureOutlet(location.pathname, location.search);
|
const hideOutletShell = hideFeatureOutlet(location.pathname, location.search);
|
||||||
|
|
||||||
|
const effectiveCollapsed = isDesktop && sidebarCollapsed;
|
||||||
|
const effectiveSidebarWidth = effectiveCollapsed ? SIDEBAR_COLLAPSED_WIDTH : sidebarWidth;
|
||||||
|
|
||||||
|
const toggleSidebarCollapsed = useCallback(() => {
|
||||||
|
setSidebarCollapsed((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, next ? '1' : '0');
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sidebarContextValue = useMemo(
|
||||||
|
() => ({ collapsed: effectiveCollapsed }),
|
||||||
|
[effectiveCollapsed],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sidebarStyle = useMemo(
|
||||||
|
() => ({
|
||||||
|
width: `${effectiveSidebarWidth}px`,
|
||||||
|
minWidth: `${effectiveSidebarWidth}px`,
|
||||||
|
}),
|
||||||
|
[effectiveSidebarWidth],
|
||||||
|
);
|
||||||
|
|
||||||
// Features laden beim Mount
|
// Features laden beim Mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialized && !loading) {
|
if (!initialized && !loading) {
|
||||||
|
|
@ -124,7 +194,9 @@ const MainLayoutInner: React.FC = () => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
if (window.innerWidth > 1024) {
|
const desktop = _isDesktopViewport();
|
||||||
|
setIsDesktop(desktop);
|
||||||
|
if (desktop) {
|
||||||
setIsMobileSidebarOpen(false);
|
setIsMobileSidebarOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -133,7 +205,82 @@ const MainLayoutInner: React.FC = () => {
|
||||||
return () => window.removeEventListener('resize', handleResize);
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (!isDesktop) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.ctrlKey && event.key.toLowerCase() === 'b') {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleSidebarCollapsed();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isDesktop, toggleSidebarCollapsed]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseMove = (event: MouseEvent) => {
|
||||||
|
if (!resizingRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { startX, startW } = resizingRef.current;
|
||||||
|
const nextWidth = Math.max(
|
||||||
|
SIDEBAR_WIDTH_MIN,
|
||||||
|
Math.min(SIDEBAR_WIDTH_MAX, startW + (event.clientX - startX)),
|
||||||
|
);
|
||||||
|
cancelAnimationFrame(animFrameRef.current);
|
||||||
|
animFrameRef.current = requestAnimationFrame(() => {
|
||||||
|
const el = sidebarRef.current;
|
||||||
|
if (el) {
|
||||||
|
el.style.width = `${nextWidth}px`;
|
||||||
|
el.style.minWidth = `${nextWidth}px`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = (event: MouseEvent) => {
|
||||||
|
if (!resizingRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelAnimationFrame(animFrameRef.current);
|
||||||
|
const { startX, startW } = resizingRef.current;
|
||||||
|
const finalWidth = Math.max(
|
||||||
|
SIDEBAR_WIDTH_MIN,
|
||||||
|
Math.min(SIDEBAR_WIDTH_MAX, startW + (event.clientX - startX)),
|
||||||
|
);
|
||||||
|
resizingRef.current = null;
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
setSidebarWidth(finalWidth);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SIDEBAR_WIDTH_KEY, String(finalWidth));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleResizeStart = useCallback((event: React.MouseEvent) => {
|
||||||
|
if (!isDesktop || effectiveCollapsed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
resizingRef.current = { startX: event.clientX, startW: sidebarWidth };
|
||||||
|
document.body.style.cursor = 'col-resize';
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
}, [effectiveCollapsed, isDesktop, sidebarWidth]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<SidebarContext.Provider value={sidebarContextValue}>
|
||||||
<div className={styles.mainLayout}>
|
<div className={styles.mainLayout}>
|
||||||
{isMobileSidebarOpen && (
|
{isMobileSidebarOpen && (
|
||||||
<button
|
<button
|
||||||
|
|
@ -144,9 +291,28 @@ const MainLayoutInner: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside className={`${styles.sidebar} ${isMobileSidebarOpen ? styles.sidebarOpen : ''}`}>
|
<aside
|
||||||
|
ref={sidebarRef}
|
||||||
|
className={`${styles.sidebar} ${isMobileSidebarOpen ? styles.sidebarOpen : ''} ${effectiveCollapsed ? styles.sidebarCollapsed : ''}`}
|
||||||
|
style={isDesktop ? sidebarStyle : undefined}
|
||||||
|
>
|
||||||
<div className={styles.logoContainer}>
|
<div className={styles.logoContainer}>
|
||||||
<img src="/logos/poweron-logo.png" alt="PowerOn" className={styles.logoImage} />
|
<img
|
||||||
|
src={effectiveCollapsed ? '/favicon.png' : '/logos/poweron-logo.png'}
|
||||||
|
alt="PowerOn"
|
||||||
|
className={effectiveCollapsed ? styles.logoIcon : styles.logoImage}
|
||||||
|
/>
|
||||||
|
{isDesktop && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.collapseToggle}
|
||||||
|
onClick={toggleSidebarCollapsed}
|
||||||
|
aria-label={effectiveCollapsed ? t('Sidebar erweitern') : t('Sidebar einklappen')}
|
||||||
|
title={effectiveCollapsed ? t('Sidebar erweitern (Strg+B)') : t('Sidebar einklappen (Strg+B)')}
|
||||||
|
>
|
||||||
|
{effectiveCollapsed ? <FaChevronRight size={12} /> : <FaChevronLeft size={12} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className={styles.navigation}>
|
<nav className={styles.navigation}>
|
||||||
|
|
@ -163,10 +329,20 @@ const MainLayoutInner: React.FC = () => {
|
||||||
|
|
||||||
{/* User-Bereich am unteren Rand */}
|
{/* User-Bereich am unteren Rand */}
|
||||||
<UserSection />
|
<UserSection />
|
||||||
|
|
||||||
|
{isDesktop && !effectiveCollapsed && (
|
||||||
|
<div
|
||||||
|
className={styles.resizeHandle}
|
||||||
|
onMouseDown={handleResizeStart}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-label={t('Sidebar-Breite anpassen')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<main className={styles.content}>
|
<main className={styles.content} data-scroll-mode={scrollMode}>
|
||||||
<div className={styles.mobileTopBar}>
|
<div className={styles.mobileTopBar}>
|
||||||
<button
|
<button
|
||||||
className={styles.mobileMenuButton}
|
className={styles.mobileMenuButton}
|
||||||
|
|
@ -192,6 +368,7 @@ const MainLayoutInner: React.FC = () => {
|
||||||
|
|
||||||
<RagRunningBadge />
|
<RagRunningBadge />
|
||||||
</div>
|
</div>
|
||||||
|
</SidebarContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
14
src/layouts/SidebarContext.tsx
Normal file
14
src/layouts/SidebarContext.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
|
|
||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
export interface SidebarContextValue {
|
||||||
|
collapsed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SidebarContext = createContext<SidebarContextValue>({ collapsed: false });
|
||||||
|
|
||||||
|
export function useSidebar(): SidebarContextValue {
|
||||||
|
return useContext(SidebarContext);
|
||||||
|
}
|
||||||
|
|
@ -9,12 +9,13 @@
|
||||||
* Tab D: Neutralization Mappings — FormGeneratorTable + delete
|
* Tab D: Neutralization Mappings — FormGeneratorTable + delete
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid,
|
ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid,
|
||||||
Tooltip, BarChart, Bar, PieChart, Pie, Cell,
|
Tooltip, BarChart, Bar, PieChart, Pie, Cell,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { FaDownload, FaEye, FaTrash, FaTimes } from 'react-icons/fa';
|
import { FaDownload, FaEye, FaTrash } from 'react-icons/fa';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
import { useApiRequest } from '../hooks/useApi';
|
import { useApiRequest } from '../hooks/useApi';
|
||||||
import { fetchAttributes } from '../api/attributesApi';
|
import { fetchAttributes } from '../api/attributesApi';
|
||||||
|
|
@ -29,6 +30,11 @@ import {
|
||||||
resolvePeriod,
|
resolvePeriod,
|
||||||
type PeriodValue,
|
type PeriodValue,
|
||||||
} from '../components/PeriodPicker';
|
} from '../components/PeriodPicker';
|
||||||
|
import { StackLayout } from '../components/Layout/StackLayout';
|
||||||
|
import { Panel } from '../components/Layout/Panel';
|
||||||
|
import { LayoutTabs } from '../components/Layout/LayoutTabs';
|
||||||
|
import type { LayoutTabItem } from '../components/Layout/types';
|
||||||
|
import ViewStack from '../components/Layout/ViewStack';
|
||||||
import styles from './ComplianceAuditPage.module.css';
|
import styles from './ComplianceAuditPage.module.css';
|
||||||
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
|
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
|
||||||
|
|
||||||
|
|
@ -130,7 +136,7 @@ interface Mandate {
|
||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ContentModalData {
|
interface ContentDetailData {
|
||||||
row: any;
|
row: any;
|
||||||
contentInputFull?: string;
|
contentInputFull?: string;
|
||||||
contentOutputFull?: string;
|
contentOutputFull?: string;
|
||||||
|
|
@ -146,6 +152,7 @@ const _NEUT_PAGE_SIZE = 100;
|
||||||
export const ComplianceAuditPage: React.FC = () => {
|
export const ComplianceAuditPage: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [aiAuditAttrs, setAiAuditAttrs] = useState<AttributeDefinition[]>([]);
|
const [aiAuditAttrs, setAiAuditAttrs] = useState<AttributeDefinition[]>([]);
|
||||||
const [auditLogAttrs, setAuditLogAttrs] = useState<AttributeDefinition[]>([]);
|
const [auditLogAttrs, setAuditLogAttrs] = useState<AttributeDefinition[]>([]);
|
||||||
const [neutAttrs, setNeutAttrs] = useState<AttributeDefinition[]>([]);
|
const [neutAttrs, setNeutAttrs] = useState<AttributeDefinition[]>([]);
|
||||||
|
|
@ -161,7 +168,7 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
const [mandates, setMandates] = useState<Mandate[]>([]);
|
const [mandates, setMandates] = useState<Mandate[]>([]);
|
||||||
const [mandatesLoading, setMandatesLoading] = useState(true);
|
const [mandatesLoading, setMandatesLoading] = useState(true);
|
||||||
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
|
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<TabId>('audit-log');
|
const activeTab = (searchParams.get('tab') as TabId) || 'audit-log';
|
||||||
|
|
||||||
// ── Tab A: AI-Log state ──
|
// ── Tab A: AI-Log state ──
|
||||||
const [aiEntries, setAiEntries] = useState<any[]>([]);
|
const [aiEntries, setAiEntries] = useState<any[]>([]);
|
||||||
|
|
@ -186,10 +193,11 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
const [neutPagination, setNeutPagination] = useState<any>(undefined);
|
const [neutPagination, setNeutPagination] = useState<any>(undefined);
|
||||||
const [neutLoading, setNeutLoading] = useState(false);
|
const [neutLoading, setNeutLoading] = useState(false);
|
||||||
|
|
||||||
// ── Content View Modal state ──
|
const selectedEntryId = searchParams.get('entryId');
|
||||||
const [contentModal, setContentModal] = useState<ContentModalData | null>(null);
|
|
||||||
const [contentModalLoading, setContentModalLoading] = useState(false);
|
const _lastAiLogParams = useRef<any>(undefined);
|
||||||
const [contentModalTab, setContentModalTab] = useState<'input' | 'output'>('input');
|
const _lastAuditLogParams = useRef<any>(undefined);
|
||||||
|
const _lastNeutParams = useRef<any>(undefined);
|
||||||
|
|
||||||
// ── Mandate loader ──
|
// ── Mandate loader ──
|
||||||
|
|
||||||
|
|
@ -218,6 +226,8 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
|
|
||||||
const _loadAiLog = useCallback(async (paginationParams?: any) => {
|
const _loadAiLog = useCallback(async (paginationParams?: any) => {
|
||||||
if (!selectedMandateId) return;
|
if (!selectedMandateId) return;
|
||||||
|
if (paginationParams) _lastAiLogParams.current = paginationParams;
|
||||||
|
else paginationParams = _lastAiLogParams.current;
|
||||||
setAiLoading(true);
|
setAiLoading(true);
|
||||||
try {
|
try {
|
||||||
const page = paginationParams?.page ?? 1;
|
const page = paginationParams?.page ?? 1;
|
||||||
|
|
@ -250,6 +260,8 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
|
|
||||||
const _loadAuditLog = useCallback(async (paginationParams?: any) => {
|
const _loadAuditLog = useCallback(async (paginationParams?: any) => {
|
||||||
if (!selectedMandateId) return;
|
if (!selectedMandateId) return;
|
||||||
|
if (paginationParams) _lastAuditLogParams.current = paginationParams;
|
||||||
|
else paginationParams = _lastAuditLogParams.current;
|
||||||
setAuditLoading(true);
|
setAuditLoading(true);
|
||||||
try {
|
try {
|
||||||
const page = paginationParams?.page ?? 1;
|
const page = paginationParams?.page ?? 1;
|
||||||
|
|
@ -297,6 +309,8 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
|
|
||||||
const _loadNeutMappings = useCallback(async (paginationParams?: any) => {
|
const _loadNeutMappings = useCallback(async (paginationParams?: any) => {
|
||||||
if (!selectedMandateId) return;
|
if (!selectedMandateId) return;
|
||||||
|
if (paginationParams) _lastNeutParams.current = paginationParams;
|
||||||
|
else paginationParams = _lastNeutParams.current;
|
||||||
setNeutLoading(true);
|
setNeutLoading(true);
|
||||||
try {
|
try {
|
||||||
const page = paginationParams?.page ?? 1;
|
const page = paginationParams?.page ?? 1;
|
||||||
|
|
@ -365,37 +379,19 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedMandateId) return;
|
if (!selectedMandateId) return;
|
||||||
if (activeTab === 'ai-log') void _loadAiLog();
|
if (activeTab === 'stats') void _loadStats({ dateFrom: statsPeriod.fromDate, dateTo: statsPeriod.toDate });
|
||||||
else if (activeTab === 'audit-log') void _loadAuditLog();
|
|
||||||
else if (activeTab === 'stats') void _loadStats({ dateFrom: statsPeriod.fromDate, dateTo: statsPeriod.toDate });
|
|
||||||
else if (activeTab === 'neutralization') void _loadNeutMappings();
|
|
||||||
}, [activeTab, selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [activeTab, selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// ── Content view handler (modal) ──
|
// ── Content view handler (modal) ──
|
||||||
|
|
||||||
const _handleContentView = useCallback(async (row: any) => {
|
const _handleContentView = useCallback((row: any) => {
|
||||||
if (!selectedMandateId || !row?.id) return;
|
if (!selectedMandateId || !row?.id) return;
|
||||||
setContentModalLoading(true);
|
setSearchParams((prev) => {
|
||||||
setContentModalTab('input');
|
const next = new URLSearchParams(prev);
|
||||||
setContentModal({ row, neutralizationMappings: [] });
|
next.set('entryId', row.id);
|
||||||
try {
|
return next;
|
||||||
const { data } = await api.get(`/api/audit/ai-log/${row.id}/content`, {
|
}, { replace: true });
|
||||||
headers: _mandateHeaders(),
|
}, [selectedMandateId, setSearchParams]);
|
||||||
});
|
|
||||||
setContentModal({
|
|
||||||
row,
|
|
||||||
contentInputFull: data?.contentInputFull,
|
|
||||||
contentOutputFull: data?.contentOutputFull,
|
|
||||||
contentInputPreview: data?.contentInputPreview,
|
|
||||||
contentOutputPreview: data?.contentOutputPreview,
|
|
||||||
neutralizationMappings: data?.neutralizationMappings ?? [],
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Content load failed:', err);
|
|
||||||
} finally {
|
|
||||||
setContentModalLoading(false);
|
|
||||||
}
|
|
||||||
}, [selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
// ── Content download handler ──
|
// ── Content download handler ──
|
||||||
|
|
||||||
|
|
@ -435,17 +431,107 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// ── Mapping lookup for modal ──
|
const _AiLogDetailView: React.FC = () => {
|
||||||
|
const [contentTab, setContentTab] = useState<'input' | 'output'>('input');
|
||||||
|
const [detail, setDetail] = useState<ContentDetailData | null>(null);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
|
||||||
const _modalMappingLookup = useMemo(() => {
|
useEffect(() => {
|
||||||
|
if (!selectedMandateId || !selectedEntryId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
setDetailLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.get(`/api/audit/ai-log/${selectedEntryId}/content`, {
|
||||||
|
headers: _mandateHeaders(),
|
||||||
|
});
|
||||||
|
if (!cancelled) {
|
||||||
|
setDetail({
|
||||||
|
row: { id: selectedEntryId },
|
||||||
|
contentInputFull: data?.contentInputFull,
|
||||||
|
contentOutputFull: data?.contentOutputFull,
|
||||||
|
contentInputPreview: data?.contentInputPreview,
|
||||||
|
contentOutputPreview: data?.contentOutputPreview,
|
||||||
|
neutralizationMappings: data?.neutralizationMappings ?? [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Content load failed:', err);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setDetailLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [selectedEntryId, selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const mappingLookup = useMemo(() => {
|
||||||
const map = new Map<string, NeutMapping>();
|
const map = new Map<string, NeutMapping>();
|
||||||
if (contentModal?.neutralizationMappings) {
|
if (detail?.neutralizationMappings) {
|
||||||
for (const m of contentModal.neutralizationMappings) {
|
for (const m of detail.neutralizationMappings) {
|
||||||
map.set(m.id, m);
|
map.set(m.id, m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [contentModal?.neutralizationMappings]);
|
}, [detail?.neutralizationMappings]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel variant="editor" title={t('AI-Audit Inhalt')} id="ai-audit-content">
|
||||||
|
{detail?.neutralizationMappings && detail.neutralizationMappings.length > 0 && (
|
||||||
|
<div className={styles.modalMappingBar}>
|
||||||
|
<span className={styles.modalMappingLabel}>
|
||||||
|
{t('{n} Platzhalter aufgelöst', { n: String(detail.neutralizationMappings.length) })}
|
||||||
|
</span>
|
||||||
|
<span className={styles.modalMappingHint}>
|
||||||
|
{t('Hover über markierte Platzhalter für Originaltext')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.modalTabBar}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.modalTab} ${contentTab === 'input' ? styles.modalTabActive : ''}`}
|
||||||
|
onClick={() => setContentTab('input')}
|
||||||
|
>
|
||||||
|
{t('Input')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.modalTab} ${contentTab === 'output' ? styles.modalTabActive : ''}`}
|
||||||
|
onClick={() => setContentTab('output')}
|
||||||
|
>
|
||||||
|
{t('Output')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.modalBody}>
|
||||||
|
{detailLoading ? (
|
||||||
|
<p className={styles.loadingText}>{t('Lade Inhalt…')}</p>
|
||||||
|
) : (
|
||||||
|
<div className={styles.modalTextContent}>
|
||||||
|
{contentTab === 'input' ? (
|
||||||
|
(() => {
|
||||||
|
const text = detail?.contentInputFull
|
||||||
|
|| detail?.contentInputPreview
|
||||||
|
|| t('(kein Input gespeichert)');
|
||||||
|
return mappingLookup.size > 0
|
||||||
|
? _renderHighlightedText(text, mappingLookup)
|
||||||
|
: text;
|
||||||
|
})()
|
||||||
|
) : (
|
||||||
|
(() => {
|
||||||
|
const text = detail?.contentOutputFull
|
||||||
|
|| detail?.contentOutputPreview
|
||||||
|
|| t('(kein Output gespeichert)');
|
||||||
|
return mappingLookup.size > 0
|
||||||
|
? _renderHighlightedText(text, mappingLookup)
|
||||||
|
: text;
|
||||||
|
})()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ── Column definitions ──
|
// ── Column definitions ──
|
||||||
|
|
||||||
|
|
@ -623,107 +709,11 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
|
|
||||||
// ── Render ──
|
// ── Render ──
|
||||||
|
|
||||||
const _tabs: TabId[] = ['audit-log', 'ai-log', 'neutralization', 'stats'];
|
const auditTabs: LayoutTabItem[] = useMemo(() => {
|
||||||
|
if (!selectedMandateId) return [];
|
||||||
|
|
||||||
return (
|
const statsPanel = (
|
||||||
<div className={styles.wrap}>
|
<Panel variant="dashboard" title={t('Statistiken')} id="audit-stats">
|
||||||
<h2 className={styles.pageTitle}>{t('Compliance & AI-Audit')}</h2>
|
|
||||||
<p className={styles.pageDesc}>
|
|
||||||
{t('Transparente Übersicht aller AI-Datenflüsse und Sicherheitsereignisse Ihres Mandanten.')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Mandate selector */}
|
|
||||||
<div className={styles.mandateSelector}>
|
|
||||||
<label className={styles.mandateLabel}>{t('Mandant auswählen')}</label>
|
|
||||||
<select
|
|
||||||
className={styles.mandateSelect}
|
|
||||||
value={selectedMandateId || ''}
|
|
||||||
onChange={e => setSelectedMandateId(e.target.value || null)}
|
|
||||||
disabled={mandatesLoading}
|
|
||||||
>
|
|
||||||
<option value="">{mandatesLoading ? t('Lade…') : t('— Mandant wählen —')}</option>
|
|
||||||
{mandates.map(m => (
|
|
||||||
<option key={m.id} value={m.id}>{mandateDisplayLabel(m)}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!selectedMandateId ? (
|
|
||||||
<p className={styles.emptyText}>{t('Bitte wählen Sie einen Mandanten aus.')}</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Tab bar */}
|
|
||||||
<div className={styles.tabBar}>
|
|
||||||
{_tabs.map(tab => (
|
|
||||||
<button
|
|
||||||
key={tab}
|
|
||||||
className={`${styles.tab} ${activeTab === tab ? styles.tabActive : ''}`}
|
|
||||||
onClick={() => setActiveTab(tab)}
|
|
||||||
>
|
|
||||||
{_tabLabel(tab, t)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Tab A: AI Data-Flow Log ── */}
|
|
||||||
{activeTab === 'ai-log' && (
|
|
||||||
<div className={styles.tabContent}>
|
|
||||||
<FormGeneratorTable
|
|
||||||
key={`ai-log-${selectedMandateId}`}
|
|
||||||
data={aiEntries}
|
|
||||||
columns={aiLogColumns}
|
|
||||||
loading={aiLoading}
|
|
||||||
pagination={true}
|
|
||||||
pageSize={_AI_LOG_PAGE_SIZE}
|
|
||||||
sortable={true}
|
|
||||||
filterable={true}
|
|
||||||
searchable={true}
|
|
||||||
selectable={false}
|
|
||||||
emptyMessage={t('Keine AI-Audit-Einträge vorhanden.')}
|
|
||||||
onRefresh={_loadAiLog}
|
|
||||||
hookData={aiLogHookData}
|
|
||||||
customActions={[
|
|
||||||
{
|
|
||||||
id: 'viewContent',
|
|
||||||
title: t('Input/Output anzeigen'),
|
|
||||||
icon: <FaEye />,
|
|
||||||
onClick: _handleContentView,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'downloadContent',
|
|
||||||
title: t('Input/Output herunterladen'),
|
|
||||||
icon: <FaDownload />,
|
|
||||||
onClick: _handleContentDownload,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Tab B: Audit Log ── */}
|
|
||||||
{activeTab === 'audit-log' && (
|
|
||||||
<div className={styles.tabContent}>
|
|
||||||
<FormGeneratorTable
|
|
||||||
key={`audit-log-${selectedMandateId}`}
|
|
||||||
data={auditEntries}
|
|
||||||
columns={auditLogColumns}
|
|
||||||
loading={auditLoading}
|
|
||||||
pagination={true}
|
|
||||||
pageSize={_AUDIT_LOG_PAGE_SIZE}
|
|
||||||
sortable={true}
|
|
||||||
filterable={true}
|
|
||||||
searchable={true}
|
|
||||||
selectable={false}
|
|
||||||
emptyMessage={t('Keine Audit-Einträge vorhanden.')}
|
|
||||||
onRefresh={_loadAuditLog}
|
|
||||||
hookData={auditLogHookData}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Tab C: Statistics ── */}
|
|
||||||
{activeTab === 'stats' && (
|
|
||||||
<div className={styles.tabContentScrollable}>
|
|
||||||
<div className={styles.statsControls}>
|
<div className={styles.statsControls}>
|
||||||
<PeriodPicker
|
<PeriodPicker
|
||||||
value={statsPeriod}
|
value={statsPeriod}
|
||||||
|
|
@ -747,7 +737,6 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
<p className={styles.emptyText}>{t('Keine Daten verfügbar.')}</p>
|
<p className={styles.emptyText}>{t('Keine Daten verfügbar.')}</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* KPIs */}
|
|
||||||
<div className={styles.kpiGrid}>
|
<div className={styles.kpiGrid}>
|
||||||
<div className={styles.kpiCard}>
|
<div className={styles.kpiCard}>
|
||||||
<p className={styles.kpiValue}>{stats.totalCalls}</p>
|
<p className={styles.kpiValue}>{stats.totalCalls}</p>
|
||||||
|
|
@ -769,7 +758,6 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Charts row 1: Calls/Day + Cost/Day */}
|
|
||||||
<div className={styles.chartRow}>
|
<div className={styles.chartRow}>
|
||||||
<div className={styles.chartBlock}>
|
<div className={styles.chartBlock}>
|
||||||
<h3 className={styles.chartTitle}>{t('AI-Aufrufe pro Tag')}</h3>
|
<h3 className={styles.chartTitle}>{t('AI-Aufrufe pro Tag')}</h3>
|
||||||
|
|
@ -805,7 +793,6 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Charts row 2: By Model (pie) + By Feature (bar) */}
|
|
||||||
<div className={styles.chartRow}>
|
<div className={styles.chartRow}>
|
||||||
<div className={styles.chartBlock}>
|
<div className={styles.chartBlock}>
|
||||||
<h3 className={styles.chartTitle}>{t('AI-Aufrufe nach Modell')}</h3>
|
<h3 className={styles.chartTitle}>{t('AI-Aufrufe nach Modell')}</h3>
|
||||||
|
|
@ -847,7 +834,6 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top Users */}
|
|
||||||
{Object.keys(stats.topUsers).length > 0 && (
|
{Object.keys(stats.topUsers).length > 0 && (
|
||||||
<div className={styles.chartBlock}>
|
<div className={styles.chartBlock}>
|
||||||
<h3 className={styles.chartTitle}>{t('Top-Nutzer nach AI-Aufrufen')}</h3>
|
<h3 className={styles.chartTitle}>{t('Top-Nutzer nach AI-Aufrufen')}</h3>
|
||||||
|
|
@ -867,12 +853,88 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Panel>
|
||||||
)}
|
);
|
||||||
|
|
||||||
{/* ── Tab D: Neutralization Mappings ── */}
|
return [
|
||||||
{activeTab === 'neutralization' && (
|
{
|
||||||
<div className={styles.tabContent}>
|
id: 'audit-log',
|
||||||
|
label: _tabLabel('audit-log', t),
|
||||||
|
render: () => (
|
||||||
|
<Panel variant="table" title={t('Audit-Einträge')} id="audit-log-table">
|
||||||
|
<FormGeneratorTable
|
||||||
|
key={`audit-log-${selectedMandateId}`}
|
||||||
|
data={auditEntries}
|
||||||
|
columns={auditLogColumns}
|
||||||
|
loading={auditLoading}
|
||||||
|
pagination={true}
|
||||||
|
pageSize={_AUDIT_LOG_PAGE_SIZE}
|
||||||
|
sortable={true}
|
||||||
|
filterable={true}
|
||||||
|
searchable={true}
|
||||||
|
selectable={false}
|
||||||
|
initialSort={[{ key: 'timestamp', direction: 'desc' }]}
|
||||||
|
emptyMessage={t('Keine Audit-Einträge vorhanden.')}
|
||||||
|
onRefresh={_loadAuditLog}
|
||||||
|
hookData={auditLogHookData}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ai-log',
|
||||||
|
label: _tabLabel('ai-log', t),
|
||||||
|
render: () => (
|
||||||
|
<ViewStack entityParam="entryId">
|
||||||
|
<ViewStack.View id="list">
|
||||||
|
<Panel variant="table" title={t('AI-Audit-Einträge')} id="ai-log-table">
|
||||||
|
<FormGeneratorTable
|
||||||
|
key={`ai-log-${selectedMandateId}`}
|
||||||
|
data={aiEntries}
|
||||||
|
columns={aiLogColumns}
|
||||||
|
loading={aiLoading}
|
||||||
|
pagination={true}
|
||||||
|
pageSize={_AI_LOG_PAGE_SIZE}
|
||||||
|
sortable={true}
|
||||||
|
filterable={true}
|
||||||
|
searchable={true}
|
||||||
|
selectable={false}
|
||||||
|
initialSort={[{ key: 'timestamp', direction: 'desc' }]}
|
||||||
|
emptyMessage={t('Keine AI-Audit-Einträge vorhanden.')}
|
||||||
|
onRefresh={_loadAiLog}
|
||||||
|
hookData={aiLogHookData}
|
||||||
|
customActions={[
|
||||||
|
{
|
||||||
|
id: 'viewContent',
|
||||||
|
title: t('Input/Output anzeigen'),
|
||||||
|
icon: <FaEye />,
|
||||||
|
onClick: _handleContentView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'downloadContent',
|
||||||
|
title: t('Input/Output herunterladen'),
|
||||||
|
icon: <FaDownload />,
|
||||||
|
onClick: _handleContentDownload,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
</ViewStack.View>
|
||||||
|
<ViewStack.View
|
||||||
|
id="detail"
|
||||||
|
backLabel={t('Zurück zum AI-Datenfluss')}
|
||||||
|
title={t('AI-Audit Inhalt')}
|
||||||
|
>
|
||||||
|
<_AiLogDetailView />
|
||||||
|
</ViewStack.View>
|
||||||
|
</ViewStack>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'neutralization',
|
||||||
|
label: _tabLabel('neutralization', t),
|
||||||
|
render: () => (
|
||||||
|
<Panel variant="table" title={t('Neutralisierungs-Zuordnungen')} id="neutralization-table">
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
key={`neut-${selectedMandateId}`}
|
key={`neut-${selectedMandateId}`}
|
||||||
data={neutEntries}
|
data={neutEntries}
|
||||||
|
|
@ -902,93 +964,84 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Panel>
|
||||||
)}
|
),
|
||||||
</>
|
},
|
||||||
)}
|
{
|
||||||
|
id: 'stats',
|
||||||
|
label: _tabLabel('stats', t),
|
||||||
|
render: () => statsPanel,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [
|
||||||
|
t,
|
||||||
|
selectedMandateId,
|
||||||
|
auditEntries,
|
||||||
|
auditLogColumns,
|
||||||
|
auditLoading,
|
||||||
|
auditLogHookData,
|
||||||
|
_loadAuditLog,
|
||||||
|
aiEntries,
|
||||||
|
aiLogColumns,
|
||||||
|
aiLoading,
|
||||||
|
aiLogHookData,
|
||||||
|
_loadAiLog,
|
||||||
|
_handleContentView,
|
||||||
|
_handleContentDownload,
|
||||||
|
neutEntries,
|
||||||
|
neutColumns,
|
||||||
|
neutLoading,
|
||||||
|
neutHookData,
|
||||||
|
_loadNeutMappings,
|
||||||
|
_handleDeleteMapping,
|
||||||
|
_handleDeleteMappingsBatch,
|
||||||
|
statsPeriod,
|
||||||
|
statsLoading,
|
||||||
|
stats,
|
||||||
|
_loadStats,
|
||||||
|
]);
|
||||||
|
|
||||||
{/* ── Content View Modal ── */}
|
return (
|
||||||
{contentModal && (
|
<StackLayout variant="table">
|
||||||
<div className={styles.modalOverlay}>
|
<StackLayout.Header>
|
||||||
<div className={styles.modalContainer}>
|
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, margin: 0 }}>{t('Compliance & AI-Audit')}</h1>
|
||||||
<div className={styles.modalHeader}>
|
<p className={styles.pageDesc}>
|
||||||
<h3 className={styles.modalTitle}>{t('AI-Audit Inhalt')}</h3>
|
{t('Transparente Übersicht aller AI-Datenflüsse und Sicherheitsereignisse Ihres Mandanten.')}
|
||||||
<div className={styles.modalMeta}>
|
</p>
|
||||||
{contentModal.row?.username || contentModal.row?.userId?.slice(0, 8) || '–'}
|
</StackLayout.Header>
|
||||||
{' · '}
|
<StackLayout.Body>
|
||||||
{contentModal.row?.aiModel || '–'}
|
<Panel variant="toolbar" title={t('Filter')} id="audit-toolbar">
|
||||||
{' · '}
|
<div className={styles.mandateSelector}>
|
||||||
{contentModal.row?.timestamp
|
<label className={styles.mandateLabel}>{t('Mandant auswählen')}</label>
|
||||||
? new Date(contentModal.row.timestamp * 1000).toLocaleString()
|
<select
|
||||||
: '–'}
|
className={styles.mandateSelect}
|
||||||
</div>
|
value={selectedMandateId || ''}
|
||||||
<button
|
onChange={e => setSelectedMandateId(e.target.value || null)}
|
||||||
className={styles.modalClose}
|
disabled={mandatesLoading}
|
||||||
onClick={() => setContentModal(null)}
|
|
||||||
title={t('Schliessen')}
|
|
||||||
>
|
>
|
||||||
<FaTimes />
|
<option value="">{mandatesLoading ? t('Lade…') : t('— Mandant wählen —')}</option>
|
||||||
</button>
|
{mandates.map(m => (
|
||||||
|
<option key={m.id} value={m.id}>{mandateDisplayLabel(m)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
{contentModal.neutralizationMappings.length > 0 && (
|
{!selectedMandateId ? (
|
||||||
<div className={styles.modalMappingBar}>
|
<Panel variant="card" title={t('Mandant auswählen')} id="audit-mandate-empty">
|
||||||
<span className={styles.modalMappingLabel}>
|
<p className={styles.emptyText}>{t('Bitte wählen Sie einen Mandanten aus.')}</p>
|
||||||
{t('{n} Platzhalter aufgelöst', { n: String(contentModal.neutralizationMappings.length) })}
|
</Panel>
|
||||||
</span>
|
|
||||||
<span className={styles.modalMappingHint}>
|
|
||||||
{t('Hover über markierte Platzhalter für Originaltext')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.modalTabBar}>
|
|
||||||
<button
|
|
||||||
className={`${styles.modalTab} ${contentModalTab === 'input' ? styles.modalTabActive : ''}`}
|
|
||||||
onClick={() => setContentModalTab('input')}
|
|
||||||
>
|
|
||||||
{t('Input')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`${styles.modalTab} ${contentModalTab === 'output' ? styles.modalTabActive : ''}`}
|
|
||||||
onClick={() => setContentModalTab('output')}
|
|
||||||
>
|
|
||||||
{t('Output')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.modalBody}>
|
|
||||||
{contentModalLoading ? (
|
|
||||||
<p className={styles.loadingText}>{t('Lade Inhalt…')}</p>
|
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.modalTextContent}>
|
<LayoutTabs
|
||||||
{contentModalTab === 'input' ? (
|
items={auditTabs}
|
||||||
(() => {
|
urlParam="tab"
|
||||||
const text = contentModal.contentInputFull
|
defaultTab="audit-log"
|
||||||
|| contentModal.contentInputPreview
|
preserveSearchParams
|
||||||
|| t('(kein Input gespeichert)');
|
lazy
|
||||||
return _modalMappingLookup.size > 0
|
/>
|
||||||
? _renderHighlightedText(text, _modalMappingLookup)
|
|
||||||
: text;
|
|
||||||
})()
|
|
||||||
) : (
|
|
||||||
(() => {
|
|
||||||
const text = contentModal.contentOutputFull
|
|
||||||
|| contentModal.contentOutputPreview
|
|
||||||
|| t('(kein Output gespeichert)');
|
|
||||||
return _modalMappingLookup.size > 0
|
|
||||||
? _renderHighlightedText(text, _modalMappingLookup)
|
|
||||||
: text;
|
|
||||||
})()
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</StackLayout.Body>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
</div>
|
</StackLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -15,23 +15,18 @@ import type { NavigationMandate, MandateFeature, FeatureInstance as NavFeatureIn
|
||||||
import { getPageIcon } from '../config/pageRegistry';
|
import { getPageIcon } from '../config/pageRegistry';
|
||||||
import { FaArrowRight, FaBuilding } from 'react-icons/fa';
|
import { FaArrowRight, FaBuilding } from 'react-icons/fa';
|
||||||
import OnboardingAssistant from '../components/OnboardingAssistant';
|
import OnboardingAssistant from '../components/OnboardingAssistant';
|
||||||
|
import { StackLayout } from '../components/Layout/StackLayout';
|
||||||
|
import { Panel } from '../components/Layout/Panel';
|
||||||
import styles from './Dashboard.module.css';
|
import styles from './Dashboard.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// INSTANCE CARD
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
interface InstanceCardProps {
|
interface InstanceCardProps {
|
||||||
instance: NavFeatureInstance;
|
instance: NavFeatureInstance;
|
||||||
feature: MandateFeature;
|
feature: MandateFeature;
|
||||||
}
|
}
|
||||||
|
|
||||||
const InstanceCard: React.FC<InstanceCardProps> = ({ instance, feature }) => {
|
const InstanceCard: React.FC<InstanceCardProps> = ({ instance, feature }) => {
|
||||||
// Ersten verfügbaren View-Pfad vom Backend nehmen
|
|
||||||
const targetPath = instance.views.length > 0 ? instance.views[0].uiPath : undefined;
|
const targetPath = instance.views.length > 0 ? instance.views[0].uiPath : undefined;
|
||||||
|
|
||||||
if (!targetPath) return null;
|
if (!targetPath) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -52,39 +47,45 @@ const InstanceCard: React.FC<InstanceCardProps> = ({ instance, feature }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// DASHBOARD PAGE
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export const DashboardPage: React.FC = () => {
|
export const DashboardPage: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { dynamicBlock, loading } = useNavigation();
|
const { dynamicBlock, loading } = useNavigation();
|
||||||
|
|
||||||
// Alle Mandate und deren Features/Instanzen aus der Navigation
|
|
||||||
const mandates: NavigationMandate[] = dynamicBlock?.mandates || [];
|
const mandates: NavigationMandate[] = dynamicBlock?.mandates || [];
|
||||||
|
|
||||||
// Gesamtzahl Instanzen und Mandate berechnen
|
|
||||||
let totalInstances = 0;
|
let totalInstances = 0;
|
||||||
const totalMandates = mandates.length;
|
const totalMandates = mandates.length;
|
||||||
mandates.forEach(m => m.features.forEach(f => {
|
mandates.forEach(m => m.features.forEach(f => {
|
||||||
totalInstances += f.instances.length;
|
totalInstances += f.instances.length;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const mandateSections = mandates
|
||||||
|
.filter(mandate => mandate.features.some(f => f.instances.length > 0))
|
||||||
|
.map(mandate => {
|
||||||
|
const mandateInstances: { instance: NavFeatureInstance; feature: MandateFeature }[] = [];
|
||||||
|
for (const feature of mandate.features) {
|
||||||
|
for (const instance of feature.instances) {
|
||||||
|
mandateInstances.push({ instance, feature });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { mandate, mandateInstances };
|
||||||
|
});
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.dashboard}>
|
<StackLayout variant="dashboard">
|
||||||
<header className={styles.header}>
|
<StackLayout.Header>
|
||||||
<h1>{t('Übersicht')}</h1>
|
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, margin: 0 }}>{t('Übersicht')}</h1>
|
||||||
<p className={styles.subtitle}>{t('Lade')}</p>
|
<p className={styles.subtitle}>{t('Lade')}</p>
|
||||||
</header>
|
</StackLayout.Header>
|
||||||
</div>
|
</StackLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.dashboard}>
|
<StackLayout variant="dashboard">
|
||||||
<header className={styles.header}>
|
<StackLayout.Header>
|
||||||
<h1>{t('Übersicht')}</h1>
|
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, margin: 0 }}>{t('Übersicht')}</h1>
|
||||||
{totalInstances > 0 && (
|
{totalInstances > 0 && (
|
||||||
<p className={styles.subtitle}>
|
<p className={styles.subtitle}>
|
||||||
{t('{instanceCount} Feature-Instanzen in {mandateCount} Mandanten', {
|
{t('{instanceCount} Feature-Instanzen in {mandateCount} Mandanten', {
|
||||||
|
|
@ -93,28 +94,24 @@ export const DashboardPage: React.FC = () => {
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</header>
|
</StackLayout.Header>
|
||||||
|
<StackLayout.Body>
|
||||||
|
<Panel variant="card" title={t('Erste Schritte')} id="dashboard-onboarding">
|
||||||
<OnboardingAssistant />
|
<OnboardingAssistant />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<main className={styles.content}>
|
{mandateSections.map(({ mandate, mandateInstances }) => (
|
||||||
{mandates
|
<Panel
|
||||||
.filter(mandate => mandate.features.some(f => f.instances.length > 0))
|
key={mandate.id}
|
||||||
.map(mandate => {
|
variant="dashboard"
|
||||||
// Alle Instanzen dieses Mandats sammeln (flach, ohne Feature-Gruppierung)
|
id={`mandate-dashboard-${mandate.id}`}
|
||||||
const mandateInstances: { instance: NavFeatureInstance; feature: MandateFeature }[] = [];
|
title={(
|
||||||
for (const feature of mandate.features) {
|
<span className={styles.sectionTitle}>
|
||||||
for (const instance of feature.instances) {
|
|
||||||
mandateInstances.push({ instance, feature });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section key={mandate.id} className={styles.featureSection}>
|
|
||||||
<h2 className={styles.sectionTitle}>
|
|
||||||
<FaBuilding />
|
<FaBuilding />
|
||||||
<span>{mandate.uiLabel}</span>
|
<span>{mandate.uiLabel}</span>
|
||||||
</h2>
|
</span>
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className={styles.instanceGrid}>
|
<div className={styles.instanceGrid}>
|
||||||
{mandateInstances.map(({ instance, feature }) => (
|
{mandateInstances.map(({ instance, feature }) => (
|
||||||
<InstanceCard
|
<InstanceCard
|
||||||
|
|
@ -124,11 +121,10 @@ export const DashboardPage: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</Panel>
|
||||||
);
|
))}
|
||||||
})}
|
</StackLayout.Body>
|
||||||
</main>
|
</StackLayout>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,10 @@
|
||||||
.featureView {
|
.featureView {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
flex: 1;
|
||||||
overflow: hidden;
|
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewHeader {
|
.viewHeader {
|
||||||
|
|
@ -29,31 +30,8 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
min-width: 0;
|
||||||
padding: 1.5rem;
|
overflow: hidden;
|
||||||
}
|
|
||||||
|
|
||||||
/* Placeholder View */
|
|
||||||
.placeholder {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 400px;
|
|
||||||
text-align: center;
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder h2 {
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary, #1a1a1a);
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder p {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--text-secondary, #666);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Not Found */
|
/* Not Found */
|
||||||
|
|
@ -92,12 +70,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.dark-theme) .viewTitle,
|
:global(.dark-theme) .viewTitle,
|
||||||
:global(.dark-theme) .placeholder h2,
|
|
||||||
:global(.dark-theme) .notFound h2 {
|
:global(.dark-theme) .notFound h2 {
|
||||||
color: var(--text-primary-dark, #ffffff);
|
color: var(--text-primary-dark, #ffffff);
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.dark-theme) .placeholder p,
|
|
||||||
:global(.dark-theme) .notFound p,
|
:global(.dark-theme) .notFound p,
|
||||||
:global(.dark-theme) .accessDenied p {
|
:global(.dark-theme) .accessDenied p {
|
||||||
color: var(--text-secondary-dark, #aaa);
|
color: var(--text-secondary-dark, #aaa);
|
||||||
|
|
@ -112,3 +88,9 @@
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* scrollMode: document — view grows with content, no internal scroll */
|
||||||
|
:global(html[data-scroll-mode="document"]) .featureView {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,35 +52,6 @@ import styles from './FeatureView.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// PLACEHOLDER VIEWS (für nicht implementierte Features)
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
const PlaceholderView: React.FC<{ title: string; description: string }> = ({ title, description }) => (
|
|
||||||
<div className={styles.placeholder}>
|
|
||||||
<h2>{title}</h2>
|
|
||||||
<p>{description}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Chatworkflow Views
|
|
||||||
const ChatworkflowDashboard: React.FC = () => {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
return (
|
|
||||||
<PlaceholderView title={t('Workflow-Dashboard')} description={t('Übersicht der Workflows')} />
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ChatworkflowRuns: React.FC = () => {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
return <PlaceholderView title={t('Ausführungen')} description={t('Workflow-Ausführungen')} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ChatworkflowFiles: React.FC = () => {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
return <PlaceholderView title={t('Dateien')} description={t('Workflow-Dateien')} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Generic/Fallback
|
// Generic/Fallback
|
||||||
const NotFound: React.FC = () => {
|
const NotFound: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
@ -118,11 +89,6 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
||||||
analyse: TrusteeAnalyseView,
|
analyse: TrusteeAnalyseView,
|
||||||
abschluss: TrusteeAbschlussView,
|
abschluss: TrusteeAbschlussView,
|
||||||
},
|
},
|
||||||
chatworkflow: {
|
|
||||||
dashboard: ChatworkflowDashboard,
|
|
||||||
runs: ChatworkflowRuns,
|
|
||||||
files: ChatworkflowFiles,
|
|
||||||
},
|
|
||||||
realestate: {
|
realestate: {
|
||||||
dashboard: RealEstatePekView,
|
dashboard: RealEstatePekView,
|
||||||
'instance-roles': RealEstateInstanceRolesPlaceholder,
|
'instance-roles': RealEstateInstanceRolesPlaceholder,
|
||||||
|
|
@ -208,9 +174,7 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.featureView}>
|
<div className={styles.featureView}>
|
||||||
<main className={styles.viewContent}>
|
|
||||||
<ViewComponent />
|
<ViewComponent />
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,9 @@ import { Link } from 'react-router-dom';
|
||||||
import { FaDownload, FaFileExport, FaShieldAlt, FaSpinner, FaTrash } from 'react-icons/fa';
|
import { FaDownload, FaFileExport, FaShieldAlt, FaSpinner, FaTrash } from 'react-icons/fa';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
import { clearUserDataCache } from '../utils/userCache';
|
import { clearUserDataCache } from '../utils/userCache';
|
||||||
|
import { StackLayout } from '../components/Layout/StackLayout';
|
||||||
|
import { Panel } from '../components/Layout/Panel';
|
||||||
import styles from './GDPR.module.css';
|
import styles from './GDPR.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
|
|
||||||
type ConsentInfo = {
|
type ConsentInfo = {
|
||||||
|
|
@ -27,7 +28,7 @@ type ActionMessage = {
|
||||||
text: string;
|
text: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadJson = (data: unknown, fileName: string, mimeType = 'application/json') => {
|
const _downloadJson = (data: unknown, fileName: string, mimeType = 'application/json') => {
|
||||||
const fileBlob = new Blob([JSON.stringify(data, null, 2)], { type: mimeType });
|
const fileBlob = new Blob([JSON.stringify(data, null, 2)], { type: mimeType });
|
||||||
const fileUrl = URL.createObjectURL(fileBlob);
|
const fileUrl = URL.createObjectURL(fileBlob);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
|
|
@ -41,7 +42,8 @@ const downloadJson = (data: unknown, fileName: string, mimeType = 'application/j
|
||||||
|
|
||||||
export const GDPRPage: React.FC = () => {
|
export const GDPRPage: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const contactEmail = 'p.motsch@poweron.swiss';
|
const contactEmail = 'privacy@poweron.swiss';
|
||||||
|
const deleteWord = t('LOESCHEN');
|
||||||
const [consentInfo, setConsentInfo] = useState<ConsentInfo | null>(null);
|
const [consentInfo, setConsentInfo] = useState<ConsentInfo | null>(null);
|
||||||
const [isLoadingConsent, setIsLoadingConsent] = useState(true);
|
const [isLoadingConsent, setIsLoadingConsent] = useState(true);
|
||||||
const [consentError, setConsentError] = useState<string | null>(null);
|
const [consentError, setConsentError] = useState<string | null>(null);
|
||||||
|
|
@ -92,7 +94,7 @@ export const GDPRPage: React.FC = () => {
|
||||||
setActionMessage(null);
|
setActionMessage(null);
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/api/user/me/data-export');
|
const response = await api.get('/api/user/me/data-export');
|
||||||
downloadJson(response.data, 'gdpr-data-export.json');
|
_downloadJson(response.data, 'gdpr-data-export.json');
|
||||||
setActionMessage({ type: 'success', text: t('Datenexport heruntergeladen.') });
|
setActionMessage({ type: 'success', text: t('Datenexport heruntergeladen.') });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('GDPR export failed:', error);
|
console.error('GDPR export failed:', error);
|
||||||
|
|
@ -108,9 +110,9 @@ export const GDPRPage: React.FC = () => {
|
||||||
setActionMessage(null);
|
setActionMessage(null);
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/api/user/me/data-portability', {
|
const response = await api.get('/api/user/me/data-portability', {
|
||||||
headers: { Accept: 'application/ld+json' }
|
headers: { Accept: 'application/ld+json' },
|
||||||
});
|
});
|
||||||
downloadJson(response.data, 'gdpr-data-portability.json', 'application/ld+json');
|
_downloadJson(response.data, 'gdpr-data-portability.json', 'application/ld+json');
|
||||||
setActionMessage({ type: 'success', text: t('Portabler Export heruntergeladen.') });
|
setActionMessage({ type: 'success', text: t('Portabler Export heruntergeladen.') });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('GDPR portability export failed:', error);
|
console.error('GDPR portability export failed:', error);
|
||||||
|
|
@ -122,8 +124,8 @@ export const GDPRPage: React.FC = () => {
|
||||||
|
|
||||||
const handleDeleteAccount = async () => {
|
const handleDeleteAccount = async () => {
|
||||||
setActionMessage(null);
|
setActionMessage(null);
|
||||||
if (deleteConfirmText !== 'LOESCHEN') {
|
if (deleteConfirmText !== deleteWord) {
|
||||||
setActionMessage({ type: 'error', text: t('Bitte geben Sie LOESCHEN ein, um die Löschung zu bestätigen.') });
|
setActionMessage({ type: 'error', text: t('Bitte geben Sie {word} ein, um die Löschung zu bestätigen.', { word: deleteWord }) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,10 +147,11 @@ export const GDPRPage: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.gdpr}>
|
<StackLayout variant="scroll">
|
||||||
<header className={styles.header}>
|
<StackLayout.Header>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.title}>
|
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, margin: 0, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
<FaShieldAlt className={styles.titleIcon} />
|
<FaShieldAlt className={styles.titleIcon} />
|
||||||
{t('DSGVO / Datenschutz')}
|
{t('DSGVO / Datenschutz')}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
@ -159,11 +162,10 @@ export const GDPRPage: React.FC = () => {
|
||||||
<Link to="/settings" className={styles.backLink}>
|
<Link to="/settings" className={styles.backLink}>
|
||||||
{t('Zurück zu Einstellungen')}
|
{t('Zurück zu Einstellungen')}
|
||||||
</Link>
|
</Link>
|
||||||
</header>
|
</div>
|
||||||
|
</StackLayout.Header>
|
||||||
<main className={styles.content}>
|
<StackLayout.Body>
|
||||||
<section className={styles.section}>
|
<Panel variant="card" title={t('Ihre Datenrechte')} id="gdpr-data-rights">
|
||||||
<h2 className={styles.sectionTitle}>{t('Ihre Datenrechte')}</h2>
|
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<div className={styles.actionCard}>
|
<div className={styles.actionCard}>
|
||||||
<h3>{t('Zugriff (Artikel 15)')}</h3>
|
<h3>{t('Zugriff (Artikel 15)')}</h3>
|
||||||
|
|
@ -226,14 +228,14 @@ export const GDPRPage: React.FC = () => {
|
||||||
<div className={styles.deleteConfirm}>
|
<div className={styles.deleteConfirm}>
|
||||||
<p className={styles.deleteWarning}>
|
<p className={styles.deleteWarning}>
|
||||||
{t('Diese Aktion ist unwiderruflich. Geben Sie {word} ein, um zu bestätigen.', {
|
{t('Diese Aktion ist unwiderruflich. Geben Sie {word} ein, um zu bestätigen.', {
|
||||||
word: 'LOESCHEN',
|
word: deleteWord,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
className={styles.deleteInput}
|
className={styles.deleteInput}
|
||||||
value={deleteConfirmText}
|
value={deleteConfirmText}
|
||||||
onChange={(event) => setDeleteConfirmText(event.target.value)}
|
onChange={(event) => setDeleteConfirmText(event.target.value)}
|
||||||
placeholder="LOESCHEN"
|
placeholder={deleteWord}
|
||||||
disabled={isDeleting}
|
disabled={isDeleting}
|
||||||
/>
|
/>
|
||||||
<div className={styles.deleteActions}>
|
<div className={styles.deleteActions}>
|
||||||
|
|
@ -250,7 +252,7 @@ export const GDPRPage: React.FC = () => {
|
||||||
<button
|
<button
|
||||||
className={styles.dangerButton}
|
className={styles.dangerButton}
|
||||||
onClick={handleDeleteAccount}
|
onClick={handleDeleteAccount}
|
||||||
disabled={isDeleting || deleteConfirmText !== 'LOESCHEN'}
|
disabled={isDeleting || deleteConfirmText !== deleteWord}
|
||||||
>
|
>
|
||||||
{isDeleting ? (
|
{isDeleting ? (
|
||||||
<span className={styles.buttonSpinner}>
|
<span className={styles.buttonSpinner}>
|
||||||
|
|
@ -279,10 +281,9 @@ export const GDPRPage: React.FC = () => {
|
||||||
{actionMessage.text}
|
{actionMessage.text}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</Panel>
|
||||||
|
|
||||||
<section className={styles.section}>
|
<Panel variant="card" title={t('Verarbeitungsinformationen')} id="gdpr-processing-info">
|
||||||
<h2 className={styles.sectionTitle}>{t('Verarbeitungsinformationen')}</h2>
|
|
||||||
{isLoadingConsent && <p className={styles.mutedText}>{t('Lade Einwilligungsinformationen')}</p>}
|
{isLoadingConsent && <p className={styles.mutedText}>{t('Lade Einwilligungsinformationen')}</p>}
|
||||||
{consentError && <p className={styles.errorText}>{consentError}</p>}
|
{consentError && <p className={styles.errorText}>{consentError}</p>}
|
||||||
{!isLoadingConsent && !consentError && consentInfo && (
|
{!isLoadingConsent && !consentError && consentInfo && (
|
||||||
|
|
@ -290,51 +291,41 @@ export const GDPRPage: React.FC = () => {
|
||||||
<div className={styles.infoBlock}>
|
<div className={styles.infoBlock}>
|
||||||
<h3>{t('Gesammelte Daten')}</h3>
|
<h3>{t('Gesammelte Daten')}</h3>
|
||||||
<ul>
|
<ul>
|
||||||
{Object.entries(consentInfo.dataCollected || {}).map(([key, value]) => (
|
<li><strong>{t('Profil')}:</strong> {t('Name, E-Mail, Benutzername, Spracheinstellungen')}</li>
|
||||||
<li key={key}>
|
<li><strong>{t('Authentifizierung')}:</strong> {t('Login-Zeitstempel, Authentifizierungsanbieter')}</li>
|
||||||
<strong>{key}:</strong> {value}
|
<li><strong>{t('Mitgliedschaften')}:</strong> {t('Mandanten- und Feature-Zugriffseinträge')}</li>
|
||||||
</li>
|
<li><strong>{t('Aktivität')}:</strong> {t('Audit-Logs für sicherheitsrelevante Aktionen')}</li>
|
||||||
))}
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.infoBlock}>
|
<div className={styles.infoBlock}>
|
||||||
<h3>{t('Verarbeitung')}</h3>
|
<h3>{t('Verarbeitung')}</h3>
|
||||||
<ul>
|
<ul>
|
||||||
{Object.entries(consentInfo.dataProcessing || {}).map(([key, value]) => (
|
<li><strong>{t('Zweck')}:</strong> {t('Bereitstellung von Multi-Tenant-Plattformdiensten')}</li>
|
||||||
<li key={key}>
|
<li><strong>{t('Rechtsgrundlage')}:</strong> {t('Vertragserfüllung und berechtigtes Interesse')}</li>
|
||||||
<strong>{key}:</strong> {value}
|
<li><strong>{t('Aufbewahrung')}:</strong> {t('Daten werden aufbewahrt, solange das Konto aktiv ist, und bei Kontolöschung gelöscht')}</li>
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.infoBlock}>
|
<div className={styles.infoBlock}>
|
||||||
<h3>{t('Ihre Rechte')}</h3>
|
<h3>{t('Ihre Rechte')}</h3>
|
||||||
<ul>
|
<ul>
|
||||||
{Object.entries(consentInfo.userRights || {}).map(([key, value]) => (
|
<li><strong>{t('Auskunft')}:</strong> {t('Datenexport (Artikel 15)')}</li>
|
||||||
<li key={key}>
|
<li><strong>{t('Datenübertragbarkeit')}:</strong> {t('Portabler Export (Artikel 20)')}</li>
|
||||||
<strong>{key}:</strong> {value}
|
<li><strong>{t('Löschung')}:</strong> {t('Kontolöschung (Artikel 17)')}</li>
|
||||||
</li>
|
<li><strong>{t('Berichtigung')}:</strong> {t('Profildaten bearbeiten (Artikel 16)')}</li>
|
||||||
))}
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.infoBlock}>
|
<div className={styles.infoBlock}>
|
||||||
<h3>{t('Kontakt')}</h3>
|
<h3>{t('Kontakt')}</h3>
|
||||||
<ul>
|
<ul>
|
||||||
{Object.entries({
|
<li><strong>{t('E-Mail')}:</strong> {contactEmail}</li>
|
||||||
...(consentInfo.contact || {}),
|
<li><strong>{t('Hinweis')}:</strong> {t('Bei Datenschutzanfragen kontaktieren Sie uns bitte mit Ihrer Benutzer-ID.')}</li>
|
||||||
email: contactEmail,
|
|
||||||
}).map(([key, value]) => (
|
|
||||||
<li key={key}>
|
|
||||||
<strong>{key}:</strong> {value}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</Panel>
|
||||||
</main>
|
</StackLayout.Body>
|
||||||
</div>
|
</StackLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
import { useIntegrationsOverview, type DataLayerItem, type LiveStats } from '../hooks/useIntegrationsOverview';
|
import { useIntegrationsOverview, type DataLayerItem, type LiveStats } from '../hooks/useIntegrationsOverview';
|
||||||
|
import { StackLayout } from '../components/Layout/StackLayout';
|
||||||
|
import { Panel } from '../components/Layout/Panel';
|
||||||
import styles from './IntegrationsOverview.module.css';
|
import styles from './IntegrationsOverview.module.css';
|
||||||
|
|
||||||
/** de-CH: 1'234'567 */
|
/** de-CH: 1'234'567 */
|
||||||
|
|
@ -211,14 +213,18 @@ export const IntegrationsOverviewPage: React.FC = () => {
|
||||||
}, [diagram?.dataLayerItems]);
|
}, [diagram?.dataLayerItems]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.pageRoot}>
|
<StackLayout variant="scroll">
|
||||||
<div className={styles.pageIntro}>
|
<StackLayout.Header>
|
||||||
<h1 className={styles.pageHeading}>{t('Integrationen')}</h1>
|
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, margin: 0 }}>{t('Integrationen')}</h1>
|
||||||
<p className={styles.pageLead}>
|
</StackLayout.Header>
|
||||||
|
<StackLayout.Body>
|
||||||
|
<Panel variant="card" title={t('Einleitung')} id="integrations-lead">
|
||||||
|
<p className={styles.pageLead} style={{ margin: 0 }}>
|
||||||
{t('PORTA Architektur — Daten, Verarbeitung und Mandanten auf einen Blick.')}
|
{t('PORTA Architektur — Daten, Verarbeitung und Mandanten auf einen Blick.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
|
<Panel variant="card" title={t('Architekturdiagramm')} id="integrations-diagram">
|
||||||
<h2 className={styles.srOnly}>
|
<h2 className={styles.srOnly}>
|
||||||
{t('PORTA Architektur v3: Drei separate Boxen in Schicht 2 — Infrastruktur, PORTA, Nutzen')}
|
{t('PORTA Architektur v3: Drei separate Boxen in Schicht 2 — Infrastruktur, PORTA, Nutzen')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
@ -489,6 +495,8 @@ export const IntegrationsOverviewPage: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
|
</StackLayout.Body>
|
||||||
|
</StackLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -32,12 +32,14 @@ import { FaCheckCircle, FaTimesCircle, FaSpinner, FaSignInAlt, FaUserPlus } from
|
||||||
import styles from './InvitePage.module.css';
|
import styles from './InvitePage.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
|
import { useDocumentTitle } from '../hooks/useDocumentTitle';
|
||||||
|
|
||||||
// Key for storing pending invitation token
|
// Key for storing pending invitation token
|
||||||
export const PENDING_INVITATION_KEY = 'pendingInvitationToken';
|
export const PENDING_INVITATION_KEY = 'pendingInvitationToken';
|
||||||
|
|
||||||
export const InvitePage: React.FC = () => {
|
export const InvitePage: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
useDocumentTitle(t('Einladung annehmen'));
|
||||||
|
|
||||||
const { token } = useParams<{ token: string }>();
|
const { token } = useParams<{ token: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import styles from './Login.module.css';
|
||||||
import { LanguageSelector } from '../components/UiComponents/LanguageSelector';
|
import { LanguageSelector } from '../components/UiComponents/LanguageSelector';
|
||||||
|
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
|
import { useDocumentTitle } from '../hooks/useDocumentTitle';
|
||||||
|
|
||||||
type LoginPhase = 'credentials' | 'mfa_code' | 'mfa_setup';
|
type LoginPhase = 'credentials' | 'mfa_code' | 'mfa_setup';
|
||||||
|
|
||||||
|
|
@ -47,8 +48,9 @@ function Login() {
|
||||||
const fromLocation = location.state?.from;
|
const fromLocation = location.state?.from;
|
||||||
const from = (fromLocation?.pathname || "/") + (fromLocation?.search || "");
|
const from = (fromLocation?.pathname || "/") + (fromLocation?.search || "");
|
||||||
|
|
||||||
|
useDocumentTitle(t('Login'));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = "PowerOn AI Platform - Login";
|
|
||||||
generateAndStoreCSRFToken();
|
generateAndStoreCSRFToken();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
|
||||||
import { LanguageSelector } from '../components/UiComponents/LanguageSelector';
|
import { LanguageSelector } from '../components/UiComponents/LanguageSelector';
|
||||||
|
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
|
import { useDocumentTitle } from '../hooks/useDocumentTitle';
|
||||||
|
|
||||||
function PasswordResetRequest() {
|
function PasswordResetRequest() {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
@ -20,9 +21,9 @@ function PasswordResetRequest() {
|
||||||
const [validationError, setValidationError] = useState<string | null>(null);
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
// Set page title and generate CSRF token
|
useDocumentTitle(t('Passwort zurücksetzen'));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = "PowerOn AI Platform - Passwort zurücksetzen";
|
|
||||||
generateAndStoreCSRFToken();
|
generateAndStoreCSRFToken();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,6 @@
|
||||||
max-width: 1100px;
|
max-width: 1100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Page Header ── */
|
|
||||||
.pageHeader {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
gap: 16px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.headerLeft {
|
.headerLeft {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -10,24 +10,29 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
import { useApiRequest } from '../hooks/useApi';
|
import { useApiRequest } from '../hooks/useApi';
|
||||||
import { useConfirm } from '../hooks/useConfirm';
|
import { useConfirm } from '../hooks/useConfirm';
|
||||||
import type { RagInventoryDto, RagConnectionDto, RagFeatureInstanceDto } from '../api/connectionApi';
|
import type { RagInventoryDto, RagConnectionDto, RagDataSourceDto, RagFeatureInstanceDto } from '../api/connectionApi';
|
||||||
|
import api from '../api';
|
||||||
import { FaDatabase, FaStop, FaSync, FaToggleOn, FaToggleOff, FaRedo, FaExclamationTriangle, FaCheckCircle, FaSlidersH, FaCubes } from 'react-icons/fa';
|
import { FaDatabase, FaStop, FaSync, FaToggleOn, FaToggleOff, FaRedo, FaExclamationTriangle, FaCheckCircle, FaSlidersH, FaCubes } from 'react-icons/fa';
|
||||||
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
|
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
|
||||||
import { DataSourceSettingsModal } from '../components/UnifiedDataBar/DataSourceSettingsModal';
|
import { DataSourceSettingsModal } from '../components/UnifiedDataBar/DataSourceSettingsModal';
|
||||||
|
import { StackLayout } from '../components/Layout/StackLayout';
|
||||||
|
import { Panel } from '../components/Layout/Panel';
|
||||||
import styles from './RagInventoryPage.module.css';
|
import styles from './RagInventoryPage.module.css';
|
||||||
|
|
||||||
export const RagInventoryPage: React.FC = () => {
|
export const RagInventoryPage: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
const { confirm, ConfirmDialog } = useConfirm();
|
const { confirm, ConfirmDialog } = useConfirm();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
const [mandates, setMandates] = useState<any[]>([]);
|
const [mandates, setMandates] = useState<any[]>([]);
|
||||||
const [mandatesLoading, setMandatesLoading] = useState(true);
|
const [mandatesLoading, setMandatesLoading] = useState(true);
|
||||||
const [selectedScope, setSelectedScope] = useState<string>('personal');
|
const selectedScope = searchParams.get('context') || 'personal';
|
||||||
const [onlyMyData, setOnlyMyData] = useState(false);
|
const onlyMyData = searchParams.get('onlyMine') === 'true';
|
||||||
|
|
||||||
const [inventory, setInventory] = useState<RagInventoryDto | null>(null);
|
const [inventory, setInventory] = useState<RagInventoryDto | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
@ -60,13 +65,37 @@ export const RagInventoryPage: React.FC = () => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
const list = Array.isArray(data) ? data : [];
|
const list = Array.isArray(data) ? data : [];
|
||||||
setMandates(list);
|
setMandates(list);
|
||||||
if (list.length === 1) setSelectedScope(list[0].id);
|
if (list.length === 1 && selectedScope === 'personal') {
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
next.set('context', list[0].id);
|
||||||
|
return next;
|
||||||
|
}, { replace: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
finally { if (!cancelled) setMandatesLoading(false); }
|
finally { if (!cancelled) setMandatesLoading(false); }
|
||||||
})();
|
})();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [request]);
|
}, [request, selectedScope, setSearchParams]);
|
||||||
|
|
||||||
|
const _handleScopeChange = useCallback((value: string) => {
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
if (value === 'personal') next.delete('context');
|
||||||
|
else next.set('context', value);
|
||||||
|
return next;
|
||||||
|
}, { replace: true });
|
||||||
|
}, [setSearchParams]);
|
||||||
|
|
||||||
|
const _handleOnlyMyDataChange = useCallback((checked: boolean) => {
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
if (checked) next.set('onlyMine', 'true');
|
||||||
|
else next.delete('onlyMine');
|
||||||
|
return next;
|
||||||
|
}, { replace: true });
|
||||||
|
}, [setSearchParams]);
|
||||||
|
|
||||||
const _apiEndpoint = useMemo(() => {
|
const _apiEndpoint = useMemo(() => {
|
||||||
if (selectedScope === 'personal') return '/api/rag/inventory/me';
|
if (selectedScope === 'personal') return '/api/rag/inventory/me';
|
||||||
|
|
@ -141,6 +170,15 @@ export const RagInventoryPage: React.FC = () => {
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _handleDsRagToggle = async (connectionId: string, ds: RagDataSourceDto) => {
|
||||||
|
const nodeKey = `ds|${connectionId}|${ds.sourceType}|${ds.path}`;
|
||||||
|
const newValue = !ds.ragIndexEnabled;
|
||||||
|
try {
|
||||||
|
await api.post(`/api/udb/node/${encodeURIComponent(nodeKey)}/flag/ragIndexEnabled`, { value: newValue });
|
||||||
|
_fetchInventory();
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
const _handleConsentToggle = async (connectionId: string, currentEnabled: boolean) => {
|
const _handleConsentToggle = async (connectionId: string, currentEnabled: boolean) => {
|
||||||
if (currentEnabled) {
|
if (currentEnabled) {
|
||||||
const ok = await confirm(t('Alle indexierten Inhalte dieser Verbindung werden entfernt. Fortfahren?'), { variant: 'danger', confirmLabel: t('Fortfahren') });
|
const ok = await confirm(t('Alle indexierten Inhalte dieser Verbindung werden entfernt. Fortfahren?'), { variant: 'danger', confirmLabel: t('Fortfahren') });
|
||||||
|
|
@ -200,25 +238,30 @@ export const RagInventoryPage: React.FC = () => {
|
||||||
}, [mandates, t]);
|
}, [mandates, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<StackLayout variant="scroll">
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
<header className={styles.pageHeader}>
|
<StackLayout.Header>
|
||||||
<div className={styles.headerLeft}>
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.75rem' }}>
|
||||||
<FaDatabase className={styles.headerIcon} />
|
<FaDatabase className={styles.headerIcon} />
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>{t('RAG-Inventar')}</h1>
|
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, margin: 0 }}>{t('RAG-Inventar')}</h1>
|
||||||
<p className={styles.pageDesc}>
|
<p className={styles.pageDesc}>
|
||||||
{t('Übersicht und Steuerung der indexierten Wissensdaten.')}
|
{t('Übersicht und Steuerung der indexierten Wissensdaten.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.headerRight}>
|
</div>
|
||||||
|
</StackLayout.Header>
|
||||||
|
<StackLayout.Body>
|
||||||
|
<Panel variant="toolbar" title={t('Filter')} id="rag-inventory-toolbar">
|
||||||
|
<div className={styles.headerRight} style={{ marginLeft: 0 }}>
|
||||||
<div className={styles.filterGroup}>
|
<div className={styles.filterGroup}>
|
||||||
<label className={styles.filterLabel}>{t('Kontext:')}</label>
|
<label className={styles.filterLabel}>{t('Kontext:')}</label>
|
||||||
<select
|
<select
|
||||||
className={styles.scopeSelect}
|
className={styles.scopeSelect}
|
||||||
value={selectedScope}
|
value={selectedScope}
|
||||||
onChange={e => setSelectedScope(e.target.value)}
|
onChange={e => _handleScopeChange(e.target.value)}
|
||||||
disabled={mandatesLoading}
|
disabled={mandatesLoading}
|
||||||
>
|
>
|
||||||
{scopeOptions.map(opt => (
|
{scopeOptions.map(opt => (
|
||||||
|
|
@ -230,18 +273,27 @@ export const RagInventoryPage: React.FC = () => {
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={onlyMyData}
|
checked={onlyMyData}
|
||||||
onChange={e => setOnlyMyData(e.target.checked)}
|
onChange={e => _handleOnlyMyDataChange(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
{t('nur meine Daten')}
|
{t('nur meine Daten')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</Panel>
|
||||||
|
|
||||||
{loading && !inventory && <div className={styles.loading}>{t('Laden...')}</div>}
|
{loading && !inventory && (
|
||||||
{error && <div className={styles.error}>{error}</div>}
|
<Panel variant="card" title={t('Laden...')} id="rag-inventory-loading">
|
||||||
|
<div className={styles.loading}>{t('Laden...')}</div>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<Panel variant="card" title={t('Fehler')} id="rag-inventory-error">
|
||||||
|
<div className={styles.error}>{error}</div>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
{inventory && (
|
{inventory && (
|
||||||
<div className={styles.content}>
|
<>
|
||||||
|
<Panel variant="card" title={t('Übersicht')} id="rag-inventory-totals">
|
||||||
<div className={styles.totals}>
|
<div className={styles.totals}>
|
||||||
<span className={styles.totalLabel}>{t('Total Dateien')}:</span>
|
<span className={styles.totalLabel}>{t('Total Dateien')}:</span>
|
||||||
<strong className={styles.totalValue}>{inventory.totals?.files ?? 0}</strong>
|
<strong className={styles.totalValue}>{inventory.totals?.files ?? 0}</strong>
|
||||||
|
|
@ -251,9 +303,11 @@ export const RagInventoryPage: React.FC = () => {
|
||||||
<span className={styles.totalBytes}>{(inventory.totals.bytes / 1024 / 1024).toFixed(1)} MB</span>
|
<span className={styles.totalBytes}>{(inventory.totals.bytes / 1024 / 1024).toFixed(1)} MB</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
{(inventory.connections || []).map((conn: RagConnectionDto) => (
|
{(inventory.connections || []).map((conn: RagConnectionDto) => (
|
||||||
<div key={conn.id} className={styles.connectionCard}>
|
<Panel key={conn.id} variant="card" title={t('Datenverbindung')} id={`rag-connection-${conn.id}`}>
|
||||||
|
<div className={styles.connectionCard} style={{ border: 'none', padding: 0, background: 'transparent' }}>
|
||||||
<div className={styles.connectionHeader}>
|
<div className={styles.connectionHeader}>
|
||||||
<span className={styles.authority}>{conn.authority}</span>
|
<span className={styles.authority}>{conn.authority}</span>
|
||||||
<span className={styles.email}>{conn.externalEmail}</span>
|
<span className={styles.email}>{conn.externalEmail}</span>
|
||||||
|
|
@ -265,6 +319,14 @@ export const RagInventoryPage: React.FC = () => {
|
||||||
{t('{f} Dateien · {c} Chunks', { f: conn.totalFiles, c: conn.totalChunks })}
|
{t('{f} Dateien · {c} Chunks', { f: conn.totalFiles, c: conn.totalChunks })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
className={styles.reindexBtn}
|
||||||
|
onClick={() => _openSettingsForConnection(conn)}
|
||||||
|
title={t('Einstellungen')}
|
||||||
|
style={{ marginLeft: 'auto' }}
|
||||||
|
>
|
||||||
|
<FaSlidersH size={14} />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className={styles.consentToggle}
|
className={styles.consentToggle}
|
||||||
onClick={() => _handleConsentToggle(conn.id, conn.knowledgeIngestionEnabled)}
|
onClick={() => _handleConsentToggle(conn.id, conn.knowledgeIngestionEnabled)}
|
||||||
|
|
@ -381,7 +443,14 @@ export const RagInventoryPage: React.FC = () => {
|
||||||
>
|
>
|
||||||
{ds.fileCount} {t('Dateien')} · {ds.chunkCount} {t('Chunks')}
|
{ds.fileCount} {t('Dateien')} · {ds.chunkCount} {t('Chunks')}
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.dsIndex}>{ds.ragIndexEnabled ? '\uD83E\uDDE0' : '\u2014'}</span>
|
<button
|
||||||
|
className={styles.dsIndex}
|
||||||
|
onClick={() => _handleDsRagToggle(conn.id, ds)}
|
||||||
|
title={ds.ragIndexEnabled ? t('Indexierung deaktivieren') : t('Indexierung aktivieren')}
|
||||||
|
style={{ cursor: 'pointer', background: 'none', border: 'none', padding: '2px 4px', fontSize: 'inherit' }}
|
||||||
|
>
|
||||||
|
{ds.ragIndexEnabled ? '\uD83E\uDDE0' : '\u2014'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{conn.dataSources.length === 0 && (
|
{conn.dataSources.length === 0 && (
|
||||||
|
|
@ -389,21 +458,23 @@ export const RagInventoryPage: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{(inventory.featureInstances || []).length > 0 && (
|
{(inventory.featureInstances || []).length > 0 && (
|
||||||
<>
|
<Panel variant="card" title={(
|
||||||
<h2 className={styles.sectionTitle}>
|
<span className={styles.sectionTitle}>
|
||||||
<FaCubes style={{ marginRight: 8 }} />
|
<FaCubes style={{ marginRight: 8 }} />
|
||||||
{t('Feature-Daten')}
|
{t('Feature-Daten')}
|
||||||
</h2>
|
</span>
|
||||||
|
)} id="rag-feature-data">
|
||||||
{(inventory.featureInstances || []).map((fi: RagFeatureInstanceDto) => {
|
{(inventory.featureInstances || []).map((fi: RagFeatureInstanceDto) => {
|
||||||
const runningJobs = fi.runningJobs || [];
|
const runningJobs = fi.runningJobs || [];
|
||||||
const lastSuccess = fi.lastSuccess;
|
const lastSuccess = fi.lastSuccess;
|
||||||
const lastError = fi.lastError;
|
const lastError = fi.lastError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={fi.featureInstanceId} className={styles.connectionCard}>
|
<div key={fi.featureInstanceId} className={styles.connectionCard} style={{ marginTop: '1rem' }}>
|
||||||
<div className={styles.connectionHeader}>
|
<div className={styles.connectionHeader}>
|
||||||
<span className={styles.authority}>{fi.featureCode}</span>
|
<span className={styles.authority}>{fi.featureCode}</span>
|
||||||
<span className={styles.email}>{fi.label}</span>
|
<span className={styles.email}>{fi.label}</span>
|
||||||
|
|
@ -499,14 +570,17 @@ export const RagInventoryPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</Panel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(inventory.connections || []).length === 0 && (inventory.featureInstances || []).length === 0 && (
|
{(inventory.connections || []).length === 0 && (inventory.featureInstances || []).length === 0 && (
|
||||||
|
<Panel variant="card" title={t('Keine Daten')} id="rag-inventory-empty">
|
||||||
<div className={styles.emptyState}>{t('Keine Daten für diese Sicht vorhanden.')}</div>
|
<div className={styles.emptyState}>{t('Keine Daten für diese Sicht vorhanden.')}</div>
|
||||||
|
</Panel>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</StackLayout.Body>
|
||||||
|
|
||||||
<DataSourceSettingsModal
|
<DataSourceSettingsModal
|
||||||
open={!!settingsModal}
|
open={!!settingsModal}
|
||||||
|
|
@ -518,7 +592,7 @@ export const RagInventoryPage: React.FC = () => {
|
||||||
onSaved={() => _fetchInventory()}
|
onSaved={() => _fetchInventory()}
|
||||||
onClose={() => setSettingsModal(null)}
|
onClose={() => setSettingsModal(null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</StackLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { PENDING_INVITATION_KEY } from './InvitePage';
|
||||||
import { LanguageSelector } from '../components/UiComponents/LanguageSelector';
|
import { LanguageSelector } from '../components/UiComponents/LanguageSelector';
|
||||||
|
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
|
import { useDocumentTitle } from '../hooks/useDocumentTitle';
|
||||||
|
|
||||||
interface RegisterFormData {
|
interface RegisterFormData {
|
||||||
username: string;
|
username: string;
|
||||||
|
|
@ -41,8 +42,9 @@ function Register() {
|
||||||
const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY);
|
const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY);
|
||||||
const hasPendingInvitation = !!pendingInvitationToken;
|
const hasPendingInvitation = !!pendingInvitationToken;
|
||||||
|
|
||||||
|
useDocumentTitle(t('Registrieren'));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = "PowerOn AI Platform - Registrieren";
|
|
||||||
generateAndStoreCSRFToken();
|
generateAndStoreCSRFToken();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
|
||||||
import { LanguageSelector } from '../components/UiComponents/LanguageSelector';
|
import { LanguageSelector } from '../components/UiComponents/LanguageSelector';
|
||||||
|
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
|
import { useDocumentTitle } from '../hooks/useDocumentTitle';
|
||||||
|
|
||||||
function Reset() {
|
function Reset() {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
@ -27,12 +28,11 @@ function Reset() {
|
||||||
// Get token from URL
|
// Get token from URL
|
||||||
const token = searchParams.get('token');
|
const token = searchParams.get('token');
|
||||||
|
|
||||||
// Set page title and generate CSRF token
|
useDocumentTitle(t('Neues Passwort setzen'));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = "PowerOn AI Platform - Neues Passwort setzen";
|
|
||||||
generateAndStoreCSRFToken();
|
generateAndStoreCSRFToken();
|
||||||
|
|
||||||
// Validate token exists and format
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setTokenError(t('Ungültiger Reset-Link. Bitte fordern Sie einen neuen Link an.'));
|
setTokenError(t('Ungültiger Reset-Link. Bitte fordern Sie einen neuen Link an.'));
|
||||||
} else if (!_isValidUUID(token)) {
|
} else if (!_isValidUUID(token)) {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Route: /settings
|
* Route: /settings
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useCallback, useEffect } from 'react';
|
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
import { useCurrentUser, useUser } from '../hooks/useUsers';
|
import { useCurrentUser, useUser } from '../hooks/useUsers';
|
||||||
|
|
@ -15,39 +15,31 @@ import type { AttributeDefinition } from '../components/FormGenerator/FormGenera
|
||||||
import { useApiRequest } from '../hooks/useApi';
|
import { useApiRequest } from '../hooks/useApi';
|
||||||
import { useVoiceCatalog } from '../contexts/VoiceCatalogContext';
|
import { useVoiceCatalog } from '../contexts/VoiceCatalogContext';
|
||||||
import { mfaStatusApi, mfaSetupApi, mfaConfirmApi, mfaDisableApi } from '../api/authApi';
|
import { mfaStatusApi, mfaSetupApi, mfaConfirmApi, mfaDisableApi } from '../api/authApi';
|
||||||
|
import { StackLayout } from '../components/Layout/StackLayout';
|
||||||
|
import { Panel } from '../components/Layout/Panel';
|
||||||
|
import { LayoutTabs } from '../components/Layout/LayoutTabs';
|
||||||
|
import type { LayoutTabItem } from '../components/Layout/types';
|
||||||
import styles from './Settings.module.css';
|
import styles from './Settings.module.css';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// TYPES
|
// TYPES
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
type SettingsTab = 'profile' | 'appearance' | 'voice' | 'security' | 'privacy';
|
|
||||||
|
|
||||||
function _getTabs(t: (key: string) => string): { key: SettingsTab; label: string }[] {
|
|
||||||
return [
|
|
||||||
{ key: 'profile', label: t('Profil') },
|
|
||||||
{ key: 'appearance', label: t('Darstellung') },
|
|
||||||
{ key: 'voice', label: t('Stimme & Sprache') },
|
|
||||||
{ key: 'security', label: t('Sicherheit') },
|
|
||||||
{ key: 'privacy', label: t('Datenschutz') },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// PROFILE EDIT MODAL
|
// PROFILE TAB
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
interface ProfileEditModalProps {
|
interface _ProfileTabProps {
|
||||||
isOpen: boolean;
|
currentUser: ReturnType<typeof useCurrentUser>['user'];
|
||||||
onClose: () => void;
|
refetchUser: ReturnType<typeof useCurrentUser>['refetch'];
|
||||||
userData: any;
|
onSave: (formData: any) => Promise<void>;
|
||||||
onSave: (data: any) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, userData, onSave }) => {
|
const _ProfileTab: React.FC<_ProfileTabProps> = ({ currentUser, refetchUser, onSave }) => {
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const { t, availableLanguages } = useLanguage();
|
const { t, availableLanguages } = useLanguage();
|
||||||
|
const [isProfileEditing, setIsProfileEditing] = useState(false);
|
||||||
|
const [isSavingProfile, setIsSavingProfile] = useState(false);
|
||||||
|
const [profileError, setProfileError] = useState<string | null>(null);
|
||||||
|
|
||||||
const languageOptions = availableLanguages.map((l) => ({ value: l.code, label: l.label || l.code }));
|
const languageOptions = availableLanguages.map((l) => ({ value: l.code, label: l.label || l.code }));
|
||||||
|
|
||||||
|
|
@ -57,34 +49,68 @@ const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, us
|
||||||
{ name: 'language', type: 'select', label: t('Sprache'), description: t('Anzeigesprache der Anwendung'), required: true, options: languageOptions },
|
{ name: 'language', type: 'select', label: t('Sprache'), description: t('Anzeigesprache der Anwendung'), required: true, options: languageOptions },
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleSubmit = async (formData: any) => {
|
const _handleProfileSubmit = async (formData: any) => {
|
||||||
setIsSaving(true);
|
setIsSavingProfile(true);
|
||||||
setError(null);
|
setProfileError(null);
|
||||||
try {
|
try {
|
||||||
await onSave(formData);
|
await onSave(formData);
|
||||||
onClose();
|
setIsProfileEditing(false);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || t('Fehler beim Speichern des Profils'));
|
setProfileError(err.message || t('Fehler beim Speichern des Profils'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSavingProfile(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.modalOverlay}>
|
<>
|
||||||
<div className={styles.modalContent}>
|
<Panel variant="card" title={t('Konto')} id="settings-account">
|
||||||
<div className={styles.modalHeader}>
|
{!isProfileEditing ? (
|
||||||
<h2>{t('Profil bearbeiten')}</h2>
|
<>
|
||||||
<button className={styles.closeButton} onClick={onClose}>×</button>
|
<div className={styles.settingRow}>
|
||||||
|
<div className={styles.settingInfo}>
|
||||||
|
<label className={styles.settingLabel}>{t('Profil bearbeiten')}</label>
|
||||||
|
<p className={styles.settingDescription}>{t('Ändern Sie Ihren Namen und')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.modalBody}>
|
<div className={styles.settingControl}>
|
||||||
{error && <div className={styles.errorMessage}>{error}</div>}
|
<button
|
||||||
<FormGeneratorForm attributes={profileAttributes} data={userData} mode="edit" onSubmit={handleSubmit} onCancel={onClose} submitButtonText={isSaving ? t('Speichern') : t('Speichern')} cancelButtonText={t('Abbrechen')} />
|
className={styles.button}
|
||||||
|
onClick={async () => { if (refetchUser) await refetchUser(); setIsProfileEditing(true); }}
|
||||||
|
>
|
||||||
|
{t('Profil bearbeiten')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{currentUser && (
|
||||||
|
<div className={styles.userInfoCard}>
|
||||||
|
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>{t('Benutzername')}</span><span className={styles.userInfoValue}>{currentUser.username}</span></div>
|
||||||
|
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>{t('Name')}</span><span className={styles.userInfoValue}>{currentUser.fullName || '-'}</span></div>
|
||||||
|
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>{t('E-Mail')}</span><span className={styles.userInfoValue}>{currentUser.email || '-'}</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{profileError && <div className={styles.errorMessage}>{profileError}</div>}
|
||||||
|
<FormGeneratorForm
|
||||||
|
attributes={profileAttributes}
|
||||||
|
data={currentUser}
|
||||||
|
mode="edit"
|
||||||
|
onSubmit={_handleProfileSubmit}
|
||||||
|
onCancel={() => { setIsProfileEditing(false); setProfileError(null); }}
|
||||||
|
submitButtonText={isSavingProfile ? t('Speichern') : t('Speichern')}
|
||||||
|
cancelButtonText={t('Abbrechen')}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
<Panel variant="card" title={t('Applikation')} id="settings-application">
|
||||||
|
<div className={styles.infoCard}>
|
||||||
|
<div className={styles.infoRow}><span className={styles.infoLabel}>{t('Version')}</span><span className={styles.infoValue}>2.0.0</span></div>
|
||||||
|
<div className={styles.infoRow}><span className={styles.infoLabel}>{t('Build')}</span><span className={styles.infoValue}>2026.03.23</span></div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -206,10 +232,10 @@ const VoiceSettingsTab: React.FC = () => {
|
||||||
return entry ? `${entry.flag ? entry.flag + ' ' : ''}${entry.label}` : code;
|
return entry ? `${entry.flag ? entry.flag + ' ' : ''}${entry.label}` : code;
|
||||||
}, [voiceCatalog]);
|
}, [voiceCatalog]);
|
||||||
|
|
||||||
if (loading) return <div style={{ padding: '1rem', color: '#888' }}>{t('Einstellungen werden geladen')}</div>;
|
if (loading) return <Panel variant="card" title={t('Spracheingabe')} id="settings-voice-loading"><p style={{ padding: '1rem', color: 'var(--text-secondary, #888)' }}>{t('Einstellungen werden geladen')}</p></Panel>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Panel variant="card" title={t('Spracheingabe')} id="settings-voice">
|
||||||
{error && <div className={styles.errorMessage}>{error}</div>}
|
{error && <div className={styles.errorMessage}>{error}</div>}
|
||||||
{success && <div style={{ background: '#f0fdf4', border: '1px solid #bbf7d0', color: '#16a34a', padding: '0.75rem 1rem', borderRadius: 6, marginBottom: '1rem', fontSize: '0.875rem' }}>{success}</div>}
|
{success && <div style={{ background: '#f0fdf4', border: '1px solid #bbf7d0', color: '#16a34a', padding: '0.75rem 1rem', borderRadius: 6, marginBottom: '1rem', fontSize: '0.875rem' }}>{success}</div>}
|
||||||
|
|
||||||
|
|
@ -294,7 +320,7 @@ const VoiceSettingsTab: React.FC = () => {
|
||||||
<button className={styles.button} onClick={_handleSave} disabled={saving} style={{ background: 'var(--primary-color, #2563eb)', color: '#fff', border: 'none', padding: '0.625rem 1.5rem', fontWeight: 600, borderRadius: 6 }}>
|
<button className={styles.button} onClick={_handleSave} disabled={saving} style={{ background: 'var(--primary-color, #2563eb)', color: '#fff', border: 'none', padding: '0.625rem 1.5rem', fontWeight: 600, borderRadius: 6 }}>
|
||||||
{saving ? t('Speichern') : t('Einstellungen speichern')}
|
{saving ? t('Speichern') : t('Einstellungen speichern')}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</Panel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -353,10 +379,10 @@ const NeutralizationMappingsTab: React.FC = () => {
|
||||||
return text.slice(0, 2) + '*'.repeat(Math.min(text.length - 4, 20)) + text.slice(-2);
|
return text.slice(0, 2) + '*'.repeat(Math.min(text.length - 4, 20)) + text.slice(-2);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <div style={{ padding: '1rem', color: '#888' }}>{t('Mappings werden geladen')}</div>;
|
if (loading) return <Panel variant="card" title={t('Platzhaltermappings')} id="settings-mappings-loading"><p style={{ padding: '1rem', color: 'var(--text-secondary, #888)' }}>{t('Mappings werden geladen')}</p></Panel>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Panel variant="card" title={t('Platzhaltermappings lokal')} id="settings-mappings">
|
||||||
{error && <div className={styles.errorMessage}>{error}</div>}
|
{error && <div className={styles.errorMessage}>{error}</div>}
|
||||||
|
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
|
|
@ -419,7 +445,7 @@ const NeutralizationMappingsTab: React.FC = () => {
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</>
|
</Panel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -507,11 +533,11 @@ const MfaSettingsTab: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <section className={styles.section}><p>{t('wird geladen…')}</p></section>;
|
return <Panel variant="card" title={t('Zwei-Faktor-Authentifizierung (MFA)')} id="settings-mfa-loading"><p>{t('wird geladen…')}</p></Panel>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={styles.section}>
|
<Panel variant="card" title={t('Zwei-Faktor-Authentifizierung (MFA)')} id="settings-mfa">
|
||||||
<h2 className={styles.sectionTitle}>{t('Zwei-Faktor-Authentifizierung (MFA)')}</h2>
|
<h2 className={styles.sectionTitle}>{t('Zwei-Faktor-Authentifizierung (MFA)')}</h2>
|
||||||
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
|
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
|
||||||
{t('Schützen Sie Ihr Konto mit einem zusätzlichen Bestätigungscode bei der Anmeldung.')}
|
{t('Schützen Sie Ihr Konto mit einem zusätzlichen Bestätigungscode bei der Anmeldung.')}
|
||||||
|
|
@ -605,7 +631,79 @@ const MfaSettingsTab: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</Panel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// APPEARANCE TAB
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface _AppearanceTabProps {
|
||||||
|
theme: 'light' | 'dark';
|
||||||
|
onThemeChange: (theme: 'light' | 'dark') => void;
|
||||||
|
currentLanguage: string;
|
||||||
|
onLanguageChange: (lang: string) => void;
|
||||||
|
isSavingLanguage: boolean;
|
||||||
|
languageError: string | null;
|
||||||
|
availableLanguages: Array<{ code: string; label?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _AppearanceTab: React.FC<_AppearanceTabProps> = ({
|
||||||
|
theme,
|
||||||
|
onThemeChange,
|
||||||
|
currentLanguage,
|
||||||
|
onLanguageChange,
|
||||||
|
isSavingLanguage,
|
||||||
|
languageError,
|
||||||
|
availableLanguages,
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel variant="card" title={t('Darstellung')} id="settings-appearance">
|
||||||
|
<div className={styles.settingRow}>
|
||||||
|
<div className={styles.settingInfo}><label className={styles.settingLabel}>{t('Theme')}</label><p className={styles.settingDescription}>{t('Wählen zwischen Hell- und Dunkelmodus')}</p></div>
|
||||||
|
<div className={styles.settingControl}>
|
||||||
|
<div className={styles.themeToggle}>
|
||||||
|
<button className={`${styles.themeButton} ${theme === 'light' ? styles.active : ''}`} onClick={() => onThemeChange('light')}>{t('Thema Hell')}</button>
|
||||||
|
<button className={`${styles.themeButton} ${theme === 'dark' ? styles.active : ''}`} onClick={() => onThemeChange('dark')}>{t('Thema Dunkel')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.settingRow}>
|
||||||
|
<div className={styles.settingInfo}><label className={styles.settingLabel}>{t('Anzeigesprache')}</label><p className={styles.settingDescription}>{t('Sprachbeschreibung')}{languageError && <span className={styles.errorText}> {languageError}</span>}</p></div>
|
||||||
|
<div className={styles.settingControl}>
|
||||||
|
<select className={styles.select} value={currentLanguage} onChange={(e) => onLanguageChange(e.target.value)} disabled={isSavingLanguage}>
|
||||||
|
{availableLanguages.map((l) => (
|
||||||
|
<option key={l.code} value={l.code}>
|
||||||
|
{l.label || l.code}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{isSavingLanguage && <span className={styles.savingIndicator}>{t('Speichern')}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const _PrivacyTab: React.FC = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Panel variant="card" title={t('Datenschutz')} id="settings-privacy">
|
||||||
|
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
|
||||||
|
{t('Datenschutzbeschreibung')}
|
||||||
|
</p>
|
||||||
|
<div className={styles.settingRow}>
|
||||||
|
<div className={styles.settingInfo}><label className={styles.settingLabel}>{t('GDPR Datenschutz')}</label><p className={styles.settingDescription}>{t('Datenexport, Portabilität und Kontolöschung')}</p></div>
|
||||||
|
<div className={styles.settingControl}><Link className={`${styles.button} ${styles.linkButton}`} to="/gdpr">{t('GDPR öffnen')}</Link></div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
<NeutralizationMappingsTab />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -618,9 +716,7 @@ export const SettingsPage: React.FC = () => {
|
||||||
const { user: currentUser, refetch: refetchUser } = useCurrentUser();
|
const { user: currentUser, refetch: refetchUser } = useCurrentUser();
|
||||||
const { updateUser } = useUser();
|
const { updateUser } = useUser();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<SettingsTab>('profile');
|
|
||||||
const [theme, setTheme] = useState<'light' | 'dark'>(() => (localStorage.getItem('theme') as 'light' | 'dark') || 'light');
|
const [theme, setTheme] = useState<'light' | 'dark'>(() => (localStorage.getItem('theme') as 'light' | 'dark') || 'light');
|
||||||
const [isProfileModalOpen, setIsProfileModalOpen] = useState(false);
|
|
||||||
const [isSavingLanguage, setIsSavingLanguage] = useState(false);
|
const [isSavingLanguage, setIsSavingLanguage] = useState(false);
|
||||||
const [languageError, setLanguageError] = useState<string | null>(null);
|
const [languageError, setLanguageError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -661,110 +757,79 @@ export const SettingsPage: React.FC = () => {
|
||||||
if (newLanguage !== currentLanguage) setLanguage(newLanguage);
|
if (newLanguage !== currentLanguage) setLanguage(newLanguage);
|
||||||
if (refetchUser) await refetchUser();
|
if (refetchUser) await refetchUser();
|
||||||
window.dispatchEvent(new CustomEvent('userInfoUpdated'));
|
window.dispatchEvent(new CustomEvent('userInfoUpdated'));
|
||||||
}, [currentUser, updateUser, refetchUser, currentLanguage, setLanguage]);
|
}, [currentUser, updateUser, refetchUser, currentLanguage, setLanguage, t]);
|
||||||
|
|
||||||
|
const settingsTabs: LayoutTabItem[] = useMemo(() => [
|
||||||
|
{
|
||||||
|
id: 'profile',
|
||||||
|
label: t('Profil'),
|
||||||
|
render: () => (
|
||||||
|
<_ProfileTab
|
||||||
|
currentUser={currentUser}
|
||||||
|
refetchUser={refetchUser}
|
||||||
|
onSave={handleProfileSave}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'appearance',
|
||||||
|
label: t('Darstellung'),
|
||||||
|
render: () => (
|
||||||
|
<_AppearanceTab
|
||||||
|
theme={theme}
|
||||||
|
onThemeChange={handleThemeChange}
|
||||||
|
currentLanguage={currentLanguage}
|
||||||
|
onLanguageChange={handleLanguageChange}
|
||||||
|
isSavingLanguage={isSavingLanguage}
|
||||||
|
languageError={languageError}
|
||||||
|
availableLanguages={availableLanguages}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'voice',
|
||||||
|
label: t('Stimme & Sprache'),
|
||||||
|
render: () => <VoiceSettingsTab />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'security',
|
||||||
|
label: t('Sicherheit'),
|
||||||
|
render: () => <MfaSettingsTab />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'privacy',
|
||||||
|
label: t('Datenschutz'),
|
||||||
|
render: () => <_PrivacyTab />,
|
||||||
|
},
|
||||||
|
], [
|
||||||
|
t,
|
||||||
|
currentUser,
|
||||||
|
refetchUser,
|
||||||
|
handleProfileSave,
|
||||||
|
theme,
|
||||||
|
handleThemeChange,
|
||||||
|
currentLanguage,
|
||||||
|
handleLanguageChange,
|
||||||
|
isSavingLanguage,
|
||||||
|
languageError,
|
||||||
|
availableLanguages,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.settings}>
|
<StackLayout variant="scroll">
|
||||||
<header className={styles.header}>
|
<StackLayout.Header>
|
||||||
<h1>{t('Einstellungen')}</h1>
|
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, margin: 0 }}>{t('Einstellungen')}</h1>
|
||||||
<p className={styles.subtitle}>{t('Persönliche Einstellungen und Präferenzen')}</p>
|
<p className={styles.subtitle}>{t('Persönliche Einstellungen und Präferenzen')}</p>
|
||||||
</header>
|
</StackLayout.Header>
|
||||||
|
<StackLayout.Body>
|
||||||
<nav style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--border-color, #e0e0e0)', marginBottom: '1.5rem' }}>
|
<LayoutTabs
|
||||||
{_getTabs(t).map(tab => (
|
items={settingsTabs}
|
||||||
<button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{
|
urlParam="tab"
|
||||||
padding: '10px 20px', border: 'none', borderBottom: activeTab === tab.key ? '2px solid var(--primary-color, #2563eb)' : '2px solid transparent',
|
defaultTab="profile"
|
||||||
background: 'none', cursor: 'pointer', fontSize: 14, fontWeight: activeTab === tab.key ? 600 : 400,
|
lazy
|
||||||
color: activeTab === tab.key ? 'var(--primary-color, #2563eb)' : 'var(--text-secondary, #888)',
|
/>
|
||||||
}}>
|
</StackLayout.Body>
|
||||||
{tab.label}
|
</StackLayout>
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main className={styles.content}>
|
|
||||||
{activeTab === 'profile' && (
|
|
||||||
<>
|
|
||||||
<section className={styles.section}>
|
|
||||||
<h2 className={styles.sectionTitle}>{t('Konto')}</h2>
|
|
||||||
<div className={styles.settingRow}>
|
|
||||||
<div className={styles.settingInfo}>
|
|
||||||
<label className={styles.settingLabel}>{t('Profil bearbeiten')}</label>
|
|
||||||
<p className={styles.settingDescription}>{t('Ändern Sie Ihren Namen und')}</p>
|
|
||||||
</div>
|
|
||||||
<div className={styles.settingControl}>
|
|
||||||
<button className={styles.button} onClick={async () => { await refetchUser(); setIsProfileModalOpen(true); }}>{t('Profil öffnen')}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{currentUser && (
|
|
||||||
<div className={styles.userInfoCard}>
|
|
||||||
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>{t('Benutzername')}</span><span className={styles.userInfoValue}>{currentUser.username}</span></div>
|
|
||||||
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>{t('Name')}</span><span className={styles.userInfoValue}>{currentUser.fullName || '-'}</span></div>
|
|
||||||
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>{t('E-Mail')}</span><span className={styles.userInfoValue}>{currentUser.email || '-'}</span></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
<section className={styles.section}>
|
|
||||||
<h2 className={styles.sectionTitle}>{t('Applikation')}</h2>
|
|
||||||
<div className={styles.infoCard}>
|
|
||||||
<div className={styles.infoRow}><span className={styles.infoLabel}>{t('Version')}</span><span className={styles.infoValue}>2.0.0</span></div>
|
|
||||||
<div className={styles.infoRow}><span className={styles.infoLabel}>{t('Build')}</span><span className={styles.infoValue}>2026.03.23</span></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'appearance' && (
|
|
||||||
<section className={styles.section}>
|
|
||||||
<h2 className={styles.sectionTitle}>{t('Darstellung')}</h2>
|
|
||||||
<div className={styles.settingRow}>
|
|
||||||
<div className={styles.settingInfo}><label className={styles.settingLabel}>{t('Theme')}</label><p className={styles.settingDescription}>{t('Wählen zwischen Hell- und Dunkelmodus')}</p></div>
|
|
||||||
<div className={styles.settingControl}>
|
|
||||||
<div className={styles.themeToggle}>
|
|
||||||
<button className={`${styles.themeButton} ${theme === 'light' ? styles.active : ''}`} onClick={() => handleThemeChange('light')}>{t('Thema Hell')}</button>
|
|
||||||
<button className={`${styles.themeButton} ${theme === 'dark' ? styles.active : ''}`} onClick={() => handleThemeChange('dark')}>{t('Thema Dunkel')}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.settingRow}>
|
|
||||||
<div className={styles.settingInfo}><label className={styles.settingLabel}>{t('Anzeigesprache')}</label><p className={styles.settingDescription}>{t('Sprachbeschreibung')}{languageError && <span className={styles.errorText}> {languageError}</span>}</p></div>
|
|
||||||
<div className={styles.settingControl}>
|
|
||||||
<select className={styles.select} value={currentLanguage} onChange={(e) => handleLanguageChange(e.target.value)} disabled={isSavingLanguage}>
|
|
||||||
{availableLanguages.map((l) => (
|
|
||||||
<option key={l.code} value={l.code}>
|
|
||||||
{l.label || l.code}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{isSavingLanguage && <span className={styles.savingIndicator}>{t('Speichern')}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'voice' && <VoiceSettingsTab />}
|
|
||||||
|
|
||||||
{activeTab === 'security' && <MfaSettingsTab />}
|
|
||||||
|
|
||||||
{activeTab === 'privacy' && (
|
|
||||||
<>
|
|
||||||
<section className={styles.section}>
|
|
||||||
<h2 className={styles.sectionTitle}>{t('Datenschutz')}</h2>
|
|
||||||
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
|
|
||||||
{t('Datenschutzbeschreibung')}
|
|
||||||
</p>
|
|
||||||
<div className={styles.settingRow}>
|
|
||||||
<div className={styles.settingInfo}><label className={styles.settingLabel}>{t('GDPR Datenschutz')}</label><p className={styles.settingDescription}>{t('Datenexport, Portabilität und Kontolöschung')}</p></div>
|
|
||||||
<div className={styles.settingControl}><Link className={`${styles.button} ${styles.linkButton}`} to="/gdpr">{t('GDPR öffnen')}</Link></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<NeutralizationMappingsTab />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<ProfileEditModal isOpen={isProfileModalOpen} onClose={() => setIsProfileModalOpen(false)} userData={currentUser} onSave={handleProfileSave} />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
|
||||||
import { useStore, _storeActionKey } from '../hooks/useStore';
|
import { useStore, _storeActionKey } from '../hooks/useStore';
|
||||||
import type { StoreFeature, UserMandate } from '../api/storeApi';
|
import type { StoreFeature, UserMandate } from '../api/storeApi';
|
||||||
import { formatBinaryDataSizeFromMebibytes } from '../utils/formatDataSize';
|
import { formatBinaryDataSizeFromMebibytes } from '../utils/formatDataSize';
|
||||||
|
import { StackLayout } from '../components/Layout/StackLayout';
|
||||||
|
import { Panel } from '../components/Layout/Panel';
|
||||||
import styles from './Store.module.css';
|
import styles from './Store.module.css';
|
||||||
|
|
||||||
const FEATURE_ICONS: Record<string, React.ReactNode> = {
|
const FEATURE_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
|
@ -23,7 +25,6 @@ const FEATURE_ICONS: Record<string, React.ReactNode> = {
|
||||||
trustee: <FaShieldAlt />,
|
trustee: <FaShieldAlt />,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Fallback when GET /store/features omits description (German i18n keys). */
|
|
||||||
const STORE_FEATURE_DESCRIPTION_FALLBACK: Record<string, string> = {
|
const STORE_FEATURE_DESCRIPTION_FALLBACK: Record<string, string> = {
|
||||||
automation: 'Erstelle und verwalte Automatisierungen, um wiederkehrende Aufgaben effizient zu erledigen.',
|
automation: 'Erstelle und verwalte Automatisierungen, um wiederkehrende Aufgaben effizient zu erledigen.',
|
||||||
teamsbot: 'Integriere einen AI-Bot in deine Microsoft Teams Meetings und Channels.',
|
teamsbot: 'Integriere einen AI-Bot in deine Microsoft Teams Meetings und Channels.',
|
||||||
|
|
@ -139,20 +140,21 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const StorePage: React.FC = () => {
|
export const StorePage: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { features, mandates, subscriptionInfo, loading, actionLoading, error, activate, deactivate } = useStore();
|
const { features, mandates, subscriptionInfo, loading, actionLoading, error, activate, deactivate } = useStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.store}>
|
<StackLayout variant="dashboard">
|
||||||
<div className={styles.header}>
|
<StackLayout.Header>
|
||||||
<h1>{t('Feature Store')}</h1>
|
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, margin: 0 }}>{t('Feature Store')}</h1>
|
||||||
<p className={styles.subtitle}>
|
<p className={styles.subtitle}>
|
||||||
{t('Aktiviere Features für dein Konto. Deine Daten sind isoliert und nur für dich sichtbar.')}
|
{t('Aktiviere Features für dein Konto. Deine Daten sind isoliert und nur für dich sichtbar.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</StackLayout.Header>
|
||||||
|
<StackLayout.Body>
|
||||||
{subscriptionInfo && subscriptionInfo.plan && (
|
{subscriptionInfo && subscriptionInfo.plan && (
|
||||||
|
<Panel variant="card" title={t('Abonnement')} id="store-subscription">
|
||||||
<div className={styles.subscriptionBanner}>
|
<div className={styles.subscriptionBanner}>
|
||||||
<span>{t('Plan:')} <strong>{subscriptionInfo.plan}</strong></span>
|
<span>{t('Plan:')} <strong>{subscriptionInfo.plan}</strong></span>
|
||||||
<span className={styles.bannerSeparator}>
|
<span className={styles.bannerSeparator}>
|
||||||
|
|
@ -182,19 +184,29 @@ const StorePage: React.FC = () => {
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <div className={styles.error}>{error}</div>}
|
{error && (
|
||||||
|
<Panel variant="card" title={t('Fehler')} id="store-error">
|
||||||
|
<div className={styles.error}>{error}</div>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
<Panel variant="card" title={t('Laden')} id="store-loading">
|
||||||
<div className={styles.loading}>
|
<div className={styles.loading}>
|
||||||
{t('Lade Features…')}
|
{t('Lade Features…')}
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
) : features.length === 0 ? (
|
) : features.length === 0 ? (
|
||||||
|
<Panel variant="card" title={t('Keine Features')} id="store-empty">
|
||||||
<div className={styles.empty}>
|
<div className={styles.empty}>
|
||||||
{t('Keine Features im Store verfügbar.')}
|
{t('Keine Features im Store verfügbar.')}
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
) : (
|
) : (
|
||||||
|
<Panel variant="dashboard" title={t('Features')} id="store-features">
|
||||||
<div className={styles.grid}>
|
<div className={styles.grid}>
|
||||||
{features.map((feature) => (
|
{features.map((feature) => (
|
||||||
<FeatureCard
|
<FeatureCard
|
||||||
|
|
@ -207,8 +219,10 @@ const StorePage: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
)}
|
)}
|
||||||
</div>
|
</StackLayout.Body>
|
||||||
|
</StackLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import {
|
||||||
} from '../../hooks/useFeatureAccess';
|
} from '../../hooks/useFeatureAccess';
|
||||||
import { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
|
import { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
|
||||||
import { FaBuilding, FaCube, FaUsers, FaCogs, FaSync, FaChartBar, FaLink, FaList, FaSitemap } from 'react-icons/fa';
|
import { FaBuilding, FaCube, FaUsers, FaCogs, FaSync, FaChartBar, FaLink, FaList, FaSitemap } from 'react-icons/fa';
|
||||||
|
import { StackLayout } from '../../components/Layout/StackLayout';
|
||||||
|
import { Panel } from '../../components/Layout/Panel';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
|
@ -306,7 +308,9 @@ export const AccessManagementHub: React.FC = () => {
|
||||||
|
|
||||||
if (error && !selectedMandateId) {
|
if (error && !selectedMandateId) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<StackLayout variant="scroll">
|
||||||
|
<StackLayout.Body>
|
||||||
|
<Panel variant="card" title={t('Fehler')} id="access-hub-error">
|
||||||
<div className={styles.errorContainer}>
|
<div className={styles.errorContainer}>
|
||||||
<span className={styles.errorIcon}>⚠️</span>
|
<span className={styles.errorIcon}>⚠️</span>
|
||||||
<p className={styles.errorMessage}>
|
<p className={styles.errorMessage}>
|
||||||
|
|
@ -316,21 +320,25 @@ export const AccessManagementHub: React.FC = () => {
|
||||||
<FaSync /> {t('Erneut versuchen')}
|
<FaSync /> {t('Erneut versuchen')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
|
</StackLayout.Body>
|
||||||
|
</StackLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<>
|
||||||
<div className={styles.pageHeader}>
|
<StackLayout variant="scroll">
|
||||||
|
<StackLayout.Header>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>{t('Zugriffsverwaltung')}</h1>
|
<h1 className={styles.pageTitle}>{t('Zugriffsverwaltung')}</h1>
|
||||||
<p className={styles.pageSubtitle}>
|
<p className={styles.pageSubtitle}>
|
||||||
{t('Feature-Instanzen, Benutzer und Rollen an einem Ort verwalten')}
|
{t('Feature-Instanzen, Benutzer und Rollen an einem Ort verwalten')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</StackLayout.Header>
|
||||||
|
<StackLayout.Body>
|
||||||
|
<Panel variant="toolbar" title={t('Filter')} id="access-hub-toolbar">
|
||||||
<div className={hubStyles.filters}>
|
<div className={hubStyles.filters}>
|
||||||
{/* Filter dropdowns only shown in list view - hierarchy shows everything */}
|
{/* Filter dropdowns only shown in list view - hierarchy shows everything */}
|
||||||
{viewMode === 'list' && (
|
{viewMode === 'list' && (
|
||||||
|
|
@ -419,6 +427,7 @@ export const AccessManagementHub: React.FC = () => {
|
||||||
<FaUsers /> {t('Mandant-Benutzer')}
|
<FaUsers /> {t('Mandant-Benutzer')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
{viewMode === 'hierarchy' ? (
|
{viewMode === 'hierarchy' ? (
|
||||||
<InstanceHierarchyView
|
<InstanceHierarchyView
|
||||||
|
|
@ -432,6 +441,7 @@ export const AccessManagementHub: React.FC = () => {
|
||||||
onOpenDetail={handleOpenDetail}
|
onOpenDetail={handleOpenDetail}
|
||||||
/>
|
/>
|
||||||
) : !selectedMandateId ? (
|
) : !selectedMandateId ? (
|
||||||
|
<Panel variant="card" title={t('Kein Mandant ausgewählt')} id="access-hub-no-mandate">
|
||||||
<div className={styles.emptyState}>
|
<div className={styles.emptyState}>
|
||||||
<FaBuilding className={styles.emptyIcon} />
|
<FaBuilding className={styles.emptyIcon} />
|
||||||
<h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3>
|
<h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3>
|
||||||
|
|
@ -439,8 +449,10 @@ export const AccessManagementHub: React.FC = () => {
|
||||||
{t('Wählen Sie einen Mandanten, um dessen Feature-Instanzen und Zugriffe zu verwalten.')}
|
{t('Wählen Sie einen Mandanten, um dessen Feature-Instanzen und Zugriffe zu verwalten.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<Panel variant="dashboard" title={t('Übersicht')} id="access-hub-overview">
|
||||||
<div className={hubStyles.overviewRow}>
|
<div className={hubStyles.overviewRow}>
|
||||||
<div className={hubStyles.statsCard}>
|
<div className={hubStyles.statsCard}>
|
||||||
<FaChartBar className={hubStyles.statsIcon} />
|
<FaChartBar className={hubStyles.statsIcon} />
|
||||||
|
|
@ -493,7 +505,9 @@ export const AccessManagementHub: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel variant="dashboard" title={t('Feature-Instanzen')} id="access-hub-instances">
|
||||||
<section className={hubStyles.section}>
|
<section className={hubStyles.section}>
|
||||||
<h2 className={hubStyles.sectionTitle}>{t('Feature-Instanzen')}</h2>
|
<h2 className={hubStyles.sectionTitle}>{t('Feature-Instanzen')}</h2>
|
||||||
{loading && filteredInstances.length === 0 ? (
|
{loading && filteredInstances.length === 0 ? (
|
||||||
|
|
@ -560,8 +574,11 @@ export const AccessManagementHub: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
</Panel>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</StackLayout.Body>
|
||||||
|
</StackLayout>
|
||||||
|
|
||||||
{detailInstance && (
|
{detailInstance && (
|
||||||
<InstanceDetailModal
|
<InstanceDetailModal
|
||||||
|
|
@ -583,7 +600,7 @@ export const AccessManagementHub: React.FC = () => {
|
||||||
onComplete={handleWizardComplete}
|
onComplete={handleWizardComplete}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,36 +4,6 @@
|
||||||
* Common styles for all admin pages using FormGeneratorTable
|
* Common styles for all admin pages using FormGeneratorTable
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.adminPage {
|
|
||||||
padding: 1.5rem;
|
|
||||||
/* Default: grow with content → scroll on MainLayout .outletShell (expandable panels, long pages). */
|
|
||||||
flex: 0 0 auto;
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* FormGeneratorTable expects a bounded height chain (height:100% / flex:1).
|
|
||||||
* With default .adminPage (flex:0 0 auto), .tableContainer flex:1 collapses → empty table.
|
|
||||||
* Use together: className={`${styles.adminPage} ${styles.adminPageFill}`}
|
|
||||||
*/
|
|
||||||
.adminPage.adminPageFill {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pageHeader {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pageTitle {
|
.pageTitle {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
@ -211,14 +181,6 @@
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableContainer {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loadingContainer {
|
.loadingContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -728,12 +690,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.pageHeader {
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.headerActions {
|
.headerActions {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
@ -751,18 +707,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.adminPage {
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pageHeader {
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pageTitle {
|
.pageTitle {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
@ -784,10 +728,6 @@
|
||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableContainer {
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================== */
|
/* ============================================== */
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,10 @@ import styles from './Admin.module.css';
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
import { useConfirm } from '../../hooks/useConfirm';
|
import { useConfirm } from '../../hooks/useConfirm';
|
||||||
import { Tabs } from '../../components/UiComponents/Tabs/Tabs';
|
import { StackLayout } from '../../components/Layout/StackLayout';
|
||||||
|
import { Panel } from '../../components/Layout/Panel';
|
||||||
|
import { LayoutTabs } from '../../components/Layout/LayoutTabs';
|
||||||
|
import type { LayoutTabItem } from '../../components/Layout/types';
|
||||||
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -311,8 +314,8 @@ const StatsTab: React.FC = () => {
|
||||||
], [t, databases]);
|
], [t, databases]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
<>
|
||||||
{/* Controls */}
|
<Panel variant="toolbar" title={t('Filter')} id="db-stats-toolbar">
|
||||||
<div className={styles.filterSection}>
|
<div className={styles.filterSection}>
|
||||||
<div className={styles.filterGroup}>
|
<div className={styles.filterGroup}>
|
||||||
<label className={styles.filterLabel}>{t('Datenbank')}</label>
|
<label className={styles.filterLabel}>{t('Datenbank')}</label>
|
||||||
|
|
@ -329,8 +332,9 @@ const StatsTab: React.FC = () => {
|
||||||
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
{/* Summary */}
|
<Panel variant="card" title={t('Zusammenfassung')} id="db-stats-summary">
|
||||||
<div className={styles.filterSection} style={{ gap: '0.75rem', flexWrap: 'wrap' }}>
|
<div className={styles.filterSection} style={{ gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||||
<span className={styles.filterLabel}>{t('{dbs} Datenbanken', { dbs: totals.dbs })}</span>
|
<span className={styles.filterLabel}>{t('{dbs} Datenbanken', { dbs: totals.dbs })}</span>
|
||||||
<span className={styles.filterLabel}>{t('{tables} Tabellen', { tables: totals.tables })}</span>
|
<span className={styles.filterLabel}>{t('{tables} Tabellen', { tables: totals.tables })}</span>
|
||||||
|
|
@ -338,11 +342,13 @@ const StatsTab: React.FC = () => {
|
||||||
<span className={styles.filterLabel}>{t('Total {size}', { size: _formatBytes(totals.size) })}</span>
|
<span className={styles.filterLabel}>{t('Total {size}', { size: _formatBytes(totals.size) })}</span>
|
||||||
<span className={styles.filterLabel}>{t('Index {size}', { size: _formatBytes(totals.idx) })}</span>
|
<span className={styles.filterLabel}>{t('Index {size}', { size: _formatBytes(totals.idx) })}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<div className={styles.tableContainer}>
|
<Panel variant="table" title={t('Tabellen')} id="db-stats-table">
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
data={visibleData}
|
data={visibleData}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
filterScopeKey="admin"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
sortable={true}
|
sortable={true}
|
||||||
searchable={true}
|
searchable={true}
|
||||||
|
|
@ -357,8 +363,8 @@ const StatsTab: React.FC = () => {
|
||||||
}}
|
}}
|
||||||
emptyMessage={t('Keine Tabellen gefunden')}
|
emptyMessage={t('Keine Tabellen gefunden')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Panel>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -625,10 +631,10 @@ const OrphansTab: React.FC = () => {
|
||||||
], [t, databases]);
|
], [t, databases]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
<>
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
|
|
||||||
{/* Controls */}
|
<Panel variant="toolbar" title={t('Filter')} id="orphans-toolbar">
|
||||||
<div className={styles.filterSection}>
|
<div className={styles.filterSection}>
|
||||||
<div className={styles.filterGroup}>
|
<div className={styles.filterGroup}>
|
||||||
<label className={styles.filterLabel}>{t('Datenbank')}</label>
|
<label className={styles.filterLabel}>{t('Datenbank')}</label>
|
||||||
|
|
@ -671,8 +677,10 @@ const OrphansTab: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
{totalOrphans > 0 && (
|
{totalOrphans > 0 && (
|
||||||
|
<Panel variant="card" title={t('Warnung')} id="orphans-warning">
|
||||||
<div className={styles.infoBox} style={{ background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
|
<div className={styles.infoBox} style={{ background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
|
||||||
<FaExclamationTriangle style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} />
|
<FaExclamationTriangle style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} />
|
||||||
{t('{count} verwaiste Einträge in {relations} Beziehungen gefunden', {
|
{t('{count} verwaiste Einträge in {relations} Beziehungen gefunden', {
|
||||||
|
|
@ -680,12 +688,14 @@ const OrphansTab: React.FC = () => {
|
||||||
relations: allOrphans.filter(o => o.orphanCount > 0).length,
|
relations: allOrphans.filter(o => o.orphanCount > 0).length,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.tableContainer}>
|
<Panel variant="table" title={t('Verwaiste Einträge')} id="orphans-table">
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
data={visibleData}
|
data={visibleData}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
filterScopeKey="admin"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
sortable={true}
|
sortable={true}
|
||||||
searchable={true}
|
searchable={true}
|
||||||
|
|
@ -718,8 +728,8 @@ const OrphansTab: React.FC = () => {
|
||||||
}}
|
}}
|
||||||
emptyMessage={onlyProblems ? t('Keine Orphans gefunden') : t('Keine FK-Beziehungen gefunden')}
|
emptyMessage={onlyProblems ? t('Keine Orphans gefunden') : t('Keine FK-Beziehungen gefunden')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Panel>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1157,10 +1167,10 @@ const MigrationTab: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, overflow: 'auto', gap: '2rem', padding: '0.5rem 0' }}>
|
<>
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
|
|
||||||
{/* ---- BACKUP SECTION ---- */}
|
<Panel variant="card" title={t('Backup')} id="db-backup">
|
||||||
<section>
|
<section>
|
||||||
<h2 style={{ fontSize: '1.125rem', fontWeight: 600, color: 'var(--text-primary)', margin: '0 0 1rem 0', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
<h2 style={{ fontSize: '1.125rem', fontWeight: 600, color: 'var(--text-primary)', margin: '0 0 1rem 0', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
<FaDownload /> {t('Backup')}
|
<FaDownload /> {t('Backup')}
|
||||||
|
|
@ -1240,17 +1250,10 @@ const MigrationTab: React.FC = () => {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
{/* ---- DIVIDER ---- */}
|
<Panel variant="card" title={t('Restore')} id="db-restore">
|
||||||
<hr style={{ border: 'none', borderTop: '1px solid var(--border-color)', margin: 0 }} />
|
|
||||||
|
|
||||||
{/* ---- RESTORE SECTION ---- */}
|
|
||||||
<section>
|
<section>
|
||||||
<h2 style={{ fontSize: '1.125rem', fontWeight: 600, color: 'var(--text-primary)', margin: '0 0 1rem 0', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
||||||
<FaUpload /> {t('Restore')}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{/* File upload zone */}
|
|
||||||
{!uploadedFile ? (
|
{!uploadedFile ? (
|
||||||
<div
|
<div
|
||||||
onDrop={_onDrop}
|
onDrop={_onDrop}
|
||||||
|
|
@ -1467,7 +1470,8 @@ const MigrationTab: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</Panel>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1661,9 +1665,10 @@ const LegacyCleanupTab: React.FC = () => {
|
||||||
], [t, databases, selected]);
|
], [t, databases, selected]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
<>
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
|
|
||||||
|
<Panel variant="toolbar" title={t('Aktionen')} id="legacy-toolbar">
|
||||||
<div className={styles.filterSection}>
|
<div className={styles.filterSection}>
|
||||||
<div className={styles.headerActions}>
|
<div className={styles.headerActions}>
|
||||||
<button className={styles.secondaryButton} onClick={_fetchLegacy} disabled={loading}>
|
<button className={styles.secondaryButton} onClick={_fetchLegacy} disabled={loading}>
|
||||||
|
|
@ -1681,20 +1686,24 @@ const LegacyCleanupTab: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
{allLegacy.length > 0 && (
|
{allLegacy.length > 0 && (
|
||||||
|
<Panel variant="card" title={t('Warnung')} id="legacy-warning">
|
||||||
<div className={styles.infoBox} style={{ background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
|
<div className={styles.infoBox} style={{ background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
|
||||||
<FaExclamationTriangle style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} />
|
<FaExclamationTriangle style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} />
|
||||||
{t('{count} Legacy-Tabellen in {dbs} Datenbanken ({rows} Zeilen, {size})', {
|
{t('{count} Legacy-Tabellen in {dbs} Datenbanken ({rows} Zeilen, {size})', {
|
||||||
count: totals.count, dbs: totals.dbs, rows: _formatNumber(totals.rows), size: _formatBytes(totals.size),
|
count: totals.count, dbs: totals.dbs, rows: _formatNumber(totals.rows), size: _formatBytes(totals.size),
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.tableContainer}>
|
<Panel variant="table" title={t('Legacy-Tabellen')} id="legacy-table">
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
data={visibleData}
|
data={visibleData}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
filterScopeKey="admin"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
sortable={true}
|
sortable={true}
|
||||||
searchable={true}
|
searchable={true}
|
||||||
|
|
@ -1718,8 +1727,8 @@ const LegacyCleanupTab: React.FC = () => {
|
||||||
}}
|
}}
|
||||||
emptyMessage={t('Keine Legacy-Tabellen gefunden')}
|
emptyMessage={t('Keine Legacy-Tabellen gefunden')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Panel>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1731,40 +1740,41 @@ const LegacyCleanupTab: React.FC = () => {
|
||||||
export const AdminDatabaseHealthPage: React.FC = () => {
|
export const AdminDatabaseHealthPage: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
||||||
const tabs = useMemo(() => [
|
const tabs: LayoutTabItem[] = useMemo(() => [
|
||||||
{
|
{
|
||||||
id: 'stats',
|
id: 'stats',
|
||||||
label: t('Statistiken'),
|
label: t('Statistiken'),
|
||||||
content: <StatsTab />,
|
render: () => <StatsTab />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'orphans',
|
id: 'orphans',
|
||||||
label: t('Orphan Cleanup'),
|
label: t('Orphan Cleanup'),
|
||||||
content: <OrphansTab />,
|
render: () => <OrphansTab />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'legacy',
|
id: 'legacy',
|
||||||
label: t('Legacy Cleanup'),
|
label: t('Legacy Cleanup'),
|
||||||
content: <LegacyCleanupTab />,
|
render: () => <LegacyCleanupTab />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'migration',
|
id: 'migration',
|
||||||
label: t('Migration'),
|
label: t('Migration'),
|
||||||
content: <MigrationTab />,
|
render: () => <MigrationTab />,
|
||||||
},
|
},
|
||||||
], [t]);
|
], [t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
<StackLayout variant="table">
|
||||||
<div className={styles.pageHeader}>
|
<StackLayout.Header>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>{t('Datenbank-Gesundheit')}</h1>
|
<h1 className={styles.pageTitle}>{t('Datenbank-Gesundheit')}</h1>
|
||||||
<p className={styles.pageSubtitle}>{t('Tabellenstatistiken, verwaiste Datensaetze und Migration')}</p>
|
<p className={styles.pageSubtitle}>{t('Tabellenstatistiken, verwaiste Datensaetze und Migration')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</StackLayout.Header>
|
||||||
|
<StackLayout.Body>
|
||||||
<Tabs tabs={tabs} defaultTabId="stats" />
|
<LayoutTabs items={tabs} urlParam="tab" defaultTab="stats" lazy />
|
||||||
</div>
|
</StackLayout.Body>
|
||||||
|
</StackLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { FaPlay, FaTrash, FaSync, FaCubes, FaCopy, FaKey } from 'react-icons/fa';
|
import { FaPlay, FaTrash, FaSync, FaCubes, FaCopy, FaKey } from 'react-icons/fa';
|
||||||
|
import { StackLayout } from '../../components/Layout/StackLayout';
|
||||||
|
import { Panel } from '../../components/Layout/Panel';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
import demoStyles from './AdminDemoConfigPage.module.css';
|
import demoStyles from './AdminDemoConfigPage.module.css';
|
||||||
|
|
@ -111,22 +113,30 @@ export const AdminDemoConfigPage: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<StackLayout variant="dashboard">
|
||||||
<div className={styles.pageHeader}>
|
<StackLayout.Header>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>{t('Demo-Konfigurationen')}</h1>
|
<h1 className={styles.pageTitle}>{t('Demo-Konfigurationen')}</h1>
|
||||||
<p className={styles.pageSubtitle}>{t('Demo-Umgebungen für Präsentationen und Tests laden oder entfernen.')}</p>
|
<p className={styles.pageSubtitle}>{t('Demo-Umgebungen für Präsentationen und Tests laden oder entfernen.')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</StackLayout.Header>
|
||||||
|
<StackLayout.Body>
|
||||||
|
<Panel variant="toolbar" title={t('Aktionen')} id="demo-toolbar">
|
||||||
<div className={styles.headerActions}>
|
<div className={styles.headerActions}>
|
||||||
<button className={styles.secondaryButton} onClick={_fetchConfigs} disabled={loading}>
|
<button className={styles.secondaryButton} onClick={_fetchConfigs} disabled={loading}>
|
||||||
<FaSync /> {t('Aktualisieren')}
|
<FaSync /> {t('Aktualisieren')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
{error && <div className={demoStyles.errorBanner}>{error}</div>}
|
{error && (
|
||||||
|
<Panel variant="card" title={t('Fehler')} id="demo-error">
|
||||||
|
<div className={demoStyles.errorBanner}>{error}</div>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
{lastResult && (
|
{lastResult && (
|
||||||
|
<Panel variant="card" title={t('Ergebnis')} id="demo-result">
|
||||||
<div className={lastResult.status === 'ok' ? demoStyles.successBanner : demoStyles.errorBanner}>
|
<div className={lastResult.status === 'ok' ? demoStyles.successBanner : demoStyles.errorBanner}>
|
||||||
<strong>{lastResult.action === 'load' ? t('Geladen') : t('Entfernt')}:</strong>{' '}
|
<strong>{lastResult.action === 'load' ? t('Geladen') : t('Entfernt')}:</strong>{' '}
|
||||||
{lastResult.status === 'ok' ? (
|
{lastResult.status === 'ok' ? (
|
||||||
|
|
@ -138,8 +148,10 @@ export const AdminDemoConfigPage: React.FC = () => {
|
||||||
<_CredentialsBox credentials={lastResult.credentials} />
|
<_CredentialsBox credentials={lastResult.credentials} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Panel variant="dashboard" title={t('Demo-Konfigurationen')} id="demo-configs">
|
||||||
{loading && configs.length === 0 ? (
|
{loading && configs.length === 0 ? (
|
||||||
<div className={demoStyles.loadingState}>{t('Lade…')}</div>
|
<div className={demoStyles.loadingState}>{t('Lade…')}</div>
|
||||||
) : configs.length === 0 ? (
|
) : configs.length === 0 ? (
|
||||||
|
|
@ -179,9 +191,10 @@ export const AdminDemoConfigPage: React.FC = () => {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</Panel>
|
||||||
|
</StackLayout.Body>
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
</div>
|
</StackLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import { useFeatureStore } from '../../stores/featureStore';
|
||||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { FaPlus, FaSync, FaCube, FaBuilding, FaCogs, FaEdit } from 'react-icons/fa';
|
import { FaPlus, FaSync, FaCube, FaBuilding, FaCogs, FaEdit } from 'react-icons/fa';
|
||||||
|
import { StackLayout } from '../../components/Layout/StackLayout';
|
||||||
|
import { Panel } from '../../components/Layout/Panel';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||||
|
|
@ -61,8 +63,8 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
const [createFeatureCode, setCreateFeatureCode] = useState<string>('');
|
const [createFeatureCode, setCreateFeatureCode] = useState<string>('');
|
||||||
const [createLabel, setCreateLabel] = useState<string>(''); // Label field value
|
const [createLabel, setCreateLabel] = useState<string>(''); // Label field value
|
||||||
|
|
||||||
// Ref to track form data for featureCode detection
|
|
||||||
const formDataRef = useRef<Record<string, any>>({});
|
const formDataRef = useRef<Record<string, any>>({});
|
||||||
|
const _lastTableParams = useRef<any>(undefined);
|
||||||
|
|
||||||
// Load features, mandates, and attributes on mount
|
// Load features, mandates, and attributes on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -80,12 +82,22 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
}).catch(() => setBackendAttributes([]));
|
}).catch(() => setBackendAttributes([]));
|
||||||
}, [fetchFeatures, fetchMandates]);
|
}, [fetchFeatures, fetchMandates]);
|
||||||
|
|
||||||
// Load instances when mandate changes
|
const _refetchInstances = React.useCallback(async (paginationParams?: any) => {
|
||||||
|
if (!selectedMandateId) return;
|
||||||
|
if (paginationParams && typeof paginationParams === 'object') {
|
||||||
|
_lastTableParams.current = paginationParams;
|
||||||
|
} else {
|
||||||
|
paginationParams = _lastTableParams.current;
|
||||||
|
}
|
||||||
|
if (paginationParams) return fetchInstances(paginationParams);
|
||||||
|
return fetchInstances(selectedMandateId);
|
||||||
|
}, [selectedMandateId, fetchInstances]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedMandateId) {
|
if (selectedMandateId) {
|
||||||
fetchInstances(selectedMandateId);
|
_lastTableParams.current = undefined;
|
||||||
}
|
}
|
||||||
}, [selectedMandateId, fetchInstances]);
|
}, [selectedMandateId]);
|
||||||
|
|
||||||
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||||
{ key: 'label', label: t('Name'), sortable: true, filterable: true, searchable: true, width: 200 },
|
{ key: 'label', label: t('Name'), sortable: true, filterable: true, searchable: true, width: 200 },
|
||||||
|
|
@ -145,8 +157,8 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
setCreateFeatureCode('');
|
setCreateFeatureCode('');
|
||||||
setCreateLabel('');
|
setCreateLabel('');
|
||||||
formDataRef.current = {};
|
formDataRef.current = {};
|
||||||
fetchInstances(selectedMandateId);
|
_refetchInstances();
|
||||||
loadFeatures(); // Refresh global navigation cache
|
loadFeatures();
|
||||||
showSuccess(t('Feature-Instanz erstellt'), t('Die Instanz "{name}" wurde erfolgreich erstellt.', { name: createLabel }));
|
showSuccess(t('Feature-Instanz erstellt'), t('Die Instanz "{name}" wurde erfolgreich erstellt.', { name: createLabel }));
|
||||||
} else {
|
} else {
|
||||||
showError(t('Fehler'), result.error || t('Fehler beim Erstellen der Feature-Instanz'));
|
showError(t('Fehler'), result.error || t('Fehler beim Erstellen der Feature-Instanz'));
|
||||||
|
|
@ -183,8 +195,8 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
setEditingInstance(null);
|
setEditingInstance(null);
|
||||||
fetchInstances(selectedMandateId);
|
_refetchInstances();
|
||||||
loadFeatures(); // Refresh global navigation cache
|
loadFeatures();
|
||||||
showSuccess(t('Feature-Instanz aktualisiert'), t('Die Instanz "{name}" wurde erfolgreich aktualisiert.', { name: data.label }));
|
showSuccess(t('Feature-Instanz aktualisiert'), t('Die Instanz "{name}" wurde erfolgreich aktualisiert.', { name: data.label }));
|
||||||
} else {
|
} else {
|
||||||
showError(t('Fehler'), result.error || t('Fehler beim Aktualisieren der Feature-Instanz'));
|
showError(t('Fehler'), result.error || t('Fehler beim Aktualisieren der Feature-Instanz'));
|
||||||
|
|
@ -262,28 +274,33 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
|
|
||||||
if (error && !selectedMandateId) {
|
if (error && !selectedMandateId) {
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
<StackLayout variant="table">
|
||||||
|
<StackLayout.Body>
|
||||||
|
<Panel variant="card" title={t('Fehler')} id="feature-access-error">
|
||||||
<div className={styles.errorContainer}>
|
<div className={styles.errorContainer}>
|
||||||
<span className={styles.errorIcon}>⚠️</span>
|
<span className={styles.errorIcon}>⚠️</span>
|
||||||
<p className={styles.errorMessage}>Fehler: {error}</p>
|
<p className={styles.errorMessage}>{t('Fehler')}: {error}</p>
|
||||||
<button className={styles.secondaryButton} onClick={() => fetchFeatures()}>
|
<button className={styles.secondaryButton} onClick={() => fetchFeatures()}>
|
||||||
<FaSync /> {t('Erneut versuchen')}
|
<FaSync /> {t('Erneut versuchen')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
|
</StackLayout.Body>
|
||||||
|
</StackLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
<>
|
||||||
<div className={styles.pageHeader}>
|
<StackLayout variant="table">
|
||||||
|
<StackLayout.Header>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>{t('Feature-Instanzen')}</h1>
|
<h1 className={styles.pageTitle}>{t('Feature-Instanzen')}</h1>
|
||||||
<p className={styles.pageSubtitle}>{t('Verwalten Sie Feature-Instanzen für jeden')}</p>
|
<p className={styles.pageSubtitle}>{t('Verwalten Sie Feature-Instanzen für jeden')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</StackLayout.Header>
|
||||||
|
<StackLayout.Body>
|
||||||
{/* Mandate Selector */}
|
<Panel variant="toolbar" title={t('Filter')} id="feature-access-toolbar">
|
||||||
<div className={styles.filterSection}>
|
<div className={styles.filterSection}>
|
||||||
<div className={styles.filterGroup}>
|
<div className={styles.filterGroup}>
|
||||||
<label className={styles.filterLabel}>
|
<label className={styles.filterLabel}>
|
||||||
|
|
@ -308,7 +325,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
<div className={styles.headerActions}>
|
<div className={styles.headerActions}>
|
||||||
<button
|
<button
|
||||||
className={styles.secondaryButton}
|
className={styles.secondaryButton}
|
||||||
onClick={() => fetchInstances(selectedMandateId)}
|
onClick={() => _refetchInstances()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
||||||
|
|
@ -328,9 +345,10 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
{/* Available Features Info / Empty Features Warning */}
|
|
||||||
{features.length > 0 ? (
|
{features.length > 0 ? (
|
||||||
|
<Panel variant="card" title={t('Verfügbare Features')} id="feature-access-features">
|
||||||
<div className={styles.infoBox}>
|
<div className={styles.infoBox}>
|
||||||
<FaCube style={{ marginRight: 8 }} />
|
<FaCube style={{ marginRight: 8 }} />
|
||||||
<span>{t('Verfügbare Features')} </span>
|
<span>{t('Verfügbare Features')} </span>
|
||||||
|
|
@ -341,7 +359,9 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
) : selectedMandateId && !loading ? (
|
) : selectedMandateId && !loading ? (
|
||||||
|
<Panel variant="card" title={t('Keine Features geladen')} id="feature-access-no-features">
|
||||||
<div className={styles.infoBox} style={{ borderColor: 'var(--error-color, #dc3545)', backgroundColor: 'var(--error-bg, rgba(220, 53, 69, 0.1))' }}>
|
<div className={styles.infoBox} style={{ borderColor: 'var(--error-color, #dc3545)', backgroundColor: 'var(--error-bg, rgba(220, 53, 69, 0.1))' }}>
|
||||||
<FaCube style={{ marginRight: 8 }} />
|
<FaCube style={{ marginRight: 8 }} />
|
||||||
<span>
|
<span>
|
||||||
|
|
@ -358,10 +378,11 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
<FaSync /> {t('Features erneut laden')}
|
<FaSync /> {t('Features erneut laden')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
{!selectedMandateId ? (
|
{!selectedMandateId ? (
|
||||||
|
<Panel variant="card" title={t('Kein Mandant ausgewählt')} id="feature-access-no-mandate">
|
||||||
<div className={styles.emptyState}>
|
<div className={styles.emptyState}>
|
||||||
<FaBuilding className={styles.emptyIcon} />
|
<FaBuilding className={styles.emptyIcon} />
|
||||||
<h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3>
|
<h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3>
|
||||||
|
|
@ -369,12 +390,14 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
{t('Wählen Sie einen Mandanten aus, um dessen Feature-Instanzen zu verwalten.')}
|
{t('Wählen Sie einen Mandanten aus, um dessen Feature-Instanzen zu verwalten.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.tableContainer}>
|
<Panel variant="table" title={t('Feature-Instanzen')} id="feature-access-table">
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
data={instances}
|
data={instances}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
apiEndpoint="/api/features/instances"
|
apiEndpoint="/api/features/instances"
|
||||||
|
filterScopeKey={selectedMandateId || 'admin'}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={true}
|
pagination={true}
|
||||||
pageSize={25}
|
pageSize={25}
|
||||||
|
|
@ -413,14 +436,16 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
hookData={{
|
hookData={{
|
||||||
refetch: fetchInstances,
|
refetch: _refetchInstances,
|
||||||
pagination: instancesPagination,
|
pagination: instancesPagination,
|
||||||
handleDelete: handleDeleteInstance,
|
handleDelete: handleDeleteInstance,
|
||||||
}}
|
}}
|
||||||
emptyMessage={t('Keine Feature-Instanzen gefunden')}
|
emptyMessage={t('Keine Feature-Instanzen gefunden')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Panel>
|
||||||
)}
|
)}
|
||||||
|
</StackLayout.Body>
|
||||||
|
</StackLayout>
|
||||||
|
|
||||||
{/* Create Instance Modal */}
|
{/* Create Instance Modal */}
|
||||||
{showCreateModal && (
|
{showCreateModal && (
|
||||||
|
|
@ -563,7 +588,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,14 @@
|
||||||
* Allows adding, removing, and updating user roles within feature instances.
|
* Allows adding, removing, and updating user roles within feature instances.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||||
import { useFeatureAccess, type FeatureAccessUser, type FeatureInstanceRole, type PaginationParams, type PaginationMetadata } from '../../hooks/useFeatureAccess';
|
import { useFeatureAccess, type FeatureAccessUser, type FeatureInstanceRole, type PaginationParams, type PaginationMetadata } from '../../hooks/useFeatureAccess';
|
||||||
import { useUserMandates } from '../../hooks/useUserMandates';
|
import { useUserMandates } from '../../hooks/useUserMandates';
|
||||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { FaPlus, FaSync, FaBuilding, FaCube } from 'react-icons/fa';
|
import { FaPlus, FaSync, FaBuilding, FaCube } from 'react-icons/fa';
|
||||||
|
import { StackLayout } from '../../components/Layout/StackLayout';
|
||||||
|
import { Panel } from '../../components/Layout/Panel';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
import { useFeatureStore } from '../../stores/featureStore';
|
import { useFeatureStore } from '../../stores/featureStore';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
|
|
@ -34,7 +36,6 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
fetchFeatures,
|
fetchFeatures,
|
||||||
fetchInstanceUsers,
|
|
||||||
addUserToInstance,
|
addUserToInstance,
|
||||||
removeUserFromInstance,
|
removeUserFromInstance,
|
||||||
updateInstanceUserRoles,
|
updateInstanceUserRoles,
|
||||||
|
|
@ -68,6 +69,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
const [, setIsSubmitting] = useState(false);
|
const [, setIsSubmitting] = useState(false);
|
||||||
const [usersLoading, setUsersLoading] = useState(false);
|
const [usersLoading, setUsersLoading] = useState(false);
|
||||||
const [usersPagination, setUsersPagination] = useState<PaginationMetadata | null>(null);
|
const [usersPagination, setUsersPagination] = useState<PaginationMetadata | null>(null);
|
||||||
|
const _lastTableParams = useRef<any>(undefined);
|
||||||
|
|
||||||
// Extract mandateId and instanceId from combined key
|
// Extract mandateId and instanceId from combined key
|
||||||
const selectedMandateId = useMemo(() => {
|
const selectedMandateId = useMemo(() => {
|
||||||
|
|
@ -135,21 +137,13 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
loadCombinedOptions();
|
loadCombinedOptions();
|
||||||
}, [fetchFeatures, fetchMandates]);
|
}, [fetchFeatures, fetchMandates]);
|
||||||
|
|
||||||
// Load users and roles when instance changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedMandateId && selectedInstanceId) {
|
if (selectedMandateId && selectedInstanceId) {
|
||||||
setUsersLoading(true);
|
_lastTableParams.current = undefined;
|
||||||
Promise.all([
|
fetchInstanceRoles(selectedMandateId, selectedInstanceId)
|
||||||
fetchInstanceUsers(selectedMandateId, selectedInstanceId),
|
.then(setInstanceRoles);
|
||||||
fetchInstanceRoles(selectedMandateId, selectedInstanceId),
|
|
||||||
]).then(([users, roles]) => {
|
|
||||||
setInstanceUsers(users);
|
|
||||||
setInstanceRoles(roles);
|
|
||||||
}).finally(() => {
|
|
||||||
setUsersLoading(false);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [selectedMandateId, selectedInstanceId, fetchInstanceUsers, fetchInstanceRoles]);
|
}, [selectedMandateId, selectedInstanceId, fetchInstanceRoles]);
|
||||||
|
|
||||||
// Load mandate members for the add modal (only users who are members of the selected mandate)
|
// Load mandate members for the add modal (only users who are members of the selected mandate)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -170,12 +164,15 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
}).catch(() => setAllUsers([]));
|
}).catch(() => setAllUsers([]));
|
||||||
}, [selectedMandateId]);
|
}, [selectedMandateId]);
|
||||||
|
|
||||||
// Refresh instance users with optional pagination
|
|
||||||
const refreshUsers = useCallback(async (paginationParams?: PaginationParams) => {
|
const refreshUsers = useCallback(async (paginationParams?: PaginationParams) => {
|
||||||
if (selectedMandateId && selectedInstanceId) {
|
if (selectedMandateId && selectedInstanceId) {
|
||||||
|
if (paginationParams && Object.keys(paginationParams).length > 0) {
|
||||||
|
_lastTableParams.current = paginationParams;
|
||||||
|
} else {
|
||||||
|
paginationParams = _lastTableParams.current;
|
||||||
|
}
|
||||||
setUsersLoading(true);
|
setUsersLoading(true);
|
||||||
try {
|
try {
|
||||||
// Build query params
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (paginationParams && Object.keys(paginationParams).length > 0) {
|
if (paginationParams && Object.keys(paginationParams).length > 0) {
|
||||||
params.append('pagination', JSON.stringify(paginationParams));
|
params.append('pagination', JSON.stringify(paginationParams));
|
||||||
|
|
@ -403,7 +400,9 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
|
|
||||||
if (error && !selectedCombinedKey) {
|
if (error && !selectedCombinedKey) {
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
<StackLayout variant="table">
|
||||||
|
<StackLayout.Body>
|
||||||
|
<Panel variant="card" title={t('Fehler')} id="fi-users-error">
|
||||||
<div className={styles.errorContainer}>
|
<div className={styles.errorContainer}>
|
||||||
<span className={styles.errorIcon}>⚠️</span>
|
<span className={styles.errorIcon}>⚠️</span>
|
||||||
<p className={styles.errorMessage}>
|
<p className={styles.errorMessage}>
|
||||||
|
|
@ -413,20 +412,23 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
<FaSync /> {t('Erneut versuchen')}
|
<FaSync /> {t('Erneut versuchen')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
|
</StackLayout.Body>
|
||||||
|
</StackLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
<>
|
||||||
<div className={styles.pageHeader}>
|
<StackLayout variant="table">
|
||||||
|
<StackLayout.Header>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>{t('Feature-Instanz-Benutzer')}</h1>
|
<h1 className={styles.pageTitle}>{t('Feature-Instanz-Benutzer')}</h1>
|
||||||
<p className={styles.pageSubtitle}>{t('Verwalten Sie Benutzerzugriffe auf Feature-Instanzen')}</p>
|
<p className={styles.pageSubtitle}>{t('Verwalten Sie Benutzerzugriffe auf Feature-Instanzen')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</StackLayout.Header>
|
||||||
|
<StackLayout.Body>
|
||||||
{/* Combined Selector: Mandate + Feature Instance */}
|
<Panel variant="toolbar" title={t('Filter')} id="fi-users-toolbar">
|
||||||
<div className={styles.filterSection}>
|
<div className={styles.filterSection}>
|
||||||
<div className={styles.filterGroup} style={{ flex: 1, maxWidth: 500 }}>
|
<div className={styles.filterGroup} style={{ flex: 1, maxWidth: 500 }}>
|
||||||
<label className={styles.filterLabel}>
|
<label className={styles.filterLabel}>
|
||||||
|
|
@ -482,9 +484,10 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
{/* Info box when instance is selected */}
|
|
||||||
{selectedOption && (
|
{selectedOption && (
|
||||||
|
<Panel variant="card" title={t('Ausgewählte Instanz')} id="fi-users-selection">
|
||||||
<div className={styles.infoBox}>
|
<div className={styles.infoBox}>
|
||||||
<FaBuilding style={{ marginRight: 8 }} />
|
<FaBuilding style={{ marginRight: 8 }} />
|
||||||
<span>
|
<span>
|
||||||
|
|
@ -496,10 +499,11 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
{t('Instanz')}: <strong>{selectedOption.instanceLabel}</strong> ({selectedOption.featureCode})
|
{t('Instanz')}: <strong>{selectedOption.instanceLabel}</strong> ({selectedOption.featureCode})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Roles info box */}
|
|
||||||
{selectedInstance && instanceRoles.length > 0 && (
|
{selectedInstance && instanceRoles.length > 0 && (
|
||||||
|
<Panel variant="card" title={t('Verfügbare Rollen')} id="fi-users-roles">
|
||||||
<div className={styles.infoBox}>
|
<div className={styles.infoBox}>
|
||||||
<span>{t('Verfügbare Rollen')} </span>
|
<span>{t('Verfügbare Rollen')} </span>
|
||||||
{instanceRoles.map((r, i) => (
|
{instanceRoles.map((r, i) => (
|
||||||
|
|
@ -509,18 +513,20 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Warning if no roles available */}
|
|
||||||
{selectedInstance && instanceRoles.length === 0 && !usersLoading && (
|
{selectedInstance && instanceRoles.length === 0 && !usersLoading && (
|
||||||
|
<Panel variant="card" title={t('Keine Rollen')} id="fi-users-no-roles">
|
||||||
<div className={styles.infoBox} style={{ borderColor: 'var(--warning-color, #d69e2e)', backgroundColor: 'var(--warning-bg, rgba(214, 158, 46, 0.12))' }}>
|
<div className={styles.infoBox} style={{ borderColor: 'var(--warning-color, #d69e2e)', backgroundColor: 'var(--warning-bg, rgba(214, 158, 46, 0.12))' }}>
|
||||||
<span>⚠️ </span>
|
<span>⚠️ </span>
|
||||||
<span>{t('Diese Instanz hat noch keine')}</span>
|
<span>{t('Diese Instanz hat noch keine')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
{!selectedCombinedKey ? (
|
{!selectedCombinedKey ? (
|
||||||
|
<Panel variant="card" title={t('Keine Feature-Instanz ausgewählt')} id="fi-users-empty">
|
||||||
<div className={styles.emptyState}>
|
<div className={styles.emptyState}>
|
||||||
<FaCube className={styles.emptyIcon} />
|
<FaCube className={styles.emptyIcon} />
|
||||||
<h3 className={styles.emptyTitle}>{t('Keine Feature-Instanz ausgewählt')}</h3>
|
<h3 className={styles.emptyTitle}>{t('Keine Feature-Instanz ausgewählt')}</h3>
|
||||||
|
|
@ -530,12 +536,14 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
: t('Wählen Sie eine Feature-Instanz aus')}
|
: t('Wählen Sie eine Feature-Instanz aus')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.tableContainer}>
|
<Panel variant="table" title={t('Benutzer')} id="fi-users-table">
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
data={instanceUsers}
|
data={instanceUsers}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
apiEndpoint={selectedInstanceId ? `/api/features/instances/${selectedInstanceId}/users` : undefined}
|
apiEndpoint={selectedInstanceId ? `/api/features/instances/${selectedInstanceId}/users` : undefined}
|
||||||
|
filterScopeKey={selectedMandateId || 'admin'}
|
||||||
loading={usersLoading}
|
loading={usersLoading}
|
||||||
pagination={true}
|
pagination={true}
|
||||||
pageSize={25}
|
pageSize={25}
|
||||||
|
|
@ -570,8 +578,10 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
}}
|
}}
|
||||||
emptyMessage={t('Keine Benutzer gefunden')}
|
emptyMessage={t('Keine Benutzer gefunden')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Panel>
|
||||||
)}
|
)}
|
||||||
|
</StackLayout.Body>
|
||||||
|
</StackLayout>
|
||||||
|
|
||||||
{/* Add User Modal */}
|
{/* Add User Modal */}
|
||||||
{showAddModal && (
|
{showAddModal && (
|
||||||
|
|
@ -635,7 +645,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,13 @@
|
||||||
* - Actions: Create feature role, edit description, manage AccessRules, delete role
|
* - Actions: Create feature role, edit description, manage AccessRules, delete role
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { AccessRulesEditor } from '../../components/AccessRules';
|
import { AccessRulesEditor } from '../../components/AccessRules';
|
||||||
import { FaPlus, FaSync, FaUserShield, FaCube, FaShieldAlt } from 'react-icons/fa';
|
import { FaPlus, FaSync, FaUserShield, FaCube, FaShieldAlt } from 'react-icons/fa';
|
||||||
|
import { StackLayout } from '../../components/Layout/StackLayout';
|
||||||
|
import { Panel } from '../../components/Layout/Panel';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { useApiRequest } from '../../hooks/useApi';
|
import { useApiRequest } from '../../hooks/useApi';
|
||||||
|
|
@ -64,6 +66,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
||||||
const [editingRole, setEditingRole] = useState<FeatureRole | null>(null);
|
const [editingRole, setEditingRole] = useState<FeatureRole | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [permissionsRole, setPermissionsRole] = useState<FeatureRole | null>(null);
|
const [permissionsRole, setPermissionsRole] = useState<FeatureRole | null>(null);
|
||||||
|
const _lastTableParams = useRef<any>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAttributes(request, 'Role')
|
fetchAttributes(request, 'Role')
|
||||||
|
|
@ -94,12 +97,13 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
||||||
|
|
||||||
const [pagination, setPagination] = useState<any>(null);
|
const [pagination, setPagination] = useState<any>(null);
|
||||||
|
|
||||||
// Load roles when feature changes
|
|
||||||
const fetchRoles = useCallback(async (params?: any) => {
|
const fetchRoles = useCallback(async (params?: any) => {
|
||||||
if (!selectedFeatureCode) {
|
if (!selectedFeatureCode) {
|
||||||
setRoles([]);
|
setRoles([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (params) _lastTableParams.current = params;
|
||||||
|
else params = _lastTableParams.current;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -284,7 +288,9 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
||||||
|
|
||||||
if (error && !selectedFeatureCode) {
|
if (error && !selectedFeatureCode) {
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
<StackLayout variant="table">
|
||||||
|
<StackLayout.Body>
|
||||||
|
<Panel variant="card" title={t('Fehler')} id="feature-roles-error">
|
||||||
<div className={styles.errorContainer}>
|
<div className={styles.errorContainer}>
|
||||||
<span className={styles.errorIcon}>⚠️</span>
|
<span className={styles.errorIcon}>⚠️</span>
|
||||||
<p className={styles.errorMessage}>{error}</p>
|
<p className={styles.errorMessage}>{error}</p>
|
||||||
|
|
@ -292,20 +298,23 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
||||||
<FaSync /> {t('Erneut versuchen')}
|
<FaSync /> {t('Erneut versuchen')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
|
</StackLayout.Body>
|
||||||
|
</StackLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
<>
|
||||||
<div className={styles.pageHeader}>
|
<StackLayout variant="table">
|
||||||
|
<StackLayout.Header>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>{t('Feature-Rollen-Rechte')}</h1>
|
<h1 className={styles.pageTitle}>{t('Feature-Rollen-Rechte')}</h1>
|
||||||
<p className={styles.pageSubtitle}>{t('Template-Rollen und deren Berechtigungen für')}</p>
|
<p className={styles.pageSubtitle}>{t('Template-Rollen und deren Berechtigungen für')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</StackLayout.Header>
|
||||||
|
<StackLayout.Body>
|
||||||
{/* Feature Selector */}
|
<Panel variant="toolbar" title={t('Filter')} id="feature-roles-toolbar">
|
||||||
<div className={styles.filterSection}>
|
<div className={styles.filterSection}>
|
||||||
<div className={styles.filterGroup}>
|
<div className={styles.filterGroup}>
|
||||||
<label className={styles.filterLabel}>
|
<label className={styles.filterLabel}>
|
||||||
|
|
@ -347,9 +356,10 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
{/* Info Box */}
|
|
||||||
{selectedFeatureCode && (
|
{selectedFeatureCode && (
|
||||||
|
<Panel variant="card" title={t('Feature-Template-Rollen')} id="feature-roles-info">
|
||||||
<div className={styles.infoBox}>
|
<div className={styles.infoBox}>
|
||||||
<FaUserShield style={{ marginRight: 8 }} />
|
<FaUserShield style={{ marginRight: 8 }} />
|
||||||
<span>
|
<span>
|
||||||
|
|
@ -357,10 +367,11 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
||||||
{t('werden bei der Erstellung neuer Feature-Instanzen automatisch kopiert. Änderungen an Template-Rollen wirken sich nicht auf bestehende Instanzen aus.')}
|
{t('werden bei der Erstellung neuer Feature-Instanzen automatisch kopiert. Änderungen an Template-Rollen wirken sich nicht auf bestehende Instanzen aus.')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
{!selectedFeatureCode ? (
|
{!selectedFeatureCode ? (
|
||||||
|
<Panel variant="card" title={t('Kein Feature ausgewählt')} id="feature-roles-empty">
|
||||||
<div className={styles.emptyState}>
|
<div className={styles.emptyState}>
|
||||||
<FaCube className={styles.emptyIcon} />
|
<FaCube className={styles.emptyIcon} />
|
||||||
<h3 className={styles.emptyTitle}>{t('Kein Feature ausgewählt')}</h3>
|
<h3 className={styles.emptyTitle}>{t('Kein Feature ausgewählt')}</h3>
|
||||||
|
|
@ -368,12 +379,14 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
||||||
{t('Wählen Sie ein Feature aus, um dessen Template-Rollen zu verwalten.')}
|
{t('Wählen Sie ein Feature aus, um dessen Template-Rollen zu verwalten.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.tableContainer}>
|
<Panel variant="table" title={t('Feature-Rollen')} id="feature-roles-table">
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
data={roles}
|
data={roles}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
apiEndpoint="/api/features/templates/roles"
|
apiEndpoint="/api/features/templates/roles"
|
||||||
|
filterScopeKey={selectedFeatureCode || 'admin'}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={true}
|
pagination={true}
|
||||||
pageSize={25}
|
pageSize={25}
|
||||||
|
|
@ -408,8 +421,10 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
||||||
}}
|
}}
|
||||||
emptyMessage={t('Keine Feature-Rollen gefunden')}
|
emptyMessage={t('Keine Feature-Rollen gefunden')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Panel>
|
||||||
)}
|
)}
|
||||||
|
</StackLayout.Body>
|
||||||
|
</StackLayout>
|
||||||
|
|
||||||
{/* Create Role Modal */}
|
{/* Create Role Modal */}
|
||||||
{showCreateModal && (
|
{showCreateModal && (
|
||||||
|
|
@ -511,7 +526,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,14 @@
|
||||||
* Allows creating, viewing, and revoking invitations.
|
* Allows creating, viewing, and revoking invitations.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||||
import { useInvitations, type Invitation, type InvitationCreate } from '../../hooks/useInvitations';
|
import { useInvitations, type Invitation, type InvitationCreate } from '../../hooks/useInvitations';
|
||||||
import { useUserMandates, type Mandate, type Role } from '../../hooks/useUserMandates';
|
import { useUserMandates, type Mandate, type Role } from '../../hooks/useUserMandates';
|
||||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { FaPlus, FaSync, FaBuilding, FaCopy, FaLink } from 'react-icons/fa';
|
import { FaPlus, FaSync, FaBuilding, FaCopy, FaLink } from 'react-icons/fa';
|
||||||
|
import { StackLayout } from '../../components/Layout/StackLayout';
|
||||||
|
import { Panel } from '../../components/Layout/Panel';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
import { useApiRequest } from '../../hooks/useApi';
|
import { useApiRequest } from '../../hooks/useApi';
|
||||||
import { fetchAttributes } from '../../api/attributesApi';
|
import { fetchAttributes } from '../../api/attributesApi';
|
||||||
|
|
@ -51,6 +53,7 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
const [_isSubmitting, setIsSubmitting] = useState(false); // Prefixed with _ to suppress warning
|
const [_isSubmitting, setIsSubmitting] = useState(false); // Prefixed with _ to suppress warning
|
||||||
const [copySuccess, setCopySuccess] = useState(false);
|
const [copySuccess, setCopySuccess] = useState(false);
|
||||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||||
|
const _lastTableParams = useRef<any>(undefined);
|
||||||
|
|
||||||
// Load mandates and attributes on mount
|
// Load mandates and attributes on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -67,13 +70,24 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
.catch(() => setBackendAttributes([]));
|
.catch(() => setBackendAttributes([]));
|
||||||
}, [fetchMandates, request]);
|
}, [fetchMandates, request]);
|
||||||
|
|
||||||
// Load invitations and roles when mandate changes (same roles as AdminUserMandatesPage: user, viewer, admin)
|
const _refetchInvitations = useCallback(async (paginationParams?: any) => {
|
||||||
|
if (!selectedMandateId) return;
|
||||||
|
if (paginationParams && typeof paginationParams === 'object' && !Array.isArray(paginationParams) && typeof paginationParams !== 'string') {
|
||||||
|
_lastTableParams.current = paginationParams;
|
||||||
|
} else {
|
||||||
|
paginationParams = _lastTableParams.current;
|
||||||
|
}
|
||||||
|
if (paginationParams) return fetchInvitations(paginationParams, { includeExpired: showExpired, includeUsed: showUsed });
|
||||||
|
return fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed });
|
||||||
|
}, [selectedMandateId, showExpired, showUsed, fetchInvitations]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedMandateId) {
|
if (selectedMandateId) {
|
||||||
fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed });
|
_lastTableParams.current = undefined;
|
||||||
|
_refetchInvitations();
|
||||||
fetchRoles(selectedMandateId).then(setRoles);
|
fetchRoles(selectedMandateId).then(setRoles);
|
||||||
}
|
}
|
||||||
}, [selectedMandateId, showExpired, showUsed, fetchInvitations, fetchRoles]);
|
}, [selectedMandateId, showExpired, showUsed, _refetchInvitations, fetchRoles]);
|
||||||
|
|
||||||
// Format timestamp (used by URL modal only).
|
// Format timestamp (used by URL modal only).
|
||||||
const formatDate = (timestamp: number) => {
|
const formatDate = (timestamp: number) => {
|
||||||
|
|
@ -163,7 +177,7 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
setShowCreateModal(false);
|
setShowCreateModal(false);
|
||||||
setShowUrlModal(result.data);
|
setShowUrlModal(result.data);
|
||||||
fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed });
|
_refetchInvitations();
|
||||||
} else {
|
} else {
|
||||||
showError(t('Fehler'), result.error || t('Fehler beim Erstellen der Einladung'));
|
showError(t('Fehler'), result.error || t('Fehler beim Erstellen der Einladung'));
|
||||||
}
|
}
|
||||||
|
|
@ -202,7 +216,9 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
|
|
||||||
if (error && !selectedMandateId) {
|
if (error && !selectedMandateId) {
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
<StackLayout variant="table">
|
||||||
|
<StackLayout.Body>
|
||||||
|
<Panel variant="card" title={t('Fehler')} id="invitations-error">
|
||||||
<div className={styles.errorContainer}>
|
<div className={styles.errorContainer}>
|
||||||
<span className={styles.errorIcon}>⚠️</span>
|
<span className={styles.errorIcon}>⚠️</span>
|
||||||
<p className={styles.errorMessage}>
|
<p className={styles.errorMessage}>
|
||||||
|
|
@ -212,20 +228,23 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
<FaSync /> {t('Erneut versuchen')}
|
<FaSync /> {t('Erneut versuchen')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
|
</StackLayout.Body>
|
||||||
|
</StackLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
<>
|
||||||
<div className={styles.pageHeader}>
|
<StackLayout variant="table">
|
||||||
|
<StackLayout.Header>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>{t('Einladungen')}</h1>
|
<h1 className={styles.pageTitle}>{t('Einladungen')}</h1>
|
||||||
<p className={styles.pageSubtitle}>{t('Erstellen und verwalten Sie Einladungen')}</p>
|
<p className={styles.pageSubtitle}>{t('Erstellen und verwalten Sie Einladungen')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</StackLayout.Header>
|
||||||
|
<StackLayout.Body>
|
||||||
{/* Mandate Selector and Filters */}
|
<Panel variant="toolbar" title={t('Filter')} id="invitations-toolbar">
|
||||||
<div className={styles.filterSection}>
|
<div className={styles.filterSection}>
|
||||||
<div className={styles.filterGroup}>
|
<div className={styles.filterGroup}>
|
||||||
<label className={styles.filterLabel}>
|
<label className={styles.filterLabel}>
|
||||||
|
|
@ -269,7 +288,7 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
<div className={styles.headerActions}>
|
<div className={styles.headerActions}>
|
||||||
<button
|
<button
|
||||||
className={styles.secondaryButton}
|
className={styles.secondaryButton}
|
||||||
onClick={() => fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed })}
|
onClick={() => _refetchInvitations()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
||||||
|
|
@ -283,9 +302,10 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
{!selectedMandateId ? (
|
{!selectedMandateId ? (
|
||||||
|
<Panel variant="card" title={t('Kein Mandant ausgewählt')} id="invitations-no-mandate">
|
||||||
<div className={styles.emptyState}>
|
<div className={styles.emptyState}>
|
||||||
<FaBuilding className={styles.emptyIcon} />
|
<FaBuilding className={styles.emptyIcon} />
|
||||||
<h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3>
|
<h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3>
|
||||||
|
|
@ -293,12 +313,14 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
{t('Wählen Sie einen Mandanten aus, um dessen Einladungen zu verwalten.')}
|
{t('Wählen Sie einen Mandanten aus, um dessen Einladungen zu verwalten.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.tableContainer}>
|
<Panel variant="table" title={t('Einladungen')} id="invitations-table">
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
data={invitations}
|
data={invitations}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
apiEndpoint="/api/invitations/"
|
apiEndpoint="/api/invitations/"
|
||||||
|
filterScopeKey={selectedMandateId || 'admin'}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={true}
|
pagination={true}
|
||||||
pageSize={25}
|
pageSize={25}
|
||||||
|
|
@ -322,13 +344,15 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
]}
|
]}
|
||||||
hookData={{
|
hookData={{
|
||||||
handleDelete: handleDeleteInvitation,
|
handleDelete: handleDeleteInvitation,
|
||||||
refetch: (params?: any) => fetchInvitations(params || selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed }),
|
refetch: _refetchInvitations,
|
||||||
pagination,
|
pagination,
|
||||||
}}
|
}}
|
||||||
emptyMessage={t('Keine Einladungen gefunden')}
|
emptyMessage={t('Keine Einladungen gefunden')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Panel>
|
||||||
)}
|
)}
|
||||||
|
</StackLayout.Body>
|
||||||
|
</StackLayout>
|
||||||
|
|
||||||
{/* Create Invitation Modal */}
|
{/* Create Invitation Modal */}
|
||||||
{showCreateModal && (
|
{showCreateModal && (
|
||||||
|
|
@ -429,7 +453,7 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ import type { AttributeDefinition } from '../../api/attributesApi';
|
||||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
import { StackLayout } from '../../components/Layout/StackLayout';
|
||||||
|
import { Panel } from '../../components/Layout/Panel';
|
||||||
|
|
||||||
type LangRow = {
|
type LangRow = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -869,8 +871,9 @@ export const AdminLanguagesPage: React.FC = () => {
|
||||||
const isBusy = progress !== null;
|
const isBusy = progress !== null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`} style={{ gap: '1rem', position: 'relative' }}>
|
<StackLayout variant="table">
|
||||||
<header>
|
<StackLayout.Header>
|
||||||
|
<div>
|
||||||
<h1 className={styles.pageTitle}>{t('UI-Sprachen')}</h1>
|
<h1 className={styles.pageTitle}>{t('UI-Sprachen')}</h1>
|
||||||
<p className={styles.pageSubtitle}>{t('Globale Sprachsets verwalten (SysAdmin).')}</p>
|
<p className={styles.pageSubtitle}>{t('Globale Sprachsets verwalten (SysAdmin).')}</p>
|
||||||
{error && !progress && (
|
{error && !progress && (
|
||||||
|
|
@ -878,8 +881,10 @@ export const AdminLanguagesPage: React.FC = () => {
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</header>
|
</div>
|
||||||
|
</StackLayout.Header>
|
||||||
|
<StackLayout.Body>
|
||||||
|
<Panel variant="toolbar" title={t('Aktionen')} id="languages-toolbar">
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem', alignItems: 'center' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem', alignItems: 'center' }}>
|
||||||
<button type="button" className={styles.secondaryButton} onClick={_load} disabled={isBusy || loading} title={t('Daten neu laden')}>
|
<button type="button" className={styles.secondaryButton} onClick={_load} disabled={isBusy || loading} title={t('Daten neu laden')}>
|
||||||
<FaSync style={loading ? { animation: 'spin 1s linear infinite' } : undefined} />
|
<FaSync style={loading ? { animation: 'spin 1s linear infinite' } : undefined} />
|
||||||
|
|
@ -918,11 +923,13 @@ export const AdminLanguagesPage: React.FC = () => {
|
||||||
{t('Hinzufügen')}
|
{t('Hinzufügen')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
<Panel variant="table" title={t('Sprachen')} id="languages-table" className="" style={{ position: 'relative' }}>
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
data={displayRows}
|
data={displayRows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
filterScopeKey="admin"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
selectable={false}
|
selectable={false}
|
||||||
|
|
@ -961,10 +968,10 @@ export const AdminLanguagesPage: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{progress && <_ProgressOverlay progress={progress} onAbort={_abortRunning} />}
|
{progress && <_ProgressOverlay progress={progress} onAbort={_abortRunning} />}
|
||||||
</div>
|
</Panel>
|
||||||
|
</StackLayout.Body>
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
</div>
|
</StackLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { FaSync, FaDownload } from 'react-icons/fa';
|
import { FaSync, FaDownload } from 'react-icons/fa';
|
||||||
|
import { StackLayout } from '../../components/Layout/StackLayout';
|
||||||
|
import { Panel } from '../../components/Layout/Panel';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
import logStyles from './AdminLogsPage.module.css';
|
import logStyles from './AdminLogsPage.module.css';
|
||||||
|
|
@ -107,8 +109,8 @@ export const AdminLogsPage: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<StackLayout variant="scroll">
|
||||||
<div className={styles.pageHeader}>
|
<StackLayout.Header>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>{t('Gateway-Logs')}</h1>
|
<h1 className={styles.pageTitle}>{t('Gateway-Logs')}</h1>
|
||||||
<p className={styles.pageSubtitle}>
|
<p className={styles.pageSubtitle}>
|
||||||
|
|
@ -126,8 +128,9 @@ export const AdminLogsPage: React.FC = () => {
|
||||||
<FaDownload /> {t('Download')}
|
<FaDownload /> {t('Download')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</StackLayout.Header>
|
||||||
|
<StackLayout.Body>
|
||||||
|
<Panel variant="toolbar" title={t('Filter')} id="logs-toolbar">
|
||||||
<div className={logStyles.controls}>
|
<div className={logStyles.controls}>
|
||||||
<div className={logStyles.loadGroup}>
|
<div className={logStyles.loadGroup}>
|
||||||
<label className={logStyles.controlLabel}>{t('Letzte')}</label>
|
<label className={logStyles.controlLabel}>{t('Letzte')}</label>
|
||||||
|
|
@ -161,8 +164,10 @@ export const AdminLogsPage: React.FC = () => {
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
<Panel variant="card" title={t('Fehler')} id="logs-error">
|
||||||
<div
|
<div
|
||||||
className={styles.infoBox}
|
className={styles.infoBox}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -173,8 +178,10 @@ export const AdminLogsPage: React.FC = () => {
|
||||||
<span style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }}>!</span>
|
<span style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }}>!</span>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Panel variant="editor" title={t('Logs')} id="logs-editor">
|
||||||
<div
|
<div
|
||||||
ref={logContainerRef}
|
ref={logContainerRef}
|
||||||
className={logStyles.logContainer}
|
className={logStyles.logContainer}
|
||||||
|
|
@ -207,7 +214,9 @@ export const AdminLogsPage: React.FC = () => {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
|
</StackLayout.Body>
|
||||||
|
</StackLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ import {
|
||||||
FaExclamationTriangle,
|
FaExclamationTriangle,
|
||||||
FaCheckCircle
|
FaCheckCircle
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
|
import { StackLayout } from '../../components/Layout/StackLayout';
|
||||||
|
import { Panel } from '../../components/Layout/Panel';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
|
@ -225,7 +227,9 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<StackLayout variant="scroll">
|
||||||
|
<StackLayout.Body>
|
||||||
|
<Panel variant="card" title={t('Fehler beim Laden')} id="role-permissions-error">
|
||||||
<div className={styles.errorContainer}>
|
<div className={styles.errorContainer}>
|
||||||
<span className={styles.errorIcon}>⚠️</span>
|
<span className={styles.errorIcon}>⚠️</span>
|
||||||
<p className={styles.errorMessage}>
|
<p className={styles.errorMessage}>
|
||||||
|
|
@ -235,14 +239,16 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
|
||||||
<FaSync /> {t('Erneut versuchen')}
|
<FaSync /> {t('Erneut versuchen')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
|
</StackLayout.Body>
|
||||||
|
</StackLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<>
|
||||||
{/* Header */}
|
<StackLayout variant="scroll">
|
||||||
<div className={styles.pageHeader}>
|
<StackLayout.Header>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>
|
<h1 className={styles.pageTitle}>
|
||||||
<FaShieldAlt style={{ marginRight: '0.5rem' }} />
|
<FaShieldAlt style={{ marginRight: '0.5rem' }} />
|
||||||
|
|
@ -269,9 +275,9 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
|
||||||
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</StackLayout.Header>
|
||||||
|
<StackLayout.Body>
|
||||||
{/* Filters */}
|
<Panel variant="toolbar" title={t('Filter')} id="role-permissions-toolbar">
|
||||||
<div className={styles.filterBar}>
|
<div className={styles.filterBar}>
|
||||||
<div className={styles.filterGroup}>
|
<div className={styles.filterGroup}>
|
||||||
<label className={styles.filterLabel}>{t('Mandant')}</label>
|
<label className={styles.filterLabel}>{t('Mandant')}</label>
|
||||||
|
|
@ -303,8 +309,9 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
{/* Info Box */}
|
<Panel variant="card" title={t('Hinweis')} id="role-permissions-info">
|
||||||
<div className={styles.infoBox}>
|
<div className={styles.infoBox}>
|
||||||
<FaShieldAlt style={{ marginRight: '0.5rem' }} />
|
<FaShieldAlt style={{ marginRight: '0.5rem' }} />
|
||||||
<span>
|
<span>
|
||||||
|
|
@ -314,17 +321,19 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
|
||||||
<strong>{t('Mandanten-Rollen')}</strong> {t('sind direkt bearbeitbar.')}
|
<strong>{t('Mandanten-Rollen')}</strong> {t('sind direkt bearbeitbar.')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
{/* Loading State */}
|
|
||||||
{loading && (
|
{loading && (
|
||||||
|
<Panel variant="card" title={t('Lade Rollen')} id="role-permissions-loading">
|
||||||
<div className={styles.loadingContainer}>
|
<div className={styles.loadingContainer}>
|
||||||
<div className={styles.spinner} />
|
<div className={styles.spinner} />
|
||||||
<span>{t('Lade Rollen')}</span>
|
<span>{t('Lade Rollen')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty State */}
|
|
||||||
{!loading && roles.length === 0 && (
|
{!loading && roles.length === 0 && (
|
||||||
|
<Panel variant="card" title={t('Keine Rollen gefunden')} id="role-permissions-empty">
|
||||||
<div className={styles.emptyState}>
|
<div className={styles.emptyState}>
|
||||||
<FaUserShield className={styles.emptyIcon} />
|
<FaUserShield className={styles.emptyIcon} />
|
||||||
<p>{t('Keine Rollen gefunden')}</p>
|
<p>{t('Keine Rollen gefunden')}</p>
|
||||||
|
|
@ -336,10 +345,11 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
|
||||||
: t('Es gibt noch keine Rollen')}
|
: t('Es gibt noch keine Rollen')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Roles List */}
|
|
||||||
{!loading && roles.length > 0 && (
|
{!loading && roles.length > 0 && (
|
||||||
|
<Panel variant="card" title={t('Rollen')} id="role-permissions-list">
|
||||||
<div className={styles.rolesList}>
|
<div className={styles.rolesList}>
|
||||||
{roles.map(role => (
|
{roles.map(role => (
|
||||||
<div key={role.id} className={styles.roleCard}>
|
<div key={role.id} className={styles.roleCard}>
|
||||||
|
|
@ -387,7 +397,10 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
)}
|
)}
|
||||||
|
</StackLayout.Body>
|
||||||
|
</StackLayout>
|
||||||
|
|
||||||
{/* Cleanup Duplicates Modal */}
|
{/* Cleanup Duplicates Modal */}
|
||||||
{showCleanupModal && (
|
{showCleanupModal && (
|
||||||
|
|
@ -598,7 +611,7 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue