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 { CompetitionTimelineSidebar } from '@/components/applicant/competition-timeline'
|
||||||
import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card'
|
import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card'
|
||||||
import { MentorConversationCard } from '@/components/applicant/mentor-conversation-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 { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
import { ProjectLogoUpload } from '@/components/shared/project-logo-upload'
|
import { ProjectLogoUpload } from '@/components/shared/project-logo-upload'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
@@ -402,6 +403,9 @@ export default function ApplicantDashboardPage() {
|
|||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Grand finale attendee roster (auto-hides until confirmation status is CONFIRMED) */}
|
||||||
|
<AttendingMembersCard />
|
||||||
|
|
||||||
{/* Conversation with assigned mentor (auto-hides when no mentor assigned) */}
|
{/* Conversation with assigned mentor (auto-hides when no mentor assigned) */}
|
||||||
<MentorConversationCard projectId={project.id} />
|
<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,
|
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