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';
|
||||
|
||||
// Billing Pages
|
||||
import { BillingDashboard, BillingTransactions, BillingAdmin } from './pages/billing';
|
||||
import { BillingDashboard, BillingDataView, BillingAdmin } from './pages/billing';
|
||||
|
||||
function App() {
|
||||
// Load saved theme preference and set app name on app mount
|
||||
|
|
@ -117,8 +117,8 @@ function App() {
|
|||
{/* BILLING ROUTES */}
|
||||
{/* ============================================== */}
|
||||
<Route path="billing">
|
||||
<Route index element={<BillingDashboard />} />
|
||||
<Route path="transactions" element={<BillingTransactions />} />
|
||||
<Route index element={<Navigate to="/billing/transactions" replace />} />
|
||||
<Route path="transactions" element={<BillingDataView />} />
|
||||
</Route>
|
||||
|
||||
{/* ============================================== */}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ export interface BillingTransaction {
|
|||
featureCode?: string;
|
||||
aicoreProvider?: string;
|
||||
createdAt?: string;
|
||||
mandateId?: string;
|
||||
mandateName?: string;
|
||||
}
|
||||
|
||||
export interface BillingSettings {
|
||||
|
|
@ -277,3 +279,95 @@ export async function fetchUsersForMandateAdmin(
|
|||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MANDATE VIEW TYPES & API FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
export interface MandateBalance {
|
||||
mandateId: string;
|
||||
mandateName: string;
|
||||
billingModel: BillingModel;
|
||||
totalBalance: number;
|
||||
userCount: number;
|
||||
defaultUserCredit: number;
|
||||
warningThresholdPercent: number;
|
||||
blockOnZeroBalance: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch mandate-level balances (SysAdmin only)
|
||||
* Endpoint: GET /api/billing/view/mandates/balances
|
||||
*/
|
||||
export async function fetchMandateViewBalances(
|
||||
request: ApiRequestFunction
|
||||
): Promise<MandateBalance[]> {
|
||||
return await request({
|
||||
url: '/api/billing/view/mandates/balances',
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch mandate-level transactions (SysAdmin only)
|
||||
* Endpoint: GET /api/billing/view/mandates/transactions
|
||||
*/
|
||||
export async function fetchMandateViewTransactions(
|
||||
request: ApiRequestFunction,
|
||||
limit: number = 100
|
||||
): Promise<BillingTransaction[]> {
|
||||
return await request({
|
||||
url: '/api/billing/view/mandates/transactions',
|
||||
method: 'get',
|
||||
params: { limit }
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// USER VIEW TYPES & API FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
export interface UserBalance {
|
||||
accountId: string;
|
||||
mandateId: string;
|
||||
mandateName: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
balance: number;
|
||||
warningThreshold: number;
|
||||
isWarning: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface UserTransaction extends BillingTransaction {
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user-level balances (RBAC-based)
|
||||
* Endpoint: GET /api/billing/view/users/balances
|
||||
*/
|
||||
export async function fetchUserViewBalances(
|
||||
request: ApiRequestFunction
|
||||
): Promise<UserBalance[]> {
|
||||
return await request({
|
||||
url: '/api/billing/view/users/balances',
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user-level transactions (RBAC-based)
|
||||
* Endpoint: GET /api/billing/view/users/transactions
|
||||
*/
|
||||
export async function fetchUserViewTransactions(
|
||||
request: ApiRequestFunction,
|
||||
limit: number = 100
|
||||
): Promise<UserTransaction[]> {
|
||||
return await request({
|
||||
url: '/api/billing/view/users/transactions',
|
||||
method: 'get',
|
||||
params: { limit }
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@
|
|||
/* Fill available space and constrain height */
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
/* No overflow - children handle their own scrolling */
|
||||
/* Prevent overflow - constrain to parent height */
|
||||
overflow: hidden;
|
||||
/* Ensure container respects parent's height */
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.title {
|
||||
|
|
@ -18,18 +22,46 @@
|
|||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Table Container - scrollable area for table data only */
|
||||
.tableContainer {
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
/* Table wrapper - contains top scrollbar and table container */
|
||||
.tableWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
/* Constrain height to prevent growing beyond parent */
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 25px;
|
||||
background: var(--color-bg);
|
||||
/* Fill remaining space after controls */
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Top horizontal scrollbar - syncs with table container */
|
||||
.topScrollbar {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
flex-shrink: 0;
|
||||
background: var(--color-bg);
|
||||
border-bottom: 1px solid var(--color-primary);
|
||||
border-radius: 25px 25px 0 0;
|
||||
}
|
||||
|
||||
/* Inner div that matches table width for proper scrollbar sizing */
|
||||
.topScrollbarInner {
|
||||
height: 1px; /* Minimal height - just need width to activate scrollbar */
|
||||
}
|
||||
|
||||
/* Table Container - scrollable area for table data only (vertical only) */
|
||||
.tableContainer {
|
||||
position: relative;
|
||||
overflow-x: hidden; /* Horizontal scroll handled by topScrollbar */
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg);
|
||||
/* Fill remaining space but constrain to available height */
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
/* Clip content to border-radius but allow sticky to work */
|
||||
isolation: isolate;
|
||||
max-height: 100%;
|
||||
border-radius: 0 0 25px 25px;
|
||||
}
|
||||
|
||||
/* Empty table styling - no extra space, just header */
|
||||
|
|
@ -39,6 +71,11 @@
|
|||
max-height: none;
|
||||
}
|
||||
|
||||
/* Hide top scrollbar when table is empty */
|
||||
.emptyTable .topScrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Empty state styling */
|
||||
.emptyState {
|
||||
display: flex;
|
||||
|
|
@ -734,7 +771,7 @@ tbody .actionsColumn {
|
|||
outline: none;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for table container */
|
||||
/* Custom scrollbar for table container (vertical only) */
|
||||
.tableContainer::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
|
|
@ -754,6 +791,25 @@ tbody .actionsColumn {
|
|||
background: var(--color-secondary);
|
||||
}
|
||||
|
||||
/* Custom scrollbar for top scrollbar (horizontal only) */
|
||||
.topScrollbar::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.topScrollbar::-webkit-scrollbar-track {
|
||||
background: var(--color-gray-disabled);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.topScrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--color-gray);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.topScrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-secondary);
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.loadingState {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -338,6 +338,11 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
const tableRef = useRef<HTMLTableElement>(null);
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Refs for top scrollbar synchronization
|
||||
const topScrollbarRef = useRef<HTMLDivElement>(null);
|
||||
const topScrollbarInnerRef = useRef<HTMLDivElement>(null);
|
||||
const isScrollingSyncRef = useRef<boolean>(false); // Prevent scroll sync loops
|
||||
|
||||
// Track container width for actions column 20% threshold
|
||||
const [containerWidth, setContainerWidth] = useState<number>(0);
|
||||
|
||||
|
|
@ -945,6 +950,53 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
};
|
||||
}, []);
|
||||
|
||||
// Sync top scrollbar width with table width and handle scroll synchronization
|
||||
useEffect(() => {
|
||||
const tableContainer = tableContainerRef.current;
|
||||
const topScrollbar = topScrollbarRef.current;
|
||||
const topScrollbarInner = topScrollbarInnerRef.current;
|
||||
const table = tableRef.current;
|
||||
|
||||
if (!tableContainer || !topScrollbar || !topScrollbarInner || !table) return;
|
||||
|
||||
// Update top scrollbar inner width to match table width
|
||||
const updateScrollbarWidth = () => {
|
||||
const tableWidth = table.scrollWidth;
|
||||
topScrollbarInner.style.width = `${tableWidth}px`;
|
||||
};
|
||||
|
||||
// Initial width calculation
|
||||
updateScrollbarWidth();
|
||||
|
||||
// Observe table size changes
|
||||
const resizeObserver = new ResizeObserver(updateScrollbarWidth);
|
||||
resizeObserver.observe(table);
|
||||
|
||||
// Sync scroll positions
|
||||
const syncTopToContainer = () => {
|
||||
if (isScrollingSyncRef.current) return;
|
||||
isScrollingSyncRef.current = true;
|
||||
tableContainer.scrollLeft = topScrollbar.scrollLeft;
|
||||
requestAnimationFrame(() => { isScrollingSyncRef.current = false; });
|
||||
};
|
||||
|
||||
const syncContainerToTop = () => {
|
||||
if (isScrollingSyncRef.current) return;
|
||||
isScrollingSyncRef.current = true;
|
||||
topScrollbar.scrollLeft = tableContainer.scrollLeft;
|
||||
requestAnimationFrame(() => { isScrollingSyncRef.current = false; });
|
||||
};
|
||||
|
||||
topScrollbar.addEventListener('scroll', syncTopToContainer);
|
||||
tableContainer.addEventListener('scroll', syncContainerToTop);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
topScrollbar.removeEventListener('scroll', syncTopToContainer);
|
||||
tableContainer.removeEventListener('scroll', syncContainerToTop);
|
||||
};
|
||||
}, [displayData, detectedColumns, columnWidths]); // Re-run when data or columns change
|
||||
|
||||
// Track which cells are currently being updated (for loading state)
|
||||
const [updatingCells, setUpdatingCells] = useState<Set<string>>(new Set());
|
||||
|
||||
|
|
@ -1367,26 +1419,36 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div
|
||||
ref={tableContainerRef}
|
||||
className={`${styles.tableContainer} ${displayData.length === 0 && !loading ? styles.emptyTable : ''}`}
|
||||
>
|
||||
{/* Loading overlay - shown while loading */}
|
||||
{loading && (
|
||||
<div className={styles.loadingOverlay}>
|
||||
<div className={styles.loadingSpinner}></div>
|
||||
<p>{t('common.loading', 'Loading...')}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Table Wrapper - contains top scrollbar and table container */}
|
||||
<div className={`${styles.tableWrapper} ${displayData.length === 0 && !loading ? styles.emptyTable : ''}`}>
|
||||
{/* Top horizontal scrollbar - syncs with table container */}
|
||||
<div
|
||||
ref={topScrollbarRef}
|
||||
className={styles.topScrollbar}
|
||||
>
|
||||
<div ref={topScrollbarInnerRef} className={styles.topScrollbarInner} />
|
||||
</div>
|
||||
|
||||
{/* Empty state - only shown when not loading AND no data */}
|
||||
{!loading && displayData.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<p className={styles.emptyMessage}>{emptyMessage || t('formgen.empty', 'No data available')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<table ref={tableRef} className={styles.table}>
|
||||
{/* Table Container - vertical scroll only */}
|
||||
<div
|
||||
ref={tableContainerRef}
|
||||
className={styles.tableContainer}
|
||||
>
|
||||
{/* Loading overlay - shown while loading */}
|
||||
{loading && (
|
||||
<div className={styles.loadingOverlay}>
|
||||
<div className={styles.loadingSpinner}></div>
|
||||
<p>{t('common.loading', 'Loading...')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state - only shown when not loading AND no data */}
|
||||
{!loading && displayData.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<p className={styles.emptyMessage}>{emptyMessage || t('formgen.empty', 'No data available')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<table ref={tableRef} className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
{selectable && (
|
||||
|
|
@ -1704,7 +1766,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
</tbody>
|
||||
)}
|
||||
</table>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,16 +8,16 @@
|
|||
* Backend liefert Blocks-Struktur mit Static und Dynamic Blocks.
|
||||
* UI mappt uiComponent zu Icons via pageRegistry.
|
||||
*
|
||||
* Struktur (gemäss Navigation-API-Konzept):
|
||||
* - SYSTEM (static block, order: 10)
|
||||
* - MEINE FEATURES (dynamic block, order: 15)
|
||||
* - Mandant 1
|
||||
* - Feature A
|
||||
* - Instanz 1 (mit Views)
|
||||
* - WORKFLOWS (static block, order: 20)
|
||||
* - BASISDATEN (static block, order: 30)
|
||||
* - MIGRATE TO FEATURES (static block, order: 40)
|
||||
* - ADMINISTRATION (static block, order: 200)
|
||||
* FLAT STRUCTURE (kompakte Darstellung):
|
||||
* - SYSTEM (static block)
|
||||
* - Mandant 1
|
||||
* - 🎯 Instanz 1 (Feature-Icon + Instanz-Name)
|
||||
* - 💼 Instanz 2 (Feature-Icon + Instanz-Name)
|
||||
* - BASISDATEN (static block)
|
||||
* - ADMINISTRATION (static block)
|
||||
*
|
||||
* Jede Instanz zeigt das Icon des zugehörigen Features.
|
||||
* Keine Gruppierung nach Features - direkt Instanzen unter Mandant.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
|
@ -75,58 +75,52 @@ function featureViewToTreeNode(view: FeatureView): TreeNodeItem {
|
|||
}
|
||||
|
||||
/**
|
||||
* Convert a FeatureInstance to TreeNodeItem
|
||||
* Instance node gets path to first view so clicking the instance name (e.g. PEK) navigates to dashboard.
|
||||
* Convert a FeatureInstance to TreeNodeItem (with feature icon)
|
||||
* Instance node gets path to first view so clicking the instance name navigates to dashboard.
|
||||
* Shows the feature icon next to the instance name for visual distinction.
|
||||
*/
|
||||
function featureInstanceToTreeNode(instance: FeatureInstance): TreeNodeItem {
|
||||
function featureInstanceToTreeNode(instance: FeatureInstance, featureUiComponent: string): TreeNodeItem {
|
||||
const children = instance.views.map(featureViewToTreeNode);
|
||||
return {
|
||||
id: instance.id,
|
||||
label: instance.uiLabel,
|
||||
icon: getPageIcon(featureUiComponent), // Use feature icon for instance
|
||||
path: instance.views.length > 0 ? instance.views[0].uiPath : undefined,
|
||||
children,
|
||||
defaultExpanded: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a MandateFeature to TreeNodeItem
|
||||
*/
|
||||
function mandateFeatureToTreeNode(feature: MandateFeature): TreeNodeItem | null {
|
||||
if (feature.instances.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: feature.uiComponent,
|
||||
label: feature.uiLabel,
|
||||
icon: getPageIcon(feature.uiComponent),
|
||||
badge: feature.instances.length,
|
||||
children: feature.instances.map(featureInstanceToTreeNode),
|
||||
defaultExpanded: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a NavigationMandate to TreeNodeItem
|
||||
*
|
||||
* FLAT STRUCTURE: Instances are listed directly under mandate (no feature grouping).
|
||||
* Each instance shows the feature's icon for visual distinction.
|
||||
*
|
||||
* Before: Mandate → Feature → Instance → Views
|
||||
* Now: Mandate → Instance (with feature icon) → Views
|
||||
*/
|
||||
function navigationMandateToTreeNode(mandate: NavigationMandate): TreeNodeItem | null {
|
||||
if (mandate.features.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const children = mandate.features
|
||||
.map(mandateFeatureToTreeNode)
|
||||
.filter((node): node is TreeNodeItem => node !== null);
|
||||
// Flatten: collect all instances from all features directly under mandate
|
||||
const instanceNodes: TreeNodeItem[] = [];
|
||||
for (const feature of mandate.features) {
|
||||
for (const instance of feature.instances) {
|
||||
instanceNodes.push(featureInstanceToTreeNode(instance, feature.uiComponent));
|
||||
}
|
||||
}
|
||||
|
||||
if (children.length === 0) {
|
||||
if (instanceNodes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: mandate.id,
|
||||
label: mandate.uiLabel,
|
||||
children,
|
||||
children: instanceNodes,
|
||||
defaultExpanded: true,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export const UserSection: React.FC = () => {
|
|||
};
|
||||
|
||||
const handleBilling = () => {
|
||||
navigate('/billing');
|
||||
navigate('/billing/transactions');
|
||||
setShowMenu(false);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -42,133 +42,90 @@
|
|||
============================================================================ */
|
||||
|
||||
.providerMultiSelect {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs, 4px);
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.providerMultiSelect.collapsed {
|
||||
/* Collapsed state styles */
|
||||
}
|
||||
|
||||
.providerMultiSelect.expanded {
|
||||
/* Expanded state styles */
|
||||
}
|
||||
|
||||
/* Collapsible Header Button */
|
||||
.collapseHeader {
|
||||
/* Trigger Button - matches iconButton style from PlaygroundPage */
|
||||
.triggerButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs, 4px);
|
||||
padding: var(--spacing-xs, 4px) var(--spacing-sm, 8px);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-md, 6px);
|
||||
background: var(--color-bg-input);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--border-color, #3a3a3a);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-color, #2d2d2d);
|
||||
color: var(--text-secondary, #888);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 140px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.collapseHeader:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-bg-hover);
|
||||
.triggerButton:hover:not(:disabled) {
|
||||
background: var(--bg-secondary, #3a3a3a);
|
||||
color: var(--text-primary, #fff);
|
||||
}
|
||||
|
||||
.collapseHeader:disabled {
|
||||
opacity: 0.6;
|
||||
.triggerButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.summaryIcons {
|
||||
font-size: 0.9em;
|
||||
.buttonIcon {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.summaryText {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.expandIcon {
|
||||
font-size: 0.7em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Expandable Content - opens upward */
|
||||
.expandableContent {
|
||||
/* Dropdown Content - opens upward */
|
||||
.dropdownContent {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
margin-bottom: var(--spacing-xs, 4px);
|
||||
padding: var(--spacing-sm, 8px);
|
||||
background: #2d2d2d;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #444;
|
||||
border-radius: var(--border-radius-md, 6px);
|
||||
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.4);
|
||||
min-width: 200px;
|
||||
bottom: calc(100% + 4px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
padding: 8px;
|
||||
background: var(--surface-color, #2d2d2d);
|
||||
border: 1px solid var(--border-color, #3a3a3a);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.5);
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.expandableContent .label {
|
||||
color: #b0b0b0;
|
||||
}
|
||||
|
||||
.expandableContent .checkboxList {
|
||||
background: #252525;
|
||||
}
|
||||
|
||||
.expandableContent .checkboxItem {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.expandableContent .checkboxItem:hover {
|
||||
background: #3a3a3a;
|
||||
}
|
||||
|
||||
.expandableContent .providerName {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.expandableContent .hint {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.expandableContent .actionButton {
|
||||
background: #3a3a3a;
|
||||
color: #e0e0e0;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.expandableContent .actionButton:hover:not(:disabled) {
|
||||
background: #4a4a4a;
|
||||
.dropdownHeader {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #888);
|
||||
padding: 4px 8px;
|
||||
margin-bottom: 4px;
|
||||
border-bottom: 1px solid var(--border-color, #3a3a3a);
|
||||
}
|
||||
|
||||
.selectActions {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs, 4px);
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm, 4px);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-xs, 0.75rem);
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border-color, #3a3a3a);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary, #252525);
|
||||
color: var(--text-secondary, #888);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.actionButton:hover:not(:disabled) {
|
||||
background: var(--color-bg-hover);
|
||||
border-color: var(--color-primary);
|
||||
background: var(--bg-hover, #3a3a3a);
|
||||
color: var(--text-primary, #fff);
|
||||
}
|
||||
|
||||
.actionButton.active {
|
||||
background: var(--primary-color, #f25843);
|
||||
border-color: var(--primary-color, #f25843);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.actionButton:disabled {
|
||||
|
|
@ -179,24 +136,27 @@
|
|||
.checkboxList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs, 4px);
|
||||
padding: var(--spacing-sm, 8px);
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--border-radius-md, 6px);
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
background: var(--bg-secondary, #252525);
|
||||
border-radius: 4px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.checkboxItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm, 8px);
|
||||
padding: var(--spacing-xs, 4px) var(--spacing-sm, 8px);
|
||||
border-radius: var(--border-radius-sm, 4px);
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
transition: background 0.15s ease;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.checkboxItem:hover {
|
||||
background: var(--color-bg-hover);
|
||||
background: var(--bg-hover, #3a3a3a);
|
||||
}
|
||||
|
||||
.checkboxItem.disabled {
|
||||
|
|
@ -205,34 +165,35 @@
|
|||
}
|
||||
|
||||
.checkboxItem input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: inherit;
|
||||
accent-color: var(--primary-color, #f25843);
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 1.1em;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.providerName {
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: var(--font-size-xs, 0.75rem);
|
||||
color: var(--color-text-tertiary);
|
||||
font-style: italic;
|
||||
padding: var(--spacing-xs, 4px) 0;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-tertiary, #666);
|
||||
text-align: center;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-md, 16px);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
padding: 12px;
|
||||
color: var(--text-secondary, #888);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
* - Lädt verfügbare Provider aus dem Billing-System
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react';
|
||||
import { useBilling } from '../../hooks/useBilling';
|
||||
import styles from './ProviderSelector.module.css';
|
||||
|
||||
|
|
@ -20,6 +20,7 @@ const PROVIDER_LABELS: Record<string, string> = {
|
|||
openai: 'OpenAI (GPT)',
|
||||
perplexity: 'Perplexity',
|
||||
tavily: 'Tavily (Web Search)',
|
||||
privatellm: 'Private LLM',
|
||||
internal: 'Internal',
|
||||
};
|
||||
|
||||
|
|
@ -29,6 +30,7 @@ const PROVIDER_ICONS: Record<string, string> = {
|
|||
openai: '💬',
|
||||
perplexity: '🔍',
|
||||
tavily: '🌐',
|
||||
privatellm: '🔒',
|
||||
internal: '🏠',
|
||||
};
|
||||
|
||||
|
|
@ -112,6 +114,7 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
|||
defaultExpanded = false,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { allowedProviders, loadAllowedProviders, loading } = useBilling();
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -120,66 +123,84 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
|||
}
|
||||
}, []);
|
||||
|
||||
// Click outside handler
|
||||
const handleClickOutside = useCallback((event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [isExpanded, handleClickOutside]);
|
||||
|
||||
// Check if all providers are selected (or none selected = all used)
|
||||
const isAllSelected = selectedProviders.length === 0 ||
|
||||
(allowedProviders.length > 0 && selectedProviders.length === allowedProviders.length);
|
||||
|
||||
// For checkbox display: if none selected, show all as checked (since all are used)
|
||||
const effectiveSelection = selectedProviders.length === 0 ? allowedProviders : selectedProviders;
|
||||
|
||||
const handleToggle = (provider: string) => {
|
||||
if (selectedProviders.includes(provider)) {
|
||||
onChange(selectedProviders.filter((p) => p !== provider));
|
||||
// Use effectiveSelection for toggle logic (handles empty = all case)
|
||||
if (effectiveSelection.includes(provider)) {
|
||||
// Deactivate: remove from effective selection
|
||||
onChange(effectiveSelection.filter((p) => p !== provider));
|
||||
} else {
|
||||
onChange([...selectedProviders, provider]);
|
||||
// Activate: add to effective selection
|
||||
onChange([...effectiveSelection, provider]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
onChange(allowedProviders);
|
||||
onChange([...allowedProviders]);
|
||||
};
|
||||
|
||||
const handleSelectNone = () => {
|
||||
onChange([]);
|
||||
};
|
||||
|
||||
// Summary text for collapsed view
|
||||
const summaryText = useMemo(() => {
|
||||
if (selectedProviders.length === 0) {
|
||||
return 'Alle Provider';
|
||||
}
|
||||
if (selectedProviders.length === 1) {
|
||||
return PROVIDER_LABELS[selectedProviders[0]] || selectedProviders[0];
|
||||
}
|
||||
return `${selectedProviders.length} Provider`;
|
||||
}, [selectedProviders]);
|
||||
|
||||
// Summary icons for collapsed view
|
||||
const summaryIcons = useMemo(() => {
|
||||
if (selectedProviders.length === 0) {
|
||||
// Summary icon for button
|
||||
const summaryIcon = useMemo(() => {
|
||||
if (selectedProviders.length === 0 || selectedProviders.length === allowedProviders.length) {
|
||||
return '🤖';
|
||||
}
|
||||
return selectedProviders.slice(0, 3).map(p => PROVIDER_ICONS[p] || '🔌').join('');
|
||||
}, [selectedProviders]);
|
||||
if (selectedProviders.length === 1) {
|
||||
return PROVIDER_ICONS[selectedProviders[0]] || '🔌';
|
||||
}
|
||||
return '🤖';
|
||||
}, [selectedProviders, allowedProviders]);
|
||||
|
||||
return (
|
||||
<div className={`${styles.providerMultiSelect} ${className || ''} ${isExpanded ? styles.expanded : styles.collapsed}`}>
|
||||
{/* Collapsible Header */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`${styles.providerMultiSelect} ${className || ''} ${isExpanded ? styles.expanded : styles.collapsed}`}
|
||||
>
|
||||
{/* Trigger Button - styled like iconButton */}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.collapseHeader}
|
||||
className={styles.triggerButton}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
disabled={disabled}
|
||||
title="Provider auswählen"
|
||||
>
|
||||
<span className={styles.summaryIcons}>{summaryIcons}</span>
|
||||
<span className={styles.summaryText}>{summaryText}</span>
|
||||
<span className={styles.expandIcon}>{isExpanded ? '▲' : '▼'}</span>
|
||||
<span className={styles.buttonIcon}>{summaryIcon}</span>
|
||||
</button>
|
||||
|
||||
{/* Expandable Content */}
|
||||
{/* Dropdown Content */}
|
||||
{isExpanded && (
|
||||
<div className={styles.expandableContent}>
|
||||
{showLabel && <label className={styles.label}>{label}</label>}
|
||||
<div className={styles.dropdownContent}>
|
||||
{showLabel && <div className={styles.dropdownHeader}>{label}</div>}
|
||||
|
||||
<div className={styles.selectActions}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectAll}
|
||||
disabled={disabled}
|
||||
className={styles.actionButton}
|
||||
className={`${styles.actionButton} ${isAllSelected ? styles.active : ''}`}
|
||||
>
|
||||
Alle
|
||||
</button>
|
||||
|
|
@ -194,7 +215,7 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
|||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className={styles.loading}>Lade Provider...</div>
|
||||
<div className={styles.loading}>Lade...</div>
|
||||
) : (
|
||||
<div className={styles.checkboxList}>
|
||||
{allowedProviders.map((provider) => (
|
||||
|
|
@ -204,7 +225,7 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
|||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedProviders.includes(provider)}
|
||||
checked={effectiveSelection.includes(provider)}
|
||||
onChange={() => handleToggle(provider)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
|
@ -219,7 +240,7 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
|||
|
||||
{selectedProviders.length === 0 && !loading && (
|
||||
<div className={styles.hint}>
|
||||
Wenn keine Provider ausgewählt sind, werden alle erlaubten Provider verwendet.
|
||||
Alle Provider aktiv
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { FaExclamationTriangle } from 'react-icons/fa';
|
||||
import { Message } from '../MessagesTypes';
|
||||
import { formatTimestamp } from '../MessageUtils';
|
||||
import { DocumentItem, ActionInfo } from '../MessageParts';
|
||||
|
|
@ -40,7 +41,9 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||
workflowId
|
||||
}) => {
|
||||
const isUser = message.role?.toLowerCase() === 'user';
|
||||
const isError = message.actionProgress === 'fail' || message.actionProgress === 'error';
|
||||
const messageClass = isUser ? styles.messageUser : styles.messageAssistant;
|
||||
const errorClass = isError ? styles.messageError : '';
|
||||
|
||||
// Debug: Log documents if in dev mode
|
||||
if (import.meta.env.DEV && message.documents) {
|
||||
|
|
@ -48,8 +51,16 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.message} ${messageClass}`}>
|
||||
<div className={`${styles.message} ${messageClass} ${errorClass}`}>
|
||||
<div className={styles.messageBubble}>
|
||||
{/* Error indicator for failed actions */}
|
||||
{isError && (
|
||||
<div className={styles.errorIndicator}>
|
||||
<FaExclamationTriangle className={styles.errorIcon} />
|
||||
<span>Aktion fehlgeschlagen</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message content */}
|
||||
{message.message && (
|
||||
<div className={styles.messageContent}>
|
||||
|
|
|
|||
|
|
@ -64,6 +64,29 @@
|
|||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
/* Error/Failed Messages */
|
||||
.messageError .messageBubble {
|
||||
background-color: var(--danger-bg, #fee2e2);
|
||||
border: 1px solid var(--danger-color, #dc2626);
|
||||
}
|
||||
|
||||
.errorIndicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--danger-color, #dc2626);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 4px 8px;
|
||||
background-color: rgba(220, 38, 38, 0.1);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.errorIcon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Message Metadata */
|
||||
.messageMetadata {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -18,7 +18,54 @@ interface UnifiedChatDataItem {
|
|||
createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* =============================================================================
|
||||
* WORKFLOW LIFECYCLE STATE MACHINE
|
||||
* =============================================================================
|
||||
*
|
||||
* WORKFLOW STATUS (from Backend):
|
||||
* • idle - No workflow
|
||||
* • running - Workflow is processing
|
||||
* • completed - Round finished (Backend processed "last" message)
|
||||
* • stopped - User stopped the workflow
|
||||
* • failed - Error occurred
|
||||
*
|
||||
* UI FLAG:
|
||||
* • hasRenderedLastMessage: boolean
|
||||
* - true: "last" message was rendered in UI
|
||||
* - false: "last" message not yet in UI
|
||||
*
|
||||
* POLLING LOGIC:
|
||||
* POLL ACTIVE when:
|
||||
* status === 'running'
|
||||
* OR (status === 'completed' AND !hasRenderedLastMessage)
|
||||
*
|
||||
* POLL STOPS when:
|
||||
* status === 'stopped'
|
||||
* OR status === 'failed'
|
||||
* OR hasRenderedLastMessage === true
|
||||
*
|
||||
* TRANSITIONS:
|
||||
* [Send Button] (from any status):
|
||||
* → hasRenderedLastMessage = false (new round starts)
|
||||
* → afterTimestamp = now
|
||||
* → Start polling
|
||||
*
|
||||
* [Load Workflow]:
|
||||
* → Load all data
|
||||
* → Check if last message has status="last"
|
||||
* → If yes: hasRenderedLastMessage = true, no polling
|
||||
* → If no AND status=running: Start polling
|
||||
*
|
||||
* [Message with status="last" rendered]:
|
||||
* → hasRenderedLastMessage = true
|
||||
* → Stop polling
|
||||
*
|
||||
* =============================================================================
|
||||
*/
|
||||
|
||||
export function useWorkflowLifecycle(instanceId: string) {
|
||||
// === STATE ===
|
||||
const [workflowId, setWorkflowId] = useState<string | null>(null);
|
||||
const [workflowStatus, setWorkflowStatus] = useState<string>('idle');
|
||||
const [currentRound, setCurrentRound] = useState<number | undefined>(undefined);
|
||||
|
|
@ -26,48 +73,35 @@ export function useWorkflowLifecycle(instanceId: string) {
|
|||
const [logs, setLogs] = useState<WorkflowLog[]>([]);
|
||||
const [dashboardLogs, setDashboardLogs] = useState<WorkflowLog[]>([]);
|
||||
const [unifiedContentLogs, setUnifiedContentLogs] = useState<WorkflowLog[]>([]);
|
||||
const [statusChangedFromRunningAt, setStatusChangedFromRunningAt] = useState<number | null>(null);
|
||||
const [latestStats, setLatestStats] = useState<{ priceUsd?: number; processingTime?: number; bytesSent?: number; bytesReceived?: number } | null>(null);
|
||||
const prevStatusRef = useRef<string>('idle');
|
||||
|
||||
// === REFS FOR SYNC ACCESS ===
|
||||
const statusRef = useRef<string>('idle');
|
||||
const statusChangedFromRunningAtRef = useRef<number | null>(null);
|
||||
const lastRenderedTimestampRef = useRef<number | null>(null);
|
||||
// Track processed stat IDs to avoid double-counting
|
||||
const processedStatIdsRef = useRef<Set<string>>(new Set());
|
||||
// Track cumulative stats
|
||||
const cumulativeStatsRef = useRef({ priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 });
|
||||
|
||||
// === KEY STATE MACHINE FLAG ===
|
||||
// This flag tracks if the UI has rendered a message with status="last"
|
||||
// Polling continues until this is true (even if backend status is "completed")
|
||||
const hasRenderedLastMessageRef = useRef<boolean>(false);
|
||||
const [hasRenderedLastMessage, setHasRenderedLastMessage] = useState<boolean>(false);
|
||||
|
||||
// === HOOKS ===
|
||||
const { startWorkflow, stopWorkflow, startingWorkflow, stoppingWorkflows } = useWorkflowOperations();
|
||||
const { request } = useApiRequest();
|
||||
const pollingController = useWorkflowPolling();
|
||||
|
||||
// Store polling controller methods in refs to avoid dependency issues
|
||||
const pollingControllerRef = useRef(pollingController);
|
||||
pollingControllerRef.current = pollingController;
|
||||
|
||||
// Helper to update status and track transitions
|
||||
|
||||
// === HELPER: Update workflow status ===
|
||||
const updateWorkflowStatus = useCallback((newStatus: string) => {
|
||||
const prevStatus = prevStatusRef.current;
|
||||
prevStatusRef.current = newStatus;
|
||||
statusRef.current = newStatus;
|
||||
setWorkflowStatus(newStatus);
|
||||
|
||||
// Track when status changes from 'running' to something else
|
||||
if (prevStatus === 'running' && newStatus !== 'running') {
|
||||
const timestamp = Date.now();
|
||||
setStatusChangedFromRunningAt(timestamp);
|
||||
statusChangedFromRunningAtRef.current = timestamp;
|
||||
} else if (newStatus === 'running') {
|
||||
setStatusChangedFromRunningAt(null);
|
||||
statusChangedFromRunningAtRef.current = null;
|
||||
}
|
||||
console.log('📍 Status updated to:', newStatus);
|
||||
}, []);
|
||||
|
||||
// Expose setWorkflowStatus for optimistic updates
|
||||
const setWorkflowStatusOptimistic = useCallback((status: string) => {
|
||||
updateWorkflowStatus(status);
|
||||
}, [updateWorkflowStatus]);
|
||||
|
||||
// Convert backend log format to frontend format
|
||||
// === HELPER: Convert backend log format to frontend format ===
|
||||
const convertLogToFrontendFormat = useCallback((log: any): WorkflowLog => {
|
||||
return {
|
||||
id: log.id,
|
||||
|
|
@ -83,15 +117,15 @@ export function useWorkflowLifecycle(instanceId: string) {
|
|||
};
|
||||
}, [workflowId]);
|
||||
|
||||
// Process unified chat data chronologically
|
||||
// === CORE: Process unified chat data ===
|
||||
const processUnifiedChatData = useCallback((chatData: { messages: WorkflowMessage[]; logs: WorkflowLog[]; stats: any[] }) => {
|
||||
console.log('🔄 Processing unified chat data:', {
|
||||
console.log('🔄 Processing chat data:', {
|
||||
messages: chatData.messages?.length || 0,
|
||||
logs: chatData.logs?.length || 0,
|
||||
stats: chatData.stats?.length || 0
|
||||
});
|
||||
|
||||
// Build unified timeline of all items
|
||||
// Build unified timeline
|
||||
const timeline: UnifiedChatDataItem[] = [];
|
||||
|
||||
// Add messages
|
||||
|
|
@ -112,154 +146,132 @@ export function useWorkflowLifecycle(instanceId: string) {
|
|||
});
|
||||
});
|
||||
|
||||
// Add stats (if needed)
|
||||
(chatData.stats || []).forEach((stat: any) => {
|
||||
// Add stats
|
||||
const rawStats = chatData.stats || [];
|
||||
rawStats.forEach((stat: any) => {
|
||||
timeline.push({
|
||||
type: 'stat',
|
||||
item: stat,
|
||||
createdAt: stat.timestamp || stat.createdAt || Date.now()
|
||||
createdAt: stat._createdAt || stat.createdAt || Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
console.log('📋 Timeline created with', timeline.length, 'items');
|
||||
|
||||
// Sort chronologically
|
||||
timeline.sort((a, b) => a.createdAt - b.createdAt);
|
||||
|
||||
// Process items sequentially to maintain chronological order
|
||||
// Update lastRenderedTimestamp after processing all items (use latest timestamp)
|
||||
// Update lastRenderedTimestamp
|
||||
if (timeline.length > 0) {
|
||||
const latestTimestamp = timeline[timeline.length - 1].createdAt;
|
||||
lastRenderedTimestampRef.current = latestTimestamp;
|
||||
lastRenderedTimestampRef.current = timeline[timeline.length - 1].createdAt;
|
||||
}
|
||||
|
||||
// Use functional updates to avoid dependency on current state
|
||||
// === CHECK FOR "LAST" MESSAGE ===
|
||||
// This is the key state machine logic: detect when a "last" message arrives
|
||||
let foundLastMessage = false;
|
||||
|
||||
timeline.forEach((item) => {
|
||||
if (item.type === 'message') {
|
||||
const message = item.item as WorkflowMessage;
|
||||
if ((message as any).status === 'last') {
|
||||
foundLastMessage = true;
|
||||
console.log('🏁 Found "last" message:', message.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// === STATE MACHINE: Handle "last" message ===
|
||||
if (foundLastMessage && !hasRenderedLastMessageRef.current) {
|
||||
console.log('🛑 "last" message detected - stopping polling');
|
||||
hasRenderedLastMessageRef.current = true;
|
||||
setHasRenderedLastMessage(true);
|
||||
pollingControllerRef.current.stopPolling();
|
||||
}
|
||||
|
||||
// === UPDATE MESSAGES STATE ===
|
||||
setMessages(prevMessages => {
|
||||
const newMessages: WorkflowMessage[] = [...prevMessages];
|
||||
let hasChanges = false;
|
||||
let messagesAdded = 0;
|
||||
let messagesUpdated = 0;
|
||||
|
||||
timeline.forEach((item) => {
|
||||
if (item.type === 'message') {
|
||||
const message = item.item as WorkflowMessage;
|
||||
if (!message || !message.id) return;
|
||||
|
||||
if (!message || !message.id) {
|
||||
console.warn('⚠️ Invalid message in timeline:', message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if message already exists
|
||||
const existingIndex = newMessages.findIndex(m => m.id === message.id);
|
||||
if (existingIndex >= 0) {
|
||||
// Always update existing message (don't compare, just update)
|
||||
newMessages[existingIndex] = message;
|
||||
hasChanges = true;
|
||||
messagesUpdated++;
|
||||
} else {
|
||||
newMessages.push(message);
|
||||
hasChanges = true;
|
||||
messagesAdded++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`📨 Messages: ${messagesAdded} added, ${messagesUpdated} updated, total: ${newMessages.length}`);
|
||||
if (messagesAdded > 0 || messagesUpdated > 0) {
|
||||
console.log('📨 Sample messages:', newMessages.slice(0, 3).map(m => ({ id: m.id, message: m.message?.substring(0, 50) })));
|
||||
}
|
||||
|
||||
// Always return sorted array if we processed any messages
|
||||
if (hasChanges || timeline.some(item => item.type === 'message')) {
|
||||
const sorted = [...newMessages].sort(sortMessages);
|
||||
console.log(`✅ Returning ${sorted.length} sorted messages`);
|
||||
return sorted;
|
||||
return [...newMessages].sort(sortMessages);
|
||||
}
|
||||
|
||||
console.log('⚠️ No changes detected, returning previous messages');
|
||||
return prevMessages;
|
||||
});
|
||||
|
||||
setDashboardLogs(prevDashboardLogs => {
|
||||
const newDashboardLogs: WorkflowLog[] = [...prevDashboardLogs];
|
||||
// === UPDATE DASHBOARD LOGS (with operationId) ===
|
||||
setDashboardLogs(prevLogs => {
|
||||
const newLogs: WorkflowLog[] = [...prevLogs];
|
||||
let hasChanges = false;
|
||||
|
||||
timeline.forEach((item) => {
|
||||
if (item.type === 'log') {
|
||||
const backendLog = item.item as any;
|
||||
const frontendLog = convertLogToFrontendFormat(backendLog);
|
||||
|
||||
// Route logs based on operationId
|
||||
const frontendLog = convertLogToFrontendFormat(item.item);
|
||||
if (frontendLog.operationId) {
|
||||
// Logs WITH operationId → Dashboard
|
||||
const existingIndex = newDashboardLogs.findIndex(l => l.id === frontendLog.id);
|
||||
const existingIndex = newLogs.findIndex(l => l.id === frontendLog.id);
|
||||
if (existingIndex >= 0) {
|
||||
// Check if log actually changed
|
||||
const existingLog = newDashboardLogs[existingIndex];
|
||||
if (JSON.stringify(existingLog) !== JSON.stringify(frontendLog)) {
|
||||
newDashboardLogs[existingIndex] = frontendLog;
|
||||
if (JSON.stringify(newLogs[existingIndex]) !== JSON.stringify(frontendLog)) {
|
||||
newLogs[existingIndex] = frontendLog;
|
||||
hasChanges = true;
|
||||
}
|
||||
} else {
|
||||
newDashboardLogs.push(frontendLog);
|
||||
newLogs.push(frontendLog);
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Only return new array if there are changes
|
||||
if (!hasChanges) {
|
||||
return prevDashboardLogs;
|
||||
}
|
||||
|
||||
return [...newDashboardLogs].sort(sortLogs);
|
||||
return hasChanges ? [...newLogs].sort(sortLogs) : prevLogs;
|
||||
});
|
||||
|
||||
setUnifiedContentLogs(prevUnifiedContentLogs => {
|
||||
const newUnifiedContentLogs: WorkflowLog[] = [...prevUnifiedContentLogs];
|
||||
// === UPDATE UNIFIED CONTENT LOGS (without operationId) ===
|
||||
setUnifiedContentLogs(prevLogs => {
|
||||
const newLogs: WorkflowLog[] = [...prevLogs];
|
||||
let hasChanges = false;
|
||||
|
||||
timeline.forEach((item) => {
|
||||
if (item.type === 'log') {
|
||||
const backendLog = item.item as any;
|
||||
const frontendLog = convertLogToFrontendFormat(backendLog);
|
||||
|
||||
// Route logs based on operationId
|
||||
const frontendLog = convertLogToFrontendFormat(item.item);
|
||||
if (!frontendLog.operationId) {
|
||||
// Logs WITHOUT operationId → Unified Content Area
|
||||
const existingIndex = newUnifiedContentLogs.findIndex(l => l.id === frontendLog.id);
|
||||
const existingIndex = newLogs.findIndex(l => l.id === frontendLog.id);
|
||||
if (existingIndex >= 0) {
|
||||
// Check if log actually changed
|
||||
const existingLog = newUnifiedContentLogs[existingIndex];
|
||||
if (JSON.stringify(existingLog) !== JSON.stringify(frontendLog)) {
|
||||
newUnifiedContentLogs[existingIndex] = frontendLog;
|
||||
if (JSON.stringify(newLogs[existingIndex]) !== JSON.stringify(frontendLog)) {
|
||||
newLogs[existingIndex] = frontendLog;
|
||||
hasChanges = true;
|
||||
}
|
||||
} else {
|
||||
newUnifiedContentLogs.push(frontendLog);
|
||||
newLogs.push(frontendLog);
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Only return new array if there are changes
|
||||
if (!hasChanges) {
|
||||
return prevUnifiedContentLogs;
|
||||
}
|
||||
|
||||
return [...newUnifiedContentLogs].sort(sortLogs);
|
||||
return hasChanges ? [...newLogs].sort(sortLogs) : prevLogs;
|
||||
});
|
||||
|
||||
// Update combined logs for backward compatibility (using functional update)
|
||||
// === UPDATE COMBINED LOGS ===
|
||||
setLogs(prevLogs => {
|
||||
const allLogs: WorkflowLog[] = [...prevLogs];
|
||||
|
||||
timeline.forEach((item) => {
|
||||
if (item.type === 'log') {
|
||||
const backendLog = item.item as any;
|
||||
const frontendLog = convertLogToFrontendFormat(backendLog);
|
||||
const frontendLog = convertLogToFrontendFormat(item.item);
|
||||
const existingIndex = allLogs.findIndex(l => l.id === frontendLog.id);
|
||||
if (existingIndex >= 0) {
|
||||
allLogs[existingIndex] = frontendLog;
|
||||
|
|
@ -272,47 +284,36 @@ export function useWorkflowLifecycle(instanceId: string) {
|
|||
return [...allLogs].sort(sortLogs);
|
||||
});
|
||||
|
||||
// Process stats - aggregate only NEW stat entries (avoid double-counting)
|
||||
// === PROCESS STATS ===
|
||||
const statsItems = timeline.filter(item => item.type === 'stat');
|
||||
|
||||
if (statsItems.length > 0) {
|
||||
let hasNewStats = false;
|
||||
|
||||
statsItems.forEach(statItem => {
|
||||
const statData = statItem.item || statItem;
|
||||
const statId = statData?.id || (statItem as any).id;
|
||||
const statData = statItem.item;
|
||||
const statId = statData?.id;
|
||||
|
||||
// Skip if already processed
|
||||
if (statId && processedStatIdsRef.current.has(statId)) {
|
||||
return;
|
||||
return; // Skip already processed
|
||||
}
|
||||
|
||||
if (statData) {
|
||||
hasNewStats = true;
|
||||
|
||||
// Mark as processed
|
||||
if (statId) {
|
||||
processedStatIdsRef.current.add(statId);
|
||||
}
|
||||
|
||||
// Add to cumulative stats
|
||||
if (statData.priceUsd !== undefined && statData.priceUsd !== null) {
|
||||
cumulativeStatsRef.current.priceUsd += statData.priceUsd;
|
||||
}
|
||||
if (statData.processingTime !== undefined && statData.processingTime !== null) {
|
||||
cumulativeStatsRef.current.processingTime += statData.processingTime;
|
||||
}
|
||||
if (statData.bytesSent !== undefined && statData.bytesSent !== null) {
|
||||
cumulativeStatsRef.current.bytesSent += statData.bytesSent;
|
||||
}
|
||||
if (statData.bytesReceived !== undefined && statData.bytesReceived !== null) {
|
||||
cumulativeStatsRef.current.bytesReceived += statData.bytesReceived;
|
||||
}
|
||||
// Accumulate stats
|
||||
const price = statData.priceCHF ?? statData.priceUsd ?? 0;
|
||||
if (price > 0) cumulativeStatsRef.current.priceUsd += price;
|
||||
if (statData.processingTime) cumulativeStatsRef.current.processingTime += statData.processingTime;
|
||||
if (statData.bytesSent) cumulativeStatsRef.current.bytesSent += statData.bytesSent;
|
||||
if (statData.bytesReceived) cumulativeStatsRef.current.bytesReceived += statData.bytesReceived;
|
||||
}
|
||||
});
|
||||
|
||||
// Update state with cumulative totals
|
||||
if (hasNewStats || (cumulativeStatsRef.current.bytesSent > 0 || cumulativeStatsRef.current.bytesReceived > 0 ||
|
||||
cumulativeStatsRef.current.processingTime > 0 || cumulativeStatsRef.current.priceUsd > 0)) {
|
||||
if (hasNewStats) {
|
||||
setLatestStats({
|
||||
priceUsd: cumulativeStatsRef.current.priceUsd,
|
||||
processingTime: cumulativeStatsRef.current.processingTime,
|
||||
|
|
@ -323,10 +324,9 @@ export function useWorkflowLifecycle(instanceId: string) {
|
|||
}
|
||||
}, [convertLogToFrontendFormat]);
|
||||
|
||||
// Poll workflow data using unified chat data endpoint
|
||||
// === POLLING FUNCTION ===
|
||||
const pollWorkflowData = useCallback(async (id: string) => {
|
||||
try {
|
||||
// Determine afterTimestamp for incremental polling
|
||||
const afterTimestamp = lastRenderedTimestampRef.current || undefined;
|
||||
|
||||
// Fetch workflow status
|
||||
|
|
@ -334,192 +334,73 @@ export function useWorkflowLifecycle(instanceId: string) {
|
|||
|
||||
if (workflowData) {
|
||||
const status = workflowData.status || 'idle';
|
||||
const round = workflowData.currentRound !== undefined ? workflowData.currentRound : undefined;
|
||||
const round = workflowData.currentRound;
|
||||
|
||||
updateWorkflowStatus(status);
|
||||
setCurrentRound(round);
|
||||
if (round !== undefined) setCurrentRound(round);
|
||||
|
||||
// === STATE MACHINE: Check if polling should stop based on status ===
|
||||
if (status === 'stopped' || status === 'failed') {
|
||||
console.log(`🛑 Workflow ${status} - stopping polling immediately`);
|
||||
pollingControllerRef.current.stopPolling();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch unified chat data
|
||||
// Fetch chat data
|
||||
const chatData = await fetchChatData(request, instanceId, id, afterTimestamp);
|
||||
|
||||
console.log('📊 Processed chat data:', {
|
||||
messagesCount: chatData.messages?.length || 0,
|
||||
logsCount: chatData.logs?.length || 0,
|
||||
statsCount: chatData.stats?.length || 0,
|
||||
afterTimestamp: afterTimestamp
|
||||
console.log('📊 Polled chat data:', {
|
||||
messages: chatData.messages?.length || 0,
|
||||
logs: chatData.logs?.length || 0,
|
||||
stats: chatData.stats?.length || 0,
|
||||
afterTimestamp
|
||||
});
|
||||
|
||||
// If we got empty results and we're using afterTimestamp, the backend might be filtering incorrectly
|
||||
// Reset timestamp to null so next poll fetches all items (but only if we have existing data)
|
||||
const hasNoNewData = (chatData.messages?.length || 0) === 0 &&
|
||||
(chatData.logs?.length || 0) === 0 &&
|
||||
(chatData.stats?.length || 0) === 0;
|
||||
|
||||
// Only reset if we're using afterTimestamp and got empty results
|
||||
// This handles cases where backend filtering might miss items due to timestamp precision issues
|
||||
if (hasNoNewData && afterTimestamp !== undefined && lastRenderedTimestampRef.current !== null) {
|
||||
console.warn('⚠️ Got empty results with afterTimestamp, resetting timestamp for next poll');
|
||||
// Don't reset immediately - let this poll complete, next poll will fetch all
|
||||
lastRenderedTimestampRef.current = null;
|
||||
}
|
||||
|
||||
// Process unified chat data
|
||||
// Process data (this will detect "last" message and stop polling if found)
|
||||
processUnifiedChatData(chatData);
|
||||
|
||||
// Determine if polling should continue
|
||||
const currentStatus = statusRef.current;
|
||||
|
||||
// Stop polling immediately for failed or stopped workflows
|
||||
// For completed workflows, allow grace period (handled by useEffect)
|
||||
if (currentStatus === 'failed' || currentStatus === 'stopped') {
|
||||
pollingControllerRef.current.stopPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
// Continue polling for 'running' status
|
||||
// For 'completed' status, continue if within grace period (handled by useEffect)
|
||||
// Polling will be stopped by the useEffect when grace period expires or status changes to failed/stopped
|
||||
} catch (error: any) {
|
||||
// Handle rate limiting (429 errors)
|
||||
if (error?.status === 429 || error?.response?.status === 429) {
|
||||
throw error; // Let polling controller handle rate limit backoff
|
||||
}
|
||||
console.error('Error polling workflow data:', error);
|
||||
// Don't throw for other errors - allow polling to continue with backoff
|
||||
}
|
||||
}, [request, updateWorkflowStatus, processUnifiedChatData]);
|
||||
|
||||
// Load initial workflow data (non-polling)
|
||||
const _loadWorkflowData = useCallback(async (id: string) => {
|
||||
try {
|
||||
const workflowData = await fetchWorkflowApi(request, id).catch(() => null);
|
||||
|
||||
if (!workflowData) {
|
||||
setMessages([]);
|
||||
setLogs([]);
|
||||
setDashboardLogs([]);
|
||||
setUnifiedContentLogs([]);
|
||||
setLatestStats(null);
|
||||
// Reset stats tracking
|
||||
processedStatIdsRef.current.clear();
|
||||
cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 };
|
||||
return;
|
||||
}
|
||||
|
||||
const messagesData = Array.isArray(workflowData.messages) ? workflowData.messages : [];
|
||||
const logsData = Array.isArray(workflowData.logs) ? workflowData.logs : [];
|
||||
const status = workflowData.status || 'idle';
|
||||
const round = workflowData.currentRound !== undefined ? workflowData.currentRound : undefined;
|
||||
|
||||
updateWorkflowStatus(status);
|
||||
setCurrentRound(round);
|
||||
|
||||
// Always fetch unified chat data to get all messages and logs
|
||||
// Reset lastRenderedTimestamp to fetch all historical data
|
||||
lastRenderedTimestampRef.current = null;
|
||||
try {
|
||||
const chatData = await fetchChatData(request, instanceId, id, undefined);
|
||||
console.log('📥 loadWorkflowData: Fetched unified chat data:', {
|
||||
messagesCount: chatData.messages?.length || 0,
|
||||
logsCount: chatData.logs?.length || 0
|
||||
});
|
||||
processUnifiedChatData(chatData);
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Failed to fetch unified chat data, falling back to workflowData:', error);
|
||||
// Fallback to workflowData if unified chat data fails
|
||||
if (messagesData.length > 0) {
|
||||
setMessages([...messagesData].sort(sortMessages));
|
||||
}
|
||||
|
||||
// Process logs and separate by operationId
|
||||
const dashboardLogsList: WorkflowLog[] = [];
|
||||
const unifiedContentLogsList: WorkflowLog[] = [];
|
||||
|
||||
logsData.forEach((log: any) => {
|
||||
const frontendLog = convertLogToFrontendFormat(log);
|
||||
if (frontendLog.operationId) {
|
||||
dashboardLogsList.push(frontendLog);
|
||||
} else {
|
||||
unifiedContentLogsList.push(frontendLog);
|
||||
}
|
||||
});
|
||||
|
||||
setDashboardLogs(dashboardLogsList.sort(sortLogs));
|
||||
setUnifiedContentLogs(unifiedContentLogsList.sort(sortLogs));
|
||||
setLogs([...dashboardLogsList, ...unifiedContentLogsList].sort(sortLogs));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading workflow data:', error);
|
||||
console.error('❌ Polling error:', error);
|
||||
}
|
||||
}, [request, updateWorkflowStatus, convertLogToFrontendFormat, processUnifiedChatData]);
|
||||
void _loadWorkflowData; // Intentionally unused, reserved for future use
|
||||
}, [request, instanceId, updateWorkflowStatus, processUnifiedChatData]);
|
||||
|
||||
// Set up polling when workflow is running
|
||||
// === POLLING CONTROL EFFECT ===
|
||||
useEffect(() => {
|
||||
if (!workflowId) {
|
||||
// Only clear state if not already cleared to avoid unnecessary updates
|
||||
setMessages(prev => prev.length > 0 ? [] : prev);
|
||||
setLogs(prev => prev.length > 0 ? [] : prev);
|
||||
setDashboardLogs(prev => prev.length > 0 ? [] : prev);
|
||||
setUnifiedContentLogs(prev => prev.length > 0 ? [] : prev);
|
||||
setLatestStats(null);
|
||||
// Reset stats tracking
|
||||
processedStatIdsRef.current.clear();
|
||||
cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 };
|
||||
setCurrentRound(prev => prev !== undefined ? undefined : prev);
|
||||
if (statusChangedFromRunningAt !== null) {
|
||||
setStatusChangedFromRunningAt(null);
|
||||
statusChangedFromRunningAtRef.current = null;
|
||||
}
|
||||
lastRenderedTimestampRef.current = null;
|
||||
pollingControllerRef.current.stopPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
// Continue polling if:
|
||||
// 1. Workflow is currently running, OR
|
||||
// 2. Workflow just completed (within last 5 seconds) - grace period to catch final messages
|
||||
// Stop polling for failed or stopped workflows immediately
|
||||
const changedAtRef = statusChangedFromRunningAtRef.current;
|
||||
const gracePeriodMs = 5000; // 5 seconds grace period
|
||||
const timeSinceCompletion = changedAtRef !== null ? Date.now() - changedAtRef : Infinity;
|
||||
const isInGracePeriod = workflowStatus === 'completed' && changedAtRef !== null && timeSinceCompletion < gracePeriodMs;
|
||||
const shouldPoll = workflowStatus === 'running' || isInGracePeriod;
|
||||
// === STATE MACHINE: Determine if polling should be active ===
|
||||
const shouldPoll =
|
||||
workflowStatus === 'running' ||
|
||||
(workflowStatus === 'completed' && !hasRenderedLastMessage);
|
||||
|
||||
const shouldStopImmediately =
|
||||
workflowStatus === 'stopped' ||
|
||||
workflowStatus === 'failed' ||
|
||||
hasRenderedLastMessage;
|
||||
|
||||
console.log('📍 Polling decision:', {
|
||||
workflowStatus,
|
||||
hasRenderedLastMessage,
|
||||
shouldPoll,
|
||||
shouldStopImmediately
|
||||
});
|
||||
|
||||
if (shouldPoll) {
|
||||
// Start polling
|
||||
pollingControllerRef.current.startPolling(workflowId, pollWorkflowData);
|
||||
|
||||
// If in grace period, set a timer to stop polling after grace period expires
|
||||
if (isInGracePeriod) {
|
||||
const remainingGraceTime = gracePeriodMs - timeSinceCompletion;
|
||||
const graceTimer = setTimeout(() => {
|
||||
pollingControllerRef.current.stopPolling();
|
||||
setStatusChangedFromRunningAt(null);
|
||||
statusChangedFromRunningAtRef.current = null;
|
||||
}, remainingGraceTime + 100); // Small buffer
|
||||
|
||||
return () => {
|
||||
clearTimeout(graceTimer);
|
||||
pollingControllerRef.current.stopPolling();
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Stop polling for failed, stopped, or completed (after grace period) workflows
|
||||
} else if (shouldStopImmediately) {
|
||||
pollingControllerRef.current.stopPolling();
|
||||
// Clear the status change timestamp when we stop polling
|
||||
if (statusChangedFromRunningAt !== null) {
|
||||
setStatusChangedFromRunningAt(null);
|
||||
statusChangedFromRunningAtRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
pollingControllerRef.current.stopPolling();
|
||||
};
|
||||
}, [workflowStatus, workflowId, pollWorkflowData, statusChangedFromRunningAt]);
|
||||
|
||||
}, [workflowStatus, workflowId, hasRenderedLastMessage, pollWorkflowData]);
|
||||
|
||||
// === START WORKFLOW (Send Button) ===
|
||||
const handleStartWorkflow = useCallback(async (
|
||||
workflowData: StartWorkflowRequest,
|
||||
options?: { workflowId?: string; workflowMode?: 'Dynamic' | 'Automation' }
|
||||
|
|
@ -529,10 +410,24 @@ export function useWorkflowLifecycle(instanceId: string) {
|
|||
|
||||
if (result.success && result.data) {
|
||||
const workflow = result.data as Workflow;
|
||||
|
||||
// === STATE MACHINE: New round starts ===
|
||||
console.log('🚀 Starting workflow:', workflow.id);
|
||||
|
||||
// Reset state for new round
|
||||
setWorkflowId(workflow.id);
|
||||
hasRenderedLastMessageRef.current = false;
|
||||
setHasRenderedLastMessage(false);
|
||||
|
||||
// Set afterTimestamp to NOW - only poll for new data
|
||||
lastRenderedTimestampRef.current = Date.now();
|
||||
|
||||
// Start polling immediately
|
||||
pollingControllerRef.current.startPolling(workflow.id, pollWorkflowData);
|
||||
|
||||
// Update status
|
||||
updateWorkflowStatus(workflow.status || 'running');
|
||||
// Reset lastRenderedTimestamp for new workflow
|
||||
lastRenderedTimestampRef.current = null;
|
||||
|
||||
return { success: true, data: result.data };
|
||||
} else {
|
||||
return { success: false, error: result.error || 'Failed to start workflow' };
|
||||
|
|
@ -540,8 +435,9 @@ export function useWorkflowLifecycle(instanceId: string) {
|
|||
} catch (error: any) {
|
||||
return { success: false, error: error.message || 'Failed to start workflow' };
|
||||
}
|
||||
}, [instanceId, startWorkflow, updateWorkflowStatus]);
|
||||
|
||||
}, [instanceId, startWorkflow, updateWorkflowStatus, pollWorkflowData]);
|
||||
|
||||
// === STOP WORKFLOW ===
|
||||
const handleStopWorkflow = useCallback(async () => {
|
||||
if (!workflowId) {
|
||||
return { success: false, error: 'No workflow to stop' };
|
||||
|
|
@ -552,6 +448,7 @@ export function useWorkflowLifecycle(instanceId: string) {
|
|||
|
||||
if (result.success) {
|
||||
updateWorkflowStatus('stopped');
|
||||
pollingControllerRef.current.stopPolling();
|
||||
return { success: true };
|
||||
} else {
|
||||
return { success: false, error: result.error || 'Failed to stop workflow' };
|
||||
|
|
@ -560,31 +457,44 @@ export function useWorkflowLifecycle(instanceId: string) {
|
|||
return { success: false, error: error.message || 'Failed to stop workflow' };
|
||||
}
|
||||
}, [instanceId, workflowId, stopWorkflow, updateWorkflowStatus]);
|
||||
|
||||
|
||||
// === RESET WORKFLOW ===
|
||||
const resetWorkflow = useCallback(() => {
|
||||
console.log('🔄 Resetting workflow state');
|
||||
|
||||
setWorkflowId(null);
|
||||
prevStatusRef.current = 'idle';
|
||||
statusRef.current = 'idle';
|
||||
updateWorkflowStatus('idle');
|
||||
setCurrentRound(undefined);
|
||||
setMessages([]);
|
||||
setLogs([]);
|
||||
setDashboardLogs([]);
|
||||
setUnifiedContentLogs([]);
|
||||
setLatestStats(null);
|
||||
// Reset stats tracking
|
||||
|
||||
// Reset refs
|
||||
lastRenderedTimestampRef.current = null;
|
||||
processedStatIdsRef.current.clear();
|
||||
cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 };
|
||||
setStatusChangedFromRunningAt(null);
|
||||
statusChangedFromRunningAtRef.current = null;
|
||||
lastRenderedTimestampRef.current = null;
|
||||
hasRenderedLastMessageRef.current = false;
|
||||
setHasRenderedLastMessage(false);
|
||||
|
||||
pollingControllerRef.current.stopPolling();
|
||||
}, [updateWorkflowStatus]);
|
||||
|
||||
|
||||
// === SELECT/LOAD WORKFLOW ===
|
||||
const selectWorkflow = useCallback(async (workflowIdToSelect: string) => {
|
||||
try {
|
||||
console.log('📥 Loading workflow:', workflowIdToSelect);
|
||||
|
||||
// Reset state
|
||||
setWorkflowId(workflowIdToSelect);
|
||||
// Reset lastRenderedTimestamp and stats for new workflow selection
|
||||
lastRenderedTimestampRef.current = null;
|
||||
processedStatIdsRef.current.clear();
|
||||
cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 };
|
||||
hasRenderedLastMessageRef.current = false;
|
||||
setHasRenderedLastMessage(false);
|
||||
|
||||
// Fetch workflow data
|
||||
const workflowData = await fetchWorkflowApi(request, workflowIdToSelect).catch(() => null);
|
||||
|
||||
if (!workflowData) {
|
||||
|
|
@ -597,58 +507,66 @@ export function useWorkflowLifecycle(instanceId: string) {
|
|||
return;
|
||||
}
|
||||
|
||||
const messagesData = Array.isArray(workflowData.messages) ? workflowData.messages : [];
|
||||
const logsData = Array.isArray(workflowData.logs) ? workflowData.logs : [];
|
||||
const status = workflowData.status || 'idle';
|
||||
const round = workflowData.currentRound !== undefined ? workflowData.currentRound : undefined;
|
||||
const round = workflowData.currentRound;
|
||||
|
||||
updateWorkflowStatus(status);
|
||||
setCurrentRound(round);
|
||||
if (round !== undefined) setCurrentRound(round);
|
||||
|
||||
// Always fetch unified chat data to get all messages and logs (regardless of status)
|
||||
// This ensures completed workflows also show their logs
|
||||
// Fetch all chat data (no afterTimestamp = get everything)
|
||||
try {
|
||||
const chatData = await fetchChatData(request, instanceId, workflowIdToSelect, undefined);
|
||||
console.log('📥 selectWorkflow: Fetched unified chat data:', {
|
||||
messagesCount: chatData.messages?.length || 0,
|
||||
logsCount: chatData.logs?.length || 0,
|
||||
status
|
||||
console.log('📥 Loaded chat data:', {
|
||||
messages: chatData.messages?.length || 0,
|
||||
logs: chatData.logs?.length || 0,
|
||||
stats: chatData.stats?.length || 0
|
||||
});
|
||||
processUnifiedChatData(chatData);
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Failed to fetch unified chat data, falling back to workflowData:', error);
|
||||
// Fallback to workflowData if unified chat data fails
|
||||
if (messagesData.length > 0) {
|
||||
setMessages([...messagesData].sort(sortMessages));
|
||||
|
||||
// === STATE MACHINE: Check if last message has status="last" ===
|
||||
const allMessages = chatData.messages || [];
|
||||
const sortedMessages = [...allMessages].sort((a, b) => {
|
||||
const aTime = a.publishedAt || a.timestamp || 0;
|
||||
const bTime = b.publishedAt || b.timestamp || 0;
|
||||
return bTime - aTime; // Sort descending (newest first)
|
||||
});
|
||||
|
||||
const lastMessage = sortedMessages[0];
|
||||
const lastMessageStatus = lastMessage ? (lastMessage as any).status : null;
|
||||
|
||||
console.log('📍 Last message status:', lastMessageStatus);
|
||||
|
||||
if (lastMessageStatus === 'last') {
|
||||
// Round is complete - don't start polling
|
||||
hasRenderedLastMessageRef.current = true;
|
||||
setHasRenderedLastMessage(true);
|
||||
console.log('✅ Workflow round complete - no polling needed');
|
||||
} else if (status === 'running') {
|
||||
// Workflow is running - polling will start via useEffect
|
||||
console.log('🔄 Workflow is running - polling will start');
|
||||
}
|
||||
|
||||
// Process logs and separate by operationId
|
||||
const dashboardLogsList: WorkflowLog[] = [];
|
||||
const unifiedContentLogsList: WorkflowLog[] = [];
|
||||
// Process the data
|
||||
processUnifiedChatData(chatData);
|
||||
|
||||
logsData.forEach((log: any) => {
|
||||
const frontendLog = convertLogToFrontendFormat(log);
|
||||
if (frontendLog.operationId) {
|
||||
dashboardLogsList.push(frontendLog);
|
||||
} else {
|
||||
unifiedContentLogsList.push(frontendLog);
|
||||
}
|
||||
});
|
||||
|
||||
setDashboardLogs(dashboardLogsList.sort(sortLogs));
|
||||
setUnifiedContentLogs(unifiedContentLogsList.sort(sortLogs));
|
||||
setLogs([...dashboardLogsList, ...unifiedContentLogsList].sort(sortLogs));
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Failed to fetch chat data:', error);
|
||||
updateWorkflowStatus('idle');
|
||||
}
|
||||
|
||||
// If workflow is running, polling will start automatically via useEffect
|
||||
} catch (error) {
|
||||
console.error('Error selecting workflow:', error);
|
||||
console.error('❌ Error selecting workflow:', error);
|
||||
}
|
||||
}, [request, updateWorkflowStatus, convertLogToFrontendFormat, processUnifiedChatData]);
|
||||
|
||||
}, [request, instanceId, updateWorkflowStatus, processUnifiedChatData]);
|
||||
|
||||
// === EXPOSE STATUS SETTER FOR OPTIMISTIC UPDATES ===
|
||||
const setWorkflowStatusOptimistic = useCallback((status: string) => {
|
||||
updateWorkflowStatus(status);
|
||||
}, [updateWorkflowStatus]);
|
||||
|
||||
// === COMPUTED VALUES ===
|
||||
const isRunning = workflowStatus === 'running';
|
||||
const isStopping = workflowId ? stoppingWorkflows.has(workflowId) : false;
|
||||
|
||||
|
||||
return {
|
||||
workflowId,
|
||||
workflowStatus,
|
||||
|
|
@ -661,6 +579,7 @@ export function useWorkflowLifecycle(instanceId: string) {
|
|||
dashboardLogs,
|
||||
unifiedContentLogs,
|
||||
latestStats,
|
||||
hasRenderedLastMessage,
|
||||
startWorkflow: handleStartWorkflow,
|
||||
stopWorkflow: handleStopWorkflow,
|
||||
resetWorkflow,
|
||||
|
|
|
|||
|
|
@ -138,8 +138,12 @@
|
|||
/* Feature Content */
|
||||
.featureContent {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
/* Let child components handle their own scrolling for sticky headers */
|
||||
overflow: hidden;
|
||||
padding: 1.5rem;
|
||||
/* Maintain flex chain for child components */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Dark Theme */
|
||||
|
|
|
|||
|
|
@ -81,7 +81,8 @@
|
|||
/* Content */
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
/* Let child components handle their own scrolling for sticky headers */
|
||||
overflow: hidden;
|
||||
background: var(--bg-primary, #ffffff);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@
|
|||
|
||||
.adminPage {
|
||||
padding: 1.5rem;
|
||||
min-height: 100%;
|
||||
/* Fill parent height and enable flex layout for sticky table headers */
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
|
|
|
|||
|
|
@ -6,9 +6,8 @@
|
|||
|
||||
.billingDashboard {
|
||||
padding: 1.5rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
|
|
@ -490,7 +489,7 @@
|
|||
|
||||
@media (max-width: 768px) {
|
||||
.billingDashboard {
|
||||
padding: 1rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.balanceGrid {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useBilling, type BillingBalance, type UsageReport } from '../../hooks/useBilling';
|
||||
import { BillingNav } from './BillingNav';
|
||||
import styles from './Billing.module.css';
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -187,6 +188,8 @@ export const BillingDashboard: React.FC = () => {
|
|||
<p className={styles.subtitle}>Übersicht über Guthaben und Nutzung</p>
|
||||
</header>
|
||||
|
||||
<BillingNav />
|
||||
|
||||
{/* Balance Cards */}
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Guthaben</h2>
|
||||
|
|
|
|||
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 { useBilling, type BillingTransaction } from '../../hooks/useBilling';
|
||||
import { BillingNav } from './BillingNav';
|
||||
import styles from './Billing.module.css';
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -56,6 +57,7 @@ const TransactionRow: React.FC<TransactionRowProps> = ({ transaction }) => {
|
|||
return (
|
||||
<tr>
|
||||
<td>{formatDate(transaction.createdAt)}</td>
|
||||
<td>{transaction.mandateName || '-'}</td>
|
||||
<td>
|
||||
<span className={`${styles.transactionType} ${getTypeClass(transaction.transactionType)}`}>
|
||||
{getTypeLabel(transaction.transactionType)}
|
||||
|
|
@ -94,6 +96,8 @@ export const BillingTransactions: React.FC = () => {
|
|||
<p className={styles.subtitle}>Übersicht aller Kontobewegungen</p>
|
||||
</header>
|
||||
|
||||
<BillingNav />
|
||||
|
||||
<section className={styles.section}>
|
||||
{loading && transactions.length === 0 ? (
|
||||
<div className={styles.loadingPlaceholder}>Lade Transaktionen...</div>
|
||||
|
|
@ -106,6 +110,7 @@ export const BillingTransactions: React.FC = () => {
|
|||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Mandant</th>
|
||||
<th>Typ</th>
|
||||
<th>Beschreibung</th>
|
||||
<th>Anbieter</th>
|
||||
|
|
|
|||
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 { BillingTransactions } from './BillingTransactions';
|
||||
export { BillingDataView } from './BillingDataView';
|
||||
export { BillingAdmin } from './BillingAdmin';
|
||||
export { BillingNav } from './BillingNav';
|
||||
|
||||
// Legacy exports (can be removed after migration)
|
||||
export { BillingTransactions } from './BillingTransactions';
|
||||
export { BillingMandateView } from './BillingMandateView';
|
||||
export { BillingUserView } from './BillingUserView';
|
||||
|
|
|
|||
|
|
@ -20,11 +20,24 @@
|
|||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.headerTitleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
|
|
@ -32,6 +45,24 @@
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.headerStats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
background-color: var(--bg-secondary);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.headerStatItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pageSubtitle {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
|
|
@ -372,30 +403,6 @@
|
|||
border-color: var(--primary-color, #f25843);
|
||||
}
|
||||
|
||||
/* Statistics bar */
|
||||
.statsBar {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
padding: 0.5rem 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.statItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Pending files */
|
||||
.pendingFiles {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -582,8 +582,26 @@ export const PlaygroundPage: React.FC = () => {
|
|||
|
||||
{/* Page Header */}
|
||||
<header className={styles.pageHeader}>
|
||||
<div>
|
||||
<h1 className={styles.pageTitle}>Chat Playground</h1>
|
||||
<div className={styles.headerLeft}>
|
||||
<div className={styles.headerTitleRow}>
|
||||
<h1 className={styles.pageTitle}>Chat Playground</h1>
|
||||
{/* Stats display in header */}
|
||||
<div className={styles.headerStats}>
|
||||
<span className={styles.headerStatItem} title="Daten gesendet / empfangen">
|
||||
↑ {formatBytes(latestStats?.bytesSent || 0)} / ↓ {formatBytes(latestStats?.bytesReceived || 0)}
|
||||
</span>
|
||||
{(latestStats?.processingTime ?? 0) > 0 && (
|
||||
<span className={styles.headerStatItem} title="Verarbeitungszeit">
|
||||
⏱️ {formatDuration(latestStats?.processingTime || 0)}
|
||||
</span>
|
||||
)}
|
||||
{(latestStats?.priceUsd ?? 0) > 0 && (
|
||||
<span className={styles.headerStatItem} title="Kosten">
|
||||
💰 CHF {(latestStats?.priceUsd || 0).toFixed(4)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className={styles.pageSubtitle}>Workflow-Ausführung und Chat-Interaktion</p>
|
||||
</div>
|
||||
<div className={styles.headerControls}>
|
||||
|
|
@ -711,36 +729,6 @@ export const PlaygroundPage: React.FC = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats bar */}
|
||||
{latestStats && (latestStats.bytesSent || latestStats.bytesReceived || latestStats.processingTime || latestStats.priceUsd) && (
|
||||
<div className={styles.statsBar}>
|
||||
{(latestStats.bytesSent !== undefined || latestStats.bytesReceived !== undefined) && (
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>Daten:</span>
|
||||
<span className={styles.statValue}>
|
||||
{formatBytes(latestStats.bytesSent || 0)} / {formatBytes(latestStats.bytesReceived || 0)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{latestStats.processingTime !== undefined && latestStats.processingTime > 0 && (
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>Zeit:</span>
|
||||
<span className={styles.statValue}>
|
||||
{formatDuration(latestStats.processingTime)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{latestStats.priceUsd !== undefined && latestStats.priceUsd > 0 && (
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>Kosten:</span>
|
||||
<span className={styles.statValue}>
|
||||
${latestStats.priceUsd.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input row */}
|
||||
<div className={styles.inputRow}>
|
||||
<div className={styles.inputWrapper}>
|
||||
|
|
|
|||
|
|
@ -82,9 +82,11 @@ export const WorkflowsPage: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
// Handle continue workflow - navigate to playground
|
||||
// Handle continue workflow - navigate to playground within same feature instance
|
||||
// Uses relative navigation since WorkflowsPage is rendered under same instance route as playground
|
||||
const handleContinueWorkflow = (workflow: Workflow) => {
|
||||
navigate(`/workflows/playground?workflowId=${workflow.id}`);
|
||||
// Navigate relatively to playground (sibling route under same instance)
|
||||
navigate(`../playground?workflowId=${workflow.id}`);
|
||||
};
|
||||
|
||||
// Handle edit submit
|
||||
|
|
|
|||
Loading…
Reference in a new issue