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