merge: PR8 Task 7 — applicant mentor list + request-change dialog
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
|
import { format } from 'date-fns'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -9,13 +11,17 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { MentorChat } from '@/components/shared/mentor-chat'
|
import { MentorChat } from '@/components/shared/mentor-chat'
|
||||||
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
|
import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel'
|
||||||
|
import { RequestChangeDialog } from './request-change-dialog'
|
||||||
import {
|
import {
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
UserCircle,
|
UserCircle,
|
||||||
FileText,
|
FileText,
|
||||||
|
UserCog,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
export default function ApplicantMentorPage() {
|
export default function ApplicantMentorPage() {
|
||||||
@@ -41,6 +47,8 @@ export default function ApplicantMentorPage() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [isChangeOpen, setIsChangeOpen] = useState(false)
|
||||||
|
|
||||||
if (dashLoading) {
|
if (dashLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -72,10 +80,20 @@ export default function ApplicantMentorPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(PR8 Task 7): show ALL assigned mentors. For now we display only the
|
const assignments = dashboardData?.project?.mentorAssignments ?? []
|
||||||
// first one until the multi-mentor applicant UI ships.
|
const hasMentors = assignments.length > 0
|
||||||
const primaryAssignment = dashboardData?.project?.mentorAssignments?.[0] ?? null
|
const primaryAssignment = assignments[0] ?? null
|
||||||
const mentor = primaryAssignment?.mentor
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -86,23 +104,72 @@ export default function ApplicantMentorPage() {
|
|||||||
Mentor Communication
|
Mentor Communication
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Chat with your assigned mentor
|
{assignments.length > 1
|
||||||
|
? 'Chat with your assigned mentor team'
|
||||||
|
: 'Chat with your assigned mentor'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mentor info */}
|
{/* Mentor list */}
|
||||||
{mentor ? (
|
{hasMentors ? (
|
||||||
<Card className="bg-muted/50">
|
<section className="space-y-3">
|
||||||
<CardContent className="p-4">
|
<h2 className="text-lg font-semibold tracking-tight">{teamHeading}</h2>
|
||||||
<div className="flex items-center gap-3">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
<UserCircle className="h-10 w-10 text-muted-foreground" />
|
{assignments.map((assignment) => {
|
||||||
<div>
|
const mentor = assignment.mentor
|
||||||
<p className="font-medium">{mentor.name || 'Mentor'}</p>
|
if (!mentor) return null
|
||||||
<p className="text-sm text-muted-foreground">{mentor.email}</p>
|
const expertise = mentor.expertiseTags ?? []
|
||||||
</div>
|
return (
|
||||||
</div>
|
<Card key={assignment.id} className="bg-muted/50">
|
||||||
</CardContent>
|
<CardContent className="p-4 space-y-3">
|
||||||
</Card>
|
<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">
|
<Card className="bg-muted/50">
|
||||||
<CardContent className="flex flex-col items-center justify-center py-8">
|
<CardContent className="flex flex-col items-center justify-center py-8">
|
||||||
@@ -116,12 +183,14 @@ export default function ApplicantMentorPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Chat */}
|
{/* Chat */}
|
||||||
{mentor && (
|
{primaryMentor && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Messages</CardTitle>
|
<CardTitle>Messages</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Your conversation history with {mentor.name || 'your mentor'}
|
{assignments.length > 1
|
||||||
|
? 'Your conversation history with your mentor team'
|
||||||
|
: `Your conversation history with ${primaryMentor.name || 'your mentor'}`}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -146,6 +215,16 @@ export default function ApplicantMentorPage() {
|
|||||||
asApplicant
|
asApplicant
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Request change dialog */}
|
||||||
|
{projectId && (
|
||||||
|
<RequestChangeDialog
|
||||||
|
projectId={projectId}
|
||||||
|
mentors={dialogMentors}
|
||||||
|
open={isChangeOpen}
|
||||||
|
onOpenChange={setIsChangeOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
179
src/app/(applicant)/applicant/mentor/request-change-dialog.tsx
Normal file
179
src/app/(applicant)/applicant/mentor/request-change-dialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user