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";
|
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 = '') =>
|
||||||
@@ -29,13 +29,27 @@ const TaskBreadcrumb = ({ match }: BreadcrumbMatch) => {
|
|||||||
return <>{task?.title ?? unslugify(taskSlug)}</>;
|
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 = [
|
const breadcrumbRoutes = [
|
||||||
// --- AREA INTERN ---
|
// --- AREA INTERN ---
|
||||||
{ path: '/intern', breadcrumb: 'Home' },
|
{ path: '/intern', breadcrumb: 'Home' },
|
||||||
{ path: '/intern/projects', breadcrumb: 'Projects' },
|
{ path: '/intern/projects', breadcrumb: 'Projects' },
|
||||||
{ path: '/intern/projects/:slug', breadcrumb: ProjectBreadcrumb },
|
{ path: '/intern/projects/:slug', breadcrumb: ProjectBreadcrumb },
|
||||||
|
{ path: '/intern/projects/:slug/:taskSlug', breadcrumb: TaskBreadcrumb },
|
||||||
{ path: '/intern/certificates', breadcrumb: 'Certificates' },
|
{ path: '/intern/certificates', breadcrumb: 'Certificates' },
|
||||||
{ path: '/intern/projects/:slug/:taskSlug', breadcrumb: TaskBreadcrumb },
|
{ path: '/intern/certificates/:slug', breadcrumb: CertBreadcrumb },
|
||||||
{ path: '/intern/faq', breadcrumb: 'FAQ' },
|
{ path: '/intern/faq', breadcrumb: 'FAQ' },
|
||||||
{ path: '/intern/notifications', breadcrumb: 'Notifications' },
|
{ path: '/intern/notifications', breadcrumb: 'Notifications' },
|
||||||
{ path: '/intern/settings', breadcrumb: 'Settings' },
|
{ path: '/intern/settings', breadcrumb: 'Settings' },
|
||||||
@@ -45,7 +59,10 @@ const breadcrumbRoutes = [
|
|||||||
{ path: '/mentor/projects', breadcrumb: 'List Projects' },
|
{ path: '/mentor/projects', breadcrumb: 'List Projects' },
|
||||||
{ path: '/mentor/projects/:slug', breadcrumb: ProjectBreadcrumb },
|
{ path: '/mentor/projects/:slug', breadcrumb: ProjectBreadcrumb },
|
||||||
{ path: '/mentor/projects/:slug/:taskSlug', breadcrumb: TaskBreadcrumb },
|
{ path: '/mentor/projects/:slug/:taskSlug', breadcrumb: TaskBreadcrumb },
|
||||||
|
{ path: '/mentor/projects/:slug/:taskSlug/:nameIntern', breadcrumb: NameInternBreadcrumb },
|
||||||
{ path: '/mentor/certificates', breadcrumb: 'Certificates' },
|
{ 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/intern', breadcrumb: 'List Intern' },
|
||||||
{ path: '/mentor/faq', breadcrumb: 'FAQ' },
|
{ path: '/mentor/faq', breadcrumb: 'FAQ' },
|
||||||
{ path: '/mentor/notifications', breadcrumb: 'Notifications' },
|
{ path: '/mentor/notifications', breadcrumb: 'Notifications' },
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user