1 import {Component, OnInit, AfterViewInit, Input, ViewChild} from '@angular/core';
2 import {Router, ActivatedRoute, ParamMap} from '@angular/router';
3 import {Subscription, Observable, empty, of, from} from 'rxjs';
4 import {tap, switchMap} from 'rxjs/operators';
5 import {NgbNav, NgbNavChangeEvent} from '@ng-bootstrap/ng-bootstrap';
6 import {IdlObject} from '@eg/core/idl.service';
7 import {OrgService} from '@eg/core/org.service';
8 import {PcrudService} from '@eg/core/pcrud.service';
9 import {NetService} from '@eg/core/net.service';
10 import {PatronService} from '@eg/staff/share/patron/patron.service';
11 import {PatronContextService, CircGridEntry} from './patron.service';
12 import {CheckoutParams, CheckoutResult, CircService
13 } from '@eg/staff/share/circ/circ.service';
14 import {PromptDialogComponent} from '@eg/share/dialog/prompt.component';
15 import {GridDataSource, GridColumn, GridCellTextGenerator} from '@eg/share/grid/grid';
16 import {GridComponent} from '@eg/share/grid/grid.component';
17 import {Pager} from '@eg/share/util/pager';
18 import {StoreService} from '@eg/core/store.service';
19 import {ServerStoreService} from '@eg/core/server-store.service';
20 import {AudioService} from '@eg/share/util/audio.service';
21 import {CopyAlertsDialogComponent
22 } from '@eg/staff/share/holdings/copy-alerts-dialog.component';
23 import {BarcodeSelectComponent
24 } from '@eg/staff/share/barcodes/barcode-select.component';
25 import {ToastService} from '@eg/share/toast/toast.service';
26 import {StringComponent} from '@eg/share/string/string.component';
27 import {AuthService} from '@eg/core/auth.service';
28 import {PrintService} from '@eg/share/print/print.service';
30 const SESSION_DUE_DATE = 'eg.circ.checkout.is_until_logout';
33 templateUrl: 'checkout.component.html',
34 selector: 'eg-patron-checkout'
36 export class CheckoutComponent implements OnInit, AfterViewInit {
39 maxNoncats = 99; // Matches AngJS version
40 checkoutNoncat: IdlObject = null;
42 gridDataSource: GridDataSource = new GridDataSource();
43 cellTextGenerator: GridCellTextGenerator;
45 dueDateOptions: 0 | 1 | 2 = 0; // auto date; specific date; session date
46 dueDateInvalid = false;
47 printOnComplete = true;
48 strictBarcode = false;
50 private copiesInFlight: {[barcode: string]: boolean} = {};
52 @ViewChild('nonCatCount')
53 private nonCatCount: PromptDialogComponent;
54 @ViewChild('checkoutsGrid')
55 private checkoutsGrid: GridComponent;
56 @ViewChild('copyAlertsDialog')
57 private copyAlertsDialog: CopyAlertsDialogComponent;
58 @ViewChild('barcodeSelect')
59 private barcodeSelect: BarcodeSelectComponent;
60 @ViewChild('receiptEmailed')
61 private receiptEmailed: StringComponent;
64 private router: Router,
65 private store: StoreService,
66 private serverStore: ServerStoreService,
67 private org: OrgService,
68 private pcrud: PcrudService,
69 private net: NetService,
70 public circ: CircService,
71 public patronService: PatronService,
72 public context: PatronContextService,
73 private toast: ToastService,
74 private auth: AuthService,
75 private printer: PrintService,
76 private audio: AudioService
80 this.circ.getNonCatTypes();
82 this.gridDataSource.getRows = (pager: Pager, sort: any[]) => {
83 return from(this.context.checkouts);
86 this.cellTextGenerator = {
87 title: row => row.title
90 if (this.store.getSessionItem(SESSION_DUE_DATE)) {
91 this.dueDate = this.store.getSessionItem('eg.circ.checkout.due_date');
92 this.toggleDateOptions(2);
95 this.serverStore.getItem('circ.staff_client.do_not_auto_attempt_print')
97 this.printOnComplete = !(
99 noPrint.includes('Checkout')
103 this.serverStore.getItem('circ.checkout.strict_barcode')
104 .then(strict => this.strictBarcode = strict);
112 const input = document.getElementById('barcode-input');
113 if (input) { input.focus(); }
116 collectParams(): Promise<CheckoutParams> {
118 const params: CheckoutParams = {
119 patron_id: this.context.summary.id,
120 _checkbarcode: this.strictBarcode,
122 user: this.context.summary.patron.family_name(),
123 patron_id: this.context.summary.id
127 if (this.checkoutNoncat) {
129 return this.noncatPrompt().toPromise().then(count => {
130 if (!count) { return null; }
131 params.noncat = true;
132 params.noncat_count = count;
133 params.noncat_type = this.checkoutNoncat.id();
137 } else if (this.checkoutBarcode) {
139 if (this.dueDateOptions > 0) { params.due_date = this.dueDate; }
141 return this.barcodeSelect.getBarcode('asset', this.checkoutBarcode)
144 params.copy_id = selection.id;
145 params.copy_barcode = selection.barcode;
148 // User canceled the multi-match selection dialog.
154 return Promise.resolve(null);
157 checkout(params?: CheckoutParams, override?: boolean): Promise<CheckoutResult> {
159 if (this.dueDateInvalid) {
160 return Promise.resolve(null);
164 const promise = params ? Promise.resolve(params) : this.collectParams();
166 return promise.then((collectedParams: CheckoutParams) => {
167 if (!collectedParams) { return null; }
169 barcode = collectedParams.copy_barcode || '';
173 if (this.copiesInFlight[barcode]) {
174 console.debug('Item ' + barcode + ' is already mid-checkout');
178 this.copiesInFlight[barcode] = true;
181 return this.circ.checkout(collectedParams);
184 .then((result: CheckoutResult) => {
185 if (result && result.success) {
186 this.gridifyResult(result);
188 delete this.copiesInFlight[barcode];
193 .finally(() => delete this.copiesInFlight[barcode]);
197 this.checkoutBarcode = '';
198 this.checkoutNoncat = null;
202 gridifyResult(result: CheckoutResult) {
203 const entry: CircGridEntry = {
204 index: CheckoutComponent.autoId++,
210 record: result.record,
211 volume: result.volume,
212 patron: result.patron,
214 author: result.author,
218 if (result.nonCatCirc) {
220 entry.title = this.checkoutNoncat.name();
221 entry.dueDate = result.nonCatCirc.duedate();
222 entry.nonCatCount = result.params.noncat_count;
224 } else if (result.circ) {
225 entry.dueDate = result.circ.due_date();
229 // Fire and forget this one
231 this.pcrud.search('aca',
232 {copy : entry.copy.id(), ack_time : null}, {}, {atomic: true}
233 ).subscribe(alerts => entry.copyAlertCount = alerts.length);
236 this.context.checkouts.unshift(entry);
237 this.checkoutsGrid.reload();
239 // update summary data
240 this.context.refreshPatron();
243 noncatPrompt(): Observable<number> {
244 return this.nonCatCount.open()
245 .pipe(switchMap(count => {
247 if (count === null || count === undefined) {
248 return empty(); // dialog canceled
251 // Even though the prompt has a type and min/max values,
252 // users can still manually enter bogus values.
253 count = Number(count);
254 if (count > 0 && count < this.maxNoncats) {
257 // Bogus value. Try again
258 return this.noncatPrompt();
263 setDueDate(iso: string) {
264 const date = new Date(Date.parse(iso));
265 this.dueDateInvalid = (date < new Date());
267 this.store.setSessionItem('eg.circ.checkout.due_date', this.dueDate);
271 // 0: use server due date
272 // 1: use specific due date once
273 // 2: use specific due date until the end of the session.
274 toggleDateOptions(value: 1 | 2) {
275 if (this.dueDateOptions > 0) {
277 if (value === 1) { // 1 or 2 -> 0
278 this.dueDateOptions = 0;
279 this.store.removeSessionItem(SESSION_DUE_DATE);
281 } else if (this.dueDateOptions === 1) { // 1 -> 2
283 this.dueDateOptions = 2;
284 this.store.setSessionItem(SESSION_DUE_DATE, true);
288 this.dueDateOptions = 1;
289 this.store.removeSessionItem(SESSION_DUE_DATE);
294 this.dueDateOptions = value;
296 this.store.setSessionItem(SESSION_DUE_DATE, true);
301 selectedCopyIds(rows: CircGridEntry[]): number[] {
303 .filter(row => row.copy)
304 .map(row => Number(row.copy.id()));
307 openItemAlerts(rows: CircGridEntry[], mode: string) {
308 const copyIds = this.selectedCopyIds(rows);
309 if (copyIds.length === 0) { return; }
311 this.copyAlertsDialog.copyIds = copyIds;
312 this.copyAlertsDialog.mode = mode;
313 this.copyAlertsDialog.open({size: 'lg'}).subscribe(
316 rows.forEach(row => row.copyAlertCount++);
317 this.checkoutsGrid.reload();
323 toggleStrictBarcode(active: boolean) {
325 this.serverStore.setItem('circ.checkout.strict_barcode', true);
327 this.serverStore.removeItem('circ.checkout.strict_barcode');
331 patronHasEmail(): boolean {
332 if (!this.context.summary) { return false; }
333 const patron = this.context.summary.patron;
336 patron.email().match(/.*@.*/) !== null
340 mayEmailReceipt(): boolean {
341 if (!this.context.summary) { return false; }
342 const patron = this.context.summary.patron;
343 const setting = patron.settings()
344 .filter(s => s.name() === 'circ.send_email_checkout_receipts')[0];
347 this.patronHasEmail() &&
349 setting.value() === 'true' // JSON encoded
354 if (this.mayEmailReceipt()) {
362 if (this.mayEmailReceipt()) {
363 this.emailReceipt(true);
364 } else if (this.printOnComplete) {
365 this.printReceipt(true);
369 emailReceipt(redirect?: boolean) {
370 if (this.patronHasEmail() && this.context.checkouts.length > 0) {
371 return this.net.request(
373 'open-ils.circ.checkout.batch_notify.session.atomic',
375 this.context.summary.id,
376 this.context.checkouts.map(c => c.circ.id())
378 this.toast.success(this.receiptEmailed.text);
379 if (redirect) { this.doneRedirect(); }
384 printReceipt(redirect?: boolean) {
385 if (this.context.checkouts.length === 0) { return; }
388 // Wait for the print job to be queued before redirecting
389 const sub: Subscription =
390 this.printer.printJobQueued$.subscribe(_ => {
397 printContext: 'receipt',
398 templateName: 'checkout',
399 contextData: {checkouts: this.context.checkouts}
404 // Clear the assumed hold recipient since we're done with
406 this.store.removeLoginSessionItem('eg.circ.patron_hold_target');
407 this.router.navigate(['/staff/circ/patron/bcsearch']);