508 lines
22 KiB
TypeScript
508 lines
22 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { formatCurrency, formatNumber, formatDate } from '../../utils/format';
|
|
import Modal from '../../components/Modal';
|
|
import DetailView from '../../components/DetailView';
|
|
import toast from 'react-hot-toast';
|
|
import {
|
|
Search,
|
|
Filter,
|
|
MoreVertical,
|
|
Eye,
|
|
Edit,
|
|
CheckCircle,
|
|
XCircle,
|
|
Clock,
|
|
TrendingUp,
|
|
Users,
|
|
DollarSign,
|
|
Calendar,
|
|
Handshake,
|
|
AlertCircle,
|
|
Download,
|
|
Mail,
|
|
MapPin,
|
|
Building2,
|
|
User,
|
|
Phone
|
|
} from 'lucide-react';
|
|
import { cn } from '../../utils/cn';
|
|
|
|
interface Reseller {
|
|
id: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
email: string;
|
|
phone?: string;
|
|
status: string;
|
|
tier?: string;
|
|
totalRevenue?: number;
|
|
lastActive?: string;
|
|
customers?: number;
|
|
commissionRate?: number;
|
|
region?: string;
|
|
avatar?: string;
|
|
company?: string;
|
|
createdAt?: string;
|
|
}
|
|
|
|
const ApprovedResellersPage: React.FC = () => {
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [statusFilter, setStatusFilter] = useState('active');
|
|
const [tierFilter, setTierFilter] = useState('all');
|
|
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
|
|
const [selectedReseller, setSelectedReseller] = useState<Reseller | null>(null);
|
|
const [resellers, setResellers] = useState<Reseller[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Set page title
|
|
useEffect(() => {
|
|
document.title = 'All Resellers - Cloudtopiaa';
|
|
}, []);
|
|
|
|
// Fetch approved resellers from API
|
|
useEffect(() => {
|
|
fetchApprovedResellers();
|
|
}, []);
|
|
|
|
const fetchApprovedResellers = async () => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
const token = localStorage.getItem('accessToken');
|
|
if (!token) {
|
|
setError('Authentication required. Please log in again.');
|
|
return;
|
|
}
|
|
|
|
// Fetch all resellers (not just active ones)
|
|
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/vendors/resellers`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch resellers');
|
|
}
|
|
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
// Filter out pending resellers - only show non-pending ones
|
|
const nonPendingResellers = (data.data.resellers || []).filter(
|
|
(reseller: Reseller) => reseller.status !== 'pending'
|
|
);
|
|
setResellers(nonPendingResellers);
|
|
} else {
|
|
const errorMsg = data.message || 'Failed to fetch resellers';
|
|
setError(errorMsg);
|
|
}
|
|
} catch (error: any) {
|
|
console.error('Error fetching approved resellers:', error);
|
|
const errorMsg = error.message || 'Failed to fetch approved resellers';
|
|
setError(errorMsg);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// Filter resellers (all non-pending ones)
|
|
const filteredResellers = resellers.filter(reseller => {
|
|
const fullName = `${reseller.firstName} ${reseller.lastName}`.toLowerCase();
|
|
const matchesSearch = fullName.includes(searchTerm.toLowerCase()) ||
|
|
reseller.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
(reseller.company && reseller.company.toLowerCase().includes(searchTerm.toLowerCase()));
|
|
const matchesStatus = statusFilter === 'all' || reseller.status === statusFilter;
|
|
const matchesTier = tierFilter === 'all' || reseller.tier === tierFilter;
|
|
|
|
return matchesSearch && matchesStatus && matchesTier;
|
|
});
|
|
|
|
const getStatusColor = (status: string) => {
|
|
switch (status) {
|
|
case 'active':
|
|
return 'bg-success-100 text-success-800 dark:bg-success-900 dark:text-success-300';
|
|
case 'pending':
|
|
return 'bg-warning-100 text-warning-800 dark:bg-warning-900 dark:text-warning-300';
|
|
case 'suspended':
|
|
return 'bg-danger-100 text-danger-800 dark:bg-danger-900 dark:text-danger-300';
|
|
default:
|
|
return 'bg-secondary-100 text-secondary-800 dark:bg-secondary-900 dark:text-secondary-300';
|
|
}
|
|
};
|
|
|
|
const getTierColor = (tier: string) => {
|
|
switch (tier) {
|
|
case 'platinum':
|
|
return 'bg-gradient-to-r from-yellow-400 to-yellow-600 text-white';
|
|
case 'gold':
|
|
return 'bg-gradient-to-r from-yellow-500 to-yellow-700 text-white';
|
|
case 'silver':
|
|
return 'bg-gradient-to-r from-gray-400 to-gray-600 text-white';
|
|
default:
|
|
return 'bg-secondary-100 text-secondary-800 dark:bg-secondary-900 dark:text-secondary-300';
|
|
}
|
|
};
|
|
|
|
const handleViewReseller = (reseller: Reseller) => {
|
|
setSelectedReseller(reseller);
|
|
setIsDetailModalOpen(true);
|
|
};
|
|
|
|
const handleEditReseller = (reseller: Reseller) => {
|
|
toast(`Edit functionality for ${reseller.firstName} ${reseller.lastName} - This would open an edit form`);
|
|
};
|
|
|
|
const handleMailReseller = (reseller: Reseller) => {
|
|
const mailtoLink = `mailto:${reseller.email}?subject=Cloudtopiaa Partnership Update`;
|
|
window.open(mailtoLink, '_blank');
|
|
toast.success(`Opening email client for ${reseller.firstName} ${reseller.lastName}`);
|
|
};
|
|
|
|
const handleMoreOptions = (reseller: Reseller) => {
|
|
const options = [
|
|
'View Performance',
|
|
'Download Report',
|
|
'Send Notification',
|
|
'Change Terms',
|
|
'Suspend Partnership'
|
|
];
|
|
const selectedOption = prompt(`Select an option for ${reseller.firstName} ${reseller.lastName}:\n${options.join('\n')}`);
|
|
if (selectedOption) {
|
|
toast.success(`Selected: ${selectedOption} for ${reseller.firstName} ${reseller.lastName}`);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return <div className="text-center py-8">Loading...</div>;
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="p-6 space-y-6">
|
|
<div className="text-center py-8">
|
|
<AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
|
<p className="text-lg text-red-600 dark:text-red-400">{error}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-6 space-y-8">
|
|
{/* Header */}
|
|
<div className="mb-8">
|
|
<h1 className="text-3xl font-bold text-secondary-900 dark:text-white mb-3">
|
|
All Resellers
|
|
</h1>
|
|
<p className="text-secondary-600 dark:text-secondary-400 text-lg">
|
|
Manage all reseller partnerships (active, inactive, rejected, etc.)
|
|
</p>
|
|
</div>
|
|
|
|
{/* Action Buttons */}
|
|
<div className="flex justify-between items-center mb-6">
|
|
<div className="flex space-x-3">
|
|
<button className="inline-flex items-center px-4 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-all duration-200">
|
|
<Download className="w-4 h-4 mr-2" />
|
|
Export
|
|
</button>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{/* Stats Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium text-secondary-600 dark:text-secondary-400 mb-2">
|
|
Total Resellers
|
|
</p>
|
|
<p className="text-3xl font-bold text-secondary-900 dark:text-white">
|
|
{resellers.length}
|
|
</p>
|
|
</div>
|
|
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center flex-shrink-0">
|
|
<Building2 className="w-6 h-6 text-primary-600 dark:text-primary-400" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium text-secondary-600 dark:text-secondary-400 mb-2">
|
|
Platinum Tier
|
|
</p>
|
|
<p className="text-3xl font-bold text-secondary-900 dark:text-white">
|
|
{resellers.filter(r => r.tier === 'platinum').length}
|
|
</p>
|
|
</div>
|
|
<div className="w-12 h-12 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center flex-shrink-0">
|
|
<TrendingUp className="w-6 h-6 text-yellow-600 dark:text-yellow-400" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium text-secondary-600 dark:text-secondary-400 mb-2">
|
|
Total Customers
|
|
</p>
|
|
<p className="text-3xl font-bold text-secondary-900 dark:text-white">
|
|
{resellers.reduce((sum, r) => sum + (r.customers || 0), 0)}
|
|
</p>
|
|
</div>
|
|
<div className="w-12 h-12 bg-success-100 dark:bg-success-900 rounded-full flex items-center justify-center flex-shrink-0">
|
|
<Users className="w-6 h-6 text-success-600 dark:text-success-400" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium text-secondary-600 dark:text-secondary-400 mb-2">
|
|
Total Revenue
|
|
</p>
|
|
<p className="text-3xl font-bold text-secondary-900 dark:text-white">
|
|
{formatCurrency(resellers.reduce((sum, r) => sum + (r.totalRevenue || 0), 0))}
|
|
</p>
|
|
</div>
|
|
<div className="w-12 h-12 bg-success-100 dark:bg-success-900 rounded-full flex items-center justify-center flex-shrink-0">
|
|
<DollarSign className="w-6 h-6 text-success-600 dark:text-success-400" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
{/* Search */}
|
|
<div className="lg:col-span-2">
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Search Resellers
|
|
</label>
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search by name, email, or company..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="w-full pl-12 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status Filter */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Filter by Status
|
|
</label>
|
|
<select
|
|
value={statusFilter}
|
|
onChange={(e) => setStatusFilter(e.target.value)}
|
|
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all"
|
|
>
|
|
<option value="all">All Statuses</option>
|
|
<option value="active">Active</option>
|
|
<option value="inactive">Inactive</option>
|
|
<option value="rejected">Rejected</option>
|
|
<option value="suspended">Suspended</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tier Filter Row */}
|
|
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
|
<div className="flex items-center space-x-4">
|
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
Filter by Tier:
|
|
</label>
|
|
<select
|
|
value={tierFilter}
|
|
onChange={(e) => setTierFilter(e.target.value)}
|
|
className="flex-1 max-w-xs px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all"
|
|
>
|
|
<option value="all">All Tiers</option>
|
|
<option value="platinum">Platinum</option>
|
|
<option value="gold">Gold</option>
|
|
<option value="silver">Silver</option>
|
|
<option value="bronze">Bronze</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content Area */}
|
|
{loading ? (
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 p-12">
|
|
<div className="text-center">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
|
<p className="text-gray-600 dark:text-gray-400">Loading resellers...</p>
|
|
</div>
|
|
</div>
|
|
) : resellers.length === 0 ? (
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 p-12">
|
|
<div className="text-center">
|
|
<Users className="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">No resellers found</h3>
|
|
<p className="text-gray-500 dark:text-gray-400 mb-6">
|
|
You don't have any reseller partnerships yet.
|
|
</p>
|
|
<div className="space-y-3">
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
To get started:
|
|
</p>
|
|
<ul className="text-sm text-gray-500 dark:text-gray-400 space-y-1">
|
|
<li>• Approve qualified reseller applications</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
|
Reseller List ({resellers.length})
|
|
</h2>
|
|
</div>
|
|
|
|
{/* Resellers Table */}
|
|
<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-700">
|
|
<tr>
|
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Reseller
|
|
</th>
|
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Contact
|
|
</th>
|
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Tier
|
|
</th>
|
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Status
|
|
</th>
|
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Performance
|
|
</th>
|
|
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Actions
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
|
{resellers.map((reseller) => (
|
|
<tr key={reseller.id} className="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="flex items-center">
|
|
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center flex-shrink-0">
|
|
{reseller.avatar ? (
|
|
<img src={reseller.avatar} alt={`${reseller.firstName} ${reseller.lastName}`} className="w-12 h-12 rounded-full" />
|
|
) : (
|
|
<User className="w-6 h-6 text-primary-600 dark:text-primary-400" />
|
|
)}
|
|
</div>
|
|
<div className="ml-4 min-w-0">
|
|
<div className="text-sm font-semibold text-gray-900 dark:text-white truncate">
|
|
{reseller.firstName} {reseller.lastName}
|
|
</div>
|
|
<div className="text-sm text-gray-500 dark:text-gray-400 truncate">
|
|
{reseller.company || 'No company'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="space-y-1">
|
|
<div className="flex items-center">
|
|
<Mail className="w-4 h-4 mr-2 text-gray-400 flex-shrink-0" />
|
|
<span className="text-sm text-gray-900 dark:text-white truncate max-w-48">
|
|
{reseller.email}
|
|
</span>
|
|
</div>
|
|
{reseller.phone && (
|
|
<div className="flex items-center">
|
|
<Phone className="w-4 h-4 mr-2 text-gray-400 flex-shrink-0" />
|
|
<span className="text-sm text-gray-900 dark:text-white">
|
|
{reseller.phone}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<span className={`inline-flex px-3 py-1 text-xs font-semibold rounded-full ${getTierColor(reseller.tier || 'standard')}`}>
|
|
{reseller.tier ? reseller.tier.charAt(0).toUpperCase() + reseller.tier.slice(1) : 'Standard'}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<span className={`inline-flex px-3 py-1 text-xs font-semibold rounded-full ${getStatusColor(reseller.status)}`}>
|
|
{reseller.status.charAt(0).toUpperCase() + reseller.status.slice(1)}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm text-gray-900 dark:text-white">
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span>Customers:</span>
|
|
<span className="font-medium">{reseller.customers || 0}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span>Revenue:</span>
|
|
<span className="font-medium">{formatCurrency(reseller.totalRevenue || 0)}</span>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="flex items-center space-x-2">
|
|
<button
|
|
onClick={() => handleViewReseller(reseller)}
|
|
className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors"
|
|
title="View Details"
|
|
>
|
|
<Eye className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
|
</button>
|
|
<button
|
|
onClick={() => handleEditReseller(reseller)}
|
|
className="p-2 bg-amber-100 dark:bg-amber-900 rounded-lg hover:bg-amber-200 dark:hover:bg-amber-800 transition-colors"
|
|
title="Edit"
|
|
>
|
|
<Edit className="w-4 h-4 text-amber-600 dark:text-amber-400" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Reseller Detail Modal */}
|
|
<Modal
|
|
isOpen={isDetailModalOpen}
|
|
onClose={() => setIsDetailModalOpen(false)}
|
|
title="Reseller Details"
|
|
size="lg"
|
|
>
|
|
{selectedReseller && (
|
|
<DetailView
|
|
type="reseller"
|
|
data={selectedReseller}
|
|
/>
|
|
)}
|
|
</Modal>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ApprovedResellersPage;
|