feat(applicant): mentor list + request-change dialog (PR8 Task 7)

- /applicant/mentor renders all co-mentors as cards
- New "Request a mentor change" dialog opens a free-form reason + optional
  per-mentor target; calls mentor.requestChange and shows admin-routed
  confirmation toast
- Pending-request guard disables the button until the admin resolves
This commit is contained in:
Matt
2026-05-22 17:09:06 +02:00
parent ee47c0305f
commit ba115f71a0
3 changed files with 423 additions and 152 deletions

View File

@@ -1,151 +1,230 @@
'use client' 'use client'
import { useSession } from 'next-auth/react' import { useState } from 'react'
import { trpc } from '@/lib/trpc/client' import { useSession } from 'next-auth/react'
import { import { format } from 'date-fns'
Card, import { trpc } from '@/lib/trpc/client'
CardContent, import {
CardDescription, Card,
CardHeader, CardContent,
CardTitle, CardDescription,
} from '@/components/ui/card' CardHeader,
import { Skeleton } from '@/components/ui/skeleton' CardTitle,
import { MentorChat } from '@/components/shared/mentor-chat' } from '@/components/ui/card'
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel' import { Badge } from '@/components/ui/badge'
import { import { Button } from '@/components/ui/button'
MessageSquare, import { Skeleton } from '@/components/ui/skeleton'
UserCircle, import { MentorChat } from '@/components/shared/mentor-chat'
FileText, import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
} from 'lucide-react' import { RequestChangeDialog } from './request-change-dialog'
import {
export default function ApplicantMentorPage() { MessageSquare,
const { data: session, status: sessionStatus } = useSession() UserCircle,
const isAuthenticated = sessionStatus === 'authenticated' FileText,
UserCog,
const { data: dashboardData, isLoading: dashLoading } = trpc.applicant.getMyDashboard.useQuery( } from 'lucide-react'
undefined,
{ enabled: isAuthenticated } export default function ApplicantMentorPage() {
) const { data: session, status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const projectId = dashboardData?.project?.id
const { data: dashboardData, isLoading: dashLoading } = trpc.applicant.getMyDashboard.useQuery(
const { data: mentorMessages, isLoading: messagesLoading } = trpc.applicant.getMentorMessages.useQuery( undefined,
{ projectId: projectId! }, { enabled: isAuthenticated }
{ enabled: !!projectId } )
)
const projectId = dashboardData?.project?.id
const utils = trpc.useUtils()
const sendMessage = trpc.applicant.sendMentorMessage.useMutation({ const { data: mentorMessages, isLoading: messagesLoading } = trpc.applicant.getMentorMessages.useQuery(
onSuccess: () => { { projectId: projectId! },
utils.applicant.getMentorMessages.invalidate({ projectId: projectId! }) { enabled: !!projectId }
}, )
})
const utils = trpc.useUtils()
if (dashLoading) { const sendMessage = trpc.applicant.sendMentorMessage.useMutation({
return ( onSuccess: () => {
<div className="space-y-6"> utils.applicant.getMentorMessages.invalidate({ projectId: projectId! })
<div className="space-y-2"> },
<Skeleton className="h-8 w-48" /> })
<Skeleton className="h-4 w-64" />
</div> const [isChangeOpen, setIsChangeOpen] = useState(false)
<Skeleton className="h-96 w-full" />
</div> if (dashLoading) {
) return (
} <div className="space-y-6">
<div className="space-y-2">
if (!projectId) { <Skeleton className="h-8 w-48" />
return ( <Skeleton className="h-4 w-64" />
<div className="space-y-6"> </div>
<div> <Skeleton className="h-96 w-full" />
<h1 className="text-2xl font-semibold tracking-tight">Mentor</h1> </div>
</div> )
<Card> }
<CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" /> if (!projectId) {
<h2 className="text-xl font-semibold mb-2">No Project</h2> return (
<p className="text-muted-foreground text-center"> <div className="space-y-6">
Submit a project first to communicate with your mentor. <div>
</p> <h1 className="text-2xl font-semibold tracking-tight">Mentor</h1>
</CardContent> </div>
</Card> <Card>
</div> <CardContent className="flex flex-col items-center justify-center py-12">
) <FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
} <h2 className="text-xl font-semibold mb-2">No Project</h2>
<p className="text-muted-foreground text-center">
// TODO(PR8 Task 7): show ALL assigned mentors. For now we display only the Submit a project first to communicate with your mentor.
// first one until the multi-mentor applicant UI ships. </p>
const primaryAssignment = dashboardData?.project?.mentorAssignments?.[0] ?? null </CardContent>
const mentor = primaryAssignment?.mentor </Card>
</div>
return ( )
<div className="space-y-6"> }
{/* Header */}
<div> const assignments = dashboardData?.project?.mentorAssignments ?? []
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2"> const hasMentors = assignments.length > 0
<MessageSquare className="h-6 w-6" /> const primaryAssignment = assignments[0] ?? null
Mentor Communication const primaryMentor = primaryAssignment?.mentor
</h1> const hasPendingChangeRequest = !!dashboardData?.hasPendingMentorChangeRequest
<p className="text-muted-foreground">
Chat with your assigned mentor const dialogMentors = assignments
</p> .filter((a) => !!a.mentor)
</div> .map((a) => ({
assignmentId: a.id,
{/* Mentor info */} name: a.mentor?.name || a.mentor?.email || 'Mentor',
{mentor ? ( }))
<Card className="bg-muted/50">
<CardContent className="p-4"> const teamHeading = assignments.length > 1 ? 'Your mentor team' : 'Your mentor'
<div className="flex items-center gap-3">
<UserCircle className="h-10 w-10 text-muted-foreground" /> return (
<div> <div className="space-y-6">
<p className="font-medium">{mentor.name || 'Mentor'}</p> {/* Header */}
<p className="text-sm text-muted-foreground">{mentor.email}</p> <div>
</div> <h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
</div> <MessageSquare className="h-6 w-6" />
</CardContent> Mentor Communication
</Card> </h1>
) : ( <p className="text-muted-foreground">
<Card className="bg-muted/50"> {assignments.length > 1
<CardContent className="flex flex-col items-center justify-center py-8"> ? 'Chat with your assigned mentor team'
<UserCircle className="h-12 w-12 text-muted-foreground/50 mb-3" /> : 'Chat with your assigned mentor'}
<p className="text-muted-foreground text-center"> </p>
No mentor has been assigned to your project yet. </div>
You&apos;ll be notified when a mentor is assigned.
</p> {/* Mentor list */}
</CardContent> {hasMentors ? (
</Card> <section className="space-y-3">
)} <h2 className="text-lg font-semibold tracking-tight">{teamHeading}</h2>
<div className="grid gap-3 md:grid-cols-2">
{/* Chat */} {assignments.map((assignment) => {
{mentor && ( const mentor = assignment.mentor
<Card> if (!mentor) return null
<CardHeader> const expertise = mentor.expertiseTags ?? []
<CardTitle>Messages</CardTitle> return (
<CardDescription> <Card key={assignment.id} className="bg-muted/50">
Your conversation history with {mentor.name || 'your mentor'} <CardContent className="p-4 space-y-3">
</CardDescription> <div className="flex items-start gap-3">
</CardHeader> <UserCircle className="h-10 w-10 text-muted-foreground shrink-0" />
<CardContent> <div className="min-w-0 flex-1">
<MentorChat <p className="font-medium truncate">
messages={mentorMessages || []} {mentor.name || 'Mentor'}
currentUserId={session?.user?.id || ''} </p>
onSendMessage={async (message) => { <p className="text-sm text-muted-foreground truncate">
await sendMessage.mutateAsync({ projectId: projectId!, message }) {mentor.email}
}} </p>
isLoading={messagesLoading} {assignment.assignedAt && (
isSending={sendMessage.isPending} <p className="text-xs text-muted-foreground mt-1">
/> Assigned since {format(new Date(assignment.assignedAt), 'MMM d, yyyy')}
</CardContent> </p>
</Card> )}
)} </div>
</div>
{/* Files */} {expertise.length > 0 && (
{primaryAssignment?.id && projectId && ( <div className="flex flex-wrap gap-1.5">
<WorkspaceFilesPanel {expertise.map((tag) => (
projectId={projectId} <Badge key={tag} variant="secondary" className="font-normal">
mentorAssignmentId={primaryAssignment.id} {tag}
asApplicant </Badge>
/> ))}
)} </div>
</div> )}
) </CardContent>
} </Card>
)
})}
</div>
{/* Request change action */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pt-1">
<p className="text-sm text-muted-foreground">
{hasPendingChangeRequest
? "You have a pending mentor change request — admins will follow up soon."
: 'Need a different match? Let the program admins know.'}
</p>
<Button
variant="outline"
onClick={() => setIsChangeOpen(true)}
disabled={hasPendingChangeRequest}
>
<UserCog className="mr-2 h-4 w-4" />
{hasPendingChangeRequest ? 'Change requested' : 'Request a mentor change'}
</Button>
</div>
</section>
) : (
<Card className="bg-muted/50">
<CardContent className="flex flex-col items-center justify-center py-8">
<UserCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="text-muted-foreground text-center">
No mentor has been assigned to your project yet.
You&apos;ll be notified when a mentor is assigned.
</p>
</CardContent>
</Card>
)}
{/* Chat */}
{primaryMentor && (
<Card>
<CardHeader>
<CardTitle>Messages</CardTitle>
<CardDescription>
{assignments.length > 1
? 'Your conversation history with your mentor team'
: `Your conversation history with ${primaryMentor.name || 'your mentor'}`}
</CardDescription>
</CardHeader>
<CardContent>
<MentorChat
messages={mentorMessages || []}
currentUserId={session?.user?.id || ''}
onSendMessage={async (message) => {
await sendMessage.mutateAsync({ projectId: projectId!, message })
}}
isLoading={messagesLoading}
isSending={sendMessage.isPending}
/>
</CardContent>
</Card>
)}
{/* Files */}
{primaryAssignment?.id && projectId && (
<WorkspaceFilesPanel
projectId={projectId}
mentorAssignmentId={primaryAssignment.id}
asApplicant
/>
)}
{/* Request change dialog */}
{projectId && (
<RequestChangeDialog
projectId={projectId}
mentors={dialogMentors}
open={isChangeOpen}
onOpenChange={setIsChangeOpen}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,179 @@
'use client'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Loader2 } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
const REASON_MIN = 10
const REASON_MAX = 2000
const TARGET_ANY = '__any__'
type MentorOption = {
assignmentId: string
name: string
}
type RequestChangeDialogProps = {
projectId: string
mentors: MentorOption[]
open: boolean
onOpenChange: (open: boolean) => void
}
export function RequestChangeDialog({
projectId,
mentors,
open,
onOpenChange,
}: RequestChangeDialogProps) {
const [reason, setReason] = useState('')
const [target, setTarget] = useState<string>(TARGET_ANY)
const [touched, setTouched] = useState(false)
const utils = trpc.useUtils()
const requestChange = trpc.mentor.requestChange.useMutation({
onSuccess: async () => {
toast.success(
"Your request has been sent to the program admins. We'll review it and follow up.",
)
onOpenChange(false)
// Refresh dashboard so the disabled state for the button updates.
await utils.applicant.getMyDashboard.invalidate()
},
onError: (error) => {
toast.error(error.message || 'Could not send your request. Please try again.')
},
})
// Reset form when the dialog is closed.
useEffect(() => {
if (!open) {
setReason('')
setTarget(TARGET_ANY)
setTouched(false)
}
}, [open])
const trimmedReason = reason.trim()
const reasonTooShort = trimmedReason.length < REASON_MIN
const reasonTooLong = trimmedReason.length > REASON_MAX
const reasonInvalid = reasonTooShort || reasonTooLong
const showReasonError = touched && reasonInvalid
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
setTouched(true)
if (reasonInvalid) return
requestChange.mutate({
projectId,
targetAssignmentId: target === TARGET_ANY ? undefined : target,
reason: trimmedReason,
})
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Request a mentor change</DialogTitle>
<DialogDescription>
Share a few details so the program admins can follow up with you.
Your current mentor will not see this message.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{mentors.length > 0 && (
<div className="space-y-2">
<Label htmlFor="targetMentor">About a specific mentor</Label>
<Select value={target} onValueChange={setTarget}>
<SelectTrigger id="targetMentor">
<SelectValue placeholder="Any / general" />
</SelectTrigger>
<SelectContent>
<SelectItem value={TARGET_ANY}>Any / general</SelectItem>
{mentors.map((m) => (
<SelectItem key={m.assignmentId} value={m.assignmentId}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Optional. Use this if your request is about one of your co-mentors in particular.
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="reason">
Why would you like a change?
</Label>
<Textarea
id="reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
onBlur={() => setTouched(true)}
placeholder="Tell us why you'd like a change. The admin team will follow up."
rows={6}
maxLength={REASON_MAX}
aria-invalid={showReasonError || undefined}
required
/>
<div className="flex items-center justify-between text-xs">
{showReasonError ? (
<p className="text-destructive">
{reasonTooShort
? `Please provide at least ${REASON_MIN} characters.`
: `Please keep your message under ${REASON_MAX} characters.`}
</p>
) : (
<p className="text-muted-foreground">
{REASON_MIN}{REASON_MAX} characters.
</p>
)}
<p className="text-muted-foreground tabular-nums">
{trimmedReason.length}/{REASON_MAX}
</p>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={requestChange.isPending}
>
Cancel
</Button>
<Button type="submit" disabled={requestChange.isPending}>
{requestChange.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Send request
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -1319,9 +1319,10 @@ export const applicantRouter = router({
mentorAssignments: { mentorAssignments: {
include: { include: {
mentor: { mentor: {
select: { id: true, name: true, email: true }, select: { id: true, name: true, email: true, expertiseTags: true },
}, },
}, },
orderBy: { assignedAt: 'asc' },
}, },
wonAwards: { wonAwards: {
select: { id: true, name: true }, select: { id: true, name: true },
@@ -1492,6 +1493,17 @@ export const applicantRouter = router({
logoUrl = await provider.getDownloadUrl(project.logoKey) logoUrl = await provider.getDownloadUrl(project.logoKey)
} }
// Does this user have an open mentor-change request for this project?
// (Used by the applicant mentor page to disable the "Request a change" button.)
const myPendingChangeRequest = await ctx.prisma.mentorChangeRequest.findFirst({
where: {
projectId: project.id,
requestedByUserId: ctx.user.id,
status: 'PENDING',
},
select: { id: true },
})
return { return {
project: { project: {
...project, ...project,
@@ -1505,6 +1517,7 @@ export const applicantRouter = router({
hasPassedIntake: !!passedIntake, hasPassedIntake: !!passedIntake,
isIntakeOpen: !!activeIntakeRound, isIntakeOpen: !!activeIntakeRound,
logoUrl, logoUrl,
hasPendingMentorChangeRequest: !!myPendingChangeRequest,
} }
}), }),