From e29c99a84969b1f09d4f3adc5c5dcab3ebd33bc4 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Wed, 10 Jun 2026 16:32:52 +0200 Subject: [PATCH 1/6] ui generic rendering - base --- .../FormGeneratorTable.module.css | 25 +- src/components/Layout/LayoutTabs.module.css | 305 +++++++++++++++++ src/components/Layout/LayoutTabs.tsx | 324 ++++++++++++++++++ src/components/Layout/Panel.module.css | 151 ++++++++ src/components/Layout/Panel.tsx | 82 +++++ src/components/Layout/StackLayout.module.css | 96 ++++++ src/components/Layout/StackLayout.tsx | 101 ++++++ src/components/Layout/ViewStack.module.css | 110 ++++++ src/components/Layout/ViewStack.tsx | 117 +++++++ src/components/Layout/index.ts | 27 ++ src/components/Layout/persistence.ts | 39 +++ src/components/Layout/types.ts | 106 ++++++ src/hooks/useScrollMode.ts | 54 +++ src/layouts/FeatureLayout.module.css | 36 +- src/layouts/FeatureLayout.tsx | 10 - src/layouts/MainLayout.module.css | 10 + src/layouts/MainLayout.tsx | 4 +- src/pages/FeatureView.module.css | 11 + src/pages/FeatureView.tsx | 4 + src/pages/admin/Admin.module.css | 18 +- .../views/solutions/SolutionsView.module.css | 30 ++ src/pages/views/solutions/SolutionsView.tsx | 128 +++++++ .../views/trustee/TrusteeDataTablesView.tsx | 217 ++++-------- src/pages/views/workspace/WorkspaceInput.tsx | 40 +-- .../WorkflowAutomationHubPage.tsx | 152 ++++---- .../workflowAutomation/tabs/EditorTab.tsx | 2 +- src/pages/workflowAutomation/tabs/RunsTab.tsx | 123 +++---- .../workflowAutomation/tabs/TemplatesTab.tsx | 2 +- .../workflowAutomation/tabs/WorkflowsTab.tsx | 46 +-- src/pages/workflowAutomation/types.ts | 2 +- src/utils/settingsSchemaToFormAttributes.ts | 112 ++++++ 31 files changed, 2125 insertions(+), 359 deletions(-) create mode 100644 src/components/Layout/LayoutTabs.module.css create mode 100644 src/components/Layout/LayoutTabs.tsx create mode 100644 src/components/Layout/Panel.module.css create mode 100644 src/components/Layout/Panel.tsx create mode 100644 src/components/Layout/StackLayout.module.css create mode 100644 src/components/Layout/StackLayout.tsx create mode 100644 src/components/Layout/ViewStack.module.css create mode 100644 src/components/Layout/ViewStack.tsx create mode 100644 src/components/Layout/index.ts create mode 100644 src/components/Layout/persistence.ts create mode 100644 src/components/Layout/types.ts create mode 100644 src/hooks/useScrollMode.ts create mode 100644 src/pages/views/solutions/SolutionsView.module.css create mode 100644 src/pages/views/solutions/SolutionsView.tsx create mode 100644 src/utils/settingsSchemaToFormAttributes.ts diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css index a2344d8..c496400 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css @@ -4,7 +4,10 @@ gap: 10px; width: 100%; font-family: var(--font-family); - min-height: 0; + /* Floor for the bounded chain: below this the table stops shrinking and the + nearest scroll ancestor (.viewContent / .outletShell) shows a scrollbar + instead of squeezing the table to invisibility on short viewports. */ + min-height: var(--table-min-height, 280px); flex: 1; overflow: hidden; height: 100%; @@ -960,6 +963,26 @@ tbody .actionsColumn { font-size: 13px; } +/* scrollMode: document — table grows to natural height, page scrolls */ +:global(html[data-scroll-mode="document"]) .formGeneratorTable { + height: auto; + min-height: 0; + max-height: none; + overflow: visible; + flex: 0 0 auto; +} + +:global(html[data-scroll-mode="document"]) .tableWrapper { + max-height: none; + overflow: visible; +} + +:global(html[data-scroll-mode="document"]) .tableContainer { + overflow: visible; + max-height: none; + flex: 0 0 auto; +} + /* Responsive */ @media (max-width: 768px) { .formGeneratorTable { diff --git a/src/components/Layout/LayoutTabs.module.css b/src/components/Layout/LayoutTabs.module.css new file mode 100644 index 0000000..9455697 --- /dev/null +++ b/src/components/Layout/LayoutTabs.module.css @@ -0,0 +1,305 @@ +/* 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; +} + +/* ---------- 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..5db3edc --- /dev/null +++ b/src/components/Layout/LayoutTabs.tsx @@ -0,0 +1,324 @@ +// 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, +}: 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..be8208b --- /dev/null +++ b/src/components/Layout/Panel.module.css @@ -0,0 +1,151 @@ +/** 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: hidden; +} + +/* --- 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; +} + +.panel[data-variant="toolbar"] .header { + display: none; +} + +/* --- Variant: editor — full height, no body padding --- */ +.panel[data-variant="editor"] { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.panel[data-variant="editor"] .body { + flex: 1; + min-height: 0; + padding: 0; + display: flex; + flex-direction: column; +} + +/* --- 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; +} + +.headerCollapsible { + cursor: pointer; + user-select: none; +} + +.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: 0; + display: flex; + align-items: center; + gap: 4px; +} + +.chevron { + flex-shrink: 0; + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 6px solid var(--text-tertiary, #888); + transition: transform 0.2s ease; + transform-origin: 50% 40%; +} + +.panelCollapsed .chevron { + transform: rotate(-90deg); +} + +.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..5054bcd --- /dev/null +++ b/src/components/Layout/Panel.tsx @@ -0,0 +1,82 @@ +// Copyright (c) 2026 PowerOn AG +// All rights reserved. +import { type FC, useState, useEffect, useCallback } from 'react'; +import type { PanelProps } from './types'; +import styles from './Panel.module.css'; + +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 */ } +} + +export const Panel: FC = ({ + variant = 'card', + title, + subtitle, + actions, + collapsible = false, + defaultCollapsed = false, + collapseKey, + className = '', + children, +}) => { + const [collapsed, setCollapsed] = useState(() => _loadCollapsed(collapseKey, defaultCollapsed)); + + useEffect(() => { + _saveCollapsed(collapseKey, collapsed); + }, [collapseKey, collapsed]); + + const _toggleCollapsed = useCallback(() => { + if (collapsible) setCollapsed((prev) => !prev); + }, [collapsible]); + + const hasHeader = title != null; + + return ( +
+ {hasHeader && ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + _toggleCollapsed(); + } + } + : undefined + } + > +
+ {title} + {subtitle && {subtitle}} +
+ {actions &&
{actions}
} + {collapsible && } +
+ )} +
+ {children} +
+
+ ); +}; diff --git a/src/components/Layout/StackLayout.module.css b/src/components/Layout/StackLayout.module.css new file mode 100644 index 0000000..3cf09bd --- /dev/null +++ b/src/components/Layout/StackLayout.module.css @@ -0,0 +1,96 @@ +/** 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; +} + +.bodyDashboard { + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 16px; +} + +/* ------------------------------------------------------------------ */ +/* 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..34519de --- /dev/null +++ b/src/components/Layout/StackLayout.tsx @@ -0,0 +1,101 @@ +// Copyright (c) 2026 PowerOn AG +// All rights reserved. +import React, { type FC, type ReactNode, Children, isValidElement, cloneElement } from 'react'; +import type { StackLayoutProps, StackLayoutVariant } from './types'; +import { useScrollMode } from '../../hooks/useScrollMode'; +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(); + + 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..07498d7 --- /dev/null +++ b/src/components/Layout/ViewStack.tsx @@ -0,0 +1,117 @@ +// Copyright (c) 2026 PowerOn AG +// All rights reserved. + +import React, { type ReactElement } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import type { ViewMode, ViewStackProps, ViewProps } from './types'; +import styles from './ViewStack.module.css'; + +function _resolveActiveView( + searchParams: URLSearchParams, + viewParam: string, + entityParam: string | undefined, + defaultView: ViewMode +): ViewMode { + const rawView = searchParams.get(viewParam) as ViewMode | null; + const entityId = entityParam ? searchParams.get(entityParam) : null; + + let resolved: ViewMode = rawView ?? defaultView; + + if (entityId && resolved === 'list') { + resolved = 'detail'; + } + + if (resolved === 'detail' && !entityId) { + resolved = defaultView; + } + + return resolved; +} + +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 View({ children }: ViewProps) { + return <>{children}; +} + +function ViewStack({ + viewParam = 'view', + entityParam, + defaultView = 'list', + children, +}: ViewStackProps) { + const [searchParams, setSearchParams] = useSearchParams(); + + const activeView = _resolveActiveView(searchParams, viewParam, entityParam, defaultView); + 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..47185e8 --- /dev/null +++ b/src/components/Layout/index.ts @@ -0,0 +1,27 @@ +// 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'; 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..fbc575f --- /dev/null +++ b/src/components/Layout/types.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2026 PowerOn AG +// All rights reserved. +/** + * Shared types for the Layout component system. + */ + +import type { 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; +} + +// --------------------------------------------------------------------------- +// 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; + title?: string | ReactNode; + subtitle?: string | ReactNode; + actions?: ReactNode; + collapsible?: boolean; + defaultCollapsed?: boolean; + collapseKey?: string; + className?: string; + 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; +} diff --git a/src/hooks/useScrollMode.ts b/src/hooks/useScrollMode.ts new file mode 100644 index 0000000..0e64d89 --- /dev/null +++ b/src/hooks/useScrollMode.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2026 PowerOn AG +// All rights reserved. +/** + * useScrollMode Hook + * + * Determines the layout scroll mode for the current viewport: + * - "bounded": Desktop with sufficient height. Content areas use overflow:hidden + * chains and tables scroll internally. + * - "document": Mobile or very short viewports. The overflow:hidden chain is + * broken so the page scrolls as a document, letting the header scroll away. + * + * Sets `data-scroll-mode` on so every CSS module can react via + * `:global(html[data-scroll-mode="document"])`. + */ + +import { useState, useEffect, useCallback } from 'react'; + +export type ScrollMode = 'bounded' | 'document'; + +const DOCUMENT_MODE_QUERY = '(max-width: 1024px), (max-height: 500px)'; + +function _evaluateScrollMode(): ScrollMode { + return window.matchMedia(DOCUMENT_MODE_QUERY).matches ? 'document' : 'bounded'; +} + +export function useScrollMode(): ScrollMode { + const [mode, setMode] = useState(_evaluateScrollMode); + + const _syncAttribute = useCallback((m: ScrollMode) => { + document.documentElement.dataset.scrollMode = m; + }, []); + + useEffect(() => { + _syncAttribute(mode); + }, [mode, _syncAttribute]); + + useEffect(() => { + const mql = window.matchMedia(DOCUMENT_MODE_QUERY); + const _handleChange = () => { + const next = _evaluateScrollMode(); + setMode(next); + }; + mql.addEventListener('change', _handleChange); + return () => mql.removeEventListener('change', _handleChange); + }, []); + + useEffect(() => { + return () => { + delete document.documentElement.dataset.scrollMode; + }; + }, []); + + return mode; +} diff --git a/src/layouts/FeatureLayout.module.css b/src/layouts/FeatureLayout.module.css index c639a07..1f6b925 100644 --- a/src/layouts/FeatureLayout.module.css +++ b/src/layouts/FeatureLayout.module.css @@ -135,13 +135,10 @@ letter-spacing: 0.025em; } -/* Feature Content */ +/* Feature Content — viewContent owns padding, not featureContent */ .featureContent { flex: 1; - /* Let child components handle their own scrolling for sticky headers */ overflow: hidden; - padding: 1.5rem; - /* Maintain flex chain for child components */ display: flex; flex-direction: column; min-height: 0; @@ -180,10 +177,35 @@ @media (max-width: 1024px) { .featureHeader { - padding: 0.75rem 1rem; + padding: 0.5rem 1rem; } - .featureContent { - padding: 1rem; + .breadcrumb { + gap: 0.25rem; + font-size: 0.8125rem; + } + + .mandateName { + display: none; + } + + .mandateName + .separator { + display: none; + } + + .roleBadge { + padding: 0.125rem 0.5rem; + font-size: 0.6875rem; } } + +/* scrollMode: document — layout grows with content, header scrolls away */ +:global(html[data-scroll-mode="document"]) .featureLayout { + height: auto; + min-height: 0; +} + +:global(html[data-scroll-mode="document"]) .featureContent { + overflow: visible; + flex: 0 0 auto; +} diff --git a/src/layouts/FeatureLayout.tsx b/src/layouts/FeatureLayout.tsx index 9af73f9..6a9dc3d 100644 --- a/src/layouts/FeatureLayout.tsx +++ b/src/layouts/FeatureLayout.tsx @@ -91,12 +91,10 @@ export const FeatureLayout: React.FC = () => { }; }, [dynamicBlock, mandateId, featureCode, instanceId]); - // Warten bis Features geladen sind if (!initialized || loading || isLoading) { return ; } - // Prüfen ob Instanz existiert und gültig ist if (!isValid) { console.warn('FeatureLayout: Invalid instance context', { path: location.pathname, @@ -112,10 +110,8 @@ export const FeatureLayout: React.FC = () => { ); } - // Alles OK - rendere Content return (
- {/* Header mit Instanz-Info */}
@@ -131,7 +127,6 @@ export const FeatureLayout: React.FC = () => {
- {/* Content Area */}
@@ -148,10 +143,6 @@ interface ProtectedFeatureRouteProps { children: React.ReactNode; } -/** - * Wrapper für geschützte Feature-Routes - * Prüft zusätzlich View-Berechtigungen - */ export const ProtectedFeatureRoute: React.FC = ({ requiredView, children, @@ -163,7 +154,6 @@ export const ProtectedFeatureRoute: React.FC = ({ return ; } - // Prüfe View-Berechtigung wenn erforderlich if (requiredView) { const hasViewAccess = instance?.permissions?.views?.[requiredView] ?? false; diff --git a/src/layouts/MainLayout.module.css b/src/layouts/MainLayout.module.css index 1bb1c9a..f0fbf31 100644 --- a/src/layouts/MainLayout.module.css +++ b/src/layouts/MainLayout.module.css @@ -115,6 +115,16 @@ -webkit-overflow-scrolling: touch; } +/* scrollMode: document — .content becomes the scroll container, outletShell is transparent */ +.content[data-scroll-mode="document"] { + overflow: auto; +} + +.content[data-scroll-mode="document"] .outletShell { + overflow: visible; + flex: 0 0 auto; +} + .mobileTopBar { display: none; } diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx index d1e9b26..46454c2 100644 --- a/src/layouts/MainLayout.tsx +++ b/src/layouts/MainLayout.tsx @@ -16,6 +16,7 @@ import { RagRunningBadge } from '../components/RagRunningBadge/RagRunningBadge'; import { KEEP_ALIVE_ROUTES, hideFeatureOutlet } from '../config/keepAliveRoutes'; import type { KeepAliveEntry, KeepAliveScopedEntry, KeepAliveUnscopedEntry } from '../types/keepAlive.types'; import { isKeepAliveScoped } from '../types/keepAlive.types'; +import { useScrollMode } from '../hooks/useScrollMode'; import styles from './MainLayout.module.css'; import { useLanguage } from '../providers/language/LanguageContext'; @@ -105,6 +106,7 @@ const RoutedKeepAliveSlot: React.FC<{ entry: KeepAliveEntry; pathname: string; s const MainLayoutInner: React.FC = () => { const { t } = useLanguage(); + const scrollMode = useScrollMode(); const { loadFeatures, initialized, loading, error } = useFeatureStore(); const location = useLocation(); @@ -166,7 +168,7 @@ const MainLayoutInner: React.FC = () => { {/* Content */} -
+
- ); - })} -
-
- ))} -
- -
- -
- + + +

