Merge pull request #3 from internship-humic/implement-backend
Implement backend
This commit is contained in:
+17
-9
@@ -31,23 +31,26 @@ import EditLowongan from "./Pages/EditLowongan";
|
||||
import HomeInternPage from "./Pages/Internify/intern/HomeInternPage";
|
||||
import { InternLayout, MentorLayout } from "./Pages/Internify/layout";
|
||||
import ProjectsPage from "./Pages/Internify/intern/ProjectsPage";
|
||||
import SertificatePage from "./Pages/Internify/intern/SertificatePage";
|
||||
import SertificatePage from "./Pages/Internify/intern/InternClaimCert";
|
||||
import ProjectDetailPage from "./Pages/Internify/intern/ProjectDetailPage";
|
||||
import HomeMentorPage from "./Pages/Internify/mentor/HomeMentorPage";
|
||||
import MentorProjectsPage from "./Pages/Internify/mentor/BaseProjectsPage";
|
||||
import MentorCertificatePage from "./Pages/Internify/mentor/CertificatePage";
|
||||
import MentorCertificatePage from "./Pages/Internify/mentor/CertificateListPage";
|
||||
import MentorInternPage from "./Pages/Internify/mentor/InternsPage";
|
||||
import MentorDetailProject from "./Pages/Internify/mentor/ProjectsPage";
|
||||
import InternifyLogin from "./Pages/Internify/LoginInternify"
|
||||
import FAQPage from "./Pages/Internify/FAQpage";
|
||||
import NotificationList from "./Pages/Internify/NotificationList";
|
||||
import SettingsContent from "./Pages/Internify/SettingsPage";
|
||||
import MentorSettingsPage from "./Pages/Internify/mentor/MentorSettingsPage";
|
||||
import TaskSubmission from "./Pages/Internify/intern/TaskSubmission";
|
||||
import SertificateList from "./Pages/Internify/intern/SertificateList";
|
||||
import SertificateList from "./Pages/Internify/intern/InternCertList";
|
||||
import MentorProjectsDetailPage from "./Pages/Internify/mentor/MentorProjectDetailPage";
|
||||
import ViewInternSubmission from "./Pages/Internify/mentor/ViewInternSubmission";
|
||||
import SettingsPage from "./Pages/Internify/mentor/MentorSettingsPage";
|
||||
import ProtectedRoutes from "./Pages/utils/ProtectedRoute";
|
||||
import CertificateDetail from "./Pages/Internify/mentor/CertificateForm";
|
||||
import CertificateResult from "./Pages/Internify/mentor/CertificateResult";
|
||||
import NotFoundPage from "./Pages/NotFoundPage";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -93,8 +96,8 @@ function App() {
|
||||
<Route path="/intern" element={<InternLayout />}>
|
||||
<Route index element={<HomeInternPage />} />
|
||||
<Route path="projects" element={<ProjectsPage />} />
|
||||
<Route path="certificates" element={<SertificatePage />} />
|
||||
<Route path="certificates/all" element={<SertificateList />} />
|
||||
<Route path="certificates" element={<SertificateList />} />
|
||||
<Route path="certificates/:slug" element={<SertificatePage />} />
|
||||
<Route path="projects/:slug" element={<ProjectDetailPage />} />
|
||||
<Route path="projects/:slug/:taskSlug" element={<TaskSubmission />} />
|
||||
<Route path="faq" element={<FAQPage />} />
|
||||
@@ -103,22 +106,27 @@ function App() {
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Mentor only */}
|
||||
<Route element={<ProtectedRoutes allowedRoles={["admin"]} />}>
|
||||
{/* Mentor/Admin only */}
|
||||
<Route element={<ProtectedRoutes allowedRoles={["admin", "mentor"]} />}>
|
||||
<Route path="/mentor" element={<MentorLayout />}>
|
||||
<Route index element={<HomeMentorPage />} />
|
||||
<Route path="projects" element={<MentorProjectsPage />} />
|
||||
<Route path="certificates" element={<MentorCertificatePage />} />
|
||||
<Route path="certificates/:slug" element={<CertificateDetail />} />
|
||||
<Route path="certificates/result" element={<CertificateResult />} />
|
||||
<Route path="intern" element={<MentorInternPage />} />
|
||||
<Route path="projects/:slug" element={<MentorDetailProject />} />
|
||||
<Route path="projects/:slug/:taskSlug" element={<MentorProjectsDetailPage />} />
|
||||
<Route path="projects/:slug/:taskSlug/:nameIntern" element={<ViewInternSubmission />} />
|
||||
<Route path="faq" element={<FAQPage />} />
|
||||
<Route path="notifications" element={<NotificationList />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="settings" element={<MentorSettingsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* 404 catch-all */}
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
|
||||
</Routes>
|
||||
</Router>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,36 @@
|
||||
import { Bell, Menu } from "lucide-react"
|
||||
import humiclogo from "../assets/logo.png";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useCurrentUser } from "../hooks/useUser";
|
||||
|
||||
/** Singkatan nama: ambil huruf pertama setiap kata, maksimal 2 huruf.
|
||||
* "Andi Mahesa" → "AM"
|
||||
* "Andi Bayu Cendika" → "AC" (huruf pertama + huruf pertama kata terakhir)
|
||||
*/
|
||||
function getInitials(fullName: string | undefined | null): string {
|
||||
if (!fullName) return "U";
|
||||
const parts = fullName.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return "U";
|
||||
if (parts.length === 1) return parts[0].charAt(0).toUpperCase();
|
||||
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
|
||||
}
|
||||
|
||||
export default function Header({ toggleSidebar }: { toggleSidebar: () => void }) {
|
||||
const nav = useNavigate();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
// Tentukan base path settings sesuai role
|
||||
const settingsPath =
|
||||
user?.role === "intern" ? "/intern/settings" : "/mentor/settings";
|
||||
|
||||
const initials = user?.full_name ? getInitials(user.full_name) : "U";
|
||||
const hasPhoto = !!user?.profile_picture;
|
||||
|
||||
return (
|
||||
<header className='sticky top-0 flex w-full justify-between items-center py-2 px-12 bg-white border-b border-gray-300 z-10'>
|
||||
<header className='sticky top-0 flex w-full justify-between items-center py-4 px-12 bg-white border-b border-gray-300 z-10'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="p-1 text-gray-600 hover:bg-gray-100 rounded-md lg:hidden"
|
||||
>
|
||||
<Menu className="w-6 h-6" />
|
||||
@@ -20,12 +42,30 @@ export default function Header({ toggleSidebar }: { toggleSidebar: () => void })
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-6 items-center">
|
||||
<button onClick={()=>nav("notifications")}>
|
||||
<Bell className="w-6 h-6 hover:text-red-800"/>
|
||||
<button onClick={() => nav("notifications")}>
|
||||
<Bell className="w-5 h-5 hover:text-red-800"/>
|
||||
</button>
|
||||
<div className="bg-blue-500 rounded-xl w-9 h-9">
|
||||
|
||||
</div>
|
||||
{/* Gambar Profil */}
|
||||
<button
|
||||
onClick={() => nav(settingsPath)}
|
||||
className="rounded-full w-9 h-9 overflow-hidden border-2 border-gray-200 hover:border-red-400 transition-colors flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-red-400"
|
||||
title="Profile Settings"
|
||||
>
|
||||
{hasPhoto ? (
|
||||
<img
|
||||
src={user!.profile_picture!}
|
||||
alt={user!.full_name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-[#B30000] flex items-center justify-center">
|
||||
<span className="text-white text-xs font-bold tracking-wide">
|
||||
{initials}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Home, ArrowLeft } from "lucide-react";
|
||||
|
||||
export default function NotFoundPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-150 via-slate-300 to-gray-50 flex items-center justify-center px-6 relative overflow-hidden">
|
||||
{/* Background decorative blobs */}
|
||||
<div className="absolute top-0 left-1/4 w-96 h-96 bg-red-700 opacity-10 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute bottom-0 right-1/4 w-96 h-96 bg-red-500 opacity-10 rounded-full blur-3xl pointer-events-none" />
|
||||
|
||||
<div className="relative z-10 flex flex-col items-center text-center max-w-2xl w-full">
|
||||
|
||||
{/* 404 Large Text */}
|
||||
<div className="relative mb-4 select-none">
|
||||
<span className="text-[160px] sm:text-[200px] font-black leading-none bg-gradient-to-b from-red to-red/10 bg-clip-text text-transparent">
|
||||
404
|
||||
</span>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-3xl sm:text-4xl font-bold text-white mb-3 tracking-tight">
|
||||
Not Found
|
||||
</h1>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-slate-800 text-base sm:text-lg mb-10 max-w-md leading-relaxed">
|
||||
Sorry, the page you are looking for does not exist or has been moved. Let's get back on track.
|
||||
</p>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-24 h-px bg-gradient-to-r from-transparent via-red-500 to-transparent mb-10" />
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="flex items-center justify-center gap-2 px-6 py-3 rounded-xl border border-white/10 bg-white/30 hover:bg-white/15 text-white font-semibold text-sm transition-all active:scale-95 backdrop-blur-sm"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Kembali
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate("/")}
|
||||
className="flex items-center justify-center gap-2 px-6 py-3 rounded-xl bg-red-700 hover:bg-red-600 text-white font-semibold text-sm transition-all active:scale-95 shadow-lg shadow-red-900/40"
|
||||
>
|
||||
<Home className="w-4 h-4" />
|
||||
Ke Beranda
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Footer note */}
|
||||
<p className="mt-12 text-slate-600 text-xs">
|
||||
Internify © {new Date().getFullYear()} — HUMIC Engineering
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import logo from "../../assets/logo.png";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Mail, Lock, Eye, EyeOff } from 'lucide-react';
|
||||
import api from "../../lib/api";
|
||||
import { useLogin } from "../../hooks/useUser";
|
||||
|
||||
const decodeJWT = (token: string) => {
|
||||
try {
|
||||
@@ -19,49 +19,37 @@ export default function InternifyLogin() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { login, loading, error, setError } = useLogin();
|
||||
|
||||
const handleSignIn = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setError(null);
|
||||
|
||||
if (!email || !password) {
|
||||
setError("Email dan password wajib diisi.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.post("/auth-api/login", { email, password });
|
||||
const data = await login(email, password);
|
||||
if (!data) return;
|
||||
|
||||
const { token } = response.data.data;
|
||||
document.cookie = `token=${token}; path=/; SameSite=Strict`;
|
||||
|
||||
const decoded = decodeJWT(token);
|
||||
const role = decoded?.role;
|
||||
if (role === "intern") {
|
||||
navigate("/intern");
|
||||
} else if (role === "admin") {
|
||||
navigate("/mentor");
|
||||
} else {
|
||||
setError("Role user tidak dikenali.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 401 || err.response?.status === 400) {
|
||||
setError("Email atau password salah.");
|
||||
} else {
|
||||
setError("Terjadi kesalahan. Coba beberapa saat lagi.");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
const { token } = data;
|
||||
document.cookie = `token=${token}; path=/; SameSite=Strict`;
|
||||
|
||||
const decoded = decodeJWT(token);
|
||||
const role = decoded?.role;
|
||||
if (role === "intern") {
|
||||
navigate("/intern");
|
||||
} else if (role === "mentor" || role === "admin") {
|
||||
navigate("/mentor");
|
||||
} else {
|
||||
setError("Role user tidak dikenali.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex flex-col justify-between items-center py-8 px-4 font-sans selection:bg-red-200">
|
||||
<div className="hidden md:block"></div>
|
||||
|
||||
<div className="min-h-screen bg-background flex flex-col justify-between items-center py-8 px-4 font-sans selection:bg-red-foreground">
|
||||
<div className="w-full max-w-[440px] flex flex-col items-center">
|
||||
<div className="text-center mb-6">
|
||||
<div className="flex items-center justify-center gap-2 font-bold text-2xl tracking-tight text-black">
|
||||
@@ -71,8 +59,8 @@ export default function InternifyLogin() {
|
||||
HUMIC LMS PORTAL
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-md bg-white rounded-2xl border border-gray-100 p-8 md:p-12">
|
||||
{/* MAIN CONTENT */}
|
||||
<div className="max-w-md bg-box-primary rounded-2xl border border-box-border p-8 md:p-12 shadow-sm">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-1">Welcome back</h2>
|
||||
<p className="text-sm text-gray-500 mb-6">Access your dashboard with your credentials.</p>
|
||||
|
||||
@@ -132,7 +120,7 @@ export default function InternifyLogin() {
|
||||
disabled={loading}
|
||||
className="w-full bg-[#B30000] hover:bg-[#990000] text-white font-medium text-sm py-2.5 rounded-lg transition-colors shadow-sm mt-2"
|
||||
>
|
||||
Sign In
|
||||
{loading ? "Signing in..." : "Sign In"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -147,14 +135,14 @@ export default function InternifyLogin() {
|
||||
<div className="mt-1">
|
||||
<div className="relative flex py-2 items-center">
|
||||
<div className="flex-grow border-t border-gray-100"></div>
|
||||
<span className="flex-shrink mx-4 text-[10px] font-bold tracking-wider text-gray-400 uppercase">
|
||||
<span className="flex-shrink mx-4 text-[10px] font-bold tracking-wider text-font-shade uppercase">
|
||||
SUPPORT
|
||||
</span>
|
||||
<div className="flex-grow border-t border-gray-100"></div>
|
||||
</div>
|
||||
|
||||
<button className="w-full mt-2 flex items-center justify-center gap-2 border border-gray-200 rounded-full py-1.5 text-xs text-gray-600 hover:bg-gray-50 transition-colors font-medium">
|
||||
<svg className="w-3.5 h-3.5 text-gray-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<button className="w-full mt-2 flex items-center justify-center gap-2 border border-gray-200 rounded-full py-1.5 text-xs text-font-shade hover:bg-gray-50 transition-colors font-medium">
|
||||
<svg className="w-3.5 h-3.5 text-font-shade" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Frequently Asked Questions
|
||||
|
||||
@@ -77,7 +77,7 @@ export default function NotificationList() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
|
||||
<div>
|
||||
@@ -86,16 +86,19 @@ export default function NotificationList() {
|
||||
Keep track of your interns' progress and system updates.
|
||||
</p>
|
||||
</div>
|
||||
{/* <button
|
||||
onClick={handleMarkAllRead}
|
||||
<button
|
||||
onClick={markAllRead}
|
||||
className="flex items-center gap-1.5 self-start sm:self-center px-4 py-2 border border-gray-200 rounded-lg text-xs font-semibold text-gray-700 bg-white hover:bg-gray-50 transition-colors shadow-sm"
|
||||
>
|
||||
<Check className="w-4 h-4 text-green-600 stroke-[3]" />
|
||||
Mark all as read
|
||||
</button> */}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* List Group Section */}
|
||||
{notifications.length === 0 && (
|
||||
<p className="text-sm text-gray-400">Belum ada notifikasi.</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Render Group TODAY */}
|
||||
@@ -184,6 +187,13 @@ export default function NotificationList() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* OLDER */}
|
||||
{olderNotifs.length > 0 && (
|
||||
<div className="space-y-3 pt-2">
|
||||
<h2 className="text-[10px] font-bold tracking-widest text-gray-400 uppercase">Older</h2>
|
||||
{olderNotifs.map(n => renderNotifCard(n, true))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,36 +1,26 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import api from "../../lib/api";
|
||||
import { useCurrentUser, useUpdateProfile } from "../../hooks/useUser";
|
||||
|
||||
export default function SettingsContent() {
|
||||
const { user, loading } = useCurrentUser();
|
||||
const { save, loading: saving, error: errorMsg, successMsg } = useUpdateProfile();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
fullName: "",
|
||||
email: "",
|
||||
bio: ""
|
||||
bio: "",
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [successMsg, setSuccessMsg] = useState("");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
|
||||
// Sync form data when user is loaded
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await api.patch("/auth-api/update-profile");
|
||||
const data = response.data.data;
|
||||
setFormData({
|
||||
fullName: data.full_name || "",
|
||||
email: data.email || "",
|
||||
bio: data.professional_bio || "",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch profile:", err)
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
if (user) {
|
||||
setFormData({
|
||||
fullName: user.full_name || "",
|
||||
email: user.email || "",
|
||||
bio: user.professional_bio || "",
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
@@ -39,25 +29,14 @@ export default function SettingsContent() {
|
||||
|
||||
const handleSaveChanges = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSuccessMsg("");
|
||||
setErrorMsg("");
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
await api.patch("/auth-api/update-profile", {
|
||||
full_name: formData.fullName,
|
||||
email: formData.email,
|
||||
professional_bio: formData.bio,
|
||||
});
|
||||
setSuccessMsg("Profile updated successfully!");
|
||||
} catch (err: any) {
|
||||
setErrorMsg(err?.response?.data?.message || "Failed to save changes.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
await save({
|
||||
full_name: formData.fullName,
|
||||
email: formData.email,
|
||||
professional_bio: formData.bio,
|
||||
});
|
||||
};
|
||||
|
||||
const initialLetter = formData.fullName ? formData.fullName.charAt(1).toUpperCase() : "U";
|
||||
const initialLetter = formData.fullName ? formData.fullName.charAt(0).toUpperCase() : "U";
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import ProjectTimeline from './components/ProjectTimeline';
|
||||
import TaskCard from './components/ProjectTaskCard';
|
||||
import HeroProject from './components/Hero-project';
|
||||
import { useProjectTasks } from '../../../hooks/useTasks';
|
||||
import { useProjectMyTasks } from '../../../hooks/useProjects';
|
||||
import type { Project } from '../../../types/project.types';
|
||||
|
||||
export default function ForumTab({ project }: { project: Project }) {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { tasks, loading, error } = useProjectTasks(slug ?? "");
|
||||
const { tasks, loading, error } = useProjectMyTasks(project.id);
|
||||
return (
|
||||
<div>
|
||||
{/* Hero banner */}
|
||||
<HeroProject title={project.project_name} description={project.description} />
|
||||
|
||||
{/* Loading */}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Loader2, AlertCircle } from "lucide-react";
|
||||
import { useMyProjects } from "../../../hooks/useProjects";
|
||||
import type { Project } from "../../../types/project.types";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import type { LucideProps } from 'lucide-react';
|
||||
|
||||
|
||||
export default function SertificateList() {
|
||||
const { projects, loading, error } = useMyProjects();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const getDynamicIcon = (iconName: string) => {
|
||||
const formatted = iconName.charAt(0).toUpperCase() + iconName.slice(1);
|
||||
const Icon = (LucideIcons as unknown as Record<string, React.ComponentType<LucideProps>>)[formatted];
|
||||
return Icon ?? LucideIcons.FolderOpen;
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-col gap-1">
|
||||
<h1 className="page-title">Certificate History</h1>
|
||||
<p className="page-title-desc">All your earned internship certificates</p>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-20 text-gray-400 gap-2">
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
<span className="text-sm">Loading certificates...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && !loading && (
|
||||
<div className="flex items-center justify-center py-20 text-red-500 gap-2">
|
||||
<AlertCircle className="w-6 h-6" />
|
||||
<span className="text-sm">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty */}
|
||||
{!loading && !error && projects.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
|
||||
<p className="text-sm">No ongoing projects yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid */}
|
||||
{!loading && !error && projects.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{projects.map((item: Project) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-shadow cursor-pointer group"
|
||||
onClick={() => navigate(`/intern/certificates/${item.slug}`, { state: { project: item } })}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="p-6 pb-0">
|
||||
<div className="rounded-lg overflow-hidden">
|
||||
<div className="w-full h-32 bg-gradient-to-br from-red-400 to-red-600 flex items-center justify-center">
|
||||
{(() => {
|
||||
const Icon = getDynamicIcon(item.project_icon);
|
||||
return <Icon className="w-10 h-10 text-white" />;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-xs text-gray-400 mb-0.5">
|
||||
{new Date(item.start_date).toLocaleDateString("id-ID", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
})} until {new Date(item.end_date).toLocaleDateString("id-ID", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-gray-800 leading-snug">
|
||||
{item.project_name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
No: <span className="font-bold text-red-600">{item.status}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import CertificateAvailable from "./components/CertificateAvailabe";
|
||||
import CertificateNotAvailable from "./components/CertificateNotAvailable";
|
||||
import SertificateHistory from "./components/CertificateHistory";
|
||||
import { useMyCertificates } from "../../../hooks/useSertificates";
|
||||
import { useProjectDetail } from "../../../hooks/useProjects";
|
||||
import type { Project } from "../../../types/project.types";
|
||||
|
||||
const SertificatePage = () => {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const location = useLocation();
|
||||
const projectFromList = location.state?.project as Project | undefined;
|
||||
|
||||
const { project: projectDetail, loading: projectLoading } = useProjectDetail(slug ?? "");
|
||||
const { certificates, loading: certLoading } = useMyCertificates();
|
||||
|
||||
const project = useMemo(() => {
|
||||
if (!projectDetail) return null;
|
||||
return {
|
||||
...projectDetail,
|
||||
task_done: projectFromList?.task_done ?? 0,
|
||||
total_tasks: projectFromList?.total_tasks ?? projectDetail.total_tasks,
|
||||
};
|
||||
}, [projectDetail, projectFromList]);
|
||||
|
||||
const allTasksDone = !projectLoading && !!project && project.task_done >= project.total_tasks;
|
||||
|
||||
const alreadyDone = useMemo(() => {
|
||||
if (!project || !certificates.length) return false;
|
||||
return certificates.some((cert) => cert.project.id === project.id);
|
||||
}, [certificates, project]);
|
||||
|
||||
const progress =
|
||||
project && project.total_tasks > 0
|
||||
? Math.round((project.task_done / project.total_tasks) * 100)
|
||||
: 0;
|
||||
|
||||
const remainingTasks = project ? project.total_tasks - project.task_done : 0;
|
||||
|
||||
const isLoading = projectLoading || certLoading;
|
||||
const showCertificate = alreadyDone;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20 text-gray-400 text-sm">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex flex-col gap-1">
|
||||
<h1 className="page-title">Certificates</h1>
|
||||
<p className="page-title-desc">View and download internship certificates</p>
|
||||
</div>
|
||||
<div className="flex flex-col lg:flex-row gap-6 items-stretch">
|
||||
<div className="w-full lg:w-5/8 flex flex-col">
|
||||
{showCertificate ? (
|
||||
<CertificateAvailable project={project} />
|
||||
) : (
|
||||
<CertificateNotAvailable
|
||||
progress={progress}
|
||||
remainingTasks={remainingTasks}
|
||||
allTasksDone={allTasksDone}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full lg:w-3/8 flex-shrink-0">
|
||||
<SertificateHistory />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SertificatePage;
|
||||
@@ -1,11 +1,6 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { } from '../../../hooks/useProjects';
|
||||
import type { Project } from '../../../types/project.types';
|
||||
|
||||
export default function ParticipantsTab({ project }: { project: Project }) {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { interns, loading, error } = useProjectInterns(slug ?? "");
|
||||
import type { ProjectDetail, ProjectMember } from '../../../types/project.types';
|
||||
|
||||
export default function ParticipantsTab({ project }: { project: ProjectDetail }) {
|
||||
return (
|
||||
<div>
|
||||
{/* Mentor */}
|
||||
@@ -30,44 +25,29 @@ export default function ParticipantsTab({ project }: { project: Project }) {
|
||||
{/* Members */}
|
||||
<div>
|
||||
<h3 className="font-bold text-font-shade mb-3.5">Members</h3>
|
||||
|
||||
{loading && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-10 bg-gray-100 rounded animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||
|
||||
{!loading && !error && interns.map((intern, idx) => (
|
||||
{project.members.map((member: ProjectMember, idx) => (
|
||||
<div
|
||||
key={intern.id}
|
||||
key={member.id}
|
||||
className={`flex items-center justify-between py-3 ${
|
||||
idx < interns.length - 1 ? 'border-b border-box-border' : ''
|
||||
idx < project.members.length - 1 ? 'border-b border-box-border' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{intern.avatar ? (
|
||||
<img
|
||||
src={intern.avatar}
|
||||
alt={intern.full_name}
|
||||
className="w-7 h-7 rounded-full object-cover"
|
||||
/>
|
||||
{member.avatar ? (
|
||||
<img src={member.avatar} alt={member.full_name} className="w-7 h-7 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-7 h-7 rounded-full bg-gray-200 flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-[#333]">{intern.full_name}</span>
|
||||
<span className="text-[11px] text-gray-400">{intern.email}</span>
|
||||
<span className="text-sm text-[#333]">{member.full_name}</span>
|
||||
<span className="text-[11px] text-gray-400">{member.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[13px] text-[#555] font-semibold">{intern.kelompok_peminatan}</span>
|
||||
<span className="text-[13px] text-[#555] font-semibold">{member.kelompok_peminatan}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!loading && !error && interns.length === 0 && (
|
||||
{project.members.length === 0 && (
|
||||
<p className="text-sm text-gray-400">Belum ada members.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
// import { useProjectDetail } from '../../../hooks/useProjectDetail';
|
||||
import { useProjectDetail } from '../../../hooks/useProjects';
|
||||
import ForumTab from './ForumTab';
|
||||
import ParticipantsTab from './ParticipantTab';
|
||||
@@ -10,9 +9,9 @@ export default function ProjectDetailPage() {
|
||||
const { project, loading, error } = useProjectDetail(slug ?? "");
|
||||
const [activeTab, setActiveTab] = useState<'forum' | 'participants'>('forum');
|
||||
|
||||
// Fallback sederhana
|
||||
if (loading) return <p className="p-10 text-gray-400">Memuat project...</p>;
|
||||
if (error) return <p className="p-10 text-red-500">{error}</p>;
|
||||
|
||||
if (!project) return <p className="p-10 text-gray-400">Project tidak ditemukan.</p>;
|
||||
|
||||
const tabClass = (tab: string) =>
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { Award } from "lucide-react";
|
||||
import { history, type CertificateHistory } from "../../../lib/mockSertificates";
|
||||
import certificateImg from "../../../assets/certificate.png";
|
||||
|
||||
export default function SertificateList() {
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-col gap-1">
|
||||
<h1 className="page-title">Certificate History</h1>
|
||||
<p className="page-title-desc">All your earned internship certificates</p>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{history.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
|
||||
<Award className="w-12 h-12 mb-3 opacity-40" />
|
||||
<p className="text-sm">No certificates yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{history.map((item: CertificateHistory) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-shadow cursor-pointer group"
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="p-6 pb-0">
|
||||
<div className="rounded-lg overflow-hidden">
|
||||
<img
|
||||
src={certificateImg}
|
||||
alt={item.title}
|
||||
className="w-full h-[160px] object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-xs text-gray-400 mb-0.5">{item.month}</p>
|
||||
<p className="text-sm font-semibold text-gray-800 leading-snug">{item.title}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Grade: <span className="font-bold text-red-600">{item.grade}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { certificateData } from "../../../lib/mockSertificates";
|
||||
import CertificateAvailable from "./components/CertificateAvailabe";
|
||||
import CertificateNotAvailable from "./components/CertificateNotAvailable";
|
||||
import SertificateHistory from "./components/CertificateHistory";
|
||||
|
||||
const SertificatePage = () => {
|
||||
const data = certificateData;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex flex-col gap-1">
|
||||
<h1 className="page-title">Certificates</h1>
|
||||
<p className="page-title-desc">View and download internship certificates</p>
|
||||
</div>
|
||||
<div className="flex flex-col lg:flex-row gap-6 items-stretch">
|
||||
{/* Kiri: Content */}
|
||||
<div className="w-full lg:w-5/8 flex flex-col">
|
||||
{data.isReceived ? (
|
||||
<CertificateAvailable data={data} />
|
||||
) : (
|
||||
<CertificateNotAvailable
|
||||
progress={data.projectProgress}
|
||||
remainingTasks={data.remainingTasks}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Kanan: History */}
|
||||
<div className="w-full lg:w-3/8 flex-shrink-0">
|
||||
<SertificateHistory />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SertificatePage;
|
||||
@@ -49,13 +49,15 @@ export default function TaskSubmission() {
|
||||
|
||||
{submissionType === "file_upload" ? (
|
||||
<TaskFormFile
|
||||
taskId={taskSlug!}
|
||||
taskId={String(task.id)}
|
||||
projectId={String(task.project.id)}
|
||||
deadline={new Date(task.deadline_at)}
|
||||
initialSubmission={task.my_submission}
|
||||
/>
|
||||
) : (
|
||||
<TaskFormLink
|
||||
taskId={taskSlug!}
|
||||
taskId={String(task.id)}
|
||||
projectId={String(task.project.id)}
|
||||
deadline={new Date(task.deadline_at)}
|
||||
initialSubmission={task.my_submission}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useState } from "react";
|
||||
import { Download, Link, Printer } from "lucide-react";
|
||||
import { type CertificateData } from "../../../../lib/mockSertificates";
|
||||
import type { ProjectDetail } from "../../../../types/project.types";
|
||||
import certificateImg from "../../../../assets/certificate.png";
|
||||
|
||||
export default function CertificateAvailable({ data }: { data: CertificateData }) {
|
||||
const { certificate } = data;
|
||||
interface CertificateAvailableProps {
|
||||
project: ProjectDetail | null;
|
||||
}
|
||||
|
||||
export default function CertificateAvailable({ project }: CertificateAvailableProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopyLink = () => {
|
||||
@@ -13,10 +16,25 @@ export default function CertificateAvailable({ data }: { data: CertificateData }
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
// Hitung durasi dari start_date - end_date
|
||||
const duration = project
|
||||
? (() => {
|
||||
const start = new Date(project.start_date);
|
||||
const end = new Date(project.end_date);
|
||||
const diffMs = end.getTime() - start.getTime();
|
||||
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
||||
const months = Math.floor(diffDays / 30);
|
||||
const days = diffDays % 30;
|
||||
if (months > 0 && days > 0) return `${months} Bulan ${days} Hari`;
|
||||
if (months > 0) return `${months} Bulan`;
|
||||
return `${diffDays} Hari`;
|
||||
})()
|
||||
: "-";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Certificate Card */}
|
||||
<div className="flex rounded-xl p-5 mb-1 border border-gray-300 shadow-md items-center justify-center">
|
||||
<div className="flex rounded-xl p-5 mb-1 border bg-box-primary border-box-border shadow-md items-center justify-center">
|
||||
<img
|
||||
src={certificateImg}
|
||||
alt="Certificate"
|
||||
@@ -32,13 +50,14 @@ export default function CertificateAvailable({ data }: { data: CertificateData }
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ label: "Nama Program", value: certificate.programName },
|
||||
{ label: "Durasi", value: certificate.duration },
|
||||
{ label: "Nama Program", value: project?.project_name ?? "-" },
|
||||
{ label: "Durasi", value: duration },
|
||||
{ label: "Total Tugas", value: project ? `${project.total_tasks} Tugas` : "-" },
|
||||
{
|
||||
label: "Status",
|
||||
value: (
|
||||
<span className="bg-green-100 text-green-700 text-xs font-bold px-2 py-0.5 rounded-full">
|
||||
VERIFIED
|
||||
UNVERIFIED
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -1,38 +1,69 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { History } from "lucide-react";
|
||||
import { type CertificateHistory, history as certificateHistory } from "../../../../lib/mockSertificates";
|
||||
import { History, Loader2, AlertCircle } from "lucide-react";
|
||||
import type { Certificate } from "../../../../types/certificate.types";
|
||||
import certificateImg from "../../../../assets/certificate.png";
|
||||
import { useMyCertificates } from "../../../../hooks/useSertificates";
|
||||
|
||||
export default function SertificateHistory() {
|
||||
const navigate = useNavigate();
|
||||
const { certificates, loading, error } = useMyCertificates();
|
||||
const displayed = certificates.slice(0, 2);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm p-5">
|
||||
<div className="bg-box-primary rounded-2xl border border-box-border shadow-sm p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<History className="w-5 h-5 text-red-700" />
|
||||
<h3 className="font-semibold text-gray-800 text-lg">Certificate History</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{certificateHistory.slice(0, 2).map((item: CertificateHistory) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex flex-col gap-3 p-3 bg-white border border-gray-200 shadow-sm rounded-xl hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="w-full flex justify-center">
|
||||
<img
|
||||
src={certificateImg}
|
||||
alt="Certificate"
|
||||
className="rounded-xl h-[150px] w-[150px] w-full object-cover"
|
||||
/>
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8 text-gray-400 gap-2">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span className="text-sm">Loading certificates...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<div className="flex items-center gap-2 py-6 text-red-500">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span className="text-sm">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && displayed.length === 0 && (
|
||||
<p className="text-sm text-gray-400 py-6 text-center">No certificates yet.</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && displayed.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{displayed.map((item: Certificate) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex flex-col gap-3 p-3 bg-white border border-gray-200 shadow-sm rounded-xl hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="w-full flex justify-center">
|
||||
<img
|
||||
src={certificateImg}
|
||||
alt="Certificate"
|
||||
className="rounded-xl h-[150px] w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<p className="text-xs text-gray-400">
|
||||
{new Date(item.issued_at).toLocaleDateString("id-ID", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-gray-800 leading-tight">
|
||||
{item.project.project_name}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">No: {item.certificate_no}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<p className="text-xs text-gray-400">{item.month}</p>
|
||||
<p className="text-sm font-semibold text-gray-800 leading-tight">{item.title}</p>
|
||||
<p className="text-sm text-gray-500">Grade: {item.grade}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full my-3 flex justify-end">
|
||||
<button
|
||||
|
||||
@@ -1,43 +1,81 @@
|
||||
import { Lock, Clock } from "lucide-react";
|
||||
import { Clock, Lock, Award } from "lucide-react";
|
||||
|
||||
export default function CertificateNotAvailable({ progress, remainingTasks }: { progress: number; remainingTasks: number }) {
|
||||
interface CertificateNotAvailableProps {
|
||||
progress: number;
|
||||
remainingTasks: number;
|
||||
allTasksDone?: boolean;
|
||||
}
|
||||
|
||||
export default function CertificateNotAvailable({
|
||||
progress,
|
||||
remainingTasks,
|
||||
allTasksDone = false,
|
||||
}: CertificateNotAvailableProps) {
|
||||
return (
|
||||
<div className="bg-white w-full h-full rounded-2xl border border-gray-100 shadow-sm px-24 flex flex-col items-center justify-center">
|
||||
<div className="bg-box-primary w-full h-full rounded-2xl border border-box-border shadow-sm px-24 py-12 flex flex-col items-center justify-center">
|
||||
{/* Icon */}
|
||||
<div className="relative mb-6">
|
||||
<div className=" w-45 h-45 border-2 border-dashed border-gray-600 rounded-xl flex justify-center items-center">
|
||||
|
||||
<div className="w-30 h-30 flex items-center rounded-lg justify-center bg-gray-300">
|
||||
<Lock className="w-16 h-16 text-red-700" />
|
||||
<div className="w-34 h-34 border-2 border-dashed border-gray-600 rounded-xl flex justify-center items-center">
|
||||
<div className="w-26 h-26 flex items-center rounded-lg justify-center bg-gray-300">
|
||||
{allTasksDone ? (
|
||||
<Award className="w-12 h-12 text-green-600" />
|
||||
) : (
|
||||
<Lock className="w-12 h-12 text-red-700" />
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute -bottom-2 -right-2 w-7 h-7 bg-amber-500 rounded- flex items-center justify-center shadow-md">
|
||||
<Clock className="w-5 h-5 text-yellow-800" />
|
||||
<Clock className="w-4 h-4 text-yellow-800" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-[37px] font-bold text-gray-900 mb-3">Sertifikat Belum Tersedia</h2>
|
||||
<p className="text-gray-500 text-[17px] mb-6">
|
||||
Anda harus menyelesaikan <span className="font-bold text-red-600">semua tugas proyek</span> sebelum dapat mengunduh sertifikat kelulusan.
|
||||
Silakan periksa kembali progres tugas Anda di menu Proyek.
|
||||
</p>
|
||||
{allTasksDone ? (
|
||||
<>
|
||||
<h2 className="text-[37px] font-bold text-gray-900 mb-3">Selamat!</h2>
|
||||
<p className="text-gray-500 text-[17px] mb-6 text-center">
|
||||
Semua tugas proyek telah selesai. Klik tombol di bawah untuk{" "}
|
||||
<span className="font-bold text-green-600">mengklaim sertifikat</span> kelulusan Anda.
|
||||
</p>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="w-1/3">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-semibold text-gray-700">Progres Proyek</span>
|
||||
<span className="text-sm font-bold text-red-700">{progress}%</span>
|
||||
</div>
|
||||
<div className="w-full h-3 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-red-700 rounded-full transition-all duration-700"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-2 text-left">
|
||||
Selesaikan {remainingTasks} tugas lagi untuk membuka akses.
|
||||
</p>
|
||||
</div>
|
||||
{/* Progress Bar — 100% */}
|
||||
<div className="w-1/3 mb-6">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-semibold text-gray-700">Progres Proyek</span>
|
||||
<span className="text-sm font-bold text-green-600">100%</span>
|
||||
</div>
|
||||
<div className="w-full h-3 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-green-500 rounded-full transition-all duration-700 w-full" />
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-2 text-left">Semua tugas telah diselesaikan! Silahkan tunggu untuk mendapatkan sertifikat</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-[28px] font-bold text-gray-900 mb-3">Sertifikat Belum Tersedia</h2>
|
||||
<p className="text-gray-500 text-[15px] mb-6 text-center">
|
||||
Anda harus menyelesaikan <span className="font-bold text-red-600">semua tugas proyek</span> sebelum dapat mengunduh sertifikat kelulusan.
|
||||
Silakan periksa kembali progres tugas Anda di menu Proyek.
|
||||
</p>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="w-1/3">
|
||||
<p className="text-xs text-font mt-2">
|
||||
Selesaikan {remainingTasks} tugas lagi untuk membuka akses.
|
||||
</p>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-semibold text-gray-700">Progres Proyek</span>
|
||||
<span className="text-sm font-bold text-red-700">{progress}%</span>
|
||||
</div>
|
||||
<div className="w-full h-3 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-red-700 rounded-full transition-all duration-700"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { useMyTasks } from '../../../../hooks/useProjects';
|
||||
import { useCurrentUser } from '../../../../hooks/useUser';
|
||||
import { useAllMentorTasks } from '../../../../hooks/useTasks';
|
||||
|
||||
const DAY_LABELS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
|
||||
const MONTH_NAMES = [
|
||||
@@ -16,19 +18,28 @@ function getFirstDayOfMonth(year: number, month: number) {
|
||||
return new Date(year, month, 1).getDay()
|
||||
}
|
||||
|
||||
const HomeCalendar = () => {
|
||||
export default function HomeCalendar() {
|
||||
const [today, setToday] = useState(new Date());
|
||||
const { tasks } = useMyTasks();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
const DEADLINES = tasks.map(task => ({
|
||||
date: new Date(task.deadline_at),
|
||||
label: task.title,
|
||||
}));
|
||||
const isIntern = user?.role === "intern";
|
||||
const { tasks: internTasks } = useMyTasks();
|
||||
const { tasks: mentorTasks } = useAllMentorTasks(!isIntern && user !== null);
|
||||
|
||||
const DEADLINES = isIntern
|
||||
? internTasks.map(task => ({
|
||||
date: new Date(task.deadline_at),
|
||||
label: task.title,
|
||||
}))
|
||||
: mentorTasks.map(task => ({
|
||||
date: new Date(task.deadline_at),
|
||||
label: `[${task.project_name}] ${task.title}`,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setToday(new Date())
|
||||
}, 60 * 1000) // update tiap 1 menit
|
||||
}, 60 * 1000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
@@ -187,6 +198,4 @@ const HomeCalendar = () => {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HomeCalendar
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import type { ProjectTask } from "../../../../types/task.types";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { InternTaskItem } from "../../../../types/project.types";
|
||||
|
||||
const formatDate = (dateStr: string) =>
|
||||
new Date(dateStr).toLocaleDateString('id-ID', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
@@ -7,37 +7,36 @@ const formatDate = (dateStr: string) =>
|
||||
const formatTime = (dateStr: string) =>
|
||||
new Date(dateStr).toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
export default function TaskCard({ task }: { task: ProjectTask }) {
|
||||
export default function TaskCard({ task }: { task: InternTaskItem }) {
|
||||
const navigate = useNavigate();
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
return (
|
||||
<div
|
||||
className="border border-card-outline rounded-xl px-6 py-4 mb-3 bg-white flex items-start justify-between gap-3"
|
||||
onClick={() => navigate(`/intern/projects/${slug}/${task.slug}?type=${task.submission_type}`)}
|
||||
className="border border-card-outline rounded-xl px-6 py-4 mb-3 bg-white flex items-start justify-between gap-3 cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
onClick={() => navigate(`/intern/projects/${task.project_slug}/${task.slug}?type=${task.submission_type}`)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className=" text-[11px] text-font mb-1">
|
||||
<div className="text-[11px] text-font mb-1">
|
||||
Deadline : {formatDate(task.deadline_at)} at {formatTime(task.deadline_at)}
|
||||
</div>
|
||||
<div className=" font-bold text-[15px] text-[#1a1a1a] mb-1">
|
||||
<div className="font-bold text-[15px] text-[#1a1a1a] mb-1">
|
||||
{task.title}
|
||||
</div>
|
||||
<div className=" text-[13px] text-font">
|
||||
<div className="text-[13px] text-font">
|
||||
{task.description}
|
||||
</div>
|
||||
<div className=" text-[13px] text-font">
|
||||
<div className="text-[13px] text-font mt-0.5">
|
||||
Tipe Submission : {task.submission_type === 'file_upload' ? "File" : "Link"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="block md:hidden h-[10px] bg-gray-600"/>
|
||||
|
||||
<div className="shrink-0 hidden md:block">
|
||||
<button
|
||||
className="bg-white text-font-shade border-[1.5px] border-card-outline font-semibold text-xs px-3.5 py-[5px] rounded-md cursor-pointer whitespace-nowrap transition-colors duration-150"
|
||||
<div className="shrink-0 hidden md:flex items-center">
|
||||
<span
|
||||
className={`font-semibold text-xs px-3.5 py-[5px] rounded-md whitespace-nowrap bg-gray-50`}
|
||||
>
|
||||
Add Submission
|
||||
</button>
|
||||
{task.submission_status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ const ProjectCard = (project: Project) => {
|
||||
const Icon = getDynamicIcon(project.project_icon);
|
||||
|
||||
|
||||
const tasksDone = -1;
|
||||
const tasksDone = project.task_done;
|
||||
const tasksTotal = project.total_tasks;
|
||||
|
||||
return (
|
||||
@@ -23,7 +23,7 @@ const ProjectCard = (project: Project) => {
|
||||
className=" rounded-xl overflow-hidden shadow-sm border border-gray-100 w-full flex flex-col cursor-pointer hover:shadow-md transition-shadow duration-200"
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="h-[150px] bg-gradient-to-tr from-red-700 to-red-800 overflow-hidden flex items-center justify-center">
|
||||
<div style={{ backgroundColor: project.background_color ?? '#dc2626' }} className="h-[150px] overflow-hidden flex items-center justify-center">
|
||||
<Icon className="w-12 h-12 text-white opacity-80" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function SidebarIntern({ isOpen, closeSidebar }: SidebarProps) {
|
||||
{/* Brand & Tombol Close untuk Mobile */}
|
||||
<div className="flex items-center justify-between px-2 pb-10">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-9 h-9 rounded-[10px] bg-red-600 flex items-center justify-center flex-shrink-0"></div>
|
||||
<div className="w-9 h-9 rounded-[10px] bg-red flex items-center justify-center flex-shrink-0"></div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-gray-900 leading-tight">Internify</p>
|
||||
<p className="text-[11px] text-gray-400">LMS Portal</p>
|
||||
|
||||
@@ -7,12 +7,13 @@ import type { TaskSubmissionData } from "../../../../types/task.types";
|
||||
|
||||
interface TaskFormFileProps {
|
||||
taskId: string;
|
||||
projectId?: string;
|
||||
deadline?: Date;
|
||||
initialSubmission?: TaskSubmissionData | null;
|
||||
}
|
||||
|
||||
export default function TaskFormFile({ taskId, deadline, initialSubmission }: TaskFormFileProps) {
|
||||
const { submission, loading, submitFile, updateFile, remove } = useSubmission(taskId, initialSubmission);
|
||||
export default function TaskFormFile({ taskId, projectId, deadline, initialSubmission }: TaskFormFileProps) {
|
||||
const { submission, loading, submitFile, updateFile, remove } = useSubmission(taskId, initialSubmission, projectId);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [isDragActive, setIsDragActive] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
@@ -7,12 +7,13 @@ import type { TaskSubmissionData } from "../../../../types/task.types";
|
||||
|
||||
interface TaskFormLinkProps {
|
||||
taskId: string;
|
||||
projectId?: string;
|
||||
deadline?: Date;
|
||||
initialSubmission?: TaskSubmissionData | null;
|
||||
}
|
||||
|
||||
export default function TaskFormLink({ taskId, deadline, initialSubmission }: TaskFormLinkProps) {
|
||||
const { submission, loading, submitLink, updateLink, remove } = useSubmission(taskId, initialSubmission);
|
||||
export default function TaskFormLink({ taskId, projectId, deadline, initialSubmission }: TaskFormLinkProps) {
|
||||
const { submission, loading, submitLink, updateLink, remove } = useSubmission(taskId, initialSubmission, projectId);
|
||||
const [inputLink, setInputLink] = useState(initialSubmission?.url_link ?? "");
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export const MentorLayout = () => {
|
||||
/>
|
||||
)}
|
||||
<main className="container">
|
||||
{/* <Breadcrumbs/> */}
|
||||
<Breadcrumbs/>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { mockProjects } from '../../../lib/mockProjects';
|
||||
import MentorProjectCard from './components/MentorProjectCard';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import CreateProjectModal from './components/CreateProjectDialog';
|
||||
import { useProjects } from '../../../hooks/useProjects';
|
||||
|
||||
export default function MentorProjectsPage(){
|
||||
|
||||
export default function MentorProjectsPage() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const { projects, loading, error } = useProjects("active");
|
||||
|
||||
const openDialog = () => {
|
||||
setIsModalOpen(true);
|
||||
@@ -15,8 +17,18 @@ export default function MentorProjectsPage(){
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
if (loading) return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-16 bg-gray-100 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (error) return <p className="text-danger text-md">{error}</p>;
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="flex flex-col">
|
||||
<h1 className="page-title">List Projects</h1>
|
||||
@@ -24,8 +36,8 @@ export default function MentorProjectsPage(){
|
||||
</div>
|
||||
<div>
|
||||
{/* Tombol pemicu buka modal */}
|
||||
<button
|
||||
onClick={openDialog}
|
||||
<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"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
@@ -35,15 +47,15 @@ export default function MentorProjectsPage(){
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 items-stretch">
|
||||
{mockProjects.map(project => (
|
||||
<MentorProjectCard key={project.id} {...project} tasks={project.tasks} />
|
||||
{projects.map(project => (
|
||||
<MentorProjectCard key={project.id} {...project} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<CreateProjectModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={closeDialog}
|
||||
<CreateProjectModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={closeDialog}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,361 +0,0 @@
|
||||
// src/pages/mentor/CertificateDetail.tsx
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Eye,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Sparkles,
|
||||
ArrowLeft,
|
||||
} from "lucide-react";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
type InternStatus = "ELIGIBLE" | "INCOMPLETE";
|
||||
|
||||
interface Intern {
|
||||
id: number;
|
||||
name: string;
|
||||
role: string;
|
||||
initials: string;
|
||||
taskProgress: number;
|
||||
taskTotal: number;
|
||||
status: InternStatus;
|
||||
}
|
||||
|
||||
// ─── Mock Data ────────────────────────────────────────────────────────────────
|
||||
const mockProject = {
|
||||
title: "Edge Computing Framework",
|
||||
period: "Dec 2023 — Mar 2024",
|
||||
status: "COMPLETED" as const,
|
||||
scope:
|
||||
"Advanced research and implementation of low-latency computing nodes at the network edge, focusing on real-time data processing for autonomous systems.",
|
||||
totalInterns: 21,
|
||||
eligibleInterns: 18,
|
||||
};
|
||||
|
||||
const mockInterns: Intern[] = [
|
||||
{ id: 1, name: "Marcus Rodriguez", role: "Software Engineer Intern", initials: "MR", taskProgress: 10, taskTotal: 10, status: "ELIGIBLE" },
|
||||
{ id: 2, name: "Priya Sharma", role: "Frontend Developer Intern", initials: "PS", taskProgress: 8, taskTotal: 10, status: "INCOMPLETE" },
|
||||
{ id: 3, name: "James Smith", role: "QA Engineering Intern", initials: "JS", taskProgress: 10, taskTotal: 10, status: "ELIGIBLE" },
|
||||
{ id: 4, name: "Nina Castillo", role: "Mobile Development Intern", initials: "NC", taskProgress: 9, taskTotal: 10, status: "ELIGIBLE" },
|
||||
{ id: 5, name: "Derek Lim", role: "DevOps & Cloud Intern", initials: "DL", taskProgress: 7, taskTotal: 10, status: "INCOMPLETE" },
|
||||
{ id: 6, name: "Alex Johnson", role: "Backend Engineering Intern", initials: "AJ", taskProgress: 10, taskTotal: 10, status: "ELIGIBLE" },
|
||||
];
|
||||
|
||||
const TOTAL_ENTRIES = 1284;
|
||||
const PER_PAGE = 3;
|
||||
|
||||
// ─── Avatar ───────────────────────────────────────────────────────────────────
|
||||
const avatarColors: Record<string, string> = {
|
||||
MR: "bg-blue-100 text-blue-700",
|
||||
PS: "bg-purple-100 text-purple-700",
|
||||
JS: "bg-green-100 text-green-700",
|
||||
NC: "bg-pink-100 text-pink-700",
|
||||
DL: "bg-teal-100 text-teal-700",
|
||||
AJ: "bg-orange-100 text-orange-700",
|
||||
};
|
||||
|
||||
function Avatar({ initials }: { initials: string }) {
|
||||
const color = avatarColors[initials] ?? "bg-gray-100 text-gray-500";
|
||||
return (
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-xs font-black flex-shrink-0 ${color}`}>
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Progress Bar ─────────────────────────────────────────────────────────────
|
||||
function ProgressBar({ value, total }: { value: number; total: number }) {
|
||||
const pct = Math.round((value / total) * 100);
|
||||
const isComplete = value === total;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-16 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${isComplete ? "bg-[#B30000]" : "bg-gray-400"}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-xs font-bold ${isComplete ? "text-[#B30000]" : "text-gray-500"}`}>
|
||||
{value}/{total}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Status Badge ─────────────────────────────────────────────────────────────
|
||||
function StatusBadge({ status }: { status: InternStatus }) {
|
||||
const isEligible = status === "ELIGIBLE";
|
||||
return (
|
||||
<div className={`flex items-center gap-1 text-[11px] font-extrabold tracking-wide ${isEligible ? "text-green-600" : "text-orange-500"}`}>
|
||||
{isEligible
|
||||
? <CheckCircle2 className="w-3.5 h-3.5" />
|
||||
: <AlertCircle className="w-3.5 h-3.5" />}
|
||||
{status}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
export default function CertificateDetail() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Selected interns (checked)
|
||||
const initialSelected = new Set(
|
||||
mockInterns.filter((i) => i.status === "ELIGIBLE").map((i) => i.id)
|
||||
);
|
||||
const [selected, setSelected] = useState<Set<number>>(initialSelected);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const totalPages = Math.ceil(mockInterns.length / PER_PAGE);
|
||||
const paginated = mockInterns.slice((page - 1) * PER_PAGE, page * PER_PAGE);
|
||||
|
||||
const eligibleIds = mockInterns.filter((i) => i.status === "ELIGIBLE").map((i) => i.id);
|
||||
const allEligibleSelected = eligibleIds.every((id) => selected.has(id));
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (allEligibleSelected) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(eligibleIds));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleOne = (id: number, status: InternStatus) => {
|
||||
if (status === "INCOMPLETE") return; // tidak bisa dipilih
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Breadcrumb */}
|
||||
<div
|
||||
className="mb-1 text-xs font-medium text-[#B30000] cursor-pointer w-fit"
|
||||
onClick={() => navigate("/mentor/certificates/all")}
|
||||
>
|
||||
Certificates ›
|
||||
</div>
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="page-title">Certificates</h1>
|
||||
<p className="page-title-desc">
|
||||
Administrate, issue, and track professional certifications for all interns.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Two-column layout ── */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||
|
||||
{/* ── LEFT: Project Info ── */}
|
||||
<div className="space-y-5">
|
||||
{/* Status badge */}
|
||||
<div className="flex items-center gap-1.5 text-xs font-extrabold text-green-600 tracking-wide">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
COMPLETED
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h2 className="text-3xl font-extrabold text-gray-900 leading-tight">
|
||||
{mockProject.title}
|
||||
</h2>
|
||||
|
||||
{/* Period */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400 font-medium">
|
||||
<CalendarDays className="w-4 h-4 flex-shrink-0" />
|
||||
{mockProject.period}
|
||||
</div>
|
||||
|
||||
{/* Project Scope */}
|
||||
<div className="border border-gray-200 rounded-xl p-5">
|
||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-3">
|
||||
Project Scope
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
{mockProject.scope}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats: Total & Eligible */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="border border-gray-200 rounded-xl p-4">
|
||||
<p className="text-2xl font-extrabold text-[#B30000]">{mockProject.totalInterns}</p>
|
||||
<p className="text-xs text-gray-400 font-medium mt-0.5">Total Interns</p>
|
||||
</div>
|
||||
<div className="border border-gray-200 rounded-xl p-4">
|
||||
<p className="text-2xl font-extrabold text-gray-900">{mockProject.eligibleInterns}</p>
|
||||
<p className="text-xs text-gray-400 font-medium mt-0.5">Eligible</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── RIGHT: Intern Roster ── */}
|
||||
<div className="border border-gray-200 rounded-2xl overflow-hidden bg-white shadow-sm">
|
||||
|
||||
{/* Roster Header */}
|
||||
<div className="flex items-start justify-between px-5 py-4 border-b border-gray-100">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-gray-900">Intern Roster</h3>
|
||||
<p className="text-xs text-gray-400 mt-0.5 leading-snug max-w-xs">
|
||||
Confirm completion status before final certificate generation.
|
||||
</p>
|
||||
</div>
|
||||
{/* Select All button */}
|
||||
<button
|
||||
onClick={toggleSelectAll}
|
||||
className="flex items-center gap-2 border border-gray-200 rounded-xl px-4 py-2.5 bg-white hover:bg-gray-50 transition-colors flex-shrink-0 ml-4"
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border-2 flex items-center justify-center flex-shrink-0 transition-colors ${
|
||||
allEligibleSelected ? "bg-[#B30000] border-[#B30000]" : "border-gray-300"
|
||||
}`}>
|
||||
{allEligibleSelected && (
|
||||
<svg className="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs font-bold text-gray-700 whitespace-nowrap">
|
||||
Select All Eligible ({mockProject.eligibleInterns})
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Column Headers */}
|
||||
<div className="grid grid-cols-[auto_1fr_auto_auto_auto] items-center gap-3 px-5 py-2.5 bg-gray-50/70 border-b border-gray-100">
|
||||
<div className="w-4" />
|
||||
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Intern Name</span>
|
||||
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider w-20 text-center">Task Progress</span>
|
||||
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider w-24 text-center">Status</span>
|
||||
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider w-16 text-center">Action</span>
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
<div className="divide-y divide-gray-50">
|
||||
{paginated.map((intern) => {
|
||||
const isChecked = selected.has(intern.id);
|
||||
const isIncomplete = intern.status === "INCOMPLETE";
|
||||
return (
|
||||
<div
|
||||
key={intern.id}
|
||||
className="grid grid-cols-[auto_1fr_auto_auto_auto] items-center gap-3 px-5 py-3.5 hover:bg-gray-50/40 transition-colors"
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<button
|
||||
onClick={() => toggleOne(intern.id, intern.status)}
|
||||
disabled={isIncomplete}
|
||||
className={`w-4 h-4 rounded border-2 flex items-center justify-center flex-shrink-0 transition-colors ${
|
||||
isIncomplete
|
||||
? "border-gray-200 bg-gray-50 cursor-not-allowed"
|
||||
: isChecked
|
||||
? "bg-[#B30000] border-[#B30000]"
|
||||
: "border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
{isChecked && !isIncomplete && (
|
||||
<svg className="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Name + role */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Avatar initials={intern.initials} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-bold text-gray-900 leading-tight truncate">{intern.name}</p>
|
||||
<p className="text-[11px] text-gray-400 mt-0.5 truncate">{intern.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task Progress */}
|
||||
<div className="w-20 flex justify-center">
|
||||
<ProgressBar value={intern.taskProgress} total={intern.taskTotal} />
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="w-24 flex justify-center">
|
||||
<StatusBadge status={intern.status} />
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
<div className="w-16 flex justify-center">
|
||||
{isIncomplete ? (
|
||||
<button className="text-[11px] font-extrabold text-[#B30000] hover:underline whitespace-nowrap">
|
||||
Nag Intern
|
||||
</button>
|
||||
) : (
|
||||
<button className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-md transition-colors" title="View">
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Pagination Footer */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 px-5 py-3.5 border-t border-gray-100 bg-gray-50/30">
|
||||
<p className="text-xs text-gray-400 font-medium">
|
||||
Showing {(page - 1) * PER_PAGE + 1} to {Math.min(page * PER_PAGE, mockInterns.length)} of {TOTAL_ENTRIES.toLocaleString()} entries
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="p-1 border border-gray-200 rounded-md bg-white text-gray-400 hover:bg-gray-50 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => setPage(n)}
|
||||
className={`w-7 h-7 flex items-center justify-center rounded-md text-xs font-bold transition-colors ${
|
||||
n === page
|
||||
? "bg-[#B30000] text-white shadow-sm"
|
||||
: "bg-white border border-gray-200 text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="p-1 border border-gray-200 rounded-md bg-white text-gray-400 hover:bg-gray-50 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Bottom Action Bar ── */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-6 pt-4 border-t border-gray-100">
|
||||
<button
|
||||
onClick={() => navigate("/mentor/certificates/all")}
|
||||
className="flex items-center gap-2 text-sm font-bold text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Project Selection
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate("/mentor/certificates/result")}
|
||||
className="flex items-center gap-2 px-8 py-3.5 bg-[#B30000] hover:bg-[#990000] text-white font-bold text-sm rounded-xl shadow-md transition-colors"
|
||||
>
|
||||
Generate Certificates
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { CalendarDays, UploadCloud, CheckCircle2, XCircle, Loader2 } from "lucide-react";
|
||||
import { useUploadCertificateTemplate } from "../../../hooks/useSertificates";
|
||||
import { useProjectDetail } from "../../../hooks/useProjects";
|
||||
import EligibleInternTable from "./components/EligibleInternTable";
|
||||
import { useProjectInternProgress } from "../../../hooks/useInternProgress"
|
||||
|
||||
export default function CertificateDetail() {
|
||||
const navigate = useNavigate();
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { project, loading, error } = useProjectDetail(slug ?? "");
|
||||
const { upload, loading: uploading, error: uploadError } = useUploadCertificateTemplate();
|
||||
const { data: internProgress, loading: progressLoading } = useProjectInternProgress(slug ?? "");
|
||||
const eligibleCount = internProgress.filter(i => i.is_eligible).length;
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [uploadSuccess, setUploadSuccess] = useState(false);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setSelectedFile(file);
|
||||
setUploadSuccess(false);
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile || !project) return;
|
||||
const result = await upload(project.id, selectedFile);
|
||||
if (result) {
|
||||
setUploadSuccess(true);
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20 text-gray-400 text-sm">
|
||||
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
||||
Memuat data project...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !project) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
||||
<XCircle className="w-10 h-10 text-red-400" />
|
||||
<p className="text-gray-500 text-sm">{error ?? "Project tidak ditemukan."}</p>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="text-xs text-[#B30000] font-semibold hover:underline"
|
||||
>
|
||||
Kembali
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="page-title">Certificates</h1>
|
||||
<p className="page-title-desc">
|
||||
Administrate, issue, and track professional certifications for all interns.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-8 gap-6 items-start">
|
||||
{/* ── Left Column ── */}
|
||||
<div className="space-y-4 lg:col-span-3">
|
||||
<h2 className="text-3xl font-extrabold text-gray-900 leading-tight">
|
||||
{project.project_name}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400 font-medium">
|
||||
<CalendarDays className="w-4 h-4 flex-shrink-0" />
|
||||
{new Date(project.start_date).toLocaleDateString("en-US", { month: "short", year: "numeric" })}
|
||||
{" — "}
|
||||
{new Date(project.end_date).toLocaleDateString("en-US", { month: "short", year: "numeric" })}
|
||||
</div>
|
||||
|
||||
{/* ── Upload Template ── */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-gray-700 tracking-wide">
|
||||
Certificate Template
|
||||
</label>
|
||||
|
||||
{/* Drop Zone */}
|
||||
<div
|
||||
className="border-2 border-dashed border-card-outline bg-primary-foreground rounded-xl p-6 text-center flex flex-col items-center justify-center cursor-pointer hover:bg-red-50/20 transition-colors group"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-red-50 flex items-center justify-center mb-2 group-hover:scale-105 transition-transform">
|
||||
<UploadCloud className="w-5 h-5 text-[#B30000]" />
|
||||
</div>
|
||||
{selectedFile ? (
|
||||
<>
|
||||
<p className="text-xs font-bold text-gray-800 truncate max-w-[180px]">
|
||||
{selectedFile.name}
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">
|
||||
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs font-bold text-gray-800">Upload Certificate Image</p>
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">PNG, JPG up to 5MB</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
{/* Feedback */}
|
||||
{uploadSuccess && (
|
||||
<div className="flex items-center gap-1.5 text-green-600 text-xs font-medium">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
Template berhasil diupload!
|
||||
</div>
|
||||
)}
|
||||
{uploadError && (
|
||||
<div className="flex items-center gap-1.5 text-red-600 text-xs font-medium">
|
||||
<XCircle className="w-4 h-4" />
|
||||
{uploadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Button */}
|
||||
{selectedFile && (
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={uploading}
|
||||
className="w-full flex items-center justify-center gap-2 py-2 bg-[#B30000] hover:bg-red-800 disabled:opacity-60 text-white text-xs font-semibold rounded-lg transition-all active:scale-95"
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
Mengupload...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UploadCloud className="w-3.5 h-3.5" />
|
||||
Upload Template
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Project Scope ── */}
|
||||
<div className="border border-card-outline rounded-xl p-5">
|
||||
<p className="text-[13px] font-bold text-gray-400 uppercase tracking-widest mb-3">
|
||||
Project Scope
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">{project.description}</p>
|
||||
</div>
|
||||
|
||||
{/* ── Stats ── */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="border border-card-outline rounded-xl p-4">
|
||||
<p className="text-2xl font-extrabold text-[#B30000]">{project.total_members}</p>
|
||||
<p className="text-xs text-font font-medium mt-0.5">Total Interns</p>
|
||||
</div>
|
||||
<div className="border border-card-outline rounded-xl p-4">
|
||||
<p className="text-2xl font-extrabold text-gray-900">{eligibleCount}</p>
|
||||
<p className="text-xs text-font font-medium mt-0.5">Eligible</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right Column ── */}
|
||||
<EligibleInternTable
|
||||
interns={internProgress}
|
||||
loading={progressLoading}
|
||||
eligibleCount={eligibleCount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,127 +1,18 @@
|
||||
// src/pages/mentor/CertificateList.tsx
|
||||
// src/pages/mentor/CertificateListPage.tsx
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {CalendarDays} from "lucide-react";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
type CertStatus = "COMPLETED" | "PENDING CERTS";
|
||||
|
||||
interface Project {
|
||||
id: number;
|
||||
title: string;
|
||||
period: string;
|
||||
internCount: number;
|
||||
status: CertStatus;
|
||||
}
|
||||
|
||||
// ─── Mock Data ────────────────────────────────────────────────────────────────
|
||||
const mockProjects: Project[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Autonomous Navigation Systems",
|
||||
period: "Jan 2024 - Apr 2024",
|
||||
internCount: 14,
|
||||
status: "COMPLETED",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Full-Stack Fintech Solution",
|
||||
period: "Feb 2024 - June 2024",
|
||||
internCount: 8,
|
||||
status: "PENDING CERTS",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "User Experience Audit 2024",
|
||||
period: "Mar 2024 - May 2024",
|
||||
internCount: 5,
|
||||
status: "PENDING CERTS",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Edge Computing Framework",
|
||||
period: "Dec 2023 - Mar 2024",
|
||||
internCount: 21,
|
||||
status: "COMPLETED",
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Sub Components ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Badge status: COMPLETED (hijau) vs PENDING CERTS (kuning) */
|
||||
function StatusBadge({ status }: { status: CertStatus }) {
|
||||
const isCompleted = status === "COMPLETED";
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Satu card project */
|
||||
function ProjectCard({
|
||||
project,
|
||||
onClick,
|
||||
}: {
|
||||
project: Project;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="bg-white border border-gray-200 rounded-2xl p-5 cursor-pointer hover:shadow-md hover:border-gray-300 transition-all duration-200 flex flex-col gap-4"
|
||||
>
|
||||
{/* Top: icon + status */}
|
||||
<div className="flex items-start justify-between">
|
||||
<StatusBadge status={project.status} />
|
||||
</div>
|
||||
|
||||
{/* Title + period */}
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-gray-900 leading-snug mb-1.5">
|
||||
{project.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-gray-400 font-medium">
|
||||
<CalendarDays className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span>{project.period}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-gray-100" />
|
||||
|
||||
{/* Intern count + avatars */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[10px] text-gray-400 font-medium mb-0.5">Interns</p>
|
||||
<p className="text-sm font-extrabold text-[#B30000]">
|
||||
{project.internCount} {project.internCount === 1 ? "intern" : "Interns"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
import CertificateProjectCard from "./components/CertificateProjectCard";
|
||||
import { useProjects } from "../../../hooks/useProjects";
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
export default function CertificateList() {
|
||||
const navigate = useNavigate();
|
||||
const { projects, loading, error } = useProjects();
|
||||
|
||||
const handleCardClick = (id: number) => {
|
||||
navigate(`/mentor/certificates/${id}`);
|
||||
const handleCardClick = (slug: string) => {
|
||||
navigate(`/mentor/certificates/${slug}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-1 text-xs font-medium text-[#B30000]">
|
||||
Certificates ›
|
||||
</div>
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="page-title">Certificates</h1>
|
||||
@@ -132,14 +23,14 @@ export default function CertificateList() {
|
||||
|
||||
{/* Section: List Projects */}
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-gray-900 mb-4">List Projects</h2>
|
||||
<h2 className="text-base font-bold text-font-shade mb-4">List Projects</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{mockProjects.map((project) => (
|
||||
<ProjectCard
|
||||
{projects.map((project) => (
|
||||
<CertificateProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
onClick={() => handleCardClick(project.id)}
|
||||
onClick={() => handleCardClick(project.slug)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { LuHistory, LuSendHorizontal } from 'react-icons/lu';
|
||||
|
||||
// Mock Data untuk Tabel Riwayat Sertifikat
|
||||
const initialHistoryData = [
|
||||
{ id: 1, name: "Jonathan Kristina", email: "JonathanKristina@gmail.com", project: "Internify Project", date: "24 Oct 2024", initials: "JK" },
|
||||
{ id: 2, name: "Ahmad Faisal", email: "ahmad.faisal@gmail.com", project: "Web Dev Fundamentals", date: "15 Sep 2024", initials: "AF" },
|
||||
@@ -20,7 +19,6 @@ const initialHistoryData = [
|
||||
];
|
||||
|
||||
const MentorCertificatePage = () => {
|
||||
// State Form Management
|
||||
const [selectedIntern, setSelectedIntern] = useState("JonathanKristina@gmail.com");
|
||||
const [selectedProject, setSelectedProject] = useState("Internify Project (Web Development)");
|
||||
const [issueDate, setIssueDate] = useState("2024-10-24");
|
||||
@@ -49,17 +47,13 @@ const MentorCertificatePage = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Title */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-gray-900 tracking-tight">Certificates</h1>
|
||||
<p className="text-sm md:text-base text-gray-500 mt-1">
|
||||
Administrate, issue, and track professional certifications for all interns.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Main Grid Content */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 items-start">
|
||||
{/* Kiri: Form Issue New Certificate (4 Kolom) */}
|
||||
<div className="bg-white border border-gray-100 rounded-2xl p-6 md:p-8 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<PlusCircle className="w-6 h-6 text-[#B30000]" />
|
||||
@@ -67,7 +61,6 @@ const MentorCertificatePage = () => {
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleIssueCertificate} className="space-y-5">
|
||||
{/* Select Intern */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-gray-700 tracking-wide">Select Intern</label>
|
||||
<select
|
||||
@@ -81,7 +74,6 @@ const MentorCertificatePage = () => {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Select Project / Course */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-gray-700 tracking-wide">Select Project / Course</label>
|
||||
<select
|
||||
@@ -94,8 +86,6 @@ const MentorCertificatePage = () => {
|
||||
<option value="UI/UX Essentials">UI/UX Essentials</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Certificate Image Upload Area */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-gray-700 tracking-wide">Certificate Image</label>
|
||||
<div className="border-2 border-dashed border-red-200/60 bg-red-50/10 rounded-xl p-6 text-center flex flex-col items-center justify-center cursor-pointer hover:bg-red-50/20 transition-colors group">
|
||||
|
||||
-8
@@ -1,6 +1,5 @@
|
||||
// src/pages/mentor/CertificateResult.tsx
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
CheckCircle2,
|
||||
FolderArchive,
|
||||
@@ -55,7 +54,6 @@ function Avatar({ initials }: { initials: string }) {
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
export default function CertificateResult() {
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
@@ -75,12 +73,6 @@ export default function CertificateResult() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-1 text-xs font-medium text-[#B30000] cursor-pointer w-fit"
|
||||
onClick={() => navigate("/mentor/certificates/all")}>
|
||||
Certificates ›
|
||||
</div>
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="page-title">Certificates</h1>
|
||||
@@ -8,7 +8,7 @@ const HomeMentorPage = () => {
|
||||
<HomeHeader />
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="">
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<div className="bg-white border border-box-border rounded-xl p-6">
|
||||
<HomeMentorProject />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,32 +1,55 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { Search, SlidersHorizontal, Plus, Trash2 } from "lucide-react";
|
||||
import { mockInternsData } from "../../../lib/mockData";
|
||||
import { useAllInterns, useRemoveMember, useProjects } from "../../../hooks/useProjects";
|
||||
import AddInternsModal from "./components/AddInternsToProjects";
|
||||
|
||||
export default function InternsManagement() {
|
||||
// State Filter & Search
|
||||
const [searchEmail, setSearchEmail] = useState("");
|
||||
const [selectedProject, setSelectedProject] = useState("All Projects");
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
// Handler Aksi Kursor/Tombol
|
||||
const handleAddAssignment = (id: number) => {
|
||||
alert(`Assign/Add action clicked for intern ID: ${id}`);
|
||||
};
|
||||
|
||||
const handleDeleteIntern = (id: number) => {
|
||||
alert(`Deleted intern ID: ${id}`);
|
||||
};
|
||||
const { interns, loading, error, refetch } = useAllInterns();
|
||||
const { remove, loading: removing } = useRemoveMember();
|
||||
const { projects: activeProjects } = useProjects("active");
|
||||
|
||||
// Logic Filtering Data menggunakan useMemo
|
||||
// Filter data berdasarkan email dan nama project
|
||||
const filteredInterns = useMemo(() => {
|
||||
return mockInternsData.filter((intern) => {
|
||||
return interns.filter((intern) => {
|
||||
const matchesEmail = intern.email.toLowerCase().includes(searchEmail.toLowerCase());
|
||||
const matchesProject =
|
||||
selectedProject === "All Projects" ||
|
||||
intern.projectName.toLowerCase() === selectedProject.toLowerCase();
|
||||
(intern.projectName || "Unassigned").toLowerCase() === selectedProject.toLowerCase();
|
||||
return matchesEmail && matchesProject;
|
||||
});
|
||||
}, [searchEmail, selectedProject]);
|
||||
}, [interns, searchEmail, selectedProject]);
|
||||
|
||||
// Hitung statistik dari data asli
|
||||
const totalInterns = interns.length;
|
||||
// Active assignments = total member dari semua project yang statusnya active
|
||||
const activeAssignments = activeProjects.length;
|
||||
|
||||
// Handler hapus intern dari project
|
||||
// Catatan: useRemoveMember memerlukan id_project & id_user.
|
||||
// Karena InternDetail tidak menyimpan id_project, aksi ini saat ini hanya placeholder.
|
||||
// Untuk implementasi penuh, perlu endpoint khusus atau data id_project dari InternDetail.
|
||||
const handleDeleteIntern = async (internId: number) => {
|
||||
const confirmed = window.confirm("Yakin ingin menghapus intern ini dari project?");
|
||||
if (!confirmed) return;
|
||||
alert(`Remove intern ID ${internId} — perlu id_project dari backend untuk dilanjutkan.`);
|
||||
// Contoh penggunaan jika id_project tersedia:
|
||||
// const res = await remove({ id_project: intern.id_project, id_user: internId });
|
||||
// if (res !== null) refetch();
|
||||
};
|
||||
|
||||
if (loading) return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-16 bg-gray-100 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (error) return <p className="text-red-500 text-sm">{error}</p>;
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
@@ -64,8 +87,6 @@ export default function InternsManagement() {
|
||||
className="w-full pl-3 pr-8 py-2 bg-gray-100 border border-transparent rounded-lg text-sm text-gray-700 font-medium appearance-none focus:outline-none focus:bg-white focus:border-gray-200"
|
||||
>
|
||||
<option value="All Projects">All Projects</option>
|
||||
<option value="Internify Project">Internify Project</option>
|
||||
<option value="MBG Project">MBG Project</option>
|
||||
<option value="Unassigned">Unassigned</option>
|
||||
</select>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-500">
|
||||
@@ -81,17 +102,21 @@ export default function InternsManagement() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right Info Box Stats */}
|
||||
{/* Right Info Box Stats — data real dari API */}
|
||||
<div className="bg-white border border-gray-100 rounded-xl px-6 py-2.5 flex items-center justify-around text-center shadow-[0_2px_10px_rgba(0,0,0,0.01)] w-full lg:max-w-sm ml-auto">
|
||||
<div>
|
||||
<span className="block text-xl font-bold text-red-600 leading-none">124</span>
|
||||
<span className="block text-xl font-bold text-red-600 leading-none">
|
||||
{totalInterns}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-gray-400 tracking-wider uppercase mt-1 block">
|
||||
TOTAL INTERNS
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-8 w-px bg-gray-100 mx-2"></div>
|
||||
<div>
|
||||
<span className="block text-xl font-bold text-green-600 leading-none">92</span>
|
||||
<span className="block text-xl font-bold text-green-600 leading-none">
|
||||
{activeAssignments}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-gray-400 tracking-wider uppercase mt-1 block">
|
||||
ACTIVE ASSIGNMENTS
|
||||
</span>
|
||||
@@ -100,7 +125,10 @@ export default function InternsManagement() {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button className="inline-flex items-center gap-5 px-3 py-1 bg-red-700 text-white rounded-xl shadow-sm hover:bg-red-800 transition-colors focus:outline-none focus:ring-2 focus:ring-red-300" onClick={() => setIsModalOpen(true)}>
|
||||
<button
|
||||
className="inline-flex items-center gap-2 px-3 py-1 bg-red-700 text-white rounded-xl shadow-sm hover:bg-red-800 transition-colors focus:outline-none focus:ring-2 focus:ring-red-300"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add
|
||||
</button>
|
||||
@@ -120,7 +148,7 @@ export default function InternsManagement() {
|
||||
<div className="divide-y divide-gray-100">
|
||||
{filteredInterns.length > 0 ? (
|
||||
filteredInterns.map((intern, index) => {
|
||||
const isUnassigned = intern.projectName === "Unassigned";
|
||||
const isUnassigned = !intern.projectName || intern.projectName === "Unassigned";
|
||||
|
||||
return (
|
||||
<div key={`${intern.id}-${index}`} className="px-6 py-4 flex items-center hover:bg-gray-50/60 transition-colors">
|
||||
@@ -147,7 +175,7 @@ export default function InternsManagement() {
|
||||
{/* Column 2: Current Project Title & Role Accent */}
|
||||
<div className="w-1/3">
|
||||
<h5 className={`text-sm font-semibold leading-tight ${isUnassigned ? 'text-gray-500 italic' : 'text-gray-900'}`}>
|
||||
{intern.projectName}
|
||||
{intern.projectName || "Unassigned"}
|
||||
</h5>
|
||||
<p className={`text-xs font-bold mt-0.5 ${isUnassigned ? 'text-red-500' : 'text-red-600'}`}>
|
||||
{intern.role}
|
||||
@@ -156,15 +184,20 @@ export default function InternsManagement() {
|
||||
|
||||
{/* Column 3: Action Buttons Block Right aligned */}
|
||||
<div className="w-1/6 flex items-center justify-end gap-3.5 pr-2">
|
||||
{/* Tombol Add: membuka modal assign member */}
|
||||
<button
|
||||
onClick={() => handleAddAssignment(intern.id)}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="p-1.5 bg-[#B30000] hover:bg-[#990000] text-white rounded-lg transition-colors shadow-sm"
|
||||
title="Assign ke project"
|
||||
>
|
||||
<Plus className="w-4 h-4 stroke-[3]" />
|
||||
</button>
|
||||
{/* Tombol Delete: remove intern dari project */}
|
||||
<button
|
||||
onClick={() => handleDeleteIntern(intern.id)}
|
||||
className="p-1.5 text-red-500 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors"
|
||||
disabled={removing}
|
||||
className="p-1.5 text-red-500 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-50"
|
||||
title="Hapus dari project"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 stroke-[2]" />
|
||||
</button>
|
||||
@@ -183,7 +216,7 @@ export default function InternsManagement() {
|
||||
{/* Red Table Pagination Bar Footer */}
|
||||
<div className="bg-[#B30000] text-white px-6 py-3.5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 text-xs font-medium">
|
||||
<div>
|
||||
Showing <span className="font-bold">{filteredInterns.length}</span> of 124 interns
|
||||
Showing <span className="font-bold">{filteredInterns.length}</span> of {totalInterns} interns
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 self-end sm:self-auto">
|
||||
@@ -196,10 +229,14 @@ export default function InternsManagement() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isModalOpen &&
|
||||
<AddInternsModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onClose={() => {
|
||||
setIsModalOpen(false);
|
||||
refetch(); // Refresh data setelah modal assign ditutup
|
||||
}}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,33 +1,41 @@
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { mockProjects } from "../../../lib/mockProjects";
|
||||
import { Calendar, Edit, SlidersHorizontal, Search } from "lucide-react";
|
||||
import { Calendar, Edit } from "lucide-react";
|
||||
import EditSubmissionModal from "./components/EditSubmissionTask";
|
||||
|
||||
import { useTaskSubmissions } from "../../../hooks/useTasks";
|
||||
|
||||
const toSlug = (name: string) => name.toLowerCase().replace(/\s+/g, '-');
|
||||
|
||||
export default function MentorProjectsDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const { slug, taskSlug } = useParams<{ slug: string; taskSlug: string }>();
|
||||
const targetedProject = mockProjects.find((p) => toSlug(p.name) === slug);
|
||||
|
||||
if (!targetedProject) {
|
||||
return <div className="p-10 text-[#888]">Project tidak ditemukan.</div>;
|
||||
}
|
||||
|
||||
const targetedTask = targetedProject.tasks.find((t) => toSlug(t.title) === taskSlug);
|
||||
if (!targetedTask) {
|
||||
return <div className="p-10 text-[#888]">Tugas tidak ditemukan.</div>;
|
||||
}
|
||||
|
||||
const { task, loading, error } = useTaskSubmissions(taskSlug!, slug);
|
||||
const [openModal, setOpenModal] = useState(false);
|
||||
if (loading) return (
|
||||
<div className="space-y-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-32 bg-box-secondary rounded-2xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
if (error) return <p className="text-red-500 text-sm">{error}</p>;
|
||||
if (!task) return null;
|
||||
|
||||
const formatDeadline = (isoString: string) => {
|
||||
const date = new Date(isoString);
|
||||
const dd = String(date.getDate()).padStart(2, '0');
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const yy = String(date.getFullYear()).slice(-2);
|
||||
const hh = String(date.getHours()).padStart(2, '0');
|
||||
const min = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${dd}/${mm}/${yy} pada jam ${hh}:${min}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header Utama */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-3xl font-bold text-[#1a1a1a]">{targetedTask.title}</h1>
|
||||
<h1 className="text-3xl font-bold text-[#1a1a1a]">{task.title}</h1>
|
||||
<button
|
||||
className="flex items-center gap-2 border border-gray-400 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium hover:bg-gray-50 transition-all"
|
||||
onClick={() => setOpenModal(true)}
|
||||
@@ -42,7 +50,7 @@ export default function MentorProjectsDetailPage() {
|
||||
<div className="flex-1 md:border-r border-gray-200 md:pr-6">
|
||||
<h2 className="text-xs font-sans text-gray-400 tracking-wider uppercase mb-2">Description</h2>
|
||||
<p className="text-sm text-gray-700 leading-relaxed whitespace-pre-line">
|
||||
{targetedTask.description}
|
||||
{task.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full md:w-120 flex flex-col justify-start">
|
||||
@@ -52,8 +60,7 @@ export default function MentorProjectsDetailPage() {
|
||||
<Calendar size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-bold text-gray-900">{targetedTask.deadline.label}</div>
|
||||
<div className="text-xs text-gray-400">at {targetedTask.deadline.time}</div>
|
||||
<div className="text-base font-bold text-gray-900">{formatDeadline(task.deadline_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,13 +73,9 @@ export default function MentorProjectsDetailPage() {
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-xl font-bold text-gray-900">Intern Submissions</h2>
|
||||
<span className="bg-gray-100 text-gray-500 font-semibold text-xs px-2.5 py-0.5 rounded-md border border-gray-200">
|
||||
{targetedTask.submissions?.length || 0} total
|
||||
{task.submissions?.length || 0} total
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-gray-500">
|
||||
<button className="hover:text-gray-800"><SlidersHorizontal size={18} /></button>
|
||||
<button className="hover:text-gray-800"><Search size={18} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Isi Tabel */}
|
||||
@@ -87,37 +90,37 @@ export default function MentorProjectsDetailPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{targetedTask.submissions?.map((sub, idx) => {
|
||||
// Cari relasi profil lengkap intern lewat email
|
||||
const internProfile = targetedProject.interns.find(i => i.email === sub.internEmail);
|
||||
if (!internProfile) return null;
|
||||
|
||||
{task.submissions.map((sub, idx) => {
|
||||
return (
|
||||
<tr key={idx} className="hover:bg-gray-50/50 transition-colors">
|
||||
{/* Kolom Nama & Profil */}
|
||||
<td className="px-6 py-4 flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-red-800 text-gray-500 font-semibold text-xs flex items-center justify-center border border-gray-300" />
|
||||
<span className="font-semibold text-sm text-gray-800">{internProfile.name}</span>
|
||||
{sub.profile_picture
|
||||
? <img src={sub.profile_picture} className="w-9 h-9 rounded-full object-cover" />
|
||||
: <div className="w-9 h-9 rounded-full bg-red-800 flex-shrink-0" />
|
||||
}
|
||||
<span className="font-semibold text-sm text-gray-800">{sub.full_name}</span>
|
||||
</td>
|
||||
|
||||
{/* Kolom Tanggal Kumpul */}
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
{sub.submittedAt || "-"}
|
||||
{sub.submitted_at
|
||||
? new Date(sub.submitted_at).toLocaleDateString("id-ID")
|
||||
: "-"}
|
||||
</td>
|
||||
|
||||
{/* Kolom Badge Status */}
|
||||
<td className="px-6 py-4">
|
||||
{sub.status === "Done" && (
|
||||
{sub.display_status === "done" && (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 text-xs font-semibold rounded-full bg-green-50 text-green-700 border border-green-200">
|
||||
<span className="w-1.5 h-1.5 bg-green-500 rounded-full"></span> Done
|
||||
</span>
|
||||
)}
|
||||
{sub.status === "Pending" && (
|
||||
{sub.display_status === "pending" && (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 text-xs font-semibold rounded-full bg-amber-50 text-amber-700 border border-amber-200">
|
||||
<span className="w-1.5 h-1.5 bg-amber-500 rounded-full"></span> Pending
|
||||
</span>
|
||||
)}
|
||||
{sub.status === "Overdue" && (
|
||||
{sub.display_status === "overdue" && (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 text-xs font-semibold rounded-full bg-red-700 text-white border">
|
||||
<span className="w-1.5 h-1.5 bg-white rounded-full"></span> Overdue
|
||||
</span>
|
||||
@@ -130,8 +133,8 @@ export default function MentorProjectsDetailPage() {
|
||||
className="text-sm font-bold text-red-800 hover:text-red-900 transition-colors inline-flex items-center gap-1"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/mentor/projects/${toSlug(targetedProject.name)}/${toSlug(targetedTask.title)}/${sub.internEmail.split("@")[0]}`,
|
||||
{ state: { status: sub.status === "Done" ? "submitted" : sub.status === "Overdue" ? "overdue" : "pending" } }
|
||||
`/mentor/projects/${slug}/${taskSlug}/${toSlug(sub.full_name)}`,
|
||||
{ state: { status: sub.display_status === "done" ? "submitted" : sub.display_status === "overdue" ? "overdue" : "pending" } }
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -145,8 +148,6 @@ export default function MentorProjectsDetailPage() {
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EditSubmissionModal sekarang berada di dalam induk div utama */}
|
||||
{openModal && (
|
||||
<EditSubmissionModal
|
||||
isOpen={openModal}
|
||||
|
||||
@@ -1,30 +1,44 @@
|
||||
import { useState, useRef } from "react";
|
||||
|
||||
const mockProfileData = {
|
||||
fullName: "JonathanKristina",
|
||||
email: "JonathanKristina@gmail.com",
|
||||
bio: "UIUX holic"
|
||||
};
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useCurrentUser, useUpdateProfile } from "../../../hooks/useUser";
|
||||
|
||||
export default function MentorSettingsPage() {
|
||||
const [formData, setFormData] = useState(mockProfileData);
|
||||
const { user, loading: userLoading } = useCurrentUser();
|
||||
const { save, loading: saving, error: errorMsg, successMsg } = useUpdateProfile();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
fullName: "",
|
||||
email: "",
|
||||
bio: "",
|
||||
});
|
||||
|
||||
// File state untuk profile_picture
|
||||
const [photoFile, setPhotoFile] = useState<File | null>(null);
|
||||
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Sync form data ketika user sudah di-fetch
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setFormData({
|
||||
fullName: user.full_name || "",
|
||||
email: user.email || "",
|
||||
bio: user.professional_bio || "",
|
||||
});
|
||||
if (user.profile_picture) {
|
||||
setPhotoPreview(user.profile_picture);
|
||||
}
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSaveChanges = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
alert("Changes saved successfully!");
|
||||
console.log("Saved Data:", formData);
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -32,12 +46,35 @@ export default function MentorSettingsPage() {
|
||||
};
|
||||
|
||||
const handleRemovePhoto = () => {
|
||||
setPhotoFile(null);
|
||||
setPhotoPreview(null);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const initialLetter = formData.fullName ? formData.fullName.charAt(0).toUpperCase() : "U";
|
||||
|
||||
if (userLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-40 text-sm text-gray-400">
|
||||
Loading profile...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header Title */}
|
||||
@@ -47,6 +84,7 @@ export default function MentorSettingsPage() {
|
||||
Manage and edit your profile and account information.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* White Card Container */}
|
||||
<div className="w-full bg-white rounded-xl border border-box-border shadow-lg overflow-hidden">
|
||||
|
||||
@@ -91,6 +129,11 @@ export default function MentorSettingsPage() {
|
||||
<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"
|
||||
@@ -166,13 +209,26 @@ export default function MentorSettingsPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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"
|
||||
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={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 disabled:opacity-60"
|
||||
>
|
||||
Save Changes
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
import type { Project } from '../../../lib/mockProjects';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import ProjectTimeline from '../intern/components/ProjectTimeline';
|
||||
import HeroProject from '../intern/components/Hero-project';
|
||||
import MentorTaskCard from './components/MentorTaskCard';
|
||||
import { useProjectTasks } from '../../../hooks/useTasks';
|
||||
import type { Project } from '../../../types/project.types';
|
||||
|
||||
export default function MentorForumTab({ project }: { project: Project }) {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { tasks, loading, error } = useProjectTasks(slug ?? "");
|
||||
return (
|
||||
<div>
|
||||
{/* Hero banner */}
|
||||
<HeroProject title={project.name} description={project.description} />
|
||||
<HeroProject title={project.project_name} description={project.description} />
|
||||
|
||||
{/* Timeline + Task list */}
|
||||
<div className="flex gap-7 items-start">
|
||||
<div className="w-1/5 bg-white rounded-xl p-5 shadow-md shrink-0">
|
||||
<ProjectTimeline tasks={project.tasks} />
|
||||
<div className="flex flex-col gap-7 md:flex-row md:items-start">
|
||||
<div className="w-full md:w-1/5 bg-white rounded-xl p-5 border border-box-border shrink-0">
|
||||
<ProjectTimeline tasks={tasks} />
|
||||
</div>
|
||||
|
||||
<div className="w-4/5">
|
||||
{project.tasks.map((task, idx) => (
|
||||
<MentorTaskCard
|
||||
key={idx}
|
||||
task={task}
|
||||
projectName={project.name} />
|
||||
))}
|
||||
<div className="w-full border-t border-box-border md:border-t-0 pt-4 md:pt-0 md:w-4/5">
|
||||
{tasks.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">Belum ada task.</p>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<MentorTaskCard key={task.id} task={task} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { useState } from 'react';
|
||||
import type { Project, Intern } from '../../../lib/mockProjects';
|
||||
import { Plus } from 'lucide-react';
|
||||
import ManageInternsModal from './components/AddInternsDialog';
|
||||
import type { ProjectDetail, ProjectMember } from '../../../types/project.types';
|
||||
import { useRemoveMember } from '../../../hooks/useProjects';
|
||||
|
||||
export default function InternsTab({ project }: { project: Project }) {
|
||||
export default function InternsTab({ project }: { project: ProjectDetail }) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
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();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -15,8 +27,17 @@ export default function InternsTab({ project }: { project: Project }) {
|
||||
</h3>
|
||||
<div className="border-b border-gray-300 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{project.admin.profile_picture ? (
|
||||
<img
|
||||
src={project.admin.profile_picture}
|
||||
alt={project.admin.full_name}
|
||||
className="w-8 h-8 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-gray-200" />
|
||||
)}
|
||||
<span className=" text-sm text-[#333]">
|
||||
{project.mentor}
|
||||
{project.admin.full_name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -37,22 +58,26 @@ export default function InternsTab({ project }: { project: Project }) {
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{project.interns.map((intern: Intern, idx: number) => (
|
||||
{project.members.map((member: ProjectMember, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`flex items-center justify-between py-3 ${idx < project.interns.length - 1 ? 'border-b border-gray-300' : ''
|
||||
className={`flex items-center justify-between py-3 ${idx < project.members.length - 1 ? 'border-b border-gray-300' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className=" text-sm text-[#333]">
|
||||
{intern.name}
|
||||
{member.email}
|
||||
</span>
|
||||
</div>
|
||||
<span className=" text-[13px] text-[#555] font-semibold">
|
||||
{intern.role}
|
||||
{member.position}
|
||||
</span>
|
||||
<div>
|
||||
<button className='flex items-center gap-1 text-[14px] bg-red-600 text-white p-1 rounded-xl'>
|
||||
<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'
|
||||
>
|
||||
<Plus className='w-4 h-4'/>Delete
|
||||
</button>
|
||||
</div>
|
||||
@@ -61,7 +86,9 @@ export default function InternsTab({ project }: { project: Project }) {
|
||||
</div>
|
||||
{isModalOpen && <ManageInternsModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
projectId={project.id}
|
||||
initialMembers={project.members}
|
||||
/>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { mockProjects } from '../../../lib/mockProjects';
|
||||
import { useProjectDetail } from '../../../hooks/useProjects';
|
||||
import MentorForumTab from './ProjectForumTab';
|
||||
import InternsTab from './ProjectsInternTab';
|
||||
import TaskTab from './TaskTab';
|
||||
|
||||
const toSlug = (name: string) => name.toLowerCase().replace(/\s+/g, '-');
|
||||
|
||||
export default function ProjectDetailPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const [activeTab, setActiveTab] = useState<'forum' | 'participants' | 'Task'>('forum');
|
||||
const { project, loading, error } = useProjectDetail(slug ?? "");
|
||||
|
||||
const project = mockProjects.find((p) => toSlug(p.name) === slug);
|
||||
if (loading) return <p className="p-10 text-gray-400">Memuat project...</p>;
|
||||
if (error) return <p className="p-10 text-red-500">{error}</p>;
|
||||
if (!project) return <p className="p-10 text-gray-400">Project tidak ditemukan.</p>
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
|
||||
@@ -1,28 +1,36 @@
|
||||
import type { Project, Task } from '../../../lib/mockData';
|
||||
import { useProjectTasks, useCreateTask, 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';
|
||||
|
||||
export default function TaskTab({ project }: { project: Project }) {
|
||||
|
||||
export default function TaskTab({ project }: { project: ProjectDetail }) {
|
||||
const { tasks, loading, error, refetch } = useProjectTasks(String(project.id));
|
||||
const { remove } = useDeleteTask();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [selectedTask, setSelectedTask] = useState<ProjectTask | null>(null);
|
||||
|
||||
const handleEditTask = (taskTitle: string) => {
|
||||
console.log('Edit task:', taskTitle);
|
||||
const handleDeleteTask = async (taskId: number) => {
|
||||
const ok = await remove(taskId, String(project.id));
|
||||
if (ok) refetch();
|
||||
};
|
||||
|
||||
const handleDeleteTask = (taskTitle: string) => {
|
||||
console.log('Delete task:', taskTitle);
|
||||
};
|
||||
if (loading) return (
|
||||
<div className="flex flex-col gap-3 p-6">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-12 bg-gray-100 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
if (error) return <p className="text-red-500 text-sm p-6">{error}</p>;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm border border-gray-100 min-h-[400px]">
|
||||
{/* Header Section */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-3xl font-bold text-slate-800 tracking-tight">Tasks</h2>
|
||||
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="bg-[#C0392B] hover:bg-[#A93226] text-white px-6 py-2.5 rounded-full font-bold text-sm transition-colors shadow-sm"
|
||||
@@ -30,39 +38,31 @@ export default function TaskTab({ project }: { project: Project }) {
|
||||
Add Task
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr className="border-gray-200/80 mb-4" />
|
||||
|
||||
{/* Task List Section */}
|
||||
<div className="flex flex-col">
|
||||
{project.tasks && project.tasks.length > 0 ? (
|
||||
project.tasks.map((task: Task, idx: number) => (
|
||||
{tasks.length > 0 ? (
|
||||
tasks.map((task, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`flex items-center justify-between py-4 group ${idx < project.tasks.length - 1 ? 'border-b border-gray-100' : ''
|
||||
}`}
|
||||
key={task.id}
|
||||
className={`flex items-center justify-between py-4 group ${
|
||||
idx < tasks.length - 1 ? 'border-b border-gray-100' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Task Title & Icon */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<ClipboardList size={22} className="text-slate-600 shrink-0" />
|
||||
<span className="text-base text-slate-700 font-medium truncate">
|
||||
{task.title}
|
||||
</span>
|
||||
<span className="text-base text-slate-700 font-medium truncate">{task.title}</span>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons (Edit & Delete) */}
|
||||
<div className="flex items-center gap-4 text-slate-600 shrink-0">
|
||||
<button
|
||||
onClick={() => setEditModalOpen(true)}
|
||||
onClick={() => { setSelectedTask(task); setEditModalOpen(true); }}
|
||||
className="hover:text-blue-600 transition-colors p-1"
|
||||
title="Edit Task"
|
||||
>
|
||||
<Pencil size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteTask(task.title)}
|
||||
onClick={() => handleDeleteTask(task.id)}
|
||||
className="hover:text-red-600 transition-colors p-1"
|
||||
title="Delete Task"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
@@ -75,18 +75,23 @@ export default function TaskTab({ project }: { project: Project }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isModalOpen &&
|
||||
|
||||
{isModalOpen && (
|
||||
<CreateTaskModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
projectId={String(project.id)}
|
||||
onSuccess={() => { setIsModalOpen(false); refetch(); }}
|
||||
/>
|
||||
}
|
||||
{editModalOpen &&
|
||||
)}
|
||||
{editModalOpen && selectedTask && (
|
||||
<EditTaskModal
|
||||
isOpen={editModalOpen}
|
||||
onClose={() => setEditModalOpen(false)}
|
||||
onClose={() => { setEditModalOpen(false); setSelectedTask(null); }}
|
||||
task={selectedTask}
|
||||
onSuccess={() => { setEditModalOpen(false); setSelectedTask(null); refetch(); }}
|
||||
/>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,144 +1,90 @@
|
||||
import { useParams, useLocation } from "react-router-dom";
|
||||
import { FileText, Download, Clock, CheckCircle2, AlertCircle, MinusCircle } from "lucide-react";
|
||||
import { mockProjects } from "../../../lib/mockProjects";
|
||||
|
||||
const toSlug = (name: string) => name.toLowerCase().replace(/\s+/g, "-");
|
||||
|
||||
type SubmissionStatus = "submitted" | "pending" | "overdue";
|
||||
|
||||
// ─── status badge ───────────────────────────────────────────────────────────
|
||||
|
||||
function StatusBadge({ status }: { status: SubmissionStatus }) {
|
||||
if (status === "submitted")
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-semibold text-green-700 bg-green-50 border border-green-200 px-2.5 py-1 rounded-full">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
Submitted for grading
|
||||
</span>
|
||||
);
|
||||
if (status === "overdue")
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-semibold text-red-700 bg-red-50 border border-red-200 px-2.5 py-1 rounded-full">
|
||||
<AlertCircle className="w-3.5 h-3.5" />
|
||||
Overdue
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-semibold text-gray-500 bg-gray-100 border border-gray-200 px-2.5 py-1 rounded-full">
|
||||
<MinusCircle className="w-3.5 h-3.5" />
|
||||
Not submitted
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── status table ────────────────────────────────────────────────────────────
|
||||
|
||||
function StatusTable({ status }: { status: SubmissionStatus }) {
|
||||
const rows: { label: string; value: React.ReactNode }[] = [
|
||||
{
|
||||
label: "Submission status",
|
||||
value: <StatusBadge status={status} />,
|
||||
},
|
||||
{
|
||||
label: "Grading status",
|
||||
value: <span className="text-sm text-gray-400">-</span>,
|
||||
},
|
||||
{
|
||||
label: "Time Remaining",
|
||||
value:
|
||||
status === "overdue" ? (
|
||||
<span className="text-sm text-red-600 font-medium">Overdue</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">-</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "File submission",
|
||||
value:
|
||||
status === "submitted" ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-800">
|
||||
<FileText className="w-4 h-4 text-red-700 stroke-[2.5]" />
|
||||
<span className="text-gray-400 italic">-</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">-</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-box-secondary border border-box-border rounded-2xl overflow-hidden">
|
||||
<div className="divide-y divide-box-border">
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className="grid grid-cols-[180px_1fr]">
|
||||
<div className="px-4 py-3 text-sm text-gray-500 border-r border-box-border bg-gray-50/50">
|
||||
{row.label}
|
||||
</div>
|
||||
<div className="px-4 py-3">{row.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── main page ───────────────────────────────────────────────────────────────
|
||||
import { Download, Clock, AlertCircle } from "lucide-react";
|
||||
import { useTaskSubmissions } from "../../../hooks/useTasks";
|
||||
import type { DisplayStatus } from "../../../types/task.types";
|
||||
import { StatusTable } from "./components/StatusSubmissionTable";
|
||||
|
||||
export default function ViewInternSubmission() {
|
||||
const { slug, taskSlug, internName } = useParams<{ slug: string; taskSlug: string; internName: string }>();
|
||||
const location = useLocation();
|
||||
const status: SubmissionStatus = location.state?.status ?? "pending";
|
||||
const { slug, taskSlug, nameIntern } = useParams<{
|
||||
slug: string;
|
||||
taskSlug: string;
|
||||
nameIntern: string;
|
||||
}>();
|
||||
const location = useLocation();
|
||||
const status: DisplayStatus = location.state?.status ?? "pending";
|
||||
|
||||
const project = mockProjects.find((p) => toSlug(p.name) === slug);
|
||||
const task = project?.tasks.find((t) => toSlug(t.title) === taskSlug);
|
||||
|
||||
if (!task) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 gap-3 text-gray-400">
|
||||
<AlertCircle className="w-10 h-10" />
|
||||
<p className="text-sm font-medium">Task not found.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const { task, loading, error } = useTaskSubmissions(taskSlug!, slug);
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="w-full bg-box-secondary rounded-2xl border border-box-border p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">
|
||||
{task.title}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 flex items-center gap-1.5">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
Due {task.deadline.label} at {task.deadline.time}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task description */}
|
||||
<div className="w-full bg-box-secondary rounded-2xl border border-box-border p-6 space-y-3">
|
||||
<h2 className="text-base font-bold text-gray-800">Task Description</h2>
|
||||
<p className="text-sm text-foreground font-medium leading-relaxed">
|
||||
{task.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status table */}
|
||||
<div className="w-full bg-box-secondary rounded-2xl border border-box-border p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-bold text-gray-800">Submission Detail</h2>
|
||||
{status === "submitted" && (
|
||||
<button className="flex items-center gap-2 bg-primary text-white text-xs font-bold px-4 py-2 rounded-xl hover:bg-red-800 transition-colors">
|
||||
<Download className="w-4 h-4" />
|
||||
Download
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<StatusTable status={status} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-32 bg-box-secondary rounded-2xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (error) return <p className="text-red-500 text-sm">{error}</p>;
|
||||
if (!task) return null;
|
||||
|
||||
const toSlug = (name: string) => name.toLowerCase().replace(/\s+/g, '-');
|
||||
|
||||
const internSubmission = task.submissions.find(
|
||||
(s) => toSlug(s.full_name) === nameIntern
|
||||
);
|
||||
|
||||
if (!internSubmission)
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 gap-3 text-gray-400">
|
||||
<AlertCircle className="w-10 h-10" />
|
||||
<p className="text-sm font-medium">Submission not found.</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="w-full bg-box-secondary rounded-2xl border border-box-border p-6">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">
|
||||
{task.title}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 flex items-center gap-1.5">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{internSubmission.full_name} — {internSubmission.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task Description */}
|
||||
<div className="w-full bg-box-secondary rounded-2xl border border-box-border p-6 space-y-3">
|
||||
<h2 className="text-base font-bold text-gray-800">Task Description</h2>
|
||||
<p className="text-sm text-foreground font-medium leading-relaxed">
|
||||
{task.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Submission Detail */}
|
||||
<div className="w-full bg-box-secondary rounded-2xl border border-box-border p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-bold text-gray-800">Submission Detail</h2>
|
||||
{internSubmission.submission?.file_path && (
|
||||
<a
|
||||
href={internSubmission.submission.file_path}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 bg-primary text-white text-xs font-bold px-4 py-2 rounded-xl hover:bg-red-800 transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<StatusTable
|
||||
status={status}
|
||||
submission={internSubmission.submission}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,34 @@
|
||||
// ManageInternsModal.tsx
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { X, Search, Plus, Trash2 } from "lucide-react";
|
||||
import { Search, Plus, Trash2 } from "lucide-react";
|
||||
import { useMahasiswa } from "../../../../hooks/useInterns";
|
||||
import { useAssignMember } from '../../../../hooks/useProjects';
|
||||
import type { ProjectMember } from '../../../../types/project.types';
|
||||
import type { Mahasiswa } from '../../../../types/project.types';
|
||||
|
||||
interface ManageInternsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
projectId: number;
|
||||
initialMembers: ProjectMember[];
|
||||
}
|
||||
|
||||
export default function ManageInternsModal({ isOpen, onClose }: ManageInternsModalProps) {
|
||||
export default function ManageInternsModal({ isOpen, onClose, projectId, initialMembers }: ManageInternsModalProps) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("Jonathankristin@gmail.com");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [invitedList, setInvitedList] = useState<ProjectMember[]>(initialMembers);
|
||||
|
||||
const [invitedList, setInvitedList] = useState([
|
||||
{ id: 1, email: "Jonathankristina@gmail.com", initials: "JK" }
|
||||
]);
|
||||
const { mahasiswa, loading: loadingMahasiswa } = useMahasiswa();
|
||||
const { assign, loading: assigning } = useAssignMember();
|
||||
|
||||
// Data Rekomendasi/Hasil Pencarian (Simulasi list bawah yang ada ikon plus)
|
||||
const [searchResult, setSearchResult] = useState([
|
||||
{ id: 2, email: "Jonathankristin@gmail.com", initials: "JK" }
|
||||
]);
|
||||
// Sync saat modal dibuka
|
||||
useEffect(() => {
|
||||
if (isOpen) setInvitedList(initialMembers);
|
||||
}, [isOpen, initialMembers]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
|
||||
if (isOpen) {
|
||||
dialog.showModal();
|
||||
document.body.style.overflow = "hidden";
|
||||
@@ -30,12 +36,7 @@ export default function ManageInternsModal({ isOpen, onClose }: ManageInternsMod
|
||||
dialog.close();
|
||||
document.body.style.overflow = "unset";
|
||||
}
|
||||
|
||||
const handleCancel = (e: Event) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCancel = (e: Event) => { e.preventDefault(); onClose(); };
|
||||
dialog.addEventListener("cancel", handleCancel);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", handleCancel);
|
||||
@@ -43,38 +44,66 @@ export default function ManageInternsModal({ isOpen, onClose }: ManageInternsMod
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Filter mahasiswa yang belum jadi member & sesuai search
|
||||
const invitedIds = new Set(invitedList.map(m => m.id));
|
||||
const searchResult = mahasiswa.filter(m =>
|
||||
!invitedIds.has(m.id) &&
|
||||
(m.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
`${m.nama_depan} ${m.nama_belakang}`.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
|
||||
const handleAssign = async (intern: Mahasiswa) => {
|
||||
const result = await assign({ id_project: projectId, id_user: intern.id });
|
||||
if (result) {
|
||||
setInvitedList(prev => [...prev, {
|
||||
id: intern.id,
|
||||
id_user: intern.id,
|
||||
full_name: `${intern.nama_depan} ${intern.nama_belakang}`,
|
||||
email: intern.email,
|
||||
avatar: "",
|
||||
kelompok_peminatan: intern.jurusan,
|
||||
professional_bio: "",
|
||||
position: intern.role,
|
||||
} as ProjectMember]);
|
||||
}
|
||||
};
|
||||
|
||||
const getInitials = (m: Mahasiswa) =>
|
||||
`${m.nama_depan[0]}${m.nama_belakang[0]}`.toUpperCase();
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className="custom-dialog"
|
||||
>
|
||||
<dialog ref={dialogRef} className="custom-dialog">
|
||||
<div className="px-3 py-5">
|
||||
{/* Search Input Area */}
|
||||
<div className="relative flex items-center mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search by email..."
|
||||
placeholder="Cari nama atau email..."
|
||||
className="w-full pl-4 pr-10 py-3 bg-white border border-gray-200 rounded-xl text-sm placeholder-gray-400 text-gray-700 focus:outline-none focus:border-red-600 focus:ring-1 focus:ring-red-600 font-medium shadow-sm"
|
||||
/>
|
||||
<Search className="absolute right-3.5 w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
|
||||
{/* List Container (Scrollable jika data banyak) */}
|
||||
<div className="overflow-y-auto space-y-3 pr-1 flex-1">
|
||||
|
||||
{/* List 1: Intern Yang Sudah Tergabung / Ditambahkan */}
|
||||
{invitedList.map((intern) => (
|
||||
<div key={intern.id} className="flex items-center justify-between p-2 rounded-xl hover:bg-gray-50 transition-colors">
|
||||
{/* Sudah jadi member */}
|
||||
{invitedList.map((member) => (
|
||||
<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">
|
||||
{intern.initials}
|
||||
{member.avatar ? (
|
||||
<img src={member.avatar} alt={member.full_name} className="w-9 h-9 rounded-full object-cover" />
|
||||
) : (
|
||||
<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()}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-700">{member.full_name}</span>
|
||||
<span className="text-xs text-gray-400">{member.email}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-600">{intern.email}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setInvitedList(invitedList.filter(i => i.id !== intern.id))}
|
||||
onClick={() => setInvitedList(prev => prev.filter(i => i.id !== member.id))}
|
||||
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
@@ -82,27 +111,35 @@ export default function ManageInternsModal({ isOpen, onClose }: ManageInternsMod
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* List 2: Hasil Pencarian / Belum Ditambahkan */}
|
||||
{searchResult.map((intern) => (
|
||||
<div key={intern.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">
|
||||
{intern.initials}
|
||||
{/* Hasil pencarian — belum jadi member */}
|
||||
{loadingMahasiswa ? (
|
||||
<p className="text-sm text-gray-400 text-center py-2">Memuat...</p>
|
||||
) : (
|
||||
searchResult.map((intern) => (
|
||||
<div key={intern.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">
|
||||
{getInitials(intern)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-700">{intern.nama_depan} {intern.nama_belakang}</span>
|
||||
<span className="text-xs text-gray-400">{intern.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-600">{intern.email}</span>
|
||||
<button
|
||||
onClick={() => handleAssign(intern)}
|
||||
disabled={assigning}
|
||||
className="p-2 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setInvitedList([...invitedList, intern]);
|
||||
setSearchResult(searchResult.filter(i => i.id !== intern.id));
|
||||
}}
|
||||
className="p-2 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
|
||||
{!loadingMahasiswa && searchQuery && searchResult.length === 0 && (
|
||||
<p className="text-sm text-gray-400 text-center py-2">Tidak ada mahasiswa ditemukan.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { LuUserPlus, LuMail, LuBriefcase, LuFolderOpen } from "react-icons/lu";
|
||||
import { LuUserPlus, LuMail, LuFolderOpen } from "react-icons/lu";
|
||||
import { useAssignMember, useProjects } from "../../../../hooks/useProjects";
|
||||
|
||||
interface AddInternsModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -12,8 +13,11 @@ export default function AddInternsModal({ isOpen, onClose }: AddInternsModalProp
|
||||
|
||||
// Form States
|
||||
const [email, setEmail] = useState("");
|
||||
const [position, setPosition] = useState("");
|
||||
const [projectName, setProjectName] = useState("");
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string>("");
|
||||
|
||||
// Hooks
|
||||
const { assign, loading: assigning, error: assignError } = useAssignMember();
|
||||
const { projects, loading: loadingProjects } = useProjects("active");
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
@@ -39,11 +43,29 @@ export default function AddInternsModal({ isOpen, onClose }: AddInternsModalProp
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
// Reset form saat modal dibuka kembali
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setEmail("");
|
||||
setSelectedProjectId("");
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const payload = { email, position, projectName };
|
||||
console.log("Adding Intern:", payload);
|
||||
alert("Intern Added Successfully!");
|
||||
|
||||
// Catatan: useAssignMember memerlukan id_user (number), bukan email.
|
||||
// Endpoint /project-api/assign-member menerima { id_project: number, id_user: number }.
|
||||
// Untuk implementasi berbasis email, perlu endpoint tambahan atau lookup user by email.
|
||||
// Saat ini form ini dikomen karena tidak ada lookup email → id_user di service.
|
||||
|
||||
// Contoh penggunaan jika id_user sudah diketahui:
|
||||
// const res = await assign({ id_project: Number(selectedProjectId), id_user: userId });
|
||||
// if (res !== null) {
|
||||
// onClose();
|
||||
// }
|
||||
|
||||
alert(`Assign intern "${email}" ke project ID ${selectedProjectId} — perlu lookup id_user dari email via backend.`);
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -69,7 +91,7 @@ export default function AddInternsModal({ isOpen, onClose }: AddInternsModalProp
|
||||
|
||||
{/* Form Body */}
|
||||
<form onSubmit={handleSubmit} className="bg-gray-100 px-6 py-6 space-y-4 overflow-y-auto flex-1">
|
||||
|
||||
|
||||
{/* Email Address */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold tracking-wide text-foreground">
|
||||
@@ -90,29 +112,30 @@ export default function AddInternsModal({ isOpen, onClose }: AddInternsModalProp
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Position */}
|
||||
{/* Project Selection */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold tracking-wide text-foreground">
|
||||
Position
|
||||
Project
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none text-border">
|
||||
<LuBriefcase className="w-4 h-4 stroke-[2]" />
|
||||
<LuFolderOpen className="w-4 h-4 stroke-[2]" />
|
||||
</span>
|
||||
<select
|
||||
required
|
||||
value={position}
|
||||
onChange={(e) => setPosition(e.target.value)}
|
||||
className={`w-full pl-10 pr-9 py-2.5 bg-white text-sm font-medium appearance-none cursor-pointer border border-border rounded-radius focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all text-foreground`}
|
||||
value={selectedProjectId}
|
||||
onChange={(e) => setSelectedProjectId(e.target.value)}
|
||||
disabled={loadingProjects}
|
||||
className="w-full pl-10 pr-9 py-2.5 bg-white text-sm font-medium appearance-none cursor-pointer border border-border rounded-radius focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all text-foreground disabled:opacity-60"
|
||||
>
|
||||
<option value="" disabled>Select position</option>
|
||||
<option value="frontend">Frontend Developer</option>
|
||||
<option value="backend">Backend Developer</option>
|
||||
<option value="fullstack">Fullstack Developer</option>
|
||||
<option value="designer">UI/UX Designer</option>
|
||||
<option value="mobile">Mobile Developer</option>
|
||||
<option value="devops">DevOps Engineer</option>
|
||||
<option value="qa">QA Engineer</option>
|
||||
<option value="" disabled>
|
||||
{loadingProjects ? "Memuat projects..." : "Pilih project"}
|
||||
</option>
|
||||
{projects.map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.project_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-border">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
@@ -122,33 +145,15 @@ export default function AddInternsModal({ isOpen, onClose }: AddInternsModalProp
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project Name */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold tracking-wide text-foreground">
|
||||
Project Name
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none text-border">
|
||||
<LuFolderOpen className="w-4 h-4 stroke-[2]" />
|
||||
</span>
|
||||
<select
|
||||
required
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
className={`w-full pl-10 pr-9 py-2.5 bg-white text-sm font-medium appearance-none cursor-pointer border border-border rounded-radius focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all text-foreground`}
|
||||
>
|
||||
<option value="" disabled>Select project assignment</option>
|
||||
<option value="internify">Internify Project</option>
|
||||
<option value="rekrutmen">Rekrutmen Digital Platform</option>
|
||||
<option value="ecommerce">E-Commerce Analytics Dashboard</option>
|
||||
</select>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-border">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Field Position dikomentari — tidak ada dalam payload assign-member */}
|
||||
{/* <div className="space-y-1.5">
|
||||
<label className="text-xs font-bold tracking-wide text-foreground">Position</label>
|
||||
<select>...</select>
|
||||
</div> */}
|
||||
|
||||
{assignError && (
|
||||
<p className="text-xs text-red-600 font-medium">{assignError}</p>
|
||||
)}
|
||||
|
||||
<div className="h-px bg-border/30" />
|
||||
|
||||
@@ -163,10 +168,11 @@ export default function AddInternsModal({ isOpen, onClose }: AddInternsModalProp
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 py-2.5 text-sm font-bold text-white bg-primary hover:bg-danger rounded-radius shadow-md flex items-center justify-center gap-2 transition-colors"
|
||||
disabled={assigning}
|
||||
className="flex-1 py-2.5 text-sm font-bold text-white bg-primary hover:bg-danger rounded-radius shadow-md flex items-center justify-center gap-2 transition-colors disabled:opacity-60"
|
||||
>
|
||||
<LuUserPlus className="w-4 h-4 stroke-[2.5]" />
|
||||
Add Member
|
||||
{assigning ? "Assigning..." : "Add Member"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { CalendarDays } from "lucide-react";
|
||||
import type { Project } from "../../../../types/project.types";
|
||||
|
||||
// ─── StatusBadge ─────────────────────────────────────────────────────────────
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const isCompleted = status === "archived";
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateRange(start: string, end: string): string {
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
};
|
||||
|
||||
const startDate = new Date(start).toLocaleDateString("id-ID", options);
|
||||
const endDate = new Date(end).toLocaleDateString("id-ID", options);
|
||||
|
||||
return `${startDate} – ${endDate}`;
|
||||
}
|
||||
|
||||
// ─── ProjectCard ──────────────────────────────────────────────────────────────
|
||||
export default function CertificateProjectCard({
|
||||
project,
|
||||
onClick,
|
||||
}: {
|
||||
project: Project;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="bg-white border border-card-outline rounded-2xl p-5 cursor-pointer hover:shadow-md hover:border-gray-300 transition-all duration-200 flex flex-col gap-4"
|
||||
>
|
||||
{/* Top: status badge */}
|
||||
<div className="flex items-start justify-between">
|
||||
<StatusBadge status={project.status} />
|
||||
</div>
|
||||
|
||||
{/* Title + period */}
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-font-shade leading-snug mb-1.5">
|
||||
{project.project_name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-font font-medium">
|
||||
<CalendarDays className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span>{formatDateRange(project.start_date, project.end_date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-card-outline" />
|
||||
|
||||
{/* Intern count */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[10px] text-font font-medium mb-0.5">Interns</p>
|
||||
<p className="text-sm font-extrabold text-[#B30000]">
|
||||
{project.total_members} interns
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,8 @@
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { X, Calendar } from "lucide-react";
|
||||
import {
|
||||
LuCode,
|
||||
LuAlbum,
|
||||
LuCloud,
|
||||
LuSmartphone,
|
||||
LuSettings,
|
||||
LuUsers,
|
||||
LuClipboardList,
|
||||
LuPenTool,
|
||||
LuLightbulb,
|
||||
LuShield,
|
||||
LuMail,
|
||||
LuSendHorizontal
|
||||
} from "react-icons/lu";
|
||||
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";
|
||||
|
||||
interface CreateProjectModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -22,32 +11,19 @@ interface CreateProjectModalProps {
|
||||
|
||||
export default function CreateProjectModal({ isOpen, onClose }: CreateProjectModalProps) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
// State Form Management
|
||||
const [selectedIcon, setSelectedIcon] = useState("code");
|
||||
const [selectedIcon, setSelectedIcon] = useState(PROJECT_ICON_CODES[0]);
|
||||
const [projectName, setProjectName] = useState("");
|
||||
const [projectDescription, setProjectDescription] = useState("");
|
||||
const [emailInput, setEmailInput] = useState("");
|
||||
const [invitedEmails, setInvitedEmails] = useState<string[]>([
|
||||
"alex.dev@gmail.com",
|
||||
"sarah.engineer@gmail.com"
|
||||
]);
|
||||
const [invitedEmails, setInvitedEmails] = useState<string[]>([]);
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const { create, loading, error } = useCreateProject();
|
||||
|
||||
// Daftar Icon Pilihan di baris atas
|
||||
const projectIcons = [
|
||||
{ id: "code", icon: LuCode },
|
||||
{ id: "chart", icon: LuAlbum },
|
||||
{ id: "cloud", icon: LuCloud },
|
||||
{ id: "mobile", icon: LuSmartphone },
|
||||
{ id: "settings", icon: LuSettings },
|
||||
{ id: "users", icon: LuUsers },
|
||||
{ id: "task", icon: LuClipboardList },
|
||||
{ id: "design", icon: LuPenTool },
|
||||
{ id: "idea", icon: LuLightbulb },
|
||||
{ id: "secure", icon: LuShield },
|
||||
];
|
||||
const projectIcons = PROJECT_ICON_CODES.map((id) => ({
|
||||
id,
|
||||
icon: PROJECT_ICON_MAP[id],
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
@@ -73,7 +49,6 @@ export default function CreateProjectModal({ isOpen, onClose }: CreateProjectMod
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Fungsi menambah email undangan baru
|
||||
const handleAddEmail = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (emailInput.trim() && !invitedEmails.includes(emailInput.trim())) {
|
||||
@@ -82,25 +57,29 @@ export default function CreateProjectModal({ isOpen, onClose }: CreateProjectMod
|
||||
}
|
||||
};
|
||||
|
||||
// Fungsi menghapus email undangan
|
||||
const handleRemoveEmail = (emailToRemove: string) => {
|
||||
setInvitedEmails(invitedEmails.filter(email => email !== emailToRemove));
|
||||
};
|
||||
|
||||
// Handler Submit Utama
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const payload = {
|
||||
icon: selectedIcon,
|
||||
name: projectName,
|
||||
const payload: any = {
|
||||
project_icon: selectedIcon,
|
||||
project_name: projectName,
|
||||
description: projectDescription,
|
||||
emails: invitedEmails,
|
||||
startDate,
|
||||
endDate
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
};
|
||||
console.log("Launching Project:", payload);
|
||||
alert("Project Launched Successfully!");
|
||||
onClose();
|
||||
|
||||
if (invitedEmails.length > 0) {
|
||||
payload.member_emails = invitedEmails;
|
||||
}
|
||||
|
||||
const result = await create(payload);
|
||||
if (result) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -125,8 +104,7 @@ export default function CreateProjectModal({ isOpen, onClose }: CreateProjectMod
|
||||
</div>
|
||||
|
||||
{/* Body Form Modal */}
|
||||
<form onSubmit={handleSubmit} className="p-6 md:p-8 space-y-3 md:space-y-4 overflow-y-auto flex-1">
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-4 md:p-6 space-y-3 md:space-y-4 overflow-y-auto flex-1">
|
||||
{/* Section: Select Project Icon */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-gray-700 tracking-wide">
|
||||
@@ -141,9 +119,9 @@ export default function CreateProjectModal({ isOpen, onClose }: CreateProjectMod
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedIcon(item.id)}
|
||||
className={`w-8 h-8 flex items-center justify-center rounded-lg border text-lg transition-all ${isSelected
|
||||
? "border-[#B30000] bg-red-50/50 text-[#B30000] ring-2 ring-[#B30000]/20 font-bold"
|
||||
: "border-gray-200 bg-gray-50 text-gray-500 hover:bg-gray-100"
|
||||
className={`w-10 h-10 flex items-center justify-center rounded-lg border text-lg transition-all ${isSelected
|
||||
? "border-red bg-red-foreground text-red ring-2 ring-red/20 font-bold"
|
||||
: "border-card-outline bg-card text-font hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
<IconComponent className={isSelected ? "stroke-[2.5]" : "stroke-[2]"} />
|
||||
@@ -174,7 +152,7 @@ export default function CreateProjectModal({ isOpen, onClose }: CreateProjectMod
|
||||
Project Description
|
||||
</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
rows={3}
|
||||
placeholder="Outline the project goals, deliverables, and technical requirements..."
|
||||
value={projectDescription}
|
||||
onChange={(e) => setProjectDescription(e.target.value)}
|
||||
@@ -246,10 +224,9 @@ export default function CreateProjectModal({ isOpen, onClose }: CreateProjectMod
|
||||
required
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-full pl-4 pr-10 py-2.5 bg-white 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 font-medium appearance-none"
|
||||
className="w-full px-3 py-2.5 bg-white 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 font-medium appearance-none"
|
||||
/>
|
||||
<span className="absolute right-3.5 top-3 text-gray-400 pointer-events-none">
|
||||
<Calendar className="w-4 h-4" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -265,15 +242,18 @@ export default function CreateProjectModal({ isOpen, onClose }: CreateProjectMod
|
||||
required
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="w-full pl-4 pr-10 py-2.5 bg-white 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 font-medium appearance-none"
|
||||
className="w-full px-3 py-2.5 bg-white 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 font-medium appearance-none"
|
||||
/>
|
||||
<span className="absolute right-3.5 top-3 text-gray-400 pointer-events-none">
|
||||
<Calendar className="w-4 h-4" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-600 text-center font-medium">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Action Row Buttons */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 pt-6">
|
||||
<button
|
||||
@@ -286,9 +266,10 @@ export default function CreateProjectModal({ isOpen, onClose }: CreateProjectMod
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full sm:w-[240px] py-2.5 bg-[#B30000] hover:bg-[#990000] text-white text-sm font-bold rounded-xl transition-colors shadow-md flex items-center justify-center gap-2 tracking-wide"
|
||||
disabled={loading}
|
||||
className="w-full sm:w-[240px] py-2.5 bg-[#B30000] hover:bg-[#990000] disabled:opacity-60 text-white text-sm font-bold rounded-xl transition-colors shadow-md flex items-center justify-center gap-2 tracking-wide"
|
||||
>
|
||||
Launch Project
|
||||
{loading ? "Launching..." : "Launch Project"}
|
||||
<LuSendHorizontal className="w-4 h-4 stroke-[2.5]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,76 +1,59 @@
|
||||
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';
|
||||
|
||||
interface CreateTaskModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
projectId: string;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export default function CreateTaskModal({ isOpen, onClose }: CreateTaskModalProps) {
|
||||
export default function CreateTaskModal({ isOpen, onClose, projectId, onSuccess }: CreateTaskModalProps) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const { create, loading } = useCreateTask(projectId);
|
||||
|
||||
// Form States
|
||||
const [taskTitle, setTaskTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [deadlineDate, setDeadlineDate] = useState("");
|
||||
const [specificTime, setSpecificTime] = useState("");
|
||||
const [submissionType, setSubmissionType] = useState<"file" | "url">("file");
|
||||
const [submissionType, setSubmissionType] = useState<SubmissionType>("file_upload");
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
|
||||
if (isOpen) {
|
||||
dialog.showModal();
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
dialog.close();
|
||||
document.body.style.overflow = "unset";
|
||||
}
|
||||
|
||||
const handleCancel = (e: Event) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (isOpen) { dialog.showModal(); document.body.style.overflow = "hidden"; }
|
||||
else { dialog.close(); document.body.style.overflow = "unset"; }
|
||||
const handleCancel = (e: Event) => { e.preventDefault(); onClose(); };
|
||||
dialog.addEventListener("cancel", handleCancel);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", handleCancel);
|
||||
document.body.style.overflow = "unset";
|
||||
};
|
||||
return () => { dialog.removeEventListener("cancel", handleCancel); document.body.style.overflow = "unset"; };
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const payload = { taskTitle, description, deadlineDate, specificTime, submissionType };
|
||||
console.log("Saving Task:", payload);
|
||||
alert("Task Saved Successfully!");
|
||||
onClose();
|
||||
const result = await create({
|
||||
title: taskTitle,
|
||||
description,
|
||||
deadline_date: deadlineDate,
|
||||
specific_time: specificTime,
|
||||
submission_type: submissionType,
|
||||
});
|
||||
if (result) onSuccess();
|
||||
};
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className="custom-dialog p-[1.5rem]"
|
||||
>
|
||||
{/* Header Modal */}
|
||||
<dialog ref={dialogRef} className="custom-dialog p-[1.5rem]">
|
||||
<div className="dialog-header">
|
||||
<div>
|
||||
<h2 className="text-[26px] font-bold text-[#B30000] tracking-tight">Create New Task</h2>
|
||||
<p className="text-xs text-gray-400 font-medium mt-0.5">
|
||||
Fill in the details for the next intern assignment.
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 font-medium mt-0.5">Fill in the details for the next intern assignment.</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-50 hover:text-gray-600 transition-colors">
|
||||
<X className="w-5 h-5 stroke-[2.5]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body Form Modal (Scrollable jika layar pendek) */}
|
||||
<form onSubmit={handleSubmit} className="px-6 space-y-4 overflow-y-auto flex-1">
|
||||
|
||||
{/* Task Title */}
|
||||
<div className="">
|
||||
<form onSubmit={handleSubmit} className="px-6 space-y-2 overflow-y-auto flex-1">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-700 tracking-wide">Task Title</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -78,11 +61,8 @@ export default function CreateTaskModal({ isOpen, onClose }: CreateTaskModalProp
|
||||
placeholder="e.g., Design High-Fidelity Prototypes"
|
||||
value={taskTitle}
|
||||
onChange={(e) => setTaskTitle(e.target.value)}
|
||||
className="w-full px-3 py-4 bg-white border border-gray-200 rounded-lg text-xs font-medium text-gray-800 focus:outline-none focus:border-[#B30000] focus:ring-1 focus:ring-[#B30000]"
|
||||
/>
|
||||
className="w-full px-3 py-4 bg-white border border-gray-200 rounded-lg text-xs font-medium text-gray-800 focus:outline-none focus:border-[#B30000] focus:ring-1 focus:ring-[#B30000]" />
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-gray-700 tracking-wide">Description</label>
|
||||
<textarea
|
||||
@@ -90,98 +70,55 @@ export default function CreateTaskModal({ isOpen, onClose }: CreateTaskModalProp
|
||||
placeholder="Outline the requirements and objectives..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-lg text-xs font-medium text-gray-800 focus:outline-none focus:border-[#B30000] focus:ring-1 focus:ring-[#B30000] resize-none leading-relaxed"
|
||||
/>
|
||||
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-lg text-xs font-medium text-gray-800 focus:outline-none focus:border-[#B30000] focus:ring-1 focus:ring-[#B30000] resize-none leading-relaxed" />
|
||||
</div>
|
||||
|
||||
{/* Deadline Grid (Date & Time) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-gray-700 tracking-wide">Deadline Date</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
value={deadlineDate}
|
||||
onChange={(e) => setDeadlineDate(e.target.value)}
|
||||
className="w-full pl-3 pr-9 py-3 bg-white border border-gray-200 rounded-lg text-xs font-medium text-gray-800 focus:outline-none focus:border-[#B30000] focus:ring-1 focus:ring-[#B30000]"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
value={deadlineDate}
|
||||
onChange={(e) => setDeadlineDate(e.target.value)}
|
||||
className="w-full px-3 py-3 bg-white border border-gray-200 rounded-lg text-xs font-medium text-gray-800 focus:outline-none focus:border-[#B30000] focus:ring-1 focus:ring-[#B30000]" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-gray-700 tracking-wide">Specific Time (Jam)</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="time"
|
||||
required
|
||||
value={specificTime}
|
||||
onChange={(e) => setSpecificTime(e.target.value)}
|
||||
className="w-full pl-3 pr-9 py-3 bg-white border border-gray-200 rounded-lg text-xs font-medium text-gray-800 focus:outline-none focus:border-[#B30000] focus:ring-1 focus:ring-[#B30000]"
|
||||
/>
|
||||
</div>
|
||||
<label className="text-xs font-bold text-gray-700 tracking-wide">Specific Time</label>
|
||||
<input
|
||||
type="time"
|
||||
required
|
||||
value={specificTime}
|
||||
onChange={(e) => setSpecificTime(e.target.value)}
|
||||
className="w-full px-3 py-3 bg-white border border-gray-200 rounded-lg text-xs font-medium text-gray-800 focus:outline-none focus:border-[#B30000] focus:ring-1 focus:ring-[#B30000]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submission Type Card Radio */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-gray-700 tracking-wide">Submission Type</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
|
||||
{/* Opsi 1: File Upload */}
|
||||
<div
|
||||
onClick={() => setSubmissionType("file")}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl border cursor-pointer transition-all ${
|
||||
submissionType === "file"
|
||||
? "border-[#B30000] bg-[#B30000] text-white font-semibold"
|
||||
: "border-gray-200 bg-white text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className={`w-3.5 h-3.5 rounded-full border flex items-center justify-center ${submissionType === "file" ? "border-white bg-white" : "border-gray-300"}`}>
|
||||
{submissionType === "file" && <div className="w-1.5 h-1.5 bg-[#B30000] rounded-full" />}
|
||||
{(["file_upload", "url_link"] as SubmissionType[]).map((type) => (
|
||||
<div key={type} onClick={() => setSubmissionType(type)}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl border cursor-pointer transition-all ${submissionType === type ? "border-[#B30000] bg-[#B30000] text-white font-semibold" : "border-gray-200 bg-white text-gray-600 hover:bg-gray-50"
|
||||
}`}>
|
||||
<div className={`w-3.5 h-3.5 rounded-full border flex items-center justify-center ${submissionType === type ? "border-white bg-white" : "border-gray-300"}`}>
|
||||
{submissionType === type && <div className="w-1.5 h-1.5 bg-[#B30000] rounded-full" />}
|
||||
</div>
|
||||
{type === "file_upload" ? <FileText className="w-4 h-4" /> : <Link className="w-4 h-4" />}
|
||||
<span className="text-xs">{type === "file_upload" ? "File Upload" : "URL Link"}</span>
|
||||
</div>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span className="text-xs">File Upload</span>
|
||||
</div>
|
||||
|
||||
{/* Opsi 2: URL Link */}
|
||||
<div
|
||||
onClick={() => setSubmissionType("url")}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl border cursor-pointer transition-all ${
|
||||
submissionType === "url"
|
||||
? "border-[#B30000] bg-[#B30000] text-white font-semibold"
|
||||
: "border-gray-200 bg-white text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className={`w-3.5 h-3.5 rounded-full border flex items-center justify-center ${submissionType === "url" ? "border-white bg-white" : "border-gray-300"}`}>
|
||||
{submissionType === "url" && <div className="w-1.5 h-1.5 bg-[#B30000] rounded-full" />}
|
||||
</div>
|
||||
<Link className="w-4 h-4" />
|
||||
<span className="text-xs">URL Link</span>
|
||||
</div>
|
||||
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-gray-100 my-2" />
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-3 border border-gray-200 rounded-xl text-xs font-semibold text-gray-500 hover:text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<button type="button" onClick={onClose}
|
||||
className="p-3 border border-gray-200 rounded-xl text-xs font-semibold text-gray-500 hover:text-gray-700 hover:bg-gray-50 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="p-3 bg-[#B30000] hover:bg-[#990000] text-white text-xs font-bold rounded-xl transition-colors shadow-md"
|
||||
>
|
||||
Save Task
|
||||
<button type="submit" disabled={loading}
|
||||
className="p-3 bg-[#B30000] hover:bg-[#990000] text-white text-xs font-bold rounded-xl transition-colors shadow-md disabled:opacity-50">
|
||||
{loading ? "Saving..." : "Save Task"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { LuSave, LuCalendar } from "react-icons/lu";
|
||||
import { LuSave } from "react-icons/lu";
|
||||
|
||||
interface EditTaskModalProps {
|
||||
isOpen: boolean;
|
||||
|
||||
@@ -1,187 +1,118 @@
|
||||
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';
|
||||
|
||||
interface EditTaskModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
task: ProjectTask;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export default function EditTaskModal({ isOpen, onClose }: EditTaskModalProps) {
|
||||
export default function EditTaskModal({ isOpen, onClose, task, onSuccess }: EditTaskModalProps) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const { update, loading } = useUpdateTask();
|
||||
|
||||
// Form States
|
||||
const [taskTitle, setTaskTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [deadlineDate, setDeadlineDate] = useState("");
|
||||
const [specificTime, setSpecificTime] = useState("");
|
||||
const [submissionType, setSubmissionType] = useState<"file" | "url">("file");
|
||||
const [taskTitle, setTaskTitle] = useState(task.title);
|
||||
const [description, setDescription] = useState(task.description);
|
||||
const [deadlineDate, setDeadlineDate] = useState(task.deadline_at.split("T")[0]);
|
||||
const [specificTime, setSpecificTime] = useState(task.deadline_at.split("T")[1]?.slice(0, 5) ?? "");
|
||||
const [submissionType, setSubmissionType] = useState<SubmissionType>(task.submission_type);
|
||||
|
||||
// Reset tiap task berubah
|
||||
useEffect(() => {
|
||||
setTaskTitle(task.title);
|
||||
setDescription(task.description);
|
||||
setDeadlineDate(task.deadline_at.split("T")[0]);
|
||||
setSpecificTime(task.deadline_at.split("T")[1]?.slice(0, 5) ?? "");
|
||||
setSubmissionType(task.submission_type);
|
||||
}, [task]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
|
||||
if (isOpen) {
|
||||
dialog.showModal();
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
dialog.close();
|
||||
document.body.style.overflow = "unset";
|
||||
}
|
||||
|
||||
const handleCancel = (e: Event) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (isOpen) { dialog.showModal(); document.body.style.overflow = "hidden"; }
|
||||
else { dialog.close(); document.body.style.overflow = "unset"; }
|
||||
const handleCancel = (e: Event) => { e.preventDefault(); onClose(); };
|
||||
dialog.addEventListener("cancel", handleCancel);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", handleCancel);
|
||||
document.body.style.overflow = "unset";
|
||||
};
|
||||
return () => { dialog.removeEventListener("cancel", handleCancel); document.body.style.overflow = "unset"; };
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const payload = { taskTitle, description, deadlineDate, specificTime, submissionType };
|
||||
console.log("Saving Task:", payload);
|
||||
alert("Task Saved Successfully!");
|
||||
onClose();
|
||||
const result = await update(task.id, {
|
||||
title: taskTitle,
|
||||
description,
|
||||
deadline_date: deadlineDate,
|
||||
specific_time: specificTime,
|
||||
submission_type: submissionType,
|
||||
}, String(task.id_project));
|
||||
if (result) onSuccess();
|
||||
};
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className="custom-dialog p-[1.5rem]"
|
||||
>
|
||||
{/* Header Modal */}
|
||||
<dialog ref={dialogRef} className="custom-dialog p-[1.5rem]">
|
||||
<div className="dialog-header">
|
||||
<div>
|
||||
<h2 className="text-[26px] font-bold text-[#B30000] tracking-tight">Edut Task</h2>
|
||||
<p className="text-xs text-gray-400 font-medium mt-0.5">
|
||||
Fill in the details for the next intern assignment.
|
||||
</p>
|
||||
<h2 className="text-[26px] font-bold text-[#B30000] tracking-tight">Edit Task</h2>
|
||||
<p className="text-xs text-gray-400 font-medium mt-0.5">Update the details for this assignment.</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-50 hover:text-gray-600 transition-colors">
|
||||
<X className="w-5 h-5 stroke-[2.5]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body Form Modal (Scrollable jika layar pendek) */}
|
||||
<form onSubmit={handleSubmit} className="px-6 space-y-4 overflow-y-auto flex-1">
|
||||
|
||||
{/* Task Title */}
|
||||
<div className="">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-700 tracking-wide">Task Title</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g., Design High-Fidelity Prototypes"
|
||||
value={taskTitle}
|
||||
onChange={(e) => setTaskTitle(e.target.value)}
|
||||
className="w-full px-3 py-4 bg-white border border-gray-200 rounded-lg text-xs font-medium text-gray-800 focus:outline-none focus:border-[#B30000] focus:ring-1 focus:ring-[#B30000]"
|
||||
/>
|
||||
<input type="text" required value={taskTitle} onChange={(e) => setTaskTitle(e.target.value)}
|
||||
className="w-full px-3 py-4 bg-white border border-gray-200 rounded-lg text-xs font-medium text-gray-800 focus:outline-none focus:border-[#B30000] focus:ring-1 focus:ring-[#B30000]" />
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-gray-700 tracking-wide">Description</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
placeholder="Outline the requirements and objectives..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-lg text-xs font-medium text-gray-800 focus:outline-none focus:border-[#B30000] focus:ring-1 focus:ring-[#B30000] resize-none leading-relaxed"
|
||||
/>
|
||||
<textarea rows={4} value={description} onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-lg text-xs font-medium text-gray-800 focus:outline-none focus:border-[#B30000] focus:ring-1 focus:ring-[#B30000] resize-none leading-relaxed" />
|
||||
</div>
|
||||
|
||||
{/* Deadline Grid (Date & Time) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-gray-700 tracking-wide">Deadline Date</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
value={deadlineDate}
|
||||
onChange={(e) => setDeadlineDate(e.target.value)}
|
||||
className="w-full pl-3 pr-9 py-3 bg-white border border-gray-200 rounded-lg text-xs font-medium text-gray-800 focus:outline-none focus:border-[#B30000] focus:ring-1 focus:ring-[#B30000]"
|
||||
/>
|
||||
</div>
|
||||
<input type="date" required value={deadlineDate} onChange={(e) => setDeadlineDate(e.target.value)}
|
||||
className="w-full pl-3 pr-9 py-3 bg-white border border-gray-200 rounded-lg text-xs font-medium text-gray-800 focus:outline-none focus:border-[#B30000] focus:ring-1 focus:ring-[#B30000]" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-gray-700 tracking-wide">Specific Time (Jam)</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="time"
|
||||
required
|
||||
value={specificTime}
|
||||
onChange={(e) => setSpecificTime(e.target.value)}
|
||||
className="w-full pl-3 pr-9 py-3 bg-white border border-gray-200 rounded-lg text-xs font-medium text-gray-800 focus:outline-none focus:border-[#B30000] focus:ring-1 focus:ring-[#B30000]"
|
||||
/>
|
||||
</div>
|
||||
<label className="text-xs font-bold text-gray-700 tracking-wide">Specific Time</label>
|
||||
<input type="time" required value={specificTime} onChange={(e) => setSpecificTime(e.target.value)}
|
||||
className="w-full pl-3 pr-9 py-3 bg-white border border-gray-200 rounded-lg text-xs font-medium text-gray-800 focus:outline-none focus:border-[#B30000] focus:ring-1 focus:ring-[#B30000]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submission Type Card Radio */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-gray-700 tracking-wide">Submission Type</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
|
||||
{/* Opsi 1: File Upload */}
|
||||
<div
|
||||
onClick={() => setSubmissionType("file")}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl border cursor-pointer transition-all ${
|
||||
submissionType === "file"
|
||||
? "border-[#B30000] bg-[#B30000] text-white font-semibold"
|
||||
: "border-gray-200 bg-white text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className={`w-3.5 h-3.5 rounded-full border flex items-center justify-center ${submissionType === "file" ? "border-white bg-white" : "border-gray-300"}`}>
|
||||
{submissionType === "file" && <div className="w-1.5 h-1.5 bg-[#B30000] rounded-full" />}
|
||||
{(["file_upload", "url_link"] as SubmissionType[]).map((type) => (
|
||||
<div key={type} onClick={() => setSubmissionType(type)}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl border cursor-pointer transition-all ${
|
||||
submissionType === type ? "border-[#B30000] bg-[#B30000] text-white font-semibold" : "border-gray-200 bg-white text-gray-600 hover:bg-gray-50"
|
||||
}`}>
|
||||
<div className={`w-3.5 h-3.5 rounded-full border flex items-center justify-center ${submissionType === type ? "border-white bg-white" : "border-gray-300"}`}>
|
||||
{submissionType === type && <div className="w-1.5 h-1.5 bg-[#B30000] rounded-full" />}
|
||||
</div>
|
||||
{type === "file_upload" ? <FileText className="w-4 h-4" /> : <Link className="w-4 h-4" />}
|
||||
<span className="text-xs">{type === "file_upload" ? "File Upload" : "URL Link"}</span>
|
||||
</div>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span className="text-xs">File Upload</span>
|
||||
</div>
|
||||
|
||||
{/* Opsi 2: URL Link */}
|
||||
<div
|
||||
onClick={() => setSubmissionType("url")}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl border cursor-pointer transition-all ${
|
||||
submissionType === "url"
|
||||
? "border-[#B30000] bg-[#B30000] text-white font-semibold"
|
||||
: "border-gray-200 bg-white text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className={`w-3.5 h-3.5 rounded-full border flex items-center justify-center ${submissionType === "url" ? "border-white bg-white" : "border-gray-300"}`}>
|
||||
{submissionType === "url" && <div className="w-1.5 h-1.5 bg-[#B30000] rounded-full" />}
|
||||
</div>
|
||||
<Link className="w-4 h-4" />
|
||||
<span className="text-xs">URL Link</span>
|
||||
</div>
|
||||
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-gray-100 my-2" />
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-3 border border-gray-200 rounded-xl text-xs font-semibold text-gray-500 hover:text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<button type="button" onClick={onClose}
|
||||
className="p-3 border border-gray-200 rounded-xl text-xs font-semibold text-gray-500 hover:text-gray-700 hover:bg-gray-50 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="p-3 bg-[#B30000] hover:bg-[#990000] text-white text-xs font-bold rounded-xl transition-colors shadow-md"
|
||||
>
|
||||
Save Task
|
||||
<button type="submit" disabled={loading}
|
||||
className="p-3 bg-[#B30000] hover:bg-[#990000] text-white text-xs font-bold rounded-xl transition-colors shadow-md disabled:opacity-50">
|
||||
{loading ? "Saving..." : "Save Task"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
// src/pages/mentor/EligibleInternTable.tsx
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Eye,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import type { InternSubmissionProgress } from "../../../../hooks/useInternProgress";
|
||||
import { useClaimCertificate } from "../../../../hooks/useSertificates";
|
||||
import { Loader2, Check } from "lucide-react";
|
||||
|
||||
interface EligibleInternTableProps {
|
||||
interns: InternSubmissionProgress[];
|
||||
loading: boolean;
|
||||
eligibleCount: number;
|
||||
}
|
||||
|
||||
const PER_PAGE = 5;
|
||||
|
||||
function ProgressBar({ value, total }: { value: number; total: number }) {
|
||||
const pct = Math.round((value / total) * 100);
|
||||
const isComplete = value === total;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-16 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${isComplete ? "bg-[#B30000]" : "bg-gray-400"}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-xs font-bold ${isComplete ? "text-[#B30000]" : "text-gray-500"}`}>
|
||||
{value}/{total}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: boolean }) {
|
||||
const isEligible = status
|
||||
return (
|
||||
<div className={`flex items-center gap-1 text-[11px] font-extrabold tracking-wide ${isEligible ? "text-green-600" : "text-orange-500"}`}>
|
||||
{isEligible
|
||||
? <CheckCircle2 className="w-3.5 h-3.5" />
|
||||
: <AlertCircle className="w-3.5 h-3.5" />}
|
||||
{status}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EligibleInternTableProps {
|
||||
interns: InternSubmissionProgress[];
|
||||
loading: boolean;
|
||||
eligibleCount: number;
|
||||
}
|
||||
|
||||
function Avatar({ initials }: { initials: string }) {
|
||||
return (
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-xs text-white flex-shrink-0 bg-primary`}>
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
export default function EligibleInternTable({ interns, loading, eligibleCount }: EligibleInternTableProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const initialSelected = new Set(
|
||||
interns.filter(i => i.is_eligible).map(i => i.id_user)
|
||||
);
|
||||
const [selected, setSelected] = useState<Set<number>>(initialSelected);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const totalPages = Math.ceil(interns.length / PER_PAGE);
|
||||
const paginated = interns.slice((page - 1) * PER_PAGE, page * PER_PAGE);
|
||||
|
||||
const eligibleIds = interns.filter(i => i.is_eligible).map(i => i.id_user);
|
||||
const allEligibleSelected = eligibleIds.every(id => selected.has(id));
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (allEligibleSelected) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(eligibleIds));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleOne = (id: number, status: boolean) => {
|
||||
if (status) return;
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="lg:col-span-5 flex items-center justify-center py-20 text-gray-400 text-sm">
|
||||
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
||||
Memuat data intern...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="lg:col-span-5 flex flex-col gap-4">
|
||||
{/* Roster Card */}
|
||||
<div className="border border-box-border rounded-2xl overflow-hidden bg-white shadow-sm">
|
||||
<div className="flex items-start justify-between px-5 py-4 border-b border-gray-100">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-gray-900">Intern Roster</h3>
|
||||
<p className="text-xs text-gray-400 mt-0.5 leading-snug max-w-xs">
|
||||
Confirm completion status before final certificate generation.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleSelectAll}
|
||||
className="flex items-center gap-2 border border-gray-200 rounded-xl px-4 py-2.5 bg-white hover:bg-gray-50 transition-colors flex-shrink-0 ml-4"
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border-2 flex items-center justify-center flex-shrink-0 transition-colors ${allEligibleSelected ? "bg-[#B30000] border-[#B30000]" : "border-gray-300"
|
||||
}`}>
|
||||
{allEligibleSelected && (
|
||||
<Check className="w-2.5 h-2.5 text-white" strokeWidth={3} />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs font-bold text-gray-700 whitespace-nowrap">
|
||||
Select All Eligible ({eligibleCount})
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Column Headers */}
|
||||
<div className="grid grid-cols-[16px_1fr_96px_88px_60px] items-center gap-x-3 px-5 py-2.5 bg-gray-50/70 border-b border-gray-100">
|
||||
<div />
|
||||
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Intern Name</span>
|
||||
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider text-center">Task Progress</span>
|
||||
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider text-center">Status</span>
|
||||
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider text-center">Action</span>
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
<div className="divide-y divide-gray-50">
|
||||
{paginated.map((intern) => {
|
||||
const isChecked = selected.has(intern.id_user);
|
||||
const isIncomplete = !intern.is_eligible;
|
||||
const initials = intern.full_name
|
||||
.split(" ")
|
||||
.slice(0, 2)
|
||||
.map(n => n[0])
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
return (
|
||||
<div
|
||||
key={intern.id_user}
|
||||
className="grid grid-cols-[16px_1fr_96px_88px_60px] items-center gap-x-3 px-5 py-3.5 hover:bg-gray-50/40 transition-colors"
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<button
|
||||
onClick={() => toggleOne(intern.id_user, intern.is_eligible)}
|
||||
disabled={isIncomplete}
|
||||
className={`w-4 h-4 rounded border-2 flex items-center justify-center flex-shrink-0 transition-colors ${isIncomplete
|
||||
? "border-gray-200 bg-gray-50 cursor-not-allowed"
|
||||
: isChecked
|
||||
? "bg-[#B30000] border-[#B30000]"
|
||||
: "border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
{isChecked && !isIncomplete && (
|
||||
<Check className="w-2.5 h-2.5 text-white" strokeWidth={3} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Name + role */}
|
||||
<div className="flex items-center gap-3">
|
||||
{intern.avatar ? (
|
||||
<img src={intern.avatar} className="w-10 h-10 rounded-full object-cover" />
|
||||
) : (
|
||||
<Avatar initials={initials} />
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-bold text-gray-900">{intern.full_name}</p>
|
||||
<p className="text-[11px] text-gray-400">{intern.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task Progress */}
|
||||
<div className="flex justify-center">
|
||||
<ProgressBar value={intern.submitted} total={intern.total_tasks} />
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex justify-center">
|
||||
<StatusBadge status={intern.is_eligible} />
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
<div className="flex justify-center">
|
||||
{isIncomplete ? (
|
||||
<button className="text-[11px] font-extrabold text-[#B30000] hover:underline whitespace-nowrap">
|
||||
Nag Intern
|
||||
</button>
|
||||
) : (
|
||||
<button className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-md transition-colors" title="View">
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Pagination Footer */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 px-5 py-3.5 border-t border-gray-100 bg-gray-50/30">
|
||||
<p className="text-xs text-gray-400 font-medium">
|
||||
Showing {(page - 1) * PER_PAGE + 1} to {Math.min(page * PER_PAGE, interns.length)} of {interns.length} entries
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="p-1 border border-gray-200 rounded-md bg-white text-gray-400 hover:bg-gray-50 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => setPage(n)}
|
||||
className={`w-7 h-7 flex items-center justify-center rounded-md text-xs font-bold transition-colors ${n === page
|
||||
? "bg-[#B30000] text-white shadow-sm"
|
||||
: "bg-white border border-gray-200 text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="p-1 border border-gray-200 rounded-md bg-white text-gray-400 hover:bg-gray-50 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Bottom Action Bar ── */}
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
onClick={() => navigate("/mentor/certificates/result")}
|
||||
className="flex items-center gap-2 px-8 py-3.5 bg-[#B30000] hover:bg-[#990000] text-white font-bold text-sm rounded-xl shadow-md transition-colors"
|
||||
>
|
||||
Generate Certificates
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,12 @@ import { mockProjects } from '../../../../lib/mockData'
|
||||
import { useState } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import CreateProjectModal from './CreateProjectDialog';
|
||||
import { useProjects } from '../../../../hooks/useProjects';
|
||||
|
||||
const HomeMentorProject = () => {
|
||||
const navigate = useNavigate();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const { projects, loading, error } = useProjects();
|
||||
const openDialog = () => {
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
@@ -17,7 +19,7 @@ const HomeMentorProject = () => {
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex flex-row justify-between mb-3 px-5 items-center'>
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4">Projects</h2>
|
||||
<button
|
||||
<button
|
||||
className='bg-gray-300 p-1.5 rounded-lg font-bold hover:bg-gray-400'
|
||||
onClick={openDialog} >
|
||||
<span className='text-sm flex flex-row justify-between items-center gap-3.5'>
|
||||
@@ -26,22 +28,22 @@ const HomeMentorProject = () => {
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{mockProjects.map((project, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => navigate(`/intern/projects/${project.name}`)}
|
||||
className="flex items-center gap-4 border border-gray-200 rounded-lg px-4 py-3 hover:bg-gray-50 cursor-pointer transition-colors duration-150"
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 bg-gray-300`}>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-800">{project.name}</p>
|
||||
</div>
|
||||
<div className="flex flex-col max-h-[210px] overflow-y-auto gap-3 pr-1">
|
||||
{loading && <p className="text-sm text-gray-400 px-5">Memuat...</p>}
|
||||
{error && <p className="text-sm text-red-500 px-5">{error}</p>}
|
||||
{projects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
onClick={() => navigate(`/mentor/projects/${project.slug}`)}
|
||||
className="flex items-center gap-4 border border-box-border rounded-lg px-4 py-3 hover:bg-gray-50 cursor-pointer transition-colors duration-150"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 bg-gray-300">
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-800">{project.project_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{isModalOpen && (
|
||||
<CreateProjectModal
|
||||
|
||||
@@ -1,48 +1,41 @@
|
||||
import { Trash2, UsersRound } from 'lucide-react'
|
||||
import type { Intern, Task } from '../../../../lib/mockProjects'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import type { LucideProps } from 'lucide-react';
|
||||
import type { Project } from '../../../../types/project.types';
|
||||
|
||||
interface ProjectCardProps {
|
||||
id: number | string
|
||||
name: string
|
||||
interns: Intern[]
|
||||
tasks: Task[]
|
||||
thumbnailUrl?: string
|
||||
}
|
||||
const getDynamicIcon = (iconName: string) => {
|
||||
const formatted = iconName.charAt(0).toUpperCase() + iconName.slice(1);
|
||||
const Icon = (LucideIcons as unknown as Record<string, React.ComponentType<LucideProps>>)[formatted];
|
||||
return Icon ?? LucideIcons.FolderOpen;
|
||||
};
|
||||
|
||||
const toSlug = (name: string) => name.toLowerCase().replace(/\s+/g, '-');
|
||||
|
||||
export default function MentorProjectCard({
|
||||
id,
|
||||
name,
|
||||
interns,
|
||||
thumbnailUrl
|
||||
}: ProjectCardProps) {
|
||||
export default function MentorProjectCard(project: Project) {
|
||||
const navigate = useNavigate();
|
||||
const handleCardClick = () => {
|
||||
navigate(`/mentor/projects/${toSlug(name)}`)
|
||||
navigate(`/mentor/projects/${toSlug(project.slug)}`)
|
||||
};
|
||||
const Icon = getDynamicIcon(project.project_icon);
|
||||
|
||||
const handleDeleteClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
console.log('Delete project dengan id:', id)
|
||||
e.stopPropagation();
|
||||
}
|
||||
return (
|
||||
<div
|
||||
onClick={handleCardClick}
|
||||
className="flex flex-col bg-white rounded-xl overflow-hidden shadow-sm border border-gray-100 w-[180px] min-h-[270px]hover:bg-gray-50 transition-colors">
|
||||
{/* Thumbnail */}
|
||||
<div className="h-[150px] bg-gradient-to-tr from-red-700 to-red-800 overflow-hidden shrink-0">
|
||||
{thumbnailUrl && (
|
||||
<img src={thumbnailUrl} alt={name} className="w-full h-full object-cover" />
|
||||
)}
|
||||
<div style={{ backgroundColor: project.background_color ?? '#dc2626' }} className="h-[150px] overflow-hidden shrink-0 flex items-center justify-center">
|
||||
<Icon className="w-12 h-12 text-white opacity-80" />
|
||||
</div>
|
||||
|
||||
{/* Content*/}
|
||||
<div className="p-3 flex flex-col gap-2 flex-1 justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-semibold text-gray-800 leading-tight line-clamp-2">
|
||||
{name}
|
||||
{project.project_name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -50,7 +43,7 @@ export default function MentorProjectCard({
|
||||
<div className="flex items-center gap-2 mt-auto">
|
||||
<UsersRound className='text-gray-400 w-4 h-4' />
|
||||
<span className="text-xs text-gray-500 truncate">
|
||||
{interns.length}/{interns.length}
|
||||
{project.total_members}/{project.total_members}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,28 +1,18 @@
|
||||
import type { Task } from "../../../../lib/mockProjects";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import type { ProjectTask } from "../../../../types/task.types";
|
||||
|
||||
const formatDate = (date: Date) =>
|
||||
const formatDate = (date: string) =>
|
||||
new Date(date).toLocaleDateString('id-ID', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
|
||||
const toSlug = (name: string) => name.toLowerCase().replace(/\s+/g, '-');
|
||||
const formatTime = (dateStr: string) =>
|
||||
new Date(dateStr).toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
interface MentorTaskCardProps {
|
||||
task: Task;
|
||||
projectName: string;
|
||||
}
|
||||
|
||||
export default function MentorTaskCard({ task, projectName }: MentorTaskCardProps) {
|
||||
export default function MentorTaskCard({ task }: {task: ProjectTask}) {
|
||||
const navigate = useNavigate();
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
|
||||
const handleCardClick = () => {
|
||||
// Pastikan jika projectName belum ter-render, proses navigasi tidak bikin crash
|
||||
if (!projectName || !task?.title) return;
|
||||
|
||||
const projectSlug = toSlug(projectName);
|
||||
const taskSlug = toSlug(task.title);
|
||||
|
||||
// Rute mutlak menuju target kamu: /mentor/projects/:slug/:taskSlug
|
||||
navigate(`/mentor/projects/${projectSlug}/${taskSlug}`);
|
||||
navigate(`/mentor/projects/${slug}/${task.slug}?type=${task.submission_type}`)
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -32,7 +22,7 @@ export default function MentorTaskCard({ task, projectName }: MentorTaskCardProp
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[11px] text-[#888] mb-1">
|
||||
Deadline : {task.deadline ? `${formatDate(task.deadline.date)} at ${task.deadline.time}` : '-'}
|
||||
Deadline : {task.deadline_at ? `${formatDate(task.deadline_at)} at ${formatTime(task.deadline_at)}` : '-'}
|
||||
</div>
|
||||
<div className="font-bold text-[15px] text-[#1a1a1a] mb-1">
|
||||
{task.title}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Home, FolderKanban, Award, Settings, HelpCircle, LogOut, ChevronRight, X } from 'lucide-react';
|
||||
import { Home, FolderKanban, Award, Settings, HelpCircle, LogOut, X } from 'lucide-react';
|
||||
|
||||
const navItems = [
|
||||
// belum kutambahin
|
||||
@@ -39,7 +39,7 @@ export default function SidebarMentor({ isOpen, closeSidebar }: SidebarProps) {
|
||||
return (
|
||||
<aside
|
||||
className={`
|
||||
bg-white border-r border-gray-300 flex flex-col py-5 px-3 gap-1 overflow-y-auto flex-shrink-0 transition-transform duration-300 ease-in-out
|
||||
bg-white border-r border-gray-300 flex flex-col py-5 px-1 gap-1 overflow-y-auto flex-shrink-0 transition-transform duration-300 ease-in-out
|
||||
md:sticky md:top-[50px] md:h-[calc(100vh-73px)] md:w-[250px] md:translate-x-0 md:z-8
|
||||
fixed top-0 left-0 h-[calc(100vh-50px)] w-[270px] z-40 pt-5
|
||||
${isOpen ? "translate-x-0" : "-translate-x-full"}
|
||||
@@ -71,10 +71,10 @@ export default function SidebarMentor({ isOpen, closeSidebar }: SidebarProps) {
|
||||
<button
|
||||
key={path}
|
||||
onClick={() => handleNav(path)}
|
||||
className={`flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-[13.5px] font-medium text-left transition-colors
|
||||
className={`flex items-center gap-2.5 w-full px-3 py-2 text-[13.5px] rounded-lg font-medium text-left transition-colors
|
||||
${isActive
|
||||
? "bg-base-foreground text-base font-semibold border-l-4 border-base"
|
||||
: "text-font hover:bg-base-foreground hover:text-base"
|
||||
? "bg-red-foreground text-red font-semibold border-l-6 border-red"
|
||||
: "text-font hover:bg-red-foreground"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-[18px] h-[18px] flex-shrink-0" />
|
||||
@@ -99,10 +99,10 @@ export default function SidebarMentor({ isOpen, closeSidebar }: SidebarProps) {
|
||||
onClick={() => isLogout ? handleLogout() : handleNav(path)}
|
||||
className={`flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-[13.5px] font-medium text-left transition-colors
|
||||
${isActive
|
||||
? "bg-red-50 text-red-600 font-semibold"
|
||||
? "bg-red-50 text-red-600 font-semibold border-l-6 border-red "
|
||||
: danger
|
||||
? "text-font hover:bg-base-foreground hover:text-base"
|
||||
: "text-font hover:bg-base-foreground hover:text-base"
|
||||
? "text-font hover:bg-base-foreground"
|
||||
: "text-font hover:bg-red-foreground"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-[18px] h-[18px] flex-shrink-0" />
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// components/SubmissionStatusPanel.tsx
|
||||
|
||||
import { FileText, CheckCircle2, AlertCircle, MinusCircle } from "lucide-react";
|
||||
import type { TaskSubmissionData, DisplayStatus } from "../../../../types/task.types";
|
||||
|
||||
// ─── StatusBadge ────────────────────────────────────────────────────────────
|
||||
|
||||
export function StatusBadge({ status }: { status: DisplayStatus }) {
|
||||
if (status === "submitted" || status === "done")
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-semibold text-green-700 bg-green-50 border border-green-200 px-2.5 py-1 rounded-full">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
{status === "done" ? "Done" : "Submitted for grading"}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (status === "overdue")
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-semibold text-red-700 bg-red-50 border border-red-200 px-2.5 py-1 rounded-full">
|
||||
<AlertCircle className="w-3.5 h-3.5" />
|
||||
Overdue
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-semibold text-gray-500 bg-gray-100 border border-gray-200 px-2.5 py-1 rounded-full">
|
||||
<MinusCircle className="w-3.5 h-3.5" />
|
||||
Not submitted
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── StatusTable ────────────────────────────────────────────────────────────
|
||||
|
||||
interface StatusTableProps {
|
||||
status: DisplayStatus;
|
||||
submission: TaskSubmissionData | null;
|
||||
}
|
||||
|
||||
export function StatusTable({ status, submission }: StatusTableProps) {
|
||||
const hasSubmission = status === "submitted" || status === "done";
|
||||
|
||||
const rows: { label: string; value: React.ReactNode }[] = [
|
||||
{
|
||||
label: "Submission status",
|
||||
value: <StatusBadge status={status} />,
|
||||
},
|
||||
{
|
||||
label: "Grading status",
|
||||
value: <span className="text-sm text-gray-400">-</span>,
|
||||
},
|
||||
{
|
||||
label: "Time Remaining",
|
||||
value: status === "overdue"
|
||||
? <span className="text-sm text-red-600 font-medium">Overdue</span>
|
||||
: <span className="text-sm text-gray-400">-</span>,
|
||||
},
|
||||
{
|
||||
label: "Submitted at",
|
||||
value: submission?.submitted_at
|
||||
? <span className="text-sm text-gray-700">{new Date(submission.submitted_at).toLocaleString("id-ID")}</span>
|
||||
: <span className="text-sm text-gray-400">-</span>,
|
||||
},
|
||||
{
|
||||
label: "File submission",
|
||||
value: hasSubmission && submission?.file_path
|
||||
? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-800">
|
||||
<FileText className="w-4 h-4 text-red-700 stroke-[2.5]" />
|
||||
<span>{submission.file_path.split("/").pop()}</span>
|
||||
</div>
|
||||
)
|
||||
: hasSubmission && submission?.url_link
|
||||
? (
|
||||
<a
|
||||
href={submission.url_link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 underline break-all"
|
||||
>
|
||||
{submission.url_link}
|
||||
</a>
|
||||
)
|
||||
: <span className="text-sm text-gray-400">-</span>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-box-secondary border border-box-border rounded-2xl overflow-hidden">
|
||||
<div className="divide-y divide-box-border">
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className="grid grid-cols-[180px_1fr]">
|
||||
<div className="px-4 py-3 text-sm text-gray-500 border-r border-box-border bg-gray-50/50">
|
||||
{row.label}
|
||||
</div>
|
||||
<div className="px-4 py-3">{row.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Outlet, Navigate } from "react-router-dom";
|
||||
|
||||
interface PrivateRouteProps {
|
||||
allowedRoles?: ("intern" | "admin")[];
|
||||
allowedRoles?: ("intern" | "admin" | "mentor")[];
|
||||
}
|
||||
|
||||
const decodeJWT = (token: string) => {
|
||||
@@ -32,7 +32,7 @@ const ProtectedRoutes = ({ allowedRoles }: PrivateRouteProps) => {
|
||||
}
|
||||
|
||||
const decoded = decodeJWT(token);
|
||||
const userRole = decoded?.role as"intern" | "admin" | undefined;
|
||||
const userRole = decoded?.role as "intern" | "admin" | "mentor" | undefined;
|
||||
|
||||
if (!userRole) {
|
||||
return <Navigate to="/login-internify" replace />;
|
||||
@@ -40,7 +40,7 @@ const ProtectedRoutes = ({ allowedRoles }: PrivateRouteProps) => {
|
||||
|
||||
if (allowedRoles && !allowedRoles.includes(userRole)) {
|
||||
// Jika role tidak diizinkan, kembalikan ke base dashboard masing-masing role
|
||||
if (userRole === "admin") return <Navigate to="/mentor" replace />;
|
||||
if (userRole === "admin" || userRole === "mentor") return <Navigate to="/mentor" replace />;
|
||||
if (userRole === "intern") return <Navigate to="/intern" replace />;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// hooks/useProjectInternProgress.ts
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { getProjectTasks, getTaskSubmissions } from "../services/TaskService";
|
||||
import type { AdminTaskDetail } from "../types/task.types";
|
||||
|
||||
export interface InternSubmissionProgress {
|
||||
id_user: number;
|
||||
full_name: string;
|
||||
email: string;
|
||||
avatar: string | null;
|
||||
submitted: number;
|
||||
total_tasks: number;
|
||||
is_eligible: boolean;
|
||||
}
|
||||
|
||||
export const useProjectInternProgress = (projectSlug: string) => {
|
||||
const [data, setData] = useState<InternSubmissionProgress[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
if (!projectSlug) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const tasks = await getProjectTasks(projectSlug);
|
||||
|
||||
const taskDetails = await Promise.all(
|
||||
tasks.map((task) =>
|
||||
getTaskSubmissions(task.slug, projectSlug) as Promise<AdminTaskDetail>
|
||||
)
|
||||
);
|
||||
|
||||
const internMap = new Map<number, InternSubmissionProgress>();
|
||||
|
||||
for (const taskDetail of taskDetails) {
|
||||
if (!taskDetail?.submissions) continue;
|
||||
|
||||
for (const sub of taskDetail.submissions) {
|
||||
// field yang benar: sub.submission (bukan submission_details)
|
||||
const hasSubmitted = !!sub.submission;
|
||||
const existing = internMap.get(sub.id_user);
|
||||
|
||||
if (existing) {
|
||||
existing.total_tasks += 1;
|
||||
if (hasSubmitted) existing.submitted += 1;
|
||||
} else {
|
||||
internMap.set(sub.id_user, {
|
||||
id_user: sub.id_user,
|
||||
full_name: sub.full_name,
|
||||
email: sub.email,
|
||||
avatar: sub.profile_picture, // field yang benar
|
||||
submitted: hasSubmitted ? 1 : 0,
|
||||
total_tasks: 1,
|
||||
is_eligible: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = Array.from(internMap.values()).map((intern) => ({
|
||||
...intern,
|
||||
is_eligible:
|
||||
intern.total_tasks > 0 && intern.submitted === intern.total_tasks,
|
||||
}));
|
||||
|
||||
setData(result);
|
||||
} catch {
|
||||
setError("Gagal memuat progress intern.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectSlug]);
|
||||
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
}, [refetch]);
|
||||
|
||||
return { data, loading, error, refetch };
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
// hooks/useMahasiswa.ts
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { Mahasiswa } from '../types/project.types';
|
||||
import { getMahasiswa } from '../services/MahasiswaService';
|
||||
|
||||
export const useMahasiswa = () => {
|
||||
const [mahasiswa, setMahasiswa] = useState<Mahasiswa[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
setLoading(true);
|
||||
getMahasiswa()
|
||||
.then(setMahasiswa)
|
||||
.catch(() => setError('Gagal memuat data mahasiswa.'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { refetch(); }, [refetch]);
|
||||
|
||||
return { mahasiswa, loading, error, refetch };
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
// hooks/useNotifications.ts
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { Notification } from "../types/certificate.types";
|
||||
import {
|
||||
getNotifications,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
} from "../services/NotificationService";
|
||||
|
||||
export const useNotifications = () => {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
setLoading(true);
|
||||
getNotifications()
|
||||
.then(setNotifications)
|
||||
.catch(() => setError("Gagal memuat notifikasi."))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { refetch(); }, [refetch]);
|
||||
|
||||
const markAllRead = async () => {
|
||||
try {
|
||||
await markAllNotificationsRead();
|
||||
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
|
||||
} catch { setError("Gagal menandai semua notifikasi."); }
|
||||
};
|
||||
|
||||
const markOneRead = async (id: number) => {
|
||||
try {
|
||||
await markNotificationRead(id);
|
||||
setNotifications(prev =>
|
||||
prev.map(n => n.id === id ? { ...n, is_read: true } : n)
|
||||
);
|
||||
} catch { setError("Gagal menandai notifikasi."); }
|
||||
};
|
||||
|
||||
const unreadCount = notifications.filter(n => !n.is_read).length;
|
||||
|
||||
return { notifications, unreadCount, loading, error, refetch, markAllRead, markOneRead };
|
||||
};
|
||||
+54
-10
@@ -21,8 +21,10 @@ import {
|
||||
updateProject,
|
||||
archiveProject,
|
||||
assignMember,
|
||||
getProjectMembers
|
||||
getProjectMembers,
|
||||
removeMember
|
||||
} from "../services/ProjectService";
|
||||
import { useMemo } from "react";
|
||||
|
||||
// GET /project-api/get
|
||||
export const useProjects = (status?: "active" | "completed" | "archived") => {
|
||||
@@ -87,30 +89,35 @@ export const useMyProjects = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const refetch = useCallback(() => {
|
||||
setLoading(true);
|
||||
getMyProjects()
|
||||
.then(setProjects)
|
||||
.catch(() => setError("Gagal memuat my projects."))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return { projects, loading, error };
|
||||
};
|
||||
useEffect(() => { refetch(); }, [refetch]);
|
||||
|
||||
return { projects, loading, error, refetch };
|
||||
};
|
||||
// GET /project-api/my-tasks
|
||||
export const useMyTasks = () => {
|
||||
const [tasks, setTasks] = useState<InternTaskItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const refetch = useCallback(() => {
|
||||
setLoading(true);
|
||||
getMyTasks()
|
||||
.then(setTasks)
|
||||
.catch(() => setError("Gagal memuat tasks."))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return { tasks, loading, error };
|
||||
useEffect(() => { refetch(); }, [refetch]);
|
||||
|
||||
return { tasks, loading, error, refetch };
|
||||
};
|
||||
|
||||
// GET /project-api/mentor-projects
|
||||
@@ -119,14 +126,17 @@ export const useMentorProjects = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const refetch = useCallback(() => {
|
||||
setLoading(true);
|
||||
getMentorProjects()
|
||||
.then(setProjects)
|
||||
.catch(() => setError("Gagal memuat mentor projects."))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return { projects, loading, error };
|
||||
useEffect(() => { refetch(); }, [refetch]);
|
||||
|
||||
return { projects, loading, error, refetch };
|
||||
};
|
||||
|
||||
// GET /project-api/interns
|
||||
@@ -135,14 +145,17 @@ export const useAllInterns = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const refetch = useCallback(() => {
|
||||
setLoading(true);
|
||||
getAllInterns()
|
||||
.then(setInterns)
|
||||
.catch(() => setError("Gagal memuat data interns."))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return { interns, loading, error };
|
||||
useEffect(() => { refetch(); }, [refetch]);
|
||||
|
||||
return { interns, loading, error, refetch };
|
||||
};
|
||||
|
||||
// POST /project-api/add
|
||||
@@ -166,6 +179,17 @@ export const useCreateProject = () => {
|
||||
return { create, loading, error };
|
||||
};
|
||||
|
||||
export const useProjectMyTasks = (projectId: number) => {
|
||||
const { tasks, loading, error } = useMyTasks();
|
||||
|
||||
const projectTasks = useMemo<InternTaskItem[]>(() => {
|
||||
if (!projectId || !tasks.length) return [];
|
||||
return tasks.filter(task => task.id_project === projectId);
|
||||
}, [tasks, projectId]);
|
||||
|
||||
return { tasks: projectTasks, loading, error };
|
||||
};
|
||||
|
||||
// PATCH /project-api/update/{id}
|
||||
export const useUpdateProject = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -227,4 +251,24 @@ export const useAssignMember = () => {
|
||||
};
|
||||
|
||||
return { assign, loading, error };
|
||||
};
|
||||
|
||||
export const useRemoveMember = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const remove = async (payload: { id_project: string | number, id_user: number }) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
return await removeMember(payload);
|
||||
} catch {
|
||||
setError("Gagal remove member.");
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { remove, loading, error };
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
// hooks/useCertificates.ts
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { Certificate, CertificateVerifyResult } from "../types/certificate.types";
|
||||
import {
|
||||
claimCertificate,
|
||||
getMyCertificates,
|
||||
getAllCertificates,
|
||||
getCertificateDetail,
|
||||
getCertificatesByProject,
|
||||
verifyCertificate,
|
||||
verifyCertificateByUuid,
|
||||
uploadCertificateTemplate,
|
||||
} from "../services/CertificateService";
|
||||
|
||||
// GET /certificate-api/my-certificates
|
||||
export const useMyCertificates = () => {
|
||||
const [certificates, setCertificates] = useState<Certificate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
setLoading(true);
|
||||
getMyCertificates()
|
||||
.then(setCertificates)
|
||||
.catch(() => setError("Gagal memuat sertifikat."))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { refetch(); }, [refetch]);
|
||||
|
||||
return { certificates, loading, error, refetch };
|
||||
};
|
||||
// GET /certificate-api/all
|
||||
export const useAllCertificates = () => {
|
||||
const [certificates, setCertificates] = useState<Certificate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
setLoading(true);
|
||||
getAllCertificates()
|
||||
.then(setCertificates)
|
||||
.catch(() => setError("Gagal memuat semua sertifikat."))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { refetch(); }, [refetch]);
|
||||
|
||||
return { certificates, loading, error, refetch };
|
||||
};
|
||||
|
||||
// GET /certificate-api/detail/{id}
|
||||
export const useCertificateDetail = (id: number) => {
|
||||
const [certificate, setCertificate] = useState<Certificate | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
getCertificateDetail(id)
|
||||
.then(setCertificate)
|
||||
.catch(() => setError("Gagal memuat detail sertifikat."))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
return { certificate, loading, error };
|
||||
};
|
||||
|
||||
// GET /certificate-api/project/{id_project}
|
||||
export const useProjectCertificates = (id_project: number) => {
|
||||
const [certificates, setCertificates] = useState<Certificate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
if (!id_project) return;
|
||||
setLoading(true);
|
||||
getCertificatesByProject(id_project)
|
||||
.then(setCertificates)
|
||||
.catch(() => setError("Gagal memuat sertifikat project."))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id_project]);
|
||||
|
||||
useEffect(() => { refetch(); }, [refetch]);
|
||||
|
||||
return { certificates, loading, error, refetch };
|
||||
};
|
||||
|
||||
// GET /certificate-api/verify & verify-uuid (public, on-demand)
|
||||
export const useVerifyCertificate = () => {
|
||||
const [result, setResult] = useState<CertificateVerifyResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const verifyByNo = useCallback(async (certificate_no: string) => {
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
const res = await verifyCertificate(certificate_no);
|
||||
setResult(res);
|
||||
return res;
|
||||
} catch { setError("Sertifikat tidak valid atau tidak ditemukan."); return null; }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
const verifyByUuid = useCallback(async (uuid: string) => {
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
const res = await verifyCertificateByUuid(uuid);
|
||||
setResult(res);
|
||||
return res;
|
||||
} catch { setError("Sertifikat tidak valid atau tidak ditemukan."); return null; }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
return { result, loading, error, verifyByNo, verifyByUuid };
|
||||
};
|
||||
|
||||
// POST /certificate-api/claim
|
||||
export const useClaimCertificate = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const claim = async (id_project: number) => {
|
||||
setLoading(true); setError(null);
|
||||
try { return await claimCertificate(id_project); }
|
||||
catch { setError("Gagal claim sertifikat."); return null; }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
return { claim, loading, error };
|
||||
};
|
||||
|
||||
// POST /certificate-api/projects/{id_project}/template
|
||||
export const useUploadCertificateTemplate = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const upload = async (id_project: number, file: File) => {
|
||||
setLoading(true); setError(null);
|
||||
try { return await uploadCertificateTemplate(id_project, file); }
|
||||
catch { setError("Gagal upload template."); return null; }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
return { upload, loading, error };
|
||||
};
|
||||
+45
-3
@@ -1,6 +1,6 @@
|
||||
// hooks/useTasks.ts
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { ProjectTask, ProjectTaskDetail, CreateTaskPayload, UpdateTaskPayload, TaskSubmissionData } from "../types/task.types";
|
||||
import type { ProjectTask, ProjectTaskDetail, CreateTaskPayload, UpdateTaskPayload, TaskSubmissionData, AdminTaskDetail } from "../types/task.types";
|
||||
import {
|
||||
getProjectTasks,
|
||||
getTaskById,
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
updateSubmissionFile,
|
||||
updateSubmissionLink,
|
||||
deleteSubmission,
|
||||
getAllMentorTasks,
|
||||
getTaskSubmissions,
|
||||
} from "../services/TaskService";
|
||||
import type { MentorTaskItem } from "../types/task.types";
|
||||
|
||||
// GET /task-api/projects/{id_project}/tasks
|
||||
export const useProjectTasks = (projectId: string) => {
|
||||
@@ -20,7 +23,7 @@ export const useProjectTasks = (projectId: string) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const refetch = useCallback(() => {
|
||||
if (!projectId) return;
|
||||
setLoading(true);
|
||||
getProjectTasks(projectId)
|
||||
@@ -29,7 +32,9 @@ export const useProjectTasks = (projectId: string) => {
|
||||
.finally(() => setLoading(false));
|
||||
}, [projectId]);
|
||||
|
||||
return { tasks, loading, error };
|
||||
useEffect(() => { refetch(); }, [refetch]);
|
||||
|
||||
return { tasks, loading, error, refetch }; // tambah refetch
|
||||
};
|
||||
|
||||
// GET /task-api/tasks/{id}
|
||||
@@ -52,6 +57,25 @@ export const useTaskDetail = (taskId: string, projectId?: string) => {
|
||||
return { task, loading, error, refetch };
|
||||
};
|
||||
|
||||
export const useTaskSubmissions = (taskId: string, projectId?: string) => {
|
||||
const [task, setTask] = useState<AdminTaskDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
if (!taskId) return;
|
||||
setLoading(true);
|
||||
getTaskSubmissions(taskId, projectId)
|
||||
.then(setTask)
|
||||
.catch(() => setError("Gagal memuat submissions."))
|
||||
.finally(() => setLoading(false));
|
||||
}, [taskId, projectId]);
|
||||
|
||||
useEffect(() => { refetch(); }, [refetch]);
|
||||
|
||||
return { task, loading, error, refetch };
|
||||
};
|
||||
|
||||
// POST /task-api/projects/{id_project}/tasks
|
||||
export const useCreateTask = (projectId: string) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -177,4 +201,22 @@ export const useSubmission = (taskId: string, initialSubmission?: TaskSubmission
|
||||
};
|
||||
|
||||
return { submission, submitFile, submitLink, updateFile, updateLink, remove, loading, error };
|
||||
};
|
||||
|
||||
// hooks/useAllMentorTasks.ts
|
||||
export const useAllMentorTasks = (enabled: boolean) => {
|
||||
const [tasks, setTasks] = useState<MentorTaskItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return; // tidak fetch kalau intern
|
||||
setLoading(true);
|
||||
getAllMentorTasks()
|
||||
.then(setTasks)
|
||||
.catch(() => setError("Gagal memuat tasks mentor."))
|
||||
.finally(() => setLoading(false));
|
||||
}, [enabled]);
|
||||
|
||||
return { tasks, loading, error };
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
// hooks/useUser.ts
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { loginUser, getProfile, updateProfile } from "../services/UserService";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CurrentUser {
|
||||
id: number;
|
||||
full_name: string;
|
||||
email: string;
|
||||
role: "mentor" | "intern" | "admin";
|
||||
professional_bio?: string;
|
||||
profile_picture?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateProfilePayload {
|
||||
full_name?: string;
|
||||
email?: string;
|
||||
professional_bio?: string;
|
||||
profile_picture?: File | null;
|
||||
}
|
||||
|
||||
// ─── useCurrentUser ───────────────────────────────────────────────────────────
|
||||
// GET /auth-api/me — ambil data user yang sedang login
|
||||
export const useCurrentUser = () => {
|
||||
const [user, setUser] = useState<CurrentUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getProfile()
|
||||
.then(setUser)
|
||||
.catch(() => setError("Gagal memuat data user."))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
}, [refetch]);
|
||||
|
||||
return { user, loading, error, refetch };
|
||||
};
|
||||
|
||||
// ─── useLogin ────────────────────────────────────────────────────────────────
|
||||
// POST /auth-api/login
|
||||
export const useLogin = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await loginUser({ email, password });
|
||||
return data; // { token }
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status;
|
||||
if (status === 401 || status === 400) {
|
||||
setError("Email atau password salah.");
|
||||
} else {
|
||||
setError("Terjadi kesalahan. Coba beberapa saat lagi.");
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { login, loading, error, setError };
|
||||
};
|
||||
|
||||
// ─── useUpdateProfile ────────────────────────────────────────────────────────
|
||||
// PATCH /auth-api/update-profile
|
||||
export const useUpdateProfile = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||
|
||||
const save = async (payload: UpdateProfilePayload) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccessMsg(null);
|
||||
try {
|
||||
const updated = await updateProfile(payload);
|
||||
setSuccessMsg("Profile updated successfully!");
|
||||
return updated;
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.message || "Failed to save changes.");
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { save, loading, error, successMsg, setSuccessMsg, setError };
|
||||
};
|
||||
+1
-1
@@ -124,7 +124,7 @@ dialog.custom-dialog::backdrop {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-bottom: 2px solid #b3b8c0; /* border-gray-100 */
|
||||
border-bottom: 1px solid #b3b8c0; /* border-gray-100 */
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
LuClipboardList,
|
||||
LuLightbulb,
|
||||
LuShield,
|
||||
} from "react-icons/lu";
|
||||
import type { IconType } from "react-icons";
|
||||
import { MdOutlineInsertChart } from "react-icons/md";
|
||||
import { IoMdCode, IoMdCloudOutline } from "react-icons/io";
|
||||
import { CiMobile3 } from "react-icons/ci";
|
||||
import { RiSettings3Fill } from "react-icons/ri";
|
||||
import { FiUsers } from "react-icons/fi";
|
||||
import { PiSpeedometer } from "react-icons/pi";
|
||||
|
||||
export const PROJECT_ICON_MAP: Record<string, IconType> = {
|
||||
code: IoMdCode,
|
||||
chart: MdOutlineInsertChart,
|
||||
cloud: IoMdCloudOutline,
|
||||
mobile: CiMobile3,
|
||||
gear: RiSettings3Fill,
|
||||
users:FiUsers,
|
||||
clipboard: LuClipboardList,
|
||||
speedometer: PiSpeedometer,
|
||||
lightbulb: LuLightbulb,
|
||||
shield: LuShield,
|
||||
};
|
||||
|
||||
export const PROJECT_ICON_CODES = Object.keys(PROJECT_ICON_MAP);
|
||||
|
||||
export const getProjectIcon = (code: string): IconType =>
|
||||
PROJECT_ICON_MAP[code] ?? IoMdCode;
|
||||
@@ -24,7 +24,7 @@ export interface CertificateData {
|
||||
}
|
||||
|
||||
export const certificateData: CertificateData = {
|
||||
isReceived: true,
|
||||
isReceived: false,
|
||||
projectProgress: 90,
|
||||
remainingTasks: 0,
|
||||
certificate: {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// services/CertificateService.ts
|
||||
import api from "../lib/api";
|
||||
import type {
|
||||
Certificate,
|
||||
CertificateVerifyResult,
|
||||
CertificateTemplate,
|
||||
} from "../types/certificate.types";
|
||||
|
||||
// POST /certificate-api/claim
|
||||
export const claimCertificate = async (id_project: number): Promise<Certificate> => {
|
||||
const res = await api.post("/certificate-api/claim", { id_project });
|
||||
return res.data.data;
|
||||
};
|
||||
|
||||
// GET /certificate-api/my-certificates
|
||||
export const getMyCertificates = async (): Promise<Certificate[]> => {
|
||||
const res = await api.get("/certificate-api/my-certificates");
|
||||
return res.data.data;
|
||||
};
|
||||
|
||||
// GET /certificate-api/all (admin only)
|
||||
export const getAllCertificates = async (): Promise<Certificate[]> => {
|
||||
const res = await api.get("/certificate-api/all");
|
||||
return res.data.data;
|
||||
};
|
||||
|
||||
// GET /certificate-api/detail/{id}
|
||||
export const getCertificateDetail = async (id: number): Promise<Certificate> => {
|
||||
const res = await api.get(`/certificate-api/detail/${id}`);
|
||||
return res.data.data;
|
||||
};
|
||||
|
||||
// GET /certificate-api/project/{id_project} (admin only)
|
||||
export const getCertificatesByProject = async (id_project: number): Promise<Certificate[]> => {
|
||||
const res = await api.get(`/certificate-api/project/${id_project}`);
|
||||
return res.data.data;
|
||||
};
|
||||
|
||||
// GET /certificate-api/verify?certificate_no=... (public)
|
||||
export const verifyCertificate = async (certificate_no: string): Promise<CertificateVerifyResult> => {
|
||||
const res = await api.get("/certificate-api/verify", {
|
||||
params: { certificate_no },
|
||||
});
|
||||
return res.data.data;
|
||||
};
|
||||
|
||||
// GET /certificate-api/verify-uuid/{uuid} (public)
|
||||
export const verifyCertificateByUuid = async (uuid: string): Promise<CertificateVerifyResult> => {
|
||||
const res = await api.get(`/certificate-api/verify-uuid/${uuid}`);
|
||||
return res.data.data;
|
||||
};
|
||||
|
||||
// POST /certificate-api/projects/{id_project}/template
|
||||
export const uploadCertificateTemplate = async (
|
||||
id_project: number,
|
||||
file: File
|
||||
): Promise<CertificateTemplate> => {
|
||||
const formData = new FormData();
|
||||
formData.append("template", file);
|
||||
const res = await api.post(`/certificate-api/projects/${id_project}/template`, formData);
|
||||
return res.data.data;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
// services/mahasiswa.service.ts
|
||||
import api from '../lib/api';
|
||||
import type { Mahasiswa, MahasiswaResponse } from "../types/project.types"
|
||||
|
||||
export const getMahasiswa = async (): Promise<Mahasiswa[]> => {
|
||||
const response = await api.get<MahasiswaResponse>('/mahasiswa-api/get');
|
||||
return response.data.data;
|
||||
};
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
// POST /project-api/add
|
||||
export const createProject = async (
|
||||
payload: CreateProjectPayload
|
||||
): Promise<ProjectDetail> => {
|
||||
): Promise<Project> => {
|
||||
const res = await api.post("/project-api/add", payload);
|
||||
return res.data.data;
|
||||
};
|
||||
@@ -23,7 +23,7 @@ export const createProject = async (
|
||||
// GET /project-api/get
|
||||
export const getProjects = async (
|
||||
status?: "active" | "completed" | "archived"
|
||||
): Promise<Project[]> => {
|
||||
): Promise<ProjectDetail[]> => {
|
||||
const res = await api.get("/project-api/get", {
|
||||
params: status ? { status } : undefined,
|
||||
});
|
||||
@@ -35,7 +35,16 @@ export const getProjectMembers = async (
|
||||
id: string | number
|
||||
): Promise<ProjectMember[]> => {
|
||||
const res = await api.get(`/project-api/get/${id}`);
|
||||
return res.data.data.members;
|
||||
console.log(res.data.data.member);
|
||||
return res.data.data.members.map((m: any) => ({
|
||||
id: m.id_user,
|
||||
avatar: m.user.profile_picture ?? null,
|
||||
full_name: m.user.full_name,
|
||||
email: m.user.email,
|
||||
professional_bio: m.user.professional_bio,
|
||||
position: m.user.position,
|
||||
kelompok_peminatan: m.user.kelompok_peminatan,
|
||||
}));
|
||||
};
|
||||
|
||||
// GET /project-api/get/{id}
|
||||
@@ -43,7 +52,21 @@ export const getProjectById = async (
|
||||
id: string | number
|
||||
): Promise<ProjectDetail> => {
|
||||
const res = await api.get(`/project-api/get/${id}`);
|
||||
return res.data.data;
|
||||
const raw = res.data.data;
|
||||
console.log("raw fields:", Object.keys(raw));
|
||||
console.log("raw full:", raw);
|
||||
return {
|
||||
...raw,
|
||||
members: raw.members.map((m: any) => ({
|
||||
id: m.id_user,
|
||||
avatar: m.user?.profile_picture ?? null,
|
||||
full_name: m.user?.full_name,
|
||||
email: m.user?.email,
|
||||
professional_bio: m.user?.professional_bio ?? null,
|
||||
position: m.user?.position,
|
||||
kelompok_peminatan: m.user?.kelompok_peminatan,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
// PATCH /project-api/update/{id}
|
||||
@@ -93,4 +116,14 @@ export const assignMember = async (
|
||||
): Promise<MemberAssignment> => {
|
||||
const res = await api.post("/project-api/assign-member", payload);
|
||||
return res.data.data;
|
||||
};
|
||||
|
||||
export const removeMember = async (
|
||||
payload: {
|
||||
id_project: string | number;
|
||||
id_user: number;
|
||||
}
|
||||
): Promise<MemberAssignment> => {
|
||||
const res = await api.post("/project-api/remove-member", { id_project: payload.id_project, id_user: payload.id_user });
|
||||
return res.data.data;
|
||||
};
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
UpdateTaskPayload,
|
||||
TaskSubmissionData,
|
||||
} from "../types/task.types";
|
||||
import type { Project } from "../types/project.types";
|
||||
import type { MentorTaskItem, AdminTaskDetail } from "../types/task.types";
|
||||
|
||||
// ── Task ──────────────────────────────────────────────
|
||||
|
||||
@@ -35,6 +37,16 @@ export const getTaskById = async (
|
||||
return res.data.data;
|
||||
};
|
||||
|
||||
export const getTaskSubmissions = async (
|
||||
taskId: string | number,
|
||||
projectId?: string | number
|
||||
): Promise<AdminTaskDetail> => {
|
||||
const res = await api.get(`/task-api/tasks/${taskId}`, {
|
||||
params: projectId ? { project: projectId } : undefined,
|
||||
});
|
||||
return res.data.data;
|
||||
};
|
||||
|
||||
export const updateTask = async (
|
||||
taskId: string | number,
|
||||
payload: UpdateTaskPayload,
|
||||
@@ -55,6 +67,26 @@ export const deleteTask = async (
|
||||
});
|
||||
};
|
||||
|
||||
export const getAllMentorTasks = async (): Promise<MentorTaskItem[]> => {
|
||||
const projectsRes = await api.get("/project-api/mentor-projects");
|
||||
const projects: Project[] = projectsRes.data.data;
|
||||
|
||||
const tasksByProject = await Promise.all(
|
||||
projects.map(async (project) => {
|
||||
const tasksRes = await api.get(`/task-api/projects/${project.id}/tasks`);
|
||||
return tasksRes.data.data.map((task: any) => ({
|
||||
...task,
|
||||
project_name: project.project_name,
|
||||
project_slug: project.slug,
|
||||
}));
|
||||
})
|
||||
);
|
||||
|
||||
return tasksByProject.flat();
|
||||
};
|
||||
|
||||
|
||||
|
||||
// ── Submission ────────────────────────────────────────
|
||||
|
||||
export const submitTaskFile = async (
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// services/UserService.ts
|
||||
import api from "../lib/api";
|
||||
import type { CurrentUser, UpdateProfilePayload } from "../hooks/useUser";
|
||||
|
||||
// POST /auth-api/login
|
||||
export const loginUser = async (payload: {
|
||||
email: string;
|
||||
password: string;
|
||||
}): Promise<{ token: string }> => {
|
||||
const res = await api.post("/auth-api/login", payload);
|
||||
return res.data.data;
|
||||
};
|
||||
|
||||
// GET /auth-api/me
|
||||
export const getProfile = async (): Promise<CurrentUser> => {
|
||||
const res = await api.get("/auth-api/me");
|
||||
return res.data.data;
|
||||
};
|
||||
|
||||
// PATCH /auth-api/update-profile
|
||||
// Jika payload mengandung profile_picture (File), kirim sebagai multipart/form-data
|
||||
export const updateProfile = async (
|
||||
payload: UpdateProfilePayload
|
||||
): Promise<CurrentUser> => {
|
||||
if (payload.profile_picture instanceof File) {
|
||||
const formData = new FormData();
|
||||
if (payload.full_name !== undefined) formData.append("full_name", payload.full_name);
|
||||
if (payload.email !== undefined) formData.append("email", payload.email);
|
||||
if (payload.professional_bio !== undefined) formData.append("professional_bio", payload.professional_bio);
|
||||
formData.append("profile_picture", payload.profile_picture);
|
||||
const res = await api.patch("/auth-api/update-profile", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return res.data.data;
|
||||
}
|
||||
const res = await api.patch("/auth-api/update-profile", payload);
|
||||
return res.data.data;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// types/certificate.types.ts
|
||||
|
||||
export interface CertificateUser {
|
||||
id: number;
|
||||
full_name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface CertificateProject {
|
||||
id: number;
|
||||
project_name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface Certificate {
|
||||
id: number;
|
||||
id_project: number;
|
||||
id_user: number;
|
||||
certificate_no: string;
|
||||
issued_at: string;
|
||||
uuid: string;
|
||||
user: CertificateUser;
|
||||
project: CertificateProject;
|
||||
certificate_status: string;
|
||||
}
|
||||
|
||||
export interface CertificateVerifyResult {
|
||||
uuid: string;
|
||||
intern_name: string;
|
||||
project_name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CertificateTemplate {
|
||||
id_project: number;
|
||||
certificate_template: string;
|
||||
}
|
||||
|
||||
// Notification
|
||||
export interface Notification {
|
||||
id: number;
|
||||
id_user: number;
|
||||
id_admin: number | null;
|
||||
title: string;
|
||||
message: string;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// types/project.types.ts
|
||||
import type { ProjectTask } from "./task.types";
|
||||
|
||||
export type ProjectStatus = "active" | "completed" | "archived";
|
||||
export type MemberStatus = "active" | "inactive";
|
||||
@@ -11,19 +11,12 @@ export interface ProjectAdmin {
|
||||
professional_bio: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectMember {
|
||||
id: number;
|
||||
id_user: number;
|
||||
status?: MemberStatus;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// Response GET /project-api/get dan GET /project-api/get/{id}
|
||||
export interface Project {
|
||||
id: number;
|
||||
slug: string;
|
||||
project_icon: string;
|
||||
background_color: string;
|
||||
project_name: string;
|
||||
description: string;
|
||||
start_date: string;
|
||||
@@ -31,6 +24,7 @@ export interface Project {
|
||||
status: ProjectStatus;
|
||||
admin: ProjectAdmin;
|
||||
total_members: number;
|
||||
task_done: number;
|
||||
total_tasks: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -39,6 +33,7 @@ export interface Project {
|
||||
// Detail project (includes members array)
|
||||
export interface ProjectDetail extends Project {
|
||||
members: ProjectMember[];
|
||||
tasks: ProjectTask[];
|
||||
}
|
||||
|
||||
// Response GET /project-api/my-tasks
|
||||
@@ -111,10 +106,30 @@ export interface MemberAssignment {
|
||||
}
|
||||
|
||||
export interface ProjectMember {
|
||||
avatar: string
|
||||
id: number;
|
||||
full_name: string;
|
||||
email: string;
|
||||
professional_bio: string | null;
|
||||
position: string;
|
||||
kelompok_peminatan: string;
|
||||
}
|
||||
|
||||
export interface Mahasiswa {
|
||||
id: number;
|
||||
nama_depan: string;
|
||||
nama_belakang: string;
|
||||
email: string;
|
||||
kontak: string;
|
||||
jurusan: string;
|
||||
role: string;
|
||||
cv_path: string;
|
||||
portofolio_path: string;
|
||||
motivasi: string;
|
||||
relevant_skills: string;
|
||||
}
|
||||
|
||||
export interface MahasiswaResponse {
|
||||
message: string;
|
||||
data: Mahasiswa[];
|
||||
}
|
||||
+18
-4
@@ -51,19 +51,33 @@ export interface AdminTaskDetail extends ProjectTask {
|
||||
submissions: Array<{
|
||||
id_user: number;
|
||||
full_name: string;
|
||||
email: string;
|
||||
file_path: string | null;
|
||||
url_link: string | null;
|
||||
profile_picture: string | null;
|
||||
submitted_at: string | null;
|
||||
updated_at: string | null;
|
||||
display_status: DisplayStatus;
|
||||
submission: TaskSubmissionData | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CreateTaskPayload {
|
||||
title: string;
|
||||
description: string;
|
||||
deadline_date: string; // "YYYY-MM-DD"
|
||||
specific_time: string; // "HH:mm"
|
||||
deadline_date: string;
|
||||
specific_time: string;
|
||||
submission_type: SubmissionType;
|
||||
}
|
||||
|
||||
export interface UpdateTaskPayload extends Partial<CreateTaskPayload> {}
|
||||
export interface UpdateTaskPayload extends Partial<CreateTaskPayload> {}
|
||||
|
||||
export interface MentorTaskItem {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
deadline_at: string;
|
||||
submission_type: "file_upload" | "url_link";
|
||||
project_name: string;
|
||||
project_slug: string;
|
||||
}
|
||||
Reference in New Issue
Block a user