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