diff --git a/src/app/(admin)/admin/logistics/page.tsx b/src/app/(admin)/admin/logistics/page.tsx
new file mode 100644
index 0000000..697dcf5
--- /dev/null
+++ b/src/app/(admin)/admin/logistics/page.tsx
@@ -0,0 +1,87 @@
+'use client'
+
+import { useState } from 'react'
+import { useEdition } from '@/contexts/edition-context'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import {
+ CheckCircle2,
+ FileText,
+ Hotel as HotelIcon,
+ Plane,
+ Salad,
+ ScrollText,
+ Settings,
+ Stamp,
+} from 'lucide-react'
+import { ConfirmationsTab } from '@/components/admin/logistics/confirmations-tab'
+import { TravelTab } from '@/components/admin/logistics/travel-tab'
+import { HotelsTab } from '@/components/admin/logistics/hotels-tab'
+
+export default function LogisticsPage() {
+ const { currentEdition } = useEdition()
+ const [tab, setTab] = useState('confirmations')
+
+ if (!currentEdition) {
+ return (
+
+ Select an edition to view logistics.
+
+ )
+ }
+ const programId = currentEdition.id
+
+ return (
+
+
+
Logistics
+
+ Operational hub for the grand finale: confirmations, travel, hotels, and more.
+
+
+
+
+
+
+ Confirmations
+
+
+ Travel
+
+
+ Hotels
+
+
+ Visas
+ (soon)
+
+
+ Lunch
+ (soon)
+
+
+ Documents
+ (soon)
+
+
+ Email Templates
+ (soon)
+
+
+ Settings
+ (soon)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/admin/logistics/confirmations-tab.tsx b/src/components/admin/logistics/confirmations-tab.tsx
new file mode 100644
index 0000000..136b673
--- /dev/null
+++ b/src/components/admin/logistics/confirmations-tab.tsx
@@ -0,0 +1,193 @@
+'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 { Skeleton } from '@/components/ui/skeleton'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { formatEnumLabel } from '@/lib/utils'
+import type { FinalistConfirmationStatus } from '@prisma/client'
+
+interface Props {
+ programId: string
+}
+
+type StatusFilter = 'all' | FinalistConfirmationStatus
+
+const STATUS_BADGE: Record<
+ FinalistConfirmationStatus,
+ { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }
+> = {
+ PENDING: { label: 'Pending', variant: 'secondary' },
+ CONFIRMED: { label: 'Confirmed', variant: 'default' },
+ DECLINED: { label: 'Declined', variant: 'destructive' },
+ EXPIRED: { label: 'Expired', variant: 'outline' },
+ SUPERSEDED: { label: 'Superseded', variant: 'outline' },
+}
+
+function formatDeadline(d: Date): string {
+ return new Intl.DateTimeFormat(undefined, {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+ }).format(d)
+}
+
+function relativeFromNow(d: Date): string {
+ const ms = d.getTime() - Date.now()
+ if (ms <= 0) return 'past deadline'
+ const hours = Math.floor(ms / 3_600_000)
+ const days = Math.floor(hours / 24)
+ if (days >= 1) return `in ${days}d`
+ return `in ${hours}h`
+}
+
+export function ConfirmationsTab({ programId }: Props) {
+ const [statusFilter, setStatusFilter] = useState('all')
+ const { data, isLoading } = trpc.logistics.listConfirmations.useQuery(
+ { programId },
+ { refetchInterval: 60_000 },
+ )
+
+ const filtered = useMemo(() => {
+ if (!data) return []
+ return statusFilter === 'all' ? data : data.filter((r) => r.status === statusFilter)
+ }, [data, statusFilter])
+
+ const totals = useMemo(() => {
+ const counts: Record = {
+ PENDING: 0,
+ CONFIRMED: 0,
+ DECLINED: 0,
+ EXPIRED: 0,
+ SUPERSEDED: 0,
+ }
+ for (const r of data ?? []) counts[r.status]++
+ return counts
+ }, [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 (
+
+
+
+
+
All confirmations
+
+
+
+
+
+
+
+
+
+
+
+ {isLoading ? (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ ) : filtered.length === 0 ? (
+
+ {statusFilter === 'all'
+ ? 'No finalists have been selected yet. Use the grand-finale round page to send confirmations.'
+ : 'No confirmations match this filter.'}
+
+ ) : (
+
+
+
+
+ Project
+ Status
+ Deadline
+ Attendees
+ Notes
+
+
+
+ {filtered.map((r) => {
+ const badge = STATUS_BADGE[r.status]
+ return (
+
+
+ {r.project.title}
+
+ {formatEnumLabel(r.category)}
+ {r.project.country && (
+ <>
+ {' · '}
+ {r.project.country}
+ >
+ )}
+
+
+
+
+ {badge.label}
+
+ {r.promotedFromWaitlistEntryId && (
+
+ Waitlist
+
+ )}
+
+
+ {formatDeadline(new Date(r.deadline))}
+ {r.status === 'PENDING' && (
+
+ {relativeFromNow(new Date(r.deadline))}
+
+ )}
+
+
+ {r.attendeeCount}
+
+
+ {r.status === 'DECLINED' && r.declineReason
+ ? `Reason: ${r.declineReason}`
+ : r.status === 'CONFIRMED' && r.confirmedAt
+ ? `Confirmed ${formatDeadline(new Date(r.confirmedAt))}`
+ : r.status === 'EXPIRED' && r.expiredAt
+ ? `Expired ${formatDeadline(new Date(r.expiredAt))}`
+ : '—'}
+
+
+ )
+ })}
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/src/components/admin/logistics/hotels-tab.tsx b/src/components/admin/logistics/hotels-tab.tsx
new file mode 100644
index 0000000..e19458c
--- /dev/null
+++ b/src/components/admin/logistics/hotels-tab.tsx
@@ -0,0 +1,175 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { trpc } from '@/lib/trpc/client'
+import { toast } from 'sonner'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+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 { Skeleton } from '@/components/ui/skeleton'
+import { ExternalLink, Hotel as HotelIcon, Loader2, Save } from 'lucide-react'
+
+interface Props {
+ programId: string
+}
+
+export function HotelsTab({ programId }: Props) {
+ const utils = trpc.useUtils()
+ const { data: hotel, isLoading } = trpc.logistics.getHotel.useQuery({ programId })
+
+ const [name, setName] = useState('')
+ const [address, setAddress] = useState('')
+ const [link, setLink] = useState('')
+ const [notes, setNotes] = useState('')
+
+ // Sync form state from server data on first load / after save.
+ useEffect(() => {
+ if (hotel) {
+ setName(hotel.name)
+ setAddress(hotel.address ?? '')
+ setLink(hotel.link ?? '')
+ setNotes(hotel.notes ?? '')
+ }
+ }, [hotel])
+
+ const upsertMutation = trpc.logistics.upsertHotel.useMutation({
+ onSuccess: () => {
+ toast.success('Hotel saved')
+ utils.logistics.getHotel.invalidate({ programId })
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ const handleSave = () => {
+ if (!name.trim()) {
+ toast.error('Hotel name is required')
+ return
+ }
+ upsertMutation.mutate({
+ programId,
+ name: name.trim(),
+ address: address.trim() || undefined,
+ link: link.trim() || '',
+ notes: notes.trim() || undefined,
+ })
+ }
+
+ if (isLoading) return
+
+ const dirty =
+ name !== (hotel?.name ?? '') ||
+ address !== (hotel?.address ?? '') ||
+ link !== (hotel?.link ?? '') ||
+ notes !== (hotel?.notes ?? '')
+
+ return (
+
+
+
+
+
+
+ Hotel for this edition
+
+
+ One hotel per edition. Used in confirmation emails and finalist communications.
+
+
+
+
+ Name *
+ setName(e.target.value)}
+ placeholder="Hôtel de Paris"
+ required
+ />
+
+
+ Address
+
+
+ Hotel website / booking link
+ setLink(e.target.value)}
+ placeholder="https://hoteldeparismontecarlo.com"
+ />
+
+
+ Internal notes
+
+
+
+ {upsertMutation.isPending ? (
+
+ ) : (
+
+ )}
+ Save
+
+
+
+
+
+
+
+
+
+ Email preview
+ What teams will see in confirmation emails.
+
+
+ {!name.trim() ? (
+ Save a hotel to see the preview.
+ ) : (
+
+
+ Your accommodation
+
+
{name}
+ {address.trim() && (
+
+ {address}
+
+ )}
+ {link.trim() && (
+
+ Visit hotel website
+
+ )}
+
+ )}
+
+
+
+
+ )
+}
diff --git a/src/components/admin/logistics/travel-tab.tsx b/src/components/admin/logistics/travel-tab.tsx
new file mode 100644
index 0000000..188f294
--- /dev/null
+++ b/src/components/admin/logistics/travel-tab.tsx
@@ -0,0 +1,426 @@
+'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)}
+ />
+
+
+
+
+
+
+ Departure
+
+
+ Date & time
+ setDepartureAt(e.target.value)}
+ />
+
+
+
+
+
+ Admin notes
+
+
+
+
+
+ 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)}
+ />
+
+ )
+}
diff --git a/src/components/layouts/admin-sidebar.tsx b/src/components/layouts/admin-sidebar.tsx
index 8d1f999..0249a04 100644
--- a/src/components/layouts/admin-sidebar.tsx
+++ b/src/components/layouts/admin-sidebar.tsx
@@ -33,6 +33,7 @@ import {
GraduationCap,
Handshake,
History,
+ Plane,
Trophy,
User,
MessageSquare,
@@ -91,6 +92,11 @@ const navigation: NavItem[] = [
href: '/admin/mentors',
icon: GraduationCap,
},
+ {
+ name: 'Logistics',
+ href: '/admin/logistics',
+ icon: Plane,
+ },
{
name: 'Awards',
href: '/admin/awards',