Public Access
mirror of
https://github.com/xlorepdarkhelm/numinar-coding-project.git
synced 2026-09-11 18:37:36 -04:00
Working on the calendar stuff.
Signed-off-by: Cliff Hill <xlorep@darkhelm.org>
This commit is contained in:
@@ -8,14 +8,8 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
getRoomBookings,
|
||||
createBooking,
|
||||
updateBooking,
|
||||
deleteBooking,
|
||||
} from "../apis/bookings";
|
||||
import { getEditBookingRoomId, formatBookingError } from "../helpers/booking";
|
||||
import { getRoomBookings, deleteBooking } from "../apis/bookings";
|
||||
import { getEditBookingRoomId } from "../helpers/booking";
|
||||
import {
|
||||
validateRoomId,
|
||||
validateStart,
|
||||
@@ -24,14 +18,11 @@ import {
|
||||
validateInviteeEmail,
|
||||
validateRoomAvailability,
|
||||
} from "../helpers/validation";
|
||||
import {
|
||||
formatLocalDateTimeInput,
|
||||
roundToStrictlyFutureQuarter,
|
||||
} from "../utils/date";
|
||||
import { roundToStrictlyFutureQuarter } from "../utils/date";
|
||||
import { connectRoomsAvailabilityStream } from "../apis/sse";
|
||||
import { getInvitees, addInvitee, removeInvitee } from "../apis/invitees";
|
||||
import { getInvitees } from "../apis/invitees";
|
||||
import { getAvailableUsers } from "../apis/users";
|
||||
import type { ConferenceRoom } from "../schemas";
|
||||
import type { ConferenceRoom, Booking } from "../schemas";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -52,14 +43,23 @@ import CircularProgress from "@mui/material/CircularProgress";
|
||||
import InfoIcon from "@mui/icons-material/InfoOutlined";
|
||||
import RoomDetailsModal from "./RoomDetailsModal";
|
||||
import { logger } from "../utils/logger";
|
||||
import { SelectChangeEvent } from "@mui/material/Select";
|
||||
|
||||
// Remove import of BookingFormProps from schemas, and define BookingFormProps locally with correct types using ConferenceRoom
|
||||
interface SlotInfo {
|
||||
start: Date | string;
|
||||
end: Date | string;
|
||||
room_id?: string | number;
|
||||
allDay?: boolean;
|
||||
viewType?: string;
|
||||
}
|
||||
|
||||
interface BookingFormProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
slotInfo: any;
|
||||
slotInfo: SlotInfo;
|
||||
rooms: ConferenceRoom[];
|
||||
editBooking?: any;
|
||||
editBooking?: Booking;
|
||||
onBookingSuccess?: () => void;
|
||||
allInvitees: string[];
|
||||
}
|
||||
@@ -77,10 +77,36 @@ const BookingForm: React.FC<BookingFormProps> = ({
|
||||
onBookingSuccess,
|
||||
allInvitees,
|
||||
}: BookingFormProps) => {
|
||||
const navigate = useNavigate();
|
||||
// Calculate initial start/end before state declarations to avoid ReferenceError
|
||||
// Calculate initial start/end before state declarations to avoid ReferenceError
|
||||
const initialStart = editBooking?.start_time
|
||||
? editBooking.start_time
|
||||
: slotInfo?.start
|
||||
? typeof slotInfo.start === "string"
|
||||
? slotInfo.start
|
||||
: slotInfo.start.toISOString()
|
||||
: "";
|
||||
|
||||
const initialEnd = editBooking?.end_time
|
||||
? editBooking.end_time
|
||||
: slotInfo?.end
|
||||
? typeof slotInfo.end === "string"
|
||||
? slotInfo.end
|
||||
: slotInfo.end.toISOString()
|
||||
: "";
|
||||
|
||||
// State and refs
|
||||
const [roomModalOpen, setRoomModalOpen] = useState(false);
|
||||
|
||||
const isEdit = !!editBooking;
|
||||
// View mode: if editing and start_time is in the past
|
||||
const now = new Date();
|
||||
const bookingStart = editBooking?.start_time
|
||||
? new Date(editBooking.start_time)
|
||||
: undefined;
|
||||
const isViewMode: boolean = Boolean(
|
||||
isEdit && bookingStart && bookingStart < now
|
||||
);
|
||||
|
||||
// Submission state
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -104,199 +130,92 @@ const BookingForm: React.FC<BookingFormProps> = ({
|
||||
}
|
||||
});
|
||||
|
||||
// Re-validate form on any relevant change to keep errors and button state in sync
|
||||
// (moved below state declarations)
|
||||
const [start, setStart] = useState<string>(initialStart);
|
||||
const [end, setEnd] = useState<string>(initialEnd);
|
||||
const [title, setTitle] = useState<string>(editBooking?.title || "");
|
||||
|
||||
// Track interval between start and end
|
||||
const [intervalMinutes, setIntervalMinutes] = useState(() => {
|
||||
if (isEdit && editBooking?.start && editBooking?.end) {
|
||||
const startDate = new Date(editBooking.start);
|
||||
const endDate = new Date(editBooking.end);
|
||||
return Math.max(
|
||||
1,
|
||||
Math.round((endDate.getTime() - startDate.getTime()) / 60000)
|
||||
);
|
||||
}
|
||||
if (slotInfo?.start && slotInfo?.end) {
|
||||
const startDate = new Date(slotInfo.start);
|
||||
const endDate = new Date(slotInfo.end);
|
||||
return Math.max(
|
||||
1,
|
||||
Math.round((endDate.getTime() - startDate.getTime()) / 60000)
|
||||
);
|
||||
}
|
||||
return 30;
|
||||
});
|
||||
useEffect(() => {
|
||||
if (editBooking && editBooking.id) {
|
||||
const currentId = String(editBooking.id);
|
||||
const intendedRoomId = getEditBookingRoomId(editBooking);
|
||||
// Only set room if switching bookings or on initial mount, and only if user hasn't changed it
|
||||
if (
|
||||
lastBookingId.current !== currentId ||
|
||||
(intendedRoomId &&
|
||||
intendedRoomId !== room_id &&
|
||||
!userChangedRoom.current)
|
||||
) {
|
||||
setRoomId(intendedRoomId);
|
||||
lastBookingId.current = currentId;
|
||||
userChangedRoom.current = false;
|
||||
}
|
||||
} else if (!editBooking && slotInfo && slotInfo.room_id) {
|
||||
if (lastBookingId.current !== null && !userChangedRoom.current) {
|
||||
setRoomId(String(slotInfo.room_id));
|
||||
lastBookingId.current = null;
|
||||
userChangedRoom.current = false;
|
||||
}
|
||||
} else if (
|
||||
!editBooking &&
|
||||
(!room_id || !rooms.some((r) => String(r.id) === String(room_id))) &&
|
||||
rooms &&
|
||||
rooms.length > 0 &&
|
||||
!userChangedRoom.current
|
||||
) {
|
||||
setRoomId(String(rooms[0].id));
|
||||
lastBookingId.current = null;
|
||||
userChangedRoom.current = false;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editBooking, slotInfo, rooms, room_id]);
|
||||
|
||||
const handleRoomChange = (e: any) => {
|
||||
userChangedRoom.current = true;
|
||||
setRoomId(e.target.value);
|
||||
};
|
||||
|
||||
// Only set room_id from editBooking or rooms on initial mount
|
||||
// (do not overwrite user changes after initial load)
|
||||
// If you want to reset when switching bookings, you can add logic for that case only
|
||||
|
||||
// Keep room_id in sync with editBooking or rooms (no debug log)
|
||||
useEffect(() => {
|
||||
if (editBooking && editBooking.id) {
|
||||
const currentId = String(editBooking.id);
|
||||
const intendedRoomId = getEditBookingRoomId(editBooking);
|
||||
// Only set room if switching bookings or on initial mount, and only if user hasn't changed it
|
||||
if (
|
||||
lastBookingId.current !== currentId ||
|
||||
(intendedRoomId &&
|
||||
intendedRoomId !== room_id &&
|
||||
!userChangedRoom.current)
|
||||
) {
|
||||
setRoomId(intendedRoomId);
|
||||
lastBookingId.current = currentId;
|
||||
userChangedRoom.current = false;
|
||||
}
|
||||
} else if (!editBooking && slotInfo && slotInfo.room_id) {
|
||||
if (lastBookingId.current !== null && !userChangedRoom.current) {
|
||||
setRoomId(String(slotInfo.room_id));
|
||||
lastBookingId.current = null;
|
||||
userChangedRoom.current = false;
|
||||
}
|
||||
} else if (
|
||||
!editBooking &&
|
||||
(!room_id || !rooms.some((r) => String(r.id) === String(room_id))) &&
|
||||
rooms &&
|
||||
rooms.length > 0 &&
|
||||
!userChangedRoom.current
|
||||
) {
|
||||
setRoomId(String(rooms[0].id));
|
||||
lastBookingId.current = null;
|
||||
userChangedRoom.current = false;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editBooking, slotInfo, rooms, room_id]);
|
||||
|
||||
// Fallback: if room_id is still empty but rooms exist, set to first room
|
||||
useEffect(() => {
|
||||
if (!room_id && rooms && rooms.length > 0) {
|
||||
setRoomId(String(rooms[0].id));
|
||||
}
|
||||
}, [room_id, rooms]);
|
||||
|
||||
// Place these hooks after all state and helper declarations
|
||||
// (Removed duplicate debug log)
|
||||
useEffect(() => {
|
||||
if (!room_id && rooms && rooms.length > 0) {
|
||||
setRoomId(String(rooms[0].id));
|
||||
}
|
||||
}, [room_id, rooms]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!room_id && rooms && rooms.length > 0) {
|
||||
setRoomId(String(rooms[0].id));
|
||||
}
|
||||
}, [room_id, rooms]);
|
||||
|
||||
const [title, setTitle] = useState(isEdit ? editBooking.title : "");
|
||||
// Compute initial start/end for new bookings
|
||||
// For new bookings, use slotInfo.start/end if provided, otherwise next quarter hour from now
|
||||
const [start, setStart] = useState<string>("");
|
||||
const [end, setEnd] = useState<string>("");
|
||||
const userChangedStart = useRef(false);
|
||||
|
||||
// On mount, room change, or after booking, always fetch latest bookings before suggesting next available slot
|
||||
// Effect for edit mode: populate start/end from editBooking or slotInfo
|
||||
useEffect(() => {
|
||||
if (userChangedStart.current) return;
|
||||
if (isEdit) {
|
||||
setStart(editBooking?.start || "");
|
||||
// Always set end to 30 minutes after start for initial booking
|
||||
if (editBooking?.start) {
|
||||
const startDate = new Date(editBooking.start);
|
||||
setEnd(new Date(startDate.getTime() + 30 * 60000).toISOString());
|
||||
} else {
|
||||
setEnd(editBooking?.end || "");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!open || !isEdit) return;
|
||||
userChangedStart.current = false;
|
||||
const rawStart = editBooking?.start_time
|
||||
? typeof editBooking.start_time === "string"
|
||||
? editBooking.start_time
|
||||
: new Date(editBooking.start_time).toISOString()
|
||||
: slotInfo?.start
|
||||
? typeof slotInfo.start === "string"
|
||||
? slotInfo.start
|
||||
: new Date(slotInfo.start).toISOString()
|
||||
: "";
|
||||
const rawEnd = editBooking?.end_time
|
||||
? typeof editBooking.end_time === "string"
|
||||
? editBooking.end_time
|
||||
: new Date(editBooking.end_time).toISOString()
|
||||
: slotInfo?.end
|
||||
? typeof slotInfo.end === "string"
|
||||
? slotInfo.end
|
||||
: new Date(slotInfo.end).toISOString()
|
||||
: "";
|
||||
setStart(rawStart);
|
||||
setEnd(rawEnd);
|
||||
}, [open, isEdit, editBooking, slotInfo]);
|
||||
|
||||
// Effect for new mode: populate start/end only after room_id is set
|
||||
useEffect(() => {
|
||||
if (!open || isEdit || !room_id) return;
|
||||
userChangedStart.current = false;
|
||||
const now = new Date();
|
||||
if (slotInfo?.start) {
|
||||
const slotStart = new Date(slotInfo.start);
|
||||
if (slotStart > now) {
|
||||
setStart(slotInfo.start);
|
||||
// Always set end to 30 minutes after start
|
||||
setStart(
|
||||
typeof slotInfo.start === "string"
|
||||
? slotInfo.start
|
||||
: slotInfo.start.toISOString()
|
||||
);
|
||||
setEnd(new Date(slotStart.getTime() + 30 * 60000).toISOString());
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (room_id) {
|
||||
// Always fetch latest bookings before suggesting slot
|
||||
getRoomBookings(room_id, now.toISOString().slice(0, 10)).then(
|
||||
(bookings) => {
|
||||
let candidate = roundToStrictlyFutureQuarter(now);
|
||||
for (let i = 0; i < 96; i++) {
|
||||
const candidateCopy = new Date(candidate);
|
||||
const candidateEndCopy = new Date(
|
||||
candidateCopy.getTime() + 30 * 60000
|
||||
);
|
||||
const conflict = bookings.find(
|
||||
(b: { start_time: string; end_time: string }) =>
|
||||
candidateCopy.toISOString() < b.end_time &&
|
||||
candidateEndCopy.toISOString() > b.start_time
|
||||
);
|
||||
if (!conflict) {
|
||||
setStart(candidateCopy.toISOString());
|
||||
setEnd(candidateEndCopy.toISOString());
|
||||
return;
|
||||
}
|
||||
candidate = new Date(conflict.end_time);
|
||||
if (candidate.getMinutes() % 15 !== 0) {
|
||||
candidate = roundToStrictlyFutureQuarter(candidate);
|
||||
}
|
||||
getRoomBookings(room_id, now.toISOString().slice(0, 10)).then(
|
||||
(bookings) => {
|
||||
let candidate = roundToStrictlyFutureQuarter(now);
|
||||
for (let i = 0; i < 96; i++) {
|
||||
const candidateCopy = new Date(candidate);
|
||||
const candidateEndCopy = new Date(
|
||||
candidateCopy.getTime() + 30 * 60000
|
||||
);
|
||||
const conflict = bookings.find(
|
||||
(b: { start_time: string; end_time: string }) =>
|
||||
candidateCopy.toISOString() < b.end_time &&
|
||||
candidateEndCopy.toISOString() > b.start_time
|
||||
);
|
||||
if (!conflict) {
|
||||
setStart(candidateCopy.toISOString());
|
||||
setEnd(candidateEndCopy.toISOString());
|
||||
return;
|
||||
}
|
||||
candidate = new Date(conflict.end_time);
|
||||
if (candidate.getMinutes() % 15 !== 0) {
|
||||
candidate = roundToStrictlyFutureQuarter(candidate);
|
||||
}
|
||||
// fallback: just use next quarter hour
|
||||
setStart(candidate.toISOString());
|
||||
setEnd(new Date(candidate.getTime() + 30 * 60000).toISOString());
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [room_id, isEdit, slotInfo, editBooking?.end, editBooking?.start]);
|
||||
setStart(candidate.toISOString());
|
||||
setEnd(new Date(candidate.getTime() + 30 * 60000).toISOString());
|
||||
}
|
||||
);
|
||||
}, [open, isEdit, room_id, slotInfo]);
|
||||
const [invitees, setInvitees] = useState<string[]>([]);
|
||||
const [availableInvitees, setAvailableInvitees] =
|
||||
useState<string[]>(allInvitees);
|
||||
const [loadingInvitees, setLoadingInvitees] = useState(false);
|
||||
// Get current room object and capacity
|
||||
const currentRoom = rooms.find((r: any) => String(r.id) === String(room_id));
|
||||
const currentRoom = rooms.find(
|
||||
(r: ConferenceRoom) => String(r.id) === String(room_id)
|
||||
);
|
||||
const roomCapacity = currentRoom?.capacity || Infinity;
|
||||
const remainingSlots = roomCapacity - invitees.length;
|
||||
|
||||
@@ -322,7 +241,7 @@ const BookingForm: React.FC<BookingFormProps> = ({
|
||||
const bookings = await getRoomBookings(room.id, date);
|
||||
const startTime = new Date(start).toISOString();
|
||||
const endTime = new Date(end).toISOString();
|
||||
const hasConflict = bookings.some((b: any) => {
|
||||
const hasConflict = bookings.some((b: Booking) => {
|
||||
if (isEdit && b.id === editBooking?.id) return false;
|
||||
return startTime < b.end_time && endTime > b.start_time;
|
||||
});
|
||||
@@ -342,14 +261,16 @@ const BookingForm: React.FC<BookingFormProps> = ({
|
||||
};
|
||||
fetchConflicts();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [start, end, rooms, isEdit, editBooking?.id]);
|
||||
}, [start, end, rooms, isEdit, editBooking?.id, setStart, setEnd]);
|
||||
|
||||
// Fetch invitees for editBooking
|
||||
useEffect(() => {
|
||||
if (isEdit && editBooking?.id) {
|
||||
getInvitees(editBooking.id)
|
||||
.then((inviteesData) => {
|
||||
setInvitees(inviteesData.map((i: any) => i.user_email));
|
||||
setInvitees(
|
||||
inviteesData.map((i: { user_email: string }) => i.user_email)
|
||||
);
|
||||
})
|
||||
.catch(() => setInvitees([]));
|
||||
}
|
||||
@@ -368,11 +289,11 @@ const BookingForm: React.FC<BookingFormProps> = ({
|
||||
end_time: new Date(end).toISOString(),
|
||||
};
|
||||
if (isEdit && editBooking?.id) {
|
||||
(params as any).exclude_booking_id = editBooking.id;
|
||||
(params as Record<string, unknown>).exclude_booking_id = editBooking.id;
|
||||
}
|
||||
getAvailableUsers(params)
|
||||
.then((users) => {
|
||||
const emails = users.map((u: any) => u.email);
|
||||
const emails = users.map((u: { email: string }) => u.email);
|
||||
setAvailableInvitees(Array.from(new Set([...emails, ...invitees])));
|
||||
})
|
||||
.catch(() =>
|
||||
@@ -389,6 +310,11 @@ const BookingForm: React.FC<BookingFormProps> = ({
|
||||
|
||||
// Independent field validation
|
||||
function validateFields() {
|
||||
if (isViewMode) {
|
||||
setErrors({});
|
||||
return true;
|
||||
}
|
||||
// ...existing code...
|
||||
const newErrors: { [key: string]: string } = {};
|
||||
// Room ID
|
||||
const roomIdError = validateRoomId(room_id);
|
||||
@@ -422,7 +348,7 @@ const BookingForm: React.FC<BookingFormProps> = ({
|
||||
end,
|
||||
roomBookings,
|
||||
isEdit,
|
||||
editBooking
|
||||
editBooking ?? null
|
||||
);
|
||||
if (availabilityErrors.roomError)
|
||||
newErrors.room_id = availabilityErrors.roomError;
|
||||
@@ -439,7 +365,7 @@ const BookingForm: React.FC<BookingFormProps> = ({
|
||||
}
|
||||
|
||||
// Real-time bookings state via SSE
|
||||
const [roomBookings, setRoomBookings] = useState<any[]>([]);
|
||||
const [roomBookings, setRoomBookings] = useState<Booking[]>([]);
|
||||
useEffect(() => {
|
||||
const es = connectRoomsAvailabilityStream({
|
||||
onMessage: (data) => {
|
||||
@@ -448,10 +374,10 @@ const BookingForm: React.FC<BookingFormProps> = ({
|
||||
const dateStr = start
|
||||
? new Date(start).toISOString().slice(0, 10)
|
||||
: null;
|
||||
const filtered = data.bookings.filter((b: any) => {
|
||||
const filtered = data.bookings.filter((b: Booking) => {
|
||||
const bookingDate = b.start_time?.slice(0, 10);
|
||||
return (
|
||||
String(b.room_id) === String(room_id) &&
|
||||
String((b as any).room_id) === String(room_id) &&
|
||||
(!dateStr || bookingDate === dateStr)
|
||||
);
|
||||
});
|
||||
@@ -475,6 +401,10 @@ const BookingForm: React.FC<BookingFormProps> = ({
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (isViewMode) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
setSubmitError(null);
|
||||
@@ -482,118 +412,36 @@ const BookingForm: React.FC<BookingFormProps> = ({
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
// Deduplicate invitee emails before submitting
|
||||
const uniqueInvitees = Array.from(new Set(invitees));
|
||||
// Helper to check if room changed
|
||||
const originalRoomId = isEdit ? getEditBookingRoomId(editBooking) : null;
|
||||
const roomChanged = isEdit && String(room_id) !== String(originalRoomId);
|
||||
|
||||
async function updateInvitees(bookingId: string) {
|
||||
const currentInvitees = await getInvitees(bookingId).then((data) =>
|
||||
data.map((i: any) => i.user_email)
|
||||
);
|
||||
for (const email of currentInvitees) {
|
||||
if (!uniqueInvitees.includes(email)) {
|
||||
await removeInvitee(bookingId, email);
|
||||
}
|
||||
}
|
||||
for (const email of uniqueInvitees) {
|
||||
if (!currentInvitees.includes(email)) {
|
||||
await addInvitee(bookingId, email);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let bookingId = isEdit ? editBooking.id : null;
|
||||
let payload = {
|
||||
room_id,
|
||||
start_time: start,
|
||||
end_time: end,
|
||||
title: title,
|
||||
};
|
||||
// DEBUG: Log payload before submission
|
||||
console.log("Booking payload submitted:", payload);
|
||||
let res;
|
||||
let triedRoomFirst = false;
|
||||
// ...existing code...
|
||||
try {
|
||||
if (isEdit) {
|
||||
// Default: invitees first, then booking
|
||||
try {
|
||||
await updateInvitees(bookingId);
|
||||
res = { data: await updateBooking(bookingId, payload) };
|
||||
} catch (err: any) {
|
||||
const detail = err?.response?.data?.detail || err.message;
|
||||
// If room changed and capacity error, try booking first then invitees
|
||||
if (
|
||||
roomChanged &&
|
||||
typeof detail === "string" &&
|
||||
detail.toLowerCase().includes("room capacity") &&
|
||||
!triedRoomFirst
|
||||
) {
|
||||
triedRoomFirst = true;
|
||||
// Try booking update first, then invitees
|
||||
try {
|
||||
res = { data: await updateBooking(bookingId, payload) };
|
||||
await updateInvitees(bookingId);
|
||||
} catch (err2: any) {
|
||||
const detail2 = err2?.response?.data?.detail || err2.message;
|
||||
setSubmitError(formatBookingError(detail2));
|
||||
logger.error(
|
||||
"[BookingForm] Booking submit error (room-first retry)",
|
||||
detail2
|
||||
);
|
||||
logger.error(
|
||||
"[BookingForm] Booking submit error (room-first retry)",
|
||||
detail2
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
setSubmitError(formatBookingError(detail));
|
||||
logger.error("[BookingForm] Booking submit error", detail);
|
||||
logger.error("[BookingForm] Booking submit error", detail);
|
||||
logger.error("[BookingForm] Booking submit error", detail);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For new booking, create booking first, then add invitees
|
||||
const bookingData = await createBooking(payload);
|
||||
bookingId = bookingData.id;
|
||||
for (const email of uniqueInvitees) {
|
||||
await addInvitee(bookingId, email);
|
||||
}
|
||||
res = { data: bookingData };
|
||||
}
|
||||
// On success, redirect to confirmation page with booking data
|
||||
const bookingData = {
|
||||
...res?.data,
|
||||
room: rooms.find((r: any) => String(r.id) === String(room_id)),
|
||||
invitees: uniqueInvitees,
|
||||
// Prepare payload for booking creation
|
||||
const payload = {
|
||||
room_id,
|
||||
start_time: new Date(start).toISOString(),
|
||||
end_time: new Date(end).toISOString(),
|
||||
title,
|
||||
};
|
||||
navigate("/confirmation", { state: { booking: bookingData } });
|
||||
} catch (err: any) {
|
||||
// Show a user-friendly error for booking overlap/conflict or capacity
|
||||
let detail = "";
|
||||
if (err?.response) {
|
||||
// Backend responded, try to extract error detail
|
||||
detail =
|
||||
err.response.data?.detail ||
|
||||
err.response.statusText ||
|
||||
"Server error.";
|
||||
} else if (err?.message) {
|
||||
// Network/transport error
|
||||
if (err.message === "Network Error") {
|
||||
detail =
|
||||
"Unable to reach the server. Please check your connection or try again later.";
|
||||
} else {
|
||||
detail = err.message;
|
||||
}
|
||||
// Call createBooking API
|
||||
const result = await import("../apis/bookings").then((mod) =>
|
||||
mod.createBooking(payload)
|
||||
);
|
||||
logger.info("[BookingForm] Booking created", result);
|
||||
if (onBookingSuccess) {
|
||||
onBookingSuccess();
|
||||
} else {
|
||||
detail = "An unknown error occurred.";
|
||||
onClose();
|
||||
}
|
||||
setSubmitError(formatBookingError(detail));
|
||||
logger.error("[BookingForm] Booking submit error", detail);
|
||||
} catch (err: unknown) {
|
||||
let detail = "";
|
||||
if (typeof err === "object" && err !== null && "response" in err) {
|
||||
// @ts-ignore
|
||||
detail = err.response?.data?.detail || err.message;
|
||||
} else if (typeof err === "object" && err !== null && "message" in err) {
|
||||
// @ts-ignore
|
||||
detail = err.message;
|
||||
}
|
||||
logger.error("[BookingForm] Booking create error", detail);
|
||||
setSubmitError(detail || "Failed to create booking.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -605,36 +453,44 @@ const BookingForm: React.FC<BookingFormProps> = ({
|
||||
if (!isEdit) return;
|
||||
if (!window.confirm("Delete this booking?")) return;
|
||||
try {
|
||||
logger.info("[BookingForm] Deleting booking", { id: editBooking.id });
|
||||
logger.info("[BookingForm] Deleting booking", {
|
||||
id: editBooking?.id,
|
||||
editBooking,
|
||||
});
|
||||
if (!editBooking?.id) {
|
||||
setSubmitError("No booking ID provided for deletion.");
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
await deleteBooking(editBooking.id);
|
||||
setSubmitting(false);
|
||||
// Booking deleted successfully
|
||||
if (onBookingSuccess) {
|
||||
onBookingSuccess();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
} catch (err: any) {
|
||||
const detail = err?.response?.data?.detail || err.message;
|
||||
// Failed to delete booking
|
||||
} catch (err: unknown) {
|
||||
setSubmitting(false);
|
||||
let detail = "";
|
||||
if (typeof err === "object" && err !== null && "response" in err) {
|
||||
// @ts-ignore
|
||||
detail = err.response?.data?.detail || err.message;
|
||||
} else if (typeof err === "object" && err !== null && "message" in err) {
|
||||
// @ts-ignore
|
||||
detail = err.message;
|
||||
}
|
||||
logger.error("[BookingForm] Booking delete error", detail);
|
||||
logger.error("[BookingForm] Booking delete error", detail);
|
||||
/**
|
||||
* BookingForm component for selecting room, date, time, title, and invitees.
|
||||
* Handles both creation and editing of bookings.
|
||||
*
|
||||
* @param {object} props - Component props
|
||||
* @param {boolean} props.open - Whether the form dialog is open
|
||||
* @param {function} props.onClose - Callback to close the form
|
||||
* @param {function} props.onBookingSuccess - Callback on successful booking
|
||||
* @param {object[]} props.rooms - List of available rooms
|
||||
* @param {object[]} props.invitees - List of available invitees
|
||||
* @param {object|null} [props.editBooking] - Booking to edit, or null for new
|
||||
* @param {object|null} [props.formSlot] - Slot info for new booking
|
||||
* @param {boolean} [props.isEdit] - True if editing an existing booking
|
||||
*/
|
||||
setSubmitError(detail || "Failed to delete booking.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoomChange = (e: SelectChangeEvent) => {
|
||||
userChangedRoom.current = true;
|
||||
setRoomId(String(e.target.value));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
@@ -660,7 +516,13 @@ const BookingForm: React.FC<BookingFormProps> = ({
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
<DialogTitle>{isEdit ? "Update Booking" : "New Booking"}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{isViewMode
|
||||
? "View Booking"
|
||||
: isEdit
|
||||
? "Update Booking"
|
||||
: "New Booking"}
|
||||
</DialogTitle>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogContent>
|
||||
{submitError && (
|
||||
@@ -693,11 +555,12 @@ const BookingForm: React.FC<BookingFormProps> = ({
|
||||
onChange={handleRoomChange}
|
||||
input={
|
||||
<OutlinedInput
|
||||
label={"Room "} // Match label width to visible label + icon + space
|
||||
inputProps={{ "aria-label": "Room" }}
|
||||
label={"Room "}
|
||||
inputProps={{ "aria-label": "Room", readOnly: isViewMode }}
|
||||
/>
|
||||
}
|
||||
required
|
||||
disabled={!!isViewMode}
|
||||
>
|
||||
{rooms && rooms.length > 0 ? (
|
||||
rooms.map((room: ConferenceRoom) => {
|
||||
@@ -749,7 +612,9 @@ const BookingForm: React.FC<BookingFormProps> = ({
|
||||
<RoomDetailsModal
|
||||
open={roomModalOpen}
|
||||
onClose={() => setRoomModalOpen(false)}
|
||||
room={rooms.find((r: any) => String(r.id) === String(room_id))}
|
||||
room={rooms.find(
|
||||
(r: ConferenceRoom) => String(r.id) === String(room_id)
|
||||
)}
|
||||
/>
|
||||
<TextField
|
||||
label="Title (optional)"
|
||||
@@ -757,24 +622,29 @@ const BookingForm: React.FC<BookingFormProps> = ({
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
InputProps={{ readOnly: !!isViewMode }}
|
||||
disabled={!!isViewMode}
|
||||
/>
|
||||
<TextField
|
||||
label="Start Time"
|
||||
type="datetime-local"
|
||||
value={start ? formatLocalDateTimeInput(new Date(start)) : ""}
|
||||
value={(() => {
|
||||
if (!start) return "";
|
||||
const d = new Date(start);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
// Format as YYYY-MM-DDTHH:mm (local time)
|
||||
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(
|
||||
d.getDate()
|
||||
)}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
})()}
|
||||
onChange={(e) => {
|
||||
userChangedStart.current = true;
|
||||
const value = e.target.value;
|
||||
if (value) {
|
||||
const [datePart, timePart] = value.split("T");
|
||||
const [year, month, day] = datePart.split("-").map(Number);
|
||||
const [hour, minute] = timePart.split(":").map(Number);
|
||||
const localDate = new Date(year, month - 1, day, hour, minute);
|
||||
// Calculate new end time using current interval
|
||||
setStart(localDate.toISOString());
|
||||
const newEnd = new Date(
|
||||
localDate.getTime() + intervalMinutes * 60000
|
||||
);
|
||||
setStart(new Date(value).toISOString());
|
||||
const localDate = new Date(value);
|
||||
const newEnd = new Date(localDate.getTime() + 30 * 60000);
|
||||
setEnd(newEnd.toISOString());
|
||||
} else {
|
||||
setStart("");
|
||||
@@ -782,33 +652,35 @@ const BookingForm: React.FC<BookingFormProps> = ({
|
||||
}
|
||||
}}
|
||||
error={!!errors.start}
|
||||
helperText={errors.start}
|
||||
helperText={""}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
required
|
||||
InputProps={{ readOnly: !!isViewMode }}
|
||||
disabled={!!isViewMode}
|
||||
/>
|
||||
{/* Always render the error message area for start time, even if untouched */}
|
||||
<Box color="error.main" fontSize={13} mb={1}>
|
||||
{errors.start || (!start ? "Start time is required." : "")}
|
||||
{errors.start}
|
||||
</Box>
|
||||
<TextField
|
||||
label="End Time"
|
||||
type="datetime-local"
|
||||
value={end ? formatLocalDateTimeInput(new Date(end)) : ""}
|
||||
value={(() => {
|
||||
if (!end) return "";
|
||||
const d = new Date(end);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
// Format as YYYY-MM-DDTHH:mm (local time)
|
||||
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(
|
||||
d.getDate()
|
||||
)}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
})()}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
const date = new Date(value);
|
||||
if (!isNaN(date.getTime())) {
|
||||
setEnd(date.toISOString());
|
||||
// Update interval to match new end time
|
||||
if (start) {
|
||||
const startDate = new Date(start);
|
||||
const newInterval = Math.max(
|
||||
1,
|
||||
Math.round((date.getTime() - startDate.getTime()) / 60000)
|
||||
);
|
||||
setIntervalMinutes(newInterval);
|
||||
}
|
||||
} else {
|
||||
setEnd("");
|
||||
}
|
||||
@@ -817,6 +689,8 @@ const BookingForm: React.FC<BookingFormProps> = ({
|
||||
margin="normal"
|
||||
required
|
||||
error={!!errors.end}
|
||||
InputProps={{ readOnly: !!isViewMode }}
|
||||
disabled={!!isViewMode}
|
||||
/>
|
||||
{/* Always render the error message area for end time, even if untouched */}
|
||||
<Box color="error.main" fontSize={13} mb={1}>
|
||||
@@ -829,13 +703,15 @@ const BookingForm: React.FC<BookingFormProps> = ({
|
||||
multiple
|
||||
value={invitees}
|
||||
onChange={(e) =>
|
||||
setInvitees(
|
||||
typeof e.target.value === "string"
|
||||
? e.target.value.split(",")
|
||||
: e.target.value
|
||||
)
|
||||
isViewMode
|
||||
? undefined
|
||||
: setInvitees(
|
||||
typeof e.target.value === "string"
|
||||
? e.target.value.split(",")
|
||||
: e.target.value
|
||||
)
|
||||
}
|
||||
input={<OutlinedInput label="Invitees" />}
|
||||
input={<OutlinedInput label="Invitees" readOnly={!!isViewMode} />}
|
||||
renderValue={(selected) => (
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
|
||||
{(selected as string[]).map((value, idx) => (
|
||||
@@ -847,6 +723,7 @@ const BookingForm: React.FC<BookingFormProps> = ({
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
disabled={!!isViewMode}
|
||||
>
|
||||
{/* Remaining slots as a non-selectable MenuItem at the top */}
|
||||
<MenuItem
|
||||
@@ -927,24 +804,30 @@ const BookingForm: React.FC<BookingFormProps> = ({
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => onClose()}>Cancel</Button>
|
||||
{isEdit && (
|
||||
<Button color="error" onClick={handleDelete}>
|
||||
{!isViewMode && <Button onClick={() => onClose()}>Cancel</Button>}
|
||||
{isEdit && !isViewMode && (
|
||||
<Button
|
||||
color="error"
|
||||
onClick={() => {
|
||||
logger.info("[BookingForm] Delete button clicked", {
|
||||
id: editBooking?.id,
|
||||
});
|
||||
handleDelete();
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={submitting || Object.keys(errors).length > 0}
|
||||
disabled={
|
||||
isViewMode ? false : submitting || Object.keys(errors).length > 0
|
||||
}
|
||||
>
|
||||
{/* Show invitee capacity error if present */}
|
||||
{errors.invitees && (
|
||||
<Box color="error.main" fontSize={13} mb={1} mt={1}>
|
||||
{errors.invitees}
|
||||
</Box>
|
||||
)}
|
||||
{submitting
|
||||
{isViewMode
|
||||
? "Close"
|
||||
: submitting
|
||||
? isEdit
|
||||
? "Updating..."
|
||||
: "Booking..."
|
||||
|
||||
@@ -18,18 +18,41 @@ import interactionPlugin from "@fullcalendar/interaction";
|
||||
import timeGridPlugin from "@fullcalendar/timegrid";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { EventInput } from "@fullcalendar/core";
|
||||
import { getEventDisplayText, getRoomClass } from "../helpers/calendar";
|
||||
import { logger } from "../utils/logger";
|
||||
import "../styles/CalendarView.css";
|
||||
|
||||
// Custom event type for calendar events
|
||||
type BookingEvent = {
|
||||
id: string | number;
|
||||
title: string;
|
||||
start: Date | string;
|
||||
end: Date | string;
|
||||
color?: string;
|
||||
resource?: {
|
||||
room_id?: string | number;
|
||||
room_name?: string;
|
||||
invitees?: string[];
|
||||
originalBooking?: any;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Props for CalendarView.
|
||||
*/
|
||||
interface SlotInfo {
|
||||
start: Date;
|
||||
end: Date;
|
||||
allDay: boolean;
|
||||
viewType: string;
|
||||
selectedRoomId?: string | number;
|
||||
}
|
||||
|
||||
interface CalendarViewProps {
|
||||
events: EventInput[];
|
||||
onEventClick?: (event: EventInput) => void;
|
||||
onSlotSelect?: (slotInfo: any) => void;
|
||||
selectedRoomId?: any;
|
||||
events: BookingEvent[];
|
||||
onEventClick?: (event: BookingEvent) => void;
|
||||
onSlotSelect?: (slotInfo: SlotInfo) => void;
|
||||
selectedRoomId?: string | number;
|
||||
officeStartHour?: number;
|
||||
officeEndHour?: number;
|
||||
initialDate?: string;
|
||||
@@ -47,28 +70,49 @@ const CalendarView: React.FC<CalendarViewProps> = ({
|
||||
initialDate,
|
||||
}) => {
|
||||
// Custom event content with tooltip
|
||||
const renderEventContent = (arg: any) => {
|
||||
const renderEventContent = (arg: { event: BookingEvent }) => {
|
||||
const { event } = arg;
|
||||
const { title, start, end, extendedProps } = event;
|
||||
const room =
|
||||
extendedProps?.resource?.room_name ||
|
||||
extendedProps?.resource?.roomId ||
|
||||
"Unknown";
|
||||
const invitees = extendedProps?.resource?.invitees || [];
|
||||
const displayText = getEventDisplayText(title, start, room);
|
||||
const roomClass = getRoomClass(extendedProps?.resource?.room_id);
|
||||
const { title, start, end, resource } = event;
|
||||
// Robust room name extraction
|
||||
const room: string =
|
||||
typeof resource?.room_name === "string" &&
|
||||
resource.room_name.trim() !== ""
|
||||
? resource.room_name
|
||||
: typeof resource?.room_id === "string" &&
|
||||
resource.room_id.trim() !== ""
|
||||
? resource.room_id
|
||||
: typeof resource?.room_id === "number"
|
||||
? String(resource.room_id)
|
||||
: resource?.originalBooking?.room?.name
|
||||
? resource.originalBooking.room.name
|
||||
: "Room";
|
||||
const invitees: string[] =
|
||||
resource && Array.isArray(resource.invitees) ? resource.invitees : [];
|
||||
const safeTitle: string = typeof title === "string" ? title : "";
|
||||
const safeStart: Date | null = start
|
||||
? new Date(start as string | number | Date)
|
||||
: null;
|
||||
const safeEnd: Date | null = end
|
||||
? new Date(end as string | number | Date)
|
||||
: null;
|
||||
const displayText = getEventDisplayText(
|
||||
safeTitle,
|
||||
safeStart ?? new Date(),
|
||||
room
|
||||
);
|
||||
const roomClass = getRoomClass(resource?.room_id ?? null);
|
||||
return (
|
||||
<Tooltip
|
||||
title={
|
||||
<div>
|
||||
<Typography variant="subtitle2">
|
||||
{title && title.trim() !== "" ? title : room}
|
||||
{safeTitle && safeTitle.trim() !== "" ? safeTitle : room}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<b>Start:</b> {start ? new Date(start).toLocaleString() : "-"}
|
||||
<b>Start:</b> {safeStart ? safeStart.toLocaleString() : "-"}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<b>End:</b> {end ? new Date(end).toLocaleString() : "-"}
|
||||
<b>End:</b> {safeEnd ? safeEnd.toLocaleString() : "-"}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<b>Room:</b> {room}
|
||||
@@ -103,6 +147,7 @@ const CalendarView: React.FC<CalendarViewProps> = ({
|
||||
borderRadius: 4,
|
||||
paddingLeft: 4,
|
||||
paddingRight: 4,
|
||||
background: undefined, // Remove inline background to allow CSS class to control color
|
||||
}}
|
||||
>
|
||||
<span
|
||||
@@ -125,7 +170,7 @@ const CalendarView: React.FC<CalendarViewProps> = ({
|
||||
};
|
||||
|
||||
// Always show full 24h grid in week/day view
|
||||
const calendarRef = React.useRef<any>(null);
|
||||
const calendarRef = React.useRef<FullCalendar | null>(null);
|
||||
let slotMinTime = "00:00:00";
|
||||
let slotMaxTime = "24:00:00";
|
||||
const businessHours = [
|
||||
@@ -135,6 +180,10 @@ const CalendarView: React.FC<CalendarViewProps> = ({
|
||||
endTime: businessEnd,
|
||||
},
|
||||
];
|
||||
// Diagnostic: log events passed to FullCalendar
|
||||
React.useEffect(() => {
|
||||
logger.info("[CalendarView] Events passed to FullCalendar:", events);
|
||||
}, [events]);
|
||||
return (
|
||||
<div className="calendarContainer">
|
||||
<FullCalendar
|
||||
@@ -143,7 +192,7 @@ const CalendarView: React.FC<CalendarViewProps> = ({
|
||||
timeZone="local"
|
||||
initialView="dayGridMonth"
|
||||
initialDate={initialDate}
|
||||
events={events}
|
||||
events={events.map((e) => ({ ...e, id: String(e.id) }))}
|
||||
height="auto"
|
||||
dayMaxEvents={true}
|
||||
headerToolbar={{
|
||||
@@ -152,12 +201,17 @@ const CalendarView: React.FC<CalendarViewProps> = ({
|
||||
right: "dayGridMonth,timeGridWeek,timeGridDay",
|
||||
}}
|
||||
eventContent={renderEventContent}
|
||||
eventClassNames={undefined}
|
||||
eventClick={(info) => {
|
||||
info.jsEvent.preventDefault();
|
||||
if (onEventClick) {
|
||||
onEventClick(
|
||||
info.event.extendedProps.originalBooking || info.event
|
||||
);
|
||||
// Pass the full event object with custom properties
|
||||
const eventObj =
|
||||
info.event.extendedProps &&
|
||||
Object.keys(info.event.extendedProps).length > 0
|
||||
? { ...info.event, ...info.event.extendedProps }
|
||||
: info.event;
|
||||
onEventClick(eventObj as BookingEvent);
|
||||
}
|
||||
}}
|
||||
selectable={true}
|
||||
@@ -176,7 +230,8 @@ const CalendarView: React.FC<CalendarViewProps> = ({
|
||||
}
|
||||
}}
|
||||
// @ts-ignore: dateClick is a valid prop for FullCalendar, but types may be missing
|
||||
dateClick={(info: any) => {
|
||||
// @ts-ignore: dateClick is a valid prop for FullCalendar, but types may be missing
|
||||
dateClick={(info: { date: Date }) => {
|
||||
if (onSlotSelect) {
|
||||
// Simulate a slot selection for the whole day in month view
|
||||
const start = info.date;
|
||||
|
||||
@@ -1,30 +1,55 @@
|
||||
// booking.ts
|
||||
// Utility functions for booking-related logic
|
||||
// Utility functions for booking-related logic in booking system frontend
|
||||
|
||||
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" &&
|
||||
// Minimal Booking type for utility functions
|
||||
import type { Booking } from "../schemas";
|
||||
|
||||
/**
|
||||
* Extracts the room ID from a booking object, handling multiple possible shapes.
|
||||
*/
|
||||
export function getEditBookingRoomId(
|
||||
editBooking: Booking | undefined | null
|
||||
): string {
|
||||
if (!editBooking || typeof editBooking !== "object") return "";
|
||||
let id: unknown = "";
|
||||
// Safely check for non-standard properties using type guards
|
||||
if ("_def" in editBooking && typeof editBooking._def === "object") {
|
||||
const ext = (editBooking._def as any).extendedProps;
|
||||
if (ext && ext.resource) {
|
||||
id = ext.resource.room_id ?? ext.resource.id ?? id;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!id &&
|
||||
"resource" in editBooking &&
|
||||
typeof (editBooking as any).resource === "object"
|
||||
) {
|
||||
id = (editBooking as any).resource.room_id ?? id;
|
||||
id = (editBooking as any).resource.id ?? id;
|
||||
}
|
||||
if (!id && "room_id" in editBooking) {
|
||||
id = (editBooking as any).room_id ?? id;
|
||||
}
|
||||
if (
|
||||
!id &&
|
||||
"room" in editBooking &&
|
||||
editBooking.room &&
|
||||
"id" in editBooking.room
|
||||
? editBooking.room.id
|
||||
: undefined) ??
|
||||
"";
|
||||
if (!id && typeof editBooking === "object") {
|
||||
typeof (editBooking as any).room === "object"
|
||||
) {
|
||||
id = (editBooking as any).room.id ?? id;
|
||||
}
|
||||
if (!id && "roomId" in editBooking) {
|
||||
id = (editBooking as any).roomId ?? id;
|
||||
}
|
||||
// Fallback: search for any property containing 'room' and is a number
|
||||
if (!id) {
|
||||
for (const k of Object.keys(editBooking)) {
|
||||
// @ts-ignore
|
||||
if (
|
||||
k.toLowerCase().includes("room") &&
|
||||
typeof editBooking[k] === "number"
|
||||
typeof (editBooking as any)[k] === "number"
|
||||
) {
|
||||
id = editBooking[k];
|
||||
// @ts-ignore
|
||||
id = (editBooking as any)[k];
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -32,6 +57,9 @@ export function getEditBookingRoomId(editBooking: any): string {
|
||||
return id !== undefined && id !== null ? String(id) : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats backend error details into user-friendly messages for booking actions.
|
||||
*/
|
||||
export function formatBookingError(detail: string): string {
|
||||
if (!detail) return "An unknown error occurred.";
|
||||
const lower = detail.toLowerCase();
|
||||
|
||||
@@ -1,23 +1,57 @@
|
||||
// bookingList.ts
|
||||
// Utility functions for BookingList component
|
||||
|
||||
export function sortBookingsByStartTime(bookings: any[]): any[] {
|
||||
/**
|
||||
* Represents a booking object with a start_time property.
|
||||
*/
|
||||
import type { Booking as SharedBooking } from "../schemas";
|
||||
|
||||
export type Booking = SharedBooking;
|
||||
|
||||
/**
|
||||
* Sorts bookings by their start time (ascending).
|
||||
* @param bookings - Array of Booking objects
|
||||
* @returns Sorted array of Booking objects
|
||||
*/
|
||||
export function sortBookingsByStartTime(bookings: Booking[]): Booking[] {
|
||||
return [...bookings].sort(
|
||||
(a, b) =>
|
||||
new Date(a.start_time).getTime() - new Date(b.start_time).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a booking time string to a human-readable time.
|
||||
* @param dateStr - ISO date string
|
||||
* @returns Formatted time string
|
||||
*/
|
||||
export function formatBookingTime(dateStr: string): string {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleTimeString([], {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
});
|
||||
return isNaN(d.getTime())
|
||||
? "Invalid time"
|
||||
: d.toLocaleTimeString([], {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function getInviteeName(inv: any): string {
|
||||
/**
|
||||
* Represents an invitee object.
|
||||
*/
|
||||
export interface Invitee {
|
||||
name?: string;
|
||||
username?: string;
|
||||
displayName?: string;
|
||||
user?: { name?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the display name for an invitee.
|
||||
* @param inv - Invitee object or string
|
||||
* @returns Invitee name string
|
||||
*/
|
||||
export function getInviteeName(inv: Invitee | string): string {
|
||||
if (typeof inv === "string") return inv;
|
||||
if (inv && typeof inv === "object") {
|
||||
if (typeof inv.name === "string") return inv.name;
|
||||
|
||||
@@ -1,20 +1,52 @@
|
||||
// calendar.ts
|
||||
// Utility functions for CalendarView component
|
||||
// Utility functions for CalendarView and booking system frontend
|
||||
|
||||
/**
|
||||
* Returns display text for a calendar event, including start time and title/room.
|
||||
* @param title - Event title (string, optional)
|
||||
* @param start - Event start time (Date or ISO string, optional)
|
||||
* @param room - Room name (string, optional)
|
||||
* @returns Formatted display string
|
||||
*/
|
||||
export function getEventDisplayText(
|
||||
title: string,
|
||||
start: Date,
|
||||
room: string
|
||||
title: string | null,
|
||||
start: Date | string | null,
|
||||
room: string | null
|
||||
): string {
|
||||
const startTime = start
|
||||
? start.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
: "-";
|
||||
return title && title.trim() !== ""
|
||||
? `${startTime} - ${title}`
|
||||
: `${startTime} - ${room}`;
|
||||
let startTime = "-";
|
||||
if (start) {
|
||||
let d: Date | null = null;
|
||||
if (typeof start === "string") {
|
||||
const parsed = Date.parse(start);
|
||||
d = isNaN(parsed) ? null : new Date(parsed);
|
||||
} else if (start instanceof Date) {
|
||||
d = start;
|
||||
}
|
||||
if (d && !isNaN(d.getTime())) {
|
||||
startTime = d.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (title && title.trim() !== "") {
|
||||
return `${startTime} - ${title}`;
|
||||
}
|
||||
return `${startTime} - ${room ?? "Room"}`;
|
||||
}
|
||||
|
||||
export function getRoomClass(roomId: any): string {
|
||||
const idx = roomId ? parseInt(roomId, 10) % 8 : 0;
|
||||
/**
|
||||
* Returns a CSS class for a room color, based on room ID.
|
||||
* @param roomId - Room ID (string, number, or null)
|
||||
* @returns CSS class string
|
||||
*/
|
||||
export function getRoomClass(roomId: string | number | null): string {
|
||||
let idx = 0;
|
||||
if (typeof roomId === "number") {
|
||||
idx = roomId % 8;
|
||||
} else if (typeof roomId === "string" && roomId !== "") {
|
||||
const parsed = parseInt(roomId, 10);
|
||||
idx = isNaN(parsed) ? 0 : parsed % 8;
|
||||
}
|
||||
return `room-color-${idx}`;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
// room.ts
|
||||
// Utility functions for RoomList component
|
||||
import { ConferenceRoom } from "../schemas";
|
||||
|
||||
export function getRoomSecondaryText(room: any): string {
|
||||
export function getRoomSecondaryText(room: ConferenceRoom): string {
|
||||
return `Location: ${room.location} | Capacity: ${room.capacity} | Equipment: ${room.equipment}`;
|
||||
}
|
||||
|
||||
export function getRoomListItemStyles(roomId: any, selectedRoomId: any): any {
|
||||
interface RoomListItemStyles {
|
||||
backgroundColor?: string;
|
||||
fontWeight?: number;
|
||||
color: string;
|
||||
border?: string;
|
||||
borderRadius?: number;
|
||||
}
|
||||
|
||||
export function getRoomListItemStyles(
|
||||
roomId: number,
|
||||
selectedRoomId?: number
|
||||
): RoomListItemStyles {
|
||||
if (roomId === selectedRoomId) {
|
||||
return {
|
||||
backgroundColor: "#ececec !important",
|
||||
|
||||
@@ -1,30 +1,59 @@
|
||||
// validation.ts
|
||||
// Independent field validation helpers for BookingForm
|
||||
// Independent field validation helpers for BookingForm and booking system frontend
|
||||
|
||||
export function validateRoomId(room_id: string | undefined): string | null {
|
||||
import type { Booking } from "../schemas";
|
||||
|
||||
/**
|
||||
* Validates room ID field.
|
||||
* @param room_id - Room ID string or null
|
||||
* @returns Error message or null
|
||||
*/
|
||||
export function validateRoomId(room_id: string | null): string | null {
|
||||
if (!room_id) return "Room is required.";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateStart(start: string | undefined): string | null {
|
||||
/**
|
||||
* Validates start time field.
|
||||
* @param start - Start time string or null
|
||||
* @returns Error message or null
|
||||
*/
|
||||
export function validateStart(start: string | null): string | null {
|
||||
if (!start) return "Start time is required.";
|
||||
if (start && new Date(start) < new Date())
|
||||
return "Start time cannot be in the past.";
|
||||
const d = new Date(start);
|
||||
if (isNaN(d.getTime())) return "Invalid start time.";
|
||||
if (d < new Date()) return "Start time cannot be in the past.";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates end time field.
|
||||
* @param end - End time string or null
|
||||
* @param start - Start time string or null
|
||||
* @returns Error message or null
|
||||
*/
|
||||
export function validateEnd(
|
||||
end: string | undefined,
|
||||
start?: string
|
||||
end: string | null,
|
||||
start?: string | null
|
||||
): 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.";
|
||||
const dEnd = new Date(end);
|
||||
if (isNaN(dEnd.getTime())) return "Invalid end time.";
|
||||
if (start) {
|
||||
const dStart = new Date(start);
|
||||
if (isNaN(dStart.getTime())) return "Invalid start time.";
|
||||
if (dStart >= dEnd) return "End time must be after start time.";
|
||||
}
|
||||
if (dEnd < new Date()) return "End time cannot be in the past.";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates invitees field against room capacity.
|
||||
* @param invitees - Array of invitee strings
|
||||
* @param roomCapacity - Room capacity number
|
||||
* @returns Error message or null
|
||||
*/
|
||||
export function validateInvitees(
|
||||
invitees: string[],
|
||||
roomCapacity: number
|
||||
@@ -35,6 +64,11 @@ export function validateInvitees(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a single invitee email address.
|
||||
* @param email - Email string
|
||||
* @returns Error message or null
|
||||
*/
|
||||
export function validateInviteeEmail(email: string): string | null {
|
||||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
||||
return `Invalid email: ${email}`;
|
||||
@@ -42,36 +76,43 @@ export function validateInviteeEmail(email: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates room availability for a given time range against existing bookings.
|
||||
* @param room_id - Room ID string
|
||||
* @param start - Start time string
|
||||
* @param end - End time string
|
||||
* @param roomBookings - Array of Booking objects
|
||||
* @param isEdit - Is editing existing booking
|
||||
* @param editBooking - The booking being edited, or null
|
||||
* @returns Error object with roomError, startError, endError
|
||||
*/
|
||||
export function validateRoomAvailability(
|
||||
room_id: string,
|
||||
start: string,
|
||||
end: string,
|
||||
roomBookings: any[],
|
||||
roomBookings: Booking[],
|
||||
isEdit: boolean,
|
||||
editBooking: any
|
||||
editBooking: Booking | null
|
||||
): { 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.filter((b: Booking) => 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)
|
||||
) {
|
||||
filteredBookings.forEach((b: Booking) => {
|
||||
const bStart = new Date(b.start_time);
|
||||
const bEnd = new Date(b.end_time);
|
||||
if (isNaN(bStart.getTime()) || isNaN(bEnd.getTime())) return;
|
||||
const dStart = new Date(start);
|
||||
const dEnd = new Date(end);
|
||||
if (isNaN(dStart.getTime()) || isNaN(dEnd.getTime())) return;
|
||||
if (dStart < bEnd && dEnd > bStart) {
|
||||
if (dStart < bEnd && dStart >= bStart) {
|
||||
startConflict = true;
|
||||
}
|
||||
if (
|
||||
new Date(end) > new Date(b.start_time) &&
|
||||
new Date(end) <= new Date(b.end_time)
|
||||
) {
|
||||
if (dEnd > bStart && dEnd <= bEnd) {
|
||||
endConflict = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
FormControl,
|
||||
Box,
|
||||
} from "@mui/material";
|
||||
import { EventInput } from "@fullcalendar/core";
|
||||
|
||||
import { getRooms } from "../apis/rooms";
|
||||
import { getUsers } from "../apis/users";
|
||||
import { getRoomBookings } from "../apis/bookings";
|
||||
@@ -31,7 +31,6 @@ const BookingPage: React.FC = () => {
|
||||
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");
|
||||
@@ -164,19 +163,50 @@ const BookingPage: React.FC = () => {
|
||||
}, [rooms]);
|
||||
|
||||
// Combine all bookings into calendar events, sorted chronologically (room id tiebreaker), with color and formatted title
|
||||
const events: EventInput[] = useMemo(() => {
|
||||
// Expose allBookings for event lookup
|
||||
const allBookings: any[] = React.useMemo(() => {
|
||||
if (!bookingsByRoom || rooms.length === 0) return [];
|
||||
// Flatten all bookings with room info
|
||||
const allBookings = Object.entries(bookingsByRoom).flatMap(
|
||||
return Object.entries(bookingsByRoom).flatMap(
|
||||
([roomId, bookings]) => bookings as any[]
|
||||
);
|
||||
}, [bookingsByRoom, rooms]);
|
||||
|
||||
const events: any[] = React.useMemo(() => {
|
||||
if (!bookingsByRoom || rooms.length === 0) return [];
|
||||
// Map allBookings to calendar events
|
||||
const mappedEvents = 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
|
||||
// Normalize roomId for lookup
|
||||
const normalizedRoomId = String(roomId);
|
||||
const room = rooms.find(
|
||||
(r: any) => String(r.id) === normalizedRoomId
|
||||
);
|
||||
let roomName = room?.name;
|
||||
if (!roomName || roomName.trim() === "") {
|
||||
if (
|
||||
typeof booking.room_name === "string" &&
|
||||
booking.room_name.trim() !== ""
|
||||
) {
|
||||
roomName = booking.room_name;
|
||||
} else if (
|
||||
typeof booking.room_id === "string" &&
|
||||
booking.room_id.trim() !== ""
|
||||
) {
|
||||
roomName = booking.room_id;
|
||||
} else if (typeof booking.room_id === "number") {
|
||||
roomName = String(booking.room_id);
|
||||
} else {
|
||||
roomName = "Room";
|
||||
}
|
||||
}
|
||||
const eventColor = roomColors[normalizedRoomId] || "#1976d2";
|
||||
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
|
||||
booking.title && booking.title.trim() !== ""
|
||||
? booking.title
|
||||
: roomName;
|
||||
let inviteeNames: string[] = [];
|
||||
if (Array.isArray(booking.invitees)) {
|
||||
inviteeNames = booking.invitees
|
||||
@@ -188,18 +218,18 @@ const BookingPage: React.FC = () => {
|
||||
title: displayTitle,
|
||||
start,
|
||||
end,
|
||||
color: roomColors[roomId],
|
||||
color: eventColor,
|
||||
backgroundColor: eventColor,
|
||||
resource: {
|
||||
room_id: booking.room_id,
|
||||
room_name: room?.name,
|
||||
room_name: roomName,
|
||||
invitees: inviteeNames,
|
||||
originalBooking: booking,
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
// Sort by start time, then by room id
|
||||
allBookings.sort((a, b) => {
|
||||
mappedEvents.sort((a, b) => {
|
||||
const aStart = a.start.getTime();
|
||||
const bStart = b.start.getTime();
|
||||
if (aStart !== bStart) return aStart - bStart;
|
||||
@@ -207,7 +237,7 @@ const BookingPage: React.FC = () => {
|
||||
String(b.resource.room_id)
|
||||
);
|
||||
});
|
||||
return allBookings;
|
||||
return mappedEvents;
|
||||
}, [bookingsByRoom, rooms, roomColors]);
|
||||
|
||||
const [formSlot, setFormSlot] = useState<any>(null);
|
||||
@@ -351,9 +381,26 @@ const BookingPage: React.FC = () => {
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const handleSelectEvent = (event: EventInput) => {
|
||||
logger.debug("[BookingPage] Event selected for edit", event);
|
||||
setEditBooking(event);
|
||||
const handleSelectEvent = (event: any) => {
|
||||
const originalBooking = event.resource?.originalBooking;
|
||||
const eventIdStr = String(event.id);
|
||||
if (originalBooking && originalBooking.id) {
|
||||
setEditBooking(originalBooking);
|
||||
} else {
|
||||
// Try to find backend booking by event.id using allBookings (compare as strings)
|
||||
const foundBooking = allBookings.find(
|
||||
(b: any) => String(b.id) === eventIdStr
|
||||
);
|
||||
if (foundBooking) {
|
||||
setEditBooking(foundBooking);
|
||||
} else {
|
||||
setEditBooking({
|
||||
...event,
|
||||
start_time: event.start,
|
||||
end_time: event.end,
|
||||
});
|
||||
}
|
||||
}
|
||||
setFormSlot(null);
|
||||
setFormOpen(true);
|
||||
};
|
||||
@@ -463,8 +510,8 @@ const BookingPage: React.FC = () => {
|
||||
room={rooms.find((r: any) => r.id === selectedRoomId)}
|
||||
/>
|
||||
<CalendarView
|
||||
events={events}
|
||||
onEventClick={(event) => handleSelectEvent(event)}
|
||||
events={events as any}
|
||||
onEventClick={(event: any) => handleSelectEvent(event)}
|
||||
onSlotSelect={handleSelectSlot}
|
||||
selectedRoomId={selectedRoomId}
|
||||
/>
|
||||
|
||||
@@ -43,7 +43,7 @@ const ConfirmationPage: React.FC = () => {
|
||||
return () => {
|
||||
es.close();
|
||||
};
|
||||
}, [booking?.id]);
|
||||
}, [booking, booking?.id]);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
// Fetch all rooms for editing
|
||||
|
||||
@@ -55,7 +55,6 @@ const LandingPage: FC = () => {
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, []);
|
||||
const navigate = useNavigate();
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
// Fetch available rooms from backend
|
||||
const {
|
||||
|
||||
@@ -12,39 +12,6 @@
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
/* Room color classes for calendar events */
|
||||
.room-color-0 {
|
||||
background: #1976d2 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.room-color-1 {
|
||||
background: #388e3c !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.room-color-2 {
|
||||
background: #fbc02d !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.room-color-3 {
|
||||
background: #d32f2f !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.room-color-4 {
|
||||
background: #7b1fa2 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.room-color-5 {
|
||||
background: #0288d1 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.room-color-6 {
|
||||
background: #c2185b !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.room-color-7 {
|
||||
background: #ffa000 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
/* Truncate and ellipsis for event content and titles */
|
||||
.fc-event-title-ellipsis,
|
||||
.fc-event .fc-event-main > div,
|
||||
|
||||
Reference in New Issue
Block a user