- Added plan dropdown to user edit modal - Shows current plan with highlighted card - Allows admin to assign/change user's subscription plan - Loads user's current plan when opening edit modal - Updates plan via setUserPlan service - Visual feedback with success/error messages Admins can now: - View user's current subscription plan - Assign users to different plans (Starter, Bronze, Silver, Gold) - See plan details (credits/month, price) in dropdown This completes the subscription plan system!
393 lines
21 KiB
TypeScript
393 lines
21 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { Edit, Plus, Search, X, Check, AlertCircle } from 'lucide-react';
|
|
import { updateUserCredits, setUserDailyLimit, setUserApiCosts } from '@/services/admin-credit-service';
|
|
import { setUserPlan, getUserSubscription, type SubscriptionPlan } from '@/services/subscription-service';
|
|
|
|
interface User {
|
|
id: string;
|
|
email: string;
|
|
username: string;
|
|
balance: number;
|
|
total_purchased: number;
|
|
total_used: number;
|
|
daily_limit: number | null;
|
|
google_search_cost: number;
|
|
gemini_ai_cost: number;
|
|
last_active?: string;
|
|
}
|
|
|
|
interface UserManagementClientProps {
|
|
initialUsers: User[];
|
|
plans: SubscriptionPlan[];
|
|
}
|
|
|
|
export default function UserManagementClient({ initialUsers, plans }: UserManagementClientProps) {
|
|
const [users, setUsers] = useState<User[]>(initialUsers);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
|
const [creditAmount, setCreditAmount] = useState('');
|
|
const [reason, setReason] = useState('');
|
|
const [dailyLimit, setDailyLimit] = useState('');
|
|
const [googleCost, setGoogleCost] = useState('');
|
|
const [geminiCost, setGeminiCost] = useState('');
|
|
const [selectedPlan, setSelectedPlan] = useState('');
|
|
const [currentPlan, setCurrentPlan] = useState<SubscriptionPlan | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
|
|
const filteredUsers = users.filter(user =>
|
|
user.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
user.username.toLowerCase().includes(searchTerm.toLowerCase())
|
|
);
|
|
|
|
// Load user's current plan when editing
|
|
useEffect(() => {
|
|
if (editingUser) {
|
|
getUserSubscription(editingUser.id).then(({ plan }) => {
|
|
setCurrentPlan(plan);
|
|
setSelectedPlan(plan?.id || '');
|
|
});
|
|
}
|
|
}, [editingUser]);
|
|
|
|
const handleEditUser = (user: User) => {
|
|
setEditingUser(user);
|
|
setCreditAmount('');
|
|
setReason('');
|
|
setDailyLimit(user.daily_limit?.toString() || '');
|
|
setGoogleCost(user.google_search_cost.toString());
|
|
setGeminiCost(user.gemini_ai_cost.toString());
|
|
setMessage(null);
|
|
};
|
|
|
|
const handleUpdatePlan = async () => {
|
|
if (!editingUser || !selectedPlan) {
|
|
setMessage({ type: 'error', text: 'Please select a plan' });
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
setMessage(null);
|
|
|
|
const result = await setUserPlan(editingUser.id, selectedPlan);
|
|
|
|
if (result.success) {
|
|
setMessage({ type: 'success', text: 'Plan updated successfully' });
|
|
// Reload current plan
|
|
const { plan } = await getUserSubscription(editingUser.id);
|
|
setCurrentPlan(plan);
|
|
} else {
|
|
setMessage({ type: 'error', text: result.error || 'Failed to update plan' });
|
|
}
|
|
|
|
setLoading(false);
|
|
};
|
|
|
|
const handleUpdateCredits = async () => {
|
|
if (!editingUser || !creditAmount || !reason) {
|
|
setMessage({ type: 'error', text: 'Please fill in all fields' });
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
setMessage(null);
|
|
|
|
const amount = parseInt(creditAmount);
|
|
const newBalance = editingUser.balance + amount;
|
|
|
|
const result = await updateUserCredits(editingUser.id, newBalance, reason);
|
|
|
|
if (result.success) {
|
|
// Update local state
|
|
setUsers(users.map(u =>
|
|
u.id === editingUser.id
|
|
? { ...u, balance: newBalance, total_purchased: u.total_purchased + (amount > 0 ? amount : 0) }
|
|
: u
|
|
));
|
|
setMessage({ type: 'success', text: `Successfully ${amount > 0 ? 'added' : 'removed'} ${Math.abs(amount)} credits` });
|
|
setCreditAmount('');
|
|
setReason('');
|
|
} else {
|
|
setMessage({ type: 'error', text: result.error || 'Failed to update credits' });
|
|
}
|
|
|
|
setLoading(false);
|
|
};
|
|
|
|
const handleUpdateSettings = async () => {
|
|
if (!editingUser) return;
|
|
|
|
setLoading(true);
|
|
setMessage(null);
|
|
|
|
// Update daily limit
|
|
if (dailyLimit !== (editingUser.daily_limit?.toString() || '')) {
|
|
const limitValue = dailyLimit === '' ? null : parseInt(dailyLimit);
|
|
const limitResult = await setUserDailyLimit(editingUser.id, limitValue);
|
|
if (!limitResult.success) {
|
|
setMessage({ type: 'error', text: 'Failed to update daily limit' });
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Update API costs
|
|
if (googleCost !== editingUser.google_search_cost.toString() || geminiCost !== editingUser.gemini_ai_cost.toString()) {
|
|
const costsResult = await setUserApiCosts(
|
|
editingUser.id,
|
|
parseInt(googleCost),
|
|
parseInt(geminiCost)
|
|
);
|
|
if (!costsResult.success) {
|
|
setMessage({ type: 'error', text: 'Failed to update API costs' });
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Update local state
|
|
setUsers(users.map(u =>
|
|
u.id === editingUser.id
|
|
? {
|
|
...u,
|
|
daily_limit: dailyLimit === '' ? null : parseInt(dailyLimit),
|
|
google_search_cost: parseInt(googleCost),
|
|
gemini_ai_cost: parseInt(geminiCost)
|
|
}
|
|
: u
|
|
));
|
|
|
|
setMessage({ type: 'success', text: 'Settings updated successfully' });
|
|
setLoading(false);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Search Bar */}
|
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={20} />
|
|
<input
|
|
type="text"
|
|
placeholder="Search users by email or username..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="w-full pl-10 pr-4 py-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* User Table */}
|
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
|
<h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Users ({filteredUsers.length})</h2>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b border-zinc-200 dark:border-zinc-800">
|
|
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">User</th>
|
|
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">Balance</th>
|
|
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">Used</th>
|
|
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">Daily Limit</th>
|
|
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">Costs</th>
|
|
<th className="text-right py-3 px-4 text-xs font-black uppercase text-zinc-400">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredUsers.map((user) => (
|
|
<tr key={user.id} className="border-b border-zinc-100 dark:border-zinc-800/50 hover:bg-zinc-50 dark:hover:bg-zinc-800/30">
|
|
<td className="py-3 px-4">
|
|
<div>
|
|
<div className="font-semibold text-zinc-900 dark:text-white">{user.username}</div>
|
|
<div className="text-xs text-zinc-500">{user.email}</div>
|
|
</div>
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
<span className={`text-lg font-black ${user.balance < 10 ? 'text-red-600' : 'text-green-600'}`}>
|
|
{user.balance}
|
|
</span>
|
|
</td>
|
|
<td className="py-3 px-4 text-sm text-zinc-600 dark:text-zinc-400">
|
|
{user.total_used}
|
|
</td>
|
|
<td className="py-3 px-4 text-sm text-zinc-600 dark:text-zinc-400">
|
|
{user.daily_limit || 'Global (80)'}
|
|
</td>
|
|
<td className="py-3 px-4 text-xs text-zinc-500">
|
|
G:{user.google_search_cost} / AI:{user.gemini_ai_cost}
|
|
</td>
|
|
<td className="py-3 px-4 text-right">
|
|
<button
|
|
onClick={() => handleEditUser(user)}
|
|
className="inline-flex items-center gap-2 px-3 py-1.5 bg-amber-600 hover:bg-amber-700 text-white rounded-lg text-xs font-bold transition-colors"
|
|
>
|
|
<Edit size={14} />
|
|
Edit
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Edit Modal */}
|
|
{editingUser && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto border border-zinc-200 dark:border-zinc-800">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h3 className="text-2xl font-black text-zinc-900 dark:text-white">Edit User</h3>
|
|
<p className="text-sm text-zinc-500 mt-1">{editingUser.email}</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setEditingUser(null)}
|
|
className="p-2 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg transition-colors"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
{message && (
|
|
<div className={`mb-4 p-4 rounded-xl flex items-center gap-3 ${message.type === 'success'
|
|
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-500'
|
|
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-500'
|
|
}`}>
|
|
{message.type === 'success' ? <Check size={20} /> : <AlertCircle size={20} />}
|
|
{message.text}
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-6">
|
|
{/* Current Balance */}
|
|
<div className="p-4 bg-zinc-50 dark:bg-zinc-800 rounded-xl">
|
|
<div className="text-xs font-black uppercase text-zinc-400 mb-1">Current Balance</div>
|
|
<div className="text-3xl font-black text-zinc-900 dark:text-white">{editingUser.balance} Credits</div>
|
|
</div>
|
|
|
|
{/* Add/Remove Credits */}
|
|
<div className="space-y-4">
|
|
<h4 className="font-bold text-zinc-900 dark:text-white">Adjust Credits</h4>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-bold text-zinc-400 uppercase mb-2">Amount</label>
|
|
<input
|
|
type="number"
|
|
value={creditAmount}
|
|
onChange={(e) => setCreditAmount(e.target.value)}
|
|
placeholder="e.g. 100 or -50"
|
|
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold text-zinc-400 uppercase mb-2">Reason</label>
|
|
<input
|
|
type="text"
|
|
value={reason}
|
|
onChange={(e) => setReason(e.target.value)}
|
|
placeholder="e.g. Monthly bonus"
|
|
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={handleUpdateCredits}
|
|
disabled={loading || !creditAmount || !reason}
|
|
className="w-full py-3 bg-amber-600 hover:bg-amber-700 text-white font-bold rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
>
|
|
<Plus size={18} />
|
|
Update Credits
|
|
</button>
|
|
</div>
|
|
|
|
<hr className="border-zinc-200 dark:border-zinc-800" />
|
|
|
|
{/* Subscription Plan */}
|
|
<div className="space-y-4">
|
|
<h4 className="font-bold text-zinc-900 dark:text-white">Subscription Plan</h4>
|
|
{currentPlan && (
|
|
<div className="p-4 bg-amber-50 dark:bg-amber-900/20 rounded-xl border border-amber-200 dark:border-amber-800">
|
|
<div className="text-xs font-bold text-amber-600 dark:text-amber-400 uppercase mb-1">Current Plan</div>
|
|
<div className="text-lg font-black text-amber-900 dark:text-amber-100">{currentPlan.display_name}</div>
|
|
<div className="text-sm text-amber-700 dark:text-amber-300 mt-1">{currentPlan.monthly_credits} credits/month</div>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<label className="block text-xs font-bold text-zinc-400 uppercase mb-2">Assign Plan</label>
|
|
<select
|
|
value={selectedPlan}
|
|
onChange={(e) => setSelectedPlan(e.target.value)}
|
|
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50"
|
|
>
|
|
<option value="">Select a plan...</option>
|
|
{plans.map(plan => (
|
|
<option key={plan.id} value={plan.id}>
|
|
{plan.display_name} - {plan.monthly_credits} credits/month (€{plan.price})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<button
|
|
onClick={handleUpdatePlan}
|
|
disabled={loading || !selectedPlan}
|
|
className="w-full py-3 bg-purple-600 hover:bg-purple-700 text-white font-bold rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
>
|
|
<Check size={18} />
|
|
Update Plan
|
|
</button>
|
|
</div>
|
|
|
|
<hr className="border-zinc-200 dark:border-zinc-800" />
|
|
|
|
{/* Settings */}
|
|
<div className="space-y-4">
|
|
<h4 className="font-bold text-zinc-900 dark:text-white">User Settings</h4>
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-bold text-zinc-400 uppercase mb-2">Daily Limit</label>
|
|
<input
|
|
type="number"
|
|
value={dailyLimit}
|
|
onChange={(e) => setDailyLimit(e.target.value)}
|
|
placeholder="Global (80)"
|
|
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold text-zinc-400 uppercase mb-2">Google Cost</label>
|
|
<input
|
|
type="number"
|
|
value={googleCost}
|
|
onChange={(e) => setGoogleCost(e.target.value)}
|
|
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold text-zinc-400 uppercase mb-2">Gemini Cost</label>
|
|
<input
|
|
type="number"
|
|
value={geminiCost}
|
|
onChange={(e) => setGeminiCost(e.target.value)}
|
|
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={handleUpdateSettings}
|
|
disabled={loading}
|
|
className="w-full py-3 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 font-bold rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
>
|
|
<Check size={18} />
|
|
Save Settings
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|