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