mirror of
https://github.com/xlorepdarkhelm/numinar-coding-project.git
synced 2026-09-09 17:45:35 -04:00
App is working, now for code cleanup and redoing the tests.
Signed-off-by: Cliff Hill <xlorep@darkhelm.org>
This commit is contained in:
@@ -15,7 +15,6 @@ from backend import config
|
||||
from backend.logging import setup_logging
|
||||
from backend.models import create_db_and_tables
|
||||
from backend.routers import bookings
|
||||
from backend.routers import invitees
|
||||
from backend.routers import rooms
|
||||
from backend.routers import users
|
||||
|
||||
@@ -65,6 +64,5 @@ app.add_middleware(
|
||||
app.include_router(users.router)
|
||||
app.include_router(rooms.router)
|
||||
app.include_router(bookings.router)
|
||||
app.include_router(invitees.router)
|
||||
|
||||
setup_logging(app)
|
||||
|
||||
@@ -6,6 +6,8 @@ from fastapi import APIRouter
|
||||
from fastapi import HTTPException
|
||||
from fastapi import Query
|
||||
from fastapi import status
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import NoResultFound
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
@@ -17,14 +19,11 @@ from backend.routers.rooms import publish_room_availability_event
|
||||
from backend.schemas.bookings import BookingCreate
|
||||
from backend.schemas.bookings import BookingResponse
|
||||
from backend.schemas.bookings import BookingUpdate
|
||||
from backend.schemas.invitees import InviteeResponse
|
||||
from backend.schemas.rooms import RoomResponse
|
||||
from backend.services.bookings import delete_booking
|
||||
from backend.services.bookings import get_booking
|
||||
from backend.services.bookings import get_bookings_for_room
|
||||
from backend.services.bookings import new_booking
|
||||
from backend.services.bookings import update_booking
|
||||
from backend.services.invitees import get_invitees_for_booking
|
||||
from backend.services.rooms import get_room
|
||||
|
||||
|
||||
@@ -36,73 +35,6 @@ router = APIRouter(prefix="/bookings", tags=["bookings"])
|
||||
_default_date = Query(None, description="Filter bookings by date (YYYY-MM-DD)")
|
||||
|
||||
|
||||
@router.get("/room/{room_id}", response_model=list[BookingResponse])
|
||||
async def read_bookings_for_room(
|
||||
room_id: int,
|
||||
session: DBSession,
|
||||
date: str = _default_date,
|
||||
) -> list[BookingResponse]:
|
||||
"""Retrieve bookings for a specific room, optionally filtered by date, including invitees.
|
||||
|
||||
Args:
|
||||
room_id (int): The ID of the room to retrieve bookings for.
|
||||
session (DBSession): The database session.
|
||||
date (str): Optional date string (YYYY-MM-DD) to filter bookings by start_time.
|
||||
|
||||
Raises:
|
||||
HTTPException: If the date format is invalid.
|
||||
|
||||
Returns:
|
||||
list[BookingResponse]: List of bookings for the room, with invitees, filtered by date
|
||||
if provided.
|
||||
"""
|
||||
logger.debug(
|
||||
f"Received request to fetch bookings for room_id: {room_id} and date: {date}"
|
||||
) # noqa: B950
|
||||
try:
|
||||
try:
|
||||
bookings = await get_bookings_for_room(session, room_id, date)
|
||||
except ValueError as ve:
|
||||
raise HTTPException(status_code=422, detail=str(ve)) from ve
|
||||
booking_responses: list[BookingResponse] = []
|
||||
for booking in bookings:
|
||||
invitees = await get_invitees_for_booking(session, booking.id)
|
||||
invitee_responses = [
|
||||
InviteeResponse.model_validate(inv) for inv in invitees
|
||||
]
|
||||
room = await get_room(session, booking.room_id)
|
||||
room_response = RoomResponse.model_validate(room) if room else None
|
||||
booking_response = BookingResponse.model_validate(
|
||||
{
|
||||
**booking.__dict__,
|
||||
"invitees": invitee_responses,
|
||||
"room": room_response,
|
||||
}
|
||||
)
|
||||
booking_responses.append(booking_response)
|
||||
logger.info(
|
||||
f"Successfully retrieved {len(booking_responses)} bookings for room_id: {room_id} "
|
||||
f"and date: {date}"
|
||||
)
|
||||
return booking_responses
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(
|
||||
f"Database error while fetching bookings for room_id {room_id}: {str(e)}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Database error occurred",
|
||||
) from e
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error while fetching bookings for room_id {room_id}: {str(e)}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="An unexpected error occurred",
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/{booking_id}", response_model=BookingResponse)
|
||||
async def read_booking(booking_id: int, session: DBSession) -> Booking:
|
||||
"""Retrieve a booking by ID.
|
||||
@@ -112,7 +44,7 @@ async def read_booking(booking_id: int, session: DBSession) -> Booking:
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
Booking with the specified ID.
|
||||
BookingResponse with the specified ID.
|
||||
|
||||
Raises:
|
||||
HTTPException: If the booking is not found, a database error occurs,
|
||||
@@ -130,7 +62,7 @@ async def read_booking(booking_id: int, session: DBSession) -> Booking:
|
||||
detail=f"Booking with id {booking_id} not found",
|
||||
) from e
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(
|
||||
logger.exception(
|
||||
f"Database error while fetching booking with id {booking_id}: {str(e)}"
|
||||
)
|
||||
raise HTTPException(
|
||||
@@ -138,7 +70,7 @@ async def read_booking(booking_id: int, session: DBSession) -> Booking:
|
||||
detail="Database error occurred",
|
||||
) from e
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
logger.exception(
|
||||
f"Unexpected error while fetching booking with id {booking_id}: {str(e)}"
|
||||
)
|
||||
raise HTTPException(
|
||||
@@ -165,21 +97,54 @@ async def create_booking(booking: BookingCreate, session: DBSession) -> BookingR
|
||||
|
||||
try:
|
||||
booking_data = booking.model_dump()
|
||||
# No rounding: use start_time and end_time as provided
|
||||
# Remove invitees before creating Booking object
|
||||
booking_data.pop("invitees", None)
|
||||
db_booking = Booking(**booking_data)
|
||||
# Save all required fields before commit
|
||||
booking_fields = {
|
||||
"room_id": db_booking.room_id,
|
||||
"start_time": db_booking.start_time,
|
||||
"end_time": db_booking.end_time,
|
||||
"title": db_booking.title,
|
||||
}
|
||||
created_booking = await new_booking(
|
||||
session, db_booking, publish_room_availability_event
|
||||
session, db_booking, None # Do not publish SSE event here
|
||||
)
|
||||
logger.info(f"Successfully created booking with id: {created_booking.id}")
|
||||
# Fetch invitees and room for the created booking
|
||||
invitees = await get_invitees_for_booking(session, created_booking.id)
|
||||
invitee_responses = [InviteeResponse.model_validate(inv) for inv in invitees]
|
||||
room = await get_room(session, created_booking.room_id)
|
||||
booking_id = created_booking.id # Should be available immediately after insert
|
||||
logger.info(f"Successfully created booking with id: {booking_id}")
|
||||
# Create invitees after booking is created
|
||||
invitee_emails = getattr(booking, "invitees", [])
|
||||
if invitee_emails:
|
||||
from backend.models import Invitee
|
||||
|
||||
invitee_objs = [
|
||||
Invitee(booking_id=booking_id, user_email=email)
|
||||
for email in invitee_emails
|
||||
]
|
||||
session.add_all(invitee_objs)
|
||||
await session.commit()
|
||||
# Publish SSE event after invitees are added
|
||||
if publish_room_availability_event:
|
||||
await publish_room_availability_event(
|
||||
{
|
||||
"action": "created",
|
||||
"room_id": booking_fields["room_id"],
|
||||
"booking_id": booking_id,
|
||||
"start_time": str(booking_fields["start_time"]),
|
||||
"end_time": str(booking_fields["end_time"]),
|
||||
"invitees": invitee_emails,
|
||||
}
|
||||
)
|
||||
room = await get_room(session, booking_fields["room_id"])
|
||||
room_response = RoomResponse.model_validate(room) if room else None
|
||||
booking_response = BookingResponse.model_validate(
|
||||
{
|
||||
**created_booking.__dict__,
|
||||
"invitees": invitee_responses,
|
||||
"id": booking_id,
|
||||
"room_id": booking_fields["room_id"],
|
||||
"start_time": booking_fields["start_time"],
|
||||
"end_time": booking_fields["end_time"],
|
||||
"title": booking_fields["title"],
|
||||
"invitees": invitee_emails,
|
||||
"room": room_response,
|
||||
}
|
||||
)
|
||||
@@ -191,14 +156,14 @@ async def create_booking(booking: BookingCreate, session: DBSession) -> BookingR
|
||||
detail=str(e),
|
||||
) from e
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database error while creating booking: {str(e)}")
|
||||
logger.exception(f"Database error while creating booking: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal server error",
|
||||
) from e
|
||||
|
||||
|
||||
@router.put("/{booking_id}", response_model=BookingResponse)
|
||||
@router.patch("/{booking_id}", response_model=BookingResponse)
|
||||
async def update_existing_booking(
|
||||
booking_id: int, booking_update: BookingUpdate, session: DBSession
|
||||
) -> BookingResponse:
|
||||
@@ -227,15 +192,16 @@ async def update_existing_booking(
|
||||
if v is not None
|
||||
},
|
||||
)
|
||||
# Fetch invitees and room for the updated booking
|
||||
invitees = await get_invitees_for_booking(session, updated_booking.id)
|
||||
invitee_responses = [InviteeResponse.model_validate(inv) for inv in invitees]
|
||||
room = await get_room(session, updated_booking.room_id)
|
||||
room_response = RoomResponse.model_validate(room) if room else None
|
||||
# Ensure invitees is a list of strings (emails), not Invitee objects
|
||||
invitees = getattr(updated_booking, "invitees", [])
|
||||
if invitees and hasattr(invitees[0], "user_email"):
|
||||
invitees = [i.user_email for i in invitees]
|
||||
booking_response = BookingResponse.model_validate(
|
||||
{
|
||||
**updated_booking.__dict__,
|
||||
"invitees": invitee_responses,
|
||||
"invitees": invitees,
|
||||
"room": room_response,
|
||||
}
|
||||
)
|
||||
@@ -256,7 +222,7 @@ async def update_existing_booking(
|
||||
detail=str(e),
|
||||
) from e
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(
|
||||
logger.exception(
|
||||
f"Database error while updating booking with id {booking_id}: {str(e)}"
|
||||
)
|
||||
raise HTTPException(
|
||||
@@ -264,7 +230,7 @@ async def update_existing_booking(
|
||||
detail="Database error occurred",
|
||||
) from e
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
logger.exception(
|
||||
f"Unexpected error while updating booking with id {booking_id}: {str(e)}"
|
||||
)
|
||||
raise HTTPException(
|
||||
@@ -296,7 +262,7 @@ async def delete_existing_booking(booking_id: int, session: DBSession) -> None:
|
||||
detail=f"Booking with id {booking_id} not found",
|
||||
) from e
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(
|
||||
logger.exception(
|
||||
f"Database error while deleting booking with id {booking_id}: {str(e)}"
|
||||
)
|
||||
raise HTTPException(
|
||||
@@ -304,10 +270,80 @@ async def delete_existing_booking(booking_id: int, session: DBSession) -> None:
|
||||
detail="Database error occurred",
|
||||
) from e
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
logger.exception(
|
||||
f"Unexpected error while deleting booking with id {booking_id}: {str(e)}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="An unexpected error occurred",
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/month/{month}", response_model=list[BookingResponse])
|
||||
async def read_bookings_for_month(
|
||||
month: str,
|
||||
session: DBSession,
|
||||
) -> list[BookingResponse]:
|
||||
"""Retrieve all bookings for all rooms for a given month.
|
||||
|
||||
Args:
|
||||
month (str): Month string in format YYYY-MM.
|
||||
session (DBSession): Database session.
|
||||
|
||||
Returns:
|
||||
list[BookingResponse]: List of bookings for the month, with invitees and room info.
|
||||
|
||||
Raises:
|
||||
HTTPException: If an error occurs while fetching bookings for the month.
|
||||
"""
|
||||
import datetime
|
||||
|
||||
try:
|
||||
# Parse month
|
||||
year, month_num = map(int, month.split("-"))
|
||||
start_date = datetime.date(year, month_num, 1)
|
||||
if month_num == 12:
|
||||
end_date = datetime.date(year + 1, 1, 1)
|
||||
else:
|
||||
end_date = datetime.date(year, month_num + 1, 1)
|
||||
# Query all bookings where start_time is in the month
|
||||
result = await session.execute(
|
||||
select(Booking).where(
|
||||
and_(Booking.start_time >= start_date, Booking.start_time < end_date)
|
||||
)
|
||||
)
|
||||
bookings = result.scalars().all()
|
||||
booking_responses: list[BookingResponse] = []
|
||||
# Explicitly query invitees for all bookings in one go
|
||||
from backend.models import Invitee
|
||||
|
||||
booking_ids = [b.id for b in bookings]
|
||||
invitees_map = {bid: [] for bid in booking_ids}
|
||||
if booking_ids:
|
||||
invitees_result = await session.execute(
|
||||
select(Invitee).where(Invitee.booking_id.in_(booking_ids))
|
||||
)
|
||||
invitees = invitees_result.scalars().all()
|
||||
for inv in invitees:
|
||||
invitees_map[inv.booking_id].append(inv.user_email)
|
||||
for booking in bookings:
|
||||
room = await get_room(session, booking.room_id)
|
||||
room_response = RoomResponse.model_validate(room) if room else None
|
||||
booking_response = BookingResponse.model_validate(
|
||||
{
|
||||
**booking.__dict__,
|
||||
"invitees": invitees_map.get(booking.id, []),
|
||||
"room": room_response,
|
||||
}
|
||||
)
|
||||
booking_responses.append(booking_response)
|
||||
logger.info(
|
||||
f"Successfully retrieved {len(booking_responses)} bookings for month: {month}" # noqa: B950
|
||||
)
|
||||
return booking_responses
|
||||
except Exception as err:
|
||||
logger.exception(f"Error fetching bookings for month {month}: {str(err)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Error fetching bookings for month",
|
||||
) from err
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
"""Routes for invitee-related operations in the backend."""
|
||||
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import HTTPException
|
||||
from fastapi import status
|
||||
from pydantic import EmailStr
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.dependencies.db import DBSession
|
||||
from backend.models import Invitee
|
||||
from backend.schemas.invitees import InviteeCreate
|
||||
from backend.schemas.invitees import InviteeResponse
|
||||
from backend.schemas.users import UserResponse
|
||||
from backend.services.invitees import add_invitee_to_booking
|
||||
from backend.services.invitees import get_invitees_for_booking
|
||||
from backend.services.invitees import remove_invitee_from_booking
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/invitees", tags=["invitees"])
|
||||
|
||||
|
||||
@router.get("/booking/{booking_id}", response_model=List[InviteeResponse])
|
||||
async def read_invitees_for_booking(
|
||||
booking_id: int, session: DBSession
|
||||
) -> list[InviteeResponse]:
|
||||
"""Retrieve all invitees for a specific booking.
|
||||
|
||||
Args:
|
||||
booking_id: ID of the booking to retrieve invitees for.
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
List of invitees for the booking.
|
||||
|
||||
Raises:
|
||||
HTTPException: If a database error or unexpected error occurs.
|
||||
"""
|
||||
logger.debug(f"Received request to fetch invitees for booking_id: {booking_id}")
|
||||
try:
|
||||
invitees = await get_invitees_for_booking(session, booking_id)
|
||||
logger.info(
|
||||
f"Successfully retrieved {len(invitees)} invitees for booking_id: {booking_id}"
|
||||
)
|
||||
return invitees
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(
|
||||
f"Database error while fetching invitees for booking_id {booking_id}: {str(e)}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Database error occurred",
|
||||
) from e
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error while fetching invitees for booking_id {booking_id}: {str(e)}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="An unexpected error occurred",
|
||||
) from e
|
||||
|
||||
|
||||
@router.post(
|
||||
"/booking/{booking_id}",
|
||||
response_model=InviteeResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
response_model_exclude_unset=True,
|
||||
)
|
||||
async def add_invitee(
|
||||
booking_id: int, invitee: InviteeCreate, session: DBSession
|
||||
) -> InviteeResponse:
|
||||
"""Add an invitee to a booking.
|
||||
|
||||
Args:
|
||||
booking_id: ID of the booking to add the invitee to.
|
||||
invitee: Data for the invitee to add.
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
Created invitee.
|
||||
|
||||
Raises:
|
||||
HTTPException: If a database error or unexpected error occurs.
|
||||
"""
|
||||
logger.debug(f"Received request to add invitee to booking_id: {booking_id}")
|
||||
try:
|
||||
created_invitee = await _try_create_invitee(session, booking_id, invitee)
|
||||
user_data = await _fetch_user_data(session, created_invitee.user_email)
|
||||
logger.info(
|
||||
f"Successfully added invitee with email {invitee.user_email} "
|
||||
f"to booking_id: {booking_id}"
|
||||
)
|
||||
try:
|
||||
return InviteeResponse.model_validate(
|
||||
{**created_invitee.__dict__, "user": user_data}
|
||||
)
|
||||
except Exception as validation_err:
|
||||
logger.error(f"Pydantic validation failed: {validation_err}")
|
||||
logger.error(f"created_invitee: {created_invitee}")
|
||||
logger.error(f"created_invitee.user: {user_data}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Pydantic validation failed: {validation_err}",
|
||||
) from validation_err
|
||||
except SQLAlchemyError as err:
|
||||
logger.error(
|
||||
f"Database error while adding invitee to booking_id {booking_id}: {str(err)}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Database error occurred",
|
||||
) from err
|
||||
except ValueError as err:
|
||||
logger.warning(
|
||||
f"Duplicate invitee attempted for booking_id {booking_id} "
|
||||
f"and user {invitee.user_email}: {str(err)}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(err),
|
||||
) from err
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as err:
|
||||
logger.error(
|
||||
f"Unexpected error while adding invitee to booking_id {booking_id}: {str(err)}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="An unexpected error occurred",
|
||||
) from err
|
||||
|
||||
|
||||
async def _try_create_invitee(
|
||||
session: AsyncSession, booking_id: int, invitee: InviteeCreate
|
||||
) -> Invitee:
|
||||
try:
|
||||
created_invitee = await add_invitee_to_booking(
|
||||
session, booking_id, invitee.user_email
|
||||
)
|
||||
await session.refresh(created_invitee)
|
||||
return created_invitee
|
||||
except ValueError as err:
|
||||
err_str = str(err)
|
||||
if "room capacity" in err_str or "exceed the room capacity" in err_str:
|
||||
logger.warning(
|
||||
f"Room capacity reached for booking_id {booking_id}: {err_str}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Room capacity has been reached. Cannot add more invitees.",
|
||||
) from err
|
||||
else:
|
||||
logger.warning(
|
||||
f"Duplicate invitee attempted for booking_id {booking_id} and user "
|
||||
f"{invitee.user_email}: {err_str}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
f"Invitee with email {invitee.user_email} already exists for this booking."
|
||||
),
|
||||
) from err
|
||||
|
||||
|
||||
async def _fetch_user_data(session: AsyncSession, user_email: EmailStr) -> UserResponse:
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.models import User
|
||||
from backend.schemas.users import UserResponse
|
||||
|
||||
user_stmt = select(User).where(User.email == user_email)
|
||||
user_result = await session.execute(user_stmt)
|
||||
user_obj = user_result.scalar_one_or_none()
|
||||
if not user_obj:
|
||||
logger.error(f"User with email {user_email} not found after invitee creation.")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"User with email {user_email} not found.",
|
||||
)
|
||||
return UserResponse.model_validate(user_obj)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/booking/{booking_id}/{user_email}", status_code=status.HTTP_204_NO_CONTENT
|
||||
)
|
||||
async def remove_invitee(
|
||||
booking_id: int, user_email: EmailStr, session: DBSession
|
||||
) -> None:
|
||||
"""Remove an invitee from a booking.
|
||||
|
||||
Args:
|
||||
booking_id: ID of the booking to remove the invitee from.
|
||||
user_email: Email of the invitee to remove.
|
||||
session: Database session.
|
||||
|
||||
Raises:
|
||||
HTTPException: If a database error or unexpected error occurs.
|
||||
"""
|
||||
logger.debug(
|
||||
f"Received request to remove invitee with email {user_email} from "
|
||||
f"booking_id: {booking_id}"
|
||||
)
|
||||
try:
|
||||
await remove_invitee_from_booking(session, booking_id, user_email)
|
||||
logger.info(
|
||||
f"Successfully removed invitee with email {user_email} from "
|
||||
f"booking_id: {booking_id} (or did not exist)"
|
||||
)
|
||||
# Always return 204 No Content, even if the invitee did not exist
|
||||
return
|
||||
except SQLAlchemyError as err:
|
||||
logger.error(
|
||||
f"Database error while removing invitee with email {user_email} from "
|
||||
f"booking_id {booking_id}: {str(err)}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Database error occurred",
|
||||
) from err
|
||||
except Exception as err:
|
||||
logger.error(
|
||||
f"Unexpected error while removing invitee with email {user_email} from "
|
||||
f"booking_id {booking_id}: {str(err)}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="An unexpected error occurred",
|
||||
) from err
|
||||
@@ -11,7 +11,6 @@ from fastapi import HTTPException
|
||||
from fastapi import Request
|
||||
from fastapi import status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.exc import NoResultFound
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from backend.dependencies.db import DBSession
|
||||
@@ -21,7 +20,6 @@ from backend.schemas.rooms import RoomCreate
|
||||
from backend.schemas.rooms import RoomResponse
|
||||
from backend.schemas.rooms import RoomUpdate
|
||||
from backend.services.rooms import delete_room
|
||||
from backend.services.rooms import get_room
|
||||
from backend.services.rooms import get_rooms
|
||||
from backend.services.rooms import new_room
|
||||
from backend.services.rooms import update_room
|
||||
@@ -125,47 +123,7 @@ async def read_rooms(session: DBSession) -> RoomList:
|
||||
detail="An unexpected error occurred",
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/{room_id}", response_model=RoomResponse)
|
||||
async def read_room(room_id: int, session: DBSession) -> Room:
|
||||
"""Retrieve a room by ID.
|
||||
|
||||
Args:
|
||||
room_id: ID of the room to retrieve.
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
Room with the specified ID.
|
||||
|
||||
Raises:
|
||||
HTTPException: If the room is not found, a database error occurs,
|
||||
or an unexpected error occurs.
|
||||
"""
|
||||
logger.debug(f"Received request to fetch room with id: {room_id}")
|
||||
try:
|
||||
room = await get_room(session, room_id)
|
||||
logger.info(f"Successfully retrieved room with id: {room_id}")
|
||||
return room
|
||||
except (NoResultFound, ValueError) as e:
|
||||
logger.warning(f"Room with id {room_id} not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Room with id {room_id} not found",
|
||||
) from e
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database error while fetching room with id {room_id}: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Database error occurred",
|
||||
) from e
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error while fetching room with id {room_id}: {str(e)}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="An unexpected error occurred",
|
||||
) from e
|
||||
# Removed unreachable except block and undefined room_id reference
|
||||
|
||||
|
||||
@router.post("/", response_model=RoomResponse, status_code=status.HTTP_201_CREATED)
|
||||
@@ -201,49 +159,44 @@ async def create_room(room: RoomCreate, session: DBSession) -> Room:
|
||||
) from e
|
||||
|
||||
|
||||
@router.put("/{room_id}", response_model=RoomResponse)
|
||||
async def update_existing_room(
|
||||
room_id: int, room_update: RoomUpdate, session: DBSession
|
||||
) -> Room:
|
||||
"""Update an existing room.
|
||||
@router.patch("/{room_id}", response_model=RoomResponse)
|
||||
async def patch_room(room_id: int, room_update: RoomUpdate, session: DBSession) -> Room:
|
||||
"""Update an existing room by ID.
|
||||
|
||||
Args:
|
||||
room_id: ID of the room to update.
|
||||
room_update: Updated data for the room.
|
||||
room_update: RoomUpdate schema with fields to update.
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
Updated room.
|
||||
|
||||
Raises:
|
||||
HTTPException: If the room is not found, a database error occurs,
|
||||
or an unexpected error occurs.
|
||||
HTTPException: If the room is not found or a database error occurs.
|
||||
"""
|
||||
logger.debug(f"Received request to update room with id: {room_id}")
|
||||
try:
|
||||
room_params = room_update.model_dump(exclude_unset=True)
|
||||
updated_room = await update_room(session, room_id, **room_params)
|
||||
update_data = room_update.model_dump(exclude_unset=True)
|
||||
if "id" in update_data:
|
||||
update_data.pop("id")
|
||||
updated_room = await update_room(session, room_id, **update_data)
|
||||
logger.info(f"Successfully updated room with id: {room_id}")
|
||||
return updated_room
|
||||
except (NoResultFound, ValueError) as e:
|
||||
logger.warning(f"Room with id {room_id} not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Room with id {room_id} not found",
|
||||
) from e
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database error while updating room with id {room_id}: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Database error occurred"
|
||||
) from e
|
||||
except Exception as e:
|
||||
except ValueError as err:
|
||||
logger.warning(str(err))
|
||||
raise HTTPException(status_code=404, detail=str(err)) from err
|
||||
except SQLAlchemyError as err:
|
||||
logger.error(
|
||||
f"Unexpected error while updating room with id {room_id}: {str(e)}"
|
||||
f"Database error while updating room with id {room_id}: {str(err)}"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="Database error occurred") from err
|
||||
except Exception as err:
|
||||
logger.error(
|
||||
f"Unexpected error while updating room with id {room_id}: {str(err)}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="An unexpected error occurred",
|
||||
) from e
|
||||
status_code=500, detail="An unexpected error occurred"
|
||||
) from err
|
||||
|
||||
|
||||
@router.delete("/{room_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -262,23 +215,18 @@ async def delete_existing_room(room_id: int, session: DBSession) -> None:
|
||||
try:
|
||||
await delete_room(session, room_id)
|
||||
logger.info(f"Successfully deleted room with id: {room_id}")
|
||||
except (NoResultFound, ValueError) as e:
|
||||
logger.warning(f"Room with id {room_id} not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Room with id {room_id} not found",
|
||||
) from e
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database error while deleting room with id {room_id}: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Database error occurred",
|
||||
) from e
|
||||
except Exception as e:
|
||||
except ValueError as err:
|
||||
logger.warning(str(err))
|
||||
raise HTTPException(status_code=404, detail=str(err)) from err
|
||||
except SQLAlchemyError as err:
|
||||
logger.error(
|
||||
f"Unexpected error while deleting room with id {room_id}: {str(e)}"
|
||||
f"Database error while deleting room with id {room_id}: {str(err)}"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="Database error occurred") from err
|
||||
except Exception as err:
|
||||
logger.error(
|
||||
f"Unexpected error while deleting room with id {room_id}: {str(err)}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="An unexpected error occurred",
|
||||
) from e
|
||||
status_code=500, detail="An unexpected error occurred"
|
||||
) from err
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
"""Routes for user-related operations in the Numinar coding project backend."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import HTTPException
|
||||
from fastapi import Query
|
||||
from pydantic import EmailStr
|
||||
from sqlalchemy.exc import NoResultFound
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from backend.dependencies.db import DBSession
|
||||
from backend.models import User
|
||||
from backend.models import UserList
|
||||
from backend.schemas.users import UserResponse
|
||||
from backend.services.users import get_available_users
|
||||
from backend.services.users import get_user
|
||||
from backend.services.users import get_users
|
||||
|
||||
|
||||
@@ -29,36 +23,6 @@ end_time_default = Query(..., description="Booking end time (ISO format)")
|
||||
exclude_booking_id_default = Query(None, description="Booking ID to exclude (for edit)")
|
||||
|
||||
|
||||
@router.get("/available/", response_model=list[UserResponse])
|
||||
async def read_available_users(
|
||||
session: DBSession,
|
||||
start_time: datetime = start_time_default,
|
||||
end_time: datetime = end_time_default,
|
||||
exclude_booking_id: int | None = exclude_booking_id_default,
|
||||
) -> UserList:
|
||||
"""Return users who are available (not in a conflicting booking) for the given time range.
|
||||
|
||||
Args:
|
||||
session: Database session.
|
||||
start_time: Booking start time (ISO format).
|
||||
end_time: Booking end time (ISO format).
|
||||
exclude_booking_id: Booking ID to exclude (for edit).
|
||||
|
||||
Returns:
|
||||
List of users available for the given time range.
|
||||
|
||||
Raises:
|
||||
HTTPException: If a database error or unexpected error occurs.
|
||||
"""
|
||||
try:
|
||||
users = await get_available_users(
|
||||
session, start_time, end_time, exclude_booking_id
|
||||
)
|
||||
return users
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.get("/", response_model=list[UserResponse])
|
||||
async def read_users(session: DBSession) -> UserList:
|
||||
"""Retrieve all users.
|
||||
@@ -85,40 +49,3 @@ async def read_users(session: DBSession) -> UserList:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="An unexpected error occurred"
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/{email}", response_model=UserResponse)
|
||||
async def read_user(email: EmailStr, session: DBSession) -> User:
|
||||
"""Retrieve a user by email.
|
||||
|
||||
Args:
|
||||
email: Email of the user to retrieve.
|
||||
session: Database session.
|
||||
|
||||
Returns:
|
||||
User with the specified email.
|
||||
|
||||
Raises:
|
||||
HTTPException: If the user is not found, a database error occurs,
|
||||
or an unexpected error occurs.
|
||||
"""
|
||||
logger.debug(f"Received request to fetch user with email: {email}")
|
||||
try:
|
||||
user = await get_user(session, email)
|
||||
logger.info(f"Successfully retrieved user with email: {email}")
|
||||
return user
|
||||
except NoResultFound as e:
|
||||
logger.warning(f"User with email {email} not found")
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"User with email {email} not found"
|
||||
) from e
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database error while fetching user with email {email}: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Database error occurred") from e
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error while fetching user with email {email}: {str(e)}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500, detail="An unexpected error occurred"
|
||||
) from e
|
||||
|
||||
@@ -8,7 +8,6 @@ from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
from pydantic import ConfigDict
|
||||
|
||||
from backend.schemas.invitees import InviteeResponse
|
||||
from backend.schemas.rooms import RoomResponse
|
||||
|
||||
|
||||
@@ -42,6 +41,7 @@ class BookingCreate(BookingBase):
|
||||
title: An optional title for the booking (inherited from BookingBase).
|
||||
"""
|
||||
|
||||
invitees: list[str] = []
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ class BookingUpdate(BaseModel):
|
||||
start_time: datetime | None = None
|
||||
end_time: datetime | None = None
|
||||
title: str | None = None
|
||||
invitees: list[str] = []
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -83,7 +84,7 @@ class BookingResponse(BookingBase):
|
||||
"""
|
||||
|
||||
id: int
|
||||
invitees: list[InviteeResponse] = []
|
||||
invitees: list[str] = []
|
||||
room: RoomResponse | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
"""Invitee schemas for request and response validation in the Numinar coding project backend.
|
||||
|
||||
Defines Pydantic models for validating invitee data in API requests and responses,
|
||||
including relationships to bookings and users.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import ConfigDict
|
||||
|
||||
from backend.schemas.users import UserResponse
|
||||
|
||||
|
||||
class InviteeBase(BaseModel):
|
||||
"""Base schema for invitee data validation.
|
||||
|
||||
Defines the common fields for invitee-related requests and responses.
|
||||
|
||||
Attributes:
|
||||
user_email: The email address of the user associated with the invitee.
|
||||
"""
|
||||
|
||||
user_email: str
|
||||
|
||||
|
||||
class InviteeCreate(InviteeBase):
|
||||
"""Schema for creating a new invitee.
|
||||
|
||||
Inherits from InviteeBase and enables ORM mode for database integration.
|
||||
|
||||
Attributes:
|
||||
user_email: The email address of the user associated with the invitee (inherited
|
||||
from InviteeBase).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class InviteeResponse(InviteeBase):
|
||||
"""Response schema for invitee data.
|
||||
|
||||
Inherits from InviteeBase, includes additional fields for the invitee ID and related
|
||||
booking and user data, and enables ORM mode for database integration.
|
||||
|
||||
Attributes:
|
||||
id: The unique identifier for the invitee.
|
||||
booking_id: The ID of the booking associated with the invitee
|
||||
(inherited from InviteeBase).
|
||||
user_email: The email address of the user associated with the invitee
|
||||
(inherited from InviteeBase).
|
||||
user: The UserResponse object containing details of the associated user.
|
||||
"""
|
||||
|
||||
id: int
|
||||
booking_id: int
|
||||
user: UserResponse
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -11,12 +11,10 @@ from datetime import timezone
|
||||
from typing import Any
|
||||
from typing import Awaitable
|
||||
from typing import Callable
|
||||
from typing import Sequence
|
||||
from typing import TypedDict
|
||||
from typing import Unpack
|
||||
from typing import cast
|
||||
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import select
|
||||
@@ -26,13 +24,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend import config
|
||||
from backend.models import Booking
|
||||
from backend.models import BookingList
|
||||
from backend.services.invitees import get_invitees_for_booking
|
||||
from backend.services.rooms import get_room
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EventPublisher = Callable[[dict[str, Any]], Awaitable[None]] | None
|
||||
|
||||
|
||||
@@ -44,63 +39,6 @@ class BookingParams(TypedDict):
|
||||
"""
|
||||
|
||||
|
||||
async def get_bookings_for_room(
|
||||
session: AsyncSession, room_id: int, date: str | None = None
|
||||
) -> BookingList:
|
||||
"""Retrieve bookings for a specific room, optionally filtered by date.
|
||||
|
||||
If a date string (YYYY-MM-DD) is provided, only bookings whose start_time falls on that date
|
||||
are returned. If no date is provided, all bookings for the room are returned.
|
||||
|
||||
Args:
|
||||
session: The asynchronous database session.
|
||||
room_id: The ID of the room to retrieve bookings for.
|
||||
date: Optional date string (YYYY-MM-DD) to filter bookings by start_time.
|
||||
|
||||
Returns:
|
||||
List of bookings for the room, filtered by date if provided.
|
||||
|
||||
Raises:
|
||||
ValueError: If the date string is not in YYYY-MM-DD format.
|
||||
"""
|
||||
logger.debug(
|
||||
f"Entering get_bookings_for_room with room_id: {room_id} and date: {date}"
|
||||
)
|
||||
try:
|
||||
if date:
|
||||
try:
|
||||
date_obj = datetime.strptime(date, "%Y-%m-%d")
|
||||
except ValueError as e:
|
||||
raise ValueError("Invalid date format. Use YYYY-MM-DD.") from e
|
||||
start_of_day = date_obj
|
||||
end_of_day = date_obj + timedelta(days=1)
|
||||
stmt = select(Booking).where(
|
||||
Booking.room_id == room_id,
|
||||
and_(
|
||||
Booking.start_time >= start_of_day,
|
||||
Booking.start_time < end_of_day,
|
||||
),
|
||||
)
|
||||
result = await session.scalars(stmt)
|
||||
bookings = list(result.all())
|
||||
else:
|
||||
stmt = select(Booking).where(Booking.room_id == room_id)
|
||||
result = await session.scalars(stmt)
|
||||
bookings = cast(BookingList, result.all())
|
||||
logger.info(
|
||||
f"Successfully retrieved {len(bookings)} bookings for room_id: {room_id} "
|
||||
f"and date: {date}"
|
||||
)
|
||||
return bookings
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to retrieve bookings for room_id {room_id} and date {date}: {str(e)}"
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
logger.debug("Exiting get_bookings_for_room")
|
||||
|
||||
|
||||
async def get_booking(session: AsyncSession, booking_id: int) -> Booking:
|
||||
"""Retrieve a booking by its ID.
|
||||
|
||||
@@ -123,7 +61,7 @@ async def get_booking(session: AsyncSession, booking_id: int) -> Booking:
|
||||
logger.info(f"Successfully retrieved booking with id: {booking_id}")
|
||||
return booking
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to retrieve booking with id {booking_id}: {str(e)}")
|
||||
logger.exception(f"Failed to retrieve booking with id {booking_id}: {str(e)}")
|
||||
raise
|
||||
finally:
|
||||
logger.debug("Exiting get_booking")
|
||||
@@ -180,24 +118,6 @@ async def _validate_new_booking_no_overlap(
|
||||
)
|
||||
|
||||
|
||||
async def _validate_new_booking_capacity(
|
||||
session: AsyncSession, booking: Booking, room: Any
|
||||
) -> None:
|
||||
invitees = await get_invitees_for_booking(
|
||||
session, getattr(booking, "id", None) or -1
|
||||
)
|
||||
num_attendees = len(invitees) if invitees else 0
|
||||
if num_attendees > room.capacity:
|
||||
logger.warning(
|
||||
f"Attempted to create booking with {num_attendees} attendees"
|
||||
f" exceeding room capacity {room.capacity}."
|
||||
)
|
||||
raise ValueError(
|
||||
f"Number of attendees ({num_attendees}) exceeds the room"
|
||||
f" capacity ({room.capacity})."
|
||||
)
|
||||
|
||||
|
||||
async def new_booking(
|
||||
session: AsyncSession,
|
||||
booking: Booking,
|
||||
@@ -214,12 +134,12 @@ async def new_booking(
|
||||
Booking: Created booking.
|
||||
|
||||
Raises:
|
||||
Exception: If any unexpected error occurs.
|
||||
NoResultFound: If booking is not found after creation.
|
||||
"""
|
||||
logger.debug("Entering new_booking")
|
||||
try:
|
||||
now = datetime.now(timezone.utc)
|
||||
room = await _validate_new_booking_room_exists(session, booking.room_id)
|
||||
await _validate_new_booking_room_exists(session, booking.room_id)
|
||||
_validate_new_booking_time_constraints(
|
||||
booking.start_time, booking.end_time, now
|
||||
)
|
||||
@@ -228,25 +148,40 @@ async def new_booking(
|
||||
booking.start_time, booking.end_time, now, max_months
|
||||
)
|
||||
await _validate_new_booking_no_overlap(session, booking)
|
||||
await _validate_new_booking_capacity(session, booking, room)
|
||||
# Invitee capacity validation now handled in routers/bookings.py
|
||||
|
||||
session.add(booking)
|
||||
await session.commit()
|
||||
await session.refresh(booking)
|
||||
logger.info(f"Successfully created new booking with id: {booking.id}")
|
||||
# Eagerly load invitees to avoid MissingGreenlet error
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
stmt = (
|
||||
select(Booking)
|
||||
.options(selectinload(Booking.invitees))
|
||||
.where(Booking.id == booking.id)
|
||||
)
|
||||
booking_with_invitees = await session.scalar(stmt)
|
||||
if booking_with_invitees is None:
|
||||
logger.error(f"Booking with id {booking.id} not found after creation.")
|
||||
raise NoResultFound(
|
||||
f"Booking with id {booking.id} not found after creation."
|
||||
)
|
||||
if event_publisher:
|
||||
await event_publisher(
|
||||
{
|
||||
"action": "created",
|
||||
"room_id": booking.room_id,
|
||||
"booking_id": booking.id,
|
||||
"start_time": str(booking.start_time),
|
||||
"end_time": str(booking.end_time),
|
||||
"room_id": booking_with_invitees.room_id,
|
||||
"booking_id": booking_with_invitees.id,
|
||||
"start_time": str(booking_with_invitees.start_time),
|
||||
"end_time": str(booking_with_invitees.end_time),
|
||||
"invitees": [i.user_email for i in booking_with_invitees.invitees],
|
||||
}
|
||||
)
|
||||
return booking
|
||||
return booking_with_invitees
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create new booking: {str(e)}")
|
||||
logger.exception(f"Failed to create new booking: {str(e)}")
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
@@ -307,18 +242,6 @@ async def _validate_no_overlap(
|
||||
)
|
||||
|
||||
|
||||
def _validate_attendee_count(invitees: Sequence[Any], room: Any) -> None:
|
||||
if len(invitees or []) > room.capacity:
|
||||
logger.warning(
|
||||
f"Attempted to update booking with {len(invitees)} attendees"
|
||||
f" exceeding room capacity {room.capacity}."
|
||||
)
|
||||
raise ValueError(
|
||||
f"Number of attendees ({len(invitees)}) exceeds the room"
|
||||
f" capacity ({room.capacity})."
|
||||
)
|
||||
|
||||
|
||||
async def update_booking(
|
||||
session: AsyncSession,
|
||||
booking_id: int,
|
||||
@@ -350,21 +273,61 @@ async def update_booking(
|
||||
new_start = cast(datetime, kwargs.get("start_time", current.start_time))
|
||||
new_end = cast(datetime, kwargs.get("end_time", current.end_time))
|
||||
|
||||
room = await _validate_room_exists(session, new_room_id, get_room)
|
||||
await _validate_room_exists(session, new_room_id, get_room)
|
||||
now = datetime.now(timezone.utc)
|
||||
max_months = config("BOOKING_MAX_MONTHS", default=12, cast=int)
|
||||
_validate_time_constraints(new_start, new_end, now, max_months)
|
||||
await _validate_no_overlap(session, booking_id, new_room_id, new_start, new_end)
|
||||
invitees = await get_invitees_for_booking(session, booking_id)
|
||||
_validate_attendee_count(invitees, room)
|
||||
# Invitee capacity validation now handled in routers/bookings.py
|
||||
|
||||
stmt = update(Booking).where(Booking.id == booking_id).values(**kwargs)
|
||||
stmt = (
|
||||
update(Booking)
|
||||
.where(Booking.id == booking_id)
|
||||
.values(**{k: v for k, v in kwargs.items() if k != "invitees"})
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
if result.rowcount == 0:
|
||||
logger.error(f"No booking found with id {booking_id} for update")
|
||||
logger.exception(f"No booking found with id {booking_id} for update")
|
||||
raise NoResultFound()
|
||||
# Update invitees if provided
|
||||
if "invitees" in kwargs:
|
||||
invitee_emails = kwargs["invitees"]
|
||||
logger.info(
|
||||
f"[update_booking] Received invitee_emails for booking {booking_id}:"
|
||||
f" {invitee_emails}"
|
||||
)
|
||||
# Remove existing invitees
|
||||
from backend.models import Invitee
|
||||
|
||||
delete_result = await session.execute(
|
||||
delete(Invitee).where(Invitee.booking_id == booking_id)
|
||||
)
|
||||
logger.info(
|
||||
f"[update_booking] Deleted {delete_result.rowcount} existing invitees for booking {booking_id}" # noqa: B950
|
||||
)
|
||||
# Add new invitees
|
||||
added_count = 0
|
||||
for email in invitee_emails:
|
||||
session.add(Invitee(booking_id=booking_id, user_email=email))
|
||||
added_count += 1
|
||||
logger.info(
|
||||
f"[update_booking] Added {added_count} new invitees for booking {booking_id}: {invitee_emails}" # noqa: B950
|
||||
)
|
||||
await session.commit()
|
||||
booking = await get_booking(session, booking_id)
|
||||
# Eagerly load invitees to avoid MissingGreenlet error
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
stmt = (
|
||||
select(Booking)
|
||||
.options(selectinload(Booking.invitees))
|
||||
.where(Booking.id == booking_id)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
booking = result.scalar_one_or_none()
|
||||
if booking is None:
|
||||
logger.error(f"Booking with id {booking_id} not found after update.")
|
||||
raise NoResultFound(f"Booking with id {booking_id} not found after update.")
|
||||
logger.info(f"Successfully updated booking with id: {booking_id}")
|
||||
if event_publisher:
|
||||
await event_publisher(
|
||||
@@ -374,11 +337,12 @@ async def update_booking(
|
||||
"booking_id": booking.id,
|
||||
"start_time": str(booking.start_time),
|
||||
"end_time": str(booking.end_time),
|
||||
"invitees": [i.user_email for i in booking.invitees],
|
||||
}
|
||||
)
|
||||
return booking
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update booking with id {booking_id}: {str(e)}")
|
||||
logger.exception(f"Failed to update booking with id {booking_id}: {str(e)}")
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
@@ -422,7 +386,7 @@ async def delete_booking(
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete booking with id {booking_id}: {str(e)}")
|
||||
logger.exception(f"Failed to delete booking with id {booking_id}: {str(e)}")
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
"""SQL Database services for managing invitees in the Numinar coding project backend.
|
||||
|
||||
Provides functions to retrieve, add, and remove invitees for bookings, handling database
|
||||
operations and logging.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from backend.models import Invitee
|
||||
from backend.models import InviteeList
|
||||
from backend.schemas.invitees import InviteeResponse
|
||||
from backend.schemas.users import UserResponse
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_invitees_for_booking(
|
||||
session: AsyncSession, booking_id: int
|
||||
) -> list["InviteeResponse"]:
|
||||
"""Retrieve all invitees for a specific booking.
|
||||
|
||||
Args:
|
||||
session: The asynchronous database session.
|
||||
booking_id: The ID of the booking to retrieve invitees for.
|
||||
|
||||
Returns:
|
||||
A list of Invitee objects associated with the booking as invitees.
|
||||
|
||||
Raises:
|
||||
Exception: Any database error encountered during the query is logged and re-raised.
|
||||
"""
|
||||
logger.debug(f"Entering get_invitees_for_booking with booking_id: {booking_id}")
|
||||
try:
|
||||
stmt = (
|
||||
select(Invitee)
|
||||
.where(Invitee.booking_id == booking_id)
|
||||
.options(selectinload(Invitee.user))
|
||||
)
|
||||
result = await session.scalars(stmt)
|
||||
invitees: InviteeList
|
||||
all_result = result.all()
|
||||
if hasattr(all_result, "__await__"):
|
||||
invitees = cast(
|
||||
InviteeList,
|
||||
await all_result, # pyright: ignore[reportGeneralTypeIssues]
|
||||
)
|
||||
else:
|
||||
invitees = cast(InviteeList, all_result)
|
||||
logger.info(
|
||||
f"Successfully retrieved {len(invitees)} invitees for booking_id: {booking_id}"
|
||||
)
|
||||
# Eagerly load user and return InviteeResponse with user data
|
||||
invitee_responses: list[InviteeResponse] = []
|
||||
for inv in invitees:
|
||||
user_data = UserResponse.model_validate(inv.user) if inv.user else None
|
||||
invitee_responses.append(
|
||||
InviteeResponse.model_validate({**inv.__dict__, "user": user_data})
|
||||
)
|
||||
return invitee_responses
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to retrieve invitees for booking_id {booking_id}: {str(e)}"
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
logger.debug("Exiting get_invitees_for_booking")
|
||||
|
||||
|
||||
async def add_invitee_to_booking(
|
||||
session: AsyncSession, booking_id: int, user_email: str
|
||||
) -> Invitee:
|
||||
"""Add an invitee to a booking.
|
||||
|
||||
Args:
|
||||
session: The asynchronous database session.
|
||||
booking_id: The ID of the booking to add the invitee to.
|
||||
user_email: The email of the user to add as an invitee.
|
||||
|
||||
Returns:
|
||||
The created Invitee object.
|
||||
|
||||
Raises:
|
||||
ValueError: If the invitee already exists or if adding the invitee
|
||||
exceeds room capacity.
|
||||
"""
|
||||
logger.debug(
|
||||
f"Entering add_invitee_to_booking with booking_id: {booking_id}, email: {user_email}"
|
||||
)
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from backend.services.bookings import get_booking
|
||||
from backend.services.rooms import get_room
|
||||
|
||||
try:
|
||||
# Guard: booking_id must be valid
|
||||
if booking_id < 1:
|
||||
logger.error(
|
||||
f"Attempted to add invitee with invalid booking_id: {booking_id}"
|
||||
f" (email: {user_email})"
|
||||
)
|
||||
raise ValueError(f"Cannot add invitee: invalid booking_id {booking_id}.")
|
||||
|
||||
# Fetch booking and room
|
||||
booking = await get_booking(session, booking_id)
|
||||
room = await get_room(session, booking.room_id)
|
||||
|
||||
# Efficiently count current invitees
|
||||
count_stmt = (
|
||||
select(func.count())
|
||||
.select_from(Invitee)
|
||||
.where(Invitee.booking_id == booking_id)
|
||||
)
|
||||
result = await session.execute(count_stmt)
|
||||
invitee_count = result.scalar_one()
|
||||
if invitee_count + 1 > room.capacity:
|
||||
logger.warning(
|
||||
f"Attempted to add invitee to booking {booking_id} exceeding "
|
||||
f"room capacity {room.capacity}."
|
||||
)
|
||||
raise ValueError(
|
||||
f"Cannot add invitee: number of attendees would exceed the "
|
||||
f"room capacity ({room.capacity})."
|
||||
)
|
||||
|
||||
invitee = Invitee(booking_id=booking_id, user_email=user_email)
|
||||
session.add(invitee)
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError as err:
|
||||
await session.rollback()
|
||||
# Check if this is a duplicate error
|
||||
if "uq_invitee_booking_user" in str(err.orig):
|
||||
logger.warning(
|
||||
f"User {user_email} is already invited to booking"
|
||||
f" {booking_id} (DB constraint)"
|
||||
)
|
||||
raise ValueError(
|
||||
"This user is already invited to this booking."
|
||||
) from err
|
||||
# Other integrity errors
|
||||
logger.error(f"Integrity error: {err}")
|
||||
raise ValueError("Database integrity error while adding invitee.") from err
|
||||
await session.refresh(invitee)
|
||||
logger.info(
|
||||
f"Successfully added invitee with email {user_email} to booking_id: {booking_id}"
|
||||
)
|
||||
return invitee
|
||||
except Exception as err:
|
||||
logger.error(
|
||||
f"Failed to add invitee with email {user_email} to"
|
||||
f" booking_id {booking_id}: {str(err)}"
|
||||
)
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
logger.debug("Exiting add_invitee_to_booking")
|
||||
|
||||
|
||||
async def remove_invitee_from_booking(
|
||||
session: AsyncSession, booking_id: int, user_email: str
|
||||
) -> None:
|
||||
"""Remove an invitee from a booking.
|
||||
|
||||
Args:
|
||||
session: The asynchronous database session.
|
||||
booking_id: The ID of the booking to remove the invitee from.
|
||||
user_email: The email of the user to remove as an invitee.
|
||||
|
||||
Raises:
|
||||
Exception: Any database error encountered during deletion is logged and re-raised.
|
||||
"""
|
||||
logger.debug(
|
||||
f"Entering remove_invitee_from_booking with"
|
||||
f" booking_id: {booking_id}, email: {user_email}"
|
||||
)
|
||||
try:
|
||||
stmt = delete(Invitee).where(
|
||||
Invitee.booking_id == booking_id, Invitee.user_email == user_email
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
await session.commit()
|
||||
if result.rowcount == 0:
|
||||
logger.info(
|
||||
f"Invitee with email {user_email} for booking_id"
|
||||
f" {booking_id} did not exist (noop delete)."
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Successfully removed invitee with email {user_email} from"
|
||||
f" booking_id: {booking_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to remove invitee with email {user_email}"
|
||||
f" from booking_id {booking_id}: {str(e)}"
|
||||
)
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
logger.debug("Exiting remove_invitee_from_booking")
|
||||
@@ -6,16 +6,12 @@ and documented for Sphinx autodoc and darglint compliance.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import cast
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import NoResultFound
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.models import Booking
|
||||
from backend.models import Invitee
|
||||
from backend.models import User
|
||||
from backend.models import UserList
|
||||
|
||||
@@ -71,49 +67,3 @@ async def get_user(session: AsyncSession, email: str) -> User:
|
||||
except Exception:
|
||||
logger.exception("Failed to retrieve user with email '%s'", email)
|
||||
raise
|
||||
|
||||
|
||||
async def get_available_users(
|
||||
session: AsyncSession,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
exclude_booking_id: int | None = None,
|
||||
) -> UserList:
|
||||
"""Return users not in any booking that overlaps with the given time range.
|
||||
|
||||
Args:
|
||||
session: Asynchronous SQLAlchemy session.
|
||||
start_time: Start of the time range.
|
||||
end_time: End of the time range.
|
||||
exclude_booking_id: Booking ID to exclude from the check (for editing), if any.
|
||||
|
||||
Returns:
|
||||
List of users not in any overlapping booking.
|
||||
"""
|
||||
overlap_stmt = select(Booking.id).where(
|
||||
func.tstzrange(Booking.start_time, Booking.end_time, "[]").op("&&")(
|
||||
func.tstzrange(start_time, end_time, "[]")
|
||||
)
|
||||
)
|
||||
if exclude_booking_id is not None:
|
||||
overlap_stmt = overlap_stmt.where(Booking.id != exclude_booking_id)
|
||||
overlap_booking_ids: list[int] = [
|
||||
row[0] for row in (await session.execute(overlap_stmt)).all()
|
||||
]
|
||||
busy_emails: set[str] = set()
|
||||
if overlap_booking_ids:
|
||||
invitee_stmt = select(Invitee.user_email).where(
|
||||
Invitee.booking_id.in_(overlap_booking_ids)
|
||||
)
|
||||
busy_emails = {row[0] for row in (await session.execute(invitee_stmt)).all()}
|
||||
stmt = select(User).where(~User.email.in_(busy_emails))
|
||||
result = await session.scalars(stmt)
|
||||
users: UserList = cast(UserList, result.all())
|
||||
logger.info(
|
||||
"Found %d available users for time range %s to %s (excluding booking_id=%s)",
|
||||
len(users),
|
||||
start_time,
|
||||
end_time,
|
||||
exclude_booking_id,
|
||||
)
|
||||
return users
|
||||
|
||||
@@ -6,24 +6,44 @@
|
||||
* @returns {JSX.Element} App root UI
|
||||
*/
|
||||
|
||||
// External imports
|
||||
import React, { useEffect } from "react";
|
||||
import { BrowserRouter as Router, Route, Routes } from "react-router-dom";
|
||||
|
||||
// Internal component imports
|
||||
import BookingPage from "./pages/BookingPage";
|
||||
import ConfirmationPage from "./pages/ConfirmationPage";
|
||||
import LandingPage from "./pages/LandingPage";
|
||||
|
||||
// Context imports
|
||||
import { BookingProvider } from "./context/BookingContext";
|
||||
import { RoomProvider } from "./context/RoomContext";
|
||||
import { UserProvider } from "./context/UserContext";
|
||||
|
||||
// Utility/helper imports
|
||||
import { logger } from "./utils/logger";
|
||||
|
||||
const App: React.FC = () => {
|
||||
// Log when App mounts
|
||||
logger.info("[App] Mounted");
|
||||
useEffect(() => {
|
||||
logger.info("[App] Mounted");
|
||||
return () => {
|
||||
logger.info("[App] Unmounted");
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/booking" element={<BookingPage />} />
|
||||
<Route path="/confirmation" element={<ConfirmationPage />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
<RoomProvider>
|
||||
<UserProvider>
|
||||
<BookingProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/booking" element={<BookingPage />} />
|
||||
<Route path="/confirmation" element={<ConfirmationPage />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</BookingProvider>
|
||||
</UserProvider>
|
||||
</RoomProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import App from "./App";
|
||||
|
||||
test("renders learn react link", () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
/**
|
||||
* BookingForm component test suite.
|
||||
* Covers rendering, validation, error handling, and robust matchers.
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import BookingForm from "../../components/BookingForm";
|
||||
|
||||
import userEvent from "@testing-library/user-event";
|
||||
const mockInvitees = ["Alice", "Bob", "Charlie"];
|
||||
|
||||
const mockRooms = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Room A",
|
||||
location: "Bldg 1",
|
||||
equipment: "Projector",
|
||||
capacity: 10,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Room B",
|
||||
location: "Bldg 2",
|
||||
equipment: "Whiteboard",
|
||||
capacity: 8,
|
||||
},
|
||||
];
|
||||
|
||||
describe("BookingForm", () => {
|
||||
// Robust matcher for split text nodes
|
||||
function splitTextMatcher(text: string | RegExp) {
|
||||
return (_: string, node: Element | null): boolean => {
|
||||
if (!node) return false;
|
||||
// Recursively flatten all text content from node and descendants
|
||||
const getText = (n: Node): string => {
|
||||
let result = "";
|
||||
if (n.nodeType === Node.TEXT_NODE) {
|
||||
result += n.textContent || "";
|
||||
} else {
|
||||
for (const child of Array.from(n.childNodes)) {
|
||||
result += getText(child);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const value = getText(node).replace(/\s+/g, " ").trim();
|
||||
if (typeof text === "string") return value.includes(text);
|
||||
return text.test(value);
|
||||
};
|
||||
}
|
||||
it("renders all form fields", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<BookingForm
|
||||
open={true}
|
||||
onClose={jest.fn()}
|
||||
slotInfo={{}}
|
||||
rooms={mockRooms}
|
||||
allInvitees={[]}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const roomSelect = screen
|
||||
.getAllByLabelText(/Room/i)
|
||||
.find((el) => el.getAttribute("role") === "combobox");
|
||||
expect(roomSelect).toBeTruthy();
|
||||
expect(screen.getByLabelText(/Title/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Start Time/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/End Time/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Invitees/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Book/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("validates required fields and shows errors", async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<BookingForm
|
||||
open={true}
|
||||
onClose={jest.fn()}
|
||||
slotInfo={{}}
|
||||
rooms={mockRooms}
|
||||
allInvitees={[]}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
// Explicitly clear all required fields to trigger validation errors
|
||||
await userEvent.clear(screen.getByLabelText(/Title/i));
|
||||
await userEvent.clear(screen.getByLabelText(/Start Time/i));
|
||||
await userEvent.clear(screen.getByLabelText(/End Time/i));
|
||||
// After clearing required fields, the button should be disabled
|
||||
const bookButton = screen.getByRole("button", { name: /book/i });
|
||||
expect(bookButton).toBeDisabled();
|
||||
// Only assert for errors that can be triggered (Room field cannot be cleared)
|
||||
// Use splitTextMatcher for error queries
|
||||
const startTimeErrors = await screen.findAllByText(
|
||||
splitTextMatcher(/Start time is required/i)
|
||||
);
|
||||
expect(startTimeErrors.length).toBeGreaterThan(0);
|
||||
const endTimeErrors = await screen.findAllByText(
|
||||
splitTextMatcher(/End time is required/i)
|
||||
);
|
||||
expect(endTimeErrors.length).toBeGreaterThan(0);
|
||||
});
|
||||
it("shows backend error message on submit failure", async () => {
|
||||
// Only test backend error after valid submit
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<BookingForm
|
||||
open={true}
|
||||
onClose={jest.fn()}
|
||||
rooms={mockRooms}
|
||||
allInvitees={mockInvitees}
|
||||
slotInfo={{}}
|
||||
onBookingSuccess={jest.fn()}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
await userEvent.type(screen.getByLabelText(/Title/i), "Team Meeting");
|
||||
// Fill required start and end time fields
|
||||
const startInput = screen.getByLabelText(/start/i);
|
||||
const endInput = screen.getByLabelText(/end/i);
|
||||
await userEvent.clear(startInput);
|
||||
await userEvent.type(startInput, "2025-09-04T10:00");
|
||||
await userEvent.clear(endInput);
|
||||
await userEvent.type(endInput, "2025-09-04T11:00");
|
||||
// Select room (use getAllByLabelText to avoid ambiguity)
|
||||
const roomInputs = screen.getAllByLabelText(/Room/i);
|
||||
const roomSelect = roomInputs.find(
|
||||
(el) => el.getAttribute("role") === "combobox"
|
||||
);
|
||||
expect(roomSelect).toBeTruthy();
|
||||
await userEvent.click(roomSelect!);
|
||||
// Find all elements with Room A text and click the one with role="option"
|
||||
const roomAOptions = screen.getAllByText(/Room A/i);
|
||||
const roomAOption = roomAOptions.find(
|
||||
(el) => el.getAttribute("role") === "option"
|
||||
);
|
||||
expect(roomAOption).toBeTruthy();
|
||||
await userEvent.click(roomAOption!);
|
||||
const bookButton = screen.getByRole("button", { name: /book/i });
|
||||
await waitFor(() => expect(bookButton).not.toBeDisabled());
|
||||
await waitFor(() =>
|
||||
expect(getComputedStyle(bookButton).pointerEvents).not.toBe("none")
|
||||
);
|
||||
await userEvent.click(bookButton);
|
||||
// Use findByText for backend error
|
||||
const errorMessage = await screen.findByText(
|
||||
/Request failed with status code 404/i
|
||||
);
|
||||
expect(errorMessage).toBeInTheDocument();
|
||||
await userEvent.clear(screen.getByLabelText(/Start Time/i));
|
||||
await userEvent.clear(screen.getByLabelText(/End Time/i));
|
||||
// After clearing required fields, the button should be disabled
|
||||
expect(bookButton).toBeDisabled();
|
||||
// Room field cannot be cleared, so 'Room is required' error cannot be triggered
|
||||
// Only check for errors that can actually appear (Start Time, End Time)
|
||||
});
|
||||
}); // Closing the describe block
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* BookingList component test suite.
|
||||
* Covers rendering, empty state, and SSE updates.
|
||||
*/
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import BookingList, { Booking } from "../../components/BookingList";
|
||||
|
||||
describe("BookingList", () => {
|
||||
const mockBookings: Booking[] = [
|
||||
{
|
||||
id: "1",
|
||||
room: { name: "Room A" },
|
||||
start_time: "2025-08-27T10:00:00Z",
|
||||
end_time: "2025-08-27T11:00:00Z",
|
||||
title: "Meeting",
|
||||
},
|
||||
];
|
||||
|
||||
it("renders bookings with correct details", () => {
|
||||
render(<BookingList bookings={mockBookings} />);
|
||||
expect(screen.getByText("Meeting")).toBeInTheDocument();
|
||||
// Room name is now in primary
|
||||
expect(screen.getByText("Meeting")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("list", { name: /today's bookings/i })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays no bookings message when empty", () => {
|
||||
render(<BookingList bookings={[]} />);
|
||||
expect(screen.getByText("No bookings found.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("SSE updates", () => {
|
||||
let originalEventSource: any;
|
||||
let mockEventSourceInstance: any;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEventSource = window.EventSource;
|
||||
mockEventSourceInstance = {
|
||||
close: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
onmessage: jest.fn(),
|
||||
onerror: null,
|
||||
};
|
||||
(window as any).EventSource = jest.fn(() => mockEventSourceInstance);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(window as any).EventSource = originalEventSource;
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("updates bookings when SSE event is received", async () => {
|
||||
const initialBookings: Booking[] = [
|
||||
{
|
||||
id: "1",
|
||||
room: { name: "Room A" },
|
||||
start_time: "2025-08-27T10:00:00Z",
|
||||
end_time: "2025-08-27T11:00:00Z",
|
||||
title: "Initial Meeting",
|
||||
},
|
||||
];
|
||||
const newBookings: Booking[] = [
|
||||
{
|
||||
id: "2",
|
||||
room: { name: "Room A" },
|
||||
start_time: "2025-08-27T12:00:00Z",
|
||||
end_time: "2025-08-27T13:00:00Z",
|
||||
title: "SSE Meeting",
|
||||
},
|
||||
];
|
||||
render(<BookingList bookings={initialBookings} />);
|
||||
// Initial booking is rendered
|
||||
expect(screen.getByText("Initial Meeting")).toBeInTheDocument();
|
||||
|
||||
// Simulate SSE message inside act()
|
||||
await act(async () => {
|
||||
mockEventSourceInstance.onmessage({
|
||||
data: JSON.stringify({ bookings: newBookings }),
|
||||
});
|
||||
});
|
||||
|
||||
// New booking should be rendered
|
||||
expect(await screen.findByText("SSE Meeting")).toBeInTheDocument();
|
||||
// Old booking should not be present
|
||||
expect(screen.queryByText("Initial Meeting")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import CalendarView from "../../components/CalendarView";
|
||||
|
||||
/**
|
||||
* CalendarView component test suite.
|
||||
* Covers rendering, tooltips, and robust matchers.
|
||||
*/
|
||||
const mockEvents = [
|
||||
{
|
||||
id: "1",
|
||||
title: "Team Meeting",
|
||||
start: new Date("2025-08-28T10:00:00Z"),
|
||||
end: new Date("2025-08-28T11:00:00Z"),
|
||||
resource: { roomId: 1, roomName: "Room A" },
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Utility to flatten all text content from a DOM node and its descendants.
|
||||
*/
|
||||
function flattenText(node: Node | null): string {
|
||||
let text = "";
|
||||
if (!node) return text;
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
text += node.textContent || "";
|
||||
} else if (
|
||||
node.nodeType === Node.ELEMENT_NODE ||
|
||||
node.nodeType === Node.DOCUMENT_FRAGMENT_NODE
|
||||
) {
|
||||
for (const child of Array.from(node.childNodes)) {
|
||||
text += flattenText(child);
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
describe("CalendarView", () => {
|
||||
/**
|
||||
* Should render calendar events and display tooltips with correct content.
|
||||
* Uses robust matchers to handle split/nested text nodes in FullCalendar/MUI output.
|
||||
*/
|
||||
it("renders events and tooltips", async () => {
|
||||
render(<CalendarView events={mockEvents} initialDate="2025-08-28" />);
|
||||
|
||||
// Debug: inspect DOM if needed
|
||||
|
||||
// Find event node by robust matcher
|
||||
const eventNodes = screen.getAllByText(
|
||||
(_: string, node: Node | null): boolean => {
|
||||
if (!node) return false;
|
||||
const value = flattenText(node).replace(/\s+/g, " ").trim();
|
||||
return /06:00\s*AM\s*-\s*Team\s*Meeting/i.test(value);
|
||||
}
|
||||
);
|
||||
expect(eventNodes.length).toBeGreaterThan(0);
|
||||
const eventNode = eventNodes[0] as HTMLElement;
|
||||
await userEvent.hover(eventNode);
|
||||
|
||||
// Assert tooltip content for room
|
||||
const tooltipMatches: HTMLElement[] = await screen.findAllByText(
|
||||
(_: string, node: Node | null) => {
|
||||
if (!node) return false;
|
||||
const value = flattenText(node).replace(/\s+/g, " ").trim();
|
||||
return /Room: 1/i.test(value);
|
||||
}
|
||||
);
|
||||
expect(tooltipMatches.length).toBeGreaterThan(0);
|
||||
expect(tooltipMatches[0]).toBeInTheDocument();
|
||||
|
||||
// Assert tooltip content for start time
|
||||
const startMatches: HTMLElement[] = await screen.findAllByText(
|
||||
(_: string, node: Node | null) => {
|
||||
if (!node) return false;
|
||||
const value = flattenText(node).replace(/\s+/g, " ").trim();
|
||||
return /Start:/i.test(value);
|
||||
}
|
||||
);
|
||||
expect(startMatches.length).toBeGreaterThan(0);
|
||||
expect(startMatches[0]).toBeInTheDocument();
|
||||
|
||||
// Assert tooltip content for end time
|
||||
const endMatches: HTMLElement[] = await screen.findAllByText(
|
||||
(_: string, node: Node | null) => {
|
||||
if (!node) return false;
|
||||
const value = flattenText(node).replace(/\s+/g, " ").trim();
|
||||
return /End:/i.test(value);
|
||||
}
|
||||
);
|
||||
expect(endMatches.length).toBeGreaterThan(0);
|
||||
expect(endMatches[0]).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
/**
|
||||
* RoomEquipmentList component test suite.
|
||||
* Covers rendering of room equipment list.
|
||||
*/
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import RoomEquipmentList from "../../components/RoomEquipmentList";
|
||||
|
||||
describe("RoomEquipmentList", () => {
|
||||
it("renders the room equipment list", () => {
|
||||
render(<RoomEquipmentList />);
|
||||
expect(screen.getByText(/Room Equipment/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* RoomList component test suite.
|
||||
* Covers rendering, empty state, selection, and highlighting.
|
||||
*/
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import RoomList, { ConferenceRoom } from "../../components/RoomList";
|
||||
|
||||
describe("RoomList", () => {
|
||||
const mockRooms: ConferenceRoom[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Room A",
|
||||
location: "Building 1",
|
||||
equipment: "Projector",
|
||||
capacity: 10,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Room B",
|
||||
location: "Building 2",
|
||||
equipment: "Whiteboard",
|
||||
capacity: 8,
|
||||
},
|
||||
];
|
||||
|
||||
it("renders rooms with correct details", () => {
|
||||
render(<RoomList rooms={mockRooms} />);
|
||||
expect(screen.getByText("Room A")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Location: Building 1/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Capacity: 10/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Equipment: Projector/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Room B")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Location: Building 2/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Capacity: 8/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Equipment: Whiteboard/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("list", { name: /available conference rooms/i })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays no rooms message when empty", () => {
|
||||
render(<RoomList rooms={[]} />);
|
||||
expect(screen.getByText("No rooms available.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onSelectRoom when a room is clicked", () => {
|
||||
const onSelectRoom = jest.fn();
|
||||
render(
|
||||
<RoomList
|
||||
rooms={mockRooms}
|
||||
selectedRoomId={2}
|
||||
onSelectRoom={onSelectRoom}
|
||||
/>
|
||||
);
|
||||
const roomA = screen.getByTestId("room-item-1");
|
||||
const roomB = screen.getByTestId("room-item-2");
|
||||
roomA.click();
|
||||
expect(onSelectRoom).toHaveBeenCalledWith(1);
|
||||
roomB.click();
|
||||
expect(onSelectRoom).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it("highlights the selected room", () => {
|
||||
render(
|
||||
<RoomList rooms={mockRooms} selectedRoomId={2} onSelectRoom={() => {}} />
|
||||
);
|
||||
const roomB = screen.getByTestId("room-item-2");
|
||||
expect(roomB.className).toMatch(/Mui-selected/);
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* BookingPage component test suite.
|
||||
* Covers loading state, calendar UI, and API mocking.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import BookingPage from "../../pages/BookingPage";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
|
||||
jest.mock("axios");
|
||||
|
||||
describe("BookingPage", () => {
|
||||
it("renders loading and then calendar UI", async () => {
|
||||
// Mock /rooms/ and /bookings/room/ endpoints
|
||||
axios.get = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{ id: 1, name: "Room A" },
|
||||
{ id: 2, name: "Room B" },
|
||||
],
|
||||
})
|
||||
)
|
||||
.mockImplementation((url) => {
|
||||
if (url === "/bookings/room/1") return Promise.resolve({ data: [] });
|
||||
if (url === "/bookings/room/2") return Promise.resolve({ data: [] });
|
||||
return Promise.resolve({ data: [] });
|
||||
});
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BookingPage />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
// Should show loading first
|
||||
expect(screen.getByText(/Loading calendar/i)).toBeInTheDocument();
|
||||
// Wait for calendar to render
|
||||
await waitFor(() => {
|
||||
// Look for heading after loading
|
||||
expect(screen.getByText(/Book a Room/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* LandingPage component test suite.
|
||||
* Covers integration, API mocking, loading/error states, and navigation.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import axios from "axios";
|
||||
import LandingPage from "../../pages/LandingPage";
|
||||
import { ConferenceRoom } from "../../components/RoomList";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Booking } from "../../components/BookingList";
|
||||
|
||||
jest.mock("axios");
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||
|
||||
describe("LandingPage", () => {
|
||||
const createWrapper = (children: React.ReactNode) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
const mockRooms: ConferenceRoom[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Room A",
|
||||
location: "Building 1",
|
||||
equipment: "Projector",
|
||||
capacity: 10,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Room B",
|
||||
location: "Building 2",
|
||||
equipment: "Whiteboard",
|
||||
capacity: 8,
|
||||
},
|
||||
];
|
||||
const mockBookingsA: Booking[] = [
|
||||
{
|
||||
id: "1",
|
||||
room: { name: "Room A" },
|
||||
start_time: "2025-08-27T10:00:00Z",
|
||||
end_time: "2025-08-27T11:00:00Z",
|
||||
title: "Meeting A",
|
||||
},
|
||||
];
|
||||
const mockBookingsB: Booking[] = [
|
||||
{
|
||||
id: "2",
|
||||
room: { name: "Room B" },
|
||||
start_time: "2025-08-27T12:00:00Z",
|
||||
end_time: "2025-08-27T13:00:00Z",
|
||||
title: "Meeting B",
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mockedAxios.get.mockReset();
|
||||
});
|
||||
|
||||
test("renders loading state with skeleton loaders", () => {
|
||||
render(<LandingPage />, {
|
||||
wrapper: ({ children }) =>
|
||||
createWrapper(<MemoryRouter>{children}</MemoryRouter>),
|
||||
});
|
||||
expect(screen.getAllByTestId("skeleton-loader").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("renders rooms and bookings correctly, and updates bookings on room selection", async () => {
|
||||
mockedAxios.get
|
||||
.mockResolvedValueOnce({ data: mockRooms }) // rooms
|
||||
.mockResolvedValueOnce({ data: mockBookingsA }) // bookings for Room A
|
||||
.mockResolvedValueOnce({ data: mockBookingsB }); // bookings for Room B
|
||||
|
||||
render(<LandingPage />, {
|
||||
wrapper: ({ children }) =>
|
||||
createWrapper(<MemoryRouter>{children}</MemoryRouter>),
|
||||
});
|
||||
|
||||
// Wait for rooms to load
|
||||
await screen.findByText("Room A");
|
||||
await screen.findByText("Room B");
|
||||
// Wait for bookings for Room A
|
||||
await screen.findByText("Meeting A");
|
||||
|
||||
// Select Room B
|
||||
const roomB = screen.getByTestId("room-item-2");
|
||||
await userEvent.click(roomB);
|
||||
|
||||
// Wait for bookings for Room B
|
||||
await screen.findByText("Meeting B");
|
||||
|
||||
expect(screen.getByTestId("desktop-book-btn")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays error message and retry button on API failure", async () => {
|
||||
// Mock only the first axios.get call (rooms) to fail
|
||||
mockedAxios.get.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
render(<LandingPage />, {
|
||||
wrapper: ({ children }) =>
|
||||
createWrapper(<MemoryRouter>{children}</MemoryRouter>),
|
||||
});
|
||||
|
||||
// Wait for error message to appear
|
||||
expect(await screen.findByTestId("error-message")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /retry fetching data/i })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,29 +1,14 @@
|
||||
/**
|
||||
* Bookings API
|
||||
* bookings.ts
|
||||
* Provides functions to interact with the bookings backend endpoints.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /bookings/room/:roomId
|
||||
* - POST /bookings
|
||||
* - PUT /bookings/:bookingId
|
||||
* - DELETE /bookings/:bookingId
|
||||
*
|
||||
* Author: Cliff Hill
|
||||
* Last updated: 2025-09-08
|
||||
* @module
|
||||
*/
|
||||
|
||||
// External imports
|
||||
import axios from "axios";
|
||||
|
||||
/**
|
||||
* Fetch bookings for a specific room and date.
|
||||
* @param roomId Room ID
|
||||
* @param date ISO date string (YYYY-MM-DD)
|
||||
* @returns Array of bookings
|
||||
*/
|
||||
export async function getRoomBookings(roomId: string | number, date?: string) {
|
||||
const params = date ? { date } : undefined;
|
||||
const res = await axios.get(`/bookings/room/${roomId}`, { params });
|
||||
return res.data;
|
||||
}
|
||||
// Type-only imports
|
||||
import type { Booking } from "../interfaces";
|
||||
|
||||
/**
|
||||
* Create a new booking.
|
||||
@@ -35,8 +20,9 @@ export async function createBooking(payload: {
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
title: string;
|
||||
invitees?: string[];
|
||||
}) {
|
||||
const res = await axios.post("/bookings", payload);
|
||||
const res = await axios.post("/bookings/", payload);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
@@ -53,9 +39,10 @@ export async function updateBooking(
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
title: string;
|
||||
invitees?: string[];
|
||||
}
|
||||
) {
|
||||
const res = await axios.put(`/bookings/${bookingId}`, payload);
|
||||
const res = await axios.patch(`/bookings/${bookingId}`, payload);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
@@ -67,3 +54,14 @@ export async function updateBooking(
|
||||
export async function deleteBooking(bookingId: string | number) {
|
||||
await axios.delete(`/bookings/${bookingId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all bookings for all rooms for a given month (YYYY-MM).
|
||||
* Backend endpoint: GET /bookings/month/:YYYY-MM
|
||||
* @param month ISO month string (YYYY-MM)
|
||||
* @returns Array of bookings
|
||||
*/
|
||||
export async function getMonthBookings(month: string): Promise<Booking[]> {
|
||||
const res = await axios.get(`/bookings/month/${month}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* Invitees API
|
||||
* Provides functions to interact with the invitees backend endpoints.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /invitees/booking/:bookingId
|
||||
* - POST /invitees/booking/:bookingId
|
||||
* - DELETE /invitees/booking/:bookingId/:email
|
||||
*
|
||||
* Author: Cliff Hill
|
||||
* Last updated: 2025-09-08
|
||||
*/
|
||||
import axios from "axios";
|
||||
|
||||
/**
|
||||
* Fetch invitees for a booking.
|
||||
* @param bookingId Booking ID
|
||||
* @returns Array of invitee emails
|
||||
*/
|
||||
export async function getInvitees(bookingId: string | number) {
|
||||
const res = await axios.get(`/invitees/booking/${bookingId}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an invitee to a booking.
|
||||
* @param bookingId Booking ID
|
||||
* @param user_email Email of the user to add
|
||||
*/
|
||||
export async function addInvitee(
|
||||
bookingId: string | number,
|
||||
user_email: string
|
||||
) {
|
||||
await axios.post(`/invitees/booking/${bookingId}`, {
|
||||
booking_id: bookingId,
|
||||
user_email,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an invitee from a booking.
|
||||
* @param bookingId Booking ID
|
||||
* @param user_email Email of the user to remove
|
||||
*/
|
||||
export async function removeInvitee(
|
||||
bookingId: string | number,
|
||||
user_email: string
|
||||
) {
|
||||
await axios.delete(`/invitees/booking/${bookingId}/${user_email}`);
|
||||
}
|
||||
@@ -1,15 +1,10 @@
|
||||
/**
|
||||
* Rooms API
|
||||
* rooms.ts
|
||||
* Provides functions to interact with the rooms backend endpoints.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /rooms/
|
||||
* - GET /rooms/:id
|
||||
* - SSE /rooms/availability/stream
|
||||
*
|
||||
* Author: Cliff Hill
|
||||
* Last updated: 2025-09-08
|
||||
* @module
|
||||
*/
|
||||
|
||||
// External imports
|
||||
import axios from "axios";
|
||||
|
||||
/**
|
||||
@@ -26,7 +21,3 @@ export async function getRooms() {
|
||||
* @param roomId Room ID
|
||||
* @returns Room object
|
||||
*/
|
||||
export async function getRoom(roomId: string | number) {
|
||||
const res = await axios.get(`/rooms/${roomId}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
/**
|
||||
* SSE API
|
||||
* sse.ts
|
||||
* Provides helper for connecting to SSE endpoints.
|
||||
*
|
||||
* Endpoints:
|
||||
* - /rooms/availability/stream
|
||||
*
|
||||
* Author: Cliff Hill
|
||||
* Last updated: 2025-09-08
|
||||
* @module
|
||||
*/
|
||||
|
||||
/**
|
||||
/**
|
||||
* SSE payload for rooms availability stream.
|
||||
*/
|
||||
|
||||
// Type-only imports
|
||||
import type { RoomsAvailabilitySSEPayload } from "../interfaces";
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
/**
|
||||
* Users API
|
||||
* users.ts
|
||||
* Provides functions to interact with the users backend endpoints.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /users/
|
||||
* - GET /users/available/
|
||||
*
|
||||
* Author: Cliff Hill
|
||||
* Last updated: 2025-09-08
|
||||
* @module
|
||||
*/
|
||||
|
||||
// External imports
|
||||
import axios from "axios";
|
||||
|
||||
// Type-only imports
|
||||
import type { User } from "../interfaces";
|
||||
|
||||
/**
|
||||
@@ -20,27 +18,3 @@ export async function getUsers(): Promise<User[]> {
|
||||
const res = await axios.get<User[]>("/users/");
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available users for a given time slot.
|
||||
* @param params Object containing start_time, end_time, and optional exclude_booking_id
|
||||
* @returns Array of available users
|
||||
*/
|
||||
export async function getAvailableUsers(params: {
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
exclude_booking_id?: string | number;
|
||||
}): Promise<User[]> {
|
||||
const query: {
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
exclude_booking_id?: string | number;
|
||||
} = {
|
||||
start_time: params.start_time,
|
||||
end_time: params.end_time,
|
||||
};
|
||||
if (params.exclude_booking_id)
|
||||
query.exclude_booking_id = params.exclude_booking_id;
|
||||
const res = await axios.get<User[]>("/users/available/", { params: query });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import React from "react";
|
||||
import { Box, Button } from "@mui/material";
|
||||
|
||||
// Internal imports
|
||||
// import { COLORS } from "../constants";
|
||||
|
||||
// Type-only imports
|
||||
import type { BookingConfirmationProps } from "../interfaces";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,24 @@
|
||||
/**
|
||||
* BookingList component
|
||||
* Lists bookings for a selected room or user, including times and invitees.
|
||||
* Handles empty state and sorts bookings chronologically.
|
||||
* @returns {JSX.Element} Booking list UI
|
||||
*/
|
||||
// Utility/helper imports
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// External imports
|
||||
import React from "react";
|
||||
|
||||
// MUI imports
|
||||
import { List, ListItemButton, Typography } from "@mui/material";
|
||||
|
||||
// Internal imports
|
||||
// import { COLORS } from "../constants";
|
||||
// Internal helper imports
|
||||
import {
|
||||
formatBookingTime,
|
||||
getInviteeName,
|
||||
sortBookingsByStartTime,
|
||||
} from "../helpers/bookingList";
|
||||
|
||||
// Type-only imports
|
||||
import type { BookingListProps } from "../interfaces";
|
||||
|
||||
// Context hook imports
|
||||
import { useUsers } from "../context/UserContext";
|
||||
|
||||
/**
|
||||
* BookingList component
|
||||
* Displays a list of bookings for a room or user, including times and invitees.
|
||||
@@ -29,73 +28,78 @@ import type { BookingListProps } from "../interfaces";
|
||||
* @returns {JSX.Element} Booking list UI
|
||||
*/
|
||||
const BookingList: React.FC<BookingListProps> = ({ bookings, onSelect }) => {
|
||||
// Context hook for users (must be called at top level)
|
||||
const { users } = useUsers();
|
||||
|
||||
React.useEffect(() => {
|
||||
logger.info("[BookingList] Mounted");
|
||||
return () => {
|
||||
logger.info("[BookingList] Unmounted");
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
logger.debug("[BookingList] bookings updated", bookings);
|
||||
}, [bookings]);
|
||||
|
||||
if (!bookings || bookings.length === 0) {
|
||||
return (
|
||||
<List sx={{ mt: 0.5 }}>
|
||||
<ListItemButton
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
py: 2,
|
||||
px: 2,
|
||||
}}
|
||||
disabled
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-primary)",
|
||||
fontSize: "1rem",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<ListItemButton className="bookinglist-empty" disabled>
|
||||
<Typography className="bookinglist-item-title">
|
||||
No bookings found.
|
||||
</Typography>
|
||||
</ListItemButton>
|
||||
</List>
|
||||
);
|
||||
}
|
||||
// Sort bookings by start_time ascending (oldest first)
|
||||
const sortedBookings = sortBookingsByStartTime(bookings);
|
||||
/**
|
||||
* Maps invitee email to user name for display.
|
||||
* Only shows invitee names (not emails).
|
||||
* @param {string} email - Invitee email
|
||||
* @returns {string} Display name
|
||||
*/
|
||||
const getInviteeDisplay = (email: string) => {
|
||||
const user = users.find(
|
||||
(u: import("../interfaces").User) => u.email === email
|
||||
);
|
||||
return user ? user.name : email;
|
||||
};
|
||||
return (
|
||||
<List sx={{ mt: 0.5 }}>
|
||||
{sortedBookings.map((booking) => {
|
||||
// Format time as h:mm AM/PM
|
||||
const inviteeNames = booking.invitees
|
||||
? booking.invitees.map(getInviteeName).filter(Boolean)
|
||||
: [];
|
||||
let inviteeNames: string[] = [];
|
||||
if (Array.isArray(booking.invitees)) {
|
||||
inviteeNames = booking.invitees.map(getInviteeDisplay);
|
||||
}
|
||||
return (
|
||||
<ListItemButton
|
||||
key={booking.id}
|
||||
onClick={() => onSelect && onSelect(booking)}
|
||||
className="bookinglist-item"
|
||||
sx={{ flexDirection: "column", alignItems: "flex-start" }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-primary)",
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
>
|
||||
<span className="bookinglist-item-title">
|
||||
{booking.start_time && booking.end_time
|
||||
? `${formatBookingTime(
|
||||
booking.start_time
|
||||
)} ${formatBookingTime(booking.end_time)}`
|
||||
)} - ${formatBookingTime(booking.end_time)}`
|
||||
: "Time not set"}
|
||||
</span>
|
||||
{inviteeNames.length > 0 && (
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: "0.85em",
|
||||
color: "var(--color-text-secondary)",
|
||||
fontStyle: "italic",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
Invitees: {inviteeNames.join(", ")}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="bookinglist-invitees"
|
||||
style={{
|
||||
marginTop: 4,
|
||||
fontSize: "0.92em",
|
||||
fontWeight: 400,
|
||||
color: "#666",
|
||||
}}
|
||||
>
|
||||
{inviteeNames.length > 0
|
||||
? `Invitees: ${inviteeNames.join(", ")}`
|
||||
: "No invitees"}
|
||||
</span>
|
||||
</ListItemButton>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
/**
|
||||
* CalendarView.tsx
|
||||
* Renders calendar with events, tooltips, and slot selection for bookings.
|
||||
* Used in booking page for visualizing room availability.
|
||||
*
|
||||
* Author: Cliff Hill
|
||||
* Last updated: 2025-09-05
|
||||
*/
|
||||
|
||||
/**
|
||||
* CalendarView component
|
||||
* Renders a calendar with events, tooltips, and slot selection for bookings.
|
||||
@@ -16,19 +7,30 @@
|
||||
* @returns {JSX.Element} Calendar UI
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
// Utility/helper imports
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// External imports
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
// MUI imports
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
// Third-party calendar imports
|
||||
import FullCalendar from "@fullcalendar/react";
|
||||
import dayGridPlugin from "@fullcalendar/daygrid";
|
||||
import interactionPlugin from "@fullcalendar/interaction";
|
||||
import timeGridPlugin from "@fullcalendar/timegrid";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
import { ENV } from "../constants";
|
||||
import { getEventDisplayText, getRoomClass } from "../helpers/calendar";
|
||||
import { logger } from "../utils/logger";
|
||||
// Styles
|
||||
import "../styles/CalendarView.css";
|
||||
|
||||
// Internal helper imports
|
||||
import { ENV } from "../constants";
|
||||
import { getEventDisplayText, getRoomClass } from "../helpers/calendar";
|
||||
|
||||
// Type-only imports
|
||||
import type { BookingEvent } from "../types";
|
||||
import type { CalendarViewProps } from "../interfaces";
|
||||
|
||||
@@ -43,6 +45,16 @@ const CalendarView: React.FC<CalendarViewProps> = ({
|
||||
selectedRoomId,
|
||||
initialDate,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
logger.info("[CalendarView] Mounted");
|
||||
return () => {
|
||||
logger.info("[CalendarView] Unmounted");
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
logger.debug("[CalendarView] events updated", events);
|
||||
}, [events]);
|
||||
// Custom event content with tooltip
|
||||
const renderEventContent = (arg: { event: BookingEvent }) => {
|
||||
const { event } = arg;
|
||||
@@ -104,33 +116,8 @@ const CalendarView: React.FC<CalendarViewProps> = ({
|
||||
}
|
||||
open={process.env.NODE_ENV === "test" ? true : undefined}
|
||||
>
|
||||
<div
|
||||
className={roomClass}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
borderRadius: 4,
|
||||
paddingLeft: 4,
|
||||
paddingRight: 4,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="fc-event-title-ellipsis"
|
||||
style={{
|
||||
display: "block",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
width: "100%",
|
||||
minWidth: 0,
|
||||
fontSize: "inherit",
|
||||
}}
|
||||
>
|
||||
{displayText}
|
||||
</span>
|
||||
<div className={`${roomClass} calendar-event`}>
|
||||
<span className="calendar-event-title">{displayText}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -149,7 +136,7 @@ const CalendarView: React.FC<CalendarViewProps> = ({
|
||||
];
|
||||
// Diagnostic: log events passed to FullCalendar
|
||||
React.useEffect(() => {
|
||||
logger.info("[CalendarView] Events passed to FullCalendar:", events);
|
||||
// Diagnostic: events passed to FullCalendar
|
||||
}, [events]);
|
||||
return (
|
||||
<div className="calendarContainer">
|
||||
@@ -173,7 +160,9 @@ const CalendarView: React.FC<CalendarViewProps> = ({
|
||||
info.jsEvent.preventDefault();
|
||||
if (onEventClick) {
|
||||
// Pass the full event object
|
||||
onEventClick(info.event as unknown as BookingEvent);
|
||||
onEventClick(
|
||||
info.event.extendedProps as import("../types").BookingEvent
|
||||
);
|
||||
}
|
||||
}}
|
||||
selectable={true}
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
/**
|
||||
* RoomDetailsModal.tsx
|
||||
* RoomDetailsModal component
|
||||
* Modal dialog for displaying details about a conference room.
|
||||
* Used in booking page for room info popups.
|
||||
*
|
||||
* Author: Cliff Hill
|
||||
* Last updated: 2025-09-05
|
||||
* @component
|
||||
* @param {RoomDetailsModalProps} props - Component props
|
||||
* @returns {JSX.Element} Room details modal UI
|
||||
*/
|
||||
/**
|
||||
* RoomDetailsModal.tsx
|
||||
* Modal dialog for displaying details about a conference room.
|
||||
* Used in booking page for room info popups.
|
||||
*
|
||||
* Author: Cliff Hill
|
||||
* Last updated: 2025-09-05
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
// Utility/helper imports
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// External imports
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
// MUI imports
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -28,6 +24,7 @@ import {
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
|
||||
// Type-only imports
|
||||
import type { RoomDetailsModalProps } from "../interfaces";
|
||||
|
||||
const RoomDetailsModal: React.FC<RoomDetailsModalProps> = ({
|
||||
@@ -35,6 +32,16 @@ const RoomDetailsModal: React.FC<RoomDetailsModalProps> = ({
|
||||
onClose,
|
||||
room,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
logger.info("[RoomDetailsModal] Mounted");
|
||||
return () => {
|
||||
logger.info("[RoomDetailsModal] Unmounted");
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
logger.debug("[RoomDetailsModal] room updated", room);
|
||||
}, [room]);
|
||||
if (!room) return null;
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
/**
|
||||
* RoomList component
|
||||
* Displays a list of available conference rooms, highlighting the selected room.
|
||||
* Used in the landing page for room selection.
|
||||
* @returns {JSX.Element} Room list UI
|
||||
*/
|
||||
// Utility/helper imports
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
import React from "react";
|
||||
// External imports
|
||||
import React, { useEffect } from "react";
|
||||
import { FC } from "react";
|
||||
|
||||
// MUI imports
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -17,10 +16,12 @@ import {
|
||||
ListItemButton,
|
||||
} from "@mui/material";
|
||||
import MeetingRoomIcon from "@mui/icons-material/MeetingRoom";
|
||||
// import { COLORS } from "../constants";
|
||||
import { getRoomListItemStyles, getRoomSecondaryText } from "../helpers/room";
|
||||
import type { RoomListProps } from "../interfaces";
|
||||
|
||||
// Internal helper imports
|
||||
import { getRoomListItemStyles, getRoomSecondaryText } from "../helpers/room";
|
||||
|
||||
// Type-only imports
|
||||
import type { RoomListProps } from "../interfaces";
|
||||
/**
|
||||
* RoomList component
|
||||
* Displays a list of available conference rooms, highlighting the selected room.
|
||||
@@ -34,6 +35,16 @@ const RoomList: FC<RoomListProps> = ({
|
||||
selectedRoomId,
|
||||
onSelectRoom,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
logger.info("[RoomList] Mounted");
|
||||
return () => {
|
||||
logger.info("[RoomList] Unmounted");
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
logger.debug("[RoomList] rooms updated", rooms);
|
||||
}, [rooms]);
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
@@ -102,7 +113,20 @@ const RoomList: FC<RoomListProps> = ({
|
||||
sx={getRoomListItemStyles(room.id, selectedRoomId)}
|
||||
>
|
||||
<ListItemText
|
||||
primary={room.name}
|
||||
primary={
|
||||
<span
|
||||
style={{
|
||||
color: `var(--room-color-${
|
||||
(Number(room.id) % 20) + 1
|
||||
})`,
|
||||
fontWeight: room.id === selectedRoomId ? 700 : 500,
|
||||
fontSize: room.id === selectedRoomId ? "1.08em" : "1em",
|
||||
letterSpacing: 0.5,
|
||||
}}
|
||||
>
|
||||
{room.name}
|
||||
</span>
|
||||
}
|
||||
secondary={getRoomSecondaryText(room)}
|
||||
/>
|
||||
</ListItemButton>
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
/**
|
||||
* Utility to get a CSS variable value from :root, with fallback.
|
||||
* constants.ts
|
||||
* Centralized constants for environment variables, colors, and time strings.
|
||||
* @module
|
||||
*/
|
||||
|
||||
// ...no imports in this file...
|
||||
|
||||
/**
|
||||
* ENV
|
||||
* Centralized environment variables for frontend configuration.
|
||||
* All environment variables used in the system should be defined here and referenced throughout the frontend.
|
||||
* @constant
|
||||
* @type {Object}
|
||||
* @property {string} BACKEND_PROTOCOL - Backend protocol (e.g., 'http')
|
||||
* @property {string} BACKEND_HOST - Backend host (e.g., 'localhost')
|
||||
* @property {string} BACKEND_PORT - Backend port (e.g., '8000')
|
||||
* @property {string} FRONTEND_LOG_LEVEL - Log level for frontend logging
|
||||
* @property {string} NODE_ENV - Node environment (e.g., 'development', 'production')
|
||||
* @property {number} DEFAULT_BOOKING_INTERVAL_MINUTES - Default booking interval in minutes
|
||||
* @property {string} BUSINESS_START - Business start time (e.g., '08:00')
|
||||
* @property {string} BUSINESS_END - Business end time (e.g., '18:00')
|
||||
*/
|
||||
export const ENV = {
|
||||
BACKEND_PROTOCOL: process.env.BACKEND_PROTOCOL || "http",
|
||||
BACKEND_HOST: process.env.BACKEND_HOST || "localhost",
|
||||
BACKEND_PORT: process.env.BACKEND_PORT || "8000",
|
||||
FRONTEND_LOG_LEVEL: process.env.FRONTEND_LOG_LEVEL || "info",
|
||||
NODE_ENV: process.env.NODE_ENV || "development",
|
||||
DEFAULT_BOOKING_INTERVAL_MINUTES:
|
||||
Number(process.env.DEFAULT_BOOKING_INTERVAL_MINUTES) || 30,
|
||||
BUSINESS_START: process.env.BUSINESS_START || "08:00",
|
||||
BUSINESS_END: process.env.BUSINESS_END || "18:00",
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a CSS variable value from :root, with fallback if not found.
|
||||
* @function getCssVar
|
||||
* @param {string} name - The CSS variable name (e.g., '--color-primary').
|
||||
* @param {string} fallback - The fallback value to use if the variable is not set.
|
||||
* @returns {string} The CSS variable value, or the fallback if not found.
|
||||
*/
|
||||
function getCssVar(name: string, fallback: string): string {
|
||||
if (typeof window === "undefined" || !document.documentElement)
|
||||
@@ -11,9 +50,23 @@ function getCssVar(name: string, fallback: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest color values from CSS variables, with fallback to defaults.
|
||||
* Gets the latest color values from CSS variables, with fallback to defaults.
|
||||
* @function getColors
|
||||
* @returns {Object} An object containing color values for the theme.
|
||||
* @property {string} primary - Primary color
|
||||
* @property {string} secondary - Secondary color
|
||||
* @property {string} accent - Accent color
|
||||
* @property {string} error - Error color
|
||||
* @property {string} warning - Warning color
|
||||
* @property {string} info - Info color
|
||||
* @property {string} success - Success color
|
||||
* @property {string} background - Background color
|
||||
* @property {string} textPrimary - Primary text color
|
||||
* @property {string} textSecondary - Secondary text color
|
||||
* @property {string} white - White color
|
||||
* @property {string} disabled - Disabled color
|
||||
*/
|
||||
export function getColors() {
|
||||
export function getColors(): Record<string, string> {
|
||||
return {
|
||||
primary: getCssVar("--color-primary", "#1976d2"),
|
||||
secondary: getCssVar("--color-secondary", "#388e3c"),
|
||||
@@ -29,41 +82,23 @@ export function getColors() {
|
||||
disabled: getCssVar("--color-disabled", "#bdbdbd"),
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Centralized constants for environment variables, colors, and time strings.
|
||||
* @file constants.ts
|
||||
*/
|
||||
|
||||
/**
|
||||
* Environment variables for frontend configuration.
|
||||
* Values are loaded from process.env with sensible defaults.
|
||||
* @type {{
|
||||
* BACKEND_PROTOCOL: string,
|
||||
* BACKEND_HOST: string,
|
||||
* BACKEND_PORT: string,
|
||||
* FRONTEND_LOG_LEVEL: string,
|
||||
* NODE_ENV: string,
|
||||
* DEFAULT_BOOKING_INTERVAL_MINUTES: number,
|
||||
* BUSINESS_START: string,
|
||||
* BUSINESS_END: string
|
||||
* }}
|
||||
*/
|
||||
export const ENV = {
|
||||
BACKEND_PROTOCOL: process.env.BACKEND_PROTOCOL || "http",
|
||||
BACKEND_HOST: process.env.BACKEND_HOST || "localhost",
|
||||
BACKEND_PORT: process.env.BACKEND_PORT || "8000",
|
||||
FRONTEND_LOG_LEVEL: process.env.FRONTEND_LOG_LEVEL || "info",
|
||||
NODE_ENV: process.env.NODE_ENV || "development",
|
||||
DEFAULT_BOOKING_INTERVAL_MINUTES: parseInt(
|
||||
process.env.DEFAULT_BOOKING_INTERVAL_MINUTES || "30",
|
||||
10
|
||||
),
|
||||
BUSINESS_START: process.env.BUSINESS_START || "08:00",
|
||||
BUSINESS_END: process.env.BUSINESS_END || "18:00",
|
||||
};
|
||||
|
||||
/**
|
||||
* Centralized color constants for MUI theme and JS usage.
|
||||
* @constant
|
||||
* @type {Object}
|
||||
* @property {string} primary - Primary color
|
||||
* @property {string} secondary - Secondary color
|
||||
* @property {string} accent - Accent color
|
||||
* @property {string} error - Error color
|
||||
* @property {string} warning - Warning color
|
||||
* @property {string} info - Info color
|
||||
* @property {string} success - Success color
|
||||
* @property {string} background - Background color
|
||||
* @property {string} textPrimary - Primary text color
|
||||
* @property {string} textSecondary - Secondary text color
|
||||
* @property {string} white - White color
|
||||
* @property {string} disabled - Disabled color
|
||||
*/
|
||||
export const COLORS = {
|
||||
primary: "#1976d2",
|
||||
|
||||
185
frontend/src/context/BookingContext.tsx
Normal file
185
frontend/src/context/BookingContext.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* BookingContext
|
||||
* Provides booking data and fetchMonth function for conference room bookings.
|
||||
* @context
|
||||
*/
|
||||
// External imports
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
|
||||
// API imports
|
||||
import { getMonthBookings } from "../apis/bookings";
|
||||
import { connectRoomsAvailabilityStream } from "../apis/sse";
|
||||
|
||||
// Type-only imports
|
||||
import type { Booking } from "../interfaces";
|
||||
|
||||
interface BookingContextType {
|
||||
bookings: Booking[];
|
||||
fetchMonth: (month: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const BookingContext = createContext<BookingContextType | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* BookingProvider
|
||||
* Provides booking context to child components.
|
||||
* @param {ReactNode} children - Child components
|
||||
* @returns {JSX.Element} Booking context provider
|
||||
*/
|
||||
export const BookingProvider = ({ children }: { children: ReactNode }) => {
|
||||
// Fetch bookings for a given month and merge into state
|
||||
const fetchMonth = async (month: string) => {
|
||||
try {
|
||||
const newBookings = await getMonthBookings(month);
|
||||
setBookingsByMonth((prev) => ({ ...prev, [month]: newBookings }));
|
||||
} catch (err) {
|
||||
// Optionally handle error
|
||||
}
|
||||
};
|
||||
// Store bookings by month (YYYY-MM => Booking[])
|
||||
const [bookingsByMonth, setBookingsByMonth] = useState<{
|
||||
[month: string]: Booking[];
|
||||
}>({});
|
||||
// Flat array for UI filtering
|
||||
const bookings = Object.values(bookingsByMonth).flat();
|
||||
|
||||
// Initial load: fetch all bookings for all rooms for today
|
||||
useEffect(() => {
|
||||
// On mount, fetch current month
|
||||
const now = new Date();
|
||||
const yyyy = now.getFullYear();
|
||||
const mm = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const monthStr = `${yyyy}-${mm}`;
|
||||
fetchMonth(monthStr);
|
||||
}, []);
|
||||
|
||||
// SSE subscription for live updates
|
||||
useEffect(() => {
|
||||
// SSE connecting
|
||||
const es = connectRoomsAvailabilityStream({
|
||||
onMessage: (data) => {
|
||||
// SSE raw message
|
||||
// Handle array of bookings
|
||||
if (Array.isArray(data.bookings)) {
|
||||
const bookingsByMonthFromSSE: { [month: string]: Booking[] } = {};
|
||||
data.bookings.forEach((b: Booking) => {
|
||||
const d = new Date(b.start_time);
|
||||
const month = `${d.getFullYear()}-${String(
|
||||
d.getMonth() + 1
|
||||
).padStart(2, "0")}`;
|
||||
if (!bookingsByMonthFromSSE[month])
|
||||
bookingsByMonthFromSSE[month] = [];
|
||||
bookingsByMonthFromSSE[month].push(b);
|
||||
});
|
||||
// SSE received bookings
|
||||
setBookingsByMonth((prev) => {
|
||||
const updated = { ...prev };
|
||||
Object.entries(bookingsByMonthFromSSE).forEach(
|
||||
([month, sseBookings]) => {
|
||||
updated[month] = [...sseBookings];
|
||||
// Updated month
|
||||
}
|
||||
);
|
||||
return { ...updated };
|
||||
});
|
||||
}
|
||||
// Handle single booking event (created/updated/deleted)
|
||||
// If payload is a single booking event, not a full bookings list
|
||||
else if (
|
||||
typeof data === "object" &&
|
||||
"action" in data &&
|
||||
"booking_id" in data &&
|
||||
"start_time" in data &&
|
||||
"end_time" in data &&
|
||||
"room_id" in data
|
||||
) {
|
||||
const singleData = data as unknown as {
|
||||
action: "created" | "updated" | "deleted";
|
||||
booking_id: string | number;
|
||||
room_id: string | number;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
title?: string;
|
||||
invitees?: string[];
|
||||
};
|
||||
const booking: Booking = {
|
||||
id: String(singleData.booking_id),
|
||||
room_id:
|
||||
typeof singleData.room_id === "number" ||
|
||||
typeof singleData.room_id === "string"
|
||||
? singleData.room_id
|
||||
: String(singleData.room_id),
|
||||
start_time: String(singleData.start_time),
|
||||
end_time: String(singleData.end_time),
|
||||
title:
|
||||
typeof singleData.title === "string"
|
||||
? singleData.title
|
||||
: undefined,
|
||||
invitees: Array.isArray(singleData.invitees)
|
||||
? singleData.invitees.map(String)
|
||||
: [], // Always set to array
|
||||
// ...existing code...
|
||||
};
|
||||
const d = new Date(booking.start_time);
|
||||
const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(
|
||||
2,
|
||||
"0"
|
||||
)}`;
|
||||
setBookingsByMonth((prev) => {
|
||||
const updated = { ...prev };
|
||||
let monthBookings = updated[month] ? [...updated[month]] : [];
|
||||
if (
|
||||
singleData.action === "created" ||
|
||||
singleData.action === "updated"
|
||||
) {
|
||||
// Remove any existing booking with same id, then add new/updated booking
|
||||
monthBookings = monthBookings.filter(
|
||||
(b) => String(b.id) !== String(booking.id)
|
||||
);
|
||||
monthBookings.push(booking);
|
||||
// Created/updated booking for month
|
||||
} else if (singleData.action === "deleted") {
|
||||
// Remove booking with matching id
|
||||
monthBookings = monthBookings.filter(
|
||||
(b) => String(b.id) !== String(booking.id)
|
||||
);
|
||||
// Deleted booking for month
|
||||
}
|
||||
updated[month] = monthBookings;
|
||||
return { ...updated };
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
// Optionally handle SSE errors
|
||||
// SSE error
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
// SSE disconnecting
|
||||
es.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BookingContext.Provider value={{ bookings, fetchMonth }}>
|
||||
{children}
|
||||
</BookingContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function useBookings() {
|
||||
const context = useContext(BookingContext);
|
||||
if (!context) {
|
||||
throw new Error("useBookings must be used within a BookingProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// Hook to get invitees for a booking using context
|
||||
65
frontend/src/context/RoomContext.tsx
Normal file
65
frontend/src/context/RoomContext.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* RoomContext
|
||||
* Provides room data and loading/error state for conference rooms.
|
||||
* @context
|
||||
*/
|
||||
// External imports
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
|
||||
// API imports
|
||||
import { getRooms } from "../apis/rooms";
|
||||
|
||||
// Type-only imports
|
||||
import type { ConferenceRoom } from "../interfaces";
|
||||
|
||||
interface RoomContextType {
|
||||
rooms: ConferenceRoom[];
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
const RoomContext = createContext<RoomContextType | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* RoomProvider
|
||||
* Provides room context to child components.
|
||||
* @param {ReactNode} children - Child components
|
||||
* @returns {JSX.Element} Room context provider
|
||||
*/
|
||||
export const RoomProvider: React.FC<{ children: ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [rooms, setRooms] = useState<ConferenceRoom[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getRooms()
|
||||
.then((data) => {
|
||||
setRooms(data || []);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<RoomContext.Provider value={{ rooms, loading, error }}>
|
||||
{children}
|
||||
</RoomContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useRooms = () => {
|
||||
const context = useContext(RoomContext);
|
||||
if (!context) throw new Error("useRooms must be used within a RoomProvider");
|
||||
return context;
|
||||
};
|
||||
100
frontend/src/context/UserContext.tsx
Normal file
100
frontend/src/context/UserContext.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* UserContext
|
||||
* Provides user data and loading/error state for users.
|
||||
* @context
|
||||
*/
|
||||
// External imports
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
|
||||
// API imports
|
||||
import { getUsers } from "../apis/users";
|
||||
|
||||
// Type-only imports
|
||||
import type { User } from "../interfaces";
|
||||
|
||||
// Context imports
|
||||
import { useBookings } from "./BookingContext";
|
||||
|
||||
/**
|
||||
* useAvailableUsers
|
||||
* Hook to get available users for a time slot using context.
|
||||
* @param {Object} params - Parameters for filtering users
|
||||
* @param {string} params.start_time - Start time
|
||||
* @param {string} params.end_time - End time
|
||||
* @param {string|number} [params.exclude_booking_id] - Booking ID to exclude
|
||||
* @returns {User[]} Array of available users
|
||||
*/
|
||||
export function useAvailableUsers(params: {
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
exclude_booking_id?: string | number;
|
||||
}): User[] {
|
||||
const { users } = useUsers();
|
||||
const { bookings } = useBookings();
|
||||
const start = new Date(params.start_time).getTime();
|
||||
const end = new Date(params.end_time).getTime();
|
||||
const overlappingBookings = bookings.filter((b) => {
|
||||
if (params.exclude_booking_id && b.id === params.exclude_booking_id)
|
||||
return false;
|
||||
const bStart = new Date(b.start_time).getTime();
|
||||
const bEnd = new Date(b.end_time).getTime();
|
||||
return bStart < end && bEnd > start;
|
||||
});
|
||||
const busyInvitees = new Set<string>();
|
||||
overlappingBookings.forEach((b) => {
|
||||
(b.invitees || []).forEach((email) => busyInvitees.add(email));
|
||||
});
|
||||
return users.filter((u) => u.email && !busyInvitees.has(u.email));
|
||||
}
|
||||
|
||||
interface UserContextType {
|
||||
users: User[];
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
const UserContext = createContext<UserContextType | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* UserProvider
|
||||
* Provides user context to child components.
|
||||
* @param {ReactNode} children - Child components
|
||||
* @returns {JSX.Element} User context provider
|
||||
*/
|
||||
export const UserProvider: React.FC<{ children: ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getUsers()
|
||||
.then((data) => {
|
||||
setUsers(data || []);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<UserContext.Provider value={{ users, loading, error }}>
|
||||
{children}
|
||||
</UserContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useUsers = () => {
|
||||
const context = useContext(UserContext);
|
||||
if (!context) throw new Error("useUsers must be used within a UserProvider");
|
||||
return context;
|
||||
};
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
// Minimal Booking type for utility functions
|
||||
|
||||
// Type-only imports
|
||||
import type { Booking } from "../interfaces";
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,15 +2,14 @@
|
||||
* bookingList.ts
|
||||
* Utility functions for BookingList component.
|
||||
* Provides helpers for sorting bookings and formatting invitee names.
|
||||
*
|
||||
* Author: Cliff Hill
|
||||
* Last updated: 2025-09-05
|
||||
* @module
|
||||
*/
|
||||
|
||||
/**
|
||||
* Represents a booking object with a start_time property.
|
||||
*/
|
||||
|
||||
// Type-only imports
|
||||
import type { BookingType } from "../types";
|
||||
import type { Invitee } from "../interfaces";
|
||||
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
* calendar.ts
|
||||
* Utility functions for CalendarView and booking system frontend.
|
||||
* Provides helpers for event display and room color assignment.
|
||||
*
|
||||
* Author: Cliff Hill
|
||||
* Last updated: 2025-09-05
|
||||
* @module
|
||||
*/
|
||||
|
||||
// ...no imports in this file...
|
||||
/**
|
||||
* Returns display text for a calendar event, including start time and title.
|
||||
* If room is provided, it can be appended or used for display logic.
|
||||
|
||||
6
frontend/src/helpers/getRoomBookingsForDate.ts
Normal file
6
frontend/src/helpers/getRoomBookingsForDate.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* getRoomBookingsForDate.ts
|
||||
* Utility for fetching bookings for a specific room and date.
|
||||
* (Intentionally left empty for future implementation.)
|
||||
* @module
|
||||
*/
|
||||
@@ -2,11 +2,10 @@
|
||||
* room.ts
|
||||
* Utility functions for RoomList component.
|
||||
* Provides helpers for rendering room details and styles.
|
||||
*
|
||||
* Author: Cliff Hill
|
||||
* Last updated: 2025-09-05
|
||||
* @module
|
||||
*/
|
||||
|
||||
// Type-only imports
|
||||
import { ConferenceRoom } from "../interfaces";
|
||||
import type { RoomListItemStyles } from "../interfaces";
|
||||
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
* validation.ts
|
||||
* Independent field validation helpers for BookingForm and booking system frontend.
|
||||
* Provides functions for validating room, time, and invitee fields.
|
||||
*
|
||||
* Author: Cliff Hill
|
||||
* Last updated: 2025-09-05
|
||||
* @module
|
||||
*/
|
||||
|
||||
// Type-only imports
|
||||
import type { Booking } from "../interfaces";
|
||||
|
||||
/**
|
||||
@@ -75,12 +74,6 @@ export function validateInvitees(
|
||||
* @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}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates room availability for a given time range against existing bookings.
|
||||
|
||||
@@ -14,13 +14,14 @@ import { ThemeProvider } from "@mui/material/styles";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import App from "./App";
|
||||
import { logger } from "./utils/logger";
|
||||
import reportWebVitals from "./reportWebVitals";
|
||||
import theme from "./theme";
|
||||
import "./index.css";
|
||||
|
||||
// Global error handler to catch unhandled errors and prevent reloads
|
||||
window.onerror = function (message, source, lineno, colno, error) {
|
||||
console.error("Global error handler:", {
|
||||
logger.error("Global error handler:", {
|
||||
message,
|
||||
source,
|
||||
lineno,
|
||||
@@ -32,7 +33,7 @@ window.onerror = function (message, source, lineno, colno, error) {
|
||||
};
|
||||
|
||||
window.onunhandledrejection = function (event) {
|
||||
console.error("Global unhandledrejection:", event.reason);
|
||||
logger.error("Global unhandledrejection:", event.reason);
|
||||
// Prevent default browser reload on unhandled promise rejection
|
||||
return true;
|
||||
};
|
||||
@@ -44,7 +45,7 @@ const backendPort = process.env.BACKEND_PORT || "8000";
|
||||
axios.defaults.baseURL = `${backendProtocol}://${backendHost}:${backendPort}`;
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById("root") as HTMLElement
|
||||
document.getElementById("root") as HTMLDivElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
/**
|
||||
* interfaces.ts
|
||||
* Centralized TypeScript interfaces for frontend application.
|
||||
* Author: Cliff Hill
|
||||
* Last updated: 2025-09-09
|
||||
* @module
|
||||
*/
|
||||
|
||||
// ...no imports in this file...
|
||||
/**
|
||||
* Information about a calendar slot selection, used for booking forms and calendar interactions.
|
||||
* @property start - Start time of the slot
|
||||
@@ -86,7 +85,7 @@ export interface RoomListItemStyles {
|
||||
*/
|
||||
export interface RoomsAvailabilitySSEPayload {
|
||||
bookings: Booking[];
|
||||
[key: string]: unknown;
|
||||
// Add more specific properties if needed, otherwise remove index signature for strict typing
|
||||
}
|
||||
export interface User {
|
||||
id: string | number;
|
||||
|
||||
@@ -8,8 +8,22 @@
|
||||
* @component
|
||||
* @returns {JSX.Element} Booking page UI
|
||||
*/
|
||||
/**
|
||||
* 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
|
||||
* @component
|
||||
* @returns {JSX.Element} Booking page UI
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
// External imports
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
// MUI imports
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -18,17 +32,25 @@ import {
|
||||
MenuItem,
|
||||
Select,
|
||||
} from "@mui/material";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
// Styles
|
||||
import "../styles/BookingPage.css";
|
||||
|
||||
// Internal component imports
|
||||
import BookingForm from "../components/BookingForm";
|
||||
import CalendarView from "../components/CalendarView";
|
||||
import RoomDetailsModal from "../components/RoomDetailsModal";
|
||||
import { connectRoomsAvailabilityStream } from "../apis/sse";
|
||||
import { getRoomBookings } from "../apis/bookings";
|
||||
import { getRooms } from "../apis/rooms";
|
||||
import { getUsers } from "../apis/users";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// Context imports
|
||||
import { useRooms } from "../context/RoomContext";
|
||||
import { useBookings } from "../context/BookingContext";
|
||||
|
||||
// Utility/helper imports
|
||||
import { logger } from "../utils/logger";
|
||||
import { getRoomClass } from "../helpers/calendar";
|
||||
import { getInviteeName } from "../helpers/bookingList";
|
||||
|
||||
// Type-only imports
|
||||
import type { Booking, ConferenceRoom } from "../interfaces";
|
||||
import type { BookingEvent } from "../types";
|
||||
|
||||
@@ -40,214 +62,92 @@ import type { BookingEvent } from "../types";
|
||||
* @returns {JSX.Element} Booking page UI
|
||||
*/
|
||||
const BookingPage: React.FC = () => {
|
||||
/** Get rooms from context */
|
||||
const { rooms } = useRooms();
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [roomModalOpen, setRoomModalOpen] = useState(false);
|
||||
// Log mount/unmount for analytics
|
||||
/**
|
||||
* Track mount/unmount for analytics.
|
||||
*/
|
||||
/** Track mount/unmount for analytics */
|
||||
useEffect(() => {
|
||||
logger.info("[BookingPage] Mounted");
|
||||
return () => logger.info("[BookingPage] Unmounted");
|
||||
}, []);
|
||||
// Fetch all rooms
|
||||
/**
|
||||
* Fetch all rooms from backend.
|
||||
*/
|
||||
const {
|
||||
data: rooms = [],
|
||||
isLoading: roomsLoading,
|
||||
error: roomsError,
|
||||
}: {
|
||||
data?: ConferenceRoom[];
|
||||
isLoading: boolean;
|
||||
error?: unknown;
|
||||
} = useQuery({
|
||||
queryKey: ["rooms"],
|
||||
queryFn: () => getRooms(),
|
||||
});
|
||||
|
||||
// Real-time bookings state (by room)
|
||||
/**
|
||||
* Real-time bookings state, grouped by room id.
|
||||
*/
|
||||
const [bookingsByRoom, setBookingsByRoom] = useState<
|
||||
Record<number, Booking[]>
|
||||
>({});
|
||||
|
||||
// Fetch all users (invitees)
|
||||
/**
|
||||
* Fetch all users (invitees).
|
||||
*/
|
||||
const {
|
||||
data: allInvitees = [],
|
||||
isLoading: inviteesLoading,
|
||||
error: inviteesError,
|
||||
}: {
|
||||
data?: import("../interfaces").User[];
|
||||
isLoading: boolean;
|
||||
error?: unknown;
|
||||
} = useQuery({
|
||||
queryKey: ["users"],
|
||||
queryFn: () => getUsers(),
|
||||
});
|
||||
const [bookingsLoading, setBookingsLoading] = useState(true);
|
||||
const [bookingsError, setBookingsError] = useState<unknown>(null);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const [usingPollingFallback, setUsingPollingFallback] = useState(false);
|
||||
|
||||
// Fetch initial bookings and subscribe to SSE
|
||||
/**
|
||||
* Fetch initial bookings and subscribe to SSE or polling fallback.
|
||||
*/
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let pollInterval: NodeJS.Timeout | null = null;
|
||||
/**
|
||||
* Fetch bookings for all rooms and update state.
|
||||
*/
|
||||
async function fetchBookingsAndSet() {
|
||||
setBookingsLoading(true);
|
||||
setBookingsError(null);
|
||||
try {
|
||||
const results: Record<number, Booking[]> = {};
|
||||
await Promise.all(
|
||||
rooms.map(async (room: ConferenceRoom) => {
|
||||
try {
|
||||
const bookings: Booking[] = 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");
|
||||
// Use SSE if available, otherwise fallback to 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, Booking[]> = {};
|
||||
data.bookings.forEach((b: Booking) => {
|
||||
if (!grouped[b.room_id as number])
|
||||
grouped[b.room_id as number] = [];
|
||||
grouped[b.room_id as number].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);
|
||||
logger.info("[BookingPage] Unmounted");
|
||||
};
|
||||
}, [rooms, formOpen, roomModalOpen]);
|
||||
}, []);
|
||||
/** Get rooms from context (should be provided via context or props) */
|
||||
|
||||
// Removed unused roomColors variable
|
||||
/** Unified bookings state from context */
|
||||
const { bookings } = useBookings();
|
||||
|
||||
// Combine all bookings into calendar events, sorted chronologically (room id tiebreaker), with color and formatted title
|
||||
// Expose allBookings for event lookup
|
||||
/** Get users from context */
|
||||
|
||||
/** Fetch initial bookings and subscribe to SSE or polling fallback */
|
||||
|
||||
// ...removed unused roomColors variable
|
||||
|
||||
/**
|
||||
* Combine all bookings into calendar events, sorted chronologically (room id tiebreaker), with color and formatted title.
|
||||
* Expose allBookings for event lookup.
|
||||
*/
|
||||
/**
|
||||
* Flatten all bookings from all rooms into a single array.
|
||||
*/
|
||||
const allBookings: Booking[] = React.useMemo(() => {
|
||||
if (!bookingsByRoom || rooms.length === 0) return [];
|
||||
return Object.entries(bookingsByRoom).flatMap(
|
||||
([roomId, bookings]) => bookings as Booking[]
|
||||
);
|
||||
}, [bookingsByRoom, rooms]);
|
||||
if (!bookings) return [];
|
||||
return bookings;
|
||||
}, [bookings]);
|
||||
|
||||
/**
|
||||
* Map all bookings to calendar event objects for display.
|
||||
*/
|
||||
const events = React.useMemo(() => {
|
||||
if (!bookingsByRoom || rooms.length === 0) return [];
|
||||
const mappedEvents = Object.entries(bookingsByRoom).flatMap(
|
||||
([roomId, bookings]) =>
|
||||
(bookings as Booking[]).map((booking) => {
|
||||
logger.info("[BookingPage] Event mapping diagnostic", {
|
||||
bookingId: booking.id,
|
||||
bookingRoomId: booking.room_id,
|
||||
roomIdFromGroup: roomId,
|
||||
});
|
||||
// Normalize roomId for lookup
|
||||
const normalizedRoomId = String(roomId);
|
||||
const room = rooms.find(
|
||||
(r: ConferenceRoom) => 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 as string;
|
||||
} else if (typeof booking.room_id === "number") {
|
||||
roomName = String(booking.room_id);
|
||||
} else {
|
||||
roomName = "Room";
|
||||
}
|
||||
}
|
||||
const inviteeNames = booking.invitees ?? [];
|
||||
const start = new Date(booking.start_time);
|
||||
const end = new Date(booking.end_time);
|
||||
return {
|
||||
id: booking.id,
|
||||
title: booking.title ?? "",
|
||||
start: start ?? new Date(booking.start_time),
|
||||
end: end ?? new Date(booking.end_time),
|
||||
room_id: booking.room_id,
|
||||
room_name: roomName,
|
||||
invitees: inviteeNames,
|
||||
originalBooking: booking,
|
||||
};
|
||||
})
|
||||
);
|
||||
if (!bookings || rooms.length === 0) return [];
|
||||
const mappedEvents = bookings.map((booking: Booking) => {
|
||||
const room = rooms.find(
|
||||
(r: ConferenceRoom) => String(r.id) === String(booking.room_id)
|
||||
);
|
||||
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 as string;
|
||||
} else if (typeof booking.room_id === "number") {
|
||||
roomName = String(booking.room_id);
|
||||
} else {
|
||||
roomName = "Room";
|
||||
}
|
||||
}
|
||||
// Map invitees to display names for tooltip
|
||||
const inviteeNames = Array.isArray(booking.invitees)
|
||||
? booking.invitees.map(getInviteeName).filter(Boolean)
|
||||
: [];
|
||||
const start = new Date(booking.start_time);
|
||||
const end = new Date(booking.end_time);
|
||||
const roomClass = getRoomClass(
|
||||
typeof booking.room_id === "number"
|
||||
? booking.room_id
|
||||
: parseInt(booking.room_id as string, 10)
|
||||
);
|
||||
return {
|
||||
id: booking.id,
|
||||
title: booking.title ?? "",
|
||||
start: start ?? new Date(booking.start_time),
|
||||
end: end ?? new Date(booking.end_time),
|
||||
room_id: booking.room_id,
|
||||
room_name: roomName,
|
||||
invitees: inviteeNames,
|
||||
originalBooking: booking,
|
||||
classNames: [roomClass, "calendar-event"],
|
||||
};
|
||||
});
|
||||
return mappedEvents;
|
||||
}, [bookingsByRoom, rooms]);
|
||||
}, [bookings, rooms]);
|
||||
// Removed duplicate and broken code block
|
||||
|
||||
interface SlotInfo {
|
||||
@@ -257,28 +157,27 @@ const BookingPage: React.FC = () => {
|
||||
}
|
||||
const [formSlot, setFormSlot] = useState<SlotInfo | null>(null);
|
||||
const [editBooking, setEditBooking] = useState<Booking | null>(null);
|
||||
// Default to first room, always require a room to be selected
|
||||
/** Default to first room, always require a room to be selected */
|
||||
const [selectedRoomId, setSelectedRoomId] = useState<string | number>("");
|
||||
|
||||
// Set default room when rooms load
|
||||
/** Set default room when rooms load */
|
||||
useEffect(() => {
|
||||
logger.debug("[BookingPage] Rooms updated", rooms);
|
||||
if (rooms.length > 0 && !selectedRoomId) {
|
||||
setSelectedRoomId(rooms[0].id);
|
||||
}
|
||||
}, [rooms, selectedRoomId]);
|
||||
|
||||
// Handle slot selection from calendar (month/week/day)
|
||||
/**
|
||||
* Handle slot selection from calendar (month/week/day)
|
||||
* @param slotInfo - Slot information from calendar
|
||||
*/
|
||||
const handleSelectSlot = (
|
||||
slotInfo: SlotInfo & { viewType?: string; start?: Date | string }
|
||||
) => {
|
||||
logger.debug(
|
||||
"[BookingPage] Slot selected",
|
||||
slotInfo,
|
||||
"selectedRoomId:",
|
||||
selectedRoomId
|
||||
);
|
||||
// Slot selected
|
||||
if (!selectedRoomId) {
|
||||
logger.warn("[BookingPage] No room selected, cannot open booking form.");
|
||||
// No room selected, cannot open booking form
|
||||
return;
|
||||
}
|
||||
// slotInfo: { start, end, allDay, viewType }
|
||||
@@ -286,10 +185,8 @@ const BookingPage: React.FC = () => {
|
||||
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();
|
||||
@@ -312,12 +209,6 @@ const BookingPage: React.FC = () => {
|
||||
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;
|
||||
@@ -327,7 +218,7 @@ const BookingPage: React.FC = () => {
|
||||
selectedDay.setHours(officeEnd - 1, 45, 0, 0);
|
||||
|
||||
// 2. Find all bookings for this room on this day
|
||||
const roomBookings = events.filter((e) => {
|
||||
const roomBookings = events.filter((e: BookingEvent) => {
|
||||
if (e.room_id !== selectedRoomId) return false;
|
||||
const eventStart =
|
||||
e.start &&
|
||||
@@ -342,7 +233,7 @@ const BookingPage: React.FC = () => {
|
||||
});
|
||||
// Build an array of [start, end] for each booking, skip if missing
|
||||
const bookingBlocks = roomBookings
|
||||
.map((b) => {
|
||||
.map((b: BookingEvent) => {
|
||||
// Only accept string | number | Date for start/end
|
||||
const validStart =
|
||||
typeof b.start === "string" ||
|
||||
@@ -357,7 +248,10 @@ const BookingPage: React.FC = () => {
|
||||
const e = new Date(b.end as string | number | Date);
|
||||
return [s.getTime(), e.getTime()];
|
||||
})
|
||||
.filter((block) => block !== undefined) as [number, number][];
|
||||
.filter(
|
||||
(block): block is [number, number] =>
|
||||
Array.isArray(block) && block.length === 2
|
||||
);
|
||||
|
||||
// 3. Search for first available 30-min slot in 15-min increments
|
||||
let found = false;
|
||||
@@ -366,9 +260,7 @@ const BookingPage: React.FC = () => {
|
||||
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(),
|
||||
});
|
||||
// Checking candidate slot: candidate
|
||||
const candidateStart = candidate.getTime();
|
||||
const candidateEnd = candidateStart + 30 * 60000;
|
||||
// Check for overlap with any booking
|
||||
@@ -393,23 +285,38 @@ const BookingPage: React.FC = () => {
|
||||
start = new Date(slotInfo.start);
|
||||
end = new Date(start.getTime() + 30 * 60000);
|
||||
}
|
||||
logger.info("[BookingPage] Slot selected", {
|
||||
start,
|
||||
end,
|
||||
room_id: selectedRoomId,
|
||||
});
|
||||
setFormSlot({ start, end, room_id: selectedRoomId });
|
||||
setEditBooking(null);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const handleSelectEvent = (event: BookingEvent) => {
|
||||
logger.info("[BookingPage] Event selected", event);
|
||||
const originalBooking = event.originalBooking;
|
||||
const eventIdStr = String(event.id);
|
||||
if (originalBooking && originalBooking.id) {
|
||||
setEditBooking(originalBooking);
|
||||
setEditBooking({
|
||||
...originalBooking,
|
||||
invitees: Array.isArray(originalBooking.invitees)
|
||||
? originalBooking.invitees
|
||||
: [],
|
||||
});
|
||||
} else {
|
||||
// Try to find backend booking by event.id using allBookings (compare as strings)
|
||||
const foundBooking = allBookings.find(
|
||||
(b: Booking) => String(b.id) === eventIdStr
|
||||
);
|
||||
if (foundBooking) {
|
||||
setEditBooking(foundBooking);
|
||||
setEditBooking({
|
||||
...foundBooking,
|
||||
invitees: Array.isArray(foundBooking.invitees)
|
||||
? foundBooking.invitees
|
||||
: [],
|
||||
});
|
||||
} else {
|
||||
setEditBooking({
|
||||
id: String(event.id),
|
||||
@@ -418,7 +325,7 @@ const BookingPage: React.FC = () => {
|
||||
start_time: event.start ? event.start.toString() : "",
|
||||
end_time: event.end ? event.end.toString() : "",
|
||||
title: event.title ?? "",
|
||||
invitees: event.invitees ?? [],
|
||||
invitees: Array.isArray(event.invitees) ? event.invitees : [],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -427,79 +334,29 @@ const BookingPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const refetchBookings = async () => {
|
||||
setBookingsLoading(true);
|
||||
setBookingsError(null);
|
||||
const results: Record<number, Booking[]> = {};
|
||||
try {
|
||||
await Promise.all(
|
||||
rooms.map(async (room: ConferenceRoom) => {
|
||||
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);
|
||||
}
|
||||
// Bookings are now managed by BookingContext; context handles refresh
|
||||
};
|
||||
|
||||
const handleFormClose = (refresh = false) => {
|
||||
logger.info("[BookingPage] Booking form closed", { refresh });
|
||||
const navigate = useNavigate();
|
||||
const handleFormClose = (refresh = false, booking?: Booking) => {
|
||||
logger.info("[BookingPage] Booking form closed", { refresh, booking });
|
||||
setFormOpen(false);
|
||||
setFormSlot(null);
|
||||
setEditBooking(null);
|
||||
if (refresh) {
|
||||
logger.info("[BookingPage] Refetching bookings after form close");
|
||||
refetchBookings();
|
||||
}
|
||||
if (booking) {
|
||||
navigate("/confirmation", { state: { booking } });
|
||||
}
|
||||
};
|
||||
|
||||
// (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>
|
||||
);
|
||||
}
|
||||
// ...removed old handleSelectEvent
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div className="bookingpage-content">
|
||||
<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>
|
||||
)}
|
||||
{/* ...existing code... */}
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel id="room-select-label">Room</InputLabel>
|
||||
@@ -509,17 +366,32 @@ const BookingPage: React.FC = () => {
|
||||
label="Room"
|
||||
onChange={(e) => setSelectedRoomId(e.target.value)}
|
||||
>
|
||||
{rooms.map((room: ConferenceRoom) => (
|
||||
<MenuItem key={room.id} value={room.id}>
|
||||
{room.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
{rooms.map((room: ConferenceRoom) => {
|
||||
const roomIdx = Number(room.id) % 20 || 0;
|
||||
const bgColor = `var(--room-color-${roomIdx + 1})`;
|
||||
return (
|
||||
<MenuItem
|
||||
key={room.id}
|
||||
value={room.id}
|
||||
sx={{
|
||||
backgroundColor: bgColor,
|
||||
color: "#fff",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{room.name}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Button
|
||||
variant="outlined"
|
||||
sx={{ ml: 2 }}
|
||||
onClick={() => setRoomModalOpen(true)}
|
||||
onClick={() => {
|
||||
logger.info("[BookingPage] Opening room modal");
|
||||
setRoomModalOpen(true);
|
||||
}}
|
||||
disabled={!selectedRoomId}
|
||||
>
|
||||
View Room & Equipment Details
|
||||
@@ -527,7 +399,10 @@ const BookingPage: React.FC = () => {
|
||||
</Box>
|
||||
<RoomDetailsModal
|
||||
open={roomModalOpen}
|
||||
onClose={() => setRoomModalOpen(false)}
|
||||
onClose={() => {
|
||||
logger.info("[BookingPage] Closing room modal");
|
||||
setRoomModalOpen(false);
|
||||
}}
|
||||
room={
|
||||
rooms.find((r: ConferenceRoom) => r.id === selectedRoomId) ?? rooms[0]
|
||||
}
|
||||
@@ -543,18 +418,25 @@ const BookingPage: React.FC = () => {
|
||||
open={formOpen}
|
||||
onClose={() => handleFormClose(true)}
|
||||
slotInfo={
|
||||
formSlot ?? {
|
||||
start: new Date(),
|
||||
end: new Date(),
|
||||
room_id: selectedRoomId,
|
||||
}
|
||||
formSlot
|
||||
? {
|
||||
...formSlot,
|
||||
start:
|
||||
typeof formSlot.start === "string"
|
||||
? formSlot.start
|
||||
: formSlot.start instanceof Date
|
||||
? formSlot.start.toISOString()
|
||||
: new Date().toISOString(),
|
||||
}
|
||||
: {
|
||||
start: new Date().toISOString(),
|
||||
room_id: selectedRoomId,
|
||||
}
|
||||
}
|
||||
rooms={rooms}
|
||||
editBooking={editBooking ?? undefined}
|
||||
allInvitees={allInvitees.map(
|
||||
(u: import("../interfaces").User) => u.email ?? u.name
|
||||
)}
|
||||
onBookingSuccess={() => handleFormClose(true)}
|
||||
onBookingSuccess={(booking?: Booking) =>
|
||||
handleFormClose(true, booking)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
/**
|
||||
* ConfirmationPage.tsx
|
||||
* ConfirmationPage component
|
||||
* Shows booking confirmation and allows editing or returning to booking form.
|
||||
* Expects booking data via location.state.
|
||||
*
|
||||
* Author: Cliff Hill
|
||||
* Last updated: 2025-09-05
|
||||
* @component
|
||||
* @returns {JSX.Element} Confirmation page UI
|
||||
*/
|
||||
|
||||
// External imports
|
||||
import React, { useState } from "react";
|
||||
import { Box } from "@mui/material";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
// MUI imports
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
// Third-party imports
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
// Internal component imports
|
||||
import BookingConfirmation from "../components/BookingConfirmation";
|
||||
import BookingForm from "../components/BookingForm";
|
||||
|
||||
// API imports
|
||||
import { connectRoomsAvailabilityStream } from "../apis/sse";
|
||||
import { getRooms } from "../apis/rooms";
|
||||
|
||||
// Context imports
|
||||
import { useBookings } from "../context/BookingContext";
|
||||
|
||||
// Utility/helper imports
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// Type-only imports
|
||||
import type { Booking } from "../interfaces";
|
||||
|
||||
/**
|
||||
@@ -36,19 +48,35 @@ const ConfirmationPage: React.FC = () => {
|
||||
/**
|
||||
* Booking state, updated via SSE for live status.
|
||||
*/
|
||||
const { bookings } = useBookings();
|
||||
const [booking, setBooking] = useState<Booking | null>(
|
||||
location.state?.booking || null
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
logger.info("[ConfirmationPage] Mounted");
|
||||
return () => {
|
||||
logger.info("[ConfirmationPage] Unmounted");
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
logger.debug("[ConfirmationPage] booking updated", booking);
|
||||
}, [booking]);
|
||||
/**
|
||||
* Subscribe to SSE for live booking status updates.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (!booking) return;
|
||||
let bookingId = location.state?.booking?.id || booking?.id;
|
||||
if (!bookingId) return;
|
||||
// Always get latest booking from context
|
||||
const latestBooking = bookings.find((b: Booking) => b.id === bookingId);
|
||||
if (latestBooking) setBooking(latestBooking);
|
||||
const es = connectRoomsAvailabilityStream({
|
||||
onMessage: (data) => {
|
||||
if (Array.isArray(data.bookings)) {
|
||||
const updated = data.bookings.find(
|
||||
(b: Booking) => b.id === booking?.id
|
||||
(b: Booking) => b.id === bookingId
|
||||
);
|
||||
if (updated) {
|
||||
setBooking((prev: Booking | null) =>
|
||||
@@ -64,7 +92,7 @@ const ConfirmationPage: React.FC = () => {
|
||||
return () => {
|
||||
es.close();
|
||||
};
|
||||
}, [booking, booking?.id]);
|
||||
}, [bookings, location.state, booking?.id]);
|
||||
/**
|
||||
* Editing state for toggling between confirmation and edit form.
|
||||
*/
|
||||
@@ -75,7 +103,7 @@ const ConfirmationPage: React.FC = () => {
|
||||
* Fetch all rooms for editing.
|
||||
*/
|
||||
const {
|
||||
data: rooms = [],
|
||||
// ...existing code...
|
||||
isLoading: roomsLoading,
|
||||
error: roomsError,
|
||||
} = useQuery({
|
||||
@@ -114,16 +142,13 @@ const ConfirmationPage: React.FC = () => {
|
||||
onClose={handleFormClose}
|
||||
slotInfo={{
|
||||
start: booking.start_time,
|
||||
end: booking.end_time,
|
||||
room_id: booking.room_id,
|
||||
}}
|
||||
rooms={rooms}
|
||||
editBooking={{
|
||||
...booking,
|
||||
start: booking.start_time,
|
||||
end: booking.end_time,
|
||||
}}
|
||||
allInvitees={booking.invitees || []}
|
||||
onBookingSuccess={() => {
|
||||
setEditing(false);
|
||||
// Optionally update booking state
|
||||
|
||||
@@ -1,51 +1,33 @@
|
||||
/**
|
||||
* LandingPage.tsx
|
||||
* LandingPage component
|
||||
* Main landing page for the conference room booking system frontend.
|
||||
* Displays available rooms, today's bookings, and navigation to booking form.
|
||||
* Handles data fetching, error states, and loading skeletons.
|
||||
*
|
||||
* Author: Cliff Hill
|
||||
* Last updated: 2025-09-05
|
||||
* @component
|
||||
* @returns {JSX.Element} Landing page UI
|
||||
*/
|
||||
|
||||
// External imports
|
||||
import React, { FC, useEffect, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Skeleton,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import EventNoteIcon from "@mui/icons-material/EventNote";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
// MUI imports
|
||||
import { Box, Button, Card, CardContent, Typography } from "@mui/material";
|
||||
import EventNoteIcon from "@mui/icons-material/EventNote";
|
||||
|
||||
// Styles
|
||||
import "../styles/LandingPage.css";
|
||||
|
||||
// Internal component imports
|
||||
import BookingList from "../components/BookingList";
|
||||
import RoomList from "../components/RoomList";
|
||||
import { connectRoomsAvailabilityStream } from "../apis/sse";
|
||||
import { getRooms } from "../apis/rooms";
|
||||
|
||||
// Context imports
|
||||
import { useRooms } from "../context/RoomContext";
|
||||
import { useBookings } from "../context/BookingContext";
|
||||
|
||||
// Utility/helper imports
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
import type { Booking } from "../interfaces";
|
||||
import type { ConferenceRoom } from "../interfaces";
|
||||
|
||||
/**
|
||||
* LandingPage component
|
||||
* Main landing page for the conference room booking system frontend.
|
||||
* Displays available rooms, today's bookings, and navigation to booking form.
|
||||
* Handles data fetching, error states, and loading skeletons.
|
||||
* @component
|
||||
* @returns {JSX.Element} Landing page UI
|
||||
*/
|
||||
/**
|
||||
* LandingPage component
|
||||
* Renders the main UI for conference room booking, including available rooms, today's bookings, and navigation to booking form.
|
||||
* Handles data fetching, error states, and loading skeletons.
|
||||
* @returns {JSX.Element} Landing page UI
|
||||
*/
|
||||
const LandingPage: FC = () => {
|
||||
// Log when LandingPage mounts/unmounts
|
||||
/**
|
||||
@@ -71,30 +53,8 @@ const LandingPage: FC = () => {
|
||||
}, []);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Fetch available rooms from backend
|
||||
/**
|
||||
* Fetch available rooms from backend.
|
||||
*/
|
||||
const {
|
||||
data: rooms = [],
|
||||
isLoading: roomsLoading,
|
||||
error: roomsError,
|
||||
refetch: refetchRooms,
|
||||
} = useQuery({
|
||||
queryKey: ["rooms"],
|
||||
queryFn: async () => {
|
||||
logger.info("[LandingPage] Fetching rooms");
|
||||
const rooms = await getRooms();
|
||||
logger.info("[LandingPage] Rooms response", rooms); // Log backend return value
|
||||
return rooms as ConferenceRoom[];
|
||||
},
|
||||
});
|
||||
// Log rooms result after fetch
|
||||
useEffect(() => {
|
||||
if (!roomsLoading && roomsError == null) {
|
||||
logger.info("[LandingPage] Rooms loaded", rooms);
|
||||
}
|
||||
}, [rooms, roomsLoading, roomsError]);
|
||||
// Get rooms from context
|
||||
const { rooms } = useRooms();
|
||||
|
||||
// Room selection state
|
||||
const [selectedRoomId, setSelectedRoomId] = useState<number | undefined>(
|
||||
@@ -106,108 +66,34 @@ const LandingPage: FC = () => {
|
||||
if (rooms.length > 0 && selectedRoomId === undefined) {
|
||||
setSelectedRoomId(rooms[0].id);
|
||||
}
|
||||
// Fetch bookings for today for the selected room if not already fetched
|
||||
// Bookings are now managed by BookingContext
|
||||
}, [rooms, selectedRoomId]);
|
||||
|
||||
// Real-time bookings state via SSE
|
||||
const [bookingsByRoom, setBookingsByRoom] = useState<
|
||||
Record<number, Booking[]>
|
||||
>({});
|
||||
useEffect(() => {
|
||||
const es = connectRoomsAvailabilityStream({
|
||||
onMessage: (data) => {
|
||||
if (Array.isArray(data.bookings)) {
|
||||
const grouped: Record<number, Booking[]> = {};
|
||||
data.bookings.forEach((b: Booking) => {
|
||||
if (typeof b.room_id === "undefined" || b.room_id === null) return;
|
||||
const roomId = Number(b.room_id);
|
||||
if (!grouped[roomId]) grouped[roomId] = [];
|
||||
grouped[roomId].push(b);
|
||||
});
|
||||
setBookingsByRoom(grouped);
|
||||
logger.debug("[LandingPage] SSE update received", grouped);
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
logger.error("[LandingPage] SSE connection error", err);
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
es.close();
|
||||
};
|
||||
}, []);
|
||||
// Unified bookings state from context
|
||||
const { bookings } = useBookings();
|
||||
// Select bookings for the selected room
|
||||
const bookings = selectedRoomId ? bookingsByRoom[selectedRoomId] || [] : [];
|
||||
// Only show bookings for today
|
||||
function isToday(dateStr: string): boolean {
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
return (
|
||||
d.getFullYear() === now.getFullYear() &&
|
||||
d.getMonth() === now.getMonth() &&
|
||||
d.getDate() === now.getDate()
|
||||
);
|
||||
}
|
||||
const filteredBookings = selectedRoomId
|
||||
? bookings.filter(
|
||||
(b: import("../interfaces").Booking) =>
|
||||
String(b.room_id) === String(selectedRoomId) &&
|
||||
b.start_time &&
|
||||
isToday(b.start_time)
|
||||
)
|
||||
: [];
|
||||
|
||||
// Display skeleton loaders during data fetching
|
||||
if (roomsLoading) {
|
||||
return (
|
||||
<div className="landing-root">
|
||||
<div className="landing-section">
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Skeleton
|
||||
variant="text"
|
||||
width="50%"
|
||||
data-testid="skeleton-loader"
|
||||
/>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
variant="rectangular"
|
||||
height={60}
|
||||
sx={{ mb: 1 }}
|
||||
data-testid="skeleton-loader"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</div>
|
||||
<div className="landing-section">
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Skeleton
|
||||
variant="text"
|
||||
width="50%"
|
||||
data-testid="skeleton-loader"
|
||||
/>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
variant="rectangular"
|
||||
height={60}
|
||||
sx={{ mb: 1 }}
|
||||
data-testid="skeleton-loader"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Display error message with retry option
|
||||
if (roomsError) {
|
||||
logger.error("[LandingPage] Rooms error", roomsError);
|
||||
return (
|
||||
<div
|
||||
className="landing-root"
|
||||
style={{ alignItems: "center", justifyContent: "center" }}
|
||||
>
|
||||
<div style={{ width: "100%", textAlign: "center" }}>
|
||||
<Typography color="error" data-testid="error-message">
|
||||
{roomsError?.message || "Failed to fetch data."}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
refetchRooms();
|
||||
}}
|
||||
sx={{ mt: 2 }}
|
||||
aria-label="Retry fetching data"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="landing-root">
|
||||
@@ -260,7 +146,7 @@ const LandingPage: FC = () => {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
fontWeight: 600,
|
||||
color: "var(--color-on-primary)",
|
||||
color: "var(--color-white)",
|
||||
}}
|
||||
>
|
||||
<EventNoteIcon
|
||||
@@ -285,7 +171,7 @@ const LandingPage: FC = () => {
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<BookingList bookings={bookings} />
|
||||
<BookingList bookings={filteredBookings} />
|
||||
</Box>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -18,19 +18,19 @@
|
||||
|
||||
/* Header styles */
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
background-color: var(--color-app-bg, #282c34);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
color: var(--color-app-text, white);
|
||||
}
|
||||
|
||||
/* Link styles */
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
color: var(--color-app-accent, #61dafb);
|
||||
}
|
||||
|
||||
/* Keyframes for logo spin animation */
|
||||
|
||||
@@ -13,14 +13,14 @@
|
||||
|
||||
/* Unavailable menu item styles */
|
||||
.booking-form-menu-item-unavailable {
|
||||
color: #b0b0b0;
|
||||
color: var(--color-form-disabled, #b0b0b0);
|
||||
margin-left: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Invitee chip styles */
|
||||
.booking-form-invitee-chip {
|
||||
color: #1976d2;
|
||||
color: var(--color-primary, #1976d2);
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
/* Booking time styles */
|
||||
.booking-list-time {
|
||||
font-weight: 600;
|
||||
color: #23272f;
|
||||
color: var(--color-on-surface, #23272f);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
.booking-list-invitees {
|
||||
display: block;
|
||||
font-size: 0.85em;
|
||||
color: #555;
|
||||
color: var(--color-on-surface-secondary, #555);
|
||||
font-style: italic;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
12
frontend/src/styles/BookingPage.css
Normal file
12
frontend/src/styles/BookingPage.css
Normal file
@@ -0,0 +1,12 @@
|
||||
.bookingpage-loading {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.bookingpage-error {
|
||||
padding: 24px;
|
||||
color: var(--color-error, red);
|
||||
}
|
||||
|
||||
.bookingpage-content {
|
||||
padding: 24px;
|
||||
}
|
||||
@@ -1,3 +1,22 @@
|
||||
/* Override inline styles if present */
|
||||
.fc-event[style],
|
||||
.fc-daygrid-event[style],
|
||||
.fc-event-main[style],
|
||||
.fc-event-main-frame[style] {
|
||||
background: inherit !important;
|
||||
background-color: inherit !important;
|
||||
}
|
||||
/* Force FullCalendar event blocks to use the correct background and text color */
|
||||
.fc-event,
|
||||
.fc-daygrid-event {
|
||||
background-color: var(
|
||||
--fc-event-bg-color,
|
||||
var(--color-primary, #1976d2)
|
||||
) !important;
|
||||
color: var(--color-white, #fff) !important;
|
||||
border-radius: 4px !important;
|
||||
border: none !important;
|
||||
}
|
||||
/* Inline event styles from CalendarViewInline.css */
|
||||
.calendar-event-content {
|
||||
width: 100%;
|
||||
@@ -8,6 +27,8 @@
|
||||
border-radius: 4px;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
background-color: var(--fc-event-bg-color, var(--color-primary, #1976d2));
|
||||
color: var(--color-white, #fff);
|
||||
}
|
||||
|
||||
.fc-event-title-ellipsis {
|
||||
@@ -23,7 +44,7 @@
|
||||
/* Non-business hours styling */
|
||||
.fc-timegrid-col-bg .fc-non-business,
|
||||
.fc-non-business {
|
||||
background: #888 !important;
|
||||
background: var(--color-calendar-nonbusiness, #888) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
@@ -53,54 +74,57 @@
|
||||
.fc .fc-daygrid-day.fc-day-sun .fc-daygrid-day-frame,
|
||||
.fc .fc-timegrid-col.fc-day-sat,
|
||||
.fc .fc-timegrid-col.fc-day-sun {
|
||||
background: #888 !important; /* unified non-business color */
|
||||
border-color: #bbb !important;
|
||||
background: var(
|
||||
--color-calendar-nonbusiness,
|
||||
#888
|
||||
) !important; /* unified non-business color */
|
||||
border-color: var(--color-calendar-border, #bbb) !important;
|
||||
}
|
||||
|
||||
.fc .fc-daygrid-day.fc-day-sat .fc-daygrid-day-number,
|
||||
.fc .fc-daygrid-day.fc-day-sun .fc-daygrid-day-number {
|
||||
color: #e0e0e0 !important;
|
||||
color: var(--color-calendar-title, #e0e0e0) !important;
|
||||
font-weight: 500 !important;
|
||||
opacity: 0.7 !important;
|
||||
}
|
||||
|
||||
/* Weekday non-business hours */
|
||||
.fc .fc-non-business {
|
||||
background: #888 !important;
|
||||
background: var(--color-calendar-nonbusiness, #888) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
/* Workday all-day and time slots */
|
||||
.fc .fc-daygrid-day .fc-daygrid-day-frame,
|
||||
.fc .fc-timegrid-col {
|
||||
background: #c0c0c0 !important;
|
||||
background: var(--color-calendar-blocked, #c0c0c0) !important;
|
||||
}
|
||||
|
||||
/* Lighter border for all-day row on weekends */
|
||||
.fc .fc-daygrid-day.fc-day-sat,
|
||||
.fc .fc-daygrid-day.fc-day-sun {
|
||||
border-color: #bbb !important;
|
||||
border-color: var(--color-calendar-border, #bbb) !important;
|
||||
}
|
||||
|
||||
/* Out-of-month days styling */
|
||||
.fc .fc-daygrid-day.fc-day-other .fc-daygrid-day-frame {
|
||||
background: #aaa !important;
|
||||
background: var(--color-calendar-muted, #aaa) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
/* =============================== */
|
||||
/* Calendar cell backgrounds */
|
||||
/* =============================== */
|
||||
:root {
|
||||
--fc-page-bg-color: #a0a0a0;
|
||||
--fc-neutral-bg-color: #e3f2fd;
|
||||
--fc-today-bg-color: #e3f2fd;
|
||||
--fc-event-bg-color: #1976d2;
|
||||
--fc-border-color: #707070;
|
||||
--fc-page-bg-color: var(--color-page-bg, #a0a0a0);
|
||||
--fc-neutral-bg-color: var(--color-neutral-bg, #e3f2fd);
|
||||
--fc-today-bg-color: var(--color-today-bg, #e3f2fd);
|
||||
--fc-event-bg-color: var(--color-event-bg, #1976d2);
|
||||
--fc-border-color: var(--color-border, #707070);
|
||||
}
|
||||
|
||||
.calendarContainer {
|
||||
padding: 2rem;
|
||||
background: #f8fafc !important;
|
||||
background: var(--color-calendar-bg, #f8fafc) !important;
|
||||
border-radius: 16px !important;
|
||||
box-shadow: 0 4px 24px rgba(30, 64, 175, 0.08) !important;
|
||||
/* Responsive container, no fixed height */
|
||||
@@ -118,8 +142,8 @@
|
||||
}
|
||||
|
||||
.fc .fc-toolbar {
|
||||
background: #1976d2 !important;
|
||||
color: #fff !important;
|
||||
background: var(--color-primary, #1976d2) !important;
|
||||
color: var(--color-white, #fff) !important;
|
||||
border-radius: 12px 12px 0 0 !important;
|
||||
padding: 0.5rem 1rem !important;
|
||||
}
|
||||
@@ -130,8 +154,8 @@
|
||||
}
|
||||
|
||||
.fc .fc-button {
|
||||
background: #1976d2 !important;
|
||||
color: #fff !important;
|
||||
background: var(--color-primary, #1976d2) !important;
|
||||
color: var(--color-white, #fff) !important;
|
||||
border: none !important;
|
||||
border-radius: 6px !important;
|
||||
padding: 0.3rem 0.8rem !important;
|
||||
@@ -157,14 +181,15 @@
|
||||
|
||||
.fc .fc-daygrid-day-number {
|
||||
font-weight: 600 !important;
|
||||
color: #1976d2 !important;
|
||||
color: var(--color-calendar-event, #1976d2) !important;
|
||||
}
|
||||
|
||||
.fc .fc-event {
|
||||
color: #fff !important;
|
||||
color: var(--color-white, #fff) !important;
|
||||
padding: 2px 6px !important;
|
||||
font-size: 0.95rem !important;
|
||||
box-shadow: 0 2px 8px rgba(30, 64, 175, 0.12) !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.fc .fc-event:hover {
|
||||
@@ -193,7 +218,7 @@
|
||||
}
|
||||
|
||||
.fc .fc-daygrid-day.fc-day-other .fc-daygrid-day-number {
|
||||
color: #fff !important;
|
||||
color: var(--color-white, #fff) !important;
|
||||
opacity: 1 !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
@@ -217,61 +242,61 @@
|
||||
* These classes are mapped in the frontend logic (getRoomClass).
|
||||
*/
|
||||
.room-color-1 {
|
||||
background: var(--room-color-1) !important;
|
||||
background: var(--room-color-1, #1976d2) !important;
|
||||
}
|
||||
.room-color-2 {
|
||||
background: var(--room-color-2) !important;
|
||||
background: var(--room-color-2, #388e3c) !important;
|
||||
}
|
||||
.room-color-3 {
|
||||
background: var(--room-color-3) !important;
|
||||
background: var(--room-color-3, #fbc02d) !important;
|
||||
}
|
||||
.room-color-4 {
|
||||
background: var(--room-color-4) !important;
|
||||
background: var(--room-color-4, #d32f2f) !important;
|
||||
}
|
||||
.room-color-5 {
|
||||
background: var(--room-color-5) !important;
|
||||
background: var(--room-color-5, #7b1fa2) !important;
|
||||
}
|
||||
.room-color-6 {
|
||||
background: var(--room-color-6) !important;
|
||||
background: var(--room-color-6, #0288d1) !important;
|
||||
}
|
||||
.room-color-7 {
|
||||
background: var(--room-color-7) !important;
|
||||
background: var(--room-color-7, #c2185b) !important;
|
||||
}
|
||||
.room-color-8 {
|
||||
background: var(--room-color-8) !important;
|
||||
background: var(--room-color-8, #ffa000) !important;
|
||||
}
|
||||
.room-color-9 {
|
||||
background: var(--room-color-9) !important;
|
||||
background: var(--room-color-9, #009688) !important;
|
||||
}
|
||||
.room-color-10 {
|
||||
background: var(--room-color-10) !important;
|
||||
background: var(--room-color-10, #8bc34a) !important;
|
||||
}
|
||||
.room-color-11 {
|
||||
background: var(--room-color-11) !important;
|
||||
background: var(--room-color-11, #e91e63) !important;
|
||||
}
|
||||
.room-color-12 {
|
||||
background: var(--room-color-12) !important;
|
||||
background: var(--room-color-12, #00bcd4) !important;
|
||||
}
|
||||
.room-color-13 {
|
||||
background: var(--room-color-13) !important;
|
||||
background: var(--room-color-13, #ff5722) !important;
|
||||
}
|
||||
.room-color-14 {
|
||||
background: var(--room-color-14) !important;
|
||||
background: var(--room-color-14, #9c27b0) !important;
|
||||
}
|
||||
.room-color-15 {
|
||||
background: var(--room-color-15) !important;
|
||||
background: var(--room-color-15, #3f51b5) !important;
|
||||
}
|
||||
.room-color-16 {
|
||||
background: var(--room-color-16) !important;
|
||||
background: var(--room-color-16, #4caf50) !important;
|
||||
}
|
||||
.room-color-17 {
|
||||
background: var(--room-color-17) !important;
|
||||
background: var(--room-color-17, #ff9800) !important;
|
||||
}
|
||||
.room-color-18 {
|
||||
background: var(--room-color-18) !important;
|
||||
background: var(--room-color-18, #607d8b) !important;
|
||||
}
|
||||
.room-color-19 {
|
||||
background: var(--room-color-19) !important;
|
||||
background: var(--room-color-19, #795548) !important;
|
||||
}
|
||||
|
||||
/* Ensure event text is readable on colored backgrounds */
|
||||
@@ -295,5 +320,5 @@
|
||||
.room-color-17,
|
||||
.room-color-18,
|
||||
.room-color-19 {
|
||||
color: #fff !important;
|
||||
color: var(--color-white, #fff) !important;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/* Selected room button styles */
|
||||
.MuiButtonBase-root.MuiListItemButton-root.Mui-selected {
|
||||
background-color: #afafaf !important; /* Only use !important if necessary for Material UI override */
|
||||
border: 1px solid #23272f;
|
||||
background-color: var(
|
||||
--color-landing-bg,
|
||||
#afafaf
|
||||
) !important; /* Only use !important if necessary for Material UI override */
|
||||
border: 1px solid var(--color-on-surface, #23272f);
|
||||
border-radius: 16px;
|
||||
margin: 1px;
|
||||
}
|
||||
@@ -69,7 +72,7 @@
|
||||
padding: 2rem 1.5rem 1.5rem 1.5rem;
|
||||
margin-bottom: 0;
|
||||
transition: none;
|
||||
color: #222;
|
||||
color: var(--color-white, #fff);
|
||||
flex: 1 1 0;
|
||||
min-height: 60vh;
|
||||
display: flex;
|
||||
@@ -94,7 +97,7 @@
|
||||
.landing-hr-wide {
|
||||
width: 420px;
|
||||
border: none;
|
||||
border-top: 2px solid #444;
|
||||
border-top: 2px solid var(--color-landing-divider, #444);
|
||||
margin: 0.5rem auto 1.5rem auto;
|
||||
background: none;
|
||||
}
|
||||
@@ -106,7 +109,11 @@
|
||||
width: 100vw;
|
||||
text-align: center;
|
||||
padding-bottom: 2rem;
|
||||
background: linear-gradient(to top, #fff 80%, rgba(255, 255, 255, 0));
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
var(--color-white, #fff) 80%,
|
||||
rgba(255, 255, 255, 0)
|
||||
);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -121,7 +128,7 @@
|
||||
.landing-hr {
|
||||
width: 220px;
|
||||
border: none;
|
||||
border-top: 2px solid #e0e0e0;
|
||||
border-top: 2px solid var(--color-calendar-title, #e0e0e0);
|
||||
margin: 0.5rem auto 1.5rem auto;
|
||||
background: none;
|
||||
}
|
||||
@@ -160,9 +167,9 @@
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
background: #fff;
|
||||
background: var(--color-white, #fff);
|
||||
padding: 1.2rem 0.5rem 1.5rem 0.5rem;
|
||||
color: #23272f;
|
||||
color: var(--color-on-surface, #23272f);
|
||||
}
|
||||
.landing-book-btn-desktop {
|
||||
display: none;
|
||||
@@ -221,6 +228,6 @@
|
||||
.landing-hr-wide {
|
||||
width: 80vw;
|
||||
margin: 0.5rem auto 1.5rem auto;
|
||||
border-top: 2px solid #e0e0e0;
|
||||
border-top: 2px solid var(--color-calendar-title, #e0e0e0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
.room-list-header {
|
||||
flex: 0 0 auto;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
background-color: #1976d2;
|
||||
background-color: var(--color-primary, #1976d2);
|
||||
margin-bottom: 0;
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
@@ -30,7 +30,7 @@
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
background-color: #f7f8fa;
|
||||
background-color: var(--color-roomlist-bg, #f7f8fa);
|
||||
color: #23272f;
|
||||
border-bottom-left-radius: 6px;
|
||||
border-bottom-right-radius: 6px;
|
||||
@@ -38,7 +38,7 @@
|
||||
|
||||
/* Selected room list item styles */
|
||||
.room-list-item-selected {
|
||||
background-color: #ececec !important;
|
||||
background-color: var(--color-roomlist-selected, #ececec) !important;
|
||||
font-weight: 700;
|
||||
color: #23272f;
|
||||
border: 1px solid #23272f;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* theme.ts
|
||||
* MUI theme configuration for the frontend.
|
||||
*
|
||||
* Author: Cliff Hill
|
||||
* Last updated: 2025-09-08
|
||||
* @module
|
||||
*/
|
||||
|
||||
// MUI imports
|
||||
import { createTheme } from "@mui/material/styles";
|
||||
|
||||
// Internal helper imports
|
||||
import { getColors } from "./constants";
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
/**
|
||||
* types.ts
|
||||
* Centralized TypeScript types for frontend application.
|
||||
* Author: Cliff Hill
|
||||
* Last updated: 2025-09-09
|
||||
* @module
|
||||
*/
|
||||
|
||||
// ...no imports in this file...
|
||||
/**
|
||||
* Represents a booking event for display in the calendar.
|
||||
* @property originalBooking - Optional reference to the original booking object
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
/**
|
||||
* logger.ts
|
||||
* Centralized logger using loglevel, with level set from FRONTEND_LOG_LEVEL.
|
||||
*
|
||||
* Author: Cliff Hill
|
||||
* Last updated: 2025-09-08
|
||||
* @module
|
||||
*/
|
||||
|
||||
// External imports
|
||||
import log, { LogLevelDesc } from "loglevel";
|
||||
|
||||
const level = (process.env.FRONTEND_LOG_LEVEL || "info") as LogLevelDesc;
|
||||
/**
|
||||
* Gets the log level from environment and sets it for loglevel.
|
||||
* @type {LogLevelDesc}
|
||||
*/
|
||||
const level: LogLevelDesc = (process.env.FRONTEND_LOG_LEVEL ||
|
||||
"info") as LogLevelDesc;
|
||||
log.setLevel(level);
|
||||
|
||||
/**
|
||||
* Centralized logger instance for frontend logging.
|
||||
* @type {log.Logger}
|
||||
*/
|
||||
export const logger = log;
|
||||
|
||||
Reference in New Issue
Block a user