From d579df1c92c8b5c71cced657e051ef98dd2368f2 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Thu, 11 Jun 2026 16:43:53 +0200 Subject: [PATCH] panel ui --- docs/MONETARISIERUNG_FEATURES_INTERAKTIV.md | 7 - docs/MONETARISIERUNG_KURZ_PRAESENTATION.md | 2 +- src/api/featuresApi.ts | 29 - .../FlowEditor/editor/CanvasHeader.tsx | 104 +- .../editor/WorkflowFlowEditor.module.css | 5 - .../FormGeneratorTable.module.css | 5 - .../FormGeneratorTable/FormGeneratorTable.tsx | 132 +-- .../FormGeneratorTree.module.css | 8 + .../FormGeneratorTree/FormGeneratorTree.tsx | 1 + .../TableViewsBar/TableViewsBar.module.css | 4 - .../TableViewsBar/TableViewsBar.tsx | 25 +- src/components/Layout/LayoutTabs.module.css | 20 + src/components/Layout/LayoutTabs.tsx | 17 +- src/components/Layout/Panel.module.css | 21 +- src/components/Layout/Panel.tsx | 2 + src/components/Layout/PanelLayout.module.css | 98 ++ src/components/Layout/PanelLayout.tsx | 298 ++++++ src/components/Layout/StackLayout.module.css | 16 +- src/components/Layout/StackLayout.tsx | 6 +- src/components/Layout/ViewStack.tsx | 100 +- src/components/Layout/index.ts | 1 + src/components/Layout/types.ts | 41 + .../Navigation/MandateNavigation.tsx | 3 + .../TreeNavigation/TreeNavigation.module.css | 79 ++ .../TreeNavigation/TreeNavigation.tsx | 86 ++ .../Navigation/UserSection.module.css | 28 +- src/components/Navigation/UserSection.tsx | 53 +- .../NotificationBell.module.css | 16 +- .../NotificationBell/NotificationBell.tsx | 102 +- .../PeriodPicker/PeriodPicker.module.css | 9 - src/components/PeriodPicker/PeriodPicker.tsx | 28 +- .../PeriodPicker/PeriodPickerPopover.tsx | 35 +- .../ProviderSelector.module.css | 9 +- .../ProviderSelector/ProviderSelector.tsx | 31 +- .../RagRunningBadge.module.css | 4 - .../RagRunningBadge/RagRunningBadge.tsx | 13 +- .../AddressAutocomplete.module.css | 7 +- .../AddressAutocomplete.tsx | 34 +- .../DropdownSelect/DropdownSelect.module.css | 7 +- .../DropdownSelect/DropdownSelect.tsx | 43 +- .../FloatingPortal/FloatingPortal.module.css | 5 + .../FloatingPortal/FloatingPortal.tsx | 168 +++ .../UiComponents/FloatingPortal/index.ts | 4 + .../UiComponents/Tabs/Tabs.module.css | 50 - src/components/UiComponents/Tabs/Tabs.tsx | 61 -- src/components/UiComponents/Tabs/index.ts | 5 - src/components/UiComponents/index.ts | 3 +- .../UnifiedDataBar/ChatsTab.module.css | 2 + src/components/UnifiedDataBar/ChatsTab.tsx | 4 +- .../UnifiedDataBar/FilesTab.module.css | 2 + .../UnifiedDataBar/UnifiedDataBar.module.css | 3 + .../UnifiedDataBar/UnifiedDataBar.tsx | 2 +- src/components/UnifiedDataBar/index.ts | 1 + src/config/keepAliveRoutes.tsx | 15 + src/config/pageRegistry.tsx | 1 - src/hooks/useDocumentTitle.ts | 37 + src/hooks/useNavigation.ts | 125 ++- src/hooks/useScrollRestoration.ts | 85 ++ src/hooks/useVisibilityRemeasure.ts | 55 + src/hooks/useWorkflows.ts | 708 ------------- src/layouts/MainLayout.module.css | 87 +- src/layouts/MainLayout.tsx | 291 ++++- src/layouts/SidebarContext.tsx | 14 + src/pages/ComplianceAuditPage.tsx | 808 +++++++------- src/pages/Dashboard.tsx | 109 +- src/pages/FeatureView.module.css | 41 +- src/pages/FeatureView.tsx | 42 +- src/pages/GDPR.tsx | 58 +- src/pages/IntegrationsOverviewPage.tsx | 38 +- src/pages/InvitePage.tsx | 2 + src/pages/Login.tsx | 4 +- src/pages/PasswordResetRequest.tsx | 5 +- src/pages/RagInventoryPage.module.css | 10 - src/pages/RagInventoryPage.tsx | 187 ++-- src/pages/Register.tsx | 4 +- src/pages/Reset.tsx | 8 +- src/pages/Settings.tsx | 371 ++++--- src/pages/Store.tsx | 138 +-- src/pages/admin/AccessManagementHub.tsx | 49 +- src/pages/admin/Admin.module.css | 74 -- src/pages/admin/AdminDatabaseHealthPage.tsx | 88 +- src/pages/admin/AdminDemoConfigPage.tsx | 137 +-- src/pages/admin/AdminFeatureAccessPage.tsx | 61 +- .../admin/AdminFeatureInstanceUsersPage.tsx | 73 +- src/pages/admin/AdminFeatureRolesPage.tsx | 179 ++-- src/pages/admin/AdminInvitationsPage.tsx | 192 ++-- src/pages/admin/AdminLanguagesPage.tsx | 123 ++- src/pages/admin/AdminLogsPage.tsx | 175 +-- .../admin/AdminMandateRolePermissionsPage.tsx | 67 +- src/pages/admin/AdminMandateRolesPage.tsx | 207 ++-- src/pages/admin/AdminMandatesPage.tsx | 94 +- .../admin/AdminUserAccessOverviewPage.tsx | 274 +++-- src/pages/admin/AdminUserMandatesPage.tsx | 158 +-- src/pages/admin/AdminUsersPage.tsx | 108 +- src/pages/admin/InstanceDetailModal.tsx | 15 +- src/pages/admin/InstanceHierarchyView.tsx | 15 +- src/pages/admin/PermissionMatrix.tsx | 43 +- .../wizards/AdminInvitationWizardPage.tsx | 20 +- .../admin/wizards/AdminMandateWizardPage.tsx | 22 +- .../admin/wizards/FeatureInstanceWizard.tsx | 5 + src/pages/basedata/BasedataPages.module.css | 364 ------- src/pages/basedata/ConnectionsPage.tsx | 128 ++- src/pages/basedata/FilesPage.module.css | 80 ++ src/pages/basedata/FilesPage.tsx | 520 +++++---- src/pages/basedata/PromptsPage.tsx | 72 +- src/pages/billing/AdminSubscriptionsPage.tsx | 57 +- src/pages/billing/Billing.module.css | 100 +- src/pages/billing/BillingAdmin.tsx | 234 +++-- src/pages/billing/BillingDashboard.tsx | 272 ----- src/pages/billing/BillingDataView.tsx | 278 +++-- src/pages/billing/BillingMandateView.tsx | 99 +- src/pages/billing/BillingNav.tsx | 66 +- src/pages/billing/BillingTransactions.tsx | 183 ---- src/pages/billing/BillingUserView.tsx | 384 ------- src/pages/billing/SubscriptionTab.tsx | 45 +- src/pages/billing/index.ts | 5 - .../views/commcoach/Commcoach.module.css | 8 + .../commcoach/CommcoachAssistantView.tsx | 63 +- .../commcoach/CommcoachDashboardView.tsx | 190 ++-- .../commcoach/CommcoachDossierView.module.css | 880 ---------------- .../views/commcoach/CommcoachDossierView.tsx | 992 ------------------ .../views/commcoach/CommcoachModulesView.tsx | 70 +- .../commcoach/CommcoachSessionView.module.css | 380 +++++++ .../views/commcoach/CommcoachSessionView.tsx | 247 ++--- .../views/commcoach/CommcoachSettingsView.tsx | 337 +++--- .../neutralization/NeutralizationView.tsx | 390 +++---- .../realestate/RealEstateDashboardView.tsx | 88 -- .../RealEstateInstanceRolesPlaceholder.tsx | 47 +- .../realestate/RealEstateParcelsView.tsx | 255 ----- .../views/realestate/RealEstatePekView.tsx | 21 +- .../realestate/RealEstateProjectsView.tsx | 214 ---- src/pages/views/realestate/index.ts | 1 - .../views/realestate/pek/PekLocationInput.tsx | 96 +- src/pages/views/realestate/pek/PekMapView.tsx | 17 +- .../views/redmine/RedmineBrowserView.tsx | 232 ++-- .../views/redmine/RedmineSettingsView.tsx | 317 +++--- src/pages/views/redmine/RedmineStatsView.tsx | 68 +- .../views/redmine/RedmineTicketEditor.tsx | 267 ++--- .../views/redmine/RedmineViews.module.css | 15 +- .../views/solutions/SolutionsView.module.css | 30 - src/pages/views/solutions/SolutionsView.tsx | 128 --- src/pages/views/teamsbot/Teamsbot.module.css | 12 +- .../views/teamsbot/TeamsbotAssistantView.tsx | 77 +- .../views/teamsbot/TeamsbotDashboardView.tsx | 132 +-- .../views/teamsbot/TeamsbotModulesView.tsx | 62 +- .../views/teamsbot/TeamsbotSessionView.tsx | 301 +++--- .../views/teamsbot/TeamsbotSettingsView.tsx | 598 ++++++----- .../views/trustee/TrusteeAbschlussView.tsx | 240 ++--- .../trustee/TrusteeAccountingSettingsView.tsx | 371 +++---- .../views/trustee/TrusteeAnalyseView.tsx | 493 ++++----- .../views/trustee/TrusteeDashboardView.tsx | 193 ++-- .../views/trustee/TrusteeDocumentsView.tsx | 169 ++- .../trustee/TrusteeExpenseImportView.tsx | 61 +- .../trustee/TrusteeImportProcessView.tsx | 130 +-- .../trustee/TrusteeInstanceRolesView.tsx | 222 ++-- .../trustee/TrusteePositionDocumentsView.tsx | 280 ----- .../views/trustee/TrusteePositionsView.tsx | 223 ++-- .../views/trustee/TrusteeScanUploadView.tsx | 153 +-- src/pages/views/trustee/components/index.ts | 9 - .../trustee/dataTables/TrusteeDataTab.tsx | 72 +- .../workflowAutomation/WorkflowEditorPage.tsx | 24 +- .../WorkflowTemplatesPage.tsx | 59 +- src/pages/views/workspace/ChatStream.tsx | 11 +- src/pages/views/workspace/FilePreview.tsx | 69 +- .../views/workspace/NeutralizationPanel.tsx | 213 ++-- src/pages/views/workspace/ToolActivityLog.tsx | 11 +- .../workspace/WorkspaceContextSidebar.tsx | 145 +++ .../workspace/WorkspaceEditorPage.module.css | 79 ++ .../views/workspace/WorkspaceEditorPage.tsx | 228 ++-- .../workspace/WorkspaceGeneralSettings.tsx | 29 +- src/pages/views/workspace/WorkspaceInput.tsx | 46 +- .../views/workspace/WorkspacePage.module.css | 496 +++++++++ src/pages/views/workspace/WorkspacePage.tsx | 694 ++++++------ .../views/workspace/WorkspaceSettingsPage.tsx | 14 +- .../workflowAutomation/tabs/EditorTab.tsx | 14 +- .../workflowAutomation/tabs/RunDetailTab.tsx | 285 ++--- src/pages/workflowAutomation/tabs/RunsTab.tsx | 5 +- .../workflowAutomation/tabs/TasksTab.tsx | 167 ++- .../workflowAutomation/tabs/TemplatesTab.tsx | 1 + .../workflowAutomation/tabs/WorkflowsTab.tsx | 6 +- src/types/mandate.ts | 4 +- src/utils/settingsSchemaToFormAttributes.ts | 112 -- src/utils/tableFilterPersistence.ts | 45 + work-around/pek.ts | 126 --- work-around/pek/PekLocationInput.module.css | 64 -- work-around/pek/PekLocationInput.tsx | 89 -- work-around/pek/PekMapView.tsx | 61 -- work-around/pek/PekPageWrapper.tsx | 20 - work-around/pek/pek-tables.ts | 210 ---- 189 files changed, 10068 insertions(+), 12558 deletions(-) create mode 100644 src/components/Layout/PanelLayout.module.css create mode 100644 src/components/Layout/PanelLayout.tsx create mode 100644 src/components/UiComponents/FloatingPortal/FloatingPortal.module.css create mode 100644 src/components/UiComponents/FloatingPortal/FloatingPortal.tsx create mode 100644 src/components/UiComponents/FloatingPortal/index.ts delete mode 100644 src/components/UiComponents/Tabs/Tabs.module.css delete mode 100644 src/components/UiComponents/Tabs/Tabs.tsx delete mode 100644 src/components/UiComponents/Tabs/index.ts create mode 100644 src/hooks/useDocumentTitle.ts create mode 100644 src/hooks/useScrollRestoration.ts create mode 100644 src/hooks/useVisibilityRemeasure.ts delete mode 100644 src/hooks/useWorkflows.ts create mode 100644 src/layouts/SidebarContext.tsx delete mode 100644 src/pages/basedata/BasedataPages.module.css create mode 100644 src/pages/basedata/FilesPage.module.css delete mode 100644 src/pages/billing/BillingDashboard.tsx delete mode 100644 src/pages/billing/BillingTransactions.tsx delete mode 100644 src/pages/billing/BillingUserView.tsx delete mode 100644 src/pages/views/commcoach/CommcoachDossierView.module.css delete mode 100644 src/pages/views/commcoach/CommcoachDossierView.tsx create mode 100644 src/pages/views/commcoach/CommcoachSessionView.module.css delete mode 100644 src/pages/views/realestate/RealEstateDashboardView.tsx delete mode 100644 src/pages/views/realestate/RealEstateParcelsView.tsx delete mode 100644 src/pages/views/realestate/RealEstateProjectsView.tsx delete mode 100644 src/pages/views/solutions/SolutionsView.module.css delete mode 100644 src/pages/views/solutions/SolutionsView.tsx delete mode 100644 src/pages/views/trustee/TrusteePositionDocumentsView.tsx delete mode 100644 src/pages/views/trustee/components/index.ts create mode 100644 src/pages/views/workspace/WorkspaceContextSidebar.tsx create mode 100644 src/pages/views/workspace/WorkspaceEditorPage.module.css create mode 100644 src/pages/views/workspace/WorkspacePage.module.css delete mode 100644 src/utils/settingsSchemaToFormAttributes.ts create mode 100644 src/utils/tableFilterPersistence.ts delete mode 100644 work-around/pek.ts delete mode 100644 work-around/pek/PekLocationInput.module.css delete mode 100644 work-around/pek/PekLocationInput.tsx delete mode 100644 work-around/pek/PekMapView.tsx delete mode 100644 work-around/pek/PekPageWrapper.tsx delete mode 100644 work-around/pek/pek-tables.ts 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/api/featuresApi.ts b/src/api/featuresApi.ts index 2bf59f3..ba368ea 100644 --- a/src/api/featuresApi.ts +++ b/src/api/featuresApi.ts @@ -56,18 +56,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 +89,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, - }, - ], - }, ], }, { @@ -193,7 +165,6 @@ export async function fetchAvailableFeatures(): Promise { if (USE_MOCK) { return [ { code: 'trustee', label: 'Treuhand', icon: 'briefcase', instances: [] }, - { code: 'chatworkflow', label: 'Workflow', icon: 'play_circle', instances: [] }, ]; } 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 index 9455697..b7ea009 100644 --- a/src/components/Layout/LayoutTabs.module.css +++ b/src/components/Layout/LayoutTabs.module.css @@ -219,6 +219,26 @@ 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 { diff --git a/src/components/Layout/LayoutTabs.tsx b/src/components/Layout/LayoutTabs.tsx index 5db3edc..8e03fff 100644 --- a/src/components/Layout/LayoutTabs.tsx +++ b/src/components/Layout/LayoutTabs.tsx @@ -72,6 +72,7 @@ export function LayoutTabs({ collapsible = false, collapseKey, defaultCollapsed = false, + fill = true, }: LayoutTabsProps) { const shouldSyncUrl = syncUrl ?? !!urlParam; const [searchParams, setSearchParams] = useSearchParams(); @@ -222,7 +223,7 @@ export function LayoutTabs({ ) : null; return ( -
+
diff --git a/src/components/Layout/Panel.module.css b/src/components/Layout/Panel.module.css index be8208b..5b9fc84 100644 --- a/src/components/Layout/Panel.module.css +++ b/src/components/Layout/Panel.module.css @@ -4,7 +4,7 @@ border: 1px solid var(--border-color, rgba(0, 0, 0, 0.12)); border-radius: 8px; background: var(--bg-primary, #fff); - overflow: hidden; + overflow: clip; } /* --- Variant: table — fills available height, bounded scroll --- */ @@ -43,12 +43,30 @@ 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 { @@ -57,6 +75,7 @@ padding: 0; display: flex; flex-direction: column; + overflow: visible; } /* --- Variant: wizard — step container --- */ diff --git a/src/components/Layout/Panel.tsx b/src/components/Layout/Panel.tsx index 5054bcd..2d2c3f2 100644 --- a/src/components/Layout/Panel.tsx +++ b/src/components/Layout/Panel.tsx @@ -29,6 +29,7 @@ export const Panel: FC = ({ defaultCollapsed = false, collapseKey, className = '', + fill = false, children, }) => { const [collapsed, setCollapsed] = useState(() => _loadCollapsed(collapseKey, defaultCollapsed)); @@ -47,6 +48,7 @@ export const Panel: FC = ({
{hasHeader && (
* { + 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 index 3cf09bd..c261ebb 100644 --- a/src/components/Layout/StackLayout.module.css +++ b/src/components/Layout/StackLayout.module.css @@ -51,11 +51,25 @@ 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 { - overflow-y: auto; + 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; } /* ------------------------------------------------------------------ */ diff --git a/src/components/Layout/StackLayout.tsx b/src/components/Layout/StackLayout.tsx index 34519de..545d950 100644 --- a/src/components/Layout/StackLayout.tsx +++ b/src/components/Layout/StackLayout.tsx @@ -1,8 +1,9 @@ // Copyright (c) 2026 PowerOn AG // All rights reserved. -import React, { type FC, type ReactNode, Children, isValidElement, cloneElement } from 'react'; +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'; // --------------------------------------------------------------------------- @@ -63,9 +64,12 @@ const _StackLayoutRoot: FC = ({ children, }) => { const scrollMode = useScrollMode(); + const rootRef = useRef(null); + useScrollRestoration(rootRef); return (
{ + if (!React.isValidElement(child)) return; + ids.push(child.props.id); + }); + return ids; +} + function _resolveActiveView( searchParams: URLSearchParams, viewParam: string, entityParam: string | undefined, - defaultView: ViewMode -): ViewMode { + defaultView: ViewMode, + registeredViews: ViewMode[], +): ViewResolution { const rawView = searchParams.get(viewParam) as ViewMode | null; const entityId = entityParam ? searchParams.get(entityParam) : null; - let resolved: ViewMode = rawView ?? defaultView; + let sanitized = false; - if (entityId && resolved === 'list') { + 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' && !entityId) { + 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 resolved; + return { activeView: resolved, sanitized }; } function _findActiveChild( children: React.ReactNode, - activeView: ViewMode + activeView: ViewMode, ): ReactElement | null { let match: ReactElement | null = null; @@ -48,7 +86,7 @@ function _buildBackParams( searchParams: URLSearchParams, viewParam: string, entityParam: string | undefined, - defaultView: ViewMode + defaultView: ViewMode, ): URLSearchParams { const next = new URLSearchParams(searchParams); @@ -65,6 +103,23 @@ function _buildBackParams( 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}; } @@ -76,8 +131,33 @@ function ViewStack({ 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 activeView = _resolveActiveView(searchParams, viewParam, entityParam, defaultView); const activeChild = _findActiveChild(children, activeView); if (!activeChild) return null; diff --git a/src/components/Layout/index.ts b/src/components/Layout/index.ts index 47185e8..351cf40 100644 --- a/src/components/Layout/index.ts +++ b/src/components/Layout/index.ts @@ -25,3 +25,4 @@ 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/types.ts b/src/components/Layout/types.ts index fbc575f..92a7ff4 100644 --- a/src/components/Layout/types.ts +++ b/src/components/Layout/types.ts @@ -41,6 +41,14 @@ export interface LayoutTabsProps { 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; } // --------------------------------------------------------------------------- @@ -80,6 +88,12 @@ export interface PanelProps { defaultCollapsed?: boolean; collapseKey?: string; className?: string; + /** + * 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; } @@ -104,3 +118,30 @@ export interface LayoutPersistenceAdapter { 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 (