From 91d7bcf3f70747d9978323f08065aa4c9a6689c2 Mon Sep 17 00:00:00 2001 From: "Kemas M. Aryadary Rasyad" <112590882+aryadary0706@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:47:33 +0700 Subject: [PATCH] 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) --- src/App.tsx | 1 - src/Layout/Navbar.tsx | 2 +- src/Pages/internify/NotificationList.tsx | 11 +- src/Pages/internify/intern/ForumTab.tsx | 45 +++-- src/Pages/internify/intern/InternCertList.tsx | 15 +- .../intern/components/CertificateAvailabe.tsx | 60 +++--- .../intern/components/ProjectTimeline.tsx | 4 +- .../intern/components/SidebarIntern.tsx | 6 +- .../internify/mentor/CertificateForm.tsx | 29 ++- src/Pages/internify/mentor/InternsPage.tsx | 177 ++++++----------- .../internify/mentor/ProjectForumTab.tsx | 52 +++-- .../internify/mentor/ProjectsInternTab.tsx | 8 +- .../mentor/components/AddInternsDialog.tsx | 84 ++++---- .../mentor/components/EligibleInternTable.tsx | 69 +++---- .../mentor/components/InternsTable.tsx | 180 ++++++++++++++++++ .../mentor/components/SidebarMentor.tsx | 12 +- .../components/StatusSubmissionTable.tsx | 35 ++-- src/types/notification.types.ts | 1 + 18 files changed, 501 insertions(+), 290 deletions(-) create mode 100644 src/Pages/internify/mentor/components/InternsTable.tsx diff --git a/src/App.tsx b/src/App.tsx index d11a380..3e9ded9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -42,7 +42,6 @@ import InternifyLogin from "./Pages/Internify/LoginInternify" import FAQPage from "./Pages/Internify/FAQpage"; import NotificationList from "./Pages/Internify/NotificationList"; import SettingsContent from "./Pages/Internify/SettingsPage"; -import MentorSettingsPage from "./Pages/Internify/mentor/MentorSettingsPage"; import TaskSubmission from "./Pages/Internify/intern/TaskSubmission"; import SertificateList from "./Pages/Internify/intern/InternCertList"; import MentorProjectsDetailPage from "./Pages/Internify/mentor/MentorProjectDetailPage"; diff --git a/src/Layout/Navbar.tsx b/src/Layout/Navbar.tsx index 4d61bea..f9533ed 100644 --- a/src/Layout/Navbar.tsx +++ b/src/Layout/Navbar.tsx @@ -82,7 +82,7 @@ const Navbar = () => { }} className="block w-full text-left hover:text-red-600 transition" > - Login + Internify diff --git a/src/Pages/internify/NotificationList.tsx b/src/Pages/internify/NotificationList.tsx index 1b97d5c..b886540 100644 --- a/src/Pages/internify/NotificationList.tsx +++ b/src/Pages/internify/NotificationList.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; import { Check, Download, Info } from "lucide-react"; import { LuFileSpreadsheet } from "react-icons/lu"; import { io } from "socket.io-client"; @@ -14,7 +15,7 @@ const getCookieToken = () => { export default function NotificationList() { const [notifications, setNotifications] = useState([]); - + const navigate = useNavigate(); useEffect(() => { const loadNotifications = async () => { try { @@ -131,7 +132,9 @@ export default function NotificationList() { {/* Conditional Actions */} {item.type === 'new-task' && (
- diff --git a/src/Pages/internify/intern/ForumTab.tsx b/src/Pages/internify/intern/ForumTab.tsx index 235fdbf..c0671ca 100644 --- a/src/Pages/internify/intern/ForumTab.tsx +++ b/src/Pages/internify/intern/ForumTab.tsx @@ -6,37 +6,44 @@ import type { Project } from '../../../types/project.types'; export default function ForumTab({ project }: { project: Project }) { const { tasks, loading, error } = useProjectMyTasks(project.id); + return (
- {/* Loading */} - {loading && ( + {/* Loading skeleton */} + {/* {loading && (
{[...Array(3)].map((_, i) => (
))}
- )} + )} */} {/* Error */} {error &&

{error}

} - {/* Timeline + Task list */} -
-
- -
-
- {tasks.length === 0 ? ( -

Belum ada task.

- ) : ( - tasks.map((task) => ( - - )) - )} -
-
+ {/* Timeline + Task list — hanya tampil jika ada task */} + {/* {!loading && tasks.length === 0 ? ( */} + {tasks.length === 0 ? ( +

Belum ada tugas yang dapat dikerjakan.

+ ) : ( + !loading && ( +
+ {/* Kolom kiri: Timeline */} +
+ +
+ + {/* Kolom kanan: Task cards */} +
+ {tasks.map((task) => ( + + ))} +
+
+ ) + )}
); -} \ No newline at end of file +} diff --git a/src/Pages/internify/intern/InternCertList.tsx b/src/Pages/internify/intern/InternCertList.tsx index 46c3b68..9272dc9 100644 --- a/src/Pages/internify/intern/InternCertList.tsx +++ b/src/Pages/internify/intern/InternCertList.tsx @@ -58,10 +58,13 @@ export default function SertificateList() { {/* Thumbnail */}
-
+
{(() => { const Icon = getDynamicIcon(item.project_icon); - return ; + return ; })()}
@@ -69,7 +72,7 @@ export default function SertificateList() { {/* Info */}
-

+

{new Date(item.start_date).toLocaleDateString("id-ID", { year: "numeric", month: "long", @@ -78,11 +81,11 @@ export default function SertificateList() { month: "long", })}

-

+

{item.project_name}

-

- No: {item.status} +

+ Status Projek: {item.status}

diff --git a/src/Pages/internify/intern/components/CertificateAvailabe.tsx b/src/Pages/internify/intern/components/CertificateAvailabe.tsx index 7fb83fa..2675a91 100644 --- a/src/Pages/internify/intern/components/CertificateAvailabe.tsx +++ b/src/Pages/internify/intern/components/CertificateAvailabe.tsx @@ -5,6 +5,7 @@ import type { ProjectDetail } from "../../../../types/project.types"; import type { Certificate } from "../../../../types/certificate.types"; import { downloadCertificatePdf, downloadCertificateImage } from "../../../utils/Certificates"; import { generateCertificate, resolveImageUrl } from "../../../utils/SertificateGenerator"; +import { customToast } from "../../../utils/showToast"; interface CertificateAvailableProps { project: ProjectDetail | null; @@ -19,29 +20,6 @@ export default function CertificateAvailable({ project, certificate, templateUrl const [previewUrl, setPreviewUrl] = useState(null); 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 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 () => { setDownloadingImg(true); try { await downloadCertificateImage(certificate, templateUrl, "png"); + customToast.success("Sertifikat berhasil diunduh!"); + } catch { + customToast.error("Gagal mengunduh sertifikat!"); } finally { setDownloadingImg(false); } @@ -69,6 +74,9 @@ export default function CertificateAvailable({ project, certificate, templateUrl setDownloadingPdf(true); try { await downloadCertificatePdf(certificate, templateUrl); + customToast.success("Sertifikat berhasil diunduh!"); + } catch { + customToast.error("Gagal mengunduh sertifikat!"); } finally { setDownloadingPdf(false); } @@ -147,9 +155,13 @@ export default function CertificateAvailable({ project, certificate, templateUrl
diff --git a/src/Pages/internify/intern/components/ProjectTimeline.tsx b/src/Pages/internify/intern/components/ProjectTimeline.tsx index 8b2314c..3340e7b 100644 --- a/src/Pages/internify/intern/components/ProjectTimeline.tsx +++ b/src/Pages/internify/intern/components/ProjectTimeline.tsx @@ -1,9 +1,9 @@ -import type { InternTaskItem } from "../../../../types/project.types"; +import type { ProjectTask } from "../../../../types/task.types"; const formatDate = (dateStr: string) => 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(); today.setHours(0, 0, 0, 0); return ( diff --git a/src/Pages/internify/intern/components/SidebarIntern.tsx b/src/Pages/internify/intern/components/SidebarIntern.tsx index 94959e2..278aeba 100644 --- a/src/Pages/internify/intern/components/SidebarIntern.tsx +++ b/src/Pages/internify/intern/components/SidebarIntern.tsx @@ -98,10 +98,8 @@ export default function SidebarIntern({ isOpen, closeSidebar }: SidebarProps) { 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 ${isActive - ? "bg-red-50 text-red-600 font-semibold" - : danger - ? "text-font hover:bg-base-foreground" - : "text-font hover:bg-red-foreground" + ? "bg-red-foreground text-red font-semibold border-l-4 border-base" + : "text-font hover:bg-red-foreground" }`} > diff --git a/src/Pages/internify/mentor/CertificateForm.tsx b/src/Pages/internify/mentor/CertificateForm.tsx index fde850d..701c3f5 100644 --- a/src/Pages/internify/mentor/CertificateForm.tsx +++ b/src/Pages/internify/mentor/CertificateForm.tsx @@ -6,6 +6,7 @@ import { useProjectDetail } from "../../../hooks/useProjects"; import EligibleInternTable from "./components/EligibleInternTable"; import { useProjectInternProgress } from "../../../hooks/useInternProgress"; import { resolveImageUrl } from "../../utils/SertificateGenerator"; +import { customToast } from "../../utils/showToast"; export default function CertificateDetail() { const navigate = useNavigate(); @@ -25,20 +26,32 @@ export default function CertificateDetail() { if (!file) return; setSelectedFile(file); setUploadSuccess(false); - - // Revoke object URL lama biar tidak memory leak if (previewUrl) URL.revokeObjectURL(previewUrl); setPreviewUrl(URL.createObjectURL(file)); }; const handleUpload = async () => { if (!selectedFile || !project) return; - const result = await upload(project.id, selectedFile); - if (result) { - setUploadSuccess(true); - setSelectedFile(null); - if (fileInputRef.current) fileInputRef.current.value = ""; - } + 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) { + setUploadSuccess(true); + setSelectedFile(null); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + }).catch(() => {}); }; const handleClearFile = () => { diff --git a/src/Pages/internify/mentor/InternsPage.tsx b/src/Pages/internify/mentor/InternsPage.tsx index 40084ac..0210e26 100644 --- a/src/Pages/internify/mentor/InternsPage.tsx +++ b/src/Pages/internify/mentor/InternsPage.tsx @@ -1,7 +1,8 @@ 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 AddInternsModal from "./components/AddInternsToProjects"; +import AddInternsModal from "./components/AddInternsToProjects"; +import InternsTable from "./components/InternsTable"; import type { InternDetail } from "../../../types/project.types"; import type { Project } from "../../../types/project.types"; import { customToast } from "../../utils/showToast"; @@ -11,32 +12,45 @@ export default function InternsManagement() { const [selectedProject, setSelectedProject] = useState("All Projects"); const [isModalOpen, setIsModalOpen] = useState(false); const [selectedUserId, setSelectedUserId] = useState(null); + const [sortOrder, setSortOrder] = useState<"asc" | "desc" | null>(null); const { interns, loading, error, refetch } = useAllInterns(); const { remove, loading: removing } = useRemoveMember(); const { projects: activeProjects } = useProjects("active"); - // Filter data berdasarkan email dan nama project const filteredInterns = useMemo(() => { - return interns.filter((intern) => { + const filtered = interns.filter((intern) => { const matchesEmail = intern.email.toLowerCase().includes(searchEmail.toLowerCase()); const matchesProject = selectedProject === "All Projects" || (intern.projectName || "Unassigned").toLowerCase() === selectedProject.toLowerCase(); 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 activeAssignments = activeProjects.length; - // Buka modal untuk assign intern tertentu (dari tombol + di baris) const openAssignFor = (userId: number | null) => { setSelectedUserId(userId); 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 matchedProject = activeProjects.find((project: Project) => project.project_name === intern.projectName); if (!matchedProject) { @@ -75,7 +89,7 @@ export default function InternsManagement() { {/* Filter Row & Stats Grid */}
{/* Left Control Inputs */} -
+
{/* Search Box Input */}
@@ -86,7 +100,7 @@ export default function InternsManagement() { placeholder="Filter by email" value={searchEmail} 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" />
@@ -95,26 +109,44 @@ export default function InternsManagement() { -
- - - +
+
- {/* Icon Sliders / Filter Button */} -
{/* Right Info Box Stats — data real dari API */} -
+
{totalInterns} @@ -123,7 +155,7 @@ export default function InternsManagement() { TOTAL INTERNS
-
+
{activeAssignments} @@ -145,101 +177,16 @@ export default function InternsManagement() {
- {/* Main Table Container Block */} -
- - {/* Red Table Header Block */} -
-
Intern Details
-
Current Project
-
Actions
-
- - {/* Table Body Iteration Rows */} -
- {filteredInterns.length > 0 ? ( - filteredInterns.map((intern, index) => { - const isUnassigned = !intern.projectName || intern.projectName === "Unassigned"; - - return ( -
- - {/* Column 1: Intern Details (Avatar + Name & Email) */} -
- {intern.avatar ? ( - {intern.name} - ) : ( -
- {intern.name.charAt(0)} -
- )} -
-

{intern.name}

-

{intern.email}

-
-
- - {/* Column 2: Current Project Title & Role Accent */} -
-
- {intern.projectName || "Unassigned"} -
-

- {intern.role} -

-
- - {/* Column 3: Action Buttons Block Right aligned */} -
- {/* Tombol Add: membuka modal assign member untuk intern ini */} - - {/* Tombol Delete: remove intern dari project */} - -
- -
- ); - }) - ) : ( -
- No interns found matching the filter criteria. -
- )} -
- - {/* Red Table Pagination Bar Footer */} -
-
- Showing {filteredInterns.length} of {totalInterns} interns -
- -
- - -
-
-
+ openAssignFor(userId)} + onDelete={handleDeleteIntern} + /> {isModalOpen && { setIsModalOpen(false); - refetch(); // Refresh data setelah modal assign ditutup + refetch(); }} /> } diff --git a/src/Pages/internify/mentor/ProjectForumTab.tsx b/src/Pages/internify/mentor/ProjectForumTab.tsx index 90ac57b..405736b 100644 --- a/src/Pages/internify/mentor/ProjectForumTab.tsx +++ b/src/Pages/internify/mentor/ProjectForumTab.tsx @@ -1,4 +1,3 @@ -import { useParams } from 'react-router-dom'; import ProjectTimeline from '../intern/components/ProjectTimeline'; import HeroProject from '../intern/components/Hero-project'; import MentorTaskCard from './components/MentorTaskCard'; @@ -6,28 +5,45 @@ import { useProjectTasks } from '../../../hooks/useTasks'; import type { Project } from '../../../types/project.types'; export default function MentorForumTab({ project }: { project: Project }) { - const { slug } = useParams<{ slug: string }>(); - const { tasks, loading, error } = useProjectTasks(slug ?? ""); + // Gunakan project.id dari props — route pakai :slug, bukan :id + const { tasks, loading, error } = useProjectTasks(project.id); + + if (error) return
Error: {error}
; + return (
{/* Hero banner */} - {/* Timeline + Task list */} -
-
- + {/* Loading skeleton */} + {loading && ( +
+ {[...Array(3)].map((_, i) => ( +
+ ))}
-
- {tasks.length === 0 ? ( -

Belum ada task.

- ) : ( - tasks.map((task) => ( - - )) - )} -
-
+ )} + + {/* Timeline + Task list — hanya tampil jika ada task */} + {!loading && tasks.length === 0 ? ( +

Belum ada tugas yang dikerjakan.

+ ) : ( + !loading && ( +
+ {/* Kolom kiri: Timeline */} +
+ +
+ + {/* Kolom kanan: Task cards */} +
+ {tasks.map((task) => ( + + ))} +
+
+ ) + )}
); -} \ No newline at end of file +} diff --git a/src/Pages/internify/mentor/ProjectsInternTab.tsx b/src/Pages/internify/mentor/ProjectsInternTab.tsx index a684847..a04563f 100644 --- a/src/Pages/internify/mentor/ProjectsInternTab.tsx +++ b/src/Pages/internify/mentor/ProjectsInternTab.tsx @@ -10,6 +10,11 @@ export default function InternsTab({ project }: { project: ProjectDetail }) { const [members, setMembers] = useState(project.members); const { remove, loading } = useRemoveMember(); + const handleModalClose = (finalMembers: ProjectMember[]) => { + setMembers(finalMembers); + setIsModalOpen(false); + }; + const handleRemove = async (id_user: number) => { const res = await remove({ id_project: project.id, id_user }); if (res !== null) { @@ -88,10 +93,9 @@ export default function InternsTab({ project }: { project: ProjectDetail }) {
{isModalOpen && setIsModalOpen(false)} + onClose={handleModalClose} projectId={project.id} initialMembers={members} - onMembersChange={(updated) => setMembers(updated)} />}
); diff --git a/src/Pages/internify/mentor/components/AddInternsDialog.tsx b/src/Pages/internify/mentor/components/AddInternsDialog.tsx index 13f4768..87407a8 100644 --- a/src/Pages/internify/mentor/components/AddInternsDialog.tsx +++ b/src/Pages/internify/mentor/components/AddInternsDialog.tsx @@ -1,6 +1,6 @@ // ManageInternsModal.tsx 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 { useAssignMember } from '../../../../hooks/useProjects'; import type { ProjectMember } from '../../../../types/project.types'; @@ -9,24 +9,24 @@ import { customToast } from "../../../utils/showToast"; interface ManageInternsModalProps { isOpen: boolean; - onClose: () => void; + onClose: (finalMembers: ProjectMember[]) => void; projectId: number; 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(null); const [searchQuery, setSearchQuery] = useState(""); - const [invitedList, setInvitedList] = useState(initialMembers); + const [invitedList] = useState(initialMembers); + const [addedIds, setAddedIds] = useState>(new Set()); const { interns, loading: loadingAssignableInterns } = useAssignableInterns(); const { assign, loading: assigning } = useAssignMember(); - // Sync saat modal dibuka + // Reset addedIds saat modal dibuka ulang useEffect(() => { - if (isOpen) setInvitedList(initialMembers); - }, [isOpen, initialMembers]); + if (isOpen) setAddedIds(new Set()); + }, [isOpen]); useEffect(() => { const dialog = dialogRef.current; @@ -38,38 +38,45 @@ export default function ManageInternsModal({ isOpen, onClose, projectId, initial dialog.close(); document.body.style.overflow = "unset"; } - const handleCancel = (e: Event) => { e.preventDefault(); onClose(); }; + const handleCancel = (e: Event) => { + e.preventDefault(); + onClose(finalMembers); + }; dialog.addEventListener("cancel", handleCancel); return () => { dialog.removeEventListener("cancel", handleCancel); document.body.style.overflow = "unset"; }; - }, [isOpen, onClose]); + }, [isOpen, onClose, addedIds]); - // Filter assignable interns yang belum jadi member & sesuai search - const invitedIds = new Set(invitedList.map(m => m.id)); + // Semua ID yang sudah jadi member atau baru ditambahkan — tidak tampil di search + const excludedIds = new Set([...invitedList.map(m => m.id), ...addedIds]); const searchResult = interns.filter(m => - !invitedIds.has(m.id) && + !excludedIds.has(m.id) && (m.email.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 result = await assign({ id_project: projectId, id_user: intern.id }); if (result) { - const newMember: ProjectMember = { - id: 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); + // Tandai sebagai sudah ditambah — intern hilang dari dialog (tidak muncul lagi) + setAddedIds(prev => new Set([...prev, intern.id])); customToast.success('Intern added', `${intern.full_name} has been successfully added to the project.`); } else { 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(); return ( - + { + if (e.target === dialogRef.current) onClose(finalMembers); + }} + >
- {/* Sudah jadi member */} + {/* Member yang sudah ada — read-only, hapus dari tab Members */} {invitedList.map((member) => (
-
- {getInitials(member as unknown as AssignableIntern)} -
+
+ {getInitials(member as unknown as AssignableIntern)} +
{member.full_name} {member.email}
- + + Member +
))} diff --git a/src/Pages/internify/mentor/components/EligibleInternTable.tsx b/src/Pages/internify/mentor/components/EligibleInternTable.tsx index b1bf1a7..b10b101 100644 --- a/src/Pages/internify/mentor/components/EligibleInternTable.tsx +++ b/src/Pages/internify/mentor/components/EligibleInternTable.tsx @@ -18,6 +18,7 @@ import { generateCertificate, resolveImageUrl } from "../../../utils/Sertificate import { useGenerateCertificates } from "../../../../hooks/useCertificates"; import Avatar from "../../Avatar"; import ProgressBar from "../../ProgressBar"; +import { customToast } from "../../../utils/showToast"; interface EligibleInternTableProps { interns: InternSubmissionProgress[]; @@ -79,51 +80,55 @@ export default function EligibleInternTable({ const handleGenerate = async () => { if (!project?.certificate_template) { - setGenerateError("Template sertifikat belum diupload."); + customToast.error("Template belum ada", "Template sertifikat belum diupload."); return; } if (selected.size === 0) { - setGenerateError("Pilih minimal satu intern."); + customToast.error("Tidak ada intern dipilih", "Pilih minimal satu intern untuk generate sertifikat."); return; } setGenerating(true); setGenerateError(null); - try { - const result = await SubmitCertificates(projectId, Array.from(selected)); - if (!result) return; + const ok = await customToast.promise( + (async () => { + const result = await SubmitCertificates(projectId, Array.from(selected)); + if (!result) throw new Error("Tidak ada data sertifikat yang dikembalikan."); - const zip = new JSZip(); - const templateUrl = resolveImageUrl(project.certificate_template); + const zip = new JSZip(); + const templateUrl = resolveImageUrl(project.certificate_template!); - await Promise.all( - result.map(async (cert) => { - const verifyUrl = `${window.location.origin}/verify-certificate/${cert.uuid}`; - - const blob = await generateCertificate( - templateUrl, - cert.user.full_name, - cert.project.project_name, - cert.certificate_no, - verifyUrl, - "20 Juni 2024 - 20 September 2024" // Placeholder untuk durasi proyek + await Promise.all( + result.map(async (cert) => { + const verifyUrl = `${window.location.origin}/verify-certificate/${cert.uuid}`; + const blob = await generateCertificate( + templateUrl, + cert.user.full_name, + cert.project.project_name, + cert.certificate_no, + verifyUrl, + "20 Juni 2024 - 20 September 2024" + ); + zip.file(`Sertifikat - ${cert.user.full_name}.png`, blob); + }) ); + })(), + { + loading: "Generating sertifikat...", + success: () => ({ + title: "Sertifikat berhasil dibuat", + description: `${selected.size} sertifikat berhasil digenerate.`, + }), + error: (err) => ({ + title: "Gagal generate sertifikat", + description: err instanceof Error ? err.message : "Terjadi kesalahan, coba lagi.", + }), + } + ).then(() => true).catch(() => false); - zip.file(`Sertifikat - ${cert.user.full_name}.png`, blob); - }) - ); - - // 3. Navigasi ke result page - navigate(`/mentor/certificates/${projectId}/result`); - } catch (err) { - console.error(err); - setGenerateError( - err instanceof Error ? err.message : "Gagal generate sertifikat." - ); - } finally { - setGenerating(false); - } + setGenerating(false); + if (ok) navigate(`/mentor/certificates/${projectId}/result`); }; if (loading) { diff --git a/src/Pages/internify/mentor/components/InternsTable.tsx b/src/Pages/internify/mentor/components/InternsTable.tsx new file mode 100644 index 0000000..7c375d4 --- /dev/null +++ b/src/Pages/internify/mentor/components/InternsTable.tsx @@ -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 ( +
+ + {/* Red Table Header Block */} +
+ {/* Intern Details header with sort toggle */} +
+ +
+
Current Project
+
Actions
+
+ + {/* Table Body Iteration Rows */} +
+ {paginatedInterns.length > 0 ? ( + paginatedInterns.map((intern, index) => { + const isUnassigned = !intern.projectName || intern.projectName === "Unassigned"; + + return ( +
+ {/* Column 1: Intern Details (Avatar + Name & Email) */} +
+ {intern.avatar ? ( + {intern.name} + ) : ( +
+ {intern.name.charAt(0)} +
+ )} +
+

{intern.name}

+

{intern.email}

+
+
+ + {/* Column 2: Current Project Title & Role Accent */} +
+
+ {intern.projectName || "Unassigned"} +
+

+ {intern.role} +

+
+ + {/* Column 3: Action Buttons Block Right aligned */} +
+ {/* Tombol Add: membuka modal assign member untuk intern ini */} + + {/* Tombol Delete: remove intern dari project */} + +
+
+ ); + }) + ) : ( +
+ No interns found matching the filter criteria. +
+ )} +
+ + {/* Red Table Pagination Bar Footer */} +
+
+ Showing{" "} + + {interns.length === 0 ? 0 : startIndex + 1}–{Math.min(startIndex + PAGE_SIZE, interns.length)} + {" "} + of {totalInterns} interns + {interns.length !== totalInterns && ( + ({interns.length} filtered) + )} +
+ +
+ {/* Previous Button */} + + + {/* Page indicator */} + + {safePage} / {totalPages} + + + {/* Next Button */} + +
+
+
+ ); +} diff --git a/src/Pages/internify/mentor/components/SidebarMentor.tsx b/src/Pages/internify/mentor/components/SidebarMentor.tsx index 4ecdbe0..07c1911 100644 --- a/src/Pages/internify/mentor/components/SidebarMentor.tsx +++ b/src/Pages/internify/mentor/components/SidebarMentor.tsx @@ -1,12 +1,12 @@ 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 = [ // belum kutambahin { label: "Home", icon: Home, path: "/mentor" }, { label: "List Projects", icon: FolderKanban, path: "/mentor/projects" }, - { label: "Interns", icon: FolderKanban, path: "/mentor/intern" }, - { label: "Certificates", icon: Award, path: "/mentor/certificates" }, + { label: "Interns", icon: UsersRound, path: "/mentor/intern" }, + { label: "Certificates", icon: Medal, path: "/mentor/certificates" }, ]; const prefItems = [ @@ -99,10 +99,8 @@ export default function SidebarMentor({ isOpen, closeSidebar }: SidebarProps) { 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 ${isActive - ? "bg-red-50 text-red-600 font-semibold border-l-6 border-red " - : danger - ? "text-font hover:bg-base-foreground" - : "text-font hover:bg-red-foreground" + ? "bg-red-foreground text-red font-semibold border-l-4 border-base " + : "text-font hover:bg-red-foreground" }`} > diff --git a/src/Pages/internify/mentor/components/StatusSubmissionTable.tsx b/src/Pages/internify/mentor/components/StatusSubmissionTable.tsx index 6a91e1f..c79965b 100644 --- a/src/Pages/internify/mentor/components/StatusSubmissionTable.tsx +++ b/src/Pages/internify/mentor/components/StatusSubmissionTable.tsx @@ -59,22 +59,35 @@ export function StatusTable({ status, submission }: StatusTableProps) { { label: "Submitted at", value: submission?.submitted_at - ? {new Date(submission.submitted_at).toLocaleString("id-ID")} + ? + {new Date(submission.submitted_at).toLocaleString("id-ID", { + day: "numeric", + month: "long", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + : -, }, { label: "File submission", - value: hasSubmission && submission?.file_path + value: hasSubmission && submission?.files && submission.files.length > 0 ? ( - - - {submission.file_path.split("/").pop()} - +
+ {submission.files.map((f) => ( + + + {f.original_name} + + ))} +
) : hasSubmission && submission?.url_link ? ( diff --git a/src/types/notification.types.ts b/src/types/notification.types.ts index e8118a4..6331ea7 100644 --- a/src/types/notification.types.ts +++ b/src/types/notification.types.ts @@ -16,4 +16,5 @@ export interface UINotification { isNew: boolean; group: "TODAY" | "YESTERDAY"; description: string; + link: string; }