feat: Refactor Mentor Settings Page and improve user profile handling
- Removed MentorSettingsPage component to streamline user profile management. - Updated ProjectForumTab and ProjectsInternTab for better UI/UX. - Enhanced ProjectsPage loading state with a spinner. - Improved TaskTab to handle loading states and error messages more effectively. - Added new utility functions for user initials and file URL resolution. - Updated UserService to normalize user data and handle profile updates more efficiently. - Enhanced CSS styles for better visual consistency across components. - Added new properties to CurrentUser type for better user data management.
This commit is contained in:
@@ -1,15 +1,15 @@
|
||||
import { Briefcase, Globe, Users } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { FaInstagram, FaLinkedin, FaRegEnvelope } from "react-icons/fa";
|
||||
import logoHumic from "../assets/logo.png"
|
||||
|
||||
const footerLinks = {
|
||||
Internship: [
|
||||
{ label: "Beranda", path: "/" },
|
||||
{ label: "Tentang", path: "/tentang" },
|
||||
{ label: "Life At Humic", path: "/life" },
|
||||
{ label: "Tentang", path: "/about-us" },
|
||||
{ label: "Life At Humic", path: "/our-developer" },
|
||||
],
|
||||
"Internify Logo": [
|
||||
{ label: "Humic Website", path: "/humic" },
|
||||
External: [
|
||||
{ label: "Humic Website", path: "https://humic.telkomuniversity.ac.id/" },
|
||||
{ label: "Our Activity", path: "/activity" },
|
||||
{ label: "Life At Humic", path: "/life-humic" },
|
||||
{ label: "Our Developer", path: "/developer" },
|
||||
@@ -17,9 +17,9 @@ const footerLinks = {
|
||||
};
|
||||
|
||||
const socials = [
|
||||
{ label: "Instagram", icon: FaInstagram, href: "https://placeholder.com" },
|
||||
{ label: "LinkedIn", icon: FaLinkedin, href: "https://placeholder.com" },
|
||||
{ label: "Email", icon: FaRegEnvelope, href: "https://placeholder.com" },
|
||||
{ label: "Instagram", icon: FaInstagram, href: "https://www.instagram.com/humicengineering/" },
|
||||
{ label: "LinkedIn", icon: FaLinkedin, href: "https://www.linkedin.com/company/humic-engineering/" },
|
||||
{ label: "Email", icon: FaRegEnvelope, href: "https://humic.telkomuniversity.ac.id/" },
|
||||
];
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ export function FooterIntern() {
|
||||
{/* Link columns */}
|
||||
<div className="flex gap-12 flex-1 justify-end flex-wrap">
|
||||
{Object.entries(footerLinks).map(([title, links]) => (
|
||||
<div key={title} className="flex flex-col gap-2.5 min-w-[110px]">
|
||||
<div key={title} className="flex flex-col gap-2 min-w-[110px]">
|
||||
<p className="text-[13px] font-bold text-gray-900">{title}</p>
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{links.map(({ label, path }) => (
|
||||
@@ -71,6 +71,11 @@ export function FooterIntern() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<img
|
||||
src={logoHumic}
|
||||
alt="logo humic"
|
||||
className='w-[110px] h-[68px]'
|
||||
/>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
import { Bell, Menu } from "lucide-react"
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import humiclogo from "../assets/logo.png";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useCurrentUser, resolveFileUrl, DEFAULT_AVATAR } from "../hooks/useUser";
|
||||
|
||||
function getInitials(fullName: string | undefined | null): string {
|
||||
if (!fullName) return "U";
|
||||
const parts = fullName.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return "U";
|
||||
if (parts.length === 1) return parts[0].charAt(0).toUpperCase();
|
||||
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
|
||||
}
|
||||
import { useCurrentUser, resolveFileUrl, getInitials } from "../hooks/useUser";
|
||||
import { useNotifications } from "../hooks/useNotification";
|
||||
|
||||
export default function Header({ toggleSidebar }: { toggleSidebar: () => void }) {
|
||||
const nav = useNavigate();
|
||||
const { user } = useCurrentUser();
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
const displayName = user?.full_name?.trim() || [user?.nama_depan, user?.nama_belakang].filter(Boolean).join(" ") || "";
|
||||
|
||||
const { unreadCount } = useNotifications();
|
||||
const photoUrl = resolveFileUrl(user?.profile_picture);
|
||||
const displayName = user?.full_name?.trim() || [user?.nama_depan, user?.nama_belakang].filter(Boolean).join(" ") || "";
|
||||
|
||||
const initials = getInitials(displayName);
|
||||
const hasPhoto = !!photoUrl && !imageError;
|
||||
@@ -40,8 +32,11 @@ 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")} className="relative">
|
||||
<Bell className="w-5 h-5 hover:text-red-800"/>
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-2.5 h-2.5 bg-red-600 rounded-full border-2 border-white" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Gambar Profil */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
|
||||
import { useDeadlines } from '../../hooks/useTasks';
|
||||
|
||||
const DAY_LABELS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
|
||||
@@ -16,6 +16,9 @@ function getFirstDayOfMonth(year: number, month: number) {
|
||||
return new Date(year, month, 1).getDay()
|
||||
}
|
||||
|
||||
const formatDate = (dateInput: Date | string) =>
|
||||
new Date(dateInput).toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||
|
||||
export default function HomeCalendar() {
|
||||
const [today, setToday] = useState(new Date());
|
||||
const { deadlines, loading } = useDeadlines();
|
||||
@@ -82,22 +85,24 @@ export default function HomeCalendar() {
|
||||
const totalCells = Math.ceil((firstDay + daysInMonth) / 7) * 7
|
||||
const leadingDays = Array.from({ length: totalCells - firstDay - daysInMonth }, (_, i) => i + 1)
|
||||
|
||||
// Bold dates (weekends or special)
|
||||
// Tanggal Akhir Pekan
|
||||
const isBold = (day: number) => {
|
||||
const d = new Date(currentYear, currentMonth, day).getDay()
|
||||
return d === 0 || d === 6 // Sun or Sat
|
||||
return d === 0 || d === 6
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="px-13 py-5 border border-card-outline rounded-2xl">
|
||||
<p className="text-[11px] text-font-shade">Loading calendar...</p>
|
||||
<div className="px-13 py-5 bg-gray-100 animate-pulse rounded-2xl">
|
||||
<div className="h-[260px] rounded-xl flex items-center justify-center animate-pulse">
|
||||
<Loader2 className='w-5 h-5 animate-spin'/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className=" px-13 py-5 border border-card-outline rounded-2xl">
|
||||
<div className="px-13 py-5 border border-card-outline rounded-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-bold text-font">
|
||||
@@ -180,7 +185,7 @@ export default function HomeCalendar() {
|
||||
{upcomingDeadlines.map((d, i) => (
|
||||
<li key={i} className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-red-600 flex-shrink-0" />
|
||||
<span className="text-xs text-font-shade">{d.label}</span>
|
||||
<span className="text-xs text-font-shade">{formatDate(d.date)} - {d.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -68,7 +68,7 @@ export default function InternifyLogin() {
|
||||
</p>
|
||||
</div>
|
||||
{/* MAIN CONTENT */}
|
||||
<div className="max-w-md bg-box-primary rounded-2xl border border-box-border p-8 md:p-12 shadow-sm">
|
||||
<div className="box max-w-md bg-box-primary border border-box-border p-8 md:p-12 shadow-sm rounded-lg">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-1">Welcome back</h2>
|
||||
<p className="text-sm text-gray-500 mb-6">Access your dashboard with your credentials.</p>
|
||||
|
||||
@@ -142,7 +142,7 @@ export default function InternifyLogin() {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<CircleQuestionMark className="w-5 h-5"/>
|
||||
<CircleQuestionMark className="w-5 h-5" />
|
||||
Frequently Asked Questions
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { io } from "socket.io-client";
|
||||
import * as NotificationService from "../../services/NotificationService";
|
||||
import type { UINotification, BackendNotification } from "../../types/notification.types";
|
||||
import mailRafiki from '../../assets/Mail-rafiki.svg'
|
||||
import { customToast } from "../utils/showToast";
|
||||
|
||||
const getCookieToken = () => {
|
||||
return document.cookie
|
||||
@@ -70,9 +71,9 @@ export default function NotificationList() {
|
||||
try {
|
||||
await NotificationService.markAllAsRead();
|
||||
setNotifications([]);
|
||||
alert("All notifications marked as read!");
|
||||
} catch (err) {
|
||||
console.error("Failed to mark all as read:", err);
|
||||
customToast.success("All notifications marked as read!");
|
||||
} catch (err:any) {
|
||||
customToast.error("Failed to mark all as read", err?.message);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useCurrentUser, useUpdateProfile, resolveFileUrl, DEFAULT_AVATAR } from "../../hooks/useUser";
|
||||
import { useCurrentUser, useUpdateProfile, resolveFileUrl, getInitials } from "../../hooks/useUser";
|
||||
import { customToast } from "../utils/showToast";
|
||||
import { Pen } from "lucide-react";
|
||||
import UpdateProfileDialog from "./UpdateProfileDialogue";
|
||||
@@ -8,7 +8,9 @@ export default function SettingsPage() {
|
||||
const { user, loading: userLoading } = useCurrentUser();
|
||||
const { save, loading: saving, msgRef } = useUpdateProfile();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
const isIntern = user?.role === "intern";
|
||||
const hasPendingChanges = isIntern && (user?.changes ?? 0) > 0;
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
fullName: "",
|
||||
@@ -16,9 +18,13 @@ export default function SettingsPage() {
|
||||
bio: "",
|
||||
});
|
||||
|
||||
const [formErrors, setFormErrors] = useState({
|
||||
fullName: "",
|
||||
email: "",
|
||||
});
|
||||
|
||||
const [photoFile, setPhotoFile] = useState<File | null>(null);
|
||||
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
|
||||
const [removePhoto, setRemovePhoto] = useState(false);
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -26,13 +32,13 @@ export default function SettingsPage() {
|
||||
if (!user) return;
|
||||
|
||||
setFormData({
|
||||
fullName: user.full_name || `${user.nama_depan} ${user.nama_belakang}`.trim(),
|
||||
fullName: user.full_name || "",
|
||||
email: user.email || "",
|
||||
bio: user.professional_bio || "",
|
||||
});
|
||||
|
||||
setPhotoPreview(resolveFileUrl(user.profile_picture) ?? DEFAULT_AVATAR);
|
||||
setRemovePhoto(false);
|
||||
setPhotoFile(null);
|
||||
setPhotoPreview(resolveFileUrl(user.profile_picture));
|
||||
setImageError(false);
|
||||
}, [user]);
|
||||
|
||||
@@ -46,7 +52,6 @@ export default function SettingsPage() {
|
||||
if (!file) return;
|
||||
|
||||
setPhotoFile(file);
|
||||
setRemovePhoto(false);
|
||||
setImageError(false);
|
||||
|
||||
const reader = new FileReader();
|
||||
@@ -54,29 +59,45 @@ export default function SettingsPage() {
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleRemovePhoto = () => {
|
||||
const handleCancelPhoto = () => {
|
||||
setPhotoFile(null);
|
||||
setRemovePhoto(true);
|
||||
setPhotoPreview(DEFAULT_AVATAR);
|
||||
setPhotoPreview(resolveFileUrl(user?.profile_picture));
|
||||
setImageError(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const errors = { fullName: "", email: "" };
|
||||
|
||||
if (!formData.fullName.trim()) errors.fullName = "Full name is required.";
|
||||
if (!formData.email.trim()) errors.email = "Email address is required.";
|
||||
|
||||
setFormErrors(errors);
|
||||
return !errors.fullName && !errors.email;
|
||||
};
|
||||
|
||||
const handleSaveChanges = async () => {
|
||||
setConfirmOpen(false);
|
||||
if (!validateForm()) return;
|
||||
|
||||
const emailChanged =
|
||||
formData.email.trim().toLowerCase() !== (user?.email ?? "").trim().toLowerCase();
|
||||
|
||||
try {
|
||||
await customToast.promise(
|
||||
save({
|
||||
full_name: formData.fullName,
|
||||
email: formData.email,
|
||||
professional_bio: formData.bio,
|
||||
profile_picture: photoFile ?? (removePhoto ? DEFAULT_AVATAR : undefined),
|
||||
profile_picture: !isIntern && photoFile ? photoFile : undefined,
|
||||
}),
|
||||
{
|
||||
loading: "Menyimpan perubahan...",
|
||||
success: () => ({
|
||||
title: "Perubahan berhasil disimpan!",
|
||||
description: msgRef.current.success ?? "",
|
||||
description: emailChanged
|
||||
? "Email berubah, silakan login ulang..."
|
||||
: msgRef.current.success ?? "",
|
||||
}),
|
||||
error: () => ({
|
||||
title: "Gagal menyimpan perubahan!",
|
||||
@@ -84,22 +105,36 @@ export default function SettingsPage() {
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// email berubah -> sesi lama tidak valid lagi, paksa login ulang
|
||||
if (emailChanged) {
|
||||
document.cookie = "token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT";
|
||||
setTimeout(() => {
|
||||
window.location.href = "/login-internify";
|
||||
}, 1200);
|
||||
return;
|
||||
}
|
||||
|
||||
setPhotoFile(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
} catch { }
|
||||
};
|
||||
|
||||
if (userLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-40 text-sm text-gray-400">
|
||||
Loading profile...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setConfirmOpen(true);
|
||||
};
|
||||
|
||||
if (userLoading) {
|
||||
return (
|
||||
<div className="space-y-4 mt-10">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-32 bg-box-secondary rounded-2xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header Title */}
|
||||
@@ -123,35 +158,32 @@ export default function SettingsPage() {
|
||||
|
||||
{/* Card Body & Form */}
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
|
||||
{/* Profile Photo Row */}
|
||||
<div className="flex items-center gap-5 py-2">
|
||||
{/* Avatar dengan badge edit */}
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="w-16 h-16 rounded-xl border border-card-outline overflow-hidden bg-gray-100 flex items-center justify-center shadow-sm">
|
||||
{photoPreview && !imageError && (
|
||||
<div className="w-16 h-16 rounded-xl border border-card-outline overflow-hidden bg-red flex items-center justify-center shadow-sm">
|
||||
{photoPreview && !imageError ? (
|
||||
<img
|
||||
src={photoPreview}
|
||||
alt="Profile"
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-lg font-bold text-white">{getInitials(user?.full_name) || "U"}</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Badge edit icon */}
|
||||
{!isIntern &&
|
||||
{!isIntern && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="absolute -bottom-1.5 -right-1.5 w-6 h-6 bg-[#B30000] rounded-full flex items-center justify-center shadow border-2 border-white hover:bg-[#990000] transition-colors"
|
||||
className="absolute -bottom-1.5 -right-1.5 w-6 h-6 bg-red rounded-full flex items-center justify-center shadow border-2 border-white hover:bg-red-800 transition-colors"
|
||||
title="Edit photo"
|
||||
>
|
||||
<Pen className="text-white w-2.5 h-2.5" />
|
||||
</button>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info & Action Buttons */}
|
||||
{!isIntern ? (
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-gray-900 mb-0.5">Profile Photo</h3>
|
||||
@@ -159,31 +191,35 @@ export default function SettingsPage() {
|
||||
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>
|
||||
<p className="text-xs text-blue-500 mb-2 font-medium">{photoFile.name}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="px-4 py-1.5 bg-[#B30000] hover:bg-[#990000] text-white text-xs font-bold rounded-lg shadow-sm transition-colors"
|
||||
className="px-4 py-1.5 bg-red hover:bg-red-600 text-white text-xs font-bold rounded-lg shadow-sm transition-colors"
|
||||
>
|
||||
Upload New
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemovePhoto}
|
||||
className="px-4 py-1.5 bg-white border border-box-border text-gray-600 hover:bg-gray-50 text-xs font-bold rounded-lg transition-colors"
|
||||
onClick={handleCancelPhoto}
|
||||
disabled={!photoFile}
|
||||
className="px-4 py-1.5 bg-white border border-box-border text-gray-600 hover:bg-gray-50 text-xs font-bold rounded-lg transition-colors disabled:opacity-40"
|
||||
>
|
||||
Remove
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-gray-900 mb-0.5">Nama</h3>
|
||||
<p className="text-base text-gray-700 font-medium">{user?.full_name || formData.fullName || 'User'}</p>
|
||||
<p className="text-base text-font-shade font-medium">
|
||||
{user?.full_name || formData.fullName || "User"}
|
||||
</p>
|
||||
<p className="text-[13px] text-font font-medium">
|
||||
{user.kelompok_peminatan}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -210,8 +246,17 @@ export default function SettingsPage() {
|
||||
name="fullName"
|
||||
value={formData.fullName}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-box-border rounded-lg text-sm text-gray-800 focus:outline-none focus:border-red-600 focus:ring-1 focus:ring-red-600 transition-colors bg-white font-medium"
|
||||
disabled={hasPendingChanges}
|
||||
className={`w-full px-3 py-2 border rounded-lg text-sm text-gray-800 focus:outline-none transition-colors bg-white font-medium disabled:bg-gray-100 disabled:text-gray-500 ${formErrors.fullName ? "border-red-600 focus:border-red-600 focus:ring-1 focus:ring-red-600" : "border-box-border focus:border-red-600 focus:ring-1 focus:ring-red-600"}`}
|
||||
/>
|
||||
{formErrors.fullName && (
|
||||
<p className="text-[11px] text-red-600">{formErrors.fullName}</p>
|
||||
)}
|
||||
{hasPendingChanges && (
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Nama hanya bisa diubah satu kali dan sudah pernah diubah.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-bold tracking-wider text-gray-700 uppercase block">
|
||||
@@ -222,8 +267,14 @@ export default function SettingsPage() {
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-box-border rounded-lg text-sm text-gray-800 focus:outline-none focus:border-red-600 focus:ring-1 focus:ring-red-600 transition-colors bg-white font-medium"
|
||||
className={`w-full px-3 py-2 border rounded-lg text-sm text-gray-800 focus:outline-none transition-colors bg-white font-medium ${formErrors.email ? "border-red-600 focus:border-red-600 focus:ring-1 focus:ring-red-600" : "border-box-border focus:border-red-600 focus:ring-1 focus:ring-red-600"}`}
|
||||
/>
|
||||
{formErrors.email && (
|
||||
<p className="text-[11px] text-red-600">{formErrors.email}</p>
|
||||
)}
|
||||
<p className="text-[11px] text-gray-500 font-normal pt-0.5">
|
||||
Mengubah email akan mengakhiri sesi — kamu harus login ulang.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -249,7 +300,7 @@ export default function SettingsPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-6 py-2 bg-[#B30000] hover:bg-[#990000] text-white font-bold text-xs rounded-lg shadow-sm transition-colors uppercase tracking-wider disabled:opacity-60"
|
||||
className="px-6 py-2 bg-red hover:bg-red-700 text-white font-bold text-xs rounded-lg shadow-sm transition-colors uppercase tracking-wider disabled:bg-gray-400"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
@@ -257,6 +308,7 @@ export default function SettingsPage() {
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<UpdateProfileDialog
|
||||
isOpen={confirmOpen}
|
||||
isIntern={isIntern}
|
||||
|
||||
@@ -26,9 +26,6 @@ export default function UpdateProfileDialog({
|
||||
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;
|
||||
@@ -42,12 +39,8 @@ export default function UpdateProfileDialog({
|
||||
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();
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useCertificateVerification } from "../../hooks/useCertificates";
|
||||
import logoHumic from "../../assets/logo.png"
|
||||
|
||||
export default function VerificationCert() {
|
||||
const { uuid } = useParams<{ uuid: string }>();
|
||||
@@ -7,7 +8,7 @@ export default function VerificationCert() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container flex items-center justify-center min-h-screen">
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<p className="text-gray-500 animate-pulse">Memverifikasi sertifikat...</p>
|
||||
</div>
|
||||
);
|
||||
@@ -15,9 +16,14 @@ export default function VerificationCert() {
|
||||
|
||||
if (error || !verification) {
|
||||
return (
|
||||
<div className="container flex flex-col items-center justify-center min-h-screen text-center px-4">
|
||||
<h1 className="text-2xl font-bold text-red-600 mb-2">Sertifikat Tidak Valid</h1>
|
||||
<p className="text-gray-500">
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-100 via-slate-300 to-gray-50 flex items-center justify-center flex-col px-6 relative overflow-hidden">
|
||||
<img
|
||||
src={logoHumic}
|
||||
alt="Humic Internify"
|
||||
className="w-[240px] h-[240x] object-cover mb-10"
|
||||
/>
|
||||
<h1 className="text-2xl font-bold text-red mb-2 animate-pulse">Maaf, ID Sertifikat anda masukkan tidak valid</h1>
|
||||
<p className="text-gray-500 text-[16px]">
|
||||
Sertifikat dengan UUID ini tidak dapat ditemukan atau sudah tidak berlaku.
|
||||
</p>
|
||||
</div>
|
||||
@@ -25,22 +31,26 @@ export default function VerificationCert() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container flex items-center justify-center min-h-screen px-4">
|
||||
<div className="bg-white shadow-md rounded-lg p-8 max-w-md w-full">
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-150 via-slate-300 to-gray-50 flex items-center justify-center flex-col px-6 relative overflow-hidden">
|
||||
<img
|
||||
src={logoHumic}
|
||||
alt="Humic Internify"
|
||||
className="[240px] h-[240x] object-cover mb-14"
|
||||
/>
|
||||
<h1 className="text-2xl font-bold text-green-600 mb-6 text-center">
|
||||
Sertifikat Valid
|
||||
</h1>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Nama Intern</span>
|
||||
<span className="text-font">Nama Intern</span>
|
||||
<p className="font-semibold">{verification.intern_name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Proyek</span>
|
||||
<span className="text-font">Proyek</span>
|
||||
<p className="font-semibold">{verification.project_name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Tanggal Terbit</span>
|
||||
<span className="text-font">Tanggal Terbit</span>
|
||||
<p className="font-semibold">
|
||||
{new Date(verification.created_at).toLocaleDateString("id-ID", {
|
||||
day: "numeric",
|
||||
@@ -50,11 +60,10 @@ export default function VerificationCert() {
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">UUID</span>
|
||||
<span className="text-font">UUID</span>
|
||||
<p className="font-mono text-xs break-all">{verification.uuid}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export default function HomeInternPage() {
|
||||
<div className="flex-1">
|
||||
<HomeHeader />
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
<div className="flex flex-col gap-5 md:grid md:grid-cols-2">
|
||||
<div className="bg-box-primary border border-box-border rounded-xl p-6">
|
||||
<HomeProjectList />
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useProjectDetail } from "../../../hooks/useProjects";
|
||||
import { useMyCertificates } from "../../../hooks/useCertificates";
|
||||
import type { Project } from "../../../types/project.types";
|
||||
|
||||
export default function InternCertificateDetail () {
|
||||
export default function InternCertificateDetail() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const location = useLocation();
|
||||
const projectFromList = location.state?.project as Project | undefined;
|
||||
@@ -43,8 +43,19 @@ export default function InternCertificateDetail () {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20 text-gray-400 text-sm">
|
||||
Memuat Data...
|
||||
<div>
|
||||
<div className="mb-4 flex flex-col gap-1">
|
||||
<h1 className="page-title">Certificates</h1>
|
||||
<p className="page-title-desc">View and download internship certificates</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-6 items-stretch animate-pulse">
|
||||
{/* Kolom kiri: certificate box */}
|
||||
<div className="w-full lg:w-5/8 h-[420px] bg-gray-200 rounded-xl" />
|
||||
|
||||
{/* Kolom kanan: history box */}
|
||||
<div className="w-full lg:w-3/8 flex-shrink-0 h-[420px] bg-gray-200 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -61,7 +72,7 @@ export default function InternCertificateDetail () {
|
||||
<CertificateAvailable
|
||||
project={project}
|
||||
certificate={myCertificateForProject}
|
||||
templateUrl = {project?.certificate_template ?? ""}
|
||||
templateUrl={project?.certificate_template ?? ""}
|
||||
/>
|
||||
) : (
|
||||
<CertificateNotAvailable
|
||||
|
||||
@@ -6,7 +6,6 @@ import * as LucideIcons 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">
|
||||
@@ -17,10 +16,7 @@ function CertCardSkeleton() {
|
||||
</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 className="px-4 py-3 space-y-2 bg-gray-100">
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -37,22 +33,17 @@ export default function SertificateList() {
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-col gap-1">
|
||||
<h1 className="page-title">Certificate History</h1>
|
||||
<p className="page-title-desc">All your earned internship certificates</p>
|
||||
</div>
|
||||
|
||||
{/* Loading Skeleton */}
|
||||
{loading && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
{[...Array(projects.length)].map((_, i) => (
|
||||
<CertCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && !loading && (
|
||||
<div className="flex items-center justify-center py-20 text-red-500 gap-2">
|
||||
<AlertCircle className="w-6 h-6" />
|
||||
@@ -60,15 +51,12 @@ export default function SertificateList() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty */}
|
||||
{!loading && !error && projects.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-5 text-font-shade">
|
||||
<img src={emptyimage} alt="empty image" className="w-64 h-64" />
|
||||
<p className="text-[18px]">Tidak ada projek yang berlangsung.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid */}
|
||||
{!loading && !error && projects.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{projects.map((item: Project) => (
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Users } from 'lucide-react';
|
||||
import type { ProjectDetail, ProjectMember } from '../../../types/project.types';
|
||||
import { useState } from 'react';
|
||||
import { resolveFileUrl, getInitials } from '../../../hooks/useUser';
|
||||
|
||||
export default function ParticipantsTab({ project }: { project: ProjectDetail }) {
|
||||
const [ImageError, setImageError] = useState(false);
|
||||
const Avatar = resolveFileUrl(project.admin.profile_picture);
|
||||
const hasAvatar = !!Avatar && !ImageError;
|
||||
return (
|
||||
<div>
|
||||
{/* Mentor */}
|
||||
@@ -9,14 +14,19 @@ export default function ParticipantsTab({ project }: { project: ProjectDetail })
|
||||
<h3 className="font-bold text-font-shade mb-3.5">Mentor</h3>
|
||||
<div className="border-b border-box-border pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{project.admin.profile_picture ? (
|
||||
{hasAvatar ? (
|
||||
<img
|
||||
src={project.admin.profile_picture}
|
||||
src={Avatar}
|
||||
alt={project.admin.full_name}
|
||||
className="w-8 h-8 rounded-full object-cover"
|
||||
className="w-[40px] h-[40px] object-cover rounded-full"
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-gray-200" />
|
||||
<div className="w-full h-full bg-[#B30000] flex items-center justify-center">
|
||||
<span className="text-white text-[10px] font-bold tracking-wide">
|
||||
{getInitials(project.admin.full_name)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-md text-font">{project.admin.full_name}</span>
|
||||
</div>
|
||||
|
||||
@@ -2,18 +2,37 @@ import ProjectCard from './components/ProjectsCard'
|
||||
import { useMyProjects } from '../../../hooks/useProjects';
|
||||
import TeamPana from '../../../assets/Team-pana.svg'
|
||||
|
||||
function ProjectCardSkeleton() {
|
||||
return (
|
||||
<div className="w-[180px] min-h-[270px] bg-gray-300 rounded-xl overflow-hidden shadow-sm animate-pulse" />
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const { projects, loading, error } = useMyProjects();
|
||||
|
||||
if (loading) return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-16 bg-gray-100 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return [...Array(5)].map((_, i) => <ProjectCardSkeleton key={i} />);
|
||||
}
|
||||
|
||||
if (error) return <p className="text-danger text-md">{error}</p>;
|
||||
if (error) {
|
||||
return <p className="text-danger text-md col-span-full">{error}</p>;
|
||||
}
|
||||
|
||||
if (projects.length === 0) {
|
||||
return (
|
||||
<div className="col-span-full 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>
|
||||
);
|
||||
}
|
||||
|
||||
return projects.map(project => (
|
||||
<ProjectCard key={project.id} {...project} />
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -22,19 +41,9 @@ export default function ProjectsPage() {
|
||||
<h1 className="page-title-desc">View assigned projects and track their progress.</h1>
|
||||
</div>
|
||||
|
||||
{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">
|
||||
{projects.map(project => (
|
||||
<ProjectCard key={project.id} {...project} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-4 items-stretch">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -20,7 +20,7 @@ const HomeProjectList = () => {
|
||||
if (error) return <p className="text-red-500 text-sm">{error}</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className=''>
|
||||
<h2 className="text-lg font-semibold text-font-shade mb-5">Projects</h2>
|
||||
|
||||
{projects.length === 0 ? (
|
||||
|
||||
@@ -34,6 +34,12 @@ export default function TaskCard({ task }: { task: InternTaskItem }) {
|
||||
onClick={() => navigate(`/intern/projects/${task.project_slug}/${task.slug}?type=${task.submission_type}`)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="md:hidden mb-[8px]">
|
||||
<span
|
||||
className={`font-semibold text-xs px-2.5 py-1 rounded-md whitespace-nowrap border ${statusStyles[displayStatus]}`}>
|
||||
{statusLabels[displayStatus]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-font mb-1">
|
||||
Deadline : {formatDate(task.deadline_at)} at {formatTime(task.deadline_at)}
|
||||
</div>
|
||||
@@ -48,7 +54,6 @@ export default function TaskCard({ task }: { task: InternTaskItem }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="block md:hidden h-[10px] bg-gray-600" />
|
||||
|
||||
<div className="shrink-0 hidden md:flex items-center border border-card-outline">
|
||||
<span
|
||||
|
||||
@@ -7,8 +7,8 @@ export default function ProjectTimeline({ tasks }: { tasks: InternTaskItem[] })
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return (
|
||||
<div className="">
|
||||
<div className="flex items-center gap-1.5 mb-1 md:mb-4 justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-0 md:mb-4 justify-between">
|
||||
<span className="font-semibold text-md text-font-shade">Timeline</span>
|
||||
</div>
|
||||
<ul className="relative pl-4">
|
||||
@@ -19,13 +19,13 @@ export default function ProjectTimeline({ tasks }: { tasks: InternTaskItem[] })
|
||||
const isPast = d < today;
|
||||
const active = isToday || isPast;
|
||||
return (
|
||||
<li key={task.id} className="relative mb-6 list-none">
|
||||
<li key={task.id} className="relative mb-2 list-none">
|
||||
{index < tasks.length - 1 && (
|
||||
<div className="absolute -left-[1.0625rem] top-3 w-px h-full bg-[#ddd]" />
|
||||
)}
|
||||
{/* Dot */}
|
||||
<div
|
||||
className={`absolute -left-5 top-0.5 w-3 h-3 rounded-full border-2 border-white ${
|
||||
className={`absolute -left-[1.45rem] top-0.5 w-3 h-3 rounded-full border-2 border-white ${
|
||||
active ? 'bg-red' : 'bg-gray-400'
|
||||
} ${isToday ? 'shadow-md' : ''}`}
|
||||
/>
|
||||
|
||||
@@ -1,39 +1,40 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getProjectIcon } from '../../../../lib/ProjectIcons';
|
||||
import type { Project } from '../../../../types/project.types';
|
||||
import { resolveFileUrl } from '../../../../hooks/useUser';
|
||||
|
||||
const ProjectCard = (project: Project) => {
|
||||
const navigate = useNavigate();
|
||||
const Icon = getProjectIcon(project.project_icon);
|
||||
|
||||
|
||||
const tasksDone = project.task_done;
|
||||
const Avatar = resolveFileUrl(project.admin.profile_picture);
|
||||
const tasksDone = project.task_done;
|
||||
const tasksTotal = project.total_tasks;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => navigate(`/intern/projects/${project.slug}`)}
|
||||
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"
|
||||
className="w-[220px] min-h-[320px] flex flex-col bg-white rounded-xl overflow-hidden shadow-sm border border-gray-100 cursor-pointer hover:shadow-md transition-shadow duration-200"
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div style={{ backgroundColor: project.background_color ?? '#dc2626' }} className="h-[150px] overflow-hidden flex items-center justify-center">
|
||||
<div
|
||||
style={{ backgroundColor: project.background_color ?? '#dc2626' }}
|
||||
className="h-[150px] flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<Icon className="w-12 h-12 text-white opacity-80" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-h-[180px] max-h-[200px] flex-1 flex flex-col justify-between py-3">
|
||||
<div className="flex flex-col flex-1 justify-between py-3">
|
||||
<div className="px-3 flex flex-col gap-2">
|
||||
{/* Project Name */}
|
||||
<p className="text-xs lg:text-sm font-semibold text-gray-800 leading-tight">
|
||||
<p className="text-sm font-semibold text-gray-800 leading-tight">
|
||||
{project.project_name}
|
||||
</p>
|
||||
|
||||
{/* Mentor */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-full bg-gray-200 overflow-hidden flex-shrink-0">
|
||||
{project.admin.profile_picture ? (
|
||||
{Avatar ? (
|
||||
<img
|
||||
src={project.admin.profile_picture}
|
||||
src={Avatar}
|
||||
alt={project.admin.full_name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
@@ -44,7 +45,6 @@ const ProjectCard = (project: Project) => {
|
||||
<span className="text-xs text-gray-500 truncate">{project.admin.email}</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="w-full h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
@@ -58,11 +58,8 @@ const ProjectCard = (project: Project) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Button */}
|
||||
<div className="flex items-end justify-end px-3 pb-1">
|
||||
<button
|
||||
className="bg-red-600 hover:bg-red-700 text-white text-xs font-medium py-1.5 px-3 rounded-xl transition-colors"
|
||||
>
|
||||
<div className="flex items-end justify-end px-3">
|
||||
<button className="bg-red-600 hover:bg-red-700 text-white text-xs font-medium py-1.5 px-3 rounded-xl transition-colors">
|
||||
View Project
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,9 @@ const TaskTimeline = () => {
|
||||
|
||||
const isOverdue = (dateStr: string) => new Date(dateStr) < today;
|
||||
|
||||
const formatDate = (dateInput: Date | string) =>
|
||||
new Date(dateInput).toLocaleDateString('id-ID', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
|
||||
if (loading) return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
@@ -19,7 +22,7 @@ const TaskTimeline = () => {
|
||||
if (error) return <p className="text-red-500 text-sm">{error}</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className=''>
|
||||
<h2 className="font-semibold text-font-shade mb-5">Timeline Tugas</h2>
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
@@ -50,7 +53,7 @@ const TaskTimeline = () => {
|
||||
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<p className="text-[10px] font-semibold text-font-shade uppercase tracking-widest">
|
||||
{deadlineDate.toLocaleDateString("id-ID")} AT{" "}
|
||||
{formatDate(deadlineDate)} AT{" "}
|
||||
{deadlineDate.toLocaleTimeString("id-ID", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
|
||||
@@ -21,7 +21,7 @@ export const InternLayout = () => {
|
||||
onClick={closeSidebar}
|
||||
/>
|
||||
)}
|
||||
<main className="container">
|
||||
<main className="main-content">
|
||||
<Breadcrumbs/>
|
||||
<Outlet />
|
||||
</main>
|
||||
@@ -46,7 +46,7 @@ export const MentorLayout = () => {
|
||||
onClick={closeSidebar}
|
||||
/>
|
||||
)}
|
||||
<main className="container">
|
||||
<main className="main-content">
|
||||
<Breadcrumbs/>
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
@@ -5,67 +5,78 @@ import CreateProjectModal from './components/CreateProjectDialog';
|
||||
import { useProjectsByRole } from '../../../hooks/useListProjects';
|
||||
import TeamPana from '../../../assets/Team-pana.svg';
|
||||
|
||||
function MentorProjectCardSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-row bg-gray-300 rounded-xl overflow-hidden shadow-sm w-[180px] min-h-[270px] animate-pulse">
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MentorProjectsPage() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const { projects, loading, error, refetch } = useProjectsByRole();
|
||||
|
||||
const openDialog = () => {
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
const closeDialog = () => {
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
const openDialog = () => setIsModalOpen(true);
|
||||
const closeDialog = () => setIsModalOpen(false);
|
||||
const handleRefetch = () => {
|
||||
closeDialog();
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-16 bg-gray-100 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (error) return <p className="text-danger text-md">{error}</p>;
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-row gap-3 w-full">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<MentorProjectCardSkeleton />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) return <p className="text-danger text-md">{error}</p>;
|
||||
|
||||
if (projects.length === 0) {
|
||||
return (
|
||||
<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 klik 'Add'
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return projects.map(project => (
|
||||
<MentorProjectCard
|
||||
key={project.id}
|
||||
{...project}
|
||||
onArchived={refetch}
|
||||
onCompleted={refetch}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="flex flex-col">
|
||||
<h1 className="page-title">List Projects</h1>
|
||||
<p className="page-title-desc">Create, manage, and monitor ongoing projects and assignments.</p>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={openDialog}
|
||||
className="flex items-center gap-2 px-4 py-1 bg-[#C0392B] text-white rounded-lg hover:bg-[#A93226] transition-colors duration-150"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Add</span>
|
||||
</button>
|
||||
<p className="page-title-desc">
|
||||
Create, manage, and monitor ongoing projects and assignments.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={openDialog}
|
||||
className="flex items-center gap-2 px-4 py-1 bg-[#C0392B] text-white rounded-lg hover:bg-[#A93226] transition-colors duration-150"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Add</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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 => (
|
||||
<MentorProjectCard
|
||||
key={project.id}
|
||||
{...project}
|
||||
onArchived={refetch}
|
||||
onCompleted={refetch}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 items-stretch">{renderContent()}</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<CreateProjectModal
|
||||
@@ -76,4 +87,4 @@ export default function MentorProjectsPage() {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -192,7 +192,7 @@ export default function CertificateDetail() {
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
Mengupload...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -66,18 +66,10 @@ export default function InternsManagement() {
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-16 bg-gray-100 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (error) return <p className="text-red-500 text-sm">{error}</p>;
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<div>
|
||||
{/* Header Title */}
|
||||
<div className="mb-6">
|
||||
<h1 className="page-title">Interns Management</h1>
|
||||
@@ -119,7 +111,7 @@ export default function InternsManagement() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-500"
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
@@ -130,11 +122,10 @@ export default function InternsManagement() {
|
||||
<button
|
||||
onClick={handleToggleSort}
|
||||
title={sortOrder === "asc" ? "Sort Z–A" : sortOrder === "desc" ? "Clear sort" : "Sort A–Z"}
|
||||
className={`p-2 shadow-sm rounded-lg transition-colors hidden sm:flex items-center gap-1 text-xs font-semibold ${
|
||||
sortOrder
|
||||
? "bg-[#B30000] text-white hover:bg-[#990000]"
|
||||
: "bg-card text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
className={`p-2 shadow-sm rounded-lg transition-colors hidden sm:flex items-center gap-1 text-xs font-semibold ${sortOrder
|
||||
? "bg-[#B30000] text-white hover:bg-[#990000]"
|
||||
: "bg-card text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{sortOrder === "desc" ? (
|
||||
<SortDesc className="w-4 h-4" />
|
||||
@@ -177,18 +168,26 @@ export default function InternsManagement() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<InternsTable
|
||||
key={`${searchEmail}-${selectedProject}`}
|
||||
interns={filteredInterns}
|
||||
totalInterns={totalInterns}
|
||||
removing={removing}
|
||||
sortOrder={sortOrder}
|
||||
onSort={handleToggleSort}
|
||||
onAssign={(userId) => openAssignFor(userId)}
|
||||
onDelete={handleDeleteIntern}
|
||||
/>
|
||||
{loading ? (
|
||||
<div className={`w-full bg-gray-400/20 h-[300px] rounded-xl flex items-center justify-center animate-pulse`}>
|
||||
<span className="text-sm text-font-shade">Memuat Data...</span>
|
||||
</div>
|
||||
) : (
|
||||
<InternsTable
|
||||
key={`${searchEmail}-${selectedProject}`}
|
||||
interns={filteredInterns}
|
||||
totalInterns={totalInterns}
|
||||
removing={removing}
|
||||
sortOrder={sortOrder}
|
||||
onSort={handleToggleSort}
|
||||
onAssign={(userId) => openAssignFor(userId)}
|
||||
onDelete={handleDeleteIntern}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
{isModalOpen &&
|
||||
{
|
||||
isModalOpen &&
|
||||
<AddInternsModal
|
||||
isOpen={isModalOpen}
|
||||
userId={selectedUserId}
|
||||
@@ -198,6 +197,6 @@ export default function InternsManagement() {
|
||||
}}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export default function MentorProjectsDetailPage() {
|
||||
};
|
||||
|
||||
if (loading) return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 mt-10">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-32 bg-box-secondary rounded-2xl animate-pulse" />
|
||||
))}
|
||||
@@ -47,7 +47,7 @@ export default function MentorProjectsDetailPage() {
|
||||
return (
|
||||
<div>
|
||||
{/* Header Utama */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="flex justify-between items-center mb-6 ">
|
||||
<h1 className="text-3xl font-bold text-[#1a1a1a]">{task.title}</h1>
|
||||
<button
|
||||
className="flex items-center gap-2 border border-gray-400 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium hover:bg-gray-50 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useCurrentUser, useUpdateProfile } from "../../../hooks/useUser";
|
||||
import { customToast } from "../../utils/showToast";
|
||||
|
||||
export default function MentorSettingsPage() {
|
||||
const { user, loading: userLoading } = useCurrentUser();
|
||||
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 [imageError, setImageError] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Sync form data ketika user sudah di-fetch
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setFormData({
|
||||
fullName:
|
||||
user.full_name ||
|
||||
`${user.nama_depan ?? ""} ${user.nama_belakang ?? ""}`.trim() ||
|
||||
"",
|
||||
email: user.email || "",
|
||||
bio: user.professional_bio || "",
|
||||
});
|
||||
if (user.profile_picture) {
|
||||
setPhotoPreview(user.profile_picture);
|
||||
} else {
|
||||
setPhotoPreview(null);
|
||||
}
|
||||
setImageError(false);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handlePhotoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setPhotoFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => setPhotoPreview(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemovePhoto = () => {
|
||||
setPhotoFile(null);
|
||||
setPhotoPreview(null);
|
||||
setImageError(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
const handleSaveChanges = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
await customToast.promise(
|
||||
save({
|
||||
full_name: formData.fullName,
|
||||
email: formData.email,
|
||||
professional_bio: formData.bio,
|
||||
}),
|
||||
{
|
||||
loading: "Menyimpan perubahan...",
|
||||
success: () => ({
|
||||
title: "Perubahan berhasil disimpan!",
|
||||
description: successMsg || "",
|
||||
}),
|
||||
error: () => ({
|
||||
title: "Gagal menyimpan perubahan!",
|
||||
description: errorMsg || "",
|
||||
}),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const initialLetter = formData.fullName ? formData.fullName.charAt(0).toUpperCase() : "U";
|
||||
|
||||
if (userLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-40 text-sm text-gray-400">
|
||||
Loading profile...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header Title */}
|
||||
<div className="mb-6">
|
||||
<h1 className="page-title">Settings</h1>
|
||||
<p className="page-title-desc">
|
||||
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">
|
||||
|
||||
{/* Card Header */}
|
||||
<div className="p-6 border-b border-box-border">
|
||||
<h2 className="text-[20px] font-bold text-foreground">Profile Information</h2>
|
||||
<p className="text-xs text-foreground mt-0.5">
|
||||
Update your photo and personal details.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Card Body & Form */}
|
||||
<form onSubmit={handleSaveChanges} className="p-6 space-y-6">
|
||||
|
||||
{/* Profile Photo Row */}
|
||||
<div className="flex items-center gap-5 py-2">
|
||||
{/* Avatar dengan badge edit */}
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="w-20 h-20 rounded-xl border-2 border-red-100 overflow-hidden bg-gray-100 flex items-center justify-center shadow-sm">
|
||||
{photoPreview && !imageError ? (
|
||||
<img
|
||||
src={photoPreview}
|
||||
alt="Profile"
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-3xl font-black text-gray-900">{initialLetter}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Badge edit icon */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="absolute -bottom-1.5 -right-1.5 w-6 h-6 bg-[#B30000] rounded-full flex items-center justify-center shadow border-2 border-white hover:bg-[#990000] transition-colors"
|
||||
title="Edit photo"
|
||||
>
|
||||
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M15.232 5.232l3.536 3.536M9 13l6.586-6.586a2 2 0 112.828 2.828L11.828 15.828a2 2 0 01-1.414.586H9v-2a2 2 0 01.586-1.414z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info & Action Buttons */}
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-gray-900 mb-0.5">Profile Photo</h3>
|
||||
<p className="text-xs text-gray-400 mb-3">
|
||||
JPG, GIF or PNG. Recommended size 800x800px. Max size of 800K.
|
||||
</p>
|
||||
{photoFile && (
|
||||
<p className="text-xs text-blue-500 mb-2 font-medium">
|
||||
📎 {photoFile.name}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="px-4 py-1.5 bg-[#B30000] hover:bg-[#990000] text-white text-xs font-bold rounded-lg shadow-sm transition-colors"
|
||||
>
|
||||
Upload New
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemovePhoto}
|
||||
className="px-4 py-1.5 bg-white border border-box-border text-gray-600 hover:bg-gray-50 text-xs font-bold rounded-lg transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png, image/jpeg, image/gif"
|
||||
className="hidden"
|
||||
onChange={handlePhotoUpload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr className="border-t border-dashed border-box-border" />
|
||||
|
||||
{/* Grid Inputs */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-bold tracking-wider text-gray-700 uppercase block">
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="fullName"
|
||||
value={formData.fullName}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-box-border rounded-lg text-sm text-gray-800 focus:outline-none focus:border-red-600 focus:ring-1 focus:ring-red-600 transition-colors bg-white font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-bold tracking-wider text-gray-700 uppercase block">
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-box-border rounded-lg text-sm text-gray-800 focus:outline-none focus:border-red-600 focus:ring-1 focus:ring-red-600 transition-colors bg-white font-medium"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Professional Bio */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-bold tracking-wider text-gray-700 uppercase block">
|
||||
Professional Bio
|
||||
</label>
|
||||
<textarea
|
||||
name="bio"
|
||||
rows={4}
|
||||
value={formData.bio}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-box-border rounded-lg text-sm text-gray-800 focus:outline-none focus:border-red-600 focus:ring-1 focus:ring-red-600 transition-colors bg-white font-medium resize-none"
|
||||
/>
|
||||
<p className="text-[11px] text-gray-500 font-normal pt-0.5">
|
||||
Brief description for your profile. URLs and @mentions are allowed.
|
||||
</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"
|
||||
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"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,9 +22,9 @@ export default function MentorForumTab({ project }: { project: Project }) {
|
||||
)}
|
||||
|
||||
{!loading && tasks.length === 0 ? (
|
||||
<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 className='flex justify-center items-center flex-col mt-12'>
|
||||
<FileText className='text-font mb-6' size={48} />
|
||||
<p className="text-md text-font">Belum ada tugas yang dapat dikerjakan.</p>
|
||||
</div>
|
||||
) : (
|
||||
!loading && (
|
||||
|
||||
@@ -4,12 +4,16 @@ import ManageInternsModal from './components/AddInternsDialog';
|
||||
import type { ProjectDetail, ProjectMember } from '../../../types/project.types';
|
||||
import { useRemoveMember } from '../../../hooks/useProjects';
|
||||
import { customToast } from '../../utils/showToast';
|
||||
import { resolveFileUrl, getInitials } from '../../../hooks/useUser';
|
||||
|
||||
export default function InternsTab({ project }: { project: ProjectDetail }) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [members, setMembers] = useState<ProjectMember[]>(project.members);
|
||||
const { remove, loading } = useRemoveMember();
|
||||
const isProjectActive = project?.status === "active"
|
||||
const [adminImageError, setAdminImageError] = useState(false);
|
||||
const adminAvatar = resolveFileUrl(project.admin.profile_picture);
|
||||
const adminHasPhoto = !!adminAvatar && !adminImageError;
|
||||
|
||||
const handleModalClose = (finalMembers: ProjectMember[]) => {
|
||||
setMembers(finalMembers);
|
||||
@@ -34,15 +38,20 @@ export default function InternsTab({ project }: { project: ProjectDetail }) {
|
||||
Mentor
|
||||
</h3>
|
||||
<div className="border-b border-gray-300 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{project.admin.profile_picture ? (
|
||||
<div className="flex items-center gap-3 ">
|
||||
{adminHasPhoto ? (
|
||||
<img
|
||||
src={project.admin.profile_picture}
|
||||
src={adminAvatar}
|
||||
alt={project.admin.full_name}
|
||||
className="w-8 h-8 rounded-full object-cover"
|
||||
className="w-[35px] h-[35px] object-cover rounded-full"
|
||||
onError={() => setAdminImageError(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-gray-200" />
|
||||
<div className="w-full h-full bg-[#B30000] flex items-center justify-center">
|
||||
<span className="text-white text-[10px] font-bold tracking-wide">
|
||||
{getInitials(project.admin.full_name)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span className=" text-sm text-[#333]">
|
||||
{project.admin.full_name}
|
||||
@@ -57,51 +66,51 @@ export default function InternsTab({ project }: { project: ProjectDetail }) {
|
||||
<h3 className="font-bold text-base text-black">
|
||||
Members
|
||||
</h3>
|
||||
<button
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
disabled={!isProjectActive}
|
||||
className={`border border-gray-500 px-2 py-1 rounded-md transition-colors ${!isProjectActive ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-gray-300 hover:bg-gray-400'
|
||||
}`}
|
||||
>
|
||||
<span className='flex items-center gap-3 text-[11px]'>
|
||||
<Plus className='w-3 h-3'/>Add
|
||||
<Plus className='w-3 h-3' />Add
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{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>
|
||||
<Users className='w-12 h-12 text-gray-300 mx-auto' />
|
||||
<p className="text-sm text-font mt-3">Belum ada anggota.</p>
|
||||
</div>
|
||||
) : (
|
||||
members.map((member: ProjectMember, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`flex items-center justify-between py-3 ${idx < members.length - 1 ? 'border-b border-gray-300' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className=" text-sm text-font">
|
||||
{member.email}
|
||||
<div
|
||||
key={idx}
|
||||
className={`flex items-center justify-between py-3 ${idx < members.length - 1 ? 'border-b border-gray-300' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className=" text-sm text-font">
|
||||
{member.email}
|
||||
</span>
|
||||
</div>
|
||||
<span className=" text-[13px] text-font font-semibold">
|
||||
{member.position}
|
||||
</span>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => handleRemove(member.id)}
|
||||
disabled={loading || !isProjectActive}
|
||||
className='flex items-center gap-1 text-[11px] bg-red-600 text-white py-1 px-2 rounded-md disabled:opacity-50 hover:bg-red-700 transition-colors disabled:cursor-not-allowed'
|
||||
>
|
||||
<Plus className='w-4 h-4' />Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span className=" text-[13px] text-font font-semibold">
|
||||
{member.position}
|
||||
</span>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => handleRemove(member.id)}
|
||||
disabled={loading || !isProjectActive}
|
||||
className='flex items-center gap-1 text-[11px] bg-red-600 text-white py-1 px-2 rounded-md disabled:opacity-50 hover:bg-red-700 transition-colors disabled:cursor-not-allowed'
|
||||
>
|
||||
<Plus className='w-4 h-4'/>Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)))}
|
||||
)))}
|
||||
</div>
|
||||
{isModalOpen && <ManageInternsModal
|
||||
isOpen={isModalOpen}
|
||||
{isModalOpen && <ManageInternsModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleModalClose}
|
||||
projectId={project.id}
|
||||
initialMembers={members}
|
||||
|
||||
@@ -4,15 +4,21 @@ import { useProjectDetail } from '../../../hooks/useProjects';
|
||||
import MentorForumTab from './ProjectForumTab';
|
||||
import InternsTab from './ProjectsInternTab';
|
||||
import TaskTab from './TaskTab';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export default function ProjectDetailPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const [activeTab, setActiveTab] = useState<'forum' | 'participants' | 'Task'>('forum');
|
||||
const { project, loading, error } = useProjectDetail(slug ?? "");
|
||||
|
||||
if (loading) return <p className="p-10 text-gray-400">Memuat project...</p>;
|
||||
if (error) return <p className="p-10 text-red-500">{error}</p>;
|
||||
if (!project) return <p className="p-10 text-gray-400">Project tidak ditemukan.</p>
|
||||
if (loading) return (
|
||||
<p className="p-10 flex items-center gap-2 text-[18px] text-gray-400">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Memuat project...
|
||||
</p>
|
||||
);
|
||||
|
||||
if (error) return <p className="p-10 text-[18px] text-red-700">{error}</p>;
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
@@ -30,7 +36,7 @@ export default function ProjectDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 min-h-screen">
|
||||
<div className="">
|
||||
<div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex mb-4 border-b-1 border-gray-300">
|
||||
@@ -57,7 +63,7 @@ export default function ProjectDetailPage() {
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})()}
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -30,13 +30,14 @@ export default function TaskTab({ project }: { project: ProjectDetail }) {
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return (
|
||||
<div className="flex flex-col gap-3 p-6">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-12 bg-gray-100 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
// if (loading) return (
|
||||
// <div className="flex flex-col gap-3 p-6">
|
||||
// {[...Array(3)].map((_, i) => (
|
||||
// <div key={i} className="h-12 bg-gray-300 rounded-lg animate-pulse" />
|
||||
// ))}
|
||||
// </div>
|
||||
// );
|
||||
|
||||
if (error) return <p className="text-red-500 text-sm p-6">{error}</p>;
|
||||
|
||||
return (
|
||||
@@ -54,7 +55,52 @@ export default function TaskTab({ project }: { project: ProjectDetail }) {
|
||||
<hr className="border-gray-200/80 mb-4" />
|
||||
|
||||
<div className="flex flex-col">
|
||||
{tasks.length > 0 ? (
|
||||
{loading ? (
|
||||
<div className="flex flex-col gap-3 py-4">
|
||||
{[...Array(tasks.length)].map((_, i) => (
|
||||
<div key={i} className="h-14 bg-gray-200 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="text-red-500 text-sm py-4">{error}</p>
|
||||
) : tasks.length > 0 ? (
|
||||
tasks.map((task, idx) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className={`flex items-center justify-between py-4 group ${idx < tasks.length - 1 ? 'border-b border-gray-100' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<ClipboardList size={22} className="text-slate-600 shrink-0" />
|
||||
<span className="text-base text-slate-700 font-medium truncate">{task.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-slate-600 shrink-0">
|
||||
<button
|
||||
onClick={() => { setSelectedTask(task); setEditModalOpen(true); }}
|
||||
disabled={!isProjectActive}
|
||||
className="border border-gray-500 text-font-shade rounded-lg px-2 py-1 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Pencil size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteTask(task.id, task.title)}
|
||||
disabled={!isProjectActive}
|
||||
className="border border-gray-500 text-font-shade rounded-lg px-2 py-1 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className='flex flex-col items-center justify-center py-16'>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
{/* {tasks.length > 0 ? (
|
||||
tasks.map((task, idx) => (
|
||||
<div
|
||||
key={task.id}
|
||||
@@ -70,14 +116,14 @@ export default function TaskTab({ project }: { project: ProjectDetail }) {
|
||||
<button
|
||||
onClick={() => { setSelectedTask(task); setEditModalOpen(true); }}
|
||||
disabled={!isProjectActive}
|
||||
className="border border-gray-500 text-font-shade rounded-lg px-2 py-1 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="border border-gray-500 text-font-shade rounded-lg px-2 py-1 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Pencil size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteTask(task.id, task.title)}
|
||||
disabled={!isProjectActive}
|
||||
className="border border-gray-500 text-font-shade rounded-lg px-2 py-1 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="border border-gray-500 text-font-shade rounded-lg px-2 py-1 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
@@ -89,7 +135,7 @@ export default function TaskTab({ project }: { project: ProjectDetail }) {
|
||||
<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>
|
||||
|
||||
{isModalOpen && (
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function ViewInternSubmission() {
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 mt-10">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-32 bg-box-secondary rounded-2xl animate-pulse" />
|
||||
))}
|
||||
|
||||
@@ -72,7 +72,7 @@ export default function CreateTaskModal({ isOpen, onClose, projectId, onSuccess
|
||||
}
|
||||
};
|
||||
return (
|
||||
<dialog ref={dialogRef} className="custom-dialog p-[1.5rem]">
|
||||
<dialog ref={dialogRef} className="custom-dialog">
|
||||
<div className="dialog-header">
|
||||
<div>
|
||||
<h2 className="text-[26px] font-bold text-[#B30000] tracking-tight">Create New Task</h2>
|
||||
@@ -82,7 +82,7 @@ export default function CreateTaskModal({ isOpen, onClose, projectId, onSuccess
|
||||
<X className="w-5 h-5 stroke-[2.5]" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="px-6 space-y-2 overflow-y-auto flex-1">
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-2 overflow-y-auto flex-1 pt-7">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-700 tracking-wide">Task Title</label>
|
||||
<input
|
||||
|
||||
@@ -8,12 +8,8 @@ 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="flex items-center gap-4 border border-gray-200 rounded-lg px-4 py-3 bg-gray-100 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>
|
||||
);
|
||||
}
|
||||
@@ -29,13 +25,17 @@ const HomeMentorProject = () => {
|
||||
closeDialog();
|
||||
refetch();
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex flex-row justify-between mb-3 items-center'>
|
||||
<h2 className="text-lg font-semibold text-gray-800">Projects</h2>
|
||||
<button
|
||||
className='bg-card p-1.5 rounded-lg font-bold hover:bg-card-hover border border-card-outline'
|
||||
onClick={openDialog} >
|
||||
onClick={openDialog}
|
||||
disabled={loading}
|
||||
>
|
||||
<span className='text-sm flex flex-row px-1 justify-between items-center gap-3.5'>
|
||||
<Plus className='w-4 h-4' />
|
||||
Add
|
||||
@@ -44,11 +44,7 @@ const HomeMentorProject = () => {
|
||||
</div>
|
||||
<div className="flex flex-col max-h-[160px] overflow-y-auto gap-3 pr-1">
|
||||
{loading && (
|
||||
<>
|
||||
{[...Array(2)].map((_, i) => (
|
||||
<ProjectCardSkeleton key={i} />
|
||||
))}
|
||||
</>
|
||||
<ProjectCardSkeleton/>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-red-500 px-5">{error}</p>}
|
||||
|
||||
@@ -27,7 +27,6 @@ export default function InternsTable({
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(interns.length / PAGE_SIZE));
|
||||
|
||||
// Clamp page bila filter mengubah jumlah hasil
|
||||
const safePage = Math.min(currentPage, totalPages);
|
||||
const startIndex = (safePage - 1) * PAGE_SIZE;
|
||||
const paginatedInterns = interns.slice(startIndex, startIndex + PAGE_SIZE);
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function MentorProjectCard({ onArchived, onCompleted, ...project
|
||||
|
||||
const Icon = getProjectIcon(project.project_icon);
|
||||
|
||||
/* ── Archive ─────────────────────────────────────────────── */
|
||||
/* Archive */
|
||||
const handleArchiveButtonClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
setShowArchiveDialog(true);
|
||||
@@ -65,24 +65,6 @@ export default function MentorProjectCard({ onArchived, onCompleted, ...project
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Archive Confirmation Dialog */}
|
||||
<ArchiveProjectDialog
|
||||
isOpen={showArchiveDialog}
|
||||
projectName={project.project_name}
|
||||
loading={archiveLoading}
|
||||
onConfirm={handleArchiveConfirm}
|
||||
onClose={() => setShowArchiveDialog(false)}
|
||||
/>
|
||||
|
||||
{/* Complete Confirmation Dialog */}
|
||||
<CompleteProjectDialog
|
||||
isOpen={showCompleteDialog}
|
||||
projectName={project.project_name}
|
||||
loading={completeLoading}
|
||||
onConfirm={handleCompleteConfirm}
|
||||
onClose={() => setShowCompleteDialog(false)}
|
||||
/>
|
||||
|
||||
{/* Card */}
|
||||
<div
|
||||
onClick={handleCardClick}
|
||||
@@ -151,6 +133,24 @@ export default function MentorProjectCard({ onArchived, onCompleted, ...project
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Archive Confirmation Dialog */}
|
||||
<ArchiveProjectDialog
|
||||
isOpen={showArchiveDialog}
|
||||
projectName={project.project_name}
|
||||
loading={archiveLoading}
|
||||
onConfirm={handleArchiveConfirm}
|
||||
onClose={() => setShowArchiveDialog(false)}
|
||||
/>
|
||||
|
||||
{/* Complete Confirmation Dialog */}
|
||||
<CompleteProjectDialog
|
||||
isOpen={showCompleteDialog}
|
||||
projectName={project.project_name}
|
||||
loading={completeLoading}
|
||||
onConfirm={handleCompleteConfirm}
|
||||
onClose={() => setShowCompleteDialog(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+83
-23
@@ -1,31 +1,80 @@
|
||||
// hooks/useUser.ts
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useRef, useSyncExternalStore } from "react";
|
||||
import { loginUser, getProfile, updateProfile } from "../services/UserService";
|
||||
import type { Mahasiswa } from "../types/project.types";
|
||||
import { getMahasiswa } from "../services/MahasiswaService";
|
||||
import type { CurrentUser, UpdateProfilePayload } from "../types/user.types";
|
||||
import { useRef } from "react";
|
||||
|
||||
// GET /auth-api/me — ambil data user yang sedang login
|
||||
|
||||
type UserStore = {
|
||||
user: CurrentUser | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
let store: UserStore = { user: null, loading: true, error: null };
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const setStore = (patch: Partial<UserStore>) => {
|
||||
store = { ...store, ...patch }; // objek baru -> getSnapshot berubah referensinya
|
||||
listeners.forEach((l) => l());
|
||||
};
|
||||
|
||||
const subscribe = (l: () => void) => {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
};
|
||||
|
||||
const getSnapshot = () => store;
|
||||
|
||||
// useUpdateProfile setelah PATCH sukses -> broadcast ke semua konsumen
|
||||
export const setCurrentUser = (user: CurrentUser | null) => {
|
||||
setStore({ user, loading: false, error: null });
|
||||
};
|
||||
|
||||
// dipakai saat logout
|
||||
export const clearCurrentUser = () => {
|
||||
fetched = false;
|
||||
inflight = null;
|
||||
setStore({ user: null, loading: false, error: null });
|
||||
};
|
||||
|
||||
let fetched = false;
|
||||
let inflight: Promise<void> | null = null;
|
||||
|
||||
const fetchUser = (): Promise<void> => {
|
||||
if (inflight) return inflight;
|
||||
|
||||
setStore({ loading: true, error: null });
|
||||
|
||||
inflight = getProfile()
|
||||
.then((u) => {
|
||||
fetched = true;
|
||||
setStore({ user: u, loading: false, error: null });
|
||||
})
|
||||
.catch(() => {
|
||||
setStore({ loading: false, error: "Gagal memuat data user." });
|
||||
})
|
||||
.finally(() => {
|
||||
inflight = null;
|
||||
});
|
||||
|
||||
return inflight;
|
||||
};
|
||||
|
||||
export const useCurrentUser = () => {
|
||||
const [user, setUser] = useState<CurrentUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getProfile()
|
||||
.then(setUser)
|
||||
.catch(() => setError("Gagal memuat data user."))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
const snap = useSyncExternalStore(subscribe, getSnapshot);
|
||||
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
}, [refetch]);
|
||||
if (!fetched && !inflight) fetchUser();
|
||||
}, []);
|
||||
|
||||
return { user, loading, error, refetch };
|
||||
const refetch = useCallback(() => {
|
||||
fetched = false;
|
||||
return fetchUser();
|
||||
}, []);
|
||||
|
||||
return { user: snap.user, loading: snap.loading, error: snap.error, refetch };
|
||||
};
|
||||
|
||||
// POST /auth-api/login
|
||||
@@ -38,6 +87,7 @@ export const useLogin = () => {
|
||||
setError(null);
|
||||
try {
|
||||
const data = await loginUser({ email, password });
|
||||
clearCurrentUser(); // buang user lama agar tidak bocor antar-sesi
|
||||
return data; // { token }
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status;
|
||||
@@ -55,7 +105,14 @@ export const useLogin = () => {
|
||||
return { login, loading, error, setError };
|
||||
};
|
||||
|
||||
export const DEFAULT_AVATAR = "https://placehold.co/150";
|
||||
export const getInitials = (fullName?: string | null): string => {
|
||||
if (!fullName) return "U";
|
||||
const parts = fullName.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return "U";
|
||||
if (parts.length === 1) return parts[0].charAt(0).toUpperCase();
|
||||
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
|
||||
};
|
||||
|
||||
|
||||
export const resolveFileUrl = (path?: string | null): string | null => {
|
||||
if (!path) return null;
|
||||
@@ -84,6 +141,8 @@ export const useUpdateProfile = () => {
|
||||
try {
|
||||
const updated = await updateProfile(payload);
|
||||
|
||||
setCurrentUser(updated);
|
||||
|
||||
const msg = "Profile updated successfully!";
|
||||
msgRef.current.success = msg;
|
||||
setSuccessMsg(msg);
|
||||
@@ -103,7 +162,10 @@ export const useUpdateProfile = () => {
|
||||
message = "Sesi Anda telah berakhir. Silakan login kembali.";
|
||||
break;
|
||||
case 403:
|
||||
message = "Anda tidak memiliki izin untuk mengubah profil ini.";
|
||||
message = apiMessage || "Anda tidak memiliki izin untuk mengubah data ini.";
|
||||
break;
|
||||
case 409:
|
||||
message = apiMessage || "Email sudah digunakan, atau nama sudah pernah diubah.";
|
||||
break;
|
||||
case 413:
|
||||
message = "Ukuran file terlalu besar. Maksimal 2MB.";
|
||||
@@ -120,8 +182,6 @@ export const useUpdateProfile = () => {
|
||||
|
||||
msgRef.current.error = message;
|
||||
setError(message);
|
||||
|
||||
// WAJIB throw — kalau `return null`, toast selalu masuk cabang success.
|
||||
throw new Error(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -150,4 +210,4 @@ export const useMahasiswa = () => {
|
||||
}, [refetch]);
|
||||
|
||||
return { mahasiswa, loading, error, refetch };
|
||||
};
|
||||
};
|
||||
+25
-27
@@ -24,7 +24,7 @@
|
||||
--color-card-hover: #d5d5d5;
|
||||
|
||||
--color-box-primary: #FFFFFF;
|
||||
--color-box-secondary: #f3f4f6 ;
|
||||
--color-box-secondary: #f3f4f6;
|
||||
--color-box-border: #a9afbb;
|
||||
|
||||
--color-primary: #ad2b19;
|
||||
@@ -42,26 +42,29 @@
|
||||
--color-danger: #c62611;
|
||||
}
|
||||
|
||||
.container {
|
||||
.main-content {
|
||||
flex: 1 1 0%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
padding-inline: calc(var(--spacing) * 14);
|
||||
padding-block: calc(var(--spacing) * 12);
|
||||
background-color: var(--color-background);
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.box {
|
||||
background-color: #F9F9F9;
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -2px rgba(0, 0, 0, 0.1);
|
||||
background-color: var(--color-box-primary);
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 32px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: var(--text-3xl) /* 1.875rem = 30px */;
|
||||
line-height: var(--tw-leading, var(--text-3xl--line-height) /* calc(2.25 / 1.875) ≈ 1.2 */);
|
||||
font-size: var(--text-3xl) ;
|
||||
line-height: var(--tw-leading, var(--text-3xl--line-height));
|
||||
font-weight: bold;
|
||||
color: var(--color-gray-900);
|
||||
--tw-tracking: var(--tracking-tight);
|
||||
@@ -74,7 +77,6 @@
|
||||
margin-top: calc(var(--spacing) * 1)
|
||||
}
|
||||
|
||||
/* Selector utama untuk elemen dialog */
|
||||
dialog.custom-dialog {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -82,7 +84,7 @@ dialog.custom-dialog {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
margin: auto;
|
||||
display: none; /* default tertutup: sembunyikan */
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
width: calc(100% - 2rem);
|
||||
max-width: 540px;
|
||||
@@ -94,22 +96,21 @@ dialog.custom-dialog {
|
||||
}
|
||||
|
||||
dialog.custom-dialog[open] {
|
||||
display: flex; /* hanya tampil saat terbuka */
|
||||
display: flex;
|
||||
animation: fadeInZoom 200ms ease-out forwards;
|
||||
}
|
||||
|
||||
/* Pseudo-element untuk Backdrop (Latar belakang gelap) */
|
||||
dialog.custom-dialog::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.4); /* bg-black/40 */
|
||||
backdrop-filter: blur(4px); /* backdrop-blur-sm */
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Keyframes untuk animasi Tailwind (animate-in fade-in zoom-in-95) */
|
||||
@keyframes fadeInZoom {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
@@ -123,21 +124,18 @@ dialog.custom-dialog::backdrop {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-bottom: 1px solid #b3b8c0; /* border-gray-100 */
|
||||
border-bottom: 1px solid #b3b8c0;
|
||||
}
|
||||
|
||||
/* Breakpoint Medium (md:) - Aktif saat lebar layar >= 768px */
|
||||
@media (min-width: 1024px) {
|
||||
.dialog-header {
|
||||
/* Perubahan padding untuk layar desktop (md:px-8 md:py-5) */
|
||||
padding-left: 2rem; /* 32px */
|
||||
padding-right: 2rem; /* 32px */
|
||||
padding-top: 1.25rem; /* 20px */
|
||||
padding-bottom: 1.25rem;/* 20px */
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
padding-top: 1.25rem;
|
||||
padding-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding-inline: calc(var(--spacing) * 14);
|
||||
.main-content {
|
||||
padding-block: calc(var(--spacing) * 10);
|
||||
}
|
||||
}
|
||||
+27
-31
@@ -15,49 +15,45 @@ export const loginUser = async (payload: {
|
||||
// GET /auth-api/me
|
||||
export const getProfile = async (): Promise<CurrentUser> => {
|
||||
const res = await api.get("/auth-api/me");
|
||||
return res.data.data;
|
||||
return normalizeUser(res.data.data);
|
||||
};
|
||||
|
||||
export const normalizeUser = (raw: any): CurrentUser => ({
|
||||
...raw,
|
||||
full_name:
|
||||
raw.full_name ??
|
||||
`${raw.nama_depan ?? ""} ${raw.nama_belakang ?? ""}`.trim(),
|
||||
});
|
||||
|
||||
// Mengembalikan inisial nama dari pengguna (Huruf pertama Nama depan dan huruf kedua Nama belakang)
|
||||
export function getInitials(fullName: string | undefined | null): string {
|
||||
if (!fullName) return "U";
|
||||
const parts = fullName.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return "U";
|
||||
if (parts.length === 1) return parts[0].charAt(0).toUpperCase();
|
||||
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
|
||||
}
|
||||
|
||||
// PATCH /auth-api/update-profile
|
||||
export const updateProfile = async (
|
||||
payload: UpdateProfilePayload,
|
||||
): Promise<CurrentUser> => {
|
||||
const parts = (payload.full_name ?? "").trim().split(/\s+/).filter(Boolean);
|
||||
const namaDepan = parts[0] ?? "";
|
||||
const namaBelakang = parts.slice(1).join(" ");
|
||||
const formData = new FormData();
|
||||
|
||||
if (payload.profile_picture instanceof File) {
|
||||
const formData = new FormData();
|
||||
|
||||
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.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);
|
||||
return res.data.data;
|
||||
}
|
||||
const body: Record<string, unknown> = {};
|
||||
if (payload.full_name !== undefined) {
|
||||
body.nama_depan = namaDepan;
|
||||
body.nama_belakang = namaBelakang;
|
||||
formData.append("full_name", payload.full_name.trim());
|
||||
}
|
||||
if (payload.email !== undefined) body.email = payload.email;
|
||||
if (payload.email !== undefined) formData.append("email", payload.email.trim());
|
||||
|
||||
if (payload.professional_bio !== undefined) {
|
||||
body.professional_bio = payload.professional_bio;
|
||||
formData.append("professional_bio", payload.professional_bio);
|
||||
}
|
||||
if (payload.profile_picture instanceof File) {
|
||||
formData.append("profile_picture", payload.profile_picture);
|
||||
}
|
||||
|
||||
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;
|
||||
const res = await api.patch("/auth-api/update-profile", formData);
|
||||
return normalizeUser(res.data.data);
|
||||
};
|
||||
|
||||
export const getMahasiswa = async (): Promise<Mahasiswa[]> => {
|
||||
|
||||
@@ -6,7 +6,11 @@ export interface CurrentUser {
|
||||
email: string;
|
||||
role: "mentor" | "intern" | "admin";
|
||||
professional_bio?: string;
|
||||
profile_picture?: string | null;
|
||||
profile_picture?: string | null;
|
||||
changes?: number;
|
||||
kelompok_peminatan: string;
|
||||
signature?: string;
|
||||
|
||||
}
|
||||
|
||||
export interface UpdateProfilePayload {
|
||||
|
||||
Reference in New Issue
Block a user