diff --git a/src/components/admin/logistics/hotels-tab.tsx b/src/components/admin/logistics/hotels-tab.tsx index e19458c..c82ad08 100644 --- a/src/components/admin/logistics/hotels-tab.tsx +++ b/src/components/admin/logistics/hotels-tab.tsx @@ -1,175 +1,696 @@ '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({ - programId, - name: name.trim(), - address: address.trim() || undefined, - link: link.trim() || '', - notes: notes.trim() || undefined, + if (mode.type === 'create') { + createMutation.mutate({ + programId, + name: name.trim(), + address: address.trim() || undefined, + 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, + }) + } + } + + return ( + { if (!isPending) onOpenChange(next) }}> + + + {mode.type === 'create' ? 'Add hotel' : 'Edit hotel'} + + {mode.type === 'create' + ? 'Add a hotel that finalists can be assigned to.' + : 'Update hotel details.'} + + + + + + Name * + setName(e.target.value)} + placeholder="Hôtel de Paris" + required + /> + + + Address + setAddress(e.target.value)} + placeholder="Place du Casino, 98000 Monaco" + rows={2} + /> + + + Website / booking link + setLink(e.target.value)} + placeholder="https://hoteldeparismontecarlo.com" + /> + + + Internal notes + setNotes(e.target.value)} + placeholder="Check-in time, special arrangements, etc." + rows={3} + /> + + + + + onOpenChange(false)} disabled={isPending}> + Cancel + + + {isPending && } + {mode.type === 'create' ? 'Add hotel' : 'Save'} + + + + + ) +} + +// ─── 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({ 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 ( + <> + + + + + + Hotels + + + + Add hotel + + + + + {isLoading ? ( + + {[1, 2].map((i) => ( + + ))} + + ) : !hotels || hotels.length === 0 ? ( + + No hotels yet. Add one above. + + ) : ( + + {hotels.map((hotel) => ( + + + + {hotel.name} + + {hotel._count.stays} guest{hotel._count.stays !== 1 ? 's' : ''} + + + {hotel.address && ( + {hotel.address} + )} + {hotel.link && ( + + Visit website + + )} + {hotel.notes && ( + {hotel.notes} + )} + + + openEdit(hotel)} + > + + Edit + + + + + + Delete + + + + + Delete hotel? + + This will permanently remove {hotel.name}. + {hotel._count.stays > 0 + ? ` Reassign the ${hotel._count.stays} guest(s) before deleting.` + : ' This action cannot be undone.'} + + + + Cancel + deleteMutation.mutate({ id: hotel.id })} + > + Delete + + + + + + + ))} + + )} + + + + + > + ) +} + +// ─── 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), }) } - if (isLoading) return + 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 dirty = - name !== (hotel?.name ?? '') || - address !== (hotel?.address ?? '') || - link !== (hotel?.link ?? '') || - notes !== (hotel?.notes ?? '') + 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 ( - - - - - - - Hotel for this edition - - - One hotel per edition. Used in confirmation emails and finalist communications. - - - - - Name * - setName(e.target.value)} - placeholder="Hôtel de Paris" - required - /> - - - Address - setAddress(e.target.value)} - placeholder="Place du Casino, 98000 Monaco" - rows={2} - /> - - - Hotel website / booking link - setLink(e.target.value)} - placeholder="https://hoteldeparismontecarlo.com" - /> - - - Internal notes - setNotes(e.target.value)} - placeholder="Check-in time, special arrangements, etc." - rows={3} - /> - - - - {upsertMutation.isPending ? ( - - ) : ( - - )} - Save - - - - + + {/* Member */} + + {row.user.name ?? row.user.email} + {row.user.email} - - - - Email preview - What teams will see in confirmation emails. - - - {!name.trim() ? ( - Save a hotel to see the preview. - ) : ( - - - Your accommodation - - {name} - {address.trim() && ( - - {address} - - )} - {link.trim() && ( - - Visit hotel website - - )} - - )} - - - + {/* Hotel select */} + + + + + + — Unassigned — + {hotels.map((h) => ( + + {h.name} + + ))} + + + + {/* Room # */} + setRoomNumber(e.target.value)} + onBlur={commitRoomNumber} + disabled={!hasHotel || isBusy} + /> + + {/* Check-in */} + setCheckIn(e.target.value)} + onBlur={commitCheckIn} + disabled={!hasHotel || isBusy} + /> + + {/* Check-out */} + setCheckOut(e.target.value)} + onBlur={commitCheckOut} + disabled={!hasHotel || isBusy} + /> + + ) +} + +// ─── 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() + 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 ( + + + + Rooming + + Download CSV + + + + + {roomingLoading ? ( + + {[1, 2, 3].map((i) => ( + + ))} + + ) : grouped.length === 0 ? ( + + No confirmed attendees yet. + + ) : ( + + {grouped.map((group) => ( + + {/* Team header */} + + {group.projectTitle} + + Assign whole team to + { + if (!hotelId) return + assignTeamMutation.mutate({ + confirmationId: group.confirmationId, + hotelId, + }) + }} + disabled={assignTeamMutation.isPending || !hotels || hotels.length === 0} + > + + + + + {(hotels ?? []).map((h) => ( + + {h.name} + + ))} + + + + + + {/* Column headers */} + + Member + Hotel + Room # + Check-in + Check-out + + + {/* Attendee rows */} + + {group.rows.map((row) => ( + + ))} + + + ))} + + )} + + + ) +} + +// ─── Main export ────────────────────────────────────────────────────────────── + +export function HotelsTab({ programId }: Props) { + return ( + + + ) }
+ No hotels yet. Add one above. +
{hotel.address}
{hotel.notes}
Save a hotel to see the preview.
+ No confirmed attendees yet. +