LP1818288 Ang staff catalog record detail holds tab/actions
authorBill Erickson <berickxx@gmail.com>
Sat, 16 Feb 2019 19:42:14 +0000 (11:42 -0800)
committerDan Wells <dbw2@calvin.edu>
Thu, 18 Apr 2019 19:17:17 +0000 (15:17 -0400)
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@gmail.com>
Signed-off-by: Dan Wells <dbw2@calvin.edu>
45 files changed:
Open-ILS/src/eg2/src/app/common.module.ts
Open-ILS/src/eg2/src/app/core/format.service.ts
Open-ILS/src/eg2/src/app/share/date-select/date-select.component.html
Open-ILS/src/eg2/src/app/share/dialog/dialog.component.ts
Open-ILS/src/eg2/src/app/share/grid/grid-toolbar-action.component.ts
Open-ILS/src/eg2/src/app/share/grid/grid-toolbar-button.component.ts
Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html
Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.ts
Open-ILS/src/eg2/src/app/share/grid/grid.ts
Open-ILS/src/eg2/src/app/share/org-select/org-select.component.html
Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts
Open-ILS/src/eg2/src/app/share/string/string.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts
Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.html
Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.html
Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts
Open-ILS/src/eg2/src/app/staff/share/hold.service.ts [deleted file]
Open-ILS/src/eg2/src/app/staff/share/holdings.service.ts [deleted file]
Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holdings/mark-damaged-dialog.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holdings/mark-damaged-dialog.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holdings/mark-missing-dialog.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holdings/mark-missing-dialog.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holds/cancel-dialog.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holds/cancel-dialog.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holds/detail.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holds/detail.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holds/holds.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holds/holds.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holds/manage-dialog.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holds/manage-dialog.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holds/manage.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holds/manage.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holds/retarget-dialog.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holds/retarget-dialog.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holds/transfer-dialog.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holds/transfer-dialog.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/styles.css

index 9361042..ec06a91 100644 (file)
@@ -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
   ]
 })
 
index e788cd0..8108eec 100644 (file)
@@ -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
+@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});
+    }
+}
+
index 575bbde..7e65f76 100644 (file)
@@ -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"
     [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>
index b7531a2..e17fe8d 100644 (file)
@@ -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');
index 0a33376..4f85554 100644 (file)
@@ -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);
     }
 }
index 8287483..62b6dd5 100644 (file)
@@ -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() {
index 5eaa81f..c5afb48 100644 (file)
@@ -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>
       <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>
index 399a4c7..82c199c 100644 (file)
@@ -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) {
index 3743488..92591a7 100644 (file)
@@ -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;
 }
 
index d4ffd53..cf7b93d 100644 (file)
@@ -14,6 +14,7 @@
     class="form-control"
     [attr.id]="domId.length ? domId : null"
     [placeholder]="placeholder"
+    [disabled]="disabled"
     [(ngModel)]="selected" 
     [ngbTypeahead]="filter"
     [resultTemplate]="displayTemplate"
index f7dddb2..f455c36 100644 (file)
@@ -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.
index f092a7e..3322fd0 100644 (file)
@@ -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))
+        );
     }
 }
 
index 2d30199..b158ac1 100644 (file)
@@ -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
   ]
 })
 
index 998aa21..1f79387 100644 (file)
@@ -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>
index 3cfbb19..8322b7a 100644 (file)
@@ -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 = [];
index 1a76b28..c526099 100644 (file)
     <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>
index b65bfae..23ed696 100644 (file)
@@ -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',
index 0bfc6fb..ff34750 100644 (file)
@@ -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>
       </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">
index 2a98e36..c70b565 100644 (file)
@@ -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;
+    }
 }
 
 
