From 8f5fb82f59e27bda01fadb81c50c56c5dafc9f62 Mon Sep 17 00:00:00 2001 From: "Kemas M. Aryadary Rasyad" <112590882+aryadary0706@users.noreply.github.com> Date: Mon, 6 Jul 2026 09:01:26 +0700 Subject: [PATCH] feat: enhance certificate verification and submission handling, add custom toast notifications --- src/App.tsx | 150 ++++++++++-------- src/Pages/internify/SettingsPage.tsx | 37 +++-- .../intern/components/CertificateAvailabe.tsx | 13 +- .../intern/components/ProjectTimeline.tsx | 6 +- .../components/SubmissionStatusTable.tsx | 31 ++-- .../intern/components/TaskForm-file.tsx | 30 ++-- .../intern/components/TaskTimeline.tsx | 22 ++- .../mentor/components/EligibleInternTable.tsx | 6 +- src/Pages/utils/SertificateGenerator.ts | 12 +- src/Pages/utils/showToast.tsx | 67 ++++++++ src/hooks/useTasks.ts | 127 +++++++++++---- src/services/TaskService.ts | 8 +- src/types/task.types.ts | 9 ++ 13 files changed, 349 insertions(+), 169 deletions(-) create mode 100644 src/Pages/utils/showToast.tsx diff --git a/src/App.tsx b/src/App.tsx index 11dce24..64e3914 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; +import { Toaster } from "react-hot-toast"; import Landing from "./Pages/Landing"; import AboutUs from "./Pages/AboutUs"; import Internships from "./Pages/Internships"; @@ -54,87 +55,96 @@ import CreateMentorForm from "./Pages/CreateMentorForm"; import EditMentorForm from "./Pages/EditMentorForm"; import NotFoundPage from "./Pages/NotFoundPage"; import InternCertificateDetail from "./Pages/Internify/intern/InternCertDetail"; +import VerificationCert from "./Pages/Internify/VerificationCert"; function App() { return ( - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + <> + - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - }> - }> - }> - }/> - }/> - }/> + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> - + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + }> + }> + }> + } /> + } /> + } /> - } /> - - {/* Intern only */} - }> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - {/* Mentor/Admin only */} - }> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + + {/* Intern only */} + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + - - {/* 404 catch-all */} - } /> + {/* Mentor/Admin only */} + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + - - + {/* 404 catch-all */} + } /> + + + + ); } diff --git a/src/Pages/internify/SettingsPage.tsx b/src/Pages/internify/SettingsPage.tsx index 9ac53ed..f6a1321 100644 --- a/src/Pages/internify/SettingsPage.tsx +++ b/src/Pages/internify/SettingsPage.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from "react"; import { useCurrentUser, useUpdateProfile } from "../../hooks/useUser"; +import { customToast } from "../utils/showToast"; export default function SettingsContent() { const { user, loading } = useCurrentUser(); @@ -11,7 +12,6 @@ export default function SettingsContent() { bio: "", }); - // Sync form data when user is loaded useEffect(() => { if (user) { setFormData({ @@ -29,11 +29,25 @@ export default function SettingsContent() { const handleSaveChanges = async (e: React.FormEvent) => { e.preventDefault(); - await save({ - full_name: formData.fullName, - email: formData.email, - professional_bio: formData.bio, - }); + + await customToast.promise( + save({ + 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"; @@ -135,17 +149,6 @@ export default function SettingsContent() {

- {successMsg && ( -

- {successMsg} -

- )} - {errorMsg && ( -

- {errorMsg} -

- )} - {/* Form Action Footer Row */}
))} diff --git a/src/Pages/internify/intern/components/ProjectTimeline.tsx b/src/Pages/internify/intern/components/ProjectTimeline.tsx index 09da275..8b2314c 100644 --- a/src/Pages/internify/intern/components/ProjectTimeline.tsx +++ b/src/Pages/internify/intern/components/ProjectTimeline.tsx @@ -1,9 +1,9 @@ -import type { ProjectTask } from "../../../../types/task.types"; +import type { InternTaskItem } from "../../../../types/project.types"; const formatDate = (dateStr: string) => 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(); today.setHours(0, 0, 0, 0); return ( @@ -27,7 +27,7 @@ export default function ProjectTimeline({ tasks }: { tasks: ProjectTask[] }) { {/* Dot */}
diff --git a/src/Pages/internify/intern/components/SubmissionStatusTable.tsx b/src/Pages/internify/intern/components/SubmissionStatusTable.tsx index 5bc0a2f..b902f73 100644 --- a/src/Pages/internify/intern/components/SubmissionStatusTable.tsx +++ b/src/Pages/internify/intern/components/SubmissionStatusTable.tsx @@ -4,7 +4,7 @@ import { resolveImageUrl } from "../../../utils/SertificateGenerator"; interface SubmitStatusTableProps { type: "file" | "link"; - filePath?: string | null; + files?: Array<{ id: number; file_path: string; original_name: string }>; link?: string; submittedAt?: Date; deadline?: Date; @@ -26,7 +26,7 @@ function getTimeEarly(submittedAt: Date, deadline: Date): string { export default function SubmitStatusTable({ type, - filePath, + files = [], link = "", submittedAt = new Date(), deadline, @@ -116,20 +116,27 @@ export default function SubmitStatusTable({ {/* File / Link row */}
-
+
{type === "file" ? "File submission" : "Link submission"}
{type === "file" ? ( - - - {resolveImageUrl(filePath ?? "").split("/").pop()} - + files?.length > 0 ? ( + files?.map((f) => ( + + + {f.original_name} + + )) + ) : ( + No file submitted + ) ) : ( diff --git a/src/Pages/internify/intern/components/TaskForm-file.tsx b/src/Pages/internify/intern/components/TaskForm-file.tsx index 2360778..19b44c1 100644 --- a/src/Pages/internify/intern/components/TaskForm-file.tsx +++ b/src/Pages/internify/intern/components/TaskForm-file.tsx @@ -20,7 +20,7 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi const fileInputRef = useRef(null); const validateAndAddFiles = (selectedFiles: File[]) => { - const allowedExtensions = ["pdf", "docx", "zip"]; + const allowedExtensions = ["pdf"]; const validFiles: File[] = []; selectedFiles.forEach((file) => { 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); else alert(`Ukuran file ${file.name} melebihi 10MB.`); } 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]); @@ -54,8 +54,8 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi e.preventDefault(); if (files.length === 0) { alert("Pilih file terlebih dahulu."); return; } const res = isEditing - ? await updateFile(submission!.id, files[0]) - : await submitFile(files[0]); + ? await updateFile(submission!.id, files) + : await submitFile(files); if (res) { setIsEditing(false); setFiles([]); } }; @@ -65,7 +65,6 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi if (ok) { setFiles([]); setIsEditing(false); } }; - // Batal edit — kembali ke tampilan status tanpa kehilangan submission lama const handleCancelEdit = () => { setFiles([]); setIsEditing(false); @@ -75,7 +74,7 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi return ( 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 (
@@ -93,7 +92,7 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi multiple ref={fileInputRef} onChange={(e) => e.target.files && validateAndAddFiles(Array.from(e.target.files))} - accept=".pdf,.docx,.zip" + accept=".pdf" className="hidden" />
{files.length > 0 ? ( - // File baru sudah dipilih — tampilkan itu (menggantikan file lama)
{files.map((file, index) => (
e.stopPropagation()}> @@ -118,13 +116,17 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
))}
- ) : isEditing && currentFileName ? ( - // Mode edit, belum pilih file baru — tampilkan file submission lama sebagai referensi + ) : isEditing && submission?.files && submission.files.length > 0 ? (
-

- File saat ini: {currentFileName} -

+

File saat ini:

+
+ {submission.files.map((f) => ( +

+ {f.original_name} +

+ ))} +

Klik atau drag & drop untuk mengganti file

diff --git a/src/Pages/internify/intern/components/TaskTimeline.tsx b/src/Pages/internify/intern/components/TaskTimeline.tsx index 5206e25..fd01609 100644 --- a/src/Pages/internify/intern/components/TaskTimeline.tsx +++ b/src/Pages/internify/intern/components/TaskTimeline.tsx @@ -19,24 +19,30 @@ const TaskTimeline = () => { return (
-

Task Timeline

+

Timeline Tugas

-
    - {tasks.map((item: InternTaskItem) => { + {tasks.map((item: InternTaskItem, index) => { const overdue = isOverdue(item.deadline_at); const deadlineDate = new Date(item.deadline_at); + const isLast = index === tasks.length - 1; + return ( -
  • +
  • + {/* Vertical line ke bawah, kecuali item terakhir */} + {!isLast && ( +
    + )} +
    +
    -

    +

    {deadlineDate.toLocaleDateString("id-ID")} AT{" "} {deadlineDate.toLocaleTimeString("id-ID", { hour: "2-digit", diff --git a/src/Pages/internify/mentor/components/EligibleInternTable.tsx b/src/Pages/internify/mentor/components/EligibleInternTable.tsx index b8cb517..b1bf1a7 100644 --- a/src/Pages/internify/mentor/components/EligibleInternTable.tsx +++ b/src/Pages/internify/mentor/components/EligibleInternTable.tsx @@ -45,6 +45,7 @@ export default function EligibleInternTable({ loading, eligibleCount, projectId + // Durasi tanggal Proyek }: EligibleInternTableProps) { const navigate = useNavigate(); const { project } = useProjectDetail(String(projectId)); @@ -98,14 +99,15 @@ export default function EligibleInternTable({ await Promise.all( 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( templateUrl, cert.user.full_name, cert.project.project_name, cert.certificate_no, - verifyUrl + verifyUrl, + "20 Juni 2024 - 20 September 2024" // Placeholder untuk durasi proyek ); zip.file(`Sertifikat - ${cert.user.full_name}.png`, blob); diff --git a/src/Pages/utils/SertificateGenerator.ts b/src/Pages/utils/SertificateGenerator.ts index ed11657..ec6d782 100644 --- a/src/Pages/utils/SertificateGenerator.ts +++ b/src/Pages/utils/SertificateGenerator.ts @@ -39,6 +39,7 @@ export async function generateCertificate( internName: string, projectName: string, certificateNo: string, + ProjectDuration: string, verifyUrl: string, ): Promise { // Pastikan font siap sebelum menggambar teks @@ -78,8 +79,13 @@ export async function generateCertificate( ctx.fillStyle = "#800000"; 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 - const qrDataUrl = await QRCode.toDataURL(verifyUrl, { + const qrDataUrl = await QRCode.toDataURL('http://localhost:5172/verify-certificate/' + verifyUrl, { width: 200, margin: 1, }); @@ -87,8 +93,8 @@ export async function generateCertificate( 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 + canvas.width * 0.77 - qrSize, + canvas.height * 0.715 - qrSize, qrSize, qrSize, ); diff --git a/src/Pages/utils/showToast.tsx b/src/Pages/utils/showToast.tsx new file mode 100644 index 0000000..e0940a4 --- /dev/null +++ b/src/Pages/utils/showToast.tsx @@ -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 ( +

    +
    + {isSuccess ? : } +
    +
    +

    + {title} +

    + {description &&

    {description}

    } +
    + +
    + ); +} + +export const customToast = { + success: (title: string, description?: string) => + toast.custom((t) => ), + + error: (title: string, description?: string) => + toast.custom((t) => ), + + promise: async ( + promise: Promise, + messages: { + loading: string; + success: (data: T) => { title: string; description?: string }; + error: (err: any) => { title: string; description?: string }; + } + ): Promise => { + 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; + } + }, +}; \ No newline at end of file diff --git a/src/hooks/useTasks.ts b/src/hooks/useTasks.ts index 36867d4..a24ce59 100644 --- a/src/hooks/useTasks.ts +++ b/src/hooks/useTasks.ts @@ -1,6 +1,13 @@ // hooks/useTasks.ts 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 { getProjectTasks, getTaskById, @@ -34,7 +41,9 @@ export const useProjectTasks = (projectId: string) => { .finally(() => setLoading(false)); }, [projectId]); - useEffect(() => { refetch(); }, [refetch]); + useEffect(() => { + refetch(); + }, [refetch]); return { tasks, loading, error, refetch }; // tambah refetch }; @@ -54,7 +63,9 @@ export const useTaskDetail = (taskId: string, projectId?: string) => { .finally(() => setLoading(false)); }, [taskId, projectId]); - useEffect(() => { refetch(); }, [refetch]); + useEffect(() => { + refetch(); + }, [refetch]); return { task, loading, error, refetch }; }; @@ -73,7 +84,9 @@ export const useTaskSubmissions = (taskId: string, projectId?: string) => { .finally(() => setLoading(false)); }, [taskId, projectId]); - useEffect(() => { refetch(); }, [refetch]); + useEffect(() => { + refetch(); + }, [refetch]); return { task, loading, error, refetch }; }; @@ -83,7 +96,9 @@ export const useCreateTask = (projectId: string) => { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const create = async (payload: CreateTaskPayload): Promise => { + const create = async ( + payload: CreateTaskPayload, + ): Promise => { setLoading(true); setError(null); try { @@ -107,7 +122,7 @@ export const useUpdateTask = () => { const update = async ( taskId: string | number, payload: UpdateTaskPayload, - projectId?: string + projectId?: string, ) => { setLoading(true); setError(null); @@ -147,62 +162,102 @@ export const useDeleteTask = () => { }; // POST + PATCH + DELETE submission — digabung dalam satu hook per task -export const useSubmission = (taskId: string, initialSubmission?: TaskSubmissionData | null, projectId?: string) => { - const [submission, setSubmission] = useState(initialSubmission ?? null); +export const useSubmission = ( + taskId: string, + initialSubmission?: TaskSubmissionData | null, + projectId?: string, +) => { + const [submission, setSubmission] = useState( + initialSubmission ?? null, + ); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const submitFile = async (file: File) => { - setLoading(true); setError(null); + const submitFile = async (files: File[]) => { + setLoading(true); + setError(null); try { - const res = await submitTaskFile(taskId, file, projectId); + const res = await submitTaskFile(taskId, files, projectId); if (res) setSubmission(res); return res; - } catch { setError("Gagal submit file."); return null; } - finally { setLoading(false); } + } catch { + setError("Gagal submit file."); + return null; + } finally { + setLoading(false); + } }; const submitLink = async (url: string) => { - setLoading(true); setError(null); + setLoading(true); + setError(null); try { const res = await submitTaskLink(taskId, url, projectId); if (res) setSubmission(res); return res; - } catch { setError("Gagal submit link."); return null; } - finally { setLoading(false); } + } catch { + setError("Gagal submit link."); + return null; + } finally { + setLoading(false); + } }; - const updateFile = async (submissionId: number, file: File) => { - setLoading(true); setError(null); + const updateFile = async (submissionId: number, files: File[]) => { + setLoading(true); + setError(null); try { - const res = await updateSubmissionFile(submissionId, file); + const res = await updateSubmissionFile(submissionId, files); if (res) setSubmission(res); return res; - } catch { setError("Gagal update submission."); return null; } - finally { setLoading(false); } + } catch { + setError("Gagal update submission."); + return null; + } finally { + setLoading(false); + } }; const updateLink = async (submissionId: number, url: string) => { - setLoading(true); setError(null); + setLoading(true); + setError(null); try { const res = await updateSubmissionLink(submissionId, url); if (res) setSubmission(res); return res; - } catch { setError("Gagal update submission."); return null; } - finally { setLoading(false); } + } catch { + setError("Gagal update submission."); + return null; + } finally { + setLoading(false); + } }; const remove = async (submissionId: number) => { - setLoading(true); setError(null); + setLoading(true); + setError(null); try { await deleteSubmission(submissionId); setSubmission(null); return true; - } catch { setError("Gagal hapus submission."); return false; } - finally { setLoading(false); } + } catch { + 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 @@ -233,10 +288,16 @@ export const useDeadlines = () => { const isIntern = user?.role === "intern"; - const { tasks: internTasks, loading: internLoading, error: internError } = useMyTasks(); - const { tasks: mentorTasks, loading: mentorLoading, error: mentorError } = useAllMentorTasks( - !isIntern && user !== null - ); + const { + tasks: internTasks, + loading: internLoading, + error: internError, + } = useMyTasks(); + const { + tasks: mentorTasks, + loading: mentorLoading, + error: mentorError, + } = useAllMentorTasks(!isIntern && user !== null); const deadlines: Deadline[] = useMemo(() => { if (!user) return []; @@ -256,4 +317,4 @@ export const useDeadlines = () => { const error = userError || (isIntern ? internError : mentorError); return { deadlines, loading, error, isIntern }; -}; \ No newline at end of file +}; diff --git a/src/services/TaskService.ts b/src/services/TaskService.ts index e1a87ab..f78ffa8 100644 --- a/src/services/TaskService.ts +++ b/src/services/TaskService.ts @@ -91,11 +91,11 @@ export const getAllMentorTasks = async (): Promise => { export const submitTaskFile = async ( taskId: string | number, - file: File, + files: File[], projectId?: string | number ): Promise => { 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, { params: projectId ? { project: projectId } : undefined, }); @@ -117,10 +117,10 @@ export const submitTaskLink = async ( export const updateSubmissionFile = async ( submissionId: number, - file: File + files: File[] ): Promise => { 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); return res.data.data; }; diff --git a/src/types/task.types.ts b/src/types/task.types.ts index 67437f7..f5e8704 100644 --- a/src/types/task.types.ts +++ b/src/types/task.types.ts @@ -20,6 +20,15 @@ export interface TaskSubmissionData { id_task: number; id_user: number; 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; submitted_at: string; updated_at: string;