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