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';
36 export interface CircGridEntry extends CircDisplayInfo {
37 index: string; // class + id -- row index
40 copyAlertCount?: number;
43 lastNotice?: string; // iso date
45 // useful for reporting precaculated values and avoiding
46 // repetitive date creation on grid render.
50 const CIRC_FLESH_DEPTH = 4;
51 const CIRC_FLESH_FIELDS = {
52 circ: ['target_copy', 'workstation', 'checkin_workstation', 'circ_lib'],
64 acn: ['record', 'owning_lib', 'prefix', 'suffix'],
65 bre: ['wide_display_entry']
69 templateUrl: 'grid.component.html',
70 selector: 'eg-circ-grid'
72 export class CircGridComponent implements OnInit {
74 @Input() persistKey: string;
75 @Input() printTemplate: string; // defaults to items_out
76 @Input() menuStyle: 'full' | 'slim' | 'none' = 'full';
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>();
84 entries: CircGridEntry[] = null;
85 gridDataSource: GridDataSource = new GridDataSource();
86 cellTextGenerator: GridCellTextGenerator;
87 rowFlair: (row: CircGridEntry) => GridRowFlairEntry;
88 rowClass: (row: CircGridEntry) => string;
91 nowDate: number = new Date().getTime();
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;
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
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(); }
135 const page = this.entries.slice(pager.offset, pager.offset + pager.limit)
136 .filter(entry => entry !== undefined);
141 this.cellTextGenerator = {
142 title: row => row.title,
143 'copy.barcode': row => row.copy ? row.copy.barcode() : ''
146 this.rowFlair = (row: CircGridEntry) => {
147 if (this.circIsOverdue(row)) {
148 return {icon: 'error_outline', title: this.overdueString.text};
152 this.rowClass = (row: CircGridEntry) => {
153 if (this.circIsOverdue(row)) {
154 return 'less-intense-alert';
159 reportError(err: any) {
160 console.error('Circ error occurred: ' + err);
161 this.toast.danger(err); // EgEvent has a toString()
164 // Ask the caller to update our data set.
165 emitReloadRequest() {
167 this.reloadRequested.emit();
170 // Reload the grid without any data retrieval
172 this.circGrid.reload();
175 // Fetch circulation data and make it available to the grid.
176 load(circIds: number[]): Observable<CircGridEntry> {
179 if (!circIds || circIds.length === 0) { return empty(); }
181 // Return the circs we have already retrieved.
182 if (this.entries) { return from(this.entries); }
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)));
192 fetchCircs(circIds: number[]): Observable<CircGridEntry> {
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']},
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']}
204 }).pipe(map(circ => {
206 const entry = this.gridify(circ);
207 this.appendGridEntry(entry);
212 fetchNotices(circIds: number[]): Observable<CircGridEntry> {
213 return this.net.request(
215 'open-ils.actor.user.itemsout.notices',
216 this.auth.token(), circIds
217 ).pipe(tap(notice => {
219 const entry = this.entries.filter(
220 e => e.circ.id() === Number(notice.circ_id))[0];
222 entry.noticeCount = notice.numNotices;
223 entry.lastNotice = notice.lastDt;
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);
235 gridify(circ: IdlObject): CircGridEntry {
237 const circDisplay = this.circ.getDisplayInfo(circ);
239 const entry: CircGridEntry = {
240 index: `circ-${circ.id()}`,
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
256 selectedCopyIds(rows: CircGridEntry[]): number[] {
258 .filter(row => row.copy)
259 .map(row => Number(row.copy.id()));
262 openItemAlerts(rows: CircGridEntry[], mode: string) {
263 const copyIds = this.selectedCopyIds(rows);
264 if (copyIds.length === 0) { return; }
266 this.copyAlertsDialog.copyIds = copyIds;
267 this.copyAlertsDialog.mode = mode;
268 this.copyAlertsDialog.open({size: 'lg'}).subscribe(
271 // TODO: verify the modified alerts are present
273 this.circGrid.reload();
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()));
284 getCopies(rows: CircGridEntry[], skipStatus?: number): IdlObject[] {
285 let copies = rows.filter(r => r.copy).map(r => r.copy);
287 copies = copies.filter(
288 c => Number(c.status().id()) !== Number(skipStatus));
293 getCircIds(rows: CircGridEntry[]): number[] {
294 return this.getCircs(rows).map(row => Number(row.id()));
297 getCircs(rows: any): IdlObject[] {
298 return rows.filter(r => r.circ).map(r => r.circ);
301 printReceipts(rows: any) {
302 if (rows.length > 0) {
304 templateName: this.printTemplate || 'items_out',
305 contextData: {circulations: rows},
306 printContext: 'default'
311 editDueDate(rows: any) {
312 const ids = this.getCircIds(rows);
313 if (ids.length === 0) { return; }
315 this.dueDateDialog.open().subscribe(isoDate => {
316 if (!isoDate) { return; } // canceled
318 const dialog = this.openProgressDialog(rows);
320 from(ids).pipe(concatMap(id => {
321 return this.net.request(
323 'open-ils.circ.circulation.due_date.update',
324 this.auth.token(), id, isoDate
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
334 err => console.log(err),
337 this.emitReloadRequest();
343 circIsOverdue(row: CircGridEntry): boolean {
344 const circ = row.circ;
346 if (!circ) { return false; } // noncat
348 if (row.overdue === undefined) {
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/)) {
355 row.overdue = (Date.parse(circ.due_date()) < this.nowDate);
361 markDamaged(rows: CircGridEntry[]) {
362 const copyIds = this.getCopyIds(rows, 14 /* ignore damaged */);
364 if (copyIds.length === 0) { return; }
366 let rowsModified = false;
368 const markNext = (ids: number[]): Promise<any> => {
369 if (ids.length === 0) {
370 return Promise.resolve();
373 this.markDamagedDialog.copyId = ids.pop();
375 return this.markDamagedDialog.open({size: 'lg'})
376 .toPromise().then(ok => {
377 if (ok) { rowsModified = true; }
378 return markNext(ids);
382 markNext(copyIds).then(_ => {
384 this.emitReloadRequest();
389 markMissing(rows: CircGridEntry[]) {
390 const copyIds = this.getCopyIds(rows, 4 /* ignore missing */);
392 if (copyIds.length === 0) { return; }
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; }
401 this.checkin(rows, {noop: true}, true).toPromise().then(_ => {
403 this.markMissingDialog.copyIds = copyIds;
404 this.markMissingDialog.open({}).subscribe(
407 this.emitReloadRequest();
415 openProgressDialog(rows: CircGridEntry[]): ProgressDialogComponent {
416 this.progressDialog.update({value: 0, max: rows.length});
417 this.progressDialog.open();
418 return this.progressDialog;
423 this.renew(this.entries);
426 renew(rows: CircGridEntry[]) {
428 const dialog = this.openProgressDialog(rows);
429 const params: CheckoutParams = {};
430 let refreshNeeded = false;
432 return this.circ.renewBatch(this.getCopyIds(rows))
436 // Value can be null when dialogs are canceled
437 if (result) { refreshNeeded = true; }
439 err => this.reportError(err),
443 this.emitReloadRequest();
449 renewWithDate(rows: any) {
450 const ids = this.getCopyIds(rows);
451 if (ids.length === 0) { return; }
453 this.dueDateDialog.open().subscribe(isoDate => {
454 if (!isoDate) { return; } // canceled
456 const dialog = this.openProgressDialog(rows);
457 const params: CheckoutParams = {due_date: isoDate};
459 let refreshNeeded = false;
460 this.circ.renewBatch(ids).subscribe(
462 if (resp.success) { refreshNeeded = true; }
465 err => this.reportError(err),
469 this.emitReloadRequest();
477 // Same params will be used for each copy
478 checkin(rows: CircGridEntry[], params?:
479 CheckinParams, noReload?: boolean): Observable<CheckinResult> {
481 const dialog = this.openProgressDialog(rows);
483 let changesApplied = false;
484 return this.circ.checkinBatch(this.getCopyIds(rows), params)
487 if (result) { changesApplied = true; }
490 err => this.reportError(err),
493 if (changesApplied && !noReload) { this.emitReloadRequest(); }
498 markLost(rows: CircGridEntry[]) {
499 const dialog = this.openProgressDialog(rows);
500 const barcodes = this.getCopies(rows).map(c => c.barcode());
502 from(barcodes).pipe(concatMap(barcode => {
503 return this.net.request(
505 'open-ils.circ.circulation.set_lost',
506 this.auth.token(), {barcode: barcode}
509 result => dialog.increment(),
510 err => this.reportError(err),
513 this.emitReloadRequest();
518 claimsReturned(rows: CircGridEntry[]) {
519 this.claimsReturnedDialog.barcodes =
520 this.getCopies(rows).map(c => c.barcode());
522 this.claimsReturnedDialog.open().subscribe(
525 this.emitReloadRequest();
531 claimsNeverCheckedOut(rows: CircGridEntry[]) {
532 const dialog = this.openProgressDialog(rows);
534 this.claimsNeverCount = rows.length;
536 this.claimsNeverConfirm.open().subscribe(confirmed => {
537 this.claimsNeverCount = 0;
544 this.circ.checkinBatch(
545 this.getCopyIds(rows), {claims_never_checked_out: true}
547 result => dialog.increment(),
548 err => this.reportError(err),
551 this.emitReloadRequest();
557 openBillingDialog(rows: CircGridEntry[]) {
559 let changesApplied = false;
561 from(this.getCircIds(rows))
562 .pipe(concatMap(id => {
563 this.addBillingDialog.xactId = id;
564 return this.addBillingDialog.open();
568 if (changes) { changesApplied = true; }
570 err => this.reportError(err),
572 if (changesApplied) {
573 this.emitReloadRequest();
579 showRecentCircs(rows: CircGridEntry[]) {
580 const copyId = this.getCopyIds(rows)[0];
582 window.open('/eg/staff/cat/item/' + copyId + '/circ_list');
586 showTriggeredEvents(rows: CircGridEntry[]) {
587 const copyId = this.getCopyIds(rows)[0];
589 window.open('/eg/staff/cat/item/' + copyId + '/triggered_events');