LP1904036 copy alerts management (checkin)
authorBill Erickson <berickxx@gmail.com>
Thu, 15 Apr 2021 16:44:01 +0000 (12:44 -0400)
committerGalen Charlton <gmc@equinoxOLI.org>
Fri, 28 Oct 2022 00:13:31 +0000 (20:13 -0400)
Signed-off-by: Bill Erickson <berickxx@gmail.com>
Signed-off-by: Jane Sandberg <js7389@princeton.edu>
Signed-off-by: Galen Charlton <gmc@equinoxOLI.org>
Open-ILS/src/eg2/src/app/staff/circ/checkin/checkin.component.html
Open-ILS/src/eg2/src/app/staff/circ/patron/checkout.component.html
Open-ILS/src/eg2/src/app/staff/circ/patron/checkout.component.ts
Open-ILS/src/eg2/src/app/staff/share/circ/circ.service.ts
Open-ILS/src/eg2/src/app/staff/share/circ/components.component.html
Open-ILS/src/eg2/src/app/staff/share/circ/components.component.ts
Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alert-manager.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alert-manager.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.module.ts
Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.service.ts

index 91a64b3..c2f2aeb 100644 (file)
       <eg-grid-column path="copy.circ_lib.shortname"
         label="Circulation Library" i18n-label></eg-grid-column>
 
+      <eg-grid-column path="copy.status.name"
+        label="Item Status" i18n-label></eg-grid-column>
+
     </eg-grid>
   </div>
 </div>
index 6ef7700..3e99c33 100644 (file)
   <ng-container *ngIf="!r.record">{{r.title}}</ng-container>
 </ng-template>
 
+<ng-template #copyAlertsTemplate let-r="row">
+  <button class="btn btn-outline-dark btn-sm p-1" 
+    (click)="openItemAlerts([r], 'manage')" i18n>
+    Manage ({{r.copyAlertCount}})
+  </button>
+</ng-template>
+
 <div class="row">
   <div class="col-lg-12">
     <eg-grid #checkoutsGrid [dataSource]="gridDataSource" [sortable]="true"
       <eg-grid-column path="nonCatCount" label="Non-Cataloged Count"
         i18n-label></eg-grid-column>
 
+      <eg-grid-column name="copyAlerts" label="Alerts" i18n-label
+        [cellTemplate]="copyAlertsTemplate"></eg-grid-column>
+
     </eg-grid>
   </div>
 </div>
index 7c7512a..e060344 100644 (file)
@@ -5,6 +5,7 @@ import {tap, switchMap} from 'rxjs/operators';
 import {NgbNav, NgbNavChangeEvent} from '@ng-bootstrap/ng-bootstrap';
 import {IdlObject} from '@eg/core/idl.service';
 import {OrgService} from '@eg/core/org.service';
+import {PcrudService} from '@eg/core/pcrud.service';
 import {NetService} from '@eg/core/net.service';
 import {PatronService} from '@eg/staff/share/patron/patron.service';
 import {PatronContextService, CircGridEntry} from './patron.service';
