Compare commits
1 commit
| Author | SHA1 | Date | |
|---|---|---|---|
| e29c99a849 |
31 changed files with 2125 additions and 359 deletions
|
|
@ -4,7 +4,10 @@
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-family: var(--font-family);
|
font-family: var(--font-family);
|
||||||
min-height: 0;
|
/* Floor for the bounded chain: below this the table stops shrinking and the
|
||||||
|
nearest scroll ancestor (.viewContent / .outletShell) shows a scrollbar
|
||||||
|
instead of squeezing the table to invisibility on short viewports. */
|
||||||
|
min-height: var(--table-min-height, 280px);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
@ -960,6 +963,26 @@ tbody .actionsColumn {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* scrollMode: document — table grows to natural height, page scrolls */
|
||||||
|
:global(html[data-scroll-mode="document"]) .formGeneratorTable {
|
||||||
|
height: auto;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html[data-scroll-mode="document"]) .tableWrapper {
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html[data-scroll-mode="document"]) .tableContainer {
|
||||||
|
overflow: visible;
|
||||||
|
max-height: none;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.formGeneratorTable {
|
.formGeneratorTable {
|
||||||
|
|
|
||||||
305
src/components/Layout/LayoutTabs.module.css
Normal file
305
src/components/Layout/LayoutTabs.module.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
324
src/components/Layout/LayoutTabs.tsx
Normal file
324
src/components/Layout/LayoutTabs.tsx
Normal file
|
|
@ -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<string, string> | undefined,
|
||||||
|
): string | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
return aliasMap?.[raw] ?? raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _findItem(
|
||||||
|
items: LayoutTabItem[],
|
||||||
|
id: string | null,
|
||||||
|
): LayoutTabItem | undefined {
|
||||||
|
if (!id) return undefined;
|
||||||
|
return items.find((item) => item.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _enabledItems(items: LayoutTabItem[]): LayoutTabItem[] {
|
||||||
|
return items.filter((item) => !item.disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface _Group {
|
||||||
|
key: string;
|
||||||
|
label: string | undefined;
|
||||||
|
items: LayoutTabItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildGroups(items: LayoutTabItem[]): _Group[] {
|
||||||
|
const groups: _Group[] = [];
|
||||||
|
let current: _Group | null = null;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const groupKey = item.group ?? '';
|
||||||
|
if (!current || current.key !== groupKey) {
|
||||||
|
current = { key: groupKey, label: item.group, items: [] };
|
||||||
|
groups.push(current);
|
||||||
|
}
|
||||||
|
current.items.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LayoutTabs({
|
||||||
|
items,
|
||||||
|
urlParam,
|
||||||
|
defaultTab,
|
||||||
|
preserveSearchParams = true,
|
||||||
|
aliasMap,
|
||||||
|
syncUrl,
|
||||||
|
lazy = false,
|
||||||
|
onTabChange,
|
||||||
|
className,
|
||||||
|
collapsible = false,
|
||||||
|
collapseKey,
|
||||||
|
defaultCollapsed = false,
|
||||||
|
}: LayoutTabsProps) {
|
||||||
|
const shouldSyncUrl = syncUrl ?? !!urlParam;
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const _initialTab = useMemo(() => {
|
||||||
|
if (urlParam) {
|
||||||
|
const raw = searchParams.get(urlParam);
|
||||||
|
const resolved = _resolveAlias(raw, aliasMap);
|
||||||
|
const matched = _findItem(items, resolved);
|
||||||
|
if (matched && !matched.disabled) return matched.id;
|
||||||
|
}
|
||||||
|
if (defaultTab) {
|
||||||
|
const matched = _findItem(items, defaultTab);
|
||||||
|
if (matched && !matched.disabled) return matched.id;
|
||||||
|
}
|
||||||
|
return _enabledItems(items)[0]?.id ?? items[0]?.id ?? '';
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [activeId, setActiveId] = useState(_initialTab);
|
||||||
|
const [mountedIds, setMountedIds] = useState<Set<string>>(
|
||||||
|
() => new Set([_initialTab]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Collapse state for the tab bar
|
||||||
|
const [tabBarCollapsed, setTabBarCollapsed] = useState(() => {
|
||||||
|
if (!collapsible) return false;
|
||||||
|
if (collapseKey) {
|
||||||
|
const stored = _collapsePersistence.load<boolean>(collapseKey);
|
||||||
|
if (stored !== null) return stored;
|
||||||
|
}
|
||||||
|
return defaultCollapsed;
|
||||||
|
});
|
||||||
|
|
||||||
|
const _toggleTabBar = useCallback(() => {
|
||||||
|
setTabBarCollapsed((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
if (collapseKey) _collapsePersistence.save(collapseKey, next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [collapseKey]);
|
||||||
|
|
||||||
|
const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||||
|
|
||||||
|
const _setTab = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
setActiveId(id);
|
||||||
|
|
||||||
|
if (lazy) {
|
||||||
|
setMountedIds((prev) => {
|
||||||
|
if (prev.has(id)) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldSyncUrl && urlParam) {
|
||||||
|
setSearchParams(
|
||||||
|
(prev) => {
|
||||||
|
const next = preserveSearchParams
|
||||||
|
? new URLSearchParams(prev)
|
||||||
|
: new URLSearchParams();
|
||||||
|
next.set(urlParam, id);
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onTabChange?.(id);
|
||||||
|
},
|
||||||
|
[shouldSyncUrl, urlParam, preserveSearchParams, setSearchParams, onTabChange, lazy],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!urlParam || !shouldSyncUrl) return;
|
||||||
|
const raw = searchParams.get(urlParam);
|
||||||
|
const resolved = _resolveAlias(raw, aliasMap);
|
||||||
|
const matched = _findItem(items, resolved);
|
||||||
|
if (matched && !matched.disabled && matched.id !== activeId) {
|
||||||
|
setActiveId(matched.id);
|
||||||
|
if (lazy) {
|
||||||
|
setMountedIds((prev) => {
|
||||||
|
if (prev.has(matched.id)) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(matched.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [searchParams, urlParam, aliasMap, items, shouldSyncUrl, activeId, lazy]);
|
||||||
|
|
||||||
|
const enabled = useMemo(() => _enabledItems(items), [items]);
|
||||||
|
|
||||||
|
const _handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent<HTMLButtonElement>) => {
|
||||||
|
const idx = enabled.findIndex((item) => item.id === activeId);
|
||||||
|
let target: LayoutTabItem | undefined;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowRight':
|
||||||
|
target = enabled[(idx + 1) % enabled.length];
|
||||||
|
break;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
target = enabled[(idx - 1 + enabled.length) % enabled.length];
|
||||||
|
break;
|
||||||
|
case 'Home':
|
||||||
|
target = enabled[0];
|
||||||
|
break;
|
||||||
|
case 'End':
|
||||||
|
target = enabled[enabled.length - 1];
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
e.preventDefault();
|
||||||
|
_setTab(target.id);
|
||||||
|
tabRefs.current.get(target.id)?.focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[enabled, activeId, _setTab],
|
||||||
|
);
|
||||||
|
|
||||||
|
const groups = useMemo(() => _buildGroups(items), [items]);
|
||||||
|
const hasGroups = groups.some((g) => !!g.label);
|
||||||
|
const panelId = `tabpanel-${activeId}`;
|
||||||
|
const activeItem = _findItem(items, activeId) ?? items[0];
|
||||||
|
|
||||||
|
if (!items.length) return null;
|
||||||
|
|
||||||
|
const showCollapsed = collapsible && tabBarCollapsed;
|
||||||
|
|
||||||
|
const _toggleButton = collapsible ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.toggleBtn}
|
||||||
|
aria-label={showCollapsed ? 'Tab-Auswahl einblenden' : 'Tab-Auswahl einklappen'}
|
||||||
|
aria-expanded={!showCollapsed}
|
||||||
|
onClick={_toggleTabBar}
|
||||||
|
>
|
||||||
|
{showCollapsed
|
||||||
|
? <FaChevronDown className={styles.toggleIcon} aria-hidden />
|
||||||
|
: <FaChevronUp className={styles.toggleIcon} aria-hidden />}
|
||||||
|
</button>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={[styles.container, className].filter(Boolean).join(' ')}>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
styles.tabBarRow,
|
||||||
|
showCollapsed && styles.tabBarRowCollapsed,
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
|
>
|
||||||
|
{!showCollapsed ? (
|
||||||
|
<div
|
||||||
|
className={[styles.tabBar, hasGroups && styles.tabBarGrouped]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
role="tablist"
|
||||||
|
>
|
||||||
|
{groups.map((group, gi) => (
|
||||||
|
<div key={group.key || gi} className={styles.group}>
|
||||||
|
{gi > 0 && !hasGroups && (
|
||||||
|
<div className={styles.groupSeparator} aria-hidden />
|
||||||
|
)}
|
||||||
|
{group.label && (
|
||||||
|
<span className={styles.groupLabel} aria-hidden>
|
||||||
|
{group.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{group.items.map((item) => {
|
||||||
|
const isActive = item.id === activeId;
|
||||||
|
const tabId = `tab-${item.id}`;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) tabRefs.current.set(item.id, el);
|
||||||
|
else tabRefs.current.delete(item.id);
|
||||||
|
}}
|
||||||
|
id={tabId}
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
aria-selected={isActive}
|
||||||
|
aria-controls={panelId}
|
||||||
|
aria-disabled={item.disabled || undefined}
|
||||||
|
tabIndex={isActive ? 0 : -1}
|
||||||
|
className={[styles.tab, isActive && styles.tabActive]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
onClick={() => {
|
||||||
|
if (!item.disabled) _setTab(item.id);
|
||||||
|
}}
|
||||||
|
onKeyDown={_handleKeyDown}
|
||||||
|
>
|
||||||
|
{item.icon && (
|
||||||
|
<span className={styles.tabIcon} aria-hidden>
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={styles.tabLabel}>{item.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className={styles.collapsedLabel}>{activeItem?.label}</span>
|
||||||
|
)}
|
||||||
|
{_toggleButton}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id={panelId}
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby={`tab-${activeId}`}
|
||||||
|
className={styles.panel}
|
||||||
|
>
|
||||||
|
{lazy
|
||||||
|
? items.map((item) =>
|
||||||
|
mountedIds.has(item.id) ? (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
style={
|
||||||
|
item.id === activeId
|
||||||
|
? {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column' as const,
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
}
|
||||||
|
: { display: 'none' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.render()}
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
)
|
||||||
|
: activeItem?.render()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LayoutTabs;
|
||||||
151
src/components/Layout/Panel.module.css
Normal file
151
src/components/Layout/Panel.module.css
Normal file
|
|
@ -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));
|
||||||
|
}
|
||||||
82
src/components/Layout/Panel.tsx
Normal file
82
src/components/Layout/Panel.tsx
Normal file
|
|
@ -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<PanelProps> = ({
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={`${styles.panel} ${collapsed ? styles.panelCollapsed : ''} ${className}`}
|
||||||
|
data-variant={variant}
|
||||||
|
>
|
||||||
|
{hasHeader && (
|
||||||
|
<div
|
||||||
|
className={`${styles.header} ${collapsible ? styles.headerCollapsible : ''}`}
|
||||||
|
role={collapsible ? 'button' : undefined}
|
||||||
|
tabIndex={collapsible ? 0 : undefined}
|
||||||
|
aria-expanded={collapsible ? !collapsed : undefined}
|
||||||
|
onClick={_toggleCollapsed}
|
||||||
|
onKeyDown={
|
||||||
|
collapsible
|
||||||
|
? (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
_toggleCollapsed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles.titles}>
|
||||||
|
<span className={styles.title}>{title}</span>
|
||||||
|
{subtitle && <span className={styles.subtitle}>{subtitle}</span>}
|
||||||
|
</div>
|
||||||
|
{actions && <div className={styles.actions}>{actions}</div>}
|
||||||
|
{collapsible && <span className={styles.chevron} aria-hidden />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`${styles.body} ${collapsed ? styles.bodyHidden : ''}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
96
src/components/Layout/StackLayout.module.css
Normal file
96
src/components/Layout/StackLayout.module.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/components/Layout/StackLayout.tsx
Normal file
101
src/components/Layout/StackLayout.tsx
Normal file
|
|
@ -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<SlotProps> = ({ className = '', children }) => (
|
||||||
|
<div className={`${styles.header} ${className}`}>{children}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Toolbar: FC<SlotProps> = ({ className = '', children }) => (
|
||||||
|
<div className={`${styles.toolbar} ${className}`}>{children}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Tabs: FC<SlotProps> = ({ className = '', children }) => (
|
||||||
|
<div className={`${styles.tabs} ${className}`}>{children}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Body: FC<SlotProps> = ({ className = '', children }) => (
|
||||||
|
<div className={className}>{children}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Footer: FC<SlotProps> = ({ className = '', children }) => (
|
||||||
|
<div className={`${styles.footer} ${className}`}>{children}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Variant → CSS class mapping
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const _variantBodyClass: Record<StackLayoutVariant, string> = {
|
||||||
|
table: styles.bodyTable,
|
||||||
|
scroll: styles.bodyScroll,
|
||||||
|
form: styles.bodyForm,
|
||||||
|
dashboard: styles.bodyDashboard,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Root component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface StackLayoutComponent extends FC<StackLayoutProps> {
|
||||||
|
Header: FC<SlotProps>;
|
||||||
|
Toolbar: FC<SlotProps>;
|
||||||
|
Tabs: FC<SlotProps>;
|
||||||
|
Body: FC<SlotProps>;
|
||||||
|
Footer: FC<SlotProps>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _StackLayoutRoot: FC<StackLayoutProps> = ({
|
||||||
|
variant = 'scroll',
|
||||||
|
className = '',
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const scrollMode = useScrollMode();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.root} ${className}`}
|
||||||
|
data-scroll-mode={scrollMode}
|
||||||
|
data-variant={variant}
|
||||||
|
>
|
||||||
|
{_processChildren(children, variant)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function _processChildren(children: ReactNode, variant: StackLayoutVariant): ReactNode {
|
||||||
|
const bodyClass = `${styles.body} ${_variantBodyClass[variant]}`;
|
||||||
|
|
||||||
|
return Children.map(children, (child: ReactNode) => {
|
||||||
|
if (!isValidElement(child)) return child;
|
||||||
|
if (child.type === Body) {
|
||||||
|
return cloneElement(child as React.ReactElement<SlotProps>, {
|
||||||
|
className: `${bodyClass} ${(child.props as SlotProps).className ?? ''}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Assemble compound component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const StackLayout = _StackLayoutRoot as StackLayoutComponent;
|
||||||
|
StackLayout.Header = Header;
|
||||||
|
StackLayout.Toolbar = Toolbar;
|
||||||
|
StackLayout.Tabs = Tabs;
|
||||||
|
StackLayout.Body = Body;
|
||||||
|
StackLayout.Footer = Footer;
|
||||||
110
src/components/Layout/ViewStack.module.css
Normal file
110
src/components/Layout/ViewStack.module.css
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
.viewStack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backButton {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backButton:hover {
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
border-color: var(--primary-color, #007bff);
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backButton:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backArrow {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailTitle {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #1a1a1a);
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .detailHeader {
|
||||||
|
border-bottom-color: var(--border-color, #333);
|
||||||
|
background: var(--bg-primary, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .backButton {
|
||||||
|
color: var(--text-secondary, #aaa);
|
||||||
|
border-color: var(--border-color, #444);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .backButton:hover {
|
||||||
|
color: var(--text-primary, #eee);
|
||||||
|
border-color: var(--primary-color, #4da3ff);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .detailTitle {
|
||||||
|
color: var(--text-primary, #f0f0f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.detailHeader {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backButton {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailTitle {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
117
src/components/Layout/ViewStack.tsx
Normal file
117
src/components/Layout/ViewStack.tsx
Normal file
|
|
@ -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<ViewProps> | null {
|
||||||
|
let match: ReactElement<ViewProps> | null = null;
|
||||||
|
|
||||||
|
React.Children.forEach(children, (child) => {
|
||||||
|
if (!React.isValidElement<ViewProps>(child)) return;
|
||||||
|
if (child.props.id === activeView) {
|
||||||
|
match = child;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildBackParams(
|
||||||
|
searchParams: URLSearchParams,
|
||||||
|
viewParam: string,
|
||||||
|
entityParam: string | undefined,
|
||||||
|
defaultView: ViewMode
|
||||||
|
): URLSearchParams {
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
|
||||||
|
if (entityParam) {
|
||||||
|
next.delete(entityParam);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defaultView === 'list') {
|
||||||
|
next.delete(viewParam);
|
||||||
|
} else {
|
||||||
|
next.set(viewParam, 'list');
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function 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 (
|
||||||
|
<div className={styles.viewStack}>
|
||||||
|
{activeView === 'detail' && (
|
||||||
|
<div className={styles.detailHeader}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.backButton}
|
||||||
|
onClick={handleBack}
|
||||||
|
>
|
||||||
|
<span className={styles.backArrow}>←</span>
|
||||||
|
{backLabel && <span>{backLabel}</span>}
|
||||||
|
</button>
|
||||||
|
{title && <h2 className={styles.detailTitle}>{title}</h2>}
|
||||||
|
{actions && <div className={styles.detailActions}>{actions}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.viewContent}>
|
||||||
|
{activeChild}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ViewStack.View = View;
|
||||||
|
|
||||||
|
export default ViewStack;
|
||||||
27
src/components/Layout/index.ts
Normal file
27
src/components/Layout/index.ts
Normal file
|
|
@ -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';
|
||||||
39
src/components/Layout/persistence.ts
Normal file
39
src/components/Layout/persistence.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
|
/**
|
||||||
|
* LayoutPersistenceAdapter — pluggable persistence for layout state.
|
||||||
|
*
|
||||||
|
* Default implementation uses localStorage.
|
||||||
|
* NOT used for navigation state (URL is source-of-truth) or settings values (DB).
|
||||||
|
* Use for: panel widths, collapse state, user UI preferences.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { LayoutPersistenceAdapter } from './types';
|
||||||
|
|
||||||
|
const PREFIX = 'po_layout_';
|
||||||
|
|
||||||
|
function _buildKey(scope: string, key: string): string {
|
||||||
|
return `${PREFIX}${scope}:${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _createLocalStorageAdapter(scope: string): LayoutPersistenceAdapter {
|
||||||
|
return {
|
||||||
|
scope,
|
||||||
|
load<T>(key: string): T | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(_buildKey(scope, key));
|
||||||
|
if (raw === null) return null;
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
save<T>(key: string, value: T): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(_buildKey(scope, key), JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
// localStorage full or unavailable — silently ignore for UI preferences
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
106
src/components/Layout/types.ts
Normal file
106
src/components/Layout/types.ts
Normal file
|
|
@ -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<string, string>;
|
||||||
|
syncUrl?: boolean;
|
||||||
|
lazy?: boolean;
|
||||||
|
onTabChange?: (tabId: string) => void;
|
||||||
|
className?: string;
|
||||||
|
/** Allow the tab bar to be collapsed into a single-line summary. */
|
||||||
|
collapsible?: boolean;
|
||||||
|
/** Persist collapse state under this key (localStorage). */
|
||||||
|
collapseKey?: string;
|
||||||
|
/** Start collapsed when no persisted state exists. */
|
||||||
|
defaultCollapsed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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: <T>(key: string) => T | null;
|
||||||
|
save: <T>(key: string, value: T) => void;
|
||||||
|
}
|
||||||
54
src/hooks/useScrollMode.ts
Normal file
54
src/hooks/useScrollMode.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
|
/**
|
||||||
|
* useScrollMode Hook
|
||||||
|
*
|
||||||
|
* Determines the layout scroll mode for the current viewport:
|
||||||
|
* - "bounded": Desktop with sufficient height. Content areas use overflow:hidden
|
||||||
|
* chains and tables scroll internally.
|
||||||
|
* - "document": Mobile or very short viewports. The overflow:hidden chain is
|
||||||
|
* broken so the page scrolls as a document, letting the header scroll away.
|
||||||
|
*
|
||||||
|
* Sets `data-scroll-mode` on <html> so every CSS module can react via
|
||||||
|
* `:global(html[data-scroll-mode="document"])`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
export type ScrollMode = 'bounded' | 'document';
|
||||||
|
|
||||||
|
const DOCUMENT_MODE_QUERY = '(max-width: 1024px), (max-height: 500px)';
|
||||||
|
|
||||||
|
function _evaluateScrollMode(): ScrollMode {
|
||||||
|
return window.matchMedia(DOCUMENT_MODE_QUERY).matches ? 'document' : 'bounded';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScrollMode(): ScrollMode {
|
||||||
|
const [mode, setMode] = useState<ScrollMode>(_evaluateScrollMode);
|
||||||
|
|
||||||
|
const _syncAttribute = useCallback((m: ScrollMode) => {
|
||||||
|
document.documentElement.dataset.scrollMode = m;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
_syncAttribute(mode);
|
||||||
|
}, [mode, _syncAttribute]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mql = window.matchMedia(DOCUMENT_MODE_QUERY);
|
||||||
|
const _handleChange = () => {
|
||||||
|
const next = _evaluateScrollMode();
|
||||||
|
setMode(next);
|
||||||
|
};
|
||||||
|
mql.addEventListener('change', _handleChange);
|
||||||
|
return () => mql.removeEventListener('change', _handleChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
delete document.documentElement.dataset.scrollMode;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
|
@ -135,13 +135,10 @@
|
||||||
letter-spacing: 0.025em;
|
letter-spacing: 0.025em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Feature Content */
|
/* Feature Content — viewContent owns padding, not featureContent */
|
||||||
.featureContent {
|
.featureContent {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
/* Let child components handle their own scrolling for sticky headers */
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 1.5rem;
|
|
||||||
/* Maintain flex chain for child components */
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|
@ -180,10 +177,35 @@
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.featureHeader {
|
.featureHeader {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.featureContent {
|
.breadcrumb {
|
||||||
padding: 1rem;
|
gap: 0.25rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mandateName {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mandateName + .separator {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleBadge {
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* scrollMode: document — layout grows with content, header scrolls away */
|
||||||
|
:global(html[data-scroll-mode="document"]) .featureLayout {
|
||||||
|
height: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html[data-scroll-mode="document"]) .featureContent {
|
||||||
|
overflow: visible;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -91,12 +91,10 @@ export const FeatureLayout: React.FC = () => {
|
||||||
};
|
};
|
||||||
}, [dynamicBlock, mandateId, featureCode, instanceId]);
|
}, [dynamicBlock, mandateId, featureCode, instanceId]);
|
||||||
|
|
||||||
// Warten bis Features geladen sind
|
|
||||||
if (!initialized || loading || isLoading) {
|
if (!initialized || loading || isLoading) {
|
||||||
return <LoadingScreen />;
|
return <LoadingScreen />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prüfen ob Instanz existiert und gültig ist
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
console.warn('FeatureLayout: Invalid instance context', {
|
console.warn('FeatureLayout: Invalid instance context', {
|
||||||
path: location.pathname,
|
path: location.pathname,
|
||||||
|
|
@ -112,10 +110,8 @@ export const FeatureLayout: React.FC = () => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alles OK - rendere Content
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.featureLayout}>
|
<div className={styles.featureLayout}>
|
||||||
{/* Header mit Instanz-Info */}
|
|
||||||
<header className={styles.featureHeader}>
|
<header className={styles.featureHeader}>
|
||||||
<div className={styles.breadcrumb}>
|
<div className={styles.breadcrumb}>
|
||||||
<span className={styles.mandateName}>
|
<span className={styles.mandateName}>
|
||||||
|
|
@ -131,7 +127,6 @@ export const FeatureLayout: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Content Area */}
|
|
||||||
<main className={styles.featureContent}>
|
<main className={styles.featureContent}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -148,10 +143,6 @@ interface ProtectedFeatureRouteProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrapper für geschützte Feature-Routes
|
|
||||||
* Prüft zusätzlich View-Berechtigungen
|
|
||||||
*/
|
|
||||||
export const ProtectedFeatureRoute: React.FC<ProtectedFeatureRouteProps> = ({
|
export const ProtectedFeatureRoute: React.FC<ProtectedFeatureRouteProps> = ({
|
||||||
requiredView,
|
requiredView,
|
||||||
children,
|
children,
|
||||||
|
|
@ -163,7 +154,6 @@ export const ProtectedFeatureRoute: React.FC<ProtectedFeatureRouteProps> = ({
|
||||||
return <Navigate to="/" replace />;
|
return <Navigate to="/" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prüfe View-Berechtigung wenn erforderlich
|
|
||||||
if (requiredView) {
|
if (requiredView) {
|
||||||
const hasViewAccess = instance?.permissions?.views?.[requiredView] ?? false;
|
const hasViewAccess = instance?.permissions?.views?.[requiredView] ?? false;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,16 @@
|
||||||
-webkit-overflow-scrolling: touch;
|
-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 {
|
.mobileTopBar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { RagRunningBadge } from '../components/RagRunningBadge/RagRunningBadge';
|
||||||
import { KEEP_ALIVE_ROUTES, hideFeatureOutlet } from '../config/keepAliveRoutes';
|
import { KEEP_ALIVE_ROUTES, hideFeatureOutlet } from '../config/keepAliveRoutes';
|
||||||
import type { KeepAliveEntry, KeepAliveScopedEntry, KeepAliveUnscopedEntry } from '../types/keepAlive.types';
|
import type { KeepAliveEntry, KeepAliveScopedEntry, KeepAliveUnscopedEntry } from '../types/keepAlive.types';
|
||||||
import { isKeepAliveScoped } from '../types/keepAlive.types';
|
import { isKeepAliveScoped } from '../types/keepAlive.types';
|
||||||
|
import { useScrollMode } from '../hooks/useScrollMode';
|
||||||
import styles from './MainLayout.module.css';
|
import styles from './MainLayout.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
|
|
@ -105,6 +106,7 @@ const RoutedKeepAliveSlot: React.FC<{ entry: KeepAliveEntry; pathname: string; s
|
||||||
|
|
||||||
const MainLayoutInner: React.FC = () => {
|
const MainLayoutInner: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const scrollMode = useScrollMode();
|
||||||
|
|
||||||
const { loadFeatures, initialized, loading, error } = useFeatureStore();
|
const { loadFeatures, initialized, loading, error } = useFeatureStore();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
@ -166,7 +168,7 @@ const MainLayoutInner: React.FC = () => {
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<main className={styles.content}>
|
<main className={styles.content} data-scroll-mode={scrollMode}>
|
||||||
<div className={styles.mobileTopBar}>
|
<div className={styles.mobileTopBar}>
|
||||||
<button
|
<button
|
||||||
className={styles.mobileMenuButton}
|
className={styles.mobileMenuButton}
|
||||||
|
|
|
||||||
|
|
@ -112,3 +112,14 @@
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* scrollMode: document — view grows with content, no internal scroll */
|
||||||
|
:global(html[data-scroll-mode="document"]) .featureView {
|
||||||
|
height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html[data-scroll-mode="document"]) .viewContent {
|
||||||
|
overflow: visible;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,9 @@ import { CommcoachDashboardView, CommcoachAssistantView, CommcoachModulesView, C
|
||||||
// Redmine Views
|
// Redmine Views
|
||||||
import { RedmineSettingsView, RedmineStatsView, RedmineBrowserView } from './views/redmine';
|
import { RedmineSettingsView, RedmineStatsView, RedmineBrowserView } from './views/redmine';
|
||||||
|
|
||||||
|
// Solutions View (Layout Foundation MVP)
|
||||||
|
import { SolutionsView } from './views/solutions/SolutionsView';
|
||||||
|
|
||||||
import styles from './FeatureView.module.css';
|
import styles from './FeatureView.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
|
|
@ -112,6 +115,7 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
||||||
trustee: {
|
trustee: {
|
||||||
dashboard: TrusteeDashboardView,
|
dashboard: TrusteeDashboardView,
|
||||||
'data-tables': TrusteeDataTablesView,
|
'data-tables': TrusteeDataTablesView,
|
||||||
|
solutions: SolutionsView,
|
||||||
'instance-roles': TrusteeInstanceRolesView,
|
'instance-roles': TrusteeInstanceRolesView,
|
||||||
'import-process': TrusteeImportProcessView,
|
'import-process': TrusteeImportProcessView,
|
||||||
settings: TrusteeAccountingSettingsView,
|
settings: TrusteeAccountingSettingsView,
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,9 @@
|
||||||
.adminPage.adminPageFill {
|
.adminPage.adminPageFill {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
/* visible: let the table's min-height overflow to the scroll ancestor on
|
||||||
|
short viewports (scrollbar instead of clipped table). */
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pageHeader {
|
.pageHeader {
|
||||||
|
|
@ -214,7 +216,8 @@
|
||||||
.tableContainer {
|
.tableContainer {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
/* visible: see .adminPageFill — table min-height must reach the scrollbar. */
|
||||||
|
overflow: visible;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
@ -750,6 +753,17 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* scrollMode: document — table grows to natural height, page scrolls */
|
||||||
|
:global(html[data-scroll-mode="document"]) .adminPage.adminPageFill {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html[data-scroll-mode="document"]) .tableContainer {
|
||||||
|
overflow: visible;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.adminPage {
|
.adminPage {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
|
|
|
||||||
30
src/pages/views/solutions/SolutionsView.module.css
Normal file
30
src/pages/views/solutions/SolutionsView.module.css
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
.placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 200px;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder h3 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder p {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .placeholder h3 {
|
||||||
|
color: var(--text-primary-dark, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .placeholder p {
|
||||||
|
color: var(--text-secondary-dark, #aaa);
|
||||||
|
}
|
||||||
128
src/pages/views/solutions/SolutionsView.tsx
Normal file
128
src/pages/views/solutions/SolutionsView.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
|
/**
|
||||||
|
* SolutionsView — MVP scaffold for the L3/L4 Solutions surface.
|
||||||
|
*
|
||||||
|
* First consumer of the Layout primitives (StackLayout, ViewStack, LayoutTabs).
|
||||||
|
* Backend Solution model + API endpoints are planned but not yet implemented;
|
||||||
|
* this component provides the UI shell and will be wired to real data once
|
||||||
|
* the backend is ready.
|
||||||
|
*
|
||||||
|
* Target URL contract:
|
||||||
|
* List: /…/solutions
|
||||||
|
* Detail: /…/solutions?solutionId=<uuid>&tab=settings
|
||||||
|
* Run: /…/solutions?solutionId=<uuid>&tab=runs&runId=<uuid>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { StackLayout } from '../../../components/Layout/StackLayout';
|
||||||
|
import { LayoutTabs } from '../../../components/Layout/LayoutTabs';
|
||||||
|
import ViewStack from '../../../components/Layout/ViewStack';
|
||||||
|
import type { LayoutTabItem } from '../../../components/Layout/types';
|
||||||
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import styles from './SolutionsView.module.css';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Placeholder bodies (wired to real components when backend is ready)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const _SolutionsListBody: React.FC = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
return (
|
||||||
|
<div className={styles.placeholder}>
|
||||||
|
<h3>{t('Lösungen')}</h3>
|
||||||
|
<p>{t('Hier erscheint die Liste aller konfigurierten Lösungen.')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const _SolutionCatalogBody: React.FC = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
return (
|
||||||
|
<div className={styles.placeholder}>
|
||||||
|
<h3>{t('Lösungskatalog')}</h3>
|
||||||
|
<p>{t('Verfügbare Lösungs-Vorlagen aus dem System-Katalog.')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const _SolutionSettingsForm: React.FC = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
return (
|
||||||
|
<div className={styles.placeholder}>
|
||||||
|
<p>{t('Einstellungen werden aus settingsSchema via FormGeneratorForm gerendert.')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const _SolutionTestRun: React.FC = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
return <div className={styles.placeholder}><p>{t('Testlauf-Oberfläche')}</p></div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const _SolutionRuns: React.FC = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
return <div className={styles.placeholder}><p>{t('Durchlauf-Historie')}</p></div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const _SolutionOutput: React.FC = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
return <div className={styles.placeholder}><p>{t('Ausgabe je outputBinding.kind (file/table/summary)')}</p></div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Detail tabs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _buildDetailTabs(t: (k: string) => string): LayoutTabItem[] {
|
||||||
|
return [
|
||||||
|
{ id: 'settings', label: t('Einstellungen'), render: () => <_SolutionSettingsForm /> },
|
||||||
|
{ id: 'test', label: t('Testlauf'), render: () => <_SolutionTestRun /> },
|
||||||
|
{ id: 'runs', label: t('Läufe'), render: () => <_SolutionRuns /> },
|
||||||
|
{ id: 'output', label: t('Ausgabe'), render: () => <_SolutionOutput /> },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SolutionsView
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const SolutionsView: React.FC = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const detailTabs = React.useMemo(() => _buildDetailTabs(t), [t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StackLayout variant="scroll">
|
||||||
|
<StackLayout.Body>
|
||||||
|
<ViewStack viewParam="view" entityParam="solutionId" defaultView="list">
|
||||||
|
<ViewStack.View
|
||||||
|
id="list"
|
||||||
|
title={t('Lösungen')}
|
||||||
|
>
|
||||||
|
<_SolutionsListBody />
|
||||||
|
</ViewStack.View>
|
||||||
|
|
||||||
|
<ViewStack.View
|
||||||
|
id="catalog"
|
||||||
|
>
|
||||||
|
<_SolutionCatalogBody />
|
||||||
|
</ViewStack.View>
|
||||||
|
|
||||||
|
<ViewStack.View
|
||||||
|
id="detail"
|
||||||
|
title={t('Lösung')}
|
||||||
|
backLabel={t('Zurück zu Lösungen')}
|
||||||
|
>
|
||||||
|
<LayoutTabs
|
||||||
|
items={detailTabs}
|
||||||
|
urlParam="tab"
|
||||||
|
preserveSearchParams
|
||||||
|
/>
|
||||||
|
</ViewStack.View>
|
||||||
|
</ViewStack>
|
||||||
|
</StackLayout.Body>
|
||||||
|
</StackLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SolutionsView;
|
||||||
|
|
@ -22,14 +22,12 @@
|
||||||
* - Tab state lives in `?tab=<key>` so deep links from QuickActions /
|
* - Tab state lives in `?tab=<key>` so deep links from QuickActions /
|
||||||
* notifications / docs stay stable.
|
* notifications / docs stay stable.
|
||||||
*
|
*
|
||||||
* Layout / sizing: see `wiki/b-reference/frontend-nyla/formgenerator.md`
|
* Layout / sizing: see `wiki/b-reference/ui-nyla/layout.md`. The page uses
|
||||||
* ("Page Layout Chain"). Outer is `adminPage + adminPageFill`, active tab
|
* `StackLayout variant="table"` with `LayoutTabs`; each tab body is a `table`
|
||||||
* sits inside `tableContainer`, which provides the bounded height chain
|
* Panel that provides the bounded height chain `FormGeneratorTable` requires.
|
||||||
* `FormGeneratorTable` requires.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import {
|
import {
|
||||||
useTrusteeOrganisations,
|
useTrusteeOrganisations,
|
||||||
|
|
@ -52,7 +50,9 @@ import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
import { TrusteeDataTab } from './dataTables/TrusteeDataTab';
|
import { TrusteeDataTab } from './dataTables/TrusteeDataTab';
|
||||||
import { TrusteePositionsView } from './TrusteePositionsView';
|
import { TrusteePositionsView } from './TrusteePositionsView';
|
||||||
import { TrusteeDocumentsView } from './TrusteeDocumentsView';
|
import { TrusteeDocumentsView } from './TrusteeDocumentsView';
|
||||||
import adminStyles from '../../admin/Admin.module.css';
|
import { StackLayout } from '../../../components/Layout/StackLayout';
|
||||||
|
import { LayoutTabs } from '../../../components/Layout/LayoutTabs';
|
||||||
|
import type { LayoutTabItem } from '../../../components/Layout/types';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tab definitions
|
// Tab definitions
|
||||||
|
|
@ -64,7 +64,6 @@ interface TabDef {
|
||||||
label: string;
|
label: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
color: string;
|
color: string;
|
||||||
readOnly: boolean;
|
|
||||||
Wrapper: React.FC<{ instanceId: string }>;
|
Wrapper: React.FC<{ instanceId: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -156,10 +155,10 @@ function _buildTabGroups(t: (k: string) => string): TabGroupDef[] {
|
||||||
label: t('Stammdaten'),
|
label: t('Stammdaten'),
|
||||||
color: '#1976d2',
|
color: '#1976d2',
|
||||||
tabs: [
|
tabs: [
|
||||||
{ id: 'organisations', entityName: 'TrusteeOrganisation', label: t('Organisation'), icon: '\uD83C\uDFE2', color: '#1976d2', readOnly: false, Wrapper: _OrganisationsWrapper },
|
{ id: 'organisations', entityName: 'TrusteeOrganisation', label: t('Organisation'), icon: '\uD83C\uDFE2', color: '#1976d2', Wrapper: _OrganisationsWrapper },
|
||||||
{ id: 'roles', entityName: 'TrusteeRole', label: t('Rolle'), icon: '\uD83D\uDC65', color: '#0277bd', readOnly: false, Wrapper: _RolesWrapper },
|
{ id: 'roles', entityName: 'TrusteeRole', label: t('Rolle'), icon: '\uD83D\uDC65', color: '#0277bd', Wrapper: _RolesWrapper },
|
||||||
{ id: 'access', entityName: 'TrusteeAccess', label: t('Zugriff'), icon: '\uD83D\uDD11', color: '#0288d1', readOnly: false, Wrapper: _AccessWrapper },
|
{ id: 'access', entityName: 'TrusteeAccess', label: t('Zugriff'), icon: '\uD83D\uDD11', color: '#0288d1', Wrapper: _AccessWrapper },
|
||||||
{ id: 'contracts', entityName: 'TrusteeContract', label: t('Vertrag'), icon: '\uD83D\uDCDC', color: '#00796b', readOnly: false, Wrapper: _ContractsWrapper },
|
{ id: 'contracts', entityName: 'TrusteeContract', label: t('Vertrag'), icon: '\uD83D\uDCDC', color: '#00796b', Wrapper: _ContractsWrapper },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -167,8 +166,8 @@ function _buildTabGroups(t: (k: string) => string): TabGroupDef[] {
|
||||||
label: t('Lokale Daten'),
|
label: t('Lokale Daten'),
|
||||||
color: '#388e3c',
|
color: '#388e3c',
|
||||||
tabs: [
|
tabs: [
|
||||||
{ id: 'documents', entityName: 'TrusteeDocument', label: t('Dokument'), icon: '\uD83D\uDCC4', color: '#388e3c', readOnly: false, Wrapper: _DocumentsWrapper },
|
{ id: 'documents', entityName: 'TrusteeDocument', label: t('Dokument'), icon: '\uD83D\uDCC4', color: '#388e3c', Wrapper: _DocumentsWrapper },
|
||||||
{ id: 'positions', entityName: 'TrusteePosition', label: t('Position'), icon: '\uD83D\uDCCA', color: '#43a047', readOnly: false, Wrapper: _PositionsWrapper },
|
{ id: 'positions', entityName: 'TrusteePosition', label: t('Position'), icon: '\uD83D\uDCCA', color: '#43a047', Wrapper: _PositionsWrapper },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -176,8 +175,8 @@ function _buildTabGroups(t: (k: string) => string): TabGroupDef[] {
|
||||||
label: t('Konfiguration'),
|
label: t('Konfiguration'),
|
||||||
color: '#5e35b1',
|
color: '#5e35b1',
|
||||||
tabs: [
|
tabs: [
|
||||||
{ id: 'accounting-configs', entityName: 'TrusteeAccountingConfig', label: t('Buchhaltungs-Verbindung'), icon: '\u2699\uFE0F', color: '#5e35b1', readOnly: true, Wrapper: _AccountingConfigsWrapper },
|
{ id: 'accounting-configs', entityName: 'TrusteeAccountingConfig', label: t('Buchhaltungs-Verbindung'), icon: '\u2699\uFE0F', color: '#5e35b1', Wrapper: _AccountingConfigsWrapper },
|
||||||
{ id: 'accounting-syncs', entityName: 'TrusteeAccountingSync', label: t('Sync-Protokoll'), icon: '\uD83D\uDD04', color: '#3949ab', readOnly: true, Wrapper: _AccountingSyncsWrapper },
|
{ id: 'accounting-syncs', entityName: 'TrusteeAccountingSync', label: t('Sync-Protokoll'), icon: '\uD83D\uDD04', color: '#3949ab', Wrapper: _AccountingSyncsWrapper },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -185,16 +184,35 @@ function _buildTabGroups(t: (k: string) => string): TabGroupDef[] {
|
||||||
label: t('Daten aus Buchhaltungssystem'),
|
label: t('Daten aus Buchhaltungssystem'),
|
||||||
color: '#ef6c00',
|
color: '#ef6c00',
|
||||||
tabs: [
|
tabs: [
|
||||||
{ id: 'accounts', entityName: 'TrusteeDataAccount', label: t('Kontenplan'), icon: '\uD83D\uDCD2', color: '#f57c00', readOnly: true, Wrapper: _DataAccountsWrapper },
|
{ id: 'accounts', entityName: 'TrusteeDataAccount', label: t('Kontenplan'), icon: '\uD83D\uDCD2', color: '#f57c00', Wrapper: _DataAccountsWrapper },
|
||||||
{ id: 'journal-entries', entityName: 'TrusteeDataJournalEntry', label: t('Buchungen'), icon: '\uD83D\uDCDD', color: '#ef6c00', readOnly: true, Wrapper: _DataJournalEntriesWrapper },
|
{ id: 'journal-entries', entityName: 'TrusteeDataJournalEntry', label: t('Buchungen'), icon: '\uD83D\uDCDD', color: '#ef6c00', Wrapper: _DataJournalEntriesWrapper },
|
||||||
{ id: 'journal-lines', entityName: 'TrusteeDataJournalLine', label: t('Buchungszeilen'), icon: '\uD83D\uDCC3', color: '#e65100', readOnly: true, Wrapper: _DataJournalLinesWrapper },
|
{ id: 'journal-lines', entityName: 'TrusteeDataJournalLine', label: t('Buchungszeilen'), icon: '\uD83D\uDCC3', color: '#e65100', Wrapper: _DataJournalLinesWrapper },
|
||||||
{ id: 'contacts', entityName: 'TrusteeDataContact', label: t('Kontakte'), icon: '\uD83D\uDC64', color: '#c2185b', readOnly: true, Wrapper: _DataContactsWrapper },
|
{ id: 'contacts', entityName: 'TrusteeDataContact', label: t('Kontakte'), icon: '\uD83D\uDC64', color: '#c2185b', Wrapper: _DataContactsWrapper },
|
||||||
{ id: 'account-balances', entityName: 'TrusteeDataAccountBalance', label: t('Kontosalden'), icon: '\uD83D\uDCB0', color: '#ad1457', readOnly: true, Wrapper: _DataAccountBalancesWrapper },
|
{ id: 'account-balances', entityName: 'TrusteeDataAccountBalance', label: t('Kontosalden'), icon: '\uD83D\uDCB0', color: '#ad1457', Wrapper: _DataAccountBalancesWrapper },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TabDef → LayoutTabItem conversion
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _toLayoutTabItems(
|
||||||
|
tabGroups: TabGroupDef[],
|
||||||
|
instanceId: string,
|
||||||
|
): LayoutTabItem[] {
|
||||||
|
return tabGroups.flatMap((group) =>
|
||||||
|
group.tabs.map((tab): LayoutTabItem => ({
|
||||||
|
id: tab.id,
|
||||||
|
label: tab.label,
|
||||||
|
group: group.label,
|
||||||
|
icon: <span aria-hidden="true">{tab.icon}</span>,
|
||||||
|
render: () => <tab.Wrapper instanceId={instanceId} />,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Component
|
// Component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -202,147 +220,48 @@ function _buildTabGroups(t: (k: string) => string): TabGroupDef[] {
|
||||||
export const TrusteeDataTablesView: React.FC = () => {
|
export const TrusteeDataTablesView: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const instanceId = useInstanceId();
|
const instanceId = useInstanceId();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
|
|
||||||
const tabGroups = useMemo(() => _buildTabGroups(t), [t]);
|
const tabGroups = useMemo(() => _buildTabGroups(t), [t]);
|
||||||
const visibleTabs = useMemo(() => tabGroups.flatMap((g) => g.tabs), [tabGroups]);
|
const layoutTabItems = useMemo(
|
||||||
|
() => (instanceId ? _toLayoutTabItems(tabGroups, instanceId) : []),
|
||||||
const requestedTab = searchParams.get('tab');
|
[tabGroups, instanceId],
|
||||||
const activeTab = useMemo(() => {
|
);
|
||||||
if (requestedTab && visibleTabs.some((tab) => tab.id === requestedTab)) {
|
|
||||||
return requestedTab;
|
|
||||||
}
|
|
||||||
return visibleTabs[0]?.id || '';
|
|
||||||
}, [requestedTab, visibleTabs]);
|
|
||||||
|
|
||||||
const _setActiveTab = useCallback((tabId: string) => {
|
|
||||||
setSearchParams({ tab: tabId }, { replace: true });
|
|
||||||
}, [setSearchParams]);
|
|
||||||
|
|
||||||
const currentTab = visibleTabs.find((tab) => tab.id === activeTab) || visibleTabs[0];
|
|
||||||
|
|
||||||
if (!instanceId) {
|
if (!instanceId) {
|
||||||
return (
|
return (
|
||||||
<div className={`${adminStyles.adminPage} ${adminStyles.adminPageFill}`}>
|
<StackLayout variant="table">
|
||||||
<p>{t('Instanz wird geladen…')}</p>
|
<StackLayout.Body><p>{t('Instanz wird geladen…')}</p></StackLayout.Body>
|
||||||
</div>
|
</StackLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentTab) {
|
if (!layoutTabItems.length) {
|
||||||
return (
|
return (
|
||||||
<div className={`${adminStyles.adminPage} ${adminStyles.adminPageFill}`}>
|
<StackLayout variant="table">
|
||||||
<div className={adminStyles.pageHeader}>
|
<StackLayout.Header>
|
||||||
<div>
|
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, margin: 0 }}>{t('Daten-Tabellen')}</h1>
|
||||||
<h1 className={adminStyles.pageTitle}>{t('Daten-Tabellen')}</h1>
|
</StackLayout.Header>
|
||||||
</div>
|
<StackLayout.Body><p>{t('Du hast keine Berechtigung für')}</p></StackLayout.Body>
|
||||||
</div>
|
</StackLayout>
|
||||||
<p>{t('Du hast keine Berechtigung für')}</p>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ActiveWrapper = currentTab.Wrapper;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${adminStyles.adminPage} ${adminStyles.adminPageFill}`}>
|
<StackLayout variant="table">
|
||||||
<div className={adminStyles.pageHeader}>
|
<StackLayout.Header>
|
||||||
<div>
|
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, margin: 0 }}>{t('Daten-Tabellen')}</h1>
|
||||||
<h1 className={adminStyles.pageTitle}>{t('Daten-Tabellen')}</h1>
|
</StackLayout.Header>
|
||||||
<p className={adminStyles.pageSubtitle}>
|
<StackLayout.Body>
|
||||||
{t('Alle Datenbanktabellen dieser Trustee-Instanz auf einen Blick.')}
|
<LayoutTabs
|
||||||
</p>
|
items={layoutTabItems}
|
||||||
</div>
|
urlParam="tab"
|
||||||
</div>
|
preserveSearchParams
|
||||||
|
lazy
|
||||||
<div
|
collapsible
|
||||||
style={{
|
collapseKey="trustee-data-tables-tabs"
|
||||||
display: 'flex',
|
/>
|
||||||
flexDirection: 'column',
|
</StackLayout.Body>
|
||||||
gap: '0.5rem',
|
</StackLayout>
|
||||||
marginBottom: '1rem',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tabGroups.map((group) => (
|
|
||||||
<div
|
|
||||||
key={group.id}
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: '11rem 1fr',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '0.75rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '0.6875rem',
|
|
||||||
fontWeight: 600,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: '0.05em',
|
|
||||||
color: group.color,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
borderLeft: `3px solid ${group.color}`,
|
|
||||||
paddingLeft: '0.5rem',
|
|
||||||
}}
|
|
||||||
title={group.label}
|
|
||||||
>
|
|
||||||
{group.label}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: '0.25rem',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{group.tabs.map((tab) => {
|
|
||||||
const isActive = activeTab === tab.id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => _setActiveTab(tab.id)}
|
|
||||||
title={tab.readOnly ? t('Nur lesen – Daten kommen aus dem Sync.') : tab.label}
|
|
||||||
style={{
|
|
||||||
padding: '0.375rem 0.75rem',
|
|
||||||
border: `1px solid ${isActive ? tab.color : 'var(--border-color, #e0e0e0)'}`,
|
|
||||||
borderRadius: 4,
|
|
||||||
background: isActive ? `${tab.color}15` : 'transparent',
|
|
||||||
color: isActive ? tab.color : 'var(--text-secondary, #555)',
|
|
||||||
fontWeight: isActive ? 600 : 400,
|
|
||||||
fontSize: '0.8125rem',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.15s',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '0.375rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span aria-hidden="true">{tab.icon}</span>
|
|
||||||
<span>{tab.label}</span>
|
|
||||||
{tab.readOnly && (
|
|
||||||
<span
|
|
||||||
aria-label={t('Nur lesen')}
|
|
||||||
style={{ fontSize: '0.75rem', opacity: 0.7, lineHeight: 1 }}
|
|
||||||
>
|
|
||||||
{'\uD83D\uDD12'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={adminStyles.tableContainer}>
|
|
||||||
<ActiveWrapper instanceId={instanceId} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -256,6 +256,11 @@ export const WorkspaceInput = forwardRef<WorkspaceInputHandle, WorkspaceInputPro
|
||||||
}
|
}
|
||||||
}, [workflowId, _persistAttachments, attachedDataSourceIds, attachedFeatureDataSourceIds]);
|
}, [workflowId, _persistAttachments, attachedDataSourceIds, attachedFeatureDataSourceIds]);
|
||||||
|
|
||||||
|
// Hydrate the chip-bar from the chat load. The backend resolves labels and
|
||||||
|
// filters deleted sources before responding (getWorkspaceMessages), so the
|
||||||
|
// loaded IDs can be rendered as-is -- no client-side reconciliation against
|
||||||
|
// the asynchronously loaded dataSources lists (that was a race that
|
||||||
|
// intermittently showed raw UUIDs or dropped valid attachments).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loadedNonce === undefined) return;
|
if (loadedNonce === undefined) return;
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
|
|
@ -263,39 +268,6 @@ export const WorkspaceInput = forwardRef<WorkspaceInputHandle, WorkspaceInputPro
|
||||||
setAttachedFeatureDataSourceIds(Array.isArray(loadedAttachedFeatureDataSourceIds) ? [...loadedAttachedFeatureDataSourceIds] : []);
|
setAttachedFeatureDataSourceIds(Array.isArray(loadedAttachedFeatureDataSourceIds) ? [...loadedAttachedFeatureDataSourceIds] : []);
|
||||||
}, [loadedNonce, loadedAttachedDataSourceIds, loadedAttachedFeatureDataSourceIds]);
|
}, [loadedNonce, loadedAttachedDataSourceIds, loadedAttachedFeatureDataSourceIds]);
|
||||||
|
|
||||||
const _reconciledDsForNonce = useRef<number | undefined>(undefined);
|
|
||||||
const _reconciledFdsForNonce = useRef<number | undefined>(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 promptBeforeVoiceRef = useRef('');
|
||||||
const finalizedTextRef = useRef('');
|
const finalizedTextRef = useRef('');
|
||||||
const currentInterimRef = useRef('');
|
const currentInterimRef = useRef('');
|
||||||
|
|
@ -731,7 +703,7 @@ export const WorkspaceInput = forwardRef<WorkspaceInputHandle, WorkspaceInputPro
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ display: 'flex', alignItems: 'center', fontSize: 12 }}>{fdsIcon || '\uD83D\uDDC3\uFE0F'}</span>
|
<span style={{ display: 'flex', alignItems: 'center', fontSize: 12 }}>{fdsIcon || '\uD83D\uDDC3\uFE0F'}</span>
|
||||||
{fds?.label || fdsLabelMap.get(fdsId) || fdsId} – {fds?.tableName || ''}
|
{fds?.label || fdsLabelMap.get(fdsId) || fdsId}{fds?.tableName ? ` – ${fds.tableName}` : ''}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => _toggleFeatureDataSource(fdsId)}
|
onClick={() => _toggleFeatureDataSource(fdsId)}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* WorkflowAutomationHubPage
|
* WorkflowAutomationHubPage
|
||||||
*
|
*
|
||||||
* System-level hub for WorkflowAutomation (user-scoped, cross-mandate).
|
* System-level hub for WorkflowAutomation (user-scoped, cross-mandate).
|
||||||
* Tabs: Workflows · Editor · Vorlagen · Läufe · Details · Aufgaben
|
* Tabs: Workflows · Editor · Vorlagen · Läufe · Aufgaben
|
||||||
*
|
*
|
||||||
* Uses /api/workflow-automation/* endpoints (RBAC-filtered).
|
* Uses /api/workflow-automation/* endpoints (RBAC-filtered).
|
||||||
* Kontext-Selector: "Alle Mandanten" or a specific mandate (like BillingDataView).
|
* Kontext-Selector: "Alle Mandanten" or a specific mandate (like BillingDataView).
|
||||||
|
|
@ -12,10 +12,12 @@
|
||||||
|
|
||||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Tabs } from '../../components/UiComponents/Tabs';
|
import { StackLayout } from '../../components/Layout/StackLayout';
|
||||||
|
import { LayoutTabs } from '../../components/Layout/LayoutTabs';
|
||||||
|
import ViewStack from '../../components/Layout/ViewStack';
|
||||||
|
import type { LayoutTabItem } from '../../components/Layout/types';
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
import { useNavigation } from '../../hooks/useNavigation';
|
import { useNavigation } from '../../hooks/useNavigation';
|
||||||
import styles from '../admin/Admin.module.css';
|
|
||||||
|
|
||||||
import { _WorkflowsTab } from './tabs/WorkflowsTab';
|
import { _WorkflowsTab } from './tabs/WorkflowsTab';
|
||||||
import { _EditorTab } from './tabs/EditorTab';
|
import { _EditorTab } from './tabs/EditorTab';
|
||||||
|
|
@ -26,29 +28,25 @@ import { _TasksTab } from './tabs/TasksTab';
|
||||||
|
|
||||||
const _TAB_ALIASES: Record<string, string> = {
|
const _TAB_ALIASES: Record<string, string> = {
|
||||||
dashboard: 'runs',
|
dashboard: 'runs',
|
||||||
workspace: 'detail',
|
workspace: 'runs',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WorkflowAutomationPage: React.FC = () => {
|
export const WorkflowAutomationPage: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { dynamicBlock } = useNavigation();
|
const { dynamicBlock } = useNavigation();
|
||||||
|
|
||||||
const rawTab = searchParams.get('tab') || 'workflows';
|
|
||||||
const initialTab = _TAB_ALIASES[rawTab] || rawTab;
|
|
||||||
const initialRunId = searchParams.get('runId') || null;
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<string>(initialRunId ? 'detail' : initialTab);
|
|
||||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(initialRunId);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const newTab = _TAB_ALIASES[searchParams.get('tab') || 'workflows'] || searchParams.get('tab') || 'workflows';
|
|
||||||
if (newTab !== activeTab) {
|
|
||||||
setActiveTab(newTab);
|
|
||||||
}
|
|
||||||
}, [searchParams]);
|
|
||||||
const [workflowFilter, setWorkflowFilter] = useState<string | null>(null);
|
const [workflowFilter, setWorkflowFilter] = useState<string | null>(null);
|
||||||
const [selectedMandateId, setSelectedMandateId] = useState<string>('all');
|
const selectedMandateId = searchParams.get('context') || 'all';
|
||||||
|
|
||||||
|
const _handleContextChange = useCallback((value: string) => {
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
if (value === 'all') next.delete('context');
|
||||||
|
else next.set('context', value);
|
||||||
|
return next;
|
||||||
|
}, { replace: true });
|
||||||
|
}, [setSearchParams]);
|
||||||
|
|
||||||
const _mandateOptions = useMemo(() => {
|
const _mandateOptions = useMemo(() => {
|
||||||
const options: Array<{ value: string; label: string }> = [
|
const options: Array<{ value: string; label: string }> = [
|
||||||
|
|
@ -64,65 +62,82 @@ export const WorkflowAutomationPage: React.FC = () => {
|
||||||
|
|
||||||
const _handleWorkflowClick = useCallback((workflowId: string) => {
|
const _handleWorkflowClick = useCallback((workflowId: string) => {
|
||||||
setWorkflowFilter(workflowId);
|
setWorkflowFilter(workflowId);
|
||||||
setActiveTab('runs');
|
setSearchParams((prev) => {
|
||||||
}, []);
|
const next = new URLSearchParams(prev);
|
||||||
|
next.set('tab', 'runs');
|
||||||
|
return next;
|
||||||
|
}, { replace: true });
|
||||||
|
}, [setSearchParams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (workflowFilter) setWorkflowFilter(null);
|
if (workflowFilter) setWorkflowFilter(null);
|
||||||
}, [workflowFilter]);
|
}, [workflowFilter]);
|
||||||
|
|
||||||
const _handleRunClick = useCallback((runId: string) => {
|
const _handleRunClick = useCallback((runId: string) => {
|
||||||
setSelectedRunId(runId);
|
setSearchParams((prev) => {
|
||||||
setActiveTab('detail');
|
const next = new URLSearchParams(prev);
|
||||||
}, []);
|
next.set('runId', runId);
|
||||||
|
return next;
|
||||||
|
}, { replace: true });
|
||||||
|
}, [setSearchParams]);
|
||||||
|
|
||||||
const _handleBackFromWorkspace = useCallback(() => {
|
const _handleBackFromDetail = useCallback(() => {
|
||||||
setSelectedRunId(null);
|
setSearchParams((prev) => {
|
||||||
setActiveTab('runs');
|
const next = new URLSearchParams(prev);
|
||||||
}, []);
|
next.delete('runId');
|
||||||
|
return next;
|
||||||
|
}, { replace: true });
|
||||||
|
}, [setSearchParams]);
|
||||||
|
|
||||||
const tabs = useMemo(() => [
|
const selectedRunId = searchParams.get('runId') || null;
|
||||||
|
|
||||||
|
const tabs: LayoutTabItem[] = useMemo(() => [
|
||||||
{
|
{
|
||||||
id: 'workflows',
|
id: 'workflows',
|
||||||
label: t('Workflows'),
|
label: t('Workflows'),
|
||||||
content: <_WorkflowsTab onWorkflowClick={_handleWorkflowClick} selectedMandateId={selectedMandateId} />,
|
render: () => <_WorkflowsTab onWorkflowClick={_handleWorkflowClick} selectedMandateId={selectedMandateId} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'editor',
|
id: 'editor',
|
||||||
label: t('Editor'),
|
label: t('Editor'),
|
||||||
content: <_EditorTab selectedMandateId={selectedMandateId} />,
|
render: () => <_EditorTab selectedMandateId={selectedMandateId} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'templates',
|
id: 'templates',
|
||||||
label: t('Vorlagen'),
|
label: t('Vorlagen'),
|
||||||
content: <_TemplatesTab selectedMandateId={selectedMandateId} />,
|
render: () => <_TemplatesTab selectedMandateId={selectedMandateId} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'runs',
|
id: 'runs',
|
||||||
label: t('Workflow-Durchläufe'),
|
label: t('Workflow-Durchläufe'),
|
||||||
content: <_RunsTab workflowFilter={workflowFilter} onRunClick={_handleRunClick} selectedMandateId={selectedMandateId} />,
|
render: () => (
|
||||||
},
|
<ViewStack entityParam="runId">
|
||||||
{
|
<ViewStack.View id="list">
|
||||||
id: 'detail',
|
<_RunsTab workflowFilter={workflowFilter} onRunClick={_handleRunClick} selectedMandateId={selectedMandateId} />
|
||||||
label: t('Durchlauf-Details'),
|
</ViewStack.View>
|
||||||
content: <_RunDetailTab runId={selectedRunId} onBack={_handleBackFromWorkspace} />,
|
<ViewStack.View id="detail" backLabel={t('Zurück zu Läufe')} title={t('Durchlauf-Details')}>
|
||||||
|
<_RunDetailTab runId={selectedRunId} onBack={_handleBackFromDetail} />
|
||||||
|
</ViewStack.View>
|
||||||
|
</ViewStack>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tasks',
|
id: 'tasks',
|
||||||
label: t('Aufgaben'),
|
label: t('Aufgaben'),
|
||||||
content: <_TasksTab selectedMandateId={selectedMandateId} />,
|
render: () => <_TasksTab selectedMandateId={selectedMandateId} />,
|
||||||
},
|
},
|
||||||
], [t, _handleWorkflowClick, workflowFilter, _handleRunClick, selectedRunId, _handleBackFromWorkspace, selectedMandateId]);
|
], [t, _handleWorkflowClick, workflowFilter, _handleRunClick, _handleBackFromDetail, selectedRunId, selectedMandateId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
<StackLayout variant="table">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
<StackLayout.Header>
|
||||||
<h1 className={styles.pageTitle} style={{ margin: 0 }}>{t('Workflow-Automation')}</h1>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, margin: 0 }}>{t('Workflow-Automation')}</h1>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', marginLeft: 'auto' }}>
|
||||||
<label style={{ fontSize: 13, opacity: 0.7 }}>{t('Kontext:')}</label>
|
<label style={{ fontSize: 13, opacity: 0.7 }}>{t('Kontext:')}</label>
|
||||||
<select
|
<select
|
||||||
value={selectedMandateId}
|
value={selectedMandateId}
|
||||||
onChange={(e) => setSelectedMandateId(e.target.value)}
|
onChange={(e) => _handleContextChange(e.target.value)}
|
||||||
style={{
|
style={{
|
||||||
padding: '4px 8px',
|
padding: '4px 8px',
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
|
|
@ -138,8 +153,17 @@ export const WorkflowAutomationPage: React.FC = () => {
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Tabs tabs={tabs} activeTabId={activeTab} onTabChange={setActiveTab} />
|
</StackLayout.Header>
|
||||||
</div>
|
<StackLayout.Body>
|
||||||
|
<LayoutTabs
|
||||||
|
items={tabs}
|
||||||
|
urlParam="tab"
|
||||||
|
defaultTab="workflows"
|
||||||
|
aliasMap={_TAB_ALIASES}
|
||||||
|
lazy
|
||||||
|
/>
|
||||||
|
</StackLayout.Body>
|
||||||
|
</StackLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export const _EditorTab: React.FC<EditorTabProps> = ({ selectedMandateId = 'all'
|
||||||
if (!editorInstance) {
|
if (!editorInstance) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||||
<p>{t('Kein Editor verfügbar. Bitte wähle einen Mandanten mit einer Feature-Instanz.')}</p>
|
<p>{t('Laden…')}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import { FaSync, FaPlay, FaCog, FaChartBar, FaDownload, FaTimes, FaStream } from 'react-icons/fa';
|
import { FaSync, FaPlay, FaCog, FaChartBar, FaDownload, FaTimes, FaStream } from 'react-icons/fa';
|
||||||
import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator';
|
import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator';
|
||||||
|
import { Panel } from '../../../components/Layout/Panel';
|
||||||
import { useToast } from '../../../contexts/ToastContext';
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
import { useApiRequest } from '../../../hooks/useApi';
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
import { fetchAttributes } from '../../../api/attributesApi';
|
import { fetchAttributes } from '../../../api/attributesApi';
|
||||||
|
|
@ -39,7 +40,7 @@ interface MetricCardProps {
|
||||||
color?: string;
|
color?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MetricCard: React.FC<MetricCardProps> = ({ icon, label, value, color }) => (
|
export const MetricCard: React.FC<MetricCardProps> = ({ icon, label, value, color }) => (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--bg-primary, #fff)',
|
background: 'var(--bg-primary, #fff)',
|
||||||
|
|
@ -190,7 +191,7 @@ const _RunTracingModal: React.FC<_RunTracingModalProps> = ({ run, onClose }) =>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h3 className={styles.modalTitle}>
|
<h3 className={styles.modalTitle}>
|
||||||
{t('Run-Tracing')}: {run.workflowLabel || run.workflowId}
|
{t('Run-Tracing')}: {run.workflowIdLabel || run.workflowId}
|
||||||
</h3>
|
</h3>
|
||||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)', marginTop: 2 }}>
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)', marginTop: 2 }}>
|
||||||
<span style={{ color: _STATUS_COLORS[run.status] || 'inherit', fontWeight: 600 }}>
|
<span style={{ color: _STATUS_COLORS[run.status] || 'inherit', fontWeight: 600 }}>
|
||||||
|
|
@ -310,14 +311,16 @@ export const _RunsTab: React.FC<RunsTabProps> = ({ workflowFilter, onRunClick, s
|
||||||
|
|
||||||
const _loadMetrics = useCallback(async () => {
|
const _loadMetrics = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const resp = await api.get('/api/workflow-automation/metrics');
|
const params: Record<string, any> = {};
|
||||||
|
if (selectedMandateId !== 'all') params.mandateId = selectedMandateId;
|
||||||
|
const resp = await api.get('/api/workflow-automation/metrics', { params });
|
||||||
setMetrics(resp.data);
|
setMetrics(resp.data);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const msg = e?.response?.data?.detail || e?.message || String(e);
|
const msg = e?.response?.data?.detail || e?.message || String(e);
|
||||||
console.error('[workflowAutomation] metrics load failed', e);
|
console.error('[workflowAutomation] metrics load failed', e);
|
||||||
showError(t('Metriken konnten nicht geladen werden: {msg}', { msg }));
|
showError(t('Metriken konnten nicht geladen werden: {msg}', { msg }));
|
||||||
}
|
}
|
||||||
}, [showError, t]);
|
}, [selectedMandateId, showError, t]);
|
||||||
|
|
||||||
const _loadRuns = useCallback(async (paginationParams?: any) => {
|
const _loadRuns = useCallback(async (paginationParams?: any) => {
|
||||||
if (paginationParams !== undefined) {
|
if (paginationParams !== undefined) {
|
||||||
|
|
@ -359,6 +362,10 @@ export const _RunsTab: React.FC<RunsTabProps> = ({ workflowFilter, onRunClick, s
|
||||||
_loadMetrics();
|
_loadMetrics();
|
||||||
}, [_loadMetrics]);
|
}, [_loadMetrics]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
_loadRuns();
|
||||||
|
}, [_loadRuns]);
|
||||||
|
|
||||||
const hasRunningRuns = runs.some((r) => r.status === 'running' || r.status === 'paused');
|
const hasRunningRuns = runs.some((r) => r.status === 'running' || r.status === 'paused');
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasRunningRuns) return;
|
if (!hasRunningRuns) return;
|
||||||
|
|
@ -377,7 +384,7 @@ export const _RunsTab: React.FC<RunsTabProps> = ({ workflowFilter, onRunClick, s
|
||||||
const report = {
|
const report = {
|
||||||
runId: run.id,
|
runId: run.id,
|
||||||
workflowId: run.workflowId,
|
workflowId: run.workflowId,
|
||||||
workflowLabel: run.workflowLabel,
|
workflowLabel: run.workflowIdLabel,
|
||||||
status: run.status,
|
status: run.status,
|
||||||
startedAt: _formatTs(run.sysCreatedAt),
|
startedAt: _formatTs(run.sysCreatedAt),
|
||||||
endedAt: _formatTs(run.sysModifiedAt),
|
endedAt: _formatTs(run.sysModifiedAt),
|
||||||
|
|
@ -408,7 +415,7 @@ export const _RunsTab: React.FC<RunsTabProps> = ({ workflowFilter, onRunClick, s
|
||||||
width: 200,
|
width: 200,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
displayField: 'workflowLabel',
|
displayField: 'workflowIdLabel',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'mandateId',
|
key: 'mandateId',
|
||||||
|
|
@ -465,25 +472,24 @@ export const _RunsTab: React.FC<RunsTabProps> = ({ workflowFilter, onRunClick, s
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.pageHeader}>
|
<Panel
|
||||||
<div>
|
title={t('Übersicht')}
|
||||||
<p className={styles.pageSubtitle}>{t('Workflow-Runs über alle Features und Mandanten')}</p>
|
collapsible
|
||||||
</div>
|
defaultCollapsed={false}
|
||||||
<div className={styles.headerActions}>
|
actions={
|
||||||
<button className={styles.secondaryButton} onClick={() => { _loadMetrics(); _loadRuns(); }} disabled={loading}>
|
<button className={styles.secondaryButton} onClick={() => { _loadMetrics(); _loadRuns(); }} disabled={loading} style={{ fontSize: '0.8rem', padding: '4px 10px' }}>
|
||||||
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
}
|
||||||
</div>
|
>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginBottom: 16 }}>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginBottom: 24, flexShrink: 0 }}>
|
|
||||||
<MetricCard icon={<FaCog />} label={t('Workflows')} value={metrics?.workflowCount ?? t('—')} />
|
<MetricCard icon={<FaCog />} label={t('Workflows')} value={metrics?.workflowCount ?? t('—')} />
|
||||||
<MetricCard icon={<FaPlay />} label={t('Aktive Workflows')} value={metrics?.activeWorkflows ?? t('—')} color="var(--success-color, #28a745)" />
|
<MetricCard icon={<FaPlay />} label={t('Aktive Workflows')} value={metrics?.activeWorkflows ?? t('—')} color="var(--success-color, #28a745)" />
|
||||||
<MetricCard icon={<FaChartBar />} label={t('Runs gesamt')} value={metrics?.totalRuns ?? t('—')} />
|
<MetricCard icon={<FaChartBar />} label={t('Runs gesamt')} value={metrics?.totalRuns ?? t('—')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{metrics?.runsByStatus && Object.keys(metrics.runsByStatus).length > 0 && (
|
{metrics?.runsByStatus && Object.keys(metrics.runsByStatus).length > 0 && (
|
||||||
<div style={{ marginBottom: 24, flexShrink: 0 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<h3 style={{ fontSize: '0.95rem', fontWeight: 600, marginBottom: 8 }}>{t('Läufe nach Status')}</h3>
|
<h3 style={{ fontSize: '0.95rem', fontWeight: 600, marginBottom: 8 }}>{t('Läufe nach Status')}</h3>
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
{Object.entries(metrics.runsByStatus).map(([status, count]) => (
|
{Object.entries(metrics.runsByStatus).map(([status, count]) => (
|
||||||
|
|
@ -506,7 +512,7 @@ export const _RunsTab: React.FC<RunsTabProps> = ({ workflowFilter, onRunClick, s
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{metrics && (metrics.totalTokens > 0 || metrics.totalCredits > 0) && (
|
{metrics && (metrics.totalTokens > 0 || metrics.totalCredits > 0) && (
|
||||||
<div style={{ marginBottom: 24, display: 'flex', gap: 24, flexShrink: 0 }}>
|
<div style={{ display: 'flex', gap: 24 }}>
|
||||||
{metrics.totalTokens > 0 && (
|
{metrics.totalTokens > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>{t('Tokens gesamt:')} </span>
|
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>{t('Tokens gesamt:')} </span>
|
||||||
|
|
@ -521,10 +527,7 @@ export const _RunsTab: React.FC<RunsTabProps> = ({ workflowFilter, onRunClick, s
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</Panel>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8, flexShrink: 0 }}>
|
|
||||||
<h3 style={{ fontSize: '0.95rem', fontWeight: 600, margin: 0 }}>{t('Letzte Runs')}</h3>
|
|
||||||
</div>
|
|
||||||
<div className={styles.tableContainer}>
|
<div className={styles.tableContainer}>
|
||||||
<FormGeneratorTable<WorkflowRun>
|
<FormGeneratorTable<WorkflowRun>
|
||||||
data={runs}
|
data={runs}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export const _TemplatesTab: React.FC<TemplatesTabProps> = ({ selectedMandateId =
|
||||||
if (!editorInstance) {
|
if (!editorInstance) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||||
<p>{t('Keine Vorlagen verfügbar. Bitte wähle einen Mandanten mit einer Feature-Instanz.')}</p>
|
<p>{t('Laden…')}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
|
|
||||||
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator';
|
||||||
import { useToast } from '../../../contexts/ToastContext';
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
import { usePrompt } from '../../../hooks/usePrompt';
|
import { usePrompt } from '../../../hooks/usePrompt';
|
||||||
|
|
@ -20,11 +20,13 @@ import type { AttributeDefinition } from '../../../api/attributesApi';
|
||||||
import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
|
import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
|
||||||
import api from '../../../api';
|
import api from '../../../api';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import { Panel } from '../../../components/Layout/Panel';
|
||||||
import styles from '../../admin/Admin.module.css';
|
import styles from '../../admin/Admin.module.css';
|
||||||
import {
|
import {
|
||||||
type SystemWorkflow,
|
type SystemWorkflow,
|
||||||
_formatTs,
|
_formatTs,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
import { MetricCard } from './RunsTab';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// WorkflowsTab (exported)
|
// WorkflowsTab (exported)
|
||||||
|
|
@ -45,7 +47,6 @@ export const _WorkflowsTab: React.FC<WorkflowsTabProps> = ({ onWorkflowClick, se
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [executingId, setExecutingId] = useState<string | null>(null);
|
const [executingId, setExecutingId] = useState<string | null>(null);
|
||||||
const [togglingId, setTogglingId] = useState<string | null>(null);
|
const [togglingId, setTogglingId] = useState<string | null>(null);
|
||||||
const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
|
||||||
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
||||||
const lastPaginationParamsRef = useRef<any>(null);
|
const lastPaginationParamsRef = useRef<any>(null);
|
||||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||||
|
|
@ -64,8 +65,6 @@ export const _WorkflowsTab: React.FC<WorkflowsTabProps> = ({ onWorkflowClick, se
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params: Record<string, any> = {};
|
const params: Record<string, any> = {};
|
||||||
if (activeFilter === 'active') params.active = true;
|
|
||||||
if (activeFilter === 'inactive') params.active = false;
|
|
||||||
if (selectedMandateId !== 'all') params.mandateId = selectedMandateId;
|
if (selectedMandateId !== 'all') params.mandateId = selectedMandateId;
|
||||||
|
|
||||||
const defaultSort = [{ field: 'sysCreatedAt', direction: 'desc' }];
|
const defaultSort = [{ field: 'sysCreatedAt', direction: 'desc' }];
|
||||||
|
|
@ -88,7 +87,7 @@ export const _WorkflowsTab: React.FC<WorkflowsTabProps> = ({ onWorkflowClick, se
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [activeFilter, selectedMandateId, showError, t]);
|
}, [selectedMandateId, showError, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
_load();
|
_load();
|
||||||
|
|
@ -289,32 +288,23 @@ export const _WorkflowsTab: React.FC<WorkflowsTabProps> = ({ onWorkflowClick, se
|
||||||
pagination: paginationMeta,
|
pagination: paginationMeta,
|
||||||
}), [_load, _handleDelete, 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.pageHeader}>
|
<Panel
|
||||||
<div>
|
title={t('Übersicht')}
|
||||||
<p className={styles.pageSubtitle}>
|
collapsible
|
||||||
{t('Alle Workflows über alle Features und Mandanten')}
|
defaultCollapsed={false}
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className={styles.headerActions} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
|
||||||
{(['all', 'active', 'inactive'] as const).map((f) => (
|
|
||||||
<button
|
|
||||||
key={f}
|
|
||||||
className={activeFilter === f ? styles.primaryButton : styles.secondaryButton}
|
|
||||||
onClick={() => setActiveFilter(f)}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
>
|
||||||
{f === 'all' ? t('Alle') : f === 'active' ? t('Aktiv') : t('Inaktiv')}
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '1rem', padding: '0.5rem 0' }}>
|
||||||
</button>
|
<MetricCard icon={<FaCog />} label={t('Workflows')} value={_totalCount} />
|
||||||
))}
|
<MetricCard icon={<FaCheck />} label={t('Aktiv')} value={_activeCount} color="var(--success-color, #28a745)" />
|
||||||
</div>
|
<MetricCard icon={<FaBolt />} label={t('Laufend')} value={_runningCount} color="var(--primary-color, #007bff)" />
|
||||||
<button className={styles.secondaryButton} onClick={() => _load()} disabled={loading}>
|
|
||||||
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<div className={styles.tableContainer}>
|
<div className={styles.tableContainer}>
|
||||||
<FormGeneratorTable<SystemWorkflow>
|
<FormGeneratorTable<SystemWorkflow>
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ export interface WorkflowRunMetrics {
|
||||||
export interface WorkflowRun {
|
export interface WorkflowRun {
|
||||||
id: string;
|
id: string;
|
||||||
workflowId: string;
|
workflowId: string;
|
||||||
workflowLabel?: string;
|
workflowIdLabel?: string;
|
||||||
mandateId?: string;
|
mandateId?: string;
|
||||||
mandateLabel?: string;
|
mandateLabel?: string;
|
||||||
featureInstanceId?: string;
|
featureInstanceId?: string;
|
||||||
|
|
|
||||||
112
src/utils/settingsSchemaToFormAttributes.ts
Normal file
112
src/utils/settingsSchemaToFormAttributes.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||||
|
pickerLabel?: string;
|
||||||
|
pickerItemLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsSchema {
|
||||||
|
name?: string;
|
||||||
|
fields: SettingsSchemaField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Type mapping
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const _PYTHON_TYPE_MAP: Record<string, AttributeType> = {
|
||||||
|
str: 'text',
|
||||||
|
string: 'text',
|
||||||
|
int: 'integer',
|
||||||
|
float: 'float',
|
||||||
|
number: 'number',
|
||||||
|
bool: 'boolean',
|
||||||
|
boolean: 'boolean',
|
||||||
|
date: 'date',
|
||||||
|
datetime: 'timestamp',
|
||||||
|
};
|
||||||
|
|
||||||
|
const _FRONTEND_TYPE_MAP: Record<string, AttributeType> = {
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue