[UNTEST] CERTIFICATE GENERATION AND BACKEND ENDPOINT IMPLEMENTATION ON CERTIFICATES (CLAIM, GENERATE, FETCH DATA)
- Updated AddInternsModal to allow selection of interns via dropdown instead of email input. - Introduced CertResultTable component for displaying certificate results with search and pagination. - Modified CreateProjectDialog to accept an onSuccess callback for better project creation handling. - Integrated certificate generation functionality in EligibleInternTable with improved error handling. - Enhanced HomeMentorProject and MentorProjectCard to dynamically display project icons and statuses. - Refactored SidebarMentor to improve active route detection. - Added utility functions for certificate preview and download in Certificates.ts. - Removed unused DownloadImagetoDevice utility and related hooks. - Implemented useProjectsByRole hook for fetching projects based on user role. - Updated types for certificates and projects to reflect recent changes in the data structure.
This commit is contained in:
@@ -1,132 +1,44 @@
|
||||
// src/pages/mentor/CertificateResult.tsx
|
||||
import { useState } from "react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
FolderArchive,
|
||||
Search,
|
||||
Eye,
|
||||
Download,
|
||||
Share2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { useProjectCertificates } from "../../../hooks/useCertificates"; //Problemnya cuma role "admin" yang bisa
|
||||
import { CheckCircle2, FolderArchive, Loader2 } from "lucide-react";
|
||||
import { useProjectCertificates } from "../../../hooks/useCertificates";
|
||||
import { useProjectDetail } from "../../../hooks/useProjects";
|
||||
import type { Certificate } from "../../../types/certificate.types";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { downloadAllCertificatesZip } from "../../utils/Certificates";
|
||||
import CertResultTable from "./components/CertResultTable";
|
||||
|
||||
const PER_PAGE = 5;
|
||||
|
||||
|
||||
const getInitials = (name: string): string => {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length === 1) return parts[0][0].toUpperCase();
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
};
|
||||
|
||||
function Avatar({ name }: { name: string }) {
|
||||
// "Kemas M Aryadary Rasyad" → "KR"
|
||||
const color = "bg-gray-100 text-gray-600";
|
||||
return (
|
||||
<div className={`w-9 h-9 rounded-full flex items-center justify-center text-[11px] font-black flex-shrink-0 ${color}`}>
|
||||
{getInitials(name)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CertificateResultSkeleton() {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
{/* Hero */}
|
||||
<div className="flex flex-col items-center text-center mb-8 px-4">
|
||||
<div className="w-16 h-16 rounded-full bg-gray-200 mb-5" />
|
||||
<div className="h-8 w-64 bg-gray-200 rounded-lg mb-3" />
|
||||
<div className="h-4 w-80 bg-gray-100 rounded mb-2" />
|
||||
<div className="h-4 w-64 bg-gray-100 rounded mb-6" />
|
||||
<div className="h-9 w-44 bg-gray-200 rounded-xl" />
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="border border-gray-200 rounded-2xl overflow-hidden bg-white shadow-sm">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
||||
<div className="h-4 w-36 bg-gray-200 rounded" />
|
||||
<div className="h-7 w-44 bg-gray-100 rounded-lg" />
|
||||
</div>
|
||||
|
||||
{/* Column headers */}
|
||||
<div className="grid grid-cols-[2fr_1.5fr_auto] px-5 py-2.5 bg-gray-50 border-b border-gray-100 gap-4">
|
||||
<div className="h-3 w-20 bg-gray-200 rounded" />
|
||||
<div className="h-3 w-24 bg-gray-200 rounded" />
|
||||
<div className="h-3 w-16 bg-gray-200 rounded" />
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
{Array.from({ length: PER_PAGE }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="grid grid-cols-[2fr_1.5fr_auto] items-center px-5 py-3.5 border-b border-gray-50 gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-gray-200 flex-shrink-0" />
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-3.5 w-28 bg-gray-200 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-3 w-32 bg-gray-100 rounded" />
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: 3 }).map((_, j) => (
|
||||
<div key={j} className="w-7 h-7 rounded-md bg-gray-100" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-5 py-3.5 border-t border-gray-100 bg-gray-50/30">
|
||||
<div className="h-3 w-40 bg-gray-100 rounded" />
|
||||
<div className="flex gap-1">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="w-7 h-7 rounded-md bg-gray-200" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
export default function CertificateResult() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [zipping, setZipping] = useState(false);
|
||||
const [zipProgress, setZipProgress] = useState<{ done: number; total: number } | null>(null);
|
||||
|
||||
const { project, loading: projectLoading, error: projectError } = useProjectDetail(slug ?? "");
|
||||
const { certificates, loading: certLoading, error: certError } = useProjectCertificates(
|
||||
project?.id ?? 0
|
||||
);
|
||||
const { certificates, loading: certLoading, error: certError } = useProjectCertificates(project?.id ?? 0);
|
||||
|
||||
const loading = projectLoading || certLoading;
|
||||
const error = projectError || certError;
|
||||
|
||||
if (loading) return <CertificateResultSkeleton />;
|
||||
if (error) return <div className="text-sm text-red-500 p-4">Gagal memuat data sertifikat.</div>;
|
||||
|
||||
// Filter + paginate
|
||||
const filtered = certificates.filter(
|
||||
(c) =>
|
||||
c.user.full_name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.user.email.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
const totalPages = Math.ceil(filtered.length / PER_PAGE);
|
||||
const paginated = filtered.slice((page - 1) * PER_PAGE, page * PER_PAGE);
|
||||
|
||||
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
const handleDownloadZip = async () => {
|
||||
if (!project || certificates.length === 0) return;
|
||||
setZipping(true);
|
||||
setZipProgress({ done: 0, total: certificates.length });
|
||||
try {
|
||||
await downloadAllCertificatesZip(
|
||||
certificates,
|
||||
project.certificate_template ?? "",
|
||||
project.project_name,
|
||||
(done, total) => setZipProgress({ done, total })
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Gagal download ZIP:", err);
|
||||
} finally {
|
||||
setZipping(false);
|
||||
setZipProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <p>Sedang Memuat...</p>;
|
||||
if (error) return <div className="text-sm text-red-500 p-4">Gagal memuat data sertifikat.</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Header */}
|
||||
@@ -137,151 +49,42 @@ export default function CertificateResult() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Success Hero ── */}
|
||||
{/* Success Hero */}
|
||||
<div className="flex flex-col items-center text-center mb-8 px-4">
|
||||
{/* Icon circle */}
|
||||
<div className="w-16 h-16 rounded-full bg-[#B30000] flex items-center justify-center shadow-lg mb-5">
|
||||
<div className="w-16 h-16 rounded-full bg-red flex items-center justify-center shadow-lg mb-5">
|
||||
<CheckCircle2 className="w-8 h-8 text-white" strokeWidth={2.5} />
|
||||
</div>
|
||||
|
||||
{/* Headline */}
|
||||
<h2 className="text-3xl md:text-4xl font-extrabold text-[#B30000] mb-3 leading-tight">
|
||||
{certificates.length} Certificates Successfully Generated!
|
||||
<h2 className="text-3xl md:text-4xl font-extrabold text-red mb-3 leading-tight">
|
||||
{certificates.length} Certificates has been Generated!
|
||||
</h2>
|
||||
|
||||
{/* Subtext */}
|
||||
<p className="text-sm text-gray-500 max-w-md leading-relaxed mb-6">
|
||||
The validation engine has processed all engineering credentials. You can now
|
||||
publish them to the public registry or download the batch for offline distribution.
|
||||
</p>
|
||||
|
||||
{/* Download CTA */}
|
||||
<button className="flex items-center gap-2 px-6 py-2.5 border border-gray-200 rounded-xl text-sm font-bold text-[#B30000] bg-white hover:bg-red-50 hover:border-red-200 shadow-sm transition-colors">
|
||||
<FolderArchive className="w-4 h-4" />
|
||||
Download All (ZIP)
|
||||
<button
|
||||
onClick={handleDownloadZip}
|
||||
disabled={zipping || certificates.length === 0}
|
||||
className="flex items-center gap-2 px-6 py-2.5 border border-gray-200 rounded-xl text-sm font-bold text-red bg-white hover:bg-red-50 hover:border-red-200 shadow-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{zipping ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{zipProgress ? `Mengemas ${zipProgress.done}/${zipProgress.total}...` : "Menyiapkan..."}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FolderArchive className="w-4 h-4" />
|
||||
Download All (ZIP)
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Bottom Section: Table + Preview Card ── */}
|
||||
<div className="flex flex-col xl:flex-row gap-4 items-start">
|
||||
|
||||
{/* Recipient Directory Table */}
|
||||
<div className="flex-1 min-w-0 border border-gray-200 rounded-2xl overflow-hidden bg-white shadow-sm">
|
||||
|
||||
{/* Table Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
||||
<h3 className="text-sm font-bold text-gray-900">Recipient Directory</h3>
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter interns..."
|
||||
value={search}
|
||||
onChange={handleSearch}
|
||||
className="pl-8 pr-3 py-1.5 text-xs border border-gray-200 rounded-lg bg-white text-gray-700 placeholder-gray-400 focus:outline-none focus:border-red-400 focus:ring-1 focus:ring-red-400 w-44 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column Headers */}
|
||||
<div className="grid grid-cols-[2fr_1.5fr_auto] px-5 py-2.5 bg-gray-50 border-b border-gray-100">
|
||||
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Intern Name</span>
|
||||
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Credential ID</span>
|
||||
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Actions</span>
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
<div className="divide-y divide-gray-50">
|
||||
{paginated.length === 0 ? (
|
||||
<div className="px-5 py-10 text-center text-sm text-gray-400">
|
||||
No interns match your search.
|
||||
</div>
|
||||
) : (
|
||||
paginated.map((cert: Certificate) => (
|
||||
<div
|
||||
key={cert.id}
|
||||
className="grid grid-cols-[2fr_1.5fr_auto] items-center px-5 py-3.5 hover:bg-gray-50/60 transition-colors"
|
||||
>
|
||||
{/* Name + role */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar name={cert.user.full_name} />
|
||||
<div>
|
||||
<p className="text-sm font-bold text-gray-900 leading-tight">{cert.user.full_name}</p>
|
||||
{/* <p className="text-[11px] text-gray-400 mt-0.5">{cert.role}</p> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Credential ID */}
|
||||
<p className="text-xs font-mono text-gray-500 tracking-wide">{cert.uuid}</p>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-md transition-colors" title="View">
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-md transition-colors" title="Download">
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
<button className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-md transition-colors" title="Share">
|
||||
<Share2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination Footer */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 px-5 py-3.5 border-t border-gray-100 bg-gray-50/30">
|
||||
<p className="text-xs text-gray-400 font-medium">
|
||||
Showing {filtered.length === 0 ? 0 : (page - 1) * PER_PAGE + 1} to{" "}
|
||||
{Math.min(page * PER_PAGE, filtered.length)} of {certificates.length} interns
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="p-1 border border-gray-200 rounded-md bg-white text-gray-400 hover:bg-gray-50 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => setPage(n)}
|
||||
className={`w-7 h-7 flex items-center justify-center rounded-md text-xs font-bold transition-colors ${n === page
|
||||
? "bg-[#B30000] text-white shadow-sm"
|
||||
: "bg-white border border-gray-200 text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages || totalPages === 0}
|
||||
className="p-1 border border-gray-200 rounded-md bg-white text-gray-400 hover:bg-gray-50 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Card (placeholder — gambar belum diimplementasi) */}
|
||||
{/* <div className="xl:w-64 w-full flex-shrink-0 rounded-2xl overflow-hidden bg-gradient-to-br from-gray-700 via-[#7a1010] to-[#B30000] shadow-md min-h-[200px] flex flex-col justify-end p-5">
|
||||
<div className="mt-auto">
|
||||
<p className="text-white font-extrabold text-base leading-snug">
|
||||
Institutional Excellence
|
||||
</p>
|
||||
<p className="text-red-200 text-xs mt-1">
|
||||
Certified via Mentor Portal v2.4
|
||||
</p>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
</div>
|
||||
{/* Table */}
|
||||
<CertResultTable
|
||||
certificates={certificates}
|
||||
templateUrl={project?.certificate_template ?? ""}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user