LP1868354 Angular catalog item/call number transfer
authorBill Erickson <berickxx@gmail.com>
Wed, 1 Apr 2020 14:47:08 +0000 (10:47 -0400)
committerChris Sharp <csharp@georgialibraries.org>
Tue, 21 Apr 2020 16:35:14 +0000 (12:35 -0400)
Add support for 3 varieties of items and call number transfer in the
Angular staff catalog.

1. Transfer selected items to selected call number.

2. Transfer selected call numbers and attached items to selected bib
record.

3. Transfer selelected items to selected bib record and org unit ID
(owning library) creating new call numbers as needed.

Signed-off-by: Chris Sharp <csharp@georgialibraries.org>
Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.html
Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.ts
Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.module.ts
Open-ILS/src/eg2/src/app/staff/share/holdings/transfer-holdings.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holdings/transfer-holdings.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holdings/transfer-items.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holdings/transfer-items.component.ts [new file with mode: 0644]

index 2e3e2ad..52e89be 100644 (file)
@@ -33,8 +33,12 @@ export class RecordActionsComponent implements OnInit {
             current: null
         },
         holdingTransfer: {
-            key: 'eg.cat.marked_holding_transfer_record',
-            current: null
+            key: 'eg.cat.transfer_target_record',
+            current: null,
+            clear: [ // Clear these values on mark.
+              'eg.cat.transfer_target_lib',
+              'eg.cat.transfer_target_vol'
+            ]
         }
     };
 
@@ -69,6 +73,12 @@ export class RecordActionsComponent implements OnInit {
         const target = this.targets[name];
         target.current = this.recId;
         this.store.setLocalItem(target.key, this.recId);
+
+        if (target.clear) {
+            // Some marks require clearing other marks.
+            target.clear.forEach(key => this.store.removeLocalItem(key));
+        }
+
         this.strings.interpolate('catalog.record.toast.' + name)
             .then(txt => this.toast.success(txt));
     }
index bed7a4e..3d6c4d4 100644 (file)
 <eg-bucket-dialog #bucketDialog></eg-bucket-dialog>
 <eg-conjoined-items-dialog #conjoinedDialog></eg-conjoined-items-dialog>
 <eg-make-bookable-dialog #makeBookableDialog></eg-make-bookable-dialog>
+<eg-transfer-items #transferItems></eg-transfer-items>
+<eg-transfer-holdings #transferHoldings></eg-transfer-holdings>
+<eg-alert-dialog #transferAlert
+  i18n-dialogTitle i18n-dialogBody
+  dialogTitle="No Target Selected"
+  dialogBody="Please select a suitable transfer target"></eg-alert-dialog>
 
 <!-- holdings grid -->
 <div class='eg-copies w-100 mt-3'>
       i18n-label label="Mark Library/Call Number as Transfer Destination"
       (onClick)="markLibCnForTransfer($event)">
     </eg-grid-toolbar-action>
-      
+
+    <eg-grid-toolbar-action
+      i18n-group group="Transfer" 
+      i18n-label label="Transfer Items to Marked Destination"
+      (onClick)="transferSelectedItems($event)">
+    </eg-grid-toolbar-action>
+
+    <eg-grid-toolbar-action
+      i18n-group group="Transfer" 
+      i18n-label label="Transfer Holdings to Marked Destination"
+      (onClick)="transferSelectedHoldings($event)">
+    </eg-grid-toolbar-action>
 
     <!-- fields -->
     <!-- NOTE column names were added to match the names from the AngJS grid
index 4186b7a..bec0730 100644 (file)
@@ -33,6 +33,12 @@ import {ConjoinedItemsDialogComponent
     } from '@eg/staff/share/holdings/conjoined-items-dialog.component';
 import {MakeBookableDialogComponent
     } from '@eg/staff/share/booking/make-bookable-dialog.component';
+import {TransferItemsComponent
+    } from '@eg/staff/share/holdings/transfer-items.component';
+import {TransferHoldingsComponent
+    } from '@eg/staff/share/holdings/transfer-holdings.component';
+import {AlertDialogComponent} from '@eg/share/dialog/alert.component';
+
 
 // The holdings grid models a single HoldingsTree, composed of HoldingsTreeNodes
 // flattened on-demand into a list of HoldingEntry objects.
