From e278db1988b776f2c759a950906758b8944492ca Mon Sep 17 00:00:00 2001 From: "Kemas M. Aryadary Rasyad" <112590882+aryadary0706@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:54:36 +0700 Subject: [PATCH] feat: refactor file URL resolution and enhance component imports --- src/Layout/Header-internify.tsx | 3 +- src/Pages/internify/LoginInternify.tsx | 2 + src/Pages/internify/ProgressBar.tsx | 23 ----------- src/Pages/internify/SettingsPage.tsx | 3 +- src/Pages/internify/intern/ParticipantTab.tsx | 3 +- .../intern/components/CertificateAvailabe.tsx | 40 +++++++++---------- .../intern/components/ProjectsCard.tsx | 2 +- .../components/SubmissionStatusTable.tsx | 4 +- .../intern/components/TaskForm-file.tsx | 4 +- .../internify/mentor/CertificateForm.tsx | 4 +- .../internify/mentor/ProjectsInternTab.tsx | 5 ++- .../mentor/components/EligibleInternTable.tsx | 35 ++++++++++++---- .../components/StatusSubmissionTable.tsx | 4 +- src/Pages/utils/Certificates.ts | 19 ++++----- src/Pages/utils/SertificateGenerator.ts | 26 ++++-------- src/Pages/utils/resolveFileFromUrl.ts | 8 ++++ src/Pages/utils/resolveObjectError.ts | 12 ------ src/Pages/utils/testlayout.html | 5 +-- src/hooks/useUser.ts | 12 +++--- 19 files changed, 100 insertions(+), 114 deletions(-) delete mode 100644 src/Pages/internify/ProgressBar.tsx create mode 100644 src/Pages/utils/resolveFileFromUrl.ts delete mode 100644 src/Pages/utils/resolveObjectError.ts diff --git a/src/Layout/Header-internify.tsx b/src/Layout/Header-internify.tsx index 5dab15f..ba2bd01 100644 --- a/src/Layout/Header-internify.tsx +++ b/src/Layout/Header-internify.tsx @@ -2,7 +2,8 @@ import { Bell, Menu } from "lucide-react" import { useEffect, useState } from "react"; import humiclogo from "../assets/logo.png"; import { useNavigate } from "react-router-dom"; -import { useCurrentUser, resolveFileUrl, getInitials } from "../hooks/useUser"; +import { useCurrentUser, getInitials } from "../hooks/useUser"; +import { resolveFileUrl } from "../Pages/utils/resolveFileFromUrl"; import { useNotifications } from "../hooks/useNotification"; export default function Header({ toggleSidebar }: { toggleSidebar: () => void }) { diff --git a/src/Pages/internify/LoginInternify.tsx b/src/Pages/internify/LoginInternify.tsx index b87c80e..16fcb56 100644 --- a/src/Pages/internify/LoginInternify.tsx +++ b/src/Pages/internify/LoginInternify.tsx @@ -85,6 +85,7 @@ export default function InternifyLogin() { type="email" placeholder="Email Account" value={email} + autoComplete="username" onChange={(e) => setEmail(e.target.value)} className="w-full pl-10 pr-4 py-2.5 bg-white border border-card-outline rounded-lg text-sm placeholder-gray-400 focus:outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500 transition-colors" /> @@ -101,6 +102,7 @@ export default function InternifyLogin() { type={showPassword ? "text" : "password"} placeholder="Account Password" value={password} + autoComplete="current-password" onChange={(e) => setPassword(e.target.value)} className="w-full pl-10 pr-10 py-2.5 bg-white border border-card-outline rounded-lg text-sm placeholder-gray-400 focus:outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500 transition-colors" /> diff --git a/src/Pages/internify/ProgressBar.tsx b/src/Pages/internify/ProgressBar.tsx deleted file mode 100644 index c3b4a62..0000000 --- a/src/Pages/internify/ProgressBar.tsx +++ /dev/null @@ -1,23 +0,0 @@ -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/SettingsPage.tsx b/src/Pages/internify/SettingsPage.tsx index f83d679..80d2c92 100644 --- a/src/Pages/internify/SettingsPage.tsx +++ b/src/Pages/internify/SettingsPage.tsx @@ -1,8 +1,9 @@ import { useState, useRef, useEffect } from "react"; -import { useCurrentUser, useUpdateProfile, resolveFileUrl, getInitials } from "../../hooks/useUser"; +import { useCurrentUser, useUpdateProfile, getInitials } from "../../hooks/useUser"; import { customToast } from "../utils/showToast"; import { Pen } from "lucide-react"; import UpdateProfileDialog from "./UpdateProfileDialogue"; +import { resolveFileUrl } from "../utils/resolveFileFromUrl"; export default function SettingsPage() { const { user, loading: userLoading } = useCurrentUser(); diff --git a/src/Pages/internify/intern/ParticipantTab.tsx b/src/Pages/internify/intern/ParticipantTab.tsx index 8db811a..e35014f 100644 --- a/src/Pages/internify/intern/ParticipantTab.tsx +++ b/src/Pages/internify/intern/ParticipantTab.tsx @@ -1,7 +1,8 @@ import { Users } from 'lucide-react'; import type { ProjectDetail, ProjectMember } from '../../../types/project.types'; import { useState } from 'react'; -import { resolveFileUrl, getInitials } from '../../../hooks/useUser'; +import { getInitials } from '../../../hooks/useUser'; +import { resolveFileUrl } from '../../utils/resolveFileFromUrl'; export default function ParticipantsTab({ project }: { project: ProjectDetail }) { const [ImageError, setImageError] = useState(false); diff --git a/src/Pages/internify/intern/components/CertificateAvailabe.tsx b/src/Pages/internify/intern/components/CertificateAvailabe.tsx index 94661df..e3a3c7c 100644 --- a/src/Pages/internify/intern/components/CertificateAvailabe.tsx +++ b/src/Pages/internify/intern/components/CertificateAvailabe.tsx @@ -4,35 +4,35 @@ import { Download, Link, Printer } from "lucide-react"; import type { ProjectDetail } from "../../../../types/project.types"; import type { Certificate } from "../../../../types/certificate.types"; import { downloadCertificatePdf, downloadCertificateImage } from "../../../utils/Certificates"; -import { generateCertificate, resolveImageUrl } from "../../../utils/SertificateGenerator"; +import { generateCertificate } from "../../../utils/SertificateGenerator"; +import { resolveFileUrl } from "../../../utils/resolveFileFromUrl"; import { customToast } from "../../../utils/showToast"; interface CertificateAvailableProps { - project: ProjectDetail | null; + project: ProjectDetail; certificate: Certificate; templateUrl: string; } +function formatDateIndo(date: string): string { + return new Intl.DateTimeFormat("id-ID", { + day: "numeric", + month: "long", + year: "numeric", + }).format(new Date(date)); +} + +function formatDateRange(startDate: string, endDate: string): string { + return `${formatDateIndo(startDate)} - ${formatDateIndo(endDate)}`; +} + export default function CertificateAvailable({ project, certificate, templateUrl }: CertificateAvailableProps) { - // const [copied, setCopied] = useState(false); const [downloadingImg, setDownloadingImg] = useState(false); const [downloadingPdf, setDownloadingPdf] = useState(false); const [previewUrl, setPreviewUrl] = useState(null); const [loadingPreview, setLoadingPreview] = useState(true); - const duration = project - ? (() => { - const start = new Date(project.start_date); - const end = new Date(project.end_date); - const diffMs = end.getTime() - start.getTime(); - const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); - const months = Math.floor(diffDays / 30); - const days = diffDays % 30; - if (months > 0 && days > 0) return `${months} Bulan ${days} Hari`; - if (months > 0) return `${months} Bulan`; - return `${diffDays} Hari`; - })() - : "-"; + const duration = formatDateRange(project?.start_date, project?.end_date) useEffect(() => { @@ -40,12 +40,13 @@ export default function CertificateAvailable({ project, certificate, templateUrl const verifyUrl = `${window.location.origin}/verify-certificate/${certificate.uuid}`; generateCertificate( - resolveImageUrl(templateUrl), + resolveFileUrl(templateUrl), certificate.user.full_name, certificate.project.project_name, certificate.certificate_no, + duration, verifyUrl, - duration) + ) .then((blob) => { const url = URL.createObjectURL(blob); setPreviewUrl(url); @@ -82,7 +83,6 @@ export default function CertificateAvailable({ project, certificate, templateUrl const handleShareLinkedIn = () => { const verifyUrl = `${window.location.origin}/verify-certificate/${certificate.uuid}`; - // Gunakan "Add Certification to Profile" LinkedIn — tidak butuh API key/approval const params = new URLSearchParams({ startTask: "CERTIFICATION_NAME", name: certificate.project.project_name, @@ -109,7 +109,7 @@ export default function CertificateAvailable({ project, certificate, templateUrl Certificate ) : (
diff --git a/src/Pages/internify/intern/components/ProjectsCard.tsx b/src/Pages/internify/intern/components/ProjectsCard.tsx index 1f0b8ce..aefac38 100644 --- a/src/Pages/internify/intern/components/ProjectsCard.tsx +++ b/src/Pages/internify/intern/components/ProjectsCard.tsx @@ -1,7 +1,7 @@ import { useNavigate } from 'react-router-dom'; import { getProjectIcon } from '../../../../lib/ProjectIcons'; import type { Project } from '../../../../types/project.types'; -import { resolveFileUrl } from '../../../../hooks/useUser'; +import { resolveFileUrl } from '../../../utils/resolveFileFromUrl'; const ProjectCard = (project: Project) => { const navigate = useNavigate(); diff --git a/src/Pages/internify/intern/components/SubmissionStatusTable.tsx b/src/Pages/internify/intern/components/SubmissionStatusTable.tsx index 742bd58..88d73c1 100644 --- a/src/Pages/internify/intern/components/SubmissionStatusTable.tsx +++ b/src/Pages/internify/intern/components/SubmissionStatusTable.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { FileText, FileUp, Link } from "lucide-react"; -import { resolveImageUrl } from "../../../utils/SertificateGenerator"; +import { resolveFileUrl } from "../../../utils/resolveFileFromUrl"; interface SubmitStatusTableProps { type: "file" | "link"; @@ -128,7 +128,7 @@ export default function SubmitStatusTable({ files?.map((f) => (
{submission.files.map((f) => ( -
- +
+ {f.original_name}
))} diff --git a/src/Pages/internify/mentor/CertificateForm.tsx b/src/Pages/internify/mentor/CertificateForm.tsx index 7c9711e..7883e9e 100644 --- a/src/Pages/internify/mentor/CertificateForm.tsx +++ b/src/Pages/internify/mentor/CertificateForm.tsx @@ -5,7 +5,7 @@ import { useUploadCertificateTemplate } from "../../../hooks/useCertificates"; import { useProjectDetail } from "../../../hooks/useProjects"; import EligibleInternTable from "./components/EligibleInternTable"; import { useProjectInternProgress } from "../../../hooks/useInternProgress"; -import { resolveImageUrl } from "../../utils/SertificateGenerator"; +import { resolveFileUrl } from "../../utils/resolveFileFromUrl"; import { customToast } from "../../utils/showToast"; export default function CertificateDetail() { @@ -140,7 +140,7 @@ export default function CertificateDetail() { {previewUrl || project.certificate_template ? ( <> Preview template diff --git a/src/Pages/internify/mentor/ProjectsInternTab.tsx b/src/Pages/internify/mentor/ProjectsInternTab.tsx index 077a31f..0a489f8 100644 --- a/src/Pages/internify/mentor/ProjectsInternTab.tsx +++ b/src/Pages/internify/mentor/ProjectsInternTab.tsx @@ -4,7 +4,8 @@ import ManageInternsModal from './components/AddInternsDialog'; import type { ProjectDetail, ProjectMember } from '../../../types/project.types'; import { useRemoveMember } from '../../../hooks/useProjects'; import { customToast } from '../../utils/showToast'; -import { resolveFileUrl, getInitials } from '../../../hooks/useUser'; +import { getInitials } from '../../../hooks/useUser'; +import { resolveFileUrl } from '../../utils/resolveFileFromUrl'; export default function InternsTab({ project }: { project: ProjectDetail }) { const [isModalOpen, setIsModalOpen] = useState(false); @@ -39,7 +40,7 @@ export default function InternsTab({ project }: { project: ProjectDetail }) {
- {adminHasPhoto ? ( + {adminHasPhoto && !adminImageError ? ( {project.admin.full_name} +
+
+
+ + {value}/{total} + +
+ ); +} + function StatusBadge({ status }: { status: boolean }) { return (
@@ -131,13 +150,15 @@ export default function EligibleInternTable({ setGenerating(true); setGenerateError(null); - const ok = await customToast.promise( + const res = await customToast.promise( (async () => { const result = await SubmitCertificates(project.id, Array.from(selected)); if (!result.data) throw new Error("Tidak ada data sertifikat yang dikembalikan."); const zip = new JSZip(); - const templateUrl = resolveImageUrl(project.certificate_template!); + const templateUrl = resolveFileUrl(project.certificate_template!); + if (!templateUrl) throw new Error("Template sertifikat tidak ditemukan."); + const duration = formatDateRange(project.start_date, project.end_date); await Promise.all( @@ -168,7 +189,7 @@ export default function EligibleInternTable({ ).then(() => true).catch(() => false); setGenerating(false); - if (ok) navigate(`/mentor/certificates/${project.slug}/result`); + if (res) navigate(`/mentor/certificates/${project.slug}/result`); }; if (loading) { @@ -185,7 +206,7 @@ export default function EligibleInternTable({ return (
{/* Roster Card */} -
+

Intern Roster

@@ -253,7 +274,7 @@ export default function EligibleInternTable({ {/* Name + role */}
{intern.avatar ? ( - + ) : ( )} diff --git a/src/Pages/internify/mentor/components/StatusSubmissionTable.tsx b/src/Pages/internify/mentor/components/StatusSubmissionTable.tsx index 27cf5cf..59df0cf 100644 --- a/src/Pages/internify/mentor/components/StatusSubmissionTable.tsx +++ b/src/Pages/internify/mentor/components/StatusSubmissionTable.tsx @@ -2,7 +2,7 @@ import { FileText, CheckCircle2, AlertCircle, MinusCircle } from "lucide-react"; import type { TaskSubmissionData, DisplayStatus } from "../../../../types/task.types"; -import { resolveImageUrl } from "../../../utils/SertificateGenerator"; +import { resolveFileUrl } from "../../../utils/resolveFileFromUrl"; // StatusBadge @@ -66,7 +66,7 @@ export function StatusTable({ status, submission }: StatusTableProps) { {submission.files.map((f) => ( => { const fileName = `Sertifikat - ${cert.user.full_name} - ${cert.certificate_no}`; const blob = await generateCertificate( - resolveImageUrl(templateUrl), + resolveFileUrl(templateUrl), cert.user.full_name, cert.project.project_name, cert.certificate_no, @@ -118,7 +119,7 @@ export const downloadCertificatePdf = async ( pdf.save(`${fileName}.pdf`); }; -// ── 3. Download PNG atau JPG ────────────────────────────────────────────────── +// Download PNG atau JPG export const downloadCertificateImage = async ( cert: Certificate, templateUrl: string, @@ -126,7 +127,7 @@ export const downloadCertificateImage = async ( ): Promise => { const fileName = `Sertifikat - ${cert.user.full_name} - ${cert.certificate_no}`; const blob = await generateCertificate( - resolveImageUrl(templateUrl), + resolveFileUrl(templateUrl), cert.user.full_name, cert.project.project_name, cert.certificate_no, @@ -176,7 +177,7 @@ export const downloadCertificateImage = async ( URL.revokeObjectURL(url); }; -// ── 4. Download All ZIP ─────────────────────────────────────────────────────── +// Download All ZIP export const downloadAllCertificatesZip = async ( certificates: Certificate[], templateUrl: string, @@ -189,7 +190,7 @@ export const downloadAllCertificatesZip = async ( await Promise.all( certificates.map(async (cert, i) => { const blob = await generateCertificate( - resolveImageUrl(templateUrl), + resolveFileUrl(templateUrl), cert.user.full_name, cert.project.project_name, cert.certificate_no, diff --git a/src/Pages/utils/SertificateGenerator.ts b/src/Pages/utils/SertificateGenerator.ts index 6bf74da..f1469b9 100644 --- a/src/Pages/utils/SertificateGenerator.ts +++ b/src/Pages/utils/SertificateGenerator.ts @@ -1,24 +1,13 @@ -import api from "../../lib/api"; +import { resolveFileUrl } from "./resolveFileFromUrl"; import QRCode from "qrcode"; -export function resolveImageUrl(path: string): string { - if (!path) return ""; - if (path.startsWith("data:")) return path; - if (path.startsWith("http://") || path.startsWith("https://")) { - return path; - } - const apiBaseURL = api.defaults.baseURL ?? ""; - const origin = new URL(apiBaseURL).origin; - return `${origin}${path.startsWith("/") ? "" : "/"}${path}`; -} - export function loadImage(src: string): Promise { return new Promise((resolve, reject) => { const img = new Image(); if (!src.startsWith("data:")) img.crossOrigin = "anonymous"; img.onload = () => resolve(img); img.onerror = () => reject(new Error(`Gagal load gambar: ${src}`)); - img.src = resolveImageUrl(src); + img.src = resolveFileUrl(src); }); } @@ -30,14 +19,13 @@ export async function ensureFontsLoaded(): Promise { await document.fonts.ready; } - const LAYOUT = { internName: { x: 0.57, y: 0.48, size: 140, font: "Great Vibes", color: "#800000" }, - internNameSmall: { x: 0.82, y: 0.889, size: 33, font: "Great Vibes", color: "#800000" }, + internNameSmall: { x: 0.82, y: 0.889, size: 47, font: "Great Vibes", color: "#090909" }, projectName: { x: 0.815, y: 0.598, size: 46, font: "Grenze", color: "#800000" }, - duration: { x: 0.692, y: 0.645, size: 48, font: "Grenze", color: "#090909" }, - certNo: { x: 0.6, y: 0.28, size: 50, font: "Grenze", color: "#090909" }, - certNoSmall: { x: 0.825, y: 0.91, size: 22, font: "Grenze", color: "#800000" }, + duration: { x: 0.7, y: 0.645, size: 48, font: "Grenze", color: "#090909" }, + certNo: { x: 0.597, y: 0.305, size: 50, font: "Grenze", color: "#090909" }, + certNoSmall: { x: 0.827, y: 0.92, size: 32, font: "Grenze", color: "#090909"}, qr: { x: 0.77, y: 0.715, size: 0.103 }, }; @@ -74,7 +62,7 @@ export async function generateCertificate( text(LAYOUT.projectName, projectName); text(LAYOUT.duration, ProjectDuration); text(LAYOUT.certNo, certificateNo); - text(LAYOUT.certNoSmall, certificateNo); + text(LAYOUT.certNoSmall, "No.2026/SPI3/" + certificateNo); // QR code — nanti akan diganti ke alamat domain const qrSize = Math.round(canvas.width * LAYOUT.qr.size); diff --git a/src/Pages/utils/resolveFileFromUrl.ts b/src/Pages/utils/resolveFileFromUrl.ts new file mode 100644 index 0000000..cb505b5 --- /dev/null +++ b/src/Pages/utils/resolveFileFromUrl.ts @@ -0,0 +1,8 @@ +import {API_BASE_URL} from "../../lib/api"; + + +export function resolveFileUrl(path?: string | null): string { + if (!path) return ""; + if (/^(https?:|data:|blob:)/i.test(path)) return path; + return `${API_BASE_URL}${path.startsWith("/") ? path : `/${path}`}`; +} \ No newline at end of file diff --git a/src/Pages/utils/resolveObjectError.ts b/src/Pages/utils/resolveObjectError.ts deleted file mode 100644 index dcdb475..0000000 --- a/src/Pages/utils/resolveObjectError.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Ubah message backend (string ATAU object field-errors) jadi satu string aman -export function resolveErrorMessage(message: unknown, fallback = "Silakan coba lagi."): string { - if (typeof message === "string") return message; - if (message && typeof message === "object") { - // ambil semua value error, gabung: { end_date: "...", start_date: "..." } -> "... \n ..." - return Object.values(message as Record) - .flat() - .filter((v): v is string => typeof v === "string") - .join(" ") || fallback; - } - return fallback; -} \ No newline at end of file diff --git a/src/Pages/utils/testlayout.html b/src/Pages/utils/testlayout.html index 161a52e..d55f2bc 100644 --- a/src/Pages/utils/testlayout.html +++ b/src/Pages/utils/testlayout.html @@ -12,9 +12,7 @@