Compare commits

..

6 commits
int ... main

Author SHA1 Message Date
5f47dd395c Merge branch 'int'
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 51s
2026-06-09 23:55:04 +02:00
19a39bc443 Merge branch 'int'
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 51s
2026-06-04 23:57:19 +02:00
b21fa78665 Merge pull request 'int' (#5) from int into main
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 50s
Reviewed-on: #5
2026-06-04 19:42:27 +00:00
30db1b8316 Merge pull request 'security and mfa' (#4) from int into main
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 48s
Reviewed-on: #4
2026-06-03 21:25:44 +00:00
5ce871fb3c Merge pull request 'fixes doc generation and renderers' (#3) from int into main
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 47s
Reviewed-on: #3
2026-06-03 15:03:00 +00:00
991952dde9 Merge pull request 'int' (#2) from int into main
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 46s
Reviewed-on: #2
2026-06-03 08:27:42 +00:00
31 changed files with 359 additions and 2125 deletions

View file

@ -4,10 +4,7 @@
gap: 10px; gap: 10px;
width: 100%; width: 100%;
font-family: var(--font-family); font-family: var(--font-family);
/* Floor for the bounded chain: below this the table stops shrinking and the min-height: 0;
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%;
@ -963,26 +960,6 @@ 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 {

View file

@ -1,305 +0,0 @@
/* 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;
}
}

View file

@ -1,324 +0,0 @@
// 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;

View file

@ -1,151 +0,0 @@
/** 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));
}

View file

@ -1,82 +0,0 @@
// 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>
);
};

View file

@ -1,96 +0,0 @@
/** 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;
}
}

View file

@ -1,101 +0,0 @@
// 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;

View file

@ -1,110 +0,0 @@
.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;
}
}

View file

@ -1,117 +0,0 @@
// 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;

View file

@ -1,27 +0,0 @@
// 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';

View file

@ -1,39 +0,0 @@
// 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
}
},
};
}

View file

@ -1,106 +0,0 @@
// 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;
}

View file

@ -1,54 +0,0 @@
// 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;
}

View file

@ -135,10 +135,13 @@
letter-spacing: 0.025em; letter-spacing: 0.025em;
} }
/* Feature Content — viewContent owns padding, not featureContent */ /* Feature Content */
.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;
@ -177,35 +180,10 @@
@media (max-width: 1024px) { @media (max-width: 1024px) {
.featureHeader { .featureHeader {
padding: 0.5rem 1rem; padding: 0.75rem 1rem;
} }
.breadcrumb { .featureContent {
gap: 0.25rem; padding: 1rem;
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;
}

View file

@ -91,10 +91,12 @@ 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,
@ -110,8 +112,10 @@ 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}>
@ -127,6 +131,7 @@ export const FeatureLayout: React.FC = () => {
</div> </div>
</header> </header>
{/* Content Area */}
<main className={styles.featureContent}> <main className={styles.featureContent}>
<Outlet /> <Outlet />
</main> </main>
@ -143,6 +148,10 @@ 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,
@ -154,6 +163,7 @@ 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;

View file

@ -115,16 +115,6 @@
-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;
} }

View file

@ -16,7 +16,6 @@ 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';
@ -106,7 +105,6 @@ 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();
@ -168,7 +166,7 @@ const MainLayoutInner: React.FC = () => {
</aside> </aside>
{/* Content */} {/* Content */}
<main className={styles.content} data-scroll-mode={scrollMode}> <main className={styles.content}>
<div className={styles.mobileTopBar}> <div className={styles.mobileTopBar}>
<button <button
className={styles.mobileMenuButton} className={styles.mobileMenuButton}

View file

@ -112,14 +112,3 @@
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;
}

View file

@ -48,9 +48,6 @@ 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';
@ -115,7 +112,6 @@ 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,

View file

@ -22,9 +22,7 @@
.adminPage.adminPageFill { .adminPage.adminPageFill {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
/* visible: let the table's min-height overflow to the scroll ancestor on overflow: hidden;
short viewports (scrollbar instead of clipped table). */
overflow: visible;
} }
.pageHeader { .pageHeader {
@ -216,8 +214,7 @@
.tableContainer { .tableContainer {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
/* visible: see .adminPageFill — table min-height must reach the scrollbar. */ overflow: hidden;
overflow: visible;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@ -753,17 +750,6 @@
} }
} }
/* 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;

View file

@ -1,30 +0,0 @@
.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);
}

View file

@ -1,128 +0,0 @@
// 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;

View file

@ -22,12 +22,14 @@
* - 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/ui-nyla/layout.md`. The page uses * Layout / sizing: see `wiki/b-reference/frontend-nyla/formgenerator.md`
* `StackLayout variant="table"` with `LayoutTabs`; each tab body is a `table` * ("Page Layout Chain"). Outer is `adminPage + adminPageFill`, active tab
* Panel that provides the bounded height chain `FormGeneratorTable` requires. * sits inside `tableContainer`, which provides the bounded height chain
* `FormGeneratorTable` requires.
*/ */
import React, { useMemo } from 'react'; import React, { useCallback, 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,
@ -50,9 +52,7 @@ 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 { StackLayout } from '../../../components/Layout/StackLayout'; import adminStyles from '../../admin/Admin.module.css';
import { LayoutTabs } from '../../../components/Layout/LayoutTabs';
import type { LayoutTabItem } from '../../../components/Layout/types';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tab definitions // Tab definitions
@ -64,6 +64,7 @@ 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 }>;
} }
@ -155,10 +156,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', Wrapper: _OrganisationsWrapper }, { id: 'organisations', entityName: 'TrusteeOrganisation', label: t('Organisation'), icon: '\uD83C\uDFE2', color: '#1976d2', readOnly: false, Wrapper: _OrganisationsWrapper },
{ id: 'roles', entityName: 'TrusteeRole', label: t('Rolle'), icon: '\uD83D\uDC65', color: '#0277bd', Wrapper: _RolesWrapper }, { id: 'roles', entityName: 'TrusteeRole', label: t('Rolle'), icon: '\uD83D\uDC65', color: '#0277bd', readOnly: false, Wrapper: _RolesWrapper },
{ id: 'access', entityName: 'TrusteeAccess', label: t('Zugriff'), icon: '\uD83D\uDD11', color: '#0288d1', Wrapper: _AccessWrapper }, { id: 'access', entityName: 'TrusteeAccess', label: t('Zugriff'), icon: '\uD83D\uDD11', color: '#0288d1', readOnly: false, Wrapper: _AccessWrapper },
{ id: 'contracts', entityName: 'TrusteeContract', label: t('Vertrag'), icon: '\uD83D\uDCDC', color: '#00796b', Wrapper: _ContractsWrapper }, { id: 'contracts', entityName: 'TrusteeContract', label: t('Vertrag'), icon: '\uD83D\uDCDC', color: '#00796b', readOnly: false, Wrapper: _ContractsWrapper },
], ],
}, },
{ {
@ -166,8 +167,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', Wrapper: _DocumentsWrapper }, { id: 'documents', entityName: 'TrusteeDocument', label: t('Dokument'), icon: '\uD83D\uDCC4', color: '#388e3c', readOnly: false, Wrapper: _DocumentsWrapper },
{ id: 'positions', entityName: 'TrusteePosition', label: t('Position'), icon: '\uD83D\uDCCA', color: '#43a047', Wrapper: _PositionsWrapper }, { id: 'positions', entityName: 'TrusteePosition', label: t('Position'), icon: '\uD83D\uDCCA', color: '#43a047', readOnly: false, Wrapper: _PositionsWrapper },
], ],
}, },
{ {
@ -175,8 +176,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', Wrapper: _AccountingConfigsWrapper }, { id: 'accounting-configs', entityName: 'TrusteeAccountingConfig', label: t('Buchhaltungs-Verbindung'), icon: '\u2699\uFE0F', color: '#5e35b1', readOnly: true, Wrapper: _AccountingConfigsWrapper },
{ id: 'accounting-syncs', entityName: 'TrusteeAccountingSync', label: t('Sync-Protokoll'), icon: '\uD83D\uDD04', color: '#3949ab', Wrapper: _AccountingSyncsWrapper }, { id: 'accounting-syncs', entityName: 'TrusteeAccountingSync', label: t('Sync-Protokoll'), icon: '\uD83D\uDD04', color: '#3949ab', readOnly: true, Wrapper: _AccountingSyncsWrapper },
], ],
}, },
{ {
@ -184,35 +185,16 @@ 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', Wrapper: _DataAccountsWrapper }, { id: 'accounts', entityName: 'TrusteeDataAccount', label: t('Kontenplan'), icon: '\uD83D\uDCD2', color: '#f57c00', readOnly: true, Wrapper: _DataAccountsWrapper },
{ id: 'journal-entries', entityName: 'TrusteeDataJournalEntry', label: t('Buchungen'), icon: '\uD83D\uDCDD', color: '#ef6c00', Wrapper: _DataJournalEntriesWrapper }, { id: 'journal-entries', entityName: 'TrusteeDataJournalEntry', label: t('Buchungen'), icon: '\uD83D\uDCDD', color: '#ef6c00', readOnly: true, Wrapper: _DataJournalEntriesWrapper },
{ id: 'journal-lines', entityName: 'TrusteeDataJournalLine', label: t('Buchungszeilen'), icon: '\uD83D\uDCC3', color: '#e65100', Wrapper: _DataJournalLinesWrapper }, { id: 'journal-lines', entityName: 'TrusteeDataJournalLine', label: t('Buchungszeilen'), icon: '\uD83D\uDCC3', color: '#e65100', readOnly: true, Wrapper: _DataJournalLinesWrapper },
{ id: 'contacts', entityName: 'TrusteeDataContact', label: t('Kontakte'), icon: '\uD83D\uDC64', color: '#c2185b', Wrapper: _DataContactsWrapper }, { id: 'contacts', entityName: 'TrusteeDataContact', label: t('Kontakte'), icon: '\uD83D\uDC64', color: '#c2185b', readOnly: true, Wrapper: _DataContactsWrapper },
{ id: 'account-balances', entityName: 'TrusteeDataAccountBalance', label: t('Kontosalden'), icon: '\uD83D\uDCB0', color: '#ad1457', Wrapper: _DataAccountBalancesWrapper }, { id: 'account-balances', entityName: 'TrusteeDataAccountBalance', label: t('Kontosalden'), icon: '\uD83D\uDCB0', color: '#ad1457', readOnly: true, 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
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -220,48 +202,147 @@ function _toLayoutTabItems(
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 layoutTabItems = useMemo( const visibleTabs = useMemo(() => tabGroups.flatMap((g) => g.tabs), [tabGroups]);
() => (instanceId ? _toLayoutTabItems(tabGroups, instanceId) : []),
[tabGroups, instanceId], const requestedTab = searchParams.get('tab');
); 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 (
<StackLayout variant="table"> <div className={`${adminStyles.adminPage} ${adminStyles.adminPageFill}`}>
<StackLayout.Body><p>{t('Instanz wird geladen…')}</p></StackLayout.Body> <p>{t('Instanz wird geladen…')}</p>
</StackLayout> </div>
); );
} }
if (!layoutTabItems.length) { if (!currentTab) {
return ( return (
<StackLayout variant="table"> <div className={`${adminStyles.adminPage} ${adminStyles.adminPageFill}`}>
<StackLayout.Header> <div className={adminStyles.pageHeader}>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, margin: 0 }}>{t('Daten-Tabellen')}</h1> <div>
</StackLayout.Header> <h1 className={adminStyles.pageTitle}>{t('Daten-Tabellen')}</h1>
<StackLayout.Body><p>{t('Du hast keine Berechtigung für')}</p></StackLayout.Body> </div>
</StackLayout> </div>
<p>{t('Du hast keine Berechtigung für')}</p>
</div>
); );
} }
const ActiveWrapper = currentTab.Wrapper;
return ( return (
<StackLayout variant="table"> <div className={`${adminStyles.adminPage} ${adminStyles.adminPageFill}`}>
<StackLayout.Header> <div className={adminStyles.pageHeader}>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, margin: 0 }}>{t('Daten-Tabellen')}</h1> <div>
</StackLayout.Header> <h1 className={adminStyles.pageTitle}>{t('Daten-Tabellen')}</h1>
<StackLayout.Body> <p className={adminStyles.pageSubtitle}>
<LayoutTabs {t('Alle Datenbanktabellen dieser Trustee-Instanz auf einen Blick.')}
items={layoutTabItems} </p>
urlParam="tab" </div>
preserveSearchParams </div>
lazy
collapsible <div
collapseKey="trustee-data-tables-tabs" style={{
/> display: 'flex',
</StackLayout.Body> flexDirection: 'column',
</StackLayout> gap: '0.5rem',
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>
); );
}; };

