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 {
|
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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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" ===
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue