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.
2026-04-28 18:25:29 +02:00
|
|
|
'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'
|
2026-04-28 19:03:01 +02:00
|
|
|
import { Button } from '@/components/ui/button'
|
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.
2026-04-28 18:25:29 +02:00
|
|
|
import { formatEnumLabel } from '@/lib/utils'
|
|
|
|
|
import type { FinalistConfirmationStatus } from '@prisma/client'
|
2026-04-28 19:03:01 +02:00
|
|
|
import { AdminAttendanceDialog, type AttendanceMode } from './admin-attendance-dialog'
|
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.
2026-04-28 18:25:29 +02:00
|
|
|
|
|
|
|
|
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<StatusFilter>('all')
|
2026-04-28 19:03:01 +02:00
|
|
|
const [dialogState, setDialogState] = useState<{
|
|
|
|
|
open: boolean
|
|
|
|
|
mode: AttendanceMode
|
|
|
|
|
confirmationId: string | null
|
|
|
|
|
}>({ open: false, mode: 'confirm', confirmationId: null })
|
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.
2026-04-28 18:25:29 +02:00
|
|
|
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<FinalistConfirmationStatus, number> = {
|
|
|
|
|
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 }) => (
|
|
|
|
|
<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-center justify-between gap-4">
|
|
|
|
|
<CardTitle className="text-base">All confirmations</CardTitle>
|
|
|
|
|
<div className="flex flex-wrap gap-1.5">
|
|
|
|
|
<StatusPill
|
|
|
|
|
value="all"
|
|
|
|
|
label="All"
|
|
|
|
|
count={(data ?? []).length}
|
|
|
|
|
/>
|
|
|
|
|
<StatusPill value="PENDING" label="Pending" count={totals.PENDING} />
|
|
|
|
|
<StatusPill value="CONFIRMED" label="Confirmed" count={totals.CONFIRMED} />
|
|
|
|
|
<StatusPill value="DECLINED" label="Declined" count={totals.DECLINED} />
|
|
|
|
|
<StatusPill value="EXPIRED" label="Expired" count={totals.EXPIRED} />
|
|
|
|
|
<StatusPill value="SUPERSEDED" label="Superseded" count={totals.SUPERSEDED} />
|
|
|
|
|
</div>
|
|
|
|
|
</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 ? (
|
|
|
|
|
<p className="text-muted-foreground py-12 text-center text-sm">
|
|
|
|
|
{statusFilter === 'all'
|
|
|
|
|
? 'No finalists have been selected yet. Use the grand-finale round page to send confirmations.'
|
|
|
|
|
: 'No confirmations match this filter.'}
|
|
|
|
|
</p>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="overflow-hidden rounded-md border">
|
|
|
|
|
<Table>
|
|
|
|
|
<TableHeader>
|
|
|
|
|
<TableRow>
|
|
|
|
|
<TableHead>Project</TableHead>
|
|
|
|
|
<TableHead>Status</TableHead>
|
|
|
|
|
<TableHead>Deadline</TableHead>
|
|
|
|
|
<TableHead className="text-right">Attendees</TableHead>
|
|
|
|
|
<TableHead>Notes</TableHead>
|
2026-04-28 19:03:01 +02:00
|
|
|
<TableHead className="text-right">Actions</TableHead>
|
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.
2026-04-28 18:25:29 +02:00
|
|
|
</TableRow>
|
|
|
|
|
</TableHeader>
|
|
|
|
|
<TableBody>
|
|
|
|
|
{filtered.map((r) => {
|
|
|
|
|
const badge = STATUS_BADGE[r.status]
|
2026-04-28 19:03:01 +02:00
|
|
|
const isPending = r.status === 'PENDING'
|
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.
2026-04-28 18:25:29 +02:00
|
|
|
return (
|
|
|
|
|
<TableRow key={r.id}>
|
|
|
|
|
<TableCell>
|
|
|
|
|
<div className="font-medium">{r.project.title}</div>
|
|
|
|
|
<div className="text-muted-foreground text-xs">
|
|
|
|
|
{formatEnumLabel(r.category)}
|
|
|
|
|
{r.project.country && (
|
|
|
|
|
<>
|
|
|
|
|
{' · '}
|
|
|
|
|
{r.project.country}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell>
|
|
|
|
|
<Badge variant={badge.variant} className="text-xs">
|
|
|
|
|
{badge.label}
|
|
|
|
|
</Badge>
|
|
|
|
|
{r.promotedFromWaitlistEntryId && (
|
|
|
|
|
<Badge variant="outline" className="ml-1 text-xs">
|
|
|
|
|
Waitlist
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell className="text-sm">
|
|
|
|
|
<div>{formatDeadline(new Date(r.deadline))}</div>
|
|
|
|
|
{r.status === 'PENDING' && (
|
|
|
|
|
<div className="text-muted-foreground text-xs">
|
|
|
|
|
{relativeFromNow(new Date(r.deadline))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell className="text-right tabular-nums">
|
|
|
|
|
{r.attendeeCount}
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell className="text-muted-foreground max-w-[20rem] truncate text-xs">
|
|
|
|
|
{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))}`
|
|
|
|
|
: '—'}
|
|
|
|
|
</TableCell>
|
2026-04-28 19:03:01 +02:00
|
|
|
<TableCell className="text-right">
|
|
|
|
|
{isPending ? (
|
|
|
|
|
<div className="flex justify-end gap-2">
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="default"
|
|
|
|
|
onClick={() =>
|
|
|
|
|
setDialogState({
|
|
|
|
|
open: true,
|
|
|
|
|
mode: 'confirm',
|
|
|
|
|
confirmationId: r.id,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
Confirm
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() =>
|
|
|
|
|
setDialogState({
|
|
|
|
|
open: true,
|
|
|
|
|
mode: 'decline',
|
|
|
|
|
confirmationId: r.id,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
Decline
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-muted-foreground text-xs">—</span>
|
|
|
|
|
)}
|
|
|
|
|
</TableCell>
|
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.
2026-04-28 18:25:29 +02:00
|
|
|
</TableRow>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</TableBody>
|
|
|
|
|
</Table>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
2026-04-28 19:03:01 +02:00
|
|
|
|
|
|
|
|
<AdminAttendanceDialog
|
|
|
|
|
open={dialogState.open}
|
|
|
|
|
mode={dialogState.mode}
|
|
|
|
|
confirmationId={dialogState.confirmationId}
|
|
|
|
|
programId={programId}
|
|
|
|
|
onOpenChange={(next) =>
|
|
|
|
|
setDialogState((prev) => ({ ...prev, open: next }))
|
|
|
|
|
}
|
|
|
|
|
/>
|
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.
2026-04-28 18:25:29 +02:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|