LP1816475: Booking module refresh
authorJane Sandberg <sandbej@linnbenton.edu>
Wed, 8 May 2019 22:12:30 +0000 (15:12 -0700)
committerJane Sandberg <sandbej@linnbenton.edu>
Wed, 19 Jun 2019 23:17:15 +0000 (16:17 -0700)
This commit ports several dojo interfaces to Angular(7).  As part of
this work,
* Adds moment.js-based timezone support to the Angular fmeditor and grid
* Starts to add validation methods to fields in the fmeditor
* Adds a note field to booking.reservation. This field is visible in all
staff views of reservations (Create, Manage, Pull List, Capture, Pick Up
and Return), but is not visible to the patron
* Adds usrname as a selector for actor.usr
* Adds the new booking.reservation note field to the receipt in the
dojo-based Capture Reservations screen
* Adds a read-only display of au to the fm-editor
* Adds a new patron service in staff/share
* Adds relevant workstation settings to the database
* Adds form validation styles to reactive form fields

Signed-off-by: Jane Sandberg <sandbej@linnbenton.edu>
45 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.ts
Open-ILS/src/eg2/src/app/staff/booking/booking.module.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/org-select-with-descendants.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-validate.service.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/resource-type-combobox.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/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/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
docs/RELEASE_NOTES_NEXT/Circulation/booking-refresh.adoc [new file with mode: 0644]
docs/circulation/booking.adoc

index 7270ee3..93784cb 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 5db749a..86aed51 100644 (file)
               </eg-date-select>
             </ng-container>
 
+            <ng-container *ngSwitchCase="'timestamp-timepicker'">
+              <eg-datetime-select
+                [showTZ]="timezone"
+                [timezone]="timezone"
+                domId="{{idPrefix}}-{{field.name}}"
+                (onChangeAsMoment)="field.validate(field.name, $event, record)"
+                (onChangeAsIso)="record[field.name]($event)"
+                [validatorError]="field.validatorError"
+                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 f25839b..de034ff 100644 (file)
@@ -9,6 +9,7 @@ import {DialogComponent} from '@eg/share/dialog/dialog.component';
 import {NgbModal} 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 {
@@ -59,6 +60,13 @@ export interface FmFieldOptions {
     // This supersedes all other isRequired specifiers.
     isRequiredOverride?: (field: string, record: IdlObject) => boolean;
 
+    // If this function is defined, the function will be called
+    // when fields change their values, to check if users are entering
+    // valid values, and delivering an error message if not.
+    //
+    // Currently only implemented for the datetime-select widget
+    validator?: (field: string, value: any, record: IdlObject) => string;
+
     // Directly apply the readonly status of the field.
     // This only has an affect if the value is true.
     isReadonly?: boolean;
@@ -86,6 +94,9 @@ export class FmRecordEditorComponent
     mode: 'create' | 'update' | 'view' = 'create';
     recId: any;
 
+    // Show datetime fields in this particular timezone
+    timezone: string = this.format.wsOrgTimezone;
+
     // IDL record we are editing
     record: IdlObject;
 
@@ -110,6 +121,10 @@ export class FmRecordEditorComponent
     @Input() requiredFieldsList: string[] = [];
     @Input() requiredFields: string; // comma-separated string version
 
+    // list of timestamp fields that should display with a timepicker
+    @Input() datetimeFieldsList: string[] = [];
+    @Input() datetimeFields: string; // comma-separated string version
+
     // list of org_unit fields where a default value may be applied by
     // the org-select if no value is present.
     @Input() orgDefaultAllowedList: string[] = [];
@@ -160,6 +175,7 @@ export class FmRecordEditorComponent
       private modal: NgbModal, // required for passing to parent
       private idl: IdlService,
       private auth: AuthService,
+      private format: FormatService,
       private pcrud: PcrudService) {
       super(modal);
     }
@@ -196,6 +212,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(/,/);
         }
@@ -323,6 +342,16 @@ export class FmRecordEditorComponent
             || fieldOptions.isReadonly === true
             || this.readonlyFieldsList.includes(field.name);
 
+        if (fieldOptions.validator) {
+            field.validator = fieldOptions.validator;
+        } else {
+            field.validator = (fieldName: string, value: any, record: IdlObject) => '';
+        }
+
+        field.validate = (fieldName: string, value: any, record: IdlObject) => {
+            field.validatorError = field.validator(fieldName, value, record); };
+
+
         if (fieldOptions.isRequiredOverride) {
             field.isRequired = () => {
                 return fieldOptions.isRequiredOverride(field.name, this.record);
@@ -369,6 +398,8 @@ export class FmRecordEditorComponent
 
             promise = this.wireUpCombobox(field);
 
+        } else if (field.datatype === 'timestamp') {
+            field.datetime = this.datetimeFieldsList.includes(field.name);
         } else if (field.datatype === 'org_unit') {
             field.orgDefaultAllowed =
                 this.orgDefaultAllowedList.includes(field.name);
@@ -479,6 +510,10 @@ export class FmRecordEditorComponent
             return 'template';
         }
 
+        if ( field.datatype === 'timestamp' && field.datetime ) {
+            return 'timestamp-timepicker';
+        }
+
         // Some widgets handle readOnly for us.
         if (   field.datatype === 'timestamp'
             || field.datatype === 'org_unit'
@@ -491,6 +526,10 @@ export class FmRecordEditorComponent
                 return 'readonly-money';
             }
 
+            if (field.datatype === 'link' && field.class === 'au') {
+                return 'readonly-au';
+            }
+
             if (field.datatype === 'link' || field.linkedValues) {
                 return 'readonly-list';
             }
@@ -530,4 +569,3 @@ export class FmRecordEditorComponent
     }
 }
 
-
index fc18fc7..8e1c9dc 100644 (file)
@@ -30,6 +30,9 @@ export class GridColumnComponent implements OnInit {
     // Display date and time when datatype = timestamp
     @Input() datePlusTime: boolean;
 
+    // Display using a specific OU's timestamp when datatype = timestamp
+    @Input() timezoneContextOrg: number;
+
     // Used in conjunction with cellTemplate
     @Input() cellContext: any;
     @Input() cellTemplate: TemplateRef<any>;
@@ -61,6 +64,7 @@ export class GridColumnComponent implements OnInit {
         col.datatype = this.datatype;
         col.datePlusTime = this.datePlusTime;
         col.ternaryBool = this.ternaryBool;
+        col.timezoneContextOrg = this.timezoneContextOrg;
         col.isAuto = false;
         this.grid.context.columnSet.add(col);
     }
index be7b19c..297d04b 100644 (file)
@@ -86,7 +86,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 ae68169..60ce771 100644 (file)
@@ -27,6 +27,7 @@ export class GridColumn {
     datatype: string;
     datePlusTime: boolean;
     ternaryBool: boolean;
+    timezoneContextOrg: number;
     cellTemplate: TemplateRef<any>;
     cellContext: any;
     isIndex: boolean;
@@ -678,7 +679,8 @@ export class GridContext {
             idlClass: col.idlClass,
             idlField: col.idlFieldDef ? col.idlFieldDef.name : col.name,
             datatype: col.datatype,
-            datePlusTime: Boolean(col.datePlusTime)
+            datePlusTime: Boolean(col.datePlusTime),
+            timezoneContextOrg: Number(col.timezoneContextOrg)
         });
     }
 
