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.'} + + + +
+
+ + setName(e.target.value)} + placeholder="Hôtel de Paris" + required + /> +
+
+ +