From: Bill Erickson Date: Sat, 16 Feb 2019 19:42:14 +0000 (-0800) Subject: LP1818288 Ang staff catalog record detail holds tab/actions X-Git-Url: https://old-git.evergreen-ils.org/?a=commitdiff_plain;h=ce5f238a434ef01cb8861c58f930257f70fdecd9;p=evergreen%2Fpines.git 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 --- 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/hold.service.ts b/Open-ILS/src/eg2/src/app/staff/share/hold.service.ts deleted file mode 100644 index 00e7374943..0000000000 --- a/Open-ILS/src/eg2/src/app/staff/share/hold.service.ts +++ /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 { - - 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 { - - 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 index cf58409982..0000000000 --- a/Open-ILS/src/eg2/src/app/staff/share/holdings.service.ts +++ /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 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/holdings.service.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.service.ts new file mode 100644 index 0000000000..4b28f70369 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.service.ts @@ -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 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/holds/holds.service.ts b/Open-ILS/src/eg2/src/app/staff/share/holds/holds.service.ts new file mode 100644 index 0000000000..784dcec4eb --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holds/holds.service.ts @@ -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 { + + 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 { + + 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 { + + 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 {