feat: add CertificateForm and CertificateResult components for managing intern certificates

- Implemented CertificateForm for uploading certificate templates and displaying project details.
- Created CertificateResult to show generated certificates with pagination and search functionality.
- Added EligibleInternTable component to display eligible interns and their progress.
- Updated CertificateListPage to reflect changes in project listing styles.
- Enhanced MentorProjectCard with dynamic icon rendering.
- Introduced useProjectInternProgress hook for fetching intern progress data.
- Modified ProjectService to include task details in project responses.
- Updated project types to accommodate new task structures.
This commit is contained in:
Kemas M. Aryadary Rasyad
2026-06-28 22:22:03 +07:00
parent 37e21a1f39
commit 732684200f
17 changed files with 612 additions and 371 deletions
+5 -5
View File
@@ -31,7 +31,7 @@ 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/SertificatePage";
import SertificatePage from "./Pages/Internify/intern/InternClaimCert";
import ProjectDetailPage from "./Pages/Internify/intern/ProjectDetailPage";
import HomeMentorPage from "./Pages/Internify/mentor/HomeMentorPage";
import MentorProjectsPage from "./Pages/Internify/mentor/BaseProjectsPage";
@@ -44,12 +44,12 @@ import NotificationList from "./Pages/Internify/NotificationList";
import SettingsContent from "./Pages/Internify/SettingsPage";
import MentorSettingsPage from "./Pages/Internify/mentor/MentorSettingsPage";
import TaskSubmission from "./Pages/Internify/intern/TaskSubmission";
import SertificateList from "./Pages/Internify/intern/SertificateList";
import SertificateList from "./Pages/Internify/intern/InternCertList";
import MentorProjectsDetailPage from "./Pages/Internify/mentor/MentorProjectDetailPage";
import ViewInternSubmission from "./Pages/Internify/mentor/ViewInternSubmission";
import ProtectedRoutes from "./Pages/utils/ProtectedRoute";
import CertificateDetail from "./Pages/Internify/mentor/CertificateDetail";
import CertificateResult from "./Pages/Internify/mentor/CertificateGenerate";
import CertificateDetail from "./Pages/Internify/mentor/CertificateForm";
import CertificateResult from "./Pages/Internify/mentor/CertificateResult";
import NotFoundPage from "./Pages/NotFoundPage";
function App() {
@@ -112,7 +112,7 @@ function App() {
<Route index element={<HomeMentorPage />} />
<Route path="projects" element={<MentorProjectsPage />} />
<Route path="certificates" element={<MentorCertificatePage />} />
<Route path="certificates/:name" element={<CertificateDetail />} />
<Route path="certificates/:slug" element={<CertificateDetail />} />
<Route path="certificates/result" element={<CertificateResult />} />
<Route path="intern" element={<MentorInternPage />} />
<Route path="projects/:slug" element={<MentorDetailProject />} />
+3 -2
View File
@@ -7,7 +7,8 @@ import { useCurrentUser } from "../hooks/useUser";
* "Andi Mahesa" → "AM"
* "Andi Bayu Cendika" → "AC" (huruf pertama + huruf pertama kata terakhir)
*/
function getInitials(fullName: string): string {
function getInitials(fullName: string | undefined | null): string {
if (!fullName) return "U";
const parts = fullName.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "U";
if (parts.length === 1) return parts[0].charAt(0).toUpperCase();
@@ -22,7 +23,7 @@ export default function Header({ toggleSidebar }: { toggleSidebar: () => void })
const settingsPath =
user?.role === "intern" ? "/intern/settings" : "/mentor/settings";
const initials = user ? getInitials(user.full_name) : "U";
const initials = user?.full_name ? getInitials(user.full_name) : "U";
const hasPhoto = !!user?.profile_picture;
return (
View File
+7 -9
View File
@@ -1,11 +1,11 @@
import { useNavigate } from "react-router-dom";
import { Home, ArrowLeft, Search } from "lucide-react";
import { Home, ArrowLeft } from "lucide-react";
export default function NotFoundPage() {
const navigate = useNavigate();
return (
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-red-950 flex items-center justify-center px-6 relative overflow-hidden">
<div className="min-h-screen bg-gradient-to-br from-slate-150 via-slate-300 to-gray-50 flex items-center justify-center px-6 relative overflow-hidden">
{/* Background decorative blobs */}
<div className="absolute top-0 left-1/4 w-96 h-96 bg-red-700 opacity-10 rounded-full blur-3xl pointer-events-none" />
<div className="absolute bottom-0 right-1/4 w-96 h-96 bg-red-500 opacity-10 rounded-full blur-3xl pointer-events-none" />
@@ -14,23 +14,21 @@ export default function NotFoundPage() {
{/* 404 Large Text */}
<div className="relative mb-4 select-none">
<span className="text-[160px] sm:text-[200px] font-black leading-none bg-gradient-to-b from-white/20 to-white/5 bg-clip-text text-transparent">
<span className="text-[160px] sm:text-[200px] font-black leading-none bg-gradient-to-b from-red to-red/10 bg-clip-text text-transparent">
404
</span>
<div className="absolute inset-0 flex items-center justify-center">
<Search className="w-20 h-20 sm:w-28 sm:h-28 text-red-500 opacity-80 drop-shadow-lg" />
</div>
</div>
{/* Title */}
<h1 className="text-3xl sm:text-4xl font-bold text-white mb-3 tracking-tight">
Halaman Tidak Ditemukan
Not Found
</h1>
{/* Description */}
<p className="text-slate-400 text-base sm:text-lg mb-10 max-w-md leading-relaxed">
Sepertinya halaman yang kamu cari tidak ada atau telah dipindahkan.
Coba kembali ke halaman sebelumnya atau beranda.
<p className="text-slate-800 text-base sm:text-lg mb-10 max-w-md leading-relaxed">
Sorry, the page you are looking for does not exist or has been moved. Let's get back on track.
</p>
{/* Divider */}
@@ -40,7 +38,7 @@ export default function NotFoundPage() {
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
<button
onClick={() => navigate(-1)}
className="flex items-center justify-center gap-2 px-6 py-3 rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 text-white font-semibold text-sm transition-all active:scale-95 backdrop-blur-sm"
className="flex items-center justify-center gap-2 px-6 py-3 rounded-xl border border-white/10 bg-white/30 hover:bg-white/15 text-white font-semibold text-sm transition-all active:scale-95 backdrop-blur-sm"
>
<ArrowLeft className="w-4 h-4" />
Kembali
@@ -2,10 +2,19 @@ import { Loader2, AlertCircle } from "lucide-react";
import { useMyProjects } from "../../../hooks/useProjects";
import type { Project } from "../../../types/project.types";
import { useNavigate } from "react-router-dom";
import * as LucideIcons from 'lucide-react';
import type { LucideProps } from 'lucide-react';
export default function SertificateList() {
const { projects, loading, error } = useMyProjects();
const navigate = useNavigate();
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;
};
return (
<div>
{/* Header */}
@@ -44,17 +53,16 @@ export default function SertificateList() {
<div
key={item.id}
className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-shadow cursor-pointer group"
onClick={() => navigate(`/intern/certificates/${item.slug}`)}
onClick={() => navigate(`/intern/certificates/${item.slug}`, { state: { project: item } })}
>
{/* Thumbnail */}
<div className="p-6 pb-0">
<div className="rounded-lg overflow-hidden">
<div className="w-full h-32 bg-gradient-to-br from-red-400 to-red-600 flex items-center justify-center">
<img
src={item.project_icon}
alt={item.project_name}
className="w-16 h-16 object-contain"
/>
{(() => {
const Icon = getDynamicIcon(item.project_icon);
return <Icon className="w-10 h-10 text-white" />;
})()}
</div>
</div>
</div>
@@ -65,15 +73,13 @@ export default function SertificateList() {
{new Date(item.start_date).toLocaleDateString("id-ID", {
year: "numeric",
month: "long",
})}
until
{new Date(item.start_date).toLocaleDateString("id-ID", {
})} until {new Date(item.end_date).toLocaleDateString("id-ID", {
year: "numeric",
month: "long",
})}
</p>
<p className="text-sm font-semibold text-gray-800 leading-snug">
{item.end_date}
{item.project_name}
</p>
<p className="text-xs text-gray-500 mt-1">
No: <span className="font-bold text-red-600">{item.status}</span>
@@ -1,16 +1,30 @@
import { useMemo } from "react";
import { useParams } from "react-router-dom";
import { useLocation } from "react-router-dom";
import CertificateAvailable from "./components/CertificateAvailabe";
import CertificateNotAvailable from "./components/CertificateNotAvailable";
import SertificateHistory from "./components/CertificateHistory";
import { useMyCertificates } from "../../../hooks/useSertificates";
import { useProjectDetail } from "../../../hooks/useProjects";
import type { Project } from "../../../types/project.types";
const SertificatePage = () => {
const { slug } = useParams<{ slug: string }>();
const { project, loading: projectLoading } = useProjectDetail(slug ?? "");
const location = useLocation();
const projectFromList = location.state?.project as Project | undefined;
const { project: projectDetail, loading: projectLoading } = useProjectDetail(slug ?? "");
const { certificates, loading: certLoading } = useMyCertificates();
const project = useMemo(() => {
if (!projectDetail) return null;
return {
...projectDetail,
task_done: projectFromList?.task_done ?? 0,
total_tasks: projectFromList?.total_tasks ?? projectDetail.total_tasks,
};
}, [projectDetail, projectFromList]);
const allTasksDone = !projectLoading && !!project && project.task_done >= project.total_tasks;
const alreadyDone = useMemo(() => {
@@ -1,7 +1,6 @@
import { useState, useEffect } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { useMyTasks } from '../../../../hooks/useProjects';
import api from '../../../../lib/api';
import { useCurrentUser } from '../../../../hooks/useUser';
import { useAllMentorTasks } from '../../../../hooks/useTasks';
@@ -1,331 +0,0 @@
// src/pages/mentor/CertificateDetail.tsx
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
CalendarDays,
CheckCircle2,
AlertCircle,
Eye,
ChevronLeft,
ChevronRight,
Sparkles,
} from "lucide-react";
// ─── Types ────────────────────────────────────────────────────────────────────
type InternStatus = "ELIGIBLE" | "INCOMPLETE";
interface Intern {
id: number;
name: string;
role: string;
initials: string;
taskProgress: number;
taskTotal: number;
status: InternStatus;
}
// ─── Mock Data ────────────────────────────────────────────────────────────────
const mockProject = {
title: "Edge Computing Framework",
period: "Dec 2023 — Mar 2024",
status: "COMPLETED" as const,
scope:
"Advanced research and implementation of low-latency computing nodes at the network edge, focusing on real-time data processing for autonomous systems.",
totalInterns: 21,
eligibleInterns: 18,
};
const mockInterns: Intern[] = [
{ id: 1, name: "Marcus Rodriguez", role: "Software Engineer Intern", initials: "MR", taskProgress: 10, taskTotal: 10, status: "ELIGIBLE" },
{ id: 2, name: "Priya Sharma", role: "Frontend Developer Intern", initials: "PS", taskProgress: 8, taskTotal: 10, status: "INCOMPLETE" },
{ id: 3, name: "James Smith", role: "QA Engineering Intern", initials: "JS", taskProgress: 10, taskTotal: 10, status: "ELIGIBLE" },
{ id: 4, name: "Nina Castillo", role: "Mobile Development Intern", initials: "NC", taskProgress: 9, taskTotal: 10, status: "ELIGIBLE" },
{ id: 5, name: "Derek Lim", role: "DevOps & Cloud Intern", initials: "DL", taskProgress: 7, taskTotal: 10, status: "INCOMPLETE" },
{ id: 6, name: "Alex Johnson", role: "Backend Engineering Intern", initials: "AJ", taskProgress: 10, taskTotal: 10, status: "ELIGIBLE" },
];
const TOTAL_ENTRIES = 1284;
const PER_PAGE = 3;
// ─── Avatar ───────────────────────────────────────────────────────────────────
const avatarColors: Record<string, string> = {
MR: "bg-blue-100 text-blue-700",
PS: "bg-purple-100 text-purple-700",
JS: "bg-green-100 text-green-700",
NC: "bg-pink-100 text-pink-700",
DL: "bg-teal-100 text-teal-700",
AJ: "bg-orange-100 text-orange-700",
};
function Avatar({ initials }: { initials: string }) {
const color = avatarColors[initials] ?? "bg-gray-100 text-gray-500";
return (
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-xs font-black flex-shrink-0 ${color}`}>
{initials}
</div>
);
}
function ProgressBar({ value, total }: { value: number; total: number }) {
const pct = Math.round((value / total) * 100);
const isComplete = value === total;
return (
<div className="flex items-center gap-2">
<div className="w-16 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${isComplete ? "bg-[#B30000]" : "bg-gray-400"}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className={`text-xs font-bold ${isComplete ? "text-[#B30000]" : "text-gray-500"}`}>
{value}/{total}
</span>
</div>
);
}
function StatusBadge({ status }: { status: InternStatus }) {
const isEligible = status === "ELIGIBLE";
return (
<div className={`flex items-center gap-1 text-[11px] font-extrabold tracking-wide ${isEligible ? "text-green-600" : "text-orange-500"}`}>
{isEligible
? <CheckCircle2 className="w-3.5 h-3.5" />
: <AlertCircle className="w-3.5 h-3.5" />}
{status}
</div>
);
}
export default function CertificateDetail() {
const navigate = useNavigate();
// Selected interns (checked)
const initialSelected = new Set(
mockInterns.filter((i) => i.status === "ELIGIBLE").map((i) => i.id)
);
const [selected, setSelected] = useState<Set<number>>(initialSelected);
const [page, setPage] = useState(1);
const totalPages = Math.ceil(mockInterns.length / PER_PAGE);
const paginated = mockInterns.slice((page - 1) * PER_PAGE, page * PER_PAGE);
const eligibleIds = mockInterns.filter((i) => i.status === "ELIGIBLE").map((i) => i.id);
const allEligibleSelected = eligibleIds.every((id) => selected.has(id));
const toggleSelectAll = () => {
if (allEligibleSelected) {
setSelected(new Set());
} else {
setSelected(new Set(eligibleIds));
}
};
const toggleOne = (id: number, status: InternStatus) => {
if (status === "INCOMPLETE") return; // tidak bisa dipilih
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
return (
<div>
<div className="mb-6">
<h1 className="page-title">Certificates</h1>
<p className="page-title-desc">
Administrate, issue, and track professional certifications for all interns.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
<div className="space-y-5">
{/* Status badge */}
<div className="flex items-center gap-1.5 text-xs font-extrabold text-green-600 tracking-wide">
<CheckCircle2 className="w-4 h-4" />
COMPLETED
</div>
<h2 className="text-3xl font-extrabold text-gray-900 leading-tight">
{mockProject.title}
</h2>
<div className="flex items-center gap-2 text-sm text-gray-400 font-medium">
<CalendarDays className="w-4 h-4 flex-shrink-0" />
{mockProject.period}
</div>
<div className="border border-gray-200 rounded-xl p-5">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-3">
Project Scope
</p>
<p className="text-sm text-gray-600 leading-relaxed">
{mockProject.scope}
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="border border-gray-200 rounded-xl p-4">
<p className="text-2xl font-extrabold text-[#B30000]">{mockProject.totalInterns}</p>
<p className="text-xs text-gray-400 font-medium mt-0.5">Total Interns</p>
</div>
<div className="border border-gray-200 rounded-xl p-4">
<p className="text-2xl font-extrabold text-gray-900">{mockProject.eligibleInterns}</p>
<p className="text-xs text-gray-400 font-medium mt-0.5">Eligible</p>
</div>
</div>
</div>
<div className="border border-gray-200 rounded-2xl overflow-hidden bg-white shadow-sm">
<div className="flex items-start justify-between px-5 py-4 border-b border-gray-100">
<div>
<h3 className="text-base font-bold text-gray-900">Intern Roster</h3>
<p className="text-xs text-gray-400 mt-0.5 leading-snug max-w-xs">
Confirm completion status before final certificate generation.
</p>
</div>
<button
onClick={toggleSelectAll}
className="flex items-center gap-2 border border-gray-200 rounded-xl px-4 py-2.5 bg-white hover:bg-gray-50 transition-colors flex-shrink-0 ml-4"
>
<div className={`w-4 h-4 rounded border-2 flex items-center justify-center flex-shrink-0 transition-colors ${
allEligibleSelected ? "bg-[#B30000] border-[#B30000]" : "border-gray-300"
}`}>
{allEligibleSelected && (
<svg className="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<span className="text-xs font-bold text-gray-700 whitespace-nowrap">
Select All Eligible ({mockProject.eligibleInterns})
</span>
</button>
</div>
{/* Column Headers */}
<div className="grid grid-cols-[auto_1fr_auto_auto_auto] items-center gap-3 px-5 py-2.5 bg-gray-50/70 border-b border-gray-100">
<div className="w-4" />
<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 w-20 text-center">Task Progress</span>
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider w-24 text-center">Status</span>
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider w-16 text-center">Action</span>
</div>
{/* Rows */}
<div className="divide-y divide-gray-50">
{paginated.map((intern) => {
const isChecked = selected.has(intern.id);
const isIncomplete = intern.status === "INCOMPLETE";
return (
<div
key={intern.id}
className="grid grid-cols-[auto_1fr_auto_auto_auto] items-center gap-3 px-5 py-3.5 hover:bg-gray-50/40 transition-colors"
>
{/* Checkbox */}
<button
onClick={() => toggleOne(intern.id, intern.status)}
disabled={isIncomplete}
className={`w-4 h-4 rounded border-2 flex items-center justify-center flex-shrink-0 transition-colors ${
isIncomplete
? "border-gray-200 bg-gray-50 cursor-not-allowed"
: isChecked
? "bg-[#B30000] border-[#B30000]"
: "border-gray-300 hover:border-gray-400"
}`}
>
{isChecked && !isIncomplete && (
<svg className="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</button>
{/* Name + role */}
<div className="flex items-center gap-3 min-w-0">
<Avatar initials={intern.initials} />
<div className="min-w-0">
<p className="text-sm font-bold text-gray-900 leading-tight truncate">{intern.name}</p>
<p className="text-[11px] text-gray-400 mt-0.5 truncate">{intern.role}</p>
</div>
</div>
{/* Task Progress */}
<div className="w-20 flex justify-center">
<ProgressBar value={intern.taskProgress} total={intern.taskTotal} />
</div>
{/* Status */}
<div className="w-24 flex justify-center">
<StatusBadge status={intern.status} />
</div>
{/* Action */}
<div className="w-16 flex justify-center">
{isIncomplete ? (
<button className="text-[11px] font-extrabold text-[#B30000] hover:underline whitespace-nowrap">
Nag Intern
</button>
) : (
<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>
)}
</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 {(page - 1) * PER_PAGE + 1} to {Math.min(page * PER_PAGE, mockInterns.length)} of {TOTAL_ENTRIES.toLocaleString()} entries
</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}
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>
</div>
{/* ── Bottom Action Bar ── */}
<div className="flex flex-col sm:flex-row items-center justify-end gap-4 mt-6 pt-4">
<button
onClick={() => navigate("/mentor/certificates/result")}
className="flex items-center gap-2 px-8 py-3.5 bg-[#B30000] hover:bg-[#990000] text-white font-bold text-sm rounded-xl shadow-md transition-colors"
>
Generate Certificates
<Sparkles className="w-4 h-4" />
</button>
</div>
</div>
);
}
@@ -0,0 +1,188 @@
import { useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { CalendarDays, UploadCloud, CheckCircle2, XCircle, Loader2 } from "lucide-react";
import { useUploadCertificateTemplate } from "../../../hooks/useSertificates";
import { useProjectDetail } from "../../../hooks/useProjects";
import EligibleInternTable from "./components/EligibleInternTable";
import { useProjectInternProgress } from "../../../hooks/useInternProgress"
export default function CertificateDetail() {
const navigate = useNavigate();
const { slug } = useParams<{ slug: string }>();
const { project, loading, error } = 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;
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploadSuccess, setUploadSuccess] = useState(false);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setSelectedFile(file);
setUploadSuccess(false);
};
const handleUpload = async () => {
if (!selectedFile || !project) return;
const result = await upload(project.id, selectedFile);
if (result) {
setUploadSuccess(true);
setSelectedFile(null);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20 text-gray-400 text-sm">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
Memuat data project...
</div>
);
}
if (error || !project) {
return (
<div className="flex flex-col items-center justify-center py-20 gap-3">
<XCircle className="w-10 h-10 text-red-400" />
<p className="text-gray-500 text-sm">{error ?? "Project tidak ditemukan."}</p>
<button
onClick={() => navigate(-1)}
className="text-xs text-[#B30000] font-semibold hover:underline"
>
Kembali
</button>
</div>
);
}
return (
<div>
<div className="mb-6">
<h1 className="page-title">Certificates</h1>
<p className="page-title-desc">
Administrate, issue, and track professional certifications for all interns.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-8 gap-6 items-start">
{/* ── Left Column ── */}
<div className="space-y-4 lg:col-span-3">
<h2 className="text-3xl font-extrabold text-gray-900 leading-tight">
{project.project_name}
</h2>
<div className="flex items-center gap-2 text-sm text-gray-400 font-medium">
<CalendarDays className="w-4 h-4 flex-shrink-0" />
{new Date(project.start_date).toLocaleDateString("en-US", { month: "short", year: "numeric" })}
{" — "}
{new Date(project.end_date).toLocaleDateString("en-US", { month: "short", year: "numeric" })}
</div>
{/* ── Upload Template ── */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-700 tracking-wide">
Certificate Template
</label>
{/* Drop Zone */}
<div
className="border-2 border-dashed border-card-outline bg-primary-foreground rounded-xl p-6 text-center flex flex-col items-center justify-center cursor-pointer hover:bg-red-50/20 transition-colors group"
onClick={() => fileInputRef.current?.click()}
>
<div className="w-10 h-10 rounded-full bg-red-50 flex items-center justify-center mb-2 group-hover:scale-105 transition-transform">
<UploadCloud className="w-5 h-5 text-[#B30000]" />
</div>
{selectedFile ? (
<>
<p className="text-xs font-bold text-gray-800 truncate max-w-[180px]">
{selectedFile.name}
</p>
<p className="text-[10px] text-gray-400 mt-0.5">
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
</p>
</>
) : (
<>
<p className="text-xs font-bold text-gray-800">Upload Certificate Image</p>
<p className="text-[10px] text-gray-400 mt-0.5">PNG, JPG up to 5MB</p>
</>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg"
className="hidden"
onChange={handleFileChange}
/>
{/* Feedback */}
{uploadSuccess && (
<div className="flex items-center gap-1.5 text-green-600 text-xs font-medium">
<CheckCircle2 className="w-4 h-4" />
Template berhasil diupload!
</div>
)}
{uploadError && (
<div className="flex items-center gap-1.5 text-red-600 text-xs font-medium">
<XCircle className="w-4 h-4" />
{uploadError}
</div>
)}
{/* Upload Button */}
{selectedFile && (
<button
onClick={handleUpload}
disabled={uploading}
className="w-full flex items-center justify-center gap-2 py-2 bg-[#B30000] hover:bg-red-800 disabled:opacity-60 text-white text-xs font-semibold rounded-lg transition-all active:scale-95"
>
{uploading ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Mengupload...
</>
) : (
<>
<UploadCloud className="w-3.5 h-3.5" />
Upload Template
</>
)}
</button>
)}
</div>
{/* ── Project Scope ── */}
<div className="border border-card-outline rounded-xl p-5">
<p className="text-[13px] font-bold text-gray-400 uppercase tracking-widest mb-3">
Project Scope
</p>
<p className="text-sm text-gray-600 leading-relaxed">{project.description}</p>
</div>
{/* ── Stats ── */}
<div className="grid grid-cols-2 gap-4">
<div className="border border-card-outline rounded-xl p-4">
<p className="text-2xl font-extrabold text-[#B30000]">{project.total_members}</p>
<p className="text-xs text-font font-medium mt-0.5">Total Interns</p>
</div>
<div className="border border-card-outline rounded-xl p-4">
<p className="text-2xl font-extrabold text-gray-900">{eligibleCount}</p>
<p className="text-xs text-font font-medium mt-0.5">Eligible</p>
</div>
</div>
</div>
{/* ── Right Column ── */}
<EligibleInternTable
interns={internProgress}
loading={progressLoading}
eligibleCount={eligibleCount}
/>
</div>
</div>
);
}
@@ -23,7 +23,7 @@ export default function CertificateList() {
{/* Section: List Projects */}
<div>
<h2 className="text-base font-bold text-gray-900 mb-4">List Projects</h2>
<h2 className="text-base font-bold text-font-shade mb-4">List Projects</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{projects.map((project) => (
@@ -3,7 +3,7 @@ import type { Project } from "../../../../types/project.types";
// ─── StatusBadge ─────────────────────────────────────────────────────────────
function StatusBadge({ status }: { status: string }) {
const isCompleted = status === "COMPLETED";
const isCompleted = status === "archived";
return (
<span
className={`text-[10px] font-extrabold tracking-wider px-2.5 py-1 rounded-full border ${
@@ -17,6 +17,19 @@ function StatusBadge({ status }: { status: string }) {
);
}
function formatDateRange(start: string, end: string): string {
const options: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "long",
year: "numeric",
};
const startDate = new Date(start).toLocaleDateString("id-ID", options);
const endDate = new Date(end).toLocaleDateString("id-ID", options);
return `${startDate} – ${endDate}`;
}
// ─── ProjectCard ──────────────────────────────────────────────────────────────
export default function CertificateProjectCard({
project,
@@ -28,7 +41,7 @@ export default function CertificateProjectCard({
return (
<div
onClick={onClick}
className="bg-white border border-gray-200 rounded-2xl p-5 cursor-pointer hover:shadow-md hover:border-gray-300 transition-all duration-200 flex flex-col gap-4"
className="bg-white border border-card-outline rounded-2xl p-5 cursor-pointer hover:shadow-md hover:border-gray-300 transition-all duration-200 flex flex-col gap-4"
>
{/* Top: status badge */}
<div className="flex items-start justify-between">
@@ -37,21 +50,21 @@ export default function CertificateProjectCard({
{/* Title + period */}
<div>
<h3 className="text-sm font-bold text-gray-900 leading-snug mb-1.5">
<h3 className="text-sm font-bold text-font-shade leading-snug mb-1.5">
{project.project_name}
</h3>
<div className="flex items-center gap-1.5 text-[11px] text-gray-400 font-medium">
<div className="flex items-center gap-1.5 text-[11px] text-font font-medium">
<CalendarDays className="w-3.5 h-3.5 flex-shrink-0" />
<span>{project.start_date} - {project.end_date}</span>
<span>{formatDateRange(project.start_date, project.end_date)}</span>
</div>
</div>
<hr className="border-gray-100" />
<hr className="border-card-outline" />
{/* Intern count */}
<div className="flex items-center justify-between">
<div>
<p className="text-[10px] text-gray-400 font-medium mb-0.5">Interns</p>
<p className="text-[10px] text-font font-medium mb-0.5">Interns</p>
<p className="text-sm font-extrabold text-[#B30000]">
{project.total_members} interns
</p>
@@ -0,0 +1,266 @@
// src/pages/mentor/EligibleInternTable.tsx
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
CheckCircle2,
AlertCircle,
Eye,
ChevronLeft,
ChevronRight,
Sparkles,
} from "lucide-react";
import type { InternSubmissionProgress } from "../../../../hooks/useInternProgress";
import { useClaimCertificate } from "../../../../hooks/useSertificates";
import { Loader2, Check } from "lucide-react";
interface EligibleInternTableProps {
interns: InternSubmissionProgress[];
loading: boolean;
eligibleCount: number;
}
const PER_PAGE = 5;
function ProgressBar({ value, total }: { value: number; total: number }) {
const pct = Math.round((value / total) * 100);
const isComplete = value === total;
return (
<div className="flex items-center gap-2">
<div className="w-16 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${isComplete ? "bg-[#B30000]" : "bg-gray-400"}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className={`text-xs font-bold ${isComplete ? "text-[#B30000]" : "text-gray-500"}`}>
{value}/{total}
</span>
</div>
);
}
function StatusBadge({ status }: { status: boolean }) {
const isEligible = status
return (
<div className={`flex items-center gap-1 text-[11px] font-extrabold tracking-wide ${isEligible ? "text-green-600" : "text-orange-500"}`}>
{isEligible
? <CheckCircle2 className="w-3.5 h-3.5" />
: <AlertCircle className="w-3.5 h-3.5" />}
{status}
</div>
);
}
interface EligibleInternTableProps {
interns: InternSubmissionProgress[];
loading: boolean;
eligibleCount: number;
}
function Avatar({ initials }: { initials: string }) {
return (
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-xs text-white flex-shrink-0 bg-primary`}>
{initials}
</div>
);
}
// ─── Component ────────────────────────────────────────────────────────────────
export default function EligibleInternTable({ interns, loading, eligibleCount }: EligibleInternTableProps) {
const navigate = useNavigate();
const initialSelected = new Set(
interns.filter(i => i.is_eligible).map(i => i.id_user)
);
const [selected, setSelected] = useState<Set<number>>(initialSelected);
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));
const toggleSelectAll = () => {
if (allEligibleSelected) {
setSelected(new Set());
} else {
setSelected(new Set(eligibleIds));
}
};
const toggleOne = (id: number, status: boolean) => {
if (status) return;
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
if (loading) {
return (
<div className="lg:col-span-5 flex items-center justify-center py-20 text-gray-400 text-sm">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
Memuat data intern...
</div>
);
}
return (
<div className="lg:col-span-5 flex flex-col gap-4">
{/* Roster Card */}
<div className="border border-box-border rounded-2xl overflow-hidden bg-white shadow-sm">
<div className="flex items-start justify-between px-5 py-4 border-b border-gray-100">
<div>
<h3 className="text-base font-bold text-gray-900">Intern Roster</h3>
<p className="text-xs text-gray-400 mt-0.5 leading-snug max-w-xs">
Confirm completion status before final certificate generation.
</p>
</div>
<button
onClick={toggleSelectAll}
className="flex items-center gap-2 border border-gray-200 rounded-xl px-4 py-2.5 bg-white hover:bg-gray-50 transition-colors flex-shrink-0 ml-4"
>
<div className={`w-4 h-4 rounded border-2 flex items-center justify-center flex-shrink-0 transition-colors ${allEligibleSelected ? "bg-[#B30000] border-[#B30000]" : "border-gray-300"
}`}>
{allEligibleSelected && (
<Check className="w-2.5 h-2.5 text-white" strokeWidth={3} />
)}
</div>
<span className="text-xs font-bold text-gray-700 whitespace-nowrap">
Select All Eligible ({eligibleCount})
</span>
</button>
</div>
{/* Column Headers */}
<div className="grid grid-cols-[16px_1fr_96px_88px_60px] items-center gap-x-3 px-5 py-2.5 bg-gray-50/70 border-b border-gray-100">
<div />
<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 text-center">Task Progress</span>
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider text-center">Status</span>
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider text-center">Action</span>
</div>
{/* Rows */}
<div className="divide-y divide-gray-50">
{paginated.map((intern) => {
const isChecked = selected.has(intern.id_user);
const isIncomplete = !intern.is_eligible;
const initials = intern.full_name
.split(" ")
.slice(0, 2)
.map(n => n[0])
.join("")
.toUpperCase();
return (
<div
key={intern.id_user}
className="grid grid-cols-[16px_1fr_96px_88px_60px] items-center gap-x-3 px-5 py-3.5 hover:bg-gray-50/40 transition-colors"
>
{/* Checkbox */}
<button
onClick={() => toggleOne(intern.id_user, intern.is_eligible)}
disabled={isIncomplete}
className={`w-4 h-4 rounded border-2 flex items-center justify-center flex-shrink-0 transition-colors ${isIncomplete
? "border-gray-200 bg-gray-50 cursor-not-allowed"
: isChecked
? "bg-[#B30000] border-[#B30000]"
: "border-gray-300 hover:border-gray-400"
}`}
>
{isChecked && !isIncomplete && (
<Check className="w-2.5 h-2.5 text-white" strokeWidth={3} />
)}
</button>
{/* Name + role */}
<div className="flex items-center gap-3">
{intern.avatar ? (
<img src={intern.avatar} className="w-10 h-10 rounded-full object-cover" />
) : (
<Avatar initials={initials} />
)}
<div>
<p className="text-sm font-bold text-gray-900">{intern.full_name}</p>
<p className="text-[11px] text-gray-400">{intern.email}</p>
</div>
</div>
{/* Task Progress */}
<div className="flex justify-center">
<ProgressBar value={intern.submitted} total={intern.total_tasks} />
</div>
{/* Status */}
<div className="flex justify-center">
<StatusBadge status={intern.is_eligible} />
</div>
{/* Action */}
<div className="flex justify-center">
{isIncomplete ? (
<button className="text-[11px] font-extrabold text-[#B30000] hover:underline whitespace-nowrap">
Nag Intern
</button>
) : (
<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>
)}
</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 {(page - 1) * PER_PAGE + 1} to {Math.min(page * PER_PAGE, interns.length)} of {interns.length} entries
</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}
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>
{/* ── Bottom Action Bar ── */}
<div className="flex items-center justify-end">
<button
onClick={() => navigate("/mentor/certificates/result")}
className="flex items-center gap-2 px-8 py-3.5 bg-[#B30000] hover:bg-[#990000] text-white font-bold text-sm rounded-xl shadow-md transition-colors"
>
Generate Certificates
<Sparkles className="w-4 h-4" />
</button>
</div>
</div>
);
}
@@ -4,7 +4,11 @@ import * as LucideIcons from 'lucide-react';
import type { LucideProps } from 'lucide-react';
import type { Project } from '../../../../types/project.types';
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 toSlug = (name: string) => name.toLowerCase().replace(/\s+/g, '-');
+81
View File
@@ -0,0 +1,81 @@
// hooks/useProjectInternProgress.ts
import { useState, useEffect, useCallback } from "react";
import { getProjectTasks, getTaskSubmissions } from "../services/TaskService";
import type { AdminTaskDetail } from "../types/task.types";
export interface InternSubmissionProgress {
id_user: number;
full_name: string;
email: string;
avatar: string | null;
submitted: number;
total_tasks: number;
is_eligible: boolean;
}
export const useProjectInternProgress = (projectSlug: string) => {
const [data, setData] = useState<InternSubmissionProgress[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refetch = useCallback(async () => {
if (!projectSlug) return;
setLoading(true);
setError(null);
try {
const tasks = await getProjectTasks(projectSlug);
const taskDetails = await Promise.all(
tasks.map((task) =>
getTaskSubmissions(task.slug, projectSlug) as Promise<AdminTaskDetail>
)
);
const internMap = new Map<number, InternSubmissionProgress>();
for (const taskDetail of taskDetails) {
if (!taskDetail?.submissions) continue;
for (const sub of taskDetail.submissions) {
// field yang benar: sub.submission (bukan submission_details)
const hasSubmitted = !!sub.submission;
const existing = internMap.get(sub.id_user);
if (existing) {
existing.total_tasks += 1;
if (hasSubmitted) existing.submitted += 1;
} else {
internMap.set(sub.id_user, {
id_user: sub.id_user,
full_name: sub.full_name,
email: sub.email,
avatar: sub.profile_picture, // field yang benar
submitted: hasSubmitted ? 1 : 0,
total_tasks: 1,
is_eligible: false,
});
}
}
}
const result = Array.from(internMap.values()).map((intern) => ({
...intern,
is_eligible:
intern.total_tasks > 0 && intern.submitted === intern.total_tasks,
}));
setData(result);
} catch {
setError("Gagal memuat progress intern.");
} finally {
setLoading(false);
}
}, [projectSlug]);
useEffect(() => {
refetch();
}, [refetch]);
return { data, loading, error, refetch };
};
+2 -1
View File
@@ -53,7 +53,8 @@ export const getProjectById = async (
): Promise<ProjectDetail> => {
const res = await api.get(`/project-api/get/${id}`);
const raw = res.data.data;
console.log("raw fields:", Object.keys(raw));
console.log("raw full:", raw);
return {
...raw,
members: raw.members.map((m: any) => ({
+2 -1
View File
@@ -1,4 +1,4 @@
// types/project.types.ts
import type { ProjectTask } from "./task.types";
export type ProjectStatus = "active" | "completed" | "archived";
export type MemberStatus = "active" | "inactive";
@@ -33,6 +33,7 @@ export interface Project {
// Detail project (includes members array)
export interface ProjectDetail extends Project {
members: ProjectMember[];
tasks: ProjectTask[];
}
// Response GET /project-api/my-tasks