From 732684200fc51ff5cebcdce79eb9f2500d528d53 Mon Sep 17 00:00:00 2001 From: "Kemas M. Aryadary Rasyad" <112590882+aryadary0706@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:22:03 +0700 Subject: [PATCH] feat: add CertificateForm and CertificateResult components for managing intern certificates - Implemented CertificateForm for uploading certificate templates and displaying project details. - Created CertificateResult to show generated certificates with pagination and search functionality. - Added EligibleInternTable component to display eligible interns and their progress. - Updated CertificateListPage to reflect changes in project listing styles. - Enhanced MentorProjectCard with dynamic icon rendering. - Introduced useProjectInternProgress hook for fetching intern progress data. - Modified ProjectService to include task details in project responses. - Updated project types to accommodate new task structures. --- src/App.tsx | 10 +- src/Layout/Header-internify.tsx | 5 +- src/Pages/NotFound.tsx | 0 src/Pages/NotFoundPage.tsx | 16 +- ...SertificateList.tsx => InternCertList.tsx} | 26 +- ...ertificatePage.tsx => InternClaimCert.tsx} | 18 +- .../intern/components/HomeCalendar.tsx | 1 - .../internify/mentor/CertificateDetail.tsx | 331 ------------------ .../internify/mentor/CertificateForm.tsx | 188 ++++++++++ .../internify/mentor/CertificateListPage.tsx | 2 +- ...cateGenerate.tsx => CertificateResult.tsx} | 0 .../components/CertificateProjectCard.tsx | 27 +- .../mentor/components/EligibleInternTable.tsx | 266 ++++++++++++++ .../mentor/components/MentorProjectCard.tsx | 6 +- src/hooks/useInternProgress.ts | 81 +++++ src/services/ProjectService.ts | 3 +- src/types/project.types.ts | 3 +- 17 files changed, 612 insertions(+), 371 deletions(-) delete mode 100644 src/Pages/NotFound.tsx rename src/Pages/internify/intern/{SertificateList.tsx => InternCertList.tsx} (80%) rename src/Pages/internify/intern/{SertificatePage.tsx => InternClaimCert.tsx} (78%) delete mode 100644 src/Pages/internify/mentor/CertificateDetail.tsx create mode 100644 src/Pages/internify/mentor/CertificateForm.tsx rename src/Pages/internify/mentor/{CertificateGenerate.tsx => CertificateResult.tsx} (100%) create mode 100644 src/Pages/internify/mentor/components/EligibleInternTable.tsx create mode 100644 src/hooks/useInternProgress.ts diff --git a/src/App.tsx b/src/App.tsx index f07af2e..cd16b03 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,7 +31,7 @@ import EditLowongan from "./Pages/EditLowongan"; import HomeInternPage from "./Pages/Internify/intern/HomeInternPage"; import { InternLayout, MentorLayout } from "./Pages/Internify/layout"; import ProjectsPage from "./Pages/Internify/intern/ProjectsPage"; -import SertificatePage from "./Pages/Internify/intern/SertificatePage"; +import SertificatePage from "./Pages/Internify/intern/InternClaimCert"; import ProjectDetailPage from "./Pages/Internify/intern/ProjectDetailPage"; import HomeMentorPage from "./Pages/Internify/mentor/HomeMentorPage"; import MentorProjectsPage from "./Pages/Internify/mentor/BaseProjectsPage"; @@ -44,12 +44,12 @@ 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/SertificateList"; +import SertificateList from "./Pages/Internify/intern/InternCertList"; import MentorProjectsDetailPage from "./Pages/Internify/mentor/MentorProjectDetailPage"; import ViewInternSubmission from "./Pages/Internify/mentor/ViewInternSubmission"; import ProtectedRoutes from "./Pages/utils/ProtectedRoute"; -import CertificateDetail from "./Pages/Internify/mentor/CertificateDetail"; -import CertificateResult from "./Pages/Internify/mentor/CertificateGenerate"; +import CertificateDetail from "./Pages/Internify/mentor/CertificateForm"; +import CertificateResult from "./Pages/Internify/mentor/CertificateResult"; import NotFoundPage from "./Pages/NotFoundPage"; function App() { @@ -112,7 +112,7 @@ function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/Layout/Header-internify.tsx b/src/Layout/Header-internify.tsx index d491a3f..990cdb7 100644 --- a/src/Layout/Header-internify.tsx +++ b/src/Layout/Header-internify.tsx @@ -7,7 +7,8 @@ import { useCurrentUser } from "../hooks/useUser"; * "Andi Mahesa" → "AM" * "Andi Bayu Cendika" → "AC" (huruf pertama + huruf pertama kata terakhir) */ -function getInitials(fullName: string): string { +function getInitials(fullName: string | undefined | null): string { + if (!fullName) return "U"; const parts = fullName.trim().split(/\s+/).filter(Boolean); if (parts.length === 0) return "U"; if (parts.length === 1) return parts[0].charAt(0).toUpperCase(); @@ -22,7 +23,7 @@ export default function Header({ toggleSidebar }: { toggleSidebar: () => void }) const settingsPath = user?.role === "intern" ? "/intern/settings" : "/mentor/settings"; - const initials = user ? getInitials(user.full_name) : "U"; + const initials = user?.full_name ? getInitials(user.full_name) : "U"; const hasPhoto = !!user?.profile_picture; return ( diff --git a/src/Pages/NotFound.tsx b/src/Pages/NotFound.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/src/Pages/NotFoundPage.tsx b/src/Pages/NotFoundPage.tsx index 3e3232f..0f5932a 100644 --- a/src/Pages/NotFoundPage.tsx +++ b/src/Pages/NotFoundPage.tsx @@ -1,11 +1,11 @@ import { useNavigate } from "react-router-dom"; -import { Home, ArrowLeft, Search } from "lucide-react"; +import { Home, ArrowLeft } from "lucide-react"; export default function NotFoundPage() { const navigate = useNavigate(); return ( -
+
{/* Background decorative blobs */}
@@ -14,23 +14,21 @@ export default function NotFoundPage() { {/* 404 Large Text */}
- + 404
-
{/* Title */}

- Halaman Tidak Ditemukan + Not Found

{/* Description */} -

- Sepertinya halaman yang kamu cari tidak ada atau telah dipindahkan. - Coba kembali ke halaman sebelumnya atau beranda. +

+ Sorry, the page you are looking for does not exist or has been moved. Let's get back on track.

{/* Divider */} @@ -40,7 +38,7 @@ export default function NotFoundPage() {
-
- - {/* Column Headers */} -
-
- Intern Name - Task Progress - Status - Action -
- - {/* Rows */} -
- {paginated.map((intern) => { - const isChecked = selected.has(intern.id); - const isIncomplete = intern.status === "INCOMPLETE"; - return ( -
- {/* Checkbox */} - - - {/* Name + role */} -
- -
-

{intern.name}

-

{intern.role}

-
-
- - {/* Task Progress */} -
- -
- - {/* Status */} -
- -
- - {/* Action */} -
- {isIncomplete ? ( - - ) : ( - - )} -
-
- ); - })} -
- - {/* Pagination Footer */} -
-

- Showing {(page - 1) * PER_PAGE + 1} to {Math.min(page * PER_PAGE, mockInterns.length)} of {TOTAL_ENTRIES.toLocaleString()} entries -

-
- - {Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => ( - - ))} - -
-
-
-
- - {/* ── Bottom Action Bar ── */} -
- -
-
- ); -} \ No newline at end of file diff --git a/src/Pages/internify/mentor/CertificateForm.tsx b/src/Pages/internify/mentor/CertificateForm.tsx new file mode 100644 index 0000000..32138b4 --- /dev/null +++ b/src/Pages/internify/mentor/CertificateForm.tsx @@ -0,0 +1,188 @@ +import { useRef, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { CalendarDays, UploadCloud, CheckCircle2, XCircle, Loader2 } from "lucide-react"; +import { useUploadCertificateTemplate } from "../../../hooks/useSertificates"; +import { useProjectDetail } from "../../../hooks/useProjects"; +import EligibleInternTable from "./components/EligibleInternTable"; +import { useProjectInternProgress } from "../../../hooks/useInternProgress" + +export default function CertificateDetail() { + const navigate = useNavigate(); + const { slug } = useParams<{ slug: string }>(); + const { project, loading, error } = useProjectDetail(slug ?? ""); + const { upload, loading: uploading, error: uploadError } = useUploadCertificateTemplate(); + const { data: internProgress, loading: progressLoading } = useProjectInternProgress(slug ?? ""); + const eligibleCount = internProgress.filter(i => i.is_eligible).length; + + const fileInputRef = useRef(null); + const [selectedFile, setSelectedFile] = useState(null); + const [uploadSuccess, setUploadSuccess] = useState(false); + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setSelectedFile(file); + setUploadSuccess(false); + }; + + 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 = ""; + } + }; + + if (loading) { + return ( +
+ + Memuat data project... +
+ ); + } + + if (error || !project) { + return ( +
+ +

{error ?? "Project tidak ditemukan."}

+ +
+ ); + } + + return ( +
+
+

Certificates

+

+ Administrate, issue, and track professional certifications for all interns. +

+
+
+ {/* ── Left Column ── */} +
+

+ {project.project_name} +

+
+ + {new Date(project.start_date).toLocaleDateString("en-US", { month: "short", year: "numeric" })} + {" — "} + {new Date(project.end_date).toLocaleDateString("en-US", { month: "short", year: "numeric" })} +
+ + {/* ── Upload Template ── */} +
+ + + {/* Drop Zone */} +
fileInputRef.current?.click()} + > +
+ +
+ {selectedFile ? ( + <> +

+ {selectedFile.name} +

+

+ {(selectedFile.size / 1024 / 1024).toFixed(2)} MB +

+ + ) : ( + <> +

Upload Certificate Image

+

PNG, JPG up to 5MB

+ + )} +
+ + + + {/* Feedback */} + {uploadSuccess && ( +
+ + Template berhasil diupload! +
+ )} + {uploadError && ( +
+ + {uploadError} +
+ )} + + {/* Upload Button */} + {selectedFile && ( + + )} +
+ + {/* ── Project Scope ── */} +
+

