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:
2026-03-04 13:29:22 +01:00
parent f24bea3df2
commit 1103d42439
11 changed files with 606 additions and 265 deletions

View File

@@ -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 && (