LP1816475: Booking module refresh
authorJane Sandberg <sandbej@linnbenton.edu>
Thu, 25 Jul 2019 17:28:47 +0000 (10:28 -0700)
committerGalen Charlton <gmc@equinoxinitiative.org>
Fri, 6 Sep 2019 14:11:58 +0000 (10:11 -0400)
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 <sandbej@linnbenton.edu>
Signed-off-by: Christine Burns <christine.burns@bc.libraries.coop>
Signed-off-by: Galen Charlton <gmc@equinoxinitiative.org>
57 files changed:
Open-ILS/examples/fm_IDL.xml
Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.html
Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts
Open-ILS/src/eg2/src/app/share/grid/grid-column.component.ts
Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html
Open-ILS/src/eg2/src/app/share/grid/grid.component.html
Open-ILS/src/eg2/src/app/share/grid/grid.component.ts
Open-ILS/src/eg2/src/app/share/grid/grid.ts
Open-ILS/src/eg2/src/app/share/validators/not_before_moment_validator.directive.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/validators/patron_barcode_validator.directive.spec.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/validators/patron_barcode_validator.directive.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/booking/booking.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/booking/booking_resource_validator.directive.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/booking/cancel-reservation-dialog.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/booking/create-reservation-dialog.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/booking/create-reservation-dialog.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/booking/create-reservation.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/booking/create-reservation.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/booking/manage-reservations.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/booking/manage-reservations.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/booking/no-timezone-set.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/booking/no-timezone-set.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/booking/pickup.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/booking/pickup.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/booking/pull-list.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/booking/pull-list.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/booking/reservation-actions.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/booking/reservation-actions.spec.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/booking/reservations-grid.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/booking/reservations-grid.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/booking/return.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/booking/return.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/booking/routing.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/booking/schedule-grid.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/booking/schedule-grid.spec.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.html
Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.ts
Open-ILS/src/eg2/src/app/staff/common.module.ts
Open-ILS/src/eg2/src/app/staff/nav.component.html
Open-ILS/src/eg2/src/app/staff/routing.module.ts
Open-ILS/src/eg2/src/app/staff/share/patron.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/polyfills.ts
Open-ILS/src/eg2/src/styles.css
Open-ILS/src/perlmods/lib/OpenILS/Application/Booking.pm
Open-ILS/src/sql/Pg/095.schema.booking.sql
Open-ILS/src/sql/Pg/950.data.seed-values.sql
Open-ILS/src/sql/Pg/upgrade/XXXX.data.booking-sticky-settings.sql [new file with mode: 0644]
Open-ILS/src/sql/Pg/upgrade/XXXX.schema.add_note_bresv.sql [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/catalog/t_holdings.tt2
Open-ILS/src/templates/staff/cat/item/index.tt2
Open-ILS/src/templates/staff/cat/item/t_list.tt2
Open-ILS/src/templates/staff/circ/patron/index.tt2
Open-ILS/src/templates/staff/navbar.tt2
Open-ILS/web/js/ui/default/booking/capture.js
Open-ILS/web/js/ui/default/staff/cat/catalog/app.js
Open-ILS/web/js/ui/default/staff/cat/item/app.js
Open-ILS/web/js/ui/default/staff/circ/services/item.js

index de6933f..6b1cf45 100644 (file)
@@ -3651,7 +3651,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
                        <field reporter:label="Last Name" name="family_name"  reporter:datatype="text"/>
                        <field reporter:label="First Name" name="first_given_name"  reporter:datatype="text"/>
                        <field reporter:label="Home Library" name="home_ou" reporter:datatype="org_unit"/>
-                       <field reporter:label="User ID" name="id" reporter:datatype="id" />
+                       <field reporter:label="User ID" name="id" reporter:datatype="id" reporter:selector="usrname" />
                        <field reporter:label="Primary Identification Type" name="ident_type" reporter:datatype="link"/>
                        <field reporter:label="Secondary Identification Type" name="ident_type2" reporter:datatype="link"/>
                        <field reporter:label="Primary Identification" name="ident_value"  reporter:datatype="text"/>
@@ -5137,8 +5137,8 @@ SELECT  usr,
                        <field reporter:label="Payment Totals" name="payment_total" oils_persist:virtual="true" reporter:datatype="money"/>
                        <field reporter:label="Payment Summary" name="summary" oils_persist:virtual="true" reporter:datatype="link"/>
                        <field reporter:label="Request Time" name="request_time" reporter:datatype="timestamp"/>
-                       <field reporter:label="Start Time" name="start_time" reporter:datatype="timestamp"/>
-                       <field reporter:label="End Time" name="end_time" reporter:datatype="timestamp"/>
+                       <field reporter:label="Start Time" name="start_time" reporter:datatype="timestamp" oils_obj:required="true"/>
+                       <field reporter:label="End Time" name="end_time" reporter:datatype="timestamp" oils_obj:required="true"/>
                        <field reporter:label="Capture Time" name="capture_time" reporter:datatype="timestamp"/>
                        <field reporter:label="Cancel Time" name="cancel_time" reporter:datatype="timestamp"/>
                        <field reporter:label="Pickup Time" name="pickup_time" reporter:datatype="timestamp"/>
@@ -5154,6 +5154,7 @@ SELECT  usr,
                        <field reporter:label="Pickup Library" name="pickup_lib" reporter:datatype="link"/>
                        <field reporter:label="Capture Staff" name="capture_staff" reporter:datatype="link"/>
                        <field reporter:label="Notify by Email?" name="email_notify" reporter:datatype="bool"/>
+                       <field reporter:label="Note" name="note" reporter:datatype="text"/>
                        <field reporter:label="Attribute Value Maps" name="attr_val_maps" oils_persist:virtual="true" reporter:datatype="link"/>
                </fields>
                <links>
index 85c0c65..bb0e347 100644 (file)
               </eg-date-select>
             </ng-container>
 
+            <ng-container *ngSwitchCase="'timestamp-timepicker'">
+              <eg-datetime-select
+                [showTZ]="timezone"
+                [timezone]="timezone"
+                domId="{{idPrefix}}-{{field.name}}"
+                (onChangeAsIso)="record[field.name]($event)"
+                i18n-validatorError
+                [readOnly]="field.readOnly"
+                initialIso="{{record[field.name]()}}">
+              </eg-datetime-select>
+            </ng-container>
+
             <ng-container *ngSwitchCase="'org_unit'">
               <eg-org-select
                 placeholder="{{field.label}}..."
                 (ngModelChange)="record[field.name]($event)"/>
             </ng-container>
   
+           <ng-container *ngSwitchCase="'readonly-au'">
+              <ng-container *ngIf="field.linkedValues">
+                <a href="/eg/staff/circ/patron/{{field.linkedValues[0].id}}/checkout" target="_blank">{{field.linkedValues[0].label}}
+                <span class="material-icons" i18n-title title="Open user record in new tab">open_in_new</span></a>
+              </ng-container>
+            </ng-container>
+
             <ng-container *ngSwitchCase="'list'">
               <eg-combobox
                 id="{{idPrefix}}-{{field.name}}" name="{{field.name}}"
index b6e2638..dcb085b 100644 (file)
@@ -11,7 +11,7 @@ import {StringComponent} from '@eg/share/string/string.component';
 import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
 import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
 import {TranslateComponent} from '@eg/staff/share/translate/translate.component';
-
+import {FormatService} from '@eg/core/format.service';
 
 interface CustomFieldTemplate {
     template: TemplateRef<any>;
@@ -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
     }
 }
 
