diff --git a/backend/api/coworking/ambassador.py b/backend/api/coworking/ambassador.py index cc6e9e9ca..8e0e2052b 100644 --- a/backend/api/coworking/ambassador.py +++ b/backend/api/coworking/ambassador.py @@ -17,15 +17,26 @@ api = APIRouter(prefix="/api/coworking/ambassador") -@api.get("", tags=["Coworking"]) -def active_and_upcoming_reservations( +@api.get("/xl", tags=["Coworking"]) +def active_and_upcoming_reservations_for_xl( subject: User = Depends(registered_user), reservation_svc: ReservationService = Depends(), ) -> Sequence[Reservation]: - """List active and upcoming reservations. + """List active and upcoming reservations for the XL. This list drives the ambassador's checkin UI.""" - return reservation_svc.list_all_active_and_upcoming(subject) + return reservation_svc.list_all_active_and_upcoming_for_xl(subject) + + +@api.get("/rooms", tags=["Coworking"]) +def active_and_upcoming_reservations_for_rooms( + subject: User = Depends(registered_user), + reservation_svc: ReservationService = Depends(), +) -> Sequence[Reservation]: + """List active and upcoming reservations for the rooms. + + This list drives the ambassador's checkin UI.""" + return reservation_svc.list_all_active_and_upcoming_for_rooms(subject) @api.put("/checkin", tags=["Coworking"]) diff --git a/backend/api/coworking/reservation.py b/backend/api/coworking/reservation.py index 8af862931..3f4877d2d 100644 --- a/backend/api/coworking/reservation.py +++ b/backend/api/coworking/reservation.py @@ -3,17 +3,22 @@ This API is used to make and manage reservations.""" from fastapi import APIRouter, Depends, HTTPException +from typing import Sequence +from datetime import datetime + +from backend.models.room import Room from ..authentication import registered_user -from ...services.coworking.reservation import ReservationService +from ...services.coworking.reservation import ReservationException, ReservationService from ...models import User from ...models.coworking import ( Reservation, ReservationRequest, ReservationPartial, ReservationState, + ReservationMapDetails ) -__authors__ = ["Kris Jordan"] +__authors__ = ["Kris Jordan, Yuvraj Jain"] __copyright__ = "Copyright 2023" __license__ = "MIT" @@ -44,6 +49,20 @@ def get_reservation( return reservation_svc.get_reservation(subject, id) +@api.get("/room-reservations/", tags=["Coworking"]) +def get_all_reservations_by_state( + state: ReservationState, + subject: User = Depends(registered_user), + reservation_svc: ReservationService = Depends(), +) -> Sequence[Reservation]: + try: + return reservation_svc.get_current_reservations_for_user( + subject=subject, focus=subject, state=state + ) + except Exception as e: + raise HTTPException(status_code=404, detail=str(e)) + + @api.put("/reservation/{id}", tags=["Coworking"]) def update_reservation( reservation: ReservationPartial, @@ -64,3 +83,25 @@ def cancel_reservation( return reservation_svc.change_reservation( subject, ReservationPartial(id=id, state=ReservationState.CANCELLED) ) + + +@api.get("/room-reservation/", tags=["Coworking"]) +def get_reservations_for_rooms_by_date( + date: datetime, + subject: User = Depends(registered_user), + reservation_svc: ReservationService = Depends(), +) -> ReservationMapDetails: + """See available rooms for any given day.""" + try: + return reservation_svc.get_map_reserved_times_by_date(date, subject) + except Exception as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@api.get("/user-reservations/", tags=["Coworking"]) +def get_total_hours_study_room_reservations( + subject: User = Depends(registered_user), + reservation_svc: ReservationService = Depends(), +) -> str: + """Allows a user to know how many hours they have reserved in all study rooms (Excludes CSXL).""" + return reservation_svc._get_total_time_user_reservations(subject) diff --git a/backend/models/coworking/__init__.py b/backend/models/coworking/__init__.py index 7c839c4c7..ef78a4d91 100644 --- a/backend/models/coworking/__init__.py +++ b/backend/models/coworking/__init__.py @@ -10,11 +10,12 @@ ReservationRequest, ReservationState, ReservationPartial, + ReservationMapDetails, ReservationIdentity, ) from .availability_list import AvailabilityList -from .availability import SeatAvailability, RoomAvailability +from .availability import RoomState, SeatAvailability, RoomAvailability from .status import Status diff --git a/backend/models/coworking/availability.py b/backend/models/coworking/availability.py index b584af262..08bf9871c 100644 --- a/backend/models/coworking/availability.py +++ b/backend/models/coworking/availability.py @@ -1,5 +1,6 @@ """Models for the availability of rooms and seats over a time range.""" +from enum import Enum from pydantic import BaseModel, validator from ..room import Room @@ -7,6 +8,18 @@ from .time_range import TimeRange from .availability_list import AvailabilityList +__authors__ = ["Kris Jordan, Yuvraj Jain"] +__copyright__ = "Copyright 2024" +__license__ = "MIT" + + +class RoomState(int, Enum): + AVAILABLE = 0 + RESERVED = 1 + SELECTED = 2 + UNAVAILABLE = 3 + SUBJECT_RESERVED = 4 + class RoomAvailability(Room, AvailabilityList): """A room that is available for a given time range.""" diff --git a/backend/models/coworking/operating_hours.py b/backend/models/coworking/operating_hours.py index f7c15366b..bec959b78 100644 --- a/backend/models/coworking/operating_hours.py +++ b/backend/models/coworking/operating_hours.py @@ -5,6 +5,10 @@ from .time_range import TimeRange +__authors__ = ["Kris Jordan, Yuvraj Jain"] +__copyright__ = "Copyright 2024" +__license__ = "MIT" + class OperatingHours(TimeRange, BaseModel): """The operating hours of the XL.""" diff --git a/backend/models/coworking/reservation.py b/backend/models/coworking/reservation.py index 48850bbad..dc4f3f3e9 100644 --- a/backend/models/coworking/reservation.py +++ b/backend/models/coworking/reservation.py @@ -2,10 +2,14 @@ from pydantic import BaseModel from datetime import datetime from ...models.user import User, UserIdentity -from ..room import Room +from ..room import Room, RoomPartial from .seat import Seat, SeatIdentity from .time_range import TimeRange +__authors__ = ["Kris Jordan, Yuvraj Jain"] +__copyright__ = "Copyright 2024" +__license__ = "MIT" + class ReservationState(str, Enum): DRAFT = "DRAFT" @@ -22,6 +26,7 @@ class ReservationIdentity(BaseModel): class ReservationRequest(TimeRange): users: list[UserIdentity] = [] seats: list[SeatIdentity] = [] + room: RoomPartial | None = None class Reservation(ReservationIdentity, TimeRange): @@ -34,6 +39,13 @@ class Reservation(ReservationIdentity, TimeRange): updated_at: datetime +class ReservationMapDetails(BaseModel): + reserved_date_map: dict[str, list[int]] = {} + operating_hours_start: datetime | None = None + operating_hours_end: datetime | None = None + number_of_time_slots: int | None = None + + class ReservationPartial(Reservation, BaseModel): start: datetime | None = None end: datetime | None = None diff --git a/backend/models/coworking/time_range.py b/backend/models/coworking/time_range.py index 95220d082..e2c5cef3e 100644 --- a/backend/models/coworking/time_range.py +++ b/backend/models/coworking/time_range.py @@ -5,7 +5,7 @@ from pydantic import BaseModel, field_validator, ValidationInfo, validator from typing import Self -__authors__ = ["Kris Jordan"] +__authors__ = ["Kris Jordan, Yuvraj Jain"] __copyright__ = "Copyright 2023" __license__ = "MIT" diff --git a/backend/models/room.py b/backend/models/room.py index 6e7aae3a0..431e272d3 100644 --- a/backend/models/room.py +++ b/backend/models/room.py @@ -7,7 +7,9 @@ __copyright__ = "Copyright 2023" __license__ = "MIT" - -class Room(BaseModel): +class RoomPartial(BaseModel): id: str + +class Room(RoomPartial): nickname: str = "" + diff --git a/backend/script/deployments/coworking_launch.py b/backend/script/deployments/coworking_launch.py index 78ab33b8f..e75e343ae 100644 --- a/backend/script/deployments/coworking_launch.py +++ b/backend/script/deployments/coworking_launch.py @@ -15,9 +15,8 @@ from ...entities.coworking import SeatEntity, OperatingHoursEntity, RoomEntity from ...test.services.reset_table_id_seq import reset_table_id_seq -from ...test.services import role_data, user_data, permission_data +from ...test.services import role_data, user_data, permission_data, room_data from ...test.services.coworking import ( - room_data, seat_data, operating_hours_data, time, diff --git a/backend/script/reset_demo.py b/backend/script/reset_demo.py index 2a4f7ce48..b1200f43a 100644 --- a/backend/script/reset_demo.py +++ b/backend/script/reset_demo.py @@ -21,7 +21,6 @@ from ..test.services import role_data, user_data, permission_data, room_data from ..test.services.organization import organization_demo_data from ..test.services.event import event_demo_data - from ..test.services.coworking import seat_data, operating_hours_data, time from ..test.services.coworking.reservation import reservation_data from ..test.services.academics import course_data, term_data, section_data diff --git a/backend/services/coworking/policy.py b/backend/services/coworking/policy.py index 723ef6f15..813eaac73 100644 --- a/backend/services/coworking/policy.py +++ b/backend/services/coworking/policy.py @@ -2,7 +2,7 @@ from fastapi import Depends from sqlalchemy.orm import Session -from datetime import timedelta +from datetime import timedelta, datetime, time from ...database import db_session from ...models import User @@ -10,6 +10,13 @@ __copyright__ = "Copyright 2023" __license__ = "MIT" +MONDAY = 0 +TUESDAY = 1 +WEDNESDAY = 2 +THURSDAY = 3 +FRIDAY = 4 +SATURDAY = 5 +SUNDAY = 6 class PolicyService: """RoleService is the access layer to the role data model, its members, and permissions. @@ -18,8 +25,7 @@ class PolicyService: for different groups of users (e.g. majors, ambassadors, LAs, etc). """ - def __init__(self): - ... + def __init__(self): ... def walkin_window(self, _subject: User) -> timedelta: """How far into the future can walkins be reserved?""" @@ -51,6 +57,103 @@ def maximum_initial_reservation_duration(self, _subject: User) -> timedelta: def reservation_draft_timeout(self) -> timedelta: return timedelta(minutes=5) + def reservation_checkin_timeout(self) -> timedelta: return timedelta(minutes=10) + + + def room_reservation_weekly_limit(self) -> timedelta: + """The maximum amount of hours a student can reserve the study rooms outside of the csxl.""" + return timedelta(hours=6) + + def non_reservable_rooms(self) -> list[str]: + return ['404'] + + def office_hours(self, date: datetime): + day = date.weekday() + if day == MONDAY: + return { + 'SN135' : [ + (time(hour=16, minute=30), time(hour=17, minute=00)), + ], + 'SN137' : [ + (time(hour=15, minute=00), time(hour=16, minute=30)) + ], + 'SN139' : [], + 'SN141' : [ + (time(hour=16, minute=00), time(hour=17, minute=30)) + ] + } + elif day == TUESDAY: + return { + 'SN135' : [ + (time(hour=14, minute=30), time(hour=16, minute=00)), + (time(hour=11, minute=00), time(hour=12, minute=00)) + ], + 'SN137' : [], + 'SN139' : [ + (time(hour=10, minute=30), time(hour=11, minute=00)), + (time(hour=11, minute=30), time(hour=13, minute=00)), + ], + 'SN141' : [ + (time(hour=10, minute=00), time(hour=11, minute=00)) + ] + } + elif day == WEDNESDAY: + return { + 'SN135' : [ + (time(hour=11, minute=00), time(hour=12, minute=00)) + ], + 'SN137' : [], + 'SN139' : [ + (time(hour=10, minute=30), time(hour=11, minute=00)), + (time(hour=11, minute=30), time(hour=13, minute=00)), + (time(hour=14, minute=30), time(hour=15, minute=00)) + ], + 'SN141' : [ + (time(hour=10, minute=00), time(hour=11, minute=00)) + ] + } + elif day == THURSDAY: + return { + 'SN135' : [ + (time(hour=14, minute=30), time(hour=16, minute=00)), + (time(hour=11, minute=00), time(hour=12, minute=00)) + ], + 'SN137' : [], + 'SN139' : [ + (time(hour=10, minute=30), time(hour=11, minute=00)), + (time(hour=11, minute=30), time(hour=13, minute=00)), + (time(hour=14, minute=30), time(hour=15, minute=00)) + ], + 'SN141' : [] + } + elif day == FRIDAY: + return { + 'SN135' : [ + (time(hour=11, minute=00), time(hour=12, minute=00)) + ], + 'SN137' : [], + 'SN139' : [ + (time(hour=10, minute=30), time(hour=11, minute=00)), + (time(hour=14, minute=30), time(hour=15, minute=00)) + ], + 'SN141' : [ + (time(hour=10, minute=00), time(hour=11, minute=00)) + ] + } + elif day == SATURDAY: + return { + 'SN135' : [], + 'SN137' : [], + 'SN139' : [], + 'SN141' : [] + } + elif day == SUNDAY: + return { + 'SN135' : [], + 'SN137' : [], + 'SN139' : [], + 'SN141' : [] + } \ No newline at end of file diff --git a/backend/services/coworking/reservation.py b/backend/services/coworking/reservation.py index 0ccac6643..38b38d5bc 100644 --- a/backend/services/coworking/reservation.py +++ b/backend/services/coworking/reservation.py @@ -5,17 +5,22 @@ from random import random from typing import Sequence from sqlalchemy.orm import Session, joinedload +from backend.entities.room_entity import RoomEntity + +from backend.models.room_details import RoomDetails from ...database import db_session from ...models.user import User, UserIdentity from ..exceptions import UserPermissionException, ResourceNotFoundException from ...models.coworking import ( Seat, Reservation, + ReservationMapDetails, ReservationRequest, ReservationPartial, TimeRange, SeatAvailability, ReservationState, + RoomState, AvailabilityList, OperatingHours, ) @@ -26,8 +31,8 @@ from .operating_hours import OperatingHoursService from ..permission import PermissionService -__authors__ = ["Kris Jordan", "Matt Vu"] -__copyright__ = "Copyright 2023 - 2024" +__authors__ = ["Kris Jordan", "Matt Vu","Yuvraj Jain"] +__copyright__ = "Copyright 2023" __license__ = "MIT" @@ -92,7 +97,7 @@ def get_reservation(self, subject: User, id: int) -> Reservation: return reservation.to_model() def get_current_reservations_for_user( - self, subject: User, focus: User + self, subject: User, focus: User, state: ReservationState | None = None ) -> Sequence[Reservation]: """Find current and upcoming reservations for a given user. The subject must either also be the focus or have permission to view reservations of @@ -114,12 +119,18 @@ def get_current_reservations_for_user( "coworking.reservation.read", f"user/{focus.id}", ) - # + now = datetime.now() time_range = TimeRange( start=now - timedelta(days=1), end=now + self._policy_svc.reservation_window(focus), ) + + if state: + return self._get_active_reservations_for_user_by_state( + focus, time_range, state + ) + return self._get_active_reservations_for_user(focus, time_range) def _get_active_reservations_for_user( @@ -149,6 +160,374 @@ def _get_active_reservations_for_user( return [reservation.to_model() for reservation in reservations] + def _get_active_reservations_for_user_by_state( + self, + focus: UserIdentity, + time_range: TimeRange, + state: ReservationState, + ) -> Sequence[Reservation]: + reservations = ( + self._session.query(ReservationEntity) + .join(ReservationEntity.users) + .filter( + ReservationEntity.start < time_range.end, + ReservationEntity.end > time_range.start, + ReservationEntity.state == state, + UserEntity.id == focus.id, + ) + .options( + joinedload(ReservationEntity.users), joinedload(ReservationEntity.seats) + ) + .order_by(ReservationEntity.start) + .all() + ) + + reservations = self._state_transition_reservation_entities_by_time( + datetime.now(), reservations + ) + + return [reservation.to_model() for reservation in reservations] + + def _check_user_reservation_duration( + self, user: UserIdentity, bounds: TimeRange + ) -> bool: + """Helper method to check if the total reservation duration for a user exceeds 6 hours. + + Args: + user (User): The user for whom to check reservation duration. + bounds (TimeRange): The time range to check for reservation duration. + + Returns: + True if a user has >= 6 total hours reserved + False if a user has exceeded the limit + """ + reservations = self.get_current_reservations_for_user(user, user) + total_duration = timedelta() + total_duration += bounds.end - bounds.start + + for reservation in reservations: + if reservation.room: + total_duration += reservation.end - reservation.start + if total_duration > self._policy_svc.room_reservation_weekly_limit(): + return False + return True + + def _get_total_time_user_reservations(self, user: UserIdentity) -> str: + """Calculate the total duration (in hours) of study room reservations for the given user. + Args: + user (UserIdentity): The user for whom to calculate the total reservation time. + Returns: + str: The total reservation time in hours. + """ + reservations = self.get_current_reservations_for_user(user, user) + duration = timedelta() + for reservation in reservations: + if reservation.room: + duration += reservation.end - reservation.start + str_duration = str(6 - (round((duration.total_seconds() / 3600) * 2) / 2)) + if str_duration[2] == "0": + return str_duration.rstrip("0").rstrip(".") + return str_duration + + def get_map_reserved_times_by_date( + self, date: datetime, subject: User + ) -> ReservationMapDetails: + """ + Retrieves a detailed mapping of room reservation statuses for a specific date, tailored for a given user. + + This method returns an instance of ReservationMapDetails, which includes: + - A dictionary (`reserved_date_map`) where keys are room IDs and values are lists of time slot statuses + for each room. Statuses are integers representing: + 0 (Available - Green) + 1 (Reserved - Red) + 2 (Selected - Orange) + 3 (Unavailable - Grayed out) + 4 (Subject's Reservation - Blue). + - The start (`operating_hours_start`) and end (`operating_hours_end`) times of operating hours for + the date queried. + - The total number of time slots (`number_of_time_slots`) available within the operating hours, + based on 30-minute intervals. + + It handles various scenarios including days without operating hours by providing a default schedule + (10 am to 6 pm) and adjusting time slots based on current time to mark past slots as unavailable. + It supports rounding start and end times to the nearest half-hour and excludes reservations that + are outside the operating hours. + + Args: + date (datetime): The date for which the reservation statuses are to be fetched. + subject (User): The user for whom the reservation statuses are being determined, to highlight + their own reservations. + + Returns: + ReservationMapDetails: An object containing the mapping of room reservation statuses, + operating hours, and the number of time slots. + + Note: + This method assumes individual user reservations. Group reservations require adjustments to + the implementation. + + Future reservations are shown up to the current time, with past slots marked as unavailable + for today's date. + """ + reserved_date_map: dict[str, list[int]] = {} + + # Query DB to get reservable rooms. You can change coworking policy to change + # which rooms are reservable. SN156 should not go in coworking policy. + rooms = self._get_reservable_rooms() + + # Generate a 1 day time range to get operating hours on date. + date_midnight = date.replace(hour=0, minute=0, second=0) + tomorrow_midnight = date_midnight + timedelta(days=1) + day_range = TimeRange(start=date_midnight, end=tomorrow_midnight) + + # Check if operating hours exist on date + try: + operating_hours_on_date = self._operating_hours_svc.schedule(day_range)[0] + except: + # TODO: Possibly consider thowing exception and handling on the frontend? + # If operating hours don't exist, then return an all grayed out table + # from 10 am to 6 pm which is the standard office hours. + for room in rooms: + if room.id: + reserved_date_map[room.id] = [RoomState.UNAVAILABLE.value] * 16 + return ReservationMapDetails( + reserved_date_map=reserved_date_map, + operating_hours_start=datetime.now().replace(hour=10, minute=0), + operating_hours_end=datetime.now().replace(hour=18, minute=0), + number_of_time_slots=16, + ) + + # Extract the start time and end time for operating hours rounded to the closest half hour + operating_hours_start = self._round_to_closest_half_hour( + operating_hours_on_date.start, round_up=True + ) + operating_hours_end = self._round_to_closest_half_hour( + operating_hours_on_date.end, round_up=False + ) + operating_hours_time_delta = operating_hours_end - operating_hours_start + + # Multiply by 2 because 30 min interval indexes + operating_hours_duration = int( + 2 * operating_hours_time_delta.total_seconds() / 3600 + ) + + # Need current time to gray out slots in the past on that day. + current_time = datetime.now() + current_time_idx = ( + self._idx_calculation(current_time, operating_hours_start) + 1 + ) + + for room in rooms: + time_slots_for_room = [0] * operating_hours_duration + + # Making slots up till current time gray + if date.date() == current_time.date(): + for i in range(0, current_time_idx): + time_slots_for_room[i] = RoomState.UNAVAILABLE.value + + room_id = room.id if room else "SN156" + reservations = self._query_confirmed_reservations_by_date_and_room( + date, room_id + ) + for reservation in reservations: + start_idx = self._idx_calculation( + reservation.start, operating_hours_start + ) + end_idx = self._idx_calculation(reservation.end, operating_hours_start) + + if start_idx < 0 or end_idx > operating_hours_duration: + continue + + # Gray out previous time slots for today only + if date.date() == current_time.date(): + if end_idx < current_time_idx: + continue + start_idx = max(current_time_idx, start_idx) + + for idx in range(start_idx, end_idx): + # Currently only assuming single user. + # TODO: If making group reservations, need to change this. + if reservation.users[0].id == subject.id: + time_slots_for_room[idx] = RoomState.SUBJECT_RESERVED.value + else: + if time_slots_for_room[idx] != RoomState.SUBJECT_RESERVED.value: + time_slots_for_room[idx] = RoomState.RESERVED.value + reserved_date_map[room.id] = time_slots_for_room + + self._transform_date_map_for_unavailable(reserved_date_map) + del reserved_date_map["SN156"] + self._transform_date_map_for_officehours( + date, reserved_date_map, operating_hours_start, operating_hours_duration + ) + + return ReservationMapDetails( + reserved_date_map=reserved_date_map, + operating_hours_start=operating_hours_start, + operating_hours_end=operating_hours_end, + number_of_time_slots=operating_hours_duration, + ) + + def _round_to_closest_half_hour( + self, dt: datetime, round_up: bool = True + ) -> datetime: + """ + This helper rounds a datetime object to the closest half hour either up or down based on the round_up flag. + + Args: + dt (datetime): The datetime object you want to round. + round_up (bool): If True, rounds up to the closest half hour. If False, rounds down to the closest half hour. + + Returns: + datetime: Rounded datetime object. + """ + minutes = dt.minute + + if round_up: + if minutes < 30: + to_add = timedelta(minutes=(30 - minutes)) + else: + to_add = timedelta(minutes=(60 - minutes)) + rounded_dt = dt + to_add + else: + if minutes > 30: + to_subtract = timedelta(minutes=(minutes - 30)) + else: + to_subtract = timedelta(minutes=minutes) + rounded_dt = dt - to_subtract + + rounded_dt = rounded_dt.replace(second=0, microsecond=0) + + return rounded_dt + + def _idx_calculation(self, time: datetime, operating_hours_start: datetime) -> int: + """ + Calculates the index of a time slot based on a given time. + + This function converts a datetime object into an index representing a specific + time slot in the reservation system. Each hour is divided into two slots. + + Args: + time (datetime): The time to convert into an index. + operating_hours_start (int): The hour when the XL opens as an int. + + Returns: + int: The index of the time slot corresponding to the given time. + """ + return int(2 * (time.hour - operating_hours_start.hour)) + ( + (time.minute - operating_hours_start.minute) // 30 + ) + + def _transform_date_map_for_unavailable( + self, reserved_date_map: dict[str, list[int]] + ) -> None: + """ + Modifies the reserved date map to mark certain slots as unavailable. + + This function updates the reserved date map so that if a slot is reserved by the subject + (indicated by a 4), then any available slots (indicated by 0) in the same column across + all rooms are marked as unavailable (changed to 3). + + Args: + reserved_date_map (dict[str, list[int]]): The map of room reservations to be transformed. + + Returns: + None: This function modifies the reserved_date_map in place. + """ + # Identify the columns where 4 appears + columns_with_4 = set() + for key, values in reserved_date_map.items(): + for i, value in enumerate(values): + if value == RoomState.SUBJECT_RESERVED.value: + columns_with_4.add(i) + + # Transform the dictionary as per the rules + for key, values in reserved_date_map.items(): + for i in columns_with_4: + if values[i] == RoomState.AVAILABLE.value: + values[i] = RoomState.UNAVAILABLE.value + + def _transform_date_map_for_officehours( + self, + date: datetime, + reserved_date_map: dict[str, list[int]], + operating_hours_start: datetime, + operating_hours_duration: int, + ) -> None: + """ + Transforms date map in place. + """ + office_hours = self._policy_svc.office_hours(date=date) + for room_id, hours in office_hours.items(): + for start, end in hours: + start_idx = max(self._idx_calculation(start, operating_hours_start), 0) + end_idx = min( + self._idx_calculation(end, operating_hours_start), + operating_hours_duration, + ) + if start_idx < end_idx: + for idx in range(start_idx, end_idx): + reserved_date_map[room_id][idx] = RoomState.UNAVAILABLE.value + + def _query_confirmed_reservations_by_date_and_room( + self, date: datetime, room_id: str + ) -> Sequence[Reservation]: + """ + Queries and returns confirmed and checked-in reservations for a given date and room. + + This function fetches all confirmed and checked-in reservations from the database for a specified date and room. + It includes reservations that have any overlap with the 24-hour period starting from the + beginning of the given date, and are associated with a specific room ID. + + Args: + date (datetime): The date for which to query confirmed reservations. + room_id (str): The ID of the room for which to query confirmed reservations. + + Returns: + Sequence[Reservation]: A sequence of Reservation model objects representing the confirmed reservations for the specified date and room. + """ + start = date.replace(hour=0, minute=0, second=0, microsecond=0) + reservations = ( + self._session.query(ReservationEntity) + .join(ReservationEntity.room) + .filter( + ReservationEntity.start < start + timedelta(hours=24), + ReservationEntity.end > start, + ReservationEntity.state.not_in( + [ReservationState.CANCELLED, ReservationState.CHECKED_OUT] + ), + RoomEntity.id == room_id, + ) + .options( + joinedload(ReservationEntity.users), joinedload(ReservationEntity.seats) + ) + .order_by(ReservationEntity.start) + .all() + ) + + return [reservation.to_model() for reservation in reservations] + + def _get_reservable_rooms(self) -> Sequence[RoomDetails]: + """ + Retrieves a list of all reservable rooms. + This method queries the RoomEntity table to find all rooms that are marked as reservable + (i.e., their 'reservable' attribute is True) and are not the room with ID 'SN156'. + The rooms are then ordered by their ID in ascending order. + + Each room entity is converted to a RoomDetails model before being returned. + + Returns: + Sequence[RoomDetails]: A sequence of RoomDetails models representing all the reservable rooms, excluding room 'SN156'. + """ + + rooms = ( + self._session.query(RoomEntity) + .filter(RoomEntity.id.not_in(self._policy_svc.non_reservable_rooms())) + .order_by(RoomEntity.id) + .all() + ) + + return [room.to_details_model() for room in rooms] + def get_seat_reservations( self, seats: Sequence[Seat], time_range: TimeRange ) -> Sequence[Reservation]: @@ -381,6 +760,13 @@ def draft_reservation( # Enforce request range is within bounds of walkin vs. pre-reserved policies bounds = TimeRange(start=start, end=end) + # Check if user has exceeded reservation limit + if request.room: + if not self._check_user_reservation_duration(request.users[0], bounds): + raise ReservationException( + "Oops! Looks like you've reached your weekly study room reservation limit" + ) + # Fetch User entities for all requested in reservation user_entities = ( self._session.query(UserEntity) @@ -402,12 +788,13 @@ def draft_reservation( ) nonconflicting = bounds.subtract(conflict) - if len(nonconflicting) == 1: + if len(nonconflicting) >= 1: bounds = nonconflicting[0] else: raise ReservationException( "Users may not have conflicting reservations." ) + # Dead code because of the NotImplementedError testing for multiple users at the top # else: # # Draft of expected functionality (needs testing and sanity checking) @@ -421,23 +808,32 @@ def draft_reservation( # Look at the seats - match bounds of assigned seat's availability # TODO: Fetch all seats - seats: list[Seat] = SeatEntity.get_models_from_identities( - self._session, request.seats - ) - seat_availability = self.seat_availability(seats, bounds) + if request.room is None: + seats: list[Seat] = SeatEntity.get_models_from_identities( + self._session, request.seats + ) + seat_availability = self.seat_availability(seats, bounds) - if not is_walkin: - seat_availability = [seat for seat in seat_availability if seat.reservable] + if not is_walkin: + seat_availability = [ + seat for seat in seat_availability if seat.reservable + ] + + if len(seat_availability) == 0: + raise ReservationException( + "The requested seat(s) are no longer available." + ) - if len(seat_availability) == 0: - raise ReservationException("The requested seat(s) are no longer available.") + # TODO (limit to # of users on request if multiple users) + # Here we constrain the reservation start/end to that of the best available seat requested. + # This matters as walk-in availability becomes scarce (may start in the near future even though request + # start is for right now), alternatively may end early due to reserved seat on backend. + seat_entities = [self._session.get(SeatEntity, seat_availability[0].id)] + bounds = seat_availability[0].availability[0] + else: + seat_entities = [] - # TODO (limit to # of users on request if multiple users) - # Here we constrain the reservation start/end to that of the best available seat requested. - # This matters as walk-in availability becomes scarce (may start in the near future even though request - # start is for right now), alternatively may end early due to reserved seat on backend. - seat_entities = [self._session.get(SeatEntity, seat_availability[0].id)] - bounds = seat_availability[0].availability[0] + room_id = request.room.id if request.room else None draft = ReservationEntity( state=ReservationState.DRAFT, @@ -445,7 +841,7 @@ def draft_reservation( end=bounds.end, users=user_entities, walkin=is_walkin, - room_id=None, + room_id=room_id, seats=seat_entities, ) @@ -521,6 +917,7 @@ def _change_state(self, entity: ReservationEntity, delta: ReservationState) -> b transition = (entity.state, delta) valid_transition = False + match transition: case (RS.DRAFT, RS.CONFIRMED): valid_transition = True @@ -531,15 +928,22 @@ def _change_state(self, entity: ReservationEntity, delta: ReservationState) -> b case (RS.CHECKED_IN, RS.CHECKED_OUT): valid_transition = True case _: - return False + valid_transition = False + + if entity.room: + match transition: + case (RS.CONFIRMED, RS.CHECKED_IN): + valid_transition = True if valid_transition: entity.state = delta - return True + return valid_transition - def list_all_active_and_upcoming(self, subject: User) -> Sequence[Reservation]: - """Ambassadors need to see all active and upcoming reservations. + def list_all_active_and_upcoming_for_xl( + self, subject: User + ) -> Sequence[Reservation]: + """Ambassadors need to see all active and upcoming reservations for the XL. This method queries all future events. When pre-reservations are added, this method will need redesign to support date/time based pagination. @@ -571,6 +975,7 @@ def list_all_active_and_upcoming(self, subject: User) -> Sequence[Reservation]: ReservationState.CHECKED_OUT, ) ), + ReservationEntity.room == None, ) .options( joinedload(ReservationEntity.users), joinedload(ReservationEntity.seats) @@ -580,6 +985,47 @@ def list_all_active_and_upcoming(self, subject: User) -> Sequence[Reservation]: ) return [reservation.to_model() for reservation in reservations] + def list_all_active_and_upcoming_for_rooms( + self, subject: User + ) -> Sequence[Reservation]: + """Ambassadors need to see all active and upcoming reservations for the rooms. + + This method queries all future events. When pre-reservations are added, this method + will need redesign to support date/time based pagination. + + Args: + subject (User): The user initiating the reservation change request. + + Returns: + Sequence[Reservation] - all active and upcoming reservations for rooms. + + Raises: + UserPermissionException when user does not have permission to read reservations + """ + self._permission_svc.enforce(subject, "coworking.reservation.read", f"user/*") + now = datetime.now() + reservations = ( + self._session.query(ReservationEntity) + .join(ReservationEntity.users) + .filter( + ReservationEntity.start >= now - timedelta(minutes=10), + ReservationEntity.state.in_( + ( + ReservationState.CONFIRMED, + ReservationState.CHECKED_IN, + ReservationState.CHECKED_OUT, + ) + ), + ReservationEntity.room != None, + ) + .options( + joinedload(ReservationEntity.users), joinedload(ReservationEntity.seats) + ) + .order_by(ReservationEntity.start.asc()) + .all() + ) + return [reservation.to_model() for reservation in reservations] + def staff_checkin_reservation( self, subject: User, reservation: Reservation ) -> Reservation: diff --git a/backend/test/services/coworking/fixtures.py b/backend/test/services/coworking/fixtures.py index 70b54b22d..cdcd78561 100644 --- a/backend/test/services/coworking/fixtures.py +++ b/backend/test/services/coworking/fixtures.py @@ -3,7 +3,10 @@ import pytest from unittest.mock import create_autospec from sqlalchemy.orm import Session -from ....services import PermissionService +from ....services import ( + PermissionService, + RoomService, +) from ....services.coworking import ( OperatingHoursService, SeatService, @@ -12,7 +15,13 @@ StatusService, ) -__authors__ = ["Kris Jordan"] +__authors__ = [ + "Kris Jordan", + "Aarjav Jain", + "John Schachte", + "Nick Wherthey", + "Yuvraj Jain", +] __copyright__ = "Copyright 2023" __license__ = "MIT" @@ -29,6 +38,12 @@ def operating_hours_svc(session: Session, permission_svc: PermissionService): return OperatingHoursService(session, permission_svc) +@pytest.fixture() +def room_svc(session: Session): + """RoomService fixture.""" + return RoomService(session) + + @pytest.fixture() def seat_svc(session: Session): """SeatService fixture.""" diff --git a/backend/test/services/coworking/reservation/get_current_reservations_for_user_test.py b/backend/test/services/coworking/reservation/get_current_reservations_for_user_test.py index 93e8ea26b..4ae74c40a 100644 --- a/backend/test/services/coworking/reservation/get_current_reservations_for_user_test.py +++ b/backend/test/services/coworking/reservation/get_current_reservations_for_user_test.py @@ -2,6 +2,8 @@ from unittest.mock import create_autospec +from backend.models.coworking.reservation import ReservationState + from .....services.coworking import ReservationService # Imported fixtures provide dependencies injected for the tests as parameters. @@ -29,7 +31,13 @@ from .. import seat_data from . import reservation_data -__authors__ = ["Kris Jordan"] +__authors__ = [ + "Kris Jordan", + "Nick Wherthey", + "Yuvraj Jain", + "Aarjav Jain", + "John Schachte", +] __copyright__ = "Copyright 2023" __license__ = "MIT" @@ -41,9 +49,8 @@ def test_get_current_reservations_for_user_as_user( reservations = reservation_svc.get_current_reservations_for_user( user_data.user, user_data.user ) - assert len(reservations) == 2 + assert len(reservations) == 3 assert reservations[0].id == reservation_data.reservation_1.id - assert reservations[1].id == reservation_data.reservation_5.id reservations = reservation_svc.get_current_reservations_for_user( user_data.ambassador, user_data.ambassador @@ -66,3 +73,13 @@ def test_get_current_reservations_for_user_permissions( "coworking.reservation.read", f"user/{user_data.user.id}", ) + + +def test_get_current_reservation_for_user_by_state(reservation_svc: ReservationService): + """Get reservation for user by state.""" + reservations = reservation_svc.get_current_reservations_for_user( + user_data.user, user_data.user, ReservationState.CHECKED_IN + ) + assert len(reservations) == 1 + assert reservations[0].id == reservation_data.reservation_1.id + assert reservations[0].state == reservation_data.reservation_1.state diff --git a/backend/test/services/coworking/reservation/get_map_current_reservations_test.py b/backend/test/services/coworking/reservation/get_map_current_reservations_test.py new file mode 100644 index 000000000..446c41ffe --- /dev/null +++ b/backend/test/services/coworking/reservation/get_map_current_reservations_test.py @@ -0,0 +1,136 @@ +"""Tests for ReservationService#get_map_reservations_for_date and helper functions.""" + +from backend.models.coworking.availability import RoomState +from backend.models.coworking.reservation import ReservationState +from datetime import date + +from .....services.coworking import ReservationService + +# Imported fixtures provide dependencies injected for the tests as parameters. +# Dependent fixtures (seat_svc) are required to be imported in the testing module. +from ..fixtures import ( + reservation_svc, + permission_svc, + seat_svc, + policy_svc, + operating_hours_svc, +) +from ..time import * + +# Import the setup_teardown fixture explicitly to load entities in database. +# The order in which these fixtures run is dependent on their imported alias. +# Since there are relationship dependencies between the entities, order matters. +from ...core_data import user_data +from ...core_data import setup_insert_data_fixture as insert_order_0 +from ..operating_hours_data import fake_data_fixture as insert_order_1 +from ...room_data import fake_data_fixture as insert_order_2 +from ..seat_data import fake_data_fixture as insert_order_3 +from .reservation_data import fake_data_fixture as insert_order_4 + +# Import the fake model data in a namespace for test assertions +from ...core_data import user_data +from .. import seat_data +from . import reservation_data + +__authors__ = [ + "Nick Wherthey", + "Yuvraj Jain", +] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + +SATURDAY, SUNDAY = [5, 6] + + +def test_transform_date_map_for_unavailable_simple(reservation_svc: ReservationService): + """ + Validates the transformation of the date map to indicate unavailable time slots. + + This test ensures that time slots are appropriately grayed out for all other rooms + once a user has made a reservation. For example, if Sally Student reserves room SN135 + from 1 pm to 3 pm on February 29, she should be prevented from booking any other room + during these hours. The function verifies that the data map returned by the endpoint + accurately reflects these unavailable slots, enhancing the user experience by + preventing double bookings. + """ + + sample_date_map_1 = { + 'SN135': [0, 0, 0, 0], + 'SN137': [0, 0, 4, 4], + 'SN139': [0, 0, 0, 0] + } + + expected_transformed_date_map_1 = { + 'SN135': [0, 0, 3, 3], + 'SN137': [0, 0, 4, 4], + 'SN139': [0, 0, 3, 3] + } + + reservation_svc._transform_date_map_for_unavailable(sample_date_map_1) + assert sample_date_map_1 == expected_transformed_date_map_1 + + +def test_transform_date_map_for_unavailable_complex(reservation_svc: ReservationService): + sample_date_map_2 = { + 'SN135': [0, 0, 0, 0, 0, 0, 1, 1, 1, 1], + 'SN137': [0, 0, 1, 1, 4, 4, 4, 4, 0, 0], + 'SN139': [0, 4, 4, 1, 1, 0, 0, 0, 0, 0] + } + + expected_transformed_date_map_2 = { + 'SN135': [0, 3, 3, 0, 3, 3, 1, 1, 1, 1], + 'SN137': [0, 3, 1, 1, 4, 4, 4, 4, 0, 0], + 'SN139': [0, 4, 4, 1, 1, 3, 3, 3, 0, 0] + } + + reservation_svc._transform_date_map_for_unavailable(sample_date_map_2) + assert expected_transformed_date_map_2 == sample_date_map_2 + + +def test_idx_calculation(reservation_svc: ReservationService): + time_1 = datetime.now().replace(hour=10, minute=12) + oh_start = datetime.now().replace(hour=10, minute=0) + assert reservation_svc._idx_calculation(time_1, oh_start) == 0 + + time_2 = datetime.now().replace(hour=12, minute=30) + assert reservation_svc._idx_calculation(time_2, oh_start) == 5 + + time_3 = datetime.now().replace(hour=13, minute=40) + assert reservation_svc._idx_calculation(time_3, oh_start) == 7 + + +def test_query_confirmed_reservations_by_date_and_room( + reservation_svc: ReservationService, time: dict[str, datetime] +): + """Test getting all reservations for a particular date.""" + reservations = reservation_svc._query_confirmed_reservations_by_date_and_room(time[TOMORROW], 'SN135') + assert True + #TODO: Add in better assert statements here. + +def test_get_reservable_rooms(reservation_svc: ReservationService): + rooms = reservation_svc._get_reservable_rooms() + assert rooms[0].id == 'SN135' and rooms[0].reservable is True + assert rooms[1].id == 'SN137' and rooms[1].reservable is True + assert rooms[2].id == 'SN139' and rooms[2].reservable is True + assert rooms[3].id == 'SN141' and rooms[3].reservable is True + +def test_get_map_reserved_times_by_date( + reservation_svc: ReservationService, time: dict[str, datetime] +): + """Test for getting a dictionary where keys are room ids and time slots array are values. + + If this test fails, consider running the reset_demo script before running this test again. + This is hard function to test, and this test does not ensure 100% coverage due to the + multiple edge cases that arise out of it. I recommend setting a breakpoint and looking at + the reserved_date_map in the debugger. + """ + test_time = time[NOW] + reserved_date_map = reservation_svc.get_map_reserved_times_by_date( + test_time + timedelta(days=2), user_data.user + ) + + reserved_date_map_root = reservation_svc.get_map_reserved_times_by_date( + test_time, user_data.root + ) + + assert True \ No newline at end of file diff --git a/backend/test/services/coworking/reservation/list_all_active_and_upcoming_test.py b/backend/test/services/coworking/reservation/list_all_active_and_upcoming_test.py index 00f0b82fa..01b448d23 100644 --- a/backend/test/services/coworking/reservation/list_all_active_and_upcoming_test.py +++ b/backend/test/services/coworking/reservation/list_all_active_and_upcoming_test.py @@ -35,8 +35,8 @@ __license__ = "MIT" -def test_list_all_active_and_upcoming(reservation_svc: ReservationService): - all = reservation_svc.list_all_active_and_upcoming(user_data.ambassador) +def test_list_all_active_and_upcoming_for_xl(reservation_svc: ReservationService): + all = reservation_svc.list_all_active_and_upcoming_for_xl(user_data.ambassador) assert len(all) == len(reservation_data.active_reservations) + len( reservation_data.confirmed_reservations ) @@ -46,7 +46,7 @@ def test_list_all_active_and_upcoming_permission(reservation_svc: ReservationSer permission_svc = create_autospec(PermissionService) permission_svc.enforce.return_value = None reservation_svc._permission_svc = permission_svc - reservation_svc.list_all_active_and_upcoming(user_data.ambassador) + reservation_svc.list_all_active_and_upcoming_for_xl(user_data.ambassador) permission_svc.enforce.assert_called_once_with( user_data.ambassador, "coworking.reservation.read", diff --git a/backend/test/services/coworking/reservation/reservation_data.py b/backend/test/services/coworking/reservation/reservation_data.py index 1f3728480..24c954dd7 100644 --- a/backend/test/services/coworking/reservation/reservation_data.py +++ b/backend/test/services/coworking/reservation/reservation_data.py @@ -13,6 +13,7 @@ from ...reset_table_id_seq import reset_table_id_seq from .. import seat_data from .. import operating_hours_data +from ... import room_data __authors__ = ["Kris Jordan"] @@ -29,6 +30,8 @@ reservation_4: Reservation # Draft reservation for user tomorrow reservation_5: Reservation +# Future room reservation +reservation_6: Reservation # Lists used for access active_reservations: list[Reservation] @@ -38,7 +41,7 @@ def instantiate_global_models(time: dict[str, datetime]): - global reservation_1, reservation_2, reservation_3, reservation_4, reservation_5 + global reservation_1, reservation_2, reservation_3, reservation_4, reservation_5, reservation_6 global active_reservations, reservations, draft_reservations, confirmed_reservations reservation_1 = Reservation( id=1, @@ -109,6 +112,22 @@ def instantiate_global_models(time: dict[str, datetime]): seats=[seat_data.reservable_seats[0]], ) + # Confirm Room Reservation + reservation_6 = Reservation( + id=6, + start=operating_hours_data.tomorrow.start.replace(hour=12, minute=0) + + timedelta(hours=24), + end=operating_hours_data.tomorrow.end.replace(hour=14, minute=30) + + timedelta(hours=24), + created_at=time[NOW], + updated_at=time[NOW], + walkin=False, + room=room_data.group_b, + state=ReservationState.CONFIRMED, + users=[user_data.user], + seats=[], + ) + active_reservations = [reservation_1] confirmed_reservations = [reservation_4] draft_reservations = [reservation_5] @@ -118,6 +137,7 @@ def instantiate_global_models(time: dict[str, datetime]): reservation_3, reservation_4, reservation_5, + reservation_6, ] diff --git a/backend/test/services/coworking/time.py b/backend/test/services/coworking/time.py index 8ed48dc93..b7cff8258 100644 --- a/backend/test/services/coworking/time.py +++ b/backend/test/services/coworking/time.py @@ -17,16 +17,19 @@ # Constants are keys to the times fixture NOW = "NOW" +MIDNIGHT_TODAY = "MIDNIGHT_TODAY" # Past A_WEEK_AGO = "A_WEEK_AGO" AN_HOUR_AGO = "AN_HOUR_AGO" THIRTY_MINUTES_AGO = "THIRTY_MINUTES_AGO" # Future +MIDNIGHT_TOMORROW = "MIDNIGHT_TOMORROW" IN_THIRTY_MINUTES = "IN_THIRTY_MINUTES" TOMORROW = "TOMORROW" IN_ONE_HOUR = "IN_ONE_HOUR" IN_TWO_HOURS = "IN_TWO_HOURS" IN_THREE_HOURS = "IN_THREE_HOURS" +IN_EIGHT_HOURS = "IN_EIGHT_HOURS" @pytest.fixture() @@ -46,16 +49,21 @@ def time_data() -> dict[str, datetime]: return { # Times NOW: now, + MIDNIGHT_TODAY: now.replace(hour=0, minute=0, second=0, microsecond=0), # Past A_WEEK_AGO: now - 7 * ONE_DAY, AN_HOUR_AGO: now - ONE_HOUR, THIRTY_MINUTES_AGO: now - THIRTY_MINUTES, # Future + MIDNIGHT_TOMORROW: (now + ONE_DAY).replace( + hour=0, minute=0, second=0, microsecond=0 + ), IN_THIRTY_MINUTES: now + THIRTY_MINUTES, TOMORROW: now + ONE_DAY, IN_ONE_HOUR: now + ONE_HOUR, IN_TWO_HOURS: now + 2 * ONE_HOUR, IN_THREE_HOURS: now + 3 * ONE_HOUR, + IN_EIGHT_HOURS: now + 8 * ONE_HOUR } diff --git a/docs/images/make-reservations.png b/docs/images/make-reservations.png new file mode 100644 index 000000000..1c7feb759 Binary files /dev/null and b/docs/images/make-reservations.png differ diff --git a/docs/images/specs/room-reservation/active-reservations.png b/docs/images/specs/room-reservation/active-reservations.png new file mode 100644 index 000000000..5d6920a5d Binary files /dev/null and b/docs/images/specs/room-reservation/active-reservations.png differ diff --git a/docs/images/specs/room-reservation/confirmation-page.png b/docs/images/specs/room-reservation/confirmation-page.png new file mode 100644 index 000000000..6f4d86e75 Binary files /dev/null and b/docs/images/specs/room-reservation/confirmation-page.png differ diff --git a/docs/images/specs/room-reservation/make-reservation.png b/docs/images/specs/room-reservation/make-reservation.png new file mode 100644 index 000000000..711e5af47 Binary files /dev/null and b/docs/images/specs/room-reservation/make-reservation.png differ diff --git a/docs/images/specs/room-reservation/reservation-confirmed.png b/docs/images/specs/room-reservation/reservation-confirmed.png new file mode 100644 index 000000000..523fb1dc9 Binary files /dev/null and b/docs/images/specs/room-reservation/reservation-confirmed.png differ diff --git a/docs/images/upcoming-reservations.png b/docs/images/upcoming-reservations.png new file mode 100644 index 000000000..73268dda5 Binary files /dev/null and b/docs/images/upcoming-reservations.png differ diff --git a/docs/specs/room-reservation.md b/docs/specs/room-reservation.md new file mode 100644 index 000000000..7fbfcd345 --- /dev/null +++ b/docs/specs/room-reservation.md @@ -0,0 +1,386 @@ +# Room Reservation Technical Specifications + +This document contains the technical specifications, including sample data representation of our feature, descriptions of underlying database / entity-level representation decisions and development concerns. + +# Authors + +- [Aarjav Jain](https://github.com/aarjavjain2002) +- [John Schachte](https://github.com/JohnSchachte) +- [Nick Wherthey](https://github.com/wherthey) +- [Yuvraj Jain](https://github.com/yuvrajjain2003) + +# Table of Contents + +- [Introduction to Demo](#introduction-to-demo) + - [How do I reserve a room?](#how-do-i-reserve-a-room) + - [How do I view my room reservation?](#how-do-i-view-my-room-reservation) + - [How do I cancel my room reservation](#how-do-i-cancel-my-room-reservation) +- [Description and Sample Data](#descriptions-and-sample-data-representation-of-feature) + - [0. Room Partial](#0-room-partial) + - [1. Reservation Request](#1-reservation-request) + - [2. Reservation Service](#2-reservation-service) + - [3. Route to get all upcoming reservations for a user.](#3-route-to-get-all-upcoming-reservations-for-a-user) +- [Underlying Database / Entity-Level Representation decisions](#underlying-database--entity-level-representation-decisions) +- [Technical and User Experience Design Choices](#technical-and-user-experience-design-choices) +- [Development Concerns](#development-concerns) +- [For Future Developers](#future-developers) + +# Introduction to Demo + +This section of the documents contains the instructions to replicate the steps we take in our demo. By reading this, you should be able to replicate how we create room reservations, view upcoming room reservations, and delete room reservations. We would recommend running the `reset_demo` script to populate the backend with some sample reservations to ease the testing process. + +## How do I reserve a room? + +- From the sidebar, click on Coworking > Room Reservations +- Now you should see a date selector and a table with certain time intervals as columns and room names as rows. To reserve a room, click on **adjacent cells** in a table showing time intervals and room names. You can select up to 4 adjacent slots, equaling a 2-hour reservation. Selecting more than 4 slots resets the selection to one slot. This 2-hour limit is set by a function in the frontend service files and can be adjusted if needed. +- While picking the slots, you will notice a legend with 5 colors. Please bear with us as we figure out more ways to make this accessible to people with color blindness. Currently, the structure is as follows: + - Available (Green): These slots are ones which available to be reserved by the user. You can try clicking on one of these slots which will turn into into a "Selected" time slot which will appear orange. + - Reserved (Red): These slots are the ones which have been reserved by someone other than you. + - Selected (Orange): These slots represent your selection. + - Unavailable (Gray): These slots are unavailble to be reserved because they are either in the past or you have a conflicting reservation in another room or at the CSXL. + - Your Reservations (Blue): These slots represent your reservations. You will notice that once you make a reservation, all other time slots in the same column turn gray. + +![Make Reservations](../images/specs/room-reservation/make-reservation.png) + +- Once you have picked the slots you like, click on the **Select** button, which will draft a reservation for you, and redirect you to the confirmation page. +- On the confirmation page, you can view the details of your selection including the time, location, and date for your reservation. If you are happy with your selection, click on the **Confirm** button. Otherwise, click on **Cancel**. Note that your reservation draft will automatically be cancelled within 5 minutes if you don't press anything. Navigating out of the page also cancels your reservation. + +![Confirmation Page](../images/specs/room-reservation/confirmation-page.png) + +## How do I view my room reservation? + +The natural next question from our previous steps would be, "How do I know that my reservation actually exists?" + +Well, our team has come up with a visually succinct way of displaying this information through coworking cards that were already present in the codebase. If you hit confirm, follow the steps below to see your reservations: + +- From the sidebar, click on Coworking > Room Reservations. + +- Now you should be able to view all your upcoming reservations below the reservation table under the "Upcoming Reservations" header. + +![Profile Card After Reservation](../images/specs/room-reservation/reservation-confirmed.png) + +- Please note that our upcoming reservations only show reservations to the rooms and not to the Colab CSXL SN156 room, since SN156 currently doesn't accept pre-reservations and only takes walk-ins. + +## How do I cancel my room reservation? + +- From the sidebar, click on Coworking > Room Reservations. + +- On the card for the reservation you want to cancel, simply click on the cancel button. Note that this feature doesn't exist for active reservations, since you are already checked-in. You can instead simply check-out for active reservations. + +## Where can I find my active reservations? + +- Once you have checked in, the reservation becomes active. The active reservations can be found on the Coworking page. + +![Active Reservations](../images/specs/room-reservation/active-reservations.png) + +# Descriptions and Sample Data Representation of feature + +We have added / modified the following models / API routes: + +## 0. Room Partial + +Before: + +```py3 +class Room(BaseModel): + id: str + nickname: str = "" +``` + +After: + +```py3 +class RoomPartial(BaseModel): + id: str | None = None + +class Room(RoomPartial): + nickname: str = "" +``` + +We had to modify the following class because it felt weird to make a frontend request to draft a reservation and send in the entire `Room` object. This would require the frontend to know both the `id` and the `nickname` for the room that they were trying to book. Instead, we broke up the `Room` class into `Room` and `RoomPartial`, and `ReservationRequest`now uses the `RoomPartial` class instead. + +Tl;dr: The frontend now only needs to send in the `id` of the room they want to reserve through the HTTP request. + +## 1. Reservation Request + +Before: + +```py3 +class ReservationRequest(TimeRange): + users: list[UserIdentity] = [] + seats: list[SeatIdentity] = [] +``` + +After: + +```py3 +class ReservationRequest(TimeRange): + users: list[UserIdentity] = [] + seats: list[SeatIdentity] = [] + room: RoomPartial | None = None +``` + +The model did not account for different rooms when making a ReservationRequest. And all reservation requests were hardcoded to make reservations for the CSXL Colab. But, now we have extended this feature to make reservations for any room the user wants. + +## 2. Reservation Service + +We had to modify the `draft_reservation()` method inside the ReservationService because previously the service would only cater to reservation requests to the XL since the room parameter was hardcoded to be None. We have laxed this condition, and instead feed the room paramater the input we get from the frontend as a FastAPI parameter in `backend/api/coworking/reservation.py`. + +We added the following methods into the registration_service file. For sake of brevity, we will only include the method signatures here, but you can locate the actual implementation in the repo. + +```py3 +def get_current_reservations_for_user( + self, subject: User, focus: User, state: ReservationState | None = None +) -> Sequence[Reservation]: + ... + + +def _get_active_reservations_for_user( + self, focus: UserIdentity, time_range: TimeRange +) -> Sequence[Reservation]: + ... + + +def _get_active_reservations_for_user_by_state( + self, + focus: UserIdentity, + time_range: TimeRange, + state: ReservationState, +) -> Sequence[Reservation]: + ... + + +def get_map_reserved_times_by_date( + self, date: datetime, subject: User +) -> dict[str, list[int]]: + ... + + +def _idx_calculation(self, time: datetime) -> int: + ... + +def _transform_date_map_for_unavailable( + self, reserved_date_map: dict[str, list[int]] +) -> None: + ... + +def _query_confirmed_reservations_by_date( + self, date: datetime + ) -> Sequence[Reservation]: + ... +``` + +If you want a deeper understanding of how these functions works, we recommened reading [this section.](#future-developers) + +## 3. Route to get all upcoming reservations for a user + +We added the following code into the backend API layer: + +```py3 +@api.get("/room-reservations/", tags=["Coworking"]) +def get_all_reservations_by_state( + state: ReservationState, + subject: User = Depends(registered_user), + reservation_svc: ReservationService = Depends(), +) -> Sequence[Reservation]: + try: + return reservation_svc.get_current_reservations_for_user( + subject=subject, focus=subject, state=state + ) + except Exception as e: + raise HTTPException(status_code=404, detail=str(e)) + +``` + +We needed a way to view all upcoming reservations for a given user so that we can display this information. So we added the following API route into the codebase. + +## 4. Route to get timeslots of all reservations for all users + +We needed this endpoint because we needed a way for the user to know which timeslots and rooms have already been booked, so that they could make their selection accordingly. We make sure that we only get the time slots and not any private information like names of users who made the reservations. This is the route we added in the `backend/api/coworking/reservation.py` file. + +```py3 +@api.get("/room-reservation/", tags=["Coworking"]) +def get_reservations_for_rooms_by_date( + date: datetime, + subject: User = Depends(registered_user), + reservation_svc: ReservationService = Depends(), +) -> dict[str, list[int]]: + """See available rooms for any given day.""" + try: + return reservation_svc.get_map_reserved_times_by_date(date, subject) + except Exception as e: + raise HTTPException(status_code=404, detail=str(e)) +``` + +Note that we had to make a novel method to get this map of reserved times. You can find this implementation under `backend/services/coworking/reservation.py`. + +# Underlying Database / Entity-Level Representation decisions + +We have not interefered with the way that our underlying database stores the data since the structure still remains the same. We have built upon this structure by making new reservations. We are now utilizing the room column within our database which was earlier set to be only None. + +# Technical and User Experience Design Choices + +## 1. Cards or Table for viewing upcoming reservation + +While implementing the upcoming reservations for the user, our team considered between using a table to view all possible upcoming reservations or using a widget card. The trade-off we considered were that tables were a more efficient way of presenting information, and they provide a more efficient way of searching and modifying data. Widgets are much harder to change inputs for and modify, but they provide a more aesthetic and consistent User Experience. + +Our team ended up picking the coworking card widgets to display this information because it was more consistent with how the website is set up right now. It actually ended up providing more modularity than a table than we had anticipated and is actually much easier to manage. + +## 2. API Routes for drafting reservations + +While implementing the API routes for drafting reservations, our team also considering making a separate endpoint for drafting room reservations which were not for the XL, but this ended up becoming very complicated. In the end, we decided that it is probably for the best to reduce the number of endpoints we have and instead modifying the current endpoint to be more expansive and inclusive so that we can make reservations for room other than the XL. + +## 3. List of Reservation Time Ranges or Matrix of Available Time Slots + +This is in context to making new reservations as illustrated in our Figma wireframe below. + +![Make Reservations](../images/make-reservations.png) + +When making reservations, you can see that some of the slots are marked red. These are possibly other reservations that other people in the XL might have made while Sally is trying to make her reservation. In order to actually get this information from the backend, we were debating whether we want to send back a list of reservation objects from the backend to the frontend or send back a matrix of 0s and 1s to represent time slots that are available vs time slots that are reserved. These are the possible trade-offs we considered. + +| List of Reservations | Matrix of Numbers | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| This would be easier to implement on the backend. | This would be considerably harder to implement on the backend | +| This would require then iterating through the list of reservations on the frontend, and it is awkwards to have business logic on the frontend for such an algorthmic process. | This would make it much easier to implement in the frontend since we can easily iterate over the matix and apply the correct widget depending on what value we see in the matrix. | +| This would be much more hardcoded with less modularity for changes in the future. For example, if we toggle to make a room not reservable anymore in the backend, it would either require making an endpoint connect which is awkward or change the hardcoded values which is also awkward. | This would be much more modular since we can actually fetch the operating hours and currently reservable rooms to dynamically update our matrix with updated values. | + +Based on the above analysis of trade-offs, we decided to actually go with the matrix approach, but we made a slight change to it by making it a Map instead, so that the number of rows and columns are not hardcoded, but rather retrived from the backend operating hours page. + +# Development Concerns + +We have a few development concerns that we would like to hopefully address in the future: + +- There should be a limit on how many rooms a user can reserve i.e how many reservations a user can have at any given time. +- Possibly consider adding group members to a reservation. (Add a button in the card to add a user.) +- Allow for pre-reservations in the XL as well. However this will require some clever thinking as the table cannot be extended to just incorporate SN156. +- There are some issues with how room reservations have been currently integrated with XL drop-in reservations, which make it a little harder for the user to navigate. + +# Future Developers + +This section is designed for developers interested in enhancing or expanding our features. Our goal is to provide a comprehensive overview that facilitates a smooth development experience. We've organized this guide into two main subsections: Frontend Concerns and Backend Concerns. This division reflects the separation of concerns inherent in the RestAPI architecture. If your focus is on frontend development, we strongly advise a thorough examination of the Frontend section and a cursory review of the Backend section. Conversely, backend developers should primarily focus on the Backend section while also familiarizing themselves with key aspects of the Frontend section. This approach ensures a holistic understanding of our system's architecture and functionalities, aiding in more effective and efficient development. + +## Frontend Concerns + +The Room Reservation feature introduces 2 components, 2 services, and 2 widgets. Users can access this feature through the coworking tab on the navigation bar. It was important to us to maintain the existing structure while developing an interface for users to book reservations, so future developers have less code to learn. + +### Components + +The components introduced in this feature are the new-reservation and confirmation-reservation components. + +#### New-Reservation Component: + +This component holds a table for users to make reservations with. In its view, it is the parent component for the Room Reservation Table and Date Selector widget. +It has access to the table service, which holds the business logic for this component to maintain appropriate barriers. A user may also see their Upcoming Reservations under the reservation table. +We decided to use the Coworking Reservation Card to display an upcoming reservation. Once the user clicks the 'Select' button, the component will navigate the user to the Confirm Reservation Component. +Availability is requested from the backend with the help of the Room Reservation Table Service and the Date Selector widget. + +#### Confirm Reservation Component: + +This is where a user may inspect the reservation they are about to make. This component uses the Coworking Reservation Card widget to display the reservation to the user. If the user does not confirm the draft reservation, this reservation draft will be cancelled on ng destroy. If either cancel or confirm are clicked, the user will be navigated to the Coworking Home Component. + +### Services + +The two services introduced are the Room Reservation Service and Reservation Table Service. + +#### Room Reservation Service: + +The Room Reservation Service extends the Reservation Service by adding 3 methods: + +1. getReservationsByState: Retrieves reservations by state. +2. checkin: Checks in a confirmed reservation. +3. deleteRoomReservation: Updates a reservation to a cancelled state. + +These methods are used to add and update room reservations with the backend. This service is the primary way of interacting with room reservations in the backend. + +#### Reservation Table Service: + +The Reservation Table Service holds all the business logic for the Room Reservation Table widget while also enumerating the table's cell states. +This service also has logic for interacting with the backend by making draft reservations and getting room reservations by date. + +A large portion of this service is aimed at encapsulating rules for maintaining a legitimate reservation request while users interact with the table. For example, a reservation should not have gaps between the reservation start and end times. + +### Widgets + +The widgets introduced in this feature are the Room Reservation Table and Date Selector. The Coworking Reservation Card widget was heavily used but was preexisting. These were discussed with how they were used within their parent components above. + +#### Room Reservation Table: + +A widget that uses a table to encapsulate the availability of room reservations for a specific day. + +#### Date Selector: + +A widget that uses a material date selector class to emit events to parent components. When a user wants to see room reservation availability for a different day, they will use the Date Selector widget to facilitate this change. + +#### Coworking Reservation Card: + +This preexisting widget is the method by which reservations are displayed to the user. + +## Backend Concerns + +For this feature, the backend primarily focuses on identifying available and reserved rooms, and also displays users' reservations. To understand the backend functionality, it's recommended to follow the outlined path where we move top-down i.e, we start at the API layer and move down to the queries that interact with the persistent storage. + +### 1. API Layer + +We recommend by checking out the code present in `backend/api/coworking/reservation.py`. Since this is the first layer that interacts with the frontend, it is the best to understand how the code is working. In this file we added the following routes: + +```py3 +@api.get("/room-reservations/", tags=["Coworking"]) +def get_all_reservations_by_state( + state: ReservationState, + subject: User = Depends(registered_user), + reservation_svc: ReservationService = Depends(), +) -> Sequence[Reservation]: + try: + return reservation_svc.get_current_reservations_for_user( + subject=subject, focus=subject, state=state + ) + except Exception as e: + raise HTTPException(status_code=404, detail=str(e)) + +@api.get("/room-reservation/", tags=["Coworking"]) +def get_reservations_for_rooms_by_date( + date: datetime, + subject: User = Depends(registered_user), + reservation_svc: ReservationService = Depends(), +) -> dict[str, list[int]]: + """See available rooms for any given day.""" + try: + return reservation_svc.get_map_reserved_times_by_date(date, subject) + except Exception as e: + raise HTTPException(status_code=404, detail=str(e)) +``` + +The `get_all_reservations_by_state()` method takes in a state as an argument and retreives all reservations of that state. To understand what a state is, we recommend checking out the `backend/models/coworking/reservation.py` and look at the class `ReservationState`. + +The `get_reservations_for_rooms_by_date` returns a dictionary of lists where the keys i.e the rows represents different rooms and the columns represent 30 minute time slots from 10:00 am to 12:00 pm. We will investigate how this dictionary is made within the next section. + +### 2. Reservation Service + +A keen reader would observe that within the routes we called the `get_map_reserved_times_by_date()` method. This is what does the majority of the work in the backend, and it is highly recommended to become familiar with this method. + +The main function `get_map_reserved_times_by_date()` uses the other 3 methods as helper functions to query through the database and turn that into a dictionary of lists where 0 represents available, 1 represents reserved, 2 represents selected, 3 represents unavailable, and 4 represents subject reservations. + +An example of what this dictionary would look like is represented below. This is the exact dictionary that is returned by the backend to the frontend. Index 0 represents the timeslot 10:00 - 10:30 am. Index 1 represents 10:30 am - 11:00 am. And so on... + +```py3 +{ + "SN135" : [0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 4, 4, 4, 4], + "SN137" : [0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 3, 3, 3, 3], + "SN139" : [1, 1, 0, 0, 1, 1, 0, 1, 1, 1, 0, 0, 3, 3, 3, 3], + "SN141" : [0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 3, 3] +} +``` + +So you can imagine that some other people have already made reservations for example in SN139 from 10:00 am to 11:00 am and SN141 from 12:00 pm to 2:00 pm. Obviously this list does not contain all the reservations listed above, but hopefully should give you a good understanding how this dictionary works. Also note that the columns which have a 4 all have 3s. This is because if you have a reservation from 4:00 pm to 6:00 pm as in the example above, you cannot make another reservation in the time slot. So those time slots become unavailable to be reserved. + +## 3. Models + +The only model we really implemented in the backend is the RoomPartial and Room model shown below. But it is recommened to go through the other models including the Reservation and User model that are frequently used through our codebase. + +```py3 +class RoomPartial(BaseModel): + id: str | None = None + +class Room(RoomPartial): + nickname: str = "" +``` diff --git a/frontend/src/app/coworking/ambassador-home/ambassador-home-routing.module.ts b/frontend/src/app/coworking/ambassador-home/ambassador-home-routing.module.ts new file mode 100644 index 000000000..edb0235a3 --- /dev/null +++ b/frontend/src/app/coworking/ambassador-home/ambassador-home-routing.module.ts @@ -0,0 +1,22 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { AmbassadorXlListComponent } from './ambassador-xl/list/ambassador-xl-list.component'; +import { AmbassadorPageComponent } from './ambassador-home.component'; +import { AmbassadorRoomListComponent } from './ambassador-room/list/ambassador-room-list.component'; + +const routes: Routes = [ + { + path: '', + component: AmbassadorPageComponent, + children: [ + AmbassadorXlListComponent.Route, + AmbassadorRoomListComponent.Route + ] + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class AmbassadorHomeRoutingModule {} diff --git a/frontend/src/app/coworking/ambassador-home/ambassador-home.component.css b/frontend/src/app/coworking/ambassador-home/ambassador-home.component.css index 79b4759b9..e69de29bb 100644 --- a/frontend/src/app/coworking/ambassador-home/ambassador-home.component.css +++ b/frontend/src/app/coworking/ambassador-home/ambassador-home.component.css @@ -1,15 +0,0 @@ -.mat-mdc-card { - max-width: 100%; -} - -.mat-mdc-card-header { - margin-bottom: 16px; -} - -.walkinReservation.mat-mdc-card-content:last-child { - padding-bottom: 0; -} - -button { - margin-right: 1vw; -} diff --git a/frontend/src/app/coworking/ambassador-home/ambassador-home.component.html b/frontend/src/app/coworking/ambassador-home/ambassador-home.component.html index 429470d20..20fe6ba7e 100644 --- a/frontend/src/app/coworking/ambassador-home/ambassador-home.component.html +++ b/frontend/src/app/coworking/ambassador-home/ambassador-home.component.html @@ -1,164 +1,15 @@ -
- - - Reserve a Drop-in at the Welcome Desk - Create a walk-in reservation for an XL community member at the welcome - desk. Members must be registered with the XL and accept the Community - Agreement. - - - - - - - - - - -
-
- - - Upcoming Reservations - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ID{{ reservation.id }}Name - {{ reservation.users[0].first_name }} {{ - reservation.users[0].last_name }} - Start - {{ reservation.start | date: 'shortTime' }} - End - {{ reservation.end | date: 'shortTime' }} - Seat - {{ reservation.seats[0].title }} - Actions - - -
-
-
- - - - - Upcoming Reservations - - No upcoming reservations. - - -
- -
- - - Active Reservations - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ID{{ reservation.id }}Name - {{ reservation.users[0].first_name }} {{ - reservation.users[0].last_name }} - Start - {{ reservation.start | date: 'shortTime' }} - End - {{ reservation.end | date: 'shortTime' }} - Seat - {{ reservation.seats[0].title }} - Actions -   -
-
-
- - - - - Active Reservations - - It's lonely in here. - - -
+ + + + diff --git a/frontend/src/app/coworking/ambassador-home/ambassador-home.component.ts b/frontend/src/app/coworking/ambassador-home/ambassador-home.component.ts index b60e64aa6..3fa966a3b 100644 --- a/frontend/src/app/coworking/ambassador-home/ambassador-home.component.ts +++ b/frontend/src/app/coworking/ambassador-home/ambassador-home.component.ts @@ -7,114 +7,30 @@ */ import { Component, OnDestroy, OnInit } from '@angular/core'; -import { Route } from '@angular/router'; -import { permissionGuard } from 'src/app/permission.guard'; -import { profileResolver } from 'src/app/profile/profile.resolver'; -import { Observable, Subscription, map, tap, timer } from 'rxjs'; -import { - CoworkingStatus, - Reservation, - SeatAvailability -} from '../coworking.models'; -import { AmbassadorService } from './ambassador.service'; -import { PublicProfile } from 'src/app/profile/profile.service'; -import { CoworkingService } from '../coworking.service'; - -const FIVE_SECONDS = 5 * 1000; +import { Router } from '@angular/router'; @Component({ selector: 'app-coworking-ambassador-home', templateUrl: './ambassador-home.component.html', styleUrls: ['./ambassador-home.component.css'] }) -export class AmbassadorPageComponent implements OnInit, OnDestroy { - /** Route information to be used in App Routing Module */ - public static Route: Route = { - path: 'ambassador', - component: AmbassadorPageComponent, - title: 'XL Ambassador', - canActivate: [permissionGuard('coworking.reservation.*', '*')], - resolve: { profile: profileResolver } - }; - - reservations$: Observable; - upcomingReservations$: Observable; - activeReservations$: Observable; - - welcomeDeskReservationSelection: PublicProfile[] = []; - status$: Observable; - - columnsToDisplay = ['id', 'name', 'seat', 'start', 'end', 'actions']; - - private refreshSubscription!: Subscription; - - constructor( - public ambassadorService: AmbassadorService, - public coworkingService: CoworkingService - ) { - this.reservations$ = this.ambassadorService.reservations$; - this.upcomingReservations$ = this.reservations$.pipe( - map((reservations) => reservations.filter((r) => r.state === 'CONFIRMED')) - ); - this.activeReservations$ = this.reservations$.pipe( - map((reservations) => - reservations.filter((r) => r.state === 'CHECKED_IN') - ) - ); - - this.status$ = coworkingService.status$; - } - - beginReservationRefresh(): void { - if (this.refreshSubscription) { - this.refreshSubscription.unsubscribe(); - } - this.refreshSubscription = timer(0, FIVE_SECONDS) - .pipe(tap((_) => this.ambassadorService.fetchReservations())) - .subscribe(); - } +export class AmbassadorPageComponent implements OnInit { + public links = [ + { + label: 'XL Reservations', + path: '/coworking/ambassador/xl', + default: true + }, + { label: 'Room Reservations', path: '/coworking/ambassador/room' } + ]; + + constructor(private router: Router) {} ngOnInit(): void { - this.beginReservationRefresh(); - } - - ngOnDestroy(): void { - this.refreshSubscription.unsubscribe(); - } - - onUsersChanged(users: PublicProfile[]) { - if (users.length > 0) { - this.coworkingService.pollStatus(); - } - } - - onWalkinSeatSelection(seatSelection: SeatAvailability[]) { - if ( - seatSelection.length > 0 && - this.welcomeDeskReservationSelection.length > 0 - ) { - this.ambassadorService - .makeDropinReservation( - seatSelection, - this.welcomeDeskReservationSelection - ) - .subscribe({ - next: (reservation) => { - this.welcomeDeskReservationSelection = []; - this.beginReservationRefresh(); - alert( - `Walk-in reservation made for ${ - reservation.users[0].first_name - } ${ - reservation.users[0].last_name - }!\nReservation ends at ${reservation.end.toLocaleTimeString()}` - ); - }, - error: (e) => { - this.welcomeDeskReservationSelection = []; - alert(e.message + '\n\n' + e.error.message); - } - }); + // Find the default link and navigate to it + const defaultLink = this.links.find((link) => link.default); + if (defaultLink) { + this.router.navigate([defaultLink.path]); } } } diff --git a/frontend/src/app/coworking/ambassador-home/ambassador-home.module.ts b/frontend/src/app/coworking/ambassador-home/ambassador-home.module.ts new file mode 100644 index 000000000..f7ca061c9 --- /dev/null +++ b/frontend/src/app/coworking/ambassador-home/ambassador-home.module.ts @@ -0,0 +1,41 @@ +import { NgModule } from '@angular/core'; +import { AsyncPipe, CommonModule } from '@angular/common'; + +import { AmbassadorHomeRoutingModule } from './ambassador-home-routing.module'; +import { MatCard, MatCardModule } from '@angular/material/card'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTableModule } from '@angular/material/table'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatDialogModule } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatListModule } from '@angular/material/list'; +import { MatPaginatorModule } from '@angular/material/paginator'; +import { MatSelectModule } from '@angular/material/select'; +import { MatTabsModule } from '@angular/material/tabs'; +// import { AmbassadorXlListComponent } from './ambassador-xl/list/ambassador-xl-list.component'; +// import { AmbassadorRoomListComponent } from './ambassador-room/list/ambassador-room-list.component'; +import { SharedModule } from 'src/app/shared/shared.module'; + +@NgModule({ + imports: [ + CommonModule, + AmbassadorHomeRoutingModule, + MatCardModule, + MatTabsModule, + MatTableModule, + MatDialogModule, + MatButtonModule, + MatSelectModule, + MatFormFieldModule, + MatInputModule, + MatPaginatorModule, + MatListModule, + MatAutocompleteModule, + AsyncPipe, + SharedModule + ] +}) +export class AmbassadorHomeModule {} diff --git a/frontend/src/app/coworking/ambassador-home/ambassador-room/ambassador-room.service.spec.ts b/frontend/src/app/coworking/ambassador-home/ambassador-room/ambassador-room.service.spec.ts new file mode 100644 index 000000000..d3053e063 --- /dev/null +++ b/frontend/src/app/coworking/ambassador-home/ambassador-room/ambassador-room.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { AmbassadorRoomService } from './ambassador-room.service'; + +describe('AmbassadorRoomService', () => { + let service: AmbassadorRoomService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(AmbassadorRoomService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/coworking/ambassador-home/ambassador-room/ambassador-room.service.ts b/frontend/src/app/coworking/ambassador-home/ambassador-room/ambassador-room.service.ts new file mode 100644 index 000000000..490b5caff --- /dev/null +++ b/frontend/src/app/coworking/ambassador-home/ambassador-room/ambassador-room.service.ts @@ -0,0 +1,75 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { + Reservation, + ReservationJSON, + parseReservationJSON +} from '../../coworking.models'; +import { RxReservations } from '../rx-reservations'; + +@Injectable({ + providedIn: 'root' +}) +export class AmbassadorRoomService { + private reservations: RxReservations = new RxReservations(); + public reservations$: Observable = this.reservations.value$; + + constructor(private http: HttpClient) {} + + fetchReservations(): void { + this.http + .get('/api/coworking/ambassador/rooms') + .subscribe((reservations) => { + this.reservations.set(reservations.map(parseReservationJSON)); + }); + } + + isCheckInDisabled(reservation: Reservation): boolean { + const currentTime = new Date(); + const reservationStartTime = new Date(reservation.start); + return reservationStartTime > currentTime; + } + + checkIn(reservation: Reservation): void { + this.http + .put(`/api/coworking/ambassador/checkin`, { + id: reservation.id, + state: 'CHECKED_IN' + }) + .subscribe((reservationJson) => { + this.reservations.updateReservation( + parseReservationJSON(reservationJson) + ); + }); + } + + checkOut(reservation: Reservation) { + this.http + .put(`/api/coworking/reservation/${reservation.id}`, { + id: reservation.id, + state: 'CHECKED_OUT' + }) + .subscribe((reservationJson) => { + this.reservations.updateReservation( + parseReservationJSON(reservationJson) + ); + }); + } + + cancel(reservation: Reservation) { + this.http + .put(`/api/coworking/reservation/${reservation.id}`, { + id: reservation.id, + state: 'CANCELLED' + }) + .subscribe({ + next: (_) => { + this.reservations.remove(reservation); + }, + error: (err) => { + alert(err); + } + }); + } +} diff --git a/frontend/src/app/coworking/ambassador-home/ambassador-room/list/ambassador-room-list.component.css b/frontend/src/app/coworking/ambassador-home/ambassador-room/list/ambassador-room-list.component.css new file mode 100644 index 000000000..fe356ae49 --- /dev/null +++ b/frontend/src/app/coworking/ambassador-home/ambassador-room/list/ambassador-room-list.component.css @@ -0,0 +1,15 @@ +.mat-mdc-card { + max-width: 100%; + } + + .mat-mdc-card-header { + margin-bottom: 16px; + } + + .walkinReservation.mat-mdc-card-content:last-child { + padding-bottom: 0; + } + + button { + margin-right: 1vw; + } \ No newline at end of file diff --git a/frontend/src/app/coworking/ambassador-home/ambassador-room/list/ambassador-room-list.component.html b/frontend/src/app/coworking/ambassador-home/ambassador-room/list/ambassador-room-list.component.html new file mode 100644 index 000000000..357a4044d --- /dev/null +++ b/frontend/src/app/coworking/ambassador-home/ambassador-room/list/ambassador-room-list.component.html @@ -0,0 +1,153 @@ +
+ + + Upcoming Reservations + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID{{ reservation.id }}Name + {{ reservation.users[0].first_name }} + {{ reservation.users[0].last_name }} + Date + {{ reservation.start | date: 'dd MMM yyyy' }} + Start + {{ reservation.start | date: 'shortTime' }} + End + {{ reservation.end | date: 'shortTime' }} + Room + {{ reservation.room.id }} + Actions + + +
+
+
+ + + + + Upcoming Reservations + + No upcoming reservations. + + +
+ +
+ + + Active Reservations + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID{{ reservation.id }}Name + {{ reservation.users[0].first_name }} + {{ reservation.users[0].last_name }} + Date + {{ reservation.start | date: 'dd MMM yyyy' }} + Start + {{ reservation.start | date: 'shortTime' }} + End + {{ reservation.end | date: 'shortTime' }} + Room + {{ reservation.room.id }} + Actions + +
+
+
+ + + + + Active Reservations + + It's lonely in here. + + +
diff --git a/frontend/src/app/coworking/ambassador-home/ambassador-room/list/ambassador-room-list.component.spec.ts b/frontend/src/app/coworking/ambassador-home/ambassador-room/list/ambassador-room-list.component.spec.ts new file mode 100644 index 000000000..e905129bf --- /dev/null +++ b/frontend/src/app/coworking/ambassador-home/ambassador-room/list/ambassador-room-list.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AmbassadorRoomListComponent } from './ambassador-room-list.component'; + +describe('AmbassadorRoomListComponent', () => { + let component: AmbassadorRoomListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [AmbassadorRoomListComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(AmbassadorRoomListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/coworking/ambassador-home/ambassador-room/list/ambassador-room-list.component.ts b/frontend/src/app/coworking/ambassador-home/ambassador-room/list/ambassador-room-list.component.ts new file mode 100644 index 000000000..7678b87d3 --- /dev/null +++ b/frontend/src/app/coworking/ambassador-home/ambassador-room/list/ambassador-room-list.component.ts @@ -0,0 +1,51 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Route } from '@angular/router'; +import { Observable, Subscription, map, timer, tap } from 'rxjs'; +import { Reservation } from 'src/app/coworking/coworking.models'; +import { permissionGuard } from 'src/app/permission.guard'; +import { AmbassadorRoomService } from '../ambassador-room.service'; + +@Component({ + selector: 'app-ambassador-room-list', + templateUrl: './ambassador-room-list.component.html', + styleUrls: ['./ambassador-room-list.component.css'] +}) +export class AmbassadorRoomListComponent implements OnInit, OnDestroy { + public static Route: Route = { + path: 'room', + component: AmbassadorRoomListComponent, + title: 'Room Reservations', + canActivate: [permissionGuard('coworking.reservation.*', '*')], + resolve: {} + }; + + reservations$: Observable; + upcomingReservations$: Observable; + activeReservations$: Observable; + + columnsToDisplay = ['id', 'name', 'room', 'date', 'start', 'end', 'actions']; + + private refreshSubscription!: Subscription; + + constructor(public ambassadorService: AmbassadorRoomService) { + this.reservations$ = this.ambassadorService.reservations$; + this.upcomingReservations$ = this.reservations$.pipe( + map((reservations) => reservations.filter((r) => r.state === 'CONFIRMED')) + ); + this.activeReservations$ = this.reservations$.pipe( + map((reservations) => + reservations.filter((r) => r.state === 'CHECKED_IN') + ) + ); + } + + ngOnInit(): void { + this.refreshSubscription = timer(0, 5000) + .pipe(tap((_) => this.ambassadorService.fetchReservations())) + .subscribe(); + } + + ngOnDestroy(): void { + this.refreshSubscription.unsubscribe(); + } +} diff --git a/frontend/src/app/coworking/ambassador-home/ambassador-xl/ambassador-xl.service.spec.ts b/frontend/src/app/coworking/ambassador-home/ambassador-xl/ambassador-xl.service.spec.ts new file mode 100644 index 000000000..88e0865a4 --- /dev/null +++ b/frontend/src/app/coworking/ambassador-home/ambassador-xl/ambassador-xl.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { AmbassadorXlService } from './ambassador-xl.service'; + +describe('AmbassadorXlService', () => { + let service: AmbassadorXlService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(AmbassadorXlService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/coworking/ambassador-home/ambassador.service.ts b/frontend/src/app/coworking/ambassador-home/ambassador-xl/ambassador-xl.service.ts similarity index 91% rename from frontend/src/app/coworking/ambassador-home/ambassador.service.ts rename to frontend/src/app/coworking/ambassador-home/ambassador-xl/ambassador-xl.service.ts index 4db97e95b..ba5078ac6 100644 --- a/frontend/src/app/coworking/ambassador-home/ambassador.service.ts +++ b/frontend/src/app/coworking/ambassador-home/ambassador-xl/ambassador-xl.service.ts @@ -1,19 +1,21 @@ +import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { RxReservations } from './rx-reservations'; import { Observable, map } from 'rxjs'; import { Reservation, ReservationJSON, SeatAvailability, parseReservationJSON -} from '../coworking.models'; -import { HttpClient } from '@angular/common/http'; +} from '../../coworking.models'; +import { RxReservations } from '../rx-reservations'; import { PublicProfile } from 'src/app/profile/profile.service'; const ONE_HOUR = 60 * 60 * 1000; -@Injectable({ providedIn: 'root' }) -export class AmbassadorService { +@Injectable({ + providedIn: 'root' +}) +export class AmbassadorXlService { private reservations: RxReservations = new RxReservations(); public reservations$: Observable = this.reservations.value$; @@ -21,7 +23,7 @@ export class AmbassadorService { fetchReservations(): void { this.http - .get('/api/coworking/ambassador') + .get('/api/coworking/ambassador/xl') .subscribe((reservations) => { this.reservations.set(reservations.map(parseReservationJSON)); }); diff --git a/frontend/src/app/coworking/ambassador-home/ambassador-xl/list/ambassador-xl-list.component.css b/frontend/src/app/coworking/ambassador-home/ambassador-xl/list/ambassador-xl-list.component.css new file mode 100644 index 000000000..fe356ae49 --- /dev/null +++ b/frontend/src/app/coworking/ambassador-home/ambassador-xl/list/ambassador-xl-list.component.css @@ -0,0 +1,15 @@ +.mat-mdc-card { + max-width: 100%; + } + + .mat-mdc-card-header { + margin-bottom: 16px; + } + + .walkinReservation.mat-mdc-card-content:last-child { + padding-bottom: 0; + } + + button { + margin-right: 1vw; + } \ No newline at end of file diff --git a/frontend/src/app/coworking/ambassador-home/ambassador-xl/list/ambassador-xl-list.component.html b/frontend/src/app/coworking/ambassador-home/ambassador-xl/list/ambassador-xl-list.component.html new file mode 100644 index 000000000..7680afda9 --- /dev/null +++ b/frontend/src/app/coworking/ambassador-home/ambassador-xl/list/ambassador-xl-list.component.html @@ -0,0 +1,164 @@ +
+ + + Reserve a Drop-in at the Welcome Desk + Create a walk-in reservation for an XL community member at the welcome + desk. Members must be registered with the XL and accept the Community + Agreement. + + + + + + + + + + +
+
+ + + Upcoming Reservations + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID{{ reservation.id }}Name + {{ reservation.users[0].first_name }} + {{ reservation.users[0].last_name }} + Start + {{ reservation.start | date: 'shortTime' }} + End + {{ reservation.end | date: 'shortTime' }} + Seat + {{ reservation.seats[0].title }} + Actions + + +
+
+
+ + + + + Upcoming Reservations + + No upcoming reservations. + + +
+ +
+ + + Active Reservations + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID{{ reservation.id }}Name + {{ reservation.users[0].first_name }} + {{ reservation.users[0].last_name }} + Start + {{ reservation.start | date: 'shortTime' }} + End + {{ reservation.end | date: 'shortTime' }} + Seat + {{ reservation.seats[0].title }} + Actions +   +
+
+
+ + + + + Active Reservations + + It's lonely in here. + + +
diff --git a/frontend/src/app/coworking/ambassador-home/ambassador-xl/list/ambassador-xl-list.component.spec.ts b/frontend/src/app/coworking/ambassador-home/ambassador-xl/list/ambassador-xl-list.component.spec.ts new file mode 100644 index 000000000..c7532b65e --- /dev/null +++ b/frontend/src/app/coworking/ambassador-home/ambassador-xl/list/ambassador-xl-list.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AmbassadorXlListComponent } from './ambassador-xl-list.component'; + +describe('AmbassadorXlListComponent', () => { + let component: AmbassadorXlListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [AmbassadorXlListComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(AmbassadorXlListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/coworking/ambassador-home/ambassador-xl/list/ambassador-xl-list.component.ts b/frontend/src/app/coworking/ambassador-home/ambassador-xl/list/ambassador-xl-list.component.ts new file mode 100644 index 000000000..e9661295a --- /dev/null +++ b/frontend/src/app/coworking/ambassador-home/ambassador-xl/list/ambassador-xl-list.component.ts @@ -0,0 +1,112 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Route } from '@angular/router'; +import { permissionGuard } from 'src/app/permission.guard'; +import { profileResolver } from 'src/app/profile/profile.resolver'; +import { Observable, Subscription, map, tap, timer } from 'rxjs'; +import { + CoworkingStatus, + Reservation, + SeatAvailability +} from '../../../coworking.models'; +import { AmbassadorXlService } from '../ambassador-xl.service'; +import { PublicProfile } from 'src/app/profile/profile.service'; +import { CoworkingService } from '../../../coworking.service'; + +const FIVE_SECONDS = 5 * 1000; + +@Component({ + selector: 'app-ambassador-xl-list', + templateUrl: './ambassador-xl-list.component.html', + styleUrls: ['./ambassador-xl-list.component.css'] +}) +export class AmbassadorXlListComponent implements OnDestroy, OnInit { + /** Route information to be used in App Routing Module */ + public static Route: Route = { + path: 'xl', + component: AmbassadorXlListComponent, + title: 'XL Reservations', + canActivate: [permissionGuard('coworking.reservation.*', '*')], + resolve: {} + }; + + reservations$: Observable; + upcomingReservations$: Observable; + activeReservations$: Observable; + + welcomeDeskReservationSelection: PublicProfile[] = []; + status$: Observable; + + columnsToDisplay = ['id', 'name', 'seat', 'start', 'end', 'actions']; + + private refreshSubscription!: Subscription; + + constructor( + public ambassadorService: AmbassadorXlService, + public coworkingService: CoworkingService + ) { + this.reservations$ = this.ambassadorService.reservations$; + this.upcomingReservations$ = this.reservations$.pipe( + map((reservations) => reservations.filter((r) => r.state === 'CONFIRMED')) + ); + this.activeReservations$ = this.reservations$.pipe( + map((reservations) => + reservations.filter((r) => r.state === 'CHECKED_IN') + ) + ); + + this.status$ = coworkingService.status$; + } + + beginReservationRefresh(): void { + if (this.refreshSubscription) { + this.refreshSubscription.unsubscribe(); + } + this.refreshSubscription = timer(0, FIVE_SECONDS) + .pipe(tap((_) => this.ambassadorService.fetchReservations())) + .subscribe(); + } + + ngOnInit(): void { + this.beginReservationRefresh(); + } + + ngOnDestroy(): void { + this.refreshSubscription.unsubscribe(); + } + + onUsersChanged(users: PublicProfile[]) { + if (users.length > 0) { + this.coworkingService.pollStatus(); + } + } + + onWalkinSeatSelection(seatSelection: SeatAvailability[]) { + if ( + seatSelection.length > 0 && + this.welcomeDeskReservationSelection.length > 0 + ) { + this.ambassadorService + .makeDropinReservation( + seatSelection, + this.welcomeDeskReservationSelection + ) + .subscribe({ + next: (reservation) => { + this.welcomeDeskReservationSelection = []; + this.beginReservationRefresh(); + alert( + `Walk-in reservation made for ${ + reservation.users[0].first_name + } ${ + reservation.users[0].last_name + }!\nReservation ends at ${reservation.end.toLocaleTimeString()}` + ); + }, + error: (e) => { + this.welcomeDeskReservationSelection = []; + alert(e.message + '\n\n' + e.error.message); + } + }); + } + } +} diff --git a/frontend/src/app/coworking/coworking-home/coworking-home.component.css b/frontend/src/app/coworking/coworking-home/coworking-home.component.css index 1587880d6..640e50bcd 100644 --- a/frontend/src/app/coworking/coworking-home/coworking-home.component.css +++ b/frontend/src/app/coworking/coworking-home/coworking-home.component.css @@ -6,28 +6,31 @@ * */ -.coworking-top-grid { - display: grid; - padding-top: 24px; - padding-left: 24px; - padding-right: 24px; - grid-template-columns: 4fr 1fr; - /* grid-template-rows: 0fr 2fr; <-- UNCOMMENT */ - row-gap: 15px; - column-gap: 15px; +.coworking-home-top { + padding-bottom: 24px; + padding-top: 24px; } .coworking-reservations-grid { - display: grid; - padding: 24px; - /* grid-template-columns: 1fr 1fr; */ - /* grid-template-rows: 0fr 2fr; <-- UNCOMMENT */ - row-gap: 15px; - column-gap: 15px; + display: grid; + padding: 0px 24px 0px 24px !important; + grid-template-columns: 4fr; + /* grid-template-rows: 0fr 2fr; <-- UNCOMMENT */ + row-gap: 15px; + column-gap: 15px; +} + +.coworking-reservations-grid { + display: grid; + padding: 24px; + /* grid-template-columns: 1fr 1fr; */ + /* grid-template-rows: 0fr 2fr; <-- UNCOMMENT */ + row-gap: 15px; + column-gap: 15px; } .coworking-top-card-container { - grid-column: span 1; + grid-column: span 1; } /* UNCOMMENT THE FOLLOWING: */ @@ -40,21 +43,178 @@ } */ .other-dropin-card-container { - grid-column-start: 1; + grid-column-start: 1; } /** Make cards display in one column, with the event card appearing last */ @media only screen and (max-device-width: 730px) { - .coworking-grid { - display: grid; - padding: 24px; - grid-template-columns: 1fr; - grid-template-rows: 1fr; - row-gap: 15px; - column-gap: 24px; - } - - .dropin-card-container { - order: 1; - } -} \ No newline at end of file + .coworking-home-top { + padding-bottom: 20px; + padding-top: 20px; + } + + .coworking-reservations-grid { + display: grid; + box-sizing: border-box; /* This ensures padding is included in the width */ + padding: 0px 20px 0px 20px !important; + grid-template-columns: 1fr; + grid-template-rows: 1fr; + row-gap: 15px; + } + + .coworking-reservations-grid > * { + box-sizing: border-box; + padding: 0px; + margin: 0px; + } + + .reservation-card { + padding: 4px 16px 4px 16px !important; + box-sizing: border-box; + max-width: 348px; /* Max width as per the other element */ + /* max-height: 321px; Max height as per the other element */ + /* ... other necessary styles ... */ + } + + .dropin-card-container { + order: 1; + } + + .upcoming-card-container { + order: 3; + } +} + +.navigation-card { + background-color: rgb(71, 134, 198); /* Set the background color to gray */ + border: 1px solid #999; /* Set the border color */ + padding: 10px; /* Add padding for spacing */ + margin-top: 15px; /* Add margin for spacing */ + text-align: center; /* Align the content to the left */ + border-radius: 5px; /* Add border radius for rounded corners */ + width: 200px; /* Set the width of the cards */ +} + +.navigation-card a { + text-decoration: none; /* Remove the default underline on the link */ + color: white; /* Set the text color */ + font-weight: bold; /* Make the text bold */ + cursor: pointer; /* Change cursor to pointer on hover */ +} + +/* Add this to your global or component CSS file */ +.room-reservation-button { + font-size: 12px; /* Adjust the font size as needed */ + background-color: red; + width: 180px; + height: 40px; +} + +.mdc-list-item--with-leading-icon .mdc-list-item__start { + font-size: 24px; + background: #4786c6; + width: 36px; + height: 50%; + text-align: center; + margin-bottom: 0; + padding-bottom: 0; + line-height: 36px; + border-radius: 100%; + color: white; +} + +.mat-mdc-list-item-meta.mdc-list-item__end { + display: flex; + align-items: center; + justify-content: center; + height: 100%; +} + +.mat-mdc-list-item-meta { + height: 100%; + vertical-align: middle; + font-size: 32px; +} + +.mdc-list-item { + display: flex; + align-items: center; +} + +.mdc-list-item--with-leading-icon .mdc-list-item__start.unavailable { + background: #612d23; +} + +.mdc-list-item--with-leading-icon .mdc-list-item__start.upcoming { + background: #234261; +} + +/* End Action List Styling */ + +.mat-mdc-card { + padding: 0px; + margin: 0 !important; + max-width: 640px; +} + +.dropin-header { + display: flex; + margin-bottom: 0.5rem; + align-items: center; + justify-content: space-between; +} + +.mdc-list-group__subheader { + margin: 0; +} + +.mdc-list-item { + padding: 0 !important; + display: flex; + align-items: baseline; +} + +.mdc-list-group__subheader { + margin: 0; +} + +.mat-mdc-list-base { + padding: 0; +} + +.mat-expansion-panel-header-description { + align-items: right; + flex: 0 0 auto; +} + +.mat-expansion-panel-header-description button { + z-index: 10 !important; +} + +mat-expansion-panel { + margin-top: 8px; +} + +.mat-action-row { + align-items: center; +} + +mat-checkbox { + flex: 1; +} + +.bolded { + font-weight: 500; +} + +a { + text-decoration: none; +} + +.reservation-card { + padding: 8px 16px 8px 16px; /* Top padding is now the same as sides and bottom */ +} + +.reservation-link { + flex: 1; +} diff --git a/frontend/src/app/coworking/coworking-home/coworking-home.component.html b/frontend/src/app/coworking/coworking-home/coworking-home.component.html index 43a820f08..955a4dbfa 100644 --- a/frontend/src/app/coworking/coworking-home/coworking-home.component.html +++ b/frontend/src/app/coworking/coworking-home/coworking-home.component.html @@ -1,29 +1,72 @@ -
- - - +
+
+ + + + - -
- -
+ + +
+ +
+
+
+ + +
+
+
+
+ + + + +

{{ 'Room Reservations' }}

+

{{ 'Make a new room reservations!' }}

+

+
+
+
+
+
- - - - +
+
+ + +
+

Upcoming Reservations

+
+
+ + + + +
+
+
+
+
+
+
diff --git a/frontend/src/app/coworking/coworking-home/coworking-home.component.ts b/frontend/src/app/coworking/coworking-home/coworking-home.component.ts index 3a43d9fae..8422f5d2d 100644 --- a/frontend/src/app/coworking/coworking-home/coworking-home.component.ts +++ b/frontend/src/app/coworking/coworking-home/coworking-home.component.ts @@ -2,13 +2,13 @@ * The Coworking Component serves as the hub for students to create reservations * for tables, rooms, and equipment from the CSXL. * - * @author Kris Jordan, Ajay Gandecha + * @author Kris Jordan, Ajay Gandecha, John Schachte * @copyright 2023 * @license MIT */ import { Component, OnDestroy, OnInit } from '@angular/core'; -import { ActivatedRoute, Route, Router } from '@angular/router'; +import { Route, Router } from '@angular/router'; import { isAuthenticated } from 'src/app/gate/gate.guard'; import { profileResolver } from 'src/app/profile/profile.resolver'; import { CoworkingService } from '../coworking.service'; @@ -19,19 +19,12 @@ import { Reservation, SeatAvailability } from '../coworking.models'; -import { - Observable, - Subscription, - filter, - map, - mergeMap, - of, - timer -} from 'rxjs'; +import { Observable, Subscription, map, mergeMap, of, timer } from 'rxjs'; +import { RoomReservationService } from '../room-reservation/room-reservation.service'; import { ReservationService } from '../reservation/reservation.service'; import { MatDialog } from '@angular/material/dialog'; import { CommunityAgreement } from 'src/app/shared/community-agreement/community-agreement.widget'; - +import { MatSnackBar } from '@angular/material/snack-bar'; @Component({ selector: 'app-coworking-home', templateUrl: './coworking-home.component.html', @@ -47,6 +40,10 @@ export class CoworkingPageComponent implements OnInit, OnDestroy { private timerSubscription!: Subscription; + public upcomingRoomReservation$!: Observable; + + public filteredRoomReservations$!: Observable; + /** Route information to be used in App Routing Module */ public static Route: Route = { path: '', @@ -57,34 +54,55 @@ export class CoworkingPageComponent implements OnInit, OnDestroy { }; constructor( - route: ActivatedRoute, public coworkingService: CoworkingService, private router: Router, private reservationService: ReservationService, + protected snackBar: MatSnackBar, + private roomReservationService: RoomReservationService, private profileService: ProfileService, private dialog: MatDialog ) { this.status$ = coworkingService.status$; + this.upcomingRoomReservation$ = + roomReservationService.upcomingReservations$; + this.openOperatingHours$ = this.initNextOperatingHours(); + this.isOpen$ = this.initIsOpen(); + this.activeReservation$ = this.initActiveReservation(); + } + + /** + * A lifecycle hook that is called after Angular has initialized all data-bound properties of a directive. + * + * Use this hook to initialize the directive or component. This is the right place to fetch data from a server, + * set up any local state, or perform operations that need to be executed only once when the component is instantiated. + * + * @returns {void} - This method does not return a value. + */ + ngOnInit(): void { + this.status$ = this.coworkingService.status$; this.openOperatingHours$ = this.initNextOperatingHours(); this.isOpen$ = this.initIsOpen(); this.activeReservation$ = this.initActiveReservation(); + this.timerSubscription = timer(0, 10000).subscribe(() => { + this.coworkingService.pollStatus(); + this.roomReservationService.pollUpcomingRoomReservation(this.snackBar); + }); } reserve(seatSelection: SeatAvailability[]) { this.coworkingService.draftReservation(seatSelection).subscribe({ + error: (error) => + this.snackBar.open( + 'Error. There may be a conflicting upcoming reservation. Please check upcoming reservations.', + '', + { duration: 8000 } + ), next: (reservation) => { this.router.navigateByUrl(`/coworking/reservation/${reservation.id}`); } }); } - ngOnInit(): void { - this.timerSubscription = timer(0, 10000).subscribe(() => - this.coworkingService.pollStatus() - ); - this.hasAcceptedAgreement(); - } - ngOnDestroy(): void { this.timerSubscription.unsubscribe(); } @@ -113,7 +131,7 @@ export class CoworkingPageComponent implements OnInit, OnDestroy { let reservations = status.my_reservations; let now = new Date(); return reservations.find( - (reservation) => reservation.start <= now && reservation.end > now + this.roomReservationService.findActiveReservationPredicate ); }), mergeMap((reservation) => @@ -124,6 +142,17 @@ export class CoworkingPageComponent implements OnInit, OnDestroy { ); } + navigateToNewReservation() { + this.router.navigateByUrl('/coworking/new-reservation'); + } + + /** + * Function that is used when coworking card triggers a need to refresh the active reservation + */ + setActiveReservation() { + this.activeReservation$ = this.initActiveReservation(); + } + private hasAcceptedAgreement() { this.profileService.profile$.subscribe((profile) => { if (profile) { diff --git a/frontend/src/app/coworking/coworking-routing.module.ts b/frontend/src/app/coworking/coworking-routing.module.ts index 5d1e50a73..36bb7827d 100644 --- a/frontend/src/app/coworking/coworking-routing.module.ts +++ b/frontend/src/app/coworking/coworking-routing.module.ts @@ -1,13 +1,23 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { CoworkingPageComponent } from './coworking-home/coworking-home.component'; -import { AmbassadorPageComponent } from './ambassador-home/ambassador-home.component'; import { ReservationComponent } from './reservation/reservation.component'; +import { NewReservationPageComponent } from './room-reservation/new-reservation-page/new-reservation-page.component'; +import { ConfirmReservationComponent } from './room-reservation/confirm-reservation/confirm-reservation.component'; const routes: Routes = [ CoworkingPageComponent.Route, ReservationComponent.Route, - AmbassadorPageComponent.Route + NewReservationPageComponent.Route, + ConfirmReservationComponent.Route, + { + path: 'ambassador', + title: 'Ambassador', + loadChildren: () => + import('./ambassador-home/ambassador-home.module').then( + (m) => m.AmbassadorHomeModule + ) + } ]; @NgModule({ diff --git a/frontend/src/app/coworking/coworking.models.ts b/frontend/src/app/coworking/coworking.models.ts index 1fa403d4c..4f1c984d0 100644 --- a/frontend/src/app/coworking/coworking.models.ts +++ b/frontend/src/app/coworking/coworking.models.ts @@ -33,6 +33,11 @@ export interface Seat { y: number; } +export interface Room { + id: string | null; + nickname: string; +} + export interface ReservationJSON extends TimeRangeJSON { id: number; users: Profile[]; @@ -40,6 +45,7 @@ export interface ReservationJSON extends TimeRangeJSON { walkin: boolean; created_at: string; updated_at: string; + room: Room | null; state: string; } @@ -50,6 +56,7 @@ export interface Reservation extends TimeRange { walkin: boolean; created_at: Date; updated_at: Date; + room: Room | null; state: string; } @@ -99,6 +106,69 @@ export const parseCoworkingStatusJSON = ( }; export interface ReservationRequest extends TimeRange { - users: Profile[]; - seats: Seat[]; + users: Profile[] | null; + seats: Seat[] | null; + room: { id: string }; +} +/* Interface for Room Reservation Type */ +export interface RoomReservation { + reservation_id: number | null; + user_id: number; + room_name: string; + start: Date; + end: Date; +} + +/** Interface for the RoomReservation JSON Response model + * Note: The API returns object data, such as `Date`s, as strings. So, + * this interface models the data directly received from the API. It is + * the job of the `parseRoomReservationJson` function to convert it to the + * `RoomReservation` type + */ +export interface RoomReservationJson { + reservation_id: number | null; + user_id: number; + room_name: string; + start: string; + end: string; +} + +/** Function that converts a RoomReservationJSON response model to a + * Room Reservation model. This function is needed because the API response + * will return certain objects (such as `Date`s) as strings. We need to + * convert this to TypeScript objects ourselves. + */ +export const parseEventJson = ( + roomReservationJson: RoomReservationJson +): RoomReservation => { + return Object.assign({}, roomReservationJson, { + start: new Date(roomReservationJson.start), + end: new Date(roomReservationJson.end) + }); +}; + +/** + * Represents a cell in a Table Widget + * @property key - The room of the cell acting as key + * @property index - The index of the cell in the reservationMap that represents the timeslot's state + */ +export interface TableCell { + key: string; + index: number; +} + +export interface TableCellProperty { + backgroundColor: string; + isDisabled: boolean; +} + +export interface TablePropertyMap { + [key: number]: TableCellProperty; +} + +export interface ReservationMapDetails { + reserved_date_map: Record; + operating_hours_start: string; + operating_hours_end: string; + number_of_time_slots: number; } diff --git a/frontend/src/app/coworking/coworking.module.ts b/frontend/src/app/coworking/coworking.module.ts index 00bc74019..fd3b5bfb8 100644 --- a/frontend/src/app/coworking/coworking.module.ts +++ b/frontend/src/app/coworking/coworking.module.ts @@ -6,33 +6,55 @@ import { CoworkingPageComponent } from './coworking-home/coworking-home.componen import { AmbassadorPageComponent } from './ambassador-home/ambassador-home.component'; import { MatCardModule } from '@angular/material/card'; import { CoworkingReservationCard } from './widgets/coworking-reservation-card/coworking-reservation-card'; -import { MatDividerModule } from '@angular/material/divider'; import { CoworkingDropInCard } from './widgets/dropin-availability-card/dropin-availability-card.widget'; import { MatListModule } from '@angular/material/list'; import { CoworkingHoursCard } from './widgets/operating-hours-panel/operating-hours-panel.widget'; import { MatExpansionModule } from '@angular/material/expansion'; -import { MatButtonModule } from '@angular/material/button'; import { MatTableModule } from '@angular/material/table'; import { ReservationComponent } from './reservation/reservation.component'; +import { MatIconModule } from '@angular/material/icon'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatButtonModule } from '@angular/material/button'; +import { NewReservationPageComponent } from './room-reservation/new-reservation-page/new-reservation-page.component'; +import { RoomReservationWidgetComponent } from './widgets/room-reservation-table/room-reservation-table.widget'; +import { ConfirmReservationComponent } from './room-reservation/confirm-reservation/confirm-reservation.component'; +import { DateSelector } from './widgets/date-selector/date-selector.widget'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatNativeDateModule } from '@angular/material/core'; import { OperatingHoursDialog } from './widgets/operating-hours-dialog/operating-hours-dialog.widget'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { ReactiveFormsModule } from '@angular/forms'; import { MatInputModule } from '@angular/material/input'; import { SharedModule } from '../shared/shared.module'; +import { MatTabsModule } from '@angular/material/tabs'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { AmbassadorXlListComponent } from './ambassador-home/ambassador-xl/list/ambassador-xl-list.component'; +import { AmbassadorRoomListComponent } from './ambassador-home/ambassador-room/list/ambassador-room-list.component'; @NgModule({ declarations: [ + NewReservationPageComponent, + RoomReservationWidgetComponent, CoworkingPageComponent, ReservationComponent, AmbassadorPageComponent, + AmbassadorXlListComponent, + AmbassadorRoomListComponent, CoworkingDropInCard, CoworkingReservationCard, CoworkingHoursCard, + ConfirmReservationComponent, + NewReservationPageComponent, + DateSelector, OperatingHoursDialog ], imports: [ CommonModule, + MatCardModule, + MatButtonModule, + MatDividerModule, + MatIconModule, CoworkingRoutingModule, MatCardModule, MatDividerModule, @@ -46,7 +68,13 @@ import { SharedModule } from '../shared/shared.module'; MatCardModule, AsyncPipe, AsyncPipe, - SharedModule + SharedModule, + MatDatepickerModule, + MatInputModule, + MatNativeDateModule, + MatFormFieldModule, + MatTooltipModule, + MatTabsModule ] }) export class CoworkingModule {} diff --git a/frontend/src/app/coworking/coworking.service.ts b/frontend/src/app/coworking/coworking.service.ts index efeace6e8..cf8f07f46 100644 --- a/frontend/src/app/coworking/coworking.service.ts +++ b/frontend/src/app/coworking/coworking.service.ts @@ -1,10 +1,15 @@ +/** + * @author Kris Jordan, John Schachte + * @copyright 2023 + * @license MIT + */ + import { HttpClient } from '@angular/common/http'; import { Injectable, OnDestroy } from '@angular/core'; -import { Observable, Subscription, map, tap } from 'rxjs'; +import { Observable, Subscription, map, BehaviorSubject } from 'rxjs'; import { CoworkingStatus, CoworkingStatusJSON, - Reservation, ReservationJSON, SeatAvailability, parseCoworkingStatusJSON, @@ -26,6 +31,8 @@ export class CoworkingService implements OnDestroy { private profile: Profile | undefined; private profileSubscription!: Subscription; + isCancelExpanded = new BehaviorSubject(false); + public constructor( protected http: HttpClient, protected profileSvc: ProfileService @@ -66,4 +73,22 @@ export class CoworkingService implements OnDestroy { .post('/api/coworking/reservation', reservation) .pipe(map(parseReservationJSON)); } + + /** + * Toggles the expansion state of the cancellation UI. + * + * This method inverts the current boolean state of `isCancelExpanded`. + * If `isCancelExpanded` is currently true, calling this method will set it to false, and vice versa. + * This is typically used to control the visibility of a UI element that allows the user to cancel an action. + * + * @example + * // Assuming `isCancelExpanded` is initially false + * toggleCancelExpansion(); + * // Now `isCancelExpanded` is true + * + * @returns {void} + */ + toggleCancelExpansion(): void { + this.isCancelExpanded.next(!this.isCancelExpanded.value); + } } diff --git a/frontend/src/app/coworking/reservation/reservation.component.css b/frontend/src/app/coworking/reservation/reservation.component.css index 2fbf68f2d..4c3fd726f 100644 --- a/frontend/src/app/coworking/reservation/reservation.component.css +++ b/frontend/src/app/coworking/reservation/reservation.component.css @@ -1,34 +1,34 @@ .card-container { - margin: 16px; + margin: 16px; } .mat-mdc-card { - max-width: 640px; - margin: 0px; + max-width: 640px; + margin: 0px; } .mat-mdc-card-header { - margin-bottom: 16px; + margin-bottom: 16px; } .mat-mdc-card-actions { - margin-top: 16px; + margin-top: 16px; } .mat-divider { - margin: 1em 0; + margin: 1em 0; } h3 { - font-size: 18px; - margin-bottom: 0px; + font-size: 18px; + margin-bottom: 0px; } -h3>label { - width: 64px; - display: inline-block; +h3 > label { + width: 64px; + display: inline-block; } p { - margin-left: 64px; -} \ No newline at end of file + margin-left: 64px; +} diff --git a/frontend/src/app/coworking/reservation/reservation.service.ts b/frontend/src/app/coworking/reservation/reservation.service.ts index e3915be50..6ebf23e48 100644 --- a/frontend/src/app/coworking/reservation/reservation.service.ts +++ b/frontend/src/app/coworking/reservation/reservation.service.ts @@ -14,7 +14,7 @@ import { RxReservation } from './rx-reservation'; export class ReservationService { private reservations: Map = new Map(); - constructor(private http: HttpClient) {} + constructor(protected http: HttpClient) {} get(id: number): Observable { let reservation = this.getRxReservation(id); diff --git a/frontend/src/app/coworking/room-reservation/confirm-reservation/confirm-reservation.component.css b/frontend/src/app/coworking/room-reservation/confirm-reservation/confirm-reservation.component.css new file mode 100644 index 000000000..4c3fd726f --- /dev/null +++ b/frontend/src/app/coworking/room-reservation/confirm-reservation/confirm-reservation.component.css @@ -0,0 +1,34 @@ +.card-container { + margin: 16px; +} + +.mat-mdc-card { + max-width: 640px; + margin: 0px; +} + +.mat-mdc-card-header { + margin-bottom: 16px; +} + +.mat-mdc-card-actions { + margin-top: 16px; +} + +.mat-divider { + margin: 1em 0; +} + +h3 { + font-size: 18px; + margin-bottom: 0px; +} + +h3 > label { + width: 64px; + display: inline-block; +} + +p { + margin-left: 64px; +} diff --git a/frontend/src/app/coworking/room-reservation/confirm-reservation/confirm-reservation.component.html b/frontend/src/app/coworking/room-reservation/confirm-reservation/confirm-reservation.component.html new file mode 100644 index 000000000..b932a7b51 --- /dev/null +++ b/frontend/src/app/coworking/room-reservation/confirm-reservation/confirm-reservation.component.html @@ -0,0 +1,7 @@ +
+ + +
diff --git a/frontend/src/app/coworking/room-reservation/confirm-reservation/confirm-reservation.component.spec.ts b/frontend/src/app/coworking/room-reservation/confirm-reservation/confirm-reservation.component.spec.ts new file mode 100644 index 000000000..fe3f7515d --- /dev/null +++ b/frontend/src/app/coworking/room-reservation/confirm-reservation/confirm-reservation.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ConfirmReservationComponent } from './confirm-reservation.component'; + +describe('ConfirmReservationComponent', () => { + let component: ConfirmReservationComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ConfirmReservationComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(ConfirmReservationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/coworking/room-reservation/confirm-reservation/confirm-reservation.component.ts b/frontend/src/app/coworking/room-reservation/confirm-reservation/confirm-reservation.component.ts new file mode 100644 index 000000000..7d9cc5c33 --- /dev/null +++ b/frontend/src/app/coworking/room-reservation/confirm-reservation/confirm-reservation.component.ts @@ -0,0 +1,77 @@ +/** + * @author John Schachte, Aarjav Jain, Nick Wherthey + * @copyright 2023 + * @license MIT + */ + +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { ActivatedRoute, Router } from '@angular/router'; +import { timer } from 'rxjs'; +import { isAuthenticated } from 'src/app/gate/gate.guard'; +import { profileResolver } from 'src/app/profile/profile.resolver'; +import { Reservation } from '../../coworking.models'; +import { RoomReservationService } from '../room-reservation.service'; + +@Component({ + selector: 'app-confirm-reservation', + templateUrl: './confirm-reservation.component.html', + styleUrls: ['./confirm-reservation.component.css'] +}) +export class ConfirmReservationComponent implements OnInit, OnDestroy { + public static Route = { + path: 'confirm-reservation/:id', + title: 'Confirm Reservation', + component: ConfirmReservationComponent, + canActivate: [isAuthenticated], + resolve: { profile: profileResolver } + }; + + reservation: Reservation | null = null; // Declaration of the reservation property + isConfirmed: boolean = false; // flag to see if reservation was confirmed + + public id: number; + + constructor( + private roomReservationService: RoomReservationService, + protected snackBar: MatSnackBar, + private router: Router, + public route: ActivatedRoute + ) { + this.id = parseInt(this.route.snapshot.params['id']); + } + + ngOnDestroy(): void { + if (this.isConfirmed) return; + this.roomReservationService + .deleteRoomReservation(this.reservation!) + .subscribe(); + } + + /** + * A lifecycle hook that is called after Angular has initialized all data-bound properties of a directive. + * + * Use this hook to initialize the directive or component. This is the right place to fetch data from a server, + * set up any local state, or perform operations that need to be executed only once when the component is instantiated. + * + * @returns {void} - This method does not return a value. + */ + ngOnInit() { + this.roomReservationService.get(this.id).subscribe({ + next: (response) => (this.reservation = response), // Assume only one draft per user + error: (error) => { + this.snackBar.open('Error while fetching draft reservation.', '', { + duration: 8000 + }); + timer(3000).subscribe(() => + this.router.navigateByUrl('/coworking/new-reservation') + ); + console.error(error.message); + } + }); + } + + setConfirmation(isConfirmed: boolean) { + this.isConfirmed = isConfirmed; + } +} diff --git a/frontend/src/app/coworking/room-reservation/new-reservation-page/new-reservation-page.component.css b/frontend/src/app/coworking/room-reservation/new-reservation-page/new-reservation-page.component.css new file mode 100644 index 000000000..baf71848f --- /dev/null +++ b/frontend/src/app/coworking/room-reservation/new-reservation-page/new-reservation-page.component.css @@ -0,0 +1,58 @@ +.table-container { + overflow-x: auto; /* Enable horizontal scrolling for the table container */ + max-width: 100%; /* Ensure it doesn't exceed the viewport width */ +} + +.horizontal-scroll { + white-space: nowrap; /* Prevent line breaks for child elements */ +} + +/* Add any additional styling for the room-reservation-table if needed */ + +.date-selector { + padding: 10px; /* Adjust the value as needed */ +} + +.upcoming-reservations-container { + display: flex; + flex-wrap: wrap; + gap: 20px; /* Adjust the gap as needed */ +} + +.coworking-reservation-card { + width: calc(50% - 10px); /* Adjust the width and margin as needed */ + margin-bottom: 20px; + box-sizing: border-box; /* Include padding and border in the total width */ +} + +.heading { + padding-top: 30px; + padding-left: 10px; +} + +.legend-container { + display: flex; + flex-wrap: wrap; + margin-top: 20px; /* Adjust the margin as needed */ +} + +.legend-item { + display: flex; + align-items: center; + margin-left: 10px; /* Adjust the margin between legend items */ + margin-top: 5px; +} + +.legend-color { + width: 20px; + height: 20px; + margin-right: 5px; +} + +.legend-text { + font-size: 14px; +} + +.reservation-limit { + padding-left: 10px; +} diff --git a/frontend/src/app/coworking/room-reservation/new-reservation-page/new-reservation-page.component.html b/frontend/src/app/coworking/room-reservation/new-reservation-page/new-reservation-page.component.html new file mode 100644 index 000000000..cc0710a13 --- /dev/null +++ b/frontend/src/app/coworking/room-reservation/new-reservation-page/new-reservation-page.component.html @@ -0,0 +1,34 @@ +
+ +
+ +
+ +
+ +
+
+
+ Available +
+
+
+ Reserved +
+
+
+ Your Reservations +
+
+
+ Selected +
+
+
+ Unavailable +
+
+ +
+

Total Hours Remaining: {{ numHoursStudyRoomReservations$ | async }}

+
diff --git a/frontend/src/app/coworking/room-reservation/new-reservation-page/new-reservation-page.component.spec.ts b/frontend/src/app/coworking/room-reservation/new-reservation-page/new-reservation-page.component.spec.ts new file mode 100644 index 000000000..aa23864ec --- /dev/null +++ b/frontend/src/app/coworking/room-reservation/new-reservation-page/new-reservation-page.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NewReservationPageComponent } from './new-reservation-page.component'; + +describe('NewReservationPageComponent', () => { + let component: NewReservationPageComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [NewReservationPageComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(NewReservationPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/coworking/room-reservation/new-reservation-page/new-reservation-page.component.ts b/frontend/src/app/coworking/room-reservation/new-reservation-page/new-reservation-page.component.ts new file mode 100644 index 000000000..738e90c6c --- /dev/null +++ b/frontend/src/app/coworking/room-reservation/new-reservation-page/new-reservation-page.component.ts @@ -0,0 +1,73 @@ +/** + * @author John Schachte, Aarjav Jain, Nick Wherthey + * @copyright 2023 + * @license MIT + */ + +import { Component, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; +import { Reservation } from 'src/app/coworking/coworking.models'; +import { isAuthenticated } from 'src/app/gate/gate.guard'; +import { profileResolver } from 'src/app/profile/profile.resolver'; +import { catchError, Observable, of } from 'rxjs'; +import { RoomReservationService } from '../room-reservation.service'; +import { MatSnackBar } from '@angular/material/snack-bar'; + +@Component({ + selector: 'app-new-reservation-page', + templateUrl: './new-reservation-page.component.html', + styleUrls: ['./new-reservation-page.component.css'] +}) +export class NewReservationPageComponent implements OnInit { + public static Route = { + path: 'new-reservation', + title: 'New Reservation', + component: NewReservationPageComponent, + canActivate: [isAuthenticated], + resolve: { profile: profileResolver } + }; + + public upcomingRoomReservations$!: Observable; + public numHoursStudyRoomReservations$!: Observable; + + constructor( + private router: Router, + private roomReservationService: RoomReservationService, + protected snackBar: MatSnackBar + ) {} + + /** + * A lifecycle hook that is called after Angular has initialized all data-bound properties of a directive. + * + * Use this hook to initialize the directive or component. This is the right place to fetch data from a server, + * set up any local state, or perform operations that need to be executed only once when the component is instantiated. + * + * @returns {void} - This method does not return a value. + */ + + ngOnInit() { + this.initUpdateReservationsList(); + this.getNumHoursStudyRoomReservations(); + } + + navigateToNewReservation() { + this.router.navigateByUrl('/coworking/new-reservation'); + } + + initUpdateReservationsList() { + this.upcomingRoomReservations$ = this.roomReservationService + .getReservationsByState('CONFIRMED') + .pipe( + catchError((err) => { + const message = 'Error while fetching upcoming reservations.'; + this.snackBar.open(message, '', { duration: 8000 }); + console.error(err); + return of([]); + }) + ); + } + getNumHoursStudyRoomReservations() { + this.numHoursStudyRoomReservations$ = + this.roomReservationService.getNumHoursStudyRoomReservations(); + } +} diff --git a/frontend/src/app/coworking/room-reservation/reservation-table.service.spec.ts b/frontend/src/app/coworking/room-reservation/reservation-table.service.spec.ts new file mode 100644 index 000000000..970637941 --- /dev/null +++ b/frontend/src/app/coworking/room-reservation/reservation-table.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { ReservationTableService } from './reservation-table.service'; + +describe('ReservationTableService', () => { + let service: ReservationTableService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ReservationTableService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/coworking/room-reservation/reservation-table.service.ts b/frontend/src/app/coworking/room-reservation/reservation-table.service.ts new file mode 100644 index 000000000..7e2afab2b --- /dev/null +++ b/frontend/src/app/coworking/room-reservation/reservation-table.service.ts @@ -0,0 +1,373 @@ +/** + * @author John Schachte, Aarjav Jain, Nick Wherthey + * @copyright 2023 + * @license MIT + */ + +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable, Subscription } from 'rxjs'; +import { + Reservation, + ReservationMapDetails, + ReservationRequest, + TableCell, + TablePropertyMap +} from '../coworking.models'; +import { ProfileService } from '../../profile/profile.service'; +import { Profile } from '../../models.module'; +import { RoomReservationWidgetComponent } from '../widgets/room-reservation-table/room-reservation-table.widget'; + +@Injectable({ + providedIn: 'root' +}) +export class ReservationTableService { + private selectedDateSubject = new BehaviorSubject(''); + selectedDate$ = this.selectedDateSubject.asObservable(); + private profile: Profile | undefined; + private profileSubscription!: Subscription; + + private EndingOperationalHour: number = 17; + + static readonly MAX_RESERVATION_CELL_LENGTH: number = 4; // rule for how long a reservation can be consecutively + + static readonly CellEnum = { + AVAILABLE: 0, + BOOKED: 1, + RESERVING: 2, + UNAVAILABLE: 3, + SUBJECT_RESERVATION: 4 + } as const; + + //Add table cell states here + static readonly CellPropertyMap: TablePropertyMap = { + [ReservationTableService.CellEnum.AVAILABLE]: { + backgroundColor: '#03691e', + isDisabled: false + }, + [ReservationTableService.CellEnum.BOOKED]: { + backgroundColor: 'red', + isDisabled: true + }, + [ReservationTableService.CellEnum.RESERVING]: { + backgroundColor: 'orange', + isDisabled: false + }, + [ReservationTableService.CellEnum.UNAVAILABLE]: { + backgroundColor: '#4d4d4d', + isDisabled: true + }, + [ReservationTableService.CellEnum.SUBJECT_RESERVATION]: { + backgroundColor: '#3479be', + isDisabled: true + } + }; + + constructor( + private http: HttpClient, + protected profileSvc: ProfileService + ) { + this.profileSubscription = this.profileSvc.profile$.subscribe( + (profile) => (this.profile = profile) + ); + } + + setSelectedDate(date: string) { + this.selectedDateSubject.next(date); + } + + //TODO Change route from ISO String to date object + getReservationsForRoomsByDate(date: Date): Observable { + let params = new HttpParams().set('date', date.toISOString()); + return this.http.get( + `/api/coworking/room-reservation/`, + { params } + ); + } + + draftReservation( + reservationsMap: Record, + operationStart: Date + ): Observable { + const selectedRoom: { room: string; availability: number[] } | null = + this._findSelectedRoom(reservationsMap); + + if (!selectedRoom) throw new Error('No room selected'); + const reservationRequest: ReservationRequest = this._makeReservationRequest( + selectedRoom!, + operationStart + ); + return this.makeDraftReservation(reservationRequest); + } + + //TODO Draft Reservation method + makeDraftReservation( + reservationRequest: ReservationRequest + ): Observable { + return this.http.post( + `/api/coworking/reservation`, + reservationRequest + ); + } + + /** + * Deselects a cell in the reservations map and updates selected cells. + * + * @param {string} key - The key representing the room in the reservations map. + * @param {number} index - The index representing the time slot in the reservations map. + * @returns {void} The method does not return a value. + * @public This method is intended for internal use. + */ + public deselectCell( + key: string, + index: number, + tableWidget: RoomReservationWidgetComponent + ): void { + tableWidget.setSlotAvailable(key, index); + + tableWidget.selectedCells = tableWidget.selectedCells.filter( + (cell) => !(cell.key === key && cell.index === index) + ); + + const isAllAdjacent = this._areAllAdjacent(tableWidget.selectedCells); + if (!isAllAdjacent) { + tableWidget.selectedCells = this._updateAdjacentCells(index, tableWidget); + } + } + + /** + * Selects a cell in the reservations map and updates selected cells. + * + * @param {string} key - The key representing the room in the reservations map. + * @param {number} index - The index representing the time slot in the reservations map. + * @returns {void} The method does not return a value. + * @public This method is intended for internal use. + */ + + public selectCell( + key: string, + index: number, + tableWidget: RoomReservationWidgetComponent + ): void { + const isAdjacentToAny = tableWidget.selectedCells.some( + (cell: TableCell) => { + return Math.abs(index - cell.index) <= 1 && key === cell.key; + } + ); + + if ( + isAdjacentToAny && + tableWidget.selectedCells.length < + ReservationTableService.MAX_RESERVATION_CELL_LENGTH + ) { + // If adjacent and within the maximum reservation length, select the cell + tableWidget.setSlotReserving(key, index); + + tableWidget.selectedCells.push({ key, index }); + } else if ( + tableWidget.selectedCells.length >= + ReservationTableService.MAX_RESERVATION_CELL_LENGTH + ) { + // If the maximum reservation length is exceeded, deselect all cells + this._setAllAvailable(tableWidget); + tableWidget.selectedCells = [{ key, index }]; // resetting selected cells to lone cell + tableWidget.setSlotReserving(key, index); + } else { + // If not adjacent to any selected cells, deselect all and select the new cell + this._setAllAvailable(tableWidget); + tableWidget.selectedCells = [{ key, index }]; // resetting selected cells to lone cell + tableWidget.setSlotReserving(key, index); + } + } + + _findSelectedRoom( + reservationsMap: Record + ): { room: string; availability: number[] } | null { + //- Finding the room with the selected cells (assuming only 1 row) + const result = Object.entries(reservationsMap).find( + ([id, availability]) => { + return availability.includes( + ReservationTableService.CellEnum.RESERVING + ); + } + ); + return result ? { room: result[0], availability: result[1] } : null; + } + + _makeReservationRequest( + selectedRoom: { room: string; availability: number[] }, + operationStart: Date + ): ReservationRequest { + const minIndex = selectedRoom?.availability.indexOf( + ReservationTableService.CellEnum.RESERVING + ); + const maxIndex = selectedRoom?.availability.lastIndexOf( + ReservationTableService.CellEnum.RESERVING + ); + const thirtyMinutes = 30 * 60 * 1000; + const startDateTime = new Date( + operationStart.getTime() + thirtyMinutes * minIndex + ); + + const endDateTime = new Date( + operationStart.getTime() + thirtyMinutes * (maxIndex + 1) + ); + + return { + users: [this.profile!], + seats: [], + room: { id: selectedRoom!.room }, + start: startDateTime, + end: endDateTime + }; + } + + /** + * Makes all selected cells Available. + * @param tableWidget RoomReservationWidgetComponent + * @returns void + * @private This method is intended for internal use. + * + */ + private _setAllAvailable(tableWidget: RoomReservationWidgetComponent): void { + tableWidget.selectedCells.forEach((cell: TableCell) => { + tableWidget.setSlotAvailable(cell.key, cell.index); + }); + } + + /** + * Checks if all currently selected cells are adjacent to each other. + * + * @returns {boolean} True if all selected cells are adjacent, false otherwise. + * @private This method is intended for internal use. + */ + + private _areAllAdjacent(selectedCells: TableCell[]): boolean { + return selectedCells.every((cell: TableCell, i) => { + if (i < selectedCells.length - 1) { + const nextCell = selectedCells[i + 1]; + return Math.abs(cell.index - nextCell.index) <= 1; // Check if the next cell is adjacent + } + return true; // Always return true for the last element + }); + } + + /** + * Updates adjacent cells based on the index of the selected cell. + * + * @param {number} index - The index representing the time slot in the reservations map. + * @returns {void} The method does not return a value. + * @private This method is intended for internal use. + */ + + private _updateAdjacentCells( + index: number, + tableWidget: RoomReservationWidgetComponent + ): TableCell[] { + // count if there are more cells on the left or on the right + const leftFrom = this._countIfOnLeft(index, tableWidget.selectedCells); + const rightFrom = tableWidget.selectedCells.length - leftFrom; // right and left counts are disjoint + return this._filterCellsBasedOnIndex( + tableWidget, + index, + leftFrom < rightFrom + ); + } + + /** + * Filters selected cells based on their index relative to a given index. + * + * @param tableWidget The RoomReservationWidgetComponent instance. + * @param index The index to compare against. + * @param filterBefore If true, filters out cells before the index; otherwise, filters out cells after the index. + */ + private _filterCellsBasedOnIndex( + tableWidget: RoomReservationWidgetComponent, + index: number, + filterBefore: boolean + ): TableCell[] { + return ( + tableWidget.selectedCells.filter((cell) => { + if (filterBefore && cell.index < index) { + tableWidget.setSlotAvailable(cell.key, cell.index); + return false; + } else if (!filterBefore && cell.index > index) { + tableWidget.reservationsMap[cell.key][cell.index] = + ReservationTableService.CellEnum.AVAILABLE; + return false; + } + return true; + }) ?? [] + ); + } + + /** + * Counts the number of cells on the left of the selected cell. + * @param index number + * @param selectedCells TableCell[] + * @returns number + * @private This method is intended for internal use. + */ + + private _countIfOnLeft(index: number, selectedCells: TableCell[]): number { + return selectedCells.reduce( + (count, cell) => (cell.index < index ? (count += 1) : count), + 0 + ); + } + + setMaxDate(): Date { + let result = new Date(); + result.setDate(result.getDate() + 7); + return result; + } + + setMinDate(): Date { + let result = new Date(); + if (result.getHours() >= this.EndingOperationalHour) { + result.setDate(result.getDate() + 1); + } + return result; + } + + /** + * Formats a date object into a string of the format 'HH:MMAM/PM'. + * + * @private + * @param {Date} date - The date object to be formatted. + * @returns {string} The formatted time string in 'HH:MMAM/PM' format. + */ + private formatAMPM(date: Date): string { + let hours = date.getHours(); + let minutes = date.getMinutes(); + const ampm = hours >= 12 ? 'PM' : 'AM'; + hours = hours % 12; + hours = hours ? hours : 12; // the hour '0' should be '12' + const minutesStr = minutes < 10 ? '0' + minutes : minutes.toString(); + return `${hours}:${minutesStr}${ampm}`; + } + + /** + * Generates time slots between two dates in increments of thirty minutes, formatted as 'HH:MMA/PM
to
HH:MMPM'. + * + * @private + * @param {Date} start - The start date and time for generating time slots. + * @param {Date} end - The end date and time for the time slots. + * @param {number} slots - The number of slots to generate. + * @returns {string[]} An array of strings representing the time slots in 'HH:MMA/PM
to
HH:MMPM' format. + */ + generateTimeSlots(start: Date, end: Date, slots: number): string[] { + const timeSlots = []; + const ThirtyMinutes = 30 * 60000; // Thirty minutes in milliseconds + while (start < end) { + let thirtyMinutesLater = new Date(start.getTime() + ThirtyMinutes); + timeSlots.push( + `${this.formatAMPM(start)}
to
${this.formatAMPM( + thirtyMinutesLater + )}` + ); + start = thirtyMinutesLater; + } + return timeSlots; + } +} diff --git a/frontend/src/app/coworking/room-reservation/room-reservation.service.spec.ts b/frontend/src/app/coworking/room-reservation/room-reservation.service.spec.ts new file mode 100644 index 000000000..085000adb --- /dev/null +++ b/frontend/src/app/coworking/room-reservation/room-reservation.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { RoomReservationService } from './room-reservation.service'; + +describe('RoomReservationService', () => { + let service: RoomReservationService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(RoomReservationService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/coworking/room-reservation/room-reservation.service.ts b/frontend/src/app/coworking/room-reservation/room-reservation.service.ts new file mode 100644 index 000000000..db9378d57 --- /dev/null +++ b/frontend/src/app/coworking/room-reservation/room-reservation.service.ts @@ -0,0 +1,114 @@ +/** + * The Room Reservation Service abstracts HTTP requests to the backend + * from the components. + * + * @author Aarjav Jain, John Schachte, Nick Wherthey, Yuvraj Jain + * @copyright 2023 + * @license MIT + */ + +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { catchError, map, Observable, of } from 'rxjs'; +import { + parseReservationJSON, + Reservation, + ReservationJSON +} from '../coworking.models'; +import { ReservationService } from '../reservation/reservation.service'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { RxUpcomingReservation } from '../rx-coworking-status'; + +@Injectable({ + providedIn: 'root' +}) +export class RoomReservationService extends ReservationService { + private upcomingReservations: RxUpcomingReservation = + new RxUpcomingReservation(); + public upcomingReservations$: Observable = + this.upcomingReservations.value$; + public findActiveReservationPredicate: (r: Reservation) => boolean = ( + r: Reservation + ) => { + let now = new Date(); + const activeStates = ['CONFIRMED', 'CHECKED_IN']; + return r.start <= now && r.end > now && activeStates.includes(r.state); + }; + + constructor(http: HttpClient) { + super(http); + } + + /** Returns all room reservations from the backend database table using the backend HTTP get request. + * @returns {Observable} + */ + + getReservationsByState(state: string): Observable { + // Create HttpParams with the state + let params = new HttpParams().set('state', state); + return this.http + .get('/api/coworking/room-reservations/', { + params + }) + .pipe(map((reservations) => reservations.map(parseReservationJSON))); + } + + checkin(reservation: Reservation): Observable { + let endpoint = `/api/coworking/reservation/${reservation.id}`; + let payload = { id: reservation.id, state: 'CHECKED_IN' }; + return this.http + .put(endpoint, payload) + .pipe(map(parseReservationJSON)); + } + + deleteRoomReservation(reservation: Reservation): Observable { + return this.http.delete( + `/api/coworking/reservation/${reservation.id}` + ); + } + + getNumHoursStudyRoomReservations(): Observable { + return this.http.get('/api/coworking/user-reservations/'); + } + + /** + * Polls for upcoming room reservations with a 'CONFIRMED' state that are not currently active. + * + * This method fetches reservations and filters them to find upcoming reservations based on a specific predicate. + * The predicate checks that the reservation is not active and that it has a defined room. + * In case of an error while fetching reservations, it displays an error message using `MatSnackBar`. + * + * @param {MatSnackBar} snackBar - The MatSnackBar service used to display notifications or error messages. + * + * @example + * pollUpcomingRoomReservation(this.snackBar); + * + * @remarks + * This method utilizes RxJS operators to process the stream of reservations. The `map` operator is used to filter + * reservations based on the provided predicate. The `catchError` operator handles any errors during the fetching process, + * displaying an error message and logging the error to the console. + * + * @returns {void} - This method does not return a value; it sets the upcoming reservations in a state management variable. + */ + pollUpcomingRoomReservation(snackBar: MatSnackBar) { + // predicate to determine if this is a non active upcoming room reservation + const isUpcomingRoomReservation = (r: Reservation) => + !this.findActiveReservationPredicate(r) && !!r && !!r.room; + + this.getReservationsByState('CONFIRMED') + .pipe( + map((reservations) => + reservations.filter((r) => isUpcomingRoomReservation(r)) + ), + catchError((err: Error) => { + const message = 'Error while fetching upcoming reservations.'; + snackBar.open(message, '', { duration: 8000 }); + console.error(err); + return of([]); + }) + ) + .subscribe((upcomingRoomReservations) => + this.upcomingReservations.set(upcomingRoomReservations) + ); + } +} diff --git a/frontend/src/app/coworking/rx-coworking-status.ts b/frontend/src/app/coworking/rx-coworking-status.ts index f4299e643..3952ef1e5 100644 --- a/frontend/src/app/coworking/rx-coworking-status.ts +++ b/frontend/src/app/coworking/rx-coworking-status.ts @@ -1,4 +1,12 @@ +/** + * @author Kris Jordan, John Schachte + * @copyright 2023 + * @license MIT + */ + import { RxObject } from '../rx-object'; import { CoworkingStatus, Reservation } from './coworking.models'; export class RxCoworkingStatus extends RxObject {} + +export class RxUpcomingReservation extends RxObject {} diff --git a/frontend/src/app/coworking/widgets/coworking-reservation-card/coworking-reservation-card.css b/frontend/src/app/coworking/widgets/coworking-reservation-card/coworking-reservation-card.css index 189d2420c..747f37d5d 100644 --- a/frontend/src/app/coworking/widgets/coworking-reservation-card/coworking-reservation-card.css +++ b/frontend/src/app/coworking/widgets/coworking-reservation-card/coworking-reservation-card.css @@ -1,30 +1,53 @@ .mat-mdc-card { - max-width: 640px; - margin: 0; + max-width: 640px; + margin: 0; } .mat-mdc-card-header { - margin-bottom: 16px; + margin-bottom: 0px; + padding: 0px; } .mat-mdc-card-actions { - margin-top: 16px; + margin-top: 0px; + padding: 0px; } .mat-divider { - margin: 1em 0; + margin: 0.7em 0; + padding: 0px; } h3 { - font-size: 18px; - margin-bottom: 0px; + font-size: 18px; + margin-bottom: 0px; + padding: 0px; + margin-top: 0px; } -h3>label { - width: 64px; - display: inline-block; +h3 > label { + width: 64px; + display: inline-block; + padding: 0px; } p { - margin-left: 64px; -} \ No newline at end of file + margin-left: 64px; + padding: 0px; +} + +.divider { + margin: 0.5em; +} + +@media only screen and (max-device-width: 730px) { + h3 > * { + font-size: 0.8em; + } +} + +.header-container { + display: flex; + justify-content: space-between; + align-items: center; /* This ensures vertical alignment if needed */ +} diff --git a/frontend/src/app/coworking/widgets/coworking-reservation-card/coworking-reservation-card.html b/frontend/src/app/coworking/widgets/coworking-reservation-card/coworking-reservation-card.html index 6d0d1bfd4..8037c40c2 100644 --- a/frontend/src/app/coworking/widgets/coworking-reservation-card/coworking-reservation-card.html +++ b/frontend/src/app/coworking/widgets/coworking-reservation-card/coworking-reservation-card.html @@ -1,112 +1,155 @@ - - - Confirm Reservation Details - + + + + Confirm Reservation Details + + + + See the XL Ambassador staffing the front desk to check-in. + + - - Reservation #{{ reservation.id }} Confirmed - See the XL Ambassador staffing the front desk to - check-in. - + + You're Checked In! + + See the XL Ambassador if you need anything. When you're done, just + press the Check Out button or check out with the XL Ambassador. +
+ Changing seats? Please check out and check back in. +
+
+
- - You're Checked In! - See the XL Ambassador if you need anything. When you're done, just - press the Check Out button or check out with the XL Ambassador. -

- Changing seats? Please check out and check back in.
-
+ + Thanks for visiting! + You're all checked out. + - - Thanks for visiting! - You're all checked out. - + + Reservation Cancelled + + +
+
- - Reservation Cancelled - - -
+
- -

- - {{ reservation.users[0].first_name }} - {{ reservation.users[0].last_name }} -

- +
+

+ + {{ reservation.start | date: 'shortTime' }} to + {{ reservation.end | date: 'shortTime' }} + {{ ', ' }} + {{ reservation.start | date: 'dd MMM yyyy' }} +

-

- - {{ reservation.start | date: 'shortTime' }} until - {{ reservation.end | date: 'shortTime' }} -

+ + + +

+ {{ reservation.seats[0].title }} +

+ +
-

- Check-in between {{ reservation.start | date: 'shortTime' }} and - {{ checkinDeadline(reservation.start) | date: 'shortTime' }} to avoid - cancellation. -

- +
+ +

+ + {{ reservation.room ? reservation.room.id : 'The XL Colab in Sitterson 156' }} +

-

- {{ reservation.seats[0].title }} -

- + + + + +
+
-

The XL Colab in Sitterson 156

+ - - - -   - - +
+ + + +   + + - - - + + + + +   + + + - - - + + + - - - + + + - - - - + + + + +
diff --git a/frontend/src/app/coworking/widgets/coworking-reservation-card/coworking-reservation-card.ts b/frontend/src/app/coworking/widgets/coworking-reservation-card/coworking-reservation-card.ts index 0c66a9a9d..c27b00f16 100644 --- a/frontend/src/app/coworking/widgets/coworking-reservation-card/coworking-reservation-card.ts +++ b/frontend/src/app/coworking/widgets/coworking-reservation-card/coworking-reservation-card.ts @@ -1,8 +1,16 @@ +/** + * @author John Schachte + * @copyright 2023 + * @license MIT + */ + import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; -import { Reservation } from '../../coworking.models'; -import { Observable, map, mergeMap, timer } from 'rxjs'; +import { Reservation } from 'src/app/coworking/coworking.models'; +import { Observable, map, timer } from 'rxjs'; import { Router } from '@angular/router'; -import { ReservationService } from '../../reservation/reservation.service'; +import { RoomReservationService } from '../../room-reservation/room-reservation.service'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { CoworkingService } from '../../coworking.service'; @Component({ selector: 'coworking-reservation-card', @@ -11,14 +19,32 @@ import { ReservationService } from '../../reservation/reservation.service'; }) export class CoworkingReservationCard implements OnInit { @Input() reservation!: Reservation; + @Output() updateReservationsList = new EventEmitter(); + @Output() isConfirmed = new EventEmitter(); + @Output() updateActiveReservation = new EventEmitter(); + @Output() reloadCoworkingHome = new EventEmitter(); public draftConfirmationDeadline$!: Observable; + isCancelExpanded$: Observable; constructor( public router: Router, - public reservationService: ReservationService - ) {} + public reservationService: RoomReservationService, + protected snackBar: MatSnackBar, + public coworkingService: CoworkingService + ) { + this.isCancelExpanded$ = + this.coworkingService.isCancelExpanded.asObservable(); + } + /** + * A lifecycle hook that is called after Angular has initialized all data-bound properties of a directive. + * + * Use this hook to initialize the directive or component. This is the right place to fetch data from a server, + * set up any local state, or perform operations that need to be executed only once when the component is instantiated. + * + * @returns {void} - This method does not return a value. + */ ngOnInit(): void { this.draftConfirmationDeadline$ = this.initDraftConfirmationDeadline(); } @@ -28,23 +54,75 @@ export class CoworkingReservationCard implements OnInit { } cancel() { - this.reservationService.cancel(this.reservation).subscribe(); + this.reservationService.deleteRoomReservation(this.reservation).subscribe({ + next: () => { + this.refreshCoworkingHome(); + }, + error: (error: Error) => { + this.snackBar.open( + 'Error: Issue cancelling reservation. Please see CSXL Ambassador for assistance.', + '', + { duration: 8000 } + ); + console.error(error.message); + } + }); } confirm() { - this.reservationService.confirm(this.reservation).subscribe(); + this.isConfirmed.emit(true); + this.reservationService.confirm(this.reservation).subscribe({ + next: () => { + this.refreshCoworkingHome(); + // this.router.navigateByUrl('/coworking'); + }, + error: (error: Error) => { + this.snackBar.open( + 'Error: Issue confirming reservation. Please see CSXL Ambassador for assistance.', + '', + { duration: 8000 } + ); + console.error(error.message); + } + }); } checkout() { - this.reservationService.checkout(this.reservation).subscribe(); + this.reservationService.checkout(this.reservation).subscribe({ + next: () => this.refreshCoworkingHome(), + error: (error: Error) => { + this.snackBar.open( + 'Error: Issue checking out reservation. Please see CSXL Ambassador for assistance.', + '', + { duration: 8000 } + ); + console.error(error.message); + } + }); + } + + checkin(): void { + this.reservationService.checkin(this.reservation).subscribe({ + next: () => { + this.refreshCoworkingHome(); + }, + error: (error: Error) => { + this.snackBar.open( + 'Error: Issue cancelling reservation. Please see CSXL Ambassador for assistance.', + '', + { duration: 8000 } + ); + } + }); } private initDraftConfirmationDeadline(): Observable { const fiveMinutes = 5 /* minutes */ * 60 /* seconds */ * 1000; /* milliseconds */ - const reservationDraftDeadline = (reservation: Reservation) => - reservation.created_at.getTime() + fiveMinutes; + const reservationDraftDeadline = (reservation: Reservation) => { + return new Date(reservation.created_at).getTime() + fiveMinutes; + }; const deadlineString = (deadline: number): string => { const now = new Date().getTime(); @@ -65,4 +143,42 @@ export class CoworkingReservationCard implements OnInit { map(deadlineString) ); } + + refreshCoworkingHome(): void { + this.reloadCoworkingHome.emit(); + this.router.navigateByUrl('/coworking'); + } + + checkCheckinAllowed(): boolean { + let now = new Date(); + return ( + new Date(this.reservation!.start) <= now && + now <= new Date(this.reservation!.end) + ); + } + + toggleCancelExpansion(): void { + this.coworkingService.toggleCancelExpansion(); + } + + /** + * Evaluates if the cancel operation is expanded or if check-in is allowed. + * + * Combines the observable `isCancelExpanded$` with the result of `checkCheckinAllowed()` to + * determine the UI state. It uses RxJS's `map` to emit true if either condition is met: the + * cancel operation is expanded (`isCancelExpanded$` is true) or check-in is allowed (`checkCheckinAllowed()` + * returns true). + * + * @returns {Observable} Observable that emits true if either condition is true, otherwise false. + * + * Usage: + * Can be used in Angular templates with async pipe for conditional UI rendering: + * `...` + */ + isExpandedOrAllowCheckin(): Observable { + return this.isCancelExpanded$.pipe( + map(isCancelExpanded => isCancelExpanded || this.checkCheckinAllowed()) + ); + } + } diff --git a/frontend/src/app/coworking/widgets/date-selector/date-selector.widget.css b/frontend/src/app/coworking/widgets/date-selector/date-selector.widget.css new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/app/coworking/widgets/date-selector/date-selector.widget.html b/frontend/src/app/coworking/widgets/date-selector/date-selector.widget.html new file mode 100644 index 000000000..e3a11a851 --- /dev/null +++ b/frontend/src/app/coworking/widgets/date-selector/date-selector.widget.html @@ -0,0 +1,11 @@ + + + + + diff --git a/frontend/src/app/coworking/widgets/date-selector/date-selector.widget.ts b/frontend/src/app/coworking/widgets/date-selector/date-selector.widget.ts new file mode 100644 index 000000000..ee2ab6347 --- /dev/null +++ b/frontend/src/app/coworking/widgets/date-selector/date-selector.widget.ts @@ -0,0 +1,41 @@ +/** + * The date selector widget that abstracts date selection. + * + * @author Aarjav Jain, John Schachte + * @copyright 2023 + * @license MIT + */ + +import { Component, EventEmitter, Output } from '@angular/core'; +import { MatDatepickerInputEvent } from '@angular/material/datepicker'; +import { ReservationTableService } from '../../room-reservation/reservation-table.service'; + +/** + * @title Date Selector + */ +@Component({ + selector: 'date-selector', + templateUrl: './date-selector.widget.html', + styleUrls: ['./date-selector.widget.css'] +}) +export class DateSelector { + @Output() dateSelected = new EventEmitter(); + minDate: Date; + maxDate: Date; + + constructor(private reservationTableService: ReservationTableService) { + this.minDate = this.reservationTableService.setMinDate(); + this.maxDate = this.reservationTableService.setMaxDate(); + } + + onDateChange(event: MatDatepickerInputEvent) { + const selectedDate: string = this.formatDate(event.value!); + this.reservationTableService.setSelectedDate(selectedDate); + } + + private formatDate(date: Date): string { + // Format the date as needed, you might want to use a library like 'date-fns' or 'moment' + // For simplicity, this example uses the default 'toLocaleDateString' method + return date.toLocaleDateString(); // Adjust this based on your actual formatting requirements + } +} diff --git a/frontend/src/app/coworking/widgets/dropin-availability-card/dropin-availability-card.widget.css b/frontend/src/app/coworking/widgets/dropin-availability-card/dropin-availability-card.widget.css index c4b58c9fb..276b425cc 100644 --- a/frontend/src/app/coworking/widgets/dropin-availability-card/dropin-availability-card.widget.css +++ b/frontend/src/app/coworking/widgets/dropin-availability-card/dropin-availability-card.widget.css @@ -1,94 +1,95 @@ /* Begin Action List Styling */ .mdc-list-item.mdc-list-item--with-trailing-meta .mdc-list-item__end { - height: 100%; - vertical-align: middle; - font-size: 32px; + height: 100%; + vertical-align: middle; + font-size: 32px; } -.mdc-list-item--with-trailing-meta.mdc-list-item--with-two-lines .mdc-list-item__end::before { - height: 40px; +.mdc-list-item--with-trailing-meta.mdc-list-item--with-two-lines + .mdc-list-item__end::before { + height: 40px; } .mdc-list-item--with-leading-icon .mdc-list-item__start { - font-size: 24px; - background: #4786C6; - width: 36px; - height: 50%; - text-align: center; - margin-bottom: 0; - margin-left: 8px; - margin-right: 8px; - padding-bottom: 0; - line-height: 36px; - border-radius: 100%; - color: white; + font-size: 24px; + background: #4786c6; + width: 36px; + height: 50%; + text-align: center; + margin-bottom: 0; + margin-left: 8px; + margin-right: 8px; + padding-bottom: 0; + line-height: 36px; + border-radius: 100%; + color: white; } .mdc-list-item--with-leading-icon .mdc-list-item__start.unavailable { - background: #612d23; + background: #612d23; } .mdc-list-item--with-leading-icon .mdc-list-item__start.upcoming { - background: #234261; + background: #234261; } /* End Action List Styling */ .mat-mdc-card { - margin: 0 !important; - max-width: 640px; + margin: 0 !important; + max-width: 640px; } .dropin-header { - display: flex; - margin-bottom: 0.5rem; - align-items: center; - justify-content: space-between; + display: flex; + margin-bottom: 0.5rem; + align-items: center; + justify-content: space-between; } .mdc-list-group__subheader { - margin: 0; + margin: 0; } .mdc-list-item { - padding: 0 !important; - display: flex; - align-items: baseline; + padding: 0 !important; + display: flex; + align-items: baseline; } .mdc-list-group__subheader { - margin: 0; + margin: 0; } .mat-mdc-list-base { - padding: 0; + padding: 0; } .mat-expansion-panel-header-description { - align-items: right; - flex: 0 0 auto; + align-items: right; + flex: 0 0 auto; } .mat-expansion-panel-header-description button { - z-index: 10 !important; + z-index: 10 !important; } mat-expansion-panel { - margin-top: 8px; + margin-top: 8px; } .mat-action-row { - align-items: center; + align-items: center; } mat-checkbox { - flex: 1; + flex: 1; } .bolded { - font-weight: 500; + font-weight: 500; } a { - text-decoration: none; -} \ No newline at end of file + text-decoration: none; +} diff --git a/frontend/src/app/coworking/widgets/operating-hours-dialog/operating-hours-dialog.widget.css b/frontend/src/app/coworking/widgets/operating-hours-dialog/operating-hours-dialog.widget.css index 398a74f4e..5c4a94198 100644 --- a/frontend/src/app/coworking/widgets/operating-hours-dialog/operating-hours-dialog.widget.css +++ b/frontend/src/app/coworking/widgets/operating-hours-dialog/operating-hours-dialog.widget.css @@ -1,10 +1,9 @@ - .dialog { - padding: 24px; + padding: 24px; } @media (prefers-color-scheme: dark) { - .dialog { - color: white; - } -} \ No newline at end of file + .dialog { + color: white; + } +} diff --git a/frontend/src/app/coworking/widgets/operating-hours-dialog/operating-hours-dialog.widget.html b/frontend/src/app/coworking/widgets/operating-hours-dialog/operating-hours-dialog.widget.html index 0b6d63cf8..20c54f0b3 100644 --- a/frontend/src/app/coworking/widgets/operating-hours-dialog/operating-hours-dialog.widget.html +++ b/frontend/src/app/coworking/widgets/operating-hours-dialog/operating-hours-dialog.widget.html @@ -2,9 +2,8 @@

Upcoming Hours

  • - {{ hours.start | date: 'EEEE M/d' }} - from {{ hours.start | date: 'h:mma' | lowercase }} to - {{ hours.end | date: 'h:mma' | lowercase }} + {{ hours.start | date: 'EEEE M/d' }} from {{ hours.start | date: 'h:mma' | + lowercase }} to {{ hours.end | date: 'h:mma' | lowercase }}
diff --git a/frontend/src/app/coworking/widgets/operating-hours-panel/operating-hours-panel.widget.css b/frontend/src/app/coworking/widgets/operating-hours-panel/operating-hours-panel.widget.css index 64fbdade0..9782d08da 100644 --- a/frontend/src/app/coworking/widgets/operating-hours-panel/operating-hours-panel.widget.css +++ b/frontend/src/app/coworking/widgets/operating-hours-panel/operating-hours-panel.widget.css @@ -1,47 +1,47 @@ .header { - padding: 16px; + padding: 16px; } .hours-title { - font-size: 20px; - font-weight: 500; - margin: 0; - letter-spacing: 0.25px; - line-height: 32px; - text-decoration-color: rgba(0, 0, 0, 0.87); - text-decoration-line: none; - text-decoration-style: solid; - text-decoration-thickness: auto; - -webkit-font-smoothing: antialiased; + font-size: 20px; + font-weight: 500; + margin: 0; + letter-spacing: 0.25px; + line-height: 32px; + text-decoration-color: rgba(0, 0, 0, 0.87); + text-decoration-line: none; + text-decoration-style: solid; + text-decoration-thickness: auto; + -webkit-font-smoothing: antialiased; } .badge { - display: inline-block; - border-radius: 20px; - margin-right: 8px; - padding: 0 16px; - color: white; + display: inline-block; + border-radius: 20px; + margin-right: 8px; + padding: 0 16px; + color: white; } .open .badge { - background: #256123; + background: #256123; } .closed .badge { - background: #612d23; + background: #612d23; } .mat-mdc-card { - max-width: 640px; - margin: 0; + max-width: 640px; + margin: 0; } .header-row { - display: flex; - flex-direction: row; - width: 100%; + display: flex; + flex-direction: row; + width: 100%; } #hours-button { - margin-left: auto; -} \ No newline at end of file + margin-left: auto; +} diff --git a/frontend/src/app/coworking/widgets/operating-hours-panel/operating-hours-panel.widget.html b/frontend/src/app/coworking/widgets/operating-hours-panel/operating-hours-panel.widget.html index f3dc05b4a..337ff1eb2 100644 --- a/frontend/src/app/coworking/widgets/operating-hours-panel/operating-hours-panel.widget.html +++ b/frontend/src/app/coworking/widgets/operating-hours-panel/operating-hours-panel.widget.html @@ -5,8 +5,7 @@ *ngIf="openOperatingHours; else closed">
Open - until - {{ openOperatingHours!.end | date: 'h:mma' | lowercase }} + until {{ openOperatingHours!.end | date: 'h:mma' | lowercase }} diff --git a/frontend/src/app/coworking/widgets/room-reservation-table/room-reservation-table.widget.css b/frontend/src/app/coworking/widgets/room-reservation-table/room-reservation-table.widget.css new file mode 100644 index 000000000..d1511b90d --- /dev/null +++ b/frontend/src/app/coworking/widgets/room-reservation-table/room-reservation-table.widget.css @@ -0,0 +1,39 @@ +/* room-reservation-table.widget.css */ + +table { + border-collapse: collapse; + width: 99%; + margin-left: 10px; +} + +th, +td { + border: 1px solid #ddd; + padding: 10px; + text-align: center; +} + +th { + height: 40px; /* Set the height for header rows */ + width: 30px; +} + +td { + height: 40px; /* Set the height for data rows */ + width: 30px; +} + +.time-slot { + font-size: 12px; /* Set the font size for time slot headings */ +} + +.button { + margin-top: 20px; /* Add top margin for spacing */ + font-size: 16px; /* Increase font size */ + padding: 10px 20px; /* Add padding */ + margin-left: 10px; +} + +.divider { + margin-bottom: 1em; +} diff --git a/frontend/src/app/coworking/widgets/room-reservation-table/room-reservation-table.widget.html b/frontend/src/app/coworking/widgets/room-reservation-table/room-reservation-table.widget.html new file mode 100644 index 000000000..72baed4c8 --- /dev/null +++ b/frontend/src/app/coworking/widgets/room-reservation-table/room-reservation-table.widget.html @@ -0,0 +1,26 @@ + + + + + + + + + + + +
{{ record.key }}
+
+ +
diff --git a/frontend/src/app/coworking/widgets/room-reservation-table/room-reservation-table.widget.ts b/frontend/src/app/coworking/widgets/room-reservation-table/room-reservation-table.widget.ts new file mode 100644 index 000000000..4c00e99f7 --- /dev/null +++ b/frontend/src/app/coworking/widgets/room-reservation-table/room-reservation-table.widget.ts @@ -0,0 +1,177 @@ +/** + * @author John Schachte, Aarjav Jain, Nick Wherthey + * @copyright 2023 + * @license MIT + */ + +import { Component } from '@angular/core'; +import { ReservationTableService } from '../../room-reservation/reservation-table.service'; +import { Subscription } from 'rxjs'; +import { Router } from '@angular/router'; +import { Reservation, TableCell } from 'src/app/coworking/coworking.models'; +import { RoomReservationService } from '../../room-reservation/room-reservation.service'; +import { MatSnackBar } from '@angular/material/snack-bar'; + +@Component({ + selector: 'room-reservation-table', + templateUrl: './room-reservation-table.widget.html', + styleUrls: ['./room-reservation-table.widget.css'] +}) +export class RoomReservationWidgetComponent { + timeSlots: string[] = []; + + //- Reservations Map + reservationsMap: Record = {}; + + //- Select Button enabled + selectButton: boolean = false; + + operationStart: Date = new Date(); + + //- Selected Date + selectedDate: string = ''; + // private subscription: Subscription; + Object: any; + subscription: Subscription; + cellPropertyMap = ReservationTableService.CellPropertyMap; + + snackBarOptions: Object = { + duration: 8000 + }; + + constructor( + protected reservationTableService: ReservationTableService, + private router: Router, + private roomReservationService: RoomReservationService, + protected snackBar: MatSnackBar + ) { + this.reservationTableService.setSelectedDate( + this.reservationTableService.setMinDate().toDateString() + ); + this.subscription = this.reservationTableService.selectedDate$.subscribe( + (selectedDate: string) => { + this.selectedDate = selectedDate; + this.getReservationsByDate(new Date(selectedDate)); + } + ); + } + + getReservationsByDate(date: Date) { + this.reservationTableService.getReservationsForRoomsByDate(date).subscribe( + (result) => { + this.reservationsMap = result.reserved_date_map; + let end = new Date(result.operating_hours_end); + this.operationStart = new Date(result.operating_hours_start); + let slots = result.number_of_time_slots; + + this.timeSlots = this.reservationTableService.generateTimeSlots( + this.operationStart, + end, + slots + ); + }, + (error: Error) => { + // Handle the error here + this.snackBar.open( + 'Error fetching reservations', + 'Close', + this.snackBarOptions + ); + console.error('Error fetching reservations:', error); + } + ); + } + + //- Array to store information about selected cells, where each element is an object + //- with 'key' representing the room number and 'index' representing the time interval. + selectedCells: TableCell[] = []; + + /** + * Toggles the color of a cell in the reservations map and manages selected cells. + * + * @param {string} key - The key representing the room in the reservations map. + * @param {number} index - The index representing the time slot in the reservations map. + * @returns {void} The method does not return a value. + */ + toggleCellColor(key: string, index: number): void { + const isSelected = + this.reservationsMap[key][index] === + ReservationTableService.CellEnum.RESERVING; + + if (isSelected) { + this.reservationTableService.deselectCell(key, index, this); + } else { + this.reservationTableService.selectCell(key, index, this); + } + + this.selectButtonToggle(); + } + + //- Check if at least one time slot selected + selectButtonToggle(): void { + this.selectButton = Object.values(this.reservationsMap).some( + (timeSlotsForRow) => + timeSlotsForRow.includes(ReservationTableService.CellEnum.RESERVING) + ); + } + + /** + * Initiates the process of drafting a reservation based on the current state + * of the reservations map and the selected date. + * + * @throws {Error} If there is an exception during the drafting process. + * + * @remarks + * The method calls the 'draftReservation' service method and handles the response: + * - If the reservation is successfully drafted, the user is navigated to the + * confirmation page with the reservation data. + * - If there is an error during the drafting process, the error is logged, and an + * alert with the error message is displayed to the user. + * + * @example + * ```typescript + * draftReservation(); + * ``` + */ + + draftReservation() { + const result = this.reservationTableService.draftReservation( + this.reservationsMap, + this.operationStart + ); + result.subscribe( + (reservation: Reservation) => { + // Navigate with the reservation data + this.router.navigateByUrl( + `/coworking/confirm-reservation/${reservation.id}` + ); + }, + + (error) => { + // Handle errors here + console.error('Error drafting reservation', error); + this.snackBar.open(error.error.message, 'Close', this.snackBarOptions); + } + ); + } + + /** + * Setter for the reservations map to set the state of a timeslot to reserving. + * @param key room id for reservationsMap. + * @param index index of the timeslot to change the state of. + */ + public setSlotReserving(key: string, index: number) { + this.reservationsMap[key][index] = + ReservationTableService.CellEnum.RESERVING; + } + + /** + * Setter for the reservations map to set the state of a timeslot to reserved. + * @param key room id for reservationsMap. + * @param index index of the timeslot to change the state of. + */ + public setSlotAvailable(key: string, index: number) { + this.reservationsMap[key][index] = + ReservationTableService.CellEnum.AVAILABLE; + } +} diff --git a/frontend/src/app/event/event-editor/event-editor.component.html b/frontend/src/app/event/event-editor/event-editor.component.html index d7a3117e1..58298b02f 100644 --- a/frontend/src/app/event/event-editor/event-editor.component.html +++ b/frontend/src/app/event/event-editor/event-editor.component.html @@ -10,60 +10,38 @@ Update Event - - - - Event Name - - + + + + Event Name + + - - - - + + + + - - - Location - - + + + Location + + - - - Description - - + + + Description + + - - - Registration Limit - - + + + Registration Limit + + - - - + + + - - - + + + - You do not have permission to view this page - + You do not have permission to view this page + \ No newline at end of file diff --git a/frontend/src/app/organization/widgets/organization-card/organization-card.widget.html b/frontend/src/app/organization/widgets/organization-card/organization-card.widget.html index 7d552405c..d90f62694 100644 --- a/frontend/src/app/organization/widgets/organization-card/organization-card.widget.html +++ b/frontend/src/app/organization/widgets/organization-card/organization-card.widget.html @@ -1,3 +1,5 @@ + + diff --git a/frontend/src/app/profile/profile-editor/profile-editor.component.html b/frontend/src/app/profile/profile-editor/profile-editor.component.html index 1c55c3268..e0dab8503 100644 --- a/frontend/src/app/profile/profile-editor/profile-editor.component.html +++ b/frontend/src/app/profile/profile-editor/profile-editor.component.html @@ -1,99 +1,70 @@
- - - Update your Profile - - - - Welcome to CSXL! Please verify and complete your profile - below. - - Once your profile is setup you will be able to take advantage of CSXL - initiatives such as enrolling in workshops, reserving a desk during - coworking hours, and more! - - - - - - First Name - - - - Last Name - - - - UNC Email - - Must provide a valid UNC email address. - - - Pronouns - - - - - - - + + + Update your Profile + + + + Welcome to CSXL! Please verify and complete your profile + below. + + Once your profile is setup you will be able to take advantage of CSXL + initiatives such as enrolling in workshops, reserving a desk during + coworking hours, and more! + + + + + + First Name + + + + Last Name + + + + UNC Email + + Must provide a valid UNC email address. + + + Pronouns + + + + + + +
-
- - - GitHub / - - {{ profile.github }} - - - - - -
- - - Link Your GitHub Account - - - - - +
+ + + GitHub / + + {{ profile.github }} + + + + + +
+ + + Link Your GitHub Account + + + + +
@@ -133,4 +104,4 @@ {{ token }}
- + \ No newline at end of file