feat: update navigation paths to use '/internship'; enhance certificate generation and user interface elements

This commit is contained in:
Kemas M. Aryadary Rasyad
2026-08-04 09:21:16 +07:00
parent 5dbb21bf5a
commit 753012732d
17 changed files with 78 additions and 46 deletions
+1 -1
View File
@@ -74,7 +74,7 @@ const OurDeveloper = () => {
link: "https://www.linkedin.com/in/reihanramadhana/", link: "https://www.linkedin.com/in/reihanramadhana/",
}, },
{ {
name: "Ayu", name: "Nomi",
role: "Web Designer", role: "Web Designer",
img: ayu, img: ayu,
link: "https://www.linkedin.com/in/anom-ayu-radhaswasti/", link: "https://www.linkedin.com/in/anom-ayu-radhaswasti/",
+9 -7
View File
@@ -143,21 +143,23 @@ export default function HomeCalendar() {
))} ))}
{currentDays.map(day => ( {currentDays.map(day => (
<div key={day} className="flex justify-center items-center py-0.5"> <div key={day} className="flex justify-center items-center py-1">
<div className="w-7 flex items-center justify-center">
{isToday(day) ? ( {isToday(day) ? (
<span className="w-8 h-7 flex items-center justify-center border-b-2 border-red- text-xs font-semibold"> <span className="w-8 h-7 flex items-center justify-center border-b-2 text-xs font-semibold">
{day} {day}
</span> </span>
) : ( ) : (
<span <span
className={`w-full h-7 flex items-center justify-center text-xs className={`w-full h-7 flex items-center justify-center text-xs
${isBold(day) ? 'font-bold text-gray-800' : 'text-gray-700'} ${isBold(day) ? 'font-bold text-gray-800' : 'text-gray-700'}
${hasDeadline(day) ? 'bg-red text-white' : ''} ${hasDeadline(day) ? 'bg-red text-white rounded-full' : ''}
transition-colors`} transition-colors`}
> >
{day} {day}
</span> </span>
)} )}
</div>
</div> </div>
))} ))}
+7 -1
View File
@@ -52,7 +52,13 @@ export default function InternifyLogin() {
<div className="w-full flex flex-col items-center mb-8"> <div className="w-full flex flex-col items-center mb-8">
<div className="text-center mb-8"> <div className="text-center mb-8">
<div className="flex items-center justify-center gap-2 font-bold text-2xl tracking-tight text-black"> <div className="flex items-center justify-center gap-2 font-bold text-2xl tracking-tight text-black">
<img src={logo} alt="Logo" /> <button
type="button"
onClick={() => navigate("/")}
className="flex items-center gap-2 hover:cursor-pointer w-[160px] md:w-full"
>
<img src={logo} alt="Logo" />
</button>
</div> </div>
<p className="text-[10px] tracking-widest text-yellow-950 font-semibold mt-1 uppercase"> <p className="text-[10px] tracking-widest text-yellow-950 font-semibold mt-1 uppercase">
HUMIC LMS PORTAL HUMIC LMS PORTAL
@@ -56,7 +56,7 @@ export default function SertificateList() {
<div <div
key={item.id} key={item.id}
className="bg-white border border-card-outline rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-shadow cursor-pointer group" className="bg-white border border-card-outline rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-shadow cursor-pointer group"
onClick={() => navigate(`/intern/certificates/${item.slug}`, { state: { project: item } })} onClick={() => navigate(`/internship/certificates/${item.slug}`, { state: { project: item } })}
> >
{/* Thumbnail */} {/* Thumbnail */}
<div className="p-6 pb-0"> <div className="p-6 pb-0">
+2 -2
View File
@@ -22,9 +22,9 @@ export default function ProjectsPage() {
if (projects.length === 0) { if (projects.length === 0) {
return ( return (
<div className="col-span-full flex flex-col items-center py-2 gap-3 mt-19"> <div className="flex w-full flex-col items-center justify-center py-2 gap-3 mt-19">
<img className="w-[440px]" src={TeamPana} alt="" /> <img className="w-[440px]" src={TeamPana} alt="" />
<p className="text-[18px] text-font-shade">Kamu belum tergabung ke projek apapun</p> <p className="text-[13px] text-font-shade">Kamu belum tergabung ke projek apapun</p>
</div> </div>
); );
} }
@@ -41,7 +41,7 @@ export default function CertificateAvailable({ project, certificate, templateUrl
generateCertificate( generateCertificate(
resolveFileUrl(templateUrl), resolveFileUrl(templateUrl),
certificate.user.full_name, certificate.user.full_name,
certificate.project.project_name, certificate.intern_position,
certificate.certificate_no, certificate.certificate_no,
duration, duration,
verifyUrl, verifyUrl,
@@ -139,7 +139,7 @@ export default function CertificateAvailable({ project, certificate, templateUrl
className="flex justify-between items-center text-sm border-b border-card-outline pb-2 last:border-0" className="flex justify-between items-center text-sm border-b border-card-outline pb-2 last:border-0"
> >
<span className="text-font text-start text-xs md:text-sm">{row.label}</span> <span className="text-font text-start text-xs md:text-sm">{row.label}</span>
<span className="text-font-shade font-bold text-start text-xs md:text-sm">{row.value}</span> <span className="text-font-shade font-bold text-end text-xs md:text-sm">{row.value}</span>
</div> </div>
))} ))}
</div> </div>
@@ -33,7 +33,7 @@ const HomeProjectList = () => {
{projects.map((project) => ( {projects.map((project) => (
<div <div
key={project.id} key={project.id}
onClick={() => navigate(`/intern/projects/${toSlug(project.slug)}`)} onClick={() => navigate(`/internship/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 <div
@@ -31,7 +31,7 @@ export default function TaskCard({ task }: { task: InternTaskItem }) {
return ( return (
<div <div
className="border border-card-outline rounded-xl px-6 py-4 mb-3 bg-box-primary flex items-start justify-between gap-3 cursor-pointer hover:bg-gray-50 transition-colors" className="border border-card-outline rounded-xl px-6 py-4 mb-3 bg-box-primary flex items-start justify-between gap-3 cursor-pointer hover:bg-gray-50 transition-colors"
onClick={() => navigate(`/intern/projects/${task.project_slug}/${task.slug}?type=${task.submission_type}`)} onClick={() => navigate(`/internship/projects/${task.project_slug}/${task.slug}?type=${task.submission_type}`)}
> >
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="md:hidden mb-[8px]"> <div className="md:hidden mb-[8px]">
@@ -12,7 +12,7 @@ const ProjectCard = (project: Project) => {
return ( return (
<div <div
onClick={() => navigate(`/intern/projects/${project.slug}`)} onClick={() => navigate(`/internship/projects/${project.slug}`)}
className="w-[220px] min-h-[320px] flex flex-col bg-white rounded-xl overflow-hidden shadow-sm border border-gray-100 cursor-pointer hover:shadow-md transition-shadow duration-200" className="w-[220px] min-h-[320px] flex flex-col bg-white rounded-xl overflow-hidden shadow-sm border border-gray-100 cursor-pointer hover:shadow-md transition-shadow duration-200"
> >
{/* Thumbnail */} {/* Thumbnail */}
@@ -15,13 +15,31 @@ interface SubmitStatusTableProps {
function getTimeEarly(submittedAt: Date, deadline: Date): string { function getTimeEarly(submittedAt: Date, deadline: Date): string {
const diffMs = deadline.getTime() - submittedAt.getTime(); const diffMs = deadline.getTime() - submittedAt.getTime();
if (diffMs <= 0) return `Submitted after deadline (${Math.abs(Math.floor(diffMs / (1000 * 60 * 60)))} hours late)`;
const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
if (diffMs <= 0) {
const absHours = Math.abs(diffHours);
const days = Math.floor(absHours / 24);
const hours = absHours % 24;
const overdueParts = [] as string[];
if (days > 0) overdueParts.push(`${days} day${days > 1 ? "s" : ""}`);
if (hours > 0) overdueParts.push(`${hours} hour${hours > 1 ? "s" : ""}`);
if (overdueParts.length === 0) {
return "Submitted after deadline (0 hours late)";
}
return `Submitted after deadline (${overdueParts.join(" ")} late)`;
}
const parts = [] as string[];
const days = Math.floor(diffHours / 24); const days = Math.floor(diffHours / 24);
const hours = diffHours % 24; const hours = diffHours % 24;
const parts = [];
if (days > 0) parts.push(`${days} day${days > 1 ? "s" : ""}`); if (days > 0) parts.push(`${days} day${days > 1 ? "s" : ""}`);
if (hours > 0) parts.push(`${hours} hour${hours > 1 ? "s" : ""}`); if (hours > 0) parts.push(`${hours} hour${hours > 1 ? "s" : ""}`);
return `Assignment was submitted ${parts.join(" ")} early`; return `Assignment was submitted ${parts.join(" ")} early`;
} }
@@ -27,7 +27,14 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
selectedFiles.forEach((file) => { selectedFiles.forEach((file) => {
const ext = file.name.split(".").pop()?.toLowerCase(); const ext = file.name.split(".").pop()?.toLowerCase();
if (ext && allowedExtensions.includes(ext)) { if (ext && allowedExtensions.includes(ext)) {
if (file.size <= 5 * 1024 * 1024) validFiles.push(file); if (file.size <= 5 * 1024 * 1024) {
validFiles.push(file);
} else {
customToast.error(
"File terlalu besar!",
`File ${file.name} melebihi batas ukuran 5MB.`
);
}
} }
}); });
if (validFiles.length > 0) setFiles((prev) => [...prev, ...validFiles]); if (validFiles.length > 0) setFiles((prev) => [...prev, ...validFiles]);
@@ -27,8 +27,8 @@ export default function MentorProjectsPage() {
if (loading) { if (loading) {
return ( return (
<div className="flex flex-row gap-3 w-full"> <div className="flex flex-row gap-3 w-full">
{[...Array(4)].map((_) => ( {[...Array(4)].map((_, index) => (
<div className="flex flex-row bg-gray-300 rounded-xl overflow-hidden shadow-sm w-[180px] min-h-[270px] animate-pulse"> <div key={index} className="flex flex-row bg-gray-300 rounded-xl overflow-hidden shadow-sm w-[180px] min-h-[270px] animate-pulse">
</div> </div>
))} ))}
</div> </div>
@@ -132,13 +132,6 @@ export default function CertResultTable({ certificates, templateUrl }: Certifica
: <Download className="w-4 h-4" /> : <Download className="w-4 h-4" />
} }
</button> </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> </div>
)) ))
@@ -118,12 +118,14 @@ export default function EligibleInternTable({
id: intern.id_user, id: intern.id_user,
id_project: project.id, id_project: project.id,
id_user: intern.id_user, id_user: intern.id_user,
certificate_no: "No.2025/SPI3/CERT/yyddmm/X", certificate_no: "No.2025/SPI3/CERT/MMDDYY/XXX",
issued_at: new Date().toISOString().slice(0, 10), issued_at: new Date().toISOString().slice(0, 10),
uuid: "HANYA SEBATAS PREVIEW", uuid: "HANYA SEBATAS PREVIEW",
intern_position: "[Posisi Intern]",
user: { user: {
id: intern.id_user, id: intern.id_user,
full_name: intern.full_name, full_name: intern.full_name,
intern_position: "[Posisi Intern]",
email: intern.email, email: intern.email,
}, },
project: { project: {
@@ -185,12 +187,12 @@ export default function EligibleInternTable({
const blob = await generateCertificate( const blob = await generateCertificate(
templateUrl, templateUrl,
cert.user.full_name, cert.user.full_name,
cert.project.project_name, cert.intern_position,
cert.certificate_no, cert.certificate_no,
duration, duration,
cert.uuid, cert.uuid,
); );
zip.file(`Sertifikat - ${cert.user.full_name}.png`, blob); zip.file(`${cert.certificate_no}_${cert.user.full_name}_${cert.project.project_name}.png`, blob);
}) })
); );
})(), })(),
+8 -8
View File
@@ -25,7 +25,7 @@ export const previewCertificate = async (
const blob = await generateCertificate( const blob = await generateCertificate(
resolveFileUrl(templateUrl), resolveFileUrl(templateUrl),
cert.user.full_name, cert.user.full_name,
cert.project.project_name, cert.intern_position,
cert.certificate_no, cert.certificate_no,
formatDateRange(cert.project.start_date, cert.project.end_date), formatDateRange(cert.project.start_date, cert.project.end_date),
cert.uuid, cert.uuid,
@@ -85,11 +85,11 @@ export const downloadCertificatePdf = async (
cert: Certificate, cert: Certificate,
templateUrl: string, templateUrl: string,
): Promise<void> => { ): Promise<void> => {
const fileName = `Sertifikat - ${cert.user.full_name} - ${cert.certificate_no}`; const fileName = `${cert.certificate_no}_${cert.user.full_name}_${cert.project.project_name}`;
const blob = await generateCertificate( const blob = await generateCertificate(
resolveFileUrl(templateUrl), resolveFileUrl(templateUrl),
cert.user.full_name, cert.user.full_name,
cert.project.project_name, cert.intern_position,
cert.certificate_no, cert.certificate_no,
formatDateRange(cert.project.start_date, cert.project.end_date), formatDateRange(cert.project.start_date, cert.project.end_date),
cert.uuid, cert.uuid,
@@ -125,11 +125,11 @@ export const downloadCertificateImage = async (
templateUrl: string, templateUrl: string,
format: "png" | "jpg" = "png", format: "png" | "jpg" = "png",
): Promise<void> => { ): Promise<void> => {
const fileName = `Sertifikat - ${cert.user.full_name} - ${cert.certificate_no}`; const fileName = `${cert.certificate_no}_${cert.user.full_name}_${cert.project.project_name}`;
const blob = await generateCertificate( const blob = await generateCertificate(
resolveFileUrl(templateUrl), resolveFileUrl(templateUrl),
cert.user.full_name, cert.user.full_name,
cert.project.project_name, cert.intern_position,
cert.certificate_no, cert.certificate_no,
formatDateRange(cert.project.start_date, cert.project.end_date), formatDateRange(cert.project.start_date, cert.project.end_date),
cert.uuid, cert.uuid,
@@ -192,12 +192,12 @@ export const downloadAllCertificatesZip = async (
const blob = await generateCertificate( const blob = await generateCertificate(
resolveFileUrl(templateUrl), resolveFileUrl(templateUrl),
cert.user.full_name, cert.user.full_name,
cert.project.project_name, cert.intern_position,
cert.certificate_no, cert.certificate_no,
formatDateRange(cert.project.start_date, cert.project.end_date), formatDateRange(cert.project.start_date, cert.project.end_date),
cert.uuid, cert.uuid,
); );
zip.file(`Sertifikat - ${cert.user.full_name}.png`, blob); zip.file(`${cert.certificate_no}_${projectName}_${cert.user.full_name}.png`, blob);
onProgress?.(i + 1, total); onProgress?.(i + 1, total);
}), }),
); );
@@ -205,7 +205,7 @@ export const downloadAllCertificatesZip = async (
const zipBlob = await zip.generateAsync({ type: "blob" }); const zipBlob = await zip.generateAsync({ type: "blob" });
const a = document.createElement("a"); const a = document.createElement("a");
a.href = URL.createObjectURL(zipBlob); a.href = URL.createObjectURL(zipBlob);
a.download = `Sertifikat - ${projectName}.zip`; a.download = `Sertifikat_${projectName}_${new Date().toISOString().slice(0, 10)}.zip`;
a.click(); a.click();
URL.revokeObjectURL(a.href); URL.revokeObjectURL(a.href);
}; };
+6 -6
View File
@@ -23,13 +23,13 @@ async function ensureFontsLoaded(): Promise<void> {
// LAYOUT DEFAULT SERTIFIKAT YANG AKAN DIGENERATE // LAYOUT DEFAULT SERTIFIKAT YANG AKAN DIGENERATE
const LAYOUT = { const LAYOUT = {
internName: { x: 0.54, y: 0.5, size: 130, font: "Great Vibes", color: "#800000" }, internName: { x: 0.54, y: 0.47, size: 130, font: "Great Vibes", color: "#800000" },
internNameSmall: { x: 0.82, y: 0.889, size: 47, font: "Great Vibes", color: "#090909" }, 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" }, userposition: { x: 0.855, y: 0.557, size: 46, font: "Grenze", color: "#800000" },
duration: { x: 0.7, y: 0.645, size: 48, font: "Grenze", color: "#090909" }, duration: { x: 0.7, y: 0.615, size: 48, font: "Grenze", color: "#090909" },
certNo: { x: 0.56, y: 0.305, size: 50, font: "Grenze", color: "#090909" }, certNo: { x: 0.56, y: 0.305, size: 50, font: "Grenze", color: "#090909" },
certNoSmall: { x: 0.827, y: 0.92, size: 32, 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 }, qr: { x: 0.772, y: 0.715, size: 0.103 },
}; };
// HELPER: kurangi ukuran font 25% jika nama melebihi 22 karakter (termasuk spasi) // HELPER: kurangi ukuran font 25% jika nama melebihi 22 karakter (termasuk spasi)
@@ -48,7 +48,7 @@ function fitNameText(name: string, baseSize: number): number {
export async function generateCertificate( export async function generateCertificate(
templateUrl: string, templateUrl: string,
internName: string, internName: string,
projectName: string, userposition: string,
certificateNo: string, certificateNo: string,
ProjectDuration: string, ProjectDuration: string,
verifyUrl: string, verifyUrl: string,
@@ -76,7 +76,7 @@ export async function generateCertificate(
text(LAYOUT.internName, internName); text(LAYOUT.internName, internName);
text(LAYOUT.internNameSmall, internName); text(LAYOUT.internNameSmall, internName);
text(LAYOUT.projectName, projectName); text(LAYOUT.userposition, userposition);
text(LAYOUT.duration, ProjectDuration); text(LAYOUT.duration, ProjectDuration);
text(LAYOUT.certNo, certificateNo); text(LAYOUT.certNo, certificateNo);
text(LAYOUT.certNoSmall, certificateNo); text(LAYOUT.certNoSmall, certificateNo);
+4
View File
@@ -2,6 +2,7 @@ export interface CertificateUser {
id: number; id: number;
full_name: string; full_name: string;
email: string; email: string;
intern_position: string;
} }
export interface CertificateProject { export interface CertificateProject {
@@ -19,6 +20,7 @@ export interface Certificate {
id_user: number; id_user: number;
certificate_no: string; certificate_no: string;
issued_at: string; issued_at: string;
intern_position: string;
uuid: string; uuid: string;
user: CertificateUser; user: CertificateUser;
project: CertificateProject; project: CertificateProject;
@@ -28,6 +30,7 @@ export interface Certificate {
export interface CertificateVerification { export interface CertificateVerification {
uuid: string; uuid: string;
intern_name: string; intern_name: string;
intern_position: string;
project_name: string; project_name: string;
created_at: string; created_at: string;
} }
@@ -49,5 +52,6 @@ export interface CertificateVerifyResult {
uuid: string; uuid: string;
intern_name: string; intern_name: string;
project: projectbysertif; project: projectbysertif;
intern_position: string;
created_at: string; created_at: string;
} }