Files
igny8/frontend/src/pages/Admin/AdminBilling.tsx
2025-12-05 12:56:24 +00:00

544 lines
21 KiB
TypeScript

/**
* 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;
email: string;
username?: string;
account_name?: string;
credits: number;
subscription_plan?: string;
is_active: boolean;
date_joined: string;
}
interface CreditCostConfig {
id: number;
operation_type: string;
display_name: string;
credits_cost: number;
unit?: string;
description?: string;
is_active: boolean;
created_at: string;
}
interface CreditPackageItem {
id: number;
name: string;
slug: string;
credits: number;
price: string;
discount_percentage: number;
is_featured: boolean;
description?: string;
is_active?: boolean;
sort_order?: number;
}
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 [creditPackages, setCreditPackages] = useState<CreditPackageItem[]>([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<'overview' | 'users' | 'pricing' | 'packages'>('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([
// Admin billing stats (modules admin endpoints)
fetchAPI('/v1/admin/billing/stats/'),
// Admin users with credits
fetchAPI('/v1/admin/users/'),
// Admin credit costs (modules billing)
fetchAPI('/v1/admin/credit-costs/'),
]);
const packagesData = await fetchAPI('/v1/billing/credit-packages/');
setStats(statsData);
setUsers(usersData.results || []);
setCreditConfigs(configsData.results || []);
setCreditPackages(packagesData.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/', {
method: 'POST',
body: JSON.stringify({ updates: [{ id: configId, 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.email || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
(user.username || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
(user.account_name || '').toLowerCase().includes(searchTerm.toLowerCase())
);
const formatLabel = (value?: string) =>
(value || '')
.split('_')
.map((w) => (w ? w[0].toUpperCase() + w.slice(1) : ''))
.join(' ')
.trim();
const updateLocalCost = (id: number, value: string) => {
setCreditConfigs((prev) =>
prev.map((c) =>
c.id === id ? { ...c, credits_cost: value === '' ? ('' as any) : Number(value) } : c
)
);
};
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>
<Button
variant="outline"
startIcon={<PlugInIcon className="w-4 h-4" />}
onClick={() => window.open('/admin/igny8_core/', '_blank')}
>
Django Admin
</Button>
</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 />}
accentColor="blue"
/>
<EnhancedMetricCard
title="Active Users"
value={stats?.active_users || 0}
icon={<CheckCircleIcon />}
accentColor="green"
/>
<EnhancedMetricCard
title="Credits Issued"
value={stats?.total_credits_issued || 0}
icon={<DollarLineIcon />}
accentColor="orange"
/>
<EnhancedMetricCard
title="Credits Used"
value={stats?.total_credits_used || 0}
icon={<BoltIcon />}
accentColor="purple"
/>
</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>
<button
onClick={() => setActiveTab('packages')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'packages'
? '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 Packages ({creditPackages.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
startIcon={<UserIcon className="w-4 h-4" />}
onClick={() => setActiveTab('users')}
>
Manage User Credits
</Button>
<Button
variant="secondary"
fullWidth
startIcon={<DollarLineIcon className="w-4 h-4" />}
onClick={() => setActiveTab('pricing')}
>
Update Credit Costs
</Button>
<Button
variant="outline"
fullWidth
startIcon={<PlugInIcon className="w-4 h-4" />}
onClick={() => window.open('/admin/igny8_core/creditcostconfig/', '_blank')}
>
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 tone="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">
Operation
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
Display Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
Credits Cost
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
Unit
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
Description
</th>
<th className="px-6 py-3 text-right 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 font-mono text-gray-700 dark:text-gray-300">
{config.operation_type}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
{config.display_name || formatLabel(config.operation_type)}
</td>
<td className="px-6 py-4">
<input
type="number"
value={config.credits_cost as any}
onChange={(e) => updateLocalCost(config.id, e.target.value)}
className="w-24 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-800 dark:text-white"
/>
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-400">
{formatLabel(config.unit)}
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-400 max-w-md">
{config.description || '—'}
</td>
<td className="px-6 py-4 text-right">
<Button
size="sm"
variant="primary"
tone="brand"
onClick={() => handleUpdateCreditCost(config.id, Number(config.credits_cost) || 0)}
>
Save
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</ComponentCard>
)}
{activeTab === 'packages' && (
<ComponentCard title="Credit Packages">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{creditPackages.map((pkg) => (
<ComponentCard key={pkg.id} title={pkg.name} desc={pkg.description || ''}>
<div className="space-y-3">
<div className="text-3xl font-bold text-blue-600">{pkg.credits.toLocaleString()}</div>
<div className="text-sm text-gray-500">credits</div>
<div className="text-2xl font-semibold text-gray-900 dark:text-white">${pkg.price}</div>
{pkg.discount_percentage > 0 && (
<div className="text-sm text-green-600">Save {pkg.discount_percentage}%</div>
)}
<div className="flex items-center gap-2">
<Badge variant="light" color={pkg.is_active ? 'success' : 'warning'}>
{pkg.is_active ? 'Active' : 'Inactive'}
</Badge>
{pkg.is_featured && (
<Badge variant="light" color="primary">
Featured
</Badge>
)}
</div>
</div>
</ComponentCard>
))}
{creditPackages.length === 0 && (
<div className="col-span-3 text-center py-8 text-gray-500">No credit packages found.</div>
)}
</div>
</ComponentCard>
)}
</div>
);
};
export default AdminBilling;