436 lines
18 KiB
TypeScript
436 lines
18 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { Award, Search, Filter, Download, Eye, Calendar, User, BookOpen } from 'lucide-react';
|
|
import toast from 'react-hot-toast';
|
|
|
|
interface Certificate {
|
|
id: number;
|
|
certificateNumber: string;
|
|
issuedAt: string;
|
|
completionDate: string;
|
|
grade: string;
|
|
score: number;
|
|
course: {
|
|
id: number;
|
|
title: string;
|
|
description: string;
|
|
level: string;
|
|
category: string;
|
|
};
|
|
user: {
|
|
id: number;
|
|
firstName: string;
|
|
lastName: string;
|
|
email: string;
|
|
avatar?: string;
|
|
};
|
|
}
|
|
|
|
interface CertificateStats {
|
|
totalCertificates: number;
|
|
thisMonthCertificates: number;
|
|
thisYearCertificates: number;
|
|
averageScore: number;
|
|
}
|
|
|
|
const VendorCertificates: React.FC = () => {
|
|
const [certificates, setCertificates] = useState<Certificate[]>([]);
|
|
const [stats, setStats] = useState<CertificateStats | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [search, setSearch] = useState('');
|
|
const [selectedCourse, setSelectedCourse] = useState<string>('all');
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
const [courses, setCourses] = useState<Array<{ id: number; title: string }>>([]);
|
|
|
|
useEffect(() => {
|
|
fetchCertificates();
|
|
fetchCertificateStats();
|
|
fetchCourses();
|
|
}, [currentPage, search, selectedCourse]);
|
|
|
|
const fetchCertificates = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const token = localStorage.getItem('accessToken');
|
|
const params = new URLSearchParams({
|
|
page: currentPage.toString(),
|
|
limit: '10'
|
|
});
|
|
|
|
if (search) params.append('search', search);
|
|
if (selectedCourse !== 'all') params.append('courseId', selectedCourse);
|
|
|
|
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/training-new/vendor/certificates?${params}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setCertificates(data.data.certificates || []);
|
|
setTotalPages(data.data.totalPages || 1);
|
|
} else {
|
|
toast.error('Failed to fetch certificates');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching certificates:', error);
|
|
toast.error('Failed to fetch certificates');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchCertificateStats = async () => {
|
|
try {
|
|
const token = localStorage.getItem('accessToken');
|
|
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/training-new/vendor/certificates/stats`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setStats(data.data);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching certificate stats:', error);
|
|
}
|
|
};
|
|
|
|
const fetchCourses = async () => {
|
|
try {
|
|
const token = localStorage.getItem('accessToken');
|
|
const response = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:5000/api'}/training-new/courses`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setCourses(data.data.courses || []);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching courses:', error);
|
|
}
|
|
};
|
|
|
|
const handlePageChange = (page: number) => {
|
|
setCurrentPage(page);
|
|
};
|
|
|
|
const handleSearch = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setCurrentPage(1);
|
|
};
|
|
|
|
const handleCourseFilter = (courseId: string) => {
|
|
setSelectedCourse(courseId);
|
|
setCurrentPage(1);
|
|
};
|
|
|
|
const formatDate = (dateString: string) => {
|
|
return new Date(dateString).toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric'
|
|
});
|
|
};
|
|
|
|
const getGradeColor = (grade: string) => {
|
|
switch (grade) {
|
|
case 'Pass': return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
|
case 'Merit': return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
|
|
case 'Distinction': return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200';
|
|
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
|
|
}
|
|
};
|
|
|
|
const getLevelColor = (level: string) => {
|
|
switch (level) {
|
|
case 'Beginner': return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
|
case 'Intermediate': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
|
|
case 'Advanced': return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
|
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
|
|
}
|
|
};
|
|
|
|
if (loading && certificates.length === 0) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-screen">
|
|
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="mb-8">
|
|
<h1 className="text-3xl font-bold text-secondary-900 dark:text-white mb-3">
|
|
Certificates Issued
|
|
</h1>
|
|
<p className="text-secondary-600 dark:text-secondary-400 text-lg">
|
|
Track all certificates issued to your resellers
|
|
</p>
|
|
</div>
|
|
|
|
{/* Stats Cards */}
|
|
{stats && (
|
|
<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-gray-600 dark:text-gray-400 mb-2">
|
|
Total Certificates
|
|
</p>
|
|
<p className="text-3xl font-bold text-blue-600 dark:text-blue-400">
|
|
{stats.totalCertificates}
|
|
</p>
|
|
</div>
|
|
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
|
<Award className="w-6 h-6 text-blue-600 dark:text-blue-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-gray-600 dark:text-gray-400 mb-2">
|
|
This Month
|
|
</p>
|
|
<p className="text-3xl font-bold text-green-600 dark:text-green-400">
|
|
{stats.thisMonthCertificates}
|
|
</p>
|
|
</div>
|
|
<div className="w-12 h-12 bg-green-100 dark:bg-green-900 rounded-full flex items-center justify-center">
|
|
<Calendar className="w-6 h-6 text-green-600 dark:text-green-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-gray-600 dark:text-gray-400 mb-2">
|
|
This Year
|
|
</p>
|
|
<p className="text-3xl font-bold text-yellow-600 dark:text-yellow-400">
|
|
{stats.thisYearCertificates}
|
|
</p>
|
|
</div>
|
|
<div className="w-12 h-12 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center">
|
|
<Calendar 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-gray-600 dark:text-gray-400 mb-2">
|
|
Average Score
|
|
</p>
|
|
<p className="text-3xl font-bold text-purple-600 dark:text-purple-400">
|
|
{stats.averageScore}%
|
|
</p>
|
|
</div>
|
|
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900 rounded-full flex items-center justify-center">
|
|
<BookOpen className="w-6 h-6 text-purple-600 dark:text-purple-400" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Filters and Search */}
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
|
|
<div className="flex flex-col lg:flex-row gap-4 items-center justify-between">
|
|
<div className="flex flex-col sm:flex-row gap-4 flex-1">
|
|
<form onSubmit={handleSearch} className="flex-1">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search by reseller name, email, or course..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="w-full pl-10 pr-4 py-2 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"
|
|
/>
|
|
</div>
|
|
</form>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<Filter className="w-4 h-4 text-gray-400" />
|
|
<select
|
|
value={selectedCourse}
|
|
onChange={(e) => handleCourseFilter(e.target.value)}
|
|
className="px-3 py-2 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"
|
|
>
|
|
<option value="all">All Courses</option>
|
|
{courses.map((course) => (
|
|
<option key={course.id} value={course.id}>
|
|
{course.title}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Certificates Table */}
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
|
<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-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
Reseller
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
Course
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
Certificate
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
Grade & Score
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
Issued Date
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
Actions
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
|
{certificates.map((certificate) => (
|
|
<tr key={certificate.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="flex items-center">
|
|
<div className="w-10 h-10 bg-gray-200 dark:bg-gray-600 rounded-full flex items-center justify-center mr-3">
|
|
{certificate.user.avatar ? (
|
|
<img src={certificate.user.avatar} alt={`${certificate.user.firstName} ${certificate.user.lastName}`} className="w-10 h-10 rounded-full" />
|
|
) : (
|
|
<span className="text-gray-600 dark:text-gray-400 font-medium">
|
|
{certificate.user.firstName.charAt(0)}{certificate.user.lastName.charAt(0)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
|
{certificate.user.firstName} {certificate.user.lastName}
|
|
</div>
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
{certificate.user.email}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div>
|
|
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
|
{certificate.course.title}
|
|
</div>
|
|
<div className="flex items-center space-x-2 mt-1">
|
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getLevelColor(certificate.course.level)}`}>
|
|
{certificate.course.level}
|
|
</span>
|
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
{certificate.course.category}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm text-gray-900 dark:text-white font-mono">
|
|
{certificate.certificateNumber}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="flex items-center space-x-2">
|
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getGradeColor(certificate.grade)}`}>
|
|
{certificate.grade}
|
|
</span>
|
|
<span className="text-sm text-gray-900 dark:text-white">
|
|
{certificate.score}%
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
|
{formatDate(certificate.issuedAt)}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
|
<div className="flex space-x-2">
|
|
<button
|
|
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
|
|
title="View Certificate"
|
|
>
|
|
<Eye className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
className="text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300"
|
|
title="Download Certificate"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{certificates.length === 0 && !loading && (
|
|
<div className="text-center py-12">
|
|
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
<Award className="w-8 h-8 text-gray-400" />
|
|
</div>
|
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
|
No certificates issued yet
|
|
</h3>
|
|
<p className="text-gray-500 dark:text-gray-400">
|
|
Certificates will appear here once resellers complete your courses
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-sm text-gray-700 dark:text-gray-300">
|
|
Page {currentPage} of {totalPages}
|
|
</div>
|
|
<div className="flex space-x-2">
|
|
<button
|
|
onClick={() => handlePageChange(currentPage - 1)}
|
|
disabled={currentPage === 1}
|
|
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md 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 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Previous
|
|
</button>
|
|
<button
|
|
onClick={() => handlePageChange(currentPage + 1)}
|
|
disabled={currentPage === totalPages}
|
|
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md 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 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default VendorCertificates;
|