LP1816475: Booking module refresh
authorJane Sandberg <sandbej@linnbenton.edu>
Mon, 25 Mar 2019 20:48:05 +0000 (20:48 +0000)
committerJane Sandberg <sandbej@linnbenton.edu>
Tue, 26 Mar 2019 19:43:42 +0000 (12:43 -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
* Adds a note field to booking.reservation
* Adds a datetime-select widget to Angular
* Adds a daterange-select widget to Angular
* 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
* Allows the grid to disable saving
* Adds a read-only display of au to the fm-editor

Signed-off-by: Jane Sandberg <sandbej@linnbenton.edu>
55 files changed:
Open-ILS/examples/fm_IDL.xml
Open-ILS/src/eg2/package.json
Open-ILS/src/eg2/src/app/core/format.service.ts
Open-ILS/src/eg2/src/app/share/date-select/date-select.component.html
Open-ILS/src/eg2/src/app/share/daterange-select/daterange-select.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/daterange-select/daterange-select.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/datetime-select/datetime-select.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/datetime-select/datetime-select.component.ts [new file with mode: 0644]
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-toolbar.component.ts
Open-ILS/src/eg2/src/app/share/grid/grid.component.html
Open-ILS/src/eg2/src/app/share/grid/grid.component.ts
Open-ILS/src/eg2/src/app/share/grid/grid.ts
Open-ILS/src/eg2/src/app/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/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/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/common.module.ts
Open-ILS/src/eg2/src/app/staff/nav.component.html
Open-ILS/src/eg2/src/app/staff/routing.module.ts
Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html
Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts
Open-ILS/src/eg2/src/app/staff/share/patron.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/staff-banner.component.ts
Open-ILS/src/eg2/src/styles.css
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]

index 1f51073..f57c5ec 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 0c57e46..3bbb19c 100644 (file)
@@ -29,6 +29,8 @@
     "bootstrap-css-only": "^4.2.1",
     "core-js": "^2.6.3",
     "file-saver": "^2.0.0",
+    "moment": "2.24.0",
+    "moment-timezone": "0.5.23",
     "ngx-cookie": "^4.1.2",
     "rxjs": "^6.4.0",
     "zone.js": "^0.8.29"
index e788cd0..3bbef69 100644 (file)
@@ -2,6 +2,7 @@ import {Injectable} from '@angular/core';
 import {DatePipe, CurrencyPipe} from '@angular/common';
 import {IdlService, IdlObject} from '@eg/core/idl.service';
 import {OrgService} from '@eg/core/org.service';
