From 57ec28edad79ea3173166184a6a6836ff40f0002 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 28 Apr 2026 18:25:29 +0200 Subject: [PATCH] feat: logistics page shell + Confirmations/Travel/Hotels tabs - /admin/logistics page with shadcn Tabs (3 active + 5 disabled "(soon)" placeholder tabs for Visas / Lunch / Documents / Email Templates / Settings). - Sidebar entry "Logistics" between Mentors and Awards (Plane icon). - Confirmations tab: read-only table with status filter pills, browser- local-time deadline display, attendee count, decline reason snippet. - Hotels tab: single-hotel form (name/address/link/notes) with live email-preview card showing what teams will see. - Travel tab: per-attendee flight tracker with arrival/departure datetimes, flight numbers, IATA airports, click-to-toggle status badge, edit Sheet, and unfilled/pending/confirmed filter pills. Smoke-tested end-to-end: navigation, sidebar entry, all three tabs render, hotel save persists to DB and renders in preview card. --- src/app/(admin)/admin/logistics/page.tsx | 87 ++++ .../admin/logistics/confirmations-tab.tsx | 193 ++++++++ src/components/admin/logistics/hotels-tab.tsx | 175 +++++++ src/components/admin/logistics/travel-tab.tsx | 426 ++++++++++++++++++ src/components/layouts/admin-sidebar.tsx | 6 + 5 files changed, 887 insertions(+) create mode 100644 src/app/(admin)/admin/logistics/page.tsx create mode 100644 src/components/admin/logistics/confirmations-tab.tsx create mode 100644 src/components/admin/logistics/hotels-tab.tsx create mode 100644 src/components/admin/logistics/travel-tab.tsx 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 }) => ( + + ) + + 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. + +
+ +
+ + setName(e.target.value)} + placeholder="Hôtel de Paris" + required + /> +
+
+ +