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:
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user