LP1821382 Angular holdings maintenance continued.
authorBill Erickson <berickxx@gmail.com>
Mon, 18 Mar 2019 21:46:42 +0000 (17:46 -0400)
committerDan Wells <dbw2@calvin.edu>
Wed, 29 May 2019 19:30:50 +0000 (15:30 -0400)
Support for various context menu actions.

Signed-off-by: Bill Erickson <berickxx@gmail.com>
Signed-off-by: Dan Wells <dbw2@calvin.edu>

17 files changed:
Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts
Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.css [new file with mode: 0644]
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/catalog/record/pagination.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
Open-ILS/src/eg2/src/app/staff/catalog/resolver.service.ts
Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.html
Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.ts
Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-dialog.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-dialog.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
Open-ILS/src/eg2/src/app/staff/share/holdings/replace-barcode-dialog.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holdings/replace-barcode-dialog.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.ts

index 8b018d5..63f964e 100644 (file)
@@ -89,7 +89,7 @@ export class ComboboxComponent implements OnInit {
 
     // Useful for massaging the match string prior to comparison
     // and display.  Default version trims leading/trailing spaces.
-    formatDisplayString: (ComboboxEntry) => string;
+    formatDisplayString: (e: ComboboxEntry) => string;
 
     constructor(
       private elm: ElementRef,
index 1855000..46d25d7 100644 (file)
@@ -47,7 +47,8 @@ import {HoldingsMaintenanceComponent} from './record/holdings.component';
     StaffCommonModule,
     CatalogCommonModule,
     CatalogRoutingModule,
-    HoldsModule
+    HoldsModule,
+    HoldingsModule
   ],
   providers: [
     StaffCatalogService
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.css b/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.css
new file mode 100644 (file)
index 0000000..61b04cd
--- /dev/null
@@ -0,0 +1,41 @@
+/*
+:host /deep/ allows us to share style with child components.
+In this case, the holdings components wants its grid to see
+the CSS we have defined for different row types
+
+See https://v2.angular.io/docs/ts/latest/guide/component-styles.html
+*/
+
+/* grid row colors are bootstrap class="bg-info" with opacity */
+
+/*
+:host /deep/ .holdings-copy-row {
+}
+*/
+
+:host /deep/ .holdings-volume-row {
+    color: #004085;
+    background-color: rgb(23,162,184,0.2);
+}
+
+:host /deep/ .holdings-org-row-0 {
+    color: #004085;
+    background-color: rgb(23,162,184);
+}
+
+:host /deep/ .holdings-org-row-1 {
+    color: #004085;
+    background-color: rgb(23,162,184,0.8);
+}
+
+:host /deep/ .holdings-org-row-2 {
+    color: #004085;
+    background-color: rgb(23,162,184,0.6);
+}
+
+:host /deep/ .holdings-org-row-3 {
+    color: #004085;
+    background-color: rgb(23,162,184,0.4);
+}
+
+/* Add additional classes for more deeply nested org unit hierarchies */
\ No newline at end of file
index 3cfcb27..4b46e62 100644 (file)
@@ -1,4 +1,21 @@
 
+<!-- org unit selector -->
+
+<div class="row mt-3">
+  <div class="col-lg-4">
+    <div class="input-group">
+      <div class="input-group-prepend">
+        <div class="input-group-text" i18n>Holdings Maintenance</div>
+      </div>
+      <eg-org-select [initialOrg]="contextOrg" 
+        (onChange)="contextOrgChanged($event)">
+      </eg-org-select>
+    </div>
+  </div>
+</div>
+
+<!-- Location / Barcode cell template -->
+
 <ng-template #locationTemplate let-row="row" let-userContext="userContext">
   <!-- pl-* is doubled for added impact -->
   <div class="pl-{{row.locationDepth}}">
   </div>
 </ng-template>
 
+<!-- Holdable true/false display -->
+
 <ng-template #holdableTemplate let-row="row" let-userContext="userContext">
   <ng-container *ngIf="row.copy">
-    <ng-container *ngIf="userContext.copyIsHoldable(row.copy); else notHoldable">
-      <span i18n>Yes</span>
-    </ng-container>
-    <ng-template #notHoldable><span i18n>No</span></ng-template>
+    <eg-bool [value]="userContext.copyIsHoldable(row.copy)">
+    </eg-bool>
   </ng-container>
 </ng-template>
 
+<eg-mark-damaged-dialog #markDamagedDialog></eg-mark-damaged-dialog>
+<eg-mark-missing-dialog #markMissingDialog></eg-mark-missing-dialog>
+<eg-copy-alerts-dialog #copyAlertsDialog></eg-copy-alerts-dialog>
+<eg-replace-barcode-dialog #replaceBarcode></eg-replace-barcode-dialog>
 
+<!-- holdings grid -->
 <div class='eg-copies w-100 mt-3'>
   <eg-grid #holdingsGrid [dataSource]="gridDataSource"
-    (onRowActivate)="onRowActivate($event)"
-    [pageSize]="50" [rowClassCallback]="rowClassCallback"
+    (onRowActivate)="onRowActivate($event)" [disablePaging]="true"
+    [rowClassCallback]="rowClassCallback"
     [sortable]="false" persistKey="cat.holdings">
 
-    <!-- checkboxes -->
+    <!-- checkboxes / filters -->
 
     <eg-grid-toolbar-checkbox i18n-label label="Show Volumes" 
       #volsCheckbox (onChange)="toggleShowVolumes($event)">
       #emptyLibsCheckbox (onChange)="toggleShowEmptyLibs($event)">
     </eg-grid-toolbar-checkbox> 
 
+    <!-- row actions -->
+
+    <!-- row actions : Ungrouped -->
+
+    <eg-grid-toolbar-action
+      i18n-label label="Print Labels" (onClick)="openItemPrintLabels($event)">
+    </eg-grid-toolbar-action>
+
+    <!-- row actions : Add -->
+
+    <eg-grid-toolbar-action
+      i18n-group group="Add" i18n-label label="Add Call Numbers"
+      (onClick)="openVolCopyEdit($event, true, false)">
+    </eg-grid-toolbar-action>
+
+    <eg-grid-toolbar-action
+      i18n-group group="Add" i18n-label label="Add Items"
+      (onClick)="openVolCopyEdit($event, false, true)">
+    </eg-grid-toolbar-action>
+
+    <eg-grid-toolbar-action
+      i18n-group group="Add" i18n-label label="Add Call Numbers and Items"
+      (onClick)="openVolCopyEdit($event, true, true)">
+    </eg-grid-toolbar-action>
+
+    <eg-grid-toolbar-action
+      i18n-group group="Add" i18n-label label="Add Item Alerts"
+      (onClick)="openItemNotes($event, 'create')">
+    </eg-grid-toolbar-action>
+
+    <!-- row actions: Edit -->
+
+    <eg-grid-toolbar-action
+      i18n-group group="Edit" i18n-label label="Edit Call Numbers"
+      (onClick)="openVolCopyEdit($event, true, false)">
+    </eg-grid-toolbar-action>
+
+    <eg-grid-toolbar-action
+      i18n-group group="Edit" i18n-label label="Edit Call Numbers And Items"
+      (onClick)="openVolCopyEdit($event, true, true)">
+    </eg-grid-toolbar-action>
+
+    <eg-grid-toolbar-action
+      i18n-group group="Edit" i18n-label label="Edit Items"
+      (onClick)="openVolCopyEdit($event, false, true)">
+    </eg-grid-toolbar-action>
+    
+    <eg-grid-toolbar-action
+      i18n-group group="Edit" i18n-label label="Edit Item Alerts"
+      (onClick)="openItemNotes($event, 'manage')">
+    </eg-grid-toolbar-action>
+
+    <eg-grid-toolbar-action
+      i18n-group group="Edit" i18n-label label="Replace Barcodes"
+      (onClick)="openReplaceBarcodeDialog($event)">
+    </eg-grid-toolbar-action>
+    
+    <!-- row actions : Show -->
+
+    <eg-grid-toolbar-action
+      i18n-group group="Show" i18n-label label="Show Item Status (list)"
+      (onClick)="openItemStatusList($event)"></eg-grid-toolbar-action>
+
+    <eg-grid-toolbar-action
+      i18n-group group="Show" i18n-label label="Show Item Status (detail)"
+      (onClick)="openItemStatus($event)"></eg-grid-toolbar-action>
+
+    <eg-grid-toolbar-action
+      i18n-group group="Show" i18n-label label="Show Item Holds"
+      (onClick)="openItemHolds($event)"></eg-grid-toolbar-action>
+
+    <eg-grid-toolbar-action
+      i18n-group group="Show" i18n-label label="Show Triggered Events"
+      (onClick)="openItemTriggeredEvents($event)"></eg-grid-toolbar-action>
+
+    <!-- row actions : Mark -->
+
+    <eg-grid-toolbar-action
+      group="Mark" i18n-group i18n-label label="Mark Item Damaged"
+      (onClick)="showMarkDamagedDialog($event)"></eg-grid-toolbar-action>
+
+    <eg-grid-toolbar-action
+      i18n-group group="Mark" i18n-label label="Mark Item Missing"
+      (onClick)="showMarkMissingDialog($event)">
+    </eg-grid-toolbar-action>
+
+    <eg-grid-toolbar-action
+      i18n-group group="Mark" 
+      i18n-label label="Mark Library/Call Number as Transfer Destination"
+      (onClick)="markLibCnForTransfer($event)">
+    </eg-grid-toolbar-action>
+      
+
     <!-- fields -->
+    <!-- NOTE column names were added to match the names from the AngJS grid
+        so grid settings would propagate -->
+
     <eg-grid-column path="index" [hidden]="true" [index]="true">
     </eg-grid-column>
-    <eg-grid-column path="copy.id" [hidden]="true" label="Copy ID" i18n-label>
+    <eg-grid-column name="id" path="copy.id" [hidden]="true" label="Copy ID" i18n-label>
     </eg-grid-column>
     <eg-grid-column path="volume.id" [hidden]="true" label="Volume ID" i18n-label>
     </eg-grid-column>
-    <eg-grid-column name="location_barcode" [flex]="4"
+    <eg-grid-column name="owner_label" [flex]="4"
       [cellTemplate]="locationTemplate" [cellContext]="gridTemplateContext" 
       label="Location/Barcode" [disableTooltip]="true" i18n-label>
     </eg-grid-column>
     </eg-grid-column>
     <eg-grid-column path="copyCount" datatype="number" label="Copies" i18n-label>
     </eg-grid-column>
-    <eg-grid-column path="callNumberLabel" label="Call Number" i18n-label>
+    <eg-grid-column path="volume._label" name="call_number.label" label="Call Number" i18n-label>
+    </eg-grid-column>
+    <eg-grid-column path="copy.barcode" name="barcode" label="Barcode" i18n-label>
     </eg-grid-column>
     <eg-grid-column i18n-label label="Circ Library" path="copy.circ_lib" 
-      datatype="org_unit"></eg-grid-column>
+      name="circ_lib.name" datatype="org_unit"></eg-grid-column>
     <eg-grid-column i18n-label label="Owning Library" path="volume.owning_lib" 
       datatype="org_unit"></eg-grid-column>
     <eg-grid-column i18n-label label="Due Date" path="circ.due_date" 
       datatype="timestamp"></eg-grid-column>
-    <eg-grid-column i18n-label label="Shelving Location" path="copy.location.name">
+    <eg-grid-column i18n-label label="Shelving Location" path="copy.location.name" name="location.name">
     </eg-grid-column>
-    <eg-grid-column i18n-label label="Circulation Modifier" path="copy.circ_modifier">
+    <eg-grid-column i18n-label label="Circulation Modifier" 
+      path="copy.circ_modifier" name="circ_modifier">
+    </eg-grid-column>
+    <eg-grid-column i18n-label label="Copy Number" path="copy.copy_number" name="copy_number" [hidden]="true">
     </eg-grid-column>
 
-    <eg-grid-column i18n-label label="Status" path="copy.status.name">
+    <eg-grid-column i18n-label label="Status" path="copy.status.name" name="status_name">
+    </eg-grid-column>
+    <eg-grid-column i18n-label label="Call Number Prefix" 
+      path="volume.prefix.label" name="call_number.prefix.label" [hidden]="true">
+    </eg-grid-column>
+    <eg-grid-column i18n-label label="Call Number Suffix" 
+      path="volume.suffix.label" name="call_number.suffix.label" [hidden]="true">
     </eg-grid-column>
     <eg-grid-column i18n-label label="Active/Create Date" 
       path="copy.active_date" datatype="timestamp">
     </eg-grid-column>
     <eg-grid-column i18n-label label="Age Hold Protection" 
-      path="copy.age_protect.name"></eg-grid-column>
+      path="copy.age_protect.name" name="age_protect.name"></eg-grid-column>
+    <eg-grid-column i18n-label label="Copy Price" 
+      path="copy.price" name="price" [hidden]="true"></eg-grid-column>
 
+    <eg-grid-column i18n-label label="Circulate" path="copy.circulate" 
+      name="circulate" datatype="bool" [hidden]="true"></eg-grid-column>
+    <eg-grid-column i18n-label label="Deposit" path="copy.deposit" 
+      name="deposit" datatype="bool" [hidden]="true"></eg-grid-column>
+    <eg-grid-column i18n-label label="Deposit Amount" path="copy.deposit_amount" 
+      name="deposit_amount" datatype="money" [hidden]="true"></eg-grid-column>
     <eg-grid-column i18n-label label="Holdable?" name="holdable" 
       [cellTemplate]="holdableTemplate" [cellContext]="gridTemplateContext">
     </eg-grid-column>
-
+    <eg-grid-column i18n-label label="Reference?" path="copy.ref" 
+      name="ref" datatype="bool" [hidden]="true"></eg-grid-column>
+    <eg-grid-column i18n-label label="Last Inventory Date" 
+      path="copy.latest_inventory.inventory_date" 
+      name="latest_inventory.inventory_date" datatype="timestamp" [hidden]="true">
+    </eg-grid-column>
+    <eg-grid-column i18n-label label="Last Inventory Workstation" 
+      path="copy.latest_inventory.inventory_workstation.name" 
+      name="latest_inventory.inventory_workstation.name" [hidden]="true">
+    </eg-grid-column>
   </eg-grid>
 </div>
 
index 0f9e4ad..3e69e90 100644 (file)
@@ -3,16 +3,26 @@ import {Observable, Observer, of} from 'rxjs';
 import {map} from 'rxjs/operators';
 import {Pager} from '@eg/share/util/pager';
 import {IdlObject} from '@eg/core/idl.service';
-import {NetService} from '@eg/core/net.service';
 import {StaffCatalogService} from '../catalog.service';
 import {OrgService} from '@eg/core/org.service';
-import {AuthService} from '@eg/core/auth.service';
 import {PcrudService} from '@eg/core/pcrud.service';
+import {AuthService} from '@eg/core/auth.service';
 import {GridDataSource} from '@eg/share/grid/grid';
 import {GridComponent} from '@eg/share/grid/grid.component';
-import {GridToolbarCheckboxComponent} from '@eg/share/grid/grid-toolbar-checkbox.component';
+import {GridToolbarCheckboxComponent
+    } from '@eg/share/grid/grid-toolbar-checkbox.component';
+import {StoreService} from '@eg/core/store.service';
 import {ServerStoreService} from '@eg/core/server-store.service';
-
+import {MarkDamagedDialogComponent
+    } from '@eg/staff/share/holdings/mark-damaged-dialog.component';
+import {MarkMissingDialogComponent
+    } from '@eg/staff/share/holdings/mark-missing-dialog.component';
+import {AnonCacheService} from '@eg/share/util/anon-cache.service';
+import {HoldingsService} from '@eg/staff/share/holdings/holdings.service';
+import {CopyAlertsDialogComponent
+    } from '@eg/staff/share/holdings/copy-alerts-dialog.component';
+import {ReplaceBarcodeDialogComponent
+    } from '@eg/staff/share/holdings/replace-barcode-dialog.component';
 
 // The holdings grid models a single HoldingsTree, composed of HoldingsTreeNodes
 // flattened on-demand into a list of HoldingEntry objects.
@@ -53,26 +63,44 @@ class HoldingsEntry {
 
 @Component({
   selector: 'eg-holdings-maintenance',
-  templateUrl: 'holdings.component.html'
+  templateUrl: 'holdings.component.html',
+  styleUrls: ['holdings.component.css']
 })
 export class HoldingsMaintenanceComponent implements OnInit {
 
-    recId: number;
     initDone = false;
     gridDataSource: GridDataSource;
     gridTemplateContext: any;
     @ViewChild('holdingsGrid') holdingsGrid: GridComponent;
 
     // Manage visibility of various sub-sections
-    @ViewChild('volsCheckbox') volsCheckbox: GridToolbarCheckboxComponent;
-    @ViewChild('copiesCheckbox') copiesCheckbox: GridToolbarCheckboxComponent;
-    @ViewChild('emptyVolsCheckbox') emptyVolsCheckbox: GridToolbarCheckboxComponent;
-    @ViewChild('emptyLibsCheckbox') emptyLibsCheckbox: GridToolbarCheckboxComponent;
+    @ViewChild('volsCheckbox')
+        private volsCheckbox: GridToolbarCheckboxComponent;
+    @ViewChild('copiesCheckbox')
+        private copiesCheckbox: GridToolbarCheckboxComponent;
+    @ViewChild('emptyVolsCheckbox')
+        private emptyVolsCheckbox: GridToolbarCheckboxComponent;
+    @ViewChild('emptyLibsCheckbox')
+        private emptyLibsCheckbox: GridToolbarCheckboxComponent;
+    @ViewChild('markDamagedDialog')
+        private markDamagedDialog: MarkDamagedDialogComponent;
+    @ViewChild('markMissingDialog')
+        private markMissingDialog: MarkMissingDialogComponent;
+    @ViewChild('copyAlertsDialog')
+        private copyAlertsDialog: CopyAlertsDialogComponent;
+    @ViewChild('replaceBarcode')
+        private replaceBarcode: ReplaceBarcodeDialogComponent;
 
-    contextOrg: IdlObject;
     holdingsTree: HoldingsTree;
-    holdingsTreeOrgCache: {[id: number]: HoldingsTreeNode};
+
+    // nodeType => id => tree node cache
+    treeNodeCache: {[nodeType: string]: {[id: number]: HoldingsTreeNode}};
+
+    // When true and a grid reload is called, the holdings data will be
+    // re-fetched from the server.
     refreshHoldings: boolean;
+
+    // Used as a row identifier in th grid, since we're mixing object types.
     gridIndex: number;
 
     // List of copies whose due date we need to retrieve.
@@ -82,35 +110,54 @@ export class HoldingsMaintenanceComponent implements OnInit {
     // When not true, render based on the current "expanded" state of each node.
     // Rendering from prefs happens on initial load and when any prefs change.
     renderFromPrefs: boolean;
+
     rowClassCallback: (row: any) => string;
 
+    private _recId: number;
     @Input() set recordId(id: number) {
-        this.recId = id;
+        this._recId = id;
         // Only force new data collection when recordId()
         // is invoked after ngInit() has already run.
         if (this.initDone) {
-            this.refreshHoldings = true;
-            this.holdingsGrid.reload();
+            this.hardRefresh();
         }
     }
+    get recordId(): number {
+        return this._recId;
+    }
+
+    contextOrg: IdlObject;
 
     constructor(
-        private net: NetService,
         private org: OrgService,
-        private auth: AuthService,
         private pcrud: PcrudService,
+        private auth: AuthService,
         private staffCat: StaffCatalogService,
-        private store: ServerStoreService
+        private store: ServerStoreService,
+        private localStore: StoreService,
+        private holdings: HoldingsService,
+        private anonCache: AnonCacheService
     ) {
         // Set some sane defaults before settings are loaded.
-        this.contextOrg = this.org.get(this.auth.user().ws_ou());
         this.gridDataSource = new GridDataSource();
         this.refreshHoldings = true;
         this.renderFromPrefs = true;
 
+        // TODO: need a separate setting for this?
+        this.contextOrg = this.staffCat.searchContext.searchOrg;
+
         this.rowClassCallback = (row: any): string => {
-             if (row.volume && !row.copy) {
-                return 'bg-info';
+            if (row.volume) {
+                if (row.copy) {
+                    return 'holdings-copy-row';
+                } else {
+                    return 'holdings-volume-row';
+                }
+            } else {
+                // Add a generic org unit class and a depth-specific
+                // class for styling different levels of the org tree.
+                return 'holdings-org-row holdings-org-row-' +
+                    row.treeNode.target.ou_type().depth();
             }
         }
 
@@ -142,7 +189,7 @@ export class HoldingsMaintenanceComponent implements OnInit {
     ngOnInit() {
         this.initDone = true;
 
-        // These are pre-cached via the resolver.
+        // These are pre-cached via the catalog resolver.
         const settings = this.store.getItemBatchCached([
             'cat.holdings_show_empty_org',
             'cat.holdings_show_empty',
@@ -150,18 +197,31 @@ export class HoldingsMaintenanceComponent implements OnInit {
             'cat.holdings_show_vols'
         ]);
 
-        this.volsCheckbox.checked(settings['cat.holdings_show_vols']);
+        // Show volumes by default when no preference is set.
+        let showVols = settings['cat.holdings_show_vols'];
+        if (showVols === null) { showVols = true; }
+
+        this.volsCheckbox.checked(showVols);
         this.copiesCheckbox.checked(settings['cat.holdings_show_copies']);
         this.emptyVolsCheckbox.checked(settings['cat.holdings_show_empty']);
         this.emptyLibsCheckbox.checked(settings['cat.holdings_show_empty_org']);
 
+        this.initHoldingsTree();
         this.gridDataSource.getRows = (pager: Pager, sort: any[]) => {
             return this.fetchHoldings(pager);
         };
     }
 
-    ngAfterViewInit() {
+    contextOrgChanged(org: IdlObject) {
+        this.contextOrg = org;
+        this.hardRefresh();
+    }
 
+    hardRefresh() {
+        this.renderFromPrefs = true;
+        this.refreshHoldings = true;
+        this.initHoldingsTree();
+        this.holdingsGrid.reload();
     }
 
     toggleShowCopies(value: boolean) {
@@ -210,33 +270,40 @@ export class HoldingsMaintenanceComponent implements OnInit {
 
     initHoldingsTree() {
 
+        const visibleOrgs = this.org.fullPath(this.contextOrg, true);
+
         // The initial tree simply matches the org unit tree
         const traverseOrg = (node: HoldingsTreeNode) => {
-            node.expanded = true;
             node.target.children().forEach((org: IdlObject) => {
+                if (visibleOrgs.indexOf(org.id()) == -1) {
+                    return; // Org is outside of scope
+                }
                 const nodeChild = new HoldingsTreeNode();
                 nodeChild.nodeType = 'org';
                 nodeChild.target = org;
                 nodeChild.parentNode = node;
                 node.children.push(nodeChild);
-                this.holdingsTreeOrgCache[org.id()] = nodeChild;
+                this.treeNodeCache.org[org.id()] = nodeChild;
                 traverseOrg(nodeChild);
             });
         }
 
+        this.treeNodeCache = {
+            org: {},
+            volume: {},
+            copy: {}
+        };
+
         this.holdingsTree = new HoldingsTree();
         this.holdingsTree.root.nodeType = 'org';
         this.holdingsTree.root.target = this.org.root();
-
-        this.holdingsTreeOrgCache = {};
-        this.holdingsTreeOrgCache[this.org.root().id()] = this.holdingsTree.root;
+        this.treeNodeCache.org[this.org.root().id()] = this.holdingsTree.root;
 
         traverseOrg(this.holdingsTree.root);
     }
 
     // Org node children are sorted with any child org nodes pushed to the
     // front, followed by the call number nodes sorted alphabetcially by label.
-    // TODO: prefix/suffix
     sortOrgNodeChildren(node: HoldingsTreeNode) {
         node.children = node.children.sort((a, b) => {
             if (a.nodeType === 'org') {
@@ -248,7 +315,9 @@ export class HoldingsMaintenanceComponent implements OnInit {
             } else if (b.nodeType === 'org') {
                 return 1;
             } else {
-                return a.target.label() < b.target.label() ? -1 : 1;
+                // TODO: should this use label sortkey instead of
+                // the compiled volume label?
+                return a.target._label < b.target._label ? -1 : 1;
             }
         });
     }
@@ -309,7 +378,7 @@ export class HoldingsMaintenanceComponent implements OnInit {
 
         switch(node.nodeType) {
             case 'org':
-                if (this.renderFromPrefs && node.volumeCount === 0
+                if (node.volumeCount === 0
                     && !this.emptyLibsCheckbox.checked()) {
                     return;
                 }
@@ -321,7 +390,16 @@ export class HoldingsMaintenanceComponent implements OnInit {
                 break;
 
             case 'volume':
-                entry.locationLabel = node.target.label(); // TODO prefix/suffix
+                if (this.renderFromPrefs) {
+                    if (!this.volsCheckbox.checked()) {
+                        return;
+                    }
+                    if (node.copyCount === 0
+                        && !this.emptyVolsCheckbox.checked()) {
+                        return;
+                    }
+                }
+                entry.locationLabel = node.target._label;
                 entry.locationDepth = node.parentNode.target.ou_type().depth() + 1;
                 entry.callNumberLabel = entry.locationLabel;
                 entry.volume = node.target;
@@ -357,9 +435,9 @@ export class HoldingsMaintenanceComponent implements OnInit {
         this.renderFromPrefs = false;
     }
 
-
+    // Grab volumes, copies, and related data.
     fetchHoldings(pager: Pager): Observable<any> {
-        if (!this.recId) { return of([]); }
+        if (!this.recordId) { return of([]); }
 
         return new Observable<any>(observer => {
 
@@ -368,12 +446,11 @@ export class HoldingsMaintenanceComponent implements OnInit {
                 return;
             }
 
-            this.initHoldingsTree();
             this.itemCircsNeeded = [];
 
             this.pcrud.search('acn',
-                {   record: this.recId,
-                    owning_lib: this.org.ancestors(this.contextOrg, true),
+                {   record: this.recordId,
+                    owning_lib: this.org.fullPath(this.contextOrg, true),
                     deleted: 'f',
                     label: {'!=' : '##URI##'}
                 }, {
@@ -384,7 +461,8 @@ export class HoldingsMaintenanceComponent implements OnInit {
                         acn: ['prefix', 'suffix', 'copies'],
                         acli: ['inventory_workstation']
                     }
-                }
+                },
+                {authoritative: true}
             ).subscribe(
                 vol => this.appendVolume(vol),
                 err => {},
@@ -413,28 +491,278 @@ export class HoldingsMaintenanceComponent implements OnInit {
         })).toPromise();
     }
 
+    // Compile prefix + label + suffix into field volume._label;
+    setVolumeLabel(volume: IdlObject) {
+        const pfx = volume.prefix() ? volume.prefix().label() : '';
+        const sfx = volume.suffix() ? volume.suffix().label() : '';
+        volume._label = pfx ? pfx + ' ' : '';
+        volume._label += volume.label();
+        volume._label += sfx ? ' ' + sfx : '';
+    }
+
+    // Create the tree node for the volume if it doesn't already exist.
+    // Do the same for its linked copies.
     appendVolume(volume: IdlObject) {
+        let volNode = this.treeNodeCache.volume[volume.id()];
+        this.setVolumeLabel(volume);
+
+        if (volNode) {
+            const pNode = this.treeNodeCache.org[volume.owning_lib()]
+            if (volNode.parentNode.target.id() !== pNode.target.id()) {
+                // Volume owning library changed.  Un-link it from the previous
+                // org unit collection before adding to the new one.
+                // XXX TODO: ^--
+                volNode.parentNode = pNode;
+                volNode.parentNode.children.push(volNode);
+            }
+        } else {
+            volNode = new HoldingsTreeNode();
+            volNode.nodeType = 'volume';
+            volNode.parentNode = this.treeNodeCache.org[volume.owning_lib()]
+            volNode.parentNode.children.push(volNode);
+            this.treeNodeCache.volume[volume.id()] = volNode;
+        }
 
-        const volNode = new HoldingsTreeNode();
-        volNode.parentNode = this.holdingsTreeOrgCache[volume.owning_lib()];
-        volNode.parentNode.children.push(volNode);
-        volNode.nodeType = 'volume';
         volNode.target = volume;
 
         volume.copies()
             .sort((a: IdlObject, b: IdlObject) => a.barcode() < b.barcode() ? -1 : 1)
-            .forEach((copy: IdlObject) => {
-                const copyNode = new HoldingsTreeNode();
+            .forEach((copy: IdlObject) => this.appendCopy(volNode, copy));
+    }
+
+    // Find or create a copy node.
+    appendCopy(volNode: HoldingsTreeNode, copy: IdlObject) {
+        let copyNode = this.treeNodeCache.copy[copy.id()];
+
+        if (copyNode) {
+            const oldParent = copyNode.parentNode;
+            if (oldParent.target.id() !== volNode.target.id()) {
+                // TODO: copy changed owning volume.  Remove it from
+                // the previous volume before adding to the new volume.
                 copyNode.parentNode = volNode;
                 volNode.children.push(copyNode);
-                copyNode.nodeType = 'copy';
-                copyNode.target = copy;
-                const stat = Number(copy.status().id());
-                if (stat === 1 /* checked out */ || stat === 16 /* long overdue */) {
-                    this.itemCircsNeeded.push(copy);
-                }
-            });
+            }
+        } else {
+            // New node required
+            copyNode = new HoldingsTreeNode();
+            copyNode.nodeType = 'copy';
+            volNode.children.push(copyNode);
+            copyNode.parentNode = volNode;
+            this.treeNodeCache.copy[copy.id()] = copyNode;
+        }
+
+        copyNode.target = copy;
+        const stat = Number(copy.status().id());
+
+        if (stat === 1 /* checked out */ || stat === 16 /* long overdue */) {
+            // Avoid looking up circs on items that are not checked out.
+            this.itemCircsNeeded.push(copy);
+        }
     }
-}
 
+    // Which copies in the grid are selected.
+    selectedCopyIds(rows: HoldingsEntry[], skipStatus?: number): number[] {
+        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()));
+    }
+
+    selectedVolumeIds(rows: HoldingsEntry[]): number[] {
+        return rows
+            .filter(r => r.treeNode.nodeType === 'volume')
+            .map(r => Number(r.volume.id()));
+    }
+
+    async showMarkDamagedDialog(rows: HoldingsEntry[]) {
+        const copyIds = this.selectedCopyIds(rows, 14 /* ignore damaged */);
+
+        if (copyIds.length === 0) { return; }
+
+        let rowsModified = false;
+
+        const markNext = async(ids: number[]) => {
+            if (ids.length === 0) {
+                return Promise.resolve();
+            }
+
+            this.markDamagedDialog.copyId = ids.pop();
+            return this.markDamagedDialog.open({size: 'lg'}).then(
+                ok => {
+                    if (ok) { rowsModified = true; }
+                    return markNext(ids);
+                },
+                dismiss => markNext(ids)
+            );
+        };
+
+        await markNext(copyIds);
+        if (rowsModified) {
+            this.refreshHoldings = true;
+            this.holdingsGrid.reload();
+        }
+    }
+
+    showMarkMissingDialog(rows: any[]) {
+        const copyIds = this.selectedCopyIds(rows, 4 /* ignore missing */);
+        if (copyIds.length > 0) {
+            this.markMissingDialog.copyIds = copyIds;
+            this.markMissingDialog.open({}).then(
+                rowsModified => {
+                    if (rowsModified) {
+                        this.refreshHoldings = true;
+                        this.holdingsGrid.reload();
+                    }
+                },
+                dismissed => {} // avoid console errors
+            );
+        }
+    }
+
+    // Mark record, library, and potentially the selected call number
+    // as the current transfer target.
+    markLibCnForTransfer(rows: HoldingsEntry[]) {
+        if (rows.length === 0) {
+            return;
+        }
+
+        // Action may only apply to a single org or volume row.
+        const node = rows[0].treeNode;
+        if (node.nodeType === 'copy') {
+            return;
+        }
+
+        let orgId: number;
+
+        if (node.nodeType === 'org') {
+            orgId = node.target.id();
+
+            // Clear volume target when performed on an org unit row
+            this.localStore.removeLocalItem('eg.cat.transfer_target_vol');
 
+        } else if (node.nodeType === 'volume') {
+
+            // All volume nodes are children of org nodes.
+            orgId = node.parentNode.target.id();
+
+            // Add volume target when performed on a volume row.
+            this.localStore.setLocalItem(
+                'eg.cat.transfer_target_vol', node.target.id())
+        }
+
+        this.localStore.setLocalItem('eg.cat.transfer_target_record', this.recordId);
+        this.localStore.setLocalItem('eg.cat.transfer_target_lib', orgId);
+    }
+
+    openAngJsWindow(path: string) {
+        const url = `/eg/staff/${path}`;
+        window.open(url, '_blank');
+    }
+
+    openItemHolds(rows: HoldingsEntry[]) {
+        if (rows.length > 0 && rows[0].copy) {
+            this.openAngJsWindow(`cat/item/${rows[0].copy.id()}/holds`);
+        }
+    }
+
+    openItemStatusList(rows: HoldingsEntry[]) {
+        const ids = this.selectedCopyIds(rows);
+        if (ids.length > 0) {
+            return this.openAngJsWindow(`cat/item/search/${ids.join(',')}`);
+        }
+    }
+
+    openItemStatus(rows: HoldingsEntry[]) {
+        if (rows.length > 0 && rows[0].copy) {
+           return this.openAngJsWindow(`cat/item/${rows[0].copy.id()}`);
+        }
+    }
+
+    openItemTriggeredEvents(rows: HoldingsEntry[]) {
+        if (rows.length > 0 && rows[0].copy) {
+           return this.openAngJsWindow(
+               `cat/item/${rows[0].copy.id()}/triggered_events`);
+        }
+    }
+
+    openItemPrintLabels(rows: HoldingsEntry[]) {
+        const ids = this.selectedCopyIds(rows);
+        if (ids.length === 0) { return; }
+
+        this.anonCache.setItem(null, 'print-labels-these-copies', {copies: ids})
+        .then(key => this.openAngJsWindow(`cat/printlabels/${key}`));
+    }
+
+    openVolCopyEdit(rows: HoldingsEntry[], addVols: boolean, addCopies: boolean) {
+
+        // The user may select a set of volumes by selecting volume and/or
+        // copy rows.
+        const volumes = [];
+        rows.forEach(r => {
+            if (r.treeNode.nodeType === 'volume') {
+                volumes.push(r.volume);
+            } else if (r.treeNode.nodeType === 'copy') {
+                volumes.push(r.treeNode.parentNode.target);
+            }
+        });
+
+        if (addCopies && !addVols) {
+            // Adding copies to an existing set of volumes.
+            if (volumes.length > 0) {
+                const volIds = volumes.map(v => Number(v.id()));
+                this.holdings.spawnAddHoldingsUi(this.recordId, volIds);
+            }
+
+        } else if (addVols) {
+            const entries = [];
+
+            if (volumes.length > 0) {
+
+                // When adding volumes, if any are selected in the grid,
+                // create volumes that have the same label and owner.
+                volumes.forEach(v =>
+                    entries.push({label: v.label(), owner: v.owning_lib()}));
+
+                } else {
+
+                // Otherwise create new volumes from scratch.
+                entries.push({owner: this.auth.user().ws_ou()})
+            }
+
+            this.holdings.spawnAddHoldingsUi(
+                this.recordId, null, entries, !addCopies);
+        }
+    }
+
+    openItemNotes(rows: HoldingsEntry[], mode: string) {
+        const copyIds = this.selectedCopyIds(rows);
+        if (copyIds.length === 0) { return; }
+
+        this.copyAlertsDialog.copyIds = copyIds;
+        this.copyAlertsDialog.mode = mode;
+        this.copyAlertsDialog.open({size: 'lg'}).then(
+            modified => {
+                if (modified) {
+                    this.hardRefresh();
+                }
+            },
+            dismissed => {}
+        )
+    }
+
+    openReplaceBarcodeDialog(rows: HoldingsEntry[]) {
+        const ids = this.selectedCopyIds(rows);
+        if (ids.length === 0) { return; }
+        this.replaceBarcode.copyIds = ids;
+        this.replaceBarcode.open({}).then(
+            modified => {
+                if (modified) {
+                    this.hardRefresh();
+                }
+            },
+            dismissed => {}
+        );
+    }
+}
index 793767b..b3e9a9c 100644 (file)
@@ -18,6 +18,14 @@ export class RecordPaginationComponent implements OnInit {
     initDone = false;
     searchContext: CatalogSearchContext;
 
+    _recordTab: string;
+    @Input() set recordTab(tab: string) {
+        this._recordTab = tab;
+    }
+    get recordTab(): string {
+        return this._recordTab;
+    }
+
     @Input() set recordId(id: number) {
         this.id = id;
         // Only apply new record data after the initial load
@@ -38,41 +46,33 @@ export class RecordPaginationComponent implements OnInit {
         this.setIndex();
     }
 
+    routeToRecord(id: number) {
+        let url = '/staff/catalog/record/' + id;
+        if (this.recordTab) { url += '/' + this.recordTab; }
+        const params = this.catUrl.toUrlParams(this.searchContext);
+        this.router.navigate([url], {queryParams: params});
+    }
+
     firstRecord(): void {
-        this.findRecordAtIndex(0).then(id => {
-            const params = this.catUrl.toUrlParams(this.searchContext);
-            this.router.navigate(
-                ['/staff/catalog/record/' + id], {queryParams: params});
-        });
+        this.findRecordAtIndex(0)
+        .then(id => this.routeToRecord(id));
     }
 
     lastRecord(): void {
-        this.findRecordAtIndex(
-            this.searchContext.result.count - 1
-        ).then(id => {
-            const params = this.catUrl.toUrlParams(this.searchContext);
-            this.router.navigate(
-                ['/staff/catalog/record/' + id], {queryParams: params});
-        });
+        this.findRecordAtIndex(this.searchContext.result.count - 1)
+        .then(id => this.routeToRecord(id));
     }
 
     nextRecord(): void {
-        this.findRecordAtIndex(this.index + 1).then(id => {
-            const params = this.catUrl.toUrlParams(this.searchContext);
-            this.router.navigate(
-                ['/staff/catalog/record/' + id], {queryParams: params});
-        });
+        this.findRecordAtIndex(this.index + 1)
+        .then(id => this.routeToRecord(id));
     }
 
     prevRecord(): void {
-        this.findRecordAtIndex(this.index - 1).then(id => {
-            const params = this.catUrl.toUrlParams(this.searchContext);
-            this.router.navigate(
-                ['/staff/catalog/record/' + id], {queryParams: params});
-        });
+        this.findRecordAtIndex(this.index - 1)
+        .then(id => this.routeToRecord(id));
     }
 
-
     // Returns the offset of the record within the search results as a whole.
     searchIndex(idx: number): number {
         return idx + this.searchContext.pager.offset;
index b583cf7..4508870 100644 (file)
@@ -3,7 +3,7 @@
   <div class="row ml-0 mr-0">
     <div id='staff-catalog-bib-navigation'>
       <div *ngIf="searchContext.isSearchable()">
-        <eg-catalog-record-pagination [recordId]="recordId">
+        <eg-catalog-record-pagination [recordId]="recordId" [recordTab]="recordTab">
         </eg-catalog-record-pagination>
       </div>
     </div>
index f4f5d97..0a09cbd 100644 (file)
@@ -45,7 +45,6 @@ export class CatalogResolver implements Resolve<Promise<any[]>> {
             'cat.holdings_show_empty',
             'cat.marcedit.stack_subfields',
             'cat.marcedit.flateditor',
-            'eg.cat.record.summary.collapse',
             'cat.holdings_show_copies',
             'cat.holdings_show_vols'
         ]).then(settings => {
index d49de1b..530e108 100644 (file)
@@ -8,12 +8,12 @@
     <div>
       <a class="with-material-icon no-href text-primary" 
         title="Show More" i18n-title
-        *ngIf="!expandDisplay" (click)="expandDisplay=true">
+        *ngIf="!expand" (click)="expand=true">
         <span class="material-icons">expand_more</span>
       </a>
       <a class="with-material-icon no-href text-primary" 
         title="Show Less" i18n-title
-        *ngIf="expandDisplay" (click)="expandDisplay=false">
+        *ngIf="expand" (click)="expand=false">
         <span class="material-icons">expand_less</span>
       </a>
     </div>
@@ -36,7 +36,7 @@
           </div>
         </div>
       </li>
-      <li class="list-group-item" *ngIf="expandDisplay">
+      <li class="list-group-item" *ngIf="expand">
         <div class="d-flex">
           <div class="flex-1 font-weight-bold" i18n>Author:</div>
           <div class="flex-3">{{summary.display.author}}</div>
@@ -52,7 +52,7 @@
           </div>
         </div>
       </li>
-      <li class="list-group-item" *ngIf="expandDisplay">
+      <li class="list-group-item" *ngIf="expand">
         <div class="d-flex">
           <div class="flex-1 font-weight-bold" i18n>Bib Call #:</div>
           <div class="flex-3">{{summary.bibCallNumber}}</div>
index 645b56c..954cb8b 100644 (file)
@@ -1,9 +1,8 @@
 import {Component, OnInit, Input} from '@angular/core';
-import {NetService} from '@eg/core/net.service';
 import {OrgService} from '@eg/core/org.service';
-import {PcrudService} from '@eg/core/pcrud.service';
-import {CatalogService} from '@eg/share/catalog/catalog.service';
-import {BibRecordService, BibRecordSummary} from '@eg/share/catalog/bib-record.service';
+import {BibRecordService, BibRecordSummary
+    } from '@eg/share/catalog/bib-record.service';
+import {ServerStoreService} from '@eg/core/server-store.service';
 
 @Component({
   selector: 'eg-bib-summary',
@@ -13,10 +12,16 @@ import {BibRecordService, BibRecordSummary} from '@eg/share/catalog/bib-record.s
 export class BibSummaryComponent implements OnInit {
 
     initDone = false;
-    expandDisplay = true;
-    @Input() set expand(e: boolean) {
-        this.expandDisplay = e;
+
+    // True / false if the display is vertically expanded
+    private _exp: boolean;
+    set expand(e: boolean) {
+        this._exp = e;
+        if (this.initDone) {
+            this.saveExpandState();
+        }
     }
+    get expand(): boolean { return this._exp; }
 
     // If provided, the record will be fetched by the component.
     @Input() recordId: number;
@@ -32,14 +37,12 @@ export class BibSummaryComponent implements OnInit {
 
     constructor(
         private bib: BibRecordService,
-        private cat: CatalogService,
-        private net: NetService,
         private org: OrgService,
-        private pcrud: PcrudService
+        private store: ServerStoreService
     ) {}
 
     ngOnInit() {
-        this.initDone = true;
+
         if (this.summary) {
             this.summary.getBibCallNumber();
         } else {
@@ -47,6 +50,14 @@ export class BibSummaryComponent implements OnInit {
                 this.loadSummary();
             }
         }
+
+        this.store.getItem('eg.cat.record.summary.collapse')
+        .then(value => this.expand = !value)
+        .then(() => this.initDone = true);
+    }
+
+    saveExpandState() {
+        this.store.setItem('eg.cat.record.summary.collapse', !this.expand);
     }
 
     loadSummary(): void {
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-dialog.component.html
new file mode 100644 (file)
index 0000000..4b3c1ca
--- /dev/null
@@ -0,0 +1,109 @@
+<eg-string #successMsg text="Successfully Modified Copy Alerts" i18n-text></eg-string>
+<eg-string #errorMsg text="Failed To Modify Copy Alerts" i18n-text></eg-string>
+
+<ng-template #dialogContent>
+  <div class="modal-header">
+    <h4 class="modal-title">
+      <ng-container *ngIf="mode == 'create'">
+        <span i18n>Adding alerts for {{copies.length}} item(s).</span>
+      </ng-container>
+      <ng-container *ngIf="mode == 'manage'">
+        <span i18n>Managing alerts for item {{copies[0].barcode()}}</span>
+      </ng-container>
+      <span i18n></span>
+    </h4>
+    <button type="button" class="close" 
+      i18n-aria-label aria-label="Close" (click)="dismiss('cross_click')">
+      <span aria-hidden="true">&times;</span>
+    </button>
+  </div>
+  <div class="modal-body p-4 form-validated">
+    <div class="row mt-2 p-2 rounded border border-success">
+      <div class="col-lg-4">
+        <eg-combobox [entries]="alertTypes" 
+          i18n-placeholder placeholder="New Alert Type..."
+          [required]="true"
+          (onChange)="newAlert.alert_type($event ? $event.id : null)">
+        </eg-combobox>
+      </div>
+      <div class="col-lg-5">
+        <textarea class="form-control" rows="2" 
+          i18n-placeholder placeholder="New Alert Note..."
+          (ngModelChange)="newAlert.note($event)" [ngModel]="newAlert.note()">
+        </textarea>
+      </div>
+      <div class="col-lg-3">
+        <div class="d-flex flex-column">
+          <div class="form-check">
+            <input class="form-check-input" type="checkbox" 
+              [ngModel]="newAlert.temp() == 't'" 
+              (ngModelChange)="newAlert.temp($event ? 't' : 'f')"
+              id="new-alert-temporary">
+            <label class="form-check-label" for="new-alert-temporary" i18n>
+              Temporary?
+            </label>
+          </div>
+          <div class="pt-2">
+            <button class="btn btn-success" (click)="addNew()" i18n>
+              Add New
+            </button>
+          </div>  
+        </div>  
+      </div>
+    </div>
+    <ng-container *ngIf="mode == 'manage'">
+      <!-- in manage mode list all of the alerts linked to the copy -->
+      <div class="row mt-2" 
+        *ngFor="let alert of copy.copy_alerts()">
+        <div class="col-lg-12 pb-2"><hr/></div>
+        <div class="col-lg-4">
+          <eg-combobox [entries]="alertTypes" [startId]="alert.alert_type()"
+            i18n-placeholder placeholder="Alert Type..."
+            [required]="true"
+            (onChange)="alert.alert_type($event ? $event.id : null); alert.ischanged(true)">
+          </eg-combobox>
+          <div class="pl-2 pt-2" i18n>
+            Added: {{alert.create_time() | date:'shortDate'}}
+          </div>
+        </div>
+        <div class="col-lg-5">
+          <textarea class="form-control" rows="2" 
+            i18n-placeholder placeholder="Alert Note..."
+            (ngModelChange)="alert.note($event); alert.ischanged(true)"
+            [ngModel]="alert.note()">
+          </textarea>
+        </div>
+        <div class="col-lg-3">
+          <div class="d-flex flex-column">
+            <div class="form-check">
+              <input class="form-check-input" type="checkbox" 
+                [ngModel]="alert.temp() == 't'" 
+                (ngModelChange)="alert.temp($event ? 't' : 'f'); alert.ischanged(true)"
+                id="alert-temporary-{{alert.id()}}">
+              <label class="form-check-label" for="alert-temporary-{{alert.id()}}" i18n>
+                Temporary?
+              </label>
+            </div>
+            <div class="form-check pt-2">
+              <input class="form-check-input" type="checkbox" 
+                [ngModel]="alert.ack_time() != null" 
+                (ngModelChange)="alert.ack_time($event ? 'now' : null); alert.ischanged(true)"
+                id="alert-temporary-{{alert.id()}}">
+              <label class="form-check-label" for="alert-temporary-{{alert.id()}}" i18n>
+                Clear?
+              </label>
+            </div>
+          </div>
+        </div>
+      </div>
+    </ng-container>
+  </div>
+  <div class="modal-footer">
+    <button type="button" class="btn btn-secondary" 
+      (click)="dismiss('canceled')" i18n>Close</button>
+    <ng-container *ngIf="mode == 'manage'">
+      <button class="btn btn-success mr-2" 
+        (click)="applyChanges()" i18n>Apply Changes</button>
+    </ng-container>
+  </div>
+</ng-template>
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-dialog.component.ts
new file mode 100644 (file)
index 0000000..e0ce763
--- /dev/null
@@ -0,0 +1,185 @@
+import {Component, OnInit, Input, ViewChild} from '@angular/core';
+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 {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+
+/**
+ * Dialog for managing copy alerts.
+ */
+
+@Component({
+  selector: 'eg-copy-alerts-dialog',
+  templateUrl: 'copy-alerts-dialog.component.html'
+})
+
+export class CopyAlertsDialogComponent
+    extends DialogComponent implements OnInit {
+
+    _copyIds: number[];
+    @Input() set copyIds(ids: number[]) {
+        this._copyIds = [].concat(ids);
+    }
+    get copyIds(): number[] {
+        return this._copyIds;
+    }
+
+    _mode: string; // create | manage
+    @Input() set mode(m: string) {
+        this._mode = m;
+    }
+    get mode(): string {
+        return this._mode;
+    }
+
+    // In 'create' mode, we may be adding notes to multiple copies.
+    copies: IdlObject[];
+    // In 'manage' mode we only handle a single copy.
+    copy: IdlObject;
+    alertTypes: ComboboxEntry[];
+    newAlert: IdlObject;
+    changesMade: boolean;
+
+    @ViewChild('successMsg') private successMsg: StringComponent;
+    @ViewChild('errorMsg') private errorMsg: StringComponent;
+    @ViewChild('confirmDeleteDialog')
+        private confirmDeleteDialog: ConfirmDialogComponent;
+
+    constructor(
+        private modal: NgbModal, // required for passing to parent
+        private toast: ToastService,
+        private net: NetService,
+        private idl: IdlService,
+        private pcrud: PcrudService,
+        private org: OrgService,
+        private auth: AuthService) {
+        super(modal); // required for subclassing
+        this.copyIds = [];
+        this.copies = [];
+    }
+
+    ngOnInit() {}
+
+    /**
+     * Fetch the item/record, then open the dialog.
+     * Dialog promise resolves with true/false indicating whether
+     * the mark-damanged action occured or was dismissed.
+     */
+    async open(args: NgbModalOptions): Promise<boolean> {
+        this.copy = null;
+        this.copies = [];
+        this.newAlert = this.idl.create('aca');
+        this.newAlert.create_staff(this.auth.user().id());
+
+        if (this.copyIds.length === 0) {
+            return Promise.reject('copy ID required');
+        }
+
+        // In manage mode, we can only manage a single copy.
+        // But in create mode, we can add alerts to multiple copies.
+
+        if (this.mode === 'manage') {
+            if (this.copyIds.length > 1) {
+                console.warn('Attempt to manage alerts for multiple copies.');
+                this.copyIds = [this.copyIds[0]];
+            }
+        }
+
+        await this.getAlertTypes();
+        await this.getCopies();
+        if (this.mode === 'manage') {
+            await this.getCopyAlerts();
+        }
+        return super.open(args);
+    }
+
+    async getAlertTypes(): Promise<any> {
+        if (this.alertTypes) {
+            return Promise.resolve();
+        }
+        return this.pcrud.retrieveAll('ccat',
+        {   active: true,
+            scope_org: this.org.ancestors(this.auth.user().ws_ou(), true)
+        }, {atomic: true}
+        ).toPromise().then(alerts => {
+            this.alertTypes = alerts.map(a => ({id: a.id(), label: a.name()}));
+        });
+    }
+
+    async getCopies(): Promise<any> {
+        return this.pcrud.search('acp', {id: this.copyIds}, {}, {atomic: true})
+        .toPromise().then(copies => {
+            this.copies = copies;
+            copies.forEach(c => c.copy_alerts([]));
+            if (this.mode === 'manage') {
+                this.copy = copies[0];
+            }
+        });
+    }
+
+    // Copy alerts for the selected copies which have not been
+    // acknowledged by staff and are within org unit range of
+    // the alert type.
+    async getCopyAlerts(): Promise<any> {
+        const copyIds = this.copies.map(c => c.id());
+        const typeIds = this.alertTypes.map(a => a.id);
+
+        return this.pcrud.search('aca',
+            {copy: copyIds, ack_time: null, alert_type: typeIds},
+            {}, {atomic: true})
+        .toPromise().then(alerts => {
+            alerts.forEach(a => {
+                const copy = this.copies.filter(c => c.id() === a.copy())[0];
+                copy.copy_alerts().push(a);
+            });
+        });
+    }
+
+    // Add the in-progress new note to all copies.
+    addNew() {
+        if (!this.newAlert.alert_type()) { return; }
+
+        const alerts: IdlObject[] = [];
+        this.copies.forEach(c => {
+            const a = this.idl.clone(this.newAlert);
+            a.copy(c.id());
+            alerts.push(a);
+        });
+
+        this.pcrud.create(alerts).toPromise().then(
+            newAlert => {
+                this.successMsg.current().then(msg => this.toast.success(msg));
+                this.changesMade = true;
+                if (this.mode === 'create') {
+                    // In create mode, we assume the user wants to create
+                    // a single alert and be done with it.
+                    this.close(this.changesMade);
+                } else {
+                    // Otherwise, add the alert to the copy
+                    this.copy.copy_alerts().push(newAlert);
+                }
+            },
+            err => {
+                this.errorMsg.current().then(msg => this.toast.danger(msg))
+            }
+        );
+    }
+
+    applyChanges() {
+        const alerts = this.copy.copy_alerts().filter(a => a.ischanged());
+        if (alerts.length === 0) { return ;}
+        this.pcrud.update(alerts).toPromise().then(
+            ok => this.successMsg.current().then(msg => this.toast.success(msg)),
+            err => this.errorMsg.current().then(msg => this.toast.danger(msg))
+        )
+    }
+}
+
index 382e906..c931a2f 100644 (file)
@@ -3,18 +3,24 @@ import {StaffCommonModule} from '@eg/staff/common.module';
 import {HoldingsService} from './holdings.service';
 import {MarkDamagedDialogComponent} from './mark-damaged-dialog.component';
 import {MarkMissingDialogComponent} from './mark-missing-dialog.component';
+import {CopyAlertsDialogComponent} from './copy-alerts-dialog.component';
+import {ReplaceBarcodeDialogComponent} from './replace-barcode-dialog.component';
 
 @NgModule({
     declarations: [
       MarkDamagedDialogComponent,
-      MarkMissingDialogComponent
+      MarkMissingDialogComponent,
+      CopyAlertsDialogComponent,
+      ReplaceBarcodeDialogComponent
     ],
     imports: [
         StaffCommonModule
     ],
     exports: [
       MarkDamagedDialogComponent,
-      MarkMissingDialogComponent
+      MarkMissingDialogComponent,
+      CopyAlertsDialogComponent,
+      ReplaceBarcodeDialogComponent
     ],
     providers: [
         HoldingsService
index 4b28f70..87b0ff1 100644 (file)
@@ -24,9 +24,10 @@ export class HoldingsService {
 
     // Open the holdings editor UI in a new browser window/tab.
     spawnAddHoldingsUi(
-        recordId: number,                   // Bib record ID
-        addToVols: number[] = [],           // Add copies to existing volumes
-        volumeData: NewVolumeData[] = []) { // Creating new volumes
+        recordId: number,               // Bib record ID
+        addToVols?: number[],           // Add copies to / modify existing vols
+        volumeData?: NewVolumeData[],   // Creating new volumes
+        hideCopies?: boolean) {         // Hide the copy edit pane
 
         const raw: any[] = [];
 
@@ -42,7 +43,7 @@ export class HoldingsService {
             record_id: recordId,
             raw: raw,
             hide_vols : false,
-            hide_copies : false
+            hide_copies : hideCopies ? true : false
         }).then(key => {
             if (!key) {
                 console.error('Could not create holds cache key!');
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/replace-barcode-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/holdings/replace-barcode-dialog.component.html
new file mode 100644 (file)
index 0000000..562681b
--- /dev/null
@@ -0,0 +1,50 @@
+
+
+<eg-string #successMsg
+    text="Successfully Replaced Barcode" i18n-text></eg-string>
+<eg-string #errorMsg 
+    text="Failed To Replace Barcode" i18n-text></eg-string>
+
+<ng-template #dialogContent>
+    <div class="modal-header bg-info">
+      <h4 class="modal-title">
+        <span i18n>Replace Item Barcode</span>
+      </h4>
+      <button type="button" class="close" 
+        i18n-aria-label aria-label="Close" (click)="dismiss('cross_click')">
+        <span aria-hidden="true">&times;</span>
+      </button>
+    </div>
+    <div class="modal-body">
+      <div class="row">
+        <div class="col-lg-4" i18n>Replacing barcode</div>
+        <div class="col-lg-8 font-weight-bold">{{copy.barcode()}}</div>
+      </div>
+      <div class="row pt-2 form-validated">
+        <div class="col-lg-4" i18n>
+          <label for="new-barcode-intput">New Barcode:</label>
+        </div>
+        <div class="col-log-8">
+          <input type="text" class="form-control" [required]="true"
+            [(ngModel)]="newBarcode" (keyup)="barcodeExists=false" 
+            id="new-barcode-input"/>
+        </div>
+      </div>
+      <div class="row d-flex pt-2 justify-content-center" *ngIf="barcodeExists">
+        <div class="alert alert-danger" i18n>
+          Barcode <span class="font-weight-bold">{{newBarcode}}</span> is already in use.
+        </div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <ng-container>
+        <button type="button" class="btn btn-warning" 
+          (click)="dismiss('canceled')" i18n>Cancel</button>
+        <button type="button" class="btn btn-success" 
+          (click)="replaceOneBarcode()" [disabled]="!newBarcode" i18n>
+          Replace Barcode
+        </button>
+      </ng-container>
+    </div>
+  </ng-template>
+  
\ No newline at end of file
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/replace-barcode-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/replace-barcode-dialog.component.ts
new file mode 100644 (file)
index 0000000..552922e
--- /dev/null
@@ -0,0 +1,111 @@
+import {Component, OnInit, Input, ViewChild, Renderer2} from '@angular/core';
+import {IdlObject} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {EventService} from '@eg/core/event.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {AuthService} from '@eg/core/auth.service';
+import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {StringComponent} from '@eg/share/string/string.component';
+
+
+/**
+ * Dialog for marking items missing.
+ */
+
+@Component({
+  selector: 'eg-replace-barcode-dialog',
+  templateUrl: 'replace-barcode-dialog.component.html'
+})
+
+export class ReplaceBarcodeDialogComponent
+    extends DialogComponent implements OnInit {
+
+    @Input() copyIds: number[];
+    ids: number[]; // copy of list so we can pop()
+
+    copy: IdlObject;
+    newBarcode: string;
+    barcodeExists: boolean;
+
+    numSucceeded: number;
+    numFailed: number;
+
+    @ViewChild('successMsg')
+        private successMsg: StringComponent;
+
+    @ViewChild('errorMsg')
+        private errorMsg: StringComponent;
+
+    constructor(
+        private modal: NgbModal, // required for passing to parent
+        private toast: ToastService,
+        private net: NetService,
+        private pcrud: PcrudService,
+        private evt: EventService,
+        private renderer: Renderer2,
+        private auth: AuthService) {
+        super(modal); // required for subclassing
+    }
+
+    ngOnInit() {}
+
+    async open(args: NgbModalOptions): Promise<boolean> {
+        this.ids = [].concat(this.copyIds);
+        this.numSucceeded = 0;
+        this.numFailed = 0;
+
+        await this.getNextCopy();
+        setTimeout(() =>
+            // Give the dialog a chance to render
+            this.renderer.selectRootElement('#new-barcode-input').focus()
+        );
+        return super.open(args);
+    }
+
+    async getNextCopy(): Promise<any> {
+
+        if (this.ids.length === 0) {
+            this.close(this.numSucceeded > 0);
+            return Promise.resolve();
+        }
+
+        this.newBarcode = '';
+
+        const id = this.ids.pop();
+
+        return this.pcrud.retrieve('acp', id)
+        .toPromise().then(c => this.copy = c);
+    }
+
+    async replaceOneBarcode(): Promise<any> {
+        this.barcodeExists = false;
+
+        // First see if the barcode is in use
+        return this.pcrud.search('acp', {deleted: 'f', barcode: this.newBarcode})
+        .toPromise().then(async (existing) => {
+            if (existing) {
+                this.barcodeExists = true;
+                return;
+            }
+
+            this.copy.barcode(this.newBarcode);
+            this.pcrud.update(this.copy).toPromise().then(
+                async (ok) => {
+                    this.numSucceeded++;
+                    this.toast.success(await this.successMsg.current());
+                    this.getNextCopy();
+                },
+                async (err) => {
+                    this.numFailed++;
+                    console.error('Replace barcode failed: ', err);
+                    this.toast.warning(await this.errorMsg.current());
+                }
+            )
+        })
+    }
+}
+
+
+
index af27574..a95ed85 100644 (file)
@@ -335,7 +335,7 @@ export class HoldsGridComponent implements OnInit {
             }
 
             this.markDamagedDialog.copyId = ids.pop();
-            this.markDamagedDialog.open({size: 'lg'}).then(
+            return this.markDamagedDialog.open({size: 'lg'}).then(
                 ok => {
                     if (ok) { rowsModified = true; }
                     return markNext(ids);
@@ -397,3 +397,5 @@ export class HoldsGridComponent implements OnInit {
 }
 
 
+
+