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:
Matt
2026-04-28 19:37:55 +02:00
parent 7c86e42413
commit fe630e0e2d
5 changed files with 506 additions and 3 deletions

View File

@@ -16,6 +16,7 @@ import {
import { ConfirmationsTab } from '@/components/admin/logistics/confirmations-tab' import { ConfirmationsTab } from '@/components/admin/logistics/confirmations-tab'
import { TravelTab } from '@/components/admin/logistics/travel-tab' import { TravelTab } from '@/components/admin/logistics/travel-tab'
import { HotelsTab } from '@/components/admin/logistics/hotels-tab' import { HotelsTab } from '@/components/admin/logistics/hotels-tab'
import { VisasTab } from '@/components/admin/logistics/visas-tab'
export default function LogisticsPage() { export default function LogisticsPage() {
const { currentEdition } = useEdition() const { currentEdition } = useEdition()
@@ -50,9 +51,8 @@ export default function LogisticsPage() {
<TabsTrigger value="hotels"> <TabsTrigger value="hotels">
<HotelIcon className="mr-2 h-4 w-4" /> Hotels <HotelIcon className="mr-2 h-4 w-4" /> Hotels
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="visas" disabled> <TabsTrigger value="visas">
<Stamp className="mr-2 h-4 w-4" /> Visas <Stamp className="mr-2 h-4 w-4" /> Visas
<span className="text-muted-foreground ml-1 text-xs">(soon)</span>
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="lunch" disabled> <TabsTrigger value="lunch" disabled>
<Salad className="mr-2 h-4 w-4" /> Lunch <Salad className="mr-2 h-4 w-4" /> Lunch
@@ -81,6 +81,9 @@ export default function LogisticsPage() {
<TabsContent value="hotels"> <TabsContent value="hotels">
<HotelsTab programId={programId} /> <HotelsTab programId={programId} />
</TabsContent> </TabsContent>
<TabsContent value="visas">
<VisasTab programId={programId} />
</TabsContent>
</Tabs> </Tabs>
</div> </div>
) )

View 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>
)
}

View 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>
)
}

View File

@@ -316,6 +316,17 @@ export const logisticsRouter = router({
return updated 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 * Flip Program.visaStatusVisibleToMembers. Controls whether the team can
* see their own visa status on the applicant dashboard. * see their own visa status on the applicant dashboard.

View File

@@ -113,7 +113,11 @@ describe('logistics.listVisaApplications', () => {
expect(result).toHaveLength(3) expect(result).toHaveLength(3)
// REQUESTED (0) → APPOINTMENT_BOOKED (2) → GRANTED (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) expect(result[0].id).toBe(app.id)
}) })
}) })