feat: admin Visas tab — table + edit dialog + visibility toggle
Activates the previously-disabled Visas tab on /admin/logistics. VisasTab renders a flat table joined per attendee per project, sorted by status priority. Status filter pills mirror the Confirmations tab. The header carries a "Visible to teams" Switch backed by a new logistics.getVisaVisibility query and the existing setVisaVisibility mutation; toggling it controls whether members see their own status. VisaEditDialog is a per-row editor with a status dropdown, nationality input, three native date inputs (invitation / appointment / decision), and a notes textarea. No file uploads — the platform deliberately holds zero document artifacts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
import { ConfirmationsTab } from '@/components/admin/logistics/confirmations-tab'
|
||||
import { TravelTab } from '@/components/admin/logistics/travel-tab'
|
||||
import { HotelsTab } from '@/components/admin/logistics/hotels-tab'
|
||||
import { VisasTab } from '@/components/admin/logistics/visas-tab'
|
||||
|
||||
export default function LogisticsPage() {
|
||||
const { currentEdition } = useEdition()
|
||||
@@ -50,9 +51,8 @@ export default function LogisticsPage() {
|
||||
<TabsTrigger value="hotels">
|
||||
<HotelIcon className="mr-2 h-4 w-4" /> Hotels
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="visas" disabled>
|
||||
<TabsTrigger value="visas">
|
||||
<Stamp className="mr-2 h-4 w-4" /> Visas
|
||||
<span className="text-muted-foreground ml-1 text-xs">(soon)</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="lunch" disabled>
|
||||
<Salad className="mr-2 h-4 w-4" /> Lunch
|
||||
@@ -81,6 +81,9 @@ export default function LogisticsPage() {
|
||||
<TabsContent value="hotels">
|
||||
<HotelsTab programId={programId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="visas">
|
||||
<VisasTab programId={programId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
|
||||
217
src/components/admin/logistics/visa-edit-dialog.tsx
Normal file
217
src/components/admin/logistics/visa-edit-dialog.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { VisaStatus } from '@prisma/client'
|
||||
|
||||
const STATUS_OPTIONS: { value: VisaStatus; label: string }[] = [
|
||||
{ value: 'NOT_NEEDED', label: 'Not needed' },
|
||||
{ value: 'REQUESTED', label: 'Requested' },
|
||||
{ value: 'INVITATION_SENT', label: 'Invitation sent' },
|
||||
{ value: 'APPOINTMENT_BOOKED', label: 'Appointment booked' },
|
||||
{ value: 'GRANTED', label: 'Granted' },
|
||||
{ value: 'DENIED', label: 'Denied' },
|
||||
]
|
||||
|
||||
function toDateInputValue(d: Date | null | undefined): string {
|
||||
if (!d) return ''
|
||||
const dt = new Date(d)
|
||||
if (Number.isNaN(dt.getTime())) return ''
|
||||
// YYYY-MM-DD for <input type="date">
|
||||
return dt.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function fromDateInputValue(s: string): Date | null {
|
||||
if (!s) return null
|
||||
const dt = new Date(s)
|
||||
return Number.isNaN(dt.getTime()) ? null : dt
|
||||
}
|
||||
|
||||
export type VisaEditTarget = {
|
||||
id: string
|
||||
status: VisaStatus
|
||||
nationality: string | null
|
||||
invitationSentAt: Date | null
|
||||
appointmentAt: Date | null
|
||||
decisionAt: Date | null
|
||||
notes: string | null
|
||||
attendeeName: string
|
||||
projectTitle: string
|
||||
}
|
||||
|
||||
export function VisaEditDialog({
|
||||
open,
|
||||
target,
|
||||
programId,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean
|
||||
target: VisaEditTarget | null
|
||||
programId: string
|
||||
onOpenChange: (next: boolean) => void
|
||||
}) {
|
||||
const utils = trpc.useUtils()
|
||||
const [status, setStatus] = useState<VisaStatus>('REQUESTED')
|
||||
const [nationality, setNationality] = useState('')
|
||||
const [invitationSent, setInvitationSent] = useState('')
|
||||
const [appointment, setAppointment] = useState('')
|
||||
const [decision, setDecision] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (target && open) {
|
||||
setStatus(target.status)
|
||||
setNationality(target.nationality ?? '')
|
||||
setInvitationSent(toDateInputValue(target.invitationSentAt))
|
||||
setAppointment(toDateInputValue(target.appointmentAt))
|
||||
setDecision(toDateInputValue(target.decisionAt))
|
||||
setNotes(target.notes ?? '')
|
||||
}
|
||||
}, [target, open])
|
||||
|
||||
const mutation = trpc.logistics.updateVisaApplication.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Visa application updated')
|
||||
utils.logistics.listVisaApplications.invalidate({ programId })
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const handleSave = () => {
|
||||
if (!target) return
|
||||
mutation.mutate({
|
||||
id: target.id,
|
||||
status,
|
||||
nationality: nationality.trim() || null,
|
||||
invitationSentAt: fromDateInputValue(invitationSent),
|
||||
appointmentAt: fromDateInputValue(appointment),
|
||||
decisionAt: fromDateInputValue(decision),
|
||||
notes: notes.trim() || null,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!mutation.isPending) onOpenChange(next)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update visa application</DialogTitle>
|
||||
<DialogDescription>
|
||||
{target
|
||||
? `${target.attendeeName} · ${target.projectTitle}`
|
||||
: 'Loading…'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="visa-status">Status</Label>
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as VisaStatus)}>
|
||||
<SelectTrigger id="visa-status">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="visa-nationality">Nationality</Label>
|
||||
<Input
|
||||
id="visa-nationality"
|
||||
value={nationality}
|
||||
onChange={(e) => setNationality(e.target.value)}
|
||||
placeholder="Self-declared, optional"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="visa-invitation">Invitation sent</Label>
|
||||
<Input
|
||||
id="visa-invitation"
|
||||
type="date"
|
||||
value={invitationSent}
|
||||
onChange={(e) => setInvitationSent(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="visa-appointment">Appointment</Label>
|
||||
<Input
|
||||
id="visa-appointment"
|
||||
type="date"
|
||||
value={appointment}
|
||||
onChange={(e) => setAppointment(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="visa-decision">Decision</Label>
|
||||
<Input
|
||||
id="visa-decision"
|
||||
type="date"
|
||||
value={decision}
|
||||
onChange={(e) => setDecision(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="visa-notes">Notes</Label>
|
||||
<Textarea
|
||||
id="visa-notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Free-text notes — embassy, contact, follow-ups, etc. No documents."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!target || mutation.isPending}>
|
||||
{mutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
268
src/components/admin/logistics/visas-tab.tsx
Normal file
268
src/components/admin/logistics/visas-tab.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { toast } from 'sonner'
|
||||
import { ShieldOff } from 'lucide-react'
|
||||
import { VisaEditDialog, type VisaEditTarget } from './visa-edit-dialog'
|
||||
import type { VisaStatus } from '@prisma/client'
|
||||
|
||||
interface Props {
|
||||
programId: string
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<
|
||||
VisaStatus,
|
||||
{ label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }
|
||||
> = {
|
||||
NOT_NEEDED: { label: 'Not needed', variant: 'outline' },
|
||||
REQUESTED: { label: 'Requested', variant: 'secondary' },
|
||||
INVITATION_SENT: { label: 'Invitation sent', variant: 'secondary' },
|
||||
APPOINTMENT_BOOKED: { label: 'Appointment booked', variant: 'default' },
|
||||
GRANTED: { label: 'Granted', variant: 'default' },
|
||||
DENIED: { label: 'Denied', variant: 'destructive' },
|
||||
}
|
||||
|
||||
type StatusFilter = 'all' | VisaStatus
|
||||
|
||||
function formatDateOnly(d: Date | null | undefined): string {
|
||||
if (!d) return '—'
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(new Date(d))
|
||||
}
|
||||
|
||||
function nextDate(row: {
|
||||
invitationSentAt: Date | null
|
||||
appointmentAt: Date | null
|
||||
decisionAt: Date | null
|
||||
status: VisaStatus
|
||||
}): { label: string; date: Date | null } {
|
||||
if (row.status === 'GRANTED' || row.status === 'DENIED') {
|
||||
return { label: 'Decision', date: row.decisionAt }
|
||||
}
|
||||
if (row.appointmentAt) return { label: 'Appointment', date: row.appointmentAt }
|
||||
if (row.invitationSentAt) return { label: 'Invitation sent', date: row.invitationSentAt }
|
||||
return { label: '—', date: null }
|
||||
}
|
||||
|
||||
export function VisasTab({ programId }: Props) {
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||
const [editTarget, setEditTarget] = useState<VisaEditTarget | null>(null)
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
|
||||
const { data, isLoading } = trpc.logistics.listVisaApplications.useQuery({ programId })
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const { data: visibility } = trpc.logistics.getVisaVisibility.useQuery({ programId })
|
||||
const setVisibility = trpc.logistics.setVisaVisibility.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.logistics.getVisaVisibility.invalidate({ programId })
|
||||
toast.success('Visibility updated')
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!data) return []
|
||||
return statusFilter === 'all'
|
||||
? data
|
||||
: data.filter((r) => r.status === statusFilter)
|
||||
}, [data, statusFilter])
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const counts: Record<VisaStatus, number> = {
|
||||
NOT_NEEDED: 0,
|
||||
REQUESTED: 0,
|
||||
INVITATION_SENT: 0,
|
||||
APPOINTMENT_BOOKED: 0,
|
||||
GRANTED: 0,
|
||||
DENIED: 0,
|
||||
}
|
||||
for (const r of data ?? []) counts[r.status]++
|
||||
return counts
|
||||
}, [data])
|
||||
|
||||
const StatusPill = ({
|
||||
value,
|
||||
label,
|
||||
count,
|
||||
}: {
|
||||
value: StatusFilter
|
||||
label: string
|
||||
count: number
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(value)}
|
||||
className={`rounded-md border px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
statusFilter === value
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
{label} <span className="tabular-nums opacity-80">({count})</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-base">Visa applications</CardTitle>
|
||||
<p className="text-muted-foreground mt-1 max-w-2xl text-xs">
|
||||
Process metadata only — invitation letters, passport copies, and visa decisions
|
||||
continue to flow over email and are never stored on this platform.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
|
||||
<Switch
|
||||
id="visa-visibility"
|
||||
checked={visibility?.visible ?? true}
|
||||
onCheckedChange={(v) =>
|
||||
setVisibility.mutate({ programId, visible: v })
|
||||
}
|
||||
disabled={setVisibility.isPending}
|
||||
/>
|
||||
<Label htmlFor="visa-visibility" className="cursor-pointer text-xs">
|
||||
Visible to teams
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<StatusPill value="all" label="All" count={(data ?? []).length} />
|
||||
<StatusPill value="REQUESTED" label="Requested" count={totals.REQUESTED} />
|
||||
<StatusPill
|
||||
value="INVITATION_SENT"
|
||||
label="Invitation sent"
|
||||
count={totals.INVITATION_SENT}
|
||||
/>
|
||||
<StatusPill
|
||||
value="APPOINTMENT_BOOKED"
|
||||
label="Appointment booked"
|
||||
count={totals.APPOINTMENT_BOOKED}
|
||||
/>
|
||||
<StatusPill value="GRANTED" label="Granted" count={totals.GRANTED} />
|
||||
<StatusPill value="DENIED" label="Denied" count={totals.DENIED} />
|
||||
<StatusPill value="NOT_NEEDED" label="Not needed" count={totals.NOT_NEEDED} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-muted-foreground py-12 text-center text-sm">
|
||||
<ShieldOff className="mx-auto mb-2 h-6 w-6 opacity-60" />
|
||||
{statusFilter === 'all'
|
||||
? 'No visa applications yet. They are auto-created when a team confirms with needsVisa=true.'
|
||||
: 'No applications match this filter.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Member</TableHead>
|
||||
<TableHead>Nationality</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Next date</TableHead>
|
||||
<TableHead>Notes</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((r) => {
|
||||
const badge = STATUS_BADGE[r.status]
|
||||
const next = nextDate(r)
|
||||
return (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-medium">{r.project.title}</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">
|
||||
{r.attendee.user.name ?? r.attendee.user.email}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{r.attendee.user.email}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{r.nationality ?? <span className="text-muted-foreground">—</span>}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={badge.variant} className="text-xs">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{next.date ? (
|
||||
<>
|
||||
<div>{formatDateOnly(next.date)}</div>
|
||||
<div className="text-muted-foreground text-xs">{next.label}</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground max-w-[18rem] truncate text-xs">
|
||||
{r.notes ?? '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditTarget({
|
||||
id: r.id,
|
||||
status: r.status,
|
||||
nationality: r.nationality,
|
||||
invitationSentAt: r.invitationSentAt,
|
||||
appointmentAt: r.appointmentAt,
|
||||
decisionAt: r.decisionAt,
|
||||
notes: r.notes,
|
||||
attendeeName: r.attendee.user.name ?? r.attendee.user.email,
|
||||
projectTitle: r.project.title,
|
||||
})
|
||||
setEditOpen(true)
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<VisaEditDialog
|
||||
open={editOpen}
|
||||
target={editTarget}
|
||||
programId={programId}
|
||||
onOpenChange={setEditOpen}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -316,6 +316,17 @@ export const logisticsRouter = router({
|
||||
return updated
|
||||
}),
|
||||
|
||||
/** Read Program.visaStatusVisibleToMembers — drives the admin Visas tab toggle. */
|
||||
getVisaVisibility: adminProcedure
|
||||
.input(z.object({ programId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const program = await ctx.prisma.program.findUniqueOrThrow({
|
||||
where: { id: input.programId },
|
||||
select: { visaStatusVisibleToMembers: true },
|
||||
})
|
||||
return { visible: program.visaStatusVisibleToMembers }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Flip Program.visaStatusVisibleToMembers. Controls whether the team can
|
||||
* see their own visa status on the applicant dashboard.
|
||||
|
||||
@@ -113,7 +113,11 @@ describe('logistics.listVisaApplications', () => {
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
// REQUESTED (0) → APPOINTMENT_BOOKED (2) → GRANTED (3)
|
||||
expect(result.map((r) => r.status)).toEqual(['REQUESTED', 'APPOINTMENT_BOOKED', 'GRANTED'])
|
||||
expect(result.map((r: (typeof result)[number]) => r.status)).toEqual([
|
||||
'REQUESTED',
|
||||
'APPOINTMENT_BOOKED',
|
||||
'GRANTED',
|
||||
])
|
||||
expect(result[0].id).toBe(app.id)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user