397 lines
23 KiB
TypeScript
397 lines
23 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
||
import axios from 'axios';
|
||
import { useAuth } from '../AuthContext';
|
||
import { Users, PlusCircle, Shield, ArrowLeft, RefreshCw, Key, DollarSign, BarChart2, Save, Activity } from 'lucide-react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { Tooltip } from '../components/Tooltip';
|
||
import { Layout } from '../components/Layout';
|
||
|
||
// Simple SVG Bar Chart Component
|
||
const AnalyticsChart = ({ data }: { data: any[] }) => {
|
||
if (!data || data.length === 0) return <div className="text-center text-stone-400 py-10">No data available for this period.</div>;
|
||
|
||
const maxVal = Math.max(...data.map(d => Math.max(d.revenue, d.usage)), 10); // Prevent zero division
|
||
const height = 200;
|
||
const barWidth = 40;
|
||
const gap = 20;
|
||
const width = data.length * (barWidth + gap);
|
||
|
||
return (
|
||
<div className="overflow-x-auto pb-4">
|
||
<svg width={Math.max(width, 600)} height={height + 50} className="font-mono text-[10px]">
|
||
{/* Y-Axis Grid */}
|
||
{[0, 0.5, 1].map(t => (
|
||
<line key={t} x1={0} y1={height * (1 - t)} x2="100%" y2={height * (1 - t)} stroke="#e5e5e5" strokeDasharray="4" />
|
||
))}
|
||
|
||
{data.map((d, i) => {
|
||
const x = i * (barWidth + gap);
|
||
const hRev = (d.revenue / maxVal) * height;
|
||
const hCost = (d.usage / maxVal) * height;
|
||
|
||
return (
|
||
<g key={i} transform={`translate(${x}, 0)`}>
|
||
{/* Revenue Bar (Green) */}
|
||
<rect x={0} y={height - hRev} width={barWidth / 2} height={hRev} fill="#22c55e" rx="2" />
|
||
{/* Cost Bar (Red) */}
|
||
<rect x={barWidth / 2} y={height - hCost} width={barWidth / 2} height={hCost} fill="#ef4444" rx="2" />
|
||
|
||
{/* Label */}
|
||
<text x={barWidth / 2} y={height + 15} textAnchor="middle" fill="#78716c">{d.date.slice(5)}</text>
|
||
{/* Values */}
|
||
<text x={0} y={height - hRev - 5} className="fill-green-600 font-bold" fontSize="8">${d.revenue.toFixed(1)}</text>
|
||
</g>
|
||
);
|
||
})}
|
||
</svg>
|
||
<div className="flex gap-4 justify-center mt-4 text-xs font-bold">
|
||
<div className="flex items-center gap-1"><div className="w-3 h-3 bg-green-500 rounded"></div> Revenue</div>
|
||
<div className="flex items-center gap-1"><div className="w-3 h-3 bg-red-500 rounded"></div> Usage Cost</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const AdminDashboard: React.FC = () => {
|
||
const { user } = useAuth();
|
||
const navigate = useNavigate();
|
||
|
||
// Tab State
|
||
const [activeTab, setActiveTab] = useState<'users' | 'pricing' | 'analytics'>('users');
|
||
|
||
// Data State
|
||
const [users, setUsers] = useState<any[]>([]);
|
||
const [configs, setConfigs] = useState<any>({});
|
||
const [analytics, setAnalytics] = useState<any[]>([]);
|
||
|
||
// Loading State
|
||
const [loading, setLoading] = useState(false);
|
||
const [editingConfig, setEditingConfig] = useState<string | null>(null);
|
||
const [configValue, setConfigValue] = useState("");
|
||
const [analyticsRange, setAnalyticsRange] = useState("30d");
|
||
|
||
// NEW: User Management State
|
||
const [selectedUser, setSelectedUser] = useState<any>(null);
|
||
|
||
useEffect(() => {
|
||
if (user && user.role !== 'ADMIN') {
|
||
navigate('/');
|
||
return;
|
||
}
|
||
fetchData();
|
||
}, [user, activeTab, analyticsRange]);
|
||
|
||
const fetchData = async () => {
|
||
setLoading(true);
|
||
const token = localStorage.getItem('token');
|
||
const headers = { Authorization: `Bearer ${token}` };
|
||
|
||
try {
|
||
if (activeTab === 'users') {
|
||
const res = await axios.get('/api/admin/users', { headers });
|
||
setUsers(res.data.users);
|
||
} else if (activeTab === 'pricing') {
|
||
const res = await axios.get('/api/admin/config', { headers });
|
||
setConfigs(res.data);
|
||
} else if (activeTab === 'analytics') {
|
||
const res = await axios.get(`/api/admin/analytics?range=${analyticsRange}`, { headers });
|
||
const data = Array.isArray(res.data.analytics) ? res.data.analytics : [];
|
||
setAnalytics(data);
|
||
}
|
||
} catch (err) {
|
||
console.error("Fetch Error:", err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const updateConfig = async (key: string) => {
|
||
try {
|
||
const token = localStorage.getItem('token');
|
||
await axios.post('/api/admin/config', { key, value: configValue }, {
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
setConfigs({ ...configs, [key]: configValue });
|
||
setEditingConfig(null);
|
||
} catch (e) {
|
||
alert("Failed to update config");
|
||
}
|
||
};
|
||
|
||
const addCredits = async (userId: string, amount: number) => {
|
||
try {
|
||
const token = localStorage.getItem('token');
|
||
await axios.post('/api/admin/credits', { userId, amount }, {
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
|
||
fetchData(); // Refresh list to show new balance
|
||
if (selectedUser) {
|
||
// Update local selected user state immediately for better UX
|
||
setSelectedUser((prev: any) => ({ ...prev, credits: prev.credits + amount }));
|
||
}
|
||
} catch (err) {
|
||
alert("Failed to add credits");
|
||
}
|
||
};
|
||
|
||
const updateUserRole = async (userId: string, role: string) => {
|
||
try {
|
||
const token = localStorage.getItem('token');
|
||
await axios.post('/api/admin/role', { userId, role }, {
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
fetchData(); // Refresh list
|
||
if (selectedUser) {
|
||
setSelectedUser((prev: any) => ({ ...prev, role }));
|
||
}
|
||
} catch (err: any) {
|
||
alert(err.response?.data?.error || "Failed to update role");
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Layout>
|
||
<div className="max-w-7xl mx-auto p-6 pt-12 space-y-8">
|
||
{/* Admin Sub-Header */}
|
||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-6 mb-8">
|
||
<div className="flex items-center gap-4">
|
||
<button
|
||
onClick={() => navigate('/')}
|
||
className="p-3 bg-white hover:bg-stone-100 border border-stone-200 rounded-2xl transition-all shadow-sm group"
|
||
>
|
||
<ArrowLeft className="w-5 h-5 text-stone-600 group-hover:-translate-x-1 transition-transform" />
|
||
</button>
|
||
<div>
|
||
<h1 className="text-3xl font-black tracking-tighter text-stone-900 flex items-center gap-3">
|
||
<Shield className="w-8 h-8 text-purple-600" />
|
||
Admin Command Center
|
||
</h1>
|
||
<p className="text-stone-500 font-medium text-sm">System management and financial oversight.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-4">
|
||
<div className="flex bg-stone-200 p-1 rounded-xl">
|
||
<button onClick={() => setActiveTab('users')} className={`px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all ${activeTab === 'users' ? 'bg-white text-stone-900 shadow-sm' : 'text-stone-500 hover:text-stone-700'}`}>Users</button>
|
||
<button onClick={() => setActiveTab('pricing')} className={`px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all ${activeTab === 'pricing' ? 'bg-white text-stone-900 shadow-sm' : 'text-stone-500 hover:text-stone-700'}`}>Pricing</button>
|
||
<button onClick={() => setActiveTab('analytics')} className={`px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all ${activeTab === 'analytics' ? 'bg-white text-stone-900 shadow-sm' : 'text-stone-500 hover:text-stone-700'}`}>Analytics</button>
|
||
</div>
|
||
|
||
<button onClick={fetchData} className="p-3 bg-white hover:bg-stone-50 border border-stone-200 rounded-xl transition-all shadow-sm">
|
||
<RefreshCw className={`w-4 h-4 text-stone-600 ${loading ? 'animate-spin' : ''}`} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* --- USERS TAB --- */}
|
||
{activeTab === 'users' && (
|
||
<div className="bg-white rounded-xl shadow-sm border border-stone-200 overflow-hidden">
|
||
<div className="p-6 border-b border-stone-100 flex items-center gap-3">
|
||
<Users className="w-5 h-5 text-stone-400" />
|
||
<h3 className="font-bold text-lg">User Management</h3>
|
||
</div>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-left">
|
||
<thead className="bg-stone-50 border-b border-stone-200">
|
||
<tr>
|
||
<th className="px-6 py-3 text-xs font-black text-stone-400 uppercase tracking-wider">User</th>
|
||
<th className="px-6 py-3 text-xs font-black text-stone-400 uppercase tracking-wider">Credits</th>
|
||
<th className="px-6 py-3 text-xs font-black text-stone-400 uppercase tracking-wider">P&L (Rev/Cost)</th>
|
||
<th className="px-6 py-3 text-xs font-black text-stone-400 uppercase tracking-wider">API Key</th>
|
||
<th className="px-6 py-3 text-xs font-black text-stone-400 uppercase tracking-wider">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-stone-100">
|
||
{users.map(u => (
|
||
<tr key={u.id} className="hover:bg-stone-50 transition-colors">
|
||
<td className="px-6 py-4">
|
||
<div className="font-bold">{u.email}</div>
|
||
<div className="text-xs text-stone-400 font-mono">{u.id}</div>
|
||
</td>
|
||
<td className="px-6 py-4 font-mono font-bold text-stone-700">{u.credits}</td>
|
||
<td className="px-6 py-4 font-mono text-xs">
|
||
<span className="text-green-600 block">+${(u.totalRevenue || 0).toFixed(2)}</span>
|
||
<span className="text-red-500 block">-${(u.totalCost || 0).toFixed(4)}</span>
|
||
</td>
|
||
<td className="px-6 py-4">
|
||
{u.apiKey ? <span className="bg-green-100 text-green-700 px-2 py-1 rounded text-xs font-bold">Key Active</span> : <span className="text-stone-400 text-xs">No Key</span>}
|
||
</td>
|
||
<td className="px-6 py-4">
|
||
<button
|
||
onClick={() => setSelectedUser(u)}
|
||
className="px-3 py-1 bg-purple-600 text-white rounded-lg text-xs font-bold hover:bg-purple-500 shadow-sm flex items-center gap-1"
|
||
>
|
||
<Key className="w-3 h-3" /> Manage
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* --- USER MANAGEMENT MODAL --- */}
|
||
{selectedUser && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden border border-stone-100">
|
||
{/* Header */}
|
||
<div className="bg-stone-50 px-6 py-4 border-b border-stone-200 flex justify-between items-center">
|
||
<div>
|
||
<h3 className="text-lg font-black text-stone-800">Manage User</h3>
|
||
<p className="text-xs text-stone-500 font-mono">{selectedUser.email}</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setSelectedUser(null)}
|
||
className="p-1 hover:bg-stone-200 rounded-full transition-colors text-stone-400 hover:text-stone-600"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
<div className="p-6 space-y-6">
|
||
{/* Role Management */}
|
||
<div className="space-y-2">
|
||
<label className="text-xs font-black uppercase tracking-widest text-stone-400">User Role</label>
|
||
<div className="flex gap-2">
|
||
<select
|
||
value={selectedUser.role}
|
||
onChange={(e) => updateUserRole(selectedUser.id, e.target.value)}
|
||
className="flex-1 bg-stone-50 border border-stone-200 rounded-xl px-4 py-2 text-sm font-bold focus:ring-2 focus:ring-purple-500 outline-none"
|
||
>
|
||
<option value="USER">USER (Standard)</option>
|
||
<option value="VIP">VIP (High Priority)</option>
|
||
<option value="MODERATOR">MODERATOR (Team)</option>
|
||
<option value="ADMIN">ADMIN (God Mode)</option>
|
||
</select>
|
||
</div>
|
||
<p className="text-[10px] text-stone-400">
|
||
⚠️ Admins have full access. VIPs get priority queue.
|
||
</p>
|
||
</div>
|
||
|
||
<hr className="border-stone-100" />
|
||
|
||
{/* Credit Management */}
|
||
<div className="space-y-4">
|
||
<div className="flex justify-between items-end">
|
||
<label className="text-xs font-black uppercase tracking-widest text-stone-400">Credit Balance</label>
|
||
<span className="text-2xl font-black text-stone-800 font-mono">{selectedUser.credits}</span>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<p className="text-xs font-bold text-green-600 text-center">Add Credits</p>
|
||
<div className="flex gap-1">
|
||
<button onClick={() => addCredits(selectedUser.id, 50)} className="flex-1 py-2 bg-green-50 text-green-600 rounded-lg text-xs font-bold hover:bg-green-100 transition-colors">+50</button>
|
||
<button onClick={() => addCredits(selectedUser.id, 500)} className="flex-1 py-2 bg-green-50 text-green-600 rounded-lg text-xs font-bold hover:bg-green-100 transition-colors">+500</button>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<p className="text-xs font-bold text-red-500 text-center">Deduct</p>
|
||
<div className="flex gap-1">
|
||
<button onClick={() => addCredits(selectedUser.id, -50)} className="flex-1 py-2 bg-red-50 text-red-500 rounded-lg text-xs font-bold hover:bg-red-100 transition-colors">-50</button>
|
||
<button onClick={() => addCredits(selectedUser.id, -500)} className="flex-1 py-2 bg-red-50 text-red-500 rounded-lg text-xs font-bold hover:bg-red-100 transition-colors">-500</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Manual Input */}
|
||
<form
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
const val = Number((e.target as any).amount.value);
|
||
if (val) addCredits(selectedUser.id, val);
|
||
(e.target as any).reset();
|
||
}}
|
||
className="flex gap-2 mt-2"
|
||
>
|
||
<input
|
||
name="amount"
|
||
type="number"
|
||
placeholder="Custom Amount (+/-)"
|
||
className="flex-1 bg-stone-50 border border-stone-200 rounded-xl px-4 py-2 text-sm font-mono focus:ring-2 focus:ring-stone-500 outline-none"
|
||
/>
|
||
<button type="submit" className="px-4 py-2 bg-stone-800 text-white rounded-xl text-xs font-bold hover:bg-stone-700">Apply</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* --- PRICING TAB --- */}
|
||
{activeTab === 'pricing' && (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
{Object.entries(configs).map(([key, value]) => (
|
||
<div key={key} className="bg-white p-4 rounded-xl border border-stone-200 shadow-sm hover:shadow-md transition-shadow">
|
||
<div className="flex justify-between items-start mb-2">
|
||
<div>
|
||
<h4 className="font-bold text-sm text-stone-800 break-all">{key}</h4>
|
||
<p className="text-xs text-stone-400">System Configuration Key</p>
|
||
</div>
|
||
<button
|
||
onClick={() => { setEditingConfig(key); setConfigValue(String(value)); }}
|
||
className="text-blue-500 hover:text-blue-600 text-xs font-bold"
|
||
>
|
||
Edit
|
||
</button>
|
||
</div>
|
||
{editingConfig === key ? (
|
||
<div className="flex gap-2 mt-2">
|
||
<input
|
||
value={configValue}
|
||
onChange={(e) => setConfigValue(e.target.value)}
|
||
className="flex-1 bg-stone-50 border border-stone-300 rounded px-2 py-1 text-sm font-mono"
|
||
/>
|
||
<button onClick={() => updateConfig(key)} className="bg-green-500 text-white px-2 rounded text-xs"><Save className="w-4 h-4" /></button>
|
||
<button onClick={() => setEditingConfig(null)} className="bg-stone-300 text-stone-700 px-2 rounded text-xs">✕</button>
|
||
</div>
|
||
) : (
|
||
<div className="bg-stone-50 p-2 rounded border border-stone-100 font-mono text-xs text-stone-600 break-all">
|
||
{String(value)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* --- ANALYTICS TAB --- */}
|
||
{activeTab === 'analytics' && (
|
||
<div className="space-y-6">
|
||
<div className="bg-white rounded-xl shadow-sm border border-stone-200 p-6">
|
||
<div className="flex items-center justify-between mb-8">
|
||
<div className="flex items-center gap-3">
|
||
<Activity className="w-6 h-6 text-blue-500" />
|
||
<div>
|
||
<h3 className="font-bold text-lg">Financial Performance</h3>
|
||
<p className="text-sm text-stone-500">Revenue (Stripe/Purchase) vs Cost (Gemini API)</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex bg-stone-100 rounded p-1">
|
||
{['24h', '7d', '30d'].map(r => (
|
||
<button
|
||
key={r}
|
||
onClick={() => setAnalyticsRange(r)}
|
||
className={`px-3 py-1 text-xs font-bold rounded ${analyticsRange === r ? 'bg-white shadow text-black' : 'text-stone-500'}`}
|
||
>
|
||
{r}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<AnalyticsChart data={analytics} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Layout>
|
||
);
|
||
};
|
||
|
||
export default AdminDashboard;
|