diff --git a/Open-ILS/src/eg2/src/app/staff/booking/booking.module.ts b/Open-ILS/src/eg2/src/app/staff/booking/booking.module.ts
new file mode 100644 (file)
index 0000000..df56a57
--- /dev/null
@@ -0,0 +1,36 @@
+import {NgModule} from '@angular/core';
+import {ReactiveFormsModule} from '@angular/forms';
+import {StaffCommonModule} from '@eg/staff/common.module';
+import {BookingRoutingModule} from './routing.module';
+import {CreateReservationComponent} from './create-reservation.component';
+import {ManageReservationsComponent} from './manage-reservations.component';
+import {OrgSelectWithDescendantsComponent} from './org-select-with-descendants.component';
+import {ReservationsGridComponent} from './reservations-grid.component';
+import {PickupComponent} from './pickup.component';
+import {PullListComponent} from './pull-list.component';
+import {ResourceTypeComboboxComponent} from './resource-type-combobox.component';
+import {ReturnComponent} from './return.component';
+import {NoTimezoneSetComponent} from './no-timezone-set.component';
+import {PatronService} from '@eg/staff/share/patron.service';
+
+
+@NgModule({
+    imports: [
+        StaffCommonModule,
+        BookingRoutingModule,
+        ReactiveFormsModule,
+    ],
+    providers: [PatronService],
+    declarations: [
+        CreateReservationComponent,
+        ManageReservationsComponent,
+        NoTimezoneSetComponent,
+        OrgSelectWithDescendantsComponent,
+        PickupComponent,
+        PullListComponent,
+        ReservationsGridComponent,
+        ResourceTypeComboboxComponent,
+        ReturnComponent]
+})
+export class BookingModule { }
+
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..b4d459b
--- /dev/null
@@ -0,0 +1,194 @@
+<eg-staff-banner bannerText="Create Reservation" i18n-bannerText>
+</eg-staff-banner>
+<eg-title i18n-prefix i18n-suffix prefix="Booking" suffix="Create Reservation"></eg-title>
+
+<div class="row">
+  <div class="col">
+    <eg-org-select-with-descendants labelText="Owning library" i18n-labelText (ouChange)="handleOwnerChange($event)">
+    </eg-org-select-with-descendants>
+  </div>
+  <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>
+      <div ngbDropdown>
+        <button *ngIf="!multiday" class="btn btn-outline-primary" ngbDropdownToggle><span class="material-icons">event</span><span i18n>Single day reservation</span></button>
+        <button *ngIf="multiday" class="btn btn-outline-primary" ngbDropdownToggle><span class="material-icons">date_range</span><span i18n>Multiple day reservation</span></button>
+        <div ngbDropdownMenu id="ideal-reservation-type">
+          <button (click)="handleSingleDayReservation()" class="btn btn-outline-primary" ngbDropdownItem><span class="material-icons">event</span><span i18n>Single day reservation</span></button>
+          <button (click)="handleMultiDayReservation()" class="btn btn-outline-primary" ngbDropdownItem><span class="material-icons">date_range</span><span i18n>Multiple day reservation</span></button>
+        </div>
+      </div>
+    </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" (onChangeAsDate)="handleDateChange($event)" [initialDate]="idealDate"></eg-date-select>
+      <eg-daterange-select *ngIf="multiday" #dateRangeLimiter (onChange)="fetchData()"></eg-daterange-select>
+    </div>
+  </div>
+  <div class="col">
+    <div class="input-group">
+      <div class="input-group-prepend">
+        <label class="input-group-text" for="ideal-resource-barcode" i18n>Resource barcode</label>
+      </div>
+      <input type="text" id="ideal-resource-barcode" class="form-control" i18n-placeholder placeholder="Resource barcode" [(ngModel)]="resourceBarcode" (change)="useCurrentResourceBarcode()">
+    </div>
+  </div>
+  <div class="col">
+    <div class="input-group">
+      <div class="input-group-prepend">
+        <label class="input-group-text" for="ideal-resource-type" i18n>Resource type</label>
+      </div>
+      <eg-resource-type-combobox #rt domId="ideal-resource-type" (typeChanged)="handleResourceTypeChange($event)"></eg-resource-type-combobox>
+    </div>
+  </div>
+</div>
+<hr class="mt1" />
+<button
+  class="btn btn-primary"
+  (click)="advancedCollapsed = !advancedCollapsed"
+  [attr.aria-expanded]="!advancedCollapsed"
+  aria-controls="advanced">
+  <span *ngIf="advancedCollapsed" class="material-icons">lock</span>
+  <span *ngIf="!advancedCollapsed" class="material-icons">lock_open</span>
+  <span *ngIf="advancedCollapsed" i18n>Show advanced options</span>
+  <span *ngIf="!advancedCollapsed" i18n>Hide advanced options</span>
+</button>
+
+<div id="advanced" class="row" [ngbCollapse]="advancedCollapsed">
+  <div class="card col-md-6">
+    <div class="card-header" i18n>Display options</div>
+    <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 [(ngModel)]="startOfDay" (ngModelChange)="fetchData()" [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 [(ngModel)]="endOfDay" (ngModelChange)="fetchData()" [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="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>
+  </div>
+  <div *ngIf="attributes.length" class="card col-md-6">
+    <div class="card-header" i18n>Filter by attributes</div>
+    <ul class="list-group list-group-flush">
+      <li *ngFor="let attribute of attributes" 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 (onChange)="limitByAttr(attribute.id(), $event)">
+            <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>
+  </div>
+</div>
+
+<eg-grid *ngIf="resources.length" #scheduleGrid
+  [sortable]="false"
+  (onRowActivate)="openCreateDialog([$event])"
+  [dataSource]="scheduleSource"
+  [rowFlairIsEnabled]="true"
+  [rowFlairCallback]="resourceAvailabilityIcon"
+  [disablePaging]="true"
+  persistKey="disabled">
+  <eg-grid-toolbar-action label="Create Reservation" i18n-label [action]="openCreateDialog"></eg-grid-toolbar-action>
+  <eg-grid-column path="time" [index]="true" ></eg-grid-column>
+  <eg-grid-column *ngFor="let resource of resources" path="{{resource.barcode()}}" [cellTemplate]="reservationsTemplate" [disableTooltip]="true"></eg-grid-column>
+</eg-grid>
+
+<eg-fm-record-editor #newDialog
+  idlClass="bresv"
+  [fieldOptions]="{usr:{customTemplate:{template:patronTemplate}},start_time:{customTemplate:{template:datetimeWithDefaults}},end_time:{customTemplate:{template:datetimeWithDefaults}},pickup_lib:{customTemplate:{template:pickupLibrary}},target_resource:{customTemplate:{template:targetResource}}}"
+  hiddenFields="id,xact_start,request_time,capture_time,pickup_time,return_time,capture_staff,xact_finish,cancel_time,booking_interval,unrecovered,request_lib,fine_interval,fine_amount,max_fine,current_resource,target_resource_type">
+</eg-fm-record-editor>
+
+<ng-template #reservationsTemplate let-row="row" let-col="col">
+  <ng-container *ngIf="row[col.name]">
+    <ul class="alert alert-primary">
+      <li *ngFor="let reservation of row[col.name]">
+        <a href="staff/booking/manage_reservations/by_patron/{{reservation['patronId']}}">{{reservation['patronLabel']}}</a>
+      </li>
+    </ul>
+  </ng-container>
+</ng-template>
+<ng-template #patronTemplate let-record="record">
+<input type="hidden" value="{{record.request_lib(auth.user().ws_ou())}}">
+  <ng-container *ngIf="patronId">
+    <input *ngIf="patronId" type="text" disabled value="{{record.usr(patronId)}}" class="form-control" name="usr">
+  </ng-container>
+  <div *ngIf="!patronId" 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" [(ngModel)]="patronBarcode" (change)="findPatronByBarcode()">
+    </div>
+  </div>
+</ng-template>
+<ng-template #datetimeWithDefaults let-record="record" let-field="field">
+  <input type="hidden" value="{{record[field.name](defaultTimes[field.name].toISOString())}}">
+  <eg-datetime-select
+    [showTZ]="true"
+    [minuteStep]="minuteStep()"
+    [timezone]="pickupLibUsesDifferentTz ? pickupLibUsesDifferentTz : format.wsOrgTimezone"
+    (onChangeAsIso)="record[field.name]($event)"
+    (onChangeAsMoment)="field.validatorError = reservationValidate[field.name](field.name, $event, record)"
+    [validatorError]="field.validatorError"
+    [initialMoment]="defaultTimes[field.name]">
+  </eg-datetime-select>
+</ng-template>
+<ng-template #pickupLibrary let-record="record" let-field="field">
+  <input type="hidden" value="{{record.pickup_lib(auth.user().ws_ou())}}">
+  <eg-org-select
+    [initialOrgId]="auth.user().ws_ou()"
+    (onChange)="handlePickupLibChange($event)">
+  </eg-org-select>
+  <div *ngIf="pickupLibUsesDifferentTz" class="alert alert-primary" i18n>Pickup library uses a different timezone than your library does. Please choose times in the pickup library's timezone.</div>
+</ng-template>
+<ng-template #targetResource let-record="record">
+  <input type="hidden" value="{{record.target_resource_type(resourceTypeId)}}">
+  <ng-container *ngIf="resourceId">
+    <input type="text" disabled value="{{resourceBarcode}}" class="form-control">
+    <input type="hidden" value="{{record.target_resource(resourceId)}}">
+    <input type="hidden" value="{{record.current_resource(resourceId)}}">
+  </ng-container>
+  <ng-container *ngIf="!resourceId">
+    <eg-combobox (onChange)="handleTargetResourceChange($event.id)" 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>
+  </ng-container>
+</ng-template>
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..c2fce86
--- /dev/null
@@ -0,0 +1,443 @@
+import {Component, Input, OnInit, AfterViewInit, QueryList, ViewChildren, ViewChild} from '@angular/core';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {forkJoin} from 'rxjs';
+import {single} from 'rxjs/operators';
+import {NgbDateStruct, NgbTimeStruct} from '@ng-bootstrap/ng-bootstrap';
+import {AuthService} from '@eg/core/auth.service';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {DateSelectComponent} from '@eg/share/date-select/date-select.component';
+import {DateRangeSelectComponent} from '@eg/share/daterange-select/daterange-select.component';
+import {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,  GridRowFlairEntry} from '@eg/share/grid/grid';
+import {IdlObject, IdlService} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {OrgService} from '@eg/core/org.service';
+import {PatronService} from '@eg/staff/share/patron.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {ResourceTypeComboboxComponent} from './resource-type-combobox.component';
+import {ServerStoreService} from '@eg/core/server-store.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {ReservationValidateService} from './reservation-validate.service';
+
+import * as Moment from 'moment-timezone';
+
+
+@Component({
+    templateUrl: './create-reservation.component.html'
+})
+
+export class CreateReservationComponent implements OnInit, AfterViewInit {
+
+    advancedCollapsed = true;
+    attributes: IdlObject[] = [];
+    selectedAttributes: number[] = [];
+    multiday = false;
+    handleDateChange: ($event: Date) => void;
+    handleOwnerChange: ($event: number[]) => void;
+    resourceAvailabilityIcon: (row: any) => GridRowFlairEntry;
+
+    owningLibraries: number[] = [];
+
+    patronBarcode: string;
+    patronId: number;
+    resourceBarcode: string;
+    resourceId: number;
+    resourceTypeId: number;
+    transferable: boolean;
+    resourceOwner: number;
+
+    pickupLibUsesDifferentTz: string;
+
+    startOfDay: NgbTimeStruct = {hour: 9, minute: 0, second: 0};
+    endOfDay: NgbTimeStruct = {hour: 17, minute: 0, second: 0};
+    granularity: 15 | 30 | 60 | 1440 = 30; // 1440 minutes = 24 hours
+
+    defaultTimes: {start_time: Moment, end_time: Moment};
+
+    scheduleSource: GridDataSource = new GridDataSource();
+
+    minuteStep: () => number;
+
+    openCreateDialog: (rows: IdlObject[]) => void;
+    openTheDialog: (rows: IdlObject[]) => any;
+
+    resources: IdlObject[] = [];
+    limitByAttr: (attributeId: number, $event: ComboboxEntry) => void;
+    useCurrentResourceBarcode: () => void;
+    findPatronByBarcode: () => void;
+
+    setGranularity: () => void;
+    handleMultiDayReservation: () => void;
+    handleSingleDayReservation: () => void;
+    changeGranularity: ($event: ComboboxEntry) => void;
+    handlePickupLibChange: ($event: IdlObject) => void;
+    handleTargetResourceChange: ($event: string | number) => void;
+
+    @ViewChildren('dateLimiter') dateLimiters: QueryList<DateSelectComponent>;
+    @ViewChildren('dateRangeLimiter') dateRangeLimiters: QueryList<DateRangeSelectComponent>;
+    @ViewChildren('scheduleGrid') scheduleGrids: QueryList<GridComponent>;
+    @ViewChild('newDialog') newDialog: FmRecordEditorComponent;
+    @ViewChild('rt') rt: ResourceTypeComboboxComponent;
+
+    idealDate = new Date();
+
+    constructor(
+        private auth: AuthService,
+        private format: FormatService,
+        private idl: IdlService,
+        private net: NetService,
+        private org: OrgService,
+        private patron: PatronService,
+        private pcrud: PcrudService,
+        private route: ActivatedRoute,
+        private router: Router,
+        private store: ServerStoreService,
+        private toast: ToastService,
+        public reservationValidate: ReservationValidateService
+    ) {
+        this.resourceAvailabilityIcon = (row: any) => {
+            let icon = {icon: 'event_busy', title: 'All resources are reserved at this time'};
+            let busy_columns = 0;
+            for (const key in row) {
+                if (row[key]) { busy_columns = busy_columns + 1; }
+            }
+            if (busy_columns <= this.resources.length) { // equal or less than, since it counts the time column
+                icon = {icon: 'event_available', title: 'Resources are available at this time'};
+            }
+            return icon;
+        };
+    }
+
+
+    ngOnInit() {
+        this.owningLibraries = [this.auth.user().ws_ou()];
+
+        this.defaultTimes = {
+            'start_time': Moment.tz([], this.format.wsOrgTimezone),
+            'end_time': Moment.tz([], this.format.wsOrgTimezone).add(this.granularity, 'minutes')
+        };
+
+        this.store.getItem('eg.booking.create.multiday').then(multiday => {
+            if (multiday) { this.multiday = multiday; }});
+
+        this.route.paramMap.subscribe((params: ParamMap) => {
+            this.patronId = +params.get('patron_id');
+            this.resourceBarcode = params.get('resource_barcode');
+
+            if (this.resourceBarcode) {
+                this.pcrud.search('brsrc',
+                {'barcode' : this.resourceBarcode}, {'limit': 1})
+                .pipe(single())
+                .subscribe((res) => {
+                    this.resourceId = res.id();
+                    this.fetchData();
+                }, (err) => {
+                    this.pcrud.search('acp',
+                        {'barcode' : this.resourceBarcode}, {'limit': 1})
+                    .pipe(single())
+                    .subscribe((item) => {
+                        this.net.request( 'open-ils.booking',
+                        'open-ils.booking.resources.create_from_copies',
+                        this.auth.token(), [item.id()])
+                        .subscribe((response) =>  {
+                            this.toast.info('Made this barcode bookable');
+                            this.resourceId = response['brsrc'][0][0];
+                        }, (error) => {
+                            this.toast.danger('Cannot make this barcode bookable');
+                        });
+                    }, (acperror) => {
+                        this.toast.danger('No resource found with this barcode');
+                        this.resourceId = -1;
+                    });
+                });
+            }
+        });
+
+        this.limitByAttr = (attributeId: number, $event: ComboboxEntry) => {
+            this.selectedAttributes[attributeId] = $event.id;
+            this.fetchData();
+        };
+
+        this.setGranularity = () => {
+            if (this.multiday) { // multiday reservations always use day granularity
+                this.granularity = 1440;
+            } else {
+                this.store.getItem('eg.booking.create.granularity').then(granularity => {
+                    if (granularity) {
+                        this.granularity = granularity;
+                    } else {
+                        this.granularity = 30;
+                    }
+                });
+            }
+        };
+
+        this.handleDateChange = ($event: Date) => {
+            this.idealDate = $event;
+            this.pcrud.retrieve('aouhoo', this.auth.user().ws_ou())
+            .subscribe(hours => {
+                const startArray = hours['dow_' + (this.idealDate.getDay() + 6) % 7 + '_open']().split(':');
+                const endArray = hours['dow_' + (this.idealDate.getDay() + 6) % 7 + '_close']().split(':');
+                this.startOfDay = {
+                    hour: ('00' === startArray[0]) ? 9 : +startArray[0],
+                    minute: +startArray[1],
+                    second: 0};
+                this.endOfDay = {
+                    hour: ('00' === endArray[0]) ? 17 : +endArray[0],
+                    minute: +endArray[1],
+                    second: 0};
+                this.fetchData();
+            });
+        };
+        this.handleOwnerChange = ($event: number[]) => {
+            this.owningLibraries = $event;
+            this.fetchData();
+        };
+
+        this.handleMultiDayReservation = () => {
+            this.multiday = true;
+            this.store.setItem('eg.booking.create.multiday', true);
+            this.fetchData();
+        };
+
+        this.handleSingleDayReservation = () => {
+            this.multiday = false;
+            this.store.setItem('eg.booking.create.multiday', false);
+            this.handleDateChange(new Date());
+        };
+
+        this.changeGranularity = ($event) => {
+            this.granularity = $event.id;
+            this.store.setItem('eg.booking.create.granularity', $event.id)
+            .then(() => this.fetchData());
+        };
+
+        this.handlePickupLibChange = ($event) => {
+            this.newDialog.record.pickup_lib($event);
+            this.org.settings('lib.timezone', $event.id()).then((tz) => {
+                if (tz['lib.timezone'] && (this.format.wsOrgTimezone !== tz['lib.timezone'])) {
+                    this.pickupLibUsesDifferentTz = tz['lib.timezone'];
+                } else {
+                    this.pickupLibUsesDifferentTz = null;
+                }
+            });
+        };
+
+        this.handleTargetResourceChange = ($event) => {
+            if ('any' !== $event) {
+                this.newDialog.record.current_resource($event);
+                this.newDialog.record.target_resource($event);
+            }
+        };
+
+        this.useCurrentResourceBarcode = () => {
+            if (this.resourceBarcode) {
+                this.router.navigate(['/staff', 'booking', 'create_reservation', 'for_resource', this.resourceBarcode]);
+            }
+        };
+
+        this.findPatronByBarcode = () => {
+            if (this.patronBarcode) {
+                this.patron.bcSearch(this.patronBarcode).pipe(single()).subscribe(
+                    resp => { this.newDialog.record.usr(resp[0].id); },
+                    err => { this.toast.danger('No patron found with this barcode'); },
+                );
+            }
+        };
+
+        this.minuteStep = () => {
+            return (this.granularity < 60) ? this.granularity : 30;
+        };
+
+    }
+
+    ngAfterViewInit() {
+        this.dateLimiters.forEach((dl) => dl.initialDate = new Date());
+        this.fetchData();
+
+        this.openTheDialog = (rows: IdlObject[]) => {
+            return this.newDialog.open({size: 'lg'}).subscribe(
+                response => {
+                    this.toast.success('Reservation successfully created'); // TODO: needs i18n, pluralization
+                    this.fetchData();
+                    return response.id();
+                }
+            );
+        };
+
+        this.openCreateDialog = (rows: IdlObject[]) => {
+            if (rows.length) {
+                if (this.multiday) {
+                    this.defaultTimes['start_time'] = this.format.momentizeDateString(rows[0]['time'], this.format.wsOrgTimezone);
+                    this.defaultTimes['end_time'] = this.format.momentizeDateString(
+                        rows[rows.length - 1]['time'], this.format.wsOrgTimezone).clone()
+                        .add(this.granularity, 'minutes');
+                } else {
+                    this.defaultTimes['start_time'] = Moment.tz('' +
+                        this.idealDate.getFullYear() + '-' +
+                        (this.idealDate.getMonth() + 1) + '-' +
+                        (this.idealDate.getDate()) + ' ' + rows[0]['time'],
+                        'YYYY-MM-DD LT', this.format.wsOrgTimezone);
+                    this.defaultTimes['end_time'] = Moment.tz('' +
+                        this.idealDate.getFullYear() + '-' +
+                        (this.idealDate.getMonth() + 1) + '-' +
+                        (this.idealDate.getDate()) + ' ' + rows[rows.length - 1]['time'],
+                        'YYYY-MM-DD LT', this.format.wsOrgTimezone).clone().add(this.granularity, 'minutes');
+                }
+            } else {
+                if (this.multiday) { this.defaultTimes['end_time'] = this.defaultTimes['start_time'].clone().add(1, 'days'); }
+            }
+            if (this.resourceId && !this.resourceTypeId) {
+                this.pcrud.search('brsrc', {id: this.resourceId}, {
+                    flesh: 1,
+                    limit: 1,
+                    flesh_fields: {'brsrc': ['type']}
+                }).subscribe( r => {
+                    this.transferable = r.type().transferable();
+                    this.resourceTypeId = r.type().id();
+                    this.resourceOwner = r.owner();
+                    this.openTheDialog(rows);
+                });
+            } else if (this.resourceTypeId) {
+                this.pcrud.search('brt', {id: this.resourceTypeId}, {
+                }).subscribe( t => {
+                    this.transferable = t.transferable();
+                    this.openTheDialog(rows).then(newId => {
+                        if (this.selectedAttributes.length) {
+                            const creates$ = [];
+                            this.selectedAttributes.forEach(attrValue => {
+                                if (attrValue) {
+                                    const bravm = this.idl.create('bravm');
+                                    bravm.attr_value(attrValue);
+                                    bravm.reservation(newId);
+                                    creates$.push(this.pcrud.create(bravm));
+                                }
+                            });
+                            forkJoin(...creates$).subscribe(() => {
+                            this.net.request('open-ils.storage', 'open-ils.storage.booking.reservation.resource_targeter', [newId]); });
+                        } else {
+                            this.net.request('open-ils.storage', 'open-ils.storage.booking.reservation.resource_targeter', [newId]);
+                        }
+                    });
+                });
+            }
+        };
+    }
+    handleResourceTypeChange($event: ComboboxEntry) {
+        this.resourceBarcode = null;
+        this.resourceId = null;
+        this.resourceTypeId = $event.id;
+        this.attributes = [];
+        this.selectedAttributes = [];
+        if (this.resourceTypeId) {
+            this.pcrud.search('bra', {resource_type : this.resourceTypeId}, {
+                order_by: 'name ASC',
+                flesh: 1,
+                flesh_fields: {'bra' : ['valid_values']}
+            }).subscribe(
+                a => { this.attributes.push(a);
+                }, err => {
+                    console.debug(err);
+                }, () => {
+                    this.fetchData();
+                });
+        }
+    }
+
+    fetchData () {
+        this.setGranularity();
+        this.resources = [];
+        const where = {'owner': this.owningLibraries};
+
+        if (this.resourceId) {
+            where['id'] = this.resourceId;
+        } else if (this.resourceTypeId) {
+            where['type'] = this.resourceTypeId;
+        } else {
+            return;
+        }
+
+        if (this.selectedAttributes.length) {
+            where['id'] = {'in': {'from': 'bram', 'select': {'bram': ['resource']}, 'where': {'value':  this.selectedAttributes.filter((a) => (a !== null))}}};
+        }
+        this.scheduleSource.data = [];
+        this.pcrud.search('brsrc', where, {
+            order_by: 'barcode ASC',
+            flesh: 1,
+            flesh_fields: {'brsrc': ['attr_maps']},
+        }).subscribe(
+            r => {
+                this.resources.push(r);
+
+                let startTime = Moment();
+                let endTime = Moment();
+                const reservations = [];
+
+                if (this.multiday) {
+                   this.dateRangeLimiters.forEach((drl) => {
+                       startTime = Moment.tz([drl.fromDate.year, drl.fromDate.month - 1, drl.fromDate.day],
+                           this.format.wsOrgTimezone).startOf('day');
+                       endTime = Moment.tz([drl.toDate.year, drl.toDate.month - 1, drl.toDate.day],
+                           this.format.wsOrgTimezone).endOf('day');
+                   });
+                } else {
+                    this.dateLimiters.forEach((dl) => {
+                        startTime = Moment.tz([
+                            dl.current.year,
+                            dl.current.month - 1,
+                            dl.current.day,
+                            this.startOfDay.hour,
+                            this.startOfDay.minute],
+                            this.format.wsOrgTimezone);
+                        endTime = Moment.tz([
+                            dl.current.year,
+                            dl.current.month - 1,
+                            dl.current.day,
+                            this.endOfDay.hour,
+                            this.endOfDay.minute],
+                            this.format.wsOrgTimezone);
+                    });
+                }
+                this.pcrud.search('bresv', {
+                    '-or': {'target_resource': r.id(), 'current_resource': r.id()},
+                    'end_time': {'>': startTime.toISOString()},
+                    'start_time': {'<': endTime.toISOString()},
+                    'return_time': null,
+                    'cancel_time': null },
+                    {'flesh': 1, 'flesh_fields': {'bresv': ['usr']}})
+                .subscribe((res) => { reservations.push(res); },
+                    (err) => { console.warn(err); },
+                    () =>  {
+                    const currentTime = startTime;
+                    while (currentTime < endTime) {
+                        let idx: number;
+                        let existingRow: number;
+                        if (this.multiday) {
+                            existingRow = this.scheduleSource.data.findIndex(
+                                (row) => row['time'] === this.format.transform({value: currentTime, datatype: 'timestamp'}));
+                            idx = (existingRow > -1) ? existingRow :
+                                (this.scheduleSource.data.push(
+                                    {'time': this.format.transform({value: currentTime, datatype: 'timestamp'})}) - 1);
+                        } else {
+                            existingRow = this.scheduleSource.data.findIndex((row) => row['time'] === currentTime.format('LT')) ;
+                            idx = (existingRow > -1) ? existingRow : (this.scheduleSource.data.push({'time': currentTime.format('LT')}) - 1);
+                        }
+                        reservations.forEach((reservation) => {
+                            if ((Moment.tz(reservation.start_time(), this.format.wsOrgTimezone) <
+                                (currentTime.clone().add(this.granularity, 'minutes'))) &&
+                                (Moment.tz(reservation.end_time(), this.format.wsOrgTimezone) > currentTime)) {
+                                if (!this.scheduleSource.data[idx][r.barcode()]) { this.scheduleSource.data[idx][r.barcode()] = []; }
+                                this.scheduleSource.data[idx][r.barcode()].push(
+                                    {'patronLabel': reservation.usr().usrname(), 'patronId': reservation.usr().id()});
+                            }
+                        });
+                        currentTime.add(this.granularity, 'minutes');
+                    }
+                });
+            });
+    }
+
+}
+
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..9926147
--- /dev/null
@@ -0,0 +1,73 @@
+<eg-staff-banner bannerText="Manage Reservations" i18n-bannerText>
+</eg-staff-banner>
+<eg-title i18n-prefix i18n-suffix prefix="Booking" suffix="Manage Reservations"></eg-title>
+
+<div class="card-body row">
+  <div class="col-sm-3">
+    <eg-org-select-with-descendants labelText="Pickup library" i18n-labelText (ouChange)="handlePickupLibChange($event)">
+    </eg-org-select-with-descendants>
+  </div>
+  <div class="col-sm-6 offset-sm-3">
+    <div class="card">
+      <h2 class="card-header" i18n>Filter reservations</h2>
+      <ngb-tabset #filters [activeId]="selectedFilter" (tabChange)="setStickyFilter($event)" 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" i18n-placeholder placeholder="Patron barcode" [(ngModel)]="patronBarcode" (change)="filterByCurrentPatronBarcode()">
+                <div class="input-group-button">
+                  <button *ngIf="patronBarcode" 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="resourceId">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" i18n-placeholder placeholder="Resource barcode" [(ngModel)]="resourceBarcode" (change)="filterByCurrentResourceBarcode()">
+                <div class="input-group-button">
+                  <button *ngIf="resourceBarcode" 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="resourceTypeId">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-resource-type-combobox domId="resource-type-value" (typeChanged)="filterByResourceType($event)" [startId]="resourceTypeId"></eg-resource-type-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>
+</div>
+<eg-reservations-grid #reservationsGrid [patron]="patronId" [resource]="resourceId" [resourceType]="resourceTypeId" [pickupLibIds]="pickupLibIds" 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..2de8583
--- /dev/null
@@ -0,0 +1,131 @@
+import {Component, OnInit, ViewChild} from '@angular/core';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {single} from 'rxjs/operators';
+import {NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {PatronService} from '@eg/staff/share/patron.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {ReservationsGridComponent} from './reservations-grid.component';
+import {ServerStoreService} from '@eg/core/server-store.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+
+@Component({
+    selector: 'eg-manage-reservations',
+    templateUrl: './manage-reservations.component.html',
+})
+export class ManageReservationsComponent implements OnInit {
+
+    pickupLibIds: number[];
+    patronBarcode: string;
+    patronId: number;
+    resourceBarcode: string;
+    resourceId: number;
+    resourceTypeId: number;
+    selectedFilter: 'patron' | 'resource' | 'type' = 'patron';
+
+    @ViewChild('reservationsGrid') reservationsGrid: ReservationsGridComponent;
+
+    handlePickupLibChange: ($event: number[]) => void;
+    filterByCurrentPatronBarcode: () => void;
+    filterByCurrentResourceBarcode: () => void;
+    filterByResourceType: (selected: ComboboxEntry) => void;
+    removeFilters: () => void;
+    setStickyFilter: ($event: NgbTabChangeEvent) => void;
+
+    constructor(
+        private route: ActivatedRoute,
+        private router: Router,
+        private pcrud: PcrudService,
+        private patron: PatronService,
+        private store: ServerStoreService,
+        private toast: ToastService
+    ) {
+    }
+
+    ngOnInit() {
+        this.route.paramMap.subscribe((params: ParamMap) => {
+            this.patronId = +params.get('patron_id');
+            this.resourceBarcode = params.get('resource_barcode');
+            this.resourceTypeId = +params.get('resource_type_id');
+
+            if (this.patronId) {
+                this.pcrud.search('au', {
+                    'id': this.patronId,
+                }, {
+                    limit: 1,
+                    flesh: 1,
+                    flesh_fields: {'au': ['card']}
+                }).subscribe(
+                    (resp) => {
+                        this.reservationsGrid.reloadGrid();
+                        this.patronBarcode = resp.card().barcode(); },
+                    (err) => { console.debug(err); }
+                );
+            } else if (this.resourceBarcode) {
+                this.selectedFilter = 'resource';
+                this.pcrud.search('brsrc',
+                {'barcode' : this.resourceBarcode}, {'limit': 1})
+                .pipe(single())
+                .subscribe((res) => {
+                    this.resourceId = res.id();
+                    this.reservationsGrid.reloadGrid();
+                }, (err) => {
+                    this.resourceId = -1;
+                    this.toast.danger('No resource found with this barcode');
+                });
+            } else if (this.resourceTypeId) {
+                this.selectedFilter = 'type';
+                this.reservationsGrid.reloadGrid();
+            }
+
+            if (!(this.patronId)) {
+                this.store.getItem('eg.booking.manage.filter').then(filter => {
+                    if (filter) { this.selectedFilter = filter; }
+                });
+            }
+        });
+
+        this.handlePickupLibChange = ($event: number[]) => {
+            this.pickupLibIds = $event;
+            this.reservationsGrid.reloadGrid();
+        };
+
+        this.setStickyFilter = ($event: NgbTabChangeEvent) => {
+            this.store.setItem('eg.booking.manage.filter', $event.nextId);
+        };
+
+        this.removeFilters = () => {
+            this.router.navigate(['/staff', 'booking', 'manage_reservations']);
+        };
+
+        this.filterByCurrentPatronBarcode = () => {
+            if (this.patronBarcode) {
+                this.patron.bcSearch(this.patronBarcode).pipe(single()).subscribe(
+                    (response) => {
+                        this.router.navigate(['/staff', 'booking', 'manage_reservations', 'by_patron', response[0].id]);
+                    }, (error) => {
+                        this.toast.danger('No patron found with this barcode');
+                });
+            } else {
+                this.removeFilters();
+            }
+        };
+
+        this.filterByCurrentResourceBarcode = () => {
+            if (this.resourceBarcode) {
+                this.router.navigate(['/staff', 'booking', 'manage_reservations', 'by_resource', this.resourceBarcode]);
+            } else {
+                this.removeFilters();
+            }
+        };
+
+        this.filterByResourceType = (selected: ComboboxEntry) => {
+            if (selected.id) {
+                this.router.navigate(['/staff', 'booking', 'manage_reservations', 'by_resource_type', selected.id]);
+            }
+        };
+
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/booking/no-timezone-set.component.html b/Open-ILS/src/eg2/src/app/staff/booking/no-timezone-set.component.html
new file mode 100644 (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..2d6282f
--- /dev/null
@@ -0,0 +1,16 @@
+import {Component, Input, ViewChild, TemplateRef} from '@angular/core';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+
+@Component({
+  selector: 'eg-no-timezone-set-dialog',
+  templateUrl: './no-timezone-set.component.html'
+})
+
+/**
+ * Dialog that warns users that there is no valid lib.timezone setting
+ */
+export class NoTimezoneSetComponent extends DialogComponent {
+    openLSE(): void {
+        window.open('/eg/staff/admin/local/asset/org_unit_settings', '_blank');
+    }
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/booking/org-select-with-descendants.component.ts b/Open-ILS/src/eg2/src/app/staff/booking/org-select-with-descendants.component.ts
new file mode 100644 (file)
index 0000000..4662775
--- /dev/null
@@ -0,0 +1,61 @@
+// TODO: Combine with the OU Selector from AdminPage to create a reusable component
+import {Component, EventEmitter, OnInit, Input, Output, ViewChild} from '@angular/core';
+import {AuthService} from '@eg/core/auth.service';
+import {IdlObject} from '@eg/core/idl.service';
+import {OrgService} from '@eg/core/org.service';
+
+@Component({
+    selector: 'eg-org-select-with-descendants',
+    template: ` <div class="input-group">
+      <div class="input-group-prepend">
+        <label [for]="domId" class="input-group-text">{{labelText}}</label>
+      </div>
+      <eg-org-select [domId]="domId" (onChange)="orgOnChange($event)"
+        [initialOrgId]="selectedOrg">
+      </eg-org-select>
+    </div>
+    <div class="form-check">
+      <input type="checkbox" class="form-check-input" id="{{domId}}-include-descendants"
+        (click)="emitArray()" [(ngModel)]="includeOrgDescendants">
+      <label class="form-check-label" for="{{domId}}-include-descendants" i18n>+ Descendants</label>
+    </div>`
+})
+export class OrgSelectWithDescendantsComponent implements OnInit {
+
+    @Input() labelText = 'Library';
+    @Output() ouChange: EventEmitter<number[]>;
+    domId: string;
+
+    selectedOrg: number;
+    includeOrgDescendants = true;
+
+    orgOnChange: ($event: IdlObject) => void;
+    emitArray: () => void;
+
+    constructor(
+        private auth: AuthService,
+        private org: OrgService
+    ) {
+        this.ouChange = new EventEmitter<number[]>();
+    }
+
+    ngOnInit() {
+        this.domId = 'org-select-' + Math.floor(Math.random() * 100000);
+        this.selectedOrg = this.auth.user().ws_ou();
+
+        this.orgOnChange = ($event: IdlObject) => {
+            this.selectedOrg = $event.id();
+            this.emitArray();
+        };
+
+        this.emitArray = () => {
+            if (this.includeOrgDescendants) {
+                this.ouChange.emit(this.org.descendants(this.selectedOrg, true));
+            } else {
+                this.ouChange.emit([this.selectedOrg]);
+            }
+        };
+    }
+
+}
+
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..5073d79
--- /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>
+
+<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" [(ngModel)]="patronBarcode" (change)="retrievePatron()">
+      </div>
+    </div>
+  </div>
+</div>
+<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..1ec9da7
--- /dev/null
@@ -0,0 +1,84 @@
+import {Component, Input, OnInit, ViewChild} from '@angular/core';
+import {single, tap} from 'rxjs/operators';
+import {Pager} from '@eg/share/util/pager';
+import {PatronService} from '@eg/staff/share/patron.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {AuthService} from '@eg/core/auth.service';
+import {IdlObject} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {ReservationsGridComponent} from './reservations-grid.component';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {ServerStoreService} from '@eg/core/server-store.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+
+
+@Component({
+  templateUrl: './pickup.component.html'
+})
+
+export class PickupComponent implements OnInit {
+    patronBarcode: string;
+    patronId: number;
+    retrievePatron: () => void;
+
+    @ViewChild('readyGrid') readyGrid: ReservationsGridComponent;
+    @ViewChild('pickedUpGrid') pickedUpGrid: ReservationsGridComponent;
+
+    noSelectedRows: (rows: IdlObject[]) => boolean;
+
+    onlyShowCaptured = true;
+    handleShowCapturedChange: () => void;
+
+    constructor(
+        private auth: AuthService,
+        private net: NetService,
+        private pcrud: PcrudService,
+        private patron: PatronService,
+        private route: ActivatedRoute,
+        private router: Router,
+        private store: ServerStoreService,
+        private toast: ToastService
+    ) {
+    }
+
+
+    ngOnInit() {
+        this.route.paramMap.subscribe((params: ParamMap) => {
+            this.patronId = +params.get('patron_id');
+            this.pcrud.search('au', {
+                'id': this.patronId,
+            }, {
+                limit: 1,
+                flesh: 1,
+                flesh_fields: {'au': ['card']}
+            }).subscribe(
+                (resp) => {
+                    this.patronBarcode = resp.card().barcode();
+                    this.readyGrid.reloadGrid();
+                    this.pickedUpGrid.reloadGrid();
+                }, (err) => { console.debug(err); }
+            );
+        });
+
+        this.retrievePatron = () => {
+            if (this.patronBarcode) {
+                this.patron.bcSearch(this.patronBarcode).pipe(single()).subscribe(
+                    resp => { this.router.navigate(['/staff', 'booking', 'pickup', 'by_patron', resp[0].id]); },
+                    err => { this.toast.danger('No patron found with this barcode'); },
+                );
+            }
+        };
+
+        this.store.getItem('eg.booking.pickup.ready.only_show_captured').then(onlyCaptured => {
+            if (onlyCaptured != null) { this.onlyShowCaptured = onlyCaptured; }
+        });
+        this.handleShowCapturedChange = () => {
+            this.onlyShowCaptured = !this.onlyShowCaptured;
+            this.readyGrid.reloadGrid();
+            this.store.setItem('eg.booking.pickup.ready.only_show_captured', this.onlyShowCaptured);
+        };
+
+
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/booking/pull-list.component.html b/Open-ILS/src/eg2/src/app/staff/booking/pull-list.component.html
new file mode 100644 (file)
index 0000000..c17494a
--- /dev/null
@@ -0,0 +1,40 @@
+<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)="fill_grid($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"
+  [sortable]="true" persistKey="booking.pull_list">
+  <eg-grid-column name="id" [hidden]="true" [index]="true" i18n-label label="ID" path="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>
+
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..44cc05a
--- /dev/null
@@ -0,0 +1,74 @@
+import {Component, Input, OnInit} from '@angular/core';
+import {FormControl, FormGroup, Validators} from '@angular/forms';
+import {AuthService} from '@eg/core/auth.service';
+import {GridColumn, GridDataSource} from '@eg/share/grid/grid';
+import {IdlObject} from '@eg/core/idl.service';
+import {NetRequest, NetService} from '@eg/core/net.service';
+import {OrgService} from '@eg/core/org.service';
+import {Pager} from '@eg/share/util/pager';
+import {PcrudService} from '@eg/core/pcrud.service';
+
+
+@Component({
+    templateUrl: './pull-list.component.html'
+})
+
+export class PullListComponent implements OnInit {
+    public dataSource: GridDataSource;
+
+    public disableOrgs: () => number[];
+    public fill_grid: (orgId?: number) => void;
+    pullListCriteria: FormGroup;
+
+    constructor(
+        private auth: AuthService,
+        private net: NetService,
+        private org: OrgService,
+        private pcrud: PcrudService
+    ) { }
+
+
+    ngOnInit() {
+        this.pullListCriteria = new FormGroup({
+            'daysHence': new FormControl(5, [
+                Validators.required,
+                Validators.min(1)])
+        });
+
+        this.pullListCriteria.valueChanges.subscribe(() => { this.fill_grid(); });
+
+        this.disableOrgs = () => this.org.filterList( { canHaveVolumes : false }, true);
+
+        this.fill_grid = (orgId = this.auth.user().ws_ou()) => {
+            this.net.request(
+                'open-ils.booking', 'open-ils.booking.reservations.get_pull_list',
+                this.auth.token(), null,
+                (86400 * this.daysHence.value), // convert seconds to days
+                orgId
+            ).subscribe( data => {
+                data.forEach(resource => { // shouldn't this be streamable?
+                    if (resource['target_resource_type'].catalog_item()) {
+                        this.pcrud.search('acp', {
+                                'barcode': resource['current_resource'].barcode()
+                            }, {
+                                limit: 1,
+                                flesh: 1,
+                                flesh_fields: {'acp' : ['call_number', 'location' ]}
+                        }).subscribe( (acp) => {
+                            resource['call_number'] = acp.call_number().label();
+                            resource['call_number_sortkey'] = acp.call_number().label_sortkey();
+                            resource['shelving_location'] = acp.location().name();
+                        });
+                    }
+                });
+                this.dataSource.data = data;
+            });
+        };
+        this.dataSource = new GridDataSource();
+        this.fill_grid(this.auth.user().ws_ou());
+    }
+    get daysHence() {
+        return this.pullListCriteria.get('daysHence');
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/booking/reservation-validate.service.ts b/Open-ILS/src/eg2/src/app/staff/booking/reservation-validate.service.ts
new file mode 100644 (file)
index 0000000..d2be8ab
--- /dev/null
@@ -0,0 +1,62 @@
+import {Injectable} from '@angular/core';
+import {IdlObject} from '@eg/core/idl.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import * as Moment from 'moment-timezone';
+
+@Injectable({providedIn: 'root'})
+export class ReservationValidateService {
+
+    constructor(
+        private pcrud: PcrudService,
+    ) {
+    }
+    errorMessage = '';
+
+    private duringExistingReservation = (value: Moment, record: IdlObject) => {
+        if (record.target_resource()) {
+            this.pcrud.search('bresv', {
+                'cancel_time': null,
+                'return_time': null,
+                'start_time': {'<': value.toISOString()},
+                'end_time': {'>': value.toISOString()},
+                '-or': {'current_resource': record.target_resource(), 'target_resource': record.target_resource()}})
+            .subscribe((foundOne) => {this.errorMessage = 'There is already a reservation for this resource at this time.'});
+        }
+    }
+
+    start_time = (fieldName: string, value: Moment, record: IdlObject) => {
+        this.errorMessage = '';
+        this.duringExistingReservation(value, record);
+        if (record.target_resource_type() && record.target_resource()) {
+            this.pcrud.retrieve('brt', record.target_resource_type())
+            .subscribe((brt) => {
+                if (brt.catalog_item()) {
+                   this.pcrud.retrieve('brsrc', record.target_resource())
+                   .subscribe((brsrc) => {
+                       this.pcrud.search('circ', {
+                           'checkin_time': 'null',
+                           'target_copy': {'barcode': brsrc.barcode()},
+                           'due_date': {'>': value.toISOString()}},
+                           {'flesh': 1, 'flesh_fields': {'circ': ['target_copy']}})
+                       .subscribe(() => {this.errorMessage = 'Start time conflicts with an existing circulation';});
+                   });
+                }
+            });
+        }      
+        if (Moment(value) < Moment()) {
+            this.errorMessage = 'Start time must be in the future';
+        }
+        return this.errorMessage;
+    }
+
+    end_time = (fieldName: string, value: Moment, record: IdlObject) => {
+        this.errorMessage = '';
+        this.duringExistingReservation(value, record);
+        if (Moment(value) <= Moment(record.start_time())) {
+            return 'End time must be after start time';
+        }
+        return '';
+    }
+
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/booking/reservations-grid.component.html b/Open-ILS/src/eg2/src/app/staff/booking/reservations-grid.component.html
new file mode 100644 (file)
index 0000000..85f182f
--- /dev/null
@@ -0,0 +1,57 @@
+<eg-grid #grid [dataSource]="gridSource"
+  (onRowActivate)="handleRowActivate($event)"
+  [sortable]="true"
+  [useLocalSort]="true"
+  persistKey="booking.{{persistSuffix}}" >
+  <eg-grid-toolbar-action label="Edit Selected" i18n-label [action]="editSelected" [disableOnRows]="editNotAppropriate"></eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="Cancel Selected" i18n-label [action]="cancelSelected" [disableOnRows]="cancelNotAppropriate"></eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="Pick Up Selected" i18n-label [action]="pickupSelected" [disableOnRows]="pickupNotAppropriate"></eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="Return Selected" i18n-label [action]="returnSelected" [disableOnRows]="returnNotAppropriate"></eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="View Reservations for This Patron" i18n-label [action]="viewByPatron" [disableOnRows]="notOnePatronSelected"></eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="View Reservations for This Resource" i18n-label [action]="viewByResource" [disableOnRows]="notOneResourceSelected"></eg-grid-toolbar-action>
+  <eg-grid-toolbar-button *ngIf="!status" label="Create New Reservation" i18n-label [action]="redirectToCreate"></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"
+  hiddenFields="xact_finish,cancel_time,booking_interval"
+  [fieldOptions]="{start_time:{validator:reservationValidate.start_time},end_time:{validator:reservationValidate.end_time}}"
+  [readonlyFields]="listReadOnlyFields()">
+</eg-fm-record-editor>
+<eg-confirm-dialog #confirmCancelReservationDialog
+  i18n-dialogTitle i18n-dialogBody
+  dialogTitle="Confirm Cancelation"
+  dialogBody="Are you sure you want to cancel {numRowsSelected, plural, =1 {this reservation} other {these {{numRowsSelected}} reservations}}?">
+</eg-confirm-dialog>
+<eg-no-timezone-set-dialog #noTimezoneSetDialog>
+</eg-no-timezone-set-dialog>
+
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..e644c31
--- /dev/null
@@ -0,0 +1,294 @@
+import {Component, EventEmitter, Input, Output, OnInit, ViewChild} from '@angular/core';
+import {Observable} from 'rxjs';
+import {tap} from 'rxjs/operators';
+import {AuthService} from '@eg/core/auth.service';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {FormatService} from '@eg/core/format.service';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {GridDataSource} from '@eg/share/grid/grid';
+import {IdlObject} from '@eg/core/idl.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {Pager} from '@eg/share/util/pager';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {NetService} from '@eg/core/net.service';
+import {NoTimezoneSetComponent} from './no-timezone-set.component';
+import {OrgService} from '@eg/core/org.service';
+import {PatronService} from '@eg/staff/share/patron.service';
+import {ReservationValidateService} from './reservation-validate.service';
+
+import * as Moment from 'moment-timezone';
+
+@Component({
+    selector: 'eg-reservations-grid',
+    templateUrl: './reservations-grid.component.html',
+})
+export class ReservationsGridComponent implements OnInit {
+
+    @Input() patron: number;
+    @Input() resource: number;
+    @Input() resourceType: number;
+    @Input() pickupLibIds: number[];
+    @Input() status: 'pickupReady' | 'pickedUp' | 'returnReady' | 'returnedToday';
+    @Input() persistSuffix: string;
+    @Input() onlyCaptured = false;
+
+    @Output() onPickup = new EventEmitter<IdlObject>();
+
+    gridSource: GridDataSource;
+    patronBarcode: string;
+    numRowsSelected: number;
+
+    @ViewChild('grid') grid: GridComponent;
+    @ViewChild('editDialog') editDialog: FmRecordEditorComponent;
+    @ViewChild('confirmCancelReservationDialog')
+    private _cancelReservationDialog: ConfirmDialogComponent;
+    @ViewChild('noTimezoneSetDialog') noTimezoneSetDialog: NoTimezoneSetComponent;
+
+    editSelected: (rows: IdlObject[]) => void;
+    pickupSelected: (rows: IdlObject[]) => void;
+    pickupResource: (rows: IdlObject) => Observable<any>;
+    returnSelected: (rows: IdlObject[]) => void;
+    returnResource: (rows: IdlObject) => Observable<any>;
+    cancelSelected: (rows: IdlObject[]) => void;
+    viewByPatron: (rows: IdlObject[]) => void;
+    viewByResource: (rows: IdlObject[]) => void;
+    filterByCurrentPatronBarcode: () => void;
+    filterByCurrentResourceBarcode: () => void;
+    listReadOnlyFields: () => string;
+
+    handleRowActivate: (row: IdlObject) => void;
+    redirectToCreate: () => void;
+
+    reloadGrid: () => void;
+
+    noSelectedRows: (rows: IdlObject[]) => boolean;
+    notOnePatronSelected: (rows: IdlObject[]) => boolean;
+    notOneResourceSelected: (rows: IdlObject[]) => boolean;
+    cancelNotAppropriate: (rows: IdlObject[]) => boolean;
+    pickupNotAppropriate: (rows: IdlObject[]) => boolean;
+    editNotAppropriate: (rows: IdlObject[]) => boolean;
+    returnNotAppropriate: (rows: IdlObject[]) => boolean;
+
+    constructor(
+        private auth: AuthService,
+        private format: FormatService,
+        private pcrud: PcrudService,
+        private route: ActivatedRoute,
+        private router: Router,
+        private toast: ToastService,
+        private net: NetService,
+        private org: OrgService,
+        private patronService: PatronService,
+        public reservationValidate: ReservationValidateService
+    ) {
+
+    }
+
+    ngOnInit() {
+        if (!(this.format.wsOrgTimezone)) {
+            this.noTimezoneSetDialog.open();
+        }
+
+
+        this.gridSource = new GridDataSource();
+
+        this.gridSource.getRows = (pager: Pager, sort: any[]) => {
+            const orderBy: any = {};
+            const where = {
+                'usr' : (this.patron ? this.patron : {'>' : 0}),
+                'target_resource_type' : (this.resourceType ? this.resourceType : {'>' : 0}),
+                'cancel_time' : null,
+                'xact_finish' : null,
+            };
+            if (this.resource) {
+                where['current_resource'] = this.resource;
+            }
+            if (this.pickupLibIds) {
+                where['pickup_lib'] = this.pickupLibIds;
+            }
+            if (this.onlyCaptured) {
+                where['capture_time'] = {'!=': null};
+            }
+
+            if (this.status) {
+                if ('pickupReady' === this.status) {
+                    where['pickup_time'] = null;
+                    where['start_time'] = {'!=': null};
+                } else if ('pickedUp' === this.status || 'returnReady' === this.status) {
+                    where['pickup_time'] = {'!=': null};
+                    where['return_time'] = null;
+                } else if ('returnedToday' === this.status) {
+                    where['return_time'] = {'>': Moment().startOf('day').toISOString()};
+                }
+            }
+            if (sort.length) {
+                orderBy.bresv = sort[0].name + ' ' + sort[0].dir;
+            }
+            return this.pcrud.search('bresv', where,  {
+                order_by: orderBy,
+                limit: pager.limit,
+                offset: pager.offset,
+                flesh: 2,
+                flesh_fields: {'bresv' : [
+                    'usr', 'capture_staff', 'target_resource', 'target_resource_type', 'current_resource', 'request_lib', 'pickup_lib'
+                ], 'au': ['card'] }
+            }).pipe(tap((row) => {
+                row['length'] = Moment(row['end_time']()).from(Moment(row['start_time']()), true);
+                this.org.settings('lib.timezone', row['pickup_lib']()).then((tz) => { row['timezone'] = tz['lib.timezone']; });
+            }));
+        };
+
+        this.editDialog.mode = 'update';
+        this.editSelected = (idlThings: IdlObject[]) => {
+            const editOneThing = (thing: IdlObject) => {
+                if (!thing) { return; }
+                this.showEditDialog(thing).subscribe(
+                    () => editOneThing(idlThings.shift()));
+            };
+           editOneThing(idlThings.shift()); };
+
+        this.cancelSelected = (reservations: IdlObject[]) => {
+            const reservationIds = reservations.map(reservation => reservation.id());
+            this.numRowsSelected = reservationIds.length;
+            this._cancelReservationDialog.open()
+                .subscribe(
+                    confirmed => {this.net.request(
+                        'open-ils.booking',
+                        'open-ils.booking.reservations.cancel',
+                        this.auth.token(), reservationIds)
+                        .subscribe(
+                            (res) => this.handleSuccessfulCancel(res),
+                            (err) => alert('ERR: ' + JSON.stringify(err))
+                        );
+                    });
+        };
+
+        this.viewByPatron = (reservations: IdlObject[]) => {
+            const patronIds = reservations.map(reservation => reservation.usr().id());
+            this.router.navigate(['/staff', 'booking', 'manage_reservations', 'by_patron', patronIds[0]]);
+        };
+
+        this.viewByResource = (reservations: IdlObject[]) => {
+            const resourceBarcodes = reservations.map(reservation => reservation.current_resource().barcode());
+            this.filterByResourceBarcode(resourceBarcodes[0]);
+        };
+
+        this.noSelectedRows = (rows: IdlObject[]) => (rows.length === 0);
+        this.notOnePatronSelected = (rows: IdlObject[]) => (new Set(rows.map(row => row.usr().id())).size !== 1);
+        this.notOneResourceSelected = (rows: IdlObject[]) =>
+            (new Set(rows.map(row => { if (row.current_resource()) { return row.current_resource().id(); }})).size !== 1);
+        this.cancelNotAppropriate = (rows: IdlObject[]) =>
+            (this.noSelectedRows(rows) || ['pickedUp', 'returnReady', 'returnedToday'].includes(this.status));
+        this.pickupNotAppropriate = (rows: IdlObject[]) => (this.noSelectedRows(rows) || ('pickupReady' !== this.status));
+        this.editNotAppropriate = (rows: IdlObject[]) => (this.noSelectedRows(rows) || ('returnedToday' === this.status));
+        this.returnNotAppropriate = (rows: IdlObject[]) => {
+            if (this.noSelectedRows(rows)) {
+                return true;
+            } else if (this.status && ('pickupReady' === this.status)) {
+                return true;
+            } else {
+                rows.forEach(row => {
+                    if ((null == row.pickup_time()) || row.return_time()) { return true; }
+                });
+            }
+            return false;
+        };
+
+        this.reloadGrid = () => { this.grid.reload(); };
+
+        this.pickupSelected = (reservations: IdlObject[]) => {
+            const pickupOne = (thing: IdlObject) => {
+                if (!thing) { return; }
+                this.pickupResource(thing).subscribe(
+                    () => pickupOne(reservations.shift()));
+            };
+            pickupOne(reservations.shift());
+        };
+
+        this.returnSelected = (reservations: IdlObject[]) => {
+            const returnOne = (thing: IdlObject) => {
+                if (!thing) { return; }
+                this.returnResource(thing).subscribe(
+                    () => returnOne(reservations.shift()));
+            };
+            returnOne(reservations.shift());
+        };
+
+        this.pickupResource = (reservation: IdlObject) => {
+            return this.net.request(
+               'open-ils.circ',
+               'open-ils.circ.reservation.pickup',
+               this.auth.token(),
+                   {'patron_barcode': reservation.usr().card().barcode(), 'reservation': reservation})
+               .pipe(tap(
+                   (success) => {
+                       this.onPickup.emit(reservation);
+                       this.grid.reload(); },
+                   (error) => { console.debug(error); }
+               ));
+        };
+
+        this.returnResource = (reservation: IdlObject) => {
+            return this.net.request(
+               'open-ils.circ',
+               'open-ils.circ.reservation.return',
+               this.auth.token(),
+               {'patron_barcode': this.patronBarcode, 'reservation': reservation})
+               .pipe(tap(
+                   (success) => { this.grid.reload(); },
+                   (error) => { console.debug(error); }
+               ));
+        };
+
+        this.listReadOnlyFields = () => {
+            let list = 'usr,xact_start,request_time,capture_time,pickup_time,return_time,capture_staff,target_resource_type,' +
+                'current_resource,target_resource,unrecovered,request_library,pickup_library,fine_interval,fine_amount,max_fine';
+            if (this.status && ('pickupReady' !== this.status)) { list = list + ',start_time'; }
+            if (this.status && ('returnedToday' === this.status)) { list = list + ',end_time'; }
+            return list;
+        };
+
+        this.handleRowActivate = (row: IdlObject) => {
+            if (this.status) {
+                if ('returnReady' === this.status) {
+                    this.returnResource(row).subscribe();
+                } else if ('pickupReady' === this.status) {
+                    this.pickupResource(row).subscribe();
+                } else if ('returnedToday' === this.status) {
+                    this.toast.warning('Cannot edit this reservation');
+                } else {
+                    this.showEditDialog(row);
+                }
+            } else {
+                this.showEditDialog(row);
+            }
+        };
+
+        this.redirectToCreate = () => {
+            this.router.navigate(['/staff', 'booking', 'create_reservation']);
+        };
+    }
+
+    showEditDialog(idlThing: IdlObject) {
+        this.editDialog.recId = idlThing.id();
+        this.editDialog.timezone = idlThing['timezone'];
+        return this.editDialog.open({size: 'lg'}).pipe(tap(
+            ok => {
+                this.toast.success('Reservation successfully updated'); // TODO: needs i18n, pluralization
+                this.grid.reload();
+            }
+        ));
+    }
+
+    handleSuccessfulCancel(res: any) {
+        this.toast.success('Reservation successfully canceled'); // TODO: needs i18n, pluralization
+        this.grid.reload();
+    }
+    filterByResourceBarcode(barcode: string) {
+        this.router.navigate(['/staff', 'booking', 'manage_reservations', 'by_resource', barcode]);
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/booking/resource-type-combobox.component.ts b/Open-ILS/src/eg2/src/app/staff/booking/resource-type-combobox.component.ts
new file mode 100644 (file)
index 0000000..54d87ad
--- /dev/null
@@ -0,0 +1,45 @@
+import {Component, EventEmitter, OnInit, Input, Output, ViewChild} from '@angular/core';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {ComboboxComponent} from '@eg/share/combobox/combobox.component';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+
+@Component({
+    selector: 'eg-resource-type-combobox',
+    template: `<eg-combobox
+        #resourceTypeCombobox
+        [attr.id]="domId"
+        placeholder="Resource type" i18n-placeholder
+        [entries]="resourceTypes"
+        (onChange)="typeChanged.emit($event)"
+        [startId]="startId"></eg-combobox>`
+})
+export class ResourceTypeComboboxComponent implements OnInit {
+
+    resourceTypes: ComboboxEntry[];
+
+    clear: () => void;
+
+    @Input() domId = '';
+    @Input() startId: number;
+    @Output() typeChanged: EventEmitter<ComboboxEntry>;
+
+    @ViewChild('resourceTypeCombobox') resourceTypeCombobox: ComboboxComponent;
+
+    constructor(private pcrud: PcrudService) {
+        this.typeChanged = new EventEmitter<ComboboxEntry>();
+    }
+
+    ngOnInit() {
+        this.pcrud.retrieveAll('brt', {order_by: {brt: 'name'}})
+        .subscribe(type => {
+            if (!this.resourceTypes) { this.resourceTypes = []; }
+            this.resourceTypes.push({id: type.id(), label: type.name()});
+        }, (err) => {},
+            () => {this.resourceTypes.sort((a, b) => a.label.localeCompare(b.label)); });
+        this.clear = () => {
+            this.resourceTypeCombobox.selected = {id: '', label: ''};
+        };
+    }
+
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/booking/return.component.html b/Open-ILS/src/eg2/src/app/staff/booking/return.component.html
new file mode 100644 (file)
index 0000000..eb903be
--- /dev/null
@@ -0,0 +1,45 @@
+<eg-staff-banner bannerText="Booking Return" i18n-bannerText>
+</eg-staff-banner>
+<eg-title i18n-prefix i18n-suffix prefix="Booking" suffix="Return"></eg-title>
+
+<ngb-tabset (tabChange)="handleTabChange($event)" [activeId]="selectedTab">
+  <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" [(ngModel)]="patronBarcode" (change)="retrievePatronByBarcode()">
+            </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)="this.returnedGrid.reloadGrid()" 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" [(ngModel)]="resourceBarcode" (change)="retrievePatronByResource()">
+        </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>
+
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..9fb508e
--- /dev/null
@@ -0,0 +1,111 @@
+import {Component, Input, OnInit, QueryList, ViewChildren} from '@angular/core';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap';
+import {single} from 'rxjs/operators';
+import {GridDataSource} from '@eg/share/grid/grid';
+import {Pager} from '@eg/share/util/pager';
+import {PatronService} from '@eg/staff/share/patron.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {AuthService} from '@eg/core/auth.service';
+import {IdlObject} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {ReservationsGridComponent} from './reservations-grid.component';
+import {ServerStoreService} from '@eg/core/server-store.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+
+
+@Component({
+  templateUrl: './return.component.html'
+})
+
+export class ReturnComponent implements OnInit {
+    resourceBarcode: string;
+    patronBarcode: string;
+    patronId: number;
+    retrievePatronByBarcode: () => void;
+    retrievePatronByResource: () => void;
+    selectedTab: 'patron' | 'resource' = 'patron';
+
+    noSelectedRows: (rows: IdlObject[]) => boolean;
+    handleTabChange: ($event: NgbTabChangeEvent) => void;
+    @ViewChildren('readyGrid') readyGrids: QueryList<ReservationsGridComponent>;
+    @ViewChildren('returnedGrid') returnedGrids: QueryList<ReservationsGridComponent>;
+
+    constructor(
+        private auth: AuthService,
+        private net: NetService,
+        private pcrud: PcrudService,
+        private patron: PatronService,
+        private route: ActivatedRoute,
+        private router: Router,
+        private store: ServerStoreService,
+        private toast: ToastService
+    ) {
+    }
+
+
+    ngOnInit() {
+        this.route.paramMap.subscribe((params: ParamMap) => {
+            this.patronId = +params.get('patron_id');
+            if (this.patronId) {
+                this.pcrud.search('au', {
+                    'id': this.patronId,
+                }, {
+                    limit: 1,
+                    flesh: 1,
+                    flesh_fields: {'au': ['card']}
+                }).subscribe(
+                    (resp) => {
+                        this.patronBarcode = resp.card().barcode();
+                        this.readyGrids.forEach (readyGrid => readyGrid.reloadGrid());
+                        this.returnedGrids.forEach (returnedGrid => returnedGrid.reloadGrid());
+                    }, (err) => { console.debug(err); }
+                );
+            } else {
+                this.store.getItem('eg.booking.return.tab').then(tab => {
+                    if (tab) { this.selectedTab = tab; }
+                });
+            }
+        });
+
+        this.retrievePatronByBarcode = () => {
+            if (this.patronBarcode) {
+                this.patron.bcSearch(this.patronBarcode).pipe(single()).subscribe(
+                    resp => { this.router.navigate(['/staff', 'booking', 'return', 'by_patron', resp[0].id]); },
+                    err => { this.toast.danger('No patron found with this barcode'); },
+                );
+            }
+        };
+
+        this.retrievePatronByResource = () => {
+            if (this.resourceBarcode) {
+                this.pcrud.search('brsrc', {'barcode': this.resourceBarcode}, {
+                    order_by: {'curr_rsrcs': 'pickup_time DESC'},
+                    limit: 1,
+                    flesh: 1,
+                    flesh_fields: {'brsrc': ['curr_rsrcs']},
+                    select: {'curr_rsrcs': {'return_time': null, 'pickup_time': {'!=': null}}}
+                }).subscribe((resp) => {
+                    if (resp.curr_rsrcs()[0].usr()) {
+                        this.patronId = resp.curr_rsrcs()[0].usr();
+                        this.readyGrids.forEach (readyGrid => readyGrid.reloadGrid());
+                        this.returnedGrids.forEach (returnedGrid => returnedGrid.reloadGrid());
+                    }
+                });
+            }
+        };
+        this.noSelectedRows = (rows: IdlObject[]) => (rows.length === 0);
+
+        this.handleTabChange = ($event) => {
+            this.store.setItem('eg.booking.return.tab', $event.nextId)
+            .then(() => {
+                this.router.navigate(['/staff', 'booking', 'return']);
+                this.resourceBarcode = null;
+                this.patronBarcode = null;
+                this.patronId = null;
+            });
+        };
+
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/booking/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/booking/routing.module.ts
new file mode 100644 (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 {}
index 8034f41..3661017 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 5819e33..a684608 100644 (file)
@@ -21,7 +21,15 @@ body, .form-control, .btn, .input-group-text {
    */
   font-size: .88rem;
 }
-h2 {font-size: 1.25rem}
+h2 {
+  font-size: 1.25rem;
+  font-weight: 550;
+  color: #129a78; /* official color of the Evergreen logo */
+  text-decoration: underline #129a78;
+}
+h2.card-header {
+  text-decoration: none;
+}
 h3 {font-size: 1.15rem}
 h4 {font-size: 1.05rem}
 h5 {font-size: .95rem}
@@ -142,10 +150,10 @@ h5 {font-size: .95rem}
  * Required valid fields are left-border styled in green-ish.
  * Invalid fields are left-border styled in red-ish.
  */
-.form-validated .ng-valid[required], .form-validated .ng-valid.required {
+.form-validated .ng-valid[required], .form-validated .ng-valid.required, input[formcontrolname].ng-valid {
   border-left: 5px solid #78FA89;
 }
-.form-validated .ng-invalid:not(form) {
+.form-validated .ng-invalid:not(form), input[formcontrolname].ng-invalid {
   border-left: 5px solid #FA787E;
 }
 
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 37df558..d4965de 100644 (file)
@@ -19969,3 +19969,79 @@ VALUES (
 
 INSERT INTO config.workstation_setting_type (name,label,grp,datatype)
 VALUES ('eg.circ.bills.annotatepayment','Bills: Annotate Payment', 'circ', 'bool');
+
+INSERT INTO config.workstation_setting_type (name, grp, datatype, label)
+VALUES (
+    'eg.grid.booking.manage', 'gui', 'object',
+    oils_i18n_gettext(
+        'booking.manage',
+        'Grid Config: Booking Manage Reservations',
+        'cwst', 'label')
+), (
+    'eg.grid.booking.pickup.ready', 'gui', 'object',
+    oils_i18n_gettext(
+        'booking.pickup.ready',
+        'Grid Config: Booking Ready to pick up grid',
+        'cwst', 'label')
+), (
+    'eg.grid.booking.pickup.picked_up', 'gui', 'object',
+    oils_i18n_gettext(
+        'booking.pickup.picked_up',
+        'Grid Config: Booking Already Picked Up grid',
+        'cwst', 'label')
+), (
+    'eg.grid.booking.return.patron.picked_up', 'gui', 'object',
+    oils_i18n_gettext(
+        'booking.return.patron.picked_up',
+        'Grid Config: Booking Return Patron tab Already Picked Up grid',
+        'cwst', 'label')
+), (
+    'eg.grid.booking.return.patron.returned', 'gui', 'object',
+    oils_i18n_gettext(
+        'booking.return.patron.returned',
+        'Grid Config: Booking Return Patron tab Returned Today grid',
+        'cwst', 'label')
+), (
+    'eg.grid.booking.return.resource.picked_up', 'gui', 'object',
+    oils_i18n_gettext(
+        'booking.return.resourcce.picked_up',
+        'Grid Config: Booking Return Resource tab Already Picked Up grid',
+        'cwst', 'label')
+), (
+    'eg.grid.booking.return.resource.returned', 'gui', 'object',
+    oils_i18n_gettext(
+        'booking.return.resource.returned',
+        'Grid Config: Booking Return Resource tab Returned Today grid',
+        'cwst', 'label')
+), (
+    'eg.booking.manage.filter', 'gui', 'string',
+    oils_i18n_gettext(
+        'booking.manage.filter',
+        'Sticky setting for filter tab in Manage Reservations',
+        'cwst', 'label')
+), (
+    'eg.booking.return.tab', 'gui', 'string',
+    oils_i18n_gettext(
+        'booking.return.tab',
+        'Sticky setting for tab in Booking Return',
+        'cwst', 'label')
+), (
+    'eg.booking.create.granularity', 'gui', 'integer',
+    oils_i18n_gettext(
+        'booking.create.granularity',
+        'Sticky setting for granularity combobox in Booking Create',
+        'cwst', 'label')
+), (
+    'eg.booking.create.multiday', 'gui', 'bool',
+    oils_i18n_gettext(
+        'booking.create.multiday',
+        'Default to creating multiday booking reservations',
+        'cwst', 'label')
+), (
+    'eg.booking.pickup.ready.only_show_captured', 'gui', 'bool',
+    oils_i18n_gettext(
+        'booking.pickup.ready.only_show_captured',
+        'Include only resources that have been captured in the Ready grid in the Pickup screen',
+        'cwst', 'label')
+);
+>>>>>>> 6ad3870841... LP1816475: Booking module refresh
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..aa53c4b
--- /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.filter', 'gui', 'string',
+    oils_i18n_gettext(
+        'booking.manage.filter',
+        'Sticky setting for filter tab in Manage Reservations',
+        'cwst', 'label')
+), (
+    'eg.booking.return.tab', 'gui', 'string',
+    oils_i18n_gettext(
+        'booking.return.tab',
+        'Sticky setting for tab in Booking Return',
+        'cwst', 'label')
+), (
+    'eg.booking.create.granularity', 'gui', 'integer',
+    oils_i18n_gettext(
+        'booking.create.granularity',
+        'Sticky setting for granularity combobox in Booking Create',
+        'cwst', 'label')
+), (
+    'eg.booking.create.multiday', 'gui', 'bool',
+    oils_i18n_gettext(
+        'booking.create.multiday',
+        'Default to creating multiday booking reservations',
+        'cwst', 'label')
+), (
+    'eg.booking.pickup.ready.only_show_captured', 'gui', 'bool',
+    oils_i18n_gettext(
+        'booking.pickup.ready.only_show_captured',
+        'Include only resources that have been captured in the Ready grid in the Pickup screen',
+        'cwst', 'label')
+);
+
+COMMIT;
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.add_note_bresv.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.add_note_bresv.sql
new file mode 100644 (file)
index 0000000..4742f1d
--- /dev/null
@@ -0,0 +1,6 @@
+BEGIN;
+
+ALTER TABLE booking.reservation
+    ADD COLUMN note TEXT;
+
+COMMIT;
index b694d6b..4250fda 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 53cf23f..0dace42 100644 (file)
@@ -87,6 +87,7 @@
         <li><a href ng-click="add_copies_to_bucket()">[% l('Add Items to Bucket') %]</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 c0ac0c2..a462967 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 5ebbe0e..3235fd4 100644 (file)
@@ -214,17 +214,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 f0ee94d..700668a 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 2988d95..6061df0 100644 (file)
@@ -884,75 +884,10 @@ function($scope , $routeParams , $location , $window , $q , egCore , egHolds , e
         });
     }
 
-    $scope.book_copies_now = function() {
-        var copies_by_record = {};
-        var record_list = [];
-        angular.forEach(
-            $scope.holdingsGridControls.selectedItems(),
-            function (item) {
-                var record_id = item['call_number.record.id'];
-                if (typeof copies_by_record[ record_id ] == 'undefined') {
-                    copies_by_record[ record_id ] = [];
-                    record_list.push( record_id );
-                }
-                copies_by_record[ record_id ].push(item.id);
-            }
-        );
-
-        var promises = [];
-        var combined_brt = [];
-        var combined_brsrc = [];
-        angular.forEach(record_list, function(record_id) {
-            promises.push(
-                egCore.net.request(
-                    'open-ils.booking',
-                    'open-ils.booking.resources.create_from_copies',
-                    egCore.auth.token(),
-                    copies_by_record[record_id]
-                ).then(function(results) {
-                    if (results && results['brt']) {
-                        combined_brt = combined_brt.concat(results['brt']);
-                    }
-                    if (results && results['brsrc']) {
-                        combined_brsrc = combined_brsrc.concat(results['brsrc']);
-                    }
-                })
-            );
-        });
-
-        $q.all(promises).then(function() {
-            if (combined_brt.length > 0 || combined_brsrc.length > 0) {
-                $uibModal.open({
-                    template: '<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;
@@ -1022,6 +957,13 @@ function($scope , $routeParams , $location , $window , $q , egCore , egHolds , e
         });
     }
 
+    $scope.manage_reservations = function() {
+        var item = $scope.holdingsGridControls.selectedItems()[0];
+        if (item)
+            location.href = "/eg2/staff/booking/manage_reservations/by_resource/" + item.barcode;
+    }
+
+
     $scope.view_place_orders = function() {
         if (!$scope.record_id) return;
         var url = egCore.env.basePath + 'acq/legacy/lineitem/related/' + $scope.record_id + '?target=bib';
index fc3e823..6afa305 100644 (file)
@@ -94,10 +94,7 @@ function($scope , $q , $window , $location , $timeout , egCore , egNet , egGridD
     }
 
     $scope.book_copies_now = function() {
-        itemSvc.book_copies_now([{
-            id : $scope.args.copyId,
-            'call_number.record.id' : $scope.args.recordId
-        }]);
+        itemSvc.book_copies_now([$scope.args.copyBarcode]);
     }
 
     $scope.findAcquisition = function() {
@@ -135,6 +132,10 @@ function($scope , $q , $window , $location , $timeout , egCore , egNet , egGridD
         });
     }
 
+    $scope.manage_reservations = function() {
+        itemSvc.manage_reservations([$scope.args.copyBarcode]);
+    }
+
     $scope.requestItems = function() {
         itemSvc.requestItems([$scope.args.copyId]);
     }
@@ -481,7 +482,15 @@ function($scope , $q , $window , $location , $timeout , egCore , egNet , egGridD
     }
 
     $scope.book_copies_now = function() {
-        itemSvc.book_copies_now(copyGrid.selectedItems());
+        var item = copyGrid.selectedItems()[0];
+        if (item)
+            itemSvc.book_copies_now(item.barcode);
+    }
+
+    $scope.manage_reservations = function() {
+        var item = copyGrid.selectedItems()[0];
+        if (item)
+            itemSvc.manage_reservations(item.barcode);
     }
 
     $scope.requestItems = function() {
index c39f038..598658e 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) {
diff --git a/docs/RELEASE_NOTES_NEXT/Circulation/booking-refresh.adoc b/docs/RELEASE_NOTES_NEXT/Circulation/booking-refresh.adoc
new file mode 100644 (file)
index 0000000..5e39e8d
--- /dev/null
@@ -0,0 +1,32 @@
+Booking Module Refresh
+^^^^^^^^^^^^^^^^^^^^^^
+
+The Booking module has been redesigned, with many of its interfaces being
+redesigned in Angular.
+
+This adds a new screen called "Manage Reservations", where staff can check details about
+all outstanding reservations, including those that have been recently placed, captured,
+picked up, or recently returned.
+
+On many screens within the new booking module, staff are able to edit reservations.  Previously,
+they would have needed to cancel and recreate those reservations with the new data.
+
+There is a new notes field attached to reservations, where staff can leave notes about the
+reservation.  One use case is to alert staff that a particular resource is being stored in
+an unfamiliar location.  This field is visible on all screens within the booking module.
+
+The Create Reservations UI is completely re-designed, and now includes a calendar-like view
+on which staff can view existing reservations and availability.
+
+Upgrade considerations
+++++++++++++++++++++++
+
+The Booking Module Refresh requires some new dependencies for the Angular
+client.  To install these, you will have to run the following commands:
+
+[source,bash]
+----
+cd $EVERGREEN_ROOT/Open-ILS/src/eg2/
+npm install
+----
+
index 18a62ae..917d7c1 100644 (file)
@@ -4,6 +4,11 @@ Booking Module
 Creating a Booking Reservation
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
+[NOTE]
+The "Create a booking reservation" screen uses your library's timezone.  If you create a reservation at a library
+in a different timezone, Evergreen will alert you and provide the time in both your timezone and the other library's
+timezone.
+
 Only staff members may create reservations. A reservation can be started from a patron record, or a booking resource. To reserve catalogued items, you may start from searching the catalogue, if you do not know the booking item's barcode.
 
 To create a reservation from a patron record