feat: add CertificateForm and CertificateResult components for managing intern certificates

- Implemented CertificateForm for uploading certificate templates and displaying project details.
- Created CertificateResult to show generated certificates with pagination and search functionality.
- Added EligibleInternTable component to display eligible interns and their progress.
- Updated CertificateListPage to reflect changes in project listing styles.
- Enhanced MentorProjectCard with dynamic icon rendering.
- Introduced useProjectInternProgress hook for fetching intern progress data.
- Modified ProjectService to include task details in project responses.
- Updated project types to accommodate new task structures.
This commit is contained in:
Kemas M. Aryadary Rasyad
2026-06-28 22:22:03 +07:00
parent 37e21a1f39
commit 732684200f
17 changed files with 612 additions and 371 deletions
+5 -5
View File
@@ -31,7 +31,7 @@ import EditLowongan from "./Pages/EditLowongan";
import HomeInternPage from "./Pages/Internify/intern/HomeInternPage"; import 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 />} />
+3 -2
View File
@@ -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 (
View File
+7 -9
View File
@@ -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
@@ -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>
@@ -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, '-');
+81
View File
@@ -0,0 +1,81 @@
// hooks/useProjectInternProgress.ts
import { useState, useEffect, useCallback } from "react";
import { getProjectTasks, getTaskSubmissions } from "../services/TaskService";
import type { AdminTaskDetail } from "../types/task.types";
export interface InternSubmissionProgress {
id_user: number;
full_name: string;
email: string;
avatar: string | null;
submitted: number;
total_tasks: number;
is_eligible: boolean;
}
export const useProjectInternProgress = (projectSlug: string) => {
const [data, setData] = useState<InternSubmissionProgress[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refetch = useCallback(async () => {
if (!projectSlug) return;
setLoading(true);
setError(null);
try {
const tasks = await getProjectTasks(projectSlug);
const taskDetails = await Promise.all(
tasks.map((task) =>
getTaskSubmissions(task.slug, projectSlug) as Promise<AdminTaskDetail>
)
);
const internMap = new Map<number, InternSubmissionProgress>();
for (const taskDetail of taskDetails) {
if (!taskDetail?.submissions) continue;
for (const sub of taskDetail.submissions) {
// field yang benar: sub.submission (bukan submission_details)
const hasSubmitted = !!sub.submission;
const existing = internMap.get(sub.id_user);
if (existing) {
existing.total_tasks += 1;
if (hasSubmitted) existing.submitted += 1;
} else {
internMap.set(sub.id_user, {
id_user: sub.id_user,
full_name: sub.full_name,
email: sub.email,
avatar: sub.profile_picture, // field yang benar
submitted: hasSubmitted ? 1 : 0,
total_tasks: 1,
is_eligible: false,
});
}
}
}
const result = Array.from(internMap.values()).map((intern) => ({
...intern,
is_eligible:
intern.total_tasks > 0 && intern.submitted === intern.total_tasks,
}));
setData(result);
} catch {
setError("Gagal memuat progress intern.");
} finally {
setLoading(false);
}
}, [projectSlug]);
useEffect(() => {
refetch();
}, [refetch]);
return { data, loading, error, refetch };
};
+2 -1
View File
@@ -53,7 +53,8 @@ export const getProjectById = async (
): Promise<ProjectDetail> => { ): 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) => ({
+2 -1
View File
@@ -1,4 +1,4 @@
// types/project.types.ts import type { ProjectTask } from "./task.types";
export type ProjectStatus = "active" | "completed" | "archived"; export type 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