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:
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,28 +8,44 @@ 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">
|
||||
<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">
|
||||
@@ -38,7 +54,6 @@ export default function ForumTab({ project }: { project: Project }) {
|
||||
))}
|
||||
</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(() => {
|
||||
|
||||
@@ -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,13 +3,39 @@ 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 { 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 (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>;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,6 +237,7 @@ export default function CertificateDetail() {
|
||||
</div>
|
||||
|
||||
{/* Right Column */}
|
||||
<div className="lg:col-span-5 lg:mt-[110px] min-w-0">
|
||||
<EligibleInternTable
|
||||
interns={internProgress}
|
||||
loading={progressLoading}
|
||||
@@ -235,5 +246,6 @@ export default function CertificateDetail() {
|
||||
/>
|
||||
</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 (
|
||||
|
||||
@@ -8,26 +8,45 @@ 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} />
|
||||
@@ -39,7 +58,6 @@ export default function MentorForumTab({ project }: { project: Project }) {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
+7
-7
@@ -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>;
|
||||
|
||||
@@ -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,10 +174,12 @@ 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" />
|
||||
<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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 |
@@ -8,6 +8,9 @@ export interface CertificateProject {
|
||||
id: number;
|
||||
project_name: string;
|
||||
description: string;
|
||||
start_date: string
|
||||
end_date: string
|
||||
|
||||
}
|
||||
|
||||
export interface Certificate {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user