+import * as Moment from 'moment-timezone';
 
 /**
  * Format IDL vield values for display.
@@ -16,6 +17,7 @@ export interface FormatParams {
     datatype?: string;
     orgField?: string; // 'shortname' || 'name'
     datePlusTime?: boolean;
+    timezoneContextOrg?: number;
 }
 
 @Injectable({providedIn: 'root'})
@@ -107,12 +109,15 @@ export class FormatService {
                 return org ? org[orgField]() : '';
 
             case 'timestamp':
-                const date = new Date(value);
-                let fmt = this.dateFormat || 'shortDate';
-                if (params.datePlusTime) {
-                    fmt = this.dateTimeFormat || 'short';
+                const date = Moment(value).tz(this.wsOrgTimezone);
+                if (date) {
+                    let fmt = this.dateFormat || 'shortDate';
+                    if (params.datePlusTime) {
+                        fmt = this.dateTimeFormat || 'short';
+                    }
+                    return this.datePipe.transform(date.toISOString(true), fmt, date.format('ZZ'));
                 }
-                return this.datePipe.transform(date, fmt);
+                return value;
 
             case 'money':
                 return this.currencyPipe.transform(value);
index 575bbde..07a386c 100644 (file)
@@ -1,7 +1,7 @@
 
 <div class="input-group">
-  <input 
-    class="form-control" 
+  <input
+    class="form-control"
     ngbDatepicker
     #datePicker="ngbDatepicker"
     [attr.id]="domId.length ? domId : null"
@@ -15,7 +15,7 @@
   <div class="input-group-append">
     <button class="btn btn-outline-secondary" [disabled]="_disabled"
       (click)="datePicker.toggle()" type="button">
-      <span title="Select Date" i18n-title                       
+      <span title="Select Date" i18n-title
         class="material-icons mat-icon-in-button">calendar_today</span>
     </button>
   </div>
diff --git a/Open-ILS/src/eg2/src/app/share/daterange-select/daterange-select.component.html b/Open-ILS/src/eg2/src/app/share/daterange-select/daterange-select.component.html
new file mode 100644 (file)
index 0000000..8dad085
--- /dev/null
@@ -0,0 +1,18 @@
+<ngb-datepicker #dp (select)="onDateSelection($event)" [displayMonths]="2" [dayTemplate]="t" outsideDays="hidden">
+</ngb-datepicker>
+
+<ng-template #t let-date let-focused="focused">
+  <span class="custom-day"
+        [class.focused]="focused"
+        [class.range]="isRange(date)"
+        [class.faded]="isHovered(date) || isInside(date)"
+        (mouseenter)="hoveredDate = date"
+        (mouseleave)="hoveredDate = null">
+    {{ date.day }}
+  </span>
+</ng-template>
+
+<hr>
+
+<pre>From: {{ fromDate | json }} </pre>
+<pre>To: {{ toDate | json }} </pre>
diff --git a/Open-ILS/src/eg2/src/app/share/daterange-select/daterange-select.component.ts b/Open-ILS/src/eg2/src/app/share/daterange-select/daterange-select.component.ts
new file mode 100644 (file)
index 0000000..72b297c
--- /dev/null
@@ -0,0 +1,61 @@
+import {Component} from '@angular/core';
+import {NgbDate, NgbCalendar} from '@ng-bootstrap/ng-bootstrap';
+
+@Component({
+  selector: 'eg-daterange-select',
+  templateUrl: './daterange-select.component.html',
+  styles: [`
+    .custom-day {
+      text-align: center;
+      padding: 0.185rem 0.25rem;
+      display: inline-block;
+      height: 2rem;
+      width: 2rem;
+    }
+    .custom-day.focused {
+      background-color: #e6e6e6;
+    }
+    .custom-day.range, .custom-day:hover {
+      background-color: rgb(2, 117, 216);
+      color: white;
+    }
+    .custom-day.faded {
+      background-color: rgba(2, 117, 216, 0.5);
+    }
+  `]
+})
+export class DateRangeSelectComponent {
+
+  hoveredDate: NgbDate;
+
+  fromDate: NgbDate;
+  toDate: NgbDate;
+
+  constructor(calendar: NgbCalendar) {
+    this.fromDate = calendar.getToday();
+    this.toDate = calendar.getNext(calendar.getToday(), 'd', 10);
+  }
+
+  onDateSelection(date: NgbDate) {
+    if (!this.fromDate && !this.toDate) {
+      this.fromDate = date;
+    } else if (this.fromDate && !this.toDate && date.after(this.fromDate)) {
+      this.toDate = date;
+    } else {
+      this.toDate = null;
+      this.fromDate = date;
+    }
+  }
+
+  isHovered(date: NgbDate) {
+    return this.fromDate && !this.toDate && this.hoveredDate && date.after(this.fromDate) && date.before(this.hoveredDate);
+  }
+
+  isInside(date: NgbDate) {
+    return date.after(this.fromDate) && date.before(this.toDate);
+  }
+
+  isRange(date: NgbDate) {
+    return date.equals(this.fromDate) || date.equals(this.toDate) || this.isInside(date) || this.isHovered(date);
+  }
+}
diff --git a/Open-ILS/src/eg2/src/app/share/datetime-select/datetime-select.component.html b/Open-ILS/src/eg2/src/app/share/datetime-select/datetime-select.component.html
new file mode 100644 (file)
index 0000000..96b17e9
--- /dev/null
@@ -0,0 +1,49 @@
+<!-- TODO:
+* Have this accept ISO input (i.e. from the database)
+* Have this accept @Input params of timezone (default to OU), disable
+* turn this into a popover that pops over the input: https://ng-bootstrap.github.io/#/components/popover/examples
+* have this input and output the five and noble stuff in the correct format (currently you can't just type ssomething into the box and have it show up on the calendar, but it does show up in the model - cool)
+
+-->
+<div class="input-group">
+  <input type="datetime"
+    [attr.id]="domId.length ? domId : null" 
+    name="{{fieldName}}"
+    class="form-control"
+    [placeholder]="initialIso"
+    [(ngModel)]="dateTime"
+    (blur)="blurred($event)"
+    #dtPicker="ngbPopover"
+    [ngbPopover]="dt"
+    placement="bottom"
+    [autoClose]="'outside'"
+    popoverTitle="Select date and time"
+    i18n-popoverTitle
+    [required]="required">
+  <div class="input-group-btn">
+    <button class="btn btn-primary" (click)="dtPicker.toggle()">
+      <span title="Select Date" i18n-title
+        class="material-icons mat-icon-in-button">calendar_today</span>
+    </button>
+  </div>
+</div>
+
+<ng-template #dt>
+  <ngb-datepicker
+    [(ngModel)]="dateModel"
+    (ngModelChange)="modelChanged($event)"
+    [footerTemplate]="time">
+  </ngb-datepicker>
+</ng-template>
+<ng-template #time>
+  <ngb-timepicker name="time"
+    [(ngModel)]="timeModel" [meridian]="true"
+    (ngModelChange)="modelChanged()"
+    [spinners]="true"
+    [hourStep]="1"
+    [minuteStep]="minuteStep || 15" >
+  </ngb-timepicker>
+  <span *ngIf="showTZ" class="badge badge-info">America/Los_Angeles</span>
+  <button i18n class="btn btn-success" (click)="dtPicker.close()">Choose time</button>
+</ng-template>
+
diff --git a/Open-ILS/src/eg2/src/app/share/datetime-select/datetime-select.component.ts b/Open-ILS/src/eg2/src/app/share/datetime-select/datetime-select.component.ts
new file mode 100644 (file)
index 0000000..c11ecfa
--- /dev/null
@@ -0,0 +1,82 @@
+import { Component, Input, Output, EventEmitter, ViewChild, OnInit } from '@angular/core';
+import { ControlValueAccessor } from '@angular/forms';
+
+import * as Moment from 'moment-timezone';
+
+import { NgbTimeStruct, NgbDateStruct } from '@ng-bootstrap/ng-bootstrap';
+
+@Component({
+    selector: 'eg-datetime-select',
+    templateUrl: './datetime-select.component.html'
+})
+export class DateTimeSelectComponent implements OnInit {
+    @Input() formModel: any;    // External model bound back to parent
+    @Input() domId = '';
+    @Input() fieldName: string;
+    @Input() required: boolean;
+    @Input() minuteStep: number;
+    @Input() showTZ = true;
+
+    @Input() initialIso: string;
+
+    @Output() formModelChange = new EventEmitter();
+
+    dateTime: any; // Used internally on internal input
+    timeModel: NgbTimeStruct;
+    dateModel: NgbDateStruct;
+
+    constructor() {
+    }
+
+    ngOnInit() {
+        const start = this.formModel ? Moment(this.formModel) : Moment();
+        this.setDefaultDate(start);
+        this.setDefaultTime(start);
+
+        if (this.formModel) {
+            this.modelChanged(null);
+        }
+    }
+
+    setDefaultDate(start: any) {
+        this.dateModel = { year: start.year(), month: start.month() + 1, day: start.date() };
+    }
+
+    setDefaultTime(start: any) {
+        const remainder = 5 - start.minute() % 5,
+            final = Moment(start).add(remainder, 'minutes');
+
+        // Seed time model with current, rounding up to nearest 5 minutes (does roll hour over if needed)
+        this.timeModel = { hour: final.hour(), minute: final.minute(), second: 0 };
+    }
+
+
+    blurred(event) {
+        this.modelChanged(event);
+    }
+
+    modelChanged(event) {
+        let newDate: any;
+
+        if (event) {
+            newDate = Moment(new Date(this.dateTime));
+        } else {
+            newDate = new Date(this.dateModel.year, this.dateModel.month, this.dateModel.day,
+                this.timeModel.hour, this.timeModel.minute, this.timeModel.second);
+        }
+
+        if (newDate && !isNaN(newDate)) {
+            console.log('newDate');
+            // Set component view value
+            this.dateTime = Moment(newDate).format('MM/D/YYYY h:mm A');
+            // Update form passed in view value
+            this.formModelChange.emit(Moment(newDate));
+        }
+    }
+
+    isDisabled(date: NgbDateStruct, current: { month: number }) {
+        return date.month !== current.month;
+    }
+
+}
+
index aad65d1..31a3cd7 100644 (file)
                 (ngModelChange)="record[field.name]($event)"/>
             </ng-container>
   
+           <ng-container *ngSwitchCase="'readonly-au'">
+              <a href="/eg/staff/circ/patron/{{field.linkedValues[0].id}}/checkout" target="_blank">{{field.linkedValues[0]['name']}}
+              <span class="material-icons" i18n-title title="Open user record in new tab">open_in_new</span></a>
+            </ng-container>
+
             <ng-container *ngSwitchCase="'list'">
               <eg-combobox
                 id="{{idPrefix}}-{{field.name}}" name="{{field.name}}"
index 3e41fa2..c8933a7 100644 (file)
@@ -477,6 +477,10 @@ export class FmRecordEditorComponent
                 return 'readonly-money';
             }
 
+            if ((field.datatype === 'link' || field.linkedValues) && field.class === 'au') {
+                return 'readonly-au';
+            }
+
             if (field.datatype === 'link' || field.linkedValues) {
                 return 'readonly-list';
             }
index 4cebd48..57f3afd 100644 (file)
@@ -27,6 +27,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>;
@@ -54,6 +57,7 @@ export class GridColumnComponent implements OnInit {
         col.isMultiSortable = this.multiSortable;
         col.datatype = this.datatype;
         col.datePlusTime = this.datePlusTime;
+        col.timezoneContextOrg = this.timezoneContextOrg;
         col.isAuto = false;
         this.grid.context.columnSet.add(col);
     }
index 5eaa81f..0c6ddca 100644 (file)
         <span class="ml-2" i18n>Manage Column Widths</span>
       </a>
       <a class="dropdown-item label-with-material-icon" 
+        [class.disabled]="disableSaveSettings"
         (click)="saveGridConfig()">
         <span class="material-icons">save</span>
         <span class="ml-2" i18n>Save Grid Settings</span>
index 399a4c7..1d33cd2 100644 (file)
@@ -16,6 +16,7 @@ export class GridToolbarComponent implements OnInit {
     @Input() gridContext: GridContext;
     @Input() colWidthConfig: GridColumnWidthComponent;
     @Input() gridPrinter: GridPrintComponent;
+    @Input() disableSaveSettings = false;
 
     csvExportInProgress: boolean;
     csvExportUrl: SafeUrl;
index a98e17a..ad228b0 100644 (file)
@@ -4,7 +4,8 @@
   <eg-grid-toolbar
     [gridContext]="context" 
     [gridPrinter]="gridPrinter"
-    [colWidthConfig]="colWidthConfig">
+    [colWidthConfig]="colWidthConfig"
+    [disableSaveSettings]="disableSaveSettings">
   </eg-grid-toolbar>
 
   <eg-grid-header [context]="context"></eg-grid-header>
index 66686ef..d4f6a7f 100644 (file)
@@ -94,6 +94,10 @@ export class GridComponent implements OnInit, AfterViewInit, OnDestroy {
     // field on the "aout" class.
     @Input() showLinkSelectors: boolean;
 
+    // If true, users won't be able to save any changes they make
+    // to grid settings
+    @Input() disableSaveSettings = false;
+
     context: GridContext;
 
     // These events are emitted from our grid-body component.
index 3743488..9cff160 100644 (file)
@@ -26,6 +26,7 @@ export class GridColumn {
     idlFieldDef: any;
     datatype: string;
     datePlusTime: boolean;
+    timezoneContextOrg: number;
     cellTemplate: TemplateRef<any>;
     cellContext: any;
     isIndex: boolean;
@@ -640,7 +641,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..079a190
--- /dev/null
@@ -0,0 +1,32 @@
+import {NgModule} from '@angular/core';
+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 {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,
+    ],
+    providers: [PatronService],
+    declarations: [
+        CreateReservationComponent,
+        ManageReservationsComponent,
+        NoTimezoneSetComponent,
+        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..ec53116
--- /dev/null
@@ -0,0 +1,135 @@
+<h2>Resources</h2>
+{{resources | json}}
+<h2>Grid</h2>
+{{scheduleSource.data | json}}
+<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">
+    <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)="multiday = false" class="btn btn-outline-primary" ngbDropdownItem><span class="material-icons">event</span><span i18n>Single day reservation</span></button>
+          <button (click)="multiday = true" 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"></eg-date-select>
+      <eg-daterange-select *ngIf="multiday" #dateRangeLimiter></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">
+    </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 domId="ideal-resource-type" (onChange)="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)]="startTime"></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)]="endTime"></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)="granularity = $event.entryId">
+            <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)="showNewDialog($event)"
+  [dataSource]="scheduleSource"
+  [rowFlairIsEnabled]="true"
+  [rowFlairCallback]="resourceAvailabilityIcon"
+  disableSaveSettings="true"
+  [cellClassCallback]="isBooked">
+  <eg-grid-column path="time" [index]="true" ></eg-grid-column>
+  <eg-grid-column *ngFor="let resource of resources" path="{{resource.barcode()}}"></eg-grid-column>
+</eg-grid>
+
+<eg-fm-record-editor #newDialog
+  idlClass="bresv"
+  hiddenFields="id,xact_start,request_time,capture_time,pickup_time,return_time,capture_staff,xact_finish,cancel_time,booking_interval,target_resource,unrecovered,request_library,fine_interval,fine_amount,max_fine">
+</eg-fm-record-editor>
+
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..3bbaab9
--- /dev/null
@@ -0,0 +1,134 @@
+import { Component, Input, OnInit, ViewChild } from '@angular/core';
+import { NgbTimeStruct } from '@ng-bootstrap/ng-bootstrap';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {DateSelectComponent} from '@eg/share/date-select/date-select.component';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {GridDataSource,  GridRowFlairEntry} from '@eg/share/grid/grid';
+import {IdlObject} from '@eg/core/idl.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+
+
+@Component({
+    templateUrl: './create-reservation.component.html'
+})
+
+export class CreateReservationComponent implements OnInit {
+
+    advancedCollapsed = true;
+    attributes: IdlObject[] = [];
+    multiday = false;
+    isBooked: (col: any, row: any) => string;
+    resourceAvailabilityIcon: (row: any) => GridRowFlairEntry;
+    fetchData: (limiter: 'resource' | 'type', id: number) => void;
+
+    startTime: NgbTimeStruct = {hour: 9, minute: 0, second: 0};
+    endTime: NgbTimeStruct = {hour: 17, minute: 0, second: 0};
+    granularity: 15 | 30 | 60 = 30;
+
+    scheduleSource: GridDataSource = new GridDataSource();
+
+    handleResourceTypeChange: ($event: ComboboxEntry) => void;
+
+    resources: IdlObject[] = [];
+    limitByAttr: (attributeId: number, $event: ComboboxEntry) => void;
+
+    @ViewChild('dateLimiter') dateLimiter: DateSelectComponent;
+    @ViewChild('newDialog') newDialog: FmRecordEditorComponent;
+    @ViewChild('scheduleGrid') scheduleGrid: GridComponent;
+
+    constructor(
+        private pcrud: PcrudService,
+        private toast: ToastService,
+    ) {
+    }
+
+
+    ngOnInit() {
+        this.dateLimiter.initialDate = new Date();
+
+        this.fetchData = (limiter: 'resource' | 'type', id: number) => {
+            this.resources = []; 
+            let where = {};
+            if ('type' === limiter) {
+                where = {type: id};
+            } else if ('resource' === limiter) {
+                where = {id: id};
+            }
+           this.pcrud.search('brsrc', where, {
+                order_by: 'barcode ASC',
+                flesh: 1,
+                flesh_fields: {'brsrc': ['curr_rsrcs']},
+                select: {'curr_rsrcs': {'end_time': {'<' : '2019-04-01'}}}
+            }).subscribe(
+                r => { this.resources.push(r); });
+
+            this.scheduleSource.data = [];
+            let current_time = this.startTime;
+            while (this._firstTimeIsSmaller(current_time, this.endTime)) {
+                this.scheduleSource.data.push({
+                    'time': current_time.hour + ':' + current_time.minute,
+                    'ROOM1231': 'Professor Pickle'
+                });
+                if ((current_time.minute + this.granularity) >= 60) {
+                    current_time.hour = current_time.hour + 1;
+                }
+                current_time.minute = (current_time.minute + this.granularity) % 60;
+            }
+        }
+
+        this.isBooked = (row: any, col: any) => {
+            if ((col.name !== 'time') && (row[col.name])) {
+                return 'bg-warning';
+            }
+        };
+        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;
+        };
+
+        this.limitByAttr = (attributeId: number, $event: ComboboxEntry) => {
+            console.log('LIMIT');
+            console.log('id: ' + attributeId);
+            console.log('event: ' + JSON.stringify($event));
+        };
+
+        this.handleResourceTypeChange = ($event: ComboboxEntry) => {
+            // TODO: unset resource barcode
+            this.attributes = [];
+            if ($event.id) {
+                this.pcrud.search('bra', {resource_type : $event.id}, {
+                    order_by: 'name ASC',
+                    flesh: 1,
+                    flesh_fields: {'bra' : ['valid_values']}
+                }).subscribe(
+                    a => { this.attributes.push(a); });
+                this.fetchData('type', $event.id);
+            }
+        };
+
+    }
+    showNewDialog(idlThing: IdlObject) {
+        return this.newDialog.open({size: 'lg'}).then(
+            ok => {
+                this.toast.success('Reservation successfully created'); // TODO: needs i18n, pluralization
+                this.scheduleGrid.reload();
+            },
+            err => {}
+        );
+    }
+    private _firstTimeIsSmaller(first: NgbTimeStruct, second: NgbTimeStruct) {
+        if (first.hour < second.hour) { return true; }
+        if ((first.hour === second.hour) && (first.minute < second.minute)) { return true; }
+        return false;
+    }
+}
+
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..b8e4e5f
--- /dev/null
@@ -0,0 +1,88 @@
+<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">
+    <div class="input-group">
+      <div class="input-group-prepend">
+        <label for="pickup-library" i18n class="input-group-text">Pickup library</label>
+      </div>
+      <eg-org-select domId="pickup-library">
+      </eg-org-select>
+    </div>
+  </div>
+  <div class="col-sm-3">
+    <div class="form-check">
+      <input type="checkbox" class="form-check-input" id="include-ancestors">
+      <label class="form-check-label" for="include-ancestors" i18n>+ Ancestors</label>
+    </div>
+    <div class="form-check">
+      <input type="checkbox" class="form-check-input" id="include-descendants">
+      <label class="form-check-label" for="include-descendants" i18n>+ Descendants</label>
+    </div>
+  </div>
+  <div class="col-sm-6">
+    <div class="card">
+      <h2 class="card-header" i18n>Filter reservations</h2>
+      <ngb-tabset #filters [activeId]="selectedFilter" (tabChange)="setStickyFilter()" 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 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 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" (onChange)="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" 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..5b11562
--- /dev/null
@@ -0,0 +1,119 @@
+import {Component, OnInit, ViewChild} from '@angular/core';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {PatronService} from '@eg/staff/share/patron.service';
+import {ReservationsGridComponent} from './reservations-grid.component';
+import {ServerStoreService} from '@eg/core/server-store.service';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+
+@Component({
+    selector: 'eg-manage-reservations',
+    templateUrl: './manage-reservations.component.html',
+})
+export class ManageReservationsComponent implements OnInit {
+
+    patronBarcode: string;
+    patronId: number;
+    resourceBarcode: string;
+    resourceId: number;
+    resourceTypeId: number;
+    selectedFilter: 'patron' | 'resource' | 'type' = 'patron';
+
+    @ViewChild('reservationsGrid') reservationsGrid: ReservationsGridComponent;
+
+    filterByCurrentPatronBarcode: () => void;
+    filterByCurrentResourceBarcode: () => void;
+    filterByResourceType: (selected: ComboboxEntry) => void;
+    removeFilters: () => void;
+    chooseAppropriateFilter: () => void;
+    setStickyFilter: () => void;
+
+    constructor(
+        private route: ActivatedRoute,
+        private router: Router,
+        private pcrud: PcrudService,
+        private patron: PatronService,
+        private store: ServerStoreService
+    ) {
+        // This is in the constructor, because we need it first thing in ngOnInit
+        this.chooseAppropriateFilter = () => {
+            if (this.resourceBarcode) {
+                this.selectedFilter = 'resource';
+            } else if (this.resourceTypeId) {
+                this.selectedFilter = 'type';
+            } else if (!(this.patronId)) {
+                this.store.getItem('eg.booking.manage.filter').then(filter => {
+                    if (filter) { this.selectedFilter = filter; }
+                });
+            }
+        };
+
+    }
+
+    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');
+            this.chooseAppropriateFilter();
+        });
+
+        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(); },
+                (err) => { console.log(err); }
+            );
+        }
+
+        if (this.resourceBarcode) {
+            this.pcrud.search('brsrc',
+            {'barcode' : this.resourceBarcode}, {'limit': 1, 'select': ['id']})
+            .subscribe((res) => {
+                this.resourceId = res.id();
+                this.reservationsGrid.reloadGrid();
+            });
+        }
+
+        this.setStickyFilter = () => {
+            this.store.setItem('eg.booking.manage.filter', this.selectedFilter);
+        };
+
+        this.removeFilters = () => {
+            this.router.navigate(['/staff', 'booking', 'manage_reservations']);
+        };
+
+        this.filterByCurrentPatronBarcode = () => {
+            if (this.patronBarcode) {
+                this.patron.bcSearch(this.patronBarcode).subscribe(
+                    (response) => {
+                        this.router.navigate(['/staff', 'booking', 'manage_reservations', 'by_patron', response.id]);
+                });
+            } 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..0abaad5
--- /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/pickup.component.html b/Open-ILS/src/eg2/src/app/staff/booking/pickup.component.html
new file mode 100644 (file)
index 0000000..2fd4ce6
--- /dev/null
@@ -0,0 +1,37 @@
+<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-grid #readyGrid [dataSource]="readySource"
+    (onRowActivate)="pickup($event)"
+    [sortable]="true" persistKey="booking.pull_list" >
+    <eg-grid-toolbar-action label="Pick up" i18n-label [action]="pickupSelected" [disableOnRows]="noSelectedRows"></eg-grid-toolbar-action>
+    <eg-grid-column name="id" [hidden]="true" [index]="true" i18n-label label="ID" path="id"></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>
+
+  <h2 class="text-center" 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..93246ab
--- /dev/null
@@ -0,0 +1,131 @@
+import { Component, Input, OnInit, ViewChild } from '@angular/core';
+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 {Observable} from 'rxjs';
+import {tap} from 'rxjs/operators';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {ReservationsGridComponent} from './reservations-grid.component';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {ServerStoreService} from '@eg/core/server-store.service';
+
+
+@Component({
+  templateUrl: './pickup.component.html'
+})
+
+export class PickupComponent implements OnInit {
+    patronBarcode: string;
+    patronId: number;
+    retrievePatron: () => void;
+
+    @ViewChild('readyGrid') readyGrid: GridComponent;
+    @ViewChild('pickedUpGrid') pickedUpGrid: ReservationsGridComponent;
+
+    public readySource: GridDataSource;
+    noSelectedRows: (rows: IdlObject[]) => boolean;
+    pickupSelected: (reservations: IdlObject[]) => void;
+    pickup: (reservation: IdlObject) => Observable<any>;
+
+    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
+    ) {
+    }
+
+
+    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(); },
+                (err) => { console.log(err); }
+            );
+        });
+
+        this.readySource = new GridDataSource();
+        this.readySource.getRows = (pager: Pager, sort: any[]) => {
+            const orderBy: any = {};
+            let where = {
+                'usr' : this.patronId,
+                'pickup_time' : null,
+                'start_time' : {'!=': null},
+                'cancel_time' : null
+            };
+            if (this.onlyShowCaptured) {
+                where['capture_time'] = {'!=': null};
+            }
+
+            return this.pcrud.search('bresv', where,  {
+                order_by: orderBy,
+                limit: pager.limit,
+                offset: pager.offset,
+                flesh: 1,
+                flesh_fields: {'bresv' : [
+                    'usr', 'capture_staff', 'target_resource', 'target_resource_type', 'current_resource', 'request_lib', 'pickup_lib'
+                ] }
+            });
+
+        };
+        this.retrievePatron = () => {
+            if (this.patronBarcode) {
+                this.patron.bcSearch(this.patronBarcode).subscribe(
+                    resp => { this.router.navigate(['/staff', 'booking', 'pickup', 'by_patron', resp[0].id]); }
+                );
+            }
+        };
+        this.noSelectedRows = (rows: IdlObject[]) => (rows.length === 0);
+
+        this.pickupSelected = (reservations: IdlObject[]) => {
+            const pickupOne = (thing: IdlObject) => {
+                if (!thing) { return; }
+                this.pickup(thing).subscribe(
+                    () => pickupOne(reservations.shift()));
+            };
+            pickupOne(reservations.shift());
+        };
+
+        this.pickup = (reservation: IdlObject) => {
+            return this.net.request(
+               'open-ils.circ',
+               'open-ils.circ.reservation.pickup',
+               this.auth.token(),
+               {'patron_barcode': this.patronBarcode, 'reservation': reservation})
+               .pipe(tap(
+                   (success) => {
+                       this.readyGrid.reload();
+                       this.pickedUpGrid.reloadGrid(); },
+                   (error) => { console.log(error); }
+               ));
+        };
+        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.reload();
+            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..af326d9
--- /dev/null
@@ -0,0 +1,36 @@
+{{dataSource | json }}
+<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>
+
+<div 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()">
+      </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" class="form-control" id="days-hence" [(ngModel)]="daysHence" (ngModelChange)="fill_grid()">
+    </div>
+  </div>
+</div>
+<eg-grid [dataSource]="dataSource"
+  [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 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..11a13a2
--- /dev/null
@@ -0,0 +1,41 @@
+import { Component, Input, OnInit } from '@angular/core';
+import { GridDataSource } from '@eg/share/grid/grid';
+import { NetRequest, NetService } from '@eg/core/net.service';
+import { Pager } from '@eg/share/util/pager';
+import { AuthService } from '@eg/core/auth.service';
+
+@Component({
+  selector: 'eg-pull-list',
+  templateUrl: './pull-list.component.html'
+})
+
+export class PullListComponent implements OnInit {
+  @Input( ) daysHence: number;
+
+  public dataSource: GridDataSource;
+  private auth: AuthService;
+
+  constructor(
+    private net: NetService,
+    egAuth: AuthService
+  ) {
+    this.auth = egAuth;
+    this.daysHence = 5;
+  }
+
+  fill_grid () {
+    this.net.request(
+      'open-ils.booking', 'open-ils.booking.reservations.get_pull_list',
+      this.auth.token(), null,
+      (86400 * this.daysHence), // convert seconds to days
+      4
+    ).subscribe( data => {
+      this.dataSource.data = data;
+    });
+  }
+
+  ngOnInit() {
+    this.dataSource = new GridDataSource();
+    this.fill_grid();
+  }
+}
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..1a26a73
--- /dev/null
@@ -0,0 +1,48 @@
+<eg-grid #grid [dataSource]="gridSource"
+  (onRowActivate)="showEditDialog($event)"
+  [sortable]="true" persistKey="booking.{{persistSuffix}}" >
+  <eg-grid-toolbar-action label="Edit Selected" i18n-label [action]="editSelected" [disableOnRows]="noSelectedRows"></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="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-column name="id" [hidden]="true" [index]="true" i18n-label label="ID" path="id"></eg-grid-column>
+  <eg-grid-column label="Patron username" 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="Request library" i18n-label [hidden]="true" path="request_lib.name"></eg-grid-column>
+  <eg-grid-column label="Pickup library" i18n-label [hidden]="true" path="pickup_lib.name"></eg-grid-column>
+
+</eg-grid>
+
+<eg-fm-record-editor #editDialog
+  idlClass="bresv"
+  hiddenFields="xact_finish,cancel_time,booking_interval"
+  readonlyFields="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">
+</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..b6b11f7
--- /dev/null
@@ -0,0 +1,208 @@
+import {Component, Input, 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 {PatronService} from '@eg/staff/share/patron.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() status: 'pickedUp' | 'returnedToday';
+    @Input() persistSuffix: string;
+
+    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;
+    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;
+
+    reloadGrid: () => void;
+
+    noSelectedRows: (rows: IdlObject[]) => boolean;
+    notOnePatronSelected: (rows: IdlObject[]) => boolean;
+    notOneResourceSelected: (rows: IdlObject[]) => boolean;
+    cancelNotAppropriate: (rows: IdlObject[]) => boolean;
+    returnNotAppropriate: (rows: IdlObject[]) => boolean;
+
+    constructor(
+        private route: ActivatedRoute,
+        private router: Router,
+        private toast: ToastService,
+        private pcrud: PcrudService,
+        private auth: AuthService,
+        private format: FormatService,
+        private net: NetService,
+        private patronService: PatronService
+    ) {
+
+    }
+
+    ngOnInit() {
+        if (!(this.format.wsOrgTimezone)) {
+            this.noTimezoneSetDialog.open();
+        }
+
+        this.gridSource = new GridDataSource();
+
+        this.gridSource.getRows = (pager: Pager, sort: any[]) => {
+            const orderBy: any = {};
+            let 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.status) {
+                if ('pickedUp' === 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'] }
+            });
+        };
+
+        this.editDialog.mode = 'update';
+        this.editSelected = (idlThings: IdlObject[]) => {
+            const editOneThing = (thing: IdlObject) => {
+                if (!thing) { return; }
+                this.showEditDialog(thing).then(
+                    () => editOneThing(idlThings.shift()));
+            };
+           editOneThing(idlThings.shift()); };
+
+        this.cancelSelected = (reservations: IdlObject[]) => {
+            const reservationIds = reservations.map(reservation => reservation.id());
+            this.numRowsSelected = reservationIds.length;
+            this._cancelReservationDialog.open()
+                .then(
+                    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))
+                        );
+                    },
+                    dismissed => console.log('user cancelled'));
+        };
+
+        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 => row.current_resource().id())).size !== 1);
+        this.cancelNotAppropriate = (rows: IdlObject[]) => (this.noSelectedRows(rows) || ('pickedUp' === this.status));
+       this.returnNotAppropriate = (rows: IdlObject[]) => {
+            if (this.noSelectedRows(rows)) {
+                return true;
+            } else {
+                rows.forEach(row => {
+                    if ((null == row.pickup_time()) || row.return_time()) { return true; }
+                });
+            }
+            return false;
+        }
+
+        this.reloadGrid = () => { this.grid.reload(); };
+
+        this.returnSelected = (reservations: IdlObject[]) => {
+            const returnOne = (thing: IdlObject) => {
+                if (!thing) { return; }
+                this.returnResource(thing).subscribe(
+                    () => returnOne(reservations.shift()));
+            };
+            returnOne(reservations.shift());
+        };
+
+        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.log(error); }
+               ));
+        };
+    }
+
+    showEditDialog(idlThing: IdlObject) {
+        this.editDialog.recId = idlThing.id();
+        return this.editDialog.open({size: 'lg'}).then(
+            ok => {
+                this.toast.success('Reservation successfully updated'); // TODO: needs i18n, pluralization
+                this.grid.reload();
+            },
+            err => {}
+        );
+    }
+
+    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..cf13183
--- /dev/null
@@ -0,0 +1,34 @@
+import { Component, EventEmitter, OnInit, Input, Output } from '@angular/core';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+
+@Component({
+    selector: 'eg-resource-type-combobox',
+    template: `<eg-combobox
+        [attr.id]="domId"
+        placeholder="Resource type" i18n-placeholder
+        [entries]="resourceTypes"
+        (onChange)="onChange.emit($event)"
+        [startId]="startId"></eg-combobox>`
+})
+export class ResourceTypeComboboxComponent implements OnInit {
+
+    resourceTypes: ComboboxEntry[];
+
+    @Input() domId = '';
+    @Input() startId: number;
+    @Output() onChange: EventEmitter<ComboboxEntry>;
+
+    constructor(private pcrud: PcrudService) {
+        this.onChange = 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()});
+        });
+    }
+
+}
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..1743504
--- /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>
+
+<!-- TODO: DRY This out: there has to be a good way to reuse the template -->
+<ngb-tabset (tabChange)="resetEverything()">
+  <ngb-tab title="By patron" i18n-title>
+    <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 [patron]="patronId" status="pickedUp" persistSuffix="return.patron.picked_up"></eg-reservations-grid>
+
+        <h2 class="text-center" i18n>Returned today</h2>
+        <eg-reservations-grid [patron]="patronId" status="returnedToday" persistSuffix="return.patron.returned"></eg-reservations-grid>
+      </div>
+    </ng-template>
+  </ngb-tab>
+  <ngb-tab title="By resource" i18n-title>
+    <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 [resource]="patronId" status="pickedUp" persistSuffix="return.patron.picked_up"></eg-reservations-grid>
+
+        <h2 class="text-center" i18n>Returned today</h2>
+        <eg-reservations-grid [patron]="patronId" status="returnedToday" persistSuffix="return.patron.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..86bab24
--- /dev/null
@@ -0,0 +1,67 @@
+import { Component, Input, OnInit } from '@angular/core';
+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 {Observable} from 'rxjs';
+
+
+@Component({
+  templateUrl: './return.component.html'
+})
+
+export class ReturnComponent implements OnInit {
+    resourceBarcode: string;
+    patronBarcode: string;
+    patronId: number;
+    retrievePatronByBarcode: () => void;
+    retrievePatronByResource: () => void;
+
+    noSelectedRows: (rows: IdlObject[]) => boolean;
+    resetEverything: () => void;
+
+    constructor(
+        private auth: AuthService,
+        private net: NetService,
+        private pcrud: PcrudService,
+        private patron: PatronService
+    ) {
+    }
+
+
+    ngOnInit() {
+        this.retrievePatronByBarcode = () => {
+            if (this.patronBarcode) {
+                this.patron.bcSearch(this.patronBarcode).subscribe(
+                    resp => { this.patronId = resp[0]['id']; }
+                );
+            }
+        };
+        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().usr()) {
+                        this.patronId = resp.curr_rsrcs().usr();
+                    }
+                });
+            }
+        };
+        this.noSelectedRows = (rows: IdlObject[]) => (rows.length === 0);
+
+        this.resetEverything = () => {
+            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..b50d26c
--- /dev/null
@@ -0,0 +1,39 @@
+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: '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: 'create_reservation',
+  component: CreateReservationComponent
+  }, {
+  path: 'pickup',
+    children: [
+      {path: '', component: PickupComponent},
+      {path: 'by_patron/:patron_id', component: PickupComponent},
+  ]}, {
+  path: 'pull_list',
+  component: PullListComponent
+  }, {
+  path: 'return',
+  component: ReturnComponent
+  },
+  ];
+
+@NgModule({
+  imports: [RouterModule.forChild(routes)],
+  exports: [RouterModule],
+  providers: []
+})
+
+export class BookingRoutingModule {}
index 5a83f8a..78db307 100644 (file)
@@ -16,7 +16,9 @@ import {StringComponent} from '@eg/share/string/string.component';
 import {StringService} from '@eg/share/string/string.service';
 import {TitleComponent} from '@eg/share/title/title.component';
 import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {DateRangeSelectComponent} from '@eg/share/daterange-select/daterange-select.component';
 import {DateSelectComponent} from '@eg/share/date-select/date-select.component';
+import {DateTimeSelectComponent} from '@eg/share/datetime-select/datetime-select.component';
 import {RecordBucketDialogComponent} from '@eg/staff/share/buckets/record-bucket-dialog.component';
 import {BibSummaryComponent} from '@eg/staff/share/bib-summary/bib-summary.component';
 import {TranslateComponent} from '@eg/staff/share/translate/translate.component';
@@ -39,7 +41,9 @@ import {AdminPageComponent} from '@eg/staff/share/admin-page/admin-page.componen
     TitleComponent,
     OpChangeComponent,
     FmRecordEditorComponent,
+    DateRangeSelectComponent,
     DateSelectComponent,
+    DateTimeSelectComponent,
     RecordBucketDialogComponent,
     BibSummaryComponent,
     TranslateComponent,
@@ -63,7 +67,9 @@ import {AdminPageComponent} from '@eg/staff/share/admin-page/admin-page.componen
     TitleComponent,
     OpChangeComponent,
     FmRecordEditorComponent,
+    DateRangeSelectComponent,
     DateSelectComponent,
+    DateTimeSelectComponent,
     RecordBucketDialogComponent,
     BibSummaryComponent,
     TranslateComponent,
index 8034f41..0aa6dae 100644 (file)
             <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>
             <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
   }, {
index f112aed..84e127e 100644 (file)
@@ -37,7 +37,7 @@
       [fieldOptions]="{marc_record_type:{customValues:[{id:'biblio'},{id:'serial'},{id:'authority'}]},description:{customTemplate:{template:descriptionTemplate,context:{'hello':'goodbye'}}}}"
       recordId="1" orgDefaultAllowed="owner">
   </eg-fm-record-editor>
-  <button class="btn btn-dark" (click)="fmRecordEditor.open({size:'lg'})">
+  <button class="btn btn-dark" (click)="openEditor()">
       Fm Record Editor
   </button>
 </div>
index 4ee4ebc..9b058cd 100644 (file)
@@ -14,6 +14,7 @@ import {DateSelectComponent} from '@eg/share/date-select/date-select.component';
 import {PrintService} from '@eg/share/print/print.service';
 import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
 import {FormatService} from '@eg/core/format.service';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
 
 @Component({
   templateUrl: 'sandbox.component.html'
@@ -29,6 +30,9 @@ export class SandboxComponent implements OnInit {
     @ViewChild('printTemplate')
     private printTemplate: TemplateRef<any>;
 
+    @ViewChild('fmRecordEditor')
+    private fmRecordEditor: FmRecordEditorComponent;
+
     // @ViewChild('helloStr') private helloStr: StringComponent;
 
     gridDataSource: GridDataSource = new GridDataSource();
@@ -132,6 +136,19 @@ export class SandboxComponent implements OnInit {
         });
     }
 
+    openEditor() {
+        this.fmRecordEditor.open({size: 'lg'}).then(
+            ok => { console.debug(ok); },
+            err => {
+                if (err && err.dismissed) {
+                    console.debug('dialog was dismissed');
+                } else {
+                    console.error(err);
+                }
+            }
+        );
+    }
+
     btGridRowClassCallback(row: any): string {
         if (row.id() === 1) {
             return 'text-uppercase font-weight-bold text-danger';
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..08f66c1
--- /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 13ac684..8c10534 100644 (file)
@@ -3,8 +3,8 @@ import {Component, OnInit, Input} from '@angular/core';
 @Component({
   selector: 'eg-staff-banner',
   template:
-    '<div class="lead alert alert-primary text-center pt-1 pb-1" role="alert">' +
-      '<span>{{bannerText}}</span>' +
+    '<div class="alert alert-primary text-center pt-1 pb-1" role="alert">' +
+      '<h1 class="lead">{{bannerText}}</h1>' +
     '</div>'
 })
 
index cf10855..40a1a3d 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}
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 184d80c..02fd4d3 100644 (file)
@@ -19590,7 +19590,6 @@ VALUES (
     )
 );
 
-
 -- server admin workstation settings
 INSERT INTO config.workstation_setting_type (name, grp, datatype, label)
 VALUES (
@@ -19874,7 +19873,6 @@ INSERT INTO config.org_unit_setting_type
         'bool'
     );
 
-
 INSERT INTO config.usr_activity_type 
     (id, ewhat, ehow, egroup, enabled, transient, label)
 VALUES (
@@ -19897,3 +19895,35 @@ VALUES (
     oils_i18n_gettext(30, 'Generic Verify', 'cuat', 'label')
 );
 
+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.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.booking.manage.filter', 'gui', 'string',
+    oils_i18n_gettext(
+        'booking.manage.filter',
+        'Sticky setting for filter tab in Manage Reservations',
+        'cwst', 'label')
+), (
+    'eg.booking.pickup.ready.only_show_captured', 'gui', 'bool',
+    oils_i18n_gettext(
+        'booking.pickup.ready.only_show_captured',
+        'Include only resources that have been captured in the Ready grid in the Pickup screen',
+        'cwst', 'label')
+);
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.data.booking-sticky-settings.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.booking-sticky-settings.sql
new file mode 100644 (file)
index 0000000..2741f17
--- /dev/null
@@ -0,0 +1,37 @@
+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.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.booking.manage.filter', 'gui', 'string',
+    oils_i18n_gettext(
+        'booking.manage.filter',
+        'Sticky setting for filter tab in Manage 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..8484077
--- /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..e151ede 100644 (file)
@@ -214,12 +214,12 @@ 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/pickup/by_patron/{{patron().id()}}" target="_top">
               [% l('Booking: Pick Up Reservations') %]
             </a>
           </li>
index 2b42347..2e839b0 100644 (file)
             </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>
               [% 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 7d525c5..90aa9bd 100644 (file)
@@ -1016,6 +1016,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 1ff71f3..f8e6e49 100644 (file)
@@ -135,6 +135,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]);
     }
@@ -482,6 +486,12 @@ function($scope , $q , $window , $location , $timeout , egCore , egNet , egGridD
         itemSvc.book_copies_now(copyGrid.selectedItems());
     }
 
+    $scope.manage_reservations = function() {
+        var item = copyGrid.selectedItems()[0];
+        if (item)
+            itemSvc.manage_reservations(item.barcode);
+    }
+
     $scope.requestItems = function() {
         var copy_list = gatherSelectedHoldingsIds();
         itemSvc.requestItems(copy_list);
index c39f038..2774132 100644 (file)
@@ -418,6 +418,10 @@ function(egCore , egCirc , $uibModal , $q , $timeout , $window , egConfirmDialog
         });
     }
 
+    service.manage_reservations = function(barcode) {
+        location.href = "/eg2/staff/booking/manage_reservations/by_resource/" + barcode;
+    }
+
     service.requestItems = function(copy_list) {
         if (copy_list.length == 0) return;
 
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..94ebea7
--- /dev/null
@@ -0,0 +1,18 @@
+Booking Module Refresh
+^^^^^^^^^^^^^^^^^^^^^^
+
+The Booking module has been redesigned, with many of its interfaces being
+redesigned in Angular.
+
+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
+----
+