feat: render visa status + next date on AttendingMembersCard

Each member now sees their own visa status (status badge + next
upcoming date) on the applicant dashboard, sourced from
applicant.getMyVisaApplications. Other teammates' rows still show the
generic "Visa support" badge if they need a visa, since the platform
deliberately scopes visa visibility to the caller. The whole visa
surface auto-hides if the program toggle is off.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-28 19:40:25 +02:00
parent fe630e0e2d
commit 46a78c3a74
2 changed files with 62 additions and 6 deletions

View File

@@ -12,9 +12,42 @@ 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'
import type { VisaStatus } from '@prisma/client'
const VISA_BADGE: Record<
VisaStatus,
{ label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }
> = {
NOT_NEEDED: { label: 'Visa not needed', variant: 'outline' },
REQUESTED: { label: 'Visa requested', variant: 'secondary' },
INVITATION_SENT: { label: 'Invitation sent', variant: 'secondary' },
APPOINTMENT_BOOKED: { label: 'Appointment booked', variant: 'default' },
GRANTED: { label: 'Visa granted', variant: 'default' },
DENIED: { label: 'Visa denied', variant: 'destructive' },
}
function formatDateOnly(d: Date | string): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(new Date(d))
}
function nextVisaDate(v: {
invitationSentAt: Date | string | null
appointmentAt: Date | string | null
decisionAt: Date | string | null
status: VisaStatus
}): { label: string; date: Date | string } | null {
if (v.status === 'GRANTED' || v.status === 'DENIED') {
if (v.decisionAt) return { label: 'Decision', date: v.decisionAt }
return null
}
if (v.appointmentAt) return { label: 'Appointment', date: v.appointmentAt }
if (v.invitationSentAt) return { label: 'Invitation sent', date: v.invitationSentAt }
return null
}
export function AttendingMembersCard() {
const { data, isLoading } = trpc.applicant.getMyFinalistConfirmation.useQuery()
const { data: myVisas } = trpc.applicant.getMyVisaApplications.useQuery()
if (isLoading) {
return (
@@ -34,6 +67,9 @@ export function AttendingMembersCard() {
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 visaByUser = new Map(
(myVisas ?? []).map((v) => [v.userId, v] as const),
)
const editDisabled = !data.editableNow
const editDisabledReason = !data.editableNow
@@ -92,6 +128,9 @@ export function AttendingMembersCard() {
{attendees.map((a) => {
const user = userById.get(a.userId)
if (!user) return null
const visa = visaByUser.get(a.userId)
const visaBadge = visa ? VISA_BADGE[visa.status] : null
const next = visa ? nextVisaDate(visa) : null
return (
<li
key={a.userId}
@@ -101,12 +140,28 @@ export function AttendingMembersCard() {
<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>
)}
<div className="flex flex-col items-end gap-1">
{visa && visaBadge ? (
<>
<Badge variant={visaBadge.variant} className="gap-1">
<ShieldCheck className="h-3 w-3" />
{visaBadge.label}
</Badge>
{next && (
<span className="text-muted-foreground text-xs">
{next.label}: {formatDateOnly(next.date)}
</span>
)}
</>
) : (
a.needsVisa && (
<Badge variant="outline" className="gap-1">
<ShieldCheck className="h-3 w-3" />
Visa support
</Badge>
)
)}
</div>
</li>
)
})}

View File

@@ -2814,6 +2814,7 @@ export const applicantRouter = router({
.map((a) => ({
id: a.visaApplication!.id,
attendingMemberId: a.id,
userId: a.userId,
status: a.visaApplication!.status,
nationality: a.visaApplication!.nationality,
invitationSentAt: a.visaApplication!.invitationSentAt,