-
index b616b82..c612eb4 100644 (file)
@@ -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<any>;
@@ -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);
     }
index 55ca188..3b22377 100644 (file)
@@ -92,7 +92,7 @@
       title="Expand Cells Vertically" i18n-title
       class="material-icons mat-icon-in-button">expand_more</span>
     <span *ngIf="gridContext.overflowCells"
-      title="Collaps Cells Vertically" i18n-title
+      title="Collapse Cells Vertically" i18n-title
       class="material-icons mat-icon-in-button">expand_less</span>
   </button>
 
   </div>
 
 <div>
-
-
-
index e29eb67..6301eec 100644 (file)
@@ -1,7 +1,7 @@
 
 <div class="eg-grid" role="grid">
 
-  <eg-grid-toolbar
+  <eg-grid-toolbar #toolbar
     [gridContext]="context"
     [gridPrinter]="gridPrinter"
     [colWidthConfig]="colWidthConfig"
index 69edbf3..29827bf 100644 (file)
@@ -1,10 +1,11 @@
 import {Component, Input, Output, OnInit, AfterViewInit, EventEmitter,
-    OnDestroy, ViewEncapsulation} from '@angular/core';
+    OnDestroy, ViewChild, ViewEncapsulation} from '@angular/core';
 import {IdlService} from '@eg/core/idl.service';
 import {OrgService} from '@eg/core/org.service';
 import {ServerStoreService} from '@eg/core/server-store.service';
 import {FormatService} from '@eg/core/format.service';
 import {GridContext, GridColumn, GridDataSource, GridRowFlairEntry} from './grid';
