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:
+2
-2
@@ -118,8 +118,8 @@ function App() {
|
|||||||
<Route index element={<HomeMentorPage />} />
|
<Route index element={<HomeMentorPage />} />
|
||||||
<Route path="projects" element={<MentorProjectsPage />} />
|
<Route path="projects" element={<MentorProjectsPage />} />
|
||||||
<Route path="certificates" element={<MentorCertificatePage />} />
|
<Route path="certificates" element={<MentorCertificatePage />} />
|
||||||
<Route path="certificates/:id" element={<CertificateDetail />} />
|
<Route path="certificates/:slug" element={<CertificateDetail />} />
|
||||||
<Route path="certificates/:id/result" element={<CertificateResult />} />
|
<Route path="certificates/:slug/result" element={<CertificateResult />} />
|
||||||
<Route path="intern" element={<MentorInternPage />} />
|
<Route path="intern" element={<MentorInternPage />} />
|
||||||
<Route path="projects/:slug" element={<MentorDetailProject />} />
|
<Route path="projects/:slug" element={<MentorDetailProject />} />
|
||||||
<Route path="projects/:slug/:taskSlug" element={<MentorProjectsDetailPage />} />
|
<Route path="projects/:slug/:taskSlug" element={<MentorProjectsDetailPage />} />
|
||||||
|
|||||||
+12
-20
@@ -1,8 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
import { useMyTasks } from '../../../../hooks/useProjects';
|
import { useDeadlines } from '../../hooks/useTasks';
|
||||||
import { useCurrentUser } from '../../../../hooks/useUser';
|
|
||||||
import { useAllMentorTasks } from '../../../../hooks/useTasks';
|
|
||||||
|
|
||||||
const DAY_LABELS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
|
const DAY_LABELS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
|
||||||
const MONTH_NAMES = [
|
const MONTH_NAMES = [
|
||||||
@@ -20,21 +18,7 @@ function getFirstDayOfMonth(year: number, month: number) {
|
|||||||
|
|
||||||
export default function HomeCalendar() {
|
export default function HomeCalendar() {
|
||||||
const [today, setToday] = useState(new Date());
|
const [today, setToday] = useState(new Date());
|
||||||
const { user } = useCurrentUser();
|
const { deadlines, loading } = useDeadlines();
|
||||||
|
|
||||||
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}`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
@@ -74,13 +58,13 @@ export default function HomeCalendar() {
|
|||||||
currentYear === today.getFullYear()
|
currentYear === today.getFullYear()
|
||||||
|
|
||||||
const hasDeadline = (day: number) =>
|
const hasDeadline = (day: number) =>
|
||||||
DEADLINES.some(d =>
|
deadlines.some(d =>
|
||||||
d.date.getDate() === day &&
|
d.date.getDate() === day &&
|
||||||
d.date.getMonth() === currentMonth &&
|
d.date.getMonth() === currentMonth &&
|
||||||
d.date.getFullYear() === currentYear
|
d.date.getFullYear() === currentYear
|
||||||
)
|
)
|
||||||
|
|
||||||
const upcomingDeadlines = DEADLINES.filter(d => {
|
const upcomingDeadlines = deadlines.filter(d => {
|
||||||
return (
|
return (
|
||||||
d.date.getMonth() === currentMonth &&
|
d.date.getMonth() === currentMonth &&
|
||||||
d.date.getFullYear() === currentYear
|
d.date.getFullYear() === currentYear
|
||||||
@@ -104,6 +88,14 @@ export default function HomeCalendar() {
|
|||||||
return d === 0 || d === 6 // Sun or Sat
|
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 (
|
return (
|
||||||
<div className=" px-13 py-5 border border-card-outline rounded-2xl">
|
<div className=" px-13 py-5 border border-card-outline rounded-2xl">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import HomeHeader from "./components/HomeHeader";
|
import HomeHeader from "./components/HomeHeader";
|
||||||
import HomeProjectList from "./components/HomeProjectList";
|
import HomeProjectList from "./components/HomeProjectList";
|
||||||
import HomeCalendar from "./components/HomeCalendar";
|
import HomeCalendar from "../HomeCalendar";
|
||||||
import TaskTimeline from "./components/TaskTimeline";
|
import TaskTimeline from "./components/TaskTimeline";
|
||||||
|
|
||||||
export default function HomeInternPage() {
|
export default function HomeInternPage() {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Download, Link, Printer } from "lucide-react";
|
|||||||
import type { ProjectDetail } from "../../../../types/project.types";
|
import type { ProjectDetail } from "../../../../types/project.types";
|
||||||
import type { Certificate } from "../../../../types/certificate.types";
|
import type { Certificate } from "../../../../types/certificate.types";
|
||||||
import { downloadCertificateAsImage, downloadCertificateAsPdf } from "../../../utils/DownloadImagetoDevice";
|
import { downloadCertificateAsImage, downloadCertificateAsPdf } from "../../../utils/DownloadImagetoDevice";
|
||||||
|
import { resolveImageUrl } from "../../../utils/SertificateGenerator";
|
||||||
|
|
||||||
interface CertificateAvailableProps {
|
interface CertificateAvailableProps {
|
||||||
project: ProjectDetail | null;
|
project: ProjectDetail | null;
|
||||||
@@ -62,7 +63,7 @@ export default function CertificateAvailable({ project, certificate }: Certifica
|
|||||||
{/* Certificate Card */}
|
{/* Certificate Card */}
|
||||||
<div className="flex rounded-xl p-5 mb-1 border bg-box-primary border-box-border shadow-md items-center justify-center">
|
<div className="flex rounded-xl p-5 mb-1 border bg-box-primary border-box-border shadow-md items-center justify-center">
|
||||||
<img
|
<img
|
||||||
src={certificate.image_path}
|
src={resolveImageUrl(certificate.image_path)}
|
||||||
alt="Certificate"
|
alt="Certificate"
|
||||||
className="rounded-xl w-[550px] h-[350px] object-contain"
|
className="rounded-xl w-[550px] h-[350px] object-contain"
|
||||||
/>
|
/>
|
||||||
@@ -83,7 +84,7 @@ export default function CertificateAvailable({ project, certificate }: Certifica
|
|||||||
label: "Status",
|
label: "Status",
|
||||||
value: (
|
value: (
|
||||||
<span className="bg-green-100 text-green-700 text-xs font-bold px-2 py-0.5 rounded-full">
|
<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>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { History, Loader2, AlertCircle } from "lucide-react";
|
import { History, Loader2, AlertCircle } from "lucide-react";
|
||||||
import type { Certificate } from "../../../../types/certificate.types";
|
import type { Certificate } from "../../../../types/certificate.types";
|
||||||
import certificateImg from "../../../../assets/certificate.png"; //Placeholder
|
|
||||||
import { useMyCertificates } from "../../../../hooks/useCertificates";
|
import { useMyCertificates } from "../../../../hooks/useCertificates";
|
||||||
|
|
||||||
export default function SertificateHistory() {
|
export default function SertificateHistory() {
|
||||||
@@ -43,7 +42,7 @@ export default function SertificateHistory() {
|
|||||||
>
|
>
|
||||||
<div className="w-full flex justify-center">
|
<div className="w-full flex justify-center">
|
||||||
<img
|
<img
|
||||||
src={certificateImg}
|
src={item.image_path}
|
||||||
alt="Certificate"
|
alt="Certificate"
|
||||||
className="rounded-xl h-[150px] w-full object-cover"
|
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>
|
<h2 className="text-[37px] font-bold text-gray-900 mb-3">Selamat!</h2>
|
||||||
<p className="text-gray-500 text-[17px] mb-6 text-center">
|
<p className="text-gray-500 text-[17px] mb-6 text-center">
|
||||||
Semua tugas proyek telah selesai. Klik tombol di bawah untuk{" "}
|
Semua tugas proyek telah selesai. Silahkan tunggu Mentor untuk{" "}
|
||||||
<span className="font-bold text-green-600">mengklaim sertifikat</span> kelulusan Anda.
|
<span className="font-bold text-green-600">Mempublish Sertifikat</span> kelulusan Anda.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Progress Bar — 100% */}
|
{/* 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="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 className="h-full bg-green-500 rounded-full transition-all duration-700 w-full" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-400 mt-2 text-left">Semua tugas telah diselesaikan! Silahkan tunggu untuk mendapatkan sertifikat</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClaim}
|
onClick={onClaim}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { CalendarDays, UploadCloud, CheckCircle2, XCircle, Loader2 } from "lucid
|
|||||||
import { useUploadCertificateTemplate } from "../../../hooks/useCertificates";
|
import { useUploadCertificateTemplate } from "../../../hooks/useCertificates";
|
||||||
import { useProjectDetail } from "../../../hooks/useProjects";
|
import { useProjectDetail } from "../../../hooks/useProjects";
|
||||||
import EligibleInternTable from "./components/EligibleInternTable";
|
import EligibleInternTable from "./components/EligibleInternTable";
|
||||||
import { useProjectInternProgress } from "../../../hooks/useInternProgress"
|
import { useProjectInternProgress } from "../../../hooks/useInternProgress";
|
||||||
|
import { resolveImageUrl } from "../../utils/SertificateGenerator";
|
||||||
|
|
||||||
export default function CertificateDetail() {
|
export default function CertificateDetail() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -17,12 +18,17 @@ export default function CertificateDetail() {
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
const [uploadSuccess, setUploadSuccess] = useState(false);
|
const [uploadSuccess, setUploadSuccess] = useState(false);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setSelectedFile(file);
|
setSelectedFile(file);
|
||||||
setUploadSuccess(false);
|
setUploadSuccess(false);
|
||||||
|
|
||||||
|
// Revoke object URL lama biar tidak memory leak
|
||||||
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||||
|
setPreviewUrl(URL.createObjectURL(file));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpload = async () => {
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-20 text-gray-400 text-sm">
|
<div className="flex items-center justify-center py-20 text-gray-400 text-sm">
|
||||||
@@ -85,30 +101,48 @@ export default function CertificateDetail() {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
{/* Drop Zone */}
|
{/* Drop Zone */}
|
||||||
<div
|
<div className="relative">
|
||||||
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"
|
<div
|
||||||
onClick={() => fileInputRef.current?.click()}
|
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()}
|
||||||
<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" />
|
{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>
|
</div>
|
||||||
{selectedFile ? (
|
|
||||||
<>
|
{/* Tombol Clear */}
|
||||||
<p className="text-xs font-bold text-gray-800 truncate max-w-[180px]">
|
{selectedFile && (
|
||||||
{selectedFile.name}
|
<button
|
||||||
</p>
|
onClick={handleClearFile}
|
||||||
<p className="text-[10px] text-gray-400 mt-0.5">
|
type="button"
|
||||||
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
|
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"
|
||||||
</p>
|
title="Hapus file"
|
||||||
</>
|
>
|
||||||
) : (
|
<XCircle className="w-3.5 h-3.5" />
|
||||||
<>
|
</button>
|
||||||
<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>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -151,25 +185,25 @@ export default function CertificateDetail() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Project Scope ── */}
|
{/* ── Project Scope ── */}
|
||||||
<div className="border border-card-outline rounded-xl p-5">
|
<div className="border border-card-outline rounded-xl p-5">
|
||||||
<p className="text-[13px] font-bold text-gray-400 uppercase tracking-widest mb-3">
|
<p className="text-[13px] font-bold text-gray-400 uppercase tracking-widest mb-3">
|
||||||
Project Scope
|
Project Scope
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-600 leading-relaxed">{project.description}</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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="border border-card-outline rounded-xl p-4">
|
|
||||||
<p className="text-2xl font-extrabold text-gray-900">{eligibleCount}</p>
|
{/* Stats: Total Intern dalam project dan jumlah intern eligible */}
|
||||||
<p className="text-xs text-font font-medium mt-0.5">Eligible</p>
|
<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>
|
</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,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
} from "lucide-react";
|
} 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 type { Certificate } from "../../../types/certificate.types";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
|
|
||||||
@@ -97,10 +98,17 @@ function CertificateResultSkeleton() {
|
|||||||
|
|
||||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||||
export default function CertificateResult() {
|
export default function CertificateResult() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
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 (loading) return <CertificateResultSkeleton />;
|
||||||
if (error) return <div className="text-sm text-red-500 p-4">Gagal memuat data sertifikat.</div>;
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Preview Card (placeholder — gambar belum diimplementasi) */}
|
{/* 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">
|
<div className="mt-auto">
|
||||||
<p className="text-white font-extrabold text-base leading-snug">
|
<p className="text-white font-extrabold text-base leading-snug">
|
||||||
Institutional Excellence
|
Institutional Excellence
|
||||||
@@ -271,7 +279,7 @@ export default function CertificateResult() {
|
|||||||
Certified via Mentor Portal v2.4
|
Certified via Mentor Portal v2.4
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> */}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import HomeHeader from '../intern/components/HomeHeader'
|
import HomeHeader from '../intern/components/HomeHeader'
|
||||||
import HomeCalendar from '../intern/components/HomeCalendar'
|
import HomeCalendar from '../HomeCalendar'
|
||||||
import HomeMentorProject from './components/HomeMentorProject'
|
import HomeMentorProject from './components/HomeMentorProject'
|
||||||
|
|
||||||
const HomeMentorPage = () => {
|
const HomeMentorPage = () => {
|
||||||
|
|||||||
@@ -78,7 +78,8 @@ export default function EligibleInternTable({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleGenerate = async () => {
|
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.");
|
setGenerateError("Template sertifikat belum diupload.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -98,7 +99,7 @@ export default function EligibleInternTable({
|
|||||||
await Promise.all(
|
await Promise.all(
|
||||||
selectedInterns.map(async (intern) => {
|
selectedInterns.map(async (intern) => {
|
||||||
const blob = await generateCertificate(
|
const blob = await generateCertificate(
|
||||||
project.template_sertificate,
|
project.certificate_template,
|
||||||
intern.full_name
|
intern.full_name
|
||||||
);
|
);
|
||||||
// Nama file: "Sertifikat - Nama Intern.png"
|
// Nama file: "Sertifikat - Nama Intern.png"
|
||||||
@@ -286,7 +287,12 @@ export default function EligibleInternTable({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bottom Action Bar */}
|
{/* 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
|
<button
|
||||||
onClick={handleGenerate}
|
onClick={handleGenerate}
|
||||||
disabled={generating || selected.size === 0}
|
disabled={generating || selected.size === 0}
|
||||||
@@ -304,6 +310,7 @@ export default function EligibleInternTable({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { mockProjects } from '../../../../lib/mockData'
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import CreateProjectModal from './CreateProjectDialog';
|
import CreateProjectModal from './CreateProjectDialog';
|
||||||
@@ -20,9 +19,9 @@ const HomeMentorProject = () => {
|
|||||||
<div className='flex flex-row justify-between mb-3 px-5 items-center'>
|
<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>
|
<h2 className="text-lg font-semibold text-gray-800 mb-4">Projects</h2>
|
||||||
<button
|
<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} >
|
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' />
|
<Plus className='w-4 h-4' />
|
||||||
Add
|
Add
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/utils/certificateGenerator.ts
|
import api from "../../lib/api";
|
||||||
|
|
||||||
// ── Konstanta posisi nama di template ─────────────────────────────────────────
|
// ── Konstanta posisi nama di template ─────────────────────────────────────────
|
||||||
const NAME_Y_RATIO = 0.55; // posisi Y = 55% dari tinggi canvas
|
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_FONT_FAMILY = "Georgia, serif";
|
||||||
const NAME_COLOR = "#1a1a1a";
|
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 ────────────────────────────────────────────────────────
|
// ── Load Image dari URL ────────────────────────────────────────────────────────
|
||||||
export function loadImage(src: string): Promise<HTMLImageElement> {
|
export function loadImage(src: string): Promise<HTMLImageElement> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.crossOrigin = "anonymous"; // penting untuk URL eksternal (Supabase, dll)
|
img.crossOrigin = "anonymous";
|
||||||
img.onload = () => resolve(img);
|
img.onload = () => resolve(img);
|
||||||
img.onerror = () => reject(new Error(`Gagal load gambar: ${src}`));
|
img.onerror = () => reject(new Error(`Gagal load gambar: ${src}`));
|
||||||
img.src = src;
|
img.src = resolveImageUrl(src);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import {
|
|||||||
getCertificatesByProject,
|
getCertificatesByProject,
|
||||||
verifyCertificateByUuid,
|
verifyCertificateByUuid,
|
||||||
uploadCertificateTemplate,
|
uploadCertificateTemplate,
|
||||||
|
generateCertificate
|
||||||
} from "../services/CertificateService";
|
} from "../services/CertificateService";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
|
||||||
// GET /certificate-api/my-certificates
|
// GET /certificate-api/my-certificates
|
||||||
export const useMyCertificates = () => {
|
export const useMyCertificates = () => {
|
||||||
@@ -119,6 +121,51 @@ export const useClaimCertificate = () => {
|
|||||||
return { claim, loading, error };
|
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
|
// POST /certificate-api/projects/{id_project}/template
|
||||||
export const useUploadCertificateTemplate = () => {
|
export const useUploadCertificateTemplate = () => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
// hooks/useNotifications.ts
|
// hooks/useNotifications.ts
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import type { Notification } from "../types/certificate.types";
|
|
||||||
import {
|
import {
|
||||||
getNotifications,
|
getNotifications,
|
||||||
markAllNotificationsRead,
|
markAllAsRead,
|
||||||
markNotificationRead,
|
markAsRead,
|
||||||
} from "../services/NotificationService";
|
} from "../services/NotificationService";
|
||||||
|
import type { BackendNotification } from "../types/notification.types";
|
||||||
|
|
||||||
export const useNotifications = () => {
|
export const useNotifications = () => {
|
||||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
const [notifications, setNotifications] = useState<BackendNotification[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -24,14 +24,14 @@ export const useNotifications = () => {
|
|||||||
|
|
||||||
const markAllRead = async () => {
|
const markAllRead = async () => {
|
||||||
try {
|
try {
|
||||||
await markAllNotificationsRead();
|
await markAllAsRead();
|
||||||
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
|
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
|
||||||
} catch { setError("Gagal menandai semua notifikasi."); }
|
} catch { setError("Gagal menandai semua notifikasi."); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const markOneRead = async (id: number) => {
|
const markOneRead = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
await markNotificationRead(id);
|
await markAsRead(id);
|
||||||
setNotifications(prev =>
|
setNotifications(prev =>
|
||||||
prev.map(n => n.id === id ? { ...n, is_read: true } : n)
|
prev.map(n => n.id === id ? { ...n, is_read: true } : n)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
CreateProjectPayload,
|
CreateProjectPayload,
|
||||||
UpdateProjectPayload,
|
UpdateProjectPayload,
|
||||||
AssignMemberPayload,
|
AssignMemberPayload,
|
||||||
|
AssignableIntern,
|
||||||
ProjectMember
|
ProjectMember
|
||||||
} from "../types/project.types";
|
} from "../types/project.types";
|
||||||
import {
|
import {
|
||||||
@@ -22,7 +23,9 @@ import {
|
|||||||
archiveProject,
|
archiveProject,
|
||||||
assignMember,
|
assignMember,
|
||||||
getProjectMembers,
|
getProjectMembers,
|
||||||
removeMember
|
removeMember,
|
||||||
|
getAssignableInterns,
|
||||||
|
completeProject
|
||||||
} from "../services/ProjectService";
|
} from "../services/ProjectService";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
@@ -158,6 +161,24 @@ export const useAllInterns = () => {
|
|||||||
return { interns, loading, error, refetch };
|
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
|
// POST /project-api/add
|
||||||
export const useCreateProject = () => {
|
export const useCreateProject = () => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -232,6 +253,26 @@ export const useArchiveProject = () => {
|
|||||||
return { archive, loading, error };
|
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
|
// POST /project-api/assign-member
|
||||||
export const useAssignMember = () => {
|
export const useAssignMember = () => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|||||||
+38
-1
@@ -1,5 +1,5 @@
|
|||||||
// hooks/useTasks.ts
|
// 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 type { ProjectTask, ProjectTaskDetail, CreateTaskPayload, UpdateTaskPayload, TaskSubmissionData, AdminTaskDetail } from "../types/task.types";
|
||||||
import {
|
import {
|
||||||
getProjectTasks,
|
getProjectTasks,
|
||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
getTaskSubmissions,
|
getTaskSubmissions,
|
||||||
} from "../services/TaskService";
|
} from "../services/TaskService";
|
||||||
import type { MentorTaskItem } from "../types/task.types";
|
import type { MentorTaskItem } from "../types/task.types";
|
||||||
|
import { useCurrentUser } from "./useUser";
|
||||||
|
import { useMyTasks } from "./useProjects";
|
||||||
|
|
||||||
// GET /task-api/projects/{id_project}/tasks
|
// GET /task-api/projects/{id_project}/tasks
|
||||||
export const useProjectTasks = (projectId: string) => {
|
export const useProjectTasks = (projectId: string) => {
|
||||||
@@ -219,4 +221,39 @@ export const useAllMentorTasks = (enabled: boolean) => {
|
|||||||
}, [enabled]);
|
}, [enabled]);
|
||||||
|
|
||||||
return { tasks, loading, error };
|
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
@@ -3,28 +3,8 @@ import { useState, useEffect, useCallback } from "react";
|
|||||||
import { loginUser, getProfile, updateProfile } from "../services/UserService";
|
import { loginUser, getProfile, updateProfile } from "../services/UserService";
|
||||||
import type { Mahasiswa } from '../types/project.types';
|
import type { Mahasiswa } from '../types/project.types';
|
||||||
import { getMahasiswa } from '../services/MahasiswaService';
|
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
|
// GET /auth-api/me — ambil data user yang sedang login
|
||||||
export const useCurrentUser = () => {
|
export const useCurrentUser = () => {
|
||||||
const [user, setUser] = useState<CurrentUser | null>(null);
|
const [user, setUser] = useState<CurrentUser | null>(null);
|
||||||
@@ -47,7 +27,6 @@ export const useCurrentUser = () => {
|
|||||||
return { user, loading, error, refetch };
|
return { user, loading, error, refetch };
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── useLogin ────────────────────────────────────────────────────────────────
|
|
||||||
// POST /auth-api/login
|
// POST /auth-api/login
|
||||||
export const useLogin = () => {
|
export const useLogin = () => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -75,7 +54,6 @@ export const useLogin = () => {
|
|||||||
return { login, loading, error, setError };
|
return { login, loading, error, setError };
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── useUpdateProfile ────────────────────────────────────────────────────────
|
|
||||||
// PATCH /auth-api/update-profile
|
// PATCH /auth-api/update-profile
|
||||||
export const useUpdateProfile = () => {
|
export const useUpdateProfile = () => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -118,4 +96,6 @@ export const useMahasiswa = () => {
|
|||||||
useEffect(() => { refetch(); }, [refetch]);
|
useEffect(() => { refetch(); }, [refetch]);
|
||||||
|
|
||||||
return { mahasiswa, loading, error, refetch };
|
return { mahasiswa, loading, error, refetch };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useAssignableInterns = () => {}
|
||||||
@@ -12,6 +12,11 @@ export const claimCertificate = async (id_project: number): Promise<Certificate>
|
|||||||
return res.data.data;
|
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
|
// GET /certificate-api/my-certificates
|
||||||
export const getMyCertificates = async (): Promise<Certificate[]> => {
|
export const getMyCertificates = async (): Promise<Certificate[]> => {
|
||||||
const res = await api.get("/certificate-api/my-certificates");
|
const res = await api.get("/certificate-api/my-certificates");
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import type {
|
|||||||
UpdateProjectPayload,
|
UpdateProjectPayload,
|
||||||
AssignMemberPayload,
|
AssignMemberPayload,
|
||||||
MemberAssignment,
|
MemberAssignment,
|
||||||
ProjectMember
|
ProjectMember,
|
||||||
|
AssignableIntern
|
||||||
} from "../types/project.types";
|
} from "../types/project.types";
|
||||||
|
|
||||||
// POST /project-api/add
|
// POST /project-api/add
|
||||||
@@ -84,6 +85,14 @@ export const archiveProject = async (
|
|||||||
return res.data.data;
|
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)
|
// GET /project-api/my-projects (intern only)
|
||||||
export const getMyProjects = async (): Promise<ProjectDetail[]> => {
|
export const getMyProjects = async (): Promise<ProjectDetail[]> => {
|
||||||
const res = await api.get("/project-api/my-projects");
|
const res = await api.get("/project-api/my-projects");
|
||||||
@@ -108,6 +117,12 @@ export const getAllInterns = async (): Promise<InternDetail[]> => {
|
|||||||
return res.data.data;
|
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)
|
// POST /project-api/assign-member (admin/mentor only)
|
||||||
export const assignMember = async (
|
export const assignMember = async (
|
||||||
payload: AssignMemberPayload
|
payload: AssignMemberPayload
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ export interface CertificateVerifyResult {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GenerateCertificatePayload {
|
||||||
|
id_project: number;
|
||||||
|
id_users: number[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface CertificateTemplate {
|
export interface CertificateTemplate {
|
||||||
id_project: number;
|
id_project: number;
|
||||||
certificate_template: string;
|
certificate_template: string;
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export interface Project {
|
|||||||
export interface ProjectDetail extends Project {
|
export interface ProjectDetail extends Project {
|
||||||
members: ProjectMember[];
|
members: ProjectMember[];
|
||||||
tasks: ProjectTask[];
|
tasks: ProjectTask[];
|
||||||
template_sertificate: string;
|
certificate_template: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Response GET /project-api/my-tasks
|
// Response GET /project-api/my-tasks
|
||||||
@@ -70,6 +70,17 @@ export interface InternDetail {
|
|||||||
avatar: string | null;
|
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
|
// Payload POST /project-api/add
|
||||||
export interface CreateProjectPayload {
|
export interface CreateProjectPayload {
|
||||||
project_icon: string;
|
project_icon: string;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user