+ Project Scope +

+

{project.description}

+
+ + {/* ── Stats ── */} +
+
+

{project.total_members}

+

Total Interns

+
+
+

{eligibleCount}

+

Eligible

+
+
+
+ + {/* ── Right Column ── */} + +
+
+ ); +} \ No newline at end of file diff --git a/src/Pages/internify/mentor/CertificateListPage.tsx b/src/Pages/internify/mentor/CertificateListPage.tsx index 5fc708a..4bd03ea 100644 --- a/src/Pages/internify/mentor/CertificateListPage.tsx +++ b/src/Pages/internify/mentor/CertificateListPage.tsx @@ -23,7 +23,7 @@ export default function CertificateList() { {/* Section: List Projects */}
-

List Projects

+

List Projects

{projects.map((project) => ( diff --git a/src/Pages/internify/mentor/CertificateGenerate.tsx b/src/Pages/internify/mentor/CertificateResult.tsx similarity index 100% rename from src/Pages/internify/mentor/CertificateGenerate.tsx rename to src/Pages/internify/mentor/CertificateResult.tsx diff --git a/src/Pages/internify/mentor/components/CertificateProjectCard.tsx b/src/Pages/internify/mentor/components/CertificateProjectCard.tsx index 13cc837..3f216bf 100644 --- a/src/Pages/internify/mentor/components/CertificateProjectCard.tsx +++ b/src/Pages/internify/mentor/components/CertificateProjectCard.tsx @@ -3,7 +3,7 @@ import type { Project } from "../../../../types/project.types"; // ─── StatusBadge ───────────────────────────────────────────────────────────── function StatusBadge({ status }: { status: string }) { - const isCompleted = status === "COMPLETED"; + const isCompleted = status === "archived"; return ( {/* Top: status badge */}
@@ -37,21 +50,21 @@ export default function CertificateProjectCard({ {/* Title + period */}
-

+

{project.project_name}

-
+
- {project.start_date} - {project.end_date} + {formatDateRange(project.start_date, project.end_date)}
-
+
{/* Intern count */}
-

Interns

+

Interns

{project.total_members} interns

diff --git a/src/Pages/internify/mentor/components/EligibleInternTable.tsx b/src/Pages/internify/mentor/components/EligibleInternTable.tsx new file mode 100644 index 0000000..aaa9538 --- /dev/null +++ b/src/Pages/internify/mentor/components/EligibleInternTable.tsx @@ -0,0 +1,266 @@ +// src/pages/mentor/EligibleInternTable.tsx +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + CheckCircle2, + AlertCircle, + Eye, + ChevronLeft, + ChevronRight, + Sparkles, +} from "lucide-react"; +import type { InternSubmissionProgress } from "../../../../hooks/useInternProgress"; +import { useClaimCertificate } from "../../../../hooks/useSertificates"; +import { Loader2, Check } from "lucide-react"; + +interface EligibleInternTableProps { + interns: InternSubmissionProgress[]; + loading: boolean; + eligibleCount: number; +} + +const PER_PAGE = 5; + +function ProgressBar({ value, total }: { value: number; total: number }) { + const pct = Math.round((value / total) * 100); + const isComplete = value === total; + return ( +
+
+
+
+ + {value}/{total} + +
+ ); +} + +function StatusBadge({ status }: { status: boolean }) { + const isEligible = status + return ( +
+ {isEligible + ? + : } + {status} +
+ ); +} + +interface EligibleInternTableProps { + interns: InternSubmissionProgress[]; + loading: boolean; + eligibleCount: number; +} + +function Avatar({ initials }: { initials: string }) { + return ( +
+ {initials} +
+ ); +} + +// ─── Component ──────────────────────────────────────────────────────────────── +export default function EligibleInternTable({ interns, loading, eligibleCount }: EligibleInternTableProps) { + const navigate = useNavigate(); + + const initialSelected = new Set( + interns.filter(i => i.is_eligible).map(i => i.id_user) + ); + const [selected, setSelected] = useState>(initialSelected); + const [page, setPage] = useState(1); + + const totalPages = Math.ceil(interns.length / PER_PAGE); + const paginated = interns.slice((page - 1) * PER_PAGE, page * PER_PAGE); + + const eligibleIds = interns.filter(i => i.is_eligible).map(i => i.id_user); + const allEligibleSelected = eligibleIds.every(id => selected.has(id)); + + const toggleSelectAll = () => { + if (allEligibleSelected) { + setSelected(new Set()); + } else { + setSelected(new Set(eligibleIds)); + } + }; + + const toggleOne = (id: number, status: boolean) => { + if (status) return; + setSelected((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + }; + + if (loading) { + return ( +
+ + Memuat data intern... +
+ ); +} + + return ( +
+ {/* Roster Card */} +
+
+
+

Intern Roster

+

+ Confirm completion status before final certificate generation. +

+
+ +
+ + {/* Column Headers */} +
+
+ Intern Name + Task Progress + Status + Action +
+ + {/* Rows */} +
+ {paginated.map((intern) => { + const isChecked = selected.has(intern.id_user); + const isIncomplete = !intern.is_eligible; + const initials = intern.full_name + .split(" ") + .slice(0, 2) + .map(n => n[0]) + .join("") + .toUpperCase(); + return ( +
+ {/* Checkbox */} + + + {/* Name + role */} +
+ {intern.avatar ? ( + + ) : ( + + )} +
+

{intern.full_name}

+

{intern.email}

+
+
+ + {/* Task Progress */} +
+ +
+ + {/* Status */} +
+ +
+ + {/* Action */} +
+ {isIncomplete ? ( + + ) : ( + + )} +
+
+ ); + })} +
+ + {/* Pagination Footer */} +
+

+ Showing {(page - 1) * PER_PAGE + 1} to {Math.min(page * PER_PAGE, interns.length)} of {interns.length} entries +

+
+ + {Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => ( + + ))} + +
+
+
+ + {/* ── Bottom Action Bar ── */} +
+ +
+
+ ); +} diff --git a/src/Pages/internify/mentor/components/MentorProjectCard.tsx b/src/Pages/internify/mentor/components/MentorProjectCard.tsx index fa9f6e1..bc33bb5 100644 --- a/src/Pages/internify/mentor/components/MentorProjectCard.tsx +++ b/src/Pages/internify/mentor/components/MentorProjectCard.tsx @@ -4,7 +4,11 @@ import * as LucideIcons from 'lucide-react'; import type { LucideProps } from 'lucide-react'; import type { Project } from '../../../../types/project.types'; - +const getDynamicIcon = (iconName: string) => { + const formatted = iconName.charAt(0).toUpperCase() + iconName.slice(1); + const Icon = (LucideIcons as unknown as Record>)[formatted]; + return Icon ?? LucideIcons.FolderOpen; + }; const toSlug = (name: string) => name.toLowerCase().replace(/\s+/g, '-'); diff --git a/src/hooks/useInternProgress.ts b/src/hooks/useInternProgress.ts new file mode 100644 index 0000000..4b50354 --- /dev/null +++ b/src/hooks/useInternProgress.ts @@ -0,0 +1,81 @@ +// hooks/useProjectInternProgress.ts +import { useState, useEffect, useCallback } from "react"; +import { getProjectTasks, getTaskSubmissions } from "../services/TaskService"; +import type { AdminTaskDetail } from "../types/task.types"; + +export interface InternSubmissionProgress { + id_user: number; + full_name: string; + email: string; + avatar: string | null; + submitted: number; + total_tasks: number; + is_eligible: boolean; +} + +export const useProjectInternProgress = (projectSlug: string) => { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refetch = useCallback(async () => { + if (!projectSlug) return; + setLoading(true); + setError(null); + + try { + const tasks = await getProjectTasks(projectSlug); + + const taskDetails = await Promise.all( + tasks.map((task) => + getTaskSubmissions(task.slug, projectSlug) as Promise + ) + ); + + const internMap = new Map(); + + for (const taskDetail of taskDetails) { + if (!taskDetail?.submissions) continue; + + for (const sub of taskDetail.submissions) { + // field yang benar: sub.submission (bukan submission_details) + const hasSubmitted = !!sub.submission; + const existing = internMap.get(sub.id_user); + + if (existing) { + existing.total_tasks += 1; + if (hasSubmitted) existing.submitted += 1; + } else { + internMap.set(sub.id_user, { + id_user: sub.id_user, + full_name: sub.full_name, + email: sub.email, + avatar: sub.profile_picture, // field yang benar + submitted: hasSubmitted ? 1 : 0, + total_tasks: 1, + is_eligible: false, + }); + } + } + } + + const result = Array.from(internMap.values()).map((intern) => ({ + ...intern, + is_eligible: + intern.total_tasks > 0 && intern.submitted === intern.total_tasks, + })); + + setData(result); + } catch { + setError("Gagal memuat progress intern."); + } finally { + setLoading(false); + } + }, [projectSlug]); + + useEffect(() => { + refetch(); + }, [refetch]); + + return { data, loading, error, refetch }; +}; \ No newline at end of file diff --git a/src/services/ProjectService.ts b/src/services/ProjectService.ts index 5f5e2e6..27f5213 100644 --- a/src/services/ProjectService.ts +++ b/src/services/ProjectService.ts @@ -53,7 +53,8 @@ export const getProjectById = async ( ): Promise => { const res = await api.get(`/project-api/get/${id}`); const raw = res.data.data; - + console.log("raw fields:", Object.keys(raw)); +console.log("raw full:", raw); return { ...raw, members: raw.members.map((m: any) => ({ diff --git a/src/types/project.types.ts b/src/types/project.types.ts index b33911e..5825a66 100644 --- a/src/types/project.types.ts +++ b/src/types/project.types.ts @@ -1,4 +1,4 @@ -// types/project.types.ts +import type { ProjectTask } from "./task.types"; export type ProjectStatus = "active" | "completed" | "archived"; export type MemberStatus = "active" | "inactive"; @@ -33,6 +33,7 @@ export interface Project { // Detail project (includes members array) export interface ProjectDetail extends Project { members: ProjectMember[]; + tasks: ProjectTask[]; } // Response GET /project-api/my-tasks