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 { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { PlaneTakeoff, ShieldCheck, AlertTriangle } from 'lucide-react'
|
import { PlaneTakeoff, ShieldCheck, AlertTriangle } from 'lucide-react'
|
||||||
import { EditAttendeesDialog } from './edit-attendees-dialog'
|
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() {
|
export function AttendingMembersCard() {
|
||||||
const { data, isLoading } = trpc.applicant.getMyFinalistConfirmation.useQuery()
|
const { data, isLoading } = trpc.applicant.getMyFinalistConfirmation.useQuery()
|
||||||
|
const { data: myVisas } = trpc.applicant.getMyVisaApplications.useQuery()
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -34,6 +67,9 @@ export function AttendingMembersCard() {
|
|||||||
const cutoffAt = data.cutoffAt ? new Date(data.cutoffAt) : null
|
const cutoffAt = data.cutoffAt ? new Date(data.cutoffAt) : null
|
||||||
const userById = new Map(data.project.teamMembers.map((tm) => [tm.userId, tm.user]))
|
const userById = new Map(data.project.teamMembers.map((tm) => [tm.userId, tm.user]))
|
||||||
const attendees = data.confirmation.attendingMembers
|
const attendees = data.confirmation.attendingMembers
|
||||||
|
const visaByUser = new Map(
|
||||||
|
(myVisas ?? []).map((v) => [v.userId, v] as const),
|
||||||
|
)
|
||||||
|
|
||||||
const editDisabled = !data.editableNow
|
const editDisabled = !data.editableNow
|
||||||
const editDisabledReason = !data.editableNow
|
const editDisabledReason = !data.editableNow
|
||||||
@@ -92,6 +128,9 @@ export function AttendingMembersCard() {
|
|||||||
{attendees.map((a) => {
|
{attendees.map((a) => {
|
||||||
const user = userById.get(a.userId)
|
const user = userById.get(a.userId)
|
||||||
if (!user) return null
|
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 (
|
return (
|
||||||
<li
|
<li
|
||||||
key={a.userId}
|
key={a.userId}
|
||||||
@@ -101,12 +140,28 @@ export function AttendingMembersCard() {
|
|||||||
<div className="text-sm font-medium">{user.name ?? user.email}</div>
|
<div className="text-sm font-medium">{user.name ?? user.email}</div>
|
||||||
<div className="text-muted-foreground text-xs">{user.email}</div>
|
<div className="text-muted-foreground text-xs">{user.email}</div>
|
||||||
</div>
|
</div>
|
||||||
{a.needsVisa && (
|
<div className="flex flex-col items-end gap-1">
|
||||||
<Badge variant="outline" className="gap-1">
|
{visa && visaBadge ? (
|
||||||
<ShieldCheck className="h-3 w-3" />
|
<>
|
||||||
Visa support
|
<Badge variant={visaBadge.variant} className="gap-1">
|
||||||
</Badge>
|
<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>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -2814,6 +2814,7 @@ export const applicantRouter = router({
|
|||||||
.map((a) => ({
|
.map((a) => ({
|
||||||
id: a.visaApplication!.id,
|
id: a.visaApplication!.id,
|
||||||
attendingMemberId: a.id,
|
attendingMemberId: a.id,
|
||||||
|
userId: a.userId,
|
||||||
status: a.visaApplication!.status,
|
status: a.visaApplication!.status,
|
||||||
nationality: a.visaApplication!.nationality,
|
nationality: a.visaApplication!.nationality,
|
||||||
invitationSentAt: a.visaApplication!.invitationSentAt,
|
invitationSentAt: a.visaApplication!.invitationSentAt,
|
||||||
|
|||||||
Reference in New Issue
Block a user