{t('Daten-Tabellen')}

+
+ + + +
); }; diff --git a/src/pages/views/workspace/WorkspaceInput.tsx b/src/pages/views/workspace/WorkspaceInput.tsx index dd5e85a..3f4f30f 100644 --- a/src/pages/views/workspace/WorkspaceInput.tsx +++ b/src/pages/views/workspace/WorkspaceInput.tsx @@ -256,6 +256,11 @@ export const WorkspaceInput = forwardRef { if (loadedNonce === undefined) return; setAttachments([]); @@ -263,39 +268,6 @@ export const WorkspaceInput = forwardRef(undefined); - const _reconciledFdsForNonce = useRef(undefined); - - useEffect(() => { - if (loadedNonce === undefined) return; - if (_reconciledDsForNonce.current === loadedNonce) return; - if (dataSources.length === 0 && !loadedDataSourceLabels) return; - _reconciledDsForNonce.current = loadedNonce; - for (const ds of dataSources) { - const lbl = ds.label || ds.path; - if (lbl) dsLabelCache.current.set(ds.id, lbl); - } - const validIds = new Set(dataSources.map(d => d.id)); - const labeledIds = new Set(Object.keys(loadedDataSourceLabels || {})); - setAttachedDataSourceIds(prev => { - const filtered = prev.filter(id => validIds.has(id) || labeledIds.has(id)); - return filtered.length === prev.length ? prev : filtered; - }); - }, [loadedNonce, dataSources, loadedDataSourceLabels]); - - useEffect(() => { - if (loadedNonce === undefined) return; - if (_reconciledFdsForNonce.current === loadedNonce) return; - if (featureDataSources.length === 0 && !loadedFeatureDataSourceLabels) return; - _reconciledFdsForNonce.current = loadedNonce; - const validIds = new Set(featureDataSources.map(d => d.id)); - const labeledIds = new Set(Object.keys(loadedFeatureDataSourceLabels || {})); - setAttachedFeatureDataSourceIds(prev => { - const filtered = prev.filter(id => validIds.has(id) || labeledIds.has(id)); - return filtered.length === prev.length ? prev : filtered; - }); - }, [loadedNonce, featureDataSources, loadedFeatureDataSourceLabels]); - const promptBeforeVoiceRef = useRef(''); const finalizedTextRef = useRef(''); const currentInterimRef = useRef(''); @@ -731,7 +703,7 @@ export const WorkspaceInput = forwardRef {fdsIcon || '\uD83D\uDDC3\uFE0F'} - {fds?.label || fdsLabelMap.get(fdsId) || fdsId} – {fds?.tableName || ''} + {fds?.label || fdsLabelMap.get(fdsId) || fdsId}{fds?.tableName ? ` – ${fds.tableName}` : ''} + } + > +
+ } label={t('Workflows')} value={metrics?.workflowCount ?? t('—')} /> + } label={t('Aktive Workflows')} value={metrics?.activeWorkflows ?? t('—')} color="var(--success-color, #28a745)" /> + } label={t('Runs gesamt')} value={metrics?.totalRuns ?? t('—')} />
- -
- } label={t('Workflows')} value={metrics?.workflowCount ?? t('—')} /> - } label={t('Aktive Workflows')} value={metrics?.activeWorkflows ?? t('—')} color="var(--success-color, #28a745)" /> - } label={t('Runs gesamt')} value={metrics?.totalRuns ?? t('—')} /> -
- - {metrics?.runsByStatus && Object.keys(metrics.runsByStatus).length > 0 && ( -
-

{t('Läufe nach Status')}

-
- {Object.entries(metrics.runsByStatus).map(([status, count]) => ( - - {status}: {count} - - ))} + {metrics?.runsByStatus && Object.keys(metrics.runsByStatus).length > 0 && ( +
+

{t('Läufe nach Status')}

+
+ {Object.entries(metrics.runsByStatus).map(([status, count]) => ( + + {status}: {count} + + ))} +
-
- )} + )} - {metrics && (metrics.totalTokens > 0 || metrics.totalCredits > 0) && ( -
- {metrics.totalTokens > 0 && ( -
- {t('Tokens gesamt:')} - {metrics.totalTokens.toLocaleString('de-DE')} -
- )} - {metrics.totalCredits > 0 && ( -
- {t('Credits gesamt:')} - {metrics.totalCredits.toLocaleString('de-DE', { minimumFractionDigits: 2 })} -
- )} -
- )} - -
-

