feat: enhance user profile update functionality and error handling

- Added detailed error messages for various HTTP status codes in useUpdateProfile hook.
- Refactored updateProfile service to handle full_name as separate first and last name fields.
- Updated UpdateProfilePayload type to allow profile_picture to be a string or File.
- Introduced resolveFileUrl utility function for consistent file URL resolution.
- Improved loading and error state management in useUpdateProfile hook.
- Added DEFAULT_AVATAR constant for default user avatar.
This commit is contained in:
Kemas M. Aryadary Rasyad
2026-07-13 12:15:15 +07:00
parent 5516d35546
commit a124a2b96e
39 changed files with 729 additions and 392 deletions
+7 -11
View File
@@ -2,7 +2,7 @@ import { Bell, Menu } from "lucide-react"
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import humiclogo from "../assets/logo.png"; import humiclogo from "../assets/logo.png";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useCurrentUser } from "../hooks/useUser"; import { useCurrentUser, resolveFileUrl, DEFAULT_AVATAR } from "../hooks/useUser";
function getInitials(fullName: string | undefined | null): string { function getInitials(fullName: string | undefined | null): string {
if (!fullName) return "U"; if (!fullName) return "U";
@@ -17,15 +17,12 @@ export default function Header({ toggleSidebar }: { toggleSidebar: () => void })
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const [imageError, setImageError] = useState(false); const [imageError, setImageError] = useState(false);
useEffect(() => { const displayName = user?.full_name?.trim() || [user?.nama_depan, user?.nama_belakang].filter(Boolean).join(" ") || "";
setImageError(false);
}, [user?.profile_picture, user?.full_name]);
const settingsPath = const photoUrl = resolveFileUrl(user?.profile_picture);
user?.role === "intern" ? "/intern" : "/mentor";
const initials = user?.full_name ? getInitials(user.full_name) : "U"; const initials = getInitials(displayName);
const hasPhoto = !!user?.profile_picture && !imageError; const hasPhoto = !!photoUrl && !imageError;
return ( return (
<header className='sticky top-0 flex w-full justify-between items-center py-4 px-12 bg-white border-b border-gray-300 z-10'> <header className='sticky top-0 flex w-full justify-between items-center py-4 px-12 bg-white border-b border-gray-300 z-10'>
@@ -49,13 +46,12 @@ export default function Header({ toggleSidebar }: { toggleSidebar: () => void })
{/* Gambar Profil */} {/* Gambar Profil */}
<button <button
onClick={() => nav(settingsPath)} className="rounded-full w-9 h-9 overflow-hidden border-2 border-gray-200"
className="rounded-full w-9 h-9 overflow-hidden border-2 border-gray-200 hover:border-red-400 transition-colors flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-red-400"
title="Profile Settings" title="Profile Settings"
> >
{hasPhoto ? ( {hasPhoto ? (
<img <img
src={user?.profile_picture ?? ""} src={`${import.meta.env.VITE_API_BASE_URL}${user?.profile_picture ?? ""}`}
alt={user?.full_name ?? "Profile"} alt={user?.full_name ?? "Profile"}
className="w-full h-full object-cover" className="w-full h-full object-cover"
onError={() => setImageError(true)} onError={() => setImageError(true)}
+5 -5
View File
@@ -14,7 +14,7 @@ export default function NotFoundPage() {
{/* 404 Large Text */} {/* 404 Large Text */}
<div className="relative mb-4 select-none"> <div className="relative mb-4 select-none">
<span className="text-[160px] sm:text-[200px] font-black leading-none bg-gradient-to-b from-red to-red/10 bg-clip-text text-transparent"> <span className="text-[160px] sm:text-[200px] font-black leading-none bg-gradient-to-b from-red to-red/60 bg-clip-text text-transparent">
404 404
</span> </span>
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
@@ -22,13 +22,13 @@ export default function NotFoundPage() {
</div> </div>
{/* Title */} {/* Title */}
<h1 className="text-3xl sm:text-4xl font-bold text-white mb-3 tracking-tight"> <h1 className="text-3xl sm:text-4xl font-bold text-red/60 mb-3 tracking-tight">
Not Found Tidak Ditemukan
</h1> </h1>
{/* Description */} {/* Description */}
<p className="text-slate-800 text-base sm:text-lg mb-10 max-w-md leading-relaxed"> <p className="text-slate-800 text-base sm:text-lg mb-10 max-w-md leading-relaxed">
Sorry, the page you are looking for does not exist or has been moved. Let's get back on track. Maaf, halaman yang Anda cari tidak ada atau telah dipindahkan. Mari kembali ke jalur yang benar.
</p> </p>
{/* Divider */} {/* Divider */}
@@ -38,7 +38,7 @@ export default function NotFoundPage() {
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto"> <div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
<button <button
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
className="flex items-center justify-center gap-2 px-6 py-3 rounded-xl border border-white/10 bg-white/30 hover:bg-white/15 text-white font-semibold text-sm transition-all active:scale-95 backdrop-blur-sm" className="flex items-center justify-center gap-2 px-6 py-3 rounded-xl border border-red/60 bg-white/30 hover:bg-white/15 text-red-700 font-semibold text-sm transition-all active:scale-95 backdrop-blur-sm"
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
Kembali Kembali
+79 -60
View File
@@ -1,60 +1,79 @@
import api from "../../lib/api"; import api from "../../lib/api";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
interface FAQ { interface FAQ {
id: number; id: number;
pertanyaan: string; pertanyaan: string;
jawaban: string; jawaban: string;
created_at: string; created_at: string;
} }
export default function FAQPage() { // Skeleton item that mirrors the real FAQ card shape
const [faq, setFaq] = useState<FAQ[]>([]); function FAQSkeleton() {
const [loading, setLoading] = useState(false); return (
<div className="relative shadow-md py-5 px-10 rounded-md bg-white border-l-6 border-gray-200 animate-pulse">
useEffect(() => { {/* Question bar */}
setLoading(true); <div className="h-5 bg-gray-200 rounded w-2/3 mb-3" />
api.get("/faq-api/get") {/* Answer bars */}
.then((res) => { <div className="pl-8 space-y-2">
setFaq(res.data.data); <div className="h-3 bg-gray-200 rounded w-full" />
}) <div className="h-3 bg-gray-200 rounded w-4/5" />
.catch((err) => { </div>
console.error("Gagal fetch FAQ:", err); </div>
}) );
.finally(() => { }
setLoading(false);
}); export default function FAQPage() {
}, []); const [faq, setFaq] = useState<FAQ[]>([]);
const [loading, setLoading] = useState(false);
return (
<div> useEffect(() => {
<div className="mb-10"> setLoading(true);
<h1 className="page-title">FAQ</h1> api.get("/faq-api/get")
<p className="page-title-desc"> .then((res) => {
Quick answers to common questions about the Internify LMS platform. setFaq(res.data.data);
</p> })
</div> .catch((err) => {
console.error("Gagal fetch FAQ:", err);
{/* FAQ Lists */} })
{loading ? ( .finally(() => {
<p className="text-gray-500">Memuat FAQ...</p> setLoading(false);
) : ( });
<div className="space-y-8 max-w-auto"> }, []);
{faq.map((item) => (
<div key={item.id} className="relative shadow-md group py-5 px-10 rounded-md bg-white border-l-6 border-red hover:shadow-xl"> return (
<div>
{/* Item Question */} <div className="mb-10">
<h2 className="text-[17px] font-bold text-red tracking-tight leading-snug mb-2"> <h1 className="page-title">FAQ</h1>
{item.pertanyaan} <p className="page-title-desc">
</h2> Quick answers to common questions about the Internify LMS platform.
</p>
<p className="text-[14px] pl-8 text-redfont-light leading-relaxed tracking-normal"> </div>
{item.jawaban}
</p> {/* FAQ Lists */}
</div> {loading ? (
))} <div className="space-y-8 max-w-auto">
</div> {[...Array(5)].map((_, i) => (
)} <FAQSkeleton key={i} />
</div> ))}
); </div>
} ) : (
<div className="space-y-8 max-w-auto">
{faq.map((item) => (
<div key={item.id} className="relative shadow-md group py-5 px-10 rounded-md bg-white border-l-6 border-red hover:shadow-xl">
{/* Item Question */}
<h2 className="text-[17px] font-bold text-red tracking-tight leading-snug mb-2">
{item.pertanyaan}
</h2>
<p className="text-[14px] pl-8 text-redfont-light leading-relaxed tracking-normal">
{item.jawaban}
</p>
</div>
))}
</div>
)}
</div>
);
}
+23 -25
View File
@@ -1,17 +1,19 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import logo from "../../assets/logo.png"; import logo from "../../assets/logo.png";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Mail, Lock, Eye, EyeOff } from 'lucide-react'; import { Mail, Lock, Eye, EyeOff } from 'lucide-react';
import { useLogin } from "../../hooks/useUser"; import { useLogin } from "../../hooks/useUser";
import { customToast } from "../utils/showToast";
import { CircleQuestionMark } from 'lucide-react';
const decodeJWT = (token: string) => { const decodeJWT = (token: string) => {
try { try {
const base64 = token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"); const base64 = token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "="); const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
return JSON.parse(atob(padded)); return JSON.parse(atob(padded));
} catch { } catch {
return null; return null;
} }
}; };
export default function InternifyLogin() { export default function InternifyLogin() {
@@ -22,6 +24,12 @@ export default function InternifyLogin() {
const { login, loading, error, setError } = useLogin(); const { login, loading, error, setError } = useLogin();
useEffect(() => {
if (!error) return;
customToast.error("Login gagal", error);
setError(null);
}, [error, setError]);
const handleSignIn = async (e: React.FormEvent) => { const handleSignIn = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
@@ -77,7 +85,7 @@ export default function InternifyLogin() {
placeholder="Email Account" placeholder="Email Account"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 bg-white border border-gray-200 rounded-lg text-sm placeholder-gray-400 focus:outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500 transition-colors" 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"
/> />
</div> </div>
</div> </div>
@@ -93,7 +101,7 @@ export default function InternifyLogin() {
placeholder="Account Password" placeholder="Account Password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="w-full pl-10 pr-10 py-2.5 bg-white border border-gray-200 rounded-lg text-sm placeholder-gray-400 focus:outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500 transition-colors" 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"
/> />
<button <button
type="button" type="button"
@@ -108,22 +116,14 @@ export default function InternifyLogin() {
</button> </button>
</div> </div>
</div> </div>
{error && (
<p className="text-xs text-red-600 bg-red-50 p-2 rounded-md font-medium">
{error}
</p>
)}
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="w-full bg-[#B30000] hover:bg-[#990000] text-white font-medium text-sm py-2.5 rounded-lg transition-colors shadow-sm mt-2" className="w-full bg-red hover:bg-red/80 text-white font-medium text-sm py-2.5 rounded-lg transition-colors shadow-sm mt-2"
> >
{loading ? "Signing in..." : "Sign In"} {loading ? "Signing in..." : "Sign In"}
</button> </button>
</form> </form>
{/* Forgot Password */} {/* Forgot Password */}
<div className="text-center mt-6"> <div className="text-center mt-6">
<a href="#" className="text-xs font-semibold text-red-600 hover:underline"> <a href="#" className="text-xs font-semibold text-red-600 hover:underline">
@@ -134,17 +134,15 @@ export default function InternifyLogin() {
{/* Divider & Support */} {/* Divider & Support */}
<div className="mt-1"> <div className="mt-1">
<div className="relative flex py-2 items-center"> <div className="relative flex py-2 items-center">
<div className="flex-grow border-t border-gray-100"></div> <div className="flex-grow border-t border-card-outline"></div>
<span className="flex-shrink mx-4 text-[10px] font-bold tracking-wider text-font-shade uppercase"> <span className="flex-shrink mx-4 text-[10px] font-bold tracking-wider text-font-shade uppercase">
SUPPORT SUPPORT
</span> </span>
<div className="flex-grow border-t border-gray-100"></div> <div className="flex-grow border-t border-card-outline"></div>
</div> </div>
<button className="w-full mt-2 flex items-center justify-center gap-2 border border-gray-200 rounded-full py-1.5 text-xs text-font-shade hover:bg-gray-50 transition-colors font-medium"> <button className="w-full mt-2 flex items-center justify-center gap-2 border border-card-outline rounded-full py-1.5 text-xs text-font-shade hover:bg-gray-50 transition-colors font-medium">
<svg className="w-3.5 h-3.5 text-font-shade" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"> <CircleQuestionMark className="w-5 h-5"/>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Frequently Asked Questions Frequently Asked Questions
</button> </button>
</div> </div>
+7 -3
View File
@@ -5,6 +5,7 @@ import { LuFileSpreadsheet } from "react-icons/lu";
import { io } from "socket.io-client"; import { io } from "socket.io-client";
import * as NotificationService from "../../services/NotificationService"; import * as NotificationService from "../../services/NotificationService";
import type { UINotification, BackendNotification } from "../../types/notification.types"; import type { UINotification, BackendNotification } from "../../types/notification.types";
import mailRafiki from '../../assets/Mail-rafiki.svg'
const getCookieToken = () => { const getCookieToken = () => {
return document.cookie return document.cookie
@@ -95,7 +96,10 @@ export default function NotificationList() {
</div> </div>
{notifications.length === 0 && ( {notifications.length === 0 && (
<p className="text-sm text-gray-400">Belum ada notifikasi.</p> <div className="flex flex-col items-center justify-center pt-14 gap-3">
<img src={mailRafiki} alt="empty notification" className="w-[280px]"/>
<p className="text-lg text-font">Belum ada pesan notifikasi yang diterima</p>
</div>
)} )}
<div className="space-y-6"> <div className="space-y-6">
@@ -165,9 +169,9 @@ export default function NotificationList() {
{/* Header Section YESTERDAY */} {/* Header Section YESTERDAY */}
<div className="space-y-3 pt-2"> <div className="space-y-3 pt-2">
<h2 className="text-[10px] font-bold tracking-widest text-gray-400 uppercase"> {/* <h2 className="text-[10px] font-bold tracking-widest text-gray-400 uppercase">
YESTERDAY YESTERDAY
</h2> </h2> */}
{notifications.filter(n => n.group === "YESTERDAY").map((item) => ( {notifications.filter(n => n.group === "YESTERDAY").map((item) => (
<div <div
+64 -62
View File
@@ -1,11 +1,13 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { useCurrentUser, useUpdateProfile } from "../../hooks/useUser"; import { useCurrentUser, useUpdateProfile, resolveFileUrl, DEFAULT_AVATAR } from "../../hooks/useUser";
import { customToast } from "../utils/showToast"; import { customToast } from "../utils/showToast";
import { Pen } from "lucide-react"; import { Pen } from "lucide-react";
import UpdateProfileDialog from "./UpdateProfileDialogue";
export default function SettingsPage() { export default function SettingsPage() {
const { user, loading: userLoading } = useCurrentUser(); const { user, loading: userLoading } = useCurrentUser();
const { save, loading: saving, error: errorMsg, successMsg } = useUpdateProfile(); const { save, loading: saving, msgRef } = useUpdateProfile();
const [confirmOpen, setConfirmOpen] = useState(false);
const isIntern = user?.role === "intern"; const isIntern = user?.role === "intern";
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@@ -16,24 +18,22 @@ export default function SettingsPage() {
const [photoFile, setPhotoFile] = useState<File | null>(null); const [photoFile, setPhotoFile] = useState<File | null>(null);
const [photoPreview, setPhotoPreview] = useState<string | null>(null); const [photoPreview, setPhotoPreview] = useState<string | null>(null);
const [removePhoto, setRemovePhoto] = useState(false);
const [imageError, setImageError] = useState(false); const [imageError, setImageError] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
if (user) { if (!user) return;
setFormData({
fullName: setFormData({
user.full_name || "", fullName: user.full_name || `${user.nama_depan} ${user.nama_belakang}`.trim(),
email: user.email || "", email: user.email || "",
bio: user.professional_bio || "", bio: user.professional_bio || "",
}); });
if (user.profile_picture) {
setPhotoPreview(user.profile_picture); setPhotoPreview(resolveFileUrl(user.profile_picture) ?? DEFAULT_AVATAR);
} else { setRemovePhoto(false);
setPhotoPreview(null); setImageError(false);
}
setImageError(false);
}
}, [user]); }, [user]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
@@ -43,46 +43,50 @@ export default function SettingsPage() {
const handlePhotoUpload = (e: React.ChangeEvent<HTMLInputElement>) => { const handlePhotoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (!file) return;
setPhotoFile(file);
const reader = new FileReader(); setPhotoFile(file);
reader.onloadend = () => setPhotoPreview(reader.result as string); setRemovePhoto(false);
reader.readAsDataURL(file); setImageError(false);
}
const reader = new FileReader();
reader.onloadend = () => setPhotoPreview(reader.result as string);
reader.readAsDataURL(file);
}; };
const handleRemovePhoto = () => { const handleRemovePhoto = () => {
setPhotoFile(null); setPhotoFile(null);
setPhotoPreview(null); setRemovePhoto(true);
setPhotoPreview(DEFAULT_AVATAR);
setImageError(false); setImageError(false);
if (fileInputRef.current) fileInputRef.current.value = ""; if (fileInputRef.current) fileInputRef.current.value = "";
}; };
const handleSaveChanges = async (e: React.FormEvent) => { const handleSaveChanges = async () => {
e.preventDefault(); setConfirmOpen(false);
try {
await customToast.promise( await customToast.promise(
save({ save({
full_name: formData.fullName, full_name: formData.fullName,
email: formData.email, email: formData.email,
professional_bio: formData.bio, professional_bio: formData.bio,
}), profile_picture: photoFile ?? (removePhoto ? DEFAULT_AVATAR : undefined),
{
loading: "Menyimpan perubahan...",
success: () => ({
title: "Perubahan berhasil disimpan!",
description: successMsg || "",
}), }),
error: () => ({ {
title: "Gagal menyimpan perubahan!", loading: "Menyimpan perubahan...",
description: errorMsg || "", success: () => ({
}), title: "Perubahan berhasil disimpan!",
} description: msgRef.current.success ?? "",
); }),
error: () => ({
title: "Gagal menyimpan perubahan!",
description: msgRef.current.error ?? "",
}),
}
);
} catch { }
}; };
const initialLetter = formData.fullName ? formData.fullName.charAt(0).toUpperCase() : "U";
if (userLoading) { if (userLoading) {
return ( return (
<div className="flex items-center justify-center h-40 text-sm text-gray-400"> <div className="flex items-center justify-center h-40 text-sm text-gray-400">
@@ -91,6 +95,11 @@ export default function SettingsPage() {
); );
} }
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setConfirmOpen(true);
};
return ( return (
<div> <div>
{/* Header Title */} {/* Header Title */}
@@ -113,22 +122,20 @@ export default function SettingsPage() {
</div> </div>
{/* Card Body & Form */} {/* Card Body & Form */}
<form onSubmit={handleSaveChanges} className="p-6 space-y-6"> <form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* Profile Photo Row */} {/* Profile Photo Row */}
<div className="flex items-center gap-5 py-2"> <div className="flex items-center gap-5 py-2">
{/* Avatar dengan badge edit */} {/* Avatar dengan badge edit */}
<div className="relative flex-shrink-0"> <div className="relative flex-shrink-0">
<div className="w-16 h-16 rounded-xl border-2 border-red-100 overflow-hidden bg-gray-100 flex items-center justify-center shadow-sm"> <div className="w-16 h-16 rounded-xl border border-card-outline overflow-hidden bg-gray-100 flex items-center justify-center shadow-sm">
{photoPreview && !imageError ? ( {photoPreview && !imageError && (
<img <img
src={photoPreview} src={photoPreview}
alt="Profile" alt="Profile"
className="w-full h-full object-cover" className="w-full h-full object-cover"
onError={() => setImageError(true)} onError={() => setImageError(true)}
/> />
) : (
<span className="text-3xl font-black text-gray-900">{initialLetter}</span>
)} )}
</div> </div>
{/* Badge edit icon */} {/* Badge edit icon */}
@@ -237,18 +244,6 @@ export default function SettingsPage() {
</p> </p>
</div> </div>
{/* Feedback Messages
{successMsg && (
<p className="text-xs text-green-600 bg-green-50 p-2 rounded-md font-medium">
{successMsg}
</p>
)}
{errorMsg && (
<p className="text-xs text-red-600 bg-red-50 p-2 rounded-md font-medium">
{errorMsg}
</p>
)} */}
{/* Footer Action */} {/* Footer Action */}
<div className="flex justify-end pt-2"> <div className="flex justify-end pt-2">
<button <button
@@ -262,6 +257,13 @@ export default function SettingsPage() {
</form> </form>
</div> </div>
<UpdateProfileDialog
isOpen={confirmOpen}
isIntern={isIntern}
loading={saving}
onConfirm={handleSaveChanges}
onClose={() => setConfirmOpen(false)}
/>
</div> </div>
); );
} }
@@ -0,0 +1,116 @@
import { useEffect, useRef } from "react";
import { AlertTriangle, X } from "lucide-react";
interface UpdateProfileDialogProps {
isOpen: boolean;
isIntern: boolean;
loading?: boolean;
onConfirm: () => void;
onClose: () => void;
}
export default function UpdateProfileDialog({
isOpen,
isIntern,
loading = false,
onConfirm,
onClose,
}: UpdateProfileDialogProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (isOpen && !dialog.open) dialog.showModal();
if (!isOpen && dialog.open) dialog.close();
}, [isOpen]);
// Event "cancel" dipicu saat user menekan Esc.
// preventDefault() tanpa onClose() = Esc benar-benar dimatikan,
// dialog hanya bisa ditutup lewat tombol Batal / X.
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
const handleCancel = (e: Event) => e.preventDefault();
dialog.addEventListener("cancel", handleCancel);
return () => dialog.removeEventListener("cancel", handleCancel);
}, []);
return (
<dialog
ref={dialogRef}
// fixed inset-0 + m-auto + h-fit -> dialog center horizontal & vertical.
// hidden + open:flex -> hanya tampil kalau attribute [open] ada,
// supaya lifecycle showModal()/close() tetap dihormati.
className="hidden open:flex fixed inset-0 m-auto h-fit max-h-[90vh] w-[90vw] max-w-md flex-col overflow-hidden rounded-xl border border-box-border p-0 shadow-xl backdrop:bg-black/40 backdrop:backdrop-blur-[2px]"
onClick={(e) => {
// klik di area backdrop (target === dialog itu sendiri) -> tutup
if (e.target === dialogRef.current && !loading) onClose();
}}
>
{/* Header */}
<div className="flex items-start justify-between gap-4 p-5 border-b border-box-border">
<div className="flex items-center gap-3">
<div>
<h2 className="text-base font-bold text-foreground">Simpan Perubahan</h2>
</div>
</div>
<button
type="button"
onClick={onClose}
disabled={loading}
className="p-1 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors disabled:opacity-50"
aria-label="Tutup"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Body */}
<div className="p-5 space-y-4 overflow-y-auto">
<p className="text-sm text-font-shade leading-relaxed">
Perubahan pada profil kamu akan langsung diterapkan setelah dikonfirmasi.
</p>
{/* Peringatan khusus intern: identitas hanya bisa diubah 1x */}
{isIntern && (
<div className="flex gap-3 p-3 rounded-lg bg-amber-50 border border-amber-200">
<AlertTriangle className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="text-xs leading-relaxed">
<p className="font-bold text-amber-900 mb-0.5">Perubahan bersifat permanen</p>
<p className="text-amber-800">
Sebagai intern, kamu hanya dapat mengubah data identitas{" "}
<span className="font-bold">satu kali</span>. Data ini akan dipakai untuk
pencetakan sertifikat, jadi pastikan penulisannya sudah sesuai.
</p>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-2 px-5 py-4 border-t border-box-border bg-gray-50/60">
<button
type="button"
onClick={onClose}
disabled={loading}
className="px-4 py-2 bg-white border border-box-border text-gray-600 hover:bg-gray-50 text-xs font-bold rounded-lg transition-colors disabled:opacity-60"
>
Batal
</button>
<button
type="button"
onClick={onConfirm}
disabled={loading}
className="px-5 py-2 bg-[#B30000] hover:bg-[#990000] text-white text-xs font-bold rounded-lg shadow-sm transition-colors uppercase tracking-wider disabled:opacity-60"
>
{loading ? "Menyimpan..." : "Ya, Simpan"}
</button>
</div>
</dialog>
);
}
+6 -8
View File
@@ -3,6 +3,7 @@ import TaskCard from './components/ProjectTaskCard';
import HeroProject from './components/Hero-project'; import HeroProject from './components/Hero-project';
import { useProjectMyTasks } from '../../../hooks/useProjects'; import { useProjectMyTasks } from '../../../hooks/useProjects';
import type { Project } from '../../../types/project.types'; import type { Project } from '../../../types/project.types';
import { FileText } from 'lucide-react';
export default function ForumTab({ project }: { project: Project }) { export default function ForumTab({ project }: { project: Project }) {
const { tasks, loading, error } = useProjectMyTasks(project.id); const { tasks, loading, error } = useProjectMyTasks(project.id);
@@ -11,29 +12,26 @@ export default function ForumTab({ project }: { project: Project }) {
<div> <div>
<HeroProject title={project.project_name} description={project.description} /> <HeroProject title={project.project_name} description={project.description} />
{/* Loading skeleton */}
{loading && ( {loading && (
<div className="flex flex-col gap-3 mt-5"> <div className="flex flex-col gap-3 mt-5">
{[...Array(3)].map((_, i) => ( {[...Array(3)].map((_, i) => (
<div key={i} className="h-24 bg-gray-100 rounded-xl animate-pulse" /> <div key={i} className="h-28 bg-gray-100 rounded-xl animate-pulse" />
))} ))}
</div> </div>
)} )}
{/* Error */}
{error && <p className="text-red-500 text-sm mt-5">{error}</p>} {error && <p className="text-red-500 text-sm mt-5">{error}</p>}
{tasks.length === 0 ? ( {tasks.length === 0 ? (
<p className="text-md text-font-shade mt-5">Belum ada tugas yang dapat dikerjakan.</p> <div className='flex justify-center items-center flex-col mt-5'>
<FileText className='text-font mb-3' size={48} />
<p className="text-md text-font-shade">Belum ada tugas yang dapat dikerjakan.</p>
</div>
) : ( ) : (
!loading && ( !loading && (
<div className="flex flex-col gap-7 md:flex-row md:items-start mt-5"> <div className="flex flex-col gap-7 md:flex-row md:items-start mt-5">
{/* Kolom kiri: Timeline */}
<div className="w-full md:w-1/5 bg-white rounded-xl p-5 border border-box-border shrink-0"> <div className="w-full md:w-1/5 bg-white rounded-xl p-5 border border-box-border shrink-0">
<ProjectTimeline tasks={tasks} /> <ProjectTimeline tasks={tasks} />
</div> </div>
{/* Kolom kanan: Task cards */}
<div className="w-full border-t border-box-border md:border-t-0 pt-4 md:pt-0 md:w-4/5"> <div className="w-full border-t border-box-border md:border-t-0 pt-4 md:pt-0 md:w-4/5">
{tasks.map((task) => ( {tasks.map((task) => (
<TaskCard key={task.id} task={task} /> <TaskCard key={task.id} task={task} />
@@ -5,7 +5,6 @@ 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 { useClaimCertificate } from "../../../hooks/useCertificates";
import { useProjectDetail } from "../../../hooks/useProjects"; import { useProjectDetail } from "../../../hooks/useProjects";
import { useMyCertificates } from "../../../hooks/useCertificates"; import { useMyCertificates } from "../../../hooks/useCertificates";
import type { Project } from "../../../types/project.types"; import type { Project } from "../../../types/project.types";
@@ -17,7 +16,7 @@ export default function InternCertificateDetail () {
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 } = useMyCertificates();
// const { claim, loading: claiming, error: claimError } = useClaimCertificate();
const myCertificateForProject = useMemo(() => { const myCertificateForProject = useMemo(() => {
if (!projectDetail) return null; if (!projectDetail) return null;
@@ -42,14 +41,6 @@ export default function InternCertificateDetail () {
const remainingTasks = project ? project.total_tasks - project.task_done : 0; const remainingTasks = project ? project.total_tasks - project.task_done : 0;
const isLoading = projectLoading || certLoading; const isLoading = projectLoading || certLoading;
// const handleClaim = async () => {
// if (!project?.id) return;
// const result = await claim(project.id);
// if (result) {
// refetchCertificates();
// }
// };
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">
@@ -77,9 +68,6 @@ export default function InternCertificateDetail () {
progress={progress} progress={progress}
remainingTasks={remainingTasks} remainingTasks={remainingTasks}
allTasksDone={allTasksDone} allTasksDone={allTasksDone}
// onClaim={handleClaim}
// claiming={claiming}
// claimError={claimError}
/> />
)} )}
</div> </div>
+29 -7
View File
@@ -1,10 +1,30 @@
import { Loader2, AlertCircle } from "lucide-react"; import { AlertCircle } from "lucide-react";
import { useMyProjects } from "../../../hooks/useProjects"; import { useMyProjects } from "../../../hooks/useProjects";
import type { Project } from "../../../types/project.types"; import type { Project } from "../../../types/project.types";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import * as LucideIcons from 'lucide-react'; import * as LucideIcons from 'lucide-react';
import type { LucideProps } from 'lucide-react'; import type { LucideProps } from 'lucide-react';
import emptyimage from '../../../assets/Learning-amico.svg'
// Skeleton card that mirrors the real certificate card layout
function CertCardSkeleton() {
return (
<div className="bg-white border border-card-outline rounded-xl overflow-hidden shadow-sm animate-pulse">
{/* Thumbnail */}
<div className="p-6 pb-0">
<div className="rounded-lg overflow-hidden">
<div className="w-full h-32 bg-gray-200 rounded-lg" />
</div>
</div>
{/* Info */}
<div className="px-4 py-3 space-y-2">
<div className="h-3 bg-gray-200 rounded w-3/4" />
<div className="h-4 bg-gray-200 rounded w-5/6" />
<div className="h-3 bg-gray-200 rounded w-1/2" />
</div>
</div>
);
}
export default function SertificateList() { export default function SertificateList() {
const { projects, loading, error } = useMyProjects(); const { projects, loading, error } = useMyProjects();
@@ -23,11 +43,12 @@ export default function SertificateList() {
<p className="page-title-desc">All your earned internship certificates</p> <p className="page-title-desc">All your earned internship certificates</p>
</div> </div>
{/* Loading */} {/* Loading Skeleton */}
{loading && ( {loading && (
<div className="flex items-center justify-center py-20 text-gray-400 gap-2"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
<Loader2 className="w-6 h-6 animate-spin" /> {[...Array(6)].map((_, i) => (
<span className="text-sm">Loading certificates...</span> <CertCardSkeleton key={i} />
))}
</div> </div>
)} )}
@@ -41,8 +62,9 @@ export default function SertificateList() {
{/* Empty */} {/* Empty */}
{!loading && !error && projects.length === 0 && ( {!loading && !error && projects.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-gray-400"> <div className="flex flex-col items-center justify-center py-20 gap-5 text-font-shade">
<p className="text-sm">No ongoing projects yet.</p> <img src={emptyimage} alt="empty image" className="w-64 h-64" />
<p className="text-[18px]">Tidak ada projek yang berlangsung.</p>
</div> </div>
)} )}
+29 -23
View File
@@ -1,3 +1,4 @@
import { Users } from 'lucide-react';
import type { ProjectDetail, ProjectMember } from '../../../types/project.types'; import type { ProjectDetail, ProjectMember } from '../../../types/project.types';
export default function ParticipantsTab({ project }: { project: ProjectDetail }) { export default function ParticipantsTab({ project }: { project: ProjectDetail }) {
@@ -25,30 +26,35 @@ export default function ParticipantsTab({ project }: { project: ProjectDetail })
{/* Members */} {/* Members */}
<div> <div>
<h3 className="font-bold text-font-shade mb-3.5">Members</h3> <h3 className="font-bold text-font-shade mb-3.5">Members</h3>
{project.members.map((member: ProjectMember, idx) => ( {project.members.length === 0 ? (
<div <div className='flex flex-col items-center justify-center py-16'>
key={member.id} <Users className='w-12 h-12 text-font-shade mx-auto'/>
className={`flex items-center justify-between py-3 ${ <p className="text-sm text-font-shade mt-3">Belum ada anggota.</p>
idx < project.members.length - 1 ? 'border-b border-box-border' : ''
}`}
>
<div className="flex items-center gap-3">
{member.avatar ? (
<img src={member.avatar} alt={member.full_name} className="w-7 h-7 rounded-full object-cover" />
) : (
<div className="w-7 h-7 rounded-full bg-gray-200 flex-shrink-0" />
)}
<div className="flex flex-col">
<span className="text-sm text-[#333]">{member.full_name}</span>
<span className="text-[11px] text-gray-400">{member.email}</span>
</div>
</div>
<span className="text-[13px] text-[#555] font-semibold">{member.kelompok_peminatan}</span>
</div> </div>
))} ) : (
<>
{project.members.length === 0 && ( {project.members.map((member: ProjectMember, idx) => (
<p className="text-sm text-gray-400">Belum ada members.</p> <div
key={member.id}
className={`flex items-center justify-between py-3 ${
idx < project.members.length - 1 ? 'border-b border-box-border' : ''
}`}
>
<div className="flex items-center gap-3">
{member.avatar ? (
<img src={member.avatar} alt={member.full_name} className="w-7 h-7 rounded-full object-cover" />
) : (
<div className="w-7 h-7 rounded-full bg-gray-200 flex-shrink-0" />
)}
<div className="flex flex-col">
<span className="text-sm text-[#333]">{member.full_name}</span>
<span className="text-[11px] text-gray-400">{member.email}</span>
</div>
</div>
<span className="text-[13px] text-[#555] font-semibold">{member.kelompok_peminatan}</span>
</div>
))}
</>
)} )}
</div> </div>
</div> </div>
+14 -6
View File
@@ -1,5 +1,6 @@
import ProjectCard from './components/ProjectsCard' import ProjectCard from './components/ProjectsCard'
import { useMyProjects } from '../../../hooks/useProjects'; import { useMyProjects } from '../../../hooks/useProjects';
import TeamPana from '../../../assets/Team-pana.svg'
export default function ProjectsPage() { export default function ProjectsPage() {
const { projects, loading, error } = useMyProjects(); const { projects, loading, error } = useMyProjects();
@@ -21,12 +22,19 @@ export default function ProjectsPage() {
<h1 className="page-title-desc">View assigned projects and track their progress.</h1> <h1 className="page-title-desc">View assigned projects and track their progress.</h1>
</div> </div>
{/* Grid card */} {projects.length === 0 ? (
<div className="grid grid-cols-1 gap-4 md:gap-3 md:grid-cols-3 lg:grid-cols-5"> <div className="flex flex-col items-center justify-center py-2 gap-3 mt-19">
{projects.map(project => ( <img className='w-[440px]' src={TeamPana} alt="" />
<ProjectCard key={project.id} {...project} /> <p className="text-[18px] text-font-shade">Kamu belum tergabung ke projek apapun</p>
))} </div>
</div> ) : (
// Grid card
<div className="grid grid-cols-1 gap-4 md:gap-3 md:grid-cols-3 lg:grid-cols-5">
{projects.map(project => (
<ProjectCard key={project.id} {...project} />
))}
</div>
)}
</div> </div>
) )
} }
@@ -5,18 +5,12 @@ interface CertificateNotAvailableProps {
progress: number; progress: number;
remainingTasks: number; remainingTasks: number;
allTasksDone?: boolean; allTasksDone?: boolean;
// onClaim: () => void;
// claiming: boolean;
// claimError: string | null;
} }
export default function CertificateNotAvailable({ export default function CertificateNotAvailable({
progress, progress,
remainingTasks, remainingTasks,
allTasksDone = false, allTasksDone = false,
// onClaim,
// claiming,
// claimError,
}: CertificateNotAvailableProps) { }: CertificateNotAvailableProps) {
return ( return (
<div className="bg-box-primary w-full h-full rounded-2xl border border-box-border shadow-sm px-24 py-12 flex flex-col items-center justify-center"> <div className="bg-box-primary w-full h-full rounded-2xl border border-box-border shadow-sm px-24 py-12 flex flex-col items-center justify-center">
@@ -54,14 +48,6 @@ export default function CertificateNotAvailable({
<div className="h-full bg-green-500 rounded-full transition-all duration-700 w-full" /> <div className="h-full bg-green-500 rounded-full transition-all duration-700 w-full" />
</div> </div>
</div> </div>
{/* <button
onClick={onClaim}
disabled={claiming}
className="mt-0 px-6 py-2.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors duration-200"
>
{claiming ? "Memproses..." : "Klaim Sertifikat"}
</button>
{claimError && <p className="text-red-500 text-sm mt-2">{claimError}</p>} */}
</> </>
) : ( ) : (
<> <>
@@ -1,16 +1,18 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useProjects } from '../../../../hooks/useProjects'; import { useProjects } from '../../../../hooks/useProjects';
import { getProjectIcon } from '../../../../lib/ProjectIcons'; import { getProjectIcon } from '../../../../lib/ProjectIcons';
import { RiFileList2Fill } from "react-icons/ri";
const toSlug = (name: string) => name.toLowerCase().replace(/\s+/g, '-'); const toSlug = (name: string) => name.toLowerCase().replace(/\s+/g, '-');
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) => (
<div key={i} className="h-16 bg-gray-100 rounded-lg animate-pulse" /> <div key={i} className="h-16 bg-gray-100 rounded-lg" />
))} ))}
</div> </div>
); );
@@ -19,27 +21,38 @@ const HomeProjectList = () => {
return ( return (
<div> <div>
<h2 className="text-lg font-semibold text-font-shade mb-8">Projects</h2> <h2 className="text-lg font-semibold text-font-shade mb-5">Projects</h2>
<div className="flex flex-col gap-3 overflow-y-auto max-h-[190px]">
{projects.map((project) => ( {projects.length === 0 ? (
<div <div className="flex flex-col items-center justify-center rounded-lg py-2 gap-3">
key={project.id} <RiFileList2Fill className="w-12 h-12 text-gray-400" />
onClick={() => navigate(`/intern/projects/${toSlug(project.slug)}`)} <p className="text-sm text-gray-400">Kamu belum tergabung ke projek apapun</p>
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 className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0" style={{ backgroundColor: project.background_color ?? '#dc2626' }}> <div className="flex flex-col gap-3 overflow-y-auto max-h-[190px]">
{(() => { {projects.map((project) => (
const Icon = getProjectIcon(project.project_icon); <div
return <Icon className="w-5 h-5 text-white" />; key={project.id}
})()} 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"
>
<div
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ backgroundColor: project.background_color ?? '#dc2626' }}
>
{(() => {
const Icon = getProjectIcon(project.project_icon);
return <Icon className="w-5 h-5 text-white" />;
})()}
</div>
<div>
<p className="text-sm font-semibold text-font-shade">{project.project_name}</p>
<p className="text-xs text-gray-400">{project.status}</p>
</div>
</div> </div>
<div> ))}
<p className="text-sm font-semibold text-font-shade">{project.project_name}</p> </div>
<p className="text-xs text-gray-400">{project.status}</p> )}
</div>
</div>
))}
</div>
</div> </div>
); );
}; };
@@ -2,6 +2,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
import { Award, Settings, HelpCircle, X } from 'lucide-react'; import { Award, Settings, HelpCircle, X } from 'lucide-react';
import { GrHomeRounded } from "react-icons/gr"; import { GrHomeRounded } from "react-icons/gr";
import { MdOutlineAssignment, MdLogout } from "react-icons/md"; import { MdOutlineAssignment, MdLogout } from "react-icons/md";
import { HiUser } from "react-icons/hi2";
const navItems = [ const navItems = [
{ label: "Home", icon: GrHomeRounded, path: "/intern" }, { label: "Home", icon: GrHomeRounded, path: "/intern" },
@@ -46,12 +47,14 @@ export default function SidebarIntern({ isOpen, closeSidebar }: SidebarProps) {
`} `}
> >
{/* Brand & Tombol Close untuk Mobile */} {/* Brand & Tombol Close untuk Mobile */}
<div className="flex items-center justify-between px-2 pb-10"> <div className="flex items-center justify-between px-2 pb-6">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="w-9 h-9 rounded-[10px] bg-red flex items-center justify-center flex-shrink-0"></div> <div className="w-9 h-9 rounded-[10px] bg-red flex items-center justify-center flex-shrink-0">
<HiUser className="w-[18px] h-[18px] text-white"/>
</div>
<div> <div>
<p className="text-sm font-bold text-gray-900 leading-tight">Internify</p> <p className="text-[19px] font-bold text-font-shade leading-tight">Internify</p>
<p className="text-[11px] text-gray-400">LMS Portal</p> <p className="text-[14px] text-font">LMS Portal</p>
</div> </div>
</div> </div>
<button <button
@@ -83,10 +86,10 @@ export default function SidebarIntern({ isOpen, closeSidebar }: SidebarProps) {
})} })}
</nav> </nav>
<div className="h-px bg-gray-200 my-1" /> <div className="h-px bg-gray-300 my-1" />
{/* Preferences */} {/* Preferences */}
<p className="text-[10px] font-semibold tracking-widest text-gray-400 px-3 py-1.5"> <p className="text-[10px] font-semibold tracking-widest text-font px-3 py-1.5">
PREFERENCES PREFERENCES
</p> </p>
<nav className="flex flex-col gap-0.5"> <nav className="flex flex-col gap-0.5">
@@ -1,5 +1,6 @@
import { useMyTasks } from "../../../../hooks/useProjects"; import { useMyTasks } from "../../../../hooks/useProjects";
import type { InternTaskItem } from "../../../../types/project.types"; import type { InternTaskItem } from "../../../../types/project.types";
import { RiFileList2Fill } from "react-icons/ri";
const TaskTimeline = () => { const TaskTimeline = () => {
const { tasks, loading, error } = useMyTasks(); const { tasks, loading, error } = useMyTasks();
@@ -20,43 +21,50 @@ const TaskTimeline = () => {
return ( return (
<div> <div>
<h2 className="font-semibold text-font-shade mb-5">Timeline Tugas</h2> <h2 className="font-semibold text-font-shade mb-5">Timeline Tugas</h2>
<div className="relative overflow-y-auto max-h-[190px]">
<ul className="flex flex-col gap-5">
{tasks.map((item: InternTaskItem, index) => {
const overdue = isOverdue(item.deadline_at);
const deadlineDate = new Date(item.deadline_at);
const isLast = index === tasks.length - 1;
return ( {tasks.length === 0 ? (
<li key={item.id} className="relative flex gap-4 pl-0"> <div className="flex flex-col items-center justify-center py-2 gap-3">
{/* Vertical line ke bawah, kecuali item terakhir */} <RiFileList2Fill className="w-12 h-12 text-gray-400" />
{!isLast && ( <p className="text-sm text-gray-400">Tidak ada tugas saat ini</p>
<div className="absolute left-[3px] top-3 w-px h-full bg-[#ddd]" /> </div>
)} ) : (
<div className="relative overflow-y-auto max-h-[190px]">
<ul className="flex flex-col gap-5">
{tasks.map((item: InternTaskItem, index) => {
const overdue = isOverdue(item.deadline_at);
const deadlineDate = new Date(item.deadline_at);
const isLast = index === tasks.length - 1;
<div className="relative flex-shrink-0 mt-1"> return (
<span <li key={item.id} className="relative flex gap-4 pl-0">
className={`block w-3 h-3 rounded-full z-10 relative border-2 right-0.5 border-white ${overdue ? "bg-primary" : "bg-gray-500" {!isLast && (
}`} <div className="absolute left-[3px] top-3 w-px h-full bg-[#ddd]" />
/> )}
</div>
<div className="flex flex-col gap-0.5 min-w-0"> <div className="relative flex-shrink-0 mt-1">
<p className="text-[10px] font-semibold text-font-shade uppercase tracking-widest"> <span
{deadlineDate.toLocaleDateString("id-ID")} AT{" "} className={`block w-3 h-3 rounded-full z-10 relative border-2 right-0.5 border-white ${overdue ? "bg-primary" : "bg-gray-500"
{deadlineDate.toLocaleTimeString("id-ID", { }`}
hour: "2-digit", />
minute: "2-digit", </div>
})}
</p> <div className="flex flex-col gap-0.5 min-w-0">
<p className="text-sm font-semibold text-gray-800">{item.title}</p> <p className="text-[10px] font-semibold text-font-shade uppercase tracking-widest">
<p className="text-xs text-gray-500">{item.description}</p> {deadlineDate.toLocaleDateString("id-ID")} AT{" "}
</div> {deadlineDate.toLocaleTimeString("id-ID", {
</li> hour: "2-digit",
); minute: "2-digit",
})} })}
</ul> </p>
</div> <p className="text-sm font-semibold text-gray-800">{item.title}</p>
<p className="text-xs text-gray-500">{item.description}</p>
</div>
</li>
);
})}
</ul>
</div>
)}
</div> </div>
); );
}; };
+3 -3
View File
@@ -36,13 +36,13 @@ export const MentorLayout = () => {
const toggleSidebar = () => setIsSidebarOpen(!isSidebarOpen); const toggleSidebar = () => setIsSidebarOpen(!isSidebarOpen);
const closeSidebar = () => setIsSidebarOpen(false); const closeSidebar = () => setIsSidebarOpen(false);
return ( return (
<div className="min-h-screenflex flex-col"> <div className="min-h-screen flex flex-col relative">
<Header toggleSidebar={toggleSidebar}/> <Header toggleSidebar={toggleSidebar}/>
<div className="flex flex-1"> <div className="flex flex-1 relative">
<SidebarMentor isOpen={isSidebarOpen} closeSidebar={closeSidebar} /> <SidebarMentor isOpen={isSidebarOpen} closeSidebar={closeSidebar} />
{isSidebarOpen && ( {isSidebarOpen && (
<div <div
className="fixed inset-0 bg-black/20 z-20 md:hidden min-h-screen" className="fixed inset-0 bg-black/20 z-20 lg:hidden min-h-screen"
onClick={closeSidebar} onClick={closeSidebar}
/> />
)} )}
@@ -3,10 +3,11 @@ import { Plus } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import CreateProjectModal from './components/CreateProjectDialog'; import CreateProjectModal from './components/CreateProjectDialog';
import { useProjectsByRole } from '../../../hooks/useListProjects'; import { useProjectsByRole } from '../../../hooks/useListProjects';
import TeamPana from '../../../assets/Team-pana.svg';
export default function MentorProjectsPage() { export default function MentorProjectsPage() {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const { projects, loading, error, refetch } = useProjectsByRole("active"); const { projects, loading, error, refetch } = useProjectsByRole();
const openDialog = () => { const openDialog = () => {
setIsModalOpen(true); setIsModalOpen(true);
@@ -48,6 +49,14 @@ export default function MentorProjectsPage() {
</div> </div>
<div className="flex flex-wrap gap-4 items-stretch"> <div className="flex flex-wrap gap-4 items-stretch">
{projects.length === 0 && (
<div className="flex flex-col items-center justify-center py-2 gap-3 mt-19 w-full">
<img className='w-[440px]' src={TeamPana} alt="" />
<p className="text-[18px] text-font-shade">Belum ada project, Buat project dengan kik 'add'</p>
</div>
)}
{projects.map(project => ( {projects.map(project => (
<MentorProjectCard <MentorProjectCard
key={project.id} key={project.id}
@@ -1,9 +1,29 @@
// src/pages/mentor/CertificateListPage.tsx // src/pages/mentor/CertificateListPage.tsx
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { AlertCircle } from "lucide-react";
import CertificateProjectCard from "./components/CertificateProjectCard"; import CertificateProjectCard from "./components/CertificateProjectCard";
import { useProjects } from "../../../hooks/useProjects"; import { useProjects } from "../../../hooks/useProjects";
import emptyimage from "../../../assets/Learning-amico.svg";
function CertCardSkeleton() {
return (
<div className="bg-white border border-card-outline rounded-xl overflow-hidden shadow-sm animate-pulse">
{/* Thumbnail */}
<div className="p-6 pb-0">
<div className="rounded-lg overflow-hidden">
<div className="w-full h-32 bg-gray-200 rounded-lg" />
</div>
</div>
{/* Info */}
<div className="px-4 py-3 space-y-2">
<div className="h-3 bg-gray-200 rounded w-3/4" />
<div className="h-4 bg-gray-200 rounded w-5/6" />
<div className="h-3 bg-gray-200 rounded w-1/2" />
</div>
</div>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function CertificateList() { export default function CertificateList() {
const navigate = useNavigate(); const navigate = useNavigate();
const { projects, loading, error } = useProjects(); const { projects, loading, error } = useProjects();
@@ -12,13 +32,9 @@ export default function CertificateList() {
navigate(`/mentor/certificates/${slug}`); navigate(`/mentor/certificates/${slug}`);
}; };
if (loading){ const isEmpty = !loading && !error && projects.length === 0;
<p>on loading</p> const hasProjects = !loading && !error && projects.length > 0;
}
if (error){
<p>error: {error}</p>
}
return ( return (
<div> <div>
{/* Page Header */} {/* Page Header */}
@@ -29,20 +45,50 @@ export default function CertificateList() {
</p> </p>
</div> </div>
{/* Section: List Projects */} {/* Loading Skeleton — struktur sama persis dengan body */}
<div> {loading && (
<h2 className="text-base font-bold text-font-shade mb-4">List Projects</h2> <div>
<div className="h-4 w-32 bg-gray-200 rounded mb-4 animate-pulse" />
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{projects.map((project) => ( {[...Array(6)].map((_, i) => (
<CertificateProjectCard <CertCardSkeleton key={i} />
key={project.id} ))}
project={project} </div>
onClick={() => handleCardClick(project.slug)}
/>
))}
</div> </div>
</div> )}
{/* Error */}
{error && !loading && (
<div className="flex items-center justify-center py-20 text-red-500 gap-2">
<AlertCircle className="w-6 h-6" />
<span className="text-sm">{error}</span>
</div>
)}
{/* Empty */}
{isEmpty && (
<div className="flex flex-col items-center justify-center py-20 gap-5 text-font-shade">
<img src={emptyimage} alt="Tidak ada projek" className="w-64 h-64" />
<p className="text-[18px]">Tidak ada projek yang berlangsung.</p>
</div>
)}
{/* Section: List Projects — hanya tampil kalau ada project */}
{hasProjects && (
<div>
<h2 className="text-base font-bold text-font-shade mb-4">List Projects</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{projects.map((project) => (
<CertificateProjectCard
key={project.id}
project={project}
onClick={() => handleCardClick(project.slug)}
/>
))}
</div>
</div>
)}
</div> </div>
); );
} }
@@ -3,6 +3,7 @@ import HeroProject from '../intern/components/Hero-project';
import MentorTaskCard from './components/MentorTaskCard'; import MentorTaskCard from './components/MentorTaskCard';
import { useProjectTasks } from '../../../hooks/useTasks'; import { useProjectTasks } from '../../../hooks/useTasks';
import type { Project } from '../../../types/project.types'; import type { Project } from '../../../types/project.types';
import { FileText } from 'lucide-react';
export default function MentorForumTab({ project }: { project: Project }) { export default function MentorForumTab({ project }: { project: Project }) {
const { tasks, loading, error } = useProjectTasks(project.id); const { tasks, loading, error } = useProjectTasks(project.id);
@@ -15,13 +16,16 @@ export default function MentorForumTab({ project }: { project: Project }) {
{loading && ( {loading && (
<div className="flex flex-col gap-3 mt-5"> <div className="flex flex-col gap-3 mt-5">
{[...Array(3)].map((_, i) => ( {[...Array(3)].map((_, i) => (
<div key={i} className="h-24 bg-gray-100 rounded-xl animate-pulse" /> <div key={i} className="h-28 bg-gray-100 rounded-xl animate-pulse" />
))} ))}
</div> </div>
)} )}
{!loading && tasks.length === 0 ? ( {!loading && tasks.length === 0 ? (
<p className="text-md text-font-shade mt-5">Belum ada tugas yang dikerjakan.</p> <div className='flex justify-center items-center flex-col mt-5'>
<FileText className='text-font mb-3' size={48} />
<p className="text-md text-font-shade">Belum ada tugas yang dapat dikerjakan.</p>
</div>
) : ( ) : (
!loading && ( !loading && (
<div className="flex flex-col gap-7 md:flex-row md:items-start mt-5"> <div className="flex flex-col gap-7 md:flex-row md:items-start mt-5">
@@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { Plus } from 'lucide-react'; import { Plus, Users } from 'lucide-react';
import ManageInternsModal from './components/AddInternsDialog'; import ManageInternsModal from './components/AddInternsDialog';
import type { ProjectDetail, ProjectMember } from '../../../types/project.types'; import type { ProjectDetail, ProjectMember } from '../../../types/project.types';
import { useRemoveMember } from '../../../hooks/useProjects'; import { useRemoveMember } from '../../../hooks/useProjects';
@@ -68,7 +68,13 @@ export default function InternsTab({ project }: { project: ProjectDetail }) {
</span> </span>
</button> </button>
</div> </div>
{members.map((member: ProjectMember, idx) => ( {members.length === 0 ? (
<div className='flex flex-col items-center justify-center py-16'>
<Users className='w-12 h-12 text-font-shade mx-auto'/>
<p className="text-sm text-font-shade mt-3">Belum ada anggota.</p>
</div>
) : (
members.map((member: ProjectMember, idx) => (
<div <div
key={idx} key={idx}
className={`flex items-center justify-between py-3 ${idx < members.length - 1 ? 'border-b border-gray-300' : '' className={`flex items-center justify-between py-3 ${idx < members.length - 1 ? 'border-b border-gray-300' : ''
@@ -92,7 +98,7 @@ export default function InternsTab({ project }: { project: ProjectDetail }) {
</button> </button>
</div> </div>
</div> </div>
))} )))}
</div> </div>
{isModalOpen && <ManageInternsModal {isModalOpen && <ManageInternsModal
isOpen={isModalOpen} isOpen={isModalOpen}
+3 -2
View File
@@ -85,8 +85,9 @@ export default function TaskTab({ project }: { project: ProjectDetail }) {
</div> </div>
)) ))
) : ( ) : (
<div className="text-center py-10 text-gray-400 text-sm"> <div className='flex flex-col items-center justify-center py-16'>
Belum ada tugas yang dibuat untuk proyek ini. <ClipboardList className='w-12 h-12 text-font-shade mx-auto'/>
<p className="text-sm text-font-shade mt-3">Belum ada tugas yang dibuat untuk proyek ini.</p>
</div> </div>
)} )}
</div> </div>
@@ -13,26 +13,24 @@ interface AddInternsModalProps {
export default function AddInternsModal({ isOpen, onClose, userId }: AddInternsModalProps) { export default function AddInternsModal({ isOpen, onClose, userId }: AddInternsModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null); const dialogRef = useRef<HTMLDialogElement>(null);
// Form States
const [selectedUserId, setSelectedUserId] = useState<string>(""); const [selectedUserId, setSelectedUserId] = useState<string>("");
const [selectedProjectId, setSelectedProjectId] = useState<string>(""); const [selectedProjectId, setSelectedProjectId] = useState<string>("");
// 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(); const { interns, loading: loadingInterns } = useAssignableInterns();
console.log(projects)
useEffect(() => { useEffect(() => {
const dialog = dialogRef.current; const dialog = dialogRef.current;
if (!dialog) return; if (!dialog) return;
if (isOpen) { if (isOpen) {
dialog.showModal(); dialog.showModal();
document.body.style.overflow = "hidden"; // document.body.style.overflow = "hidden";
} else { } else {
dialog.close(); dialog.close();
document.body.style.overflow = "unset"; // document.body.style.overflow = "unset";
} }
const handleCancel = (e: Event) => { const handleCancel = (e: Event) => {
@@ -24,10 +24,10 @@ export default function CompleteProjectDialog({
if (isOpen) { if (isOpen) {
dialog.showModal(); dialog.showModal();
document.body.style.overflow = "hidden"; // document.body.style.overflow = "hidden";
} else { } else {
dialog.close(); dialog.close();
document.body.style.overflow = "unset"; // document.body.style.overflow = "unset";
} }
const handleCancel = (e: Event) => { const handleCancel = (e: Event) => {
@@ -38,10 +38,8 @@ export default function CreateProjectModal({ isOpen, onClose, onSuccess }: Creat
if (isOpen) { if (isOpen) {
dialog.showModal(); dialog.showModal();
document.body.style.overflow = "hidden";
} else { } else {
dialog.close(); dialog.close();
document.body.style.overflow = "unset";
} }
const handleCancel = (e: Event) => { const handleCancel = (e: Event) => {
@@ -171,7 +171,7 @@ export default function EligibleInternTable({
).then(() => true).catch(() => false); ).then(() => true).catch(() => false);
setGenerating(false); setGenerating(false);
if (ok) navigate(`/mentor/certificates/${project.id}/result`); if (ok) navigate(`/mentor/certificates/${project.slug}/result`);
}; };
if (loading) { if (loading) {
@@ -4,6 +4,19 @@ import { Plus } from 'lucide-react';
import CreateProjectModal from './CreateProjectDialog'; import CreateProjectModal from './CreateProjectDialog';
import { useProjects } from '../../../../hooks/useProjects'; import { useProjects } from '../../../../hooks/useProjects';
import { getProjectIcon } from '../../../../lib/ProjectIcons'; import { getProjectIcon } from '../../../../lib/ProjectIcons';
import { RiFileList2Fill } from 'react-icons/ri';
function ProjectCardSkeleton() {
return (
<div className="flex items-center gap-4 border border-gray-200 rounded-lg px-4 py-3 bg-gray-50 animate-pulse">
<div className="w-10 h-10 rounded-lg bg-gray-300 flex-shrink-0" />
<div className="space-y-2">
<div className="h-4 bg-gray-300 rounded w-32" />
<div className="h-3 bg-gray-300 rounded w-40" />
</div>
</div>
);
}
const HomeMentorProject = () => { const HomeMentorProject = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -30,8 +43,23 @@ const HomeMentorProject = () => {
</button> </button>
</div> </div>
<div className="flex flex-col max-h-[160px] overflow-y-auto gap-3 pr-1"> <div className="flex flex-col max-h-[160px] overflow-y-auto gap-3 pr-1">
{loading && <p className="text-sm text-gray-400 px-5">Memuat...</p>} {loading && (
<>
{[...Array(2)].map((_, i) => (
<ProjectCardSkeleton key={i} />
))}
</>
)}
{error && <p className="text-sm text-red-500 px-5">{error}</p>} {error && <p className="text-sm text-red-500 px-5">{error}</p>}
{projects.length === 0 && !loading && (
<div className="flex flex-col items-center justify-center gap-2">
<RiFileList2Fill className="w-12 h-12 text-gray-400" />
<p className="text-sm text-gray-400 text-center">Belum ada project</p>
</div>
)}
{projects.map((project) => ( {projects.map((project) => (
<div <div
key={project.id} key={project.id}
@@ -35,10 +35,6 @@ export default function InternsTable({
const handlePrev = () => setCurrentPage((p) => Math.max(1, p - 1)); const handlePrev = () => setCurrentPage((p) => Math.max(1, p - 1));
const handleNext = () => setCurrentPage((p) => Math.min(totalPages, p + 1)); const handleNext = () => setCurrentPage((p) => Math.min(totalPages, p + 1));
// Reset ke halaman 1 ketika filter berubah (melalui props interns)
// Gunakan useEffect agar reset terjadi saat props berubah
// eslint-disable-next-line react-hooks/exhaustive-deps
return ( return (
<div className="w-full mt-3 bg-white rounded-xl border border-gray-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)] overflow-hidden"> <div className="w-full mt-3 bg-white rounded-xl border border-gray-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)] overflow-hidden">
@@ -67,7 +63,7 @@ export default function InternsTable({
{/* Table Body Iteration Rows */} {/* Table Body Iteration Rows */}
<div className="divide-y divide-gray-100"> <div className="divide-y divide-gray-100">
{paginatedInterns.length > 0 ? ( {paginatedInterns.length > 0 ? (
paginatedInterns.map((intern, index) => { paginatedInterns.map((intern, index) => {
const isUnassigned = !intern.projectName || intern.projectName === "Unassigned"; const isUnassigned = !intern.projectName || intern.projectName === "Unassigned";
@@ -128,9 +124,13 @@ export default function InternsTable({
</div> </div>
); );
}) })
) : totalInterns != 0 ? (
<div className="text-center py-12 text-md text-font font-medium">
Tidak ada intern yang sesuai dengan kriteria
</div>
) : ( ) : (
<div className="text-center py-12 text-sm text-gray-400 font-medium"> <div className="text-center py-12 text-md text-font font-medium">
No interns found matching the filter criteria. Tidak ada intern yang terdaftar
</div> </div>
)} )}
</div> </div>
@@ -1,5 +1,6 @@
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { Home, FolderKanban, UsersRound, Medal, Settings, HelpCircle, LogOut, X } from 'lucide-react'; import { Home, FolderKanban, UsersRound, Medal, Settings, HelpCircle, LogOut, X } from 'lucide-react';
import { FaUserTie } from "react-icons/fa";
const navItems = [ const navItems = [
// belum kutambahin // belum kutambahin
@@ -39,19 +40,21 @@ export default function SidebarMentor({ isOpen, closeSidebar }: SidebarProps) {
return ( return (
<aside <aside
className={` className={`
bg-white border-r border-gray-300 flex flex-col py-5 px-1 gap-1 overflow-y-auto flex-shrink-0 transition-transform duration-300 ease-in-out bg-white border-r border-gray-300 flex flex-col py-5 px-3 gap-1 overflow-y-auto flex-shrink-0 transition-transform duration-300 ease-in-out
lg:sticky lg:top-[50px] lg:h-[calc(100vh-73px)] lg:w-[250px] lg:translate-x-0 lg:z-8 lg:sticky lg:top-[50px] lg:h-[calc(100vh-73px)] lg:w-[250px] lg:translate-x-0 lg:z-8
fixed top-0 left-0 h-[calc(100vh-50px)] w-[270px] z-40 pt-5 fixed top-0 left-0 h-screen w-[270px] z-40 pt-5
${isOpen ? "translate-x-0" : "-translate-x-full"} ${isOpen ? "translate-x-0" : "-translate-x-full"}
`} `}
> >
{/* Brand & Tombol Close untuk Mobile */} {/* Brand & Tombol Close untuk Mobile */}
<div className="flex items-center justify-between px-2 pb-10"> <div className="flex items-center justify-between px-2 pb-6">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="w-9 h-9 rounded-[10px] bg-red-600 flex items-center justify-center flex-shrink-0"></div> <div className="w-9 h-9 rounded-[10px] bg-red flex items-center justify-center flex-shrink-0">
<FaUserTie className="w-[18px] h-[18px] text-white"/>
</div>
<div> <div>
<p className="text-sm font-bold text-gray-900 leading-tight">Internify</p> <p className="text-[19px] font-bold text-font-shade leading-tight">Internify</p>
<p className="text-[11px] text-gray-400">LMS Portal</p> <p className="text-[14px] text-font">LMS Portal</p>
</div> </div>
</div> </div>
@@ -71,9 +74,9 @@ export default function SidebarMentor({ isOpen, closeSidebar }: SidebarProps) {
<button <button
key={path} key={path}
onClick={() => handleNav(path)} onClick={() => handleNav(path)}
className={`flex items-center gap-2.5 w-full px-3 py-2 text-[13.5px] rounded-lg font-medium text-left transition-colors className={`flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-[13.5px] font-medium text-left transition-colors
${isActive ${isActive
? "bg-red-foreground text-red font-semibold border-l-6 border-red" ? "bg-red-foreground text-red font-semibold border-l-4 border-base"
: "text-font hover:bg-red-foreground" : "text-font hover:bg-red-foreground"
}`} }`}
> >
@@ -84,14 +87,14 @@ export default function SidebarMentor({ isOpen, closeSidebar }: SidebarProps) {
})} })}
</nav> </nav>
<div className="h-px bg-gray-200 my-1" /> <div className="h-px bg-gray-300 my-1" />
{/* Preferences */} {/* Preferences */}
<p className="text-[10px] font-semibold tracking-widest text-gray-400 px-3 py-1.5"> <p className="text-[10px] font-semibold tracking-widest text-font px-3 py-1.5">
PREFERENCES PREFERENCES
</p> </p>
<nav className="flex flex-col gap-0.5"> <nav className="flex flex-col gap-0.5">
{prefItems.map(({ label, icon: Icon, path, danger, isLogout }) => { {prefItems.map(({ label, icon: Icon, path, isLogout }) => {
const isActive = location.pathname === path; const isActive = location.pathname === path;
return ( return (
<button <button
+5 -10
View File
@@ -3,7 +3,7 @@ import QRCode from "qrcode";
export function resolveImageUrl(path: string): string { export function resolveImageUrl(path: string): string {
if (!path) return ""; if (!path) return "";
if (path.startsWith("data:")) return path; // untuk QR data URL if (path.startsWith("data:")) return path;
if (path.startsWith("http://") || path.startsWith("https://")) { if (path.startsWith("http://") || path.startsWith("https://")) {
return path; return path;
} }
@@ -12,7 +12,6 @@ export function resolveImageUrl(path: string): string {
return `${origin}${path.startsWith("/") ? "" : "/"}${path}`; return `${origin}${path.startsWith("/") ? "" : "/"}${path}`;
} }
// Load Image dari URL
export function loadImage(src: string): Promise<HTMLImageElement> { export function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
@@ -23,9 +22,7 @@ export function loadImage(src: string): Promise<HTMLImageElement> {
}); });
} }
// Load kedua font dari Google Fonts (hanya sekali)
export async function ensureFontsLoaded(): Promise<void> { export async function ensureFontsLoaded(): Promise<void> {
// Trigger & tunggu font yang sudah diload via <link> di index.html
await Promise.all([ await Promise.all([
document.fonts.load('105px "Great Vibes"'), document.fonts.load('105px "Great Vibes"'),
document.fonts.load('54px "Grenze"'), document.fonts.load('54px "Grenze"'),
@@ -33,7 +30,6 @@ export async function ensureFontsLoaded(): Promise<void> {
await document.fonts.ready; await document.fonts.ready;
} }
// Generate satu sertifikat sebagai Blob
export async function generateCertificate( export async function generateCertificate(
templateUrl: string, templateUrl: string,
internName: string, internName: string,
@@ -42,7 +38,6 @@ export async function generateCertificate(
ProjectDuration: string, ProjectDuration: string,
verifyUrl: string, verifyUrl: string,
): Promise<Blob> { ): Promise<Blob> {
// Pastikan font siap sebelum menggambar teks
await ensureFontsLoaded(); await ensureFontsLoaded();
const img = await loadImage(templateUrl); const img = await loadImage(templateUrl);
@@ -84,17 +79,17 @@ export async function generateCertificate(
ctx.fillStyle = "#090909"; ctx.fillStyle = "#090909";
ctx.fillText(ProjectDuration, canvas.width * 0.68, canvas.height * 0.66); ctx.fillText(ProjectDuration, canvas.width * 0.68, canvas.height * 0.66);
// QR code — kalau verifyUrl diisi // QR code — nanti akan diganti ke alamat domain
const qrDataUrl = await QRCode.toDataURL('http://localhost:5173/verify-certificate/' + verifyUrl, { const qrDataUrl = await QRCode.toDataURL('http://localhost:5173/verify-certificate/' + verifyUrl, {
width: 200, width: 200,
margin: 1, margin: 1,
}); });
const qrImg = await loadImage(qrDataUrl); const qrImg = await loadImage(qrDataUrl);
const qrSize = canvas.width * 0.103; const qrSize = canvas.width;
ctx.drawImage( ctx.drawImage(
qrImg, qrImg,
canvas.width * 0.8 - qrSize, canvas.width * 0.855 - qrSize,
canvas.height * 0.75 - qrSize, canvas.height * 0.85 - qrSize,
qrSize, qrSize,
qrSize, qrSize,
); );
+3 -4
View File
@@ -63,10 +63,9 @@
ctx.fillStyle = "#800000"; ctx.fillStyle = "#800000";
ctx.fillText("CERT/20260609/PRJ2/USR5", canvas.width * 0.825, canvas.height * 0.918); ctx.fillText("CERT/20260609/PRJ2/USR5", canvas.width * 0.825, canvas.height * 0.918);
// 5. QR code — posisi & ukuran pakai persentase, sama seperti elemen lain const qrSize = canvas.width * 0.103;
const qrSize = canvas.width * 0.103; // atur besar-kecil di sini const qrX = canvas.width * 0.855;
const qrX = canvas.width * 0.77; // titik kiri-atas QR (bukan tengah!) const qrY = canvas.height * 0.85;
const qrY = canvas.height * 0.715;
ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize); ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize);
} }
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 60 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 128 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 79 KiB

+63 -9
View File
@@ -1,9 +1,10 @@
// hooks/useUser.ts // hooks/useUser.ts
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { loginUser, getProfile, updateProfile } from "../services/UserService"; import { loginUser, getProfile, updateProfile } from "../services/UserService";
import type { Mahasiswa } from '../types/project.types'; import type { Mahasiswa } from "../types/project.types";
import { getMahasiswa } from '../services/MahasiswaService'; import { getMahasiswa } from "../services/MahasiswaService";
import type { CurrentUser, UpdateProfilePayload } from "../types/user.types"; import type { CurrentUser, UpdateProfilePayload } from "../types/user.types";
import { useRef } from "react";
// GET /auth-api/me — ambil data user yang sedang login // GET /auth-api/me — ambil data user yang sedang login
export const useCurrentUser = () => { export const useCurrentUser = () => {
@@ -54,29 +55,80 @@ export const useLogin = () => {
return { login, loading, error, setError }; return { login, loading, error, setError };
}; };
export const DEFAULT_AVATAR = "https://placehold.co/150";
export const resolveFileUrl = (path?: string | null): string | null => {
if (!path) return null;
if (/^(https?:|data:|blob:)/i.test(path)) return path;
const base = import.meta.env.VITE_API_BASE_URL as string;
return `${base}${path.startsWith("/") ? path : `/${path}`}`;
};
// PATCH /auth-api/update-profile // PATCH /auth-api/update-profile
export const useUpdateProfile = () => { export const useUpdateProfile = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null); const [successMsg, setSuccessMsg] = useState<string | null>(null);
const msgRef = useRef<{ success: string | null; error: string | null }>({
success: null,
error: null,
});
const save = async (payload: UpdateProfilePayload) => { const save = async (payload: UpdateProfilePayload) => {
setLoading(true); setLoading(true);
setError(null); setError(null);
setSuccessMsg(null); setSuccessMsg(null);
msgRef.current = { success: null, error: null };
try { try {
const updated = await updateProfile(payload); const updated = await updateProfile(payload);
setSuccessMsg("Profile updated successfully!");
const msg = "Profile updated successfully!";
msgRef.current.success = msg;
setSuccessMsg(msg);
return updated; return updated;
} catch (err: any) { } catch (err: any) {
setError(err?.response?.data?.message || "Failed to save changes."); const status = err?.response?.status;
return null; const apiMessage = err?.response?.data?.message;
let message: string;
switch (status) {
case 400:
message =
apiMessage ||
"Data yang dikirim tidak valid. Periksa kembali isian Anda.";
break;
case 401:
message = "Sesi Anda telah berakhir. Silakan login kembali.";
break;
case 403:
message = "Anda tidak memiliki izin untuk mengubah profil ini.";
break;
case 413:
message = "Ukuran file terlalu besar. Maksimal 2MB.";
break;
case 417:
message = apiMessage || "Validasi gagal. Periksa kembali isian Anda.";
break;
case 500:
message = "Terjadi kesalahan di server. Coba beberapa saat lagi.";
break;
default:
message = apiMessage || "Failed to save changes.";
}
msgRef.current.error = message;
setError(message);
// WAJIB throw — kalau `return null`, toast selalu masuk cabang success.
throw new Error(message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
return { save, loading, error, successMsg, setSuccessMsg, setError }; return { save, loading, error, successMsg, setSuccessMsg, setError, msgRef };
}; };
// Ambil Seluruh Mahasiswa // Ambil Seluruh Mahasiswa
@@ -89,11 +141,13 @@ export const useMahasiswa = () => {
setLoading(true); setLoading(true);
getMahasiswa() getMahasiswa()
.then(setMahasiswa) .then(setMahasiswa)
.catch(() => setError('Gagal memuat data mahasiswa.')) .catch(() => setError("Gagal memuat data mahasiswa."))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
useEffect(() => { refetch(); }, [refetch]); useEffect(() => {
refetch();
}, [refetch]);
return { mahasiswa, loading, error, refetch }; return { mahasiswa, loading, error, refetch };
}; };
+2
View File
@@ -1,5 +1,7 @@
import axios from "axios"; import axios from "axios";
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string;
const api = axios.create({ const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL, baseURL: import.meta.env.VITE_API_BASE_URL,
}); });
+32 -9
View File
@@ -1,7 +1,7 @@
// services/UserService.ts // services/UserService.ts
import api from "../lib/api"; import api from "../lib/api";
import type { CurrentUser, UpdateProfilePayload } from "../types/user.types"; 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
export const loginUser = async (payload: { export const loginUser = async (payload: {
@@ -20,24 +20,47 @@ export const getProfile = async (): Promise<CurrentUser> => {
// PATCH /auth-api/update-profile // PATCH /auth-api/update-profile
export const updateProfile = async ( export const updateProfile = async (
payload: UpdateProfilePayload payload: UpdateProfilePayload,
): Promise<CurrentUser> => { ): Promise<CurrentUser> => {
const parts = (payload.full_name ?? "").trim().split(/\s+/).filter(Boolean);
const namaDepan = parts[0] ?? "";
const namaBelakang = parts.slice(1).join(" ");
if (payload.profile_picture instanceof File) { if (payload.profile_picture instanceof File) {
const formData = new FormData(); const formData = new FormData();
if (payload.full_name !== undefined) formData.append("full_name", payload.full_name);
if (payload.full_name !== undefined) {
formData.append("nama_depan", namaDepan);
formData.append("nama_belakang", namaBelakang);
}
if (payload.email !== undefined) formData.append("email", payload.email); if (payload.email !== undefined) formData.append("email", payload.email);
if (payload.professional_bio !== undefined) formData.append("professional_bio", payload.professional_bio); if (payload.professional_bio !== undefined)
formData.append("professional_bio", payload.professional_bio);
formData.append("profile_picture", payload.profile_picture); formData.append("profile_picture", payload.profile_picture);
const res = await api.patch("/auth-api/update-profile", formData, {
headers: { "Content-Type": "multipart/form-data" }, const res = await api.patch("/auth-api/update-profile", formData);
});
return res.data.data; return res.data.data;
} }
const res = await api.patch("/auth-api/update-profile", payload); const body: Record<string, unknown> = {};
if (payload.full_name !== undefined) {
body.nama_depan = namaDepan;
body.nama_belakang = namaBelakang;
}
if (payload.email !== undefined) body.email = payload.email;
if (payload.professional_bio !== undefined) {
body.professional_bio = payload.professional_bio;
}
if (typeof payload.profile_picture === "string") {
body.profile_picture = payload.profile_picture;
}
const res = await api.patch("/auth-api/update-profile", body);
return res.data.data; return res.data.data;
}; };
export const getMahasiswa = async (): Promise<Mahasiswa[]> => { export const getMahasiswa = async (): Promise<Mahasiswa[]> => {
const response = await api.get<MahasiswaResponse>('/mahasiswa-api/get'); const response = await api.get<MahasiswaResponse>("/mahasiswa-api/get");
return response.data.data; return response.data.data;
}; };
+1 -1
View File
@@ -13,5 +13,5 @@ export interface UpdateProfilePayload {
full_name?: string; full_name?: string;
email?: string; email?: string;
professional_bio?: string; professional_bio?: string;
profile_picture?: File | null; profile_picture?: File | string | null;
} }