View file

@ -256,11 +256,6 @@ 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([]);
@ -268,6 +263,39 @@ 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('');
@ -703,7 +731,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.tableName}` : ''} {fds?.label || fdsLabelMap.get(fdsId) || fdsId} {fds?.tableName || ''}
<button <button
type="button" type="button"
onClick={() => _toggleFeatureDataSource(fdsId)} onClick={() => _toggleFeatureDataSource(fdsId)}

View file

@ -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 · Aufgaben * Tabs: Workflows · Editor · Vorlagen · Läufe · Details · 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,12 +12,10 @@
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 { StackLayout } from '../../components/Layout/StackLayout'; import { Tabs } from '../../components/UiComponents/Tabs';
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';
@ -28,25 +26,29 @@ import { _TasksTab } from './tabs/TasksTab';
const _TAB_ALIASES: Record<string, string> = { const _TAB_ALIASES: Record<string, string> = {
dashboard: 'runs', dashboard: 'runs',
workspace: 'runs', workspace: 'detail',
}; };
export const WorkflowAutomationPage: React.FC = () => { export const WorkflowAutomationPage: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { dynamicBlock } = useNavigation(); const { dynamicBlock } = useNavigation();
const [workflowFilter, setWorkflowFilter] = useState<string | null>(null); const rawTab = searchParams.get('tab') || 'workflows';
const selectedMandateId = searchParams.get('context') || 'all'; const initialTab = _TAB_ALIASES[rawTab] || rawTab;
const initialRunId = searchParams.get('runId') || null;
const _handleContextChange = useCallback((value: string) => { const [activeTab, setActiveTab] = useState<string>(initialRunId ? 'detail' : initialTab);
setSearchParams((prev) => { const [selectedRunId, setSelectedRunId] = useState<string | null>(initialRunId);
const next = new URLSearchParams(prev);
if (value === 'all') next.delete('context'); useEffect(() => {
else next.set('context', value); const newTab = _TAB_ALIASES[searchParams.get('tab') || 'workflows'] || searchParams.get('tab') || 'workflows';
return next; if (newTab !== activeTab) {
}, { replace: true }); setActiveTab(newTab);
}, [setSearchParams]); }
}, [searchParams]);
const [workflowFilter, setWorkflowFilter] = useState<string | null>(null);
const [selectedMandateId, setSelectedMandateId] = useState<string>('all');
const _mandateOptions = useMemo(() => { const _mandateOptions = useMemo(() => {
const options: Array<{ value: string; label: string }> = [ const options: Array<{ value: string; label: string }> = [
@ -62,82 +64,65 @@ export const WorkflowAutomationPage: React.FC = () => {
const _handleWorkflowClick = useCallback((workflowId: string) => { const _handleWorkflowClick = useCallback((workflowId: string) => {
setWorkflowFilter(workflowId); setWorkflowFilter(workflowId);
setSearchParams((prev) => { setActiveTab('runs');
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) => {
setSearchParams((prev) => { setSelectedRunId(runId);
const next = new URLSearchParams(prev); setActiveTab('detail');
next.set('runId', runId); }, []);
return next;
}, { replace: true });
}, [setSearchParams]);
const _handleBackFromDetail = useCallback(() => { const _handleBackFromWorkspace = useCallback(() => {
setSearchParams((prev) => { setSelectedRunId(null);
const next = new URLSearchParams(prev); setActiveTab('runs');
next.delete('runId'); }, []);
return next;
}, { replace: true });
}, [setSearchParams]);
const selectedRunId = searchParams.get('runId') || null; const tabs = useMemo(() => [
const tabs: LayoutTabItem[] = useMemo(() => [
{ {
id: 'workflows', id: 'workflows',
label: t('Workflows'), label: t('Workflows'),
render: () => <_WorkflowsTab onWorkflowClick={_handleWorkflowClick} selectedMandateId={selectedMandateId} />, content: <_WorkflowsTab onWorkflowClick={_handleWorkflowClick} selectedMandateId={selectedMandateId} />,
}, },
{ {
id: 'editor', id: 'editor',
label: t('Editor'), label: t('Editor'),
render: () => <_EditorTab selectedMandateId={selectedMandateId} />, content: <_EditorTab selectedMandateId={selectedMandateId} />,
}, },
{ {
id: 'templates', id: 'templates',
label: t('Vorlagen'), label: t('Vorlagen'),
render: () => <_TemplatesTab selectedMandateId={selectedMandateId} />, content: <_TemplatesTab selectedMandateId={selectedMandateId} />,
}, },
{ {
id: 'runs', id: 'runs',
label: t('Workflow-Durchläufe'), label: t('Workflow-Durchläufe'),
render: () => ( content: <_RunsTab workflowFilter={workflowFilter} onRunClick={_handleRunClick} selectedMandateId={selectedMandateId} />,
<ViewStack entityParam="runId"> },
<ViewStack.View id="list"> {
<_RunsTab workflowFilter={workflowFilter} onRunClick={_handleRunClick} selectedMandateId={selectedMandateId} /> id: 'detail',
</ViewStack.View> label: t('Durchlauf-Details'),
<ViewStack.View id="detail" backLabel={t('Zurück zu Läufe')} title={t('Durchlauf-Details')}> content: <_RunDetailTab runId={selectedRunId} onBack={_handleBackFromWorkspace} />,
<_RunDetailTab runId={selectedRunId} onBack={_handleBackFromDetail} />
</ViewStack.View>
</ViewStack>
),
}, },
{ {
id: 'tasks', id: 'tasks',
label: t('Aufgaben'), label: t('Aufgaben'),
render: () => <_TasksTab selectedMandateId={selectedMandateId} />, content: <_TasksTab selectedMandateId={selectedMandateId} />,
}, },
], [t, _handleWorkflowClick, workflowFilter, _handleRunClick, _handleBackFromDetail, selectedRunId, selectedMandateId]); ], [t, _handleWorkflowClick, workflowFilter, _handleRunClick, selectedRunId, _handleBackFromWorkspace, selectedMandateId]);
return ( return (
<StackLayout variant="table"> <div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<StackLayout.Header> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}> <h1 className={styles.pageTitle} style={{ margin: 0 }}>{t('Workflow-Automation')}</h1>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, margin: 0 }}>{t('Workflow-Automation')}</h1> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<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) => _handleContextChange(e.target.value)} onChange={(e) => setSelectedMandateId(e.target.value)}
style={{ style={{
padding: '4px 8px', padding: '4px 8px',
borderRadius: 4, borderRadius: 4,
@ -153,17 +138,8 @@ export const WorkflowAutomationPage: React.FC = () => {
</select> </select>
</div> </div>
</div> </div>
</StackLayout.Header> <Tabs tabs={tabs} activeTabId={activeTab} onTabChange={setActiveTab} />
<StackLayout.Body> </div>
<LayoutTabs
items={tabs}
urlParam="tab"
defaultTab="workflows"
aliasMap={_TAB_ALIASES}
lazy
/>
</StackLayout.Body>
</StackLayout>
); );
}; };

View file

@ -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('Laden…')}</p> <p>{t('Kein Editor verfügbar. Bitte wähle einen Mandanten mit einer Feature-Instanz.')}</p>
</div> </div>
); );
} }

View file

@ -9,7 +9,6 @@
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';
@ -40,7 +39,7 @@ interface MetricCardProps {
color?: string; color?: string;
} }
export const MetricCard: React.FC<MetricCardProps> = ({ icon, label, value, color }) => ( const MetricCard: React.FC<MetricCardProps> = ({ icon, label, value, color }) => (
<div <div
style={{ style={{
background: 'var(--bg-primary, #fff)', background: 'var(--bg-primary, #fff)',
@ -191,7 +190,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.workflowIdLabel || run.workflowId} {t('Run-Tracing')}: {run.workflowLabel || 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 }}>
@ -311,16 +310,14 @@ export const _RunsTab: React.FC<RunsTabProps> = ({ workflowFilter, onRunClick, s
const _loadMetrics = useCallback(async () => { const _loadMetrics = useCallback(async () => {
try { try {
const params: Record<string, any> = {}; const resp = await api.get('/api/workflow-automation/metrics');
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 }));
} }
}, [selectedMandateId, showError, t]); }, [showError, t]);
const _loadRuns = useCallback(async (paginationParams?: any) => { const _loadRuns = useCallback(async (paginationParams?: any) => {
if (paginationParams !== undefined) { if (paginationParams !== undefined) {
@ -362,10 +359,6 @@ 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;
@ -384,7 +377,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.workflowIdLabel, workflowLabel: run.workflowLabel,
status: run.status, status: run.status,
startedAt: _formatTs(run.sysCreatedAt), startedAt: _formatTs(run.sysCreatedAt),
endedAt: _formatTs(run.sysModifiedAt), endedAt: _formatTs(run.sysModifiedAt),
@ -415,7 +408,7 @@ export const _RunsTab: React.FC<RunsTabProps> = ({ workflowFilter, onRunClick, s
width: 200, width: 200,
sortable: true, sortable: true,
filterable: true, filterable: true,
displayField: 'workflowIdLabel', displayField: 'workflowLabel',
}, },
{ {
key: 'mandateId', key: 'mandateId',
@ -472,24 +465,25 @@ export const _RunsTab: React.FC<RunsTabProps> = ({ workflowFilter, onRunClick, s
return ( return (
<> <>
<Panel <div className={styles.pageHeader}>
title={t('Übersicht')} <div>
collapsible <p className={styles.pageSubtitle}>{t('Workflow-Runs über alle Features und Mandanten')}</p>
defaultCollapsed={false} </div>
actions={ <div className={styles.headerActions}>
<button className={styles.secondaryButton} onClick={() => { _loadMetrics(); _loadRuns(); }} disabled={loading} style={{ fontSize: '0.8rem', padding: '4px 10px' }}> <button className={styles.secondaryButton} onClick={() => { _loadMetrics(); _loadRuns(); }} disabled={loading}>
<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: 16 }}> <div style={{ marginBottom: 24, flexShrink: 0 }}>
<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]) => (
@ -512,7 +506,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={{ display: 'flex', gap: 24 }}> <div style={{ marginBottom: 24, display: 'flex', gap: 24, flexShrink: 0 }}>
{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>
@ -527,7 +521,10 @@ 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}

View file

@ -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('Laden…')}</p> <p>{t('Keine Vorlagen verfügbar. Bitte wähle einen Mandanten mit einer Feature-Instanz.')}</p>
</div> </div>
); );
} }

View file

@ -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 { FaCog, FaPlay, FaCheck, FaBan, FaPen, FaEye, FaStop, FaBolt } from 'react-icons/fa'; import { FaSync, FaPlay, FaCheck, FaBan, FaPen, FaEye, FaStop } 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,13 +20,11 @@ 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)
@ -47,6 +45,7 @@ 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[]>([]);
@ -65,6 +64,8 @@ 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' }];
@ -87,7 +88,7 @@ export const _WorkflowsTab: React.FC<WorkflowsTabProps> = ({ onWorkflowClick, se
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [selectedMandateId, showError, t]); }, [activeFilter, selectedMandateId, showError, t]);
useEffect(() => { useEffect(() => {
_load(); _load();
@ -288,23 +289,32 @@ 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 (
<> <>
<Panel <div className={styles.pageHeader}>
title={t('Übersicht')} <div>
collapsible <p className={styles.pageSubtitle}>
defaultCollapsed={false} {t('Alle Workflows über alle Features und Mandanten')}
> </p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '1rem', padding: '0.5rem 0' }}> </div>
<MetricCard icon={<FaCog />} label={t('Workflows')} value={_totalCount} /> <div className={styles.headerActions} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<MetricCard icon={<FaCheck />} label={t('Aktiv')} value={_activeCount} color="var(--success-color, #28a745)" /> <div style={{ display: 'flex', gap: 4 }}>
<MetricCard icon={<FaBolt />} label={t('Laufend')} value={_runningCount} color="var(--primary-color, #007bff)" /> {(['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')}
</button>
))}
</div>
<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>

View file

@ -26,7 +26,7 @@ export interface WorkflowRunMetrics {
export interface WorkflowRun { export interface WorkflowRun {
id: string; id: string;
workflowId: string; workflowId: string;
workflowIdLabel?: string; workflowLabel?: string;
mandateId?: string; mandateId?: string;
mandateLabel?: string; mandateLabel?: string;
featureInstanceId?: string; featureInstanceId?: string;

View file

@ -1,112 +0,0 @@
// 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,
}));
}