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 {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';
23 holdMeta: HoldRequestTarget;
25 lastRequest: HoldRequest;
26 canOverride?: boolean;
31 constructor(target: number) {
32 this.holdTarget = target;
33 this.processing = false;
34 this.selectedFormats = {
35 // code => selected-boolean
41 clone(target: number): HoldContext {
42 const ctx = new HoldContext(target);
43 ctx.holdMeta = this.holdMeta;
49 templateUrl: 'hold.component.html'
51 export class HoldComponent implements OnInit {
54 holdTargets: number[];
66 activeDateStr: string;
69 holdContexts: HoldContext[];
70 recordSummaries: BibRecordSummary[];
72 currentUserBarcode: string;
73 smsCarriers: ComboboxEntry[];
79 // True if mult-copy holds are active for the current receipient.
80 multiHoldsActive = false;
82 canPlaceMultiAt: number[] = [];
84 placeHoldsClicked: boolean;
85 badBarcode: string = null;
87 puLibWsFallback = false;
89 // Orgs which are not valid pickup locations
90 disableOrgs: number[] = [];
92 @ViewChild('patronSearch', {static: false})
93 patronSearch: PatronSearchDialogComponent;
95 @ViewChild('smsCbox', {static: false}) smsCbox: ComboboxComponent;
98 private router: Router,
99 private route: ActivatedRoute,
100 private evt: EventService,
101 private net: NetService,
102 private org: OrgService,
103 private store: ServerStoreService,
104 private auth: AuthService,
105 private pcrud: PcrudService,
106 private bib: BibRecordService,
107 private cat: CatalogService,
108 private staffCat: StaffCatalogService,
109 private holds: HoldsService,
110 private patron: PatronService,
111 private perm: PermService
113 this.holdContexts = [];
114 this.smsCarriers = [];
119 // Respond to changes in hold type. This currently assumes hold
120 // types only toggle post-init between copy-level types (C,R,F)
121 // and no other params (e.g. target) change with it. If other
122 // types require tracking, additional data collection may be needed.
123 this.route.paramMap.subscribe(
124 (params: ParamMap) => this.holdType = params.get('type'));
126 this.holdType = this.route.snapshot.params['type'];
127 this.holdTargets = this.route.snapshot.queryParams['target'];
128 this.holdFor = this.route.snapshot.queryParams['holdFor'] || 'patron';
130 if (this.staffCat.holdForBarcode) {
131 this.holdFor = 'patron';
132 this.userBarcode = this.staffCat.holdForBarcode;
135 this.store.getItem('circ.staff_placed_holds_fallback_to_ws_ou')
136 .then(setting => this.puLibWsFallback = setting === true);
138 this.org.list().forEach(org => {
139 if (org.ou_type().can_have_vols() === 'f') {
140 this.disableOrgs.push(org.id());
144 this.net.request('open-ils.actor',
145 'open-ils.actor.settings.value_for_all_orgs',
146 null, 'opac.holds.org_unit_not_pickup_lib'
147 ).subscribe(resp => {
148 if (resp.summary.value) {
149 this.disableOrgs.push(Number(resp.org_unit));
153 if (!Array.isArray(this.holdTargets)) {
154 this.holdTargets = [this.holdTargets];
157 this.holdTargets = this.holdTargets.map(t => Number(t));
159 this.requestor = this.auth.user();
160 this.pickupLib = this.auth.user().ws_ou();
164 this.getRequestorSetsAndPerms()
167 // Load receipient data if we have any.
168 if (this.staffCat.holdForBarcode) {
169 this.holdFor = 'patron';
170 this.userBarcode = this.staffCat.holdForBarcode;
173 if (this.holdFor === 'staff' || this.userBarcode) {
174 this.holdForChanged();
179 const node = document.getElementById('patron-barcode');
180 if (node) { node.focus(); }
184 getRequestorSetsAndPerms(): Promise<any> {
186 return this.org.settings(
187 ['sms.enable', 'circ.holds.max_duplicate_holds'])
191 this.smsEnabled = sets['sms.enable'];
193 const max = Number(sets['circ.holds.max_duplicate_holds']);
194 if (Number(max) > 0) { this.maxMultiHolds = Number(max); }
196 if (this.smsEnabled) {
198 return this.pcrud.search(
199 'csc', {active: 't'}, {order_by: {csc: 'name'}})
200 .pipe(tap(carrier => {
201 this.smsCarriers.push({
203 label: carrier.name()
210 if (this.maxMultiHolds) {
212 // Multi-copy holds are supported. Let's see where this
213 // requestor has permission to take advantage of them.
214 return this.perm.hasWorkPermAt(
215 ['CREATE_DUPLICATE_HOLDS'], true).then(perms =>
216 this.canPlaceMultiAt = perms['CREATE_DUPLICATE_HOLDS']);
221 holdCountRange(): number[] {
222 return [...Array(this.maxMultiHolds).keys()].map(n => n + 1);
225 // Load the bib, call number, copy, etc. data associated with each target.
226 getTargetMeta(): Promise<any> {
228 return new Promise(resolve => {
229 this.holds.getHoldTargetMeta(this.holdType, this.holdTargets)
232 this.holdContexts.filter(ctx => ctx.holdTarget === meta.target)
235 this.mrFiltersToSelectors(ctx);
244 // By default, all metarecord filters options are enabled.
245 mrFiltersToSelectors(ctx: HoldContext) {
246 if (this.holdType !== 'M') { return; }
248 const meta = ctx.holdMeta;
249 if (meta.metarecord_filters) {
250 if (meta.metarecord_filters.formats) {
251 meta.metarecord_filters.formats.forEach(
252 ccvm => ctx.selectedFormats.formats[ccvm.code()] = true);
254 if (meta.metarecord_filters.langs) {
255 meta.metarecord_filters.langs.forEach(
256 ccvm => ctx.selectedFormats.langs[ccvm.code()] = true);
261 // Map the selected metarecord filters optoins to a JSON-encoded
262 // list of attr filters as required by the API.
263 // Compiles a blob of
264 // {target: JSON({"0": [{_attr: ctype, _val: code}, ...], "1": [...]})}
265 // TODO: this should live in the hold service, not in the UI code.
266 mrSelectorsToFilters(ctx: HoldContext): {[target: number]: string} {
268 const meta = ctx.holdMeta;
269 const slf = ctx.selectedFormats;
270 const result: any = {};
272 const formats = Object.keys(slf.formats)
273 .filter(code => Boolean(slf.formats[code])); // user-selected
275 const langs = Object.keys(slf.langs)
276 .filter(code => Boolean(slf.langs[code])); // user-selected
278 const compiled: any = {};
280 if (formats.length > 0) {
282 formats.forEach(code => {
283 const ccvm = meta.metarecord_filters.formats.filter(
284 format => format.code() === code)[0];
292 if (langs.length > 0) {
294 langs.forEach(code => {
295 const ccvm = meta.metarecord_filters.langs.filter(
296 format => format.code() === code)[0];
304 if (Object.keys(compiled).length > 0) {
306 res[ctx.holdTarget] = JSON.stringify(compiled);
316 if (this.holdFor === 'patron') {
317 if (this.userBarcode) {
318 this.userBarcodeChanged();
321 this.userBarcode = null;
322 this.currentUserBarcode = null;
323 this.getUser(this.requestor.id());
327 activeDateSelected(dateStr: string) {
328 this.activeDateStr = dateStr;
331 userBarcodeChanged() {
332 const newBc = this.userBarcode;
334 if (!newBc) { this.resetRecipient(); return; }
336 // Avoid simultaneous or duplicate lookups
337 if (newBc === this.currentUserBarcode) { return; }
339 if (newBc !== this.staffCat.holdForBarcode) {
340 // If an alternate barcode is entered, it takes us out of
341 // place-hold-for-patron-x-from-search mode.
342 this.staffCat.clearHoldPatron();
348 getUser(id?: number): Promise<any> {
350 let promise = this.resetForm(true);
351 this.currentUserBarcode = this.userBarcode;
353 const flesh = {flesh: 1, flesh_fields: {au: ['settings']}};
355 promise = promise.then(_ => {
357 this.patron.getById(id, flesh) :
358 this.patron.getByBarcode(this.userBarcode, flesh);
361 this.badBarcode = null;
362 return promise.then(user => {
365 // IDs are assumed to valid
366 this.badBarcode = this.userBarcode;
371 this.applyUserSettings();
372 this.multiHoldsActive =
373 this.canPlaceMultiAt.includes(user.home_ou());
377 resetRecipient(keepBarcode?: boolean) {
379 this.notifyEmail = true;
380 this.notifyPhone = true;
381 this.notifySms = false;
382 this.phoneValue = '';
383 this.pickupLib = this.requestor.ws_ou();
384 this.currentUserBarcode = null;
385 this.multiHoldCount = 1;
387 this.activeDate = null;
388 this.activeDateStr = null;
389 this.suspend = false;
390 if (this.smsCbox) { this.smsCbox.selectedId = null; }
392 // Avoid clearing the barcode in cases where the form is
393 // reset as the result of a barcode change.
394 if (!keepBarcode) { this.userBarcode = null; }
397 resetForm(keepBarcode?: boolean): Promise<any> {
398 this.placeHoldsClicked = false;
399 this.resetRecipient(keepBarcode);
401 this.holdContexts = this.holdTargets.map(target => {
402 const ctx = new HoldContext(target);
406 // Required after rebuilding the contexts
407 return this.getTargetMeta();
410 applyUserSettings() {
411 if (!this.user) { return; }
413 // Start with defaults.
414 this.phoneValue = this.user.day_phone() || this.user.evening_phone();
416 // Default to work org if placing holds for staff.
417 if (this.user.id() !== this.requestor.id() && !this.puLibWsFallback) {
418 // This value may be superseded below by user settings.
419 this.pickupLib = this.user.home_ou();
422 if (!this.user.settings()) { return; }
424 this.user.settings().forEach(setting => {
425 const name = setting.name();
426 let value = setting.value();
428 if (value === '' || value === null) { return; }
430 // When fleshing 'settings' on the actor.usr object,
431 // we're grabbing the raw JSON values.
432 value = JSON.parse(value);
435 case 'opac.hold_notify':
436 this.notifyPhone = Boolean(value.match(/phone/));
437 this.notifyEmail = Boolean(value.match(/email/));
438 this.notifySms = Boolean(value.match(/sms/));
441 case 'opac.default_pickup_location':
442 this.pickupLib = Number(value);
445 case 'opac.default_phone':
446 this.phoneValue = value;
449 case 'opac.default_sms_carrier':
451 // timeout creates an extra window where the cbox
452 // can be rendered in cases where the hold receipient
453 // is known at page load time. This out of an
454 // abundance of caution.
456 this.smsCbox.selectedId = Number(value);
461 case 'opac.default_sms_notify':
462 this.smsValue = value;
467 if (!this.user.email()) {
468 this.notifyEmail = false;
471 if (!this.phoneValue) {
472 this.notifyPhone = false;
476 // Attempt hold placement on all targets
477 placeHolds(idx?: number) {
480 if (this.multiHoldCount > 1) {
481 this.addMultHoldContexts();
485 if (!this.holdContexts[idx]) {
486 return this.afterPlaceHolds(idx > 0);
489 this.placeHoldsClicked = true;
491 const ctx = this.holdContexts[idx];
492 this.placeOneHold(ctx).then(() => this.placeHolds(idx + 1));
495 afterPlaceHolds(somePlaced: boolean) {
496 this.placeHoldsClicked = false;
498 if (!somePlaced) { return; }
500 // At least one hold attempted. Confirm all succeeded
501 // before resetting the recipient info in the form.
503 this.holdContexts.forEach(ctx => {
504 if (!ctx.success) { reset = false; }
507 if (reset) { this.resetRecipient(); }
510 // When placing holds on multiple copies per target, add a hold
511 // context for each instance of the request.
512 addMultHoldContexts() {
513 const newContexts = [];
515 this.holdContexts.forEach(ctx => {
516 for (let idx = 2; idx <= this.multiHoldCount; idx++) {
517 const newCtx = ctx.clone(ctx.holdTarget);
518 newContexts.push(newCtx);
522 // Group the contexts by hold target
523 this.holdContexts = this.holdContexts.concat(newContexts)
525 h1.holdTarget === h2.holdTarget ? 0 :
526 h1.holdTarget < h2.holdTarget ? -1 : 1
530 placeOneHold(ctx: HoldContext, override?: boolean): Promise<any> {
532 ctx.processing = true;
533 const selectedFormats = this.mrSelectorsToFilters(ctx);
535 let hType = this.holdType;
536 let hTarget = ctx.holdTarget;
537 if (hType === 'T' && ctx.holdMeta.part) {
538 // A Title hold morphs into a Part hold at hold placement time
539 // if a part is selected. This can happen on a per-hold basis
540 // when placing T-level holds.
542 hTarget = ctx.holdMeta.part.id();
545 console.debug(`Placing ${hType}-type hold on ${hTarget}`);
547 return this.holds.placeHold({
550 recipient: this.user.id(),
551 requestor: this.requestor.id(),
552 pickupLib: this.pickupLib,
554 notifyEmail: this.notifyEmail, // bool
555 notifyPhone: this.notifyPhone ? this.phoneValue : null,
556 notifySms: this.notifySms ? this.smsValue : null,
557 smsCarrier: this.smsCbox ? this.smsCbox.selectedId : null,
558 thawDate: this.suspend ? this.activeDateStr : null,
559 frozen: this.suspend,
560 holdableFormats: selectedFormats
564 ctx.lastRequest = request;
565 ctx.processing = false;
567 if (request.result.success) {
570 // Overrides are processed one hold at a time, so
571 // we have to invoke the post-holds logic here
572 // instead of the batch placeHolds() method. If
573 // there is ever a batch override option, this
574 // logic will have to be adjusted avoid callling
575 // afterPlaceHolds in batch mode.
576 if (override) { this.afterPlaceHolds(true); }
579 console.debug('hold failed with: ', request);
581 // If this request failed and was not already an override,
582 // see of this user has permission to override.
583 if (!request.override && request.result.evt) {
585 const txtcode = request.result.evt.textcode;
586 const perm = txtcode + '.override';
588 return this.perm.hasWorkPermHere(perm).then(
589 permResult => ctx.canOverride = permResult[perm]);
594 ctx.processing = false;
595 console.error(error);
600 override(ctx: HoldContext) {
601 this.placeOneHold(ctx, true);
604 canOverride(ctx: HoldContext): boolean {
605 return ctx.lastRequest &&
606 !ctx.lastRequest.result.success && ctx.canOverride;
609 iconFormatLabel(code: string): string {
610 return this.cat.iconFormatLabel(code);
613 // TODO: for now, only show meta filters for meta holds.
614 // Add an "advanced holds" option to display these for T hold.
615 hasMetaFilters(ctx: HoldContext): boolean {
617 this.holdType === 'M' && // TODO
618 ctx.holdMeta.metarecord_filters && (
619 ctx.holdMeta.metarecord_filters.langs.length > 1 ||
620 ctx.holdMeta.metarecord_filters.formats.length > 1
626 this.patronSearch.open({size: 'xl'}).toPromise().then(
628 if (!patrons || patrons.length === 0) { return; }
629 const user = patrons[0];
630 this.userBarcode = user.card().barcode();
631 this.userBarcodeChanged();
636 isItemHold(): boolean {
637 return this.holdType === 'C'
638 || this.holdType === 'R'
639 || this.holdType === 'F';
642 setPart(ctx: HoldContext, $event) {
643 const partId = $event.target.value;
646 ctx.holdMeta.parts.filter(p => +p.id() === +partId)[0];
648 ctx.holdMeta.part = null;
652 hasNoHistory(): boolean {
653 return history.length === 0;