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 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";
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 ? ( */}
|
||||||
<div className="w-full md:w-1/5 bg-white rounded-xl p-5 border border-box-border shrink-0">
|
{tasks.length === 0 ? (
|
||||||
<ProjectTimeline tasks={tasks} />
|
<p className="text-md text-font-shade mt-5">Belum ada tugas yang dapat dikerjakan.</p>
|
||||||
</div>
|
) : (
|
||||||
<div className="w-full border-t border-box-border md:border-t-0 pt-4 md:pt-0 md:w-4/5">
|
!loading && (
|
||||||
{tasks.length === 0 ? (
|
<div className="flex flex-col gap-7 md:flex-row md:items-start mt-5">
|
||||||
<p className="text-sm text-gray-400">Belum ada task.</p>
|
{/* Kolom kiri: Timeline */}
|
||||||
) : (
|
<div className="w-full md:w-1/5 bg-white rounded-xl p-5 border border-box-border shrink-0">
|
||||||
tasks.map((task) => (
|
<ProjectTimeline tasks={tasks} />
|
||||||
<TaskCard key={task.id} task={task} />
|
</div>
|
||||||
))
|
|
||||||
)}
|
{/* Kolom kanan: Task cards */}
|
||||||
</div>
|
<div className="w-full border-t border-box-border md:border-t-0 pt-4 md:pt-0 md:w-4/5">
|
||||||
</div>
|
{tasks.map((task) => (
|
||||||
|
<TaskCard key={task.id} task={task} />
|
||||||
|
))}
|
||||||
|
</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,9 +155,13 @@ 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
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,10 +98,8 @@ 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-red-foreground"
|
||||||
? "text-font hover:bg-base-foreground"
|
|
||||||
: "text-font hover:bg-red-foreground"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon className="w-[18px] h-[18px] flex-shrink-0" />
|
<Icon className="w-[18px] h-[18px] flex-shrink-0" />
|
||||||
|
|||||||
@@ -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(
|
||||||
if (result) {
|
upload(project.id, selectedFile),
|
||||||
setUploadSuccess(true);
|
{
|
||||||
setSelectedFile(null);
|
loading: "Mengupload template...",
|
||||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
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 = () => {
|
const handleClearFile = () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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="w-full md:w-1/5 bg-white rounded-xl p-5 border border-box-border shrink-0">
|
<div className="flex flex-col gap-3 mt-5">
|
||||||
<ProjectTimeline tasks={tasks} />
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<div key={i} className="h-24 bg-gray-100 rounded-xl animate-pulse" />
|
||||||
|
))}
|
||||||
</div>
|
</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>
|
{/* Timeline + Task list — hanya tampil jika ada task */}
|
||||||
) : (
|
{!loading && tasks.length === 0 ? (
|
||||||
tasks.map((task) => (
|
<p className="text-md text-font-shade mt-5">Belum ada tugas yang dikerjakan.</p>
|
||||||
<MentorTaskCard key={task.id} task={task} />
|
) : (
|
||||||
))
|
!loading && (
|
||||||
)}
|
<div className="flex flex-col gap-7 md:flex-row md:items-start mt-5">
|
||||||
</div>
|
{/* Kolom kiri: Timeline */}
|
||||||
</div>
|
<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>
|
</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,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]">
|
<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">
|
||||||
<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">
|
<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)}
|
{getInitials(member as unknown as AssignableIntern)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-sm font-medium text-gray-700">{member.full_name}</span>
|
<span className="text-sm font-medium text-gray-700">{member.full_name}</span>
|
||||||
<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(
|
||||||
const result = await SubmitCertificates(projectId, Array.from(selected));
|
(async () => {
|
||||||
if (!result) return;
|
const result = await SubmitCertificates(projectId, Array.from(selected));
|
||||||
|
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"
|
||||||
"20 Juni 2024 - 20 September 2024" // Placeholder untuk durasi proyek
|
);
|
||||||
|
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);
|
setGenerating(false);
|
||||||
})
|
if (ok) navigate(`/mentor/certificates/${projectId}/result`);
|
||||||
);
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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,10 +99,8 @@ 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-red-foreground"
|
||||||
? "text-font hover:bg-base-foreground"
|
|
||||||
: "text-font hover:bg-red-foreground"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon className="w-[18px] h-[18px] flex-shrink-0" />
|
<Icon className="w-[18px] h-[18px] flex-shrink-0" />
|
||||||
|
|||||||
@@ -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
|
||||||
? (
|
? (
|
||||||
<a
|
<div className="flex flex-col gap-1.5">
|
||||||
href={resolveImageUrl(submission.file_path)}
|
{submission.files.map((f) => (
|
||||||
target="_blank"
|
<a
|
||||||
rel="noopener noreferrer"
|
key={f.id}
|
||||||
className="flex items-center gap-2 text-sm text-blue-600 hover:underline"
|
href={resolveImageUrl(f.file_path)}
|
||||||
>
|
target="_blank"
|
||||||
<FileText className="w-4 h-4 stroke-[2.5]" />
|
rel="noopener noreferrer"
|
||||||
<span>{submission.file_path.split("/").pop()}</span>
|
className="flex items-center gap-2 text-sm text-blue-600 hover:underline"
|
||||||
</a>
|
>
|
||||||
|
<FileText className="w-4 h-4 stroke-[2.5]" />
|
||||||
|
<span>{f.original_name}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
: hasSubmission && submission?.url_link
|
: hasSubmission && submission?.url_link
|
||||||
? (
|
? (
|
||||||
|
|||||||
@@ -16,4 +16,5 @@ export interface UINotification {
|
|||||||
isNew: boolean;
|
isNew: boolean;
|
||||||
group: "TODAY" | "YESTERDAY";
|
group: "TODAY" | "YESTERDAY";
|
||||||
description: string;
|
description: string;
|
||||||
|
link: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user