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} 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;
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[];
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 puLibWsFallback = false;
88 @ViewChild('patronSearch', {static: false})
89 patronSearch: PatronSearchDialogComponent;
92 private router: Router,
93 private route: ActivatedRoute,
94 private evt: EventService,
95 private net: NetService,
96 private org: OrgService,
97 private store: ServerStoreService,
98 private auth: AuthService,
99 private pcrud: PcrudService,
100 private bib: BibRecordService,
101 private cat: CatalogService,
102 private staffCat: StaffCatalogService,
103 private holds: HoldsService,
104 private patron: PatronService,
105 private perm: PermService
107 this.holdContexts = [];
108 this.smsCarriers = [];
113 // Respond to changes in hold type. This currently assumes hold
114 // types only toggle post-init between copy-level types (C,R,F)
115 // and no other params (e.g. target) change with it. If other
116 // types require tracking, additional data collection may be needed.
117 this.route.paramMap.subscribe(
118 (params: ParamMap) => this.holdType = params.get('type'));
120 this.holdType = this.route.snapshot.params['type'];
121 this.holdTargets = this.route.snapshot.queryParams['target'];
122 this.holdFor = this.route.snapshot.queryParams['holdFor'] || 'patron';
124 if (this.staffCat.holdForBarcode) {
125 this.holdFor = 'patron';
126 this.userBarcode = this.staffCat.holdForBarcode;
129 this.store.getItem('circ.staff_placed_holds_fallback_to_ws_ou')
130 .then(setting => this.puLibWsFallback = setting === true);
132 if (!Array.isArray(this.holdTargets)) {
133 this.holdTargets = [this.holdTargets];
136 this.holdTargets = this.holdTargets.map(t => Number(t));
138 this.requestor = this.auth.user();
139 this.pickupLib = this.auth.user().ws_ou();
143 this.getRequestorSetsAndPerms()
146 // Load receipient data if we have any.
147 if (this.staffCat.holdForBarcode) {
148 this.holdFor = 'patron';
149 this.userBarcode = this.staffCat.holdForBarcode;
152 if (this.holdFor === 'staff' || this.userBarcode) {
153 this.holdForChanged();
158 const node = document.getElementById('patron-barcode');
159 if (node) { node.focus(); }
163 getRequestorSetsAndPerms(): Promise<any> {
165 return this.org.settings(
166 ['sms.enable', 'circ.holds.max_duplicate_holds'])
170 this.smsEnabled = sets['sms.enable'];
172 const max = Number(sets['circ.holds.max_duplicate_holds']);
173 if (Number(max) > 0) { this.maxMultiHolds = Number(max); }
175 if (this.smsEnabled) {
177 return this.pcrud.search(
178 'csc', {active: 't'}, {order_by: {csc: 'name'}})
179 .pipe(tap(carrier => {
180 this.smsCarriers.push({
182 label: carrier.name()
189 if (this.maxMultiHolds) {
191 // Multi-copy holds are supported. Let's see where this
192 // requestor has permission to take advantage of them.
193 return this.perm.hasWorkPermAt(
194 ['CREATE_DUPLICATE_HOLDS'], true).then(perms =>
195 this.canPlaceMultiAt = perms['CREATE_DUPLICATE_HOLDS']);
200 holdCountRange(): number[] {
201 return [...Array(this.maxMultiHolds).keys()].map(n => n + 1);
204 // Load the bib, call number, copy, etc. data associated with each target.
206 this.holds.getHoldTargetMeta(this.holdType, this.holdTargets)
208 this.holdContexts.filter(ctx => ctx.holdTarget === meta.target)
211 this.mrFiltersToSelectors(ctx);
216 // By default, all metarecord filters options are enabled.
217 mrFiltersToSelectors(ctx: HoldContext) {
218 if (this.holdType !== 'M') { return; }
220 const meta = ctx.holdMeta;
221 if (meta.metarecord_filters) {
222 if (meta.metarecord_filters.formats) {
223 meta.metarecord_filters.formats.forEach(
224 ccvm => ctx.selectedFormats.formats[ccvm.code()] = true);
226 if (meta.metarecord_filters.langs) {
227 meta.metarecord_filters.langs.forEach(
228 ccvm => ctx.selectedFormats.langs[ccvm.code()] = true);
233 // Map the selected metarecord filters optoins to a JSON-encoded
234 // list of attr filters as required by the API.
235 // Compiles a blob of
236 // {target: JSON({"0": [{_attr: ctype, _val: code}, ...], "1": [...]})}
237 // TODO: this should live in the hold service, not in the UI code.
238 mrSelectorsToFilters(ctx: HoldContext): {[target: number]: string} {
240 const meta = ctx.holdMeta;
241 const slf = ctx.selectedFormats;
242 const result: any = {};
244 const formats = Object.keys(slf.formats)
245 .filter(code => Boolean(slf.formats[code])); // user-selected
247 const langs = Object.keys(slf.langs)
248 .filter(code => Boolean(slf.langs[code])); // user-selected
250 const compiled: any = {};
252 if (formats.length > 0) {
254 formats.forEach(code => {
255 const ccvm = meta.metarecord_filters.formats.filter(
256 format => format.code() === code)[0];
264 if (langs.length > 0) {
266 langs.forEach(code => {
267 const ccvm = meta.metarecord_filters.langs.filter(
268 format => format.code() === code)[0];
276 if (Object.keys(compiled).length > 0) {
278 res[ctx.holdTarget] = JSON.stringify(compiled);
288 if (this.holdFor === 'patron') {
289 if (this.userBarcode) {
290 this.userBarcodeChanged();
293 // To bypass the dupe check.
294 this.currentUserBarcode = '_' + this.requestor.id();
295 this.getUser(this.requestor.id());
299 activeDateSelected(dateStr: string) {
300 this.activeDate = dateStr;
303 userBarcodeChanged() {
304 const newBc = this.userBarcode;
306 if (!newBc) { this.user = null; return; }
308 // Avoid simultaneous or duplicate lookups
309 if (newBc === this.currentUserBarcode) { return; }
311 if (newBc !== this.staffCat.holdForBarcode) {
312 // If an alternate barcode is entered, it takes us out of
313 // place-hold-for-patron-x-from-search mode.
314 this.staffCat.clearHoldPatron();
318 this.userBarcode = newBc; // clobbered in reset
320 this.currentUserBarcode = this.userBarcode;
324 getUser(id?: number) {
325 const flesh = {flesh: 1, flesh_fields: {au: ['settings']}};
327 const promise = id ? this.patron.getById(id, flesh) :
328 this.patron.getByBarcode(this.userBarcode, flesh);
330 this.badBarcode = null;
331 promise.then(user => {
334 // IDs are assumed to valid
335 this.badBarcode = this.userBarcode;
340 this.applyUserSettings();
341 this.multiHoldsActive =
342 this.canPlaceMultiAt.includes(user.home_ou());
348 this.userBarcode = null;
349 this.notifyEmail = true;
350 this.notifyPhone = true;
351 this.phoneValue = '';
352 this.pickupLib = this.requestor.ws_ou();
353 this.placeHoldsClicked = false;
355 this.holdContexts = this.holdTargets.map(target => {
356 const ctx = new HoldContext(target);
360 // Required after rebuilding the contexts
361 this.getTargetMeta();
364 applyUserSettings() {
365 if (!this.user) { return; }
367 // Start with defaults.
368 this.phoneValue = this.user.day_phone() || this.user.evening_phone();
370 // Default to work org if placing holds for staff.
371 if (this.user.id() !== this.requestor.id() && !this.puLibWsFallback) {
372 // This value may be superseded below by user settings.
373 this.pickupLib = this.user.home_ou();
376 if (!this.user.settings()) { return; }
378 this.user.settings().forEach(setting => {
379 const name = setting.name();
380 let value = setting.value();
382 if (value === '' || value === null) { return; }
384 // When fleshing 'settings' on the actor.usr object,
385 // we're grabbing the raw JSON values.
386 value = JSON.parse(value);
389 case 'opac.hold_notify':
390 this.notifyPhone = Boolean(value.match(/phone/));
391 this.notifyEmail = Boolean(value.match(/email/));
392 this.notifySms = Boolean(value.match(/sms/));
395 case 'opac.default_pickup_location':
396 this.pickupLib = Number(value);
401 if (!this.user.email()) {
402 this.notifyEmail = false;
405 if (!this.phoneValue) {
406 this.notifyPhone = false;
410 // Attempt hold placement on all targets
411 placeHolds(idx?: number) {
414 if (this.multiHoldCount > 1) {
415 this.addMultHoldContexts();
419 if (!this.holdContexts[idx]) {
420 this.placeHoldsClicked = false;
424 this.placeHoldsClicked = true;
426 const ctx = this.holdContexts[idx];
427 this.placeOneHold(ctx).then(() => this.placeHolds(idx + 1));
430 // When placing holds on multiple copies per target, add a hold
431 // context for each instance of the request.
432 addMultHoldContexts() {
433 const newContexts = [];
435 this.holdContexts.forEach(ctx => {
436 for (let idx = 2; idx <= this.multiHoldCount; idx++) {
437 const newCtx = ctx.clone(ctx.holdTarget);
438 newContexts.push(newCtx);
442 // Group the contexts by hold target
443 this.holdContexts = this.holdContexts.concat(newContexts)
445 h1.holdTarget === h2.holdTarget ? 0 :
446 h1.holdTarget < h2.holdTarget ? -1 : 1
450 placeOneHold(ctx: HoldContext, override?: boolean): Promise<any> {
452 ctx.processing = true;
453 const selectedFormats = this.mrSelectorsToFilters(ctx);
455 let hType = this.holdType;
456 let hTarget = ctx.holdTarget;
457 if (hType === 'T' && ctx.holdMeta.part) {
458 // A Title hold morphs into a Part hold at hold placement time
459 // if a part is selected. This can happen on a per-hold basis
460 // when placing T-level holds.
462 hTarget = ctx.holdMeta.part.id();
465 console.debug(`Placing ${hType}-type hold on ${hTarget}`);
467 return this.holds.placeHold({
470 recipient: this.user.id(),
471 requestor: this.requestor.id(),
472 pickupLib: this.pickupLib,
474 notifyEmail: this.notifyEmail, // bool
475 notifyPhone: this.notifyPhone ? this.phoneValue : null,
476 notifySms: this.notifySms ? this.smsValue : null,
477 smsCarrier: this.notifySms ? this.smsCarrier : null,
478 thawDate: this.suspend ? this.activeDate : null,
479 frozen: this.suspend,
480 holdableFormats: selectedFormats
484 ctx.lastRequest = request;
485 ctx.processing = false;
487 if (!request.result.success) {
488 console.debug('hold failed with: ', request);
490 // If this request failed and was not already an override,
491 // see of this user has permission to override.
492 if (!request.override && request.result.evt) {
494 const txtcode = request.result.evt.textcode;
495 const perm = txtcode + '.override';
497 return this.perm.hasWorkPermHere(perm).then(
498 permResult => ctx.canOverride = permResult[perm]);
503 ctx.processing = false;
504 console.error(error);
509 override(ctx: HoldContext) {
510 this.placeOneHold(ctx, true);
513 canOverride(ctx: HoldContext): boolean {
514 return ctx.lastRequest &&
515 !ctx.lastRequest.result.success && ctx.canOverride;
518 iconFormatLabel(code: string): string {
519 return this.cat.iconFormatLabel(code);
522 // TODO: for now, only show meta filters for meta holds.
523 // Add an "advanced holds" option to display these for T hold.
524 hasMetaFilters(ctx: HoldContext): boolean {
526 this.holdType === 'M' && // TODO
527 ctx.holdMeta.metarecord_filters && (
528 ctx.holdMeta.metarecord_filters.langs.length > 1 ||
529 ctx.holdMeta.metarecord_filters.formats.length > 1
535 this.patronSearch.open({size: 'xl'}).toPromise().then(
537 if (!patrons || patrons.length === 0) { return; }
538 const user = patrons[0];
539 this.userBarcode = user.card().barcode();
540 this.userBarcodeChanged();
545 isItemHold(): boolean {
546 return this.holdType === 'C'
547 || this.holdType === 'R'
548 || this.holdType === 'F';
551 setPart(ctx: HoldContext, $event) {
552 const partId = $event.target.value;
555 ctx.holdMeta.parts.filter(p => +p.id() === +partId)[0];
557 ctx.holdMeta.part = null;