diff --git a/src/app/(applicant)/applicant/page.tsx b/src/app/(applicant)/applicant/page.tsx index 33dbbc6..59728f1 100644 --- a/src/app/(applicant)/applicant/page.tsx +++ b/src/app/(applicant)/applicant/page.tsx @@ -18,6 +18,7 @@ import { Textarea } from '@/components/ui/textarea' import { CompetitionTimelineSidebar } from '@/components/applicant/competition-timeline' import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card' import { MentorConversationCard } from '@/components/applicant/mentor-conversation-card' +import { AttendingMembersCard } from '@/components/applicant/attending-members-card' import { AnimatedCard } from '@/components/shared/animated-container' import { ProjectLogoUpload } from '@/components/shared/project-logo-upload' import { Progress } from '@/components/ui/progress' @@ -402,6 +403,9 @@ export default function ApplicantDashboardPage() { ))} + {/* Grand finale attendee roster (auto-hides until confirmation status is CONFIRMED) */} + + {/* Conversation with assigned mentor (auto-hides when no mentor assigned) */} diff --git a/src/components/applicant/attending-members-card.tsx b/src/components/applicant/attending-members-card.tsx new file mode 100644 index 0000000..4951d37 --- /dev/null +++ b/src/components/applicant/attending-members-card.tsx @@ -0,0 +1,118 @@ +'use client' + +import { trpc } from '@/lib/trpc/client' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Skeleton } from '@/components/ui/skeleton' +import { PlaneTakeoff, ShieldCheck, AlertTriangle } from 'lucide-react' +import { EditAttendeesDialog } from './edit-attendees-dialog' + +export function AttendingMembersCard() { + const { data, isLoading } = trpc.applicant.getMyFinalistConfirmation.useQuery() + + if (isLoading) { + return ( + + + + + + + + + ) + } + + if (!data || data.confirmation.status !== 'CONFIRMED') return null + + const cutoffAt = data.cutoffAt ? new Date(data.cutoffAt) : null + const userById = new Map(data.project.teamMembers.map((tm) => [tm.userId, tm.user])) + const attendees = data.confirmation.attendingMembers + + const editDisabled = !data.editableNow + const editDisabledReason = !data.editableNow + ? 'Attendee changes are closed for this edition.' + : undefined + + return ( + + +
+ +
+ +
+ Grand Finale Attendees +
+ + Team members confirmed to travel to Monaco + {cutoffAt && data.editableNow && ( + <> + {' '} + · editable until{' '} + + {new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(cutoffAt)} + + + )} + {cutoffAt && !data.editableNow && ( + + {' '} + · editing closed + + )} + +
+ {data.isLead && ( + + )} +
+ + {attendees.length === 0 ? ( +

No attendees selected yet.

+ ) : ( +
    + {attendees.map((a) => { + const user = userById.get(a.userId) + if (!user) return null + return ( +
  • +
    +
    {user.name ?? user.email}
    +
    {user.email}
    +
    + {a.needsVisa && ( + + + Visa support + + )} +
  • + ) + })} +
+ )} +
+
+ ) +} diff --git a/src/components/applicant/edit-attendees-dialog.tsx b/src/components/applicant/edit-attendees-dialog.tsx new file mode 100644 index 0000000..b3e7a18 --- /dev/null +++ b/src/components/applicant/edit-attendees-dialog.tsx @@ -0,0 +1,182 @@ +'use client' + +import { useEffect, useState } from 'react' +import { trpc } from '@/lib/trpc/client' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Switch } from '@/components/ui/switch' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { Loader2, Pencil } from 'lucide-react' +import { toast } from 'sonner' + +type TeamMember = { + userId: string + role: string + user: { id: string; name: string | null; email: string } +} + +type AttendingMember = { userId: string; needsVisa: boolean } + +export function EditAttendeesDialog({ + confirmationId, + cap, + teamMembers, + attendingMembers, + cutoffAt, + disabled, + disabledReason, +}: { + confirmationId: string + cap: number + teamMembers: TeamMember[] + attendingMembers: AttendingMember[] + cutoffAt: Date | null + disabled?: boolean + disabledReason?: string +}) { + const [open, setOpen] = useState(false) + const [selected, setSelected] = useState>(new Set()) + const [visa, setVisa] = useState>({}) + + const utils = trpc.useUtils() + const edit = trpc.finalist.editAttendees.useMutation({ + onSuccess: () => { + toast.success('Attendees updated') + utils.applicant.getMyFinalistConfirmation.invalidate() + setOpen(false) + }, + onError: (e) => toast.error(e.message), + }) + + // Reset form to current roster when dialog opens + useEffect(() => { + if (open) { + setSelected(new Set(attendingMembers.map((m) => m.userId))) + setVisa( + Object.fromEntries(attendingMembers.map((m) => [m.userId, m.needsVisa])), + ) + } + }, [open, attendingMembers]) + + const toggle = (userId: string, checked: boolean) => { + setSelected((prev) => { + const next = new Set(prev) + if (checked) next.add(userId) + else next.delete(userId) + return next + }) + } + + const overCap = selected.size > cap + const noneSelected = selected.size === 0 + + const handleSubmit = () => { + const ids = Array.from(selected) + edit.mutate({ + confirmationId, + attendingUserIds: ids, + visaFlags: Object.fromEntries(ids.map((id) => [id, !!visa[id]])), + }) + } + + return ( + { + if (!edit.isPending) setOpen(next) + }} + > + + + + + + Edit attendees + + Update who from your team will travel to the grand finale. You can select up to{' '} + {cap} team members. Mark anyone who needs visa support so we can prepare + documents in time. + {cutoffAt && ( + <> + {' '} + Editable until{' '} + + {new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(cutoffAt)} + + . + + )} + + + +
    + {teamMembers.map((tm) => { + const checked = selected.has(tm.userId) + return ( +
  • + + {checked && ( + + )} +
  • + ) + })} +
+ + {overCap && ( +

+ Please select no more than {cap} member{cap === 1 ? '' : 's'}. +

+ )} + + + + + +
+
+ ) +} diff --git a/src/server/routers/applicant.ts b/src/server/routers/applicant.ts index 9104706..f5df4ec 100644 --- a/src/server/routers/applicant.ts +++ b/src/server/routers/applicant.ts @@ -2705,4 +2705,73 @@ export const applicantRouter = router({ unreadCount, } }), + + /** + * Returns the caller's project's finalist confirmation (if any) plus the + * data needed by the team-lead's "Edit attendees" dialog: the team roster, + * the current AttendingMember rows, the program cap, and the editable + * cutoff derived from the LIVE_FINAL round window. + * + * Returns null when the caller is not on a team with a confirmation. + */ + getMyFinalistConfirmation: protectedProcedure.query(async ({ ctx }) => { + const project = await ctx.prisma.project.findFirst({ + where: { teamMembers: { some: { userId: ctx.user.id } } }, + include: { + program: { select: { id: true, defaultAttendeeCap: true } }, + teamMembers: { + include: { + user: { select: { id: true, name: true, email: true } }, + }, + orderBy: { joinedAt: 'asc' }, + }, + finalistConfirmation: { + include: { + attendingMembers: { select: { userId: true, needsVisa: true } }, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }) + + if (!project || !project.finalistConfirmation) return null + + const callerMember = project.teamMembers.find((tm) => tm.userId === ctx.user.id) + const isLead = callerMember?.role === 'LEAD' + + let cutoffAt: Date | null = null + let editableNow = true + const round = await ctx.prisma.round.findFirst({ + where: { competition: { programId: project.program.id }, roundType: 'LIVE_FINAL' }, + orderBy: { sortOrder: 'desc' }, + select: { windowOpenAt: true, configJson: true }, + }) + if (round?.windowOpenAt) { + const cfg = (round.configJson ?? {}) as { attendeeEditCutoffHours?: number } + const cutoffHours = cfg.attendeeEditCutoffHours ?? 48 + cutoffAt = new Date(round.windowOpenAt.getTime() - cutoffHours * 3_600_000) + editableNow = Date.now() <= cutoffAt.getTime() + } + + return { + project: { + id: project.id, + title: project.title, + teamMembers: project.teamMembers.map((tm) => ({ + userId: tm.userId, + role: tm.role, + user: tm.user, + })), + program: { defaultAttendeeCap: project.program.defaultAttendeeCap }, + }, + confirmation: { + id: project.finalistConfirmation.id, + status: project.finalistConfirmation.status, + attendingMembers: project.finalistConfirmation.attendingMembers, + }, + isLead, + cutoffAt, + editableNow, + } + }), })