@@ -109,6 +115,12 @@ export class HoldingsMaintenanceComponent implements OnInit {
         private conjoinedDialog: ConjoinedItemsDialogComponent;
     @ViewChild('makeBookableDialog', { static: true })
         private makeBookableDialog: MakeBookableDialogComponent;
+    @ViewChild('transferItems', {static: false})
+        private transferItems: TransferItemsComponent;
+    @ViewChild('transferHoldings', {static: false})
+        private transferHoldings: TransferHoldingsComponent;
+    @ViewChild('transferAlert', {static: false})
+        private transferAlert: AlertDialogComponent;
 
     holdingsTree: HoldingsTree;
 
@@ -592,20 +604,29 @@ export class HoldingsMaintenanceComponent implements OnInit {
 
     // Which copies in the grid are selected.
     selectedCopyIds(rows: HoldingsEntry[], skipStatus?: number): number[] {
+        return this.selectedCopies(rows, skipStatus).map(c => Number(c.id()));
+    }
+
+    selectedCopies(rows: HoldingsEntry[], skipStatus?: number): IdlObject[] {
         let copyRows = rows.filter(r => Boolean(r.copy)).map(r => r.copy);
         if (skipStatus) {
             copyRows = copyRows.filter(
                 c => Number(c.status().id()) !== Number(skipStatus));
         }
-        return copyRows.map(c => Number(c.id()));
+        return copyRows;
     }
 
     selectedCallNumIds(rows: HoldingsEntry[]): number[] {
+        return this.selectedCallNums(rows).map(cn => cn.id());
+    }
+
+    selectedCallNums(rows: HoldingsEntry[]): IdlObject[] {
         return rows
             .filter(r => r.treeNode.nodeType === 'callNum')
-            .map(r => Number(r.callNum.id()));
+            .map(r => r.callNum);
     }
 
+
     async showMarkDamagedDialog(rows: HoldingsEntry[]) {
         const copyIds = this.selectedCopyIds(rows, 14 /* ignore damaged */);
 
@@ -660,9 +681,7 @@ export class HoldingsMaintenanceComponent implements OnInit {
 
         // Action may only apply to a single org or call number row.
         const node = rows[0].treeNode;
-        if (node.nodeType === 'copy') {
-            return;
-        }
+        if (node.nodeType === 'copy') { return; }
 
         let orgId: number;
 
@@ -682,8 +701,11 @@ export class HoldingsMaintenanceComponent implements OnInit {
                 'eg.cat.transfer_target_vol', node.target.id());
         }
 
-        this.localStore.setLocalItem('eg.cat.transfer_target_record', this.recordId);
+        // Track lib and record to support transfering items from
+        // a different bib record to this record at the selected
+        // owning lib.
         this.localStore.setLocalItem('eg.cat.transfer_target_lib', orgId);
+        this.localStore.setLocalItem('eg.cat.transfer_target_record', this.recordId);
     }
 
     openAngJsWindow(path: string) {
@@ -901,4 +923,70 @@ export class HoldingsMaintenanceComponent implements OnInit {
             );
         }
     }
+
+    transferSelectedItems(rows: HoldingsEntry[]) {
+        if (rows.length === 0) { return; }
+
+        const cnId =
+            this.localStore.getLocalItem('eg.cat.transfer_target_vol');
+
+        const orgId =
+            this.localStore.getLocalItem('eg.cat.transfer_target_lib');
+
+        const recId =
+            this.localStore.getLocalItem('eg.cat.transfer_target_record');
+
+        let promise;
+
+        if (cnId) { // Direct call number transfer
+
+            const itemIds = this.selectedCopyIds(rows);
+            promise = this.transferItems.transferItems(itemIds, cnId);
+
+        } else if (orgId && recId) { // "Auto" transfer
+
+            // Clone the items to be modified to avoid any unexpected
+            // modifications and fesh the call numbers.
+            const items = this.idl.clone(this.selectedCopies(rows));
+            items.forEach(i => i.call_number(
+                this.treeNodeCache.callNum[i.call_number()].target));
+
+            console.log(items);
+            promise = this.transferItems.autoTransferItems(items, recId, orgId);
+
+        } else {
+            promise = this.transferAlert.open().toPromise();
+        }
+
+        promise.then(success => success ?  this.hardRefresh() : null);
+    }
+
+    transferSelectedHoldings(rows: HoldingsEntry[]) {
+        const callNums = this.selectedCallNums(rows);
+        if (callNums.length === 0) { return; }
+
+        const orgId =
+            this.localStore.getLocalItem('eg.cat.transfer_target_lib');
+
+        let recId =
+            this.localStore.getLocalItem('eg.cat.transfer_target_record');
+
+        if (orgId) {
+            // When transferring holdings (call numbers) between org units,
+            // limit transfers to within the current record.
+            recId = this.recordId;
+
+        } else if (!recId) {
+            // No destinations applied.
+            return this.transferAlert.open();
+        }
+
+        this.transferHoldings.targetRecId = recId;
+        this.transferHoldings.targetOrgId = orgId;
+        this.transferHoldings.callNums = callNums;
+
+        this.transferHoldings.transferHoldings()
+        .then(success => success ?  this.hardRefresh() : null);
+    }
 }
