LP1889128 Confirm data loaded before Place Hold activated
[Evergreen.git] / Open-ILS / src / eg2 / src / app / staff / catalog / hold / hold.component.ts
1 import {Component, OnInit, Input, ViewChild} from '@angular/core';
2 import {Router, ActivatedRoute, ParamMap} from '@angular/router';
3 import {tap} from 'rxjs/operators';
4 import {EventService} from '@eg/core/event.service';
5 import {NetService} from '@eg/core/net.service';
6 import {AuthService} from '@eg/core/auth.service';
7 import {PcrudService} from '@eg/core/pcrud.service';
8 import {PermService} from '@eg/core/perm.service';
9 import {IdlObject} from '@eg/core/idl.service';
10 import {OrgService} from '@eg/core/org.service';
11 import {ServerStoreService} from '@eg/core/server-store.service';
12 import {BibRecordService, BibRecordSummary} from '@eg/share/catalog/bib-record.service';
13 import {CatalogService} from '@eg/share/catalog/catalog.service';
14 import {StaffCatalogService} from '../catalog.service';
15 import {HoldsService, HoldRequest,
16     HoldRequestTarget} from '@eg/staff/share/holds/holds.service';
17 import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
18 import {PatronService} from '@eg/staff/share/patron/patron.service';
19 import {PatronSearchDialogComponent
20   } from '@eg/staff/share/patron/search-dialog.component';
21
22 class HoldContext {
23     holdMeta: HoldRequestTarget;
24     holdTarget: number;
25     lastRequest: HoldRequest;
26     canOverride?: boolean;
27     processing: boolean;
28     selectedFormats: any;
29
30     constructor(target: number) {
31         this.holdTarget = target;
32         this.processing = false;
33         this.selectedFormats = {
34            // code => selected-boolean
35            formats: {},
36            langs: {}
37         };
38     }
39
40     clone(target: number): HoldContext {
41         const ctx = new HoldContext(target);
42         ctx.holdMeta = this.holdMeta;
43         return ctx;
44     }
45 }
46
47 @Component({
48   templateUrl: 'hold.component.html'
49 })
50 export class HoldComponent implements OnInit {
51
52     holdType: string;
53     holdTargets: number[];
54     user: IdlObject; //
55     userBarcode: string;
56     requestor: IdlObject;
57     holdFor: string;
58     pickupLib: number;
59     notifyEmail: boolean;
60     notifyPhone: boolean;
61     phoneValue: string;
62     notifySms: boolean;
63     smsValue: string;
64     smsCarrier: string;
65     suspend: boolean;
66     activeDate: string;
67
68     holdContexts: HoldContext[];
69     recordSummaries: BibRecordSummary[];
70
71     currentUserBarcode: string;
72     smsCarriers: ComboboxEntry[];
73
74     smsEnabled: boolean;
75
76     maxMultiHolds = 0;
77
78     // True if mult-copy holds are active for the current receipient.
79     multiHoldsActive = false;
80
81     canPlaceMultiAt: number[] = [];
82     multiHoldCount = 1;
83     placeHoldsClicked: boolean;
84     badBarcode: string = null;
85
86     puLibWsFallback = false;
87
88     @ViewChild('patronSearch', {static: false})
89       patronSearch: PatronSearchDialogComponent;
90
91     constructor(
92         private router: Router,
93         private route: ActivatedRoute,
94         private evt: EventService,
95         private net: NetService,
96         private org: OrgService,
97         private store: ServerStoreService,
98         private auth: AuthService,
99         private pcrud: PcrudService,
100         private bib: BibRecordService,
101         private cat: CatalogService,
102         private staffCat: StaffCatalogService,
103         private holds: HoldsService,
104         private patron: PatronService,
105         private perm: PermService
106     ) {
107         this.holdContexts = [];
108         this.smsCarriers = [];
109     }
110
111     ngOnInit() {
112
113         // Respond to changes in hold type.  This currently assumes hold
114         // types only toggle post-init between copy-level types (C,R,F)
115         // and no other params (e.g. target) change with it.  If other
116         // types require tracking, additional data collection may be needed.
117         this.route.paramMap.subscribe(
118             (params: ParamMap) => this.holdType = params.get('type'));
119
120         this.holdType = this.route.snapshot.params['type'];
121         this.holdTargets = this.route.snapshot.queryParams['target'];
122         this.holdFor = this.route.snapshot.queryParams['holdFor'] || 'patron';
123
124         if (this.staffCat.holdForBarcode) {
125             this.holdFor = 'patron';
126             this.userBarcode = this.staffCat.holdForBarcode;
127         }
128
129         this.store.getItem('circ.staff_placed_holds_fallback_to_ws_ou')
130         .then(setting => this.puLibWsFallback = setting === true);
131
132         if (!Array.isArray(this.holdTargets)) {
133             this.holdTargets = [this.holdTargets];
134         }
135
136         this.holdTargets = this.holdTargets.map(t => Number(t));
137
138         this.requestor = this.auth.user();
139         this.pickupLib = this.auth.user().ws_ou();
140
141         this.resetForm();
142
143         this.getRequestorSetsAndPerms()
144         .then(_ => {
145
146             // Load receipient data if we have any.
147             if (this.staffCat.holdForBarcode) {
148                 this.holdFor = 'patron';
149                 this.userBarcode = this.staffCat.holdForBarcode;
150             }
151
152             if (this.holdFor === 'staff' || this.userBarcode) {
153                 this.holdForChanged();
154             }
155         });
156
157         setTimeout(() => {
158             const node = document.getElementById('patron-barcode');
159             if (node) { node.focus(); }
160         });
161     }
162
163     getRequestorSetsAndPerms(): Promise<any> {
164
165         return this.org.settings(
166             ['sms.enable', 'circ.holds.max_duplicate_holds'])
167
168         .then(sets => {
169
170             this.smsEnabled = sets['sms.enable'];
171
172             const max = Number(sets['circ.holds.max_duplicate_holds']);
173             if (Number(max) > 0) { this.maxMultiHolds = Number(max); }
174
175             if (this.smsEnabled) {
176
177                 return this.pcrud.search(
178                     'csc', {active: 't'}, {order_by: {csc: 'name'}})
179                 .pipe(tap(carrier => {
180                     this.smsCarriers.push({
181                         id: carrier.id(),
182                         label: carrier.name()
183                     });
184                 })).toPromise();
185             }
186
187         }).then(_ => {
188
189             if (this.maxMultiHolds) {
190
191                 // Multi-copy holds are supported.  Let's see where this
192                 // requestor has permission to take advantage of them.
193                 return this.perm.hasWorkPermAt(
194                     ['CREATE_DUPLICATE_HOLDS'], true).then(perms =>
195                     this.canPlaceMultiAt = perms['CREATE_DUPLICATE_HOLDS']);
196             }
197         });
198     }
199
200     holdCountRange(): number[] {
201         return [...Array(this.maxMultiHolds).keys()].map(n => n + 1);
202     }
203
204     // Load the bib, call number, copy, etc. data associated with each target.
205     getTargetMeta(): Promise<any> {
206         return this.holds.getHoldTargetMeta(this.holdType, this.holdTargets)
207         .toPromise().then(meta => {
208             this.holdContexts.filter(ctx => ctx.holdTarget === meta.target)
209             .forEach(ctx => {
210                 ctx.holdMeta = meta;
211                 this.mrFiltersToSelectors(ctx);
212             });
213         });
214     }
215
216     // By default, all metarecord filters options are enabled.
217     mrFiltersToSelectors(ctx: HoldContext) {
218         if (this.holdType !== 'M') { return; }
219
220         const meta = ctx.holdMeta;
221         if (meta.metarecord_filters) {
222             if (meta.metarecord_filters.formats) {
223                 meta.metarecord_filters.formats.forEach(
224                     ccvm => ctx.selectedFormats.formats[ccvm.code()] = true);
225             }
226             if (meta.metarecord_filters.langs) {
227                 meta.metarecord_filters.langs.forEach(
228                     ccvm => ctx.selectedFormats.langs[ccvm.code()] = true);
229             }
230         }
231     }
232
233     // Map the selected metarecord filters optoins to a JSON-encoded
234     // list of attr filters as required by the API.
235     // Compiles a blob of
236     // {target: JSON({"0": [{_attr: ctype, _val: code}, ...], "1": [...]})}
237     // TODO: this should live in the hold service, not in the UI code.
238     mrSelectorsToFilters(ctx: HoldContext): {[target: number]: string} {
239
240         const meta = ctx.holdMeta;
241         const slf = ctx.selectedFormats;
242         const result: any = {};
243
244         const formats = Object.keys(slf.formats)
245             .filter(code => Boolean(slf.formats[code])); // user-selected
246
247         const langs = Object.keys(slf.langs)
248             .filter(code => Boolean(slf.langs[code])); // user-selected
249
250         const compiled: any = {};
251
252         if (formats.length > 0) {
253             compiled['0'] = [];
254             formats.forEach(code => {
255                 const ccvm = meta.metarecord_filters.formats.filter(
256                     format => format.code() === code)[0];
257                 compiled['0'].push({
258                     _attr: ccvm.ctype(),
259                     _val: ccvm.code()
260                 });
261             });
262         }
263
264         if (langs.length > 0) {
265             compiled['1'] = [];
266             langs.forEach(code => {
267                 const ccvm = meta.metarecord_filters.langs.filter(
268                     format => format.code() === code)[0];
269                 compiled['1'].push({
270                     _attr: ccvm.ctype(),
271                     _val: ccvm.code()
272                 });
273             });
274         }
275
276         if (Object.keys(compiled).length > 0) {
277             const res = {};
278             res[ctx.holdTarget] = JSON.stringify(compiled);
279             return res;
280         }
281
282         return null;
283     }
284
285     holdForChanged() {
286         this.user = null;
287
288         if (this.holdFor === 'patron') {
289             if (this.userBarcode) {
290                 this.userBarcodeChanged();
291             }
292         } else {
293             // To bypass the dupe check.
294             this.currentUserBarcode = '_' + this.requestor.id();
295             this.getUser(this.requestor.id());
296         }
297     }
298
299     activeDateSelected(dateStr: string) {
300         this.activeDate = dateStr;
301     }
302
303     userBarcodeChanged() {
304         const newBc = this.userBarcode;
305
306         if (!newBc) { this.user = null; return; }
307
308         // Avoid simultaneous or duplicate lookups
309         if (newBc === this.currentUserBarcode) { return; }
310
311         if (newBc !== this.staffCat.holdForBarcode) {
312             // If an alternate barcode is entered, it takes us out of
313             // place-hold-for-patron-x-from-search mode.
314             this.staffCat.clearHoldPatron();
315         }
316
317         this.currentUserBarcode = this.userBarcode;
318         this.getUser();
319     }
320
321     getUser(id?: number): Promise<any> {
322
323         let promise = this.resetForm(true);
324
325         const flesh = {flesh: 1, flesh_fields: {au: ['settings']}};
326
327         promise = promise.then(_ => {
328             return id ?
329                 this.patron.getById(id, flesh) :
330                 this.patron.getByBarcode(this.userBarcode, flesh);
331         });
332
333         this.badBarcode = null;
334         return promise.then(user => {
335
336             if (!user) {
337                 // IDs are assumed to valid
338                 this.badBarcode = this.userBarcode;
339                 return;
340             }
341
342             this.user = user;
343             this.applyUserSettings();
344             this.multiHoldsActive =
345                 this.canPlaceMultiAt.includes(user.home_ou());
346         });
347     }
348
349     resetForm(keepBarcode?: boolean): Promise<any> {
350         this.user = null;
351         this.notifyEmail = true;
352         this.notifyPhone = true;
353         this.phoneValue = '';
354         this.pickupLib = this.requestor.ws_ou();
355         this.placeHoldsClicked = false;
356
357         // Avoid clearing the barcode in cases where the form is
358         // reset as the result of a barcode change.
359         if (!keepBarcode) { this.userBarcode = null; }
360
361         this.holdContexts = this.holdTargets.map(target => {
362             const ctx = new HoldContext(target);
363             return ctx;
364         });
365
366         // Required after rebuilding the contexts
367         return this.getTargetMeta();
368     }
369
370     applyUserSettings() {
371         if (!this.user) { return; }
372
373         // Start with defaults.
374         this.phoneValue = this.user.day_phone() || this.user.evening_phone();
375
376         // Default to work org if placing holds for staff.
377         if (this.user.id() !== this.requestor.id() && !this.puLibWsFallback) {
378             // This value may be superseded below by user settings.
379             this.pickupLib = this.user.home_ou();
380         }
381
382         if (!this.user.settings()) { return; }
383
384         this.user.settings().forEach(setting => {
385             const name = setting.name();
386             let value = setting.value();
387
388             if (value === '' || value === null) { return; }
389
390             // When fleshing 'settings' on the actor.usr object,
391             // we're grabbing the raw JSON values.
392             value = JSON.parse(value);
393
394             switch (name) {
395                 case 'opac.hold_notify':
396                     this.notifyPhone = Boolean(value.match(/phone/));
397                     this.notifyEmail = Boolean(value.match(/email/));
398                     this.notifySms = Boolean(value.match(/sms/));
399                     break;
400
401                 case 'opac.default_pickup_location':
402                     this.pickupLib = Number(value);
403                     break;
404             }
405         });
406
407         if (!this.user.email()) {
408             this.notifyEmail = false;
409         }
410
411         if (!this.phoneValue) {
412             this.notifyPhone = false;
413         }
414     }
415
416     // Attempt hold placement on all targets
417     placeHolds(idx?: number) {
418         if (!idx) {
419             idx = 0;
420             if (this.multiHoldCount > 1) {
421                 this.addMultHoldContexts();
422             }
423         }
424
425         if (!this.holdContexts[idx]) {
426             this.placeHoldsClicked = false;
427             return;
428         }
429
430         this.placeHoldsClicked = true;
431
432         const ctx = this.holdContexts[idx];
433         this.placeOneHold(ctx).then(() => this.placeHolds(idx + 1));
434     }
435
436     // When placing holds on multiple copies per target, add a hold
437     // context for each instance of the request.
438     addMultHoldContexts() {
439         const newContexts = [];
440
441         this.holdContexts.forEach(ctx => {
442             for (let idx = 2; idx <= this.multiHoldCount; idx++) {
443                 const newCtx = ctx.clone(ctx.holdTarget);
444                 newContexts.push(newCtx);
445             }
446         });
447
448         // Group the contexts by hold target
449         this.holdContexts = this.holdContexts.concat(newContexts)
450             .sort((h1, h2) =>
451                 h1.holdTarget === h2.holdTarget ? 0 :
452                     h1.holdTarget < h2.holdTarget ? -1 : 1
453             );
454     }
455
456     placeOneHold(ctx: HoldContext, override?: boolean): Promise<any> {
457
458         ctx.processing = true;
459         const selectedFormats = this.mrSelectorsToFilters(ctx);
460
461         let hType = this.holdType;
462         let hTarget = ctx.holdTarget;
463         if (hType === 'T' && ctx.holdMeta.part) {
464             // A Title hold morphs into a Part hold at hold placement time
465             // if a part is selected.  This can happen on a per-hold basis
466             // when placing T-level holds.
467             hType = 'P';
468             hTarget = ctx.holdMeta.part.id();
469         }
470
471         console.debug(`Placing ${hType}-type hold on ${hTarget}`);
472
473         return this.holds.placeHold({
474             holdTarget: hTarget,
475             holdType: hType,
476             recipient: this.user.id(),
477             requestor: this.requestor.id(),
478             pickupLib: this.pickupLib,
479             override: override,
480             notifyEmail: this.notifyEmail, // bool
481             notifyPhone: this.notifyPhone ? this.phoneValue : null,
482             notifySms: this.notifySms ? this.smsValue : null,
483             smsCarrier: this.notifySms ? this.smsCarrier : null,
484             thawDate: this.suspend ? this.activeDate : null,
485             frozen: this.suspend,
486             holdableFormats: selectedFormats
487
488         }).toPromise().then(
489             request => {
490                 ctx.lastRequest = request;
491                 ctx.processing = false;
492
493                 if (!request.result.success) {
494                     console.debug('hold failed with: ', request);
495
496                     // If this request failed and was not already an override,
497                     // see of this user has permission to override.
498                     if (!request.override && request.result.evt) {
499
500                         const txtcode = request.result.evt.textcode;
501                         const perm = txtcode + '.override';
502
503                         return this.perm.hasWorkPermHere(perm).then(
504                             permResult => ctx.canOverride = permResult[perm]);
505                     }
506                 }
507             },
508             error => {
509                 ctx.processing = false;
510                 console.error(error);
511             }
512         );
513     }
514
515     override(ctx: HoldContext) {
516         this.placeOneHold(ctx, true);
517     }
518
519     canOverride(ctx: HoldContext): boolean {
520         return ctx.lastRequest &&
521                 !ctx.lastRequest.result.success && ctx.canOverride;
522     }
523
524     iconFormatLabel(code: string): string {
525         return this.cat.iconFormatLabel(code);
526     }
527
528     // TODO: for now, only show meta filters for meta holds.
529     // Add an "advanced holds" option to display these for T hold.
530     hasMetaFilters(ctx: HoldContext): boolean {
531         return (
532             this.holdType === 'M' && // TODO
533             ctx.holdMeta.metarecord_filters && (
534                 ctx.holdMeta.metarecord_filters.langs.length > 1 ||
535                 ctx.holdMeta.metarecord_filters.formats.length > 1
536             )
537         );
538     }
539
540     searchPatrons() {
541         this.patronSearch.open({size: 'xl'}).toPromise().then(
542             patrons => {
543                 if (!patrons || patrons.length === 0) { return; }
544                 const user = patrons[0];
545                 this.userBarcode = user.card().barcode();
546                 this.userBarcodeChanged();
547             }
548         );
549     }
550
551     isItemHold(): boolean {
552         return this.holdType === 'C'
553             || this.holdType === 'R'
554             || this.holdType === 'F';
555     }
556
557     setPart(ctx: HoldContext, $event) {
558         const partId = $event.target.value;
559         if (partId) {
560             ctx.holdMeta.part =
561                 ctx.holdMeta.parts.filter(p => +p.id() === +partId)[0];
562         } else {
563             ctx.holdMeta.part = null;
564         }
565     }
566
567     hasNoHistory(): boolean {
568         return history.length === 0;
569     }
570
571     goBack() {
572         history.back();
573     }
574 }
575
576