LP1887429 Holds supports circ.staff_placed_holds_fallback_to_ws_ou
[Evergreen.git] / Open-ILS / src / eg2 / src / app / staff / catalog / hold / hold.component.ts
1 import {Component, OnInit, Input, ViewChild, Renderer2} 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} 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
29     constructor(target: number) {
30         this.holdTarget = target;
31         this.processing = false;
32         this.selectedFormats = {
33            // code => selected-boolean
34            formats: {},
35            langs: {}
36         };
37     }
38 }
39
40 @Component({
41   templateUrl: 'hold.component.html'
42 })
43 export class HoldComponent implements OnInit {
44
45     holdType: string;
46     holdTargets: number[];
47     user: IdlObject; //
48     userBarcode: string;
49     requestor: IdlObject;
50     holdFor: string;
51     pickupLib: number;
52     notifyEmail: boolean;
53     notifyPhone: boolean;
54     phoneValue: string;
55     notifySms: boolean;
56     smsValue: string;
57     smsCarrier: string;
58     suspend: boolean;
59     activeDate: string;
60
61     holdContexts: HoldContext[];
62     recordSummaries: BibRecordSummary[];
63
64     currentUserBarcode: string;
65     smsCarriers: ComboboxEntry[];
66
67     smsEnabled: boolean;
68     placeHoldsClicked: boolean;
69
70     puLibWsFallback = false;
71
72     @ViewChild('patronSearch', {static: false})
73       patronSearch: PatronSearchDialogComponent;
74
75     constructor(
76         private router: Router,
77         private route: ActivatedRoute,
78         private renderer: Renderer2,
79         private evt: EventService,
80         private net: NetService,
81         private org: OrgService,
82         private store: ServerStoreService,
83         private auth: AuthService,
84         private pcrud: PcrudService,
85         private bib: BibRecordService,
86         private cat: CatalogService,
87         private staffCat: StaffCatalogService,
88         private holds: HoldsService,
89         private patron: PatronService,
90         private perm: PermService
91     ) {
92         this.holdContexts = [];
93         this.smsCarriers = [];
94     }
95
96     ngOnInit() {
97
98         // Respond to changes in hold type.  This currently assumes hold
99         // types only toggle post-init between copy-level types (C,R,F)
100         // and no other params (e.g. target) change with it.  If other
101         // types require tracking, additional data collection may be needed.
102         this.route.paramMap.subscribe(
103             (params: ParamMap) => this.holdType = params.get('type'));
104
105         this.holdType = this.route.snapshot.params['type'];
106         this.holdTargets = this.route.snapshot.queryParams['target'];
107         this.holdFor = this.route.snapshot.queryParams['holdFor'] || 'patron';
108
109         if (this.staffCat.holdForBarcode) {
110             this.holdFor = 'patron';
111             this.userBarcode = this.staffCat.holdForBarcode;
112         }
113
114         this.store.getItem('circ.staff_placed_holds_fallback_to_ws_ou')
115         .then(setting => this.puLibWsFallback = setting === true);
116
117         if (!Array.isArray(this.holdTargets)) {
118             this.holdTargets = [this.holdTargets];
119         }
120
121         this.holdTargets = this.holdTargets.map(t => Number(t));
122
123         this.requestor = this.auth.user();
124         this.pickupLib = this.auth.user().ws_ou();
125
126         this.holdContexts = this.holdTargets.map(target => {
127             const ctx = new HoldContext(target);
128             return ctx;
129         });
130
131         if (this.holdFor === 'staff' || this.userBarcode) {
132             this.holdForChanged();
133         }
134
135         this.getTargetMeta();
136
137         this.org.settings('sms.enable').then(sets => {
138             this.smsEnabled = sets['sms.enable'];
139             if (!this.smsEnabled) { return; }
140
141             this.pcrud.search('csc', {active: 't'}, {order_by: {csc: 'name'}})
142             .subscribe(carrier => {
143                 this.smsCarriers.push({
144                     id: carrier.id(),
145                     label: carrier.name()
146                 });
147             });
148         });
149
150         setTimeout(() => // Focus barcode input
151             this.renderer.selectRootElement('#patron-barcode').focus());
152     }
153
154     // Load the bib, call number, copy, etc. data associated with each target.
155     getTargetMeta() {
156         this.holds.getHoldTargetMeta(this.holdType, this.holdTargets)
157         .subscribe(meta => {
158             this.holdContexts.filter(ctx => ctx.holdTarget === meta.target)
159             .forEach(ctx => {
160                 ctx.holdMeta = meta;
161                 this.mrFiltersToSelectors(ctx);
162             });
163         });
164     }
165
166     // By default, all metarecord filters options are enabled.
167     mrFiltersToSelectors(ctx: HoldContext) {
168         if (this.holdType !== 'M') { return; }
169
170         const meta = ctx.holdMeta;
171         if (meta.metarecord_filters) {
172             if (meta.metarecord_filters.formats) {
173                 meta.metarecord_filters.formats.forEach(
174                     ccvm => ctx.selectedFormats.formats[ccvm.code()] = true);
175             }
176             if (meta.metarecord_filters.langs) {
177                 meta.metarecord_filters.langs.forEach(
178                     ccvm => ctx.selectedFormats.langs[ccvm.code()] = true);
179             }
180         }
181     }
182
183     // Map the selected metarecord filters optoins to a JSON-encoded
184     // list of attr filters as required by the API.
185     // Compiles a blob of
186     // {target: JSON({"0": [{_attr: ctype, _val: code}, ...], "1": [...]})}
187     // TODO: this should live in the hold service, not in the UI code.
188     mrSelectorsToFilters(ctx: HoldContext): {[target: number]: string} {
189
190         const meta = ctx.holdMeta;
191         const slf = ctx.selectedFormats;
192         const result: any = {};
193
194         const formats = Object.keys(slf.formats)
195             .filter(code => Boolean(slf.formats[code])); // user-selected
196
197         const langs = Object.keys(slf.langs)
198             .filter(code => Boolean(slf.langs[code])); // user-selected
199
200         const compiled: any = {};
201
202         if (formats.length > 0) {
203             compiled['0'] = [];
204             formats.forEach(code => {
205                 const ccvm = meta.metarecord_filters.formats.filter(
206                     format => format.code() === code)[0];
207                 compiled['0'].push({
208                     _attr: ccvm.ctype(),
209                     _val: ccvm.code()
210                 });
211             });
212         }
213
214         if (langs.length > 0) {
215             compiled['1'] = [];
216             langs.forEach(code => {
217                 const ccvm = meta.metarecord_filters.langs.filter(
218                     format => format.code() === code)[0];
219                 compiled['1'].push({
220                     _attr: ccvm.ctype(),
221                     _val: ccvm.code()
222                 });
223             });
224         }
225
226         if (Object.keys(compiled).length > 0) {
227             const res = {};
228             res[ctx.holdTarget] = JSON.stringify(compiled);
229             return res;
230         }
231
232         return null;
233     }
234
235     holdForChanged() {
236         this.user = null;
237
238         if (this.holdFor === 'patron') {
239             if (this.userBarcode) {
240                 this.userBarcodeChanged();
241             }
242         } else {
243             // To bypass the dupe check.
244             this.currentUserBarcode = '_' + this.requestor.id();
245             this.getUser(this.requestor.id());
246         }
247     }
248
249     activeDateSelected(dateStr: string) {
250         this.activeDate = dateStr;
251     }
252
253     userBarcodeChanged() {
254
255         // Avoid simultaneous or duplicate lookups
256         if (this.userBarcode === this.currentUserBarcode) {
257             return;
258         }
259
260         this.resetForm();
261
262         if (!this.userBarcode) {
263             this.user = null;
264             return;
265         }
266
267         this.user = null;
268         this.currentUserBarcode = this.userBarcode;
269         this.getUser();
270     }
271
272     getUser(id?: number) {
273         const flesh = {flesh: 1, flesh_fields: {au: ['settings']}};
274
275         const promise = id ? this.patron.getById(id, flesh) :
276             this.patron.getByBarcode(this.userBarcode, flesh);
277
278         promise.then(user => {
279             this.user = user;
280             this.applyUserSettings();
281         });
282     }
283
284     resetForm() {
285         this.notifyEmail = true;
286         this.notifyPhone = true;
287         this.phoneValue = '';
288         this.pickupLib = this.requestor.ws_ou();
289     }
290
291     applyUserSettings() {
292         if (!this.user) { return; }
293
294         // Start with defaults.
295         this.phoneValue = this.user.day_phone() || this.user.evening_phone();
296
297         // Default to work org if placing holds for staff.
298         if (this.user.id() !== this.requestor.id() && !this.puLibWsFallback) {
299             // This value may be superseded below by user settings.
300             this.pickupLib = this.user.home_ou();
301         }
302
303         if (!this.user.settings()) { return; }
304
305         this.user.settings().forEach(setting => {
306             const name = setting.name();
307             let value = setting.value();
308
309             if (value === '' || value === null) { return; }
310
311             // When fleshing 'settings' on the actor.usr object,
312             // we're grabbing the raw JSON values.
313             value = JSON.parse(value);
314
315             switch (name) {
316                 case 'opac.hold_notify':
317                     this.notifyPhone = Boolean(value.match(/phone/));
318                     this.notifyEmail = Boolean(value.match(/email/));
319                     this.notifySms = Boolean(value.match(/sms/));
320                     break;
321
322                 case 'opac.default_pickup_location':
323                     this.pickupLib = Number(value);
324                     break;
325             }
326         });
327
328         if (!this.user.email()) {
329             this.notifyEmail = false;
330         }
331
332         if (!this.phoneValue) {
333             this.notifyPhone = false;
334         }
335     }
336
337     // Attempt hold placement on all targets
338     placeHolds(idx?: number) {
339         if (!idx) { idx = 0; }
340         if (!this.holdTargets[idx]) {
341             this.placeHoldsClicked = false;
342             return;
343         }
344         this.placeHoldsClicked = true;
345
346         const target = this.holdTargets[idx];
347         const ctx = this.holdContexts.filter(
348             c => c.holdTarget === target)[0];
349
350         this.placeOneHold(ctx).then(() => this.placeHolds(idx + 1));
351     }
352
353     placeOneHold(ctx: HoldContext, override?: boolean): Promise<any> {
354
355         ctx.processing = true;
356         const selectedFormats = this.mrSelectorsToFilters(ctx);
357
358         let hType = this.holdType;
359         let hTarget = ctx.holdTarget;
360         if (hType === 'T' && ctx.holdMeta.part) {
361             // A Title hold morphs into a Part hold at hold placement time
362             // if a part is selected.  This can happen on a per-hold basis
363             // when placing T-level holds.
364             hType = 'P';
365             hTarget = ctx.holdMeta.part.id();
366         }
367
368         console.debug(`Placing ${hType}-type hold on ${hTarget}`);
369
370         return this.holds.placeHold({
371             holdTarget: hTarget,
372             holdType: hType,
373             recipient: this.user.id(),
374             requestor: this.requestor.id(),
375             pickupLib: this.pickupLib,
376             override: override,
377             notifyEmail: this.notifyEmail, // bool
378             notifyPhone: this.notifyPhone ? this.phoneValue : null,
379             notifySms: this.notifySms ? this.smsValue : null,
380             smsCarrier: this.notifySms ? this.smsCarrier : null,
381             thawDate: this.suspend ? this.activeDate : null,
382             frozen: this.suspend,
383             holdableFormats: selectedFormats
384
385         }).toPromise().then(
386             request => {
387                 ctx.lastRequest = request;
388                 ctx.processing = false;
389
390                 if (!request.result.success) {
391                     console.debug('hold failed with: ', request);
392
393                     // If this request failed and was not already an override,
394                     // see of this user has permission to override.
395                     if (!request.override && request.result.evt) {
396
397                         const txtcode = request.result.evt.textcode;
398                         const perm = txtcode + '.override';
399
400                         return this.perm.hasWorkPermHere(perm).then(
401                             permResult => ctx.canOverride = permResult[perm]);
402                     }
403                 }
404             },
405             error => {
406                 ctx.processing = false;
407                 console.error(error);
408             }
409         );
410     }
411
412     override(ctx: HoldContext) {
413         this.placeOneHold(ctx, true);
414     }
415
416     canOverride(ctx: HoldContext): boolean {
417         return ctx.lastRequest &&
418                 !ctx.lastRequest.result.success && ctx.canOverride;
419     }
420
421     iconFormatLabel(code: string): string {
422         return this.cat.iconFormatLabel(code);
423     }
424
425     // TODO: for now, only show meta filters for meta holds.
426     // Add an "advanced holds" option to display these for T hold.
427     hasMetaFilters(ctx: HoldContext): boolean {
428         return (
429             this.holdType === 'M' && // TODO
430             ctx.holdMeta.metarecord_filters && (
431                 ctx.holdMeta.metarecord_filters.langs.length > 1 ||
432                 ctx.holdMeta.metarecord_filters.formats.length > 1
433             )
434         );
435     }
436
437     searchPatrons() {
438         this.patronSearch.open({size: 'xl'}).toPromise().then(
439             patrons => {
440                 if (!patrons || patrons.length === 0) { return; }
441
442                 const user = patrons[0];
443
444                 this.user = user;
445                 this.userBarcode =
446                     this.currentUserBarcode = user.card().barcode();
447                 user.home_ou(this.org.get(user.home_ou()).id()); // de-flesh
448                 this.applyUserSettings();
449             }
450         );
451     }
452
453     isItemHold(): boolean {
454         return this.holdType === 'C'
455             || this.holdType === 'R'
456             || this.holdType === 'F';
457     }
458
459     setPart(ctx: HoldContext, $event) {
460         const partId = $event.target.value;
461         if (partId) {
462             ctx.holdMeta.part =
463                 ctx.holdMeta.parts.filter(p => +p.id() === +partId)[0];
464         } else {
465             ctx.holdMeta.part = null;
466         }
467     }
468 }
469
470