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
+79 -60
View File
@@ -1,60 +1,79 @@
import api from "../../lib/api";
import { useEffect, useState } from "react";
interface FAQ {
id: number;
pertanyaan: string;
jawaban: string;
created_at: string;
}
export default function FAQPage() {
const [faq, setFaq] = useState<FAQ[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
api.get("/faq-api/get")
.then((res) => {
setFaq(res.data.data);
})
.catch((err) => {
console.error("Gagal fetch FAQ:", err);
})
.finally(() => {
setLoading(false);
});
}, []);
return (
<div>
<div className="mb-10">
<h1 className="page-title">FAQ</h1>
<p className="page-title-desc">
Quick answers to common questions about the Internify LMS platform.
</p>
</div>
{/* FAQ Lists */}
{loading ? (
<p className="text-gray-500">Memuat FAQ...</p>
) : (
<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>
);
}
import api from "../../lib/api";
import { useEffect, useState } from "react";
interface FAQ {
id: number;
pertanyaan: string;
jawaban: string;
created_at: string;
}
// Skeleton item that mirrors the real FAQ card shape
function FAQSkeleton() {
return (
<div className="relative shadow-md py-5 px-10 rounded-md bg-white border-l-6 border-gray-200 animate-pulse">
{/* Question bar */}
<div className="h-5 bg-gray-200 rounded w-2/3 mb-3" />
{/* Answer bars */}
<div className="pl-8 space-y-2">
<div className="h-3 bg-gray-200 rounded w-full" />
<div className="h-3 bg-gray-200 rounded w-4/5" />
</div>
</div>
);
}
export default function FAQPage() {
const [faq, setFaq] = useState<FAQ[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
api.get("/faq-api/get")
.then((res) => {
setFaq(res.data.data);
})
.catch((err) => {
console.error("Gagal fetch FAQ:", err);
})
.finally(() => {
setLoading(false);
});
}, []);
return (
<div>
<div className="mb-10">
<h1 className="page-title">FAQ</h1>
<p className="page-title-desc">
Quick answers to common questions about the Internify LMS platform.
</p>
</div>
{/* FAQ Lists */}
{loading ? (
<div className="space-y-8 max-w-auto">
{[...Array(5)].map((_, i) => (
<FAQSkeleton key={i} />
))}
</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 { useNavigate } from "react-router-dom";
import { Mail, Lock, Eye, EyeOff } from 'lucide-react';
import { useLogin } from "../../hooks/useUser";
import { customToast } from "../utils/showToast";
import { CircleQuestionMark } from 'lucide-react';
const decodeJWT = (token: string) => {
try {
const base64 = token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
return JSON.parse(atob(padded));
} catch {
return null;
}
try {
const base64 = token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
return JSON.parse(atob(padded));
} catch {
return null;
}
};
export default function InternifyLogin() {
@@ -22,6 +24,12 @@ export default function InternifyLogin() {
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) => {
e.preventDefault();
setError(null);
@@ -77,7 +85,7 @@ export default function InternifyLogin() {
placeholder="Email Account"
value={email}
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>
@@ -93,7 +101,7 @@ export default function InternifyLogin() {
placeholder="Account Password"
value={password}
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
type="button"
@@ -108,22 +116,14 @@ export default function InternifyLogin() {
</button>
</div>
</div>
{error && (
<p className="text-xs text-red-600 bg-red-50 p-2 rounded-md font-medium">
{error}
</p>
)}
<button
type="submit"
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"}
</button>
</form>
{/* Forgot Password */}
<div className="text-center mt-6">
<a href="#" className="text-xs font-semibold text-red-600 hover:underline">
@@ -134,17 +134,15 @@ export default function InternifyLogin() {
{/* Divider & Support */}
<div className="mt-1">
<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">
SUPPORT
</span>
<div className="flex-grow border-t border-gray-100"></div>
<div className="flex-grow border-t border-card-outline"></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">
<svg className="w-3.5 h-3.5 text-font-shade" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<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>
<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">
<CircleQuestionMark className="w-5 h-5"/>
Frequently Asked Questions
</button>
</div>
+7 -3
View File
@@ -5,6 +5,7 @@ import { LuFileSpreadsheet } from "react-icons/lu";
import { io } from "socket.io-client";
import * as NotificationService from "../../services/NotificationService";
import type { UINotification, BackendNotification } from "../../types/notification.types";
import mailRafiki from '../../assets/Mail-rafiki.svg'
const getCookieToken = () => {
return document.cookie
@@ -95,7 +96,10 @@ export default function NotificationList() {
</div>
{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">
@@ -165,9 +169,9 @@ export default function NotificationList() {
{/* Header Section YESTERDAY */}
<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
</h2>
</h2> */}
{notifications.filter(n => n.group === "YESTERDAY").map((item) => (
<div
+64 -62
View File
@@ -1,11 +1,13 @@
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 { Pen } from "lucide-react";
import UpdateProfileDialog from "./UpdateProfileDialogue";
export default function SettingsPage() {
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 [formData, setFormData] = useState({
@@ -16,24 +18,22 @@ export default function SettingsPage() {
const [photoFile, setPhotoFile] = useState<File | null>(null);
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
const [removePhoto, setRemovePhoto] = useState(false);
const [imageError, setImageError] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (user) {
setFormData({
fullName:
user.full_name || "",
email: user.email || "",
bio: user.professional_bio || "",
});
if (user.profile_picture) {
setPhotoPreview(user.profile_picture);
} else {
setPhotoPreview(null);
}
setImageError(false);
}
if (!user) return;
setFormData({
fullName: user.full_name || `${user.nama_depan} ${user.nama_belakang}`.trim(),
email: user.email || "",
bio: user.professional_bio || "",
});
setPhotoPreview(resolveFileUrl(user.profile_picture) ?? DEFAULT_AVATAR);
setRemovePhoto(false);
setImageError(false);
}, [user]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
@@ -43,46 +43,50 @@ export default function SettingsPage() {
const handlePhotoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setPhotoFile(file);
const reader = new FileReader();
reader.onloadend = () => setPhotoPreview(reader.result as string);
reader.readAsDataURL(file);
}
if (!file) return;
setPhotoFile(file);
setRemovePhoto(false);
setImageError(false);
const reader = new FileReader();
reader.onloadend = () => setPhotoPreview(reader.result as string);
reader.readAsDataURL(file);
};
const handleRemovePhoto = () => {
setPhotoFile(null);
setPhotoPreview(null);
setRemovePhoto(true);
setPhotoPreview(DEFAULT_AVATAR);
setImageError(false);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleSaveChanges = async (e: React.FormEvent) => {
e.preventDefault();
await customToast.promise(
save({
full_name: formData.fullName,
email: formData.email,
professional_bio: formData.bio,
}),
{
loading: "Menyimpan perubahan...",
success: () => ({
title: "Perubahan berhasil disimpan!",
description: successMsg || "",
const handleSaveChanges = async () => {
setConfirmOpen(false);
try {
await customToast.promise(
save({
full_name: formData.fullName,
email: formData.email,
professional_bio: formData.bio,
profile_picture: photoFile ?? (removePhoto ? DEFAULT_AVATAR : undefined),
}),
error: () => ({
title: "Gagal menyimpan perubahan!",
description: errorMsg || "",
}),
}
);
{
loading: "Menyimpan perubahan...",
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) {
return (
<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 (
<div>
{/* Header Title */}
@@ -113,22 +122,20 @@ export default function SettingsPage() {
</div>
{/* Card Body & Form */}
<form onSubmit={handleSaveChanges} className="p-6 space-y-6">
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* Profile Photo Row */}
<div className="flex items-center gap-5 py-2">
{/* Avatar dengan badge edit */}
<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">
{photoPreview && !imageError ? (
<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 && (
<img
src={photoPreview}
alt="Profile"
className="w-full h-full object-cover"
onError={() => setImageError(true)}
/>
) : (
<span className="text-3xl font-black text-gray-900">{initialLetter}</span>
)}
</div>
{/* Badge edit icon */}
@@ -237,18 +244,6 @@ export default function SettingsPage() {
</p>
</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 */}
<div className="flex justify-end pt-2">
<button
@@ -262,6 +257,13 @@ export default function SettingsPage() {
</form>
</div>
<UpdateProfileDialog
isOpen={confirmOpen}
isIntern={isIntern}
loading={saving}
onConfirm={handleSaveChanges}
onClose={() => setConfirmOpen(false)}
/>
</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 { useProjectMyTasks } from '../../../hooks/useProjects';
import type { Project } from '../../../types/project.types';
import { FileText } from 'lucide-react';
export default function ForumTab({ project }: { project: Project }) {
const { tasks, loading, error } = useProjectMyTasks(project.id);
@@ -11,29 +12,26 @@ export default function ForumTab({ project }: { project: Project }) {
<div>
<HeroProject title={project.project_name} description={project.description} />
{/* Loading skeleton */}
{loading && (
<div className="flex flex-col gap-3 mt-5">
{[...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>
)}
{/* Error */}
{error && <p className="text-red-500 text-sm mt-5">{error}</p>}
{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 && (
<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">
<ProjectTimeline tasks={tasks} />
</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">
{tasks.map((task) => (
<TaskCard key={task.id} task={task} />
@@ -5,7 +5,6 @@ import CertificateAvailable from "./components/CertificateAvailabe";
import CertificateNotAvailable from "./components/CertificateNotAvailable";
import SertificateHistory from "./components/CertificateHistory";
// import { useClaimCertificate } from "../../../hooks/useCertificates";
import { useProjectDetail } from "../../../hooks/useProjects";
import { useMyCertificates } from "../../../hooks/useCertificates";
import type { Project } from "../../../types/project.types";
@@ -17,7 +16,7 @@ export default function InternCertificateDetail () {
const { project: projectDetail, loading: projectLoading } = useProjectDetail(slug ?? "");
const { certificates, loading: certLoading, refetch: refetchCertificates } = useMyCertificates();
// const { claim, loading: claiming, error: claimError } = useClaimCertificate();
const myCertificateForProject = useMemo(() => {
if (!projectDetail) return null;
@@ -42,14 +41,6 @@ export default function InternCertificateDetail () {
const remainingTasks = project ? project.total_tasks - project.task_done : 0;
const isLoading = projectLoading || certLoading;
// const handleClaim = async () => {
// if (!project?.id) return;
// const result = await claim(project.id);
// if (result) {
// refetchCertificates();
// }
// };
if (isLoading) {
return (
<div className="flex items-center justify-center py-20 text-gray-400 text-sm">
@@ -77,9 +68,6 @@ export default function InternCertificateDetail () {
progress={progress}
remainingTasks={remainingTasks}
allTasksDone={allTasksDone}
// onClaim={handleClaim}
// claiming={claiming}
// claimError={claimError}
/>
)}
</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 type { Project } from "../../../types/project.types";
import { useNavigate } from "react-router-dom";
import * as LucideIcons 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() {
const { projects, loading, error } = useMyProjects();
@@ -23,11 +43,12 @@ export default function SertificateList() {
<p className="page-title-desc">All your earned internship certificates</p>
</div>
{/* Loading */}
{/* Loading Skeleton */}
{loading && (
<div className="flex items-center justify-center py-20 text-gray-400 gap-2">
<Loader2 className="w-6 h-6 animate-spin" />
<span className="text-sm">Loading certificates...</span>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
{[...Array(6)].map((_, i) => (
<CertCardSkeleton key={i} />
))}
</div>
)}
@@ -41,8 +62,9 @@ export default function SertificateList() {
{/* Empty */}
{!loading && !error && projects.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
<p className="text-sm">No ongoing projects yet.</p>
<div className="flex flex-col items-center justify-center py-20 gap-5 text-font-shade">
<img src={emptyimage} alt="empty image" className="w-64 h-64" />
<p className="text-[18px]">Tidak ada projek yang berlangsung.</p>
</div>
)}
+29 -23
View File
@@ -1,3 +1,4 @@
import { Users } from 'lucide-react';
import type { ProjectDetail, ProjectMember } from '../../../types/project.types';
export default function ParticipantsTab({ project }: { project: ProjectDetail }) {
@@ -25,30 +26,35 @@ export default function ParticipantsTab({ project }: { project: ProjectDetail })
{/* Members */}
<div>
<h3 className="font-bold text-font-shade mb-3.5">Members</h3>
{project.members.map((member: ProjectMember, idx) => (
<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>
{project.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>
))}
{project.members.length === 0 && (
<p className="text-sm text-gray-400">Belum ada members.</p>
) : (
<>
{project.members.map((member: ProjectMember, idx) => (
<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>
+14 -6
View File
@@ -1,5 +1,6 @@
import ProjectCard from './components/ProjectsCard'
import { useMyProjects } from '../../../hooks/useProjects';
import TeamPana from '../../../assets/Team-pana.svg'
export default function ProjectsPage() {
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>
</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>
{projects.length === 0 ? (
<div className="flex flex-col items-center justify-center py-2 gap-3 mt-19">
<img className='w-[440px]' src={TeamPana} alt="" />
<p className="text-[18px] text-font-shade">Kamu belum tergabung ke projek apapun</p>
</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>
)
}
@@ -5,18 +5,12 @@ interface CertificateNotAvailableProps {
progress: number;
remainingTasks: number;
allTasksDone?: boolean;
// onClaim: () => void;
// claiming: boolean;
// claimError: string | null;
}
export default function CertificateNotAvailable({
progress,
remainingTasks,
allTasksDone = false,
// onClaim,
// claiming,
// claimError,
}: CertificateNotAvailableProps) {
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">
@@ -54,14 +48,6 @@ export default function CertificateNotAvailable({
<div className="h-full bg-green-500 rounded-full transition-all duration-700 w-full" />
</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 { useProjects } from '../../../../hooks/useProjects';
import { getProjectIcon } from '../../../../lib/ProjectIcons';
import { RiFileList2Fill } from "react-icons/ri";
const toSlug = (name: string) => name.toLowerCase().replace(/\s+/g, '-');
const HomeProjectList = () => {
const navigate = useNavigate();
const { projects, loading, error } = useProjects();
if (loading) return (
<div className="flex flex-col gap-3">
{[...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>
);
@@ -19,27 +21,38 @@ const HomeProjectList = () => {
return (
<div>
<h2 className="text-lg font-semibold text-font-shade mb-8">Projects</h2>
<div className="flex flex-col gap-3 overflow-y-auto max-h-[190px]">
{projects.map((project) => (
<div
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" />;
})()}
<h2 className="text-lg font-semibold text-font-shade mb-5">Projects</h2>
{projects.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg py-2 gap-3">
<RiFileList2Fill className="w-12 h-12 text-gray-400" />
<p className="text-sm text-gray-400">Kamu belum tergabung ke projek apapun</p>
</div>
) : (
<div className="flex flex-col gap-3 overflow-y-auto max-h-[190px]">
{projects.map((project) => (
<div
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>
<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>
)}
</div>
);
};
@@ -2,6 +2,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
import { Award, Settings, HelpCircle, X } from 'lucide-react';
import { GrHomeRounded } from "react-icons/gr";
import { MdOutlineAssignment, MdLogout } from "react-icons/md";
import { HiUser } from "react-icons/hi2";
const navItems = [
{ label: "Home", icon: GrHomeRounded, path: "/intern" },
@@ -46,12 +47,14 @@ export default function SidebarIntern({ isOpen, closeSidebar }: SidebarProps) {
`}
>
{/* 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="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>
<p className="text-sm font-bold text-gray-900 leading-tight">Internify</p>
<p className="text-[11px] text-gray-400">LMS Portal</p>
<p className="text-[19px] font-bold text-font-shade leading-tight">Internify</p>
<p className="text-[14px] text-font">LMS Portal</p>
</div>
</div>
<button
@@ -83,10 +86,10 @@ export default function SidebarIntern({ isOpen, closeSidebar }: SidebarProps) {
})}
</nav>
<div className="h-px bg-gray-200 my-1" />
<div className="h-px bg-gray-300 my-1" />
{/* 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
</p>
<nav className="flex flex-col gap-0.5">
@@ -1,5 +1,6 @@
import { useMyTasks } from "../../../../hooks/useProjects";
import type { InternTaskItem } from "../../../../types/project.types";
import { RiFileList2Fill } from "react-icons/ri";
const TaskTimeline = () => {
const { tasks, loading, error } = useMyTasks();
@@ -20,43 +21,50 @@ const TaskTimeline = () => {
return (
<div>
<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 (
<li key={item.id} className="relative flex gap-4 pl-0">
{/* Vertical line ke bawah, kecuali item terakhir */}
{!isLast && (
<div className="absolute left-[3px] top-3 w-px h-full bg-[#ddd]" />
)}
{tasks.length === 0 ? (
<div className="flex flex-col items-center justify-center py-2 gap-3">
<RiFileList2Fill className="w-12 h-12 text-gray-400" />
<p className="text-sm text-gray-400">Tidak ada tugas saat ini</p>
</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">
<span
className={`block w-3 h-3 rounded-full z-10 relative border-2 right-0.5 border-white ${overdue ? "bg-primary" : "bg-gray-500"
}`}
/>
</div>
return (
<li key={item.id} className="relative flex gap-4 pl-0">
{!isLast && (
<div className="absolute left-[3px] top-3 w-px h-full bg-[#ddd]" />
)}
<div className="flex flex-col gap-0.5 min-w-0">
<p className="text-[10px] font-semibold text-font-shade uppercase tracking-widest">
{deadlineDate.toLocaleDateString("id-ID")} AT{" "}
{deadlineDate.toLocaleTimeString("id-ID", {
hour: "2-digit",
minute: "2-digit",
})}
</p>
<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 className="relative flex-shrink-0 mt-1">
<span
className={`block w-3 h-3 rounded-full z-10 relative border-2 right-0.5 border-white ${overdue ? "bg-primary" : "bg-gray-500"
}`}
/>
</div>
<div className="flex flex-col gap-0.5 min-w-0">
<p className="text-[10px] font-semibold text-font-shade uppercase tracking-widest">
{deadlineDate.toLocaleDateString("id-ID")} AT{" "}
{deadlineDate.toLocaleTimeString("id-ID", {
hour: "2-digit",
minute: "2-digit",
})}
</p>
<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>
);
};
+3 -3
View File
@@ -36,13 +36,13 @@ export const MentorLayout = () => {
const toggleSidebar = () => setIsSidebarOpen(!isSidebarOpen);
const closeSidebar = () => setIsSidebarOpen(false);
return (
<div className="min-h-screenflex flex-col">
<div className="min-h-screen flex flex-col relative">
<Header toggleSidebar={toggleSidebar}/>
<div className="flex flex-1">
<div className="flex flex-1 relative">
<SidebarMentor isOpen={isSidebarOpen} closeSidebar={closeSidebar} />
{isSidebarOpen && (
<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}
/>
)}
@@ -3,10 +3,11 @@ import { Plus } from 'lucide-react';
import { useState } from 'react';
import CreateProjectModal from './components/CreateProjectDialog';
import { useProjectsByRole } from '../../../hooks/useListProjects';
import TeamPana from '../../../assets/Team-pana.svg';
export default function MentorProjectsPage() {
const [isModalOpen, setIsModalOpen] = useState(false);
const { projects, loading, error, refetch } = useProjectsByRole("active");
const { projects, loading, error, refetch } = useProjectsByRole();
const openDialog = () => {
setIsModalOpen(true);
@@ -48,6 +49,14 @@ export default function MentorProjectsPage() {
</div>
<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 => (
<MentorProjectCard
key={project.id}
@@ -1,9 +1,29 @@
// src/pages/mentor/CertificateListPage.tsx
import { useNavigate } from "react-router-dom";
import { AlertCircle } from "lucide-react";
import CertificateProjectCard from "./components/CertificateProjectCard";
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() {
const navigate = useNavigate();
const { projects, loading, error } = useProjects();
@@ -12,13 +32,9 @@ export default function CertificateList() {
navigate(`/mentor/certificates/${slug}`);
};
if (loading){
<p>on loading</p>
}
const isEmpty = !loading && !error && projects.length === 0;
const hasProjects = !loading && !error && projects.length > 0;
if (error){
<p>error: {error}</p>
}
return (
<div>
{/* Page Header */}
@@ -29,20 +45,50 @@ export default function CertificateList() {
</p>
</div>
{/* Section: List Projects */}
<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)}
/>
))}
{/* Loading Skeleton — struktur sama persis dengan body */}
{loading && (
<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">
{[...Array(6)].map((_, i) => (
<CertCardSkeleton key={i} />
))}
</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>
);
}
@@ -3,6 +3,7 @@ import HeroProject from '../intern/components/Hero-project';
import MentorTaskCard from './components/MentorTaskCard';
import { useProjectTasks } from '../../../hooks/useTasks';
import type { Project } from '../../../types/project.types';
import { FileText } from 'lucide-react';
export default function MentorForumTab({ project }: { project: Project }) {
const { tasks, loading, error } = useProjectTasks(project.id);
@@ -15,13 +16,16 @@ export default function MentorForumTab({ project }: { project: Project }) {
{loading && (
<div className="flex flex-col gap-3 mt-5">
{[...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>
)}
{!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 && (
<div className="flex flex-col gap-7 md:flex-row md:items-start mt-5">
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Plus } from 'lucide-react';
import { Plus, Users } from 'lucide-react';
import ManageInternsModal from './components/AddInternsDialog';
import type { ProjectDetail, ProjectMember } from '../../../types/project.types';
import { useRemoveMember } from '../../../hooks/useProjects';
@@ -68,7 +68,13 @@ export default function InternsTab({ project }: { project: ProjectDetail }) {
</span>
</button>
</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
key={idx}
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>
</div>
</div>
))}
)))}
</div>
{isModalOpen && <ManageInternsModal
isOpen={isModalOpen}
+3 -2
View File
@@ -85,8 +85,9 @@ export default function TaskTab({ project }: { project: ProjectDetail }) {
</div>
))
) : (
<div className="text-center py-10 text-gray-400 text-sm">
Belum ada tugas yang dibuat untuk proyek ini.
<div className='flex flex-col items-center justify-center py-16'>
<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>
@@ -13,26 +13,24 @@ interface AddInternsModalProps {
export default function AddInternsModal({ isOpen, onClose, userId }: AddInternsModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
// Form States
const [selectedUserId, setSelectedUserId] = useState<string>("");
const [selectedProjectId, setSelectedProjectId] = useState<string>("");
// Hooks
const { assign, loading: assigning, error: assignError } = useAssignMember();
const { projects, loading: loadingProjects } = useProjects("active");
const { interns, loading: loadingInterns } = useAssignableInterns();
console.log(projects)
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (isOpen) {
dialog.showModal();
document.body.style.overflow = "hidden";
// document.body.style.overflow = "hidden";
} else {
dialog.close();
document.body.style.overflow = "unset";
// document.body.style.overflow = "unset";
}
const handleCancel = (e: Event) => {
@@ -24,10 +24,10 @@ export default function CompleteProjectDialog({
if (isOpen) {
dialog.showModal();
document.body.style.overflow = "hidden";
// document.body.style.overflow = "hidden";
} else {
dialog.close();
document.body.style.overflow = "unset";
// document.body.style.overflow = "unset";
}
const handleCancel = (e: Event) => {
@@ -38,10 +38,8 @@ export default function CreateProjectModal({ isOpen, onClose, onSuccess }: Creat
if (isOpen) {
dialog.showModal();
document.body.style.overflow = "hidden";
} else {
dialog.close();
document.body.style.overflow = "unset";
}
const handleCancel = (e: Event) => {
@@ -171,7 +171,7 @@ export default function EligibleInternTable({
).then(() => true).catch(() => false);
setGenerating(false);
if (ok) navigate(`/mentor/certificates/${project.id}/result`);
if (ok) navigate(`/mentor/certificates/${project.slug}/result`);
};
if (loading) {
@@ -4,6 +4,19 @@ import { Plus } from 'lucide-react';
import CreateProjectModal from './CreateProjectDialog';
import { useProjects } from '../../../../hooks/useProjects';
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 navigate = useNavigate();
@@ -30,8 +43,23 @@ const HomeMentorProject = () => {
</button>
</div>
<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>}
{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) => (
<div
key={project.id}
@@ -35,10 +35,6 @@ export default function InternsTable({
const handlePrev = () => setCurrentPage((p) => Math.max(1, 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 (
<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 */}
<div className="divide-y divide-gray-100">
{paginatedInterns.length > 0 ? (
{paginatedInterns.length > 0 ? (
paginatedInterns.map((intern, index) => {
const isUnassigned = !intern.projectName || intern.projectName === "Unassigned";
@@ -128,9 +124,13 @@ export default function InternsTable({
</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">
No interns found matching the filter criteria.
<div className="text-center py-12 text-md text-font font-medium">
Tidak ada intern yang terdaftar
</div>
)}
</div>
@@ -1,5 +1,6 @@
import { useNavigate, useLocation } from 'react-router-dom';
import { Home, FolderKanban, UsersRound, Medal, Settings, HelpCircle, LogOut, X } from 'lucide-react';
import { FaUserTie } from "react-icons/fa";
const navItems = [
// belum kutambahin
@@ -39,19 +40,21 @@ export default function SidebarMentor({ isOpen, closeSidebar }: SidebarProps) {
return (
<aside
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
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"}
`}
>
{/* 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="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>
<p className="text-sm font-bold text-gray-900 leading-tight">Internify</p>
<p className="text-[11px] text-gray-400">LMS Portal</p>
<p className="text-[19px] font-bold text-font-shade leading-tight">Internify</p>
<p className="text-[14px] text-font">LMS Portal</p>
</div>
</div>
@@ -71,9 +74,9 @@ export default function SidebarMentor({ isOpen, closeSidebar }: SidebarProps) {
<button
key={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
? "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"
}`}
>
@@ -84,14 +87,14 @@ export default function SidebarMentor({ isOpen, closeSidebar }: SidebarProps) {
})}
</nav>
<div className="h-px bg-gray-200 my-1" />
<div className="h-px bg-gray-300 my-1" />
{/* 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
</p>
<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;
return (
<button