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:
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user