revised state machine for workflow backend and ui
This commit is contained in:
parent
a544ab2c78
commit
148412c31c
25 changed files with 1939 additions and 624 deletions
|
|
@ -47,7 +47,7 @@ import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandat
|
||||||
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
||||||
|
|
||||||
// Billing Pages
|
// Billing Pages
|
||||||
import { BillingDashboard, BillingTransactions, BillingAdmin } from './pages/billing';
|
import { BillingDashboard, BillingDataView, BillingAdmin } from './pages/billing';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
// Load saved theme preference and set app name on app mount
|
// Load saved theme preference and set app name on app mount
|
||||||
|
|
@ -117,8 +117,8 @@ function App() {
|
||||||
{/* BILLING ROUTES */}
|
{/* BILLING ROUTES */}
|
||||||
{/* ============================================== */}
|
{/* ============================================== */}
|
||||||
<Route path="billing">
|
<Route path="billing">
|
||||||
<Route index element={<BillingDashboard />} />
|
<Route index element={<Navigate to="/billing/transactions" replace />} />
|
||||||
<Route path="transactions" element={<BillingTransactions />} />
|
<Route path="transactions" element={<BillingDataView />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* ============================================== */}
|
{/* ============================================== */}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ export interface BillingTransaction {
|
||||||
featureCode?: string;
|
featureCode?: string;
|
||||||
aicoreProvider?: string;
|
aicoreProvider?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
|
mandateId?: string;
|
||||||
|
mandateName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BillingSettings {
|
export interface BillingSettings {
|
||||||
|
|
@ -277,3 +279,95 @@ export async function fetchUsersForMandateAdmin(
|
||||||
method: 'get'
|
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 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,11 @@
|
||||||
/* Fill available space and constrain height */
|
/* Fill available space and constrain height */
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1;
|
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 {
|
.title {
|
||||||
|
|
@ -18,18 +22,46 @@
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Table Container - scrollable area for table data only */
|
/* Table wrapper - contains top scrollbar and table container */
|
||||||
.tableContainer {
|
.tableWrapper {
|
||||||
position: relative;
|
display: flex;
|
||||||
overflow: auto;
|
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: 1px solid var(--color-primary);
|
||||||
border-radius: 25px;
|
border-radius: 25px;
|
||||||
background: var(--color-bg);
|
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;
|
min-height: 0;
|
||||||
/* Clip content to border-radius but allow sticky to work */
|
max-height: 100%;
|
||||||
isolation: isolate;
|
border-radius: 0 0 25px 25px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Empty table styling - no extra space, just header */
|
/* Empty table styling - no extra space, just header */
|
||||||
|
|
@ -39,6 +71,11 @@
|
||||||
max-height: none;
|
max-height: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide top scrollbar when table is empty */
|
||||||
|
.emptyTable .topScrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Empty state styling */
|
/* Empty state styling */
|
||||||
.emptyState {
|
.emptyState {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -734,7 +771,7 @@ tbody .actionsColumn {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom scrollbar for table container */
|
/* Custom scrollbar for table container (vertical only) */
|
||||||
.tableContainer::-webkit-scrollbar {
|
.tableContainer::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
|
|
@ -754,6 +791,25 @@ tbody .actionsColumn {
|
||||||
background: var(--color-secondary);
|
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 */
|
/* Loading State */
|
||||||
.loadingState {
|
.loadingState {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -338,6 +338,11 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
const tableRef = useRef<HTMLTableElement>(null);
|
const tableRef = useRef<HTMLTableElement>(null);
|
||||||
const tableContainerRef = useRef<HTMLDivElement>(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
|
// Track container width for actions column 20% threshold
|
||||||
const [containerWidth, setContainerWidth] = useState<number>(0);
|
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)
|
// Track which cells are currently being updated (for loading state)
|
||||||
const [updatingCells, setUpdatingCells] = useState<Set<string>>(new Set());
|
const [updatingCells, setUpdatingCells] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
|
@ -1367,26 +1419,36 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table Wrapper - contains top scrollbar and table container */}
|
||||||
<div
|
<div className={`${styles.tableWrapper} ${displayData.length === 0 && !loading ? styles.emptyTable : ''}`}>
|
||||||
ref={tableContainerRef}
|
{/* Top horizontal scrollbar - syncs with table container */}
|
||||||
className={`${styles.tableContainer} ${displayData.length === 0 && !loading ? styles.emptyTable : ''}`}
|
<div
|
||||||
>
|
ref={topScrollbarRef}
|
||||||
{/* Loading overlay - shown while loading */}
|
className={styles.topScrollbar}
|
||||||
{loading && (
|
>
|
||||||
<div className={styles.loadingOverlay}>
|
<div ref={topScrollbarInnerRef} className={styles.topScrollbarInner} />
|
||||||
<div className={styles.loadingSpinner}></div>
|
</div>
|
||||||
<p>{t('common.loading', 'Loading...')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Empty state - only shown when not loading AND no data */}
|
{/* Table Container - vertical scroll only */}
|
||||||
{!loading && displayData.length === 0 ? (
|
<div
|
||||||
<div className={styles.emptyState}>
|
ref={tableContainerRef}
|
||||||
<p className={styles.emptyMessage}>{emptyMessage || t('formgen.empty', 'No data available')}</p>
|
className={styles.tableContainer}
|
||||||
</div>
|
>
|
||||||
) : (
|
{/* Loading overlay - shown while loading */}
|
||||||
<table ref={tableRef} className={styles.table}>
|
{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>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{selectable && (
|
{selectable && (
|
||||||
|
|
@ -1704,7 +1766,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
</tbody>
|
</tbody>
|
||||||
)}
|
)}
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,16 @@
|
||||||
* Backend liefert Blocks-Struktur mit Static und Dynamic Blocks.
|
* Backend liefert Blocks-Struktur mit Static und Dynamic Blocks.
|
||||||
* UI mappt uiComponent zu Icons via pageRegistry.
|
* UI mappt uiComponent zu Icons via pageRegistry.
|
||||||
*
|
*
|
||||||
* Struktur (gemäss Navigation-API-Konzept):
|
* FLAT STRUCTURE (kompakte Darstellung):
|
||||||
* - SYSTEM (static block, order: 10)
|
* - SYSTEM (static block)
|
||||||
* - MEINE FEATURES (dynamic block, order: 15)
|
* - Mandant 1
|
||||||
* - Mandant 1
|
* - 🎯 Instanz 1 (Feature-Icon + Instanz-Name)
|
||||||
* - Feature A
|
* - 💼 Instanz 2 (Feature-Icon + Instanz-Name)
|
||||||
* - Instanz 1 (mit Views)
|
* - BASISDATEN (static block)
|
||||||
* - WORKFLOWS (static block, order: 20)
|
* - ADMINISTRATION (static block)
|
||||||
* - BASISDATEN (static block, order: 30)
|
*
|
||||||
* - MIGRATE TO FEATURES (static block, order: 40)
|
* Jede Instanz zeigt das Icon des zugehörigen Features.
|
||||||
* - ADMINISTRATION (static block, order: 200)
|
* Keine Gruppierung nach Features - direkt Instanzen unter Mandant.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
|
|
@ -75,58 +75,52 @@ function featureViewToTreeNode(view: FeatureView): TreeNodeItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a FeatureInstance to TreeNodeItem
|
* Convert a FeatureInstance to TreeNodeItem (with feature icon)
|
||||||
* Instance node gets path to first view so clicking the instance name (e.g. PEK) navigates to dashboard.
|
* 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);
|
const children = instance.views.map(featureViewToTreeNode);
|
||||||
return {
|
return {
|
||||||
id: instance.id,
|
id: instance.id,
|
||||||
label: instance.uiLabel,
|
label: instance.uiLabel,
|
||||||
|
icon: getPageIcon(featureUiComponent), // Use feature icon for instance
|
||||||
path: instance.views.length > 0 ? instance.views[0].uiPath : undefined,
|
path: instance.views.length > 0 ? instance.views[0].uiPath : undefined,
|
||||||
children,
|
children,
|
||||||
defaultExpanded: false,
|
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
|
* 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 {
|
function navigationMandateToTreeNode(mandate: NavigationMandate): TreeNodeItem | null {
|
||||||
if (mandate.features.length === 0) {
|
if (mandate.features.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const children = mandate.features
|
// Flatten: collect all instances from all features directly under mandate
|
||||||
.map(mandateFeatureToTreeNode)
|
const instanceNodes: TreeNodeItem[] = [];
|
||||||
.filter((node): node is TreeNodeItem => node !== null);
|
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 null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: mandate.id,
|
id: mandate.id,
|
||||||
label: mandate.uiLabel,
|
label: mandate.uiLabel,
|
||||||
children,
|
children: instanceNodes,
|
||||||
defaultExpanded: true,
|
defaultExpanded: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ export const UserSection: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBilling = () => {
|
const handleBilling = () => {
|
||||||
navigate('/billing');
|
navigate('/billing/transactions');
|
||||||
setShowMenu(false);
|
setShowMenu(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,133 +42,90 @@
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
|
|
||||||
.providerMultiSelect {
|
.providerMultiSelect {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-xs, 4px);
|
|
||||||
position: relative;
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.providerMultiSelect.collapsed {
|
/* Trigger Button - matches iconButton style from PlaygroundPage */
|
||||||
/* Collapsed state styles */
|
.triggerButton {
|
||||||
}
|
|
||||||
|
|
||||||
.providerMultiSelect.expanded {
|
|
||||||
/* Expanded state styles */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Collapsible Header Button */
|
|
||||||
.collapseHeader {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-xs, 4px);
|
justify-content: center;
|
||||||
padding: var(--spacing-xs, 4px) var(--spacing-sm, 8px);
|
width: 36px;
|
||||||
border: 1px solid var(--color-border);
|
height: 36px;
|
||||||
border-radius: var(--border-radius-md, 6px);
|
border: 1px solid var(--border-color, #3a3a3a);
|
||||||
background: var(--color-bg-input);
|
border-radius: 6px;
|
||||||
color: var(--color-text-primary);
|
background: var(--surface-color, #2d2d2d);
|
||||||
font-size: var(--font-size-sm, 0.875rem);
|
color: var(--text-secondary, #888);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s;
|
||||||
min-width: 140px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.collapseHeader:hover:not(:disabled) {
|
.triggerButton:hover:not(:disabled) {
|
||||||
border-color: var(--color-primary);
|
background: var(--bg-secondary, #3a3a3a);
|
||||||
background: var(--color-bg-hover);
|
color: var(--text-primary, #fff);
|
||||||
}
|
}
|
||||||
|
|
||||||
.collapseHeader:disabled {
|
.triggerButton:disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summaryIcons {
|
.buttonIcon {
|
||||||
font-size: 0.9em;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summaryText {
|
/* Dropdown Content - opens upward */
|
||||||
flex: 1;
|
.dropdownContent {
|
||||||
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 {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 100%;
|
bottom: calc(100% + 4px);
|
||||||
left: 0;
|
left: 50%;
|
||||||
right: 0;
|
transform: translateX(-50%);
|
||||||
z-index: 100;
|
z-index: 1000;
|
||||||
margin-bottom: var(--spacing-xs, 4px);
|
padding: 8px;
|
||||||
padding: var(--spacing-sm, 8px);
|
background: var(--surface-color, #2d2d2d);
|
||||||
background: #2d2d2d;
|
border: 1px solid var(--border-color, #3a3a3a);
|
||||||
color: #e0e0e0;
|
border-radius: 6px;
|
||||||
border: 1px solid #444;
|
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.5);
|
||||||
border-radius: var(--border-radius-md, 6px);
|
min-width: 220px;
|
||||||
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.4);
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.expandableContent .label {
|
.dropdownHeader {
|
||||||
color: #b0b0b0;
|
font-size: 0.75rem;
|
||||||
}
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
.expandableContent .checkboxList {
|
padding: 4px 8px;
|
||||||
background: #252525;
|
margin-bottom: 4px;
|
||||||
}
|
border-bottom: 1px solid var(--border-color, #3a3a3a);
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.selectActions {
|
.selectActions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--spacing-xs, 4px);
|
gap: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actionButton {
|
.actionButton {
|
||||||
padding: 2px 8px;
|
flex: 1;
|
||||||
border: 1px solid var(--color-border);
|
padding: 4px 8px;
|
||||||
border-radius: var(--border-radius-sm, 4px);
|
border: 1px solid var(--border-color, #3a3a3a);
|
||||||
background: var(--color-bg-secondary);
|
border-radius: 4px;
|
||||||
color: var(--color-text-secondary);
|
background: var(--bg-secondary, #252525);
|
||||||
font-size: var(--font-size-xs, 0.75rem);
|
color: var(--text-secondary, #888);
|
||||||
|
font-size: 0.75rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actionButton:hover:not(:disabled) {
|
.actionButton:hover:not(:disabled) {
|
||||||
background: var(--color-bg-hover);
|
background: var(--bg-hover, #3a3a3a);
|
||||||
border-color: var(--color-primary);
|
color: var(--text-primary, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionButton.active {
|
||||||
|
background: var(--primary-color, #f25843);
|
||||||
|
border-color: var(--primary-color, #f25843);
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actionButton:disabled {
|
.actionButton:disabled {
|
||||||
|
|
@ -179,24 +136,27 @@
|
||||||
.checkboxList {
|
.checkboxList {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--spacing-xs, 4px);
|
gap: 2px;
|
||||||
padding: var(--spacing-sm, 8px);
|
padding: 4px;
|
||||||
background: var(--color-bg-secondary);
|
background: var(--bg-secondary, #252525);
|
||||||
border-radius: var(--border-radius-md, 6px);
|
border-radius: 4px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkboxItem {
|
.checkboxItem {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-sm, 8px);
|
gap: 8px;
|
||||||
padding: var(--spacing-xs, 4px) var(--spacing-sm, 8px);
|
padding: 6px 8px;
|
||||||
border-radius: var(--border-radius-sm, 4px);
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s ease;
|
transition: background 0.15s ease;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkboxItem:hover {
|
.checkboxItem:hover {
|
||||||
background: var(--color-bg-hover);
|
background: var(--bg-hover, #3a3a3a);
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkboxItem.disabled {
|
.checkboxItem.disabled {
|
||||||
|
|
@ -205,34 +165,35 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkboxItem input[type="checkbox"] {
|
.checkboxItem input[type="checkbox"] {
|
||||||
width: 16px;
|
width: 14px;
|
||||||
height: 16px;
|
height: 14px;
|
||||||
cursor: inherit;
|
cursor: inherit;
|
||||||
|
accent-color: var(--primary-color, #f25843);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
font-size: 1.1em;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.providerName {
|
.providerName {
|
||||||
font-size: var(--font-size-sm, 0.875rem);
|
font-size: 0.8rem;
|
||||||
color: var(--color-text-primary);
|
color: var(--text-primary, #e0e0e0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
font-size: var(--font-size-xs, 0.75rem);
|
font-size: 0.7rem;
|
||||||
color: var(--color-text-tertiary);
|
color: var(--text-tertiary, #666);
|
||||||
font-style: italic;
|
text-align: center;
|
||||||
padding: var(--spacing-xs, 4px) 0;
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: var(--spacing-md, 16px);
|
padding: 12px;
|
||||||
color: var(--color-text-secondary);
|
color: var(--text-secondary, #888);
|
||||||
font-size: var(--font-size-sm, 0.875rem);
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
* - Lädt verfügbare Provider aus dem Billing-System
|
* - 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 { useBilling } from '../../hooks/useBilling';
|
||||||
import styles from './ProviderSelector.module.css';
|
import styles from './ProviderSelector.module.css';
|
||||||
|
|
||||||
|
|
@ -20,6 +20,7 @@ const PROVIDER_LABELS: Record<string, string> = {
|
||||||
openai: 'OpenAI (GPT)',
|
openai: 'OpenAI (GPT)',
|
||||||
perplexity: 'Perplexity',
|
perplexity: 'Perplexity',
|
||||||
tavily: 'Tavily (Web Search)',
|
tavily: 'Tavily (Web Search)',
|
||||||
|
privatellm: 'Private LLM',
|
||||||
internal: 'Internal',
|
internal: 'Internal',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -29,6 +30,7 @@ const PROVIDER_ICONS: Record<string, string> = {
|
||||||
openai: '💬',
|
openai: '💬',
|
||||||
perplexity: '🔍',
|
perplexity: '🔍',
|
||||||
tavily: '🌐',
|
tavily: '🌐',
|
||||||
|
privatellm: '🔒',
|
||||||
internal: '🏠',
|
internal: '🏠',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -112,6 +114,7 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
||||||
defaultExpanded = false,
|
defaultExpanded = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const { allowedProviders, loadAllowedProviders, loading } = useBilling();
|
const { allowedProviders, loadAllowedProviders, loading } = useBilling();
|
||||||
|
|
||||||
useEffect(() => {
|
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) => {
|
const handleToggle = (provider: string) => {
|
||||||
if (selectedProviders.includes(provider)) {
|
// Use effectiveSelection for toggle logic (handles empty = all case)
|
||||||
onChange(selectedProviders.filter((p) => p !== provider));
|
if (effectiveSelection.includes(provider)) {
|
||||||
|
// Deactivate: remove from effective selection
|
||||||
|
onChange(effectiveSelection.filter((p) => p !== provider));
|
||||||
} else {
|
} else {
|
||||||
onChange([...selectedProviders, provider]);
|
// Activate: add to effective selection
|
||||||
|
onChange([...effectiveSelection, provider]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
const handleSelectAll = () => {
|
||||||
onChange(allowedProviders);
|
onChange([...allowedProviders]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectNone = () => {
|
const handleSelectNone = () => {
|
||||||
onChange([]);
|
onChange([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Summary text for collapsed view
|
// Summary icon for button
|
||||||
const summaryText = useMemo(() => {
|
const summaryIcon = useMemo(() => {
|
||||||
if (selectedProviders.length === 0) {
|
if (selectedProviders.length === 0 || selectedProviders.length === allowedProviders.length) {
|
||||||
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) {
|
|
||||||
return '🤖';
|
return '🤖';
|
||||||
}
|
}
|
||||||
return selectedProviders.slice(0, 3).map(p => PROVIDER_ICONS[p] || '🔌').join('');
|
if (selectedProviders.length === 1) {
|
||||||
}, [selectedProviders]);
|
return PROVIDER_ICONS[selectedProviders[0]] || '🔌';
|
||||||
|
}
|
||||||
|
return '🤖';
|
||||||
|
}, [selectedProviders, allowedProviders]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.providerMultiSelect} ${className || ''} ${isExpanded ? styles.expanded : styles.collapsed}`}>
|
<div
|
||||||
{/* Collapsible Header */}
|
ref={containerRef}
|
||||||
|
className={`${styles.providerMultiSelect} ${className || ''} ${isExpanded ? styles.expanded : styles.collapsed}`}
|
||||||
|
>
|
||||||
|
{/* Trigger Button - styled like iconButton */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.collapseHeader}
|
className={styles.triggerButton}
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
title="Provider auswählen"
|
||||||
>
|
>
|
||||||
<span className={styles.summaryIcons}>{summaryIcons}</span>
|
<span className={styles.buttonIcon}>{summaryIcon}</span>
|
||||||
<span className={styles.summaryText}>{summaryText}</span>
|
|
||||||
<span className={styles.expandIcon}>{isExpanded ? '▲' : '▼'}</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Expandable Content */}
|
{/* Dropdown Content */}
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className={styles.expandableContent}>
|
<div className={styles.dropdownContent}>
|
||||||
{showLabel && <label className={styles.label}>{label}</label>}
|
{showLabel && <div className={styles.dropdownHeader}>{label}</div>}
|
||||||
|
|
||||||
<div className={styles.selectActions}>
|
<div className={styles.selectActions}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSelectAll}
|
onClick={handleSelectAll}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={styles.actionButton}
|
className={`${styles.actionButton} ${isAllSelected ? styles.active : ''}`}
|
||||||
>
|
>
|
||||||
Alle
|
Alle
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -194,7 +215,7 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className={styles.loading}>Lade Provider...</div>
|
<div className={styles.loading}>Lade...</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.checkboxList}>
|
<div className={styles.checkboxList}>
|
||||||
{allowedProviders.map((provider) => (
|
{allowedProviders.map((provider) => (
|
||||||
|
|
@ -204,7 +225,7 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedProviders.includes(provider)}
|
checked={effectiveSelection.includes(provider)}
|
||||||
onChange={() => handleToggle(provider)}
|
onChange={() => handleToggle(provider)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
|
|
@ -219,7 +240,7 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
||||||
|
|
||||||
{selectedProviders.length === 0 && !loading && (
|
{selectedProviders.length === 0 && !loading && (
|
||||||
<div className={styles.hint}>
|
<div className={styles.hint}>
|
||||||
Wenn keine Provider ausgewählt sind, werden alle erlaubten Provider verwendet.
|
Alle Provider aktiv
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import { FaExclamationTriangle } from 'react-icons/fa';
|
||||||
import { Message } from '../MessagesTypes';
|
import { Message } from '../MessagesTypes';
|
||||||
import { formatTimestamp } from '../MessageUtils';
|
import { formatTimestamp } from '../MessageUtils';
|
||||||
import { DocumentItem, ActionInfo } from '../MessageParts';
|
import { DocumentItem, ActionInfo } from '../MessageParts';
|
||||||
|
|
@ -40,7 +41,9 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||||
workflowId
|
workflowId
|
||||||
}) => {
|
}) => {
|
||||||
const isUser = message.role?.toLowerCase() === 'user';
|
const isUser = message.role?.toLowerCase() === 'user';
|
||||||
|
const isError = message.actionProgress === 'fail' || message.actionProgress === 'error';
|
||||||
const messageClass = isUser ? styles.messageUser : styles.messageAssistant;
|
const messageClass = isUser ? styles.messageUser : styles.messageAssistant;
|
||||||
|
const errorClass = isError ? styles.messageError : '';
|
||||||
|
|
||||||
// Debug: Log documents if in dev mode
|
// Debug: Log documents if in dev mode
|
||||||
if (import.meta.env.DEV && message.documents) {
|
if (import.meta.env.DEV && message.documents) {
|
||||||
|
|
@ -48,8 +51,16 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.message} ${messageClass}`}>
|
<div className={`${styles.message} ${messageClass} ${errorClass}`}>
|
||||||
<div className={styles.messageBubble}>
|
<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 content */}
|
||||||
{message.message && (
|
{message.message && (
|
||||||
<div className={styles.messageContent}>
|
<div className={styles.messageContent}>
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,29 @@
|
||||||
border-bottom-left-radius: 4px;
|
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 */
|
/* Message Metadata */
|
||||||
.messageMetadata {
|
.messageMetadata {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,54 @@ interface UnifiedChatDataItem {
|
||||||
createdAt: number;
|
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) {
|
export function useWorkflowLifecycle(instanceId: string) {
|
||||||
|
// === STATE ===
|
||||||
const [workflowId, setWorkflowId] = useState<string | null>(null);
|
const [workflowId, setWorkflowId] = useState<string | null>(null);
|
||||||
const [workflowStatus, setWorkflowStatus] = useState<string>('idle');
|
const [workflowStatus, setWorkflowStatus] = useState<string>('idle');
|
||||||
const [currentRound, setCurrentRound] = useState<number | undefined>(undefined);
|
const [currentRound, setCurrentRound] = useState<number | undefined>(undefined);
|
||||||
|
|
@ -26,48 +73,35 @@ export function useWorkflowLifecycle(instanceId: string) {
|
||||||
const [logs, setLogs] = useState<WorkflowLog[]>([]);
|
const [logs, setLogs] = useState<WorkflowLog[]>([]);
|
||||||
const [dashboardLogs, setDashboardLogs] = useState<WorkflowLog[]>([]);
|
const [dashboardLogs, setDashboardLogs] = useState<WorkflowLog[]>([]);
|
||||||
const [unifiedContentLogs, setUnifiedContentLogs] = 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 [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 statusRef = useRef<string>('idle');
|
||||||
const statusChangedFromRunningAtRef = useRef<number | null>(null);
|
|
||||||
const lastRenderedTimestampRef = 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());
|
const processedStatIdsRef = useRef<Set<string>>(new Set());
|
||||||
// Track cumulative stats
|
|
||||||
const cumulativeStatsRef = useRef({ priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 });
|
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 { startWorkflow, stopWorkflow, startingWorkflow, stoppingWorkflows } = useWorkflowOperations();
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
const pollingController = useWorkflowPolling();
|
const pollingController = useWorkflowPolling();
|
||||||
|
|
||||||
// Store polling controller methods in refs to avoid dependency issues
|
|
||||||
const pollingControllerRef = useRef(pollingController);
|
const pollingControllerRef = useRef(pollingController);
|
||||||
pollingControllerRef.current = pollingController;
|
pollingControllerRef.current = pollingController;
|
||||||
|
|
||||||
// Helper to update status and track transitions
|
// === HELPER: Update workflow status ===
|
||||||
const updateWorkflowStatus = useCallback((newStatus: string) => {
|
const updateWorkflowStatus = useCallback((newStatus: string) => {
|
||||||
const prevStatus = prevStatusRef.current;
|
|
||||||
prevStatusRef.current = newStatus;
|
|
||||||
statusRef.current = newStatus;
|
statusRef.current = newStatus;
|
||||||
setWorkflowStatus(newStatus);
|
setWorkflowStatus(newStatus);
|
||||||
|
console.log('📍 Status updated to:', 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;
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 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 => {
|
const convertLogToFrontendFormat = useCallback((log: any): WorkflowLog => {
|
||||||
return {
|
return {
|
||||||
id: log.id,
|
id: log.id,
|
||||||
|
|
@ -83,15 +117,15 @@ export function useWorkflowLifecycle(instanceId: string) {
|
||||||
};
|
};
|
||||||
}, [workflowId]);
|
}, [workflowId]);
|
||||||
|
|
||||||
// Process unified chat data chronologically
|
// === CORE: Process unified chat data ===
|
||||||
const processUnifiedChatData = useCallback((chatData: { messages: WorkflowMessage[]; logs: WorkflowLog[]; stats: any[] }) => {
|
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,
|
messages: chatData.messages?.length || 0,
|
||||||
logs: chatData.logs?.length || 0,
|
logs: chatData.logs?.length || 0,
|
||||||
stats: chatData.stats?.length || 0
|
stats: chatData.stats?.length || 0
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build unified timeline of all items
|
// Build unified timeline
|
||||||
const timeline: UnifiedChatDataItem[] = [];
|
const timeline: UnifiedChatDataItem[] = [];
|
||||||
|
|
||||||
// Add messages
|
// Add messages
|
||||||
|
|
@ -112,154 +146,132 @@ export function useWorkflowLifecycle(instanceId: string) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add stats (if needed)
|
// Add stats
|
||||||
(chatData.stats || []).forEach((stat: any) => {
|
const rawStats = chatData.stats || [];
|
||||||
|
rawStats.forEach((stat: any) => {
|
||||||
timeline.push({
|
timeline.push({
|
||||||
type: 'stat',
|
type: 'stat',
|
||||||
item: 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
|
// Sort chronologically
|
||||||
timeline.sort((a, b) => a.createdAt - b.createdAt);
|
timeline.sort((a, b) => a.createdAt - b.createdAt);
|
||||||
|
|
||||||
// Process items sequentially to maintain chronological order
|
// Update lastRenderedTimestamp
|
||||||
// Update lastRenderedTimestamp after processing all items (use latest timestamp)
|
|
||||||
if (timeline.length > 0) {
|
if (timeline.length > 0) {
|
||||||
const latestTimestamp = timeline[timeline.length - 1].createdAt;
|
lastRenderedTimestampRef.current = timeline[timeline.length - 1].createdAt;
|
||||||
lastRenderedTimestampRef.current = latestTimestamp;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 => {
|
setMessages(prevMessages => {
|
||||||
const newMessages: WorkflowMessage[] = [...prevMessages];
|
const newMessages: WorkflowMessage[] = [...prevMessages];
|
||||||
let hasChanges = false;
|
let hasChanges = false;
|
||||||
let messagesAdded = 0;
|
|
||||||
let messagesUpdated = 0;
|
|
||||||
|
|
||||||
timeline.forEach((item) => {
|
timeline.forEach((item) => {
|
||||||
if (item.type === 'message') {
|
if (item.type === 'message') {
|
||||||
const message = item.item as WorkflowMessage;
|
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);
|
const existingIndex = newMessages.findIndex(m => m.id === message.id);
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
// Always update existing message (don't compare, just update)
|
|
||||||
newMessages[existingIndex] = message;
|
newMessages[existingIndex] = message;
|
||||||
hasChanges = true;
|
hasChanges = true;
|
||||||
messagesUpdated++;
|
|
||||||
} else {
|
} else {
|
||||||
newMessages.push(message);
|
newMessages.push(message);
|
||||||
hasChanges = true;
|
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')) {
|
if (hasChanges || timeline.some(item => item.type === 'message')) {
|
||||||
const sorted = [...newMessages].sort(sortMessages);
|
return [...newMessages].sort(sortMessages);
|
||||||
console.log(`✅ Returning ${sorted.length} sorted messages`);
|
|
||||||
return sorted;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('⚠️ No changes detected, returning previous messages');
|
|
||||||
return prevMessages;
|
return prevMessages;
|
||||||
});
|
});
|
||||||
|
|
||||||
setDashboardLogs(prevDashboardLogs => {
|
// === UPDATE DASHBOARD LOGS (with operationId) ===
|
||||||
const newDashboardLogs: WorkflowLog[] = [...prevDashboardLogs];
|
setDashboardLogs(prevLogs => {
|
||||||
|
const newLogs: WorkflowLog[] = [...prevLogs];
|
||||||
let hasChanges = false;
|
let hasChanges = false;
|
||||||
|
|
||||||
timeline.forEach((item) => {
|
timeline.forEach((item) => {
|
||||||
if (item.type === 'log') {
|
if (item.type === 'log') {
|
||||||
const backendLog = item.item as any;
|
const frontendLog = convertLogToFrontendFormat(item.item);
|
||||||
const frontendLog = convertLogToFrontendFormat(backendLog);
|
|
||||||
|
|
||||||
// Route logs based on operationId
|
|
||||||
if (frontendLog.operationId) {
|
if (frontendLog.operationId) {
|
||||||
// Logs WITH operationId → Dashboard
|
const existingIndex = newLogs.findIndex(l => l.id === frontendLog.id);
|
||||||
const existingIndex = newDashboardLogs.findIndex(l => l.id === frontendLog.id);
|
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
// Check if log actually changed
|
if (JSON.stringify(newLogs[existingIndex]) !== JSON.stringify(frontendLog)) {
|
||||||
const existingLog = newDashboardLogs[existingIndex];
|
newLogs[existingIndex] = frontendLog;
|
||||||
if (JSON.stringify(existingLog) !== JSON.stringify(frontendLog)) {
|
|
||||||
newDashboardLogs[existingIndex] = frontendLog;
|
|
||||||
hasChanges = true;
|
hasChanges = true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
newDashboardLogs.push(frontendLog);
|
newLogs.push(frontendLog);
|
||||||
hasChanges = true;
|
hasChanges = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only return new array if there are changes
|
return hasChanges ? [...newLogs].sort(sortLogs) : prevLogs;
|
||||||
if (!hasChanges) {
|
|
||||||
return prevDashboardLogs;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...newDashboardLogs].sort(sortLogs);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setUnifiedContentLogs(prevUnifiedContentLogs => {
|
// === UPDATE UNIFIED CONTENT LOGS (without operationId) ===
|
||||||
const newUnifiedContentLogs: WorkflowLog[] = [...prevUnifiedContentLogs];
|
setUnifiedContentLogs(prevLogs => {
|
||||||
|
const newLogs: WorkflowLog[] = [...prevLogs];
|
||||||
let hasChanges = false;
|
let hasChanges = false;
|
||||||
|
|
||||||
timeline.forEach((item) => {
|
timeline.forEach((item) => {
|
||||||
if (item.type === 'log') {
|
if (item.type === 'log') {
|
||||||
const backendLog = item.item as any;
|
const frontendLog = convertLogToFrontendFormat(item.item);
|
||||||
const frontendLog = convertLogToFrontendFormat(backendLog);
|
|
||||||
|
|
||||||
// Route logs based on operationId
|
|
||||||
if (!frontendLog.operationId) {
|
if (!frontendLog.operationId) {
|
||||||
// Logs WITHOUT operationId → Unified Content Area
|
const existingIndex = newLogs.findIndex(l => l.id === frontendLog.id);
|
||||||
const existingIndex = newUnifiedContentLogs.findIndex(l => l.id === frontendLog.id);
|
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
// Check if log actually changed
|
if (JSON.stringify(newLogs[existingIndex]) !== JSON.stringify(frontendLog)) {
|
||||||
const existingLog = newUnifiedContentLogs[existingIndex];
|
newLogs[existingIndex] = frontendLog;
|
||||||
if (JSON.stringify(existingLog) !== JSON.stringify(frontendLog)) {
|
|
||||||
newUnifiedContentLogs[existingIndex] = frontendLog;
|
|
||||||
hasChanges = true;
|
hasChanges = true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
newUnifiedContentLogs.push(frontendLog);
|
newLogs.push(frontendLog);
|
||||||
hasChanges = true;
|
hasChanges = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only return new array if there are changes
|
return hasChanges ? [...newLogs].sort(sortLogs) : prevLogs;
|
||||||
if (!hasChanges) {
|
|
||||||
return prevUnifiedContentLogs;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...newUnifiedContentLogs].sort(sortLogs);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update combined logs for backward compatibility (using functional update)
|
// === UPDATE COMBINED LOGS ===
|
||||||
setLogs(prevLogs => {
|
setLogs(prevLogs => {
|
||||||
const allLogs: WorkflowLog[] = [...prevLogs];
|
const allLogs: WorkflowLog[] = [...prevLogs];
|
||||||
|
|
||||||
timeline.forEach((item) => {
|
timeline.forEach((item) => {
|
||||||
if (item.type === 'log') {
|
if (item.type === 'log') {
|
||||||
const backendLog = item.item as any;
|
const frontendLog = convertLogToFrontendFormat(item.item);
|
||||||
const frontendLog = convertLogToFrontendFormat(backendLog);
|
|
||||||
const existingIndex = allLogs.findIndex(l => l.id === frontendLog.id);
|
const existingIndex = allLogs.findIndex(l => l.id === frontendLog.id);
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
allLogs[existingIndex] = frontendLog;
|
allLogs[existingIndex] = frontendLog;
|
||||||
|
|
@ -272,47 +284,36 @@ export function useWorkflowLifecycle(instanceId: string) {
|
||||||
return [...allLogs].sort(sortLogs);
|
return [...allLogs].sort(sortLogs);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Process stats - aggregate only NEW stat entries (avoid double-counting)
|
// === PROCESS STATS ===
|
||||||
const statsItems = timeline.filter(item => item.type === 'stat');
|
const statsItems = timeline.filter(item => item.type === 'stat');
|
||||||
|
|
||||||
if (statsItems.length > 0) {
|
if (statsItems.length > 0) {
|
||||||
let hasNewStats = false;
|
let hasNewStats = false;
|
||||||
|
|
||||||
statsItems.forEach(statItem => {
|
statsItems.forEach(statItem => {
|
||||||
const statData = statItem.item || statItem;
|
const statData = statItem.item;
|
||||||
const statId = statData?.id || (statItem as any).id;
|
const statId = statData?.id;
|
||||||
|
|
||||||
// Skip if already processed
|
|
||||||
if (statId && processedStatIdsRef.current.has(statId)) {
|
if (statId && processedStatIdsRef.current.has(statId)) {
|
||||||
return;
|
return; // Skip already processed
|
||||||
}
|
}
|
||||||
|
|
||||||
if (statData) {
|
if (statData) {
|
||||||
hasNewStats = true;
|
hasNewStats = true;
|
||||||
|
|
||||||
// Mark as processed
|
|
||||||
if (statId) {
|
if (statId) {
|
||||||
processedStatIdsRef.current.add(statId);
|
processedStatIdsRef.current.add(statId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to cumulative stats
|
// Accumulate stats
|
||||||
if (statData.priceUsd !== undefined && statData.priceUsd !== null) {
|
const price = statData.priceCHF ?? statData.priceUsd ?? 0;
|
||||||
cumulativeStatsRef.current.priceUsd += statData.priceUsd;
|
if (price > 0) cumulativeStatsRef.current.priceUsd += price;
|
||||||
}
|
if (statData.processingTime) cumulativeStatsRef.current.processingTime += statData.processingTime;
|
||||||
if (statData.processingTime !== undefined && statData.processingTime !== null) {
|
if (statData.bytesSent) cumulativeStatsRef.current.bytesSent += statData.bytesSent;
|
||||||
cumulativeStatsRef.current.processingTime += statData.processingTime;
|
if (statData.bytesReceived) cumulativeStatsRef.current.bytesReceived += statData.bytesReceived;
|
||||||
}
|
|
||||||
if (statData.bytesSent !== undefined && statData.bytesSent !== null) {
|
|
||||||
cumulativeStatsRef.current.bytesSent += statData.bytesSent;
|
|
||||||
}
|
|
||||||
if (statData.bytesReceived !== undefined && statData.bytesReceived !== null) {
|
|
||||||
cumulativeStatsRef.current.bytesReceived += statData.bytesReceived;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update state with cumulative totals
|
if (hasNewStats) {
|
||||||
if (hasNewStats || (cumulativeStatsRef.current.bytesSent > 0 || cumulativeStatsRef.current.bytesReceived > 0 ||
|
|
||||||
cumulativeStatsRef.current.processingTime > 0 || cumulativeStatsRef.current.priceUsd > 0)) {
|
|
||||||
setLatestStats({
|
setLatestStats({
|
||||||
priceUsd: cumulativeStatsRef.current.priceUsd,
|
priceUsd: cumulativeStatsRef.current.priceUsd,
|
||||||
processingTime: cumulativeStatsRef.current.processingTime,
|
processingTime: cumulativeStatsRef.current.processingTime,
|
||||||
|
|
@ -323,10 +324,9 @@ export function useWorkflowLifecycle(instanceId: string) {
|
||||||
}
|
}
|
||||||
}, [convertLogToFrontendFormat]);
|
}, [convertLogToFrontendFormat]);
|
||||||
|
|
||||||
// Poll workflow data using unified chat data endpoint
|
// === POLLING FUNCTION ===
|
||||||
const pollWorkflowData = useCallback(async (id: string) => {
|
const pollWorkflowData = useCallback(async (id: string) => {
|
||||||
try {
|
try {
|
||||||
// Determine afterTimestamp for incremental polling
|
|
||||||
const afterTimestamp = lastRenderedTimestampRef.current || undefined;
|
const afterTimestamp = lastRenderedTimestampRef.current || undefined;
|
||||||
|
|
||||||
// Fetch workflow status
|
// Fetch workflow status
|
||||||
|
|
@ -334,192 +334,73 @@ export function useWorkflowLifecycle(instanceId: string) {
|
||||||
|
|
||||||
if (workflowData) {
|
if (workflowData) {
|
||||||
const status = workflowData.status || 'idle';
|
const status = workflowData.status || 'idle';
|
||||||
const round = workflowData.currentRound !== undefined ? workflowData.currentRound : undefined;
|
const round = workflowData.currentRound;
|
||||||
|
|
||||||
updateWorkflowStatus(status);
|
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);
|
const chatData = await fetchChatData(request, instanceId, id, afterTimestamp);
|
||||||
|
|
||||||
console.log('📊 Processed chat data:', {
|
console.log('📊 Polled chat data:', {
|
||||||
messagesCount: chatData.messages?.length || 0,
|
messages: chatData.messages?.length || 0,
|
||||||
logsCount: chatData.logs?.length || 0,
|
logs: chatData.logs?.length || 0,
|
||||||
statsCount: chatData.stats?.length || 0,
|
stats: chatData.stats?.length || 0,
|
||||||
afterTimestamp: afterTimestamp
|
afterTimestamp
|
||||||
});
|
});
|
||||||
|
|
||||||
// If we got empty results and we're using afterTimestamp, the backend might be filtering incorrectly
|
// Process data (this will detect "last" message and stop polling if found)
|
||||||
// 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
|
|
||||||
processUnifiedChatData(chatData);
|
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) {
|
} catch (error) {
|
||||||
console.error('Error loading workflow data:', error);
|
console.error('❌ Polling error:', error);
|
||||||
}
|
}
|
||||||
}, [request, updateWorkflowStatus, convertLogToFrontendFormat, processUnifiedChatData]);
|
}, [request, instanceId, updateWorkflowStatus, processUnifiedChatData]);
|
||||||
void _loadWorkflowData; // Intentionally unused, reserved for future use
|
|
||||||
|
|
||||||
// Set up polling when workflow is running
|
// === POLLING CONTROL EFFECT ===
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!workflowId) {
|
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();
|
pollingControllerRef.current.stopPolling();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Continue polling if:
|
// === STATE MACHINE: Determine if polling should be active ===
|
||||||
// 1. Workflow is currently running, OR
|
const shouldPoll =
|
||||||
// 2. Workflow just completed (within last 5 seconds) - grace period to catch final messages
|
workflowStatus === 'running' ||
|
||||||
// Stop polling for failed or stopped workflows immediately
|
(workflowStatus === 'completed' && !hasRenderedLastMessage);
|
||||||
const changedAtRef = statusChangedFromRunningAtRef.current;
|
|
||||||
const gracePeriodMs = 5000; // 5 seconds grace period
|
const shouldStopImmediately =
|
||||||
const timeSinceCompletion = changedAtRef !== null ? Date.now() - changedAtRef : Infinity;
|
workflowStatus === 'stopped' ||
|
||||||
const isInGracePeriod = workflowStatus === 'completed' && changedAtRef !== null && timeSinceCompletion < gracePeriodMs;
|
workflowStatus === 'failed' ||
|
||||||
const shouldPoll = workflowStatus === 'running' || isInGracePeriod;
|
hasRenderedLastMessage;
|
||||||
|
|
||||||
|
console.log('📍 Polling decision:', {
|
||||||
|
workflowStatus,
|
||||||
|
hasRenderedLastMessage,
|
||||||
|
shouldPoll,
|
||||||
|
shouldStopImmediately
|
||||||
|
});
|
||||||
|
|
||||||
if (shouldPoll) {
|
if (shouldPoll) {
|
||||||
// Start polling
|
|
||||||
pollingControllerRef.current.startPolling(workflowId, pollWorkflowData);
|
pollingControllerRef.current.startPolling(workflowId, pollWorkflowData);
|
||||||
|
} else if (shouldStopImmediately) {
|
||||||
// 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
|
|
||||||
pollingControllerRef.current.stopPolling();
|
pollingControllerRef.current.stopPolling();
|
||||||
// Clear the status change timestamp when we stop polling
|
|
||||||
if (statusChangedFromRunningAt !== null) {
|
|
||||||
setStatusChangedFromRunningAt(null);
|
|
||||||
statusChangedFromRunningAtRef.current = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
pollingControllerRef.current.stopPolling();
|
pollingControllerRef.current.stopPolling();
|
||||||
};
|
};
|
||||||
}, [workflowStatus, workflowId, pollWorkflowData, statusChangedFromRunningAt]);
|
}, [workflowStatus, workflowId, hasRenderedLastMessage, pollWorkflowData]);
|
||||||
|
|
||||||
|
// === START WORKFLOW (Send Button) ===
|
||||||
const handleStartWorkflow = useCallback(async (
|
const handleStartWorkflow = useCallback(async (
|
||||||
workflowData: StartWorkflowRequest,
|
workflowData: StartWorkflowRequest,
|
||||||
options?: { workflowId?: string; workflowMode?: 'Dynamic' | 'Automation' }
|
options?: { workflowId?: string; workflowMode?: 'Dynamic' | 'Automation' }
|
||||||
|
|
@ -529,10 +410,24 @@ export function useWorkflowLifecycle(instanceId: string) {
|
||||||
|
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
const workflow = result.data as Workflow;
|
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);
|
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');
|
updateWorkflowStatus(workflow.status || 'running');
|
||||||
// Reset lastRenderedTimestamp for new workflow
|
|
||||||
lastRenderedTimestampRef.current = null;
|
|
||||||
return { success: true, data: result.data };
|
return { success: true, data: result.data };
|
||||||
} else {
|
} else {
|
||||||
return { success: false, error: result.error || 'Failed to start workflow' };
|
return { success: false, error: result.error || 'Failed to start workflow' };
|
||||||
|
|
@ -540,8 +435,9 @@ export function useWorkflowLifecycle(instanceId: string) {
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return { success: false, error: error.message || 'Failed to start workflow' };
|
return { success: false, error: error.message || 'Failed to start workflow' };
|
||||||
}
|
}
|
||||||
}, [instanceId, startWorkflow, updateWorkflowStatus]);
|
}, [instanceId, startWorkflow, updateWorkflowStatus, pollWorkflowData]);
|
||||||
|
|
||||||
|
// === STOP WORKFLOW ===
|
||||||
const handleStopWorkflow = useCallback(async () => {
|
const handleStopWorkflow = useCallback(async () => {
|
||||||
if (!workflowId) {
|
if (!workflowId) {
|
||||||
return { success: false, error: 'No workflow to stop' };
|
return { success: false, error: 'No workflow to stop' };
|
||||||
|
|
@ -552,6 +448,7 @@ export function useWorkflowLifecycle(instanceId: string) {
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
updateWorkflowStatus('stopped');
|
updateWorkflowStatus('stopped');
|
||||||
|
pollingControllerRef.current.stopPolling();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} else {
|
} else {
|
||||||
return { success: false, error: result.error || 'Failed to stop workflow' };
|
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' };
|
return { success: false, error: error.message || 'Failed to stop workflow' };
|
||||||
}
|
}
|
||||||
}, [instanceId, workflowId, stopWorkflow, updateWorkflowStatus]);
|
}, [instanceId, workflowId, stopWorkflow, updateWorkflowStatus]);
|
||||||
|
|
||||||
|
// === RESET WORKFLOW ===
|
||||||
const resetWorkflow = useCallback(() => {
|
const resetWorkflow = useCallback(() => {
|
||||||
|
console.log('🔄 Resetting workflow state');
|
||||||
|
|
||||||
setWorkflowId(null);
|
setWorkflowId(null);
|
||||||
prevStatusRef.current = 'idle';
|
|
||||||
statusRef.current = 'idle';
|
|
||||||
updateWorkflowStatus('idle');
|
updateWorkflowStatus('idle');
|
||||||
setCurrentRound(undefined);
|
setCurrentRound(undefined);
|
||||||
|
setMessages([]);
|
||||||
|
setLogs([]);
|
||||||
|
setDashboardLogs([]);
|
||||||
|
setUnifiedContentLogs([]);
|
||||||
setLatestStats(null);
|
setLatestStats(null);
|
||||||
// Reset stats tracking
|
|
||||||
|
// Reset refs
|
||||||
|
lastRenderedTimestampRef.current = null;
|
||||||
processedStatIdsRef.current.clear();
|
processedStatIdsRef.current.clear();
|
||||||
cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 };
|
cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 };
|
||||||
setStatusChangedFromRunningAt(null);
|
hasRenderedLastMessageRef.current = false;
|
||||||
statusChangedFromRunningAtRef.current = null;
|
setHasRenderedLastMessage(false);
|
||||||
lastRenderedTimestampRef.current = null;
|
|
||||||
pollingControllerRef.current.stopPolling();
|
pollingControllerRef.current.stopPolling();
|
||||||
}, [updateWorkflowStatus]);
|
}, [updateWorkflowStatus]);
|
||||||
|
|
||||||
|
// === SELECT/LOAD WORKFLOW ===
|
||||||
const selectWorkflow = useCallback(async (workflowIdToSelect: string) => {
|
const selectWorkflow = useCallback(async (workflowIdToSelect: string) => {
|
||||||
try {
|
try {
|
||||||
|
console.log('📥 Loading workflow:', workflowIdToSelect);
|
||||||
|
|
||||||
|
// Reset state
|
||||||
setWorkflowId(workflowIdToSelect);
|
setWorkflowId(workflowIdToSelect);
|
||||||
// Reset lastRenderedTimestamp and stats for new workflow selection
|
|
||||||
lastRenderedTimestampRef.current = null;
|
lastRenderedTimestampRef.current = null;
|
||||||
processedStatIdsRef.current.clear();
|
processedStatIdsRef.current.clear();
|
||||||
cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 };
|
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);
|
const workflowData = await fetchWorkflowApi(request, workflowIdToSelect).catch(() => null);
|
||||||
|
|
||||||
if (!workflowData) {
|
if (!workflowData) {
|
||||||
|
|
@ -597,58 +507,66 @@ export function useWorkflowLifecycle(instanceId: string) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const messagesData = Array.isArray(workflowData.messages) ? workflowData.messages : [];
|
|
||||||
const logsData = Array.isArray(workflowData.logs) ? workflowData.logs : [];
|
|
||||||
const status = workflowData.status || 'idle';
|
const status = workflowData.status || 'idle';
|
||||||
const round = workflowData.currentRound !== undefined ? workflowData.currentRound : undefined;
|
const round = workflowData.currentRound;
|
||||||
|
|
||||||
updateWorkflowStatus(status);
|
updateWorkflowStatus(status);
|
||||||
setCurrentRound(round);
|
if (round !== undefined) setCurrentRound(round);
|
||||||
|
|
||||||
// Always fetch unified chat data to get all messages and logs (regardless of status)
|
// Fetch all chat data (no afterTimestamp = get everything)
|
||||||
// This ensures completed workflows also show their logs
|
|
||||||
try {
|
try {
|
||||||
const chatData = await fetchChatData(request, instanceId, workflowIdToSelect, undefined);
|
const chatData = await fetchChatData(request, instanceId, workflowIdToSelect, undefined);
|
||||||
console.log('📥 selectWorkflow: Fetched unified chat data:', {
|
console.log('📥 Loaded chat data:', {
|
||||||
messagesCount: chatData.messages?.length || 0,
|
messages: chatData.messages?.length || 0,
|
||||||
logsCount: chatData.logs?.length || 0,
|
logs: chatData.logs?.length || 0,
|
||||||
status
|
stats: chatData.stats?.length || 0
|
||||||
});
|
});
|
||||||
processUnifiedChatData(chatData);
|
|
||||||
} catch (error) {
|
// === STATE MACHINE: Check if last message has status="last" ===
|
||||||
console.warn('⚠️ Failed to fetch unified chat data, falling back to workflowData:', error);
|
const allMessages = chatData.messages || [];
|
||||||
// Fallback to workflowData if unified chat data fails
|
const sortedMessages = [...allMessages].sort((a, b) => {
|
||||||
if (messagesData.length > 0) {
|
const aTime = a.publishedAt || a.timestamp || 0;
|
||||||
setMessages([...messagesData].sort(sortMessages));
|
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
|
// Process the data
|
||||||
const dashboardLogsList: WorkflowLog[] = [];
|
processUnifiedChatData(chatData);
|
||||||
const unifiedContentLogsList: WorkflowLog[] = [];
|
|
||||||
|
|
||||||
logsData.forEach((log: any) => {
|
} catch (error) {
|
||||||
const frontendLog = convertLogToFrontendFormat(log);
|
console.warn('⚠️ Failed to fetch chat data:', error);
|
||||||
if (frontendLog.operationId) {
|
updateWorkflowStatus('idle');
|
||||||
dashboardLogsList.push(frontendLog);
|
|
||||||
} else {
|
|
||||||
unifiedContentLogsList.push(frontendLog);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setDashboardLogs(dashboardLogsList.sort(sortLogs));
|
|
||||||
setUnifiedContentLogs(unifiedContentLogsList.sort(sortLogs));
|
|
||||||
setLogs([...dashboardLogsList, ...unifiedContentLogsList].sort(sortLogs));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If workflow is running, polling will start automatically via useEffect
|
|
||||||
} catch (error) {
|
} 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 isRunning = workflowStatus === 'running';
|
||||||
const isStopping = workflowId ? stoppingWorkflows.has(workflowId) : false;
|
const isStopping = workflowId ? stoppingWorkflows.has(workflowId) : false;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
workflowId,
|
workflowId,
|
||||||
workflowStatus,
|
workflowStatus,
|
||||||
|
|
@ -661,6 +579,7 @@ export function useWorkflowLifecycle(instanceId: string) {
|
||||||
dashboardLogs,
|
dashboardLogs,
|
||||||
unifiedContentLogs,
|
unifiedContentLogs,
|
||||||
latestStats,
|
latestStats,
|
||||||
|
hasRenderedLastMessage,
|
||||||
startWorkflow: handleStartWorkflow,
|
startWorkflow: handleStartWorkflow,
|
||||||
stopWorkflow: handleStopWorkflow,
|
stopWorkflow: handleStopWorkflow,
|
||||||
resetWorkflow,
|
resetWorkflow,
|
||||||
|
|
|
||||||
|
|
@ -138,8 +138,12 @@
|
||||||
/* Feature Content */
|
/* Feature Content */
|
||||||
.featureContent {
|
.featureContent {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
/* Let child components handle their own scrolling for sticky headers */
|
||||||
|
overflow: hidden;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
|
/* Maintain flex chain for child components */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark Theme */
|
/* Dark Theme */
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,8 @@
|
||||||
/* Content */
|
/* Content */
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
/* Let child components handle their own scrolling for sticky headers */
|
||||||
|
overflow: hidden;
|
||||||
background: var(--bg-primary, #ffffff);
|
background: var(--bg-primary, #ffffff);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,12 @@
|
||||||
|
|
||||||
.adminPage {
|
.adminPage {
|
||||||
padding: 1.5rem;
|
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 {
|
.pageHeader {
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,8 @@
|
||||||
|
|
||||||
.billingDashboard {
|
.billingDashboard {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pageHeader {
|
.pageHeader {
|
||||||
|
|
@ -490,7 +489,7 @@
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.billingDashboard {
|
.billingDashboard {
|
||||||
padding: 1rem;
|
padding: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.balanceGrid {
|
.balanceGrid {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { useBilling, type BillingBalance, type UsageReport } from '../../hooks/useBilling';
|
import { useBilling, type BillingBalance, type UsageReport } from '../../hooks/useBilling';
|
||||||
|
import { BillingNav } from './BillingNav';
|
||||||
import styles from './Billing.module.css';
|
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>
|
<p className={styles.subtitle}>Übersicht über Guthaben und Nutzung</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<BillingNav />
|
||||||
|
|
||||||
{/* Balance Cards */}
|
{/* Balance Cards */}
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
<h2 className={styles.sectionTitle}>Guthaben</h2>
|
<h2 className={styles.sectionTitle}>Guthaben</h2>
|
||||||
|
|
|
||||||
443
src/pages/billing/BillingDataView.tsx
Normal file
443
src/pages/billing/BillingDataView.tsx
Normal 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;
|
||||||
280
src/pages/billing/BillingMandateView.tsx
Normal file
280
src/pages/billing/BillingMandateView.tsx
Normal 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;
|
||||||
54
src/pages/billing/BillingNav.tsx
Normal file
54
src/pages/billing/BillingNav.tsx
Normal 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;
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useBilling, type BillingTransaction } from '../../hooks/useBilling';
|
import { useBilling, type BillingTransaction } from '../../hooks/useBilling';
|
||||||
|
import { BillingNav } from './BillingNav';
|
||||||
import styles from './Billing.module.css';
|
import styles from './Billing.module.css';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -56,6 +57,7 @@ const TransactionRow: React.FC<TransactionRowProps> = ({ transaction }) => {
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
<td>{formatDate(transaction.createdAt)}</td>
|
<td>{formatDate(transaction.createdAt)}</td>
|
||||||
|
<td>{transaction.mandateName || '-'}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`${styles.transactionType} ${getTypeClass(transaction.transactionType)}`}>
|
<span className={`${styles.transactionType} ${getTypeClass(transaction.transactionType)}`}>
|
||||||
{getTypeLabel(transaction.transactionType)}
|
{getTypeLabel(transaction.transactionType)}
|
||||||
|
|
@ -94,6 +96,8 @@ export const BillingTransactions: React.FC = () => {
|
||||||
<p className={styles.subtitle}>Übersicht aller Kontobewegungen</p>
|
<p className={styles.subtitle}>Übersicht aller Kontobewegungen</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<BillingNav />
|
||||||
|
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
{loading && transactions.length === 0 ? (
|
{loading && transactions.length === 0 ? (
|
||||||
<div className={styles.loadingPlaceholder}>Lade Transaktionen...</div>
|
<div className={styles.loadingPlaceholder}>Lade Transaktionen...</div>
|
||||||
|
|
@ -106,6 +110,7 @@ export const BillingTransactions: React.FC = () => {
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Datum</th>
|
<th>Datum</th>
|
||||||
|
<th>Mandant</th>
|
||||||
<th>Typ</th>
|
<th>Typ</th>
|
||||||
<th>Beschreibung</th>
|
<th>Beschreibung</th>
|
||||||
<th>Anbieter</th>
|
<th>Anbieter</th>
|
||||||
|
|
|
||||||
376
src/pages/billing/BillingUserView.tsx
Normal file
376
src/pages/billing/BillingUserView.tsx
Normal 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;
|
||||||
|
|
@ -3,5 +3,11 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { BillingDashboard } from './BillingDashboard';
|
export { BillingDashboard } from './BillingDashboard';
|
||||||
export { BillingTransactions } from './BillingTransactions';
|
export { BillingDataView } from './BillingDataView';
|
||||||
export { BillingAdmin } from './BillingAdmin';
|
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';
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,24 @@
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
border-bottom: 1px solid var(--border-color);
|
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 {
|
.pageTitle {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
@ -32,6 +45,24 @@
|
||||||
margin: 0;
|
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 {
|
.pageSubtitle {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|
@ -372,30 +403,6 @@
|
||||||
border-color: var(--primary-color, #f25843);
|
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 */
|
/* Pending files */
|
||||||
.pendingFiles {
|
.pendingFiles {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -582,8 +582,26 @@ export const PlaygroundPage: React.FC = () => {
|
||||||
|
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<header className={styles.pageHeader}>
|
<header className={styles.pageHeader}>
|
||||||
<div>
|
<div className={styles.headerLeft}>
|
||||||
<h1 className={styles.pageTitle}>Chat Playground</h1>
|
<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>
|
<p className={styles.pageSubtitle}>Workflow-Ausführung und Chat-Interaktion</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.headerControls}>
|
<div className={styles.headerControls}>
|
||||||
|
|
@ -711,36 +729,6 @@ export const PlaygroundPage: React.FC = () => {
|
||||||
</div>
|
</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 */}
|
{/* Input row */}
|
||||||
<div className={styles.inputRow}>
|
<div className={styles.inputRow}>
|
||||||
<div className={styles.inputWrapper}>
|
<div className={styles.inputWrapper}>
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
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
|
// Handle edit submit
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue