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