From ce5f238a434ef01cb8861c58f930257f70fdecd9 Mon Sep 17 00:00:00 2001 From: Bill Erickson Date: Sat, 16 Feb 2019 11:42:14 -0800 Subject: [PATCH] LP1818288 Ang staff catalog record detail holds tab/actions Adds support for the Holds tab in the record detail view of the Angular staff catalog. Includes grid and hold-related actions. * Holds grid built from the new wide-holds API. * batch cancel holds * batch retarget holds * batch edit holds ** Unified form to modify notify options, dates, etc. * hold detail page (menu and row double-click) * batch mark items damaged * batch mark items missing * show last few circulations * retrieve patron * support for indented menu groups a la angjs grids for grouping the hold actions menu. Signed-off-by: Bill Erickson Signed-off-by: Dan Wells --- Open-ILS/src/eg2/src/app/common.module.ts | 8 +- .../src/eg2/src/app/core/format.service.ts | 13 +- .../date-select/date-select.component.html | 8 +- .../src/app/share/dialog/dialog.component.ts | 2 +- .../grid/grid-toolbar-action.component.ts | 18 +- .../grid/grid-toolbar-button.component.ts | 10 +- .../share/grid/grid-toolbar.component.html | 13 +- .../app/share/grid/grid-toolbar.component.ts | 54 ++- Open-ILS/src/eg2/src/app/share/grid/grid.ts | 10 +- .../org-select/org-select.component.html | 1 + .../share/org-select/org-select.component.ts | 7 +- .../src/app/share/string/string.component.ts | 15 +- .../src/app/staff/catalog/catalog.module.ts | 13 +- .../staff/catalog/hold/hold.component.html | 12 +- .../app/staff/catalog/hold/hold.component.ts | 6 +- .../catalog/record/actions.component.html | 4 + .../staff/catalog/record/actions.component.ts | 2 +- .../catalog/record/record.component.html | 13 +- .../staff/catalog/record/record.component.ts | 7 + .../staff/catalog/search-form.component.ts | 8 +- .../staff/share/holdings/holdings.module.ts | 25 ++ .../share/{ => holdings}/holdings.service.ts | 4 + .../mark-damaged-dialog.component.html | 108 ++++++ .../holdings/mark-damaged-dialog.component.ts | 154 ++++++++ .../mark-missing-dialog.component.html | 44 +++ .../holdings/mark-missing-dialog.component.ts | 79 ++++ .../share/holds/cancel-dialog.component.html | 60 +++ .../share/holds/cancel-dialog.component.ts | 98 +++++ .../staff/share/holds/detail.component.html | 99 +++++ .../app/staff/share/holds/detail.component.ts | 67 ++++ .../app/staff/share/holds/grid.component.html | 244 ++++++++++++ .../app/staff/share/holds/grid.component.ts | 366 ++++++++++++++++++ .../src/app/staff/share/holds/holds.module.ts | 41 ++ .../holds.service.ts} | 33 +- .../share/holds/manage-dialog.component.html | 18 + .../share/holds/manage-dialog.component.ts | 34 ++ .../staff/share/holds/manage.component.html | 270 +++++++++++++ .../app/staff/share/holds/manage.component.ts | 144 +++++++ .../holds/retarget-dialog.component.html | 41 ++ .../share/holds/retarget-dialog.component.ts | 80 ++++ .../holds/transfer-dialog.component.html | 43 ++ .../share/holds/transfer-dialog.component.ts | 87 +++++ Open-ILS/src/eg2/src/styles.css | 27 ++ 43 files changed, 2333 insertions(+), 57 deletions(-) create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.module.ts rename Open-ILS/src/eg2/src/app/staff/share/{ => holdings}/holdings.service.ts (89%) create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holdings/mark-damaged-dialog.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holdings/mark-damaged-dialog.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holdings/mark-missing-dialog.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holdings/mark-missing-dialog.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holds/cancel-dialog.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holds/cancel-dialog.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holds/detail.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holds/detail.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holds/holds.module.ts rename Open-ILS/src/eg2/src/app/staff/share/{hold.service.ts => holds/holds.service.ts} (84%) create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holds/manage-dialog.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holds/manage-dialog.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holds/manage.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holds/manage.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holds/retarget-dialog.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holds/retarget-dialog.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holds/transfer-dialog.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holds/transfer-dialog.component.ts diff --git a/Open-ILS/src/eg2/src/app/common.module.ts b/Open-ILS/src/eg2/src/app/common.module.ts index 9361042074..ec06a91b80 100644 --- a/Open-ILS/src/eg2/src/app/common.module.ts +++ b/Open-ILS/src/eg2/src/app/common.module.ts @@ -13,7 +13,7 @@ They do not have to be added to the providers list. */ // consider moving these to core... -import {FormatService} from '@eg/core/format.service'; +import {FormatService, FormatValuePipe} from '@eg/core/format.service'; import {PrintService} from '@eg/share/print/print.service'; // Globally available components @@ -33,7 +33,8 @@ import {ProgressDialogComponent} from '@eg/share/dialog/progress.component'; ConfirmDialogComponent, PromptDialogComponent, ProgressInlineComponent, - ProgressDialogComponent + ProgressDialogComponent, + FormatValuePipe ], imports: [ CommonModule, @@ -52,7 +53,8 @@ import {ProgressDialogComponent} from '@eg/share/dialog/progress.component'; ConfirmDialogComponent, PromptDialogComponent, ProgressInlineComponent, - ProgressDialogComponent + ProgressDialogComponent, + FormatValuePipe ] }) diff --git a/Open-ILS/src/eg2/src/app/core/format.service.ts b/Open-ILS/src/eg2/src/app/core/format.service.ts index e788cd0e40..8108eec91b 100644 --- a/Open-ILS/src/eg2/src/app/core/format.service.ts +++ b/Open-ILS/src/eg2/src/app/core/format.service.ts @@ -1,4 +1,4 @@ -import {Injectable} from '@angular/core'; +import {Injectable, Pipe, PipeTransform} from '@angular/core'; import {DatePipe, CurrencyPipe} from '@angular/common'; import {IdlService, IdlObject} from '@eg/core/idl.service'; import {OrgService} from '@eg/core/org.service'; @@ -131,3 +131,14 @@ export class FormatService { } } + +// Pipe-ify the above formating logic for use in templates +@Pipe({name: 'formatValue'}) +export class FormatValuePipe implements PipeTransform { + constructor(private formatter: FormatService) {} + // Add other filter params as needed to fill in the FormatParams + transform(value: string, datatype: string): string { + return this.formatter.transform({value: value, datatype: datatype}); + } +} + diff --git a/Open-ILS/src/eg2/src/app/share/date-select/date-select.component.html b/Open-ILS/src/eg2/src/app/share/date-select/date-select.component.html index 575bbde5c8..7e65f7628e 100644 --- a/Open-ILS/src/eg2/src/app/share/date-select/date-select.component.html +++ b/Open-ILS/src/eg2/src/app/share/date-select/date-select.component.html @@ -1,7 +1,7 @@
- + (dateSelect)="onDateSelect($event)"/>
diff --git a/Open-ILS/src/eg2/src/app/share/dialog/dialog.component.ts b/Open-ILS/src/eg2/src/app/share/dialog/dialog.component.ts index b7531a2a20..e17fe8dcc7 100644 --- a/Open-ILS/src/eg2/src/app/share/dialog/dialog.component.ts +++ b/Open-ILS/src/eg2/src/app/share/dialog/dialog.component.ts @@ -41,7 +41,7 @@ export class DialogComponent implements OnInit { this.onOpen$ = new EventEmitter(); } - open(options?: NgbModalOptions): Promise { + async open(options?: NgbModalOptions): Promise { if (this.modalRef !== null) { console.warn('Dismissing existing dialog'); diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar-action.component.ts b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar-action.component.ts index 0a3337633d..4f8555404f 100644 --- a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar-action.component.ts +++ b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar-action.component.ts @@ -1,4 +1,4 @@ -import {Component, Input, OnInit, Host, TemplateRef} from '@angular/core'; +import {Component, Input, Output, OnInit, Host, TemplateRef, EventEmitter} from '@angular/core'; import {GridToolbarAction} from './grid'; import {GridComponent} from './grid.component'; @@ -11,15 +11,26 @@ export class GridToolbarActionComponent implements OnInit { // Note most input fields should match class fields for GridColumn @Input() label: string; + + // Register to click events + @Output() onClick: EventEmitter; + + // 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(); + } ngOnInit() { @@ -31,8 +42,9 @@ export class GridToolbarActionComponent implements OnInit { const action = new GridToolbarAction(); action.label = this.label; action.action = this.action; + action.onClick = this.onClick; + action.group = this.group; action.disableOnRows = this.disableOnRows; - this.grid.context.toolbarActions.push(action); } } diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar-button.component.ts b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar-button.component.ts index 8287483863..62b6dd5f13 100644 --- a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar-button.component.ts +++ b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar-button.component.ts @@ -1,4 +1,4 @@ -import {Component, Input, OnInit, Host, TemplateRef} from '@angular/core'; +import {Component, Input, Output, OnInit, Host, TemplateRef, EventEmitter} from '@angular/core'; import {GridToolbarButton} from './grid'; import {GridComponent} from './grid.component'; @@ -11,8 +11,14 @@ export class GridToolbarButtonComponent implements OnInit { // Note most input fields should match class fields for GridColumn @Input() label: string; + + // Register to click events + @Output() onClick: EventEmitter; + + // 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(); this.button = new GridToolbarButton(); + this.button.onClick = this.onClick; } ngOnInit() { diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html index 5eaa81ff62..c5afb48796 100644 --- a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html +++ b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html @@ -7,7 +7,7 @@
@@ -38,7 +38,16 @@
diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.ts b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.ts index 399a4c7211..82c199c36b 100644 --- a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.ts +++ b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.ts @@ -17,13 +17,53 @@ export class GridToolbarComponent implements OnInit { @Input() colWidthConfig: GridColumnWidthComponent; @Input() gridPrinter: GridPrintComponent; + renderedGroups: {[group: string]: boolean}; + csvExportInProgress: boolean; csvExportUrl: SafeUrl; csvExportFileName: string; - constructor(private sanitizer: DomSanitizer) {} + constructor(private sanitizer: DomSanitizer) { + this.renderedGroups = {}; + } + + ngOnInit() { + this.sortActions(); + } + + sortActions() { + const actions = this.gridContext.toolbarActions; + + const unGrouped = actions.filter(a => !a.group) + .sort((a, b) => { + return a.label < b.label ? -1 : 1; + }); + + const grouped = actions.filter(a => Boolean(a.group)) + .sort((a, b) => { + if (a.group === b.group) { + return a.label < b.label ? -1 : 1; + } else { + return a.group < b.group ? -1 : 1; + } + }); - ngOnInit() {} + // Insert group markers for rendering + const seen: any = {}; + const grouped2: any[] = []; + grouped.forEach(action => { + if (!seen[action.group]) { + seen[action.group] = true; + const act = new GridToolbarAction(); + act.label = action.group; + act.isGroup = true; + grouped2.push(act); + } + grouped2.push(action); + }); + + this.gridContext.toolbarActions = unGrouped.concat(grouped2); + } saveGridConfig() { // TODO: when server-side settings are supported, this operation @@ -38,7 +78,15 @@ export class GridToolbarComponent implements OnInit { } performAction(action: GridToolbarAction) { - action.action(this.gridContext.getSelectedRows()); + const rows = this.gridContext.getSelectedRows(); + action.onClick.emit(rows); + if (action.action) { action.action(rows); } + } + + performButtonAction(button: GridToolbarButton) { + const rows = this.gridContext.getSelectedRows(); + button.onClick.emit(); + if (button.action) { button.action(); } } shouldDisableAction(action: GridToolbarAction) { diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid.ts b/Open-ILS/src/eg2/src/app/share/grid/grid.ts index 3743488c39..92591a76e3 100644 --- a/Open-ILS/src/eg2/src/app/share/grid/grid.ts +++ b/Open-ILS/src/eg2/src/app/share/grid/grid.ts @@ -1,7 +1,7 @@ /** * Collection of grid related classses and interfaces. */ -import {TemplateRef} from '@angular/core'; +import {TemplateRef, EventEmitter} from '@angular/core'; import {Observable, Subscription} from 'rxjs'; import {IdlService, IdlObject} from '@eg/core/idl.service'; import {OrgService} from '@eg/core/org.service'; @@ -910,14 +910,18 @@ export class GridContext { // Actions apply to specific rows export class GridToolbarAction { label: string; - action: (rows: any[]) => any; + onClick: EventEmitter; + 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; + action: () => any; // DEPRECATED disabled: boolean; } diff --git a/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.html b/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.html index d4ffd53cc0..cf7b93d80f 100644 --- a/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.html +++ b/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.html @@ -14,6 +14,7 @@ class="form-control" [attr.id]="domId.length ? domId : null" [placeholder]="placeholder" + [disabled]="disabled" [(ngModel)]="selected" [ngbTypeahead]="filter" [resultTemplate]="displayTemplate" diff --git a/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts b/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts index f7dddb2a23..f455c36bf3 100644 --- a/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts +++ b/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts @@ -27,10 +27,12 @@ export class OrgSelectComponent implements OnInit { selected: OrgDisplay; hidden: number[] = []; - disabled: number[] = []; click$ = new Subject(); startOrg: IdlObject; + // Disable the entire input + @Input() disabled: boolean; + @ViewChild('instance') instance: NgbTypeahead; // Placeholder text for selector input @@ -56,8 +58,9 @@ export class OrgSelectComponent implements OnInit { } // List of org unit IDs to disable in the selector + _disabledOrgs: number[] = []; @Input() set disableOrgs(ids: number[]) { - if (ids) { this.disabled = ids; } + if (ids) { this._disabledOrgs = ids; } } // Apply an org unit value at load time. diff --git a/Open-ILS/src/eg2/src/app/share/string/string.component.ts b/Open-ILS/src/eg2/src/app/share/string/string.component.ts index f092a7ef5f..3322fd07ce 100644 --- a/Open-ILS/src/eg2/src/app/share/string/string.component.ts +++ b/Open-ILS/src/eg2/src/app/share/string/string.component.ts @@ -14,7 +14,12 @@ import {StringService} from '@eg/share/string/string.service'; selector: 'eg-string', template: ` - + + + + + {{text}} + ` }) @@ -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 { + async current(ctx?: any): Promise { if (ctx) { this.ctx = ctx; } - return new Promise(resolve => { - setTimeout(() => resolve(this.elm.nativeElement.textContent)); - }); + return new Promise(resolve => + setTimeout(() => resolve(this.elm.nativeElement.textContent)) + ); } } diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts index 2d30199441..b158ac1442 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts @@ -2,6 +2,8 @@ import {NgModule} from '@angular/core'; import {StaffCommonModule} from '@eg/staff/common.module'; import {CatalogCommonModule} from '@eg/share/catalog/catalog-common.module'; import {CatalogRoutingModule} from './routing.module'; +import {HoldsModule} from '@eg/staff/share/holds/holds.module'; +import {HoldingsModule} from '@eg/staff/share/holdings/holdings.module'; import {CatalogComponent} from './catalog.component'; import {SearchFormComponent} from './search-form.component'; import {ResultsComponent} from './result/results.component'; @@ -13,10 +15,8 @@ import {ResultRecordComponent} from './result/record.component'; import {StaffCatalogService} from './catalog.service'; import {RecordPaginationComponent} from './record/pagination.component'; import {RecordActionsComponent} from './record/actions.component'; -import {HoldingsService} from '@eg/staff/share/holdings.service'; import {BasketActionsComponent} from './basket-actions.component'; import {HoldComponent} from './hold/hold.component'; -import {HoldService} from '@eg/staff/share/hold.service'; import {PartsComponent} from './record/parts.component'; import {PartMergeDialogComponent} from './record/part-merge-dialog.component'; import {BrowseComponent} from './browse.component'; @@ -39,17 +39,16 @@ import {BrowseResultsComponent} from './browse/results.component'; PartsComponent, PartMergeDialogComponent, BrowseComponent, - BrowseResultsComponent + BrowseResultsComponent, ], imports: [ StaffCommonModule, CatalogCommonModule, - CatalogRoutingModule + CatalogRoutingModule, + HoldsModule ], providers: [ - StaffCatalogService, - HoldingsService, - HoldService + StaffCatalogService ] }) diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.html index 998aa212fe..1f79387232 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.html +++ b/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.html @@ -1,16 +1,16 @@
-
+

Place Hold ({{user.family_name()}}, {{user.first_given_name()}})

-
-
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.ts index 3cfbb19ba9..8322b7a145 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.ts @@ -13,8 +13,8 @@ import {BibRecordService, BibRecordSummary} from '@eg/share/catalog/bib-record.s import {CatalogSearchContext, CatalogSearchState} from '@eg/share/catalog/search-context'; import {CatalogService} from '@eg/share/catalog/catalog.service'; import {StaffCatalogService} from '../catalog.service'; -import {HoldService, HoldRequest, - HoldRequestTarget} from '@eg/staff/share/hold.service'; +import {HoldsService, HoldRequest, + HoldRequestTarget} from '@eg/staff/share/holds/holds.service'; import {ComboboxEntry} from '@eg/share/combobox/combobox.component'; class HoldContext { @@ -78,7 +78,7 @@ export class HoldComponent implements OnInit { private bib: BibRecordService, private cat: CatalogService, private staffCat: StaffCatalogService, - private holds: HoldService, + private holds: HoldsService, private perm: PermService ) { this.holdContexts = []; diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.html index 1a76b282f7..c52609925e 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.html +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.html @@ -19,6 +19,10 @@ + + + + diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.ts index b65bfae3a9..23ed6960cb 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/actions.component.ts @@ -7,7 +7,7 @@ import {CatalogUrlService} from '@eg/share/catalog/catalog-url.service'; import {StaffCatalogService} from '../catalog.service'; import {StringService} from '@eg/share/string/string.service'; import {ToastService} from '@eg/share/toast/toast.service'; -import {HoldingsService} from '@eg/staff/share/holdings.service'; +import {HoldingsService} from '@eg/staff/share/holdings/holdings.service'; @Component({ selector: 'eg-catalog-record-actions', diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html index 0bfc6fbd55..ff3475076d 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html @@ -21,7 +21,7 @@
-
@@ -51,13 +51,10 @@ -
- Holds tab not yet implemented. See the - - AngularJS Holds Tab. - -
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts index 2a98e36b18..c70b5658be 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts @@ -103,6 +103,13 @@ export class RecordComponent implements OnInit { this.bib.fleshBibUsers([summary.record]); }); } + + currentSearchOrg(): IdlObject { + if (this.staffCat && this.staffCat.searchContext) { + return this.staffCat.searchContext.searchOrg; + } + return null; + } } diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts index 5b16f71816..785e69e682 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts @@ -97,7 +97,13 @@ export class SearchFormComponent implements OnInit, AfterViewInit { selector = '#first-query-input'; } - this.renderer.selectRootElement(selector).focus(); + try { + // TODO: sometime the selector is not available in the DOM + // until even later (even with setTimeouts). Need to fix this. + // Note the error is thrown from selectRootElement(), not the + // call to .focus() on a null reference. + this.renderer.selectRootElement(selector).focus(); + } catch (E) {} } /** diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.module.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.module.ts new file mode 100644 index 0000000000..382e9060d7 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.module.ts @@ -0,0 +1,25 @@ +import {NgModule} from '@angular/core'; +import {StaffCommonModule} from '@eg/staff/common.module'; +import {HoldingsService} from './holdings.service'; +import {MarkDamagedDialogComponent} from './mark-damaged-dialog.component'; +import {MarkMissingDialogComponent} from './mark-missing-dialog.component'; + +@NgModule({ + declarations: [ + MarkDamagedDialogComponent, + MarkMissingDialogComponent + ], + imports: [ + StaffCommonModule + ], + exports: [ + MarkDamagedDialogComponent, + MarkMissingDialogComponent + ], + providers: [ + HoldingsService + ] +}) + +export class HoldingsModule {} + diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings.service.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.service.ts similarity index 89% rename from Open-ILS/src/eg2/src/app/staff/share/holdings.service.ts rename to Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.service.ts index cf58409982..4b28f70369 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/holdings.service.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.service.ts @@ -4,6 +4,8 @@ import {Injectable, EventEmitter} from '@angular/core'; import {NetService} from '@eg/core/net.service'; import {AnonCacheService} from '@eg/share/util/anon-cache.service'; +import {AuthService} from '@eg/core/auth.service'; +import {EventService} from '@eg/core/event.service'; interface NewVolumeData { owner: number; @@ -15,6 +17,8 @@ export class HoldingsService { constructor( private net: NetService, + private auth: AuthService, + private evt: EventService, private anonCache: AnonCacheService ) {} diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/mark-damaged-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/holdings/mark-damaged-dialog.component.html new file mode 100644 index 0000000000..ddcf6b1134 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/mark-damaged-dialog.component.html @@ -0,0 +1,108 @@ + + + + + + + + diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/mark-damaged-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/mark-damaged-dialog.component.ts new file mode 100644 index 0000000000..70d7f8fa9a --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/mark-damaged-dialog.component.ts @@ -0,0 +1,154 @@ +import {Component, OnInit, Input, ViewChild} from '@angular/core'; +import {NetService} from '@eg/core/net.service'; +import {IdlObject} from '@eg/core/idl.service'; +import {EventService} from '@eg/core/event.service'; +import {ToastService} from '@eg/share/toast/toast.service'; +import {AuthService} from '@eg/core/auth.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {OrgService} from '@eg/core/org.service'; +import {StringComponent} from '@eg/share/string/string.component'; +import {DialogComponent} from '@eg/share/dialog/dialog.component'; +import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap'; +import {BibRecordService, BibRecordSummary} from '@eg/share/catalog/bib-record.service'; +import {ComboboxEntry} from '@eg/share/combobox/combobox.component'; + +/** + * Dialog for marking items damaged and asessing related bills. + */ + +@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 { + 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 { + 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 { + return this.pcrud.retrieve('acp', this.copyId, + {flesh: 1, flesh_fields: {acp: ['call_number']}}).toPromise() + .then(copy => { + this.copy = copy; + return this.bib.getBibSummary( + copy.call_number().record()).toPromise(); + }).then(summary => { + this.bibSummary = summary; + }); + } + + reset() { + this.copy = null; + this.bibSummary = null; + this.chargeResponse = null; + this.newCharge = null; + this.newNote = null; + this.amountChangeRequested = false; + } + + bTypeChange(entry: ComboboxEntry) { + this.newBtype = entry.id; + } + + markDamaged(args: any) { + this.chargeResponse = null; + + if (args && args.apply_fines === 'apply') { + args.override_amount = this.newCharge; + args.override_btype = this.newBtype; + args.override_note = this.newNote; + } + + this.net.request( + 'open-ils.circ', 'open-ils.circ.mark_item_damaged', + this.auth.token(), this.copyId, args + ).subscribe( + result => { + console.debug('Mark damaged returned', result); + + if (Number(result) === 1) { + this.successMsg.current().then(msg => this.toast.success(msg)); + this.close(true); + return; + } + + const evt = this.evt.parse(result); + + if (evt.textcode === 'DAMAGE_CHARGE') { + // More info needed from staff on how to hangle charges. + this.chargeResponse = evt.payload; + this.newCharge = this.chargeResponse.charge; + } + }, + err => { + this.errorMsg.current().then(m => this.toast.danger(m)); + console.error(err); + } + ); + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/mark-missing-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/holdings/mark-missing-dialog.component.html new file mode 100644 index 0000000000..5e85a861a9 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/mark-missing-dialog.component.html @@ -0,0 +1,44 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/mark-missing-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/mark-missing-dialog.component.ts new file mode 100644 index 0000000000..14e8ceb7a7 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/mark-missing-dialog.component.ts @@ -0,0 +1,79 @@ +import {Component, OnInit, Input, ViewChild} from '@angular/core'; +import {NetService} from '@eg/core/net.service'; +import {EventService} from '@eg/core/event.service'; +import {ToastService} from '@eg/share/toast/toast.service'; +import {AuthService} from '@eg/core/auth.service'; +import {DialogComponent} from '@eg/share/dialog/dialog.component'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {StringComponent} from '@eg/share/string/string.component'; + + +/** + * Dialog for marking items missing. + */ + +@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 { + 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 { + this.numSucceeded = 0; + this.numFailed = 0; + const ids = [].concat(this.copyIds); + await this.markOneItemMissing(ids); + this.close(this.numSucceeded > 0); + } +} + + + diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/cancel-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/holds/cancel-dialog.component.html new file mode 100644 index 0000000000..d7417fa646 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holds/cancel-dialog.component.html @@ -0,0 +1,60 @@ + + + + + + + + \ No newline at end of file diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/cancel-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holds/cancel-dialog.component.ts new file mode 100644 index 0000000000..98af5143d3 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holds/cancel-dialog.component.ts @@ -0,0 +1,98 @@ +import {Component, OnInit, Input, ViewChild} from '@angular/core'; +import {NetService} from '@eg/core/net.service'; +import {EventService} from '@eg/core/event.service'; +import {ToastService} from '@eg/share/toast/toast.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {AuthService} from '@eg/core/auth.service'; +import {DialogComponent} from '@eg/share/dialog/dialog.component'; +import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap'; +import {StringComponent} from '@eg/share/string/string.component'; +import {ComboboxEntry} from '@eg/share/combobox/combobox.component'; + +/** + * Dialog for canceling hold requests. + */ + +@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 { + + 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 { + 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 { + this.numSucceeded = 0; + this.numFailed = 0; + const ids = [].concat(this.holdIds); + await this.cancelNext(ids); + this.close(this.numSucceeded > 0); + } +} + + + diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/detail.component.html b/Open-ILS/src/eg2/src/app/staff/share/holds/detail.component.html new file mode 100644 index 0000000000..daeeb8957c --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holds/detail.component.html @@ -0,0 +1,99 @@ + + + + +
+
+ +
+
+ +
+
+
Request Date
+
{{hold.request_time | formatValue:'timestamp'}}
+
Capture Date
+
{{hold.capture_time | formatValue:'timestamp'}}
+
Available On
+
{{hold.shelf_time | formatValue:'timestamp'}}
+
+
+
hold Type
+
+ {{hold.hold_type}} + +
+
Current Item
+ +
Call Number
+
{{hold.cn_full_label}}
+
+
+
Pickup Lib
+
{{hold.pl_shortname}}
+
Status
+
+ +
Unknown Error
+
Waiting for Item
+
Waiting for Capture
+
In Transit
+
Ready for Pickup
+
Hold Shelf Delay
+
Canceled
+
Suspended
+
Wrong Shelf
+
Fulfilled
+
+
+
Behind Desk
+
{{hold.behind_desk == '1'}}
+
+
+
Current Shelf Lib
+
{{getOrgName(hold.current_shelf_lib)}}
+
Current Shelving Location
+
{{hold.acpl_name}}
+
Force Item Quality
+
{{hold.mint_condition == '1'}}
+
+
+
Email Notify
+
{{hold.email_notify == '1'}}
+
Phone Notify
+
{{hold.phone_notify}}
+
SMS Notify
+
{{hold.sms_notify}}
+
+
+
Cancel Cause
+
{{hold.cancel_cause}}
+
Cancel Time
+
{{hold.cancel_time | formatValue:'timestamp'}}
+
Cancel Note
+
{{hold.cancel_note}}
+
+
+
Patron Name
+ + +
Patron Barcode
+ + +
+
+
+
+ diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/detail.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holds/detail.component.ts new file mode 100644 index 0000000000..67b3801e0d --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holds/detail.component.ts @@ -0,0 +1,67 @@ +import {Component, OnInit, Input, Output, ViewChild, EventEmitter} from '@angular/core'; +import {Observable, Observer, of} from 'rxjs'; +import {NetService} from '@eg/core/net.service'; +import {OrgService} from '@eg/core/org.service'; +import {AuthService} from '@eg/core/auth.service'; + +/** Hold details read-only view */ + +@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; + + constructor( + private net: NetService, + private org: OrgService, + private auth: AuthService, + ) { + this.onShowList = new EventEmitter(); + } + + ngOnInit() { + this.initDone = true; + this.fetchHold(); + } + + fetchHold() { + if (!this._holdId) { return; } + + this.net.request( + 'open-ils.circ', + 'open-ils.circ.hold.wide_hash.stream', + this.auth.token(), {id: this._holdId} + ).subscribe(wideHold => { + this.hold = wideHold; + }); + } + + getOrgName(id: number) { + if (id) { + return this.org.get(id).shortname(); + } + } + + showListView() { + this.onShowList.emit(); + } +} + + diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.html b/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.html new file mode 100644 index 0000000000..62d269b306 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.html @@ -0,0 +1,244 @@ + + + + + + + + + + + +
+ + + + + + + + +
+
+
+
+
Pickup Library
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{hold.cp_barcode}} + + + + + + + + + + + + + + + + {{hold.title}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.ts new file mode 100644 index 0000000000..e0e894d871 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.ts @@ -0,0 +1,366 @@ +import {Component, OnInit, Input, ViewChild} from '@angular/core'; +import {Observable, Observer, of} from 'rxjs'; +import {IdlObject} from '@eg/core/idl.service'; +import {NetService} from '@eg/core/net.service'; +import {OrgService} from '@eg/core/org.service'; +import {AuthService} from '@eg/core/auth.service'; +import {Pager} from '@eg/share/util/pager'; +import {GridDataSource} from '@eg/share/grid/grid'; +import {GridComponent} from '@eg/share/grid/grid.component'; +import {ProgressDialogComponent} from '@eg/share/dialog/progress.component'; +import {MarkDamagedDialogComponent + } from '@eg/staff/share/holdings/mark-damaged-dialog.component'; +import {MarkMissingDialogComponent + } from '@eg/staff/share/holdings/mark-missing-dialog.component'; +import {HoldRetargetDialogComponent + } from '@eg/staff/share/holds/retarget-dialog.component'; +import {HoldTransferDialogComponent} from './transfer-dialog.component'; +import {HoldCancelDialogComponent} from './cancel-dialog.component'; +import {HoldManageDialogComponent} from './manage-dialog.component'; + +/** Holds grid with access to detail page and other actions */ + +@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 { + + // 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; + const observable = new Observable(obs => observer = obs); + + this.progressDialog.open(); + this.progressDialog.update({value: 0, max: 1}); + let first = true; + let loadCount = 0; + this.net.request( + 'open-ils.circ', + 'open-ils.circ.hold.wide_hash.stream', + // Pre-fetch all holds consistent with AngJS version + this.auth.token(), filters, orderBy + // Alternatively, fetch holds in pages. + // this.auth.token(), filters, orderBy, pager.limit, pager.offset + ).subscribe( + holdData => { + + if (first) { // First response is the hold count. + this.holdsCount = Number(holdData); + first = false; + + } else { // Subsequent responses are hold data blobs + + this.progressDialog.update( + {value: ++loadCount, max: this.holdsCount}); + + observer.next(holdData); + } + }, + err => { + this.progressDialog.close(); + observer.error(err); + }, + () => { + this.progressDialog.close(); + observer.complete(); + } + ); + + return observable; + } + + showDetails(rows: any[]) { + this.showDetail(rows[0]); + } + + showDetail(row: any) { + if (row) { + this.mode = 'detail'; + this.detailHold = row; + } + } + + showManager(rows: any[]) { + if (rows.length) { + this.mode = 'manage'; + this.editHolds = rows.map(r => r.id); + } + } + + handleModify(rowsModified: boolean) { + this.mode = 'list'; + + if (rowsModified) { + // give the grid a chance to render then ask it to reload + setTimeout(() => this.holdsGrid.reload()); + } + } + + + + showRecentCircs(rows: any[]) { + if (rows.length) { + const url = + '/eg/staff/cat/item/' + rows[0].cp_id + '/circ_list'; + window.open(url, '_blank'); + } + } + + showPatron(rows: any[]) { + if (rows.length) { + const url = + '/eg/staff/circ/patron/' + rows[0].usr_id + '/checkout'; + window.open(url, '_blank'); + } + } + + showManageDialog(rows: any[]) { + const holdIds = rows.map(r => r.id).filter(id => Boolean(id)); + if (holdIds.length > 0) { + this.manageDialog.holdIds = holdIds; + this.manageDialog.open({size: 'lg'}).then( + rowsModified => { + if (rowsModified) { + this.holdsGrid.reload(); + } + }, + dismissed => {} + ); + } + } + + showTransferDialog(rows: any[]) { + const holdIds = rows.map(r => r.id).filter(id => Boolean(id)); + if (holdIds.length > 0) { + this.transferDialog.holdIds = holdIds; + this.transferDialog.open({}).then( + rowsModified => { + if (rowsModified) { + this.holdsGrid.reload(); + } + }, + dismissed => {} + ); + } + } + + async showMarkDamagedDialog(rows: any[]) { + const copyIds = rows.map(r => r.cp_id).filter(id => Boolean(id)); + if (copyIds.length === 0) { return; } + + let rowsModified = false; + + const markNext = async(ids: number[]) => { + if (ids.length === 0) { + return Promise.resolve(); + } + + this.markDamagedDialog.copyId = ids.pop(); + this.markDamagedDialog.open({size: 'lg'}).then( + ok => { + if (ok) { rowsModified = true; } + return markNext(ids); + }, + dismiss => markNext(ids) + ); + }; + + await markNext(copyIds); + if (rowsModified) { + this.holdsGrid.reload(); + } + } + + showMarkMissingDialog(rows: any[]) { + const copyIds = rows.map(r => r.cp_id).filter(id => Boolean(id)); + if (copyIds.length > 0) { + this.markMissingDialog.copyIds = copyIds; + this.markMissingDialog.open({}).then( + rowsModified => { + if (rowsModified) { + this.holdsGrid.reload(); + } + }, + dismissed => {} // avoid console errors + ); + } + } + + showRetargetDialog(rows: any[]) { + const holdIds = rows.map(r => r.id).filter(id => Boolean(id)); + if (holdIds.length > 0) { + this.retargetDialog.holdIds = holdIds; + this.retargetDialog.open({}).then( + rowsModified => { + if (rowsModified) { + this.holdsGrid.reload(); + } + }, + dismissed => {} + ); + } + } + + showCancelDialog(rows: any[]) { + const holdIds = rows.map(r => r.id).filter(id => Boolean(id)); + if (holdIds.length > 0) { + this.cancelDialog.holdIds = holdIds; + this.cancelDialog.open({}).then( + rowsModified => { + if (rowsModified) { + this.holdsGrid.reload(); + } + }, + dismissed => {} + ); + } + } +} + + diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/holds.module.ts b/Open-ILS/src/eg2/src/app/staff/share/holds/holds.module.ts new file mode 100644 index 0000000000..5bcb68aeaf --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holds/holds.module.ts @@ -0,0 +1,41 @@ +import {NgModule} from '@angular/core'; +import {StaffCommonModule} from '@eg/staff/common.module'; +import {HoldingsModule} from '@eg/staff/share/holdings/holdings.module'; +import {HoldsService} from './holds.service'; +import {HoldsGridComponent} from './grid.component'; +import {HoldDetailComponent} from './detail.component'; +import {HoldManageComponent} from './manage.component'; +import {HoldRetargetDialogComponent} from './retarget-dialog.component'; +import {HoldTransferDialogComponent} from './transfer-dialog.component'; +import {HoldCancelDialogComponent} from './cancel-dialog.component'; +import {HoldManageDialogComponent} from './manage-dialog.component'; + +@NgModule({ + declarations: [ + HoldsGridComponent, + HoldDetailComponent, + HoldManageComponent, + HoldRetargetDialogComponent, + HoldTransferDialogComponent, + HoldCancelDialogComponent, + HoldManageDialogComponent + ], + imports: [ + StaffCommonModule, + HoldingsModule + ], + exports: [ + HoldsGridComponent, + HoldDetailComponent, + HoldManageComponent, + HoldRetargetDialogComponent, + HoldTransferDialogComponent, + HoldCancelDialogComponent, + HoldManageDialogComponent + ], + providers: [ + HoldsService + ] +}) + +export class HoldsModule {} diff --git a/Open-ILS/src/eg2/src/app/staff/share/hold.service.ts b/Open-ILS/src/eg2/src/app/staff/share/holds/holds.service.ts similarity index 84% rename from Open-ILS/src/eg2/src/app/staff/share/hold.service.ts rename to Open-ILS/src/eg2/src/app/staff/share/holds/holds.service.ts index 00e7374943..784dcec4eb 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/hold.service.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/holds/holds.service.ts @@ -1,7 +1,7 @@ /** * Common code for mananging holdings */ -import {Injectable, EventEmitter} from '@angular/core'; +import {Injectable} from '@angular/core'; import {Observable} from 'rxjs'; import {map, mergeMap} from 'rxjs/operators'; import {IdlObject} from '@eg/core/idl.service'; @@ -56,13 +56,14 @@ export interface HoldRequestTarget { metarecord_filters?: any; } +/** Service for performing various hold-related actions */ + @Injectable() -export class HoldService { +export class HoldsService { constructor( private evt: EventService, private net: NetService, - private pcrud: PcrudService, private auth: AuthService, private bib: BibRecordService, ) {} @@ -138,5 +139,31 @@ export class HoldService { })); })); } + + /** + * Update a list of holds. + * Returns observable of results, one per hold. + * Result is either a Number (hold ID) or an EgEvent object. + */ + updateHolds(holds: IdlObject[]): Observable { + + return this.net.request( + 'open-ils.circ', + 'open-ils.circ.hold.update.batch', + this.auth.token(), holds + ).pipe(map(response => { + + if (Number(response) > 0) { return Number(response); } + + if (Array.isArray(response)) { response = response[0]; } + + const evt = this.evt.parse(response); + + console.warn('Hold update returned event', evt); + return evt; + })); + } } + + diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/manage-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/holds/manage-dialog.component.html new file mode 100644 index 0000000000..ac07dd6f35 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holds/manage-dialog.component.html @@ -0,0 +1,18 @@ + + + + \ No newline at end of file diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/manage-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holds/manage-dialog.component.ts new file mode 100644 index 0000000000..93375c0eb7 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holds/manage-dialog.component.ts @@ -0,0 +1,34 @@ +import {Component, OnInit, Input} from '@angular/core'; +import {DialogComponent} from '@eg/share/dialog/dialog.component'; +import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap'; + +/** + * Dialog wrapper for ManageHoldsComponent. + */ + +@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 { + return super.open(args); + } + + onComplete(changesMade: boolean) { + this.close(changesMade); + } +} + + + diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/manage.component.html b/Open-ILS/src/eg2/src/app/staff/share/holds/manage.component.html new file mode 100644 index 0000000000..fd9896e2d6 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holds/manage.component.html @@ -0,0 +1,270 @@ + +
+ +
+
+
+
+ +
+
+
+
+
+ + + +
+
+
+
+ +
+
+
+ +
+
+
+
+ + +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ + +
+
+
+
+
+ +
+
+
+
+
+ + +
+
+
+
+ +
+
+
+
+
+ + +
+
+ +
+
+
+
+ +
+
+
+
+
+ + +
+
+
+
+ +
+
+
+
+
+ + +
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/manage.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holds/manage.component.ts new file mode 100644 index 0000000000..f21e64946d --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holds/manage.component.ts @@ -0,0 +1,144 @@ +import {Component, OnInit, Input, Output, ViewChild, EventEmitter} from '@angular/core'; +import {IdlObject, IdlService} from '@eg/core/idl.service'; +import {NetService} from '@eg/core/net.service'; +import {OrgService} from '@eg/core/org.service'; +import {AuthService} from '@eg/core/auth.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {ComboboxEntry} from '@eg/share/combobox/combobox.component'; +import {HoldsService} from './holds.service'; + +/** Edit holds in single or batch mode. */ + +@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; + + constructor( + private idl: IdlService, + private org: OrgService, + private pcrud: PcrudService, + private holds: HoldsService + ) { + this.onComplete = new EventEmitter(); + this.smsCarriers = []; + this.holdIds = []; + this.activeFields = {}; + } + + ngOnInit() { + this.org.settings('sms.enable').then(sets => { + this.smsEnabled = sets['sms.enable']; + if (!this.smsEnabled) { return; } + + this.pcrud.search('csc', {active: 't'}, {order_by: {csc: 'name'}}) + .subscribe(carrier => { + this.smsCarriers.push({ + id: carrier.id(), + label: carrier.name() + }); + }); + }); + + this.fetchHold(); + } + + fetchHold() { + this.hold = null; + + if (this.holdIds.length === 0) { + return; + + } else if (this.isBatch()) { + // Use a dummy hold to store form values. + this.hold = this.idl.create('ahr'); + + } else { + // Form values are stored in the one hold we're editing. + this.pcrud.retrieve('ahr', this.holdIds[0]) + .subscribe(hold => this.hold = hold); + } + } + + toFormData() { + + } + + isBatch(): boolean { + return this.holdIds.length > 1; + } + + pickupLibChanged(org: IdlObject) { + if (org) { + this.hold.pickup_lib(org.id()); + } + } + + save() { + if (this.isBatch()) { + + // Fields with edit-active checkboxes + const fields = Object.keys(this.activeFields) + .filter(field => this.activeFields[field]); + + const holds: IdlObject[] = []; + this.pcrud.search('ahr', {id: this.holdIds}) + .subscribe( + hold => { + // Copy form fields to each hold to update. + fields.forEach(field => hold[field](this.hold[field]())); + holds.push(hold); + }, + err => {}, + () => { + this.saveBatch(holds); + } + ); + } else { + this.saveBatch([this.hold]); + } + } + + saveBatch(holds: IdlObject[]) { + let successCount = 0; + this.holds.updateHolds(holds) + .subscribe( + res => { + if (Number(res) > 0) { + successCount++; + console.debug('hold update succeeded with ', res); + } else { + // TODO: toast? + } + }, + err => console.error('hold update failed with ', err), + () => { + if (successCount === holds.length) { + this.onComplete.emit(true); + } else { + // TODO: toast? + console.error('Some holds failed to update'); + } + } + ); + } + + exit() { + this.onComplete.emit(false); + } +} + + diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/retarget-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/holds/retarget-dialog.component.html new file mode 100644 index 0000000000..37d349dd80 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holds/retarget-dialog.component.html @@ -0,0 +1,41 @@ + + + + + + + + \ No newline at end of file diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/retarget-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holds/retarget-dialog.component.ts new file mode 100644 index 0000000000..feca64d92a --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holds/retarget-dialog.component.ts @@ -0,0 +1,80 @@ +import {Component, OnInit, Input, ViewChild} from '@angular/core'; +import {NetService} from '@eg/core/net.service'; +import {EventService} from '@eg/core/event.service'; +import {ToastService} from '@eg/share/toast/toast.service'; +import {AuthService} from '@eg/core/auth.service'; +import {DialogComponent} from '@eg/share/dialog/dialog.component'; +import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap'; +import {StringComponent} from '@eg/share/string/string.component'; + + +/** + * Dialog for retargeting holds. + */ + +@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 { + this.holdIds = [].concat(this.holdIds); // array-ify ints + return super.open(args); + } + + async retargetNext(ids: number[]): Promise { + 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 { + this.numSucceeded = 0; + this.numFailed = 0; + const ids = [].concat(this.holdIds); + await this.retargetNext(ids); + this.close(this.numSucceeded > 0); + } +} + + + diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/transfer-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/holds/transfer-dialog.component.html new file mode 100644 index 0000000000..80728caf8b --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holds/transfer-dialog.component.html @@ -0,0 +1,43 @@ + + + + + + + + + \ No newline at end of file diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/transfer-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holds/transfer-dialog.component.ts new file mode 100644 index 0000000000..5ce38ea02e --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holds/transfer-dialog.component.ts @@ -0,0 +1,87 @@ +import {Component, OnInit, Input, ViewChild} from '@angular/core'; +import {NetService} from '@eg/core/net.service'; +import {StoreService} from '@eg/core/store.service'; +import {EventService} from '@eg/core/event.service'; +import {ToastService} from '@eg/share/toast/toast.service'; +import {AuthService} from '@eg/core/auth.service'; +import {DialogComponent} from '@eg/share/dialog/dialog.component'; +import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap'; +import {StringComponent} from '@eg/share/string/string.component'; + + +/** + * Dialog for transferring holds. + */ + +@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 { + 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 { + 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 { + this.numSucceeded = 0; + this.numFailed = 0; + await this.transferHolds(); + this.close(this.numSucceeded > 0); + } +} + + + diff --git a/Open-ILS/src/eg2/src/styles.css b/Open-ILS/src/eg2/src/styles.css index cf10855253..4ca3abab7d 100644 --- a/Open-ILS/src/eg2/src/styles.css +++ b/Open-ILS/src/eg2/src/styles.css @@ -48,6 +48,33 @@ h5 {font-size: .95rem} .flex-4 {flex: 4} .flex-5 {flex: 5} +/** BS deprecated the well, but it's replacement is not quite the same. + * Define our own version and expand it to a full "table". + * */ +.well-row { + display: flex; +} +.well-table .well-label { + flex: 1; + display: flex; + align-items: center; + margin: 4px; + padding: 4px; + min-height: 40px; +} + +.well-table .well-value { + flex: 1; + display: flex; + align-items: center; + background-color: #f5f5f5; + border-radius: 5px; + box-shadow: inset 0 1px 1px rgba(0,0,0,.05); + padding: 4px; + margin: 4px; + min-height: 40px; +} + /* usefuf for mat-icon buttons without any background or borders */ .material-icon-button { -- 2.43.2