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