feat: enhance certificate management and project task features

- Updated CertificateForm to improve loading state UI with skeletons.
- Enhanced CertificateResult with better loading indicators and structure.
- Refactored ProjectForumTab to include a skeleton loader and improved error handling.
- Introduced ProjectTaskTab for managing project tasks with modals for creating and editing tasks.
- Improved ProjectsPage with a skeleton loader during project data fetching.
- Refined AddInternsToProjects, ArchiveProjectDialog, CompleteProjectDialog, CreateProjectDialog, CreateTaskDialog, and EditTaskDialog components to handle modal states more effectively.
- Updated EligibleInternTable to enhance loading states and formatting of dates.
- Modified StatusSubmissionTable to simplify status display.
- Improved certificate generation utility functions for better date formatting and layout management.
- Removed deprecated certificate assets and added a new template for certificates.
- Updated types for certificates and projects to include start and end dates.
This commit is contained in:
Kemas M. Aryadary Rasyad
2026-07-15 11:42:22 +07:00
parent c2aacb505f
commit 5689898f33
36 changed files with 390 additions and 240 deletions
+9 -12
View File
@@ -102,9 +102,9 @@ export default function HomeCalendar() {
}
return (
<div className="px-13 py-5 border border-card-outline rounded-2xl">
<div className="box">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center justify-between mb-4 px-6">
<h2 className="font-bold text-font">
{MONTH_NAMES[currentMonth]} {currentYear}
</h2>
@@ -126,8 +126,7 @@ export default function HomeCalendar() {
</div>
</div>
{/* Day labels */}
<div className="grid grid-cols-7 mb-1">
<div className="grid grid-cols-7 mb-1 px-5">
{DAY_LABELS.map(day => (
<div key={day} className="text-center text-xs text-gray-400 font-medium py-1">
{day}
@@ -135,8 +134,7 @@ export default function HomeCalendar() {
))}
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7">
<div className="grid grid-cols-7 px-5">
{/* Trailing days from prev month */}
{trailingDays.map(day => (
<div key={`prev-${day}`} className="text-center py-2">
@@ -144,18 +142,17 @@ export default function HomeCalendar() {
</div>
))}
{/* Current month days */}
{currentDays.map(day => (
<div key={day} className="flex justify-center items-center py-0.5">
{isToday(day) ? (
<span className="w-8 h-7 flex items-center justify-center border-b-2 border-red-800 text-xs font-semibold">
<span className="w-8 h-7 flex items-center justify-center border-b-2 border-red- text-xs font-semibold">
{day}
</span>
) : (
<span
className={`w-full h-7 flex items-center justify-center text-xs
${isBold(day) ? 'font-bold text-gray-800' : 'text-gray-700'}
${hasDeadline(day) ? 'bg-red-800 text-white' : ''}
${hasDeadline(day) ? 'bg-red text-white' : ''}
transition-colors`}
>
{day}
@@ -173,10 +170,10 @@ export default function HomeCalendar() {
</div>
{/* Divider */}
<hr className="my-3 border-gray-400" />
<hr className="my-3 border-box-border" />
{/* Upcoming Deadlines */}
<div>
<div className='px-5 mt-2'>
<p className="text-[10px] font-semibold text-font uppercase tracking-widest mb-2">
Upcoming Deadlines
</p>
@@ -184,7 +181,7 @@ export default function HomeCalendar() {
<ul className="flex flex-col gap-1">
{upcomingDeadlines.map((d, i) => (
<li key={i} className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-red-600 flex-shrink-0" />
<span className="w-2 h-2 rounded-full bg-red flex-shrink-0" />
<span className="text-xs text-font-shade">{formatDate(d.date)} - {d.label}</span>
</li>
))}
+3 -3
View File
@@ -57,9 +57,9 @@ export default function InternifyLogin() {
};
return (
<div className="min-h-screen bg-background flex flex-col justify-between items-center py-8 px-4 font-sans selection:bg-red-foreground">
<div className="w-full max-w-[440px] flex flex-col items-center">
<div className="text-center mb-6">
<div className="min-h-screen bg-background flex flex-col justify-between items-center py-12 px-4 font-sans selection:bg-red-foreground">
<div className="w-full flex flex-col items-center">
<div className="text-center mb-12">
<div className="flex items-center justify-center gap-2 font-bold text-2xl tracking-tight text-black">
<img src={logo} alt="Logo" />
</div>
+39 -24
View File
@@ -8,38 +8,53 @@ import { FileText } from 'lucide-react';
export default function ForumTab({ project }: { project: Project }) {
const { tasks, loading, error } = useProjectMyTasks(project.id);
const renderSkeleton = () => (
<div className="flex flex-col gap-7 md:flex-row md:items-start mt-5 animate-pulse">
<div className="w-full md:w-1/5 box p-5">
<div className="flex flex-col gap-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="flex items-center gap-3">
<div className="h-3 flex-1 rounded bg-gray-200" />
</div>
))}
</div>
</div>
<div className="w-full border-t border-box-border md:border-t-0 pt-4 md:pt-0 md:w-4/5 flex flex-col gap-3">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-28 rounded-xl bg-box-secondary" />
))}
</div>
</div>
);
const isEmpty = !loading && !error && tasks.length === 0;
return (
<div>
<HeroProject title={project.project_name} description={project.description} />
{loading && (
<div className="flex flex-col gap-3 mt-5">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-28 bg-gray-100 rounded-xl animate-pulse" />
))}
</div>
)}
{error && <p className="text-red-500 text-sm mt-5">{error}</p>}
{tasks.length === 0 ? (
<div className='flex justify-center items-center flex-col mt-5'>
<FileText className='text-font mb-3' size={48} />
{/* Urutan eksklusif: hanya satu state yang tampil dalam satu waktu */}
{loading ? (
renderSkeleton()
) : error ? (
<p className="text-red-500 text-sm mt-5">{error}</p>
) : isEmpty ? (
<div className="flex flex-col justify-center items-center mt-5">
<FileText className="text-font mb-3" size={48} />
<p className="text-md text-font-shade">Belum ada tugas yang dapat dikerjakan.</p>
</div>
) : (
!loading && (
<div className="flex flex-col gap-7 md:flex-row md:items-start mt-5">
<div className="w-full md:w-1/5 bg-white rounded-xl p-5 border border-box-border shrink-0">
<ProjectTimeline tasks={tasks} />
</div>
<div className="w-full border-t border-box-border md:border-t-0 pt-4 md:pt-0 md:w-4/5">
{tasks.map((task) => (
<TaskCard key={task.id} task={task} />
))}
</div>
<div className="flex flex-col gap-7 md:flex-row md:items-start mt-5">
<div className="w-full md:w-1/5 box p-5">
<ProjectTimeline tasks={tasks} />
</div>
)
<div className="w-full border-t border-box-border md:border-t-0 pt-4 md:pt-0 md:w-4/5">
{tasks.map((task) => (
<TaskCard key={task.id} task={task} />
))}
</div>
</div>
)}
</div>
);
}
}
@@ -16,7 +16,7 @@ export default function HomeInternPage() {
<TaskTimeline />
</div>
</div>
<div className="bg-box-primary rounded-xl">
<div className="bg-box-primary">
<HomeCalendar />
</div>
</div>
@@ -15,7 +15,7 @@ export default function InternCertificateDetail() {
const projectFromList = location.state?.project as Project | undefined;
const { project: projectDetail, loading: projectLoading } = useProjectDetail(slug ?? "");
const { certificates, loading: certLoading, refetch: refetchCertificates } = useMyCertificates();
const { certificates, loading: certLoading } = useMyCertificates();
const myCertificateForProject = useMemo(() => {
+4 -11
View File
@@ -2,13 +2,12 @@ import { AlertCircle } from "lucide-react";
import { useMyProjects } from "../../../hooks/useProjects";
import type { Project } from "../../../types/project.types";
import { useNavigate } from "react-router-dom";
import * as LucideIcons from 'lucide-react';
import type { LucideProps } from 'lucide-react';
import { getProjectIcon } from "../../../lib/ProjectIcons";
import emptyimage from '../../../assets/Learning-amico.svg'
function CertCardSkeleton() {
return (
<div className="bg-white border border-card-outline rounded-xl overflow-hidden shadow-sm animate-pulse">
<div className="bg-box-secondary border border-card-outline rounded-xl overflow-hidden shadow-sm animate-pulse">
{/* Thumbnail */}
<div className="p-6 pb-0">
<div className="rounded-lg overflow-hidden">
@@ -25,12 +24,6 @@ function CertCardSkeleton() {
export default function SertificateList() {
const { projects, loading, error } = useMyProjects();
const navigate = useNavigate();
const getDynamicIcon = (iconName: string) => {
const formatted = iconName.charAt(0).toUpperCase() + iconName.slice(1);
const Icon = (LucideIcons as unknown as Record<string, React.ComponentType<LucideProps>>)[formatted];
return Icon ?? LucideIcons.FolderOpen;
};
return (
<div>
<div className="mb-6 flex flex-col gap-1">
@@ -73,8 +66,8 @@ export default function SertificateList() {
style={{ backgroundColor: item.background_color || '#403f3fff' }}
>
{(() => {
const Icon = getDynamicIcon(item.project_icon);
return <Icon className="w-10 h-10 text-primary text-white" />;
const Icon = getProjectIcon(item.project_icon);
return <Icon className="w-10 h-10 text-white" />;
})()}
</div>
</div>
@@ -3,15 +3,41 @@ import { useParams } from 'react-router-dom';
import { useProjectDetail } from '../../../hooks/useProjects';
import ForumTab from './ForumTab';
import ParticipantsTab from './ParticipantTab';
import { Loader2 } from 'lucide-react';
function ProjectDetailSkeleton() {
return (
<div className="animate-pulse">
{/* Tab bar skeleton — tinggi & spacing disamakan dengan tombol tab asli */}
<div className="flex mb-6 border-b border-card-outline">
<div className="pr-0 py-2.5 mr-7">
<div className="h-4 w-16 rounded bg-gray-200" />
</div>
<div className="pr-0 py-2.5 mr-7">
<div className="h-4 w-24 rounded bg-gray-200" />
</div>
</div>
{/* Konten tab skeleton (default tab = forum) */}
<div className="flex flex-col gap-4 mb-5 h-screen">
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-2 text-gray-400">
<Loader2 className="w-[45px] h-[45px] animate-spin" />
<span>Memuat Konten..</span>
</div>
</div>
</div>
);
}
export default function ProjectDetailPage() {
const { slug } = useParams<{ slug: string }>();
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 (loading) return <ProjectDetailSkeleton />;
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,7 +41,7 @@ export default function TaskSubmission() {
</div>
<div className="w-full bg-box-secondary rounded-2xl border border-box-border p-6 space-y-3">
<h2 className="text-xl text-gray-800 tracking-tight">Task Description</h2>
<h2 className="text-xl text-gray-800 tracking-tight font-bold">Deskripsi</h2>
<p className="text-sm text-foreground font-light leading-relaxed">{task.description}</p>
</div>
@@ -71,8 +71,8 @@ export default function TaskSubmission() {
<div className="w-full border border-box-border rounded-lg p-3 flex items-start gap-2.5">
<Info className="w-4 h-4 text-font mt-0.5 flex-shrink-0" />
<p className="text-[11px] text-font font-medium leading-relaxed">
Once submitted, your work will be locked for mentor review. You can request a re-submission if needed.
<p className="text-[11px] text-font-shade font-medium leading-relaxed">
Setelah disubmit, submission anda dapat dilihat oleh mentor. Pastikan submission yang anda masukan benar
</p>
</div>
</div>
@@ -16,7 +16,7 @@ export default function SertificateHistory() {
{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>
<span className="text-sm">memuat sertifikat...</span>
</div>
)}
@@ -30,7 +30,7 @@ export default function TaskCard({ task }: { task: InternTaskItem }) {
: task.submission_status;
return (
<div
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"
className="border border-card-outline rounded-xl px-6 py-4 mb-3 bg-box-primary 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">
@@ -8,7 +8,7 @@ export default function ProjectTimeline({ tasks }: { tasks: InternTaskItem[] })
today.setHours(0, 0, 0, 0);
return (
<div>
<div className="flex items-center gap-1.5 mb-0 md:mb-4 justify-between">
<div className="flex items-center gap-1.5 mb-1 md:mb-4 justify-between">
<span className="font-semibold text-md text-font-shade">Timeline</span>
</div>
<ul className="relative pl-4">
@@ -21,7 +21,7 @@ export default function ProjectTimeline({ tasks }: { tasks: InternTaskItem[] })
return (
<li key={task.id} className="relative mb-2 list-none">
{index < tasks.length - 1 && (
<div className="absolute -left-[1.0625rem] top-3 w-px h-full bg-[#ddd]" />
<div className="absolute -left-[1.0625rem] top-3 w-px h-full bg-font-shade/30" />
)}
{/* Dot */}
<div
@@ -43,7 +43,6 @@ export default function SubmitStatusTable({
const rows = [
{ label: "Submission status", value: "Submitted for grading" },
{ label: "Grading status", value: "Not graded" },
{ label: "Time Remaining", value: timeLabel },
];
@@ -98,7 +98,7 @@ export default function TaskFormLink({
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-1.5">
<label className="text-[11px] font-bold text-font-shade tracking-wide block">
<label className="text-[15px] font-bold text-font-shade tracking-wide block">
{isEditing ? "Update Submission Link" : "Submission Link"}
</label>
<div className="relative flex items-center">
@@ -107,14 +107,14 @@ export default function TaskFormLink({
</span>
<input
type="url" required
placeholder="https://example.com/your-pdf-link"
placeholder="https://example.com/"
value={inputLink}
onChange={(e) => setInputLink(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 bg-box-primary/50 border border-box-border rounded-lg text-sm placeholder-font text-font focus:outline-none focus:bg-white focus:border-red-600 focus:ring-1 focus:ring-red-600 font-medium"
/>
</div>
<p className="text-[11px] text-font font-medium pt-0.5">
Ensure the link is accessible to the mentor (e.g., Google Drive, Dropbox).
<p className="text-[13px] text-font font-medium pt-0.5">
Pastikan link yang disubmit bisa diakses oleh publik.
</p>
</div>
<button
@@ -22,7 +22,7 @@ const TaskTimeline = () => {
if (error) return <p className="text-red-500 text-sm">{error}</p>;
return (
<div className=''>
<div>
<h2 className="font-semibold text-font-shade mb-5">Timeline Tugas</h2>
{tasks.length === 0 ? (
@@ -31,7 +31,7 @@ const TaskTimeline = () => {
<p className="text-sm text-gray-400">Tidak ada tugas saat ini</p>
</div>
) : (
<div className="relative overflow-y-auto max-h-[190px]">
<div className="relative overflow-y-auto max-h-[140px] px-3 md:px-6">
<ul className="flex flex-col gap-5">
{tasks.map((item: InternTaskItem, index) => {
const overdue = isOverdue(item.deadline_at);
@@ -41,7 +41,7 @@ const TaskTimeline = () => {
return (
<li key={item.id} className="relative flex gap-4 pl-0">
{!isLast && (
<div className="absolute left-[3px] top-3 w-px h-full bg-[#ddd]" />
<div className="absolute left-[3px] top-3 w-px h-3/2 bg-font-shade/30" />
)}
<div className="relative flex-shrink-0 mt-1">
+21 -9
View File
@@ -67,9 +67,19 @@ export default function CertificateDetail() {
if (loading) {
return (
<div className="flex items-center justify-center py-20 text-gray-400 text-sm">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
Memuat data project...
<div>
<div className="mb-4 flex flex-col gap-1">
<h1 className="page-title">Certificates</h1>
<p className="page-title-desc">Administrate, issue, and track professional certifications for all interns.</p>
</div>
<div className="flex flex-col lg:flex-row gap-6 items-stretch animate-pulse">
{/* Kolom kiri: certificate box */}
<div className="w-full lg:w-5/8 h-[420px] bg-box-secondary rounded-xl" />
{/* Kolom kanan: history box */}
<div className="w-full lg:w-3/8 flex-shrink-0 h-[420px] bg-box-secondary rounded-xl" />
</div>
</div>
);
}
@@ -227,12 +237,14 @@ export default function CertificateDetail() {
</div>
{/* Right Column */}
<EligibleInternTable
interns={internProgress}
loading={progressLoading}
eligibleCount={eligibleCount}
project={project}
/>
<div className="lg:col-span-5 lg:mt-[110px] min-w-0">
<EligibleInternTable
interns={internProgress}
loading={progressLoading}
eligibleCount={eligibleCount}
project={project}
/>
</div>
</div>
</div>
);
@@ -36,7 +36,16 @@ export default function CertificateResult() {
}
};
if (loading) return <p>Sedang Memuat...</p>;
if (loading) return (
<div className="flex flex-col gap-3 pb-14">
<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="h-[360px] bg-box-secondary rounded-xl animate-pulse" />
<div className="h-[150px] bg-box-secondary rounded-xl animate-pulse" />
</div>
);
if (error) return <div className="text-sm text-red-500 p-4">Gagal memuat data sertifikat.</div>;
return (
@@ -81,8 +90,8 @@ export default function CertificateResult() {
</div>
{/* Table */}
<CertResultTable
certificates={certificates}
<CertResultTable
certificates={certificates}
templateUrl={project?.certificate_template ?? ""}
/>
</div>
+42 -24
View File
@@ -8,39 +8,57 @@ import { FileText } from 'lucide-react';
export default function MentorForumTab({ project }: { project: Project }) {
const { tasks, loading, error } = useProjectTasks(project.id);
if (error) return <div>Error: {error}</div>;
// Skeleton meniru layout final: kolom timeline 1/5 + list task card 4/5
const renderSkeleton = () => (
<div className="flex flex-col gap-7 md:flex-row md:items-start mt-5 animate-pulse">
<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-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="flex items-center gap-3">
<div className="h-3 w-3 shrink-0 rounded-full bg-gray-200" />
<div className="h-3 flex-1 rounded bg-gray-200" />
</div>
))}
</div>
</div>
<div className="w-full border-t border-box-border md:border-t-0 pt-4 md:pt-0 md:w-4/5 flex flex-col gap-3">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-28 rounded-xl bg-gray-100" />
))}
</div>
</div>
);
// Empty hanya valid setelah loading selesai & tidak error
const isEmpty = !loading && !error && tasks.length === 0;
return (
<div>
<HeroProject title={project.project_name} description={project.description} />
{loading && (
<div className="flex flex-col gap-3 mt-5">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-28 bg-gray-100 rounded-xl animate-pulse" />
))}
</div>
)}
{!loading && tasks.length === 0 ? (
<div className='flex justify-center items-center flex-col mt-12'>
<FileText className='text-font mb-6' size={48} />
{/* Kondisi eksklusif: hanya satu state tampil dalam satu waktu */}
{loading ? (
renderSkeleton()
) : error ? (
<p className="text-red-500 text-sm mt-5">{error}</p>
) : isEmpty ? (
<div className="flex flex-col justify-center items-center mt-12">
<FileText className="text-font mb-6" size={48} />
<p className="text-md text-font">Belum ada tugas yang dapat dikerjakan.</p>
</div>
) : (
!loading && (
<div className="flex flex-col gap-7 md:flex-row md:items-start mt-5">
<div className="w-full md:w-1/5 bg-white rounded-xl p-5 border border-box-border shrink-0">
<ProjectTimeline tasks={tasks} />
</div>
<div className="w-full border-t border-box-border md:border-t-0 pt-4 md:pt-0 md:w-4/5">
{tasks.map((task) => (
<MentorTaskCard key={task.id} task={task} />
))}
</div>
<div className="flex flex-col gap-7 md:flex-row md:items-start mt-5">
<div className="w-full md:w-1/5 bg-white rounded-xl p-5 border border-box-border shrink-0">
<ProjectTimeline tasks={tasks} />
</div>
)
<div className="w-full border-t border-box-border md:border-t-0 pt-4 md:pt-0 md:w-4/5">
{tasks.map((task) => (
<MentorTaskCard key={task.id} task={task} />
))}
</div>
</div>
)}
</div>
);
}
}
@@ -30,13 +30,13 @@ export default function TaskTab({ project }: { project: ProjectDetail }) {
}
};
// if (loading) return (
// <div className="flex flex-col gap-3 p-6">
// {[...Array(3)].map((_, i) => (
// <div key={i} className="h-12 bg-gray-300 rounded-lg animate-pulse" />
// ))}
// </div>
// );
if (loading) return (
<div className="flex flex-col gap-3 p-6">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-12 bg-gray-300 rounded-lg animate-pulse" />
))}
</div>
);
if (error) return <p className="text-red-500 text-sm p-6">{error}</p>;
+61 -8
View File
@@ -3,20 +3,73 @@ import { useParams } from 'react-router-dom';
import { useProjectDetail } from '../../../hooks/useProjects';
import MentorForumTab from './ProjectForumTab';
import InternsTab from './ProjectsInternTab';
import TaskTab from './TaskTab';
import { Loader2 } from 'lucide-react';
import TaskTab from './ProjectTaskTab';
function ProjectDetailSkeleton() {
return (
<div className="animate-pulse">
{/* Tab bar skeleton — tinggi & spacing disamakan dengan tombol tab asli */}
<div className="flex mb-4 border-b border-card-outline">
<div className="pr-0 py-2.5 mr-7">
<div className="h-4 w-16 rounded bg-gray-200" />
</div>
<div className="pr-0 py-2.5 mr-7">
<div className="h-4 w-24 rounded bg-gray-200" />
</div>
<div className="pr-0 py-2.5 mr-7">
<div className="h-4 w-24 rounded bg-gray-200" />
</div>
</div>
{/* Konten tab skeleton (default tab = forum) */}
<div className="flex flex-col gap-4">
{/* Composer / input bar */}
<div className="rounded-lg border border-card-outline p-4">
<div className="flex items-center gap-3">
<div className="h-10 w-10 shrink-0 rounded-full bg-gray-200" />
<div className="h-10 flex-1 rounded-md bg-gray-200" />
<div className="h-9 w-24 shrink-0 rounded-md bg-gray-200" />
</div>
</div>
{/* List card (forum post / participant row) */}
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-lg border border-card-outline p-4">
{/* Header: avatar + nama + timestamp */}
<div className="mb-3 flex items-center gap-3">
<div className="h-9 w-9 shrink-0 rounded-full bg-gray-200" />
<div className="flex flex-col gap-2">
<div className="h-3.5 w-32 rounded bg-gray-200" />
<div className="h-3 w-20 rounded bg-gray-200" />
</div>
<div className="ml-auto h-3 w-16 rounded bg-gray-200" />
</div>
{/* Body teks — baris terakhir sengaja lebih pendek biar natural */}
<div className="flex flex-col gap-2">
<div className="h-3 w-full rounded bg-gray-200" />
<div className="h-3 w-11/12 rounded bg-gray-200" />
<div className="h-3 w-2/3 rounded bg-gray-200" />
</div>
{/* Footer action */}
<div className="mt-4 flex gap-3">
<div className="h-7 w-20 rounded-md bg-gray-200" />
<div className="h-7 w-20 rounded-md bg-gray-200" />
</div>
</div>
))}
</div>
</div>
);
}
export default function ProjectDetailPage() {
const { slug } = useParams<{ slug: string }>();
const [activeTab, setActiveTab] = useState<'forum' | 'participants' | 'Task'>('forum');
const { project, loading, error } = useProjectDetail(slug ?? "");
if (loading) return (
<p className="p-10 flex items-center gap-2 text-[18px] text-gray-400">
<Loader2 className="w-4 h-4 animate-spin" />
Memuat project...
</p>
);
if (loading) return <ProjectDetailSkeleton/>
if (error) return <p className="p-10 text-[18px] text-red-700">{error}</p>;
@@ -27,15 +27,14 @@ export default function AddInternsModal({ isOpen, onClose, userId }: AddInternsM
if (isOpen) {
dialog.showModal();
// document.body.style.overflow = "hidden";
document.body.style.overflow = "hidden";
} else {
dialog.close();
// document.body.style.overflow = "unset";
document.body.style.overflow = "unset";
}
const handleCancel = (e: Event) => {
e.preventDefault();
onClose();
e.preventDefault()
};
dialog.addEventListener("cancel", handleCancel);
@@ -32,7 +32,6 @@ export default function ArchiveProjectDialog({
const handleCancel = (e: Event) => {
e.preventDefault();
onClose();
};
dialog.addEventListener("cancel", handleCancel);
@@ -1,10 +1,8 @@
import { CalendarDays } from "lucide-react";
import type { Project } from "../../../../types/project.types";
// ─── StatusBadge ─────────────────────────────────────────────────────────────
function StatusBadge({ status }: { status: string }) {
const isCompleted = status === "active";
return (
const isCompleted = status === "active"; return (
<span
className={`text-[10px] font-extrabold tracking-wider px-2.5 py-1 rounded-full border ${
isCompleted
@@ -24,15 +24,14 @@ export default function CompleteProjectDialog({
if (isOpen) {
dialog.showModal();
// document.body.style.overflow = "hidden";
document.body.style.overflow = "hidden";
} else {
dialog.close();
// document.body.style.overflow = "unset";
document.body.style.overflow = "unset";
}
const handleCancel = (e: Event) => {
e.preventDefault();
onClose();
};
dialog.addEventListener("cancel", handleCancel);
@@ -44,7 +44,6 @@ export default function CreateProjectModal({ isOpen, onClose, onSuccess }: Creat
const handleCancel = (e: Event) => {
e.preventDefault();
onClose();
};
dialog.addEventListener("cancel", handleCancel);
@@ -36,7 +36,6 @@ export default function CreateTaskModal({ isOpen, onClose, projectId, onSuccess
const handleCancel = (e: Event) => {
e.preventDefault();
onClose();
};
dialog.addEventListener("cancel", handleCancel);
@@ -54,7 +54,6 @@ export default function EditSubmissionModal({
const handleCancel = (e: Event) => {
e.preventDefault();
onClose();
};
dialog.addEventListener("cancel", handleCancel);
@@ -34,11 +34,22 @@ export default function EditTaskModal({ isOpen, onClose, task, onSuccess }: Edit
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (isOpen) { dialog.showModal(); document.body.style.overflow = "hidden"; }
else { dialog.close(); document.body.style.overflow = "unset"; }
const handleCancel = (e: Event) => { e.preventDefault(); onClose(); };
if (isOpen) {
dialog.showModal();
document.body.style.overflow = "hidden";
} else {
dialog.close();
document.body.style.overflow = "unset";
}
const handleCancel = (e: Event) => {
e.preventDefault();
};
dialog.addEventListener("cancel", handleCancel);
return () => { dialog.removeEventListener("cancel", handleCancel); document.body.style.overflow = "unset"; };
return () => {
dialog.removeEventListener("cancel", handleCancel);
document.body.style.overflow = "unset";
};
}, [isOpen, onClose]);
const handleSubmit = async (e: React.FormEvent) => {
@@ -31,14 +31,12 @@ interface EligibleInternTableProps {
const PER_PAGE = 5;
const BULAN_ID = [
"Januari", "Februari", "Maret", "April", "Mei", "Juni",
"Juli", "Agustus", "September", "Oktober", "November", "Desember",
];
function formatDateIndo(dateStr: string): string {
const [year, month, day] = dateStr.split("-").map(Number);
return `${day} ${BULAN_ID[month - 1]} ${year}`;
function formatDateIndo(date: string): string {
return new Intl.DateTimeFormat("id-ID", {
day: "numeric",
month: "long",
year: "numeric",
}).format(new Date(date));
}
function formatDateRange(startDate: string, endDate: string): string {
@@ -62,7 +60,6 @@ export default function EligibleInternTable({
loading,
eligibleCount,
project
// Durasi tanggal Proyek
}: EligibleInternTableProps) {
const navigate = useNavigate();
const { generate: SubmitCertificates } = useGenerateCertificates();
@@ -95,6 +92,8 @@ export default function EligibleInternTable({
id: project.id,
project_name: project.project_name,
description: project.description,
start_date: project.start_date,
end_date: project.end_date
},
};
previewCertificate(mockCert, project.certificate_template);
@@ -144,14 +143,13 @@ export default function EligibleInternTable({
await Promise.all(
result.map(async (cert) => {
const verifyUrl = `${window.location.origin}/verify-certificate/${cert.uuid}`;
const blob = await generateCertificate(
templateUrl,
cert.user.full_name,
cert.project.project_name,
cert.certificate_no,
verifyUrl,
duration
duration,
cert.uuid,
);
zip.file(`Sertifikat - ${cert.user.full_name}.png`, blob);
})
@@ -176,9 +174,11 @@ export default function EligibleInternTable({
if (loading) {
return (
<div className="lg:col-span-5 flex items-center justify-center py-20 text-gray-400 text-sm">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
Memuat data intern...
<div className="lg:col-span-5 flex flex-1">
<div className="flex h-[240px] w-full items-center justify-center rounded-xl bg-box-secondary text-font">
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Memuat data intern...
</div>
</div>
);
}
@@ -11,7 +11,7 @@ export function StatusBadge({ status }: { status: DisplayStatus }) {
return (
<span className="inline-flex items-center gap-1.5 text-xs font-semibold text-green-700 bg-green-50 border border-green-200 px-2.5 py-1 rounded-full">
<CheckCircle2 className="w-3.5 h-3.5" />
{status === "done" ? "Done" : "Submitted for grading"}
{status === "done" ? "Done" : "Submitted"}
</span>
);