fina autoamtiona adn billing and credits

This commit is contained in:
IGNY8 VPS (Salman)
2025-12-04 15:54:15 +00:00
parent f8a9293196
commit 40dfe20ead
40 changed files with 5680 additions and 18 deletions

View File

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

View 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;

View 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;

View File

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

View File

@@ -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",

View 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;

View File

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

View 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;

View File

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