feat: admin UX improvements — notify buttons, eval config, round finalization
Custom body support for advancement/rejection notification emails, evaluation config toggle fix, user actions improvements, round finalization with reorder support, project detail page enhancements, award pool duplicate prevention. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,29 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { FileViewer } from '@/components/shared/file-viewer'
|
||||
import { FileUpload } from '@/components/shared/file-upload'
|
||||
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
|
||||
@@ -37,7 +60,6 @@ import {
|
||||
Users,
|
||||
FileText,
|
||||
Calendar,
|
||||
Clock,
|
||||
BarChart3,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
@@ -50,9 +72,11 @@ import {
|
||||
Loader2,
|
||||
ScanSearch,
|
||||
Eye,
|
||||
Plus,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { formatDate, formatDateOnly } from '@/lib/utils'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
@@ -121,6 +145,42 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [selectedEvalAssignment, setSelectedEvalAssignment] = useState<any>(null)
|
||||
|
||||
// State for add member dialog
|
||||
const [addMemberOpen, setAddMemberOpen] = useState(false)
|
||||
const [addMemberForm, setAddMemberForm] = useState({
|
||||
email: '',
|
||||
name: '',
|
||||
role: 'MEMBER' as 'LEAD' | 'MEMBER' | 'ADVISOR',
|
||||
title: '',
|
||||
sendInvite: true,
|
||||
})
|
||||
|
||||
// State for remove member confirmation
|
||||
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null)
|
||||
|
||||
const addTeamMember = trpc.project.addTeamMember.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Team member added')
|
||||
setAddMemberOpen(false)
|
||||
setAddMemberForm({ email: '', name: '', role: 'MEMBER', title: '', sendInvite: true })
|
||||
utils.project.getFullDetail.invalidate({ id: projectId })
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message || 'Failed to add team member')
|
||||
},
|
||||
})
|
||||
|
||||
const removeTeamMember = trpc.project.removeTeamMember.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Team member removed')
|
||||
setRemovingMemberId(null)
|
||||
utils.project.getFullDetail.invalidate({ id: projectId })
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message || 'Failed to remove team member')
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return <ProjectDetailSkeleton />
|
||||
}
|
||||
@@ -184,9 +244,13 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{project.title}
|
||||
</h1>
|
||||
<Badge variant={statusColors[project.status ?? 'SUBMITTED'] || 'secondary'}>
|
||||
{(project.status ?? 'SUBMITTED').replace('_', ' ')}
|
||||
</Badge>
|
||||
{(() => {
|
||||
const prs = (project as any).projectRoundStates ?? []
|
||||
if (!prs.length) return <Badge variant="secondary">Submitted</Badge>
|
||||
if (prs.some((p: any) => p.state === 'REJECTED')) return <Badge variant="destructive">Rejected</Badge>
|
||||
const latest = prs[0]
|
||||
return <Badge variant={latest.state === 'PASSED' ? 'default' : 'secondary'}>{latest.round.name}</Badge>
|
||||
})()}
|
||||
</div>
|
||||
{project.teamName && (
|
||||
<p className="text-muted-foreground">{project.teamName}</p>
|
||||
@@ -430,53 +494,203 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Team Members Section */}
|
||||
{project.teamMembers && project.teamMembers.length > 0 && (
|
||||
<AnimatedCard index={2}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-violet-500/10 p-1.5">
|
||||
<Users className="h-4 w-4 text-violet-500" />
|
||||
</div>
|
||||
Team Members ({project.teamMembers.length})
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AnimatedCard index={2}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-violet-500/10 p-1.5">
|
||||
<Users className="h-4 w-4 text-violet-500" />
|
||||
</div>
|
||||
Team Members ({project.teamMembers?.length ?? 0})
|
||||
</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={() => setAddMemberOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Member
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{project.teamMembers && project.teamMembers.length > 0 ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{project.teamMembers.map((member: { id: string; role: string; title: string | null; user: { id: string; name: string | null; email: string; avatarUrl?: string | null } }) => (
|
||||
<div key={member.id} className="flex items-center gap-3 p-3 rounded-lg border">
|
||||
{member.role === 'LEAD' ? (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
|
||||
<Crown className="h-5 w-5 text-yellow-500" />
|
||||
</div>
|
||||
) : (
|
||||
<UserAvatar user={member.user} avatarUrl={member.user.avatarUrl} size="md" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-sm truncate">
|
||||
{member.user.name || 'Unnamed'}
|
||||
</p>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{member.role === 'LEAD' ? 'Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{member.user.email}
|
||||
</p>
|
||||
{member.title && (
|
||||
<p className="text-xs text-muted-foreground">{member.title}</p>
|
||||
{project.teamMembers.map((member: { id: string; role: string; title: string | null; user: { id: string; name: string | null; email: string; avatarUrl?: string | null } }) => {
|
||||
const isLastLead =
|
||||
member.role === 'LEAD' &&
|
||||
project.teamMembers.filter((m: { role: string }) => m.role === 'LEAD').length <= 1
|
||||
return (
|
||||
<div key={member.id} className="flex items-center gap-3 p-3 rounded-lg border">
|
||||
{member.role === 'LEAD' ? (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
|
||||
<Crown className="h-5 w-5 text-yellow-500" />
|
||||
</div>
|
||||
) : (
|
||||
<UserAvatar user={member.user} avatarUrl={member.user.avatarUrl} size="md" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-sm truncate">
|
||||
{member.user.name || 'Unnamed'}
|
||||
</p>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{member.role === 'LEAD' ? 'Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{member.user.email}
|
||||
</p>
|
||||
{member.title && (
|
||||
<p className="text-xs text-muted-foreground">{member.title}</p>
|
||||
)}
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
disabled={isLastLead}
|
||||
onClick={() => setRemovingMemberId(member.user.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{isLastLead && (
|
||||
<TooltipContent>
|
||||
Cannot remove the last team lead
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No team members yet.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Add Member Dialog */}
|
||||
<Dialog open={addMemberOpen} onOpenChange={setAddMemberOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Team Member</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="member-email">Email</Label>
|
||||
<Input
|
||||
id="member-email"
|
||||
type="email"
|
||||
placeholder="member@example.com"
|
||||
value={addMemberForm.email}
|
||||
onChange={(e) => setAddMemberForm((f) => ({ ...f, email: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="member-name">Name</Label>
|
||||
<Input
|
||||
id="member-name"
|
||||
placeholder="Full name"
|
||||
value={addMemberForm.name}
|
||||
onChange={(e) => setAddMemberForm((f) => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="member-role">Role</Label>
|
||||
<Select
|
||||
value={addMemberForm.role}
|
||||
onValueChange={(v) => setAddMemberForm((f) => ({ ...f, role: v as 'LEAD' | 'MEMBER' | 'ADVISOR' }))}
|
||||
>
|
||||
<SelectTrigger id="member-role">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="LEAD">Lead</SelectItem>
|
||||
<SelectItem value="MEMBER">Member</SelectItem>
|
||||
<SelectItem value="ADVISOR">Advisor</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="member-title">Title (optional)</Label>
|
||||
<Input
|
||||
id="member-title"
|
||||
placeholder="e.g. CEO, Co-founder"
|
||||
value={addMemberForm.title}
|
||||
onChange={(e) => setAddMemberForm((f) => ({ ...f, title: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="member-invite"
|
||||
checked={addMemberForm.sendInvite}
|
||||
onCheckedChange={(checked) =>
|
||||
setAddMemberForm((f) => ({ ...f, sendInvite: checked === true }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="member-invite" className="font-normal cursor-pointer">
|
||||
Send invite email
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAddMemberOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
addTeamMember.mutate({
|
||||
projectId,
|
||||
email: addMemberForm.email,
|
||||
name: addMemberForm.name,
|
||||
role: addMemberForm.role,
|
||||
title: addMemberForm.title || undefined,
|
||||
sendInvite: addMemberForm.sendInvite,
|
||||
})
|
||||
}
|
||||
disabled={addTeamMember.isPending || !addMemberForm.email || !addMemberForm.name}
|
||||
>
|
||||
{addTeamMember.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Add Member
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Remove Member Confirmation Dialog */}
|
||||
<Dialog open={!!removingMemberId} onOpenChange={(open) => { if (!open) setRemovingMemberId(null) }}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove Team Member</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Are you sure you want to remove this team member? This action cannot be undone.
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRemovingMemberId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (removingMemberId) {
|
||||
removeTeamMember.mutate({ projectId, userId: removingMemberId })
|
||||
}
|
||||
}}
|
||||
disabled={removeTeamMember.isPending}
|
||||
>
|
||||
{removeTeamMember.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Remove
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Mentor Assignment Section */}
|
||||
{project.wantsMentorship && (
|
||||
|
||||
Reference in New Issue
Block a user