b6d831b10ea4201fb5ecd3deef50d419af60b688
[evergreen-equinox.git] / Open-ILS / src / eg2 / src / app / staff / share / circ / grid.component.ts
1 import {Component, OnInit, Output, Input, ViewChild, EventEmitter} from '@angular/core';
2 import {Router, ActivatedRoute, ParamMap} from '@angular/router';
3 import {Observable, empty, of, from} from 'rxjs';
4 import {map, concat, ignoreElements, last, tap, mergeMap, switchMap, concatMap} from 'rxjs/operators';
5 import {IdlObject} from '@eg/core/idl.service';
6 import {OrgService} from '@eg/core/org.service';
7 import {NetService} from '@eg/core/net.service';
8 import {AuthService} from '@eg/core/auth.service';
9 import {PcrudService} from '@eg/core/pcrud.service';
10 import {CheckoutParams, CheckoutResult, CheckinParams, CheckinResult,
11     CircDisplayInfo, CircService} from './circ.service';
12 import {PromptDialogComponent} from '@eg/share/dialog/prompt.component';
13 import {ProgressDialogComponent} from '@eg/share/dialog/progress.component';
14 import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
15 import {GridDataSource, GridColumn, GridCellTextGenerator,
16     GridRowFlairEntry} from '@eg/share/grid/grid';
17 import {GridComponent} from '@eg/share/grid/grid.component';
18 import {Pager} from '@eg/share/util/pager';
19 import {StoreService} from '@eg/core/store.service';
20 import {ServerStoreService} from '@eg/core/server-store.service';
21 import {AudioService} from '@eg/share/util/audio.service';
22 import {CopyAlertsDialogComponent
23     } from '@eg/staff/share/holdings/copy-alerts-dialog.component';
24 import {ArrayUtil} from '@eg/share/util/array';
25 import {PrintService} from '@eg/share/print/print.service';
26 import {StringComponent} from '@eg/share/string/string.component';
27 import {DueDateDialogComponent} from './due-date-dialog.component';
28 import {MarkDamagedDialogComponent
29     } from '@eg/staff/share/holdings/mark-damaged-dialog.component';
30 import {MarkMissingDialogComponent
31     } from '@eg/staff/share/holdings/mark-missing-dialog.component';
32 import {ClaimsReturnedDialogComponent} from './claims-returned-dialog.component';
33 import {ToastService} from '@eg/share/toast/toast.service';
34 import {AddBillingDialogComponent} from '@eg/staff/share/billing/billing-dialog.component';
35
36 export interface CircGridEntry extends CircDisplayInfo {
37     index: string; // class + id -- row index
38     circ?: IdlObject;
39     dueDate?: string;
40     copyAlertCount?: number;
41     nonCatCount?: number;
42     noticeCount?: number;
43     lastNotice?: string; // iso date
44
45     // useful for reporting precaculated values and avoiding
46     // repetitive date creation on grid render.
47     overdue?: boolean;
48 }
49
50 const CIRC_FLESH_DEPTH = 4;
51 const CIRC_FLESH_FIELDS = {
52   circ: ['target_copy', 'workstation', 'checkin_workstation', 'circ_lib'],
53   acp:  [
54     'call_number',
55     'holds_count',
56     'status',
57     'circ_lib',
58     'location',
59     'floating',
60     'age_protect',
61     'parts'
62   ],
63   acpm: ['part'],
64   acn:  ['record', 'owning_lib', 'prefix', 'suffix'],
65   bre:  ['wide_display_entry']
66 };
67
68 @Component({
69   templateUrl: 'grid.component.html',
70   selector: 'eg-circ-grid'
71 })
72 export class CircGridComponent implements OnInit {
73
74     @Input() persistKey: string;
75     @Input() printTemplate: string; // defaults to items_out
76     @Input() menuStyle: 'full' | 'slim' | 'none' = 'full';
77
78     // Emitted when a grid action modified data in a way that could
79     // affect which cirulcations should appear in the grid.  Caller
80     // should then refresh their data and call the load() or
81     // appendGridEntry() function.
82     @Output() reloadRequested: EventEmitter<void> = new EventEmitter<void>();
83
84     entries: CircGridEntry[] = null;
85     gridDataSource: GridDataSource = new GridDataSource();
86     cellTextGenerator: GridCellTextGenerator;
87     rowFlair: (row: CircGridEntry) => GridRowFlairEntry;
88     rowClass: (row: CircGridEntry) => string;
89     claimsNeverCount = 0;
90
91     nowDate: number = new Date().getTime();
92
93     @ViewChild('overdueString') private overdueString: StringComponent;
94     @ViewChild('circGrid') private circGrid: GridComponent;
95     @ViewChild('copyAlertsDialog')
96         private copyAlertsDialog: CopyAlertsDialogComponent;
97     @ViewChild('dueDateDialog') private dueDateDialog: DueDateDialogComponent;
98     @ViewChild('markDamagedDialog')
99         private markDamagedDialog: MarkDamagedDialogComponent;
100     @ViewChild('markMissingDialog')
101         private markMissingDialog: MarkMissingDialogComponent;
102     @ViewChild('itemsOutConfirm')
103         private itemsOutConfirm: ConfirmDialogComponent;
104     @ViewChild('claimsReturnedConfirm')
105         private claimsReturnedConfirm: ConfirmDialogComponent;
106     @ViewChild('claimsNeverConfirm')
107         private claimsNeverConfirm: ConfirmDialogComponent;
108     @ViewChild('progressDialog')
109         private progressDialog: ProgressDialogComponent;
110     @ViewChild('claimsReturnedDialog')
111         private claimsReturnedDialog: ClaimsReturnedDialogComponent;
112     @ViewChild('addBillingDialog')
113         private addBillingDialog: AddBillingDialogComponent;
114
115     constructor(
116         private org: OrgService,
117         private net: NetService,
118         private auth: AuthService,
119         private pcrud: PcrudService,
120         public circ: CircService,
121         private audio: AudioService,
122         private store: StoreService,
123         private printer: PrintService,
124         private toast: ToastService,
125         private serverStore: ServerStoreService
126     ) {}
127
128     ngOnInit() {
129
130         // The grid never fetches data directly.
131         // The caller is responsible initiating all data loads.
132         this.gridDataSource.getRows = (pager: Pager, sort: any[]) => {
133             if (!this.entries) { return empty(); }
134
135             const page = this.entries.slice(pager.offset, pager.offset + pager.limit)
136                 .filter(entry => entry !== undefined);
137
138             return from(page);
139         };
140
141         this.cellTextGenerator = {
142             title: row => row.title,
143             'copy.barcode': row => row.copy ? row.copy.barcode() : ''
144         };
145
146         this.rowFlair = (row: CircGridEntry) => {
147             if (this.circIsOverdue(row)) {
148                 return {icon: 'error_outline', title: this.overdueString.text};
149             }
150         };
151
152         this.rowClass = (row: CircGridEntry) => {
153             if (this.circIsOverdue(row)) {
154                 return 'less-intense-alert';
155             }
156         };
157     }
158
159     reportError(err: any) {
160         console.error('Circ error occurred: ' + err);
161         this.toast.danger(err); // EgEvent has a toString()
162     }
163
164     // Ask the caller to update our data set.
165     emitReloadRequest() {
166         this.entries = null;
167         this.reloadRequested.emit();
168     }
169
170     // Reload the grid without any data retrieval
171     reloadGrid() {
172         this.circGrid.reload();
173     }
174
175     // Fetch circulation data and make it available to the grid.
176     load(circIds: number[]): Observable<CircGridEntry> {
177
178         // No circs to load
179         if (!circIds || circIds.length === 0) { return empty(); }
180
181         // Return the circs we have already retrieved.
182         if (this.entries) { return from(this.entries); }
183
184         this.entries = [];
185
186         // fetchCircs and fetchNotices both return observable of grid entries.
187         // ignore the entries from fetchCircs so they are not duplicated.
188         return this.fetchCircs(circIds)
189             .pipe(ignoreElements(), concat(this.fetchNotices(circIds)));
190     }
191
192     fetchCircs(circIds: number[]): Observable<CircGridEntry> {
193
194         return this.pcrud.search('circ', {id: circIds}, {
195             flesh: CIRC_FLESH_DEPTH,
196             flesh_fields: CIRC_FLESH_FIELDS,
197             order_by : {circ : ['xact_start']},
198
199             // Avoid fetching the MARC blob by specifying which
200             // fields on the bre to select.  More may be needed.
201             // Note that fleshed fields are explicitly selected.
202             select: {bre : ['id']}
203
204         }).pipe(map(circ => {
205
206             const entry = this.gridify(circ);
207             this.appendGridEntry(entry);
208             return entry;
209         }));
210     }
211
212     fetchNotices(circIds: number[]): Observable<CircGridEntry> {
213         return this.net.request(
214             'open-ils.actor',
215             'open-ils.actor.user.itemsout.notices',
216             this.auth.token(), circIds
217         ).pipe(tap(notice => {
218
219             const entry = this.entries.filter(
220                 e => e.circ.id() === Number(notice.circ_id))[0];
221
222             entry.noticeCount = notice.numNotices;
223             entry.lastNotice = notice.lastDt;
224             return entry;
225         }));
226     }
227
228     // Also useful for manually appending circ-like things (e.g. noncat
229     // circs) that can be massaged into CircGridEntry structs.
230     appendGridEntry(entry: CircGridEntry) {
231         if (!this.entries) { this.entries = []; }
232         this.entries.push(entry);
233     }
234
235     gridify(circ: IdlObject): CircGridEntry {
236
237         const circDisplay = this.circ.getDisplayInfo(circ);
238
239         const entry: CircGridEntry = {
240             index: `circ-${circ.id()}`,
241             circ: circ,
242             dueDate: circ.due_date(),
243             title: circDisplay.title,
244             author: circDisplay.author,
245             isbn: circDisplay.isbn,
246             copy: circDisplay.copy,
247             volume: circDisplay.volume,
248             record: circDisplay.copy,
249             display: circDisplay.display,
250             copyAlertCount: 0 // TODO
251         };
252
253         return entry;
254     }
255
256     selectedCopyIds(rows: CircGridEntry[]): number[] {
257         return rows
258             .filter(row => row.copy)
259             .map(row => Number(row.copy.id()));
260     }
261
262     openItemAlerts(rows: CircGridEntry[], mode: string) {
263         const copyIds = this.selectedCopyIds(rows);
264         if (copyIds.length === 0) { return; }
265
266         this.copyAlertsDialog.copyIds = copyIds;
267         this.copyAlertsDialog.mode = mode;
268         this.copyAlertsDialog.open({size: 'lg'}).subscribe(
269             modified => {
270                 if (modified) {
271                     // TODO: verify the modified alerts are present
272                     // or go fetch them.
273                     this.circGrid.reload();
274                 }
275             }
276         );
277     }
278
279     // Which copies in the grid are selected.
280     getCopyIds(rows: CircGridEntry[], skipStatus?: number): number[] {
281         return this.getCopies(rows, skipStatus).map(c => Number(c.id()));
282     }
283
284     getCopies(rows: CircGridEntry[], skipStatus?: number): IdlObject[] {
285         let copies = rows.filter(r => r.copy).map(r => r.copy);
286         if (skipStatus) {
287             copies = copies.filter(
288                 c => Number(c.status().id()) !== Number(skipStatus));
289         }
290         return copies;
291     }
292
293     getCircIds(rows: CircGridEntry[]): number[] {
294         return this.getCircs(rows).map(row => Number(row.id()));
295     }
296
297     getCircs(rows: any): IdlObject[] {
298         return rows.filter(r => r.circ).map(r => r.circ);
299     }
300
301     printReceipts(rows: any) {
302         if (rows.length > 0) {
303             this.printer.print({
304                 templateName: this.printTemplate || 'items_out',
305                 contextData: {circulations: rows},
306                 printContext: 'default'
307             });
308         }
309     }
310
311     editDueDate(rows: any) {
312         const ids = this.getCircIds(rows);
313         if (ids.length === 0) { return; }
314
315         this.dueDateDialog.open().subscribe(isoDate => {
316             if (!isoDate) { return; } // canceled
317
318             const dialog = this.openProgressDialog(rows);
319
320             from(ids).pipe(concatMap(id => {
321                 return this.net.request(
322                     'open-ils.circ',
323                     'open-ils.circ.circulation.due_date.update',
324                     this.auth.token(), id, isoDate
325                 );
326             })).subscribe(
327                 circ => {
328                     const row = rows.filter(r => r.circ.id() === circ.id())[0];
329                     row.circ.due_date(circ.due_date());
330                     row.dueDate = circ.due_date();
331                     delete row.overdue; // it will recalculate
332                     dialog.increment();
333                 },
334                 err  => console.log(err),
335                 ()   => {
336                     dialog.close();
337                     this.emitReloadRequest();
338                 }
339             );
340         });
341     }
342
343     circIsOverdue(row: CircGridEntry): boolean {
344         const circ = row.circ;
345
346         if (!circ) { return false; } // noncat
347
348         if (row.overdue === undefined) {
349
350             if (circ.stop_fines() &&
351                 // Items that aren't really checked out can't be overdue.
352                 circ.stop_fines().match(/LOST|CLAIMSRETURNED|CLAIMSNEVERCHECKEDOUT/)) {
353                 row.overdue = false;
354             } else {
355                 row.overdue = (Date.parse(circ.due_date()) < this.nowDate);
356             }
357         }
358         return row.overdue;
359     }
360
361     markDamaged(rows: CircGridEntry[]) {
362         const copyIds = this.getCopyIds(rows, 14 /* ignore damaged */);
363
364         if (copyIds.length === 0) { return; }
365
366         let rowsModified = false;
367
368         const markNext = (ids: number[]): Promise<any> => {
369             if (ids.length === 0) {
370                 return Promise.resolve();
371             }
372
373             this.markDamagedDialog.copyId = ids.pop();
374
375             return this.markDamagedDialog.open({size: 'lg'})
376             .toPromise().then(ok => {
377                 if (ok) { rowsModified = true; }
378                 return markNext(ids);
379             });
380         };
381
382         markNext(copyIds).then(_ => {
383             if (rowsModified) {
384                 this.emitReloadRequest();
385             }
386         });
387     }
388
389     markMissing(rows: CircGridEntry[]) {
390         const copyIds = this.getCopyIds(rows, 4 /* ignore missing */);
391
392         if (copyIds.length === 0) { return; }
393
394         // This assumes all of our items our checked out, since this is
395         // a circ grid.  If we add support later for showing completed
396         // circulations, there may be cases where we can skip the items
397         // out confirmation alert and subsequent checkin
398         this.itemsOutConfirm.open().subscribe(confirmed => {
399             if (!confirmed) { return; }
400
401             this.checkin(rows, {noop: true}, true).toPromise().then(_ => {
402
403                 this.markMissingDialog.copyIds = copyIds;
404                 this.markMissingDialog.open({}).subscribe(
405                     rowsModified => {
406                         if (rowsModified) {
407                             this.emitReloadRequest();
408                         }
409                     }
410                 );
411             });
412         });
413     }
414
415     openProgressDialog(rows: CircGridEntry[]): ProgressDialogComponent {
416         this.progressDialog.update({value: 0, max: rows.length});
417         this.progressDialog.open();
418         return this.progressDialog;
419     }
420
421
422     renewAll() {
423         this.renew(this.entries);
424     }
425
426     renew(rows: CircGridEntry[]) {
427
428         const dialog = this.openProgressDialog(rows);
429         const params: CheckoutParams = {};
430         let refreshNeeded = false;
431
432         return this.circ.renewBatch(this.getCopyIds(rows))
433         .subscribe(
434             result => {
435                 dialog.increment();
436                 // Value can be null when dialogs are canceled
437                 if (result) { refreshNeeded = true; }
438             },
439             err => this.reportError(err),
440             () => {
441                 dialog.close();
442                 if (refreshNeeded) {
443                     this.emitReloadRequest();
444                 }
445             }
446         );
447     }
448
449     renewWithDate(rows: any) {
450         const ids = this.getCopyIds(rows);
451         if (ids.length === 0) { return; }
452
453         this.dueDateDialog.open().subscribe(isoDate => {
454             if (!isoDate) { return; } // canceled
455
456             const dialog = this.openProgressDialog(rows);
457             const params: CheckoutParams = {due_date: isoDate};
458
459             let refreshNeeded = false;
460             this.circ.renewBatch(ids).subscribe(
461                 resp => {
462                     if (resp.success) { refreshNeeded = true; }
463                     dialog.increment();
464                 },
465                 err => this.reportError(err),
466                 () => {
467                     dialog.close();
468                     if (refreshNeeded) {
469                         this.emitReloadRequest();
470                     }
471                 }
472             );
473         });
474     }
475
476
477     // Same params will be used for each copy
478     checkin(rows: CircGridEntry[], params?:
479         CheckinParams, noReload?: boolean): Observable<CheckinResult> {
480
481         const dialog = this.openProgressDialog(rows);
482
483         let changesApplied = false;
484         return this.circ.checkinBatch(this.getCopyIds(rows), params)
485         .pipe(tap(
486             result => {
487                 if (result) { changesApplied = true; }
488                 dialog.increment();
489             },
490             err => this.reportError(err),
491             () => {
492                 dialog.close();
493                 if (changesApplied && !noReload) { this.emitReloadRequest(); }
494             }
495         ));
496     }
497
498     markLost(rows: CircGridEntry[]) {
499         const dialog = this.openProgressDialog(rows);
500         const barcodes = this.getCopies(rows).map(c => c.barcode());
501
502         from(barcodes).pipe(concatMap(barcode => {
503             return this.net.request(
504                 'open-ils.circ',
505                 'open-ils.circ.circulation.set_lost',
506                 this.auth.token(), {barcode: barcode}
507             );
508         })).subscribe(
509             result => dialog.increment(),
510             err => this.reportError(err),
511             () => {
512                 dialog.close();
513                 this.emitReloadRequest();
514             }
515         );
516     }
517
518     claimsReturned(rows: CircGridEntry[]) {
519         this.claimsReturnedDialog.barcodes =
520             this.getCopies(rows).map(c => c.barcode());
521
522         this.claimsReturnedDialog.open().subscribe(
523             rowsModified => {
524                 if (rowsModified) {
525                     this.emitReloadRequest();
526                 }
527             }
528         );
529     }
530
531     claimsNeverCheckedOut(rows: CircGridEntry[]) {
532         const dialog = this.openProgressDialog(rows);
533
534         this.claimsNeverCount = rows.length;
535
536         this.claimsNeverConfirm.open().subscribe(confirmed => {
537             this.claimsNeverCount = 0;
538
539             if (!confirmed) {
540                 dialog.close();
541                 return;
542             }
543
544             this.circ.checkinBatch(
545                 this.getCopyIds(rows), {claims_never_checked_out: true}
546             ).subscribe(
547                 result => dialog.increment(),
548                 err => this.reportError(err),
549                 () => {
550                     dialog.close();
551                     this.emitReloadRequest();
552                 }
553             );
554         });
555     }
556
557     openBillingDialog(rows: CircGridEntry[]) {
558
559         let changesApplied = false;
560
561         from(this.getCircIds(rows))
562         .pipe(concatMap(id => {
563             this.addBillingDialog.xactId = id;
564             return this.addBillingDialog.open();
565         }))
566         .subscribe(
567             changes => {
568                 if (changes) { changesApplied = true; }
569             },
570             err => this.reportError(err),
571             ()  => {
572                 if (changesApplied) {
573                     this.emitReloadRequest();
574                 }
575             }
576         );
577     }
578
579     showRecentCircs(rows: CircGridEntry[]) {
580         const copyId = this.getCopyIds(rows)[0];
581         if (copyId) {
582             window.open('/eg/staff/cat/item/' + copyId + '/circ_list');
583         }
584     }
585
586     showTriggeredEvents(rows: CircGridEntry[]) {
587         const copyId = this.getCopyIds(rows)[0];
588         if (copyId) {
589             window.open('/eg/staff/cat/item/' + copyId + '/triggered_events');
590         }
591     }
592 }
593