+import {GridToolbarComponent} from './grid-toolbar.component';
 
 /**
  * Main grid entry point.
@@ -125,6 +126,8 @@ export class GridComponent implements OnInit, AfterViewInit, OnDestroy {
     @Output() onRowActivate: EventEmitter<any>;
     @Output() onRowClick: EventEmitter<any>;
 
+    @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();
     }
index 7835f45..01b5c09 100644 (file)
@@ -28,6 +28,7 @@ export class GridColumn {
     datatype: string;
     datePlusTime: boolean;
     ternaryBool: boolean;
+    timezoneContextOrg: number;
     cellTemplate: TemplateRef<any>;
     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 (file)
index 0000000..7b6f1fc
--- /dev/null
@@ -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 (file)
index 0000000..1e1208e
--- /dev/null
@@ -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 (file)
index 0000000..81d1b15
--- /dev/null
@@ -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<any>) => {
+        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 (file)
index 0000000..65da637
--- /dev/null
@@ -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 (file)
index 0000000..5e3fa71
--- /dev/null
@@ -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 (file)
index 0000000..022ef96
--- /dev/null
@@ -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: `
+  <eg-confirm-dialog #confirmCancelReservationDialog
+    i18n-dialogTitle i18n-dialogBody
+    dialogTitle="Confirm Cancelation"
+    [dialogBodyTemplate]="confirmMessage">
+  </eg-confirm-dialog>
+  <ng-template #confirmMessage>
+    <span i18n>
+      Are you sure you want to cancel
+      {reservations.length, plural, =1 {this reservation} other {these {{reservations.length}} reservations}}?
+    </span>
+  </ng-template>
+  `
+})
+
+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 (file)
index 0000000..1fe0195
--- /dev/null
@@ -0,0 +1,86 @@
+<ng-template #dialogContent>
+  <div class="modal-header bg-info">
+    <h3 class="modal-title" i18n>Confirm Reservation Details</h3>
+    <button type="button" class="close"
+      i18n-aria-label aria-label="Close"
+      (click)="dismiss('cross_click')">
+      <span aria-hidden="true">&times;</span>
+    </button>
+  </div>
+  <form class="modal-body form-common" [formGroup]="create">
+    <div class="form-group row">
+      <label class="col-lg-4 text-right font-weight-bold"
+        i18n for="create-patron-barcode">Patron barcode</label>
+      <input type="text" id="create-patron-barcode"
+        class="form-control col-lg-7" formControlName="patronBarcode">
+      <span class="col-lg-7 offset-lg-4" i18n>
+        {{ (patron$ | async)?.first_given_name}}
+        {{ (patron$ | async)?.second_given_name}}
+        {{ (patron$ | async)?.family_name}}
+      </span>
+    </div>
+    <div class="form-group row">
+      <label class="col-lg-4 text-right font-weight-bold"
+        i18n for="create-end-time">Start time</label>
+      <eg-datetime-select
+        formControlName="startTime"
+        [timezone]="timezone">
+      </eg-datetime-select>
+    </div>
+    <div class="form-group row">
+      <label class="col-lg-4 text-right font-weight-bold"
+        i18n for="create-end-time">End time</label>
+      <eg-datetime-select
+        formControlName="endTime"
+        [timezone]="timezone">
+      </eg-datetime-select>
+      <div role="alert" class="alert alert-danger" *ngIf="create.errors && create.errors.startTimeNotBeforeEndTime">
+        <span class="material-icons" aria-hidden="true">error</span>
+        <span i18n>Start time must be before end time</span>
+      </div>
+    </div>
+    <div class="form-group row">
+      <label class="col-lg-4 text-right font-weight-bold"
+        i18n for="create-pickup-library">Pickup library</label>
+      <eg-org-select domId="create-pickup-library" [applyDefault]="true"
+        [disableOrgs]="disableOrgs()" [hideOrgs]="disableOrgs()"
+        (onChange)="handlePickupLibChange($event)">
+      </eg-org-select>
+    </div>
+    <div *ngIf="pickupLibraryUsesDifferentTz"
+      role="alert"
+      class="alert alert-info">
+      <span class="material-icons" aria-hidden="true">access_time</span>
+      <span i18n>Pickup Library is in the {{timezone}} timezone</span>
+    </div>
+    <div class="form-group row">
+      <label class="col-lg-4 text-right font-weight-bold"
+        i18n for="create-resource">Resource</label>
+      <input *ngIf="targetResource && targetResourceBarcode" id="create-resource" value="{{targetResourceBarcode}}" disabled>
+      <eg-combobox
+        formControlName="resourceList"
+        *ngIf="!(targetResource && targetResourceBarcode)"
+        startId="any">
+        <eg-combobox-entry entryId="any" entryLabel="Any resource"
+          i18n-entryLabel></eg-combobox-entry>
+        <eg-combobox-entry *ngFor="let r of resources" entryId="{{r.id()}}" entryLabel="{{r.barcode()}}">
+        </eg-combobox-entry>
+      </eg-combobox>
+    </div>
+    <div class="form-group row">
+      <label class="col-lg-4 text-right font-weight-bold"
+        i18n for="create-email-notify">Notify by email?</label>
+      <input type="checkbox" formControlName="emailNotify">
+    </div>
+  </form>
+  <div class="modal-footer">
+    <button (click)="addBresv$().subscribe()" [disabled]="!create.valid" class="btn btn-info" i18n>Confirm reservation</button>
+    <button (click)="addBresvAndOpenPatronReservations()" [disabled]="!create.valid" class="btn btn-info" i18n>
+      Confirm and show patron reservations
+    </button>
+    <button (click)="close()" class="btn btn-warning ml-2" i18n>Cancel</button>
+  </div>
+</ng-template>
+<eg-alert-dialog #fail i18n-dialogBody
+  dialogBody="Could not create this reservation">
+</eg-alert-dialog>
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 (file)
index 0000000..759c8e4
--- /dev/null
@@ -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<boolean>;
+
+    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<any>;
+    @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<boolean>();
+    }
+
+    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 (file)
index 0000000..a658af6
--- /dev/null
@@ -0,0 +1,219 @@
+<eg-staff-banner bannerText="Create Reservation" i18n-bannerText>
+</eg-staff-banner>
+<eg-title i18n-prefix i18n-suffix prefix="Booking" suffix="Create Reservation"></eg-title>
+{{attributes | json}}
+{{selectedAttributes.value | json}}
+<form [formGroup]="criteria" class="row">
+  <div class="col-sm-6">
+    <div class="row">
+      <div class="col">
+        <div class="input-group">
+          <div class="input-group-prepend">
+            <label class="input-group-text" for="ideal-reservation-type" i18n>Reservation type</label>
+          </div>
+          <select class="form-control" id="ideal-reservation-type" formControlName="reservationType">
+            <option *ngFor="let type of reservationTypes" [ngValue]="type" i18n>{{type.name}}</option>
+          </select>
+        </div>
+      </div>
+      <div class="col">
+        <div class="input-group">
+          <div class="input-group-prepend">
+            <label class="input-group-text" for="ideal-reservation-date" i18n>Reservation date</label>
+          </div>
+          <eg-date-select *ngIf="!multiday" #dateLimiter domId="ideal-reservation-date" formControlName="idealDate"></eg-date-select>
+          <eg-daterange-select *ngIf="multiday" formControlName="idealDateRange"></eg-daterange-select>
+        </div>
+      </div>
+    </div>
+  </div>
+  <div class="card col-sm-6">
+    <h2 class="card-header" i18n>Reservation details</h2>
+    <ngb-tabset #details="ngbTabset">
+      <ngb-tab id="select-resource-type">
+        <ng-template ngbTabTitle>
+          <span class="material-icons">category</span>
+          <ng-container i18n>Choose resource by type</ng-container>
+        </ng-template>
+        <ng-template ngbTabContent>
+          <div ngbPanelContent class="row">
+            <div class="col">
+              <div class="input-group">
+                <div class="input-group-prepend">
+                  <label class="input-group-text" for="ideal-resource-type" i18n>Search by resource type</label>
+                </div>
+                <eg-combobox
+                  formControlName="resourceType"
+                  domId="ideal-resource-type"
+                  idlClass="brt"
+                  [asyncSupportsEmptyTermClick]="true">
+                </eg-combobox>
+              </div>
+              <div class="col">
+                <eg-org-family-select [hideAncestorSelector]="true" labelText="Owning library" i18n-labelText formControlName="owningLibrary">
+                </eg-org-family-select>
+              </div>
+            </div>
+          </div>
+        </ng-template>
+      </ngb-tab>
+
+      <ngb-tab id="select-resource">
+        <ng-template ngbTabTitle>
+          <span class="material-icons">assignment</span>
+          <ng-container i18n>Choose resource by barcode</ng-container>
+        </ng-template>
+        <ng-template ngbTabContent>
+          <div ngbPanelContent class="row">
+            <div class="col">
+              <div class="input-group">
+                <div class="input-group-prepend">
+                  <label class="input-group-text" for="ideal-resource-barcode" i18n>Search by resource barcode</label>
+                </div>
+                <input type="text" id="ideal-resource-barcode" class="form-control" formControlName="resourceBarcode">
+              </div>
+            </div>
+          </div>
+        </ng-template>
+      </ngb-tab>
+
+      <ngb-tab id="attributes" [disabled]="0 === attributes.length">
+        <ng-template ngbTabTitle>
+          <span class="material-icons">filter_list</span>
+          <ng-container i18n>Limit by attributes</ng-container>
+        </ng-template>
+        <ng-template ngbTabContent>
+          <ul class="list-group list-group-flush" formArrayName="selectedAttributes">
+            <li *ngFor="let attribute of attributes; let i = index" class="list-group-item">
+              <span class="input-group">
+                <span class="input-group-prepend">
+                  <label class="input-group-text" for="attribute-{{attribute.id()}}" i18n>{{attribute.name()}}</label>
+                </span>
+                <eg-combobox [formControlName]="i">
+                  <eg-combobox-entry *ngFor="let value of attribute.valid_values()"
+                    [entryId]="value.id()" [entryLabel]="value.valid_value()">
+                  </eg-combobox-entry>
+                </eg-combobox>
+              </span>
+            </li>
+          </ul>
+        </ng-template>
+      </ngb-tab>
+
+      <ngb-tab id="display-settings">
+        <ng-template ngbTabTitle>
+          <span class="material-icons">settings</span>
+          <ng-container i18n>Schedule settings</ng-container>
+        </ng-template>
+        <ng-template ngbTabContent>
+          <ul class="list-group list-group-flush">
+            <li class="list-group-item">
+              <span class="input-group">
+                <span class="input-group-prepend">
+                  <label class="input-group-text" for="start-time" i18n>Start time</label>
+                </span>
+                <ngb-timepicker formControlName="startOfDay" [minuteStep]="minuteStep()" [meridian]="true"></ngb-timepicker>
+              </span>
+            </li>
+            <li class="list-group-item">
+              <span class="input-group">
+                <span class="input-group-prepend">
+                  <label class="input-group-text" for="end-time" i18n>End time</label>
+                </span>
+                <ngb-timepicker formControlName="endOfDay" [minuteStep]="minuteStep()" [meridian]="true"></ngb-timepicker>
+              </span>
+            </li>
+            <li *ngIf="criteria.errors && criteria.errors.startOfDayNotBeforeEndOfDay" class="list-group-item">
+              <div role="alert" class="alert alert-danger">
+                <span class="material-icons" aria-hidden="true">error</span>
+                <span i18n>Start time must be before end time</span>
+              </div>
+            </li>
+            <li class="list-group-item">
+              <span class="input-group">
+                <span class="input-group-prepend">
+                  <label class="input-group-text" for="granularity" i18n>Granularity</label>
+                </span>
+                <eg-combobox (onChange)="changeGranularity($event)" [startId]="granularity ? granularity : 30">
+                  <eg-combobox-entry [entryId]="15" entryLabel="15 minutes"
+                    i18n-entryLabel></eg-combobox-entry>
+                  <eg-combobox-entry [entryId]="30" entryLabel="30 minutes"
+                    i18n-entryLabel></eg-combobox-entry>
+                  <eg-combobox-entry [entryId]="60" entryLabel="60 minutes"
+                    i18n-entryLabel></eg-combobox-entry>
+                </eg-combobox>
+              </span>
+            </li>
+          </ul>
+        </ng-template>
+      </ngb-tab>
+    </ngb-tabset>
+  </div>
+</form>
+
+<ng-container *ngIf="resources.length">
+  <hr>
+  <div class="row" *ngIf="idealDate && !multiday">
+    <button class="btn btn-info col-sm-2 offset-sm-3" (click)="addDays(-1)">
+        <span class="material-icons mat-icon-in-button">keyboard_arrow_left</span>
+        <span i18n>Previous day</span>
+    </button>
+    <h2 class="col-sm-2 text-center" i18n>{{idealDate | formatValue:'timestamp'}}</h2>
+    <button class="btn btn-info col-sm-2" (click)="addDays(1)">
+      <span i18n>Next day</span>
+      <span class="material-icons mat-icon-in-button">keyboard_arrow_right</span>
+    </button>
+  </div>
+  <eg-grid #scheduleGrid
+    [sortable]="false"
+    (onRowActivate)="openTheDialog([$event])"
+    [dataSource]="scheduleSource"
+    [rowFlairIsEnabled]="true"
+    [rowFlairCallback]="resourceAvailabilityIcon"
+    [disablePaging]="true"
+    persistKey="disabled">
+    <eg-grid-toolbar-action label="Create Reservation" i18n-label (onClick)="openTheDialog($event)"></eg-grid-toolbar-action>
+    <eg-grid-column path="time" [index]="true" name="Time" i18n-name [cellTemplate]="timeTemplate" ></eg-grid-column>
+    <eg-grid-column *ngFor="let resource of resources" path="{{resource.barcode()}}" [cellTemplate]="reservationsTemplate" [disableTooltip]="true"></eg-grid-column>
+  </eg-grid>
+</ng-container>
+<div class="text-sm-center" *ngIf="this.resourceType.value && !resources.length" i18n>
+  There are no bookable resource that match your criteria.
+  Would you like to create <a [routerLink]="['/staff', 'admin', 'booking', 'splash']">some new resources</a>?
+</div>
+
+<eg-create-reservation-dialog #createDialog
+  (onComplete)="fetchData()"
+  [targetResourceBarcode]="resourceBarcode"
+  [targetResource]="resourceId"
+  [targetResourceType]="resourceType.value"
+  [attributes]="flattenedSelectedAttributes"
+  [resources]="resources">
+</eg-create-reservation-dialog>
+
+<ng-template #reservationsTemplate let-row="row" let-col="col">
+  <ng-container *ngIf="row[col.name]">
+    <ul class="list-unstyled">
+      <li *ngFor="let reservation of row[col.name]">
+        <button class="btn btn-info" (click)="openReservationViewer(reservation['reservationId'])">
+          {{reservation['patronLabel']}}
+        </button>
+      </li>
+    </ul>
+  </ng-container>
+</ng-template>
+<ng-template #timeTemplate let-row="row" let-col="col">
+  <ng-container *ngIf="!multiday">
+    {{row['time'].format('LT')}}
+  </ng-container>
+  <ng-container *ngIf="multiday">
+    {{row['time'] | formatValue:'timestamp'}}
+  </ng-container>
+</ng-template>
+<eg-fm-record-editor #viewReservation
+  idlClass="bresv"
+  datetimeFields="start_time,end_time"
+  hiddenFields="xact_start,xact_finish,cancel_time,booking_interval">
+</eg-fm-record-editor>
+<eg-no-timezone-set-dialog #noTimezoneSetDialog>
+</eg-no-timezone-set-dialog>
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 (file)
index 0000000..23cddcb
--- /dev/null
@@ -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<GridComponent>;
+
+    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<any> {
+        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<IdlObject> {
+        return this.pcrud.search('brsrc',
+            {'barcode' : barcode}, {'limit': 1})
+            .pipe(single());
+    }
+
+    handleBrsrcError$(barcode: string): Observable<any> {
+        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<any> {
+        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 <FormArray>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 (file)
index 0000000..b625672
--- /dev/null
@@ -0,0 +1,72 @@
+<eg-staff-banner bannerText="Manage Reservations" i18n-bannerText>
+</eg-staff-banner>
+<eg-title i18n-prefix i18n-suffix prefix="Booking" suffix="Manage Reservations"></eg-title>
+
+<form [formGroup]="filters" class="row">
+  <div class="col-sm-3">
+    <eg-org-family-select [hideAncestorSelector]="true" labelText="Pickup library" i18n-labelText formControlName="pickupLibraries">
+    </eg-org-family-select>
+  </div>
+  <div class="col-sm-6 offset-sm-3">
+    <div class="card">
+      <h2 class="card-header" i18n>Filter reservations</h2>
+      <ngb-tabset #filterTabs [activeId]="startingTab" class="mt-1">
+        <ngb-tab id="patron">
+          <ng-template ngbTabTitle>
+            <span class="material-icons" *ngIf="patronId">filter_list</span> <span i18n>Filter by patron</span>
+          </ng-template>
+          <ng-template ngbTabContent>
+            <div class="m-2">
+              <div class="input-group m-2">
+                <div class="input-group-prepend">
+                  <label class="input-group-text" for="patron-barcode-value" i18n>Patron barcode</label>
+                </div>
+                <input type="text" id="patron-barcode-value" class="form-control" formControlName="patronBarcode">
+                <div class="input-group-button">
+                  <button *ngIf="patronBarcode.value" class="btn btn-warning" (click)="removeFilters()" i18n><span class="material-icons">delete</span> Remove filter</button>
+                </div>
+              </div>
+            </div>
+          </ng-template>
+        </ngb-tab>
+        <ngb-tab id="resource">
+          <ng-template ngbTabTitle>
+            <span class="material-icons" *ngIf="resourceBarcode.value">filter_list</span> <span i18n>Filter by resource</span>
+          </ng-template>
+          <ng-template ngbTabContent>
+            <div class="m-2">
+              <div class="input-group m-2">
+                <div class="input-group-prepend">
+                  <label class="input-group-text" for="resource-barcode-value" i18n>Resource barcode</label>
+                </div>
+                <input type="text" id="resource-barcode-value" class="form-control" formControlName="resourceBarcode">
+                <div class="input-group-button">
+                  <button *ngIf="resourceBarcode.value" class="btn btn-warning" (click)="removeFilters()" i18n><span class="material-icons">delete</span> Remove filter</button>
+                </div>
+              </div>
+            </div>
+          </ng-template>
+        </ngb-tab>
+        <ngb-tab id="type">
+          <ng-template ngbTabTitle>
+            <span class="material-icons" *ngIf="resourceType.value">filter_list</span> <span i18n>Filter by resource type</span>
+          </ng-template>
+          <ng-template ngbTabContent>
+            <div class="m-2">
+              <div class="input-group m-2">
+                <div class="input-group-prepend">
+                  <label class="input-group-text" for="resource-type-value" i18n>Resource type</label>
+                </div>
+                <eg-combobox domId="resource-type-value" formControlName="resourceType" idlClass="brt" [asyncSupportsEmptyTermClick]="true"></eg-combobox>
+                <div class="input-group-button">
+                  <button class="btn btn-warning" (click)="removeFilters()" i18n><span class="material-icons">delete</span> Remove filter</button>
+                </div>
+              </div>
+            </div>
+          </ng-template>
+        </ngb-tab>
+      </ngb-tabset>
+    </div>
+  </div>
+</form>
+<eg-reservations-grid #reservationsGrid [patron]="patronId" [resourceBarcode]="resourceBarcode.value" [resourceType]="resourceTypeForGrid" [pickupLibIds]="pickupLibrariesForGrid" persistSuffix="manage"></eg-reservations-grid>
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 (file)
index 0000000..239e1bf
--- /dev/null
@@ -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 (file)
index 0000000..9d8e646
--- /dev/null
@@ -0,0 +1,17 @@
+<ng-template #dialogContent>
+  <div class="modal-header bg-info">
+    <h4 class="modal-title" i18n>Timezone not set for your library</h4>
+    <button type="button" class="close"
+      i18n-aria-label aria-label="Close"
+      (click)="dismiss('cross_click')">
+      <span aria-hidden="true">&times;</span>
+    </button>
+  </div>
+  <div class="modal-body" i18n><p>Please make sure that <i>lib.timezone</i> has a valid value in the Library Settings Editor.</p></div>
+  <div class="modal-footer">
+    <button type="button" class="btn btn-success"
+      (click)="openLSE()" i18n>Go to Library Settings Editor</button>
+    <button type="button" class="btn btn-warning"
+      (click)="dismiss('canceled')" i18n>Continue anyway</button>
+  </div>
+</ng-template>
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 (file)
index 0000000..c613d1f
--- /dev/null
@@ -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 (file)
index 0000000..0ec465d
--- /dev/null
@@ -0,0 +1,27 @@
+<eg-staff-banner bannerText="Booking Pickup" i18n-bannerText>
+</eg-staff-banner>
+<eg-title i18n-prefix i18n-suffix prefix="Booking" suffix="Pickup"></eg-title>
+
+<form [formGroup]="findPatron" class="row">
+  <div class="col-md-4">
+    <div class="input-group flex-nowrap">
+      <div class="input-group-prepend">
+        <label class="input-group-text" for="patron-barcode" i18n>Patron barcode</label>
+        <input type="text" id="patron-barcode" class="form-control" formControlName="patronBarcode">
+      </div>
+    </div>
+  </div>
+</form>
+<div *ngIf="patronId">
+  <h2 class="text-center" i18n>Ready for pickup</h2>
+  <div class="form-check">
+    <input class="form-check-input" type="checkbox" [checked]="onlyShowCaptured" id="only-show-captured" (change)="handleShowCapturedChange()">
+    <label class="form-check-label" for="only-show-captured" i18n>Show only captured resources</label>
+  </div>
+  <eg-reservations-grid #readyGrid [patron]="patronId" status="pickupReady" [onlyCaptured]="onlyShowCaptured" persistSuffix="pickup.ready" (onPickup)="this.pickedUpGrid.reloadGrid()"></eg-reservations-grid>
+
+  <h2 class="text-center mt-2" i18n>Already picked up</h2>
+  <eg-reservations-grid #pickedUpGrid [patron]="patronId" status="pickedUp" persistSuffix="pickup.picked_up"></eg-reservations-grid>
+
+</div>
+
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 (file)
index 0000000..cec32c2
--- /dev/null
@@ -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 (file)
index 0000000..d6715dd
--- /dev/null
@@ -0,0 +1,47 @@
+<eg-staff-banner bannerText="Booking Pull List" i18n-bannerText>
+</eg-staff-banner>
+<eg-title i18n-prefix i18n-suffix prefix="Booking" suffix="Pull List"></eg-title>
+
+<form [formGroup]="pullListCriteria" class="row">
+  <div class="col-md-4">
+    <div class="input-group">
+      <div class="input-group-prepend">
+        <label for="ou" class="input-group-text" i18n>Library:</label>
+      </div>
+      <eg-org-select domId="ou" [applyDefault]="true"
+        (onChange)="fillGrid($event.id())"
+        [disableOrgs]="disableOrgs()" [hideOrgs]="disableOrgs()">
+      </eg-org-select>
+    </div>
+  </div>
+  <div class="col-md-4">
+    <div class="input-group">
+      <div class="input-group-prepend">
+        <label for="days-hence" class="input-group-text" i18n>Number of days to fetch:</label>
+      </div>
+      <input type="number" min="1" class="form-control" formControlName="daysHence">
+    </div>
+  </div>
+</form>
+<eg-grid [dataSource]="dataSource" [useLocalSort]="true" #pullList
+  [sortable]="true" persistKey="booking.pull_list">
+  <eg-grid-toolbar-action label="Cancel Selected" i18n-label (onClick)="cancelSelected($event)" [disableOnRows]="noSelectedRows"></eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="View Item Status" i18n-label (onClick)="viewItemStatus($event)" [disableOnRows]="notOneCatalogedItemSelected"></eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="View Reservations for This Resource" i18n-label (onClick)="viewByResource($event)" [disableOnRows]="notOneResourceSelected"></eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="Print Pull List" i18n-label (onClick)="pullList.print()"></eg-grid-toolbar-action>
+
+  <eg-grid-column name="id" [hidden]="true" [index]="true" i18n-label label="ID" path="current_resource.id"></eg-grid-column>
+  <eg-grid-column label="Shelving location" path="shelving_location" i18n-label></eg-grid-column>
+  <eg-grid-column label="Call number" path="call_number" i18n-label></eg-grid-column>
+  <eg-grid-column label="Call number sortkey" path="call_number_sortkey" i18n-label></eg-grid-column>
+  <eg-grid-column name="barcode" label="Barcode" i18n-label path="current_resource.barcode"></eg-grid-column>
+  <eg-grid-column name="title" label="Title or name" i18n-label path="target_resource_type.name"></eg-grid-column>
+  <eg-grid-column label="Reservation start time" [datePlusTime]="true" path="reservations.0.start_time" i18n-label></eg-grid-column>
+  <eg-grid-column label="Reservation end time" [datePlusTime]="true" path="reservations.0.end_time" i18n-label></eg-grid-column>
+  <eg-grid-column label="Patron first name" path="reservations.0.usr.first_given_name" i18n-label></eg-grid-column>
+  <eg-grid-column label="Patron last name" path="reservations.0.usr.family_name" i18n-label></eg-grid-column>
+</eg-grid>
+
+<eg-cancel-reservation-dialog #confirmCancelReservationDialog
+  (onSuccessfulCancel)="fillGrid()">
+</eg-cancel-reservation-dialog>
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 (file)
index 0000000..745c52d
--- /dev/null
@@ -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<PullListRow> => {
+        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 (file)
index 0000000..5545d06
--- /dev/null
@@ -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 (file)
index 0000000..10f8549
--- /dev/null
@@ -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 (file)
index 0000000..ab8d923
--- /dev/null
@@ -0,0 +1,69 @@
+<eg-grid #grid [dataSource]="gridSource"
+  (onRowActivate)="handleRowActivate($event)"
+  [sortable]="true"
+  [useLocalSort]="true"
+  persistKey="booking.{{persistSuffix}}" >
+  <eg-grid-toolbar-action label="Edit Selected" i18n-label (onClick)="editSelected($event)" [disableOnRows]="editNotAppropriate"></eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="Cancel Selected" i18n-label (onClick)="cancelSelected($event)" [disableOnRows]="cancelNotAppropriate"></eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="Pick Up Selected" i18n-label (onClick)="pickupSelected($event)" [disableOnRows]="pickupNotAppropriate"></eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="Return Selected" i18n-label (onClick)="returnSelected($event)" [disableOnRows]="returnNotAppropriate"></eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="View Patron Record" i18n-label (onClick)="viewPatronRecord($event)" [disableOnRows]="notOnePatronSelected"></eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="View Reservations for This Patron" i18n-label (onClick)="viewByPatron($event)" [disableOnRows]="notOnePatronSelected"></eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="View Item Status" i18n-label (onClick)="viewItemStatus($event)" [disableOnRows]="notOneCatalogedItemSelected"></eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="View Reservations for This Resource" i18n-label (onClick)="viewByResource($event)" [disableOnRows]="notOneResourceSelected"></eg-grid-toolbar-action>
+  <eg-grid-toolbar-button *ngIf="!status" label="Create New Reservation" i18n-label (onClick)="redirectToCreate($event)"></eg-grid-toolbar-button>
+
+  <eg-grid-column name="id" [hidden]="true" [index]="true" i18n-label label="ID" path="id"></eg-grid-column>
+  <eg-grid-column label="Patron username" [hidden]="true" i18n-label path="usr.usrname"></eg-grid-column>
+  <eg-grid-column label="Patron barcode" i18n-label path="usr.card.barcode"></eg-grid-column>
+  <eg-grid-column label="Patron first name" i18n-label  path="usr.first_given_name"></eg-grid-column>
+  <eg-grid-column label="Patron middle name" i18n-label [hidden]="true" path="usr.second_given_name"></eg-grid-column>
+  <eg-grid-column label="Patron family name" i18n-label path="usr.family_name"></eg-grid-column>
+  <eg-grid-column name="start_time" label="Start Time" [datePlusTime]="true" i18n-label path="start_time" datatype="timestamp"></eg-grid-column>
+  <eg-grid-column name="end_time" label="End Time" [datePlusTime]="true" i18n-label path="end_time" datatype="timestamp"></eg-grid-column>
+  <eg-grid-column name="request_time" label="Request Time" [datePlusTime]="true" i18n-label path="request_time" datatype="timestamp"></eg-grid-column>
+  <eg-grid-column name="capture_time" label="Capture Time" [datePlusTime]="true" i18n-label path="capture_time" datatype="timestamp"></eg-grid-column>
+  <eg-grid-column name="pickup_time" label="Pickup Time" [datePlusTime]="true" i18n-label path="pickup_time" datatype="timestamp"></eg-grid-column>
+  <eg-grid-column label="Email notify" i18n-label [hidden]="true" path="email_notify" datatype="bool"></eg-grid-column>
+  <eg-grid-column i18n-label [hidden]="true" path="unrecovered" datatype="bool"></eg-grid-column>
+  <eg-grid-column label="Billing total" i18n-label path="billing_total" datatype="money"></eg-grid-column>
+  <eg-grid-column label="Payment total" i18n-label path="payment_total" datatype="money"></eg-grid-column>
+  <eg-grid-column label="Booking interval" i18n-label [hidden]="true" path="booking_interval" [hidden]="true"></eg-grid-column>
+  <eg-grid-column label="Fine interval" i18n-label [hidden]="true" path="fine_interval" [hidden]="true"></eg-grid-column>
+  <eg-grid-column label="Fine amount" i18n-label [hidden]="true" path="fine_amount" datatype="money"></eg-grid-column>
+  <eg-grid-column label="Maximum fine" i18n-label [hidden]="true" path="max_fine" datatype="money"></eg-grid-column>
+  <eg-grid-column i18n-label label="Resource Barcode" path="current_resource.barcode"></eg-grid-column>
+  <eg-grid-column i18n-label label="Note" path="note"></eg-grid-column>
+  <eg-grid-column i18n-label label="Resource Type" path="target_resource_type.name"></eg-grid-column>
+  <eg-grid-column label="Reservation length" i18n-label  path="length"></eg-grid-column>
+  <eg-grid-column label="Request library" i18n-label  path="request_lib.name"></eg-grid-column>
+  <eg-grid-column label="Pickup library" i18n-label path="pickup_lib.name"></eg-grid-column>
+  <eg-grid-column label="Pickup library timezone" i18n-label path="timezone"></eg-grid-column>
+
+</eg-grid>
+
+<eg-fm-record-editor #editDialog
+  idlClass="bresv"
+  datetimeFields="start_time,end_time"
+  [fieldOptions]="{end_time:{customTemplate:{template:endTimeTemplate}}}"
+  hiddenFields="xact_start,xact_finish,cancel_time,booking_interval"
+  [readonlyFields]="listReadOnlyFields()">
+</eg-fm-record-editor>
+<eg-cancel-reservation-dialog #confirmCancelReservationDialog
+  (onSuccessfulCancel)="grid.reload()">
+</eg-cancel-reservation-dialog>
+<eg-no-timezone-set-dialog #noTimezoneSetDialog>
+</eg-no-timezone-set-dialog>
+
+<ng-template #endTimeTemplate let-field="field" let-record="record">
+  <eg-datetime-select
+    domId="endTime"
+    ngModel
+    [showTZ]="editDialog.timezone"
+    [timezone]="editDialog.timezone"
+    [egNotBeforeMoment]="momentizeIsoString(record['start_time'](), editDialog.timezone)"
+    [readOnly]="field.readOnly"
+    (onChangeAsIso)="record[field.name]($event)"
+    initialIso="{{record[field.name]()}}">
+  </eg-datetime-select>
+</ng-template>
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 (file)
index 0000000..b14a811
--- /dev/null
@@ -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<IdlObject>();
+
+    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<any>;
+    returnSelected: (rows: IdlObject[]) => void;
+    returnResource: (rows: IdlObject) => Observable<any>;
+    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<IdlObject> => {
+            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<IdlObject> => {
+        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 (file)
index 0000000..262910f
--- /dev/null
@@ -0,0 +1,46 @@
+<eg-staff-banner bannerText="Booking Return" i18n-bannerText>
+</eg-staff-banner>
+<eg-title i18n-prefix i18n-suffix prefix="Booking" suffix="Return"></eg-title>
+
+<form [formGroup]="findPatron">
+  <ngb-tabset (tabChange)="handleTabChange($event)" activeId="patron" #tabs>
+    <ngb-tab title="By patron" i18n-title id="patron">
+      <ng-template ngbTabContent>
+        <div class="row">
+          <div class="col-md-4">
+            <div class="input-group flex-nowrap">
+              <div class="input-group-prepend">
+                <label class="input-group-text" for="patron-barcode" i18n>Patron barcode</label>
+                <input type="text" id="patron-barcode" class="form-control" i18n-placeholder placeholder="Patron barcode" formControlName="patronBarcode">
+              </div>
+            </div>
+          </div>
+        </div>
+        <div *ngIf="patronId">
+          <h2 class="text-center" i18n>Ready for return</h2>
+          <eg-reservations-grid #readyGrid [patron]="patronId" status="returnReady" (onReturn)="refreshGrids()" persistSuffix="return.patron.picked_up"></eg-reservations-grid>
+
+          <h2 class="text-center" i18n>Returned today</h2>
+          <eg-reservations-grid #returnedGrid [patron]="patronId" status="returnedToday" persistSuffix="return.patron.returned"></eg-reservations-grid>
+        </div>
+      </ng-template>
+    </ngb-tab>
+    <ngb-tab title="By resource" i18n-title id="resource">
+      <ng-template ngbTabContent>
+        <div class="input-group flex-nowrap">
+          <div class="input-group-prepend">
+            <label class="input-group-text" for="resource-barcode" i18n>Resource barcode</label>
+            <input type="text" id="resource-barcode" class="form-control" i18n-placeholder placeholder="Resource barcode" formControlName="resourceBarcode">
+          </div>
+        </div>
+        <div *ngIf="patronId">
+          <h2 class="text-center" i18n>Ready for return</h2>
+          <eg-reservations-grid #readyGrid [patron]="patronId" status="returnReady" (onReturn)="this.returnedGrid.reloadGrid()" persistSuffix="return.resource.picked_up"></eg-reservations-grid>
+
+          <h2 class="text-center" i18n>Returned today</h2>
+          <eg-reservations-grid #returnedGrid [patron]="patronId" status="returnedToday" persistSuffix="return.resource.returned"></eg-reservations-grid>
+        </div>
+      </ng-template>
+    </ngb-tab>
+  </ngb-tabset>
+</form>
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 (file)
index 0000000..74fb95a
--- /dev/null
@@ -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<ReservationsGridComponent>;
+
+    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<any> => {
+      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 (file)
index 0000000..bc12e96
--- /dev/null
@@ -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 (file)
index 0000000..7c6823f
--- /dev/null
@@ -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<IdlObject> => {
+        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<IdlObject> => {
+        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 (file)
index 0000000..85b567e
--- /dev/null
@@ -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');
+    });
+});
index 3c09dde..bcd980e 100644 (file)
       i18n-group group="Booking" i18n-label label="Make Items Bookable"
       (onClick)="makeBookable($event)">
     </eg-grid-toolbar-action>
+
+    <eg-grid-toolbar-action
+      i18n-group group="Booking" i18n-label label="Manage Reservations"
+      (onClick)="manageReservations($event)">
+    </eg-grid-toolbar-action>
     
     <!-- row actions: Edit -->
 
index 25e0894..edde96c 100644 (file)
@@ -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()]);
+        }
+    }
 }
index e833a34..458b0f5 100644 (file)
@@ -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
   ]
 })
 
index 3595b3c..2be4518 100644 (file)
           Booking
         </a>
         <div class="dropdown-menu" ngbDropdownMenu>
-          <a class="dropdown-item" href="/eg/staff/booking/legacy/booking/reservation">
+          <a class="dropdown-item" href="staff/booking/create_reservation">
             <span class="material-icons">add</span>
             <span i18n>Create Reservations</span>
           </a>
-          <a class="dropdown-item" href="/eg/staff/booking/legacy/booking/pull_list">
+          <a class="dropdown-item" href="staff/booking/pull_list">
             <span class="material-icons">list</span>
             <span i18n>Pull List</span>
           </a>
             <span class="material-icons">pin_drop</span>
             <span i18n>Capture Resources</span>
           </a>
-          <a class="dropdown-item" href="/eg/staff/booking/legacy/booking/pickup">
+          <a class="dropdown-item" href="staff/booking/pickup">
             <span class="material-icons">trending_up</span>
             <span i18n>Pick Up Reservations</span>
           </a>
-          <a class="dropdown-item" href="/eg/staff/booking/legacy/booking/return">
+          <a class="dropdown-item" href="staff/booking/return">
             <span class="material-icons">trending_down</span>
             <span i18n>Return Reservations</span>
           </a>
+          <a class="dropdown-item" href="staff/booking/manage_reservations">
+            <span class="material-icons">edit_attributes</span>
+            <span i18n>Manage Reservations</span>
+          </a>
         </div>
       </div>
     </div>
index 6f20336..e390a3d 100644 (file)
@@ -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 (file)
index 0000000..b11626c
--- /dev/null
@@ -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<any> {
+        return this.net.request(
+            'open-ils.actor',
+            'open-ils.actor.get_barcodes',
+            this.auth.token(), this.auth.user().ws_ou(),
+           'actor', barcode);
+    }
+
+}
+
index 9dc048b..1e3dcd8 100644 (file)
@@ -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`.
index 9573b52..ef97e2a 100644 (file)
@@ -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}
index a715f88..c01db43 100644 (file)
@@ -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"}
index 974f3b9..7144fde 100644 (file)
@@ -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);
index f2c6e85..36260a4 100644 (file)
@@ -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 (file)
index 0000000..8da02ed
--- /dev/null
@@ -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 (file)
index 0000000..4742f1d
--- /dev/null
@@ -0,0 +1,6 @@
+BEGIN;
+
+ALTER TABLE booking.reservation
+    ADD COLUMN note TEXT;
+
+COMMIT;
index d30ad1a..2cddd41 100644 (file)
@@ -45,6 +45,9 @@
     <eg-grid-action handler="book_copies_now"
       disabled="need_one_selected"
       label="[% l('Book Item Now') %]"></eg-grid-action>
+    <eg-grid-action handler="manage_reservations"
+      disabled="need_one_selected"
+      label="[% l('Manage Reservations') %]"></eg-grid-action>
     <eg-grid-action handler="requestItems"
       label="[% l('Request Items') %]"></eg-grid-action>
     <eg-grid-action handler="attach_to_peer_bib"
index 785f250..6af18a6 100644 (file)
@@ -89,6 +89,7 @@
         <li><a href ng-click="show_in_catalog()">[% l('Show in Catalog') %]</a></li>
         <li><a href ng-click="make_copies_bookable()">[% l('Make Items Bookable') %]</a></li>
         <li><a href ng-click="book_copies_now()">[% l('Book Item Now') %]</a></li>
+        <li><a href ng-click="manage_reservations()">[% l('Manage Reservations') %]</a></li>
         <li><a href ng-click="requestItems()">[% l('Request Items') %]</a></li>
         <li><a href ng-click="attach_to_peer_bib()">[% l('Link as Conjoined to Previously Marked Bib Record') %]</a></li>
         <li><a href ng-click="selectedHoldingsCopyDelete()">[% l('Delete Items') %]</a></li>
index 024271e..3747835 100644 (file)
@@ -19,6 +19,9 @@
   <eg-grid-action handler="book_copies_now"
     disabled="need_one_selected"
     label="[% l('Book Item Now') %]"></eg-grid-action>
+  <eg-grid-action handler="manage_reservations"
+    disabled="need_one_selected"
+    label="[% l('Manage Reservations') %]"></eg-grid-action>
   <eg-grid-action handler="requestItems"
     label="[% l('Request Items') %]"></eg-grid-action>
   <eg-grid-action handler="attach_to_peer_bib"
index 259dae7..677a4e8 100644 (file)
@@ -215,17 +215,22 @@ angular.module('egCoreMod').run(['egStrings', function(s) {
             </a>
           </li>
           <li>
-            <a href="./booking/legacy/booking/reservation?patron_barcode={{patron().card().barcode()}}" target="_top">
-              [% l('Booking: Create or Cancel Reservations') %]
+            <a href="/eg2/staff/booking/manage_reservations/by_patron/{{patron().id()}}" target="_top">
+              [% l('Booking: Manage Reservations') %]
             </a>
           </li>
           <li>
-            <a href="./booking/legacy/booking/pickup?patron_barcode={{patron().card().barcode()}}" target="_top">
+            <a href="/eg2/staff/booking/create_reservation/for_patron/{{patron().id()}}" target="_top">
+              [% l('Booking: Create Reservation') %]
+            </a>
+          </li>
+          <li>
+            <a href="/eg2/staff/booking/pickup/by_patron/{{patron().id()}}" target="_top">
               [% l('Booking: Pick Up Reservations') %]
             </a>
           </li>
           <li>
-            <a href="./booking/legacy/booking/return?patron_barcode={{patron().card().barcode()}}" target="_top">
+            <a href="/eg2/staff/booking/return/by_patron/{{patron().id()}}" target="_top">
               [% l('Booking: Return Reservations') %]
             </a>
           </li>
index a9208c8..1028f42 100644 (file)
         </a>
         <ul uib-dropdown-menu>
           <li>
-            <a href="./booking/legacy/booking/reservation" target="_self">
+            <a href="/eg2/staff/booking/create_reservation" target="_self">
               <span class="glyphicon glyphicon-plus"></span>
               [% l('Create Reservations') %]
             </a>
           </li>
           <li>
-            <a href="./booking/legacy/booking/pull_list" target="_self">
+            <a href="/eg2/staff/booking/pull_list" target="_self">
               <span class="glyphicon glyphicon-th-list"></span>
               [% l('Pull List') %]
             </a>
             </a>
           </li>
           <li>
-            <a href="./booking/legacy/booking/pickup" target="_self">
+            <a href="/eg2/staff/booking/pickup" target="_self">
               <span class="glyphicon glyphicon-export"></span>
               [% l('Pick Up Reservations') %]
             </a>
           </li>
           <li>
-            <a href="./booking/legacy/booking/return" target="_self">
+            <a href="/eg2/staff/booking/return" target="_self">
               <span class="glyphicon glyphicon-import"></span>
               [% l('Return Reservations') %]
             </a>
           </li>
+          <li>
+            <a href="/eg2/staff/booking/manage_reservations" target="_self">
+              <span class="glyphicon glyphicon-wrench"></span>
+              [% l('Manage Reservations') %]
+            </a>
+          </li>
         </ul>
       </li>
 
index 0e69a2d..7a53625 100644 (file)
@@ -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 = "<strong>" + payload.reservation.note() + "</strong>";
+    }
+    return p;
+};
 CaptureDisplay.prototype._generate_patron_info = function(payload) {
     var p = document.createElement("p");
     p.innerHTML = "<strong>" + localeStrings.RESERVED + "</strong> " +
@@ -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));
index c207b97..f474dc4 100644 (file)
@@ -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: '<eg-embed-frame url="booking_admin_url" handlers="funcs"></eg-embed-frame>',
-                    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';
index 5e418e7..b861801 100644 (file)
@@ -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() {
index 6382852..eda4d8e 100644 (file)
@@ -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: '<eg-embed-frame url="booking_admin_url" handlers="funcs"></eg-embed-frame>',
-                    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) {