{t('Letzte Runs')}

-
+ {metrics && (metrics.totalTokens > 0 || metrics.totalCredits > 0) && ( +
+ {metrics.totalTokens > 0 && ( +
+ {t('Tokens gesamt:')} + {metrics.totalTokens.toLocaleString('de-DE')} +
+ )} + {metrics.totalCredits > 0 && ( +
+ {t('Credits gesamt:')} + {metrics.totalCredits.toLocaleString('de-DE', { minimumFractionDigits: 2 })} +
+ )} +
+ )} +
data={runs} diff --git a/src/pages/workflowAutomation/tabs/TemplatesTab.tsx b/src/pages/workflowAutomation/tabs/TemplatesTab.tsx index 1b549b9..47bd67b 100644 --- a/src/pages/workflowAutomation/tabs/TemplatesTab.tsx +++ b/src/pages/workflowAutomation/tabs/TemplatesTab.tsx @@ -31,7 +31,7 @@ export const _TemplatesTab: React.FC = ({ selectedMandateId = if (!editorInstance) { return (
-

{t('Keine Vorlagen verfügbar. Bitte wähle einen Mandanten mit einer Feature-Instanz.')}

+

{t('Laden…')}

); } diff --git a/src/pages/workflowAutomation/tabs/WorkflowsTab.tsx b/src/pages/workflowAutomation/tabs/WorkflowsTab.tsx index a80d410..d819799 100644 --- a/src/pages/workflowAutomation/tabs/WorkflowsTab.tsx +++ b/src/pages/workflowAutomation/tabs/WorkflowsTab.tsx @@ -9,7 +9,7 @@ import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; -import { FaSync, FaPlay, FaCheck, FaBan, FaPen, FaEye, FaStop } from 'react-icons/fa'; +import { FaCog, FaPlay, FaCheck, FaBan, FaPen, FaEye, FaStop, FaBolt } from 'react-icons/fa'; import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator'; import { useToast } from '../../../contexts/ToastContext'; import { usePrompt } from '../../../hooks/usePrompt'; @@ -20,11 +20,13 @@ import type { AttributeDefinition } from '../../../api/attributesApi'; import { resolveColumnTypes } from '../../../utils/columnTypeResolver'; import api from '../../../api'; import { useLanguage } from '../../../providers/language/LanguageContext'; +import { Panel } from '../../../components/Layout/Panel'; import styles from '../../admin/Admin.module.css'; import { type SystemWorkflow, _formatTs, } from '../types'; +import { MetricCard } from './RunsTab'; // --------------------------------------------------------------------------- // WorkflowsTab (exported) @@ -45,7 +47,6 @@ export const _WorkflowsTab: React.FC = ({ onWorkflowClick, se const [loading, setLoading] = useState(true); const [executingId, setExecutingId] = useState(null); const [togglingId, setTogglingId] = useState(null); - const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all'); const [paginationMeta, setPaginationMeta] = useState(null); const lastPaginationParamsRef = useRef(null); const [backendAttributes, setBackendAttributes] = useState([]); @@ -64,8 +65,6 @@ export const _WorkflowsTab: React.FC = ({ onWorkflowClick, se setLoading(true); try { const params: Record = {}; - if (activeFilter === 'active') params.active = true; - if (activeFilter === 'inactive') params.active = false; if (selectedMandateId !== 'all') params.mandateId = selectedMandateId; const defaultSort = [{ field: 'sysCreatedAt', direction: 'desc' }]; @@ -88,7 +87,7 @@ export const _WorkflowsTab: React.FC = ({ onWorkflowClick, se } finally { setLoading(false); } - }, [activeFilter, selectedMandateId, showError, t]); + }, [selectedMandateId, showError, t]); useEffect(() => { _load(); @@ -289,32 +288,23 @@ export const _WorkflowsTab: React.FC = ({ onWorkflowClick, se pagination: paginationMeta, }), [_load, _handleDelete, paginationMeta]); + const _totalCount = workflows.length; + const _activeCount = workflows.filter((w) => w.active !== false).length; + const _runningCount = workflows.filter((w) => w.isRunning).length; + return ( <> -
-
-

- {t('Alle Workflows über alle Features und Mandanten')} -

+ +
+ } label={t('Workflows')} value={_totalCount} /> + } label={t('Aktiv')} value={_activeCount} color="var(--success-color, #28a745)" /> + } label={t('Laufend')} value={_runningCount} color="var(--primary-color, #007bff)" />
-
-
- {(['all', 'active', 'inactive'] as const).map((f) => ( - - ))} -
- -
-
+
diff --git a/src/pages/workflowAutomation/types.ts b/src/pages/workflowAutomation/types.ts index e25fca9..6007a3b 100644 --- a/src/pages/workflowAutomation/types.ts +++ b/src/pages/workflowAutomation/types.ts @@ -26,7 +26,7 @@ export interface WorkflowRunMetrics { export interface WorkflowRun { id: string; workflowId: string; - workflowLabel?: string; + workflowIdLabel?: string; mandateId?: string; mandateLabel?: string; featureInstanceId?: string; diff --git a/src/utils/settingsSchemaToFormAttributes.ts b/src/utils/settingsSchemaToFormAttributes.ts new file mode 100644 index 0000000..6695ea3 --- /dev/null +++ b/src/utils/settingsSchemaToFormAttributes.ts @@ -0,0 +1,112 @@ +// Copyright (c) 2026 PowerOn AG +// All rights reserved. +/** + * settingsSchemaToFormAttributes + * + * Bridge between a Solution's `settingsSchema` (derived from trigger.form / + * exposed node params on the backend) and the `AttributeDefinition[]` array + * that FormGeneratorForm expects. + * + * This is NOT a second render system — it maps onto the existing + * `FRONTEND_TYPE_RENDERERS`-independent FormGenerator pipeline. + */ + +import type { AttributeType } from './attributeTypeMapper'; +import type { AttributeDefinition, AttributeOption } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm'; + +// --------------------------------------------------------------------------- +// Input types (mirror backend PortField / settingsSchema) +// --------------------------------------------------------------------------- + +export interface SettingsSchemaField { + name: string; + type: string; + description?: string; + required?: boolean; + enumValues?: string[] | null; + default?: unknown; + frontendType?: string; + frontendOptions?: Record; + pickerLabel?: string; + pickerItemLabel?: string; +} + +export interface SettingsSchema { + name?: string; + fields: SettingsSchemaField[]; +} + +// --------------------------------------------------------------------------- +// Type mapping +// --------------------------------------------------------------------------- + +const _PYTHON_TYPE_MAP: Record = { + str: 'text', + string: 'text', + int: 'integer', + float: 'float', + number: 'number', + bool: 'boolean', + boolean: 'boolean', + date: 'date', + datetime: 'timestamp', +}; + +const _FRONTEND_TYPE_MAP: Record = { + text: 'text', + textarea: 'textarea', + templateTextarea: 'textarea', + number: 'number', + checkbox: 'boolean', + date: 'date', + datetime: 'timestamp', + email: 'email', + select: 'select', + multiselect: 'multiselect', + json: 'json', + file: 'file', + hidden: 'text', + multilingual: 'multilingual', +}; + +function _resolveAttributeType(field: SettingsSchemaField): AttributeType { + if (field.frontendType && _FRONTEND_TYPE_MAP[field.frontendType]) { + return _FRONTEND_TYPE_MAP[field.frontendType]; + } + + if (field.enumValues?.length) { + return 'select'; + } + + const baseType = field.type.replace(/^List\[|]$/g, '').toLowerCase(); + return _PYTHON_TYPE_MAP[baseType] ?? 'text'; +} + +function _resolveOptions(field: SettingsSchemaField): AttributeOption[] | undefined { + if (!field.enumValues?.length) return undefined; + return field.enumValues.map((v) => ({ + value: v, + label: field.pickerItemLabel ? `${v}` : v, + })); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export function settingsSchemaToFormAttributes( + schema: SettingsSchema, +): AttributeDefinition[] { + return schema.fields.map((field, index): AttributeDefinition => ({ + name: field.name, + type: _resolveAttributeType(field), + label: field.pickerLabel ?? field.description ?? field.name, + description: field.description, + required: field.required ?? false, + default: field.default ?? undefined, + options: _resolveOptions(field), + visible: field.frontendType !== 'hidden', + order: index, + placeholder: field.frontendOptions?.placeholder as string | undefined, + })); +} -- 2.45.2 From d579df1c92c8b5c71cced657e051ef98dd2368f2 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Thu, 11 Jun 2026 16:43:53 +0200 Subject: [PATCH 2/6] 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 (