diff --git a/src/app/(admin)/admin/mentors/[id]/page.tsx b/src/app/(admin)/admin/mentors/[id]/page.tsx new file mode 100644 index 0000000..74a916b --- /dev/null +++ b/src/app/(admin)/admin/mentors/[id]/page.tsx @@ -0,0 +1,389 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useParams, useRouter } from 'next/navigation' +import Link from 'next/link' +import type { Route } from 'next' +import { trpc } from '@/lib/trpc/client' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Badge } from '@/components/ui/badge' +import { Skeleton } from '@/components/ui/skeleton' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { toast } from 'sonner' +import { TagInput } from '@/components/shared/tag-input' +import { + ArrowLeft, + Save, + Mail, + GraduationCap, + Loader2, + AlertCircle, + ClipboardList, + User, +} from 'lucide-react' + +export default function MentorDetailPage() { + const params = useParams() + const router = useRouter() + const mentorId = params.id as string + + const { data: mentor, isLoading, refetch } = trpc.user.get.useQuery({ id: mentorId }) + const updateUser = trpc.user.update.useMutation() + const sendInvitation = trpc.user.sendInvitation.useMutation() + const { data: assignmentsData } = trpc.mentor.listAssignments.useQuery({ + mentorId, + perPage: 50, + }) + + const [name, setName] = useState('') + const [status, setStatus] = useState<'INVITED' | 'ACTIVE' | 'SUSPENDED'>('INVITED') + const [expertiseTags, setExpertiseTags] = useState([]) + const [maxAssignments, setMaxAssignments] = useState('') + + useEffect(() => { + if (mentor) { + setName(mentor.name || '') + setStatus(mentor.status as 'INVITED' | 'ACTIVE' | 'SUSPENDED') + setExpertiseTags(mentor.expertiseTags || []) + setMaxAssignments(mentor.maxAssignments?.toString() || '') + } + }, [mentor]) + + const handleSave = async () => { + try { + await updateUser.mutateAsync({ + id: mentorId, + name: name || null, + status, + expertiseTags, + maxAssignments: maxAssignments ? parseInt(maxAssignments) : null, + }) + toast.success('Mentor updated successfully') + refetch() + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to update mentor') + } + } + + const handleSendInvitation = async () => { + try { + await sendInvitation.mutateAsync({ userId: mentorId }) + toast.success('Invitation email sent successfully') + refetch() + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to send invitation') + } + } + + if (isLoading) { + return ( +
+
+ +
+ + + + + + + + + + + +
+ ) + } + + if (!mentor) { + return ( +
+ + + Mentor not found + + The mentor you're looking for does not exist. + + + +
+ ) + } + + const statusColors: Record = { + ACTIVE: 'success', + INVITED: 'secondary', + SUSPENDED: 'destructive', + } + + return ( +
+ {/* Header */} +
+ +
+ +
+
+

+ {mentor.name || 'Unnamed Mentor'} +

+

{mentor.email}

+
+
+ + {mentor.status} + + {mentor.status === 'INVITED' && ( + + )} +
+
+ +
+ {/* Profile Info */} + + + + + Profile Information + + + Update the mentor's profile and settings + + + +
+ + +
+ +
+ + setName(e.target.value)} + placeholder="Enter name" + /> +
+ +
+ + +
+
+
+ + {/* Expertise & Capacity */} + + + + + Expertise & Capacity + + + Configure expertise areas and assignment limits + + + +
+ + +
+ +
+ + setMaxAssignments(e.target.value)} + placeholder="Unlimited" + /> +

+ Maximum number of projects this mentor can be assigned +

