1 import {Component, OnInit, 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 {ServerStoreService} from '@eg/core/server-store.service';
12 import {BibRecordService, BibRecordSummary} from '@eg/share/catalog/bib-record.service';
13 import {CatalogService} from '@eg/share/catalog/catalog.service';
14 import {StaffCatalogService} from '../catalog.service';
15 import {HoldsService, HoldRequest,
16 HoldRequestTarget} from '@eg/staff/share/holds/holds.service';
17 import {ComboboxEntry, ComboboxComponent} from '@eg/share/combobox/combobox.component';
18 import {PatronService} from '@eg/staff/share/patron/patron.service';
19 import {PatronSearchDialogComponent
20 } from '@eg/staff/share/patron/search-dialog.component';
21 import {AlertDialogComponent} from '@eg/share/dialog/alert.component';
24 holdMeta: HoldRequestTarget;
26 lastRequest: HoldRequest;
27 canOverride?: boolean;
32 constructor(target: number) {
33 this.holdTarget = target;
34 this.processing = false;
35 this.selectedFormats = {
36 // code => selected-boolean
42 clone(target: number): HoldContext {
43 const ctx = new HoldContext(target);
44 ctx.holdMeta = this.holdMeta;
50 templateUrl: 'hold.component.html'
52 export class HoldComponent implements OnInit {
55 holdTargets: number[];
67 activeDateStr: string;
68 activeDateYmd: string;
70 activeDateInvalid = false;
72 holdContexts: HoldContext[];
73 recordSummaries: BibRecordSummary[];
75 currentUserBarcode: string;
76 smsCarriers: ComboboxEntry[];
77 userBarcodeTimeout: number;
83 // True if mult-copy holds are active for the current receipient.
84 multiHoldsActive = false;
86 canPlaceMultiAt: number[] = [];
88 placeHoldsClicked: boolean;
89 badBarcode: string = null;
91 puLibWsFallback = false;
92 puLibWsDefault = false;
94 // Orgs which are not valid pickup locations
95 disableOrgs: number[] = [];
97 @ViewChild('patronSearch', {static: false})
98 patronSearch: PatronSearchDialogComponent;
100 @ViewChild('smsCbox', {static: false}) smsCbox: ComboboxComponent;
102 @ViewChild('activeDateAlert') private activeDateAlert: AlertDialogComponent;
105 private router: Router,
106 private route: ActivatedRoute,
107 private evt: EventService,
108 private net: NetService,
109 private org: OrgService,
110 private store: ServerStoreService,
111 private auth: AuthService,
112 private pcrud: PcrudService,
113 private bib: BibRecordService,
114 private cat: CatalogService,
115 private staffCat: StaffCatalogService,
116 private holds: HoldsService,
117 private patron: PatronService,
118 private perm: PermService
120 this.holdContexts = [];
121 this.smsCarriers = [];
126 // Respond to changes in hold type. This currently assumes hold
127 // types only toggle post-init between copy-level types (C,R,F)
128 // and no other params (e.g. target) change with it. If other
129 // types require tracking, additional data collection may be needed.
130 this.route.paramMap.subscribe(
131 (params: ParamMap) => this.holdType = params.get('type'));
133 this.holdType = this.route.snapshot.params['type'];
134 this.holdTargets = this.route.snapshot.queryParams['target'];
135 this.holdFor = this.route.snapshot.queryParams['holdFor'] || 'patron';
137 if (this.staffCat.holdForBarcode) {
138 this.holdFor = 'patron';
139 this.userBarcode = this.staffCat.holdForBarcode;
142 this.store.getItemBatch([
143 'circ.staff_placed_holds_fallback_to_ws_ou',
144 'circ.staff_placed_holds_default_to_ws_ou'
145 ]).then(settings => {
146 this.puLibWsFallback =
147 settings['circ.staff_placed_holds_fallback_to_ws_ou'] === true;
148 this.puLibWsDefault =
149 settings['circ.staff_placed_holds_default_to_ws_ou'] === true;
152 this.org.list().forEach(org => {
153 if (org.ou_type().can_have_vols() === 'f') {
154 this.disableOrgs.push(org.id());
158 this.net.request('open-ils.actor',
159 'open-ils.actor.settings.value_for_all_orgs',
160 null, 'opac.holds.org_unit_not_pickup_lib'
161 ).subscribe(resp => {
162 if (resp.summary.value) {
163 this.disableOrgs.push(Number(resp.org_unit));
167 if (!Array.isArray(this.holdTargets)) {
168 this.holdTargets = [this.holdTargets];
171 this.holdTargets = this.holdTargets.map(t => Number(t));
173 this.requestor = this.auth.user();
174 this.pickupLib = this.auth.user().ws_ou();
178 this.getRequestorSetsAndPerms()
181 // Load receipient data if we have any.
182 if (this.staffCat.holdForBarcode) {
183 this.holdFor = 'patron';
184 this.userBarcode = this.staffCat.holdForBarcode;
187 if (this.holdFor === 'staff' || this.userBarcode) {
188 this.holdForChanged();
193 const node = document.getElementById('patron-barcode');
194 if (node) { node.focus(); }
198 getRequestorSetsAndPerms(): Promise<any> {
200 return this.org.settings(
201 ['sms.enable', 'circ.holds.max_duplicate_holds'])
205 this.smsEnabled = sets['sms.enable'];
207 const max = Number(sets['circ.holds.max_duplicate_holds']);
208 if (Number(max) > 0) { this.maxMultiHolds = Number(max); }
210 if (this.smsEnabled) {
212 return this.pcrud.search(
213 'csc', {active: 't'}, {order_by: {csc: 'name'}})
214 .pipe(tap(carrier => {
215 this.smsCarriers.push({
217 label: carrier.name()
224 if (this.maxMultiHolds) {
226 // Multi-copy holds are supported. Let's see where this
227 // requestor has permission to take advantage of them.
228 return this.perm.hasWorkPermAt(
229 ['CREATE_DUPLICATE_HOLDS'], true).then(perms =>
230 this.canPlaceMultiAt = perms['CREATE_DUPLICATE_HOLDS']);
235 holdCountRange(): number[] {
236 return [...Array(this.maxMultiHolds).keys()].map(n => n + 1);
239 // Load the bib, call number, copy, etc. data associated with each target.
240 getTargetMeta(): Promise<any> {
242 return new Promise(resolve => {
243 this.holds.getHoldTargetMeta(this.holdType, this.holdTargets)
246 this.holdContexts.filter(ctx => ctx.holdTarget === meta.target)
249 this.mrFiltersToSelectors(ctx);
258 // By default, all metarecord filters options are enabled.
259 mrFiltersToSelectors(ctx: HoldContext) {
260 if (this.holdType !== 'M') { return; }
262 const meta = ctx.holdMeta;
263 if (meta.metarecord_filters) {
264 if (meta.metarecord_filters.formats) {
265 meta.metarecord_filters.formats.forEach(
266 ccvm => ctx.selectedFormats.formats[ccvm.code()] = true);
268 if (meta.metarecord_filters.langs) {
269 meta.metarecord_filters.langs.forEach(
270 ccvm => ctx.selectedFormats.langs[ccvm.code()] = true);
275 // Map the selected metarecord filters optoins to a JSON-encoded
276 // list of attr filters as required by the API.
277 // Compiles a blob of
278 // {target: JSON({"0": [{_attr: ctype, _val: code}, ...], "1": [...]})}
279 // TODO: this should live in the hold service, not in the UI code.
280 mrSelectorsToFilters(ctx: HoldContext): {[target: number]: string} {
282 const meta = ctx.holdMeta;
283 const slf = ctx.selectedFormats;
284 const result: any = {};
286 const formats = Object.keys(slf.formats)
287 .filter(code => Boolean(slf.formats[code])); // user-selected
289 const langs = Object.keys(slf.langs)
290 .filter(code => Boolean(slf.langs[code])); // user-selected
292 const compiled: any = {};
294 if (formats.length > 0) {
296 formats.forEach(code => {
297 const ccvm = meta.metarecord_filters.formats.filter(
298 format => format.code() === code)[0];
306 if (langs.length > 0) {
308 langs.forEach(code => {
309 const ccvm = meta.metarecord_filters.langs.filter(
310 format => format.code() === code)[0];
318 if (Object.keys(compiled).length > 0) {
320 res[ctx.holdTarget] = JSON.stringify(compiled);
330 if (this.holdFor === 'patron') {
331 if (this.userBarcode) {
332 this.userBarcodeChanged();
335 this.userBarcode = null;
336 this.currentUserBarcode = null;
337 this.getUser(this.requestor.id());
341 activeDateSelected(dateStr: string) {
342 this.activeDateStr = dateStr;
345 setActiveDate(date: Date) {
346 this.activeDate = date;
347 if (date && date < new Date()) {
348 this.activeDateInvalid = true;
349 this.activeDateAlert.open();
351 this.activeDateInvalid = false;
355 // Note this is called before this.userBarcode has its latest value.
356 debounceUserBarcodeLookup(barcode: string | ClipboardEvent) {
357 clearTimeout(this.userBarcodeTimeout);
360 this.badBarcode = null;
365 (barcode && (barcode as ClipboardEvent).target) ? 0 : 500;
367 this.userBarcodeTimeout =
368 setTimeout(() => this.userBarcodeChanged(), timeout);
371 userBarcodeChanged() {
372 const newBc = this.userBarcode;
374 if (!newBc) { this.resetRecipient(); return; }
376 // Avoid simultaneous or duplicate lookups
377 if (newBc === this.currentUserBarcode) { return; }
379 if (newBc !== this.staffCat.holdForBarcode) {
380 // If an alternate barcode is entered, it takes us out of
381 // place-hold-for-patron-x-from-search mode.
382 this.staffCat.clearHoldPatron();
388 getUser(id?: number): Promise<any> {
390 let promise = this.resetForm(true);
391 this.currentUserBarcode = this.userBarcode;
393 const flesh = {flesh: 1, flesh_fields: {au: ['settings']}};
395 promise = promise.then(_ => {
397 this.patron.getById(id, flesh) :
398 this.patron.getByBarcode(this.userBarcode, flesh);
401 this.badBarcode = null;
402 return promise.then(user => {
405 // IDs are assumed to valid
406 this.badBarcode = this.userBarcode;
411 this.applyUserSettings();
412 this.multiHoldsActive =
413 this.canPlaceMultiAt.includes(user.home_ou());
417 resetRecipient(keepBarcode?: boolean) {
419 this.notifyEmail = true;
420 this.notifyPhone = true;
421 this.notifySms = false;
422 this.phoneValue = '';
423 this.pickupLib = this.requestor.ws_ou();
424 this.currentUserBarcode = null;
425 this.multiHoldCount = 1;
427 this.activeDate = null;
428 this.activeDateStr = null;
429 this.suspend = false;
430 if (this.smsCbox) { this.smsCbox.selectedId = null; }
432 // Avoid clearing the barcode in cases where the form is
433 // reset as the result of a barcode change.
434 if (!keepBarcode) { this.userBarcode = null; }
437 resetForm(keepBarcode?: boolean): Promise<any> {
438 this.placeHoldsClicked = false;
439 this.resetRecipient(keepBarcode);
441 this.holdContexts = this.holdTargets.map(target => {
442 const ctx = new HoldContext(target);
446 // Required after rebuilding the contexts
447 return this.getTargetMeta();
450 applyUserSettings() {
451 if (!this.user) { return; }
453 // Start with defaults.
454 this.phoneValue = this.user.day_phone() || this.user.evening_phone();
456 // Default to work org if placing holds for staff.
457 // Default to home org if placing holds for patrons unless
458 // settings default or fallback to the workstation.
459 if (this.user.id() !== this.requestor.id()) {
460 if (!this.puLibWsFallback && !this.puLibWsDefault) {
461 // This value may be superseded below by user settings.
462 this.pickupLib = this.user.home_ou();
466 if (!this.user.settings()) { return; }
468 this.user.settings().forEach(setting => {
469 const name = setting.name();
470 let value = setting.value();
472 if (value === '' || value === null) { return; }
474 // When fleshing 'settings' on the actor.usr object,
475 // we're grabbing the raw JSON values.
476 value = JSON.parse(value);
479 case 'opac.hold_notify':
480 this.notifyPhone = Boolean(value.match(/phone/));
481 this.notifyEmail = Boolean(value.match(/email/));
482 this.notifySms = Boolean(value.match(/sms/));
485 case 'opac.default_pickup_location':
486 if (!this.puLibWsDefault && value) {
487 this.pickupLib = Number(value);
491 case 'opac.default_phone':
492 this.phoneValue = value;
495 case 'opac.default_sms_carrier':
497 // timeout creates an extra window where the cbox
498 // can be rendered in cases where the hold receipient
499 // is known at page load time. This out of an
500 // abundance of caution.
502 this.smsCbox.selectedId = Number(value);
507 case 'opac.default_sms_notify':
508 this.smsValue = value;
513 if (!this.user.email()) {
514 this.notifyEmail = false;
517 if (!this.phoneValue) {
518 this.notifyPhone = false;
522 readyToPlaceHolds(): boolean {
523 if (!this.user || this.placeHoldsClicked || this.activeDateInvalid) {
526 if (this.notifySms) {
527 if (!this.smsValue.length || !this.smsCbox?.selectedId) {
534 // Attempt hold placement on all targets
535 placeHolds(idx?: number) {
538 if (this.multiHoldCount > 1) {
539 this.addMultHoldContexts();
543 if (!this.holdContexts[idx]) {
544 return this.afterPlaceHolds(idx > 0);
547 this.placeHoldsClicked = true;
549 const ctx = this.holdContexts[idx];
550 this.placeOneHold(ctx).then(() => this.placeHolds(idx + 1));
553 afterPlaceHolds(somePlaced: boolean) {
554 this.placeHoldsClicked = false;
556 if (!somePlaced) { return; }
558 // At least one hold attempted. Confirm all succeeded
559 // before resetting the recipient info in the form.
561 this.holdContexts.forEach(ctx => {
562 if (!ctx.success) { reset = false; }
565 if (reset) { this.resetRecipient(); }
568 // When placing holds on multiple copies per target, add a hold
569 // context for each instance of the request.
570 addMultHoldContexts() {
571 const newContexts = [];
573 this.holdContexts.forEach(ctx => {
574 for (let idx = 2; idx <= this.multiHoldCount; idx++) {
575 const newCtx = ctx.clone(ctx.holdTarget);
576 newContexts.push(newCtx);
580 // Group the contexts by hold target
581 this.holdContexts = this.holdContexts.concat(newContexts)
583 h1.holdTarget === h2.holdTarget ? 0 :
584 h1.holdTarget < h2.holdTarget ? -1 : 1
588 placeOneHold(ctx: HoldContext, override?: boolean): Promise<any> {
590 ctx.processing = true;
591 const selectedFormats = this.mrSelectorsToFilters(ctx);
593 let hType = this.holdType;
594 let hTarget = ctx.holdTarget;
595 if (hType === 'T' && ctx.holdMeta.part) {
596 // A Title hold morphs into a Part hold at hold placement time
597 // if a part is selected. This can happen on a per-hold basis
598 // when placing T-level holds.
600 hTarget = ctx.holdMeta.part.id();
603 console.debug(`Placing ${hType}-type hold on ${hTarget}`);
605 return this.holds.placeHold({
608 recipient: this.user.id(),
609 requestor: this.requestor.id(),
610 pickupLib: this.pickupLib,
612 notifyEmail: this.notifyEmail, // bool
613 notifyPhone: this.notifyPhone ? this.phoneValue : null,
614 notifySms: this.notifySms ? this.smsValue : null,
615 smsCarrier: this.smsCbox ? this.smsCbox.selectedId : null,
616 thawDate: this.suspend ? this.activeDateStr : null,
617 frozen: this.suspend,
618 holdableFormats: selectedFormats
622 ctx.lastRequest = request;
623 ctx.processing = false;
625 if (request.result.success) {
628 // Overrides are processed one hold at a time, so
629 // we have to invoke the post-holds logic here
630 // instead of the batch placeHolds() method. If
631 // there is ever a batch override option, this
632 // logic will have to be adjusted avoid callling
633 // afterPlaceHolds in batch mode.
634 if (override) { this.afterPlaceHolds(true); }
637 console.debug('hold failed with: ', request);
639 // If this request failed and was not already an override,
640 // see of this user has permission to override.
641 if (!request.override && request.result.evt) {
643 const txtcode = request.result.evt.textcode;
644 const perm = txtcode + '.override';
646 return this.perm.hasWorkPermHere(perm).then(
647 permResult => ctx.canOverride = permResult[perm]);
652 ctx.processing = false;
653 console.error(error);
658 override(ctx: HoldContext) {
659 this.placeOneHold(ctx, true);
662 canOverride(ctx: HoldContext): boolean {
663 return ctx.lastRequest &&
664 !ctx.lastRequest.result.success && ctx.canOverride;
667 iconFormatLabel(code: string): string {
668 return this.cat.iconFormatLabel(code);
671 // TODO: for now, only show meta filters for meta holds.
672 // Add an "advanced holds" option to display these for T hold.
673 hasMetaFilters(ctx: HoldContext): boolean {
675 this.holdType === 'M' && // TODO
676 ctx.holdMeta.metarecord_filters && (
677 ctx.holdMeta.metarecord_filters.langs.length > 1 ||
678 ctx.holdMeta.metarecord_filters.formats.length > 1
684 this.patronSearch.open({size: 'xl'}).toPromise().then(
686 if (!patrons || patrons.length === 0) { return; }
687 const user = patrons[0];
688 this.userBarcode = user.card().barcode();
689 this.userBarcodeChanged();
694 isItemHold(): boolean {
695 return this.holdType === 'C'
696 || this.holdType === 'R'
697 || this.holdType === 'F';
700 setPart(ctx: HoldContext, $event) {
701 const partId = $event.target.value;
704 ctx.holdMeta.parts.filter(p => +p.id() === +partId)[0];
706 ctx.holdMeta.part = null;
710 hasNoHistory(): boolean {
711 return history.length === 0;