feat: enhance certificate verification and submission handling, add custom toast notifications
This commit is contained in:
@@ -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() {
|
||||
</p>
|
||||
</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 */}
|
||||
<div className="flex justify-end pt-2">
|
||||
<button
|
||||
|
||||
@@ -21,8 +21,15 @@ export default function CertificateAvailable({ project, certificate, templateUrl
|
||||
|
||||
useEffect(() => {
|
||||
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) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
setPreviewUrl(url);
|
||||
@@ -104,8 +111,8 @@ export default function CertificateAvailable({ project, certificate, templateUrl
|
||||
key={row.label}
|
||||
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-gray-800 font-medium">{row.value}</span>
|
||||
<span className="text-font">{row.label}</span>
|
||||
<span className="text-font-shade font-bold">{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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 */}
|
||||
<div
|
||||
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' : ''}`}
|
||||
/>
|
||||
<div className={`text-[11px] font-semibold ${isToday ? 'text-[#C0392B]' : 'text-[#888]'}`}>
|
||||
|
||||
@@ -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 */}
|
||||
<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"}
|
||||
</div>
|
||||
<div className="px-4 py-3 flex flex-col gap-2">
|
||||
{type === "file" ? (
|
||||
<a
|
||||
href={resolveImageUrl(filePath ?? "")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
<FileText className="w-4 h-4 stroke-[2.5]" />
|
||||
<span>{resolveImageUrl(filePath ?? "").split("/").pop()}</span>
|
||||
</a>
|
||||
files?.length > 0 ? (
|
||||
files?.map((f) => (
|
||||
<a
|
||||
key={f.id}
|
||||
href={resolveImageUrl(f.file_path)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
<FileText className="w-4 h-4 stroke-[2.5]" />
|
||||
<span>{f.original_name}</span>
|
||||
</a>
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-gray-500">No file submitted</span>
|
||||
)
|
||||
) : (
|
||||
<a href={link} target="_blank" rel="noopener noreferrer"
|
||||
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 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 (
|
||||
<SubmitStatusTable
|
||||
type="file"
|
||||
filePath={submission.file_path}
|
||||
files={submission.files}
|
||||
submittedAt={new Date(submission.submitted_at)}
|
||||
deadline={deadline}
|
||||
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 (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
@@ -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"
|
||||
/>
|
||||
<div
|
||||
@@ -104,7 +103,6 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
|
||||
}`}
|
||||
>
|
||||
{files.length > 0 ? (
|
||||
// File baru sudah dipilih — tampilkan itu (menggantikan file lama)
|
||||
<div className="space-y-2">
|
||||
{files.map((file, index) => (
|
||||
<div key={index} className="flex items-center justify-between text-sm font-medium text-gray-800" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -118,13 +116,17 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : isEditing && currentFileName ? (
|
||||
// Mode edit, belum pilih file baru — tampilkan file submission lama sebagai referensi
|
||||
) : isEditing && submission?.files && submission.files.length > 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full space-y-2 text-center py-6">
|
||||
<FileText className="w-6 h-6 text-gray-400" />
|
||||
<p className="text-sm font-semibold text-gray-700">
|
||||
File saat ini: <span className="text-red-700">{currentFileName}</span>
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-gray-700">File saat ini:</p>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-400 font-medium pointer-events-none">
|
||||
Klik atau drag & drop untuk mengganti file
|
||||
</p>
|
||||
|
||||
@@ -19,24 +19,30 @@ const TaskTimeline = () => {
|
||||
|
||||
return (
|
||||
<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="absolute left-1 top-2 bottom-2 w-px bg-gray-300" />
|
||||
<ul className="flex flex-col gap-5">
|
||||
{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 (
|
||||
<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">
|
||||
<span
|
||||
className={`block w-2 h-2 rounded-full z-10 relative ${
|
||||
overdue ? "bg-primary" : "bg-gray-500"
|
||||
}`}
|
||||
className={`block w-3 h-3 rounded-full z-10 relative border-2 right-0.5 border-white ${overdue ? "bg-primary" : "bg-gray-500"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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.toLocaleTimeString("id-ID", {
|
||||
hour: "2-digit",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user