diff --git a/src/api/workflowApi.ts b/src/api/workflowApi.ts index 7254640..ce902c1 100644 --- a/src/api/workflowApi.ts +++ b/src/api/workflowApi.ts @@ -57,8 +57,8 @@ export interface StartWorkflowResponse extends Workflow { export interface ChatDataResponse { messages: WorkflowMessage[]; logs: WorkflowLog[]; - stats: WorkflowStats[]; documents: WorkflowDocument[]; + workflowCost: number; } // Type for the request function passed to API functions @@ -259,35 +259,25 @@ export async function fetchChatData( console.log('📥 fetchChatData response:', data); - // Handle unified items format: { items: [{ type: 'message'|'log'|'stat', item: {...}, createdAt: ... }] } + const workflowCost: number = data.workflowCost ?? 0; + if (data.items && Array.isArray(data.items)) { const messages: WorkflowMessage[] = []; const logs: WorkflowLog[] = []; - const stats: WorkflowStats[] = []; const documents: WorkflowDocument[] = []; data.items.forEach((item: any) => { if (item.type === 'message') { - // Handle both formats: item.item or direct item data const messageData = item.item || item; if (messageData && (messageData.id || messageData.message)) { messages.push(messageData); - } else { - console.warn('⚠️ Invalid message item:', item); } } else if (item.type === 'log') { const logData = item.item || item; if (logData) { logs.push(logData); } - } else if (item.type === 'stat') { - const statData = item.item || item; - if (statData) { - stats.push(statData); - } - } - // Documents might be in items or separate - if (item.type === 'document') { + } else if (item.type === 'document') { const docData = item.item || item; if (docData) { documents.push(docData); @@ -295,27 +285,19 @@ export async function fetchChatData( } }); - console.log('📦 Extracted from items:', { - messages: messages.length, - logs: logs.length, - stats: stats.length, - documents: documents.length - }); - return { messages, logs, - stats, - documents: documents.length > 0 ? documents : (Array.isArray(data.documents) ? data.documents : []) + documents: documents.length > 0 ? documents : (Array.isArray(data.documents) ? data.documents : []), + workflowCost }; } - // Fallback to direct format: { messages: [], logs: [], stats: [] } return { messages: Array.isArray(data.messages) ? data.messages : [], logs: Array.isArray(data.logs) ? data.logs : [], - stats: Array.isArray(data.stats) ? data.stats : [], - documents: Array.isArray(data.documents) ? data.documents : [] + documents: Array.isArray(data.documents) ? data.documents : [], + workflowCost }; } diff --git a/src/components/UiComponents/WorkflowStatus/WorkflowStatus.tsx b/src/components/UiComponents/WorkflowStatus/WorkflowStatus.tsx index 59031c8..3002c35 100644 --- a/src/components/UiComponents/WorkflowStatus/WorkflowStatus.tsx +++ b/src/components/UiComponents/WorkflowStatus/WorkflowStatus.tsx @@ -88,28 +88,9 @@ const extractWorkflowStatus = (logs: any[]): { status: WorkflowStatusType; round }; }; -// Helper function to format bytes to KB or MB -const formatBytes = (bytes?: number): string => { - if (bytes === undefined || bytes === null) return '-'; - if (bytes === 0) return '0 B'; - const kb = bytes / 1024; - if (kb < 1024) { - return `${kb.toFixed(2)} KB`; - } - const mb = kb / 1024; - return `${mb.toFixed(2)} MB`; -}; - -// Helper function to format price -const formatPrice = (price?: number): string => { - if (price === undefined || price === null) return '-'; - return `$${price.toFixed(2)}`; -}; - -// Helper function to format processing time -const formatProcessingTime = (time?: number): string => { - if (time === undefined || time === null) return '-'; - return `${time.toFixed(2)}s`; +const _formatCurrency = (amount?: number): string => { + if (amount === undefined || amount === null) return '-'; + return `${amount.toFixed(2)} CHF`; }; const WorkflowStatus: React.FC = ({ @@ -185,33 +166,13 @@ const WorkflowStatus: React.FC = ({ )} - {/* Stats Display */} - {latestStats && ( + {/* Cost Display */} + {latestStats && latestStats.priceCHF !== undefined && (
- {latestStats.priceUsd !== undefined && ( -
- Price: - {formatPrice(latestStats.priceUsd)} -
- )} - {latestStats.processingTime !== undefined && ( -
- Time: - {formatProcessingTime(latestStats.processingTime)} -
- )} - {latestStats.bytesSent !== undefined && ( -
- Sent: - {formatBytes(latestStats.bytesSent)} -
- )} - {latestStats.bytesReceived !== undefined && ( -
- Received: - {formatBytes(latestStats.bytesReceived)} -
- )} +
+ Cost: + {_formatCurrency(latestStats.priceCHF)} +
)} diff --git a/src/components/UiComponents/WorkflowStatus/WorkflowStatusTypes.ts b/src/components/UiComponents/WorkflowStatus/WorkflowStatusTypes.ts index d83ca14..5275b9a 100644 --- a/src/components/UiComponents/WorkflowStatus/WorkflowStatusTypes.ts +++ b/src/components/UiComponents/WorkflowStatus/WorkflowStatusTypes.ts @@ -44,13 +44,10 @@ export interface WorkflowStatusProps { isRunning?: boolean; /** - * Latest statistics from the workflow (price, processing time, bytes sent/received) + * Latest cost from billing transactions (single source of truth) */ latestStats?: { - priceUsd?: number; - processingTime?: number; - bytesSent?: number; - bytesReceived?: number; + priceCHF?: number; } | null; } diff --git a/src/hooks/playground/useWorkflowLifecycle.ts b/src/hooks/playground/useWorkflowLifecycle.ts index 9b1be84..085d6f7 100644 --- a/src/hooks/playground/useWorkflowLifecycle.ts +++ b/src/hooks/playground/useWorkflowLifecycle.ts @@ -14,8 +14,8 @@ import { useWorkflowPolling } from './useWorkflowPolling'; import { getWorkflowApiBaseUrl } from '../useWorkflows'; interface UnifiedChatDataItem { - type: 'message' | 'log' | 'stat'; - item: WorkflowMessage | WorkflowLog | any; + type: 'message' | 'log'; + item: WorkflowMessage | WorkflowLog; createdAt: number; } @@ -76,13 +76,11 @@ export function useWorkflowLifecycle(instanceId: string) { const [logs, setLogs] = useState([]); const [dashboardLogs, setDashboardLogs] = useState([]); const [unifiedContentLogs, setUnifiedContentLogs] = useState([]); - const [latestStats, setLatestStats] = useState<{ priceUsd?: number; processingTime?: number; bytesSent?: number; bytesReceived?: number } | null>(null); + const [latestStats, setLatestStats] = useState<{ priceCHF?: number } | null>(null); // === REFS FOR SYNC ACCESS === const statusRef = useRef('idle'); const lastRenderedTimestampRef = useRef(null); - const processedStatIdsRef = useRef>(new Set()); - 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" @@ -124,17 +122,15 @@ export function useWorkflowLifecycle(instanceId: string) { }, [workflowId]); // === CORE: Process unified chat data === - const processUnifiedChatData = useCallback((chatData: { messages: WorkflowMessage[]; logs: WorkflowLog[]; stats: any[] }) => { + const processUnifiedChatData = useCallback((chatData: { messages: WorkflowMessage[]; logs: WorkflowLog[]; workflowCost: number }) => { console.log('🔄 Processing chat data:', { messages: chatData.messages?.length || 0, logs: chatData.logs?.length || 0, - stats: chatData.stats?.length || 0 + workflowCost: chatData.workflowCost ?? 0 }); - // Build unified timeline const timeline: UnifiedChatDataItem[] = []; - // Add messages (chatData.messages || []).forEach((message: WorkflowMessage) => { timeline.push({ type: 'message', @@ -143,7 +139,6 @@ export function useWorkflowLifecycle(instanceId: string) { }); }); - // Add logs (chatData.logs || []).forEach((log: any) => { timeline.push({ type: 'log', @@ -152,17 +147,6 @@ export function useWorkflowLifecycle(instanceId: string) { }); }); - // Add stats - const rawStats = chatData.stats || []; - rawStats.forEach((stat: any) => { - timeline.push({ - type: 'stat', - item: stat, - createdAt: stat._createdAt || stat.createdAt || Date.now() - }); - }); - - // Sort chronologically timeline.sort((a, b) => a.createdAt - b.createdAt); // Update lastRenderedTimestamp @@ -290,44 +274,9 @@ export function useWorkflowLifecycle(instanceId: string) { return [...allLogs].sort(sortLogs); }); - // === PROCESS STATS === - const statsItems = timeline.filter(item => item.type === 'stat'); - - if (statsItems.length > 0) { - let hasNewStats = false; - - statsItems.forEach(statItem => { - const statData = statItem.item; - const statId = statData?.id; - - if (statId && processedStatIdsRef.current.has(statId)) { - return; // Skip already processed - } - - if (statData) { - hasNewStats = true; - if (statId) { - processedStatIdsRef.current.add(statId); - } - - // 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; - } - }); - - if (hasNewStats) { - setLatestStats({ - priceUsd: cumulativeStatsRef.current.priceUsd, - processingTime: cumulativeStatsRef.current.processingTime, - bytesSent: cumulativeStatsRef.current.bytesSent, - bytesReceived: cumulativeStatsRef.current.bytesReceived - }); - } - } + // === UPDATE COST from billing transactions (single source of truth) === + const cost = chatData.workflowCost ?? 0; + setLatestStats(cost > 0 ? { priceCHF: cost } : null); }, [convertLogToFrontendFormat]); // === POLLING FUNCTION === @@ -359,7 +308,7 @@ export function useWorkflowLifecycle(instanceId: string) { console.log('📊 Polled chat data:', { messages: chatData.messages?.length || 0, logs: chatData.logs?.length || 0, - stats: chatData.stats?.length || 0, + workflowCost: chatData.workflowCost ?? 0, afterTimestamp }); @@ -496,10 +445,7 @@ export function useWorkflowLifecycle(instanceId: string) { setUnifiedContentLogs([]); setLatestStats(null); - // Reset refs lastRenderedTimestampRef.current = null; - processedStatIdsRef.current.clear(); - cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 }; hasRenderedLastMessageRef.current = false; setHasRenderedLastMessage(false); @@ -511,13 +457,11 @@ export function useWorkflowLifecycle(instanceId: string) { try { console.log('📥 Loading workflow:', workflowIdToSelect); - // Reset state setWorkflowId(workflowIdToSelect); lastRenderedTimestampRef.current = null; - processedStatIdsRef.current.clear(); - cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 }; hasRenderedLastMessageRef.current = false; setHasRenderedLastMessage(false); + setLatestStats(null); // Fetch workflow data const workflowData = await fetchWorkflowApi(request, workflowIdToSelect, apiBaseUrl).catch(() => null); @@ -544,7 +488,7 @@ export function useWorkflowLifecycle(instanceId: string) { console.log('📥 Loaded chat data:', { messages: chatData.messages?.length || 0, logs: chatData.logs?.length || 0, - stats: chatData.stats?.length || 0 + workflowCost: chatData.workflowCost ?? 0 }); // === STATE MACHINE: Check if last message has status="last" === diff --git a/src/pages/billing/BillingAdmin.tsx b/src/pages/billing/BillingAdmin.tsx index 7624f24..d25bfba 100644 --- a/src/pages/billing/BillingAdmin.tsx +++ b/src/pages/billing/BillingAdmin.tsx @@ -8,12 +8,16 @@ */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; -import { useSearchParams } from 'react-router-dom'; import { useBillingAdmin, type BillingSettings, type AccountSummary, type MandateUserSummary } from '../../hooks/useBilling'; import { useAdminMandates } from '../../hooks/useMandates'; import styles from './Billing.module.css'; -const STRIPE_AMOUNT_PRESETS = [10, 25, 50, 100, 250, 500]; +const _formatCurrency = (amount: number) => { + return new Intl.NumberFormat('de-CH', { + style: 'currency', + currency: 'CHF' + }).format(amount); +}; // ============================================================================ // MANDATE SELECTOR @@ -195,18 +199,18 @@ interface CreditAdderProps { settings: BillingSettings | null; accounts: AccountSummary[]; users: MandateUserSummary[]; - onCreateCheckout: (userId: string | undefined, amount: number) => Promise<{ redirectUrl: string }>; + onAddCredit: (userId: string | undefined, amount: number, description: string) => Promise; } -const CreditAdder: React.FC = ({ settings, accounts, users, onCreateCheckout }) => { +const CreditAdder: React.FC = ({ settings, accounts, users, onAddCredit }) => { const [selectedUserId, setSelectedUserId] = useState(''); - const [amount, setAmount] = useState(10); + const [amount, setAmount] = useState(''); + const [description, setDescription] = useState('Manuelles Aufladen durch Admin'); const [saving, setSaving] = useState(false); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const isPrepayUser = settings?.billingModel === 'PREPAY_USER'; - // Map accounts by userId for balance lookup const accountsByUserId = accounts .filter(acc => acc.accountType === 'USER') .reduce((map, acc) => { @@ -214,9 +218,10 @@ const CreditAdder: React.FC = ({ settings, accounts, users, on return map; }, {} as Record); - const handleSubmit = async (e: React.FormEvent) => { + const _handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (amount <= 0) { + const numAmount = parseFloat(amount); + if (!numAmount || numAmount <= 0) { setMessage({ type: 'error', text: 'Betrag muss positiv sein' }); return; } @@ -225,24 +230,19 @@ const CreditAdder: React.FC = ({ settings, accounts, users, on setMessage(null); try { - const { redirectUrl } = await onCreateCheckout(isPrepayUser ? selectedUserId : undefined, amount); - window.location.href = redirectUrl; + await onAddCredit(isPrepayUser ? selectedUserId : undefined, numAmount, description); + setMessage({ type: 'success', text: `${_formatCurrency(numAmount)} erfolgreich gutgeschrieben.` }); + setAmount(''); } catch (err: any) { setMessage({ type: 'error', text: err.message || 'Fehler beim Aufladen' }); + } finally { setSaving(false); } }; - const formatCurrency = (amount: number) => { - return new Intl.NumberFormat('de-CH', { - style: 'currency', - currency: 'CHF' - }).format(amount); - }; - return (
-

Guthaben aufladen

+

Guthaben manuell aufladen

{message && (
@@ -250,7 +250,7 @@ const CreditAdder: React.FC = ({ settings, accounts, users, on
)} -
+ {isPrepayUser && (
@@ -264,7 +264,7 @@ const CreditAdder: React.FC = ({ settings, accounts, users, on {users.map((user) => { const account = accountsByUserId[user.id]; - const balanceInfo = account ? ` (${formatCurrency(account.balance)})` : ' (kein Konto)'; + const balanceInfo = account ? ` (${_formatCurrency(account.balance)})` : ' (kein Konto)'; return (
@@ -370,18 +378,8 @@ const AccountsOverview: React.FC = ({ accounts, users, lo // ============================================================================ export const BillingAdmin: React.FC = () => { - const [searchParams, setSearchParams] = useSearchParams(); const [selectedMandateId, setSelectedMandateId] = useState(null); - const { settings, accounts, users, loading, saveSettings, createCheckout, loadAccounts } = useBillingAdmin(selectedMandateId || undefined); - - const successParam = searchParams.get('success'); - const canceledParam = searchParams.get('canceled'); - - useEffect(() => { - if (successParam === 'true' && selectedMandateId) { - loadAccounts(); - } - }, [successParam, selectedMandateId, loadAccounts]); + const { settings, accounts, users, loading, saveSettings, addCredit, loadAccounts } = useBillingAdmin(selectedMandateId || undefined); const handleMandateSelect = (mandateId: string) => { setSelectedMandateId(mandateId || null); @@ -392,19 +390,13 @@ export const BillingAdmin: React.FC = () => { await saveSettings(settingsUpdate); }, [selectedMandateId, saveSettings]); - const handleCreateCheckout = useCallback(async (userId: string | undefined, amount: number) => { + const _handleAddCredit = useCallback(async (userId: string | undefined, amount: number, description: string) => { if (!selectedMandateId) throw new Error('Mandant nicht ausgewählt'); - const result = await createCheckout({ userId, amount }); - if (!result) throw new Error('Checkout konnte nicht erstellt werden'); + const result = await addCredit({ userId, amount, description }); + if (!result) throw new Error('Gutschrift konnte nicht erstellt werden'); + await loadAccounts(); return result; - }, [selectedMandateId, createCheckout]); - - const clearStripeParams = useCallback(() => { - searchParams.delete('success'); - searchParams.delete('canceled'); - searchParams.delete('session_id'); - setSearchParams(searchParams, { replace: true }); - }, [searchParams, setSearchParams]); + }, [selectedMandateId, addCredit, loadAccounts]); return (
@@ -413,19 +405,6 @@ export const BillingAdmin: React.FC = () => {

Verwaltung von Abrechnungseinstellungen und Guthaben

- {successParam === 'true' && ( -
- Zahlung erfolgreich. Guthaben wird gutgeschrieben. - -
- )} - {canceledParam === 'true' && ( -
- Zahlung abgebrochen. - -
- )} -
{ settings={settings} accounts={accounts} users={users} - onCreateCheckout={handleCreateCheckout} + onAddCredit={_handleAddCredit} /> void; + checkoutLoading?: boolean; } -const BalanceCard: React.FC = ({ balance }) => { +const BalanceCard: React.FC = ({ balance, onCheckout, checkoutLoading }) => { + const [selectedAmount, setSelectedAmount] = useState(STRIPE_AMOUNT_PRESETS[0]); + const [showCheckout, setShowCheckout] = useState(false); + const _getBillingModelLabel = (model: string) => { switch (model) { case 'PREPAY_MANDATE': return 'Prepaid (Mandant)'; @@ -59,7 +69,11 @@ const BalanceCard: React.FC = ({ balance }) => { default: return model; } }; - + + const canTopUp = balance.billingModel === 'PREPAY_USER' + || balance.billingModel === 'PREPAY_MANDATE' + || balance.billingModel === 'CREDIT_POSTPAY'; + return (
@@ -74,6 +88,47 @@ const BalanceCard: React.FC = ({ balance }) => { Niedriges Guthaben
)} + {canTopUp && onCheckout && ( +
+ {!showCheckout ? ( + + ) : ( +
+ + + +
+ )} +
+ )}
); }; @@ -265,6 +320,10 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] { export const BillingDataView: React.FC = () => { const [activeTab, setActiveTab] = useState('overview'); + const [searchParams, setSearchParams] = useSearchParams(); + const { request } = useApiRequest(); + const [checkoutLoading, setCheckoutLoading] = useState(false); + const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); // Scope filter: 'personal' | 'all' | mandateId const [selectedScope, setSelectedScope] = useState('personal'); @@ -272,9 +331,48 @@ export const BillingDataView: React.FC = () => { // Dashboard state (for Overview tab) const { balances, - loading: dashboardLoading, + loading: dashboardLoading, + refetch: refetchBalances, } = useBilling(); + const successParam = searchParams.get('success'); + const canceledParam = searchParams.get('canceled'); + + useEffect(() => { + if (successParam === 'true') { + setCheckoutMessage({ type: 'success', text: 'Zahlung erfolgreich. Guthaben wird gutgeschrieben.' }); + refetchBalances(); + } else if (canceledParam === 'true') { + setCheckoutMessage({ type: 'error', text: 'Zahlung abgebrochen.' }); + } + }, [successParam, canceledParam, refetchBalances]); + + const _clearStripeParams = useCallback(() => { + searchParams.delete('success'); + searchParams.delete('canceled'); + searchParams.delete('session_id'); + setSearchParams(searchParams, { replace: true }); + setCheckoutMessage(null); + }, [searchParams, setSearchParams]); + + const _handleCheckout = useCallback(async (mandateId: string, amount: number) => { + setCheckoutLoading(true); + setCheckoutMessage(null); + try { + const currentUser = getUserDataCache(); + const result = await createCheckoutSession(request, mandateId, { + userId: currentUser?.id, + amount, + }); + if (result?.redirectUrl) { + window.location.href = result.redirectUrl; + } + } catch (err: any) { + setCheckoutMessage({ type: 'error', text: err.message || 'Fehler beim Erstellen der Checkout-Session' }); + setCheckoutLoading(false); + } + }, [request]); + // All user balances (for admin overview cards) const [allUserBalances, setAllUserBalances] = useState([]); const [allUserBalancesLoading, setAllUserBalancesLoading] = useState(false); @@ -475,6 +573,15 @@ export const BillingDataView: React.FC = () => { + {checkoutMessage && ( +
+ {checkoutMessage.text} + {(successParam || canceledParam) && ( + + )} +
+ )} + {/* ================================================================ */} {/* Tab: Übersicht (My Overview) */} {/* ================================================================ */} @@ -502,7 +609,12 @@ export const BillingDataView: React.FC = () => { ) : (
{filteredBalances.map((balance) => ( - + ))}
)} diff --git a/src/pages/workflows/PlaygroundPage.tsx b/src/pages/workflows/PlaygroundPage.tsx index 00c2d9f..4b5371a 100644 --- a/src/pages/workflows/PlaygroundPage.tsx +++ b/src/pages/workflows/PlaygroundPage.tsx @@ -128,25 +128,6 @@ export const PlaygroundPage: React.FC = () => { } }, [urlWorkflowId, onWorkflowSelect]); - // Format bytes helper - const formatBytes = (bytes: number): string => { - if (!bytes || bytes < 0) return '0 B'; - if (bytes < 1024) return `${bytes} B`; - const kbytes = bytes / 1024; - if (kbytes < 1000) return `${Math.round(kbytes)} kB`; - const mbytes = kbytes / 1024; - return `${Math.round(mbytes * 10) / 10} MB`; - }; - - // Format duration helper (for stats) - const formatDuration = (seconds: number): string => { - if (!seconds || seconds < 0) return '0s'; - if (seconds < 60) return `${Math.round(seconds)}s`; - const minutes = Math.floor(seconds / 60); - const remainingSeconds = Math.round(seconds % 60); - return `${minutes}m ${remainingSeconds}s`; - }; - // Handle prompt selection const handlePromptSelect = (promptId: string) => { setSelectedPromptId(promptId); @@ -589,22 +570,13 @@ export const PlaygroundPage: React.FC = () => {

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 && ( + {latestStats?.priceCHF != null && latestStats.priceCHF > 0 && ( +
- 💰 CHF {(latestStats?.priceUsd || 0).toFixed(4)} + CHF {latestStats.priceCHF.toFixed(2)} - )} -
+
+ )}

Workflow-Ausführung und Chat-Interaktion