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