LP1995623 Ang checkout prevents due dates in the past
[evergreen-equinox.git] / Open-ILS / src / eg2 / src / app / staff / circ / patron / checkout.component.ts
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';
29
30 const SESSION_DUE_DATE = 'eg.circ.checkout.is_until_logout';
31
32 @Component({
33   templateUrl: 'checkout.component.html',
34   selector: 'eg-patron-checkout'
35 })
36 export class CheckoutComponent implements OnInit, AfterViewInit {
37     static autoId = 0;
38
39     maxNoncats = 99; // Matches AngJS version
40     checkoutNoncat: IdlObject = null;
41     checkoutBarcode = '';
42     gridDataSource: GridDataSource = new GridDataSource();
43     cellTextGenerator: GridCellTextGenerator;
44     dueDate: string;
45     dueDateOptions: 0 | 1 | 2 = 0; // auto date; specific date; session date
46     dueDateInvalid = false;
47     printOnComplete = true;
48     strictBarcode = false;
49
50     private copiesInFlight: {[barcode: string]: boolean} = {};
51
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;
62
63     constructor(
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
77     ) {}
78
79     ngOnInit() {
80         this.circ.getNonCatTypes();
81
82         this.gridDataSource.getRows = (pager: Pager, sort: any[]) => {
83             return from(this.context.checkouts);
84         };
85
86         this.cellTextGenerator = {
87             title: row => row.title
88         };
89
90         if (this.store.getSessionItem(SESSION_DUE_DATE)) {
91             this.dueDate = this.store.getSessionItem('eg.circ.checkout.due_date');
92             this.toggleDateOptions(2);
93         }
94
95         this.serverStore.getItem('circ.staff_client.do_not_auto_attempt_print')
96         .then(noPrint => {
97             this.printOnComplete = !(
98                 noPrint &&
99                 noPrint.includes('Checkout')
100             );
101         });
102
103         this.serverStore.getItem('circ.checkout.strict_barcode')
104         .then(strict => this.strictBarcode = strict);
105     }
106
107     ngAfterViewInit() {
108         this.focusInput();
109     }
110
111     focusInput() {
112         const input = document.getElementById('barcode-input');
113         if (input) { input.focus(); }
114     }
115
116     collectParams(): Promise<CheckoutParams> {
117
118         const params: CheckoutParams = {
119             patron_id: this.context.summary.id,
120             _checkbarcode: this.strictBarcode,
121             _worklog: {
122                 user: this.context.summary.patron.family_name(),
123                 patron_id: this.context.summary.id
124             }
125         };
126
127         if (this.checkoutNoncat) {
128
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();
134                 return params;
135             });
136
137         } else if (this.checkoutBarcode) {
138
139             if (this.dueDateOptions > 0) { params.due_date = this.dueDate; }
140
141             return this.barcodeSelect.getBarcode('asset', this.checkoutBarcode)
142             .then(selection => {
143                 if (selection) {
144                     params.copy_id = selection.id;
145                     params.copy_barcode = selection.barcode;
146                     return params;
147                 } else {
148                     // User canceled the multi-match selection dialog.
149                     return null;
150                 }
151             });
152         }
153
154         return Promise.resolve(null);
155     }
156
157     checkout(params?: CheckoutParams, override?: boolean): Promise<CheckoutResult> {
158
159         if (this.dueDateInvalid) {
160             return Promise.resolve(null);
161         }
162
163         let barcode;
164         const promise = params ? Promise.resolve(params) : this.collectParams();
165
166         return promise.then((collectedParams: CheckoutParams) => {
167             if (!collectedParams) { return null; }
168
169             barcode = collectedParams.copy_barcode || '';
170
171             if (barcode) {
172
173                 if (this.copiesInFlight[barcode]) {
174                     console.debug('Item ' + barcode + ' is already mid-checkout');
175                     return null;
176                 }
177
178                 this.copiesInFlight[barcode] = true;
179             }
180
181             return this.circ.checkout(collectedParams);
182         })
183
184         .then((result: CheckoutResult) => {
185             if (result && result.success) {
186                 this.gridifyResult(result);
187             }
188             delete this.copiesInFlight[barcode];
189             this.resetForm();
190             return result;
191         })
192
193         .finally(() => delete this.copiesInFlight[barcode]);
194     }
195
196     resetForm() {
197         this.checkoutBarcode = '';
198         this.checkoutNoncat = null;
199         this.focusInput();
200     }
201
202     gridifyResult(result: CheckoutResult) {
203         const entry: CircGridEntry = {
204             index: CheckoutComponent.autoId++,
205             copy: result.copy,
206             circ: result.circ,
207             dueDate: null,
208             copyAlertCount: 0,
209             nonCatCount: 0,
210             record: result.record,
211             volume: result.volume,
212             patron: result.patron,
213             title: result.title,
214             author: result.author,
215             isbn: result.isbn
216         };
217
218         if (result.nonCatCirc) {
219
220             entry.title = this.checkoutNoncat.name();
221             entry.dueDate = result.nonCatCirc.duedate();
222             entry.nonCatCount = result.params.noncat_count;
223
224         } else if (result.circ) {
225             entry.dueDate = result.circ.due_date();
226         }
227
228         if (entry.copy) {
229             // Fire and forget this one
230
231             this.pcrud.search('aca',
232                 {copy : entry.copy.id(), ack_time : null}, {}, {atomic: true}
233             ).subscribe(alerts => entry.copyAlertCount = alerts.length);
234         }
235
236         this.context.checkouts.unshift(entry);
237         this.checkoutsGrid.reload();
238
239         // update summary data
240         this.context.refreshPatron();
241     }
242
243     noncatPrompt(): Observable<number> {
244         return this.nonCatCount.open()
245         .pipe(switchMap(count => {
246
247             if (count === null || count === undefined) {
248                 return empty(); // dialog canceled
249             }
250
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) {
255                 return of(count);
256             } else {
257                 // Bogus value.  Try again
258                 return this.noncatPrompt();
259             }
260         }));
261     }
262
263     setDueDate(iso: string) {
264         const date = new Date(Date.parse(iso));
265         this.dueDateInvalid = (date < new Date());
266         this.dueDate = iso;
267         this.store.setSessionItem('eg.circ.checkout.due_date', this.dueDate);
268     }
269
270
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) {
276
277             if (value === 1) { // 1 or 2 -> 0
278                 this.dueDateOptions = 0;
279                 this.store.removeSessionItem(SESSION_DUE_DATE);
280
281             } else if (this.dueDateOptions === 1) { // 1 -> 2
282
283                 this.dueDateOptions = 2;
284                 this.store.setSessionItem(SESSION_DUE_DATE, true);
285
286             } else { // 2 -> 1
287
288                 this.dueDateOptions = 1;
289                 this.store.removeSessionItem(SESSION_DUE_DATE);
290             }
291
292         } else {
293
294             this.dueDateOptions = value;
295             if (value === 2) {
296                 this.store.setSessionItem(SESSION_DUE_DATE, true);
297             }
298         }
299     }
300
301     selectedCopyIds(rows: CircGridEntry[]): number[] {
302         return rows
303             .filter(row => row.copy)
304             .map(row => Number(row.copy.id()));
305     }
306
307     openItemAlerts(rows: CircGridEntry[], mode: string) {
308         const copyIds = this.selectedCopyIds(rows);
309         if (copyIds.length === 0) { return; }
310
311         this.copyAlertsDialog.copyIds = copyIds;
312         this.copyAlertsDialog.mode = mode;
313         this.copyAlertsDialog.open({size: 'lg'}).subscribe(
314             modified => {
315                 if (modified) {
316                     rows.forEach(row => row.copyAlertCount++);
317                     this.checkoutsGrid.reload();
318                 }
319             }
320         );
321     }
322
323     toggleStrictBarcode(active: boolean) {
324         if (active) {
325             this.serverStore.setItem('circ.checkout.strict_barcode', true);
326         } else {
327             this.serverStore.removeItem('circ.checkout.strict_barcode');
328         }
329     }
330
331     patronHasEmail(): boolean {
332         if (!this.context.summary) { return false; }
333         const patron = this.context.summary.patron;
334         return (
335             patron.email() &&
336             patron.email().match(/.*@.*/) !== null
337         );
338     }
339
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];
345
346         return (
347             this.patronHasEmail() &&
348             setting &&
349             setting.value() === 'true' // JSON encoded
350         );
351     }
352
353     quickReceipt() {
354         if (this.mayEmailReceipt()) {
355             this.emailReceipt();
356         } else {
357             this.printReceipt();
358         }
359     }
360
361     doneAutoReceipt() {
362         if (this.mayEmailReceipt()) {
363             this.emailReceipt(true);
364         } else if (this.printOnComplete) {
365             this.printReceipt(true);
366         }
367     }
368
369     emailReceipt(redirect?: boolean) {
370         if (this.patronHasEmail() && this.context.checkouts.length > 0) {
371             return this.net.request(
372                 'open-ils.circ',
373                 'open-ils.circ.checkout.batch_notify.session.atomic',
374                 this.auth.token(),
375                 this.context.summary.id,
376                 this.context.checkouts.map(c => c.circ.id())
377             ).subscribe(_ => {
378                 this.toast.success(this.receiptEmailed.text);
379                 if (redirect) { this.doneRedirect(); }
380             });
381         }
382     }
383
384     printReceipt(redirect?: boolean) {
385         if (this.context.checkouts.length === 0) { return; }
386
387         if (redirect) {
388             // Wait for the print job to be queued before redirecting
389             const sub: Subscription =
390                 this.printer.printJobQueued$.subscribe(_ => {
391                 sub.unsubscribe();
392                 this.doneRedirect();
393             });
394         }
395
396         this.printer.print({
397             printContext: 'receipt',
398             templateName: 'checkout',
399             contextData: {checkouts: this.context.checkouts}
400         });
401     }
402
403     doneRedirect() {
404         // Clear the assumed hold recipient since we're done with
405         // this patron.
406         this.store.removeLoginSessionItem('eg.circ.patron_hold_target');
407         this.router.navigate(['/staff/circ/patron/bcsearch']);
408     }
409 }
410