feat: update project task card to use new task type and improve navigation

fix: update project card to reflect actual tasks done from project data

feat: enhance task form components to accept project ID for submissions

refactor: clean up certificate detail page by removing unused elements

chore: streamline certificate generation page by removing unnecessary navigation

refactor: update certificate list page to use dynamic project data

feat: improve interns management page with API integration and better state handling

feat: add modal for assigning interns to projects with project selection

feat: create a new component for displaying project cards in certificate list

fix: correct import paths in create project dialog component

refactor: enhance project hooks to include task filtering and refetch capabilities

fix: update mock certificate data to reflect correct status

feat: extend certificate types to include new fields for better tracking

feat: add task_done field to project types for improved task management
This commit is contained in:
Kemas M. Aryadary Rasyad
2026-06-28 08:12:56 +07:00
parent 6ec9620f1f
commit ad26a53f21
26 changed files with 587 additions and 347 deletions
+5 -1
View File
@@ -50,6 +50,7 @@ import SettingsPage from "./Pages/Internify/mentor/MentorSettingsPage";
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/CertificateDetail";
import CertificateResult from "./Pages/Internify/mentor/CertificateGenerate"; import CertificateResult from "./Pages/Internify/mentor/CertificateGenerate";
import NotFoundPage from "./Pages/NotFoundPage";
function App() { function App() {
return ( return (
@@ -111,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/:id" element={<CertificateDetail />} /> <Route path="certificates/:name" 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 />} />
@@ -123,6 +124,9 @@ function App() {
</Route> </Route>
</Route> </Route>
{/* 404 catch-all */}
<Route path="*" element={<NotFoundPage />} />
</Routes> </Routes>
</Router> </Router>
); );
+65
View File
@@ -0,0 +1,65 @@
import { useNavigate } from "react-router-dom";
import { Home, ArrowLeft, Search } from "lucide-react";
export default function NotFoundPage() {
const navigate = useNavigate();
return (
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-red-950 flex items-center justify-center px-6 relative overflow-hidden">
{/* Background decorative blobs */}
<div className="absolute top-0 left-1/4 w-96 h-96 bg-red-700 opacity-10 rounded-full blur-3xl pointer-events-none" />
<div className="absolute bottom-0 right-1/4 w-96 h-96 bg-red-500 opacity-10 rounded-full blur-3xl pointer-events-none" />
<div className="relative z-10 flex flex-col items-center text-center max-w-2xl w-full">
{/* 404 Large Text */}
<div className="relative mb-4 select-none">
<span className="text-[160px] sm:text-[200px] font-black leading-none bg-gradient-to-b from-white/20 to-white/5 bg-clip-text text-transparent">
404
</span>
<div className="absolute inset-0 flex items-center justify-center">
<Search className="w-20 h-20 sm:w-28 sm:h-28 text-red-500 opacity-80 drop-shadow-lg" />
</div>
</div>
{/* Title */}
<h1 className="text-3xl sm:text-4xl font-bold text-white mb-3 tracking-tight">
Halaman Tidak Ditemukan
</h1>
{/* Description */}
<p className="text-slate-400 text-base sm:text-lg mb-10 max-w-md leading-relaxed">
Sepertinya halaman yang kamu cari tidak ada atau telah dipindahkan.
Coba kembali ke halaman sebelumnya atau beranda.
</p>
{/* Divider */}
<div className="w-24 h-px bg-gradient-to-r from-transparent via-red-500 to-transparent mb-10" />
{/* Buttons */}
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
<button
onClick={() => navigate(-1)}
className="flex items-center justify-center gap-2 px-6 py-3 rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 text-white font-semibold text-sm transition-all active:scale-95 backdrop-blur-sm"
>
<ArrowLeft className="w-4 h-4" />
Kembali
</button>
<button
onClick={() => navigate("/")}
className="flex items-center justify-center gap-2 px-6 py-3 rounded-xl bg-red-700 hover:bg-red-600 text-white font-semibold text-sm transition-all active:scale-95 shadow-lg shadow-red-900/40"
>
<Home className="w-4 h-4" />
Ke Beranda
</button>
</div>
{/* Footer note */}
<p className="mt-12 text-slate-600 text-xs">
Internify &copy; {new Date().getFullYear()} — HUMIC Engineering
</p>
</div>
</div>
);
}
+2 -1
View File
@@ -90,7 +90,8 @@ export default function NotificationList() {
{/* Actions */} {/* Actions */}
{type === "new-task" && !isOld && ( {type === "new-task" && !isOld && (
<div className="flex items-center gap-2 pt-3"> <div className="flex items-center gap-2 pt-3">
<button className="px-4 py-1.5 bg-[#B30000] hover:bg-[#990000] text-white text-xs font-bold rounded-lg shadow-sm transition-colors"> <button
className="px-4 py-1.5 bg-[#B30000] hover:bg-[#990000] text-white text-xs font-bold rounded-lg shadow-sm transition-colors">
View Task View Task
</button> </button>
<button className="px-4 py-1.5 border border-gray-200 text-gray-500 hover:text-gray-700 hover:bg-gray-50 text-xs font-semibold rounded-lg transition-colors"> <button className="px-4 py-1.5 border border-gray-200 text-gray-500 hover:text-gray-700 hover:bg-gray-50 text-xs font-semibold rounded-lg transition-colors">
+2 -4
View File
@@ -1,13 +1,11 @@
import { useParams } from 'react-router-dom';
import ProjectTimeline from './components/ProjectTimeline'; import ProjectTimeline from './components/ProjectTimeline';
import TaskCard from './components/ProjectTaskCard'; import TaskCard from './components/ProjectTaskCard';
import HeroProject from './components/Hero-project'; import HeroProject from './components/Hero-project';
import { useProjectTasks } from '../../../hooks/useTasks'; import { useProjectMyTasks } from '../../../hooks/useProjects';
import type { Project } from '../../../types/project.types'; import type { Project } from '../../../types/project.types';
export default function ForumTab({ project }: { project: Project }) { export default function ForumTab({ project }: { project: Project }) {
const { slug } = useParams<{ slug: string }>(); const { tasks, loading, error } = useProjectMyTasks(project.id);
const { tasks, loading, error } = useProjectTasks(slug ?? "");
return ( return (
<div> <div>
<HeroProject title={project.project_name} description={project.description} /> <HeroProject title={project.project_name} description={project.description} />
@@ -8,8 +8,10 @@ export default function ProjectDetailPage() {
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
const { project, loading, error } = useProjectDetail(slug ?? ""); const { project, loading, error } = useProjectDetail(slug ?? "");
const [activeTab, setActiveTab] = useState<'forum' | 'participants'>('forum'); const [activeTab, setActiveTab] = useState<'forum' | 'participants'>('forum');
if (loading) return <p className="p-10 text-gray-400">Memuat project...</p>; if (loading) return <p className="p-10 text-gray-400">Memuat project...</p>;
if (error) return <p className="p-10 text-red-500">{error}</p>; if (error) return <p className="p-10 text-red-500">{error}</p>;
if (!project) return <p className="p-10 text-gray-400">Project tidak ditemukan.</p>; if (!project) return <p className="p-10 text-gray-400">Project tidak ditemukan.</p>;
const tabClass = (tab: string) => const tabClass = (tab: string) =>
+41 -11
View File
@@ -1,8 +1,11 @@
import { Award } from "lucide-react"; import { Award, Loader2, AlertCircle } from "lucide-react";
import { history, type CertificateHistory } from "../../../lib/mockSertificates"; import { useMyCertificates } from "../../../hooks/useSertificates";
import type { Certificate } from "../../../types/certificate.types";
import certificateImg from "../../../assets/certificate.png"; import certificateImg from "../../../assets/certificate.png";
export default function SertificateList() { export default function SertificateList() {
const { certificates, loading, error } = useMyCertificates();
return ( return (
<div> <div>
{/* Header */} {/* Header */}
@@ -11,15 +14,33 @@ export default function SertificateList() {
<p className="page-title-desc">All your earned internship certificates</p> <p className="page-title-desc">All your earned internship certificates</p>
</div> </div>
{/* Grid */} {/* Loading */}
{history.length === 0 ? ( {loading && (
<div className="flex items-center justify-center py-20 text-gray-400 gap-2">
<Loader2 className="w-6 h-6 animate-spin" />
<span className="text-sm">Loading certificates...</span>
</div>
)}
{/* Error */}
{error && !loading && (
<div className="flex items-center justify-center py-20 text-red-500 gap-2">
<AlertCircle className="w-6 h-6" />
<span className="text-sm">{error}</span>
</div>
)}
{/* Empty */}
{!loading && !error && certificates.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-gray-400"> <div className="flex flex-col items-center justify-center py-20 text-gray-400">
<Award className="w-12 h-12 mb-3 opacity-40" />
<p className="text-sm">No certificates yet.</p> <p className="text-sm">No certificates yet.</p>
</div> </div>
) : ( )}
{/* Grid */}
{!loading && !error && certificates.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
{history.map((item: CertificateHistory) => ( {certificates.map((item: Certificate) => (
<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"
@@ -29,7 +50,7 @@ export default function SertificateList() {
<div className="rounded-lg overflow-hidden"> <div className="rounded-lg overflow-hidden">
<img <img
src={certificateImg} src={certificateImg}
alt={item.title} alt={item.project.project_name}
className="w-full h-[160px] object-cover" className="w-full h-[160px] object-cover"
/> />
</div> </div>
@@ -37,9 +58,18 @@ export default function SertificateList() {
{/* Info */} {/* Info */}
<div className="px-4 py-3"> <div className="px-4 py-3">
<p className="text-xs text-gray-400 mb-0.5">{item.month}</p> <p className="text-xs text-gray-400 mb-0.5">
<p className="text-sm font-semibold text-gray-800 leading-snug">{item.title}</p> {new Date(item.issued_at).toLocaleDateString("id-ID", {
<p className="text-xs text-gray-500 mt-1">Grade: <span className="font-bold text-red-600">{item.grade}</span></p> year: "numeric",
month: "long",
})}
</p>
<p className="text-sm font-semibold text-gray-800 leading-snug">
{item.project.project_name}
</p>
<p className="text-xs text-gray-500 mt-1">
No: <span className="font-bold text-red-600">{item.certificate_no}</span>
</p>
</div> </div>
</div> </div>
))} ))}
+59 -9
View File
@@ -1,31 +1,81 @@
import { certificateData } from "../../../lib/mockSertificates"; import { useState, useMemo } from "react";
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, useClaimCertificate } from "../../../hooks/useSertificates";
import { useMyProjects } from "../../../hooks/useProjects";
import type { ProjectDetail } from "../../../types/project.types";
const SertificatePage = () => { const SertificatePage = () => {
const data = certificateData; const { projects, loading: projectsLoading } = useMyProjects();
const { certificates, loading: certLoading } = useMyCertificates();
const { claim, loading: claiming } = useClaimCertificate();
const [justClaimed, setJustClaimed] = useState(false);
const current_task = useMemo(() => {
if (!projects || projects.length === 0) return null;
return projects.find((p) => p.task_done < p.total_tasks) ?? null;
}, [projects]);
const finished_task = useMemo((): ProjectDetail | null => {
if (!projects || projects.length === 0) return null;
return projects.find((p) => p.task_done >= p.total_tasks) ?? null;
}, [projects]);
// Cek apakah finished_task sudah punya certificate di backend
const alreadyClaimed = useMemo(() => {
if (!finished_task || !certificates.length) return false;
return certificates.some((cert) => cert.project.id === finished_task.id);
}, [certificates, finished_task]);
const allTasksDone = !projectsLoading && current_task === null && projects.length > 0;
const progress =
current_task && current_task.total_tasks > 0
? Math.round((current_task.task_done / current_task.total_tasks) * 100)
: 100;
const remainingTasks = current_task
? current_task.total_tasks - current_task.task_done
: 0;
const handleClaim = async () => {
if (!finished_task) return;
const result = await claim(finished_task.id);
if (result) setJustClaimed(true);
};
const isLoading = projectsLoading || certLoading;
const showCertificate = alreadyClaimed || justClaimed;
if (isLoading) {
return (
<div className="flex items-center justify-center py-20 text-gray-400 text-sm">
Loading...
</div>
);
}
console.log(finished_task)
return ( return (
<div> <div>
{/* Header */}
<div className="mb-4 flex flex-col gap-1"> <div className="mb-4 flex flex-col gap-1">
<h1 className="page-title">Certificates</h1> <h1 className="page-title">Certificates</h1>
<p className="page-title-desc">View and download internship certificates</p> <p className="page-title-desc">View and download internship certificates</p>
</div> </div>
<div className="flex flex-col lg:flex-row gap-6 items-stretch"> <div className="flex flex-col lg:flex-row gap-6 items-stretch">
{/* Kiri: Content */}
<div className="w-full lg:w-5/8 flex flex-col"> <div className="w-full lg:w-5/8 flex flex-col">
{data.isReceived ? ( {showCertificate ? (
<CertificateAvailable data={data} /> <CertificateAvailable project={finished_task} />
) : ( ) : (
<CertificateNotAvailable <CertificateNotAvailable
progress={data.projectProgress} progress={progress}
remainingTasks={data.remainingTasks} remainingTasks={remainingTasks}
allTasksDone={allTasksDone}
onClaim={handleClaim}
claiming={claiming}
/> />
)} )}
</div> </div>
{/* Kanan: History */}
<div className="w-full lg:w-3/8 flex-shrink-0"> <div className="w-full lg:w-3/8 flex-shrink-0">
<SertificateHistory /> <SertificateHistory />
</div> </div>
@@ -49,13 +49,15 @@ export default function TaskSubmission() {
{submissionType === "file_upload" ? ( {submissionType === "file_upload" ? (
<TaskFormFile <TaskFormFile
taskId={taskSlug!} taskId={String(task.id)}
projectId={String(task.project.id)}
deadline={new Date(task.deadline_at)} deadline={new Date(task.deadline_at)}
initialSubmission={task.my_submission} initialSubmission={task.my_submission}
/> />
) : ( ) : (
<TaskFormLink <TaskFormLink
taskId={taskSlug!} taskId={String(task.id)}
projectId={String(task.project.id)}
deadline={new Date(task.deadline_at)} deadline={new Date(task.deadline_at)}
initialSubmission={task.my_submission} initialSubmission={task.my_submission}
/> />
@@ -1,10 +1,13 @@
import { useState } from "react"; import { useState } from "react";
import { Download, Link, Printer } from "lucide-react"; import { Download, Link, Printer } from "lucide-react";
import { type CertificateData } from "../../../../lib/mockSertificates"; import type { ProjectDetail } from "../../../../types/project.types";
import certificateImg from "../../../../assets/certificate.png"; import certificateImg from "../../../../assets/certificate.png";
export default function CertificateAvailable({ data }: { data: CertificateData }) { interface CertificateAvailableProps {
const { certificate } = data; project: ProjectDetail | null;
}
export default function CertificateAvailable({ project }: CertificateAvailableProps) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const handleCopyLink = () => { const handleCopyLink = () => {
@@ -13,10 +16,25 @@ export default function CertificateAvailable({ data }: { data: CertificateData }
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
}; };
// Hitung durasi dari start_date - end_date
const duration = project
? (() => {
const start = new Date(project.start_date);
const end = new Date(project.end_date);
const diffMs = end.getTime() - start.getTime();
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
const months = Math.floor(diffDays / 30);
const days = diffDays % 30;
if (months > 0 && days > 0) return `${months} Bulan ${days} Hari`;
if (months > 0) return `${months} Bulan`;
return `${diffDays} Hari`;
})()
: "-";
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Certificate Card */} {/* Certificate Card */}
<div className="flex rounded-xl p-5 mb-1 border border-gray-300 shadow-md items-center justify-center"> <div className="flex rounded-xl p-5 mb-1 border bg-box-primary border-box-border shadow-md items-center justify-center">
<img <img
src={certificateImg} src={certificateImg}
alt="Certificate" alt="Certificate"
@@ -32,13 +50,14 @@ export default function CertificateAvailable({ data }: { data: CertificateData }
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{[ {[
{ label: "Nama Program", value: certificate.programName }, { label: "Nama Program", value: project?.project_name ?? "-" },
{ label: "Durasi", value: certificate.duration }, { label: "Durasi", value: duration },
{ label: "Total Tugas", value: project ? `${project.total_tasks} Tugas` : "-" },
{ {
label: "Status", label: "Status",
value: ( value: (
<span className="bg-green-100 text-green-700 text-xs font-bold px-2 py-0.5 rounded-full"> <span className="bg-green-100 text-green-700 text-xs font-bold px-2 py-0.5 rounded-full">
VERIFIED UNVERIFIED
</span> </span>
), ),
}, },
@@ -1,38 +1,69 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { History } from "lucide-react"; import { History, Loader2, AlertCircle } from "lucide-react";
import { type CertificateHistory, history as certificateHistory } from "../../../../lib/mockSertificates"; import type { Certificate } from "../../../../types/certificate.types";
import certificateImg from "../../../../assets/certificate.png"; import certificateImg from "../../../../assets/certificate.png";
import { useMyCertificates } from "../../../../hooks/useSertificates";
export default function SertificateHistory() { export default function SertificateHistory() {
const navigate = useNavigate(); const navigate = useNavigate();
const { certificates, loading, error } = useMyCertificates();
const displayed = certificates.slice(0, 2);
return ( return (
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm p-5"> <div className="bg-box-primary rounded-2xl border border-box-border shadow-sm p-5">
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<History className="w-5 h-5 text-red-700" /> <History className="w-5 h-5 text-red-700" />
<h3 className="font-semibold text-gray-800 text-lg">Certificate History</h3> <h3 className="font-semibold text-gray-800 text-lg">Certificate History</h3>
</div> </div>
<div className="space-y-3"> {loading && (
{certificateHistory.slice(0, 2).map((item: CertificateHistory) => ( <div className="flex items-center justify-center py-8 text-gray-400 gap-2">
<div <Loader2 className="w-5 h-5 animate-spin" />
key={item.id} <span className="text-sm">Loading certificates...</span>
className="flex flex-col gap-3 p-3 bg-white border border-gray-200 shadow-sm rounded-xl hover:bg-gray-50 transition-colors cursor-pointer" </div>
> )}
<div className="w-full flex justify-center">
<img {error && !loading && (
src={certificateImg} <div className="flex items-center gap-2 py-6 text-red-500">
alt="Certificate" <AlertCircle className="w-4 h-4" />
className="rounded-xl h-[150px] w-[150px] w-full object-cover" <span className="text-sm">{error}</span>
/> </div>
)}
{!loading && !error && displayed.length === 0 && (
<p className="text-sm text-gray-400 py-6 text-center">No certificates yet.</p>
)}
{!loading && !error && displayed.length > 0 && (
<div className="space-y-3">
{displayed.map((item: Certificate) => (
<div
key={item.id}
className="flex flex-col gap-3 p-3 bg-white border border-gray-200 shadow-sm rounded-xl hover:bg-gray-50 transition-colors cursor-pointer"
>
<div className="w-full flex justify-center">
<img
src={certificateImg}
alt="Certificate"
className="rounded-xl h-[150px] w-full object-cover"
/>
</div>
<div className="w-full">
<p className="text-xs text-gray-400">
{new Date(item.issued_at).toLocaleDateString("id-ID", {
year: "numeric",
month: "long",
})}
</p>
<p className="text-sm font-semibold text-gray-800 leading-tight">
{item.project.project_name}
</p>
<p className="text-sm text-gray-500">No: {item.certificate_no}</p>
</div>
</div> </div>
<div className="w-full"> ))}
<p className="text-xs text-gray-400">{item.month}</p> </div>
<p className="text-sm font-semibold text-gray-800 leading-tight">{item.title}</p> )}
<p className="text-sm text-gray-500">Grade: {item.grade}</p>
</div>
</div>
))}
</div>
<div className="w-full my-3 flex justify-end"> <div className="w-full my-3 flex justify-end">
<button <button
@@ -1,43 +1,104 @@
import { Lock, Clock } from "lucide-react"; import { Lock, Award, Loader2 } from "lucide-react";
export default function CertificateNotAvailable({ progress, remainingTasks }: { progress: number; remainingTasks: number }) { interface CertificateNotAvailableProps {
progress: number;
remainingTasks: number;
allTasksDone?: boolean;
onClaim?: () => void;
claiming?: boolean;
}
export default function CertificateNotAvailable({
progress,
remainingTasks,
allTasksDone = false,
onClaim,
claiming = false,
}: CertificateNotAvailableProps) {
return ( return (
<div className="bg-white w-full h-full rounded-2xl border border-gray-100 shadow-sm px-24 flex flex-col items-center justify-center"> <div className="bg-box-primary w-full h-full rounded-2xl border border-box-border shadow-sm px-24 py-12 flex flex-col items-center justify-center">
{/* Icon */} {/* Icon */}
<div className="relative mb-6"> <div className="relative mb-6">
<div className=" w-45 h-45 border-2 border-dashed border-gray-600 rounded-xl flex justify-center items-center"> <div className="w-34 h-34 border-2 border-dashed border-gray-600 rounded-xl flex justify-center items-center">
<div className="w-26 h-26 flex items-center rounded-lg justify-center bg-gray-300">
<div className="w-30 h-30 flex items-center rounded-lg justify-center bg-gray-300"> {allTasksDone ? (
<Lock className="w-16 h-16 text-red-700" /> <Award className="w-12 h-12 text-green-600" />
</div> ) : (
<div className="absolute -bottom-2 -right-2 w-7 h-7 bg-amber-500 rounded- flex items-center justify-center shadow-md"> <Lock className="w-12 h-12 text-red-700" />
<Clock className="w-5 h-5 text-yellow-800" /> )}
</div> </div>
{/* <div className="absolute -bottom-2 -right-2 w-7 h-7 bg-amber-500 rounded- flex items-center justify-center shadow-md">
<Clock className="w-4 h-4 text-yellow-800" />
</div> */}
</div> </div>
</div> </div>
<h2 className="text-[37px] font-bold text-gray-900 mb-3">Sertifikat Belum Tersedia</h2> {allTasksDone ? (
<p className="text-gray-500 text-[17px] mb-6"> <>
Anda harus menyelesaikan <span className="font-bold text-red-600">semua tugas proyek</span> sebelum dapat mengunduh sertifikat kelulusan. <h2 className="text-[37px] font-bold text-gray-900 mb-3">Selamat!</h2>
Silakan periksa kembali progres tugas Anda di menu Proyek. <p className="text-gray-500 text-[17px] mb-6 text-center">
</p> Semua tugas proyek telah selesai. Klik tombol di bawah untuk{" "}
<span className="font-bold text-green-600">mengklaim sertifikat</span> kelulusan Anda.
</p>
{/* Progress Bar */} {/* Progress Bar — 100% */}
<div className="w-1/3"> <div className="w-1/3 mb-6">
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
<span className="text-sm font-semibold text-gray-700">Progres Proyek</span> <span className="text-sm font-semibold text-gray-700">Progres Proyek</span>
<span className="text-sm font-bold text-red-700">{progress}%</span> <span className="text-sm font-bold text-green-600">100%</span>
</div> </div>
<div className="w-full h-3 bg-gray-100 rounded-full overflow-hidden"> <div className="w-full h-3 bg-gray-100 rounded-full overflow-hidden">
<div <div className="h-full bg-green-500 rounded-full transition-all duration-700 w-full" />
className="h-full bg-red-700 rounded-full transition-all duration-700" </div>
style={{ width: `${progress}%` }} <p className="text-xs text-gray-400 mt-2 text-left">Semua tugas telah diselesaikan!</p>
/> </div>
</div>
<p className="text-xs text-gray-400 mt-2 text-left"> {/* Tombol Claim */}
Selesaikan {remainingTasks} tugas lagi untuk membuka akses. <button
</p> onClick={onClaim}
</div> disabled={claiming}
className="flex items-center justify-center gap-2 px-8 py-3 bg-red-700 hover:bg-red-800 disabled:opacity-60 active:scale-95 transition-all text-white font-semibold rounded-xl text-sm"
>
{claiming ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Mengklaim...
</>
) : (
<>
<Award className="w-4 h-4" />
Claim Certificate
</>
)}
</button>
</>
) : (
<>
<h2 className="text-[28px] font-bold text-gray-900 mb-3">Sertifikat Belum Tersedia</h2>
<p className="text-gray-500 text-[15px] mb-6 text-center">
Anda harus menyelesaikan <span className="font-bold text-red-600">semua tugas proyek</span> sebelum dapat mengunduh sertifikat kelulusan.
Silakan periksa kembali progres tugas Anda di menu Proyek.
</p>
{/* Progress Bar */}
<div className="w-1/3">
<p className="text-xs text-font mt-2">
Selesaikan {remainingTasks} tugas lagi untuk membuka akses.
</p>
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-semibold text-gray-700">Progres Proyek</span>
<span className="text-sm font-bold text-red-700">{progress}%</span>
</div>
<div className="w-full h-3 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-red-700 rounded-full transition-all duration-700"
style={{ width: `${progress}%` }}
/>
</div>
</div>
</>
)}
</div> </div>
); );
} }
@@ -1,5 +1,5 @@
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import type { ProjectTask } from "../../../../types/task.types"; import type { InternTaskItem } from "../../../../types/project.types";
const formatDate = (dateStr: string) => const formatDate = (dateStr: string) =>
new Date(dateStr).toLocaleDateString('id-ID', { day: 'numeric', month: 'short', year: 'numeric' }); new Date(dateStr).toLocaleDateString('id-ID', { day: 'numeric', month: 'short', year: 'numeric' });
@@ -7,37 +7,36 @@ const formatDate = (dateStr: string) =>
const formatTime = (dateStr: string) => const formatTime = (dateStr: string) =>
new Date(dateStr).toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' }); new Date(dateStr).toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' });
export default function TaskCard({ task }: { task: ProjectTask }) { export default function TaskCard({ task }: { task: InternTaskItem }) {
const navigate = useNavigate(); const navigate = useNavigate();
const { slug } = useParams<{ slug: string }>();
return ( return (
<div <div
className="border border-card-outline rounded-xl px-6 py-4 mb-3 bg-white flex items-start justify-between gap-3" className="border border-card-outline rounded-xl px-6 py-4 mb-3 bg-white flex items-start justify-between gap-3 cursor-pointer hover:bg-gray-50 transition-colors"
onClick={() => navigate(`/intern/projects/${slug}/${task.slug}?type=${task.submission_type}`)} onClick={() => navigate(`/intern/projects/${task.project_slug}/${task.slug}?type=${task.submission_type}`)}
> >
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className=" text-[11px] text-font mb-1"> <div className="text-[11px] text-font mb-1">
Deadline : {formatDate(task.deadline_at)} at {formatTime(task.deadline_at)} Deadline : {formatDate(task.deadline_at)} at {formatTime(task.deadline_at)}
</div> </div>
<div className=" font-bold text-[15px] text-[#1a1a1a] mb-1"> <div className="font-bold text-[15px] text-[#1a1a1a] mb-1">
{task.title} {task.title}
</div> </div>
<div className=" text-[13px] text-font"> <div className="text-[13px] text-font">
{task.description} {task.description}
</div> </div>
<div className=" text-[13px] text-font"> <div className="text-[13px] text-font mt-0.5">
Tipe Submission : {task.submission_type === 'file_upload' ? "File" : "Link"} Tipe Submission : {task.submission_type === 'file_upload' ? "File" : "Link"}
</div> </div>
</div> </div>
<div className="block md:hidden h-[10px] bg-gray-600"/> <div className="block md:hidden h-[10px] bg-gray-600"/>
<div className="shrink-0 hidden md:block"> <div className="shrink-0 hidden md:flex items-center">
<button <span
className="bg-white text-font-shade border-[1.5px] border-card-outline font-semibold text-xs px-3.5 py-[5px] rounded-md cursor-pointer whitespace-nowrap transition-colors duration-150" className={`font-semibold text-xs px-3.5 py-[5px] rounded-md whitespace-nowrap bg-gray-50`}
> >
Add Submission {task.submission_status}
</button> </span>
</div> </div>
</div> </div>
); );
@@ -14,7 +14,7 @@ const ProjectCard = (project: Project) => {
const Icon = getDynamicIcon(project.project_icon); const Icon = getDynamicIcon(project.project_icon);
const tasksDone = 1; const tasksDone = project.task_done;
const tasksTotal = project.total_tasks; const tasksTotal = project.total_tasks;
return ( return (
@@ -7,12 +7,13 @@ import type { TaskSubmissionData } from "../../../../types/task.types";
interface TaskFormFileProps { interface TaskFormFileProps {
taskId: string; taskId: string;
projectId?: string;
deadline?: Date; deadline?: Date;
initialSubmission?: TaskSubmissionData | null; initialSubmission?: TaskSubmissionData | null;
} }
export default function TaskFormFile({ taskId, deadline, initialSubmission }: TaskFormFileProps) { export default function TaskFormFile({ taskId, projectId, deadline, initialSubmission }: TaskFormFileProps) {
const { submission, loading, submitFile, updateFile, remove } = useSubmission(taskId, initialSubmission); const { submission, loading, submitFile, updateFile, remove } = useSubmission(taskId, initialSubmission, projectId);
const [files, setFiles] = useState<File[]>([]); const [files, setFiles] = useState<File[]>([]);
const [isDragActive, setIsDragActive] = useState(false); const [isDragActive, setIsDragActive] = useState(false);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
@@ -7,12 +7,13 @@ import type { TaskSubmissionData } from "../../../../types/task.types";
interface TaskFormLinkProps { interface TaskFormLinkProps {
taskId: string; taskId: string;
projectId?: string;
deadline?: Date; deadline?: Date;
initialSubmission?: TaskSubmissionData | null; initialSubmission?: TaskSubmissionData | null;
} }
export default function TaskFormLink({ taskId, deadline, initialSubmission }: TaskFormLinkProps) { export default function TaskFormLink({ taskId, projectId, deadline, initialSubmission }: TaskFormLinkProps) {
const { submission, loading, submitLink, updateLink, remove } = useSubmission(taskId, initialSubmission); const { submission, loading, submitLink, updateLink, remove } = useSubmission(taskId, initialSubmission, projectId);
const [inputLink, setInputLink] = useState(initialSubmission?.url_link ?? ""); const [inputLink, setInputLink] = useState(initialSubmission?.url_link ?? "");
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
@@ -9,7 +9,6 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Sparkles, Sparkles,
ArrowLeft,
} from "lucide-react"; } from "lucide-react";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -67,7 +66,6 @@ function Avatar({ initials }: { initials: string }) {
); );
} }
// ─── Progress Bar ─────────────────────────────────────────────────────────────
function ProgressBar({ value, total }: { value: number; total: number }) { function ProgressBar({ value, total }: { value: number; total: number }) {
const pct = Math.round((value / total) * 100); const pct = Math.round((value / total) * 100);
const isComplete = value === total; const isComplete = value === total;
@@ -86,7 +84,6 @@ function ProgressBar({ value, total }: { value: number; total: number }) {
); );
} }
// ─── Status Badge ─────────────────────────────────────────────────────────────
function StatusBadge({ status }: { status: InternStatus }) { function StatusBadge({ status }: { status: InternStatus }) {
const isEligible = status === "ELIGIBLE"; const isEligible = status === "ELIGIBLE";
return ( return (
@@ -99,7 +96,6 @@ function StatusBadge({ status }: { status: InternStatus }) {
); );
} }
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function CertificateDetail() { export default function CertificateDetail() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -135,26 +131,14 @@ export default function CertificateDetail() {
return ( return (
<div> <div>
{/* Breadcrumb */}
<div
className="mb-1 text-xs font-medium text-[#B30000] cursor-pointer w-fit"
onClick={() => navigate("/mentor/certificates/all")}
>
Certificates &rsaquo;
</div>
{/* Page Header */}
<div className="mb-6"> <div className="mb-6">
<h1 className="page-title">Certificates</h1> <h1 className="page-title">Certificates</h1>
<p className="page-title-desc"> <p className="page-title-desc">
Administrate, issue, and track professional certifications for all interns. Administrate, issue, and track professional certifications for all interns.
</p> </p>
</div> </div>
{/* ── Two-column layout ── */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
{/* ── LEFT: Project Info ── */}
<div className="space-y-5"> <div className="space-y-5">
{/* Status badge */} {/* Status badge */}
<div className="flex items-center gap-1.5 text-xs font-extrabold text-green-600 tracking-wide"> <div className="flex items-center gap-1.5 text-xs font-extrabold text-green-600 tracking-wide">
@@ -162,18 +146,15 @@ export default function CertificateDetail() {
COMPLETED COMPLETED
</div> </div>
{/* Title */}
<h2 className="text-3xl font-extrabold text-gray-900 leading-tight"> <h2 className="text-3xl font-extrabold text-gray-900 leading-tight">
{mockProject.title} {mockProject.title}
</h2> </h2>
{/* Period */}
<div className="flex items-center gap-2 text-sm text-gray-400 font-medium"> <div className="flex items-center gap-2 text-sm text-gray-400 font-medium">
<CalendarDays className="w-4 h-4 flex-shrink-0" /> <CalendarDays className="w-4 h-4 flex-shrink-0" />
{mockProject.period} {mockProject.period}
</div> </div>
{/* Project Scope */}
<div className="border border-gray-200 rounded-xl p-5"> <div className="border border-gray-200 rounded-xl p-5">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-3"> <p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-3">
Project Scope Project Scope
@@ -183,7 +164,6 @@ export default function CertificateDetail() {
</p> </p>
</div> </div>
{/* Stats: Total & Eligible */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="border border-gray-200 rounded-xl p-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-2xl font-extrabold text-[#B30000]">{mockProject.totalInterns}</p>
@@ -196,10 +176,8 @@ export default function CertificateDetail() {
</div> </div>
</div> </div>
{/* ── RIGHT: Intern Roster ── */}
<div className="border border-gray-200 rounded-2xl overflow-hidden bg-white shadow-sm"> <div className="border border-gray-200 rounded-2xl overflow-hidden bg-white shadow-sm">
{/* Roster Header */}
<div className="flex items-start justify-between px-5 py-4 border-b border-gray-100"> <div className="flex items-start justify-between px-5 py-4 border-b border-gray-100">
<div> <div>
<h3 className="text-base font-bold text-gray-900">Intern Roster</h3> <h3 className="text-base font-bold text-gray-900">Intern Roster</h3>
@@ -207,7 +185,6 @@ export default function CertificateDetail() {
Confirm completion status before final certificate generation. Confirm completion status before final certificate generation.
</p> </p>
</div> </div>
{/* Select All button */}
<button <button
onClick={toggleSelectAll} 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" 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"
@@ -340,14 +317,7 @@ export default function CertificateDetail() {
</div> </div>
{/* ── Bottom Action Bar ── */} {/* ── Bottom Action Bar ── */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-6 pt-4 border-t border-gray-100"> <div className="flex flex-col sm:flex-row items-center justify-end gap-4 mt-6 pt-4">
<button
onClick={() => navigate("/mentor/certificates/all")}
className="flex items-center gap-2 text-sm font-bold text-gray-500 hover:text-gray-700 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Back to Project Selection
</button>
<button <button
onClick={() => navigate("/mentor/certificates/result")} 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" 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"
@@ -1,6 +1,5 @@
// src/pages/mentor/CertificateResult.tsx // src/pages/mentor/CertificateResult.tsx
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { import {
CheckCircle2, CheckCircle2,
FolderArchive, FolderArchive,
@@ -55,7 +54,6 @@ function Avatar({ initials }: { initials: string }) {
// ─── Main Page ──────────────────────────────────────────────────────────────── // ─── Main Page ────────────────────────────────────────────────────────────────
export default function CertificateResult() { export default function CertificateResult() {
const navigate = useNavigate();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -75,12 +73,6 @@ export default function CertificateResult() {
return ( return (
<div> <div>
{/* Breadcrumb */}
<div className="mb-1 text-xs font-medium text-[#B30000] cursor-pointer w-fit"
onClick={() => navigate("/mentor/certificates/all")}>
Certificates &rsaquo;
</div>
{/* Page Header */} {/* Page Header */}
<div className="mb-8"> <div className="mb-8">
<h1 className="page-title">Certificates</h1> <h1 className="page-title">Certificates</h1>
@@ -1,127 +1,18 @@
// src/pages/mentor/CertificateList.tsx // src/pages/mentor/CertificateListPage.tsx
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import {CalendarDays} from "lucide-react"; import CertificateProjectCard from "./components/CertificateProjectCard";
import { useProjects } from "../../../hooks/useProjects";
// ─── Types ───────────────────────────────────────────────────────────────────
type CertStatus = "COMPLETED" | "PENDING CERTS";
interface Project {
id: number;
title: string;
period: string;
internCount: number;
status: CertStatus;
}
// ─── Mock Data ────────────────────────────────────────────────────────────────
const mockProjects: Project[] = [
{
id: 1,
title: "Autonomous Navigation Systems",
period: "Jan 2024 - Apr 2024",
internCount: 14,
status: "COMPLETED",
},
{
id: 2,
title: "Full-Stack Fintech Solution",
period: "Feb 2024 - June 2024",
internCount: 8,
status: "PENDING CERTS",
},
{
id: 3,
title: "User Experience Audit 2024",
period: "Mar 2024 - May 2024",
internCount: 5,
status: "PENDING CERTS",
},
{
id: 4,
title: "Edge Computing Framework",
period: "Dec 2023 - Mar 2024",
internCount: 21,
status: "COMPLETED",
},
];
// ─── Sub Components ───────────────────────────────────────────────────────────
/** Badge status: COMPLETED (hijau) vs PENDING CERTS (kuning) */
function StatusBadge({ status }: { status: CertStatus }) {
const isCompleted = status === "COMPLETED";
return (
<span
className={`text-[10px] font-extrabold tracking-wider px-2.5 py-1 rounded-full border ${
isCompleted
? "bg-green-50 text-green-600 border-green-200"
: "bg-yellow-50 text-yellow-600 border-yellow-200"
}`}
>
{status}
</span>
);
}
/** Satu card project */
function ProjectCard({
project,
onClick,
}: {
project: Project;
onClick: () => void;
}) {
return (
<div
onClick={onClick}
className="bg-white border border-gray-200 rounded-2xl p-5 cursor-pointer hover:shadow-md hover:border-gray-300 transition-all duration-200 flex flex-col gap-4"
>
{/* Top: icon + status */}
<div className="flex items-start justify-between">
<StatusBadge status={project.status} />
</div>
{/* Title + period */}
<div>
<h3 className="text-sm font-bold text-gray-900 leading-snug mb-1.5">
{project.title}
</h3>
<div className="flex items-center gap-1.5 text-[11px] text-gray-400 font-medium">
<CalendarDays className="w-3.5 h-3.5 flex-shrink-0" />
<span>{project.period}</span>
</div>
</div>
<hr className="border-gray-100" />
{/* Intern count + avatars */}
<div className="flex items-center justify-between">
<div>
<p className="text-[10px] text-gray-400 font-medium mb-0.5">Interns</p>
<p className="text-sm font-extrabold text-[#B30000]">
{project.internCount} {project.internCount === 1 ? "intern" : "Interns"}
</p>
</div>
</div>
</div>
);
}
// ─── Main Page ──────────────────────────────────────────────────────────────── // ─── Main Page ────────────────────────────────────────────────────────────────
export default function CertificateList() { export default function CertificateList() {
const navigate = useNavigate(); const navigate = useNavigate();
const { projects, loading, error } = useProjects();
const handleCardClick = (id: number) => { const handleCardClick = (slug: string) => {
navigate(`/mentor/certificates/${id}`); navigate(`/mentor/certificates/${slug}`);
}; };
return ( return (
<div> <div>
{/* Breadcrumb */}
<div className="mb-1 text-xs font-medium text-[#B30000]">
Certificates &rsaquo;
</div>
{/* Page Header */} {/* Page Header */}
<div className="mb-6"> <div className="mb-6">
<h1 className="page-title">Certificates</h1> <h1 className="page-title">Certificates</h1>
@@ -135,11 +26,11 @@ export default function CertificateList() {
<h2 className="text-base font-bold text-gray-900 mb-4">List Projects</h2> <h2 className="text-base font-bold text-gray-900 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">
{mockProjects.map((project) => ( {projects.map((project) => (
<ProjectCard <CertificateProjectCard
key={project.id} key={project.id}
project={project} project={project}
onClick={() => handleCardClick(project.id)} onClick={() => handleCardClick(project.slug)}
/> />
))} ))}
</div> </div>
+62 -25
View File
@@ -1,32 +1,55 @@
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { Search, SlidersHorizontal, Plus, Trash2 } from "lucide-react"; import { Search, SlidersHorizontal, Plus, Trash2 } from "lucide-react";
import { mockInternsData } from "../../../lib/mockData"; import { useAllInterns, useRemoveMember, useProjects } from "../../../hooks/useProjects";
import AddInternsModal from "./components/AddInternsToProjects"; import AddInternsModal from "./components/AddInternsToProjects";
export default function InternsManagement() { export default function InternsManagement() {
// State Filter & Search
const [searchEmail, setSearchEmail] = useState(""); const [searchEmail, setSearchEmail] = useState("");
const [selectedProject, setSelectedProject] = useState("All Projects"); const [selectedProject, setSelectedProject] = useState("All Projects");
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
// Handler Aksi Kursor/Tombol
const handleAddAssignment = (id: number) => {
alert(`Assign/Add action clicked for intern ID: ${id}`);
};
const handleDeleteIntern = (id: number) => { const { interns, loading, error, refetch } = useAllInterns();
alert(`Deleted intern ID: ${id}`); const { remove, loading: removing } = useRemoveMember();
}; const { projects: activeProjects } = useProjects("active");
// Logic Filtering Data menggunakan useMemo // Filter data berdasarkan email dan nama project
const filteredInterns = useMemo(() => { const filteredInterns = useMemo(() => {
return mockInternsData.filter((intern) => { return interns.filter((intern) => {
const matchesEmail = intern.email.toLowerCase().includes(searchEmail.toLowerCase()); const matchesEmail = intern.email.toLowerCase().includes(searchEmail.toLowerCase());
const matchesProject = const matchesProject =
selectedProject === "All Projects" || selectedProject === "All Projects" ||
intern.projectName.toLowerCase() === selectedProject.toLowerCase(); (intern.projectName || "Unassigned").toLowerCase() === selectedProject.toLowerCase();
return matchesEmail && matchesProject; return matchesEmail && matchesProject;
}); });
}, [searchEmail, selectedProject]); }, [interns, searchEmail, selectedProject]);
// Hitung statistik dari data asli
const totalInterns = interns.length;
// Active assignments = total member dari semua project yang statusnya active
const activeAssignments = activeProjects.length;
// Handler hapus intern dari project
// Catatan: useRemoveMember memerlukan id_project & id_user.
// Karena InternDetail tidak menyimpan id_project, aksi ini saat ini hanya placeholder.
// Untuk implementasi penuh, perlu endpoint khusus atau data id_project dari InternDetail.
const handleDeleteIntern = async (internId: number) => {
const confirmed = window.confirm("Yakin ingin menghapus intern ini dari project?");
if (!confirmed) return;
alert(`Remove intern ID ${internId} — perlu id_project dari backend untuk dilanjutkan.`);
// Contoh penggunaan jika id_project tersedia:
// const res = await remove({ id_project: intern.id_project, id_user: internId });
// if (res !== null) refetch();
};
if (loading) return (
<div className="flex flex-col gap-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-16 bg-gray-100 rounded-lg animate-pulse" />
))}
</div>
);
if (error) return <p className="text-red-500 text-sm">{error}</p>;
return ( return (
<div className=""> <div className="">
@@ -64,8 +87,6 @@ export default function InternsManagement() {
className="w-full pl-3 pr-8 py-2 bg-gray-100 border border-transparent rounded-lg text-sm text-gray-700 font-medium appearance-none focus:outline-none focus:bg-white focus:border-gray-200" className="w-full pl-3 pr-8 py-2 bg-gray-100 border border-transparent rounded-lg text-sm text-gray-700 font-medium appearance-none focus:outline-none focus:bg-white focus:border-gray-200"
> >
<option value="All Projects">All Projects</option> <option value="All Projects">All Projects</option>
<option value="Internify Project">Internify Project</option>
<option value="MBG Project">MBG Project</option>
<option value="Unassigned">Unassigned</option> <option value="Unassigned">Unassigned</option>
</select> </select>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-500"> <div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-500">
@@ -81,17 +102,21 @@ export default function InternsManagement() {
</button> </button>
</div> </div>
{/* Right Info Box Stats */} {/* Right Info Box Stats — data real dari API */}
<div className="bg-white border border-gray-100 rounded-xl px-6 py-2.5 flex items-center justify-around text-center shadow-[0_2px_10px_rgba(0,0,0,0.01)] w-full lg:max-w-sm ml-auto"> <div className="bg-white border border-gray-100 rounded-xl px-6 py-2.5 flex items-center justify-around text-center shadow-[0_2px_10px_rgba(0,0,0,0.01)] w-full lg:max-w-sm ml-auto">
<div> <div>
<span className="block text-xl font-bold text-red-600 leading-none">124</span> <span className="block text-xl font-bold text-red-600 leading-none">
{totalInterns}
</span>
<span className="text-[10px] font-bold text-gray-400 tracking-wider uppercase mt-1 block"> <span className="text-[10px] font-bold text-gray-400 tracking-wider uppercase mt-1 block">
TOTAL INTERNS TOTAL INTERNS
</span> </span>
</div> </div>
<div className="h-8 w-px bg-gray-100 mx-2"></div> <div className="h-8 w-px bg-gray-100 mx-2"></div>
<div> <div>
<span className="block text-xl font-bold text-green-600 leading-none">92</span> <span className="block text-xl font-bold text-green-600 leading-none">
{activeAssignments}
</span>
<span className="text-[10px] font-bold text-gray-400 tracking-wider uppercase mt-1 block"> <span className="text-[10px] font-bold text-gray-400 tracking-wider uppercase mt-1 block">
ACTIVE ASSIGNMENTS ACTIVE ASSIGNMENTS
</span> </span>
@@ -100,7 +125,10 @@ export default function InternsManagement() {
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<button className="inline-flex items-center gap-5 px-3 py-1 bg-red-700 text-white rounded-xl shadow-sm hover:bg-red-800 transition-colors focus:outline-none focus:ring-2 focus:ring-red-300" onClick={() => setIsModalOpen(true)}> <button
className="inline-flex items-center gap-2 px-3 py-1 bg-red-700 text-white rounded-xl shadow-sm hover:bg-red-800 transition-colors focus:outline-none focus:ring-2 focus:ring-red-300"
onClick={() => setIsModalOpen(true)}
>
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
Add Add
</button> </button>
@@ -120,7 +148,7 @@ export default function InternsManagement() {
<div className="divide-y divide-gray-100"> <div className="divide-y divide-gray-100">
{filteredInterns.length > 0 ? ( {filteredInterns.length > 0 ? (
filteredInterns.map((intern, index) => { filteredInterns.map((intern, index) => {
const isUnassigned = intern.projectName === "Unassigned"; const isUnassigned = !intern.projectName || intern.projectName === "Unassigned";
return ( return (
<div key={`${intern.id}-${index}`} className="px-6 py-4 flex items-center hover:bg-gray-50/60 transition-colors"> <div key={`${intern.id}-${index}`} className="px-6 py-4 flex items-center hover:bg-gray-50/60 transition-colors">
@@ -147,7 +175,7 @@ export default function InternsManagement() {
{/* Column 2: Current Project Title & Role Accent */} {/* Column 2: Current Project Title & Role Accent */}
<div className="w-1/3"> <div className="w-1/3">
<h5 className={`text-sm font-semibold leading-tight ${isUnassigned ? 'text-gray-500 italic' : 'text-gray-900'}`}> <h5 className={`text-sm font-semibold leading-tight ${isUnassigned ? 'text-gray-500 italic' : 'text-gray-900'}`}>
{intern.projectName} {intern.projectName || "Unassigned"}
</h5> </h5>
<p className={`text-xs font-bold mt-0.5 ${isUnassigned ? 'text-red-500' : 'text-red-600'}`}> <p className={`text-xs font-bold mt-0.5 ${isUnassigned ? 'text-red-500' : 'text-red-600'}`}>
{intern.role} {intern.role}
@@ -156,15 +184,20 @@ export default function InternsManagement() {
{/* Column 3: Action Buttons Block Right aligned */} {/* Column 3: Action Buttons Block Right aligned */}
<div className="w-1/6 flex items-center justify-end gap-3.5 pr-2"> <div className="w-1/6 flex items-center justify-end gap-3.5 pr-2">
{/* Tombol Add: membuka modal assign member */}
<button <button
onClick={() => handleAddAssignment(intern.id)} onClick={() => setIsModalOpen(true)}
className="p-1.5 bg-[#B30000] hover:bg-[#990000] text-white rounded-lg transition-colors shadow-sm" className="p-1.5 bg-[#B30000] hover:bg-[#990000] text-white rounded-lg transition-colors shadow-sm"
title="Assign ke project"
> >
<Plus className="w-4 h-4 stroke-[3]" /> <Plus className="w-4 h-4 stroke-[3]" />
</button> </button>
{/* Tombol Delete: remove intern dari project */}
<button <button
onClick={() => handleDeleteIntern(intern.id)} onClick={() => handleDeleteIntern(intern.id)}
className="p-1.5 text-red-500 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors" disabled={removing}
className="p-1.5 text-red-500 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-50"
title="Hapus dari project"
> >
<Trash2 className="w-4 h-4 stroke-[2]" /> <Trash2 className="w-4 h-4 stroke-[2]" />
</button> </button>
@@ -183,7 +216,7 @@ export default function InternsManagement() {
{/* Red Table Pagination Bar Footer */} {/* Red Table Pagination Bar Footer */}
<div className="bg-[#B30000] text-white px-6 py-3.5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 text-xs font-medium"> <div className="bg-[#B30000] text-white px-6 py-3.5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 text-xs font-medium">
<div> <div>
Showing <span className="font-bold">{filteredInterns.length}</span> of 124 interns Showing <span className="font-bold">{filteredInterns.length}</span> of {totalInterns} interns
</div> </div>
<div className="flex items-center gap-2 self-end sm:self-auto"> <div className="flex items-center gap-2 self-end sm:self-auto">
@@ -196,10 +229,14 @@ export default function InternsManagement() {
</div> </div>
</div> </div>
</div> </div>
{isModalOpen && {isModalOpen &&
<AddInternsModal <AddInternsModal
isOpen={isModalOpen} isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)} onClose={() => {
setIsModalOpen(false);
refetch(); // Refresh data setelah modal assign ditutup
}}
/> />
} }
</div> </div>
@@ -1,6 +1,7 @@
import { useRef, useEffect, useState } from "react"; import { useRef, useEffect, useState } from "react";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { LuUserPlus, LuMail, LuBriefcase, LuFolderOpen } from "react-icons/lu"; import { LuUserPlus, LuMail, LuFolderOpen } from "react-icons/lu";
import { useAssignMember, useProjects } from "../../../../hooks/useProjects";
interface AddInternsModalProps { interface AddInternsModalProps {
isOpen: boolean; isOpen: boolean;
@@ -12,8 +13,11 @@ export default function AddInternsModal({ isOpen, onClose }: AddInternsModalProp
// Form States // Form States
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [position, setPosition] = useState(""); const [selectedProjectId, setSelectedProjectId] = useState<string>("");
const [projectName, setProjectName] = useState("");
// Hooks
const { assign, loading: assigning, error: assignError } = useAssignMember();
const { projects, loading: loadingProjects } = useProjects("active");
useEffect(() => { useEffect(() => {
const dialog = dialogRef.current; const dialog = dialogRef.current;
@@ -39,11 +43,29 @@ export default function AddInternsModal({ isOpen, onClose }: AddInternsModalProp
}; };
}, [isOpen, onClose]); }, [isOpen, onClose]);
const handleSubmit = (e: React.FormEvent) => { // Reset form saat modal dibuka kembali
useEffect(() => {
if (isOpen) {
setEmail("");
setSelectedProjectId("");
}
}, [isOpen]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const payload = { email, position, projectName };
console.log("Adding Intern:", payload); // Catatan: useAssignMember memerlukan id_user (number), bukan email.
alert("Intern Added Successfully!"); // Endpoint /project-api/assign-member menerima { id_project: number, id_user: number }.
// Untuk implementasi berbasis email, perlu endpoint tambahan atau lookup user by email.
// Saat ini form ini dikomen karena tidak ada lookup email → id_user di service.
// Contoh penggunaan jika id_user sudah diketahui:
// const res = await assign({ id_project: Number(selectedProjectId), id_user: userId });
// if (res !== null) {
// onClose();
// }
alert(`Assign intern "${email}" ke project ID ${selectedProjectId} — perlu lookup id_user dari email via backend.`);
onClose(); onClose();
}; };
@@ -90,29 +112,30 @@ export default function AddInternsModal({ isOpen, onClose }: AddInternsModalProp
</div> </div>
</div> </div>
{/* Position */} {/* Project Selection */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-bold tracking-wide text-foreground"> <label className="text-xs font-bold tracking-wide text-foreground">
Position Project
</label> </label>
<div className="relative"> <div className="relative">
<span className="absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none text-border"> <span className="absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none text-border">
<LuBriefcase className="w-4 h-4 stroke-[2]" /> <LuFolderOpen className="w-4 h-4 stroke-[2]" />
</span> </span>
<select <select
required required
value={position} value={selectedProjectId}
onChange={(e) => setPosition(e.target.value)} onChange={(e) => setSelectedProjectId(e.target.value)}
className={`w-full pl-10 pr-9 py-2.5 bg-white text-sm font-medium appearance-none cursor-pointer border border-border rounded-radius focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all text-foreground`} disabled={loadingProjects}
className="w-full pl-10 pr-9 py-2.5 bg-white text-sm font-medium appearance-none cursor-pointer border border-border rounded-radius focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all text-foreground disabled:opacity-60"
> >
<option value="" disabled>Select position</option> <option value="" disabled>
<option value="frontend">Frontend Developer</option> {loadingProjects ? "Memuat projects..." : "Pilih project"}
<option value="backend">Backend Developer</option> </option>
<option value="fullstack">Fullstack Developer</option> {projects.map((project) => (
<option value="designer">UI/UX Designer</option> <option key={project.id} value={project.id}>
<option value="mobile">Mobile Developer</option> {project.project_name}
<option value="devops">DevOps Engineer</option> </option>
<option value="qa">QA Engineer</option> ))}
</select> </select>
<span className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-border"> <span className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-border">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}> <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
@@ -122,33 +145,15 @@ export default function AddInternsModal({ isOpen, onClose }: AddInternsModalProp
</div> </div>
</div> </div>
{/* Project Name */} {/* Field Position dikomentari — tidak ada dalam payload assign-member */}
<div className="space-y-1.5"> {/* <div className="space-y-1.5">
<label className="text-xs font-bold tracking-wide text-foreground"> <label className="text-xs font-bold tracking-wide text-foreground">Position</label>
Project Name <select>...</select>
</label> </div> */}
<div className="relative">
<span className="absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none text-border"> {assignError && (
<LuFolderOpen className="w-4 h-4 stroke-[2]" /> <p className="text-xs text-red-600 font-medium">{assignError}</p>
</span> )}
<select
required
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
className={`w-full pl-10 pr-9 py-2.5 bg-white text-sm font-medium appearance-none cursor-pointer border border-border rounded-radius focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all text-foreground`}
>
<option value="" disabled>Select project assignment</option>
<option value="internify">Internify Project</option>
<option value="rekrutmen">Rekrutmen Digital Platform</option>
<option value="ecommerce">E-Commerce Analytics Dashboard</option>
</select>
<span className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-border">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</span>
</div>
</div>
<div className="h-px bg-border/30" /> <div className="h-px bg-border/30" />
@@ -163,10 +168,11 @@ export default function AddInternsModal({ isOpen, onClose }: AddInternsModalProp
</button> </button>
<button <button
type="submit" type="submit"
className="flex-1 py-2.5 text-sm font-bold text-white bg-primary hover:bg-danger rounded-radius shadow-md flex items-center justify-center gap-2 transition-colors" disabled={assigning}
className="flex-1 py-2.5 text-sm font-bold text-white bg-primary hover:bg-danger rounded-radius shadow-md flex items-center justify-center gap-2 transition-colors disabled:opacity-60"
> >
<LuUserPlus className="w-4 h-4 stroke-[2.5]" /> <LuUserPlus className="w-4 h-4 stroke-[2.5]" />
Add Member {assigning ? "Assigning..." : "Add Member"}
</button> </button>
</div> </div>
@@ -0,0 +1,62 @@
import { CalendarDays } from "lucide-react";
import type { Project } from "../../../../types/project.types";
// ─── StatusBadge ─────────────────────────────────────────────────────────────
function StatusBadge({ status }: { status: string }) {
const isCompleted = status === "COMPLETED";
return (
<span
className={`text-[10px] font-extrabold tracking-wider px-2.5 py-1 rounded-full border ${
isCompleted
? "bg-green-50 text-green-600 border-green-200"
: "bg-yellow-50 text-yellow-600 border-yellow-200"
}`}
>
{status}
</span>
);
}
// ─── ProjectCard ──────────────────────────────────────────────────────────────
export default function CertificateProjectCard({
project,
onClick,
}: {
project: Project;
onClick: () => void;
}) {
return (
<div
onClick={onClick}
className="bg-white border border-gray-200 rounded-2xl p-5 cursor-pointer hover:shadow-md hover:border-gray-300 transition-all duration-200 flex flex-col gap-4"
>
{/* Top: status badge */}
<div className="flex items-start justify-between">
<StatusBadge status={project.status} />
</div>
{/* Title + period */}
<div>
<h3 className="text-sm font-bold text-gray-900 leading-snug mb-1.5">
{project.project_name}
</h3>
<div className="flex items-center gap-1.5 text-[11px] text-gray-400 font-medium">
<CalendarDays className="w-3.5 h-3.5 flex-shrink-0" />
<span>{project.start_date} - {project.end_date}</span>
</div>
</div>
<hr className="border-gray-100" />
{/* Intern count */}
<div className="flex items-center justify-between">
<div>
<p className="text-[10px] text-gray-400 font-medium mb-0.5">Interns</p>
<p className="text-sm font-extrabold text-[#B30000]">
{project.total_members} interns
</p>
</div>
</div>
</div>
);
}
@@ -1,6 +1,6 @@
import { useRef, useEffect, useState } from "react"; import { useRef, useEffect, useState } from "react";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { PROJECT_ICON_MAP, PROJECT_ICON_CODES } from "../../..//../lib/ProjectIcons"; import { PROJECT_ICON_MAP, PROJECT_ICON_CODES } from "../../../../lib/ProjectIcons";
import { LuSendHorizontal, LuMail } from "react-icons/lu"; import { LuSendHorizontal, LuMail } from "react-icons/lu";
import { useCreateProject } from "../../../../hooks/useProjects"; import { useCreateProject } from "../../../../hooks/useProjects";
+17 -2
View File
@@ -24,6 +24,7 @@ import {
getProjectMembers, getProjectMembers,
removeMember removeMember
} from "../services/ProjectService"; } from "../services/ProjectService";
import { useMemo } from "react";
// GET /project-api/get // GET /project-api/get
export const useProjects = (status?: "active" | "completed" | "archived") => { export const useProjects = (status?: "active" | "completed" | "archived") => {
@@ -136,14 +137,17 @@ export const useAllInterns = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { const refetch = useCallback(() => {
setLoading(true);
getAllInterns() getAllInterns()
.then(setInterns) .then(setInterns)
.catch(() => setError("Gagal memuat data interns.")) .catch(() => setError("Gagal memuat data interns."))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
return { interns, loading, error }; useEffect(() => { refetch(); }, [refetch]);
return { interns, loading, error, refetch };
}; };
// POST /project-api/add // POST /project-api/add
@@ -167,6 +171,17 @@ export const useCreateProject = () => {
return { create, loading, error }; return { create, loading, error };
}; };
export const useProjectMyTasks = (projectId: number) => {
const { tasks, loading, error } = useMyTasks();
const projectTasks = useMemo<InternTaskItem[]>(() => {
if (!projectId || !tasks.length) return [];
return tasks.filter(task => task.id_project === projectId);
}, [tasks, projectId]);
return { tasks: projectTasks, loading, error };
};
// PATCH /project-api/update/{id} // PATCH /project-api/update/{id}
export const useUpdateProject = () => { export const useUpdateProject = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
+1 -1
View File
@@ -24,7 +24,7 @@ export interface CertificateData {
} }
export const certificateData: CertificateData = { export const certificateData: CertificateData = {
isReceived: true, isReceived: false,
projectProgress: 90, projectProgress: 90,
remainingTasks: 0, remainingTasks: 0,
certificate: { certificate: {
+2
View File
@@ -18,8 +18,10 @@ export interface Certificate {
id_user: number; id_user: number;
certificate_no: string; certificate_no: string;
issued_at: string; issued_at: string;
uuid: string;
user: CertificateUser; user: CertificateUser;
project: CertificateProject; project: CertificateProject;
certificate_status: string;
} }
export interface CertificateVerifyResult { export interface CertificateVerifyResult {
+1
View File
@@ -24,6 +24,7 @@ export interface Project {
status: ProjectStatus; status: ProjectStatus;
admin: ProjectAdmin; admin: ProjectAdmin;
total_members: number; total_members: number;
task_done: number;
total_tasks: number; total_tasks: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;