-
+ {/* Kondisi eksklusif: hanya satu state tampil dalam satu waktu */}
+ {loading ? (
+ renderSkeleton()
+ ) : error ? (
+
{error}
+ ) : isEmpty ? (
+
+
Belum ada tugas yang dapat dikerjakan.
) : (
- !loading && (
-
-
-
-
- {tasks.map((task) => (
-
- ))}
-
+
+
- )
+
+
+ {tasks.map((task) => (
+
+ ))}
+
+
)}
);
-}
+}
\ No newline at end of file
diff --git a/src/Pages/internify/mentor/TaskTab.tsx b/src/Pages/internify/mentor/ProjectTaskTab.tsx
similarity index 96%
rename from src/Pages/internify/mentor/TaskTab.tsx
rename to src/Pages/internify/mentor/ProjectTaskTab.tsx
index 24cc0ee..fa75882 100644
--- a/src/Pages/internify/mentor/TaskTab.tsx
+++ b/src/Pages/internify/mentor/ProjectTaskTab.tsx
@@ -30,13 +30,13 @@ export default function TaskTab({ project }: { project: ProjectDetail }) {
}
};
- // if (loading) return (
- //
- // {[...Array(3)].map((_, i) => (
- //
- // ))}
- //
- // );
+ if (loading) return (
+
+ {[...Array(3)].map((_, i) => (
+
+ ))}
+
+ );
if (error) return
{error}
;
diff --git a/src/Pages/internify/mentor/ProjectsPage.tsx b/src/Pages/internify/mentor/ProjectsPage.tsx
index 3bfea1b..59e0953 100644
--- a/src/Pages/internify/mentor/ProjectsPage.tsx
+++ b/src/Pages/internify/mentor/ProjectsPage.tsx
@@ -3,20 +3,73 @@ import { useParams } from 'react-router-dom';
import { useProjectDetail } from '../../../hooks/useProjects';
import MentorForumTab from './ProjectForumTab';
import InternsTab from './ProjectsInternTab';
-import TaskTab from './TaskTab';
-import { Loader2 } from 'lucide-react';
+import TaskTab from './ProjectTaskTab';
+
+function ProjectDetailSkeleton() {
+ return (
+
+ {/* Tab bar skeleton — tinggi & spacing disamakan dengan tombol tab asli */}
+
+
+ {/* Konten tab skeleton (default tab = forum) */}
+
+ {/* Composer / input bar */}
+
+
+ {/* List card (forum post / participant row) */}
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ {/* Header: avatar + nama + timestamp */}
+
+
+ {/* Body teks — baris terakhir sengaja lebih pendek biar natural */}
+
+
+ {/* Footer action */}
+
+
+ ))}
+
+
+ );
+}
export default function ProjectDetailPage() {
const { slug } = useParams<{ slug: string }>();
const [activeTab, setActiveTab] = useState<'forum' | 'participants' | 'Task'>('forum');
const { project, loading, error } = useProjectDetail(slug ?? "");
- if (loading) return (
-
-
- Memuat project...
-
- );
+ if (loading) return
if (error) return
{error}
;
diff --git a/src/Pages/internify/mentor/components/AddInternsToProjects.tsx b/src/Pages/internify/mentor/components/AddInternsToProjects.tsx
index 28d7458..c84a145 100644
--- a/src/Pages/internify/mentor/components/AddInternsToProjects.tsx
+++ b/src/Pages/internify/mentor/components/AddInternsToProjects.tsx
@@ -27,15 +27,14 @@ export default function AddInternsModal({ isOpen, onClose, userId }: AddInternsM
if (isOpen) {
dialog.showModal();
- // document.body.style.overflow = "hidden";
+ document.body.style.overflow = "hidden";
} else {
dialog.close();
- // document.body.style.overflow = "unset";
+ document.body.style.overflow = "unset";
}
const handleCancel = (e: Event) => {
- e.preventDefault();
- onClose();
+ e.preventDefault()
};
dialog.addEventListener("cancel", handleCancel);
diff --git a/src/Pages/internify/mentor/components/ArchiveProjectDialog.tsx b/src/Pages/internify/mentor/components/ArchiveProjectDialog.tsx
index 8988f4b..8e449d5 100644
--- a/src/Pages/internify/mentor/components/ArchiveProjectDialog.tsx
+++ b/src/Pages/internify/mentor/components/ArchiveProjectDialog.tsx
@@ -32,7 +32,6 @@ export default function ArchiveProjectDialog({
const handleCancel = (e: Event) => {
e.preventDefault();
- onClose();
};
dialog.addEventListener("cancel", handleCancel);
diff --git a/src/Pages/internify/mentor/components/CertificateProjectCard.tsx b/src/Pages/internify/mentor/components/CertificateProjectCard.tsx
index cbc5870..c9c585f 100644
--- a/src/Pages/internify/mentor/components/CertificateProjectCard.tsx
+++ b/src/Pages/internify/mentor/components/CertificateProjectCard.tsx
@@ -1,10 +1,8 @@
import { CalendarDays } from "lucide-react";
import type { Project } from "../../../../types/project.types";
-// ─── StatusBadge ─────────────────────────────────────────────────────────────
function StatusBadge({ status }: { status: string }) {
- const isCompleted = status === "active";
- return (
+ const isCompleted = status === "active"; return (
{
e.preventDefault();
- onClose();
};
dialog.addEventListener("cancel", handleCancel);
diff --git a/src/Pages/internify/mentor/components/CreateProjectDialog.tsx b/src/Pages/internify/mentor/components/CreateProjectDialog.tsx
index 12e1cfe..3b47f9a 100644
--- a/src/Pages/internify/mentor/components/CreateProjectDialog.tsx
+++ b/src/Pages/internify/mentor/components/CreateProjectDialog.tsx
@@ -44,7 +44,6 @@ export default function CreateProjectModal({ isOpen, onClose, onSuccess }: Creat
const handleCancel = (e: Event) => {
e.preventDefault();
- onClose();
};
dialog.addEventListener("cancel", handleCancel);
diff --git a/src/Pages/internify/mentor/components/CreateTaskDialog.tsx b/src/Pages/internify/mentor/components/CreateTaskDialog.tsx
index 2028962..5cc0a16 100644
--- a/src/Pages/internify/mentor/components/CreateTaskDialog.tsx
+++ b/src/Pages/internify/mentor/components/CreateTaskDialog.tsx
@@ -36,7 +36,6 @@ export default function CreateTaskModal({ isOpen, onClose, projectId, onSuccess
const handleCancel = (e: Event) => {
e.preventDefault();
- onClose();
};
dialog.addEventListener("cancel", handleCancel);
diff --git a/src/Pages/internify/mentor/components/EditSubmissionTask.tsx b/src/Pages/internify/mentor/components/EditSubmissionTask.tsx
index ebd10aa..3ad8b95 100644
--- a/src/Pages/internify/mentor/components/EditSubmissionTask.tsx
+++ b/src/Pages/internify/mentor/components/EditSubmissionTask.tsx
@@ -54,7 +54,6 @@ export default function EditSubmissionModal({
const handleCancel = (e: Event) => {
e.preventDefault();
- onClose();
};
dialog.addEventListener("cancel", handleCancel);
diff --git a/src/Pages/internify/mentor/components/EditTaskDialog.tsx b/src/Pages/internify/mentor/components/EditTaskDialog.tsx
index e1ee44e..1106545 100644
--- a/src/Pages/internify/mentor/components/EditTaskDialog.tsx
+++ b/src/Pages/internify/mentor/components/EditTaskDialog.tsx
@@ -34,11 +34,22 @@ export default function EditTaskModal({ isOpen, onClose, task, onSuccess }: Edit
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
- if (isOpen) { dialog.showModal(); document.body.style.overflow = "hidden"; }
- else { dialog.close(); document.body.style.overflow = "unset"; }
- const handleCancel = (e: Event) => { e.preventDefault(); onClose(); };
+ if (isOpen) {
+ dialog.showModal();
+ document.body.style.overflow = "hidden";
+ } else {
+ dialog.close();
+ document.body.style.overflow = "unset";
+ }
+ const handleCancel = (e: Event) => {
+ e.preventDefault();
+ };
+
dialog.addEventListener("cancel", handleCancel);
- return () => { dialog.removeEventListener("cancel", handleCancel); document.body.style.overflow = "unset"; };
+ return () => {
+ dialog.removeEventListener("cancel", handleCancel);
+ document.body.style.overflow = "unset";
+ };
}, [isOpen, onClose]);
const handleSubmit = async (e: React.FormEvent) => {
diff --git a/src/Pages/internify/mentor/components/EligibleInternTable.tsx b/src/Pages/internify/mentor/components/EligibleInternTable.tsx
index 6c39373..0c13a13 100644
--- a/src/Pages/internify/mentor/components/EligibleInternTable.tsx
+++ b/src/Pages/internify/mentor/components/EligibleInternTable.tsx
@@ -31,14 +31,12 @@ interface EligibleInternTableProps {
const PER_PAGE = 5;
-const BULAN_ID = [
- "Januari", "Februari", "Maret", "April", "Mei", "Juni",
- "Juli", "Agustus", "September", "Oktober", "November", "Desember",
-];
-
-function formatDateIndo(dateStr: string): string {
- const [year, month, day] = dateStr.split("-").map(Number);
- return `${day} ${BULAN_ID[month - 1]} ${year}`;
+function formatDateIndo(date: string): string {
+ return new Intl.DateTimeFormat("id-ID", {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ }).format(new Date(date));
}
function formatDateRange(startDate: string, endDate: string): string {
@@ -62,7 +60,6 @@ export default function EligibleInternTable({
loading,
eligibleCount,
project
- // Durasi tanggal Proyek
}: EligibleInternTableProps) {
const navigate = useNavigate();
const { generate: SubmitCertificates } = useGenerateCertificates();
@@ -95,6 +92,8 @@ export default function EligibleInternTable({
id: project.id,
project_name: project.project_name,
description: project.description,
+ start_date: project.start_date,
+ end_date: project.end_date
},
};
previewCertificate(mockCert, project.certificate_template);
@@ -144,14 +143,13 @@ export default function EligibleInternTable({
await Promise.all(
result.map(async (cert) => {
- 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,
- duration
+ duration,
+ cert.uuid,
);
zip.file(`Sertifikat - ${cert.user.full_name}.png`, blob);
})
@@ -176,9 +174,11 @@ export default function EligibleInternTable({
if (loading) {
return (
-
-
- Memuat data intern...
+
+
+
+ Memuat data intern...
+
);
}
diff --git a/src/Pages/internify/mentor/components/StatusSubmissionTable.tsx b/src/Pages/internify/mentor/components/StatusSubmissionTable.tsx
index c8fd0c8..27cf5cf 100644
--- a/src/Pages/internify/mentor/components/StatusSubmissionTable.tsx
+++ b/src/Pages/internify/mentor/components/StatusSubmissionTable.tsx
@@ -11,7 +11,7 @@ export function StatusBadge({ status }: { status: DisplayStatus }) {
return (
- {status === "done" ? "Done" : "Submitted for grading"}
+ {status === "done" ? "Done" : "Submitted"}
);
diff --git a/src/Pages/utils/Certificates.ts b/src/Pages/utils/Certificates.ts
index 8a1e262..7106637 100644
--- a/src/Pages/utils/Certificates.ts
+++ b/src/Pages/utils/Certificates.ts
@@ -3,12 +3,22 @@ import JSZip from "jszip";
import type { Certificate } from "../../types/certificate.types";
import { generateCertificate, resolveImageUrl } from "./SertificateGenerator";
-// ── 1. Preview di tab baru ────────────────────────────────────────────────────
+function formatDateIndo(date: string): string {
+ return new Intl.DateTimeFormat("id-ID", {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ }).format(new Date(date));
+}
+
+function formatDateRange(startDate: string, endDate: string): string {
+ return `${formatDateIndo(startDate)} - ${formatDateIndo(endDate)}`;
+}
+// 1. Preview di tab baru
export const previewCertificate = async (
cert: Certificate,
templateUrl: string,
): Promise
=> {
- // Buka tab DULU sebelum await — supaya tidak diblokir popup blocker
const tab = window.open("", "_blank");
const blob = await generateCertificate(
@@ -16,6 +26,7 @@ export const previewCertificate = async (
cert.user.full_name,
cert.project.project_name,
cert.certificate_no,
+ formatDateRange(cert.project.start_date, cert.project.end_date),
cert.uuid,
);
const url = URL.createObjectURL(blob);
@@ -79,6 +90,7 @@ export const downloadCertificatePdf = async (
cert.user.full_name,
cert.project.project_name,
cert.certificate_no,
+ formatDateRange(cert.project.start_date, cert.project.end_date),
cert.uuid,
);
@@ -118,6 +130,7 @@ export const downloadCertificateImage = async (
cert.user.full_name,
cert.project.project_name,
cert.certificate_no,
+ formatDateRange(cert.project.start_date, cert.project.end_date),
cert.uuid,
);
@@ -180,6 +193,7 @@ export const downloadAllCertificatesZip = async (
cert.user.full_name,
cert.project.project_name,
cert.certificate_no,
+ formatDateRange(cert.project.start_date, cert.project.end_date),
cert.uuid,
);
zip.file(`Sertifikat - ${cert.user.full_name}.png`, blob);
diff --git a/src/Pages/utils/SertificateGenerator.ts b/src/Pages/utils/SertificateGenerator.ts
index 0d3bfc1..937fef7 100644
--- a/src/Pages/utils/SertificateGenerator.ts
+++ b/src/Pages/utils/SertificateGenerator.ts
@@ -15,7 +15,7 @@ export function resolveImageUrl(path: string): string {
export function loadImage(src: string): Promise {
return new Promise((resolve, reject) => {
const img = new Image();
- img.crossOrigin = "anonymous";
+ if (!src.startsWith("data:")) img.crossOrigin = "anonymous";
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Gagal load gambar: ${src}`));
img.src = resolveImageUrl(src);
@@ -30,6 +30,17 @@ export async function ensureFontsLoaded(): Promise {
await document.fonts.ready;
}
+
+const LAYOUT = {
+ internName: { x: 0.57, y: 0.48, size: 140, font: "Great Vibes", color: "#800000" },
+ internNameSmall: { x: 0.82, y: 0.889, size: 33, font: "Great Vibes", color: "#800000" },
+ projectName: { x: 0.815, y: 0.598, size: 46, font: "Grenze", color: "#800000" },
+ duration: { x: 0.692, y: 0.645, size: 48, font: "Grenze", color: "#090909" },
+ certNo: { x: 0.6, y: 0.305, size: 50, font: "Grenze", color: "#090909" },
+ certNoSmall: { x: 0.825, y: 0.91, size: 22, font: "Grenze", color: "#800000" },
+ qr: { x: 0.77, y: 0.715, size: 0.103 },
+};
+
export async function generateCertificate(
templateUrl: string,
internName: string,
@@ -49,50 +60,30 @@ export async function generateCertificate(
ctx.textAlign = "center";
ctx.textBaseline = "middle";
- // Nama intern — besar (Great Vibes)
- ctx.font = `105px "Great Vibes", cursive`;
- ctx.fillStyle = "#800000";
- ctx.fillText(internName, canvas.width * 0.57, canvas.height * 0.48);
+ const text = (
+ cfg: { x: number; y: number; size: number; font: string; color: string },
+ value: string,
+ ) => {
+ ctx.font = `400 ${cfg.size}px "${cfg.font}", serif`;
+ ctx.fillStyle = cfg.color;
+ ctx.fillText(value, canvas.width * cfg.x, canvas.height * cfg.y);
+ };
- // Nama intern — versi kecil di pojok bawah
- ctx.font = `30px "Great Vibes", cursive`;
- ctx.fillStyle = "#800000";
- ctx.fillText(internName, canvas.width * 0.825, canvas.height * 0.895);
-
- // Nama project (Grenze)
- ctx.font = `400 54px "Grenze", serif`;
- ctx.fillStyle = "#090909";
- ctx.fillText(projectName, canvas.width * 0.57, canvas.height * 0.615);
-
- // Certificate no — besar (Grenze)
- ctx.font = `50px "Grenze", serif`;
- ctx.fillStyle = "#090909";
- ctx.fillText(certificateNo, canvas.width * 0.666, canvas.height * 0.305);
-
- // Certificate no — kecil di pojok bawah
- ctx.font = `20px "Grenze", serif`;
- ctx.fillStyle = "#800000";
- ctx.fillText(certificateNo, canvas.width * 0.825, canvas.height * 0.918);
-
- //Durasi Project — besar (Grenze)
- ctx.font = `400 48px "Grenze", serif`;
- ctx.fillStyle = "#090909";
- ctx.fillText(ProjectDuration, canvas.width * 0.68, canvas.height * 0.66);
+ text(LAYOUT.internName, internName);
+ text(LAYOUT.internNameSmall, internName);
+ text(LAYOUT.projectName, projectName);
+ text(LAYOUT.duration, ProjectDuration);
+ text(LAYOUT.certNo, certificateNo);
+ text(LAYOUT.certNoSmall, certificateNo);
// QR code — nanti akan diganti ke alamat domain
- const qrDataUrl = await QRCode.toDataURL('http://localhost:5173/verify-certificate/' + verifyUrl, {
- width: 200,
- margin: 1,
- });
- const qrImg = await loadImage(qrDataUrl);
- const qrSize = canvas.width;
- ctx.drawImage(
- qrImg,
- canvas.width * 0.855 - qrSize,
- canvas.height * 0.85 - qrSize,
- qrSize,
- qrSize,
+ const qrSize = Math.round(canvas.width * LAYOUT.qr.size);
+ const qrDataUrl = await QRCode.toDataURL(
+ `${window.location.origin}/verify-certificate/${verifyUrl}`,
+ { width: qrSize, margin: 1 },
);
+ const qrImg = await loadImage(qrDataUrl);
+ ctx.drawImage(qrImg, canvas.width * LAYOUT.qr.x, canvas.height * LAYOUT.qr.y, qrSize, qrSize);
return new Promise((resolve, reject) => {
canvas.toBlob(
diff --git a/src/Pages/utils/testlayout.html b/src/Pages/utils/testlayout.html
index 7040fb3..161a52e 100644
--- a/src/Pages/utils/testlayout.html
+++ b/src/Pages/utils/testlayout.html
@@ -1,75 +1,92 @@
+
+
\ No newline at end of file
diff --git a/src/assets/certificate.png b/src/assets/certificate.png
deleted file mode 100644
index 4c7ef1c..0000000
Binary files a/src/assets/certificate.png and /dev/null differ
diff --git a/src/assets/template.png b/src/assets/template.png
new file mode 100644
index 0000000..fa8ea0f
Binary files /dev/null and b/src/assets/template.png differ
diff --git a/src/types/certificate.types.ts b/src/types/certificate.types.ts
index 5c0a02d..c26052c 100644
--- a/src/types/certificate.types.ts
+++ b/src/types/certificate.types.ts
@@ -8,6 +8,9 @@ export interface CertificateProject {
id: number;
project_name: string;
description: string;
+ start_date: string
+ end_date: string
+
}
export interface Certificate {
diff --git a/src/types/project.types.ts b/src/types/project.types.ts
index 1712245..21684ea 100644
--- a/src/types/project.types.ts
+++ b/src/types/project.types.ts
@@ -87,8 +87,8 @@ export interface CreateProjectPayload {
project_icon: string;
project_name: string;
description: string;
- start_date: string; // "YYYY-MM-DD"
- end_date: string; // "YYYY-MM-DD"
+ start_date: string;
+ end_date: string;
member_emails?: string[];
}