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:
+5
-1
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 © {new Date().getFullYear()} — HUMIC Engineering
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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,19 +1,42 @@
|
|||||||
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>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-8 text-gray-400 gap-2">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
<span className="text-sm">Loading certificates...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && !loading && (
|
||||||
|
<div className="flex items-center gap-2 py-6 text-red-500">
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
<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">
|
<div className="space-y-3">
|
||||||
{certificateHistory.slice(0, 2).map((item: CertificateHistory) => (
|
{displayed.map((item: Certificate) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
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"
|
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"
|
||||||
@@ -22,17 +45,25 @@ export default function SertificateHistory() {
|
|||||||
<img
|
<img
|
||||||
src={certificateImg}
|
src={certificateImg}
|
||||||
alt="Certificate"
|
alt="Certificate"
|
||||||
className="rounded-xl h-[150px] w-[150px] w-full object-cover"
|
className="rounded-xl h-[150px] w-full object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<p className="text-xs text-gray-400">{item.month}</p>
|
<p className="text-xs text-gray-400">
|
||||||
<p className="text-sm font-semibold text-gray-800 leading-tight">{item.title}</p>
|
{new Date(item.issued_at).toLocaleDateString("id-ID", {
|
||||||
<p className="text-sm text-gray-500">Grade: {item.grade}</p>
|
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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="w-full my-3 flex justify-end">
|
<div className="w-full my-3 flex justify-end">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,29 +1,90 @@
|
|||||||
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">
|
<>
|
||||||
|
<h2 className="text-[37px] font-bold text-gray-900 mb-3">Selamat!</h2>
|
||||||
|
<p className="text-gray-500 text-[17px] mb-6 text-center">
|
||||||
|
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 — 100% */}
|
||||||
|
<div className="w-1/3 mb-6">
|
||||||
|
<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-green-600">100%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-3 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-green-500 rounded-full transition-all duration-700 w-full" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 mt-2 text-left">Semua tugas telah diselesaikan!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tombol Claim */}
|
||||||
|
<button
|
||||||
|
onClick={onClaim}
|
||||||
|
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.
|
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.
|
Silakan periksa kembali progres tugas Anda di menu Proyek.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Progress Bar */}
|
{/* Progress Bar */}
|
||||||
<div className="w-1/3">
|
<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">
|
<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-red-700">{progress}%</span>
|
||||||
@@ -34,10 +95,10 @@ export default function CertificateNotAvailable({ progress, remainingTasks }: {
|
|||||||
style={{ width: `${progress}%` }}
|
style={{ width: `${progress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-400 mt-2 text-left">
|
|
||||||
Selesaikan {remainingTasks} tugas lagi untuk membuka akses.
|
|
||||||
</p>
|
|
||||||
</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,13 +7,12 @@ 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">
|
||||||
@@ -25,19 +24,19 @@ export default function TaskCard({ task }: { task: ProjectTask }) {
|
|||||||
<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 ›
|
|
||||||
</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 ›
|
|
||||||
</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 ›
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user