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';
36 export interface CircGridEntry {
37 index: string; // class + id -- row index
46 copyAlertCount?: number;
49 lastNotice?: string; // iso date
51 // useful for reporting precaculated values and avoiding
52 // repetitive date creation on grid render.
56 const CIRC_FLESH_DEPTH = 4;
57 const CIRC_FLESH_FIELDS = {
58 circ: ['target_copy', 'workstation', 'checkin_workstation', 'circ_lib'],
70 acn: ['record', 'owning_lib', 'prefix', 'suffix'],
71 bre: ['wide_display_entry']
75 templateUrl: 'grid.component.html',
76 selector: 'eg-circ-grid'
78 export class CircGridComponent implements OnInit {
80 @Input() persistKey: string;
81 @Input() printTemplate: string; // defaults to items_out
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>();
89 entries: CircGridEntry[] = null;
90 gridDataSource: GridDataSource = new GridDataSource();
91 cellTextGenerator: GridCellTextGenerator;
92 rowFlair: (row: CircGridEntry) => GridRowFlairEntry;
93 rowClass: (row: CircGridEntry) => string;
96 nowDate: number = new Date().getTime();
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;
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
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(); }
140 const page = this.entries.slice(pager.offset, pager.offset + pager.limit)
141 .filter(entry => entry !== undefined);
146 this.cellTextGenerator = {
147 title: row => row.title,
148 'copy.barcode': row => row.copy ? row.copy.barcode() : ''
151 this.rowFlair = (row: CircGridEntry) => {
152 if (this.circIsOverdue(row)) {
153 return {icon: 'error_outline', title: this.overdueString.text};
157 this.rowClass = (row: CircGridEntry) => {
158 if (this.circIsOverdue(row)) {
159 return 'less-intense-alert';
163 this.serverStore.getItemBatch(['ui.circ.suppress_checkin_popups'])
165 this.circ.suppressCheckinPopups =
166 sets['ui.circ.suppress_checkin_popups'];
170 reportError(err: any) {
171 console.error('Circ error occurred: ' + err);
172 this.toast.danger(err); // EgEvent has a toString()
175 // Ask the caller to update our data set.
176 emitReloadRequest() {
178 this.reloadRequested.emit();
181 // Reload the grid without any data retrieval
183 this.circGrid.reload();
186 // Fetch circulation data and make it available to the grid.
187 load(circIds: number[]): Observable<CircGridEntry> {
190 if (!circIds || circIds.length === 0) { return empty(); }
192 // Return the circs we have already retrieved.
193 if (this.entries) { return from(this.entries); }
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)));
203 fetchCircs(circIds: number[]): Observable<CircGridEntry> {
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']},
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']}
215 }).pipe(map(circ => {
217 const entry = this.gridify(circ);
218 this.appendGridEntry(entry);
223 fetchNotices(circIds: number[]): Observable<CircGridEntry> {
224 return this.net.request(
226 'open-ils.actor.user.itemsout.notices',
227 this.auth.token(), circIds
228 ).pipe(tap(notice => {
230 const entry = this.entries.filter(
231 e => e.circ.id() === Number(notice.circ_id))[0];
233 entry.noticeCount = notice.numNotices;
234 entry.lastNotice = notice.lastDt;
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);
246 gridify(circ: IdlObject): CircGridEntry {
248 const entry: CircGridEntry = {
249 index: `circ-${circ.id()}`,
251 dueDate: circ.due_date(),
252 copyAlertCount: 0 // TODO
255 const copy = circ.target_copy();
258 // Some values have to be manually extracted / normalized
259 if (copy.call_number().id() === -1) {
261 entry.title = copy.dummy_title();
262 entry.author = copy.dummy_author();
263 entry.isbn = copy.dummy_isbn();
267 entry.volume = copy.call_number();
268 entry.record = entry.volume.record();
270 // display entries are JSON-encoded and some are lists
271 const display = entry.record.wide_display_entry();
273 entry.title = JSON.parse(display.title());
274 entry.author = JSON.parse(display.author());
275 entry.isbn = JSON.parse(display.isbn());
277 if (Array.isArray(entry.isbn)) {
278 entry.isbn = entry.isbn.join(',');
285 selectedCopyIds(rows: CircGridEntry[]): number[] {
287 .filter(row => row.copy)
288 .map(row => Number(row.copy.id()));
291 openItemAlerts(rows: CircGridEntry[], mode: string) {
292 const copyIds = this.selectedCopyIds(rows);
293 if (copyIds.length === 0) { return; }
295 this.copyAlertsDialog.copyIds = copyIds;
296 this.copyAlertsDialog.mode = mode;
297 this.copyAlertsDialog.open({size: 'lg'}).subscribe(
300 // TODO: verify the modiifed alerts are present
302 this.circGrid.reload();
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()));
313 getCopies(rows: CircGridEntry[], skipStatus?: number): IdlObject[] {
314 let copies = rows.filter(r => r.copy).map(r => r.copy);
316 copies = copies.filter(
317 c => Number(c.status().id()) !== Number(skipStatus));
322 getCircIds(rows: CircGridEntry[]): number[] {
323 return this.getCircs(rows).map(row => Number(row.id()));
326 getCircs(rows: any): IdlObject[] {
327 return rows.filter(r => r.circ).map(r => r.circ);
330 printReceipts(rows: any) {
331 if (rows.length > 0) {
333 templateName: this.printTemplate || 'items_out',
334 contextData: {circulations: rows},
335 printContext: 'default'
340 editDueDate(rows: any) {
341 const ids = this.getCircIds(rows);
342 if (ids.length === 0) { return; }
344 this.dueDateDialog.open().subscribe(isoDate => {
345 if (!isoDate) { return; } // canceled
347 const dialog = this.openProgressDialog(rows);
349 from(ids).pipe(concatMap(id => {
350 return this.net.request(
352 'open-ils.circ.circulation.due_date.update',
353 this.auth.token(), id, isoDate
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
363 err => console.log(err),
366 this.emitReloadRequest();
372 circIsOverdue(row: CircGridEntry): boolean {
373 const circ = row.circ;
375 if (!circ) { return false; } // noncat
377 if (row.overdue === undefined) {
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/)) {
384 row.overdue = (Date.parse(circ.due_date()) < this.nowDate);
390 markDamaged(rows: CircGridEntry[]) {
391 const copyIds = this.getCopyIds(rows, 14 /* ignore damaged */);
393 if (copyIds.length === 0) { return; }
395 let rowsModified = false;
397 const markNext = (ids: number[]): Promise<any> => {
398 if (ids.length === 0) {
399 return Promise.resolve();
402 this.markDamagedDialog.copyId = ids.pop();
404 return this.markDamagedDialog.open({size: 'lg'})
405 .toPromise().then(ok => {
406 if (ok) { rowsModified = true; }
407 return markNext(ids);
411 markNext(copyIds).then(_ => {
413 this.emitReloadRequest();
418 markMissing(rows: CircGridEntry[]) {
419 const copyIds = this.getCopyIds(rows, 4 /* ignore missing */);
421 if (copyIds.length === 0) { return; }
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; }
430 this.checkin(rows, {noop: true}, true).toPromise().then(_ => {
432 this.markMissingDialog.copyIds = copyIds;
433 this.markMissingDialog.open({}).subscribe(
436 this.emitReloadRequest();
444 openProgressDialog(rows: CircGridEntry[]): ProgressDialogComponent {
445 this.progressDialog.update({value: 0, max: rows.length});
446 this.progressDialog.open();
447 return this.progressDialog;
452 this.renew(this.entries);
455 renew(rows: CircGridEntry[]) {
457 const dialog = this.openProgressDialog(rows);
458 const params: CheckoutParams = {};
459 let refreshNeeded = false;
461 return this.circ.renewBatch(this.getCopyIds(rows))
465 // Value can be null when dialogs are canceled
466 if (result) { refreshNeeded = true; }
468 err => this.reportError(err),
472 this.emitReloadRequest();
478 renewWithDate(rows: any) {
479 const ids = this.getCopyIds(rows);
480 if (ids.length === 0) { return; }
482 this.dueDateDialog.open().subscribe(isoDate => {
483 if (!isoDate) { return; } // canceled
485 const dialog = this.openProgressDialog(rows);
486 const params: CheckoutParams = {due_date: isoDate};
488 let refreshNeeded = false;
489 this.circ.renewBatch(ids).subscribe(
491 if (resp.success) { refreshNeeded = true; }
494 err => this.reportError(err),
498 this.emitReloadRequest();
506 // Same params will be used for each copy
507 checkin(rows: CircGridEntry[], params?:
508 CheckinParams, noReload?: boolean): Observable<CheckinResult> {
510 const dialog = this.openProgressDialog(rows);
512 let changesApplied = false;
513 return this.circ.checkinBatch(this.getCopyIds(rows), params)
516 if (result) { changesApplied = true; }
519 err => this.reportError(err),
522 if (changesApplied && !noReload) { this.emitReloadRequest(); }
527 markLost(rows: CircGridEntry[]) {
528 const dialog = this.openProgressDialog(rows);
529 const barcodes = this.getCopies(rows).map(c => c.barcode());
531 from(barcodes).pipe(concatMap(barcode => {
532 return this.net.request(
534 'open-ils.circ.circulation.set_lost',
535 this.auth.token(), {barcode: barcode}
538 result => dialog.increment(),
539 err => this.reportError(err),
542 this.emitReloadRequest();
547 claimsReturned(rows: CircGridEntry[]) {
548 this.claimsReturnedDialog.barcodes =
549 this.getCopies(rows).map(c => c.barcode());
551 this.claimsReturnedDialog.open().subscribe(
554 this.emitReloadRequest();
560 claimsNeverCheckedOut(rows: CircGridEntry[]) {
561 const dialog = this.openProgressDialog(rows);
563 this.claimsNeverCount = rows.length;
565 this.claimsNeverConfirm.open().subscribe(confirmed => {
566 this.claimsNeverCount = 0;
573 this.circ.checkinBatch(
574 this.getCopyIds(rows), {claims_never_checked_out: true}
576 result => dialog.increment(),
577 err => this.reportError(err),
580 this.emitReloadRequest();
586 openBillingDialog(rows: CircGridEntry[]) {
588 let changesApplied = false;
590 from(this.getCircIds(rows))
591 .pipe(concatMap(id => {
592 this.addBillingDialog.xactId = id;
593 return this.addBillingDialog.open();
597 if (changes) { changesApplied = true; }
599 err => this.reportError(err),
601 if (changesApplied) {
602 this.emitReloadRequest();
608 showRecentCircs(rows: CircGridEntry[]) {
609 const copyId = this.getCopyIds(rows)[0];
611 window.open('/eg/staff/cat/item/' + copyId + '/circ_list');
615 showTriggeredEvents(rows: CircGridEntry[]) {
616 const copyId = this.getCopyIds(rows)[0];
618 window.open('/eg/staff/cat/item/' + copyId + '/triggered_events');