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