+
index be49554..bc93932 100644 (file)
@@ -7,6 +7,8 @@ import {CopyAlertsDialogComponent} from './copy-alerts-dialog.component';
 import {ReplaceBarcodeDialogComponent} from './replace-barcode-dialog.component';
 import {DeleteHoldingDialogComponent} from './delete-volcopy-dialog.component';
 import {ConjoinedItemsDialogComponent} from './conjoined-items-dialog.component';
+import {TransferItemsComponent} from './transfer-items.component';
+import {TransferHoldingsComponent} from './transfer-holdings.component';
 
 @NgModule({
     declarations: [
@@ -15,7 +17,9 @@ import {ConjoinedItemsDialogComponent} from './conjoined-items-dialog.component'
       CopyAlertsDialogComponent,
       ReplaceBarcodeDialogComponent,
       DeleteHoldingDialogComponent,
-      ConjoinedItemsDialogComponent
+      ConjoinedItemsDialogComponent,
+      TransferItemsComponent,
+      TransferHoldingsComponent
     ],
     imports: [
         StaffCommonModule
@@ -26,7 +30,9 @@ import {ConjoinedItemsDialogComponent} from './conjoined-items-dialog.component'
       CopyAlertsDialogComponent,
       ReplaceBarcodeDialogComponent,
       DeleteHoldingDialogComponent,
-      ConjoinedItemsDialogComponent
+      ConjoinedItemsDialogComponent,
+      TransferItemsComponent,
+      TransferHoldingsComponent
     ],
     providers: [
         HoldingsService
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/transfer-holdings.component.html b/Open-ILS/src/eg2/src/app/staff/share/holdings/transfer-holdings.component.html
new file mode 100644 (file)
index 0000000..e5b64b4
--- /dev/null
@@ -0,0 +1,17 @@
+
+<eg-string #successMsg
+    text="Successfully Transferred Item(s)" i18n-text></eg-string>
+
+<eg-string #errorMsg
+    text="Failed To Transfer Item(s)" i18n-text></eg-string>
+
+<eg-string #noTargetMsg
+    text="No transfer target selected" i18n-text></eg-string>
+
+<eg-alert-dialog #alertDialog
+  i18n-dialogTitle i18n-dialogBody
+  dialogTitle="One or more items could not be transferred"
+  dialogBody="Reason(s) include: {{eventDesc}}">
+</eg-alert-dialog>
+
+<eg-progress-dialog #progressDialog></eg-progress-dialog>
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/transfer-holdings.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/transfer-holdings.component.ts
new file mode 100644 (file)
index 0000000..8563e9a
--- /dev/null
@@ -0,0 +1,142 @@
+import {Component, OnInit, Input, ViewChild, Renderer2} from '@angular/core';
+import {Observable} from 'rxjs';
+import {switchMap, map, tap} from 'rxjs/operators';
+import {IdlObject} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {EventService} from '@eg/core/event.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {AlertDialogComponent} from '@eg/share/dialog/alert.component';
+import {StringComponent} from '@eg/share/string/string.component';
+import {ProgressDialogComponent} from '@eg/share/dialog/progress.component';
+
+/* Transfer holdings (AKA asset.call_number) to a target bib record. */
+
+@Component({
+  selector: 'eg-transfer-holdings',
+  templateUrl: 'transfer-holdings.component.html'
+})
+
+export class TransferHoldingsComponent implements OnInit {
+
+    // Array of 'acn' objects.
+    // Assumes all acn's are children of the same bib record.
+    @Input() callNums: IdlObject[];
+
+    // Required field.
+    // All call numbers will be transferred to this record ID.
+    @Input() targetRecId: number;
+
+    // Optional.  If set, all call numbers will transfer to this org
+    // unit (owning lib) in addition to transfering to the select bib
+    // record.
+    @Input() targetOrgId: number;
+
+    @ViewChild('successMsg', {static: false})
+        private successMsg: StringComponent;
+
+    @ViewChild('noTargetMsg', {static: false})
+        private noTargetMsg: StringComponent;
+
+    @ViewChild('alertDialog', {static: false})
+        private alertDialog: AlertDialogComponent;
+
+    @ViewChild('progressDialog', {static: false})
+        private progressDialog: ProgressDialogComponent;
+
+    eventDesc: string;
+
+    constructor(
+        private toast: ToastService,
+        private net: NetService,
+        private auth: AuthService,
+        private evt: EventService) {}
+
+    ngOnInit() {}
+
+    // Resolves with true if transfer completed, false otherwise.
+    // Assumes all volumes are transferred to the same bib record.
+    transferHoldings(): Promise<Boolean> {
+        if (!this.callNums || this.callNums.length === 0) {
+            return Promise.resolve(false);
+        }
+
+        if (!this.targetRecId) {
+            this.toast.warning(this.noTargetMsg.text);
+            return Promise.resolve(false);
+        }
+
+        this.eventDesc = '';
+
+        // Group the transfers by owning library.
+        const transferVols: {[orgId: number]: number[]} = {};
+
+        if (this.targetOrgId) {
+
+            // Transfering all call numbers to the same bib record
+            // and owning library.
+            transferVols[+this.targetOrgId] = this.callNums.map(cn => cn.id());
+
+        } else {
+
+            // Transfering all call numbers to the same bib record
+            // while retaining existing owning library.
+            this.callNums.forEach(cn => {
+                const orgId = Number(cn.owning_lib());
+                if (!transferVols[orgId]) { transferVols[orgId] = []; }
+                transferVols[orgId].push(cn.id());
+            });
+        }
+
+        this.progressDialog.update({
+            value: 0,
+            max: Object.keys(transferVols).length
+        });
+        this.progressDialog.open();
+
+        return this.performTransfers(transferVols)
+        .then(res => {
+            this.progressDialog.close();
+            return res;
+        });
+    }
+
+    performTransfers(transferVols: any): Promise<Boolean> {
+        const orgId = Object.keys(transferVols)[0];
+        const volIds = transferVols[orgId];
+
+        // Avoid re-processing
+        delete transferVols[orgId];
+
+        // Note the AngJS client also assumes .override.
+        const method = 'open-ils.cat.asset.volume.batch.transfer.override';
+
+        return this.net.request('open-ils.cat', method, this.auth.token(), {
+            docid: this.targetRecId,
+            lib: orgId,
+            volumes: volIds
+        }).toPromise().then(resp => {
+            const evt = this.evt.parse(resp);
+
+            if (evt || Number(resp) !== 1) {
+                console.warn(resp);
+                this.eventDesc = evt ? evt.desc : '';
+
+                // Failure -- stop short there to avoid alert storm.
+                return this.alertDialog.open().toPromise()
+                .then(_ => { this.eventDesc = ''; return false; });
+            }
+
+            this.progressDialog.increment();
+
+            if (Object.keys(transferVols).length > 0) {
+                return this.performTransfers(transferVols);
+            }
+
+            return true; // All done
+        });
+    }
+}
+
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/transfer-items.component.html b/Open-ILS/src/eg2/src/app/staff/share/holdings/transfer-items.component.html
new file mode 100644 (file)
index 0000000..8c1b02c
--- /dev/null
@@ -0,0 +1,22 @@
+
+<eg-string #successMsg
+    text="Successfully Transferred Item(s)" i18n-text></eg-string>
+
+<eg-string #errorMsg
+    text="Failed To Transfer Item(s)" i18n-text></eg-string>
+
+<eg-string #noTargetMsg
+    text="No transfer target selected" i18n-text></eg-string>
+
+<eg-confirm-dialog #confirmDialog
+  i18n-dialogTitle i18n-dialogBody
+  dialogTitle="One or more items could not be transferred. Override?"
+  dialogBody="Reason(s) include: {{eventDesc}}">
+</eg-confirm-dialog>
+
+<eg-alert-dialog #alertDialog
+  i18n-dialogTitle i18n-dialogBody
+  dialogTitle="One or more items could not be transferred"
+  dialogBody="Reason(s) include: {{eventDesc}}">
+</eg-alert-dialog>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/transfer-items.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/transfer-items.component.ts
new file mode 100644 (file)
index 0000000..2fb52b0
--- /dev/null
@@ -0,0 +1,160 @@
+import {Component, OnInit, Input, ViewChild, Renderer2} from '@angular/core';
+import {Observable} from 'rxjs';
+import {switchMap, map, tap} from 'rxjs/operators';
+import {IdlObject, IdlService} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {EventService} from '@eg/core/event.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+import {AlertDialogComponent} from '@eg/share/dialog/alert.component';
+import {StringComponent} from '@eg/share/string/string.component';
+
+/* Transfer items to a call number. */
+
+@Component({
+  selector: 'eg-transfer-items',
+  templateUrl: 'transfer-items.component.html'
+})
+
+export class TransferItemsComponent implements OnInit {
+
+    @ViewChild('successMsg', {static: false})
+        private successMsg: StringComponent;
+
+    @ViewChild('errorMsg', {static: false})
+        private errorMsg: StringComponent;
+
+    @ViewChild('noTargetMsg', {static: false})
+        private noTargetMsg: StringComponent;
+
+    @ViewChild('confirmDialog', {static: false})
+        private confirmDialog: ConfirmDialogComponent;
+
+    @ViewChild('alertDialog', {static: false})
+        private alertDialog: AlertDialogComponent;
+
+    eventDesc: string;
+
+    constructor(
+        private toast: ToastService,
+        private idl: IdlService,
+        private net: NetService,
+        private auth: AuthService,
+        private evt: EventService) {}
+
+    ngOnInit() {}
+
+    // Transfers a set of items/copies (by ID) to the selected call
+    // number (by ID).
+    // Resolves with true if transfer completed, false otherwise.
+    transferItems(itemIds: number[],
+        cnId: number, override?: boolean): Promise<boolean> {
+
+        this.eventDesc = '';
+
+        let method = 'open-ils.cat.transfer_copies_to_volume';
+        if (override) { method += '.override'; }
+
+        return this.net.request('open-ils.cat',
+            method, this.auth.token(), cnId, itemIds)
+        .toPromise().then(resp => {
+
+            const evt = this.evt.parse(resp);
+
+            if (evt) {
+
+                if (override) {
+                    // Override failed, no looping please.
+                    this.toast.warning(this.errorMsg.text);
+                    return false;
+                }
+
+                this.eventDesc = evt.desc;
+
+                return this.confirmDialog.open().toPromise().then(ok =>
+                    ok ? this.transferItems(itemIds, cnId, true) : false);
+
+            } else { // success
+
+                this.toast.success(this.successMsg.text);
+                return true;
+            }
+        });
+    }
+
+    // Transfers a set of items/copies (by object with fleshed call numbers)
+    // to the selected record and org unit ID, creating new call numbers
+    // where needed.
+    // Resolves with true if transfer completed, false otherwise.
+    autoTransferItems(items: IdlObject[], // acp with fleshed call_number's
+        recId: number, orgId: number): Promise<Boolean> {
+
+        this.eventDesc = '';
+
+        const cnTransfers: any = {};
+        const itemTransfers: any = {};
+
+        items.forEach(item => {
+            const cn = item.call_number();
+
+            if (cn.owning_lib() !== orgId || cn.record() !== recId) {
+                cn.owning_lib(orgId);
+                cn.record(recId);
+
+                if (cnTransfers[cn.id()]) {
+                    itemTransfers[cn.id()].push(item.id());
+
+                } else {
+                    cnTransfers[cn.id()] = cn;
+                    itemTransfers[cn.id()] = [item.id()];
+                }
+            }
+        });
+
+        return this.transferCallNumbers(cnTransfers, itemTransfers);
+    }
+
+    transferCallNumbers(cnTransfers, itemTransfers): Promise<boolean> {
+
+        const cnId = Object.keys(cnTransfers)[0];
+        const cn = cnTransfers[cnId];
+        delete cnTransfers[cnId];
+
+        return this.net.request('open-ils.cat',
+            'open-ils.cat.call_number.find_or_create',
+            this.auth.token(),
+            cn.label(),
+            cn.record(),     // may be new
+            cn.owning_lib(), // may be new
+            (typeof cn.prefix() === 'object' ? cn.prefix().id() : cn.prefix()),
+            (typeof cn.suffix() === 'object' ? cn.suffix().id() : cn.suffix()),
+            cn.label_class()
+
+        ).toPromise().then(resp => {
+
+            const evt = this.evt.parse(resp);
+
+            if (evt) {
+                // Problem.  Stop processing.
+                this.toast.warning(this.errorMsg.text);
+                this.eventDesc = evt.desc;
+                return this.alertDialog.open().toPromise().then(_ => false);
+            }
+
+            return this.transferItems(itemTransfers[cn.id()], resp.acn_id)
+            .then(ok => {
+
+                if (ok && Object.keys(cnTransfers).length > 0) {
+                    // More call numbers to transfer.
+                    return this.transferCallNumbers(cnTransfers, itemTransfers);
+                }
+
+                return ok;
+            });
+        });
+    }
+}
+
+
+