index 5b16f71..785e69e 100644 (file)
@@ -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/hold.service.ts b/Open-ILS/src/eg2/src/app/staff/share/hold.service.ts
deleted file mode 100644 (file)
index 00e7374..0000000
+++ /dev/null
@@ -1,142 +0,0 @@
-/**
- * Common code for mananging holdings
- */
-import {Injectable, EventEmitter} from '@angular/core';
-import {Observable} from 'rxjs';
-import {map, mergeMap} from 'rxjs/operators';
-import {IdlObject} from '@eg/core/idl.service';
-import {NetService} from '@eg/core/net.service';
-import {PcrudService} from '@eg/core/pcrud.service';
-import {EventService, EgEvent} from '@eg/core/event.service';
-import {AuthService} from '@eg/core/auth.service';
-import {BibRecordService,
-    BibRecordSummary} from '@eg/share/catalog/bib-record.service';
-
-// Response from a place-holds API call.
-export interface HoldRequestResult {
-    success: boolean;
-    holdId?: number;
-    evt?: EgEvent;
-}
-
-// Values passed to the place-holds API call.
-export interface HoldRequest {
-    holdType: string;
-    holdTarget: number;
-    recipient: number;
-    requestor: number;
-    pickupLib: number;
-    override?: boolean;
-    notifyEmail?: boolean;
-    notifyPhone?: string;
-    notifySms?: string;
-    smsCarrier?: string;
-    thawDate?: string; // ISO date
-    frozen?: boolean;
-    holdableFormats?: {[target: number]: string};
-    result?: HoldRequestResult;
-}
-
-// A fleshed hold request target object containing whatever data is
-// available for each hold type / target.  E.g. a TITLE hold will
-// not have a value for 'volume', but a COPY hold will, since all
-// copies have volumes.  Every HoldRequestTarget will have a bibId and
-// bibSummary.  Some values come directly from the API call, others
-// applied locally.
-export interface HoldRequestTarget {
-    target: number;
-    metarecord?: IdlObject;
-    bibrecord?: IdlObject;
-    bibId?: number;
-    bibSummary?: BibRecordSummary;
-    part?: IdlObject;
-    volume?: IdlObject;
-    copy?: IdlObject;
-    issuance?: IdlObject;
-    metarecord_filters?: any;
-}
-
-@Injectable()
-export class HoldService {
-
-    constructor(
-        private evt: EventService,
-        private net: NetService,
-        private pcrud: PcrudService,
-        private auth: AuthService,
-        private bib: BibRecordService,
-    ) {}
-
-    placeHold(request: HoldRequest): Observable<HoldRequest> {
-
-        let method = 'open-ils.circ.holds.test_and_create.batch';
-        if (request.override) { method = method + '.override'; }
-
-        return this.net.request(
-            'open-ils.circ', method, this.auth.token(), {
-                patronid:       request.recipient,
-                pickup_lib:     request.pickupLib,
-                hold_type:      request.holdType,
-                email_notify:   request.notifyEmail,
-                phone_notify:   request.notifyPhone,
-                thaw_date:      request.thawDate,
-                frozen:         request.frozen,
-                sms_notify:     request.notifySms,
-                sms_carrier:    request.smsCarrier,
-                holdable_formats_map: request.holdableFormats
-            },
-            [request.holdTarget]
-        ).pipe(map(
-            resp => {
-                let result = resp.result;
-                const holdResult: HoldRequestResult = {success: true};
-
-                // API can return an ID, an array of events, or a hash
-                // of info.
-
-                if (Number(result) > 0) {
-                    // On success, the API returns the hold ID.
-                    holdResult.holdId = result;
-                    console.debug(`Hold successfully placed ${result}`);
-
-                } else {
-                    holdResult.success = false;
-                    console.info('Hold request failed: ', result);
-
-                    if (Array.isArray(result)) { result = result[0]; }
-
-                    if (this.evt.parse(result)) {
-                        holdResult.evt = this.evt.parse(result);
-                    } else {
-                        holdResult.evt = this.evt.parse(result.last_event);
-                    }
-                }
-
-                request.result = holdResult;
-                return request;
-            }
-        ));
-    }
-
-    getHoldTargetMeta(holdType: string, holdTarget: number | number[],
-        orgId?: number): Observable<HoldRequestTarget> {
-
-        const targetIds = [].concat(holdTarget);
-
-        return this.net.request(
-            'open-ils.circ',
-            'open-ils.circ.hold.get_metadata',
-            holdType, targetIds, orgId
-        ).pipe(mergeMap(meta => {
-            const target: HoldRequestTarget = meta;
-            target.bibId = target.bibrecord.id();
-
-            return this.bib.getBibSummary(target.bibId)
-            .pipe(map(sum => {
-                target.bibSummary = sum;
-                return target;
-            }));
-        }));
-    }
-}
-
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings.service.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings.service.ts
deleted file mode 100644 (file)
index cf58409..0000000
+++ /dev/null
@@ -1,54 +0,0 @@
-/**
- * Common code for mananging holdings
- */
-import {Injectable, EventEmitter} from '@angular/core';
-import {NetService} from '@eg/core/net.service';
-import {AnonCacheService} from '@eg/share/util/anon-cache.service';
-
-interface NewVolumeData {
-    owner: number;
-    label?: string;
-}
-
-@Injectable()
-export class HoldingsService {
-
-    constructor(
-        private net: NetService,
-        private anonCache: AnonCacheService
-    ) {}
-
-    // Open the holdings editor UI in a new browser window/tab.
-    spawnAddHoldingsUi(
-        recordId: number,                   // Bib record ID
-        addToVols: number[] = [],           // Add copies to existing volumes
-        volumeData: NewVolumeData[] = []) { // Creating new volumes
-
-        const raw: any[] = [];
-
-        if (addToVols) {
-            addToVols.forEach(volId => raw.push({callnumber: volId}));
-        } else if (volumeData) {
-            volumeData.forEach(data => raw.push(data));
-        }
-
-        if (raw.length === 0) { raw.push({}); }
-
-        this.anonCache.setItem(null, 'edit-these-copies', {
-            record_id: recordId,
-            raw: raw,
-            hide_vols : false,
-            hide_copies : false
-        }).then(key => {
-            if (!key) {
-                console.error('Could not create holds cache key!');
-                return;
-            }
-            setTimeout(() => {
-                const url = `/eg/staff/cat/volcopy/${key}`;
-                window.open(url, '_blank');
-            });
-        });
-    }
-}
-
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 (file)
index 0000000..382e906
--- /dev/null
@@ -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';
+
+@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/holdings.service.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.service.ts
new file mode 100644 (file)
index 0000000..4b28f70
--- /dev/null
@@ -0,0 +1,58 @@
+/**
+ * Common code for mananging holdings
+ */
+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;
+    label?: string;
+}
+
+@Injectable()
+export class HoldingsService {
+
+    constructor(
+        private net: NetService,
+        private auth: AuthService,
+        private evt: EventService,
+        private anonCache: AnonCacheService
+    ) {}
+
+    // Open the holdings editor UI in a new browser window/tab.
+    spawnAddHoldingsUi(
+        recordId: number,                   // Bib record ID
+        addToVols: number[] = [],           // Add copies to existing volumes
+        volumeData: NewVolumeData[] = []) { // Creating new volumes
+
+        const raw: any[] = [];
+
+        if (addToVols) {
+            addToVols.forEach(volId => raw.push({callnumber: volId}));
+        } else if (volumeData) {
+            volumeData.forEach(data => raw.push(data));
+        }
+
+        if (raw.length === 0) { raw.push({}); }
+
+        this.anonCache.setItem(null, 'edit-these-copies', {
+            record_id: recordId,
+            raw: raw,
+            hide_vols : false,
+            hide_copies : false
+        }).then(key => {
+            if (!key) {
+                console.error('Could not create holds cache key!');
+                return;
+            }
+            setTimeout(() => {
+                const url = `/eg/staff/cat/volcopy/${key}`;
+                window.open(url, '_blank');
+            });
+        });
+    }
+}
+
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 (file)
index 0000000..ddcf6b1
--- /dev/null
@@ -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">&times;</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 (file)
index 0000000..70d7f8f
--- /dev/null
@@ -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.
+ */
+
+@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 (file)
index 0000000..5e85a86
--- /dev/null
@@ -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">&times;</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 (file)
index 0000000..14e8ceb
--- /dev/null
@@ -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.
+ */
+
+@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 (file)
index 0000000..d7417fa
--- /dev/null
@@ -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">&times;</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 (file)
index 0000000..98af514
--- /dev/null
@@ -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.
+ */
+
+@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 (file)
index 0000000..daeeb89
--- /dev/null
@@ -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'">&nbsp;-&nbsp;{{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 (file)
index 0000000..67b3801
--- /dev/null
@@ -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 */
+
+@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 (file)
index 0000000..62d269b
--- /dev/null
@@ -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 (file)
index 0000000..e0e894d
--- /dev/null
@@ -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 */
+
+@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 (file)
index 0000000..5bcb68a
--- /dev/null
@@ -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';
+
+@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/holds/holds.service.ts b/Open-ILS/src/eg2/src/app/staff/share/holds/holds.service.ts
new file mode 100644 (file)
index 0000000..784dcec
--- /dev/null
@@ -0,0 +1,169 @@
+/**
+ * Common code for mananging holdings
+ */
+import {Injectable} from '@angular/core';
+import {Observable} from 'rxjs';
+import {map, mergeMap} from 'rxjs/operators';
+import {IdlObject} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {EventService, EgEvent} from '@eg/core/event.service';
+import {AuthService} from '@eg/core/auth.service';
+import {BibRecordService,
+    BibRecordSummary} from '@eg/share/catalog/bib-record.service';
+
+// Response from a place-holds API call.
+export interface HoldRequestResult {
+    success: boolean;
+    holdId?: number;
+    evt?: EgEvent;
+}
+
+// Values passed to the place-holds API call.
+export interface HoldRequest {
+    holdType: string;
+    holdTarget: number;
+    recipient: number;
+    requestor: number;
+    pickupLib: number;
+    override?: boolean;
+    notifyEmail?: boolean;
+    notifyPhone?: string;
+    notifySms?: string;
+    smsCarrier?: string;
+    thawDate?: string; // ISO date
+    frozen?: boolean;
+    holdableFormats?: {[target: number]: string};
+    result?: HoldRequestResult;
+}
+
+// A fleshed hold request target object containing whatever data is
+// available for each hold type / target.  E.g. a TITLE hold will
+// not have a value for 'volume', but a COPY hold will, since all
+// copies have volumes.  Every HoldRequestTarget will have a bibId and
+// bibSummary.  Some values come directly from the API call, others
+// applied locally.
+export interface HoldRequestTarget {
+    target: number;
+    metarecord?: IdlObject;
+    bibrecord?: IdlObject;
+    bibId?: number;
+    bibSummary?: BibRecordSummary;
+    part?: IdlObject;
+    volume?: IdlObject;
+    copy?: IdlObject;
+    issuance?: IdlObject;
+    metarecord_filters?: any;
+}
+
+/** Service for performing various hold-related actions */
+
+@Injectable()
+export class HoldsService {
+
+    constructor(
+        private evt: EventService,
+        private net: NetService,
+        private auth: AuthService,
+        private bib: BibRecordService,
+    ) {}
+
+    placeHold(request: HoldRequest): Observable<HoldRequest> {
+
+        let method = 'open-ils.circ.holds.test_and_create.batch';
+        if (request.override) { method = method + '.override'; }
+
+        return this.net.request(
+            'open-ils.circ', method, this.auth.token(), {
+                patronid:       request.recipient,
+                pickup_lib:     request.pickupLib,
+                hold_type:      request.holdType,
+                email_notify:   request.notifyEmail,
+                phone_notify:   request.notifyPhone,
+                thaw_date:      request.thawDate,
+                frozen:         request.frozen,
+                sms_notify:     request.notifySms,
+                sms_carrier:    request.smsCarrier,
+                holdable_formats_map: request.holdableFormats
+            },
+            [request.holdTarget]
+        ).pipe(map(
+            resp => {
+                let result = resp.result;
+                const holdResult: HoldRequestResult = {success: true};
+
+                // API can return an ID, an array of events, or a hash
+                // of info.
+
+                if (Number(result) > 0) {
+                    // On success, the API returns the hold ID.
+                    holdResult.holdId = result;
+                    console.debug(`Hold successfully placed ${result}`);
+
+                } else {
+                    holdResult.success = false;
+                    console.info('Hold request failed: ', result);
+
+                    if (Array.isArray(result)) { result = result[0]; }
+
+                    if (this.evt.parse(result)) {
+                        holdResult.evt = this.evt.parse(result);
+                    } else {
+                        holdResult.evt = this.evt.parse(result.last_event);
+                    }
+                }
+
+                request.result = holdResult;
+                return request;
+            }
+        ));
+    }
+
+    getHoldTargetMeta(holdType: string, holdTarget: number | number[],
+        orgId?: number): Observable<HoldRequestTarget> {
+
+        const targetIds = [].concat(holdTarget);
+
+        return this.net.request(
+            'open-ils.circ',
+            'open-ils.circ.hold.get_metadata',
+            holdType, targetIds, orgId
+        ).pipe(mergeMap(meta => {
+            const target: HoldRequestTarget = meta;
+            target.bibId = target.bibrecord.id();
+
+            return this.bib.getBibSummary(target.bibId)
+            .pipe(map(sum => {
+                target.bibSummary = sum;
+                return target;
+            }));
+        }));
+    }
+
+    /**
+      * 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 (file)
index 0000000..ac07dd6
--- /dev/null
@@ -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">&times;</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 (file)
index 0000000..93375c0
--- /dev/null
@@ -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.
+ */
+
+@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 (file)
index 0000000..fd9896e
--- /dev/null
@@ -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 (file)
index 0000000..f21e649
--- /dev/null
@@ -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. */
+
+@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 (file)
index 0000000..37d349d
--- /dev/null
@@ -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">&times;</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 (file)
index 0000000..feca64d
--- /dev/null
@@ -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.
+ */
+
+@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 (file)
index 0000000..80728ca
--- /dev/null
@@ -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">&times;</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 (file)
index 0000000..5ce38ea
--- /dev/null
@@ -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.
+ */
+
+@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);
+    }
+}
+
+
+
index cf10855..4ca3aba 100644 (file)
@@ -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 {