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