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