diff --git a/backend/src/backend/models.py b/backend/src/backend/models.py index 67a95d3c..181bbef8 100644 --- a/backend/src/backend/models.py +++ b/backend/src/backend/models.py @@ -132,6 +132,10 @@ class Booking(Base): """ return self._invitees + def reset_invitee_cache(self) -> None: + """Reset the cached list of invitee email addresses.""" + self._invitee_emails = None + class Invitee(Base): """Model representing an invitee to a booking in the application. diff --git a/backend/src/backend/schemas/bookings.py b/backend/src/backend/schemas/bookings.py index bdb29256..6ac080aa 100644 --- a/backend/src/backend/schemas/bookings.py +++ b/backend/src/backend/schemas/bookings.py @@ -62,6 +62,7 @@ class BookingUpdate(BaseModel): start_time: datetime | None = None end_time: datetime | None = None title: str | None = None + invitees: list[str] | None = None model_config = ConfigDict(from_attributes=True) diff --git a/backend/src/backend/services/bookings.py b/backend/src/backend/services/bookings.py index 8235c5e1..1a8c0403 100644 --- a/backend/src/backend/services/bookings.py +++ b/backend/src/backend/services/bookings.py @@ -415,6 +415,7 @@ async def update_booking( logger.debug( f"Entering update_booking with booking_id: {booking_id}, params: {kwargs}" ) + try: # Fetch current booking to compare and validate changes current = await get_booking(session, booking_id) @@ -435,6 +436,9 @@ async def update_booking( # Remove old invitees await _remove_old_invitees(session, current, new_invitees) + await session.commit() # Ensure deletions are persisted + await session.refresh(current) # Refresh relationship from DB + current.reset_invitee_cache() # Run validations logger.debug( @@ -468,6 +472,16 @@ async def update_booking( new_invitees, {i.user_email for i in updated.get_invitee_objects()}, ) + await session.commit() + + # Always refresh and reset cache after adding invitees + updated = await get_booking(session, booking_id) + await session.refresh(updated) + updated.reset_invitee_cache() + + # Force reload of invitee relationship and cache to ensure up-to-date list + # This ensures that updated.invitees returns the correct, current list + _ = [i.user_email for i in updated.get_invitee_objects()] logger.info(f"Successfully updated booking with id: {booking_id}") diff --git a/frontend/package.json b/frontend/package.json index a184561a..c4ee6dd8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -83,6 +83,7 @@ "eslint-plugin-jest": "29.0.1", "eslint-plugin-jsdoc": "60.1.1", "eslint-plugin-react": "7.37.5", + "eslint-plugin-react-hooks": "^6.1.1", "jest": "30.1.1", "jest-environment-jsdom": "30.1.1", "jsdom": "26.1.0", diff --git a/frontend/src/components/BookingForm.tsx b/frontend/src/components/BookingForm.tsx index 198f8022..24ec5201 100644 --- a/frontend/src/components/BookingForm.tsx +++ b/frontend/src/components/BookingForm.tsx @@ -10,6 +10,8 @@ import { logger } from "../utils/logger"; // External imports import React, { useState, useMemo, useCallback } from "react"; +// For type usage in JSX event handlers + import { Box, Button, @@ -57,7 +59,7 @@ interface BookingFormProps { * Called when booking is successful or deleted. * If booking is provided, navigate to confirmation. If not, just close the form. */ - onBookingSuccess?: (_booking?: Booking) => void; + onBookingSuccess?: () => void; } const BookingForm: React.FC = ({ @@ -298,14 +300,14 @@ const BookingForm: React.FC = ({ title, invitees, }; - // logger.info("[BookingForm] updateBooking payload:", updatePayload); + logger.info("[BookingForm] updateBooking payload:", updatePayload); bookingResult = await updateBooking( Number(editBooking.id), updatePayload ); } if (onBookingSuccess && bookingResult) { - onBookingSuccess(bookingResult); + onBookingSuccess(); } onClose(); } catch (err: unknown) { @@ -352,7 +354,7 @@ const BookingForm: React.FC = ({ {isViewMode - ? "View Booking" + ? "View Past Booking" : isEdit ? "Update Booking" : "New Booking"} @@ -366,14 +368,46 @@ const BookingForm: React.FC = ({ {/* Room selection */} - - handleRoomChange({ target: { value: id } } as SelectChangeEvent) - } - label="Room" - minWidth={180} - /> + {isViewMode ? ( + + ) : ( + <> + + handleRoomChange({ + target: { value: id }, + } as SelectChangeEvent) + } + label="Room" + minWidth={180} + /> + + )} {errors.room_id} @@ -381,11 +415,30 @@ const BookingForm: React.FC = ({ {/* Title */} setTitle(e.target.value)} fullWidth margin="normal" - InputProps={{ readOnly: isViewMode }} + InputProps={ + isViewMode + ? { + readOnly: true, + sx: { + color: "rgba(34,34,34,0.8) !important", + opacity: 0.8, + WebkitTextFillColor: "rgba(34,34,34,0.8)", + }, + inputProps: { + style: { + color: "rgba(34,34,34,0.8)", + background: "#f0f0f0", + opacity: 0.8, + WebkitTextFillColor: "rgba(34,34,34,0.8)", + }, + }, + } + : {} + } disabled={isViewMode} /> {/* Start time */} @@ -394,11 +447,11 @@ const BookingForm: React.FC = ({ type="datetime-local" value={(() => { if (!start) { - return ""; + return isViewMode ? "\u00A0" : ""; } const d = new Date(start); if (isNaN(d.getTime())) { - return ""; + return isViewMode ? "\u00A0" : ""; } const pad = (n: number) => n.toString().padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad( @@ -409,8 +462,27 @@ const BookingForm: React.FC = ({ error={!!errors.start} fullWidth margin="normal" - required - InputProps={{ readOnly: isViewMode }} + {...(!isViewMode && { required: true })} + InputProps={ + isViewMode + ? { + readOnly: true, + sx: { + color: "rgba(34,34,34,0.8) !important", + opacity: 0.8, + WebkitTextFillColor: "rgba(34,34,34,0.8)", + }, + inputProps: { + style: { + color: "rgba(34,34,34,0.8)", + background: "#f0f0f0", + opacity: 0.8, + WebkitTextFillColor: "rgba(34,34,34,0.8)", + }, + }, + } + : {} + } disabled={isViewMode} /> @@ -422,11 +494,11 @@ const BookingForm: React.FC = ({ type="datetime-local" value={(() => { if (!end) { - return ""; + return isViewMode ? "\u00A0" : ""; } const d = new Date(end); if (isNaN(d.getTime())) { - return ""; + return isViewMode ? "\u00A0" : ""; } const pad = (n: number) => n.toString().padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad( @@ -437,179 +509,279 @@ const BookingForm: React.FC = ({ error={!!errors.end} fullWidth margin="normal" - required - InputProps={{ readOnly: isViewMode }} + {...(!isViewMode && { required: true })} + InputProps={ + isViewMode + ? { + readOnly: true, + sx: { + color: "rgba(34,34,34,0.8) !important", + opacity: 0.8, + WebkitTextFillColor: "rgba(34,34,34,0.8)", + }, + inputProps: { + style: { + color: "rgba(34,34,34,0.8)", + background: "#f0f0f0", + opacity: 0.8, + WebkitTextFillColor: "rgba(34,34,34,0.8)", + }, + }, + } + : {} + } disabled={isViewMode} /> {errors.end} {/* Invitees */} - - Invitees - } + renderValue={(selected) => ( + + {(selected as string[]).map((value, idx) => { + const user = users.find( + (u: import("../interfaces").User) => u.email === value + ); + let label; + if (user && user.name && user.email) { + label = `${user.name} <${user.email}>`; + } else if (user && user.name) { + label = user.name; + } else { + label = value; + } + return ( + + ); + })} + + )} + disabled={isViewMode} + > + + {remainingSlots >= 0 + ? `${remainingSlots} invitee slot${ + remainingSlots === 1 ? "" : "s" + } left` + : `${Math.abs(remainingSlots)} over room capacity`} + + {users.map((user: import("../interfaces").User) => { + const email = user.email ?? user.name; + const isSelected = invitees.includes(email); + // User unavailable if booked for another event at this time + const unavailable = bookings.some((b: Booking) => { + if (isEdit && b.id === editBooking?.id) { + return false; + } + return ( + b.invitees?.includes(email) && + new Date(start) < new Date(b.end_time) && + new Date(end) > new Date(b.start_time) + ); + }); + const disableUnselected = + !isSelected && + (unavailable || invitees.length >= roomCapacity); + let label; + if (user && user.name && user.email) { + label = `${user.name} <${user.email}>`; + } else if (user && user.name) { + label = user.name; + } else { + label = email; } return ( - b.invitees?.includes(email) && - new Date(start) < new Date(b.end_time) && - new Date(end) > new Date(b.start_time) + + {isSelected && ( + + ✔ + + )} + {label} + ); - }); - const disableUnselected = - !isSelected && - (unavailable || invitees.length >= roomCapacity); - return ( - - {isSelected && ( - - ✔ - - )} - {user.name ? `${user.name} <${email}>` : email} - - ); - })} - - {errors.invitees && ( - + })} + + {errors.invitees} - )} - + + )} - {!isViewMode && } - {isEdit && !isViewMode && ( + {isViewMode ? ( + ) : ( + <> + + {isEdit && ( + + }} + > + Delete + + )} + + )} - diff --git a/frontend/src/components/RoomSelect.tsx b/frontend/src/components/RoomSelect.tsx index 81a0cda9..460868c6 100644 --- a/frontend/src/components/RoomSelect.tsx +++ b/frontend/src/components/RoomSelect.tsx @@ -45,9 +45,25 @@ const RoomSelect: React.FC = ({ // [RoomSelect] Unmounted log removed }; }, [rooms.length]); + // Compute background color for selected room + let selectBgColor: string | undefined = undefined; + if (rooms.length > 0 && selectedRoomId) { + const selectedRoom = rooms.find( + (room) => String(room.id) === String(selectedRoomId) + ); + if (selectedRoom) { + const roomIdx = ((Number(selectedRoom.id) - 1) % 20) + 1; + selectBgColor = `var(--room-color-${roomIdx})`; + } + } return ( - {label} + + {label} +