[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:
Kemas M. Aryadary Rasyad
2026-07-03 15:03:17 +07:00
parent 5dec77862c
commit 508ffe5f84
29 changed files with 782 additions and 607 deletions
+2 -2
View File
@@ -31,7 +31,6 @@ import EditLowongan from "./Pages/EditLowongan";
import HomeInternPage from "./Pages/Internify/intern/HomeInternPage"; import HomeInternPage from "./Pages/Internify/intern/HomeInternPage";
import { InternLayout, MentorLayout } from "./Pages/Internify/layout"; import { InternLayout, MentorLayout } from "./Pages/Internify/layout";
import ProjectsPage from "./Pages/Internify/intern/ProjectsPage"; import ProjectsPage from "./Pages/Internify/intern/ProjectsPage";
import SertificatePage from "./Pages/Internify/intern/InternCertiDetail";
import ProjectDetailPage from "./Pages/Internify/intern/ProjectDetailPage"; import ProjectDetailPage from "./Pages/Internify/intern/ProjectDetailPage";
import HomeMentorPage from "./Pages/Internify/mentor/HomeMentorPage"; import HomeMentorPage from "./Pages/Internify/mentor/HomeMentorPage";
import MentorProjectsPage from "./Pages/Internify/mentor/BaseProjectsPage"; import MentorProjectsPage from "./Pages/Internify/mentor/BaseProjectsPage";
@@ -54,6 +53,7 @@ import AdminMentorPage from "./Pages/MentorPage";
import CreateMentorForm from "./Pages/CreateMentorForm"; import CreateMentorForm from "./Pages/CreateMentorForm";
import EditMentorForm from "./Pages/EditMentorForm"; import EditMentorForm from "./Pages/EditMentorForm";
import NotFoundPage from "./Pages/NotFoundPage"; import NotFoundPage from "./Pages/NotFoundPage";
import InternCertificateDetail from "./Pages/Internify/intern/InternCertDetail";
function App() { function App() {
return ( return (
@@ -103,7 +103,7 @@ function App() {
<Route index element={<HomeInternPage />} /> <Route index element={<HomeInternPage />} />
<Route path="projects" element={<ProjectsPage />} /> <Route path="projects" element={<ProjectsPage />} />
<Route path="certificates" element={<SertificateList />} /> <Route path="certificates" element={<SertificateList />} />
<Route path="certificates/:slug" element={<SertificatePage />} /> <Route path="certificates/:slug" element={<InternCertificateDetail />} />
<Route path="projects/:slug" element={<ProjectDetailPage />} /> <Route path="projects/:slug" element={<ProjectDetailPage />} />
<Route path="projects/:slug/:taskSlug" element={<TaskSubmission />} /> <Route path="projects/:slug/:taskSlug" element={<TaskSubmission />} />
<Route path="faq" element={<FAQPage />} /> <Route path="faq" element={<FAQPage />} />
@@ -4,19 +4,27 @@ import { useParams, useLocation } from "react-router-dom";
import CertificateAvailable from "./components/CertificateAvailabe"; import CertificateAvailable from "./components/CertificateAvailabe";
import CertificateNotAvailable from "./components/CertificateNotAvailable"; import CertificateNotAvailable from "./components/CertificateNotAvailable";
import SertificateHistory from "./components/CertificateHistory"; import SertificateHistory from "./components/CertificateHistory";
import { useMyCertificates, useClaimCertificate } from "../../../hooks/useCertificates";
import { useClaimCertificate } from "../../../hooks/useCertificates";
import { useProjectDetail } from "../../../hooks/useProjects"; import { useProjectDetail } from "../../../hooks/useProjects";
import { useProjectCertificates } from "../../../hooks/useCertificates";
import type { Project } from "../../../types/project.types"; import type { Project } from "../../../types/project.types";
const SertificatePage = () => { export default function InternCertificateDetail () {
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
const location = useLocation(); const location = useLocation();
const projectFromList = location.state?.project as Project | undefined; const projectFromList = location.state?.project as Project | undefined;
const { project: projectDetail, loading: projectLoading } = useProjectDetail(slug ?? ""); 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 { 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(() => { const project = useMemo(() => {
if (!projectDetail) return null; if (!projectDetail) return null;
return { return {
@@ -28,13 +36,6 @@ const SertificatePage = () => {
const allTasksDone = !projectLoading && !!project && project.task_done >= project.total_tasks; 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 = const progress =
project && project.total_tasks > 0 project && project.total_tasks > 0
? Math.round((project.task_done / project.total_tasks) * 100) ? Math.round((project.task_done / project.total_tasks) * 100)
@@ -53,7 +54,7 @@ const SertificatePage = () => {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center py-20 text-gray-400 text-sm"> <div className="flex items-center justify-center py-20 text-gray-400 text-sm">
Loading... Memuat Data...
</div> </div>
); );
} }
@@ -66,10 +67,11 @@ const SertificatePage = () => {
</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">
{alreadyDone ? ( {myCertificateForProject ? (
<CertificateAvailable <CertificateAvailable
project={project} project={project}
certificate={myCertificateForProject} certificate={myCertificateForProject}
templateUrl = {project?.certificate_template ?? ""}
/> />
) : ( ) : (
<CertificateNotAvailable <CertificateNotAvailable
@@ -88,6 +90,4 @@ const SertificatePage = () => {
</div> </div>
</div> </div>
); );
}; };
export default SertificatePage;
@@ -1,72 +1,91 @@
// CertificateAvailable.tsx // CertificateAvailable.tsx
import { useState, useRef } from "react"; import { useState, useEffect } from "react";
import { Download, Link, Printer } from "lucide-react"; import { Download, Link, Printer } from "lucide-react";
import type { ProjectDetail } from "../../../../types/project.types"; import type { ProjectDetail } from "../../../../types/project.types";
import type { Certificate } from "../../../../types/certificate.types"; import type { Certificate } from "../../../../types/certificate.types";
import { downloadCertificateAsImage, downloadCertificateAsPdf } from "../../../utils/DownloadImagetoDevice"; import { downloadCertificatePdf, downloadCertificateImage } from "../../../utils/Certificates";
import { resolveImageUrl } from "../../../utils/SertificateGenerator"; import { generateCertificate, resolveImageUrl } from "../../../utils/SertificateGenerator";
interface CertificateAvailableProps { interface CertificateAvailableProps {
project: ProjectDetail | null; project: ProjectDetail | null;
certificate: Certificate; certificate: Certificate;
templateUrl: string;
} }
export default function CertificateAvailable({ project, certificate }: CertificateAvailableProps) { export default function CertificateAvailable({ project, certificate, templateUrl }: CertificateAvailableProps) {
const [copied, setCopied] = useState(false); // const [copied, setCopied] = useState(false);
const [downloadingImg, setDownloadingImg] = useState(false); const [downloadingImg, setDownloadingImg] = useState(false);
const [downloadingPdf, setDownloadingPdf] = 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 = () => { useEffect(() => {
navigator.clipboard.writeText(window.location.href); if (!templateUrl) return;
setCopied(true);
setTimeout(() => setCopied(false), 2000); 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 duration = project
? (() => { ? (() => {
const start = new Date(project.start_date); const start = new Date(project.start_date);
const end = new Date(project.end_date); const end = new Date(project.end_date);
const diffMs = end.getTime() - start.getTime(); const diffMs = end.getTime() - start.getTime();
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
const months = Math.floor(diffDays / 30); const months = Math.floor(diffDays / 30);
const days = diffDays % 30; const days = diffDays % 30;
if (months > 0 && days > 0) return `${months} Bulan ${days} Hari`; if (months > 0 && days > 0) return `${months} Bulan ${days} Hari`;
if (months > 0) return `${months} Bulan`; if (months > 0) return `${months} Bulan`;
return `${diffDays} Hari`; return `${diffDays} Hari`;
})() })()
: "-"; : "-";
const handleDownloadImage = async () => { const handleDownloadImage = async () => {
setDownloadingImg(true); setDownloadingImg(true);
await downloadCertificateAsImage( try {
certificateRef.current, await downloadCertificateImage(certificate, templateUrl, "png");
`Sertifikat - ${certificate.certificate_no}`, } finally {
"png", setDownloadingImg(false);
certificate.image_path }
);
setDownloadingImg(false);
}; };
const handleDownloadPdf = async () => { const handleDownloadPdf = async () => {
setDownloadingPdf(true); setDownloadingPdf(true);
await downloadCertificateAsPdf( try {
certificateRef.current, await downloadCertificatePdf(certificate, templateUrl);
`Sertifikat - ${certificate.certificate_no}` } finally {
); setDownloadingPdf(false);
setDownloadingPdf(false); }
}; };
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Certificate Card */} {/* Certificate Card */}
<div className="flex rounded-xl p-5 mb-1 border bg-box-primary border-box-border shadow-md items-center justify-center"> <div className="flex rounded-xl p-5 mb-1 border bg-box-primary border-box-border shadow-md items-center justify-center">
<img {loadingPreview ? (
src={resolveImageUrl(certificate.image_path)} <div className="w-[550px] h-[350px] flex items-center justify-center text-gray-400 text-sm">
alt="Certificate" Memuat sertifikat...
className="rounded-xl w-[550px] h-[350px] object-contain" </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> </div>
{/* Program Summary */} {/* Program Summary */}
@@ -79,15 +98,7 @@ export default function CertificateAvailable({ project, certificate }: Certifica
{[ {[
{ label: "Nama Program", value: project?.project_name ?? "-" }, { label: "Nama Program", value: project?.project_name ?? "-" },
{ label: "Durasi", value: duration }, { label: "Durasi", value: duration },
{ label: "Total Tugas", value: project ? `${project.total_tasks} Tugas` : "-" }, { label: "No. Sertifikat", value: certificate.certificate_no ?? "-" },
{
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>
),
},
].map((row) => ( ].map((row) => (
<div <div
key={row.label} 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" 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" /> <Download className="w-4 h-4" />
{downloadingPdf? "Memproses": "Unduh Sertifikat"} {downloadingPdf ? "Memproses" : "Unduh Sertifikat"}
</button> </button>
{/* Bagikan ke LinkedIn */} {/* Bagikan ke LinkedIn */}
@@ -124,14 +135,14 @@ export default function CertificateAvailable({ project, certificate }: Certifica
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-3.5 rounded-xl text-sm">
<Printer className="w-5 h-5" /> <Printer className="w-5 h-5" />
{downloadingImg? "Memproses" : "Cetak"} {downloadingImg ? "Memproses" : "Cetak"}
</button> </button>
<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" 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" /> <Link className="w-5 h-5" />
{copied ? "Copied!" : "Salin Link"} Salin Link
</button> </button>
</div> </div>
</div> </div>
@@ -40,13 +40,6 @@ export default function SertificateHistory() {
key={item.id} 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" 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"> <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", {
@@ -1,12 +1,19 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useProjects } from '../../../../hooks/useProjects'; 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 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 HomeProjectList = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { projects, loading, error } = useProjects(); const { projects, loading, error } = useProjects();
if (loading) return ( if (loading) return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{[...Array(3)].map((_, i) => ( {[...Array(3)].map((_, i) => (
@@ -27,7 +34,12 @@ const HomeProjectList = () => {
onClick={() => navigate(`/intern/projects/${toSlug(project.slug)}`)} 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" 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> <div>
<p className="text-sm font-semibold text-font-shade">{project.project_name}</p> <p className="text-sm font-semibold text-font-shade">{project.project_name}</p>
<p className="text-xs text-gray-400">{project.status}</p> <p className="text-xs text-gray-400">{project.status}</p>
@@ -65,7 +65,7 @@ export default function SidebarIntern({ isOpen, closeSidebar }: SidebarProps) {
{/* Main nav */} {/* Main nav */}
<nav className="flex flex-col gap-0.5"> <nav className="flex flex-col gap-0.5">
{navItems.map(({ label, icon: Icon, path }) => { {navItems.map(({ label, icon: Icon, path }) => {
const isActive = location.pathname === path; const isActive = location.pathname === path || location.pathname.startsWith(path + "/");
return ( return (
<button <button
key={path} key={path}
@@ -2,20 +2,23 @@ import MentorProjectCard from './components/MentorProjectCard';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import CreateProjectModal from './components/CreateProjectDialog'; import CreateProjectModal from './components/CreateProjectDialog';
import { useProjects } from '../../../hooks/useProjects'; import { useProjectsByRole } from '../../../hooks/useListProjects';
export default function MentorProjectsPage() { export default function MentorProjectsPage() {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const { projects, loading, error } = useProjects("active"); const { projects, loading, error, refetch } = useProjectsByRole();
const openDialog = () => { const openDialog = () => {
setIsModalOpen(true); setIsModalOpen(true);
}; };
const closeDialog = () => { const closeDialog = () => {
setIsModalOpen(false); setIsModalOpen(false);
}; };
const handleRefetch = () => {
closeDialog();
refetch();
}
if (loading) return ( if (loading) return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@@ -56,6 +59,7 @@ export default function MentorProjectsPage() {
<CreateProjectModal <CreateProjectModal
isOpen={isModalOpen} isOpen={isModalOpen}
onClose={closeDialog} onClose={closeDialog}
onSuccess={handleRefetch}
/> />
)} )}
</div> </div>
+52 -249
View File
@@ -1,132 +1,44 @@
// src/pages/mentor/CertificateResult.tsx
import { useState } from "react"; import { useState } from "react";
import { import { CheckCircle2, FolderArchive, Loader2 } from "lucide-react";
CheckCircle2, import { useProjectCertificates } from "../../../hooks/useCertificates";
FolderArchive,
Search,
Eye,
Download,
Share2,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { useProjectCertificates } from "../../../hooks/useCertificates"; //Problemnya cuma role "admin" yang bisa
import { useProjectDetail } from "../../../hooks/useProjects"; import { useProjectDetail } from "../../../hooks/useProjects";
import type { Certificate } from "../../../types/certificate.types";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { downloadAllCertificatesZip } from "../../utils/Certificates";
import CertResultTable from "./components/CertResultTable";
const PER_PAGE = 5;
const getInitials = (name: string): string => {
const parts = name.trim().split(/\s+/);
if (parts.length === 1) return parts[0][0].toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
};
function Avatar({ name }: { name: string }) {
// "Kemas M Aryadary Rasyad" → "KR"
const color = "bg-gray-100 text-gray-600";
return (
<div className={`w-9 h-9 rounded-full flex items-center justify-center text-[11px] font-black flex-shrink-0 ${color}`}>
{getInitials(name)}
</div>
);
}
function CertificateResultSkeleton() {
return (
<div className="animate-pulse">
{/* Hero */}
<div className="flex flex-col items-center text-center mb-8 px-4">
<div className="w-16 h-16 rounded-full bg-gray-200 mb-5" />
<div className="h-8 w-64 bg-gray-200 rounded-lg mb-3" />
<div className="h-4 w-80 bg-gray-100 rounded mb-2" />
<div className="h-4 w-64 bg-gray-100 rounded mb-6" />
<div className="h-9 w-44 bg-gray-200 rounded-xl" />
</div>
{/* Table */}
<div className="border border-gray-200 rounded-2xl overflow-hidden bg-white shadow-sm">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<div className="h-4 w-36 bg-gray-200 rounded" />
<div className="h-7 w-44 bg-gray-100 rounded-lg" />
</div>
{/* Column headers */}
<div className="grid grid-cols-[2fr_1.5fr_auto] px-5 py-2.5 bg-gray-50 border-b border-gray-100 gap-4">
<div className="h-3 w-20 bg-gray-200 rounded" />
<div className="h-3 w-24 bg-gray-200 rounded" />
<div className="h-3 w-16 bg-gray-200 rounded" />
</div>
{/* Rows */}
{Array.from({ length: PER_PAGE }).map((_, i) => (
<div
key={i}
className="grid grid-cols-[2fr_1.5fr_auto] items-center px-5 py-3.5 border-b border-gray-50 gap-4"
>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-gray-200 flex-shrink-0" />
<div className="space-y-1.5">
<div className="h-3.5 w-28 bg-gray-200 rounded" />
</div>
</div>
<div className="h-3 w-32 bg-gray-100 rounded" />
<div className="flex items-center gap-1">
{Array.from({ length: 3 }).map((_, j) => (
<div key={j} className="w-7 h-7 rounded-md bg-gray-100" />
))}
</div>
</div>
))}
{/* Footer */}
<div className="flex items-center justify-between px-5 py-3.5 border-t border-gray-100 bg-gray-50/30">
<div className="h-3 w-40 bg-gray-100 rounded" />
<div className="flex gap-1">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="w-7 h-7 rounded-md bg-gray-200" />
))}
</div>
</div>
</div>
</div>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function CertificateResult() { export default function CertificateResult() {
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
const [search, setSearch] = useState(""); const [zipping, setZipping] = useState(false);
const [page, setPage] = useState(1); const [zipProgress, setZipProgress] = useState<{ done: number; total: number } | null>(null);
const { project, loading: projectLoading, error: projectError } = useProjectDetail(slug ?? ""); const { project, loading: projectLoading, error: projectError } = useProjectDetail(slug ?? "");
const { certificates, loading: certLoading, error: certError } = useProjectCertificates( const { certificates, loading: certLoading, error: certError } = useProjectCertificates(project?.id ?? 0);
project?.id ?? 0
);
const loading = projectLoading || certLoading; const loading = projectLoading || certLoading;
const error = projectError || certError; const error = projectError || certError;
if (loading) return <CertificateResultSkeleton />; const handleDownloadZip = async () => {
if (error) return <div className="text-sm text-red-500 p-4">Gagal memuat data sertifikat.</div>; if (!project || certificates.length === 0) return;
setZipping(true);
// Filter + paginate setZipProgress({ done: 0, total: certificates.length });
const filtered = certificates.filter( try {
(c) => await downloadAllCertificatesZip(
c.user.full_name.toLowerCase().includes(search.toLowerCase()) || certificates,
c.user.email.toLowerCase().includes(search.toLowerCase()) project.certificate_template ?? "",
); project.project_name,
const totalPages = Math.ceil(filtered.length / PER_PAGE); (done, total) => setZipProgress({ done, total })
const paginated = filtered.slice((page - 1) * PER_PAGE, page * PER_PAGE); );
} catch (err) {
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => { console.error("Gagal download ZIP:", err);
setSearch(e.target.value); } finally {
setPage(1); setZipping(false);
setZipProgress(null);
}
}; };
if (loading) return <p>Sedang Memuat...</p>;
if (error) return <div className="text-sm text-red-500 p-4">Gagal memuat data sertifikat.</div>;
return ( return (
<div> <div>
{/* Page Header */} {/* Page Header */}
@@ -137,151 +49,42 @@ export default function CertificateResult() {
</p> </p>
</div> </div>
{/* ── Success Hero ── */} {/* Success Hero */}
<div className="flex flex-col items-center text-center mb-8 px-4"> <div className="flex flex-col items-center text-center mb-8 px-4">
{/* Icon circle */} <div className="w-16 h-16 rounded-full bg-red flex items-center justify-center shadow-lg mb-5">
<div className="w-16 h-16 rounded-full bg-[#B30000] flex items-center justify-center shadow-lg mb-5">
<CheckCircle2 className="w-8 h-8 text-white" strokeWidth={2.5} /> <CheckCircle2 className="w-8 h-8 text-white" strokeWidth={2.5} />
</div> </div>
<h2 className="text-3xl md:text-4xl font-extrabold text-red mb-3 leading-tight">
{/* Headline */} {certificates.length} Certificates has been Generated!
<h2 className="text-3xl md:text-4xl font-extrabold text-[#B30000] mb-3 leading-tight">
{certificates.length} Certificates Successfully Generated!
</h2> </h2>
{/* Subtext */}
<p className="text-sm text-gray-500 max-w-md leading-relaxed mb-6"> <p className="text-sm text-gray-500 max-w-md leading-relaxed mb-6">
The validation engine has processed all engineering credentials. You can now The validation engine has processed all engineering credentials. You can now
publish them to the public registry or download the batch for offline distribution. publish them to the public registry or download the batch for offline distribution.
</p> </p>
<button
{/* Download CTA */} onClick={handleDownloadZip}
<button className="flex items-center gap-2 px-6 py-2.5 border border-gray-200 rounded-xl text-sm font-bold text-[#B30000] bg-white hover:bg-red-50 hover:border-red-200 shadow-sm transition-colors"> disabled={zipping || certificates.length === 0}
<FolderArchive className="w-4 h-4" /> className="flex items-center gap-2 px-6 py-2.5 border border-gray-200 rounded-xl text-sm font-bold text-red bg-white hover:bg-red-50 hover:border-red-200 shadow-sm transition-colors disabled:opacity-50"
Download All (ZIP) >
{zipping ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{zipProgress ? `Mengemas ${zipProgress.done}/${zipProgress.total}...` : "Menyiapkan..."}
</>
) : (
<>
<FolderArchive className="w-4 h-4" />
Download All (ZIP)
</>
)}
</button> </button>
</div> </div>
{/* ── Bottom Section: Table + Preview Card ── */} {/* Table */}
<div className="flex flex-col xl:flex-row gap-4 items-start"> <CertResultTable
certificates={certificates}
{/* Recipient Directory Table */} templateUrl={project?.certificate_template ?? ""}
<div className="flex-1 min-w-0 border border-gray-200 rounded-2xl overflow-hidden bg-white shadow-sm"> />
{/* Table Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<h3 className="text-sm font-bold text-gray-900">Recipient Directory</h3>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
<input
type="text"
placeholder="Filter interns..."
value={search}
onChange={handleSearch}
className="pl-8 pr-3 py-1.5 text-xs border border-gray-200 rounded-lg bg-white text-gray-700 placeholder-gray-400 focus:outline-none focus:border-red-400 focus:ring-1 focus:ring-red-400 w-44 transition-colors"
/>
</div>
</div>
{/* Column Headers */}
<div className="grid grid-cols-[2fr_1.5fr_auto] px-5 py-2.5 bg-gray-50 border-b border-gray-100">
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Intern Name</span>
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Credential ID</span>
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Actions</span>
</div>
{/* Rows */}
<div className="divide-y divide-gray-50">
{paginated.length === 0 ? (
<div className="px-5 py-10 text-center text-sm text-gray-400">
No interns match your search.
</div>
) : (
paginated.map((cert: Certificate) => (
<div
key={cert.id}
className="grid grid-cols-[2fr_1.5fr_auto] items-center px-5 py-3.5 hover:bg-gray-50/60 transition-colors"
>
{/* Name + role */}
<div className="flex items-center gap-3">
<Avatar name={cert.user.full_name} />
<div>
<p className="text-sm font-bold text-gray-900 leading-tight">{cert.user.full_name}</p>
{/* <p className="text-[11px] text-gray-400 mt-0.5">{cert.role}</p> */}
</div>
</div>
{/* Credential ID */}
<p className="text-xs font-mono text-gray-500 tracking-wide">{cert.uuid}</p>
{/* Actions */}
<div className="flex items-center gap-1">
<button className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-md transition-colors" title="View">
<Eye className="w-4 h-4" />
</button>
<button className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-md transition-colors" title="Download">
<Download className="w-4 h-4" />
</button>
<button className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-md transition-colors" title="Share">
<Share2 className="w-4 h-4" />
</button>
</div>
</div>
))
)}
</div>
{/* Pagination Footer */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 px-5 py-3.5 border-t border-gray-100 bg-gray-50/30">
<p className="text-xs text-gray-400 font-medium">
Showing {filtered.length === 0 ? 0 : (page - 1) * PER_PAGE + 1} to{" "}
{Math.min(page * PER_PAGE, filtered.length)} of {certificates.length} interns
</p>
<div className="flex items-center gap-1">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="p-1 border border-gray-200 rounded-md bg-white text-gray-400 hover:bg-gray-50 disabled:opacity-40 transition-colors"
>
<ChevronLeft className="w-4 h-4" />
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (
<button
key={n}
onClick={() => setPage(n)}
className={`w-7 h-7 flex items-center justify-center rounded-md text-xs font-bold transition-colors ${n === page
? "bg-[#B30000] text-white shadow-sm"
: "bg-white border border-gray-200 text-gray-600 hover:bg-gray-50"
}`}
>
{n}
</button>
))}
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages || totalPages === 0}
className="p-1 border border-gray-200 rounded-md bg-white text-gray-400 hover:bg-gray-50 disabled:opacity-40 transition-colors"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* Preview Card (placeholder — gambar belum diimplementasi) */}
{/* <div className="xl:w-64 w-full flex-shrink-0 rounded-2xl overflow-hidden bg-gradient-to-br from-gray-700 via-[#7a1010] to-[#B30000] shadow-md min-h-[200px] flex flex-col justify-end p-5">
<div className="mt-auto">
<p className="text-white font-extrabold text-base leading-snug">
Institutional Excellence
</p>
<p className="text-red-200 text-xs mt-1">
Certified via Mentor Portal v2.4
</p>
</div>
</div> */}
</div>
</div> </div>
); );
} }
+23 -17
View File
@@ -1,12 +1,15 @@
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { Search, SlidersHorizontal, Plus, Trash2 } from "lucide-react"; import { Search, SlidersHorizontal, Plus, Trash2 } from "lucide-react";
import { useAllInterns, useRemoveMember, useProjects } from "../../../hooks/useProjects"; import { useAllInterns, useRemoveMember, useProjects } from "../../../hooks/useProjects";
import AddInternsModal from "./components/AddInternsToProjects"; import AddInternsModal from "./components/AddInternsToProjects";
import type { InternDetail } from "../../../types/project.types";
import type { Project } from "../../../types/project.types";
export default function InternsManagement() { export default function InternsManagement() {
const [searchEmail, setSearchEmail] = useState(""); const [searchEmail, setSearchEmail] = useState("");
const [selectedProject, setSelectedProject] = useState("All Projects"); const [selectedProject, setSelectedProject] = useState("All Projects");
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
const { interns, loading, error, refetch } = useAllInterns(); const { interns, loading, error, refetch } = useAllInterns();
const { remove, loading: removing } = useRemoveMember(); const { remove, loading: removing } = useRemoveMember();
@@ -23,22 +26,24 @@ export default function InternsManagement() {
}); });
}, [interns, searchEmail, selectedProject]); }, [interns, searchEmail, selectedProject]);
// Hitung statistik dari data asli
const totalInterns = interns.length; const totalInterns = interns.length;
// Active assignments = total member dari semua project yang statusnya active
const activeAssignments = activeProjects.length; const activeAssignments = activeProjects.length;
// Buka modal untuk assign intern tertentu (dari tombol + di baris)
const openAssignFor = (userId: number | null) => {
setSelectedUserId(userId);
setIsModalOpen(true);
};
// Handler hapus intern dari project // Handler hapus intern dari project
// Catatan: useRemoveMember memerlukan id_project & id_user. const handleDeleteIntern = async (intern: InternDetail) => {
// Karena InternDetail tidak menyimpan id_project, aksi ini saat ini hanya placeholder. const matchedProject = activeProjects.find((project: Project) => project.project_name === intern.projectName);
// Untuk implementasi penuh, perlu endpoint khusus atau data id_project dari InternDetail. if (!matchedProject) {
const handleDeleteIntern = async (internId: number) => { console.error("Project not found for intern:", intern);
const confirmed = window.confirm("Yakin ingin menghapus intern ini dari project?"); return;
if (!confirmed) return; }
alert(`Remove intern ID ${internId} — perlu id_project dari backend untuk dilanjutkan.`); const res = await remove({ id_project: matchedProject.id, id_user: intern.id });
// Contoh penggunaan jika id_project tersedia: if (res !== null) refetch();
// const res = await remove({ id_project: intern.id_project, id_user: internId });
// if (res !== null) refetch();
}; };
if (loading) return ( if (loading) return (
@@ -127,7 +132,7 @@ export default function InternsManagement() {
<div className="flex justify-end"> <div className="flex justify-end">
<button <button
className="inline-flex items-center gap-2 px-3 py-1 bg-red-700 text-white rounded-xl shadow-sm hover:bg-red-800 transition-colors focus:outline-none focus:ring-2 focus:ring-red-300" className="inline-flex items-center gap-2 px-3 py-1 bg-red-700 text-white rounded-xl shadow-sm hover:bg-red-800 transition-colors focus:outline-none focus:ring-2 focus:ring-red-300"
onClick={() => setIsModalOpen(true)} onClick={() => openAssignFor(null)}
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
Add Add
@@ -184,9 +189,9 @@ export default function InternsManagement() {
{/* Column 3: Action Buttons Block Right aligned */} {/* Column 3: Action Buttons Block Right aligned */}
<div className="w-1/6 flex items-center justify-end gap-3.5 pr-2"> <div className="w-1/6 flex items-center justify-end gap-3.5 pr-2">
{/* Tombol Add: membuka modal assign member */} {/* Tombol Add: membuka modal assign member untuk intern ini */}
<button <button
onClick={() => setIsModalOpen(true)} onClick={() => openAssignFor(intern.id)}
className="p-1.5 bg-[#B30000] hover:bg-[#990000] text-white rounded-lg transition-colors shadow-sm" className="p-1.5 bg-[#B30000] hover:bg-[#990000] text-white rounded-lg transition-colors shadow-sm"
title="Assign ke project" title="Assign ke project"
> >
@@ -194,7 +199,7 @@ export default function InternsManagement() {
</button> </button>
{/* Tombol Delete: remove intern dari project */} {/* Tombol Delete: remove intern dari project */}
<button <button
onClick={() => handleDeleteIntern(intern.id)} onClick={() => handleDeleteIntern(intern)}
disabled={removing} disabled={removing}
className="p-1.5 text-red-500 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-50" className="p-1.5 text-red-500 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-50"
title="Hapus dari project" title="Hapus dari project"
@@ -233,6 +238,7 @@ export default function InternsManagement() {
{isModalOpen && {isModalOpen &&
<AddInternsModal <AddInternsModal
isOpen={isModalOpen} isOpen={isModalOpen}
userId={selectedUserId}
onClose={() => { onClose={() => {
setIsModalOpen(false); setIsModalOpen(false);
refetch(); // Refresh data setelah modal assign ditutup refetch(); // Refresh data setelah modal assign ditutup
@@ -1,10 +1,10 @@
// ManageInternsModal.tsx // ManageInternsModal.tsx
import { useRef, useEffect, useState } from "react"; import { useRef, useEffect, useState } from "react";
import { Search, Plus, Trash2 } from "lucide-react"; import { Search, Plus, Trash2 } from "lucide-react";
import { useMahasiswa } from "../../../../hooks/useUser"; import { useAssignableInterns } from "../../../../hooks/useProjects";
import { useAssignMember } from '../../../../hooks/useProjects'; import { useAssignMember } from '../../../../hooks/useProjects';
import type { ProjectMember } from '../../../../types/project.types'; import type { ProjectMember } from '../../../../types/project.types';
import type { Mahasiswa } from '../../../../types/project.types'; import type { AssignableIntern } from '../../../../types/project.types';
interface ManageInternsModalProps { interface ManageInternsModalProps {
isOpen: boolean; isOpen: boolean;
@@ -18,7 +18,7 @@ export default function ManageInternsModal({ isOpen, onClose, projectId, initial
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [invitedList, setInvitedList] = useState<ProjectMember[]>(initialMembers); const [invitedList, setInvitedList] = useState<ProjectMember[]>(initialMembers);
const { mahasiswa, loading: loadingMahasiswa } = useMahasiswa(); const { interns, loading: loadingAssignableInterns } = useAssignableInterns();
const { assign, loading: assigning, error } = useAssignMember(); const { assign, loading: assigning, error } = useAssignMember();
// Sync saat modal dibuka // Sync saat modal dibuka
@@ -44,32 +44,33 @@ export default function ManageInternsModal({ isOpen, onClose, projectId, initial
}; };
}, [isOpen, onClose]); }, [isOpen, onClose]);
// Filter mahasiswa yang belum jadi member & sesuai search // Filter assignable interns yang belum jadi member & sesuai search
const invitedIds = new Set(invitedList.map(m => m.id)); const invitedIds = new Set(invitedList.map(m => m.id));
const searchResult = mahasiswa.filter(m => const searchResult = interns.filter(m =>
!invitedIds.has(m.id) && !invitedIds.has(m.id) &&
(m.email.toLowerCase().includes(searchQuery.toLowerCase()) || (m.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
`${m.nama_depan} ${m.nama_belakang}`.toLowerCase().includes(searchQuery.toLowerCase())) `${m.full_name}`.toLowerCase().includes(searchQuery.toLowerCase()))
); );
const handleAssign = async (intern: Mahasiswa) => { const handleAssign = async (intern: AssignableIntern) => {
const result = await assign({ id_project: projectId, id_user: intern.id }); const result = await assign({ id_project: projectId, id_user: intern.id });
if (result) { if (result) {
setInvitedList(prev => [...prev, { setInvitedList(prev => [...prev, {
id: intern.id, id: intern.id,
id_user: intern.id, id_user: intern.id,
full_name: `${intern.nama_depan} ${intern.nama_belakang}`, // full_name: `${intern.nama_depan} ${intern.nama_belakang}`,
full_name: intern.full_name,
email: intern.email, email: intern.email,
avatar: "", avatar: "",
kelompok_peminatan: intern.jurusan, kelompok_peminatan: intern.kelompok_peminatan,
professional_bio: "", professional_bio: "",
position: intern.role, position: intern.position,
} as ProjectMember]); } as ProjectMember]);
} }
}; };
const getInitials = (m: Mahasiswa) => const getInitials = (m: AssignableIntern) =>
`${m.nama_depan[0]}${m.nama_belakang[0]}`.toUpperCase(); m.full_name.toUpperCase();
return ( return (
<dialog ref={dialogRef} className="custom-dialog"> <dialog ref={dialogRef} className="custom-dialog">
@@ -95,13 +96,9 @@ export default function ManageInternsModal({ isOpen, onClose, projectId, initial
{invitedList.map((member) => ( {invitedList.map((member) => (
<div key={member.id} className="flex items-center justify-between p-2 rounded-xl hover:bg-gray-50 transition-colors"> <div key={member.id} className="flex items-center justify-between p-2 rounded-xl hover:bg-gray-50 transition-colors">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{member.avatar ? (
<img src={member.avatar} alt={member.full_name} className="w-9 h-9 rounded-full object-cover" />
) : (
<div className="w-9 h-9 rounded-full bg-gray-200 border border-gray-300 flex items-center justify-center text-xs font-bold text-gray-500"> <div className="w-9 h-9 rounded-full bg-gray-200 border border-gray-300 flex items-center justify-center text-xs font-bold text-gray-500">
{member.full_name.slice(0, 2).toUpperCase()} {member.full_name.slice(0, 2).toUpperCase()}
</div> </div>
)}
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-sm font-medium text-gray-700">{member.full_name}</span> <span className="text-sm font-medium text-gray-700">{member.full_name}</span>
<span className="text-xs text-gray-400">{member.email}</span> <span className="text-xs text-gray-400">{member.email}</span>
@@ -117,17 +114,17 @@ export default function ManageInternsModal({ isOpen, onClose, projectId, initial
))} ))}
{/* Hasil pencarian — belum jadi member */} {/* Hasil pencarian — belum jadi member */}
{loadingMahasiswa ? ( {loadingAssignableInterns ? (
<p className="text-sm text-gray-400 text-center py-2">Memuat...</p> <p className="text-sm text-gray-400 text-center py-2">Memuat...</p>
) : ( ) : (
searchResult.map((intern) => ( searchResult.map((intern: AssignableIntern) => (
<div key={intern.id} className="flex items-center justify-between p-2 rounded-xl hover:bg-gray-50 transition-colors"> <div key={intern.id} className="flex items-center justify-between p-2 rounded-xl hover:bg-gray-50 transition-colors">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-gray-200 border border-gray-300 flex items-center justify-center text-xs font-bold text-gray-500"> <div className="w-9 h-9 rounded-full bg-gray-200 border border-gray-300 flex items-center justify-center text-xs font-bold text-gray-500">
{getInitials(intern)} {getInitials(intern)}
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-sm font-medium text-gray-700">{intern.nama_depan} {intern.nama_belakang}</span> <span className="text-sm font-medium text-gray-700">{intern.full_name}</span>
<span className="text-xs text-gray-400">{intern.email}</span> <span className="text-xs text-gray-400">{intern.email}</span>
</div> </div>
</div> </div>
@@ -142,7 +139,7 @@ export default function ManageInternsModal({ isOpen, onClose, projectId, initial
)) ))
)} )}
{!loadingMahasiswa && searchQuery && searchResult.length === 0 && ( {!loadingAssignableInterns && searchQuery && searchResult.length === 0 && (
<p className="text-sm text-gray-400 text-center py-2">Tidak ada mahasiswa ditemukan.</p> <p className="text-sm text-gray-400 text-center py-2">Tidak ada mahasiswa ditemukan.</p>
)} )}
</div> </div>
@@ -1,23 +1,26 @@
import { useRef, useEffect, useState } from "react"; import { useRef, useEffect, useState } from "react";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { LuUserPlus, LuMail, LuFolderOpen } from "react-icons/lu"; import { LuUserPlus, LuUser, LuFolderOpen } from "react-icons/lu";
import { useAssignMember, useProjects } from "../../../../hooks/useProjects"; import { useAssignMember, useProjects } from "../../../../hooks/useProjects";
import { useAssignableInterns } from "../../../../hooks/useProjects";
interface AddInternsModalProps { interface AddInternsModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
userId: number | null;
} }
export default function AddInternsModal({ isOpen, onClose }: AddInternsModalProps) { export default function AddInternsModal({ isOpen, onClose, userId }: AddInternsModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null); const dialogRef = useRef<HTMLDialogElement>(null);
// Form States // Form States
const [email, setEmail] = useState(""); const [selectedUserId, setSelectedUserId] = useState<string>("");
const [selectedProjectId, setSelectedProjectId] = useState<string>(""); const [selectedProjectId, setSelectedProjectId] = useState<string>("");
// Hooks // Hooks
const { assign, loading: assigning, error: assignError } = useAssignMember(); const { assign, loading: assigning, error: assignError } = useAssignMember();
const { projects, loading: loadingProjects } = useProjects("active"); const { projects, loading: loadingProjects } = useProjects("active");
const { interns, loading: loadingInterns } = useAssignableInterns();
useEffect(() => { useEffect(() => {
const dialog = dialogRef.current; const dialog = dialogRef.current;
@@ -43,30 +46,26 @@ export default function AddInternsModal({ isOpen, onClose }: AddInternsModalProp
}; };
}, [isOpen, onClose]); }, [isOpen, onClose]);
// Reset form saat modal dibuka kembali // Reset form saat modal dibuka kembali.
// Kalau userId diberikan, dropdown langsung ter-select ke intern tsb.
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setEmail(""); setSelectedUserId(userId !== null ? String(userId) : "");
setSelectedProjectId(""); setSelectedProjectId("");
} }
}, [isOpen]); }, [isOpen, userId]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!selectedUserId) return;
// Catatan: useAssignMember memerlukan id_user (number), bukan email. const res = await assign({
// Endpoint /project-api/assign-member menerima { id_project: number, id_user: number }. id_project: Number(selectedProjectId),
// Untuk implementasi berbasis email, perlu endpoint tambahan atau lookup user by email. id_user: Number(selectedUserId),
// Saat ini form ini dikomen karena tidak ada lookup email → id_user di service. });
if (res !== null) {
// Contoh penggunaan jika id_user sudah diketahui: onClose();
// const res = await assign({ id_project: Number(selectedProjectId), id_user: userId }); }
// if (res !== null) {
// onClose();
// }
alert(`Assign intern "${email}" ke project ID ${selectedProjectId} — perlu lookup id_user dari email via backend.`);
onClose();
}; };
return ( return (
@@ -92,23 +91,36 @@ export default function AddInternsModal({ isOpen, onClose }: AddInternsModalProp
{/* Form Body */} {/* Form Body */}
<form onSubmit={handleSubmit} className="bg-gray-100 px-6 py-6 space-y-4 overflow-y-auto flex-1"> <form onSubmit={handleSubmit} className="bg-gray-100 px-6 py-6 space-y-4 overflow-y-auto flex-1">
{/* Email Address */} {/* Intern Selection */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-bold tracking-wide text-foreground"> <label className="text-xs font-bold tracking-wide text-foreground">
Email Address Intern
</label> </label>
<div className="relative"> <div className="relative">
<span className="absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none text-border"> <span className="absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none text-border">
<LuMail className="w-4 h-4 stroke-[2]" /> <LuUser className="w-4 h-4 stroke-[2]" />
</span> </span>
<input <select
type="email"
required required
placeholder="intern.name@gmail.com" value={selectedUserId}
value={email} onChange={(e) => setSelectedUserId(e.target.value)}
onChange={(e) => setEmail(e.target.value)} disabled={loadingInterns}
className="w-full pl-10 pr-4 py-2.5 bg-white text-sm font-medium border border-border rounded-radius text-foreground focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all" className="w-full pl-10 pr-9 py-2.5 bg-white text-sm font-medium appearance-none cursor-pointer border border-border rounded-radius focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all text-foreground disabled:opacity-60"
/> >
<option value="" disabled>
{loadingInterns ? "Memuat interns..." : "Pilih intern"}
</option>
{interns.map((intern) => (
<option key={intern.id} value={intern.id}>
{intern.full_name}
</option>
))}
</select>
<span className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-border">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</span>
</div> </div>
</div> </div>
@@ -145,12 +157,6 @@ export default function AddInternsModal({ isOpen, onClose }: AddInternsModalProp
</div> </div>
</div> </div>
{/* Field Position dikomentari — tidak ada dalam payload assign-member */}
{/* <div className="space-y-1.5">
<label className="text-xs font-bold tracking-wide text-foreground">Position</label>
<select>...</select>
</div> */}
{assignError && ( {assignError && (
<p className="text-xs text-red-600 font-medium">{assignError}</p> <p className="text-xs text-red-600 font-medium">{assignError}</p>
)} )}
@@ -0,0 +1,186 @@
import { useState } from "react";
import {
Search, Eye, Download, Share2,
ChevronLeft, ChevronRight, Loader2,
} from "lucide-react";
import type { Certificate } from "../../../../types/certificate.types";
import {
previewCertificate,
downloadCertificatePdf,
} from "../../../utils/Certificates";
const PER_PAGE = 5;
const getInitials = (name: string): string => {
const parts = name.trim().split(/\s+/);
if (parts.length === 1) return parts[0][0].toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
};
function Avatar({ name }: { name: string }) {
return (
<div className="w-9 h-9 rounded-full flex items-center justify-center text-[11px] font-black flex-shrink-0 bg-gray-100 text-gray-600">
{getInitials(name)}
</div>
);
}
interface CertificateTableProps {
certificates: Certificate[];
templateUrl: string;
}
export default function CertResultTable({ certificates, templateUrl }: CertificateTableProps) {
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [downloadingId, setDownloadingId] = useState<number | null>(null);
const handlePreview = (cert: Certificate) => {
previewCertificate(cert, templateUrl);
};
const handleDownloadPdf = async (cert: Certificate) => {
setDownloadingId(cert.id);
try {
await downloadCertificatePdf(cert, templateUrl);
} catch (err) {
console.error("Gagal download PDF:", err);
} finally {
setDownloadingId(null);
}
};
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
setPage(1);
};
const filtered = certificates.filter(
(c) =>
c.user.full_name.toLowerCase().includes(search.toLowerCase()) ||
c.user.email.toLowerCase().includes(search.toLowerCase())
);
const totalPages = Math.ceil(filtered.length / PER_PAGE);
const paginated = filtered.slice((page - 1) * PER_PAGE, page * PER_PAGE);
return (
<div className="border border-gray-200 rounded-2xl overflow-hidden bg-white shadow-sm">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<h3 className="text-sm font-bold text-gray-900">Recipient Directory</h3>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
<input
type="text"
placeholder="Filter interns..."
value={search}
onChange={handleSearch}
className="pl-8 pr-3 py-1.5 text-xs border border-card-outline rounded-lg bg-white text-font-shade placeholder-font focus:outline-none focus:border-red-400 focus:ring-1 focus:ring-red-400 w-44 transition-colors"
/>
</div>
</div>
{/* Column Headers */}
<div className="grid grid-cols-[2fr_1.5fr_auto] px-5 py-2.5 bg-gray-50 border-b border-gray-100">
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Intern Name</span>
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Credential ID</span>
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Actions</span>
</div>
{/* Rows */}
<div className="divide-y divide-gray-50">
{paginated.length === 0 ? (
<div className="px-5 py-10 text-center text-sm text-gray-400">
No interns match your search.
</div>
) : (
paginated.map((cert) => (
<div
key={cert.id}
className="grid grid-cols-[2fr_1.5fr_auto] items-center px-5 py-3.5 hover:bg-gray-50/60 transition-colors"
>
{/* Name */}
<div className="flex items-center gap-3">
<Avatar name={cert.user.full_name} />
<div>
<p className="text-sm font-bold text-gray-900 leading-tight">{cert.user.full_name}</p>
</div>
</div>
{/* Credential ID */}
<p className="text-xs font-mono text-gray-500 tracking-wide">{cert.uuid}</p>
{/* Actions */}
<div className="flex items-center gap-1">
<button
onClick={() => handlePreview(cert)}
className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-md transition-colors"
title="Preview"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => handleDownloadPdf(cert)}
disabled={downloadingId === cert.id}
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-md transition-colors disabled:opacity-40"
title="Download PDF"
>
{downloadingId === cert.id
? <Loader2 className="w-4 h-4 animate-spin" />
: <Download className="w-4 h-4" />
}
</button>
<button
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-md transition-colors"
title="Share"
>
<Share2 className="w-4 h-4" />
</button>
</div>
</div>
))
)}
</div>
{/* Pagination */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 px-5 py-3.5 border-t border-gray-100 bg-gray-50/30">
<p className="text-xs text-gray-400 font-medium">
Showing {filtered.length === 0 ? 0 : (page - 1) * PER_PAGE + 1} to{" "}
{Math.min(page * PER_PAGE, filtered.length)} of {certificates.length} interns
</p>
<div className="flex items-center gap-1">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="p-1 border border-gray-200 rounded-md bg-white text-gray-400 hover:bg-gray-50 disabled:opacity-40 transition-colors"
>
<ChevronLeft className="w-4 h-4" />
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (
<button
key={n}
onClick={() => setPage(n)}
className={`w-7 h-7 flex items-center justify-center rounded-md text-xs font-bold transition-colors ${
n === page
? "bg-[#B30000] text-white shadow-sm"
: "bg-white border border-gray-200 text-gray-600 hover:bg-gray-50"
}`}
>
{n}
</button>
))}
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages || totalPages === 0}
className="p-1 border border-gray-200 rounded-md bg-white text-gray-400 hover:bg-gray-50 disabled:opacity-40 transition-colors"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
</div>
);
}
@@ -3,13 +3,15 @@ import { X } from "lucide-react";
import { PROJECT_ICON_MAP, PROJECT_ICON_CODES } from "../../../../lib/ProjectIcons"; import { PROJECT_ICON_MAP, PROJECT_ICON_CODES } from "../../../../lib/ProjectIcons";
import { LuSendHorizontal, LuMail } from "react-icons/lu"; import { LuSendHorizontal, LuMail } from "react-icons/lu";
import { useCreateProject } from "../../../../hooks/useProjects"; import { useCreateProject } from "../../../../hooks/useProjects";
import type { CreateProjectPayload } from "../../../../types/project.types";
interface CreateProjectModalProps { interface CreateProjectModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onSuccess?: () => void;
} }
export default function CreateProjectModal({ isOpen, onClose }: CreateProjectModalProps) { export default function CreateProjectModal({ isOpen, onClose, onSuccess }: CreateProjectModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null); const dialogRef = useRef<HTMLDialogElement>(null);
const [selectedIcon, setSelectedIcon] = useState(PROJECT_ICON_CODES[0]); const [selectedIcon, setSelectedIcon] = useState(PROJECT_ICON_CODES[0]);
const [projectName, setProjectName] = useState(""); const [projectName, setProjectName] = useState("");
@@ -61,23 +63,23 @@ export default function CreateProjectModal({ isOpen, onClose }: CreateProjectMod
setInvitedEmails(invitedEmails.filter(email => email !== emailToRemove)); setInvitedEmails(invitedEmails.filter(email => email !== emailToRemove));
}; };
// Handler Submit Utama
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const payload: any = { const payload: CreateProjectPayload = {
project_icon: selectedIcon, project_icon: selectedIcon,
project_name: projectName, project_name: projectName,
description: projectDescription, description: projectDescription,
start_date: startDate, start_date: startDate,
end_date: endDate, end_date: endDate,
}; };
if (invitedEmails.length > 0) { if (invitedEmails.length > 0) {
payload.member_emails = invitedEmails; payload.member_emails = invitedEmails;
} }
const result = await create(payload); const result = await create(payload);
if (result) { if (result) {
onSuccess?.(); // panggil refetch di parent (kalau ada)
onClose(); onClose();
} }
}; };
@@ -15,6 +15,7 @@ import type { InternSubmissionProgress } from "../../../../hooks/useInternProgre
import { useProjectDetail } from "../../../../hooks/useProjects"; import { useProjectDetail } from "../../../../hooks/useProjects";
import JSZip from "jszip"; import JSZip from "jszip";
import { generateCertificate } from "../../../utils/SertificateGenerator"; import { generateCertificate } from "../../../utils/SertificateGenerator";
import { useGenerateCertificates } from "../../../../hooks/useCertificates";
import Avatar from "../../Avatar"; import Avatar from "../../Avatar";
import ProgressBar from "../../ProgressBar"; import ProgressBar from "../../ProgressBar";
@@ -47,20 +48,19 @@ export default function EligibleInternTable({
}: EligibleInternTableProps) { }: EligibleInternTableProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { project } = useProjectDetail(String(projectId)); const { project } = useProjectDetail(String(projectId));
const { generate: SubmitCertificates, loading: generatingCertificates, error, success, reset } = useGenerateCertificates();
const initialSelected = new Set( const initialSelected = new Set(
interns.filter(i => i.is_eligible).map(i => i.id_user) interns.filter(i => i.is_eligible).map(i => i.id_user)
); );
// Pemilihan Id
const [selected, setSelected] = useState<Set<number>>(initialSelected); const [selected, setSelected] = useState<Set<number>>(initialSelected);
// state saat generate sertifikat
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
const [generateError, setGenerateError] = useState<string | null>(null); const [generateError, setGenerateError] = useState<string | null>(null);
// Halaman (Pagination) // Halaman (Pagination)
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const totalPages = Math.ceil(interns.length / PER_PAGE); const totalPages = Math.ceil(interns.length / PER_PAGE);
const paginated = interns.slice((page - 1) * PER_PAGE, page * PER_PAGE); const paginated = interns.slice((page - 1) * PER_PAGE, page * PER_PAGE);
const eligibleIds = interns.filter(i => i.is_eligible).map(i => i.id_user); const eligibleIds = interns.filter(i => i.is_eligible).map(i => i.id_user);
const allEligibleSelected = eligibleIds.every(id => selected.has(id)); const allEligibleSelected = eligibleIds.every(id => selected.has(id));
@@ -78,56 +78,48 @@ export default function EligibleInternTable({
}; };
const handleGenerate = async () => { const handleGenerate = async () => {
// NOTE: DI RESPONSE GAK DIKASIH TAHU DIMANA SIMPAN TEMPLATE SERTIF NYA if (!project?.certificate_template) {
if (!project?.certificate_template) { setGenerateError("Template sertifikat belum diupload.");
setGenerateError("Template sertifikat belum diupload."); return;
return; }
} if (selected.size === 0) {
if (selected.size === 0) { setGenerateError("Pilih minimal satu intern.");
setGenerateError("Pilih minimal satu intern."); return;
return; }
}
setGenerating(true); setGenerating(true);
setGenerateError(null); setGenerateError(null);
try { try {
const selectedInterns = interns.filter((i) => selected.has(i.id_user)); // 1. Generate blob PNG per intern & kumpulkan ke ZIP
const zip = new JSZip(); const selectedInterns = interns.filter((i) => selected.has(i.id_user));
const zip = new JSZip();
// Generate semua sertifikat secara paralel await Promise.all(
await Promise.all( selectedInterns.map(async (intern) => {
selectedInterns.map(async (intern) => { const blob = await generateCertificate(
const blob = await generateCertificate( project.certificate_template,
project.certificate_template, intern.full_name
intern.full_name );
); zip.file(`Sertifikat - ${intern.full_name}.png`, blob);
// Nama file: "Sertifikat - Nama Intern.png" })
const filename = `Sertifikat - ${intern.full_name}.png`; );
zip.file(filename, blob);
})
);
// Download ZIP // 2. POST ke backend untuk mencatat sertifikat sudah di-generate
const zipBlob = await zip.generateAsync({ type: "blob" }); const ok = await SubmitCertificates(projectId, Array.from(selected));
const url = URL.createObjectURL(zipBlob); if (!ok) return;
const a = document.createElement("a");
a.href = url;
a.download = `Sertifikat-Project-${projectId}.zip`;
a.click();
URL.revokeObjectURL(url);
// Navigasi ke result page // 3. Navigasi ke result page
navigate(`/mentor/certificates/${projectId}/result`); navigate(`/mentor/certificates/${projectId}/result`);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
setGenerateError( setGenerateError(
err instanceof Error ? err.message : "Gagal generate sertifikat." err instanceof Error ? err.message : "Gagal generate sertifikat."
); );
} finally { } finally {
setGenerating(false); setGenerating(false);
} }
}; };
if (loading) { if (loading) {
return ( return (
@@ -3,6 +3,14 @@ import { useState } from 'react';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import CreateProjectModal from './CreateProjectDialog'; import CreateProjectModal from './CreateProjectDialog';
import { useProjects } from '../../../../hooks/useProjects'; import { useProjects } from '../../../../hooks/useProjects';
import * as LucideIcons from 'lucide-react';
import type { LucideProps } from 'lucide-react';
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 HomeMentorProject = () => { const HomeMentorProject = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -36,7 +44,11 @@ const HomeMentorProject = () => {
onClick={() => navigate(`/mentor/projects/${project.slug}`)} onClick={() => navigate(`/mentor/projects/${project.slug}`)}
className="flex items-center gap-4 border border-box-border rounded-lg px-4 py-3 hover:bg-gray-50 cursor-pointer transition-colors duration-150" className="flex items-center gap-4 border border-box-border 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-gray-300"> <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>
<div> <div>
<p className="text-sm font-semibold text-gray-800">{project.project_name}</p> <p className="text-sm font-semibold text-gray-800">{project.project_name}</p>
@@ -6,10 +6,10 @@ import type { Project } from '../../../../types/project.types';
import { useArchiveProject } from '../../../../hooks/useProjects'; import { useArchiveProject } from '../../../../hooks/useProjects';
const getDynamicIcon = (iconName: string) => { const getDynamicIcon = (iconName: string) => {
const formatted = iconName.charAt(0).toUpperCase() + iconName.slice(1); const formatted = iconName.charAt(0).toUpperCase() + iconName.slice(1);
const Icon = (LucideIcons as unknown as Record<string, React.ComponentType<LucideProps>>)[formatted]; const Icon = (LucideIcons as unknown as Record<string, React.ComponentType<LucideProps>>)[formatted];
return Icon ?? LucideIcons.FolderOpen; return Icon ?? LucideIcons.FolderOpen;
}; };
const toSlug = (name: string) => name.toLowerCase().replace(/\s+/g, '-'); const toSlug = (name: string) => name.toLowerCase().replace(/\s+/g, '-');
@@ -61,8 +61,20 @@ export default function MentorProjectCard(project: Project) {
{project.total_members}/{project.total_members} {project.total_members}/{project.total_members}
</span> </span>
</div> </div>
<div className="flex items-center text-xs text-gray-500 gap-1">
{project.status === "active" ? (
<span className="px-2 py-0.5 bg-green-100 text-green-800 rounded-full">Active</span>
) : project.status === "completed" ? (
<span className="px-2 py-0.5 bg-blue-100 text-blue-800 rounded-full">Completed</span>
) : project.status === "archived" ? (
<span className="px-2 py-0.5 bg-yellow-100 text-yellow-800 rounded-full">Archived</span>
) : (
<span className="px-2 py-0.5 bg-gray-100 text-gray-800 rounded-full">Inactive</span>
)}
</div>
</div> </div>
{/* Footer */} {/* Footer */}
<div className="border-t border-gray-300 text-gray-400 flex justify-end p-2 bg-gray-50/50"> <div className="border-t border-gray-300 text-gray-400 flex justify-end p-2 bg-gray-50/50">
<button <button
@@ -66,7 +66,7 @@ export default function SidebarMentor({ isOpen, closeSidebar }: SidebarProps) {
{/* Main nav */} {/* Main nav */}
<nav className="flex flex-col gap-0.5"> <nav className="flex flex-col gap-0.5">
{navItems.map(({ label, icon: Icon, path }) => { {navItems.map(({ label, icon: Icon, path }) => {
const isActive = location.pathname === path; const isActive = location.pathname === path || location.pathname.startsWith(path + "/");
return ( return (
<button <button
key={path} key={path}
+180
View File
@@ -0,0 +1,180 @@
import jsPDF from "jspdf";
import JSZip from "jszip";
import type { Certificate } from "../../types/certificate.types";
import { generateCertificate, resolveImageUrl } from "./SertificateGenerator";
// ── 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(
resolveImageUrl(templateUrl),
cert.user.full_name
);
const url = URL.createObjectURL(blob);
if (!tab) {
const a = document.createElement("a");
a.href = url;
a.target = "_blank";
a.click();
return;
}
tab.document.write(`
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8" />
<title>Sertifikat - ${cert.user.full_name}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #1a1a1a;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
font-family: sans-serif;
}
p { color: #aaa; font-size: 13px; letter-spacing: 0.05em; }
small { color: #555; font-size: 11px; font-family: monospace; }
img {
max-width: 90vw;
max-height: 82vh;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 8px 40px rgba(0,0,0,0.6);
}
</style>
</head>
<body>
<p>${cert.user.full_name.toUpperCase()}</p>
<small>${cert.certificate_no}</small>
<img src="${url}" alt="Sertifikat ${cert.user.full_name}"
onload="URL.revokeObjectURL(this.src)" />
</body>
</html>
`);
tab.document.close();
};
// ── 2. Download PDF ───────────────────────────────────────────────────────────
export const downloadCertificatePdf = async (
cert: Certificate,
templateUrl: string
): Promise<void> => {
const fileName = `Sertifikat - ${cert.user.full_name} - ${cert.certificate_no}`;
const blob = await generateCertificate(
resolveImageUrl(templateUrl),
cert.user.full_name
);
const dataUrl: string = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
const img = new Image();
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = reject;
img.src = dataUrl;
});
const orientation = img.width > img.height ? "landscape" : "portrait";
const pdf = new jsPDF({ orientation, unit: "px", format: [img.width, img.height] });
pdf.addImage(dataUrl, "PNG", 0, 0, img.width, img.height);
pdf.save(`${fileName}.pdf`);
};
// ── 3. Download PNG atau JPG ──────────────────────────────────────────────────
export const downloadCertificateImage = async (
cert: Certificate,
templateUrl: string,
format: "png" | "jpg" = "png"
): Promise<void> => {
const fileName = `Sertifikat - ${cert.user.full_name} - ${cert.certificate_no}`;
const blob = await generateCertificate(
resolveImageUrl(templateUrl),
cert.user.full_name
);
let finalBlob = blob;
if (format === "jpg") {
const dataUrl: string = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
const img = new Image();
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = reject;
img.src = dataUrl;
});
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
finalBlob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error("toBlob gagal"))),
"image/jpeg",
0.95
);
});
}
const url = URL.createObjectURL(finalBlob);
const a = document.createElement("a");
a.href = url;
a.download = `${fileName}.${format}`;
a.click();
URL.revokeObjectURL(url);
};
// ── 4. Download All ZIP ───────────────────────────────────────────────────────
export const downloadAllCertificatesZip = async (
certificates: Certificate[],
templateUrl: string,
projectName: string,
onProgress?: (done: number, total: number) => void
): Promise<void> => {
const zip = new JSZip();
const total = certificates.length;
await Promise.all(
certificates.map(async (cert, i) => {
const blob = await generateCertificate(
resolveImageUrl(templateUrl),
cert.user.full_name
);
zip.file(`Sertifikat - ${cert.user.full_name}.png`, blob);
onProgress?.(i + 1, total);
})
);
const zipBlob = await zip.generateAsync({ type: "blob" });
const a = document.createElement("a");
a.href = URL.createObjectURL(zipBlob);
a.download = `Sertifikat - ${projectName}.zip`;
a.click();
URL.revokeObjectURL(a.href);
};
-85
View File
@@ -1,85 +0,0 @@
// utils/certificateDownload.ts
import html2canvas from "html2canvas";
import jsPDF from "jspdf";
/**
* Helper: capture sebuah HTMLElement menjadi canvas
*/
export const captureCertificateElement = async (
element: HTMLElement
): Promise<HTMLCanvasElement> => {
return html2canvas(element, {
useCORS: true,
allowTaint: false,
backgroundColor: "#ffffff",
scale: 2, // hasil lebih tajam
});
};
/**
* Download elemen sertifikat sebagai PNG/JPG
*/
export const downloadCertificateAsImage = async (
element: HTMLElement | null,
fileName: string,
format: "png" | "jpg" = "png",
fallbackImageUrl?: string
): Promise<void> => {
try {
if (!element) throw new Error("Elemen sertifikat tidak ditemukan");
const canvas = await captureCertificateElement(element);
const mimeType = format === "jpg" ? "image/jpeg" : "image/png";
const quality = format === "jpg" ? 0.95 : 1;
const dataUrl = canvas.toDataURL(mimeType, quality);
const a = document.createElement("a");
a.href = dataUrl;
a.download = `${fileName}.${format}`;
a.click();
} catch (error) {
console.error("Gagal mengunduh sertifikat sebagai gambar:", error);
// fallback: download langsung dari image_path kalau html2canvas gagal (mis. CORS)
if (fallbackImageUrl) {
const a = document.createElement("a");
a.href = fallbackImageUrl;
a.download = `${fileName}.png`;
a.target = "_blank";
a.click();
}
}
};
/**
* Download elemen sertifikat sebagai PDF
*/
export const downloadCertificateAsPdf = async (
element: HTMLElement | null,
fileName: string
): Promise<void> => {
try {
if (!element) throw new Error("Elemen sertifikat tidak ditemukan");
const canvas = await captureCertificateElement(element);
const imgData = canvas.toDataURL("image/png", 1.0);
const imgWidth = canvas.width;
const imgHeight = canvas.height;
// Tentukan orientasi PDF berdasarkan rasio gambar
const orientation = imgWidth > imgHeight ? "landscape" : "portrait";
const pdf = new jsPDF({
orientation,
unit: "px",
format: [imgWidth, imgHeight],
});
pdf.addImage(imgData, "PNG", 0, 0, imgWidth, imgHeight);
pdf.save(`${fileName}.pdf`);
} catch (error) {
console.error("Gagal mengunduh sertifikat sebagai PDF:", error);
}
};
-2
View File
@@ -39,12 +39,10 @@ const ProtectedRoutes = ({ allowedRoles }: PrivateRouteProps) => {
} }
if (allowedRoles && !allowedRoles.includes(userRole)) { if (allowedRoles && !allowedRoles.includes(userRole)) {
// Jika role tidak diizinkan, kembalikan ke base dashboard masing-masing role
if (userRole === "admin" || userRole === "mentor") return <Navigate to="/mentor" replace />; if (userRole === "admin" || userRole === "mentor") return <Navigate to="/mentor" replace />;
if (userRole === "intern") return <Navigate to="/intern" replace />; if (userRole === "intern") return <Navigate to="/intern" replace />;
} }
// Jika lolos seleksi, render komponen di dalamnya
return <Outlet />; return <Outlet />;
}; };
+7 -10
View File
@@ -1,11 +1,5 @@
import api from "../../lib/api"; import api from "../../lib/api";
// ── Konstanta posisi nama di template ─────────────────────────────────────────
const NAME_Y_RATIO = 0.55; // posisi Y = 55% dari tinggi canvas
const NAME_FONT_SIZE = 48; // px
const NAME_FONT_FAMILY = "Georgia, serif";
const NAME_COLOR = "#1a1a1a";
// src/utils/certificateGenerator.ts // src/utils/certificateGenerator.ts
export function resolveImageUrl(path: string): string { export function resolveImageUrl(path: string): string {
if (!path) return ""; if (!path) return "";
@@ -31,7 +25,10 @@ export function loadImage(src: string): Promise<HTMLImageElement> {
// ── Generate satu sertifikat sebagai Blob ───────────────────────────────────── // ── Generate satu sertifikat sebagai Blob ─────────────────────────────────────
export async function generateCertificate( export async function generateCertificate(
templateUrl: string, templateUrl: string,
internName: string internName: string,
projectName: string,
uuid: string,
barcodeUrl: string
): Promise<Blob> { ): Promise<Blob> {
const img = await loadImage(templateUrl); const img = await loadImage(templateUrl);
@@ -43,11 +40,11 @@ export async function generateCertificate(
ctx.drawImage(img, 0, 0); ctx.drawImage(img, 0, 0);
// Tulis nama di tengah horizontal, posisi Y dari konstanta // Tulis nama di tengah horizontal, posisi Y dari konstanta
ctx.font = `bold ${NAME_FONT_SIZE}px ${NAME_FONT_FAMILY}`; ctx.font = `bold 48px Georgia, serif`;
ctx.fillStyle = NAME_COLOR; ctx.fillStyle = "#800000";
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.textBaseline = "middle"; ctx.textBaseline = "middle";
ctx.fillText(internName, canvas.width / 2, canvas.height * NAME_Y_RATIO); ctx.fillText(internName, canvas.width / 2, canvas.height * 0.55);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
canvas.toBlob( canvas.toBlob(
+39
View File
@@ -0,0 +1,39 @@
<!-- test-cert.html — buka langsung di browser -->
<!DOCTYPE html>
<html>
<body>
<canvas id="preview"></canvas>
<script>
// GANTI INI UNTUK MENGGUNAKAN TEMPLATE YANG BERBEDA
const templateUrl = "http://localhost:9000/uploads/template.png"; // ganti URL template
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
const canvas = document.getElementById("preview");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0);
// Nama
ctx.font = "bold 48px Georgia, serif";
ctx.fillStyle = "#800000";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("Rafi Athallah", canvas.width / 2, canvas.height * 0.55);
// Project
ctx.font = "24px Georgia, serif";
ctx.fillStyle = "#444444";
ctx.fillText("Internify Platform Dev", canvas.width / 2, canvas.height * 0.65);
// UUID
ctx.font = "16px monospace";
ctx.fillStyle = "#888888";
ctx.fillText("CERT/20260609/PRJ2/USR5", canvas.width / 2, canvas.height * 0.88);
};
img.src = templateUrl;
</script>
</body>
</html>
+1 -21
View File
@@ -1,13 +1,12 @@
// hooks/useCertificates.ts // hooks/useCertificates.ts
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import type { Certificate, CertificateVerifyResult } from "../types/certificate.types"; import type { Certificate } from "../types/certificate.types";
import { import {
claimCertificate, claimCertificate,
getMyCertificates, getMyCertificates,
getAllCertificates, getAllCertificates,
getCertificateDetail, getCertificateDetail,
getCertificatesByProject, getCertificatesByProject,
verifyCertificateByUuid,
uploadCertificateTemplate, uploadCertificateTemplate,
generateCertificate generateCertificate
} from "../services/CertificateService"; } from "../services/CertificateService";
@@ -87,25 +86,6 @@ export const useProjectCertificates = (id_project: number) => {
return { certificates, loading, error, refetch }; return { certificates, loading, error, refetch };
}; };
// GET /certificate-api/verify & verify-uuid (public, on-demand)
export const useVerifyCertificate = () => {
const [result, setResult] = useState<CertificateVerifyResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const verifyByUuid = useCallback(async (uuid: string) => {
setLoading(true); setError(null);
try {
const res = await verifyCertificateByUuid(uuid);
setResult(res);
return res;
} catch { setError("Sertifikat tidak valid atau tidak ditemukan."); return null; }
finally { setLoading(false); }
}, []);
return { result, loading, error, verifyByUuid };
};
// POST /certificate-api/claim // POST /certificate-api/claim
export const useClaimCertificate = () => { export const useClaimCertificate = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
-1
View File
@@ -1 +0,0 @@
+52
View File
@@ -0,0 +1,52 @@
import { useEffect, useState } from 'react';
import { useCallback } from 'react';
import { useCurrentUser } from './useUser';
import type { Project } from '../types/project.types';
import { getProjects, getMentorProjects } from '../services/ProjectService';
export const useProjectsByRole = (
status?: "active" | "completed" | "archived"
) => {
const {
user,
loading: userLoading,
error: userError,
} = useCurrentUser();
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refetch = useCallback(() => {
if (!user) return;
setLoading(true);
setError(null);
const fetcher =
user.role === "admin"
? getProjects(status)
: user.role === "mentor"
? getMentorProjects()
: Promise.resolve([]);
fetcher
.then(setProjects)
.catch(() => setError("Gagal memuat projects."))
.finally(() => setLoading(false));
}, [user, status]);
useEffect(() => {
if (!userLoading && user) {
refetch();
}
}, [userLoading, user, refetch]);
return {
projects,
loading: userLoading || loading,
error: userError || error,
refetch,
role: user?.role ?? null,
};
};
+1 -3
View File
@@ -96,6 +96,4 @@ export const useMahasiswa = () => {
useEffect(() => { refetch(); }, [refetch]); useEffect(() => { refetch(); }, [refetch]);
return { mahasiswa, loading, error, refetch }; return { mahasiswa, loading, error, refetch };
}; };
export const useAssignableInterns = () => {}
+1 -1
View File
@@ -1,6 +1,6 @@
// services/UserService.ts // services/UserService.ts
import api from "../lib/api"; import api from "../lib/api";
import type { CurrentUser, UpdateProfilePayload } from "../hooks/useUser"; import type { CurrentUser, UpdateProfilePayload } from "../types/user.types";
import type { Mahasiswa, MahasiswaResponse } from "../types/project.types" import type { Mahasiswa, MahasiswaResponse } from "../types/project.types"
// POST /auth-api/login // POST /auth-api/login
+2 -21
View File
@@ -1,5 +1,3 @@
// types/certificate.types.ts
export interface CertificateUser { export interface CertificateUser {
id: number; id: number;
full_name: string; full_name: string;
@@ -17,38 +15,21 @@ export interface Certificate {
id_project: number; id_project: number;
id_user: number; id_user: number;
certificate_no: string; certificate_no: string;
image_path: string
issued_at: string; issued_at: string;
uuid: string; uuid: string;
user: CertificateUser; user: CertificateUser;
project: CertificateProject; project: CertificateProject;
certificate_status: string;
} }
export interface CertificateVerifyResult { // Khusus GET /certificate-api/verify-uuid/{uuid}
export interface CertificateVerification {
uuid: string; uuid: string;
intern_name: string; intern_name: string;
project_name: string; project_name: string;
created_at: string; created_at: string;
} }
export interface GenerateCertificatePayload {
id_project: number;
id_users: number[];
}
export interface CertificateTemplate { export interface CertificateTemplate {
id_project: number; id_project: number;
certificate_template: string; certificate_template: string;
}
// Notification
export interface Notification {
id: number;
id_user: number;
id_admin: number | null;
title: string;
message: string;
is_read: boolean;
created_at: string;
} }
+1 -1
View File
@@ -74,7 +74,7 @@ export interface AssignableIntern {
id: number; id: number;
full_name: string; full_name: string;
email: string; email: string;
profesional_bio: string; professional_bio: string;
position: string; position: string;
kelompok_peminatan: string; kelompok_peminatan: string;
is_active: boolean; is_active: boolean;