diff --git a/package.json b/package.json index 7dd0d56..2d47eb2 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,9 @@ "@tiptap/starter-kit": "^2.23.0", "axios": "^1.10.0", "froala-editor": "^4.5.2", + "html2canvas": "^1.4.1", + "jspdf": "^4.2.1", + "jszip": "^3.10.1", "lucide-react": "^0.553.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/src/App.tsx b/src/App.tsx index b2de85d..4b1c47e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,7 +31,7 @@ import EditLowongan from "./Pages/EditLowongan"; import HomeInternPage from "./Pages/Internify/intern/HomeInternPage"; import { InternLayout, MentorLayout } from "./Pages/Internify/layout"; import ProjectsPage from "./Pages/Internify/intern/ProjectsPage"; -import SertificatePage from "./Pages/Internify/intern/InternClaimCert"; +import SertificatePage from "./Pages/Internify/intern/InternCertiDetail"; import ProjectDetailPage from "./Pages/Internify/intern/ProjectDetailPage"; import HomeMentorPage from "./Pages/Internify/mentor/HomeMentorPage"; import MentorProjectsPage from "./Pages/Internify/mentor/BaseProjectsPage"; @@ -118,8 +118,8 @@ function App() { } /> } /> } /> - } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/src/Pages/internify/Avatar.tsx b/src/Pages/internify/Avatar.tsx new file mode 100644 index 0000000..15f0ef4 --- /dev/null +++ b/src/Pages/internify/Avatar.tsx @@ -0,0 +1,9 @@ +// src/components/common/Avatar.tsx + +export default function Avatar({ initials }: { initials: string }) { + return ( +
+ {initials} +
+ ); +} \ No newline at end of file diff --git a/src/Pages/internify/ProgressBar.tsx b/src/Pages/internify/ProgressBar.tsx new file mode 100644 index 0000000..c3b4a62 --- /dev/null +++ b/src/Pages/internify/ProgressBar.tsx @@ -0,0 +1,23 @@ +interface ProgressBarProps { + value: number; + total: number; +} + +export default function ProgressBar({ value, total }: ProgressBarProps) { + const pct = total === 0 ? 0 : Math.round((value / total) * 100); + const isComplete = value === total; + + return ( +
+
+
+
+ + {value}/{total} + +
+ ); +} \ No newline at end of file diff --git a/src/Pages/internify/intern/InternClaimCert.tsx b/src/Pages/internify/intern/InternCertiDetail.tsx similarity index 68% rename from src/Pages/internify/intern/InternClaimCert.tsx rename to src/Pages/internify/intern/InternCertiDetail.tsx index e62f9e6..b28b2e6 100644 --- a/src/Pages/internify/intern/InternClaimCert.tsx +++ b/src/Pages/internify/intern/InternCertiDetail.tsx @@ -1,10 +1,10 @@ +// src/pages/intern/SertificatePage.tsx import { useMemo } from "react"; -import { useParams } from "react-router-dom"; -import { useLocation } from "react-router-dom"; +import { useParams, useLocation } from "react-router-dom"; import CertificateAvailable from "./components/CertificateAvailabe"; import CertificateNotAvailable from "./components/CertificateNotAvailable"; import SertificateHistory from "./components/CertificateHistory"; -import { useMyCertificates } from "../../../hooks/useSertificates"; +import { useMyCertificates, useClaimCertificate } from "../../../hooks/useCertificates"; import { useProjectDetail } from "../../../hooks/useProjects"; import type { Project } from "../../../types/project.types"; @@ -14,7 +14,8 @@ const SertificatePage = () => { const projectFromList = location.state?.project as Project | undefined; const { project: projectDetail, loading: projectLoading } = useProjectDetail(slug ?? ""); - const { certificates, loading: certLoading } = useMyCertificates(); + const { certificates, loading: certLoading, refetch: refetchCertificates } = useMyCertificates(); + const { claim, loading: claiming, error: claimError } = useClaimCertificate(); const project = useMemo(() => { if (!projectDetail) return null; @@ -27,20 +28,27 @@ const SertificatePage = () => { const allTasksDone = !projectLoading && !!project && project.task_done >= project.total_tasks; - const alreadyDone = useMemo(() => { - if (!project || !certificates.length) return false; - return certificates.some((cert) => cert.project.id === project.id); + 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 = project && project.total_tasks > 0 ? Math.round((project.task_done / project.total_tasks) * 100) : 0; - const remainingTasks = project ? project.total_tasks - project.task_done : 0; - const isLoading = projectLoading || certLoading; - const showCertificate = alreadyDone; + + const handleClaim = async () => { + if (!project?.id) return; + const result = await claim(project.id); + if (result) { + refetchCertificates(); + } + }; if (isLoading) { return ( @@ -49,7 +57,7 @@ const SertificatePage = () => {
); } - + return (
@@ -58,14 +66,19 @@ const SertificatePage = () => {
- {showCertificate ? ( - + {alreadyDone ? ( + ) : ( )}
diff --git a/src/Pages/internify/intern/components/CertificateAvailabe.tsx b/src/Pages/internify/intern/components/CertificateAvailabe.tsx index 70757f6..cbda7df 100644 --- a/src/Pages/internify/intern/components/CertificateAvailabe.tsx +++ b/src/Pages/internify/intern/components/CertificateAvailabe.tsx @@ -1,13 +1,15 @@ +// CertificateAvailable.tsx import { useState } from "react"; import { Download, Link, Printer } from "lucide-react"; import type { ProjectDetail } from "../../../../types/project.types"; -import certificateImg from "../../../../assets/certificate.png"; +import type { Certificate } from "../../../../types/certificate.types"; interface CertificateAvailableProps { project: ProjectDetail | null; + certificate: Certificate; } -export default function CertificateAvailable({ project }: CertificateAvailableProps) { +export default function CertificateAvailable({ project, certificate }: CertificateAvailableProps) { const [copied, setCopied] = useState(false); const handleCopyLink = () => { @@ -31,80 +33,89 @@ export default function CertificateAvailable({ project }: CertificateAvailablePr })() : "-"; + const handleDownload = () => { + const a = document.createElement("a"); + a.href = certificate.image_path; + a.download = `Sertifikat - ${certificate.certificate_no}.png`; + a.target = "_blank"; + a.click(); + }; + return (
{/* Certificate Card */} -
- Certificate -
+
+ Certificate +
- {/* Program Summary */} -
-
-
-

Ringkasan Program

-
-
- {[ - { label: "Nama Program", value: project?.project_name ?? "-" }, - { label: "Durasi", value: duration }, - { label: "Total Tugas", value: project ? `${project.total_tasks} Tugas` : "-" }, - { - label: "Status", - value: ( - - UNVERIFIED - - ), - }, - ].map((row) => ( -
- {row.label} - {row.value} -
- ))} -
+ {/* Program Summary */} +
+
+
+

Ringkasan Program

- -
-
- - {/* Unduh Sertifikat */} - - - {/* Bagikan ke LinkedIn */} - - - {/* Cetak & Salin Link */} -
- - +
+ {[ + { label: "Nama Program", value: project?.project_name ?? "-" }, + { label: "Durasi", value: duration }, + { label: "Total Tugas", value: project ? `${project.total_tasks} Tugas` : "-" }, + { + label: "Status", + value: ( + + {certificate.certificate_status.toUpperCase()} + + ), + }, + ].map((row) => ( +
+ {row.label} + {row.value}
+ ))} +
+
+
+
+ {/* Unduh Sertifikat */} + + + {/* Bagikan ke LinkedIn */} + + + {/* Cetak & Salin Link */} +
+ +
+
); } \ No newline at end of file diff --git a/src/Pages/internify/intern/components/CertificateHistory.tsx b/src/Pages/internify/intern/components/CertificateHistory.tsx index 55b37e5..62c5676 100644 --- a/src/Pages/internify/intern/components/CertificateHistory.tsx +++ b/src/Pages/internify/intern/components/CertificateHistory.tsx @@ -1,8 +1,8 @@ import { useNavigate } from "react-router-dom"; import { History, Loader2, AlertCircle } from "lucide-react"; import type { Certificate } from "../../../../types/certificate.types"; -import certificateImg from "../../../../assets/certificate.png"; -import { useMyCertificates } from "../../../../hooks/useSertificates"; +import certificateImg from "../../../../assets/certificate.png"; //Placeholder +import { useMyCertificates } from "../../../../hooks/useCertificates"; export default function SertificateHistory() { const navigate = useNavigate(); diff --git a/src/Pages/internify/intern/components/CertificateNotAvailable.tsx b/src/Pages/internify/intern/components/CertificateNotAvailable.tsx index 97eeab3..6a0190e 100644 --- a/src/Pages/internify/intern/components/CertificateNotAvailable.tsx +++ b/src/Pages/internify/intern/components/CertificateNotAvailable.tsx @@ -1,27 +1,23 @@ +// CertificateNotAvailable.tsx import { Clock, Lock, Award } from "lucide-react"; -import { useClaimCertificate } from "../../../../hooks/useSertificates"; interface CertificateNotAvailableProps { progress: number; remainingTasks: number; allTasksDone?: boolean; - project_id?: number; + onClaim: () => void; + claiming: boolean; + claimError: string | null; } export default function CertificateNotAvailable({ progress, remainingTasks, allTasksDone = false, - project_id + onClaim, + claiming, + claimError, }: CertificateNotAvailableProps) { - - const { claim, loading, error } = useClaimCertificate(); - - const handleClaim = async () => { - if (!project_id) return; - await claim(project_id); - }; - return (
{/* Icon */} @@ -60,12 +56,13 @@ export default function CertificateNotAvailable({

Semua tugas telah diselesaikan! Silahkan tunggu untuk mendapatkan sertifikat

+ {claimError &&

{claimError}

} ) : ( <> @@ -91,7 +88,6 @@ export default function CertificateNotAvailable({ />
- {error &&

{error}

} )}
diff --git a/src/Pages/internify/mentor/CertificateForm.tsx b/src/Pages/internify/mentor/CertificateForm.tsx index 32138b4..61ee764 100644 --- a/src/Pages/internify/mentor/CertificateForm.tsx +++ b/src/Pages/internify/mentor/CertificateForm.tsx @@ -1,7 +1,7 @@ import { useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { CalendarDays, UploadCloud, CheckCircle2, XCircle, Loader2 } from "lucide-react"; -import { useUploadCertificateTemplate } from "../../../hooks/useSertificates"; +import { useUploadCertificateTemplate } from "../../../hooks/useCertificates"; import { useProjectDetail } from "../../../hooks/useProjects"; import EligibleInternTable from "./components/EligibleInternTable"; import { useProjectInternProgress } from "../../../hooks/useInternProgress" @@ -68,7 +68,7 @@ export default function CertificateDetail() {

- {/* ── Left Column ── */} + {/* Left Column */}

{project.project_name} @@ -76,11 +76,9 @@ export default function CertificateDetail() {
{new Date(project.start_date).toLocaleDateString("en-US", { month: "short", year: "numeric" })} - {" — "} + {" - "} {new Date(project.end_date).toLocaleDateString("en-US", { month: "short", year: "numeric" })}
- - {/* ── Upload Template ── */}
- {/* ── Stats ── */} + {/* Stats: Total Intern dalam project dan jumlah intern eligible */}

{project.total_members}

@@ -176,11 +174,12 @@ export default function CertificateDetail() {
- {/* ── Right Column ── */} + {/* Right Column */}

diff --git a/src/Pages/internify/mentor/CertificateResult.tsx b/src/Pages/internify/mentor/CertificateResult.tsx index 4398e81..2cabc93 100644 --- a/src/Pages/internify/mentor/CertificateResult.tsx +++ b/src/Pages/internify/mentor/CertificateResult.tsx @@ -10,61 +10,109 @@ import { ChevronLeft, ChevronRight, } from "lucide-react"; +import { useProjectCertificates } from "../../../hooks/useCertificates"; +import type { Certificate } from "../../../types/certificate.types"; +import { useParams } from "react-router-dom"; -// ─── Types ──────────────────────────────────────────────────────────────────── -interface InternCert { - id: number; - name: string; - role: string; - initials: string; - credentialId: string; -} +const PER_PAGE = 5; -// ─── Mock Data ──────────────────────────────────────────────────────────────── -const mockCerts: InternCert[] = [ - { id: 1, name: "Alex Johnson", role: "Backend Engineering", initials: "AJ", credentialId: "CERT-2023-0045" }, - { id: 2, name: "Sarah Lin", role: "UI/UX Design Specialist",initials: "SL", credentialId: "CERT-2023-0046" }, - { id: 3, name: "Marcus Kane", role: "Data Analytics", initials: "MK", credentialId: "CERT-2023-0047" }, - { id: 4, name: "Priya Sharma", role: "Frontend Engineering", initials: "PS", credentialId: "CERT-2023-0048" }, - { id: 5, name: "Derek Lim", role: "DevOps & Cloud", initials: "DL", credentialId: "CERT-2023-0049" }, - { id: 6, name: "Nina Castillo", role: "Mobile Development", initials: "NC", credentialId: "CERT-2023-0050" }, -]; -const TOTAL_CERTS = 18; -const PER_PAGE = 3; - -// ─── Avatar ─────────────────────────────────────────────────────────────────── -const avatarColors: Record = { - AJ: "bg-blue-100 text-blue-700", - SL: "bg-purple-100 text-purple-700", - MK: "bg-orange-100 text-orange-700", - PS: "bg-teal-100 text-teal-700", - DL: "bg-green-100 text-green-700", - NC: "bg-pink-100 text-pink-700", +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({ initials }: { initials: string }) { - const color = avatarColors[initials] ?? "bg-gray-100 text-gray-600"; +function Avatar({ name }: { name: string }) { + // "Kemas M Aryadary Rasyad" → "KR" + const color = "bg-gray-100 text-gray-600"; return (
- {initials} + {getInitials(name)} +
+ ); +} + +function CertificateResultSkeleton() { + return ( +
+ {/* Hero */} +
+
+
+
+
+
+
+ + {/* Table */} +
+ {/* Header */} +
+
+
+
+ + {/* Column headers */} +
+
+
+
+
+ + {/* Rows */} + {Array.from({ length: PER_PAGE }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+ {Array.from({ length: 3 }).map((_, j) => ( +
+ ))} +
+
+ ))} + + {/* Footer */} +
+
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+
+
); } // ─── Main Page ──────────────────────────────────────────────────────────────── export default function CertificateResult() { - const [search, setSearch] = useState(""); - const [page, setPage] = useState(1); + const { id } = useParams<{ id: string }>(); + const [search, setSearch] = useState(""); + const [page, setPage] = useState(1); + const {certificates, loading, error} = useProjectCertificates(Number(id)); + + if (loading) return ; + if (error) return
Gagal memuat data sertifikat.
; // Filter + paginate - const filtered = mockCerts.filter( + const filtered = certificates.filter( (c) => - c.name.toLowerCase().includes(search.toLowerCase()) || - c.role.toLowerCase().includes(search.toLowerCase()) + 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); + const paginated = filtered.slice((page - 1) * PER_PAGE, page * PER_PAGE); const handleSearch = (e: React.ChangeEvent) => { setSearch(e.target.value); @@ -90,7 +138,7 @@ export default function CertificateResult() { {/* Headline */}

- {TOTAL_CERTS} Certificates Successfully Generated! + {certificates.length} Certificates Successfully Generated!

{/* Subtext */} @@ -142,22 +190,22 @@ export default function CertificateResult() { No interns match your search.
) : ( - paginated.map((cert) => ( + paginated.map((cert: Certificate) => (
{/* Name + role */}
- +
-

{cert.name}

-

{cert.role}

+

{cert.user.full_name}

+ {/*

{cert.role}

*/}
{/* Credential ID */} -

{cert.credentialId}

+

{cert.uuid}

{/* Actions */}
@@ -180,7 +228,7 @@ export default function CertificateResult() {

Showing {filtered.length === 0 ? 0 : (page - 1) * PER_PAGE + 1} to{" "} - {Math.min(page * PER_PAGE, filtered.length)} of {TOTAL_CERTS} interns + {Math.min(page * PER_PAGE, filtered.length)} of {certificates.length} interns

diff --git a/src/Pages/internify/mentor/components/EligibleInternTable.tsx b/src/Pages/internify/mentor/components/EligibleInternTable.tsx index aaa9538..29a965a 100644 --- a/src/Pages/internify/mentor/components/EligibleInternTable.tsx +++ b/src/Pages/internify/mentor/components/EligibleInternTable.tsx @@ -8,73 +8,56 @@ import { ChevronLeft, ChevronRight, Sparkles, + Check, + Loader2 } from "lucide-react"; import type { InternSubmissionProgress } from "../../../../hooks/useInternProgress"; -import { useClaimCertificate } from "../../../../hooks/useSertificates"; -import { Loader2, Check } from "lucide-react"; +import { useProjectDetail } from "../../../../hooks/useProjects"; +import JSZip from "jszip"; +import { generateCertificate } from "../../../utils/SertificateGenerator"; +import Avatar from "../../Avatar"; +import ProgressBar from "../../ProgressBar"; interface EligibleInternTableProps { interns: InternSubmissionProgress[]; loading: boolean; eligibleCount: number; + projectId: number } const PER_PAGE = 5; -function ProgressBar({ value, total }: { value: number; total: number }) { - const pct = Math.round((value / total) * 100); - const isComplete = value === total; - return ( -
-
-
-
- - {value}/{total} - -
- ); -} - function StatusBadge({ status }: { status: boolean }) { - const isEligible = status return ( -
- {isEligible +
+ {status ? : } - {status} + {status ? "Eligible" : "Incomplete"}
); } -interface EligibleInternTableProps { - interns: InternSubmissionProgress[]; - loading: boolean; - eligibleCount: number; -} - -function Avatar({ initials }: { initials: string }) { - return ( -
- {initials} -
- ); -} - -// ─── Component ──────────────────────────────────────────────────────────────── -export default function EligibleInternTable({ interns, loading, eligibleCount }: EligibleInternTableProps) { +// Main Component +export default function EligibleInternTable({ + interns, + loading, + eligibleCount, + projectId +}: EligibleInternTableProps) { const navigate = useNavigate(); + const { project } = useProjectDetail(String(projectId)); const initialSelected = new Set( interns.filter(i => i.is_eligible).map(i => i.id_user) ); + // Pemilihan Id const [selected, setSelected] = useState>(initialSelected); + // state saat generate sertifikat + const [generating, setGenerating] = useState(false); + const [generateError, setGenerateError] = useState(null); + // Halaman (Pagination) const [page, setPage] = useState(1); - const totalPages = Math.ceil(interns.length / PER_PAGE); const paginated = interns.slice((page - 1) * PER_PAGE, page * PER_PAGE); @@ -82,15 +65,11 @@ export default function EligibleInternTable({ interns, loading, eligibleCount }: const allEligibleSelected = eligibleIds.every(id => selected.has(id)); const toggleSelectAll = () => { - if (allEligibleSelected) { - setSelected(new Set()); - } else { - setSelected(new Set(eligibleIds)); - } + setSelected(allEligibleSelected ? new Set() : new Set(eligibleIds)); }; - const toggleOne = (id: number, status: boolean) => { - if (status) return; + const toggleOne = (id: number, isEligible: boolean) => { + if (!isEligible) return; setSelected((prev) => { const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); @@ -98,14 +77,65 @@ export default function EligibleInternTable({ interns, loading, eligibleCount }: }); }; + const handleGenerate = async () => { + if (!project?.template_sertificate) { + setGenerateError("Template sertifikat belum diupload."); + return; + } + if (selected.size === 0) { + setGenerateError("Pilih minimal satu intern."); + return; + } + + setGenerating(true); + setGenerateError(null); + + try { + const selectedInterns = interns.filter((i) => selected.has(i.id_user)); + const zip = new JSZip(); + + // Generate semua sertifikat secara paralel + await Promise.all( + selectedInterns.map(async (intern) => { + const blob = await generateCertificate( + project.template_sertificate, + intern.full_name + ); + // Nama file: "Sertifikat - Nama Intern.png" + const filename = `Sertifikat - ${intern.full_name}.png`; + zip.file(filename, blob); + }) + ); + + // Download ZIP + const zipBlob = await zip.generateAsync({ type: "blob" }); + const url = URL.createObjectURL(zipBlob); + const a = document.createElement("a"); + a.href = url; + a.download = `Sertifikat-Project-${projectId}.zip`; + a.click(); + URL.revokeObjectURL(url); + + // Navigasi ke result page + navigate(`/mentor/certificates/${projectId}/result`); + } catch (err) { + console.error(err); + setGenerateError( + err instanceof Error ? err.message : "Gagal generate sertifikat." + ); + } finally { + setGenerating(false); + } + }; + if (loading) { - return ( -
- - Memuat data intern... -
- ); -} + return ( +
+ + Memuat data intern... +
+ ); + } return (
@@ -251,14 +281,28 @@ export default function EligibleInternTable({ interns, loading, eligibleCount }:
- {/* ── Bottom Action Bar ── */} + {generateError && ( +

{generateError}

+ )} + + {/* Bottom Action Bar */}
diff --git a/src/Pages/utils/SertificateGenerator.ts b/src/Pages/utils/SertificateGenerator.ts new file mode 100644 index 0000000..16a2029 --- /dev/null +++ b/src/Pages/utils/SertificateGenerator.ts @@ -0,0 +1,47 @@ +// src/utils/certificateGenerator.ts + +// ── 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"; + +// ── Load Image dari URL ──────────────────────────────────────────────────────── +export function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = "anonymous"; // penting untuk URL eksternal (Supabase, dll) + img.onload = () => resolve(img); + img.onerror = () => reject(new Error(`Gagal load gambar: ${src}`)); + img.src = src; + }); +} + +// ── Generate satu sertifikat sebagai Blob ───────────────────────────────────── +export async function generateCertificate( + templateUrl: string, + internName: string +): Promise { + const img = await loadImage(templateUrl); + + const canvas = document.createElement("canvas"); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + + const ctx = canvas.getContext("2d")!; + ctx.drawImage(img, 0, 0); + + // Tulis nama di tengah horizontal, posisi Y dari konstanta + ctx.font = `bold ${NAME_FONT_SIZE}px ${NAME_FONT_FAMILY}`; + ctx.fillStyle = NAME_COLOR; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(internName, canvas.width / 2, canvas.height * NAME_Y_RATIO); + + return new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => (blob ? resolve(blob) : reject(new Error("toBlob gagal"))), + "image/png" + ); + }); +} \ No newline at end of file diff --git a/src/hooks/useSertificates.ts b/src/hooks/useCertificates.ts similarity index 100% rename from src/hooks/useSertificates.ts rename to src/hooks/useCertificates.ts diff --git a/src/services/ProjectService.ts b/src/services/ProjectService.ts index 8e8f5ee..1a21384 100644 --- a/src/services/ProjectService.ts +++ b/src/services/ProjectService.ts @@ -63,6 +63,7 @@ export const getProjectById = async ( position: m.user?.position, kelompok_peminatan: m.user?.kelompok_peminatan, })), + template_sertificate: raw.template_sertificate, }; }; diff --git a/src/types/certificate.types.ts b/src/types/certificate.types.ts index 4f8bc01..0cd9738 100644 --- a/src/types/certificate.types.ts +++ b/src/types/certificate.types.ts @@ -17,6 +17,7 @@ export interface Certificate { id_project: number; id_user: number; certificate_no: string; + image_path: string issued_at: string; uuid: string; user: CertificateUser; diff --git a/src/types/project.types.ts b/src/types/project.types.ts index c78b959..fa2b7ed 100644 --- a/src/types/project.types.ts +++ b/src/types/project.types.ts @@ -34,6 +34,7 @@ export interface Project { export interface ProjectDetail extends Project { members: ProjectMember[]; tasks: ProjectTask[]; + template_sertificate: string; } // Response GET /project-api/my-tasks