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 */}
<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>
<h2 className="text-base font-bold text-foreground">Simpan Perubahan</h2>
@@ -101,7 +101,7 @@ export default function UpdateProfileDialog({
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"}
{loading ? "Menyimpan..." : "Simpan"}
</button>
</div>
</dialog>
@@ -79,7 +79,7 @@ export default function SertificateList() {
{new Date(item.start_date).toLocaleDateString("id-ID", {
year: "numeric",
month: "long",
})} until {new Date(item.end_date).toLocaleDateString("id-ID", {
})} - {new Date(item.end_date).toLocaleDateString("id-ID", {
year: "numeric",
month: "long",
})}
@@ -88,7 +88,7 @@ export default function SertificateList() {
{item.project_name}
</p>
<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>
</div>
</div>
@@ -104,7 +104,7 @@ export default function CertificateAvailable({ project, certificate, templateUrl
return (
<div className="flex flex-col gap-4">
{/* 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 ? (
<div className="w-[550px] h-[350px] flex items-center justify-center text-gray-400 text-sm">
Memuat sertifikat...
@@ -113,7 +113,7 @@ export default function CertificateAvailable({ project, certificate, templateUrl
<img
src={previewUrl}
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">
@@ -29,11 +29,11 @@ export default function ProjectTimeline({ tasks }: { tasks: InternTaskItem[] })
active ? 'bg-red' : 'bg-gray-400'
} ${isToday ? 'shadow-md' : ''}`}
/>
<div className={`text-[11px] font-semibold ${isToday ? 'text-red' : 'text-font'}`}>
{isToday ? 'Hari ini' : formatDate(task.deadline_at)}
<div className={`text-[14px] font-semibold ${isToday ? 'text-red' : 'text-font'}`}>
{task.title}
</div>
<div className="text-xs text-font mt-px leading-[1.3]">
{task.title}
{isToday ? 'Hari ini' : formatDate(task.deadline_at)}
</div>
</li>
);
@@ -34,18 +34,14 @@ export default function TaskFormLink({
loading: isEditing ? "Memperbarui Link..." : "Mengirim Link...",
success: (res) => ({
title: isEditing ? "Link diperbaharui" : "Link berhasil dikirim",
description:
res?.message ??
(isEditing
? "Perubahan submission Anda telah disimpan."
: "Link berhasil dikirim."),
description: res?.message
// ?? (isEditing ? "Perubahan submission Anda telah disimpan." : "Link berhasil dikirim."),
}),
error: (err) => ({
title: isEditing ? "Gagal memperbarui link!" : "Gagal mengirim link!",
description:
err?.response?.data?.message ||
err?.message ||
"Link yang anda submit gagal dikirim",
err?.response?.data?.message || err?.message
// || "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 { interns, loading: loadingInterns } = useAssignableInterns();
console.log(projects)
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
@@ -33,6 +33,7 @@ export default function CreateProjectModal({ isOpen, onClose, onSuccess }: Creat
icon: PROJECT_ICON_MAP[id],
}));
//open/close doalog komponen
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
@@ -54,8 +55,22 @@ export default function CreateProjectModal({ isOpen, onClose, onSuccess }: Creat
};
}, [isOpen, onClose]);
const handleAddEmail = (emailToAdd?: string) => {
const email = (emailToAdd ?? emailInput).trim();
// tutup dropdown email di luar kotak
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)) {
setInvitedEmails([...invitedEmails, email]);
setEmailInput("");
@@ -182,34 +197,23 @@ export default function CreateProjectModal({ isOpen, onClose, onSuccess }: Creat
<label className="text-xs font-bold text-gray-700 tracking-wide">
Invite via Gmail
</label>
{/* Input email + dropdown sugesti intern */}
{/* filterisasi email */}
<div ref={emailSectionRef} className="relative">
<div className="relative flex items-center">
<span className="absolute left-3.5 text-gray-400">
<LuMail className="w-4 h-4 stroke-[2.5]" />
</span>
<input
type="email"
placeholder="intern.name@gmail.com"
type="text"
placeholder="cari intern berdasarkan nama/email"
value={emailInput}
onChange={(e) => { setEmailInput(e.target.value); setShowDropdown(true); }}
onFocus={() => setShowDropdown(true)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddEmail();
}
if (e.key === 'Escape') setShowDropdown(false);
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"
/>
<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>
{/* Dropdown daftar intern */}
@@ -231,12 +235,16 @@ export default function CreateProjectModal({ isOpen, onClose, onSuccess }: Creat
<button
key={intern.id}
type="button"
onMouseDown={(e) => { e.preventDefault(); handleAddEmail(intern.email); }}
className="w-full flex items-center gap-3 px-6 py-2.5 hover:bg-gray-50 transition-colors text-left"
onMouseDown={(e) => {
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">
<span className="text-[13px] font-semibold text-gray-800 truncate">{intern.full_name}</span>
<span className="text-[11px] text-gray-400 truncate">{intern.email}</span>
<span className="text-[10px] font-semibold text-gray-800 truncate">{intern.full_name}</span>
<span className="text-[8px] text-gray-400 truncate">{intern.email}</span>
</div>
</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"
/>
) : (
<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)}
</div>
)}
@@ -15,6 +15,11 @@ export default function MentorTaskCard({ task }: {task: InternTaskItem}) {
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 (
<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"
@@ -28,7 +33,7 @@ export default function MentorTaskCard({ task }: {task: InternTaskItem}) {
{task.title}
</div>
<div className="text-[13px] text-[#666]">
{task.description}
{truncateText(task.description)}
</div>
</div>
</div>
+7 -4
View File
@@ -3,6 +3,7 @@ import { FiX } from 'react-icons/fi';
import { FaCheck } from "react-icons/fa";
import { IoAlertCircleOutline } from "react-icons/io5";
// Komponen Toast Card dengan Tailwind untuk pesan sukses dan pesan error
function ToastCard({ t, type, title, description }: {
t: Toast;
type: 'success' | 'error';
@@ -27,13 +28,15 @@ function ToastCard({ t, type, title, description }: {
</p>
{description && <p className="text-[13px] text-gray-700 mt-0.5">{description}</p>}
</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} />
</button>
</div>
);
}
// pemanggilan function ToastCard dengan params
export const customToast = {
success: (title: string, description?: string) =>
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;
if (isHookFailure) {
toast.dismiss(loadingId);
toast.remove(loadingId);
const result = data as { success: boolean; message?: string };
const { title, description } = messages.error(result);
customToast.error(title, description ?? result.message);
throw new Error(result.message ?? 'Operasi gagal.');
}
toast.dismiss(loadingId);
toast.remove(loadingId);
const { title, description } = messages.success(data);
customToast.success(title, description);
return data;
} catch (err) {
toast.dismiss(loadingId);
toast.remove(loadingId);
const { title, description } = messages.error(err);
customToast.error(title, description ?? (err as Error)?.message);
throw err;