From 19caf4b6b8d645cb93f0e29f6eaff51a7bc91e56 Mon Sep 17 00:00:00 2001 From: Jane Sandberg Date: Thu, 25 Jul 2019 10:28:47 -0700 Subject: [PATCH] LP1816475: Booking module refresh This commit ports several dojo interfaces to Angular(7). As part of this work, * Adds moment.js-based timezone support to the Angular fmeditor and grid * Adds a note field to booking.reservation. This field is visible in all staff views of reservations (Create, Manage, Pull List, Capture, Pick Up and Return), but is not visible to the patron * Adds usrname as a selector for actor.usr * Adds the new booking.reservation note field to the receipt in the dojo-based Capture Reservations screen * Adds a read-only display of au to the fm-editor * Adds a new patron service in staff/share * Adds relevant workstation settings to the database * Adds form validation styles to reactive form fields * Adds a necessary polyfill for testing Signed-off-by: Jane Sandberg Signed-off-by: Christine Burns Signed-off-by: Galen Charlton --- Open-ILS/examples/fm_IDL.xml | 7 +- .../share/fm-editor/fm-editor.component.html | 19 + .../share/fm-editor/fm-editor.component.ts | 24 +- .../app/share/grid/grid-column.component.ts | 4 + .../share/grid/grid-toolbar.component.html | 5 +- .../src/app/share/grid/grid.component.html | 2 +- .../eg2/src/app/share/grid/grid.component.ts | 9 +- Open-ILS/src/eg2/src/app/share/grid/grid.ts | 4 +- .../not_before_moment_validator.directive.ts | 32 ++ ...patron_barcode_validator.directive.spec.ts | 43 ++ .../patron_barcode_validator.directive.ts | 56 +++ .../src/app/staff/booking/booking.module.ts | 42 ++ .../booking_resource_validator.directive.ts | 42 ++ .../cancel-reservation-dialog.component.ts | 63 +++ .../create-reservation-dialog.component.html | 86 ++++ .../create-reservation-dialog.component.ts | 203 +++++++++ .../booking/create-reservation.component.html | 219 +++++++++ .../booking/create-reservation.component.ts | 417 ++++++++++++++++++ .../manage-reservations.component.html | 72 +++ .../booking/manage-reservations.component.ts | 188 ++++++++ .../booking/no-timezone-set.component.html | 17 + .../booking/no-timezone-set.component.ts | 16 + .../app/staff/booking/pickup.component.html | 27 ++ .../src/app/staff/booking/pickup.component.ts | 110 +++++ .../staff/booking/pull-list.component.html | 47 ++ .../app/staff/booking/pull-list.component.ts | 127 ++++++ .../booking/reservation-actions.service.ts | 32 ++ .../staff/booking/reservation-actions.spec.ts | 35 ++ .../booking/reservations-grid.component.html | 69 +++ .../booking/reservations-grid.component.ts | 302 +++++++++++++ .../app/staff/booking/return.component.html | 46 ++ .../src/app/staff/booking/return.component.ts | 145 ++++++ .../src/app/staff/booking/routing.module.ts | 44 ++ .../staff/booking/schedule-grid.service.ts | 173 ++++++++ .../app/staff/booking/schedule-grid.spec.ts | 51 +++ .../catalog/record/holdings.component.html | 5 + .../catalog/record/holdings.component.ts | 9 +- .../src/eg2/src/app/staff/common.module.ts | 11 +- .../src/eg2/src/app/staff/nav.component.html | 12 +- .../src/eg2/src/app/staff/routing.module.ts | 3 + .../eg2/src/app/staff/share/patron.service.ts | 23 + Open-ILS/src/eg2/src/polyfills.ts | 1 + Open-ILS/src/eg2/src/styles.css | 10 +- .../lib/OpenILS/Application/Booking.pm | 4 +- Open-ILS/src/sql/Pg/095.schema.booking.sql | 3 +- Open-ILS/src/sql/Pg/950.data.seed-values.sql | 88 ++++ .../XXXX.data.booking-sticky-settings.sql | 78 ++++ .../Pg/upgrade/XXXX.schema.add_note_bresv.sql | 6 + .../staff/cat/catalog/t_holdings.tt2 | 3 + .../src/templates/staff/cat/item/index.tt2 | 1 + .../src/templates/staff/cat/item/t_list.tt2 | 3 + .../src/templates/staff/circ/patron/index.tt2 | 13 +- Open-ILS/src/templates/staff/navbar.tt2 | 14 +- Open-ILS/web/js/ui/default/booking/capture.js | 9 + .../js/ui/default/staff/cat/catalog/app.js | 76 +--- .../web/js/ui/default/staff/cat/item/app.js | 19 +- .../js/ui/default/staff/circ/services/item.js | 70 +-- 57 files changed, 3071 insertions(+), 168 deletions(-) create mode 100644 Open-ILS/src/eg2/src/app/share/validators/not_before_moment_validator.directive.ts create mode 100644 Open-ILS/src/eg2/src/app/share/validators/patron_barcode_validator.directive.spec.ts create mode 100644 Open-ILS/src/eg2/src/app/share/validators/patron_barcode_validator.directive.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/booking/booking.module.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/booking/booking_resource_validator.directive.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/booking/cancel-reservation-dialog.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/booking/create-reservation-dialog.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/booking/create-reservation-dialog.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/booking/create-reservation.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/booking/create-reservation.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/booking/manage-reservations.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/booking/manage-reservations.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/booking/no-timezone-set.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/booking/no-timezone-set.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/booking/pickup.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/booking/pickup.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/booking/pull-list.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/booking/pull-list.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/booking/reservation-actions.service.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/booking/reservation-actions.spec.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/booking/reservations-grid.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/booking/reservations-grid.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/booking/return.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/booking/return.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/booking/routing.module.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/booking/schedule-grid.service.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/booking/schedule-grid.spec.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/share/patron.service.ts create mode 100644 Open-ILS/src/sql/Pg/upgrade/XXXX.data.booking-sticky-settings.sql create mode 100644 Open-ILS/src/sql/Pg/upgrade/XXXX.schema.add_note_bresv.sql diff --git a/Open-ILS/examples/fm_IDL.xml b/Open-ILS/examples/fm_IDL.xml index de6933f6f7..6b1cf45df8 100644 --- a/Open-ILS/examples/fm_IDL.xml +++ b/Open-ILS/examples/fm_IDL.xml @@ -3651,7 +3651,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA - + @@ -5137,8 +5137,8 @@ SELECT usr, - - + + @@ -5154,6 +5154,7 @@ SELECT usr, + diff --git a/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.html b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.html index 85c0c65619..bb0e34785e 100644 --- a/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.html +++ b/Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.html @@ -59,6 +59,18 @@ + + + + + + + + {{field.linkedValues[0].label}} + open_in_new + + + ; @@ -84,6 +84,9 @@ export class FmRecordEditorComponent recId: any; + // Show datetime fields in this particular timezone + timezone: string = this.format.wsOrgTimezone; + // IDL record we are editing record: IdlObject; @@ -108,6 +111,10 @@ export class FmRecordEditorComponent @Input() requiredFieldsList: string[] = []; @Input() requiredFields: string; // comma-separated string version + // list of timestamp fields that should display with a timepicker + @Input() datetimeFieldsList: string[] = []; + @Input() datetimeFields: string; // comma-separated string version + // list of org_unit fields where a default value may be applied by // the org-select if no value is present. @Input() orgDefaultAllowedList: string[] = []; @@ -169,6 +176,7 @@ export class FmRecordEditorComponent private idl: IdlService, private auth: AuthService, private toast: ToastService, + private format: FormatService, private pcrud: PcrudService) { super(modal); } @@ -230,6 +238,9 @@ export class FmRecordEditorComponent if (this.requiredFields) { this.requiredFieldsList = this.requiredFields.split(/,/); } + if (this.datetimeFields) { + this.datetimeFieldsList = this.datetimeFields.split(/,/); + } if (this.orgDefaultAllowed) { this.orgDefaultAllowedList = this.orgDefaultAllowed.split(/,/); } @@ -403,6 +414,8 @@ export class FmRecordEditorComponent promise = this.wireUpCombobox(field); + } else if (field.datatype === 'timestamp') { + field.datetime = this.datetimeFieldsList.includes(field.name); } else if (field.datatype === 'org_unit') { field.orgDefaultAllowed = this.orgDefaultAllowedList.includes(field.name); @@ -531,6 +544,10 @@ export class FmRecordEditorComponent return 'template'; } + if ( field.datatype === 'timestamp' && field.datetime ) { + return 'timestamp-timepicker'; + } + // Some widgets handle readOnly for us. if ( field.datatype === 'timestamp' || field.datatype === 'org_unit' @@ -543,6 +560,10 @@ export class FmRecordEditorComponent return 'readonly-money'; } + if (field.datatype === 'link' && field.class === 'au') { + return 'readonly-au'; + } + if (field.datatype === 'link' || field.linkedValues) { return 'readonly-list'; } @@ -582,4 +603,3 @@ export class FmRecordEditorComponent } } - diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-column.component.ts b/Open-ILS/src/eg2/src/app/share/grid/grid-column.component.ts index b616b82480..c612eb42fd 100644 --- a/Open-ILS/src/eg2/src/app/share/grid/grid-column.component.ts +++ b/Open-ILS/src/eg2/src/app/share/grid/grid-column.component.ts @@ -33,6 +33,9 @@ export class GridColumnComponent implements OnInit { // Display date and time when datatype = timestamp @Input() datePlusTime: boolean; + // Display using a specific OU's timestamp when datatype = timestamp + @Input() timezoneContextOrg: number; + // Used in conjunction with cellTemplate @Input() cellContext: any; @Input() cellTemplate: TemplateRef; @@ -65,6 +68,7 @@ export class GridColumnComponent implements OnInit { col.datatype = this.datatype; col.datePlusTime = this.datePlusTime; col.ternaryBool = this.ternaryBool; + col.timezoneContextOrg = this.timezoneContextOrg; col.isAuto = false; this.grid.context.columnSet.add(col); } 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 55ca188f50..3b2237771b 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 @@ -92,7 +92,7 @@ title="Expand Cells Vertically" i18n-title class="material-icons mat-icon-in-button">expand_more expand_less @@ -150,6 +150,3 @@
- - - diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid.component.html b/Open-ILS/src/eg2/src/app/share/grid/grid.component.html index e29eb67e63..6301eec8c5 100644 --- a/Open-ILS/src/eg2/src/app/share/grid/grid.component.html +++ b/Open-ILS/src/eg2/src/app/share/grid/grid.component.html @@ -1,7 +1,7 @@
- ; @Output() onRowClick: EventEmitter; + @ViewChild('toolbar') toolbar: GridToolbarComponent; + constructor( private idl: IdlService, private org: OrgService, @@ -190,6 +193,10 @@ export class GridComponent implements OnInit, AfterViewInit, OnDestroy { this.context.destroy(); } + print = () => { + this.toolbar.printHtml(); + } + reload() { this.context.reload(); } 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 7835f454ab..01b5c09aa8 100644 --- a/Open-ILS/src/eg2/src/app/share/grid/grid.ts +++ b/Open-ILS/src/eg2/src/app/share/grid/grid.ts @@ -28,6 +28,7 @@ export class GridColumn { datatype: string; datePlusTime: boolean; ternaryBool: boolean; + timezoneContextOrg: number; cellTemplate: TemplateRef; cellContext: any; isIndex: boolean; @@ -732,7 +733,8 @@ export class GridContext { idlClass: col.idlClass, idlField: col.idlFieldDef ? col.idlFieldDef.name : col.name, datatype: col.datatype, - datePlusTime: Boolean(col.datePlusTime) + datePlusTime: Boolean(col.datePlusTime), + timezoneContextOrg: Number(col.timezoneContextOrg) }); } diff --git a/Open-ILS/src/eg2/src/app/share/validators/not_before_moment_validator.directive.ts b/Open-ILS/src/eg2/src/app/share/validators/not_before_moment_validator.directive.ts new file mode 100644 index 0000000000..7b6f1fc55d --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/validators/not_before_moment_validator.directive.ts @@ -0,0 +1,32 @@ +import {Directive, Input} from '@angular/core'; +import {NG_VALIDATORS, AbstractControl, FormControl, ValidationErrors, ValidatorFn} from '@angular/forms'; +import {Injectable} from '@angular/core'; + +import * as Moment from 'moment-timezone'; + +export function notBeforeMomentValidator(notBeforeMe: Moment): ValidatorFn { + return (control: AbstractControl): {[key: string]: any} | null => { + return (control.value && control.value.isBefore(notBeforeMe)) ? + {tooEarly: 'This cannot be before ' + notBeforeMe.format('LLL')} : null; + }; +} + +@Directive({ + selector: '[egNotBeforeMoment]', + providers: [{ + provide: NG_VALIDATORS, + useExisting: NotBeforeMomentValidatorDirective, + multi: true + }] +}) +export class NotBeforeMomentValidatorDirective { + @Input('egNotBeforeMoment') egNotBeforeMoment: Moment; + + validate(control: AbstractControl): {[key: string]: any} | null { + return this.egNotBeforeMoment ? + notBeforeMomentValidator(this.egNotBeforeMoment)(control) + : null; + } +} + + diff --git a/Open-ILS/src/eg2/src/app/share/validators/patron_barcode_validator.directive.spec.ts b/Open-ILS/src/eg2/src/app/share/validators/patron_barcode_validator.directive.spec.ts new file mode 100644 index 0000000000..1e1208e0cf --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/validators/patron_barcode_validator.directive.spec.ts @@ -0,0 +1,43 @@ +import {PatronBarcodeValidator} from './patron_barcode_validator.directive'; +import {of} from 'rxjs'; +import {NetService} from '@eg/core/net.service'; +import {AuthService} from '@eg/core/auth.service'; +import {EventService} from '@eg/core/event.service'; +import {StoreService} from '@eg/core/store.service'; + +let netService: NetService; +let authService: AuthService; +let evtService: EventService; +let storeService: StoreService; + +beforeEach(() => { + evtService = new EventService(); + storeService = new StoreService(null /* CookieService */); + netService = new NetService(evtService); + authService = new AuthService(evtService, netService, storeService); +}); + +describe('PatronBarcodeValidator', () => { + it('should not throw an error if there is exactly 1 match', () => { + const pbv = new PatronBarcodeValidator(authService, netService); + pbv['parseActorCall'](of(1)) + .subscribe((val) => { + expect(val).toBeNull(); + }); + }); + it('should throw an error if there is more than 1 match', () => { + const pbv = new PatronBarcodeValidator(authService, netService); + pbv['parseActorCall'](of(1, 2, 3)) + .subscribe((val) => { + expect(val).not.toBeNull(); + }); + }); + it('should throw an error if there is no match', () => { + const pbv = new PatronBarcodeValidator(authService, netService); + pbv['parseActorCall'](of()) + .subscribe((val) => { + expect(val).not.toBeNull(); + }); + }); +}); + diff --git a/Open-ILS/src/eg2/src/app/share/validators/patron_barcode_validator.directive.ts b/Open-ILS/src/eg2/src/app/share/validators/patron_barcode_validator.directive.ts new file mode 100644 index 0000000000..81d1b159b0 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/validators/patron_barcode_validator.directive.ts @@ -0,0 +1,56 @@ +import { Directive, forwardRef } from '@angular/core'; +import { NG_VALIDATORS, NG_ASYNC_VALIDATORS, AbstractControl, ValidationErrors, AsyncValidator, FormControl } from '@angular/forms'; +import {NetService} from '@eg/core/net.service'; +import {AuthService} from '@eg/core/auth.service'; +import {EmptyError, Observable, of} from 'rxjs'; +import {single, switchMap, catchError} from 'rxjs/operators'; +import {Injectable} from '@angular/core'; + +@Injectable({providedIn: 'root'}) +export class PatronBarcodeValidator implements AsyncValidator { + constructor( + private auth: AuthService, + private net: NetService) { + } + + validate = (control: FormControl) => { + return this.parseActorCall(this.net.request( + 'open-ils.actor', + 'open-ils.actor.get_barcodes', + this.auth.token(), + this.auth.user().ws_ou(), + 'actor', control.value)); + } + + private parseActorCall = (actorCall: Observable) => { + return actorCall + .pipe(single(), + switchMap(() => of(null)), + catchError((err) => { + if (err instanceof EmptyError) { + return of({ patronBarcode: 'No patron found with that barcode' }); + } else if ('Sequence contains more than one element' === err) { + return of({ patronBarcode: 'Barcode matches more than one patron' }); + } + })); + } +} + +@Directive({ + selector: '[egValidPatronBarcode]', + providers: [{ + provide: NG_ASYNC_VALIDATORS, + useExisting: forwardRef(() => PatronBarcodeValidator), + multi: true + }] +}) +export class PatronBarcodeValidatorDirective { + constructor( + private pbv: PatronBarcodeValidator + ) { } + + validate = (control: FormControl) => { + this.pbv.validate(control); + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/booking/booking.module.ts b/Open-ILS/src/eg2/src/app/staff/booking/booking.module.ts new file mode 100644 index 0000000000..65da637f14 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/booking.module.ts @@ -0,0 +1,42 @@ +import {NgModule} from '@angular/core'; +import {ReactiveFormsModule} from '@angular/forms'; +import {StaffCommonModule} from '@eg/staff/common.module'; +import {BookingRoutingModule} from './routing.module'; +import {CancelReservationDialogComponent} from './cancel-reservation-dialog.component'; +import {CreateReservationComponent} from './create-reservation.component'; +import {CreateReservationDialogComponent} from './create-reservation-dialog.component'; +import {ManageReservationsComponent} from './manage-reservations.component'; +import {ReservationsGridComponent} from './reservations-grid.component'; +import {PickupComponent} from './pickup.component'; +import {PullListComponent} from './pull-list.component'; +import {ReturnComponent} from './return.component'; +import {NoTimezoneSetComponent} from './no-timezone-set.component'; +import {PatronService} from '@eg/staff/share/patron.service'; +import {BookingResourceBarcodeValidatorDirective} from './booking_resource_validator.directive'; + + +@NgModule({ + imports: [ + StaffCommonModule, + BookingRoutingModule, + ReactiveFormsModule, + ], + providers: [PatronService], + declarations: [ + CancelReservationDialogComponent, + CreateReservationComponent, + CreateReservationDialogComponent, + ManageReservationsComponent, + NoTimezoneSetComponent, + PickupComponent, + PullListComponent, + ReservationsGridComponent, + ReturnComponent, + BookingResourceBarcodeValidatorDirective + ], + exports: [ + BookingResourceBarcodeValidatorDirective + ] +}) +export class BookingModule { } + diff --git a/Open-ILS/src/eg2/src/app/staff/booking/booking_resource_validator.directive.ts b/Open-ILS/src/eg2/src/app/staff/booking/booking_resource_validator.directive.ts new file mode 100644 index 0000000000..5e3fa712be --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/booking_resource_validator.directive.ts @@ -0,0 +1,42 @@ +import {Directive, forwardRef, Injectable} from '@angular/core'; +import {NG_ASYNC_VALIDATORS, AsyncValidator, FormControl} from '@angular/forms'; +import {of} from 'rxjs'; +import {switchMap, catchError} from 'rxjs/operators'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {BookingModule} from './booking.module'; + +@Injectable({providedIn: BookingModule}) +export class BookingResourceBarcodeValidator implements AsyncValidator { + constructor( + private pcrud: PcrudService) { + } + + validate = (control: FormControl) => { + return this.pcrud.search('brsrc', + {'barcode' : control.value}, + {'limit': 1}).pipe( + switchMap(() => of(null)), + catchError((err) => { + return of({ resourceBarcode: 'No resource found with that barcode' }); + })); + } +} + +@Directive({ + selector: '[egValidBookingResourceBarcode]', + providers: [{ + provide: NG_ASYNC_VALIDATORS, + useExisting: forwardRef(() => BookingResourceBarcodeValidator), + multi: true + }] +}) +export class BookingResourceBarcodeValidatorDirective { + constructor( + private validator: BookingResourceBarcodeValidator + ) { } + + validate = (control: FormControl) => { + this.validator.validate(control); + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/booking/cancel-reservation-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/booking/cancel-reservation-dialog.component.ts new file mode 100644 index 0000000000..022ef96a38 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/cancel-reservation-dialog.component.ts @@ -0,0 +1,63 @@ +import {Component, EventEmitter, Output, ViewChild} from '@angular/core'; +import {switchMap} from 'rxjs/operators'; +import {AuthService} from '@eg/core/auth.service'; +import {NetService} from '@eg/core/net.service'; +import {ToastService} from '@eg/share/toast/toast.service'; +import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component'; + +@Component({ + selector: 'eg-cancel-reservation-dialog', + template: ` + + + + + Are you sure you want to cancel + {reservations.length, plural, =1 {this reservation} other {these {{reservations.length}} reservations}}? + + + ` +}) + +export class CancelReservationDialogComponent { + + constructor( + private auth: AuthService, + private net: NetService, + private toast: ToastService + ) { + } + + reservations: number[]; + + @ViewChild('confirmCancelReservationDialog') + private cancelReservationDialog: ConfirmDialogComponent; + + @Output() onSuccessfulCancel = new EventEmitter(); + + open(reservations: number[]) { + this.reservations = reservations; + this.cancelReservationDialog.open() + .pipe( + switchMap(() => this.net.request( + 'open-ils.booking', + 'open-ils.booking.reservations.cancel', + this.auth.token(), reservations)) + ) + .subscribe( + (res) => { + if (res.textcode) { + this.toast.danger('Could not cancel reservation'); // TODO: needs i18n, pluralization + } else { + this.toast.success('Reservation successfully canceled'); // TODO: needs i18n, pluralization + this.onSuccessfulCancel.emit(); + } + } + ); + } + +} + diff --git a/Open-ILS/src/eg2/src/app/staff/booking/create-reservation-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/booking/create-reservation-dialog.component.html new file mode 100644 index 0000000000..1fe01953e2 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/create-reservation-dialog.component.html @@ -0,0 +1,86 @@ + + + + + + + diff --git a/Open-ILS/src/eg2/src/app/staff/booking/create-reservation-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/booking/create-reservation-dialog.component.ts new file mode 100644 index 0000000000..759c8e477d --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/create-reservation-dialog.component.ts @@ -0,0 +1,203 @@ +import {Component, Input, Output, OnInit, ViewChild, EventEmitter} from '@angular/core'; +import {FormGroup, FormControl, Validators, ValidatorFn, ValidationErrors} from '@angular/forms'; +import {Router} from '@angular/router'; +import {Observable, of} from 'rxjs'; +import {switchMap, single, startWith, tap} from 'rxjs/operators'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {AuthService} from '@eg/core/auth.service'; +import {FormatService} from '@eg/core/format.service'; +import {IdlObject} from '@eg/core/idl.service'; +import {NetService} from '@eg/core/net.service'; +import {OrgService} from '@eg/core/org.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {DialogComponent} from '@eg/share/dialog/dialog.component'; +import {PatronBarcodeValidator} from '@eg/share/validators/patron_barcode_validator.directive'; +import {ToastService} from '@eg/share/toast/toast.service'; +import {AlertDialogComponent} from '@eg/share/dialog/alert.component'; +import {ComboboxEntry} from '@eg/share/combobox/combobox.component'; +import * as Moment from 'moment-timezone'; + +const startTimeIsBeforeEndTimeValidator: ValidatorFn = (fg: FormGroup): ValidationErrors | null => { + const start = fg.get('startTime').value; + const end = fg.get('endTime').value; + return start !== null && end !== null && + start.isBefore(end) + ? null + : { startTimeNotBeforeEndTime: true }; +}; + +@Component({ + selector: 'eg-create-reservation-dialog', + templateUrl: './create-reservation-dialog.component.html' +}) + +export class CreateReservationDialogComponent + extends DialogComponent implements OnInit { + + @Input() targetResource: number; + @Input() targetResourceBarcode: string; + @Input() targetResourceType: ComboboxEntry; + @Input() attributes: number[] = []; + @Input() resources: IdlObject[] = []; + @Output() onComplete: EventEmitter; + + create: FormGroup; + patron$: Observable<{first_given_name: string, second_given_name: string, family_name: string}>; + pickupLibId: number; + timezone: string = this.format.wsOrgTimezone; + pickupLibraryUsesDifferentTz: boolean; + + public disableOrgs: () => number[]; + addBresv$: () => Observable; + @ViewChild('fail') private fail: AlertDialogComponent; + + handlePickupLibChange: ($event: IdlObject) => void; + + constructor( + private auth: AuthService, + private format: FormatService, + private net: NetService, + private org: OrgService, + private pcrud: PcrudService, + private router: Router, + private modal: NgbModal, + private pbv: PatronBarcodeValidator, + private toast: ToastService + ) { + super(modal); + this.onComplete = new EventEmitter(); + } + + ngOnInit() { + + this.create = new FormGroup({ + // TODO: replace this control with a patron search form + // when available in the Angular client + 'patronBarcode': new FormControl('', + [Validators.required], + [this.pbv.validate] + ), + 'emailNotify': new FormControl(true), + 'startTime': new FormControl(), + 'endTime': new FormControl(), + 'resourceList': new FormControl(), + }, [startTimeIsBeforeEndTimeValidator] + ); + + this.addBresv$ = () => { + let selectedResourceId = this.targetResource ? [this.targetResource] : null; + if (!selectedResourceId && + this.resourceListSelection !== null && + 'any' !== this.resourceListSelection.id) { + selectedResourceId = [this.resourceListSelection.id]; + } + return this.net.request( + 'open-ils.booking', + 'open-ils.booking.reservations.create', + this.auth.token(), + this.patronBarcode.value, + this.selectedTimes, + this.pickupLibId, + this.targetResourceType.id, + selectedResourceId, + this.attributes.filter(Boolean), + this.emailNotify + ).pipe(tap( + (success) => { + if (success.ilsevent) { + console.warn(success); + this.fail.open(); + } else { + this.toast.success('Reservation successfully created'); + console.debug(success); + this.close(); + } + }, (fail) => { + console.warn(fail); + this.fail.open(); + }, () => this.onComplete.emit(true) + )); + }; + + this.handlePickupLibChange = ($event) => { + this.pickupLibId = $event.id(); + this.org.settings('lib.timezone', this.pickupLibId).then((tz) => { + this.timezone = tz['lib.timezone'] || this.format.wsOrgTimezone; + this.pickupLibraryUsesDifferentTz = (tz['lib.timezone'] && (this.format.wsOrgTimezone !== tz['lib.timezone'])); + }); + }; + + this.disableOrgs = () => this.org.filterList( { canHaveVolumes : false }, true); + + this.patron$ = this.patronBarcode.statusChanges.pipe( + startWith({first_given_name: '', second_given_name: '', family_name: ''}), + switchMap(() => { + if ('VALID' === this.patronBarcode.status) { + return this.net.request( + 'open-ils.actor', + 'open-ils.actor.get_barcodes', + this.auth.token(), + this.auth.user().ws_ou(), + 'actor', this.patronBarcode.value).pipe( + single(), + switchMap((result) => { + return this.pcrud.retrieve('au', result[0]['id']).pipe( + switchMap((au) => { + return of({ + first_given_name: au.first_given_name(), + second_given_name: au.second_given_name(), + family_name: au.family_name()}); + }) + ); + }) + ); + } else { + return of({ + first_given_name: '', + second_given_name: '', + family_name: '' + }); + } + }) + ); + } + + setDefaultTimes(times: Moment[], granularity: number) { + this.create.patchValue({startTime: Moment.min(times), + endTime: Moment.max(times).clone().add(granularity, 'minutes') + }); + } + + openPatronReservations = (): void => { + this.net.request( + 'open-ils.actor', + 'open-ils.actor.get_barcodes', + this.auth.token(), + this.auth.user().ws_ou(), + 'actor', this.patronBarcode.value + ).subscribe((patron) => this.router.navigate(['/staff', 'booking', 'manage_reservations', 'by_patron', patron[0]['id']])); + } + + addBresvAndOpenPatronReservations = (): void => { + this.addBresv$() + .subscribe(() => this.openPatronReservations()); + } + + get emailNotify() { + return this.create.get('emailNotify').value; + } + + get patronBarcode() { + return this.create.get('patronBarcode'); + } + + get resourceListSelection() { + return this.create.get('resourceList').value; + } + + get selectedTimes() { + return [this.create.get('startTime').value.toISOString(), + this.create.get('endTime').value.toISOString()]; + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/booking/create-reservation.component.html b/Open-ILS/src/eg2/src/app/staff/booking/create-reservation.component.html new file mode 100644 index 0000000000..a658af65d4 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/create-reservation.component.html @@ -0,0 +1,219 @@ + + + +{{attributes | json}} +{{selectedAttributes.value | json}} +
+
+
+
+
+
+ +
+ +
+
+
+
+
+ +
+ + +
+
+
+
+
+

Reservation details

+ + + + category + Choose resource by type + + +
+
+
+
+ +
+ + +
+
+ + +
+
+
+
+
+ + + + assignment + Choose resource by barcode + + +
+
+
+
+ +
+ +
+
+
+
+
+ + + + filter_list + Limit by attributes + + +
    +
  • + + + + + + + + + +
  • +
+
+
+ + + + settings + Schedule settings + + +
    +
  • + + + + + + +
  • +
  • + + + + + + +
  • +
  • + +
  • +
  • + + + + + + + + + + +
  • +
+
+
+
+
+
+ + +
+
+ +

{{idealDate | formatValue:'timestamp'}}

+ +
+ + + + + +
+
+ There are no bookable resource that match your criteria. + Would you like to create some new resources? +
+ + + + + + +
    +
  • + +
  • +
+
+
+ + + {{row['time'].format('LT')}} + + + {{row['time'] | formatValue:'timestamp'}} + + + + + + diff --git a/Open-ILS/src/eg2/src/app/staff/booking/create-reservation.component.ts b/Open-ILS/src/eg2/src/app/staff/booking/create-reservation.component.ts new file mode 100644 index 0000000000..23cddcb3ff --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/create-reservation.component.ts @@ -0,0 +1,417 @@ +import { Component, OnInit, AfterViewInit, QueryList, ViewChildren, ViewChild, OnDestroy } from '@angular/core'; +import {FormGroup, FormControl, ValidationErrors, ValidatorFn, FormArray} from '@angular/forms'; +import {Router, ActivatedRoute} from '@angular/router'; +import {from, iif, Observable, of, throwError, timer, Subscription} from 'rxjs'; +import {catchError, debounceTime, takeLast, mapTo, single, switchMap, tap} from 'rxjs/operators'; +import {NgbCalendar, NgbTabset} from '@ng-bootstrap/ng-bootstrap'; +import {AuthService} from '@eg/core/auth.service'; +import {ComboboxEntry} from '@eg/share/combobox/combobox.component'; +import {FormatService} from '@eg/core/format.service'; +import {GridComponent} from '@eg/share/grid/grid.component'; +import {GridDataSource, GridRowFlairEntry} from '@eg/share/grid/grid'; +import {IdlObject} from '@eg/core/idl.service'; +import {NetService} from '@eg/core/net.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {CreateReservationDialogComponent} from './create-reservation-dialog.component'; +import {ServerStoreService} from '@eg/core/server-store.service'; +import {ToastService} from '@eg/share/toast/toast.service'; +import {DateRange} from '@eg/share/daterange-select/daterange-select.component'; +import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component'; +import {ScheduleGridService, ScheduleRow} from './schedule-grid.service'; +import {NoTimezoneSetComponent} from './no-timezone-set.component'; + +import * as Moment from 'moment-timezone'; + +const startOfDayIsBeforeEndOfDayValidator: ValidatorFn = (fg: FormGroup): ValidationErrors | null => { + const start = fg.get('startOfDay').value; + const end = fg.get('endOfDay').value; + return start !== null && end !== null && + (start.hour <= end.hour) && + !((start.hour === end.hour) && (start.minute >= end.minute)) + ? null + : { startOfDayNotBeforeEndOfDay: true }; +}; + +@Component({ + templateUrl: './create-reservation.component.html', + styles: ['#ideal-resource-barcode {min-width: 300px;}'] +}) +export class CreateReservationComponent implements OnInit, AfterViewInit, OnDestroy { + + criteria: FormGroup; + + attributes: IdlObject[] = []; + multiday = false; + resourceAvailabilityIcon: (row: ScheduleRow) => GridRowFlairEntry; + + patronBarcode: string; + patronId: number; + resourceBarcode: string; + resourceId: number; + transferable: boolean; + resourceOwner: number; + subscriptions: Subscription[] = []; + + defaultGranularity = 30; + granularity: number = this.defaultGranularity; + + scheduleSource: GridDataSource = new GridDataSource(); + + minuteStep: () => number; + reservationTypes: {id: string, name: string}[]; + + openTheDialog: (rows: IdlObject[]) => void; + + resources: IdlObject[] = []; + + setGranularity: () => void; + changeGranularity: ($event: ComboboxEntry) => void; + + dateRange: DateRange; + + @ViewChild('createDialog') createDialog: CreateReservationDialogComponent; + @ViewChild('details') details: NgbTabset; + @ViewChild('noTimezoneSetDialog') noTimezoneSetDialog: NoTimezoneSetComponent; + @ViewChild('viewReservation') viewReservation: FmRecordEditorComponent; + @ViewChildren('scheduleGrid') scheduleGrids: QueryList; + + constructor( + private auth: AuthService, + private calendar: NgbCalendar, + private format: FormatService, + private net: NetService, + private pcrud: PcrudService, + private route: ActivatedRoute, + private router: Router, + private scheduleService: ScheduleGridService, + private store: ServerStoreService, + private toast: ToastService, + ) { + } + + ngOnInit() { + if (!(this.format.wsOrgTimezone)) { + this.noTimezoneSetDialog.open(); + } + + const initialRangeLength = 10; + const defaultRange = { + fromDate: this.calendar.getToday(), + toDate: this.calendar.getNext( + this.calendar.getToday(), 'd', initialRangeLength) + }; + + this.route.paramMap.pipe( + tap(params => { + this.patronId = +params.get('patron_id'); + this.resourceBarcode = params.get('resource_barcode'); + }), + switchMap(params => iif(() => params.has('resource_barcode'), + this.handleBarcodeFromUrl$(params.get('resource_barcode')), + of(params) + )) + ).subscribe({ + error() { + console.warn('could not find a resource with this barcode'); + } + }); + + this.reservationTypes = [ + {id: 'single', name: 'Single day reservation'}, + {id: 'multi', name: 'Multiple day reservation'}, + ]; + + const waitToLoadResource = 800; + this.criteria = new FormGroup({ + 'resourceBarcode': new FormControl(this.resourceBarcode ? this.resourceBarcode : '', + [], (rb) => + timer(waitToLoadResource).pipe(switchMap(() => + this.pcrud.search('brsrc', + {'barcode' : rb.value}, + {'limit': 1})), + single(), + mapTo(null), + catchError(() => of({ resourceBarcode: 'No resource found with that barcode' })) + )), + 'resourceType': new FormControl(), + 'startOfDay': new FormControl({hour: 9, minute: 0, second: 0}), + 'endOfDay': new FormControl({hour: 17, minute: 0, second: 0}), + 'idealDate': new FormControl(new Date()), + 'idealDateRange': new FormControl(defaultRange), + 'reservationType': new FormControl(), + 'owningLibrary': new FormControl({primaryOrgId: this.auth.user().ws_ou(), includeDescendants: true}), + 'selectedAttributes': new FormArray([]), + }, [ startOfDayIsBeforeEndOfDayValidator + ]); + + const debouncing = 1500; + this.criteria.get('resourceBarcode').valueChanges + .pipe(debounceTime(debouncing)) + .subscribe((barcode) => { + this.resources = []; + if ('INVALID' === this.criteria.get('resourceBarcode').status) { + this.toast.danger('No resource found with this barcode'); + } else { + this.router.navigate(['/staff', 'booking', 'create_reservation', 'for_resource', barcode]); + } + }); + + this.subscriptions.push( + this.resourceType.valueChanges.pipe( + switchMap((value) => { + this.resourceBarcode = null; + this.resources = []; + this.resourceId = null; + this.attributes = []; + // TODO: when we upgrade to Angular 8, this can + // be simplified to this.selectedAttributes.clear(); + while (this.selectedAttributes.length) { + this.selectedAttributes.removeAt(0); + } + if (value.id) { + return this.pcrud.search('bra', {resource_type : value.id}, { + order_by: 'name ASC', + flesh: 1, + flesh_fields: {'bra' : ['valid_values']} + }).pipe( + tap((attribute) => { + this.attributes.push(attribute); + this.selectedAttributes.push(new FormControl()); + }) + ); + } else { + return of(); + } + }) + ).subscribe(() => this.fetchData())); + + this.criteria.get('reservationType').valueChanges.subscribe((val) => { + this.multiday = ('multi' === val.id); + this.store.setItem('eg.booking.create.multiday', this.multiday); + }); + + this.subscriptions.push( + this.owningLibraryFamily.valueChanges + .subscribe(() => this.resources = [])); + + this.subscriptions.push( + this.criteria.valueChanges + .subscribe(() => this.fetchData())); + + this.store.getItem('eg.booking.create.multiday').then(multiday => { + if (multiday) { this.multiday = multiday; } + this.criteria.patchValue({reservationType: + this.multiday ? this.reservationTypes[1] : this.reservationTypes[0] + }); + }); + + const minutesInADay = 1440; + + this.setGranularity = () => { + if (this.multiday) { // multiday reservations always use day granularity + this.granularity = minutesInADay; + } else { + this.store.getItem('eg.booking.create.granularity').then(granularity => { + if (granularity) { + this.granularity = granularity; + } else { + this.granularity = this.defaultGranularity; + } + }); + } + }; + + this.criteria.get('idealDate').valueChanges + .pipe(switchMap((date) => this.scheduleService.hoursOfOperation(date))) + .subscribe((hours) => this.criteria.patchValue(hours, {emitEvent: false}), + () => {}, + () => this.fetchData()); + + this.changeGranularity = ($event) => { + this.granularity = $event.id; + this.store.setItem('eg.booking.create.granularity', $event.id) + .then(() => this.fetchData()); + }; + + const minutesInAnHour = 60; + + this.minuteStep = () => { + return (this.granularity < minutesInAnHour) ? this.granularity : this.defaultGranularity; + }; + + this.resourceAvailabilityIcon = (row: ScheduleRow) => { + return this.scheduleService.resourceAvailabilityIcon(row, this.resources.length); + }; + } + + ngAfterViewInit() { + this.fetchData(); + + this.openTheDialog = (rows: IdlObject[]) => { + if (rows && rows.length) { + this.createDialog.setDefaultTimes(rows.map((row) => row['time'].clone()), this.granularity); + } + this.subscriptions.push( + this.createDialog.open({size: 'lg'}) + .subscribe(() => this.fetchData()) + ); + }; + } + + fetchData = (): void => { + this.setGranularity(); + this.scheduleSource.data = []; + let resources$ = this.scheduleService.fetchRelevantResources( + this.resourceType.value ? this.resourceType.value.id : null, + this.owningLibraries, + this.flattenedSelectedAttributes + ); + if (this.resourceId) { + resources$ = from(this.resources); + } else { + this.resources = []; + } + + resources$.pipe( + tap((resource) => this.resources.push(resource)), + takeLast(1), + switchMap(() => { + let range = {startTime: Moment(), endTime: Moment()}; + + if (this.multiday) { + range = this.scheduleService.momentizeDateRange( + this.idealDateRange, + this.format.wsOrgTimezone + ); + } else { + range = this.scheduleService.momentizeDay( + this.idealDate, + this.userStartOfDay, + this.userEndOfDay, + this.format.wsOrgTimezone + ); + } + this.scheduleSource.data = this.scheduleService.createBasicSchedule( + range, this.granularity); + return this.scheduleService.fetchReservations(range, this.resources.map(r => r.id())); + }) + ).subscribe((reservation) => { + this.scheduleSource.data = this.scheduleService.addReservationToSchedule( + reservation, + this.scheduleSource.data, + this.granularity, + this.format.wsOrgTimezone + ); + }); + } + // TODO: make this into cross-field validation, and don't fetch data if true + invalidMultidaySettings(): boolean { + return (this.multiday && (!this.idealDateRange || + (null == this.idealDateRange.fromDate) || + (null == this.idealDateRange.toDate))); + } + + handleBarcodeFromUrl$(barcode: string): Observable { + return this.findResourceByBarcode$(barcode) + .pipe( + catchError(() => this.handleBrsrcError$(barcode)), + tap((resource) => { + if (resource) { + this.resourceId = resource.id(); + this.criteria.patchValue({ + resourceType: {id: resource.type()}}, + {emitEvent: false}); + this.resources = [resource]; + this.details.select('select-resource'); + this.fetchData(); + } + }) + ); + } + + findResourceByBarcode$(barcode: string): Observable { + return this.pcrud.search('brsrc', + {'barcode' : barcode}, {'limit': 1}) + .pipe(single()); + } + + handleBrsrcError$(barcode: string): Observable { + return this.tryToMakeThisBookable$(barcode) + .pipe(switchMap(() => this.findResourceByBarcode$(barcode)), + catchError(() => { + this.toast.danger('No resource found with this barcode'); + this.resourceId = -1; + return throwError('could not find or create a resource'); + })); + } + + tryToMakeThisBookable$(barcode: string): Observable { + return this.pcrud.search('acp', + {'barcode' : barcode}, {'limit': 1}) + .pipe(single(), + switchMap((item) => + this.net.request( 'open-ils.booking', + 'open-ils.booking.resources.create_from_copies', + this.auth.token(), [item.id()]) + ), + catchError(() => { + this.toast.danger('Cannot make this barcode bookable'); + return throwError('Tried and failed to make that barcode bookable'); + }), + tap((response) => { + this.toast.info('Made this barcode bookable'); + this.resourceId = response['brsrc'][0][0]; + })); + } + + addDays = (days: number): void => { + const result = new Date(this.idealDate); + result.setDate(result.getDate() + days); + this.criteria.patchValue({idealDate: result}); + } + + openReservationViewer = (id: number): void => { + this.viewReservation.mode = 'view'; + this.viewReservation.recId = id; + this.viewReservation.open({ size: 'lg' }); + } + + get resourceType() { + return this.criteria.get('resourceType'); + } + get userStartOfDay() { + return this.criteria.get('startOfDay').value; + } + get userEndOfDay() { + return this.criteria.get('endOfDay').value; + } + get idealDate() { + return this.criteria.get('idealDate').value; + } + get idealDateRange() { + return this.criteria.get('idealDateRange').value; + } + get owningLibraryFamily() { + return this.criteria.get('owningLibrary'); + } + get owningLibraries() { + if (this.criteria.get('owningLibrary').value.orgIds) { + return this.criteria.get('owningLibrary').value.orgIds; + } else { + return [this.criteria.get('owningLibrary').value.primaryOrgId]; + } + } + get selectedAttributes() { + return this.criteria.get('selectedAttributes'); + } + get flattenedSelectedAttributes(): number[] { + return this.selectedAttributes.value.filter(Boolean).map((entry) => entry.id); + } + ngOnDestroy(): void { + this.subscriptions.forEach((subscription) => { + subscription.unsubscribe(); + }); + } + +} + diff --git a/Open-ILS/src/eg2/src/app/staff/booking/manage-reservations.component.html b/Open-ILS/src/eg2/src/app/staff/booking/manage-reservations.component.html new file mode 100644 index 0000000000..b625672a1b --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/manage-reservations.component.html @@ -0,0 +1,72 @@ + + + + +
+
+ + +
+
+
+

Filter reservations

+ + + + filter_list Filter by patron + + +
+
+
+ +
+ +
+ +
+
+
+
+
+ + + filter_list Filter by resource + + +
+
+
+ +
+ +
+ +
+
+
+
+
+ + + filter_list Filter by resource type + + +
+
+
+ +
+ +
+ +
+
+
+
+
+
+
+
+
+ diff --git a/Open-ILS/src/eg2/src/app/staff/booking/manage-reservations.component.ts b/Open-ILS/src/eg2/src/app/staff/booking/manage-reservations.component.ts new file mode 100644 index 0000000000..239e1bf01b --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/manage-reservations.component.ts @@ -0,0 +1,188 @@ +import {Component, OnInit, ViewChild, OnDestroy} from '@angular/core'; +import {FormGroup, FormControl} from '@angular/forms'; +import {Router, ActivatedRoute, ParamMap} from '@angular/router'; +import {Subscription, of, from} from 'rxjs'; +import {debounceTime, single, tap, switchMap} from 'rxjs/operators'; +import {NgbTabset} from '@ng-bootstrap/ng-bootstrap'; +import {AuthService} from '@eg/core/auth.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {ReservationsGridComponent} from './reservations-grid.component'; +import {ServerStoreService} from '@eg/core/server-store.service'; +import {ToastService} from '@eg/share/toast/toast.service'; +import {NetService} from '@eg/core/net.service'; +import {PatronBarcodeValidator} from '@eg/share/validators/patron_barcode_validator.directive'; +import {BookingResourceBarcodeValidator} from './booking_resource_validator.directive'; +import {OrgFamily} from '@eg/share/org-family-select/org-family-select.component'; + +@Component({ + selector: 'eg-manage-reservations', + templateUrl: './manage-reservations.component.html', +}) +export class ManageReservationsComponent implements OnInit, OnDestroy { + + patronId: number; + resourceId: number; + subscriptions: Subscription[] = []; + filters: FormGroup; + startingTab: 'patron' | 'resource' | 'type' = 'patron'; + startingPickupOrgs: OrgFamily = {primaryOrgId: this.auth.user().ws_ou(), includeDescendants: true}; + + @ViewChild('filterTabs') filterTabs: NgbTabset; + @ViewChild('reservationsGrid') reservationsGrid: ReservationsGridComponent; + + removeFilters: () => void; + + constructor( + private route: ActivatedRoute, + private router: Router, + private auth: AuthService, + private net: NetService, + private pcrud: PcrudService, + private store: ServerStoreService, + private toast: ToastService, + private patronValidator: PatronBarcodeValidator, + private resourceValidator: BookingResourceBarcodeValidator + ) { + this.store.getItem('eg.booking.manage.selected_org_family').then((pickupLibs) => { + if (pickupLibs) { + this.startingPickupOrgs = pickupLibs; + } + }); + } + + ngOnInit() { + this.filters = new FormGroup({ + 'pickupLibraries': new FormControl(this.startingPickupOrgs), + 'patronBarcode': new FormControl('', [], [this.patronValidator.validate]), + 'resourceBarcode': new FormControl('', [], [this.resourceValidator.validate]), + 'resourceType': new FormControl(null), + }); + + const debouncing = 300; + + this.subscriptions.push( + this.pickupLibraries.valueChanges.pipe( + ).subscribe(() => this.reservationsGrid.reloadGrid())); + + this.subscriptions.push( + this.patronBarcode.statusChanges.pipe( + debounceTime(debouncing), + switchMap((status) => { + if ('VALID' === status) { + return this.net.request( + 'open-ils.actor', + 'open-ils.actor.get_barcodes', + this.auth.token(), this.auth.user().ws_ou(), + 'actor', this.patronBarcode.value).pipe( + single(), + tap((response) => + this.router.navigate(['/staff', 'booking', 'manage_reservations', 'by_patron', response[0].id]) + )); + } else { + this.toast.danger('No patron found with this barcode'); + return of(); + }}) + ).subscribe()); + + this.subscriptions.push( + this.resourceBarcode.statusChanges.pipe( + debounceTime(debouncing), + tap((status) => { + if ('VALID' === status) { + if (this.resourceBarcode.value) { + this.router.navigate(['/staff', 'booking', 'manage_reservations', 'by_resource', this.resourceBarcode.value]); + } else { + this.removeFilters(); + } + } + } + )).subscribe()); + + this.subscriptions.push( + this.resourceType.valueChanges.pipe( + tap((value) => { + if (value) { + this.router.navigate(['/staff', 'booking', 'manage_reservations', 'by_resource_type', value.id]); + } else { + this.removeFilters(); + } + } + )).subscribe()); + + this.subscriptions.push( + this.pickupLibraries.valueChanges.pipe( + tap((value) => this.store.setItem('eg.booking.manage.selected_org_family', value)) + ).subscribe()); + + this.removeFilters = () => { + this.router.navigate(['/staff', 'booking', 'manage_reservations']); + }; + + + this.route.paramMap.pipe( + switchMap((params: ParamMap) => { + this.patronId = params.has('patron_id') ? +params.get('patron_id') : null; + this.filters.patchValue({resourceBarcode: params.get('resource_barcode')}, {emitEvent: false}); + this.filters.patchValue({resourceType: {id: +params.get('resource_type_id')}}, {emitEvent: false}); + + if (this.patronId) { + return this.pcrud.search('au', { + 'id': this.patronId, + }, { + limit: 1, + flesh: 1, + flesh_fields: {'au': ['card']} + }).pipe(tap( + (resp) => { + this.filters.patchValue({patronBarcode: resp.card().barcode()}); }, + (err) => { console.debug(err); } + )); + } else if (this.resourceBarcode.value) { + this.startingTab = 'resource'; + return this.pcrud.search('brsrc', + {'barcode' : this.resourceBarcode.value}, {'limit': 1}).pipe( + tap((res) => { + this.resourceId = res.id(); + }, (err) => { + this.resourceId = -1; + this.toast.danger('No resource found with this barcode'); + })); + } else if (this.resourceType.value) { + this.startingTab = 'type'; + return of(null); + } else { + return of(null); + } + + })).subscribe(); + } + + get pickupLibraries() { + return this.filters.get('pickupLibraries'); + } + get patronBarcode() { + return this.filters.get('patronBarcode'); + } + get resourceBarcode() { + return this.filters.get('resourceBarcode'); + } + get resourceType() { + return this.filters.get('resourceType'); + } + get pickupLibrariesForGrid() { + return this.pickupLibraries.value ? + this.pickupLibraries.value.orgIds : + [this.auth.user().ws_ou()]; + } + get resourceTypeForGrid() { + return this.resourceType.value ? this.resourceType.value.id : null; + } + + ngOnDestroy(): void { + this.subscriptions.forEach((subscription) => { + subscription.unsubscribe(); + }); + } + +} + diff --git a/Open-ILS/src/eg2/src/app/staff/booking/no-timezone-set.component.html b/Open-ILS/src/eg2/src/app/staff/booking/no-timezone-set.component.html new file mode 100644 index 0000000000..9d8e646e81 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/no-timezone-set.component.html @@ -0,0 +1,17 @@ + + + + + diff --git a/Open-ILS/src/eg2/src/app/staff/booking/no-timezone-set.component.ts b/Open-ILS/src/eg2/src/app/staff/booking/no-timezone-set.component.ts new file mode 100644 index 0000000000..c613d1f7a4 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/no-timezone-set.component.ts @@ -0,0 +1,16 @@ +import {Component} from '@angular/core'; +import {DialogComponent} from '@eg/share/dialog/dialog.component'; + +@Component({ + selector: 'eg-no-timezone-set-dialog', + templateUrl: './no-timezone-set.component.html' +}) + +/** + * Dialog that warns users that there is no valid lib.timezone setting + */ +export class NoTimezoneSetComponent extends DialogComponent { + openLSE(): void { + window.open('/eg/staff/admin/local/asset/org_unit_settings', '_blank'); + } +} diff --git a/Open-ILS/src/eg2/src/app/staff/booking/pickup.component.html b/Open-ILS/src/eg2/src/app/staff/booking/pickup.component.html new file mode 100644 index 0000000000..0ec465d8ec --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/pickup.component.html @@ -0,0 +1,27 @@ + + + + +
+
+
+
+ + +
+
+
+
+
+

Ready for pickup

+
+ + +
+ + +

Already picked up

+ + +
+ diff --git a/Open-ILS/src/eg2/src/app/staff/booking/pickup.component.ts b/Open-ILS/src/eg2/src/app/staff/booking/pickup.component.ts new file mode 100644 index 0000000000..cec32c2df9 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/pickup.component.ts @@ -0,0 +1,110 @@ +import {Component, OnInit, ViewChild, OnDestroy} from '@angular/core'; +import {Router, ActivatedRoute, ParamMap} from '@angular/router'; +import {Subscription, of} from 'rxjs'; +import {single, filter, switchMap, debounceTime, tap} from 'rxjs/operators'; +import {PatronService} from '@eg/staff/share/patron.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {IdlObject} from '@eg/core/idl.service'; +import {ReservationsGridComponent} from './reservations-grid.component'; +import {ServerStoreService} from '@eg/core/server-store.service'; +import {ToastService} from '@eg/share/toast/toast.service'; +import {FormControl, FormGroup, Validators} from '@angular/forms'; +import {PatronBarcodeValidator} from '@eg/share/validators/patron_barcode_validator.directive'; + + +@Component({ + templateUrl: './pickup.component.html' +}) + +export class PickupComponent implements OnInit, OnDestroy { + patronId: number; + findPatron: FormGroup; + subscriptions: Subscription[] = []; + onlyShowCaptured = true; + + @ViewChild('readyGrid') readyGrid: ReservationsGridComponent; + @ViewChild('pickedUpGrid') pickedUpGrid: ReservationsGridComponent; + + noSelectedRows: (rows: IdlObject[]) => boolean; + handleShowCapturedChange: () => void; + retrievePatron: () => void; + + constructor( + private pcrud: PcrudService, + private patron: PatronService, + private pbv: PatronBarcodeValidator, + private route: ActivatedRoute, + private router: Router, + private store: ServerStoreService, + private toast: ToastService + ) { + } + + + ngOnInit() { + this.findPatron = new FormGroup({ + 'patronBarcode': new FormControl(null, + [Validators.required], + [this.pbv.validate]) + }); + + this.route.paramMap.pipe( + filter((params: ParamMap) => params.has('patron_id')), + switchMap((params: ParamMap) => { + this.patronId = +params.get('patron_id'); + return this.pcrud.search('au', { + 'id': this.patronId, + }, { + limit: 1, + flesh: 1, + flesh_fields: {'au': ['card']}}); + }) + ).subscribe( + (response) => { + this.findPatron.patchValue({patronBarcode: response.card().barcode()}, {emitEvent: false}); + this.readyGrid.reloadGrid(); + this.pickedUpGrid.reloadGrid(); + } + ); + + const debouncing = 1500; + this.subscriptions.push( + this.patronBarcode.valueChanges.pipe( + debounceTime(debouncing), + switchMap((val) => { + if ('INVALID' === this.patronBarcode.status) { + this.toast.danger('No patron found with this barcode'); + return of(); + } else { + return this.patron.bcSearch(val).pipe( + single(), + tap((resp) => { this.router.navigate(['/staff', 'booking', 'pickup', 'by_patron', resp[0].id]); }) + ); + } + }) + ) + .subscribe()); + + + this.store.getItem('eg.booking.pickup.ready.only_show_captured').then(onlyCaptured => { + if (onlyCaptured != null) { this.onlyShowCaptured = onlyCaptured; } + }); + this.handleShowCapturedChange = () => { + this.onlyShowCaptured = !this.onlyShowCaptured; + this.readyGrid.reloadGrid(); + this.store.setItem('eg.booking.pickup.ready.only_show_captured', this.onlyShowCaptured); + }; + + + } + get patronBarcode() { + return this.findPatron.get('patronBarcode'); + } + + ngOnDestroy(): void { + this.subscriptions.forEach((subscription) => { + subscription.unsubscribe(); + }); + } + +} diff --git a/Open-ILS/src/eg2/src/app/staff/booking/pull-list.component.html b/Open-ILS/src/eg2/src/app/staff/booking/pull-list.component.html new file mode 100644 index 0000000000..d6715dd59e --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/pull-list.component.html @@ -0,0 +1,47 @@ + + + + +
+
+
+
+ +
+ + +
+
+
+
+
+ +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + diff --git a/Open-ILS/src/eg2/src/app/staff/booking/pull-list.component.ts b/Open-ILS/src/eg2/src/app/staff/booking/pull-list.component.ts new file mode 100644 index 0000000000..745c52d830 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/pull-list.component.ts @@ -0,0 +1,127 @@ +import {Component, OnInit, ViewChild} from '@angular/core'; +import {FormControl, FormGroup, Validators} from '@angular/forms'; +import {from, Observable, of} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; +import {AuthService} from '@eg/core/auth.service'; +import {GridDataSource} from '@eg/share/grid/grid'; +import {IdlObject} from '@eg/core/idl.service'; +import {NetService} from '@eg/core/net.service'; +import {OrgService} from '@eg/core/org.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {ReservationActionsService} from './reservation-actions.service'; +import {CancelReservationDialogComponent} from './cancel-reservation-dialog.component'; + +// The data that comes from the API, along with some fleshing +interface PullListRow { + call_number?: string; + call_number_sortkey?: string; + current_resource: IdlObject; + reservations: IdlObject[]; + shelving_location?: string; + target_resource_type: IdlObject; +} + +@Component({ + templateUrl: './pull-list.component.html' +}) + +export class PullListComponent implements OnInit { + @ViewChild('confirmCancelReservationDialog') + private cancelReservationDialog: CancelReservationDialogComponent; + + public dataSource: GridDataSource; + + public disableOrgs: () => number[]; + public fillGrid: (orgId?: number) => void; + pullListCriteria: FormGroup; + + constructor( + private auth: AuthService, + private net: NetService, + private org: OrgService, + private pcrud: PcrudService, + private actions: ReservationActionsService, + ) { } + + + ngOnInit() { + this.dataSource = new GridDataSource(); + + const defaultDaysHence = 5; + + this.pullListCriteria = new FormGroup({ + 'daysHence': new FormControl(defaultDaysHence, [ + Validators.required, + Validators.min(1)]) + }); + + this.pullListCriteria.valueChanges.subscribe(() => this.fillGrid() ); + + this.disableOrgs = () => this.org.filterList( { canHaveVolumes : false }, true); + + this.fillGrid = (orgId = this.auth.user().ws_ou()) => { + this.dataSource.data = []; + const numberOfSecondsInADay = 86400; + this.net.request( + 'open-ils.booking', 'open-ils.booking.reservations.get_pull_list', + this.auth.token(), null, + (this.daysHence.value * numberOfSecondsInADay), + orgId + ).pipe(switchMap((resources) => from(resources)), + switchMap((resource: PullListRow) => this.fleshResource(resource)) + ) + .subscribe((resource) => this.dataSource.data.push(resource)); + }; + } + + noSelectedRows = (rows: IdlObject[]) => (rows.length === 0); + + notOneResourceSelected = (rows: IdlObject[]) => { + return this.actions.notOneUniqueSelected( + rows.map(row => { if (row['current_resource']) { return row['current_resource']['id']; }})); + } + + notOneCatalogedItemSelected = (rows: IdlObject[]) => { + return this.actions.notOneUniqueSelected( + rows.filter(row => (row['current_resource'] && row['call_number'])) + .map(row => row['current_resource'].id()) + ); + } + + cancelSelected = (rows: IdlObject[]) => { + this.cancelReservationDialog.open(rows.map(row => row['reservations'][0].id())); + } + + fleshResource = (resource: PullListRow): Observable => { + if ('t' === resource['target_resource_type'].catalog_item()) { + return this.pcrud.search('acp', { + 'barcode': resource['current_resource'].barcode() + }, { + limit: 1, + flesh: 1, + flesh_fields: {'acp' : ['call_number', 'location' ]} + }).pipe(switchMap((acp) => { + resource['call_number'] = acp.call_number().label(); + resource['call_number_sortkey'] = acp.call_number().label_sortkey(); + resource['shelving_location'] = acp.location().name(); + return of(resource); + })); + } else { + return of(resource); + } + } + + viewByResource = (reservations: IdlObject[]) => { + this.actions.manageReservationsByResource(reservations[0]['current_resource'].barcode()); + } + + viewItemStatus = (reservations: IdlObject[]) => { + this.actions.viewItemStatus(reservations[0]['current_resource'].barcode()); + } + + get daysHence() { + return this.pullListCriteria.get('daysHence'); + } + +} + diff --git a/Open-ILS/src/eg2/src/app/staff/booking/reservation-actions.service.ts b/Open-ILS/src/eg2/src/app/staff/booking/reservation-actions.service.ts new file mode 100644 index 0000000000..5545d06225 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/reservation-actions.service.ts @@ -0,0 +1,32 @@ +import {Injectable} from '@angular/core'; +import {Router} from '@angular/router'; +import {PcrudService} from '@eg/core/pcrud.service'; + +// Some grid actions that are shared across booking grids + +@Injectable({providedIn: 'root'}) +export class ReservationActionsService { + + constructor( + private pcrud: PcrudService, + private router: Router, + ) { + } + + manageReservationsByResource = (barcode: string) => { + this.router.navigate(['/staff', 'booking', 'manage_reservations', 'by_resource', barcode]); + } + + viewItemStatus = (barcode: string) => { + this.pcrud.search('acp', { 'barcode': barcode }, { limit: 1 }) + .subscribe((acp) => { + window.open('/eg/staff/cat/item/' + acp.id()); + }); + } + + notOneUniqueSelected = (ids: number[]) => { + return (new Set(ids).size !== 1); + } + +} + diff --git a/Open-ILS/src/eg2/src/app/staff/booking/reservation-actions.spec.ts b/Open-ILS/src/eg2/src/app/staff/booking/reservation-actions.spec.ts new file mode 100644 index 0000000000..10f8549a52 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/reservation-actions.spec.ts @@ -0,0 +1,35 @@ +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { PcrudService } from '@eg/core/pcrud.service'; +import { ReservationActionsService } from './reservation-actions.service'; +describe('ReservationActionsService', () => { + let service: ReservationActionsService; + const routerSpy = { + navigate: jasmine.createSpy('navigate') + }; + beforeEach(() => { + const pcrudServiceStub = {}; + TestBed.configureTestingModule({ + providers: [ + ReservationActionsService, + { provide: Router, useValue: routerSpy }, + { provide: PcrudService, useValue: pcrudServiceStub } + ] + }); + service = TestBed.get(ReservationActionsService); + }); + it('can open the manage by barcode route', () => { + service.manageReservationsByResource('barcode123'); + expect(routerSpy.navigate).toHaveBeenCalledWith( + ['/staff', 'booking', 'manage_reservations', 'by_resource', 'barcode123']); + }); + it('recognizes 3 as one unique value', () => { + expect(service.notOneUniqueSelected([3])).toBe(false); + }); + it('recognizes 1 1 as one unique value', () => { + expect(service.notOneUniqueSelected([1, 1])).toBe(false); + }); + it('recognizes 2 3 as more than one unique value', () => { + expect(service.notOneUniqueSelected([2, 3])).toBe(true); + }); +}); diff --git a/Open-ILS/src/eg2/src/app/staff/booking/reservations-grid.component.html b/Open-ILS/src/eg2/src/app/staff/booking/reservations-grid.component.html new file mode 100644 index 0000000000..ab8d923668 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/reservations-grid.component.html @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Open-ILS/src/eg2/src/app/staff/booking/reservations-grid.component.ts b/Open-ILS/src/eg2/src/app/staff/booking/reservations-grid.component.ts new file mode 100644 index 0000000000..b14a8111e2 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/reservations-grid.component.ts @@ -0,0 +1,302 @@ +import {Component, EventEmitter, Input, Output, OnInit, ViewChild} from '@angular/core'; +import {Router} from '@angular/router'; +import {Observable, from, of} from 'rxjs'; +import {tap, switchMap, mergeMap} from 'rxjs/operators'; +import {AuthService} from '@eg/core/auth.service'; +import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component'; +import {FormatService} from '@eg/core/format.service'; +import {GridComponent} from '@eg/share/grid/grid.component'; +import {GridDataSource} from '@eg/share/grid/grid'; +import {IdlObject} from '@eg/core/idl.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {Pager} from '@eg/share/util/pager'; +import {ToastService} from '@eg/share/toast/toast.service'; +import {NetService} from '@eg/core/net.service'; +import {OrgService} from '@eg/core/org.service'; +import {NoTimezoneSetComponent} from './no-timezone-set.component'; +import {ReservationActionsService} from './reservation-actions.service'; +import {CancelReservationDialogComponent} from './cancel-reservation-dialog.component'; + +import * as Moment from 'moment-timezone'; + +@Component({ + selector: 'eg-reservations-grid', + templateUrl: './reservations-grid.component.html', +}) +export class ReservationsGridComponent implements OnInit { + + @Input() patron: number; + @Input() resourceBarcode: string; + @Input() resourceType: number; + @Input() pickupLibIds: number[]; + @Input() status: 'pickupReady' | 'pickedUp' | 'returnReady' | 'returnedToday'; + @Input() persistSuffix: string; + @Input() onlyCaptured = false; + + @Output() onPickup = new EventEmitter(); + + gridSource: GridDataSource; + patronBarcode: string; + numRowsSelected: number; + + @ViewChild('grid') grid: GridComponent; + @ViewChild('editDialog') editDialog: FmRecordEditorComponent; + @ViewChild('confirmCancelReservationDialog') + private cancelReservationDialog: CancelReservationDialogComponent; + @ViewChild('noTimezoneSetDialog') noTimezoneSetDialog: NoTimezoneSetComponent; + + editSelected: (rows: IdlObject[]) => void; + pickupSelected: (rows: IdlObject[]) => void; + pickupResource: (rows: IdlObject) => Observable; + returnSelected: (rows: IdlObject[]) => void; + returnResource: (rows: IdlObject) => Observable; + cancelSelected: (rows: IdlObject[]) => void; + viewByPatron: (rows: IdlObject[]) => void; + viewByResource: (rows: IdlObject[]) => void; + viewItemStatus: (rows: IdlObject[]) => void; + viewPatronRecord: (rows: IdlObject[]) => void; + listReadOnlyFields: () => string; + + handleRowActivate: (row: IdlObject) => void; + redirectToCreate: () => void; + + reloadGrid: () => void; + + noSelectedRows: (rows: IdlObject[]) => boolean; + notOnePatronSelected: (rows: IdlObject[]) => boolean; + notOneResourceSelected: (rows: IdlObject[]) => boolean; + notOneCatalogedItemSelected: (rows: IdlObject[]) => boolean; + cancelNotAppropriate: (rows: IdlObject[]) => boolean; + pickupNotAppropriate: (rows: IdlObject[]) => boolean; + editNotAppropriate: (rows: IdlObject[]) => boolean; + returnNotAppropriate: (rows: IdlObject[]) => boolean; + + constructor( + private auth: AuthService, + private format: FormatService, + private pcrud: PcrudService, + private router: Router, + private toast: ToastService, + private net: NetService, + private org: OrgService, + private actions: ReservationActionsService, + ) { + + } + + ngOnInit() { + if (!(this.format.wsOrgTimezone)) { + this.noTimezoneSetDialog.open(); + } + + this.gridSource = new GridDataSource(); + + this.gridSource.getRows = (pager: Pager, sort: any[]): Observable => { + const orderBy: any = {}; + const where = { + 'usr' : (this.patron ? this.patron : {'>' : 0}), + 'target_resource_type' : (this.resourceType ? this.resourceType : {'>' : 0}), + 'cancel_time' : null, + 'xact_finish' : null, + }; + if (this.resourceBarcode) { + where['current_resource'] = {'in': + {'from': 'brsrc', 'select': {'brsrc': ['id']}, 'where': {'barcode': this.resourceBarcode}}}; + } + if (this.pickupLibIds) { + where['pickup_lib'] = this.pickupLibIds; + } + if (this.onlyCaptured) { + where['capture_time'] = {'!=': null}; + } + + if (this.status) { + if ('pickupReady' === this.status) { + where['pickup_time'] = null; + where['start_time'] = {'!=': null}; + } else if ('pickedUp' === this.status || 'returnReady' === this.status) { + where['pickup_time'] = {'!=': null}; + where['return_time'] = null; + } else if ('returnedToday' === this.status) { + where['return_time'] = {'>': Moment().startOf('day').toISOString()}; + } + } else { + where['return_time'] = null; + } + if (sort.length) { + orderBy.bresv = sort[0].name + ' ' + sort[0].dir; + } + return this.pcrud.search('bresv', where, { + order_by: orderBy, + limit: pager.limit, + offset: pager.offset, + flesh: 2, + flesh_fields: {'bresv' : [ + 'usr', 'capture_staff', 'target_resource', 'target_resource_type', 'current_resource', 'request_lib', 'pickup_lib' + ], 'au': ['card'] } + }).pipe(mergeMap((row) => this.enrichRow$(row))); + }; + + this.editDialog.mode = 'update'; + this.editSelected = (idlThings: IdlObject[]) => { + const editOneThing = (thing: IdlObject) => { + if (!thing) { return; } + this.showEditDialog(thing).subscribe( + () => editOneThing(idlThings.shift())); + }; + editOneThing(idlThings.shift()); }; + + this.cancelSelected = (reservations: IdlObject[]) => { + this.cancelReservationDialog.open(reservations.map(reservation => reservation.id())); + }; + + this.viewByResource = (reservations: IdlObject[]) => { + this.actions.manageReservationsByResource(reservations[0].current_resource().barcode()); + }; + + this.viewByPatron = (reservations: IdlObject[]) => { + const patronIds = reservations.map(reservation => reservation.usr().id()); + this.router.navigate(['/staff', 'booking', 'manage_reservations', 'by_patron', patronIds[0]]); + }; + + this.viewItemStatus = (reservations: IdlObject[]) => { + this.actions.viewItemStatus(reservations[0].current_resource().barcode()); + }; + + this.viewPatronRecord = (reservations: IdlObject[]) => { + const patronIds = reservations.map(reservation => reservation.usr().id()); + window.open('/eg/staff/circ/patron/' + patronIds[0] + '/checkout'); + }; + + this.noSelectedRows = (rows: IdlObject[]) => (rows.length === 0); + this.notOnePatronSelected = (rows: IdlObject[]) => this.actions.notOneUniqueSelected(rows.map(row => row.usr().id())); + this.notOneResourceSelected = (rows: IdlObject[]) => { + return this.actions.notOneUniqueSelected( + rows.map(row => { if (row.current_resource()) { return row.current_resource().id(); }})); + }; + this.notOneCatalogedItemSelected = (rows: IdlObject[]) => { + return this.actions.notOneUniqueSelected( + rows.filter(row => (row.current_resource() && 't' === row.target_resource_type().catalog_item())) + .map(row => row.current_resource().id()) + ); + }; + this.cancelNotAppropriate = (rows: IdlObject[]) => + (this.noSelectedRows(rows) || ['pickedUp', 'returnReady', 'returnedToday'].includes(this.status)); + this.pickupNotAppropriate = (rows: IdlObject[]) => (this.noSelectedRows(rows) || ('pickupReady' !== this.status)); + this.editNotAppropriate = (rows: IdlObject[]) => (this.noSelectedRows(rows) || ('returnedToday' === this.status)); + this.returnNotAppropriate = (rows: IdlObject[]) => { + if (this.noSelectedRows(rows)) { + return true; + } else if (this.status && ('pickupReady' === this.status)) { + return true; + } else { + rows.forEach(row => { + if ((null == row.pickup_time()) || row.return_time()) { return true; } + }); + } + return false; + }; + + this.reloadGrid = () => { this.grid.reload(); }; + + this.pickupSelected = (reservations: IdlObject[]) => { + const pickupOne = (thing: IdlObject) => { + if (!thing) { return; } + this.pickupResource(thing).subscribe( + () => pickupOne(reservations.shift())); + }; + pickupOne(reservations.shift()); + }; + + this.returnSelected = (reservations: IdlObject[]) => { + const returnOne = (thing: IdlObject) => { + if (!thing) { return; } + this.returnResource(thing).subscribe( + () => returnOne(reservations.shift())); + }; + returnOne(reservations.shift()); + }; + + this.pickupResource = (reservation: IdlObject) => { + return this.net.request( + 'open-ils.circ', + 'open-ils.circ.reservation.pickup', + this.auth.token(), + {'patron_barcode': reservation.usr().card().barcode(), 'reservation': reservation}) + .pipe(tap( + () => { + this.onPickup.emit(reservation); + this.grid.reload(); }, + )); + }; + + this.returnResource = (reservation: IdlObject) => { + return this.net.request( + 'open-ils.circ', + 'open-ils.circ.reservation.return', + this.auth.token(), + {'patron_barcode': this.patronBarcode, 'reservation': reservation}) + .pipe(tap( + () => { this.grid.reload(); }, + )); + }; + + this.listReadOnlyFields = () => { + let list = 'usr,xact_start,request_time,capture_time,pickup_time,return_time,capture_staff,target_resource_type,' + + 'current_resource,target_resource,unrecovered,request_library,pickup_library,fine_interval,fine_amount,max_fine'; + if (this.status && ('pickupReady' !== this.status)) { list = list + ',start_time'; } + if (this.status && ('returnedToday' === this.status)) { list = list + ',end_time'; } + return list; + }; + + this.handleRowActivate = (row: IdlObject) => { + if (this.status) { + if ('returnReady' === this.status) { + this.returnResource(row).subscribe(); + } else if ('pickupReady' === this.status) { + this.pickupResource(row).subscribe(); + } else if ('returnedToday' === this.status) { + this.toast.warning('Cannot edit this reservation'); + } else { + this.showEditDialog(row); + } + } else { + this.showEditDialog(row); + } + }; + + this.redirectToCreate = () => { + this.router.navigate(['/staff', 'booking', 'create_reservation']); + }; + } + + enrichRow$ = (row: IdlObject): Observable => { + return from(this.org.settings('lib.timezone', row.pickup_lib().id())).pipe( + switchMap((tz) => { + row['length'] = Moment(row['end_time']()).from(Moment(row['start_time']()), true); + row['timezone'] = tz['lib.timezone']; + return of(row); + }) + ); + } + + showEditDialog(idlThing: IdlObject) { + this.editDialog.recId = idlThing.id(); + this.editDialog.timezone = idlThing['timezone']; + return this.editDialog.open({size: 'lg'}).pipe(tap( + () => { + this.toast.success('Reservation successfully updated'); // TODO: needs i18n, pluralization + this.grid.reload(); + } + )); + } + + filterByResourceBarcode(barcode: string) { + this.router.navigate(['/staff', 'booking', 'manage_reservations', 'by_resource', barcode]); + } + + momentizeIsoString(isoString: string, timezone: string): Moment { + return this.format.momentizeIsoString(isoString, timezone); + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/booking/return.component.html b/Open-ILS/src/eg2/src/app/staff/booking/return.component.html new file mode 100644 index 0000000000..262910fcb3 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/return.component.html @@ -0,0 +1,46 @@ + + + + +
+ + + +
+
+
+
+ + +
+
+
+
+
+

Ready for return

+ + +

Returned today

+ +
+
+
+ + +
+
+ + +
+
+
+

Ready for return

+ + +

Returned today

+ +
+
+
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/booking/return.component.ts b/Open-ILS/src/eg2/src/app/staff/booking/return.component.ts new file mode 100644 index 0000000000..74fb95aa05 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/return.component.ts @@ -0,0 +1,145 @@ +import {Component, OnInit, OnDestroy, QueryList, ViewChildren, ViewChild} from '@angular/core'; +import {Router, ActivatedRoute, ParamMap} from '@angular/router'; +import {FormGroup, FormControl, Validators} from '@angular/forms'; +import {NgbTabChangeEvent, NgbTabset} from '@ng-bootstrap/ng-bootstrap'; +import {Observable, from, of, Subscription} from 'rxjs'; +import { single, switchMap, tap, debounceTime } from 'rxjs/operators'; +import {PatronService} from '@eg/staff/share/patron.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {IdlObject} from '@eg/core/idl.service'; +import {ReservationsGridComponent} from './reservations-grid.component'; +import {ServerStoreService} from '@eg/core/server-store.service'; +import {ToastService} from '@eg/share/toast/toast.service'; +import {PatronBarcodeValidator} from '@eg/share/validators/patron_barcode_validator.directive'; + + +@Component({ + templateUrl: './return.component.html' +}) + +export class ReturnComponent implements OnInit, OnDestroy { + patronId: number; + findPatron: FormGroup; + subscriptions: Subscription[] = []; + + noSelectedRows: (rows: IdlObject[]) => boolean; + handleTabChange: ($event: NgbTabChangeEvent) => void; + @ViewChild('tabs') tabs: NgbTabset; + @ViewChildren(ReservationsGridComponent) grids: QueryList; + + constructor( + private pcrud: PcrudService, + private patron: PatronService, + private pbv: PatronBarcodeValidator, + private route: ActivatedRoute, + private router: Router, + private store: ServerStoreService, + private toast: ToastService + ) { + } + + + ngOnInit() { + this.route.paramMap.pipe(switchMap((params: ParamMap) => { + return this.handleParams$(params); + })).subscribe(); + + this.findPatron = new FormGroup({ + 'patronBarcode': new FormControl(null, + [Validators.required], + [this.pbv.validate]), + 'resourceBarcode': new FormControl(null, + [Validators.required]) + }); + + const debouncing = 1500; + this.subscriptions.push( + this.patronBarcode.valueChanges.pipe( + debounceTime(debouncing), + switchMap((val) => { + if ('INVALID' === this.patronBarcode.status) { + this.toast.danger('No patron found with this barcode'); + return of(); + } else { + return this.patron.bcSearch(val).pipe( + single(), + tap((resp) => { this.router.navigate(['/staff', 'booking', 'return', 'by_patron', resp[0].id]); }) + ); + } + }) + ) + .subscribe()); + + this.subscriptions.push( + this.resourceBarcode.valueChanges.pipe( + debounceTime(debouncing), + switchMap((val) => { + if ('INVALID' !== this.resourceBarcode.status) { + return this.pcrud.search('brsrc', {'barcode': val}, { + order_by: {'curr_rsrcs': 'pickup_time DESC'}, + limit: 1, + flesh: 1, + flesh_fields: {'brsrc': ['curr_rsrcs']}, + select: {'curr_rsrcs': {'return_time': null, 'pickup_time': {'!=': null}}} + }).pipe(tap((resp) => { + if (resp.curr_rsrcs()[0].usr()) { + this.patronId = resp.curr_rsrcs()[0].usr(); + this.refreshGrids(); + } + })); + } else { + return of(); + } + }) + ).subscribe() + ); + this.noSelectedRows = (rows: IdlObject[]) => (rows.length === 0); + + this.handleTabChange = ($event) => { + this.store.setItem('eg.booking.return.tab', $event.nextId) + .then(() => { + this.router.navigate(['/staff', 'booking', 'return']); + this.findPatron.patchValue({resourceBarcode: ''}); + this.patronId = null; + }); + }; + } + + handleParams$ = (params: ParamMap): Observable => { + this.patronId = +params.get('patron_id'); + if (this.patronId) { + return this.pcrud.search('au', { + 'id': this.patronId, + }, { + limit: 1, + flesh: 1, + flesh_fields: {'au': ['card']} + }).pipe(tap( + (resp) => { + this.findPatron.patchValue({patronBarcode: resp.card().barcode()}); + this.refreshGrids(); + }, (err) => { console.debug(err); } + )); + } else { + return from(this.store.getItem('eg.booking.return.tab')) + .pipe(tap(tab => { + if (tab) { this.tabs.select(tab); } + })); + } + } + refreshGrids = (): void => { + this.grids.forEach (grid => grid.reloadGrid()); + } + get patronBarcode() { + return this.findPatron.get('patronBarcode'); + } + get resourceBarcode() { + return this.findPatron.get('resourceBarcode'); + } + + ngOnDestroy(): void { + this.subscriptions.forEach((subscription) => { + subscription.unsubscribe(); + }); + } +} diff --git a/Open-ILS/src/eg2/src/app/staff/booking/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/booking/routing.module.ts new file mode 100644 index 0000000000..bc12e96a45 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/routing.module.ts @@ -0,0 +1,44 @@ +import {NgModule} from '@angular/core'; +import {RouterModule, Routes} from '@angular/router'; +import {CreateReservationComponent} from './create-reservation.component'; +import {ManageReservationsComponent} from './manage-reservations.component'; +import {PickupComponent} from './pickup.component'; +import {PullListComponent} from './pull-list.component'; +import {ReturnComponent} from './return.component'; + +const routes: Routes = [{ + path: 'create_reservation', + children: [ + {path: '', component: CreateReservationComponent}, + {path: 'for_patron/:patron_id', component: CreateReservationComponent}, + {path: 'for_resource/:resource_barcode', component: CreateReservationComponent}, + ]}, { + path: 'manage_reservations', + children: [ + {path: '', component: ManageReservationsComponent}, + {path: 'by_patron/:patron_id', component: ManageReservationsComponent}, + {path: 'by_resource/:resource_barcode', component: ManageReservationsComponent}, + {path: 'by_resource_type/:resource_type_id', component: ManageReservationsComponent}, + ]}, { + path: 'pickup', + children: [ + {path: '', component: PickupComponent}, + {path: 'by_patron/:patron_id', component: PickupComponent}, + ]}, { + path: 'pull_list', + component: PullListComponent + }, { + path: 'return', + children: [ + {path: '', component: ReturnComponent}, + {path: 'by_patron/:patron_id', component: ReturnComponent}, + ]}, + ]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], + providers: [] +}) + +export class BookingRoutingModule {} diff --git a/Open-ILS/src/eg2/src/app/staff/booking/schedule-grid.service.ts b/Open-ILS/src/eg2/src/app/staff/booking/schedule-grid.service.ts new file mode 100644 index 0000000000..7c6823f6e1 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/schedule-grid.service.ts @@ -0,0 +1,173 @@ +import {Injectable} from '@angular/core'; +import {Observable, of} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; +import {NgbTimeStruct} from '@ng-bootstrap/ng-bootstrap'; +import {AuthService} from '@eg/core/auth.service'; +import {IdlObject} from '@eg/core/idl.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {GridRowFlairEntry} from '@eg/share/grid/grid'; +import {DateRange} from '@eg/share/daterange-select/daterange-select.component'; + +import * as Moment from 'moment-timezone'; + +export interface ReservationPatron { + patronId: number; + patronLabel: string; + reservationId: number; +} + +export interface ScheduleRow { + time: Moment; + [key: string]: ReservationPatron[]; +} + +// Various methods that fetch data for and process the schedule of reservations + +@Injectable({providedIn: 'root'}) +export class ScheduleGridService { + + constructor( + private auth: AuthService, + private pcrud: PcrudService, + ) { + } + hoursOfOperation = (date: Date): Observable<{startOfDay: NgbTimeStruct, endOfDay: NgbTimeStruct}> => { + const defaultStartHour = 9; + const defaultEndHour = 17; + return this.pcrud.retrieve('aouhoo', this.auth.user().ws_ou()) + .pipe(switchMap((hours) => { + const startArray = hours[this.evergreenStyleDow(date) + '_open']().split(':'); + const endArray = hours[this.evergreenStyleDow(date) + '_close']().split(':'); + return of({ + startOfDay: { + hour: ('00' === startArray[0]) ? defaultStartHour : +startArray[0], + minute: +startArray[1], + second: 0}, + endOfDay: { + hour: ('00' === endArray[0]) ? defaultEndHour : +endArray[0], + minute: +endArray[1], + second: 0} + }); + })); + } + + resourceAvailabilityIcon = (row: ScheduleRow, numResources: number): GridRowFlairEntry => { + let icon = {icon: 'event_busy', title: 'All resources are reserved at this time'}; + let busyColumns = 0; + for (const key in row) { + if (row[key] instanceof Array && row[key].length) { + busyColumns += 1; + } + } + if (busyColumns < numResources) { + icon = {icon: 'event_available', title: 'Resources are available at this time'}; + } + return icon; + } + + fetchRelevantResources = (resourceTypeId: number, owningLibraries: number[], selectedAttributes: number[]): Observable => { + const where = { + type: resourceTypeId, + owner: owningLibraries, + }; + + if (selectedAttributes.length) { + where['id'] = {'in': + {'from': 'bram', 'select': {'bram': ['resource']}, + 'where': {'value': selectedAttributes}}}; + } + return this.pcrud.search('brsrc', where, { + order_by: 'barcode ASC', + flesh: 1, + flesh_fields: {'brsrc': ['attr_maps']}, + }); + } + + momentizeDateRange = (range: DateRange, timezone: string): {startTime: Moment, endTime: Moment} => { + return { + startTime: Moment.tz([ + range.fromDate.year, + range.fromDate.month - 1, + range.fromDate.day], + timezone), + endTime: Moment.tz([ + range.toDate.year, + range.toDate.month - 1, + range.toDate.day + 1], + timezone) + }; + } + momentizeDay = (date: Date, start: NgbTimeStruct, end: NgbTimeStruct, timezone: string): {startTime: Moment, endTime: Moment} => { + return { + startTime: Moment.tz([ + date.getFullYear(), + date.getMonth(), + date.getDate(), + start.hour, + start.minute], + timezone), + endTime: Moment.tz([ + date.getFullYear(), + date.getMonth(), + date.getDate(), + end.hour, + end.minute], + timezone) + }; + } + + createBasicSchedule = (range: {startTime: Moment, endTime: Moment}, granularity: number): ScheduleRow[] => { + const currentTime = range.startTime.clone(); + const schedule = []; + while (currentTime < range.endTime) { + schedule.push({'time': currentTime.clone()}); + currentTime.add(granularity, 'minutes'); + } + return schedule; + } + + fetchReservations = (range: {startTime: Moment, endTime: Moment}, resourceIds: number[]): Observable => { + return this.pcrud.search('bresv', { + '-or': {'target_resource': resourceIds, 'current_resource': resourceIds}, + 'end_time': {'>': range.startTime.toISOString()}, + 'start_time': {'<': range.endTime.toISOString()}, + 'return_time': null, + 'cancel_time': null }, + {'flesh': 1, 'flesh_fields': {'bresv': ['current_resource', 'usr']}}); + } + + addReservationToSchedule = (reservation: IdlObject, schedule: ScheduleRow[], granularity: number, timezone: string): ScheduleRow[] => { + for (let index = 0; index < schedule.length; index++) { + const start = schedule[index].time; + const end = (index + 1 < schedule.length) ? + schedule[index + 1].time : + schedule[index].time.clone().add(granularity, 'minutes'); + if ((Moment.tz(reservation.start_time(), timezone).isBefore(end)) && + (Moment.tz(reservation.end_time(), timezone).isAfter(start))) { + if (!schedule[index][reservation.current_resource().barcode()]) { + schedule[index][reservation.current_resource().barcode()] = []; + } + if (schedule[index][reservation.current_resource().barcode()] + .findIndex(patron => patron.patronId === reservation.usr().id()) === -1) { + schedule[index][reservation.current_resource().barcode()].push( + {'patronLabel': reservation.usr().usrname(), + 'patronId': reservation.usr().id(), + 'reservationId': reservation.id()}); + } + } + + } + return schedule; + + } + + // Evergreen uses its own day of week style, where dow_0 = Monday and dow_6 = Sunday + private evergreenStyleDow = (original: Date): string => { + const daysInAWeek = 7; + const offset = 6; + return 'dow_' + (original.getDay() + offset) % daysInAWeek; + } + + +} + diff --git a/Open-ILS/src/eg2/src/app/staff/booking/schedule-grid.spec.ts b/Open-ILS/src/eg2/src/app/staff/booking/schedule-grid.spec.ts new file mode 100644 index 0000000000..85b567e73b --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/schedule-grid.spec.ts @@ -0,0 +1,51 @@ +import { TestBed } from '@angular/core/testing'; +import { AuthService } from '@eg/core/auth.service'; +import { PcrudService } from '@eg/core/pcrud.service'; +import { ScheduleGridService, ScheduleRow } from './schedule-grid.service'; +import * as Moment from 'moment-timezone'; + +describe('ScheduleGridService', () => { + let service: ScheduleGridService; + beforeEach(() => { + const authServiceStub = {}; + const pcrudServiceStub = {}; + TestBed.configureTestingModule({ + providers: [ + ScheduleGridService, + { provide: AuthService, useValue: authServiceStub }, + { provide: PcrudService, useValue: pcrudServiceStub } + ] + }); + service = TestBed.get(ScheduleGridService); + }); + + it('should recognize when a row is completely busy', () => { + const busyRow: ScheduleRow = { + 'time': Moment(), + 'barcode1': [{patronLabel: 'Joe', patronId: 1, reservationId: 3}], + 'barcode2': [{patronLabel: 'Jill', patronId: 2, reservationId: 5}], + 'barcode3': [{patronLabel: 'James', patronId: 3, reservationId: 12}, + {patronLabel: 'Juanes', patronId: 4, reservationId: 18}] + }; + expect(service.resourceAvailabilityIcon(busyRow, 3).icon).toBe('event_busy'); + }); + + it('should recognize when a row has some availability', () => { + const rowWithAvailability: ScheduleRow = { + 'time': Moment(), + 'barcode3': [{patronLabel: 'James', patronId: 3, reservationId: 11}, + {patronLabel: 'Juanes', patronId: 4, reservationId: 17}] + }; + expect(service.resourceAvailabilityIcon(rowWithAvailability, 3).icon).toBe('event_available'); + }); + + it('should recognize 4 February 2019 as a Monday', () => { + const date = new Date(2019, 1, 4); + expect(service['evergreenStyleDow'](date)).toBe('dow_0'); + }); + + it('should recognize 3 February 2019 as a Sunday', () => { + const date = new Date(2019, 1, 3); + expect(service['evergreenStyleDow'](date)).toBe('dow_6'); + }); +}); diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.html index 3c09dded91..bcd980e45f 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.html +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.html @@ -130,6 +130,11 @@ i18n-group group="Booking" i18n-label label="Make Items Bookable" (onClick)="makeBookable($event)"> + + + diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.ts index 25e0894a0e..edde96cbc5 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.ts @@ -871,7 +871,7 @@ export class HoldingsMaintenanceComponent implements OnInit { bookItems(rows: HoldingsEntry[]) { const copyIds = this.selectedCopyIds(rows); if (copyIds.length > 0) { - alert('TODO'); + this.router.navigate(['staff', 'booking', 'create_reservation', 'for_resource', rows.filter(r => Boolean(r.copy))[0].copy.barcode()]); } } @@ -882,4 +882,11 @@ export class HoldingsMaintenanceComponent implements OnInit { this.makeBookableDialog.open({}); } } + + manageReservations(rows: HoldingsEntry[]) { + const copyIds = this.selectedCopyIds(rows); + if (copyIds.length > 0) { + this.router.navigate(['staff', 'booking', 'manage_reservations', 'by_resource', rows.filter(r => Boolean(r.copy))[0].copy.barcode()]); + } + } } diff --git a/Open-ILS/src/eg2/src/app/staff/common.module.ts b/Open-ILS/src/eg2/src/app/staff/common.module.ts index e833a347e2..458b0f5ffb 100644 --- a/Open-ILS/src/eg2/src/app/staff/common.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/common.module.ts @@ -17,8 +17,9 @@ import {TranslateComponent} from '@eg/staff/share/translate/translate.component' import {AdminPageComponent} from '@eg/staff/share/admin-page/admin-page.component'; import {EgHelpPopoverComponent} from '@eg/share/eg-help-popover/eg-help-popover.component'; import {DatetimeValidatorDirective} from '@eg/share/validators/datetime_validator.directive'; -import {ReactiveFormsModule} from '@angular/forms'; import {MultiSelectComponent} from '@eg/share/multi-select/multi-select.component'; +import {NotBeforeMomentValidatorDirective} from '@eg/share/validators/not_before_moment_validator.directive'; +import {PatronBarcodeValidatorDirective} from '@eg/share/validators/patron_barcode_validator.directive'; /** * Imports the EG common modules and adds modules common to all staff UI's. @@ -39,7 +40,9 @@ import {MultiSelectComponent} from '@eg/share/multi-select/multi-select.componen AdminPageComponent, EgHelpPopoverComponent, DatetimeValidatorDirective, - MultiSelectComponent + MultiSelectComponent, + NotBeforeMomentValidatorDirective, + PatronBarcodeValidatorDirective, ], imports: [ EgCommonModule, @@ -63,7 +66,9 @@ import {MultiSelectComponent} from '@eg/share/multi-select/multi-select.componen AdminPageComponent, EgHelpPopoverComponent, DatetimeValidatorDirective, - MultiSelectComponent + MultiSelectComponent, + NotBeforeMomentValidatorDirective, + PatronBarcodeValidatorDirective ] }) diff --git a/Open-ILS/src/eg2/src/app/staff/nav.component.html b/Open-ILS/src/eg2/src/app/staff/nav.component.html index 3595b3c9be..2be451825e 100644 --- a/Open-ILS/src/eg2/src/app/staff/nav.component.html +++ b/Open-ILS/src/eg2/src/app/staff/nav.component.html @@ -310,11 +310,11 @@ Booking
diff --git a/Open-ILS/src/eg2/src/app/staff/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/routing.module.ts index 6f20336660..e390a3db4d 100644 --- a/Open-ILS/src/eg2/src/app/staff/routing.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/routing.module.ts @@ -18,6 +18,9 @@ const routes: Routes = [{ path: '', redirectTo: 'splash', pathMatch: 'full', + }, { + path: 'booking', + loadChildren : '@eg/staff/booking/booking.module#BookingModule' }, { path: 'about', component: AboutComponent diff --git a/Open-ILS/src/eg2/src/app/staff/share/patron.service.ts b/Open-ILS/src/eg2/src/app/staff/share/patron.service.ts new file mode 100644 index 0000000000..b11626c71d --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/patron.service.ts @@ -0,0 +1,23 @@ +import {Injectable} from '@angular/core'; +import {NetService} from '@eg/core/net.service'; +import {AuthService} from '@eg/core/auth.service'; +import {Observable} from 'rxjs'; + + +@Injectable() +export class PatronService { + constructor( + private net: NetService, + private auth: AuthService + ) {} + + bcSearch(barcode: string): Observable { + return this.net.request( + 'open-ils.actor', + 'open-ils.actor.get_barcodes', + this.auth.token(), this.auth.user().ws_ou(), + 'actor', barcode); + } + +} + diff --git a/Open-ILS/src/eg2/src/polyfills.ts b/Open-ILS/src/eg2/src/polyfills.ts index 9dc048b83f..1e3dcd8664 100644 --- a/Open-ILS/src/eg2/src/polyfills.ts +++ b/Open-ILS/src/eg2/src/polyfills.ts @@ -37,6 +37,7 @@ // PhantomJS needs these import 'core-js/es6/array'; import 'core-js/es6/string'; +import 'core-js/es6/symbol'; // needed by app/staff/booking/reservation-actions.spec.ts /** IE10 and IE11 requires the following for NgClass support on SVG elements */ // import 'classlist.js'; // Run `npm install --save classlist.js`. diff --git a/Open-ILS/src/eg2/src/styles.css b/Open-ILS/src/eg2/src/styles.css index 9573b52e4a..ef97e2a93d 100644 --- a/Open-ILS/src/eg2/src/styles.css +++ b/Open-ILS/src/eg2/src/styles.css @@ -13,7 +13,15 @@ body, .form-control, .btn, .input-group-text { */ font-size: .88rem; } -h2 {font-size: 1.25rem} +h2 { + font-size: 1.25rem; + font-weight: 550; + color: #129a78; /* official color of the Evergreen logo */ + text-decoration: underline #129a78; +} +h2.card-header { + text-decoration: none; +} h3 {font-size: 1.15rem} h4 {font-size: 1.05rem} h5 {font-size: .95rem} diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Booking.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Booking.pm index a715f88259..c01db43442 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Booking.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Booking.pm @@ -192,7 +192,7 @@ __PACKAGE__->register_method( sub create_bresv { my ($self, $client, $authtoken, $target_user_barcode, $datetime_range, $pickup_lib, - $brt, $brsrc_list, $attr_values, $email_notify) = @_; + $brt, $brsrc_list, $attr_values, $email_notify, $note) = @_; $brsrc_list = [ undef ] if not defined $brsrc_list; return undef if scalar(@$brsrc_list) < 1; # Empty list not ok. @@ -213,6 +213,7 @@ sub create_bresv { $bresv->start_time($datetime_range->[0]); $bresv->end_time($datetime_range->[1]); $bresv->email_notify(1) if $email_notify; + $bresv->note($note) if $note; # A little sanity checking: don't agree to put a reservation on a # brsrc and a brt when they don't match. In fact, bomb out of @@ -306,6 +307,7 @@ __PACKAGE__->register_method( {type => 'list', desc => 'Booking resource (undef ok; empty not ok)'}, {type => 'array', desc => 'Attribute values selected'}, {type => 'bool', desc => 'Email notification?'}, + {type => 'string', desc => 'Optional note'}, ], return => { desc => "A hash containing the new bresv and a list " . "of new bravm"} diff --git a/Open-ILS/src/sql/Pg/095.schema.booking.sql b/Open-ILS/src/sql/Pg/095.schema.booking.sql index 974f3b9bba..7144fded4e 100644 --- a/Open-ILS/src/sql/Pg/095.schema.booking.sql +++ b/Open-ILS/src/sql/Pg/095.schema.booking.sql @@ -129,7 +129,8 @@ CREATE TABLE booking.reservation ( DEFERRABLE INITIALLY DEFERRED, capture_staff INT REFERENCES actor.usr(id) DEFERRABLE INITIALLY DEFERRED, - email_notify BOOLEAN NOT NULL DEFAULT FALSE + email_notify BOOLEAN NOT NULL DEFAULT FALSE, + note TEXT ) INHERITS (money.billable_xact); ALTER TABLE booking.reservation ADD PRIMARY KEY (id); diff --git a/Open-ILS/src/sql/Pg/950.data.seed-values.sql b/Open-ILS/src/sql/Pg/950.data.seed-values.sql index f2c6e85de2..36260a4f4c 100644 --- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql +++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql @@ -20133,3 +20133,91 @@ $TEMPLATE$ -- Allow for 1k stock templates SELECT SETVAL('config.print_template_id_seq'::TEXT, 1000); + +INSERT INTO config.workstation_setting_type + (name, grp, datatype, label) +VALUES ( + 'eg.grid.circ.patron.group_members', 'gui', 'object', + oils_i18n_gettext( + 'eg.grid.circ.patron.group_members', + 'Grid Config: circ.patron.group_members', + 'cwst', 'label') +); + +INSERT INTO config.workstation_setting_type (name,label,grp,datatype) +VALUES ('eg.circ.bills.annotatepayment','Bills: Annotate Payment', 'circ', 'bool'); + +INSERT INTO config.workstation_setting_type (name, grp, datatype, label) +VALUES ( + 'eg.grid.booking.manage', 'gui', 'object', + oils_i18n_gettext( + 'booking.manage', + 'Grid Config: Booking Manage Reservations', + 'cwst', 'label') +), ( + 'eg.grid.booking.pickup.ready', 'gui', 'object', + oils_i18n_gettext( + 'booking.pickup.ready', + 'Grid Config: Booking Ready to pick up grid', + 'cwst', 'label') +), ( + 'eg.grid.booking.pickup.picked_up', 'gui', 'object', + oils_i18n_gettext( + 'booking.pickup.picked_up', + 'Grid Config: Booking Already Picked Up grid', + 'cwst', 'label') +), ( + 'eg.grid.booking.return.patron.picked_up', 'gui', 'object', + oils_i18n_gettext( + 'booking.return.patron.picked_up', + 'Grid Config: Booking Return Patron tab Already Picked Up grid', + 'cwst', 'label') +), ( + 'eg.grid.booking.return.patron.returned', 'gui', 'object', + oils_i18n_gettext( + 'booking.return.patron.returned', + 'Grid Config: Booking Return Patron tab Returned Today grid', + 'cwst', 'label') +), ( + 'eg.grid.booking.return.resource.picked_up', 'gui', 'object', + oils_i18n_gettext( + 'booking.return.resourcce.picked_up', + 'Grid Config: Booking Return Resource tab Already Picked Up grid', + 'cwst', 'label') +), ( + 'eg.grid.booking.return.resource.returned', 'gui', 'object', + oils_i18n_gettext( + 'booking.return.resource.returned', + 'Grid Config: Booking Return Resource tab Returned Today grid', + 'cwst', 'label') +), ( + 'eg.booking.manage.selected_org_family', 'gui', 'object', + oils_i18n_gettext( + 'booking.manage.selected_org_family', + 'Sticky setting for pickup ou family in Manage Reservations screen', + 'cwst', 'label') +), ( + 'eg.booking.return.tab', 'gui', 'string', + oils_i18n_gettext( + 'booking.return.tab', + 'Sticky setting for tab in Booking Return', + 'cwst', 'label') +), ( + 'eg.booking.create.granularity', 'gui', 'integer', + oils_i18n_gettext( + 'booking.create.granularity', + 'Sticky setting for granularity combobox in Booking Create', + 'cwst', 'label') +), ( + 'eg.booking.create.multiday', 'gui', 'bool', + oils_i18n_gettext( + 'booking.create.multiday', + 'Default to creating multiday booking reservations', + 'cwst', 'label') +), ( + 'eg.booking.pickup.ready.only_show_captured', 'gui', 'bool', + oils_i18n_gettext( + 'booking.pickup.ready.only_show_captured', + 'Include only resources that have been captured in the Ready grid in the Pickup screen', + 'cwst', 'label') +); diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.data.booking-sticky-settings.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.booking-sticky-settings.sql new file mode 100644 index 0000000000..8da02ed13b --- /dev/null +++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.booking-sticky-settings.sql @@ -0,0 +1,78 @@ +BEGIN; +--SELECT evergreen.upgrade_deps_block_check('XXXX', :eg_version); +INSERT INTO config.workstation_setting_type (name, grp, datatype, label) +VALUES ( + 'eg.grid.booking.manage', 'gui', 'object', + oils_i18n_gettext( + 'booking.manage', + 'Grid Config: Booking Manage Reservations', + 'cwst', 'label') +), ( + 'eg.grid.booking.pickup.ready', 'gui', 'object', + oils_i18n_gettext( + 'booking.pickup.ready', + 'Grid Config: Booking Ready to pick up grid', + 'cwst', 'label') +), ( + 'eg.grid.booking.pickup.picked_up', 'gui', 'object', + oils_i18n_gettext( + 'booking.pickup.picked_up', + 'Grid Config: Booking Already Picked Up grid', + 'cwst', 'label') +), ( + 'eg.grid.booking.return.patron.picked_up', 'gui', 'object', + oils_i18n_gettext( + 'booking.return.patron.picked_up', + 'Grid Config: Booking Return Patron tab Already Picked Up grid', + 'cwst', 'label') +), ( + 'eg.grid.booking.return.patron.returned', 'gui', 'object', + oils_i18n_gettext( + 'booking.return.patron.returned', + 'Grid Config: Booking Return Patron tab Returned Today grid', + 'cwst', 'label') +), ( + 'eg.grid.booking.return.resource.picked_up', 'gui', 'object', + oils_i18n_gettext( + 'booking.return.resourcce.picked_up', + 'Grid Config: Booking Return Resource tab Already Picked Up grid', + 'cwst', 'label') +), ( + 'eg.grid.booking.return.resource.returned', 'gui', 'object', + oils_i18n_gettext( + 'booking.return.resource.returned', + 'Grid Config: Booking Return Resource tab Returned Today grid', + 'cwst', 'label') +), ( + 'eg.booking.manage.selected_org_family', 'gui', 'object', + oils_i18n_gettext( + 'booking.manage.selected_org_family', + 'Sticky setting for pickup ou family in Manage Reservations screen', + 'cwst', 'label') +), ( + 'eg.booking.return.tab', 'gui', 'string', + oils_i18n_gettext( + 'booking.return.tab', + 'Sticky setting for tab in Booking Return', + 'cwst', 'label') +), ( + 'eg.booking.create.granularity', 'gui', 'integer', + oils_i18n_gettext( + 'booking.create.granularity', + 'Sticky setting for granularity combobox in Booking Create', + 'cwst', 'label') +), ( + 'eg.booking.create.multiday', 'gui', 'bool', + oils_i18n_gettext( + 'booking.create.multiday', + 'Default to creating multiday booking reservations', + 'cwst', 'label') +), ( + 'eg.booking.pickup.ready.only_show_captured', 'gui', 'bool', + oils_i18n_gettext( + 'booking.pickup.ready.only_show_captured', + 'Include only resources that have been captured in the Ready grid in the Pickup screen', + 'cwst', 'label') +); + +COMMIT; diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.add_note_bresv.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.add_note_bresv.sql new file mode 100644 index 0000000000..4742f1d9a4 --- /dev/null +++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.add_note_bresv.sql @@ -0,0 +1,6 @@ +BEGIN; + +ALTER TABLE booking.reservation + ADD COLUMN note TEXT; + +COMMIT; diff --git a/Open-ILS/src/templates/staff/cat/catalog/t_holdings.tt2 b/Open-ILS/src/templates/staff/cat/catalog/t_holdings.tt2 index d30ad1ab67..2cddd413ef 100644 --- a/Open-ILS/src/templates/staff/cat/catalog/t_holdings.tt2 +++ b/Open-ILS/src/templates/staff/cat/catalog/t_holdings.tt2 @@ -45,6 +45,9 @@ + [% l('Show in Catalog') %]
  • [% l('Make Items Bookable') %]
  • [% l('Book Item Now') %]
  • +
  • [% l('Manage Reservations') %]
  • [% l('Request Items') %]
  • [% l('Link as Conjoined to Previously Marked Bib Record') %]
  • [% l('Delete Items') %]
  • diff --git a/Open-ILS/src/templates/staff/cat/item/t_list.tt2 b/Open-ILS/src/templates/staff/cat/item/t_list.tt2 index 024271e767..3747835a8e 100644 --- a/Open-ILS/src/templates/staff/cat/item/t_list.tt2 +++ b/Open-ILS/src/templates/staff/cat/item/t_list.tt2 @@ -19,6 +19,9 @@ +
  • - - [% l('Booking: Create or Cancel Reservations') %] + + [% l('Booking: Manage Reservations') %]
  • - + + [% l('Booking: Create Reservation') %] + +
  • +
  • + [% l('Booking: Pick Up Reservations') %]
  • - + [% l('Booking: Return Reservations') %]
  • diff --git a/Open-ILS/src/templates/staff/navbar.tt2 b/Open-ILS/src/templates/staff/navbar.tt2 index a9208c8d04..1028f42bb7 100644 --- a/Open-ILS/src/templates/staff/navbar.tt2 +++ b/Open-ILS/src/templates/staff/navbar.tt2 @@ -439,13 +439,13 @@ diff --git a/Open-ILS/web/js/ui/default/booking/capture.js b/Open-ILS/web/js/ui/default/booking/capture.js index 0e69a2dc7a..7a53625734 100644 --- a/Open-ILS/web/js/ui/default/booking/capture.js +++ b/Open-ILS/web/js/ui/default/booking/capture.js @@ -76,6 +76,13 @@ CaptureDisplay.prototype._generate_route_line = function(payload) { div.appendChild(strong); return div; }; +CaptureDisplay.prototype._generate_notes_line = function(payload) { + var p = document.createElement("p"); + if (payload.reservation.note()) { + p.innerHTML = "" + payload.reservation.note() + ""; + } + return p; +}; CaptureDisplay.prototype._generate_patron_info = function(payload) { var p = document.createElement("p"); p.innerHTML = "" + localeStrings.RESERVED + " " + @@ -131,6 +138,8 @@ CaptureDisplay.prototype.display_with_transit_info = function(result) { p.appendChild(this._generate_author_line(result.payload)); div.appendChild(p); + div.appendChild(this._generate_notes_line(result.payload)); + div.appendChild(this._generate_patron_info(result.payload)); div.appendChild(this._generate_resv_info(result.payload)); div.appendChild(this._generate_meta_info(result)); diff --git a/Open-ILS/web/js/ui/default/staff/cat/catalog/app.js b/Open-ILS/web/js/ui/default/staff/cat/catalog/app.js index c207b9797a..f474dc4401 100644 --- a/Open-ILS/web/js/ui/default/staff/cat/catalog/app.js +++ b/Open-ILS/web/js/ui/default/staff/cat/catalog/app.js @@ -936,75 +936,10 @@ function($scope , $routeParams , $location , $window , $q , egCore , egHolds , e }); } - $scope.book_copies_now = function() { - var copies_by_record = {}; - var record_list = []; - angular.forEach( - $scope.holdingsGridControls.selectedItems(), - function (item) { - var record_id = item['call_number.record.id']; - if (typeof copies_by_record[ record_id ] == 'undefined') { - copies_by_record[ record_id ] = []; - record_list.push( record_id ); - } - copies_by_record[ record_id ].push(item.id); - } - ); - - var promises = []; - var combined_brt = []; - var combined_brsrc = []; - angular.forEach(record_list, function(record_id) { - promises.push( - egCore.net.request( - 'open-ils.booking', - 'open-ils.booking.resources.create_from_copies', - egCore.auth.token(), - copies_by_record[record_id] - ).then(function(results) { - if (results && results['brt']) { - combined_brt = combined_brt.concat(results['brt']); - } - if (results && results['brsrc']) { - combined_brsrc = combined_brsrc.concat(results['brsrc']); - } - }) - ); - }); - - $q.all(promises).then(function() { - if (combined_brt.length > 0 || combined_brsrc.length > 0) { - $uibModal.open({ - template: '', - backdrop: 'static', - animation: true, - size: 'md', - controller: - ['$scope','$location','egCore','$uibModalInstance', - function($scope , $location , egCore , $uibModalInstance) { - - $scope.funcs = { - ses : egCore.auth.token(), - bresv_interface_opts : { - booking_results : { - brt : combined_brt - ,brsrc : combined_brsrc - } - } - } - - var booking_path = '/eg/booking/reservation'; - - $scope.booking_admin_url = - $location.absUrl().replace(/\/eg\/staff.*/, booking_path); - - }] - }); - } - }); + $scope.book_copies_now = function(items) { + location.href = "/eg2/staff/booking/create_reservation/for_resource/" + items[0]['barcode']; } - $scope.requestItems = function() { var copy_list = gatherSelectedHoldingsIds(); if (copy_list.length == 0) return; @@ -1074,6 +1009,13 @@ function($scope , $routeParams , $location , $window , $q , egCore , egHolds , e }); } + $scope.manage_reservations = function() { + var item = $scope.holdingsGridControls.selectedItems()[0]; + if (item) + location.href = "/eg2/staff/booking/manage_reservations/by_resource/" + item.barcode; + } + + $scope.view_place_orders = function() { if (!$scope.record_id) return; var url = egCore.env.basePath + 'acq/legacy/lineitem/related/' + $scope.record_id + '?target=bib'; diff --git a/Open-ILS/web/js/ui/default/staff/cat/item/app.js b/Open-ILS/web/js/ui/default/staff/cat/item/app.js index 5e418e76e4..b861801746 100644 --- a/Open-ILS/web/js/ui/default/staff/cat/item/app.js +++ b/Open-ILS/web/js/ui/default/staff/cat/item/app.js @@ -103,10 +103,7 @@ function($scope , $q , $window , $location , $timeout , egCore , egNet , egGridD } $scope.book_copies_now = function() { - itemSvc.book_copies_now([{ - id : $scope.args.copyId, - 'call_number.record.id' : $scope.args.recordId - }]); + itemSvc.book_copies_now([$scope.args.copyBarcode]); } $scope.findAcquisition = function() { @@ -144,6 +141,10 @@ function($scope , $q , $window , $location , $timeout , egCore , egNet , egGridD }); } + $scope.manage_reservations = function() { + itemSvc.manage_reservations([$scope.args.copyBarcode]); + } + $scope.requestItems = function() { itemSvc.requestItems([$scope.args.copyId]); } @@ -524,7 +525,15 @@ function($scope , $q , $window , $location , $timeout , egCore , egNet , egGridD } $scope.book_copies_now = function() { - itemSvc.book_copies_now(copyGrid.selectedItems()); + var item = copyGrid.selectedItems()[0]; + if (item) + itemSvc.book_copies_now(item.barcode); + } + + $scope.manage_reservations = function() { + var item = copyGrid.selectedItems()[0]; + if (item) + itemSvc.manage_reservations(item.barcode); } $scope.requestItems = function() { diff --git a/Open-ILS/web/js/ui/default/staff/circ/services/item.js b/Open-ILS/web/js/ui/default/staff/circ/services/item.js index 6382852d32..eda4d8e07b 100644 --- a/Open-ILS/web/js/ui/default/staff/circ/services/item.js +++ b/Open-ILS/web/js/ui/default/staff/circ/services/item.js @@ -350,72 +350,12 @@ function(egCore , egCirc , $uibModal , $q , $timeout , $window , egConfirmDialog }); } - service.book_copies_now = function(items) { - var copies_by_record = {}; - var record_list = []; - angular.forEach( - items, - function (item) { - var record_id = item['call_number.record.id']; - if (typeof copies_by_record[ record_id ] == 'undefined') { - copies_by_record[ record_id ] = []; - record_list.push( record_id ); - } - copies_by_record[ record_id ].push(item.id); - } - ); - - var promises = []; - var combined_brt = []; - var combined_brsrc = []; - angular.forEach(record_list, function(record_id) { - promises.push( - egCore.net.request( - 'open-ils.booking', - 'open-ils.booking.resources.create_from_copies', - egCore.auth.token(), - copies_by_record[record_id] - ).then(function(results) { - if (results && results['brt']) { - combined_brt = combined_brt.concat(results['brt']); - } - if (results && results['brsrc']) { - combined_brsrc = combined_brsrc.concat(results['brsrc']); - } - }) - ); - }); - - $q.all(promises).then(function() { - if (combined_brt.length > 0 || combined_brsrc.length > 0) { - $uibModal.open({ - template: '', - backdrop: 'static', - animation: true, - size: 'md', - controller: - ['$scope','$location','egCore','$uibModalInstance', - function($scope , $location , egCore , $uibModalInstance) { - - $scope.funcs = { - ses : egCore.auth.token(), - bresv_interface_opts : { - booking_results : { - brt : combined_brt - ,brsrc : combined_brsrc - } - } - } - - var booking_path = '/eg/booking/reservation'; - - $scope.booking_admin_url = - $location.absUrl().replace(/\/eg\/staff.*/, booking_path); + service.book_copies_now = function(barcode) { + location.href = "/eg2/staff/booking/create_reservation/for_resource/" + barcode; + } - }] - }); - } - }); + service.manage_reservations = function(barcode) { + location.href = "/eg2/staff/booking/manage_reservations/by_resource/" + barcode; } service.requestItems = function(copy_list) { -- 2.43.2