@@ -53,6 +54,7 @@ export class CheckoutComponent implements OnInit, AfterViewInit {
         private store: StoreService,
         private serverStore: ServerStoreService,
         private org: OrgService,
+        private pcrud: PcrudService,
         private net: NetService,
         public circ: CircService,
         public patronService: PatronService,
@@ -175,7 +177,7 @@ export class CheckoutComponent implements OnInit, AfterViewInit {
             copy: result.copy,
             circ: result.circ,
             dueDate: null,
-            copyAlertCount: 0, // TODO
+            copyAlertCount: 0,
             nonCatCount: 0,
             title: result.title,
             author: result.author,
@@ -188,11 +190,16 @@ export class CheckoutComponent implements OnInit, AfterViewInit {
             entry.dueDate = result.nonCatCirc.duedate();
             entry.nonCatCount = result.params.noncat_count;
 
-        } else {
+        } else if (result.circ) {
+            entry.dueDate = result.circ.due_date();
+        }
 
-            if (result.circ) {
-                entry.dueDate = result.circ.due_date();
-            }
+        if (entry.copy) {
+            // Fire and forget this one
+
+            this.pcrud.search('aca',
+                {copy : entry.copy.id(), ack_time : null}, {}, {atomic: true}
+            ).subscribe(alerts => entry.copyAlertCount = alerts.length);
         }
 
         this.context.checkouts.unshift(entry);
index eb51abc..111122c 100644 (file)
@@ -13,6 +13,7 @@ import {CircEventsComponent} from './events-dialog.component';
 import {CircComponentsComponent} from './components.component';
 import {StringService} from '@eg/share/string/string.service';
 import {ServerStoreService} from '@eg/core/server-store.service';
+import {HoldingsService} from '@eg/staff/share/holdings/holdings.service';
 
 export interface CircDisplayInfo {
     title?: string;
@@ -123,6 +124,7 @@ export interface CheckoutParams {
     dummy_isbn?: string;
     circ_modifier?: string;
     void_overdues?: boolean;
+    new_copy_alerts?: boolean;
 
     // internal tracking
     _override?: boolean;
@@ -143,6 +145,7 @@ export interface CircResultCommon {
     hold?: IdlObject;
     patron?: IdlObject;
     transit?: IdlObject;
+    copyAlerts?: IdlObject[];
 
     // Calculated values
     title?: string;
@@ -165,6 +168,9 @@ export interface CheckinParams {
     void_overdues?: boolean;
     auto_print_hold_transits?: boolean;
     backdate?: string;
+    capture?: string;
+    next_copy_status?: number[];
+    new_copy_alerts?: boolean;
 
     // internal / local values that are moved from the API request.
     _override?: boolean;
@@ -201,7 +207,8 @@ export class CircService {
         private serverStore: ServerStoreService,
         private strings: StringService,
         private auth: AuthService,
-        private bib: BibRecordService,
+        private holdings: HoldingsService,
+        private bib: BibRecordService
     ) {}
 
     applySettings(): Promise<any> {
@@ -358,6 +365,7 @@ export class CircService {
 
     checkout(params: CheckoutParams): Promise<CheckoutResult> {
 
+        params.new_copy_alerts = true;
         params._renewal = false;
         console.debug('checking out with', params);
 
@@ -373,6 +381,7 @@ export class CircService {
 
     renew(params: CheckoutParams): Promise<CheckoutResult> {
 
+        params.new_copy_alerts = true;
         params._renewal = true;
         console.debug('renewing out with', params);
 
@@ -621,6 +630,7 @@ export class CircService {
     }
 
     checkin(params: CheckinParams): Promise<CheckinResult> {
+        params.new_copy_alerts = true;
 
         console.debug('checking in with', params);
 
@@ -648,7 +658,7 @@ export class CircService {
             result.author = result.record.author();
             result.isbn = result.record.isbn();
 
-        } else if (result.copy) {
+        } else if (copy) {
             result.title = result.copy.dummy_title();
             result.author = result.copy.dummy_author();
             result.isbn = result.copy.dummy_isbn();
@@ -664,6 +674,15 @@ export class CircService {
                     this.copyLocationCache[loc.id()] = loc;
                 });
             }
+
+            if (typeof copy.status() !== 'object') {
+                promise = promise.then(_ => this.holdings.getCopyStatuses())
+                .then(stats => {
+                    const stat =
+                        Object.values(stats).filter(s => s.id() === copy.status())[0];
+                    if (stat) { copy.status(stat); }
+                });
+            }
         }
 
         if (volume) {
@@ -689,7 +708,8 @@ export class CircService {
         const allEvents = Array.isArray(response) ?
             response.map(r => this.evt.parse(r)) : [this.evt.parse(response)];
 
-        console.debug('checkin returned', allEvents.map(e => e.textcode));
+        console.debug('checkin events', allEvents.map(e => e.textcode));
+        console.debug('checkin response', response);
 
         const firstEvent = allEvents[0];
         const payload = firstEvent.payload;
@@ -764,11 +784,7 @@ export class CircService {
         // Alerts that require a manual override.
         if (allEvents.filter(
             e => CAN_OVERRIDE_CHECKIN_ALERTS.includes(e.textcode)).length > 0) {
-
-            // Should not be necessary, but good to be safe.
-            if (params._override) { return Promise.resolve(null); }
-
-            return this.showOverrideDialog(result, allEvents, true);
+            return this.handleOverridableCheckinEvents(result);
         }
 
         switch (result.firstEvent.textcode) {
@@ -809,8 +825,14 @@ export class CircService {
     }
 
     handleCheckinSuccess(result: CheckinResult): Promise<CheckinResult> {
+        const copy = result.copy;
+
+        if (!copy) { return Promise.resolve(result); }
+
+        const stat = copy.status();
+        const statId = typeof stat === 'object' ? stat.id() : stat;
 
-        switch (result.copy.status()) {
+        switch (statId) {
 
             case 0: /* AVAILABLE */
             case 4: /* MISSING */
@@ -852,9 +874,8 @@ export class CircService {
 
             default:
                 this.audio.play('success.checkin');
-                const stat = result.copy;
                 console.debug(`Unusual checkin copy status (may have been
-                    set via copy alert): ${stat.id()} : ${stat.name()}`);
+                    set via copy alert): status=${statId}`);
         }
 
         return Promise.resolve(result);
@@ -896,15 +917,48 @@ export class CircService {
     }
 
 
-    handleOverridableCheckinEvents(
-        result: CheckinResult, events: EgEvent[]): Promise<CheckinResult> {
+    handleOverridableCheckinEvents(result: CheckinResult): Promise<CheckinResult> {
         const params = result.params;
-        const firstEvent = events[0];
+        const events = result.allEvents;
+        const firstEvent = result.firstEvent
 
         if (params._override) {
             // Should never get here.  Just being safe.
             return Promise.reject(null);
         }
+
+        if (this.suppressCheckinPopups && events.filter(
+            e => !CAN_SUPPRESS_CHECKIN_ALERTS.includes(e.textcode)).length === 0) {
+            // These events are automatically overridden when suppress
+            // popups are in effect.
+            params._override = true;
+            return this.checkin(params);
+        }
+
+        // New-style alerts are reported via COPY_ALERT_MESSAGE and
+        // includes the alerts in the payload as an array.
+        if (firstEvent.textcode === 'COPY_ALERT_MESSAGE'
+            && Array.isArray(firstEvent.payload)) {
+            this.components.copyAlertManager.alerts = firstEvent.payload;
+            this.components.copyAlertManager.mode = 'checkin';
+
+            return this.components.copyAlertManager.open().toPromise()
+            .then(resp => {
+
+                if (!resp) { return result; } // dialog was canceled
+
+                if (resp.nextStatus !== null) {
+                    params.next_copy_status = [resp.nextStatus];
+                    params.capture = 'nocapture';
+                }
+
+                params._override = true;
+
+                return this.checkin(params);
+            });
+        }
+
+        return this.showOverrideDialog(result, events, true);
     }
 
 
index 3a29fcc..a937b4b 100644 (file)
@@ -59,3 +59,5 @@
 <eg-string key="staff.circ.events.CHECKOUT_FAILED_GENERIC" 
   [template]="genericCheckoutFailedTmpl"></eg-string>
 
+<eg-copy-alert-manager #copyAlertManager></eg-copy-alert-manager>
+
index c7f8202..76dc5f5 100644 (file)
@@ -7,6 +7,8 @@ import {AlertDialogComponent} from '@eg/share/dialog/alert.component';
 import {OpenCircDialogComponent} from './open-circ-dialog.component';
 import {RouteDialogComponent} from './route-dialog.component';
 import {CopyInTransitDialogComponent} from './in-transit-dialog.component';
+import {CopyAlertManagerDialogComponent
+    } from '@eg/staff/share/holdings/copy-alert-manager.component';
 
 /* Container component for sub-components used by circulation actions.
  *
@@ -30,6 +32,7 @@ export class CircComponentsComponent {
     @ViewChild('circFailedDialog') circFailedDialog: AlertDialogComponent;
     @ViewChild('routeDialog') routeDialog: RouteDialogComponent;
     @ViewChild('copyInTransitDialog') copyInTransitDialog: CopyInTransitDialogComponent;
+    @ViewChild('copyAlertManager') copyAlertManager: CopyAlertManagerDialogComponent;
 
     @ViewChild('holdShelfStr') holdShelfStr: StringComponent;
     @ViewChild('catalogingStr') catalogingStr: StringComponent;
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alert-manager.component.html b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alert-manager.component.html
new file mode 100644 (file)
index 0000000..8bf3be0
--- /dev/null
@@ -0,0 +1,80 @@
+
+
+<eg-string key="staff.holdings.copyalert.CHECKOUT.NORMAL" 
+  text="Normal checkin" i18n-text></eg-string>
+<eg-string key="staff.holdings.copyalert.CHECKOUT.LOST" 
+  text="Item was marked lost" i18n-text></eg-string>
+<eg-string key="staff.holdings.copyalert.CHECKOUT.LOST_AND_PAID" 
+  text="Item was marked lost and paid for" i18n-text></eg-string>
+<eg-string key="staff.holdings.copyalert.CHECKOUT.MISSING" 
+  text="Item was marked missing" i18n-text></eg-string>
+<eg-string key="staff.holdings.copyalert.CHECKOUT.DAMAGED" 
+  text="Item was marked damaged" i18n-text></eg-string>
+<eg-string key="staff.holdings.copyalert.CHECKOUT.CLAIMSRETURNED" 
+  text="Item was marked claims returned" i18n-text></eg-string>
+<eg-string key="staff.holdings.copyalert.CHECKOUT.LONGOVERDUE" 
+  text="Item was marked long overdue" i18n-text></eg-string>
+<eg-string key="staff.holdings.copyalert.CHECKOUT.CLAIMSNEVERCHECKEDOUT" 
+  text="Item was marked claims never checked out" i18n-text></eg-string>
+<eg-string key="staff.holdings.copyalert.CHECKOUT.NORMAL" 
+  text="Normal checkout" i18n-text></eg-string>
+<eg-string key="staff.holdings.copyalert.CHECKOUT.LOST" 
+  text="Item was marked lost" i18n-text></eg-string>
+<eg-string key="staff.holdings.copyalert.CHECKOUT.LOST_AND_PAID" 
+  text="Item was marked lost and paid for" i18n-text></eg-string>
+<eg-string key="staff.holdings.copyalert.CHECKOUT.MISSING" 
+  text="Item was marked missing" i18n-text></eg-string>
+<eg-string key="staff.holdings.copyalert.CHECKOUT.DAMAGED" 
+  text="Item was marked damaged" i18n-text></eg-string>
+<eg-string key="staff.holdings.copyalert.CHECKOUT.CLAIMSRETURNED" 
+  text="Item was marked claims returned" i18n-text></eg-string>
+<eg-string key="staff.holdings.copyalert.CHECKOUT.LONGOVERDUE" 
+  text="Item was marked long overdue" i18n-text></eg-string>
+<eg-string key="staff.holdings.copyalert.CHECKOUT.CLAIMSNEVERCHECKEDOUT" 
+  text="Item was marked claims never checked out" i18n-text></eg-string>
+
+
+<ng-template #dialogContent>
+  <div class="modal-header bg-info">
+    <h4 class="modal-title" i18n>Item Alerts</h4>
+    <button type="button" class="close"
+      i18n-aria-label aria-label="Close" (click)="close()">
+      <span aria-hidden="true">&times;</span>
+    </button>
+  </div>
+  <div class="modal-body">
+
+    <div class="row mb-2" *ngFor="let alert of alerts">
+      <div class="col-lg-4">{{alert._event}}</div>
+      <div class="col-lg-6" [ngClass]="{acknowledged: alert._acked}">
+        {{alert._message}}
+      </div>
+      <div class="col-lg-2">
+        <button class="btn btn-sm btn-outline-dark mr-2" *ngIf="canBeAcked(alert)" 
+          (click)="alert._acked = !alert._acked" i18n>Clear</button>
+      </div>
+    </div>
+
+    <div class="row border-top mt-3 pt-3" 
+      *ngIf="mode == 'checkin' && nextStatuses.length > 0; let index = index">
+      <div class="col-lg-4" i18n>Next item status:</div>
+      <div class="col-lg-5">
+        <ng-container *ngIf="nextStatuses.length == 1">
+          {{nextStatuses[0].name()}}
+        </ng-container>
+        <ng-container *ngIf="nextStatuses.length > 1">
+          <select class="form-control" [(ngModel)]="nextStatus">
+            <option [value]="stat.id()" *ngFor="let stat of nextStatuses">
+              {{stat.name()}}
+            </option>
+          </select>
+        </ng-container>
+      </div>
+    </div>
+
+  </div>
+  <div class="modal-footer">
+    <button class="btn btn-success mr-2" (click)="ok()" i18n>OK/Continue</button>
+    <button type="button" class="btn btn-secondary" (click)="close()" i18n>Cancel</button>
+  </div>
+</ng-template>
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alert-manager.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alert-manager.component.ts
new file mode 100644 (file)
index 0000000..f6a4740
--- /dev/null
@@ -0,0 +1,138 @@
+import {Component, OnInit, Input, ViewChild} from '@angular/core';
+import {Observable, throwError, from} from 'rxjs';
+import {concatMap} from 'rxjs/operators';
+import {NetService} from '@eg/core/net.service';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {EventService} from '@eg/core/event.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {AuthService} from '@eg/core/auth.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {OrgService} from '@eg/core/org.service';
+import {StringComponent} from '@eg/share/string/string.component';
+import {StringService} from '@eg/share/string/string.service';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {HoldingsService} from './holdings.service';
+
+/**
+ * Dialog for managing copy alerts.
+ */
+
+@Component({
+  selector: 'eg-copy-alert-manager',
+  templateUrl: 'copy-alert-manager.component.html',
+  styles: ['.acknowledged {text-decoration: line-through }']
+})
+
+export class CopyAlertManagerDialogComponent
+    extends DialogComponent implements OnInit {
+
+    mode: string;
+    alerts: IdlObject[];
+    nextStatuses: IdlObject[];
+    nextStatus: number;
+
+    constructor(
+        private modal: NgbModal,
+        private toast: ToastService,
+        private net: NetService,
+        private idl: IdlService,
+        private pcrud: PcrudService,
+        private org: OrgService,
+        private auth: AuthService,
+        private strings: StringService,
+        private holdings: HoldingsService
+    ) { super(modal); }
+
+    ngOnInit() {}
+
+    open(ops?: NgbModalOptions): Observable<any> {
+
+        this.nextStatus = null;
+
+        let promise = Promise.resolve(null);
+        this.alerts.forEach(copyAlert =>
+            promise = promise.then(_ => this.ingestAlert(copyAlert)));
+
+        return from(promise).pipe(concatMap(_ => super.open(ops)));
+    }
+
+    ingestAlert(copyAlert: IdlObject): Promise<any> {
+        let promise = Promise.resolve(null);
+
+        const state = copyAlert.alert_type().state();
+        copyAlert._event = copyAlert.alert_type().event();
+
+        if (copyAlert.note()) {
+            copyAlert._message = copyAlert.note();
+        } else {
+            const key = `staff.holdings.copyalert.${copyAlert._event}.${state}`;
+            promise = promise.then(_ => {
+                return this.strings.interpolate(key)
+                .then(str => copyAlert._message = str);
+            });
+        }
+
+        const nextStatuses: number[] = [];
+        this.nextStatuses = [];
+
+        if (copyAlert.temp() === 'f') { return promise; }
+
+        copyAlert.alert_type().next_status().forEach(statId => {
+            if (!nextStatuses.includes(statId)) {
+                nextStatuses.push(statId);
+            }
+        });
+
+        if (this.mode === 'checkin' && nextStatuses.length > 0) {
+
+            promise = promise.then(_ => this.holdings.getCopyStatuses())
+            .then(statMap => {
+                nextStatuses.forEach(statId => {
+                    const wanted = statMap[statId];
+                    if (wanted) { this.nextStatuses.push(wanted); }
+                })
+
+                if (this.nextStatuses.length > 0) {
+                    this.nextStatus = this.nextStatuses[0].id();
+                }
+            });
+        }
+
+        return promise;
+    }
+
+    canBeAcked(copyAlert: IdlObject): boolean {
+        return !copyAlert.ack_time() && copyAlert.temp() === 't';
+    }
+
+    canBeRemoved(copyAlert: IdlObject): boolean {
+        return !copyAlert.ack_time() && copyAlert.temp() === 'f';
+    }
+
+    isAcked(copyAlert: IdlObject): boolean {
+        return copyAlert._acked;
+    }
+
+    ok() {
+        const acks: IdlObject[] = [];
+        this.alerts.forEach(copyAlert => {
+
+            if (copyAlert._acked) {
+                copyAlert.ack_time('now');
+                copyAlert.ack_staff(this.auth.user().id());
+                copyAlert.ischanged(true);
+                acks.push(copyAlert);
+            }
+
+            if (acks.length > 0) {
+                this.pcrud.update(acks).toPromise()
+                .then(_ => this.close({nextStatus: this.nextStatus}));
+            } else {
+                this.close({nextStatus: this.nextStatus});
+            }
+        });
+    }
+}
+
index 96357a6..fd1ec0f 100644 (file)
@@ -14,6 +14,7 @@ import {ConjoinedItemsDialogComponent} from './conjoined-items-dialog.component'
 import {TransferItemsComponent} from './transfer-items.component';
 import {TransferHoldingsComponent} from './transfer-holdings.component';
 import {BatchItemAttrComponent} from './batch-item-attr.component';
+import {CopyAlertManagerDialogComponent} from './copy-alert-manager.component';
 
 @NgModule({
     declarations: [
@@ -28,7 +29,8 @@ import {BatchItemAttrComponent} from './batch-item-attr.component';
       ConjoinedItemsDialogComponent,
       TransferItemsComponent,
       TransferHoldingsComponent,
-      BatchItemAttrComponent
+      BatchItemAttrComponent,
+      CopyAlertManagerDialogComponent
     ],
     imports: [
         StaffCommonModule,
@@ -46,7 +48,8 @@ import {BatchItemAttrComponent} from './batch-item-attr.component';
       ConjoinedItemsDialogComponent,
       TransferItemsComponent,
       TransferHoldingsComponent,
-      BatchItemAttrComponent
+      BatchItemAttrComponent,
+      CopyAlertManagerDialogComponent
     ],
     providers: [
         HoldingsService
index 00cd34c..6b21646 100644 (file)
@@ -3,6 +3,7 @@
  */
 import {Injectable, EventEmitter} from '@angular/core';
 import {IdlObject, IdlService} from '@eg/core/idl.service';
+import {tap} from 'rxjs/operators';
 import {NetService} from '@eg/core/net.service';
 import {AnonCacheService} from '@eg/share/util/anon-cache.service';
 import {PcrudService} from '@eg/core/pcrud.service';
@@ -20,6 +21,8 @@ export interface CallNumData {
 @Injectable()
 export class HoldingsService {
 
+    copyStatuses: {[id: number]: IdlObject};
+
     constructor(
         private net: NetService,
         private auth: AuthService,
@@ -93,5 +96,16 @@ export class HoldingsService {
             18  // Canceled Transit
         ]);
     }
+
+    getCopyStatuses(): Promise<{[id: number]: IdlObject}> {
+        if (this.copyStatuses) {
+            return Promise.resolve(this.copyStatuses);
+        }
+
+        this.copyStatuses = {};
+        return this.pcrud.retrieveAll('ccs', {order_by: {ccs: 'name'}})
+        .pipe(tap(stat => this.copyStatuses[stat.id()] = stat))
+        .toPromise().then(_ => this.copyStatuses);
+    }
 }