[UNTEST] CERTIFICATE GENERATION AND BACKEND ENDPOINT IMPLEMENTATION ON CERTIFICATES (CLAIM, GENERATE, FETCH DATA)
- Updated AddInternsModal to allow selection of interns via dropdown instead of email input. - Introduced CertResultTable component for displaying certificate results with search and pagination. - Modified CreateProjectDialog to accept an onSuccess callback for better project creation handling. - Integrated certificate generation functionality in EligibleInternTable with improved error handling. - Enhanced HomeMentorProject and MentorProjectCard to dynamically display project icons and statuses. - Refactored SidebarMentor to improve active route detection. - Added utility functions for certificate preview and download in Certificates.ts. - Removed unused DownloadImagetoDevice utility and related hooks. - Implemented useProjectsByRole hook for fetching projects based on user role. - Updated types for certificates and projects to reflect recent changes in the data structure.
This commit is contained in:
+15
-15
@@ -4,19 +4,27 @@ import { useParams, useLocation } from "react-router-dom";
|
||||
import CertificateAvailable from "./components/CertificateAvailabe";
|
||||
import CertificateNotAvailable from "./components/CertificateNotAvailable";
|
||||
import SertificateHistory from "./components/CertificateHistory";
|
||||
import { useMyCertificates, useClaimCertificate } from "../../../hooks/useCertificates";
|
||||
|
||||
import { useClaimCertificate } from "../../../hooks/useCertificates";
|
||||
import { useProjectDetail } from "../../../hooks/useProjects";
|
||||
|
||||
import { useProjectCertificates } from "../../../hooks/useCertificates";
|
||||
import type { Project } from "../../../types/project.types";
|
||||
|
||||
const SertificatePage = () => {
|
||||
export default function InternCertificateDetail () {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const location = useLocation();
|
||||
const projectFromList = location.state?.project as Project | undefined;
|
||||
|
||||
const { project: projectDetail, loading: projectLoading } = useProjectDetail(slug ?? "");
|
||||
const { certificates, loading: certLoading, refetch: refetchCertificates } = useMyCertificates();
|
||||
const { certificates, loading: certLoading, refetch: refetchCertificates } = useProjectCertificates(projectDetail?.id ?? 0);
|
||||
const { claim, loading: claiming, error: claimError } = useClaimCertificate();
|
||||
|
||||
const myCertificateForProject = useMemo(() => {
|
||||
if (!projectDetail) return null;
|
||||
return certificates.find((cert) => cert.id_project === projectDetail.id);
|
||||
}, [projectDetail, certificates]);
|
||||
|
||||
const project = useMemo(() => {
|
||||
if (!projectDetail) return null;
|
||||
return {
|
||||
@@ -28,13 +36,6 @@ const SertificatePage = () => {
|
||||
|
||||
const allTasksDone = !projectLoading && !!project && project.task_done >= project.total_tasks;
|
||||
|
||||
const myCertificateForProject = useMemo(() => {
|
||||
if (!project || !certificates.length) return null;
|
||||
return certificates.find((cert) => cert.project.id === project.id) ?? null;
|
||||
}, [certificates, project]);
|
||||
|
||||
const alreadyDone = !!myCertificateForProject;
|
||||
|
||||
const progress =
|
||||
project && project.total_tasks > 0
|
||||
? Math.round((project.task_done / project.total_tasks) * 100)
|
||||
@@ -53,7 +54,7 @@ const SertificatePage = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20 text-gray-400 text-sm">
|
||||
Loading...
|
||||
Memuat Data...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -66,10 +67,11 @@ const SertificatePage = () => {
|
||||
</div>
|
||||
<div className="flex flex-col lg:flex-row gap-6 items-stretch">
|
||||
<div className="w-full lg:w-5/8 flex flex-col">
|
||||
{alreadyDone ? (
|
||||
{myCertificateForProject ? (
|
||||
<CertificateAvailable
|
||||
project={project}
|
||||
certificate={myCertificateForProject}
|
||||
templateUrl = {project?.certificate_template ?? ""}
|
||||
/>
|
||||
) : (
|
||||
<CertificateNotAvailable
|
||||
@@ -88,6 +90,4 @@ const SertificatePage = () => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SertificatePage;
|
||||
};
|
||||
@@ -1,72 +1,91 @@
|
||||
// CertificateAvailable.tsx
|
||||
import { useState, useRef } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Download, Link, Printer } from "lucide-react";
|
||||
import type { ProjectDetail } from "../../../../types/project.types";
|
||||
import type { Certificate } from "../../../../types/certificate.types";
|
||||
import { downloadCertificateAsImage, downloadCertificateAsPdf } from "../../../utils/DownloadImagetoDevice";
|
||||
import { resolveImageUrl } from "../../../utils/SertificateGenerator";
|
||||
import { downloadCertificatePdf, downloadCertificateImage } from "../../../utils/Certificates";
|
||||
import { generateCertificate, resolveImageUrl } from "../../../utils/SertificateGenerator";
|
||||
|
||||
interface CertificateAvailableProps {
|
||||
project: ProjectDetail | null;
|
||||
certificate: Certificate;
|
||||
templateUrl: string;
|
||||
}
|
||||
|
||||
export default function CertificateAvailable({ project, certificate }: CertificateAvailableProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
export default function CertificateAvailable({ project, certificate, templateUrl }: CertificateAvailableProps) {
|
||||
// const [copied, setCopied] = useState(false);
|
||||
const [downloadingImg, setDownloadingImg] = useState(false);
|
||||
const [downloadingPdf, setDownloadingPdf] = useState(false);
|
||||
const certificateRef = useRef<HTMLDivElement>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [loadingPreview, setLoadingPreview] = useState(true);
|
||||
|
||||
const handleCopyLink = () => {
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!templateUrl) return;
|
||||
|
||||
generateCertificate(resolveImageUrl(templateUrl), certificate.user.full_name)
|
||||
.then((blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
setPreviewUrl(url);
|
||||
})
|
||||
.finally(() => setLoadingPreview(false));
|
||||
|
||||
// Cleanup supaya tidak memory leak
|
||||
return () => {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
};
|
||||
}, [templateUrl, certificate.user.full_name]);
|
||||
|
||||
// Hitung durasi dari start_date - end_date
|
||||
const duration = project
|
||||
? (() => {
|
||||
const start = new Date(project.start_date);
|
||||
const end = new Date(project.end_date);
|
||||
const diffMs = end.getTime() - start.getTime();
|
||||
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
||||
const months = Math.floor(diffDays / 30);
|
||||
const days = diffDays % 30;
|
||||
if (months > 0 && days > 0) return `${months} Bulan ${days} Hari`;
|
||||
if (months > 0) return `${months} Bulan`;
|
||||
return `${diffDays} Hari`;
|
||||
})()
|
||||
const start = new Date(project.start_date);
|
||||
const end = new Date(project.end_date);
|
||||
const diffMs = end.getTime() - start.getTime();
|
||||
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
||||
const months = Math.floor(diffDays / 30);
|
||||
const days = diffDays % 30;
|
||||
if (months > 0 && days > 0) return `${months} Bulan ${days} Hari`;
|
||||
if (months > 0) return `${months} Bulan`;
|
||||
return `${diffDays} Hari`;
|
||||
})()
|
||||
: "-";
|
||||
|
||||
const handleDownloadImage = async () => {
|
||||
const handleDownloadImage = async () => {
|
||||
setDownloadingImg(true);
|
||||
await downloadCertificateAsImage(
|
||||
certificateRef.current,
|
||||
`Sertifikat - ${certificate.certificate_no}`,
|
||||
"png",
|
||||
certificate.image_path
|
||||
);
|
||||
setDownloadingImg(false);
|
||||
try {
|
||||
await downloadCertificateImage(certificate, templateUrl, "png");
|
||||
} finally {
|
||||
setDownloadingImg(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadPdf = async () => {
|
||||
setDownloadingPdf(true);
|
||||
await downloadCertificateAsPdf(
|
||||
certificateRef.current,
|
||||
`Sertifikat - ${certificate.certificate_no}`
|
||||
);
|
||||
setDownloadingPdf(false);
|
||||
try {
|
||||
await downloadCertificatePdf(certificate, templateUrl);
|
||||
} finally {
|
||||
setDownloadingPdf(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Certificate Card */}
|
||||
<div className="flex rounded-xl p-5 mb-1 border bg-box-primary border-box-border shadow-md items-center justify-center">
|
||||
<img
|
||||
src={resolveImageUrl(certificate.image_path)}
|
||||
alt="Certificate"
|
||||
className="rounded-xl w-[550px] h-[350px] object-contain"
|
||||
/>
|
||||
{loadingPreview ? (
|
||||
<div className="w-[550px] h-[350px] flex items-center justify-center text-gray-400 text-sm">
|
||||
Memuat sertifikat...
|
||||
</div>
|
||||
) : previewUrl ? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Certificate"
|
||||
className="rounded-xl w-[550px] h-[350px] object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-[550px] h-[350px] flex items-center justify-center text-gray-400 text-sm">
|
||||
Gagal memuat sertifikat.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Program Summary */}
|
||||
@@ -79,15 +98,7 @@ export default function CertificateAvailable({ project, certificate }: Certifica
|
||||
{[
|
||||
{ label: "Nama Program", value: project?.project_name ?? "-" },
|
||||
{ label: "Durasi", value: duration },
|
||||
{ label: "Total Tugas", value: project ? `${project.total_tasks} Tugas` : "-" },
|
||||
{
|
||||
label: "Status",
|
||||
value: (
|
||||
<span className="bg-green-100 text-green-700 text-xs font-bold px-2 py-0.5 rounded-full">
|
||||
{certificate.certificate_status}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ label: "No. Sertifikat", value: certificate.certificate_no ?? "-" },
|
||||
].map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
@@ -109,7 +120,7 @@ export default function CertificateAvailable({ project, certificate }: Certifica
|
||||
className="flex items-center justify-center gap-2 w-full bg-red-700 hover:bg-red-800 active:scale-95 transition-all text-white font-semibold py-2 rounded-xl text-sm"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{downloadingPdf? "Memproses": "Unduh Sertifikat"}
|
||||
{downloadingPdf ? "Memproses" : "Unduh Sertifikat"}
|
||||
</button>
|
||||
|
||||
{/* Bagikan ke LinkedIn */}
|
||||
@@ -124,14 +135,14 @@ export default function CertificateAvailable({ project, certificate }: Certifica
|
||||
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">
|
||||
<Printer className="w-5 h-5" />
|
||||
{downloadingImg? "Memproses" : "Cetak"}
|
||||
{downloadingImg ? "Memproses" : "Cetak"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopyLink}
|
||||
// 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-3.5 rounded-xl text-sm"
|
||||
>
|
||||
<Link className="w-5 h-5" />
|
||||
{copied ? "Copied!" : "Salin Link"}
|
||||
Salin Link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,13 +40,6 @@ export default function SertificateHistory() {
|
||||
key={item.id}
|
||||
className="flex flex-col gap-3 p-3 bg-white border border-gray-200 shadow-sm rounded-xl hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="w-full flex justify-center">
|
||||
<img
|
||||
src={item.image_path}
|
||||
alt="Certificate"
|
||||
className="rounded-xl h-[150px] w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<p className="text-xs text-gray-400">
|
||||
{new Date(item.issued_at).toLocaleDateString("id-ID", {
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useProjects } from '../../../../hooks/useProjects';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import type { LucideProps } from 'lucide-react';
|
||||
|
||||
const toSlug = (name: string) => name.toLowerCase().replace(/\s+/g, '-');
|
||||
|
||||
const getDynamicIcon = (iconName: string) => {
|
||||
const formatted = iconName.charAt(0).toUpperCase() + iconName.slice(1);
|
||||
const Icon = (LucideIcons as unknown as Record<string, React.ComponentType<LucideProps>>)[formatted];
|
||||
return Icon ?? LucideIcons.FolderOpen;
|
||||
};
|
||||
|
||||
const HomeProjectList = () => {
|
||||
const navigate = useNavigate();
|
||||
const { projects, loading, error } = useProjects();
|
||||
|
||||
if (loading) return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
@@ -27,7 +34,12 @@ const HomeProjectList = () => {
|
||||
onClick={() => navigate(`/intern/projects/${toSlug(project.slug)}`)}
|
||||
className="flex items-center gap-4 border border-card-outline rounded-lg px-4 py-3 hover:bg-gray-50 cursor-pointer transition-colors duration-150"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 bg-red-600" />
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0" style={{ backgroundColor: project.background_color ?? '#dc2626' }}>
|
||||
{(() => {
|
||||
const Icon = getDynamicIcon(project.project_icon);
|
||||
return <Icon className="w-5 h-5 text-white" />;
|
||||
})()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-font-shade">{project.project_name}</p>
|
||||
<p className="text-xs text-gray-400">{project.status}</p>
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function SidebarIntern({ isOpen, closeSidebar }: SidebarProps) {
|
||||
{/* Main nav */}
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{navItems.map(({ label, icon: Icon, path }) => {
|
||||
const isActive = location.pathname === path;
|
||||
const isActive = location.pathname === path || location.pathname.startsWith(path + "/");
|
||||
return (
|
||||
<button
|
||||
key={path}
|
||||
|
||||
Reference in New Issue
Block a user