Files
conference-room-booking-system/frontend/src/pages/BookingPage.tsx
2025-09-08 10:46:54 -04:00

485 lines
16 KiB
TypeScript

/**
* BookingPage.tsx
* Page for booking a conference room, showing calendar and booking form.
* Handles room selection, booking creation, and SSE updates.
*
* Author: Cliff Hill
* Last updated: 2025-09-05
*/
import React, { useMemo, useState, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { getRooms } from "../apis/rooms";
import { getUsers } from "../apis/users";
import { getRoomBookings } from "../apis/bookings";
import { connectRoomsAvailabilityStream } from "../apis/sse";
import {
Button,
MenuItem,
Select,
InputLabel,
FormControl,
Box,
} from "@mui/material";
import CalendarView from "../components/CalendarView";
import { EventInput } from "@fullcalendar/core";
import BookingForm from "../components/BookingForm";
import RoomDetailsModal from "../components/RoomDetailsModal";
import { logger } from "../utils/logger";
const BookingPage: React.FC = () => {
// State for booking form
const [formOpen, setFormOpen] = useState(false);
// State for room details modal (page-level)
const [roomModalOpen, setRoomModalOpen] = useState(false);
// ...existing code...
// Log mount
useEffect(() => {
logger.info("[BookingPage] Mounted");
return () => logger.info("[BookingPage] Unmounted");
}, []);
// Fetch all rooms
const {
data: rooms = [],
isLoading: roomsLoading,
error: roomsError,
} = useQuery({
queryKey: ["rooms"],
queryFn: () => getRooms(),
});
// Real-time bookings state (by room)
const [bookingsByRoom, setBookingsByRoom] = useState<Record<number, any[]>>(
{}
);
// Fetch all users (invitees)
const {
data: allInvitees = [],
isLoading: inviteesLoading,
error: inviteesError,
} = useQuery({
queryKey: ["users"],
queryFn: () => getUsers(),
});
const [bookingsLoading, setBookingsLoading] = useState(true);
const [bookingsError, setBookingsError] = useState<any>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const [usingPollingFallback, setUsingPollingFallback] = useState(false);
// Fetch initial bookings and subscribe to SSE
useEffect(() => {
let cancelled = false;
let pollInterval: NodeJS.Timeout | null = null;
async function fetchBookingsAndSet() {
setBookingsLoading(true);
setBookingsError(null);
try {
const results: Record<number, any[]> = {};
await Promise.all(
rooms.map(async (room: any) => {
try {
const bookings = await getRoomBookings(room.id);
results[room.id] = bookings;
logger.debug(
`[BookingPage] Bookings fetched for room ${room.id}`,
bookings
);
} catch (e) {
results[room.id] = [];
logger.warn(
`[BookingPage] Failed to fetch bookings for room ${room.id}`
);
}
})
);
if (!cancelled) setBookingsByRoom(results);
logger.info("[BookingPage] All bookings fetched and set");
} catch (err) {
if (!cancelled) setBookingsError(err);
logger.error("[BookingPage] Error fetching bookings", err);
} finally {
if (!cancelled) setBookingsLoading(false);
}
}
if (rooms.length > 0) {
fetchBookingsAndSet();
logger.info("[BookingPage] Fetching bookings for all rooms");
// Fallback: If EventSource/SSE is not supported, use polling
const sseAllowed =
typeof window !== "undefined" &&
typeof window.EventSource !== "undefined";
if (sseAllowed) {
setUsingPollingFallback(false);
if (eventSourceRef.current) eventSourceRef.current.close();
eventSourceRef.current = connectRoomsAvailabilityStream({
onMessage: (data) => {
if (Array.isArray(data.bookings)) {
const grouped: Record<number, any[]> = {};
data.bookings.forEach((b: any) => {
if (!grouped[b.room_id]) grouped[b.room_id] = [];
grouped[b.room_id].push(b);
});
setBookingsByRoom(grouped);
logger.debug("[BookingPage] SSE update received", grouped);
}
},
onError: (err) => {
logger.error("[BookingPage] SSE connection error", err);
},
});
} else {
setUsingPollingFallback(true);
logger.warn("[BookingPage] SSE not supported, falling back to polling");
// Poll every 10 seconds if SSE not available
pollInterval = setInterval(() => {
logger.info("[BookingPage] Polling for bookings update");
fetchBookingsAndSet();
}, 10000);
}
}
return () => {
cancelled = true;
if (eventSourceRef.current) eventSourceRef.current.close();
if (pollInterval) clearInterval(pollInterval);
};
}, [rooms, formOpen, roomModalOpen]);
// Assign a color to each room
const roomColors: Record<string, string> = useMemo(() => {
const defaultColors = [
"#1976d2",
"#388e3c",
"#fbc02d",
"#d32f2f",
"#7b1fa2",
"#0288d1",
"#c2185b",
"#ffa000",
];
const colors: Record<string, string> = {};
rooms.forEach((room: any, idx: number) => {
colors[room.id] = defaultColors[idx % defaultColors.length];
});
return colors;
}, [rooms]);
// Combine all bookings into calendar events, sorted chronologically (room id tiebreaker), with color and formatted title
const events: EventInput[] = useMemo(() => {
if (!bookingsByRoom || rooms.length === 0) return [];
// Flatten all bookings with room info
const allBookings = Object.entries(bookingsByRoom).flatMap(
([roomId, bookings]) =>
(bookings as any[]).map((booking) => {
const room = rooms.find((r: any) => r.id === Number(roomId));
// Use backend's snake_case property names
const start = new Date(booking.start_time);
const end = new Date(booking.end_time);
const displayTitle =
booking.title && booking.title.trim() !== "" ? booking.title : "";
// Extract invitee names from booking.invitees[].user.name
let inviteeNames: string[] = [];
if (Array.isArray(booking.invitees)) {
inviteeNames = booking.invitees
.map((inv: any) => inv?.user?.name)
.filter((name: string | undefined) => !!name);
}
return {
id: booking.id,
title: displayTitle,
start,
end,
color: roomColors[roomId],
resource: {
room_id: booking.room_id,
room_name: room?.name,
invitees: inviteeNames,
originalBooking: booking,
},
};
})
);
// Sort by start time, then by room id
allBookings.sort((a, b) => {
const aStart = a.start.getTime();
const bStart = b.start.getTime();
if (aStart !== bStart) return aStart - bStart;
return String(a.resource.room_id).localeCompare(
String(b.resource.room_id)
);
});
return allBookings;
}, [bookingsByRoom, rooms, roomColors]);
const [formSlot, setFormSlot] = useState<any>(null);
const [editBooking, setEditBooking] = useState<any>(null);
// Default to first room, always require a room to be selected
const [selectedRoomId, setSelectedRoomId] = useState<any>("");
// Set default room when rooms load
useEffect(() => {
if (rooms.length > 0 && !selectedRoomId) {
setSelectedRoomId(rooms[0].id);
}
}, [rooms, selectedRoomId]);
// Handle slot selection from calendar (month/week/day)
const handleSelectSlot = (slotInfo: any) => {
logger.debug(
"[BookingPage] Slot selected",
slotInfo,
"selectedRoomId:",
selectedRoomId
);
if (!selectedRoomId) {
logger.warn("[BookingPage] No room selected, cannot open booking form.");
return;
}
// slotInfo: { start, end, allDay, viewType }
let start = slotInfo.start ? new Date(slotInfo.start) : new Date();
let end = new Date(start.getTime() + 30 * 60000);
if (slotInfo.viewType === "month") {
// Debug: log the current time, selected day, and initial candidate
// (logger.debug moved below variable initialization)
// 1. Use selected day, but round to nearest quarter-hour of current time (all in user's local timezone)
// All Date objects in JS are in the user's local timezone unless constructed with a UTC string.
const now = new Date();
// Always construct selectedDay in local time using year/month/day from the selected date
let baseDate = slotInfo.start ? new Date(slotInfo.start) : new Date();
// Round current time to nearest quarter hour
let minutes = now.getMinutes();
let roundedMinutes = Math.round(minutes / 15) * 15;
let hour = now.getHours();
if (roundedMinutes === 60) {
hour += 1;
roundedMinutes = 0;
}
// Construct selectedDay as local time: new Date(year, month, day, hour, minute, 0, 0)
let selectedDay = new Date(
baseDate.getFullYear(),
baseDate.getMonth(),
baseDate.getDate(),
hour,
roundedMinutes,
0,
0
);
// Debug: log the current time, selected day, and initial candidate
logger.debug("[BookingPage] Timezone debug", {
now_string: now.toString(),
now_locale: now.toLocaleString(),
selectedDay_string: selectedDay.toString(),
selectedDay_locale: selectedDay.toLocaleString(),
});
// Clamp to office hours (local time)
const officeStart = 8;
const officeEnd = 18;
if (selectedDay.getHours() < officeStart)
selectedDay.setHours(officeStart, 0, 0, 0);
if (selectedDay.getHours() >= officeEnd)
selectedDay.setHours(officeEnd - 1, 45, 0, 0);
// 2. Find all bookings for this room on this day
const roomBookings = events.filter((e) => {
if (e.resource?.room_id !== selectedRoomId) return false;
const eventStart =
e.start &&
(typeof e.start === "string" ||
typeof e.start === "number" ||
e.start instanceof Date)
? new Date(e.start)
: undefined;
return (
eventStart && eventStart.toDateString() === selectedDay.toDateString()
);
});
// Build an array of [start, end] for each booking, skip if missing
const bookingBlocks = roomBookings
.map((b) => {
// Only accept string | number | Date for start/end
const validStart =
typeof b.start === "string" ||
typeof b.start === "number" ||
b.start instanceof Date;
const validEnd =
typeof b.end === "string" ||
typeof b.end === "number" ||
b.end instanceof Date;
if (!validStart || !validEnd) return undefined;
const s = new Date(b.start as string | number | Date);
const e = new Date(b.end as string | number | Date);
return [s.getTime(), e.getTime()];
})
.filter((block) => block !== undefined) as [number, number][];
// 3. Search for first available 30-min slot in 15-min increments
let found = false;
let candidate = new Date(selectedDay);
// Clamp search to office hours
const searchEnd = new Date(selectedDay);
searchEnd.setHours(officeEnd, 0, 0, 0);
while (candidate.getTime() + 30 * 60000 <= searchEnd.getTime()) {
logger.debug("[BookingPage] Checking candidate slot", {
candidate: candidate.toString(),
});
const candidateStart = candidate.getTime();
const candidateEnd = candidateStart + 30 * 60000;
// Check for overlap with any booking
const overlaps = bookingBlocks.some(
([bStart, bEnd]) => candidateStart < bEnd && candidateEnd > bStart
);
if (!overlaps) {
start = new Date(candidateStart);
end = new Date(candidateEnd);
found = true;
break;
}
candidate = new Date(candidate.getTime() + 15 * 60000);
}
if (!found) {
// fallback: just use selected day at 8am
start = new Date(selectedDay.setHours(officeStart, 0, 0, 0));
end = new Date(start.getTime() + 30 * 60000);
}
} else if (slotInfo.start) {
// week/day view: use clicked slot
start = new Date(slotInfo.start);
end = new Date(start.getTime() + 30 * 60000);
}
setFormSlot({ start, end, room_id: selectedRoomId });
setEditBooking(null);
setFormOpen(true);
};
const handleSelectEvent = (event: EventInput) => {
logger.debug("[BookingPage] Event selected for edit", event);
setEditBooking(event);
setFormSlot(null);
setFormOpen(true);
};
const refetchBookings = async () => {
setBookingsLoading(true);
setBookingsError(null);
const results: Record<number, any[]> = {};
try {
await Promise.all(
rooms.map(async (room: any) => {
try {
const bookings = await getRoomBookings(room.id);
results[room.id] = bookings;
logger.debug(
`[BookingPage] Bookings refetched for room ${room.id}`,
bookings
);
} catch (e) {
results[room.id] = [];
logger.warn(
`[BookingPage] Failed to refetch bookings for room ${room.id}`
);
}
})
);
setBookingsByRoom(results);
logger.info("[BookingPage] Bookings refetched and set");
} catch (err) {
setBookingsError(err);
logger.error("[BookingPage] Error refetching bookings", err);
} finally {
setBookingsLoading(false);
}
};
const handleFormClose = (refresh = false) => {
logger.info("[BookingPage] Booking form closed", { refresh });
setFormOpen(false);
setFormSlot(null);
setEditBooking(null);
if (refresh) {
logger.info("[BookingPage] Refetching bookings after form close");
refetchBookings();
}
};
// (removed old handleSelectEvent)
if (roomsLoading || bookingsLoading || inviteesLoading) {
return <div style={{ padding: 24 }}>Loading calendar...</div>;
}
if (roomsError || bookingsError || inviteesError) {
return (
<div style={{ padding: 24, color: "red" }}>
Error loading calendar data.
</div>
);
}
return (
<div style={{ padding: 24 }}>
<h2>Book a Room</h2>
{usingPollingFallback && (
<Box
mb={2}
sx={{
background: "#fff3cd",
color: "#856404",
border: "1px solid #ffeeba",
borderRadius: 2,
p: 2,
}}
>
<strong>Warning:</strong> Your browser does not support real-time
updates. The calendar will refresh every 10 seconds.
</Box>
)}
<Box display="flex" alignItems="center" mb={2}>
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel id="room-select-label">Room</InputLabel>
<Select
labelId="room-select-label"
value={selectedRoomId}
label="Room"
onChange={(e) => setSelectedRoomId(e.target.value)}
>
{rooms.map((room: any) => (
<MenuItem key={room.id} value={room.id}>
{room.name}
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="outlined"
sx={{ ml: 2 }}
onClick={() => setRoomModalOpen(true)}
disabled={!selectedRoomId}
>
View Room & Equipment Details
</Button>
</Box>
<RoomDetailsModal
open={roomModalOpen}
onClose={() => setRoomModalOpen(false)}
room={rooms.find((r: any) => r.id === selectedRoomId)}
/>
<CalendarView
events={events}
onEventClick={(event) => handleSelectEvent(event)}
onSlotSelect={handleSelectSlot}
selectedRoomId={selectedRoomId}
/>
{formOpen && (
<BookingForm
open={formOpen}
onClose={() => handleFormClose(true)}
slotInfo={formSlot}
rooms={rooms}
editBooking={editBooking}
allInvitees={allInvitees.map((u: any) => u.email)}
onBookingSuccess={() => handleFormClose(true)}
/>
)}
</div>
);
};
export default BookingPage;