feat: enhance project and mentor management features

- Updated MentorProjectCard to provide more detailed success/error messages upon archiving and completing projects.
- Adjusted layout for certificate generation to improve visual alignment.
- Introduced resolveErrorMessage utility to standardize error message handling across the application.
- Enhanced customToast functionality to handle hook failures more gracefully.
- Refactored useCertificates hook to return structured responses for certificate claims and generation.
- Updated useMentors hook to improve error handling and response structure for mentor operations.
- Improved useNotification hook to return structured responses for marking notifications as read.
- Refactored useProjects hook to provide structured responses for project creation, updates, and member assignments.
- Enhanced useTasks hook to return structured responses for task creation, updates, and deletions.
- Updated services to return structured responses with messages for various API calls related to certificates, mentors, notifications, projects, tasks, and user management.
- Moved mentor-related types to a dedicated user.types file for better organization.
This commit is contained in:
Kemas M. Aryadary Rasyad
2026-07-16 22:50:56 +07:00
parent 99082efbdb
commit 62aaf2bcba
34 changed files with 408 additions and 425 deletions
+1
View File
@@ -46,6 +46,7 @@ export default function InternifyLogin() {
document.cookie = `token=${token}; path=/; SameSite=Strict`;
const decoded = decodeJWT(token);
console.log(decoded)
const role = decoded?.role;
if (role === "intern") {
navigate("/intern");
+25 -39
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Check, Download, Info } from "lucide-react";
import { Check, Download } from "lucide-react";
import { LuFileSpreadsheet } from "react-icons/lu";
import { io } from "socket.io-client";
import * as NotificationService from "../../services/NotificationService";
@@ -66,14 +66,25 @@ export default function NotificationList() {
};
}, []);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const handleMarkAllRead = async () => {
try {
await NotificationService.markAllAsRead();
const res = await NotificationService.markAllAsRead();
setNotifications([]);
customToast.success("All notifications marked as read!");
} catch (err:any) {
customToast.error("Failed to mark all as read", err?.message);
customToast.success("Berhasil", res.message);
} catch (err: any) {
const msg = err?.response?.data?.message ?? "Gagal menandai semua notifikasi.";
customToast.error("Gagal", msg);
}
};
const handleReadMessage = async (id: number) => {
try {
const res = await NotificationService.markAsRead(id);
setNotifications(prev => prev.filter(n => n.id !== id));
customToast.success("Ditandai dibaca", res.message);
} catch (err: any) {
const msg = err?.response?.data?.message ?? "Gagal menandai notifikasi.";
customToast.error("Gagal", msg);
}
};
@@ -98,7 +109,7 @@ export default function NotificationList() {
{notifications.length === 0 && (
<div className="flex flex-col items-center justify-center pt-14 gap-3">
<img src={mailRafiki} alt="empty notification" className="w-[280px]"/>
<img src={mailRafiki} alt="empty notification" className="w-[280px]" />
<p className="text-lg text-font">Belum ada pesan notifikasi yang diterima</p>
</div>
)}
@@ -110,7 +121,7 @@ export default function NotificationList() {
{notifications.filter(n => n.group === "TODAY").map((item) => (
<div
key={item.id}
className={`relative flex items-start gap-4 p-5 bg-white border border-gray-100 rounded-xl shadow-[0_2px_10px_rgba(0,0,0,0.01)] ${item.type === 'new-task' ? 'border-l-4 border-l-[#B30000]' : ''
className={`relative flex items-start gap-4 p-5 bg-box border border-card-outline/40 rounded-xl shadow-[0_2px_10px_rgba(0,0,0,0.01)] ${item.type === 'new-task' ? 'border-l-4 border-l-[#B30000]' : ''
}`}
>
{/* Icon Container */}
@@ -137,16 +148,19 @@ export default function NotificationList() {
</p>
{/* Conditional Actions */}
{item.type === 'new-task' && item.link &&(
{item.type === 'new-task' && item.link && (
<div className="flex items-center gap-2 pt-3">
<button
className="px-4 py-1.5 bg-[#B30000] hover:bg-[#990000] text-white text-xs font-bold rounded-lg shadow-sm transition-colors"
onClick={() => navigate(`${item.link}`)}>
View Task
</button>
{/* <button className="px-4 py-1.5 border border-gray-200 text-gray-500 hover:text-gray-700 hover:bg-gray-50 text-xs font-semibold rounded-lg transition-colors">
<button
className="px-4 py-1.5 border border-card-outline text-font hover:text-font-shade hover:bg-gray-50 text-xs font-semibold rounded-lg transition-colors"
onClick={() => handleReadMessage(item.id)}
>
Later
</button> */}
</button>
</div>
)}
@@ -167,34 +181,6 @@ export default function NotificationList() {
</div>
))}
</div>
{/* Header Section YESTERDAY */}
<div className="space-y-3 pt-2">
{/* <h2 className="text-[10px] font-bold tracking-widest text-gray-400 uppercase">
YESTERDAY
</h2> */}
{notifications.filter(n => n.group === "YESTERDAY").map((item) => (
<div
key={item.id}
className="relative flex items-start gap-4 p-5 bg-gray-50 border border-gray-100 rounded-xl"
>
<div className="p-2.5 bg-gray-200 text-gray-500 rounded-xl">
<Info className="w-5 h-5" />
</div>
<div className="flex-grow space-y-1">
<h3 className="text-sm font-bold text-gray-700">{item.title}</h3>
<p className="text-xs text-gray-500 font-medium leading-relaxed max-w-2xl">
{item.description}
</p>
</div>
<span className="text-[11px] text-gray-400 font-medium whitespace-nowrap md:absolute md:top-5 md:right-5">
{item.time}
</span>
</div>
))}
</div>
</div>
</div>
);
+5 -5
View File
@@ -6,7 +6,7 @@ import UpdateProfileDialog from "./UpdateProfileDialogue";
export default function SettingsPage() {
const { user, loading: userLoading } = useCurrentUser();
const { save, loading: saving, msgRef } = useUpdateProfile();
const { save, loading: saving } = useUpdateProfile();
const [confirmOpen, setConfirmOpen] = useState(false);
const isIntern = user?.role === "intern";
@@ -93,15 +93,15 @@ export default function SettingsPage() {
}),
{
loading: "Menyimpan perubahan...",
success: () => ({
success: (updated) => ({
title: "Perubahan berhasil disimpan!",
description: emailChanged
? "Email berubah, silakan login ulang..."
: msgRef.current.success ?? "",
: updated.message
}),
error: () => ({
error: (err) => ({
title: "Gagal menyimpan perubahan!",
description: msgRef.current.error ?? "",
description: err.message ?? "",
}),
}
);
@@ -62,9 +62,8 @@ export default function CertificateAvailable({ project, certificate, templateUrl
setDownloadingImg(true);
try {
await downloadCertificateImage(certificate, templateUrl, "png");
customToast.success("Sertifikat berhasil diunduh!");
} catch {
customToast.error("Gagal mengunduh sertifikat!");
customToast.error("Gagal mengunduh sertifikat!", "Terjadi kendala saat mengunduh sertifikat!");
} finally {
setDownloadingImg(false);
}
@@ -74,9 +73,8 @@ export default function CertificateAvailable({ project, certificate, templateUrl
setDownloadingPdf(true);
try {
await downloadCertificatePdf(certificate, templateUrl);
customToast.success("Sertifikat berhasil diunduh!");
} catch {
customToast.error("Gagal mengunduh sertifikat!");
customToast.error("Gagal mengunduh sertifikat!", "Terjadi kendala saat mengunduh sertifikat!");
} finally {
setDownloadingPdf(false);
}
@@ -54,7 +54,7 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
if (files.length === 0) {
customToast.error(
"File belum dipilih!",
"Silakan pilih file terlebih dahulu sebelum mengirim."
"Choose your file before submit it!"
);
return;
}
@@ -63,11 +63,12 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
isEditing ? updateFile(submission!.id, files) : submitFile(files),
{
loading: isEditing ? "Memperbarui file..." : "Mengirim file...",
success: () => ({
success: (res) => ({
title: isEditing ? "File berhasil diperbarui!" : "File berhasil dikirim!",
description: isEditing
? "Perubahan submission Anda telah disimpan."
: "Submission Anda telah berhasil dikirim.",
description: res?.message ??
(isEditing
? "Perubahan submission Anda telah disimpan."
: "Link berhasil dikirim."),
}),
error: (err) => ({
title: isEditing ? "Gagal memperbarui file!" : "Gagal mengirim file!",
@@ -86,8 +87,6 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
};
const handleDelete = async () => {
if (!confirm("Yakin ingin menghapus submission ini?")) return;
try {
const ok = await remove(submission!.id);
if (ok) {
@@ -95,7 +94,7 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
setIsEditing(false);
customToast.success(
"Submission berhasil dihapus!",
"Anda dapat mengirim submission baru kapan saja."
ok.message
);
}
} catch (err: any) {
@@ -27,24 +27,18 @@ export default function TaskFormLink({
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!inputLink.trim()) {
customToast.error(
"Link belum dibuat",
"Masukkan link untuk mengirim submission"
);
return;
}
try {
await customToast.promise(
isEditing ? updateLink(submission!.id, inputLink) : submitLink(inputLink),
{
loading: isEditing ? "Memperbarui Link..." : "Mengirim Link...",
success: () => ({
success: (res) => ({
title: isEditing ? "Link diperbaharui" : "Link berhasil dikirim",
description: isEditing
? "Perubahan submission Anda telah disimpan."
: "Submission Anda telah berhasil dikirim.",
description:
res?.message ??
(isEditing
? "Perubahan submission Anda telah disimpan."
: "Link berhasil dikirim."),
}),
error: (err) => ({
title: isEditing ? "Gagal memperbarui link!" : "Gagal mengirim link!",
@@ -56,13 +50,10 @@ export default function TaskFormLink({
}
);
setIsEditing(false);
} catch {
// error sudah ditangani toast, tetap di form
}
} catch { }
};
const handleDelete = async () => {
if (!confirm("Yakin ingin menghapus submission ini?")) return;
try {
const ok = await remove(submission!.id);
if (ok) {
@@ -70,7 +61,7 @@ export default function TaskFormLink({
setIsEditing(false);
customToast.success(
"Submission berhasil dihapus!",
"Anda dapat mengirim submission baru kapan saja."
ok.message
);
}
} catch (err: any) {
@@ -90,7 +81,7 @@ export default function TaskFormLink({
deadline={deadline}
onEdit={() => setIsEditing(true)}
onDelete={handleDelete}
disabled={!isProjectActive}
disabled={!isProjectActive}
/>
);
}
@@ -36,13 +36,15 @@ export default function CertificateDetail() {
upload(project.id, selectedFile),
{
loading: "Mengupload template...",
success: () => ({
title: "Upload berhasil",
description: "Template sertifikat berhasil disimpan.",
success: (result) => ({
title: result?.success === false ? "Upload gagal" : "Upload berhasil",
description: result?.message ?? (result?.success === false
? "Terjadi kesalahan saat upload."
: "Template sertifikat berhasil disimpan."),
}),
error: (err) => ({
title: "Upload gagal",
description: err instanceof Error ? err.message : "Terjadi kesalahan saat upload.",
description: err?.message ?? "Terjadi kesalahan saat upload.",
}),
}
).then(async (result) => {
+3 -3
View File
@@ -58,11 +58,11 @@ export default function InternsManagement() {
return;
}
const res = await remove({ id_project: matchedProject.id, id_user: intern.id });
if (res !== null) {
customToast.success('Intern removed', `${intern.name} has been successfully removed from the project.`);
if (res.success) {
customToast.success('Intern removed', res.message);
refetch();
} else {
customToast.error('Failed to remove', 'An error occurred while removing the intern. Please try again.');
customToast.error('Failed to remove', res.message);
}
};
+7 -13
View File
@@ -14,19 +14,13 @@ export default function TaskTab({ project }: { project: ProjectDetail }) {
const [selectedTask, setSelectedTask] = useState<InternTaskItem | null>(null);
const isProjectActive = project?.status === "active"
const handleDeleteTask = async (taskId: number, taskTitle: string) => {
const confirmed = window.confirm(`Yakin ingin menghapus task “${taskTitle}”?`);
if (!confirmed) return;
const handleDeleteTask = async (taskId: number) => {
const del = await remove(taskId, String(project.id));
if (del) {
try {
customToast.success("Berhasil", `Task ${taskTitle} berhasil dihapus`)
} catch (error) {
customToast.error("Gagal", `Task ${taskTitle} gagal dihapus`)
} finally {
refetch();
}
if (!del.failed) {
customToast.success("Berhasil Menghapus Task", del.message);
refetch();
} else {
customToast.error("Gagal Menghapus Task", del.message);
}
};
@@ -83,7 +77,7 @@ export default function TaskTab({ project }: { project: ProjectDetail }) {
<Pencil size={20} />
</button>
<button
onClick={() => handleDeleteTask(task.id, task.title)}
onClick={() => handleDeleteTask(task.id)}
disabled={!isProjectActive}
className="border border-gray-500 text-font-shade rounded-lg px-2 py-1 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
@@ -22,11 +22,11 @@ export default function InternsTab({ project }: { project: ProjectDetail }) {
const handleRemove = async (id_user: number) => {
const res = await remove({ id_project: project.id, id_user });
if (res !== null) {
if (res.success) {
setMembers(prev => prev.filter(m => m.id !== id_user));
customToast.success('Member removed', 'The intern has been successfully removed from this project.');
customToast.success('Member removed', res.message);
} else {
customToast.error('Failed to remove', 'An error occurred while removing the member. Please try again.');
customToast.error('Failed to remove', res.message);
}
};
@@ -74,16 +74,15 @@ export default function ManageInternsModal({ isOpen, onClose, projectId, initial
const handleAssign = async (intern: AssignableIntern) => {
const result = await assign({ id_project: projectId, id_user: intern.id });
if (result) {
// Tandai sebagai sudah ditambah — intern hilang dari dialog (tidak muncul lagi)
if (result.success) {
setAddedIds(prev => new Set([...prev, intern.id]));
customToast.success('Intern added', `${intern.full_name} has been successfully added to the project.`);
customToast.success('Intern added', result.message);
} else {
customToast.error('Failed to add', 'An error occurred while adding the intern. Please try again.');
customToast.error('Failed to add', result.message);
}
};
const getInitials = (m: AssignableIntern) =>
const getInitials = (m: AssignableIntern) =>
m.full_name.split(' ').slice(0, 2).map(n => n[0]).join('').toUpperCase();
return (
@@ -59,11 +59,11 @@ export default function AddInternsModal({ isOpen, onClose, userId }: AddInternsM
id_project: Number(selectedProjectId),
id_user: Number(selectedUserId),
});
if (res !== null) {
customToast.success('Intern assigned', 'The intern has been successfully assigned to the project.');
if (res.success) {
customToast.success('Intern assigned', res.message);
onClose();
} else {
customToast.error('Failed to assign', 'An error occurred while assigning the intern. Please try again.');
customToast.error('Failed to assign', res.message);
}
};
@@ -1,6 +1,6 @@
import { useState } from "react";
import {
Search, Eye, Download, Share2,
Search, Eye, Download,
ChevronLeft, ChevronRight, Loader2,
} from "lucide-react";
import type { Certificate } from "../../../../types/certificate.types";
@@ -132,13 +132,13 @@ export default function CertResultTable({ certificates, templateUrl }: Certifica
: <Download className="w-4 h-4" />
}
</button>
{/*
<button
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-md transition-colors"
title="Share"
>
<Share2 className="w-4 h-4" />
</button>
</button> */}
</div>
</div>
))
@@ -22,6 +22,7 @@ export default function CreateProjectModal({ isOpen, onClose, onSuccess }: Creat
const [invitedEmails, setInvitedEmails] = useState<string[]>([]);
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [dateError, setDateError] = useState("");
const { create, loading, error } = useCreateProject();
const { interns: assignableInterns, loading: loadingInterns } = useAssignableInterns();
const [showDropdown, setShowDropdown] = useState(false);
@@ -68,6 +69,14 @@ export default function CreateProjectModal({ isOpen, onClose, onSuccess }: Creat
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setDateError("");
if (startDate && endDate && new Date(endDate) < new Date(startDate)) {
setDateError("Tanggal selesai tidak boleh lebih awal dari tanggal mulai.");
return;
}
const payload: CreateProjectPayload = {
project_icon: selectedIcon,
project_name: projectName,
@@ -81,13 +90,12 @@ export default function CreateProjectModal({ isOpen, onClose, onSuccess }: Creat
}
const result = await create(payload);
if (result) {
if (result.success) {
onSuccess?.();
onClose();
customToast.success(
"Project berhasil dibuat!",
`${projectName} sudah aktif dan siap dikelola.`
);
customToast.success("Project berhasil dibuat!", (result.message));
} else {
customToast.error("Gagal membuat project", (result.message));
}
};
@@ -263,14 +271,14 @@ export default function CreateProjectModal({ isOpen, onClose, onSuccess }: Creat
{/* Start Date */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-700 tracking-wide">
Start Date
Tanggal Mulai
</label>
<div className="relative">
<input
type="date"
required
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
onChange={(e) => { setStartDate(e.target.value); setDateError(""); }}
className="w-full px-3 py-2.5 bg-white border border-card-outline rounded-lg text-sm text-gray-800 focus:outline-none focus:border-red-600 focus:ring-1 focus:ring-red-600 font-medium appearance-none"
/>
<span className="absolute right-3.5 top-3 text-gray-400 pointer-events-none">
@@ -281,14 +289,14 @@ export default function CreateProjectModal({ isOpen, onClose, onSuccess }: Creat
{/* End Date */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-700 tracking-wide">
End Date
Tanggal Berakhir
</label>
<div className="relative">
<input
type="date"
required
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
onChange={(e) => { setEndDate(e.target.value); setDateError(""); }}
className="w-full px-3 py-2.5 bg-white border border-card-outline rounded-lg text-sm text-gray-800 focus:outline-none focus:border-red-600 focus:ring-1 focus:ring-red-600 font-medium appearance-none"
/>
<span className="absolute right-3.5 top-3 text-gray-400 pointer-events-none">
@@ -296,9 +304,12 @@ export default function CreateProjectModal({ isOpen, onClose, onSuccess }: Creat
</div>
</div>
</div>
{dateError && (
<p className="text-xs text-red font-medium">{dateError}</p>
)}
{error && (
<p className="text-xs text-red-600 text-center font-medium">{error}</p>
<p className="text-xs text-red text-center font-medium">{error}</p>
)}
{/* Action Row Buttons */}
@@ -58,18 +58,14 @@ export default function CreateTaskModal({ isOpen, onClose, projectId, onSuccess
submission_type: submissionType,
});
if (result) {
customToast.success(
'Task created',
`"${taskTitle}" has been successfully added to the project.`);
if (result.data) {
customToast.success('Task created', result.message);
onSuccess();
} else {
customToast.error(
'Failed to create task',
'An error occurred while saving the task. Please try again.');
customToast.error('Failed to create task', result.message);
}
};
return (
<dialog ref={dialogRef} className="custom-dialog">
<div className="dialog-header">
@@ -3,6 +3,7 @@ import { X } from "lucide-react";
import { LuSave } from "react-icons/lu";
import { useUpdateTask } from "../../../../hooks/useTasks";
import type { ProjectTask, SubmissionType, UpdateTaskPayload } from "../../../../types/task.types";
import { customToast } from "../../../utils/showToast";
interface EditTaskModalProps {
isOpen: boolean;
@@ -79,9 +80,12 @@ export default function EditSubmissionModal({
return;
}
const result = await update(task.id, payload, String(task.id_project));
if (result) {
onSuccess(result);
if (result.data) {
customToast.success("Task diperbarui", result.message);
onSuccess(result.data);
onClose();
} else {
customToast.error("Gagal", result.message);
}
};
@@ -124,7 +124,7 @@ export default function EligibleInternTable({
return;
}
if (selected.size === 0) {
customToast.error("Tidak ada intern dipilih", "Pilih minimal satu intern untuk generate sertifikat.");
customToast.error("Tidak ada intern dipilih", "Checklist intern yang sudah eligible untuk membuat sertifikat.");
return;
}
@@ -134,15 +134,14 @@ export default function EligibleInternTable({
const ok = await customToast.promise(
(async () => {
const result = await SubmitCertificates(project.id, Array.from(selected));
if (!result) throw new Error("Tidak ada data sertifikat yang dikembalikan.");
if (!result.data) throw new Error("Tidak ada data sertifikat yang dikembalikan.");
const zip = new JSZip();
const templateUrl = resolveImageUrl(project.certificate_template!);
const duration = formatDateRange(project.start_date, project.end_date);
await Promise.all(
result.map(async (cert) => {
result.data.map(async (cert) => {
const blob = await generateCertificate(
templateUrl,
cert.user.full_name,
@@ -37,15 +37,15 @@ export default function MentorProjectCard({ onArchived, onCompleted, ...project
const result = await archive(project.id);
setShowArchiveDialog(false);
if (result) {
customToast.success("Berhasil!", "Projek berubah status menjadi Archived (Diarsipkan");
if (result.success) {
customToast.success("Berhasil!", result.message);
onArchived?.();
} else {
customToast.error("Gagal!", "Projek gagal diarsipkan (archived)");
customToast.error("Gagal!", result.message);
}
};
/* ── Complete ────────────────────────────────────────────── */
/* Complete */
const handleCompleteButtonClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
setShowCompleteDialog(true);
@@ -55,11 +55,11 @@ export default function MentorProjectCard({ onArchived, onCompleted, ...project
const result = await complete(project.id);
setShowCompleteDialog(false);
if (result) {
customToast.success("Berhasil!", "Projek berubah status menjadi completed (selesai)");
if (result?.success) {
customToast.success("Berhasil!", result.message);
onCompleted?.();
} else {
customToast.error("Gagal!", "Projek gagal diselesaikan (completed)");
customToast.error("Gagal!", result?.message);
}
};