'use client' import { useEffect, useMemo, useState } from 'react' import { trpc } from '@/lib/trpc/client' import { toast } from 'sonner' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' import { Skeleton } from '@/components/ui/skeleton' import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, } from '@/components/ui/sheet' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { Loader2, Plane } from 'lucide-react' import type { FlightDetailStatus } from '@prisma/client' interface Props { programId: string } type AttendeeRow = { id: string needsVisa: boolean user: { id: string; name: string | null; email: string; country: string | null } confirmation: { project: { id: string title: string country: string | null competitionCategory: string | null } } flightDetail: { id: string arrivalAt: Date | null arrivalFlightNumber: string | null arrivalAirport: string | null departureAt: Date | null departureFlightNumber: string | null departureAirport: string | null status: FlightDetailStatus adminNotes: string | null } | null } type StatusFilter = 'all' | 'PENDING' | 'CONFIRMED' | 'unfilled' function formatDateTime(d: Date | null): string { if (!d) return '—' return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short', }).format(new Date(d)) } function isoLocalForInput(d: Date | null): string { if (!d) return '' // Format as 'YYYY-MM-DDTHH:mm' for datetime-local input const local = new Date(d.getTime() - new Date().getTimezoneOffset() * 60000) return local.toISOString().slice(0, 16) } function FlightEditorSheet({ attendee, programId, open, onClose, }: { attendee: AttendeeRow | null programId: string open: boolean onClose: () => void }) { const utils = trpc.useUtils() const [arrivalAt, setArrivalAt] = useState('') const [arrivalFlightNumber, setArrivalFlightNumber] = useState('') const [arrivalAirport, setArrivalAirport] = useState('') const [departureAt, setDepartureAt] = useState('') const [departureFlightNumber, setDepartureFlightNumber] = useState('') const [departureAirport, setDepartureAirport] = useState('') const [adminNotes, setAdminNotes] = useState('') useEffect(() => { if (!attendee) return const fd = attendee.flightDetail setArrivalAt(isoLocalForInput(fd?.arrivalAt ?? null)) setArrivalFlightNumber(fd?.arrivalFlightNumber ?? '') setArrivalAirport(fd?.arrivalAirport ?? '') setDepartureAt(isoLocalForInput(fd?.departureAt ?? null)) setDepartureFlightNumber(fd?.departureFlightNumber ?? '') setDepartureAirport(fd?.departureAirport ?? '') setAdminNotes(fd?.adminNotes ?? '') }, [attendee]) const upsertMutation = trpc.logistics.upsertFlightDetail.useMutation({ onSuccess: () => { toast.success('Flight details saved') utils.logistics.listFlightDetails.invalidate({ programId }) onClose() }, onError: (err) => toast.error(err.message), }) if (!attendee) return null const handleSave = () => { upsertMutation.mutate({ attendingMemberId: attendee.id, arrivalAt: arrivalAt ? new Date(arrivalAt) : null, arrivalFlightNumber: arrivalFlightNumber.trim() || null, arrivalAirport: arrivalAirport.trim().toUpperCase() || null, departureAt: departureAt ? new Date(departureAt) : null, departureFlightNumber: departureFlightNumber.trim() || null, departureAirport: departureAirport.trim().toUpperCase() || null, adminNotes: adminNotes.trim() || null, }) } return ( !o && onClose()}> {attendee.user.name ?? attendee.user.email} {attendee.confirmation.project.title} Arrival Date & time setArrivalAt(e.target.value)} /> Flight number setArrivalFlightNumber(e.target.value)} placeholder="AF7400" /> Airport (IATA) setArrivalAirport(e.target.value)} placeholder="NCE" /> Departure Date & time setDepartureAt(e.target.value)} /> Flight number setDepartureFlightNumber(e.target.value)} placeholder="AF7405" /> Airport (IATA) setDepartureAirport(e.target.value)} placeholder="NCE" /> Admin notes setAdminNotes(e.target.value)} placeholder="e.g. paid by program, awaiting receipt" rows={3} /> Cancel {upsertMutation.isPending ? ( ) : null} Save ) } export function TravelTab({ programId }: Props) { const utils = trpc.useUtils() const [statusFilter, setStatusFilter] = useState('all') const [editing, setEditing] = useState(null) const { data, isLoading } = trpc.logistics.listFlightDetails.useQuery( { programId }, { refetchInterval: 60_000 }, ) const setStatusMutation = trpc.logistics.setFlightStatus.useMutation({ onSuccess: () => { toast.success('Status updated') utils.logistics.listFlightDetails.invalidate({ programId }) }, onError: (err) => toast.error(err.message), }) const filtered = useMemo(() => { if (!data) return [] if (statusFilter === 'all') return data if (statusFilter === 'unfilled') return data.filter((r) => !r.flightDetail) return data.filter((r) => r.flightDetail?.status === statusFilter) }, [data, statusFilter]) const totals = useMemo(() => { const c = { all: 0, PENDING: 0, CONFIRMED: 0, unfilled: 0 } for (const r of data ?? []) { c.all++ if (!r.flightDetail) c.unfilled++ else c[r.flightDetail.status]++ } return c }, [data]) const StatusPill = ({ value, label, count, }: { value: StatusFilter label: string count: number }) => ( 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} ({count}) ) return ( Travel for confirmed finalists {isLoading ? ( {[1, 2, 3].map((i) => ( ))} ) : filtered.length === 0 ? ( {data && data.length === 0 ? 'No confirmed finalist attendees yet.' : 'No attendees match this filter.'} ) : ( Attendee Arrival Departure Status {filtered.map((r) => { const fd = r.flightDetail return ( {r.user.name ?? r.user.email} {r.confirmation.project.title} {r.needsVisa ? ' · needs visa' : ''} {formatDateTime(fd?.arrivalAt ?? null)} {(fd?.arrivalFlightNumber || fd?.arrivalAirport) && ( {fd.arrivalFlightNumber ?? '—'} {fd.arrivalAirport ? ` · ${fd.arrivalAirport}` : ''} )} {formatDateTime(fd?.departureAt ?? null)} {(fd?.departureFlightNumber || fd?.departureAirport) && ( {fd.departureFlightNumber ?? '—'} {fd.departureAirport ? ` · ${fd.departureAirport}` : ''} )} {fd ? ( setStatusMutation.mutate({ flightDetailId: fd.id, status: fd.status === 'PENDING' ? 'CONFIRMED' : 'PENDING', }) } className="cursor-pointer" title="Click to toggle" > {fd.status === 'CONFIRMED' ? 'Confirmed' : 'Pending'} ) : ( No info )} setEditing(r as AttendeeRow)} > Edit ) })} )} setEditing(null)} /> ) }
{data && data.length === 0 ? 'No confirmed finalist attendees yet.' : 'No attendees match this filter.'}