1 import { Component, OnInit, AfterViewInit, QueryList, ViewChildren, ViewChild, OnDestroy } from '@angular/core';
2 import {FormGroup, FormControl, ValidationErrors, ValidatorFn, FormArray} from '@angular/forms';
3 import {Router, ActivatedRoute} from '@angular/router';
4 import {iif, Observable, of, throwError, timer, Subscription} from 'rxjs';
5 import {catchError, debounceTime, takeLast, mapTo, single, switchMap, tap} from 'rxjs/operators';
6 import {NgbCalendar, NgbNav} from '@ng-bootstrap/ng-bootstrap';
7 import {AuthService} from '@eg/core/auth.service';
8 import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
9 import {FormatService} from '@eg/core/format.service';
10 import {GridComponent} from '@eg/share/grid/grid.component';
11 import {GridDataSource, GridRowFlairEntry, GridCellTextGenerator} from '@eg/share/grid/grid';
12 import {IdlObject} from '@eg/core/idl.service';
13 import {NetService} from '@eg/core/net.service';
14 import {PcrudService} from '@eg/core/pcrud.service';
15 import {CreateReservationDialogComponent} from './create-reservation-dialog.component';
16 import {ServerStoreService} from '@eg/core/server-store.service';
17 import {ToastService} from '@eg/share/toast/toast.service';
18 import {DateRange} from '@eg/share/daterange-select/daterange-select.component';
19 import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
20 import {ScheduleGridService, ScheduleRow} from './schedule-grid.service';
21 import {NoTimezoneSetComponent} from './no-timezone-set.component';
23 import * as moment from 'moment-timezone';
25 const startOfDayIsBeforeEndOfDayValidator: ValidatorFn = (fg: FormGroup): ValidationErrors | null => {
26 const start = fg.get('startOfDay').value;
27 const end = fg.get('endOfDay').value;
28 return start !== null && end !== null &&
29 (start.hour <= end.hour) &&
30 !((start.hour === end.hour) && (start.minute >= end.minute))
32 : { startOfDayNotBeforeEndOfDay: true };
36 templateUrl: './create-reservation.component.html',
37 styles: ['#ideal-resource-barcode {min-width: 300px;}']
39 export class CreateReservationComponent implements OnInit, AfterViewInit, OnDestroy {
43 attributes: IdlObject[] = [];
45 resourceAvailabilityIcon: (row: ScheduleRow) => GridRowFlairEntry;
46 cellTextGenerator: GridCellTextGenerator;
49 resourceBarcode: string;
51 transferable: boolean;
52 resourceOwner: number;
53 subscriptions: Subscription[] = [];
55 // eslint-disable-next-line no-magic-numbers
56 defaultGranularity = 30;
57 granularity: number = this.defaultGranularity;
59 scheduleSource: GridDataSource = new GridDataSource();
61 minuteStep: () => number;
62 reservationTypes: {id: string, name: string}[];
64 openTheDialog: (rows: IdlObject[]) => void;
66 resources: IdlObject[] = [];
68 setGranularity: () => void;
69 changeGranularity: ($event: ComboboxEntry) => void;
74 @ViewChild('createDialog', { static: true }) createDialog: CreateReservationDialogComponent;
75 @ViewChild('details', { static: true }) details: NgbNav;
76 @ViewChild('noTimezoneSetDialog', { static: true }) noTimezoneSetDialog: NoTimezoneSetComponent;
77 @ViewChild('viewReservation', { static: true }) viewReservation: FmRecordEditorComponent;
78 @ViewChildren('scheduleGrid') scheduleGrids: QueryList<GridComponent>;
81 private auth: AuthService,
82 private calendar: NgbCalendar,
83 private format: FormatService,
84 private net: NetService,
85 private pcrud: PcrudService,
86 private route: ActivatedRoute,
87 private router: Router,
88 private scheduleService: ScheduleGridService,
89 private store: ServerStoreService,
90 private toast: ToastService,
95 if (!(this.format.wsOrgTimezone)) {
96 this.noTimezoneSetDialog.open();
99 const initialRangeLength = 10;
100 const defaultRange = {
101 fromDate: this.calendar.getToday(),
102 toDate: this.calendar.getNext(
103 this.calendar.getToday(), 'd', initialRangeLength)
106 this.route.paramMap.pipe(
108 this.patronId = +params.get('patron_id');
109 this.resourceBarcode = params.get('resource_barcode');
111 switchMap(params => iif(() => params.has('resource_barcode'),
112 this.handleBarcodeFromUrl$(params.get('resource_barcode')),
117 console.warn('could not find a resource with this barcode');
121 this.reservationTypes = [
122 {id: 'single', name: 'Single day reservation'},
123 {id: 'multi', name: 'Multiple day reservation'},
126 const waitToLoadResource = 800;
127 this.criteria = new FormGroup({
128 'resourceBarcode': new FormControl(this.resourceBarcode ? this.resourceBarcode : '',
130 timer(waitToLoadResource).pipe(switchMap(() =>
131 this.pcrud.search('brsrc',
132 {'barcode' : rb.value},
136 catchError(() => of({ resourceBarcode: 'No resource found with that barcode' }))
138 'resourceType': new FormControl(),
139 'startOfDay': new FormControl({hour: 9, minute: 0, second: 0}),
140 'endOfDay': new FormControl({hour: 17, minute: 0, second: 0}),
141 'idealDate': new FormControl(new Date()),
142 'idealDateRange': new FormControl(defaultRange),
143 'reservationType': new FormControl(),
144 'owningLibrary': new FormControl({primaryOrgId: this.auth.user().ws_ou(), includeDescendants: true}),
145 'selectedAttributes': new FormArray([]),
146 }, [ startOfDayIsBeforeEndOfDayValidator
149 const debouncing = 1500;
150 this.criteria.get('resourceBarcode').valueChanges
151 .pipe(debounceTime(debouncing))
152 .subscribe((barcode) => {
154 if ('INVALID' === this.criteria.get('resourceBarcode').status) {
155 this.toast.danger('No resource found with this barcode');
157 this.router.navigate(['/staff', 'booking', 'create_reservation', 'for_resource', barcode]);
161 this.subscriptions.push(
162 this.resourceType.valueChanges.pipe(
163 switchMap((value) => {
164 this.resourceBarcode = null;
166 this.resourceId = null;
167 this.attributes = [];
168 // TODO: when we upgrade to Angular 8, this can
169 // be simplified to this.selectedAttributes.clear();
170 while (this.selectedAttributes.length) {
171 this.selectedAttributes.removeAt(0);
174 return this.pcrud.search('bra', {resource_type : value.id}, {
175 order_by: 'name ASC',
177 flesh_fields: {'bra' : ['valid_values']}
180 this.attributes.push(attribute);
181 this.selectedAttributes.push(new FormControl());
188 ).subscribe(() => this.fetchData()));
190 this.criteria.get('reservationType').valueChanges.subscribe((val) => {
191 this.multiday = ('multi' === val.id);
192 this.store.setItem('eg.booking.create.multiday', this.multiday);
195 this.subscriptions.push(
196 this.owningLibraryFamily.valueChanges
197 .subscribe(() => this.resources = []));
199 this.subscriptions.push(
200 this.criteria.valueChanges
201 .subscribe(() => this.fetchData()));
203 this.store.getItem('eg.booking.create.multiday').then(multiday => {
204 if (multiday) { this.multiday = multiday; }
205 this.criteria.patchValue({reservationType:
206 this.multiday ? this.reservationTypes[1] : this.reservationTypes[0]
207 }, {emitEvent: false});
210 const minutesInADay = 1440;
212 this.setGranularity = () => {
213 if (this.multiday) { // multiday reservations always use day granularity
214 this.granularity = minutesInADay;
216 this.store.getItem('eg.booking.create.granularity').then(granularity => {
218 this.granularity = granularity;
220 this.granularity = this.defaultGranularity;
226 this.criteria.get('idealDate').valueChanges
227 .pipe(switchMap((date) => this.scheduleService.hoursOfOperation(date)))
228 .subscribe((hours) => this.criteria.patchValue(hours, {emitEvent: false}),
230 () => this.fetchData());
232 this.changeGranularity = ($event) => {
233 this.granularity = $event.id;
234 this.store.setItem('eg.booking.create.granularity', $event.id)
235 .then(() => this.fetchData());
238 const minutesInAnHour = 60;
240 this.minuteStep = () => {
241 return (this.granularity < minutesInAnHour) ? this.granularity : this.defaultGranularity;
244 this.resourceAvailabilityIcon = (row: ScheduleRow) => {
245 return this.scheduleService.resourceAvailabilityIcon(row, this.resources.length);
252 this.openTheDialog = (rows: IdlObject[]) => {
253 if (rows && rows.length) {
254 this.createDialog.setDefaultTimes(rows.map((row) => row['time'].clone()), this.granularity);
256 this.subscriptions.push(
257 this.createDialog.open({size: 'lg'})
258 .subscribe(() => this.fetchData())
263 fetchData = (): void => {
264 this.setGranularity();
265 this.scheduleSource.data = [];
266 let resources$ = this.scheduleService.fetchRelevantResources(
267 this.resourceType.value ? this.resourceType.value.id : null,
268 this.owningLibraries,
269 this.flattenedSelectedAttributes
271 if (this.resourceId) {
272 resources$ = of(this.resources[0]);
279 this.resources.push(resource);
280 this.resources.sort((a, b) =>
281 (a.barcode() > b.barcode()) ? 1 : ((b.barcode() > a.barcode()) ? -1 : 0));
285 let range = {startTime: moment(), endTime: moment()};
288 range = this.scheduleService.momentizeDateRange(
290 this.format.wsOrgTimezone
293 range = this.scheduleService.momentizeDay(
297 this.format.wsOrgTimezone
300 this.scheduleSource.data = this.scheduleService.createBasicSchedule(
301 range, this.granularity);
302 return this.scheduleService.fetchReservations(range, this.resources.map(r => r.id()));
304 ).subscribe((reservation) => {
305 this.scheduleSource.data = this.scheduleService.addReservationToSchedule(
307 this.scheduleSource.data,
309 this.format.wsOrgTimezone
311 }, (err: unknown) => {
313 this.cellTextGenerator = {
315 return this.multiday ? row['time'].format('LT') :
316 this.format.transform({value: row['time'], datatype: 'timestamp', datePlusTime: true});
319 this.resources.forEach(resource => {
320 this.cellTextGenerator[resource.barcode()] = row => {
321 return row.patrons[resource.barcode()] ?
322 row.patrons[resource.barcode()].map(reservation => reservation['patronLabel']).join(', ') : '';
327 // TODO: make this into cross-field validation, and don't fetch data if true
328 /* eslint-disable eqeqeq */
329 invalidMultidaySettings(): boolean {
330 return (this.multiday && (!this.idealDateRange ||
331 (null == this.idealDateRange.fromDate) ||
332 (null == this.idealDateRange.toDate)));
334 /* eslint-enable eqeqeq */
336 handleBarcodeFromUrl$(barcode: string): Observable<any> {
337 return this.findResourceByBarcode$(barcode)
339 catchError(() => this.handleBrsrcError$(barcode)),
342 this.resourceId = resource.id();
343 this.criteria.patchValue({
344 resourceType: {id: resource.type()}},
346 this.resources = [resource];
347 this.details.select('select-resource');
354 findResourceByBarcode$(barcode: string): Observable<IdlObject> {
355 return this.pcrud.search('brsrc',
356 {'barcode' : barcode}, {'limit': 1})
360 handleBrsrcError$(barcode: string): Observable<any> {
361 return this.tryToMakeThisBookable$(barcode)
362 .pipe(switchMap(() => this.findResourceByBarcode$(barcode)),
364 this.toast.danger('No resource found with this barcode');
365 this.resourceId = -1;
366 return throwError('could not find or create a resource');
370 tryToMakeThisBookable$(barcode: string): Observable<any> {
371 return this.pcrud.search('acp',
372 {'barcode' : barcode}, {'limit': 1})
375 this.net.request( 'open-ils.booking',
376 'open-ils.booking.resources.create_from_copies',
377 this.auth.token(), [item.id()])
380 this.toast.danger('Cannot make this barcode bookable');
381 return throwError('Tried and failed to make that barcode bookable');
384 this.toast.info('Made this barcode bookable');
385 this.resourceId = response['brsrc'][0][0];
389 addDays = (days: number): void => {
390 const result = new Date(this.idealDate);
391 result.setDate(result.getDate() + days);
392 this.criteria.patchValue({idealDate: result});
395 openReservationViewer = (id: number): void => {
396 this.viewReservation.mode = 'view';
397 this.viewReservation.recordId = id;
398 this.viewReservation.open({ size: 'lg' });
402 return this.criteria.get('resourceType');
404 get userStartOfDay() {
405 return this.criteria.get('startOfDay').value;
408 return this.criteria.get('endOfDay').value;
411 return this.criteria.get('idealDate').value;
413 get idealDateRange() {
414 return this.criteria.get('idealDateRange').value;
416 get owningLibraryFamily() {
417 return this.criteria.get('owningLibrary');
419 get owningLibraries() {
420 if (this.criteria.get('owningLibrary').value.orgIds) {
421 return this.criteria.get('owningLibrary').value.orgIds;
423 return [this.criteria.get('owningLibrary').value.primaryOrgId];
426 get selectedAttributes() {
427 return <FormArray>this.criteria.get('selectedAttributes');
429 get flattenedSelectedAttributes(): number[] {
430 return this.selectedAttributes.value.filter(Boolean).map((entry) => entry.id);
432 ngOnDestroy(): void {
433 this.subscriptions.forEach((subscription) => {
434 subscription.unsubscribe();