Skip to content

Commit 05a4f88

Browse files
committed
feat(bookings): add check and flow for recurrence clashes (PPT-2241)
1 parent 20d6749 commit 05a4f88

File tree

5 files changed

+273
-2
lines changed

5 files changed

+273
-2
lines changed

libs/bookings/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ export * from './lib/parking.service';
1717

1818
export * from './lib/visitor-invite-form.component';
1919
export * from './lib/visitor-invite-success.component';
20+
export * from './lib/recurring-clash-modal.component';

libs/bookings/src/lib/booking-form.service.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,14 @@ import {
6262
loadLockerBanks,
6363
loadLockers,
6464
} from './booking.utilities';
65-
import { bookedResourceList, queryBookings, saveBooking } from './bookings.fn';
65+
import {
66+
bookedResourceList,
67+
BookingClash,
68+
findBookingClashes,
69+
queryBookings,
70+
saveBooking,
71+
} from './bookings.fn';
72+
import { openRecurringClashModal } from './recurring-clash-modal.component';
6673
import { DeskQuestionsModalComponent } from './desk-questions-modal.component';
6774

6875
import { AssetStateService } from 'libs/assets/src/lib/asset-state.service';
@@ -558,6 +565,14 @@ export class BookingFormService extends AsyncHandler {
558565
value.duration,
559566
host,
560567
);
568+
await this._checkRecurringClashes(
569+
{
570+
...booking,
571+
...value,
572+
user_email: host,
573+
},
574+
this._options.getValue().type,
575+
);
561576
}
562577
if (this._payments.enabled) {
563578
const receipt = await this._payments.makePayment({
@@ -895,6 +910,76 @@ export class BookingFormService extends AsyncHandler {
895910
return true;
896911
}
897912

913+
/**
914+
* Check for clashing bookings in a recurring booking series
915+
* @param booking The booking to check for clashes
916+
* @param type The booking type
917+
* @returns true if no clashes or user confirmed to continue
918+
* @throws Error if first instance clashes or clashes not allowed
919+
*/
920+
private async _checkRecurringClashes(
921+
booking: Partial<Booking>,
922+
type: BookingType,
923+
): Promise<boolean> {
924+
// Only check for recurring bookings
925+
if (!booking.recurrence_type || booking.recurrence_type === 'none') {
926+
return true;
927+
}
928+
929+
const temp_booking = new Booking({
930+
...booking,
931+
booking_type: type,
932+
});
933+
934+
const clashes = (await lastValueFrom(
935+
findBookingClashes(temp_booking, { include_clash_time: true }),
936+
)) as BookingClash[];
937+
938+
if (!clashes?.length) {
939+
return true;
940+
}
941+
942+
// Sort clashes by booking_start to identify first instance
943+
const sorted_clashes = [...clashes].sort(
944+
(a, b) => a.booking_start - b.booking_start,
945+
);
946+
947+
// Check if first instance clashes (compare with booking start time)
948+
const booking_start_unix = Math.floor(booking.date / 1000);
949+
const first_clash = sorted_clashes[0];
950+
const is_first_instance_clash =
951+
first_clash.booking_start === booking_start_unix;
952+
953+
if (is_first_instance_clash) {
954+
throw i18n('BOOKINGS.FIRST_INSTANCE_CLASH');
955+
}
956+
957+
// Check setting for allow_recurring_instance_clashes
958+
const allow_clashes =
959+
this._settings.get(`app.${type}s.allow_recurring_instance_clashes`) ??
960+
this._settings.get(`app.${type}.allow_recurring_instance_clashes`) ??
961+
this._settings.get('app.bookings.allow_recurring_instance_clashes') ??
962+
true;
963+
964+
if (!allow_clashes) {
965+
throw i18n('BOOKINGS.RECURRING_CLASHES_NOT_ALLOWED', {
966+
count: clashes.length,
967+
});
968+
}
969+
970+
// Show modal to confirm with user
971+
const result = await openRecurringClashModal(
972+
{ clashes: sorted_clashes },
973+
this._dialog,
974+
);
975+
976+
if (result?.reason !== 'done') {
977+
throw 'User cancelled';
978+
}
979+
980+
return true;
981+
}
982+
898983
public loadResourceList(type: string) {
899984
const use_region = this._settings.get('app.use_region');
900985
const map_metadata = (_) =>

libs/bookings/src/lib/bookings.fn.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,41 @@ export function bookedResourceList(
7474
);
7575
}
7676

77+
export interface BookingClashQueryOptions {
78+
// Requires multple assets in the booking to use
79+
return_available?: boolean;
80+
// Added the time that the clashes occur with each returned asset
81+
include_clash_time?: boolean;
82+
}
83+
84+
export interface BookingClash {
85+
asset_id: string;
86+
booking_start: number;
87+
booking_end: number;
88+
}
89+
90+
/**
91+
* List resources that clash within the given parameters
92+
* @param q Parameters to pass to the API request
93+
*/
94+
export function findBookingClashes(
95+
booking: Booking,
96+
q: BookingClashQueryOptions = {},
97+
): Observable<string[] | BookingClash[]> {
98+
const query = toQueryString({ ...q, limit: 10000 });
99+
return post(
100+
`${BOOKINGS_ENDPOINT}/clashing-assets${query ? '?' + query : ''}`,
101+
booking.toJSON(),
102+
).pipe(
103+
map((list) =>
104+
q.include_clash_time
105+
? (list as BookingClash[])
106+
: (list as string[]),
107+
),
108+
catchError((_) => of([])),
109+
);
110+
}
111+
77112
/**
78113
* List bookings with link to next page of bookings
79114
* @param q Parameters to pass to the API request
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { DatePipe } from '@angular/common';
2+
import { Component, EventEmitter, Output, inject } from '@angular/core';
3+
import { MatRippleModule } from '@angular/material/core';
4+
import {
5+
MAT_DIALOG_DATA,
6+
MatDialog,
7+
MatDialogModule,
8+
MatDialogRef,
9+
} from '@angular/material/dialog';
10+
import { DialogEvent } from '@placeos/common';
11+
import { IconComponent } from 'libs/components/src/lib/icon.component';
12+
import { TranslatePipe } from 'libs/components/src/lib/translate.pipe';
13+
import { first } from 'rxjs/operators';
14+
import { BookingClash } from './bookings.fn';
15+
16+
export interface RecurringClashModalData {
17+
clashes: BookingClash[];
18+
}
19+
20+
export async function openRecurringClashModal(
21+
data: RecurringClashModalData,
22+
dialog: MatDialog,
23+
): Promise<{ reason: 'done' | '' | null }> {
24+
const ref = dialog.open<
25+
RecurringClashModalComponent,
26+
RecurringClashModalData
27+
>(RecurringClashModalComponent, {
28+
data,
29+
});
30+
return Promise.race([
31+
ref.componentInstance.event
32+
.pipe(first((_) => _.reason === 'done'))
33+
.toPromise(),
34+
ref.afterClosed().toPromise(),
35+
]);
36+
}
37+
38+
@Component({
39+
selector: 'placeos-recurring-clash-modal',
40+
template: `
41+
<div class="relative">
42+
<header
43+
class="sticky top-0 z-10 m-2 h-14 w-[calc(100%-1rem)] min-w-[20rem] rounded border-none bg-base-200 p-2"
44+
>
45+
<h2 class="px-2 text-xl font-medium">
46+
{{ 'BOOKINGS.RECURRING_CLASHES_TITLE' | translate }}
47+
</h2>
48+
</header>
49+
<main
50+
class="flex max-h-[60vh] w-full max-w-[calc(100vw-2rem)] flex-col items-center space-y-4 overflow-auto px-4 py-2 sm:max-w-[28rem]"
51+
>
52+
<div
53+
class="flex items-center space-x-2 rounded-xl border border-base-200 bg-warning p-2 text-warning-content shadow"
54+
>
55+
<icon class="text-5xl">warning</icon>
56+
<p>
57+
{{ 'BOOKINGS.RECURRING_CLASHES_MSG' | translate }}
58+
</p>
59+
</div>
60+
<div
61+
class="max-h-48 w-full overflow-auto rounded border border-base-300 bg-base-100"
62+
>
63+
<table class="w-full text-sm">
64+
<thead class="sticky top-0 bg-base-200">
65+
<tr>
66+
<th class="p-2 text-left">
67+
{{ 'FORM.DATE' | translate }}
68+
</th>
69+
<th class="p-2 text-left">
70+
{{ 'COMMON.TIME' | translate }}
71+
</th>
72+
</tr>
73+
</thead>
74+
<tbody>
75+
@for (clash of clashes; track clash.booking_start) {
76+
<tr class="border-t border-base-300">
77+
<td class="p-2">
78+
{{
79+
clash.booking_start * 1000
80+
| date: 'EEE, MMM d, yyyy'
81+
}}
82+
</td>
83+
<td class="p-2">
84+
{{
85+
clash.booking_start * 1000
86+
| date: 'h:mm a'
87+
}}
88+
-
89+
{{
90+
clash.booking_end * 1000
91+
| date: 'h:mm a'
92+
}}
93+
</td>
94+
</tr>
95+
}
96+
</tbody>
97+
</table>
98+
</div>
99+
<p class="text-base-content/70 text-center text-xs">
100+
{{ 'BOOKINGS.RECURRING_CLASHES_CONFIRM' | translate }}
101+
</p>
102+
</main>
103+
<footer
104+
class="sticky bottom-0 m-2 flex items-center justify-center space-x-2 rounded border-none bg-base-200 p-2"
105+
>
106+
<button
107+
btn
108+
matRipple
109+
class="inverse flex-1 bg-base-100"
110+
mat-dialog-close
111+
>
112+
{{ 'COMMON.CANCEL' | translate }}
113+
</button>
114+
<button btn matRipple class="flex-1" (click)="onConfirm()">
115+
{{ 'BOOKINGS.CONTINUE_BOOKING' | translate }}
116+
</button>
117+
</footer>
118+
</div>
119+
`,
120+
styles: [``],
121+
imports: [
122+
IconComponent,
123+
MatDialogModule,
124+
MatRippleModule,
125+
TranslatePipe,
126+
DatePipe,
127+
],
128+
})
129+
export class RecurringClashModalComponent {
130+
@Output() public event = new EventEmitter<DialogEvent>();
131+
132+
private _data = inject<RecurringClashModalData>(MAT_DIALOG_DATA);
133+
private _dialog_ref =
134+
inject<MatDialogRef<RecurringClashModalComponent>>(MatDialogRef);
135+
136+
public get clashes(): BookingClash[] {
137+
return this._data.clashes || [];
138+
}
139+
140+
public onConfirm() {
141+
this.event.emit({ reason: 'done' });
142+
this._dialog_ref.close({ reason: 'done' });
143+
}
144+
}

shared/assets/locale/en-AU.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -629,7 +629,13 @@
629629
"ASSETS_DELIVER_TIME": "Deliver After",
630630
"ASSETS_AVAILABLE": "{{ count }} available",
631631
"RULES_HIDDEN_1": "You are not allowed to book the selected {{ type }} for the selected time or duration",
632-
"RULES_HIDDEN_N": "You are not allowed to book some of the selected {{ type }}s for the selected time or duration"
632+
"RULES_HIDDEN_N": "You are not allowed to book some of the selected {{ type }}s for the selected time or duration",
633+
"FIRST_INSTANCE_CLASH": "The first booking in the series clashes with an existing booking",
634+
"RECURRING_CLASHES_NOT_ALLOWED": "Recurring bookings with {{ count }} clashing instance(s) are not allowed",
635+
"RECURRING_CLASHES_TITLE": "Booking Clashes Detected",
636+
"RECURRING_CLASHES_MSG": "Some instances in this recurring booking series clash with existing bookings.",
637+
"RECURRING_CLASHES_CONFIRM": "These instances will be skipped. Do you want to continue with the booking?",
638+
"CONTINUE_BOOKING": "Continue Booking"
633639
},
634640
"CALENDAR_EVENT": {
635641
"CATERING": "Catering",

0 commit comments

Comments
 (0)