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:
@@ -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";
|
||||
|
||||
@@ -82,7 +82,7 @@ const Navbar = () => {
|
||||
}}
|
||||
className="block w-full text-left hover:text-red-600 transition"
|
||||
>
|
||||
Login
|
||||
Internify
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<UINotification[]>([]);
|
||||
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => {
|
||||
const loadNotifications = async () => {
|
||||
try {
|
||||
@@ -131,7 +132,9 @@ export default function NotificationList() {
|
||||
{/* Conditional Actions */}
|
||||
{item.type === 'new-task' && (
|
||||
<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
|
||||
</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">
|
||||
@@ -141,7 +144,9 @@ export default function NotificationList() {
|
||||
)}
|
||||
|
||||
{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 Certificate
|
||||
</button>
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<HeroProject title={project.project_name} description={project.description} />
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
{/* Loading skeleton */}
|
||||
{/* {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>
|
||||
)}
|
||||
)} */}
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-red-500 text-sm mt-5">{error}</p>}
|
||||
|
||||
{/* Timeline + Task list */}
|
||||
<div className="flex flex-col gap-7 md:flex-row md:items-start">
|
||||
<div className="w-full md:w-1/5 bg-white rounded-xl p-5 border border-box-border shrink-0">
|
||||
<ProjectTimeline tasks={tasks} />
|
||||
</div>
|
||||
<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 ? (
|
||||
<p className="text-sm text-gray-400">Belum ada task.</p>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Timeline + Task list — hanya tampil jika ada task */}
|
||||
{/* {!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">
|
||||
<ProjectTimeline tasks={tasks} />
|
||||
</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">
|
||||
{tasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,10 +58,13 @@ export default function SertificateList() {
|
||||
{/* Thumbnail */}
|
||||
<div className="p-6 pb-0">
|
||||
<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);
|
||||
return <Icon className="w-10 h-10 text-primary" />;
|
||||
return <Icon className="w-10 h-10 text-primary text-white" />;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,7 +72,7 @@ export default function SertificateList() {
|
||||
|
||||
{/* Info */}
|
||||
<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", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
@@ -78,11 +81,11 @@ export default function SertificateList() {
|
||||
month: "long",
|
||||
})}
|
||||
</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}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
No: <span className="font-bold text-red-600">{item.status}</span>
|
||||
<p className="text-xs text-font mt-1">
|
||||
Status Projek: <span className="font-bold text-red-600">{item.status}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<string | null>(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
|
||||
<button
|
||||
// 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"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(`${window.location.origin}/verify-certificate/${certificate.uuid}`);
|
||||
customToast.success("Link berhasil disalin!");
|
||||
}}
|
||||
>
|
||||
<Link className="w-5 h-5" />
|
||||
Salin Link
|
||||
Salin Link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-[18px] h-[18px] flex-shrink-0" />
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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<number | null>(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 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 items-center mb-6">
|
||||
{/* 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 */}
|
||||
<div className="relative w-full sm:max-w-xs">
|
||||
<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"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -95,26 +109,44 @@ export default function InternsManagement() {
|
||||
<select
|
||||
value={selectedProject}
|
||||
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="Unassigned">Unassigned</option>
|
||||
{activeProjects.map((project) => (
|
||||
<option key={project.id} value={project.project_name}>
|
||||
{project.project_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-500">
|
||||
<svg className="fill-current h-4 w-4" viewBox="0 0 20 20">
|
||||
<path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z" />
|
||||
</svg>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-500"
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Icon Sliders / Filter Button */}
|
||||
<button className="p-2 bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200 transition-colors hidden sm:block">
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
{/* Sort A-Z / Z-A Toggle Button */}
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<span className="block text-xl font-bold text-red-600 leading-none">
|
||||
{totalInterns}
|
||||
@@ -123,7 +155,7 @@ export default function InternsManagement() {
|
||||
TOTAL INTERNS
|
||||
</span>
|
||||
</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>
|
||||
<span className="block text-xl font-bold text-green-600 leading-none">
|
||||
{activeAssignments}
|
||||
@@ -145,101 +177,16 @@ export default function InternsManagement() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main Table Container Block */}
|
||||
<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">
|
||||
<div className="w-1/2">Intern Details</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">
|
||||
{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>
|
||||
<InternsTable
|
||||
key={`${searchEmail}-${selectedProject}`}
|
||||
interns={filteredInterns}
|
||||
totalInterns={totalInterns}
|
||||
removing={removing}
|
||||
sortOrder={sortOrder}
|
||||
onSort={handleToggleSort}
|
||||
onAssign={(userId) => openAssignFor(userId)}
|
||||
onDelete={handleDeleteIntern}
|
||||
/>
|
||||
|
||||
{isModalOpen &&
|
||||
<AddInternsModal
|
||||
@@ -247,7 +194,7 @@ export default function InternsManagement() {
|
||||
userId={selectedUserId}
|
||||
onClose={() => {
|
||||
setIsModalOpen(false);
|
||||
refetch(); // Refresh data setelah modal assign ditutup
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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 <div>Error: {error}</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Hero banner */}
|
||||
<HeroProject title={project.project_name} description={project.description} />
|
||||
|
||||
{/* Timeline + Task list */}
|
||||
<div className="flex flex-col gap-7 md:flex-row md:items-start">
|
||||
<div className="w-full md:w-1/5 bg-white rounded-xl p-5 border border-box-border shrink-0">
|
||||
<ProjectTimeline tasks={tasks} />
|
||||
{/* Loading skeleton */}
|
||||
{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>
|
||||
<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 ? (
|
||||
<p className="text-sm text-gray-400">Belum ada task.</p>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<MentorTaskCard key={task.id} task={task} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</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">
|
||||
<ProjectTimeline tasks={tasks} />
|
||||
</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">
|
||||
{tasks.map((task) => (
|
||||
<MentorTaskCard key={task.id} task={task} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ export default function InternsTab({ project }: { project: ProjectDetail }) {
|
||||
const [members, setMembers] = useState<ProjectMember[]>(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 }) {
|
||||
</div>
|
||||
{isModalOpen && <ManageInternsModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onClose={handleModalClose}
|
||||
projectId={project.id}
|
||||
initialMembers={members}
|
||||
onMembersChange={(updated) => setMembers(updated)}
|
||||
/>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<HTMLDialogElement>(null);
|
||||
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 { 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 (
|
||||
<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="relative flex items-center mb-4">
|
||||
<input
|
||||
@@ -95,24 +108,21 @@ export default function ManageInternsModal({ isOpen, onClose, projectId, initial
|
||||
|
||||
|
||||
<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) => (
|
||||
<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="w-9 h-9 rounded-full bg-gray-200 border border-gray-300 flex items-center justify-center text-xs font-bold text-gray-500">
|
||||
{getInitials(member as unknown as AssignableIntern)}
|
||||
</div>
|
||||
<div className="w-9 h-9 rounded-full bg-gray-200 border border-gray-300 flex items-center justify-center text-xs font-bold text-gray-500">
|
||||
{getInitials(member as unknown as AssignableIntern)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-700">{member.full_name}</span>
|
||||
<span className="text-xs text-gray-400">{member.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setInvitedList(prev => prev.filter(i => i.id !== member.id))}
|
||||
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-[10px] font-bold text-gray-400 bg-gray-100 px-2 py-1 rounded-full uppercase tracking-wide">
|
||||
Member
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 { 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"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-[18px] h-[18px] flex-shrink-0" />
|
||||
|
||||
@@ -59,22 +59,35 @@ export function StatusTable({ status, submission }: StatusTableProps) {
|
||||
{
|
||||
label: "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>,
|
||||
},
|
||||
{
|
||||
label: "File submission",
|
||||
value: hasSubmission && submission?.file_path
|
||||
value: hasSubmission && submission?.files && submission.files.length > 0
|
||||
? (
|
||||
<a
|
||||
href={resolveImageUrl(submission.file_path)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
<FileText className="w-4 h-4 stroke-[2.5]" />
|
||||
<span>{submission.file_path.split("/").pop()}</span>
|
||||
</a>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{submission.files.map((f) => (
|
||||
<a
|
||||
key={f.id}
|
||||
href={resolveImageUrl(f.file_path)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
<FileText className="w-4 h-4 stroke-[2.5]" />
|
||||
<span>{f.original_name}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
: hasSubmission && submission?.url_link
|
||||
? (
|
||||
|
||||
@@ -16,4 +16,5 @@ export interface UINotification {
|
||||
isNew: boolean;
|
||||
group: "TODAY" | "YESTERDAY";
|
||||
description: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user