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:
@@ -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} />
|
||||
|
||||
|
||||
118
src/components/applicant/attending-members-card.tsx
Normal file
118
src/components/applicant/attending-members-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
182
src/components/applicant/edit-attendees-dialog.tsx
Normal file
182
src/components/applicant/edit-attendees-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user