merge: PR8 Task 8 — admin multi-mentor UI + change-request inbox
This commit is contained in:
@@ -18,6 +18,8 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Table,
|
||||
@@ -27,15 +29,35 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Bot,
|
||||
Check,
|
||||
Inbox,
|
||||
Loader2,
|
||||
Search,
|
||||
Sparkles,
|
||||
Users,
|
||||
UserPlus,
|
||||
} from 'lucide-react'
|
||||
import { getInitials, formatEnumLabel } from '@/lib/utils'
|
||||
|
||||
@@ -48,14 +70,31 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
const utils = trpc.useUtils()
|
||||
const [search, setSearch] = useState('')
|
||||
const [pendingMentorId, setPendingMentorId] = useState<string | null>(null)
|
||||
const [unassignTarget, setUnassignTarget] = useState<{
|
||||
assignmentId: string
|
||||
mentorName: string
|
||||
} | null>(null)
|
||||
|
||||
const { data: project, isLoading: projectLoading } = trpc.project.get.useQuery({ id: projectId })
|
||||
|
||||
const { data: candidatesData, isLoading: candidatesLoading } =
|
||||
trpc.mentor.getCandidates.useQuery(
|
||||
{ projectId },
|
||||
{ enabled: !!project && !project.mentorAssignment },
|
||||
// Already-assigned mentors (full list). Project.get spreads the underlying
|
||||
// `mentorAssignments` relation so we can read it directly.
|
||||
const assignedMentorAssignments = useMemo(() => {
|
||||
if (!project) return []
|
||||
// The Prisma relation is included via `...project` spread; type comes
|
||||
// through the tRPC client.
|
||||
type Assignment = NonNullable<typeof project>['mentorAssignments'][number]
|
||||
return ((project as unknown as { mentorAssignments?: Assignment[] }).mentorAssignments ?? []).filter(
|
||||
(a) => !a.droppedAt,
|
||||
)
|
||||
}, [project])
|
||||
const assignedMentorIds = useMemo(
|
||||
() => new Set(assignedMentorAssignments.map((a) => a.mentorId)),
|
||||
[assignedMentorAssignments],
|
||||
)
|
||||
|
||||
const { data: candidatesData, isLoading: candidatesLoading } =
|
||||
trpc.mentor.getCandidates.useQuery({ projectId }, { enabled: !!project })
|
||||
|
||||
const {
|
||||
data: suggestionsData,
|
||||
@@ -63,12 +102,12 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
refetch: refetchSuggestions,
|
||||
} = trpc.mentor.getSuggestions.useQuery(
|
||||
{ projectId, limit: 5 },
|
||||
{ enabled: !!project && !project.mentorAssignment },
|
||||
{ enabled: !!project },
|
||||
)
|
||||
|
||||
const assignMutation = trpc.mentor.assign.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Mentor assigned')
|
||||
toast.success('Mentor added')
|
||||
utils.project.get.invalidate({ id: projectId })
|
||||
utils.mentor.getCandidates.invalidate({ projectId })
|
||||
utils.mentor.getSuggestions.invalidate({ projectId })
|
||||
@@ -86,21 +125,31 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
utils.project.get.invalidate({ id: projectId })
|
||||
utils.mentor.getCandidates.invalidate({ projectId })
|
||||
utils.mentor.getSuggestions.invalidate({ projectId })
|
||||
setUnassignTarget(null)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
setUnassignTarget(null)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const filteredCandidates = useMemo(() => {
|
||||
if (!candidatesData) return []
|
||||
const base = candidatesData.candidates.filter((c) => !assignedMentorIds.has(c.id))
|
||||
const q = search.trim().toLowerCase()
|
||||
if (!q) return candidatesData.candidates
|
||||
return candidatesData.candidates.filter((c) => {
|
||||
if (!q) return base
|
||||
return base.filter((c) => {
|
||||
const hay = [c.name ?? '', c.email, ...(c.expertiseTags ?? []), c.country ?? '']
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
return hay.includes(q)
|
||||
})
|
||||
}, [candidatesData, search])
|
||||
}, [candidatesData, search, assignedMentorIds])
|
||||
|
||||
const filteredSuggestions = useMemo(() => {
|
||||
if (!suggestionsData) return []
|
||||
return suggestionsData.suggestions.filter((s) => !assignedMentorIds.has(s.mentorId))
|
||||
}, [suggestionsData, assignedMentorIds])
|
||||
|
||||
if (projectLoading) return <MentorAssignmentSkeleton />
|
||||
if (!project) {
|
||||
@@ -113,7 +162,6 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
const hasMentor = !!project.mentorAssignment
|
||||
const teamSize = project.teamMembers?.length ?? 0
|
||||
const aiSource = suggestionsData?.source ?? 'ai'
|
||||
|
||||
@@ -206,80 +254,112 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ─── Pending Change Requests ─── */}
|
||||
<PendingChangeRequestsPanel projectId={projectId} />
|
||||
|
||||
{/* ─── Currently Assigned ─── */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Currently Assigned</CardTitle>
|
||||
<CardDescription>
|
||||
{assignedMentorAssignments.length === 0
|
||||
? 'No mentors assigned yet'
|
||||
: `${assignedMentorAssignments.length} mentor${
|
||||
assignedMentorAssignments.length === 1 ? '' : 's'
|
||||
} on this team`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{hasMentor ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{assignedMentorAssignments.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed py-8 text-center">
|
||||
<Users className="text-muted-foreground mx-auto mb-2 h-8 w-8" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No mentors assigned yet — add one below.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{assignedMentorAssignments.map((a) => {
|
||||
const m = a.mentor
|
||||
const tags = m.expertiseTags ?? []
|
||||
return (
|
||||
<li
|
||||
key={a.id}
|
||||
className="flex items-start justify-between gap-4 py-4 first:pt-0 last:pb-0"
|
||||
>
|
||||
<div className="flex flex-1 items-start gap-4">
|
||||
<Avatar className="h-12 w-12">
|
||||
<AvatarFallback>
|
||||
{getInitials(
|
||||
project.mentorAssignment!.mentor.name ||
|
||||
project.mentorAssignment!.mentor.email,
|
||||
)}
|
||||
{getInitials(m.name || m.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
href={`/admin/mentors/${project.mentorAssignment!.mentor.id}`}
|
||||
href={`/admin/mentors/${m.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{project.mentorAssignment!.mentor.name || 'Unnamed'}
|
||||
{m.name || 'Unnamed'}
|
||||
</Link>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{project.mentorAssignment!.mentor.email}
|
||||
</p>
|
||||
{project.mentorAssignment!.mentor.expertiseTags &&
|
||||
project.mentorAssignment!.mentor.expertiseTags.length > 0 && (
|
||||
<p className="text-muted-foreground text-sm">{m.email}</p>
|
||||
{tags.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{project.mentorAssignment!.mentor.expertiseTags
|
||||
.slice(0, 5)
|
||||
.map((tag: string) => (
|
||||
{tags.slice(0, 5).map((tag: string) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{tags.length > 5 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{tags.length - 5}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
Assigned{' '}
|
||||
{new Date(a.assignedAt).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{project.mentorAssignment!.method.replace(/_/g, ' ')}
|
||||
{a.method.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="destructive"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => unassignMutation.mutate({ projectId })}
|
||||
onClick={() =>
|
||||
setUnassignTarget({
|
||||
assignmentId: a.id,
|
||||
mentorName: m.name || m.email,
|
||||
})
|
||||
}
|
||||
disabled={unassignMutation.isPending}
|
||||
>
|
||||
{unassignMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
'Unassign'
|
||||
)}
|
||||
Unassign
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No mentor assigned yet — pick one below.
|
||||
</p>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ─── Pick a Mentor ─── */}
|
||||
{!hasMentor && (
|
||||
{/* ─── Add a Mentor ─── */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Pick a Mentor</CardTitle>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<UserPlus className="h-5 w-5" />
|
||||
Add a Mentor
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Browse all eligible mentors or use AI to surface the best fits.
|
||||
Stack additional mentors on this team. Browse all eligible mentors or use AI to surface the best fits.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -311,7 +391,9 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
</div>
|
||||
) : filteredCandidates.length === 0 ? (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||
No matching mentors. Try a different search.
|
||||
{assignedMentorIds.size > 0 && search.trim() === ''
|
||||
? 'All eligible mentors are already assigned.'
|
||||
: 'No matching mentors. Try a different search.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
@@ -376,7 +458,7 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-1 h-3.5 w-3.5" /> Assign
|
||||
<Check className="mr-1 h-3.5 w-3.5" /> Add
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -422,13 +504,15 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
<Skeleton key={i} className="h-24 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : !suggestionsData || suggestionsData.suggestions.length === 0 ? (
|
||||
) : filteredSuggestions.length === 0 ? (
|
||||
<p className="text-muted-foreground py-8 text-center text-sm">
|
||||
No suggestions available.
|
||||
{assignedMentorIds.size > 0
|
||||
? 'All top suggestions are already assigned.'
|
||||
: 'No suggestions available.'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{suggestionsData.suggestions.map((s, i) => (
|
||||
{filteredSuggestions.map((s, i) => (
|
||||
<div
|
||||
key={s.mentorId}
|
||||
className="flex items-start justify-between rounded-md border p-4"
|
||||
@@ -503,7 +587,7 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-1 h-3.5 w-3.5" /> Assign
|
||||
<Check className="mr-1 h-3.5 w-3.5" /> Add
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -515,8 +599,284 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ─── Unassign confirm ─── */}
|
||||
<AlertDialog
|
||||
open={!!unassignTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setUnassignTarget(null)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Unassign mentor?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{unassignTarget
|
||||
? `Remove ${unassignTarget.mentorName} from this team? Other co-mentors will remain.`
|
||||
: ''}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={unassignMutation.isPending}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
if (!unassignTarget) return
|
||||
unassignMutation.mutate({ assignmentId: unassignTarget.assignmentId })
|
||||
}}
|
||||
disabled={unassignMutation.isPending}
|
||||
>
|
||||
{unassignMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
'Unassign'
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Pending Change Requests panel
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function PendingChangeRequestsPanel({ projectId }: { projectId: string }) {
|
||||
const utils = trpc.useUtils()
|
||||
const { data: requests, isLoading } = trpc.mentor.listChangeRequests.useQuery({
|
||||
projectId,
|
||||
status: 'PENDING',
|
||||
})
|
||||
|
||||
const [resolveTarget, setResolveTarget] = useState<{
|
||||
id: string
|
||||
status: 'RESOLVED' | 'DISMISSED'
|
||||
requesterName: string
|
||||
} | null>(null)
|
||||
const [resolutionNote, setResolutionNote] = useState('')
|
||||
|
||||
const resolveMutation = trpc.mentor.resolveChangeRequest.useMutation({
|
||||
onSuccess: (_, variables) => {
|
||||
toast.success(
|
||||
`Request marked ${variables.status === 'RESOLVED' ? 'resolved' : 'dismissed'}`,
|
||||
)
|
||||
utils.mentor.listChangeRequests.invalidate()
|
||||
setResolveTarget(null)
|
||||
setResolutionNote('')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Inbox className="h-5 w-5" />
|
||||
Pending change requests
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (!requests || requests.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="border-amber-300 dark:border-amber-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Inbox className="h-5 w-5 text-amber-600" />
|
||||
Pending change requests
|
||||
<Badge variant="secondary" className="ml-1">
|
||||
{requests.length}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Team members or mentors have asked admin to change a mentor on this team.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-3">
|
||||
{requests.map((r) => (
|
||||
<ChangeRequestRow
|
||||
key={r.id}
|
||||
request={r}
|
||||
onResolve={(status) =>
|
||||
setResolveTarget({
|
||||
id: r.id,
|
||||
status,
|
||||
requesterName:
|
||||
r.requestedBy?.name ?? r.requestedBy?.email ?? 'Unknown',
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog
|
||||
open={!!resolveTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setResolveTarget(null)
|
||||
setResolutionNote('')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{resolveTarget?.status === 'RESOLVED'
|
||||
? 'Mark request resolved'
|
||||
: 'Dismiss request'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{resolveTarget?.status === 'RESOLVED'
|
||||
? `You've taken action on the request from ${resolveTarget?.requesterName}. Optionally add a note explaining what was done.`
|
||||
: `Close the request from ${resolveTarget?.requesterName} without action. Optionally add a note explaining why.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="resolution-note">Resolution note (optional)</Label>
|
||||
<Textarea
|
||||
id="resolution-note"
|
||||
value={resolutionNote}
|
||||
onChange={(e) => setResolutionNote(e.target.value)}
|
||||
placeholder="e.g. Replaced Jane with John based on expertise mismatch."
|
||||
rows={4}
|
||||
maxLength={2000}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setResolveTarget(null)
|
||||
setResolutionNote('')
|
||||
}}
|
||||
disabled={resolveMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!resolveTarget) return
|
||||
resolveMutation.mutate({
|
||||
id: resolveTarget.id,
|
||||
status: resolveTarget.status,
|
||||
resolutionNote: resolutionNote.trim() || undefined,
|
||||
})
|
||||
}}
|
||||
disabled={resolveMutation.isPending}
|
||||
>
|
||||
{resolveMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : resolveTarget?.status === 'RESOLVED' ? (
|
||||
'Mark Resolved'
|
||||
) : (
|
||||
'Dismiss'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type ChangeRequestRowProps = {
|
||||
request: {
|
||||
id: string
|
||||
reason: string
|
||||
createdAt: Date
|
||||
requestedBy: { id: string; name: string | null; email: string } | null
|
||||
targetAssignment: {
|
||||
id: string
|
||||
mentor: { id: string; name: string | null; email: string }
|
||||
} | null
|
||||
}
|
||||
onResolve: (status: 'RESOLVED' | 'DISMISSED') => void
|
||||
}
|
||||
|
||||
function ChangeRequestRow({ request, onResolve }: ChangeRequestRowProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const reasonIsLong = request.reason.length > 240
|
||||
return (
|
||||
<li className="rounded-md border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
||||
<span className="font-medium">
|
||||
{request.requestedBy?.name ?? request.requestedBy?.email ?? 'Unknown'}
|
||||
</span>
|
||||
{request.requestedBy?.email && request.requestedBy.name && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{request.requestedBy.email}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground text-xs">
|
||||
·{' '}
|
||||
{new Date(request.createdAt).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{request.targetAssignment && (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
About:{' '}
|
||||
<span className="font-medium">
|
||||
{request.targetAssignment.mentor.name ||
|
||||
request.targetAssignment.mentor.email}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<p
|
||||
className={
|
||||
expanded || !reasonIsLong
|
||||
? 'text-sm whitespace-pre-wrap'
|
||||
: 'text-sm whitespace-pre-wrap line-clamp-4'
|
||||
}
|
||||
>
|
||||
{request.reason}
|
||||
</p>
|
||||
{reasonIsLong && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary text-xs hover:underline"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{expanded ? 'Show less' : 'Show more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col gap-2">
|
||||
<Button size="sm" onClick={() => onResolve('RESOLVED')}>
|
||||
Mark Resolved
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onResolve('DISMISSED')}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ArrowRight,
|
||||
Clock,
|
||||
FileText,
|
||||
Inbox,
|
||||
MessageCircle,
|
||||
Target,
|
||||
UserCheck,
|
||||
@@ -48,6 +49,10 @@ export function MentoringRoundOverview({ roundId }: Props) {
|
||||
{ refetchInterval: 30_000 },
|
||||
)
|
||||
const { data: pool, isLoading: poolLoading } = trpc.mentor.getMentorPool.useQuery({})
|
||||
const { data: pendingChangeRequests } = trpc.mentor.listChangeRequests.useQuery(
|
||||
{ status: 'PENDING' },
|
||||
{ refetchInterval: 30_000 },
|
||||
)
|
||||
|
||||
if (statsLoading || poolLoading) {
|
||||
return (
|
||||
@@ -60,6 +65,15 @@ export function MentoringRoundOverview({ roundId }: Props) {
|
||||
}
|
||||
if (!stats || !pool) return null
|
||||
|
||||
const pendingCount = pendingChangeRequests?.length ?? 0
|
||||
// If there's at least one pending request, deep-link directly into the
|
||||
// first one's project (admins can resolve / view siblings from there).
|
||||
// Otherwise the card stays static.
|
||||
const firstPendingProjectId = pendingChangeRequests?.[0]?.project.id ?? null
|
||||
const changeRequestsHref = firstPendingProjectId
|
||||
? `/admin/projects/${firstPendingProjectId}/mentor`
|
||||
: null
|
||||
|
||||
const requestedPct = stats.totalProjects
|
||||
? Math.round((stats.requestedCount / stats.totalProjects) * 100)
|
||||
: 0
|
||||
@@ -173,6 +187,42 @@ export function MentoringRoundOverview({ roundId }: Props) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className={`md:col-span-2 xl:col-span-4 ${
|
||||
pendingCount > 0 ? 'border-amber-300 dark:border-amber-700' : ''
|
||||
}`}
|
||||
>
|
||||
<CardContent className="flex items-center justify-between py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Inbox
|
||||
className={`h-5 w-5 ${
|
||||
pendingCount > 0 ? 'text-amber-600' : 'text-muted-foreground'
|
||||
}`}
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium">Pending change requests</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Team members asking admin to swap a mentor
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-2xl font-bold tabular-nums">{pendingCount}</div>
|
||||
{changeRequestsHref ? (
|
||||
<Link
|
||||
href={changeRequestsHref}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center text-xs"
|
||||
>
|
||||
Review
|
||||
<ArrowRight className="ml-0.5 h-3 w-3" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">All clear</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-2 xl:col-span-4">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Workspace activity</CardTitle>
|
||||
|
||||
@@ -565,16 +565,34 @@ export const mentorRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* Remove mentor assignment
|
||||
* Remove mentor assignment.
|
||||
*
|
||||
* Multi-mentor (PR8): callers should pass `assignmentId` to target a
|
||||
* specific co-mentor. Legacy callers passing only `projectId` get the
|
||||
* most-recent assignment removed (kept for backward compatibility).
|
||||
*/
|
||||
unassign: adminProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
assignmentId: z.string().optional(),
|
||||
projectId: z.string().optional(),
|
||||
})
|
||||
.refine((v) => !!v.assignmentId || !!v.projectId, {
|
||||
message: 'Either assignmentId or projectId is required',
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// TODO(PR8 Task 8): admin UI should specify which mentor to drop when
|
||||
// multiple are assigned. Legacy callers pass only projectId — we resolve
|
||||
// to the most-recent assignment for backward compatibility.
|
||||
const assignment = await ctx.prisma.mentorAssignment.findFirst({
|
||||
where: { projectId: input.projectId },
|
||||
const assignment = input.assignmentId
|
||||
? await ctx.prisma.mentorAssignment.findUnique({
|
||||
where: { id: input.assignmentId },
|
||||
include: {
|
||||
mentor: { select: { id: true, name: true } },
|
||||
project: { select: { id: true, title: true } },
|
||||
},
|
||||
})
|
||||
: await ctx.prisma.mentorAssignment.findFirst({
|
||||
where: { projectId: input.projectId! },
|
||||
orderBy: { assignedAt: 'desc' },
|
||||
include: {
|
||||
mentor: { select: { id: true, name: true } },
|
||||
@@ -585,7 +603,7 @@ export const mentorRouter = router({
|
||||
if (!assignment) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'No mentor assignment found for this project',
|
||||
message: 'No mentor assignment found',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -602,7 +620,7 @@ export const mentorRouter = router({
|
||||
entityType: 'MentorAssignment',
|
||||
entityId: assignment.id,
|
||||
detailsJson: {
|
||||
projectId: input.projectId,
|
||||
projectId: assignment.project.id,
|
||||
projectTitle: assignment.project.title,
|
||||
mentorId: assignment.mentor.id,
|
||||
mentorName: assignment.mentor.name,
|
||||
|
||||
Reference in New Issue
Block a user