diff --git a/src/App.tsx b/src/App.tsx index 41c6028..3b82754 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 */} {/* ============================================== */} - } /> - } /> + } /> + } /> {/* ============================================== */} diff --git a/src/api/billingApi.ts b/src/api/billingApi.ts index 800adf1..69ebf34 100644 --- a/src/api/billingApi.ts +++ b/src/api/billingApi.ts @@ -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 { + 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 { + 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 { + 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 { + return await request({ + url: '/api/billing/view/users/transactions', + method: 'get', + params: { limit } + }); +} diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css index fa439f6..1195f68 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css @@ -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; diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx index 936231f..c54cfa0 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx @@ -338,6 +338,11 @@ export function FormGeneratorTable>({ const tableRef = useRef(null); const tableContainerRef = useRef(null); + // Refs for top scrollbar synchronization + const topScrollbarRef = useRef(null); + const topScrollbarInnerRef = useRef(null); + const isScrollingSyncRef = useRef(false); // Prevent scroll sync loops + // Track container width for actions column 20% threshold const [containerWidth, setContainerWidth] = useState(0); @@ -945,6 +950,53 @@ export function FormGeneratorTable>({ }; }, []); + // 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>(new Set()); @@ -1367,26 +1419,36 @@ export function FormGeneratorTable>({ /> )} - {/* Table */} -
- {/* Loading overlay - shown while loading */} - {loading && ( -
-
-

{t('common.loading', 'Loading...')}

-
- )} + {/* Table Wrapper - contains top scrollbar and table container */} +
+ {/* Top horizontal scrollbar - syncs with table container */} +
+
+
- {/* Empty state - only shown when not loading AND no data */} - {!loading && displayData.length === 0 ? ( -
-

{emptyMessage || t('formgen.empty', 'No data available')}

-
- ) : ( - + {/* Table Container - vertical scroll only */} +
+ {/* Loading overlay - shown while loading */} + {loading && ( +
+
+

{t('common.loading', 'Loading...')}

+
+ )} + + {/* Empty state - only shown when not loading AND no data */} + {!loading && displayData.length === 0 ? ( +
+

{emptyMessage || t('formgen.empty', 'No data available')}

+
+ ) : ( +
{selectable && ( @@ -1704,7 +1766,8 @@ export function FormGeneratorTable>({ )}
- )} + )} +
); diff --git a/src/components/Navigation/MandateNavigation.tsx b/src/components/Navigation/MandateNavigation.tsx index 85f5398..c67b0e0 100644 --- a/src/components/Navigation/MandateNavigation.tsx +++ b/src/components/Navigation/MandateNavigation.tsx @@ -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, }; } diff --git a/src/components/Navigation/UserSection.tsx b/src/components/Navigation/UserSection.tsx index 1d7ba38..e19cd0e 100644 --- a/src/components/Navigation/UserSection.tsx +++ b/src/components/Navigation/UserSection.tsx @@ -35,7 +35,7 @@ export const UserSection: React.FC = () => { }; const handleBilling = () => { - navigate('/billing'); + navigate('/billing/transactions'); setShowMenu(false); }; diff --git a/src/components/ProviderSelector/ProviderSelector.module.css b/src/components/ProviderSelector/ProviderSelector.module.css index 242df68..ddfa9c9 100644 --- a/src/components/ProviderSelector/ProviderSelector.module.css +++ b/src/components/ProviderSelector/ProviderSelector.module.css @@ -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; } /* ============================================================================ diff --git a/src/components/ProviderSelector/ProviderSelector.tsx b/src/components/ProviderSelector/ProviderSelector.tsx index 73f0184..55596a8 100644 --- a/src/components/ProviderSelector/ProviderSelector.tsx +++ b/src/components/ProviderSelector/ProviderSelector.tsx @@ -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 = { openai: 'OpenAI (GPT)', perplexity: 'Perplexity', tavily: 'Tavily (Web Search)', + privatellm: 'Private LLM', internal: 'Internal', }; @@ -29,6 +30,7 @@ const PROVIDER_ICONS: Record = { openai: '💬', perplexity: '🔍', tavily: '🌐', + privatellm: '🔒', internal: '🏠', }; @@ -112,6 +114,7 @@ export const ProviderMultiSelect: React.FC = ({ defaultExpanded = false, }) => { const [isExpanded, setIsExpanded] = useState(defaultExpanded); + const containerRef = useRef(null); const { allowedProviders, loadAllowedProviders, loading } = useBilling(); useEffect(() => { @@ -120,66 +123,84 @@ export const ProviderMultiSelect: React.FC = ({ } }, []); + // 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 ( -
- {/* Collapsible Header */} +
+ {/* Trigger Button - styled like iconButton */} - {/* Expandable Content */} + {/* Dropdown Content */} {isExpanded && ( -
- {showLabel && } +
+ {showLabel &&
{label}
}
@@ -194,7 +215,7 @@ export const ProviderMultiSelect: React.FC = ({
{loading ? ( -
Lade Provider...
+
Lade...
) : (
{allowedProviders.map((provider) => ( @@ -204,7 +225,7 @@ export const ProviderMultiSelect: React.FC = ({ > handleToggle(provider)} disabled={disabled} /> @@ -219,7 +240,7 @@ export const ProviderMultiSelect: React.FC = ({ {selectedProviders.length === 0 && !loading && (
- Wenn keine Provider ausgewählt sind, werden alle erlaubten Provider verwendet. + Alle Provider aktiv
)}
diff --git a/src/components/UiComponents/Messages/ChatMessages/ChatMessage.tsx b/src/components/UiComponents/Messages/ChatMessages/ChatMessage.tsx index 169bd72..aff1272 100644 --- a/src/components/UiComponents/Messages/ChatMessages/ChatMessage.tsx +++ b/src/components/UiComponents/Messages/ChatMessages/ChatMessage.tsx @@ -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 = ({ 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 = ({ } return ( -
+
+ {/* Error indicator for failed actions */} + {isError && ( +
+ + Aktion fehlgeschlagen +
+ )} + {/* Message content */} {message.message && (
diff --git a/src/components/UiComponents/Messages/Messages.module.css b/src/components/UiComponents/Messages/Messages.module.css index e5741d5..620e4ac 100644 --- a/src/components/UiComponents/Messages/Messages.module.css +++ b/src/components/UiComponents/Messages/Messages.module.css @@ -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; diff --git a/src/hooks/playground/useWorkflowLifecycle.ts b/src/hooks/playground/useWorkflowLifecycle.ts index 039864f..e553a68 100644 --- a/src/hooks/playground/useWorkflowLifecycle.ts +++ b/src/hooks/playground/useWorkflowLifecycle.ts @@ -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(null); const [workflowStatus, setWorkflowStatus] = useState('idle'); const [currentRound, setCurrentRound] = useState(undefined); @@ -26,48 +73,35 @@ export function useWorkflowLifecycle(instanceId: string) { const [logs, setLogs] = useState([]); const [dashboardLogs, setDashboardLogs] = useState([]); const [unifiedContentLogs, setUnifiedContentLogs] = useState([]); - const [statusChangedFromRunningAt, setStatusChangedFromRunningAt] = useState(null); const [latestStats, setLatestStats] = useState<{ priceUsd?: number; processingTime?: number; bytesSent?: number; bytesReceived?: number } | null>(null); - const prevStatusRef = useRef('idle'); + + // === REFS FOR SYNC ACCESS === const statusRef = useRef('idle'); - const statusChangedFromRunningAtRef = useRef(null); const lastRenderedTimestampRef = useRef(null); - // Track processed stat IDs to avoid double-counting const processedStatIdsRef = useRef>(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(false); + const [hasRenderedLastMessage, setHasRenderedLastMessage] = useState(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, diff --git a/src/layouts/FeatureLayout.module.css b/src/layouts/FeatureLayout.module.css index 5cce883..aeca795 100644 --- a/src/layouts/FeatureLayout.module.css +++ b/src/layouts/FeatureLayout.module.css @@ -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 */ diff --git a/src/layouts/MainLayout.module.css b/src/layouts/MainLayout.module.css index f245160..ac71c8b 100644 --- a/src/layouts/MainLayout.module.css +++ b/src/layouts/MainLayout.module.css @@ -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); } diff --git a/src/pages/admin/Admin.module.css b/src/pages/admin/Admin.module.css index 50e63ef..6e99486 100644 --- a/src/pages/admin/Admin.module.css +++ b/src/pages/admin/Admin.module.css @@ -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 { diff --git a/src/pages/billing/Billing.module.css b/src/pages/billing/Billing.module.css index 35ce33a..f09b398 100644 --- a/src/pages/billing/Billing.module.css +++ b/src/pages/billing/Billing.module.css @@ -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 { diff --git a/src/pages/billing/BillingDashboard.tsx b/src/pages/billing/BillingDashboard.tsx index 2576818..4e3f963 100644 --- a/src/pages/billing/BillingDashboard.tsx +++ b/src/pages/billing/BillingDashboard.tsx @@ -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 = () => {

Übersicht über Guthaben und Nutzung

+ + {/* Balance Cards */}

Guthaben

diff --git a/src/pages/billing/BillingDataView.tsx b/src/pages/billing/BillingDataView.tsx new file mode 100644 index 0000000..a8ceecc --- /dev/null +++ b/src/pages/billing/BillingDataView.tsx @@ -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 = ({ 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 ( +
+
+

{balance.mandateName}

+ {getBillingModelLabel(balance.billingModel)} +
+
+ {formatCurrency(balance.balance)} +
+ {balance.isWarning && ( +
+ Niedriges Guthaben +
+ )} +
+ ); +}; + +// ============================================================================ +// STATISTICS CHART COMPONENT +// ============================================================================ + +interface StatisticsChartProps { + statistics: UsageReport | null; + loading?: boolean; +} + +const StatisticsChart: React.FC = ({ statistics, loading }) => { + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('de-CH', { + style: 'currency', + currency: 'CHF' + }).format(amount); + }; + + if (loading) { + return
Lade Statistiken...
; + } + + if (!statistics) { + return
Keine Statistiken verfügbar
; + } + + const maxProviderCost = Math.max(...Object.values(statistics.costByProvider), 1); + + return ( +
+
+ Gesamtkosten + {formatCurrency(statistics.totalCost)} +
+ +
+

Kosten nach Anbieter

+ {Object.entries(statistics.costByProvider).length === 0 ? ( +
Keine Daten
+ ) : ( +
+ {Object.entries(statistics.costByProvider).map(([provider, cost]) => ( +
+ {provider} +
+
+
+ {formatCurrency(cost)} +
+ ))} +
+ )} +
+ +
+

Kosten nach Feature

+ {Object.entries(statistics.costByFeature).length === 0 ? ( +
Keine Daten
+ ) : ( +
+ {Object.entries(statistics.costByFeature).map(([feature, cost]) => ( +
+ {feature} + {formatCurrency(cost)} +
+ ))} +
+ )} +
+
+ ); +}; + +// ============================================================================ +// TAB NAVIGATION COMPONENT +// ============================================================================ + +type TabType = 'overview' | 'transactions'; + +interface TabNavProps { + activeTab: TabType; + onTabChange: (tab: TabType) => void; +} + +const TabNav: React.FC = ({ 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 ( + + ); +}; + +// ============================================================================ +// MAIN COMPONENT +// ============================================================================ + +export const BillingDataView: React.FC = () => { + const [activeTab, setActiveTab] = useState('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([]); + const [transactionsLoading, setTransactionsLoading] = useState(false); + const [transactionsError, setTransactionsError] = useState(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 ( +
+
+

Billing

+

Guthaben, Statistiken und Transaktionen

+
+ + + + {/* Overview Tab */} + {activeTab === 'overview' && ( + <> + {/* Balance Cards */} +
+

Guthaben

+ {dashboardLoading ? ( +
Lade Guthaben...
+ ) : balances.length === 0 ? ( +
Keine Abrechnungskonten vorhanden
+ ) : ( +
+ {balances.map((balance) => ( + + ))} +
+ )} +
+ + {/* Statistics */} +
+
+

Nutzungsstatistik

+
+ + + {selectedPeriod === 'month' && ( + + )} +
+
+ +
+ + )} + + {/* Transactions Tab */} + {activeTab === 'transactions' && ( + <> + {transactionsError && ( +
+ {transactionsError} +
+ )} + + + + )} +
+ ); +}; + +export default BillingDataView; diff --git a/src/pages/billing/BillingMandateView.tsx b/src/pages/billing/BillingMandateView.tsx new file mode 100644 index 0000000..82d022f --- /dev/null +++ b/src/pages/billing/BillingMandateView.tsx @@ -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 = ({ + 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 ( +
+ + + + + + + + + + + + + {balances.map((balance) => ( + + + + + + + + + ))} + +
MandantBilling-ModellAnzahl BenutzerStandard-GuthabenGesamtguthabenAktion
{balance.mandateName || balance.mandateId}{getBillingModelLabel(balance.billingModel)}{balance.userCount}{formatCurrency(balance.defaultUserCredit)}{formatCurrency(balance.totalBalance)} + +
+
+ ); +}; + +// ============================================================================ +// TRANSACTION TABLE +// ============================================================================ + +interface TransactionTableProps { + transactions: BillingTransaction[]; +} + +const TransactionTable: React.FC = ({ 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 ( +
+ + + + + + + + + + + + + + {transactions.map((t) => ( + + + + + + + + + + ))} + +
DatumMandantTypBeschreibungAnbieterFeatureBetrag
{formatDate(t.createdAt)}{t.mandateName || '-'} + + {getTypeLabel(t.transactionType)} + + {t.description}{t.aicoreProvider || '-'}{t.featureCode || '-'} + {t.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(t.amount)} +
+
+ ); +}; + +// ============================================================================ +// MAIN COMPONENT +// ============================================================================ + +export const BillingMandateView: React.FC = () => { + const { request, isLoading: loading } = useApiRequest(); + const [balances, setBalances] = useState([]); + const [transactions, setTransactions] = useState([]); + const [selectedMandateId, setSelectedMandateId] = useState(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 ( +
+
+

Mandanten-Billing

+

Guthaben und Transaktionen pro Mandant

+
+ + + + {/* Mandate Balances */} +
+

Mandanten-Guthaben

+ {loading && balances.length === 0 ? ( +
Lade Daten...
+ ) : balances.length === 0 ? ( +
Keine Mandanten mit Billing-Settings vorhanden
+ ) : ( + + )} +
+ + {/* Transactions */} +
+
+

+ Transaktionen + {selectedMandateId && ( + + (gefiltert nach {balances.find(b => b.mandateId === selectedMandateId)?.mandateName || selectedMandateId}) + + )} +

+
+ {loading && transactions.length === 0 ? ( +
Lade Transaktionen...
+ ) : filteredTransactions.length === 0 ? ( +
Keine Transaktionen vorhanden
+ ) : ( + <> + + + {transactions.length >= limit && ( +
+ +
+ )} + + )} +
+
+ ); +}; + +export default BillingMandateView; diff --git a/src/pages/billing/BillingNav.tsx b/src/pages/billing/BillingNav.tsx new file mode 100644 index 0000000..0b4c918 --- /dev/null +++ b/src/pages/billing/BillingNav.tsx @@ -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 ( + + ); +}; + +export default BillingNav; diff --git a/src/pages/billing/BillingTransactions.tsx b/src/pages/billing/BillingTransactions.tsx index f584bce..ddd7f2c 100644 --- a/src/pages/billing/BillingTransactions.tsx +++ b/src/pages/billing/BillingTransactions.tsx @@ -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 = ({ transaction }) => { return ( {formatDate(transaction.createdAt)} + {transaction.mandateName || '-'} {getTypeLabel(transaction.transactionType)} @@ -94,6 +96,8 @@ export const BillingTransactions: React.FC = () => {

Übersicht aller Kontobewegungen

+ +
{loading && transactions.length === 0 ? (
Lade Transaktionen...
@@ -106,6 +110,7 @@ export const BillingTransactions: React.FC = () => { Datum + Mandant Typ Beschreibung Anbieter diff --git a/src/pages/billing/BillingUserView.tsx b/src/pages/billing/BillingUserView.tsx new file mode 100644 index 0000000..194be7d --- /dev/null +++ b/src/pages/billing/BillingUserView.tsx @@ -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 = ({ + 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(); + 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(); + 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 */} +
+
+ + +
+
+ + +
+
+ + {/* Table */} +
+ + + + + + + + + + + + {filteredBalances.map((balance) => ( + + + + + + + + ))} + +
MandantBenutzerGuthabenWarnschwelleStatus
{balance.mandateName || balance.mandateId}{balance.userName || balance.userId}{formatCurrency(balance.balance)}{formatCurrency(balance.warningThreshold)} + {balance.isWarning ? ( + + Niedrig + + ) : balance.enabled ? ( + Aktiv + ) : ( + Deaktiviert + )} +
+
+ + ); +}; + +// ============================================================================ +// USER TRANSACTION TABLE +// ============================================================================ + +interface UserTransactionTableProps { + transactions: UserTransaction[]; + selectedMandateId: string | null; + selectedUserId: string | null; +} + +const UserTransactionTable: React.FC = ({ + 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 ( +
+ + + + + + + + + + + + + + + {filteredTransactions.map((t) => ( + + + + + + + + + + + ))} + +
DatumMandantBenutzerTypBeschreibungAnbieterFeatureBetrag
{formatDate(t.createdAt)}{t.mandateName || '-'}{t.userName || '-'} + + {getTypeLabel(t.transactionType)} + + {t.description}{t.aicoreProvider || '-'}{t.featureCode || '-'} + {t.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(t.amount)} +
+
+ ); +}; + +// ============================================================================ +// MAIN COMPONENT +// ============================================================================ + +export const BillingUserView: React.FC = () => { + const { request, isLoading: loading } = useApiRequest(); + const [balances, setBalances] = useState([]); + const [transactions, setTransactions] = useState([]); + const [selectedMandateId, setSelectedMandateId] = useState(null); + const [selectedUserId, setSelectedUserId] = useState(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 ( +
+
+

Benutzer-Billing

+

Guthaben und Transaktionen pro Benutzer

+
+ + + + {/* User Balances */} +
+

Benutzer-Guthaben

+ {loading && balances.length === 0 ? ( +
Lade Daten...
+ ) : balances.length === 0 ? ( +
Keine Benutzer-Konten vorhanden
+ ) : ( + + )} +
+ + {/* Transactions */} +
+
+

+ Transaktionen + {(selectedMandateId || selectedUserId) && ( + + ({filteredTransactionCount} gefiltert) + + )} +

+
+ {loading && transactions.length === 0 ? ( +
Lade Transaktionen...
+ ) : transactions.length === 0 ? ( +
Keine Transaktionen vorhanden
+ ) : ( + <> + + + {transactions.length >= limit && ( +
+ +
+ )} + + )} +
+
+ ); +}; + +export default BillingUserView; diff --git a/src/pages/billing/index.ts b/src/pages/billing/index.ts index eb4f6c7..86d7563 100644 --- a/src/pages/billing/index.ts +++ b/src/pages/billing/index.ts @@ -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'; diff --git a/src/pages/workflows/PlaygroundPage.module.css b/src/pages/workflows/PlaygroundPage.module.css index cbb271b..32e8025 100644 --- a/src/pages/workflows/PlaygroundPage.module.css +++ b/src/pages/workflows/PlaygroundPage.module.css @@ -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; diff --git a/src/pages/workflows/PlaygroundPage.tsx b/src/pages/workflows/PlaygroundPage.tsx index 0733329..9dc6af7 100644 --- a/src/pages/workflows/PlaygroundPage.tsx +++ b/src/pages/workflows/PlaygroundPage.tsx @@ -582,8 +582,26 @@ export const PlaygroundPage: React.FC = () => { {/* Page Header */}
-
-

Chat Playground

+
+
+

Chat Playground

+ {/* Stats display in header */} +
+ + ↑ {formatBytes(latestStats?.bytesSent || 0)} / ↓ {formatBytes(latestStats?.bytesReceived || 0)} + + {(latestStats?.processingTime ?? 0) > 0 && ( + + ⏱️ {formatDuration(latestStats?.processingTime || 0)} + + )} + {(latestStats?.priceUsd ?? 0) > 0 && ( + + 💰 CHF {(latestStats?.priceUsd || 0).toFixed(4)} + + )} +
+

Workflow-Ausführung und Chat-Interaktion

@@ -711,36 +729,6 @@ export const PlaygroundPage: React.FC = () => {
)} - {/* Stats bar */} - {latestStats && (latestStats.bytesSent || latestStats.bytesReceived || latestStats.processingTime || latestStats.priceUsd) && ( -
- {(latestStats.bytesSent !== undefined || latestStats.bytesReceived !== undefined) && ( -
- Daten: - - {formatBytes(latestStats.bytesSent || 0)} / {formatBytes(latestStats.bytesReceived || 0)} - -
- )} - {latestStats.processingTime !== undefined && latestStats.processingTime > 0 && ( -
- Zeit: - - {formatDuration(latestStats.processingTime)} - -
- )} - {latestStats.priceUsd !== undefined && latestStats.priceUsd > 0 && ( -
- Kosten: - - ${latestStats.priceUsd.toFixed(4)} - -
- )} -
- )} - {/* Input row */}
diff --git a/src/pages/workflows/WorkflowsPage.tsx b/src/pages/workflows/WorkflowsPage.tsx index 2816cc0..a17060c 100644 --- a/src/pages/workflows/WorkflowsPage.tsx +++ b/src/pages/workflows/WorkflowsPage.tsx @@ -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