refactor: improve UI consistency and enhance loading states across components

This commit is contained in:
Kemas M. Aryadary Rasyad
2026-07-29 16:06:04 +07:00
parent 41349e5303
commit 3c00a7c366
10 changed files with 57 additions and 47 deletions
@@ -45,7 +45,7 @@ export default function UpdateProfileDialog({
}} }}
> >
{/* Header */} {/* Header */}
<div className="flex items-start justify-between gap-4 p-5 border-b border-box-border"> <div className="flex items-start justify-between gap-4 p-3 border-b border-box-border">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div> <div>
<h2 className="text-base font-bold text-foreground">Simpan Perubahan</h2> <h2 className="text-base font-bold text-foreground">Simpan Perubahan</h2>
@@ -101,7 +101,7 @@ export default function UpdateProfileDialog({
disabled={loading} 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" 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"} {loading ? "Menyimpan..." : "Simpan"}
</button> </button>
</div> </div>
</dialog> </dialog>
@@ -79,7 +79,7 @@ export default function SertificateList() {
{new Date(item.start_date).toLocaleDateString("id-ID", { {new Date(item.start_date).toLocaleDateString("id-ID", {
year: "numeric", year: "numeric",
month: "long", month: "long",
})} until {new Date(item.end_date).toLocaleDateString("id-ID", { })} - {new Date(item.end_date).toLocaleDateString("id-ID", {
year: "numeric", year: "numeric",
month: "long", month: "long",
})} })}
@@ -88,7 +88,7 @@ export default function SertificateList() {
{item.project_name} {item.project_name}
</p> </p>
<p className="text-xs text-font mt-1"> <p className="text-xs text-font mt-1">
Status Projek: <span className="font-bold text-red-600">{item.status}</span> Status: <span className="font-bold text-red-600">{item.status}</span>
</p> </p>
</div> </div>
</div> </div>
@@ -104,7 +104,7 @@ export default function CertificateAvailable({ project, certificate, templateUrl
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Certificate Card */} {/* Certificate Card */}
<div className="flex rounded-xl p-5 mb-1 border bg-box-primary border-box-border shadow-md items-center justify-center"> <div className="flex rounded-xl p-2 mb-1 border bg-box-primary border-box-border shadow-md items-center justify-center">
{loadingPreview ? ( {loadingPreview ? (
<div className="w-[550px] h-[350px] flex items-center justify-center text-gray-400 text-sm"> <div className="w-[550px] h-[350px] flex items-center justify-center text-gray-400 text-sm">
Memuat sertifikat... Memuat sertifikat...
@@ -113,7 +113,7 @@ export default function CertificateAvailable({ project, certificate, templateUrl
<img <img
src={previewUrl} src={previewUrl}
alt="Certificate" alt="Certificate"
className="rounded-xl object-contain border-2" className="object-contain border shadow-md h-[400px]"
/> />
) : ( ) : (
<div className="w-[550px] h-[350px] flex items-center justify-center text-gray-400 text-sm"> <div className="w-[550px] h-[350px] flex items-center justify-center text-gray-400 text-sm">
@@ -29,11 +29,11 @@ export default function ProjectTimeline({ tasks }: { tasks: InternTaskItem[] })
active ? 'bg-red' : 'bg-gray-400' active ? 'bg-red' : 'bg-gray-400'
} ${isToday ? 'shadow-md' : ''}`} } ${isToday ? 'shadow-md' : ''}`}
/> />
<div className={`text-[11px] font-semibold ${isToday ? 'text-red' : 'text-font'}`}> <div className={`text-[14px] font-semibold ${isToday ? 'text-red' : 'text-font'}`}>
{isToday ? 'Hari ini' : formatDate(task.deadline_at)} {task.title}
</div> </div>
<div className="text-xs text-font mt-px leading-[1.3]"> <div className="text-xs text-font mt-px leading-[1.3]">
{task.title} {isToday ? 'Hari ini' : formatDate(task.deadline_at)}
</div> </div>
</li> </li>
); );
@@ -34,18 +34,14 @@ export default function TaskFormLink({
loading: isEditing ? "Memperbarui Link..." : "Mengirim Link...", loading: isEditing ? "Memperbarui Link..." : "Mengirim Link...",
success: (res) => ({ success: (res) => ({
title: isEditing ? "Link diperbaharui" : "Link berhasil dikirim", title: isEditing ? "Link diperbaharui" : "Link berhasil dikirim",
description: description: res?.message
res?.message ?? // ?? (isEditing ? "Perubahan submission Anda telah disimpan." : "Link berhasil dikirim."),
(isEditing
? "Perubahan submission Anda telah disimpan."
: "Link berhasil dikirim."),
}), }),
error: (err) => ({ error: (err) => ({
title: isEditing ? "Gagal memperbarui link!" : "Gagal mengirim link!", title: isEditing ? "Gagal memperbarui link!" : "Gagal mengirim link!",
description: description:
err?.response?.data?.message || err?.response?.data?.message || err?.message
err?.message || // || "Link yang anda submit gagal dikirim",
"Link yang anda submit gagal dikirim",
}), }),
} }
); );
@@ -19,8 +19,6 @@ export default function AddInternsModal({ isOpen, onClose, userId }: AddInternsM
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;
@@ -33,6 +33,7 @@ export default function CreateProjectModal({ isOpen, onClose, onSuccess }: Creat
icon: PROJECT_ICON_MAP[id], icon: PROJECT_ICON_MAP[id],
})); }));
//open/close doalog komponen
useEffect(() => { useEffect(() => {
const dialog = dialogRef.current; const dialog = dialogRef.current;
if (!dialog) return; if (!dialog) return;
@@ -54,8 +55,22 @@ export default function CreateProjectModal({ isOpen, onClose, onSuccess }: Creat
}; };
}, [isOpen, onClose]); }, [isOpen, onClose]);
const handleAddEmail = (emailToAdd?: string) => { // tutup dropdown email di luar kotak
const email = (emailToAdd ?? emailInput).trim(); useEffect(() => {
if (!showDropdown) return;
const handleClickOutside = (e: MouseEvent) => {
if (emailSectionRef.current && !emailSectionRef.current.contains(e.target as Node)) {
setShowDropdown(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showDropdown]);
const handleAddEmail = (email: string) => {
if (email && !invitedEmails.includes(email)) { if (email && !invitedEmails.includes(email)) {
setInvitedEmails([...invitedEmails, email]); setInvitedEmails([...invitedEmails, email]);
setEmailInput(""); setEmailInput("");
@@ -182,34 +197,23 @@ export default function CreateProjectModal({ isOpen, onClose, onSuccess }: Creat
<label className="text-xs font-bold text-gray-700 tracking-wide"> <label className="text-xs font-bold text-gray-700 tracking-wide">
Invite via Gmail Invite via Gmail
</label> </label>
{/* Input email + dropdown sugesti intern */} {/* filterisasi email */}
<div ref={emailSectionRef} className="relative"> <div ref={emailSectionRef} className="relative">
<div className="relative flex items-center"> <div className="relative flex items-center">
<span className="absolute left-3.5 text-gray-400"> <span className="absolute left-3.5 text-gray-400">
<LuMail className="w-4 h-4 stroke-[2.5]" /> <LuMail className="w-4 h-4 stroke-[2.5]" />
</span> </span>
<input <input
type="email" type="text"
placeholder="intern.name@gmail.com" placeholder="cari intern berdasarkan nama/email"
value={emailInput} value={emailInput}
onChange={(e) => { setEmailInput(e.target.value); setShowDropdown(true); }} onChange={(e) => { setEmailInput(e.target.value); setShowDropdown(true); }}
onFocus={() => setShowDropdown(true)} onFocus={() => setShowDropdown(true)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Escape') setShowDropdown(false)
e.preventDefault();
handleAddEmail();
}
if (e.key === 'Escape') setShowDropdown(false);
}} }}
className="w-full pl-10 pr-20 py-2.5 bg-white border border-card-outline rounded-lg text-sm placeholder-gray-400 text-gray-800 focus:outline-none focus:border-red-600 focus:ring-1 focus:ring-red-600 font-medium" className="w-full pl-10 pr-20 py-2.5 bg-white border border-card-outline rounded-lg text-sm placeholder-gray-400 text-gray-800 focus:outline-none focus:border-red-600 focus:ring-1 focus:ring-red-600 font-medium"
/> />
<button
type="button"
onClick={() => handleAddEmail()}
className="absolute right-2 px-3 py-1 bg-card hover:bg-card-hover border border-card-outline text-font-shade text-[11px] font-bold rounded-md transition-colors tracking-wider uppercase"
>
ADD
</button>
</div> </div>
{/* Dropdown daftar intern */} {/* Dropdown daftar intern */}
@@ -231,12 +235,16 @@ export default function CreateProjectModal({ isOpen, onClose, onSuccess }: Creat
<button <button
key={intern.id} key={intern.id}
type="button" type="button"
onMouseDown={(e) => { e.preventDefault(); handleAddEmail(intern.email); }} onMouseDown={(e) => {
className="w-full flex items-center gap-3 px-6 py-2.5 hover:bg-gray-50 transition-colors text-left" e.preventDefault();
handleAddEmail(intern.email);
}}
className="w-full flex items-center gap-3 px-6 py-1.5 hover:bg-gray-50 transition-colors text-left"
> >
<div className="flex flex-col min-w-0"> <div className="flex flex-col min-w-0">
<span className="text-[13px] font-semibold text-gray-800 truncate">{intern.full_name}</span> <span className="text-[10px] font-semibold text-gray-800 truncate">{intern.full_name}</span>
<span className="text-[11px] text-gray-400 truncate">{intern.email}</span> <span className="text-[8px] text-gray-400 truncate">{intern.email}</span>
</div> </div>
</button> </button>
)) ))
@@ -80,7 +80,7 @@ export default function InternsTable({
className="w-10 h-10 rounded-full object-cover border border-gray-100 shadow-sm hidden md:block" className="w-10 h-10 rounded-full object-cover border border-gray-100 shadow-sm hidden md:block"
/> />
) : ( ) : (
<div className=" w-10 h-10 rounded-full bg-gradient-to-br from-teal-400 to-emerald-500 items-center justify-center border border-gray-100 shadow-sm text-white text-xs font-bold hidden md:flex"> <div className=" w-8 h-8 rounded-full bg-gray-300 items-center justify-center shadow-sm text-font-shade text-xs font-bold hidden md:flex">
{intern.name.charAt(0)} {intern.name.charAt(0)}
</div> </div>
)} )}
@@ -15,6 +15,11 @@ export default function MentorTaskCard({ task }: {task: InternTaskItem}) {
navigate(`/mentor/projects/${slug}/${task.slug}?type=${task.submission_type}`) navigate(`/mentor/projects/${slug}/${task.slug}?type=${task.submission_type}`)
}; };
function truncateText(text: string) {
if (text.length <= 230) return text;
return text.slice(0, 230) + "...";
}
return ( return (
<div <div
className="border border-card-outline rounded-[10px] px-5 py-4 mb-3 bg-card shadow-lg flex items-start justify-between gap-3 cursor-pointer hover:border-card-outline/50 transition-colors" className="border border-card-outline rounded-[10px] px-5 py-4 mb-3 bg-card shadow-lg flex items-start justify-between gap-3 cursor-pointer hover:border-card-outline/50 transition-colors"
@@ -28,7 +33,7 @@ export default function MentorTaskCard({ task }: {task: InternTaskItem}) {
{task.title} {task.title}
</div> </div>
<div className="text-[13px] text-[#666]"> <div className="text-[13px] text-[#666]">
{task.description} {truncateText(task.description)}
</div> </div>
</div> </div>
</div> </div>
+7 -4
View File
@@ -3,6 +3,7 @@ import { FiX } from 'react-icons/fi';
import { FaCheck } from "react-icons/fa"; import { FaCheck } from "react-icons/fa";
import { IoAlertCircleOutline } from "react-icons/io5"; import { IoAlertCircleOutline } from "react-icons/io5";
// Komponen Toast Card dengan Tailwind untuk pesan sukses dan pesan error
function ToastCard({ t, type, title, description }: { function ToastCard({ t, type, title, description }: {
t: Toast; t: Toast;
type: 'success' | 'error'; type: 'success' | 'error';
@@ -27,13 +28,15 @@ function ToastCard({ t, type, title, description }: {
</p> </p>
{description && <p className="text-[13px] text-gray-700 mt-0.5">{description}</p>} {description && <p className="text-[13px] text-gray-700 mt-0.5">{description}</p>}
</div> </div>
<button onClick={() => toast.dismiss(t.id)} className="text-gray-500 hover:text-gray-800 mt-0.5 shrink-0"> <button onClick={() => toast.remove(t.id)} className="text-gray-500 hover:text-gray-800 mt-0.5 shrink-0">
<FiX size={16} /> <FiX size={16} />
</button> </button>
</div> </div>
); );
} }
// pemanggilan function ToastCard dengan params
export const customToast = { export const customToast = {
success: (title: string, description?: string) => success: (title: string, description?: string) =>
toast.custom((t) => <ToastCard t={t} type="success" title={title} description={description} />), toast.custom((t) => <ToastCard t={t} type="success" title={title} description={description} />),
@@ -55,19 +58,19 @@ export const customToast = {
const isHookFailure = typeof data === 'object' && data !== null && 'success' in data && (data as { success?: boolean }).success === false; const isHookFailure = typeof data === 'object' && data !== null && 'success' in data && (data as { success?: boolean }).success === false;
if (isHookFailure) { if (isHookFailure) {
toast.dismiss(loadingId); toast.remove(loadingId);
const result = data as { success: boolean; message?: string }; const result = data as { success: boolean; message?: string };
const { title, description } = messages.error(result); const { title, description } = messages.error(result);
customToast.error(title, description ?? result.message); customToast.error(title, description ?? result.message);
throw new Error(result.message ?? 'Operasi gagal.'); throw new Error(result.message ?? 'Operasi gagal.');
} }
toast.dismiss(loadingId); toast.remove(loadingId);
const { title, description } = messages.success(data); const { title, description } = messages.success(data);
customToast.success(title, description); customToast.success(title, description);
return data; return data;
} catch (err) { } catch (err) {
toast.dismiss(loadingId); toast.remove(loadingId);
const { title, description } = messages.error(err); const { title, description } = messages.error(err);
customToast.error(title, description ?? (err as Error)?.message); customToast.error(title, description ?? (err as Error)?.message);
throw err; throw err;