feat: edit-attendees dialog + roster card on applicant dashboard

Adds applicant.getMyFinalistConfirmation query (returns roster + cutoff
metadata for the team's confirmation, or null). New AttendingMembersCard
shows the confirmed attendee list and surfaces an Edit dialog to the
team lead — disabled past the editable cutoff. Card auto-hides until the
confirmation reaches CONFIRMED status.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-28 18:54:40 +02:00
parent 5b642c3d50
commit a6284e5c66
4 changed files with 373 additions and 0 deletions

View File

@@ -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() {
</AnimatedCard>
))}
{/* Grand finale attendee roster (auto-hides until confirmation status is CONFIRMED) */}
<AttendingMembersCard />
{/* Conversation with assigned mentor (auto-hides when no mentor assigned) */}
<MentorConversationCard projectId={project.id} />

View File

@@ -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 (
<Card>
<CardHeader>
<Skeleton className="h-5 w-48" />
</CardHeader>
<CardContent>
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
)
}
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 (
<Card>
<CardHeader className="flex-row items-start justify-between gap-4 space-y-0">
<div>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-sky-500/10 p-1.5">
<PlaneTakeoff className="h-4 w-4 text-sky-500" />
</div>
Grand Finale Attendees
</CardTitle>
<CardDescription className="mt-1">
Team members confirmed to travel to Monaco
{cutoffAt && data.editableNow && (
<>
{' '}
· editable until{' '}
<strong>
{new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(cutoffAt)}
</strong>
</>
)}
{cutoffAt && !data.editableNow && (
<span className="text-muted-foreground inline-flex items-center gap-1">
{' '}
· <AlertTriangle className="h-3 w-3" /> editing closed
</span>
)}
</CardDescription>
</div>
{data.isLead && (
<EditAttendeesDialog
confirmationId={data.confirmation.id}
cap={data.project.program.defaultAttendeeCap}
teamMembers={data.project.teamMembers}
attendingMembers={attendees}
cutoffAt={cutoffAt}
disabled={editDisabled}
disabledReason={editDisabledReason}
/>
)}
</CardHeader>
<CardContent>
{attendees.length === 0 ? (
<p className="text-muted-foreground text-sm">No attendees selected yet.</p>
) : (
<ul className="space-y-2">
{attendees.map((a) => {
const user = userById.get(a.userId)
if (!user) return null
return (
<li
key={a.userId}
className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"
>
<div>
<div className="text-sm font-medium">{user.name ?? user.email}</div>
<div className="text-muted-foreground text-xs">{user.email}</div>
</div>
{a.needsVisa && (
<Badge variant="outline" className="gap-1">
<ShieldCheck className="h-3 w-3" />
Visa support
</Badge>
)}
</li>
)
})}
</ul>
)}
</CardContent>
</Card>
)
}

View File

@@ -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<Set<string>>(new Set())
const [visa, setVisa] = useState<Record<string, boolean>>({})
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 (
<Dialog
open={open}
onOpenChange={(next) => {
if (!edit.isPending) setOpen(next)
}}
>
<DialogTrigger asChild>
<Button variant="outline" size="sm" disabled={disabled} title={disabledReason}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit attendees
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Edit attendees</DialogTitle>
<DialogDescription>
Update who from your team will travel to the grand finale. You can select up to{' '}
<strong>{cap}</strong> team members. Mark anyone who needs visa support so we can prepare
documents in time.
{cutoffAt && (
<>
{' '}
Editable until{' '}
<strong>
{new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(cutoffAt)}
</strong>
.
</>
)}
</DialogDescription>
</DialogHeader>
<ul className="space-y-3 max-h-[50vh] overflow-y-auto pr-1">
{teamMembers.map((tm) => {
const checked = selected.has(tm.userId)
return (
<li key={tm.userId} className="flex items-start justify-between gap-4">
<label className="flex flex-1 items-start gap-3 cursor-pointer">
<Checkbox
checked={checked}
onCheckedChange={(c) => toggle(tm.userId, c === true)}
className="mt-0.5"
/>
<div>
<div className="font-medium">{tm.user.name ?? tm.user.email}</div>
<div className="text-muted-foreground text-xs">
{tm.user.email}
{tm.role && tm.role !== 'MEMBER' ? ` · ${tm.role.toLowerCase()}` : ''}
</div>
</div>
</label>
{checked && (
<label className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Needs visa?</span>
<Switch
checked={!!visa[tm.userId]}
onCheckedChange={(c) =>
setVisa((prev) => ({ ...prev, [tm.userId]: c }))
}
/>
</label>
)}
</li>
)
})}
</ul>
{overCap && (
<p className="text-destructive text-sm">
Please select no more than {cap} member{cap === 1 ? '' : 's'}.
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={edit.isPending}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={overCap || noneSelected || edit.isPending}
>
{edit.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save attendees
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -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,
}
}),
})