mirror of
https://github.com/xlorepdarkhelm/numinar-coding-project.git
synced 2026-09-09 11:59:48 -04:00
485 lines
16 KiB
TypeScript
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;
|