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