revised state machine for workflow backend and ui

This commit is contained in:
ValueOn AG 2026-02-08 00:25:53 +01:00
parent a544ab2c78
commit 148412c31c
25 changed files with 1939 additions and 624 deletions

View file

@ -47,7 +47,7 @@ import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandat
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
// Billing Pages
import { BillingDashboard, BillingTransactions, BillingAdmin } from './pages/billing';
import { BillingDashboard, BillingDataView, BillingAdmin } from './pages/billing';
function App() {
// Load saved theme preference and set app name on app mount
@ -117,8 +117,8 @@ function App() {
{/* BILLING ROUTES */}
{/* ============================================== */}
<Route path="billing">
<Route index element={<BillingDashboard />} />
<Route path="transactions" element={<BillingTransactions />} />
<Route index element={<Navigate to="/billing/transactions" replace />} />
<Route path="transactions" element={<BillingDataView />} />
</Route>
{/* ============================================== */}

View file

@ -39,6 +39,8 @@ export interface BillingTransaction {
featureCode?: string;
aicoreProvider?: string;
createdAt?: string;
mandateId?: string;
mandateName?: string;
}
export interface BillingSettings {
@ -277,3 +279,95 @@ export async function fetchUsersForMandateAdmin(
method: 'get'
});
}
// ============================================================================
// MANDATE VIEW TYPES & API FUNCTIONS
// ============================================================================
export interface MandateBalance {
mandateId: string;
mandateName: string;
billingModel: BillingModel;
totalBalance: number;
userCount: number;
defaultUserCredit: number;
warningThresholdPercent: number;
blockOnZeroBalance: boolean;
}
/**
* Fetch mandate-level balances (SysAdmin only)
* Endpoint: GET /api/billing/view/mandates/balances
*/
export async function fetchMandateViewBalances(
request: ApiRequestFunction
): Promise<MandateBalance[]> {
return await request({
url: '/api/billing/view/mandates/balances',
method: 'get'
});
}
/**
* Fetch mandate-level transactions (SysAdmin only)
* Endpoint: GET /api/billing/view/mandates/transactions
*/
export async function fetchMandateViewTransactions(
request: ApiRequestFunction,
limit: number = 100
): Promise<BillingTransaction[]> {
return await request({
url: '/api/billing/view/mandates/transactions',
method: 'get',
params: { limit }
});
}
// ============================================================================
// USER VIEW TYPES & API FUNCTIONS
// ============================================================================
export interface UserBalance {
accountId: string;
mandateId: string;
mandateName: string;
userId: string;
userName: string;
balance: number;
warningThreshold: number;
isWarning: boolean;
enabled: boolean;
}
export interface UserTransaction extends BillingTransaction {
userId?: string;
userName?: string;
}
/**
* Fetch user-level balances (RBAC-based)
* Endpoint: GET /api/billing/view/users/balances
*/
export async function fetchUserViewBalances(
request: ApiRequestFunction
): Promise<UserBalance[]> {
return await request({
url: '/api/billing/view/users/balances',
method: 'get'
});
}
/**
* Fetch user-level transactions (RBAC-based)
* Endpoint: GET /api/billing/view/users/transactions
*/
export async function fetchUserViewTransactions(
request: ApiRequestFunction,
limit: number = 100
): Promise<UserTransaction[]> {
return await request({
url: '/api/billing/view/users/transactions',
method: 'get',
params: { limit }
});
}

View file

@ -7,7 +7,11 @@
/* Fill available space and constrain height */
min-height: 0;
flex: 1;
/* No overflow - children handle their own scrolling */
/* Prevent overflow - constrain to parent height */
overflow: hidden;
/* Ensure container respects parent's height */
height: 100%;
max-height: 100%;
}
.title {
@ -18,18 +22,46 @@
margin-bottom: 10px;
}
/* Table Container - scrollable area for table data only */
.tableContainer {
position: relative;
overflow: auto;
/* Table wrapper - contains top scrollbar and table container */
.tableWrapper {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
/* Constrain height to prevent growing beyond parent */
max-height: 100%;
overflow: hidden;
border: 1px solid var(--color-primary);
border-radius: 25px;
background: var(--color-bg);
/* Fill remaining space after controls */
flex: 1;
}
/* Top horizontal scrollbar - syncs with table container */
.topScrollbar {
overflow-x: auto;
overflow-y: hidden;
flex-shrink: 0;
background: var(--color-bg);
border-bottom: 1px solid var(--color-primary);
border-radius: 25px 25px 0 0;
}
/* Inner div that matches table width for proper scrollbar sizing */
.topScrollbarInner {
height: 1px; /* Minimal height - just need width to activate scrollbar */
}
/* Table Container - scrollable area for table data only (vertical only) */
.tableContainer {
position: relative;
overflow-x: hidden; /* Horizontal scroll handled by topScrollbar */
overflow-y: auto;
background: var(--color-bg);
/* Fill remaining space but constrain to available height */
flex: 1 1 0;
min-height: 0;
/* Clip content to border-radius but allow sticky to work */
isolation: isolate;
max-height: 100%;
border-radius: 0 0 25px 25px;
}
/* Empty table styling - no extra space, just header */
@ -39,6 +71,11 @@
max-height: none;
}
/* Hide top scrollbar when table is empty */
.emptyTable .topScrollbar {
display: none;
}
/* Empty state styling */
.emptyState {
display: flex;
@ -734,7 +771,7 @@ tbody .actionsColumn {
outline: none;
}
/* Custom scrollbar for table container */
/* Custom scrollbar for table container (vertical only) */
.tableContainer::-webkit-scrollbar {
width: 8px;
height: 8px;
@ -754,6 +791,25 @@ tbody .actionsColumn {
background: var(--color-secondary);
}
/* Custom scrollbar for top scrollbar (horizontal only) */
.topScrollbar::-webkit-scrollbar {
height: 8px;
}
.topScrollbar::-webkit-scrollbar-track {
background: var(--color-gray-disabled);
border-radius: 4px;
}
.topScrollbar::-webkit-scrollbar-thumb {
background: var(--color-gray);
border-radius: 4px;
}
.topScrollbar::-webkit-scrollbar-thumb:hover {
background: var(--color-secondary);
}
/* Loading State */
.loadingState {
display: flex;

View file

@ -338,6 +338,11 @@ export function FormGeneratorTable<T extends Record<string, any>>({
const tableRef = useRef<HTMLTableElement>(null);
const tableContainerRef = useRef<HTMLDivElement>(null);
// Refs for top scrollbar synchronization
const topScrollbarRef = useRef<HTMLDivElement>(null);
const topScrollbarInnerRef = useRef<HTMLDivElement>(null);
const isScrollingSyncRef = useRef<boolean>(false); // Prevent scroll sync loops
// Track container width for actions column 20% threshold
const [containerWidth, setContainerWidth] = useState<number>(0);
@ -945,6 +950,53 @@ export function FormGeneratorTable<T extends Record<string, any>>({
};
}, []);
// Sync top scrollbar width with table width and handle scroll synchronization
useEffect(() => {
const tableContainer = tableContainerRef.current;
const topScrollbar = topScrollbarRef.current;
const topScrollbarInner = topScrollbarInnerRef.current;
const table = tableRef.current;
if (!tableContainer || !topScrollbar || !topScrollbarInner || !table) return;
// Update top scrollbar inner width to match table width
const updateScrollbarWidth = () => {
const tableWidth = table.scrollWidth;
topScrollbarInner.style.width = `${tableWidth}px`;
};
// Initial width calculation
updateScrollbarWidth();
// Observe table size changes
const resizeObserver = new ResizeObserver(updateScrollbarWidth);
resizeObserver.observe(table);
// Sync scroll positions
const syncTopToContainer = () => {
if (isScrollingSyncRef.current) return;
isScrollingSyncRef.current = true;
tableContainer.scrollLeft = topScrollbar.scrollLeft;
requestAnimationFrame(() => { isScrollingSyncRef.current = false; });
};
const syncContainerToTop = () => {
if (isScrollingSyncRef.current) return;
isScrollingSyncRef.current = true;
topScrollbar.scrollLeft = tableContainer.scrollLeft;
requestAnimationFrame(() => { isScrollingSyncRef.current = false; });
};
topScrollbar.addEventListener('scroll', syncTopToContainer);
tableContainer.addEventListener('scroll', syncContainerToTop);
return () => {
resizeObserver.disconnect();
topScrollbar.removeEventListener('scroll', syncTopToContainer);
tableContainer.removeEventListener('scroll', syncContainerToTop);
};
}, [displayData, detectedColumns, columnWidths]); // Re-run when data or columns change
// Track which cells are currently being updated (for loading state)
const [updatingCells, setUpdatingCells] = useState<Set<string>>(new Set());
@ -1367,26 +1419,36 @@ export function FormGeneratorTable<T extends Record<string, any>>({
/>
)}
{/* Table */}
<div
ref={tableContainerRef}
className={`${styles.tableContainer} ${displayData.length === 0 && !loading ? styles.emptyTable : ''}`}
>
{/* Loading overlay - shown while loading */}
{loading && (
<div className={styles.loadingOverlay}>
<div className={styles.loadingSpinner}></div>
<p>{t('common.loading', 'Loading...')}</p>
</div>
)}
{/* Table Wrapper - contains top scrollbar and table container */}
<div className={`${styles.tableWrapper} ${displayData.length === 0 && !loading ? styles.emptyTable : ''}`}>
{/* Top horizontal scrollbar - syncs with table container */}
<div
ref={topScrollbarRef}
className={styles.topScrollbar}
>
<div ref={topScrollbarInnerRef} className={styles.topScrollbarInner} />
</div>
{/* Empty state - only shown when not loading AND no data */}
{!loading && displayData.length === 0 ? (
<div className={styles.emptyState}>
<p className={styles.emptyMessage}>{emptyMessage || t('formgen.empty', 'No data available')}</p>
</div>
) : (
<table ref={tableRef} className={styles.table}>
{/* Table Container - vertical scroll only */}
<div
ref={tableContainerRef}
className={styles.tableContainer}
>
{/* Loading overlay - shown while loading */}
{loading && (
<div className={styles.loadingOverlay}>
<div className={styles.loadingSpinner}></div>
<p>{t('common.loading', 'Loading...')}</p>
</div>
)}
{/* Empty state - only shown when not loading AND no data */}
{!loading && displayData.length === 0 ? (
<div className={styles.emptyState}>
<p className={styles.emptyMessage}>{emptyMessage || t('formgen.empty', 'No data available')}</p>
</div>
) : (
<table ref={tableRef} className={styles.table}>
<thead>
<tr>
{selectable && (
@ -1704,7 +1766,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
</tbody>
)}
</table>
)}
)}
</div>
</div>
</div>
);

View file

@ -8,16 +8,16 @@
* Backend liefert Blocks-Struktur mit Static und Dynamic Blocks.
* UI mappt uiComponent zu Icons via pageRegistry.
*
* Struktur (gemäss Navigation-API-Konzept):
* - SYSTEM (static block, order: 10)
* - MEINE FEATURES (dynamic block, order: 15)
* - Mandant 1
* - Feature A
* - Instanz 1 (mit Views)
* - WORKFLOWS (static block, order: 20)
* - BASISDATEN (static block, order: 30)
* - MIGRATE TO FEATURES (static block, order: 40)
* - ADMINISTRATION (static block, order: 200)
* FLAT STRUCTURE (kompakte Darstellung):
* - SYSTEM (static block)
* - Mandant 1
* - 🎯 Instanz 1 (Feature-Icon + Instanz-Name)
* - 💼 Instanz 2 (Feature-Icon + Instanz-Name)
* - BASISDATEN (static block)
* - ADMINISTRATION (static block)
*
* Jede Instanz zeigt das Icon des zugehörigen Features.
* Keine Gruppierung nach Features - direkt Instanzen unter Mandant.
*/
import React, { useMemo } from 'react';
@ -75,58 +75,52 @@ function featureViewToTreeNode(view: FeatureView): TreeNodeItem {
}
/**
* Convert a FeatureInstance to TreeNodeItem
* Instance node gets path to first view so clicking the instance name (e.g. PEK) navigates to dashboard.
* Convert a FeatureInstance to TreeNodeItem (with feature icon)
* Instance node gets path to first view so clicking the instance name navigates to dashboard.
* Shows the feature icon next to the instance name for visual distinction.
*/
function featureInstanceToTreeNode(instance: FeatureInstance): TreeNodeItem {
function featureInstanceToTreeNode(instance: FeatureInstance, featureUiComponent: string): TreeNodeItem {
const children = instance.views.map(featureViewToTreeNode);
return {
id: instance.id,
label: instance.uiLabel,
icon: getPageIcon(featureUiComponent), // Use feature icon for instance
path: instance.views.length > 0 ? instance.views[0].uiPath : undefined,
children,
defaultExpanded: false,
};
}
/**
* Convert a MandateFeature to TreeNodeItem
*/
function mandateFeatureToTreeNode(feature: MandateFeature): TreeNodeItem | null {
if (feature.instances.length === 0) {
return null;
}
return {
id: feature.uiComponent,
label: feature.uiLabel,
icon: getPageIcon(feature.uiComponent),
badge: feature.instances.length,
children: feature.instances.map(featureInstanceToTreeNode),
defaultExpanded: false,
};
}
/**
* Convert a NavigationMandate to TreeNodeItem
*
* FLAT STRUCTURE: Instances are listed directly under mandate (no feature grouping).
* Each instance shows the feature's icon for visual distinction.
*
* Before: Mandate Feature Instance Views
* Now: Mandate Instance (with feature icon) Views
*/
function navigationMandateToTreeNode(mandate: NavigationMandate): TreeNodeItem | null {
if (mandate.features.length === 0) {
return null;
}
const children = mandate.features
.map(mandateFeatureToTreeNode)
.filter((node): node is TreeNodeItem => node !== null);
// Flatten: collect all instances from all features directly under mandate
const instanceNodes: TreeNodeItem[] = [];
for (const feature of mandate.features) {
for (const instance of feature.instances) {
instanceNodes.push(featureInstanceToTreeNode(instance, feature.uiComponent));
}
}
if (children.length === 0) {
if (instanceNodes.length === 0) {
return null;
}
return {
id: mandate.id,
label: mandate.uiLabel,
children,
children: instanceNodes,
defaultExpanded: true,
};
}

View file

@ -35,7 +35,7 @@ export const UserSection: React.FC = () => {
};
const handleBilling = () => {
navigate('/billing');
navigate('/billing/transactions');
setShowMenu(false);
};

View file

@ -42,133 +42,90 @@
============================================================================ */
.providerMultiSelect {
display: flex;
flex-direction: column;
gap: var(--spacing-xs, 4px);
position: relative;
display: inline-block;
}
.providerMultiSelect.collapsed {
/* Collapsed state styles */
}
.providerMultiSelect.expanded {
/* Expanded state styles */
}
/* Collapsible Header Button */
.collapseHeader {
/* Trigger Button - matches iconButton style from PlaygroundPage */
.triggerButton {
display: flex;
align-items: center;
gap: var(--spacing-xs, 4px);
padding: var(--spacing-xs, 4px) var(--spacing-sm, 8px);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-md, 6px);
background: var(--color-bg-input);
color: var(--color-text-primary);
font-size: var(--font-size-sm, 0.875rem);
justify-content: center;
width: 36px;
height: 36px;
border: 1px solid var(--border-color, #3a3a3a);
border-radius: 6px;
background: var(--surface-color, #2d2d2d);
color: var(--text-secondary, #888);
cursor: pointer;
transition: all 0.2s ease;
min-width: 140px;
transition: all 0.2s;
}
.collapseHeader:hover:not(:disabled) {
border-color: var(--color-primary);
background: var(--color-bg-hover);
.triggerButton:hover:not(:disabled) {
background: var(--bg-secondary, #3a3a3a);
color: var(--text-primary, #fff);
}
.collapseHeader:disabled {
opacity: 0.6;
.triggerButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.summaryIcons {
font-size: 0.9em;
.buttonIcon {
font-size: 1.1rem;
}
.summaryText {
flex: 1;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.expandIcon {
font-size: 0.7em;
color: var(--color-text-secondary);
}
/* Expandable Content - opens upward */
.expandableContent {
/* Dropdown Content - opens upward */
.dropdownContent {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
z-index: 100;
margin-bottom: var(--spacing-xs, 4px);
padding: var(--spacing-sm, 8px);
background: #2d2d2d;
color: #e0e0e0;
border: 1px solid #444;
border-radius: var(--border-radius-md, 6px);
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.4);
min-width: 200px;
bottom: calc(100% + 4px);
left: 50%;
transform: translateX(-50%);
z-index: 1000;
padding: 8px;
background: var(--surface-color, #2d2d2d);
border: 1px solid var(--border-color, #3a3a3a);
border-radius: 6px;
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.5);
min-width: 220px;
}
.expandableContent .label {
color: #b0b0b0;
}
.expandableContent .checkboxList {
background: #252525;
}
.expandableContent .checkboxItem {
color: #e0e0e0;
}
.expandableContent .checkboxItem:hover {
background: #3a3a3a;
}
.expandableContent .providerName {
color: #e0e0e0;
}
.expandableContent .hint {
color: #888;
}
.expandableContent .actionButton {
background: #3a3a3a;
color: #e0e0e0;
border-color: #555;
}
.expandableContent .actionButton:hover:not(:disabled) {
background: #4a4a4a;
.dropdownHeader {
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary, #888);
padding: 4px 8px;
margin-bottom: 4px;
border-bottom: 1px solid var(--border-color, #3a3a3a);
}
.selectActions {
display: flex;
gap: var(--spacing-xs, 4px);
gap: 4px;
margin-bottom: 8px;
}
.actionButton {
padding: 2px 8px;
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm, 4px);
background: var(--color-bg-secondary);
color: var(--color-text-secondary);
font-size: var(--font-size-xs, 0.75rem);
flex: 1;
padding: 4px 8px;
border: 1px solid var(--border-color, #3a3a3a);
border-radius: 4px;
background: var(--bg-secondary, #252525);
color: var(--text-secondary, #888);
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
}
.actionButton:hover:not(:disabled) {
background: var(--color-bg-hover);
border-color: var(--color-primary);
background: var(--bg-hover, #3a3a3a);
color: var(--text-primary, #fff);
}
.actionButton.active {
background: var(--primary-color, #f25843);
border-color: var(--primary-color, #f25843);
color: #fff;
}
.actionButton:disabled {
@ -179,24 +136,27 @@
.checkboxList {
display: flex;
flex-direction: column;
gap: var(--spacing-xs, 4px);
padding: var(--spacing-sm, 8px);
background: var(--color-bg-secondary);
border-radius: var(--border-radius-md, 6px);
gap: 2px;
padding: 4px;
background: var(--bg-secondary, #252525);
border-radius: 4px;
max-height: 200px;
overflow-y: auto;
}
.checkboxItem {
display: flex;
align-items: center;
gap: var(--spacing-sm, 8px);
padding: var(--spacing-xs, 4px) var(--spacing-sm, 8px);
border-radius: var(--border-radius-sm, 4px);
gap: 8px;
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s ease;
transition: background 0.15s ease;
color: var(--text-primary, #e0e0e0);
}
.checkboxItem:hover {
background: var(--color-bg-hover);
background: var(--bg-hover, #3a3a3a);
}
.checkboxItem.disabled {
@ -205,34 +165,35 @@
}
.checkboxItem input[type="checkbox"] {
width: 16px;
height: 16px;
width: 14px;
height: 14px;
cursor: inherit;
accent-color: var(--primary-color, #f25843);
}
.icon {
font-size: 1.1em;
font-size: 1rem;
}
.providerName {
font-size: var(--font-size-sm, 0.875rem);
color: var(--color-text-primary);
font-size: 0.8rem;
color: var(--text-primary, #e0e0e0);
}
.hint {
font-size: var(--font-size-xs, 0.75rem);
color: var(--color-text-tertiary);
font-style: italic;
padding: var(--spacing-xs, 4px) 0;
font-size: 0.7rem;
color: var(--text-tertiary, #666);
text-align: center;
padding: 4px 0;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-md, 16px);
color: var(--color-text-secondary);
font-size: var(--font-size-sm, 0.875rem);
padding: 12px;
color: var(--text-secondary, #888);
font-size: 0.8rem;
}
/* ============================================================================

View file

@ -10,7 +10,7 @@
* - Lädt verfügbare Provider aus dem Billing-System
*/
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react';
import { useBilling } from '../../hooks/useBilling';
import styles from './ProviderSelector.module.css';
@ -20,6 +20,7 @@ const PROVIDER_LABELS: Record<string, string> = {
openai: 'OpenAI (GPT)',
perplexity: 'Perplexity',
tavily: 'Tavily (Web Search)',
privatellm: 'Private LLM',
internal: 'Internal',
};
@ -29,6 +30,7 @@ const PROVIDER_ICONS: Record<string, string> = {
openai: '💬',
perplexity: '🔍',
tavily: '🌐',
privatellm: '🔒',
internal: '🏠',
};
@ -112,6 +114,7 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
defaultExpanded = false,
}) => {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const containerRef = useRef<HTMLDivElement>(null);
const { allowedProviders, loadAllowedProviders, loading } = useBilling();
useEffect(() => {
@ -120,66 +123,84 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
}
}, []);
// Click outside handler
const handleClickOutside = useCallback((event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsExpanded(false);
}
}, []);
useEffect(() => {
if (isExpanded) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isExpanded, handleClickOutside]);
// Check if all providers are selected (or none selected = all used)
const isAllSelected = selectedProviders.length === 0 ||
(allowedProviders.length > 0 && selectedProviders.length === allowedProviders.length);
// For checkbox display: if none selected, show all as checked (since all are used)
const effectiveSelection = selectedProviders.length === 0 ? allowedProviders : selectedProviders;
const handleToggle = (provider: string) => {
if (selectedProviders.includes(provider)) {
onChange(selectedProviders.filter((p) => p !== provider));
// Use effectiveSelection for toggle logic (handles empty = all case)
if (effectiveSelection.includes(provider)) {
// Deactivate: remove from effective selection
onChange(effectiveSelection.filter((p) => p !== provider));
} else {
onChange([...selectedProviders, provider]);
// Activate: add to effective selection
onChange([...effectiveSelection, provider]);
}
};
const handleSelectAll = () => {
onChange(allowedProviders);
onChange([...allowedProviders]);
};
const handleSelectNone = () => {
onChange([]);
};
// Summary text for collapsed view
const summaryText = useMemo(() => {
if (selectedProviders.length === 0) {
return 'Alle Provider';
}
if (selectedProviders.length === 1) {
return PROVIDER_LABELS[selectedProviders[0]] || selectedProviders[0];
}
return `${selectedProviders.length} Provider`;
}, [selectedProviders]);
// Summary icons for collapsed view
const summaryIcons = useMemo(() => {
if (selectedProviders.length === 0) {
// Summary icon for button
const summaryIcon = useMemo(() => {
if (selectedProviders.length === 0 || selectedProviders.length === allowedProviders.length) {
return '🤖';
}
return selectedProviders.slice(0, 3).map(p => PROVIDER_ICONS[p] || '🔌').join('');
}, [selectedProviders]);
if (selectedProviders.length === 1) {
return PROVIDER_ICONS[selectedProviders[0]] || '🔌';
}
return '🤖';
}, [selectedProviders, allowedProviders]);
return (
<div className={`${styles.providerMultiSelect} ${className || ''} ${isExpanded ? styles.expanded : styles.collapsed}`}>
{/* Collapsible Header */}
<div
ref={containerRef}
className={`${styles.providerMultiSelect} ${className || ''} ${isExpanded ? styles.expanded : styles.collapsed}`}
>
{/* Trigger Button - styled like iconButton */}
<button
type="button"
className={styles.collapseHeader}
className={styles.triggerButton}
onClick={() => setIsExpanded(!isExpanded)}
disabled={disabled}
title="Provider auswählen"
>
<span className={styles.summaryIcons}>{summaryIcons}</span>
<span className={styles.summaryText}>{summaryText}</span>
<span className={styles.expandIcon}>{isExpanded ? '▲' : '▼'}</span>
<span className={styles.buttonIcon}>{summaryIcon}</span>
</button>
{/* Expandable Content */}
{/* Dropdown Content */}
{isExpanded && (
<div className={styles.expandableContent}>
{showLabel && <label className={styles.label}>{label}</label>}
<div className={styles.dropdownContent}>
{showLabel && <div className={styles.dropdownHeader}>{label}</div>}
<div className={styles.selectActions}>
<button
type="button"
onClick={handleSelectAll}
disabled={disabled}
className={styles.actionButton}
className={`${styles.actionButton} ${isAllSelected ? styles.active : ''}`}
>
Alle
</button>
@ -194,7 +215,7 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
</div>
{loading ? (
<div className={styles.loading}>Lade Provider...</div>
<div className={styles.loading}>Lade...</div>
) : (
<div className={styles.checkboxList}>
{allowedProviders.map((provider) => (
@ -204,7 +225,7 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
>
<input
type="checkbox"
checked={selectedProviders.includes(provider)}
checked={effectiveSelection.includes(provider)}
onChange={() => handleToggle(provider)}
disabled={disabled}
/>
@ -219,7 +240,7 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
{selectedProviders.length === 0 && !loading && (
<div className={styles.hint}>
Wenn keine Provider ausgewählt sind, werden alle erlaubten Provider verwendet.
Alle Provider aktiv
</div>
)}
</div>

View file

@ -1,6 +1,7 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { FaExclamationTriangle } from 'react-icons/fa';
import { Message } from '../MessagesTypes';
import { formatTimestamp } from '../MessageUtils';
import { DocumentItem, ActionInfo } from '../MessageParts';
@ -40,7 +41,9 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
workflowId
}) => {
const isUser = message.role?.toLowerCase() === 'user';
const isError = message.actionProgress === 'fail' || message.actionProgress === 'error';
const messageClass = isUser ? styles.messageUser : styles.messageAssistant;
const errorClass = isError ? styles.messageError : '';
// Debug: Log documents if in dev mode
if (import.meta.env.DEV && message.documents) {
@ -48,8 +51,16 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
}
return (
<div className={`${styles.message} ${messageClass}`}>
<div className={`${styles.message} ${messageClass} ${errorClass}`}>
<div className={styles.messageBubble}>
{/* Error indicator for failed actions */}
{isError && (
<div className={styles.errorIndicator}>
<FaExclamationTriangle className={styles.errorIcon} />
<span>Aktion fehlgeschlagen</span>
</div>
)}
{/* Message content */}
{message.message && (
<div className={styles.messageContent}>

View file

@ -64,6 +64,29 @@
border-bottom-left-radius: 4px;
}
/* Error/Failed Messages */
.messageError .messageBubble {
background-color: var(--danger-bg, #fee2e2);
border: 1px solid var(--danger-color, #dc2626);
}
.errorIndicator {
display: flex;
align-items: center;
gap: 6px;
color: var(--danger-color, #dc2626);
font-size: 12px;
font-weight: 500;
padding: 4px 8px;
background-color: rgba(220, 38, 38, 0.1);
border-radius: 4px;
margin-bottom: 4px;
}
.errorIcon {
font-size: 14px;
}
/* Message Metadata */
.messageMetadata {
display: flex;

View file

@ -18,7 +18,54 @@ interface UnifiedChatDataItem {
createdAt: number;
}
/**
* =============================================================================
* WORKFLOW LIFECYCLE STATE MACHINE
* =============================================================================
*
* WORKFLOW STATUS (from Backend):
* idle - No workflow
* running - Workflow is processing
* completed - Round finished (Backend processed "last" message)
* stopped - User stopped the workflow
* failed - Error occurred
*
* UI FLAG:
* hasRenderedLastMessage: boolean
* - true: "last" message was rendered in UI
* - false: "last" message not yet in UI
*
* POLLING LOGIC:
* POLL ACTIVE when:
* status === 'running'
* OR (status === 'completed' AND !hasRenderedLastMessage)
*
* POLL STOPS when:
* status === 'stopped'
* OR status === 'failed'
* OR hasRenderedLastMessage === true
*
* TRANSITIONS:
* [Send Button] (from any status):
* hasRenderedLastMessage = false (new round starts)
* afterTimestamp = now
* Start polling
*
* [Load Workflow]:
* Load all data
* Check if last message has status="last"
* If yes: hasRenderedLastMessage = true, no polling
* If no AND status=running: Start polling
*
* [Message with status="last" rendered]:
* hasRenderedLastMessage = true
* Stop polling
*
* =============================================================================
*/
export function useWorkflowLifecycle(instanceId: string) {
// === STATE ===
const [workflowId, setWorkflowId] = useState<string | null>(null);
const [workflowStatus, setWorkflowStatus] = useState<string>('idle');
const [currentRound, setCurrentRound] = useState<number | undefined>(undefined);
@ -26,48 +73,35 @@ export function useWorkflowLifecycle(instanceId: string) {
const [logs, setLogs] = useState<WorkflowLog[]>([]);
const [dashboardLogs, setDashboardLogs] = useState<WorkflowLog[]>([]);
const [unifiedContentLogs, setUnifiedContentLogs] = useState<WorkflowLog[]>([]);
const [statusChangedFromRunningAt, setStatusChangedFromRunningAt] = useState<number | null>(null);
const [latestStats, setLatestStats] = useState<{ priceUsd?: number; processingTime?: number; bytesSent?: number; bytesReceived?: number } | null>(null);
const prevStatusRef = useRef<string>('idle');
// === REFS FOR SYNC ACCESS ===
const statusRef = useRef<string>('idle');
const statusChangedFromRunningAtRef = useRef<number | null>(null);
const lastRenderedTimestampRef = useRef<number | null>(null);
// Track processed stat IDs to avoid double-counting
const processedStatIdsRef = useRef<Set<string>>(new Set());
// Track cumulative stats
const cumulativeStatsRef = useRef({ priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 });
// === KEY STATE MACHINE FLAG ===
// This flag tracks if the UI has rendered a message with status="last"
// Polling continues until this is true (even if backend status is "completed")
const hasRenderedLastMessageRef = useRef<boolean>(false);
const [hasRenderedLastMessage, setHasRenderedLastMessage] = useState<boolean>(false);
// === HOOKS ===
const { startWorkflow, stopWorkflow, startingWorkflow, stoppingWorkflows } = useWorkflowOperations();
const { request } = useApiRequest();
const pollingController = useWorkflowPolling();
// Store polling controller methods in refs to avoid dependency issues
const pollingControllerRef = useRef(pollingController);
pollingControllerRef.current = pollingController;
// Helper to update status and track transitions
// === HELPER: Update workflow status ===
const updateWorkflowStatus = useCallback((newStatus: string) => {
const prevStatus = prevStatusRef.current;
prevStatusRef.current = newStatus;
statusRef.current = newStatus;
setWorkflowStatus(newStatus);
// Track when status changes from 'running' to something else
if (prevStatus === 'running' && newStatus !== 'running') {
const timestamp = Date.now();
setStatusChangedFromRunningAt(timestamp);
statusChangedFromRunningAtRef.current = timestamp;
} else if (newStatus === 'running') {
setStatusChangedFromRunningAt(null);
statusChangedFromRunningAtRef.current = null;
}
console.log('📍 Status updated to:', newStatus);
}, []);
// Expose setWorkflowStatus for optimistic updates
const setWorkflowStatusOptimistic = useCallback((status: string) => {
updateWorkflowStatus(status);
}, [updateWorkflowStatus]);
// Convert backend log format to frontend format
// === HELPER: Convert backend log format to frontend format ===
const convertLogToFrontendFormat = useCallback((log: any): WorkflowLog => {
return {
id: log.id,
@ -83,15 +117,15 @@ export function useWorkflowLifecycle(instanceId: string) {
};
}, [workflowId]);
// Process unified chat data chronologically
// === CORE: Process unified chat data ===
const processUnifiedChatData = useCallback((chatData: { messages: WorkflowMessage[]; logs: WorkflowLog[]; stats: any[] }) => {
console.log('🔄 Processing unified chat data:', {
console.log('🔄 Processing chat data:', {
messages: chatData.messages?.length || 0,
logs: chatData.logs?.length || 0,
stats: chatData.stats?.length || 0
});
// Build unified timeline of all items
// Build unified timeline
const timeline: UnifiedChatDataItem[] = [];
// Add messages
@ -112,154 +146,132 @@ export function useWorkflowLifecycle(instanceId: string) {
});
});
// Add stats (if needed)
(chatData.stats || []).forEach((stat: any) => {
// Add stats
const rawStats = chatData.stats || [];
rawStats.forEach((stat: any) => {
timeline.push({
type: 'stat',
item: stat,
createdAt: stat.timestamp || stat.createdAt || Date.now()
createdAt: stat._createdAt || stat.createdAt || Date.now()
});
});
console.log('📋 Timeline created with', timeline.length, 'items');
// Sort chronologically
timeline.sort((a, b) => a.createdAt - b.createdAt);
// Process items sequentially to maintain chronological order
// Update lastRenderedTimestamp after processing all items (use latest timestamp)
// Update lastRenderedTimestamp
if (timeline.length > 0) {
const latestTimestamp = timeline[timeline.length - 1].createdAt;
lastRenderedTimestampRef.current = latestTimestamp;
lastRenderedTimestampRef.current = timeline[timeline.length - 1].createdAt;
}
// Use functional updates to avoid dependency on current state
// === CHECK FOR "LAST" MESSAGE ===
// This is the key state machine logic: detect when a "last" message arrives
let foundLastMessage = false;
timeline.forEach((item) => {
if (item.type === 'message') {
const message = item.item as WorkflowMessage;
if ((message as any).status === 'last') {
foundLastMessage = true;
console.log('🏁 Found "last" message:', message.id);
}
}
});
// === STATE MACHINE: Handle "last" message ===
if (foundLastMessage && !hasRenderedLastMessageRef.current) {
console.log('🛑 "last" message detected - stopping polling');
hasRenderedLastMessageRef.current = true;
setHasRenderedLastMessage(true);
pollingControllerRef.current.stopPolling();
}
// === UPDATE MESSAGES STATE ===
setMessages(prevMessages => {
const newMessages: WorkflowMessage[] = [...prevMessages];
let hasChanges = false;
let messagesAdded = 0;
let messagesUpdated = 0;
timeline.forEach((item) => {
if (item.type === 'message') {
const message = item.item as WorkflowMessage;
if (!message || !message.id) return;
if (!message || !message.id) {
console.warn('⚠️ Invalid message in timeline:', message);
return;
}
// Check if message already exists
const existingIndex = newMessages.findIndex(m => m.id === message.id);
if (existingIndex >= 0) {
// Always update existing message (don't compare, just update)
newMessages[existingIndex] = message;
hasChanges = true;
messagesUpdated++;
} else {
newMessages.push(message);
hasChanges = true;
messagesAdded++;
}
}
});
console.log(`📨 Messages: ${messagesAdded} added, ${messagesUpdated} updated, total: ${newMessages.length}`);
if (messagesAdded > 0 || messagesUpdated > 0) {
console.log('📨 Sample messages:', newMessages.slice(0, 3).map(m => ({ id: m.id, message: m.message?.substring(0, 50) })));
}
// Always return sorted array if we processed any messages
if (hasChanges || timeline.some(item => item.type === 'message')) {
const sorted = [...newMessages].sort(sortMessages);
console.log(`✅ Returning ${sorted.length} sorted messages`);
return sorted;
return [...newMessages].sort(sortMessages);
}
console.log('⚠️ No changes detected, returning previous messages');
return prevMessages;
});
setDashboardLogs(prevDashboardLogs => {
const newDashboardLogs: WorkflowLog[] = [...prevDashboardLogs];
// === UPDATE DASHBOARD LOGS (with operationId) ===
setDashboardLogs(prevLogs => {
const newLogs: WorkflowLog[] = [...prevLogs];
let hasChanges = false;
timeline.forEach((item) => {
if (item.type === 'log') {
const backendLog = item.item as any;
const frontendLog = convertLogToFrontendFormat(backendLog);
// Route logs based on operationId
const frontendLog = convertLogToFrontendFormat(item.item);
if (frontendLog.operationId) {
// Logs WITH operationId → Dashboard
const existingIndex = newDashboardLogs.findIndex(l => l.id === frontendLog.id);
const existingIndex = newLogs.findIndex(l => l.id === frontendLog.id);
if (existingIndex >= 0) {
// Check if log actually changed
const existingLog = newDashboardLogs[existingIndex];
if (JSON.stringify(existingLog) !== JSON.stringify(frontendLog)) {
newDashboardLogs[existingIndex] = frontendLog;
if (JSON.stringify(newLogs[existingIndex]) !== JSON.stringify(frontendLog)) {
newLogs[existingIndex] = frontendLog;
hasChanges = true;
}
} else {
newDashboardLogs.push(frontendLog);
newLogs.push(frontendLog);
hasChanges = true;
}
}
}
});
// Only return new array if there are changes
if (!hasChanges) {
return prevDashboardLogs;
}
return [...newDashboardLogs].sort(sortLogs);
return hasChanges ? [...newLogs].sort(sortLogs) : prevLogs;
});
setUnifiedContentLogs(prevUnifiedContentLogs => {
const newUnifiedContentLogs: WorkflowLog[] = [...prevUnifiedContentLogs];
// === UPDATE UNIFIED CONTENT LOGS (without operationId) ===
setUnifiedContentLogs(prevLogs => {
const newLogs: WorkflowLog[] = [...prevLogs];
let hasChanges = false;
timeline.forEach((item) => {
if (item.type === 'log') {
const backendLog = item.item as any;
const frontendLog = convertLogToFrontendFormat(backendLog);
// Route logs based on operationId
const frontendLog = convertLogToFrontendFormat(item.item);
if (!frontendLog.operationId) {
// Logs WITHOUT operationId → Unified Content Area
const existingIndex = newUnifiedContentLogs.findIndex(l => l.id === frontendLog.id);
const existingIndex = newLogs.findIndex(l => l.id === frontendLog.id);
if (existingIndex >= 0) {
// Check if log actually changed
const existingLog = newUnifiedContentLogs[existingIndex];
if (JSON.stringify(existingLog) !== JSON.stringify(frontendLog)) {
newUnifiedContentLogs[existingIndex] = frontendLog;
if (JSON.stringify(newLogs[existingIndex]) !== JSON.stringify(frontendLog)) {
newLogs[existingIndex] = frontendLog;
hasChanges = true;
}
} else {
newUnifiedContentLogs.push(frontendLog);
newLogs.push(frontendLog);
hasChanges = true;
}
}
}
});
// Only return new array if there are changes
if (!hasChanges) {
return prevUnifiedContentLogs;
}
return [...newUnifiedContentLogs].sort(sortLogs);
return hasChanges ? [...newLogs].sort(sortLogs) : prevLogs;
});
// Update combined logs for backward compatibility (using functional update)
// === UPDATE COMBINED LOGS ===
setLogs(prevLogs => {
const allLogs: WorkflowLog[] = [...prevLogs];
timeline.forEach((item) => {
if (item.type === 'log') {
const backendLog = item.item as any;
const frontendLog = convertLogToFrontendFormat(backendLog);
const frontendLog = convertLogToFrontendFormat(item.item);
const existingIndex = allLogs.findIndex(l => l.id === frontendLog.id);
if (existingIndex >= 0) {
allLogs[existingIndex] = frontendLog;
@ -272,47 +284,36 @@ export function useWorkflowLifecycle(instanceId: string) {
return [...allLogs].sort(sortLogs);
});
// Process stats - aggregate only NEW stat entries (avoid double-counting)
// === PROCESS STATS ===
const statsItems = timeline.filter(item => item.type === 'stat');
if (statsItems.length > 0) {
let hasNewStats = false;
statsItems.forEach(statItem => {
const statData = statItem.item || statItem;
const statId = statData?.id || (statItem as any).id;
const statData = statItem.item;
const statId = statData?.id;
// Skip if already processed
if (statId && processedStatIdsRef.current.has(statId)) {
return;
return; // Skip already processed
}
if (statData) {
hasNewStats = true;
// Mark as processed
if (statId) {
processedStatIdsRef.current.add(statId);
}
// Add to cumulative stats
if (statData.priceUsd !== undefined && statData.priceUsd !== null) {
cumulativeStatsRef.current.priceUsd += statData.priceUsd;
}
if (statData.processingTime !== undefined && statData.processingTime !== null) {
cumulativeStatsRef.current.processingTime += statData.processingTime;
}
if (statData.bytesSent !== undefined && statData.bytesSent !== null) {
cumulativeStatsRef.current.bytesSent += statData.bytesSent;
}
if (statData.bytesReceived !== undefined && statData.bytesReceived !== null) {
cumulativeStatsRef.current.bytesReceived += statData.bytesReceived;
}
// Accumulate stats
const price = statData.priceCHF ?? statData.priceUsd ?? 0;
if (price > 0) cumulativeStatsRef.current.priceUsd += price;
if (statData.processingTime) cumulativeStatsRef.current.processingTime += statData.processingTime;
if (statData.bytesSent) cumulativeStatsRef.current.bytesSent += statData.bytesSent;
if (statData.bytesReceived) cumulativeStatsRef.current.bytesReceived += statData.bytesReceived;
}
});
// Update state with cumulative totals
if (hasNewStats || (cumulativeStatsRef.current.bytesSent > 0 || cumulativeStatsRef.current.bytesReceived > 0 ||
cumulativeStatsRef.current.processingTime > 0 || cumulativeStatsRef.current.priceUsd > 0)) {
if (hasNewStats) {
setLatestStats({
priceUsd: cumulativeStatsRef.current.priceUsd,
processingTime: cumulativeStatsRef.current.processingTime,
@ -323,10 +324,9 @@ export function useWorkflowLifecycle(instanceId: string) {
}
}, [convertLogToFrontendFormat]);
// Poll workflow data using unified chat data endpoint
// === POLLING FUNCTION ===
const pollWorkflowData = useCallback(async (id: string) => {
try {
// Determine afterTimestamp for incremental polling
const afterTimestamp = lastRenderedTimestampRef.current || undefined;
// Fetch workflow status
@ -334,192 +334,73 @@ export function useWorkflowLifecycle(instanceId: string) {
if (workflowData) {
const status = workflowData.status || 'idle';
const round = workflowData.currentRound !== undefined ? workflowData.currentRound : undefined;
const round = workflowData.currentRound;
updateWorkflowStatus(status);
setCurrentRound(round);
if (round !== undefined) setCurrentRound(round);
// === STATE MACHINE: Check if polling should stop based on status ===
if (status === 'stopped' || status === 'failed') {
console.log(`🛑 Workflow ${status} - stopping polling immediately`);
pollingControllerRef.current.stopPolling();
return;
}
}
// Fetch unified chat data
// Fetch chat data
const chatData = await fetchChatData(request, instanceId, id, afterTimestamp);
console.log('📊 Processed chat data:', {
messagesCount: chatData.messages?.length || 0,
logsCount: chatData.logs?.length || 0,
statsCount: chatData.stats?.length || 0,
afterTimestamp: afterTimestamp
console.log('📊 Polled chat data:', {
messages: chatData.messages?.length || 0,
logs: chatData.logs?.length || 0,
stats: chatData.stats?.length || 0,
afterTimestamp
});
// If we got empty results and we're using afterTimestamp, the backend might be filtering incorrectly
// Reset timestamp to null so next poll fetches all items (but only if we have existing data)
const hasNoNewData = (chatData.messages?.length || 0) === 0 &&
(chatData.logs?.length || 0) === 0 &&
(chatData.stats?.length || 0) === 0;
// Only reset if we're using afterTimestamp and got empty results
// This handles cases where backend filtering might miss items due to timestamp precision issues
if (hasNoNewData && afterTimestamp !== undefined && lastRenderedTimestampRef.current !== null) {
console.warn('⚠️ Got empty results with afterTimestamp, resetting timestamp for next poll');
// Don't reset immediately - let this poll complete, next poll will fetch all
lastRenderedTimestampRef.current = null;
}
// Process unified chat data
// Process data (this will detect "last" message and stop polling if found)
processUnifiedChatData(chatData);
// Determine if polling should continue
const currentStatus = statusRef.current;
// Stop polling immediately for failed or stopped workflows
// For completed workflows, allow grace period (handled by useEffect)
if (currentStatus === 'failed' || currentStatus === 'stopped') {
pollingControllerRef.current.stopPolling();
return;
}
// Continue polling for 'running' status
// For 'completed' status, continue if within grace period (handled by useEffect)
// Polling will be stopped by the useEffect when grace period expires or status changes to failed/stopped
} catch (error: any) {
// Handle rate limiting (429 errors)
if (error?.status === 429 || error?.response?.status === 429) {
throw error; // Let polling controller handle rate limit backoff
}
console.error('Error polling workflow data:', error);
// Don't throw for other errors - allow polling to continue with backoff
}
}, [request, updateWorkflowStatus, processUnifiedChatData]);
// Load initial workflow data (non-polling)
const _loadWorkflowData = useCallback(async (id: string) => {
try {
const workflowData = await fetchWorkflowApi(request, id).catch(() => null);
if (!workflowData) {
setMessages([]);
setLogs([]);
setDashboardLogs([]);
setUnifiedContentLogs([]);
setLatestStats(null);
// Reset stats tracking
processedStatIdsRef.current.clear();
cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 };
return;
}
const messagesData = Array.isArray(workflowData.messages) ? workflowData.messages : [];
const logsData = Array.isArray(workflowData.logs) ? workflowData.logs : [];
const status = workflowData.status || 'idle';
const round = workflowData.currentRound !== undefined ? workflowData.currentRound : undefined;
updateWorkflowStatus(status);
setCurrentRound(round);
// Always fetch unified chat data to get all messages and logs
// Reset lastRenderedTimestamp to fetch all historical data
lastRenderedTimestampRef.current = null;
try {
const chatData = await fetchChatData(request, instanceId, id, undefined);
console.log('📥 loadWorkflowData: Fetched unified chat data:', {
messagesCount: chatData.messages?.length || 0,
logsCount: chatData.logs?.length || 0
});
processUnifiedChatData(chatData);
} catch (error) {
console.warn('⚠️ Failed to fetch unified chat data, falling back to workflowData:', error);
// Fallback to workflowData if unified chat data fails
if (messagesData.length > 0) {
setMessages([...messagesData].sort(sortMessages));
}
// Process logs and separate by operationId
const dashboardLogsList: WorkflowLog[] = [];
const unifiedContentLogsList: WorkflowLog[] = [];
logsData.forEach((log: any) => {
const frontendLog = convertLogToFrontendFormat(log);
if (frontendLog.operationId) {
dashboardLogsList.push(frontendLog);
} else {
unifiedContentLogsList.push(frontendLog);
}
});
setDashboardLogs(dashboardLogsList.sort(sortLogs));
setUnifiedContentLogs(unifiedContentLogsList.sort(sortLogs));
setLogs([...dashboardLogsList, ...unifiedContentLogsList].sort(sortLogs));
}
} catch (error) {
console.error('Error loading workflow data:', error);
console.error('❌ Polling error:', error);
}
}, [request, updateWorkflowStatus, convertLogToFrontendFormat, processUnifiedChatData]);
void _loadWorkflowData; // Intentionally unused, reserved for future use
}, [request, instanceId, updateWorkflowStatus, processUnifiedChatData]);
// Set up polling when workflow is running
// === POLLING CONTROL EFFECT ===
useEffect(() => {
if (!workflowId) {
// Only clear state if not already cleared to avoid unnecessary updates
setMessages(prev => prev.length > 0 ? [] : prev);
setLogs(prev => prev.length > 0 ? [] : prev);
setDashboardLogs(prev => prev.length > 0 ? [] : prev);
setUnifiedContentLogs(prev => prev.length > 0 ? [] : prev);
setLatestStats(null);
// Reset stats tracking
processedStatIdsRef.current.clear();
cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 };
setCurrentRound(prev => prev !== undefined ? undefined : prev);
if (statusChangedFromRunningAt !== null) {
setStatusChangedFromRunningAt(null);
statusChangedFromRunningAtRef.current = null;
}
lastRenderedTimestampRef.current = null;
pollingControllerRef.current.stopPolling();
return;
}
// Continue polling if:
// 1. Workflow is currently running, OR
// 2. Workflow just completed (within last 5 seconds) - grace period to catch final messages
// Stop polling for failed or stopped workflows immediately
const changedAtRef = statusChangedFromRunningAtRef.current;
const gracePeriodMs = 5000; // 5 seconds grace period
const timeSinceCompletion = changedAtRef !== null ? Date.now() - changedAtRef : Infinity;
const isInGracePeriod = workflowStatus === 'completed' && changedAtRef !== null && timeSinceCompletion < gracePeriodMs;
const shouldPoll = workflowStatus === 'running' || isInGracePeriod;
// === STATE MACHINE: Determine if polling should be active ===
const shouldPoll =
workflowStatus === 'running' ||
(workflowStatus === 'completed' && !hasRenderedLastMessage);
const shouldStopImmediately =
workflowStatus === 'stopped' ||
workflowStatus === 'failed' ||
hasRenderedLastMessage;
console.log('📍 Polling decision:', {
workflowStatus,
hasRenderedLastMessage,
shouldPoll,
shouldStopImmediately
});
if (shouldPoll) {
// Start polling
pollingControllerRef.current.startPolling(workflowId, pollWorkflowData);
// If in grace period, set a timer to stop polling after grace period expires
if (isInGracePeriod) {
const remainingGraceTime = gracePeriodMs - timeSinceCompletion;
const graceTimer = setTimeout(() => {
pollingControllerRef.current.stopPolling();
setStatusChangedFromRunningAt(null);
statusChangedFromRunningAtRef.current = null;
}, remainingGraceTime + 100); // Small buffer
return () => {
clearTimeout(graceTimer);
pollingControllerRef.current.stopPolling();
};
}
} else {
// Stop polling for failed, stopped, or completed (after grace period) workflows
} else if (shouldStopImmediately) {
pollingControllerRef.current.stopPolling();
// Clear the status change timestamp when we stop polling
if (statusChangedFromRunningAt !== null) {
setStatusChangedFromRunningAt(null);
statusChangedFromRunningAtRef.current = null;
}
}
return () => {
pollingControllerRef.current.stopPolling();
};
}, [workflowStatus, workflowId, pollWorkflowData, statusChangedFromRunningAt]);
}, [workflowStatus, workflowId, hasRenderedLastMessage, pollWorkflowData]);
// === START WORKFLOW (Send Button) ===
const handleStartWorkflow = useCallback(async (
workflowData: StartWorkflowRequest,
options?: { workflowId?: string; workflowMode?: 'Dynamic' | 'Automation' }
@ -529,10 +410,24 @@ export function useWorkflowLifecycle(instanceId: string) {
if (result.success && result.data) {
const workflow = result.data as Workflow;
// === STATE MACHINE: New round starts ===
console.log('🚀 Starting workflow:', workflow.id);
// Reset state for new round
setWorkflowId(workflow.id);
hasRenderedLastMessageRef.current = false;
setHasRenderedLastMessage(false);
// Set afterTimestamp to NOW - only poll for new data
lastRenderedTimestampRef.current = Date.now();
// Start polling immediately
pollingControllerRef.current.startPolling(workflow.id, pollWorkflowData);
// Update status
updateWorkflowStatus(workflow.status || 'running');
// Reset lastRenderedTimestamp for new workflow
lastRenderedTimestampRef.current = null;
return { success: true, data: result.data };
} else {
return { success: false, error: result.error || 'Failed to start workflow' };
@ -540,8 +435,9 @@ export function useWorkflowLifecycle(instanceId: string) {
} catch (error: any) {
return { success: false, error: error.message || 'Failed to start workflow' };
}
}, [instanceId, startWorkflow, updateWorkflowStatus]);
}, [instanceId, startWorkflow, updateWorkflowStatus, pollWorkflowData]);
// === STOP WORKFLOW ===
const handleStopWorkflow = useCallback(async () => {
if (!workflowId) {
return { success: false, error: 'No workflow to stop' };
@ -552,6 +448,7 @@ export function useWorkflowLifecycle(instanceId: string) {
if (result.success) {
updateWorkflowStatus('stopped');
pollingControllerRef.current.stopPolling();
return { success: true };
} else {
return { success: false, error: result.error || 'Failed to stop workflow' };
@ -560,31 +457,44 @@ export function useWorkflowLifecycle(instanceId: string) {
return { success: false, error: error.message || 'Failed to stop workflow' };
}
}, [instanceId, workflowId, stopWorkflow, updateWorkflowStatus]);
// === RESET WORKFLOW ===
const resetWorkflow = useCallback(() => {
console.log('🔄 Resetting workflow state');
setWorkflowId(null);
prevStatusRef.current = 'idle';
statusRef.current = 'idle';
updateWorkflowStatus('idle');
setCurrentRound(undefined);
setMessages([]);
setLogs([]);
setDashboardLogs([]);
setUnifiedContentLogs([]);
setLatestStats(null);
// Reset stats tracking
// Reset refs
lastRenderedTimestampRef.current = null;
processedStatIdsRef.current.clear();
cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 };
setStatusChangedFromRunningAt(null);
statusChangedFromRunningAtRef.current = null;
lastRenderedTimestampRef.current = null;
hasRenderedLastMessageRef.current = false;
setHasRenderedLastMessage(false);
pollingControllerRef.current.stopPolling();
}, [updateWorkflowStatus]);
// === SELECT/LOAD WORKFLOW ===
const selectWorkflow = useCallback(async (workflowIdToSelect: string) => {
try {
console.log('📥 Loading workflow:', workflowIdToSelect);
// Reset state
setWorkflowId(workflowIdToSelect);
// Reset lastRenderedTimestamp and stats for new workflow selection
lastRenderedTimestampRef.current = null;
processedStatIdsRef.current.clear();
cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 };
hasRenderedLastMessageRef.current = false;
setHasRenderedLastMessage(false);
// Fetch workflow data
const workflowData = await fetchWorkflowApi(request, workflowIdToSelect).catch(() => null);
if (!workflowData) {
@ -597,58 +507,66 @@ export function useWorkflowLifecycle(instanceId: string) {
return;
}
const messagesData = Array.isArray(workflowData.messages) ? workflowData.messages : [];
const logsData = Array.isArray(workflowData.logs) ? workflowData.logs : [];
const status = workflowData.status || 'idle';
const round = workflowData.currentRound !== undefined ? workflowData.currentRound : undefined;
const round = workflowData.currentRound;
updateWorkflowStatus(status);
setCurrentRound(round);
if (round !== undefined) setCurrentRound(round);
// Always fetch unified chat data to get all messages and logs (regardless of status)
// This ensures completed workflows also show their logs
// Fetch all chat data (no afterTimestamp = get everything)
try {
const chatData = await fetchChatData(request, instanceId, workflowIdToSelect, undefined);
console.log('📥 selectWorkflow: Fetched unified chat data:', {
messagesCount: chatData.messages?.length || 0,
logsCount: chatData.logs?.length || 0,
status
console.log('📥 Loaded chat data:', {
messages: chatData.messages?.length || 0,
logs: chatData.logs?.length || 0,
stats: chatData.stats?.length || 0
});
processUnifiedChatData(chatData);
} catch (error) {
console.warn('⚠️ Failed to fetch unified chat data, falling back to workflowData:', error);
// Fallback to workflowData if unified chat data fails
if (messagesData.length > 0) {
setMessages([...messagesData].sort(sortMessages));
// === STATE MACHINE: Check if last message has status="last" ===
const allMessages = chatData.messages || [];
const sortedMessages = [...allMessages].sort((a, b) => {
const aTime = a.publishedAt || a.timestamp || 0;
const bTime = b.publishedAt || b.timestamp || 0;
return bTime - aTime; // Sort descending (newest first)
});
const lastMessage = sortedMessages[0];
const lastMessageStatus = lastMessage ? (lastMessage as any).status : null;
console.log('📍 Last message status:', lastMessageStatus);
if (lastMessageStatus === 'last') {
// Round is complete - don't start polling
hasRenderedLastMessageRef.current = true;
setHasRenderedLastMessage(true);
console.log('✅ Workflow round complete - no polling needed');
} else if (status === 'running') {
// Workflow is running - polling will start via useEffect
console.log('🔄 Workflow is running - polling will start');
}
// Process logs and separate by operationId
const dashboardLogsList: WorkflowLog[] = [];
const unifiedContentLogsList: WorkflowLog[] = [];
// Process the data
processUnifiedChatData(chatData);
logsData.forEach((log: any) => {
const frontendLog = convertLogToFrontendFormat(log);
if (frontendLog.operationId) {
dashboardLogsList.push(frontendLog);
} else {
unifiedContentLogsList.push(frontendLog);
}
});
setDashboardLogs(dashboardLogsList.sort(sortLogs));
setUnifiedContentLogs(unifiedContentLogsList.sort(sortLogs));
setLogs([...dashboardLogsList, ...unifiedContentLogsList].sort(sortLogs));
} catch (error) {
console.warn('⚠️ Failed to fetch chat data:', error);
updateWorkflowStatus('idle');
}
// If workflow is running, polling will start automatically via useEffect
} catch (error) {
console.error('Error selecting workflow:', error);
console.error('❌ Error selecting workflow:', error);
}
}, [request, updateWorkflowStatus, convertLogToFrontendFormat, processUnifiedChatData]);
}, [request, instanceId, updateWorkflowStatus, processUnifiedChatData]);
// === EXPOSE STATUS SETTER FOR OPTIMISTIC UPDATES ===
const setWorkflowStatusOptimistic = useCallback((status: string) => {
updateWorkflowStatus(status);
}, [updateWorkflowStatus]);
// === COMPUTED VALUES ===
const isRunning = workflowStatus === 'running';
const isStopping = workflowId ? stoppingWorkflows.has(workflowId) : false;
return {
workflowId,
workflowStatus,
@ -661,6 +579,7 @@ export function useWorkflowLifecycle(instanceId: string) {
dashboardLogs,
unifiedContentLogs,
latestStats,
hasRenderedLastMessage,
startWorkflow: handleStartWorkflow,
stopWorkflow: handleStopWorkflow,
resetWorkflow,

View file

@ -138,8 +138,12 @@
/* Feature Content */
.featureContent {
flex: 1;
overflow: auto;
/* Let child components handle their own scrolling for sticky headers */
overflow: hidden;
padding: 1.5rem;
/* Maintain flex chain for child components */
display: flex;
flex-direction: column;
}
/* Dark Theme */

View file

@ -81,7 +81,8 @@
/* Content */
.content {
flex: 1;
overflow: auto;
/* Let child components handle their own scrolling for sticky headers */
overflow: hidden;
background: var(--bg-primary, #ffffff);
}

View file

@ -6,7 +6,12 @@
.adminPage {
padding: 1.5rem;
min-height: 100%;
/* Fill parent height and enable flex layout for sticky table headers */
height: 100%;
max-height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.pageHeader {

View file

@ -6,9 +6,8 @@
.billingDashboard {
padding: 1.5rem;
max-width: 1200px;
margin: 0 auto;
min-height: 100%;
width: 100%;
}
.pageHeader {
@ -490,7 +489,7 @@
@media (max-width: 768px) {
.billingDashboard {
padding: 1rem;
padding: 0.75rem;
}
.balanceGrid {

View file

@ -6,6 +6,7 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useBilling, type BillingBalance, type UsageReport } from '../../hooks/useBilling';
import { BillingNav } from './BillingNav';
import styles from './Billing.module.css';
// ============================================================================
@ -187,6 +188,8 @@ export const BillingDashboard: React.FC = () => {
<p className={styles.subtitle}>Übersicht über Guthaben und Nutzung</p>
</header>
<BillingNav />
{/* Balance Cards */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Guthaben</h2>

View file

@ -0,0 +1,443 @@
/**
* BillingDataView
*
* Unified billing page with internal tabs:
* - Tab "Übersicht": Balance cards + Statistics (from BillingDashboard)
* - Tab "Transaktionen": Transaction table with FormGeneratorTable
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
import api from '../../api';
import { useBilling, type BillingBalance, type UsageReport } from '../../hooks/useBilling';
import { UserTransaction } from '../../api/billingApi';
import styles from './Billing.module.css';
// ============================================================================
// BALANCE CARD COMPONENT
// ============================================================================
interface BalanceCardProps {
balance: BillingBalance;
}
const BalanceCard: React.FC<BalanceCardProps> = ({ balance }) => {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(amount);
};
const getBillingModelLabel = (model: string) => {
switch (model) {
case 'PREPAY_MANDATE': return 'Prepaid (Mandant)';
case 'PREPAY_USER': return 'Prepaid (Benutzer)';
case 'CREDIT_POSTPAY': return 'Kredit';
case 'UNLIMITED': return 'Unlimited';
default: return model;
}
};
return (
<div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}>
<div className={styles.balanceHeader}>
<h3 className={styles.mandateName}>{balance.mandateName}</h3>
<span className={styles.billingModel}>{getBillingModelLabel(balance.billingModel)}</span>
</div>
<div className={styles.balanceAmount}>
{formatCurrency(balance.balance)}
</div>
{balance.isWarning && (
<div className={styles.warningBadge}>
Niedriges Guthaben
</div>
)}
</div>
);
};
// ============================================================================
// STATISTICS CHART COMPONENT
// ============================================================================
interface StatisticsChartProps {
statistics: UsageReport | null;
loading?: boolean;
}
const StatisticsChart: React.FC<StatisticsChartProps> = ({ statistics, loading }) => {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(amount);
};
if (loading) {
return <div className={styles.loadingPlaceholder}>Lade Statistiken...</div>;
}
if (!statistics) {
return <div className={styles.noData}>Keine Statistiken verfügbar</div>;
}
const maxProviderCost = Math.max(...Object.values(statistics.costByProvider), 1);
return (
<div className={styles.statisticsChart}>
<div className={styles.totalCost}>
<span className={styles.totalLabel}>Gesamtkosten</span>
<span className={styles.totalAmount}>{formatCurrency(statistics.totalCost)}</span>
</div>
<div className={styles.chartSection}>
<h4>Kosten nach Anbieter</h4>
{Object.entries(statistics.costByProvider).length === 0 ? (
<div className={styles.noData}>Keine Daten</div>
) : (
<div className={styles.barChart}>
{Object.entries(statistics.costByProvider).map(([provider, cost]) => (
<div key={provider} className={styles.barRow}>
<span className={styles.barLabel}>{provider}</span>
<div className={styles.barContainer}>
<div
className={styles.bar}
style={{ width: `${(cost / maxProviderCost) * 100}%` }}
/>
</div>
<span className={styles.barValue}>{formatCurrency(cost)}</span>
</div>
))}
</div>
)}
</div>
<div className={styles.chartSection}>
<h4>Kosten nach Feature</h4>
{Object.entries(statistics.costByFeature).length === 0 ? (
<div className={styles.noData}>Keine Daten</div>
) : (
<div className={styles.featureList}>
{Object.entries(statistics.costByFeature).map(([feature, cost]) => (
<div key={feature} className={styles.featureRow}>
<span className={styles.featureLabel}>{feature}</span>
<span className={styles.featureValue}>{formatCurrency(cost)}</span>
</div>
))}
</div>
)}
</div>
</div>
);
};
// ============================================================================
// TAB NAVIGATION COMPONENT
// ============================================================================
type TabType = 'overview' | 'transactions';
interface TabNavProps {
activeTab: TabType;
onTabChange: (tab: TabType) => void;
}
const TabNav: React.FC<TabNavProps> = ({ activeTab, onTabChange }) => {
const navLinkStyle = (isActive: boolean) => ({
padding: '8px 16px',
textDecoration: 'none',
borderRadius: '4px',
backgroundColor: isActive ? 'var(--color-primary, #3b82f6)' : 'transparent',
color: isActive ? 'white' : 'var(--color-text, #e0e0e0)',
fontWeight: isActive ? 600 : 400,
cursor: 'pointer',
border: 'none',
fontSize: '14px',
});
return (
<nav style={{
display: 'flex',
gap: '8px',
marginBottom: '24px',
borderBottom: '1px solid var(--color-border, #333)',
paddingBottom: '8px'
}}>
<button
onClick={() => onTabChange('overview')}
style={navLinkStyle(activeTab === 'overview')}
>
Übersicht
</button>
<button
onClick={() => onTabChange('transactions')}
style={navLinkStyle(activeTab === 'transactions')}
>
Transaktionen
</button>
</nav>
);
};
// ============================================================================
// MAIN COMPONENT
// ============================================================================
export const BillingDataView: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabType>('overview');
// Dashboard state (for Overview tab)
const {
balances,
statistics,
loading: dashboardLoading,
loadStatistics
} = useBilling();
const [selectedPeriod, setSelectedPeriod] = useState<'month' | 'year'>('month');
const [selectedYear, setSelectedYear] = useState(new Date().getFullYear());
const [selectedMonth, setSelectedMonth] = useState(new Date().getMonth() + 1);
// Transactions state (for Transactions tab)
const [transactions, setTransactions] = useState<UserTransaction[]>([]);
const [transactionsLoading, setTransactionsLoading] = useState(false);
const [transactionsError, setTransactionsError] = useState<string | null>(null);
// Load statistics when period changes
useEffect(() => {
if (selectedPeriod === 'month') {
loadStatistics('month', selectedYear);
} else {
loadStatistics('year', selectedYear);
}
}, [selectedPeriod, selectedYear, loadStatistics]);
// Load transactions
const loadTransactions = useCallback(async () => {
try {
setTransactionsLoading(true);
setTransactionsError(null);
const response = await api.get('/api/billing/view/users/transactions', {
params: { limit: 500 }
});
setTransactions(response.data || []);
} catch (err: any) {
console.error('Failed to load transactions:', err);
setTransactionsError(err.response?.data?.detail || err.message || 'Fehler beim Laden der Transaktionen');
} finally {
setTransactionsLoading(false);
}
}, []);
// Load transactions when switching to transactions tab
useEffect(() => {
if (activeTab === 'transactions' && transactions.length === 0) {
loadTransactions();
}
}, [activeTab, transactions.length, loadTransactions]);
// Available years
const availableYears = useMemo(() => {
const current = new Date().getFullYear();
return [current, current - 1, current - 2];
}, []);
// Available months
const availableMonths = [
{ value: 1, label: 'Januar' },
{ value: 2, label: 'Februar' },
{ value: 3, label: 'März' },
{ value: 4, label: 'April' },
{ value: 5, label: 'Mai' },
{ value: 6, label: 'Juni' },
{ value: 7, label: 'Juli' },
{ value: 8, label: 'August' },
{ value: 9, label: 'September' },
{ value: 10, label: 'Oktober' },
{ value: 11, label: 'November' },
{ value: 12, label: 'Dezember' },
];
// Transform transactions for table display
const tableData = useMemo(() => {
return transactions.map((t, index) => ({
_uniqueId: `${t.id}-${t.mandateId}-${index}`,
id: t.id,
createdAt: t.createdAt,
mandateId: t.mandateId,
mandateName: t.mandateName || '-',
userId: t.userId,
userName: t.userName || '-',
transactionType: t.transactionType,
description: t.description || '-',
aicoreProvider: t.aicoreProvider || '-',
featureCode: t.featureCode || '-',
amount: t.transactionType === 'DEBIT' ? -t.amount : t.amount,
}));
}, [transactions]);
// Table column definitions
const columns: ColumnConfig[] = useMemo(() => [
{
key: 'createdAt',
label: 'Datum',
type: 'datetime',
sortable: true,
width: 160,
},
{
key: 'mandateName',
label: 'Mandant',
type: 'text',
sortable: true,
filterable: true,
searchable: true,
width: 150,
},
{
key: 'userName',
label: 'Benutzer',
type: 'text',
sortable: true,
filterable: true,
searchable: true,
width: 150,
},
{
key: 'transactionType',
label: 'Typ',
type: 'text',
sortable: true,
filterable: true,
filterOptions: ['CREDIT', 'DEBIT', 'ADJUSTMENT'],
width: 100,
},
{
key: 'description',
label: 'Beschreibung',
type: 'text',
searchable: true,
width: 250,
},
{
key: 'aicoreProvider',
label: 'Anbieter',
type: 'text',
sortable: true,
filterable: true,
width: 120,
},
{
key: 'featureCode',
label: 'Feature',
type: 'text',
sortable: true,
filterable: true,
width: 120,
},
{
key: 'amount',
label: 'Betrag (CHF)',
type: 'number',
sortable: true,
width: 120,
},
], []);
return (
<div className={styles.billingDashboard}>
<header className={styles.pageHeader}>
<h1>Billing</h1>
<p className={styles.subtitle}>Guthaben, Statistiken und Transaktionen</p>
</header>
<TabNav activeTab={activeTab} onTabChange={setActiveTab} />
{/* Overview Tab */}
{activeTab === 'overview' && (
<>
{/* Balance Cards */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Guthaben</h2>
{dashboardLoading ? (
<div className={styles.loadingPlaceholder}>Lade Guthaben...</div>
) : balances.length === 0 ? (
<div className={styles.noData}>Keine Abrechnungskonten vorhanden</div>
) : (
<div className={styles.balanceGrid}>
{balances.map((balance) => (
<BalanceCard key={balance.mandateId} balance={balance} />
))}
</div>
)}
</section>
{/* Statistics */}
<section className={styles.section}>
<div className={styles.sectionHeader}>
<h2 className={styles.sectionTitle}>Nutzungsstatistik</h2>
<div className={styles.periodSelector}>
<select
value={selectedPeriod}
onChange={(e) => setSelectedPeriod(e.target.value as 'month' | 'year')}
className={styles.select}
>
<option value="month">Monat</option>
<option value="year">Jahr</option>
</select>
<select
value={selectedYear}
onChange={(e) => setSelectedYear(Number(e.target.value))}
className={styles.select}
>
{availableYears.map((year) => (
<option key={year} value={year}>{year}</option>
))}
</select>
{selectedPeriod === 'month' && (
<select
value={selectedMonth}
onChange={(e) => setSelectedMonth(Number(e.target.value))}
className={styles.select}
>
{availableMonths.map((month) => (
<option key={month.value} value={month.value}>{month.label}</option>
))}
</select>
)}
</div>
</div>
<StatisticsChart statistics={statistics} loading={dashboardLoading} />
</section>
</>
)}
{/* Transactions Tab */}
{activeTab === 'transactions' && (
<>
{transactionsError && (
<div className={styles.errorMessage}>
{transactionsError}
</div>
)}
<FormGeneratorTable
data={tableData}
columns={columns}
loading={transactionsLoading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
idField="_uniqueId"
emptyMessage="Keine Transaktionen vorhanden"
onRefresh={loadTransactions}
/>
</>
)}
</div>
);
};
export default BillingDataView;

View file

@ -0,0 +1,280 @@
/**
* Billing Mandate View Page
*
* Shows mandate-level balances and transactions for SysAdmins.
* Includes filtering by mandate.
*/
import React, { useEffect, useState, useMemo } from 'react';
import { useApiRequest } from '../../hooks/useApi';
import {
fetchMandateViewBalances,
fetchMandateViewTransactions,
type MandateBalance,
type BillingTransaction
} from '../../api/billingApi';
import { BillingNav } from './BillingNav';
import styles from './Billing.module.css';
// ============================================================================
// MANDATE BALANCE TABLE
// ============================================================================
interface MandateBalanceTableProps {
balances: MandateBalance[];
selectedMandateId: string | null;
onSelectMandate: (mandateId: string | null) => void;
}
const MandateBalanceTable: React.FC<MandateBalanceTableProps> = ({
balances,
selectedMandateId,
onSelectMandate
}) => {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(amount);
};
const getBillingModelLabel = (model: string) => {
switch (model) {
case 'PREPAY_MANDATE': return 'Prepaid (Mandant)';
case 'PREPAY_USER': return 'Prepaid (Benutzer)';
case 'CREDIT_POSTPAY': return 'Kredit';
case 'UNLIMITED': return 'Unlimited';
default: return model;
}
};
return (
<div style={{ overflowX: 'auto' }}>
<table className={styles.transactionsTable}>
<thead>
<tr>
<th>Mandant</th>
<th>Billing-Modell</th>
<th>Anzahl Benutzer</th>
<th>Standard-Guthaben</th>
<th style={{ textAlign: 'right' }}>Gesamtguthaben</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{balances.map((balance) => (
<tr
key={balance.mandateId}
className={selectedMandateId === balance.mandateId ? styles.selectedRow : ''}
>
<td>{balance.mandateName || balance.mandateId}</td>
<td>{getBillingModelLabel(balance.billingModel)}</td>
<td>{balance.userCount}</td>
<td>{formatCurrency(balance.defaultUserCredit)}</td>
<td style={{ textAlign: 'right' }}>{formatCurrency(balance.totalBalance)}</td>
<td>
<button
className={`${styles.button} ${styles.buttonSecondary}`}
onClick={() => onSelectMandate(
selectedMandateId === balance.mandateId ? null : balance.mandateId
)}
style={{ padding: '4px 8px', fontSize: '12px' }}
>
{selectedMandateId === balance.mandateId ? 'Alle' : 'Filter'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
// ============================================================================
// TRANSACTION TABLE
// ============================================================================
interface TransactionTableProps {
transactions: BillingTransaction[];
}
const TransactionTable: React.FC<TransactionTableProps> = ({ transactions }) => {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(amount);
};
const formatDate = (dateString?: string) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleString('de-CH', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
const getTypeClass = (type: string) => {
switch (type) {
case 'CREDIT': return styles.credit;
case 'DEBIT': return styles.debit;
case 'ADJUSTMENT': return styles.adjustment;
default: return '';
}
};
const getTypeLabel = (type: string) => {
switch (type) {
case 'CREDIT': return 'Gutschrift';
case 'DEBIT': return 'Belastung';
case 'ADJUSTMENT': return 'Korrektur';
default: return type;
}
};
return (
<div style={{ overflowX: 'auto' }}>
<table className={styles.transactionsTable}>
<thead>
<tr>
<th>Datum</th>
<th>Mandant</th>
<th>Typ</th>
<th>Beschreibung</th>
<th>Anbieter</th>
<th>Feature</th>
<th style={{ textAlign: 'right' }}>Betrag</th>
</tr>
</thead>
<tbody>
{transactions.map((t) => (
<tr key={t.id}>
<td>{formatDate(t.createdAt)}</td>
<td>{t.mandateName || '-'}</td>
<td>
<span className={`${styles.transactionType} ${getTypeClass(t.transactionType)}`}>
{getTypeLabel(t.transactionType)}
</span>
</td>
<td>{t.description}</td>
<td>{t.aicoreProvider || '-'}</td>
<td>{t.featureCode || '-'}</td>
<td style={{ textAlign: 'right' }}>
{t.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(t.amount)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
// ============================================================================
// MAIN COMPONENT
// ============================================================================
export const BillingMandateView: React.FC = () => {
const { request, isLoading: loading } = useApiRequest();
const [balances, setBalances] = useState<MandateBalance[]>([]);
const [transactions, setTransactions] = useState<BillingTransaction[]>([]);
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
const [limit, setLimit] = useState(100);
// Load data
useEffect(() => {
const loadData = async () => {
try {
const [balanceData, transactionData] = await Promise.all([
fetchMandateViewBalances(request),
fetchMandateViewTransactions(request, limit)
]);
setBalances(Array.isArray(balanceData) ? balanceData : []);
setTransactions(Array.isArray(transactionData) ? transactionData : []);
} catch (err) {
console.error('Error loading mandate view data:', err);
setBalances([]);
setTransactions([]);
}
};
loadData();
}, [request, limit]);
// Filter transactions by selected mandate
const filteredTransactions = useMemo(() => {
if (!selectedMandateId) return transactions;
return transactions.filter(t => t.mandateId === selectedMandateId);
}, [transactions, selectedMandateId]);
const handleLoadMore = () => {
setLimit(prev => prev + 100);
};
return (
<div className={styles.billingDashboard}>
<header className={styles.pageHeader}>
<h1>Mandanten-Billing</h1>
<p className={styles.subtitle}>Guthaben und Transaktionen pro Mandant</p>
</header>
<BillingNav />
{/* Mandate Balances */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Mandanten-Guthaben</h2>
{loading && balances.length === 0 ? (
<div className={styles.loadingPlaceholder}>Lade Daten...</div>
) : balances.length === 0 ? (
<div className={styles.noData}>Keine Mandanten mit Billing-Settings vorhanden</div>
) : (
<MandateBalanceTable
balances={balances}
selectedMandateId={selectedMandateId}
onSelectMandate={setSelectedMandateId}
/>
)}
</section>
{/* Transactions */}
<section className={styles.section}>
<div className={styles.sectionHeader}>
<h2 className={styles.sectionTitle}>
Transaktionen
{selectedMandateId && (
<span style={{ fontWeight: 'normal', fontSize: '14px', marginLeft: '8px' }}>
(gefiltert nach {balances.find(b => b.mandateId === selectedMandateId)?.mandateName || selectedMandateId})
</span>
)}
</h2>
</div>
{loading && transactions.length === 0 ? (
<div className={styles.loadingPlaceholder}>Lade Transaktionen...</div>
) : filteredTransactions.length === 0 ? (
<div className={styles.noData}>Keine Transaktionen vorhanden</div>
) : (
<>
<TransactionTable transactions={filteredTransactions} />
{transactions.length >= limit && (
<div style={{ textAlign: 'center', marginTop: 'var(--spacing-md)' }}>
<button
className={`${styles.button} ${styles.buttonSecondary}`}
onClick={handleLoadMore}
disabled={loading}
>
{loading ? 'Laden...' : 'Mehr laden'}
</button>
</div>
)}
</>
)}
</section>
</div>
);
};
export default BillingMandateView;

View file

@ -0,0 +1,54 @@
/**
* Billing Navigation Component
*
* Provides navigation between billing views.
* Simplified: Übersicht (Dashboard) + Daten (FormGeneratorTable view)
*/
import React from 'react';
import { NavLink } from 'react-router-dom';
import styles from './Billing.module.css';
export const BillingNav: React.FC = () => {
const navLinkStyle = (isActive: boolean) => ({
padding: '8px 16px',
textDecoration: 'none',
borderRadius: '4px',
backgroundColor: isActive ? 'var(--color-primary)' : 'transparent',
color: isActive ? 'white' : 'var(--color-text)',
fontWeight: isActive ? 600 : 400,
});
return (
<nav className={styles.billingNav} style={{
display: 'flex',
gap: '8px',
marginBottom: '24px',
borderBottom: '1px solid var(--color-border)',
paddingBottom: '8px'
}}>
<NavLink
to="/billing"
end
className={({ isActive }) =>
`${styles.navLink} ${isActive ? styles.navLinkActive : ''}`
}
style={({ isActive }) => navLinkStyle(isActive)}
>
Übersicht
</NavLink>
<NavLink
to="/billing/transactions"
className={({ isActive }) =>
`${styles.navLink} ${isActive ? styles.navLinkActive : ''}`
}
style={({ isActive }) => navLinkStyle(isActive)}
>
Transaktionen
</NavLink>
</nav>
);
};
export default BillingNav;

View file

@ -6,6 +6,7 @@
import React, { useEffect, useState } from 'react';
import { useBilling, type BillingTransaction } from '../../hooks/useBilling';
import { BillingNav } from './BillingNav';
import styles from './Billing.module.css';
// ============================================================================
@ -56,6 +57,7 @@ const TransactionRow: React.FC<TransactionRowProps> = ({ transaction }) => {
return (
<tr>
<td>{formatDate(transaction.createdAt)}</td>
<td>{transaction.mandateName || '-'}</td>
<td>
<span className={`${styles.transactionType} ${getTypeClass(transaction.transactionType)}`}>
{getTypeLabel(transaction.transactionType)}
@ -94,6 +96,8 @@ export const BillingTransactions: React.FC = () => {
<p className={styles.subtitle}>Übersicht aller Kontobewegungen</p>
</header>
<BillingNav />
<section className={styles.section}>
{loading && transactions.length === 0 ? (
<div className={styles.loadingPlaceholder}>Lade Transaktionen...</div>
@ -106,6 +110,7 @@ export const BillingTransactions: React.FC = () => {
<thead>
<tr>
<th>Datum</th>
<th>Mandant</th>
<th>Typ</th>
<th>Beschreibung</th>
<th>Anbieter</th>

View file

@ -0,0 +1,376 @@
/**
* Billing User View Page
*
* Shows user-level balances and transactions.
* RBAC-based: Users see only their own data, Admins see all.
* Includes filtering by mandate and user.
*/
import React, { useEffect, useState, useMemo } from 'react';
import { useApiRequest } from '../../hooks/useApi';
import {
fetchUserViewBalances,
fetchUserViewTransactions,
type UserBalance,
type UserTransaction
} from '../../api/billingApi';
import { BillingNav } from './BillingNav';
import styles from './Billing.module.css';
// ============================================================================
// USER BALANCE TABLE
// ============================================================================
interface UserBalanceTableProps {
balances: UserBalance[];
selectedMandateId: string | null;
selectedUserId: string | null;
onSelectMandate: (mandateId: string | null) => void;
onSelectUser: (userId: string | null) => void;
}
const UserBalanceTable: React.FC<UserBalanceTableProps> = ({
balances,
selectedMandateId,
selectedUserId,
onSelectMandate,
onSelectUser
}) => {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(amount);
};
// Get unique mandates and users for filter dropdowns
const uniqueMandates = useMemo(() => {
const mandates = new Map<string, string>();
balances.forEach(b => {
if (b.mandateId && !mandates.has(b.mandateId)) {
mandates.set(b.mandateId, b.mandateName || b.mandateId);
}
});
return Array.from(mandates.entries());
}, [balances]);
const uniqueUsers = useMemo(() => {
const users = new Map<string, string>();
balances.forEach(b => {
if (b.userId && !users.has(b.userId)) {
users.set(b.userId, b.userName || b.userId);
}
});
return Array.from(users.entries());
}, [balances]);
// Apply filters
const filteredBalances = useMemo(() => {
let result = balances;
if (selectedMandateId) {
result = result.filter(b => b.mandateId === selectedMandateId);
}
if (selectedUserId) {
result = result.filter(b => b.userId === selectedUserId);
}
return result;
}, [balances, selectedMandateId, selectedUserId]);
return (
<>
{/* Filter Controls */}
<div className={styles.filterControls} style={{ display: 'flex', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '12px', fontWeight: 500 }}>
Mandant:
</label>
<select
value={selectedMandateId || ''}
onChange={(e) => onSelectMandate(e.target.value || null)}
className={styles.select}
>
<option value="">Alle Mandanten</option>
{uniqueMandates.map(([id, name]) => (
<option key={id} value={id}>{name}</option>
))}
</select>
</div>
<div>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '12px', fontWeight: 500 }}>
Benutzer:
</label>
<select
value={selectedUserId || ''}
onChange={(e) => onSelectUser(e.target.value || null)}
className={styles.select}
>
<option value="">Alle Benutzer</option>
{uniqueUsers.map(([id, name]) => (
<option key={id} value={id}>{name}</option>
))}
</select>
</div>
</div>
{/* Table */}
<div style={{ overflowX: 'auto' }}>
<table className={styles.transactionsTable}>
<thead>
<tr>
<th>Mandant</th>
<th>Benutzer</th>
<th style={{ textAlign: 'right' }}>Guthaben</th>
<th style={{ textAlign: 'right' }}>Warnschwelle</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{filteredBalances.map((balance) => (
<tr
key={balance.accountId}
className={balance.isWarning ? styles.warningRow : ''}
>
<td>{balance.mandateName || balance.mandateId}</td>
<td>{balance.userName || balance.userId}</td>
<td style={{ textAlign: 'right' }}>{formatCurrency(balance.balance)}</td>
<td style={{ textAlign: 'right' }}>{formatCurrency(balance.warningThreshold)}</td>
<td>
{balance.isWarning ? (
<span className={styles.warningBadge} style={{ fontSize: '12px', padding: '2px 6px' }}>
Niedrig
</span>
) : balance.enabled ? (
<span style={{ color: 'var(--color-success)' }}>Aktiv</span>
) : (
<span style={{ color: 'var(--color-error)' }}>Deaktiviert</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
};
// ============================================================================
// USER TRANSACTION TABLE
// ============================================================================
interface UserTransactionTableProps {
transactions: UserTransaction[];
selectedMandateId: string | null;
selectedUserId: string | null;
}
const UserTransactionTable: React.FC<UserTransactionTableProps> = ({
transactions,
selectedMandateId,
selectedUserId
}) => {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(amount);
};
const formatDate = (dateString?: string) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleString('de-CH', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
const getTypeClass = (type: string) => {
switch (type) {
case 'CREDIT': return styles.credit;
case 'DEBIT': return styles.debit;
case 'ADJUSTMENT': return styles.adjustment;
default: return '';
}
};
const getTypeLabel = (type: string) => {
switch (type) {
case 'CREDIT': return 'Gutschrift';
case 'DEBIT': return 'Belastung';
case 'ADJUSTMENT': return 'Korrektur';
default: return type;
}
};
// Apply filters
const filteredTransactions = useMemo(() => {
let result = transactions;
if (selectedMandateId) {
result = result.filter(t => t.mandateId === selectedMandateId);
}
if (selectedUserId) {
result = result.filter(t => t.userId === selectedUserId);
}
return result;
}, [transactions, selectedMandateId, selectedUserId]);
return (
<div style={{ overflowX: 'auto' }}>
<table className={styles.transactionsTable}>
<thead>
<tr>
<th>Datum</th>
<th>Mandant</th>
<th>Benutzer</th>
<th>Typ</th>
<th>Beschreibung</th>
<th>Anbieter</th>
<th>Feature</th>
<th style={{ textAlign: 'right' }}>Betrag</th>
</tr>
</thead>
<tbody>
{filteredTransactions.map((t) => (
<tr key={t.id}>
<td>{formatDate(t.createdAt)}</td>
<td>{t.mandateName || '-'}</td>
<td>{t.userName || '-'}</td>
<td>
<span className={`${styles.transactionType} ${getTypeClass(t.transactionType)}`}>
{getTypeLabel(t.transactionType)}
</span>
</td>
<td>{t.description}</td>
<td>{t.aicoreProvider || '-'}</td>
<td>{t.featureCode || '-'}</td>
<td style={{ textAlign: 'right' }}>
{t.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(t.amount)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
// ============================================================================
// MAIN COMPONENT
// ============================================================================
export const BillingUserView: React.FC = () => {
const { request, isLoading: loading } = useApiRequest();
const [balances, setBalances] = useState<UserBalance[]>([]);
const [transactions, setTransactions] = useState<UserTransaction[]>([]);
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [limit, setLimit] = useState(100);
// Load data
useEffect(() => {
const loadData = async () => {
try {
const [balanceData, transactionData] = await Promise.all([
fetchUserViewBalances(request),
fetchUserViewTransactions(request, limit)
]);
setBalances(Array.isArray(balanceData) ? balanceData : []);
setTransactions(Array.isArray(transactionData) ? transactionData : []);
} catch (err) {
console.error('Error loading user view data:', err);
setBalances([]);
setTransactions([]);
}
};
loadData();
}, [request, limit]);
const handleLoadMore = () => {
setLimit(prev => prev + 100);
};
// Count filtered transactions for display
const filteredTransactionCount = useMemo(() => {
let result = transactions;
if (selectedMandateId) {
result = result.filter(t => t.mandateId === selectedMandateId);
}
if (selectedUserId) {
result = result.filter(t => t.userId === selectedUserId);
}
return result.length;
}, [transactions, selectedMandateId, selectedUserId]);
return (
<div className={styles.billingDashboard}>
<header className={styles.pageHeader}>
<h1>Benutzer-Billing</h1>
<p className={styles.subtitle}>Guthaben und Transaktionen pro Benutzer</p>
</header>
<BillingNav />
{/* User Balances */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Benutzer-Guthaben</h2>
{loading && balances.length === 0 ? (
<div className={styles.loadingPlaceholder}>Lade Daten...</div>
) : balances.length === 0 ? (
<div className={styles.noData}>Keine Benutzer-Konten vorhanden</div>
) : (
<UserBalanceTable
balances={balances}
selectedMandateId={selectedMandateId}
selectedUserId={selectedUserId}
onSelectMandate={setSelectedMandateId}
onSelectUser={setSelectedUserId}
/>
)}
</section>
{/* Transactions */}
<section className={styles.section}>
<div className={styles.sectionHeader}>
<h2 className={styles.sectionTitle}>
Transaktionen
{(selectedMandateId || selectedUserId) && (
<span style={{ fontWeight: 'normal', fontSize: '14px', marginLeft: '8px' }}>
({filteredTransactionCount} gefiltert)
</span>
)}
</h2>
</div>
{loading && transactions.length === 0 ? (
<div className={styles.loadingPlaceholder}>Lade Transaktionen...</div>
) : transactions.length === 0 ? (
<div className={styles.noData}>Keine Transaktionen vorhanden</div>
) : (
<>
<UserTransactionTable
transactions={transactions}
selectedMandateId={selectedMandateId}
selectedUserId={selectedUserId}
/>
{transactions.length >= limit && (
<div style={{ textAlign: 'center', marginTop: 'var(--spacing-md)' }}>
<button
className={`${styles.button} ${styles.buttonSecondary}`}
onClick={handleLoadMore}
disabled={loading}
>
{loading ? 'Laden...' : 'Mehr laden'}
</button>
</div>
)}
</>
)}
</section>
</div>
);
};
export default BillingUserView;

View file

@ -3,5 +3,11 @@
*/
export { BillingDashboard } from './BillingDashboard';
export { BillingTransactions } from './BillingTransactions';
export { BillingDataView } from './BillingDataView';
export { BillingAdmin } from './BillingAdmin';
export { BillingNav } from './BillingNav';
// Legacy exports (can be removed after migration)
export { BillingTransactions } from './BillingTransactions';
export { BillingMandateView } from './BillingMandateView';
export { BillingUserView } from './BillingUserView';

View file

@ -20,11 +20,24 @@
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
align-items: flex-start;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color);
}
.headerLeft {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.headerTitleRow {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.pageTitle {
font-size: 1.5rem;
font-weight: 600;
@ -32,6 +45,24 @@
margin: 0;
}
.headerStats {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.75rem;
color: var(--text-secondary);
background-color: var(--bg-secondary);
padding: 0.25rem 0.75rem;
border-radius: 12px;
}
.headerStatItem {
display: flex;
align-items: center;
gap: 0.25rem;
white-space: nowrap;
}
.pageSubtitle {
font-size: 0.875rem;
color: var(--text-secondary);
@ -372,30 +403,6 @@
border-color: var(--primary-color, #f25843);
}
/* Statistics bar */
.statsBar {
display: flex;
gap: 1.5rem;
padding: 0.5rem 0;
font-size: 0.75rem;
color: var(--text-secondary);
}
.statItem {
display: flex;
align-items: center;
gap: 0.25rem;
}
.statLabel {
color: var(--text-tertiary);
}
.statValue {
font-weight: 500;
color: var(--text-secondary);
}
/* Pending files */
.pendingFiles {
display: flex;

View file

@ -582,8 +582,26 @@ export const PlaygroundPage: React.FC = () => {
{/* Page Header */}
<header className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Chat Playground</h1>
<div className={styles.headerLeft}>
<div className={styles.headerTitleRow}>
<h1 className={styles.pageTitle}>Chat Playground</h1>
{/* Stats display in header */}
<div className={styles.headerStats}>
<span className={styles.headerStatItem} title="Daten gesendet / empfangen">
{formatBytes(latestStats?.bytesSent || 0)} / {formatBytes(latestStats?.bytesReceived || 0)}
</span>
{(latestStats?.processingTime ?? 0) > 0 && (
<span className={styles.headerStatItem} title="Verarbeitungszeit">
{formatDuration(latestStats?.processingTime || 0)}
</span>
)}
{(latestStats?.priceUsd ?? 0) > 0 && (
<span className={styles.headerStatItem} title="Kosten">
💰 CHF {(latestStats?.priceUsd || 0).toFixed(4)}
</span>
)}
</div>
</div>
<p className={styles.pageSubtitle}>Workflow-Ausführung und Chat-Interaktion</p>
</div>
<div className={styles.headerControls}>
@ -711,36 +729,6 @@ export const PlaygroundPage: React.FC = () => {
</div>
)}
{/* Stats bar */}
{latestStats && (latestStats.bytesSent || latestStats.bytesReceived || latestStats.processingTime || latestStats.priceUsd) && (
<div className={styles.statsBar}>
{(latestStats.bytesSent !== undefined || latestStats.bytesReceived !== undefined) && (
<div className={styles.statItem}>
<span className={styles.statLabel}>Daten:</span>
<span className={styles.statValue}>
{formatBytes(latestStats.bytesSent || 0)} / {formatBytes(latestStats.bytesReceived || 0)}
</span>
</div>
)}
{latestStats.processingTime !== undefined && latestStats.processingTime > 0 && (
<div className={styles.statItem}>
<span className={styles.statLabel}>Zeit:</span>
<span className={styles.statValue}>
{formatDuration(latestStats.processingTime)}
</span>
</div>
)}
{latestStats.priceUsd !== undefined && latestStats.priceUsd > 0 && (
<div className={styles.statItem}>
<span className={styles.statLabel}>Kosten:</span>
<span className={styles.statValue}>
${latestStats.priceUsd.toFixed(4)}
</span>
</div>
)}
</div>
)}
{/* Input row */}
<div className={styles.inputRow}>
<div className={styles.inputWrapper}>

View file

@ -82,9 +82,11 @@ export const WorkflowsPage: React.FC = () => {
}
};
// Handle continue workflow - navigate to playground
// Handle continue workflow - navigate to playground within same feature instance
// Uses relative navigation since WorkflowsPage is rendered under same instance route as playground
const handleContinueWorkflow = (workflow: Workflow) => {
navigate(`/workflows/playground?workflowId=${workflow.id}`);
// Navigate relatively to playground (sibling route under same instance)
navigate(`../playground?workflowId=${workflow.id}`);
};
// Handle edit submit