feat: add progress bar component and integrate into certificate detail page

- Implemented ProgressBar component for visual task completion representation.
- Created SertificatePage to display certificate details and handle claiming logic.
- Updated CertificateAvailable component to support dynamic certificate data.
- Enhanced CertificateNotAvailable component with error handling and claim functionality.
- Refactored CertificateHistory component to use updated hooks for fetching certificates.
- Modified CertificateForm to utilize new hooks for uploading certificate templates.
- Introduced CertificateResult component for displaying generated certificates with pagination.
- Added EligibleInternTable component for managing eligible interns and generating certificates.
- Developed utility functions for generating certificates as images.
- Created hooks for managing certificate-related API interactions.
- Updated project and certificate types to include new properties for templates and images.
This commit is contained in:
Kemas M. Aryadary Rasyad
2026-06-30 12:28:17 +07:00
parent 28bd1d3d87
commit f22e4683a7
16 changed files with 412 additions and 217 deletions
+3
View File
@@ -15,6 +15,9 @@
"@tiptap/starter-kit": "^2.23.0", "@tiptap/starter-kit": "^2.23.0",
"axios": "^1.10.0", "axios": "^1.10.0",
"froala-editor": "^4.5.2", "froala-editor": "^4.5.2",
"html2canvas": "^1.4.1",
"jspdf": "^4.2.1",
"jszip": "^3.10.1",
"lucide-react": "^0.553.0", "lucide-react": "^0.553.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
+3 -3
View File
@@ -31,7 +31,7 @@ import EditLowongan from "./Pages/EditLowongan";
import HomeInternPage from "./Pages/Internify/intern/HomeInternPage"; import HomeInternPage from "./Pages/Internify/intern/HomeInternPage";
import { InternLayout, MentorLayout } from "./Pages/Internify/layout"; import { InternLayout, MentorLayout } from "./Pages/Internify/layout";
import ProjectsPage from "./Pages/Internify/intern/ProjectsPage"; import ProjectsPage from "./Pages/Internify/intern/ProjectsPage";
import SertificatePage from "./Pages/Internify/intern/InternClaimCert"; import SertificatePage from "./Pages/Internify/intern/InternCertiDetail";
import ProjectDetailPage from "./Pages/Internify/intern/ProjectDetailPage"; import ProjectDetailPage from "./Pages/Internify/intern/ProjectDetailPage";
import HomeMentorPage from "./Pages/Internify/mentor/HomeMentorPage"; import HomeMentorPage from "./Pages/Internify/mentor/HomeMentorPage";
import MentorProjectsPage from "./Pages/Internify/mentor/BaseProjectsPage"; import MentorProjectsPage from "./Pages/Internify/mentor/BaseProjectsPage";
@@ -118,8 +118,8 @@ function App() {
<Route index element={<HomeMentorPage />} /> <Route index element={<HomeMentorPage />} />
<Route path="projects" element={<MentorProjectsPage />} /> <Route path="projects" element={<MentorProjectsPage />} />
<Route path="certificates" element={<MentorCertificatePage />} /> <Route path="certificates" element={<MentorCertificatePage />} />
<Route path="certificates/:slug" element={<CertificateDetail />} /> <Route path="certificates/:id" element={<CertificateDetail />} />
<Route path="certificates/result" element={<CertificateResult />} /> <Route path="certificates/:id/result" element={<CertificateResult />} />
<Route path="intern" element={<MentorInternPage />} /> <Route path="intern" element={<MentorInternPage />} />
<Route path="projects/:slug" element={<MentorDetailProject />} /> <Route path="projects/:slug" element={<MentorDetailProject />} />
<Route path="projects/:slug/:taskSlug" element={<MentorProjectsDetailPage />} /> <Route path="projects/:slug/:taskSlug" element={<MentorProjectsDetailPage />} />
+9
View File
@@ -0,0 +1,9 @@
// src/components/common/Avatar.tsx
export default function Avatar({ initials }: { initials: string }) {
return (
<div className="w-10 h-10 rounded-full flex items-center justify-center text-xs text-white flex-shrink-0 bg-primary">
{initials}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
interface ProgressBarProps {
value: number;
total: number;
}
export default function ProgressBar({ value, total }: ProgressBarProps) {
const pct = total === 0 ? 0 : Math.round((value / total) * 100);
const isComplete = value === total;
return (
<div className="flex items-center gap-2">
<div className="w-16 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${isComplete ? "bg-[#B30000]" : "bg-gray-400"}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className={`text-xs font-bold ${isComplete ? "text-[#B30000]" : "text-gray-500"}`}>
{value}/{total}
</span>
</div>
);
}
@@ -1,10 +1,10 @@
// src/pages/intern/SertificatePage.tsx
import { useMemo } from "react"; import { useMemo } from "react";
import { useParams } from "react-router-dom"; import { useParams, useLocation } from "react-router-dom";
import { useLocation } from "react-router-dom";
import CertificateAvailable from "./components/CertificateAvailabe"; import CertificateAvailable from "./components/CertificateAvailabe";
import CertificateNotAvailable from "./components/CertificateNotAvailable"; import CertificateNotAvailable from "./components/CertificateNotAvailable";
import SertificateHistory from "./components/CertificateHistory"; import SertificateHistory from "./components/CertificateHistory";
import { useMyCertificates } from "../../../hooks/useSertificates"; import { useMyCertificates, useClaimCertificate } from "../../../hooks/useCertificates";
import { useProjectDetail } from "../../../hooks/useProjects"; import { useProjectDetail } from "../../../hooks/useProjects";
import type { Project } from "../../../types/project.types"; import type { Project } from "../../../types/project.types";
@@ -14,7 +14,8 @@ const SertificatePage = () => {
const projectFromList = location.state?.project as Project | undefined; const projectFromList = location.state?.project as Project | undefined;
const { project: projectDetail, loading: projectLoading } = useProjectDetail(slug ?? ""); const { project: projectDetail, loading: projectLoading } = useProjectDetail(slug ?? "");
const { certificates, loading: certLoading } = useMyCertificates(); const { certificates, loading: certLoading, refetch: refetchCertificates } = useMyCertificates();
const { claim, loading: claiming, error: claimError } = useClaimCertificate();
const project = useMemo(() => { const project = useMemo(() => {
if (!projectDetail) return null; if (!projectDetail) return null;
@@ -27,20 +28,27 @@ const SertificatePage = () => {
const allTasksDone = !projectLoading && !!project && project.task_done >= project.total_tasks; const allTasksDone = !projectLoading && !!project && project.task_done >= project.total_tasks;
const alreadyDone = useMemo(() => { const myCertificateForProject = useMemo(() => {
if (!project || !certificates.length) return false; if (!project || !certificates.length) return null;
return certificates.some((cert) => cert.project.id === project.id); return certificates.find((cert) => cert.project.id === project.id) ?? null;
}, [certificates, project]); }, [certificates, project]);
const alreadyDone = !!myCertificateForProject;
const progress = const progress =
project && project.total_tasks > 0 project && project.total_tasks > 0
? Math.round((project.task_done / project.total_tasks) * 100) ? Math.round((project.task_done / project.total_tasks) * 100)
: 0; : 0;
const remainingTasks = project ? project.total_tasks - project.task_done : 0; const remainingTasks = project ? project.total_tasks - project.task_done : 0;
const isLoading = projectLoading || certLoading; const isLoading = projectLoading || certLoading;
const showCertificate = alreadyDone;
const handleClaim = async () => {
if (!project?.id) return;
const result = await claim(project.id);
if (result) {
refetchCertificates();
}
};
if (isLoading) { if (isLoading) {
return ( return (
@@ -49,7 +57,7 @@ const SertificatePage = () => {
</div> </div>
); );
} }
return ( return (
<div> <div>
<div className="mb-4 flex flex-col gap-1"> <div className="mb-4 flex flex-col gap-1">
@@ -58,14 +66,19 @@ const SertificatePage = () => {
</div> </div>
<div className="flex flex-col lg:flex-row gap-6 items-stretch"> <div className="flex flex-col lg:flex-row gap-6 items-stretch">
<div className="w-full lg:w-5/8 flex flex-col"> <div className="w-full lg:w-5/8 flex flex-col">
{showCertificate ? ( {alreadyDone ? (
<CertificateAvailable project={project} /> <CertificateAvailable
project={project}
certificate={myCertificateForProject}
/>
) : ( ) : (
<CertificateNotAvailable <CertificateNotAvailable
progress={progress} progress={progress}
remainingTasks={remainingTasks} remainingTasks={remainingTasks}
allTasksDone={allTasksDone} allTasksDone={allTasksDone}
project_id={project?.id} onClaim={handleClaim}
claiming={claiming}
claimError={claimError}
/> />
)} )}
</div> </div>
@@ -1,13 +1,15 @@
// CertificateAvailable.tsx
import { useState } from "react"; import { useState } from "react";
import { Download, Link, Printer } from "lucide-react"; import { Download, Link, Printer } from "lucide-react";
import type { ProjectDetail } from "../../../../types/project.types"; import type { ProjectDetail } from "../../../../types/project.types";
import certificateImg from "../../../../assets/certificate.png"; import type { Certificate } from "../../../../types/certificate.types";
interface CertificateAvailableProps { interface CertificateAvailableProps {
project: ProjectDetail | null; project: ProjectDetail | null;
certificate: Certificate;
} }
export default function CertificateAvailable({ project }: CertificateAvailableProps) { export default function CertificateAvailable({ project, certificate }: CertificateAvailableProps) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const handleCopyLink = () => { const handleCopyLink = () => {
@@ -31,80 +33,89 @@ export default function CertificateAvailable({ project }: CertificateAvailablePr
})() })()
: "-"; : "-";
const handleDownload = () => {
const a = document.createElement("a");
a.href = certificate.image_path;
a.download = `Sertifikat - ${certificate.certificate_no}.png`;
a.target = "_blank";
a.click();
};
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Certificate Card */} {/* Certificate Card */}
<div className="flex rounded-xl p-5 mb-1 border bg-box-primary border-box-border shadow-md items-center justify-center"> <div className="flex rounded-xl p-5 mb-1 border bg-box-primary border-box-border shadow-md items-center justify-center">
<img <img
src={certificateImg} src={certificate.image_path}
alt="Certificate" alt="Certificate"
className="rounded-xl w-[550px] h-[350px]" className="rounded-xl w-[550px] h-[350px] object-contain"
/> />
</div> </div>
{/* Program Summary */} {/* Program Summary */}
<div className="grid grid-cols-2 gap-5"> <div className="grid grid-cols-2 gap-5">
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm p-5"> <div className="bg-white rounded-2xl border border-card-outline shadow-sm p-5">
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<h3 className="font-semibold text-gray-800 text-sm">Ringkasan Program</h3> <h3 className="font-semibold text-gray-800 text-sm">Ringkasan Program</h3>
</div>
<div className="space-y-3">
{[
{ label: "Nama Program", value: project?.project_name ?? "-" },
{ label: "Durasi", value: duration },
{ label: "Total Tugas", value: project ? `${project.total_tasks} Tugas` : "-" },
{
label: "Status",
value: (
<span className="bg-green-100 text-green-700 text-xs font-bold px-2 py-0.5 rounded-full">
UNVERIFIED
</span>
),
},
].map((row) => (
<div
key={row.label}
className="flex justify-between items-center text-sm border-b border-gray-50 pb-2 last:border-0"
>
<span className="text-gray-500">{row.label}</span>
<span className="text-gray-800 font-medium">{row.value}</span>
</div>
))}
</div>
</div> </div>
<div className="space-y-3">
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm p-5"> {[
<div className="flex flex-col gap-3"> { label: "Nama Program", value: project?.project_name ?? "-" },
{ label: "Durasi", value: duration },
{/* Unduh Sertifikat */} { label: "Total Tugas", value: project ? `${project.total_tasks} Tugas` : "-" },
<button className="flex items-center justify-center gap-2 w-full bg-red-700 hover:bg-red-800 active:scale-95 transition-all text-white font-semibold py-2 rounded-xl text-sm"> {
<Download className="w-4 h-4" /> label: "Status",
Unduh Sertifikat (PDF) value: (
</button> <span className="bg-green-100 text-green-700 text-xs font-bold px-2 py-0.5 rounded-full">
{certificate.certificate_status.toUpperCase()}
{/* Bagikan ke LinkedIn */} </span>
<button className="flex items-center justify-center gap-2 w-full bg-blue-600 hover:bg-blue-700 active:scale-95 transition-all text-white font-semibold py-2 rounded-xl text-sm"> ),
Bagikan ke LinkedIn },
</button> ].map((row) => (
<div
{/* Cetak & Salin Link */} key={row.label}
<div className="grid grid-cols-2 gap-3"> className="flex justify-between items-center text-sm border-b border-card-outline pb-2 last:border-0"
<button className="flex flex-col items-center justify-center gap-1.5 border border-gray-200 hover:bg-gray-50 active:scale-95 transition-all text-red-700 font-medium py-3.5 rounded-xl text-sm"> >
<Printer className="w-5 h-5" /> <span className="text-gray-500">{row.label}</span>
Cetak <span className="text-gray-800 font-medium">{row.value}</span>
</button>
<button
onClick={handleCopyLink}
className="flex flex-col items-center justify-center gap-1.5 border border-gray-200 hover:bg-gray-50 active:scale-95 transition-all text-red-700 font-medium py-3.5 rounded-xl text-sm"
>
<Link className="w-5 h-5" />
{copied ? "Copied!" : "Salin Link"}
</button>
</div> </div>
))}
</div>
</div>
<div className="bg-white rounded-2xl border border-card-outline shadow-sm p-5">
<div className="flex flex-col gap-3">
{/* Unduh Sertifikat */}
<button
onClick={handleDownload}
className="flex items-center justify-center gap-2 w-full bg-red-700 hover:bg-red-800 active:scale-95 transition-all text-white font-semibold py-2 rounded-xl text-sm"
>
<Download className="w-4 h-4" />
Unduh Sertifikat
</button>
{/* Bagikan ke LinkedIn */}
<button className="flex items-center justify-center gap-2 w-full bg-blue-600 hover:bg-blue-700 active:scale-95 transition-all text-white font-semibold py-2 rounded-xl text-sm">
Bagikan ke LinkedIn
</button>
{/* Cetak & Salin Link */}
<div className="grid grid-cols-2 gap-3">
<button className="flex flex-col items-center justify-center gap-1.5 border border-card-outline hover:bg-gray-50 active:scale-95 transition-all text-red-700 font-medium py-3.5 rounded-xl text-sm">
<Printer className="w-5 h-5" />
Cetak
</button>
<button
onClick={handleCopyLink}
className="flex flex-col items-center justify-center gap-1.5 border border-card-outline hover:bg-gray-50 active:scale-95 transition-all text-red-700 font-medium py-3.5 rounded-xl text-sm"
>
<Link className="w-5 h-5" />
{copied ? "Copied!" : "Salin Link"}
</button>
</div> </div>
</div> </div>
</div> </div>
</div>
</div> </div>
); );
} }
@@ -1,8 +1,8 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { History, Loader2, AlertCircle } from "lucide-react"; import { History, Loader2, AlertCircle } from "lucide-react";
import type { Certificate } from "../../../../types/certificate.types"; import type { Certificate } from "../../../../types/certificate.types";
import certificateImg from "../../../../assets/certificate.png"; import certificateImg from "../../../../assets/certificate.png"; //Placeholder
import { useMyCertificates } from "../../../../hooks/useSertificates"; import { useMyCertificates } from "../../../../hooks/useCertificates";
export default function SertificateHistory() { export default function SertificateHistory() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -1,27 +1,23 @@
// CertificateNotAvailable.tsx
import { Clock, Lock, Award } from "lucide-react"; import { Clock, Lock, Award } from "lucide-react";
import { useClaimCertificate } from "../../../../hooks/useSertificates";
interface CertificateNotAvailableProps { interface CertificateNotAvailableProps {
progress: number; progress: number;
remainingTasks: number; remainingTasks: number;
allTasksDone?: boolean; allTasksDone?: boolean;
project_id?: number; onClaim: () => void;
claiming: boolean;
claimError: string | null;
} }
export default function CertificateNotAvailable({ export default function CertificateNotAvailable({
progress, progress,
remainingTasks, remainingTasks,
allTasksDone = false, allTasksDone = false,
project_id onClaim,
claiming,
claimError,
}: CertificateNotAvailableProps) { }: CertificateNotAvailableProps) {
const { claim, loading, error } = useClaimCertificate();
const handleClaim = async () => {
if (!project_id) return;
await claim(project_id);
};
return ( return (
<div className="bg-box-primary w-full h-full rounded-2xl border border-box-border shadow-sm px-24 py-12 flex flex-col items-center justify-center"> <div className="bg-box-primary w-full h-full rounded-2xl border border-box-border shadow-sm px-24 py-12 flex flex-col items-center justify-center">
{/* Icon */} {/* Icon */}
@@ -60,12 +56,13 @@ export default function CertificateNotAvailable({
<p className="text-xs text-gray-400 mt-2 text-left">Semua tugas telah diselesaikan! Silahkan tunggu untuk mendapatkan sertifikat</p> <p className="text-xs text-gray-400 mt-2 text-left">Semua tugas telah diselesaikan! Silahkan tunggu untuk mendapatkan sertifikat</p>
</div> </div>
<button <button
onClick={handleClaim} onClick={onClaim}
disabled={loading} disabled={claiming}
className="mt-0 px-6 py-2.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors duration-200" className="mt-0 px-6 py-2.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors duration-200"
> >
{loading ? "Memproses..." : "Klaim Sertifikat"} {claiming ? "Memproses..." : "Klaim Sertifikat"}
</button> </button>
{claimError && <p className="text-red-500 text-sm mt-2">{claimError}</p>}
</> </>
) : ( ) : (
<> <>
@@ -91,7 +88,6 @@ export default function CertificateNotAvailable({
/> />
</div> </div>
</div> </div>
{error && <p className="text-red-500 text-sm mt-2">{error}</p>}
</> </>
)} )}
</div> </div>
@@ -1,7 +1,7 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { CalendarDays, UploadCloud, CheckCircle2, XCircle, Loader2 } from "lucide-react"; import { CalendarDays, UploadCloud, CheckCircle2, XCircle, Loader2 } from "lucide-react";
import { useUploadCertificateTemplate } from "../../../hooks/useSertificates"; import { useUploadCertificateTemplate } from "../../../hooks/useCertificates";
import { useProjectDetail } from "../../../hooks/useProjects"; import { useProjectDetail } from "../../../hooks/useProjects";
import EligibleInternTable from "./components/EligibleInternTable"; import EligibleInternTable from "./components/EligibleInternTable";
import { useProjectInternProgress } from "../../../hooks/useInternProgress" import { useProjectInternProgress } from "../../../hooks/useInternProgress"
@@ -68,7 +68,7 @@ export default function CertificateDetail() {
</p> </p>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-8 gap-6 items-start"> <div className="grid grid-cols-1 lg:grid-cols-8 gap-6 items-start">
{/* ── Left Column ── */} {/* Left Column */}
<div className="space-y-4 lg:col-span-3"> <div className="space-y-4 lg:col-span-3">
<h2 className="text-3xl font-extrabold text-gray-900 leading-tight"> <h2 className="text-3xl font-extrabold text-gray-900 leading-tight">
{project.project_name} {project.project_name}
@@ -76,11 +76,9 @@ export default function CertificateDetail() {
<div className="flex items-center gap-2 text-sm text-gray-400 font-medium"> <div className="flex items-center gap-2 text-sm text-gray-400 font-medium">
<CalendarDays className="w-4 h-4 flex-shrink-0" /> <CalendarDays className="w-4 h-4 flex-shrink-0" />
{new Date(project.start_date).toLocaleDateString("en-US", { month: "short", year: "numeric" })} {new Date(project.start_date).toLocaleDateString("en-US", { month: "short", year: "numeric" })}
{" — "} {" - "}
{new Date(project.end_date).toLocaleDateString("en-US", { month: "short", year: "numeric" })} {new Date(project.end_date).toLocaleDateString("en-US", { month: "short", year: "numeric" })}
</div> </div>
{/* ── Upload Template ── */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-bold text-gray-700 tracking-wide"> <label className="text-xs font-bold text-gray-700 tracking-wide">
Certificate Template Certificate Template
@@ -92,7 +90,7 @@ export default function CertificateDetail() {
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
> >
<div className="w-10 h-10 rounded-full bg-red-50 flex items-center justify-center mb-2 group-hover:scale-105 transition-transform"> <div className="w-10 h-10 rounded-full bg-red-50 flex items-center justify-center mb-2 group-hover:scale-105 transition-transform">
<UploadCloud className="w-5 h-5 text-[#B30000]" /> <UploadCloud className="w-5 h-5 text-red" />
</div> </div>
{selectedFile ? ( {selectedFile ? (
<> <>
@@ -163,7 +161,7 @@ export default function CertificateDetail() {
<p className="text-sm text-gray-600 leading-relaxed">{project.description}</p> <p className="text-sm text-gray-600 leading-relaxed">{project.description}</p>
</div> </div>
{/* ── Stats ── */} {/* Stats: Total Intern dalam project dan jumlah intern eligible */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="border border-card-outline rounded-xl p-4"> <div className="border border-card-outline rounded-xl p-4">
<p className="text-2xl font-extrabold text-[#B30000]">{project.total_members}</p> <p className="text-2xl font-extrabold text-[#B30000]">{project.total_members}</p>
@@ -176,11 +174,12 @@ export default function CertificateDetail() {
</div> </div>
</div> </div>
{/* ── Right Column ── */} {/* Right Column */}
<EligibleInternTable <EligibleInternTable
interns={internProgress} interns={internProgress}
loading={progressLoading} loading={progressLoading}
eligibleCount={eligibleCount} eligibleCount={eligibleCount}
projectId={project.id}
/> />
</div> </div>
</div> </div>
@@ -10,61 +10,109 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
} from "lucide-react"; } from "lucide-react";
import { useProjectCertificates } from "../../../hooks/useCertificates";
import type { Certificate } from "../../../types/certificate.types";
import { useParams } from "react-router-dom";
// ─── Types ──────────────────────────────────────────────────────────────────── const PER_PAGE = 5;
interface InternCert {
id: number;
name: string;
role: string;
initials: string;
credentialId: string;
}
// ─── Mock Data ────────────────────────────────────────────────────────────────
const mockCerts: InternCert[] = [
{ id: 1, name: "Alex Johnson", role: "Backend Engineering", initials: "AJ", credentialId: "CERT-2023-0045" },
{ id: 2, name: "Sarah Lin", role: "UI/UX Design Specialist",initials: "SL", credentialId: "CERT-2023-0046" },
{ id: 3, name: "Marcus Kane", role: "Data Analytics", initials: "MK", credentialId: "CERT-2023-0047" },
{ id: 4, name: "Priya Sharma", role: "Frontend Engineering", initials: "PS", credentialId: "CERT-2023-0048" },
{ id: 5, name: "Derek Lim", role: "DevOps & Cloud", initials: "DL", credentialId: "CERT-2023-0049" },
{ id: 6, name: "Nina Castillo", role: "Mobile Development", initials: "NC", credentialId: "CERT-2023-0050" },
];
const TOTAL_CERTS = 18; const getInitials = (name: string): string => {
const PER_PAGE = 3; const parts = name.trim().split(/\s+/);
if (parts.length === 1) return parts[0][0].toUpperCase();
// ─── Avatar ─────────────────────────────────────────────────────────────────── return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
const avatarColors: Record<string, string> = {
AJ: "bg-blue-100 text-blue-700",
SL: "bg-purple-100 text-purple-700",
MK: "bg-orange-100 text-orange-700",
PS: "bg-teal-100 text-teal-700",
DL: "bg-green-100 text-green-700",
NC: "bg-pink-100 text-pink-700",
}; };
function Avatar({ initials }: { initials: string }) { function Avatar({ name }: { name: string }) {
const color = avatarColors[initials] ?? "bg-gray-100 text-gray-600"; // "Kemas M Aryadary Rasyad" → "KR"
const color = "bg-gray-100 text-gray-600";
return ( return (
<div className={`w-9 h-9 rounded-full flex items-center justify-center text-[11px] font-black flex-shrink-0 ${color}`}> <div className={`w-9 h-9 rounded-full flex items-center justify-center text-[11px] font-black flex-shrink-0 ${color}`}>
{initials} {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> </div>
); );
} }
// ─── Main Page ──────────────────────────────────────────────────────────────── // ─── Main Page ────────────────────────────────────────────────────────────────
export default function CertificateResult() { export default function CertificateResult() {
const [search, setSearch] = useState(""); const { id } = useParams<{ id: string }>();
const [page, setPage] = useState(1); const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const {certificates, loading, error} = useProjectCertificates(Number(id));
if (loading) return <CertificateResultSkeleton />;
if (error) return <div className="text-sm text-red-500 p-4">Gagal memuat data sertifikat.</div>;
// Filter + paginate // Filter + paginate
const filtered = mockCerts.filter( const filtered = certificates.filter(
(c) => (c) =>
c.name.toLowerCase().includes(search.toLowerCase()) || c.user.full_name.toLowerCase().includes(search.toLowerCase()) ||
c.role.toLowerCase().includes(search.toLowerCase()) c.user.email.toLowerCase().includes(search.toLowerCase())
); );
const totalPages = Math.ceil(filtered.length / PER_PAGE); const totalPages = Math.ceil(filtered.length / PER_PAGE);
const paginated = filtered.slice((page - 1) * PER_PAGE, page * PER_PAGE); const paginated = filtered.slice((page - 1) * PER_PAGE, page * PER_PAGE);
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => { const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value); setSearch(e.target.value);
@@ -90,7 +138,7 @@ export default function CertificateResult() {
{/* Headline */} {/* Headline */}
<h2 className="text-3xl md:text-4xl font-extrabold text-[#B30000] mb-3 leading-tight"> <h2 className="text-3xl md:text-4xl font-extrabold text-[#B30000] mb-3 leading-tight">
{TOTAL_CERTS} Certificates Successfully Generated! {certificates.length} Certificates Successfully Generated!
</h2> </h2>
{/* Subtext */} {/* Subtext */}
@@ -142,22 +190,22 @@ export default function CertificateResult() {
No interns match your search. No interns match your search.
</div> </div>
) : ( ) : (
paginated.map((cert) => ( paginated.map((cert: Certificate) => (
<div <div
key={cert.id} 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" className="grid grid-cols-[2fr_1.5fr_auto] items-center px-5 py-3.5 hover:bg-gray-50/60 transition-colors"
> >
{/* Name + role */} {/* Name + role */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Avatar initials={cert.initials} /> <Avatar name={cert.user.full_name} />
<div> <div>
<p className="text-sm font-bold text-gray-900 leading-tight">{cert.name}</p> <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> {/* <p className="text-[11px] text-gray-400 mt-0.5">{cert.role}</p> */}
</div> </div>
</div> </div>
{/* Credential ID */} {/* Credential ID */}
<p className="text-xs font-mono text-gray-500 tracking-wide">{cert.credentialId}</p> <p className="text-xs font-mono text-gray-500 tracking-wide">{cert.uuid}</p>
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -180,7 +228,7 @@ export default function CertificateResult() {
<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"> <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"> <p className="text-xs text-gray-400 font-medium">
Showing {filtered.length === 0 ? 0 : (page - 1) * PER_PAGE + 1} to{" "} Showing {filtered.length === 0 ? 0 : (page - 1) * PER_PAGE + 1} to{" "}
{Math.min(page * PER_PAGE, filtered.length)} of {TOTAL_CERTS} interns {Math.min(page * PER_PAGE, filtered.length)} of {certificates.length} interns
</p> </p>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
@@ -194,11 +242,10 @@ export default function CertificateResult() {
<button <button
key={n} key={n}
onClick={() => setPage(n)} onClick={() => setPage(n)}
className={`w-7 h-7 flex items-center justify-center rounded-md text-xs font-bold transition-colors ${ className={`w-7 h-7 flex items-center justify-center rounded-md text-xs font-bold transition-colors ${n === page
n === page ? "bg-[#B30000] text-white shadow-sm"
? "bg-[#B30000] text-white shadow-sm" : "bg-white border border-gray-200 text-gray-600 hover:bg-gray-50"
: "bg-white border border-gray-200 text-gray-600 hover:bg-gray-50" }`}
}`}
> >
{n} {n}
</button> </button>
@@ -8,73 +8,56 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Sparkles, Sparkles,
Check,
Loader2
} from "lucide-react"; } from "lucide-react";
import type { InternSubmissionProgress } from "../../../../hooks/useInternProgress"; import type { InternSubmissionProgress } from "../../../../hooks/useInternProgress";
import { useClaimCertificate } from "../../../../hooks/useSertificates"; import { useProjectDetail } from "../../../../hooks/useProjects";
import { Loader2, Check } from "lucide-react"; import JSZip from "jszip";
import { generateCertificate } from "../../../utils/SertificateGenerator";
import Avatar from "../../Avatar";
import ProgressBar from "../../ProgressBar";
interface EligibleInternTableProps { interface EligibleInternTableProps {
interns: InternSubmissionProgress[]; interns: InternSubmissionProgress[];
loading: boolean; loading: boolean;
eligibleCount: number; eligibleCount: number;
projectId: number
} }
const PER_PAGE = 5; const PER_PAGE = 5;
function ProgressBar({ value, total }: { value: number; total: number }) {
const pct = Math.round((value / total) * 100);
const isComplete = value === total;
return (
<div className="flex items-center gap-2">
<div className="w-16 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${isComplete ? "bg-[#B30000]" : "bg-gray-400"}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className={`text-xs font-bold ${isComplete ? "text-[#B30000]" : "text-gray-500"}`}>
{value}/{total}
</span>
</div>
);
}
function StatusBadge({ status }: { status: boolean }) { function StatusBadge({ status }: { status: boolean }) {
const isEligible = status
return ( return (
<div className={`flex items-center gap-1 text-[11px] font-extrabold tracking-wide ${isEligible ? "text-green-600" : "text-orange-500"}`}> <div className={`flex items-center gap-1 text-[11px] font-extrabold tracking-wide ${status ? "text-green-600" : "text-orange-500"}`}>
{isEligible {status
? <CheckCircle2 className="w-3.5 h-3.5" /> ? <CheckCircle2 className="w-3.5 h-3.5" />
: <AlertCircle className="w-3.5 h-3.5" />} : <AlertCircle className="w-3.5 h-3.5" />}
{status} {status ? "Eligible" : "Incomplete"}
</div> </div>
); );
} }
interface EligibleInternTableProps { // Main Component
interns: InternSubmissionProgress[]; export default function EligibleInternTable({
loading: boolean; interns,
eligibleCount: number; loading,
} eligibleCount,
projectId
function Avatar({ initials }: { initials: string }) { }: EligibleInternTableProps) {
return (
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-xs text-white flex-shrink-0 bg-primary`}>
{initials}
</div>
);
}
// ─── Component ────────────────────────────────────────────────────────────────
export default function EligibleInternTable({ interns, loading, eligibleCount }: EligibleInternTableProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { project } = useProjectDetail(String(projectId));
const initialSelected = new Set( const initialSelected = new Set(
interns.filter(i => i.is_eligible).map(i => i.id_user) interns.filter(i => i.is_eligible).map(i => i.id_user)
); );
// Pemilihan Id
const [selected, setSelected] = useState<Set<number>>(initialSelected); const [selected, setSelected] = useState<Set<number>>(initialSelected);
// state saat generate sertifikat
const [generating, setGenerating] = useState(false);
const [generateError, setGenerateError] = useState<string | null>(null);
// Halaman (Pagination)
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const totalPages = Math.ceil(interns.length / PER_PAGE); const totalPages = Math.ceil(interns.length / PER_PAGE);
const paginated = interns.slice((page - 1) * PER_PAGE, page * PER_PAGE); const paginated = interns.slice((page - 1) * PER_PAGE, page * PER_PAGE);
@@ -82,15 +65,11 @@ export default function EligibleInternTable({ interns, loading, eligibleCount }:
const allEligibleSelected = eligibleIds.every(id => selected.has(id)); const allEligibleSelected = eligibleIds.every(id => selected.has(id));
const toggleSelectAll = () => { const toggleSelectAll = () => {
if (allEligibleSelected) { setSelected(allEligibleSelected ? new Set() : new Set(eligibleIds));
setSelected(new Set());
} else {
setSelected(new Set(eligibleIds));
}
}; };
const toggleOne = (id: number, status: boolean) => { const toggleOne = (id: number, isEligible: boolean) => {
if (status) return; if (!isEligible) return;
setSelected((prev) => { setSelected((prev) => {
const next = new Set(prev); const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id); next.has(id) ? next.delete(id) : next.add(id);
@@ -98,14 +77,65 @@ export default function EligibleInternTable({ interns, loading, eligibleCount }:
}); });
}; };
const handleGenerate = async () => {
if (!project?.template_sertificate) {
setGenerateError("Template sertifikat belum diupload.");
return;
}
if (selected.size === 0) {
setGenerateError("Pilih minimal satu intern.");
return;
}
setGenerating(true);
setGenerateError(null);
try {
const selectedInterns = interns.filter((i) => selected.has(i.id_user));
const zip = new JSZip();
// Generate semua sertifikat secara paralel
await Promise.all(
selectedInterns.map(async (intern) => {
const blob = await generateCertificate(
project.template_sertificate,
intern.full_name
);
// Nama file: "Sertifikat - Nama Intern.png"
const filename = `Sertifikat - ${intern.full_name}.png`;
zip.file(filename, blob);
})
);
// Download ZIP
const zipBlob = await zip.generateAsync({ type: "blob" });
const url = URL.createObjectURL(zipBlob);
const a = document.createElement("a");
a.href = url;
a.download = `Sertifikat-Project-${projectId}.zip`;
a.click();
URL.revokeObjectURL(url);
// Navigasi ke result page
navigate(`/mentor/certificates/${projectId}/result`);
} catch (err) {
console.error(err);
setGenerateError(
err instanceof Error ? err.message : "Gagal generate sertifikat."
);
} finally {
setGenerating(false);
}
};
if (loading) { if (loading) {
return ( return (
<div className="lg:col-span-5 flex items-center justify-center py-20 text-gray-400 text-sm"> <div className="lg:col-span-5 flex items-center justify-center py-20 text-gray-400 text-sm">
<Loader2 className="w-5 h-5 animate-spin mr-2" /> <Loader2 className="w-5 h-5 animate-spin mr-2" />
Memuat data intern... Memuat data intern...
</div> </div>
); );
} }
return ( return (
<div className="lg:col-span-5 flex flex-col gap-4"> <div className="lg:col-span-5 flex flex-col gap-4">
@@ -251,14 +281,28 @@ export default function EligibleInternTable({ interns, loading, eligibleCount }:
</div> </div>
</div> </div>
{/* ── Bottom Action Bar ── */} {generateError && (
<p className="text-xs text-red-500 font-medium text-right">{generateError}</p>
)}
{/* Bottom Action Bar */}
<div className="flex items-center justify-end"> <div className="flex items-center justify-end">
<button <button
onClick={() => navigate("/mentor/certificates/result")} onClick={handleGenerate}
className="flex items-center gap-2 px-8 py-3.5 bg-[#B30000] hover:bg-[#990000] text-white font-bold text-sm rounded-xl shadow-md transition-colors" disabled={generating || selected.size === 0}
className="flex items-center gap-2 px-8 py-3.5 bg-[#B30000] hover:bg-red-800 disabled:opacity-60 text-white font-bold text-sm rounded-xl shadow-md transition-colors"
> >
Generate Certificates {generating ? (
<Sparkles className="w-4 h-4" /> <>
<Loader2 className="w-4 h-4 animate-spin" />
Generating...
</>
) : (
<>
Generate Certificates
<Sparkles className="w-4 h-4" />
</>
)}
</button> </button>
</div> </div>
</div> </div>
+47
View File
@@ -0,0 +1,47 @@
// src/utils/certificateGenerator.ts
// ── Konstanta posisi nama di template ─────────────────────────────────────────
const NAME_Y_RATIO = 0.55; // posisi Y = 55% dari tinggi canvas
const NAME_FONT_SIZE = 48; // px
const NAME_FONT_FAMILY = "Georgia, serif";
const NAME_COLOR = "#1a1a1a";
// ── Load Image dari URL ────────────────────────────────────────────────────────
export function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous"; // penting untuk URL eksternal (Supabase, dll)
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Gagal load gambar: ${src}`));
img.src = src;
});
}
// ── Generate satu sertifikat sebagai Blob ─────────────────────────────────────
export async function generateCertificate(
templateUrl: string,
internName: string
): Promise<Blob> {
const img = await loadImage(templateUrl);
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(img, 0, 0);
// Tulis nama di tengah horizontal, posisi Y dari konstanta
ctx.font = `bold ${NAME_FONT_SIZE}px ${NAME_FONT_FAMILY}`;
ctx.fillStyle = NAME_COLOR;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(internName, canvas.width / 2, canvas.height * NAME_Y_RATIO);
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error("toBlob gagal"))),
"image/png"
);
});
}
+1
View File
@@ -63,6 +63,7 @@ export const getProjectById = async (
position: m.user?.position, position: m.user?.position,
kelompok_peminatan: m.user?.kelompok_peminatan, kelompok_peminatan: m.user?.kelompok_peminatan,
})), })),
template_sertificate: raw.template_sertificate,
}; };
}; };
+1
View File
@@ -17,6 +17,7 @@ export interface Certificate {
id_project: number; id_project: number;
id_user: number; id_user: number;
certificate_no: string; certificate_no: string;
image_path: string
issued_at: string; issued_at: string;
uuid: string; uuid: string;
user: CertificateUser; user: CertificateUser;
+1
View File
@@ -34,6 +34,7 @@ export interface Project {
export interface ProjectDetail extends Project { export interface ProjectDetail extends Project {
members: ProjectMember[]; members: ProjectMember[];
tasks: ProjectTask[]; tasks: ProjectTask[];
template_sertificate: string;
} }
// Response GET /project-api/my-tasks // Response GET /project-api/my-tasks