feat: implement certificate history, intern submission views, and task management hooks
This commit is contained in:
@@ -58,12 +58,6 @@ const Landing = () => {
|
|||||||
return feedbacks[displayIndex];
|
return feedbacks[displayIndex];
|
||||||
}, [feedbacks, displayIndex]);
|
}, [feedbacks, displayIndex]);
|
||||||
|
|
||||||
// Gambar statis: selalu pakai foto dari feedback pertama
|
|
||||||
const staticImgUrl = useMemo(() => {
|
|
||||||
if (feedbacks.length === 0) return "";
|
|
||||||
return getFeedbackImageUrl(feedbacks[0].image_path);
|
|
||||||
}, [feedbacks]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Fetch internships
|
// Fetch internships
|
||||||
fetch(`${import.meta.env.VITE_API_BASE_URL}/lowongan-magang-api/get`)
|
fetch(`${import.meta.env.VITE_API_BASE_URL}/lowongan-magang-api/get`)
|
||||||
|
|||||||
@@ -32,7 +32,12 @@ export default function InternCertificateDetail() {
|
|||||||
};
|
};
|
||||||
}, [projectDetail, projectFromList]);
|
}, [projectDetail, projectFromList]);
|
||||||
|
|
||||||
const allTasksDone = !projectLoading && !!project && project.task_done >= project.total_tasks;
|
|
||||||
|
const allTasksDone =
|
||||||
|
!projectLoading &&
|
||||||
|
!!project &&
|
||||||
|
(project.total_tasks ?? 0) > 0 &&
|
||||||
|
project.task_done >= project.total_tasks;
|
||||||
|
|
||||||
const progress =
|
const progress =
|
||||||
project && project.total_tasks > 0
|
project && project.total_tasks > 0
|
||||||
@@ -50,10 +55,8 @@ export default function InternCertificateDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row gap-6 items-stretch animate-pulse">
|
<div className="flex flex-col lg:flex-row gap-6 items-stretch animate-pulse">
|
||||||
{/* Kolom kiri: certificate box */}
|
|
||||||
<div className="w-full lg:w-5/8 h-[420px] bg-gray-200 rounded-xl" />
|
<div className="w-full lg:w-5/8 h-[420px] bg-gray-200 rounded-xl" />
|
||||||
|
|
||||||
{/* Kolom kanan: history box */}
|
|
||||||
<div className="w-full lg:w-3/8 flex-shrink-0 h-[420px] bg-gray-200 rounded-xl" />
|
<div className="w-full lg:w-3/8 flex-shrink-0 h-[420px] bg-gray-200 rounded-xl" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,9 +71,9 @@ export default function InternCertificateDetail() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col lg:flex-row gap-6 items-stretch">
|
<div className="flex flex-col lg:flex-row gap-6 items-stretch">
|
||||||
<div className="w-full lg:w-5/8 flex flex-col">
|
<div className="w-full lg:w-5/8 flex flex-col">
|
||||||
{myCertificateForProject ? (
|
{myCertificateForProject && (project?.total_tasks ?? 0) > 0 ? (
|
||||||
<CertificateAvailable
|
<CertificateAvailable
|
||||||
project={project}
|
project={project!}
|
||||||
certificate={myCertificateForProject}
|
certificate={myCertificateForProject}
|
||||||
templateUrl={project?.certificate_template ?? ""}
|
templateUrl={project?.certificate_template ?? ""}
|
||||||
/>
|
/>
|
||||||
@@ -79,6 +82,7 @@ export default function InternCertificateDetail() {
|
|||||||
progress={progress}
|
progress={progress}
|
||||||
remainingTasks={remainingTasks}
|
remainingTasks={remainingTasks}
|
||||||
allTasksDone={allTasksDone}
|
allTasksDone={allTasksDone}
|
||||||
|
noTasks={(project?.total_tasks ?? 0) === 0}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export default function CertificateAvailable({ project, certificate, templateUrl
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Program Summary */}
|
{/* Program Summary */}
|
||||||
<div className="grid grid-cols-2 gap-5">
|
<div className="grid md:grid-cols-2 gap-5">
|
||||||
<div className="bg-box-primary rounded-2xl border border-box-border shadow-sm p-5">
|
<div className="bg-box-primary rounded-2xl border border-box-border shadow-sm p-5">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<h3 className="font-semibold text-gray-800 text-sm">Ringkasan Program</h3>
|
<h3 className="font-semibold text-gray-800 text-sm">Ringkasan Program</h3>
|
||||||
@@ -138,13 +138,13 @@ 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-font">{row.label}</span>
|
<span className="text-font text-start text-xs md:text-sm">{row.label}</span>
|
||||||
<span className="text-font-shade font-bold">{row.value}</span>
|
<span className="text-font-shade font-bold text-start text-xs md:text-sm">{row.value}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* End Program Summary */}
|
||||||
<div className="bg-box-primary rounded-2xl border border-box-border shadow-sm p-5">
|
<div className="bg-box-primary rounded-2xl border border-box-border shadow-sm p-5">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{/* Unduh Sertifikat */}
|
{/* Unduh Sertifikat */}
|
||||||
@@ -174,20 +174,19 @@ export default function CertificateAvailable({ project, certificate, templateUrl
|
|||||||
<button
|
<button
|
||||||
onClick={handleDownloadImage}
|
onClick={handleDownloadImage}
|
||||||
disabled={downloadingImg}
|
disabled={downloadingImg}
|
||||||
className="flex flex-col items-center justify-center gap-1.5 border border-card-outline hover:bg-gray-50 active:scale-95 transition-all text-red-700 font-medium py-3.5 rounded-xl text-sm">
|
className="flex flex-col items-center justify-center gap-1.5 border border-card-outline hover:bg-gray-50 active:scale-95 transition-all text-red-700 font-medium py-1 md:py-3.5 rounded-xl text-sm">
|
||||||
<Printer className="w-5 h-5" />
|
<Printer className="w-4 h-4 md:w-5 md:h-5" />
|
||||||
{downloadingImg ? "Memproses" : "Cetak"}
|
<span className="text-xs md:text-sm">{downloadingImg ? "Memproses" : "Cetak"}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
// onClick={handleCopyLink}
|
className="flex flex-col items-center justify-center gap-1.5 border border-card-outline hover:bg-gray-50 active:scale-95 transition-all text-red-700 font-medium py-1 md:py-3.5 rounded-xl text-sm"
|
||||||
className="flex flex-col items-center justify-center gap-1.5 border border-card-outline hover:bg-gray-50 active:scale-95 transition-all text-red-700 font-medium py-3.5 rounded-xl text-sm"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(`${window.location.origin}/verify-certificate/${certificate.uuid}`);
|
navigator.clipboard.writeText(`${window.location.origin}/verify-certificate/${certificate.uuid}`);
|
||||||
customToast.success("Link berhasil disalin!", "Link sudah disalin ke clipboard");
|
customToast.success("Link berhasil disalin!", "Link sudah disalin ke clipboard");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Link className="w-5 h-5" />
|
<Link className="w-4 h-4 md:w-5 md:h-5" />
|
||||||
Salin Link
|
<span className="text-xs md:text-sm">Salin Link</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,11 +35,12 @@ export default function SertificateHistory() {
|
|||||||
{displayed.map((item: Certificate) => (
|
{displayed.map((item: Certificate) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="flex flex-col gap-3 p-3 bg-card border border-card-outline shadow-sm rounded-xl hover:bg-gray-50 transition-colors cursor-pointer"
|
className="flex flex-col gap-3 p-3 bg-box-primary border border-box-border shadow-sm rounded-xl transition-colors"
|
||||||
>
|
>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-gray-400">
|
||||||
{new Date(item.issued_at).toLocaleDateString("id-ID", {
|
{new Date(item.issued_at).toLocaleDateString("id-ID", {
|
||||||
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
})}
|
})}
|
||||||
@@ -47,7 +48,7 @@ export default function SertificateHistory() {
|
|||||||
<p className="text-sm font-semibold text-gray-800 leading-tight">
|
<p className="text-sm font-semibold text-gray-800 leading-tight">
|
||||||
{item.project.project_name}
|
{item.project.project_name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500">{item.certificate_no}</p>
|
<p className="text-sm text-font-shade">{item.certificate_no}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { Clock, Lock } from "lucide-react";
|
import { Clock, Lock } from "lucide-react";
|
||||||
import { FaAward } from "react-icons/fa";
|
import { FaAward } from "react-icons/fa";
|
||||||
|
import { ListTodo } from "lucide-react";
|
||||||
|
|
||||||
interface CertificateNotAvailableProps {
|
interface CertificateNotAvailableProps {
|
||||||
progress: number;
|
progress: number;
|
||||||
remainingTasks: number;
|
remainingTasks: number;
|
||||||
allTasksDone?: boolean;
|
allTasksDone?: boolean;
|
||||||
|
noTasks?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CertificateNotAvailable({
|
export default function CertificateNotAvailable({
|
||||||
progress,
|
progress,
|
||||||
remainingTasks,
|
remainingTasks,
|
||||||
allTasksDone = false,
|
allTasksDone = false,
|
||||||
|
noTasks = false,
|
||||||
}: CertificateNotAvailableProps) {
|
}: CertificateNotAvailableProps) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-box-primary w-full h-full rounded-2xl border border-box-border shadow-sm px-24 py-12 flex flex-col items-center justify-center">
|
<div className="bg-box-primary w-full h-full rounded-2xl border border-box-border shadow-sm px-24 py-12 flex flex-col items-center justify-center">
|
||||||
@@ -18,7 +21,9 @@ export default function CertificateNotAvailable({
|
|||||||
<div className="relative mb-6">
|
<div className="relative mb-6">
|
||||||
<div className="w-34 h-34 border-2 border-dashed border-gray-600 rounded-xl flex justify-center items-center">
|
<div className="w-34 h-34 border-2 border-dashed border-gray-600 rounded-xl flex justify-center items-center">
|
||||||
<div className="w-26 h-26 flex items-center rounded-lg justify-center bg-gray-300">
|
<div className="w-26 h-26 flex items-center rounded-lg justify-center bg-gray-300">
|
||||||
{allTasksDone ? (
|
{noTasks ? (
|
||||||
|
<ListTodo className="w-12 h-12 text-gray-400" />
|
||||||
|
) : allTasksDone ? (
|
||||||
<FaAward className="w-12 h-12 text-green-400" />
|
<FaAward className="w-12 h-12 text-green-400" />
|
||||||
) : (
|
) : (
|
||||||
<Lock className="w-12 h-12 text-red-500" />
|
<Lock className="w-12 h-12 text-red-500" />
|
||||||
@@ -30,7 +35,15 @@ export default function CertificateNotAvailable({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{allTasksDone ? (
|
{noTasks ? (
|
||||||
|
<>
|
||||||
|
<h2 className="text-[28px] font-bold text-gray-900 mb-3">Sertifikat Belum Tersedia</h2>
|
||||||
|
<p className="text-gray-500 text-[15px] mb-6 text-center">
|
||||||
|
Proyek ini belum memiliki tugas yang ditetapkan.
|
||||||
|
Sertifikat akan tersedia setelah semua tugas proyek diselesaikan.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : allTasksDone ? (
|
||||||
<>
|
<>
|
||||||
<h2 className="text-[37px] font-bold text-gray-900 mb-3">Selamat!</h2>
|
<h2 className="text-[37px] font-bold text-gray-900 mb-3">Selamat!</h2>
|
||||||
<p className="text-gray-500 text-[17px] mb-6 text-center">
|
<p className="text-gray-500 text-[17px] mb-6 text-center">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useParams, useLocation } from "react-router-dom";
|
import { useParams, useLocation } from "react-router-dom";
|
||||||
import { Download, Clock, AlertCircle } from "lucide-react";
|
import { Download, AlertCircle, User2 } from "lucide-react";
|
||||||
import { useTaskSubmissions } from "../../../hooks/useTasks";
|
import { useTaskSubmissions } from "../../../hooks/useTasks";
|
||||||
import type { DisplayStatus } from "../../../types/task.types";
|
import type { DisplayStatus } from "../../../types/task.types";
|
||||||
import { StatusTable } from "./components/StatusSubmissionTable";
|
import { StatusTable } from "./components/StatusSubmissionTable";
|
||||||
@@ -50,8 +50,8 @@ export default function ViewInternSubmission() {
|
|||||||
{task.title}
|
{task.title}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-gray-500 flex items-center gap-1.5">
|
<p className="text-sm text-gray-500 flex items-center gap-1.5">
|
||||||
<Clock className="w-3.5 h-3.5" />
|
<User2 className="w-3.5 h-3.5" />
|
||||||
{internSubmission.full_name} — {internSubmission.email}
|
{internSubmission.full_name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ export default function EditSubmissionModal({
|
|||||||
}: EditTaskModalProps) {
|
}: EditTaskModalProps) {
|
||||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
const { update, loading, error } = useUpdateTask();
|
const { update, loading, error } = useUpdateTask();
|
||||||
|
|
||||||
// Form States — diinisialisasi dari props task
|
|
||||||
const [taskTitle, setTaskTitle] = useState(task.title);
|
const [taskTitle, setTaskTitle] = useState(task.title);
|
||||||
const [description, setDescription] = useState(task.description);
|
const [description, setDescription] = useState(task.description);
|
||||||
const [deadlineDate, setDeadlineDate] = useState(
|
const [deadlineDate, setDeadlineDate] = useState(
|
||||||
@@ -32,7 +30,9 @@ export default function EditSubmissionModal({
|
|||||||
);
|
);
|
||||||
const [submissionType, setSubmissionType] = useState<SubmissionType>(task.submission_type);
|
const [submissionType, setSubmissionType] = useState<SubmissionType>(task.submission_type);
|
||||||
|
|
||||||
// Sync form ketika task berubah (misal modal dibuka untuk task berbeda)
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
const isDeadlinePast = !!deadlineDate && deadlineDate < today;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTaskTitle(task.title);
|
setTaskTitle(task.title);
|
||||||
setDescription(task.description);
|
setDescription(task.description);
|
||||||
@@ -108,7 +108,7 @@ export default function EditSubmissionModal({
|
|||||||
|
|
||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-xs text-red-600 font-medium">
|
<div className="px-3 py-2 border border-red-200 rounded-lg text-xs text-red font-medium">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -148,9 +148,18 @@ export default function EditSubmissionModal({
|
|||||||
required
|
required
|
||||||
value={deadlineDate}
|
value={deadlineDate}
|
||||||
onChange={(e) => setDeadlineDate(e.target.value)}
|
onChange={(e) => setDeadlineDate(e.target.value)}
|
||||||
className="w-full p-3 py-2.5 bg-white border border-gray-200 rounded-lg text-sm font-medium text-gray-800 focus:outline-none focus:border-[#B30000] focus:ring-1 focus:ring-[#B30000] appearance-none"
|
className={`w-full p-3 py-2.5 bg-white border rounded-lg text-sm font-medium text-gray-800 focus:outline-none appearance-none ${
|
||||||
|
isDeadlinePast
|
||||||
|
? "border-red-400 focus:border-red-500 focus:ring-1 focus:ring-red-400"
|
||||||
|
: "border-gray-200 focus:border-[#B30000] focus:ring-1 focus:ring-[#B30000]"
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{isDeadlinePast && (
|
||||||
|
<p className="text-xs text-red-500 font-medium mt-1">
|
||||||
|
Tenggat waktu tidak boleh sebelum hari ini.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -200,7 +209,7 @@ export default function EditSubmissionModal({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading || isDeadlinePast}
|
||||||
className="px-5 py-2.5 bg-[#B30000] hover:bg-[#990000] text-white text-sm font-bold rounded-xl transition-colors shadow-md flex items-center gap-2 disabled:opacity-60 disabled:cursor-not-allowed"
|
className="px-5 py-2.5 bg-[#B30000] hover:bg-[#990000] text-white text-sm font-bold rounded-xl transition-colors shadow-md flex items-center gap-2 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<LuSave className="w-4 h-4 stroke-[2.5]" />
|
<LuSave className="w-4 h-4 stroke-[2.5]" />
|
||||||
|
|||||||
@@ -15,14 +15,12 @@ interface EditTaskModalProps {
|
|||||||
export default function EditTaskModal({ isOpen, onClose, task, onSuccess }: EditTaskModalProps) {
|
export default function EditTaskModal({ isOpen, onClose, task, onSuccess }: EditTaskModalProps) {
|
||||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
const { update, loading } = useUpdateTask();
|
const { update, loading } = useUpdateTask();
|
||||||
|
|
||||||
const [taskTitle, setTaskTitle] = useState(task.title);
|
const [taskTitle, setTaskTitle] = useState(task.title);
|
||||||
const [description, setDescription] = useState(task.description);
|
const [description, setDescription] = useState(task.description);
|
||||||
const [deadlineDate, setDeadlineDate] = useState(task.deadline_at.split("T")[0]);
|
const [deadlineDate, setDeadlineDate] = useState(task.deadline_at.split("T")[0]);
|
||||||
const [specificTime, setSpecificTime] = useState(task.deadline_at.split("T")[1]?.slice(0, 5) ?? "");
|
const [specificTime, setSpecificTime] = useState(task.deadline_at.split("T")[1]?.slice(0, 5) ?? "");
|
||||||
const [submissionType, setSubmissionType] = useState<SubmissionType>(task.submission_type);
|
const [submissionType, setSubmissionType] = useState<SubmissionType>(task.submission_type);
|
||||||
|
|
||||||
// Reset tiap task berubah
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTaskTitle(task.title);
|
setTaskTitle(task.title);
|
||||||
setDescription(task.description);
|
setDescription(task.description);
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
<!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">
|
|
||||||
<style>
|
|
||||||
html, body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
min-height: 100vh;
|
|
||||||
background: #1a1a1a;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#preview {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
border: 1px solid #ffffff55;
|
|
||||||
box-shadow: 0 0 0 1px #000, 0 4px 20px rgba(0, 0, 0, 0.6);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<canvas id="preview"></canvas>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const TEMPLATE_PATH = "../../assets/template.png";
|
|
||||||
|
|
||||||
// LAYOUT
|
|
||||||
const LAYOUT = {
|
|
||||||
internName: { x: 0.54, y: 0.5, size: 130, 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.56, 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 },
|
|
||||||
};
|
|
||||||
|
|
||||||
const DATA = {
|
|
||||||
internName: "Kemas M. Aryadary Rasyad",
|
|
||||||
projectName: "Internify Proyek Humic",
|
|
||||||
duration: "20 April 2026 - 20 Juni 2026",
|
|
||||||
certNo: "No.2026/SPI3/CERT/XXXXXX/1",
|
|
||||||
verifyUrl: "dummy-uuid-preview",
|
|
||||||
};
|
|
||||||
|
|
||||||
function loadImage(src) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => resolve(img);
|
|
||||||
img.onerror = () => reject(new Error("Gagal load gambar: " + src));
|
|
||||||
img.src = src;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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");
|
|
||||||
|
|
||||||
ctx.drawImage(templateImg, 0, 0);
|
|
||||||
ctx.textAlign = "center";
|
|
||||||
ctx.textBaseline = "middle";
|
|
||||||
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
// 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().catch((err) => {
|
|
||||||
document.body.insertAdjacentHTML("beforeend", `<pre style="color:red">${err.message}</pre>`);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 224 KiB |
@@ -1,11 +1,11 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import type {
|
import type {
|
||||||
ProjectTask,
|
|
||||||
ProjectTaskDetail,
|
ProjectTaskDetail,
|
||||||
CreateTaskPayload,
|
CreateTaskPayload,
|
||||||
UpdateTaskPayload,
|
UpdateTaskPayload,
|
||||||
TaskSubmissionData,
|
TaskSubmissionData,
|
||||||
AdminTaskDetail,
|
AdminTaskDetail,
|
||||||
|
MentorTaskItem,
|
||||||
} from "../types/task.types";
|
} from "../types/task.types";
|
||||||
import {
|
import {
|
||||||
getProjectTasks,
|
getProjectTasks,
|
||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
getAllMentorTasks,
|
getAllMentorTasks,
|
||||||
getTaskSubmissions,
|
getTaskSubmissions,
|
||||||
} from "../services/TaskService";
|
} from "../services/TaskService";
|
||||||
import type { MentorTaskItem } from "../types/task.types";
|
|
||||||
import { useCurrentUser } from "./useUser";
|
import { useCurrentUser } from "./useUser";
|
||||||
import { useMyTasks } from "./useProjects";
|
import { useMyTasks } from "./useProjects";
|
||||||
import type { InternTaskItem } from "../types/project.types";
|
import type { InternTaskItem } from "../types/project.types";
|
||||||
|
|||||||
Reference in New Issue
Block a user