feat: implement certificate template management and eligible intern tracking module for mentors
This commit is contained in:
@@ -5,7 +5,7 @@ import { useMyProjects, useProjects } from "../../hooks/useProjects";
|
||||
import { useProjectTasks } from "../../hooks/useTasks";
|
||||
|
||||
type BreadcrumbMatch = {
|
||||
match: { params: { slug?: string; taskSlug?: string } };
|
||||
match: { params: { slug?: string; taskSlug?: string; nameIntern?: string } };
|
||||
};
|
||||
|
||||
const unslugify = (slug = '') =>
|
||||
@@ -29,13 +29,27 @@ const TaskBreadcrumb = ({ match }: BreadcrumbMatch) => {
|
||||
return <>{task?.title ?? unslugify(taskSlug)}</>;
|
||||
};
|
||||
|
||||
// Breadcrumb untuk halaman detail sertifikat (menggunakan nama project dari slug)
|
||||
const CertBreadcrumb = ({ match }: BreadcrumbMatch) => {
|
||||
const { projects } = useProjects();
|
||||
const slug = match.params.slug;
|
||||
const project = projects?.find((p) => p.slug === slug);
|
||||
return <>{project?.project_name ?? unslugify(slug)}</>;
|
||||
};
|
||||
|
||||
// Breadcrumb untuk halaman submission intern (unslugify nameIntern)
|
||||
const NameInternBreadcrumb = ({ match }: BreadcrumbMatch) => {
|
||||
return <>{unslugify(match.params.nameIntern)}</>;
|
||||
};
|
||||
|
||||
const breadcrumbRoutes = [
|
||||
// --- AREA INTERN ---
|
||||
{ path: '/intern', breadcrumb: 'Home' },
|
||||
{ path: '/intern/projects', breadcrumb: 'Projects' },
|
||||
{ path: '/intern/projects/:slug', breadcrumb: ProjectBreadcrumb },
|
||||
{ path: '/intern/certificates', breadcrumb: 'Certificates' },
|
||||
{ path: '/intern/projects/:slug/:taskSlug', breadcrumb: TaskBreadcrumb },
|
||||
{ path: '/intern/certificates', breadcrumb: 'Certificates' },
|
||||
{ path: '/intern/certificates/:slug', breadcrumb: CertBreadcrumb },
|
||||
{ path: '/intern/faq', breadcrumb: 'FAQ' },
|
||||
{ path: '/intern/notifications', breadcrumb: 'Notifications' },
|
||||
{ path: '/intern/settings', breadcrumb: 'Settings' },
|
||||
@@ -45,7 +59,10 @@ const breadcrumbRoutes = [
|
||||
{ path: '/mentor/projects', breadcrumb: 'List Projects' },
|
||||
{ path: '/mentor/projects/:slug', breadcrumb: ProjectBreadcrumb },
|
||||
{ path: '/mentor/projects/:slug/:taskSlug', breadcrumb: TaskBreadcrumb },
|
||||
{ path: '/mentor/projects/:slug/:taskSlug/:nameIntern', breadcrumb: NameInternBreadcrumb },
|
||||
{ path: '/mentor/certificates', breadcrumb: 'Certificates' },
|
||||
{ path: '/mentor/certificates/:slug', breadcrumb: CertBreadcrumb },
|
||||
{ path: '/mentor/certificates/:slug/result', breadcrumb: 'Result' },
|
||||
{ path: '/mentor/intern', breadcrumb: 'List Intern' },
|
||||
{ path: '/mentor/faq', breadcrumb: 'FAQ' },
|
||||
{ path: '/mentor/notifications', breadcrumb: 'Notifications' },
|
||||
|
||||
@@ -134,7 +134,7 @@ export default function NotificationList() {
|
||||
<div className="flex items-center gap-2 pt-3">
|
||||
<button
|
||||
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
|
||||
</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">
|
||||
@@ -146,7 +146,7 @@ export default function NotificationList() {
|
||||
{item.type === 'achievement' && (
|
||||
<button
|
||||
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 Certificate
|
||||
</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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Certificate Card */}
|
||||
@@ -139,8 +156,15 @@ export default function CertificateAvailable({ project, certificate, templateUrl
|
||||
</button>
|
||||
|
||||
{/* 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">
|
||||
Bagikan ke LinkedIn
|
||||
<button
|
||||
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>
|
||||
|
||||
{/* Cetak & Salin Link */}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { customToast } from "../../utils/showToast";
|
||||
export default function CertificateDetail() {
|
||||
const navigate = useNavigate();
|
||||
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 { data: internProgress, loading: progressLoading } = useProjectInternProgress(slug ?? "");
|
||||
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.",
|
||||
}),
|
||||
}
|
||||
).then((result) => {
|
||||
).then(async(result) => {
|
||||
if (result) {
|
||||
setUploadSuccess(true);
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
await refetch();
|
||||
}
|
||||
}).catch(() => {});
|
||||
};
|
||||
@@ -143,7 +144,6 @@ export default function CertificateDetail() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tombol Clear */}
|
||||
{selectedFile && (
|
||||
<button
|
||||
@@ -226,7 +226,7 @@ export default function CertificateDetail() {
|
||||
interns={internProgress}
|
||||
loading={progressLoading}
|
||||
eligibleCount={eligibleCount}
|
||||
projectId={project.id}
|
||||
project={project}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,19 +12,19 @@ import {
|
||||
Loader2
|
||||
} from "lucide-react";
|
||||
import type { InternSubmissionProgress } from "../../../../hooks/useInternProgress";
|
||||
import { useProjectDetail } from "../../../../hooks/useProjects";
|
||||
import JSZip from "jszip";
|
||||
import { generateCertificate, resolveImageUrl } from "../../../utils/SertificateGenerator";
|
||||
import { useGenerateCertificates } from "../../../../hooks/useCertificates";
|
||||
import Avatar from "../../Avatar";
|
||||
import ProgressBar from "../../ProgressBar";
|
||||
import { customToast } from "../../../utils/showToast";
|
||||
import type { Project } from "../../../../types/project.types";
|
||||
|
||||
interface EligibleInternTableProps {
|
||||
interns: InternSubmissionProgress[];
|
||||
loading: boolean;
|
||||
eligibleCount: number;
|
||||
projectId: number
|
||||
project: Project
|
||||
}
|
||||
|
||||
const PER_PAGE = 5;
|
||||
@@ -45,11 +45,11 @@ export default function EligibleInternTable({
|
||||
interns,
|
||||
loading,
|
||||
eligibleCount,
|
||||
projectId
|
||||
project
|
||||
// Durasi tanggal Proyek
|
||||
}: EligibleInternTableProps) {
|
||||
const navigate = useNavigate();
|
||||
const { project } = useProjectDetail(String(projectId));
|
||||
// const { project } = useProjectDetail(String(projectId));
|
||||
const { generate: SubmitCertificates } = useGenerateCertificates();
|
||||
|
||||
const initialSelected = new Set(
|
||||
@@ -93,7 +93,7 @@ export default function EligibleInternTable({
|
||||
|
||||
const ok = await customToast.promise(
|
||||
(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.");
|
||||
|
||||
const zip = new JSZip();
|
||||
@@ -128,7 +128,7 @@ export default function EligibleInternTable({
|
||||
).then(() => true).catch(() => false);
|
||||
|
||||
setGenerating(false);
|
||||
if (ok) navigate(`/mentor/certificates/${projectId}/result`);
|
||||
if (ok) navigate(`/mentor/certificates/${project.id}/result`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface Project {
|
||||
total_tasks: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
certificate_template: string | null;
|
||||
}
|
||||
|
||||
// Detail project (includes members array)
|
||||
|
||||
Reference in New Issue
Block a user