fina autoamtiona adn billing and credits
This commit is contained in:
@@ -59,6 +59,10 @@ const ImageTesting = lazy(() => import("./pages/Thinker/ImageTesting"));
|
||||
const Credits = lazy(() => import("./pages/Billing/Credits"));
|
||||
const Transactions = lazy(() => import("./pages/Billing/Transactions"));
|
||||
const Usage = lazy(() => import("./pages/Billing/Usage"));
|
||||
const CreditsAndBilling = lazy(() => import("./pages/Settings/CreditsAndBilling"));
|
||||
|
||||
// Admin Module - Lazy loaded
|
||||
const AdminBilling = lazy(() => import("./pages/Admin/AdminBilling"));
|
||||
|
||||
// Reference Data - Lazy loaded
|
||||
const SeedKeywords = lazy(() => import("./pages/Reference/SeedKeywords"));
|
||||
@@ -326,6 +330,12 @@ export default function App() {
|
||||
} />
|
||||
|
||||
{/* Billing Module */}
|
||||
<Route path="/billing" element={<Navigate to="/billing/overview" replace />} />
|
||||
<Route path="/billing/overview" element={
|
||||
<Suspense fallback={null}>
|
||||
<CreditsAndBilling />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/billing/credits" element={
|
||||
<Suspense fallback={null}>
|
||||
<Credits />
|
||||
@@ -342,6 +352,13 @@ export default function App() {
|
||||
</Suspense>
|
||||
} />
|
||||
|
||||
{/* Admin Routes */}
|
||||
<Route path="/admin/billing" element={
|
||||
<Suspense fallback={null}>
|
||||
<AdminBilling />
|
||||
</Suspense>
|
||||
} />
|
||||
|
||||
{/* Reference Data */}
|
||||
<Route path="/reference/seed-keywords" element={
|
||||
<Suspense fallback={null}>
|
||||
|
||||
184
frontend/src/components/Automation/CurrentProcessingCard.old.tsx
Normal file
184
frontend/src/components/Automation/CurrentProcessingCard.old.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Current Processing Card Component
|
||||
* Shows real-time automation progress with currently processing items
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { automationService, ProcessingState } from '../../services/automationService';
|
||||
|
||||
interface CurrentProcessingCardProps {
|
||||
runId: string;
|
||||
siteId: number;
|
||||
currentStage: number;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
const CurrentProcessingCard: React.FC<CurrentProcessingCardProps> = ({
|
||||
runId,
|
||||
siteId,
|
||||
currentStage,
|
||||
onComplete,
|
||||
}) => {
|
||||
const [processingState, setProcessingState] = useState<ProcessingState | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const fetchState = async () => {
|
||||
try {
|
||||
const state = await automationService.getCurrentProcessing(siteId, runId);
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
setProcessingState(state);
|
||||
setError(null);
|
||||
|
||||
// If stage completed (all items processed), trigger refresh
|
||||
if (state && state.processed_items >= state.total_items && state.total_items > 0) {
|
||||
onComplete?.();
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isMounted) return;
|
||||
console.error('Error fetching processing state:', err);
|
||||
setError('Failed to load processing state');
|
||||
}
|
||||
};
|
||||
|
||||
// Initial fetch
|
||||
fetchState();
|
||||
|
||||
// Poll every 3 seconds
|
||||
const interval = setInterval(fetchState, 3000);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [siteId, runId, onComplete]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border-2 border-red-500 rounded-lg p-4 mb-6">
|
||||
<p className="text-red-700 dark:text-red-300 text-sm">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!processingState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const percentage = processingState.percentage;
|
||||
|
||||
return (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-500 rounded-lg p-6 mb-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-pulse">
|
||||
<svg
|
||||
className="w-8 h-8 text-blue-600 dark:text-blue-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Automation In Progress
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Stage {currentStage}: {processingState.stage_name}
|
||||
<span className="ml-2 px-2 py-0.5 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded text-xs">
|
||||
{processingState.stage_type}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-4xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{percentage}%
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{processingState.processed_items}/{processingState.total_items} processed
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-6">
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3">
|
||||
<div
|
||||
className="bg-blue-600 dark:bg-blue-500 h-3 rounded-full transition-all duration-500"
|
||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Currently Processing and Up Next */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Currently Processing */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Currently Processing:
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{processingState.currently_processing.length > 0 ? (
|
||||
processingState.currently_processing.map((item, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2 text-sm">
|
||||
<span className="text-blue-600 dark:text-blue-400 mt-1">•</span>
|
||||
<span className="text-gray-800 dark:text-gray-200 font-medium line-clamp-2">
|
||||
{item.title}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
No items currently processing
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Up Next */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Up Next:
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{processingState.up_next.length > 0 ? (
|
||||
<>
|
||||
{processingState.up_next.map((item, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2 text-sm">
|
||||
<span className="text-gray-400 dark:text-gray-500 mt-1">•</span>
|
||||
<span className="text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
{item.title}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{processingState.remaining_count > processingState.up_next.length + processingState.currently_processing.length && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
+ {processingState.remaining_count - processingState.up_next.length - processingState.currently_processing.length} more in queue
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
Queue empty
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CurrentProcessingCard;
|
||||
390
frontend/src/components/Automation/CurrentProcessingCard.tsx
Normal file
390
frontend/src/components/Automation/CurrentProcessingCard.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* Current Processing Card Component
|
||||
* Shows real-time automation progress with pause/resume/cancel controls
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { automationService, ProcessingState, AutomationRun } from '../../services/automationService';
|
||||
import { useToast } from '../ui/toast/ToastContainer';
|
||||
import Button from '../ui/button/Button';
|
||||
import {
|
||||
PlayIcon,
|
||||
PauseIcon,
|
||||
XMarkIcon,
|
||||
ClockIcon,
|
||||
BoltIcon
|
||||
} from '../../icons';
|
||||
|
||||
interface CurrentProcessingCardProps {
|
||||
runId: string;
|
||||
siteId: number;
|
||||
currentRun: AutomationRun;
|
||||
onUpdate: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const CurrentProcessingCard: React.FC<CurrentProcessingCardProps> = ({
|
||||
runId,
|
||||
siteId,
|
||||
currentRun,
|
||||
onUpdate,
|
||||
onClose,
|
||||
}) => {
|
||||
const [processingState, setProcessingState] = useState<ProcessingState | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPausing, setIsPausing] = useState(false);
|
||||
const [isResuming, setIsResuming] = useState(false);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const fetchState = async () => {
|
||||
try {
|
||||
const state = await automationService.getCurrentProcessing(siteId, runId);
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
setProcessingState(state);
|
||||
setError(null);
|
||||
|
||||
// If stage completed (all items processed), trigger page refresh
|
||||
if (state && state.processed_items >= state.total_items && state.total_items > 0) {
|
||||
onUpdate();
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isMounted) return;
|
||||
console.error('Error fetching processing state:', err);
|
||||
setError('Failed to load processing state');
|
||||
}
|
||||
};
|
||||
|
||||
// Only fetch if status is running or paused
|
||||
if (currentRun.status === 'running' || currentRun.status === 'paused') {
|
||||
// Initial fetch
|
||||
fetchState();
|
||||
|
||||
// Poll every 3 seconds
|
||||
const interval = setInterval(fetchState, 3000);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [siteId, runId, currentRun.status, onUpdate]);
|
||||
|
||||
const handlePause = async () => {
|
||||
setIsPausing(true);
|
||||
try {
|
||||
await automationService.pause(siteId, runId);
|
||||
toast?.success('Automation pausing... will complete current item');
|
||||
// Trigger update to refresh run status
|
||||
setTimeout(onUpdate, 1000);
|
||||
} catch (error: any) {
|
||||
toast?.error(error?.message || 'Failed to pause automation');
|
||||
} finally {
|
||||
setIsPausing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResume = async () => {
|
||||
setIsResuming(true);
|
||||
try {
|
||||
await automationService.resume(siteId, runId);
|
||||
toast?.success('Automation resumed');
|
||||
// Trigger update to refresh run status
|
||||
setTimeout(onUpdate, 1000);
|
||||
} catch (error: any) {
|
||||
toast?.error(error?.message || 'Failed to resume automation');
|
||||
} finally {
|
||||
setIsResuming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!confirm('Are you sure you want to cancel this automation run? This cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCancelling(true);
|
||||
try {
|
||||
await automationService.cancel(siteId, runId);
|
||||
toast?.success('Automation cancelling... will complete current item');
|
||||
// Trigger update to refresh run status
|
||||
setTimeout(onUpdate, 1500);
|
||||
} catch (error: any) {
|
||||
toast?.error(error?.message || 'Failed to cancel automation');
|
||||
} finally {
|
||||
setIsCancelling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (startTime: string) => {
|
||||
const start = new Date(startTime).getTime();
|
||||
const now = Date.now();
|
||||
const diffMs = now - start;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
|
||||
if (diffHours > 0) {
|
||||
return `${diffHours}h ${diffMins % 60}m`;
|
||||
}
|
||||
return `${diffMins}m`;
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border-2 border-red-500 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-red-700 dark:text-red-300 text-sm">{error}</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-red-500 hover:text-red-700 dark:hover:text-red-300"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!processingState && currentRun.status === 'running') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const percentage = processingState?.percentage || 0;
|
||||
const isPaused = currentRun.status === 'paused';
|
||||
|
||||
return (
|
||||
<div className={`border-2 rounded-lg p-6 mb-6 ${
|
||||
isPaused
|
||||
? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500'
|
||||
: 'bg-blue-50 dark:bg-blue-900/20 border-blue-500'
|
||||
}`}>
|
||||
{/* Header Row with Main Info and Close */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
{/* Left Side - Main Info (75%) */}
|
||||
<div className="flex-1 pr-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className={isPaused ? '' : 'animate-pulse'}>
|
||||
{isPaused ? (
|
||||
<PauseIcon className="w-8 h-8 text-yellow-600 dark:text-yellow-400" />
|
||||
) : (
|
||||
<BoltIcon className="w-8 h-8 text-blue-600 dark:text-blue-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{isPaused ? 'Automation Paused' : 'Automation In Progress'}
|
||||
</h2>
|
||||
{processingState && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Stage {currentRun.current_stage}: {processingState.stage_name}
|
||||
<span className={`ml-2 px-2 py-0.5 rounded text-xs ${
|
||||
isPaused
|
||||
? 'bg-yellow-100 dark:bg-yellow-900 text-yellow-700 dark:text-yellow-300'
|
||||
: 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
|
||||
}`}>
|
||||
{processingState.stage_type}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Info */}
|
||||
{processingState && (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-3xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{percentage}%
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{processingState.processed_items}/{processingState.total_items} completed
|
||||
</div>
|
||||
</div>
|
||||
{/* Progress Bar */}
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3">
|
||||
<div
|
||||
className={`h-3 rounded-full transition-all duration-500 ${
|
||||
isPaused
|
||||
? 'bg-yellow-600 dark:bg-yellow-500'
|
||||
: 'bg-blue-600 dark:bg-blue-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Currently Processing and Up Next */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
{/* Currently Processing */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Currently Processing:
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{processingState.currently_processing.length > 0 ? (
|
||||
processingState.currently_processing.map((item, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2 text-sm">
|
||||
<span className={isPaused ? 'text-yellow-600 dark:text-yellow-400 mt-1' : 'text-blue-600 dark:text-blue-400 mt-1'}>•</span>
|
||||
<span className="text-gray-800 dark:text-gray-200 font-medium line-clamp-2">
|
||||
{item.title}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
{isPaused ? 'Paused' : 'No items currently processing'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Up Next */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Up Next:
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{processingState.up_next.length > 0 ? (
|
||||
<>
|
||||
{processingState.up_next.map((item, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2 text-sm">
|
||||
<span className="text-gray-400 dark:text-gray-500 mt-1">•</span>
|
||||
<span className="text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
{item.title}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{processingState.remaining_count > processingState.up_next.length + processingState.currently_processing.length && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
+ {processingState.remaining_count - processingState.up_next.length - processingState.currently_processing.length} more in queue
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
Queue empty
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Control Buttons */}
|
||||
<div className="flex items-center gap-3">
|
||||
{currentRun.status === 'running' ? (
|
||||
<Button
|
||||
onClick={handlePause}
|
||||
disabled={isPausing}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<PauseIcon className="w-4 h-4 mr-2" />
|
||||
{isPausing ? 'Pausing...' : 'Pause'}
|
||||
</Button>
|
||||
) : currentRun.status === 'paused' ? (
|
||||
<Button
|
||||
onClick={handleResume}
|
||||
disabled={isResuming}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<PlayIcon className="w-4 h-4 mr-2" />
|
||||
{isResuming ? 'Resuming...' : 'Resume'}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
onClick={handleCancel}
|
||||
disabled={isCancelling}
|
||||
variant="danger"
|
||||
size="sm"
|
||||
>
|
||||
<XMarkIcon className="w-4 h-4 mr-2" />
|
||||
{isCancelling ? 'Cancelling...' : 'Cancel'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Side - Metrics and Close (25%) */}
|
||||
<div className="w-64 flex-shrink-0">
|
||||
{/* Close Button */}
|
||||
<div className="flex justify-end mb-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
title="Close (card will remain available below)"
|
||||
>
|
||||
<XMarkIcon className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Metrics Cards */}
|
||||
<div className="space-y-3">
|
||||
{/* Duration */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<ClockIcon className="w-4 h-4 text-gray-500" />
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 uppercase font-semibold">
|
||||
Duration
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{formatDuration(currentRun.started_at)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Credits Used */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<BoltIcon className="w-4 h-4 text-amber-500" />
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 uppercase font-semibold">
|
||||
Credits Used
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-amber-600 dark:text-amber-400">
|
||||
{currentRun.total_credits_used}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Stage */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 uppercase font-semibold mb-1">
|
||||
Stage
|
||||
</div>
|
||||
<div className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{currentRun.current_stage} of 7
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 uppercase font-semibold mb-1">
|
||||
Status
|
||||
</div>
|
||||
<div className={`text-sm font-semibold ${
|
||||
isPaused
|
||||
? 'text-yellow-600 dark:text-yellow-400'
|
||||
: 'text-blue-600 dark:text-blue-400'
|
||||
}`}>
|
||||
{isPaused ? 'Paused' : 'Running'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CurrentProcessingCard;
|
||||
@@ -123,3 +123,6 @@ export { FileIcon as ImageIcon }; // Use FileIcon as ImageIcon alias
|
||||
export { TimeIcon as ClockIcon };
|
||||
export { ErrorIcon as XCircleIcon };
|
||||
export { BoxIcon as TagIcon };
|
||||
export { CloseIcon as XMarkIcon };
|
||||
export { BoltIcon as PlayIcon }; // Use BoltIcon for play (running state)
|
||||
export { TimeIcon as PauseIcon }; // Use TimeIcon for pause state
|
||||
|
||||
@@ -190,6 +190,7 @@ const AppSidebar: React.FC = () => {
|
||||
icon: <DollarLineIcon />,
|
||||
name: "Billing",
|
||||
subItems: [
|
||||
{ name: "Overview", path: "/billing/overview" },
|
||||
{ name: "Credits", path: "/billing/credits" },
|
||||
{ name: "Transactions", path: "/billing/transactions" },
|
||||
{ name: "Usage", path: "/billing/usage" },
|
||||
@@ -209,6 +210,14 @@ const AppSidebar: React.FC = () => {
|
||||
const adminSection: MenuSection = useMemo(() => ({
|
||||
label: "ADMIN",
|
||||
items: [
|
||||
{
|
||||
icon: <DollarLineIcon />,
|
||||
name: "Billing & Credits",
|
||||
subItems: [
|
||||
{ name: "Billing Management", path: "/admin/billing" },
|
||||
{ name: "Credit Costs", path: "/admin/credit-costs" },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <PlugInIcon />,
|
||||
name: "User Management",
|
||||
|
||||
470
frontend/src/pages/Admin/AdminBilling.tsx
Normal file
470
frontend/src/pages/Admin/AdminBilling.tsx
Normal file
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* Admin Billing Management Page
|
||||
* Admin-only interface for managing credits, billing, and user accounts
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import ComponentCard from '../../components/common/ComponentCard';
|
||||
import EnhancedMetricCard from '../../components/dashboard/EnhancedMetricCard';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { fetchAPI } from '../../services/api';
|
||||
import Button from '../../components/ui/button/Button';
|
||||
import Badge from '../../components/ui/badge/Badge';
|
||||
import {
|
||||
BoltIcon,
|
||||
UserIcon,
|
||||
DollarLineIcon,
|
||||
PlugInIcon,
|
||||
CheckCircleIcon,
|
||||
TimeIcon
|
||||
} from '../../icons';
|
||||
|
||||
interface UserAccount {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
credits: number;
|
||||
subscription_plan: string;
|
||||
is_active: boolean;
|
||||
date_joined: string;
|
||||
}
|
||||
|
||||
interface CreditCostConfig {
|
||||
id: number;
|
||||
model_name: string;
|
||||
operation_type: string;
|
||||
cost: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface SystemStats {
|
||||
total_users: number;
|
||||
active_users: number;
|
||||
total_credits_issued: number;
|
||||
total_credits_used: number;
|
||||
}
|
||||
|
||||
const AdminBilling: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const [stats, setStats] = useState<SystemStats | null>(null);
|
||||
const [users, setUsers] = useState<UserAccount[]>([]);
|
||||
const [creditConfigs, setCreditConfigs] = useState<CreditCostConfig[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'users' | 'pricing'>('overview');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedUser, setSelectedUser] = useState<UserAccount | null>(null);
|
||||
const [creditAmount, setCreditAmount] = useState('');
|
||||
const [adjustmentReason, setAdjustmentReason] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [statsData, usersData, configsData] = await Promise.all([
|
||||
fetchAPI('/v1/admin/billing/stats/'),
|
||||
fetchAPI('/v1/admin/users/?limit=100'),
|
||||
fetchAPI('/v1/admin/credit-costs/'),
|
||||
]);
|
||||
|
||||
setStats(statsData);
|
||||
setUsers(usersData.results || []);
|
||||
setCreditConfigs(configsData.results || []);
|
||||
} catch (error: any) {
|
||||
toast?.error(error?.message || 'Failed to load admin data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdjustCredits = async () => {
|
||||
if (!selectedUser || !creditAmount) {
|
||||
toast?.error('Please select a user and enter amount');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchAPI(`/v1/admin/users/${selectedUser.id}/adjust-credits/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
amount: parseInt(creditAmount),
|
||||
reason: adjustmentReason || 'Admin adjustment',
|
||||
}),
|
||||
});
|
||||
|
||||
toast?.success(`Credits adjusted for ${selectedUser.username}`);
|
||||
setCreditAmount('');
|
||||
setAdjustmentReason('');
|
||||
setSelectedUser(null);
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
toast?.error(error?.message || 'Failed to adjust credits');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateCreditCost = async (configId: number, newCost: number) => {
|
||||
try {
|
||||
await fetchAPI(`/v1/admin/credit-costs/${configId}/`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ cost: newCost }),
|
||||
});
|
||||
|
||||
toast?.success('Credit cost updated successfully');
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
toast?.error(error?.message || 'Failed to update credit cost');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(user =>
|
||||
user.username.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.email.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Admin - Billing Management" description="Manage billing and credits" />
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading admin data...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Admin - Billing Management" description="Manage billing and credits" />
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Billing Management</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Admin controls for credits, pricing, and user billing
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/admin/igny8_core/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
<PlugInIcon className="w-4 h-4 mr-2" />
|
||||
Django Admin
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* System Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<EnhancedMetricCard
|
||||
title="Total Users"
|
||||
value={stats?.total_users || 0}
|
||||
icon={UserIcon}
|
||||
color="blue"
|
||||
iconColor="text-blue-500"
|
||||
/>
|
||||
<EnhancedMetricCard
|
||||
title="Active Users"
|
||||
value={stats?.active_users || 0}
|
||||
icon={CheckCircleIcon}
|
||||
color="green"
|
||||
iconColor="text-green-500"
|
||||
/>
|
||||
<EnhancedMetricCard
|
||||
title="Credits Issued"
|
||||
value={stats?.total_credits_issued || 0}
|
||||
icon={DollarLineIcon}
|
||||
color="amber"
|
||||
iconColor="text-amber-500"
|
||||
/>
|
||||
<EnhancedMetricCard
|
||||
title="Credits Used"
|
||||
value={stats?.total_credits_used || 0}
|
||||
icon={BoltIcon}
|
||||
color="purple"
|
||||
iconColor="text-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('overview')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'overview'
|
||||
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('users')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'users'
|
||||
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
User Management ({users.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('pricing')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'pricing'
|
||||
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
Credit Pricing ({creditConfigs.length})
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<ComponentCard title="Quick Actions">
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
fullWidth
|
||||
onClick={() => setActiveTab('users')}
|
||||
>
|
||||
<UserIcon className="w-4 h-4 mr-2" />
|
||||
Manage User Credits
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onClick={() => setActiveTab('pricing')}
|
||||
>
|
||||
<DollarLineIcon className="w-4 h-4 mr-2" />
|
||||
Update Credit Costs
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
fullWidth
|
||||
onClick={() => window.open('/admin/igny8_core/creditcostconfig/', '_blank')}
|
||||
>
|
||||
<PlugInIcon className="w-4 h-4 mr-2" />
|
||||
Full Admin Panel
|
||||
</Button>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
<ComponentCard title="Recent Activity">
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
Activity log coming soon
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'users' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<ComponentCard title="User Accounts">
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="Search by username or email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
User
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Plan
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Credits
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{filteredUsers.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td className="px-4 py-4">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{user.username}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{user.email}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<Badge variant="info">{user.subscription_plan || 'Free'}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap text-right font-bold text-amber-600 dark:text-amber-400">
|
||||
{user.credits}
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap text-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedUser(user)}
|
||||
>
|
||||
Adjust
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ComponentCard title="Adjust Credits">
|
||||
{selectedUser ? (
|
||||
<div className="space-y-4">
|
||||
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{selectedUser.username}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||
Current: {selectedUser.credits} credits
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Amount</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="Enter credits (use - for deduction)"
|
||||
value={creditAmount}
|
||||
onChange={(e) => setCreditAmount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Reason</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="e.g., Bonus credits, Refund, etc."
|
||||
value={adjustmentReason}
|
||||
onChange={(e) => setAdjustmentReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
fullWidth
|
||||
onClick={handleAdjustCredits}
|
||||
>
|
||||
Apply Adjustment
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedUser(null);
|
||||
setCreditAmount('');
|
||||
setAdjustmentReason('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
Select a user to adjust credits
|
||||
</div>
|
||||
)}
|
||||
</ComponentCard>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'pricing' && (
|
||||
<ComponentCard title="Credit Cost Configuration">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Model
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Operation
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Cost (Credits)
|
||||
</th>
|
||||
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{creditConfigs.map((config) => (
|
||||
<tr key={config.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||
{config.model_name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{config.operation_type}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-bold text-amber-600 dark:text-amber-400">
|
||||
{config.cost}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-center">
|
||||
<Badge variant={config.is_active ? 'success' : 'warning'}>
|
||||
{config.is_active ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(`/admin/igny8_core/creditcostconfig/${config.id}/change/`, '_blank')}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
To add new credit costs or modify these settings, use the{' '}
|
||||
<a
|
||||
href="/admin/igny8_core/creditcostconfig/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-600 dark:text-primary-400 hover:underline"
|
||||
>
|
||||
Django Admin Panel
|
||||
</a>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminBilling;
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import ActivityLog from '../../components/Automation/ActivityLog';
|
||||
import ConfigModal from '../../components/Automation/ConfigModal';
|
||||
import RunHistory from '../../components/Automation/RunHistory';
|
||||
import CurrentProcessingCard from '../../components/Automation/CurrentProcessingCard';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import ComponentCard from '../../components/common/ComponentCard';
|
||||
import EnhancedMetricCard from '../../components/dashboard/EnhancedMetricCard';
|
||||
@@ -600,6 +601,22 @@ const AutomationPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Processing Card - Shows real-time automation progress */}
|
||||
{currentRun && (currentRun.status === 'running' || currentRun.status === 'paused') && activeSite && (
|
||||
<CurrentProcessingCard
|
||||
runId={currentRun.run_id}
|
||||
siteId={activeSite.id}
|
||||
currentRun={currentRun}
|
||||
onUpdate={() => {
|
||||
// Refresh current run status
|
||||
loadCurrentRun();
|
||||
}}
|
||||
onClose={() => {
|
||||
// Card will remain in DOM but user acknowledged it
|
||||
// Can add state here to minimize it if needed
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pipeline Stages */}
|
||||
<ComponentCard>
|
||||
|
||||
362
frontend/src/pages/Settings/CreditsAndBilling.tsx
Normal file
362
frontend/src/pages/Settings/CreditsAndBilling.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* Credits & Billing Page
|
||||
* User-facing credits usage, transactions, and billing information
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import ComponentCard from '../../components/common/ComponentCard';
|
||||
import EnhancedMetricCard from '../../components/dashboard/EnhancedMetricCard';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { fetchAPI } from '../../services/api';
|
||||
import Button from '../../components/ui/button/Button';
|
||||
import Badge from '../../components/ui/badge/Badge';
|
||||
import {
|
||||
BoltIcon,
|
||||
DollarLineIcon,
|
||||
ClockIcon,
|
||||
CheckCircleIcon
|
||||
} from '../../icons';
|
||||
|
||||
interface CreditTransaction {
|
||||
id: number;
|
||||
transaction_type: string;
|
||||
amount: number;
|
||||
balance_after: number;
|
||||
description: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface CreditUsageLog {
|
||||
id: number;
|
||||
operation_type: string;
|
||||
credits_used: number;
|
||||
model_used: string;
|
||||
created_at: string;
|
||||
metadata: any;
|
||||
}
|
||||
|
||||
interface AccountBalance {
|
||||
credits: number;
|
||||
subscription_plan: string;
|
||||
monthly_credits_included: number;
|
||||
bonus_credits: number;
|
||||
}
|
||||
|
||||
const CreditsAndBilling: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const [balance, setBalance] = useState<AccountBalance | null>(null);
|
||||
const [transactions, setTransactions] = useState<CreditTransaction[]>([]);
|
||||
const [usageLogs, setUsageLogs] = useState<CreditUsageLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'transactions' | 'usage'>('overview');
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [balanceData, transactionsData, usageData] = await Promise.all([
|
||||
fetchAPI('/v1/billing/account_balance/'),
|
||||
fetchAPI('/v1/billing/transactions/?limit=50'),
|
||||
fetchAPI('/v1/billing/usage/?limit=50'),
|
||||
]);
|
||||
|
||||
setBalance(balanceData);
|
||||
setTransactions(transactionsData.results || []);
|
||||
setUsageLogs(usageData.results || []);
|
||||
} catch (error: any) {
|
||||
toast?.error(error?.message || 'Failed to load billing data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getTransactionTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'purchase': return 'success';
|
||||
case 'grant': return 'info';
|
||||
case 'deduction': return 'warning';
|
||||
case 'refund': return 'primary';
|
||||
case 'adjustment': return 'secondary';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const formatOperationType = (type: string) => {
|
||||
return type.split('_').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
).join(' ');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Credits & Billing" description="Manage your credits and billing" />
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading billing data...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Credits & Billing" description="Manage your credits and billing" />
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Credits & Billing</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Manage your credits, view transactions, and monitor usage
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => {
|
||||
// TODO: Link to purchase credits page
|
||||
toast?.info('Purchase credits feature coming soon');
|
||||
}}>
|
||||
<DollarLineIcon className="w-4 h-4 mr-2" />
|
||||
Purchase Credits
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Credit Balance Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<EnhancedMetricCard
|
||||
title="Current Balance"
|
||||
value={balance?.credits || 0}
|
||||
icon={BoltIcon}
|
||||
color="amber"
|
||||
iconColor="text-amber-500"
|
||||
/>
|
||||
<EnhancedMetricCard
|
||||
title="Monthly Included"
|
||||
value={balance?.monthly_credits_included || 0}
|
||||
subtitle={balance?.subscription_plan || 'Free'}
|
||||
icon={CheckCircleIcon}
|
||||
color="green"
|
||||
iconColor="text-green-500"
|
||||
/>
|
||||
<EnhancedMetricCard
|
||||
title="Bonus Credits"
|
||||
value={balance?.bonus_credits || 0}
|
||||
icon={DollarLineIcon}
|
||||
color="blue"
|
||||
iconColor="text-blue-500"
|
||||
/>
|
||||
<EnhancedMetricCard
|
||||
title="Total This Month"
|
||||
value={usageLogs.reduce((sum, log) => sum + log.credits_used, 0)}
|
||||
icon={ClockIcon}
|
||||
color="purple"
|
||||
iconColor="text-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('overview')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'overview'
|
||||
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('transactions')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'transactions'
|
||||
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
Transactions ({transactions.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('usage')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'usage'
|
||||
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
Usage History ({usageLogs.length})
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Recent Transactions */}
|
||||
<ComponentCard title="Recent Transactions">
|
||||
<div className="space-y-3">
|
||||
{transactions.slice(0, 5).map((transaction) => (
|
||||
<div key={transaction.id} className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={getTransactionTypeColor(transaction.transaction_type)}>
|
||||
{transaction.transaction_type}
|
||||
</Badge>
|
||||
<span className="text-sm text-gray-900 dark:text-white">
|
||||
{transaction.description}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{new Date(transaction.created_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className={`font-bold ${transaction.amount > 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{transaction.amount > 0 ? '+' : ''}{transaction.amount}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
Balance: {transaction.balance_after}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{transactions.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
No transactions yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
{/* Recent Usage */}
|
||||
<ComponentCard title="Recent Usage">
|
||||
<div className="space-y-3">
|
||||
{usageLogs.slice(0, 5).map((log) => (
|
||||
<div key={log.id} className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{formatOperationType(log.operation_type)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{log.model_used} • {new Date(log.created_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-bold text-amber-600 dark:text-amber-400">
|
||||
{log.credits_used} credits
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{usageLogs.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
No usage history yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'transactions' && (
|
||||
<ComponentCard title="All Transactions">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Description
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Amount
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Balance
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{transactions.map((transaction) => (
|
||||
<tr key={transaction.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{new Date(transaction.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<Badge variant={getTransactionTypeColor(transaction.transaction_type)}>
|
||||
{transaction.transaction_type}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-900 dark:text-white">
|
||||
{transaction.description}
|
||||
</td>
|
||||
<td className={`px-6 py-4 whitespace-nowrap text-sm text-right font-bold ${
|
||||
transaction.amount > 0 ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{transaction.amount > 0 ? '+' : ''}{transaction.amount}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white text-right">
|
||||
{transaction.balance_after}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
)}
|
||||
|
||||
{activeTab === 'usage' && (
|
||||
<ComponentCard title="Usage History">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Operation
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Model
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Credits
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{usageLogs.map((log) => (
|
||||
<tr key={log.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{new Date(log.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-900 dark:text-white">
|
||||
{formatOperationType(log.operation_type)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{log.model_used || 'N/A'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-right font-bold text-amber-600 dark:text-amber-400">
|
||||
{log.credits_used}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreditsAndBilling;
|
||||
@@ -25,10 +25,14 @@ export interface StageResult {
|
||||
|
||||
export interface AutomationRun {
|
||||
run_id: string;
|
||||
status: 'running' | 'paused' | 'completed' | 'failed';
|
||||
status: 'running' | 'paused' | 'cancelled' | 'completed' | 'failed';
|
||||
current_stage: number;
|
||||
trigger_type: 'manual' | 'scheduled';
|
||||
started_at: string;
|
||||
completed_at?: string | null;
|
||||
paused_at?: string | null;
|
||||
resumed_at?: string | null;
|
||||
cancelled_at?: string | null;
|
||||
total_credits_used: number;
|
||||
stage_1_result: StageResult | null;
|
||||
stage_2_result: StageResult | null;
|
||||
@@ -56,6 +60,24 @@ export interface PipelineStage {
|
||||
type: 'AI' | 'Local' | 'Manual';
|
||||
}
|
||||
|
||||
export interface ProcessingItem {
|
||||
id: number;
|
||||
title: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface ProcessingState {
|
||||
stage_number: number;
|
||||
stage_name: string;
|
||||
stage_type: 'AI' | 'Local' | 'Manual';
|
||||
total_items: number;
|
||||
processed_items: number;
|
||||
percentage: number;
|
||||
currently_processing: ProcessingItem[];
|
||||
up_next: ProcessingItem[];
|
||||
remaining_count: number;
|
||||
}
|
||||
|
||||
function buildUrl(endpoint: string, params?: Record<string, any>): string {
|
||||
let url = `/v1/automation${endpoint}`;
|
||||
if (params) {
|
||||
@@ -109,8 +131,8 @@ export const automationService = {
|
||||
/**
|
||||
* Pause automation run
|
||||
*/
|
||||
pause: async (runId: string): Promise<void> => {
|
||||
await fetchAPI(buildUrl('/pause/', { run_id: runId }), {
|
||||
pause: async (siteId: number, runId: string): Promise<void> => {
|
||||
await fetchAPI(buildUrl('/pause/', { site_id: siteId, run_id: runId }), {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
@@ -118,8 +140,17 @@ export const automationService = {
|
||||
/**
|
||||
* Resume paused automation run
|
||||
*/
|
||||
resume: async (runId: string): Promise<void> => {
|
||||
await fetchAPI(buildUrl('/resume/', { run_id: runId }), {
|
||||
resume: async (siteId: number, runId: string): Promise<void> => {
|
||||
await fetchAPI(buildUrl('/resume/', { site_id: siteId, run_id: runId }), {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Cancel automation run
|
||||
*/
|
||||
cancel: async (siteId: number, runId: string): Promise<void> => {
|
||||
await fetchAPI(buildUrl('/cancel/', { site_id: siteId, run_id: runId }), {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
@@ -167,4 +198,17 @@ export const automationService = {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current processing state for active automation run
|
||||
*/
|
||||
getCurrentProcessing: async (
|
||||
siteId: number,
|
||||
runId: string
|
||||
): Promise<ProcessingState | null> => {
|
||||
const response = await fetchAPI(
|
||||
buildUrl('/current_processing/', { site_id: siteId, run_id: runId })
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user