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>
);
+16 -2
View File
@@ -3,12 +3,22 @@ import JSZip from "jszip";
import type { Certificate } from "../../types/certificate.types";
import { generateCertificate, resolveImageUrl } from "./SertificateGenerator";
// ── 1. Preview di tab baru ────────────────────────────────────────────────────
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 {
return `${formatDateIndo(startDate)} - ${formatDateIndo(endDate)}`;
}
// 1. Preview di tab baru
export const previewCertificate = async (
cert: Certificate,
templateUrl: string,
): Promise<void> => {
// Buka tab DULU sebelum await — supaya tidak diblokir popup blocker
const tab = window.open("", "_blank");
const blob = await generateCertificate(
@@ -16,6 +26,7 @@ export const previewCertificate = async (
cert.user.full_name,
cert.project.project_name,
cert.certificate_no,
formatDateRange(cert.project.start_date, cert.project.end_date),
cert.uuid,
);
const url = URL.createObjectURL(blob);
@@ -79,6 +90,7 @@ export const downloadCertificatePdf = async (
cert.user.full_name,
cert.project.project_name,
cert.certificate_no,
formatDateRange(cert.project.start_date, cert.project.end_date),
cert.uuid,
);
@@ -118,6 +130,7 @@ export const downloadCertificateImage = async (
cert.user.full_name,
cert.project.project_name,
cert.certificate_no,
formatDateRange(cert.project.start_date, cert.project.end_date),
cert.uuid,
);
@@ -180,6 +193,7 @@ export const downloadAllCertificatesZip = async (
cert.user.full_name,
cert.project.project_name,
cert.certificate_no,
formatDateRange(cert.project.start_date, cert.project.end_date),
cert.uuid,
);
zip.file(`Sertifikat - ${cert.user.full_name}.png`, blob);
+32 -41
View File
@@ -15,7 +15,7 @@ export function resolveImageUrl(path: string): string {
export function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
if (!src.startsWith("data:")) img.crossOrigin = "anonymous";
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Gagal load gambar: ${src}`));
img.src = resolveImageUrl(src);
@@ -30,6 +30,17 @@ export async function ensureFontsLoaded(): Promise<void> {
await document.fonts.ready;
}
const LAYOUT = {
internName: { x: 0.57, y: 0.48, size: 140, font: "Great Vibes", color: "#800000" },
internNameSmall: { x: 0.82, y: 0.889, size: 33, font: "Great Vibes", color: "#800000" },
projectName: { x: 0.815, y: 0.598, size: 46, font: "Grenze", color: "#800000" },
duration: { x: 0.692, y: 0.645, size: 48, font: "Grenze", color: "#090909" },
certNo: { x: 0.6, y: 0.305, size: 50, font: "Grenze", color: "#090909" },
certNoSmall: { x: 0.825, y: 0.91, size: 22, font: "Grenze", color: "#800000" },
qr: { x: 0.77, y: 0.715, size: 0.103 },
};
export async function generateCertificate(
templateUrl: string,
internName: string,
@@ -49,50 +60,30 @@ export async function generateCertificate(
ctx.textAlign = "center";
ctx.textBaseline = "middle";
// Nama intern — besar (Great Vibes)
ctx.font = `105px "Great Vibes", cursive`;
ctx.fillStyle = "#800000";
ctx.fillText(internName, canvas.width * 0.57, canvas.height * 0.48);
const text = (
cfg: { x: number; y: number; size: number; font: string; color: string },
value: string,
) => {
ctx.font = `400 ${cfg.size}px "${cfg.font}", serif`;
ctx.fillStyle = cfg.color;
ctx.fillText(value, canvas.width * cfg.x, canvas.height * cfg.y);
};
// Nama intern — versi kecil di pojok bawah
ctx.font = `30px "Great Vibes", cursive`;
ctx.fillStyle = "#800000";
ctx.fillText(internName, canvas.width * 0.825, canvas.height * 0.895);
// Nama project (Grenze)
ctx.font = `400 54px "Grenze", serif`;
ctx.fillStyle = "#090909";
ctx.fillText(projectName, canvas.width * 0.57, canvas.height * 0.615);
// Certificate no — besar (Grenze)
ctx.font = `50px "Grenze", serif`;
ctx.fillStyle = "#090909";
ctx.fillText(certificateNo, canvas.width * 0.666, canvas.height * 0.305);
// Certificate no — kecil di pojok bawah
ctx.font = `20px "Grenze", serif`;
ctx.fillStyle = "#800000";
ctx.fillText(certificateNo, canvas.width * 0.825, canvas.height * 0.918);
//Durasi Project — besar (Grenze)
ctx.font = `400 48px "Grenze", serif`;
ctx.fillStyle = "#090909";
ctx.fillText(ProjectDuration, canvas.width * 0.68, canvas.height * 0.66);
text(LAYOUT.internName, internName);
text(LAYOUT.internNameSmall, internName);
text(LAYOUT.projectName, projectName);
text(LAYOUT.duration, ProjectDuration);
text(LAYOUT.certNo, certificateNo);
text(LAYOUT.certNoSmall, certificateNo);
// QR code — nanti akan diganti ke alamat domain
const qrDataUrl = await QRCode.toDataURL('http://localhost:5173/verify-certificate/' + verifyUrl, {
width: 200,
margin: 1,
});
const qrImg = await loadImage(qrDataUrl);
const qrSize = canvas.width;
ctx.drawImage(
qrImg,
canvas.width * 0.855 - qrSize,
canvas.height * 0.85 - qrSize,
qrSize,
qrSize,
const qrSize = Math.round(canvas.width * LAYOUT.qr.size);
const qrDataUrl = await QRCode.toDataURL(
`${window.location.origin}/verify-certificate/${verifyUrl}`,
{ width: qrSize, margin: 1 },
);
const qrImg = await loadImage(qrDataUrl);
ctx.drawImage(qrImg, canvas.width * LAYOUT.qr.x, canvas.height * LAYOUT.qr.y, qrSize, qrSize);
return new Promise((resolve, reject) => {
canvas.toBlob(
+56 -39
View File
@@ -1,75 +1,92 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Great+Vibes&family=Grenze:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">
</head>
<body>
<canvas id="preview" style="max-width: 100%;"></canvas>
<script>
// Helper: bungkus loading image jadi Promise, biar bisa di-await
const TEMPLATE_PATH = "../../assets/template.png";
// ====== KOORDINAT (rasio terhadap ukuran template) ======
// fillText -> titik TENGAH teks (textAlign=center, textBaseline=middle)
// drawImage -> pojok KIRI-ATAS gambar
const LAYOUT = {
internName: { x: 0.57, y: 0.48, size: 140, font: "Great Vibes", color: "#800000" },
internNameSmall: { x: 0.82, y: 0.889, size: 33, font: "Great Vibes", color: "#800000" },
projectName: { x: 0.815, y: 0.598, size: 46, font: "Grenze", color: "#090909" },
duration: { x: 0.692, y: 0.645, size: 48, font: "Grenze", color: "#090909" },
certNo: { x: 0.6, y: 0.305, size: 50, font: "Grenze", color: "#090909" },
certNoSmall: { x: 0.825, y: 0.91, size: 22, font: "Grenze", color: "#800000" },
qr: { x: 0.77, y: 0.715, size: 0.103 },
};
// ====== DATA DUMMY ======
const DATA = {
internName: "Jonathan Kristina",
projectName: "Internify Proyek Humic",
duration: "20 April 2026 - 20 Juni 2026",
certNo: "CERT/XXXXXX/1",
verifyUrl: "dummy-uuid-preview",
};
function loadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => resolve(img);
img.onerror = reject;
img.onerror = () => reject(new Error("Gagal load gambar: " + src));
img.src = src;
});
}
async function draw() {
await document.fonts.ready;
// Load template sertifikat & QR dummy secara paralel
const [templateImg, qrImg] = await Promise.all([
loadImage("http://localhost:9000/uploads/1783080498359-420570422.png"),
loadImage("https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=dummy-uuid-preview")
async function ensureFontsLoaded() {
await Promise.all([
document.fonts.load('400 105px "Great Vibes"'),
document.fonts.load('400 54px "Grenze"'),
]);
await document.fonts.ready;
}
async function draw() {
await ensureFontsLoaded();
const templateImg = await loadImage(TEMPLATE_PATH);
const canvas = document.getElementById("preview");
canvas.width = templateImg.naturalWidth;
canvas.height = templateImg.naturalHeight;
const ctx = canvas.getContext("2d");
// 1. Gambar template dulu (paling belakang)
ctx.drawImage(templateImg, 0, 0);
// 2. Nama intern — Great Vibes
ctx.font = `105px "Great Vibes", cursive`;
ctx.fillStyle = "#800000";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("Jonathan Kristina", canvas.width * 0.57, canvas.height * 0.48);
ctx.font = `30px "Great Vibes", cursive`;
ctx.fillText("Jonathan Kristina", canvas.width * 0.825, canvas.height * 0.895);
const text = (cfg, value) => {
ctx.font = `400 ${cfg.size}px "${cfg.font}", serif`;
ctx.fillStyle = cfg.color;
ctx.fillText(value, canvas.width * cfg.x, canvas.height * cfg.y);
};
// 3. Nama project — Grenze
ctx.font = `400 54px "Grenze", serif`;
ctx.fillStyle = "#090909";
ctx.fillText("Internify Platform Dev", canvas.width * 0.57, canvas.height * 0.615);
text(LAYOUT.internName, DATA.internName);
text(LAYOUT.internNameSmall, DATA.internName);
text(LAYOUT.projectName, DATA.projectName);
text(LAYOUT.duration, DATA.duration);
text(LAYOUT.certNo, DATA.certNo);
text(LAYOUT.certNoSmall, DATA.certNo);
ctx.font = `400 48px "Grenze", serif`;
ctx.fillStyle = "#090909";
ctx.fillText("20 April - 20 Juni 2025", canvas.width * 0.68, canvas.height * 0.66);
// 4. Certificate no
ctx.font = `50px Grenze`;
ctx.fillText("CERT/20260609/PRJ2/USR5", canvas.width * 0.665, canvas.height * 0.305);
ctx.font = `20px Grenze`;
ctx.fillStyle = "#800000";
ctx.fillText("CERT/20260609/PRJ2/USR5", canvas.width * 0.825, canvas.height * 0.918);
const qrSize = canvas.width * 0.103;
const qrX = canvas.width * 0.855;
const qrY = canvas.height * 0.85;
ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize);
// QR
const qrSize = Math.round(canvas.width * LAYOUT.qr.size);
ctx.fillStyle = "#000";
ctx.fillRect(canvas.width * LAYOUT.qr.x, canvas.height * LAYOUT.qr.y, qrSize, qrSize);
}
draw();
draw().catch((err) => {
document.body.insertAdjacentHTML("beforeend", `<pre style="color:red">${err.message}</pre>`);
});
</script>
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

+3
View File
@@ -8,6 +8,9 @@ export interface CertificateProject {
id: number;
project_name: string;
description: string;
start_date: string
end_date: string
}
export interface Certificate {
+2 -2
View File
@@ -87,8 +87,8 @@ export interface CreateProjectPayload {
project_icon: string;
project_name: string;
description: string;
start_date: string; // "YYYY-MM-DD"
end_date: string; // "YYYY-MM-DD"
start_date: string;
end_date: string;
member_emails?: string[];
}