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