Toast Implementation; fix add interns dialogue: penghapusan UI delete pada dialog komponen; penambahan params pada generate sertificate; perubahan tipe state untuk submission (user bisa open file submission)

This commit is contained in:
Kemas M. Aryadary Rasyad
2026-07-07 11:47:33 +07:00
parent 7a331b8e7d
commit 91d7bcf3f7
18 changed files with 501 additions and 290 deletions
-1
View File
@@ -42,7 +42,6 @@ import InternifyLogin from "./Pages/Internify/LoginInternify"
import FAQPage from "./Pages/Internify/FAQpage"; import FAQPage from "./Pages/Internify/FAQpage";
import NotificationList from "./Pages/Internify/NotificationList"; import NotificationList from "./Pages/Internify/NotificationList";
import SettingsContent from "./Pages/Internify/SettingsPage"; import SettingsContent from "./Pages/Internify/SettingsPage";
import MentorSettingsPage from "./Pages/Internify/mentor/MentorSettingsPage";
import TaskSubmission from "./Pages/Internify/intern/TaskSubmission"; import TaskSubmission from "./Pages/Internify/intern/TaskSubmission";
import SertificateList from "./Pages/Internify/intern/InternCertList"; import SertificateList from "./Pages/Internify/intern/InternCertList";
import MentorProjectsDetailPage from "./Pages/Internify/mentor/MentorProjectDetailPage"; import MentorProjectsDetailPage from "./Pages/Internify/mentor/MentorProjectDetailPage";
+1 -1
View File
@@ -82,7 +82,7 @@ const Navbar = () => {
}} }}
className="block w-full text-left hover:text-red-600 transition" className="block w-full text-left hover:text-red-600 transition"
> >
Login Internify
</button> </button>
</div> </div>
</div> </div>
+8 -3
View File
@@ -1,4 +1,5 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Check, Download, Info } from "lucide-react"; import { Check, Download, Info } from "lucide-react";
import { LuFileSpreadsheet } from "react-icons/lu"; import { LuFileSpreadsheet } from "react-icons/lu";
import { io } from "socket.io-client"; import { io } from "socket.io-client";
@@ -14,7 +15,7 @@ const getCookieToken = () => {
export default function NotificationList() { export default function NotificationList() {
const [notifications, setNotifications] = useState<UINotification[]>([]); const [notifications, setNotifications] = useState<UINotification[]>([]);
const navigate = useNavigate();
useEffect(() => { useEffect(() => {
const loadNotifications = async () => { const loadNotifications = async () => {
try { try {
@@ -131,7 +132,9 @@ export default function NotificationList() {
{/* Conditional Actions */} {/* Conditional Actions */}
{item.type === 'new-task' && ( {item.type === 'new-task' && (
<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"
onClick={() => navigate(`/projects/${item.link}`)}>
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">
@@ -141,7 +144,9 @@ export default function NotificationList() {
)} )}
{item.type === 'achievement' && ( {item.type === 'achievement' && (
<button className="flex items-center gap-1 text-xs font-bold text-[#B30000] hover:underline pt-2"> <button
className="flex items-center gap-1 text-xs font-bold text-[#B30000] hover:underline pt-2"
onClick={() => navigate(`/certificate`)}>
<Download className="w-3.5 h-3.5" /> <Download className="w-3.5 h-3.5" />
Download Certificate Download Certificate
</button> </button>
+19 -12
View File
@@ -6,37 +6,44 @@ import type { Project } from '../../../types/project.types';
export default function ForumTab({ project }: { project: Project }) { export default function ForumTab({ project }: { project: Project }) {
const { tasks, loading, error } = useProjectMyTasks(project.id); const { tasks, loading, error } = useProjectMyTasks(project.id);
return ( return (
<div> <div>
<HeroProject title={project.project_name} description={project.description} /> <HeroProject title={project.project_name} description={project.description} />
{/* Loading */} {/* Loading skeleton */}
{loading && ( {/* {loading && (
<div className="flex flex-col gap-3 mt-5"> <div className="flex flex-col gap-3 mt-5">
{[...Array(3)].map((_, i) => ( {[...Array(3)].map((_, i) => (
<div key={i} className="h-24 bg-gray-100 rounded-xl animate-pulse" /> <div key={i} className="h-24 bg-gray-100 rounded-xl animate-pulse" />
))} ))}
</div> </div>
)} )} */}
{/* Error */} {/* Error */}
{error && <p className="text-red-500 text-sm mt-5">{error}</p>} {error && <p className="text-red-500 text-sm mt-5">{error}</p>}
{/* Timeline + Task list */} {/* Timeline + Task list — hanya tampil jika ada task */}
<div className="flex flex-col gap-7 md:flex-row md:items-start"> {/* {!loading && tasks.length === 0 ? ( */}
{tasks.length === 0 ? (
<p className="text-md text-font-shade mt-5">Belum ada tugas yang dapat dikerjakan.</p>
) : (
!loading && (
<div className="flex flex-col gap-7 md:flex-row md:items-start mt-5">
{/* Kolom kiri: Timeline */}
<div className="w-full md:w-1/5 bg-white rounded-xl p-5 border border-box-border shrink-0"> <div className="w-full md:w-1/5 bg-white rounded-xl p-5 border border-box-border shrink-0">
<ProjectTimeline tasks={tasks} /> <ProjectTimeline tasks={tasks} />
</div> </div>
{/* Kolom kanan: Task cards */}
<div className="w-full border-t border-box-border md:border-t-0 pt-4 md:pt-0 md:w-4/5"> <div className="w-full border-t border-box-border md:border-t-0 pt-4 md:pt-0 md:w-4/5">
{tasks.length === 0 ? ( {tasks.map((task) => (
<p className="text-sm text-gray-400">Belum ada task.</p>
) : (
tasks.map((task) => (
<TaskCard key={task.id} task={task} /> <TaskCard key={task.id} task={task} />
)) ))}
</div>
</div>
)
)} )}
</div> </div>
</div>
</div>
); );
} }
@@ -58,10 +58,13 @@ export default function SertificateList() {
{/* Thumbnail */} {/* Thumbnail */}
<div className="p-6 pb-0"> <div className="p-6 pb-0">
<div className="rounded-lg overflow-hidden"> <div className="rounded-lg overflow-hidden">
<div className="w-full h-32 bg-gray-300 flex items-center justify-center"> <div
className="w-full h-32 flex items-center justify-center"
style={{ backgroundColor: item.background_color || '#403f3fff' }}
>
{(() => { {(() => {
const Icon = getDynamicIcon(item.project_icon); const Icon = getDynamicIcon(item.project_icon);
return <Icon className="w-10 h-10 text-primary" />; return <Icon className="w-10 h-10 text-primary text-white" />;
})()} })()}
</div> </div>
</div> </div>
@@ -69,7 +72,7 @@ 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"> <p className="text-xs text-font mb-0.5">
{new Date(item.start_date).toLocaleDateString("id-ID", { {new Date(item.start_date).toLocaleDateString("id-ID", {
year: "numeric", year: "numeric",
month: "long", month: "long",
@@ -78,11 +81,11 @@ export default function SertificateList() {
month: "long", month: "long",
})} })}
</p> </p>
<p className="text-sm font-semibold text-gray-800 leading-snug"> <p className="text-sm font-semibold text-font-shade leading-snug">
{item.project_name} {item.project_name}
</p> </p>
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-font mt-1">
No: <span className="font-bold text-red-600">{item.status}</span> Status Projek: <span className="font-bold text-red-600">{item.status}</span>
</p> </p>
</div> </div>
</div> </div>
@@ -5,6 +5,7 @@ import type { ProjectDetail } from "../../../../types/project.types";
import type { Certificate } from "../../../../types/certificate.types"; import type { Certificate } from "../../../../types/certificate.types";
import { downloadCertificatePdf, downloadCertificateImage } from "../../../utils/Certificates"; import { downloadCertificatePdf, downloadCertificateImage } from "../../../utils/Certificates";
import { generateCertificate, resolveImageUrl } from "../../../utils/SertificateGenerator"; import { generateCertificate, resolveImageUrl } from "../../../utils/SertificateGenerator";
import { customToast } from "../../../utils/showToast";
interface CertificateAvailableProps { interface CertificateAvailableProps {
project: ProjectDetail | null; project: ProjectDetail | null;
@@ -19,29 +20,6 @@ export default function CertificateAvailable({ project, certificate, templateUrl
const [previewUrl, setPreviewUrl] = useState<string | null>(null); const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [loadingPreview, setLoadingPreview] = useState(true); const [loadingPreview, setLoadingPreview] = useState(true);
useEffect(() => {
if (!templateUrl) return;
const verifyUrl = `${window.location.origin}/verify-certificate/${certificate.uuid}`;
generateCertificate(
resolveImageUrl(templateUrl),
certificate.user.full_name,
certificate.project.project_name,
certificate.certificate_no,
verifyUrl,
"20 Juni 2024 - 20 September 2024") //placeholder karena saya butuh data durasi certificate
.then((blob) => {
const url = URL.createObjectURL(blob);
setPreviewUrl(url);
})
.finally(() => setLoadingPreview(false));
// Cleanup supaya tidak memory leak
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
};
}, [templateUrl, certificate.user.full_name]);
const duration = project const duration = project
? (() => { ? (() => {
const start = new Date(project.start_date); const start = new Date(project.start_date);
@@ -56,10 +34,37 @@ export default function CertificateAvailable({ project, certificate, templateUrl
})() })()
: "-"; : "-";
useEffect(() => {
if (!templateUrl) return;
const verifyUrl = `${window.location.origin}/verify-certificate/${certificate.uuid}`;
generateCertificate(
resolveImageUrl(templateUrl),
certificate.user.full_name,
certificate.project.project_name,
certificate.certificate_no,
verifyUrl,
duration)
.then((blob) => {
const url = URL.createObjectURL(blob);
setPreviewUrl(url);
})
.finally(() => setLoadingPreview(false));
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
};
}, [templateUrl, certificate.user.full_name]);
const handleDownloadImage = async () => { const handleDownloadImage = async () => {
setDownloadingImg(true); setDownloadingImg(true);
try { try {
await downloadCertificateImage(certificate, templateUrl, "png"); await downloadCertificateImage(certificate, templateUrl, "png");
customToast.success("Sertifikat berhasil diunduh!");
} catch {
customToast.error("Gagal mengunduh sertifikat!");
} finally { } finally {
setDownloadingImg(false); setDownloadingImg(false);
} }
@@ -69,6 +74,9 @@ export default function CertificateAvailable({ project, certificate, templateUrl
setDownloadingPdf(true); setDownloadingPdf(true);
try { try {
await downloadCertificatePdf(certificate, templateUrl); await downloadCertificatePdf(certificate, templateUrl);
customToast.success("Sertifikat berhasil diunduh!");
} catch {
customToast.error("Gagal mengunduh sertifikat!");
} finally { } finally {
setDownloadingPdf(false); setDownloadingPdf(false);
} }
@@ -147,6 +155,10 @@ export default function CertificateAvailable({ project, certificate, templateUrl
<button <button
// onClick={handleCopyLink} // onClick={handleCopyLink}
className="flex flex-col items-center justify-center gap-1.5 border border-card-outline hover:bg-gray-50 active:scale-95 transition-all text-red-700 font-medium py-3.5 rounded-xl text-sm" className="flex flex-col items-center justify-center gap-1.5 border border-card-outline hover:bg-gray-50 active:scale-95 transition-all text-red-700 font-medium py-3.5 rounded-xl text-sm"
onClick={() => {
navigator.clipboard.writeText(`${window.location.origin}/verify-certificate/${certificate.uuid}`);
customToast.success("Link berhasil disalin!");
}}
> >
<Link className="w-5 h-5" /> <Link className="w-5 h-5" />
Salin Link Salin Link
@@ -1,9 +1,9 @@
import type { InternTaskItem } from "../../../../types/project.types"; import type { ProjectTask } from "../../../../types/task.types";
const formatDate = (dateStr: string) => const formatDate = (dateStr: string) =>
new Date(dateStr).toLocaleDateString('id-ID', { day: 'numeric', month: 'short' }); new Date(dateStr).toLocaleDateString('id-ID', { day: 'numeric', month: 'short' });
export default function ProjectTimeline({ tasks }: { tasks: InternTaskItem[] }) { export default function ProjectTimeline({ tasks }: { tasks: ProjectTask[] }) {
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
return ( return (
@@ -98,9 +98,7 @@ export default function SidebarIntern({ isOpen, closeSidebar }: SidebarProps) {
onClick={() => isLogout ? handleLogout() : handleNav(path)} onClick={() => isLogout ? handleLogout() : handleNav(path)}
className={`flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-[13.5px] font-medium text-left transition-colors className={`flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-[13.5px] font-medium text-left transition-colors
${isActive ${isActive
? "bg-red-50 text-red-600 font-semibold" ? "bg-red-foreground text-red font-semibold border-l-4 border-base"
: danger
? "text-font hover:bg-base-foreground"
: "text-font hover:bg-red-foreground" : "text-font hover:bg-red-foreground"
}`} }`}
> >
+16 -3
View File
@@ -6,6 +6,7 @@ import { useProjectDetail } from "../../../hooks/useProjects";
import EligibleInternTable from "./components/EligibleInternTable"; import EligibleInternTable from "./components/EligibleInternTable";
import { useProjectInternProgress } from "../../../hooks/useInternProgress"; import { useProjectInternProgress } from "../../../hooks/useInternProgress";
import { resolveImageUrl } from "../../utils/SertificateGenerator"; import { resolveImageUrl } from "../../utils/SertificateGenerator";
import { customToast } from "../../utils/showToast";
export default function CertificateDetail() { export default function CertificateDetail() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -25,20 +26,32 @@ export default function CertificateDetail() {
if (!file) return; if (!file) return;
setSelectedFile(file); setSelectedFile(file);
setUploadSuccess(false); setUploadSuccess(false);
// Revoke object URL lama biar tidak memory leak
if (previewUrl) URL.revokeObjectURL(previewUrl); if (previewUrl) URL.revokeObjectURL(previewUrl);
setPreviewUrl(URL.createObjectURL(file)); setPreviewUrl(URL.createObjectURL(file));
}; };
const handleUpload = async () => { const handleUpload = async () => {
if (!selectedFile || !project) return; if (!selectedFile || !project) return;
const result = await upload(project.id, selectedFile); await customToast.promise(
upload(project.id, selectedFile),
{
loading: "Mengupload template...",
success: () => ({
title: "Upload berhasil",
description: "Template sertifikat berhasil disimpan.",
}),
error: (err) => ({
title: "Upload gagal",
description: err instanceof Error ? err.message : "Terjadi kesalahan saat upload.",
}),
}
).then((result) => {
if (result) { if (result) {
setUploadSuccess(true); setUploadSuccess(true);
setSelectedFile(null); setSelectedFile(null);
if (fileInputRef.current) fileInputRef.current.value = ""; if (fileInputRef.current) fileInputRef.current.value = "";
} }
}).catch(() => {});
}; };
const handleClearFile = () => { const handleClearFile = () => {
+60 -113
View File
@@ -1,7 +1,8 @@
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { Search, SlidersHorizontal, Plus, Trash2 } from "lucide-react"; import { Search, Plus, ChevronDown, SortAsc, SortDesc } from "lucide-react";
import { useAllInterns, useRemoveMember, useProjects } from "../../../hooks/useProjects"; import { useAllInterns, useRemoveMember, useProjects } from "../../../hooks/useProjects";
import AddInternsModal from "./components/AddInternsToProjects"; import AddInternsModal from "./components/AddInternsToProjects";
import InternsTable from "./components/InternsTable";
import type { InternDetail } from "../../../types/project.types"; import type { InternDetail } from "../../../types/project.types";
import type { Project } from "../../../types/project.types"; import type { Project } from "../../../types/project.types";
import { customToast } from "../../utils/showToast"; import { customToast } from "../../utils/showToast";
@@ -11,32 +12,45 @@ export default function InternsManagement() {
const [selectedProject, setSelectedProject] = useState("All Projects"); const [selectedProject, setSelectedProject] = useState("All Projects");
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<number | null>(null); const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
const [sortOrder, setSortOrder] = useState<"asc" | "desc" | null>(null);
const { interns, loading, error, refetch } = useAllInterns(); const { interns, loading, error, refetch } = useAllInterns();
const { remove, loading: removing } = useRemoveMember(); const { remove, loading: removing } = useRemoveMember();
const { projects: activeProjects } = useProjects("active"); const { projects: activeProjects } = useProjects("active");
// Filter data berdasarkan email dan nama project
const filteredInterns = useMemo(() => { const filteredInterns = useMemo(() => {
return interns.filter((intern) => { const filtered = 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 || "Unassigned").toLowerCase() === selectedProject.toLowerCase(); (intern.projectName || "Unassigned").toLowerCase() === selectedProject.toLowerCase();
return matchesEmail && matchesProject; return matchesEmail && matchesProject;
}); });
}, [interns, searchEmail, selectedProject]);
if (sortOrder === "asc") {
return [...filtered].sort((a, b) => a.name.localeCompare(b.name));
} else if (sortOrder === "desc") {
return [...filtered].sort((a, b) => b.name.localeCompare(a.name));
}
return filtered;
}, [interns, searchEmail, selectedProject, sortOrder]);
const totalInterns = interns.length; const totalInterns = interns.length;
const activeAssignments = activeProjects.length; const activeAssignments = activeProjects.length;
// Buka modal untuk assign intern tertentu (dari tombol + di baris)
const openAssignFor = (userId: number | null) => { const openAssignFor = (userId: number | null) => {
setSelectedUserId(userId); setSelectedUserId(userId);
setIsModalOpen(true); setIsModalOpen(true);
}; };
// Handler hapus intern dari project const handleToggleSort = () => {
setSortOrder((prev) => {
if (prev === null) return "asc";
if (prev === "asc") return "desc";
return null;
});
};
const handleDeleteIntern = async (intern: InternDetail) => { const handleDeleteIntern = async (intern: InternDetail) => {
const matchedProject = activeProjects.find((project: Project) => project.project_name === intern.projectName); const matchedProject = activeProjects.find((project: Project) => project.project_name === intern.projectName);
if (!matchedProject) { if (!matchedProject) {
@@ -75,7 +89,7 @@ export default function InternsManagement() {
{/* Filter Row & Stats Grid */} {/* Filter Row & Stats Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 items-center mb-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-4 items-center mb-6">
{/* Left Control Inputs */} {/* Left Control Inputs */}
<div className="lg:col-span-2 flex flex-col sm:flex-row items-center gap-3 border border-gray-300 p-4 rounded-xl"> <div className="lg:col-span-2 flex flex-col sm:flex-row items-center gap-3 border border-card-outline p-4 rounded-xl">
{/* Search Box Input */} {/* Search Box Input */}
<div className="relative w-full sm:max-w-xs"> <div className="relative w-full sm:max-w-xs">
<span className="absolute inset-y-0 left-3 flex items-center text-gray-400"> <span className="absolute inset-y-0 left-3 flex items-center text-gray-400">
@@ -86,7 +100,7 @@ export default function InternsManagement() {
placeholder="Filter by email" placeholder="Filter by email"
value={searchEmail} value={searchEmail}
onChange={(e) => setSearchEmail(e.target.value)} onChange={(e) => setSearchEmail(e.target.value)}
className="w-full pl-9 pr-4 py-2 bg-gray-100 border border-transparent rounded-lg text-sm placeholder-gray-400 text-gray-800 focus:outline-none focus:bg-white focus:border-gray-200 font-medium" className="w-full pl-9 pr-4 py-2 shadow-sm border border-card-outline rounded-lg text-sm placeholder-gray-400 text-gray-800 focus:outline-none focus:bg-card focus:border-gray-200 font-medium"
/> />
</div> </div>
@@ -95,26 +109,44 @@ export default function InternsManagement() {
<select <select
value={selectedProject} value={selectedProject}
onChange={(e) => setSelectedProject(e.target.value)} onChange={(e) => setSelectedProject(e.target.value)}
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 shadow-sm border border-card-outline rounded-lg text-sm text-gray-700 font-medium appearance-none focus:outline-none focus:bg-card focus:border-gray-200"
> >
<option value="All Projects">All Projects</option> <option value="All Projects">All Projects</option>
<option value="Unassigned">Unassigned</option> <option value="Unassigned">Unassigned</option>
{activeProjects.map((project) => (
<option key={project.id} value={project.project_name}>
{project.project_name}
</option>
))}
</select> </select>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-500"> <div
<svg className="fill-current h-4 w-4" viewBox="0 0 20 20"> className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-500"
<path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z" /> >
</svg> <ChevronDown className="w-4 h-4" />
</div> </div>
</div> </div>
{/* Icon Sliders / Filter Button */} {/* Sort A-Z / Z-A Toggle Button */}
<button className="p-2 bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200 transition-colors hidden sm:block"> <button
<SlidersHorizontal className="w-4 h-4" /> onClick={handleToggleSort}
title={sortOrder === "asc" ? "Sort Z–A" : sortOrder === "desc" ? "Clear sort" : "Sort A–Z"}
className={`p-2 shadow-sm rounded-lg transition-colors hidden sm:flex items-center gap-1 text-xs font-semibold ${
sortOrder
? "bg-[#B30000] text-white hover:bg-[#990000]"
: "bg-card text-gray-600 hover:bg-gray-200"
}`}
>
{sortOrder === "desc" ? (
<SortDesc className="w-4 h-4" />
) : (
<SortAsc className="w-4 h-4" />
)}
{sortOrder === "asc" ? "A–Z" : sortOrder === "desc" ? "Z–A" : "Sort"}
</button> </button>
</div> </div>
{/* Right Info Box Stats — data real dari API */} {/* 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-card-outline rounded-xl px-6 py-2.5 flex items-center justify-around text-center shadow-md w-full lg:max-w-sm ml-auto">
<div> <div>
<span className="block text-xl font-bold text-red-600 leading-none"> <span className="block text-xl font-bold text-red-600 leading-none">
{totalInterns} {totalInterns}
@@ -123,7 +155,7 @@ export default function InternsManagement() {
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-card-outline mx-2"></div>
<div> <div>
<span className="block text-xl font-bold text-green-600 leading-none"> <span className="block text-xl font-bold text-green-600 leading-none">
{activeAssignments} {activeAssignments}
@@ -145,101 +177,16 @@ export default function InternsManagement() {
</button> </button>
</div> </div>
{/* Main Table Container Block */} <InternsTable
<div className="w-full mt-3 bg-white rounded-xl border border-gray-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)] overflow-hidden"> key={`${searchEmail}-${selectedProject}`}
interns={filteredInterns}
{/* Red Table Header Block */} totalInterns={totalInterns}
<div className="bg-[#B30000] text-white px-6 py-3.5 flex items-center text-xs font-bold uppercase tracking-wider"> removing={removing}
<div className="w-1/2">Intern Details</div> sortOrder={sortOrder}
<div className="w-1/3">Current Project</div> onSort={handleToggleSort}
<div className="w-1/6 text-right pr-4">Actions</div> onAssign={(userId) => openAssignFor(userId)}
</div> onDelete={handleDeleteIntern}
{/* Table Body Iteration Rows */}
<div className="divide-y divide-gray-100">
{filteredInterns.length > 0 ? (
filteredInterns.map((intern, index) => {
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">
{/* Column 1: Intern Details (Avatar + Name & Email) */}
<div className="w-1/2 flex items-center gap-3">
{intern.avatar ? (
<img
src={intern.avatar}
alt={intern.name}
className="w-10 h-10 rounded-full object-cover border border-gray-100 shadow-sm"
/> />
) : (
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-teal-400 to-emerald-500 flex items-center justify-center border border-gray-100 shadow-sm text-white text-xs font-bold">
{intern.name.charAt(0)}
</div>
)}
<div>
<h4 className="text-sm font-bold text-gray-900 leading-tight">{intern.name}</h4>
<p className="text-xs text-gray-400 font-medium mt-0.5">{intern.email}</p>
</div>
</div>
{/* 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 || "Unassigned"}
</h5>
<p className={`text-xs font-bold mt-0.5 ${isUnassigned ? 'text-red-500' : 'text-red-600'}`}>
{intern.role}
</p>
</div>
{/* 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 untuk intern ini */}
<button
onClick={() => openAssignFor(intern.id)}
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)}
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>
</div>
</div>
);
})
) : (
<div className="text-center py-12 text-sm text-gray-400 font-medium">
No interns found matching the filter criteria.
</div>
)}
</div>
{/* 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 {totalInterns} interns
</div>
<div className="flex items-center gap-2 self-end sm:self-auto">
<button className="px-3 py-1.5 bg-black/20 hover:bg-black/30 rounded-md font-semibold opacity-50 cursor-not-allowed transition-colors">
Previous
</button>
<button className="px-4 py-1.5 bg-white text-gray-900 hover:bg-gray-100 rounded-md font-bold shadow-sm transition-colors">
Next
</button>
</div>
</div>
</div>
{isModalOpen && {isModalOpen &&
<AddInternsModal <AddInternsModal
@@ -247,7 +194,7 @@ export default function InternsManagement() {
userId={selectedUserId} userId={selectedUserId}
onClose={() => { onClose={() => {
setIsModalOpen(false); setIsModalOpen(false);
refetch(); // Refresh data setelah modal assign ditutup refetch();
}} }}
/> />
} }
+28 -12
View File
@@ -1,4 +1,3 @@
import { useParams } from 'react-router-dom';
import ProjectTimeline from '../intern/components/ProjectTimeline'; import ProjectTimeline from '../intern/components/ProjectTimeline';
import HeroProject from '../intern/components/Hero-project'; import HeroProject from '../intern/components/Hero-project';
import MentorTaskCard from './components/MentorTaskCard'; import MentorTaskCard from './components/MentorTaskCard';
@@ -6,28 +5,45 @@ import { useProjectTasks } from '../../../hooks/useTasks';
import type { Project } from '../../../types/project.types'; import type { Project } from '../../../types/project.types';
export default function MentorForumTab({ project }: { project: Project }) { export default function MentorForumTab({ project }: { project: Project }) {
const { slug } = useParams<{ slug: string }>(); // Gunakan project.id dari props — route pakai :slug, bukan :id
const { tasks, loading, error } = useProjectTasks(slug ?? ""); const { tasks, loading, error } = useProjectTasks(project.id);
if (error) return <div>Error: {error}</div>;
return ( return (
<div> <div>
{/* Hero banner */} {/* Hero banner */}
<HeroProject title={project.project_name} description={project.description} /> <HeroProject title={project.project_name} description={project.description} />
{/* Timeline + Task list */} {/* Loading skeleton */}
<div className="flex flex-col gap-7 md:flex-row md:items-start"> {loading && (
<div className="flex flex-col gap-3 mt-5">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-24 bg-gray-100 rounded-xl animate-pulse" />
))}
</div>
)}
{/* Timeline + Task list — hanya tampil jika ada task */}
{!loading && tasks.length === 0 ? (
<p className="text-md text-font-shade mt-5">Belum ada tugas yang dikerjakan.</p>
) : (
!loading && (
<div className="flex flex-col gap-7 md:flex-row md:items-start mt-5">
{/* Kolom kiri: Timeline */}
<div className="w-full md:w-1/5 bg-white rounded-xl p-5 border border-box-border shrink-0"> <div className="w-full md:w-1/5 bg-white rounded-xl p-5 border border-box-border shrink-0">
<ProjectTimeline tasks={tasks} /> <ProjectTimeline tasks={tasks} />
</div> </div>
{/* Kolom kanan: Task cards */}
<div className="w-full border-t border-box-border md:border-t-0 pt-4 md:pt-0 md:w-4/5"> <div className="w-full border-t border-box-border md:border-t-0 pt-4 md:pt-0 md:w-4/5">
{tasks.length === 0 ? ( {tasks.map((task) => (
<p className="text-sm text-gray-400">Belum ada task.</p>
) : (
tasks.map((task) => (
<MentorTaskCard key={task.id} task={task} /> <MentorTaskCard key={task.id} task={task} />
)) ))}
</div>
</div>
)
)} )}
</div> </div>
</div>
</div>
); );
} }
@@ -10,6 +10,11 @@ export default function InternsTab({ project }: { project: ProjectDetail }) {
const [members, setMembers] = useState<ProjectMember[]>(project.members); const [members, setMembers] = useState<ProjectMember[]>(project.members);
const { remove, loading } = useRemoveMember(); const { remove, loading } = useRemoveMember();
const handleModalClose = (finalMembers: ProjectMember[]) => {
setMembers(finalMembers);
setIsModalOpen(false);
};
const handleRemove = async (id_user: number) => { const handleRemove = async (id_user: number) => {
const res = await remove({ id_project: project.id, id_user }); const res = await remove({ id_project: project.id, id_user });
if (res !== null) { if (res !== null) {
@@ -88,10 +93,9 @@ export default function InternsTab({ project }: { project: ProjectDetail }) {
</div> </div>
{isModalOpen && <ManageInternsModal {isModalOpen && <ManageInternsModal
isOpen={isModalOpen} isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)} onClose={handleModalClose}
projectId={project.id} projectId={project.id}
initialMembers={members} initialMembers={members}
onMembersChange={(updated) => setMembers(updated)}
/>} />}
</div> </div>
); );
@@ -1,6 +1,6 @@
// ManageInternsModal.tsx // ManageInternsModal.tsx
import { useRef, useEffect, useState } from "react"; import { useRef, useEffect, useState } from "react";
import { Search, Plus, Trash2 } from "lucide-react"; import { Search, Plus } from "lucide-react";
import { useAssignableInterns } from "../../../../hooks/useProjects"; import { useAssignableInterns } from "../../../../hooks/useProjects";
import { useAssignMember } from '../../../../hooks/useProjects'; import { useAssignMember } from '../../../../hooks/useProjects';
import type { ProjectMember } from '../../../../types/project.types'; import type { ProjectMember } from '../../../../types/project.types';
@@ -9,24 +9,24 @@ import { customToast } from "../../../utils/showToast";
interface ManageInternsModalProps { interface ManageInternsModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: (finalMembers: ProjectMember[]) => void;
projectId: number; projectId: number;
initialMembers: ProjectMember[]; initialMembers: ProjectMember[];
onMembersChange?: (updated: ProjectMember[]) => void;
} }
export default function ManageInternsModal({ isOpen, onClose, projectId, initialMembers, onMembersChange }: ManageInternsModalProps) { export default function ManageInternsModal({ isOpen, onClose, projectId, initialMembers }: ManageInternsModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null); const dialogRef = useRef<HTMLDialogElement>(null);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [invitedList, setInvitedList] = useState<ProjectMember[]>(initialMembers); const [invitedList] = useState<ProjectMember[]>(initialMembers);
const [addedIds, setAddedIds] = useState<Set<number>>(new Set());
const { interns, loading: loadingAssignableInterns } = useAssignableInterns(); const { interns, loading: loadingAssignableInterns } = useAssignableInterns();
const { assign, loading: assigning } = useAssignMember(); const { assign, loading: assigning } = useAssignMember();
// Sync saat modal dibuka // Reset addedIds saat modal dibuka ulang
useEffect(() => { useEffect(() => {
if (isOpen) setInvitedList(initialMembers); if (isOpen) setAddedIds(new Set());
}, [isOpen, initialMembers]); }, [isOpen]);
useEffect(() => { useEffect(() => {
const dialog = dialogRef.current; const dialog = dialogRef.current;
@@ -38,38 +38,45 @@ export default function ManageInternsModal({ isOpen, onClose, projectId, initial
dialog.close(); dialog.close();
document.body.style.overflow = "unset"; document.body.style.overflow = "unset";
} }
const handleCancel = (e: Event) => { e.preventDefault(); onClose(); }; const handleCancel = (e: Event) => {
e.preventDefault();
onClose(finalMembers);
};
dialog.addEventListener("cancel", handleCancel); dialog.addEventListener("cancel", handleCancel);
return () => { return () => {
dialog.removeEventListener("cancel", handleCancel); dialog.removeEventListener("cancel", handleCancel);
document.body.style.overflow = "unset"; document.body.style.overflow = "unset";
}; };
}, [isOpen, onClose]); }, [isOpen, onClose, addedIds]);
// Filter assignable interns yang belum jadi member & sesuai search // Semua ID yang sudah jadi member atau baru ditambahkan — tidak tampil di search
const invitedIds = new Set(invitedList.map(m => m.id)); const excludedIds = new Set([...invitedList.map(m => m.id), ...addedIds]);
const searchResult = interns.filter(m => const searchResult = interns.filter(m =>
!invitedIds.has(m.id) && !excludedIds.has(m.id) &&
(m.email.toLowerCase().includes(searchQuery.toLowerCase()) || (m.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
`${m.full_name}`.toLowerCase().includes(searchQuery.toLowerCase())) `${m.full_name}`.toLowerCase().includes(searchQuery.toLowerCase()))
); );
// Gabungan member lama + yang baru ditambah — dikirim ke parent saat close
const newlyAddedMembers = interns
.filter(i => addedIds.has(i.id))
.map(i => ({
id: i.id,
id_user: i.id,
full_name: i.full_name,
email: i.email,
avatar: "",
kelompok_peminatan: i.kelompok_peminatan,
professional_bio: "",
position: i.position,
} as ProjectMember));
const finalMembers = [...invitedList, ...newlyAddedMembers];
const handleAssign = async (intern: AssignableIntern) => { const handleAssign = async (intern: AssignableIntern) => {
const result = await assign({ id_project: projectId, id_user: intern.id }); const result = await assign({ id_project: projectId, id_user: intern.id });
if (result) { if (result) {
const newMember: ProjectMember = { // Tandai sebagai sudah ditambah — intern hilang dari dialog (tidak muncul lagi)
id: intern.id, setAddedIds(prev => new Set([...prev, intern.id]));
id_user: intern.id,
full_name: intern.full_name,
email: intern.email,
avatar: "",
kelompok_peminatan: intern.kelompok_peminatan,
professional_bio: "",
position: intern.position,
} as ProjectMember;
const updatedList = [...invitedList, newMember];
setInvitedList(updatedList);
onMembersChange?.(updatedList);
customToast.success('Intern added', `${intern.full_name} has been successfully added to the project.`); customToast.success('Intern added', `${intern.full_name} has been successfully added to the project.`);
} else { } else {
customToast.error('Failed to add', 'An error occurred while adding the intern. Please try again.'); customToast.error('Failed to add', 'An error occurred while adding the intern. Please try again.');
@@ -80,7 +87,13 @@ export default function ManageInternsModal({ isOpen, onClose, projectId, initial
m.full_name.split(' ').slice(0, 2).map(n => n[0]).join('').toUpperCase(); m.full_name.split(' ').slice(0, 2).map(n => n[0]).join('').toUpperCase();
return ( return (
<dialog ref={dialogRef} className="custom-dialog"> <dialog
ref={dialogRef}
className="custom-dialog"
onClick={(e) => {
if (e.target === dialogRef.current) onClose(finalMembers);
}}
>
<div className="px-3 py-5"> <div className="px-3 py-5">
<div className="relative flex items-center mb-4"> <div className="relative flex items-center mb-4">
<input <input
@@ -95,7 +108,7 @@ export default function ManageInternsModal({ isOpen, onClose, projectId, initial
<div className="overflow-y-auto space-y-3 pr-1 flex-1 h-[170px]"> <div className="overflow-y-auto space-y-3 pr-1 flex-1 h-[170px]">
{/* Sudah jadi member */} {/* Member yang sudah ada — read-only, hapus dari tab Members */}
{invitedList.map((member) => ( {invitedList.map((member) => (
<div key={member.id} className="flex items-center justify-between p-2 rounded-xl hover:bg-gray-50 transition-colors"> <div key={member.id} className="flex items-center justify-between p-2 rounded-xl hover:bg-gray-50 transition-colors">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -107,12 +120,9 @@ export default function ManageInternsModal({ isOpen, onClose, projectId, initial
<span className="text-xs text-gray-400">{member.email}</span> <span className="text-xs text-gray-400">{member.email}</span>
</div> </div>
</div> </div>
<button <span className="text-[10px] font-bold text-gray-400 bg-gray-100 px-2 py-1 rounded-full uppercase tracking-wide">
onClick={() => setInvitedList(prev => prev.filter(i => i.id !== member.id))} Member
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" </span>
>
<Trash2 className="w-4 h-4" />
</button>
</div> </div>
))} ))}
@@ -18,6 +18,7 @@ import { generateCertificate, resolveImageUrl } from "../../../utils/Sertificate
import { useGenerateCertificates } from "../../../../hooks/useCertificates"; import { useGenerateCertificates } from "../../../../hooks/useCertificates";
import Avatar from "../../Avatar"; import Avatar from "../../Avatar";
import ProgressBar from "../../ProgressBar"; import ProgressBar from "../../ProgressBar";
import { customToast } from "../../../utils/showToast";
interface EligibleInternTableProps { interface EligibleInternTableProps {
interns: InternSubmissionProgress[]; interns: InternSubmissionProgress[];
@@ -79,51 +80,55 @@ export default function EligibleInternTable({
const handleGenerate = async () => { const handleGenerate = async () => {
if (!project?.certificate_template) { if (!project?.certificate_template) {
setGenerateError("Template sertifikat belum diupload."); customToast.error("Template belum ada", "Template sertifikat belum diupload.");
return; return;
} }
if (selected.size === 0) { if (selected.size === 0) {
setGenerateError("Pilih minimal satu intern."); customToast.error("Tidak ada intern dipilih", "Pilih minimal satu intern untuk generate sertifikat.");
return; return;
} }
setGenerating(true); setGenerating(true);
setGenerateError(null); setGenerateError(null);
try { const ok = await customToast.promise(
(async () => {
const result = await SubmitCertificates(projectId, Array.from(selected)); const result = await SubmitCertificates(projectId, Array.from(selected));
if (!result) return; if (!result) throw new Error("Tidak ada data sertifikat yang dikembalikan.");
const zip = new JSZip(); const zip = new JSZip();
const templateUrl = resolveImageUrl(project.certificate_template); const templateUrl = resolveImageUrl(project.certificate_template!);
await Promise.all( await Promise.all(
result.map(async (cert) => { result.map(async (cert) => {
const verifyUrl = `${window.location.origin}/verify-certificate/${cert.uuid}`; const verifyUrl = `${window.location.origin}/verify-certificate/${cert.uuid}`;
const blob = await generateCertificate( const blob = await generateCertificate(
templateUrl, templateUrl,
cert.user.full_name, cert.user.full_name,
cert.project.project_name, cert.project.project_name,
cert.certificate_no, cert.certificate_no,
verifyUrl, verifyUrl,
"20 Juni 2024 - 20 September 2024" // Placeholder untuk durasi proyek "20 Juni 2024 - 20 September 2024"
); );
zip.file(`Sertifikat - ${cert.user.full_name}.png`, blob); zip.file(`Sertifikat - ${cert.user.full_name}.png`, blob);
}) })
); );
})(),
// 3. Navigasi ke result page {
navigate(`/mentor/certificates/${projectId}/result`); loading: "Generating sertifikat...",
} catch (err) { success: () => ({
console.error(err); title: "Sertifikat berhasil dibuat",
setGenerateError( description: `${selected.size} sertifikat berhasil digenerate.`,
err instanceof Error ? err.message : "Gagal generate sertifikat." }),
); error: (err) => ({
} finally { title: "Gagal generate sertifikat",
setGenerating(false); description: err instanceof Error ? err.message : "Terjadi kesalahan, coba lagi.",
}),
} }
).then(() => true).catch(() => false);
setGenerating(false);
if (ok) navigate(`/mentor/certificates/${projectId}/result`);
}; };
if (loading) { if (loading) {
@@ -0,0 +1,180 @@
import { useState } from "react";
import { Plus, Trash2, ChevronLeft, ChevronRight, SortAsc, SortDesc, ArrowUpDown } from "lucide-react";
import type { InternDetail } from "../../../../types/project.types";
const PAGE_SIZE = 5;
interface InternsTableProps {
interns: InternDetail[];
totalInterns: number;
removing: boolean;
sortOrder: "asc" | "desc" | null;
onSort: () => void;
onAssign: (userId: number) => void;
onDelete: (intern: InternDetail) => void;
}
export default function InternsTable({
interns,
totalInterns,
removing,
sortOrder,
onSort,
onAssign,
onDelete,
}: InternsTableProps) {
const [currentPage, setCurrentPage] = useState(1);
const totalPages = Math.max(1, Math.ceil(interns.length / PAGE_SIZE));
// Clamp page bila filter mengubah jumlah hasil
const safePage = Math.min(currentPage, totalPages);
const startIndex = (safePage - 1) * PAGE_SIZE;
const paginatedInterns = interns.slice(startIndex, startIndex + PAGE_SIZE);
const handlePrev = () => setCurrentPage((p) => Math.max(1, p - 1));
const handleNext = () => setCurrentPage((p) => Math.min(totalPages, p + 1));
// Reset ke halaman 1 ketika filter berubah (melalui props interns)
// Gunakan useEffect agar reset terjadi saat props berubah
// eslint-disable-next-line react-hooks/exhaustive-deps
return (
<div className="w-full mt-3 bg-white rounded-xl border border-gray-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)] overflow-hidden">
{/* Red Table Header Block */}
<div className="bg-[#B30000] text-white px-6 py-3.5 flex items-center text-xs font-bold uppercase tracking-wider">
{/* Intern Details header with sort toggle */}
<div className="w-1/2">
<button
onClick={onSort}
title={sortOrder === "asc" ? "Sort Z–A" : sortOrder === "desc" ? "Clear sort" : "Sort A–Z"}
className="inline-flex items-center gap-1.5 hover:opacity-80 transition-opacity focus:outline-none"
>
Intern Details
{sortOrder === "asc" ? (
<SortAsc className="w-3.5 h-3.5 opacity-90" />
) : sortOrder === "desc" ? (
<SortDesc className="w-3.5 h-3.5 opacity-90" />
) : (
<ArrowUpDown className="w-3.5 h-3.5 opacity-50" />
)}
</button>
</div>
<div className="w-1/3">Current Project</div>
<div className="w-1/6 text-right pr-4">Actions</div>
</div>
{/* Table Body Iteration Rows */}
<div className="divide-y divide-gray-100">
{paginatedInterns.length > 0 ? (
paginatedInterns.map((intern, index) => {
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"
>
{/* Column 1: Intern Details (Avatar + Name & Email) */}
<div className="w-1/2 flex items-center gap-3">
{intern.avatar ? (
<img
src={intern.avatar}
alt={intern.name}
className="w-10 h-10 rounded-full object-cover border border-gray-100 shadow-sm"
/>
) : (
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-teal-400 to-emerald-500 flex items-center justify-center border border-gray-100 shadow-sm text-white text-xs font-bold">
{intern.name.charAt(0)}
</div>
)}
<div>
<h4 className="text-sm font-bold text-gray-900 leading-tight">{intern.name}</h4>
<p className="text-xs text-gray-400 font-medium mt-0.5">{intern.email}</p>
</div>
</div>
{/* 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 || "Unassigned"}
</h5>
<p className={`text-xs font-bold mt-0.5 ${isUnassigned ? "text-red-500" : "text-red-600"}`}>
{intern.role}
</p>
</div>
{/* 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 untuk intern ini */}
<button
onClick={() => onAssign(intern.id)}
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={() => onDelete(intern)}
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>
</div>
</div>
);
})
) : (
<div className="text-center py-12 text-sm text-gray-400 font-medium">
No interns found matching the filter criteria.
</div>
)}
</div>
{/* 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">
{interns.length === 0 ? 0 : startIndex + 1}–{Math.min(startIndex + PAGE_SIZE, interns.length)}
</span>{" "}
of <span className="font-bold">{totalInterns}</span> interns
{interns.length !== totalInterns && (
<span className="ml-1 opacity-75">({interns.length} filtered)</span>
)}
</div>
<div className="flex items-center gap-2 self-end sm:self-auto">
{/* Previous Button */}
<button
onClick={handlePrev}
disabled={safePage <= 1}
className="flex items-center gap-1 px-3 py-1.5 bg-black/20 hover:bg-black/30 rounded-md font-semibold transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-3 h-3" />
Previous
</button>
{/* Page indicator */}
<span className="px-2 py-1.5 bg-white/10 rounded-md font-bold min-w-[56px] text-center">
{safePage} / {totalPages}
</span>
{/* Next Button */}
<button
onClick={handleNext}
disabled={safePage >= totalPages}
className="flex items-center gap-1 px-3 py-1.5 bg-white text-gray-900 hover:bg-gray-100 rounded-md font-bold shadow-sm transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Next
<ChevronRight className="w-3 h-3" />
</button>
</div>
</div>
</div>
);
}
@@ -1,12 +1,12 @@
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { Home, FolderKanban, Award, Settings, HelpCircle, LogOut, X } from 'lucide-react'; import { Home, FolderKanban, UsersRound, Medal, Settings, HelpCircle, LogOut, X } from 'lucide-react';
const navItems = [ const navItems = [
// belum kutambahin // belum kutambahin
{ label: "Home", icon: Home, path: "/mentor" }, { label: "Home", icon: Home, path: "/mentor" },
{ label: "List Projects", icon: FolderKanban, path: "/mentor/projects" }, { label: "List Projects", icon: FolderKanban, path: "/mentor/projects" },
{ label: "Interns", icon: FolderKanban, path: "/mentor/intern" }, { label: "Interns", icon: UsersRound, path: "/mentor/intern" },
{ label: "Certificates", icon: Award, path: "/mentor/certificates" }, { label: "Certificates", icon: Medal, path: "/mentor/certificates" },
]; ];
const prefItems = [ const prefItems = [
@@ -99,9 +99,7 @@ export default function SidebarMentor({ isOpen, closeSidebar }: SidebarProps) {
onClick={() => isLogout ? handleLogout() : handleNav(path)} onClick={() => isLogout ? handleLogout() : handleNav(path)}
className={`flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-[13.5px] font-medium text-left transition-colors className={`flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-[13.5px] font-medium text-left transition-colors
${isActive ${isActive
? "bg-red-50 text-red-600 font-semibold border-l-6 border-red " ? "bg-red-foreground text-red font-semibold border-l-4 border-base "
: danger
? "text-font hover:bg-base-foreground"
: "text-font hover:bg-red-foreground" : "text-font hover:bg-red-foreground"
}`} }`}
> >
@@ -59,22 +59,35 @@ export function StatusTable({ status, submission }: StatusTableProps) {
{ {
label: "Submitted at", label: "Submitted at",
value: submission?.submitted_at value: submission?.submitted_at
? <span className="text-sm text-gray-700">{new Date(submission.submitted_at).toLocaleString("id-ID")}</span> ? <span className="text-sm text-gray-700">
{new Date(submission.submitted_at).toLocaleString("id-ID", {
day: "numeric",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
: <span className="text-sm text-gray-400">-</span>, : <span className="text-sm text-gray-400">-</span>,
}, },
{ {
label: "File submission", label: "File submission",
value: hasSubmission && submission?.file_path value: hasSubmission && submission?.files && submission.files.length > 0
? ( ? (
<div className="flex flex-col gap-1.5">
{submission.files.map((f) => (
<a <a
href={resolveImageUrl(submission.file_path)} key={f.id}
href={resolveImageUrl(f.file_path)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-blue-600 hover:underline" className="flex items-center gap-2 text-sm text-blue-600 hover:underline"
> >
<FileText className="w-4 h-4 stroke-[2.5]" /> <FileText className="w-4 h-4 stroke-[2.5]" />
<span>{submission.file_path.split("/").pop()}</span> <span>{f.original_name}</span>
</a> </a>
))}
</div>
) )
: hasSubmission && submission?.url_link : hasSubmission && submission?.url_link
? ( ? (
+1
View File
@@ -16,4 +16,5 @@ export interface UINotification {
isNew: boolean; isNew: boolean;
group: "TODAY" | "YESTERDAY"; group: "TODAY" | "YESTERDAY";
description: string; description: string;
link: string;
} }