Files
MOPC-Portal/src/app/(applicant)/applicant/team/page.tsx

438 lines
15 KiB
TypeScript
Raw Normal View History

'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>
)
}