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'
|
'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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user