feat: enhance user profile update functionality and error handling
- Added detailed error messages for various HTTP status codes in useUpdateProfile hook. - Refactored updateProfile service to handle full_name as separate first and last name fields. - Updated UpdateProfilePayload type to allow profile_picture to be a string or File. - Introduced resolveFileUrl utility function for consistent file URL resolution. - Improved loading and error state management in useUpdateProfile hook. - Added DEFAULT_AVATAR constant for default user avatar.
This commit is contained in:
@@ -2,7 +2,7 @@ import { Bell, Menu } from "lucide-react"
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import humiclogo from "../assets/logo.png";
|
import humiclogo from "../assets/logo.png";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useCurrentUser } from "../hooks/useUser";
|
import { useCurrentUser, resolveFileUrl, DEFAULT_AVATAR } from "../hooks/useUser";
|
||||||
|
|
||||||
function getInitials(fullName: string | undefined | null): string {
|
function getInitials(fullName: string | undefined | null): string {
|
||||||
if (!fullName) return "U";
|
if (!fullName) return "U";
|
||||||
@@ -17,15 +17,12 @@ export default function Header({ toggleSidebar }: { toggleSidebar: () => void })
|
|||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const displayName = user?.full_name?.trim() || [user?.nama_depan, user?.nama_belakang].filter(Boolean).join(" ") || "";
|
||||||
setImageError(false);
|
|
||||||
}, [user?.profile_picture, user?.full_name]);
|
|
||||||
|
|
||||||
const settingsPath =
|
const photoUrl = resolveFileUrl(user?.profile_picture);
|
||||||
user?.role === "intern" ? "/intern" : "/mentor";
|
|
||||||
|
|
||||||
const initials = user?.full_name ? getInitials(user.full_name) : "U";
|
const initials = getInitials(displayName);
|
||||||
const hasPhoto = !!user?.profile_picture && !imageError;
|
const hasPhoto = !!photoUrl && !imageError;
|
||||||
|
|
||||||
return (
|
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'>
|
<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'>
|
||||||
@@ -49,13 +46,12 @@ export default function Header({ toggleSidebar }: { toggleSidebar: () => void })
|
|||||||
|
|
||||||
{/* Gambar Profil */}
|
{/* Gambar Profil */}
|
||||||
<button
|
<button
|
||||||
onClick={() => nav(settingsPath)}
|
className="rounded-full w-9 h-9 overflow-hidden border-2 border-gray-200"
|
||||||
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"
|
title="Profile Settings"
|
||||||
>
|
>
|
||||||
{hasPhoto ? (
|
{hasPhoto ? (
|
||||||
<img
|
<img
|
||||||
src={user?.profile_picture ?? ""}
|
src={`${import.meta.env.VITE_API_BASE_URL}${user?.profile_picture ?? ""}`}
|
||||||
alt={user?.full_name ?? "Profile"}
|
alt={user?.full_name ?? "Profile"}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
onError={() => setImageError(true)}
|
onError={() => setImageError(true)}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default function NotFoundPage() {
|
|||||||
|
|
||||||
{/* 404 Large Text */}
|
{/* 404 Large Text */}
|
||||||
<div className="relative mb-4 select-none">
|
<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">
|
<span className="text-[160px] sm:text-[200px] font-black leading-none bg-gradient-to-b from-red to-red/60 bg-clip-text text-transparent">
|
||||||
404
|
404
|
||||||
</span>
|
</span>
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
@@ -22,13 +22,13 @@ export default function NotFoundPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<h1 className="text-3xl sm:text-4xl font-bold text-white mb-3 tracking-tight">
|
<h1 className="text-3xl sm:text-4xl font-bold text-red/60 mb-3 tracking-tight">
|
||||||
Not Found
|
Tidak Ditemukan
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<p className="text-slate-800 text-base sm:text-lg mb-10 max-w-md leading-relaxed">
|
<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.
|
Maaf, halaman yang Anda cari tidak ada atau telah dipindahkan. Mari kembali ke jalur yang benar.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
@@ -38,7 +38,7 @@ export default function NotFoundPage() {
|
|||||||
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
|
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(-1)}
|
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"
|
className="flex items-center justify-center gap-2 px-6 py-3 rounded-xl border border-red/60 bg-white/30 hover:bg-white/15 text-red-700 font-semibold text-sm transition-all active:scale-95 backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
Kembali
|
Kembali
|
||||||
|
|||||||
@@ -8,6 +8,21 @@ interface FAQ {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skeleton item that mirrors the real FAQ card shape
|
||||||
|
function FAQSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="relative shadow-md py-5 px-10 rounded-md bg-white border-l-6 border-gray-200 animate-pulse">
|
||||||
|
{/* Question bar */}
|
||||||
|
<div className="h-5 bg-gray-200 rounded w-2/3 mb-3" />
|
||||||
|
{/* Answer bars */}
|
||||||
|
<div className="pl-8 space-y-2">
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-full" />
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-4/5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function FAQPage() {
|
export default function FAQPage() {
|
||||||
const [faq, setFaq] = useState<FAQ[]>([]);
|
const [faq, setFaq] = useState<FAQ[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -37,7 +52,11 @@ export default function FAQPage() {
|
|||||||
|
|
||||||
{/* FAQ Lists */}
|
{/* FAQ Lists */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-gray-500">Memuat FAQ...</p>
|
<div className="space-y-8 max-w-auto">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<FAQSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-8 max-w-auto">
|
<div className="space-y-8 max-w-auto">
|
||||||
{faq.map((item) => (
|
{faq.map((item) => (
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import logo from "../../assets/logo.png";
|
import logo from "../../assets/logo.png";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Mail, Lock, Eye, EyeOff } from 'lucide-react';
|
import { Mail, Lock, Eye, EyeOff } from 'lucide-react';
|
||||||
import { useLogin } from "../../hooks/useUser";
|
import { useLogin } from "../../hooks/useUser";
|
||||||
|
import { customToast } from "../utils/showToast";
|
||||||
|
import { CircleQuestionMark } from 'lucide-react';
|
||||||
|
|
||||||
const decodeJWT = (token: string) => {
|
const decodeJWT = (token: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -22,6 +24,12 @@ export default function InternifyLogin() {
|
|||||||
|
|
||||||
const { login, loading, error, setError } = useLogin();
|
const { login, loading, error, setError } = useLogin();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!error) return;
|
||||||
|
customToast.error("Login gagal", error);
|
||||||
|
setError(null);
|
||||||
|
}, [error, setError]);
|
||||||
|
|
||||||
const handleSignIn = async (e: React.FormEvent) => {
|
const handleSignIn = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -77,7 +85,7 @@ export default function InternifyLogin() {
|
|||||||
placeholder="Email Account"
|
placeholder="Email Account"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-2.5 bg-white border border-gray-200 rounded-lg text-sm placeholder-gray-400 focus:outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500 transition-colors"
|
className="w-full pl-10 pr-4 py-2.5 bg-white border border-card-outline rounded-lg text-sm placeholder-gray-400 focus:outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500 transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,7 +101,7 @@ export default function InternifyLogin() {
|
|||||||
placeholder="Account Password"
|
placeholder="Account Password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="w-full pl-10 pr-10 py-2.5 bg-white border border-gray-200 rounded-lg text-sm placeholder-gray-400 focus:outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500 transition-colors"
|
className="w-full pl-10 pr-10 py-2.5 bg-white border border-card-outline rounded-lg text-sm placeholder-gray-400 focus:outline-none focus:border-red-500 focus:ring-1 focus:ring-red-500 transition-colors"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -108,22 +116,14 @@ export default function InternifyLogin() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="text-xs text-red-600 bg-red-50 p-2 rounded-md font-medium">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
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"
|
className="w-full bg-red hover:bg-red/80 text-white font-medium text-sm py-2.5 rounded-lg transition-colors shadow-sm mt-2"
|
||||||
>
|
>
|
||||||
{loading ? "Signing in..." : "Sign In"}
|
{loading ? "Signing in..." : "Sign In"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Forgot Password */}
|
{/* Forgot Password */}
|
||||||
<div className="text-center mt-6">
|
<div className="text-center mt-6">
|
||||||
<a href="#" className="text-xs font-semibold text-red-600 hover:underline">
|
<a href="#" className="text-xs font-semibold text-red-600 hover:underline">
|
||||||
@@ -134,17 +134,15 @@ export default function InternifyLogin() {
|
|||||||
{/* Divider & Support */}
|
{/* Divider & Support */}
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<div className="relative flex py-2 items-center">
|
<div className="relative flex py-2 items-center">
|
||||||
<div className="flex-grow border-t border-gray-100"></div>
|
<div className="flex-grow border-t border-card-outline"></div>
|
||||||
<span className="flex-shrink mx-4 text-[10px] font-bold tracking-wider text-font-shade uppercase">
|
<span className="flex-shrink mx-4 text-[10px] font-bold tracking-wider text-font-shade uppercase">
|
||||||
SUPPORT
|
SUPPORT
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-grow border-t border-gray-100"></div>
|
<div className="flex-grow border-t border-card-outline"></div>
|
||||||
</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-font-shade hover:bg-gray-50 transition-colors font-medium">
|
<button className="w-full mt-2 flex items-center justify-center gap-2 border border-card-outline 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">
|
<CircleQuestionMark className="w-5 h-5"/>
|
||||||
<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
|
Frequently Asked Questions
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { LuFileSpreadsheet } from "react-icons/lu";
|
|||||||
import { io } from "socket.io-client";
|
import { io } from "socket.io-client";
|
||||||
import * as NotificationService from "../../services/NotificationService";
|
import * as NotificationService from "../../services/NotificationService";
|
||||||
import type { UINotification, BackendNotification } from "../../types/notification.types";
|
import type { UINotification, BackendNotification } from "../../types/notification.types";
|
||||||
|
import mailRafiki from '../../assets/Mail-rafiki.svg'
|
||||||
|
|
||||||
const getCookieToken = () => {
|
const getCookieToken = () => {
|
||||||
return document.cookie
|
return document.cookie
|
||||||
@@ -95,7 +96,10 @@ export default function NotificationList() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{notifications.length === 0 && (
|
{notifications.length === 0 && (
|
||||||
<p className="text-sm text-gray-400">Belum ada notifikasi.</p>
|
<div className="flex flex-col items-center justify-center pt-14 gap-3">
|
||||||
|
<img src={mailRafiki} alt="empty notification" className="w-[280px]"/>
|
||||||
|
<p className="text-lg text-font">Belum ada pesan notifikasi yang diterima</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -165,9 +169,9 @@ export default function NotificationList() {
|
|||||||
|
|
||||||
{/* Header Section YESTERDAY */}
|
{/* Header Section YESTERDAY */}
|
||||||
<div className="space-y-3 pt-2">
|
<div className="space-y-3 pt-2">
|
||||||
<h2 className="text-[10px] font-bold tracking-widest text-gray-400 uppercase">
|
{/* <h2 className="text-[10px] font-bold tracking-widest text-gray-400 uppercase">
|
||||||
YESTERDAY
|
YESTERDAY
|
||||||
</h2>
|
</h2> */}
|
||||||
|
|
||||||
{notifications.filter(n => n.group === "YESTERDAY").map((item) => (
|
{notifications.filter(n => n.group === "YESTERDAY").map((item) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { useCurrentUser, useUpdateProfile } from "../../hooks/useUser";
|
import { useCurrentUser, useUpdateProfile, resolveFileUrl, DEFAULT_AVATAR } from "../../hooks/useUser";
|
||||||
import { customToast } from "../utils/showToast";
|
import { customToast } from "../utils/showToast";
|
||||||
import { Pen } from "lucide-react";
|
import { Pen } from "lucide-react";
|
||||||
|
import UpdateProfileDialog from "./UpdateProfileDialogue";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { user, loading: userLoading } = useCurrentUser();
|
const { user, loading: userLoading } = useCurrentUser();
|
||||||
const { save, loading: saving, error: errorMsg, successMsg } = useUpdateProfile();
|
const { save, loading: saving, msgRef } = useUpdateProfile();
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const isIntern = user?.role === "intern";
|
const isIntern = user?.role === "intern";
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -16,24 +18,22 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
const [photoFile, setPhotoFile] = useState<File | null>(null);
|
const [photoFile, setPhotoFile] = useState<File | null>(null);
|
||||||
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
|
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
|
||||||
|
const [removePhoto, setRemovePhoto] = useState(false);
|
||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (!user) return;
|
||||||
|
|
||||||
setFormData({
|
setFormData({
|
||||||
fullName:
|
fullName: user.full_name || `${user.nama_depan} ${user.nama_belakang}`.trim(),
|
||||||
user.full_name || "",
|
|
||||||
email: user.email || "",
|
email: user.email || "",
|
||||||
bio: user.professional_bio || "",
|
bio: user.professional_bio || "",
|
||||||
});
|
});
|
||||||
if (user.profile_picture) {
|
|
||||||
setPhotoPreview(user.profile_picture);
|
setPhotoPreview(resolveFileUrl(user.profile_picture) ?? DEFAULT_AVATAR);
|
||||||
} else {
|
setRemovePhoto(false);
|
||||||
setPhotoPreview(null);
|
|
||||||
}
|
|
||||||
setImageError(false);
|
setImageError(false);
|
||||||
}
|
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
@@ -43,46 +43,50 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
const handlePhotoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handlePhotoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (file) {
|
if (!file) return;
|
||||||
|
|
||||||
setPhotoFile(file);
|
setPhotoFile(file);
|
||||||
|
setRemovePhoto(false);
|
||||||
|
setImageError(false);
|
||||||
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onloadend = () => setPhotoPreview(reader.result as string);
|
reader.onloadend = () => setPhotoPreview(reader.result as string);
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemovePhoto = () => {
|
const handleRemovePhoto = () => {
|
||||||
setPhotoFile(null);
|
setPhotoFile(null);
|
||||||
setPhotoPreview(null);
|
setRemovePhoto(true);
|
||||||
|
setPhotoPreview(DEFAULT_AVATAR);
|
||||||
setImageError(false);
|
setImageError(false);
|
||||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveChanges = async (e: React.FormEvent) => {
|
const handleSaveChanges = async () => {
|
||||||
e.preventDefault();
|
setConfirmOpen(false);
|
||||||
|
try {
|
||||||
await customToast.promise(
|
await customToast.promise(
|
||||||
save({
|
save({
|
||||||
full_name: formData.fullName,
|
full_name: formData.fullName,
|
||||||
email: formData.email,
|
email: formData.email,
|
||||||
professional_bio: formData.bio,
|
professional_bio: formData.bio,
|
||||||
|
profile_picture: photoFile ?? (removePhoto ? DEFAULT_AVATAR : undefined),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
loading: "Menyimpan perubahan...",
|
loading: "Menyimpan perubahan...",
|
||||||
success: () => ({
|
success: () => ({
|
||||||
title: "Perubahan berhasil disimpan!",
|
title: "Perubahan berhasil disimpan!",
|
||||||
description: successMsg || "",
|
description: msgRef.current.success ?? "",
|
||||||
}),
|
}),
|
||||||
error: () => ({
|
error: () => ({
|
||||||
title: "Gagal menyimpan perubahan!",
|
title: "Gagal menyimpan perubahan!",
|
||||||
description: errorMsg || "",
|
description: msgRef.current.error ?? "",
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
} catch { }
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialLetter = formData.fullName ? formData.fullName.charAt(0).toUpperCase() : "U";
|
|
||||||
|
|
||||||
if (userLoading) {
|
if (userLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-40 text-sm text-gray-400">
|
<div className="flex items-center justify-center h-40 text-sm text-gray-400">
|
||||||
@@ -91,6 +95,11 @@ export default function SettingsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setConfirmOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Header Title */}
|
{/* Header Title */}
|
||||||
@@ -113,22 +122,20 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Card Body & Form */}
|
{/* Card Body & Form */}
|
||||||
<form onSubmit={handleSaveChanges} className="p-6 space-y-6">
|
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||||
|
|
||||||
{/* Profile Photo Row */}
|
{/* Profile Photo Row */}
|
||||||
<div className="flex items-center gap-5 py-2">
|
<div className="flex items-center gap-5 py-2">
|
||||||
{/* Avatar dengan badge edit */}
|
{/* Avatar dengan badge edit */}
|
||||||
<div className="relative flex-shrink-0">
|
<div className="relative flex-shrink-0">
|
||||||
<div className="w-16 h-16 rounded-xl border-2 border-red-100 overflow-hidden bg-gray-100 flex items-center justify-center shadow-sm">
|
<div className="w-16 h-16 rounded-xl border border-card-outline overflow-hidden bg-gray-100 flex items-center justify-center shadow-sm">
|
||||||
{photoPreview && !imageError ? (
|
{photoPreview && !imageError && (
|
||||||
<img
|
<img
|
||||||
src={photoPreview}
|
src={photoPreview}
|
||||||
alt="Profile"
|
alt="Profile"
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
onError={() => setImageError(true)}
|
onError={() => setImageError(true)}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<span className="text-3xl font-black text-gray-900">{initialLetter}</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Badge edit icon */}
|
{/* Badge edit icon */}
|
||||||
@@ -237,18 +244,6 @@ export default function SettingsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 */}
|
{/* Footer Action */}
|
||||||
<div className="flex justify-end pt-2">
|
<div className="flex justify-end pt-2">
|
||||||
<button
|
<button
|
||||||
@@ -262,6 +257,13 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<UpdateProfileDialog
|
||||||
|
isOpen={confirmOpen}
|
||||||
|
isIntern={isIntern}
|
||||||
|
loading={saving}
|
||||||
|
onConfirm={handleSaveChanges}
|
||||||
|
onClose={() => setConfirmOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { AlertTriangle, X } from "lucide-react";
|
||||||
|
|
||||||
|
interface UpdateProfileDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
isIntern: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UpdateProfileDialog({
|
||||||
|
isOpen,
|
||||||
|
isIntern,
|
||||||
|
loading = false,
|
||||||
|
onConfirm,
|
||||||
|
onClose,
|
||||||
|
}: UpdateProfileDialogProps) {
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
|
||||||
|
if (isOpen && !dialog.open) dialog.showModal();
|
||||||
|
if (!isOpen && dialog.open) dialog.close();
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Event "cancel" dipicu saat user menekan Esc.
|
||||||
|
// preventDefault() tanpa onClose() = Esc benar-benar dimatikan,
|
||||||
|
// dialog hanya bisa ditutup lewat tombol Batal / X.
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
|
||||||
|
const handleCancel = (e: Event) => e.preventDefault();
|
||||||
|
|
||||||
|
dialog.addEventListener("cancel", handleCancel);
|
||||||
|
return () => dialog.removeEventListener("cancel", handleCancel);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
ref={dialogRef}
|
||||||
|
// fixed inset-0 + m-auto + h-fit -> dialog center horizontal & vertical.
|
||||||
|
// hidden + open:flex -> hanya tampil kalau attribute [open] ada,
|
||||||
|
// supaya lifecycle showModal()/close() tetap dihormati.
|
||||||
|
className="hidden open:flex fixed inset-0 m-auto h-fit max-h-[90vh] w-[90vw] max-w-md flex-col overflow-hidden rounded-xl border border-box-border p-0 shadow-xl backdrop:bg-black/40 backdrop:backdrop-blur-[2px]"
|
||||||
|
onClick={(e) => {
|
||||||
|
// klik di area backdrop (target === dialog itu sendiri) -> tutup
|
||||||
|
if (e.target === dialogRef.current && !loading) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-4 p-5 border-b border-box-border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-foreground">Simpan Perubahan</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-1 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors disabled:opacity-50"
|
||||||
|
aria-label="Tutup"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="p-5 space-y-4 overflow-y-auto">
|
||||||
|
<p className="text-sm text-font-shade leading-relaxed">
|
||||||
|
Perubahan pada profil kamu akan langsung diterapkan setelah dikonfirmasi.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Peringatan khusus intern: identitas hanya bisa diubah 1x */}
|
||||||
|
{isIntern && (
|
||||||
|
<div className="flex gap-3 p-3 rounded-lg bg-amber-50 border border-amber-200">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="text-xs leading-relaxed">
|
||||||
|
<p className="font-bold text-amber-900 mb-0.5">Perubahan bersifat permanen</p>
|
||||||
|
<p className="text-amber-800">
|
||||||
|
Sebagai intern, kamu hanya dapat mengubah data identitas{" "}
|
||||||
|
<span className="font-bold">satu kali</span>. Data ini akan dipakai untuk
|
||||||
|
pencetakan sertifikat, jadi pastikan penulisannya sudah sesuai.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex justify-end gap-2 px-5 py-4 border-t border-box-border bg-gray-50/60">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-4 py-2 bg-white border border-box-border text-gray-600 hover:bg-gray-50 text-xs font-bold rounded-lg transition-colors disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-5 py-2 bg-[#B30000] hover:bg-[#990000] text-white text-xs font-bold rounded-lg shadow-sm transition-colors uppercase tracking-wider disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{loading ? "Menyimpan..." : "Ya, Simpan"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import TaskCard from './components/ProjectTaskCard';
|
|||||||
import HeroProject from './components/Hero-project';
|
import HeroProject from './components/Hero-project';
|
||||||
import { useProjectMyTasks } from '../../../hooks/useProjects';
|
import { useProjectMyTasks } from '../../../hooks/useProjects';
|
||||||
import type { Project } from '../../../types/project.types';
|
import type { Project } from '../../../types/project.types';
|
||||||
|
import { FileText } from 'lucide-react';
|
||||||
|
|
||||||
export default function ForumTab({ project }: { project: Project }) {
|
export default function ForumTab({ project }: { project: Project }) {
|
||||||
const { tasks, loading, error } = useProjectMyTasks(project.id);
|
const { tasks, loading, error } = useProjectMyTasks(project.id);
|
||||||
@@ -11,29 +12,26 @@ export default function ForumTab({ project }: { project: Project }) {
|
|||||||
<div>
|
<div>
|
||||||
<HeroProject title={project.project_name} description={project.description} />
|
<HeroProject title={project.project_name} description={project.description} />
|
||||||
|
|
||||||
{/* Loading skeleton */}
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex flex-col gap-3 mt-5">
|
<div className="flex flex-col gap-3 mt-5">
|
||||||
{[...Array(3)].map((_, i) => (
|
{[...Array(3)].map((_, i) => (
|
||||||
<div key={i} className="h-24 bg-gray-100 rounded-xl animate-pulse" />
|
<div key={i} className="h-28 bg-gray-100 rounded-xl animate-pulse" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error */}
|
|
||||||
{error && <p className="text-red-500 text-sm mt-5">{error}</p>}
|
{error && <p className="text-red-500 text-sm mt-5">{error}</p>}
|
||||||
|
|
||||||
{tasks.length === 0 ? (
|
{tasks.length === 0 ? (
|
||||||
<p className="text-md text-font-shade mt-5">Belum ada tugas yang dapat dikerjakan.</p>
|
<div className='flex justify-center items-center flex-col mt-5'>
|
||||||
|
<FileText className='text-font mb-3' size={48} />
|
||||||
|
<p className="text-md text-font-shade">Belum ada tugas yang dapat dikerjakan.</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
!loading && (
|
!loading && (
|
||||||
<div className="flex flex-col gap-7 md:flex-row md:items-start mt-5">
|
<div className="flex flex-col gap-7 md:flex-row md:items-start mt-5">
|
||||||
{/* Kolom kiri: Timeline */}
|
|
||||||
<div className="w-full md:w-1/5 bg-white rounded-xl p-5 border border-box-border shrink-0">
|
<div className="w-full md:w-1/5 bg-white rounded-xl p-5 border border-box-border shrink-0">
|
||||||
<ProjectTimeline tasks={tasks} />
|
<ProjectTimeline tasks={tasks} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Kolom kanan: Task cards */}
|
|
||||||
<div className="w-full border-t border-box-border md:border-t-0 pt-4 md:pt-0 md:w-4/5">
|
<div className="w-full border-t border-box-border md:border-t-0 pt-4 md:pt-0 md:w-4/5">
|
||||||
{tasks.map((task) => (
|
{tasks.map((task) => (
|
||||||
<TaskCard key={task.id} task={task} />
|
<TaskCard key={task.id} task={task} />
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import CertificateAvailable from "./components/CertificateAvailabe";
|
|||||||
import CertificateNotAvailable from "./components/CertificateNotAvailable";
|
import CertificateNotAvailable from "./components/CertificateNotAvailable";
|
||||||
import SertificateHistory from "./components/CertificateHistory";
|
import SertificateHistory from "./components/CertificateHistory";
|
||||||
|
|
||||||
// import { useClaimCertificate } from "../../../hooks/useCertificates";
|
|
||||||
import { useProjectDetail } from "../../../hooks/useProjects";
|
import { useProjectDetail } from "../../../hooks/useProjects";
|
||||||
import { useMyCertificates } from "../../../hooks/useCertificates";
|
import { useMyCertificates } from "../../../hooks/useCertificates";
|
||||||
import type { Project } from "../../../types/project.types";
|
import type { Project } from "../../../types/project.types";
|
||||||
@@ -17,7 +16,7 @@ export default function InternCertificateDetail () {
|
|||||||
|
|
||||||
const { project: projectDetail, loading: projectLoading } = useProjectDetail(slug ?? "");
|
const { project: projectDetail, loading: projectLoading } = useProjectDetail(slug ?? "");
|
||||||
const { certificates, loading: certLoading, refetch: refetchCertificates } = useMyCertificates();
|
const { certificates, loading: certLoading, refetch: refetchCertificates } = useMyCertificates();
|
||||||
// const { claim, loading: claiming, error: claimError } = useClaimCertificate();
|
|
||||||
|
|
||||||
const myCertificateForProject = useMemo(() => {
|
const myCertificateForProject = useMemo(() => {
|
||||||
if (!projectDetail) return null;
|
if (!projectDetail) return null;
|
||||||
@@ -42,14 +41,6 @@ export default function InternCertificateDetail () {
|
|||||||
const remainingTasks = project ? project.total_tasks - project.task_done : 0;
|
const remainingTasks = project ? project.total_tasks - project.task_done : 0;
|
||||||
const isLoading = projectLoading || certLoading;
|
const isLoading = projectLoading || certLoading;
|
||||||
|
|
||||||
// const handleClaim = async () => {
|
|
||||||
// if (!project?.id) return;
|
|
||||||
// const result = await claim(project.id);
|
|
||||||
// if (result) {
|
|
||||||
// refetchCertificates();
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-20 text-gray-400 text-sm">
|
<div className="flex items-center justify-center py-20 text-gray-400 text-sm">
|
||||||
@@ -77,9 +68,6 @@ export default function InternCertificateDetail () {
|
|||||||
progress={progress}
|
progress={progress}
|
||||||
remainingTasks={remainingTasks}
|
remainingTasks={remainingTasks}
|
||||||
allTasksDone={allTasksDone}
|
allTasksDone={allTasksDone}
|
||||||
// onClaim={handleClaim}
|
|
||||||
// claiming={claiming}
|
|
||||||
// claimError={claimError}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,30 @@
|
|||||||
import { Loader2, AlertCircle } from "lucide-react";
|
import { AlertCircle } from "lucide-react";
|
||||||
import { useMyProjects } from "../../../hooks/useProjects";
|
import { useMyProjects } from "../../../hooks/useProjects";
|
||||||
import type { Project } from "../../../types/project.types";
|
import type { Project } from "../../../types/project.types";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import * as LucideIcons from 'lucide-react';
|
import * as LucideIcons from 'lucide-react';
|
||||||
import type { LucideProps } from 'lucide-react';
|
import type { LucideProps } from 'lucide-react';
|
||||||
|
import emptyimage from '../../../assets/Learning-amico.svg'
|
||||||
|
|
||||||
|
// Skeleton card that mirrors the real certificate card layout
|
||||||
|
function CertCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-card-outline rounded-xl overflow-hidden shadow-sm animate-pulse">
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<div className="p-6 pb-0">
|
||||||
|
<div className="rounded-lg overflow-hidden">
|
||||||
|
<div className="w-full h-32 bg-gray-200 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Info */}
|
||||||
|
<div className="px-4 py-3 space-y-2">
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-3/4" />
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-5/6" />
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-1/2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function SertificateList() {
|
export default function SertificateList() {
|
||||||
const { projects, loading, error } = useMyProjects();
|
const { projects, loading, error } = useMyProjects();
|
||||||
@@ -23,11 +43,12 @@ export default function SertificateList() {
|
|||||||
<p className="page-title-desc">All your earned internship certificates</p>
|
<p className="page-title-desc">All your earned internship certificates</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Loading */}
|
{/* Loading Skeleton */}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex items-center justify-center py-20 text-gray-400 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||||
<Loader2 className="w-6 h-6 animate-spin" />
|
{[...Array(6)].map((_, i) => (
|
||||||
<span className="text-sm">Loading certificates...</span>
|
<CertCardSkeleton key={i} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -41,8 +62,9 @@ export default function SertificateList() {
|
|||||||
|
|
||||||
{/* Empty */}
|
{/* Empty */}
|
||||||
{!loading && !error && projects.length === 0 && (
|
{!loading && !error && projects.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
|
<div className="flex flex-col items-center justify-center py-20 gap-5 text-font-shade">
|
||||||
<p className="text-sm">No ongoing projects yet.</p>
|
<img src={emptyimage} alt="empty image" className="w-64 h-64" />
|
||||||
|
<p className="text-[18px]">Tidak ada projek yang berlangsung.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Users } from 'lucide-react';
|
||||||
import type { ProjectDetail, ProjectMember } from '../../../types/project.types';
|
import type { ProjectDetail, ProjectMember } from '../../../types/project.types';
|
||||||
|
|
||||||
export default function ParticipantsTab({ project }: { project: ProjectDetail }) {
|
export default function ParticipantsTab({ project }: { project: ProjectDetail }) {
|
||||||
@@ -25,6 +26,13 @@ export default function ParticipantsTab({ project }: { project: ProjectDetail })
|
|||||||
{/* Members */}
|
{/* Members */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-bold text-font-shade mb-3.5">Members</h3>
|
<h3 className="font-bold text-font-shade mb-3.5">Members</h3>
|
||||||
|
{project.members.length === 0 ? (
|
||||||
|
<div className='flex flex-col items-center justify-center py-16'>
|
||||||
|
<Users className='w-12 h-12 text-font-shade mx-auto'/>
|
||||||
|
<p className="text-sm text-font-shade mt-3">Belum ada anggota.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{project.members.map((member: ProjectMember, idx) => (
|
{project.members.map((member: ProjectMember, idx) => (
|
||||||
<div
|
<div
|
||||||
key={member.id}
|
key={member.id}
|
||||||
@@ -46,9 +54,7 @@ export default function ParticipantsTab({ project }: { project: ProjectDetail })
|
|||||||
<span className="text-[13px] text-[#555] font-semibold">{member.kelompok_peminatan}</span>
|
<span className="text-[13px] text-[#555] font-semibold">{member.kelompok_peminatan}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</>
|
||||||
{project.members.length === 0 && (
|
|
||||||
<p className="text-sm text-gray-400">Belum ada members.</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import ProjectCard from './components/ProjectsCard'
|
import ProjectCard from './components/ProjectsCard'
|
||||||
import { useMyProjects } from '../../../hooks/useProjects';
|
import { useMyProjects } from '../../../hooks/useProjects';
|
||||||
|
import TeamPana from '../../../assets/Team-pana.svg'
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
const { projects, loading, error } = useMyProjects();
|
const { projects, loading, error } = useMyProjects();
|
||||||
@@ -21,12 +22,19 @@ export default function ProjectsPage() {
|
|||||||
<h1 className="page-title-desc">View assigned projects and track their progress.</h1>
|
<h1 className="page-title-desc">View assigned projects and track their progress.</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grid card */}
|
{projects.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-2 gap-3 mt-19">
|
||||||
|
<img className='w-[440px]' src={TeamPana} alt="" />
|
||||||
|
<p className="text-[18px] text-font-shade">Kamu belum tergabung ke projek apapun</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Grid card
|
||||||
<div className="grid grid-cols-1 gap-4 md:gap-3 md:grid-cols-3 lg:grid-cols-5">
|
<div className="grid grid-cols-1 gap-4 md:gap-3 md:grid-cols-3 lg:grid-cols-5">
|
||||||
{projects.map(project => (
|
{projects.map(project => (
|
||||||
<ProjectCard key={project.id} {...project} />
|
<ProjectCard key={project.id} {...project} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -5,18 +5,12 @@ interface CertificateNotAvailableProps {
|
|||||||
progress: number;
|
progress: number;
|
||||||
remainingTasks: number;
|
remainingTasks: number;
|
||||||
allTasksDone?: boolean;
|
allTasksDone?: boolean;
|
||||||
// onClaim: () => void;
|
|
||||||
// claiming: boolean;
|
|
||||||
// claimError: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CertificateNotAvailable({
|
export default function CertificateNotAvailable({
|
||||||
progress,
|
progress,
|
||||||
remainingTasks,
|
remainingTasks,
|
||||||
allTasksDone = false,
|
allTasksDone = false,
|
||||||
// onClaim,
|
|
||||||
// claiming,
|
|
||||||
// claimError,
|
|
||||||
}: CertificateNotAvailableProps) {
|
}: CertificateNotAvailableProps) {
|
||||||
return (
|
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">
|
<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">
|
||||||
@@ -54,14 +48,6 @@ export default function CertificateNotAvailable({
|
|||||||
<div className="h-full bg-green-500 rounded-full transition-all duration-700 w-full" />
|
<div className="h-full bg-green-500 rounded-full transition-all duration-700 w-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* <button
|
|
||||||
onClick={onClaim}
|
|
||||||
disabled={claiming}
|
|
||||||
className="mt-0 px-6 py-2.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors duration-200"
|
|
||||||
>
|
|
||||||
{claiming ? "Memproses..." : "Klaim Sertifikat"}
|
|
||||||
</button>
|
|
||||||
{claimError && <p className="text-red-500 text-sm mt-2">{claimError}</p>} */}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useProjects } from '../../../../hooks/useProjects';
|
import { useProjects } from '../../../../hooks/useProjects';
|
||||||
import { getProjectIcon } from '../../../../lib/ProjectIcons';
|
import { getProjectIcon } from '../../../../lib/ProjectIcons';
|
||||||
|
import { RiFileList2Fill } from "react-icons/ri";
|
||||||
|
|
||||||
const toSlug = (name: string) => name.toLowerCase().replace(/\s+/g, '-');
|
const toSlug = (name: string) => name.toLowerCase().replace(/\s+/g, '-');
|
||||||
|
|
||||||
const HomeProjectList = () => {
|
const HomeProjectList = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { projects, loading, error } = useProjects();
|
const { projects, loading, error } = useProjects();
|
||||||
|
|
||||||
if (loading) return (
|
if (loading) return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{[...Array(3)].map((_, i) => (
|
{[...Array(3)].map((_, i) => (
|
||||||
<div key={i} className="h-16 bg-gray-100 rounded-lg animate-pulse" />
|
<div key={i} className="h-16 bg-gray-100 rounded-lg" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -19,7 +21,14 @@ const HomeProjectList = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-font-shade mb-8">Projects</h2>
|
<h2 className="text-lg font-semibold text-font-shade mb-5">Projects</h2>
|
||||||
|
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-lg py-2 gap-3">
|
||||||
|
<RiFileList2Fill className="w-12 h-12 text-gray-400" />
|
||||||
|
<p className="text-sm text-gray-400">Kamu belum tergabung ke projek apapun</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="flex flex-col gap-3 overflow-y-auto max-h-[190px]">
|
<div className="flex flex-col gap-3 overflow-y-auto max-h-[190px]">
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
<div
|
<div
|
||||||
@@ -27,7 +36,10 @@ const HomeProjectList = () => {
|
|||||||
onClick={() => navigate(`/intern/projects/${toSlug(project.slug)}`)}
|
onClick={() => navigate(`/intern/projects/${toSlug(project.slug)}`)}
|
||||||
className="flex items-center gap-4 border border-card-outline rounded-lg px-4 py-3 hover:bg-gray-50 cursor-pointer transition-colors duration-150"
|
className="flex items-center gap-4 border border-card-outline 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" style={{ backgroundColor: project.background_color ?? '#dc2626' }}>
|
<div
|
||||||
|
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||||
|
style={{ backgroundColor: project.background_color ?? '#dc2626' }}
|
||||||
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
const Icon = getProjectIcon(project.project_icon);
|
const Icon = getProjectIcon(project.project_icon);
|
||||||
return <Icon className="w-5 h-5 text-white" />;
|
return <Icon className="w-5 h-5 text-white" />;
|
||||||
@@ -40,6 +52,7 @@ const HomeProjectList = () => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
|
|||||||
import { Award, Settings, HelpCircle, X } from 'lucide-react';
|
import { Award, Settings, HelpCircle, X } from 'lucide-react';
|
||||||
import { GrHomeRounded } from "react-icons/gr";
|
import { GrHomeRounded } from "react-icons/gr";
|
||||||
import { MdOutlineAssignment, MdLogout } from "react-icons/md";
|
import { MdOutlineAssignment, MdLogout } from "react-icons/md";
|
||||||
|
import { HiUser } from "react-icons/hi2";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ label: "Home", icon: GrHomeRounded, path: "/intern" },
|
{ label: "Home", icon: GrHomeRounded, path: "/intern" },
|
||||||
@@ -46,12 +47,14 @@ export default function SidebarIntern({ isOpen, closeSidebar }: SidebarProps) {
|
|||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{/* Brand & Tombol Close untuk Mobile */}
|
{/* Brand & Tombol Close untuk Mobile */}
|
||||||
<div className="flex items-center justify-between px-2 pb-10">
|
<div className="flex items-center justify-between px-2 pb-6">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="w-9 h-9 rounded-[10px] bg-red 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">
|
||||||
|
<HiUser className="w-[18px] h-[18px] text-white"/>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-bold text-gray-900 leading-tight">Internify</p>
|
<p className="text-[19px] font-bold text-font-shade leading-tight">Internify</p>
|
||||||
<p className="text-[11px] text-gray-400">LMS Portal</p>
|
<p className="text-[14px] text-font">LMS Portal</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -83,10 +86,10 @@ export default function SidebarIntern({ isOpen, closeSidebar }: SidebarProps) {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="h-px bg-gray-200 my-1" />
|
<div className="h-px bg-gray-300 my-1" />
|
||||||
|
|
||||||
{/* Preferences */}
|
{/* Preferences */}
|
||||||
<p className="text-[10px] font-semibold tracking-widest text-gray-400 px-3 py-1.5">
|
<p className="text-[10px] font-semibold tracking-widest text-font px-3 py-1.5">
|
||||||
PREFERENCES
|
PREFERENCES
|
||||||
</p>
|
</p>
|
||||||
<nav className="flex flex-col gap-0.5">
|
<nav className="flex flex-col gap-0.5">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMyTasks } from "../../../../hooks/useProjects";
|
import { useMyTasks } from "../../../../hooks/useProjects";
|
||||||
import type { InternTaskItem } from "../../../../types/project.types";
|
import type { InternTaskItem } from "../../../../types/project.types";
|
||||||
|
import { RiFileList2Fill } from "react-icons/ri";
|
||||||
|
|
||||||
const TaskTimeline = () => {
|
const TaskTimeline = () => {
|
||||||
const { tasks, loading, error } = useMyTasks();
|
const { tasks, loading, error } = useMyTasks();
|
||||||
@@ -20,6 +21,13 @@ const TaskTimeline = () => {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold text-font-shade mb-5">Timeline Tugas</h2>
|
<h2 className="font-semibold text-font-shade mb-5">Timeline Tugas</h2>
|
||||||
|
|
||||||
|
{tasks.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-2 gap-3">
|
||||||
|
<RiFileList2Fill className="w-12 h-12 text-gray-400" />
|
||||||
|
<p className="text-sm text-gray-400">Tidak ada tugas saat ini</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="relative overflow-y-auto max-h-[190px]">
|
<div className="relative overflow-y-auto max-h-[190px]">
|
||||||
<ul className="flex flex-col gap-5">
|
<ul className="flex flex-col gap-5">
|
||||||
{tasks.map((item: InternTaskItem, index) => {
|
{tasks.map((item: InternTaskItem, index) => {
|
||||||
@@ -29,7 +37,6 @@ const TaskTimeline = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={item.id} className="relative flex gap-4 pl-0">
|
<li key={item.id} className="relative flex gap-4 pl-0">
|
||||||
{/* Vertical line ke bawah, kecuali item terakhir */}
|
|
||||||
{!isLast && (
|
{!isLast && (
|
||||||
<div className="absolute left-[3px] top-3 w-px h-full bg-[#ddd]" />
|
<div className="absolute left-[3px] top-3 w-px h-full bg-[#ddd]" />
|
||||||
)}
|
)}
|
||||||
@@ -57,6 +64,7 @@ const TaskTimeline = () => {
|
|||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,13 +36,13 @@ export const MentorLayout = () => {
|
|||||||
const toggleSidebar = () => setIsSidebarOpen(!isSidebarOpen);
|
const toggleSidebar = () => setIsSidebarOpen(!isSidebarOpen);
|
||||||
const closeSidebar = () => setIsSidebarOpen(false);
|
const closeSidebar = () => setIsSidebarOpen(false);
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screenflex flex-col">
|
<div className="min-h-screen flex flex-col relative">
|
||||||
<Header toggleSidebar={toggleSidebar}/>
|
<Header toggleSidebar={toggleSidebar}/>
|
||||||
<div className="flex flex-1">
|
<div className="flex flex-1 relative">
|
||||||
<SidebarMentor isOpen={isSidebarOpen} closeSidebar={closeSidebar} />
|
<SidebarMentor isOpen={isSidebarOpen} closeSidebar={closeSidebar} />
|
||||||
{isSidebarOpen && (
|
{isSidebarOpen && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/20 z-20 md:hidden min-h-screen"
|
className="fixed inset-0 bg-black/20 z-20 lg:hidden min-h-screen"
|
||||||
onClick={closeSidebar}
|
onClick={closeSidebar}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { Plus } from 'lucide-react';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import CreateProjectModal from './components/CreateProjectDialog';
|
import CreateProjectModal from './components/CreateProjectDialog';
|
||||||
import { useProjectsByRole } from '../../../hooks/useListProjects';
|
import { useProjectsByRole } from '../../../hooks/useListProjects';
|
||||||
|
import TeamPana from '../../../assets/Team-pana.svg';
|
||||||
|
|
||||||
export default function MentorProjectsPage() {
|
export default function MentorProjectsPage() {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const { projects, loading, error, refetch } = useProjectsByRole("active");
|
const { projects, loading, error, refetch } = useProjectsByRole();
|
||||||
|
|
||||||
const openDialog = () => {
|
const openDialog = () => {
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
@@ -48,6 +49,14 @@ export default function MentorProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-4 items-stretch">
|
<div className="flex flex-wrap gap-4 items-stretch">
|
||||||
|
|
||||||
|
{projects.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-2 gap-3 mt-19 w-full">
|
||||||
|
<img className='w-[440px]' src={TeamPana} alt="" />
|
||||||
|
<p className="text-[18px] text-font-shade">Belum ada project, Buat project dengan kik 'add'</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{projects.map(project => (
|
{projects.map(project => (
|
||||||
<MentorProjectCard
|
<MentorProjectCard
|
||||||
key={project.id}
|
key={project.id}
|
||||||
|
|||||||
@@ -1,9 +1,29 @@
|
|||||||
// src/pages/mentor/CertificateListPage.tsx
|
// src/pages/mentor/CertificateListPage.tsx
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { AlertCircle } from "lucide-react";
|
||||||
import CertificateProjectCard from "./components/CertificateProjectCard";
|
import CertificateProjectCard from "./components/CertificateProjectCard";
|
||||||
import { useProjects } from "../../../hooks/useProjects";
|
import { useProjects } from "../../../hooks/useProjects";
|
||||||
|
import emptyimage from "../../../assets/Learning-amico.svg";
|
||||||
|
|
||||||
|
function CertCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-card-outline rounded-xl overflow-hidden shadow-sm animate-pulse">
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<div className="p-6 pb-0">
|
||||||
|
<div className="rounded-lg overflow-hidden">
|
||||||
|
<div className="w-full h-32 bg-gray-200 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Info */}
|
||||||
|
<div className="px-4 py-3 space-y-2">
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-3/4" />
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-5/6" />
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-1/2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
|
||||||
export default function CertificateList() {
|
export default function CertificateList() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { projects, loading, error } = useProjects();
|
const { projects, loading, error } = useProjects();
|
||||||
@@ -12,13 +32,9 @@ export default function CertificateList() {
|
|||||||
navigate(`/mentor/certificates/${slug}`);
|
navigate(`/mentor/certificates/${slug}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading){
|
const isEmpty = !loading && !error && projects.length === 0;
|
||||||
<p>on loading</p>
|
const hasProjects = !loading && !error && projects.length > 0;
|
||||||
}
|
|
||||||
|
|
||||||
if (error){
|
|
||||||
<p>error: {error}</p>
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
@@ -29,7 +45,36 @@ export default function CertificateList() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section: List Projects */}
|
{/* Loading Skeleton — struktur sama persis dengan body */}
|
||||||
|
{loading && (
|
||||||
|
<div>
|
||||||
|
<div className="h-4 w-32 bg-gray-200 rounded mb-4 animate-pulse" />
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
|
{[...Array(6)].map((_, i) => (
|
||||||
|
<CertCardSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</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 */}
|
||||||
|
{isEmpty && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 gap-5 text-font-shade">
|
||||||
|
<img src={emptyimage} alt="Tidak ada projek" className="w-64 h-64" />
|
||||||
|
<p className="text-[18px]">Tidak ada projek yang berlangsung.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Section: List Projects — hanya tampil kalau ada project */}
|
||||||
|
{hasProjects && (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-bold text-font-shade mb-4">List Projects</h2>
|
<h2 className="text-base font-bold text-font-shade mb-4">List Projects</h2>
|
||||||
|
|
||||||
@@ -43,6 +88,7 @@ export default function CertificateList() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ import HeroProject from '../intern/components/Hero-project';
|
|||||||
import MentorTaskCard from './components/MentorTaskCard';
|
import MentorTaskCard from './components/MentorTaskCard';
|
||||||
import { useProjectTasks } from '../../../hooks/useTasks';
|
import { useProjectTasks } from '../../../hooks/useTasks';
|
||||||
import type { Project } from '../../../types/project.types';
|
import type { Project } from '../../../types/project.types';
|
||||||
|
import { FileText } from 'lucide-react';
|
||||||
|
|
||||||
export default function MentorForumTab({ project }: { project: Project }) {
|
export default function MentorForumTab({ project }: { project: Project }) {
|
||||||
const { tasks, loading, error } = useProjectTasks(project.id);
|
const { tasks, loading, error } = useProjectTasks(project.id);
|
||||||
@@ -15,13 +16,16 @@ export default function MentorForumTab({ project }: { project: Project }) {
|
|||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex flex-col gap-3 mt-5">
|
<div className="flex flex-col gap-3 mt-5">
|
||||||
{[...Array(3)].map((_, i) => (
|
{[...Array(3)].map((_, i) => (
|
||||||
<div key={i} className="h-24 bg-gray-100 rounded-xl animate-pulse" />
|
<div key={i} className="h-28 bg-gray-100 rounded-xl animate-pulse" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && tasks.length === 0 ? (
|
{!loading && tasks.length === 0 ? (
|
||||||
<p className="text-md text-font-shade mt-5">Belum ada tugas yang dikerjakan.</p>
|
<div className='flex justify-center items-center flex-col mt-5'>
|
||||||
|
<FileText className='text-font mb-3' size={48} />
|
||||||
|
<p className="text-md text-font-shade">Belum ada tugas yang dapat dikerjakan.</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
!loading && (
|
!loading && (
|
||||||
<div className="flex flex-col gap-7 md:flex-row md:items-start mt-5">
|
<div className="flex flex-col gap-7 md:flex-row md:items-start mt-5">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus, Users } from 'lucide-react';
|
||||||
import ManageInternsModal from './components/AddInternsDialog';
|
import ManageInternsModal from './components/AddInternsDialog';
|
||||||
import type { ProjectDetail, ProjectMember } from '../../../types/project.types';
|
import type { ProjectDetail, ProjectMember } from '../../../types/project.types';
|
||||||
import { useRemoveMember } from '../../../hooks/useProjects';
|
import { useRemoveMember } from '../../../hooks/useProjects';
|
||||||
@@ -68,7 +68,13 @@ export default function InternsTab({ project }: { project: ProjectDetail }) {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{members.map((member: ProjectMember, idx) => (
|
{members.length === 0 ? (
|
||||||
|
<div className='flex flex-col items-center justify-center py-16'>
|
||||||
|
<Users className='w-12 h-12 text-font-shade mx-auto'/>
|
||||||
|
<p className="text-sm text-font-shade mt-3">Belum ada anggota.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
members.map((member: ProjectMember, idx) => (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
className={`flex items-center justify-between py-3 ${idx < members.length - 1 ? 'border-b border-gray-300' : ''
|
className={`flex items-center justify-between py-3 ${idx < members.length - 1 ? 'border-b border-gray-300' : ''
|
||||||
@@ -92,7 +98,7 @@ export default function InternsTab({ project }: { project: ProjectDetail }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)))}
|
||||||
</div>
|
</div>
|
||||||
{isModalOpen && <ManageInternsModal
|
{isModalOpen && <ManageInternsModal
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
|
|||||||
@@ -85,8 +85,9 @@ export default function TaskTab({ project }: { project: ProjectDetail }) {
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-10 text-gray-400 text-sm">
|
<div className='flex flex-col items-center justify-center py-16'>
|
||||||
Belum ada tugas yang dibuat untuk proyek ini.
|
<ClipboardList className='w-12 h-12 text-font-shade mx-auto'/>
|
||||||
|
<p className="text-sm text-font-shade mt-3">Belum ada tugas yang dibuat untuk proyek ini.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,26 +13,24 @@ interface AddInternsModalProps {
|
|||||||
|
|
||||||
export default function AddInternsModal({ isOpen, onClose, userId }: AddInternsModalProps) {
|
export default function AddInternsModal({ isOpen, onClose, userId }: AddInternsModalProps) {
|
||||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
// Form States
|
|
||||||
const [selectedUserId, setSelectedUserId] = useState<string>("");
|
const [selectedUserId, setSelectedUserId] = useState<string>("");
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState<string>("");
|
const [selectedProjectId, setSelectedProjectId] = useState<string>("");
|
||||||
|
|
||||||
// Hooks
|
|
||||||
const { assign, loading: assigning, error: assignError } = useAssignMember();
|
const { assign, loading: assigning, error: assignError } = useAssignMember();
|
||||||
const { projects, loading: loadingProjects } = useProjects("active");
|
const { projects, loading: loadingProjects } = useProjects("active");
|
||||||
const { interns, loading: loadingInterns } = useAssignableInterns();
|
const { interns, loading: loadingInterns } = useAssignableInterns();
|
||||||
|
|
||||||
|
console.log(projects)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const dialog = dialogRef.current;
|
const dialog = dialogRef.current;
|
||||||
if (!dialog) return;
|
if (!dialog) return;
|
||||||
|
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
dialog.showModal();
|
dialog.showModal();
|
||||||
document.body.style.overflow = "hidden";
|
// document.body.style.overflow = "hidden";
|
||||||
} else {
|
} else {
|
||||||
dialog.close();
|
dialog.close();
|
||||||
document.body.style.overflow = "unset";
|
// document.body.style.overflow = "unset";
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancel = (e: Event) => {
|
const handleCancel = (e: Event) => {
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ export default function CompleteProjectDialog({
|
|||||||
|
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
dialog.showModal();
|
dialog.showModal();
|
||||||
document.body.style.overflow = "hidden";
|
// document.body.style.overflow = "hidden";
|
||||||
} else {
|
} else {
|
||||||
dialog.close();
|
dialog.close();
|
||||||
document.body.style.overflow = "unset";
|
// document.body.style.overflow = "unset";
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancel = (e: Event) => {
|
const handleCancel = (e: Event) => {
|
||||||
|
|||||||
@@ -38,10 +38,8 @@ export default function CreateProjectModal({ isOpen, onClose, onSuccess }: Creat
|
|||||||
|
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
dialog.showModal();
|
dialog.showModal();
|
||||||
document.body.style.overflow = "hidden";
|
|
||||||
} else {
|
} else {
|
||||||
dialog.close();
|
dialog.close();
|
||||||
document.body.style.overflow = "unset";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancel = (e: Event) => {
|
const handleCancel = (e: Event) => {
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ export default function EligibleInternTable({
|
|||||||
).then(() => true).catch(() => false);
|
).then(() => true).catch(() => false);
|
||||||
|
|
||||||
setGenerating(false);
|
setGenerating(false);
|
||||||
if (ok) navigate(`/mentor/certificates/${project.id}/result`);
|
if (ok) navigate(`/mentor/certificates/${project.slug}/result`);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|||||||
@@ -4,6 +4,19 @@ import { Plus } from 'lucide-react';
|
|||||||
import CreateProjectModal from './CreateProjectDialog';
|
import CreateProjectModal from './CreateProjectDialog';
|
||||||
import { useProjects } from '../../../../hooks/useProjects';
|
import { useProjects } from '../../../../hooks/useProjects';
|
||||||
import { getProjectIcon } from '../../../../lib/ProjectIcons';
|
import { getProjectIcon } from '../../../../lib/ProjectIcons';
|
||||||
|
import { RiFileList2Fill } from 'react-icons/ri';
|
||||||
|
|
||||||
|
function ProjectCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4 border border-gray-200 rounded-lg px-4 py-3 bg-gray-50 animate-pulse">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-gray-300 flex-shrink-0" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-4 bg-gray-300 rounded w-32" />
|
||||||
|
<div className="h-3 bg-gray-300 rounded w-40" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const HomeMentorProject = () => {
|
const HomeMentorProject = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -30,8 +43,23 @@ const HomeMentorProject = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col max-h-[160px] overflow-y-auto gap-3 pr-1">
|
<div className="flex flex-col max-h-[160px] overflow-y-auto gap-3 pr-1">
|
||||||
{loading && <p className="text-sm text-gray-400 px-5">Memuat...</p>}
|
{loading && (
|
||||||
|
<>
|
||||||
|
{[...Array(2)].map((_, i) => (
|
||||||
|
<ProjectCardSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-500 px-5">{error}</p>}
|
{error && <p className="text-sm text-red-500 px-5">{error}</p>}
|
||||||
|
|
||||||
|
{projects.length === 0 && !loading && (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2">
|
||||||
|
<RiFileList2Fill className="w-12 h-12 text-gray-400" />
|
||||||
|
<p className="text-sm text-gray-400 text-center">Belum ada project</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
<div
|
<div
|
||||||
key={project.id}
|
key={project.id}
|
||||||
|
|||||||
@@ -35,10 +35,6 @@ export default function InternsTable({
|
|||||||
const handlePrev = () => setCurrentPage((p) => Math.max(1, p - 1));
|
const handlePrev = () => setCurrentPage((p) => Math.max(1, p - 1));
|
||||||
const handleNext = () => setCurrentPage((p) => Math.min(totalPages, p + 1));
|
const handleNext = () => setCurrentPage((p) => Math.min(totalPages, p + 1));
|
||||||
|
|
||||||
// Reset ke halaman 1 ketika filter berubah (melalui props interns)
|
|
||||||
// Gunakan useEffect agar reset terjadi saat props berubah
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full mt-3 bg-white rounded-xl border border-gray-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)] overflow-hidden">
|
<div className="w-full mt-3 bg-white rounded-xl border border-gray-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)] overflow-hidden">
|
||||||
|
|
||||||
@@ -128,9 +124,13 @@ export default function InternsTable({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
) : totalInterns != 0 ? (
|
||||||
|
<div className="text-center py-12 text-md text-font font-medium">
|
||||||
|
Tidak ada intern yang sesuai dengan kriteria
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-12 text-sm text-gray-400 font-medium">
|
<div className="text-center py-12 text-md text-font font-medium">
|
||||||
No interns found matching the filter criteria.
|
Tidak ada intern yang terdaftar
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { Home, FolderKanban, UsersRound, Medal, Settings, HelpCircle, LogOut, X } from 'lucide-react';
|
import { Home, FolderKanban, UsersRound, Medal, Settings, HelpCircle, LogOut, X } from 'lucide-react';
|
||||||
|
import { FaUserTie } from "react-icons/fa";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
// belum kutambahin
|
// belum kutambahin
|
||||||
@@ -39,19 +40,21 @@ export default function SidebarMentor({ isOpen, closeSidebar }: SidebarProps) {
|
|||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={`
|
className={`
|
||||||
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
|
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
|
||||||
lg:sticky lg:top-[50px] lg:h-[calc(100vh-73px)] lg:w-[250px] lg:translate-x-0 lg:z-8
|
lg:sticky lg:top-[50px] lg:h-[calc(100vh-73px)] lg:w-[250px] lg:translate-x-0 lg:z-8
|
||||||
fixed top-0 left-0 h-[calc(100vh-50px)] w-[270px] z-40 pt-5
|
fixed top-0 left-0 h-screen w-[270px] z-40 pt-5
|
||||||
${isOpen ? "translate-x-0" : "-translate-x-full"}
|
${isOpen ? "translate-x-0" : "-translate-x-full"}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{/* Brand & Tombol Close untuk Mobile */}
|
{/* Brand & Tombol Close untuk Mobile */}
|
||||||
<div className="flex items-center justify-between px-2 pb-10">
|
<div className="flex items-center justify-between px-2 pb-6">
|
||||||
<div className="flex items-center gap-2.5">
|
<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">
|
||||||
|
<FaUserTie className="w-[18px] h-[18px] text-white"/>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-bold text-gray-900 leading-tight">Internify</p>
|
<p className="text-[19px] font-bold text-font-shade leading-tight">Internify</p>
|
||||||
<p className="text-[11px] text-gray-400">LMS Portal</p>
|
<p className="text-[14px] text-font">LMS Portal</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -71,9 +74,9 @@ export default function SidebarMentor({ isOpen, closeSidebar }: SidebarProps) {
|
|||||||
<button
|
<button
|
||||||
key={path}
|
key={path}
|
||||||
onClick={() => handleNav(path)}
|
onClick={() => handleNav(path)}
|
||||||
className={`flex items-center gap-2.5 w-full px-3 py-2 text-[13.5px] rounded-lg font-medium text-left transition-colors
|
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
|
${isActive
|
||||||
? "bg-red-foreground text-red font-semibold border-l-6 border-red"
|
? "bg-red-foreground text-red font-semibold border-l-4 border-base"
|
||||||
: "text-font hover:bg-red-foreground"
|
: "text-font hover:bg-red-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -84,14 +87,14 @@ export default function SidebarMentor({ isOpen, closeSidebar }: SidebarProps) {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="h-px bg-gray-200 my-1" />
|
<div className="h-px bg-gray-300 my-1" />
|
||||||
|
|
||||||
{/* Preferences */}
|
{/* Preferences */}
|
||||||
<p className="text-[10px] font-semibold tracking-widest text-gray-400 px-3 py-1.5">
|
<p className="text-[10px] font-semibold tracking-widest text-font px-3 py-1.5">
|
||||||
PREFERENCES
|
PREFERENCES
|
||||||
</p>
|
</p>
|
||||||
<nav className="flex flex-col gap-0.5">
|
<nav className="flex flex-col gap-0.5">
|
||||||
{prefItems.map(({ label, icon: Icon, path, danger, isLogout }) => {
|
{prefItems.map(({ label, icon: Icon, path, isLogout }) => {
|
||||||
const isActive = location.pathname === path;
|
const isActive = location.pathname === path;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import QRCode from "qrcode";
|
|||||||
|
|
||||||
export function resolveImageUrl(path: string): string {
|
export function resolveImageUrl(path: string): string {
|
||||||
if (!path) return "";
|
if (!path) return "";
|
||||||
if (path.startsWith("data:")) return path; // untuk QR data URL
|
if (path.startsWith("data:")) return path;
|
||||||
if (path.startsWith("http://") || path.startsWith("https://")) {
|
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,6 @@ export function resolveImageUrl(path: string): string {
|
|||||||
return `${origin}${path.startsWith("/") ? "" : "/"}${path}`;
|
return `${origin}${path.startsWith("/") ? "" : "/"}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load Image dari URL
|
|
||||||
export function loadImage(src: string): Promise<HTMLImageElement> {
|
export function loadImage(src: string): Promise<HTMLImageElement> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
@@ -23,9 +22,7 @@ export function loadImage(src: string): Promise<HTMLImageElement> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load kedua font dari Google Fonts (hanya sekali)
|
|
||||||
export async function ensureFontsLoaded(): Promise<void> {
|
export async function ensureFontsLoaded(): Promise<void> {
|
||||||
// Trigger & tunggu font yang sudah diload via <link> di index.html
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
document.fonts.load('105px "Great Vibes"'),
|
document.fonts.load('105px "Great Vibes"'),
|
||||||
document.fonts.load('54px "Grenze"'),
|
document.fonts.load('54px "Grenze"'),
|
||||||
@@ -33,7 +30,6 @@ export async function ensureFontsLoaded(): Promise<void> {
|
|||||||
await document.fonts.ready;
|
await document.fonts.ready;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate satu sertifikat sebagai Blob
|
|
||||||
export async function generateCertificate(
|
export async function generateCertificate(
|
||||||
templateUrl: string,
|
templateUrl: string,
|
||||||
internName: string,
|
internName: string,
|
||||||
@@ -42,7 +38,6 @@ export async function generateCertificate(
|
|||||||
ProjectDuration: string,
|
ProjectDuration: string,
|
||||||
verifyUrl: string,
|
verifyUrl: string,
|
||||||
): Promise<Blob> {
|
): Promise<Blob> {
|
||||||
// Pastikan font siap sebelum menggambar teks
|
|
||||||
await ensureFontsLoaded();
|
await ensureFontsLoaded();
|
||||||
|
|
||||||
const img = await loadImage(templateUrl);
|
const img = await loadImage(templateUrl);
|
||||||
@@ -84,17 +79,17 @@ export async function generateCertificate(
|
|||||||
ctx.fillStyle = "#090909";
|
ctx.fillStyle = "#090909";
|
||||||
ctx.fillText(ProjectDuration, canvas.width * 0.68, canvas.height * 0.66);
|
ctx.fillText(ProjectDuration, canvas.width * 0.68, canvas.height * 0.66);
|
||||||
|
|
||||||
// QR code — kalau verifyUrl diisi
|
// QR code — nanti akan diganti ke alamat domain
|
||||||
const qrDataUrl = await QRCode.toDataURL('http://localhost:5173/verify-certificate/' + verifyUrl, {
|
const qrDataUrl = await QRCode.toDataURL('http://localhost:5173/verify-certificate/' + verifyUrl, {
|
||||||
width: 200,
|
width: 200,
|
||||||
margin: 1,
|
margin: 1,
|
||||||
});
|
});
|
||||||
const qrImg = await loadImage(qrDataUrl);
|
const qrImg = await loadImage(qrDataUrl);
|
||||||
const qrSize = canvas.width * 0.103;
|
const qrSize = canvas.width;
|
||||||
ctx.drawImage(
|
ctx.drawImage(
|
||||||
qrImg,
|
qrImg,
|
||||||
canvas.width * 0.8 - qrSize,
|
canvas.width * 0.855 - qrSize,
|
||||||
canvas.height * 0.75 - qrSize,
|
canvas.height * 0.85 - qrSize,
|
||||||
qrSize,
|
qrSize,
|
||||||
qrSize,
|
qrSize,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -63,10 +63,9 @@
|
|||||||
ctx.fillStyle = "#800000";
|
ctx.fillStyle = "#800000";
|
||||||
ctx.fillText("CERT/20260609/PRJ2/USR5", canvas.width * 0.825, canvas.height * 0.918);
|
ctx.fillText("CERT/20260609/PRJ2/USR5", canvas.width * 0.825, canvas.height * 0.918);
|
||||||
|
|
||||||
// 5. QR code — posisi & ukuran pakai persentase, sama seperti elemen lain
|
const qrSize = canvas.width * 0.103;
|
||||||
const qrSize = canvas.width * 0.103; // atur besar-kecil di sini
|
const qrX = canvas.width * 0.855;
|
||||||
const qrX = canvas.width * 0.77; // titik kiri-atas QR (bukan tengah!)
|
const qrY = canvas.height * 0.85;
|
||||||
const qrY = canvas.height * 0.715;
|
|
||||||
ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize);
|
ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 60 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 128 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 10 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 79 KiB |
+62
-8
@@ -1,9 +1,10 @@
|
|||||||
// hooks/useUser.ts
|
// hooks/useUser.ts
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { loginUser, getProfile, updateProfile } from "../services/UserService";
|
import { loginUser, getProfile, updateProfile } from "../services/UserService";
|
||||||
import type { Mahasiswa } from '../types/project.types';
|
import type { Mahasiswa } from "../types/project.types";
|
||||||
import { getMahasiswa } from '../services/MahasiswaService';
|
import { getMahasiswa } from "../services/MahasiswaService";
|
||||||
import type { CurrentUser, UpdateProfilePayload } from "../types/user.types";
|
import type { CurrentUser, UpdateProfilePayload } from "../types/user.types";
|
||||||
|
import { useRef } from "react";
|
||||||
|
|
||||||
// GET /auth-api/me — ambil data user yang sedang login
|
// GET /auth-api/me — ambil data user yang sedang login
|
||||||
export const useCurrentUser = () => {
|
export const useCurrentUser = () => {
|
||||||
@@ -54,29 +55,80 @@ export const useLogin = () => {
|
|||||||
return { login, loading, error, setError };
|
return { login, loading, error, setError };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_AVATAR = "https://placehold.co/150";
|
||||||
|
|
||||||
|
export const resolveFileUrl = (path?: string | null): string | null => {
|
||||||
|
if (!path) return null;
|
||||||
|
if (/^(https?:|data:|blob:)/i.test(path)) return path;
|
||||||
|
const base = import.meta.env.VITE_API_BASE_URL as string;
|
||||||
|
return `${base}${path.startsWith("/") ? path : `/${path}`}`;
|
||||||
|
};
|
||||||
|
|
||||||
// PATCH /auth-api/update-profile
|
// PATCH /auth-api/update-profile
|
||||||
export const useUpdateProfile = () => {
|
export const useUpdateProfile = () => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const msgRef = useRef<{ success: string | null; error: string | null }>({
|
||||||
|
success: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
const save = async (payload: UpdateProfilePayload) => {
|
const save = async (payload: UpdateProfilePayload) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSuccessMsg(null);
|
setSuccessMsg(null);
|
||||||
|
msgRef.current = { success: null, error: null };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updated = await updateProfile(payload);
|
const updated = await updateProfile(payload);
|
||||||
setSuccessMsg("Profile updated successfully!");
|
|
||||||
|
const msg = "Profile updated successfully!";
|
||||||
|
msgRef.current.success = msg;
|
||||||
|
setSuccessMsg(msg);
|
||||||
return updated;
|
return updated;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.response?.data?.message || "Failed to save changes.");
|
const status = err?.response?.status;
|
||||||
return null;
|
const apiMessage = err?.response?.data?.message;
|
||||||
|
|
||||||
|
let message: string;
|
||||||
|
switch (status) {
|
||||||
|
case 400:
|
||||||
|
message =
|
||||||
|
apiMessage ||
|
||||||
|
"Data yang dikirim tidak valid. Periksa kembali isian Anda.";
|
||||||
|
break;
|
||||||
|
case 401:
|
||||||
|
message = "Sesi Anda telah berakhir. Silakan login kembali.";
|
||||||
|
break;
|
||||||
|
case 403:
|
||||||
|
message = "Anda tidak memiliki izin untuk mengubah profil ini.";
|
||||||
|
break;
|
||||||
|
case 413:
|
||||||
|
message = "Ukuran file terlalu besar. Maksimal 2MB.";
|
||||||
|
break;
|
||||||
|
case 417:
|
||||||
|
message = apiMessage || "Validasi gagal. Periksa kembali isian Anda.";
|
||||||
|
break;
|
||||||
|
case 500:
|
||||||
|
message = "Terjadi kesalahan di server. Coba beberapa saat lagi.";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
message = apiMessage || "Failed to save changes.";
|
||||||
|
}
|
||||||
|
|
||||||
|
msgRef.current.error = message;
|
||||||
|
setError(message);
|
||||||
|
|
||||||
|
// WAJIB throw — kalau `return null`, toast selalu masuk cabang success.
|
||||||
|
throw new Error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return { save, loading, error, successMsg, setSuccessMsg, setError };
|
return { save, loading, error, successMsg, setSuccessMsg, setError, msgRef };
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ambil Seluruh Mahasiswa
|
// Ambil Seluruh Mahasiswa
|
||||||
@@ -89,11 +141,13 @@ export const useMahasiswa = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
getMahasiswa()
|
getMahasiswa()
|
||||||
.then(setMahasiswa)
|
.then(setMahasiswa)
|
||||||
.catch(() => setError('Gagal memuat data mahasiswa.'))
|
.catch(() => setError("Gagal memuat data mahasiswa."))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { refetch(); }, [refetch]);
|
useEffect(() => {
|
||||||
|
refetch();
|
||||||
|
}, [refetch]);
|
||||||
|
|
||||||
return { mahasiswa, loading, error, refetch };
|
return { mahasiswa, loading, error, refetch };
|
||||||
};
|
};
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
|
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string;
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// services/UserService.ts
|
// services/UserService.ts
|
||||||
import api from "../lib/api";
|
import api from "../lib/api";
|
||||||
import type { CurrentUser, UpdateProfilePayload } from "../types/user.types";
|
import type { CurrentUser, UpdateProfilePayload } from "../types/user.types";
|
||||||
import type { Mahasiswa, MahasiswaResponse } from "../types/project.types"
|
import type { Mahasiswa, MahasiswaResponse } from "../types/project.types";
|
||||||
|
|
||||||
// POST /auth-api/login
|
// POST /auth-api/login
|
||||||
export const loginUser = async (payload: {
|
export const loginUser = async (payload: {
|
||||||
@@ -20,24 +20,47 @@ export const getProfile = async (): Promise<CurrentUser> => {
|
|||||||
|
|
||||||
// PATCH /auth-api/update-profile
|
// PATCH /auth-api/update-profile
|
||||||
export const updateProfile = async (
|
export const updateProfile = async (
|
||||||
payload: UpdateProfilePayload
|
payload: UpdateProfilePayload,
|
||||||
): Promise<CurrentUser> => {
|
): Promise<CurrentUser> => {
|
||||||
|
const parts = (payload.full_name ?? "").trim().split(/\s+/).filter(Boolean);
|
||||||
|
const namaDepan = parts[0] ?? "";
|
||||||
|
const namaBelakang = parts.slice(1).join(" ");
|
||||||
|
|
||||||
if (payload.profile_picture instanceof File) {
|
if (payload.profile_picture instanceof File) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
if (payload.full_name !== undefined) formData.append("full_name", payload.full_name);
|
|
||||||
|
if (payload.full_name !== undefined) {
|
||||||
|
formData.append("nama_depan", namaDepan);
|
||||||
|
formData.append("nama_belakang", namaBelakang);
|
||||||
|
}
|
||||||
if (payload.email !== undefined) formData.append("email", payload.email);
|
if (payload.email !== undefined) formData.append("email", payload.email);
|
||||||
if (payload.professional_bio !== undefined) formData.append("professional_bio", payload.professional_bio);
|
if (payload.professional_bio !== undefined)
|
||||||
|
formData.append("professional_bio", payload.professional_bio);
|
||||||
|
|
||||||
formData.append("profile_picture", payload.profile_picture);
|
formData.append("profile_picture", payload.profile_picture);
|
||||||
const res = await api.patch("/auth-api/update-profile", formData, {
|
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
const res = await api.patch("/auth-api/update-profile", formData);
|
||||||
});
|
|
||||||
return res.data.data;
|
return res.data.data;
|
||||||
}
|
}
|
||||||
const res = await api.patch("/auth-api/update-profile", payload);
|
const body: Record<string, unknown> = {};
|
||||||
|
if (payload.full_name !== undefined) {
|
||||||
|
body.nama_depan = namaDepan;
|
||||||
|
body.nama_belakang = namaBelakang;
|
||||||
|
}
|
||||||
|
if (payload.email !== undefined) body.email = payload.email;
|
||||||
|
if (payload.professional_bio !== undefined) {
|
||||||
|
body.professional_bio = payload.professional_bio;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof payload.profile_picture === "string") {
|
||||||
|
body.profile_picture = payload.profile_picture;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await api.patch("/auth-api/update-profile", body);
|
||||||
return res.data.data;
|
return res.data.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getMahasiswa = async (): Promise<Mahasiswa[]> => {
|
export const getMahasiswa = async (): Promise<Mahasiswa[]> => {
|
||||||
const response = await api.get<MahasiswaResponse>('/mahasiswa-api/get');
|
const response = await api.get<MahasiswaResponse>("/mahasiswa-api/get");
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,5 +13,5 @@ export interface UpdateProfilePayload {
|
|||||||
full_name?: string;
|
full_name?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
professional_bio?: string;
|
professional_bio?: string;
|
||||||
profile_picture?: File | null;
|
profile_picture?: File | string | null;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user