+
+ + {mentor._count && ( +
+

Statistics

+
+
+

Total Assignments

+

{mentor._count.assignments}

+
+
+

Last Login

+

+ {mentor.lastLoginAt + ? new Date(mentor.lastLoginAt).toLocaleDateString() + : 'Never'} +

+
+
+
+ )} +
+
+
+ + {/* Save Button */} +
+ + +
+ + {/* Assigned Projects */} + + + + + Assigned Projects + + + Projects currently assigned to this mentor + + + + {assignmentsData && assignmentsData.assignments.length > 0 ? ( + + + + Project + Team + Category + Status + Assigned + + + + {assignmentsData.assignments.map((assignment) => ( + + + + {assignment.project.title} + + + + {assignment.project.teamName || '-'} + + + {assignment.project.competitionCategory ? ( + + {assignment.project.competitionCategory} + + ) : ( + - + )} + + + + {assignment.project.status} + + + + {new Date(assignment.assignedAt).toLocaleDateString()} + + + ))} + +
+ ) : ( +

+ No projects assigned to this mentor yet. +

+ )} +
+
+ + {/* Invitation Status */} + {mentor.status === 'INVITED' && ( + + + Invitation Pending + + This mentor hasn't accepted their invitation yet. You can resend + the invitation email using the button above. + + + )} +
+ ) +} diff --git a/src/app/(admin)/admin/mentors/page.tsx b/src/app/(admin)/admin/mentors/page.tsx new file mode 100644 index 0000000..fe71849 --- /dev/null +++ b/src/app/(admin)/admin/mentors/page.tsx @@ -0,0 +1,251 @@ +import { Suspense } from 'react' +import Link from 'next/link' +import { prisma } from '@/lib/prisma' + +export const dynamic = 'force-dynamic' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { Avatar, AvatarFallback } from '@/components/ui/avatar' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import type { Route } from 'next' +import { Plus, GraduationCap, Eye } from 'lucide-react' +import { formatDate, getInitials } from '@/lib/utils' + +async function MentorsContent() { + const mentors = await prisma.user.findMany({ + where: { + role: 'MENTOR', + }, + include: { + _count: { + select: { + mentorAssignments: true, + }, + }, + }, + orderBy: [{ status: 'asc' }, { name: 'asc' }], + }) + + if (mentors.length === 0) { + return ( + + + +

No mentors yet

+

+ Invite mentors to start matching them with projects +

+ +
+
+ ) + } + + const statusColors: Record = { + ACTIVE: 'success', + INVITED: 'secondary', + SUSPENDED: 'destructive', + } + + return ( + <> + {/* Desktop table view */} + + + + + Mentor + Expertise + Assigned Projects + Status + Last Login + Actions + + + + {mentors.map((mentor) => ( + + +
+ + + {getInitials(mentor.name || mentor.email || 'M')} + + +
+

{mentor.name || 'Unnamed'}

+

+ {mentor.email} +

+
+
+
+ + {mentor.expertiseTags && mentor.expertiseTags.length > 0 ? ( +
+ {mentor.expertiseTags.slice(0, 3).map((tag) => ( + + {tag} + + ))} + {mentor.expertiseTags.length > 3 && ( + + +{mentor.expertiseTags.length - 3} + + )} +
+ ) : ( + - + )} +
+ + {mentor._count.mentorAssignments} + project{mentor._count.mentorAssignments !== 1 ? 's' : ''} + + + + {mentor.status} + + + + {mentor.lastLoginAt ? ( + formatDate(mentor.lastLoginAt) + ) : ( + Never + )} + + + + +
+ ))} +
+
+
+ + {/* Mobile card view */} +
+ {mentors.map((mentor) => ( + + +
+
+ + + {getInitials(mentor.name || mentor.email || 'M')} + + +
+ + {mentor.name || 'Unnamed'} + + + {mentor.email} + +
+
+ + {mentor.status} + +
+
+ +
+ Assigned Projects + {mentor._count.mentorAssignments} +
+ {mentor.expertiseTags && mentor.expertiseTags.length > 0 && ( +
+ {mentor.expertiseTags.map((tag) => ( + + {tag} + + ))} +
+ )} + +
+
+ ))} +
+ + ) +} + +function MentorsSkeleton() { + return ( + + +
+ {[...Array(5)].map((_, i) => ( +
+ +
+ + +
+ +
+ ))} +
+
+
+ ) +} + +export default function MentorsPage() { + return ( +
+ {/* Header */} +
+
+

Mentors

+

+ Manage mentors and their project assignments +

+
+ +
+ + {/* Content */} + }> + + +
+ ) +} diff --git a/src/app/(settings)/layout.tsx b/src/app/(settings)/layout.tsx new file mode 100644 index 0000000..9303296 --- /dev/null +++ b/src/app/(settings)/layout.tsx @@ -0,0 +1,55 @@ +import { redirect } from 'next/navigation' +import { auth } from '@/lib/auth' + +const ROLE_DASHBOARDS: Record = { + SUPER_ADMIN: '/admin', + PROGRAM_ADMIN: '/admin', + JURY_MEMBER: '/jury', + MENTOR: '/mentor', + OBSERVER: '/observer', +} + +export default async function SettingsLayout({ + children, +}: { + children: React.ReactNode +}) { + const session = await auth() + + if (!session?.user) { + redirect('/login') + } + + const dashboardUrl = ROLE_DASHBOARDS[session.user.role] || '/login' + + return ( +
+
+ +
+
+ {children} +
+
+ ) +} diff --git a/src/app/(settings)/settings/profile/page.tsx b/src/app/(settings)/settings/profile/page.tsx new file mode 100644 index 0000000..3124b50 --- /dev/null +++ b/src/app/(settings)/settings/profile/page.tsx @@ -0,0 +1,427 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { signOut } from 'next-auth/react' +import { trpc } from '@/lib/trpc/client' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { Skeleton } from '@/components/ui/skeleton' +import { AvatarUpload } from '@/components/shared/avatar-upload' +import { UserAvatar } from '@/components/shared/user-avatar' +import { + Loader2, + Save, + Camera, + Lock, + Bell, + Trash2, + User, +} from 'lucide-react' + +export default function ProfileSettingsPage() { + const router = useRouter() + const { data: user, isLoading, refetch } = trpc.user.me.useQuery() + const { data: avatarUrl } = trpc.avatar.getUrl.useQuery() + const updateProfile = trpc.user.updateProfile.useMutation() + const changePassword = trpc.user.changePassword.useMutation() + const deleteAccount = trpc.user.deleteAccount.useMutation() + + // Profile form state + const [name, setName] = useState('') + const [bio, setBio] = useState('') + const [phoneNumber, setPhoneNumber] = useState('') + const [notificationPreference, setNotificationPreference] = useState('EMAIL') + const [profileLoaded, setProfileLoaded] = useState(false) + + // Password form state + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmNewPassword, setConfirmNewPassword] = useState('') + + // Delete account state + const [deletePassword, setDeletePassword] = useState('') + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + + // Populate form when user data loads + if (user && !profileLoaded) { + setName(user.name || '') + const meta = (user.metadataJson as Record) || {} + setBio((meta.bio as string) || '') + setPhoneNumber(user.phoneNumber || '') + setNotificationPreference(user.notificationPreference || 'EMAIL') + setProfileLoaded(true) + } + + const handleSaveProfile = async () => { + try { + await updateProfile.mutateAsync({ + name: name || undefined, + bio, + phoneNumber: phoneNumber || null, + notificationPreference: notificationPreference as 'EMAIL' | 'WHATSAPP' | 'BOTH' | 'NONE', + }) + toast.success('Profile updated successfully') + refetch() + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to update profile') + } + } + + const handleChangePassword = async () => { + if (newPassword !== confirmNewPassword) { + toast.error('New passwords do not match') + return + } + + try { + await changePassword.mutateAsync({ + currentPassword, + newPassword, + confirmNewPassword, + }) + toast.success('Password changed successfully') + setCurrentPassword('') + setNewPassword('') + setConfirmNewPassword('') + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to change password') + } + } + + const handleDeleteAccount = async () => { + try { + await deleteAccount.mutateAsync({ password: deletePassword }) + toast.success('Account deleted') + setDeleteDialogOpen(false) + signOut({ callbackUrl: '/login' }) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to delete account') + } + } + + if (isLoading) { + return ( +
+ + + +
+ ) + } + + if (!user) return null + + return ( +
+
+

Profile Settings

+

+ Manage your personal information and preferences +

+
+ + {/* Profile Photo */} + + + + + Profile Photo + + + Click your avatar to upload a new profile picture + + + + refetch()} + > +
+ +
+
+
+
+ + {/* Personal Information */} + + + + + Personal Information + + + Update your name, bio, and contact information + + + +
+ + +

Email cannot be changed

+
+ +
+ + setName(e.target.value)} + placeholder="Your full name" + /> +
+ +
+ +