1 import {Component, OnInit, ViewChild} 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, 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';
20 import {AlertDialogComponent} from '@eg/share/dialog/alert.component';
21 import {BarcodeSelectComponent
22 } from '@eg/staff/share/barcodes/barcode-select.component';
23 import {WorkLogService} from '@eg/staff/share/worklog/worklog.service';
26 holdMeta: HoldRequestTarget;
28 lastRequest: HoldRequest;
29 canOverride?: boolean;
34 constructor(target: number) {
35 this.holdTarget = target;
36 this.processing = false;
37 this.selectedFormats = {
38 // code => selected-boolean
44 clone(target: number): HoldContext {
45 const ctx = new HoldContext(target);
46 ctx.holdMeta = this.holdMeta;
52 templateUrl: 'hold.component.html'
54 export class HoldComponent implements OnInit {
57 holdTargets: number[];
69 activeDateStr: string;
70 activeDateYmd: string;
72 activeDateInvalid = false;
74 holdContexts: HoldContext[];
75 recordSummaries: BibRecordSummary[];
77 currentUserBarcode: string;
78 smsCarriers: ComboboxEntry[];
79 userBarcodeTimeout: number;
85 // True if mult-copy holds are active for the current receipient.
86 multiHoldsActive = false;
88 canPlaceMultiAt: number[] = [];
90 placeHoldsClicked: boolean;
91 badBarcode: string = null;
93 puLibWsFallback = false;
94 puLibWsDefault = false;
96 // Orgs which are not valid pickup locations
97 disableOrgs: number[] = [];
99 @ViewChild('patronSearch', {static: false})
100 patronSearch: PatronSearchDialogComponent;
102 @ViewChild('smsCbox', {static: false}) smsCbox: ComboboxComponent;
103 @ViewChild('barcodeSelect') private barcodeSelect: BarcodeSelectComponent;
105 @ViewChild('activeDateAlert') private activeDateAlert: AlertDialogComponent;
108 private router: Router,
109 private route: ActivatedRoute,
110 private evt: EventService,
111 private net: NetService,
112 private org: OrgService,
113 private store: ServerStoreService,
114 private auth: AuthService,
115 private pcrud: PcrudService,
116 private bib: BibRecordService,
117 private cat: CatalogService,
118 private staffCat: StaffCatalogService,
119 private holds: HoldsService,
120 private patron: PatronService,
121 private perm: PermService,
122 private worklog: WorkLogService
124 this.holdContexts = [];
125 this.smsCarriers = [];
130 // Respond to changes in hold type. This currently assumes hold
131 // types only toggle post-init between copy-level types (C,R,F)
132 // and no other params (e.g. target) change with it. If other
133 // types require tracking, additional data collection may be needed.
134 this.route.paramMap.subscribe(
135 (params: ParamMap) => this.holdType = params.get('type'));
137 this.holdType = this.route.snapshot.params['type'];
138 this.holdTargets = this.route.snapshot.queryParams['target'];
139 this.holdFor = this.route.snapshot.queryParams['holdFor'] || 'patron';
141 if (this.staffCat.holdForBarcode) {
142 this.holdFor = 'patron';
143 this.userBarcode = this.staffCat.holdForBarcode;
146 this.store.getItemBatch([
147 'circ.staff_placed_holds_fallback_to_ws_ou',
148 'circ.staff_placed_holds_default_to_ws_ou'
149 ]).then(settings => {
150 this.puLibWsFallback =
151 settings['circ.staff_placed_holds_fallback_to_ws_ou'] === true;
152 this.puLibWsDefault =
153 settings['circ.staff_placed_holds_default_to_ws_ou'] === true;
154 }).then(_ => this.worklog.loadSettings());
156 this.org.list().forEach(org => {
157 if (org.ou_type().can_have_vols() === 'f') {
158 this.disableOrgs.push(org.id());
162 this.net.request('open-ils.actor',
163 'open-ils.actor.settings.value_for_all_orgs',
164 null, 'opac.holds.org_unit_not_pickup_lib'
165 ).subscribe(resp => {
166 if (resp.summary.value) {
167 this.disableOrgs.push(Number(resp.org_unit));
171 if (!Array.isArray(this.holdTargets)) {
172 this.holdTargets = [this.holdTargets];
175 this.holdTargets = this.holdTargets.map(t => Number(t));
177 this.requestor = this.auth.user();
178 this.pickupLib = this.auth.user().ws_ou();
182 this.getRequestorSetsAndPerms()
185 // Load receipient data if we have any.
186 if (this.staffCat.holdForBarcode) {
187 this.holdFor = 'patron';
188 this.userBarcode = this.staffCat.holdForBarcode;
191 if (this.holdFor === 'staff' || this.userBarcode) {
192 this.holdForChanged();
197 const node = document.getElementById('patron-barcode');
198 if (node) { node.focus(); }
202 getRequestorSetsAndPerms(): Promise<any> {
204 return this.org.settings(
205 ['sms.enable', 'circ.holds.max_duplicate_holds'])
209 this.smsEnabled = sets['sms.enable'];
211 const max = Number(sets['circ.holds.max_duplicate_holds']);
212 if (Number(max) > 0) { this.maxMultiHolds = Number(max); }
214 if (this.smsEnabled) {
216 return this.patron.getSmsCarriers().then(carriers => {
217 carriers.forEach(carrier => {
218 this.smsCarriers.push({
220 label: carrier.name()
228 if (this.maxMultiHolds) {
230 // Multi-copy holds are supported. Let's see where this
231 // requestor has permission to take advantage of them.
232 return this.perm.hasWorkPermAt(
233 ['CREATE_DUPLICATE_HOLDS'], true).then(perms =>
234 this.canPlaceMultiAt = perms['CREATE_DUPLICATE_HOLDS']);
239 holdCountRange(): number[] {
240 return [...Array(this.maxMultiHolds).keys()].map(n => n + 1);
243 // Load the bib, call number, copy, etc. data associated with each target.
244 getTargetMeta(): Promise<any> {
246 return new Promise(resolve => {
247 this.holds.getHoldTargetMeta(this.holdType, this.holdTargets, this.auth.user().ws_ou())
250 this.holdContexts.filter(ctx => ctx.holdTarget === meta.target)
253 this.mrFiltersToSelectors(ctx);
256 (err: unknown) => {},
262 // By default, all metarecord filters options are enabled.
263 mrFiltersToSelectors(ctx: HoldContext) {
264 if (this.holdType !== 'M') { return; }
266 const meta = ctx.holdMeta;
267 if (meta.metarecord_filters) {
268 if (meta.metarecord_filters.formats) {
269 meta.metarecord_filters.formats.forEach(
270 ccvm => ctx.selectedFormats.formats[ccvm.code()] = true);
272 if (meta.metarecord_filters.langs) {
273 meta.metarecord_filters.langs.forEach(
274 ccvm => ctx.selectedFormats.langs[ccvm.code()] = true);
279 // Map the selected metarecord filters optoins to a JSON-encoded
280 // list of attr filters as required by the API.
281 // Compiles a blob of
282 // {target: JSON({"0": [{_attr: ctype, _val: code}, ...], "1": [...]})}
283 // TODO: this should live in the hold service, not in the UI code.
284 mrSelectorsToFilters(ctx: HoldContext): {[target: number]: string} {
286 const meta = ctx.holdMeta;
287 const slf = ctx.selectedFormats;
288 const result: any = {};
290 const formats = Object.keys(slf.formats)
291 .filter(code => Boolean(slf.formats[code])); // user-selected
293 const langs = Object.keys(slf.langs)
294 .filter(code => Boolean(slf.langs[code])); // user-selected
296 const compiled: any = {};
298 if (formats.length > 0) {
300 formats.forEach(code => {
301 const ccvm = meta.metarecord_filters.formats.filter(
302 format => format.code() === code)[0];
310 if (langs.length > 0) {
312 langs.forEach(code => {
313 const ccvm = meta.metarecord_filters.langs.filter(
314 format => format.code() === code)[0];
322 if (Object.keys(compiled).length > 0) {
324 res[ctx.holdTarget] = JSON.stringify(compiled);
334 if (this.holdFor === 'patron') {
335 if (this.userBarcode) {
336 this.userBarcodeChanged();
339 this.userBarcode = null;
340 this.currentUserBarcode = null;
341 this.getUser(this.requestor.id());
345 activeDateSelected(dateStr: string) {
346 this.activeDateStr = dateStr;
349 setActiveDate(date: Date) {
350 this.activeDate = date;
351 if (date && date < new Date()) {
352 this.activeDateInvalid = true;
353 this.activeDateAlert.open();
355 this.activeDateInvalid = false;
359 // Note this is called before this.userBarcode has its latest value.
360 debounceUserBarcodeLookup(barcode: string | ClipboardEvent) {
361 clearTimeout(this.userBarcodeTimeout);
364 this.badBarcode = null;
369 // eslint-disable-next-line no-magic-numbers
370 (barcode && (barcode as ClipboardEvent).target) ? 0 : 500;
372 this.userBarcodeTimeout =
373 setTimeout(() => this.userBarcodeChanged(), timeout);
376 userBarcodeChanged() {
377 const newBc = this.userBarcode;
379 if (!newBc) { this.resetRecipient(); return; }
381 // Avoid simultaneous or duplicate lookups
382 if (newBc === this.currentUserBarcode) { return; }
384 if (newBc !== this.staffCat.holdForBarcode) {
385 // If an alternate barcode is entered, it takes us out of
386 // place-hold-for-patron-x-from-search mode.
387 this.staffCat.clearHoldPatron();
393 getUser(id?: number): Promise<any> {
395 let promise = this.resetForm(true);
396 this.currentUserBarcode = this.userBarcode;
398 const flesh = {flesh: 1, flesh_fields: {au: ['settings']}};
400 promise = promise.then(_ => {
401 if (id) { return id; }
402 // Find the patron ID from the provided barcode.
403 return this.barcodeSelect.getBarcode('actor', this.userBarcode)
404 .then(selection => selection ? selection.id : null);
407 promise = promise.then(matchId => {
409 return this.patron.getById(matchId, flesh);
415 this.badBarcode = null;
416 return promise.then(user => {
419 // IDs are assumed to valid
420 this.badBarcode = this.userBarcode;
425 this.applyUserSettings();
426 this.multiHoldsActive =
427 this.canPlaceMultiAt.includes(user.home_ou());
431 resetRecipient(keepBarcode?: boolean) {
433 this.notifyEmail = true;
434 this.notifyPhone = true;
435 this.notifySms = false;
436 this.phoneValue = '';
437 this.pickupLib = this.requestor.ws_ou();
438 this.currentUserBarcode = null;
439 this.multiHoldCount = 1;
441 this.activeDate = null;
442 this.activeDateStr = null;
443 this.suspend = false;
444 if (this.smsCbox) { this.smsCbox.selectedId = null; }
446 // Avoid clearing the barcode in cases where the form is
447 // reset as the result of a barcode change.
448 if (!keepBarcode) { this.userBarcode = null; }
451 resetForm(keepBarcode?: boolean): Promise<any> {
452 this.placeHoldsClicked = false;
453 this.resetRecipient(keepBarcode);
455 this.holdContexts = this.holdTargets.map(target => {
456 const ctx = new HoldContext(target);
460 // Required after rebuilding the contexts
461 return this.getTargetMeta();
464 applyUserSettings() {
465 if (!this.user) { return; }
467 // Start with defaults.
468 this.phoneValue = this.user.day_phone() || this.user.evening_phone();
470 // Default to work org if placing holds for staff.
471 // Default to home org if placing holds for patrons unless
472 // settings default or fallback to the workstation.
473 if (this.user.id() !== this.requestor.id()) {
474 if (!this.puLibWsFallback && !this.puLibWsDefault) {
475 // This value may be superseded below by user settings.
476 this.pickupLib = this.user.home_ou();
480 if (!this.user.settings()) { return; }
482 this.user.settings().forEach(setting => {
483 const name = setting.name();
484 let value = setting.value();
486 if (value === '' || value === null) { return; }
488 // When fleshing 'settings' on the actor.usr object,
489 // we're grabbing the raw JSON values.
490 value = JSON.parse(value);
493 case 'opac.hold_notify':
494 this.notifyPhone = Boolean(value.match(/phone/));
495 this.notifyEmail = Boolean(value.match(/email/));
496 this.notifySms = Boolean(value.match(/sms/));
499 case 'opac.default_pickup_location':
500 if (!this.puLibWsDefault && value) {
501 this.pickupLib = Number(value);
505 case 'opac.default_phone':
506 this.phoneValue = value;
509 case 'opac.default_sms_carrier':
511 // timeout creates an extra window where the cbox
512 // can be rendered in cases where the hold receipient
513 // is known at page load time. This out of an
514 // abundance of caution.
516 this.smsCbox.selectedId = Number(value);
521 case 'opac.default_sms_notify':
522 this.smsValue = value;
527 if (!this.user.email()) {
528 this.notifyEmail = false;
531 if (!this.phoneValue) {
532 this.notifyPhone = false;
536 readyToPlaceHolds(): boolean {
537 if (!this.user || this.placeHoldsClicked || this.activeDateInvalid) {
540 if (!this.pickupLib || this.disableOrgs.includes(this.pickupLib)) {
543 if (this.notifySms) {
544 if (!this.smsValue.length || !this.smsCbox?.selectedId) {
551 // Attempt hold placement on all targets
552 placeHolds(idx?: number, override?: boolean) {
555 if (this.multiHoldCount > 1 && !override) {
556 this.addMultHoldContexts();
560 if (!this.holdContexts[idx]) {
561 return this.afterPlaceHolds(idx > 0);
564 this.placeHoldsClicked = true;
566 const ctx = this.holdContexts[idx];
567 this.placeOneHold(ctx, override).then(() =>
568 this.placeHolds(idx + 1, override)
572 afterPlaceHolds(somePlaced: boolean) {
573 this.placeHoldsClicked = false;
575 if (!somePlaced) { return; }
577 // At least one hold attempted. Confirm all succeeded
578 // before resetting the recipient info in the form.
580 this.holdContexts.forEach(ctx => {
581 if (!ctx.success) { reset = false; }
584 if (reset) { this.resetRecipient(); }
587 // When placing holds on multiple copies per target, add a hold
588 // context for each instance of the request.
589 addMultHoldContexts() {
590 const newContexts = [];
592 this.holdContexts.forEach(ctx => {
593 for (let idx = 2; idx <= this.multiHoldCount; idx++) {
594 const newCtx = ctx.clone(ctx.holdTarget);
595 newContexts.push(newCtx);
599 // Group the contexts by hold target
600 this.holdContexts = this.holdContexts.concat(newContexts)
602 h1.holdTarget === h2.holdTarget ? 0 :
603 h1.holdTarget < h2.holdTarget ? -1 : 1
607 placeOneHold(ctx: HoldContext, override?: boolean): Promise<any> {
609 if (override && !this.canOverride(ctx)) {
610 return Promise.resolve();
613 ctx.processing = true;
614 const selectedFormats = this.mrSelectorsToFilters(ctx);
616 let hType = this.holdType;
617 let hTarget = ctx.holdTarget;
619 if (ctx.holdMeta.parts && !ctx.holdMeta.part) {
620 ctx.holdMeta.part = (ctx.holdMeta.part_required ? ctx.holdMeta.parts[0] : null);
623 if (hType === 'T' && ctx.holdMeta.part) {
624 // A Title hold morphs into a Part hold at hold placement time
625 // if a part is selected. This can happen on a per-hold basis
626 // when placing T-level holds.
628 hTarget = ctx.holdMeta.part.id();
631 console.debug(`Placing ${hType}-type hold on ${hTarget}`);
633 return this.holds.placeHold({
636 recipient: this.user.id(),
637 requestor: this.requestor.id(),
638 pickupLib: this.pickupLib,
640 notifyEmail: this.notifyEmail, // bool
641 notifyPhone: this.notifyPhone ? this.phoneValue : null,
642 notifySms: this.notifySms ? this.smsValue : null,
643 smsCarrier: this.smsCbox ? this.smsCbox.selectedId : null,
644 thawDate: this.suspend ? this.activeDateStr : null,
645 frozen: this.suspend,
646 holdableFormats: selectedFormats
650 ctx.lastRequest = request;
651 ctx.processing = false;
653 if (request.result.success) {
656 this.worklog.record({
657 action: 'requested_hold',
658 hold_id: request.result.holdId,
659 patron_id: this.user.id(),
660 user: this.user.family_name()
664 console.debug('hold failed with: ', request);
666 // If this request failed and was not already an override,
667 // see of this user has permission to override.
668 if (!request.override && request.result.evt) {
670 const txtcode = request.result.evt.textcode;
671 const perm = txtcode + '.override';
673 return this.perm.hasWorkPermHere(perm).then(
674 permResult => ctx.canOverride = permResult[perm]);
679 ctx.processing = false;
680 console.error(error);
685 override(ctx: HoldContext) {
686 this.placeOneHold(ctx, true).then(() => {
687 this.afterPlaceHolds(ctx.success);
691 canOverride(ctx: HoldContext): boolean {
692 return ctx.lastRequest &&
693 !ctx.lastRequest.result.success && ctx.canOverride;
696 showOverrideAll(): boolean {
697 return this.holdContexts.filter(ctx =>
698 this.canOverride(ctx)
702 overrideAll(): void {
703 this.placeHolds(0, true);
706 iconFormatLabel(code: string): string {
707 return this.cat.iconFormatLabel(code);
710 // TODO: for now, only show meta filters for meta holds.
711 // Add an "advanced holds" option to display these for T hold.
712 hasMetaFilters(ctx: HoldContext): boolean {
714 this.holdType === 'M' && // TODO
715 ctx.holdMeta.metarecord_filters && (
716 ctx.holdMeta.metarecord_filters.langs.length > 1 ||
717 ctx.holdMeta.metarecord_filters.formats.length > 1
723 this.patronSearch.open({size: 'xl'}).toPromise().then(
725 if (!patrons || patrons.length === 0) { return; }
726 const user = patrons[0];
727 this.userBarcode = user.card().barcode();
728 this.userBarcodeChanged();
733 isItemHold(): boolean {
734 return this.holdType === 'C'
735 || this.holdType === 'R'
736 || this.holdType === 'F';
739 setPart(ctx: HoldContext, $event) {
740 const partId = $event.target.value;
743 ctx.holdMeta.parts.filter(p => +p.id() === +partId)[0];
745 ctx.holdMeta.part = null;
749 hasNoHistory(): boolean {
750 return history.length === 0;