diff --git a/docs/MONETARISIERUNG_FEATURES_INTERAKTIV.md b/docs/MONETARISIERUNG_FEATURES_INTERAKTIV.md index 2c73318..f4ad80d 100644 --- a/docs/MONETARISIERUNG_FEATURES_INTERAKTIV.md +++ b/docs/MONETARISIERUNG_FEATURES_INTERAKTIV.md @@ -75,7 +75,6 @@ Die folgende Tabelle ist die **Checkliste pro Modul**. Pro Zeile: **was** verkau | `trustee` | Treuhand | | | ☐ ja ☐ nein | | | | `realestate` | Immobilien | | | ☐ ja ☐ nein | | | | `chatbot` | Chatbot | | | ☐ ja ☐ nein | | | -| `chatworkflow` | Workflow | | | ☐ ja ☐ nein | | | | `automation` | Automatisierung | | | ☐ ja ☐ nein | | | | `teamsbot` | Teams Bot | | | ☐ ja ☐ nein | | | | `neutralization` | Neutralisierung | | | ☐ ja ☐ nein | | | @@ -144,12 +143,6 @@ Viele **Views** sind Kandidaten für „Basic / Pro“ oder Add-ons (technisch: - [ ] `dashboard` — … - [ ] `instance-roles` (adminOnly) — … -### `chatworkflow` - -- [ ] `dashboard` — … -- [ ] `runs` — … -- [ ] `files` — … - **Paket-Entscheid (freies Feld):** | Paketname | Enthaltene `featureCode`s | Enthaltene Views / Ausnahmen | Limits (Instanzen, Nutzer, Speicher, Credits) | diff --git a/docs/MONETARISIERUNG_KURZ_PRAESENTATION.md b/docs/MONETARISIERUNG_KURZ_PRAESENTATION.md index d8f9415..647e2d1 100644 --- a/docs/MONETARISIERUNG_KURZ_PRAESENTATION.md +++ b/docs/MONETARISIERUNG_KURZ_PRAESENTATION.md @@ -43,7 +43,7 @@ Transparenz: Verbrauch lässt sich nach **Feature**, **Instanz**, **Provider/Mod | Treuhand (`trustee`) | Dokumente, Positionen, Import/Scan, Buchhaltung | | Immobilien (`realestate`) | Karte / Mandantenfähigkeit | | Chatbot (`chatbot`) | Konversationen, Konfiguration | -| Workflow (`chatworkflow`) | Überblicke, Runs, Dateien | +| Workflow-Automation (Systemkomponente) | Workflows, Editor, Durchläufe | | Automatisierung (`automation`) | Definitionen, Vorlagen, Logs | | Teams Bot (`teamsbot`) | Dashboard, Sessions, Settings | | Neutralisierung (`neutralization`) | Playground, Config, Attribute | diff --git a/src/App.tsx b/src/App.tsx index ebbc86c..af4219c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -41,7 +41,7 @@ import { GDPRPage } from './pages/GDPR'; import StorePage from './pages/Store'; import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage'; 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 { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata'; import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing'; @@ -220,6 +220,7 @@ function App() { } /> } /> + } /> } /> diff --git a/src/api.ts b/src/api.ts index 7902246..2ed2077 100644 --- a/src/api.ts +++ b/src/api.ts @@ -106,20 +106,29 @@ 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( (response) => response, async (error) => { + const originalRequest = error.config; + if (error.response?.status === 401) { - // Don't redirect to login if the request was to a login endpoint - const isLoginEndpoint = error.config?.url?.includes('/login') || - error.config?.url?.includes('/api/local/login') || - error.config?.url?.includes('/api/msft/auth/login') || - error.config?.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 isAuthEndpoint = originalRequest?.url?.includes('/login') || + originalRequest?.url?.includes('/api/local/login') || + originalRequest?.url?.includes('/api/local/refresh') || + originalRequest?.url?.includes('/api/msft/auth/login') || + originalRequest?.url?.includes('/api/google/auth/login'); + const pathname = window.location.pathname; - const isOnPublicAuthPage = pathname === '/login' || + const isOnPublicAuthPage = pathname === '/login' || pathname.startsWith('/login') || pathname === '/register' || pathname.startsWith('/register') || @@ -128,22 +137,55 @@ api.interceptors.response.use( pathname === '/password-reset-request' || pathname.startsWith('/password-reset-request') || pathname.startsWith('/invite'); - - if (!isLoginEndpoint && !isOnPublicAuthPage) { - // Clear local auth data (httpOnly cookies are cleared by backend) - sessionStorage.removeItem('auth_authority'); - clearUserDataCache(); - // Redirect to login - window.location.href = '/login'; + + if (isAuthEndpoint || isOnPublicAuthPage) { + 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'); + clearUserDataCache(); + 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); + } + }); + }); + } + } + + // Refresh already failed for this request + sessionStorage.removeItem('auth_authority'); + clearUserDataCache(); + window.location.href = '/login'; } - - // Handle rate limiting (429) - don't throw, just log and return error + + // Handle rate limiting (429) if (error.response?.status === 429) { console.warn('Rate limit exceeded (429). Please wait before making more requests.'); - // Don't cause cascading errors by throwing here } - + return Promise.reject(error); } ); diff --git a/src/api/billingApi.ts b/src/api/billingApi.ts index 52e71a9..0e28aba 100644 --- a/src/api/billingApi.ts +++ b/src/api/billingApi.ts @@ -38,29 +38,6 @@ export interface BillingTransaction { 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; - 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 { id: 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 { - const paginationObj: Record = {}; - 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 { - return await request({ - url: '/api/billing/transactions', - method: 'get', - params: { limit, offset } - }); -} - /** * Fetch usage statistics for an explicit date range. * 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 { userId?: string; userName?: string; } - -/** - * Fetch user-level balances (RBAC-based) - * Endpoint: GET /api/billing/view/users/balances - */ -export async function fetchUserViewBalances( - request: ApiRequestFunction -): Promise { - 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 { - return await request({ - url: '/api/billing/view/users/transactions', - method: 'get', - params: { limit } - }); -} diff --git a/src/api/featuresApi.ts b/src/api/featuresApi.ts index 2bf59f3..dc96c91 100644 --- a/src/api/featuresApi.ts +++ b/src/api/featuresApi.ts @@ -11,7 +11,6 @@ import api from '../api'; import type { FeaturesMyResponse, Mandate, - MandateFeature, FeatureInstance, InstancePermissions, 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 = { 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 { } } -/** - * Lädt die verfügbaren Features (für Admin - Feature-Instanz erstellen) - * - * Endpoint: GET /api/features/available - */ -export async function fetchAvailableFeatures(): Promise { - if (USE_MOCK) { - return [ - { code: 'trustee', label: 'Treuhand', icon: 'briefcase', instances: [] }, - { code: 'chatworkflow', label: 'Workflow', icon: 'play_circle', instances: [] }, - ]; - } - - const response = await api.get('/api/features/available'); - return response.data; -} - // ============================================================================= // TYPE GUARDS // ============================================================================= diff --git a/src/components/FlowEditor/editor/CanvasHeader.tsx b/src/components/FlowEditor/editor/CanvasHeader.tsx index 5046036..41b79da 100644 --- a/src/components/FlowEditor/editor/CanvasHeader.tsx +++ b/src/components/FlowEditor/editor/CanvasHeader.tsx @@ -5,6 +5,7 @@ */ import React, { useState, useRef, useEffect, useMemo } from 'react'; +import { FloatingPortal } from '../../UiComponents/FloatingPortal'; import { FaPlay, FaSpinner, @@ -146,13 +147,13 @@ export const CanvasHeader: React.FC = ({ const badge = statusBadge[currentStatus] || statusBadge.draft; const [newMenuOpen, setNewMenuOpen] = useState(false); - const newMenuRef = useRef(null); + const newMenuAnchorRef = useRef(null); const [templateMenuOpen, setTemplateMenuOpen] = useState(false); - const templateMenuRef = useRef(null); + const templateMenuAnchorRef = useRef(null); const [zoomMenuOpen, setZoomMenuOpen] = useState(false); - const zoomMenuRef = useRef(null); + const zoomMenuAnchorRef = useRef(null); const [zoomInputDraft, setZoomInputDraft] = useState(''); useEffect(() => { @@ -160,16 +161,6 @@ export const CanvasHeader: React.FC = ({ if (zp !== undefined) setZoomInputDraft(String(zp)); }, [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( () => ({ @@ -237,7 +228,7 @@ export const CanvasHeader: React.FC = ({ aria-label={_panelOpen ? t('Workspace-Panel ausblenden') : t('Workspace-Panel öffnen')} /> )} -
+
)}
- {newMenuOpen && onNewFromTemplate && ( -
- -
+ {onNewFromTemplate && ( + setNewMenuOpen(false)} + placement="bottom" + > +
+ +
+
)}
= ({ - {zoomMenuOpen && ( + setZoomMenuOpen(false)} + placement="bottom" + align="end" + >
))}
- )} +
- {groupMenuOpen && ( + setGroupMenuOpen(false)} + placement="bottom" + >
{t('Gruppieren nach')}

{t('Wählen Sie eine Spalte und die Reihenfolge der Gruppen.')}

@@ -256,7 +255,7 @@ export function TableViewsBar({ {t('+ Weitere Ebene')}
- )} +
diff --git a/src/components/Layout/LayoutTabs.module.css b/src/components/Layout/LayoutTabs.module.css new file mode 100644 index 0000000..b7ea009 --- /dev/null +++ b/src/components/Layout/LayoutTabs.module.css @@ -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; + } +} diff --git a/src/components/Layout/LayoutTabs.tsx b/src/components/Layout/LayoutTabs.tsx new file mode 100644 index 0000000..8e03fff --- /dev/null +++ b/src/components/Layout/LayoutTabs.tsx @@ -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 | 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>( + () => new Set([_initialTab]), + ); + + // Collapse state for the tab bar + const [tabBarCollapsed, setTabBarCollapsed] = useState(() => { + if (!collapsible) return false; + if (collapseKey) { + const stored = _collapsePersistence.load(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>(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) => { + 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 ? ( + + ) : null; + + return ( +
+
+ {!showCollapsed ? ( +
+ {groups.map((group, gi) => ( +
+ {gi > 0 && !hasGroups && ( +
+ )} + {group.label && ( + + {group.label} + + )} + {group.items.map((item) => { + const isActive = item.id === activeId; + const tabId = `tab-${item.id}`; + return ( + + ); + })} +
+ ))} +
+ ) : ( + {activeItem?.label} + )} + {_toggleButton} +
+ +
+ {lazy + ? items.map((item) => + mountedIds.has(item.id) ? ( +
+ {item.render()} +
+ ) : null, + ) + : activeItem?.render()} +
+
+ ); +} + +export default LayoutTabs; diff --git a/src/components/Layout/Panel.module.css b/src/components/Layout/Panel.module.css new file mode 100644 index 0000000..d428a07 --- /dev/null +++ b/src/components/Layout/Panel.module.css @@ -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)); +} diff --git a/src/components/Layout/Panel.tsx b/src/components/Layout/Panel.tsx new file mode 100644 index 0000000..73e7c83 --- /dev/null +++ b/src/components/Layout/Panel.tsx @@ -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 = ({ + 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 ( +
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + _toggleCollapsed(); + } + } + : undefined + } + > +
+ {title} + {subtitle && {subtitle}} +
+ {collapsible && ( + + {collapsed ? : } + + )} + {actions &&
e.stopPropagation()}>{actions}
} +
+
+ {children} +
+
+ ); +}; diff --git a/src/components/Layout/PanelLayout.module.css b/src/components/Layout/PanelLayout.module.css new file mode 100644 index 0000000..fbe5325 --- /dev/null +++ b/src/components/Layout/PanelLayout.module.css @@ -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); +} diff --git a/src/components/Layout/PanelLayout.tsx b/src/components/Layout/PanelLayout.tsx new file mode 100644 index 0000000..a25c965 --- /dev/null +++ b/src/components/Layout/PanelLayout.tsx @@ -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 = ({ + persistenceKey, + direction = 'horizontal', + panes, + className = '', +}) => { + const { t } = useLanguage(); + const containerRef = useRef(null); + const [sizes, setSizes] = useState(() => _loadSizes(persistenceKey, panes)); + const [collapsedById, setCollapsedById] = useState>(() => { + const initial: Record = {}; + for (const pane of panes) { + initial[pane.id] = _loadCollapsed(pane.collapseKey, pane.defaultCollapsed ?? false); + } + return initial; + }); + const [draggingIndex, setDraggingIndex] = useState(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 ( +
+ {panes.map((pane, index) => ( + _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)} + /> + ))} +
+ ); +}; + +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 = ({ + pane, + style, + collapsed, + onToggleCollapse, + collapseLabel, + expandLabel, + direction, + showDivider, + dividerClass, + onDividerMouseDown, +}) => { + const _collapseIcon = direction === 'horizontal' + ? (collapsed ? : ) + : (collapsed ? : ); + + return ( + <> +
+ {pane.collapsible && ( + + )} +
+ {pane.content} +
+
+ {showDivider && ( +
+ )} + + ); +}; + +export default PanelLayout; diff --git a/src/components/Layout/StackLayout.module.css b/src/components/Layout/StackLayout.module.css new file mode 100644 index 0000000..c261ebb --- /dev/null +++ b/src/components/Layout/StackLayout.module.css @@ -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; + } +} diff --git a/src/components/Layout/StackLayout.tsx b/src/components/Layout/StackLayout.tsx new file mode 100644 index 0000000..545d950 --- /dev/null +++ b/src/components/Layout/StackLayout.tsx @@ -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 = ({ className = '', children }) => ( +
{children}
+); + +const Toolbar: FC = ({ className = '', children }) => ( +
{children}
+); + +const Tabs: FC = ({ className = '', children }) => ( +
{children}
+); + +const Body: FC = ({ className = '', children }) => ( +
{children}
+); + +const Footer: FC = ({ className = '', children }) => ( +
{children}
+); + +// --------------------------------------------------------------------------- +// Variant → CSS class mapping +// --------------------------------------------------------------------------- + +const _variantBodyClass: Record = { + table: styles.bodyTable, + scroll: styles.bodyScroll, + form: styles.bodyForm, + dashboard: styles.bodyDashboard, +}; + +// --------------------------------------------------------------------------- +// Root component +// --------------------------------------------------------------------------- + +interface StackLayoutComponent extends FC { + Header: FC; + Toolbar: FC; + Tabs: FC; + Body: FC; + Footer: FC; +} + +const _StackLayoutRoot: FC = ({ + variant = 'scroll', + className = '', + children, +}) => { + const scrollMode = useScrollMode(); + const rootRef = useRef(null); + useScrollRestoration(rootRef); + + return ( +
+ {_processChildren(children, variant)} +
+ ); +}; + +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, { + 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; diff --git a/src/components/Layout/ViewStack.module.css b/src/components/Layout/ViewStack.module.css new file mode 100644 index 0000000..136e14d --- /dev/null +++ b/src/components/Layout/ViewStack.module.css @@ -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; + } +} diff --git a/src/components/Layout/ViewStack.tsx b/src/components/Layout/ViewStack.tsx new file mode 100644 index 0000000..da7332a --- /dev/null +++ b/src/components/Layout/ViewStack.tsx @@ -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(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 | null { + let match: ReactElement | null = null; + + React.Children.forEach(children, (child) => { + if (!React.isValidElement(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 ( +
+ {activeView === 'detail' && ( +
+ + {title &&

{title}

} + {actions &&
{actions}
} +
+ )} +
+ {activeChild} +
+
+ ); +} + +ViewStack.View = View; + +export default ViewStack; diff --git a/src/components/Layout/index.ts b/src/components/Layout/index.ts new file mode 100644 index 0000000..351cf40 --- /dev/null +++ b/src/components/Layout/index.ts @@ -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'; diff --git a/src/components/Layout/persistence.ts b/src/components/Layout/persistence.ts new file mode 100644 index 0000000..b6a3db5 --- /dev/null +++ b/src/components/Layout/persistence.ts @@ -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(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(key: string, value: T): void { + try { + localStorage.setItem(_buildKey(scope, key), JSON.stringify(value)); + } catch { + // localStorage full or unavailable — silently ignore for UI preferences + } + }, + }; +} diff --git a/src/components/Layout/types.ts b/src/components/Layout/types.ts new file mode 100644 index 0000000..b3fcba6 --- /dev/null +++ b/src/components/Layout/types.ts @@ -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; + 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: (key: string) => T | null; + save: (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; +} diff --git a/src/components/Navigation/MandateNavigation.tsx b/src/components/Navigation/MandateNavigation.tsx index b7083c8..cf9f1c8 100644 --- a/src/components/Navigation/MandateNavigation.tsx +++ b/src/components/Navigation/MandateNavigation.tsx @@ -39,6 +39,7 @@ import { usePrompt } from '../../hooks/usePrompt'; import { useToast } from '../../contexts/ToastContext'; import api from '../../api'; import { useLanguage } from '../../providers/language/LanguageContext'; +import { useSidebar } from '../../layouts/SidebarContext'; import styles from './MandateNavigation.module.css'; type NavTranslateFn = (key: string, params?: Record) => string; @@ -210,6 +211,7 @@ const EmptyState: React.FC = () => { export const MandateNavigation: React.FC = () => { const { t } = useLanguage(); + const { collapsed } = useSidebar(); const { blocks, loading, refresh } = useNavigation(); const { prompt, PromptDialog } = usePrompt(); const { showWarning } = useToast(); @@ -332,6 +334,7 @@ export const MandateNavigation: React.FC = () => { ) : ( diff --git a/src/components/Navigation/TreeNavigation/TreeNavigation.module.css b/src/components/Navigation/TreeNavigation/TreeNavigation.module.css index 4eaf04a..3563475 100644 --- a/src/components/Navigation/TreeNavigation/TreeNavigation.module.css +++ b/src/components/Navigation/TreeNavigation/TreeNavigation.module.css @@ -345,3 +345,82 @@ background: var(--primary-color, #2563eb); 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); +} diff --git a/src/components/Navigation/TreeNavigation/TreeNavigation.tsx b/src/components/Navigation/TreeNavigation/TreeNavigation.tsx index 2eff3a9..a2689ab 100644 --- a/src/components/Navigation/TreeNavigation/TreeNavigation.tsx +++ b/src/components/Navigation/TreeNavigation/TreeNavigation.tsx @@ -76,6 +76,8 @@ export interface TreeNavigationProps { items: TreeItem[]; /** Whether to auto-expand nodes when their path is active */ autoExpandActive?: boolean; + /** Icon-only rail mode for collapsed sidebar */ + collapsed?: boolean; /** Callback when a node is clicked */ onNodeClick?: (node: TreeNodeItem) => void; /** Maximum depth to render (0 = unlimited) */ @@ -122,6 +124,34 @@ function isTreeSeparator(item: TreeItem): item is TreeSeparatorItem { 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 // ============================================================================= @@ -344,6 +374,45 @@ const TreeSection: React.FC = ({ ); }; +// ============================================================================= +// COLLAPSED ICON RAIL +// ============================================================================= + +interface CollapsedNavItemProps { + node: TreeNodeItem; + currentPath: string; + onNodeClick?: (node: TreeNodeItem) => void; +} + +const CollapsedNavItem: React.FC = ({ 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 ( + + {node.icon ? ( + {node.icon} + ) : ( + {letterFallback} + )} + + ); +}; + // ============================================================================= // MAIN COMPONENT // ============================================================================= @@ -351,6 +420,7 @@ const TreeSection: React.FC = ({ export const TreeNavigation: React.FC = ({ items, autoExpandActive = true, + collapsed = false, onNodeClick, maxDepth = 0, className = '', @@ -358,6 +428,22 @@ export const TreeNavigation: React.FC = ({ const location = useLocation(); const currentPath = location.pathname; + if (collapsed) { + const navLinks = _collectNavLinks(items); + return ( + + ); + } + return (