[UNTEST] CERTIFICATE GENERATION AND BACKEND ENDPOINT IMPLEMENTATION ON CERTIFICATES (CLAIM, GENERATE, FETCH DATA)
- Updated AddInternsModal to allow selection of interns via dropdown instead of email input. - Introduced CertResultTable component for displaying certificate results with search and pagination. - Modified CreateProjectDialog to accept an onSuccess callback for better project creation handling. - Integrated certificate generation functionality in EligibleInternTable with improved error handling. - Enhanced HomeMentorProject and MentorProjectCard to dynamically display project icons and statuses. - Refactored SidebarMentor to improve active route detection. - Added utility functions for certificate preview and download in Certificates.ts. - Removed unused DownloadImagetoDevice utility and related hooks. - Implemented useProjectsByRole hook for fetching projects based on user role. - Updated types for certificates and projects to reflect recent changes in the data structure.
This commit is contained in:
+2
-2
@@ -31,7 +31,6 @@ import EditLowongan from "./Pages/EditLowongan";
|
|||||||
import HomeInternPage from "./Pages/Internify/intern/HomeInternPage";
|
import HomeInternPage from "./Pages/Internify/intern/HomeInternPage";
|
||||||
import { InternLayout, MentorLayout } from "./Pages/Internify/layout";
|
import { InternLayout, MentorLayout } from "./Pages/Internify/layout";
|
||||||
import ProjectsPage from "./Pages/Internify/intern/ProjectsPage";
|
import ProjectsPage from "./Pages/Internify/intern/ProjectsPage";
|
||||||
import SertificatePage from "./Pages/Internify/intern/InternCertiDetail";
|
|
||||||
import ProjectDetailPage from "./Pages/Internify/intern/ProjectDetailPage";
|
import ProjectDetailPage from "./Pages/Internify/intern/ProjectDetailPage";
|
||||||
import HomeMentorPage from "./Pages/Internify/mentor/HomeMentorPage";
|
import HomeMentorPage from "./Pages/Internify/mentor/HomeMentorPage";
|
||||||
import MentorProjectsPage from "./Pages/Internify/mentor/BaseProjectsPage";
|
import MentorProjectsPage from "./Pages/Internify/mentor/BaseProjectsPage";
|
||||||
@@ -54,6 +53,7 @@ import AdminMentorPage from "./Pages/MentorPage";
|
|||||||
import CreateMentorForm from "./Pages/CreateMentorForm";
|
import CreateMentorForm from "./Pages/CreateMentorForm";
|
||||||
import EditMentorForm from "./Pages/EditMentorForm";
|
import EditMentorForm from "./Pages/EditMentorForm";
|
||||||
import NotFoundPage from "./Pages/NotFoundPage";
|
import NotFoundPage from "./Pages/NotFoundPage";
|
||||||
|
import InternCertificateDetail from "./Pages/Internify/intern/InternCertDetail";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -103,7 +103,7 @@ function App() {
|
|||||||
<Route index element={<HomeInternPage />} />
|
<Route index element={<HomeInternPage />} />
|
||||||
<Route path="projects" element={<ProjectsPage />} />
|
<Route path="projects" element={<ProjectsPage />} />
|
||||||
<Route path="certificates" element={<SertificateList />} />
|
<Route path="certificates" element={<SertificateList />} />
|
||||||
<Route path="certificates/:slug" element={<SertificatePage />} />
|
<Route path="certificates/:slug" element={<InternCertificateDetail />} />
|
||||||
<Route path="projects/:slug" element={<ProjectDetailPage />} />
|
<Route path="projects/:slug" element={<ProjectDetailPage />} />
|
||||||
<Route path="projects/:slug/:taskSlug" element={<TaskSubmission />} />
|
<Route path="projects/:slug/:taskSlug" element={<TaskSubmission />} />
|
||||||
<Route path="faq" element={<FAQPage />} />
|
<Route path="faq" element={<FAQPage />} />
|
||||||
|
|||||||
+15
-15
@@ -4,19 +4,27 @@ import { useParams, useLocation } from "react-router-dom";
|
|||||||
import CertificateAvailable from "./components/CertificateAvailabe";
|
import CertificateAvailable from "./components/CertificateAvailabe";
|
||||||
import CertificateNotAvailable from "./components/CertificateNotAvailable";
|
import CertificateNotAvailable from "./components/CertificateNotAvailable";
|
||||||
import SertificateHistory from "./components/CertificateHistory";
|
import SertificateHistory from "./components/CertificateHistory";
|
||||||
import { useMyCertificates, useClaimCertificate } from "../../../hooks/useCertificates";
|
|
||||||
|
import { useClaimCertificate } from "../../../hooks/useCertificates";
|
||||||
import { useProjectDetail } from "../../../hooks/useProjects";
|
import { useProjectDetail } from "../../../hooks/useProjects";
|
||||||
|
|
||||||
|
import { useProjectCertificates } from "../../../hooks/useCertificates";
|
||||||
import type { Project } from "../../../types/project.types";
|
import type { Project } from "../../../types/project.types";
|
||||||
|
|
||||||
const SertificatePage = () => {
|
export default function InternCertificateDetail () {
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const projectFromList = location.state?.project as Project | undefined;
|
const projectFromList = location.state?.project as Project | undefined;
|
||||||
|
|
||||||
const { project: projectDetail, loading: projectLoading } = useProjectDetail(slug ?? "");
|
const { project: projectDetail, loading: projectLoading } = useProjectDetail(slug ?? "");
|
||||||
const { certificates, loading: certLoading, refetch: refetchCertificates } = useMyCertificates();
|
const { certificates, loading: certLoading, refetch: refetchCertificates } = useProjectCertificates(projectDetail?.id ?? 0);
|
||||||
const { claim, loading: claiming, error: claimError } = useClaimCertificate();
|
const { claim, loading: claiming, error: claimError } = useClaimCertificate();
|
||||||
|
|
||||||
|
const myCertificateForProject = useMemo(() => {
|
||||||
|
if (!projectDetail) return null;
|
||||||
|
return certificates.find((cert) => cert.id_project === projectDetail.id);
|
||||||
|
}, [projectDetail, certificates]);
|
||||||
|
|
||||||
const project = useMemo(() => {
|
const project = useMemo(() => {
|
||||||
if (!projectDetail) return null;
|
if (!projectDetail) return null;
|
||||||
return {
|
return {
|
||||||
@@ -28,13 +36,6 @@ const SertificatePage = () => {
|
|||||||
|
|
||||||
const allTasksDone = !projectLoading && !!project && project.task_done >= project.total_tasks;
|
const allTasksDone = !projectLoading && !!project && project.task_done >= project.total_tasks;
|
||||||
|
|
||||||
const myCertificateForProject = useMemo(() => {
|
|
||||||
if (!project || !certificates.length) return null;
|
|
||||||
return certificates.find((cert) => cert.project.id === project.id) ?? null;
|
|
||||||
}, [certificates, project]);
|
|
||||||
|
|
||||||
const alreadyDone = !!myCertificateForProject;
|
|
||||||
|
|
||||||
const progress =
|
const progress =
|
||||||
project && project.total_tasks > 0
|
project && project.total_tasks > 0
|
||||||
? Math.round((project.task_done / project.total_tasks) * 100)
|
? Math.round((project.task_done / project.total_tasks) * 100)
|
||||||
@@ -53,7 +54,7 @@ const SertificatePage = () => {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
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">
|
||||||
Loading...
|
Memuat Data...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -66,10 +67,11 @@ const SertificatePage = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col lg:flex-row gap-6 items-stretch">
|
<div className="flex flex-col lg:flex-row gap-6 items-stretch">
|
||||||
<div className="w-full lg:w-5/8 flex flex-col">
|
<div className="w-full lg:w-5/8 flex flex-col">
|
||||||
{alreadyDone ? (
|
{myCertificateForProject ? (
|
||||||
<CertificateAvailable
|
<CertificateAvailable
|
||||||
project={project}
|
project={project}
|
||||||
certificate={myCertificateForProject}
|
certificate={myCertificateForProject}
|
||||||
|
templateUrl = {project?.certificate_template ?? ""}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CertificateNotAvailable
|
<CertificateNotAvailable
|
||||||
@@ -88,6 +90,4 @@ const SertificatePage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SertificatePage;
|
|
||||||
@@ -1,72 +1,91 @@
|
|||||||
// CertificateAvailable.tsx
|
// CertificateAvailable.tsx
|
||||||
import { useState, useRef } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Download, Link, Printer } from "lucide-react";
|
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 { downloadCertificatePdf, downloadCertificateImage } from "../../../utils/Certificates";
|
||||||
import { resolveImageUrl } from "../../../utils/SertificateGenerator";
|
import { generateCertificate, resolveImageUrl } from "../../../utils/SertificateGenerator";
|
||||||
|
|
||||||
interface CertificateAvailableProps {
|
interface CertificateAvailableProps {
|
||||||
project: ProjectDetail | null;
|
project: ProjectDetail | null;
|
||||||
certificate: Certificate;
|
certificate: Certificate;
|
||||||
|
templateUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CertificateAvailable({ project, certificate }: CertificateAvailableProps) {
|
export default function CertificateAvailable({ project, certificate, templateUrl }: CertificateAvailableProps) {
|
||||||
const [copied, setCopied] = useState(false);
|
// const [copied, setCopied] = useState(false);
|
||||||
const [downloadingImg, setDownloadingImg] = useState(false);
|
const [downloadingImg, setDownloadingImg] = useState(false);
|
||||||
const [downloadingPdf, setDownloadingPdf] = useState(false);
|
const [downloadingPdf, setDownloadingPdf] = useState(false);
|
||||||
const certificateRef = useRef<HTMLDivElement>(null);
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [loadingPreview, setLoadingPreview] = useState(true);
|
||||||
|
|
||||||
const handleCopyLink = () => {
|
useEffect(() => {
|
||||||
navigator.clipboard.writeText(window.location.href);
|
if (!templateUrl) return;
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
generateCertificate(resolveImageUrl(templateUrl), certificate.user.full_name)
|
||||||
};
|
.then((blob) => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
setPreviewUrl(url);
|
||||||
|
})
|
||||||
|
.finally(() => setLoadingPreview(false));
|
||||||
|
|
||||||
|
// Cleanup supaya tidak memory leak
|
||||||
|
return () => {
|
||||||
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||||
|
};
|
||||||
|
}, [templateUrl, certificate.user.full_name]);
|
||||||
|
|
||||||
// Hitung durasi dari start_date - end_date
|
|
||||||
const duration = project
|
const duration = project
|
||||||
? (() => {
|
? (() => {
|
||||||
const start = new Date(project.start_date);
|
const start = new Date(project.start_date);
|
||||||
const end = new Date(project.end_date);
|
const end = new Date(project.end_date);
|
||||||
const diffMs = end.getTime() - start.getTime();
|
const diffMs = end.getTime() - start.getTime();
|
||||||
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
||||||
const months = Math.floor(diffDays / 30);
|
const months = Math.floor(diffDays / 30);
|
||||||
const days = diffDays % 30;
|
const days = diffDays % 30;
|
||||||
if (months > 0 && days > 0) return `${months} Bulan ${days} Hari`;
|
if (months > 0 && days > 0) return `${months} Bulan ${days} Hari`;
|
||||||
if (months > 0) return `${months} Bulan`;
|
if (months > 0) return `${months} Bulan`;
|
||||||
return `${diffDays} Hari`;
|
return `${diffDays} Hari`;
|
||||||
})()
|
})()
|
||||||
: "-";
|
: "-";
|
||||||
|
|
||||||
const handleDownloadImage = async () => {
|
const handleDownloadImage = async () => {
|
||||||
setDownloadingImg(true);
|
setDownloadingImg(true);
|
||||||
await downloadCertificateAsImage(
|
try {
|
||||||
certificateRef.current,
|
await downloadCertificateImage(certificate, templateUrl, "png");
|
||||||
`Sertifikat - ${certificate.certificate_no}`,
|
} finally {
|
||||||
"png",
|
setDownloadingImg(false);
|
||||||
certificate.image_path
|
}
|
||||||
);
|
|
||||||
setDownloadingImg(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownloadPdf = async () => {
|
const handleDownloadPdf = async () => {
|
||||||
setDownloadingPdf(true);
|
setDownloadingPdf(true);
|
||||||
await downloadCertificateAsPdf(
|
try {
|
||||||
certificateRef.current,
|
await downloadCertificatePdf(certificate, templateUrl);
|
||||||
`Sertifikat - ${certificate.certificate_no}`
|
} finally {
|
||||||
);
|
setDownloadingPdf(false);
|
||||||
setDownloadingPdf(false);
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{/* 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
|
{loadingPreview ? (
|
||||||
src={resolveImageUrl(certificate.image_path)}
|
<div className="w-[550px] h-[350px] flex items-center justify-center text-gray-400 text-sm">
|
||||||
alt="Certificate"
|
Memuat sertifikat...
|
||||||
className="rounded-xl w-[550px] h-[350px] object-contain"
|
</div>
|
||||||
/>
|
) : previewUrl ? (
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="Certificate"
|
||||||
|
className="rounded-xl w-[550px] h-[350px] object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-[550px] h-[350px] flex items-center justify-center text-gray-400 text-sm">
|
||||||
|
Gagal memuat sertifikat.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Program Summary */}
|
{/* Program Summary */}
|
||||||
@@ -79,15 +98,7 @@ export default function CertificateAvailable({ project, certificate }: Certifica
|
|||||||
{[
|
{[
|
||||||
{ label: "Nama Program", value: project?.project_name ?? "-" },
|
{ label: "Nama Program", value: project?.project_name ?? "-" },
|
||||||
{ label: "Durasi", value: duration },
|
{ label: "Durasi", value: duration },
|
||||||
{ label: "Total Tugas", value: project ? `${project.total_tasks} Tugas` : "-" },
|
{ label: "No. Sertifikat", value: certificate.certificate_no ?? "-" },
|
||||||
{
|
|
||||||
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}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
].map((row) => (
|
].map((row) => (
|
||||||
<div
|
<div
|
||||||
key={row.label}
|
key={row.label}
|
||||||
@@ -109,7 +120,7 @@ export default function CertificateAvailable({ project, certificate }: Certifica
|
|||||||
className="flex items-center justify-center gap-2 w-full bg-red-700 hover:bg-red-800 active:scale-95 transition-all text-white font-semibold py-2 rounded-xl text-sm"
|
className="flex items-center justify-center gap-2 w-full bg-red-700 hover:bg-red-800 active:scale-95 transition-all text-white font-semibold py-2 rounded-xl text-sm"
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
{downloadingPdf? "Memproses": "Unduh Sertifikat"}
|
{downloadingPdf ? "Memproses" : "Unduh Sertifikat"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Bagikan ke LinkedIn */}
|
{/* Bagikan ke LinkedIn */}
|
||||||
@@ -124,14 +135,14 @@ export default function CertificateAvailable({ project, certificate }: Certifica
|
|||||||
disabled={downloadingImg}
|
disabled={downloadingImg}
|
||||||
className="flex flex-col items-center justify-center gap-1.5 border border-card-outline hover:bg-gray-50 active:scale-95 transition-all text-red-700 font-medium py-3.5 rounded-xl text-sm">
|
className="flex flex-col items-center justify-center gap-1.5 border border-card-outline hover:bg-gray-50 active:scale-95 transition-all text-red-700 font-medium py-3.5 rounded-xl text-sm">
|
||||||
<Printer className="w-5 h-5" />
|
<Printer className="w-5 h-5" />
|
||||||
{downloadingImg? "Memproses" : "Cetak"}
|
{downloadingImg ? "Memproses" : "Cetak"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleCopyLink}
|
// onClick={handleCopyLink}
|
||||||
className="flex flex-col items-center justify-center gap-1.5 border border-card-outline hover:bg-gray-50 active:scale-95 transition-all text-red-700 font-medium py-3.5 rounded-xl text-sm"
|
className="flex flex-col items-center justify-center gap-1.5 border border-card-outline hover:bg-gray-50 active:scale-95 transition-all text-red-700 font-medium py-3.5 rounded-xl text-sm"
|
||||||
>
|
>
|
||||||
<Link className="w-5 h-5" />
|
<Link className="w-5 h-5" />
|
||||||
{copied ? "Copied!" : "Salin Link"}
|
Salin Link
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,13 +40,6 @@ export default function SertificateHistory() {
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
className="flex flex-col gap-3 p-3 bg-white border border-gray-200 shadow-sm rounded-xl hover:bg-gray-50 transition-colors cursor-pointer"
|
className="flex flex-col gap-3 p-3 bg-white border border-gray-200 shadow-sm rounded-xl hover:bg-gray-50 transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<div className="w-full flex justify-center">
|
|
||||||
<img
|
|
||||||
src={item.image_path}
|
|
||||||
alt="Certificate"
|
|
||||||
className="rounded-xl h-[150px] w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-gray-400">
|
||||||
{new Date(item.issued_at).toLocaleDateString("id-ID", {
|
{new Date(item.issued_at).toLocaleDateString("id-ID", {
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useProjects } from '../../../../hooks/useProjects';
|
import { useProjects } from '../../../../hooks/useProjects';
|
||||||
|
import * as LucideIcons from 'lucide-react';
|
||||||
|
import type { LucideProps } from 'lucide-react';
|
||||||
|
|
||||||
const toSlug = (name: string) => name.toLowerCase().replace(/\s+/g, '-');
|
const toSlug = (name: string) => name.toLowerCase().replace(/\s+/g, '-');
|
||||||
|
|
||||||
|
const getDynamicIcon = (iconName: string) => {
|
||||||
|
const formatted = iconName.charAt(0).toUpperCase() + iconName.slice(1);
|
||||||
|
const Icon = (LucideIcons as unknown as Record<string, React.ComponentType<LucideProps>>)[formatted];
|
||||||
|
return Icon ?? LucideIcons.FolderOpen;
|
||||||
|
};
|
||||||
|
|
||||||
const HomeProjectList = () => {
|
const HomeProjectList = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { projects, loading, error } = useProjects();
|
const { projects, loading, error } = useProjects();
|
||||||
|
|
||||||
if (loading) return (
|
if (loading) return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{[...Array(3)].map((_, i) => (
|
{[...Array(3)].map((_, i) => (
|
||||||
@@ -27,7 +34,12 @@ const HomeProjectList = () => {
|
|||||||
onClick={() => navigate(`/intern/projects/${toSlug(project.slug)}`)}
|
onClick={() => navigate(`/intern/projects/${toSlug(project.slug)}`)}
|
||||||
className="flex items-center gap-4 border border-card-outline rounded-lg px-4 py-3 hover:bg-gray-50 cursor-pointer transition-colors duration-150"
|
className="flex items-center gap-4 border border-card-outline rounded-lg px-4 py-3 hover:bg-gray-50 cursor-pointer transition-colors duration-150"
|
||||||
>
|
>
|
||||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 bg-red-600" />
|
<div className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0" style={{ backgroundColor: project.background_color ?? '#dc2626' }}>
|
||||||
|
{(() => {
|
||||||
|
const Icon = getDynamicIcon(project.project_icon);
|
||||||
|
return <Icon className="w-5 h-5 text-white" />;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold text-font-shade">{project.project_name}</p>
|
<p className="text-sm font-semibold text-font-shade">{project.project_name}</p>
|
||||||
<p className="text-xs text-gray-400">{project.status}</p>
|
<p className="text-xs text-gray-400">{project.status}</p>
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default function SidebarIntern({ isOpen, closeSidebar }: SidebarProps) {
|
|||||||
{/* Main nav */}
|
{/* Main nav */}
|
||||||
<nav className="flex flex-col gap-0.5">
|
<nav className="flex flex-col gap-0.5">
|
||||||
{navItems.map(({ label, icon: Icon, path }) => {
|
{navItems.map(({ label, icon: Icon, path }) => {
|
||||||
const isActive = location.pathname === path;
|
const isActive = location.pathname === path || location.pathname.startsWith(path + "/");
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={path}
|
key={path}
|
||||||
|
|||||||
@@ -2,20 +2,23 @@ import MentorProjectCard from './components/MentorProjectCard';
|
|||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import CreateProjectModal from './components/CreateProjectDialog';
|
import CreateProjectModal from './components/CreateProjectDialog';
|
||||||
import { useProjects } from '../../../hooks/useProjects';
|
import { useProjectsByRole } from '../../../hooks/useListProjects';
|
||||||
|
|
||||||
|
|
||||||
export default function MentorProjectsPage() {
|
export default function MentorProjectsPage() {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const { projects, loading, error } = useProjects("active");
|
const { projects, loading, error, refetch } = useProjectsByRole();
|
||||||
|
|
||||||
const openDialog = () => {
|
const openDialog = () => {
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeDialog = () => {
|
const closeDialog = () => {
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
};
|
};
|
||||||
|
const handleRefetch = () => {
|
||||||
|
closeDialog();
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) return (
|
if (loading) return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
@@ -56,6 +59,7 @@ export default function MentorProjectsPage() {
|
|||||||
<CreateProjectModal
|
<CreateProjectModal
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
onClose={closeDialog}
|
onClose={closeDialog}
|
||||||
|
onSuccess={handleRefetch}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,132 +1,44 @@
|
|||||||
// src/pages/mentor/CertificateResult.tsx
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import { CheckCircle2, FolderArchive, Loader2 } from "lucide-react";
|
||||||
CheckCircle2,
|
import { useProjectCertificates } from "../../../hooks/useCertificates";
|
||||||
FolderArchive,
|
|
||||||
Search,
|
|
||||||
Eye,
|
|
||||||
Download,
|
|
||||||
Share2,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useProjectCertificates } from "../../../hooks/useCertificates"; //Problemnya cuma role "admin" yang bisa
|
|
||||||
import { useProjectDetail } from "../../../hooks/useProjects";
|
import { useProjectDetail } from "../../../hooks/useProjects";
|
||||||
import type { Certificate } from "../../../types/certificate.types";
|
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
|
import { downloadAllCertificatesZip } from "../../utils/Certificates";
|
||||||
|
import CertResultTable from "./components/CertResultTable";
|
||||||
|
|
||||||
const PER_PAGE = 5;
|
|
||||||
|
|
||||||
|
|
||||||
const getInitials = (name: string): string => {
|
|
||||||
const parts = name.trim().split(/\s+/);
|
|
||||||
if (parts.length === 1) return parts[0][0].toUpperCase();
|
|
||||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
|
||||||
};
|
|
||||||
|
|
||||||
function Avatar({ name }: { name: string }) {
|
|
||||||
// "Kemas M Aryadary Rasyad" → "KR"
|
|
||||||
const color = "bg-gray-100 text-gray-600";
|
|
||||||
return (
|
|
||||||
<div className={`w-9 h-9 rounded-full flex items-center justify-center text-[11px] font-black flex-shrink-0 ${color}`}>
|
|
||||||
{getInitials(name)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CertificateResultSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="animate-pulse">
|
|
||||||
{/* Hero */}
|
|
||||||
<div className="flex flex-col items-center text-center mb-8 px-4">
|
|
||||||
<div className="w-16 h-16 rounded-full bg-gray-200 mb-5" />
|
|
||||||
<div className="h-8 w-64 bg-gray-200 rounded-lg mb-3" />
|
|
||||||
<div className="h-4 w-80 bg-gray-100 rounded mb-2" />
|
|
||||||
<div className="h-4 w-64 bg-gray-100 rounded mb-6" />
|
|
||||||
<div className="h-9 w-44 bg-gray-200 rounded-xl" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
<div className="border border-gray-200 rounded-2xl overflow-hidden bg-white shadow-sm">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
|
||||||
<div className="h-4 w-36 bg-gray-200 rounded" />
|
|
||||||
<div className="h-7 w-44 bg-gray-100 rounded-lg" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Column headers */}
|
|
||||||
<div className="grid grid-cols-[2fr_1.5fr_auto] px-5 py-2.5 bg-gray-50 border-b border-gray-100 gap-4">
|
|
||||||
<div className="h-3 w-20 bg-gray-200 rounded" />
|
|
||||||
<div className="h-3 w-24 bg-gray-200 rounded" />
|
|
||||||
<div className="h-3 w-16 bg-gray-200 rounded" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Rows */}
|
|
||||||
{Array.from({ length: PER_PAGE }).map((_, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="grid grid-cols-[2fr_1.5fr_auto] items-center px-5 py-3.5 border-b border-gray-50 gap-4"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-9 h-9 rounded-full bg-gray-200 flex-shrink-0" />
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<div className="h-3.5 w-28 bg-gray-200 rounded" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="h-3 w-32 bg-gray-100 rounded" />
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{Array.from({ length: 3 }).map((_, j) => (
|
|
||||||
<div key={j} className="w-7 h-7 rounded-md bg-gray-100" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="flex items-center justify-between px-5 py-3.5 border-t border-gray-100 bg-gray-50/30">
|
|
||||||
<div className="h-3 w-40 bg-gray-100 rounded" />
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
|
||||||
<div key={i} className="w-7 h-7 rounded-md bg-gray-200" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
|
||||||
export default function CertificateResult() {
|
export default function CertificateResult() {
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
const [search, setSearch] = useState("");
|
const [zipping, setZipping] = useState(false);
|
||||||
const [page, setPage] = useState(1);
|
const [zipProgress, setZipProgress] = useState<{ done: number; total: number } | null>(null);
|
||||||
|
|
||||||
const { project, loading: projectLoading, error: projectError } = useProjectDetail(slug ?? "");
|
const { project, loading: projectLoading, error: projectError } = useProjectDetail(slug ?? "");
|
||||||
const { certificates, loading: certLoading, error: certError } = useProjectCertificates(
|
const { certificates, loading: certLoading, error: certError } = useProjectCertificates(project?.id ?? 0);
|
||||||
project?.id ?? 0
|
|
||||||
);
|
|
||||||
|
|
||||||
const loading = projectLoading || certLoading;
|
const loading = projectLoading || certLoading;
|
||||||
const error = projectError || certError;
|
const error = projectError || certError;
|
||||||
|
|
||||||
if (loading) return <CertificateResultSkeleton />;
|
const handleDownloadZip = async () => {
|
||||||
if (error) return <div className="text-sm text-red-500 p-4">Gagal memuat data sertifikat.</div>;
|
if (!project || certificates.length === 0) return;
|
||||||
|
setZipping(true);
|
||||||
// Filter + paginate
|
setZipProgress({ done: 0, total: certificates.length });
|
||||||
const filtered = certificates.filter(
|
try {
|
||||||
(c) =>
|
await downloadAllCertificatesZip(
|
||||||
c.user.full_name.toLowerCase().includes(search.toLowerCase()) ||
|
certificates,
|
||||||
c.user.email.toLowerCase().includes(search.toLowerCase())
|
project.certificate_template ?? "",
|
||||||
);
|
project.project_name,
|
||||||
const totalPages = Math.ceil(filtered.length / PER_PAGE);
|
(done, total) => setZipProgress({ done, total })
|
||||||
const paginated = filtered.slice((page - 1) * PER_PAGE, page * PER_PAGE);
|
);
|
||||||
|
} catch (err) {
|
||||||
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
console.error("Gagal download ZIP:", err);
|
||||||
setSearch(e.target.value);
|
} finally {
|
||||||
setPage(1);
|
setZipping(false);
|
||||||
|
setZipProgress(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (loading) return <p>Sedang Memuat...</p>;
|
||||||
|
if (error) return <div className="text-sm text-red-500 p-4">Gagal memuat data sertifikat.</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
@@ -137,151 +49,42 @@ export default function CertificateResult() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Success Hero ── */}
|
{/* Success Hero */}
|
||||||
<div className="flex flex-col items-center text-center mb-8 px-4">
|
<div className="flex flex-col items-center text-center mb-8 px-4">
|
||||||
{/* Icon circle */}
|
<div className="w-16 h-16 rounded-full bg-red flex items-center justify-center shadow-lg mb-5">
|
||||||
<div className="w-16 h-16 rounded-full bg-[#B30000] flex items-center justify-center shadow-lg mb-5">
|
|
||||||
<CheckCircle2 className="w-8 h-8 text-white" strokeWidth={2.5} />
|
<CheckCircle2 className="w-8 h-8 text-white" strokeWidth={2.5} />
|
||||||
</div>
|
</div>
|
||||||
|
<h2 className="text-3xl md:text-4xl font-extrabold text-red mb-3 leading-tight">
|
||||||
{/* Headline */}
|
{certificates.length} Certificates has been Generated!
|
||||||
<h2 className="text-3xl md:text-4xl font-extrabold text-[#B30000] mb-3 leading-tight">
|
|
||||||
{certificates.length} Certificates Successfully Generated!
|
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Subtext */}
|
|
||||||
<p className="text-sm text-gray-500 max-w-md leading-relaxed mb-6">
|
<p className="text-sm text-gray-500 max-w-md leading-relaxed mb-6">
|
||||||
The validation engine has processed all engineering credentials. You can now
|
The validation engine has processed all engineering credentials. You can now
|
||||||
publish them to the public registry or download the batch for offline distribution.
|
publish them to the public registry or download the batch for offline distribution.
|
||||||
</p>
|
</p>
|
||||||
|
<button
|
||||||
{/* Download CTA */}
|
onClick={handleDownloadZip}
|
||||||
<button className="flex items-center gap-2 px-6 py-2.5 border border-gray-200 rounded-xl text-sm font-bold text-[#B30000] bg-white hover:bg-red-50 hover:border-red-200 shadow-sm transition-colors">
|
disabled={zipping || certificates.length === 0}
|
||||||
<FolderArchive className="w-4 h-4" />
|
className="flex items-center gap-2 px-6 py-2.5 border border-gray-200 rounded-xl text-sm font-bold text-red bg-white hover:bg-red-50 hover:border-red-200 shadow-sm transition-colors disabled:opacity-50"
|
||||||
Download All (ZIP)
|
>
|
||||||
|
{zipping ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
{zipProgress ? `Mengemas ${zipProgress.done}/${zipProgress.total}...` : "Menyiapkan..."}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FolderArchive className="w-4 h-4" />
|
||||||
|
Download All (ZIP)
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Bottom Section: Table + Preview Card ── */}
|
{/* Table */}
|
||||||
<div className="flex flex-col xl:flex-row gap-4 items-start">
|
<CertResultTable
|
||||||
|
certificates={certificates}
|
||||||
{/* Recipient Directory Table */}
|
templateUrl={project?.certificate_template ?? ""}
|
||||||
<div className="flex-1 min-w-0 border border-gray-200 rounded-2xl overflow-hidden bg-white shadow-sm">
|
/>
|
||||||
|
|
||||||
{/* Table Header */}
|
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
|
||||||
<h3 className="text-sm font-bold text-gray-900">Recipient Directory</h3>
|
|
||||||
{/* Search */}
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Filter interns..."
|
|
||||||
value={search}
|
|
||||||
onChange={handleSearch}
|
|
||||||
className="pl-8 pr-3 py-1.5 text-xs border border-gray-200 rounded-lg bg-white text-gray-700 placeholder-gray-400 focus:outline-none focus:border-red-400 focus:ring-1 focus:ring-red-400 w-44 transition-colors"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Column Headers */}
|
|
||||||
<div className="grid grid-cols-[2fr_1.5fr_auto] px-5 py-2.5 bg-gray-50 border-b border-gray-100">
|
|
||||||
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Intern Name</span>
|
|
||||||
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Credential ID</span>
|
|
||||||
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Actions</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Rows */}
|
|
||||||
<div className="divide-y divide-gray-50">
|
|
||||||
{paginated.length === 0 ? (
|
|
||||||
<div className="px-5 py-10 text-center text-sm text-gray-400">
|
|
||||||
No interns match your search.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
paginated.map((cert: Certificate) => (
|
|
||||||
<div
|
|
||||||
key={cert.id}
|
|
||||||
className="grid grid-cols-[2fr_1.5fr_auto] items-center px-5 py-3.5 hover:bg-gray-50/60 transition-colors"
|
|
||||||
>
|
|
||||||
{/* Name + role */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Avatar name={cert.user.full_name} />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-bold text-gray-900 leading-tight">{cert.user.full_name}</p>
|
|
||||||
{/* <p className="text-[11px] text-gray-400 mt-0.5">{cert.role}</p> */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Credential ID */}
|
|
||||||
<p className="text-xs font-mono text-gray-500 tracking-wide">{cert.uuid}</p>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-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">
|
|
||||||
<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 className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-md transition-colors" title="Share">
|
|
||||||
<Share2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination Footer */}
|
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 px-5 py-3.5 border-t border-gray-100 bg-gray-50/30">
|
|
||||||
<p className="text-xs text-gray-400 font-medium">
|
|
||||||
Showing {filtered.length === 0 ? 0 : (page - 1) * PER_PAGE + 1} to{" "}
|
|
||||||
{Math.min(page * PER_PAGE, filtered.length)} of {certificates.length} interns
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
||||||
disabled={page === 1}
|
|
||||||
className="p-1 border border-gray-200 rounded-md bg-white text-gray-400 hover:bg-gray-50 disabled:opacity-40 transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (
|
|
||||||
<button
|
|
||||||
key={n}
|
|
||||||
onClick={() => setPage(n)}
|
|
||||||
className={`w-7 h-7 flex items-center justify-center rounded-md text-xs font-bold transition-colors ${n === page
|
|
||||||
? "bg-[#B30000] text-white shadow-sm"
|
|
||||||
: "bg-white border border-gray-200 text-gray-600 hover:bg-gray-50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{n}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
||||||
disabled={page === totalPages || totalPages === 0}
|
|
||||||
className="p-1 border border-gray-200 rounded-md bg-white text-gray-400 hover:bg-gray-50 disabled:opacity-40 transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronRight className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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="mt-auto">
|
|
||||||
<p className="text-white font-extrabold text-base leading-snug">
|
|
||||||
Institutional Excellence
|
|
||||||
</p>
|
|
||||||
<p className="text-red-200 text-xs mt-1">
|
|
||||||
Certified via Mentor Portal v2.4
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div> */}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { Search, SlidersHorizontal, Plus, Trash2 } from "lucide-react";
|
import { Search, SlidersHorizontal, Plus, Trash2 } from "lucide-react";
|
||||||
import { useAllInterns, useRemoveMember, useProjects } from "../../../hooks/useProjects";
|
import { useAllInterns, useRemoveMember, useProjects } from "../../../hooks/useProjects";
|
||||||
import AddInternsModal from "./components/AddInternsToProjects";
|
import AddInternsModal from "./components/AddInternsToProjects";
|
||||||
|
import type { InternDetail } from "../../../types/project.types";
|
||||||
|
import type { Project } from "../../../types/project.types";
|
||||||
|
|
||||||
export default function InternsManagement() {
|
export default function InternsManagement() {
|
||||||
const [searchEmail, setSearchEmail] = useState("");
|
const [searchEmail, setSearchEmail] = useState("");
|
||||||
const [selectedProject, setSelectedProject] = useState("All Projects");
|
const [selectedProject, setSelectedProject] = useState("All Projects");
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
|
||||||
|
|
||||||
const { interns, loading, error, refetch } = useAllInterns();
|
const { interns, loading, error, refetch } = useAllInterns();
|
||||||
const { remove, loading: removing } = useRemoveMember();
|
const { remove, loading: removing } = useRemoveMember();
|
||||||
@@ -23,22 +26,24 @@ export default function InternsManagement() {
|
|||||||
});
|
});
|
||||||
}, [interns, searchEmail, selectedProject]);
|
}, [interns, searchEmail, selectedProject]);
|
||||||
|
|
||||||
// Hitung statistik dari data asli
|
|
||||||
const totalInterns = interns.length;
|
const totalInterns = interns.length;
|
||||||
// Active assignments = total member dari semua project yang statusnya active
|
|
||||||
const activeAssignments = activeProjects.length;
|
const activeAssignments = activeProjects.length;
|
||||||
|
|
||||||
|
// Buka modal untuk assign intern tertentu (dari tombol + di baris)
|
||||||
|
const openAssignFor = (userId: number | null) => {
|
||||||
|
setSelectedUserId(userId);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
// Handler hapus intern dari project
|
// Handler hapus intern dari project
|
||||||
// Catatan: useRemoveMember memerlukan id_project & id_user.
|
const handleDeleteIntern = async (intern: InternDetail) => {
|
||||||
// Karena InternDetail tidak menyimpan id_project, aksi ini saat ini hanya placeholder.
|
const matchedProject = activeProjects.find((project: Project) => project.project_name === intern.projectName);
|
||||||
// Untuk implementasi penuh, perlu endpoint khusus atau data id_project dari InternDetail.
|
if (!matchedProject) {
|
||||||
const handleDeleteIntern = async (internId: number) => {
|
console.error("Project not found for intern:", intern);
|
||||||
const confirmed = window.confirm("Yakin ingin menghapus intern ini dari project?");
|
return;
|
||||||
if (!confirmed) return;
|
}
|
||||||
alert(`Remove intern ID ${internId} — perlu id_project dari backend untuk dilanjutkan.`);
|
const res = await remove({ id_project: matchedProject.id, id_user: intern.id });
|
||||||
// Contoh penggunaan jika id_project tersedia:
|
if (res !== null) refetch();
|
||||||
// const res = await remove({ id_project: intern.id_project, id_user: internId });
|
|
||||||
// if (res !== null) refetch();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return (
|
if (loading) return (
|
||||||
@@ -127,7 +132,7 @@ export default function InternsManagement() {
|
|||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button
|
<button
|
||||||
className="inline-flex items-center gap-2 px-3 py-1 bg-red-700 text-white rounded-xl shadow-sm hover:bg-red-800 transition-colors focus:outline-none focus:ring-2 focus:ring-red-300"
|
className="inline-flex items-center gap-2 px-3 py-1 bg-red-700 text-white rounded-xl shadow-sm hover:bg-red-800 transition-colors focus:outline-none focus:ring-2 focus:ring-red-300"
|
||||||
onClick={() => setIsModalOpen(true)}
|
onClick={() => openAssignFor(null)}
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
Add
|
Add
|
||||||
@@ -184,9 +189,9 @@ export default function InternsManagement() {
|
|||||||
|
|
||||||
{/* Column 3: Action Buttons Block Right aligned */}
|
{/* Column 3: Action Buttons Block Right aligned */}
|
||||||
<div className="w-1/6 flex items-center justify-end gap-3.5 pr-2">
|
<div className="w-1/6 flex items-center justify-end gap-3.5 pr-2">
|
||||||
{/* Tombol Add: membuka modal assign member */}
|
{/* Tombol Add: membuka modal assign member untuk intern ini */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsModalOpen(true)}
|
onClick={() => openAssignFor(intern.id)}
|
||||||
className="p-1.5 bg-[#B30000] hover:bg-[#990000] text-white rounded-lg transition-colors shadow-sm"
|
className="p-1.5 bg-[#B30000] hover:bg-[#990000] text-white rounded-lg transition-colors shadow-sm"
|
||||||
title="Assign ke project"
|
title="Assign ke project"
|
||||||
>
|
>
|
||||||
@@ -194,7 +199,7 @@ export default function InternsManagement() {
|
|||||||
</button>
|
</button>
|
||||||
{/* Tombol Delete: remove intern dari project */}
|
{/* Tombol Delete: remove intern dari project */}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteIntern(intern.id)}
|
onClick={() => handleDeleteIntern(intern)}
|
||||||
disabled={removing}
|
disabled={removing}
|
||||||
className="p-1.5 text-red-500 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-50"
|
className="p-1.5 text-red-500 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-50"
|
||||||
title="Hapus dari project"
|
title="Hapus dari project"
|
||||||
@@ -233,6 +238,7 @@ export default function InternsManagement() {
|
|||||||
{isModalOpen &&
|
{isModalOpen &&
|
||||||
<AddInternsModal
|
<AddInternsModal
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
|
userId={selectedUserId}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
refetch(); // Refresh data setelah modal assign ditutup
|
refetch(); // Refresh data setelah modal assign ditutup
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
// ManageInternsModal.tsx
|
// ManageInternsModal.tsx
|
||||||
import { useRef, useEffect, useState } from "react";
|
import { useRef, useEffect, useState } from "react";
|
||||||
import { Search, Plus, Trash2 } from "lucide-react";
|
import { Search, Plus, Trash2 } from "lucide-react";
|
||||||
import { useMahasiswa } from "../../../../hooks/useUser";
|
import { useAssignableInterns } from "../../../../hooks/useProjects";
|
||||||
import { useAssignMember } from '../../../../hooks/useProjects';
|
import { useAssignMember } from '../../../../hooks/useProjects';
|
||||||
import type { ProjectMember } from '../../../../types/project.types';
|
import type { ProjectMember } from '../../../../types/project.types';
|
||||||
import type { Mahasiswa } from '../../../../types/project.types';
|
import type { AssignableIntern } from '../../../../types/project.types';
|
||||||
|
|
||||||
interface ManageInternsModalProps {
|
interface ManageInternsModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -18,7 +18,7 @@ export default function ManageInternsModal({ isOpen, onClose, projectId, initial
|
|||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [invitedList, setInvitedList] = useState<ProjectMember[]>(initialMembers);
|
const [invitedList, setInvitedList] = useState<ProjectMember[]>(initialMembers);
|
||||||
|
|
||||||
const { mahasiswa, loading: loadingMahasiswa } = useMahasiswa();
|
const { interns, loading: loadingAssignableInterns } = useAssignableInterns();
|
||||||
const { assign, loading: assigning, error } = useAssignMember();
|
const { assign, loading: assigning, error } = useAssignMember();
|
||||||
|
|
||||||
// Sync saat modal dibuka
|
// Sync saat modal dibuka
|
||||||
@@ -44,32 +44,33 @@ export default function ManageInternsModal({ isOpen, onClose, projectId, initial
|
|||||||
};
|
};
|
||||||
}, [isOpen, onClose]);
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
// Filter mahasiswa yang belum jadi member & sesuai search
|
// Filter assignable interns yang belum jadi member & sesuai search
|
||||||
const invitedIds = new Set(invitedList.map(m => m.id));
|
const invitedIds = new Set(invitedList.map(m => m.id));
|
||||||
const searchResult = mahasiswa.filter(m =>
|
const searchResult = interns.filter(m =>
|
||||||
!invitedIds.has(m.id) &&
|
!invitedIds.has(m.id) &&
|
||||||
(m.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
(m.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
`${m.nama_depan} ${m.nama_belakang}`.toLowerCase().includes(searchQuery.toLowerCase()))
|
`${m.full_name}`.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAssign = async (intern: Mahasiswa) => {
|
const handleAssign = async (intern: AssignableIntern) => {
|
||||||
const result = await assign({ id_project: projectId, id_user: intern.id });
|
const result = await assign({ id_project: projectId, id_user: intern.id });
|
||||||
if (result) {
|
if (result) {
|
||||||
setInvitedList(prev => [...prev, {
|
setInvitedList(prev => [...prev, {
|
||||||
id: intern.id,
|
id: intern.id,
|
||||||
id_user: intern.id,
|
id_user: intern.id,
|
||||||
full_name: `${intern.nama_depan} ${intern.nama_belakang}`,
|
// full_name: `${intern.nama_depan} ${intern.nama_belakang}`,
|
||||||
|
full_name: intern.full_name,
|
||||||
email: intern.email,
|
email: intern.email,
|
||||||
avatar: "",
|
avatar: "",
|
||||||
kelompok_peminatan: intern.jurusan,
|
kelompok_peminatan: intern.kelompok_peminatan,
|
||||||
professional_bio: "",
|
professional_bio: "",
|
||||||
position: intern.role,
|
position: intern.position,
|
||||||
} as ProjectMember]);
|
} as ProjectMember]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getInitials = (m: Mahasiswa) =>
|
const getInitials = (m: AssignableIntern) =>
|
||||||
`${m.nama_depan[0]}${m.nama_belakang[0]}`.toUpperCase();
|
m.full_name.toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<dialog ref={dialogRef} className="custom-dialog">
|
<dialog ref={dialogRef} className="custom-dialog">
|
||||||
@@ -95,13 +96,9 @@ export default function ManageInternsModal({ isOpen, onClose, projectId, initial
|
|||||||
{invitedList.map((member) => (
|
{invitedList.map((member) => (
|
||||||
<div key={member.id} className="flex items-center justify-between p-2 rounded-xl hover:bg-gray-50 transition-colors">
|
<div key={member.id} className="flex items-center justify-between p-2 rounded-xl hover:bg-gray-50 transition-colors">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{member.avatar ? (
|
|
||||||
<img src={member.avatar} alt={member.full_name} className="w-9 h-9 rounded-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<div className="w-9 h-9 rounded-full bg-gray-200 border border-gray-300 flex items-center justify-center text-xs font-bold text-gray-500">
|
<div className="w-9 h-9 rounded-full bg-gray-200 border border-gray-300 flex items-center justify-center text-xs font-bold text-gray-500">
|
||||||
{member.full_name.slice(0, 2).toUpperCase()}
|
{member.full_name.slice(0, 2).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-sm font-medium text-gray-700">{member.full_name}</span>
|
<span className="text-sm font-medium text-gray-700">{member.full_name}</span>
|
||||||
<span className="text-xs text-gray-400">{member.email}</span>
|
<span className="text-xs text-gray-400">{member.email}</span>
|
||||||
@@ -117,17 +114,17 @@ export default function ManageInternsModal({ isOpen, onClose, projectId, initial
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Hasil pencarian — belum jadi member */}
|
{/* Hasil pencarian — belum jadi member */}
|
||||||
{loadingMahasiswa ? (
|
{loadingAssignableInterns ? (
|
||||||
<p className="text-sm text-gray-400 text-center py-2">Memuat...</p>
|
<p className="text-sm text-gray-400 text-center py-2">Memuat...</p>
|
||||||
) : (
|
) : (
|
||||||
searchResult.map((intern) => (
|
searchResult.map((intern: AssignableIntern) => (
|
||||||
<div key={intern.id} className="flex items-center justify-between p-2 rounded-xl hover:bg-gray-50 transition-colors">
|
<div key={intern.id} className="flex items-center justify-between p-2 rounded-xl hover:bg-gray-50 transition-colors">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-9 h-9 rounded-full bg-gray-200 border border-gray-300 flex items-center justify-center text-xs font-bold text-gray-500">
|
<div className="w-9 h-9 rounded-full bg-gray-200 border border-gray-300 flex items-center justify-center text-xs font-bold text-gray-500">
|
||||||
{getInitials(intern)}
|
{getInitials(intern)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-sm font-medium text-gray-700">{intern.nama_depan} {intern.nama_belakang}</span>
|
<span className="text-sm font-medium text-gray-700">{intern.full_name}</span>
|
||||||
<span className="text-xs text-gray-400">{intern.email}</span>
|
<span className="text-xs text-gray-400">{intern.email}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,7 +139,7 @@ export default function ManageInternsModal({ isOpen, onClose, projectId, initial
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loadingMahasiswa && searchQuery && searchResult.length === 0 && (
|
{!loadingAssignableInterns && searchQuery && searchResult.length === 0 && (
|
||||||
<p className="text-sm text-gray-400 text-center py-2">Tidak ada mahasiswa ditemukan.</p>
|
<p className="text-sm text-gray-400 text-center py-2">Tidak ada mahasiswa ditemukan.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,23 +1,26 @@
|
|||||||
import { useRef, useEffect, useState } from "react";
|
import { useRef, useEffect, useState } from "react";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { LuUserPlus, LuMail, LuFolderOpen } from "react-icons/lu";
|
import { LuUserPlus, LuUser, LuFolderOpen } from "react-icons/lu";
|
||||||
import { useAssignMember, useProjects } from "../../../../hooks/useProjects";
|
import { useAssignMember, useProjects } from "../../../../hooks/useProjects";
|
||||||
|
import { useAssignableInterns } from "../../../../hooks/useProjects";
|
||||||
|
|
||||||
interface AddInternsModalProps {
|
interface AddInternsModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
userId: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AddInternsModal({ isOpen, onClose }: AddInternsModalProps) {
|
export default function AddInternsModal({ isOpen, onClose, userId }: AddInternsModalProps) {
|
||||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
// Form States
|
// Form States
|
||||||
const [email, setEmail] = useState("");
|
const [selectedUserId, setSelectedUserId] = useState<string>("");
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState<string>("");
|
const [selectedProjectId, setSelectedProjectId] = useState<string>("");
|
||||||
|
|
||||||
// Hooks
|
// Hooks
|
||||||
const { assign, loading: assigning, error: assignError } = useAssignMember();
|
const { assign, loading: assigning, error: assignError } = useAssignMember();
|
||||||
const { projects, loading: loadingProjects } = useProjects("active");
|
const { projects, loading: loadingProjects } = useProjects("active");
|
||||||
|
const { interns, loading: loadingInterns } = useAssignableInterns();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const dialog = dialogRef.current;
|
const dialog = dialogRef.current;
|
||||||
@@ -43,30 +46,26 @@ export default function AddInternsModal({ isOpen, onClose }: AddInternsModalProp
|
|||||||
};
|
};
|
||||||
}, [isOpen, onClose]);
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
// Reset form saat modal dibuka kembali
|
// Reset form saat modal dibuka kembali.
|
||||||
|
// Kalau userId diberikan, dropdown langsung ter-select ke intern tsb.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
setEmail("");
|
setSelectedUserId(userId !== null ? String(userId) : "");
|
||||||
setSelectedProjectId("");
|
setSelectedProjectId("");
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen, userId]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!selectedUserId) return;
|
||||||
|
|
||||||
// Catatan: useAssignMember memerlukan id_user (number), bukan email.
|
const res = await assign({
|
||||||
// Endpoint /project-api/assign-member menerima { id_project: number, id_user: number }.
|
id_project: Number(selectedProjectId),
|
||||||
// Untuk implementasi berbasis email, perlu endpoint tambahan atau lookup user by email.
|
id_user: Number(selectedUserId),
|
||||||
// Saat ini form ini dikomen karena tidak ada lookup email → id_user di service.
|
});
|
||||||
|
if (res !== null) {
|
||||||
// Contoh penggunaan jika id_user sudah diketahui:
|
onClose();
|
||||||
// const res = await assign({ id_project: Number(selectedProjectId), id_user: userId });
|
}
|
||||||
// if (res !== null) {
|
|
||||||
// onClose();
|
|
||||||
// }
|
|
||||||
|
|
||||||
alert(`Assign intern "${email}" ke project ID ${selectedProjectId} — perlu lookup id_user dari email via backend.`);
|
|
||||||
onClose();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -92,23 +91,36 @@ export default function AddInternsModal({ isOpen, onClose }: AddInternsModalProp
|
|||||||
{/* Form Body */}
|
{/* Form Body */}
|
||||||
<form onSubmit={handleSubmit} className="bg-gray-100 px-6 py-6 space-y-4 overflow-y-auto flex-1">
|
<form onSubmit={handleSubmit} className="bg-gray-100 px-6 py-6 space-y-4 overflow-y-auto flex-1">
|
||||||
|
|
||||||
{/* Email Address */}
|
{/* Intern Selection */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-bold tracking-wide text-foreground">
|
<label className="text-xs font-bold tracking-wide text-foreground">
|
||||||
Email Address
|
Intern
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<span className="absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none text-border">
|
<span className="absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none text-border">
|
||||||
<LuMail className="w-4 h-4 stroke-[2]" />
|
<LuUser className="w-4 h-4 stroke-[2]" />
|
||||||
</span>
|
</span>
|
||||||
<input
|
<select
|
||||||
type="email"
|
|
||||||
required
|
required
|
||||||
placeholder="intern.name@gmail.com"
|
value={selectedUserId}
|
||||||
value={email}
|
onChange={(e) => setSelectedUserId(e.target.value)}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
disabled={loadingInterns}
|
||||||
className="w-full pl-10 pr-4 py-2.5 bg-white text-sm font-medium border border-border rounded-radius text-foreground focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
className="w-full pl-10 pr-9 py-2.5 bg-white text-sm font-medium appearance-none cursor-pointer border border-border rounded-radius focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all text-foreground disabled:opacity-60"
|
||||||
/>
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
{loadingInterns ? "Memuat interns..." : "Pilih intern"}
|
||||||
|
</option>
|
||||||
|
{interns.map((intern) => (
|
||||||
|
<option key={intern.id} value={intern.id}>
|
||||||
|
{intern.full_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-border">
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -145,12 +157,6 @@ export default function AddInternsModal({ isOpen, onClose }: AddInternsModalProp
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Field Position dikomentari — tidak ada dalam payload assign-member */}
|
|
||||||
{/* <div className="space-y-1.5">
|
|
||||||
<label className="text-xs font-bold tracking-wide text-foreground">Position</label>
|
|
||||||
<select>...</select>
|
|
||||||
</div> */}
|
|
||||||
|
|
||||||
{assignError && (
|
{assignError && (
|
||||||
<p className="text-xs text-red-600 font-medium">{assignError}</p>
|
<p className="text-xs text-red-600 font-medium">{assignError}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Search, Eye, Download, Share2,
|
||||||
|
ChevronLeft, ChevronRight, Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { Certificate } from "../../../../types/certificate.types";
|
||||||
|
import {
|
||||||
|
previewCertificate,
|
||||||
|
downloadCertificatePdf,
|
||||||
|
} from "../../../utils/Certificates";
|
||||||
|
|
||||||
|
const PER_PAGE = 5;
|
||||||
|
|
||||||
|
const getInitials = (name: string): string => {
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
if (parts.length === 1) return parts[0][0].toUpperCase();
|
||||||
|
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
function Avatar({ name }: { name: string }) {
|
||||||
|
return (
|
||||||
|
<div className="w-9 h-9 rounded-full flex items-center justify-center text-[11px] font-black flex-shrink-0 bg-gray-100 text-gray-600">
|
||||||
|
{getInitials(name)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CertificateTableProps {
|
||||||
|
certificates: Certificate[];
|
||||||
|
templateUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CertResultTable({ certificates, templateUrl }: CertificateTableProps) {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [downloadingId, setDownloadingId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const handlePreview = (cert: Certificate) => {
|
||||||
|
previewCertificate(cert, templateUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadPdf = async (cert: Certificate) => {
|
||||||
|
setDownloadingId(cert.id);
|
||||||
|
try {
|
||||||
|
await downloadCertificatePdf(cert, templateUrl);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Gagal download PDF:", err);
|
||||||
|
} finally {
|
||||||
|
setDownloadingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = certificates.filter(
|
||||||
|
(c) =>
|
||||||
|
c.user.full_name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
c.user.email.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
const totalPages = Math.ceil(filtered.length / PER_PAGE);
|
||||||
|
const paginated = filtered.slice((page - 1) * PER_PAGE, page * PER_PAGE);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-200 rounded-2xl overflow-hidden bg-white shadow-sm">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
||||||
|
<h3 className="text-sm font-bold text-gray-900">Recipient Directory</h3>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Filter interns..."
|
||||||
|
value={search}
|
||||||
|
onChange={handleSearch}
|
||||||
|
className="pl-8 pr-3 py-1.5 text-xs border border-card-outline rounded-lg bg-white text-font-shade placeholder-font focus:outline-none focus:border-red-400 focus:ring-1 focus:ring-red-400 w-44 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column Headers */}
|
||||||
|
<div className="grid grid-cols-[2fr_1.5fr_auto] px-5 py-2.5 bg-gray-50 border-b border-gray-100">
|
||||||
|
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Intern Name</span>
|
||||||
|
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Credential ID</span>
|
||||||
|
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Actions</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rows */}
|
||||||
|
<div className="divide-y divide-gray-50">
|
||||||
|
{paginated.length === 0 ? (
|
||||||
|
<div className="px-5 py-10 text-center text-sm text-gray-400">
|
||||||
|
No interns match your search.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
paginated.map((cert) => (
|
||||||
|
<div
|
||||||
|
key={cert.id}
|
||||||
|
className="grid grid-cols-[2fr_1.5fr_auto] items-center px-5 py-3.5 hover:bg-gray-50/60 transition-colors"
|
||||||
|
>
|
||||||
|
{/* Name */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar name={cert.user.full_name} />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-gray-900 leading-tight">{cert.user.full_name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Credential ID */}
|
||||||
|
<p className="text-xs font-mono text-gray-500 tracking-wide">{cert.uuid}</p>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => handlePreview(cert)}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-md transition-colors"
|
||||||
|
title="Preview"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownloadPdf(cert)}
|
||||||
|
disabled={downloadingId === cert.id}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-md transition-colors disabled:opacity-40"
|
||||||
|
title="Download PDF"
|
||||||
|
>
|
||||||
|
{downloadingId === cert.id
|
||||||
|
? <Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
: <Download className="w-4 h-4" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-md transition-colors"
|
||||||
|
title="Share"
|
||||||
|
>
|
||||||
|
<Share2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 px-5 py-3.5 border-t border-gray-100 bg-gray-50/30">
|
||||||
|
<p className="text-xs text-gray-400 font-medium">
|
||||||
|
Showing {filtered.length === 0 ? 0 : (page - 1) * PER_PAGE + 1} to{" "}
|
||||||
|
{Math.min(page * PER_PAGE, filtered.length)} of {certificates.length} interns
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="p-1 border border-gray-200 rounded-md bg-white text-gray-400 hover:bg-gray-50 disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
onClick={() => setPage(n)}
|
||||||
|
className={`w-7 h-7 flex items-center justify-center rounded-md text-xs font-bold transition-colors ${
|
||||||
|
n === page
|
||||||
|
? "bg-[#B30000] text-white shadow-sm"
|
||||||
|
: "bg-white border border-gray-200 text-gray-600 hover:bg-gray-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages || totalPages === 0}
|
||||||
|
className="p-1 border border-gray-200 rounded-md bg-white text-gray-400 hover:bg-gray-50 disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,13 +3,15 @@ import { X } from "lucide-react";
|
|||||||
import { PROJECT_ICON_MAP, PROJECT_ICON_CODES } from "../../../../lib/ProjectIcons";
|
import { PROJECT_ICON_MAP, PROJECT_ICON_CODES } from "../../../../lib/ProjectIcons";
|
||||||
import { LuSendHorizontal, LuMail } from "react-icons/lu";
|
import { LuSendHorizontal, LuMail } from "react-icons/lu";
|
||||||
import { useCreateProject } from "../../../../hooks/useProjects";
|
import { useCreateProject } from "../../../../hooks/useProjects";
|
||||||
|
import type { CreateProjectPayload } from "../../../../types/project.types";
|
||||||
|
|
||||||
interface CreateProjectModalProps {
|
interface CreateProjectModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onSuccess?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CreateProjectModal({ isOpen, onClose }: CreateProjectModalProps) {
|
export default function CreateProjectModal({ isOpen, onClose, onSuccess }: CreateProjectModalProps) {
|
||||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
const [selectedIcon, setSelectedIcon] = useState(PROJECT_ICON_CODES[0]);
|
const [selectedIcon, setSelectedIcon] = useState(PROJECT_ICON_CODES[0]);
|
||||||
const [projectName, setProjectName] = useState("");
|
const [projectName, setProjectName] = useState("");
|
||||||
@@ -61,23 +63,23 @@ export default function CreateProjectModal({ isOpen, onClose }: CreateProjectMod
|
|||||||
setInvitedEmails(invitedEmails.filter(email => email !== emailToRemove));
|
setInvitedEmails(invitedEmails.filter(email => email !== emailToRemove));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handler Submit Utama
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const payload: any = {
|
const payload: CreateProjectPayload = {
|
||||||
project_icon: selectedIcon,
|
project_icon: selectedIcon,
|
||||||
project_name: projectName,
|
project_name: projectName,
|
||||||
description: projectDescription,
|
description: projectDescription,
|
||||||
start_date: startDate,
|
start_date: startDate,
|
||||||
end_date: endDate,
|
end_date: endDate,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (invitedEmails.length > 0) {
|
if (invitedEmails.length > 0) {
|
||||||
payload.member_emails = invitedEmails;
|
payload.member_emails = invitedEmails;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await create(payload);
|
const result = await create(payload);
|
||||||
if (result) {
|
if (result) {
|
||||||
|
onSuccess?.(); // panggil refetch di parent (kalau ada)
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type { InternSubmissionProgress } from "../../../../hooks/useInternProgre
|
|||||||
import { useProjectDetail } from "../../../../hooks/useProjects";
|
import { useProjectDetail } from "../../../../hooks/useProjects";
|
||||||
import JSZip from "jszip";
|
import JSZip from "jszip";
|
||||||
import { generateCertificate } from "../../../utils/SertificateGenerator";
|
import { generateCertificate } from "../../../utils/SertificateGenerator";
|
||||||
|
import { useGenerateCertificates } from "../../../../hooks/useCertificates";
|
||||||
import Avatar from "../../Avatar";
|
import Avatar from "../../Avatar";
|
||||||
import ProgressBar from "../../ProgressBar";
|
import ProgressBar from "../../ProgressBar";
|
||||||
|
|
||||||
@@ -47,20 +48,19 @@ export default function EligibleInternTable({
|
|||||||
}: EligibleInternTableProps) {
|
}: EligibleInternTableProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { project } = useProjectDetail(String(projectId));
|
const { project } = useProjectDetail(String(projectId));
|
||||||
|
const { generate: SubmitCertificates, loading: generatingCertificates, error, success, reset } = useGenerateCertificates();
|
||||||
|
|
||||||
const initialSelected = new Set(
|
const initialSelected = new Set(
|
||||||
interns.filter(i => i.is_eligible).map(i => i.id_user)
|
interns.filter(i => i.is_eligible).map(i => i.id_user)
|
||||||
);
|
);
|
||||||
// Pemilihan Id
|
|
||||||
const [selected, setSelected] = useState<Set<number>>(initialSelected);
|
const [selected, setSelected] = useState<Set<number>>(initialSelected);
|
||||||
// state saat generate sertifikat
|
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
const [generateError, setGenerateError] = useState<string | null>(null);
|
const [generateError, setGenerateError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Halaman (Pagination)
|
// Halaman (Pagination)
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const totalPages = Math.ceil(interns.length / PER_PAGE);
|
const totalPages = Math.ceil(interns.length / PER_PAGE);
|
||||||
const paginated = interns.slice((page - 1) * PER_PAGE, page * PER_PAGE);
|
const paginated = interns.slice((page - 1) * PER_PAGE, page * PER_PAGE);
|
||||||
|
|
||||||
const eligibleIds = interns.filter(i => i.is_eligible).map(i => i.id_user);
|
const eligibleIds = interns.filter(i => i.is_eligible).map(i => i.id_user);
|
||||||
const allEligibleSelected = eligibleIds.every(id => selected.has(id));
|
const allEligibleSelected = eligibleIds.every(id => selected.has(id));
|
||||||
|
|
||||||
@@ -78,56 +78,48 @@ export default function EligibleInternTable({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleGenerate = async () => {
|
const handleGenerate = async () => {
|
||||||
// NOTE: DI RESPONSE GAK DIKASIH TAHU DIMANA SIMPAN TEMPLATE SERTIF NYA
|
if (!project?.certificate_template) {
|
||||||
if (!project?.certificate_template) {
|
setGenerateError("Template sertifikat belum diupload.");
|
||||||
setGenerateError("Template sertifikat belum diupload.");
|
return;
|
||||||
return;
|
}
|
||||||
}
|
if (selected.size === 0) {
|
||||||
if (selected.size === 0) {
|
setGenerateError("Pilih minimal satu intern.");
|
||||||
setGenerateError("Pilih minimal satu intern.");
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
setGenerating(true);
|
setGenerating(true);
|
||||||
setGenerateError(null);
|
setGenerateError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const selectedInterns = interns.filter((i) => selected.has(i.id_user));
|
// 1. Generate blob PNG per intern & kumpulkan ke ZIP
|
||||||
const zip = new JSZip();
|
const selectedInterns = interns.filter((i) => selected.has(i.id_user));
|
||||||
|
const zip = new JSZip();
|
||||||
|
|
||||||
// Generate semua sertifikat secara paralel
|
await Promise.all(
|
||||||
await Promise.all(
|
selectedInterns.map(async (intern) => {
|
||||||
selectedInterns.map(async (intern) => {
|
const blob = await generateCertificate(
|
||||||
const blob = await generateCertificate(
|
project.certificate_template,
|
||||||
project.certificate_template,
|
intern.full_name
|
||||||
intern.full_name
|
);
|
||||||
);
|
zip.file(`Sertifikat - ${intern.full_name}.png`, blob);
|
||||||
// Nama file: "Sertifikat - Nama Intern.png"
|
})
|
||||||
const filename = `Sertifikat - ${intern.full_name}.png`;
|
);
|
||||||
zip.file(filename, blob);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Download ZIP
|
// 2. POST ke backend untuk mencatat sertifikat sudah di-generate
|
||||||
const zipBlob = await zip.generateAsync({ type: "blob" });
|
const ok = await SubmitCertificates(projectId, Array.from(selected));
|
||||||
const url = URL.createObjectURL(zipBlob);
|
if (!ok) return;
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = `Sertifikat-Project-${projectId}.zip`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
|
|
||||||
// Navigasi ke result page
|
// 3. Navigasi ke result page
|
||||||
navigate(`/mentor/certificates/${projectId}/result`);
|
navigate(`/mentor/certificates/${projectId}/result`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setGenerateError(
|
setGenerateError(
|
||||||
err instanceof Error ? err.message : "Gagal generate sertifikat."
|
err instanceof Error ? err.message : "Gagal generate sertifikat."
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setGenerating(false);
|
setGenerating(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ import { useState } from 'react';
|
|||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import CreateProjectModal from './CreateProjectDialog';
|
import CreateProjectModal from './CreateProjectDialog';
|
||||||
import { useProjects } from '../../../../hooks/useProjects';
|
import { useProjects } from '../../../../hooks/useProjects';
|
||||||
|
import * as LucideIcons from 'lucide-react';
|
||||||
|
import type { LucideProps } from 'lucide-react';
|
||||||
|
|
||||||
|
const getDynamicIcon = (iconName: string) => {
|
||||||
|
const formatted = iconName.charAt(0).toUpperCase() + iconName.slice(1);
|
||||||
|
const Icon = (LucideIcons as unknown as Record<string, React.ComponentType<LucideProps>>)[formatted];
|
||||||
|
return Icon ?? LucideIcons.FolderOpen;
|
||||||
|
};
|
||||||
|
|
||||||
const HomeMentorProject = () => {
|
const HomeMentorProject = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -36,7 +44,11 @@ const HomeMentorProject = () => {
|
|||||||
onClick={() => navigate(`/mentor/projects/${project.slug}`)}
|
onClick={() => navigate(`/mentor/projects/${project.slug}`)}
|
||||||
className="flex items-center gap-4 border border-box-border rounded-lg px-4 py-3 hover:bg-gray-50 cursor-pointer transition-colors duration-150"
|
className="flex items-center gap-4 border border-box-border rounded-lg px-4 py-3 hover:bg-gray-50 cursor-pointer transition-colors duration-150"
|
||||||
>
|
>
|
||||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 bg-gray-300">
|
<div className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0" style={{ backgroundColor: project.background_color ?? '#dc2626' }}>
|
||||||
|
{(() => {
|
||||||
|
const Icon = getDynamicIcon(project.project_icon);
|
||||||
|
return <Icon className="w-5 h-5 text-white" />;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold text-gray-800">{project.project_name}</p>
|
<p className="text-sm font-semibold text-gray-800">{project.project_name}</p>
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import type { Project } from '../../../../types/project.types';
|
|||||||
import { useArchiveProject } from '../../../../hooks/useProjects';
|
import { useArchiveProject } from '../../../../hooks/useProjects';
|
||||||
|
|
||||||
const getDynamicIcon = (iconName: string) => {
|
const getDynamicIcon = (iconName: string) => {
|
||||||
const formatted = iconName.charAt(0).toUpperCase() + iconName.slice(1);
|
const formatted = iconName.charAt(0).toUpperCase() + iconName.slice(1);
|
||||||
const Icon = (LucideIcons as unknown as Record<string, React.ComponentType<LucideProps>>)[formatted];
|
const Icon = (LucideIcons as unknown as Record<string, React.ComponentType<LucideProps>>)[formatted];
|
||||||
return Icon ?? LucideIcons.FolderOpen;
|
return Icon ?? LucideIcons.FolderOpen;
|
||||||
};
|
};
|
||||||
|
|
||||||
const toSlug = (name: string) => name.toLowerCase().replace(/\s+/g, '-');
|
const toSlug = (name: string) => name.toLowerCase().replace(/\s+/g, '-');
|
||||||
|
|
||||||
@@ -61,8 +61,20 @@ export default function MentorProjectCard(project: Project) {
|
|||||||
{project.total_members}/{project.total_members}
|
{project.total_members}/{project.total_members}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center text-xs text-gray-500 gap-1">
|
||||||
|
{project.status === "active" ? (
|
||||||
|
<span className="px-2 py-0.5 bg-green-100 text-green-800 rounded-full">Active</span>
|
||||||
|
) : project.status === "completed" ? (
|
||||||
|
<span className="px-2 py-0.5 bg-blue-100 text-blue-800 rounded-full">Completed</span>
|
||||||
|
) : project.status === "archived" ? (
|
||||||
|
<span className="px-2 py-0.5 bg-yellow-100 text-yellow-800 rounded-full">Archived</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-0.5 bg-gray-100 text-gray-800 rounded-full">Inactive</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="border-t border-gray-300 text-gray-400 flex justify-end p-2 bg-gray-50/50">
|
<div className="border-t border-gray-300 text-gray-400 flex justify-end p-2 bg-gray-50/50">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export default function SidebarMentor({ isOpen, closeSidebar }: SidebarProps) {
|
|||||||
{/* Main nav */}
|
{/* Main nav */}
|
||||||
<nav className="flex flex-col gap-0.5">
|
<nav className="flex flex-col gap-0.5">
|
||||||
{navItems.map(({ label, icon: Icon, path }) => {
|
{navItems.map(({ label, icon: Icon, path }) => {
|
||||||
const isActive = location.pathname === path;
|
const isActive = location.pathname === path || location.pathname.startsWith(path + "/");
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={path}
|
key={path}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import jsPDF from "jspdf";
|
||||||
|
import JSZip from "jszip";
|
||||||
|
import type { Certificate } from "../../types/certificate.types";
|
||||||
|
import { generateCertificate, resolveImageUrl } from "./SertificateGenerator";
|
||||||
|
|
||||||
|
// ── 1. Preview di tab baru ────────────────────────────────────────────────────
|
||||||
|
export const previewCertificate = async (
|
||||||
|
cert: Certificate,
|
||||||
|
templateUrl: string
|
||||||
|
): Promise<void> => {
|
||||||
|
// Buka tab DULU sebelum await — supaya tidak diblokir popup blocker
|
||||||
|
const tab = window.open("", "_blank");
|
||||||
|
|
||||||
|
const blob = await generateCertificate(
|
||||||
|
resolveImageUrl(templateUrl),
|
||||||
|
cert.user.full_name
|
||||||
|
);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
if (!tab) {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.target = "_blank";
|
||||||
|
a.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tab.document.write(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Sertifikat - ${cert.user.full_name}</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
background: #1a1a1a;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
p { color: #aaa; font-size: 13px; letter-spacing: 0.05em; }
|
||||||
|
small { color: #555; font-size: 11px; font-family: monospace; }
|
||||||
|
img {
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 82vh;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 40px rgba(0,0,0,0.6);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>${cert.user.full_name.toUpperCase()}</p>
|
||||||
|
<small>${cert.certificate_no}</small>
|
||||||
|
<img src="${url}" alt="Sertifikat ${cert.user.full_name}"
|
||||||
|
onload="URL.revokeObjectURL(this.src)" />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
tab.document.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 2. Download PDF ───────────────────────────────────────────────────────────
|
||||||
|
export const downloadCertificatePdf = async (
|
||||||
|
cert: Certificate,
|
||||||
|
templateUrl: string
|
||||||
|
): Promise<void> => {
|
||||||
|
const fileName = `Sertifikat - ${cert.user.full_name} - ${cert.certificate_no}`;
|
||||||
|
const blob = await generateCertificate(
|
||||||
|
resolveImageUrl(templateUrl),
|
||||||
|
cert.user.full_name
|
||||||
|
);
|
||||||
|
|
||||||
|
const dataUrl: string = await new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
img.onload = () => resolve();
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = dataUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
const orientation = img.width > img.height ? "landscape" : "portrait";
|
||||||
|
const pdf = new jsPDF({ orientation, unit: "px", format: [img.width, img.height] });
|
||||||
|
pdf.addImage(dataUrl, "PNG", 0, 0, img.width, img.height);
|
||||||
|
pdf.save(`${fileName}.pdf`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 3. Download PNG atau JPG ──────────────────────────────────────────────────
|
||||||
|
export const downloadCertificateImage = async (
|
||||||
|
cert: Certificate,
|
||||||
|
templateUrl: string,
|
||||||
|
format: "png" | "jpg" = "png"
|
||||||
|
): Promise<void> => {
|
||||||
|
const fileName = `Sertifikat - ${cert.user.full_name} - ${cert.certificate_no}`;
|
||||||
|
const blob = await generateCertificate(
|
||||||
|
resolveImageUrl(templateUrl),
|
||||||
|
cert.user.full_name
|
||||||
|
);
|
||||||
|
|
||||||
|
let finalBlob = blob;
|
||||||
|
|
||||||
|
if (format === "jpg") {
|
||||||
|
const dataUrl: string = await new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
img.onload = () => resolve();
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = dataUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = img.naturalWidth;
|
||||||
|
canvas.height = img.naturalHeight;
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
|
finalBlob = await new Promise<Blob>((resolve, reject) => {
|
||||||
|
canvas.toBlob(
|
||||||
|
(b) => (b ? resolve(b) : reject(new Error("toBlob gagal"))),
|
||||||
|
"image/jpeg",
|
||||||
|
0.95
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(finalBlob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${fileName}.${format}`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 4. Download All ZIP ───────────────────────────────────────────────────────
|
||||||
|
export const downloadAllCertificatesZip = async (
|
||||||
|
certificates: Certificate[],
|
||||||
|
templateUrl: string,
|
||||||
|
projectName: string,
|
||||||
|
onProgress?: (done: number, total: number) => void
|
||||||
|
): Promise<void> => {
|
||||||
|
const zip = new JSZip();
|
||||||
|
const total = certificates.length;
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
certificates.map(async (cert, i) => {
|
||||||
|
const blob = await generateCertificate(
|
||||||
|
resolveImageUrl(templateUrl),
|
||||||
|
cert.user.full_name
|
||||||
|
);
|
||||||
|
zip.file(`Sertifikat - ${cert.user.full_name}.png`, blob);
|
||||||
|
onProgress?.(i + 1, total);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const zipBlob = await zip.generateAsync({ type: "blob" });
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = URL.createObjectURL(zipBlob);
|
||||||
|
a.download = `Sertifikat - ${projectName}.zip`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
};
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
// utils/certificateDownload.ts
|
|
||||||
import html2canvas from "html2canvas";
|
|
||||||
import jsPDF from "jspdf";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper: capture sebuah HTMLElement menjadi canvas
|
|
||||||
*/
|
|
||||||
export const captureCertificateElement = async (
|
|
||||||
element: HTMLElement
|
|
||||||
): Promise<HTMLCanvasElement> => {
|
|
||||||
return html2canvas(element, {
|
|
||||||
useCORS: true,
|
|
||||||
allowTaint: false,
|
|
||||||
backgroundColor: "#ffffff",
|
|
||||||
scale: 2, // hasil lebih tajam
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Download elemen sertifikat sebagai PNG/JPG
|
|
||||||
*/
|
|
||||||
export const downloadCertificateAsImage = async (
|
|
||||||
element: HTMLElement | null,
|
|
||||||
fileName: string,
|
|
||||||
format: "png" | "jpg" = "png",
|
|
||||||
fallbackImageUrl?: string
|
|
||||||
): Promise<void> => {
|
|
||||||
try {
|
|
||||||
if (!element) throw new Error("Elemen sertifikat tidak ditemukan");
|
|
||||||
|
|
||||||
const canvas = await captureCertificateElement(element);
|
|
||||||
|
|
||||||
const mimeType = format === "jpg" ? "image/jpeg" : "image/png";
|
|
||||||
const quality = format === "jpg" ? 0.95 : 1;
|
|
||||||
const dataUrl = canvas.toDataURL(mimeType, quality);
|
|
||||||
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = dataUrl;
|
|
||||||
a.download = `${fileName}.${format}`;
|
|
||||||
a.click();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Gagal mengunduh sertifikat sebagai gambar:", error);
|
|
||||||
|
|
||||||
// fallback: download langsung dari image_path kalau html2canvas gagal (mis. CORS)
|
|
||||||
if (fallbackImageUrl) {
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = fallbackImageUrl;
|
|
||||||
a.download = `${fileName}.png`;
|
|
||||||
a.target = "_blank";
|
|
||||||
a.click();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Download elemen sertifikat sebagai PDF
|
|
||||||
*/
|
|
||||||
export const downloadCertificateAsPdf = async (
|
|
||||||
element: HTMLElement | null,
|
|
||||||
fileName: string
|
|
||||||
): Promise<void> => {
|
|
||||||
try {
|
|
||||||
if (!element) throw new Error("Elemen sertifikat tidak ditemukan");
|
|
||||||
|
|
||||||
const canvas = await captureCertificateElement(element);
|
|
||||||
const imgData = canvas.toDataURL("image/png", 1.0);
|
|
||||||
|
|
||||||
const imgWidth = canvas.width;
|
|
||||||
const imgHeight = canvas.height;
|
|
||||||
|
|
||||||
// Tentukan orientasi PDF berdasarkan rasio gambar
|
|
||||||
const orientation = imgWidth > imgHeight ? "landscape" : "portrait";
|
|
||||||
|
|
||||||
const pdf = new jsPDF({
|
|
||||||
orientation,
|
|
||||||
unit: "px",
|
|
||||||
format: [imgWidth, imgHeight],
|
|
||||||
});
|
|
||||||
|
|
||||||
pdf.addImage(imgData, "PNG", 0, 0, imgWidth, imgHeight);
|
|
||||||
pdf.save(`${fileName}.pdf`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Gagal mengunduh sertifikat sebagai PDF:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -39,12 +39,10 @@ const ProtectedRoutes = ({ allowedRoles }: PrivateRouteProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (allowedRoles && !allowedRoles.includes(userRole)) {
|
if (allowedRoles && !allowedRoles.includes(userRole)) {
|
||||||
// Jika role tidak diizinkan, kembalikan ke base dashboard masing-masing role
|
|
||||||
if (userRole === "admin" || userRole === "mentor") return <Navigate to="/mentor" replace />;
|
if (userRole === "admin" || userRole === "mentor") return <Navigate to="/mentor" replace />;
|
||||||
if (userRole === "intern") return <Navigate to="/intern" replace />;
|
if (userRole === "intern") return <Navigate to="/intern" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jika lolos seleksi, render komponen di dalamnya
|
|
||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import api from "../../lib/api";
|
import api from "../../lib/api";
|
||||||
|
|
||||||
// ── Konstanta posisi nama di template ─────────────────────────────────────────
|
|
||||||
const NAME_Y_RATIO = 0.55; // posisi Y = 55% dari tinggi canvas
|
|
||||||
const NAME_FONT_SIZE = 48; // px
|
|
||||||
const NAME_FONT_FAMILY = "Georgia, serif";
|
|
||||||
const NAME_COLOR = "#1a1a1a";
|
|
||||||
|
|
||||||
// src/utils/certificateGenerator.ts
|
// src/utils/certificateGenerator.ts
|
||||||
export function resolveImageUrl(path: string): string {
|
export function resolveImageUrl(path: string): string {
|
||||||
if (!path) return "";
|
if (!path) return "";
|
||||||
@@ -31,7 +25,10 @@ export function loadImage(src: string): Promise<HTMLImageElement> {
|
|||||||
// ── Generate satu sertifikat sebagai Blob ─────────────────────────────────────
|
// ── Generate satu sertifikat sebagai Blob ─────────────────────────────────────
|
||||||
export async function generateCertificate(
|
export async function generateCertificate(
|
||||||
templateUrl: string,
|
templateUrl: string,
|
||||||
internName: string
|
internName: string,
|
||||||
|
projectName: string,
|
||||||
|
uuid: string,
|
||||||
|
barcodeUrl: string
|
||||||
): Promise<Blob> {
|
): Promise<Blob> {
|
||||||
const img = await loadImage(templateUrl);
|
const img = await loadImage(templateUrl);
|
||||||
|
|
||||||
@@ -43,11 +40,11 @@ export async function generateCertificate(
|
|||||||
ctx.drawImage(img, 0, 0);
|
ctx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
// Tulis nama di tengah horizontal, posisi Y dari konstanta
|
// Tulis nama di tengah horizontal, posisi Y dari konstanta
|
||||||
ctx.font = `bold ${NAME_FONT_SIZE}px ${NAME_FONT_FAMILY}`;
|
ctx.font = `bold 48px Georgia, serif`;
|
||||||
ctx.fillStyle = NAME_COLOR;
|
ctx.fillStyle = "#800000";
|
||||||
ctx.textAlign = "center";
|
ctx.textAlign = "center";
|
||||||
ctx.textBaseline = "middle";
|
ctx.textBaseline = "middle";
|
||||||
ctx.fillText(internName, canvas.width / 2, canvas.height * NAME_Y_RATIO);
|
ctx.fillText(internName, canvas.width / 2, canvas.height * 0.55);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
canvas.toBlob(
|
canvas.toBlob(
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<!-- test-cert.html — buka langsung di browser -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<canvas id="preview"></canvas>
|
||||||
|
<script>
|
||||||
|
// GANTI INI UNTUK MENGGUNAKAN TEMPLATE YANG BERBEDA
|
||||||
|
const templateUrl = "http://localhost:9000/uploads/template.png"; // ganti URL template
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = "anonymous";
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = document.getElementById("preview");
|
||||||
|
canvas.width = img.naturalWidth;
|
||||||
|
canvas.height = img.naturalHeight;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
|
// Nama
|
||||||
|
ctx.font = "bold 48px Georgia, serif";
|
||||||
|
ctx.fillStyle = "#800000";
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
ctx.fillText("Rafi Athallah", canvas.width / 2, canvas.height * 0.55);
|
||||||
|
|
||||||
|
// Project
|
||||||
|
ctx.font = "24px Georgia, serif";
|
||||||
|
ctx.fillStyle = "#444444";
|
||||||
|
ctx.fillText("Internify Platform Dev", canvas.width / 2, canvas.height * 0.65);
|
||||||
|
|
||||||
|
// UUID
|
||||||
|
ctx.font = "16px monospace";
|
||||||
|
ctx.fillStyle = "#888888";
|
||||||
|
ctx.fillText("CERT/20260609/PRJ2/USR5", canvas.width / 2, canvas.height * 0.88);
|
||||||
|
};
|
||||||
|
img.src = templateUrl;
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
// hooks/useCertificates.ts
|
// hooks/useCertificates.ts
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import type { Certificate, CertificateVerifyResult } from "../types/certificate.types";
|
import type { Certificate } from "../types/certificate.types";
|
||||||
import {
|
import {
|
||||||
claimCertificate,
|
claimCertificate,
|
||||||
getMyCertificates,
|
getMyCertificates,
|
||||||
getAllCertificates,
|
getAllCertificates,
|
||||||
getCertificateDetail,
|
getCertificateDetail,
|
||||||
getCertificatesByProject,
|
getCertificatesByProject,
|
||||||
verifyCertificateByUuid,
|
|
||||||
uploadCertificateTemplate,
|
uploadCertificateTemplate,
|
||||||
generateCertificate
|
generateCertificate
|
||||||
} from "../services/CertificateService";
|
} from "../services/CertificateService";
|
||||||
@@ -87,25 +86,6 @@ export const useProjectCertificates = (id_project: number) => {
|
|||||||
return { certificates, loading, error, refetch };
|
return { certificates, loading, error, refetch };
|
||||||
};
|
};
|
||||||
|
|
||||||
// GET /certificate-api/verify & verify-uuid (public, on-demand)
|
|
||||||
export const useVerifyCertificate = () => {
|
|
||||||
const [result, setResult] = useState<CertificateVerifyResult | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const verifyByUuid = useCallback(async (uuid: string) => {
|
|
||||||
setLoading(true); setError(null);
|
|
||||||
try {
|
|
||||||
const res = await verifyCertificateByUuid(uuid);
|
|
||||||
setResult(res);
|
|
||||||
return res;
|
|
||||||
} catch { setError("Sertifikat tidak valid atau tidak ditemukan."); return null; }
|
|
||||||
finally { setLoading(false); }
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { result, loading, error, verifyByUuid };
|
|
||||||
};
|
|
||||||
|
|
||||||
// POST /certificate-api/claim
|
// POST /certificate-api/claim
|
||||||
export const useClaimCertificate = () => {
|
export const useClaimCertificate = () => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useCurrentUser } from './useUser';
|
||||||
|
import type { Project } from '../types/project.types';
|
||||||
|
import { getProjects, getMentorProjects } from '../services/ProjectService';
|
||||||
|
|
||||||
|
export const useProjectsByRole = (
|
||||||
|
status?: "active" | "completed" | "archived"
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
user,
|
||||||
|
loading: userLoading,
|
||||||
|
error: userError,
|
||||||
|
} = useCurrentUser();
|
||||||
|
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refetch = useCallback(() => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const fetcher =
|
||||||
|
user.role === "admin"
|
||||||
|
? getProjects(status)
|
||||||
|
: user.role === "mentor"
|
||||||
|
? getMentorProjects()
|
||||||
|
: Promise.resolve([]);
|
||||||
|
|
||||||
|
fetcher
|
||||||
|
.then(setProjects)
|
||||||
|
.catch(() => setError("Gagal memuat projects."))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [user, status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userLoading && user) {
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
}, [userLoading, user, refetch]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
projects,
|
||||||
|
loading: userLoading || loading,
|
||||||
|
error: userError || error,
|
||||||
|
refetch,
|
||||||
|
role: user?.role ?? null,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -96,6 +96,4 @@ export const useMahasiswa = () => {
|
|||||||
useEffect(() => { refetch(); }, [refetch]);
|
useEffect(() => { refetch(); }, [refetch]);
|
||||||
|
|
||||||
return { mahasiswa, loading, error, refetch };
|
return { mahasiswa, loading, error, refetch };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useAssignableInterns = () => {}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// services/UserService.ts
|
// services/UserService.ts
|
||||||
import api from "../lib/api";
|
import api from "../lib/api";
|
||||||
import type { CurrentUser, UpdateProfilePayload } from "../hooks/useUser";
|
import type { CurrentUser, UpdateProfilePayload } from "../types/user.types";
|
||||||
import type { Mahasiswa, MahasiswaResponse } from "../types/project.types"
|
import type { Mahasiswa, MahasiswaResponse } from "../types/project.types"
|
||||||
|
|
||||||
// POST /auth-api/login
|
// POST /auth-api/login
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
// types/certificate.types.ts
|
|
||||||
|
|
||||||
export interface CertificateUser {
|
export interface CertificateUser {
|
||||||
id: number;
|
id: number;
|
||||||
full_name: string;
|
full_name: string;
|
||||||
@@ -17,38 +15,21 @@ export interface Certificate {
|
|||||||
id_project: number;
|
id_project: number;
|
||||||
id_user: number;
|
id_user: number;
|
||||||
certificate_no: string;
|
certificate_no: string;
|
||||||
image_path: string
|
|
||||||
issued_at: string;
|
issued_at: string;
|
||||||
uuid: string;
|
uuid: string;
|
||||||
user: CertificateUser;
|
user: CertificateUser;
|
||||||
project: CertificateProject;
|
project: CertificateProject;
|
||||||
certificate_status: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CertificateVerifyResult {
|
// Khusus GET /certificate-api/verify-uuid/{uuid}
|
||||||
|
export interface CertificateVerification {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
intern_name: string;
|
intern_name: string;
|
||||||
project_name: string;
|
project_name: string;
|
||||||
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;
|
||||||
}
|
|
||||||
|
|
||||||
// Notification
|
|
||||||
export interface Notification {
|
|
||||||
id: number;
|
|
||||||
id_user: number;
|
|
||||||
id_admin: number | null;
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
is_read: boolean;
|
|
||||||
created_at: string;
|
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,7 @@ export interface AssignableIntern {
|
|||||||
id: number;
|
id: number;
|
||||||
full_name: string;
|
full_name: string;
|
||||||
email: string;
|
email: string;
|
||||||
profesional_bio: string;
|
professional_bio: string;
|
||||||
position: string;
|
position: string;
|
||||||
kelompok_peminatan: string;
|
kelompok_peminatan: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user