removed ChatStats, only billing and transactions relevant
This commit is contained in:
parent
9cad69fd0c
commit
869d1f24c3
7 changed files with 199 additions and 252 deletions
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<span className={styles.statLabel}>Cost:</span>
|
||||
<span className={styles.statValue}>{_formatCurrency(latestStats.priceCHF)}</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>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" ===
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
>
|
||||
×
|
||||
</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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
{latestStats?.priceCHF != null && latestStats.priceCHF > 0 && (
|
||||
<div className={styles.headerStats}>
|
||||
<span className={styles.headerStatItem} title="Daten gesendet / empfangen">
|
||||
↑ {formatBytes(latestStats?.bytesSent || 0)} / ↓ {formatBytes(latestStats?.bytesReceived || 0)}
|
||||
</span>
|
||||
{(latestStats?.processingTime ?? 0) > 0 && (
|
||||
<span className={styles.headerStatItem} title="Verarbeitungszeit">
|
||||
⏱️ {formatDuration(latestStats?.processingTime || 0)}
|
||||
</span>
|
||||
)}
|
||||
{(latestStats?.priceUsd ?? 0) > 0 && (
|
||||
<span className={styles.headerStatItem} title="Kosten">
|
||||
💰 CHF {(latestStats?.priceUsd || 0).toFixed(4)}
|
||||
CHF {latestStats.priceCHF.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className={styles.pageSubtitle}>Workflow-Ausführung und Chat-Interaktion</p>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue