feat: enhance certificate verification and submission handling, add custom toast notifications
This commit is contained in:
+10
@@ -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,9 +55,16 @@ 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 (
|
||||||
|
<>
|
||||||
|
<Toaster
|
||||||
|
position="bottom-right"
|
||||||
|
reverseOrder={true}
|
||||||
|
/>
|
||||||
|
|
||||||
<Router>
|
<Router>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Landing />} />
|
<Route path="/" element={<Landing />} />
|
||||||
@@ -68,6 +76,7 @@ function App() {
|
|||||||
<Route path="/our-developer" element={<OurDeveloper />} />
|
<Route path="/our-developer" element={<OurDeveloper />} />
|
||||||
<Route path="/details-product/:id" element={<DetailsProduct />} />
|
<Route path="/details-product/:id" element={<DetailsProduct />} />
|
||||||
<Route path="/login-admin" element={<LoginAdmin />} />
|
<Route path="/login-admin" element={<LoginAdmin />} />
|
||||||
|
<Route path="/verify-certificate/:uuid" element={<VerificationCert />} />
|
||||||
|
|
||||||
<Route element={<PrivateRoute />}>
|
<Route element={<PrivateRoute />}>
|
||||||
<Route path="/add-product-admin" element={<AddProduct />} />
|
<Route path="/add-product-admin" element={<AddProduct />} />
|
||||||
@@ -135,6 +144,7 @@ function App() {
|
|||||||
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
|
||||||
|
await customToast.promise(
|
||||||
|
save({
|
||||||
full_name: formData.fullName,
|
full_name: formData.fullName,
|
||||||
email: formData.email,
|
email: formData.email,
|
||||||
professional_bio: formData.bio,
|
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" ? (
|
||||||
|
files?.length > 0 ? (
|
||||||
|
files?.map((f) => (
|
||||||
<a
|
<a
|
||||||
href={resolveImageUrl(filePath ?? "")}
|
key={f.id}
|
||||||
|
href={resolveImageUrl(f.file_path)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-2 text-sm text-blue-600 hover:underline"
|
className="flex items-center gap-2 text-sm text-blue-600 hover:underline"
|
||||||
>
|
>
|
||||||
<FileText className="w-4 h-4 stroke-[2.5]" />
|
<FileText className="w-4 h-4 stroke-[2.5]" />
|
||||||
<span>{resolveImageUrl(filePath ?? "").split("/").pop()}</span>
|
<span>{f.original_name}</span>
|
||||||
</a>
|
</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">
|
||||||
|
{submission.files.map((f) => (
|
||||||
|
<p key={f.id} className="text-sm font-medium text-red-700">
|
||||||
|
{f.original_name}
|
||||||
</p>
|
</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);
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
+93
-32
@@ -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 [];
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user