feat: implement authentication system, role-based routing, and profile settings for interns and mentors
This commit is contained in:
+6
-6
@@ -42,11 +42,11 @@ 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 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/CertificateDetail";
|
||||
import CertificateResult from "./Pages/Internify/mentor/CertificateGenerate";
|
||||
@@ -96,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 />} />
|
||||
@@ -106,8 +106,8 @@ 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 />} />
|
||||
@@ -120,7 +120,7 @@ function App() {
|
||||
<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>
|
||||
|
||||
|
||||
@@ -1,14 +1,35 @@
|
||||
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): string {
|
||||
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 ? getInitials(user.full_name) : "U";
|
||||
const hasPhoto = !!user?.profile_picture;
|
||||
|
||||
return (
|
||||
<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 +41,30 @@ export default function Header({ toggleSidebar }: { toggleSidebar: () => void })
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-6 items-center">
|
||||
<button onClick={()=>nav("notifications")}>
|
||||
<button onClick={() => nav("notifications")}>
|
||||
<Bell className="w-5 h-5 hover:text-red-800"/>
|
||||
</button>
|
||||
<div className="bg-red rounded-full 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>
|
||||
)
|
||||
|
||||
@@ -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,42 +19,32 @@ 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 });
|
||||
console.log(response.data.data)
|
||||
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 === "mentor" || 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 data = await login(email, password);
|
||||
if (!data) return;
|
||||
|
||||
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.");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -130,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>
|
||||
|
||||
|
||||
@@ -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,11 +1,11 @@
|
||||
import { Award, Loader2, AlertCircle } from "lucide-react";
|
||||
import { useMyCertificates } from "../../../hooks/useSertificates";
|
||||
import type { Certificate } from "../../../types/certificate.types";
|
||||
import certificateImg from "../../../assets/certificate.png";
|
||||
import { Loader2, AlertCircle } from "lucide-react";
|
||||
import { useMyProjects } from "../../../hooks/useProjects";
|
||||
import type { Project } from "../../../types/project.types";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export default function SertificateList() {
|
||||
const { certificates, loading, error } = useMyCertificates();
|
||||
|
||||
const { projects, loading, error } = useMyProjects();
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
@@ -31,44 +31,52 @@ export default function SertificateList() {
|
||||
)}
|
||||
|
||||
{/* Empty */}
|
||||
{!loading && !error && certificates.length === 0 && (
|
||||
{!loading && !error && projects.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
|
||||
<p className="text-sm">No certificates yet.</p>
|
||||
<p className="text-sm">No ongoing projects yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid */}
|
||||
{!loading && !error && certificates.length > 0 && (
|
||||
{!loading && !error && projects.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{certificates.map((item: Certificate) => (
|
||||
{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}`)}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="p-6 pb-0">
|
||||
<div className="rounded-lg overflow-hidden">
|
||||
<img
|
||||
src={certificateImg}
|
||||
alt={item.project.project_name}
|
||||
className="w-full h-[160px] object-cover"
|
||||
/>
|
||||
<div className="w-full h-32 bg-gradient-to-br from-red-400 to-red-600 flex items-center justify-center">
|
||||
<img
|
||||
src={item.project_icon}
|
||||
alt={item.project_name}
|
||||
className="w-16 h-16 object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-xs text-gray-400 mb-0.5">
|
||||
{new Date(item.issued_at).toLocaleDateString("id-ID", {
|
||||
{new Date(item.start_date).toLocaleDateString("id-ID", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
})}
|
||||
until
|
||||
{new Date(item.start_date).toLocaleDateString("id-ID", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-gray-800 leading-snug">
|
||||
{item.project.project_name}
|
||||
{item.end_date}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
No: <span className="font-bold text-red-600">{item.certificate_no}</span>
|
||||
No: <span className="font-bold text-red-600">{item.status}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,52 +1,32 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import CertificateAvailable from "./components/CertificateAvailabe";
|
||||
import CertificateNotAvailable from "./components/CertificateNotAvailable";
|
||||
import SertificateHistory from "./components/CertificateHistory";
|
||||
import { useMyCertificates, useClaimCertificate } from "../../../hooks/useSertificates";
|
||||
import { useMyProjects } from "../../../hooks/useProjects";
|
||||
import type { ProjectDetail } from "../../../types/project.types";
|
||||
import { useMyCertificates } from "../../../hooks/useSertificates";
|
||||
import { useProjectDetail } from "../../../hooks/useProjects";
|
||||
|
||||
const SertificatePage = () => {
|
||||
const { projects, loading: projectsLoading } = useMyProjects();
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { project, loading: projectLoading } = useProjectDetail(slug ?? "");
|
||||
const { certificates, loading: certLoading } = useMyCertificates();
|
||||
const { claim, loading: claiming } = useClaimCertificate();
|
||||
const [justClaimed, setJustClaimed] = useState(false);
|
||||
|
||||
const current_task = useMemo(() => {
|
||||
if (!projects || projects.length === 0) return null;
|
||||
return projects.find((p) => p.task_done < p.total_tasks) ?? null;
|
||||
}, [projects]);
|
||||
const allTasksDone = !projectLoading && !!project && project.task_done >= project.total_tasks;
|
||||
|
||||
const finished_task = useMemo((): ProjectDetail | null => {
|
||||
if (!projects || projects.length === 0) return null;
|
||||
return projects.find((p) => p.task_done >= p.total_tasks) ?? null;
|
||||
}, [projects]);
|
||||
|
||||
// Cek apakah finished_task sudah punya certificate di backend
|
||||
const alreadyClaimed = useMemo(() => {
|
||||
if (!finished_task || !certificates.length) return false;
|
||||
return certificates.some((cert) => cert.project.id === finished_task.id);
|
||||
}, [certificates, finished_task]);
|
||||
|
||||
const allTasksDone = !projectsLoading && current_task === null && projects.length > 0;
|
||||
const alreadyDone = useMemo(() => {
|
||||
if (!project || !certificates.length) return false;
|
||||
return certificates.some((cert) => cert.project.id === project.id);
|
||||
}, [certificates, project]);
|
||||
|
||||
const progress =
|
||||
current_task && current_task.total_tasks > 0
|
||||
? Math.round((current_task.task_done / current_task.total_tasks) * 100)
|
||||
: 100;
|
||||
project && project.total_tasks > 0
|
||||
? Math.round((project.task_done / project.total_tasks) * 100)
|
||||
: 0;
|
||||
|
||||
const remainingTasks = current_task
|
||||
? current_task.total_tasks - current_task.task_done
|
||||
: 0;
|
||||
const remainingTasks = project ? project.total_tasks - project.task_done : 0;
|
||||
|
||||
const handleClaim = async () => {
|
||||
if (!finished_task) return;
|
||||
const result = await claim(finished_task.id);
|
||||
if (result) setJustClaimed(true);
|
||||
};
|
||||
|
||||
const isLoading = projectsLoading || certLoading;
|
||||
const showCertificate = alreadyClaimed || justClaimed;
|
||||
const isLoading = projectLoading || certLoading;
|
||||
const showCertificate = alreadyDone;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -55,7 +35,7 @@ const SertificatePage = () => {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
console.log(finished_task)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex flex-col gap-1">
|
||||
@@ -65,14 +45,12 @@ const SertificatePage = () => {
|
||||
<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={finished_task} />
|
||||
<CertificateAvailable project={project} />
|
||||
) : (
|
||||
<CertificateNotAvailable
|
||||
progress={progress}
|
||||
remainingTasks={remainingTasks}
|
||||
allTasksDone={allTasksDone}
|
||||
onClaim={handleClaim}
|
||||
claiming={claiming}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import { Lock, Award, Loader2 } from "lucide-react";
|
||||
import { Clock, Lock, Award } from "lucide-react";
|
||||
|
||||
interface CertificateNotAvailableProps {
|
||||
progress: number;
|
||||
remainingTasks: number;
|
||||
allTasksDone?: boolean;
|
||||
onClaim?: () => void;
|
||||
claiming?: boolean;
|
||||
}
|
||||
|
||||
export default function CertificateNotAvailable({
|
||||
progress,
|
||||
remainingTasks,
|
||||
allTasksDone = false,
|
||||
onClaim,
|
||||
claiming = false,
|
||||
}: CertificateNotAvailableProps) {
|
||||
return (
|
||||
<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">
|
||||
@@ -27,9 +23,9 @@ export default function CertificateNotAvailable({
|
||||
<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">
|
||||
<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-4 h-4 text-yellow-800" />
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,27 +46,8 @@ export default function CertificateNotAvailable({
|
||||
<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!</p>
|
||||
<p className="text-xs text-gray-400 mt-2 text-left">Semua tugas telah diselesaikan! Silahkan tunggu untuk mendapatkan sertifikat</p>
|
||||
</div>
|
||||
|
||||
{/* Tombol Claim */}
|
||||
<button
|
||||
onClick={onClaim}
|
||||
disabled={claiming}
|
||||
className="flex items-center justify-center gap-2 px-8 py-3 bg-red-700 hover:bg-red-800 disabled:opacity-60 active:scale-95 transition-all text-white font-semibold rounded-xl text-sm"
|
||||
>
|
||||
{claiming ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Mengklaim...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Award className="w-4 h-4" />
|
||||
Claim Certificate
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -47,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]" />
|
||||
@@ -65,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
|
||||
@@ -79,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
|
||||
@@ -92,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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -4,11 +4,7 @@ import * as LucideIcons from 'lucide-react';
|
||||
import type { LucideProps } from 'lucide-react';
|
||||
import type { Project } from '../../../../types/project.types';
|
||||
|
||||
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, '-');
|
||||
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// hooks/useMahasiswa.ts
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { Mahasiswa } from '../types/project.types';
|
||||
import { getMahasiswa } from '../services/MahasiswaService';
|
||||
|
||||
@@ -8,12 +8,15 @@ export const useMahasiswa = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const refetch = useCallback(() => {
|
||||
setLoading(true);
|
||||
getMahasiswa()
|
||||
.then(setMahasiswa)
|
||||
.catch(() => setError('Gagal memuat data mahasiswa.'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return { mahasiswa, loading, error };
|
||||
useEffect(() => { refetch(); }, [refetch]);
|
||||
|
||||
return { mahasiswa, loading, error, refetch };
|
||||
};
|
||||
@@ -67,7 +67,7 @@ export const useProjectDetail = (id: string) => {
|
||||
|
||||
//Khusus Ambil Intern dari Project/{id}
|
||||
export const useProjectInterns = (projectId: string) => {
|
||||
const [interns, setInterns] = useState<ProjectMember[]>([] );
|
||||
const [interns, setInterns] = useState<ProjectMember[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -89,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
|
||||
@@ -121,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
|
||||
|
||||
@@ -18,30 +18,35 @@ export const useMyCertificates = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const refetch = useCallback(() => {
|
||||
setLoading(true);
|
||||
getMyCertificates()
|
||||
.then(setCertificates)
|
||||
.catch(() => setError("Gagal memuat sertifikat."))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return { certificates, loading, error };
|
||||
};
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
const refetch = useCallback(() => {
|
||||
setLoading(true);
|
||||
getAllCertificates()
|
||||
.then(setCertificates)
|
||||
.catch(() => setError("Gagal memuat semua sertifikat."))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return { certificates, loading, error };
|
||||
useEffect(() => { refetch(); }, [refetch]);
|
||||
|
||||
return { certificates, loading, error, refetch };
|
||||
};
|
||||
|
||||
// GET /certificate-api/detail/{id}
|
||||
@@ -67,15 +72,18 @@ export const useProjectCertificates = (id_project: number) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
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]);
|
||||
|
||||
return { certificates, loading, error };
|
||||
useEffect(() => { refetch(); }, [refetch]);
|
||||
|
||||
return { certificates, loading, error, refetch };
|
||||
};
|
||||
|
||||
// GET /certificate-api/verify & verify-uuid (public, on-demand)
|
||||
|
||||
+81
-7
@@ -1,24 +1,98 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import api from "../lib/api";
|
||||
// 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);
|
||||
|
||||
useEffect(() => {
|
||||
api.get("/auth-api/me")
|
||||
.then((res) => setUser(res.data.data))
|
||||
.catch(() => setError("Gagal memuat user."))
|
||||
const refetch = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getProfile()
|
||||
.then(setUser)
|
||||
.catch(() => setError("Gagal memuat data user."))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return { user, loading, error };
|
||||
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,3 +1,38 @@
|
||||
// services/UserService.ts
|
||||
import api from "../lib/api";
|
||||
import type { CurrentUser, UpdateProfilePayload } from "../hooks/useUser";
|
||||
|
||||
// POST auth-api/login
|
||||
// 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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user