Generate Certificates: Adding QR with UUID of certificates to verify

This commit is contained in:
Kemas M. Aryadary Rasyad
2026-07-04 02:06:02 +07:00
parent 5b7ac30589
commit c4ca23e869
11 changed files with 280 additions and 131 deletions
+10 -1
View File
@@ -75,6 +75,15 @@ const Navbar = () => {
> >
Internship Internship
</button> </button>
<button
onClick={() => {
navigate("/login-internify");
}}
className="block w-full text-left hover:text-red-600 transition"
>
Login
</button>
</div> </div>
</div> </div>
@@ -114,7 +123,7 @@ const Navbar = () => {
onClick={() => { onClick={() => {
navigate("/login-internify"); navigate("/login-internify");
}} }}
className="block w-full text-left hover:text-blue-600 transition" className="block w-full text-left hover:text-red-600 transition"
> >
Login Login
</button> </button>
+60
View File
@@ -0,0 +1,60 @@
import { useParams } from "react-router-dom";
import { useCertificateVerification } from "../../hooks/useCertificates";
export default function VerificationCert() {
const { uuid } = useParams<{ uuid: string }>();
const { verification, loading, error } = useCertificateVerification(uuid ?? "");
if (loading) {
return (
<div className="container flex items-center justify-center min-h-screen">
<p className="text-gray-500 animate-pulse">Memverifikasi sertifikat...</p>
</div>
);
}
if (error || !verification) {
return (
<div className="container flex flex-col items-center justify-center min-h-screen text-center px-4">
<h1 className="text-2xl font-bold text-red-600 mb-2">Sertifikat Tidak Valid</h1>
<p className="text-gray-500">
Sertifikat dengan UUID ini tidak dapat ditemukan atau sudah tidak berlaku.
</p>
</div>
);
}
return (
<div className="container flex items-center justify-center min-h-screen px-4">
<div className="bg-white shadow-md rounded-lg p-8 max-w-md w-full">
<h1 className="text-2xl font-bold text-green-600 mb-6 text-center">
Sertifikat Valid
</h1>
<div className="space-y-3 text-sm">
<div>
<span className="text-gray-500">Nama Intern</span>
<p className="font-semibold">{verification.intern_name}</p>
</div>
<div>
<span className="text-gray-500">Proyek</span>
<p className="font-semibold">{verification.project_name}</p>
</div>
<div>
<span className="text-gray-500">Tanggal Terbit</span>
<p className="font-semibold">
{new Date(verification.created_at).toLocaleDateString("id-ID", {
day: "numeric",
month: "long",
year: "numeric",
})}
</p>
</div>
<div>
<span className="text-gray-500">UUID</span>
<p className="font-mono text-xs break-all">{verification.uuid}</p>
</div>
</div>
</div>
</div>
);
}
@@ -7,7 +7,6 @@ import SertificateHistory from "./components/CertificateHistory";
// import { useClaimCertificate } from "../../../hooks/useCertificates"; // import { useClaimCertificate } from "../../../hooks/useCertificates";
import { useProjectDetail } from "../../../hooks/useProjects"; import { useProjectDetail } from "../../../hooks/useProjects";
import { useMyCertificates } from "../../../hooks/useCertificates"; import { useMyCertificates } from "../../../hooks/useCertificates";
import type { Project } from "../../../types/project.types"; import type { Project } from "../../../types/project.types";
@@ -1,9 +1,10 @@
import { useState } from "react"; import { useState } from "react";
import { FileText, FileUp, Link } from "lucide-react"; import { FileText, FileUp, Link } from "lucide-react";
import { resolveImageUrl } from "../../../utils/SertificateGenerator";
interface SubmitStatusTableProps { interface SubmitStatusTableProps {
type: "file" | "link"; type: "file" | "link";
filePath?: string | null; // ganti dari files: File[] filePath?: string | null;
link?: string; link?: string;
submittedAt?: Date; submittedAt?: Date;
deadline?: Date; deadline?: Date;
@@ -66,7 +67,7 @@ export default function SubmitStatusTable({
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
onClick={() => setConfirmDelete(false)} onClick={() => setConfirmDelete(false)}
className="text-xs font-medium px-4 py-1.5 rounded-lg bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 transition-colors" className="text-xs font-medium px-4 py-1.5 rounded-lg bg-card border border-card-outline text-gray-700 hover:bg-gray-50 transition-colors"
> >
Cancel Cancel
</button> </button>
@@ -106,7 +107,7 @@ export default function SubmitStatusTable({
key={row.label} key={row.label}
className="grid grid-cols-[180px_1fr] border-b border-box-border" className="grid grid-cols-[180px_1fr] border-b border-box-border"
> >
<div className="px-4 py-3 text-sm text-gray-500 border-r border-box-border"> <div className="px-4 py-3 text-sm text-font border-r border-box-border">
{row.label} {row.label}
</div> </div>
<div className="px-4 py-3 text-sm text-gray-800">{row.value}</div> <div className="px-4 py-3 text-sm text-gray-800">{row.value}</div>
@@ -115,15 +116,20 @@ export default function SubmitStatusTable({
{/* File / Link row */} {/* File / Link row */}
<div className="grid grid-cols-[180px_1fr]"> <div className="grid grid-cols-[180px_1fr]">
<div className="px-4 py-3 text-sm text-gray-500 border-r border-box-border"> <div className="px-4 py-3 text-sm text-font border-r border-box-border">
{type === "file" ? "File submission" : "Link submission"} {type === "file" ? "File submission" : "Link submission"}
</div> </div>
<div className="px-4 py-3 flex flex-col gap-2"> <div className="px-4 py-3 flex flex-col gap-2">
{type === "file" ? ( {type === "file" ? (
<div className="flex items-center gap-2 text-sm text-gray-800"> <a
<FileText className="w-4 h-4 text-red-700 stroke-[2.5]" /> href={resolveImageUrl(filePath ?? "")}
{filePath ? filePath.split("/").pop() : "File submitted"} target="_blank"
</div> rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-blue-600 hover:underline"
>
<FileText className="w-4 h-4 stroke-[2.5]" />
<span>{resolveImageUrl(filePath ?? "").split("/").pop()}</span>
</a>
) : ( ) : (
<a href={link} target="_blank" rel="noopener noreferrer" <a href={link} target="_blank" rel="noopener noreferrer"
className="flex items-center gap-1.5 text-sm text-blue-600 hover:underline break-all"> className="flex items-center gap-1.5 text-sm text-blue-600 hover:underline break-all">
@@ -65,7 +65,12 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
if (ok) { setFiles([]); setIsEditing(false); } if (ok) { setFiles([]); setIsEditing(false); }
}; };
// Sudah ada submission dan tidak sedang edit -> tampilkan status // Batal edit — kembali ke tampilan status tanpa kehilangan submission lama
const handleCancelEdit = () => {
setFiles([]);
setIsEditing(false);
};
if (submission && !isEditing) { if (submission && !isEditing) {
return ( return (
<SubmitStatusTable <SubmitStatusTable
@@ -79,6 +84,8 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
); );
} }
const currentFileName = submission?.file_path?.split("/").pop();
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<input <input
@@ -97,6 +104,7 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
}`} }`}
> >
{files.length > 0 ? ( {files.length > 0 ? (
// File baru sudah dipilih — tampilkan itu (menggantikan file lama)
<div className="space-y-2"> <div className="space-y-2">
{files.map((file, index) => ( {files.map((file, index) => (
<div key={index} className="flex items-center justify-between text-sm font-medium text-gray-800" onClick={(e) => e.stopPropagation()}> <div key={index} className="flex items-center justify-between text-sm font-medium text-gray-800" onClick={(e) => e.stopPropagation()}>
@@ -110,6 +118,17 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
</div> </div>
))} ))}
</div> </div>
) : isEditing && currentFileName ? (
// Mode edit, belum pilih file baru — tampilkan file submission lama sebagai referensi
<div className="flex flex-col items-center justify-center h-full space-y-2 text-center py-6">
<FileText className="w-6 h-6 text-gray-400" />
<p className="text-sm font-semibold text-gray-700">
File saat ini: <span className="text-red-700">{currentFileName}</span>
</p>
<p className="text-[11px] text-gray-400 font-medium pointer-events-none">
Klik atau drag & drop untuk mengganti file
</p>
</div>
) : ( ) : (
<div className="flex flex-col items-center justify-center h-full space-y-2 pointer-events-none text-center py-6"> <div className="flex flex-col items-center justify-center h-full space-y-2 pointer-events-none text-center py-6">
<UploadCloud className="w-8 h-8 text-gray-400 mx-auto stroke-[1.5]" /> <UploadCloud className="w-8 h-8 text-gray-400 mx-auto stroke-[1.5]" />
@@ -118,13 +137,25 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
</div> </div>
)} )}
</div> </div>
<button
type="submit" disabled={loading} <div className="flex gap-2">
className="w-full bg-[#B30000] hover:bg-[#990000] disabled:opacity-60 text-white font-bold py-3 rounded-xl transition-colors shadow-md flex items-center justify-center gap-2 tracking-wide cursor-pointer" {isEditing && (
> <button
{loading ? "Mengirim..." : isEditing ? "Update Submission" : "Save Changes"} type="button"
<LuSendHorizontal className="w-4 h-4 stroke-[2.5]" /> onClick={handleCancelEdit}
</button> className="w-full bg-white border border-gray-300 hover:bg-gray-50 text-gray-700 font-bold py-3 rounded-xl transition-colors"
>
Batal
</button>
)}
<button
type="submit" disabled={loading}
className="w-full bg-[#B30000] hover:bg-[#990000] disabled:opacity-60 text-white font-bold py-3 rounded-xl transition-colors shadow-md flex items-center justify-center gap-2 tracking-wide cursor-pointer"
>
{loading ? "Mengirim..." : isEditing ? "Update Submission" : "Save Changes"}
<LuSendHorizontal className="w-4 h-4 stroke-[2.5]" />
</button>
</div>
</form> </form>
); );
} }
@@ -14,7 +14,7 @@ import {
import type { InternSubmissionProgress } from "../../../../hooks/useInternProgress"; import type { InternSubmissionProgress } from "../../../../hooks/useInternProgress";
import { useProjectDetail } from "../../../../hooks/useProjects"; import { useProjectDetail } from "../../../../hooks/useProjects";
import JSZip from "jszip"; import JSZip from "jszip";
import { generateCertificate } from "../../../utils/SertificateGenerator"; import { generateCertificate, resolveImageUrl } from "../../../utils/SertificateGenerator";
import { useGenerateCertificates } from "../../../../hooks/useCertificates"; import { useGenerateCertificates } from "../../../../hooks/useCertificates";
import Avatar from "../../Avatar"; import Avatar from "../../Avatar";
import ProgressBar from "../../ProgressBar"; import ProgressBar from "../../ProgressBar";
@@ -40,15 +40,15 @@ function StatusBadge({ status }: { status: boolean }) {
} }
// Main Component // Main Component
export default function EligibleInternTable({ export default function EligibleInternTable({
interns, interns,
loading, loading,
eligibleCount, eligibleCount,
projectId projectId
}: EligibleInternTableProps) { }: EligibleInternTableProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { project } = useProjectDetail(String(projectId)); const { project } = useProjectDetail(String(projectId));
const { generate: SubmitCertificates, loading: generatingCertificates, error, success, reset } = useGenerateCertificates(); const { generate: SubmitCertificates } = useGenerateCertificates();
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)
@@ -57,7 +57,6 @@ export default function EligibleInternTable({
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
const [generateError, setGenerateError] = useState<string | null>(null); 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);
@@ -78,48 +77,52 @@ export default function EligibleInternTable({
}; };
const handleGenerate = async () => { const handleGenerate = async () => {
if (!project?.certificate_template) { if (!project?.certificate_template) {
setGenerateError("Template sertifikat belum diupload."); setGenerateError("Template sertifikat belum diupload.");
return; return;
} }
if (selected.size === 0) { if (selected.size === 0) {
setGenerateError("Pilih minimal satu intern."); setGenerateError("Pilih minimal satu intern.");
return; return;
} }
setGenerating(true); setGenerating(true);
setGenerateError(null); setGenerateError(null);
try { try {
// 1. Generate blob PNG per intern & kumpulkan ke ZIP const result = await SubmitCertificates(projectId, Array.from(selected));
const selectedInterns = interns.filter((i) => selected.has(i.id_user)); if (!result) return;
const zip = new JSZip();
await Promise.all( const zip = new JSZip();
selectedInterns.map(async (intern) => { const templateUrl = resolveImageUrl(project.certificate_template);
const blob = await generateCertificate(
project.certificate_template, await Promise.all(
intern.full_name result.map(async (cert) => {
const verifyUrl = `${window.location.origin}/verify/${cert.uuid}`;
const blob = await generateCertificate(
templateUrl,
cert.user.full_name,
cert.project.project_name,
cert.certificate_no,
verifyUrl
); );
zip.file(`Sertifikat - ${intern.full_name}.png`, blob);
})
);
// 2. POST ke backend untuk mencatat sertifikat sudah di-generate zip.file(`Sertifikat - ${cert.user.full_name}.png`, blob);
const ok = await SubmitCertificates(projectId, Array.from(selected)); })
if (!ok) return; );
// 3. Navigasi ke result page // 3. Navigasi ke result page
navigate(`/mentor/certificates/${projectId}/result`); navigate(`/mentor/certificates/${projectId}/result`);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
setGenerateError( setGenerateError(
err instanceof Error ? err.message : "Gagal generate sertifikat." err instanceof Error ? err.message : "Gagal generate sertifikat."
); );
} finally { } finally {
setGenerating(false); setGenerating(false);
} }
}; };
if (loading) { if (loading) {
return ( return (
@@ -281,10 +284,10 @@ export default function EligibleInternTable({
{/* Bottom Action Bar */} {/* Bottom Action Bar */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<button <button
className="text-gray-700 hover:underline" className="text-gray-700 hover:underline"
onClick={()=> navigate(`/mentor/certificates/${project?.slug}/result`)}> onClick={() => navigate(`/mentor/certificates/${project?.slug}/result`)}>
Ke Hasil Ke Hasil
</button> </button>
<button <button
onClick={handleGenerate} onClick={handleGenerate}
disabled={generating || selected.size === 0} disabled={generating || selected.size === 0}
@@ -302,7 +305,7 @@ export default function EligibleInternTable({
</> </>
)} )}
</button> </button>
</div> </div>
</div> </div>
); );
+17 -19
View File
@@ -1,9 +1,6 @@
import api from "../../lib/api"; import api from "../../lib/api";
import QRCode from "qrcode"; import QRCode from "qrcode";
// Flag supaya font hanya di-load sekali (tidak berulang tiap generate)
let fontsLoaded = false;
export function resolveImageUrl(path: string): string { export function resolveImageUrl(path: string): string {
if (!path) return ""; if (!path) return "";
if (path.startsWith("data:")) return path; // untuk QR data URL if (path.startsWith("data:")) return path; // untuk QR data URL
@@ -36,13 +33,13 @@ export async function ensureFontsLoaded(): Promise<void> {
await document.fonts.ready; await document.fonts.ready;
} }
// ── Generate satu sertifikat sebagai Blob ───────────────────────────────────── // Generate satu sertifikat sebagai Blob
export async function generateCertificate( export async function generateCertificate(
templateUrl: string, templateUrl: string,
internName: string, internName: string,
projectName: string, projectName: string,
certificateNo: string, certificateNo: string,
verifyUrl: string verifyUrl: string,
): Promise<Blob> { ): Promise<Blob> {
// Pastikan font siap sebelum menggambar teks // Pastikan font siap sebelum menggambar teks
await ensureFontsLoaded(); await ensureFontsLoaded();
@@ -82,23 +79,24 @@ export async function generateCertificate(
ctx.fillText(certificateNo, canvas.width * 0.825, canvas.height * 0.918); ctx.fillText(certificateNo, canvas.width * 0.825, canvas.height * 0.918);
// QR code — kalau verifyUrl diisi // QR code — kalau verifyUrl diisi
// if (verifyUrl) { const qrDataUrl = await QRCode.toDataURL(verifyUrl, {
// const qrDataUrl = await QRCode.toDataURL(verifyUrl, { width: 200, margin: 1 }); width: 200,
// const qrImg = await loadImage(qrDataUrl); margin: 1,
// const qrSize = canvas.width * 0.08; });
// ctx.drawImage( const qrImg = await loadImage(qrDataUrl);
// qrImg, const qrSize = canvas.width * 0.103;
// canvas.width * 0.1 - qrSize / 2, // posisi X — sesuaikan ctx.drawImage(
// canvas.height * 0.88 - qrSize / 2, // posisi Y — sesuaikan qrImg,
// qrSize, canvas.width * 0.77 - qrSize / 2, // posisi X — sesuaikan
// qrSize canvas.height * 0.715 - qrSize / 2, // posisi Y — sesuaikan
// ); qrSize,
// } qrSize,
);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
canvas.toBlob( canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error("toBlob gagal"))), (blob) => (blob ? resolve(blob) : reject(new Error("toBlob gagal"))),
"image/png" "image/png",
); );
}); });
} }
+47 -36
View File
@@ -7,52 +7,63 @@
</head> </head>
<body> <body>
<canvas id="preview" style="max-width: 100%;"></canvas> <canvas id="preview" style="max-width: 100%;"></canvas>
<script> <script>
// Helper: bungkus loading image jadi Promise, biar bisa di-await
function loadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
}
async function draw() { async function draw() {
// Tunggu semua font dari Google Fonts selesai load
await document.fonts.ready; await document.fonts.ready;
const img = new Image(); // Load template sertifikat & QR dummy secara paralel
img.crossOrigin = "anonymous"; const [templateImg, qrImg] = await Promise.all([
img.onload = () => { loadImage("http://localhost:9000/uploads/1783080498359-420570422.png"),
const canvas = document.getElementById("preview"); loadImage("https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=dummy-uuid-preview")
canvas.width = img.naturalWidth; ]);
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0);
// Nama intern — pakai Great Vibes (font kaligrafi/script) const canvas = document.getElementById("preview");
ctx.font = `105px "Great Vibes", cursive`; canvas.width = templateImg.naturalWidth;
ctx.fillStyle = "#800000"; canvas.height = templateImg.naturalHeight;
ctx.textAlign = "center"; const ctx = canvas.getContext("2d");
ctx.textBaseline = "middle";
ctx.fillText("Jonathan Kristina", canvas.width * 0.57, canvas.height * 0.48);
// Nama intern — versi kecil // 1. Gambar template dulu (paling belakang)
ctx.font = `30px "Great Vibes", cursive`; ctx.drawImage(templateImg, 0, 0);
ctx.fillStyle = "#800000";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("Jonathan Kristina", canvas.width * 0.825, canvas.height * 0.895);
// Nama project — pakai Grenze // 2. Nama intern — Great Vibes
ctx.font = `400 54px "Grenze", serif`; ctx.font = `105px "Great Vibes", cursive`;
ctx.fillStyle = "#090909"; ctx.fillStyle = "#800000";
ctx.fillText("Internify Platform Dev", canvas.width * 0.57, canvas.height * 0.615); ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("Jonathan Kristina", canvas.width * 0.57, canvas.height * 0.48);
// Certificate no — monospace biasa ctx.font = `30px "Great Vibes", cursive`;
ctx.font = `50px Grenze`; ctx.fillText("Jonathan Kristina", canvas.width * 0.825, canvas.height * 0.895);
ctx.fillStyle = "#090909";
ctx.fillText("CERT/20260609/PRJ2/USR5", canvas.width * 0.666, canvas.height * 0.305);
// Certificate no — monospace biasa // 3. Nama project — Grenze
ctx.font = `20px Grenze`; ctx.font = `400 54px "Grenze", serif`;
ctx.fillStyle = "#800000"; ctx.fillStyle = "#090909";
ctx.fillText("CERT/20260609/PRJ2/USR5",canvas.width * 0.825, canvas.height * 0.918); ctx.fillText("Internify Platform Dev", canvas.width * 0.57, canvas.height * 0.615);
};
img.src = "http://localhost:9000/uploads/1783080498359-420570422.png"; // ganti URL template kamu // 4. Certificate no
ctx.font = `50px Grenze`;
ctx.fillText("CERT/20260609/PRJ2/USR5", canvas.width * 0.665, canvas.height * 0.305);
ctx.font = `20px Grenze`;
ctx.fillStyle = "#800000";
ctx.fillText("CERT/20260609/PRJ2/USR5", canvas.width * 0.825, canvas.height * 0.918);
// 5. QR code — posisi & ukuran pakai persentase, sama seperti elemen lain
const qrSize = canvas.width * 0.103; // atur besar-kecil di sini
const qrX = canvas.width * 0.77; // titik kiri-atas QR (bukan tengah!)
const qrY = canvas.height * 0.715;
ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize);
} }
draw(); draw();
+31 -7
View File
@@ -1,6 +1,6 @@
// hooks/useCertificates.ts // hooks/useCertificates.ts
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import type { Certificate } from "../types/certificate.types"; import type { Certificate, CertificateVerifyResult } from "../types/certificate.types";
import { import {
claimCertificate, claimCertificate,
getMyCertificates, getMyCertificates,
@@ -8,7 +8,8 @@ import {
getCertificateDetail, getCertificateDetail,
getCertificatesByProject, getCertificatesByProject,
uploadCertificateTemplate, uploadCertificateTemplate,
generateCertificate generateCertificate,
verifyCertificateByUuid
} from "../services/CertificateService"; } from "../services/CertificateService";
import { AxiosError } from "axios"; import { AxiosError } from "axios";
@@ -106,20 +107,26 @@ export const useGenerateCertificates = () => {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const generate = async (id_project: number, id_users: number[]): Promise<boolean> => { const generate = async (
id_project: number,
id_users: number[]
): Promise<Certificate[] | null> => {
setLoading(true); setLoading(true);
setError(null); setError(null);
setSuccess(false); setSuccess(false);
try { try {
await generateCertificate(id_project, id_users); const data = await generateCertificate(id_project, id_users);
setSuccess(true); setSuccess(true);
return true; return data;
} catch (err) { } catch (err) {
if (err instanceof AxiosError) { if (err instanceof AxiosError) {
switch (err.response?.status) { switch (err.response?.status) {
case 400: case 400:
setError("Ada intern yang tidak eligible atau parameter tidak lengkap."); setError("Ada intern yang tidak eligible atau parameter tidak lengkap.");
break; break;
case 401:
setError("Sesi kamu berakhir, silakan login ulang.");
break;
case 403: case 403:
setError("Hanya mentor/admin project ini yang bisa generate sertifikat."); setError("Hanya mentor/admin project ini yang bisa generate sertifikat.");
break; break;
@@ -132,7 +139,7 @@ export const useGenerateCertificates = () => {
} else { } else {
setError("Gagal generate sertifikat."); setError("Gagal generate sertifikat.");
} }
return false; return null;
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -159,4 +166,21 @@ export const useUploadCertificateTemplate = () => {
}; };
return { upload, loading, error }; return { upload, loading, error };
}; };
export const useCertificateVerification = (uuid: string) => {
const [verification, setVerification] = useState<CertificateVerifyResult | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!uuid) return;
verifyCertificateByUuid(uuid)
.then(setVerification)
.catch(() => setError("Gagal memverifikasi sertifikat."))
.finally(() => setLoading(false));
}, [uuid]);
return { verification, loading, error };
}
+3 -2
View File
@@ -12,9 +12,10 @@ export const claimCertificate = async (id_project: number): Promise<Certificate>
return res.data.data; return res.data.data;
}; };
export const generateCertificate = async (id_project: number, id_users: number[]): Promise<void> => { // POST /certificate-api/generate
export const generateCertificate = async (id_project: number, id_users: number[]): Promise<Certificate[]> => {
const res = await api.post("/certificate-api/generate", { id_project, id_users }); const res = await api.post("/certificate-api/generate", { id_project, id_users });
return res.data.data; return res.data.data.certificates;
}; };
// GET /certificate-api/my-certificates // GET /certificate-api/my-certificates
+7
View File
@@ -32,4 +32,11 @@ export interface CertificateVerification {
export interface CertificateTemplate { export interface CertificateTemplate {
id_project: number; id_project: number;
certificate_template: string; certificate_template: string;
}
export interface CertificateVerifyResult {
uuid: string;
intern_name: string;
project_name: string;
created_at: string;
} }