merge: PR8 Task 7 — applicant mentor list + request-change dialog

This commit is contained in:
Matt
2026-05-22 17:12:58 +02:00
3 changed files with 423 additions and 152 deletions

View File

@@ -1,151 +1,230 @@
'use client'
import { useSession } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { MentorChat } from '@/components/shared/mentor-chat'
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
import {
MessageSquare,
UserCircle,
FileText,
} from 'lucide-react'
export default function ApplicantMentorPage() {
const { data: session, status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const { data: dashboardData, isLoading: dashLoading } = trpc.applicant.getMyDashboard.useQuery(
undefined,
{ enabled: isAuthenticated }
)
const projectId = dashboardData?.project?.id
const { data: mentorMessages, isLoading: messagesLoading } = trpc.applicant.getMentorMessages.useQuery(
{ projectId: projectId! },
{ enabled: !!projectId }
)
const utils = trpc.useUtils()
const sendMessage = trpc.applicant.sendMentorMessage.useMutation({
onSuccess: () => {
utils.applicant.getMentorMessages.invalidate({ projectId: projectId! })
},
})
if (dashLoading) {
return (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<Skeleton className="h-96 w-full" />
</div>
)
}
if (!projectId) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Mentor</h1>
</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" />
<h2 className="text-xl font-semibold mb-2">No Project</h2>
<p className="text-muted-foreground text-center">
Submit a project first to communicate with your mentor.
</p>
</CardContent>
</Card>
</div>
)
}
// TODO(PR8 Task 7): show ALL assigned mentors. For now we display only the
// first one until the multi-mentor applicant UI ships.
const primaryAssignment = dashboardData?.project?.mentorAssignments?.[0] ?? null
const mentor = primaryAssignment?.mentor
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<MessageSquare className="h-6 w-6" />
Mentor Communication
</h1>
<p className="text-muted-foreground">
Chat with your assigned mentor
</p>
</div>
{/* Mentor info */}
{mentor ? (
<Card className="bg-muted/50">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<UserCircle className="h-10 w-10 text-muted-foreground" />
<div>
<p className="font-medium">{mentor.name || 'Mentor'}</p>
<p className="text-sm text-muted-foreground">{mentor.email}</p>
</div>
</div>
</CardContent>
</Card>
) : (
<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 */}
{mentor && (
<Card>
<CardHeader>
<CardTitle>Messages</CardTitle>
<CardDescription>
Your conversation history with {mentor.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
/>
)}
</div>
)
}
'use client'
import { useState } from 'react'
import { useSession } from 'next-auth/react'
import { format } from 'date-fns'
import { trpc } from '@/lib/trpc/client'
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 { MentorChat } from '@/components/shared/mentor-chat'
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
import { RequestChangeDialog } from './request-change-dialog'
import {
MessageSquare,
UserCircle,
FileText,
UserCog,
} from 'lucide-react'
export default function ApplicantMentorPage() {
const { data: session, status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const { data: dashboardData, isLoading: dashLoading } = trpc.applicant.getMyDashboard.useQuery(
undefined,
{ enabled: isAuthenticated }
)
const projectId = dashboardData?.project?.id
const { data: mentorMessages, isLoading: messagesLoading } = trpc.applicant.getMentorMessages.useQuery(
{ projectId: projectId! },
{ enabled: !!projectId }
)
const utils = trpc.useUtils()
const sendMessage = trpc.applicant.sendMentorMessage.useMutation({
onSuccess: () => {
utils.applicant.getMentorMessages.invalidate({ projectId: projectId! })
},
})
const [isChangeOpen, setIsChangeOpen] = useState(false)
if (dashLoading) {
return (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<Skeleton className="h-96 w-full" />
</div>
)
}
if (!projectId) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Mentor</h1>
</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" />
<h2 className="text-xl font-semibold mb-2">No Project</h2>
<p className="text-muted-foreground text-center">
Submit a project first to communicate with your mentor.
</p>
</CardContent>
</Card>
</div>
)
}
const assignments = dashboardData?.project?.mentorAssignments ?? []
const hasMentors = assignments.length > 0
const primaryAssignment = assignments[0] ?? null
const primaryMentor = primaryAssignment?.mentor
const hasPendingChangeRequest = !!dashboardData?.hasPendingMentorChangeRequest
const dialogMentors = assignments
.filter((a) => !!a.mentor)
.map((a) => ({
assignmentId: a.id,
name: a.mentor?.name || a.mentor?.email || 'Mentor',
}))
const teamHeading = assignments.length > 1 ? 'Your mentor team' : 'Your mentor'
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<MessageSquare className="h-6 w-6" />
Mentor Communication
</h1>
<p className="text-muted-foreground">
{assignments.length > 1
? 'Chat with your assigned mentor team'
: 'Chat with your assigned mentor'}
</p>
</div>
{/* Mentor list */}
{hasMentors ? (
<section className="space-y-3">
<h2 className="text-lg font-semibold tracking-tight">{teamHeading}</h2>
<div className="grid gap-3 md:grid-cols-2">
{assignments.map((assignment) => {
const mentor = assignment.mentor
if (!mentor) return null
const expertise = mentor.expertiseTags ?? []
return (
<Card key={assignment.id} className="bg-muted/50">
<CardContent className="p-4 space-y-3">
<div className="flex items-start gap-3">
<UserCircle className="h-10 w-10 text-muted-foreground shrink-0" />
<div className="min-w-0 flex-1">
<p className="font-medium truncate">
{mentor.name || 'Mentor'}
</p>
<p className="text-sm text-muted-foreground truncate">
{mentor.email}
</p>
{assignment.assignedAt && (
<p className="text-xs text-muted-foreground mt-1">
Assigned since {format(new Date(assignment.assignedAt), 'MMM d, yyyy')}
</p>
)}
</div>
</div>
{expertise.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{expertise.map((tag) => (
<Badge key={tag} variant="secondary" className="font-normal">
{tag}
</Badge>
))}
</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: {
include: {
mentor: {
select: { id: true, name: true, email: true },
select: { id: true, name: true, email: true, expertiseTags: true },
},
},
orderBy: { assignedAt: 'asc' },
},
wonAwards: {
select: { id: true, name: true },
@@ -1492,6 +1493,17 @@ export const applicantRouter = router({
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 {
project: {
...project,
@@ -1505,6 +1517,7 @@ export const applicantRouter = router({
hasPassedIntake: !!passedIntake,
isIntakeOpen: !!activeIntakeRound,
logoUrl,
hasPendingMentorChangeRequest: !!myPendingChangeRequest,
}
}),