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