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