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:
@@ -1,83 +1,199 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
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 { 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 { 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 {
|
||||
programId: string
|
||||
}
|
||||
|
||||
export function HotelsTab({ programId }: Props) {
|
||||
const utils = trpc.useUtils()
|
||||
const { data: hotel, isLoading } = trpc.logistics.getHotel.useQuery({ programId })
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
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 [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 ?? '')
|
||||
if (!open) return
|
||||
if (mode.type === 'edit') {
|
||||
setName(mode.hotel.name)
|
||||
setAddress(mode.hotel.address ?? '')
|
||||
setLink(mode.hotel.link ?? '')
|
||||
setNotes(mode.hotel.notes ?? '')
|
||||
} else {
|
||||
setName('')
|
||||
setAddress('')
|
||||
setLink('')
|
||||
setNotes('')
|
||||
}
|
||||
}, [hotel])
|
||||
}, [open, mode])
|
||||
|
||||
const upsertMutation = trpc.logistics.upsertHotel.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Hotel saved')
|
||||
utils.logistics.getHotel.invalidate({ programId })
|
||||
},
|
||||
const onSuccess = () => {
|
||||
toast.success(mode.type === 'create' ? 'Hotel added' : 'Hotel updated')
|
||||
utils.logistics.listHotels.invalidate({ programId })
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const createMutation = trpc.logistics.createHotel.useMutation({
|
||||
onSuccess,
|
||||
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 = () => {
|
||||
if (!name.trim()) {
|
||||
toast.error('Hotel name is required')
|
||||
return
|
||||
}
|
||||
upsertMutation.mutate({
|
||||
if (mode.type === 'create') {
|
||||
createMutation.mutate({
|
||||
programId,
|
||||
name: name.trim(),
|
||||
address: address.trim() || undefined,
|
||||
link: link.trim() || '',
|
||||
link: link.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 (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="md:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<HotelIcon className="text-muted-foreground h-4 w-4" />
|
||||
<CardTitle className="text-base">Hotel for this edition</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
One hotel per edition. Used in confirmation emails and finalist communications.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Dialog open={open} onOpenChange={(next) => { if (!isPending) onOpenChange(next) }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mode.type === 'create' ? 'Add hotel' : 'Edit hotel'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{mode.type === 'create'
|
||||
? 'Add a hotel that finalists can be assigned to.'
|
||||
: 'Update hotel details.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hotel-name">Name *</Label>
|
||||
<Input
|
||||
@@ -99,7 +215,7 @@ export function HotelsTab({ programId }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hotel-link">Hotel website / booking link</Label>
|
||||
<Label htmlFor="hotel-link">Website / booking link</Label>
|
||||
<Input
|
||||
id="hotel-link"
|
||||
type="url"
|
||||
@@ -118,58 +234,463 @@ export function HotelsTab({ programId }: Props) {
|
||||
rows={3}
|
||||
/>
|
||||
</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 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>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Email preview</CardTitle>
|
||||
<CardDescription>What teams will see in confirmation emails.</CardDescription>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<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>
|
||||
<CardContent>
|
||||
{!name.trim() ? (
|
||||
<p className="text-muted-foreground text-sm">Save a hotel to see the preview.</p>
|
||||
{isLoading ? (
|
||||
<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="text-muted-foreground mb-1 text-xs uppercase tracking-wide">
|
||||
Your accommodation
|
||||
</div>
|
||||
<div className="font-semibold">{name}</div>
|
||||
{address.trim() && (
|
||||
<div className="text-muted-foreground mt-1 whitespace-pre-line text-xs">
|
||||
{address}
|
||||
<div className="space-y-3">
|
||||
{hotels.map((hotel) => (
|
||||
<div
|
||||
key={hotel.id}
|
||||
className="flex items-start justify-between gap-4 rounded-md border p-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<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>
|
||||
{hotel.address && (
|
||||
<p className="text-muted-foreground text-xs whitespace-pre-line">{hotel.address}</p>
|
||||
)}
|
||||
{link.trim() && (
|
||||
{hotel.link && (
|
||||
<a
|
||||
href={link}
|
||||
href={hotel.link}
|
||||
target="_blank"
|
||||
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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
</CardContent>
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user