feat: update project task card to use new task type and improve navigation

fix: update project card to reflect actual tasks done from project data

feat: enhance task form components to accept project ID for submissions

refactor: clean up certificate detail page by removing unused elements

chore: streamline certificate generation page by removing unnecessary navigation

refactor: update certificate list page to use dynamic project data

feat: improve interns management page with API integration and better state handling

feat: add modal for assigning interns to projects with project selection

feat: create a new component for displaying project cards in certificate list

fix: correct import paths in create project dialog component

refactor: enhance project hooks to include task filtering and refetch capabilities

fix: update mock certificate data to reflect correct status

feat: extend certificate types to include new fields for better tracking

feat: add task_done field to project types for improved task management
This commit is contained in:
Kemas M. Aryadary Rasyad
2026-06-28 08:12:56 +07:00
parent 6ec9620f1f
commit ad26a53f21
26 changed files with 587 additions and 347 deletions
+2 -4
View File
@@ -1,13 +1,11 @@
import { useParams } from 'react-router-dom';
import ProjectTimeline from './components/ProjectTimeline';
import TaskCard from './components/ProjectTaskCard';
import HeroProject from './components/Hero-project';
import { useProjectTasks } from '../../../hooks/useTasks';
import { useProjectMyTasks } from '../../../hooks/useProjects';
import type { Project } from '../../../types/project.types';
export default function ForumTab({ project }: { project: Project }) {
const { slug } = useParams<{ slug: string }>();
const { tasks, loading, error } = useProjectTasks(slug ?? "");
const { tasks, loading, error } = useProjectMyTasks(project.id);
return (
<div>
<HeroProject title={project.project_name} description={project.description} />
@@ -8,8 +8,10 @@ export default function ProjectDetailPage() {
const { slug } = useParams<{ slug: string }>();
const { project, loading, error } = useProjectDetail(slug ?? "");
const [activeTab, setActiveTab] = useState<'forum' | 'participants'>('forum');
if (loading) return <p className="p-10 text-gray-400">Memuat project...</p>;
if (error) return <p className="p-10 text-red-500">{error}</p>;
if (!project) return <p className="p-10 text-gray-400">Project tidak ditemukan.</p>;
const tabClass = (tab: string) =>
+41 -11
View File
@@ -1,8 +1,11 @@
import { Award } from "lucide-react";
import { history, type CertificateHistory } from "../../../lib/mockSertificates";
import { Award, Loader2, AlertCircle } from "lucide-react";
import { useMyCertificates } from "../../../hooks/useSertificates";
import type { Certificate } from "../../../types/certificate.types";
import certificateImg from "../../../assets/certificate.png";
export default function SertificateList() {
const { certificates, loading, error } = useMyCertificates();
return (
<div>
{/* Header */}
@@ -11,15 +14,33 @@ export default function SertificateList() {
<p className="page-title-desc">All your earned internship certificates</p>
</div>
{/* Grid */}
{history.length === 0 ? (
{/* Loading */}
{loading && (
<div className="flex items-center justify-center py-20 text-gray-400 gap-2">
<Loader2 className="w-6 h-6 animate-spin" />
<span className="text-sm">Loading certificates...</span>
</div>
)}
{/* Error */}
{error && !loading && (
<div className="flex items-center justify-center py-20 text-red-500 gap-2">
<AlertCircle className="w-6 h-6" />
<span className="text-sm">{error}</span>
</div>
)}
{/* Empty */}
{!loading && !error && certificates.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
<Award className="w-12 h-12 mb-3 opacity-40" />
<p className="text-sm">No certificates yet.</p>
</div>
) : (
)}
{/* Grid */}
{!loading && !error && certificates.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
{history.map((item: CertificateHistory) => (
{certificates.map((item: Certificate) => (
<div
key={item.id}
className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-shadow cursor-pointer group"
@@ -29,7 +50,7 @@ export default function SertificateList() {
<div className="rounded-lg overflow-hidden">
<img
src={certificateImg}
alt={item.title}
alt={item.project.project_name}
className="w-full h-[160px] object-cover"
/>
</div>
@@ -37,9 +58,18 @@ export default function SertificateList() {
{/* Info */}
<div className="px-4 py-3">
<p className="text-xs text-gray-400 mb-0.5">{item.month}</p>
<p className="text-sm font-semibold text-gray-800 leading-snug">{item.title}</p>
<p className="text-xs text-gray-500 mt-1">Grade: <span className="font-bold text-red-600">{item.grade}</span></p>
<p className="text-xs text-gray-400 mb-0.5">
{new Date(item.issued_at).toLocaleDateString("id-ID", {
year: "numeric",
month: "long",
})}
</p>
<p className="text-sm font-semibold text-gray-800 leading-snug">
{item.project.project_name}
</p>
<p className="text-xs text-gray-500 mt-1">
No: <span className="font-bold text-red-600">{item.certificate_no}</span>
</p>
</div>
</div>
))}
+60 -10
View File
@@ -1,31 +1,81 @@
import { certificateData } from "../../../lib/mockSertificates";
import { useState, useMemo } from "react";
import CertificateAvailable from "./components/CertificateAvailabe";
import CertificateNotAvailable from "./components/CertificateNotAvailable";
import SertificateHistory from "./components/CertificateHistory";
import { useMyCertificates, useClaimCertificate } from "../../../hooks/useSertificates";
import { useMyProjects } from "../../../hooks/useProjects";
import type { ProjectDetail } from "../../../types/project.types";
const SertificatePage = () => {
const data = certificateData;
const { projects, loading: projectsLoading } = useMyProjects();
const { certificates, loading: certLoading } = useMyCertificates();
const { claim, loading: claiming } = useClaimCertificate();
const [justClaimed, setJustClaimed] = useState(false);
const current_task = useMemo(() => {
if (!projects || projects.length === 0) return null;
return projects.find((p) => p.task_done < p.total_tasks) ?? null;
}, [projects]);
const finished_task = useMemo((): ProjectDetail | null => {
if (!projects || projects.length === 0) return null;
return projects.find((p) => p.task_done >= p.total_tasks) ?? null;
}, [projects]);
// Cek apakah finished_task sudah punya certificate di backend
const alreadyClaimed = useMemo(() => {
if (!finished_task || !certificates.length) return false;
return certificates.some((cert) => cert.project.id === finished_task.id);
}, [certificates, finished_task]);
const allTasksDone = !projectsLoading && current_task === null && projects.length > 0;
const progress =
current_task && current_task.total_tasks > 0
? Math.round((current_task.task_done / current_task.total_tasks) * 100)
: 100;
const remainingTasks = current_task
? current_task.total_tasks - current_task.task_done
: 0;
const handleClaim = async () => {
if (!finished_task) return;
const result = await claim(finished_task.id);
if (result) setJustClaimed(true);
};
const isLoading = projectsLoading || certLoading;
const showCertificate = alreadyClaimed || justClaimed;
if (isLoading) {
return (
<div className="flex items-center justify-center py-20 text-gray-400 text-sm">
Loading...
</div>
);
}
console.log(finished_task)
return (
<div>
{/* Header */}
<div className="mb-4 flex flex-col gap-1">
<h1 className="page-title">Certificates</h1>
<p className="page-title-desc">View and download internship certificates</p>
</div>
<div className="flex flex-col lg:flex-row gap-6 items-stretch">
{/* Kiri: Content */}
<div className="w-full lg:w-5/8 flex flex-col">
{data.isReceived ? (
<CertificateAvailable data={data} />
{showCertificate ? (
<CertificateAvailable project={finished_task} />
) : (
<CertificateNotAvailable
progress={data.projectProgress}
remainingTasks={data.remainingTasks}
progress={progress}
remainingTasks={remainingTasks}
allTasksDone={allTasksDone}
onClaim={handleClaim}
claiming={claiming}
/>
)}
</div>
{/* Kanan: History */}
<div className="w-full lg:w-3/8 flex-shrink-0">
<SertificateHistory />
</div>
@@ -34,4 +84,4 @@ const SertificatePage = () => {
);
};
export default SertificatePage;
export default SertificatePage;
@@ -49,13 +49,15 @@ export default function TaskSubmission() {
{submissionType === "file_upload" ? (
<TaskFormFile
taskId={taskSlug!}
taskId={String(task.id)}
projectId={String(task.project.id)}
deadline={new Date(task.deadline_at)}
initialSubmission={task.my_submission}
/>
) : (
<TaskFormLink
taskId={taskSlug!}
taskId={String(task.id)}
projectId={String(task.project.id)}
deadline={new Date(task.deadline_at)}
initialSubmission={task.my_submission}
/>
@@ -1,10 +1,13 @@
import { useState } from "react";
import { Download, Link, Printer } from "lucide-react";
import { type CertificateData } from "../../../../lib/mockSertificates";
import type { ProjectDetail } from "../../../../types/project.types";
import certificateImg from "../../../../assets/certificate.png";
export default function CertificateAvailable({ data }: { data: CertificateData }) {
const { certificate } = data;
interface CertificateAvailableProps {
project: ProjectDetail | null;
}
export default function CertificateAvailable({ project }: CertificateAvailableProps) {
const [copied, setCopied] = useState(false);
const handleCopyLink = () => {
@@ -13,10 +16,25 @@ export default function CertificateAvailable({ data }: { data: CertificateData }
setTimeout(() => setCopied(false), 2000);
};
// Hitung durasi dari start_date - end_date
const duration = project
? (() => {
const start = new Date(project.start_date);
const end = new Date(project.end_date);
const diffMs = end.getTime() - start.getTime();
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
const months = Math.floor(diffDays / 30);
const days = diffDays % 30;
if (months > 0 && days > 0) return `${months} Bulan ${days} Hari`;
if (months > 0) return `${months} Bulan`;
return `${diffDays} Hari`;
})()
: "-";
return (
<div className="flex flex-col gap-4">
{/* Certificate Card */}
<div className="flex rounded-xl p-5 mb-1 border border-gray-300 shadow-md items-center justify-center">
<div className="flex rounded-xl p-5 mb-1 border bg-box-primary border-box-border shadow-md items-center justify-center">
<img
src={certificateImg}
alt="Certificate"
@@ -32,13 +50,14 @@ export default function CertificateAvailable({ data }: { data: CertificateData }
</div>
<div className="space-y-3">
{[
{ label: "Nama Program", value: certificate.programName },
{ label: "Durasi", value: certificate.duration },
{ label: "Nama Program", value: project?.project_name ?? "-" },
{ label: "Durasi", value: duration },
{ label: "Total Tugas", value: project ? `${project.total_tasks} Tugas` : "-" },
{
label: "Status",
value: (
<span className="bg-green-100 text-green-700 text-xs font-bold px-2 py-0.5 rounded-full">
VERIFIED
UNVERIFIED
</span>
),
},
@@ -1,38 +1,69 @@
import { useNavigate } from "react-router-dom";
import { History } from "lucide-react";
import { type CertificateHistory, history as certificateHistory } from "../../../../lib/mockSertificates";
import { History, Loader2, AlertCircle } from "lucide-react";
import type { Certificate } from "../../../../types/certificate.types";
import certificateImg from "../../../../assets/certificate.png";
import { useMyCertificates } from "../../../../hooks/useSertificates";
export default function SertificateHistory() {
const navigate = useNavigate();
const { certificates, loading, error } = useMyCertificates();
const displayed = certificates.slice(0, 2);
return (
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm p-5">
<div className="bg-box-primary rounded-2xl border border-box-border shadow-sm p-5">
<div className="flex items-center gap-2 mb-4">
<History className="w-5 h-5 text-red-700" />
<h3 className="font-semibold text-gray-800 text-lg">Certificate History</h3>
</div>
<div className="space-y-3">
{certificateHistory.slice(0, 2).map((item: CertificateHistory) => (
<div
key={item.id}
className="flex flex-col gap-3 p-3 bg-white border border-gray-200 shadow-sm rounded-xl hover:bg-gray-50 transition-colors cursor-pointer"
>
<div className="w-full flex justify-center">
<img
src={certificateImg}
alt="Certificate"
className="rounded-xl h-[150px] w-[150px] w-full object-cover"
/>
{loading && (
<div className="flex items-center justify-center py-8 text-gray-400 gap-2">
<Loader2 className="w-5 h-5 animate-spin" />
<span className="text-sm">Loading certificates...</span>
</div>
)}
{error && !loading && (
<div className="flex items-center gap-2 py-6 text-red-500">
<AlertCircle className="w-4 h-4" />
<span className="text-sm">{error}</span>
</div>
)}
{!loading && !error && displayed.length === 0 && (
<p className="text-sm text-gray-400 py-6 text-center">No certificates yet.</p>
)}
{!loading && !error && displayed.length > 0 && (
<div className="space-y-3">
{displayed.map((item: Certificate) => (
<div
key={item.id}
className="flex flex-col gap-3 p-3 bg-white border border-gray-200 shadow-sm rounded-xl hover:bg-gray-50 transition-colors cursor-pointer"
>
<div className="w-full flex justify-center">
<img
src={certificateImg}
alt="Certificate"
className="rounded-xl h-[150px] w-full object-cover"
/>
</div>
<div className="w-full">
<p className="text-xs text-gray-400">
{new Date(item.issued_at).toLocaleDateString("id-ID", {
year: "numeric",
month: "long",
})}
</p>
<p className="text-sm font-semibold text-gray-800 leading-tight">
{item.project.project_name}
</p>
<p className="text-sm text-gray-500">No: {item.certificate_no}</p>
</div>
</div>
<div className="w-full">
<p className="text-xs text-gray-400">{item.month}</p>
<p className="text-sm font-semibold text-gray-800 leading-tight">{item.title}</p>
<p className="text-sm text-gray-500">Grade: {item.grade}</p>
</div>
</div>
))}
</div>
))}
</div>
)}
<div className="w-full my-3 flex justify-end">
<button
@@ -1,43 +1,104 @@
import { Lock, Clock } from "lucide-react";
import { Lock, Award, Loader2 } from "lucide-react";
export default function CertificateNotAvailable({ progress, remainingTasks }: { progress: number; remainingTasks: number }) {
interface CertificateNotAvailableProps {
progress: number;
remainingTasks: number;
allTasksDone?: boolean;
onClaim?: () => void;
claiming?: boolean;
}
export default function CertificateNotAvailable({
progress,
remainingTasks,
allTasksDone = false,
onClaim,
claiming = false,
}: CertificateNotAvailableProps) {
return (
<div className="bg-white w-full h-full rounded-2xl border border-gray-100 shadow-sm px-24 flex flex-col items-center justify-center">
<div className="bg-box-primary w-full h-full rounded-2xl border border-box-border shadow-sm px-24 py-12 flex flex-col items-center justify-center">
{/* Icon */}
<div className="relative mb-6">
<div className=" w-45 h-45 border-2 border-dashed border-gray-600 rounded-xl flex justify-center items-center">
<div className="w-30 h-30 flex items-center rounded-lg justify-center bg-gray-300">
<Lock className="w-16 h-16 text-red-700" />
</div>
<div className="absolute -bottom-2 -right-2 w-7 h-7 bg-amber-500 rounded- flex items-center justify-center shadow-md">
<Clock className="w-5 h-5 text-yellow-800" />
<div className="w-34 h-34 border-2 border-dashed border-gray-600 rounded-xl flex justify-center items-center">
<div className="w-26 h-26 flex items-center rounded-lg justify-center bg-gray-300">
{allTasksDone ? (
<Award className="w-12 h-12 text-green-600" />
) : (
<Lock className="w-12 h-12 text-red-700" />
)}
</div>
{/* <div className="absolute -bottom-2 -right-2 w-7 h-7 bg-amber-500 rounded- flex items-center justify-center shadow-md">
<Clock className="w-4 h-4 text-yellow-800" />
</div> */}
</div>
</div>
<h2 className="text-[37px] font-bold text-gray-900 mb-3">Sertifikat Belum Tersedia</h2>
<p className="text-gray-500 text-[17px] mb-6">
Anda harus menyelesaikan <span className="font-bold text-red-600">semua tugas proyek</span> sebelum dapat mengunduh sertifikat kelulusan.
Silakan periksa kembali progres tugas Anda di menu Proyek.
</p>
{allTasksDone ? (
<>
<h2 className="text-[37px] font-bold text-gray-900 mb-3">Selamat!</h2>
<p className="text-gray-500 text-[17px] mb-6 text-center">
Semua tugas proyek telah selesai. Klik tombol di bawah untuk{" "}
<span className="font-bold text-green-600">mengklaim sertifikat</span> kelulusan Anda.
</p>
{/* Progress Bar */}
<div className="w-1/3">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-semibold text-gray-700">Progres Proyek</span>
<span className="text-sm font-bold text-red-700">{progress}%</span>
</div>
<div className="w-full h-3 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-red-700 rounded-full transition-all duration-700"
style={{ width: `${progress}%` }}
/>
</div>
<p className="text-xs text-gray-400 mt-2 text-left">
Selesaikan {remainingTasks} tugas lagi untuk membuka akses.
</p>
</div>
{/* Progress Bar — 100% */}
<div className="w-1/3 mb-6">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-semibold text-gray-700">Progres Proyek</span>
<span className="text-sm font-bold text-green-600">100%</span>
</div>
<div className="w-full h-3 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-green-500 rounded-full transition-all duration-700 w-full" />
</div>
<p className="text-xs text-gray-400 mt-2 text-left">Semua tugas telah diselesaikan!</p>
</div>
{/* Tombol Claim */}
<button
onClick={onClaim}
disabled={claiming}
className="flex items-center justify-center gap-2 px-8 py-3 bg-red-700 hover:bg-red-800 disabled:opacity-60 active:scale-95 transition-all text-white font-semibold rounded-xl text-sm"
>
{claiming ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Mengklaim...
</>
) : (
<>
<Award className="w-4 h-4" />
Claim Certificate
</>
)}
</button>
</>
) : (
<>
<h2 className="text-[28px] font-bold text-gray-900 mb-3">Sertifikat Belum Tersedia</h2>
<p className="text-gray-500 text-[15px] mb-6 text-center">
Anda harus menyelesaikan <span className="font-bold text-red-600">semua tugas proyek</span> sebelum dapat mengunduh sertifikat kelulusan.
Silakan periksa kembali progres tugas Anda di menu Proyek.
</p>
{/* Progress Bar */}
<div className="w-1/3">
<p className="text-xs text-font mt-2">
Selesaikan {remainingTasks} tugas lagi untuk membuka akses.
</p>
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-semibold text-gray-700">Progres Proyek</span>
<span className="text-sm font-bold text-red-700">{progress}%</span>
</div>
<div className="w-full h-3 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-red-700 rounded-full transition-all duration-700"
style={{ width: `${progress}%` }}
/>
</div>
</div>
</>
)}
</div>
);
}
@@ -1,5 +1,5 @@
import { useNavigate, useParams } from "react-router-dom";
import type { ProjectTask } from "../../../../types/task.types";
import { useNavigate } from "react-router-dom";
import type { InternTaskItem } from "../../../../types/project.types";
const formatDate = (dateStr: string) =>
new Date(dateStr).toLocaleDateString('id-ID', { day: 'numeric', month: 'short', year: 'numeric' });
@@ -7,37 +7,36 @@ const formatDate = (dateStr: string) =>
const formatTime = (dateStr: string) =>
new Date(dateStr).toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' });
export default function TaskCard({ task }: { task: ProjectTask }) {
export default function TaskCard({ task }: { task: InternTaskItem }) {
const navigate = useNavigate();
const { slug } = useParams<{ slug: string }>();
return (
<div
className="border border-card-outline rounded-xl px-6 py-4 mb-3 bg-white flex items-start justify-between gap-3"
onClick={() => navigate(`/intern/projects/${slug}/${task.slug}?type=${task.submission_type}`)}
className="border border-card-outline rounded-xl px-6 py-4 mb-3 bg-white flex items-start justify-between gap-3 cursor-pointer hover:bg-gray-50 transition-colors"
onClick={() => navigate(`/intern/projects/${task.project_slug}/${task.slug}?type=${task.submission_type}`)}
>
<div className="flex-1 min-w-0">
<div className=" text-[11px] text-font mb-1">
<div className="text-[11px] text-font mb-1">
Deadline : {formatDate(task.deadline_at)} at {formatTime(task.deadline_at)}
</div>
<div className=" font-bold text-[15px] text-[#1a1a1a] mb-1">
<div className="font-bold text-[15px] text-[#1a1a1a] mb-1">
{task.title}
</div>
<div className=" text-[13px] text-font">
<div className="text-[13px] text-font">
{task.description}
</div>
<div className=" text-[13px] text-font">
<div className="text-[13px] text-font mt-0.5">
Tipe Submission : {task.submission_type === 'file_upload' ? "File" : "Link"}
</div>
</div>
<div className="block md:hidden h-[10px] bg-gray-600"/>
<div className="shrink-0 hidden md:block">
<button
className="bg-white text-font-shade border-[1.5px] border-card-outline font-semibold text-xs px-3.5 py-[5px] rounded-md cursor-pointer whitespace-nowrap transition-colors duration-150"
<div className="shrink-0 hidden md:flex items-center">
<span
className={`font-semibold text-xs px-3.5 py-[5px] rounded-md whitespace-nowrap bg-gray-50`}
>
Add Submission
</button>
{task.submission_status}
</span>
</div>
</div>
);
@@ -14,7 +14,7 @@ const ProjectCard = (project: Project) => {
const Icon = getDynamicIcon(project.project_icon);
const tasksDone = 1;
const tasksDone = project.task_done;
const tasksTotal = project.total_tasks;
return (
@@ -7,12 +7,13 @@ import type { TaskSubmissionData } from "../../../../types/task.types";
interface TaskFormFileProps {
taskId: string;
projectId?: string;
deadline?: Date;
initialSubmission?: TaskSubmissionData | null;
}
export default function TaskFormFile({ taskId, deadline, initialSubmission }: TaskFormFileProps) {
const { submission, loading, submitFile, updateFile, remove } = useSubmission(taskId, initialSubmission);
export default function TaskFormFile({ taskId, projectId, deadline, initialSubmission }: TaskFormFileProps) {
const { submission, loading, submitFile, updateFile, remove } = useSubmission(taskId, initialSubmission, projectId);
const [files, setFiles] = useState<File[]>([]);
const [isDragActive, setIsDragActive] = useState(false);
const [isEditing, setIsEditing] = useState(false);
@@ -7,12 +7,13 @@ import type { TaskSubmissionData } from "../../../../types/task.types";
interface TaskFormLinkProps {
taskId: string;
projectId?: string;
deadline?: Date;
initialSubmission?: TaskSubmissionData | null;
}
export default function TaskFormLink({ taskId, deadline, initialSubmission }: TaskFormLinkProps) {
const { submission, loading, submitLink, updateLink, remove } = useSubmission(taskId, initialSubmission);
export default function TaskFormLink({ taskId, projectId, deadline, initialSubmission }: TaskFormLinkProps) {
const { submission, loading, submitLink, updateLink, remove } = useSubmission(taskId, initialSubmission, projectId);
const [inputLink, setInputLink] = useState(initialSubmission?.url_link ?? "");
const [isEditing, setIsEditing] = useState(false);