From 255b6515997c692996adecca7cb3775640f9239f Mon Sep 17 00:00:00 2001 From: Jane Sandberg Date: Wed, 8 May 2019 15:12:30 -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 * Starts to add validation methods to fields in the fmeditor * 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 Signed-off-by: Jane Sandberg --- Open-ILS/examples/fm_IDL.xml | 7 +- Open-ILS/src/eg2/src/app/core/format.service.ts | 5 + .../app/share/fm-editor/fm-editor.component.html | 18 + .../src/app/share/fm-editor/fm-editor.component.ts | 24 +- .../src/app/share/grid/grid-column.component.ts | 4 + .../src/app/share/grid/grid-toolbar.component.html | 5 +- Open-ILS/src/eg2/src/app/share/grid/grid.ts | 4 +- .../patron_barcode_validator.directive.spec.ts | 43 +++ .../patron_barcode_validator.directive.ts | 56 +++ .../eg2/src/app/staff/booking/booking.module.ts | 36 ++ .../create-reservation-dialog.component.html | 46 +++ .../booking/create-reservation-dialog.component.ts | 78 ++++ .../booking/create-reservation.component.html | 175 +++++++++ .../staff/booking/create-reservation.component.ts | 393 +++++++++++++++++++++ .../booking/manage-reservations.component.html | 73 ++++ .../staff/booking/manage-reservations.component.ts | 131 +++++++ .../staff/booking/no-timezone-set.component.html | 17 + .../app/staff/booking/no-timezone-set.component.ts | 16 + .../src/app/staff/booking/pickup.component.html | 27 ++ .../eg2/src/app/staff/booking/pickup.component.ts | 84 +++++ .../src/app/staff/booking/pull-list.component.html | 40 +++ .../src/app/staff/booking/pull-list.component.ts | 74 ++++ .../staff/booking/reservation-validate.service.ts | 62 ++++ .../staff/booking/reservations-grid.component.html | 57 +++ .../staff/booking/reservations-grid.component.ts | 294 +++++++++++++++ .../booking/resource-type-combobox.component.ts | 45 +++ .../src/app/staff/booking/return.component.html | 45 +++ .../eg2/src/app/staff/booking/return.component.ts | 111 ++++++ .../eg2/src/app/staff/booking/routing.module.ts | 44 +++ Open-ILS/src/eg2/src/app/staff/common.module.ts | 4 + Open-ILS/src/eg2/src/app/staff/nav.component.html | 12 +- Open-ILS/src/eg2/src/app/staff/routing.module.ts | 3 + .../src/eg2/src/app/staff/share/patron.service.ts | 23 ++ Open-ILS/src/eg2/src/styles.css | 10 +- .../perlmods/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 +++++ .../upgrade/XXXX.data.booking-sticky-settings.sql | 78 ++++ .../sql/Pg/upgrade/XXXX.schema.add_note_bresv.sql | 6 + .../src/templates/staff/cat/catalog/t_holdings.tt2 | 3 + Open-ILS/src/templates/staff/cat/item/index.tt2 | 1 + Open-ILS/src/templates/staff/cat/item/t_list.tt2 | 3 + Open-ILS/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 + .../web/js/ui/default/staff/cat/catalog/app.js | 76 +--- Open-ILS/web/js/ui/default/staff/cat/item/app.js | 19 +- .../web/js/ui/default/staff/circ/services/item.js | 70 +--- .../Circulation/booking-refresh.adoc | 32 ++ docs/circulation/booking.adoc | 5 + 50 files changed, 2328 insertions(+), 162 deletions(-) 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/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-validate.service.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/resource-type-combobox.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/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 create mode 100644 docs/RELEASE_NOTES_NEXT/Circulation/booking-refresh.adoc diff --git a/Open-ILS/examples/fm_IDL.xml b/Open-ILS/examples/fm_IDL.xml index 7270ee3be6..93784cb5ee 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/core/format.service.ts b/Open-ILS/src/eg2/src/app/core/format.service.ts index b12f876141..89ac506196 100644 --- a/Open-ILS/src/eg2/src/app/core/format.service.ts +++ b/Open-ILS/src/eg2/src/app/core/format.service.ts @@ -148,6 +148,11 @@ export class FormatService { idlFormatDatetime(datetime: string, timezone: string): string { return this.momentizeDateTimeString(datetime, timezone).toISOString(); } /** + * Create a Moment from an ISO string + */ + momentizeIsoString(isoString: string, timezone: string): string { return isoString.length ? Moment(isoString, timezone) : Moment(); } + + /** * Turn a date string into a Moment using the date format org setting. */ momentizeDateString(date: string, timezone: string, strict = false): Moment { 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 5db749af35..bbe377b36a 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 @@ -53,6 +53,17 @@ + + + + + + + + {{field.linkedValues[0].label}} + open_in_new + + + ; @@ -86,6 +86,9 @@ export class FmRecordEditorComponent mode: 'create' | 'update' | 'view' = 'create'; recId: any; + // Show datetime fields in this particular timezone + timezone: string = this.format.wsOrgTimezone; + // IDL record we are editing record: IdlObject; @@ -110,6 +113,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[] = []; @@ -160,6 +167,7 @@ export class FmRecordEditorComponent private modal: NgbModal, // required for passing to parent private idl: IdlService, private auth: AuthService, + private format: FormatService, private pcrud: PcrudService) { super(modal); } @@ -196,6 +204,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(/,/); } @@ -369,6 +380,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); @@ -479,6 +492,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' @@ -491,6 +508,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'; } @@ -530,4 +551,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 fc18fc7258..8e1c9dce85 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 @@ -30,6 +30,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; @@ -61,6 +64,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 be7b19cb7e..297d04bb8f 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 @@ -86,7 +86,7 @@ title="Expand Cells Vertically" i18n-title class="material-icons mat-icon-in-button">expand_more expand_less @@ -144,6 +144,3 @@
- - - 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 ae681694d8..60ce77166b 100644 --- a/Open-ILS/src/eg2/src/app/share/grid/grid.ts +++ b/Open-ILS/src/eg2/src/app/share/grid/grid.ts @@ -27,6 +27,7 @@ export class GridColumn { datatype: string; datePlusTime: boolean; ternaryBool: boolean; + timezoneContextOrg: number; cellTemplate: TemplateRef; cellContext: any; isIndex: boolean; @@ -678,7 +679,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/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..09962a5a9b --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/booking.module.ts @@ -0,0 +1,36 @@ +import {NgModule} from '@angular/core'; +import {ReactiveFormsModule} from '@angular/forms'; +import {StaffCommonModule} from '@eg/staff/common.module'; +import {BookingRoutingModule} from './routing.module'; +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 {ResourceTypeComboboxComponent} from './resource-type-combobox.component'; +import {ReturnComponent} from './return.component'; +import {NoTimezoneSetComponent} from './no-timezone-set.component'; +import {PatronService} from '@eg/staff/share/patron.service'; + + +@NgModule({ + imports: [ + StaffCommonModule, + BookingRoutingModule, + ReactiveFormsModule, + ], + providers: [PatronService], + declarations: [ + CreateReservationComponent, + CreateReservationDialogComponent, + ManageReservationsComponent, + NoTimezoneSetComponent, + PickupComponent, + PullListComponent, + ReservationsGridComponent, + ResourceTypeComboboxComponent, + ReturnComponent] +}) +export class BookingModule { } + 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..a1b40a5890 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/create-reservation-dialog.component.html @@ -0,0 +1,46 @@ + + + + + + + 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..0157bd90bf --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/create-reservation-dialog.component.ts @@ -0,0 +1,78 @@ +import {Component, Input, OnInit, ViewChild} from '@angular/core'; +import {FormGroup, FormControl, Validators} from '@angular/forms'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {AuthService} from '@eg/core/auth.service'; +import {DialogComponent} from '@eg/share/dialog/dialog.component'; +import {NetService} from '@eg/core/net.service'; +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 * as Moment from 'moment-timezone'; + +@Component({ + selector: 'eg-create-reservation-dialog', + templateUrl: './create-reservation-dialog.component.html' +}) + +export class CreateReservationDialogComponent + extends DialogComponent implements OnInit { + + constructor( + private auth: AuthService, + private net: NetService, + private modal: NgbModal, + private pbv: PatronBarcodeValidator, + private toast: ToastService + ) { + super(modal); + } + + create: FormGroup; + + addBresv: () => void; + + @Input() startTime: Moment; + @Input() endTime: Moment; + @Input() targetResource: number; + @Input() targetResourceBarcode: string; + @Input() attributes: any[]; + + @ViewChild('fail') private fail: AlertDialogComponent; + + ngOnInit() { + + this.create = new FormGroup({ + 'patronBarcode': new FormControl('', + [ Validators.required ], + [this.pbv.validate] + ), + 'emailNotify': new FormControl(true), + }); + + this.addBresv = () => { + this.net.request( + 'open-ils.booking', + 'open-ils.booking.reservations.create', + this.auth.token(), + '99999382659', // patron barcode + ['2019-09-09 10:00', '2019-09-09 14:00'], // start/end + 7, // pickup lib + 555, // brt + this.targetResource ? [this.targetResource] : null, + [], // bravm + 0 // email + ).subscribe( + (success) => { + this.toast.success('Reservation successfully created'); + this.close(); + }, (fail) => { + console.warn(fail); + this.fail.open(); + } + ); + } + + } +} + 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..c1e0890271 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/create-reservation.component.html @@ -0,0 +1,175 @@ + + + + +
+ + + + category + Choose resource by type + + +
+
+ + +
+
+
+
+ +
+ +
+
+
+
+
+ + + + assignment + Choose resource by barcode + + +
+
+
+
+ +
+ +
+
+
+
+
+ + + + calendar_today + Select dates - () selected + + +
+
+
+
+ +
+ +
+
+
+
+
+ +
+ + +
+
+
+
+
+ + + + filter_list + Limit by attributes + + +
    +
  • + + + + + + + + + +
  • +
+
+
+ + + + settings + Schedule settings + + +
    +
  • + + + + + + +
  • +
  • + + + + + + +
  • +
  • + + + + + + + + + + +
  • +
+
+
+ +
+
+ + + + + + + + + + + + + + + + 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..fe37b8374d --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/create-reservation.component.ts @@ -0,0 +1,393 @@ +import {Component, Input, OnInit, AfterViewInit, QueryList, ViewChildren, ViewChild} from '@angular/core'; +import {FormGroup, FormControl} from "@angular/forms"; +import {Router, ActivatedRoute, ParamMap} from '@angular/router'; +import {forkJoin, of, timer} from 'rxjs'; +import {catchError, debounceTime, mapTo, single, switchMap} from 'rxjs/operators'; +import {NgbDateStruct, NgbTimeStruct} from '@ng-bootstrap/ng-bootstrap'; +import {AuthService} from '@eg/core/auth.service'; +import {ComboboxEntry} from '@eg/share/combobox/combobox.component'; +import {DateSelectComponent} from '@eg/share/date-select/date-select.component'; +import {DateRangeSelectComponent} from '@eg/share/daterange-select/daterange-select.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, IdlService} from '@eg/core/idl.service'; +import {NetService} from '@eg/core/net.service'; +import {OrgService} from '@eg/core/org.service'; +import {PatronService} from '@eg/staff/share/patron.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {CreateReservationDialogComponent} from './create-reservation-dialog.component'; +import {ResourceTypeComboboxComponent} from './resource-type-combobox.component'; +import {ServerStoreService} from '@eg/core/server-store.service'; +import {ToastService} from '@eg/share/toast/toast.service'; +import {ReservationValidateService} from './reservation-validate.service'; +import {DateRange} from '@eg/share/daterange-select/daterange-select.component'; + +import * as Moment from 'moment-timezone'; + + +@Component({ + templateUrl: './create-reservation.component.html' +}) + +export class CreateReservationComponent implements OnInit, AfterViewInit { + + criteria: FormGroup; + + attributes: IdlObject[] = []; + selectedAttributes: number[] = []; + multiday = false; + handleDateChange: ($event: Date) => void; + handleOwnerChange: ($event: number[]) => void; + resourceAvailabilityIcon: (row: any) => GridRowFlairEntry; + + owningLibraries: number[] = []; + + patronBarcode: string; + patronId: number; + resourceBarcode: string; + resourceId: number; + resourceTypeId: number; + transferable: boolean; + resourceOwner: number; + + pickupLibUsesDifferentTz: string; + + startOfDay: NgbTimeStruct = {hour: 9, minute: 0, second: 0}; + endOfDay: NgbTimeStruct = {hour: 17, minute: 0, second: 0}; + granularity: 15 | 30 | 60 | 1440 = 30; // 1440 minutes = 24 hours + + defaultTimes: {start_time: Moment, end_time: Moment}; + + scheduleSource: GridDataSource = new GridDataSource(); + + minuteStep: () => number; + + openTheDialog: (rows: IdlObject[]) => any; + + resources: IdlObject[] = []; + limitByAttr: (attributeId: number, $event: ComboboxEntry) => void; + findPatronByBarcode: () => void; + + setGranularity: () => void; + handleMultiDayReservation: () => void; + handleSingleDayReservation: () => void; + changeGranularity: ($event: ComboboxEntry) => void; + handlePickupLibChange: ($event: IdlObject) => void; + handleTargetResourceChange: ($event: string | number) => void; + + dateRange: DateRange; + + @ViewChildren('dateLimiter') dateLimiters: QueryList; + @ViewChildren('scheduleGrid') scheduleGrids: QueryList; + @ViewChild('rt') rt: ResourceTypeComboboxComponent; + @ViewChild('createDialog') createDialog: CreateReservationDialogComponent; + + idealDate = new Date(); + + constructor( + private auth: AuthService, + private format: FormatService, + private idl: IdlService, + private net: NetService, + private org: OrgService, + private patron: PatronService, + private pcrud: PcrudService, + private route: ActivatedRoute, + private router: Router, + private store: ServerStoreService, + private toast: ToastService, + public reservationValidate: ReservationValidateService + ) { + this.resourceAvailabilityIcon = (row: any) => { + let icon = {icon: 'event_busy', title: 'All resources are reserved at this time'}; + let busy_columns = 0; + for (const key in row) { + if (row[key]) { busy_columns = busy_columns + 1; } + } + if (busy_columns <= this.resources.length) { // equal or less than, since it counts the time column + icon = {icon: 'event_available', title: 'Resources are available at this time'}; + } + return icon; + }; + } + + + ngOnInit() { + this.owningLibraries = [this.auth.user().ws_ou()]; + + this.defaultTimes = { + 'start_time': Moment.tz([], this.format.wsOrgTimezone), + 'end_time': Moment.tz([], this.format.wsOrgTimezone).add(this.granularity, 'minutes') + }; + + this.store.getItem('eg.booking.create.multiday').then(multiday => { + if (multiday) { this.multiday = multiday; }}); + + this.route.paramMap.subscribe((params: ParamMap) => { + this.patronId = +params.get('patron_id'); + this.resourceBarcode = params.get('resource_barcode'); + + if (this.resourceBarcode) { + this.pcrud.search('brsrc', + {'barcode' : this.resourceBarcode}, {'limit': 1}) + .pipe(single()) + .subscribe((res) => { + this.resourceId = res.id(); + this.fetchData(); + }, (err) => { + this.pcrud.search('acp', + {'barcode' : this.resourceBarcode}, {'limit': 1}) + .pipe(single()) + .subscribe((item) => { + this.net.request( 'open-ils.booking', + 'open-ils.booking.resources.create_from_copies', + this.auth.token(), [item.id()]) + .subscribe((response) => { + this.toast.info('Made this barcode bookable'); + this.resourceId = response['brsrc'][0][0]; + }, (error) => { + this.toast.danger('Cannot make this barcode bookable'); + }); + }, (acperror) => { + this.toast.danger('No resource found with this barcode'); + this.resourceId = -1; + }); + }); + } + }); + + this.criteria = new FormGroup({ + 'resourceBarcode': new FormControl(this.resourceBarcode ? this.resourceBarcode : '', + [], (rb) => + timer(800).pipe(switchMap(() => + this.pcrud.search('brsrc', + {'barcode' : rb.value}, + {'limit': 1})), + single(), + mapTo(null), + catchError(() => of({ resourceBarcode: 'No resource found with that barcode' })) + )), + 'startOfDay': new FormControl(this.startOfDay), + 'endOfDay': new FormControl(this.endOfDay), + 'reservationType': new FormControl(this.multiday ? 'multi' : 'single'), + }); + + this.criteria.get('resourceBarcode').valueChanges + .pipe(debounceTime(1000)) + .subscribe((barcode) => { + 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.criteria.get('reservationType').valueChanges.subscribe((val) => { + this.multiday = ('multi' === val); + this.store.setItem('eg.booking.create.multiday', this.multiday); + }); + + this.criteria.valueChanges.subscribe(() => { this.fetchData(); }); + + this.limitByAttr = (attributeId: number, $event: ComboboxEntry) => { + this.selectedAttributes[attributeId] = $event.id; + this.fetchData(); + }; + + this.setGranularity = () => { + if (this.multiday) { // multiday reservations always use day granularity + this.granularity = 1440; + } else { + this.store.getItem('eg.booking.create.granularity').then(granularity => { + if (granularity) { + this.granularity = granularity; + } else { + this.granularity = 30; + } + }); + } + }; + + this.handleDateChange = ($event: Date) => { + this.idealDate = $event; + this.pcrud.retrieve('aouhoo', this.auth.user().ws_ou()) + .subscribe(hours => { + const startArray = hours['dow_' + (this.idealDate.getDay() + 6) % 7 + '_open']().split(':'); + const endArray = hours['dow_' + (this.idealDate.getDay() + 6) % 7 + '_close']().split(':'); + this.startOfDay = { + hour: ('00' === startArray[0]) ? 9 : +startArray[0], + minute: +startArray[1], + second: 0}; + this.endOfDay = { + hour: ('00' === endArray[0]) ? 17 : +endArray[0], + minute: +endArray[1], + second: 0}; + this.fetchData(); + }); + }; + this.handleOwnerChange = ($event: number[]) => { + this.owningLibraries = $event; + this.fetchData(); + }; + + this.changeGranularity = ($event) => { + this.granularity = $event.id; + this.store.setItem('eg.booking.create.granularity', $event.id) + .then(() => this.fetchData()); + }; + + this.handlePickupLibChange = ($event) => { + this.org.settings('lib.timezone', $event.id()).then((tz) => { + if (tz['lib.timezone'] && (this.format.wsOrgTimezone !== tz['lib.timezone'])) { + this.pickupLibUsesDifferentTz = tz['lib.timezone']; + } else { + this.pickupLibUsesDifferentTz = null; + } + }); + }; + + this.minuteStep = () => { + return (this.granularity < 60) ? this.granularity : 30; + }; + + this.dateRange = {fromDate: null, toDate: null}; + + } + + ngAfterViewInit() { + this.dateLimiters.forEach((dl) => dl.initialDate = new Date()); + this.fetchData(); + + this.openTheDialog = (rows: IdlObject[]) => { + return this.createDialog.open({size: 'lg'}).subscribe( + response => { + this.toast.success('Reservation successfully created'); // TODO: needs i18n, pluralization + this.fetchData(); + return response.id(); + } + ); + }; + + } + handleResourceTypeChange($event: ComboboxEntry) { + this.resourceBarcode = null; + this.resourceId = null; + this.resourceTypeId = $event.id; + this.attributes = []; + this.selectedAttributes = []; + if (this.resourceTypeId) { + this.pcrud.search('bra', {resource_type : this.resourceTypeId}, { + order_by: 'name ASC', + flesh: 1, + flesh_fields: {'bra' : ['valid_values']} + }).subscribe( + a => { this.attributes.push(a); + }, err => { + console.debug(err); + }, () => { + this.fetchData(); + }); + } + } + + fetchData () { + this.setGranularity(); + this.resources = []; + let where = {}; + + if (this.resourceId) { + where['id'] = this.resourceId; + } else if (this.resourceTypeId) { + where['type'] = this.resourceTypeId; + where['owner'] = this.owningLibraries; + } else { + return; + } + + if (this.selectedAttributes.length) { + where['id'] = {'in': + {'from': 'bram', 'select': {'bram': ['resource']}, + 'where': {'value': this.selectedAttributes.filter((a) => (a !== null))}}}; + } + this.scheduleSource.data = []; + this.pcrud.search('brsrc', where, { + order_by: 'barcode ASC', + flesh: 1, + flesh_fields: {'brsrc': ['attr_maps']}, + }).subscribe( + r => { + this.resources.push(r); + + let startTime = Moment(); + let endTime = Moment(); + const reservations = []; + + if (this.multiday) { + startTime = this.dateRange.fromDate; + endTime = this.dateRange.toDate; + } else { + this.dateLimiters.forEach((dl) => { + startTime = Moment.tz([ + dl.current.year, + dl.current.month - 1, + dl.current.day, + this.userStartOfDay.hour, + this.userStartOfDay.minute], + this.format.wsOrgTimezone); + endTime = Moment.tz([ + dl.current.year, + dl.current.month - 1, + dl.current.day, + this.userEndOfDay.hour, + this.userEndOfDay.minute], + this.format.wsOrgTimezone); + }); + } + this.pcrud.search('bresv', { + '-or': {'target_resource': r.id(), 'current_resource': r.id()}, + 'end_time': {'>': startTime.toISOString()}, + 'start_time': {'<': endTime.toISOString()}, + 'return_time': null, + 'cancel_time': null }, + {'flesh': 1, 'flesh_fields': {'bresv': ['usr']}}) + .subscribe((res) => { reservations.push(res); }, + (err) => { console.warn(err); }, + () => { + const currentTime = startTime; + while (currentTime < endTime) { + let idx: number; + let existingRow: number; + if (this.multiday) { + existingRow = this.scheduleSource.data.findIndex( + (row) => row['time'] === this.format.transform({value: currentTime, datatype: 'timestamp'})); + idx = (existingRow > -1) ? existingRow : + (this.scheduleSource.data.push( + {'time': this.format.transform({value: currentTime, datatype: 'timestamp'})}) - 1); + } else { + existingRow = this.scheduleSource.data.findIndex((row) => row['time'] === currentTime.format('LT')) ; + idx = (existingRow > -1) ? existingRow : (this.scheduleSource.data.push({'time': currentTime.format('LT')}) - 1); + } + reservations.forEach((reservation) => { + if ((Moment.tz(reservation.start_time(), this.format.wsOrgTimezone) < + (currentTime.clone().add(this.granularity, 'minutes'))) && + (Moment.tz(reservation.end_time(), this.format.wsOrgTimezone) > currentTime)) { + if (!this.scheduleSource.data[idx][r.barcode()]) { this.scheduleSource.data[idx][r.barcode()] = []; } + this.scheduleSource.data[idx][r.barcode()].push( + {'patronLabel': reservation.usr().usrname(), 'patronId': reservation.usr().id()}); + } + }); + currentTime.add(this.granularity, 'minutes'); + } + }); + }); + } + get userStartOfDay() { + return this.criteria.get('startOfDay').value; + } + get userEndOfDay() { + return this.criteria.get('startOfDay').value; + } + +} + 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..774d15c78b --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/manage-reservations.component.html @@ -0,0 +1,73 @@ + + + + +
+
+ + +
+
+
+

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..2de8583eae --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/manage-reservations.component.ts @@ -0,0 +1,131 @@ +import {Component, OnInit, ViewChild} from '@angular/core'; +import {Router, ActivatedRoute, ParamMap} from '@angular/router'; +import {single} from 'rxjs/operators'; +import {NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap'; +import {ComboboxEntry} from '@eg/share/combobox/combobox.component'; +import {GridComponent} from '@eg/share/grid/grid.component'; +import {PatronService} from '@eg/staff/share/patron.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'; + +@Component({ + selector: 'eg-manage-reservations', + templateUrl: './manage-reservations.component.html', +}) +export class ManageReservationsComponent implements OnInit { + + pickupLibIds: number[]; + patronBarcode: string; + patronId: number; + resourceBarcode: string; + resourceId: number; + resourceTypeId: number; + selectedFilter: 'patron' | 'resource' | 'type' = 'patron'; + + @ViewChild('reservationsGrid') reservationsGrid: ReservationsGridComponent; + + handlePickupLibChange: ($event: number[]) => void; + filterByCurrentPatronBarcode: () => void; + filterByCurrentResourceBarcode: () => void; + filterByResourceType: (selected: ComboboxEntry) => void; + removeFilters: () => void; + setStickyFilter: ($event: NgbTabChangeEvent) => void; + + constructor( + private route: ActivatedRoute, + private router: Router, + private pcrud: PcrudService, + private patron: PatronService, + private store: ServerStoreService, + private toast: ToastService + ) { + } + + ngOnInit() { + this.route.paramMap.subscribe((params: ParamMap) => { + this.patronId = +params.get('patron_id'); + this.resourceBarcode = params.get('resource_barcode'); + this.resourceTypeId = +params.get('resource_type_id'); + + if (this.patronId) { + this.pcrud.search('au', { + 'id': this.patronId, + }, { + limit: 1, + flesh: 1, + flesh_fields: {'au': ['card']} + }).subscribe( + (resp) => { + this.reservationsGrid.reloadGrid(); + this.patronBarcode = resp.card().barcode(); }, + (err) => { console.debug(err); } + ); + } else if (this.resourceBarcode) { + this.selectedFilter = 'resource'; + this.pcrud.search('brsrc', + {'barcode' : this.resourceBarcode}, {'limit': 1}) + .pipe(single()) + .subscribe((res) => { + this.resourceId = res.id(); + this.reservationsGrid.reloadGrid(); + }, (err) => { + this.resourceId = -1; + this.toast.danger('No resource found with this barcode'); + }); + } else if (this.resourceTypeId) { + this.selectedFilter = 'type'; + this.reservationsGrid.reloadGrid(); + } + + if (!(this.patronId)) { + this.store.getItem('eg.booking.manage.filter').then(filter => { + if (filter) { this.selectedFilter = filter; } + }); + } + }); + + this.handlePickupLibChange = ($event: number[]) => { + this.pickupLibIds = $event; + this.reservationsGrid.reloadGrid(); + }; + + this.setStickyFilter = ($event: NgbTabChangeEvent) => { + this.store.setItem('eg.booking.manage.filter', $event.nextId); + }; + + this.removeFilters = () => { + this.router.navigate(['/staff', 'booking', 'manage_reservations']); + }; + + this.filterByCurrentPatronBarcode = () => { + if (this.patronBarcode) { + this.patron.bcSearch(this.patronBarcode).pipe(single()).subscribe( + (response) => { + this.router.navigate(['/staff', 'booking', 'manage_reservations', 'by_patron', response[0].id]); + }, (error) => { + this.toast.danger('No patron found with this barcode'); + }); + } else { + this.removeFilters(); + } + }; + + this.filterByCurrentResourceBarcode = () => { + if (this.resourceBarcode) { + this.router.navigate(['/staff', 'booking', 'manage_reservations', 'by_resource', this.resourceBarcode]); + } else { + this.removeFilters(); + } + }; + + this.filterByResourceType = (selected: ComboboxEntry) => { + if (selected.id) { + this.router.navigate(['/staff', 'booking', 'manage_reservations', 'by_resource_type', selected.id]); + } + }; + + } +} + 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..2d6282f2aa --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/no-timezone-set.component.ts @@ -0,0 +1,16 @@ +import {Component, Input, ViewChild, TemplateRef} 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..5073d79c3e --- /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..1ec9da7924 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/pickup.component.ts @@ -0,0 +1,84 @@ +import {Component, Input, OnInit, ViewChild} from '@angular/core'; +import {single, tap} from 'rxjs/operators'; +import {Pager} from '@eg/share/util/pager'; +import {PatronService} from '@eg/staff/share/patron.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {AuthService} from '@eg/core/auth.service'; +import {IdlObject} from '@eg/core/idl.service'; +import {NetService} from '@eg/core/net.service'; +import {ReservationsGridComponent} from './reservations-grid.component'; +import {Router, ActivatedRoute, ParamMap} from '@angular/router'; +import {ServerStoreService} from '@eg/core/server-store.service'; +import {ToastService} from '@eg/share/toast/toast.service'; + + +@Component({ + templateUrl: './pickup.component.html' +}) + +export class PickupComponent implements OnInit { + patronBarcode: string; + patronId: number; + retrievePatron: () => void; + + @ViewChild('readyGrid') readyGrid: ReservationsGridComponent; + @ViewChild('pickedUpGrid') pickedUpGrid: ReservationsGridComponent; + + noSelectedRows: (rows: IdlObject[]) => boolean; + + onlyShowCaptured = true; + handleShowCapturedChange: () => void; + + constructor( + private auth: AuthService, + private net: NetService, + private pcrud: PcrudService, + private patron: PatronService, + private route: ActivatedRoute, + private router: Router, + private store: ServerStoreService, + private toast: ToastService + ) { + } + + + ngOnInit() { + this.route.paramMap.subscribe((params: ParamMap) => { + this.patronId = +params.get('patron_id'); + this.pcrud.search('au', { + 'id': this.patronId, + }, { + limit: 1, + flesh: 1, + flesh_fields: {'au': ['card']} + }).subscribe( + (resp) => { + this.patronBarcode = resp.card().barcode(); + this.readyGrid.reloadGrid(); + this.pickedUpGrid.reloadGrid(); + }, (err) => { console.debug(err); } + ); + }); + + this.retrievePatron = () => { + if (this.patronBarcode) { + this.patron.bcSearch(this.patronBarcode).pipe(single()).subscribe( + resp => { this.router.navigate(['/staff', 'booking', 'pickup', 'by_patron', resp[0].id]); }, + err => { this.toast.danger('No patron found with this barcode'); }, + ); + } + }; + + 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); + }; + + + } +} + 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..c17494a0c2 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/pull-list.component.html @@ -0,0 +1,40 @@ + + + + +
+
+
+
+ +
+ + +
+
+
+
+
+ +
+ +
+
+
+ + + + + + + + + + + + + + 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..44cc05acd6 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/pull-list.component.ts @@ -0,0 +1,74 @@ +import {Component, Input, OnInit} from '@angular/core'; +import {FormControl, FormGroup, Validators} from '@angular/forms'; +import {AuthService} from '@eg/core/auth.service'; +import {GridColumn, GridDataSource} from '@eg/share/grid/grid'; +import {IdlObject} from '@eg/core/idl.service'; +import {NetRequest, NetService} from '@eg/core/net.service'; +import {OrgService} from '@eg/core/org.service'; +import {Pager} from '@eg/share/util/pager'; +import {PcrudService} from '@eg/core/pcrud.service'; + + +@Component({ + templateUrl: './pull-list.component.html' +}) + +export class PullListComponent implements OnInit { + public dataSource: GridDataSource; + + public disableOrgs: () => number[]; + public fill_grid: (orgId?: number) => void; + pullListCriteria: FormGroup; + + constructor( + private auth: AuthService, + private net: NetService, + private org: OrgService, + private pcrud: PcrudService + ) { } + + + ngOnInit() { + this.pullListCriteria = new FormGroup({ + 'daysHence': new FormControl(5, [ + Validators.required, + Validators.min(1)]) + }); + + this.pullListCriteria.valueChanges.subscribe(() => { this.fill_grid(); }); + + this.disableOrgs = () => this.org.filterList( { canHaveVolumes : false }, true); + + this.fill_grid = (orgId = this.auth.user().ws_ou()) => { + this.net.request( + 'open-ils.booking', 'open-ils.booking.reservations.get_pull_list', + this.auth.token(), null, + (86400 * this.daysHence.value), // convert seconds to days + orgId + ).subscribe( data => { + data.forEach(resource => { // shouldn't this be streamable? + if (resource['target_resource_type'].catalog_item()) { + this.pcrud.search('acp', { + 'barcode': resource['current_resource'].barcode() + }, { + limit: 1, + flesh: 1, + flesh_fields: {'acp' : ['call_number', 'location' ]} + }).subscribe( (acp) => { + resource['call_number'] = acp.call_number().label(); + resource['call_number_sortkey'] = acp.call_number().label_sortkey(); + resource['shelving_location'] = acp.location().name(); + }); + } + }); + this.dataSource.data = data; + }); + }; + this.dataSource = new GridDataSource(); + this.fill_grid(this.auth.user().ws_ou()); + } + get daysHence() { + return this.pullListCriteria.get('daysHence'); + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/booking/reservation-validate.service.ts b/Open-ILS/src/eg2/src/app/staff/booking/reservation-validate.service.ts new file mode 100644 index 0000000000..d2be8ab0d9 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/reservation-validate.service.ts @@ -0,0 +1,62 @@ +import {Injectable} from '@angular/core'; +import {IdlObject} from '@eg/core/idl.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import * as Moment from 'moment-timezone'; + +@Injectable({providedIn: 'root'}) +export class ReservationValidateService { + + constructor( + private pcrud: PcrudService, + ) { + } + errorMessage = ''; + + private duringExistingReservation = (value: Moment, record: IdlObject) => { + if (record.target_resource()) { + this.pcrud.search('bresv', { + 'cancel_time': null, + 'return_time': null, + 'start_time': {'<': value.toISOString()}, + 'end_time': {'>': value.toISOString()}, + '-or': {'current_resource': record.target_resource(), 'target_resource': record.target_resource()}}) + .subscribe((foundOne) => {this.errorMessage = 'There is already a reservation for this resource at this time.'}); + } + } + + start_time = (fieldName: string, value: Moment, record: IdlObject) => { + this.errorMessage = ''; + this.duringExistingReservation(value, record); + if (record.target_resource_type() && record.target_resource()) { + this.pcrud.retrieve('brt', record.target_resource_type()) + .subscribe((brt) => { + if (brt.catalog_item()) { + this.pcrud.retrieve('brsrc', record.target_resource()) + .subscribe((brsrc) => { + this.pcrud.search('circ', { + 'checkin_time': 'null', + 'target_copy': {'barcode': brsrc.barcode()}, + 'due_date': {'>': value.toISOString()}}, + {'flesh': 1, 'flesh_fields': {'circ': ['target_copy']}}) + .subscribe(() => {this.errorMessage = 'Start time conflicts with an existing circulation';}); + }); + } + }); + } + if (Moment(value) < Moment()) { + this.errorMessage = 'Start time must be in the future'; + } + return this.errorMessage; + } + + end_time = (fieldName: string, value: Moment, record: IdlObject) => { + this.errorMessage = ''; + this.duringExistingReservation(value, record); + if (Moment(value) <= Moment(record.start_time())) { + return 'End time must be after start time'; + } + return ''; + } + +} + 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..85f182f928 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/reservations-grid.component.html @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..e644c3125a --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/reservations-grid.component.ts @@ -0,0 +1,294 @@ +import {Component, EventEmitter, Input, Output, OnInit, ViewChild} from '@angular/core'; +import {Observable} from 'rxjs'; +import {tap} from 'rxjs/operators'; +import {AuthService} from '@eg/core/auth.service'; +import {ComboboxEntry} from '@eg/share/combobox/combobox.component'; +import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component'; +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 {Router, ActivatedRoute, ParamMap} from '@angular/router'; +import {NetService} from '@eg/core/net.service'; +import {NoTimezoneSetComponent} from './no-timezone-set.component'; +import {OrgService} from '@eg/core/org.service'; +import {PatronService} from '@eg/staff/share/patron.service'; +import {ReservationValidateService} from './reservation-validate.service'; + +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() resource: number; + @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: ConfirmDialogComponent; + @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; + filterByCurrentPatronBarcode: () => void; + filterByCurrentResourceBarcode: () => void; + listReadOnlyFields: () => string; + + handleRowActivate: (row: IdlObject) => void; + redirectToCreate: () => void; + + reloadGrid: () => void; + + noSelectedRows: (rows: IdlObject[]) => boolean; + notOnePatronSelected: (rows: IdlObject[]) => boolean; + notOneResourceSelected: (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 route: ActivatedRoute, + private router: Router, + private toast: ToastService, + private net: NetService, + private org: OrgService, + private patronService: PatronService, + public reservationValidate: ReservationValidateService + ) { + + } + + ngOnInit() { + if (!(this.format.wsOrgTimezone)) { + this.noTimezoneSetDialog.open(); + } + + + this.gridSource = new GridDataSource(); + + this.gridSource.getRows = (pager: Pager, sort: any[]) => { + 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.resource) { + where['current_resource'] = this.resource; + } + 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()}; + } + } + 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(tap((row) => { + row['length'] = Moment(row['end_time']()).from(Moment(row['start_time']()), true); + this.org.settings('lib.timezone', row['pickup_lib']()).then((tz) => { row['timezone'] = tz['lib.timezone']; }); + })); + }; + + 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[]) => { + const reservationIds = reservations.map(reservation => reservation.id()); + this.numRowsSelected = reservationIds.length; + this._cancelReservationDialog.open() + .subscribe( + confirmed => {this.net.request( + 'open-ils.booking', + 'open-ils.booking.reservations.cancel', + this.auth.token(), reservationIds) + .subscribe( + (res) => this.handleSuccessfulCancel(res), + (err) => alert('ERR: ' + JSON.stringify(err)) + ); + }); + }; + + this.viewByPatron = (reservations: IdlObject[]) => { + const patronIds = reservations.map(reservation => reservation.usr().id()); + this.router.navigate(['/staff', 'booking', 'manage_reservations', 'by_patron', patronIds[0]]); + }; + + this.viewByResource = (reservations: IdlObject[]) => { + const resourceBarcodes = reservations.map(reservation => reservation.current_resource().barcode()); + this.filterByResourceBarcode(resourceBarcodes[0]); + }; + + this.noSelectedRows = (rows: IdlObject[]) => (rows.length === 0); + this.notOnePatronSelected = (rows: IdlObject[]) => (new Set(rows.map(row => row.usr().id())).size !== 1); + this.notOneResourceSelected = (rows: IdlObject[]) => + (new Set(rows.map(row => { if (row.current_resource()) { return row.current_resource().id(); }})).size !== 1); + 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( + (success) => { + this.onPickup.emit(reservation); + this.grid.reload(); }, + (error) => { console.debug(error); } + )); + }; + + 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( + (success) => { this.grid.reload(); }, + (error) => { console.debug(error); } + )); + }; + + 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']); + }; + } + + showEditDialog(idlThing: IdlObject) { + this.editDialog.recId = idlThing.id(); + this.editDialog.timezone = idlThing['timezone']; + return this.editDialog.open({size: 'lg'}).pipe(tap( + ok => { + this.toast.success('Reservation successfully updated'); // TODO: needs i18n, pluralization + this.grid.reload(); + } + )); + } + + handleSuccessfulCancel(res: any) { + this.toast.success('Reservation successfully canceled'); // TODO: needs i18n, pluralization + this.grid.reload(); + } + filterByResourceBarcode(barcode: string) { + this.router.navigate(['/staff', 'booking', 'manage_reservations', 'by_resource', barcode]); + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/booking/resource-type-combobox.component.ts b/Open-ILS/src/eg2/src/app/staff/booking/resource-type-combobox.component.ts new file mode 100644 index 0000000000..54d87ad277 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/resource-type-combobox.component.ts @@ -0,0 +1,45 @@ +import {Component, EventEmitter, OnInit, Input, Output, ViewChild} from '@angular/core'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {ComboboxComponent} from '@eg/share/combobox/combobox.component'; +import {ComboboxEntry} from '@eg/share/combobox/combobox.component'; + +@Component({ + selector: 'eg-resource-type-combobox', + template: `` +}) +export class ResourceTypeComboboxComponent implements OnInit { + + resourceTypes: ComboboxEntry[]; + + clear: () => void; + + @Input() domId = ''; + @Input() startId: number; + @Output() typeChanged: EventEmitter; + + @ViewChild('resourceTypeCombobox') resourceTypeCombobox: ComboboxComponent; + + constructor(private pcrud: PcrudService) { + this.typeChanged = new EventEmitter(); + } + + ngOnInit() { + this.pcrud.retrieveAll('brt', {order_by: {brt: 'name'}}) + .subscribe(type => { + if (!this.resourceTypes) { this.resourceTypes = []; } + this.resourceTypes.push({id: type.id(), label: type.name()}); + }, (err) => {}, + () => {this.resourceTypes.sort((a, b) => a.label.localeCompare(b.label)); }); + this.clear = () => { + this.resourceTypeCombobox.selected = {id: '', label: ''}; + }; + } + +} + 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..eb903beace --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/return.component.html @@ -0,0 +1,45 @@ + + + + + + + +
+
+
+
+ + +
+
+
+
+
+

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..9fb508e027 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/booking/return.component.ts @@ -0,0 +1,111 @@ +import {Component, Input, OnInit, QueryList, ViewChildren} from '@angular/core'; +import {Router, ActivatedRoute, ParamMap} from '@angular/router'; +import {NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap'; +import {single} from 'rxjs/operators'; +import {GridDataSource} from '@eg/share/grid/grid'; +import {Pager} from '@eg/share/util/pager'; +import {PatronService} from '@eg/staff/share/patron.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {AuthService} from '@eg/core/auth.service'; +import {IdlObject} from '@eg/core/idl.service'; +import {NetService} from '@eg/core/net.service'; +import {ReservationsGridComponent} from './reservations-grid.component'; +import {ServerStoreService} from '@eg/core/server-store.service'; +import {ToastService} from '@eg/share/toast/toast.service'; + + +@Component({ + templateUrl: './return.component.html' +}) + +export class ReturnComponent implements OnInit { + resourceBarcode: string; + patronBarcode: string; + patronId: number; + retrievePatronByBarcode: () => void; + retrievePatronByResource: () => void; + selectedTab: 'patron' | 'resource' = 'patron'; + + noSelectedRows: (rows: IdlObject[]) => boolean; + handleTabChange: ($event: NgbTabChangeEvent) => void; + @ViewChildren('readyGrid') readyGrids: QueryList; + @ViewChildren('returnedGrid') returnedGrids: QueryList; + + constructor( + private auth: AuthService, + private net: NetService, + private pcrud: PcrudService, + private patron: PatronService, + private route: ActivatedRoute, + private router: Router, + private store: ServerStoreService, + private toast: ToastService + ) { + } + + + ngOnInit() { + this.route.paramMap.subscribe((params: ParamMap) => { + this.patronId = +params.get('patron_id'); + if (this.patronId) { + this.pcrud.search('au', { + 'id': this.patronId, + }, { + limit: 1, + flesh: 1, + flesh_fields: {'au': ['card']} + }).subscribe( + (resp) => { + this.patronBarcode = resp.card().barcode(); + this.readyGrids.forEach (readyGrid => readyGrid.reloadGrid()); + this.returnedGrids.forEach (returnedGrid => returnedGrid.reloadGrid()); + }, (err) => { console.debug(err); } + ); + } else { + this.store.getItem('eg.booking.return.tab').then(tab => { + if (tab) { this.selectedTab = tab; } + }); + } + }); + + this.retrievePatronByBarcode = () => { + if (this.patronBarcode) { + this.patron.bcSearch(this.patronBarcode).pipe(single()).subscribe( + resp => { this.router.navigate(['/staff', 'booking', 'return', 'by_patron', resp[0].id]); }, + err => { this.toast.danger('No patron found with this barcode'); }, + ); + } + }; + + this.retrievePatronByResource = () => { + if (this.resourceBarcode) { + this.pcrud.search('brsrc', {'barcode': this.resourceBarcode}, { + 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}}} + }).subscribe((resp) => { + if (resp.curr_rsrcs()[0].usr()) { + this.patronId = resp.curr_rsrcs()[0].usr(); + this.readyGrids.forEach (readyGrid => readyGrid.reloadGrid()); + this.returnedGrids.forEach (returnedGrid => returnedGrid.reloadGrid()); + } + }); + } + }; + 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.resourceBarcode = null; + this.patronBarcode = null; + this.patronId = null; + }); + }; + + } +} + 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/common.module.ts b/Open-ILS/src/eg2/src/app/staff/common.module.ts index e4f1fadbd9..6d5a763643 100644 --- a/Open-ILS/src/eg2/src/app/staff/common.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/common.module.ts @@ -28,6 +28,8 @@ import {EgHelpPopoverComponent} from '@eg/share/eg-help-popover/eg-help-popover. import {ReactiveFormsModule} from '@angular/forms'; import {DatetimeValidatorDirective} from '@eg/share/validators/datetime_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. */ @@ -55,6 +57,7 @@ import {DatetimeValidatorDirective} from '@eg/share/validators/datetime_validato AdminPageComponent, EgHelpPopoverComponent, DatetimeValidatorDirective, + PatronBarcodeValidatorDirective ], imports: [ EgCommonModule, @@ -85,6 +88,7 @@ import {DatetimeValidatorDirective} from '@eg/share/validators/datetime_validato AdminPageComponent, EgHelpPopoverComponent, DatetimeValidatorDirective, + 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 8034f4174e..36610177f8 100644 --- a/Open-ILS/src/eg2/src/app/staff/nav.component.html +++ b/Open-ILS/src/eg2/src/app/staff/nav.component.html @@ -282,11 +282,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 @@ -19,6 +19,9 @@ const routes: Routes = [{ 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/styles.css b/Open-ILS/src/eg2/src/styles.css index 3fa7281404..b35cd02399 100644 --- a/Open-ILS/src/eg2/src/styles.css +++ b/Open-ILS/src/eg2/src/styles.css @@ -21,7 +21,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 81620b8b7d..d47f08b34b 100644 --- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql +++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql @@ -19970,3 +19970,91 @@ VALUES ( 'cwst', 'label' ) ); + +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.filter', 'gui', 'string', + oils_i18n_gettext( + 'booking.manage.filter', + 'Sticky setting for filter tab in Manage Reservations', + '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..aa53c4bf19 --- /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.filter', 'gui', 'string', + oils_i18n_gettext( + 'booking.manage.filter', + 'Sticky setting for filter tab in Manage Reservations', + '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 b694d6bc6c..4250fdacf3 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('Add Items to Bucket') %]
  • [% 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 c0ac0c25d5..a462967c8e 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 f0ee94d9db..700668aba9 100644 --- a/Open-ILS/src/templates/staff/navbar.tt2 +++ b/Open-ILS/src/templates/staff/navbar.tt2 @@ -432,13 +432,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 2988d9504c..6061df098e 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 @@ -884,75 +884,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; @@ -1022,6 +957,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 fc3e823dfa..6afa30558f 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 @@ -94,10 +94,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() { @@ -135,6 +132,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]); } @@ -481,7 +482,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 c39f0380be..598658e007 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) { diff --git a/docs/RELEASE_NOTES_NEXT/Circulation/booking-refresh.adoc b/docs/RELEASE_NOTES_NEXT/Circulation/booking-refresh.adoc new file mode 100644 index 0000000000..5e39e8debe --- /dev/null +++ b/docs/RELEASE_NOTES_NEXT/Circulation/booking-refresh.adoc @@ -0,0 +1,32 @@ +Booking Module Refresh +^^^^^^^^^^^^^^^^^^^^^^ + +The Booking module has been redesigned, with many of its interfaces being +redesigned in Angular. + +This adds a new screen called "Manage Reservations", where staff can check details about +all outstanding reservations, including those that have been recently placed, captured, +picked up, or recently returned. + +On many screens within the new booking module, staff are able to edit reservations. Previously, +they would have needed to cancel and recreate those reservations with the new data. + +There is a new notes field attached to reservations, where staff can leave notes about the +reservation. One use case is to alert staff that a particular resource is being stored in +an unfamiliar location. This field is visible on all screens within the booking module. + +The Create Reservations UI is completely re-designed, and now includes a calendar-like view +on which staff can view existing reservations and availability. + +Upgrade considerations +++++++++++++++++++++++ + +The Booking Module Refresh requires some new dependencies for the Angular +client. To install these, you will have to run the following commands: + +[source,bash] +---- +cd $EVERGREEN_ROOT/Open-ILS/src/eg2/ +npm install +---- + diff --git a/docs/circulation/booking.adoc b/docs/circulation/booking.adoc index 18a62ae1a2..917d7c1ac1 100644 --- a/docs/circulation/booking.adoc +++ b/docs/circulation/booking.adoc @@ -4,6 +4,11 @@ Booking Module Creating a Booking Reservation ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +[NOTE] +The "Create a booking reservation" screen uses your library's timezone. If you create a reservation at a library +in a different timezone, Evergreen will alert you and provide the time in both your timezone and the other library's +timezone. + Only staff members may create reservations. A reservation can be started from a patron record, or a booking resource. To reserve catalogued items, you may start from searching the catalogue, if you do not know the booking item's barcode. To create a reservation from a patron record -- 2.11.0