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';
22 holdMeta: HoldRequestTarget;
24 lastRequest: HoldRequest;
25 canOverride?: boolean;
30 constructor(target: number) {
31 this.holdTarget = target;
32 this.processing = false;
33 this.selectedFormats = {
34 // code => selected-boolean
40 clone(target: number): HoldContext {
41 const ctx = new HoldContext(target);
42 ctx.holdMeta = this.holdMeta;
48 templateUrl: 'hold.component.html'
50 export class HoldComponent implements OnInit {
53 holdTargets: number[];
65 activeDateStr: string;
68 holdContexts: HoldContext[];
69 recordSummaries: BibRecordSummary[];
71 currentUserBarcode: string;
72 smsCarriers: ComboboxEntry[];
78 // True if mult-copy holds are active for the current receipient.
79 multiHoldsActive = false;
81 canPlaceMultiAt: number[] = [];
83 placeHoldsClicked: boolean;
84 badBarcode: string = null;
86 @ViewChild('patronSearch', {static: false})
87 patronSearch: PatronSearchDialogComponent;
89 @ViewChild('smsCbox', {static: false}) smsCbox: ComboboxComponent;
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
106 this.holdContexts = [];
107 this.smsCarriers = [];
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'));
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';
123 if (this.staffCat.holdForBarcode) {
124 this.holdFor = 'patron';
125 this.userBarcode = this.staffCat.holdForBarcode;
128 if (!Array.isArray(this.holdTargets)) {
129 this.holdTargets = [this.holdTargets];
132 this.holdTargets = this.holdTargets.map(t => Number(t));
134 this.requestor = this.auth.user();
135 this.pickupLib = this.auth.user().ws_ou();
139 this.getRequestorSetsAndPerms()
142 // Load receipient data if we have any.
143 if (this.staffCat.holdForBarcode) {
144 this.holdFor = 'patron';
145 this.userBarcode = this.staffCat.holdForBarcode;
148 if (this.holdFor === 'staff' || this.userBarcode) {
149 this.holdForChanged();
154 const node = document.getElementById('patron-barcode');
155 if (node) { node.focus(); }
159 getRequestorSetsAndPerms(): Promise<any> {
161 return this.org.settings(
162 ['sms.enable', 'circ.holds.max_duplicate_holds'])
166 this.smsEnabled = sets['sms.enable'];
168 const max = Number(sets['circ.holds.max_duplicate_holds']);
169 if (Number(max) > 0) { this.maxMultiHolds = Number(max); }
171 if (this.smsEnabled) {
173 return this.pcrud.search(
174 'csc', {active: 't'}, {order_by: {csc: 'name'}})
175 .pipe(tap(carrier => {
176 this.smsCarriers.push({
178 label: carrier.name()
185 if (this.maxMultiHolds) {
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']);
196 holdCountRange(): number[] {
197 return [...Array(this.maxMultiHolds).keys()].map(n => n + 1);
200 // Load the bib, call number, copy, etc. data associated with each target.
201 getTargetMeta(): Promise<any> {
203 return new Promise(resolve => {
204 this.holds.getHoldTargetMeta(this.holdType, this.holdTargets)
207 this.holdContexts.filter(ctx => ctx.holdTarget === meta.target)
210 this.mrFiltersToSelectors(ctx);
219 // By default, all metarecord filters options are enabled.
220 mrFiltersToSelectors(ctx: HoldContext) {
221 if (this.holdType !== 'M') { return; }
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);
229 if (meta.metarecord_filters.langs) {
230 meta.metarecord_filters.langs.forEach(
231 ccvm => ctx.selectedFormats.langs[ccvm.code()] = true);
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} {
243 const meta = ctx.holdMeta;
244 const slf = ctx.selectedFormats;
245 const result: any = {};
247 const formats = Object.keys(slf.formats)
248 .filter(code => Boolean(slf.formats[code])); // user-selected
250 const langs = Object.keys(slf.langs)
251 .filter(code => Boolean(slf.langs[code])); // user-selected
253 const compiled: any = {};
255 if (formats.length > 0) {
257 formats.forEach(code => {
258 const ccvm = meta.metarecord_filters.formats.filter(
259 format => format.code() === code)[0];
267 if (langs.length > 0) {
269 langs.forEach(code => {
270 const ccvm = meta.metarecord_filters.langs.filter(
271 format => format.code() === code)[0];
279 if (Object.keys(compiled).length > 0) {
281 res[ctx.holdTarget] = JSON.stringify(compiled);
291 if (this.holdFor === 'patron') {
292 if (this.userBarcode) {
293 this.userBarcodeChanged();
296 this.userBarcode = null;
297 this.currentUserBarcode = null;
298 this.getUser(this.requestor.id());
302 activeDateSelected(dateStr: string) {
303 this.activeDateStr = dateStr;
306 userBarcodeChanged() {
307 const newBc = this.userBarcode;
309 if (!newBc) { this.resetRecipient(); return; }
311 // Avoid simultaneous or duplicate lookups
312 if (newBc === this.currentUserBarcode) { return; }
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();
323 getUser(id?: number): Promise<any> {
325 let promise = this.resetForm(true);
326 this.currentUserBarcode = this.userBarcode;
328 const flesh = {flesh: 1, flesh_fields: {au: ['settings']}};
330 promise = promise.then(_ => {
332 this.patron.getById(id, flesh) :
333 this.patron.getByBarcode(this.userBarcode, flesh);
336 this.badBarcode = null;
337 return promise.then(user => {
340 // IDs are assumed to valid
341 this.badBarcode = this.userBarcode;
346 this.applyUserSettings();
347 this.multiHoldsActive =
348 this.canPlaceMultiAt.includes(user.home_ou());
352 resetRecipient(keepBarcode?: boolean) {
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;
362 this.activeDate = null;
363 this.activeDateStr = null;
364 this.suspend = false;
365 if (this.smsCbox) { this.smsCbox.selectedId = null; }
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; }
372 resetForm(keepBarcode?: boolean): Promise<any> {
373 this.placeHoldsClicked = false;
374 this.resetRecipient(keepBarcode);
376 this.holdContexts = this.holdTargets.map(target => {
377 const ctx = new HoldContext(target);
381 // Required after rebuilding the contexts
382 return this.getTargetMeta();
385 applyUserSettings() {
386 if (!this.user || !this.user.settings()) { return; }
388 // Start with defaults.
389 this.phoneValue = this.user.day_phone() || this.user.evening_phone();
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();
396 this.user.settings().forEach(setting => {
397 const name = setting.name();
398 const value = setting.value();
400 if (value === '' || value === null) { return; }
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/));
409 case 'opac.default_pickup_location':
410 this.pickupLib = value;
413 case 'opac.default_phone':
414 this.phoneValue = value;
417 case 'opac.default_sms_carrier':
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.
424 this.smsCbox.selectedId = Number(value);
429 case 'opac.default_sms_notify':
430 this.smsValue = value;
435 if (!this.user.email()) {
436 this.notifyEmail = false;
439 if (!this.phoneValue) {
440 this.notifyPhone = false;
444 // Attempt hold placement on all targets
445 placeHolds(idx?: number) {
448 if (this.multiHoldCount > 1) {
449 this.addMultHoldContexts();
453 if (!this.holdContexts[idx]) {
454 return this.afterPlaceHolds(idx > 0);
457 this.placeHoldsClicked = true;
459 const ctx = this.holdContexts[idx];
460 this.placeOneHold(ctx).then(() => this.placeHolds(idx + 1));
463 afterPlaceHolds(somePlaced: boolean) {
464 this.placeHoldsClicked = false;
466 if (!somePlaced) { return; }
468 // At least one hold attempted. Confirm all succeeded
469 // before resetting the recipient info in the form.
471 this.holdContexts.forEach(ctx => {
472 if (!ctx.success) { reset = false; }
475 if (reset) { this.resetRecipient(); }
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 = [];
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);
490 // Group the contexts by hold target
491 this.holdContexts = this.holdContexts.concat(newContexts)
493 h1.holdTarget === h2.holdTarget ? 0 :
494 h1.holdTarget < h2.holdTarget ? -1 : 1
498 placeOneHold(ctx: HoldContext, override?: boolean): Promise<any> {
500 ctx.processing = true;
501 const selectedFormats = this.mrSelectorsToFilters(ctx);
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.
510 hTarget = ctx.holdMeta.part.id();
513 console.debug(`Placing ${hType}-type hold on ${hTarget}`);
515 return this.holds.placeHold({
518 recipient: this.user.id(),
519 requestor: this.requestor.id(),
520 pickupLib: this.pickupLib,
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
532 ctx.lastRequest = request;
533 ctx.processing = false;
535 if (request.result.success) {
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); }
547 console.debug('hold failed with: ', request);
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) {
553 const txtcode = request.result.evt.textcode;
554 const perm = txtcode + '.override';
556 return this.perm.hasWorkPermHere(perm).then(
557 permResult => ctx.canOverride = permResult[perm]);
562 ctx.processing = false;
563 console.error(error);
568 override(ctx: HoldContext) {
569 this.placeOneHold(ctx, true);
572 canOverride(ctx: HoldContext): boolean {
573 return ctx.lastRequest &&
574 !ctx.lastRequest.result.success && ctx.canOverride;
577 iconFormatLabel(code: string): string {
578 return this.cat.iconFormatLabel(code);
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 {
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
594 this.patronSearch.open({size: 'xl'}).toPromise().then(
596 if (!patrons || patrons.length === 0) { return; }
597 const user = patrons[0];
598 this.userBarcode = user.card().barcode();
599 this.userBarcodeChanged();
604 isItemHold(): boolean {
605 return this.holdType === 'C'
606 || this.holdType === 'R'
607 || this.holdType === 'F';
610 setPart(ctx: HoldContext, $event) {
611 const partId = $event.target.value;
614 ctx.holdMeta.parts.filter(p => +p.id() === +partId)[0];
616 ctx.holdMeta.part = null;
620 hasNoHistory(): boolean {
621 return history.length === 0;