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