2026-02-14 15:26:42 +01:00
|
|
|
'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 {
|
|
|
|
|
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'
|
feat: applicant onboarding, bulk invite, team management enhancements
- Add nationality/institution fields to User model with migration
- Applicant onboarding wizard (name, photo, nationality, country, institution, bio, project logo, preferences)
- Project logo upload from applicant context with team membership verification
- APPLICANT redirects in set-password, onboarding, and auth layout
- Mask evaluation round names as "Evaluation Round 1/2/..." for applicants
- Extend inviteTeamMember with nationality/country/institution/sendInvite fields
- Admin getApplicants query with search/filter/pagination
- Admin bulkInviteApplicants mutation with token generation and emails
- Applicants tab on Members page with bulk select and floating invite bar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:11:11 +01:00
|
|
|
import { CountrySelect } from '@/components/ui/country-select'
|
|
|
|
|
import { Checkbox as CheckboxPrimitive } from '@/components/ui/checkbox'
|
2026-03-03 19:14:41 +01:00
|
|
|
import { ProjectLogoUpload } from '@/components/shared/project-logo-upload'
|
|
|
|
|
import { UserAvatar } from '@/components/shared/user-avatar'
|
2026-02-14 15:26:42 +01:00
|
|
|
import {
|
2026-03-03 19:14:41 +01:00
|
|
|
FolderOpen,
|
2026-02-14 15:26:42 +01:00
|
|
|
Users,
|
|
|
|
|
UserPlus,
|
|
|
|
|
Crown,
|
|
|
|
|
Mail,
|
|
|
|
|
Trash2,
|
|
|
|
|
Loader2,
|
|
|
|
|
AlertCircle,
|
|
|
|
|
CheckCircle,
|
|
|
|
|
Clock,
|
|
|
|
|
FileText,
|
2026-03-03 19:14:41 +01:00
|
|
|
ImageIcon,
|
|
|
|
|
MapPin,
|
|
|
|
|
Waves,
|
|
|
|
|
GraduationCap,
|
|
|
|
|
Heart,
|
|
|
|
|
Calendar,
|
2026-02-14 15:26:42 +01:00
|
|
|
} from 'lucide-react'
|
2026-03-03 19:14:41 +01:00
|
|
|
import { formatDateOnly } from '@/lib/utils'
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
|
|
|
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(),
|
feat: applicant onboarding, bulk invite, team management enhancements
- Add nationality/institution fields to User model with migration
- Applicant onboarding wizard (name, photo, nationality, country, institution, bio, project logo, preferences)
- Project logo upload from applicant context with team membership verification
- APPLICANT redirects in set-password, onboarding, and auth layout
- Mask evaluation round names as "Evaluation Round 1/2/..." for applicants
- Extend inviteTeamMember with nationality/country/institution/sendInvite fields
- Admin getApplicants query with search/filter/pagination
- Admin bulkInviteApplicants mutation with token generation and emails
- Applicants tab on Members page with bulk select and floating invite bar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:11:11 +01:00
|
|
|
nationality: z.string().optional(),
|
|
|
|
|
country: z.string().optional(),
|
|
|
|
|
institution: z.string().optional(),
|
|
|
|
|
sendInvite: z.boolean().default(true),
|
2026-02-14 15:26:42 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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 },
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 19:14:41 +01:00
|
|
|
const OCEAN_ISSUE_LABELS: Record<string, string> = {
|
|
|
|
|
POLLUTION_REDUCTION: 'Pollution Reduction',
|
|
|
|
|
CLIMATE_MITIGATION: 'Climate Mitigation',
|
|
|
|
|
TECHNOLOGY_INNOVATION: 'Technology Innovation',
|
|
|
|
|
SUSTAINABLE_SHIPPING: 'Sustainable Shipping',
|
|
|
|
|
BLUE_CARBON: 'Blue Carbon',
|
|
|
|
|
HABITAT_RESTORATION: 'Habitat Restoration',
|
|
|
|
|
COMMUNITY_CAPACITY: 'Community Capacity',
|
|
|
|
|
SUSTAINABLE_FISHING: 'Sustainable Fishing',
|
|
|
|
|
CONSUMER_AWARENESS: 'Consumer Awareness',
|
|
|
|
|
OCEAN_ACIDIFICATION: 'Ocean Acidification',
|
|
|
|
|
OTHER: 'Other',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function ApplicantProjectPage() {
|
2026-02-14 15:26:42 +01:00
|
|
|
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 }
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-03 19:14:41 +01:00
|
|
|
const project = dashboardData?.project
|
|
|
|
|
const projectId = project?.id
|
|
|
|
|
const isIntakeOpen = dashboardData?.isIntakeOpen ?? false
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
|
|
|
const { data: teamData, isLoading: teamLoading, refetch } = trpc.applicant.getTeamMembers.useQuery(
|
|
|
|
|
{ projectId: projectId! },
|
|
|
|
|
{ enabled: !!projectId }
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-03 19:14:41 +01:00
|
|
|
const { data: logoUrl, refetch: refetchLogo } = trpc.applicant.getProjectLogoUrl.useQuery(
|
|
|
|
|
{ projectId: projectId! },
|
|
|
|
|
{ enabled: !!projectId }
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
const inviteMutation = trpc.applicant.inviteTeamMember.useMutation({
|
|
|
|
|
onSuccess: (result) => {
|
|
|
|
|
if (result.requiresAccountSetup) {
|
|
|
|
|
toast.success('Invitation email sent to team member')
|
|
|
|
|
} else {
|
|
|
|
|
toast.success('Team member added and notified by email')
|
|
|
|
|
}
|
|
|
|
|
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: '',
|
feat: applicant onboarding, bulk invite, team management enhancements
- Add nationality/institution fields to User model with migration
- Applicant onboarding wizard (name, photo, nationality, country, institution, bio, project logo, preferences)
- Project logo upload from applicant context with team membership verification
- APPLICANT redirects in set-password, onboarding, and auth layout
- Mask evaluation round names as "Evaluation Round 1/2/..." for applicants
- Extend inviteTeamMember with nationality/country/institution/sendInvite fields
- Admin getApplicants query with search/filter/pagination
- Admin bulkInviteApplicants mutation with token generation and emails
- Applicants tab on Members page with bulk select and floating invite bar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:11:11 +01:00
|
|
|
nationality: '',
|
|
|
|
|
country: '',
|
|
|
|
|
institution: '',
|
|
|
|
|
sendInvite: true,
|
2026-02-14 15:26:42 +01:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 19:14:41 +01:00
|
|
|
if (!projectId || !project) {
|
2026-02-14 15:26:42 +01:00
|
|
|
return (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
<div>
|
2026-03-03 19:14:41 +01:00
|
|
|
<h1 className="text-2xl font-semibold tracking-tight">Project</h1>
|
2026-02-14 15:26:42 +01:00
|
|
|
</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">
|
2026-03-03 19:14:41 +01:00
|
|
|
Submit a project first to view details.
|
2026-02-14 15:26:42 +01:00
|
|
|
</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 */}
|
2026-03-03 19:14:41 +01:00
|
|
|
<div className="flex items-center gap-4">
|
|
|
|
|
{/* Project logo */}
|
|
|
|
|
<div className="shrink-0 h-14 w-14 rounded-xl border bg-muted/50 flex items-center justify-center overflow-hidden">
|
|
|
|
|
{logoUrl ? (
|
|
|
|
|
<img src={logoUrl} alt={project.title} className="h-full w-full object-cover" />
|
|
|
|
|
) : (
|
|
|
|
|
<FolderOpen className="h-7 w-7 text-muted-foreground/60" />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-02-14 15:26:42 +01:00
|
|
|
<div>
|
2026-03-03 19:14:41 +01:00
|
|
|
<h1 className="text-2xl font-semibold tracking-tight">
|
|
|
|
|
{project.title}
|
2026-02-14 15:26:42 +01:00
|
|
|
</h1>
|
|
|
|
|
<p className="text-muted-foreground">
|
2026-03-03 19:14:41 +01:00
|
|
|
{project.teamName ? `Team: ${project.teamName}` : 'Project details and team management'}
|
2026-02-14 15:26:42 +01:00
|
|
|
</p>
|
|
|
|
|
</div>
|
2026-03-03 19:14:41 +01:00
|
|
|
</div>
|
2026-02-14 15:26:42 +01:00
|
|
|
|
2026-03-03 19:14:41 +01:00
|
|
|
{/* Project Details Card */}
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<CardTitle className="flex items-center gap-2">
|
|
|
|
|
<FileText className="h-5 w-5" />
|
|
|
|
|
Project Information
|
|
|
|
|
</CardTitle>
|
|
|
|
|
{isIntakeOpen && (
|
|
|
|
|
<Badge variant="outline" className="text-amber-600 border-amber-200 bg-amber-50">
|
|
|
|
|
Editable during intake
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent className="space-y-4">
|
|
|
|
|
{/* Category & Ocean Issue badges */}
|
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
|
|
{project.competitionCategory && (
|
|
|
|
|
<Badge variant="outline" className="gap-1">
|
|
|
|
|
<GraduationCap className="h-3 w-3" />
|
|
|
|
|
{project.competitionCategory === 'STARTUP' ? 'Start-up' : 'Business Concept'}
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
{project.oceanIssue && (
|
|
|
|
|
<Badge variant="outline" className="gap-1">
|
|
|
|
|
<Waves className="h-3 w-3" />
|
|
|
|
|
{OCEAN_ISSUE_LABELS[project.oceanIssue] || project.oceanIssue.replace(/_/g, ' ')}
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
{project.wantsMentorship && (
|
|
|
|
|
<Badge variant="outline" className="gap-1 text-pink-600 border-pink-200 bg-pink-50">
|
|
|
|
|
<Heart className="h-3 w-3" />
|
|
|
|
|
Wants Mentorship
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Description */}
|
|
|
|
|
{project.description && (
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm font-medium text-muted-foreground mb-1">Description</p>
|
|
|
|
|
<p className="text-sm whitespace-pre-wrap">{project.description}</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Location, Institution, Founded */}
|
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
|
|
|
{(project.country || project.geographicZone) && (
|
|
|
|
|
<div className="flex items-start gap-2">
|
|
|
|
|
<MapPin className="h-4 w-4 text-muted-foreground mt-0.5" />
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm font-medium text-muted-foreground">Location</p>
|
|
|
|
|
<p className="text-sm">{project.geographicZone || project.country}</p>
|
feat: applicant onboarding, bulk invite, team management enhancements
- Add nationality/institution fields to User model with migration
- Applicant onboarding wizard (name, photo, nationality, country, institution, bio, project logo, preferences)
- Project logo upload from applicant context with team membership verification
- APPLICANT redirects in set-password, onboarding, and auth layout
- Mask evaluation round names as "Evaluation Round 1/2/..." for applicants
- Extend inviteTeamMember with nationality/country/institution/sendInvite fields
- Admin getApplicants query with search/filter/pagination
- Admin bulkInviteApplicants mutation with token generation and emails
- Applicants tab on Members page with bulk select and floating invite bar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:11:11 +01:00
|
|
|
</div>
|
2026-03-03 19:14:41 +01:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{project.institution && (
|
|
|
|
|
<div className="flex items-start gap-2">
|
|
|
|
|
<GraduationCap className="h-4 w-4 text-muted-foreground mt-0.5" />
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm font-medium text-muted-foreground">Institution</p>
|
|
|
|
|
<p className="text-sm">{project.institution}</p>
|
feat: applicant onboarding, bulk invite, team management enhancements
- Add nationality/institution fields to User model with migration
- Applicant onboarding wizard (name, photo, nationality, country, institution, bio, project logo, preferences)
- Project logo upload from applicant context with team membership verification
- APPLICANT redirects in set-password, onboarding, and auth layout
- Mask evaluation round names as "Evaluation Round 1/2/..." for applicants
- Extend inviteTeamMember with nationality/country/institution/sendInvite fields
- Admin getApplicants query with search/filter/pagination
- Admin bulkInviteApplicants mutation with token generation and emails
- Applicants tab on Members page with bulk select and floating invite bar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:11:11 +01:00
|
|
|
</div>
|
2026-03-03 19:14:41 +01:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{project.foundedAt && (
|
|
|
|
|
<div className="flex items-start gap-2">
|
|
|
|
|
<Calendar className="h-4 w-4 text-muted-foreground mt-0.5" />
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm font-medium text-muted-foreground">Founded</p>
|
|
|
|
|
<p className="text-sm">{formatDateOnly(project.foundedAt)}</p>
|
Overhaul applicant portal: timeline, evaluations, nav, resources
- Fix programId/competitionId bug in competition timeline
- Add applicantVisibility config to EvaluationConfigSchema (JSONB)
- Add admin UI card for controlling applicant feedback visibility
- Add 6 new tRPC procedures: getNavFlags, getMyCompetitionTimeline,
getMyEvaluations, getUpcomingDeadlines, getDocumentCompleteness,
and extend getMyDashboard with hasPassedIntake
- Rewrite competition timeline to show only EVALUATION + Grand Finale,
synthesize FILTERING rejections, handle manually-created projects
- Dynamic ApplicantNav with conditional Evaluations/Mentoring/Resources
- Dashboard: conditional timeline, jury feedback card, deadlines,
document completeness, conditional mentor tile
- New /applicant/evaluations page with anonymous jury feedback
- New /applicant/resources pages (clone of jury learning hub)
- Rename /applicant/competitions → /applicant/competition
- Remove broken /applicant/competitions/[windowId] page
- Add permission info banner to team invite dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:51:17 +01:00
|
|
|
</div>
|
2026-03-03 19:14:41 +01:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Mentor info */}
|
|
|
|
|
{project.mentorAssignment?.mentor && (
|
|
|
|
|
<div className="rounded-lg border p-3 bg-muted/50">
|
|
|
|
|
<p className="text-sm font-medium mb-1">Assigned Mentor</p>
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
{project.mentorAssignment.mentor.name} ({project.mentorAssignment.mentor.email})
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Tags */}
|
|
|
|
|
{project.tags && project.tags.length > 0 && (
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm font-medium text-muted-foreground mb-1">Tags</p>
|
|
|
|
|
<div className="flex flex-wrap gap-1">
|
|
|
|
|
{project.tags.map((tag: string) => (
|
|
|
|
|
<Badge key={tag} variant="secondary" className="text-xs">
|
|
|
|
|
{tag}
|
|
|
|
|
</Badge>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{/* Project Logo */}
|
|
|
|
|
{isTeamLead && projectId && (
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle className="flex items-center gap-2">
|
|
|
|
|
<ImageIcon className="h-5 w-5" />
|
|
|
|
|
Project Logo
|
|
|
|
|
</CardTitle>
|
|
|
|
|
<CardDescription>
|
|
|
|
|
Click the image to upload or change your project logo.
|
|
|
|
|
</CardDescription>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent className="flex justify-center">
|
|
|
|
|
<ProjectLogoUpload
|
|
|
|
|
projectId={projectId}
|
|
|
|
|
currentLogoUrl={logoUrl}
|
|
|
|
|
onUploadComplete={() => refetchLogo()}
|
|
|
|
|
/>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
|
|
|
{/* Team Members List */}
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
2026-03-03 19:14:41 +01:00
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<CardTitle className="flex items-center gap-2">
|
|
|
|
|
<Users className="h-5 w-5" />
|
|
|
|
|
Team ({teamData?.teamMembers.length || 0} members)
|
|
|
|
|
</CardTitle>
|
|
|
|
|
<CardDescription>
|
|
|
|
|
Everyone on this list can view and collaborate on this project.
|
|
|
|
|
</CardDescription>
|
|
|
|
|
</div>
|
|
|
|
|
{isTeamLead && (
|
|
|
|
|
<Dialog open={isInviteOpen} onOpenChange={setIsInviteOpen}>
|
|
|
|
|
<DialogTrigger asChild>
|
|
|
|
|
<Button size="sm">
|
|
|
|
|
<UserPlus className="mr-2 h-4 w-4" />
|
|
|
|
|
Invite
|
|
|
|
|
</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>
|
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label>Nationality</Label>
|
|
|
|
|
<CountrySelect
|
|
|
|
|
value={form.watch('nationality') || ''}
|
|
|
|
|
onChange={(v) => form.setValue('nationality', v)}
|
|
|
|
|
placeholder="Select nationality"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label>Country of Residence</Label>
|
|
|
|
|
<CountrySelect
|
|
|
|
|
value={form.watch('country') || ''}
|
|
|
|
|
onChange={(v) => form.setValue('country', v)}
|
|
|
|
|
placeholder="Select country"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="institution">Institution (optional)</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="institution"
|
|
|
|
|
placeholder="e.g., Ocean Research Institute"
|
|
|
|
|
{...form.register('institution')}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<CheckboxPrimitive
|
|
|
|
|
id="sendInvite"
|
|
|
|
|
checked={form.watch('sendInvite')}
|
|
|
|
|
onCheckedChange={(checked) => form.setValue('sendInvite', !!checked)}
|
|
|
|
|
/>
|
|
|
|
|
<Label htmlFor="sendInvite" className="text-sm font-normal cursor-pointer">
|
|
|
|
|
Send platform invite email
|
|
|
|
|
</Label>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="rounded-lg bg-muted/50 border p-3 text-sm">
|
|
|
|
|
<p className="font-medium mb-1">What invited members can do:</p>
|
|
|
|
|
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
|
|
|
|
<li>Upload documents for submission rounds</li>
|
|
|
|
|
<li>View project status and competition progress</li>
|
|
|
|
|
<li>Receive email notifications about round updates</li>
|
|
|
|
|
</ul>
|
|
|
|
|
<p className="mt-2 text-muted-foreground">Only the Team Lead can invite or remove members.</p>
|
|
|
|
|
</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>
|
2026-02-14 15:26:42 +01:00
|
|
|
</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">
|
2026-03-03 19:14:41 +01:00
|
|
|
<div className="relative">
|
|
|
|
|
<UserAvatar
|
|
|
|
|
user={member.user}
|
|
|
|
|
avatarUrl={teamData?.avatarUrls?.[member.userId] || null}
|
|
|
|
|
size="md"
|
|
|
|
|
/>
|
|
|
|
|
{member.role === 'LEAD' && (
|
|
|
|
|
<div className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-yellow-100 ring-2 ring-white">
|
|
|
|
|
<Crown className="h-2.5 w-2.5 text-yellow-600" />
|
|
|
|
|
</div>
|
2026-02-14 15:26:42 +01:00
|
|
|
)}
|
|
|
|
|
</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>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|