1 import {Component, OnInit, Input, ViewChild, Renderer2} 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} 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;
29 constructor(target: number) {
30 this.holdTarget = target;
31 this.processing = false;
32 this.selectedFormats = {
33 // code => selected-boolean
39 clone(target: number): HoldContext {
40 const ctx = new HoldContext(target);
41 ctx.holdMeta = this.holdMeta;
47 templateUrl: 'hold.component.html'
49 export class HoldComponent implements OnInit {
52 holdTargets: number[];
67 holdContexts: HoldContext[];
68 recordSummaries: BibRecordSummary[];
70 currentUserBarcode: string;
71 smsCarriers: ComboboxEntry[];
76 placeHoldsClicked: boolean;
78 puLibWsFallback = false;
80 @ViewChild('patronSearch', {static: false})
81 patronSearch: PatronSearchDialogComponent;
84 private router: Router,
85 private route: ActivatedRoute,
86 private renderer: Renderer2,
87 private evt: EventService,
88 private net: NetService,
89 private org: OrgService,
90 private store: ServerStoreService,
91 private auth: AuthService,
92 private pcrud: PcrudService,
93 private bib: BibRecordService,
94 private cat: CatalogService,
95 private staffCat: StaffCatalogService,
96 private holds: HoldsService,
97 private patron: PatronService,
98 private perm: PermService
100 this.holdContexts = [];
101 this.smsCarriers = [];
106 // Respond to changes in hold type. This currently assumes hold
107 // types only toggle post-init between copy-level types (C,R,F)
108 // and no other params (e.g. target) change with it. If other
109 // types require tracking, additional data collection may be needed.
110 this.route.paramMap.subscribe(
111 (params: ParamMap) => this.holdType = params.get('type'));
113 this.holdType = this.route.snapshot.params['type'];
114 this.holdTargets = this.route.snapshot.queryParams['target'];
115 this.holdFor = this.route.snapshot.queryParams['holdFor'] || 'patron';
117 if (this.staffCat.holdForBarcode) {
118 this.holdFor = 'patron';
119 this.userBarcode = this.staffCat.holdForBarcode;
122 this.store.getItem('circ.staff_placed_holds_fallback_to_ws_ou')
123 .then(setting => this.puLibWsFallback = setting === true);
125 if (!Array.isArray(this.holdTargets)) {
126 this.holdTargets = [this.holdTargets];
129 this.holdTargets = this.holdTargets.map(t => Number(t));
131 this.requestor = this.auth.user();
132 this.pickupLib = this.auth.user().ws_ou();
134 this.holdContexts = this.holdTargets.map(target => {
135 const ctx = new HoldContext(target);
139 if (this.holdFor === 'staff' || this.userBarcode) {
140 this.holdForChanged();
143 this.getTargetMeta();
145 this.org.settings(['sms.enable', 'circ.holds.max_duplicate_holds'])
148 this.smsEnabled = sets['sms.enable'];
150 if (this.smsEnabled) {
152 'csc', {active: 't'}, {order_by: {csc: 'name'}})
153 .subscribe(carrier => {
154 this.smsCarriers.push({
156 label: carrier.name()
161 const max = sets['circ.holds.max_duplicate_holds'];
162 if (Number(max) > 0) { this.maxMultiHolds = max; }
165 setTimeout(() => // Focus barcode input
166 this.renderer.selectRootElement('#patron-barcode').focus());
169 holdCountRange(): number[] {
170 return [...Array(this.maxMultiHolds).keys()].map(n => n + 1);
173 // Load the bib, call number, copy, etc. data associated with each target.
175 this.holds.getHoldTargetMeta(this.holdType, this.holdTargets)
177 this.holdContexts.filter(ctx => ctx.holdTarget === meta.target)
180 this.mrFiltersToSelectors(ctx);
185 // By default, all metarecord filters options are enabled.
186 mrFiltersToSelectors(ctx: HoldContext) {
187 if (this.holdType !== 'M') { return; }
189 const meta = ctx.holdMeta;
190 if (meta.metarecord_filters) {
191 if (meta.metarecord_filters.formats) {
192 meta.metarecord_filters.formats.forEach(
193 ccvm => ctx.selectedFormats.formats[ccvm.code()] = true);
195 if (meta.metarecord_filters.langs) {
196 meta.metarecord_filters.langs.forEach(
197 ccvm => ctx.selectedFormats.langs[ccvm.code()] = true);
202 // Map the selected metarecord filters optoins to a JSON-encoded
203 // list of attr filters as required by the API.
204 // Compiles a blob of
205 // {target: JSON({"0": [{_attr: ctype, _val: code}, ...], "1": [...]})}
206 // TODO: this should live in the hold service, not in the UI code.
207 mrSelectorsToFilters(ctx: HoldContext): {[target: number]: string} {
209 const meta = ctx.holdMeta;
210 const slf = ctx.selectedFormats;
211 const result: any = {};
213 const formats = Object.keys(slf.formats)
214 .filter(code => Boolean(slf.formats[code])); // user-selected
216 const langs = Object.keys(slf.langs)
217 .filter(code => Boolean(slf.langs[code])); // user-selected
219 const compiled: any = {};
221 if (formats.length > 0) {
223 formats.forEach(code => {
224 const ccvm = meta.metarecord_filters.formats.filter(
225 format => format.code() === code)[0];
233 if (langs.length > 0) {
235 langs.forEach(code => {
236 const ccvm = meta.metarecord_filters.langs.filter(
237 format => format.code() === code)[0];
245 if (Object.keys(compiled).length > 0) {
247 res[ctx.holdTarget] = JSON.stringify(compiled);
257 if (this.holdFor === 'patron') {
258 if (this.userBarcode) {
259 this.userBarcodeChanged();
262 // To bypass the dupe check.
263 this.currentUserBarcode = '_' + this.requestor.id();
264 this.getUser(this.requestor.id());
268 activeDateSelected(dateStr: string) {
269 this.activeDate = dateStr;
272 userBarcodeChanged() {
274 // Avoid simultaneous or duplicate lookups
275 if (this.userBarcode === this.currentUserBarcode) {
281 if (!this.userBarcode) {
287 this.currentUserBarcode = this.userBarcode;
291 getUser(id?: number) {
292 const flesh = {flesh: 1, flesh_fields: {au: ['settings']}};
294 const promise = id ? this.patron.getById(id, flesh) :
295 this.patron.getByBarcode(this.userBarcode, flesh);
297 promise.then(user => {
299 this.applyUserSettings();
304 this.notifyEmail = true;
305 this.notifyPhone = true;
306 this.phoneValue = '';
307 this.pickupLib = this.requestor.ws_ou();
310 applyUserSettings() {
311 if (!this.user) { return; }
313 // Start with defaults.
314 this.phoneValue = this.user.day_phone() || this.user.evening_phone();
316 // Default to work org if placing holds for staff.
317 if (this.user.id() !== this.requestor.id() && !this.puLibWsFallback) {
318 // This value may be superseded below by user settings.
319 this.pickupLib = this.user.home_ou();
322 if (!this.user.settings()) { return; }
324 this.user.settings().forEach(setting => {
325 const name = setting.name();
326 let value = setting.value();
328 if (value === '' || value === null) { return; }
330 // When fleshing 'settings' on the actor.usr object,
331 // we're grabbing the raw JSON values.
332 value = JSON.parse(value);
335 case 'opac.hold_notify':
336 this.notifyPhone = Boolean(value.match(/phone/));
337 this.notifyEmail = Boolean(value.match(/email/));
338 this.notifySms = Boolean(value.match(/sms/));
341 case 'opac.default_pickup_location':
342 this.pickupLib = Number(value);
347 if (!this.user.email()) {
348 this.notifyEmail = false;
351 if (!this.phoneValue) {
352 this.notifyPhone = false;
356 // Attempt hold placement on all targets
357 placeHolds(idx?: number) {
360 if (this.multiHoldCount > 1) {
361 this.addMultHoldContexts();
365 if (!this.holdContexts[idx]) {
366 this.placeHoldsClicked = false;
370 this.placeHoldsClicked = true;
372 const ctx = this.holdContexts[idx];
373 this.placeOneHold(ctx).then(() => this.placeHolds(idx + 1));
376 // When placing holds on multiple copies per target, add a hold
377 // context for each instance of the request.
378 addMultHoldContexts() {
379 const newContexts = [];
381 this.holdContexts.forEach(ctx => {
382 for (let idx = 2; idx <= this.multiHoldCount; idx++) {
383 const newCtx = ctx.clone(ctx.holdTarget);
384 newContexts.push(newCtx);
388 // Group the contexts by hold target
389 this.holdContexts = this.holdContexts.concat(newContexts)
391 h1.holdTarget === h2.holdTarget ? 0 :
392 h1.holdTarget < h2.holdTarget ? -1 : 1
396 placeOneHold(ctx: HoldContext, override?: boolean): Promise<any> {
398 ctx.processing = true;
399 const selectedFormats = this.mrSelectorsToFilters(ctx);
401 let hType = this.holdType;
402 let hTarget = ctx.holdTarget;
403 if (hType === 'T' && ctx.holdMeta.part) {
404 // A Title hold morphs into a Part hold at hold placement time
405 // if a part is selected. This can happen on a per-hold basis
406 // when placing T-level holds.
408 hTarget = ctx.holdMeta.part.id();
411 console.debug(`Placing ${hType}-type hold on ${hTarget}`);
413 return this.holds.placeHold({
416 recipient: this.user.id(),
417 requestor: this.requestor.id(),
418 pickupLib: this.pickupLib,
420 notifyEmail: this.notifyEmail, // bool
421 notifyPhone: this.notifyPhone ? this.phoneValue : null,
422 notifySms: this.notifySms ? this.smsValue : null,
423 smsCarrier: this.notifySms ? this.smsCarrier : null,
424 thawDate: this.suspend ? this.activeDate : null,
425 frozen: this.suspend,
426 holdableFormats: selectedFormats
430 ctx.lastRequest = request;
431 ctx.processing = false;
433 if (!request.result.success) {
434 console.debug('hold failed with: ', request);
436 // If this request failed and was not already an override,
437 // see of this user has permission to override.
438 if (!request.override && request.result.evt) {
440 const txtcode = request.result.evt.textcode;
441 const perm = txtcode + '.override';
443 return this.perm.hasWorkPermHere(perm).then(
444 permResult => ctx.canOverride = permResult[perm]);
449 ctx.processing = false;
450 console.error(error);
455 override(ctx: HoldContext) {
456 this.placeOneHold(ctx, true);
459 canOverride(ctx: HoldContext): boolean {
460 return ctx.lastRequest &&
461 !ctx.lastRequest.result.success && ctx.canOverride;
464 iconFormatLabel(code: string): string {
465 return this.cat.iconFormatLabel(code);
468 // TODO: for now, only show meta filters for meta holds.
469 // Add an "advanced holds" option to display these for T hold.
470 hasMetaFilters(ctx: HoldContext): boolean {
472 this.holdType === 'M' && // TODO
473 ctx.holdMeta.metarecord_filters && (
474 ctx.holdMeta.metarecord_filters.langs.length > 1 ||
475 ctx.holdMeta.metarecord_filters.formats.length > 1
481 this.patronSearch.open({size: 'xl'}).toPromise().then(
483 if (!patrons || patrons.length === 0) { return; }
485 const user = patrons[0];
489 this.currentUserBarcode = user.card().barcode();
490 user.home_ou(this.org.get(user.home_ou()).id()); // de-flesh
491 this.applyUserSettings();
496 isItemHold(): boolean {
497 return this.holdType === 'C'
498 || this.holdType === 'R'
499 || this.holdType === 'F';
502 setPart(ctx: HoldContext, $event) {
503 const partId = $event.target.value;
506 ctx.holdMeta.parts.filter(p => +p.id() === +partId)[0];
508 ctx.holdMeta.part = null;