feat: enhance task creation and editing with success/error toasts

- Added success and error toast notifications for task creation in CreateTaskDialog.
- Implemented success and error toast notifications for task updates in EditTaskDialog.
- Integrated custom toast notifications in MentorProjectCard for project archiving.
- Removed unused mock data files related to projects, interns, deadlines, and certificates.
- Updated useProjectTasks hook to accept projectId as a number instead of a string.
- Improved error handling in useSubmission hook methods to throw errors instead of returning null.
This commit is contained in:
Kemas M. Aryadary Rasyad
2026-07-06 20:38:30 +07:00
parent 8f5fb82f59
commit 7a331b8e7d
27 changed files with 500 additions and 913 deletions
+27 -17
View File
@@ -1,31 +1,41 @@
import { Link, useLocation } from "react-router-dom";
import useBreadcrumbs from "use-react-router-breadcrumbs";
import { ChevronRight } from "lucide-react";
import { mockProjects } from "../../lib/mockData";
import { useMyProjects, useProjects } from "../../hooks/useProjects";
import { useProjectTasks } from "../../hooks/useTasks";
const toSlug = (name: string) => name.toLowerCase().replace(/\s+/g, '-');
// Dynamic breadcrumb: ubah slug jadi nama project asli
const ProjectNameBreadcrumb = ({ match }: { match: { params: { slug?: string } } }) => {
const slug = match.params.slug;
const project = mockProjects.find((p) => toSlug(p.name) === slug);
return <>{project ? project.name : slug}</>;
type BreadcrumbMatch = {
match: { params: { slug?: string; taskSlug?: string } };
};
const TaskNameBreadcrumb = ({ match }: { match: { params: { slug?: string; taskSlug?: string } } }) => {
const { slug, taskSlug } = match.params;
const project = mockProjects.find((p) => toSlug(p.name) === slug);
const task = project?.tasks.find((t) => toSlug(t.title) === taskSlug);
return <>{task ? task.title : taskSlug}</>;
const unslugify = (slug = '') =>
slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const ProjectBreadcrumb = ({ match }: BreadcrumbMatch) => {
const { projects } = useProjects();
const slug = match.params.slug;
const project = projects?.find((p) => p.slug === slug);
return <>{project?.project_name ?? unslugify(slug)}</>;
};
const TaskBreadcrumb = ({ match }: BreadcrumbMatch) => {
const {slug, taskSlug} = match.params;
const { projects } = useMyProjects();
const project = projects?.find((p) => p.slug === slug);
const { tasks } = useProjectTasks(project?.id ?? 0);
const task = tasks?.find((t) => t.slug === taskSlug);
return <>{task?.title ?? unslugify(taskSlug)}</>;
};
const breadcrumbRoutes = [
// --- AREA INTERN ---
{ path: '/intern', breadcrumb: 'Home' },
{ path: '/intern/projects', breadcrumb: 'Projects' },
{ path: '/intern/projects/:slug', breadcrumb: ProjectNameBreadcrumb },
{ path: '/intern/projects/:slug', breadcrumb: ProjectBreadcrumb },
{ path: '/intern/certificates', breadcrumb: 'Certificates' },
{ path: '/intern/projects/:slug/:taskSlug', breadcrumb: TaskNameBreadcrumb },
{ path: '/intern/projects/:slug/:taskSlug', breadcrumb: TaskBreadcrumb },
{ path: '/intern/faq', breadcrumb: 'FAQ' },
{ path: '/intern/notifications', breadcrumb: 'Notifications' },
{ path: '/intern/settings', breadcrumb: 'Settings' },
@@ -33,8 +43,8 @@ const breadcrumbRoutes = [
// --- AREA MENTOR / ADMIN ---
{ path: '/mentor', breadcrumb: 'Home' },
{ path: '/mentor/projects', breadcrumb: 'List Projects' },
{ path: '/mentor/projects/:slug', breadcrumb: ProjectNameBreadcrumb },
{ path: '/mentor/projects/:slug/:taskSlug', breadcrumb: TaskNameBreadcrumb },
{ path: '/mentor/projects/:slug', breadcrumb: ProjectBreadcrumb },
{ path: '/mentor/projects/:slug/:taskSlug', breadcrumb: TaskBreadcrumb },
{ path: '/mentor/certificates', breadcrumb: 'Certificates' },
{ path: '/mentor/intern', breadcrumb: 'List Intern' },
{ path: '/mentor/faq', breadcrumb: 'FAQ' },
+4 -4
View File
@@ -16,7 +16,7 @@ export default function FAQPage() {
setLoading(true);
api.get("/faq-api/get")
.then((res) => {
setFaq(res.data.data); // ambil array dari field "data"
setFaq(res.data.data);
})
.catch((err) => {
console.error("Gagal fetch FAQ:", err);
@@ -41,14 +41,14 @@ export default function FAQPage() {
) : (
<div className="space-y-8 max-w-auto">
{faq.map((item) => (
<div key={item.id} className="relative shadow-md group py-5 px-10 rounded-md bg-white">
<div key={item.id} className="relative shadow-md group py-5 px-10 rounded-md bg-white border-l-6 border-red hover:shadow-xl">
{/* Item Question */}
<h2 className="text-[17px] font-bold text-[#8A0000] tracking-tight leading-snug mb-2 border-l-4 border-[#8A0000] pl-3">
<h2 className="text-[17px] font-bold text-red tracking-tight leading-snug mb-2">
{item.pertanyaan}
</h2>
<p className="text-[14px] pl-8 text-[#8A0000] font-light leading-relaxed tracking-normal">
<p className="text-[14px] pl-8 text-redfont-light leading-relaxed tracking-normal">
{item.jawaban}
</p>
</div>
+135 -38
View File
@@ -1,10 +1,12 @@
import { useState, useEffect } from "react";
import { useState, useRef, useEffect } from "react";
import { useCurrentUser, useUpdateProfile } from "../../hooks/useUser";
import { customToast } from "../utils/showToast";
import { Pen } from "lucide-react";
export default function SettingsContent() {
const { user, loading } = useCurrentUser();
export default function MentorSettingsPage() {
const { user, loading: userLoading } = useCurrentUser();
const { save, loading: saving, error: errorMsg, successMsg } = useUpdateProfile();
const isIntern = user?.role === "intern";
const [formData, setFormData] = useState({
fullName: "",
@@ -12,13 +14,27 @@ export default function SettingsContent() {
bio: "",
});
const [photoFile, setPhotoFile] = useState<File | null>(null);
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
const [imageError, setImageError] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (user) {
setFormData({
fullName: user.full_name || "",
fullName:
user.full_name ||
`${user.nama_depan ?? ""} ${user.nama_belakang ?? ""}`.trim() ||
"",
email: user.email || "",
bio: user.professional_bio || "",
});
if (user.profile_picture) {
setPhotoPreview(user.profile_picture);
} else {
setPhotoPreview(null);
}
setImageError(false);
}
}, [user]);
@@ -27,10 +43,27 @@ export default function SettingsContent() {
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handlePhotoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setPhotoFile(file);
const reader = new FileReader();
reader.onloadend = () => setPhotoPreview(reader.result as string);
reader.readAsDataURL(file);
}
};
const handleRemovePhoto = () => {
setPhotoFile(null);
setPhotoPreview(null);
setImageError(false);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleSaveChanges = async (e: React.FormEvent) => {
e.preventDefault();
await customToast.promise(
await customToast.promise(
save({
full_name: formData.fullName,
email: formData.email,
@@ -40,11 +73,11 @@ export default function SettingsContent() {
loading: "Menyimpan perubahan...",
success: () => ({
title: "Perubahan berhasil disimpan!",
description: successMsg || "Profil Anda telah diperbarui.",
description: successMsg || "",
}),
error: () => ({
title: "Gagal menyimpan perubahan!",
description: errorMsg || "Terjadi kesalahan saat menyimpan perubahan.",
description: errorMsg || "",
}),
}
);
@@ -52,7 +85,7 @@ export default function SettingsContent() {
const initialLetter = formData.fullName ? formData.fullName.charAt(0).toUpperCase() : "U";
if (loading) {
if (userLoading) {
return (
<div className="flex items-center justify-center h-40 text-sm text-gray-400">
Loading profile...
@@ -61,8 +94,7 @@ export default function SettingsContent() {
}
return (
<div className="">
<div>
{/* Header Title */}
<div className="mb-6">
<h1 className="page-title">Settings</h1>
@@ -72,12 +104,12 @@ export default function SettingsContent() {
</div>
{/* White Card Container */}
<div className="w-full bg-white rounded-xl border border-gray-100 shadow-lg overflow-hidden">
<div className="w-full bg-white rounded-xl border border-box-border shadow-lg overflow-hidden">
{/* Card Header */}
<div className="p-6 border-b border-gray-100">
<h2 className="text-base font-bold text-gray-900">Profile Information</h2>
<p className="text-xs text-gray-400 mt-0.5">
<div className="p-6 border-b border-box-border">
<h2 className="text-[20px] font-bold text-foreground">Profile Information</h2>
<p className="text-xs text-foreground mt-0.5">
Update your photo and personal details.
</p>
</div>
@@ -85,25 +117,80 @@ export default function SettingsContent() {
{/* Card Body & Form */}
<form onSubmit={handleSaveChanges} className="p-6 space-y-6">
{/* Profile Photo Display Row */}
<div className="flex items-center gap-4 py-2">
<div className="w-16 h-16 bg-gray-100 border border-gray-200 rounded-xl flex items-center justify-center shadow-sm">
<span className="text-2xl font-black text-gray-900 tracking-tighter">
{initialLetter}
</span>
</div>
<div>
<h3 className="text-xs font-bold text-gray-900">Profile Photo</h3>
<p className="text-sm text-gray-600 mt-0.5">{formData.fullName}</p>
{/* Profile Photo Row */}
<div className="flex items-center gap-5 py-2">
{/* Avatar dengan badge edit */}
<div className="relative flex-shrink-0">
<div className="w-20 h-20 rounded-xl border-2 border-red-100 overflow-hidden bg-gray-100 flex items-center justify-center shadow-sm">
{photoPreview && !imageError ? (
<img
src={photoPreview}
alt="Profile"
className="w-full h-full object-cover"
onError={() => setImageError(true)}
/>
) : (
<span className="text-3xl font-black text-gray-900">{initialLetter}</span>
)}
</div>
{/* Badge edit icon */}
{!isIntern &&
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="absolute -bottom-1.5 -right-1.5 w-6 h-6 bg-[#B30000] rounded-full flex items-center justify-center shadow border-2 border-white hover:bg-[#990000] transition-colors"
title="Edit photo"
>
<Pen className="text-white w-2.5 h-2.5" />
</button>
}
</div>
{/* Info & Action Buttons */}
{!isIntern &&
<div>
<h3 className="text-sm font-bold text-gray-900 mb-0.5">Profile Photo</h3>
<p className="text-xs text-gray-400 mb-3">
JPG, GIF or PNG. Recommended size 800x800px. Max size of 800K.
</p>
{photoFile && (
<p className="text-xs text-blue-500 mb-2 font-medium">
📎 {photoFile.name}
</p>
)}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="px-4 py-1.5 bg-[#B30000] hover:bg-[#990000] text-white text-xs font-bold rounded-lg shadow-sm transition-colors"
>
Upload New
</button>
<button
type="button"
onClick={handleRemovePhoto}
className="px-4 py-1.5 bg-white border border-box-border text-gray-600 hover:bg-gray-50 text-xs font-bold rounded-lg transition-colors"
>
Remove
</button>
</div>
</div>
}
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/png, image/jpeg, image/gif"
className="hidden"
onChange={handlePhotoUpload}
/>
</div>
<hr className="border-t border-dashed border-gray-200" />
<hr className="border-t border-dashed border-box-border" />
{/* Grid Inputs (Full Name & Email Address) */}
{/* Grid Inputs */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Field: Full Name */}
<div className="space-y-1">
<label className="text-[10px] font-bold tracking-wider text-gray-700 uppercase block">
Full Name
@@ -113,11 +200,9 @@ export default function SettingsContent() {
name="fullName"
value={formData.fullName}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm text-gray-800 focus:outline-none focus:border-red-600 focus:ring-1 focus:ring-red-600 transition-colors bg-white font-medium"
className="w-full px-3 py-2 border border-box-border rounded-lg text-sm text-gray-800 focus:outline-none focus:border-red-600 focus:ring-1 focus:ring-red-600 transition-colors bg-white font-medium"
/>
</div>
{/* Field: Email Address */}
<div className="space-y-1">
<label className="text-[10px] font-bold tracking-wider text-gray-700 uppercase block">
Email Address
@@ -127,12 +212,12 @@ export default function SettingsContent() {
name="email"
value={formData.email}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm text-gray-800 focus:outline-none focus:border-red-600 focus:ring-1 focus:ring-red-600 transition-colors bg-white font-medium"
className="w-full px-3 py-2 border border-box-border rounded-lg text-sm text-gray-800 focus:outline-none focus:border-red-600 focus:ring-1 focus:ring-red-600 transition-colors bg-white font-medium"
/>
</div>
</div>
{/* Field: Professional Bio */}
{/* Professional Bio */}
<div className="space-y-1">
<label className="text-[10px] font-bold tracking-wider text-gray-700 uppercase block">
Professional Bio
@@ -142,19 +227,31 @@ export default function SettingsContent() {
rows={4}
value={formData.bio}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm text-gray-800 focus:outline-none focus:border-red-600 focus:ring-1 focus:ring-red-600 transition-colors bg-white font-medium resize-none"
className="w-full px-3 py-2 border border-box-border rounded-lg text-sm text-gray-800 focus:outline-none focus:border-red-600 focus:ring-1 focus:ring-red-600 transition-colors bg-white font-medium resize-none"
/>
<p className="text-[11px] text-gray-700 font-normal pt-0.5">
<p className="text-[11px] text-gray-500 font-normal pt-0.5">
Brief description for your profile. URLs and @mentions are allowed.
</p>
</div>
{/* Form Action Footer Row */}
{/* Feedback Messages
{successMsg && (
<p className="text-xs text-green-600 bg-green-50 p-2 rounded-md font-medium">
{successMsg}
</p>
)}
{errorMsg && (
<p className="text-xs text-red-600 bg-red-50 p-2 rounded-md font-medium">
{errorMsg}
</p>
)} */}
{/* Footer Action */}
<div className="flex justify-end pt-2">
<button
type="submit"
disabled={saving}
className="px-6 py-2 bg-[#B30000] hover:bg-[#990000] text-white font-bold text-xs rounded-lg shadow-sm transition-colors uppercase tracking-wider"
className="px-6 py-2 bg-[#B30000] hover:bg-[#990000] text-white font-bold text-xs rounded-lg shadow-sm transition-colors uppercase tracking-wider disabled:opacity-60"
>
{saving ? "Saving..." : "Save Changes"}
</button>
@@ -4,6 +4,7 @@ import { LuSendHorizontal } from "react-icons/lu";
import SubmitStatusTable from "./SubmissionStatusTable";
import { useSubmission } from "../../../../hooks/useTasks";
import type { TaskSubmissionData } from "../../../../types/task.types";
import { customToast } from "../../../utils/showToast";
interface TaskFormFileProps {
taskId: string;
@@ -25,10 +26,7 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
selectedFiles.forEach((file) => {
const ext = file.name.split(".").pop()?.toLowerCase();
if (ext && allowedExtensions.includes(ext)) {
if (file.size <= 10 * 1024 * 1024) validFiles.push(file);
else alert(`Ukuran file ${file.name} melebihi 10MB.`);
} else {
alert(`Format ${file.name} tidak didukung. Gunakan PDF.`);
if (file.size <= 5 * 1024 * 1024) validFiles.push(file);
}
});
if (validFiles.length > 0) setFiles((prev) => [...prev, ...validFiles]);
@@ -52,17 +50,59 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (files.length === 0) { alert("Pilih file terlebih dahulu."); return; }
const res = isEditing
? await updateFile(submission!.id, files)
: await submitFile(files);
if (res) { setIsEditing(false); setFiles([]); }
if (files.length === 0) {
customToast.error(
"File belum dipilih!",
"Silakan pilih file terlebih dahulu sebelum mengirim."
);
return;
}
const res = await customToast.promise(
isEditing ? updateFile(submission!.id, files) : submitFile(files),
{
loading: isEditing ? "Memperbarui file..." : "Mengirim file...",
success: () => ({
title: isEditing ? "File berhasil diperbarui!" : "File berhasil dikirim!",
description: isEditing
? "Perubahan submission Anda telah disimpan."
: "Submission Anda telah berhasil dikirim.",
}),
error: (err) => ({
title: isEditing ? "Gagal memperbarui file!" : "Gagal mengirim file!",
description:
err?.response?.data?.message ||
err?.message ||
"Terjadi kesalahan saat memproses file Anda.",
}),
}
);
if (res) {
setIsEditing(false);
setFiles([]);
}
};
const handleDelete = async () => {
if (!confirm("Yakin ingin menghapus submission ini?")) return;
const ok = await remove(submission!.id);
if (ok) { setFiles([]); setIsEditing(false); }
try {
const ok = await remove(submission!.id);
if (ok) {
setFiles([]);
setIsEditing(false);
customToast.success(
"Submission berhasil dihapus!",
"Anda dapat mengirim submission baru kapan saja."
);
}
} catch (err: any) {
customToast.error(
"Gagal menghapus submission!",
err?.response?.data?.message || "Terjadi kesalahan saat menghapus submission."
);
}
};
const handleCancelEdit = () => {
@@ -83,8 +123,6 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
);
}
// const currentFileName = submission?.file_path?.split("/").pop();
return (
<form onSubmit={handleSubmit} className="space-y-4">
<input
@@ -118,24 +156,23 @@ export default function TaskFormFile({ taskId, projectId, deadline, initialSubmi
</div>
) : isEditing && submission?.files && submission.files.length > 0 ? (
<div className="flex flex-col items-center justify-center h-full space-y-2 text-center py-6">
<FileText className="w-6 h-6 text-gray-400" />
<p className="text-sm font-semibold text-gray-700">File saat ini:</p>
<div className="space-y-0.5">
{submission.files.map((f) => (
<p key={f.id} className="text-sm font-medium text-red-700">
<div className="flex items-center gap-2 text-[11px]">
<FileText className="w-4 h-4 text-red-700 stroke-[2.5]" />
{f.original_name}
</p>
</div>
))}
</div>
<p className="text-[11px] text-gray-400 font-medium pointer-events-none">
Klik atau drag & drop untuk mengganti file
<p className="text-[14px] text-gray-400 font-medium pointer-events-none">
Upload ulang files yang akan diganti ke dalam submissions, atau klik batal
</p>
</div>
) : (
<div className="flex flex-col items-center justify-center h-full space-y-2 pointer-events-none text-center py-6">
<UploadCloud className="w-8 h-8 text-gray-400 mx-auto stroke-[1.5]" />
<p className="text-sm font-bold text-gray-700">Click to upload <span className="font-medium text-gray-500">or drag & drop</span></p>
<p className="text-[11px] text-gray-400 font-semibold">PDF, DOCX, or ZIP (Max. 10MB)</p>
<p className="text-[11px] text-gray-400 font-semibold">PDF, DOCX, or ZIP (Max.5MB)</p>
</div>
)}
</div>
@@ -4,6 +4,7 @@ import { LuSendHorizontal } from "react-icons/lu";
import SubmitStatusTable from "./SubmissionStatusTable";
import { useSubmission } from "../../../../hooks/useTasks";
import type { TaskSubmissionData } from "../../../../types/task.types";
import { customToast } from "../../../utils/showToast";
interface TaskFormLinkProps {
taskId: string;
@@ -19,18 +20,54 @@ export default function TaskFormLink({ taskId, projectId, deadline, initialSubmi
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!inputLink.trim()) return;
const res = isEditing
? await updateLink(submission!.id, inputLink)
: await submitLink(inputLink);
if (res) { setIsEditing(false); }
if (!inputLink.trim()) {
customToast.error(
"Link belum dibuat",
"Masukkan link untuk mengirim submission"
);
return;
}
await customToast.promise(
isEditing ? updateLink(submission!.id, inputLink) : submitLink(inputLink),
{
loading: isEditing ? "Memperbarui Link..." : "Mengirim Link...",
success: () => ({
title: isEditing ? "Link diperbaharui" : "Link berhasil dikirim",
description: isEditing
? "Perubahan submission Anda telah disimpan."
: "Submission Anda telah 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",
}),
}
);
};
const handleDelete = async () => {
if (!confirm("Yakin ingin menghapus submission ini?")) return;
const ok = await remove(submission!.id);
if (ok) setInputLink("");
};
if (!confirm("Yakin ingin menghapus submission ini?")) return;
try {
const ok = await remove(submission!.id);
if (ok) {
setInputLink("");
setIsEditing(false);
customToast.success(
"Submission berhasil dihapus!",
"Anda dapat mengirim submission baru kapan saja."
);
}
} catch (err: any) {
customToast.error(
"Gagal menghapus submission!",
err?.response?.data?.message || "Terjadi kesalahan saat menghapus submission."
);
}
};
if (submission && !isEditing) {
return (
@@ -2,9 +2,9 @@ import MentorProjectCard from './components/MentorProjectCard';
import { Plus } from 'lucide-react';
import { useState } from 'react';
import CreateProjectModal from './components/CreateProjectDialog';
import { customToast } from '../../utils/showToast';
import { useProjectsByRole } from '../../../hooks/useListProjects';
export default function MentorProjectsPage() {
const [isModalOpen, setIsModalOpen] = useState(false);
const { projects, loading, error, refetch } = useProjectsByRole();
@@ -38,7 +38,6 @@ export default function MentorProjectsPage() {
<p className="page-title-desc">Create, manage, and monitor ongoing projects and assignments.</p>
</div>
<div>
{/* Tombol pemicu buka modal */}
<button
onClick={openDialog}
className="flex items-center gap-2 px-4 py-1 bg-[#C0392B] text-white rounded-lg hover:bg-[#A93226] transition-colors duration-150"
+8 -2
View File
@@ -4,6 +4,7 @@ import { useAllInterns, useRemoveMember, useProjects } from "../../../hooks/useP
import AddInternsModal from "./components/AddInternsToProjects";
import type { InternDetail } from "../../../types/project.types";
import type { Project } from "../../../types/project.types";
import { customToast } from "../../utils/showToast";
export default function InternsManagement() {
const [searchEmail, setSearchEmail] = useState("");
@@ -39,11 +40,16 @@ export default function InternsManagement() {
const handleDeleteIntern = async (intern: InternDetail) => {
const matchedProject = activeProjects.find((project: Project) => project.project_name === intern.projectName);
if (!matchedProject) {
console.error("Project not found for intern:", intern);
customToast.error('Project not found', `Could not find a matching project for intern ${intern.name}.`);
return;
}
const res = await remove({ id_project: matchedProject.id, id_user: intern.id });
if (res !== null) refetch();
if (res !== null) {
customToast.success('Intern removed', `${intern.name} has been successfully removed from the project.`);
refetch();
} else {
customToast.error('Failed to remove', 'An error occurred while removing the intern. Please try again.');
}
};
if (loading) return (
@@ -1,5 +1,6 @@
import { useState, useRef, useEffect } from "react";
import { useCurrentUser, useUpdateProfile } from "../../../hooks/useUser";
import { customToast } from "../../utils/showToast";
export default function MentorSettingsPage() {
const { user, loading: userLoading } = useCurrentUser();
@@ -59,20 +60,28 @@ export default function MentorSettingsPage() {
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleSaveChanges = async (e: React.FormEvent) => {
e.preventDefault();
await save({
full_name: formData.fullName,
email: formData.email,
professional_bio: formData.bio,
...(photoFile ? { profile_picture: photoFile } : {}),
});
// Reset file state setelah berhasil save
if (!errorMsg) {
setPhotoFile(null);
setImageError(false);
}
};
const handleSaveChanges = async (e: React.FormEvent) => {
e.preventDefault();
await customToast.promise(
save({
full_name: formData.fullName,
email: formData.email,
professional_bio: formData.bio,
}),
{
loading: "Menyimpan perubahan...",
success: () => ({
title: "Perubahan berhasil disimpan!",
description: successMsg || "",
}),
error: () => ({
title: "Gagal menyimpan perubahan!",
description: errorMsg || "",
}),
}
);
};
const initialLetter = formData.fullName ? formData.fullName.charAt(0).toUpperCase() : "U";
@@ -223,7 +232,7 @@ export default function MentorSettingsPage() {
</p>
</div>
{/* Feedback Messages */}
{/* Feedback Messages
{successMsg && (
<p className="text-xs text-green-600 bg-green-50 p-2 rounded-md font-medium">
{successMsg}
@@ -233,7 +242,7 @@ export default function MentorSettingsPage() {
<p className="text-xs text-red-600 bg-red-50 p-2 rounded-md font-medium">
{errorMsg}
</p>
)}
)} */}
{/* Footer Action */}
<div className="flex justify-end pt-2">
@@ -3,18 +3,20 @@ import { Plus } from 'lucide-react';
import ManageInternsModal from './components/AddInternsDialog';
import type { ProjectDetail, ProjectMember } from '../../../types/project.types';
import { useRemoveMember } from '../../../hooks/useProjects';
import { customToast } from '../../utils/showToast';
export default function InternsTab({ project }: { project: ProjectDetail }) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [members, setMembers] = useState<ProjectMember[]>(project.members);
const { remove, loading } = useRemoveMember();
const handleRemove = async (id_user: number) => {
if (window.confirm('Are you sure you want to remove this member?')) {
const res = await remove({ id_project: project.id, id_user });
if (res !== null) {
alert('Member removed successfully');
window.location.reload();
}
const res = await remove({ id_project: project.id, id_user });
if (res !== null) {
setMembers(prev => prev.filter(m => m.id !== id_user));
customToast.success('Member removed', 'The intern has been successfully removed from this project.');
} else {
customToast.error('Failed to remove', 'An error occurred while removing the member. Please try again.');
}
};
@@ -51,32 +53,32 @@ export default function InternsTab({ project }: { project: ProjectDetail }) {
</h3>
<button
onClick={() => setIsModalOpen(true)}
className='bg-gray-300 border border-gray-500 px-2 py-1 rounded-xl hover:bg-gray-400 transition-colors'
className='bg-gray-300 border border-gray-500 px-2 py-1 rounded-md hover:bg-gray-400 transition-colors'
>
<span className='flex items-center gap-3 text-[14px]'>
<span className='flex items-center gap-3 text-[11px]'>
<Plus className='w-3 h-3'/>Add
</span>
</button>
</div>
{project.members.map((member: ProjectMember, idx) => (
{members.map((member: ProjectMember, idx) => (
<div
key={idx}
className={`flex items-center justify-between py-3 ${idx < project.members.length - 1 ? 'border-b border-gray-300' : ''
className={`flex items-center justify-between py-3 ${idx < members.length - 1 ? 'border-b border-gray-300' : ''
}`}
>
<div className="flex items-center gap-3">
<span className=" text-sm text-[#333]">
<span className=" text-sm text-font">
{member.email}
</span>
</div>
<span className=" text-[13px] text-[#555] font-semibold">
<span className=" text-[13px] text-font font-semibold">
{member.position}
</span>
<div>
<button
onClick={() => handleRemove(member.id)}
disabled={loading}
className='flex items-center gap-1 text-[14px] bg-red-600 text-white p-1 rounded-xl disabled:opacity-50 hover:bg-red-700 transition-colors'
className='flex items-center gap-1 text-[11px] bg-red-600 text-white py-1 px-2 rounded-md disabled:opacity-50 hover:bg-red-700 transition-colors'
>
<Plus className='w-4 h-4'/>Delete
</button>
@@ -88,7 +90,8 @@ export default function InternsTab({ project }: { project: ProjectDetail }) {
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
projectId={project.id}
initialMembers={project.members}
initialMembers={members}
onMembersChange={(updated) => setMembers(updated)}
/>}
</div>
);
+15 -6
View File
@@ -1,21 +1,30 @@
import { useProjectTasks, useCreateTask, useDeleteTask } from '../../../hooks/useTasks';
import { useProjectTasks, useDeleteTask } from '../../../hooks/useTasks';
import type { ProjectDetail } from '../../../types/project.types';
import type { ProjectTask } from '../../../types/task.types';
import { ClipboardList, Pencil, Trash2 } from 'lucide-react';
import { useState } from 'react';
import CreateTaskModal from './components/CreateTaskDialog';
import EditTaskModal from './components/EditTaskDialog';
import { customToast } from '../../utils/showToast';
export default function TaskTab({ project }: { project: ProjectDetail }) {
const { tasks, loading, error, refetch } = useProjectTasks(String(project.id));
const { tasks, loading, error, refetch } = useProjectTasks(project.id);
const { remove } = useDeleteTask();
const [isModalOpen, setIsModalOpen] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [selectedTask, setSelectedTask] = useState<ProjectTask | null>(null);
const handleDeleteTask = async (taskId: number) => {
const ok = await remove(taskId, String(project.id));
if (ok) refetch();
const handleDeleteTask = async (taskId: number,taskTitle: string) => {
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 (loading) return (
@@ -61,7 +70,7 @@ export default function TaskTab({ project }: { project: ProjectDetail }) {
<Pencil size={20} />
</button>
<button
onClick={() => handleDeleteTask(task.id)}
onClick={() => handleDeleteTask(task.id, task.title)}
className="hover:text-red-600 transition-colors p-1"
>
<Trash2 size={20} />
@@ -5,21 +5,23 @@ import { useAssignableInterns } from "../../../../hooks/useProjects";
import { useAssignMember } from '../../../../hooks/useProjects';
import type { ProjectMember } from '../../../../types/project.types';
import type { AssignableIntern } from '../../../../types/project.types';
import { customToast } from "../../../utils/showToast";
interface ManageInternsModalProps {
isOpen: boolean;
onClose: () => void;
projectId: number;
initialMembers: ProjectMember[];
onMembersChange?: (updated: ProjectMember[]) => void;
}
export default function ManageInternsModal({ isOpen, onClose, projectId, initialMembers }: ManageInternsModalProps) {
export default function ManageInternsModal({ isOpen, onClose, projectId, initialMembers, onMembersChange }: ManageInternsModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
const [searchQuery, setSearchQuery] = useState("");
const [invitedList, setInvitedList] = useState<ProjectMember[]>(initialMembers);
const { interns, loading: loadingAssignableInterns } = useAssignableInterns();
const { assign, loading: assigning, error } = useAssignMember();
const { assign, loading: assigning } = useAssignMember();
// Sync saat modal dibuka
useEffect(() => {
@@ -55,22 +57,27 @@ 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) {
setInvitedList(prev => [...prev, {
const newMember: ProjectMember = {
id: intern.id,
id_user: intern.id,
// full_name: `${intern.nama_depan} ${intern.nama_belakang}`,
full_name: intern.full_name,
email: intern.email,
avatar: "",
kelompok_peminatan: intern.kelompok_peminatan,
professional_bio: "",
position: intern.position,
} as ProjectMember]);
} as ProjectMember;
const updatedList = [...invitedList, newMember];
setInvitedList(updatedList);
onMembersChange?.(updatedList);
customToast.success('Intern added', `${intern.full_name} has been successfully added to the project.`);
} else {
customToast.error('Failed to add', 'An error occurred while adding the intern. Please try again.');
}
};
const getInitials = (m: AssignableIntern) =>
m.full_name.toUpperCase();
const getInitials = (m: AssignableIntern) =>
m.full_name.split(' ').slice(0, 2).map(n => n[0]).join('').toUpperCase();
return (
<dialog ref={dialogRef} className="custom-dialog">
@@ -85,11 +92,7 @@ export default function ManageInternsModal({ isOpen, onClose, projectId, initial
/>
<Search className="absolute right-3.5 w-4 h-4 text-gray-400" />
</div>
{error &&
<div className="p-1 flex justify-center bg-red-foreground border border-red text-sm text-red-500 font-light rounded-md">
{error}: Sudah berada di project lain
</div>
}
<div className="overflow-y-auto space-y-3 pr-1 flex-1 h-[170px]">
{/* Sudah jadi member */}
@@ -97,7 +100,7 @@ export default function ManageInternsModal({ isOpen, onClose, projectId, initial
<div key={member.id} className="flex items-center justify-between p-2 rounded-xl hover:bg-gray-50 transition-colors">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-gray-200 border border-gray-300 flex items-center justify-center text-xs font-bold text-gray-500">
{member.full_name.slice(0, 2).toUpperCase()}
{getInitials(member as unknown as AssignableIntern)}
</div>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-700">{member.full_name}</span>
@@ -3,6 +3,7 @@ import { X } from "lucide-react";
import { LuUserPlus, LuUser, LuFolderOpen } from "react-icons/lu";
import { useAssignMember, useProjects } from "../../../../hooks/useProjects";
import { useAssignableInterns } from "../../../../hooks/useProjects";
import { customToast } from "../../../utils/showToast";
interface AddInternsModalProps {
isOpen: boolean;
@@ -46,8 +47,6 @@ export default function AddInternsModal({ isOpen, onClose, userId }: AddInternsM
};
}, [isOpen, onClose]);
// Reset form saat modal dibuka kembali.
// Kalau userId diberikan, dropdown langsung ter-select ke intern tsb.
useEffect(() => {
if (isOpen) {
setSelectedUserId(userId !== null ? String(userId) : "");
@@ -64,7 +63,10 @@ export default function AddInternsModal({ isOpen, onClose, userId }: AddInternsM
id_user: Number(selectedUserId),
});
if (res !== null) {
customToast.success('Intern assigned', 'The intern has been successfully assigned to the project.');
onClose();
} else {
customToast.error('Failed to assign', 'An error occurred while assigning the intern. Please try again.');
}
};
@@ -3,13 +3,13 @@ import type { Project } from "../../../../types/project.types";
// ─── StatusBadge ─────────────────────────────────────────────────────────────
function StatusBadge({ status }: { status: string }) {
const isCompleted = status === "archived";
const isCompleted = status === "active";
return (
<span
className={`text-[10px] font-extrabold tracking-wider px-2.5 py-1 rounded-full border ${
isCompleted
? "bg-green-50 text-green-600 border-green-200"
: "bg-yellow-50 text-yellow-600 border-yellow-200"
: "bg-red-50 text-red-600 border-red-200"
}`}
>
{status}
@@ -1,9 +1,11 @@
import { useRef, useEffect, useState } from "react";
import { useRef, useEffect, useState, useCallback } from "react";
import { X } from "lucide-react";
import { PROJECT_ICON_MAP, PROJECT_ICON_CODES } from "../../../../lib/ProjectIcons";
import { LuSendHorizontal, LuMail } from "react-icons/lu";
import { useCreateProject } from "../../../../hooks/useProjects";
import type { CreateProjectPayload } from "../../../../types/project.types";
import { useAssignableInterns } from "../../../../hooks/useProjects";
import { customToast } from "../../../utils/showToast";
interface CreateProjectModalProps {
isOpen: boolean;
@@ -21,6 +23,21 @@ export default function CreateProjectModal({ isOpen, onClose, onSuccess }: Creat
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const { create, loading, error } = useCreateProject();
const { interns: assignableInterns, loading: loadingInterns } = useAssignableInterns();
const [showDropdown, setShowDropdown] = useState(false);
const emailSectionRef = useRef<HTMLDivElement>(null);
// Tutup dropdown saat klik di luar area input
const handleClickOutside = useCallback((e: MouseEvent) => {
if (emailSectionRef.current && !emailSectionRef.current.contains(e.target as Node)) {
setShowDropdown(false);
}
}, []);
useEffect(() => {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [handleClickOutside]);
const projectIcons = PROJECT_ICON_CODES.map((id) => ({
id,
@@ -51,14 +68,20 @@ export default function CreateProjectModal({ isOpen, onClose, onSuccess }: Creat
};
}, [isOpen, onClose]);
const handleAddEmail = (e: React.FormEvent) => {
e.preventDefault();
if (emailInput.trim() && !invitedEmails.includes(emailInput.trim())) {
setInvitedEmails([...invitedEmails, emailInput.trim()]);
const handleAddEmail = (emailToAdd?: string) => {
const email = (emailToAdd ?? emailInput).trim();
if (email && !invitedEmails.includes(email)) {
setInvitedEmails([...invitedEmails, email]);
setEmailInput("");
setShowDropdown(false);
}
};
// const handleAddEmailFromForm = (e: React.FormEvent) => {
// e.preventDefault();
// handleAddEmail();
// };
const handleRemoveEmail = (emailToRemove: string) => {
setInvitedEmails(invitedEmails.filter(email => email !== emailToRemove));
};
@@ -79,8 +102,12 @@ export default function CreateProjectModal({ isOpen, onClose, onSuccess }: Creat
const result = await create(payload);
if (result) {
onSuccess?.(); // panggil refetch di parent (kalau ada)
onSuccess?.();
onClose();
customToast.success(
"Project berhasil dibuat!",
`${projectName} sudah aktif dan siap dikelola.`
);
}
};
@@ -167,30 +194,71 @@ export default function CreateProjectModal({ isOpen, onClose, onSuccess }: Creat
<label className="text-xs font-bold text-gray-700 tracking-wide">
Invite via Gmail
</label>
<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"
value={emailInput}
onChange={(e) => setEmailInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddEmail(e);
}
}}
className="w-full pl-10 pr-20 py-2.5 bg-white border border-gray-200 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-gray-100 hover:bg-gray-200 text-gray-800 text-[11px] font-bold rounded-md transition-colors tracking-wider uppercase"
>
ADD
</button>
{/* Input email + dropdown sugesti intern */}
<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"
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);
}}
className="w-full pl-10 pr-20 py-2.5 bg-white border border-gray-200 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-gray-100 hover:bg-gray-200 text-gray-800 text-[11px] font-bold rounded-md transition-colors tracking-wider uppercase"
>
ADD
</button>
</div>
{/* Dropdown daftar intern */}
{showDropdown && (
<div className="absolute z-10 mt-1 w-full bg-white border border-gray-200 rounded-xl shadow-lg max-h-44 overflow-y-auto">
{loadingInterns ? (
<p className="text-xs text-gray-400 text-center py-3">Memuat data intern...</p>
) : (() => {
const filtered = assignableInterns.filter(
(i) =>
!invitedEmails.includes(i.email) &&
(i.email.toLowerCase().includes(emailInput.toLowerCase()) ||
i.full_name.toLowerCase().includes(emailInput.toLowerCase()))
);
return filtered.length === 0 ? (
<p className="text-xs text-gray-400 text-center py-3">Tidak ada intern ditemukan.</p>
) : (
filtered.map((intern) => (
<button
key={intern.id}
type="button"
onMouseDown={(e) => { e.preventDefault(); handleAddEmail(intern.email); }}
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-gray-50 transition-colors text-left"
>
<div className="w-7 h-7 rounded-full bg-red-100 text-red-700 flex items-center justify-center text-[10px] font-bold shrink-0">
{intern.full_name.split(' ').slice(0, 2).map((n) => n[0]).join('').toUpperCase()}
</div>
<div className="flex flex-col min-w-0">
<span className="text-xs font-semibold text-gray-800 truncate">{intern.full_name}</span>
<span className="text-[11px] text-gray-400 truncate">{intern.email}</span>
</div>
</button>
))
);
})()}
</div>
)}
</div>
{/* Badges List Email */}
@@ -2,6 +2,7 @@ import { useRef, useEffect, useState } from "react";
import { X, FileText, Link } from "lucide-react";
import { useCreateTask } from '../../../../hooks/useTasks';
import type { SubmissionType } from '../../../../types/task.types';
import { customToast } from '../../../utils/showToast';
interface CreateTaskModalProps {
isOpen: boolean;
@@ -39,7 +40,12 @@ export default function CreateTaskModal({ isOpen, onClose, projectId, onSuccess
specific_time: specificTime,
submission_type: submissionType,
});
if (result) onSuccess();
if (result) {
customToast.success('Task created', `"${taskTitle}" has been successfully added to the project.`);
onSuccess();
} else {
customToast.error('Failed to create task', 'An error occurred while saving the task. Please try again.');
}
};
return (
<dialog ref={dialogRef} className="custom-dialog p-[1.5rem]">
@@ -2,6 +2,7 @@ import { useRef, useEffect, useState } from "react";
import { X, FileText, Link } from "lucide-react";
import { useUpdateTask } from '../../../../hooks/useTasks';
import type { ProjectTask, SubmissionType } from '../../../../types/task.types';
import { customToast } from '../../../utils/showToast';
interface EditTaskModalProps {
isOpen: boolean;
@@ -48,7 +49,12 @@ export default function EditTaskModal({ isOpen, onClose, task, onSuccess }: Edit
specific_time: specificTime,
submission_type: submissionType,
}, String(task.id_project));
if (result) onSuccess();
if (result) {
customToast.success('Task updated', `"${taskTitle}" has been successfully updated.`);
onSuccess();
} else {
customToast.error('Failed to update task', 'An error occurred while saving changes. Please try again.');
}
};
return (
@@ -4,6 +4,7 @@ import * as LucideIcons from 'lucide-react';
import type { LucideProps } from 'lucide-react';
import type { Project } from '../../../../types/project.types';
import { useArchiveProject } from '../../../../hooks/useProjects';
import { customToast } from '../../../utils/showToast';
const getDynamicIcon = (iconName: string) => {
const formatted = iconName.charAt(0).toUpperCase() + iconName.slice(1);
@@ -11,14 +12,16 @@ const getDynamicIcon = (iconName: string) => {
return Icon ?? LucideIcons.FolderOpen;
};
const toSlug = (name: string) => name.toLowerCase().replace(/\s+/g, '-');
interface MentorProjectCardProps extends Project {
onArchived?: () => void;
}
export default function MentorProjectCard(project: Project) {
export default function MentorProjectCard({ onArchived, ...project} : MentorProjectCardProps) {
const navigate = useNavigate();
const { archive, loading } = useArchiveProject();
const handleCardClick = () => {
navigate(`/mentor/projects/${toSlug(project.slug)}`)
navigate(`/mentor/projects/${project.slug}`)
};
const Icon = getDynamicIcon(project.project_icon);
@@ -31,12 +34,19 @@ export default function MentorProjectCard(project: Project) {
const result = await archive(project.id);
if (result) {
window.alert("Project berhasil diarsipkan.");
window.location.reload();
customToast.success(
"Berhasil!",
"Projek Berhasil Diarsipkan (archived)"
)
onArchived?.();
} else {
window.alert("Gagal mengarsipkan project.");
customToast.error(
"Gagal!",
"Projek gagal diarsipkan (archived)"
)
}
};
return (
<div
onClick={handleCardClick}
+1 -1
View File
@@ -1,6 +1,6 @@
// utils/toast.tsx
import toast, { type Toast } from 'react-hot-toast';
import { FiXCircle, FiX } from 'react-icons/fi';
import { FiX } from 'react-icons/fi';
import { FaCheck } from "react-icons/fa";
import { IoAlertOutline } from "react-icons/io5";