feat: implement certificate template management and eligible intern tracking module for mentors

This commit is contained in:
Kemas M. Aryadary Rasyad
2026-07-07 15:35:01 +07:00
parent 91d7bcf3f7
commit 77965be7f9
6 changed files with 147 additions and 105 deletions
+108 -91
View File
@@ -1,91 +1,108 @@
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import useBreadcrumbs from "use-react-router-breadcrumbs"; import useBreadcrumbs from "use-react-router-breadcrumbs";
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import { useMyProjects, useProjects } from "../../hooks/useProjects"; import { useMyProjects, useProjects } from "../../hooks/useProjects";
import { useProjectTasks } from "../../hooks/useTasks"; import { useProjectTasks } from "../../hooks/useTasks";
type BreadcrumbMatch = { type BreadcrumbMatch = {
match: { params: { slug?: string; taskSlug?: string } }; match: { params: { slug?: string; taskSlug?: string; nameIntern?: string } };
}; };
const unslugify = (slug = '') => const unslugify = (slug = '') =>
slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const ProjectBreadcrumb = ({ match }: BreadcrumbMatch) => { const ProjectBreadcrumb = ({ match }: BreadcrumbMatch) => {
const { projects } = useProjects(); const { projects } = useProjects();
const slug = match.params.slug; const slug = match.params.slug;
const project = projects?.find((p) => p.slug === slug); const project = projects?.find((p) => p.slug === slug);
return <>{project?.project_name ?? unslugify(slug)}</>; return <>{project?.project_name ?? unslugify(slug)}</>;
}; };
const TaskBreadcrumb = ({ match }: BreadcrumbMatch) => { const TaskBreadcrumb = ({ match }: BreadcrumbMatch) => {
const {slug, taskSlug} = match.params; const {slug, taskSlug} = match.params;
const { projects } = useMyProjects(); const { projects } = useMyProjects();
const project = projects?.find((p) => p.slug === slug); const project = projects?.find((p) => p.slug === slug);
const { tasks } = useProjectTasks(project?.id ?? 0); const { tasks } = useProjectTasks(project?.id ?? 0);
const task = tasks?.find((t) => t.slug === taskSlug); const task = tasks?.find((t) => t.slug === taskSlug);
return <>{task?.title ?? unslugify(taskSlug)}</>; return <>{task?.title ?? unslugify(taskSlug)}</>;
}; };
const breadcrumbRoutes = [ // Breadcrumb untuk halaman detail sertifikat (menggunakan nama project dari slug)
// --- AREA INTERN --- const CertBreadcrumb = ({ match }: BreadcrumbMatch) => {
{ path: '/intern', breadcrumb: 'Home' }, const { projects } = useProjects();
{ path: '/intern/projects', breadcrumb: 'Projects' }, const slug = match.params.slug;
{ path: '/intern/projects/:slug', breadcrumb: ProjectBreadcrumb }, const project = projects?.find((p) => p.slug === slug);
{ path: '/intern/certificates', breadcrumb: 'Certificates' }, return <>{project?.project_name ?? unslugify(slug)}</>;
{ path: '/intern/projects/:slug/:taskSlug', breadcrumb: TaskBreadcrumb }, };
{ path: '/intern/faq', breadcrumb: 'FAQ' },
{ path: '/intern/notifications', breadcrumb: 'Notifications' }, // Breadcrumb untuk halaman submission intern (unslugify nameIntern)
{ path: '/intern/settings', breadcrumb: 'Settings' }, const NameInternBreadcrumb = ({ match }: BreadcrumbMatch) => {
return <>{unslugify(match.params.nameIntern)}</>;
// --- AREA MENTOR / ADMIN --- };
{ path: '/mentor', breadcrumb: 'Home' },
{ path: '/mentor/projects', breadcrumb: 'List Projects' }, const breadcrumbRoutes = [
{ path: '/mentor/projects/:slug', breadcrumb: ProjectBreadcrumb }, // --- AREA INTERN ---
{ path: '/mentor/projects/:slug/:taskSlug', breadcrumb: TaskBreadcrumb }, { path: '/intern', breadcrumb: 'Home' },
{ path: '/mentor/certificates', breadcrumb: 'Certificates' }, { path: '/intern/projects', breadcrumb: 'Projects' },
{ path: '/mentor/intern', breadcrumb: 'List Intern' }, { path: '/intern/projects/:slug', breadcrumb: ProjectBreadcrumb },
{ path: '/mentor/faq', breadcrumb: 'FAQ' }, { path: '/intern/projects/:slug/:taskSlug', breadcrumb: TaskBreadcrumb },
{ path: '/mentor/notifications', breadcrumb: 'Notifications' }, { path: '/intern/certificates', breadcrumb: 'Certificates' },
{ path: '/mentor/settings', breadcrumb: 'Settings' }, { path: '/intern/certificates/:slug', breadcrumb: CertBreadcrumb },
]; { path: '/intern/faq', breadcrumb: 'FAQ' },
{ path: '/intern/notifications', breadcrumb: 'Notifications' },
export default function Breadcrumbs(){ { path: '/intern/settings', breadcrumb: 'Settings' },
const breadcrumbs = useBreadcrumbs(breadcrumbRoutes);
const location = useLocation(); // --- AREA MENTOR / ADMIN ---
{ path: '/mentor', breadcrumb: 'Home' },
const rootPaths = ['/intern', '/mentor']; { path: '/mentor/projects', breadcrumb: 'List Projects' },
const isOnRootPath = rootPaths.includes(location.pathname); { path: '/mentor/projects/:slug', breadcrumb: ProjectBreadcrumb },
const fixbreadcumbs = breadcrumbs.filter(({ match }) => { { path: '/mentor/projects/:slug/:taskSlug', breadcrumb: TaskBreadcrumb },
if (match.pathname === '/') return false; { path: '/mentor/projects/:slug/:taskSlug/:nameIntern', breadcrumb: NameInternBreadcrumb },
if (rootPaths.includes(match.pathname) && !isOnRootPath) return false; { path: '/mentor/certificates', breadcrumb: 'Certificates' },
return true; { path: '/mentor/certificates/:slug', breadcrumb: CertBreadcrumb },
}); { path: '/mentor/certificates/:slug/result', breadcrumb: 'Result' },
return ( { path: '/mentor/intern', breadcrumb: 'List Intern' },
<nav className="text-[15px] mb-3"> { path: '/mentor/faq', breadcrumb: 'FAQ' },
{fixbreadcumbs.map(({ match, breadcrumb }) => { { path: '/mentor/notifications', breadcrumb: 'Notifications' },
const isLast = match.pathname === location.pathname; { path: '/mentor/settings', breadcrumb: 'Settings' },
];
return (
<span key={match.pathname} className="inline-flex items-center"> export default function Breadcrumbs(){
{isLast ? ( const breadcrumbs = useBreadcrumbs(breadcrumbRoutes);
<span className="font-bold text-red-800"> const location = useLocation();
{breadcrumb}
const rootPaths = ['/intern', '/mentor'];
</span> const isOnRootPath = rootPaths.includes(location.pathname);
) : ( const fixbreadcumbs = breadcrumbs.filter(({ match }) => {
<> if (match.pathname === '/') return false;
<Link to={match.pathname} className="text-gray-800"> if (rootPaths.includes(match.pathname) && !isOnRootPath) return false;
{breadcrumb} return true;
</Link> });
<ChevronRight className="w-4 h-4 mx-1.5"/> return (
</> <nav className="text-[15px] mb-3">
)} {fixbreadcumbs.map(({ match, breadcrumb }) => {
</span> const isLast = match.pathname === location.pathname;
);
})} return (
</nav> <span key={match.pathname} className="inline-flex items-center">
); {isLast ? (
}; <span className="font-bold text-red-800">
{breadcrumb}
</span>
) : (
<>
<Link to={match.pathname} className="text-gray-800">
{breadcrumb}
</Link>
<ChevronRight className="w-4 h-4 mx-1.5"/>
</>
)}
</span>
);
})}
</nav>
);
};
+2 -2
View File
@@ -134,7 +134,7 @@ export default function NotificationList() {
<div className="flex items-center gap-2 pt-3"> <div className="flex items-center gap-2 pt-3">
<button <button
className="px-4 py-1.5 bg-[#B30000] hover:bg-[#990000] text-white text-xs font-bold rounded-lg shadow-sm transition-colors" className="px-4 py-1.5 bg-[#B30000] hover:bg-[#990000] text-white text-xs font-bold rounded-lg shadow-sm transition-colors"
onClick={() => navigate(`/projects/${item.link}`)}> onClick={() => navigate(`/${item.link}`)}>
View Task View Task
</button> </button>
<button className="px-4 py-1.5 border border-gray-200 text-gray-500 hover:text-gray-700 hover:bg-gray-50 text-xs font-semibold rounded-lg transition-colors"> <button className="px-4 py-1.5 border border-gray-200 text-gray-500 hover:text-gray-700 hover:bg-gray-50 text-xs font-semibold rounded-lg transition-colors">
@@ -146,7 +146,7 @@ export default function NotificationList() {
{item.type === 'achievement' && ( {item.type === 'achievement' && (
<button <button
className="flex items-center gap-1 text-xs font-bold text-[#B30000] hover:underline pt-2" className="flex items-center gap-1 text-xs font-bold text-[#B30000] hover:underline pt-2"
onClick={() => navigate(`/certificate`)}> onClick={() => navigate(`/certificates`)}>
<Download className="w-3.5 h-3.5" /> <Download className="w-3.5 h-3.5" />
Download Certificate Download Certificate
</button> </button>
@@ -82,6 +82,23 @@ export default function CertificateAvailable({ project, certificate, templateUrl
} }
}; };
const handleShareLinkedIn = () => {
const verifyUrl = `${window.location.origin}/verify-certificate/${certificate.uuid}`;
// Gunakan "Add Certification to Profile" LinkedIn — tidak butuh API key/approval
const params = new URLSearchParams({
startTask: "CERTIFICATION_NAME",
name: certificate.project.project_name,
organizationName: "Internify",
certUrl: verifyUrl,
certId: certificate.certificate_no,
});
window.open(
`https://www.linkedin.com/profile/add?${params.toString()}`,
"_blank",
"width=600,height=600,noopener,noreferrer"
);
};
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Certificate Card */} {/* Certificate Card */}
@@ -139,8 +156,15 @@ export default function CertificateAvailable({ project, certificate, templateUrl
</button> </button>
{/* Bagikan ke LinkedIn */} {/* Bagikan ke LinkedIn */}
<button className="flex items-center justify-center gap-2 w-full bg-blue-600 hover:bg-blue-700 active:scale-95 transition-all text-white font-semibold py-2 rounded-xl text-sm"> <button
Bagikan ke LinkedIn onClick={handleShareLinkedIn}
className="flex items-center justify-center gap-2 w-full bg-[#0A66C2] hover:bg-[#004182] active:scale-95 transition-all text-white font-semibold py-2 rounded-xl text-sm"
>
{/* LinkedIn icon */}
<svg className="w-4 h-4 fill-white" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
Tambah ke Profil LinkedIn
</button> </button>
{/* Cetak & Salin Link */} {/* Cetak & Salin Link */}
@@ -11,7 +11,7 @@ import { customToast } from "../../utils/showToast";
export default function CertificateDetail() { export default function CertificateDetail() {
const navigate = useNavigate(); const navigate = useNavigate();
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
const { project, loading, error } = useProjectDetail(slug ?? ""); const { project, loading, error, refetch } = useProjectDetail(slug ?? "");
const { upload, loading: uploading, error: uploadError } = useUploadCertificateTemplate(); const { upload, loading: uploading, error: uploadError } = useUploadCertificateTemplate();
const { data: internProgress, loading: progressLoading } = useProjectInternProgress(slug ?? ""); const { data: internProgress, loading: progressLoading } = useProjectInternProgress(slug ?? "");
const eligibleCount = internProgress.filter(i => i.is_eligible).length; const eligibleCount = internProgress.filter(i => i.is_eligible).length;
@@ -45,11 +45,12 @@ export default function CertificateDetail() {
description: err instanceof Error ? err.message : "Terjadi kesalahan saat upload.", description: err instanceof Error ? err.message : "Terjadi kesalahan saat upload.",
}), }),
} }
).then((result) => { ).then(async(result) => {
if (result) { if (result) {
setUploadSuccess(true); setUploadSuccess(true);
setSelectedFile(null); setSelectedFile(null);
if (fileInputRef.current) fileInputRef.current.value = ""; if (fileInputRef.current) fileInputRef.current.value = "";
await refetch();
} }
}).catch(() => {}); }).catch(() => {});
}; };
@@ -143,7 +144,6 @@ export default function CertificateDetail() {
</> </>
)} )}
</div> </div>
{/* Tombol Clear */} {/* Tombol Clear */}
{selectedFile && ( {selectedFile && (
<button <button
@@ -226,7 +226,7 @@ export default function CertificateDetail() {
interns={internProgress} interns={internProgress}
loading={progressLoading} loading={progressLoading}
eligibleCount={eligibleCount} eligibleCount={eligibleCount}
projectId={project.id} project={project}
/> />
</div> </div>
</div> </div>
@@ -12,19 +12,19 @@ import {
Loader2 Loader2
} from "lucide-react"; } from "lucide-react";
import type { InternSubmissionProgress } from "../../../../hooks/useInternProgress"; import type { InternSubmissionProgress } from "../../../../hooks/useInternProgress";
import { useProjectDetail } from "../../../../hooks/useProjects";
import JSZip from "jszip"; import JSZip from "jszip";
import { generateCertificate, resolveImageUrl } from "../../../utils/SertificateGenerator"; import { generateCertificate, resolveImageUrl } from "../../../utils/SertificateGenerator";
import { useGenerateCertificates } from "../../../../hooks/useCertificates"; import { useGenerateCertificates } from "../../../../hooks/useCertificates";
import Avatar from "../../Avatar"; import Avatar from "../../Avatar";
import ProgressBar from "../../ProgressBar"; import ProgressBar from "../../ProgressBar";
import { customToast } from "../../../utils/showToast"; import { customToast } from "../../../utils/showToast";
import type { Project } from "../../../../types/project.types";
interface EligibleInternTableProps { interface EligibleInternTableProps {
interns: InternSubmissionProgress[]; interns: InternSubmissionProgress[];
loading: boolean; loading: boolean;
eligibleCount: number; eligibleCount: number;
projectId: number project: Project
} }
const PER_PAGE = 5; const PER_PAGE = 5;
@@ -45,11 +45,11 @@ export default function EligibleInternTable({
interns, interns,
loading, loading,
eligibleCount, eligibleCount,
projectId project
// Durasi tanggal Proyek // Durasi tanggal Proyek
}: EligibleInternTableProps) { }: EligibleInternTableProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { project } = useProjectDetail(String(projectId)); // const { project } = useProjectDetail(String(projectId));
const { generate: SubmitCertificates } = useGenerateCertificates(); const { generate: SubmitCertificates } = useGenerateCertificates();
const initialSelected = new Set( const initialSelected = new Set(
@@ -93,7 +93,7 @@ export default function EligibleInternTable({
const ok = await customToast.promise( const ok = await customToast.promise(
(async () => { (async () => {
const result = await SubmitCertificates(projectId, Array.from(selected)); const result = await SubmitCertificates(project.id, Array.from(selected));
if (!result) throw new Error("Tidak ada data sertifikat yang dikembalikan."); if (!result) throw new Error("Tidak ada data sertifikat yang dikembalikan.");
const zip = new JSZip(); const zip = new JSZip();
@@ -128,7 +128,7 @@ export default function EligibleInternTable({
).then(() => true).catch(() => false); ).then(() => true).catch(() => false);
setGenerating(false); setGenerating(false);
if (ok) navigate(`/mentor/certificates/${projectId}/result`); if (ok) navigate(`/mentor/certificates/${project.id}/result`);
}; };
if (loading) { if (loading) {
+1
View File
@@ -28,6 +28,7 @@ export interface Project {
total_tasks: number; total_tasks: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
certificate_template: string | null;
} }
// Detail project (includes members array) // Detail project (includes members array)