removed ChatStats, only billing and transactions relevant

This commit is contained in:
ValueOn AG 2026-03-14 11:52:17 +01:00
parent 9cad69fd0c
commit 869d1f24c3
7 changed files with 199 additions and 252 deletions

View file

@ -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
};
}

View file

@ -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<WorkflowStatusProps> = ({
@ -185,33 +166,13 @@ const WorkflowStatus: React.FC<WorkflowStatusProps> = ({
)}
</div>
{/* Stats Display */}
{latestStats && (
{/* Cost Display */}
{latestStats && latestStats.priceCHF !== undefined && (
<div className={styles.statsContainer}>
{latestStats.priceUsd !== undefined && (
<div className={styles.statItem}>
<span className={styles.statLabel}>Price:</span>
<span className={styles.statValue}>{formatPrice(latestStats.priceUsd)}</span>
</div>
)}
{latestStats.processingTime !== undefined && (
<div className={styles.statItem}>
<span className={styles.statLabel}>Time:</span>
<span className={styles.statValue}>{formatProcessingTime(latestStats.processingTime)}</span>
</div>
)}
{latestStats.bytesSent !== undefined && (
<div className={styles.statItem}>
<span className={styles.statLabel}>Sent:</span>
<span className={styles.statValue}>{formatBytes(latestStats.bytesSent)}</span>
</div>
)}
{latestStats.bytesReceived !== undefined && (
<div className={styles.statItem}>
<span className={styles.statLabel}>Received:</span>
<span className={styles.statValue}>{formatBytes(latestStats.bytesReceived)}</span>
</div>
)}
<div className={styles.statItem}>
<span className={styles.statLabel}>Cost:</span>
<span className={styles.statValue}>{_formatCurrency(latestStats.priceCHF)}</span>
</div>
</div>
)}
</div>

View file

@ -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;
}

View file

@ -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<WorkflowLog[]>([]);
const [dashboardLogs, setDashboardLogs] = useState<WorkflowLog[]>([]);
const [unifiedContentLogs, setUnifiedContentLogs] = useState<WorkflowLog[]>([]);
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<string>('idle');
const lastRenderedTimestampRef = useRef<number | null>(null);
const processedStatIdsRef = useRef<Set<string>>(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" ===

View file

@ -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<any>;
}
const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, onCreateCheckout }) => {
const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, onAddCredit }) => {
const [selectedUserId, setSelectedUserId] = useState<string>('');
const [amount, setAmount] = useState<number>(10);
const [amount, setAmount] = useState<string>('');
const [description, setDescription] = useState<string>('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<CreditAdderProps> = ({ settings, accounts, users, on
return map;
}, {} as Record<string, AccountSummary>);
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<CreditAdderProps> = ({ 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 (
<div className={styles.adminSection}>
<h3>Guthaben aufladen</h3>
<h3>Guthaben manuell aufladen</h3>
{message && (
<div className={message.type === 'success' ? styles.successMessage : styles.errorMessage}>
@ -250,7 +250,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
</div>
)}
<form onSubmit={handleSubmit}>
<form onSubmit={_handleSubmit}>
{isPrepayUser && (
<div className={styles.formRow}>
<div className={styles.formGroup}>
@ -264,7 +264,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
<option value="">-- Benutzer wählen --</option>
{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 (
<option key={user.id} value={user.id}>
{user.displayName || user.username || user.id}{balanceInfo}
@ -279,27 +279,35 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>Betrag (CHF)</label>
<select
className={styles.select}
<input
type="number"
className={styles.input}
value={amount}
onChange={(e) => setAmount(Number(e.target.value))}
onChange={(e) => setAmount(e.target.value)}
placeholder="z.B. 50"
min="0.01"
step="0.01"
required
>
{STRIPE_AMOUNT_PRESETS.map((preset) => (
<option key={preset} value={preset}>
{preset} CHF
</option>
))}
</select>
/>
</div>
<div className={styles.formGroup}>
<label>Beschreibung</label>
<input
type="text"
className={styles.input}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Beschreibung der Gutschrift"
/>
</div>
</div>
<button
type="submit"
className={`${styles.button} ${styles.buttonPrimary}`}
disabled={saving || (isPrepayUser && !selectedUserId)}
disabled={saving || (isPrepayUser && !selectedUserId) || !amount}
>
{saving ? 'Weiterleitung zu Stripe...' : 'Mit Stripe aufladen'}
{saving ? 'Wird gutgeschrieben...' : 'Manuell aufladen'}
</button>
</form>
</div>
@ -370,18 +378,8 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, lo
// ============================================================================
export const BillingAdmin: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(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 (
<div className={styles.billingDashboard}>
@ -413,19 +405,6 @@ export const BillingAdmin: React.FC = () => {
<p className={styles.subtitle}>Verwaltung von Abrechnungseinstellungen und Guthaben</p>
</header>
{successParam === 'true' && (
<div className={styles.successMessage} style={{ marginBottom: '1rem' }}>
Zahlung erfolgreich. Guthaben wird gutgeschrieben.
<button type="button" onClick={clearStripeParams} style={{ marginLeft: '1rem', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline' }}>Schliessen</button>
</div>
)}
{canceledParam === 'true' && (
<div className={styles.errorMessage} style={{ marginBottom: '1rem' }}>
Zahlung abgebrochen.
<button type="button" onClick={clearStripeParams} style={{ marginLeft: '1rem', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline' }}>Schliessen</button>
</div>
)}
<section className={styles.section}>
<MandateSelector
selectedMandateId={selectedMandateId}
@ -445,7 +424,7 @@ export const BillingAdmin: React.FC = () => {
settings={settings}
accounts={accounts}
users={users}
onCreateCheckout={handleCreateCheckout}
onAddCredit={_handleAddCredit}
/>
<AccountsOverview

View file

@ -8,14 +8,19 @@
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport';
import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport';
import api from '../../api';
import { useApiRequest } from '../../hooks/useApi';
import { useBilling, type BillingBalance } from '../../hooks/useBilling';
import { UserTransaction } from '../../api/billingApi';
import { createCheckoutSession, UserTransaction } from '../../api/billingApi';
import { getUserDataCache } from '../../utils/userCache';
import styles from './Billing.module.css';
const STRIPE_AMOUNT_PRESETS = [10, 25, 50, 100, 250, 500];
// ============================================================================
// HELPER: Currency formatter
// ============================================================================
@ -47,9 +52,14 @@ interface ViewStatistics {
interface BalanceCardProps {
balance: BillingBalance;
onCheckout?: (mandateId: string, amount: number) => void;
checkoutLoading?: boolean;
}
const BalanceCard: React.FC<BalanceCardProps> = ({ balance }) => {
const BalanceCard: React.FC<BalanceCardProps> = ({ 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)';
@ -60,6 +70,10 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance }) => {
}
};
const canTopUp = balance.billingModel === 'PREPAY_USER'
|| balance.billingModel === 'PREPAY_MANDATE'
|| balance.billingModel === 'CREDIT_POSTPAY';
return (
<div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}>
<div className={styles.balanceHeader}>
@ -74,6 +88,47 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance }) => {
Niedriges Guthaben
</div>
)}
{canTopUp && onCheckout && (
<div style={{ marginTop: '12px' }}>
{!showCheckout ? (
<button
className={`${styles.button} ${styles.buttonPrimary}`}
style={{ width: '100%', fontSize: '13px', padding: '6px 12px' }}
onClick={() => setShowCheckout(true)}
>
Budget laden mit Kreditkarte
</button>
) : (
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<select
className={styles.select}
value={selectedAmount}
onChange={(e) => setSelectedAmount(Number(e.target.value))}
style={{ flex: 1, fontSize: '13px' }}
>
{STRIPE_AMOUNT_PRESETS.map((preset) => (
<option key={preset} value={preset}>{preset} CHF</option>
))}
</select>
<button
className={`${styles.button} ${styles.buttonPrimary}`}
style={{ fontSize: '13px', padding: '6px 12px', whiteSpace: 'nowrap' }}
disabled={checkoutLoading}
onClick={() => onCheckout(balance.mandateId, selectedAmount)}
>
{checkoutLoading ? 'Laden...' : 'Zahlen'}
</button>
<button
className={`${styles.button} ${styles.buttonSecondary || ''}`}
style={{ fontSize: '13px', padding: '6px 12px' }}
onClick={() => setShowCheckout(false)}
>
&times;
</button>
</div>
)}
</div>
)}
</div>
);
};
@ -265,6 +320,10 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] {
export const BillingDataView: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabType>('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<string>('personal');
@ -273,8 +332,47 @@ export const BillingDataView: React.FC = () => {
const {
balances,
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<any[]>([]);
const [allUserBalancesLoading, setAllUserBalancesLoading] = useState(false);
@ -475,6 +573,15 @@ export const BillingDataView: React.FC = () => {
<TabNav activeTab={activeTab} onTabChange={setActiveTab} />
{checkoutMessage && (
<div className={checkoutMessage.type === 'success' ? styles.successMessage : styles.errorMessage} style={{ marginBottom: '1rem' }}>
{checkoutMessage.text}
{(successParam || canceledParam) && (
<button type="button" onClick={_clearStripeParams} style={{ marginLeft: '1rem', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline', color: 'inherit' }}>Schliessen</button>
)}
</div>
)}
{/* ================================================================ */}
{/* Tab: Übersicht (My Overview) */}
{/* ================================================================ */}
@ -502,7 +609,12 @@ export const BillingDataView: React.FC = () => {
) : (
<div className={styles.balanceGrid}>
{filteredBalances.map((balance) => (
<BalanceCard key={balance.mandateId} balance={balance} />
<BalanceCard
key={balance.mandateId}
balance={balance}
onCheckout={_handleCheckout}
checkoutLoading={checkoutLoading}
/>
))}
</div>
)}

View file

@ -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 = () => {
<div className={styles.headerLeft}>
<div className={styles.headerTitleRow}>
<h1 className={styles.pageTitle}>Chat Playground</h1>
{/* Stats display in header */}
<div className={styles.headerStats}>
<span className={styles.headerStatItem} title="Daten gesendet / empfangen">
{formatBytes(latestStats?.bytesSent || 0)} / {formatBytes(latestStats?.bytesReceived || 0)}
</span>
{(latestStats?.processingTime ?? 0) > 0 && (
<span className={styles.headerStatItem} title="Verarbeitungszeit">
{formatDuration(latestStats?.processingTime || 0)}
</span>
)}
{(latestStats?.priceUsd ?? 0) > 0 && (
{latestStats?.priceCHF != null && latestStats.priceCHF > 0 && (
<div className={styles.headerStats}>
<span className={styles.headerStatItem} title="Kosten">
💰 CHF {(latestStats?.priceUsd || 0).toFixed(4)}
CHF {latestStats.priceCHF.toFixed(2)}
</span>
)}
</div>
</div>
)}
</div>
<p className={styles.pageSubtitle}>Workflow-Ausführung und Chat-Interaktion</p>
</div>