// timeline.jsx — Timeline view: rooms x days grid with booking bars // Click empty cell → onNewSlot(roomId, date) // Click booking bar → onOpenBooking(id) function Timeline({ anchorDate, today, rooms, bookings, guests, filters, onNewSlot, onOpenBooking, blockStyle = 'default', // default | outline | soft | gradient daysToShow = 14, }) { const [tip, setTip] = React.useState(null); // {b, x, y} // Build day list const days = React.useMemo(() => { const arr = []; for (let i = 0; i < daysToShow; i++) { arr.push(window.HotelData.addDays(anchorDate, i)); } return arr; }, [anchorDate, daysToShow]); // Filter rooms const visibleRooms = React.useMemo(() => { return rooms.filter(r => { const t = r.type.startsWith('Suite') ? 'Suite' : r.type; if (filters.types.length && !filters.types.includes(t)) return false; return true; }); }, [rooms, filters.types]); // Group rooms by floor const grouped = React.useMemo(() => { const g = {}; visibleRooms.forEach(r => { if (!g[r.floor]) g[r.floor] = []; g[r.floor].push(r); }); return g; }, [visibleRooms]); // Filter bookings const visibleBookings = React.useMemo(() => { return bookings.filter(b => filters.statuses.includes(b.status)); }, [bookings, filters.statuses]); // Pre-compute width / today position const COL = 110; // sync with --col-w const ROOM_COL = 200; const totalWidth = ROOM_COL + COL * daysToShow; // Today line position const todayIdx = daysBetween(anchorDate, today); const showTodayLine = todayIdx >= 0 && todayIdx < daysToShow; const todayX = ROOM_COL + COL * todayIdx + (COL * 0.5); // place at noon // Booking → x, w, row const renderBookingBars = (roomId, roomRowIdx) => { const bks = visibleBookings.filter(b => b.roomId === roomId); return bks.map(b => { const startIdx = daysBetween(anchorDate, b.checkIn); const endIdx = daysBetween(anchorDate, b.checkOut); // Allow off-screen bookings to be clipped const visStart = Math.max(0, startIdx); const visEnd = Math.min(daysToShow, endIdx); if (visEnd <= 0 || visStart >= daysToShow) return null; // Inset by ~14px on each end of an unclipped edge for natural check-in/out gap const clipL = startIdx < 0; const clipR = endIdx > daysToShow; // Position: cell start + half day at check-in side, end at half day before checkout const leftPx = visStart * COL + (clipL ? 0 : COL * 0.5); const rightPx = visEnd * COL - (clipR ? 0 : COL * 0.5); const widthPx = Math.max(40, rightPx - leftPx); const guest = b.guestId ? findGuest(guests, b.guestId) : null; const isMaint = b.status === 'maintenance'; const isCancelled = b.status === 'cancelled'; const styleClass = blockStyle === 'default' ? 'style-default' : blockStyle === 'outline' ? 'style-outline' : blockStyle === 'soft' ? 'style-soft' : 'style-gradient'; const label = isMaint ? '🔧 ' + (b.note ? b.note.split('.')[0] : 'Bảo trì') : guest ? guest.name : 'Khách'; return (
{ e.stopPropagation(); onOpenBooking(b.id); }} onMouseEnter={(e) => setTip({ b, guest, x: e.clientX, y: e.clientY })} onMouseMove={(e) => setTip(t => t && { ...t, x: e.clientX, y: e.clientY })} onMouseLeave={() => setTip(null)} > {!isMaint && guest && !clipL && ( {initials(guest.name)} )} {label} {b.paymentStatus === 'paid' && !isMaint && ( )}
); }); }; const floors = Object.keys(grouped).map(Number).sort((a, b) => a - b); return (
Phòng
{days.map((d, i) => { const isToday = sameDay(d, today); const isWeekend = d.getDay() === 0 || d.getDay() === 6; return (
{DOW_VN[d.getDay()]}
{d.getDate()}
); })}
{floors.map((f, fi) => { const floorRooms = grouped[f]; return (
Tầng {f} · {floorRooms[0].type.startsWith('Suite') ? 'Suite' : floorRooms[0].type}
{days.map((d, i) => { const isWeekend = d.getDay() === 0 || d.getDay() === 6; return
; })}
{floorRooms.map((r, ri) => (
{r.name || ('Phòng ' + r.number)} P.{r.number} · {r.type} · {VNDshort(r.price)}đ
{days.map((d, i) => { const isWeekend = d.getDay() === 0 || d.getDay() === 6; const isToday = sameDay(d, today); return (
onNewSlot(r.id, d)} /> ); })} {renderBookingBars(r.id, ri)}
))} ); })} {showTodayLine && (
)}
{tip && }
); } function BookingTip({ b, guest, x, y }) { const wrapRef = React.useRef(null); const [pos, setPos] = React.useState({ x, y }); React.useLayoutEffect(() => { if (!wrapRef.current) return; const r = wrapRef.current.getBoundingClientRect(); let nx = x + 14, ny = y + 14; if (nx + r.width > window.innerWidth - 8) nx = x - r.width - 14; if (ny + r.height > window.innerHeight - 8) ny = y - r.height - 14; setPos({ x: nx, y: ny }); }, [x, y]); const data = window.HotelData; const room = findRoom(data.rooms, b.roomId); const t = computeBookingTotal(b, data.rooms, data.serviceCatalog, data.inStayMenu); const status = data.statuses.find(s => s.key === b.status); return (
{guest ? guest.name : 'Bảo trì'}
Phòng {room?.number} · {room?.type}
{formatDateShort(b.checkIn)}{formatDateShort(b.checkOut)}
{t.nights} đêm
{b.status !== 'maintenance' && (
Tổng {VND(t.total)}
)} {status?.label}
); } window.Timeline = Timeline;