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:
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user