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 { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import { Input } from '@/components/ui/input'
|
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -27,15 +29,35 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} 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 {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Bot,
|
Bot,
|
||||||
Check,
|
Check,
|
||||||
|
Inbox,
|
||||||
Loader2,
|
Loader2,
|
||||||
Search,
|
Search,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Users,
|
Users,
|
||||||
|
UserPlus,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { getInitials, formatEnumLabel } from '@/lib/utils'
|
import { getInitials, formatEnumLabel } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -48,14 +70,31 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
|||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [pendingMentorId, setPendingMentorId] = useState<string | null>(null)
|
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: project, isLoading: projectLoading } = trpc.project.get.useQuery({ id: projectId })
|
||||||
|
|
||||||
const { data: candidatesData, isLoading: candidatesLoading } =
|
// Already-assigned mentors (full list). Project.get spreads the underlying
|
||||||
trpc.mentor.getCandidates.useQuery(
|
// `mentorAssignments` relation so we can read it directly.
|
||||||
{ projectId },
|
const assignedMentorAssignments = useMemo(() => {
|
||||||
{ enabled: !!project && !project.mentorAssignment },
|
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 {
|
const {
|
||||||
data: suggestionsData,
|
data: suggestionsData,
|
||||||
@@ -63,12 +102,12 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
|||||||
refetch: refetchSuggestions,
|
refetch: refetchSuggestions,
|
||||||
} = trpc.mentor.getSuggestions.useQuery(
|
} = trpc.mentor.getSuggestions.useQuery(
|
||||||
{ projectId, limit: 5 },
|
{ projectId, limit: 5 },
|
||||||
{ enabled: !!project && !project.mentorAssignment },
|
{ enabled: !!project },
|
||||||
)
|
)
|
||||||
|
|
||||||
const assignMutation = trpc.mentor.assign.useMutation({
|
const assignMutation = trpc.mentor.assign.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Mentor assigned')
|
toast.success('Mentor added')
|
||||||
utils.project.get.invalidate({ id: projectId })
|
utils.project.get.invalidate({ id: projectId })
|
||||||
utils.mentor.getCandidates.invalidate({ projectId })
|
utils.mentor.getCandidates.invalidate({ projectId })
|
||||||
utils.mentor.getSuggestions.invalidate({ projectId })
|
utils.mentor.getSuggestions.invalidate({ projectId })
|
||||||
@@ -86,21 +125,31 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
|||||||
utils.project.get.invalidate({ id: projectId })
|
utils.project.get.invalidate({ id: projectId })
|
||||||
utils.mentor.getCandidates.invalidate({ projectId })
|
utils.mentor.getCandidates.invalidate({ projectId })
|
||||||
utils.mentor.getSuggestions.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(() => {
|
const filteredCandidates = useMemo(() => {
|
||||||
if (!candidatesData) return []
|
if (!candidatesData) return []
|
||||||
|
const base = candidatesData.candidates.filter((c) => !assignedMentorIds.has(c.id))
|
||||||
const q = search.trim().toLowerCase()
|
const q = search.trim().toLowerCase()
|
||||||
if (!q) return candidatesData.candidates
|
if (!q) return base
|
||||||
return candidatesData.candidates.filter((c) => {
|
return base.filter((c) => {
|
||||||
const hay = [c.name ?? '', c.email, ...(c.expertiseTags ?? []), c.country ?? '']
|
const hay = [c.name ?? '', c.email, ...(c.expertiseTags ?? []), c.country ?? '']
|
||||||
.join(' ')
|
.join(' ')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
return hay.includes(q)
|
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 (projectLoading) return <MentorAssignmentSkeleton />
|
||||||
if (!project) {
|
if (!project) {
|
||||||
@@ -113,7 +162,6 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasMentor = !!project.mentorAssignment
|
|
||||||
const teamSize = project.teamMembers?.length ?? 0
|
const teamSize = project.teamMembers?.length ?? 0
|
||||||
const aiSource = suggestionsData?.source ?? 'ai'
|
const aiSource = suggestionsData?.source ?? 'ai'
|
||||||
|
|
||||||
@@ -206,80 +254,112 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* ─── Pending Change Requests ─── */}
|
||||||
|
<PendingChangeRequestsPanel projectId={projectId} />
|
||||||
|
|
||||||
{/* ─── Currently Assigned ─── */}
|
{/* ─── Currently Assigned ─── */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">Currently Assigned</CardTitle>
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{hasMentor ? (
|
{assignedMentorAssignments.length === 0 ? (
|
||||||
<div className="flex items-center justify-between">
|
<div className="rounded-md border border-dashed py-8 text-center">
|
||||||
<div className="flex items-center gap-4">
|
<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">
|
<Avatar className="h-12 w-12">
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
{getInitials(
|
{getInitials(m.name || m.email)}
|
||||||
project.mentorAssignment!.mentor.name ||
|
|
||||||
project.mentorAssignment!.mentor.email,
|
|
||||||
)}
|
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div>
|
<div className="min-w-0 flex-1">
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/mentors/${project.mentorAssignment!.mentor.id}`}
|
href={`/admin/mentors/${m.id}`}
|
||||||
className="font-medium hover:underline"
|
className="font-medium hover:underline"
|
||||||
>
|
>
|
||||||
{project.mentorAssignment!.mentor.name || 'Unnamed'}
|
{m.name || 'Unnamed'}
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">{m.email}</p>
|
||||||
{project.mentorAssignment!.mentor.email}
|
{tags.length > 0 && (
|
||||||
</p>
|
|
||||||
{project.mentorAssignment!.mentor.expertiseTags &&
|
|
||||||
project.mentorAssignment!.mentor.expertiseTags.length > 0 && (
|
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
{project.mentorAssignment!.mentor.expertiseTags
|
{tags.slice(0, 5).map((tag: string) => (
|
||||||
.slice(0, 5)
|
|
||||||
.map((tag: string) => (
|
|
||||||
<Badge key={tag} variant="secondary" className="text-xs">
|
<Badge key={tag} variant="secondary" className="text-xs">
|
||||||
{tag}
|
{tag}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
|
{tags.length > 5 && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
+{tags.length - 5}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-2">
|
<div className="flex flex-col items-end gap-2">
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{project.mentorAssignment!.method.replace(/_/g, ' ')}
|
{a.method.replace(/_/g, ' ')}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => unassignMutation.mutate({ projectId })}
|
onClick={() =>
|
||||||
|
setUnassignTarget({
|
||||||
|
assignmentId: a.id,
|
||||||
|
mentorName: m.name || m.email,
|
||||||
|
})
|
||||||
|
}
|
||||||
disabled={unassignMutation.isPending}
|
disabled={unassignMutation.isPending}
|
||||||
>
|
>
|
||||||
{unassignMutation.isPending ? (
|
Unassign
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
'Unassign'
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</li>
|
||||||
) : (
|
)
|
||||||
<p className="text-muted-foreground text-sm">
|
})}
|
||||||
No mentor assigned yet — pick one below.
|
</ul>
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* ─── Pick a Mentor ─── */}
|
{/* ─── Add a Mentor ─── */}
|
||||||
{!hasMentor && (
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<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>
|
<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>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -311,7 +391,9 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
) : filteredCandidates.length === 0 ? (
|
) : filteredCandidates.length === 0 ? (
|
||||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-hidden rounded-md border">
|
<div className="overflow-hidden rounded-md border">
|
||||||
@@ -376,7 +458,7 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
|||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<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>
|
</Button>
|
||||||
@@ -422,13 +504,15 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
|||||||
<Skeleton key={i} className="h-24 w-full" />
|
<Skeleton key={i} className="h-24 w-full" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : !suggestionsData || suggestionsData.suggestions.length === 0 ? (
|
) : filteredSuggestions.length === 0 ? (
|
||||||
<p className="text-muted-foreground py-8 text-center text-sm">
|
<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>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{suggestionsData.suggestions.map((s, i) => (
|
{filteredSuggestions.map((s, i) => (
|
||||||
<div
|
<div
|
||||||
key={s.mentorId}
|
key={s.mentorId}
|
||||||
className="flex items-start justify-between rounded-md border p-4"
|
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" />
|
<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>
|
</Button>
|
||||||
@@ -515,8 +599,284 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
||||||
|
<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,
|
ArrowRight,
|
||||||
Clock,
|
Clock,
|
||||||
FileText,
|
FileText,
|
||||||
|
Inbox,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
Target,
|
Target,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
@@ -48,6 +49,10 @@ export function MentoringRoundOverview({ roundId }: Props) {
|
|||||||
{ refetchInterval: 30_000 },
|
{ refetchInterval: 30_000 },
|
||||||
)
|
)
|
||||||
const { data: pool, isLoading: poolLoading } = trpc.mentor.getMentorPool.useQuery({})
|
const { data: pool, isLoading: poolLoading } = trpc.mentor.getMentorPool.useQuery({})
|
||||||
|
const { data: pendingChangeRequests } = trpc.mentor.listChangeRequests.useQuery(
|
||||||
|
{ status: 'PENDING' },
|
||||||
|
{ refetchInterval: 30_000 },
|
||||||
|
)
|
||||||
|
|
||||||
if (statsLoading || poolLoading) {
|
if (statsLoading || poolLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -60,6 +65,15 @@ export function MentoringRoundOverview({ roundId }: Props) {
|
|||||||
}
|
}
|
||||||
if (!stats || !pool) return null
|
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
|
const requestedPct = stats.totalProjects
|
||||||
? Math.round((stats.requestedCount / stats.totalProjects) * 100)
|
? Math.round((stats.requestedCount / stats.totalProjects) * 100)
|
||||||
: 0
|
: 0
|
||||||
@@ -173,6 +187,42 @@ export function MentoringRoundOverview({ roundId }: Props) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<Card className="md:col-span-2 xl:col-span-4">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm">Workspace activity</CardTitle>
|
<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
|
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 }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
// TODO(PR8 Task 8): admin UI should specify which mentor to drop when
|
const assignment = input.assignmentId
|
||||||
// multiple are assigned. Legacy callers pass only projectId — we resolve
|
? await ctx.prisma.mentorAssignment.findUnique({
|
||||||
// to the most-recent assignment for backward compatibility.
|
where: { id: input.assignmentId },
|
||||||
const assignment = await ctx.prisma.mentorAssignment.findFirst({
|
include: {
|
||||||
where: { projectId: input.projectId },
|
mentor: { select: { id: true, name: true } },
|
||||||
|
project: { select: { id: true, title: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: await ctx.prisma.mentorAssignment.findFirst({
|
||||||
|
where: { projectId: input.projectId! },
|
||||||
orderBy: { assignedAt: 'desc' },
|
orderBy: { assignedAt: 'desc' },
|
||||||
include: {
|
include: {
|
||||||
mentor: { select: { id: true, name: true } },
|
mentor: { select: { id: true, name: true } },
|
||||||
@@ -585,7 +603,7 @@ export const mentorRouter = router({
|
|||||||
if (!assignment) {
|
if (!assignment) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'NOT_FOUND',
|
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',
|
entityType: 'MentorAssignment',
|
||||||
entityId: assignment.id,
|
entityId: assignment.id,
|
||||||
detailsJson: {
|
detailsJson: {
|
||||||
projectId: input.projectId,
|
projectId: assignment.project.id,
|
||||||
projectTitle: assignment.project.title,
|
projectTitle: assignment.project.title,
|
||||||
mentorId: assignment.mentor.id,
|
mentorId: assignment.mentor.id,
|
||||||
mentorName: assignment.mentor.name,
|
mentorName: assignment.mentor.name,
|
||||||
|
|||||||
Reference in New Issue
Block a user