diff --git a/frontend/src/components/BookingForm.tsx b/frontend/src/components/BookingForm.tsx index 1d1c549c..6453455c 100644 --- a/frontend/src/components/BookingForm.tsx +++ b/frontend/src/components/BookingForm.tsx @@ -9,6 +9,19 @@ import React, { useState, useEffect, useRef } from "react"; import { useNavigate } from "react-router-dom"; +import { + formatLocalDateTimeInput, + roundToStrictlyFutureQuarter, +} from "../utils/date"; +import { getEditBookingRoomId, formatBookingError } from "../helpers/booking"; +import { + validateRoomId, + validateStart, + validateEnd, + validateInvitees, + validateInviteeEmail, + validateRoomAvailability, +} from "../helpers/validation"; import { getRoomBookings, createBooking, @@ -53,51 +66,7 @@ interface BookingFormProps { // BookingForm: form for selecting room, date, time, title, and invitees -// Format Date as 'YYYY-MM-DDTHH:mm' for datetime-local input -function formatLocalDateTimeInput(date: Date): string { - function pad(n: number): string { - return n < 10 ? "0" + n : n.toString(); - } - const yyyy = date.getFullYear(); - const mm = pad(date.getMonth() + 1); - const dd = pad(date.getDate()); - const hh = pad(date.getHours()); - const min = pad(date.getMinutes()); - return `${yyyy}-${mm}-${dd}T${hh}:${min}`; -} - -// Extract room_id from editBooking -function getEditBookingRoomId(editBooking: any): string { - // Try FullCalendar event object structure first - let id = - editBooking?._def?.extendedProps?.resource?.room_id ?? - editBooking?._def?.extendedProps?.resource?.id ?? - editBooking?.resource?.room_id ?? - editBooking?.room_id ?? - editBooking?.room?.id ?? - (typeof editBooking === "object" && "roomId" in editBooking - ? editBooking.roomId - : undefined) ?? - (typeof editBooking === "object" && - "room" in editBooking && - editBooking.room && - "id" in editBooking.room - ? editBooking.room.id - : undefined) ?? - ""; - if (!id && typeof editBooking === "object") { - for (const k of Object.keys(editBooking)) { - if ( - k.toLowerCase().includes("room") && - typeof editBooking[k] === "number" - ) { - id = editBooking[k]; - break; - } - } - } - return id !== undefined && id !== null ? String(id) : ""; -} +// ...existing code... const BookingForm: React.FC = ({ open, @@ -413,88 +382,55 @@ const BookingForm: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [room_id, start, end, isEdit, editBooking?.id]); - // Helper: round a Date up to the next strictly future quarter hour - function roundToStrictlyFutureQuarter(dt: Date): Date { - const copy = new Date(dt.getTime()); - copy.setSeconds(0, 0); - let add = 15 - (copy.getMinutes() % 15); - if (add === 0) add = 15; - copy.setMinutes(copy.getMinutes() + add); - return copy; - } + // ...existing code... const [errors, setErrors] = useState<{ [key: string]: string }>({}); const [submitError, setSubmitError] = useState(null); - function validate() { + // Independent field validation + function validateFields() { const newErrors: { [key: string]: string } = {}; - if (!room_id) newErrors.room_id = "Room is required."; - if (!start) newErrors.start = "Start time is required."; - if (!end) newErrors.end = "End time is required."; - if (start && end && new Date(start) >= new Date(end)) - newErrors.end = "End time must be after start time."; - if (invitees.length > roomCapacity) { - newErrors.invitees = `The number of invitees exceeds the room's capacity (${roomCapacity}).`; - } + // Room ID + const roomIdError = validateRoomId(room_id); + if (roomIdError) newErrors.room_id = roomIdError; + // Start + const startError = validateStart(start); + if (startError) newErrors.start = startError; + // End + const endError = validateEnd(end, start); + if (endError) newErrors.end = endError; + // Invitees + const inviteesError = validateInvitees(invitees, roomCapacity); + if (inviteesError) newErrors.invitees = inviteesError; + // Individual invitee emails let hasInviteeError = false; invitees.forEach((email, idx) => { - if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { - newErrors[`invitee_${idx}`] = `Invalid email: ${email}`; + const emailError = validateInviteeEmail(email); + if (emailError) { + newErrors[`invitee_${idx}`] = emailError; hasInviteeError = true; } }); if (hasInviteeError) { newErrors.invitees = "One or more invitees have invalid email addresses."; } - - // Overlap validation for start/end time (selected room) + // Room availability if (room_id && start && end && roomBookings.length > 0) { - // Filter out the current booking if editing - const filteredBookings = - isEdit && editBooking?.id - ? roomBookings.filter((b: any) => b.id !== editBooking.id) - : roomBookings; - // Only mark as conflict if there is actual overlap (not back-to-back) - let startConflict = false; - let endConflict = false; - filteredBookings.forEach((b: any) => { - // Overlap if start < b.end_time && end > b.start_time - if ( - new Date(start) < new Date(b.end_time) && - new Date(end) > new Date(b.start_time) - ) { - // Mark which field overlaps - if ( - new Date(start) < new Date(b.end_time) && - new Date(start) >= new Date(b.start_time) - ) { - startConflict = true; - } - if ( - new Date(end) > new Date(b.start_time) && - new Date(end) <= new Date(b.end_time) - ) { - endConflict = true; - } - } - }); - if (startConflict || endConflict) { - newErrors.room_id = "Room is unavailable for the selected time."; - } else { - // If no conflict, ensure room_id error is cleared - if (newErrors.room_id) delete newErrors.room_id; - } - if (startConflict) { - newErrors.start = "Start time overlaps with another booking."; - } - if (endConflict) { - newErrors.end = "End time overlaps with another booking."; - } + const availabilityErrors = validateRoomAvailability( + room_id, + start, + end, + roomBookings, + isEdit, + editBooking + ); + if (availabilityErrors.roomError) + newErrors.room_id = availabilityErrors.roomError; + if (availabilityErrors.startError) + newErrors.start = availabilityErrors.startError; + if (availabilityErrors.endError) + newErrors.end = availabilityErrors.endError; } - if (start && new Date(start) < new Date()) { - newErrors.start = "Start time cannot be in the past."; - } - setErrors(newErrors); if (Object.keys(newErrors).length > 0) { logger.warn("[BookingForm] Validation failed", newErrors); @@ -533,7 +469,7 @@ const BookingForm: React.FC = ({ // Move this effect after roomBookings is defined useEffect(() => { - validate(); + validateFields(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [room_id, invitees, start, end, roomBookings]); @@ -542,7 +478,7 @@ const BookingForm: React.FC = ({ if (submitting) return; setSubmitting(true); setSubmitError(null); - if (!validate()) { + if (!validateFields()) { setSubmitting(false); return; } @@ -663,18 +599,7 @@ const BookingForm: React.FC = ({ } }; - // Helper to format booking errors for user-friendly messages - function formatBookingError(detail: string): string { - if (!detail) return "An unknown error occurred."; - const lower = detail.toLowerCase(); - if (lower.includes("overlap")) { - return "The selected room is already booked for the chosen time. Please pick a different time or room."; - } - if (lower.includes("capacity") || lower.includes("attendee")) { - return "The number of invitees exceeds the room's capacity. Please remove some invitees or choose a larger room."; - } - return detail; - } + // ...existing code... const handleDelete = async () => { if (!isEdit) return; diff --git a/frontend/src/components/BookingList.tsx b/frontend/src/components/BookingList.tsx index 87c2494a..86be88f2 100644 --- a/frontend/src/components/BookingList.tsx +++ b/frontend/src/components/BookingList.tsx @@ -10,6 +10,11 @@ import React from "react"; import { List, Typography, ListItemButton } from "@mui/material"; import type { BookingListProps } from "../schemas"; +import { + sortBookingsByStartTime, + formatBookingTime, + getInviteeName, +} from "../helpers/bookingList"; /** * BookingList component. @@ -43,35 +48,11 @@ const BookingList: React.FC = ({ bookings, onSelect }) => { ); } // Sort bookings by start_time ascending (oldest first) - const sortedBookings = [...bookings].sort( - (a, b) => - new Date(a.start_time).getTime() - new Date(b.start_time).getTime() - ); + const sortedBookings = sortBookingsByStartTime(bookings); return ( {sortedBookings.map((booking) => { // Format time as h:mm AM/PM - const formatTime = (dateStr: string) => { - const d = new Date(dateStr); - return d.toLocaleTimeString([], { - hour: "numeric", - minute: "2-digit", - hour12: true, - }); - }; - // Robust invitee name extraction - const getInviteeName = (inv: any) => { - if (typeof inv === "string") return inv; - if (inv && typeof inv === "object") { - if (typeof inv.name === "string") return inv.name; - if (typeof inv.username === "string") return inv.username; - if (typeof inv.displayName === "string") return inv.displayName; - // Check for nested user object with name - if (inv.user && typeof inv.user.name === "string") - return inv.user.name; - } - return ""; - }; const inviteeNames = booking.invitees ? booking.invitees.map(getInviteeName).filter(Boolean) : []; @@ -85,9 +66,9 @@ const BookingList: React.FC = ({ bookings, onSelect }) => { style={{ fontWeight: 600, color: "#23272f", fontSize: "1rem" }} > {booking.start_time && booking.end_time - ? `${formatTime(booking.start_time)} – ${formatTime( - booking.end_time - )}` + ? `${formatBookingTime( + booking.start_time + )}  ${formatBookingTime(booking.end_time)}` : "Time not set"} {inviteeNames.length > 0 && ( diff --git a/frontend/src/components/CalendarView.tsx b/frontend/src/components/CalendarView.tsx index cb6db515..46235d52 100644 --- a/frontend/src/components/CalendarView.tsx +++ b/frontend/src/components/CalendarView.tsx @@ -19,6 +19,7 @@ import { EventInput } from "@fullcalendar/core"; import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; import "./CalendarView.css"; +import { getEventDisplayText, getRoomClass } from "../helpers/calendar"; /** * Props for CalendarView. @@ -53,23 +54,8 @@ const CalendarView: React.FC = ({ extendedProps?.resource?.roomId || "Unknown"; const invitees = extendedProps?.resource?.invitees || []; - // Format start time as HH:mm - const startTime = start - ? new Date(start).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }) - : "-"; - // Display: ' - ' or '<start time> - <room name>' - const displayText = - title && title.trim() !== "" - ? `${startTime} - ${title}` - : `${startTime} - ${room}`; - // Assign CSS class for room color - const roomIdx = event.extendedProps?.resource?.room_id - ? parseInt(event.extendedProps.resource.room_id, 10) % 8 - : 0; - const roomClass = `room-color-${roomIdx}`; + const displayText = getEventDisplayText(title, start, room); + const roomClass = getRoomClass(extendedProps?.resource?.room_id); return ( <Tooltip title={ diff --git a/frontend/src/components/RoomList.tsx b/frontend/src/components/RoomList.tsx index 125db0a7..258ef80a 100644 --- a/frontend/src/components/RoomList.tsx +++ b/frontend/src/components/RoomList.tsx @@ -24,6 +24,7 @@ import { import MeetingRoomIcon from "@mui/icons-material/MeetingRoom"; import { FC } from "react"; import type { RoomListProps } from "../schemas"; +import { getRoomListItemStyles, getRoomSecondaryText } from "../helpers/room"; /** * Renders a list of conference rooms with their details. @@ -99,21 +100,11 @@ const RoomList: FC<RoomListProps> = ({ }} aria-label={`Select room ${room.name}`} data-testid={`room-item-${room.id}`} - sx={ - room.id === selectedRoomId - ? { - backgroundColor: "#ececec !important", - fontWeight: 700, - color: "#23272f", - border: "1px solid #23272f", - borderRadius: 4, - } - : { color: "#23272f" } - } + sx={getRoomListItemStyles(room.id, selectedRoomId)} > <ListItemText primary={room.name} - secondary={`Location: ${room.location} | Capacity: ${room.capacity} | Equipment: ${room.equipment}`} + secondary={getRoomSecondaryText(room)} /> </ListItemButton> ) : ( @@ -124,7 +115,7 @@ const RoomList: FC<RoomListProps> = ({ > <ListItemText primary={room.name} - secondary={`Location: ${room.location} | Capacity: ${room.capacity} | Equipment: ${room.equipment}`} + secondary={getRoomSecondaryText(room)} /> </ListItem> ) diff --git a/frontend/src/helpers/booking.ts b/frontend/src/helpers/booking.ts new file mode 100644 index 00000000..9607bbb8 --- /dev/null +++ b/frontend/src/helpers/booking.ts @@ -0,0 +1,45 @@ +// booking.ts +// Utility functions for booking-related logic + +export function getEditBookingRoomId(editBooking: any): string { + let id = + editBooking?._def?.extendedProps?.resource?.room_id ?? + editBooking?._def?.extendedProps?.resource?.id ?? + editBooking?.resource?.room_id ?? + editBooking?.room_id ?? + editBooking?.room?.id ?? + (typeof editBooking === "object" && "roomId" in editBooking + ? editBooking.roomId + : undefined) ?? + (typeof editBooking === "object" && + "room" in editBooking && + editBooking.room && + "id" in editBooking.room + ? editBooking.room.id + : undefined) ?? + ""; + if (!id && typeof editBooking === "object") { + for (const k of Object.keys(editBooking)) { + if ( + k.toLowerCase().includes("room") && + typeof editBooking[k] === "number" + ) { + id = editBooking[k]; + break; + } + } + } + return id !== undefined && id !== null ? String(id) : ""; +} + +export function formatBookingError(detail: string): string { + if (!detail) return "An unknown error occurred."; + const lower = detail.toLowerCase(); + if (lower.includes("overlap")) { + return "The selected room is already booked for the chosen time. Please pick a different time or room."; + } + if (lower.includes("capacity") || lower.includes("attendee")) { + return "The number of invitees exceeds the room's capacity. Please remove some invitees or choose a larger room."; + } + return detail; +} diff --git a/frontend/src/helpers/bookingList.ts b/frontend/src/helpers/bookingList.ts new file mode 100644 index 00000000..d3288133 --- /dev/null +++ b/frontend/src/helpers/bookingList.ts @@ -0,0 +1,29 @@ +// bookingList.ts +// Utility functions for BookingList component + +export function sortBookingsByStartTime(bookings: any[]): any[] { + return [...bookings].sort( + (a, b) => + new Date(a.start_time).getTime() - new Date(b.start_time).getTime() + ); +} + +export function formatBookingTime(dateStr: string): string { + const d = new Date(dateStr); + return d.toLocaleTimeString([], { + hour: "numeric", + minute: "2-digit", + hour12: true, + }); +} + +export function getInviteeName(inv: any): string { + if (typeof inv === "string") return inv; + if (inv && typeof inv === "object") { + if (typeof inv.name === "string") return inv.name; + if (typeof inv.username === "string") return inv.username; + if (typeof inv.displayName === "string") return inv.displayName; + if (inv.user && typeof inv.user.name === "string") return inv.user.name; + } + return ""; +} diff --git a/frontend/src/helpers/calendar.ts b/frontend/src/helpers/calendar.ts new file mode 100644 index 00000000..42a04899 --- /dev/null +++ b/frontend/src/helpers/calendar.ts @@ -0,0 +1,20 @@ +// calendar.ts +// Utility functions for CalendarView component + +export function getEventDisplayText( + title: string, + start: Date, + room: string +): string { + const startTime = start + ? start.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + : "-"; + return title && title.trim() !== "" + ? `${startTime} - ${title}` + : `${startTime} - ${room}`; +} + +export function getRoomClass(roomId: any): string { + const idx = roomId ? parseInt(roomId, 10) % 8 : 0; + return `room-color-${idx}`; +} diff --git a/frontend/src/helpers/room.ts b/frontend/src/helpers/room.ts new file mode 100644 index 00000000..9efc54f8 --- /dev/null +++ b/frontend/src/helpers/room.ts @@ -0,0 +1,19 @@ +// room.ts +// Utility functions for RoomList component + +export function getRoomSecondaryText(room: any): string { + return `Location: ${room.location} | Capacity: ${room.capacity} | Equipment: ${room.equipment}`; +} + +export function getRoomListItemStyles(roomId: any, selectedRoomId: any): any { + if (roomId === selectedRoomId) { + return { + backgroundColor: "#ececec !important", + fontWeight: 700, + color: "#23272f", + border: "1px solid #23272f", + borderRadius: 4, + }; + } + return { color: "#23272f" }; +} diff --git a/frontend/src/helpers/validation.ts b/frontend/src/helpers/validation.ts new file mode 100644 index 00000000..15538c42 --- /dev/null +++ b/frontend/src/helpers/validation.ts @@ -0,0 +1,91 @@ +// validation.ts +// Independent field validation helpers for BookingForm + +export function validateRoomId(room_id: string | undefined): string | null { + if (!room_id) return "Room is required."; + return null; +} + +export function validateStart(start: string | undefined): string | null { + if (!start) return "Start time is required."; + if (start && new Date(start) < new Date()) + return "Start time cannot be in the past."; + return null; +} + +export function validateEnd( + end: string | undefined, + start?: string +): string | null { + if (!end) return "End time is required."; + if (start && end && new Date(start) >= new Date(end)) + return "End time must be after start time."; + if (end && new Date(end) < new Date()) + return "End time cannot be in the past."; + return null; +} + +export function validateInvitees( + invitees: string[], + roomCapacity: number +): string | null { + if (invitees.length > roomCapacity) { + return `The number of invitees exceeds the room's capacity (${roomCapacity}).`; + } + return null; +} + +export function validateInviteeEmail(email: string): string | null { + if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { + return `Invalid email: ${email}`; + } + return null; +} + +export function validateRoomAvailability( + room_id: string, + start: string, + end: string, + roomBookings: any[], + isEdit: boolean, + editBooking: any +): { roomError?: string; startError?: string; endError?: string } { + // Filter out the current booking if editing + const filteredBookings = + isEdit && editBooking?.id + ? roomBookings.filter((b: any) => b.id !== editBooking.id) + : roomBookings; + let startConflict = false; + let endConflict = false; + filteredBookings.forEach((b: any) => { + if ( + new Date(start) < new Date(b.end_time) && + new Date(end) > new Date(b.start_time) + ) { + if ( + new Date(start) < new Date(b.end_time) && + new Date(start) >= new Date(b.start_time) + ) { + startConflict = true; + } + if ( + new Date(end) > new Date(b.start_time) && + new Date(end) <= new Date(b.end_time) + ) { + endConflict = true; + } + } + }); + const errors: { roomError?: string; startError?: string; endError?: string } = + {}; + if (startConflict || endConflict) { + errors.roomError = "Room is unavailable for the selected time."; + } + if (startConflict) { + errors.startError = "Start time overlaps with another booking."; + } + if (endConflict) { + errors.endError = "End time overlaps with another booking."; + } + return errors; +} diff --git a/frontend/src/utils/date.ts b/frontend/src/utils/date.ts new file mode 100644 index 00000000..4fec8d49 --- /dev/null +++ b/frontend/src/utils/date.ts @@ -0,0 +1,23 @@ +// date.ts +// Utility functions for date and time formatting and manipulation + +export function formatLocalDateTimeInput(date: Date): string { + function pad(n: number): string { + return n < 10 ? "0" + n : n.toString(); + } + const yyyy = date.getFullYear(); + const mm = pad(date.getMonth() + 1); + const dd = pad(date.getDate()); + const hh = pad(date.getHours()); + const min = pad(date.getMinutes()); + return `${yyyy}-${mm}-${dd}T${hh}:${min}`; +} + +export function roundToStrictlyFutureQuarter(dt: Date): Date { + const copy = new Date(dt.getTime()); + copy.setSeconds(0, 0); + let add = 15 - (copy.getMinutes() % 15); + if (add === 0) add = 15; + copy.setMinutes(copy.getMinutes() + add); + return copy; +}