refactor: improve UI consistency and enhance loading states across components
This commit is contained in:
@@ -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"
|
||||
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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user