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 = [];
107 this.userBarcode = null;
108 this.holdType = this.route.snapshot.params['type'];
109 this.holdTargets = this.route.snapshot.queryParams['target'];
110 this.holdFor = this.route.snapshot.queryParams['holdFor'] || 'patron';
112 if (this.staffCat.holdForBarcode) {
113 this.holdFor = 'patron';
114 this.userBarcode = this.staffCat.holdForBarcode;
117 this.store.getItem('circ.staff_placed_holds_fallback_to_ws_ou')
118 .then(setting => this.puLibWsFallback = setting === true);
120 if (!Array.isArray(this.holdTargets)) {
121 this.holdTargets = [this.holdTargets];
124 this.holdTargets = this.holdTargets.map(t => Number(t));
126 this.requestor = this.auth.user();
127 this.pickupLib = this.auth.user().ws_ou();
129 this.holdContexts = this.holdTargets.map(target => {
130 const ctx = new HoldContext(target);
136 if (this.holdFor === 'staff' || this.userBarcode) {
137 this.holdForChanged();
140 this.getTargetMeta();
141 this.placeHoldsClicked = false;
146 // Respond to changes in hold type. This currently assumes hold
147 // types only toggle post-init between copy-level types (C,R,F)
148 // and no other params (e.g. target) change with it. If other
149 // types require tracking, additional data collection may be needed.
150 this.route.paramMap.subscribe(
151 (params: ParamMap) => this.holdType = params.get('type'));
155 this.org.settings(['sms.enable', 'circ.holds.max_duplicate_holds'])
158 this.smsEnabled = sets['sms.enable'];
160 if (this.smsEnabled) {
162 'csc', {active: 't'}, {order_by: {csc: 'name'}})
163 .subscribe(carrier => {
164 this.smsCarriers.push({
166 label: carrier.name()
171 const max = sets['circ.holds.max_duplicate_holds'];
172 if (Number(max) > 0) { this.maxMultiHolds = max; }
175 setTimeout(() => // Focus barcode input
176 this.renderer.selectRootElement('#patron-barcode').focus());
179 holdCountRange(): number[] {
180 return [...Array(this.maxMultiHolds).keys()].map(n => n + 1);
183 // Load the bib, call number, copy, etc. data associated with each target.
185 this.holds.getHoldTargetMeta(this.holdType, this.holdTargets)
187 this.holdContexts.filter(ctx => ctx.holdTarget === meta.target)
190 this.mrFiltersToSelectors(ctx);
195 // By default, all metarecord filters options are enabled.
196 mrFiltersToSelectors(ctx: HoldContext) {
197 if (this.holdType !== 'M') { return; }
199 const meta = ctx.holdMeta;
200 if (meta.metarecord_filters) {
201 if (meta.metarecord_filters.formats) {
202 meta.metarecord_filters.formats.forEach(
203 ccvm => ctx.selectedFormats.formats[ccvm.code()] = true);
205 if (meta.metarecord_filters.langs) {
206 meta.metarecord_filters.langs.forEach(
207 ccvm => ctx.selectedFormats.langs[ccvm.code()] = true);
212 // Map the selected metarecord filters optoins to a JSON-encoded
213 // list of attr filters as required by the API.
214 // Compiles a blob of
215 // {target: JSON({"0": [{_attr: ctype, _val: code}, ...], "1": [...]})}
216 // TODO: this should live in the hold service, not in the UI code.
217 mrSelectorsToFilters(ctx: HoldContext): {[target: number]: string} {
219 const meta = ctx.holdMeta;
220 const slf = ctx.selectedFormats;
221 const result: any = {};
223 const formats = Object.keys(slf.formats)
224 .filter(code => Boolean(slf.formats[code])); // user-selected
226 const langs = Object.keys(slf.langs)
227 .filter(code => Boolean(slf.langs[code])); // user-selected
229 const compiled: any = {};
231 if (formats.length > 0) {
233 formats.forEach(code => {
234 const ccvm = meta.metarecord_filters.formats.filter(
235 format => format.code() === code)[0];
243 if (langs.length > 0) {
245 langs.forEach(code => {
246 const ccvm = meta.metarecord_filters.langs.filter(
247 format => format.code() === code)[0];
255 if (Object.keys(compiled).length > 0) {
257 res[ctx.holdTarget] = JSON.stringify(compiled);
267 if (this.holdFor === 'patron') {
268 if (this.userBarcode) {
269 this.userBarcodeChanged();
272 // To bypass the dupe check.
273 this.currentUserBarcode = '_' + this.requestor.id();
274 this.getUser(this.requestor.id());
278 activeDateSelected(dateStr: string) {
279 this.activeDate = dateStr;
282 userBarcodeChanged() {
284 // Avoid simultaneous or duplicate lookups
285 if (this.userBarcode === this.currentUserBarcode) {
291 if (!this.userBarcode) {
297 this.currentUserBarcode = this.userBarcode;
301 getUser(id?: number) {
302 const flesh = {flesh: 1, flesh_fields: {au: ['settings']}};
304 const promise = id ? this.patron.getById(id, flesh) :
305 this.patron.getByBarcode(this.userBarcode, flesh);
307 promise.then(user => {
309 this.applyUserSettings();
314 this.notifyEmail = true;
315 this.notifyPhone = true;
316 this.phoneValue = '';
317 this.pickupLib = this.requestor.ws_ou();
320 applyUserSettings() {
321 if (!this.user) { return; }
323 // Start with defaults.
324 this.phoneValue = this.user.day_phone() || this.user.evening_phone();
326 // Default to work org if placing holds for staff.
327 if (this.user.id() !== this.requestor.id() && !this.puLibWsFallback) {
328 // This value may be superseded below by user settings.
329 this.pickupLib = this.user.home_ou();
332 if (!this.user.settings()) { return; }
334 this.user.settings().forEach(setting => {
335 const name = setting.name();
336 let value = setting.value();
338 if (value === '' || value === null) { return; }
340 // When fleshing 'settings' on the actor.usr object,
341 // we're grabbing the raw JSON values.
342 value = JSON.parse(value);
345 case 'opac.hold_notify':
346 this.notifyPhone = Boolean(value.match(/phone/));
347 this.notifyEmail = Boolean(value.match(/email/));
348 this.notifySms = Boolean(value.match(/sms/));
351 case 'opac.default_pickup_location':
352 this.pickupLib = Number(value);
357 if (!this.user.email()) {
358 this.notifyEmail = false;
361 if (!this.phoneValue) {
362 this.notifyPhone = false;
366 // Attempt hold placement on all targets
367 placeHolds(idx?: number) {
370 if (this.multiHoldCount > 1) {
371 this.addMultHoldContexts();
375 if (!this.holdContexts[idx]) {
376 this.placeHoldsClicked = false;
380 this.placeHoldsClicked = true;
382 const ctx = this.holdContexts[idx];
383 this.placeOneHold(ctx).then(() => this.placeHolds(idx + 1));
386 // When placing holds on multiple copies per target, add a hold
387 // context for each instance of the request.
388 addMultHoldContexts() {
389 const newContexts = [];
391 this.holdContexts.forEach(ctx => {
392 for (let idx = 2; idx <= this.multiHoldCount; idx++) {
393 const newCtx = ctx.clone(ctx.holdTarget);
394 newContexts.push(newCtx);
398 // Group the contexts by hold target
399 this.holdContexts = this.holdContexts.concat(newContexts)
401 h1.holdTarget === h2.holdTarget ? 0 :
402 h1.holdTarget < h2.holdTarget ? -1 : 1
406 placeOneHold(ctx: HoldContext, override?: boolean): Promise<any> {
408 ctx.processing = true;
409 const selectedFormats = this.mrSelectorsToFilters(ctx);
411 let hType = this.holdType;
412 let hTarget = ctx.holdTarget;
413 if (hType === 'T' && ctx.holdMeta.part) {
414 // A Title hold morphs into a Part hold at hold placement time
415 // if a part is selected. This can happen on a per-hold basis
416 // when placing T-level holds.
418 hTarget = ctx.holdMeta.part.id();
421 console.debug(`Placing ${hType}-type hold on ${hTarget}`);
423 return this.holds.placeHold({
426 recipient: this.user.id(),
427 requestor: this.requestor.id(),
428 pickupLib: this.pickupLib,
430 notifyEmail: this.notifyEmail, // bool
431 notifyPhone: this.notifyPhone ? this.phoneValue : null,
432 notifySms: this.notifySms ? this.smsValue : null,
433 smsCarrier: this.notifySms ? this.smsCarrier : null,
434 thawDate: this.suspend ? this.activeDate : null,
435 frozen: this.suspend,
436 holdableFormats: selectedFormats
440 ctx.lastRequest = request;
441 ctx.processing = false;
443 if (!request.result.success) {
444 console.debug('hold failed with: ', request);
446 // If this request failed and was not already an override,
447 // see of this user has permission to override.
448 if (!request.override && request.result.evt) {
450 const txtcode = request.result.evt.textcode;
451 const perm = txtcode + '.override';
453 return this.perm.hasWorkPermHere(perm).then(
454 permResult => ctx.canOverride = permResult[perm]);
459 ctx.processing = false;
460 console.error(error);
465 override(ctx: HoldContext) {
466 this.placeOneHold(ctx, true);
469 canOverride(ctx: HoldContext): boolean {
470 return ctx.lastRequest &&
471 !ctx.lastRequest.result.success && ctx.canOverride;
474 iconFormatLabel(code: string): string {
475 return this.cat.iconFormatLabel(code);
478 // TODO: for now, only show meta filters for meta holds.
479 // Add an "advanced holds" option to display these for T hold.
480 hasMetaFilters(ctx: HoldContext): boolean {
482 this.holdType === 'M' && // TODO
483 ctx.holdMeta.metarecord_filters && (
484 ctx.holdMeta.metarecord_filters.langs.length > 1 ||
485 ctx.holdMeta.metarecord_filters.formats.length > 1
491 this.patronSearch.open({size: 'xl'}).toPromise().then(
493 if (!patrons || patrons.length === 0) { return; }
495 const user = patrons[0];
499 this.currentUserBarcode = user.card().barcode();
500 user.home_ou(this.org.get(user.home_ou()).id()); // de-flesh
501 this.applyUserSettings();
506 isItemHold(): boolean {
507 return this.holdType === 'C'
508 || this.holdType === 'R'
509 || this.holdType === 'F';
512 setPart(ctx: HoldContext, $event) {
513 const partId = $event.target.value;
516 ctx.holdMeta.parts.filter(p => +p.id() === +partId)[0];
518 ctx.holdMeta.part = null;