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