[open-ils-commits] [GIT] Evergreen ILS branch master updated. 8c0482c873f352dc9d56ee7484553a1b55202fd6

Evergreen Git git at git.evergreen-ils.org
Thu Apr 18 15:39:45 EDT 2019


This is an automated email from the git hooks/post-receive script. It was
generated because a ref change was pushed to the repository containing
the project "Evergreen ILS".

The branch, master has been updated
       via  8c0482c873f352dc9d56ee7484553a1b55202fd6 (commit)
       via  b7577f2e00c0d5a720af7100631ca2ecdb91b3ae (commit)
       via  bb744216e59a8fd7a0b7afdca8daf1ecb431eece (commit)
       via  92cc36d7dc10140c17336029a3752d9a62358b9e (commit)
       via  ce5f238a434ef01cb8861c58f930257f70fdecd9 (commit)
      from  a992137f701a8c87a93fd973d019fe529a0f676e (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.

- Log -----------------------------------------------------------------
commit 8c0482c873f352dc9d56ee7484553a1b55202fd6
Author: Dan Wells <dbw2 at calvin.edu>
Date:   Thu Apr 18 15:19:18 2019 -0400

    Stamping upgrade script for holds prefetch setting
    
    Signed-off-by: Dan Wells <dbw2 at calvin.edu>

diff --git a/Open-ILS/src/sql/Pg/002.schema.config.sql b/Open-ILS/src/sql/Pg/002.schema.config.sql
index 06969d1e25..b236d92e96 100644
--- a/Open-ILS/src/sql/Pg/002.schema.config.sql
+++ b/Open-ILS/src/sql/Pg/002.schema.config.sql
@@ -92,7 +92,7 @@ CREATE TRIGGER no_overlapping_deps
     BEFORE INSERT OR UPDATE ON config.db_patch_dependencies
     FOR EACH ROW EXECUTE PROCEDURE evergreen.array_overlap_check ('deprecates');
 
-INSERT INTO config.upgrade_log (version, applied_to) VALUES ('1159', :eg_version); -- cesardv/willis/csharp
+INSERT INTO config.upgrade_log (version, applied_to) VALUES ('1160', :eg_version); -- berick/dbwells
 
 CREATE TABLE config.bib_source (
 	id		SERIAL	PRIMARY KEY,
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.data.catalog-holds-prefetch.sql b/Open-ILS/src/sql/Pg/upgrade/1160.data.catalog-holds-prefetch.sql
similarity index 81%
rename from Open-ILS/src/sql/Pg/upgrade/XXXX.data.catalog-holds-prefetch.sql
rename to Open-ILS/src/sql/Pg/upgrade/1160.data.catalog-holds-prefetch.sql
index 8de3ff210e..74109ef73e 100644
--- a/Open-ILS/src/sql/Pg/upgrade/XXXX.data.catalog-holds-prefetch.sql
+++ b/Open-ILS/src/sql/Pg/upgrade/1160.data.catalog-holds-prefetch.sql
@@ -1,6 +1,6 @@
 BEGIN;
 
---SELECT evergreen.upgrade_deps_block_check('XXXX', :eg_version);
+SELECT evergreen.upgrade_deps_block_check('1160', :eg_version);
 
 INSERT INTO config.workstation_setting_type (name, grp, datatype, label)
 VALUES (

commit b7577f2e00c0d5a720af7100631ca2ecdb91b3ae
Author: Bill Erickson <berickxx at gmail.com>
Date:   Mon Mar 4 11:12:33 2019 -0500

    LP1818288 WS Option to pre-fetch record holds
    
    Adds a workstation setting allowing staff to decide whether to pre-fetch
    all holds on the record detail holds tab, to perform sorting paging in
    the client, or to leave the sorting/paging on the server.
    
    Improves client-side sorting in the grid.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Dan Wells <dbw2 at calvin.edu>

diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar-checkbox.component.ts b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar-checkbox.component.ts
index 8765917ceb..7ee3019477 100644
--- a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar-checkbox.component.ts
+++ b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar-checkbox.component.ts
@@ -12,6 +12,10 @@ export class GridToolbarCheckboxComponent implements OnInit {
     // Note most input fields should match class fields for GridColumn
     @Input() label: string;
 
+    // Set the render time value.
+    // This does NOT fire the onChange handler.
+    @Input() initialValue: boolean;
+
     // This is an input instead of an Output because the handler is
     // passed off to the grid context for maintenance -- events
     // are not fired directly from this component.
@@ -32,6 +36,7 @@ export class GridToolbarCheckboxComponent implements OnInit {
         const cb = new GridToolbarCheckbox();
         cb.label = this.label;
         cb.onChange = this.onChange;
+        cb.isChecked = this.initialValue;
 
         this.grid.context.toolbarCheckboxes.push(cb);
     }
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html
index 52c3ae1099..5dd307f4f9 100644
--- a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html
+++ b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html
@@ -18,6 +18,7 @@
       <ng-container *ngFor="let cb of gridContext.toolbarCheckboxes">
         <label class="form-check-label">
           <input class="form-check-input" type="checkbox"
+            [(ngModel)]="cb.isChecked"
             (click)="cb.onChange.emit($event.target.checked)"/>
             {{cb.label}}
         </label>
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid.ts b/Open-ILS/src/eg2/src/app/share/grid/grid.ts
index f7e681dfcb..7c30438ccc 100644
--- a/Open-ILS/src/eg2/src/app/share/grid/grid.ts
+++ b/Open-ILS/src/eg2/src/app/share/grid/grid.ts
@@ -542,18 +542,34 @@ export class GridContext {
     sortLocalData() {
 
         const sortDefs = this.dataSource.sort.map(sort => {
+            const column = this.columnSet.getColByName(sort.name);
+
             const def = {
                 name: sort.name,
                 dir: sort.dir,
-                col: this.columnSet.getColByName(sort.name)
+                col: column
             };
 
             if (!def.col.comparator) {
-                def.col.comparator = (a, b) => {
-                    if (a < b) { return -1; }
-                    if (a > b) { return 1; }
-                    return 0;
-                };
+                switch (def.col.datatype) {
+                    case 'id':
+                    case 'money':
+                    case 'int':
+                        def.col.comparator = (a, b) => {
+                            a = Number(a);
+                            b = Number(b);
+                            if (a < b) { return -1; }
+                            if (a > b) { return 1; }
+                            return 0;
+                        };
+                        break;
+                    default:
+                        def.col.comparator = (a, b) => {
+                            if (a < b) { return -1; }
+                            if (a > b) { return 1; }
+                            return 0;
+                        };
+                }
             }
 
             return def;
@@ -574,8 +590,6 @@ export class GridContext {
                 const diff = sortDef.col.comparator(valueA, valueB);
                 if (diff === 0) { continue; }
 
-                console.log(valueA, valueB, diff);
-
                 return sortDef.dir === 'DESC' ? -diff : diff;
             }
 
@@ -927,6 +941,7 @@ export class GridToolbarButton {
 
 export class GridToolbarCheckbox {
     label: string;
+    isChecked: boolean;
     onChange: EventEmitter<boolean>;
 }
 
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
index ff3475076d..ad75118182 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
@@ -52,6 +52,7 @@
       <ngb-tab title="View Holds" i18n-title id="holds">
         <ng-template ngbTabContent>
           <eg-holds-grid [recordId]="recordId"
+            preFetchSetting="catalog.record.holds.prefetch"
             persistKey="cat.catalog.wide_holds"
             [defaultSort]="[{name:'request_time',dir:'asc'}]"
             [initialPickupLib]="currentSearchOrg()"></eg-holds-grid>
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.html b/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.html
index 62d269b306..db3d31b25a 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.html
@@ -16,7 +16,7 @@
     </eg-hold-detail>
   </ng-container>
 
-  <ng-container *ngIf="mode == 'list'">
+  <ng-container *ngIf="mode == 'list' && initComplete()">
 
     <div class="row" *ngIf="!hidePickupLibFilter">
       <div class="col-lg-4">
@@ -31,9 +31,14 @@
     </div>
 
     <eg-grid #holdsGrid [dataSource]="gridDataSource" [sortable]="true"
+      [useLocalSort]="enablePreFetch"
       [multiSortable]="true" [persistKey]="persistKey"
       (onRowActivate)="showDetail($event)">
 
+      <eg-grid-toolbar-checkbox (onChange)="preFetchHolds($event)"
+        [initialValue]="enablePreFetch" i18n-label label="Pre-Fetch All Holds">
+      </eg-grid-toolbar-checkbox>
+
       <eg-grid-toolbar-action
         i18n-label label="Show Hold Details" i18n-group group="Hold"
         (onClick)="showDetails($event)"></eg-grid-toolbar-action>
@@ -74,7 +79,7 @@
         i18-group group="Hold" i18n-label label="Cancel Hold"
         (onClick)="showCancelDialog($event)"></eg-grid-toolbar-action>
 
-      <eg-grid-column i18n-label label="Hold ID" path='id' [index]="true">
+      <eg-grid-column i18n-label label="Hold ID" path='id' [index]="true" datatype="id">
       </eg-grid-column>
 
       <ng-template #barcodeTmpl let-hold="row">
@@ -107,12 +112,12 @@
           name='title' [cellTemplate]="titleTmpl"></eg-grid-column>
       <eg-grid-column i18n-label label="Author" path='author'
           [hidden]="true"></eg-grid-column>
-      <eg-grid-column i18n-label label="Potential Items" path='potentials'>
+      <eg-grid-column i18n-label label="Potential Items" path='potentials' datatype="int">
       </eg-grid-column>
       <eg-grid-column i18n-label label="Status" path='status_string'>
       </eg-grid-column>
       <eg-grid-column i18n-label label="Queue Position"
-          path='relative_queue_position' [hidden]="true"></eg-grid-column>
+          path='relative_queue_position' [hidden]="true" datatype="int"></eg-grid-column>
       <eg-grid-column path='usr_id' i18n-label label="User ID" [hidden]="true"></eg-grid-column>
       <eg-grid-column path='usr_usrname' i18n-label label="Username" [hidden]="true"></eg-grid-column>
 
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.ts
index e0e894d871..af27574528 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.ts
@@ -5,6 +5,7 @@ import {NetService} from '@eg/core/net.service';
 import {OrgService} from '@eg/core/org.service';
 import {AuthService} from '@eg/core/auth.service';
 import {Pager} from '@eg/share/util/pager';
+import {ServerStoreService} from '@eg/core/server-store.service';
 import {GridDataSource} from '@eg/share/grid/grid';
 import {GridComponent} from '@eg/share/grid/grid.component';
 import {ProgressDialogComponent} from '@eg/share/dialog/progress.component';
@@ -33,6 +34,12 @@ export class HoldsGridComponent implements OnInit {
     // Grid persist key
     @Input() persistKey: string;
 
+    @Input() preFetchSetting: string;
+        // If set, all holds are fetched on grid load and sorting/paging all
+    // happens in the client.  If false, sorting and paging occur on
+    // the server.
+    enablePreFetch: boolean;
+
     // How to sort when no sort parameters have been applied
     // via grid controls.  This uses the eg-grid sort format:
     // [{name: fname, dir: 'asc'}, {name: fname2, dir: 'desc'}]
@@ -46,7 +53,6 @@ export class HoldsGridComponent implements OnInit {
     detailHold: any;
     editHolds: number[];
     transferTarget: number;
-    copyStatuses: {[id: string]: IdlObject};
 
     @ViewChild('holdsGrid') private holdsGrid: GridComponent;
     @ViewChild('progressDialog')
@@ -104,34 +110,59 @@ export class HoldsGridComponent implements OnInit {
     constructor(
         private net: NetService,
         private org: OrgService,
+        private store: ServerStoreService,
         private auth: AuthService
     ) {
         this.gridDataSource = new GridDataSource();
-        this.copyStatuses = {};
+        this.enablePreFetch = null;
     }
 
     ngOnInit() {
         this.initDone = true;
         this.pickupLib = this.org.get(this.initialPickupLib);
 
-        this.gridDataSource.getRows = (pager: Pager, sort: any[]) => {
+        if (this.preFetchSetting) {
 
-            if (this.defaultSort && sort.length === 0) {
-                // Only use initial sort if sorting has not been modified
-                // by the grid's own sort controls.
-                sort = this.defaultSort;
-            }
+                this.store.getItem(this.preFetchSetting).then(
+                    applied => this.enablePreFetch = Boolean(applied)
+                );
+
+        }
 
-            // sorting not currently supported
+        if (!this.defaultSort) {
+            this.defaultSort = [{name: 'request_time', dir: 'asc'}];
+        }
+
+        this.gridDataSource.getRows = (pager: Pager, sort: any[]) => {
+            sort = sort.length > 0 ? sort : this.defaultSort;
             return this.fetchHolds(pager, sort);
         };
     }
 
+    // Returns true after all data/settings/etc required to render the
+    // grid have been fetched.
+    initComplete(): boolean {
+        return this.enablePreFetch !== null;
+    }
+
     pickupLibChanged(org: IdlObject) {
         this.pickupLib = org;
         this.holdsGrid.reload();
     }
 
+    preFetchHolds(apply: boolean) {
+        this.enablePreFetch = apply;
+
+        if (apply) {
+            setTimeout(() => this.holdsGrid.reload());
+        }
+
+        if (this.preFetchSetting) {
+            // fire and forget
+            this.store.setItem(this.preFetchSetting, apply);
+        }
+    }
+
     applyFilters(): any {
         const filters: any = {
             is_staff_request: true,
@@ -167,11 +198,16 @@ export class HoldsGridComponent implements OnInit {
         const filters = this.applyFilters();
 
         const orderBy: any = [];
-        sort.forEach(obj => {
-            const subObj: any = {};
-            subObj[obj.name] = {dir: obj.dir, nulls: 'last'};
-            orderBy.push(subObj);
-        });
+        if (sort.length > 0) {
+            sort.forEach(obj => {
+                const subObj: any = {};
+                subObj[obj.name] = {dir: obj.dir, nulls: 'last'};
+                orderBy.push(subObj);
+            });
+        }
+
+        const limit = this.enablePreFetch ? null : pager.limit;
+        const offset = this.enablePreFetch ? 0 : pager.offset;
 
         let observer: Observer<any>;
         const observable = new Observable(obs => observer = obs);
@@ -183,10 +219,7 @@ export class HoldsGridComponent implements OnInit {
         this.net.request(
             'open-ils.circ',
             'open-ils.circ.hold.wide_hash.stream',
-            // Pre-fetch all holds consistent with AngJS version
-            this.auth.token(), filters, orderBy
-            // Alternatively, fetch holds in pages.
-            // this.auth.token(), filters, orderBy, pager.limit, pager.offset
+            this.auth.token(), filters, orderBy, limit, offset
         ).subscribe(
             holdData => {
 
diff --git a/Open-ILS/src/sql/Pg/950.data.seed-values.sql b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
index 184d80c411..3c8a257018 100644
--- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql
+++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
@@ -19874,7 +19874,6 @@ INSERT INTO config.org_unit_setting_type
         'bool'
     );
 
-
 INSERT INTO config.usr_activity_type 
     (id, ewhat, ehow, egroup, enabled, transient, label)
 VALUES (
@@ -19897,3 +19896,13 @@ VALUES (
     oils_i18n_gettext(30, 'Generic Verify', 'cuat', 'label')
 );
 
+INSERT INTO config.workstation_setting_type (name, grp, datatype, label)
+VALUES (
+    'catalog.record.holds.prefetch', 'cat', 'bool',
+    oils_i18n_gettext(
+        'catalog.record.holds.prefetch',
+        'Pre-Fetch Record Holds',
+        'cwst', 'label'
+    )
+);
+
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.data.catalog-holds-prefetch.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.catalog-holds-prefetch.sql
new file mode 100644
index 0000000000..8de3ff210e
--- /dev/null
+++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.catalog-holds-prefetch.sql
@@ -0,0 +1,15 @@
+BEGIN;
+
+--SELECT evergreen.upgrade_deps_block_check('XXXX', :eg_version);
+
+INSERT INTO config.workstation_setting_type (name, grp, datatype, label)
+VALUES (
+    'catalog.record.holds.prefetch', 'cat', 'bool',
+    oils_i18n_gettext(
+        'catalog.record.holds.prefetch',
+        'Pre-Fetch Record Holds',
+        'cwst', 'label'
+    )
+);
+
+COMMIT;

commit bb744216e59a8fd7a0b7afdca8daf1ecb431eece
Author: Bill Erickson <berickxx at gmail.com>
Date:   Mon Mar 4 11:11:24 2019 -0500

    LP1818288 Grid checkboxes emit events
    
    Modify the grid toolbar checkbox onClick handler to emit events instead
    of requesting a reference to a function.  This is more consistent with
    other event-handling code.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Dan Wells <dbw2 at calvin.edu>

diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar-checkbox.component.ts b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar-checkbox.component.ts
index f078797525..8765917ceb 100644
--- a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar-checkbox.component.ts
+++ b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar-checkbox.component.ts
@@ -1,4 +1,4 @@
-import {Component, Input, OnInit, Host, TemplateRef} from '@angular/core';
+import {Component, Input, OnInit, Host, Output, EventEmitter} from '@angular/core';
 import {GridToolbarCheckbox} from './grid';
 import {GridComponent} from './grid.component';
 
@@ -15,10 +15,12 @@ export class GridToolbarCheckboxComponent implements OnInit {
     // This is an input instead of an Output because the handler is
     // passed off to the grid context for maintenance -- events
     // are not fired directly from this component.
-    @Input() onChange: (checked: boolean) => void;
+    @Output() onChange: EventEmitter<boolean>;
 
     // get a reference to our container grid.
-    constructor(@Host() private grid: GridComponent) {}
+    constructor(@Host() private grid: GridComponent) {
+        this.onChange = new EventEmitter<boolean>();
+    }
 
     ngOnInit() {
 
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html
index c5afb48796..52c3ae1099 100644
--- a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html
+++ b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html
@@ -5,7 +5,7 @@
 
     <!-- buttons -->
     <div class="btn-grp" *ngIf="gridContext.toolbarButtons.length">
-      <button *ngFor="let btn of gridContext.toolbarButtons" 
+      <button *ngFor="let btn of gridContext.toolbarButtons"
         [disabled]="btn.disabled"
         class="btn btn-outline-dark mr-1" (click)="performButtonAction(btn)">
         {{btn.label}}
@@ -13,12 +13,12 @@
     </div>
 
     <!-- checkboxes -->
-    <div class="form-check form-check-inline" 
+    <div class="form-check form-check-inline"
       *ngIf="gridContext.toolbarCheckboxes.length">
       <ng-container *ngFor="let cb of gridContext.toolbarCheckboxes">
         <label class="form-check-label">
-          <input class="form-check-input" type="checkbox" 
-            (click)="cb.onChange($event.target.checked)"/>
+          <input class="form-check-input" type="checkbox"
+            (click)="cb.onChange.emit($event.target.checked)"/>
             {{cb.label}}
         </label>
       </ng-container>
@@ -31,7 +31,7 @@
   <div ngbDropdown class="mr-1" placement="bottom-right">
     <button ngbDropdownToggle [disabled]="!gridContext.toolbarActions.length"
         class="btn btn-outline-dark no-dropdown-caret">
-      <span title="Actions For Selected Rows" i18n-title 
+      <span title="Actions For Selected Rows" i18n-title
         class="material-icons mat-icon-in-button">playlist_add_check</span>
     </button>
     <div class="dropdown-menu" ngbDropdownMenu>
@@ -52,28 +52,28 @@
     </div>
   </div>
 
-  <button [disabled]="gridContext.pager.isFirstPage()" type="button" 
+  <button [disabled]="gridContext.pager.isFirstPage()" type="button"
     class="btn btn-outline-dark mr-1" (click)="gridContext.pager.toFirst()">
-    <span title="First Page" i18n-title 
+    <span title="First Page" i18n-title
         class="material-icons mat-icon-in-button">first_page</span>
   </button>
-  <button [disabled]="gridContext.pager.isFirstPage()" type="button" 
+  <button [disabled]="gridContext.pager.isFirstPage()" type="button"
     class="btn btn-outline-dark mr-1" (click)="gridContext.pager.decrement()">
-    <span title="Previous Page" i18n-title 
+    <span title="Previous Page" i18n-title
         class="material-icons mat-icon-in-button">keyboard_arrow_left</span>
   </button>
-  <button [disabled]="gridContext.pager.isLastPage()" type="button" 
+  <button [disabled]="gridContext.pager.isLastPage()" type="button"
     class="btn btn-outline-dark mr-1" (click)="gridContext.pager.increment()">
-    <span title="Next Page" i18n-title 
+    <span title="Next Page" i18n-title
         class="material-icons mat-icon-in-button">keyboard_arrow_right</span>
   </button>
 
   <!--
   Hiding jump-to-last since there's no analog in the angularjs grid and
   it has limited value since the size of the data set is often unknown.
-  <button [disabled]="!gridContext.pager.resultCount || gridContext.pager.isLastPage()" 
+  <button [disabled]="!gridContext.pager.resultCount || gridContext.pager.isLastPage()"
     type="button" class="btn btn-outline-dark mr-1" (click)="gridContext.pager.toLast()">
-    <span title="First Page" i18n-title 
+    <span title="First Page" i18n-title
         class="material-icons mat-icon-in-button">last_page</span>
   </button>
   -->
@@ -85,7 +85,7 @@
       </span>
     </button>
     <div class="dropdown-menu" ngbDropdownMenu>
-      <a class="dropdown-item" 
+      <a class="dropdown-item"
         *ngFor="let count of [5, 10, 25, 50, 100]"
         (click)="gridContext.pager.setLimit(count)">
         <span class="ml-2">{{count}}</span>
@@ -93,14 +93,14 @@
     </div>
   </div>
 
-  <button type="button" 
-    class="btn btn-outline-dark mr-1" 
+  <button type="button"
+    class="btn btn-outline-dark mr-1"
     (click)="gridContext.overflowCells=!gridContext.overflowCells">
     <span *ngIf="!gridContext.overflowCells"
-      title="Expand Cells Vertically" i18n-title 
+      title="Expand Cells Vertically" i18n-title
       class="material-icons mat-icon-in-button">expand_more</span>
     <span *ngIf="gridContext.overflowCells"
-      title="Collaps Cells Vertically" i18n-title 
+      title="Collaps Cells Vertically" i18n-title
       class="material-icons mat-icon-in-button">expand_less</span>
   </button>
 
@@ -108,31 +108,31 @@
   </eg-grid-column-config>
   <div ngbDropdown placement="bottom-right">
     <button ngbDropdownToggle class="btn btn-outline-dark no-dropdown-caret">
-      <span title="Show Grid Options" i18n-title 
+      <span title="Show Grid Options" i18n-title
         class="material-icons mat-icon-in-button">settings</span>
     </button>
     <div class="dropdown-menu" ngbDropdownMenu>
-      <a class="dropdown-item label-with-material-icon" 
+      <a class="dropdown-item label-with-material-icon"
         (click)="columnConfDialog.open({size:'lg'})">
         <span class="material-icons">build</span>
         <span class="ml-2" i18n>Manage Columns</span>
       </a>
-      <a class="dropdown-item label-with-material-icon" 
+      <a class="dropdown-item label-with-material-icon"
         (click)="colWidthConfig.isVisible = !colWidthConfig.isVisible">
         <span class="material-icons">compare_arrows</span>
         <span class="ml-2" i18n>Manage Column Widths</span>
       </a>
-      <a class="dropdown-item label-with-material-icon" 
+      <a class="dropdown-item label-with-material-icon"
         (click)="saveGridConfig()">
         <span class="material-icons">save</span>
         <span class="ml-2" i18n>Save Grid Settings</span>
       </a>
-      <a class="dropdown-item label-with-material-icon" 
+      <a class="dropdown-item label-with-material-icon"
         (click)="gridContext.columnSet.reset()">
         <span class="material-icons">restore</span>
         <span class="ml-2" i18n>Reset Columns</span>
       </a>
-      <a class="dropdown-item label-with-material-icon" 
+      <a class="dropdown-item label-with-material-icon"
         (click)="generateCsvExportUrl($event)"
         [download]="csvExportFileName"
         [href]="csvExportUrl">
@@ -146,7 +146,7 @@
 
       <div class="dropdown-divider"></div>
 
-      <a class="dropdown-item label-with-material-icon" 
+      <a class="dropdown-item label-with-material-icon"
         (click)="col.visible=!col.visible" *ngFor="let col of gridContext.columnSet.columns">
         <span *ngIf="col.visible" class="badge badge-success">&#x2713;</span>
         <span *ngIf="!col.visible" class="badge badge-warning">&#x2717;</span>
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid.ts b/Open-ILS/src/eg2/src/app/share/grid/grid.ts
index 92591a76e3..f7e681dfcb 100644
--- a/Open-ILS/src/eg2/src/app/share/grid/grid.ts
+++ b/Open-ILS/src/eg2/src/app/share/grid/grid.ts
@@ -927,7 +927,7 @@ export class GridToolbarButton {
 
 export class GridToolbarCheckbox {
     label: string;
-    onChange: (checked: boolean) => void;
+    onChange: EventEmitter<boolean>;
 }
 
 export class GridDataSource {
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue-items.component.html b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue-items.component.html
index 8bc896f5b6..3185a38697 100644
--- a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue-items.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue-items.component.html
@@ -8,11 +8,11 @@
   </div>
 </div>
 
-<eg-grid #itemsGrid 
+<eg-grid #itemsGrid
   showFields="record,import_error,imported_as,import_time,owning_lib,call_number,barcode"
   persistKey="cat.vandelay.queue.items"
   idlClass="vii" [dataSource]="gridSource">
-  <eg-grid-toolbar-checkbox [onChange]="limitToImportErrors"
+  <eg-grid-toolbar-checkbox (onChange)="limitToImportErrors($event)"
     i18n-label label="Limit to Import Failures"></eg-grid-toolbar-checkbox>
 
 </eg-grid>
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue-items.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue-items.component.ts
index 7340e3d719..cae7c54a8e 100644
--- a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue-items.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue-items.component.ts
@@ -18,7 +18,6 @@ export class QueueItemsComponent {
     queueType: string;
     queueId: number;
     filterImportErrors: boolean;
-    limitToImportErrors: (checked: boolean) => void;
 
     gridSource: GridDataSource;
     @ViewChild('itemsGrid') itemsGrid: GridComponent;
@@ -49,11 +48,12 @@ export class QueueItemsComponent {
                 }
             );
         };
+    }
 
-        this.limitToImportErrors = (checked: boolean) => {
-            this.filterImportErrors = checked;
-            this.itemsGrid.reload();
-        };
+    limitToImportErrors(checked: boolean) {
+        this.filterImportErrors = checked;
+        this.itemsGrid.reload();
     }
+
 }
 
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue.component.html b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue.component.html
index 5584700eed..e4ef84565b 100644
--- a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue.component.html
@@ -4,7 +4,7 @@
 
 <ng-container *ngIf="queueSummary && queueSummary.queue">
 
-  <eg-confirm-dialog 
+  <eg-confirm-dialog
     #confirmDelDlg
     i18n-dialogTitle i18n-dialogBody
     dialogTitle="Confirm Delete"
@@ -51,7 +51,7 @@
           <li class="list-group-item">
             <div class="d-flex">
               <div class="flex-1">
-                <a [routerLink]="" (click)="importSelected()" 
+                <a [routerLink]="" (click)="importSelected()"
                   i18n>Import Selected Records</a>
               </div>
               <div class="flex-1">
@@ -68,7 +68,7 @@
                 </a>
               </div>
               <div class="flex-1">
-                <a [routerLink]="" (click)="exportNonImported()" 
+                <a [routerLink]="" (click)="exportNonImported()"
                   i18n>Export Non-Imported Records</a>
               </div>
             </div>
@@ -117,11 +117,11 @@
   <a *ngIf="queueType=='auth'" href="/eg/staff/cat/catalog/authority/{{row.imported_as}}/marc_edit">
     {{row.imported_as}}
   </a>
-  
+
 </ng-template>
 
 
-<!-- 
+<!--
 Most columns are generated programmatically from queued record attribute
 definitions.  Hide a number of stock record attributes by default
 because there are a lot of them.
@@ -134,23 +134,22 @@ because there are a lot of them.
   hideFields="language,pagination,price,rec_identifier,eg_tcn_source,eg_identifier,item_barcode,zsource">
 
   <eg-grid-toolbar-checkbox i18n-label label="Records With Matches"
-    [onChange]="limitToMatches"></eg-grid-toolbar-checkbox>
+    (onChange)="limitToMatches($event)"></eg-grid-toolbar-checkbox>
 
   <eg-grid-toolbar-checkbox i18n-label label="Non-Imported Records"
-    [onChange]="limitToNonImported"></eg-grid-toolbar-checkbox>
+    (onChange)="limitToNonImported($event)"></eg-grid-toolbar-checkbox>
 
   <eg-grid-toolbar-checkbox i18n-label label="Records with Import Errors"
-    [onChange]="limitToImportErrors"></eg-grid-toolbar-checkbox>
+    (onChange)="limitToImportErrors($event)"></eg-grid-toolbar-checkbox>
 
-  <eg-grid-column name="id" [index]="true" 
-    [hidden]="true"></eg-grid-column>
-  <eg-grid-column i18n-label label="Matches" 
+  <eg-grid-column name="id" [index]="true" [hidden]="true"></eg-grid-column>
+  <eg-grid-column i18n-label label="Matches"
     name="+matches" [cellTemplate]="matchesTmpl"></eg-grid-column>
-  <eg-grid-column name="import_error" i18n-label 
+  <eg-grid-column name="import_error" i18n-label
     label="Import Errors" [cellTemplate]="errorsTmpl"></eg-grid-column>
-  <eg-grid-column name="import_time" i18n-label 
+  <eg-grid-column name="import_time" i18n-label
     label="Import Date" datatype="timestamp"></eg-grid-column>
-  <eg-grid-column name="imported_as" i18n-label 
+  <eg-grid-column name="imported_as" i18n-label
     label="Imported As" [cellTemplate]="importedAsTmpl"></eg-grid-column>
 </eg-grid>
 
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue.component.ts
index fbc62ba783..9055f21cd4 100644
--- a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue.component.ts
@@ -31,10 +31,6 @@ export class QueueComponent implements OnInit, AfterViewInit {
         withErrors: false
     };
 
-    limitToMatches: (checked: boolean) => void;
-    limitToNonImported: (checked: boolean) => void;
-    limitToImportErrors: (checked: boolean) => void;
-
     // keep a local copy for convenience
     attrDefs: IdlObject[];
 
@@ -61,23 +57,24 @@ export class QueueComponent implements OnInit, AfterViewInit {
             return this.loadQueueRecords(pager);
         };
 
-        this.limitToMatches = (checked: boolean) => {
-            this.filters.matches = checked;
-            this.queueGrid.reload();
-        };
+    }
 
-        this.limitToNonImported = (checked: boolean) => {
-            this.filters.nonImported = checked;
-            this.queueGrid.reload();
-        };
+    ngOnInit() {
+    }
 
-        this.limitToImportErrors = (checked: boolean) => {
-            this.filters.withErrors = checked;
-            this.queueGrid.reload();
-        };
+    limitToMatches(checked: boolean) {
+        this.filters.matches = checked;
+        this.queueGrid.reload();
     }
 
-    ngOnInit() {
+    limitToNonImported(checked: boolean) {
+        this.filters.nonImported = checked;
+        this.queueGrid.reload();
+    }
+
+    limitToImportErrors(checked: boolean) {
+        this.filters.withErrors = checked;
+        this.queueGrid.reload();
     }
 
     queuePageOffset(): number {

commit 92cc36d7dc10140c17336029a3752d9a62358b9e
Author: Bill Erickson <berickxx at gmail.com>
Date:   Fri Mar 1 15:42:07 2019 -0500

    LP1818288 Release notes - record holds tab
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Dan Wells <dbw2 at calvin.edu>

diff --git a/docs/RELEASE_NOTES_NEXT/Client/ang-staff-catalog-record-holds.adoc b/docs/RELEASE_NOTES_NEXT/Client/ang-staff-catalog-record-holds.adoc
new file mode 100644
index 0000000000..c18e123de6
--- /dev/null
+++ b/docs/RELEASE_NOTES_NEXT/Client/ang-staff-catalog-record-holds.adoc
@@ -0,0 +1,15 @@
+(Experimental) Angular Staff Catalog Record Holds Tab
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+Adds support for the Holds tab in the record detail view of the Angular
+staff catalog.  Includes grid and hold-related actions.
+
+ * Holds grid
+ * Batch cancel holds
+ * Batch retarget holds
+ * Batch edit holds
+  ** Unified form to modify notify options, dates, etc.
+ * hold detail page (menu and row double-click)
+ * Batch mark items damaged
+ * Batch mark items missing
+ * Show last few circulations
+ * Retrieve patron

commit ce5f238a434ef01cb8861c58f930257f70fdecd9
Author: Bill Erickson <berickxx at gmail.com>
Date:   Sat Feb 16 11:42:14 2019 -0800

    LP1818288 Ang staff catalog record detail holds tab/actions
    
    Adds support for the Holds tab in the record detail view of the Angular
    staff catalog.  Includes grid and hold-related actions.
    
    * Holds grid built from the new wide-holds API.
    * batch cancel holds
    * batch retarget holds
    * batch edit holds
    ** Unified form to modify notify options, dates, etc.
    * hold detail page (menu and row double-click)
    * batch mark items damaged
    * batch mark items missing
    * show last few circulations
    * retrieve patron
    * support for indented menu groups a la angjs grids for grouping the
      hold actions menu.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Dan Wells <dbw2 at calvin.edu>

diff --git a/Open-ILS/src/eg2/src/app/common.module.ts b/Open-ILS/src/eg2/src/app/common.module.ts
index 9361042074..ec06a91b80 100644
--- a/Open-ILS/src/eg2/src/app/common.module.ts
+++ b/Open-ILS/src/eg2/src/app/common.module.ts
@@ -13,7 +13,7 @@ They do not have to be added to the providers list.
 */
 
 // consider moving these to core...
-import {FormatService} from '@eg/core/format.service';
+import {FormatService, FormatValuePipe} from '@eg/core/format.service';
 import {PrintService} from '@eg/share/print/print.service';
 
 // Globally available components
@@ -33,7 +33,8 @@ import {ProgressDialogComponent} from '@eg/share/dialog/progress.component';
     ConfirmDialogComponent,
     PromptDialogComponent,
     ProgressInlineComponent,
-    ProgressDialogComponent
+    ProgressDialogComponent,
+    FormatValuePipe
   ],
   imports: [
     CommonModule,
@@ -52,7 +53,8 @@ import {ProgressDialogComponent} from '@eg/share/dialog/progress.component';
     ConfirmDialogComponent,
     PromptDialogComponent,
     ProgressInlineComponent,
-    ProgressDialogComponent
+    ProgressDialogComponent,
+    FormatValuePipe
   ]
 })
 
diff --git a/Open-ILS/src/eg2/src/app/core/format.service.ts b/Open-ILS/src/eg2/src/app/core/format.service.ts
index e788cd0e40..8108eec91b 100644
--- a/Open-ILS/src/eg2/src/app/core/format.service.ts
+++ b/Open-ILS/src/eg2/src/app/core/format.service.ts
@@ -1,4 +1,4 @@
-import {Injectable} from '@angular/core';
+import {Injectable, Pipe, PipeTransform} from '@angular/core';
 import {DatePipe, CurrencyPipe} from '@angular/common';
 import {IdlService, IdlObject} from '@eg/core/idl.service';
 import {OrgService} from '@eg/core/org.service';
@@ -131,3 +131,14 @@ export class FormatService {
     }
 }
 
+
+// Pipe-ify the above formating logic for use in templates
+ at Pipe({name: 'formatValue'})
+export class FormatValuePipe implements PipeTransform {
+    constructor(private formatter: FormatService) {}
+    // Add other filter params as needed to fill in the FormatParams
+    transform(value: string, datatype: string): string {
+        return this.formatter.transform({value: value, datatype: datatype});
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/share/date-select/date-select.component.html b/Open-ILS/src/eg2/src/app/share/date-select/date-select.component.html
index 575bbde5c8..7e65f7628e 100644
--- a/Open-ILS/src/eg2/src/app/share/date-select/date-select.component.html
+++ b/Open-ILS/src/eg2/src/app/share/date-select/date-select.component.html
@@ -1,7 +1,7 @@
 
 <div class="input-group">
-  <input 
-    class="form-control" 
+  <input
+    class="form-control"
     ngbDatepicker
     #datePicker="ngbDatepicker"
     [attr.id]="domId.length ? domId : null"
@@ -11,11 +11,11 @@
     [disabled]="_disabled"
     [required]="required"
     [(ngModel)]="current"
-    (dateSelect)="onDateSelect($event)">
+    (dateSelect)="onDateSelect($event)"/>
   <div class="input-group-append">
     <button class="btn btn-outline-secondary" [disabled]="_disabled"
       (click)="datePicker.toggle()" type="button">
-      <span title="Select Date" i18n-title                       
+      <span title="Select Date" i18n-title
         class="material-icons mat-icon-in-button">calendar_today</span>
     </button>
   </div>
diff --git a/Open-ILS/src/eg2/src/app/share/dialog/dialog.component.ts b/Open-ILS/src/eg2/src/app/share/dialog/dialog.component.ts
index b7531a2a20..e17fe8dcc7 100644
--- a/Open-ILS/src/eg2/src/app/share/dialog/dialog.component.ts
+++ b/Open-ILS/src/eg2/src/app/share/dialog/dialog.component.ts
@@ -41,7 +41,7 @@ export class DialogComponent implements OnInit {
         this.onOpen$ = new EventEmitter<any>();
     }
 
-    open(options?: NgbModalOptions): Promise<any> {
+    async open(options?: NgbModalOptions): Promise<any> {
 
         if (this.modalRef !== null) {
             console.warn('Dismissing existing dialog');
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar-action.component.ts b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar-action.component.ts
index 0a3337633d..4f8555404f 100644
--- a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar-action.component.ts
+++ b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar-action.component.ts
@@ -1,4 +1,4 @@
-import {Component, Input, OnInit, Host, TemplateRef} from '@angular/core';
+import {Component, Input, Output, OnInit, Host, TemplateRef, EventEmitter} from '@angular/core';
 import {GridToolbarAction} from './grid';
 import {GridComponent} from './grid.component';
 
@@ -11,15 +11,26 @@ export class GridToolbarActionComponent implements OnInit {
 
     // Note most input fields should match class fields for GridColumn
     @Input() label: string;
+
+    // Register to click events
+    @Output() onClick: EventEmitter<any []>;
+
+    // DEPRECATED: Pass a reference to a function that is called on click.
     @Input() action: (rows: any[]) => any;
 
+    // When present, actions will be grouped by the provided label.
+    @Input() group: string;
+
     // Optional: add a function that returns true or false.
     // If true, this action will be disabled; if false
     // (default behavior), the action will be enabled.
     @Input() disableOnRows: (rows: any[]) => boolean;
 
+
     // get a reference to our container grid.
-    constructor(@Host() private grid: GridComponent) {}
+    constructor(@Host() private grid: GridComponent) {
+        this.onClick = new EventEmitter<any []>();
+    }
 
     ngOnInit() {
 
@@ -31,8 +42,9 @@ export class GridToolbarActionComponent implements OnInit {
         const action = new GridToolbarAction();
         action.label = this.label;
         action.action = this.action;
+        action.onClick = this.onClick;
+        action.group = this.group;
         action.disableOnRows = this.disableOnRows;
-
         this.grid.context.toolbarActions.push(action);
     }
 }
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar-button.component.ts b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar-button.component.ts
index 8287483863..62b6dd5f13 100644
--- a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar-button.component.ts
+++ b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar-button.component.ts
@@ -1,4 +1,4 @@
-import {Component, Input, OnInit, Host, TemplateRef} from '@angular/core';
+import {Component, Input, Output, OnInit, Host, TemplateRef, EventEmitter} from '@angular/core';
 import {GridToolbarButton} from './grid';
 import {GridComponent} from './grid.component';
 
@@ -11,8 +11,14 @@ export class GridToolbarButtonComponent implements OnInit {
 
     // Note most input fields should match class fields for GridColumn
     @Input() label: string;
+
+    // Register to click events
+    @Output() onClick: EventEmitter<any>;
+
+    // DEPRECATED: Pass a reference to a function that is called on click.
     @Input() action: () => any;
 
+
     @Input() set disabled(d: boolean) {
         // Support asynchronous disabled values by appling directly
         // to our button object as values arrive.
@@ -25,7 +31,9 @@ export class GridToolbarButtonComponent implements OnInit {
 
     // get a reference to our container grid.
     constructor(@Host() private grid: GridComponent) {
+        this.onClick = new EventEmitter<any>();
         this.button = new GridToolbarButton();
+        this.button.onClick = this.onClick;
     }
 
     ngOnInit() {
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html
index 5eaa81ff62..c5afb48796 100644
--- a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html
+++ b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html
@@ -7,7 +7,7 @@
     <div class="btn-grp" *ngIf="gridContext.toolbarButtons.length">
       <button *ngFor="let btn of gridContext.toolbarButtons" 
         [disabled]="btn.disabled"
-        class="btn btn-outline-dark mr-1" (click)="btn.action()">
+        class="btn btn-outline-dark mr-1" (click)="performButtonAction(btn)">
         {{btn.label}}
       </button>
     </div>
@@ -38,7 +38,16 @@
       <button class="dropdown-item" (click)="performAction(action)"
         *ngFor="let action of gridContext.toolbarActions"
         [disabled]="shouldDisableAction(action)">
-        <span class="ml-2">{{action.label}}</span>
+        <ng-container *ngIf="action.isGroup">
+          <span class="ml-2 font-weight-bold font-italic">{{action.label}}</span>
+        </ng-container>
+        <ng-container *ngIf="action.group && !action.isGroup">
+          <!-- grouped entries are indented -->
+          <span class="ml-4">{{action.label}}</span>
+        </ng-container>
+        <ng-container *ngIf="!action.group && !action.isGroup">
+          <span class="ml-2">{{action.label}}</span>
+        </ng-container>
       </button>
     </div>
   </div>
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.ts b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.ts
index 399a4c7211..82c199c36b 100644
--- a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.ts
+++ b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.ts
@@ -17,13 +17,53 @@ export class GridToolbarComponent implements OnInit {
     @Input() colWidthConfig: GridColumnWidthComponent;
     @Input() gridPrinter: GridPrintComponent;
 
+    renderedGroups: {[group: string]: boolean};
+
     csvExportInProgress: boolean;
     csvExportUrl: SafeUrl;
     csvExportFileName: string;
 
-    constructor(private sanitizer: DomSanitizer) {}
+    constructor(private sanitizer: DomSanitizer) {
+        this.renderedGroups = {};
+    }
+
+    ngOnInit() {
+        this.sortActions();
+    }
+
+    sortActions() {
+        const actions = this.gridContext.toolbarActions;
+
+        const unGrouped = actions.filter(a => !a.group)
+        .sort((a, b) => {
+            return a.label < b.label ? -1 : 1;
+        });
+
+        const grouped = actions.filter(a => Boolean(a.group))
+        .sort((a, b) => {
+            if (a.group === b.group) {
+                return a.label < b.label ? -1 : 1;
+            } else {
+                return a.group < b.group ? -1 : 1;
+            }
+        });
 
-    ngOnInit() {}
+        // Insert group markers for rendering
+        const seen: any = {};
+        const grouped2: any[] = [];
+        grouped.forEach(action => {
+            if (!seen[action.group]) {
+                seen[action.group] = true;
+                const act = new GridToolbarAction();
+                act.label = action.group;
+                act.isGroup = true;
+                grouped2.push(act);
+            }
+            grouped2.push(action);
+        });
+
+        this.gridContext.toolbarActions = unGrouped.concat(grouped2);
+    }
 
     saveGridConfig() {
         // TODO: when server-side settings are supported, this operation
@@ -38,7 +78,15 @@ export class GridToolbarComponent implements OnInit {
     }
 
     performAction(action: GridToolbarAction) {
-        action.action(this.gridContext.getSelectedRows());
+        const rows = this.gridContext.getSelectedRows();
+        action.onClick.emit(rows);
+        if (action.action) { action.action(rows); }
+    }
+
+    performButtonAction(button: GridToolbarButton) {
+        const rows = this.gridContext.getSelectedRows();
+        button.onClick.emit();
+        if (button.action) { button.action(); }
     }
 
     shouldDisableAction(action: GridToolbarAction) {
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid.ts b/Open-ILS/src/eg2/src/app/share/grid/grid.ts
index 3743488c39..92591a76e3 100644
--- a/Open-ILS/src/eg2/src/app/share/grid/grid.ts
+++ b/Open-ILS/src/eg2/src/app/share/grid/grid.ts
@@ -1,7 +1,7 @@
 /**
  * Collection of grid related classses and interfaces.
  */
-import {TemplateRef} from '@angular/core';
+import {TemplateRef, EventEmitter} from '@angular/core';
 import {Observable, Subscription} from 'rxjs';
 import {IdlService, IdlObject} from '@eg/core/idl.service';
 import {OrgService} from '@eg/core/org.service';
@@ -910,14 +910,18 @@ export class GridContext {
 // Actions apply to specific rows
 export class GridToolbarAction {
     label: string;
-    action: (rows: any[]) => any;
+    onClick: EventEmitter<any []>;
+    action: (rows: any[]) => any; // DEPRECATED
+    group: string;
+    isGroup: boolean; // used for group placeholder entries
     disableOnRows: (rows: any[]) => boolean;
 }
 
 // Buttons are global actions
 export class GridToolbarButton {
     label: string;
-    action: () => any;
+    onClick: EventEmitter<any []>;
+    action: () => any; // DEPRECATED
     disabled: boolean;
 }
 
diff --git a/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.html b/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.html
index d4ffd53cc0..cf7b93d80f 100644
--- a/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.html
+++ b/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.html
@@ -14,6 +14,7 @@
     class="form-control"
     [attr.id]="domId.length ? domId : null"
     [placeholder]="placeholder"
+    [disabled]="disabled"
     [(ngModel)]="selected" 
     [ngbTypeahead]="filter"
     [resultTemplate]="displayTemplate"
diff --git a/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts b/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts
index f7dddb2a23..f455c36bf3 100644
--- a/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts
+++ b/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts
@@ -27,10 +27,12 @@ export class OrgSelectComponent implements OnInit {
 
     selected: OrgDisplay;
     hidden: number[] = [];
-    disabled: number[] = [];
     click$ = new Subject<string>();
     startOrg: IdlObject;
 
+    // Disable the entire input
+    @Input() disabled: boolean;
+
     @ViewChild('instance') instance: NgbTypeahead;
 
     // Placeholder text for selector input
@@ -56,8 +58,9 @@ export class OrgSelectComponent implements OnInit {
     }
 
     // List of org unit IDs to disable in the selector
+    _disabledOrgs: number[] = [];
     @Input() set disableOrgs(ids: number[]) {
-        if (ids) { this.disabled = ids; }
+        if (ids) { this._disabledOrgs = ids; }
     }
 
     // Apply an org unit value at load time.
diff --git a/Open-ILS/src/eg2/src/app/share/string/string.component.ts b/Open-ILS/src/eg2/src/app/share/string/string.component.ts
index f092a7ef5f..3322fd07ce 100644
--- a/Open-ILS/src/eg2/src/app/share/string/string.component.ts
+++ b/Open-ILS/src/eg2/src/app/share/string/string.component.ts
@@ -14,7 +14,12 @@ import {StringService} from '@eg/share/string/string.service';
   selector: 'eg-string',
   template: `
     <span style='display:none'>
-    <ng-container *ngTemplateOutlet="template; context:ctx"></ng-container>
+      <ng-container *ngIf="template">
+        <ng-container *ngTemplateOutlet="template; context:ctx"></ng-container>
+      </ng-container>
+      <ng-container *ngIf="!template">
+        <span>{{text}}</span>
+      </ng-container>
     </span>
   `
 })
@@ -64,11 +69,11 @@ export class StringComponent implements OnInit {
     // NOTE: talking to the native DOM element is not so great, but
     // hopefully we can retire the String* code entirely once
     // in-code translations are supported (Ang6?)
-    current(ctx?: any): Promise<string> {
+    async current(ctx?: any): Promise<string> {
         if (ctx) { this.ctx = ctx; }
-        return new Promise(resolve => {
-            setTimeout(() => resolve(this.elm.nativeElement.textContent));
-        });
+        return new Promise<string>(resolve =>
+            setTimeout(() => resolve(this.elm.nativeElement.textContent))
+        );
     }
 }
 
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts
index 2d30199441..b158ac1442 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts
@@ -2,6 +2,8 @@ import {NgModule} from '@angular/core';
 import {StaffCommonModule} from '@eg/staff/common.module';
 import {CatalogCommonModule} from '@eg/share/catalog/catalog-common.module';
 import {CatalogRoutingModule} from './routing.module';
+import {HoldsModule} from '@eg/staff/share/holds/holds.module';
+import {HoldingsModule} from '@eg/staff/share/holdings/holdings.module';
 import {CatalogComponent} from './catalog.component';
 import {SearchFormComponent} from './search-form.component';
 import {ResultsComponent} from './result/results.component';
@@ -13,10 +15,8 @@ import {ResultRecordComponent} from './result/record.component';
 import {StaffCatalogService} from './catalog.service';
 import {RecordPaginationComponent} from './record/pagination.component';
 import {RecordActionsComponent} from './record/actions.component';
-import {HoldingsService} from '@eg/staff/share/holdings.service';
 import {BasketActionsComponent} from './basket-actions.component';
 import {HoldComponent} from './hold/hold.component';
-import {HoldService} from '@eg/staff/share/hold.service';
 import {PartsComponent} from './record/parts.component';
 import {PartMergeDialogComponent} from './record/part-merge-dialog.component';
 import {BrowseComponent} from './browse.component';
@@ -39,17 +39,16 @@ import {BrowseResultsComponent} from './browse/results.component';
     PartsComponent,
     PartMergeDialogComponent,
     BrowseComponent,
-    BrowseResultsComponent
+    BrowseResultsComponent,
   ],
   imports: [
     StaffCommonModule,
     CatalogCommonModule,
-    CatalogRoutingModule
+    CatalogRoutingModule,
+    HoldsModule
   ],
   providers: [
-    StaffCatalogService,
-    HoldingsService,
-    HoldService
+    StaffCatalogService
   ]
 })
 
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.html
index 998aa212fe..1f79387232 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.html
@@ -1,16 +1,16 @@
 <div class="row">
-  <div class="col-lg-3">
+  <div class="col-lg-4">
     <h3 i18n>Place Hold 
       <small *ngIf="user">
        ({{user.family_name()}}, {{user.first_given_name()}})
       </small>
     </h3>
   </div>
-  <div class="col-lg-3 text-right">
-    <button class="btn btn-outline-dark btn-sm"
-      [disabled]="true" i18n>
-      <span class="material-icons mat-icon-in-button align-middle" title="Search for Patron">search</span>
-      <span class="align-middle">Search for Patron</span>
+  <div class="col-lg-2 text-right">
+    <button class="btn btn-outline-dark btn-sm" [disabled]="true">
+      <span class="material-icons mat-icon-in-button align-middle" 
+        i18n-title title="Search for Patron">search</span>
+      <span class="align-middle" i18n>Search for Patron</span>
     </button>
   </div>
 </div>
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.ts
index 3cfbb19ba9..8322b7a145 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.ts
@@ -13,8 +13,8 @@ import {BibRecordService, BibRecordSummary} from '@eg/share/catalog/bib-record.s
 import {CatalogSearchContext, CatalogSearchState} from '@eg/share/catalog/search-context';
 import {CatalogService} from '@eg/share/catalog/catalog.service';
 import {StaffCatalogService} from '../catalog.service';
-import {HoldService, HoldRequest,
-    HoldRequestTarget} from '@eg/staff/share/hold.service';
+import {HoldsService, HoldRequest,
+    HoldRequestTarget} from '@eg/staff/share/holds/holds.service';
 import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
 
 class HoldContext {
@@ -78,7 +78,7 @@ export class HoldComponent implements OnInit {
         private bib: BibRecordService,
         private cat: CatalogService,
         private staffCat: StaffCatalogService,
-        private holds: HoldService,
+        private holds: HoldsService,
         private perm: PermService
     ) {
         this.holdContexts = [];
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.html
index 1a76b282f7..c52609925e 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.html
@@ -19,6 +19,10 @@
     <button class="btn btn-info ml-1" i18n>View in Catalog</button>
   </a>
 
+  <a routerLink="/staff/catalog/hold/T" [queryParams]="{target: recId}">
+    <button class="btn btn-info ml-1" i18n>Place Hold</button>
+  </a>
+
   <button class="btn btn-info ml-1" (click)="addVolumes()" i18n>
     Add Holdings
   </button>
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.ts
index b65bfae3a9..23ed6960cb 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.ts
@@ -7,7 +7,7 @@ import {CatalogUrlService} from '@eg/share/catalog/catalog-url.service';
 import {StaffCatalogService} from '../catalog.service';
 import {StringService} from '@eg/share/string/string.service';
 import {ToastService} from '@eg/share/toast/toast.service';
-import {HoldingsService} from '@eg/staff/share/holdings.service';
+import {HoldingsService} from '@eg/staff/share/holdings/holdings.service';
 
 @Component({
   selector: 'eg-catalog-record-actions',
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
index 0bfc6fbd55..ff3475076d 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
@@ -21,7 +21,7 @@
   <div id='staff-catalog-bib-tabs-container' class='mt-3'>
     <div class="row">
       <div class="col-lg-12 text-right">
-        <button class="btn btn-secondary btn-sm" 
+        <button class="btn btn-secondary btn-sm"
             [disabled]="recordTab == defaultTab"
             (click)="setDefaultTab()" i18n>Set Default View</button>
       </div>
@@ -51,13 +51,10 @@
       </ngb-tab>
       <ngb-tab title="View Holds" i18n-title id="holds">
         <ng-template ngbTabContent>
-          <div class="alert alert-info mt-3" i18n>
-            Holds tab not yet implemented.  See the
-            <a target="_blank"
-              href="/eg/staff/cat/catalog/record/{{recordId}}/holds">
-              AngularJS Holds Tab.
-            </a>
-          </div>
+          <eg-holds-grid [recordId]="recordId"
+            persistKey="cat.catalog.wide_holds"
+            [defaultSort]="[{name:'request_time',dir:'asc'}]"
+            [initialPickupLib]="currentSearchOrg()"></eg-holds-grid>
         </ng-template>
       </ngb-tab>
       <ngb-tab title="Monograph Parts" i18n-title id="monoparts">
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts
index 2a98e36b18..c70b5658be 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts
@@ -103,6 +103,13 @@ export class RecordComponent implements OnInit {
             this.bib.fleshBibUsers([summary.record]);
         });
     }
+
+    currentSearchOrg(): IdlObject {
+        if (this.staffCat && this.staffCat.searchContext) {
+            return this.staffCat.searchContext.searchOrg;
+        }
+        return null;
+    }
 }
 
 
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts
index 5b16f71816..785e69e682 100644
--- a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts
+++ b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts
@@ -97,7 +97,13 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
                 selector = '#first-query-input';
         }
 
-        this.renderer.selectRootElement(selector).focus();
+        try {
+            // TODO: sometime the selector is not available in the DOM
+            // until even later (even with setTimeouts).  Need to fix this.
+            // Note the error is thrown from selectRootElement(), not the
+            // call to .focus() on a null reference.
+            this.renderer.selectRootElement(selector).focus();
+        } catch (E) {}
     }
 
     /**
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.module.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.module.ts
new file mode 100644
index 0000000000..382e9060d7
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.module.ts
@@ -0,0 +1,25 @@
+import {NgModule} from '@angular/core';
+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';
+
+ at NgModule({
+    declarations: [
+      MarkDamagedDialogComponent,
+      MarkMissingDialogComponent
+    ],
+    imports: [
+        StaffCommonModule
+    ],
+    exports: [
+      MarkDamagedDialogComponent,
+      MarkMissingDialogComponent
+    ],
+    providers: [
+        HoldingsService
+    ]
+})
+
+export class HoldingsModule {}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings.service.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.service.ts
similarity index 89%
rename from Open-ILS/src/eg2/src/app/staff/share/holdings.service.ts
rename to Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.service.ts
index cf58409982..4b28f70369 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/holdings.service.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.service.ts
@@ -4,6 +4,8 @@
 import {Injectable, EventEmitter} from '@angular/core';
 import {NetService} from '@eg/core/net.service';
 import {AnonCacheService} from '@eg/share/util/anon-cache.service';
+import {AuthService} from '@eg/core/auth.service';
+import {EventService} from '@eg/core/event.service';
 
 interface NewVolumeData {
     owner: number;
@@ -15,6 +17,8 @@ export class HoldingsService {
 
     constructor(
         private net: NetService,
+        private auth: AuthService,
+        private evt: EventService,
         private anonCache: AnonCacheService
     ) {}
 
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/mark-damaged-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/holdings/mark-damaged-dialog.component.html
new file mode 100644
index 0000000000..ddcf6b1134
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/mark-damaged-dialog.component.html
@@ -0,0 +1,108 @@
+<eg-string #successMsg text="Successfully Marked Item Damaged" i18n-text></eg-string>
+<eg-string #errorMsg text="Failed To Mark Item Damaged" i18n-text></eg-string>
+
+<ng-template #dialogContent>
+  <div class="modal-header bg-info">
+    <h4 class="modal-title">
+      <span i18n>Mark Item Damaged</span>
+    </h4>
+    <button type="button" class="close" 
+      i18n-aria-label aria-label="Close" (click)="dismiss('cross_click')">
+      <span aria-hidden="true">×</span>
+    </button>
+  </div>
+  <div class="modal-body">
+    <div class="row">
+        <div class="col-lg-1">Barcode:</div>
+        <div class="col-lg-11 font-weight-bold">{{copy.barcode()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-lg-1">Title:</div>
+      <div class="col-lg-11">{{bibSummary.display.title}}</div>
+    </div>
+    <div class="row">
+        <div class="col-lg-1">Author:</div>
+        <div class="col-lg-11">{{bibSummary.display.author}}</div>
+    </div>
+    <div class="card mt-3" *ngIf="chargeResponse">
+      <div class="card-header" i18n>
+        Item was previously checked out
+      </div>
+      <div class="card-body">
+        <ul class="list-group list-group-flush">
+          <li class="list-group-item" i18n>
+            Item was last checked out by patron
+            <a href="/eg/staff/circ/patron/{{chargeResponse.circ.usr().id()}}/checkout">
+              {{chargeResponse.circ.usr().family_name()}},
+              {{chargeResponse.circ.usr().first_given_name()}} 
+              ({{chargeResponse.circ.usr().usrname()}})
+            </a>.
+          </li>
+          <li class="list-group-item" i18n>
+            Item was due
+            {{chargeResponse.circ.due_date() | formatValue:'timestamp'}} 
+            and returned 
+            {{chargeResponse.circ.checkin_time() | date:'MM/dd/yy H:mm a'}}.
+          </li>
+          <li class="list-group-item">
+            <span i18n>
+              Calucated fine amount is 
+              <span class="font-weight-bold text-danger">
+                {{chargeResponse.charge | currency}}
+              </span>
+            </span>
+          </li>
+          <ng-container *ngIf="amountChangeRequested">
+            <li class="list-group-item">
+              <div class="row">
+                <div class="col-lg-3" i8n>Billing Type</div>
+                <div class="col-lg-6">
+                  <eg-combobox 
+                    placeholder="Billing Type..." i18n-placeholder
+                    (onChange)="newBtype = $event.id"
+                    [entries]="billingTypes"></eg-combobox>
+                </div>
+              </div>  
+            </li>
+            <li class="list-group-item">
+              <div class="row">
+                <div class="col-lg-3" i8n>Charge Amount</div>
+                <div class="col-lg-6">
+                  <input class="form-control" type="number" step="0.01" min="0"
+                  [(ngModel)]="newCharge"/>
+                </div>
+              </div>  
+            </li>
+            <li class="list-group-item">
+              <div class="row">
+                <div class="col-lg-3" i8n>Note</div>
+                <div class="col-lg-6">
+                  <textarea class="form-control" rows="3"
+                    [(ngModel)]="newNote"></textarea>
+                </div>
+              </div>  
+            </li>
+          </ng-container><!-- amount change requested -->
+        </ul>
+      </div>
+    </div>
+  </div>
+  <div class="modal-footer">
+    <ng-container *ngIf="!chargeResponse">
+      <button type="button" class="btn btn-warning" 
+        (click)="dismiss('canceled')" i18n>Cancel</button>
+      <button type="button" class="btn btn-success" 
+        (click)="markDamaged()" i18n>Mark Damaged</button>
+    </ng-container>
+    <ng-container *ngIf="chargeResponse">
+      <button type="button" class="btn btn-warning" 
+        (click)="dismiss('canceled')" i18n>Cancel</button>
+      <button class="btn btn-info mr-2" 
+        (click)="amountChangeRequested = true" i18n>Change Amount</button>
+      <button class="btn btn-secondary mr-2" 
+        (click)="markDamaged({apply_fines:'noapply'})" i18n>No Charge</button>
+      <button class="btn btn-success mr-2" 
+        (click)="markDamaged({apply_fines:'apply'})" i18n>OK</button>
+    </ng-container>
+  </div>
+</ng-template>
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/mark-damaged-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/mark-damaged-dialog.component.ts
new file mode 100644
index 0000000000..70d7f8fa9a
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/mark-damaged-dialog.component.ts
@@ -0,0 +1,154 @@
+import {Component, OnInit, Input, ViewChild} from '@angular/core';
+import {NetService} from '@eg/core/net.service';
+import {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 {BibRecordService, BibRecordSummary} from '@eg/share/catalog/bib-record.service';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+
+/**
+ * Dialog for marking items damaged and asessing related bills.
+ */
+
+ at Component({
+  selector: 'eg-mark-damaged-dialog',
+  templateUrl: 'mark-damaged-dialog.component.html'
+})
+
+export class MarkDamagedDialogComponent
+    extends DialogComponent implements OnInit {
+
+    @Input() copyId: number;
+    copy: IdlObject;
+    bibSummary: BibRecordSummary;
+    billingTypes: ComboboxEntry[];
+
+    // Overide the API suggested charge amount
+    amountChangeRequested: boolean;
+    newCharge: number;
+    newNote: string;
+    newBtype: number;
+
+    @ViewChild('successMsg') private successMsg: StringComponent;
+    @ViewChild('errorMsg') private errorMsg: StringComponent;
+
+
+    // Charge data returned from the server requesting additional charge info.
+    chargeResponse: any;
+
+    constructor(
+        private modal: NgbModal, // required for passing to parent
+        private toast: ToastService,
+        private net: NetService,
+        private evt: EventService,
+        private pcrud: PcrudService,
+        private org: OrgService,
+        private bib: BibRecordService,
+        private auth: AuthService) {
+        super(modal); // required for subclassing
+        this.billingTypes = [];
+    }
+
+    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.reset();
+
+        if (!this.copyId) {
+            return Promise.reject('copy ID required');
+        }
+
+        await this.getBillingTypes();
+        await this.getData();
+        return super.open(args);
+    }
+
+    // Fetch-cache billing types
+    async getBillingTypes(): Promise<any> {
+        if (this.billingTypes.length > 1) {
+            return Promise.resolve();
+        }
+        return this.pcrud.search('cbt',
+            {owner: this.org.fullPath(this.auth.user().ws_ou(), true)},
+            {}, {atomic: true}
+        ).toPromise().then(bts => {
+            this.billingTypes = bts
+                .sort((a, b) => a.name() < b.name() ? -1 : 1)
+                .map(bt => ({id: bt.id(), label: bt.name()}));
+        });
+    }
+
+    async getData(): Promise<any> {
+        return this.pcrud.retrieve('acp', this.copyId,
+            {flesh: 1, flesh_fields: {acp: ['call_number']}}).toPromise()
+        .then(copy => {
+            this.copy = copy;
+            return this.bib.getBibSummary(
+                copy.call_number().record()).toPromise();
+        }).then(summary => {
+                this.bibSummary = summary;
+        });
+    }
+
+    reset() {
+        this.copy = null;
+        this.bibSummary = null;
+        this.chargeResponse = null;
+        this.newCharge = null;
+        this.newNote = null;
+        this.amountChangeRequested = false;
+    }
+
+    bTypeChange(entry: ComboboxEntry) {
+        this.newBtype = entry.id;
+    }
+
+    markDamaged(args: any) {
+        this.chargeResponse = null;
+
+        if (args && args.apply_fines === 'apply') {
+            args.override_amount = this.newCharge;
+            args.override_btype = this.newBtype;
+            args.override_note = this.newNote;
+        }
+
+        this.net.request(
+            'open-ils.circ', 'open-ils.circ.mark_item_damaged',
+            this.auth.token(), this.copyId, args
+        ).subscribe(
+            result => {
+                console.debug('Mark damaged returned', result);
+
+                if (Number(result) === 1) {
+                    this.successMsg.current().then(msg => this.toast.success(msg));
+                    this.close(true);
+                    return;
+                }
+
+                const evt = this.evt.parse(result);
+
+                if (evt.textcode === 'DAMAGE_CHARGE') {
+                    // More info needed from staff on how to hangle charges.
+                    this.chargeResponse = evt.payload;
+                    this.newCharge = this.chargeResponse.charge;
+                }
+            },
+            err => {
+                this.errorMsg.current().then(m => this.toast.danger(m));
+                console.error(err);
+            }
+        );
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/mark-missing-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/holdings/mark-missing-dialog.component.html
new file mode 100644
index 0000000000..5e85a861a9
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/mark-missing-dialog.component.html
@@ -0,0 +1,44 @@
+
+
+<eg-string #successMsg
+    text="Successfully Marked Item Missing" i18n-text></eg-string>
+<eg-string #errorMsg 
+    text="Failed To Mark Item Missing" i18n-text></eg-string>
+
+<ng-template #dialogContent>
+    <div class="modal-header bg-info">
+      <h4 class="modal-title">
+        <span i18n>Mark Item Missing</span>
+      </h4>
+      <button type="button" class="close" 
+        i18n-aria-label aria-label="Close" (click)="dismiss('cross_click')">
+        <span aria-hidden="true">×</span>
+      </button>
+    </div>
+    <div class="modal-body">
+      <div class="row d-flex justify-content-center">
+          <h5>Mark {{copyIds.length}} Item(s) Missing?</h5>
+      </div>
+      <div class="row" *ngIf="numSucceeded > 0">
+        <div class="col-lg-12" i18n>
+          {{numSucceeded}} Items(s) Successfully Marked Missing
+        </div>
+      </div>
+      <div class="row" *ngIf="numFailed > 0">
+        <div class="col-lg-12">
+          <div class="alert alert-warning">
+            {{numFailed}} Items(s) Failed to be Marked Missing
+          </div>
+        </div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <ng-container *ngIf="!chargeResponse">
+        <button type="button" class="btn btn-warning" 
+          (click)="dismiss('canceled')" i18n>Cancel</button>
+        <button type="button" class="btn btn-success" 
+          (click)="markItemsMissing()" i18n>Mark Missing</button>
+      </ng-container>
+    </div>
+  </ng-template>
+  
\ No newline at end of file
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/mark-missing-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/mark-missing-dialog.component.ts
new file mode 100644
index 0000000000..14e8ceb7a7
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/mark-missing-dialog.component.ts
@@ -0,0 +1,79 @@
+import {Component, OnInit, Input, ViewChild} from '@angular/core';
+import {NetService} from '@eg/core/net.service';
+import {EventService} from '@eg/core/event.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {AuthService} from '@eg/core/auth.service';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
+import {StringComponent} from '@eg/share/string/string.component';
+
+
+/**
+ * Dialog for marking items missing.
+ */
+
+ at Component({
+  selector: 'eg-mark-missing-dialog',
+  templateUrl: 'mark-missing-dialog.component.html'
+})
+
+export class MarkMissingDialogComponent
+    extends DialogComponent implements OnInit {
+
+    @Input() copyIds: number[];
+
+    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 evt: EventService,
+        private auth: AuthService) {
+        super(modal); // required for subclassing
+    }
+
+    ngOnInit() {}
+
+    async markOneItemMissing(ids: number[]): Promise<any> {
+        if (ids.length === 0) {
+            return Promise.resolve();
+        }
+
+        const id = ids.pop();
+
+        return this.net.request(
+            'open-ils.circ',
+            'open-ils.circ.mark_item_missing',
+            this.auth.token(), id
+        ).toPromise().then(async(result) => {
+            if (Number(result) === 1) {
+                this.numSucceeded++;
+                this.toast.success(await this.successMsg.current());
+            } else {
+                this.numFailed++;
+                console.error('Mark missing failed ', this.evt.parse(result));
+                this.toast.warning(await this.errorMsg.current());
+            }
+            return this.markOneItemMissing(ids);
+        });
+    }
+
+    async markItemsMissing(): Promise<any> {
+        this.numSucceeded = 0;
+        this.numFailed = 0;
+        const ids = [].concat(this.copyIds);
+        await this.markOneItemMissing(ids);
+        this.close(this.numSucceeded > 0);
+    }
+}
+
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/cancel-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/holds/cancel-dialog.component.html
new file mode 100644
index 0000000000..d7417fa646
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/holds/cancel-dialog.component.html
@@ -0,0 +1,60 @@
+<eg-string #successMsg
+    text="Successfully Canceled Hold" i18n-text></eg-string>
+<eg-string #errorMsg
+    text="Failed To Cancel Hold" i18n-text></eg-string>
+
+<ng-template #dialogContent>
+    <div class="modal-header bg-info">
+      <h4 class="modal-title">
+        <span i18n>Cancel Hold</span>
+      </h4>
+      <button type="button" class="close"
+        i18n-aria-label aria-label="Close" (click)="dismiss('cross_click')">
+        <span aria-hidden="true">×</span>
+      </button>
+    </div>
+    <div class="modal-body">
+      <div class="row d-flex justify-content-center">
+          <h5>Cancel {{holdIds.length}} Holds?</h5>
+      </div>
+      <div class="row mt-2">
+        <div class="col-lg-4">
+          <label for="cance-reasons" i18n>Cancel Reason</label>
+        </div>
+        <div class="col-lg-8">
+          <eg-combobox id='cancel-reasons' [entries]="cancelReasons"
+            [startId]="5" (onChange)="cancelReason = $event ? $event.id : null">
+          </eg-combobox>
+        </div>
+      </div>
+      <div class="row mt-2">
+        <div class="col-lg-4">
+          <label for="cance-note" i18n>Cancel Note</label>
+        </div>
+        <div class="col-lg-8">
+          <textarea id='cancel-note' class="form-control"
+            [(ngModel)]="cancelNote"></textarea>
+        </div>
+        </div>
+      <div class="row mt-2" *ngIf="numSucceeded > 0">
+        <div class="col-lg-12" i18n>
+          {{numSucceeded}} Hold(s) Successfully Canceled
+        </div>
+        <div class="row" *ngIf="numFailed > 0">
+          <div class="col-lg-12">
+            <div class="alert alert-warning">
+              {{numFailed}} Hold(s) Failed to Cancel.
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <ng-container *ngIf="!chargeResponse">
+        <button type="button" class="btn btn-warning"
+          (click)="dismiss('canceled')" i18n>Cancel</button>
+        <button type="button" class="btn btn-success"
+          (click)="cancelBatch()" i18n>Cancel Hold</button>
+      </ng-container>
+    </div>
+  </ng-template>
\ No newline at end of file
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/cancel-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holds/cancel-dialog.component.ts
new file mode 100644
index 0000000000..98af5143d3
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/holds/cancel-dialog.component.ts
@@ -0,0 +1,98 @@
+import {Component, OnInit, Input, ViewChild} from '@angular/core';
+import {NetService} from '@eg/core/net.service';
+import {EventService} from '@eg/core/event.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {AuthService} from '@eg/core/auth.service';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
+import {StringComponent} from '@eg/share/string/string.component';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+
+/**
+ * Dialog for canceling hold requests.
+ */
+
+ at Component({
+  selector: 'eg-hold-cancel-dialog',
+  templateUrl: 'cancel-dialog.component.html'
+})
+
+export class HoldCancelDialogComponent
+    extends DialogComponent implements OnInit {
+
+    @Input() holdIds: number[];
+    @ViewChild('successMsg') private successMsg: StringComponent;
+    @ViewChild('errorMsg') private errorMsg: StringComponent;
+
+    changesApplied: boolean;
+    numSucceeded: number;
+    numFailed: number;
+    cancelReason: number;
+    cancelReasons: ComboboxEntry[];
+    cancelNote: string;
+
+    constructor(
+        private modal: NgbModal, // required for passing to parent
+        private toast: ToastService,
+        private net: NetService,
+        private evt: EventService,
+        private pcrud: PcrudService,
+        private auth: AuthService) {
+        super(modal); // required for subclassing
+        this.cancelReasons = [];
+    }
+
+    ngOnInit() {
+        // Avoid fetching cancel reasons in ngOnInit becaues that causes
+        // them to load regardless of whether the dialog is ever used.
+    }
+
+    open(args: NgbModalOptions): Promise<boolean> {
+
+        if (this.cancelReasons.length === 0) {
+            this.pcrud.retrieveAll('ahrcc', {}, {atomic: true}).toPromise()
+            .then(reasons => {
+                this.cancelReasons =
+                    reasons.map(r => ({id: r.id(), label: r.label()}));
+            });
+        }
+
+        return super.open(args);
+    }
+
+    async cancelNext(ids: number[]): Promise<any> {
+        if (ids.length === 0) {
+            return Promise.resolve();
+        }
+
+        return this.net.request(
+            'open-ils.circ', 'open-ils.circ.hold.cancel',
+            this.auth.token(), ids.pop(),
+            this.cancelReason, this.cancelNote
+        ).toPromise().then(
+            async(result) => {
+                if (Number(result) === 1) {
+                    this.numSucceeded++;
+                    this.toast.success(await this.successMsg.current());
+                } else {
+                    this.numFailed++;
+                    console.error(this.evt.parse(result));
+                    this.toast.warning(await this.errorMsg.current());
+                }
+                this.cancelNext(ids);
+            }
+        );
+    }
+
+    async cancelBatch(): Promise<any> {
+        this.numSucceeded = 0;
+        this.numFailed = 0;
+        const ids = [].concat(this.holdIds);
+        await this.cancelNext(ids);
+        this.close(this.numSucceeded > 0);
+    }
+}
+
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/detail.component.html b/Open-ILS/src/eg2/src/app/staff/share/holds/detail.component.html
new file mode 100644
index 0000000000..daeeb8957c
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/holds/detail.component.html
@@ -0,0 +1,99 @@
+
+<eg-staff-banner bannerText="Hold Details (#{{hold.id}})" i18n-bannerText>
+</eg-staff-banner>
+
+<div class="row">
+  <div class="col-lg-3">
+    <button (click)="showListView()" class="btn btn-info" i18n>List View</button>
+  </div>
+</div>
+
+<div class="well-table">
+  <div class="well-row">
+    <div class="well-label" i18n>Request Date</div>
+    <div class="well-value">{{hold.request_time | formatValue:'timestamp'}}</div>
+    <div class="well-label" i18n>Capture Date</div>
+    <div class="well-value">{{hold.capture_time | formatValue:'timestamp'}}</div>
+    <div class="well-label" i18n>Available On</div>
+    <div class="well-value">{{hold.shelf_time | formatValue:'timestamp'}}</div>
+  </div>
+  <div class="well-row">
+    <div class="well-label" i18n>hold Type</div>
+    <div class="well-value">
+      {{hold.hold_type}}
+      <!-- TODO: add part data to wide holds 
+      <span *ngIf="hold.hold_type == 'P'"> - {{hold.part_label}}</span>
+      -->
+    </div>
+    <div class="well-label" i18n>Current Item</div>
+    <div class="well-value">
+      <a href="/eg/staff/cat/item/{{hold.cp_id}}">{{hold.cp_barcode}}</a>
+    </div>
+    <div class="well-label" i18n>Call Number</div>
+    <div class="well-value">{{hold.cn_full_label}}</div>
+  </div>
+  <div class="well-row">
+    <div class="well-label" i18n>Pickup Lib</div>
+    <div class="well-value">{{hold.pl_shortname}}</div>
+    <div class="well-label" i18n>Status</div>
+    <div class="well-value">
+      <ng-container [ngSwitch]="hold.hold_status">
+        <div *ngSwitchCase="-1" i18n>Unknown Error</div>
+        <div *ngSwitchCase="1" i18n>Waiting for Item</div>
+        <div *ngSwitchCase="2" i18n>Waiting for Capture</div>
+        <div *ngSwitchCase="3" i18n>In Transit</div>
+        <div *ngSwitchCase="4" i18n>Ready for Pickup</div>
+        <div *ngSwitchCase="5" i18n>Hold Shelf Delay</div>
+        <div *ngSwitchCase="6" i18n>Canceled</div>
+        <div *ngSwitchCase="7" i18n>Suspended</div>
+        <div *ngSwitchCase="8" i18n>Wrong Shelf</div>
+        <div *ngSwitchCase="9" i18n>Fulfilled</div>
+      </ng-container>
+    </div>
+    <div class="well-label" i18n>Behind Desk</div>
+    <div class="well-value">{{hold.behind_desk == '1'}}</div>
+  </div>
+  <div class="well-row">
+    <div class="well-label" i18n>Current Shelf Lib</div>
+    <div class="well-value">{{getOrgName(hold.current_shelf_lib)}}</div>
+    <div class="well-label" i18n>Current Shelving Location</div>
+    <div class="well-value">{{hold.acpl_name}}</div>
+    <div class="well-label" i18n>Force Item Quality</div>
+    <div class="well-value">{{hold.mint_condition == '1'}}</div>
+  </div>
+  <div class="well-row">
+    <div class="well-label" i18n>Email Notify</div>
+    <div class="well-value">{{hold.email_notify == '1'}}</div>
+    <div class="well-label" i18n>Phone Notify</div>
+    <div class="well-value">{{hold.phone_notify}}</div>
+    <div class="well-label" i18n>SMS Notify</div>
+    <div class="well-value">{{hold.sms_notify}}</div>
+  </div>
+  <div class="well-row">
+    <div class="well-label" i18n>Cancel Cause</div>
+    <div class="well-value">{{hold.cancel_cause}}</div><!-- TODO: label -->
+    <div class="well-label" i18n>Cancel Time</div>
+    <div class="well-value">{{hold.cancel_time | formatValue:'timestamp'}}</div>
+    <div class="well-label" i18n>Cancel Note</div>
+    <div class="well-value">{{hold.cancel_note}}</div>
+  </div>
+  <div class="well-row">
+    <div class="well-label" i18n>Patron Name</div>
+    <div class="well-value">
+      <a href="/eg/staff/circ/patron/{{hold.usr_id}}/checkout">
+        {{hold.usr_display_name}}
+      </a>
+    </div>
+    <!-- force consistent width -->
+    <div class="well-label" i18n>Patron Barcode</div>
+    <div class="well-value">
+      <a href="/eg/staff/circ/patron/{{hold.usr_id}}/checkout">
+        {{hold.ucard_barcode}}
+      </a>
+    </div>
+    <!-- for balance -->
+    <div class="well-label" i18n></div>
+    <div class="well-label" i18n></div>
+  </div>
+</div>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/detail.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holds/detail.component.ts
new file mode 100644
index 0000000000..67b3801e0d
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/holds/detail.component.ts
@@ -0,0 +1,67 @@
+import {Component, OnInit, Input, Output, ViewChild, EventEmitter} from '@angular/core';
+import {Observable, Observer, of} from 'rxjs';
+import {NetService} from '@eg/core/net.service';
+import {OrgService} from '@eg/core/org.service';
+import {AuthService} from '@eg/core/auth.service';
+
+/** Hold details read-only view */
+
+ at Component({
+  selector: 'eg-hold-detail',
+  templateUrl: 'detail.component.html'
+})
+export class HoldDetailComponent implements OnInit {
+
+    _holdId: number;
+    @Input() set holdId(id: number) {
+        this._holdId = id;
+        if (this.initDone) {
+            this.fetchHold();
+        }
+    }
+
+    hold: any; // wide hold reference
+    @Input() set wideHold(wh: any) {
+        this.hold = wh;
+    }
+
+    initDone: boolean;
+    @Output() onShowList: EventEmitter<any>;
+
+    constructor(
+        private net: NetService,
+        private org: OrgService,
+        private auth: AuthService,
+    ) {
+        this.onShowList = new EventEmitter<any>();
+    }
+
+    ngOnInit() {
+        this.initDone = true;
+        this.fetchHold();
+    }
+
+    fetchHold() {
+        if (!this._holdId) { return; }
+
+        this.net.request(
+            'open-ils.circ',
+            'open-ils.circ.hold.wide_hash.stream',
+            this.auth.token(), {id: this._holdId}
+        ).subscribe(wideHold => {
+            this.hold = wideHold;
+        });
+    }
+
+    getOrgName(id: number) {
+        if (id) {
+            return this.org.get(id).shortname();
+        }
+    }
+
+    showListView() {
+        this.onShowList.emit();
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.html b/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.html
new file mode 100644
index 0000000000..62d269b306
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.html
@@ -0,0 +1,244 @@
+<!-- hold grid with jump-off points to detail page and other actions -->
+
+<!-- our on-demand dialogs-->
+<eg-progress-dialog #progressDialog></eg-progress-dialog>
+<eg-hold-transfer-dialog #transferDialog></eg-hold-transfer-dialog>
+<eg-mark-damaged-dialog #markDamagedDialog></eg-mark-damaged-dialog>
+<eg-mark-missing-dialog #markMissingDialog></eg-mark-missing-dialog>
+<eg-hold-retarget-dialog #retargetDialog></eg-hold-retarget-dialog>
+<eg-hold-cancel-dialog #cancelDialog></eg-hold-cancel-dialog>
+<eg-hold-manage-dialog #manageDialog></eg-hold-manage-dialog>
+
+<div class='eg-holds w-100 mt-3'>
+
+  <ng-container *ngIf="mode == 'detail'">
+    <eg-hold-detail [wideHold]="detailHold" (onShowList)="mode='list'">
+    </eg-hold-detail>
+  </ng-container>
+
+  <ng-container *ngIf="mode == 'list'">
+
+    <div class="row" *ngIf="!hidePickupLibFilter">
+      <div class="col-lg-4">
+        <div class="input-group">
+          <div class="input-group-prepend">
+            <div class="input-group-text" i18n>Pickup Library</div>
+          </div>
+          <eg-org-select [initialOrg]="pickupLib" (onChange)="pickupLibChanged($event)">
+          </eg-org-select>
+        </div>
+      </div>
+    </div>
+
+    <eg-grid #holdsGrid [dataSource]="gridDataSource" [sortable]="true"
+      [multiSortable]="true" [persistKey]="persistKey"
+      (onRowActivate)="showDetail($event)">
+
+      <eg-grid-toolbar-action
+        i18n-label label="Show Hold Details" i18n-group group="Hold"
+        (onClick)="showDetails($event)"></eg-grid-toolbar-action>
+
+      <eg-grid-toolbar-action
+        i18n-label label="Modify Hold(s)" group="Hold" i18n-group
+        (onClick)="showManageDialog($event)">
+      </eg-grid-toolbar-action>
+
+      <eg-grid-toolbar-action
+        i18n-label label="Show Last Few Circulations" group="Item" i18n-group
+        (onClick)="showRecentCircs($event)"></eg-grid-toolbar-action>
+
+      <eg-grid-toolbar-action
+        i18n-label label="Retrieve Patron" group="Patron" i18n-group
+        (onClick)="showPatron($event)">
+      </eg-grid-toolbar-action>
+
+      <eg-grid-toolbar-action
+        i18n-group group="Hold" i18n-label label="Transfer To Marked Title"
+        (onClick)="showTransferDialog($event)">
+      </eg-grid-toolbar-action>
+
+      <eg-grid-toolbar-action
+        group="Item" i18n-group i18n-label label="Mark Item Damaged"
+        (onClick)="showMarkDamagedDialog($event)"></eg-grid-toolbar-action>
+
+      <eg-grid-toolbar-action
+        i18n-group group="Item" i18n-label label="Mark Item Missing"
+        (onClick)="showMarkMissingDialog($event)">
+      </eg-grid-toolbar-action>
+
+      <eg-grid-toolbar-action
+        i18n-group group="Hold" i18n-label label="Find Another Target"
+        (onClick)="showRetargetDialog($event)"></eg-grid-toolbar-action>
+
+      <eg-grid-toolbar-action
+        i18-group group="Hold" i18n-label label="Cancel Hold"
+        (onClick)="showCancelDialog($event)"></eg-grid-toolbar-action>
+
+      <eg-grid-column i18n-label label="Hold ID" path='id' [index]="true">
+      </eg-grid-column>
+
+      <ng-template #barcodeTmpl let-hold="row">
+        <a href="/eg/staff/cat/item/{{cp_id}}/summary">
+          {{hold.cp_barcode}}
+        </a>
+      </ng-template>
+      <eg-grid-column i18n-label label="Current Item" name='cp_barcode'
+        [cellTemplate]="barcodeTmpl">
+      </eg-grid-column>
+
+      <eg-grid-column i18n-label label="Patron Barcode"
+          path='ucard_barcode' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Patron alias" path="usr_alias"></eg-grid-column>
+      <eg-grid-column i18n-label label="Request Date"
+          path='request_time' datatype="timestamp"></eg-grid-column>
+      <eg-grid-column i18n-label label="Capture Date" path='capture_time'
+          datatype="timestamp"></eg-grid-column>
+      <eg-grid-column i18n-label label="Available Date" path='shelf_time'
+          datatype="timestamp"></eg-grid-column>
+      <eg-grid-column i18n-label label="Hold Type" path='hold_type'></eg-grid-column>
+      <eg-grid-column i18n-label label="Pickup Library" path='pl_shortname'></eg-grid-column>
+
+      <ng-template #titleTmpl let-hold="row">
+        <a class="no-href" routerLink="/staff/catalog/record/{{hold.record_id}}">
+          {{hold.title}}
+        </a>
+      </ng-template>
+      <eg-grid-column i18n-label label="Title" [hidden]="true"
+          name='title' [cellTemplate]="titleTmpl"></eg-grid-column>
+      <eg-grid-column i18n-label label="Author" path='author'
+          [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Potential Items" path='potentials'>
+      </eg-grid-column>
+      <eg-grid-column i18n-label label="Status" path='status_string'>
+      </eg-grid-column>
+      <eg-grid-column i18n-label label="Queue Position"
+          path='relative_queue_position' [hidden]="true"></eg-grid-column>
+      <eg-grid-column path='usr_id' i18n-label label="User ID" [hidden]="true"></eg-grid-column>
+      <eg-grid-column path='usr_usrname' i18n-label label="Username" [hidden]="true"></eg-grid-column>
+
+      <eg-grid-column path='usr_first_given_name' i18n-label label="First Name" [hidden]="true"></eg-grid-column>
+      <eg-grid-column path='usr_family_name' i18n-label label="Last Name" [hidden]="true"></eg-grid-column>
+      <eg-grid-column path='rusr_id' i18n-label label="Requestor ID" [hidden]="true"></eg-grid-column>
+      <eg-grid-column path='rusr_usrname' i18n-label label="Requestor Username" [hidden]="true"></eg-grid-column>
+
+     <eg-grid-column i18n-label label="Item Status" path="cs_name" [hidden]="true"></eg-grid-column>
+
+      <eg-grid-column path='acnp_label' i18n-label label="CN Prefix" [hidden]="true"></eg-grid-column>
+      <eg-grid-column path='acns_label' i18n-label label="CN Suffix" [hidden]="true"></eg-grid-column>
+      <eg-grid-column path='mvr.*' parent-idl-class="mvr" [hidden]="true"></eg-grid-column>
+
+      <eg-grid-column i18n-label label="Fulfillment Date/Time" path='fulfillment_time' datatype="timestamp" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Checkin Time" path='checkin_time' datatype="timestamp" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Return Time" path='return_time' datatype="timestamp" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Last Targeting Date/Time" path='prev_check_time' datatype="timestamp" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Expire Time" path='expire_time' datatype="timestamp" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Hold Cancel Date/Time" path='cancel_time' datatype="timestamp" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Cancelation note" path='cancel_note' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Hold Target" path='target' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Current Copy" path='current_copy' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Fulfilling Staff" path='fulfillment_staff' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Fulfilling Library" path='fulfillment_lib' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Requesting Library" path='request_lib' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Requesting User" path='requestor' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="User" path='usr' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Selection Library" path='selection_ou' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Item Selection Depth" path='selection_depth' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Holdable Formats (for M-type hold)" path='holdable_formats' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Notifications Phone Number" path='phone_notify' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Notifications SMS Number" path='sms_notify' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Notify by Email?" path='email_notify' datatype="bool" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="SMS Carrier" path='sms_carrier' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Currently Frozen" path='frozen' datatype="bool" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Activation Date" path='thaw_date' datatype="timestamp" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Top of Queue" path='cut_in_line' datatype="bool" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Is Mint Condition" path='mint_condition' datatype="bool" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Shelf Expire Time" path='shelf_expire_time' datatype="timestamp" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Current Shelf Library" path='current_shelf_lib' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Behind Desk" path='behind_desk' datatype="bool" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Status" path='hold_status' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Clearable" path='clear_me' datatype="bool" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Is Staff-placed Hold" path='is_staff_hold' datatype="bool" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Cancelation Cause ID" path='cc_id' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Cancelation Cause" path='cc_label' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Pickup Library" path='pl_shortname'></eg-grid-column>
+      <eg-grid-column i18n-label label="Pickup Library Name" path='pl_name' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Pickup Library Email" path='pl_email' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Pickup Library Phone" path='pl_phone' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Pickup Library Opac Visible" path='pl_opac_visible' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Transit ID" path='tr_id' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Transit Send Time" path='tr_source_send_time' datatype="timestamp" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Transit Receive Time" path='tr_dest_recv_time' datatype="timestamp" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Transit Copy" path='tr_target_copy' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Transit Source" path='tr_source' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Transit Destination" path='tr_dest' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Transit Copy Status" path='tr_copy_status' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Transit Hold" path='tr_hold' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Transit Cancel Time" path='tr_cancel_time' datatype="timestamp" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Hold Note Count" path='note_count' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="User Display Name" path='usr_display_name' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Requestor Username" path='rusr_usrname' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Copy ID" path='cp_id' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Copy Number on Volume" path='cp_copy_number' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Fine Level" path='cp_fine_level' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Can Circulate" path='cp_circulate' datatype="bool" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Deposit Amount" path='cp_deposit_amount' datatype="bool" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Is Deposit Required" path='cp_deposit' datatype="bool" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Is Reference" path='cp_ref' datatype="bool" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Is Holdable" path='cp_holdable' datatype="bool" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Price" path='cp_price' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Copy Barcode" path='cp_barcode' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Circulation Modifier" path='cp_circ_modifier' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Circulate as MARC Type" path='cp_circ_as_type' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Precat Dummy Title" path='cp_dummy_title' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Precat Dummy Author" path='cp_dummy_author' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Copy Alert Message (deprecated)" path='cp_alert_message' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Copy OPAC Visible" path='cp_opac_visible' datatype="bool" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Copy Deleted" path='cp_deleted' datatype="bool" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Floating Group" path='cp_floating' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Precat Dummy ISBN" path='cp_dummy_isbn' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Copy Status Change Time" path='cp_status_change_time' datatype="timestamp" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Copy Active Date" path='cp_active_date' datatype="timestamp" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Copy Is Mint Condition" path='cp_mint_condition' datatype="bool" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Cost" path='cp_cost' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Status Is Holdable" path='cs_holdable' datatype="bool" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Status Is OPAC Visible" path='cs_opac_visible' datatype="bool" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Status Is Copy-Active" path='cs_copy_active' datatype="bool" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Status Is Deleted" path='cs_restrict_copy_delete' datatype="bool" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Status Is Available" path='cs_is_available' datatype="bool" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Issuance i18n-label label" path='issuance_label' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Call Number ID" path='cn_id' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="CN i18n-label label" path='cn_label' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="CN i18n-label label Class" path='cn_label_class' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="CN Sort Key" path='cn_label_sortkey' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Part ID" path='p_id' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Part i18n-label label" path='p_label' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Part Sort Key" path='p_label_sortkey' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Part Is Deleted" path='p_deleted' datatype="bool" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="CN Full i18n-label label" path='cn_full_label' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Record ID" path='record_id' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Copy Location ID" path='acpl_id' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Copy Location" path='acpl_name' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Copy Location Holdable" path='acpl_holdable' datatype="bool" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Copy Location Hold-Verify" path='acpl_hold_verify' datatype="bool" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Copy Location OPAC Visible" path='acpl_opac_visible' datatype="bool" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Copy Location Can Circulate" path='acpl_circulate' datatype="bool" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Copy Location Prefix" path='acpl_label_prefix' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Copy Location Suffix" path='acpl_label_suffix' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Copy Location Checkin Alert" path='acpl_checkin_alert' datatype="bool" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Copy Location Is Deleted" path='acpl_deleted' datatype="bool" [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Copy Location URL" path='acpl_url' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Copy Location Order" path='copy_location_order_position' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Default Estimated Wait Time" path='default_estimated_wait' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Minimum Estimated Wait Time" path='min_estimated_wait' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Peer Hold Count" path='other_holds' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Total Wait Time" path='total_wait_time' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Notify Count" path='notification_count' [hidden]="true"></eg-grid-column>
+      <eg-grid-column i18n-label label="Last Notify Time" path='last_notification_time' datatype="timestamp" [hidden]="true"></eg-grid-column>
+
+    </eg-grid>
+
+  </ng-container>
+
+</div>
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.ts
new file mode 100644
index 0000000000..e0e894d871
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.ts
@@ -0,0 +1,366 @@
+import {Component, OnInit, Input, ViewChild} from '@angular/core';
+import {Observable, Observer, of} from 'rxjs';
+import {IdlObject} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {OrgService} from '@eg/core/org.service';
+import {AuthService} from '@eg/core/auth.service';
+import {Pager} from '@eg/share/util/pager';
+import {GridDataSource} from '@eg/share/grid/grid';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {ProgressDialogComponent} from '@eg/share/dialog/progress.component';
+import {MarkDamagedDialogComponent
+    } from '@eg/staff/share/holdings/mark-damaged-dialog.component';
+import {MarkMissingDialogComponent
+    } from '@eg/staff/share/holdings/mark-missing-dialog.component';
+import {HoldRetargetDialogComponent
+    } from '@eg/staff/share/holds/retarget-dialog.component';
+import {HoldTransferDialogComponent} from './transfer-dialog.component';
+import {HoldCancelDialogComponent} from './cancel-dialog.component';
+import {HoldManageDialogComponent} from './manage-dialog.component';
+
+/** Holds grid with access to detail page and other actions */
+
+ at Component({
+  selector: 'eg-holds-grid',
+  templateUrl: 'grid.component.html'
+})
+export class HoldsGridComponent implements OnInit {
+
+    // If either are set/true, the pickup lib selector will display
+    @Input() initialPickupLib: number | IdlObject;
+    @Input() hidePickupLibFilter: boolean;
+
+    // Grid persist key
+    @Input() persistKey: string;
+
+    // How to sort when no sort parameters have been applied
+    // via grid controls.  This uses the eg-grid sort format:
+    // [{name: fname, dir: 'asc'}, {name: fname2, dir: 'desc'}]
+    @Input() defaultSort: any[];
+
+    mode: 'list' | 'detail' | 'manage' = 'list';
+    initDone = false;
+    holdsCount: number;
+    pickupLib: IdlObject;
+    gridDataSource: GridDataSource;
+    detailHold: any;
+    editHolds: number[];
+    transferTarget: number;
+    copyStatuses: {[id: string]: IdlObject};
+
+    @ViewChild('holdsGrid') private holdsGrid: GridComponent;
+    @ViewChild('progressDialog')
+        private progressDialog: ProgressDialogComponent;
+    @ViewChild('transferDialog')
+        private transferDialog: HoldTransferDialogComponent;
+    @ViewChild('markDamagedDialog')
+        private markDamagedDialog: MarkDamagedDialogComponent;
+    @ViewChild('markMissingDialog')
+        private markMissingDialog: MarkMissingDialogComponent;
+    @ViewChild('retargetDialog')
+        private retargetDialog: HoldRetargetDialogComponent;
+    @ViewChild('cancelDialog')
+        private cancelDialog: HoldCancelDialogComponent;
+    @ViewChild('manageDialog')
+        private manageDialog: HoldManageDialogComponent;
+
+    // Bib record ID.
+    _recordId: number;
+    @Input() set recordId(id: number) {
+        this._recordId = id;
+        if (this.initDone) { // reload on update
+            this.holdsGrid.reload();
+        }
+    }
+
+    _userId: number;
+    @Input() set userId(id: number) {
+        this._userId = id;
+        if (this.initDone) {
+            this.holdsGrid.reload();
+        }
+    }
+
+    // Include holds canceled on or after the provided date.
+    // If no value is passed, canceled holds are not displayed.
+    _showCanceledSince: Date;
+    @Input() set showCanceledSince(show: Date) {
+        this._showCanceledSince = show;
+        if (this.initDone) { // reload on update
+            this.holdsGrid.reload();
+        }
+    }
+
+    // Include holds fulfilled on or after hte provided date.
+    // If no value is passed, fulfilled holds are not displayed.
+    _showFulfilledSince: Date;
+    @Input() set showFulfilledSince(show: Date) {
+        this._showFulfilledSince = show;
+        if (this.initDone) { // reload on update
+            this.holdsGrid.reload();
+        }
+    }
+
+    constructor(
+        private net: NetService,
+        private org: OrgService,
+        private auth: AuthService
+    ) {
+        this.gridDataSource = new GridDataSource();
+        this.copyStatuses = {};
+    }
+
+    ngOnInit() {
+        this.initDone = true;
+        this.pickupLib = this.org.get(this.initialPickupLib);
+
+        this.gridDataSource.getRows = (pager: Pager, sort: any[]) => {
+
+            if (this.defaultSort && sort.length === 0) {
+                // Only use initial sort if sorting has not been modified
+                // by the grid's own sort controls.
+                sort = this.defaultSort;
+            }
+
+            // sorting not currently supported
+            return this.fetchHolds(pager, sort);
+        };
+    }
+
+    pickupLibChanged(org: IdlObject) {
+        this.pickupLib = org;
+        this.holdsGrid.reload();
+    }
+
+    applyFilters(): any {
+        const filters: any = {
+            is_staff_request: true,
+            fulfillment_time: this._showFulfilledSince ?
+                this._showFulfilledSince.toISOString() : null,
+            cancel_time: this._showCanceledSince ?
+                this._showCanceledSince.toISOString() : null,
+        };
+
+        if (this.pickupLib) {
+            filters.pickup_lib =
+                this.org.descendants(this.pickupLib, true);
+        }
+
+        if (this._recordId) {
+            filters.record_id = this._recordId;
+        }
+
+        if (this._userId) {
+            filters.usr_id = this._userId;
+        }
+
+        return filters;
+    }
+
+    fetchHolds(pager: Pager, sort: any[]): Observable<any> {
+
+        // We need at least one filter.
+        if (!this._recordId && !this.pickupLib && !this._userId) {
+            return of([]);
+        }
+
+        const filters = this.applyFilters();
+
+        const orderBy: any = [];
+        sort.forEach(obj => {
+            const subObj: any = {};
+            subObj[obj.name] = {dir: obj.dir, nulls: 'last'};
+            orderBy.push(subObj);
+        });
+
+        let observer: Observer<any>;
+        const observable = new Observable(obs => observer = obs);
+
+        this.progressDialog.open();
+        this.progressDialog.update({value: 0, max: 1});
+        let first = true;
+        let loadCount = 0;
+        this.net.request(
+            'open-ils.circ',
+            'open-ils.circ.hold.wide_hash.stream',
+            // Pre-fetch all holds consistent with AngJS version
+            this.auth.token(), filters, orderBy
+            // Alternatively, fetch holds in pages.
+            // this.auth.token(), filters, orderBy, pager.limit, pager.offset
+        ).subscribe(
+            holdData => {
+
+                if (first) { // First response is the hold count.
+                    this.holdsCount = Number(holdData);
+                    first = false;
+
+                } else { // Subsequent responses are hold data blobs
+
+                    this.progressDialog.update(
+                        {value: ++loadCount, max: this.holdsCount});
+
+                    observer.next(holdData);
+                }
+            },
+            err => {
+                this.progressDialog.close();
+                observer.error(err);
+            },
+            ()  => {
+                this.progressDialog.close();
+                observer.complete();
+            }
+        );
+
+        return observable;
+    }
+
+    showDetails(rows: any[]) {
+        this.showDetail(rows[0]);
+    }
+
+    showDetail(row: any) {
+        if (row) {
+            this.mode = 'detail';
+            this.detailHold = row;
+        }
+    }
+
+    showManager(rows: any[]) {
+        if (rows.length) {
+            this.mode = 'manage';
+            this.editHolds = rows.map(r => r.id);
+        }
+    }
+
+    handleModify(rowsModified: boolean) {
+        this.mode = 'list';
+
+        if (rowsModified) {
+            // give the grid a chance to render then ask it to reload
+            setTimeout(() => this.holdsGrid.reload());
+        }
+    }
+
+
+
+    showRecentCircs(rows: any[]) {
+        if (rows.length) {
+            const url =
+                '/eg/staff/cat/item/' + rows[0].cp_id + '/circ_list';
+            window.open(url, '_blank');
+        }
+    }
+
+    showPatron(rows: any[]) {
+        if (rows.length) {
+            const url =
+                '/eg/staff/circ/patron/' + rows[0].usr_id + '/checkout';
+            window.open(url, '_blank');
+        }
+    }
+
+    showManageDialog(rows: any[]) {
+        const holdIds = rows.map(r => r.id).filter(id => Boolean(id));
+        if (holdIds.length > 0) {
+            this.manageDialog.holdIds = holdIds;
+            this.manageDialog.open({size: 'lg'}).then(
+                rowsModified => {
+                    if (rowsModified) {
+                        this.holdsGrid.reload();
+                    }
+                },
+                dismissed => {}
+            );
+        }
+    }
+
+    showTransferDialog(rows: any[]) {
+        const holdIds = rows.map(r => r.id).filter(id => Boolean(id));
+        if (holdIds.length > 0) {
+            this.transferDialog.holdIds = holdIds;
+            this.transferDialog.open({}).then(
+                rowsModified => {
+                    if (rowsModified) {
+                        this.holdsGrid.reload();
+                    }
+                },
+                dismissed => {}
+            );
+        }
+    }
+
+    async showMarkDamagedDialog(rows: any[]) {
+        const copyIds = rows.map(r => r.cp_id).filter(id => Boolean(id));
+        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();
+            this.markDamagedDialog.open({size: 'lg'}).then(
+                ok => {
+                    if (ok) { rowsModified = true; }
+                    return markNext(ids);
+                },
+                dismiss => markNext(ids)
+            );
+        };
+
+        await markNext(copyIds);
+        if (rowsModified) {
+            this.holdsGrid.reload();
+        }
+    }
+
+    showMarkMissingDialog(rows: any[]) {
+        const copyIds = rows.map(r => r.cp_id).filter(id => Boolean(id));
+        if (copyIds.length > 0) {
+            this.markMissingDialog.copyIds = copyIds;
+            this.markMissingDialog.open({}).then(
+                rowsModified => {
+                    if (rowsModified) {
+                        this.holdsGrid.reload();
+                    }
+                },
+                dismissed => {} // avoid console errors
+            );
+        }
+    }
+
+    showRetargetDialog(rows: any[]) {
+        const holdIds = rows.map(r => r.id).filter(id => Boolean(id));
+        if (holdIds.length > 0) {
+            this.retargetDialog.holdIds = holdIds;
+            this.retargetDialog.open({}).then(
+                rowsModified => {
+                    if (rowsModified) {
+                        this.holdsGrid.reload();
+                    }
+                },
+                dismissed => {}
+            );
+        }
+    }
+
+    showCancelDialog(rows: any[]) {
+        const holdIds = rows.map(r => r.id).filter(id => Boolean(id));
+        if (holdIds.length > 0) {
+            this.cancelDialog.holdIds = holdIds;
+            this.cancelDialog.open({}).then(
+                rowsModified => {
+                    if (rowsModified) {
+                        this.holdsGrid.reload();
+                    }
+                },
+                dismissed => {}
+            );
+        }
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/holds.module.ts b/Open-ILS/src/eg2/src/app/staff/share/holds/holds.module.ts
new file mode 100644
index 0000000000..5bcb68aeaf
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/holds/holds.module.ts
@@ -0,0 +1,41 @@
+import {NgModule} from '@angular/core';
+import {StaffCommonModule} from '@eg/staff/common.module';
+import {HoldingsModule} from '@eg/staff/share/holdings/holdings.module';
+import {HoldsService} from './holds.service';
+import {HoldsGridComponent} from './grid.component';
+import {HoldDetailComponent} from './detail.component';
+import {HoldManageComponent} from './manage.component';
+import {HoldRetargetDialogComponent} from './retarget-dialog.component';
+import {HoldTransferDialogComponent} from './transfer-dialog.component';
+import {HoldCancelDialogComponent} from './cancel-dialog.component';
+import {HoldManageDialogComponent} from './manage-dialog.component';
+
+ at NgModule({
+    declarations: [
+        HoldsGridComponent,
+        HoldDetailComponent,
+        HoldManageComponent,
+        HoldRetargetDialogComponent,
+        HoldTransferDialogComponent,
+        HoldCancelDialogComponent,
+        HoldManageDialogComponent
+    ],
+    imports: [
+        StaffCommonModule,
+        HoldingsModule
+    ],
+    exports: [
+        HoldsGridComponent,
+        HoldDetailComponent,
+        HoldManageComponent,
+        HoldRetargetDialogComponent,
+        HoldTransferDialogComponent,
+        HoldCancelDialogComponent,
+        HoldManageDialogComponent
+    ],
+    providers: [
+        HoldsService
+    ]
+})
+
+export class HoldsModule {}
diff --git a/Open-ILS/src/eg2/src/app/staff/share/hold.service.ts b/Open-ILS/src/eg2/src/app/staff/share/holds/holds.service.ts
similarity index 84%
rename from Open-ILS/src/eg2/src/app/staff/share/hold.service.ts
rename to Open-ILS/src/eg2/src/app/staff/share/holds/holds.service.ts
index 00e7374943..784dcec4eb 100644
--- a/Open-ILS/src/eg2/src/app/staff/share/hold.service.ts
+++ b/Open-ILS/src/eg2/src/app/staff/share/holds/holds.service.ts
@@ -1,7 +1,7 @@
 /**
  * Common code for mananging holdings
  */
-import {Injectable, EventEmitter} from '@angular/core';
+import {Injectable} from '@angular/core';
 import {Observable} from 'rxjs';
 import {map, mergeMap} from 'rxjs/operators';
 import {IdlObject} from '@eg/core/idl.service';
@@ -56,13 +56,14 @@ export interface HoldRequestTarget {
     metarecord_filters?: any;
 }
 
+/** Service for performing various hold-related actions */
+
 @Injectable()
-export class HoldService {
+export class HoldsService {
 
     constructor(
         private evt: EventService,
         private net: NetService,
-        private pcrud: PcrudService,
         private auth: AuthService,
         private bib: BibRecordService,
     ) {}
@@ -138,5 +139,31 @@ export class HoldService {
             }));
         }));
     }
+
+    /**
+      * Update a list of holds.
+      * Returns observable of results, one per hold.
+      * Result is either a Number (hold ID) or an EgEvent object.
+      */
+    updateHolds(holds: IdlObject[]): Observable<any> {
+
+        return this.net.request(
+            'open-ils.circ',
+            'open-ils.circ.hold.update.batch',
+            this.auth.token(), holds
+        ).pipe(map(response => {
+
+            if (Number(response) > 0) { return Number(response); }
+
+            if (Array.isArray(response)) { response = response[0]; }
+
+            const evt = this.evt.parse(response);
+
+            console.warn('Hold update returned event', evt);
+            return evt;
+        }));
+    }
 }
 
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/manage-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/holds/manage-dialog.component.html
new file mode 100644
index 0000000000..ac07dd6f35
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/holds/manage-dialog.component.html
@@ -0,0 +1,18 @@
+<ng-template #dialogContent>
+    <div class="modal-header bg-info">
+      <ng-container *ngIf="holdIds.length == 1">
+        <h4 class="modal-title" i18n>Modify Hold (#{{holdIds[0]}})</h4>
+      </ng-container>
+      <ng-container *ngIf="holdIds.length > 1">
+        <h4 class="modal-title">Batch Modify {{holdIds.length}} Holds</h4>
+      </ng-container>
+      <button type="button" class="close"
+        i18n-aria-label aria-label="Close" (click)="dismiss('cross_click')">
+        <span aria-hidden="true">×</span>
+      </button>
+    </div>
+    <div class="modal-body">
+      <eg-hold-manage [holdIds]="holdIds" (onComplete)="onComplete($event)">
+      </eg-hold-manage>
+    </div>
+  </ng-template>
\ No newline at end of file
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/manage-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holds/manage-dialog.component.ts
new file mode 100644
index 0000000000..93375c0eb7
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/holds/manage-dialog.component.ts
@@ -0,0 +1,34 @@
+import {Component, OnInit, Input} from '@angular/core';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
+
+/**
+ * Dialog wrapper for ManageHoldsComponent.
+ */
+
+ at Component({
+  selector: 'eg-hold-manage-dialog',
+  templateUrl: 'manage-dialog.component.html'
+})
+
+export class HoldManageDialogComponent
+    extends DialogComponent implements OnInit {
+
+    @Input() holdIds: number[];
+
+    constructor(
+        private modal: NgbModal) { // required for passing to parent
+        super(modal); // required for subclassing
+    }
+
+    open(args: NgbModalOptions): Promise<boolean> {
+        return super.open(args);
+    }
+
+    onComplete(changesMade: boolean) {
+        this.close(changesMade);
+    }
+}
+
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/manage.component.html b/Open-ILS/src/eg2/src/app/staff/share/holds/manage.component.html
new file mode 100644
index 0000000000..fd9896e2d6
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/holds/manage.component.html
@@ -0,0 +1,270 @@
+
+<form #holdManageForm role="form" *ngIf="hold"
+  class="form-validated common-form striped-odd">
+
+  <div class="form-group row d-flex">
+    <div class="col-lg-2 d-flex">
+      <div class="" *ngIf="isBatch()">
+        <div class="form-check form-check-inline">
+          <input class="form-check-input" type="checkbox"
+            title="Activate Column Editing" i18n-title
+            name="active_pickup_lib" [(ngModel)]="activeFields.pickup_lib"/>
+        </div>
+      </div>
+      <div class="flex-1"><label i18n>Pickup Library:</label></div>
+    </div>
+    <div class="col-lg-4">
+      <!-- TODO: filter orgs as needed -->
+      <eg-org-select [initialOrgId]="hold.pickup_lib()"
+        [disabled]="isBatch() && !activeFields.pickup_lib"
+        (onChange)="pickupLibChanged($event)">
+      </eg-org-select>
+    </div>
+    <div class="col-lg-2 d-flex">
+      <div class="" *ngIf="isBatch()">
+        <div class="form-check form-check-inline">
+          <input class="form-check-input" type="checkbox"
+            title="Activate Column Editing" i18n-title
+            name="active_mint_condition" [(ngModel)]="activeFields.mint_condition"/>
+        </div>
+      </div>
+      <div class="flex-1">
+        <label i18n>Desired Item Condition:</label>
+      </div>
+    </div>
+    <div class="col-lg-4">
+      <div class="form-check form-check-inline">
+        <input class="form-check-input" type="checkbox" id="mint-condition"
+          name="mint" value="mint"
+          [disabled]="isBatch() && !activeFields.mint_condition"
+          [ngModel]="hold.mint_condition() == 't'"
+          (ngModelChange)="hold.mint_condition($event ? 't' : 'f')">
+        <label class="form-check-label" for="mint-condition">
+          Good Condition Only
+        </label>
+      </div>
+    </div>
+  </div>
+
+  <div class="form-group row">
+    <div class="col-lg-2 d-flex">
+      <div class="" *ngIf="isBatch()">
+        <div class="form-check form-check-inline">
+          <input class="form-check-input" type="checkbox"
+            title="Activate Column Editing" i18n-title
+            name="active_frozen" [(ngModel)]="activeFields.frozen"/>
+        </div>
+      </div>
+      <div class="flex-1">
+        <label for="frozen" i18n>Hold is Suspended:</label>
+      </div>
+    </div>
+    <div class="col-lg-4">
+      <div class="form-check form-check-inline">
+        <input class="form-check-input" type="checkbox"
+          id="frozen" name="frozen"
+          [disabled]="isBatch() && !activeFields.frozen"
+          [ngModel]="hold.frozen() == 't'"
+          (ngModelChange)="hold.frozen($event ? 't' : 'f')">
+      </div>
+    </div>
+    <div class="col-lg-2 d-flex">
+      <div class="" *ngIf="isBatch()">
+        <div class="form-check form-check-inline">
+          <input class="form-check-input" type="checkbox"
+            title="Activate Column Editing" i18n-title
+            name="active_cut_in_line" [(ngModel)]="activeFields.cut_in_line"/>
+        </div>
+      </div>
+      <div class="flex-1">
+        <label for="cut_in_line" i18n>Top of Queue:</label>
+      </div>
+    </div>
+    <div class="col-lg-4">
+      <div class="form-check form-check-inline">
+        <input class="form-check-input" type="checkbox"
+          id="cut_in_line" name="cut_in_line"
+          [disabled]="isBatch() && !activeFields.cut_in_line"
+          [ngModel]="hold.cut_in_line() == 't'"
+          (ngModelChange)="hold.cut_in_line($event ? 't' : 'f')">
+      </div>
+    </div>
+  </div>
+
+  <!-- wrap the date mod fields in a border to help
+      differentiate from other fields -->
+  <div class="w-100 border border-primary rounded">
+    <div class="form-group row">
+      <div class="col-lg-2 d-flex">
+        <div class="" *ngIf="isBatch()">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox"
+              title="Activate Column Editing" i18n-title
+              name="active_thaw_date" [(ngModel)]="activeFields.thaw_date"/>
+          </div>
+        </div>
+        <div class="flex-1"><label for="thaw_date" i18n>Activate Date:</label></div>
+      </div>
+      <div class="col-lg-4">
+        <eg-date-select
+          domId="thaw_date"
+          [disabled]="isBatch() && !activeFields.thaw_date"
+          (onChangeAsIso)="hold.thaw_date($event)"
+          [initialIso]="hold.thaw_date()">
+        </eg-date-select>
+      </div>
+      <div class="col-lg-2 d-flex">
+        <div class="" *ngIf="isBatch()">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox"
+              title="Activate Column Editing" i18n-title
+              name="active_request_time" [(ngModel)]="activeFields.request_time"/>
+          </div>
+        </div>
+        <div class="flex-1"><label for="request_time" i18n>Request Date:</label></div>
+      </div>
+      <div class="col-lg-4">
+        <eg-date-select
+          domId="request_time"
+          [disabled]="isBatch() && !activeFields.request_time"
+          (onChangeAsIso)="hold.request_time($event)"
+          [initialIso]="hold.request_time()">
+        </eg-date-select>
+      </div>
+    </div>
+
+    <div class="form-group row">
+      <div class="col-lg-2 d-flex">
+        <div class="" *ngIf="isBatch()">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox"
+              title="Activate Column Editing" i18n-title
+              name="active_expire_time" [(ngModel)]="activeFields.expire_time"/>
+          </div>
+        </div>
+        <div class="flex-1"><label for="expire_time" i18n>Expire Date:</label></div>
+      </div>
+      <div class="col-lg-4">
+        <eg-date-select
+          domId="expire_time"
+          [disabled]="isBatch() && !activeFields.expire_time"
+          (onChangeAsIso)="hold.expire_time($event)"
+          [initialIso]="hold.expire_time()">
+        </eg-date-select>
+      </div>
+      <div class="col-lg-2 d-flex">
+        <div class="" *ngIf="isBatch()">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox"
+              title="Activate Column Editing" i18n-title
+              name="active_shelf_expire_time" [(ngModel)]="activeFields.shelf_expire_time"/>
+          </div>
+        </div>
+        <div class="flex-1"><label for="shelf_expire_time" i18n>Shelf Expire Date:</label></div>
+      </div>
+      <div class="col-lg-4">
+        <eg-date-select
+          domId="shelf_expire_time"
+          [disabled]="isBatch() && !activeFields.shelf_expire_time"
+          (onChangeAsIso)="hold.shelf_expire_time($event)"
+          [initialIso]="hold.shelf_expire_time()">
+        </eg-date-select>
+      </div>
+    </div>
+  </div><!-- modify dates group border -->
+
+  <div class="form-group row">
+    <div class="col-lg-2 d-flex">
+      <div class="" *ngIf="isBatch()">
+        <div class="form-check form-check-inline">
+          <input class="form-check-input" type="checkbox"
+            title="Activate Column Editing" i18n-title
+            name="active_email_notify" [(ngModel)]="activeFields.email_notify"/>
+        </div>
+      </div>
+      <div class="flex-1"><label for="email" i18n>Send Emails:</label></div>
+    </div>
+    <div class="col-lg-4">
+      <div class="form-check form-check-inline">
+        <input class="form-check-input" type="checkbox" id="email"
+          name="email" [ngModel]="hold.email_notify() == 't'"
+          [disabled]="isBatch() && !activeFields.email_notify"
+          (ngModelChange)="hold.email_notify($event ? 't' : 'f')"/>
+      </div>
+    </div>
+    <div class="col-lg-2 d-flex">
+      <div class="" *ngIf="isBatch()">
+        <div class="form-check form-check-inline">
+          <input class="form-check-input" type="checkbox"
+            title="Activate Column Editing" i18n-title
+            name="active_phone_notify" [(ngModel)]="activeFields.phone_notify"/>
+        </div>
+      </div>
+      <div class="flex-1"><label for="phone" i18n>Phone Number:</label></div>
+    </div>
+    <div class="col-lg-4">
+      <input type="text" class="form-control" name="phone" id="phone"
+        placeholder="Phone Number..." i18n-placeholder
+          [disabled]="isBatch() && !activeFields.phone_notify"
+        [ngModel]="hold.phone_notify()"
+        (ngModelChange)="hold.phone_notify($event)"/>
+    </div>
+  </div>
+
+  <ng-container *ngIf="smsEnabled">
+    <div class="form-group row">
+      <div class="col-lg-2 d-flex">
+        <div class="" *ngIf="isBatch()">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox"
+              title="Activate Column Editing" i18n-title
+              name="active_sms_notify" [(ngModel)]="activeFields.sms_notify"/>
+          </div>
+        </div>
+        <div class="flex-1"><label for="sms_notify" i18n>Text/SMS Number:</label></div>
+      </div>
+      <div class="col-lg-4">
+        <input type="text" class="form-control" name="sms_notify" id="sms_notify"
+          placeholder="SMS Number..." i18n-placeholder
+          [disabled]="isBatch() && !activeFields.sms_notify"
+          [ngModel]="hold.sms_notify()"
+          (ngModelChange)="hold.sms_notify($event)"/>
+      </div>
+      <div class="col-lg-2 d-flex">
+        <div class="" *ngIf="isBatch()">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox"
+              title="Activate Column Editing" i18n-title
+              name="active_sms_carrier" [(ngModel)]="activeFields.sms_carrier"/>
+          </div>
+        </div>
+        <div class="flex-1">
+          <label for="sms_carrier" i18n>Text/SMS Number:</label>
+        </div>
+      </div>
+      <div class="col-lg-4">
+        <eg-combobox
+          id="sms_carrier"
+          [disabled]="isBatch() && !activeFields.sms_carrier"
+          (onChange)="hold.sms_carrier($event.id)"
+          [startId]="hold.sms_carrier()"
+          [entries]="smsCarriers"
+          placeholder="SMS Carrier..." i18n-placeholder>
+        </eg-combobox>
+      </div>
+    </div>
+  </ng-container>
+
+
+  <div class="row d-flex justify-content-end">
+    <div>
+      <button type="button" class="btn btn-warning" (click)="exit()" i18n>
+        Cancel
+      </button>
+      <button type="button" class="btn btn-success ml-2" (click)="save()" i18n>
+        Apply
+      </button>
+    </div>
+  </div>
+</form>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/manage.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holds/manage.component.ts
new file mode 100644
index 0000000000..f21e64946d
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/holds/manage.component.ts
@@ -0,0 +1,144 @@
+import {Component, OnInit, Input, Output, ViewChild, EventEmitter} from '@angular/core';
+import {IdlObject, IdlService} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {OrgService} from '@eg/core/org.service';
+import {AuthService} from '@eg/core/auth.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {HoldsService} from './holds.service';
+
+/** Edit holds in single or batch mode. */
+
+ at Component({
+  selector: 'eg-hold-manage',
+  templateUrl: 'manage.component.html'
+})
+export class HoldManageComponent implements OnInit {
+
+    // One holds ID means standard edit mode.
+    // >1 hold IDs means batch edit mode.
+    @Input() holdIds: number[];
+
+    hold: IdlObject;
+    smsEnabled: boolean;
+    smsCarriers: ComboboxEntry[];
+    activeFields: {[key: string]: boolean};
+
+    // Emits true if changes were applied to the hold.
+    @Output() onComplete: EventEmitter<boolean>;
+
+    constructor(
+        private idl: IdlService,
+        private org: OrgService,
+        private pcrud: PcrudService,
+        private holds: HoldsService
+    ) {
+        this.onComplete = new EventEmitter<boolean>();
+        this.smsCarriers = [];
+        this.holdIds = [];
+        this.activeFields = {};
+    }
+
+    ngOnInit() {
+        this.org.settings('sms.enable').then(sets => {
+            this.smsEnabled = sets['sms.enable'];
+            if (!this.smsEnabled) { return; }
+
+            this.pcrud.search('csc', {active: 't'}, {order_by: {csc: 'name'}})
+            .subscribe(carrier => {
+                this.smsCarriers.push({
+                    id: carrier.id(),
+                    label: carrier.name()
+                });
+            });
+        });
+
+        this.fetchHold();
+    }
+
+    fetchHold() {
+        this.hold = null;
+
+        if (this.holdIds.length === 0) {
+            return;
+
+        } else if (this.isBatch()) {
+            // Use a dummy hold to store form values.
+            this.hold = this.idl.create('ahr');
+
+        } else {
+            // Form values are stored in the one hold we're editing.
+            this.pcrud.retrieve('ahr', this.holdIds[0])
+            .subscribe(hold => this.hold = hold);
+        }
+    }
+
+    toFormData() {
+
+    }
+
+    isBatch(): boolean {
+        return this.holdIds.length > 1;
+    }
+
+    pickupLibChanged(org: IdlObject) {
+        if (org) {
+            this.hold.pickup_lib(org.id());
+        }
+    }
+
+    save() {
+        if (this.isBatch()) {
+
+            // Fields with edit-active checkboxes
+            const fields = Object.keys(this.activeFields)
+                .filter(field => this.activeFields[field]);
+
+            const holds: IdlObject[] = [];
+            this.pcrud.search('ahr', {id: this.holdIds})
+            .subscribe(
+                hold => {
+                    // Copy form fields to each hold to update.
+                    fields.forEach(field => hold[field](this.hold[field]()));
+                    holds.push(hold);
+                },
+                err => {},
+                ()  => {
+                    this.saveBatch(holds);
+                }
+            );
+        } else {
+            this.saveBatch([this.hold]);
+        }
+    }
+
+    saveBatch(holds: IdlObject[]) {
+        let successCount = 0;
+        this.holds.updateHolds(holds)
+        .subscribe(
+            res  => {
+                if (Number(res) > 0) {
+                    successCount++;
+                    console.debug('hold update succeeded with ', res);
+                } else {
+                    // TODO: toast?
+                }
+            },
+            err => console.error('hold update failed with ', err),
+            ()  => {
+                if (successCount === holds.length) {
+                    this.onComplete.emit(true);
+                } else {
+                    // TODO: toast?
+                    console.error('Some holds failed to update');
+                }
+            }
+        );
+    }
+
+    exit() {
+        this.onComplete.emit(false);
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/retarget-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/holds/retarget-dialog.component.html
new file mode 100644
index 0000000000..37d349dd80
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/holds/retarget-dialog.component.html
@@ -0,0 +1,41 @@
+<eg-string #successMsg
+    text="Successfully Retargetd Hold" i18n-text></eg-string>
+<eg-string #errorMsg
+    text="Failed To Retarget Hold" i18n-text></eg-string>
+
+<ng-template #dialogContent>
+    <div class="modal-header bg-info">
+      <h4 class="modal-title">
+        <span i18n>Retarget Hold</span>
+      </h4>
+      <button type="button" class="close"
+        i18n-aria-label aria-label="Close" (click)="dismiss('cross_click')">
+        <span aria-hidden="true">×</span>
+      </button>
+    </div>
+    <div class="modal-body">
+      <div class="row d-flex justify-content-center">
+          <h5>Retarget {{holdIds.length}} Holds?</h5>
+      </div>
+      <div class="row" *ngIf="numSucceeded > 0">
+        <div class="col-lg-12" i18n>
+          {{numSucceeded}} Hold(s) Successfully Retargeted
+        </div>
+      </div>
+      <div class="row" *ngIf="numFailed > 0">
+        <div class="col-lg-12">
+          <div class="alert alert-warning">
+            {{numFailed}} Hold(s) Failed to Retarget.
+          </div>
+        </div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <ng-container *ngIf="!chargeResponse">
+        <button type="button" class="btn btn-warning"
+          (click)="dismiss('canceled')" i18n>Cancel</button>
+        <button type="button" class="btn btn-success"
+          (click)="retargetBatch()" i18n>Retarget</button>
+      </ng-container>
+    </div>
+  </ng-template>
\ No newline at end of file
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/retarget-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holds/retarget-dialog.component.ts
new file mode 100644
index 0000000000..feca64d92a
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/holds/retarget-dialog.component.ts
@@ -0,0 +1,80 @@
+import {Component, OnInit, Input, ViewChild} from '@angular/core';
+import {NetService} from '@eg/core/net.service';
+import {EventService} from '@eg/core/event.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {AuthService} from '@eg/core/auth.service';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
+import {StringComponent} from '@eg/share/string/string.component';
+
+
+/**
+ * Dialog for retargeting holds.
+ */
+
+ at Component({
+  selector: 'eg-hold-retarget-dialog',
+  templateUrl: 'retarget-dialog.component.html'
+})
+
+export class HoldRetargetDialogComponent
+    extends DialogComponent implements OnInit {
+
+    @Input() holdIds: number | number[];
+    @ViewChild('successMsg') private successMsg: StringComponent;
+    @ViewChild('errorMsg') private errorMsg: StringComponent;
+
+    changesApplied: boolean;
+    numSucceeded: number;
+    numFailed: number;
+
+    constructor(
+        private modal: NgbModal, // required for passing to parent
+        private toast: ToastService,
+        private net: NetService,
+        private evt: EventService,
+        private auth: AuthService) {
+        super(modal); // required for subclassing
+    }
+
+    ngOnInit() {}
+
+    open(args: NgbModalOptions): Promise<boolean> {
+        this.holdIds = [].concat(this.holdIds); // array-ify ints
+        return super.open(args);
+    }
+
+    async retargetNext(ids: number[]): Promise<any> {
+        if (ids.length === 0) {
+            return Promise.resolve();
+        }
+
+        return this.net.request(
+            'open-ils.circ', 'open-ils.circ.hold.reset',
+            this.auth.token(), ids.pop()
+        ).toPromise().then(
+            async(result) => {
+                if (Number(result) === 1) {
+                    this.numSucceeded++;
+                    this.toast.success(await this.successMsg.current());
+                } else {
+                    this.numFailed++;
+                    console.error(this.evt.parse(result));
+                    this.toast.warning(await this.errorMsg.current());
+                }
+                this.retargetNext(ids);
+            }
+        );
+    }
+
+    async retargetBatch(): Promise<any> {
+        this.numSucceeded = 0;
+        this.numFailed = 0;
+        const ids = [].concat(this.holdIds);
+        await this.retargetNext(ids);
+        this.close(this.numSucceeded > 0);
+    }
+}
+
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/transfer-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/holds/transfer-dialog.component.html
new file mode 100644
index 0000000000..80728caf8b
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/holds/transfer-dialog.component.html
@@ -0,0 +1,43 @@
+<eg-string #successMsg
+    text="Successfully Transfered Hold" i18n-text></eg-string>
+<eg-string #errorMsg
+    text="Failed To Transfer Hold" i18n-text></eg-string>
+<eg-string #targetNeeded
+    text="Transfer Target Required" i18n-text> </eg-string>
+
+<ng-template #dialogContent>
+    <div class="modal-header bg-info">
+      <h4 class="modal-title">
+        <span i18n>Transfer Hold(s) To Marked Target</span>
+      </h4>
+      <button type="button" class="close"
+        i18n-aria-label aria-label="Close" (click)="dismiss('cross_click')">
+        <span aria-hidden="true">×</span>
+      </button>
+    </div>
+    <div class="modal-body">
+      <div class="row d-flex justify-content-center">
+          <h5>Transfer {{holdIds.length}} Holds To Record {{transferTarget}}?</h5>
+      </div>
+      <div class="row" *ngIf="numSucceeded > 0">
+        <div class="col-lg-12" i18n>
+          {{numSucceeded}} Hold(s) Successfully Transferred.
+        </div>
+        <div class="row" *ngIf="numFailed > 0">
+          <div class="col-lg-12">
+            <div class="alert alert-warning">
+              {{numFailed}} Hold(s) Failed to Transfer.
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <ng-container *ngIf="!chargeResponse">
+        <button type="button" class="btn btn-warning"
+          (click)="dismiss('canceled')" i18n>Cancel</button>
+        <button type="button" class="btn btn-success"
+          (click)="transferBatch()" i18n>Transfer</button>
+      </ng-container>
+    </div>
+  </ng-template>
\ No newline at end of file
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/transfer-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holds/transfer-dialog.component.ts
new file mode 100644
index 0000000000..5ce38ea02e
--- /dev/null
+++ b/Open-ILS/src/eg2/src/app/staff/share/holds/transfer-dialog.component.ts
@@ -0,0 +1,87 @@
+import {Component, OnInit, Input, ViewChild} from '@angular/core';
+import {NetService} from '@eg/core/net.service';
+import {StoreService} from '@eg/core/store.service';
+import {EventService} from '@eg/core/event.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {AuthService} from '@eg/core/auth.service';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
+import {StringComponent} from '@eg/share/string/string.component';
+
+
+/**
+ * Dialog for transferring holds.
+ */
+
+ at Component({
+  selector: 'eg-hold-transfer-dialog',
+  templateUrl: 'transfer-dialog.component.html'
+})
+
+export class HoldTransferDialogComponent
+    extends DialogComponent implements OnInit {
+
+    @Input() holdIds: number | number[];
+
+    @ViewChild('successMsg') private successMsg: StringComponent;
+    @ViewChild('errorMsg') private errorMsg: StringComponent;
+    @ViewChild('targetNeeded') private targetNeeded: StringComponent;
+
+    transferTarget: number;
+    changesApplied: boolean;
+    numSucceeded: number;
+    numFailed: number;
+
+    constructor(
+        private modal: NgbModal, // required for passing to parent
+        private toast: ToastService,
+        private store: StoreService,
+        private net: NetService,
+        private evt: EventService,
+        private auth: AuthService) {
+        super(modal); // required for subclassing
+    }
+
+    ngOnInit() {}
+
+    async open(args: NgbModalOptions): Promise<boolean> {
+        this.holdIds = [].concat(this.holdIds); // array-ify ints
+
+        this.transferTarget =
+            this.store.getLocalItem('eg.circ.hold.title_transfer_target');
+
+        if (!this.transferTarget) {
+            this.toast.warning(await this.targetNeeded.current());
+            return Promise.reject('Transfer Target Required');
+        }
+
+        return super.open(args);
+    }
+
+    async transferHolds(): Promise<any> {
+        return this.net.request(
+            'open-ils.circ',
+            'open-ils.circ.hold.change_title.specific_holds',
+            this.auth.token(), this.transferTarget, this.holdIds
+        ).toPromise().then(async(result) => {
+            if (Number(result) === 1) {
+                this.numSucceeded++;
+                this.toast.success(await this.successMsg.current());
+            } else {
+                this.numFailed++;
+                console.error('Retarget Failed', this.evt.parse(result));
+                this.toast.warning(await this.errorMsg.current());
+            }
+        });
+    }
+
+    async transferBatch(): Promise<any> {
+        this.numSucceeded = 0;
+        this.numFailed = 0;
+        await this.transferHolds();
+        this.close(this.numSucceeded > 0);
+    }
+}
+
+
+
diff --git a/Open-ILS/src/eg2/src/styles.css b/Open-ILS/src/eg2/src/styles.css
index cf10855253..4ca3abab7d 100644
--- a/Open-ILS/src/eg2/src/styles.css
+++ b/Open-ILS/src/eg2/src/styles.css
@@ -48,6 +48,33 @@ h5 {font-size: .95rem}
 .flex-4 {flex: 4}
 .flex-5 {flex: 5}
 
+/** BS deprecated the well, but it's replacement is not quite the same.
+ * Define our own version and expand it to a full "table".
+ * */
+.well-row {
+  display: flex;
+}
+.well-table .well-label {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  margin: 4px;
+  padding: 4px;
+  min-height: 40px;
+}
+
+.well-table .well-value {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  background-color: #f5f5f5;
+  border-radius: 5px;
+  box-shadow: inset 0 1px 1px rgba(0,0,0,.05);
+  padding: 4px;
+  margin: 4px;
+  min-height: 40px;
+}
+
 
 /* usefuf for mat-icon buttons without any background or borders */
 .material-icon-button {

-----------------------------------------------------------------------

Summary of changes:
 Open-ILS/src/eg2/src/app/common.module.ts          |   8 +-
 Open-ILS/src/eg2/src/app/core/format.service.ts    |  13 +-
 .../share/date-select/date-select.component.html   |   8 +-
 .../eg2/src/app/share/dialog/dialog.component.ts   |   2 +-
 .../share/grid/grid-toolbar-action.component.ts    |  18 +-
 .../share/grid/grid-toolbar-button.component.ts    |  10 +-
 .../share/grid/grid-toolbar-checkbox.component.ts  |  13 +-
 .../src/app/share/grid/grid-toolbar.component.html |  64 ++--
 .../src/app/share/grid/grid-toolbar.component.ts   |  54 ++-
 Open-ILS/src/eg2/src/app/share/grid/grid.ts        |  43 ++-
 .../app/share/org-select/org-select.component.html |   1 +
 .../app/share/org-select/org-select.component.ts   |   7 +-
 .../eg2/src/app/share/string/string.component.ts   |  15 +-
 .../staff/cat/vandelay/queue-items.component.html  |   4 +-
 .../staff/cat/vandelay/queue-items.component.ts    |  10 +-
 .../app/staff/cat/vandelay/queue.component.html    |  27 +-
 .../src/app/staff/cat/vandelay/queue.component.ts  |  31 +-
 .../eg2/src/app/staff/catalog/catalog.module.ts    |  13 +-
 .../src/app/staff/catalog/hold/hold.component.html |  12 +-
 .../src/app/staff/catalog/hold/hold.component.ts   |   6 +-
 .../staff/catalog/record/actions.component.html    |   4 +
 .../app/staff/catalog/record/actions.component.ts  |   2 +-
 .../app/staff/catalog/record/record.component.html |  14 +-
 .../app/staff/catalog/record/record.component.ts   |   7 +
 .../src/app/staff/catalog/search-form.component.ts |   8 +-
 .../app/staff/share/holdings/holdings.module.ts    |  25 ++
 .../staff/share/{ => holdings}/holdings.service.ts |   4 +
 .../holdings/mark-damaged-dialog.component.html    | 108 ++++++
 .../holdings/mark-damaged-dialog.component.ts      | 154 ++++++++
 .../holdings/mark-missing-dialog.component.html    |  44 +++
 .../holdings/mark-missing-dialog.component.ts      |  79 ++++
 .../staff/share/holds/cancel-dialog.component.html |  60 ++++
 .../staff/share/holds/cancel-dialog.component.ts   |  98 +++++
 .../app/staff/share/holds/detail.component.html    |  99 +++++
 .../src/app/staff/share/holds/detail.component.ts  |  67 ++++
 .../src/app/staff/share/holds/grid.component.html  | 249 +++++++++++++
 .../src/app/staff/share/holds/grid.component.ts    | 399 +++++++++++++++++++++
 .../eg2/src/app/staff/share/holds/holds.module.ts  |  41 +++
 .../{hold.service.ts => holds/holds.service.ts}    |  33 +-
 .../staff/share/holds/manage-dialog.component.html |  18 +
 .../staff/share/holds/manage-dialog.component.ts   |  34 ++
 .../app/staff/share/holds/manage.component.html    | 270 ++++++++++++++
 .../src/app/staff/share/holds/manage.component.ts  | 144 ++++++++
 .../share/holds/retarget-dialog.component.html     |  41 +++
 .../staff/share/holds/retarget-dialog.component.ts |  80 +++++
 .../share/holds/transfer-dialog.component.html     |  43 +++
 .../staff/share/holds/transfer-dialog.component.ts |  87 +++++
 Open-ILS/src/eg2/src/styles.css                    |  27 ++
 Open-ILS/src/sql/Pg/002.schema.config.sql          |   2 +-
 Open-ILS/src/sql/Pg/950.data.seed-values.sql       |  11 +-
 .../upgrade/1160.data.catalog-holds-prefetch.sql   |  15 +
 .../Client/ang-staff-catalog-record-holds.adoc     |  15 +
 52 files changed, 2507 insertions(+), 134 deletions(-)
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.module.ts
 rename Open-ILS/src/eg2/src/app/staff/share/{ => holdings}/holdings.service.ts (89%)
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holdings/mark-damaged-dialog.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holdings/mark-damaged-dialog.component.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holdings/mark-missing-dialog.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holdings/mark-missing-dialog.component.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holds/cancel-dialog.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holds/cancel-dialog.component.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holds/detail.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holds/detail.component.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holds/holds.module.ts
 rename Open-ILS/src/eg2/src/app/staff/share/{hold.service.ts => holds/holds.service.ts} (84%)
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holds/manage-dialog.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holds/manage-dialog.component.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holds/manage.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holds/manage.component.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holds/retarget-dialog.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holds/retarget-dialog.component.ts
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holds/transfer-dialog.component.html
 create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holds/transfer-dialog.component.ts
 create mode 100644 Open-ILS/src/sql/Pg/upgrade/1160.data.catalog-holds-prefetch.sql
 create mode 100644 docs/RELEASE_NOTES_NEXT/Client/ang-staff-catalog-record-holds.adoc


hooks/post-receive
-- 
Evergreen ILS


More information about the open-ils-commits mailing list