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 HomeInternPage from "./Pages/Internify/intern/HomeInternPage";
|
||||||
import { InternLayout, MentorLayout } from "./Pages/Internify/layout";
|
import { InternLayout, MentorLayout } from "./Pages/Internify/layout";
|
||||||
import ProjectsPage from "./Pages/Internify/intern/ProjectsPage";
|
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 ProjectDetailPage from "./Pages/Internify/intern/ProjectDetailPage";
|
||||||
import HomeMentorPage from "./Pages/Internify/mentor/HomeMentorPage";
|
import HomeMentorPage from "./Pages/Internify/mentor/HomeMentorPage";
|
||||||
import MentorProjectsPage from "./Pages/Internify/mentor/BaseProjectsPage";
|
import MentorProjectsPage from "./Pages/Internify/mentor/BaseProjectsPage";
|
||||||
@@ -44,12 +44,12 @@ import NotificationList from "./Pages/Internify/NotificationList";
|
|||||||
import SettingsContent from "./Pages/Internify/SettingsPage";
|
import SettingsContent from "./Pages/Internify/SettingsPage";
|
||||||
import MentorSettingsPage from "./Pages/Internify/mentor/MentorSettingsPage";
|
import MentorSettingsPage from "./Pages/Internify/mentor/MentorSettingsPage";
|
||||||
import TaskSubmission from "./Pages/Internify/intern/TaskSubmission";
|
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 MentorProjectsDetailPage from "./Pages/Internify/mentor/MentorProjectDetailPage";
|
||||||
import ViewInternSubmission from "./Pages/Internify/mentor/ViewInternSubmission";
|
import ViewInternSubmission from "./Pages/Internify/mentor/ViewInternSubmission";
|
||||||
import ProtectedRoutes from "./Pages/utils/ProtectedRoute";
|
import ProtectedRoutes from "./Pages/utils/ProtectedRoute";
|
||||||
import CertificateDetail from "./Pages/Internify/mentor/CertificateDetail";
|
import CertificateDetail from "./Pages/Internify/mentor/CertificateForm";
|
||||||
import CertificateResult from "./Pages/Internify/mentor/CertificateGenerate";
|
import CertificateResult from "./Pages/Internify/mentor/CertificateResult";
|
||||||
import NotFoundPage from "./Pages/NotFoundPage";
|
import NotFoundPage from "./Pages/NotFoundPage";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -112,7 +112,7 @@ function App() {
|
|||||||
<Route index element={<HomeMentorPage />} />
|
<Route index element={<HomeMentorPage />} />
|
||||||
<Route path="projects" element={<MentorProjectsPage />} />
|
<Route path="projects" element={<MentorProjectsPage />} />
|
||||||
<Route path="certificates" element={<MentorCertificatePage />} />
|
<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="certificates/result" element={<CertificateResult />} />
|
||||||
<Route path="intern" element={<MentorInternPage />} />
|
<Route path="intern" element={<MentorInternPage />} />
|
||||||
<Route path="projects/:slug" element={<MentorDetailProject />} />
|
<Route path="projects/:slug" element={<MentorDetailProject />} />
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { useCurrentUser } from "../hooks/useUser";
|
|||||||
* "Andi Mahesa" → "AM"
|
* "Andi Mahesa" → "AM"
|
||||||
* "Andi Bayu Cendika" → "AC" (huruf pertama + huruf pertama kata terakhir)
|
* "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);
|
const parts = fullName.trim().split(/\s+/).filter(Boolean);
|
||||||
if (parts.length === 0) return "U";
|
if (parts.length === 0) return "U";
|
||||||
if (parts.length === 1) return parts[0].charAt(0).toUpperCase();
|
if (parts.length === 1) return parts[0].charAt(0).toUpperCase();
|
||||||
@@ -22,7 +23,7 @@ export default function Header({ toggleSidebar }: { toggleSidebar: () => void })
|
|||||||
const settingsPath =
|
const settingsPath =
|
||||||
user?.role === "intern" ? "/intern/settings" : "/mentor/settings";
|
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;
|
const hasPhoto = !!user?.profile_picture;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Home, ArrowLeft, Search } from "lucide-react";
|
import { Home, ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
export default function NotFoundPage() {
|
export default function NotFoundPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* 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 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" />
|
<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 */}
|
{/* 404 Large Text */}
|
||||||
<div className="relative mb-4 select-none">
|
<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
|
404
|
||||||
</span>
|
</span>
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<h1 className="text-3xl sm:text-4xl font-bold text-white mb-3 tracking-tight">
|
<h1 className="text-3xl sm:text-4xl font-bold text-white mb-3 tracking-tight">
|
||||||
Halaman Tidak Ditemukan
|
Not Found
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<p className="text-slate-400 text-base sm:text-lg mb-10 max-w-md leading-relaxed">
|
<p className="text-slate-800 text-base sm:text-lg mb-10 max-w-md leading-relaxed">
|
||||||
Sepertinya halaman yang kamu cari tidak ada atau telah dipindahkan.
|
Sorry, the page you are looking for does not exist or has been moved. Let's get back on track.
|
||||||
Coba kembali ke halaman sebelumnya atau beranda.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
@@ -40,7 +38,7 @@ export default function NotFoundPage() {
|
|||||||
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
|
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(-1)}
|
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" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
Kembali
|
Kembali
|
||||||
|
|||||||
+16
-10
@@ -2,10 +2,19 @@ import { Loader2, AlertCircle } from "lucide-react";
|
|||||||
import { useMyProjects } from "../../../hooks/useProjects";
|
import { useMyProjects } from "../../../hooks/useProjects";
|
||||||
import type { Project } from "../../../types/project.types";
|
import type { Project } from "../../../types/project.types";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import * as LucideIcons from 'lucide-react';
|
||||||
|
import type { LucideProps } from 'lucide-react';
|
||||||
|
|
||||||
|
|
||||||
export default function SertificateList() {
|
export default function SertificateList() {
|
||||||
const { projects, loading, error } = useMyProjects();
|
const { projects, loading, error } = useMyProjects();
|
||||||
const navigate = useNavigate();
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -44,17 +53,16 @@ export default function SertificateList() {
|
|||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-shadow cursor-pointer group"
|
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 */}
|
{/* Thumbnail */}
|
||||||
<div className="p-6 pb-0">
|
<div className="p-6 pb-0">
|
||||||
<div className="rounded-lg overflow-hidden">
|
<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">
|
<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}
|
const Icon = getDynamicIcon(item.project_icon);
|
||||||
alt={item.project_name}
|
return <Icon className="w-10 h-10 text-white" />;
|
||||||
className="w-16 h-16 object-contain"
|
})()}
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,15 +73,13 @@ export default function SertificateList() {
|
|||||||
{new Date(item.start_date).toLocaleDateString("id-ID", {
|
{new Date(item.start_date).toLocaleDateString("id-ID", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
})}
|
})} until {new Date(item.end_date).toLocaleDateString("id-ID", {
|
||||||
until
|
|
||||||
{new Date(item.start_date).toLocaleDateString("id-ID", {
|
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm font-semibold text-gray-800 leading-snug">
|
<p className="text-sm font-semibold text-gray-800 leading-snug">
|
||||||
{item.end_date}
|
{item.project_name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
No: <span className="font-bold text-red-600">{item.status}</span>
|
No: <span className="font-bold text-red-600">{item.status}</span>
|
||||||
+16
-2
@@ -1,16 +1,30 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
import CertificateAvailable from "./components/CertificateAvailabe";
|
import CertificateAvailable from "./components/CertificateAvailabe";
|
||||||
import CertificateNotAvailable from "./components/CertificateNotAvailable";
|
import CertificateNotAvailable from "./components/CertificateNotAvailable";
|
||||||
import SertificateHistory from "./components/CertificateHistory";
|
import SertificateHistory from "./components/CertificateHistory";
|
||||||
import { useMyCertificates } from "../../../hooks/useSertificates";
|
import { useMyCertificates } from "../../../hooks/useSertificates";
|
||||||
import { useProjectDetail } from "../../../hooks/useProjects";
|
import { useProjectDetail } from "../../../hooks/useProjects";
|
||||||
|
import type { Project } from "../../../types/project.types";
|
||||||
|
|
||||||
const SertificatePage = () => {
|
const SertificatePage = () => {
|
||||||
const { slug } = useParams<{ slug: string }>();
|
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 { 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 allTasksDone = !projectLoading && !!project && project.task_done >= project.total_tasks;
|
||||||
|
|
||||||
const alreadyDone = useMemo(() => {
|
const alreadyDone = useMemo(() => {
|
||||||
@@ -35,7 +49,7 @@ const SertificatePage = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-4 flex flex-col gap-1">
|
<div className="mb-4 flex flex-col gap-1">
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
import { useMyTasks } from '../../../../hooks/useProjects';
|
import { useMyTasks } from '../../../../hooks/useProjects';
|
||||||
import api from '../../../../lib/api';
|
|
||||||
import { useCurrentUser } from '../../../../hooks/useUser';
|
import { useCurrentUser } from '../../../../hooks/useUser';
|
||||||
import { useAllMentorTasks } from '../../../../hooks/useTasks';
|
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 */}
|
{/* Section: List Projects */}
|
||||||
<div>
|
<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">
|
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Project } from "../../../../types/project.types";
|
|||||||
|
|
||||||
// ─── StatusBadge ─────────────────────────────────────────────────────────────
|
// ─── StatusBadge ─────────────────────────────────────────────────────────────
|
||||||
function StatusBadge({ status }: { status: string }) {
|
function StatusBadge({ status }: { status: string }) {
|
||||||
const isCompleted = status === "COMPLETED";
|
const isCompleted = status === "archived";
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`text-[10px] font-extrabold tracking-wider px-2.5 py-1 rounded-full border ${
|
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 ──────────────────────────────────────────────────────────────
|
// ─── ProjectCard ──────────────────────────────────────────────────────────────
|
||||||
export default function CertificateProjectCard({
|
export default function CertificateProjectCard({
|
||||||
project,
|
project,
|
||||||
@@ -28,7 +41,7 @@ export default function CertificateProjectCard({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
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 */}
|
{/* Top: status badge */}
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
@@ -37,21 +50,21 @@ export default function CertificateProjectCard({
|
|||||||
|
|
||||||
{/* Title + period */}
|
{/* Title + period */}
|
||||||
<div>
|
<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}
|
{project.project_name}
|
||||||
</h3>
|
</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" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr className="border-gray-100" />
|
<hr className="border-card-outline" />
|
||||||
|
|
||||||
{/* Intern count */}
|
{/* Intern count */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<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]">
|
<p className="text-sm font-extrabold text-[#B30000]">
|
||||||
{project.total_members} interns
|
{project.total_members} interns
|
||||||
</p>
|
</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 { LucideProps } from 'lucide-react';
|
||||||
import type { Project } from '../../../../types/project.types';
|
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, '-');
|
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> => {
|
): Promise<ProjectDetail> => {
|
||||||
const res = await api.get(`/project-api/get/${id}`);
|
const res = await api.get(`/project-api/get/${id}`);
|
||||||
const raw = res.data.data;
|
const raw = res.data.data;
|
||||||
|
console.log("raw fields:", Object.keys(raw));
|
||||||
|
console.log("raw full:", raw);
|
||||||
return {
|
return {
|
||||||
...raw,
|
...raw,
|
||||||
members: raw.members.map((m: any) => ({
|
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 ProjectStatus = "active" | "completed" | "archived";
|
||||||
export type MemberStatus = "active" | "inactive";
|
export type MemberStatus = "active" | "inactive";
|
||||||
@@ -33,6 +33,7 @@ export interface Project {
|
|||||||
// Detail project (includes members array)
|
// Detail project (includes members array)
|
||||||
export interface ProjectDetail extends Project {
|
export interface ProjectDetail extends Project {
|
||||||
members: ProjectMember[];
|
members: ProjectMember[];
|
||||||
|
tasks: ProjectTask[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Response GET /project-api/my-tasks
|
// Response GET /project-api/my-tasks
|
||||||
|
|||||||
Reference in New Issue
Block a user