feat: update certificate handling and project management features

- Refactored CertificateHistory component to display dynamic certificate images.
- Modified CertificateNotAvailable component text for clarity on certificate publication.
- Enhanced CertificateForm to include file preview and clear functionality for uploaded images.
- Removed obsolete CertificatePage component to streamline mentor interface.
- Updated CertificateResult to handle project details and loading states more effectively.
- Improved HomeMentorPage structure by adjusting imports for better organization.
- Enhanced EligibleInternTable with navigation to results and improved error handling for certificate generation.
- Updated HomeMentorProject styles for consistency with design system.
- Added utility function to resolve image URLs for certificates.
- Introduced new hooks for generating certificates and managing notifications.
- Expanded project service functions to include project completion and assignable interns retrieval.
- Updated types for certificates and projects to reflect new data structures.
- Added user types for better type safety in user management.
This commit is contained in:
Kemas M. Aryadary Rasyad
2026-07-02 10:27:28 +07:00
parent 7fffc08912
commit 5dec77862c
23 changed files with 326 additions and 350 deletions
+2 -2
View File
@@ -118,8 +118,8 @@ function App() {
<Route index element={<HomeMentorPage />} />
<Route path="projects" element={<MentorProjectsPage />} />
<Route path="certificates" element={<MentorCertificatePage />} />
<Route path="certificates/:id" element={<CertificateDetail />} />
<Route path="certificates/:id/result" element={<CertificateResult />} />
<Route path="certificates/:slug" element={<CertificateDetail />} />
<Route path="certificates/:slug/result" element={<CertificateResult />} />
<Route path="intern" element={<MentorInternPage />} />
<Route path="projects/:slug" element={<MentorDetailProject />} />
<Route path="projects/:slug/:taskSlug" element={<MentorProjectsDetailPage />} />
@@ -1,8 +1,6 @@
import { useState, useEffect } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { useMyTasks } from '../../../../hooks/useProjects';
import { useCurrentUser } from '../../../../hooks/useUser';
import { useAllMentorTasks } from '../../../../hooks/useTasks';
import { useDeadlines } from '../../hooks/useTasks';
const DAY_LABELS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
const MONTH_NAMES = [
@@ -20,21 +18,7 @@ function getFirstDayOfMonth(year: number, month: number) {
export default function HomeCalendar() {
const [today, setToday] = useState(new Date());
const { user } = useCurrentUser();
const isIntern = user?.role === "intern";
const { tasks: internTasks } = useMyTasks();
const { tasks: mentorTasks } = useAllMentorTasks(!isIntern && user !== null);
const DEADLINES = isIntern
? internTasks.map(task => ({
date: new Date(task.deadline_at),
label: task.title,
}))
: mentorTasks.map(task => ({
date: new Date(task.deadline_at),
label: `[${task.project_name}] ${task.title}`,
}));
const { deadlines, loading } = useDeadlines();
useEffect(() => {
const interval = setInterval(() => {
@@ -74,13 +58,13 @@ export default function HomeCalendar() {
currentYear === today.getFullYear()
const hasDeadline = (day: number) =>
DEADLINES.some(d =>
deadlines.some(d =>
d.date.getDate() === day &&
d.date.getMonth() === currentMonth &&
d.date.getFullYear() === currentYear
)
const upcomingDeadlines = DEADLINES.filter(d => {
const upcomingDeadlines = deadlines.filter(d => {
return (
d.date.getMonth() === currentMonth &&
d.date.getFullYear() === currentYear
@@ -104,6 +88,14 @@ export default function HomeCalendar() {
return d === 0 || d === 6 // Sun or Sat
}
if (loading) {
return (
<div className="px-13 py-5 border border-card-outline rounded-2xl">
<p className="text-[11px] text-font-shade">Loading calendar...</p>
</div>
)
}
return (
<div className=" px-13 py-5 border border-card-outline rounded-2xl">
{/* Header */}
@@ -1,6 +1,6 @@
import HomeHeader from "./components/HomeHeader";
import HomeProjectList from "./components/HomeProjectList";
import HomeCalendar from "./components/HomeCalendar";
import HomeCalendar from "../HomeCalendar";
import TaskTimeline from "./components/TaskTimeline";
export default function HomeInternPage() {
@@ -4,6 +4,7 @@ import { Download, Link, Printer } from "lucide-react";
import type { ProjectDetail } from "../../../../types/project.types";
import type { Certificate } from "../../../../types/certificate.types";
import { downloadCertificateAsImage, downloadCertificateAsPdf } from "../../../utils/DownloadImagetoDevice";
import { resolveImageUrl } from "../../../utils/SertificateGenerator";
interface CertificateAvailableProps {
project: ProjectDetail | null;
@@ -62,7 +63,7 @@ export default function CertificateAvailable({ project, certificate }: Certifica
{/* Certificate Card */}
<div className="flex rounded-xl p-5 mb-1 border bg-box-primary border-box-border shadow-md items-center justify-center">
<img
src={certificate.image_path}
src={resolveImageUrl(certificate.image_path)}
alt="Certificate"
className="rounded-xl w-[550px] h-[350px] object-contain"
/>
@@ -83,7 +84,7 @@ export default function CertificateAvailable({ project, certificate }: Certifica
label: "Status",
value: (
<span className="bg-green-100 text-green-700 text-xs font-bold px-2 py-0.5 rounded-full">
{certificate.certificate_status.toUpperCase()}
{certificate.certificate_status}
</span>
),
},
@@ -1,7 +1,6 @@
import { useNavigate } from "react-router-dom";
import { History, Loader2, AlertCircle } from "lucide-react";
import type { Certificate } from "../../../../types/certificate.types";
import certificateImg from "../../../../assets/certificate.png"; //Placeholder
import { useMyCertificates } from "../../../../hooks/useCertificates";
export default function SertificateHistory() {
@@ -43,7 +42,7 @@ export default function SertificateHistory() {
>
<div className="w-full flex justify-center">
<img
src={certificateImg}
src={item.image_path}
alt="Certificate"
className="rounded-xl h-[150px] w-full object-cover"
/>
@@ -40,8 +40,8 @@ export default function CertificateNotAvailable({
<>
<h2 className="text-[37px] font-bold text-gray-900 mb-3">Selamat!</h2>
<p className="text-gray-500 text-[17px] mb-6 text-center">
Semua tugas proyek telah selesai. Klik tombol di bawah untuk{" "}
<span className="font-bold text-green-600">mengklaim sertifikat</span> kelulusan Anda.
Semua tugas proyek telah selesai. Silahkan tunggu Mentor untuk{" "}
<span className="font-bold text-green-600">Mempublish Sertifikat</span> kelulusan Anda.
</p>
{/* Progress Bar — 100% */}
@@ -53,7 +53,6 @@ export default function CertificateNotAvailable({
<div className="w-full h-3 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-green-500 rounded-full transition-all duration-700 w-full" />
</div>
<p className="text-xs text-gray-400 mt-2 text-left">Semua tugas telah diselesaikan! Silahkan tunggu untuk mendapatkan sertifikat</p>
</div>
<button
onClick={onClaim}
+73 -39
View File
@@ -4,7 +4,8 @@ import { CalendarDays, UploadCloud, CheckCircle2, XCircle, Loader2 } from "lucid
import { useUploadCertificateTemplate } from "../../../hooks/useCertificates";
import { useProjectDetail } from "../../../hooks/useProjects";
import EligibleInternTable from "./components/EligibleInternTable";
import { useProjectInternProgress } from "../../../hooks/useInternProgress"
import { useProjectInternProgress } from "../../../hooks/useInternProgress";
import { resolveImageUrl } from "../../utils/SertificateGenerator";
export default function CertificateDetail() {
const navigate = useNavigate();
@@ -17,12 +18,17 @@ export default function CertificateDetail() {
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploadSuccess, setUploadSuccess] = useState(false);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setSelectedFile(file);
setUploadSuccess(false);
// Revoke object URL lama biar tidak memory leak
if (previewUrl) URL.revokeObjectURL(previewUrl);
setPreviewUrl(URL.createObjectURL(file));
};
const handleUpload = async () => {
@@ -35,6 +41,16 @@ export default function CertificateDetail() {
}
};
const handleClearFile = () => {
setSelectedFile(null);
setUploadSuccess(false);
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
}
if (fileInputRef.current) fileInputRef.current.value = "";
};
if (loading) {
return (
<div className="flex items-center justify-center py-20 text-gray-400 text-sm">
@@ -85,30 +101,48 @@ export default function CertificateDetail() {
</label>
{/* Drop Zone */}
<div
className="border-2 border-dashed border-card-outline bg-primary-foreground rounded-xl p-6 text-center flex flex-col items-center justify-center cursor-pointer hover:bg-red-50/20 transition-colors group"
onClick={() => fileInputRef.current?.click()}
>
<div className="w-10 h-10 rounded-full bg-red-50 flex items-center justify-center mb-2 group-hover:scale-105 transition-transform">
<UploadCloud className="w-5 h-5 text-red" />
<div className="relative">
<div
className="border-2 border-dashed border-card-outline bg-primary-foreground rounded-xl p-6 text-center flex flex-col items-center justify-center cursor-pointer hover:bg-red-50/20 transition-colors group overflow-hidden"
onClick={() => !selectedFile && fileInputRef.current?.click()}
>
{previewUrl || project.certificate_template ? (
<>
<img
src={previewUrl || resolveImageUrl(project.certificate_template)}
alt="Preview template"
className="w-full h-32 object-contain rounded-lg mb-2"
/>
<p className="text-xs font-bold text-gray-800 truncate max-w-[180px]">
{selectedFile?.name}
</p>
<p className="text-[10px] text-gray-400 mt-0.5">
{selectedFile && (selectedFile.size / 1024 / 1024).toFixed(2)} MB
</p>
</>
) : (
<>
<div className="w-10 h-10 rounded-full bg-red-50 flex items-center justify-center mb-2 group-hover:scale-105 transition-transform">
<UploadCloud className="w-5 h-5 text-red" />
</div>
<p className="text-xs font-bold text-gray-800">Upload Certificate Image</p>
<p className="text-[10px] text-gray-400 mt-0.5">PNG, JPG up to 5MB</p>
</>
)}
</div>
{selectedFile ? (
<>
<p className="text-xs font-bold text-gray-800 truncate max-w-[180px]">
{selectedFile.name}
</p>
<p className="text-[10px] text-gray-400 mt-0.5">
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
</p>
</>
) : (
<>
<p className="text-xs font-bold text-gray-800">Upload Certificate Image</p>
<p className="text-[10px] text-gray-400 mt-0.5">PNG, JPG up to 5MB</p>
</>
{/* Tombol Clear */}
{selectedFile && (
<button
onClick={handleClearFile}
type="button"
className="absolute top-2 right-2 w-6 h-6 rounded-full bg-white border border-gray-200 shadow-sm flex items-center justify-center text-gray-500 hover:text-red-600 hover:border-red-200 transition-colors"
title="Hapus file"
>
<XCircle className="w-3.5 h-3.5" />
</button>
)}
</div>
<input
ref={fileInputRef}
type="file"
@@ -151,25 +185,25 @@ export default function CertificateDetail() {
)}
</button>
)}
</div>
{/* ── Project Scope ── */}
<div className="border border-card-outline rounded-xl p-5">
<p className="text-[13px] font-bold text-gray-400 uppercase tracking-widest mb-3">
Project Scope
</p>
<p className="text-sm text-gray-600 leading-relaxed">{project.description}</p>
</div>
{/* Stats: Total Intern dalam project dan jumlah intern eligible */}
<div className="grid grid-cols-2 gap-4">
<div className="border border-card-outline rounded-xl p-4">
<p className="text-2xl font-extrabold text-[#B30000]">{project.total_members}</p>
<p className="text-xs text-font font-medium mt-0.5">Total Interns</p>
{/* ── Project Scope ── */}
<div className="border border-card-outline rounded-xl p-5">
<p className="text-[13px] font-bold text-gray-400 uppercase tracking-widest mb-3">
Project Scope
</p>
<p className="text-sm text-gray-600 leading-relaxed">{project.description}</p>
</div>
<div className="border border-card-outline rounded-xl p-4">
<p className="text-2xl font-extrabold text-gray-900">{eligibleCount}</p>
<p className="text-xs text-font font-medium mt-0.5">Eligible</p>
{/* Stats: Total Intern dalam project dan jumlah intern eligible */}
<div className="grid grid-cols-2 gap-4">
<div className="border border-card-outline rounded-xl p-4">
<p className="text-2xl font-extrabold text-[#B30000]">{project.total_members}</p>
<p className="text-xs text-font font-medium mt-0.5">Total Interns</p>
</div>
<div className="border border-card-outline rounded-xl p-4">
<p className="text-2xl font-extrabold text-gray-900">{eligibleCount}</p>
<p className="text-xs text-font font-medium mt-0.5">Eligible</p>
</div>
</div>
</div>
</div>
@@ -1,232 +0,0 @@
import React, { useState } from 'react';
import {
PlusCircle,
UploadCloud,
Eye,
Download,
Trash2,
ChevronLeft,
ChevronRight,
DownloadCloud
} from 'lucide-react';
import { LuHistory, LuSendHorizontal } from 'react-icons/lu';
const initialHistoryData = [
{ id: 1, name: "Jonathan Kristina", email: "JonathanKristina@gmail.com", project: "Internify Project", date: "24 Oct 2024", initials: "JK" },
{ id: 2, name: "Ahmad Faisal", email: "ahmad.faisal@gmail.com", project: "Web Dev Fundamentals", date: "15 Sep 2024", initials: "AF" },
{ id: 3, name: "Siti Rahma", email: "siti.rahma@gmail.com", project: "UI/UX Essentials", date: "10 Aug 2024", initials: "SR" },
{ id: 4, name: "David Beckham", email: "david.b@gmail.com", project: "Engineering Intern 101", date: "05 Aug 2024", initials: "DB" },
];
const MentorCertificatePage = () => {
const [selectedIntern, setSelectedIntern] = useState("JonathanKristina@gmail.com");
const [selectedProject, setSelectedProject] = useState("Internify Project (Web Development)");
const [issueDate, setIssueDate] = useState("2024-10-24");
const [expiryDate, setExpiryDate] = useState("");
// State Data Tabel
const [historyData, setHistoryData] = useState(initialHistoryData);
const handleIssueCertificate = (e: React.FormEvent) => {
e.preventDefault();
const payload = {
intern: selectedIntern,
project: selectedProject,
issueDate,
expiryDate
};
console.log("Issuing Certificate:", payload);
alert("Certificate Issued Successfully!");
};
const handleDeleteHistory = (id: number) => {
if(confirm("Are you sure you want to delete this certificate log?")) {
setHistoryData(historyData.filter(item => item.id !== id));
}
};
return (
<div>
<div className="mb-8">
<h1 className="text-3xl md:text-4xl font-bold text-gray-900 tracking-tight">Certificates</h1>
<p className="text-sm md:text-base text-gray-500 mt-1">
Administrate, issue, and track professional certifications for all interns.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 items-start">
<div className="bg-white border border-gray-100 rounded-2xl p-6 md:p-8 shadow-sm">
<div className="flex items-center gap-3 mb-6">
<PlusCircle className="w-6 h-6 text-[#B30000]" />
<h2 className="text-xl font-bold text-gray-900 tracking-tight">Issue New Certificate</h2>
</div>
<form onSubmit={handleIssueCertificate} className="space-y-5">
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-700 tracking-wide">Select Intern</label>
<select
value={selectedIntern}
onChange={(e) => setSelectedIntern(e.target.value)}
className="w-full px-3 py-2.5 bg-white border border-gray-200 rounded-lg text-sm text-gray-800 focus:outline-none focus:border-red-600 focus:ring-1 focus:ring-red-600 font-medium"
>
<option value="JonathanKristina@gmail.com">JonathanKristina@gmail.com</option>
<option value="ahmad.faisal@gmail.com">ahmad.faisal@gmail.com</option>
<option value="siti.rahma@gmail.com">siti.rahma@gmail.com</option>
</select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-700 tracking-wide">Select Project / Course</label>
<select
value={selectedProject}
onChange={(e) => setSelectedProject(e.target.value)}
className="w-full px-3 py-2.5 bg-white border border-gray-200 rounded-lg text-sm text-gray-800 focus:outline-none focus:border-red-600 focus:ring-1 focus:ring-red-600 font-medium"
>
<option value="Internify Project (Web Development)">Internify Project (Web Development)</option>
<option value="Web Dev Fundamentals">Web Dev Fundamentals</option>
<option value="UI/UX Essentials">UI/UX Essentials</option>
</select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-700 tracking-wide">Certificate Image</label>
<div className="border-2 border-dashed border-red-200/60 bg-red-50/10 rounded-xl p-6 text-center flex flex-col items-center justify-center cursor-pointer hover:bg-red-50/20 transition-colors group">
<div className="w-10 h-10 rounded-full bg-red-50 flex items-center justify-center mb-2 group-hover:scale-105 transition-transform">
<UploadCloud className="w-5 h-5 text-[#B30000]" />
</div>
<p className="text-xs font-bold text-gray-800">Upload Certificate Image</p>
<p className="text-[10px] text-gray-400 mt-0.5">PNG, JPG up to 5MB</p>
</div>
</div>
{/* Date Grid */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-700 tracking-wide">Issue Date</label>
<input
type="date"
required
value={issueDate}
onChange={(e) => setIssueDate(e.target.value)}
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-lg text-xs font-medium text-gray-800 focus:outline-none focus:border-red-600 focus:ring-1 focus:ring-red-600"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-500 tracking-wide">Expiry (Optional)</label>
<input
type="date"
value={expiryDate}
onChange={(e) => setExpiryDate(e.target.value)}
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-lg text-xs font-medium text-gray-800 focus:outline-none focus:border-red-600 focus:ring-1 focus:ring-red-600"
/>
</div>
</div>
{/* Submit Button */}
<button
type="submit"
className="w-full py-3 mt-4 bg-[#B30000] hover:bg-[#990000] text-white text-sm font-bold rounded-xl transition-colors shadow-sm flex items-center justify-center gap-2"
>
<LuSendHorizontal className="w-4 h-4" />
Issue Certificate
</button>
</form>
</div>
{/* Kanan: Issued Certificates History (8 Kolom) */}
<div className="bg-white border border-gray-100 rounded-2xl shadow-sm overflow-hidden flex flex-col justify-between h-full">
<div>
{/* Header Table */}
<div className="flex items-center justify-between px-6 py-5 border-b border-gray-50">
<div className="flex items-center gap-2.5">
<LuHistory className="w-5 h-5 text-red-600 stroke-[2.5]" />
<h2 className="text-lg font-bold text-gray-900 tracking-tight">Issued Certificates History</h2>
</div>
<button className="text-xs font-bold text-[#B30000] hover:text-[#990000] flex items-center gap-1.5 bg-red-50/50 px-3 py-1.5 rounded-lg border border-red-100 transition-colors">
<span>Export CSV</span>
<DownloadCloud className="w-3.5 h-3.5" />
</button>
</div>
{/* Responsive Table Wrapper */}
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-gray-50/70 border-b border-gray-100">
<th className="px-6 py-3.5 text-[10.5px] font-bold tracking-wider text-gray-400 uppercase">Intern Name</th>
<th className="px-6 py-3.5 text-[10.5px] font-bold tracking-wider text-gray-400 uppercase">Project</th>
<th className="px-6 py-3.5 text-[10.5px] font-bold tracking-wider text-gray-400 uppercase">Issue Date</th>
<th className="px-6 py-3.5 text-[10.5px] font-bold tracking-wider text-gray-400 uppercase text-center">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{historyData.map((row) => (
<tr key={row.id} className="hover:bg-gray-50/40 transition-colors">
{/* Kolom Nama Intern + Avatar Ringkas */}
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gray-100 border border-gray-200/60 flex items-center justify-center text-xs font-bold text-gray-500 flex-shrink-0">
{row.initials}
</div>
<div>
<p className="text-sm font-bold text-gray-900 leading-tight">{row.name}</p>
<p className="text-[11px] text-gray-400 mt-0.5">{row.email}</p>
</div>
</div>
</td>
{/* Kolom Nama Proyek */}
<td className="px-6 py-4">
<p className="text-sm text-gray-600 font-medium">{row.project}</p>
</td>
{/* Kolom Tanggal Terbit */}
<td className="px-6 py-4">
<p className="text-sm text-gray-500 font-medium">{row.date}</p>
</td>
{/* Kolom Deretan Aksi */}
<td className="px-6 py-4">
<div className="flex items-center justify-center gap-1">
<button className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-md transition-colors" title="View Certificate">
<Eye className="w-4 h-4" />
</button>
<button className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-md transition-colors" title="Download">
<Download className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteHistory(row.id)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-md transition-colors"
title="Delete Log"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Bagian Footer Tabel & Paginasi */}
<div className="px-6 py-4 border-t border-gray-100 bg-gray-50/30 flex flex-col sm:flex-row gap-3 items-center justify-between text-xs font-medium text-gray-500">
<p>Showing 1 to {historyData.length} of 1,284 entries</p>
<div className="flex items-center gap-1">
<button className="p-1 border border-gray-200 rounded-md bg-white text-gray-400 hover:bg-gray-50 disabled:opacity-50">
<ChevronLeft className="w-4 h-4" />
</button>
<button className="w-7 h-7 flex items-center justify-center rounded-md bg-[#B30000] text-white font-bold shadow-sm">1</button>
<button className="w-7 h-7 flex items-center justify-center rounded-md bg-white border border-gray-200 text-gray-600 hover:bg-gray-50">2</button>
<button className="w-7 h-7 flex items-center justify-center rounded-md bg-white border border-gray-200 text-gray-600 hover:bg-gray-50">3</button>
<button className="p-1 border border-gray-200 rounded-md bg-white text-gray-400 hover:bg-gray-50">
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
</div>
);
}
export default MentorCertificatePage;
@@ -10,7 +10,8 @@ import {
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { useProjectCertificates } from "../../../hooks/useCertificates";
import { useProjectCertificates } from "../../../hooks/useCertificates"; //Problemnya cuma role "admin" yang bisa
import { useProjectDetail } from "../../../hooks/useProjects";
import type { Certificate } from "../../../types/certificate.types";
import { useParams } from "react-router-dom";
@@ -97,10 +98,17 @@ function CertificateResultSkeleton() {
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function CertificateResult() {
const { id } = useParams<{ id: string }>();
const { slug } = useParams<{ slug: string }>();
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const {certificates, loading, error} = useProjectCertificates(Number(id));
const { project, loading: projectLoading, error: projectError } = useProjectDetail(slug ?? "");
const { certificates, loading: certLoading, error: certError } = useProjectCertificates(
project?.id ?? 0
);
const loading = projectLoading || certLoading;
const error = projectError || certError;
if (loading) return <CertificateResultSkeleton />;
if (error) return <div className="text-sm text-red-500 p-4">Gagal memuat data sertifikat.</div>;
@@ -262,7 +270,7 @@ export default function CertificateResult() {
</div>
{/* Preview Card (placeholder — gambar belum diimplementasi) */}
<div className="xl:w-64 w-full flex-shrink-0 rounded-2xl overflow-hidden bg-gradient-to-br from-gray-700 via-[#7a1010] to-[#B30000] shadow-md min-h-[200px] flex flex-col justify-end p-5">
{/* <div className="xl:w-64 w-full flex-shrink-0 rounded-2xl overflow-hidden bg-gradient-to-br from-gray-700 via-[#7a1010] to-[#B30000] shadow-md min-h-[200px] flex flex-col justify-end p-5">
<div className="mt-auto">
<p className="text-white font-extrabold text-base leading-snug">
Institutional Excellence
@@ -271,7 +279,7 @@ export default function CertificateResult() {
Certified via Mentor Portal v2.4
</p>
</div>
</div>
</div> */}
</div>
</div>
@@ -1,5 +1,5 @@
import HomeHeader from '../intern/components/HomeHeader'
import HomeCalendar from '../intern/components/HomeCalendar'
import HomeCalendar from '../HomeCalendar'
import HomeMentorProject from './components/HomeMentorProject'
const HomeMentorPage = () => {
@@ -78,7 +78,8 @@ export default function EligibleInternTable({
};
const handleGenerate = async () => {
if (!project?.template_sertificate) {
// NOTE: DI RESPONSE GAK DIKASIH TAHU DIMANA SIMPAN TEMPLATE SERTIF NYA
if (!project?.certificate_template) {
setGenerateError("Template sertifikat belum diupload.");
return;
}
@@ -98,7 +99,7 @@ export default function EligibleInternTable({
await Promise.all(
selectedInterns.map(async (intern) => {
const blob = await generateCertificate(
project.template_sertificate,
project.certificate_template,
intern.full_name
);
// Nama file: "Sertifikat - Nama Intern.png"
@@ -286,7 +287,12 @@ export default function EligibleInternTable({
)}
{/* Bottom Action Bar */}
<div className="flex items-center justify-end">
<div className="flex items-center justify-between">
<button
className="text-gray-700 hover:underline"
onClick={()=> navigate(`/mentor/certificates/${project?.slug}/result`)}>
Ke Hasil
</button>
<button
onClick={handleGenerate}
disabled={generating || selected.size === 0}
@@ -304,6 +310,7 @@ export default function EligibleInternTable({
</>
)}
</button>
</div>
</div>
);
@@ -1,5 +1,4 @@
import { useNavigate } from 'react-router-dom'
import { mockProjects } from '../../../../lib/mockData'
import { useState } from 'react';
import { Plus } from 'lucide-react';
import CreateProjectModal from './CreateProjectDialog';
@@ -20,9 +19,9 @@ const HomeMentorProject = () => {
<div className='flex flex-row justify-between mb-3 px-5 items-center'>
<h2 className="text-lg font-semibold text-gray-800 mb-4">Projects</h2>
<button
className='bg-gray-300 p-1.5 rounded-lg font-bold hover:bg-gray-400'
className='bg-card p-1.5 rounded-lg font-bold hover:bg-card-hover border border-card-outline'
onClick={openDialog} >
<span className='text-sm flex flex-row justify-between items-center gap-3.5'>
<span className='text-sm flex flex-row px-1 justify-between items-center gap-3.5'>
<Plus className='w-4 h-4' />
Add
</span>
+14 -3
View File
@@ -1,4 +1,4 @@
// src/utils/certificateGenerator.ts
import api from "../../lib/api";
// ── Konstanta posisi nama di template ─────────────────────────────────────────
const NAME_Y_RATIO = 0.55; // posisi Y = 55% dari tinggi canvas
@@ -6,14 +6,25 @@ const NAME_FONT_SIZE = 48; // px
const NAME_FONT_FAMILY = "Georgia, serif";
const NAME_COLOR = "#1a1a1a";
// src/utils/certificateGenerator.ts
export function resolveImageUrl(path: string): string {
if (!path) return "";
if (path.startsWith("http://") || path.startsWith("https://")) {
return path;
}
const apiBaseURL = api.defaults.baseURL ?? "";
const origin = new URL(apiBaseURL).origin;
return `${origin}${path.startsWith("/") ? "" : "/"}${path}`;
}
// ── Load Image dari URL ────────────────────────────────────────────────────────
export function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous"; // penting untuk URL eksternal (Supabase, dll)
img.crossOrigin = "anonymous";
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Gagal load gambar: ${src}`));
img.src = src;
img.src = resolveImageUrl(src);
});
}
+47
View File
@@ -9,7 +9,9 @@ import {
getCertificatesByProject,
verifyCertificateByUuid,
uploadCertificateTemplate,
generateCertificate
} from "../services/CertificateService";
import { AxiosError } from "axios";
// GET /certificate-api/my-certificates
export const useMyCertificates = () => {
@@ -119,6 +121,51 @@ export const useClaimCertificate = () => {
return { claim, loading, error };
};
export const useGenerateCertificates = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const generate = async (id_project: number, id_users: number[]): Promise<boolean> => {
setLoading(true);
setError(null);
setSuccess(false);
try {
await generateCertificate(id_project, id_users);
setSuccess(true);
return true;
} catch (err) {
if (err instanceof AxiosError) {
switch (err.response?.status) {
case 400:
setError("Ada intern yang tidak eligible atau parameter tidak lengkap.");
break;
case 403:
setError("Hanya mentor/admin project ini yang bisa generate sertifikat.");
break;
case 409:
setError("Sertifikat sudah pernah di-generate untuk satu atau lebih intern.");
break;
default:
setError("Gagal generate sertifikat.");
}
} else {
setError("Gagal generate sertifikat.");
}
return false;
} finally {
setLoading(false);
}
};
const reset = () => {
setError(null);
setSuccess(false);
};
return { generate, loading, error, success, reset };
};
// POST /certificate-api/projects/{id_project}/template
export const useUploadCertificateTemplate = () => {
const [loading, setLoading] = useState(false);
+6 -6
View File
@@ -1,14 +1,14 @@
// hooks/useNotifications.ts
import { useState, useEffect, useCallback } from "react";
import type { Notification } from "../types/certificate.types";
import {
getNotifications,
markAllNotificationsRead,
markNotificationRead,
markAllAsRead,
markAsRead,
} from "../services/NotificationService";
import type { BackendNotification } from "../types/notification.types";
export const useNotifications = () => {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [notifications, setNotifications] = useState<BackendNotification[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -24,14 +24,14 @@ export const useNotifications = () => {
const markAllRead = async () => {
try {
await markAllNotificationsRead();
await markAllAsRead();
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
} catch { setError("Gagal menandai semua notifikasi."); }
};
const markOneRead = async (id: number) => {
try {
await markNotificationRead(id);
await markAsRead(id);
setNotifications(prev =>
prev.map(n => n.id === id ? { ...n, is_read: true } : n)
);
+42 -1
View File
@@ -8,6 +8,7 @@ import type {
CreateProjectPayload,
UpdateProjectPayload,
AssignMemberPayload,
AssignableIntern,
ProjectMember
} from "../types/project.types";
import {
@@ -22,7 +23,9 @@ import {
archiveProject,
assignMember,
getProjectMembers,
removeMember
removeMember,
getAssignableInterns,
completeProject
} from "../services/ProjectService";
import { useMemo } from "react";
@@ -158,6 +161,24 @@ export const useAllInterns = () => {
return { interns, loading, error, refetch };
};
export const useAssignableInterns = () => {
const [AssignableInterns, setAssignableInterns] = useState<AssignableIntern[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refetch = useCallback(() => {
setLoading(true);
getAssignableInterns()
.then(setAssignableInterns)
.catch(() => setError("Gagal memuat data assignable interns."))
.finally(() => setLoading(false));
}, []);
useEffect(() => { refetch(); }, [refetch]);
return { interns: AssignableInterns, loading, error, refetch };
}
// POST /project-api/add
export const useCreateProject = () => {
const [loading, setLoading] = useState(false);
@@ -232,6 +253,26 @@ export const useArchiveProject = () => {
return { archive, loading, error };
};
export const useCompleteProject = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const complete = async (id: string | number) => {
setLoading(true);
setError(null);
try {
return await completeProject(id);
} catch {
setError("Gagal menyelesaikan project.");
return null;
} finally {
setLoading(false);
}
return { complete, loading, error };
}
}
// POST /project-api/assign-member
export const useAssignMember = () => {
const [loading, setLoading] = useState(false);
+38 -1
View File
@@ -1,5 +1,5 @@
// hooks/useTasks.ts
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useMemo } from "react";
import type { ProjectTask, ProjectTaskDetail, CreateTaskPayload, UpdateTaskPayload, TaskSubmissionData, AdminTaskDetail } from "../types/task.types";
import {
getProjectTasks,
@@ -16,6 +16,8 @@ import {
getTaskSubmissions,
} from "../services/TaskService";
import type { MentorTaskItem } from "../types/task.types";
import { useCurrentUser } from "./useUser";
import { useMyTasks } from "./useProjects";
// GET /task-api/projects/{id_project}/tasks
export const useProjectTasks = (projectId: string) => {
@@ -219,4 +221,39 @@ export const useAllMentorTasks = (enabled: boolean) => {
}, [enabled]);
return { tasks, loading, error };
};
export interface Deadline {
date: Date;
label: string;
}
export const useDeadlines = () => {
const { user, loading: userLoading, error: userError } = useCurrentUser();
const isIntern = user?.role === "intern";
const { tasks: internTasks, loading: internLoading, error: internError } = useMyTasks();
const { tasks: mentorTasks, loading: mentorLoading, error: mentorError } = useAllMentorTasks(
!isIntern && user !== null
);
const deadlines: Deadline[] = useMemo(() => {
if (!user) return [];
return isIntern
? internTasks.map((task) => ({
date: new Date(task.deadline_at),
label: task.title,
}))
: mentorTasks.map((task) => ({
date: new Date(task.deadline_at),
label: `[${task.project_name}] ${task.title}`,
}));
}, [isIntern, internTasks, mentorTasks, user]);
// Loading: tunggu user dulu, baru tunggu task yang relevan dengan rolenya
const loading = userLoading || (isIntern ? internLoading : mentorLoading);
const error = userError || (isIntern ? internError : mentorError);
return { deadlines, loading, error, isIntern };
};
+4 -24
View File
@@ -3,28 +3,8 @@ import { useState, useEffect, useCallback } from "react";
import { loginUser, getProfile, updateProfile } from "../services/UserService";
import type { Mahasiswa } from '../types/project.types';
import { getMahasiswa } from '../services/MahasiswaService';
import type { CurrentUser, UpdateProfilePayload } from "../types/user.types";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface CurrentUser {
id: number;
full_name: string;
nama_depan: string;
nama_belakang: string;
email: string;
role: "mentor" | "intern" | "admin";
professional_bio?: string;
profile_picture?: string | null;
}
export interface UpdateProfilePayload {
full_name?: string;
email?: string;
professional_bio?: string;
profile_picture?: File | null;
}
// ─── useCurrentUser ───────────────────────────────────────────────────────────
// GET /auth-api/me — ambil data user yang sedang login
export const useCurrentUser = () => {
const [user, setUser] = useState<CurrentUser | null>(null);
@@ -47,7 +27,6 @@ export const useCurrentUser = () => {
return { user, loading, error, refetch };
};
// ─── useLogin ────────────────────────────────────────────────────────────────
// POST /auth-api/login
export const useLogin = () => {
const [loading, setLoading] = useState(false);
@@ -75,7 +54,6 @@ export const useLogin = () => {
return { login, loading, error, setError };
};
// ─── useUpdateProfile ────────────────────────────────────────────────────────
// PATCH /auth-api/update-profile
export const useUpdateProfile = () => {
const [loading, setLoading] = useState(false);
@@ -118,4 +96,6 @@ export const useMahasiswa = () => {
useEffect(() => { refetch(); }, [refetch]);
return { mahasiswa, loading, error, refetch };
};
};
export const useAssignableInterns = () => {}
+5
View File
@@ -12,6 +12,11 @@ export const claimCertificate = async (id_project: number): Promise<Certificate>
return res.data.data;
};
export const generateCertificate = async (id_project: number, id_users: number[]): Promise<void> => {
const res = await api.post("/certificate-api/generate", { id_project, id_users });
return res.data.data;
};
// GET /certificate-api/my-certificates
export const getMyCertificates = async (): Promise<Certificate[]> => {
const res = await api.get("/certificate-api/my-certificates");
+16 -1
View File
@@ -9,7 +9,8 @@ import type {
UpdateProjectPayload,
AssignMemberPayload,
MemberAssignment,
ProjectMember
ProjectMember,
AssignableIntern
} from "../types/project.types";
// POST /project-api/add
@@ -84,6 +85,14 @@ export const archiveProject = async (
return res.data.data;
};
export const completeProject = async (
id: string | number
): Promise<ProjectDetail> => {
const res = await api.post(`/project-api/complete/${id}`);
return res.data.data;
};
// GET /project-api/my-projects (intern only)
export const getMyProjects = async (): Promise<ProjectDetail[]> => {
const res = await api.get("/project-api/my-projects");
@@ -108,6 +117,12 @@ export const getAllInterns = async (): Promise<InternDetail[]> => {
return res.data.data;
};
// GET /project-api/assignable-interns
export const getAssignableInterns = async (): Promise<AssignableIntern[]> => {
const res = await api.get("/project-api/assignable-interns");
return res.data.data;
}
// POST /project-api/assign-member (admin/mentor only)
export const assignMember = async (
payload: AssignMemberPayload
+5
View File
@@ -32,6 +32,11 @@ export interface CertificateVerifyResult {
created_at: string;
}
export interface GenerateCertificatePayload {
id_project: number;
id_users: number[];
}
export interface CertificateTemplate {
id_project: number;
certificate_template: string;
+12 -1
View File
@@ -34,7 +34,7 @@ export interface Project {
export interface ProjectDetail extends Project {
members: ProjectMember[];
tasks: ProjectTask[];
template_sertificate: string;
certificate_template: string;
}
// Response GET /project-api/my-tasks
@@ -70,6 +70,17 @@ export interface InternDetail {
avatar: string | null;
}
export interface AssignableIntern {
id: number;
full_name: string;
email: string;
profesional_bio: string;
position: string;
kelompok_peminatan: string;
is_active: boolean;
is_assignable: boolean;
}
// Payload POST /project-api/add
export interface CreateProjectPayload {
project_icon: string;
+17
View File
@@ -0,0 +1,17 @@
export interface CurrentUser {
id: number;
full_name: string;
nama_depan: string;
nama_belakang: string;
email: string;
role: "mentor" | "intern" | "admin";
professional_bio?: string;
profile_picture?: string | null;
}
export interface UpdateProfilePayload {
full_name?: string;
email?: string;
professional_bio?: string;
profile_picture?: File | null;
}