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