Skip to content

Commit

Permalink
Create Walk-in Reservations for XL Members from Ambassador Welcome De…
Browse files Browse the repository at this point in the history
…sk (#313)

Ambassadors requested the ability to create a drop-in reservation for a community member showing up to the XL without having already created a reservation.

This commit adds a new area to the Ambassador Home Component that allows searching for a community member and registering an available seat for them. Since this is staff-only, the UX around pop-up notifications is just alerts for now. Future work could improve this to make use of snackbar or other material UX.

To implement this feature, a few other useful additions were made in other parts of the application, including:

Search for XL members by partial PID (with unit test coverage)
Get notified of changes from the UsersLookup widget so that components which use it can have callback methods (example use case shown in this widget: after a user is selected, we lookup available seats and show the control for selecting a seat type)
  • Loading branch information
KrisJordan authored Mar 4, 2024
1 parent 9a1f142 commit 5499238
Show file tree
Hide file tree
Showing 12 changed files with 203 additions and 23 deletions.
23 changes: 21 additions & 2 deletions backend/api/coworking/ambassador.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
from ..authentication import registered_user
from ...services.coworking.reservation import ReservationService
from ...models import User
from ...models.coworking import Reservation, ReservationPartial
from ...models.coworking import Reservation, ReservationPartial, ReservationRequest, ReservationState

__authors__ = ["Kris Jordan"]
__copyright__ = "Copyright 2023"
__copyright__ = "Copyright 2023 - 2024"
__license__ = "MIT"


Expand All @@ -36,3 +36,22 @@ def checkin_reservation(
) -> Reservation:
"""CheckIn a confirmed reservation."""
return reservation_svc.staff_checkin_reservation(subject, reservation)


@api.post("/reservation", tags=["Coworking"])
def create_walkin_reservation(
reservation_request: ReservationRequest,
subject: User = Depends(registered_user),
reservation_svc: ReservationService = Depends(),
) -> Reservation:
"""Create a walk-in reservation as an ambassador for a user showing up to the desk
without having drafted/confirmed a reservation of their own ahead of time."""
# TODO: The efficiency of this operation could be improved with a custom method, but since this
# happens at the speed of an ambassador manually checking someone in (and is the sequence of steps
# that normally take place otherwise), reusing existing methods here is fine for now.
reservation_draft = reservation_svc.draft_reservation(subject, reservation_request)
# Confirm the Draft Reservation
reservation_partial = ReservationPartial(id=reservation_draft.id, state=ReservationState.CONFIRMED)
reservation_confirmed = reservation_svc.change_reservation(subject, reservation_partial)
# Check Reservation In
return reservation_svc.staff_checkin_reservation(subject, reservation_confirmed)
3 changes: 2 additions & 1 deletion backend/services/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"""

from fastapi import Depends
from sqlalchemy import select, or_, func
from sqlalchemy import select, or_, func, cast, String
from sqlalchemy.orm import Session
from ..database import db_session
from ..models import User, UserDetails, Paginated, PaginationParams
Expand Down Expand Up @@ -84,6 +84,7 @@ def search(self, _subject: User, query: str) -> list[User]:
UserEntity.last_name.ilike(f"%{query}%"),
UserEntity.onyen.ilike(f"%{query}%"),
UserEntity.email.ilike(f"%{query}%"),
cast(UserEntity.pid, String).ilike(f"%{query}%")
)
statement = statement.where(criteria).limit(10)
entities = self._session.execute(statement).scalars()
Expand Down
11 changes: 11 additions & 0 deletions backend/test/services/user_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,17 @@ def test_search_no_match(user_svc: UserService):
assert len(users) == 0


def test_search_by_pid_does_not_exist(user_svc: UserService):
"""Test searching for a partial PID that does not exist."""
users = user_svc.search(ambassador, "123")
assert len(users) == 0

def test_search_by_pid_rhonda(user_svc: UserService):
"""Test searching for a partial PID that does exist."""
users = user_svc.search(ambassador, "999")
assert len(users) == 1
assert users[0] == root

def test_list(user_svc: UserService):
"""Test that a paginated list of users can be produced."""
pagination_params = PaginationParams(page=0, page_size=2, order_by="id", filter="")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { Component, inject, OnInit } from '@angular/core';
import { FormControl } from '@angular/forms';
import {
MatAutocompleteActivatedEvent,
MatAutocompleteSelectedEvent
} from '@angular/material/autocomplete';
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import {
ActivatedRoute,
ActivatedRouteSnapshot,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
.mat-mdc-card {
max-width: 100%;
max-width: 100%;
}

.mat-mdc-card-header {
margin-bottom: 16px;
}

.walkinReservation.mat-mdc-card-content:last-child {
padding-bottom: 0;
}

button {
margin-right: 1vw;
}
margin-right: 1vw;
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,29 @@
<div>
<mat-card class="content" appearance="outlined">
<mat-card-header>
<mat-card-title>Reserve a Drop-in at the Welcome Desk</mat-card-title>
<mat-card-subtitle
>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.</mat-card-subtitle
>
</mat-card-header>
<mat-card-content class="walkinReservation">
<user-lookup
label="Member Lookup"
[maxSelected]="1"
[users]="welcomeDeskReservationSelection"
(usersChanged)="onUsersChanged($event)"></user-lookup>
<ng-container *ngIf="welcomeDeskReservationSelection.length > 0">
<ng-container *ngIf="status$ | async as status">
<coworking-dropin-availability-card
[seat_availability]="status.seat_availability"
(seatsSelected)="onWalkinSeatSelection($event)" />
</ng-container>
</ng-container>
</mat-card-content>
</mat-card>
</div>
<div *ngIf="upcomingReservations$ | async as reservations">
<mat-card
class="content"
Expand All @@ -15,8 +41,8 @@
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>Name</th>
<td mat-cell *matCellDef="let reservation">
{{ reservation.users[0].first_name }}
{{ reservation.users[0].last_name }}
{{ reservation.users[0].first_name }} {{
reservation.users[0].last_name }}
</td>
</ng-container>
<ng-container matColumnDef="start">
Expand Down Expand Up @@ -87,8 +113,8 @@
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>Name</th>
<td mat-cell *matCellDef="let reservation">
{{ reservation.users[0].first_name }}
{{ reservation.users[0].last_name }}
{{ reservation.users[0].first_name }} {{
reservation.users[0].last_name }}
</td>
</ng-container>
<ng-container matColumnDef="start">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,26 @@
/**
* This component is the primary screen for ambassadors at the check-in desk.
*
* @author Kris Jordan <[email protected]>
* @copyright 2023 - 2024
* @license MIT
*/

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, mergeMap, tap, timer } from 'rxjs';
import { Reservation } from '../coworking.models';
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;

@Component({
selector: 'app-coworking-ambassador-home',
Expand All @@ -25,11 +41,17 @@ export class AmbassadorPageComponent implements OnInit, OnDestroy {
upcomingReservations$: Observable<Reservation[]>;
activeReservations$: Observable<Reservation[]>;

welcomeDeskReservationSelection: PublicProfile[] = [];
status$: Observable<CoworkingStatus>;

columnsToDisplay = ['id', 'name', 'seat', 'start', 'end', 'actions'];

private refreshSubscription!: Subscription;

constructor(public ambassadorService: AmbassadorService) {
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'))
Expand All @@ -39,15 +61,60 @@ export class AmbassadorPageComponent implements OnInit, OnDestroy {
reservations.filter((r) => r.state === 'CHECKED_IN')
)
);

this.status$ = coworkingService.status$;
}

ngOnInit(): void {
this.refreshSubscription = timer(0, 5000)
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);
}
});
}
}
}
27 changes: 27 additions & 0 deletions frontend/src/app/coworking/ambassador-home/ambassador.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@ import { Observable, map } from 'rxjs';
import {
Reservation,
ReservationJSON,
SeatAvailability,
parseReservationJSON
} from '../coworking.models';
import { HttpClient } from '@angular/common/http';
import { PublicProfile } from 'src/app/profile/profile.service';

const ONE_HOUR = 60 * 60 * 1000;

@Injectable({ providedIn: 'root' })
export class AmbassadorService {
Expand Down Expand Up @@ -64,4 +68,27 @@ export class AmbassadorService {
}
});
}

makeDropinReservation(
seatSelection: SeatAvailability[],
users: PublicProfile[]
) {
let start = seatSelection[0].availability[0].start;
let end = new Date(start.getTime() + 2 * ONE_HOUR);
let reservation = {
users: users,
seats: seatSelection.map((seatAvailability) => {
return { id: seatAvailability.id };
}),
start,
end
};

return this.http
.post<ReservationJSON>(
'/api/coworking/ambassador/reservation',
reservation
)
.pipe(map(parseReservationJSON));
}
}
13 changes: 12 additions & 1 deletion frontend/src/app/coworking/coworking.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ import { MatButtonModule } from '@angular/material/button';
import { MatTableModule } from '@angular/material/table';
import { ReservationComponent } from './reservation/reservation.component';
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';

@NgModule({
declarations: [
Expand All @@ -35,7 +40,13 @@ import { OperatingHoursDialog } from './widgets/operating-hours-dialog/operating
MatExpansionModule,
MatButtonModule,
MatTableModule,
AsyncPipe
MatFormFieldModule,
MatInputModule,
MatAutocompleteModule,
MatCardModule,
AsyncPipe,
AsyncPipe,
SharedModule
]
})
export class CoworkingModule {}
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@
<!-- User Selection / Organizers Form Control -->
<user-lookup
label="Organizers"
[profile]="profile"
[users]="organizers"
[disabled]="(enabled$ | async) === false"></user-lookup>
</mat-card-content>
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/app/permission.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ export class PermissionService {
action: string,
resource: string
) {
if (!permissions) {
return false;
}
let permission = permissions.find((p) =>
this.checkPermission(p, action, resource)
);
Expand Down
15 changes: 13 additions & 2 deletions frontend/src/app/shared/user-lookup/user-lookup.widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,15 @@
* @license MIT
*/

import { Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core';
import {
Component,
ElementRef,
EventEmitter,
Input,
OnInit,
Output,
ViewChild
} from '@angular/core';
import { FormControl } from '@angular/forms';
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import {
Expand All @@ -29,10 +37,11 @@ import { ProfileService, PublicProfile } from 'src/app/profile/profile.service';
export class UserLookup implements OnInit {
@Input() label: string = 'Users';
@Input() maxSelected: number | null = null;
@Input() profile: Profile | null = null;
@Input() users: PublicProfile[] = [];
@Input() disabled: boolean | null = false;

@Output() usersChanged: EventEmitter<PublicProfile[]> = new EventEmitter();

userLookup = new FormControl();

@ViewChild('usersInput') usersInput!: ElementRef<HTMLInputElement>;
Expand Down Expand Up @@ -72,11 +81,13 @@ export class UserLookup implements OnInit {
}
this.usersInput.nativeElement.value = '';
this.userLookup.setValue('');
this.usersChanged.emit(this.users);
}

/** Handler for selecting an option in the who chip grid. */
public onUserRemoved(person: PublicProfile) {
this.users.splice(this.users.indexOf(person), 1);
this.userLookup.setValue('');
this.usersChanged.emit(this.users);
}
}

0 comments on commit 5499238

Please sign in to comment.