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:
+5
-5
@@ -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 />} />
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
+16
-10
@@ -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>
|
||||
+15
-1
@@ -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, '-');
|
||||
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user