Performance optimization, applicant portal, and missing DB migration

Performance:
- Convert admin dashboard from SSR to client-side tRPC (fixes 503/ChunkLoadError)
- New dashboard.getStats tRPC endpoint batches 16 queries into single response
- Parallelize jury dashboard queries (assignments + gracePeriods via Promise.all)
- Add project.getFullDetail combined endpoint (project + assignments + stats)
- Configure Prisma connection pool (connection_limit=20, pool_timeout=10)
- Add optimizePackageImports for lucide-react tree-shaking
- Increase React Query staleTime from 1min to 5min

Applicant portal:
- Add applicant layout, nav, dashboard, documents, team, and mentor pages
- Add applicant router with document and team management endpoints
- Add chunk error recovery utility
- Update role nav and auth redirect for applicant role

Database:
- Add migration for missing schema elements (SpecialAward job tracking
  columns, WizardTemplate table, missing indexes)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 11:04:26 +01:00
parent 09091d7c08
commit 98f4a957cc
32 changed files with 3002 additions and 1121 deletions

View File

@@ -0,0 +1,437 @@
'use client'
import { useState } from 'react'
import { useSession } from 'next-auth/react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { RequirementUploadList } from '@/components/shared/requirement-upload-slot'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Users,
UserPlus,
Crown,
Mail,
Trash2,
Loader2,
AlertCircle,
CheckCircle,
Clock,
FileText,
} from 'lucide-react'
const inviteSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
role: z.enum(['MEMBER', 'ADVISOR']),
title: z.string().optional(),
})
type InviteFormData = z.infer<typeof inviteSchema>
const roleLabels: Record<string, string> = {
LEAD: 'Team Lead',
MEMBER: 'Team Member',
ADVISOR: 'Advisor',
}
const statusLabels: Record<string, { label: string; icon: React.ComponentType<{ className?: string }> }> = {
ACTIVE: { label: 'Active', icon: CheckCircle },
INVITED: { label: 'Pending', icon: Clock },
SUSPENDED: { label: 'Suspended', icon: AlertCircle },
}
export default function ApplicantTeamPage() {
const { data: session, status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const [isInviteOpen, setIsInviteOpen] = useState(false)
const { data: dashboardData, isLoading: dashLoading } = trpc.applicant.getMyDashboard.useQuery(
undefined,
{ enabled: isAuthenticated }
)
const projectId = dashboardData?.project?.id
const { data: teamData, isLoading: teamLoading, refetch } = trpc.applicant.getTeamMembers.useQuery(
{ projectId: projectId! },
{ enabled: !!projectId }
)
const inviteMutation = trpc.applicant.inviteTeamMember.useMutation({
onSuccess: () => {
toast.success('Team member invited!')
setIsInviteOpen(false)
refetch()
},
onError: (error) => {
toast.error(error.message)
},
})
const removeMutation = trpc.applicant.removeTeamMember.useMutation({
onSuccess: () => {
toast.success('Team member removed')
refetch()
},
onError: (error) => {
toast.error(error.message)
},
})
const form = useForm<InviteFormData>({
resolver: zodResolver(inviteSchema),
defaultValues: {
name: '',
email: '',
role: 'MEMBER',
title: '',
},
})
const onInvite = async (data: InviteFormData) => {
if (!projectId) return
await inviteMutation.mutateAsync({
projectId,
...data,
})
form.reset()
}
const isLoading = dashLoading || teamLoading
if (isLoading) {
return (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<Card>
<CardContent className="p-6 space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-24" />
</div>
</div>
<Skeleton className="h-8 w-20" />
</div>
))}
</CardContent>
</Card>
</div>
)
}
if (!projectId) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Team</h1>
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h2 className="text-xl font-semibold mb-2">No Project</h2>
<p className="text-muted-foreground text-center">
Submit a project first to manage your team.
</p>
</CardContent>
</Card>
</div>
)
}
// Check if user is team lead
const currentUserMember = teamData?.teamMembers.find(
(tm) => tm.userId === session?.user?.id
)
const isTeamLead =
currentUserMember?.role === 'LEAD' ||
teamData?.submittedBy?.id === session?.user?.id
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<Users className="h-6 w-6" />
Team Members
</h1>
<p className="text-muted-foreground">
Manage your project team
</p>
</div>
{isTeamLead && (
<Dialog open={isInviteOpen} onOpenChange={setIsInviteOpen}>
<DialogTrigger asChild>
<Button>
<UserPlus className="mr-2 h-4 w-4" />
Invite Member
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Invite Team Member</DialogTitle>
<DialogDescription>
Send an invitation to join your project team. They will receive an email
with instructions to create their account.
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onInvite)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
placeholder="Jane Doe"
{...form.register('name')}
/>
{form.formState.errors.name && (
<p className="text-sm text-destructive">
{form.formState.errors.name.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
placeholder="jane@example.com"
{...form.register('email')}
/>
{form.formState.errors.email && (
<p className="text-sm text-destructive">
{form.formState.errors.email.message}
</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select
value={form.watch('role')}
onValueChange={(value) => form.setValue('role', value as 'MEMBER' | 'ADVISOR')}
>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">Team Member</SelectItem>
<SelectItem value="ADVISOR">Advisor</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="title">Title (optional)</Label>
<Input
id="title"
placeholder="CTO, Designer..."
{...form.register('title')}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsInviteOpen(false)}
>
Cancel
</Button>
<Button type="submit" disabled={inviteMutation.isPending}>
{inviteMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Send Invitation
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)}
</div>
{/* Team Members List */}
<Card>
<CardHeader>
<CardTitle>Team ({teamData?.teamMembers.length || 0} members)</CardTitle>
<CardDescription>
Everyone on this list can view and collaborate on this project.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{teamData?.teamMembers.map((member) => {
const StatusIcon = statusLabels[member.user.status]?.icon || AlertCircle
return (
<div
key={member.id}
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
{member.role === 'LEAD' ? (
<Crown className="h-5 w-5 text-yellow-500" />
) : (
<span className="text-sm font-medium">
{member.user.name?.charAt(0).toUpperCase() || '?'}
</span>
)}
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{member.user.name}</span>
<Badge variant="outline" className="text-xs">
{roleLabels[member.role] || member.role}
</Badge>
{member.title && (
<span className="text-xs text-muted-foreground">
({member.title})
</span>
)}
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Mail className="h-3 w-3" />
{member.user.email}
<StatusIcon className="h-3 w-3 ml-2" />
<span className="text-xs">
{statusLabels[member.user.status]?.label || member.user.status}
</span>
</div>
</div>
</div>
{isTeamLead && member.role !== 'LEAD' && teamData.submittedBy?.id !== member.userId && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="text-destructive">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Team Member</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove {member.user.name} from the team?
They will no longer have access to this project.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => removeMutation.mutate({ projectId, userId: member.userId })}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
)
})}
{(!teamData?.teamMembers || teamData.teamMembers.length === 0) && (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Users className="h-12 w-12 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">No team members yet.</p>
{isTeamLead && (
<Button
variant="outline"
className="mt-4"
onClick={() => setIsInviteOpen(true)}
>
<UserPlus className="mr-2 h-4 w-4" />
Invite Your First Team Member
</Button>
)}
</div>
)}
</CardContent>
</Card>
{/* Team Documents */}
{teamData?.roundId && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Team Documents</CardTitle>
<CardDescription>
Upload required documents for your project. Any team member can upload files.
</CardDescription>
</CardHeader>
<CardContent>
<RequirementUploadList
projectId={projectId}
roundId={teamData.roundId}
/>
</CardContent>
</Card>
)}
{/* Info Card */}
<Card className="bg-muted/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-muted-foreground mt-0.5" />
<div className="text-sm text-muted-foreground">
<p className="font-medium text-foreground">About Team Access</p>
<p className="mt-1">
All team members can view project details and status updates.
Only the team lead can invite or remove team members.
Invited members will receive an email to set up their account.
</p>
</div>
</div>
</CardContent>
</Card>
</div>
)
}