Generate Certificates: Adding QR with UUID of certificates to verify
This commit is contained in:
@@ -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 { useProjectDetail } from "../../../hooks/useProjects";
|
||||
|
||||
import { useMyCertificates } from "../../../hooks/useCertificates";
|
||||
import type { Project } from "../../../types/project.types";
|
||||
|
||||
|
||||
@@ -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({
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
@@ -106,7 +107,7 @@ export default function SubmitStatusTable({
|
||||
key={row.label}
|
||||
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}
|
||||
</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 */}
|
||||
<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"}
|
||||
</div>
|
||||
<div className="px-4 py-3 flex flex-col gap-2">
|
||||
{type === "file" ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-800">
|
||||
<FileText className="w-4 h-4 text-red-700 stroke-[2.5]" />
|
||||
{filePath ? filePath.split("/").pop() : "File submitted"}
|
||||
</div>
|
||||
<a
|
||||
href={resolveImageUrl(filePath ?? "")}
|
||||
target="_blank"
|
||||
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"
|
||||
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); }
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<SubmitStatusTable
|
||||
@@ -79,6 +84,8 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
|
||||
);
|
||||
}
|
||||
|
||||
const currentFileName = submission?.file_path?.split("/").pop();
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<input
|
||||
@@ -97,6 +104,7 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
|
||||
}`}
|
||||
>
|
||||
{files.length > 0 ? (
|
||||
// File baru sudah dipilih — tampilkan itu (menggantikan file lama)
|
||||
<div className="space-y-2">
|
||||
{files.map((file, index) => (
|
||||
<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>
|
||||
) : 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">
|
||||
<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>
|
||||
<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 className="flex gap-2">
|
||||
{isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelEdit}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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<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);
|
||||
@@ -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 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
className="text-gray-700 hover:underline"
|
||||
onClick={()=> navigate(`/mentor/certificates/${project?.slug}/result`)}>
|
||||
Ke Hasil
|
||||
</button>
|
||||
className="text-gray-700 hover:underline"
|
||||
onClick={() => navigate(`/mentor/certificates/${project?.slug}/result`)}>
|
||||
Ke Hasil
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={generating || selected.size === 0}
|
||||
@@ -302,7 +305,7 @@ export default function EligibleInternTable({
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user