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:
@@ -15,6 +15,9 @@
|
||||
"@tiptap/starter-kit": "^2.23.0",
|
||||
"axios": "^1.10.0",
|
||||
"froala-editor": "^4.5.2",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^4.2.1",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.553.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
||||
+3
-3
@@ -31,7 +31,7 @@ import EditLowongan from "./Pages/EditLowongan";
|
||||
import HomeInternPage from "./Pages/Internify/intern/HomeInternPage";
|
||||
import { InternLayout, MentorLayout } from "./Pages/Internify/layout";
|
||||
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 HomeMentorPage from "./Pages/Internify/mentor/HomeMentorPage";
|
||||
import MentorProjectsPage from "./Pages/Internify/mentor/BaseProjectsPage";
|
||||
@@ -118,8 +118,8 @@ function App() {
|
||||
<Route index element={<HomeMentorPage />} />
|
||||
<Route path="projects" element={<MentorProjectsPage />} />
|
||||
<Route path="certificates" element={<MentorCertificatePage />} />
|
||||
<Route path="certificates/:slug" element={<CertificateDetail />} />
|
||||
<Route path="certificates/result" element={<CertificateResult />} />
|
||||
<Route path="certificates/:id" element={<CertificateDetail />} />
|
||||
<Route path="certificates/:id/result" element={<CertificateResult />} />
|
||||
<Route path="intern" element={<MentorInternPage />} />
|
||||
<Route path="projects/:slug" element={<MentorDetailProject />} />
|
||||
<Route path="projects/:slug/:taskSlug" element={<MentorProjectsDetailPage />} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+26
-13
@@ -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 (
|
||||
@@ -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,20 +33,28 @@ 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}
|
||||
src={certificate.image_path}
|
||||
alt="Certificate"
|
||||
className="rounded-xl w-[550px] h-[350px]"
|
||||
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="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>
|
||||
@@ -57,14 +67,14 @@ export default function CertificateAvailable({ project }: CertificateAvailablePr
|
||||
label: "Status",
|
||||
value: (
|
||||
<span className="bg-green-100 text-green-700 text-xs font-bold px-2 py-0.5 rounded-full">
|
||||
UNVERIFIED
|
||||
{certificate.certificate_status.toUpperCase()}
|
||||
</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"
|
||||
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>
|
||||
@@ -73,13 +83,15 @@ export default function CertificateAvailable({ project }: CertificateAvailablePr
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 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">
|
||||
<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 (PDF)
|
||||
Unduh Sertifikat
|
||||
</button>
|
||||
|
||||
{/* Bagikan ke LinkedIn */}
|
||||
@@ -89,19 +101,18 @@ export default function CertificateAvailable({ project }: CertificateAvailablePr
|
||||
|
||||
{/* 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">
|
||||
<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-gray-200 hover:bg-gray-50 active:scale-95 transition-all text-red-700 font-medium py-3.5 rounded-xl text-sm"
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
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 EligibleInternTable from "./components/EligibleInternTable";
|
||||
import { useProjectInternProgress } from "../../../hooks/useInternProgress"
|
||||
@@ -68,7 +68,7 @@ export default function CertificateDetail() {
|
||||
</p>
|
||||
</div>
|
||||
<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">
|
||||
<h2 className="text-3xl font-extrabold text-gray-900 leading-tight">
|
||||
{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">
|
||||
<CalendarDays className="w-4 h-4 flex-shrink-0" />
|
||||
{new Date(project.start_date).toLocaleDateString("en-US", { month: "short", year: "numeric" })}
|
||||
{" — "}
|
||||
{" - "}
|
||||
{new Date(project.end_date).toLocaleDateString("en-US", { month: "short", year: "numeric" })}
|
||||
</div>
|
||||
|
||||
{/* ── Upload Template ── */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-gray-700 tracking-wide">
|
||||
Certificate Template
|
||||
@@ -92,7 +90,7 @@ export default function CertificateDetail() {
|
||||
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">
|
||||
<UploadCloud className="w-5 h-5 text-[#B30000]" />
|
||||
<UploadCloud className="w-5 h-5 text-red" />
|
||||
</div>
|
||||
{selectedFile ? (
|
||||
<>
|
||||
@@ -163,7 +161,7 @@ export default function CertificateDetail() {
|
||||
<p className="text-sm text-gray-600 leading-relaxed">{project.description}</p>
|
||||
</div>
|
||||
|
||||
{/* ── Stats ── */}
|
||||
{/* Stats: Total Intern dalam project dan jumlah intern eligible */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="border border-card-outline rounded-xl p-4">
|
||||
<p className="text-2xl font-extrabold text-[#B30000]">{project.total_members}</p>
|
||||
@@ -176,11 +174,12 @@ export default function CertificateDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right Column ── */}
|
||||
{/* Right Column */}
|
||||
<EligibleInternTable
|
||||
interns={internProgress}
|
||||
loading={progressLoading}
|
||||
eligibleCount={eligibleCount}
|
||||
projectId={project.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,58 +10,106 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { useProjectCertificates } from "../../../hooks/useCertificates";
|
||||
import type { Certificate } from "../../../types/certificate.types";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
interface InternCert {
|
||||
id: number;
|
||||
name: string;
|
||||
role: string;
|
||||
initials: string;
|
||||
credentialId: string;
|
||||
}
|
||||
const PER_PAGE = 5;
|
||||
|
||||
// ─── 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 PER_PAGE = 3;
|
||||
|
||||
// ─── Avatar ───────────────────────────────────────────────────────────────────
|
||||
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",
|
||||
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({ initials }: { initials: string }) {
|
||||
const color = avatarColors[initials] ?? "bg-gray-100 text-gray-600";
|
||||
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}`}>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
export default function CertificateResult() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
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
|
||||
const filtered = mockCerts.filter(
|
||||
const filtered = certificates.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.role.toLowerCase().includes(search.toLowerCase())
|
||||
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);
|
||||
@@ -90,7 +138,7 @@ export default function CertificateResult() {
|
||||
|
||||
{/* Headline */}
|
||||
<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>
|
||||
|
||||
{/* Subtext */}
|
||||
@@ -142,22 +190,22 @@ export default function CertificateResult() {
|
||||
No interns match your search.
|
||||
</div>
|
||||
) : (
|
||||
paginated.map((cert) => (
|
||||
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 initials={cert.initials} />
|
||||
<Avatar name={cert.user.full_name} />
|
||||
<div>
|
||||
<p className="text-sm font-bold text-gray-900 leading-tight">{cert.name}</p>
|
||||
<p className="text-[11px] text-gray-400 mt-0.5">{cert.role}</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> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
<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">
|
||||
<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 {TOTAL_CERTS} interns
|
||||
{Math.min(page * PER_PAGE, filtered.length)} of {certificates.length} interns
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
@@ -194,8 +242,7 @@ export default function CertificateResult() {
|
||||
<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
|
||||
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"
|
||||
}`}
|
||||
|
||||
@@ -8,73 +8,56 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Sparkles,
|
||||
Check,
|
||||
Loader2
|
||||
} from "lucide-react";
|
||||
import type { InternSubmissionProgress } from "../../../../hooks/useInternProgress";
|
||||
import { useClaimCertificate } from "../../../../hooks/useSertificates";
|
||||
import { Loader2, Check } from "lucide-react";
|
||||
import { useProjectDetail } from "../../../../hooks/useProjects";
|
||||
import JSZip from "jszip";
|
||||
import { generateCertificate } from "../../../utils/SertificateGenerator";
|
||||
import Avatar from "../../Avatar";
|
||||
import ProgressBar from "../../ProgressBar";
|
||||
|
||||
interface EligibleInternTableProps {
|
||||
interns: InternSubmissionProgress[];
|
||||
loading: boolean;
|
||||
eligibleCount: number;
|
||||
projectId: number
|
||||
}
|
||||
|
||||
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 }) {
|
||||
const isEligible = status
|
||||
return (
|
||||
<div className={`flex items-center gap-1 text-[11px] font-extrabold tracking-wide ${isEligible ? "text-green-600" : "text-orange-500"}`}>
|
||||
{isEligible
|
||||
<div className={`flex items-center gap-1 text-[11px] font-extrabold tracking-wide ${status ? "text-green-600" : "text-orange-500"}`}>
|
||||
{status
|
||||
? <CheckCircle2 className="w-3.5 h-3.5" />
|
||||
: <AlertCircle className="w-3.5 h-3.5" />}
|
||||
{status}
|
||||
{status ? "Eligible" : "Incomplete"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EligibleInternTableProps {
|
||||
interns: InternSubmissionProgress[];
|
||||
loading: boolean;
|
||||
eligibleCount: number;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
export default function EligibleInternTable({ interns, loading, eligibleCount }: EligibleInternTableProps) {
|
||||
// Main Component
|
||||
export default function EligibleInternTable({
|
||||
interns,
|
||||
loading,
|
||||
eligibleCount,
|
||||
projectId
|
||||
}: EligibleInternTableProps) {
|
||||
const navigate = useNavigate();
|
||||
const { project } = useProjectDetail(String(projectId));
|
||||
|
||||
const initialSelected = new Set(
|
||||
interns.filter(i => i.is_eligible).map(i => i.id_user)
|
||||
);
|
||||
// Pemilihan Id
|
||||
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 totalPages = Math.ceil(interns.length / 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 toggleSelectAll = () => {
|
||||
if (allEligibleSelected) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(eligibleIds));
|
||||
}
|
||||
setSelected(allEligibleSelected ? new Set() : new Set(eligibleIds));
|
||||
};
|
||||
|
||||
const toggleOne = (id: number, status: boolean) => {
|
||||
if (status) return;
|
||||
const toggleOne = (id: number, isEligible: boolean) => {
|
||||
if (!isEligible) return;
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
@@ -98,6 +77,57 @@ 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) {
|
||||
return (
|
||||
<div className="lg:col-span-5 flex items-center justify-center py-20 text-gray-400 text-sm">
|
||||
@@ -105,7 +135,7 @@ export default function EligibleInternTable({ interns, loading, eligibleCount }:
|
||||
Memuat data intern...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="lg:col-span-5 flex flex-col gap-4">
|
||||
@@ -251,14 +281,28 @@ export default function EligibleInternTable({ interns, loading, eligibleCount }:
|
||||
</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">
|
||||
<button
|
||||
onClick={() => navigate("/mentor/certificates/result")}
|
||||
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"
|
||||
onClick={handleGenerate}
|
||||
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"
|
||||
>
|
||||
{generating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Generate Certificates
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -63,6 +63,7 @@ export const getProjectById = async (
|
||||
position: m.user?.position,
|
||||
kelompok_peminatan: m.user?.kelompok_peminatan,
|
||||
})),
|
||||
template_sertificate: raw.template_sertificate,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface Certificate {
|
||||
id_project: number;
|
||||
id_user: number;
|
||||
certificate_no: string;
|
||||
image_path: string
|
||||
issued_at: string;
|
||||
uuid: string;
|
||||
user: CertificateUser;
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface Project {
|
||||
export interface ProjectDetail extends Project {
|
||||
members: ProjectMember[];
|
||||
tasks: ProjectTask[];
|
||||
template_sertificate: string;
|
||||
}
|
||||
|
||||
// Response GET /project-api/my-tasks
|
||||
|
||||
Reference in New Issue
Block a user