feat: enhance certificate management and project task features

- Updated CertificateForm to improve loading state UI with skeletons.
- Enhanced CertificateResult with better loading indicators and structure.
- Refactored ProjectForumTab to include a skeleton loader and improved error handling.
- Introduced ProjectTaskTab for managing project tasks with modals for creating and editing tasks.
- Improved ProjectsPage with a skeleton loader during project data fetching.
- Refined AddInternsToProjects, ArchiveProjectDialog, CompleteProjectDialog, CreateProjectDialog, CreateTaskDialog, and EditTaskDialog components to handle modal states more effectively.
- Updated EligibleInternTable to enhance loading states and formatting of dates.
- Modified StatusSubmissionTable to simplify status display.
- Improved certificate generation utility functions for better date formatting and layout management.
- Removed deprecated certificate assets and added a new template for certificates.
- Updated types for certificates and projects to include start and end dates.
This commit is contained in:
Kemas M. Aryadary Rasyad
2026-07-15 11:42:22 +07:00
parent c2aacb505f
commit 5689898f33
36 changed files with 390 additions and 240 deletions
+16 -2
View File
@@ -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<void> => {
// 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);
+32 -41
View File
@@ -15,7 +15,7 @@ export function resolveImageUrl(path: string): string {
export function loadImage(src: string): Promise<HTMLImageElement> {
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<void> {
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(
+56 -39
View File
@@ -1,75 +1,92 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Great+Vibes&family=Grenze:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">
</head>
<body>
<canvas id="preview" style="max-width: 100%;"></canvas>
<script>
// Helper: bungkus loading image jadi Promise, biar bisa di-await
const TEMPLATE_PATH = "../../assets/template.png";
// ====== KOORDINAT (rasio terhadap ukuran template) ======
// fillText -> titik TENGAH teks (textAlign=center, textBaseline=middle)
// drawImage -> pojok KIRI-ATAS gambar
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: "#090909" },
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 },
};
// ====== DATA DUMMY ======
const DATA = {
internName: "Jonathan Kristina",
projectName: "Internify Proyek Humic",
duration: "20 April 2026 - 20 Juni 2026",
certNo: "CERT/XXXXXX/1",
verifyUrl: "dummy-uuid-preview",
};
function loadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => resolve(img);
img.onerror = reject;
img.onerror = () => reject(new Error("Gagal load gambar: " + src));
img.src = src;
});
}
async function draw() {
await document.fonts.ready;
// Load template sertifikat & QR dummy secara paralel
const [templateImg, qrImg] = await Promise.all([
loadImage("http://localhost:9000/uploads/1783080498359-420570422.png"),
loadImage("https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=dummy-uuid-preview")
async function ensureFontsLoaded() {
await Promise.all([
document.fonts.load('400 105px "Great Vibes"'),
document.fonts.load('400 54px "Grenze"'),
]);
await document.fonts.ready;
}
async function draw() {
await ensureFontsLoaded();
const templateImg = await loadImage(TEMPLATE_PATH);
const canvas = document.getElementById("preview");
canvas.width = templateImg.naturalWidth;
canvas.height = templateImg.naturalHeight;
const ctx = canvas.getContext("2d");
// 1. Gambar template dulu (paling belakang)
ctx.drawImage(templateImg, 0, 0);
// 2. Nama intern — Great Vibes
ctx.font = `105px "Great Vibes", cursive`;
ctx.fillStyle = "#800000";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("Jonathan Kristina", canvas.width * 0.57, canvas.height * 0.48);
ctx.font = `30px "Great Vibes", cursive`;
ctx.fillText("Jonathan Kristina", canvas.width * 0.825, canvas.height * 0.895);
const text = (cfg, value) => {
ctx.font = `400 ${cfg.size}px "${cfg.font}", serif`;
ctx.fillStyle = cfg.color;
ctx.fillText(value, canvas.width * cfg.x, canvas.height * cfg.y);
};
// 3. Nama project — Grenze
ctx.font = `400 54px "Grenze", serif`;
ctx.fillStyle = "#090909";
ctx.fillText("Internify Platform Dev", canvas.width * 0.57, canvas.height * 0.615);
text(LAYOUT.internName, DATA.internName);
text(LAYOUT.internNameSmall, DATA.internName);
text(LAYOUT.projectName, DATA.projectName);
text(LAYOUT.duration, DATA.duration);
text(LAYOUT.certNo, DATA.certNo);
text(LAYOUT.certNoSmall, DATA.certNo);
ctx.font = `400 48px "Grenze", serif`;
ctx.fillStyle = "#090909";
ctx.fillText("20 April - 20 Juni 2025", canvas.width * 0.68, canvas.height * 0.66);
// 4. Certificate no
ctx.font = `50px Grenze`;
ctx.fillText("CERT/20260609/PRJ2/USR5", canvas.width * 0.665, canvas.height * 0.305);
ctx.font = `20px Grenze`;
ctx.fillStyle = "#800000";
ctx.fillText("CERT/20260609/PRJ2/USR5", canvas.width * 0.825, canvas.height * 0.918);
const qrSize = canvas.width * 0.103;
const qrX = canvas.width * 0.855;
const qrY = canvas.height * 0.85;
ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize);
// QR
const qrSize = Math.round(canvas.width * LAYOUT.qr.size);
ctx.fillStyle = "#000";
ctx.fillRect(canvas.width * LAYOUT.qr.x, canvas.height * LAYOUT.qr.y, qrSize, qrSize);
}
draw();
draw().catch((err) => {
document.body.insertAdjacentHTML("beforeend", `<pre style="color:red">${err.message}</pre>`);
});
</script>
</body>
</html>