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