]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/eg2/src/app/staff/booking/create-reservation.component.ts
7461a6cfcba8b327d56d7078c688d1da29bb9e17
[Evergreen.git] / Open-ILS / src / eg2 / src / app / staff / booking / create-reservation.component.ts
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 {from, iif, Observable, of, throwError, timer, Subscription} from 'rxjs';
5 import {catchError, debounceTime, takeLast, mapTo, single, switchMap, tap} from 'rxjs/operators';
6 import {NgbCalendar, NgbTabset} 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} 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';
22
23 import * as Moment from 'moment-timezone';
24
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))
31         ? null
32         : { startOfDayNotBeforeEndOfDay: true };
33 };
34
35 @Component({
36     templateUrl: './create-reservation.component.html',
37     styles: ['#ideal-resource-barcode {min-width: 300px;}']
38 })
39 export class CreateReservationComponent implements OnInit, AfterViewInit, OnDestroy {
40
41     criteria: FormGroup;
42
43     attributes: IdlObject[] = [];
44     multiday = false;
45     resourceAvailabilityIcon: (row: ScheduleRow) => GridRowFlairEntry;
46
47     patronId: number;
48     resourceBarcode: string;
49     resourceId: number;
50     transferable: boolean;
51     resourceOwner: number;
52     subscriptions: Subscription[] = [];
53
54     defaultGranularity = 30;
55     granularity: number = this.defaultGranularity;
56
57     scheduleSource: GridDataSource = new GridDataSource();
58
59     minuteStep: () => number;
60     reservationTypes: {id: string, name: string}[];
61
62     openTheDialog: (rows: IdlObject[]) => void;
63
64     resources: IdlObject[] = [];
65
66     setGranularity: () => void;
67     changeGranularity: ($event: ComboboxEntry) => void;
68
69     dateRange: DateRange;
70
71     @ViewChild('createDialog') createDialog: CreateReservationDialogComponent;
72     @ViewChild('details') details: NgbTabset;
73     @ViewChild('noTimezoneSetDialog') noTimezoneSetDialog: NoTimezoneSetComponent;
74     @ViewChild('viewReservation') viewReservation: FmRecordEditorComponent;
75     @ViewChildren('scheduleGrid') scheduleGrids: QueryList<GridComponent>;
76
77     constructor(
78         private auth: AuthService,
79         private calendar: NgbCalendar,
80         private format: FormatService,
81         private net: NetService,
82         private pcrud: PcrudService,
83         private route: ActivatedRoute,
84         private router: Router,
85         private scheduleService: ScheduleGridService,
86         private store: ServerStoreService,
87         private toast: ToastService,
88     ) {
89     }
90
91     ngOnInit() {
92         if (!(this.format.wsOrgTimezone)) {
93             this.noTimezoneSetDialog.open();
94         }
95
96         const initialRangeLength = 10;
97         const defaultRange = {
98             fromDate: this.calendar.getToday(),
99             toDate: this.calendar.getNext(
100                 this.calendar.getToday(), 'd', initialRangeLength)
101         };
102
103         this.route.paramMap.pipe(
104             tap(params => {
105                 this.patronId = +params.get('patron_id');
106                 this.resourceBarcode = params.get('resource_barcode');
107             }),
108             switchMap(params => iif(() => params.has('resource_barcode'),
109                 this.handleBarcodeFromUrl$(params.get('resource_barcode')),
110                 of(params)
111             ))
112         ).subscribe({
113             error() {
114                 console.warn('could not find a resource with this barcode');
115             }
116         });
117
118         this.reservationTypes = [
119             {id: 'single', name: 'Single day reservation'},
120             {id: 'multi', name: 'Multiple day reservation'},
121         ];
122
123         const waitToLoadResource = 800;
124         this.criteria = new FormGroup({
125             'resourceBarcode': new FormControl(this.resourceBarcode ? this.resourceBarcode : '',
126                 [], (rb) =>
127                     timer(waitToLoadResource).pipe(switchMap(() =>
128                         this.pcrud.search('brsrc',
129                             {'barcode' : rb.value},
130                             {'limit': 1})),
131                         single(),
132                         mapTo(null),
133                         catchError(() => of({ resourceBarcode: 'No resource found with that barcode' }))
134                     )),
135             'resourceType': new FormControl(),
136             'startOfDay': new FormControl({hour: 9, minute: 0, second: 0}),
137             'endOfDay': new FormControl({hour: 17, minute: 0, second: 0}),
138             'idealDate': new FormControl(new Date()),
139             'idealDateRange': new FormControl(defaultRange),
140             'reservationType': new FormControl(),
141             'owningLibrary': new FormControl({primaryOrgId: this.auth.user().ws_ou(), includeDescendants: true}),
142             'selectedAttributes': new FormArray([]),
143         }, [ startOfDayIsBeforeEndOfDayValidator
144         ]);
145
146         const debouncing = 1500;
147         this.criteria.get('resourceBarcode').valueChanges
148         .pipe(debounceTime(debouncing))
149         .subscribe((barcode) => {
150             this.resources = [];
151             if ('INVALID' === this.criteria.get('resourceBarcode').status) {
152                 this.toast.danger('No resource found with this barcode');
153             } else {
154                 this.router.navigate(['/staff', 'booking', 'create_reservation', 'for_resource', barcode]);
155             }
156         });
157
158         this.subscriptions.push(
159             this.resourceType.valueChanges.pipe(
160                 switchMap((value) => {
161                     this.resourceBarcode = null;
162                     this.resources = [];
163                     this.resourceId = null;
164                     this.attributes = [];
165                     // TODO: when we upgrade to Angular 8, this can
166                     // be simplified to this.selectedAttributes.clear();
167                     while (this.selectedAttributes.length) {
168                         this.selectedAttributes.removeAt(0);
169                      }
170                     if (value.id) {
171                         return this.pcrud.search('bra', {resource_type : value.id}, {
172                             order_by: 'name ASC',
173                             flesh: 1,
174                             flesh_fields: {'bra' : ['valid_values']}
175                         }).pipe(
176                             tap((attribute) => {
177                                 this.attributes.push(attribute);
178                                 this.selectedAttributes.push(new FormControl());
179                             })
180                         );
181                     } else {
182                         return of();
183                     }
184                 })
185             ).subscribe(() => this.fetchData()));
186
187         this.criteria.get('reservationType').valueChanges.subscribe((val) => {
188             this.multiday = ('multi' === val.id);
189             this.store.setItem('eg.booking.create.multiday', this.multiday);
190         });
191
192         this.subscriptions.push(
193             this.owningLibraryFamily.valueChanges
194                 .subscribe(() => this.resources = []));
195
196         this.subscriptions.push(
197             this.criteria.valueChanges
198                 .subscribe(() => this.fetchData()));
199
200         this.store.getItem('eg.booking.create.multiday').then(multiday => {
201             if (multiday) { this.multiday = multiday; }
202             this.criteria.patchValue({reservationType:
203                 this.multiday ? this.reservationTypes[1] : this.reservationTypes[0]
204             });
205         });
206
207         const minutesInADay = 1440;
208
209         this.setGranularity = () => {
210             if (this.multiday) { // multiday reservations always use day granularity
211                 this.granularity = minutesInADay;
212             } else {
213                 this.store.getItem('eg.booking.create.granularity').then(granularity => {
214                     if (granularity) {
215                         this.granularity = granularity;
216                     } else {
217                         this.granularity = this.defaultGranularity;
218                     }
219                 });
220             }
221         };
222
223         this.criteria.get('idealDate').valueChanges
224             .pipe(switchMap((date) => this.scheduleService.hoursOfOperation(date)))
225             .subscribe((hours) => this.criteria.patchValue(hours, {emitEvent: false}),
226                 () => {},
227                 () => this.fetchData());
228
229         this.changeGranularity = ($event) => {
230             this.granularity = $event.id;
231             this.store.setItem('eg.booking.create.granularity', $event.id)
232             .then(() => this.fetchData());
233         };
234
235         const minutesInAnHour = 60;
236
237         this.minuteStep = () => {
238             return (this.granularity < minutesInAnHour) ? this.granularity : this.defaultGranularity;
239         };
240
241         this.resourceAvailabilityIcon = (row: ScheduleRow) => {
242             return this.scheduleService.resourceAvailabilityIcon(row,  this.resources.length);
243         };
244     }
245
246     ngAfterViewInit() {
247         this.fetchData();
248
249         this.openTheDialog = (rows: IdlObject[]) => {
250             if (rows && rows.length) {
251                 this.createDialog.setDefaultTimes(rows.map((row) => row['time'].clone()), this.granularity);
252             }
253             this.subscriptions.push(
254                 this.createDialog.open({size: 'lg'})
255                 .subscribe(() => this.fetchData())
256             );
257         };
258     }
259
260     fetchData = (): void => {
261         this.setGranularity();
262         this.scheduleSource.data = [];
263         let resources$ = this.scheduleService.fetchRelevantResources(
264             this.resourceType.value ? this.resourceType.value.id : null,
265             this.owningLibraries,
266             this.flattenedSelectedAttributes
267         );
268         if (this.resourceId) {
269             resources$ = from(this.resources);
270         } else {
271             this.resources = [];
272         }
273
274         resources$.pipe(
275             tap((resource) =>  {
276                 this.resources.push(resource);
277                 this.resources.sort((a,b) =>
278                     (a.barcode() > b.barcode()) ? 1 : ((b.barcode() > a.barcode()) ? -1 : 0));
279             }),
280             takeLast(1),
281             switchMap(() => {
282                 let range = {startTime: Moment(), endTime: Moment()};
283
284                 if (this.multiday) {
285                     range = this.scheduleService.momentizeDateRange(
286                         this.idealDateRange,
287                         this.format.wsOrgTimezone
288                     );
289                 } else {
290                     range = this.scheduleService.momentizeDay(
291                         this.idealDate,
292                         this.userStartOfDay,
293                         this.userEndOfDay,
294                         this.format.wsOrgTimezone
295                     );
296                 }
297                 this.scheduleSource.data = this.scheduleService.createBasicSchedule(
298                     range, this.granularity);
299                 return this.scheduleService.fetchReservations(range, this.resources.map(r => r.id()));
300             })
301         ).subscribe((reservation) => {
302             this.scheduleSource.data = this.scheduleService.addReservationToSchedule(
303                 reservation,
304                 this.scheduleSource.data,
305                 this.granularity,
306                 this.format.wsOrgTimezone
307             );
308         });
309     }
310     // TODO: make this into cross-field validation, and don't fetch data if true
311     invalidMultidaySettings(): boolean {
312         return (this.multiday && (!this.idealDateRange ||
313             (null == this.idealDateRange.fromDate) ||
314             (null == this.idealDateRange.toDate)));
315     }
316
317     handleBarcodeFromUrl$(barcode: string): Observable<any> {
318         return this.findResourceByBarcode$(barcode)
319         .pipe(
320             catchError(() => this.handleBrsrcError$(barcode)),
321             tap((resource) => {
322                 if (resource) {
323                     this.resourceId = resource.id();
324                     this.criteria.patchValue({
325                         resourceType: {id: resource.type()}},
326                         {emitEvent: false});
327                     this.resources = [resource];
328                     this.details.select('select-resource');
329                     this.fetchData();
330                 }
331             })
332         );
333     }
334
335     findResourceByBarcode$(barcode: string): Observable<IdlObject> {
336         return this.pcrud.search('brsrc',
337             {'barcode' : barcode}, {'limit': 1})
338             .pipe(single());
339     }
340
341     handleBrsrcError$(barcode: string): Observable<any> {
342         return this.tryToMakeThisBookable$(barcode)
343         .pipe(switchMap(() => this.findResourceByBarcode$(barcode)),
344             catchError(() => {
345                 this.toast.danger('No resource found with this barcode');
346                 this.resourceId = -1;
347                 return throwError('could not find or create a resource');
348             }));
349     }
350
351     tryToMakeThisBookable$(barcode: string): Observable<any> {
352         return this.pcrud.search('acp',
353             {'barcode' : barcode}, {'limit': 1})
354         .pipe(single(),
355             switchMap((item) =>
356                 this.net.request( 'open-ils.booking',
357                     'open-ils.booking.resources.create_from_copies',
358                     this.auth.token(), [item.id()])
359                 ),
360             catchError(() => {
361                 this.toast.danger('Cannot make this barcode bookable');
362                 return throwError('Tried and failed to make that barcode bookable');
363             }),
364             tap((response) => {
365                 this.toast.info('Made this barcode bookable');
366                 this.resourceId = response['brsrc'][0][0];
367             }));
368     }
369
370     addDays = (days: number): void => {
371         const result = new Date(this.idealDate);
372         result.setDate(result.getDate() + days);
373         this.criteria.patchValue({idealDate: result});
374     }
375
376     openReservationViewer = (id: number): void => {
377         this.viewReservation.mode = 'view';
378         this.viewReservation.recId = id;
379         this.viewReservation.open({ size: 'lg' });
380     }
381
382     get resourceType() {
383         return this.criteria.get('resourceType');
384     }
385     get userStartOfDay() {
386         return this.criteria.get('startOfDay').value;
387     }
388     get userEndOfDay() {
389         return this.criteria.get('endOfDay').value;
390     }
391     get idealDate() {
392         return this.criteria.get('idealDate').value;
393     }
394     get idealDateRange() {
395         return this.criteria.get('idealDateRange').value;
396     }
397     get owningLibraryFamily() {
398         return this.criteria.get('owningLibrary');
399     }
400     get owningLibraries() {
401         if (this.criteria.get('owningLibrary').value.orgIds) {
402             return this.criteria.get('owningLibrary').value.orgIds;
403         } else {
404             return [this.criteria.get('owningLibrary').value.primaryOrgId];
405         }
406     }
407     get selectedAttributes() {
408         return <FormArray>this.criteria.get('selectedAttributes');
409     }
410     get flattenedSelectedAttributes(): number[] {
411         return this.selectedAttributes.value.filter(Boolean).map((entry) => entry.id);
412     }
413     ngOnDestroy(): void {
414         this.subscriptions.forEach((subscription) => {
415             subscription.unsubscribe();
416         });
417       }
418
419 }
420