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