diff --git a/src/Layout/Navbar.tsx b/src/Layout/Navbar.tsx index 95ad247..4d61bea 100644 --- a/src/Layout/Navbar.tsx +++ b/src/Layout/Navbar.tsx @@ -75,6 +75,15 @@ const Navbar = () => { > Internship + + @@ -114,7 +123,7 @@ const Navbar = () => { onClick={() => { 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 diff --git a/src/Pages/internify/VerificationCert.tsx b/src/Pages/internify/VerificationCert.tsx new file mode 100644 index 0000000..39884f1 --- /dev/null +++ b/src/Pages/internify/VerificationCert.tsx @@ -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 ( +
+

Memverifikasi sertifikat...

+
+ ); + } + + if (error || !verification) { + return ( +
+

Sertifikat Tidak Valid

+

+ Sertifikat dengan UUID ini tidak dapat ditemukan atau sudah tidak berlaku. +

+
+ ); + } + + return ( +
+
+

+ Sertifikat Valid +

+
+
+ Nama Intern +

{verification.intern_name}

+
+
+ Proyek +

{verification.project_name}

+
+
+ Tanggal Terbit +

+ {new Date(verification.created_at).toLocaleDateString("id-ID", { + day: "numeric", + month: "long", + year: "numeric", + })} +

+
+
+ UUID +

{verification.uuid}

+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/Pages/internify/intern/InternCertDetail.tsx b/src/Pages/internify/intern/InternCertDetail.tsx index e34c7ce..2d0cedf 100644 --- a/src/Pages/internify/intern/InternCertDetail.tsx +++ b/src/Pages/internify/intern/InternCertDetail.tsx @@ -7,7 +7,6 @@ import SertificateHistory from "./components/CertificateHistory"; // import { useClaimCertificate } from "../../../hooks/useCertificates"; import { useProjectDetail } from "../../../hooks/useProjects"; - import { useMyCertificates } from "../../../hooks/useCertificates"; import type { Project } from "../../../types/project.types"; diff --git a/src/Pages/internify/intern/components/SubmissionStatusTable.tsx b/src/Pages/internify/intern/components/SubmissionStatusTable.tsx index 5de91a6..5bc0a2f 100644 --- a/src/Pages/internify/intern/components/SubmissionStatusTable.tsx +++ b/src/Pages/internify/intern/components/SubmissionStatusTable.tsx @@ -1,9 +1,10 @@ import { useState } from "react"; import { FileText, FileUp, Link } from "lucide-react"; +import { resolveImageUrl } from "../../../utils/SertificateGenerator"; interface SubmitStatusTableProps { type: "file" | "link"; - filePath?: string | null; // ganti dari files: File[] + filePath?: string | null; link?: string; submittedAt?: Date; deadline?: Date; @@ -66,7 +67,7 @@ export default function SubmitStatusTable({
@@ -106,7 +107,7 @@ export default function SubmitStatusTable({ key={row.label} className="grid grid-cols-[180px_1fr] border-b border-box-border" > -
+
{row.label}
{row.value}
@@ -115,15 +116,20 @@ export default function SubmitStatusTable({ {/* File / Link row */}
-
+
{type === "file" ? "File submission" : "Link submission"}
{type === "file" ? ( -
- - {filePath ? filePath.split("/").pop() : "File submitted"} -
+ + + {resolveImageUrl(filePath ?? "").split("/").pop()} + ) : ( diff --git a/src/Pages/internify/intern/components/TaskForm-file.tsx b/src/Pages/internify/intern/components/TaskForm-file.tsx index 9b1ce84..2360778 100644 --- a/src/Pages/internify/intern/components/TaskForm-file.tsx +++ b/src/Pages/internify/intern/components/TaskForm-file.tsx @@ -65,7 +65,12 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi 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) { return ( {files.length > 0 ? ( + // File baru sudah dipilih — tampilkan itu (menggantikan file lama)
{files.map((file, index) => (
e.stopPropagation()}> @@ -110,6 +118,17 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
))}
+ ) : isEditing && currentFileName ? ( + // Mode edit, belum pilih file baru — tampilkan file submission lama sebagai referensi +
+ +

+ File saat ini: {currentFileName} +

+

+ Klik atau drag & drop untuk mengganti file +

+
) : (
@@ -118,13 +137,25 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
)}
- + +
+ {isEditing && ( + + )} + +
); } \ No newline at end of file diff --git a/src/Pages/internify/mentor/components/EligibleInternTable.tsx b/src/Pages/internify/mentor/components/EligibleInternTable.tsx index 42f2bd0..b8cb517 100644 --- a/src/Pages/internify/mentor/components/EligibleInternTable.tsx +++ b/src/Pages/internify/mentor/components/EligibleInternTable.tsx @@ -14,7 +14,7 @@ import { import type { InternSubmissionProgress } from "../../../../hooks/useInternProgress"; import { useProjectDetail } from "../../../../hooks/useProjects"; import JSZip from "jszip"; -import { generateCertificate } from "../../../utils/SertificateGenerator"; +import { generateCertificate, resolveImageUrl } from "../../../utils/SertificateGenerator"; import { useGenerateCertificates } from "../../../../hooks/useCertificates"; import Avatar from "../../Avatar"; import ProgressBar from "../../ProgressBar"; @@ -40,15 +40,15 @@ function StatusBadge({ status }: { status: boolean }) { } // Main Component -export default function EligibleInternTable({ - interns, - loading, - eligibleCount, - projectId +export default function EligibleInternTable({ + interns, + loading, + eligibleCount, + projectId }: EligibleInternTableProps) { const navigate = useNavigate(); const { project } = useProjectDetail(String(projectId)); - const { generate: SubmitCertificates, loading: generatingCertificates, error, success, reset } = useGenerateCertificates(); + const { generate: SubmitCertificates } = useGenerateCertificates(); const initialSelected = new Set( 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 [generateError, setGenerateError] = useState(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); @@ -78,48 +77,52 @@ export default function EligibleInternTable({ }; const handleGenerate = async () => { - if (!project?.certificate_template) { - setGenerateError("Template sertifikat belum diupload."); - return; - } - if (selected.size === 0) { - setGenerateError("Pilih minimal satu intern."); - return; - } + if (!project?.certificate_template) { + setGenerateError("Template sertifikat belum diupload."); + return; + } + if (selected.size === 0) { + setGenerateError("Pilih minimal satu intern."); + return; + } - setGenerating(true); - setGenerateError(null); + setGenerating(true); + setGenerateError(null); - try { - // 1. Generate blob PNG per intern & kumpulkan ke ZIP - const selectedInterns = interns.filter((i) => selected.has(i.id_user)); - const zip = new JSZip(); + try { + const result = await SubmitCertificates(projectId, Array.from(selected)); + if (!result) return; - await Promise.all( - selectedInterns.map(async (intern) => { - const blob = await generateCertificate( - project.certificate_template, - intern.full_name + const zip = new JSZip(); + const templateUrl = resolveImageUrl(project.certificate_template); + + await Promise.all( + 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 - const ok = await SubmitCertificates(projectId, Array.from(selected)); - if (!ok) return; + zip.file(`Sertifikat - ${cert.user.full_name}.png`, blob); + }) + ); - // 3. 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); - } -}; + // 3. 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 ( @@ -281,10 +284,10 @@ export default function EligibleInternTable({ {/* Bottom Action Bar */}
+ className="text-gray-700 hover:underline" + onClick={() => navigate(`/mentor/certificates/${project?.slug}/result`)}> + Ke Hasil + - +
); diff --git a/src/Pages/utils/SertificateGenerator.ts b/src/Pages/utils/SertificateGenerator.ts index 0d22a13..ed11657 100644 --- a/src/Pages/utils/SertificateGenerator.ts +++ b/src/Pages/utils/SertificateGenerator.ts @@ -1,9 +1,6 @@ import api from "../../lib/api"; import QRCode from "qrcode"; -// Flag supaya font hanya di-load sekali (tidak berulang tiap generate) -let fontsLoaded = false; - export function resolveImageUrl(path: string): string { if (!path) return ""; if (path.startsWith("data:")) return path; // untuk QR data URL @@ -36,13 +33,13 @@ export async function ensureFontsLoaded(): Promise { await document.fonts.ready; } -// ── Generate satu sertifikat sebagai Blob ───────────────────────────────────── +// Generate satu sertifikat sebagai Blob export async function generateCertificate( templateUrl: string, internName: string, projectName: string, certificateNo: string, - verifyUrl: string + verifyUrl: string, ): Promise { // Pastikan font siap sebelum menggambar teks await ensureFontsLoaded(); @@ -82,23 +79,24 @@ export async function generateCertificate( ctx.fillText(certificateNo, canvas.width * 0.825, canvas.height * 0.918); // QR code — kalau verifyUrl diisi - // if (verifyUrl) { - // const qrDataUrl = await QRCode.toDataURL(verifyUrl, { width: 200, margin: 1 }); - // const qrImg = await loadImage(qrDataUrl); - // const qrSize = canvas.width * 0.08; - // ctx.drawImage( - // qrImg, - // canvas.width * 0.1 - qrSize / 2, // posisi X — sesuaikan - // canvas.height * 0.88 - qrSize / 2, // posisi Y — sesuaikan - // qrSize, - // qrSize - // ); - // } + const qrDataUrl = await QRCode.toDataURL(verifyUrl, { + width: 200, + margin: 1, + }); + const qrImg = await loadImage(qrDataUrl); + const qrSize = canvas.width * 0.103; + ctx.drawImage( + qrImg, + canvas.width * 0.77 - qrSize / 2, // posisi X — sesuaikan + canvas.height * 0.715 - qrSize / 2, // posisi Y — sesuaikan + qrSize, + qrSize, + ); return new Promise((resolve, reject) => { canvas.toBlob( (blob) => (blob ? resolve(blob) : reject(new Error("toBlob gagal"))), - "image/png" + "image/png", ); }); -} \ No newline at end of file +} diff --git a/src/Pages/utils/testlayout.html b/src/Pages/utils/testlayout.html index 78eb396..b411376 100644 --- a/src/Pages/utils/testlayout.html +++ b/src/Pages/utils/testlayout.html @@ -7,52 +7,63 @@ -