From e29c99a84969b1f09d4f3adc5c5dcab3ebd33bc4 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Wed, 10 Jun 2026 16:32:52 +0200 Subject: [PATCH] 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, + })); +}