feat: enhance certificate verification and submission handling, add custom toast notifications

This commit is contained in:
Kemas M. Aryadary Rasyad
2026-07-06 09:01:26 +07:00
parent c4ca23e869
commit 8f5fb82f59
13 changed files with 349 additions and 169 deletions
+80 -70
View File
@@ -1,4 +1,5 @@
import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import { Toaster } from "react-hot-toast";
import Landing from "./Pages/Landing"; import Landing from "./Pages/Landing";
import AboutUs from "./Pages/AboutUs"; import AboutUs from "./Pages/AboutUs";
import Internships from "./Pages/Internships"; import Internships from "./Pages/Internships";
@@ -54,87 +55,96 @@ import CreateMentorForm from "./Pages/CreateMentorForm";
import EditMentorForm from "./Pages/EditMentorForm"; import EditMentorForm from "./Pages/EditMentorForm";
import NotFoundPage from "./Pages/NotFoundPage"; import NotFoundPage from "./Pages/NotFoundPage";
import InternCertificateDetail from "./Pages/Internify/intern/InternCertDetail"; import InternCertificateDetail from "./Pages/Internify/intern/InternCertDetail";
import VerificationCert from "./Pages/Internify/VerificationCert";
function App() { function App() {
return ( return (
<Router> <>
<Routes> <Toaster
<Route path="/" element={<Landing />} /> position="bottom-right"
<Route path="/about-us" element={<AboutUs />} /> reverseOrder={true}
<Route path="/internships" element={<Internships />} /> />
<Route path="/details/:id" element={<InternshipDetails />} />
<Route path="/register-intern/:id" element={<RegisterInternships />} />
<Route path="/register-done" element={<RegisterDone />} />
<Route path="/our-developer" element={<OurDeveloper />} />
<Route path="/details-product/:id" element={<DetailsProduct />} />
<Route path="/login-admin" element={<LoginAdmin />} />
<Route element={<PrivateRoute />}> <Router>
<Route path="/add-product-admin" element={<AddProduct />} /> <Routes>
<Route path="/internships-list" element={<InternshipList />} /> <Route path="/" element={<Landing />} />
<Route path="/lowongan-list" element={<ListLowongan />} /> <Route path="/about-us" element={<AboutUs />} />
<Route path="/product-list" element={<ListProduct />} /> <Route path="/internships" element={<Internships />} />
<Route path="/add-product-humic" element={<AddProductHumic />} /> <Route path="/details/:id" element={<InternshipDetails />} />
<Route path="/edit-product-humic/:id" element={<EditProductHumic />} /> <Route path="/register-intern/:id" element={<RegisterInternships />} />
<Route path="/kontent-aktif/edit/:id" element={<EditKontenAdmin />} /> <Route path="/register-done" element={<RegisterDone />} />
<Route path="/internships-details-admin/:id" element={<InternshipDetailsAdmin />} /> <Route path="/our-developer" element={<OurDeveloper />} />
<Route path="/partnership-admin" element={<PartnershipsList />} /> <Route path="/details-product/:id" element={<DetailsProduct />} />
<Route path="/add-partnership" element={<AddPartnershipAdmin />} /> <Route path="/login-admin" element={<LoginAdmin />} />
<Route path="/edit-partnership/:id" element={<EditPartnerships />} /> <Route path="/verify-certificate/:uuid" element={<VerificationCert />} />
<Route path="/feedback-list" element={<ListFeedback />} />
<Route path="/add-feedback" element={<AddFeedback />} />
<Route path="/faq-list" element={<ListFaq />} />
<Route path="/add-faq" element={<AddFaq />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/edit-faq/:id" element={<EditFaq />}></Route>
<Route path="/edit-feedback/:id" element={<EditFeedback />}></Route>
<Route path="/edit-lowongan/:id" element={<EditLowongan />}></Route>
<Route path="/mentor-admin" element={<AdminMentorPage/>}/>
<Route path="/create-mentor" element={<CreateMentorForm/>}/>
<Route path="/edit-mentor/:id" element={<EditMentorForm/>}/>
</Route> <Route element={<PrivateRoute />}>
<Route path="/add-product-admin" element={<AddProduct />} />
<Route path="/internships-list" element={<InternshipList />} />
<Route path="/lowongan-list" element={<ListLowongan />} />
<Route path="/product-list" element={<ListProduct />} />
<Route path="/add-product-humic" element={<AddProductHumic />} />
<Route path="/edit-product-humic/:id" element={<EditProductHumic />} />
<Route path="/kontent-aktif/edit/:id" element={<EditKontenAdmin />} />
<Route path="/internships-details-admin/:id" element={<InternshipDetailsAdmin />} />
<Route path="/partnership-admin" element={<PartnershipsList />} />
<Route path="/add-partnership" element={<AddPartnershipAdmin />} />
<Route path="/edit-partnership/:id" element={<EditPartnerships />} />
<Route path="/feedback-list" element={<ListFeedback />} />
<Route path="/add-feedback" element={<AddFeedback />} />
<Route path="/faq-list" element={<ListFaq />} />
<Route path="/add-faq" element={<AddFaq />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/edit-faq/:id" element={<EditFaq />}></Route>
<Route path="/edit-feedback/:id" element={<EditFeedback />}></Route>
<Route path="/edit-lowongan/:id" element={<EditLowongan />}></Route>
<Route path="/mentor-admin" element={<AdminMentorPage />} />
<Route path="/create-mentor" element={<CreateMentorForm />} />
<Route path="/edit-mentor/:id" element={<EditMentorForm />} />
<Route path="/login-internify" element={<InternifyLogin />} />
{/* Intern only */}
<Route element={<ProtectedRoutes allowedRoles={["intern"]} />}>
<Route path="/intern" element={<InternLayout />}>
<Route index element={<HomeInternPage />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="certificates" element={<SertificateList />} />
<Route path="certificates/:slug" element={<InternCertificateDetail />} />
<Route path="projects/:slug" element={<ProjectDetailPage />} />
<Route path="projects/:slug/:taskSlug" element={<TaskSubmission />} />
<Route path="faq" element={<FAQPage />} />
<Route path="notifications" element={<NotificationList />} />
<Route path="settings" element={<SettingsContent />} />
</Route> </Route>
</Route>
{/* Mentor/Admin only */} <Route path="/login-internify" element={<InternifyLogin />} />
<Route element={<ProtectedRoutes allowedRoles={["admin", "mentor"]} />}>
<Route path="/mentor" element={<MentorLayout />}> {/* Intern only */}
<Route index element={<HomeMentorPage />} /> <Route element={<ProtectedRoutes allowedRoles={["intern"]} />}>
<Route path="projects" element={<MentorProjectsPage />} /> <Route path="/intern" element={<InternLayout />}>
<Route path="certificates" element={<MentorCertificatePage />} /> <Route index element={<HomeInternPage />} />
<Route path="certificates/:slug" element={<CertificateDetail />} /> <Route path="projects" element={<ProjectsPage />} />
<Route path="certificates/:slug/result" element={<CertificateResult />} /> <Route path="certificates" element={<SertificateList />} />
<Route path="intern" element={<MentorInternPage />} /> <Route path="certificates/:slug" element={<InternCertificateDetail />} />
<Route path="projects/:slug" element={<MentorDetailProject />} /> <Route path="projects/:slug" element={<ProjectDetailPage />} />
<Route path="projects/:slug/:taskSlug" element={<MentorProjectsDetailPage />} /> <Route path="projects/:slug/:taskSlug" element={<TaskSubmission />} />
<Route path="projects/:slug/:taskSlug/:nameIntern" element={<ViewInternSubmission />} /> <Route path="faq" element={<FAQPage />} />
<Route path="faq" element={<FAQPage />} /> <Route path="notifications" element={<NotificationList />} />
<Route path="notifications" element={<NotificationList />} /> <Route path="settings" element={<SettingsContent />} />
<Route path="settings" element={<MentorSettingsPage />} /> </Route>
</Route> </Route>
</Route>
{/* 404 catch-all */} {/* Mentor/Admin only */}
<Route path="*" element={<NotFoundPage />} /> <Route element={<ProtectedRoutes allowedRoles={["admin", "mentor"]} />}>
<Route path="/mentor" element={<MentorLayout />}>
<Route index element={<HomeMentorPage />} />
<Route path="projects" element={<MentorProjectsPage />} />
<Route path="certificates" element={<MentorCertificatePage />} />
<Route path="certificates/:slug" element={<CertificateDetail />} />
<Route path="certificates/:slug/result" element={<CertificateResult />} />
<Route path="intern" element={<MentorInternPage />} />
<Route path="projects/:slug" element={<MentorDetailProject />} />
<Route path="projects/:slug/:taskSlug" element={<MentorProjectsDetailPage />} />
<Route path="projects/:slug/:taskSlug/:nameIntern" element={<ViewInternSubmission />} />
<Route path="faq" element={<FAQPage />} />
<Route path="notifications" element={<NotificationList />} />
<Route path="settings" element={<MentorSettingsPage />} />
</Route>
</Route>
</Routes> {/* 404 catch-all */}
</Router> <Route path="*" element={<NotFoundPage />} />
</Routes>
</Router>
</>
); );
} }
+20 -17
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useCurrentUser, useUpdateProfile } from "../../hooks/useUser"; import { useCurrentUser, useUpdateProfile } from "../../hooks/useUser";
import { customToast } from "../utils/showToast";
export default function SettingsContent() { export default function SettingsContent() {
const { user, loading } = useCurrentUser(); const { user, loading } = useCurrentUser();
@@ -11,7 +12,6 @@ export default function SettingsContent() {
bio: "", bio: "",
}); });
// Sync form data when user is loaded
useEffect(() => { useEffect(() => {
if (user) { if (user) {
setFormData({ setFormData({
@@ -29,11 +29,25 @@ export default function SettingsContent() {
const handleSaveChanges = async (e: React.FormEvent) => { const handleSaveChanges = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
await save({
full_name: formData.fullName, await customToast.promise(
email: formData.email, save({
professional_bio: formData.bio, full_name: formData.fullName,
}); email: formData.email,
professional_bio: formData.bio,
}),
{
loading: "Menyimpan perubahan...",
success: () => ({
title: "Perubahan berhasil disimpan!",
description: successMsg || "Profil Anda telah diperbarui.",
}),
error: () => ({
title: "Gagal menyimpan perubahan!",
description: errorMsg || "Terjadi kesalahan saat menyimpan perubahan.",
}),
}
);
}; };
const initialLetter = formData.fullName ? formData.fullName.charAt(0).toUpperCase() : "U"; const initialLetter = formData.fullName ? formData.fullName.charAt(0).toUpperCase() : "U";
@@ -135,17 +149,6 @@ export default function SettingsContent() {
</p> </p>
</div> </div>
{successMsg && (
<p className="text-xs text-green-600 bg-green-50 p-2 rounded-md font-medium">
{successMsg}
</p>
)}
{errorMsg && (
<p className="text-xs text-red-600 bg-red-50 p-2 rounded-md font-medium">
{errorMsg}
</p>
)}
{/* Form Action Footer Row */} {/* Form Action Footer Row */}
<div className="flex justify-end pt-2"> <div className="flex justify-end pt-2">
<button <button
@@ -21,8 +21,15 @@ export default function CertificateAvailable({ project, certificate, templateUrl
useEffect(() => { useEffect(() => {
if (!templateUrl) return; if (!templateUrl) return;
const verifyUrl = `${window.location.origin}/verify-certificate/${certificate.uuid}`;
generateCertificate(resolveImageUrl(templateUrl), certificate.user.full_name, certificate.project.project_name, certificate.certificate_no, certificate.uuid) generateCertificate(
resolveImageUrl(templateUrl),
certificate.user.full_name,
certificate.project.project_name,
certificate.certificate_no,
verifyUrl,
"20 Juni 2024 - 20 September 2024") //placeholder karena saya butuh data durasi certificate
.then((blob) => { .then((blob) => {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
setPreviewUrl(url); setPreviewUrl(url);
@@ -104,8 +111,8 @@ export default function CertificateAvailable({ project, certificate, templateUrl
key={row.label} key={row.label}
className="flex justify-between items-center text-sm border-b border-card-outline 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-font">{row.label}</span>
<span className="text-gray-800 font-medium">{row.value}</span> <span className="text-font-shade font-bold">{row.value}</span>
</div> </div>
))} ))}
</div> </div>
@@ -1,9 +1,9 @@
import type { ProjectTask } from "../../../../types/task.types"; import type { InternTaskItem } from "../../../../types/project.types";
const formatDate = (dateStr: string) => const formatDate = (dateStr: string) =>
new Date(dateStr).toLocaleDateString('id-ID', { day: 'numeric', month: 'short' }); new Date(dateStr).toLocaleDateString('id-ID', { day: 'numeric', month: 'short' });
export default function ProjectTimeline({ tasks }: { tasks: ProjectTask[] }) { export default function ProjectTimeline({ tasks }: { tasks: InternTaskItem[] }) {
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
return ( return (
@@ -27,7 +27,7 @@ export default function ProjectTimeline({ tasks }: { tasks: ProjectTask[] }) {
{/* Dot */} {/* Dot */}
<div <div
className={`absolute -left-5 top-0.5 w-3 h-3 rounded-full border-2 border-white ${ className={`absolute -left-5 top-0.5 w-3 h-3 rounded-full border-2 border-white ${
active ? 'bg-[#C0392B]' : 'bg-[#ccc]' active ? 'bg-red' : 'bg-gray-400'
} ${isToday ? 'shadow-md' : ''}`} } ${isToday ? 'shadow-md' : ''}`}
/> />
<div className={`text-[11px] font-semibold ${isToday ? 'text-[#C0392B]' : 'text-[#888]'}`}> <div className={`text-[11px] font-semibold ${isToday ? 'text-[#C0392B]' : 'text-[#888]'}`}>
@@ -4,7 +4,7 @@ import { resolveImageUrl } from "../../../utils/SertificateGenerator";
interface SubmitStatusTableProps { interface SubmitStatusTableProps {
type: "file" | "link"; type: "file" | "link";
filePath?: string | null; files?: Array<{ id: number; file_path: string; original_name: string }>;
link?: string; link?: string;
submittedAt?: Date; submittedAt?: Date;
deadline?: Date; deadline?: Date;
@@ -26,7 +26,7 @@ function getTimeEarly(submittedAt: Date, deadline: Date): string {
export default function SubmitStatusTable({ export default function SubmitStatusTable({
type, type,
filePath, files = [],
link = "", link = "",
submittedAt = new Date(), submittedAt = new Date(),
deadline, deadline,
@@ -116,20 +116,27 @@ 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-font border-r border-box-border"> <div className="px-4 py-3 text-sm flex flex-col gap-2 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" ? (
<a files?.length > 0 ? (
href={resolveImageUrl(filePath ?? "")} files?.map((f) => (
target="_blank" <a
rel="noopener noreferrer" key={f.id}
className="flex items-center gap-2 text-sm text-blue-600 hover:underline" href={resolveImageUrl(f.file_path)}
> target="_blank"
<FileText className="w-4 h-4 stroke-[2.5]" /> rel="noopener noreferrer"
<span>{resolveImageUrl(filePath ?? "").split("/").pop()}</span> className="flex items-center gap-2 text-sm text-blue-600 hover:underline"
</a> >
<FileText className="w-4 h-4 stroke-[2.5]" />
<span>{f.original_name}</span>
</a>
))
) : (
<span className="text-sm text-gray-500">No file submitted</span>
)
) : ( ) : (
<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">
@@ -20,7 +20,7 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const validateAndAddFiles = (selectedFiles: File[]) => { const validateAndAddFiles = (selectedFiles: File[]) => {
const allowedExtensions = ["pdf", "docx", "zip"]; const allowedExtensions = ["pdf"];
const validFiles: File[] = []; const validFiles: File[] = [];
selectedFiles.forEach((file) => { selectedFiles.forEach((file) => {
const ext = file.name.split(".").pop()?.toLowerCase(); const ext = file.name.split(".").pop()?.toLowerCase();
@@ -28,7 +28,7 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
if (file.size <= 10 * 1024 * 1024) validFiles.push(file); if (file.size <= 10 * 1024 * 1024) validFiles.push(file);
else alert(`Ukuran file ${file.name} melebihi 10MB.`); else alert(`Ukuran file ${file.name} melebihi 10MB.`);
} else { } else {
alert(`Format ${file.name} tidak didukung. Gunakan PDF, DOCX, atau ZIP.`); alert(`Format ${file.name} tidak didukung. Gunakan PDF.`);
} }
}); });
if (validFiles.length > 0) setFiles((prev) => [...prev, ...validFiles]); if (validFiles.length > 0) setFiles((prev) => [...prev, ...validFiles]);
@@ -54,8 +54,8 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
e.preventDefault(); e.preventDefault();
if (files.length === 0) { alert("Pilih file terlebih dahulu."); return; } if (files.length === 0) { alert("Pilih file terlebih dahulu."); return; }
const res = isEditing const res = isEditing
? await updateFile(submission!.id, files[0]) ? await updateFile(submission!.id, files)
: await submitFile(files[0]); : await submitFile(files);
if (res) { setIsEditing(false); setFiles([]); } if (res) { setIsEditing(false); setFiles([]); }
}; };
@@ -65,7 +65,6 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
if (ok) { setFiles([]); setIsEditing(false); } if (ok) { setFiles([]); setIsEditing(false); }
}; };
// Batal edit — kembali ke tampilan status tanpa kehilangan submission lama
const handleCancelEdit = () => { const handleCancelEdit = () => {
setFiles([]); setFiles([]);
setIsEditing(false); setIsEditing(false);
@@ -75,7 +74,7 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
return ( return (
<SubmitStatusTable <SubmitStatusTable
type="file" type="file"
filePath={submission.file_path} files={submission.files}
submittedAt={new Date(submission.submitted_at)} submittedAt={new Date(submission.submitted_at)}
deadline={deadline} deadline={deadline}
onEdit={() => setIsEditing(true)} onEdit={() => setIsEditing(true)}
@@ -84,7 +83,7 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
); );
} }
const currentFileName = submission?.file_path?.split("/").pop(); // const currentFileName = submission?.file_path?.split("/").pop();
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
@@ -93,7 +92,7 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
multiple multiple
ref={fileInputRef} ref={fileInputRef}
onChange={(e) => e.target.files && validateAndAddFiles(Array.from(e.target.files))} onChange={(e) => e.target.files && validateAndAddFiles(Array.from(e.target.files))}
accept=".pdf,.docx,.zip" accept=".pdf"
className="hidden" className="hidden"
/> />
<div <div
@@ -104,7 +103,6 @@ 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()}>
@@ -118,13 +116,17 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
</div> </div>
))} ))}
</div> </div>
) : isEditing && currentFileName ? ( ) : isEditing && submission?.files && submission.files.length > 0 ? (
// 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"> <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" /> <FileText className="w-6 h-6 text-gray-400" />
<p className="text-sm font-semibold text-gray-700"> <p className="text-sm font-semibold text-gray-700">File saat ini:</p>
File saat ini: <span className="text-red-700">{currentFileName}</span> <div className="space-y-0.5">
</p> {submission.files.map((f) => (
<p key={f.id} className="text-sm font-medium text-red-700">
{f.original_name}
</p>
))}
</div>
<p className="text-[11px] text-gray-400 font-medium pointer-events-none"> <p className="text-[11px] text-gray-400 font-medium pointer-events-none">
Klik atau drag & drop untuk mengganti file Klik atau drag & drop untuk mengganti file
</p> </p>
@@ -19,24 +19,30 @@ const TaskTimeline = () => {
return ( return (
<div> <div>
<h2 className="font-semibold text-font-shade mb-5">Task Timeline</h2> <h2 className="font-semibold text-font-shade mb-5">Timeline Tugas</h2>
<div className="relative"> <div className="relative">
<div className="absolute left-1 top-2 bottom-2 w-px bg-gray-300" />
<ul className="flex flex-col gap-5"> <ul className="flex flex-col gap-5">
{tasks.map((item: InternTaskItem) => { {tasks.map((item: InternTaskItem, index) => {
const overdue = isOverdue(item.deadline_at); const overdue = isOverdue(item.deadline_at);
const deadlineDate = new Date(item.deadline_at); const deadlineDate = new Date(item.deadline_at);
const isLast = index === tasks.length - 1;
return ( return (
<li key={item.id} className="flex gap-4 pl-0"> <li key={item.id} className="relative flex gap-4 pl-0">
{/* Vertical line ke bawah, kecuali item terakhir */}
{!isLast && (
<div className="absolute left-[3px] top-3 w-px h-full bg-[#ddd]" />
)}
<div className="relative flex-shrink-0 mt-1"> <div className="relative flex-shrink-0 mt-1">
<span <span
className={`block w-2 h-2 rounded-full z-10 relative ${ className={`block w-3 h-3 rounded-full z-10 relative border-2 right-0.5 border-white ${overdue ? "bg-primary" : "bg-gray-500"
overdue ? "bg-primary" : "bg-gray-500" }`}
}`}
/> />
</div> </div>
<div className="flex flex-col gap-0.5 min-w-0"> <div className="flex flex-col gap-0.5 min-w-0">
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-widest"> <p className="text-[10px] font-semibold text-font-shade uppercase tracking-widest">
{deadlineDate.toLocaleDateString("id-ID")} AT{" "} {deadlineDate.toLocaleDateString("id-ID")} AT{" "}
{deadlineDate.toLocaleTimeString("id-ID", { {deadlineDate.toLocaleTimeString("id-ID", {
hour: "2-digit", hour: "2-digit",
@@ -45,6 +45,7 @@ export default function EligibleInternTable({
loading, loading,
eligibleCount, eligibleCount,
projectId projectId
// Durasi tanggal Proyek
}: EligibleInternTableProps) { }: EligibleInternTableProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { project } = useProjectDetail(String(projectId)); const { project } = useProjectDetail(String(projectId));
@@ -98,14 +99,15 @@ export default function EligibleInternTable({
await Promise.all( await Promise.all(
result.map(async (cert) => { result.map(async (cert) => {
const verifyUrl = `${window.location.origin}/verify/${cert.uuid}`; const verifyUrl = `${window.location.origin}/verify-certificate/${cert.uuid}`;
const blob = await generateCertificate( const blob = await generateCertificate(
templateUrl, templateUrl,
cert.user.full_name, cert.user.full_name,
cert.project.project_name, cert.project.project_name,
cert.certificate_no, cert.certificate_no,
verifyUrl verifyUrl,
"20 Juni 2024 - 20 September 2024" // Placeholder untuk durasi proyek
); );
zip.file(`Sertifikat - ${cert.user.full_name}.png`, blob); zip.file(`Sertifikat - ${cert.user.full_name}.png`, blob);
+9 -3
View File
@@ -39,6 +39,7 @@ export async function generateCertificate(
internName: string, internName: string,
projectName: string, projectName: string,
certificateNo: string, certificateNo: string,
ProjectDuration: string,
verifyUrl: string, verifyUrl: string,
): Promise<Blob> { ): Promise<Blob> {
// Pastikan font siap sebelum menggambar teks // Pastikan font siap sebelum menggambar teks
@@ -78,8 +79,13 @@ export async function generateCertificate(
ctx.fillStyle = "#800000"; ctx.fillStyle = "#800000";
ctx.fillText(certificateNo, canvas.width * 0.825, canvas.height * 0.918); ctx.fillText(certificateNo, canvas.width * 0.825, canvas.height * 0.918);
//Durasi Project — besar (Grenze)
ctx.font = `50px "Grenze", serif`;
ctx.fillStyle = "#090909";
ctx.fillText(ProjectDuration, canvas.width * 0.57, canvas.height * 0.715);
// QR code — kalau verifyUrl diisi // QR code — kalau verifyUrl diisi
const qrDataUrl = await QRCode.toDataURL(verifyUrl, { const qrDataUrl = await QRCode.toDataURL('http://localhost:5172/verify-certificate/' + verifyUrl, {
width: 200, width: 200,
margin: 1, margin: 1,
}); });
@@ -87,8 +93,8 @@ export async function generateCertificate(
const qrSize = canvas.width * 0.103; const qrSize = canvas.width * 0.103;
ctx.drawImage( ctx.drawImage(
qrImg, qrImg,
canvas.width * 0.77 - qrSize / 2, // posisi X — sesuaikan canvas.width * 0.77 - qrSize,
canvas.height * 0.715 - qrSize / 2, // posisi Y — sesuaikan canvas.height * 0.715 - qrSize,
qrSize, qrSize,
qrSize, qrSize,
); );
+67
View File
@@ -0,0 +1,67 @@
// utils/toast.tsx
import toast, { type Toast } from 'react-hot-toast';
import { FiXCircle, FiX } from 'react-icons/fi';
import { FaCheck } from "react-icons/fa";
import { IoAlertOutline } from "react-icons/io5";
function ToastCard({ t, type, title, description }: {
t: Toast;
type: 'success' | 'error';
title: string;
description?: string;
}) {
const isSuccess = type === 'success';
return (
<div
className={`${t.visible ? 'animate-enter' : 'animate-leave'}
flex items-start gap-3 bg-white shadow-xl rounded-lg z-10 pointer-events-auto
border-l-4 ${isSuccess ? 'border-green-600' : 'border-red-600'}
px-4 py-3 min-w-[320px] max-w-[420px]`}
>
<div className={`flex items-center justify-center w-6 h-6 rounded-full mt-0.5 shrink-0
${isSuccess ? 'bg-green-600 text-white' : 'bg-red-600 text-white'}`}>
{isSuccess ? <FaCheck size={10} /> : <IoAlertOutline size={10} />}
</div>
<div className="flex-1">
<p className={`font-semibold text-[10px] ${isSuccess ? 'text-green-700' : 'text-red-700'}`}>
{title}
</p>
{description && <p className="text-[13px] text-gray-700 mt-0.5">{description}</p>}
</div>
<button onClick={() => toast.dismiss(t.id)} className="text-gray-500 hover:text-gray-800 mt-0.5 shrink-0">
<FiX size={16} />
</button>
</div>
);
}
export const customToast = {
success: (title: string, description?: string) =>
toast.custom((t) => <ToastCard t={t} type="success" title={title} description={description} />),
error: (title: string, description?: string) =>
toast.custom((t) => <ToastCard t={t} type="error" title={title} description={description} />),
promise: async <T,>(
promise: Promise<T>,
messages: {
loading: string;
success: (data: T) => { title: string; description?: string };
error: (err: any) => { title: string; description?: string };
}
): Promise<T> => {
const loadingId = toast.loading(messages.loading);
try {
const data = await promise;
toast.dismiss(loadingId);
const { title, description } = messages.success(data);
customToast.success(title, description);
return data;
} catch (err) {
toast.dismiss(loadingId);
const { title, description } = messages.error(err);
customToast.error(title, description);
throw err;
}
},
};
+94 -33
View File
@@ -1,6 +1,13 @@
// hooks/useTasks.ts // hooks/useTasks.ts
import { useState, useEffect, useCallback, useMemo } from "react"; import { useState, useEffect, useCallback, useMemo } from "react";
import type { ProjectTask, ProjectTaskDetail, CreateTaskPayload, UpdateTaskPayload, TaskSubmissionData, AdminTaskDetail } from "../types/task.types"; import type {
ProjectTask,
ProjectTaskDetail,
CreateTaskPayload,
UpdateTaskPayload,
TaskSubmissionData,
AdminTaskDetail,
} from "../types/task.types";
import { import {
getProjectTasks, getProjectTasks,
getTaskById, getTaskById,
@@ -34,7 +41,9 @@ export const useProjectTasks = (projectId: string) => {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [projectId]); }, [projectId]);
useEffect(() => { refetch(); }, [refetch]); useEffect(() => {
refetch();
}, [refetch]);
return { tasks, loading, error, refetch }; // tambah refetch return { tasks, loading, error, refetch }; // tambah refetch
}; };
@@ -54,7 +63,9 @@ export const useTaskDetail = (taskId: string, projectId?: string) => {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [taskId, projectId]); }, [taskId, projectId]);
useEffect(() => { refetch(); }, [refetch]); useEffect(() => {
refetch();
}, [refetch]);
return { task, loading, error, refetch }; return { task, loading, error, refetch };
}; };
@@ -73,7 +84,9 @@ export const useTaskSubmissions = (taskId: string, projectId?: string) => {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [taskId, projectId]); }, [taskId, projectId]);
useEffect(() => { refetch(); }, [refetch]); useEffect(() => {
refetch();
}, [refetch]);
return { task, loading, error, refetch }; return { task, loading, error, refetch };
}; };
@@ -83,7 +96,9 @@ export const useCreateTask = (projectId: string) => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const create = async (payload: CreateTaskPayload): Promise<ProjectTask | null> => { const create = async (
payload: CreateTaskPayload,
): Promise<ProjectTask | null> => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
@@ -107,7 +122,7 @@ export const useUpdateTask = () => {
const update = async ( const update = async (
taskId: string | number, taskId: string | number,
payload: UpdateTaskPayload, payload: UpdateTaskPayload,
projectId?: string projectId?: string,
) => { ) => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -147,62 +162,102 @@ export const useDeleteTask = () => {
}; };
// POST + PATCH + DELETE submission — digabung dalam satu hook per task // POST + PATCH + DELETE submission — digabung dalam satu hook per task
export const useSubmission = (taskId: string, initialSubmission?: TaskSubmissionData | null, projectId?: string) => { export const useSubmission = (
const [submission, setSubmission] = useState<TaskSubmissionData | null>(initialSubmission ?? null); taskId: string,
initialSubmission?: TaskSubmissionData | null,
projectId?: string,
) => {
const [submission, setSubmission] = useState<TaskSubmissionData | null>(
initialSubmission ?? null,
);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const submitFile = async (file: File) => { const submitFile = async (files: File[]) => {
setLoading(true); setError(null); setLoading(true);
setError(null);
try { try {
const res = await submitTaskFile(taskId, file, projectId); const res = await submitTaskFile(taskId, files, projectId);
if (res) setSubmission(res); if (res) setSubmission(res);
return res; return res;
} catch { setError("Gagal submit file."); return null; } } catch {
finally { setLoading(false); } setError("Gagal submit file.");
return null;
} finally {
setLoading(false);
}
}; };
const submitLink = async (url: string) => { const submitLink = async (url: string) => {
setLoading(true); setError(null); setLoading(true);
setError(null);
try { try {
const res = await submitTaskLink(taskId, url, projectId); const res = await submitTaskLink(taskId, url, projectId);
if (res) setSubmission(res); if (res) setSubmission(res);
return res; return res;
} catch { setError("Gagal submit link."); return null; } } catch {
finally { setLoading(false); } setError("Gagal submit link.");
return null;
} finally {
setLoading(false);
}
}; };
const updateFile = async (submissionId: number, file: File) => { const updateFile = async (submissionId: number, files: File[]) => {
setLoading(true); setError(null); setLoading(true);
setError(null);
try { try {
const res = await updateSubmissionFile(submissionId, file); const res = await updateSubmissionFile(submissionId, files);
if (res) setSubmission(res); if (res) setSubmission(res);
return res; return res;
} catch { setError("Gagal update submission."); return null; } } catch {
finally { setLoading(false); } setError("Gagal update submission.");
return null;
} finally {
setLoading(false);
}
}; };
const updateLink = async (submissionId: number, url: string) => { const updateLink = async (submissionId: number, url: string) => {
setLoading(true); setError(null); setLoading(true);
setError(null);
try { try {
const res = await updateSubmissionLink(submissionId, url); const res = await updateSubmissionLink(submissionId, url);
if (res) setSubmission(res); if (res) setSubmission(res);
return res; return res;
} catch { setError("Gagal update submission."); return null; } } catch {
finally { setLoading(false); } setError("Gagal update submission.");
return null;
} finally {
setLoading(false);
}
}; };
const remove = async (submissionId: number) => { const remove = async (submissionId: number) => {
setLoading(true); setError(null); setLoading(true);
setError(null);
try { try {
await deleteSubmission(submissionId); await deleteSubmission(submissionId);
setSubmission(null); setSubmission(null);
return true; return true;
} catch { setError("Gagal hapus submission."); return false; } } catch {
finally { setLoading(false); } setError("Gagal hapus submission.");
return false;
} finally {
setLoading(false);
}
}; };
return { submission, submitFile, submitLink, updateFile, updateLink, remove, loading, error }; return {
submission,
submitFile,
submitLink,
updateFile,
updateLink,
remove,
loading,
error,
};
}; };
// hooks/useAllMentorTasks.ts // hooks/useAllMentorTasks.ts
@@ -233,10 +288,16 @@ export const useDeadlines = () => {
const isIntern = user?.role === "intern"; const isIntern = user?.role === "intern";
const { tasks: internTasks, loading: internLoading, error: internError } = useMyTasks(); const {
const { tasks: mentorTasks, loading: mentorLoading, error: mentorError } = useAllMentorTasks( tasks: internTasks,
!isIntern && user !== null loading: internLoading,
); error: internError,
} = useMyTasks();
const {
tasks: mentorTasks,
loading: mentorLoading,
error: mentorError,
} = useAllMentorTasks(!isIntern && user !== null);
const deadlines: Deadline[] = useMemo(() => { const deadlines: Deadline[] = useMemo(() => {
if (!user) return []; if (!user) return [];
@@ -256,4 +317,4 @@ export const useDeadlines = () => {
const error = userError || (isIntern ? internError : mentorError); const error = userError || (isIntern ? internError : mentorError);
return { deadlines, loading, error, isIntern }; return { deadlines, loading, error, isIntern };
}; };
+4 -4
View File
@@ -91,11 +91,11 @@ export const getAllMentorTasks = async (): Promise<MentorTaskItem[]> => {
export const submitTaskFile = async ( export const submitTaskFile = async (
taskId: string | number, taskId: string | number,
file: File, files: File[],
projectId?: string | number projectId?: string | number
): Promise<TaskSubmissionData> => { ): Promise<TaskSubmissionData> => {
const formData = new FormData(); const formData = new FormData();
formData.append("submission_file", file); files.forEach((file) => formData.append("submission_files", file));
const res = await api.post(`/task-api/tasks/${taskId}/submissions`, formData, { const res = await api.post(`/task-api/tasks/${taskId}/submissions`, formData, {
params: projectId ? { project: projectId } : undefined, params: projectId ? { project: projectId } : undefined,
}); });
@@ -117,10 +117,10 @@ export const submitTaskLink = async (
export const updateSubmissionFile = async ( export const updateSubmissionFile = async (
submissionId: number, submissionId: number,
file: File files: File[]
): Promise<TaskSubmissionData> => { ): Promise<TaskSubmissionData> => {
const formData = new FormData(); const formData = new FormData();
formData.append("submission_file", file); files.forEach((file) => formData.append("submission_files", file));
const res = await api.patch(`/task-api/submissions/${submissionId}`, formData); const res = await api.patch(`/task-api/submissions/${submissionId}`, formData);
return res.data.data; return res.data.data;
}; };
+9
View File
@@ -20,6 +20,15 @@ export interface TaskSubmissionData {
id_task: number; id_task: number;
id_user: number; id_user: number;
file_path: string | null; file_path: string | null;
files: Array<{
id: number;
file_path: string;
original_name: string;
mime_type: string;
size_bytes: number;
created_at: string;
}>
file_paths:string[];
url_link: string | null; url_link: string | null;
submitted_at: string; submitted_at: string;
updated_at: string; updated_at: string;