[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 { InternLayout, MentorLayout } from "./Pages/Internify/layout";
import ProjectsPage from "./Pages/Internify/intern/ProjectsPage";
import SertificatePage from "./Pages/Internify/intern/InternCertiDetail";
import ProjectDetailPage from "./Pages/Internify/intern/ProjectDetailPage";
import HomeMentorPage from "./Pages/Internify/mentor/HomeMentorPage";
import MentorProjectsPage from "./Pages/Internify/mentor/BaseProjectsPage";
@@ -54,6 +53,7 @@ import AdminMentorPage from "./Pages/MentorPage";
import CreateMentorForm from "./Pages/CreateMentorForm";
import EditMentorForm from "./Pages/EditMentorForm";
import NotFoundPage from "./Pages/NotFoundPage";
import InternCertificateDetail from "./Pages/Internify/intern/InternCertDetail";
function App() {
return (
@@ -103,7 +103,7 @@ function App() {
<Route index element={<HomeInternPage />} />
<Route path="projects" element={<ProjectsPage />} />
<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/:taskSlug" element={<TaskSubmission />} />
<Route path="faq" element={<FAQPage />} />
@@ -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}
@@ -2,20 +2,23 @@ import MentorProjectCard from './components/MentorProjectCard';
import { Plus } from 'lucide-react';
import { useState } from 'react';
import CreateProjectModal from './components/CreateProjectDialog';
import { useProjects } from '../../../hooks/useProjects';
import { useProjectsByRole } from '../../../hooks/useListProjects';
export default function MentorProjectsPage() {
const [isModalOpen, setIsModalOpen] = useState(false);
const { projects, loading, error } = useProjects("active");
const { projects, loading, error, refetch } = useProjectsByRole();
const openDialog = () => {
setIsModalOpen(true);
};
const closeDialog = () => {
setIsModalOpen(false);
};
const handleRefetch = () => {
closeDialog();
refetch();
}
if (loading) return (
<div className="flex flex-col gap-3">
@@ -56,6 +59,7 @@ export default function MentorProjectsPage() {
<CreateProjectModal
isOpen={isModalOpen}
onClose={closeDialog}
onSuccess={handleRefetch}
/>
)}
</div>
+52 -249
View File
@@ -1,132 +1,44 @@
// src/pages/mentor/CertificateResult.tsx
import { useState } from "react";
import {
CheckCircle2,
FolderArchive,
Search,
Eye,
Download,
Share2,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { useProjectCertificates } from "../../../hooks/useCertificates"; //Problemnya cuma role "admin" yang bisa
import { CheckCircle2, FolderArchive, Loader2 } from "lucide-react";
import { useProjectCertificates } from "../../../hooks/useCertificates";
import { useProjectDetail } from "../../../hooks/useProjects";
import type { Certificate } from "../../../types/certificate.types";
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() {
const { slug } = useParams<{ slug: string }>();
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [zipping, setZipping] = useState(false);
const [zipProgress, setZipProgress] = useState<{ done: number; total: number } | null>(null);
const { project, loading: projectLoading, error: projectError } = useProjectDetail(slug ?? "");
const { certificates, loading: certLoading, error: certError } = useProjectCertificates(
project?.id ?? 0
);
const { certificates, loading: certLoading, error: certError } = useProjectCertificates(project?.id ?? 0);
const loading = projectLoading || certLoading;
const error = projectError || certError;
if (loading) return <CertificateResultSkeleton />;
if (error) return <div className="text-sm text-red-500 p-4">Gagal memuat data sertifikat.</div>;
// Filter + paginate
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);
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
setPage(1);
const handleDownloadZip = async () => {
if (!project || certificates.length === 0) return;
setZipping(true);
setZipProgress({ done: 0, total: certificates.length });
try {
await downloadAllCertificatesZip(
certificates,
project.certificate_template ?? "",
project.project_name,
(done, total) => setZipProgress({ done, total })
);
} catch (err) {
console.error("Gagal download ZIP:", err);
} finally {
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 (
<div>
{/* Page Header */}
@@ -137,151 +49,42 @@ export default function CertificateResult() {
</p>
</div>
{/* ── Success Hero ── */}
{/* Success Hero */}
<div className="flex flex-col items-center text-center mb-8 px-4">
{/* Icon circle */}
<div className="w-16 h-16 rounded-full bg-[#B30000] flex items-center justify-center shadow-lg mb-5">
<div className="w-16 h-16 rounded-full bg-red flex items-center justify-center shadow-lg mb-5">
<CheckCircle2 className="w-8 h-8 text-white" strokeWidth={2.5} />
</div>
{/* Headline */}
<h2 className="text-3xl md:text-4xl font-extrabold text-[#B30000] mb-3 leading-tight">
{certificates.length} Certificates Successfully Generated!
<h2 className="text-3xl md:text-4xl font-extrabold text-red mb-3 leading-tight">
{certificates.length} Certificates has been Generated!
</h2>
{/* Subtext */}
<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
publish them to the public registry or download the batch for offline distribution.
</p>
{/* Download CTA */}
<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">
<FolderArchive className="w-4 h-4" />
Download All (ZIP)
<button
onClick={handleDownloadZip}
disabled={zipping || certificates.length === 0}
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"
>
{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>
</div>
{/* ── Bottom Section: Table + Preview Card ── */}
<div className="flex flex-col xl:flex-row gap-4 items-start">
{/* Recipient Directory Table */}
<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>
{/* Table */}
<CertResultTable
certificates={certificates}
templateUrl={project?.certificate_template ?? ""}
/>
</div>
);
}
+23 -17
View File
@@ -1,12 +1,15 @@
import { useState, useMemo } from "react";
import { Search, SlidersHorizontal, Plus, Trash2 } from "lucide-react";
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() {
const [searchEmail, setSearchEmail] = useState("");
const [selectedProject, setSelectedProject] = useState("All Projects");
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
const { interns, loading, error, refetch } = useAllInterns();
const { remove, loading: removing } = useRemoveMember();
@@ -23,22 +26,24 @@ export default function InternsManagement() {
});
}, [interns, searchEmail, selectedProject]);
// Hitung statistik dari data asli
const totalInterns = interns.length;
// Active assignments = total member dari semua project yang statusnya active
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
// Catatan: useRemoveMember memerlukan id_project & id_user.
// Karena InternDetail tidak menyimpan id_project, aksi ini saat ini hanya placeholder.
// Untuk implementasi penuh, perlu endpoint khusus atau data id_project dari InternDetail.
const handleDeleteIntern = async (internId: number) => {
const confirmed = window.confirm("Yakin ingin menghapus intern ini dari project?");
if (!confirmed) return;
alert(`Remove intern ID ${internId} — perlu id_project dari backend untuk dilanjutkan.`);
// Contoh penggunaan jika id_project tersedia:
// const res = await remove({ id_project: intern.id_project, id_user: internId });
// if (res !== null) refetch();
const handleDeleteIntern = async (intern: InternDetail) => {
const matchedProject = activeProjects.find((project: Project) => project.project_name === intern.projectName);
if (!matchedProject) {
console.error("Project not found for intern:", intern);
return;
}
const res = await remove({ id_project: matchedProject.id, id_user: intern.id });
if (res !== null) refetch();
};
if (loading) return (
@@ -127,7 +132,7 @@ export default function InternsManagement() {
<div className="flex justify-end">
<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"
onClick={() => setIsModalOpen(true)}
onClick={() => openAssignFor(null)}
>
<Plus className="w-4 h-4" />
Add
@@ -184,9 +189,9 @@ export default function InternsManagement() {
{/* Column 3: Action Buttons Block Right aligned */}
<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
onClick={() => setIsModalOpen(true)}
onClick={() => openAssignFor(intern.id)}
className="p-1.5 bg-[#B30000] hover:bg-[#990000] text-white rounded-lg transition-colors shadow-sm"
title="Assign ke project"
>
@@ -194,7 +199,7 @@ export default function InternsManagement() {
</button>
{/* Tombol Delete: remove intern dari project */}
<button
onClick={() => handleDeleteIntern(intern.id)}
onClick={() => handleDeleteIntern(intern)}
disabled={removing}
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"
@@ -233,6 +238,7 @@ export default function InternsManagement() {
{isModalOpen &&
<AddInternsModal
isOpen={isModalOpen}
userId={selectedUserId}
onClose={() => {
setIsModalOpen(false);
refetch(); // Refresh data setelah modal assign ditutup
@@ -1,10 +1,10 @@
// ManageInternsModal.tsx
import { useRef, useEffect, useState } from "react";
import { Search, Plus, Trash2 } from "lucide-react";
import { useMahasiswa } from "../../../../hooks/useUser";
import { useAssignableInterns } from "../../../../hooks/useProjects";
import { useAssignMember } from '../../../../hooks/useProjects';
import type { ProjectMember } from '../../../../types/project.types';
import type { Mahasiswa } from '../../../../types/project.types';
import type { AssignableIntern } from '../../../../types/project.types';
interface ManageInternsModalProps {
isOpen: boolean;
@@ -18,7 +18,7 @@ export default function ManageInternsModal({ isOpen, onClose, projectId, initial
const [searchQuery, setSearchQuery] = useState("");
const [invitedList, setInvitedList] = useState<ProjectMember[]>(initialMembers);
const { mahasiswa, loading: loadingMahasiswa } = useMahasiswa();
const { interns, loading: loadingAssignableInterns } = useAssignableInterns();
const { assign, loading: assigning, error } = useAssignMember();
// Sync saat modal dibuka
@@ -44,32 +44,33 @@ export default function ManageInternsModal({ isOpen, onClose, projectId, initial
};
}, [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 searchResult = mahasiswa.filter(m =>
const searchResult = interns.filter(m =>
!invitedIds.has(m.id) &&
(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 });
if (result) {
setInvitedList(prev => [...prev, {
id: 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,
avatar: "",
kelompok_peminatan: intern.jurusan,
kelompok_peminatan: intern.kelompok_peminatan,
professional_bio: "",
position: intern.role,
position: intern.position,
} as ProjectMember]);
}
};
const getInitials = (m: Mahasiswa) =>
`${m.nama_depan[0]}${m.nama_belakang[0]}`.toUpperCase();
const getInitials = (m: AssignableIntern) =>
m.full_name.toUpperCase();
return (
<dialog ref={dialogRef} className="custom-dialog">
@@ -95,13 +96,9 @@ export default function ManageInternsModal({ isOpen, onClose, projectId, initial
{invitedList.map((member) => (
<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">
{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">
{member.full_name.slice(0, 2).toUpperCase()}
</div>
)}
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-700">{member.full_name}</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 */}
{loadingMahasiswa ? (
{loadingAssignableInterns ? (
<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 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">
{getInitials(intern)}
</div>
<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>
</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>
)}
</div>
@@ -1,23 +1,26 @@
import { useRef, useEffect, useState } from "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 { useAssignableInterns } from "../../../../hooks/useProjects";
interface AddInternsModalProps {
isOpen: boolean;
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);
// Form States
const [email, setEmail] = useState("");
const [selectedUserId, setSelectedUserId] = useState<string>("");
const [selectedProjectId, setSelectedProjectId] = useState<string>("");
// Hooks
const { assign, loading: assigning, error: assignError } = useAssignMember();
const { projects, loading: loadingProjects } = useProjects("active");
const { interns, loading: loadingInterns } = useAssignableInterns();
useEffect(() => {
const dialog = dialogRef.current;
@@ -43,30 +46,26 @@ export default function AddInternsModal({ isOpen, onClose }: AddInternsModalProp
};
}, [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(() => {
if (isOpen) {
setEmail("");
setSelectedUserId(userId !== null ? String(userId) : "");
setSelectedProjectId("");
}
}, [isOpen]);
}, [isOpen, userId]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedUserId) return;
// Catatan: useAssignMember memerlukan id_user (number), bukan email.
// Endpoint /project-api/assign-member menerima { id_project: number, id_user: number }.
// Untuk implementasi berbasis email, perlu endpoint tambahan atau lookup user by email.
// Saat ini form ini dikomen karena tidak ada lookup email → id_user di service.
// Contoh penggunaan jika id_user sudah diketahui:
// 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();
const res = await assign({
id_project: Number(selectedProjectId),
id_user: Number(selectedUserId),
});
if (res !== null) {
onClose();
}
};
return (
@@ -92,23 +91,36 @@ export default function AddInternsModal({ isOpen, onClose }: AddInternsModalProp
{/* Form Body */}
<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">
<label className="text-xs font-bold tracking-wide text-foreground">
Email Address
Intern
</label>
<div className="relative">
<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>
<input
type="email"
<select
required
placeholder="intern.name@gmail.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
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"
/>
value={selectedUserId}
onChange={(e) => setSelectedUserId(e.target.value)}
disabled={loadingInterns}
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>
@@ -145,12 +157,6 @@ export default function AddInternsModal({ isOpen, onClose }: AddInternsModalProp
</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 && (
<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 { LuSendHorizontal, LuMail } from "react-icons/lu";
import { useCreateProject } from "../../../../hooks/useProjects";
import type { CreateProjectPayload } from "../../../../types/project.types";
interface CreateProjectModalProps {
isOpen: boolean;
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 [selectedIcon, setSelectedIcon] = useState(PROJECT_ICON_CODES[0]);
const [projectName, setProjectName] = useState("");
@@ -61,23 +63,23 @@ export default function CreateProjectModal({ isOpen, onClose }: CreateProjectMod
setInvitedEmails(invitedEmails.filter(email => email !== emailToRemove));
};
// Handler Submit Utama
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const payload: any = {
const payload: CreateProjectPayload = {
project_icon: selectedIcon,
project_name: projectName,
description: projectDescription,
start_date: startDate,
end_date: endDate,
};
if (invitedEmails.length > 0) {
payload.member_emails = invitedEmails;
}
const result = await create(payload);
if (result) {
onSuccess?.(); // panggil refetch di parent (kalau ada)
onClose();
}
};
@@ -15,6 +15,7 @@ import type { InternSubmissionProgress } from "../../../../hooks/useInternProgre
import { useProjectDetail } from "../../../../hooks/useProjects";
import JSZip from "jszip";
import { generateCertificate } from "../../../utils/SertificateGenerator";
import { useGenerateCertificates } from "../../../../hooks/useCertificates";
import Avatar from "../../Avatar";
import ProgressBar from "../../ProgressBar";
@@ -47,20 +48,19 @@ export default function EligibleInternTable({
}: EligibleInternTableProps) {
const navigate = useNavigate();
const { project } = useProjectDetail(String(projectId));
const { generate: SubmitCertificates, loading: generatingCertificates, error, success, reset } = useGenerateCertificates();
const initialSelected = new Set(
interns.filter(i => i.is_eligible).map(i => i.id_user)
);
// Pemilihan Id
const [selected, setSelected] = useState<Set<number>>(initialSelected);
// state saat generate sertifikat
const [generating, setGenerating] = useState(false);
const [generateError, setGenerateError] = useState<string | null>(null);
// Halaman (Pagination)
const [page, setPage] = useState(1);
const totalPages = Math.ceil(interns.length / 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 allEligibleSelected = eligibleIds.every(id => selected.has(id));
@@ -78,56 +78,48 @@ export default function EligibleInternTable({
};
const handleGenerate = async () => {
// NOTE: DI RESPONSE GAK DIKASIH TAHU DIMANA SIMPAN TEMPLATE SERTIF NYA
if (!project?.certificate_template) {
setGenerateError("Template sertifikat belum diupload.");
return;
}
if (selected.size === 0) {
setGenerateError("Pilih minimal satu intern.");
return;
}
if (!project?.certificate_template) {
setGenerateError("Template sertifikat belum diupload.");
return;
}
if (selected.size === 0) {
setGenerateError("Pilih minimal satu intern.");
return;
}
setGenerating(true);
setGenerateError(null);
setGenerating(true);
setGenerateError(null);
try {
const selectedInterns = interns.filter((i) => selected.has(i.id_user));
const zip = new JSZip();
try {
// 1. Generate blob PNG per intern & kumpulkan ke ZIP
const selectedInterns = interns.filter((i) => selected.has(i.id_user));
const zip = new JSZip();
// Generate semua sertifikat secara paralel
await Promise.all(
selectedInterns.map(async (intern) => {
const blob = await generateCertificate(
project.certificate_template,
intern.full_name
);
// Nama file: "Sertifikat - Nama Intern.png"
const filename = `Sertifikat - ${intern.full_name}.png`;
zip.file(filename, blob);
})
);
await Promise.all(
selectedInterns.map(async (intern) => {
const blob = await generateCertificate(
project.certificate_template,
intern.full_name
);
zip.file(`Sertifikat - ${intern.full_name}.png`, blob);
})
);
// Download ZIP
const zipBlob = await zip.generateAsync({ type: "blob" });
const url = URL.createObjectURL(zipBlob);
const a = document.createElement("a");
a.href = url;
a.download = `Sertifikat-Project-${projectId}.zip`;
a.click();
URL.revokeObjectURL(url);
// 2. POST ke backend untuk mencatat sertifikat sudah di-generate
const ok = await SubmitCertificates(projectId, Array.from(selected));
if (!ok) return;
// Navigasi ke result page
navigate(`/mentor/certificates/${projectId}/result`);
} catch (err) {
console.error(err);
setGenerateError(
err instanceof Error ? err.message : "Gagal generate sertifikat."
);
} finally {
setGenerating(false);
}
};
// 3. Navigasi ke result page
navigate(`/mentor/certificates/${projectId}/result`);
} catch (err) {
console.error(err);
setGenerateError(
err instanceof Error ? err.message : "Gagal generate sertifikat."
);
} finally {
setGenerating(false);
}
};
if (loading) {
return (
@@ -3,6 +3,14 @@ import { useState } from 'react';
import { Plus } from 'lucide-react';
import CreateProjectModal from './CreateProjectDialog';
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 navigate = useNavigate();
@@ -36,7 +44,11 @@ const HomeMentorProject = () => {
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"
>
<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>
<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';
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 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 toSlug = (name: string) => name.toLowerCase().replace(/\s+/g, '-');
@@ -61,8 +61,20 @@ export default function MentorProjectCard(project: Project) {
{project.total_members}/{project.total_members}
</span>
</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>
{/* Footer */}
<div className="border-t border-gray-300 text-gray-400 flex justify-end p-2 bg-gray-50/50">
<button
@@ -66,7 +66,7 @@ export default function SidebarMentor({ 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}
+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)) {
// Jika role tidak diizinkan, kembalikan ke base dashboard masing-masing role
if (userRole === "admin" || userRole === "mentor") return <Navigate to="/mentor" replace />;
if (userRole === "intern") return <Navigate to="/intern" replace />;
}
// Jika lolos seleksi, render komponen di dalamnya
return <Outlet />;
};
+7 -10
View File
@@ -1,11 +1,5 @@
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
export function resolveImageUrl(path: string): string {
if (!path) return "";
@@ -31,7 +25,10 @@ export function loadImage(src: string): Promise<HTMLImageElement> {
// ── Generate satu sertifikat sebagai Blob ─────────────────────────────────────
export async function generateCertificate(
templateUrl: string,
internName: string
internName: string,
projectName: string,
uuid: string,
barcodeUrl: string
): Promise<Blob> {
const img = await loadImage(templateUrl);
@@ -43,11 +40,11 @@ export async function generateCertificate(
ctx.drawImage(img, 0, 0);
// Tulis nama di tengah horizontal, posisi Y dari konstanta
ctx.font = `bold ${NAME_FONT_SIZE}px ${NAME_FONT_FAMILY}`;
ctx.fillStyle = NAME_COLOR;
ctx.font = `bold 48px Georgia, serif`;
ctx.fillStyle = "#800000";
ctx.textAlign = "center";
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) => {
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
import { useState, useEffect, useCallback } from "react";
import type { Certificate, CertificateVerifyResult } from "../types/certificate.types";
import type { Certificate } from "../types/certificate.types";
import {
claimCertificate,
getMyCertificates,
getAllCertificates,
getCertificateDetail,
getCertificatesByProject,
verifyCertificateByUuid,
uploadCertificateTemplate,
generateCertificate
} from "../services/CertificateService";
@@ -87,25 +86,6 @@ export const useProjectCertificates = (id_project: number) => {
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
export const useClaimCertificate = () => {
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]);
return { mahasiswa, loading, error, refetch };
};
export const useAssignableInterns = () => {}
};
+1 -1
View File
@@ -1,6 +1,6 @@
// services/UserService.ts
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"
// POST /auth-api/login
+2 -21
View File
@@ -1,5 +1,3 @@
// types/certificate.types.ts
export interface CertificateUser {
id: number;
full_name: string;
@@ -17,38 +15,21 @@ export interface Certificate {
id_project: number;
id_user: number;
certificate_no: string;
image_path: string
issued_at: string;
uuid: string;
user: CertificateUser;
project: CertificateProject;
certificate_status: string;
}
export interface CertificateVerifyResult {
// Khusus GET /certificate-api/verify-uuid/{uuid}
export interface CertificateVerification {
uuid: string;
intern_name: string;
project_name: string;
created_at: string;
}
export interface GenerateCertificatePayload {
id_project: number;
id_users: number[];
}
export interface CertificateTemplate {
id_project: number;
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;
full_name: string;
email: string;
profesional_bio: string;
professional_bio: string;
position: string;
kelompok_peminatan: string;
is_active: boolean;