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 CertificateDetail from "./Pages/Internify/mentor/CertificateDetail";
|
||||
import CertificateResult from "./Pages/Internify/mentor/CertificateGenerate";
|
||||
import NotFoundPage from "./Pages/NotFoundPage";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -111,7 +112,7 @@ function App() {
|
||||
<Route index element={<HomeMentorPage />} />
|
||||
<Route path="projects" element={<MentorProjectsPage />} />
|
||||
<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="intern" element={<MentorInternPage />} />
|
||||
<Route path="projects/:slug" element={<MentorDetailProject />} />
|
||||
@@ -123,6 +124,9 @@ function App() {
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* 404 catch-all */}
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
|
||||
</Routes>
|
||||
</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 */}
|
||||
{type === "new-task" && !isOld && (
|
||||
<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
|
||||
</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">
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import ProjectTimeline from './components/ProjectTimeline';
|
||||
import TaskCard from './components/ProjectTaskCard';
|
||||
import HeroProject from './components/Hero-project';
|
||||
import { useProjectTasks } from '../../../hooks/useTasks';
|
||||
import { useProjectMyTasks } from '../../../hooks/useProjects';
|
||||
import type { Project } from '../../../types/project.types';
|
||||
|
||||
export default function ForumTab({ project }: { project: Project }) {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { tasks, loading, error } = useProjectTasks(slug ?? "");
|
||||
const { tasks, loading, error } = useProjectMyTasks(project.id);
|
||||
return (
|
||||
<div>
|
||||
<HeroProject title={project.project_name} description={project.description} />
|
||||
|
||||
@@ -8,8 +8,10 @@ export default function ProjectDetailPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { project, loading, error } = useProjectDetail(slug ?? "");
|
||||
const [activeTab, setActiveTab] = useState<'forum' | 'participants'>('forum');
|
||||
|
||||
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 (!project) return <p className="p-10 text-gray-400">Project tidak ditemukan.</p>;
|
||||
|
||||
const tabClass = (tab: string) =>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Award } from "lucide-react";
|
||||
import { history, type CertificateHistory } from "../../../lib/mockSertificates";
|
||||
import { Award, Loader2, AlertCircle } from "lucide-react";
|
||||
import { useMyCertificates } from "../../../hooks/useSertificates";
|
||||
import type { Certificate } from "../../../types/certificate.types";
|
||||
import certificateImg from "../../../assets/certificate.png";
|
||||
|
||||
export default function SertificateList() {
|
||||
const { certificates, loading, error } = useMyCertificates();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
@@ -11,15 +14,33 @@ export default function SertificateList() {
|
||||
<p className="page-title-desc">All your earned internship certificates</p>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{history.length === 0 ? (
|
||||
{/* Loading */}
|
||||
{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">
|
||||
<Award className="w-12 h-12 mb-3 opacity-40" />
|
||||
<p className="text-sm">No certificates yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
)}
|
||||
|
||||
{/* Grid */}
|
||||
{!loading && !error && certificates.length > 0 && (
|
||||
<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
|
||||
key={item.id}
|
||||
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">
|
||||
<img
|
||||
src={certificateImg}
|
||||
alt={item.title}
|
||||
alt={item.project.project_name}
|
||||
className="w-full h-[160px] object-cover"
|
||||
/>
|
||||
</div>
|
||||
@@ -37,9 +58,18 @@ export default function SertificateList() {
|
||||
|
||||
{/* Info */}
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-xs text-gray-400 mb-0.5">{item.month}</p>
|
||||
<p className="text-sm font-semibold text-gray-800 leading-snug">{item.title}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Grade: <span className="font-bold text-red-600">{item.grade}</span></p>
|
||||
<p className="text-xs text-gray-400 mb-0.5">
|
||||
{new Date(item.issued_at).toLocaleDateString("id-ID", {
|
||||
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>
|
||||
))}
|
||||
|
||||
@@ -1,31 +1,81 @@
|
||||
import { certificateData } from "../../../lib/mockSertificates";
|
||||
import { useState, useMemo } from "react";
|
||||
import CertificateAvailable from "./components/CertificateAvailabe";
|
||||
import CertificateNotAvailable from "./components/CertificateNotAvailable";
|
||||
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 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 (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex flex-col gap-1">
|
||||
<h1 className="page-title">Certificates</h1>
|
||||
<p className="page-title-desc">View and download internship certificates</p>
|
||||
</div>
|
||||
<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">
|
||||
{data.isReceived ? (
|
||||
<CertificateAvailable data={data} />
|
||||
{showCertificate ? (
|
||||
<CertificateAvailable project={finished_task} />
|
||||
) : (
|
||||
<CertificateNotAvailable
|
||||
progress={data.projectProgress}
|
||||
remainingTasks={data.remainingTasks}
|
||||
progress={progress}
|
||||
remainingTasks={remainingTasks}
|
||||
allTasksDone={allTasksDone}
|
||||
onClaim={handleClaim}
|
||||
claiming={claiming}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Kanan: History */}
|
||||
<div className="w-full lg:w-3/8 flex-shrink-0">
|
||||
<SertificateHistory />
|
||||
</div>
|
||||
|
||||
@@ -49,13 +49,15 @@ export default function TaskSubmission() {
|
||||
|
||||
{submissionType === "file_upload" ? (
|
||||
<TaskFormFile
|
||||
taskId={taskSlug!}
|
||||
taskId={String(task.id)}
|
||||
projectId={String(task.project.id)}
|
||||
deadline={new Date(task.deadline_at)}
|
||||
initialSubmission={task.my_submission}
|
||||
/>
|
||||
) : (
|
||||
<TaskFormLink
|
||||
taskId={taskSlug!}
|
||||
taskId={String(task.id)}
|
||||
projectId={String(task.project.id)}
|
||||
deadline={new Date(task.deadline_at)}
|
||||
initialSubmission={task.my_submission}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useState } from "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";
|
||||
|
||||
export default function CertificateAvailable({ data }: { data: CertificateData }) {
|
||||
const { certificate } = data;
|
||||
interface CertificateAvailableProps {
|
||||
project: ProjectDetail | null;
|
||||
}
|
||||
|
||||
export default function CertificateAvailable({ project }: CertificateAvailableProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopyLink = () => {
|
||||
@@ -13,10 +16,25 @@ export default function CertificateAvailable({ data }: { data: CertificateData }
|
||||
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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* 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
|
||||
src={certificateImg}
|
||||
alt="Certificate"
|
||||
@@ -32,13 +50,14 @@ export default function CertificateAvailable({ data }: { data: CertificateData }
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ label: "Nama Program", value: certificate.programName },
|
||||
{ label: "Durasi", value: certificate.duration },
|
||||
{ label: "Nama Program", value: project?.project_name ?? "-" },
|
||||
{ label: "Durasi", value: duration },
|
||||
{ label: "Total Tugas", value: project ? `${project.total_tasks} Tugas` : "-" },
|
||||
{
|
||||
label: "Status",
|
||||
value: (
|
||||
<span className="bg-green-100 text-green-700 text-xs font-bold px-2 py-0.5 rounded-full">
|
||||
VERIFIED
|
||||
UNVERIFIED
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -1,19 +1,42 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { History } from "lucide-react";
|
||||
import { type CertificateHistory, history as certificateHistory } from "../../../../lib/mockSertificates";
|
||||
import { History, Loader2, AlertCircle } from "lucide-react";
|
||||
import type { Certificate } from "../../../../types/certificate.types";
|
||||
import certificateImg from "../../../../assets/certificate.png";
|
||||
import { useMyCertificates } from "../../../../hooks/useSertificates";
|
||||
|
||||
export default function SertificateHistory() {
|
||||
const navigate = useNavigate();
|
||||
const { certificates, loading, error } = useMyCertificates();
|
||||
const displayed = certificates.slice(0, 2);
|
||||
|
||||
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">
|
||||
<History className="w-5 h-5 text-red-700" />
|
||||
<h3 className="font-semibold text-gray-800 text-lg">Certificate History</h3>
|
||||
</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">
|
||||
{certificateHistory.slice(0, 2).map((item: CertificateHistory) => (
|
||||
{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"
|
||||
@@ -22,17 +45,25 @@ export default function SertificateHistory() {
|
||||
<img
|
||||
src={certificateImg}
|
||||
alt="Certificate"
|
||||
className="rounded-xl h-[150px] w-[150px] w-full object-cover"
|
||||
className="rounded-xl h-[150px] w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<p className="text-xs text-gray-400">{item.month}</p>
|
||||
<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>
|
||||
<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 my-3 flex justify-end">
|
||||
<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 (
|
||||
<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 */}
|
||||
<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-30 h-30 flex items-center rounded-lg justify-center bg-gray-300">
|
||||
<Lock className="w-16 h-16 text-red-700" />
|
||||
</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-5 h-5 text-yellow-800" />
|
||||
<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">
|
||||
{allTasksDone ? (
|
||||
<Award className="w-12 h-12 text-green-600" />
|
||||
) : (
|
||||
<Lock className="w-12 h-12 text-red-700" />
|
||||
)}
|
||||
</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>
|
||||
|
||||
<h2 className="text-[37px] font-bold text-gray-900 mb-3">Sertifikat Belum Tersedia</h2>
|
||||
<p className="text-gray-500 text-[17px] mb-6">
|
||||
{allTasksDone ? (
|
||||
<>
|
||||
<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.
|
||||
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>
|
||||
@@ -34,10 +95,10 @@ export default function CertificateNotAvailable({ progress, remainingTasks }: {
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-2 text-left">
|
||||
Selesaikan {remainingTasks} tugas lagi untuk membuka akses.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import type { ProjectTask } from "../../../../types/task.types";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { InternTaskItem } from "../../../../types/project.types";
|
||||
|
||||
const formatDate = (dateStr: string) =>
|
||||
new Date(dateStr).toLocaleDateString('id-ID', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
@@ -7,37 +7,36 @@ const formatDate = (dateStr: string) =>
|
||||
const formatTime = (dateStr: string) =>
|
||||
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 { slug } = useParams<{ slug: string }>();
|
||||
return (
|
||||
<div
|
||||
className="border border-card-outline rounded-xl px-6 py-4 mb-3 bg-white flex items-start justify-between gap-3"
|
||||
onClick={() => navigate(`/intern/projects/${slug}/${task.slug}?type=${task.submission_type}`)}
|
||||
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/${task.project_slug}/${task.slug}?type=${task.submission_type}`)}
|
||||
>
|
||||
<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)}
|
||||
</div>
|
||||
<div className=" font-bold text-[15px] text-[#1a1a1a] mb-1">
|
||||
<div className="font-bold text-[15px] text-[#1a1a1a] mb-1">
|
||||
{task.title}
|
||||
</div>
|
||||
<div className=" text-[13px] text-font">
|
||||
<div className="text-[13px] text-font">
|
||||
{task.description}
|
||||
</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"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="block md:hidden h-[10px] bg-gray-600"/>
|
||||
|
||||
<div className="shrink-0 hidden md:block">
|
||||
<button
|
||||
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"
|
||||
<div className="shrink-0 hidden md:flex items-center">
|
||||
<span
|
||||
className={`font-semibold text-xs px-3.5 py-[5px] rounded-md whitespace-nowrap bg-gray-50`}
|
||||
>
|
||||
Add Submission
|
||||
</button>
|
||||
{task.submission_status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ const ProjectCard = (project: Project) => {
|
||||
const Icon = getDynamicIcon(project.project_icon);
|
||||
|
||||
|
||||
const tasksDone = 1;
|
||||
const tasksDone = project.task_done;
|
||||
const tasksTotal = project.total_tasks;
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,12 +7,13 @@ import type { TaskSubmissionData } from "../../../../types/task.types";
|
||||
|
||||
interface TaskFormFileProps {
|
||||
taskId: string;
|
||||
projectId?: string;
|
||||
deadline?: Date;
|
||||
initialSubmission?: TaskSubmissionData | null;
|
||||
}
|
||||
|
||||
export default function TaskFormFile({ taskId, deadline, initialSubmission }: TaskFormFileProps) {
|
||||
const { submission, loading, submitFile, updateFile, remove } = useSubmission(taskId, initialSubmission);
|
||||
export default function TaskFormFile({ taskId, projectId, deadline, initialSubmission }: TaskFormFileProps) {
|
||||
const { submission, loading, submitFile, updateFile, remove } = useSubmission(taskId, initialSubmission, projectId);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [isDragActive, setIsDragActive] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
@@ -7,12 +7,13 @@ import type { TaskSubmissionData } from "../../../../types/task.types";
|
||||
|
||||
interface TaskFormLinkProps {
|
||||
taskId: string;
|
||||
projectId?: string;
|
||||
deadline?: Date;
|
||||
initialSubmission?: TaskSubmissionData | null;
|
||||
}
|
||||
|
||||
export default function TaskFormLink({ taskId, deadline, initialSubmission }: TaskFormLinkProps) {
|
||||
const { submission, loading, submitLink, updateLink, remove } = useSubmission(taskId, initialSubmission);
|
||||
export default function TaskFormLink({ taskId, projectId, deadline, initialSubmission }: TaskFormLinkProps) {
|
||||
const { submission, loading, submitLink, updateLink, remove } = useSubmission(taskId, initialSubmission, projectId);
|
||||
const [inputLink, setInputLink] = useState(initialSubmission?.url_link ?? "");
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Sparkles,
|
||||
ArrowLeft,
|
||||
} from "lucide-react";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -67,7 +66,6 @@ function Avatar({ initials }: { initials: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Progress Bar ─────────────────────────────────────────────────────────────
|
||||
function ProgressBar({ value, total }: { value: number; total: number }) {
|
||||
const pct = Math.round((value / total) * 100);
|
||||
const isComplete = value === total;
|
||||
@@ -86,7 +84,6 @@ function ProgressBar({ value, total }: { value: number; total: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Status Badge ─────────────────────────────────────────────────────────────
|
||||
function StatusBadge({ status }: { status: InternStatus }) {
|
||||
const isEligible = status === "ELIGIBLE";
|
||||
return (
|
||||
@@ -99,7 +96,6 @@ function StatusBadge({ status }: { status: InternStatus }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
export default function CertificateDetail() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -135,26 +131,14 @@ export default function CertificateDetail() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<h1 className="page-title">Certificates</h1>
|
||||
<p className="page-title-desc">
|
||||
Administrate, issue, and track professional certifications for all interns.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Two-column layout ── */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||
|
||||
{/* ── LEFT: Project Info ── */}
|
||||
<div className="space-y-5">
|
||||
{/* Status badge */}
|
||||
<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
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h2 className="text-3xl font-extrabold text-gray-900 leading-tight">
|
||||
{mockProject.title}
|
||||
</h2>
|
||||
|
||||
{/* Period */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400 font-medium">
|
||||
<CalendarDays className="w-4 h-4 flex-shrink-0" />
|
||||
{mockProject.period}
|
||||
</div>
|
||||
|
||||
{/* Project Scope */}
|
||||
<div className="border border-gray-200 rounded-xl p-5">
|
||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-3">
|
||||
Project Scope
|
||||
@@ -183,7 +164,6 @@ export default function CertificateDetail() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats: Total & Eligible */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="border border-gray-200 rounded-xl p-4">
|
||||
<p className="text-2xl font-extrabold text-[#B30000]">{mockProject.totalInterns}</p>
|
||||
@@ -196,10 +176,8 @@ export default function CertificateDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── RIGHT: Intern Roster ── */}
|
||||
<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>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
{/* Select All button */}
|
||||
<button
|
||||
onClick={toggleSelectAll}
|
||||
className="flex items-center gap-2 border border-gray-200 rounded-xl px-4 py-2.5 bg-white hover:bg-gray-50 transition-colors flex-shrink-0 ml-4"
|
||||
@@ -340,14 +317,7 @@ export default function CertificateDetail() {
|
||||
</div>
|
||||
|
||||
{/* ── 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">
|
||||
<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>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-end gap-4 mt-6 pt-4">
|
||||
<button
|
||||
onClick={() => navigate("/mentor/certificates/result")}
|
||||
className="flex items-center gap-2 px-8 py-3.5 bg-[#B30000] hover:bg-[#990000] text-white font-bold text-sm rounded-xl shadow-md transition-colors"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// src/pages/mentor/CertificateResult.tsx
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
CheckCircle2,
|
||||
FolderArchive,
|
||||
@@ -55,7 +54,6 @@ function Avatar({ initials }: { initials: string }) {
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
export default function CertificateResult() {
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
@@ -75,12 +73,6 @@ export default function CertificateResult() {
|
||||
|
||||
return (
|
||||
<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-8">
|
||||
<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 {CalendarDays} from "lucide-react";
|
||||
|
||||
// ─── 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>
|
||||
);
|
||||
}
|
||||
|
||||
import CertificateProjectCard from "./components/CertificateProjectCard";
|
||||
import { useProjects } from "../../../hooks/useProjects";
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
export default function CertificateList() {
|
||||
const navigate = useNavigate();
|
||||
const { projects, loading, error } = useProjects();
|
||||
|
||||
const handleCardClick = (id: number) => {
|
||||
navigate(`/mentor/certificates/${id}`);
|
||||
const handleCardClick = (slug: string) => {
|
||||
navigate(`/mentor/certificates/${slug}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-1 text-xs font-medium text-[#B30000]">
|
||||
Certificates ›
|
||||
</div>
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<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>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{mockProjects.map((project) => (
|
||||
<ProjectCard
|
||||
{projects.map((project) => (
|
||||
<CertificateProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
onClick={() => handleCardClick(project.id)}
|
||||
onClick={() => handleCardClick(project.slug)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,32 +1,55 @@
|
||||
import { useState, useMemo } from "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";
|
||||
|
||||
export default function InternsManagement() {
|
||||
// State Filter & Search
|
||||
const [searchEmail, setSearchEmail] = useState("");
|
||||
const [selectedProject, setSelectedProject] = useState("All Projects");
|
||||
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) => {
|
||||
alert(`Deleted intern ID: ${id}`);
|
||||
};
|
||||
const { interns, loading, error, refetch } = useAllInterns();
|
||||
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(() => {
|
||||
return mockInternsData.filter((intern) => {
|
||||
return interns.filter((intern) => {
|
||||
const matchesEmail = intern.email.toLowerCase().includes(searchEmail.toLowerCase());
|
||||
const matchesProject =
|
||||
selectedProject === "All Projects" ||
|
||||
intern.projectName.toLowerCase() === selectedProject.toLowerCase();
|
||||
(intern.projectName || "Unassigned").toLowerCase() === selectedProject.toLowerCase();
|
||||
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 (
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
</select>
|
||||
<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>
|
||||
</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>
|
||||
<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">
|
||||
TOTAL INTERNS
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-8 w-px bg-gray-100 mx-2"></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">
|
||||
ACTIVE ASSIGNMENTS
|
||||
</span>
|
||||
@@ -100,7 +125,10 @@ export default function InternsManagement() {
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
Add
|
||||
</button>
|
||||
@@ -120,7 +148,7 @@ export default function InternsManagement() {
|
||||
<div className="divide-y divide-gray-100">
|
||||
{filteredInterns.length > 0 ? (
|
||||
filteredInterns.map((intern, index) => {
|
||||
const isUnassigned = intern.projectName === "Unassigned";
|
||||
const isUnassigned = !intern.projectName || intern.projectName === "Unassigned";
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<div className="w-1/3">
|
||||
<h5 className={`text-sm font-semibold leading-tight ${isUnassigned ? 'text-gray-500 italic' : 'text-gray-900'}`}>
|
||||
{intern.projectName}
|
||||
{intern.projectName || "Unassigned"}
|
||||
</h5>
|
||||
<p className={`text-xs font-bold mt-0.5 ${isUnassigned ? 'text-red-500' : 'text-red-600'}`}>
|
||||
{intern.role}
|
||||
@@ -156,15 +184,20 @@ export default function InternsManagement() {
|
||||
|
||||
{/* Column 3: Action Buttons Block Right aligned */}
|
||||
<div className="w-1/6 flex items-center justify-end gap-3.5 pr-2">
|
||||
{/* Tombol Add: membuka modal assign member */}
|
||||
<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"
|
||||
title="Assign ke project"
|
||||
>
|
||||
<Plus className="w-4 h-4 stroke-[3]" />
|
||||
</button>
|
||||
{/* Tombol Delete: remove intern dari project */}
|
||||
<button
|
||||
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]" />
|
||||
</button>
|
||||
@@ -183,7 +216,7 @@ export default function InternsManagement() {
|
||||
{/* 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>
|
||||
Showing <span className="font-bold">{filteredInterns.length}</span> of 124 interns
|
||||
Showing <span className="font-bold">{filteredInterns.length}</span> of {totalInterns} interns
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 self-end sm:self-auto">
|
||||
@@ -196,10 +229,14 @@ export default function InternsManagement() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isModalOpen &&
|
||||
<AddInternsModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onClose={() => {
|
||||
setIsModalOpen(false);
|
||||
refetch(); // Refresh data setelah modal assign ditutup
|
||||
}}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRef, useEffect, useState } from "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 {
|
||||
isOpen: boolean;
|
||||
@@ -12,8 +13,11 @@ export default function AddInternsModal({ isOpen, onClose }: AddInternsModalProp
|
||||
|
||||
// Form States
|
||||
const [email, setEmail] = useState("");
|
||||
const [position, setPosition] = useState("");
|
||||
const [projectName, setProjectName] = useState("");
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string>("");
|
||||
|
||||
// Hooks
|
||||
const { assign, loading: assigning, error: assignError } = useAssignMember();
|
||||
const { projects, loading: loadingProjects } = useProjects("active");
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
@@ -39,11 +43,29 @@ export default function AddInternsModal({ isOpen, onClose }: AddInternsModalProp
|
||||
};
|
||||
}, [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();
|
||||
const payload = { email, position, projectName };
|
||||
console.log("Adding Intern:", payload);
|
||||
alert("Intern Added Successfully!");
|
||||
|
||||
// Catatan: useAssignMember memerlukan id_user (number), bukan email.
|
||||
// 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();
|
||||
};
|
||||
|
||||
@@ -90,29 +112,30 @@ export default function AddInternsModal({ isOpen, onClose }: AddInternsModalProp
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Position */}
|
||||
{/* Project Selection */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold tracking-wide text-foreground">
|
||||
Position
|
||||
Project
|
||||
</label>
|
||||
<div className="relative">
|
||||
<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>
|
||||
<select
|
||||
required
|
||||
value={position}
|
||||
onChange={(e) => setPosition(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`}
|
||||
value={selectedProjectId}
|
||||
onChange={(e) => setSelectedProjectId(e.target.value)}
|
||||
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="frontend">Frontend Developer</option>
|
||||
<option value="backend">Backend Developer</option>
|
||||
<option value="fullstack">Fullstack Developer</option>
|
||||
<option value="designer">UI/UX Designer</option>
|
||||
<option value="mobile">Mobile Developer</option>
|
||||
<option value="devops">DevOps Engineer</option>
|
||||
<option value="qa">QA Engineer</option>
|
||||
<option value="" disabled>
|
||||
{loadingProjects ? "Memuat projects..." : "Pilih project"}
|
||||
</option>
|
||||
{projects.map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.project_name}
|
||||
</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}>
|
||||
@@ -122,33 +145,15 @@ export default function AddInternsModal({ isOpen, onClose }: AddInternsModalProp
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project Name */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold tracking-wide text-foreground">
|
||||
Project Name
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none text-border">
|
||||
<LuFolderOpen className="w-4 h-4 stroke-[2]" />
|
||||
</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>
|
||||
{/* Field Position dikomentari — tidak ada dalam payload assign-member */}
|
||||
{/* <div className="space-y-1.5">
|
||||
<label className="text-xs font-bold tracking-wide text-foreground">Position</label>
|
||||
<select>...</select>
|
||||
</div> */}
|
||||
|
||||
{assignError && (
|
||||
<p className="text-xs text-red-600 font-medium">{assignError}</p>
|
||||
)}
|
||||
|
||||
<div className="h-px bg-border/30" />
|
||||
|
||||
@@ -163,10 +168,11 @@ export default function AddInternsModal({ isOpen, onClose }: AddInternsModalProp
|
||||
</button>
|
||||
<button
|
||||
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]" />
|
||||
Add Member
|
||||
{assigning ? "Assigning..." : "Add Member"}
|
||||
</button>
|
||||
</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 { 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 { useCreateProject } from "../../../../hooks/useProjects";
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
getProjectMembers,
|
||||
removeMember
|
||||
} from "../services/ProjectService";
|
||||
import { useMemo } from "react";
|
||||
|
||||
// GET /project-api/get
|
||||
export const useProjects = (status?: "active" | "completed" | "archived") => {
|
||||
@@ -136,14 +137,17 @@ export const useAllInterns = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const refetch = useCallback(() => {
|
||||
setLoading(true);
|
||||
getAllInterns()
|
||||
.then(setInterns)
|
||||
.catch(() => setError("Gagal memuat data interns."))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return { interns, loading, error };
|
||||
useEffect(() => { refetch(); }, [refetch]);
|
||||
|
||||
return { interns, loading, error, refetch };
|
||||
};
|
||||
|
||||
// POST /project-api/add
|
||||
@@ -167,6 +171,17 @@ export const useCreateProject = () => {
|
||||
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}
|
||||
export const useUpdateProject = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface CertificateData {
|
||||
}
|
||||
|
||||
export const certificateData: CertificateData = {
|
||||
isReceived: true,
|
||||
isReceived: false,
|
||||
projectProgress: 90,
|
||||
remainingTasks: 0,
|
||||
certificate: {
|
||||
|
||||
@@ -18,8 +18,10 @@ export interface Certificate {
|
||||
id_user: number;
|
||||
certificate_no: string;
|
||||
issued_at: string;
|
||||
uuid: string;
|
||||
user: CertificateUser;
|
||||
project: CertificateProject;
|
||||
certificate_status: string;
|
||||
}
|
||||
|
||||
export interface CertificateVerifyResult {
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface Project {
|
||||
status: ProjectStatus;
|
||||
admin: ProjectAdmin;
|
||||
total_members: number;
|
||||
task_done: number;
|
||||
total_tasks: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
Reference in New Issue
Block a user