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
@@ -1,10 +1,10 @@
// src/pages/intern/SertificatePage.tsx
import { useMemo } from "react";
import { useParams } from "react-router-dom";
import { useLocation } from "react-router-dom";
import { useParams, useLocation } from "react-router-dom";
import CertificateAvailable from "./components/CertificateAvailabe";
import CertificateNotAvailable from "./components/CertificateNotAvailable";
import SertificateHistory from "./components/CertificateHistory";
import { useMyCertificates } from "../../../hooks/useSertificates";
import { useMyCertificates, useClaimCertificate } from "../../../hooks/useCertificates";
import { useProjectDetail } from "../../../hooks/useProjects";
import type { Project } from "../../../types/project.types";
@@ -14,7 +14,8 @@ const SertificatePage = () => {
const projectFromList = location.state?.project as Project | undefined;
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(() => {
if (!projectDetail) return null;
@@ -27,20 +28,27 @@ const SertificatePage = () => {
const allTasksDone = !projectLoading && !!project && project.task_done >= project.total_tasks;
const alreadyDone = useMemo(() => {
if (!project || !certificates.length) return false;
return certificates.some((cert) => cert.project.id === project.id);
const myCertificateForProject = useMemo(() => {
if (!project || !certificates.length) return null;
return certificates.find((cert) => cert.project.id === project.id) ?? null;
}, [certificates, project]);
const alreadyDone = !!myCertificateForProject;
const progress =
project && project.total_tasks > 0
? Math.round((project.task_done / project.total_tasks) * 100)
: 0;
const remainingTasks = project ? project.total_tasks - project.task_done : 0;
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) {
return (
@@ -49,7 +57,7 @@ const SertificatePage = () => {
</div>
);
}
return (
<div>
<div className="mb-4 flex flex-col gap-1">
@@ -58,14 +66,19 @@ const SertificatePage = () => {
</div>
<div className="flex flex-col lg:flex-row gap-6 items-stretch">
<div className="w-full lg:w-5/8 flex flex-col">
{showCertificate ? (
<CertificateAvailable project={project} />
{alreadyDone ? (
<CertificateAvailable
project={project}
certificate={myCertificateForProject}
/>
) : (
<CertificateNotAvailable
progress={progress}
remainingTasks={remainingTasks}
allTasksDone={allTasksDone}
project_id={project?.id}
onClaim={handleClaim}
claiming={claiming}
claimError={claimError}
/>
)}
</div>
@@ -1,13 +1,15 @@
// CertificateAvailable.tsx
import { useState } from "react";
import { Download, Link, Printer } from "lucide-react";
import type { ProjectDetail } from "../../../../types/project.types";
import certificateImg from "../../../../assets/certificate.png";
import type { Certificate } from "../../../../types/certificate.types";
interface CertificateAvailableProps {
project: ProjectDetail | null;
certificate: Certificate;
}
export default function CertificateAvailable({ project }: CertificateAvailableProps) {
export default function CertificateAvailable({ project, certificate }: CertificateAvailableProps) {
const [copied, setCopied] = useState(false);
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 (
<div className="flex flex-col gap-4">
{/* Certificate Card */}
<div className="flex rounded-xl p-5 mb-1 border bg-box-primary border-box-border shadow-md items-center justify-center">
<img
src={certificateImg}
alt="Certificate"
className="rounded-xl w-[550px] h-[350px]"
/>
</div>
<div className="flex rounded-xl p-5 mb-1 border bg-box-primary border-box-border shadow-md items-center justify-center">
<img
src={certificate.image_path}
alt="Certificate"
className="rounded-xl w-[550px] h-[350px] object-contain"
/>
</div>
{/* Program Summary */}
<div className="grid grid-cols-2 gap-5">
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm p-5">
<div className="flex items-center gap-2 mb-4">
<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>
{/* Program Summary */}
<div className="grid grid-cols-2 gap-5">
<div className="bg-white rounded-2xl border border-card-outline shadow-sm p-5">
<div className="flex items-center gap-2 mb-4">
<h3 className="font-semibold text-gray-800 text-sm">Ringkasan Program</h3>
</div>
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm p-5">
<div className="flex flex-col gap-3">
{/* Unduh Sertifikat */}
<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" />
Unduh Sertifikat (PDF)
</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-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" />
Cetak
</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 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">
{certificate.certificate_status.toUpperCase()}
</span>
),
},
].map((row) => (
<div
key={row.label}
className="flex justify-between items-center text-sm border-b border-card-outline 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 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>
);
}
@@ -1,8 +1,8 @@
import { useNavigate } from "react-router-dom";
import { History, Loader2, AlertCircle } from "lucide-react";
import type { Certificate } from "../../../../types/certificate.types";
import certificateImg from "../../../../assets/certificate.png";
import { useMyCertificates } from "../../../../hooks/useSertificates";
import certificateImg from "../../../../assets/certificate.png"; //Placeholder
import { useMyCertificates } from "../../../../hooks/useCertificates";
export default function SertificateHistory() {
const navigate = useNavigate();
@@ -1,27 +1,23 @@
// CertificateNotAvailable.tsx
import { Clock, Lock, Award } from "lucide-react";
import { useClaimCertificate } from "../../../../hooks/useSertificates";
interface CertificateNotAvailableProps {
progress: number;
remainingTasks: number;
allTasksDone?: boolean;
project_id?: number;
onClaim: () => void;
claiming: boolean;
claimError: string | null;
}
export default function CertificateNotAvailable({
progress,
remainingTasks,
allTasksDone = false,
project_id
onClaim,
claiming,
claimError,
}: CertificateNotAvailableProps) {
const { claim, loading, error } = useClaimCertificate();
const handleClaim = async () => {
if (!project_id) return;
await claim(project_id);
};
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">
{/* 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>
</div>
<button
onClick={handleClaim}
disabled={loading}
onClick={onClaim}
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"
>
{loading ? "Memproses..." : "Klaim Sertifikat"}
{claiming ? "Memproses..." : "Klaim Sertifikat"}
</button>
{claimError && <p className="text-red-500 text-sm mt-2">{claimError}</p>}
</>
) : (
<>
@@ -91,7 +88,6 @@ export default function CertificateNotAvailable({
/>
</div>
</div>
{error && <p className="text-red-500 text-sm mt-2">{error}</p>}
</>
)}
</div>