feat(logistics): Hotels tab — multi-hotel management + rooming assignment

Rewrites hotels-tab.tsx from the removed single-hotel getHotel/upsertHotel
pattern to the new multi-hotel API: Hotels section (list/add/edit/delete with
occupancy badge) + Rooming section (per-attendee hotel+room+dates assignment,
team-assign shortcut, CSV export).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-06-04 19:24:06 +02:00
parent 9313eb96f0
commit 3bbc80332c

View File

@@ -1,83 +1,199 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { ExternalLink, Hotel as HotelIcon, Loader2, Save } from 'lucide-react' import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Download, ExternalLink, Hotel as HotelIcon, Loader2, Plus, Trash2, Pencil } from 'lucide-react'
interface Props { interface Props {
programId: string programId: string
} }
export function HotelsTab({ programId }: Props) { // ─── Types ────────────────────────────────────────────────────────────────────
const utils = trpc.useUtils()
const { data: hotel, isLoading } = trpc.logistics.getHotel.useQuery({ programId })
type HotelRow = {
id: string
name: string
address: string | null
link: string | null
notes: string | null
_count: { stays: number }
}
type RoomingRow = {
attendingMemberId: string
confirmationId: string
projectId: string
projectTitle: string
user: { id: string; name: string | null; email: string }
stay: { hotelId: string; roomNumber: string | null; checkInAt: Date | null; checkOutAt: Date | null } | null
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function toDateInputValue(d: Date | null | undefined): string {
if (!d) return ''
const dt = new Date(d)
if (Number.isNaN(dt.getTime())) return ''
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
}
function csvEscape(value: string | null | undefined): string {
const str = value ?? ''
if (str.includes('"') || str.includes(',') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
function buildRoomingCsv(rows: RoomingRow[], hotels: HotelRow[]): string {
const hotelMap = new Map(hotels.map((h) => [h.id, h.name]))
const header = ['Team', 'Member', 'Email', 'Hotel', 'Room', 'Check-in', 'Check-out'].join(',')
const lines = rows.map((r) => {
const s = r.stay
return [
csvEscape(r.projectTitle),
csvEscape(r.user.name ?? r.user.email),
csvEscape(r.user.email),
csvEscape(s ? (hotelMap.get(s.hotelId) ?? '') : ''),
csvEscape(s?.roomNumber ?? ''),
csvEscape(s?.checkInAt ? toDateInputValue(s.checkInAt) : ''),
csvEscape(s?.checkOutAt ? toDateInputValue(s.checkOutAt) : ''),
].join(',')
})
return [header, ...lines].join('\r\n')
}
// ─── Hotel Form Dialog ────────────────────────────────────────────────────────
type HotelFormMode = { type: 'create' } | { type: 'edit'; hotel: HotelRow }
function HotelFormDialog({
open,
mode,
programId,
onOpenChange,
}: {
open: boolean
mode: HotelFormMode
programId: string
onOpenChange: (next: boolean) => void
}) {
const utils = trpc.useUtils()
const [name, setName] = useState('') const [name, setName] = useState('')
const [address, setAddress] = useState('') const [address, setAddress] = useState('')
const [link, setLink] = useState('') const [link, setLink] = useState('')
const [notes, setNotes] = useState('') const [notes, setNotes] = useState('')
// Sync form state from server data on first load / after save.
useEffect(() => { useEffect(() => {
if (hotel) { if (!open) return
setName(hotel.name) if (mode.type === 'edit') {
setAddress(hotel.address ?? '') setName(mode.hotel.name)
setLink(hotel.link ?? '') setAddress(mode.hotel.address ?? '')
setNotes(hotel.notes ?? '') setLink(mode.hotel.link ?? '')
setNotes(mode.hotel.notes ?? '')
} else {
setName('')
setAddress('')
setLink('')
setNotes('')
} }
}, [hotel]) }, [open, mode])
const upsertMutation = trpc.logistics.upsertHotel.useMutation({ const onSuccess = () => {
onSuccess: () => { toast.success(mode.type === 'create' ? 'Hotel added' : 'Hotel updated')
toast.success('Hotel saved') utils.logistics.listHotels.invalidate({ programId })
utils.logistics.getHotel.invalidate({ programId }) onOpenChange(false)
}, }
const createMutation = trpc.logistics.createHotel.useMutation({
onSuccess,
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
const updateMutation = trpc.logistics.updateHotel.useMutation({
onSuccess,
onError: (err) => toast.error(err.message),
})
const isPending = createMutation.isPending || updateMutation.isPending
const handleSave = () => { const handleSave = () => {
if (!name.trim()) { if (!name.trim()) {
toast.error('Hotel name is required') toast.error('Hotel name is required')
return return
} }
upsertMutation.mutate({ if (mode.type === 'create') {
createMutation.mutate({
programId, programId,
name: name.trim(), name: name.trim(),
address: address.trim() || undefined, address: address.trim() || undefined,
link: link.trim() || '', link: link.trim() || undefined,
notes: notes.trim() || undefined, notes: notes.trim() || undefined,
}) })
} else {
updateMutation.mutate({
id: mode.hotel.id,
name: name.trim(),
address: address.trim() || null,
link: link.trim() || null,
notes: notes.trim() || null,
})
}
} }
if (isLoading) return <Skeleton className="h-96 w-full" />
const dirty =
name !== (hotel?.name ?? '') ||
address !== (hotel?.address ?? '') ||
link !== (hotel?.link ?? '') ||
notes !== (hotel?.notes ?? '')
return ( return (
<div className="grid gap-4 md:grid-cols-3"> <Dialog open={open} onOpenChange={(next) => { if (!isPending) onOpenChange(next) }}>
<div className="md:col-span-2"> <DialogContent className="sm:max-w-md">
<Card> <DialogHeader>
<CardHeader> <DialogTitle>{mode.type === 'create' ? 'Add hotel' : 'Edit hotel'}</DialogTitle>
<div className="flex items-center gap-2"> <DialogDescription>
<HotelIcon className="text-muted-foreground h-4 w-4" /> {mode.type === 'create'
<CardTitle className="text-base">Hotel for this edition</CardTitle> ? 'Add a hotel that finalists can be assigned to.'
</div> : 'Update hotel details.'}
<CardDescription> </DialogDescription>
One hotel per edition. Used in confirmation emails and finalist communications. </DialogHeader>
</CardDescription>
</CardHeader> <div className="space-y-4">
<CardContent className="space-y-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="hotel-name">Name *</Label> <Label htmlFor="hotel-name">Name *</Label>
<Input <Input
@@ -99,7 +215,7 @@ export function HotelsTab({ programId }: Props) {
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="hotel-link">Hotel website / booking link</Label> <Label htmlFor="hotel-link">Website / booking link</Label>
<Input <Input
id="hotel-link" id="hotel-link"
type="url" type="url"
@@ -118,58 +234,463 @@ export function HotelsTab({ programId }: Props) {
rows={3} rows={3}
/> />
</div> </div>
<div className="flex justify-end">
<Button
onClick={handleSave}
disabled={!dirty || upsertMutation.isPending}
>
{upsertMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save
</Button>
</div>
</CardContent>
</Card>
</div> </div>
<div className="md:col-span-1"> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{mode.type === 'create' ? 'Add hotel' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ─── Hotels Section ───────────────────────────────────────────────────────────
function HotelsSection({ programId }: { programId: string }) {
const utils = trpc.useUtils()
const { data: hotels, isLoading } = trpc.logistics.listHotels.useQuery({ programId })
const [dialogOpen, setDialogOpen] = useState(false)
const [dialogMode, setDialogMode] = useState<HotelFormMode>({ type: 'create' })
const deleteMutation = trpc.logistics.deleteHotel.useMutation({
onSuccess: () => {
toast.success('Hotel removed')
utils.logistics.listHotels.invalidate({ programId })
utils.logistics.listRooming.invalidate({ programId })
},
onError: (err) => toast.error(err.message),
})
const openCreate = () => {
setDialogMode({ type: 'create' })
setDialogOpen(true)
}
const openEdit = (hotel: HotelRow) => {
setDialogMode({ type: 'edit', hotel })
setDialogOpen(true)
}
return (
<>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Email preview</CardTitle> <div className="flex items-center justify-between gap-4">
<CardDescription>What teams will see in confirmation emails.</CardDescription> <div className="flex items-center gap-2">
<HotelIcon className="text-muted-foreground h-4 w-4" />
<CardTitle className="text-base">Hotels</CardTitle>
</div>
<Button size="sm" onClick={openCreate}>
<Plus className="mr-1 h-4 w-4" />
Add hotel
</Button>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{!name.trim() ? ( {isLoading ? (
<p className="text-muted-foreground text-sm">Save a hotel to see the preview.</p> <div className="space-y-2">
{[1, 2].map((i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : !hotels || hotels.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
No hotels yet. Add one above.
</p>
) : ( ) : (
<div className="bg-muted/30 rounded-md border p-4 text-sm"> <div className="space-y-3">
<div className="text-muted-foreground mb-1 text-xs uppercase tracking-wide"> {hotels.map((hotel) => (
Your accommodation <div
</div> key={hotel.id}
<div className="font-semibold">{name}</div> className="flex items-start justify-between gap-4 rounded-md border p-3"
{address.trim() && ( >
<div className="text-muted-foreground mt-1 whitespace-pre-line text-xs"> <div className="min-w-0 flex-1 space-y-1">
{address} <div className="flex items-center gap-2">
<span className="font-medium">{hotel.name}</span>
<Badge variant="secondary" className="text-xs">
{hotel._count.stays} guest{hotel._count.stays !== 1 ? 's' : ''}
</Badge>
</div> </div>
{hotel.address && (
<p className="text-muted-foreground text-xs whitespace-pre-line">{hotel.address}</p>
)} )}
{link.trim() && ( {hotel.link && (
<a <a
href={link} href={hotel.link}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-primary mt-2 inline-flex items-center gap-1 text-xs hover:underline" className="text-primary inline-flex items-center gap-1 text-xs hover:underline"
> >
Visit hotel website <ExternalLink className="h-3 w-3" /> Visit website <ExternalLink className="h-3 w-3" />
</a> </a>
)} )}
{hotel.notes && (
<p className="text-muted-foreground text-xs italic">{hotel.notes}</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => openEdit(hotel)}
>
<Pencil className="h-4 w-4" />
<span className="sr-only">Edit</span>
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive h-8 w-8"
disabled={deleteMutation.isPending}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete</span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete hotel?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently remove <strong>{hotel.name}</strong>.
{hotel._count.stays > 0
? ` Reassign the ${hotel._count.stays} guest(s) before deleting.`
: ' This action cannot be undone.'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => deleteMutation.mutate({ id: hotel.id })}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
))}
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
<HotelFormDialog
open={dialogOpen}
mode={dialogMode}
programId={programId}
onOpenChange={setDialogOpen}
/>
</>
)
}
// ─── Attendee Row ─────────────────────────────────────────────────────────────
function AttendeeRoomRow({
row,
hotels,
programId,
}: {
row: RoomingRow
hotels: HotelRow[]
programId: string
}) {
const utils = trpc.useUtils()
const [roomNumber, setRoomNumber] = useState(row.stay?.roomNumber ?? '')
const [checkIn, setCheckIn] = useState(toDateInputValue(row.stay?.checkInAt ?? null))
const [checkOut, setCheckOut] = useState(toDateInputValue(row.stay?.checkOutAt ?? null))
// Keep local state in sync when server data updates
const prevStayRef = useRef(row.stay)
useEffect(() => {
const prev = prevStayRef.current
const cur = row.stay
// Only sync if the stay changed from outside (different hotelId or null/non-null)
if (prev?.hotelId !== cur?.hotelId || (prev === null) !== (cur === null)) {
setRoomNumber(cur?.roomNumber ?? '')
setCheckIn(toDateInputValue(cur?.checkInAt ?? null))
setCheckOut(toDateInputValue(cur?.checkOutAt ?? null))
}
prevStayRef.current = cur
}, [row.stay])
const assignMutation = trpc.logistics.assignStay.useMutation({
onSuccess: () => utils.logistics.listRooming.invalidate({ programId }),
onError: (err) => toast.error(err.message),
})
const unassignMutation = trpc.logistics.unassignStay.useMutation({
onSuccess: () => utils.logistics.listRooming.invalidate({ programId }),
onError: (err) => toast.error(err.message),
})
const currentHotelId = row.stay?.hotelId ?? ''
const handleHotelChange = (value: string) => {
if (!value) {
unassignMutation.mutate({ attendingMemberId: row.attendingMemberId })
} else {
assignMutation.mutate({
attendingMemberId: row.attendingMemberId,
hotelId: value,
roomNumber: roomNumber.trim() || null,
checkInAt: fromDateInputValue(checkIn),
checkOutAt: fromDateInputValue(checkOut),
})
}
}
const commitRoomNumber = () => {
if (!currentHotelId) return
const trimmed = roomNumber.trim()
if (trimmed === (row.stay?.roomNumber ?? '')) return
assignMutation.mutate({
attendingMemberId: row.attendingMemberId,
hotelId: currentHotelId,
roomNumber: trimmed || null,
checkInAt: fromDateInputValue(checkIn),
checkOutAt: fromDateInputValue(checkOut),
})
}
const commitCheckIn = () => {
if (!currentHotelId) return
if (checkIn === toDateInputValue(row.stay?.checkInAt ?? null)) return
assignMutation.mutate({
attendingMemberId: row.attendingMemberId,
hotelId: currentHotelId,
roomNumber: roomNumber.trim() || null,
checkInAt: fromDateInputValue(checkIn),
checkOutAt: fromDateInputValue(checkOut),
})
}
const commitCheckOut = () => {
if (!currentHotelId) return
if (checkOut === toDateInputValue(row.stay?.checkOutAt ?? null)) return
assignMutation.mutate({
attendingMemberId: row.attendingMemberId,
hotelId: currentHotelId,
roomNumber: roomNumber.trim() || null,
checkInAt: fromDateInputValue(checkIn),
checkOutAt: fromDateInputValue(checkOut),
})
}
const isBusy = assignMutation.isPending || unassignMutation.isPending
const hasHotel = !!currentHotelId
return (
<div className="grid grid-cols-[1fr_auto] items-center gap-2 py-2 pl-4 sm:grid-cols-[2fr_2fr_1fr_1fr_1fr]">
{/* Member */}
<div className="col-span-2 sm:col-span-1">
<div className="text-sm font-medium">{row.user.name ?? row.user.email}</div>
<div className="text-muted-foreground text-xs">{row.user.email}</div>
</div> </div>
{/* Hotel select */}
<Select value={currentHotelId} onValueChange={handleHotelChange} disabled={isBusy}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="— Unassigned —" />
</SelectTrigger>
<SelectContent>
<SelectItem value=""> Unassigned </SelectItem>
{hotels.map((h) => (
<SelectItem key={h.id} value={h.id}>
{h.name}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Room # */}
<Input
className="h-8 text-xs"
placeholder="Room #"
value={roomNumber}
onChange={(e) => setRoomNumber(e.target.value)}
onBlur={commitRoomNumber}
disabled={!hasHotel || isBusy}
/>
{/* Check-in */}
<Input
className="h-8 text-xs"
type="date"
value={checkIn}
onChange={(e) => setCheckIn(e.target.value)}
onBlur={commitCheckIn}
disabled={!hasHotel || isBusy}
/>
{/* Check-out */}
<Input
className="h-8 text-xs"
type="date"
value={checkOut}
onChange={(e) => setCheckOut(e.target.value)}
onBlur={commitCheckOut}
disabled={!hasHotel || isBusy}
/>
</div>
)
}
// ─── Rooming Section ──────────────────────────────────────────────────────────
function RoomingSection({ programId }: { programId: string }) {
const utils = trpc.useUtils()
const { data: rooming, isLoading: roomingLoading } = trpc.logistics.listRooming.useQuery({ programId })
const { data: hotels } = trpc.logistics.listHotels.useQuery({ programId })
const assignTeamMutation = trpc.logistics.assignTeamToHotel.useMutation({
onSuccess: () => {
toast.success('Team assigned')
utils.logistics.listRooming.invalidate({ programId })
utils.logistics.listHotels.invalidate({ programId })
},
onError: (err) => toast.error(err.message),
})
// Group rows by projectTitle
const grouped = useMemo(() => {
if (!rooming) return []
const map = new Map<string, { confirmationId: string; projectTitle: string; rows: RoomingRow[] }>()
for (const row of rooming) {
if (!map.has(row.projectId)) {
map.set(row.projectId, {
confirmationId: row.confirmationId,
projectTitle: row.projectTitle,
rows: [],
})
}
map.get(row.projectId)!.rows.push(row)
}
return Array.from(map.values())
}, [rooming])
const downloadCsv = () => {
if (!rooming || !hotels) return
const csv = buildRoomingCsv(rooming, hotels)
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'rooming-manifest.csv'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-4">
<CardTitle className="text-base">Rooming</CardTitle>
<Button
variant="outline"
size="sm"
disabled={!rooming || rooming.length === 0}
onClick={downloadCsv}
>
<Download className="mr-1 h-4 w-4" /> Download CSV
</Button>
</div>
</CardHeader>
<CardContent>
{roomingLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : grouped.length === 0 ? (
<p className="text-muted-foreground py-12 text-center text-sm">
No confirmed attendees yet.
</p>
) : (
<div className="space-y-6">
{grouped.map((group) => (
<div key={group.confirmationId}>
{/* Team header */}
<div className="bg-muted/40 flex items-center justify-between gap-4 rounded-t-md border px-3 py-2">
<span className="text-sm font-semibold">{group.projectTitle}</span>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs shrink-0">Assign whole team to</span>
<Select
value=""
onValueChange={(hotelId) => {
if (!hotelId) return
assignTeamMutation.mutate({
confirmationId: group.confirmationId,
hotelId,
})
}}
disabled={assignTeamMutation.isPending || !hotels || hotels.length === 0}
>
<SelectTrigger className="h-7 w-40 text-xs">
<SelectValue placeholder="Select hotel…" />
</SelectTrigger>
<SelectContent>
{(hotels ?? []).map((h) => (
<SelectItem key={h.id} value={h.id}>
{h.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Column headers */}
<div className="hidden grid-cols-[2fr_2fr_1fr_1fr_1fr] gap-2 border-x border-b bg-white px-4 py-1 sm:grid">
<span className="text-muted-foreground text-xs font-medium">Member</span>
<span className="text-muted-foreground text-xs font-medium">Hotel</span>
<span className="text-muted-foreground text-xs font-medium">Room #</span>
<span className="text-muted-foreground text-xs font-medium">Check-in</span>
<span className="text-muted-foreground text-xs font-medium">Check-out</span>
</div>
{/* Attendee rows */}
<div className="divide-y rounded-b-md border-x border-b">
{group.rows.map((row) => (
<AttendeeRoomRow
key={row.attendingMemberId}
row={row}
hotels={hotels ?? []}
programId={programId}
/>
))}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}
// ─── Main export ──────────────────────────────────────────────────────────────
export function HotelsTab({ programId }: Props) {
return (
<div className="space-y-6">
<HotelsSection programId={programId} />
<RoomingSection programId={programId} />
</div> </div>
) )
} }