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 { export interface ChatDataResponse {
messages: WorkflowMessage[]; messages: WorkflowMessage[];
logs: WorkflowLog[]; logs: WorkflowLog[];
stats: WorkflowStats[];
documents: WorkflowDocument[]; documents: WorkflowDocument[];
workflowCost: number;
} }
// Type for the request function passed to API functions // Type for the request function passed to API functions
@ -259,35 +259,25 @@ export async function fetchChatData(
console.log('📥 fetchChatData response:', data); 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)) { if (data.items && Array.isArray(data.items)) {
const messages: WorkflowMessage[] = []; const messages: WorkflowMessage[] = [];
const logs: WorkflowLog[] = []; const logs: WorkflowLog[] = [];
const stats: WorkflowStats[] = [];
const documents: WorkflowDocument[] = []; const documents: WorkflowDocument[] = [];
data.items.forEach((item: any) => { data.items.forEach((item: any) => {
if (item.type === 'message') { if (item.type === 'message') {
// Handle both formats: item.item or direct item data
const messageData = item.item || item; const messageData = item.item || item;
if (messageData && (messageData.id || messageData.message)) { if (messageData && (messageData.id || messageData.message)) {
messages.push(messageData); messages.push(messageData);
} else {
console.warn('⚠️ Invalid message item:', item);
} }
} else if (item.type === 'log') { } else if (item.type === 'log') {
const logData = item.item || item; const logData = item.item || item;
if (logData) { if (logData) {
logs.push(logData); logs.push(logData);
} }
} else if (item.type === 'stat') { } else if (item.type === 'document') {
const statData = item.item || item;
if (statData) {
stats.push(statData);
}
}
// Documents might be in items or separate
if (item.type === 'document') {
const docData = item.item || item; const docData = item.item || item;
if (docData) { if (docData) {
documents.push(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 { return {
messages, messages,
logs, 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 { return {
messages: Array.isArray(data.messages) ? data.messages : [], messages: Array.isArray(data.messages) ? data.messages : [],
logs: Array.isArray(data.logs) ? data.logs : [], 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 _formatCurrency = (amount?: number): string => {
const formatBytes = (bytes?: number): string => { if (amount === undefined || amount === null) return '-';
if (bytes === undefined || bytes === null) return '-'; return `${amount.toFixed(2)} CHF`;
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 WorkflowStatus: React.FC<WorkflowStatusProps> = ({ const WorkflowStatus: React.FC<WorkflowStatusProps> = ({
@ -185,33 +166,13 @@ const WorkflowStatus: React.FC<WorkflowStatusProps> = ({
)} )}
</div> </div>
{/* Stats Display */} {/* Cost Display */}
{latestStats && ( {latestStats && latestStats.priceCHF !== undefined && (
<div className={styles.statsContainer}> <div className={styles.statsContainer}>
{latestStats.priceUsd !== undefined && ( <div className={styles.statItem}>
<div className={styles.statItem}> <span className={styles.statLabel}>Cost:</span>
<span className={styles.statLabel}>Price:</span> <span className={styles.statValue}>{_formatCurrency(latestStats.priceCHF)}</span>
<span className={styles.statValue}>{formatPrice(latestStats.priceUsd)}</span> </div>
</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> </div>
)} )}
</div> </div>

View file

@ -44,13 +44,10 @@ export interface WorkflowStatusProps {
isRunning?: boolean; isRunning?: boolean;
/** /**
* Latest statistics from the workflow (price, processing time, bytes sent/received) * Latest cost from billing transactions (single source of truth)
*/ */
latestStats?: { latestStats?: {
priceUsd?: number; priceCHF?: number;
processingTime?: number;
bytesSent?: number;
bytesReceived?: number;
} | null; } | null;
} }

View file

@ -14,8 +14,8 @@ import { useWorkflowPolling } from './useWorkflowPolling';
import { getWorkflowApiBaseUrl } from '../useWorkflows'; import { getWorkflowApiBaseUrl } from '../useWorkflows';
interface UnifiedChatDataItem { interface UnifiedChatDataItem {
type: 'message' | 'log' | 'stat'; type: 'message' | 'log';
item: WorkflowMessage | WorkflowLog | any; item: WorkflowMessage | WorkflowLog;
createdAt: number; createdAt: number;
} }
@ -76,13 +76,11 @@ export function useWorkflowLifecycle(instanceId: string) {
const [logs, setLogs] = useState<WorkflowLog[]>([]); const [logs, setLogs] = useState<WorkflowLog[]>([]);
const [dashboardLogs, setDashboardLogs] = useState<WorkflowLog[]>([]); const [dashboardLogs, setDashboardLogs] = useState<WorkflowLog[]>([]);
const [unifiedContentLogs, setUnifiedContentLogs] = useState<WorkflowLog[]>([]); const [unifiedContentLogs, setUnifiedContentLogs] = useState<WorkflowLog[]>([]);
const [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 === // === REFS FOR SYNC ACCESS ===
const statusRef = useRef<string>('idle'); const statusRef = useRef<string>('idle');
const lastRenderedTimestampRef = useRef<number | null>(null); 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 === // === KEY STATE MACHINE FLAG ===
// This flag tracks if the UI has rendered a message with status="last" // This flag tracks if the UI has rendered a message with status="last"
@ -124,17 +122,15 @@ export function useWorkflowLifecycle(instanceId: string) {
}, [workflowId]); }, [workflowId]);
// === CORE: Process unified chat data === // === 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:', { console.log('🔄 Processing chat data:', {
messages: chatData.messages?.length || 0, messages: chatData.messages?.length || 0,
logs: chatData.logs?.length || 0, logs: chatData.logs?.length || 0,
stats: chatData.stats?.length || 0 workflowCost: chatData.workflowCost ?? 0
}); });
// Build unified timeline
const timeline: UnifiedChatDataItem[] = []; const timeline: UnifiedChatDataItem[] = [];
// Add messages
(chatData.messages || []).forEach((message: WorkflowMessage) => { (chatData.messages || []).forEach((message: WorkflowMessage) => {
timeline.push({ timeline.push({
type: 'message', type: 'message',
@ -143,7 +139,6 @@ export function useWorkflowLifecycle(instanceId: string) {
}); });
}); });
// Add logs
(chatData.logs || []).forEach((log: any) => { (chatData.logs || []).forEach((log: any) => {
timeline.push({ timeline.push({
type: 'log', 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); timeline.sort((a, b) => a.createdAt - b.createdAt);
// Update lastRenderedTimestamp // Update lastRenderedTimestamp
@ -290,44 +274,9 @@ export function useWorkflowLifecycle(instanceId: string) {
return [...allLogs].sort(sortLogs); return [...allLogs].sort(sortLogs);
}); });
// === PROCESS STATS === // === UPDATE COST from billing transactions (single source of truth) ===
const statsItems = timeline.filter(item => item.type === 'stat'); const cost = chatData.workflowCost ?? 0;
setLatestStats(cost > 0 ? { priceCHF: cost } : null);
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
});
}
}
}, [convertLogToFrontendFormat]); }, [convertLogToFrontendFormat]);
// === POLLING FUNCTION === // === POLLING FUNCTION ===
@ -359,7 +308,7 @@ export function useWorkflowLifecycle(instanceId: string) {
console.log('📊 Polled chat data:', { console.log('📊 Polled chat data:', {
messages: chatData.messages?.length || 0, messages: chatData.messages?.length || 0,
logs: chatData.logs?.length || 0, logs: chatData.logs?.length || 0,
stats: chatData.stats?.length || 0, workflowCost: chatData.workflowCost ?? 0,
afterTimestamp afterTimestamp
}); });
@ -496,10 +445,7 @@ export function useWorkflowLifecycle(instanceId: string) {
setUnifiedContentLogs([]); setUnifiedContentLogs([]);
setLatestStats(null); setLatestStats(null);
// Reset refs
lastRenderedTimestampRef.current = null; lastRenderedTimestampRef.current = null;
processedStatIdsRef.current.clear();
cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 };
hasRenderedLastMessageRef.current = false; hasRenderedLastMessageRef.current = false;
setHasRenderedLastMessage(false); setHasRenderedLastMessage(false);
@ -511,13 +457,11 @@ export function useWorkflowLifecycle(instanceId: string) {
try { try {
console.log('📥 Loading workflow:', workflowIdToSelect); console.log('📥 Loading workflow:', workflowIdToSelect);
// Reset state
setWorkflowId(workflowIdToSelect); setWorkflowId(workflowIdToSelect);
lastRenderedTimestampRef.current = null; lastRenderedTimestampRef.current = null;
processedStatIdsRef.current.clear();
cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 };
hasRenderedLastMessageRef.current = false; hasRenderedLastMessageRef.current = false;
setHasRenderedLastMessage(false); setHasRenderedLastMessage(false);
setLatestStats(null);
// Fetch workflow data // Fetch workflow data
const workflowData = await fetchWorkflowApi(request, workflowIdToSelect, apiBaseUrl).catch(() => null); const workflowData = await fetchWorkflowApi(request, workflowIdToSelect, apiBaseUrl).catch(() => null);
@ -544,7 +488,7 @@ export function useWorkflowLifecycle(instanceId: string) {
console.log('📥 Loaded chat data:', { console.log('📥 Loaded chat data:', {
messages: chatData.messages?.length || 0, messages: chatData.messages?.length || 0,
logs: chatData.logs?.length || 0, logs: chatData.logs?.length || 0,
stats: chatData.stats?.length || 0 workflowCost: chatData.workflowCost ?? 0
}); });
// === STATE MACHINE: Check if last message has status="last" === // === STATE MACHINE: Check if last message has status="last" ===

View file

@ -8,12 +8,16 @@
*/ */
import React, { useState, useEffect, useCallback, useMemo } from 'react'; 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 { useBillingAdmin, type BillingSettings, type AccountSummary, type MandateUserSummary } from '../../hooks/useBilling';
import { useAdminMandates } from '../../hooks/useMandates'; import { useAdminMandates } from '../../hooks/useMandates';
import styles from './Billing.module.css'; 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 // MANDATE SELECTOR
@ -195,18 +199,18 @@ interface CreditAdderProps {
settings: BillingSettings | null; settings: BillingSettings | null;
accounts: AccountSummary[]; accounts: AccountSummary[];
users: MandateUserSummary[]; 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 [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 [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const isPrepayUser = settings?.billingModel === 'PREPAY_USER'; const isPrepayUser = settings?.billingModel === 'PREPAY_USER';
// Map accounts by userId for balance lookup
const accountsByUserId = accounts const accountsByUserId = accounts
.filter(acc => acc.accountType === 'USER') .filter(acc => acc.accountType === 'USER')
.reduce((map, acc) => { .reduce((map, acc) => {
@ -214,9 +218,10 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
return map; return map;
}, {} as Record<string, AccountSummary>); }, {} as Record<string, AccountSummary>);
const handleSubmit = async (e: React.FormEvent) => { const _handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (amount <= 0) { const numAmount = parseFloat(amount);
if (!numAmount || numAmount <= 0) {
setMessage({ type: 'error', text: 'Betrag muss positiv sein' }); setMessage({ type: 'error', text: 'Betrag muss positiv sein' });
return; return;
} }
@ -225,24 +230,19 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
setMessage(null); setMessage(null);
try { try {
const { redirectUrl } = await onCreateCheckout(isPrepayUser ? selectedUserId : undefined, amount); await onAddCredit(isPrepayUser ? selectedUserId : undefined, numAmount, description);
window.location.href = redirectUrl; setMessage({ type: 'success', text: `${_formatCurrency(numAmount)} erfolgreich gutgeschrieben.` });
setAmount('');
} catch (err: any) { } catch (err: any) {
setMessage({ type: 'error', text: err.message || 'Fehler beim Aufladen' }); setMessage({ type: 'error', text: err.message || 'Fehler beim Aufladen' });
} finally {
setSaving(false); setSaving(false);
} }
}; };
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(amount);
};
return ( return (
<div className={styles.adminSection}> <div className={styles.adminSection}>
<h3>Guthaben aufladen</h3> <h3>Guthaben manuell aufladen</h3>
{message && ( {message && (
<div className={message.type === 'success' ? styles.successMessage : styles.errorMessage}> <div className={message.type === 'success' ? styles.successMessage : styles.errorMessage}>
@ -250,7 +250,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
</div> </div>
)} )}
<form onSubmit={handleSubmit}> <form onSubmit={_handleSubmit}>
{isPrepayUser && ( {isPrepayUser && (
<div className={styles.formRow}> <div className={styles.formRow}>
<div className={styles.formGroup}> <div className={styles.formGroup}>
@ -264,7 +264,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
<option value="">-- Benutzer wählen --</option> <option value="">-- Benutzer wählen --</option>
{users.map((user) => { {users.map((user) => {
const account = accountsByUserId[user.id]; const account = accountsByUserId[user.id];
const balanceInfo = account ? ` (${formatCurrency(account.balance)})` : ' (kein Konto)'; const balanceInfo = account ? ` (${_formatCurrency(account.balance)})` : ' (kein Konto)';
return ( return (
<option key={user.id} value={user.id}> <option key={user.id} value={user.id}>
{user.displayName || user.username || user.id}{balanceInfo} {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.formRow}>
<div className={styles.formGroup}> <div className={styles.formGroup}>
<label>Betrag (CHF)</label> <label>Betrag (CHF)</label>
<select <input
className={styles.select} type="number"
className={styles.input}
value={amount} 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 required
> />
{STRIPE_AMOUNT_PRESETS.map((preset) => ( </div>
<option key={preset} value={preset}> <div className={styles.formGroup}>
{preset} CHF <label>Beschreibung</label>
</option> <input
))} type="text"
</select> className={styles.input}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Beschreibung der Gutschrift"
/>
</div> </div>
</div> </div>
<button <button
type="submit" type="submit"
className={`${styles.button} ${styles.buttonPrimary}`} 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> </button>
</form> </form>
</div> </div>
@ -370,18 +378,8 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, lo
// ============================================================================ // ============================================================================
export const BillingAdmin: React.FC = () => { export const BillingAdmin: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null); const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
const { settings, accounts, users, loading, saveSettings, createCheckout, loadAccounts } = useBillingAdmin(selectedMandateId || undefined); const { settings, accounts, users, loading, saveSettings, addCredit, loadAccounts } = useBillingAdmin(selectedMandateId || undefined);
const successParam = searchParams.get('success');
const canceledParam = searchParams.get('canceled');
useEffect(() => {
if (successParam === 'true' && selectedMandateId) {
loadAccounts();
}
}, [successParam, selectedMandateId, loadAccounts]);
const handleMandateSelect = (mandateId: string) => { const handleMandateSelect = (mandateId: string) => {
setSelectedMandateId(mandateId || null); setSelectedMandateId(mandateId || null);
@ -392,19 +390,13 @@ export const BillingAdmin: React.FC = () => {
await saveSettings(settingsUpdate); await saveSettings(settingsUpdate);
}, [selectedMandateId, saveSettings]); }, [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'); if (!selectedMandateId) throw new Error('Mandant nicht ausgewählt');
const result = await createCheckout({ userId, amount }); const result = await addCredit({ userId, amount, description });
if (!result) throw new Error('Checkout konnte nicht erstellt werden'); if (!result) throw new Error('Gutschrift konnte nicht erstellt werden');
await loadAccounts();
return result; return result;
}, [selectedMandateId, createCheckout]); }, [selectedMandateId, addCredit, loadAccounts]);
const clearStripeParams = useCallback(() => {
searchParams.delete('success');
searchParams.delete('canceled');
searchParams.delete('session_id');
setSearchParams(searchParams, { replace: true });
}, [searchParams, setSearchParams]);
return ( return (
<div className={styles.billingDashboard}> <div className={styles.billingDashboard}>
@ -413,19 +405,6 @@ export const BillingAdmin: React.FC = () => {
<p className={styles.subtitle}>Verwaltung von Abrechnungseinstellungen und Guthaben</p> <p className={styles.subtitle}>Verwaltung von Abrechnungseinstellungen und Guthaben</p>
</header> </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}> <section className={styles.section}>
<MandateSelector <MandateSelector
selectedMandateId={selectedMandateId} selectedMandateId={selectedMandateId}
@ -445,7 +424,7 @@ export const BillingAdmin: React.FC = () => {
settings={settings} settings={settings}
accounts={accounts} accounts={accounts}
users={users} users={users}
onCreateCheckout={handleCreateCheckout} onAddCredit={_handleAddCredit}
/> />
<AccountsOverview <AccountsOverview

View file

@ -8,14 +8,19 @@
*/ */
import React, { useState, useEffect, useMemo, useCallback } from 'react'; import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport'; import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport';
import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport'; import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport';
import api from '../../api'; import api from '../../api';
import { useApiRequest } from '../../hooks/useApi';
import { useBilling, type BillingBalance } from '../../hooks/useBilling'; 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'; import styles from './Billing.module.css';
const STRIPE_AMOUNT_PRESETS = [10, 25, 50, 100, 250, 500];
// ============================================================================ // ============================================================================
// HELPER: Currency formatter // HELPER: Currency formatter
// ============================================================================ // ============================================================================
@ -47,9 +52,14 @@ interface ViewStatistics {
interface BalanceCardProps { interface BalanceCardProps {
balance: BillingBalance; 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) => { const _getBillingModelLabel = (model: string) => {
switch (model) { switch (model) {
case 'PREPAY_MANDATE': return 'Prepaid (Mandant)'; case 'PREPAY_MANDATE': return 'Prepaid (Mandant)';
@ -59,7 +69,11 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance }) => {
default: return model; default: return model;
} }
}; };
const canTopUp = balance.billingModel === 'PREPAY_USER'
|| balance.billingModel === 'PREPAY_MANDATE'
|| balance.billingModel === 'CREDIT_POSTPAY';
return ( return (
<div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}> <div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}>
<div className={styles.balanceHeader}> <div className={styles.balanceHeader}>
@ -74,6 +88,47 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance }) => {
Niedriges Guthaben Niedriges Guthaben
</div> </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> </div>
); );
}; };
@ -265,6 +320,10 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] {
export const BillingDataView: React.FC = () => { export const BillingDataView: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabType>('overview'); 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 // Scope filter: 'personal' | 'all' | mandateId
const [selectedScope, setSelectedScope] = useState<string>('personal'); const [selectedScope, setSelectedScope] = useState<string>('personal');
@ -272,9 +331,48 @@ export const BillingDataView: React.FC = () => {
// Dashboard state (for Overview tab) // Dashboard state (for Overview tab)
const { const {
balances, balances,
loading: dashboardLoading, loading: dashboardLoading,
refetch: refetchBalances,
} = useBilling(); } = 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) // All user balances (for admin overview cards)
const [allUserBalances, setAllUserBalances] = useState<any[]>([]); const [allUserBalances, setAllUserBalances] = useState<any[]>([]);
const [allUserBalancesLoading, setAllUserBalancesLoading] = useState(false); const [allUserBalancesLoading, setAllUserBalancesLoading] = useState(false);
@ -475,6 +573,15 @@ export const BillingDataView: React.FC = () => {
<TabNav activeTab={activeTab} onTabChange={setActiveTab} /> <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) */} {/* Tab: Übersicht (My Overview) */}
{/* ================================================================ */} {/* ================================================================ */}
@ -502,7 +609,12 @@ export const BillingDataView: React.FC = () => {
) : ( ) : (
<div className={styles.balanceGrid}> <div className={styles.balanceGrid}>
{filteredBalances.map((balance) => ( {filteredBalances.map((balance) => (
<BalanceCard key={balance.mandateId} balance={balance} /> <BalanceCard
key={balance.mandateId}
balance={balance}
onCheckout={_handleCheckout}
checkoutLoading={checkoutLoading}
/>
))} ))}
</div> </div>
)} )}

View file

@ -128,25 +128,6 @@ export const PlaygroundPage: React.FC = () => {
} }
}, [urlWorkflowId, onWorkflowSelect]); }, [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 // Handle prompt selection
const handlePromptSelect = (promptId: string) => { const handlePromptSelect = (promptId: string) => {
setSelectedPromptId(promptId); setSelectedPromptId(promptId);
@ -589,22 +570,13 @@ export const PlaygroundPage: React.FC = () => {
<div className={styles.headerLeft}> <div className={styles.headerLeft}>
<div className={styles.headerTitleRow}> <div className={styles.headerTitleRow}>
<h1 className={styles.pageTitle}>Chat Playground</h1> <h1 className={styles.pageTitle}>Chat Playground</h1>
{/* Stats display in header */} {latestStats?.priceCHF != null && latestStats.priceCHF > 0 && (
<div className={styles.headerStats}> <div className={styles.headerStats}>
<span className={styles.headerStatItem} title="Daten gesendet / empfangen">
{formatBytes(latestStats?.bytesSent || 0)} / {formatBytes(latestStats?.bytesReceived || 0)}
</span>
{(latestStats?.processingTime ?? 0) > 0 && (
<span className={styles.headerStatItem} title="Verarbeitungszeit">
{formatDuration(latestStats?.processingTime || 0)}
</span>
)}
{(latestStats?.priceUsd ?? 0) > 0 && (
<span className={styles.headerStatItem} title="Kosten"> <span className={styles.headerStatItem} title="Kosten">
💰 CHF {(latestStats?.priceUsd || 0).toFixed(4)} CHF {latestStats.priceCHF.toFixed(2)}
</span> </span>
)} </div>
</div> )}
</div> </div>
<p className={styles.pageSubtitle}>Workflow-Ausführung und Chat-Interaktion</p> <p className={styles.pageSubtitle}>Workflow-Ausführung und Chat-Interaktion</p>
</div> </div>