feat: mentor self-drop dialog on project detail page

DropAssignmentDialog with required reason (10-1000 chars) calls
mentor.dropAssignment, redirects to /mentor on success. Button surfaces
in the project header only when the viewer is the assigned mentor and
the assignment is neither dropped nor completed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-28 18:48:09 +02:00
parent 3bc1cc14c7
commit 3d8aab46f1
2 changed files with 128 additions and 1 deletions

View File

@@ -2,6 +2,7 @@
import { Suspense, use, useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client'
import {
Card,
@@ -31,6 +32,7 @@ import { AnimatedCard } from '@/components/shared/animated-container'
import { FileViewer } from '@/components/shared/file-viewer'
import { MentorChat } from '@/components/shared/mentor-chat'
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
import { DropAssignmentDialog } from '@/components/mentor/drop-assignment-dialog'
import {
ArrowLeft,
AlertCircle,
@@ -76,6 +78,7 @@ const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'ou
function ProjectDetailContent({ projectId }: { projectId: string }) {
const router = useRouter()
const { data: session } = useSession()
const { data: project, isLoading, error } = trpc.mentor.getProjectDetail.useQuery({
projectId,
})
@@ -132,8 +135,15 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
const teamLead = project.teamMembers?.find((m) => m.role === 'LEAD')
const otherMembers = project.teamMembers?.filter((m) => m.role !== 'LEAD') || []
const mentorAssignmentId = project.mentorAssignment?.id
const mentorAssignment = project.mentorAssignment
const mentorAssignmentId = mentorAssignment?.id
const programId = project.program?.id
const viewerIsAssignedMentor =
!!mentorAssignment && session?.user?.id === mentorAssignment.mentor?.id
const canDrop =
viewerIsAssignedMentor &&
!mentorAssignment.droppedAt &&
mentorAssignment.completionStatus !== 'completed'
return (
<div className="space-y-6">
@@ -179,6 +189,12 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
)}
</div>
</div>
{canDrop && mentorAssignmentId && (
<DropAssignmentDialog
assignmentId={mentorAssignmentId}
projectTitle={project.title}
/>
)}
</div>
{project.assignedAt && (

View File

@@ -0,0 +1,111 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Loader2, UserMinus } from 'lucide-react'
import { toast } from 'sonner'
const MIN_REASON = 10
const MAX_REASON = 1000
export function DropAssignmentDialog({
assignmentId,
projectTitle,
}: {
assignmentId: string
projectTitle: string
}) {
const router = useRouter()
const [open, setOpen] = useState(false)
const [reason, setReason] = useState('')
const drop = trpc.mentor.dropAssignment.useMutation({
onSuccess: () => {
toast.success('You have dropped this assignment.')
setOpen(false)
router.push('/mentor')
router.refresh()
},
onError: (e) => {
toast.error(e.message)
},
})
const trimmed = reason.trim()
const tooShort = trimmed.length < MIN_REASON
const tooLong = trimmed.length > MAX_REASON
return (
<AlertDialog
open={open}
onOpenChange={(next) => {
if (!drop.isPending) setOpen(next)
if (!next) setReason('')
}}
>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" className="text-destructive hover:text-destructive">
<UserMinus className="mr-2 h-4 w-4" />
Drop this team
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Drop {projectTitle}?</AlertDialogTitle>
<AlertDialogDescription>
The team will be unassigned from you and an admin will be notified so they can find a
replacement mentor. Please share why so we can adjust future assignments.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-2">
<Label htmlFor="drop-reason">Reason</Label>
<Textarea
id="drop-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="e.g. schedule conflict, expertise mismatch, conflict of interest..."
rows={4}
disabled={drop.isPending}
className="resize-none"
/>
<p
className={`text-xs ${tooShort || tooLong ? 'text-destructive' : 'text-muted-foreground'}`}
>
{trimmed.length}/{MAX_REASON} characters · minimum {MIN_REASON}
</p>
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={drop.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={tooShort || tooLong || drop.isPending}
onClick={(e) => {
e.preventDefault()
drop.mutate({ assignmentId, reason: trimmed })
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{drop.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Drop assignment
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}