<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"/>
<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"/>
<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>
</eg-date-select>
</ng-container>
+ <ng-container *ngSwitchCase="'timestamp-timepicker'">
+ <eg-datetime-select
+ [showTZ]="timezone"
+ [timezone]="timezone"
+ domId="{{idPrefix}}-{{field.name}}"
+ (onChangeAsIso)="record[field.name]($event)"
+ i18n-validatorError
+ [readOnly]="field.readOnly"
+ initialIso="{{record[field.name]()}}">
+ </eg-datetime-select>
+ </ng-container>
+
<ng-container *ngSwitchCase="'org_unit'">
<eg-org-select
placeholder="{{field.label}}..."
(ngModelChange)="record[field.name]($event)"/>
</ng-container>
+ <ng-container *ngSwitchCase="'readonly-au'">
+ <ng-container *ngIf="field.linkedValues">
+ <a href="/eg/staff/circ/patron/{{field.linkedValues[0].id}}/checkout" target="_blank">{{field.linkedValues[0].label}}
+ <span class="material-icons" i18n-title title="Open user record in new tab">open_in_new</span></a>
+ </ng-container>
+ </ng-container>
+
<ng-container *ngSwitchCase="'list'">
<eg-combobox
id="{{idPrefix}}-{{field.name}}" name="{{field.name}}"
import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
import {TranslateComponent} from '@eg/staff/share/translate/translate.component';
-
+import {FormatService} from '@eg/core/format.service';
interface CustomFieldTemplate {
template: TemplateRef<any>;
recId: any;
+ // Show datetime fields in this particular timezone
+ timezone: string = this.format.wsOrgTimezone;
+
// IDL record we are editing
record: IdlObject;
@Input() requiredFieldsList: string[] = [];
@Input() requiredFields: string; // comma-separated string version
+ // list of timestamp fields that should display with a timepicker
+ @Input() datetimeFieldsList: string[] = [];
+ @Input() datetimeFields: string; // comma-separated string version
+
// list of org_unit fields where a default value may be applied by
// the org-select if no value is present.
@Input() orgDefaultAllowedList: string[] = [];
private idl: IdlService,
private auth: AuthService,
private toast: ToastService,
+ private format: FormatService,
private pcrud: PcrudService) {
super(modal);
}
if (this.requiredFields) {
this.requiredFieldsList = this.requiredFields.split(/,/);
}
+ if (this.datetimeFields) {
+ this.datetimeFieldsList = this.datetimeFields.split(/,/);
+ }
if (this.orgDefaultAllowed) {
this.orgDefaultAllowedList = this.orgDefaultAllowed.split(/,/);
}
promise = this.wireUpCombobox(field);
+ } else if (field.datatype === 'timestamp') {
+ field.datetime = this.datetimeFieldsList.includes(field.name);
} else if (field.datatype === 'org_unit') {
field.orgDefaultAllowed =
this.orgDefaultAllowedList.includes(field.name);
return 'template';
}
+ if ( field.datatype === 'timestamp' && field.datetime ) {
+ return 'timestamp-timepicker';
+ }
+
// Some widgets handle readOnly for us.
if ( field.datatype === 'timestamp'
|| field.datatype === 'org_unit'
return 'readonly-money';
}
+ if (field.datatype === 'link' && field.class === 'au') {
+ return 'readonly-au';
+ }
+
if (field.datatype === 'link' || field.linkedValues) {
return 'readonly-list';
}
}
}
-
// 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>;
col.datatype = this.datatype;
col.datePlusTime = this.datePlusTime;
col.ternaryBool = this.ternaryBool;
+ col.timezoneContextOrg = this.timezoneContextOrg;
col.isAuto = false;
this.grid.context.columnSet.add(col);
}
title="Expand Cells Vertically" i18n-title
class="material-icons mat-icon-in-button">expand_more</span>
<span *ngIf="gridContext.overflowCells"
- title="Collaps Cells Vertically" i18n-title
+ title="Collapse Cells Vertically" i18n-title
class="material-icons mat-icon-in-button">expand_less</span>
</button>
</div>
<div>
-
-
-
<div class="eg-grid" role="grid">
- <eg-grid-toolbar
+ <eg-grid-toolbar #toolbar
[gridContext]="context"
[gridPrinter]="gridPrinter"
[colWidthConfig]="colWidthConfig"
import {Component, Input, Output, OnInit, AfterViewInit, EventEmitter,
- OnDestroy, ViewEncapsulation} from '@angular/core';
+ OnDestroy, ViewChild, ViewEncapsulation} from '@angular/core';
import {IdlService} from '@eg/core/idl.service';
import {OrgService} from '@eg/core/org.service';
import {ServerStoreService} from '@eg/core/server-store.service';
import {FormatService} from '@eg/core/format.service';
import {GridContext, GridColumn, GridDataSource, GridRowFlairEntry} from './grid';
+import {GridToolbarComponent} from './grid-toolbar.component';
/**
* Main grid entry point.
@Output() onRowActivate: EventEmitter<any>;
@Output() onRowClick: EventEmitter<any>;
+ @ViewChild('toolbar') toolbar: GridToolbarComponent;
+
constructor(
private idl: IdlService,
private org: OrgService,
this.context.destroy();
}
+ print = () => {
+ this.toolbar.printHtml();
+ }
+
reload() {
this.context.reload();
}
datatype: string;
datePlusTime: boolean;
ternaryBool: boolean;
+ timezoneContextOrg: number;
cellTemplate: TemplateRef<any>;
cellContext: any;
isIndex: boolean;
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)
});
}
--- /dev/null
+import {Directive, Input} from '@angular/core';
+import {NG_VALIDATORS, AbstractControl, FormControl, ValidationErrors, ValidatorFn} from '@angular/forms';
+import {Injectable} from '@angular/core';
+
+import * as Moment from 'moment-timezone';
+
+export function notBeforeMomentValidator(notBeforeMe: Moment): ValidatorFn {
+ return (control: AbstractControl): {[key: string]: any} | null => {
+ return (control.value && control.value.isBefore(notBeforeMe)) ?
+ {tooEarly: 'This cannot be before ' + notBeforeMe.format('LLL')} : null;
+ };
+}
+
+@Directive({
+ selector: '[egNotBeforeMoment]',
+ providers: [{
+ provide: NG_VALIDATORS,
+ useExisting: NotBeforeMomentValidatorDirective,
+ multi: true
+ }]
+})
+export class NotBeforeMomentValidatorDirective {
+ @Input('egNotBeforeMoment') egNotBeforeMoment: Moment;
+
+ validate(control: AbstractControl): {[key: string]: any} | null {
+ return this.egNotBeforeMoment ?
+ notBeforeMomentValidator(this.egNotBeforeMoment)(control)
+ : null;
+ }
+}
+
+
--- /dev/null
+import {PatronBarcodeValidator} from './patron_barcode_validator.directive';
+import {of} from 'rxjs';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {EventService} from '@eg/core/event.service';
+import {StoreService} from '@eg/core/store.service';
+
+let netService: NetService;
+let authService: AuthService;
+let evtService: EventService;
+let storeService: StoreService;
+
+beforeEach(() => {
+ evtService = new EventService();
+ storeService = new StoreService(null /* CookieService */);
+ netService = new NetService(evtService);
+ authService = new AuthService(evtService, netService, storeService);
+});
+
+describe('PatronBarcodeValidator', () => {
+ it('should not throw an error if there is exactly 1 match', () => {
+ const pbv = new PatronBarcodeValidator(authService, netService);
+ pbv['parseActorCall'](of(1))
+ .subscribe((val) => {
+ expect(val).toBeNull();
+ });
+ });
+ it('should throw an error if there is more than 1 match', () => {
+ const pbv = new PatronBarcodeValidator(authService, netService);
+ pbv['parseActorCall'](of(1, 2, 3))
+ .subscribe((val) => {
+ expect(val).not.toBeNull();
+ });
+ });
+ it('should throw an error if there is no match', () => {
+ const pbv = new PatronBarcodeValidator(authService, netService);
+ pbv['parseActorCall'](of())
+ .subscribe((val) => {
+ expect(val).not.toBeNull();
+ });
+ });
+});
+
--- /dev/null
+import { Directive, forwardRef } from '@angular/core';
+import { NG_VALIDATORS, NG_ASYNC_VALIDATORS, AbstractControl, ValidationErrors, AsyncValidator, FormControl } from '@angular/forms';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {EmptyError, Observable, of} from 'rxjs';
+import {single, switchMap, catchError} from 'rxjs/operators';
+import {Injectable} from '@angular/core';
+
+@Injectable({providedIn: 'root'})
+export class PatronBarcodeValidator implements AsyncValidator {
+ constructor(
+ private auth: AuthService,
+ private net: NetService) {
+ }
+
+ validate = (control: FormControl) => {
+ return this.parseActorCall(this.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.get_barcodes',
+ this.auth.token(),
+ this.auth.user().ws_ou(),
+ 'actor', control.value));
+ }
+
+ private parseActorCall = (actorCall: Observable<any>) => {
+ return actorCall
+ .pipe(single(),
+ switchMap(() => of(null)),
+ catchError((err) => {
+ if (err instanceof EmptyError) {
+ return of({ patronBarcode: 'No patron found with that barcode' });
+ } else if ('Sequence contains more than one element' === err) {
+ return of({ patronBarcode: 'Barcode matches more than one patron' });
+ }
+ }));
+ }
+}
+
+@Directive({
+ selector: '[egValidPatronBarcode]',
+ providers: [{
+ provide: NG_ASYNC_VALIDATORS,
+ useExisting: forwardRef(() => PatronBarcodeValidator),
+ multi: true
+ }]
+})
+export class PatronBarcodeValidatorDirective {
+ constructor(
+ private pbv: PatronBarcodeValidator
+ ) { }
+
+ validate = (control: FormControl) => {
+ this.pbv.validate(control);
+ }
+}
+
--- /dev/null
+import {NgModule} from '@angular/core';
+import {ReactiveFormsModule} from '@angular/forms';
+import {StaffCommonModule} from '@eg/staff/common.module';
+import {BookingRoutingModule} from './routing.module';
+import {CancelReservationDialogComponent} from './cancel-reservation-dialog.component';
+import {CreateReservationComponent} from './create-reservation.component';
+import {CreateReservationDialogComponent} from './create-reservation-dialog.component';
+import {ManageReservationsComponent} from './manage-reservations.component';
+import {ReservationsGridComponent} from './reservations-grid.component';
+import {PickupComponent} from './pickup.component';
+import {PullListComponent} from './pull-list.component';
+import {ReturnComponent} from './return.component';
+import {NoTimezoneSetComponent} from './no-timezone-set.component';
+import {PatronService} from '@eg/staff/share/patron.service';
+import {BookingResourceBarcodeValidatorDirective} from './booking_resource_validator.directive';
+
+
+@NgModule({
+ imports: [
+ StaffCommonModule,
+ BookingRoutingModule,
+ ReactiveFormsModule,
+ ],
+ providers: [PatronService],
+ declarations: [
+ CancelReservationDialogComponent,
+ CreateReservationComponent,
+ CreateReservationDialogComponent,
+ ManageReservationsComponent,
+ NoTimezoneSetComponent,
+ PickupComponent,
+ PullListComponent,
+ ReservationsGridComponent,
+ ReturnComponent,
+ BookingResourceBarcodeValidatorDirective
+ ],
+ exports: [
+ BookingResourceBarcodeValidatorDirective
+ ]
+})
+export class BookingModule { }
+
--- /dev/null
+import {Directive, forwardRef, Injectable} from '@angular/core';
+import {NG_ASYNC_VALIDATORS, AsyncValidator, FormControl} from '@angular/forms';
+import {of} from 'rxjs';
+import {switchMap, catchError} from 'rxjs/operators';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {BookingModule} from './booking.module';
+
+@Injectable({providedIn: BookingModule})
+export class BookingResourceBarcodeValidator implements AsyncValidator {
+ constructor(
+ private pcrud: PcrudService) {
+ }
+
+ validate = (control: FormControl) => {
+ return this.pcrud.search('brsrc',
+ {'barcode' : control.value},
+ {'limit': 1}).pipe(
+ switchMap(() => of(null)),
+ catchError((err) => {
+ return of({ resourceBarcode: 'No resource found with that barcode' });
+ }));
+ }
+}
+
+@Directive({
+ selector: '[egValidBookingResourceBarcode]',
+ providers: [{
+ provide: NG_ASYNC_VALIDATORS,
+ useExisting: forwardRef(() => BookingResourceBarcodeValidator),
+ multi: true
+ }]
+})
+export class BookingResourceBarcodeValidatorDirective {
+ constructor(
+ private validator: BookingResourceBarcodeValidator
+ ) { }
+
+ validate = (control: FormControl) => {
+ this.validator.validate(control);
+ }
+}
+
--- /dev/null
+import {Component, EventEmitter, Output, ViewChild} from '@angular/core';
+import {switchMap} from 'rxjs/operators';
+import {AuthService} from '@eg/core/auth.service';
+import {NetService} from '@eg/core/net.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+
+@Component({
+ selector: 'eg-cancel-reservation-dialog',
+ template: `
+ <eg-confirm-dialog #confirmCancelReservationDialog
+ i18n-dialogTitle i18n-dialogBody
+ dialogTitle="Confirm Cancelation"
+ [dialogBodyTemplate]="confirmMessage">
+ </eg-confirm-dialog>
+ <ng-template #confirmMessage>
+ <span i18n>
+ Are you sure you want to cancel
+ {reservations.length, plural, =1 {this reservation} other {these {{reservations.length}} reservations}}?
+ </span>
+ </ng-template>
+ `
+})
+
+export class CancelReservationDialogComponent {
+
+ constructor(
+ private auth: AuthService,
+ private net: NetService,
+ private toast: ToastService
+ ) {
+ }
+
+ reservations: number[];
+
+ @ViewChild('confirmCancelReservationDialog')
+ private cancelReservationDialog: ConfirmDialogComponent;
+
+ @Output() onSuccessfulCancel = new EventEmitter();
+
+ open(reservations: number[]) {
+ this.reservations = reservations;
+ this.cancelReservationDialog.open()
+ .pipe(
+ switchMap(() => this.net.request(
+ 'open-ils.booking',
+ 'open-ils.booking.reservations.cancel',
+ this.auth.token(), reservations))
+ )
+ .subscribe(
+ (res) => {
+ if (res.textcode) {
+ this.toast.danger('Could not cancel reservation'); // TODO: needs i18n, pluralization
+ } else {
+ this.toast.success('Reservation successfully canceled'); // TODO: needs i18n, pluralization
+ this.onSuccessfulCancel.emit();
+ }
+ }
+ );
+ }
+
+}
+
--- /dev/null
+<ng-template #dialogContent>
+ <div class="modal-header bg-info">
+ <h3 class="modal-title" i18n>Confirm Reservation Details</h3>
+ <button type="button" class="close"
+ i18n-aria-label aria-label="Close"
+ (click)="dismiss('cross_click')">
+ <span aria-hidden="true">×</span>
+ </button>
+ </div>
+ <form class="modal-body form-common" [formGroup]="create">
+ <div class="form-group row">
+ <label class="col-lg-4 text-right font-weight-bold"
+ i18n for="create-patron-barcode">Patron barcode</label>
+ <input type="text" id="create-patron-barcode"
+ class="form-control col-lg-7" formControlName="patronBarcode">
+ <span class="col-lg-7 offset-lg-4" i18n>
+ {{ (patron$ | async)?.first_given_name}}
+ {{ (patron$ | async)?.second_given_name}}
+ {{ (patron$ | async)?.family_name}}
+ </span>
+ </div>
+ <div class="form-group row">
+ <label class="col-lg-4 text-right font-weight-bold"
+ i18n for="create-end-time">Start time</label>
+ <eg-datetime-select
+ formControlName="startTime"
+ [timezone]="timezone">
+ </eg-datetime-select>
+ </div>
+ <div class="form-group row">
+ <label class="col-lg-4 text-right font-weight-bold"
+ i18n for="create-end-time">End time</label>
+ <eg-datetime-select
+ formControlName="endTime"
+ [timezone]="timezone">
+ </eg-datetime-select>
+ <div role="alert" class="alert alert-danger" *ngIf="create.errors && create.errors.startTimeNotBeforeEndTime">
+ <span class="material-icons" aria-hidden="true">error</span>
+ <span i18n>Start time must be before end time</span>
+ </div>
+ </div>
+ <div class="form-group row">
+ <label class="col-lg-4 text-right font-weight-bold"
+ i18n for="create-pickup-library">Pickup library</label>
+ <eg-org-select domId="create-pickup-library" [applyDefault]="true"
+ [disableOrgs]="disableOrgs()" [hideOrgs]="disableOrgs()"
+ (onChange)="handlePickupLibChange($event)">
+ </eg-org-select>
+ </div>
+ <div *ngIf="pickupLibraryUsesDifferentTz"
+ role="alert"
+ class="alert alert-info">
+ <span class="material-icons" aria-hidden="true">access_time</span>
+ <span i18n>Pickup Library is in the {{timezone}} timezone</span>
+ </div>
+ <div class="form-group row">
+ <label class="col-lg-4 text-right font-weight-bold"
+ i18n for="create-resource">Resource</label>
+ <input *ngIf="targetResource && targetResourceBarcode" id="create-resource" value="{{targetResourceBarcode}}" disabled>
+ <eg-combobox
+ formControlName="resourceList"
+ *ngIf="!(targetResource && targetResourceBarcode)"
+ startId="any">
+ <eg-combobox-entry entryId="any" entryLabel="Any resource"
+ i18n-entryLabel></eg-combobox-entry>
+ <eg-combobox-entry *ngFor="let r of resources" entryId="{{r.id()}}" entryLabel="{{r.barcode()}}">
+ </eg-combobox-entry>
+ </eg-combobox>
+ </div>
+ <div class="form-group row">
+ <label class="col-lg-4 text-right font-weight-bold"
+ i18n for="create-email-notify">Notify by email?</label>
+ <input type="checkbox" formControlName="emailNotify">
+ </div>
+ </form>
+ <div class="modal-footer">
+ <button (click)="addBresv$().subscribe()" [disabled]="!create.valid" class="btn btn-info" i18n>Confirm reservation</button>
+ <button (click)="addBresvAndOpenPatronReservations()" [disabled]="!create.valid" class="btn btn-info" i18n>
+ Confirm and show patron reservations
+ </button>
+ <button (click)="close()" class="btn btn-warning ml-2" i18n>Cancel</button>
+ </div>
+</ng-template>
+<eg-alert-dialog #fail i18n-dialogBody
+ dialogBody="Could not create this reservation">
+</eg-alert-dialog>
--- /dev/null
+import {Component, Input, Output, OnInit, ViewChild, EventEmitter} from '@angular/core';
+import {FormGroup, FormControl, Validators, ValidatorFn, ValidationErrors} from '@angular/forms';
+import {Router} from '@angular/router';
+import {Observable, of} from 'rxjs';
+import {switchMap, single, startWith, tap} from 'rxjs/operators';
+import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
+import {AuthService} from '@eg/core/auth.service';
+import {FormatService} from '@eg/core/format.service';
+import {IdlObject} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {OrgService} from '@eg/core/org.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {PatronBarcodeValidator} from '@eg/share/validators/patron_barcode_validator.directive';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {AlertDialogComponent} from '@eg/share/dialog/alert.component';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import * as Moment from 'moment-timezone';
+
+const startTimeIsBeforeEndTimeValidator: ValidatorFn = (fg: FormGroup): ValidationErrors | null => {
+ const start = fg.get('startTime').value;
+ const end = fg.get('endTime').value;
+ return start !== null && end !== null &&
+ start.isBefore(end)
+ ? null
+ : { startTimeNotBeforeEndTime: true };
+};
+
+@Component({
+ selector: 'eg-create-reservation-dialog',
+ templateUrl: './create-reservation-dialog.component.html'
+})
+
+export class CreateReservationDialogComponent
+ extends DialogComponent implements OnInit {
+
+ @Input() targetResource: number;
+ @Input() targetResourceBarcode: string;
+ @Input() targetResourceType: ComboboxEntry;
+ @Input() attributes: number[] = [];
+ @Input() resources: IdlObject[] = [];
+ @Output() onComplete: EventEmitter<boolean>;
+
+ create: FormGroup;
+ patron$: Observable<{first_given_name: string, second_given_name: string, family_name: string}>;
+ pickupLibId: number;
+ timezone: string = this.format.wsOrgTimezone;
+ pickupLibraryUsesDifferentTz: boolean;
+
+ public disableOrgs: () => number[];
+ addBresv$: () => Observable<any>;
+ @ViewChild('fail') private fail: AlertDialogComponent;
+
+ handlePickupLibChange: ($event: IdlObject) => void;
+
+ constructor(
+ private auth: AuthService,
+ private format: FormatService,
+ private net: NetService,
+ private org: OrgService,
+ private pcrud: PcrudService,
+ private router: Router,
+ private modal: NgbModal,
+ private pbv: PatronBarcodeValidator,
+ private toast: ToastService
+ ) {
+ super(modal);
+ this.onComplete = new EventEmitter<boolean>();
+ }
+
+ ngOnInit() {
+
+ this.create = new FormGroup({
+ // TODO: replace this control with a patron search form
+ // when available in the Angular client
+ 'patronBarcode': new FormControl('',
+ [Validators.required],
+ [this.pbv.validate]
+ ),
+ 'emailNotify': new FormControl(true),
+ 'startTime': new FormControl(),
+ 'endTime': new FormControl(),
+ 'resourceList': new FormControl(),
+ }, [startTimeIsBeforeEndTimeValidator]
+ );
+
+ this.addBresv$ = () => {
+ let selectedResourceId = this.targetResource ? [this.targetResource] : null;
+ if (!selectedResourceId &&
+ this.resourceListSelection !== null &&
+ 'any' !== this.resourceListSelection.id) {
+ selectedResourceId = [this.resourceListSelection.id];
+ }
+ return this.net.request(
+ 'open-ils.booking',
+ 'open-ils.booking.reservations.create',
+ this.auth.token(),
+ this.patronBarcode.value,
+ this.selectedTimes,
+ this.pickupLibId,
+ this.targetResourceType.id,
+ selectedResourceId,
+ this.attributes.filter(Boolean),
+ this.emailNotify
+ ).pipe(tap(
+ (success) => {
+ if (success.ilsevent) {
+ console.warn(success);
+ this.fail.open();
+ } else {
+ this.toast.success('Reservation successfully created');
+ console.debug(success);
+ this.close();
+ }
+ }, (fail) => {
+ console.warn(fail);
+ this.fail.open();
+ }, () => this.onComplete.emit(true)
+ ));
+ };
+
+ this.handlePickupLibChange = ($event) => {
+ this.pickupLibId = $event.id();
+ this.org.settings('lib.timezone', this.pickupLibId).then((tz) => {
+ this.timezone = tz['lib.timezone'] || this.format.wsOrgTimezone;
+ this.pickupLibraryUsesDifferentTz = (tz['lib.timezone'] && (this.format.wsOrgTimezone !== tz['lib.timezone']));
+ });
+ };
+
+ this.disableOrgs = () => this.org.filterList( { canHaveVolumes : false }, true);
+
+ this.patron$ = this.patronBarcode.statusChanges.pipe(
+ startWith({first_given_name: '', second_given_name: '', family_name: ''}),
+ switchMap(() => {
+ if ('VALID' === this.patronBarcode.status) {
+ return this.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.get_barcodes',
+ this.auth.token(),
+ this.auth.user().ws_ou(),
+ 'actor', this.patronBarcode.value).pipe(
+ single(),
+ switchMap((result) => {
+ return this.pcrud.retrieve('au', result[0]['id']).pipe(
+ switchMap((au) => {
+ return of({
+ first_given_name: au.first_given_name(),
+ second_given_name: au.second_given_name(),
+ family_name: au.family_name()});
+ })
+ );
+ })
+ );
+ } else {
+ return of({
+ first_given_name: '',
+ second_given_name: '',
+ family_name: ''
+ });
+ }
+ })
+ );
+ }
+
+ setDefaultTimes(times: Moment[], granularity: number) {
+ this.create.patchValue({startTime: Moment.min(times),
+ endTime: Moment.max(times).clone().add(granularity, 'minutes')
+ });
+ }
+
+ openPatronReservations = (): void => {
+ this.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.get_barcodes',
+ this.auth.token(),
+ this.auth.user().ws_ou(),
+ 'actor', this.patronBarcode.value
+ ).subscribe((patron) => this.router.navigate(['/staff', 'booking', 'manage_reservations', 'by_patron', patron[0]['id']]));
+ }
+
+ addBresvAndOpenPatronReservations = (): void => {
+ this.addBresv$()
+ .subscribe(() => this.openPatronReservations());
+ }
+
+ get emailNotify() {
+ return this.create.get('emailNotify').value;
+ }
+
+ get patronBarcode() {
+ return this.create.get('patronBarcode');
+ }
+
+ get resourceListSelection() {
+ return this.create.get('resourceList').value;
+ }
+
+ get selectedTimes() {
+ return [this.create.get('startTime').value.toISOString(),
+ this.create.get('endTime').value.toISOString()];
+ }
+}
+
--- /dev/null
+<eg-staff-banner bannerText="Create Reservation" i18n-bannerText>
+</eg-staff-banner>
+<eg-title i18n-prefix i18n-suffix prefix="Booking" suffix="Create Reservation"></eg-title>
+{{attributes | json}}
+{{selectedAttributes.value | json}}
+<form [formGroup]="criteria" class="row">
+ <div class="col-sm-6">
+ <div class="row">
+ <div class="col">
+ <div class="input-group">
+ <div class="input-group-prepend">
+ <label class="input-group-text" for="ideal-reservation-type" i18n>Reservation type</label>
+ </div>
+ <select class="form-control" id="ideal-reservation-type" formControlName="reservationType">
+ <option *ngFor="let type of reservationTypes" [ngValue]="type" i18n>{{type.name}}</option>
+ </select>
+ </div>
+ </div>
+ <div class="col">
+ <div class="input-group">
+ <div class="input-group-prepend">
+ <label class="input-group-text" for="ideal-reservation-date" i18n>Reservation date</label>
+ </div>
+ <eg-date-select *ngIf="!multiday" #dateLimiter domId="ideal-reservation-date" formControlName="idealDate"></eg-date-select>
+ <eg-daterange-select *ngIf="multiday" formControlName="idealDateRange"></eg-daterange-select>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="card col-sm-6">
+ <h2 class="card-header" i18n>Reservation details</h2>
+ <ngb-tabset #details="ngbTabset">
+ <ngb-tab id="select-resource-type">
+ <ng-template ngbTabTitle>
+ <span class="material-icons">category</span>
+ <ng-container i18n>Choose resource by type</ng-container>
+ </ng-template>
+ <ng-template ngbTabContent>
+ <div ngbPanelContent class="row">
+ <div class="col">
+ <div class="input-group">
+ <div class="input-group-prepend">
+ <label class="input-group-text" for="ideal-resource-type" i18n>Search by resource type</label>
+ </div>
+ <eg-combobox
+ formControlName="resourceType"
+ domId="ideal-resource-type"
+ idlClass="brt"
+ [asyncSupportsEmptyTermClick]="true">
+ </eg-combobox>
+ </div>
+ <div class="col">
+ <eg-org-family-select [hideAncestorSelector]="true" labelText="Owning library" i18n-labelText formControlName="owningLibrary">
+ </eg-org-family-select>
+ </div>
+ </div>
+ </div>
+ </ng-template>
+ </ngb-tab>
+
+ <ngb-tab id="select-resource">
+ <ng-template ngbTabTitle>
+ <span class="material-icons">assignment</span>
+ <ng-container i18n>Choose resource by barcode</ng-container>
+ </ng-template>
+ <ng-template ngbTabContent>
+ <div ngbPanelContent class="row">
+ <div class="col">
+ <div class="input-group">
+ <div class="input-group-prepend">
+ <label class="input-group-text" for="ideal-resource-barcode" i18n>Search by resource barcode</label>
+ </div>
+ <input type="text" id="ideal-resource-barcode" class="form-control" formControlName="resourceBarcode">
+ </div>
+ </div>
+ </div>
+ </ng-template>
+ </ngb-tab>
+
+ <ngb-tab id="attributes" [disabled]="0 === attributes.length">
+ <ng-template ngbTabTitle>
+ <span class="material-icons">filter_list</span>
+ <ng-container i18n>Limit by attributes</ng-container>
+ </ng-template>
+ <ng-template ngbTabContent>
+ <ul class="list-group list-group-flush" formArrayName="selectedAttributes">
+ <li *ngFor="let attribute of attributes; let i = index" class="list-group-item">
+ <span class="input-group">
+ <span class="input-group-prepend">
+ <label class="input-group-text" for="attribute-{{attribute.id()}}" i18n>{{attribute.name()}}</label>
+ </span>
+ <eg-combobox [formControlName]="i">
+ <eg-combobox-entry *ngFor="let value of attribute.valid_values()"
+ [entryId]="value.id()" [entryLabel]="value.valid_value()">
+ </eg-combobox-entry>
+ </eg-combobox>
+ </span>
+ </li>
+ </ul>
+ </ng-template>
+ </ngb-tab>
+
+ <ngb-tab id="display-settings">
+ <ng-template ngbTabTitle>
+ <span class="material-icons">settings</span>
+ <ng-container i18n>Schedule settings</ng-container>
+ </ng-template>
+ <ng-template ngbTabContent>
+ <ul class="list-group list-group-flush">
+ <li class="list-group-item">
+ <span class="input-group">
+ <span class="input-group-prepend">
+ <label class="input-group-text" for="start-time" i18n>Start time</label>
+ </span>
+ <ngb-timepicker formControlName="startOfDay" [minuteStep]="minuteStep()" [meridian]="true"></ngb-timepicker>
+ </span>
+ </li>
+ <li class="list-group-item">
+ <span class="input-group">
+ <span class="input-group-prepend">
+ <label class="input-group-text" for="end-time" i18n>End time</label>
+ </span>
+ <ngb-timepicker formControlName="endOfDay" [minuteStep]="minuteStep()" [meridian]="true"></ngb-timepicker>
+ </span>
+ </li>
+ <li *ngIf="criteria.errors && criteria.errors.startOfDayNotBeforeEndOfDay" class="list-group-item">
+ <div role="alert" class="alert alert-danger">
+ <span class="material-icons" aria-hidden="true">error</span>
+ <span i18n>Start time must be before end time</span>
+ </div>
+ </li>
+ <li class="list-group-item">
+ <span class="input-group">
+ <span class="input-group-prepend">
+ <label class="input-group-text" for="granularity" i18n>Granularity</label>
+ </span>
+ <eg-combobox (onChange)="changeGranularity($event)" [startId]="granularity ? granularity : 30">
+ <eg-combobox-entry [entryId]="15" entryLabel="15 minutes"
+ i18n-entryLabel></eg-combobox-entry>
+ <eg-combobox-entry [entryId]="30" entryLabel="30 minutes"
+ i18n-entryLabel></eg-combobox-entry>
+ <eg-combobox-entry [entryId]="60" entryLabel="60 minutes"
+ i18n-entryLabel></eg-combobox-entry>
+ </eg-combobox>
+ </span>
+ </li>
+ </ul>
+ </ng-template>
+ </ngb-tab>
+ </ngb-tabset>
+ </div>
+</form>
+
+<ng-container *ngIf="resources.length">
+ <hr>
+ <div class="row" *ngIf="idealDate && !multiday">
+ <button class="btn btn-info col-sm-2 offset-sm-3" (click)="addDays(-1)">
+ <span class="material-icons mat-icon-in-button">keyboard_arrow_left</span>
+ <span i18n>Previous day</span>
+ </button>
+ <h2 class="col-sm-2 text-center" i18n>{{idealDate | formatValue:'timestamp'}}</h2>
+ <button class="btn btn-info col-sm-2" (click)="addDays(1)">
+ <span i18n>Next day</span>
+ <span class="material-icons mat-icon-in-button">keyboard_arrow_right</span>
+ </button>
+ </div>
+ <eg-grid #scheduleGrid
+ [sortable]="false"
+ (onRowActivate)="openTheDialog([$event])"
+ [dataSource]="scheduleSource"
+ [rowFlairIsEnabled]="true"
+ [rowFlairCallback]="resourceAvailabilityIcon"
+ [disablePaging]="true"
+ persistKey="disabled">
+ <eg-grid-toolbar-action label="Create Reservation" i18n-label (onClick)="openTheDialog($event)"></eg-grid-toolbar-action>
+ <eg-grid-column path="time" [index]="true" name="Time" i18n-name [cellTemplate]="timeTemplate" ></eg-grid-column>
+ <eg-grid-column *ngFor="let resource of resources" path="{{resource.barcode()}}" [cellTemplate]="reservationsTemplate" [disableTooltip]="true"></eg-grid-column>
+ </eg-grid>
+</ng-container>
+<div class="text-sm-center" *ngIf="this.resourceType.value && !resources.length" i18n>
+ There are no bookable resource that match your criteria.
+ Would you like to create <a [routerLink]="['/staff', 'admin', 'booking', 'splash']">some new resources</a>?
+</div>
+
+<eg-create-reservation-dialog #createDialog
+ (onComplete)="fetchData()"
+ [targetResourceBarcode]="resourceBarcode"
+ [targetResource]="resourceId"
+ [targetResourceType]="resourceType.value"
+ [attributes]="flattenedSelectedAttributes"
+ [resources]="resources">
+</eg-create-reservation-dialog>
+
+<ng-template #reservationsTemplate let-row="row" let-col="col">
+ <ng-container *ngIf="row[col.name]">
+ <ul class="list-unstyled">
+ <li *ngFor="let reservation of row[col.name]">
+ <button class="btn btn-info" (click)="openReservationViewer(reservation['reservationId'])">
+ {{reservation['patronLabel']}}
+ </button>
+ </li>
+ </ul>
+ </ng-container>
+</ng-template>
+<ng-template #timeTemplate let-row="row" let-col="col">
+ <ng-container *ngIf="!multiday">
+ {{row['time'].format('LT')}}
+ </ng-container>
+ <ng-container *ngIf="multiday">
+ {{row['time'] | formatValue:'timestamp'}}
+ </ng-container>
+</ng-template>
+<eg-fm-record-editor #viewReservation
+ idlClass="bresv"
+ datetimeFields="start_time,end_time"
+ hiddenFields="xact_start,xact_finish,cancel_time,booking_interval">
+</eg-fm-record-editor>
+<eg-no-timezone-set-dialog #noTimezoneSetDialog>
+</eg-no-timezone-set-dialog>
--- /dev/null
+import { Component, OnInit, AfterViewInit, QueryList, ViewChildren, ViewChild, OnDestroy } from '@angular/core';
+import {FormGroup, FormControl, ValidationErrors, ValidatorFn, FormArray} from '@angular/forms';
+import {Router, ActivatedRoute} from '@angular/router';
+import {from, iif, Observable, of, throwError, timer, Subscription} from 'rxjs';
+import {catchError, debounceTime, takeLast, mapTo, single, switchMap, tap} from 'rxjs/operators';
+import {NgbCalendar, NgbTabset} from '@ng-bootstrap/ng-bootstrap';
+import {AuthService} from '@eg/core/auth.service';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {FormatService} from '@eg/core/format.service';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {GridDataSource, GridRowFlairEntry} from '@eg/share/grid/grid';
+import {IdlObject} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {CreateReservationDialogComponent} from './create-reservation-dialog.component';
+import {ServerStoreService} from '@eg/core/server-store.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {DateRange} from '@eg/share/daterange-select/daterange-select.component';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {ScheduleGridService, ScheduleRow} from './schedule-grid.service';
+import {NoTimezoneSetComponent} from './no-timezone-set.component';
+
+import * as Moment from 'moment-timezone';
+
+const startOfDayIsBeforeEndOfDayValidator: ValidatorFn = (fg: FormGroup): ValidationErrors | null => {
+ const start = fg.get('startOfDay').value;
+ const end = fg.get('endOfDay').value;
+ return start !== null && end !== null &&
+ (start.hour <= end.hour) &&
+ !((start.hour === end.hour) && (start.minute >= end.minute))
+ ? null
+ : { startOfDayNotBeforeEndOfDay: true };
+};
+
+@Component({
+ templateUrl: './create-reservation.component.html',
+ styles: ['#ideal-resource-barcode {min-width: 300px;}']
+})
+export class CreateReservationComponent implements OnInit, AfterViewInit, OnDestroy {
+
+ criteria: FormGroup;
+
+ attributes: IdlObject[] = [];
+ multiday = false;
+ resourceAvailabilityIcon: (row: ScheduleRow) => GridRowFlairEntry;
+
+ patronBarcode: string;
+ patronId: number;
+ resourceBarcode: string;
+ resourceId: number;
+ transferable: boolean;
+ resourceOwner: number;
+ subscriptions: Subscription[] = [];
+
+ defaultGranularity = 30;
+ granularity: number = this.defaultGranularity;
+
+ scheduleSource: GridDataSource = new GridDataSource();
+
+ minuteStep: () => number;
+ reservationTypes: {id: string, name: string}[];
+
+ openTheDialog: (rows: IdlObject[]) => void;
+
+ resources: IdlObject[] = [];
+
+ setGranularity: () => void;
+ changeGranularity: ($event: ComboboxEntry) => void;
+
+ dateRange: DateRange;
+
+ @ViewChild('createDialog') createDialog: CreateReservationDialogComponent;
+ @ViewChild('details') details: NgbTabset;
+ @ViewChild('noTimezoneSetDialog') noTimezoneSetDialog: NoTimezoneSetComponent;
+ @ViewChild('viewReservation') viewReservation: FmRecordEditorComponent;
+ @ViewChildren('scheduleGrid') scheduleGrids: QueryList<GridComponent>;
+
+ constructor(
+ private auth: AuthService,
+ private calendar: NgbCalendar,
+ private format: FormatService,
+ private net: NetService,
+ private pcrud: PcrudService,
+ private route: ActivatedRoute,
+ private router: Router,
+ private scheduleService: ScheduleGridService,
+ private store: ServerStoreService,
+ private toast: ToastService,
+ ) {
+ }
+
+ ngOnInit() {
+ if (!(this.format.wsOrgTimezone)) {
+ this.noTimezoneSetDialog.open();
+ }
+
+ const initialRangeLength = 10;
+ const defaultRange = {
+ fromDate: this.calendar.getToday(),
+ toDate: this.calendar.getNext(
+ this.calendar.getToday(), 'd', initialRangeLength)
+ };
+
+ this.route.paramMap.pipe(
+ tap(params => {
+ this.patronId = +params.get('patron_id');
+ this.resourceBarcode = params.get('resource_barcode');
+ }),
+ switchMap(params => iif(() => params.has('resource_barcode'),
+ this.handleBarcodeFromUrl$(params.get('resource_barcode')),
+ of(params)
+ ))
+ ).subscribe({
+ error() {
+ console.warn('could not find a resource with this barcode');
+ }
+ });
+
+ this.reservationTypes = [
+ {id: 'single', name: 'Single day reservation'},
+ {id: 'multi', name: 'Multiple day reservation'},
+ ];
+
+ const waitToLoadResource = 800;
+ this.criteria = new FormGroup({
+ 'resourceBarcode': new FormControl(this.resourceBarcode ? this.resourceBarcode : '',
+ [], (rb) =>
+ timer(waitToLoadResource).pipe(switchMap(() =>
+ this.pcrud.search('brsrc',
+ {'barcode' : rb.value},
+ {'limit': 1})),
+ single(),
+ mapTo(null),
+ catchError(() => of({ resourceBarcode: 'No resource found with that barcode' }))
+ )),
+ 'resourceType': new FormControl(),
+ 'startOfDay': new FormControl({hour: 9, minute: 0, second: 0}),
+ 'endOfDay': new FormControl({hour: 17, minute: 0, second: 0}),
+ 'idealDate': new FormControl(new Date()),
+ 'idealDateRange': new FormControl(defaultRange),
+ 'reservationType': new FormControl(),
+ 'owningLibrary': new FormControl({primaryOrgId: this.auth.user().ws_ou(), includeDescendants: true}),
+ 'selectedAttributes': new FormArray([]),
+ }, [ startOfDayIsBeforeEndOfDayValidator
+ ]);
+
+ const debouncing = 1500;
+ this.criteria.get('resourceBarcode').valueChanges
+ .pipe(debounceTime(debouncing))
+ .subscribe((barcode) => {
+ this.resources = [];
+ if ('INVALID' === this.criteria.get('resourceBarcode').status) {
+ this.toast.danger('No resource found with this barcode');
+ } else {
+ this.router.navigate(['/staff', 'booking', 'create_reservation', 'for_resource', barcode]);
+ }
+ });
+
+ this.subscriptions.push(
+ this.resourceType.valueChanges.pipe(
+ switchMap((value) => {
+ this.resourceBarcode = null;
+ this.resources = [];
+ this.resourceId = null;
+ this.attributes = [];
+ // TODO: when we upgrade to Angular 8, this can
+ // be simplified to this.selectedAttributes.clear();
+ while (this.selectedAttributes.length) {
+ this.selectedAttributes.removeAt(0);
+ }
+ if (value.id) {
+ return this.pcrud.search('bra', {resource_type : value.id}, {
+ order_by: 'name ASC',
+ flesh: 1,
+ flesh_fields: {'bra' : ['valid_values']}
+ }).pipe(
+ tap((attribute) => {
+ this.attributes.push(attribute);
+ this.selectedAttributes.push(new FormControl());
+ })
+ );
+ } else {
+ return of();
+ }
+ })
+ ).subscribe(() => this.fetchData()));
+
+ this.criteria.get('reservationType').valueChanges.subscribe((val) => {
+ this.multiday = ('multi' === val.id);
+ this.store.setItem('eg.booking.create.multiday', this.multiday);
+ });
+
+ this.subscriptions.push(
+ this.owningLibraryFamily.valueChanges
+ .subscribe(() => this.resources = []));
+
+ this.subscriptions.push(
+ this.criteria.valueChanges
+ .subscribe(() => this.fetchData()));
+
+ this.store.getItem('eg.booking.create.multiday').then(multiday => {
+ if (multiday) { this.multiday = multiday; }
+ this.criteria.patchValue({reservationType:
+ this.multiday ? this.reservationTypes[1] : this.reservationTypes[0]
+ });
+ });
+
+ const minutesInADay = 1440;
+
+ this.setGranularity = () => {
+ if (this.multiday) { // multiday reservations always use day granularity
+ this.granularity = minutesInADay;
+ } else {
+ this.store.getItem('eg.booking.create.granularity').then(granularity => {
+ if (granularity) {
+ this.granularity = granularity;
+ } else {
+ this.granularity = this.defaultGranularity;
+ }
+ });
+ }
+ };
+
+ this.criteria.get('idealDate').valueChanges
+ .pipe(switchMap((date) => this.scheduleService.hoursOfOperation(date)))
+ .subscribe((hours) => this.criteria.patchValue(hours, {emitEvent: false}),
+ () => {},
+ () => this.fetchData());
+
+ this.changeGranularity = ($event) => {
+ this.granularity = $event.id;
+ this.store.setItem('eg.booking.create.granularity', $event.id)
+ .then(() => this.fetchData());
+ };
+
+ const minutesInAnHour = 60;
+
+ this.minuteStep = () => {
+ return (this.granularity < minutesInAnHour) ? this.granularity : this.defaultGranularity;
+ };
+
+ this.resourceAvailabilityIcon = (row: ScheduleRow) => {
+ return this.scheduleService.resourceAvailabilityIcon(row, this.resources.length);
+ };
+ }
+
+ ngAfterViewInit() {
+ this.fetchData();
+
+ this.openTheDialog = (rows: IdlObject[]) => {
+ if (rows && rows.length) {
+ this.createDialog.setDefaultTimes(rows.map((row) => row['time'].clone()), this.granularity);
+ }
+ this.subscriptions.push(
+ this.createDialog.open({size: 'lg'})
+ .subscribe(() => this.fetchData())
+ );
+ };
+ }
+
+ fetchData = (): void => {
+ this.setGranularity();
+ this.scheduleSource.data = [];
+ let resources$ = this.scheduleService.fetchRelevantResources(
+ this.resourceType.value ? this.resourceType.value.id : null,
+ this.owningLibraries,
+ this.flattenedSelectedAttributes
+ );
+ if (this.resourceId) {
+ resources$ = from(this.resources);
+ } else {
+ this.resources = [];
+ }
+
+ resources$.pipe(
+ tap((resource) => this.resources.push(resource)),
+ takeLast(1),
+ switchMap(() => {
+ let range = {startTime: Moment(), endTime: Moment()};
+
+ if (this.multiday) {
+ range = this.scheduleService.momentizeDateRange(
+ this.idealDateRange,
+ this.format.wsOrgTimezone
+ );
+ } else {
+ range = this.scheduleService.momentizeDay(
+ this.idealDate,
+ this.userStartOfDay,
+ this.userEndOfDay,
+ this.format.wsOrgTimezone
+ );
+ }
+ this.scheduleSource.data = this.scheduleService.createBasicSchedule(
+ range, this.granularity);
+ return this.scheduleService.fetchReservations(range, this.resources.map(r => r.id()));
+ })
+ ).subscribe((reservation) => {
+ this.scheduleSource.data = this.scheduleService.addReservationToSchedule(
+ reservation,
+ this.scheduleSource.data,
+ this.granularity,
+ this.format.wsOrgTimezone
+ );
+ });
+ }
+ // TODO: make this into cross-field validation, and don't fetch data if true
+ invalidMultidaySettings(): boolean {
+ return (this.multiday && (!this.idealDateRange ||
+ (null == this.idealDateRange.fromDate) ||
+ (null == this.idealDateRange.toDate)));
+ }
+
+ handleBarcodeFromUrl$(barcode: string): Observable<any> {
+ return this.findResourceByBarcode$(barcode)
+ .pipe(
+ catchError(() => this.handleBrsrcError$(barcode)),
+ tap((resource) => {
+ if (resource) {
+ this.resourceId = resource.id();
+ this.criteria.patchValue({
+ resourceType: {id: resource.type()}},
+ {emitEvent: false});
+ this.resources = [resource];
+ this.details.select('select-resource');
+ this.fetchData();
+ }
+ })
+ );
+ }
+
+ findResourceByBarcode$(barcode: string): Observable<IdlObject> {
+ return this.pcrud.search('brsrc',
+ {'barcode' : barcode}, {'limit': 1})
+ .pipe(single());
+ }
+
+ handleBrsrcError$(barcode: string): Observable<any> {
+ return this.tryToMakeThisBookable$(barcode)
+ .pipe(switchMap(() => this.findResourceByBarcode$(barcode)),
+ catchError(() => {
+ this.toast.danger('No resource found with this barcode');
+ this.resourceId = -1;
+ return throwError('could not find or create a resource');
+ }));
+ }
+
+ tryToMakeThisBookable$(barcode: string): Observable<any> {
+ return this.pcrud.search('acp',
+ {'barcode' : barcode}, {'limit': 1})
+ .pipe(single(),
+ switchMap((item) =>
+ this.net.request( 'open-ils.booking',
+ 'open-ils.booking.resources.create_from_copies',
+ this.auth.token(), [item.id()])
+ ),
+ catchError(() => {
+ this.toast.danger('Cannot make this barcode bookable');
+ return throwError('Tried and failed to make that barcode bookable');
+ }),
+ tap((response) => {
+ this.toast.info('Made this barcode bookable');
+ this.resourceId = response['brsrc'][0][0];
+ }));
+ }
+
+ addDays = (days: number): void => {
+ const result = new Date(this.idealDate);
+ result.setDate(result.getDate() + days);
+ this.criteria.patchValue({idealDate: result});
+ }
+
+ openReservationViewer = (id: number): void => {
+ this.viewReservation.mode = 'view';
+ this.viewReservation.recId = id;
+ this.viewReservation.open({ size: 'lg' });
+ }
+
+ get resourceType() {
+ return this.criteria.get('resourceType');
+ }
+ get userStartOfDay() {
+ return this.criteria.get('startOfDay').value;
+ }
+ get userEndOfDay() {
+ return this.criteria.get('endOfDay').value;
+ }
+ get idealDate() {
+ return this.criteria.get('idealDate').value;
+ }
+ get idealDateRange() {
+ return this.criteria.get('idealDateRange').value;
+ }
+ get owningLibraryFamily() {
+ return this.criteria.get('owningLibrary');
+ }
+ get owningLibraries() {
+ if (this.criteria.get('owningLibrary').value.orgIds) {
+ return this.criteria.get('owningLibrary').value.orgIds;
+ } else {
+ return [this.criteria.get('owningLibrary').value.primaryOrgId];
+ }
+ }
+ get selectedAttributes() {
+ return <FormArray>this.criteria.get('selectedAttributes');
+ }
+ get flattenedSelectedAttributes(): number[] {
+ return this.selectedAttributes.value.filter(Boolean).map((entry) => entry.id);
+ }
+ ngOnDestroy(): void {
+ this.subscriptions.forEach((subscription) => {
+ subscription.unsubscribe();
+ });
+ }
+
+}
+
--- /dev/null
+<eg-staff-banner bannerText="Manage Reservations" i18n-bannerText>
+</eg-staff-banner>
+<eg-title i18n-prefix i18n-suffix prefix="Booking" suffix="Manage Reservations"></eg-title>
+
+<form [formGroup]="filters" class="row">
+ <div class="col-sm-3">
+ <eg-org-family-select [hideAncestorSelector]="true" labelText="Pickup library" i18n-labelText formControlName="pickupLibraries">
+ </eg-org-family-select>
+ </div>
+ <div class="col-sm-6 offset-sm-3">
+ <div class="card">
+ <h2 class="card-header" i18n>Filter reservations</h2>
+ <ngb-tabset #filterTabs [activeId]="startingTab" class="mt-1">
+ <ngb-tab id="patron">
+ <ng-template ngbTabTitle>
+ <span class="material-icons" *ngIf="patronId">filter_list</span> <span i18n>Filter by patron</span>
+ </ng-template>
+ <ng-template ngbTabContent>
+ <div class="m-2">
+ <div class="input-group m-2">
+ <div class="input-group-prepend">
+ <label class="input-group-text" for="patron-barcode-value" i18n>Patron barcode</label>
+ </div>
+ <input type="text" id="patron-barcode-value" class="form-control" formControlName="patronBarcode">
+ <div class="input-group-button">
+ <button *ngIf="patronBarcode.value" class="btn btn-warning" (click)="removeFilters()" i18n><span class="material-icons">delete</span> Remove filter</button>
+ </div>
+ </div>
+ </div>
+ </ng-template>
+ </ngb-tab>
+ <ngb-tab id="resource">
+ <ng-template ngbTabTitle>
+ <span class="material-icons" *ngIf="resourceBarcode.value">filter_list</span> <span i18n>Filter by resource</span>
+ </ng-template>
+ <ng-template ngbTabContent>
+ <div class="m-2">
+ <div class="input-group m-2">
+ <div class="input-group-prepend">
+ <label class="input-group-text" for="resource-barcode-value" i18n>Resource barcode</label>
+ </div>
+ <input type="text" id="resource-barcode-value" class="form-control" formControlName="resourceBarcode">
+ <div class="input-group-button">
+ <button *ngIf="resourceBarcode.value" class="btn btn-warning" (click)="removeFilters()" i18n><span class="material-icons">delete</span> Remove filter</button>
+ </div>
+ </div>
+ </div>
+ </ng-template>
+ </ngb-tab>
+ <ngb-tab id="type">
+ <ng-template ngbTabTitle>
+ <span class="material-icons" *ngIf="resourceType.value">filter_list</span> <span i18n>Filter by resource type</span>
+ </ng-template>
+ <ng-template ngbTabContent>
+ <div class="m-2">
+ <div class="input-group m-2">
+ <div class="input-group-prepend">
+ <label class="input-group-text" for="resource-type-value" i18n>Resource type</label>
+ </div>
+ <eg-combobox domId="resource-type-value" formControlName="resourceType" idlClass="brt" [asyncSupportsEmptyTermClick]="true"></eg-combobox>
+ <div class="input-group-button">
+ <button class="btn btn-warning" (click)="removeFilters()" i18n><span class="material-icons">delete</span> Remove filter</button>
+ </div>
+ </div>
+ </div>
+ </ng-template>
+ </ngb-tab>
+ </ngb-tabset>
+ </div>
+ </div>
+</form>
+<eg-reservations-grid #reservationsGrid [patron]="patronId" [resourceBarcode]="resourceBarcode.value" [resourceType]="resourceTypeForGrid" [pickupLibIds]="pickupLibrariesForGrid" persistSuffix="manage"></eg-reservations-grid>
--- /dev/null
+import {Component, OnInit, ViewChild, OnDestroy} from '@angular/core';
+import {FormGroup, FormControl} from '@angular/forms';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {Subscription, of, from} from 'rxjs';
+import {debounceTime, single, tap, switchMap} from 'rxjs/operators';
+import {NgbTabset} from '@ng-bootstrap/ng-bootstrap';
+import {AuthService} from '@eg/core/auth.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {ReservationsGridComponent} from './reservations-grid.component';
+import {ServerStoreService} from '@eg/core/server-store.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {NetService} from '@eg/core/net.service';
+import {PatronBarcodeValidator} from '@eg/share/validators/patron_barcode_validator.directive';
+import {BookingResourceBarcodeValidator} from './booking_resource_validator.directive';
+import {OrgFamily} from '@eg/share/org-family-select/org-family-select.component';
+
+@Component({
+ selector: 'eg-manage-reservations',
+ templateUrl: './manage-reservations.component.html',
+})
+export class ManageReservationsComponent implements OnInit, OnDestroy {
+
+ patronId: number;
+ resourceId: number;
+ subscriptions: Subscription[] = [];
+ filters: FormGroup;
+ startingTab: 'patron' | 'resource' | 'type' = 'patron';
+ startingPickupOrgs: OrgFamily = {primaryOrgId: this.auth.user().ws_ou(), includeDescendants: true};
+
+ @ViewChild('filterTabs') filterTabs: NgbTabset;
+ @ViewChild('reservationsGrid') reservationsGrid: ReservationsGridComponent;
+
+ removeFilters: () => void;
+
+ constructor(
+ private route: ActivatedRoute,
+ private router: Router,
+ private auth: AuthService,
+ private net: NetService,
+ private pcrud: PcrudService,
+ private store: ServerStoreService,
+ private toast: ToastService,
+ private patronValidator: PatronBarcodeValidator,
+ private resourceValidator: BookingResourceBarcodeValidator
+ ) {
+ this.store.getItem('eg.booking.manage.selected_org_family').then((pickupLibs) => {
+ if (pickupLibs) {
+ this.startingPickupOrgs = pickupLibs;
+ }
+ });
+ }
+
+ ngOnInit() {
+ this.filters = new FormGroup({
+ 'pickupLibraries': new FormControl(this.startingPickupOrgs),
+ 'patronBarcode': new FormControl('', [], [this.patronValidator.validate]),
+ 'resourceBarcode': new FormControl('', [], [this.resourceValidator.validate]),
+ 'resourceType': new FormControl(null),
+ });
+
+ const debouncing = 300;
+
+ this.subscriptions.push(
+ this.pickupLibraries.valueChanges.pipe(
+ ).subscribe(() => this.reservationsGrid.reloadGrid()));
+
+ this.subscriptions.push(
+ this.patronBarcode.statusChanges.pipe(
+ debounceTime(debouncing),
+ switchMap((status) => {
+ if ('VALID' === status) {
+ return this.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.get_barcodes',
+ this.auth.token(), this.auth.user().ws_ou(),
+ 'actor', this.patronBarcode.value).pipe(
+ single(),
+ tap((response) =>
+ this.router.navigate(['/staff', 'booking', 'manage_reservations', 'by_patron', response[0].id])
+ ));
+ } else {
+ this.toast.danger('No patron found with this barcode');
+ return of();
+ }})
+ ).subscribe());
+
+ this.subscriptions.push(
+ this.resourceBarcode.statusChanges.pipe(
+ debounceTime(debouncing),
+ tap((status) => {
+ if ('VALID' === status) {
+ if (this.resourceBarcode.value) {
+ this.router.navigate(['/staff', 'booking', 'manage_reservations', 'by_resource', this.resourceBarcode.value]);
+ } else {
+ this.removeFilters();
+ }
+ }
+ }
+ )).subscribe());
+
+ this.subscriptions.push(
+ this.resourceType.valueChanges.pipe(
+ tap((value) => {
+ if (value) {
+ this.router.navigate(['/staff', 'booking', 'manage_reservations', 'by_resource_type', value.id]);
+ } else {
+ this.removeFilters();
+ }
+ }
+ )).subscribe());
+
+ this.subscriptions.push(
+ this.pickupLibraries.valueChanges.pipe(
+ tap((value) => this.store.setItem('eg.booking.manage.selected_org_family', value))
+ ).subscribe());
+
+ this.removeFilters = () => {
+ this.router.navigate(['/staff', 'booking', 'manage_reservations']);
+ };
+
+
+ this.route.paramMap.pipe(
+ switchMap((params: ParamMap) => {
+ this.patronId = params.has('patron_id') ? +params.get('patron_id') : null;
+ this.filters.patchValue({resourceBarcode: params.get('resource_barcode')}, {emitEvent: false});
+ this.filters.patchValue({resourceType: {id: +params.get('resource_type_id')}}, {emitEvent: false});
+
+ if (this.patronId) {
+ return this.pcrud.search('au', {
+ 'id': this.patronId,
+ }, {
+ limit: 1,
+ flesh: 1,
+ flesh_fields: {'au': ['card']}
+ }).pipe(tap(
+ (resp) => {
+ this.filters.patchValue({patronBarcode: resp.card().barcode()}); },
+ (err) => { console.debug(err); }
+ ));
+ } else if (this.resourceBarcode.value) {
+ this.startingTab = 'resource';
+ return this.pcrud.search('brsrc',
+ {'barcode' : this.resourceBarcode.value}, {'limit': 1}).pipe(
+ tap((res) => {
+ this.resourceId = res.id();
+ }, (err) => {
+ this.resourceId = -1;
+ this.toast.danger('No resource found with this barcode');
+ }));
+ } else if (this.resourceType.value) {
+ this.startingTab = 'type';
+ return of(null);
+ } else {
+ return of(null);
+ }
+
+ })).subscribe();
+ }
+
+ get pickupLibraries() {
+ return this.filters.get('pickupLibraries');
+ }
+ get patronBarcode() {
+ return this.filters.get('patronBarcode');
+ }
+ get resourceBarcode() {
+ return this.filters.get('resourceBarcode');
+ }
+ get resourceType() {
+ return this.filters.get('resourceType');
+ }
+ get pickupLibrariesForGrid() {
+ return this.pickupLibraries.value ?
+ this.pickupLibraries.value.orgIds :
+ [this.auth.user().ws_ou()];
+ }
+ get resourceTypeForGrid() {
+ return this.resourceType.value ? this.resourceType.value.id : null;
+ }
+
+ ngOnDestroy(): void {
+ this.subscriptions.forEach((subscription) => {
+ subscription.unsubscribe();
+ });
+ }
+
+}
+
--- /dev/null
+<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">×</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>
--- /dev/null
+import {Component} from '@angular/core';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+
+@Component({
+ selector: 'eg-no-timezone-set-dialog',
+ templateUrl: './no-timezone-set.component.html'
+})
+
+/**
+ * Dialog that warns users that there is no valid lib.timezone setting
+ */
+export class NoTimezoneSetComponent extends DialogComponent {
+ openLSE(): void {
+ window.open('/eg/staff/admin/local/asset/org_unit_settings', '_blank');
+ }
+}
--- /dev/null
+<eg-staff-banner bannerText="Booking Pickup" i18n-bannerText>
+</eg-staff-banner>
+<eg-title i18n-prefix i18n-suffix prefix="Booking" suffix="Pickup"></eg-title>
+
+<form [formGroup]="findPatron" class="row">
+ <div class="col-md-4">
+ <div class="input-group flex-nowrap">
+ <div class="input-group-prepend">
+ <label class="input-group-text" for="patron-barcode" i18n>Patron barcode</label>
+ <input type="text" id="patron-barcode" class="form-control" formControlName="patronBarcode">
+ </div>
+ </div>
+ </div>
+</form>
+<div *ngIf="patronId">
+ <h2 class="text-center" i18n>Ready for pickup</h2>
+ <div class="form-check">
+ <input class="form-check-input" type="checkbox" [checked]="onlyShowCaptured" id="only-show-captured" (change)="handleShowCapturedChange()">
+ <label class="form-check-label" for="only-show-captured" i18n>Show only captured resources</label>
+ </div>
+ <eg-reservations-grid #readyGrid [patron]="patronId" status="pickupReady" [onlyCaptured]="onlyShowCaptured" persistSuffix="pickup.ready" (onPickup)="this.pickedUpGrid.reloadGrid()"></eg-reservations-grid>
+
+ <h2 class="text-center mt-2" i18n>Already picked up</h2>
+ <eg-reservations-grid #pickedUpGrid [patron]="patronId" status="pickedUp" persistSuffix="pickup.picked_up"></eg-reservations-grid>
+
+</div>
+
--- /dev/null
+import {Component, OnInit, ViewChild, OnDestroy} from '@angular/core';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {Subscription, of} from 'rxjs';
+import {single, filter, switchMap, debounceTime, tap} from 'rxjs/operators';
+import {PatronService} from '@eg/staff/share/patron.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {IdlObject} from '@eg/core/idl.service';
+import {ReservationsGridComponent} from './reservations-grid.component';
+import {ServerStoreService} from '@eg/core/server-store.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {FormControl, FormGroup, Validators} from '@angular/forms';
+import {PatronBarcodeValidator} from '@eg/share/validators/patron_barcode_validator.directive';
+
+
+@Component({
+ templateUrl: './pickup.component.html'
+})
+
+export class PickupComponent implements OnInit, OnDestroy {
+ patronId: number;
+ findPatron: FormGroup;
+ subscriptions: Subscription[] = [];
+ onlyShowCaptured = true;
+
+ @ViewChild('readyGrid') readyGrid: ReservationsGridComponent;
+ @ViewChild('pickedUpGrid') pickedUpGrid: ReservationsGridComponent;
+
+ noSelectedRows: (rows: IdlObject[]) => boolean;
+ handleShowCapturedChange: () => void;
+ retrievePatron: () => void;
+
+ constructor(
+ private pcrud: PcrudService,
+ private patron: PatronService,
+ private pbv: PatronBarcodeValidator,
+ private route: ActivatedRoute,
+ private router: Router,
+ private store: ServerStoreService,
+ private toast: ToastService
+ ) {
+ }
+
+
+ ngOnInit() {
+ this.findPatron = new FormGroup({
+ 'patronBarcode': new FormControl(null,
+ [Validators.required],
+ [this.pbv.validate])
+ });
+
+ this.route.paramMap.pipe(
+ filter((params: ParamMap) => params.has('patron_id')),
+ switchMap((params: ParamMap) => {
+ this.patronId = +params.get('patron_id');
+ return this.pcrud.search('au', {
+ 'id': this.patronId,
+ }, {
+ limit: 1,
+ flesh: 1,
+ flesh_fields: {'au': ['card']}});
+ })
+ ).subscribe(
+ (response) => {
+ this.findPatron.patchValue({patronBarcode: response.card().barcode()}, {emitEvent: false});
+ this.readyGrid.reloadGrid();
+ this.pickedUpGrid.reloadGrid();
+ }
+ );
+
+ const debouncing = 1500;
+ this.subscriptions.push(
+ this.patronBarcode.valueChanges.pipe(
+ debounceTime(debouncing),
+ switchMap((val) => {
+ if ('INVALID' === this.patronBarcode.status) {
+ this.toast.danger('No patron found with this barcode');
+ return of();
+ } else {
+ return this.patron.bcSearch(val).pipe(
+ single(),
+ tap((resp) => { this.router.navigate(['/staff', 'booking', 'pickup', 'by_patron', resp[0].id]); })
+ );
+ }
+ })
+ )
+ .subscribe());
+
+
+ this.store.getItem('eg.booking.pickup.ready.only_show_captured').then(onlyCaptured => {
+ if (onlyCaptured != null) { this.onlyShowCaptured = onlyCaptured; }
+ });
+ this.handleShowCapturedChange = () => {
+ this.onlyShowCaptured = !this.onlyShowCaptured;
+ this.readyGrid.reloadGrid();
+ this.store.setItem('eg.booking.pickup.ready.only_show_captured', this.onlyShowCaptured);
+ };
+
+
+ }
+ get patronBarcode() {
+ return this.findPatron.get('patronBarcode');
+ }
+
+ ngOnDestroy(): void {
+ this.subscriptions.forEach((subscription) => {
+ subscription.unsubscribe();
+ });
+ }
+
+}
--- /dev/null
+<eg-staff-banner bannerText="Booking Pull List" i18n-bannerText>
+</eg-staff-banner>
+<eg-title i18n-prefix i18n-suffix prefix="Booking" suffix="Pull List"></eg-title>
+
+<form [formGroup]="pullListCriteria" class="row">
+ <div class="col-md-4">
+ <div class="input-group">
+ <div class="input-group-prepend">
+ <label for="ou" class="input-group-text" i18n>Library:</label>
+ </div>
+ <eg-org-select domId="ou" [applyDefault]="true"
+ (onChange)="fillGrid($event.id())"
+ [disableOrgs]="disableOrgs()" [hideOrgs]="disableOrgs()">
+ </eg-org-select>
+ </div>
+ </div>
+ <div class="col-md-4">
+ <div class="input-group">
+ <div class="input-group-prepend">
+ <label for="days-hence" class="input-group-text" i18n>Number of days to fetch:</label>
+ </div>
+ <input type="number" min="1" class="form-control" formControlName="daysHence">
+ </div>
+ </div>
+</form>
+<eg-grid [dataSource]="dataSource" [useLocalSort]="true" #pullList
+ [sortable]="true" persistKey="booking.pull_list">
+ <eg-grid-toolbar-action label="Cancel Selected" i18n-label (onClick)="cancelSelected($event)" [disableOnRows]="noSelectedRows"></eg-grid-toolbar-action>
+ <eg-grid-toolbar-action label="View Item Status" i18n-label (onClick)="viewItemStatus($event)" [disableOnRows]="notOneCatalogedItemSelected"></eg-grid-toolbar-action>
+ <eg-grid-toolbar-action label="View Reservations for This Resource" i18n-label (onClick)="viewByResource($event)" [disableOnRows]="notOneResourceSelected"></eg-grid-toolbar-action>
+ <eg-grid-toolbar-action label="Print Pull List" i18n-label (onClick)="pullList.print()"></eg-grid-toolbar-action>
+
+ <eg-grid-column name="id" [hidden]="true" [index]="true" i18n-label label="ID" path="current_resource.id"></eg-grid-column>
+ <eg-grid-column label="Shelving location" path="shelving_location" i18n-label></eg-grid-column>
+ <eg-grid-column label="Call number" path="call_number" i18n-label></eg-grid-column>
+ <eg-grid-column label="Call number sortkey" path="call_number_sortkey" i18n-label></eg-grid-column>
+ <eg-grid-column name="barcode" label="Barcode" i18n-label path="current_resource.barcode"></eg-grid-column>
+ <eg-grid-column name="title" label="Title or name" i18n-label path="target_resource_type.name"></eg-grid-column>
+ <eg-grid-column label="Reservation start time" [datePlusTime]="true" path="reservations.0.start_time" i18n-label></eg-grid-column>
+ <eg-grid-column label="Reservation end time" [datePlusTime]="true" path="reservations.0.end_time" i18n-label></eg-grid-column>
+ <eg-grid-column label="Patron first name" path="reservations.0.usr.first_given_name" i18n-label></eg-grid-column>
+ <eg-grid-column label="Patron last name" path="reservations.0.usr.family_name" i18n-label></eg-grid-column>
+</eg-grid>
+
+<eg-cancel-reservation-dialog #confirmCancelReservationDialog
+ (onSuccessfulCancel)="fillGrid()">
+</eg-cancel-reservation-dialog>
--- /dev/null
+import {Component, OnInit, ViewChild} from '@angular/core';
+import {FormControl, FormGroup, Validators} from '@angular/forms';
+import {from, Observable, of} from 'rxjs';
+import {switchMap} from 'rxjs/operators';
+import {AuthService} from '@eg/core/auth.service';
+import {GridDataSource} from '@eg/share/grid/grid';
+import {IdlObject} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {OrgService} from '@eg/core/org.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {ReservationActionsService} from './reservation-actions.service';
+import {CancelReservationDialogComponent} from './cancel-reservation-dialog.component';
+
+// The data that comes from the API, along with some fleshing
+interface PullListRow {
+ call_number?: string;
+ call_number_sortkey?: string;
+ current_resource: IdlObject;
+ reservations: IdlObject[];
+ shelving_location?: string;
+ target_resource_type: IdlObject;
+}
+
+@Component({
+ templateUrl: './pull-list.component.html'
+})
+
+export class PullListComponent implements OnInit {
+ @ViewChild('confirmCancelReservationDialog')
+ private cancelReservationDialog: CancelReservationDialogComponent;
+
+ public dataSource: GridDataSource;
+
+ public disableOrgs: () => number[];
+ public fillGrid: (orgId?: number) => void;
+ pullListCriteria: FormGroup;
+
+ constructor(
+ private auth: AuthService,
+ private net: NetService,
+ private org: OrgService,
+ private pcrud: PcrudService,
+ private actions: ReservationActionsService,
+ ) { }
+
+
+ ngOnInit() {
+ this.dataSource = new GridDataSource();
+
+ const defaultDaysHence = 5;
+
+ this.pullListCriteria = new FormGroup({
+ 'daysHence': new FormControl(defaultDaysHence, [
+ Validators.required,
+ Validators.min(1)])
+ });
+
+ this.pullListCriteria.valueChanges.subscribe(() => this.fillGrid() );
+
+ this.disableOrgs = () => this.org.filterList( { canHaveVolumes : false }, true);
+
+ this.fillGrid = (orgId = this.auth.user().ws_ou()) => {
+ this.dataSource.data = [];
+ const numberOfSecondsInADay = 86400;
+ this.net.request(
+ 'open-ils.booking', 'open-ils.booking.reservations.get_pull_list',
+ this.auth.token(), null,
+ (this.daysHence.value * numberOfSecondsInADay),
+ orgId
+ ).pipe(switchMap((resources) => from(resources)),
+ switchMap((resource: PullListRow) => this.fleshResource(resource))
+ )
+ .subscribe((resource) => this.dataSource.data.push(resource));
+ };
+ }
+
+ noSelectedRows = (rows: IdlObject[]) => (rows.length === 0);
+
+ notOneResourceSelected = (rows: IdlObject[]) => {
+ return this.actions.notOneUniqueSelected(
+ rows.map(row => { if (row['current_resource']) { return row['current_resource']['id']; }}));
+ }
+
+ notOneCatalogedItemSelected = (rows: IdlObject[]) => {
+ return this.actions.notOneUniqueSelected(
+ rows.filter(row => (row['current_resource'] && row['call_number']))
+ .map(row => row['current_resource'].id())
+ );
+ }
+
+ cancelSelected = (rows: IdlObject[]) => {
+ this.cancelReservationDialog.open(rows.map(row => row['reservations'][0].id()));
+ }
+
+ fleshResource = (resource: PullListRow): Observable<PullListRow> => {
+ if ('t' === resource['target_resource_type'].catalog_item()) {
+ return this.pcrud.search('acp', {
+ 'barcode': resource['current_resource'].barcode()
+ }, {
+ limit: 1,
+ flesh: 1,
+ flesh_fields: {'acp' : ['call_number', 'location' ]}
+ }).pipe(switchMap((acp) => {
+ resource['call_number'] = acp.call_number().label();
+ resource['call_number_sortkey'] = acp.call_number().label_sortkey();
+ resource['shelving_location'] = acp.location().name();
+ return of(resource);
+ }));
+ } else {
+ return of(resource);
+ }
+ }
+
+ viewByResource = (reservations: IdlObject[]) => {
+ this.actions.manageReservationsByResource(reservations[0]['current_resource'].barcode());
+ }
+
+ viewItemStatus = (reservations: IdlObject[]) => {
+ this.actions.viewItemStatus(reservations[0]['current_resource'].barcode());
+ }
+
+ get daysHence() {
+ return this.pullListCriteria.get('daysHence');
+ }
+
+}
+
--- /dev/null
+import {Injectable} from '@angular/core';
+import {Router} from '@angular/router';
+import {PcrudService} from '@eg/core/pcrud.service';
+
+// Some grid actions that are shared across booking grids
+
+@Injectable({providedIn: 'root'})
+export class ReservationActionsService {
+
+ constructor(
+ private pcrud: PcrudService,
+ private router: Router,
+ ) {
+ }
+
+ manageReservationsByResource = (barcode: string) => {
+ this.router.navigate(['/staff', 'booking', 'manage_reservations', 'by_resource', barcode]);
+ }
+
+ viewItemStatus = (barcode: string) => {
+ this.pcrud.search('acp', { 'barcode': barcode }, { limit: 1 })
+ .subscribe((acp) => {
+ window.open('/eg/staff/cat/item/' + acp.id());
+ });
+ }
+
+ notOneUniqueSelected = (ids: number[]) => {
+ return (new Set(ids).size !== 1);
+ }
+
+}
+
--- /dev/null
+import { TestBed } from '@angular/core/testing';
+import { Router } from '@angular/router';
+import { PcrudService } from '@eg/core/pcrud.service';
+import { ReservationActionsService } from './reservation-actions.service';
+describe('ReservationActionsService', () => {
+ let service: ReservationActionsService;
+ const routerSpy = {
+ navigate: jasmine.createSpy('navigate')
+ };
+ beforeEach(() => {
+ const pcrudServiceStub = {};
+ TestBed.configureTestingModule({
+ providers: [
+ ReservationActionsService,
+ { provide: Router, useValue: routerSpy },
+ { provide: PcrudService, useValue: pcrudServiceStub }
+ ]
+ });
+ service = TestBed.get(ReservationActionsService);
+ });
+ it('can open the manage by barcode route', () => {
+ service.manageReservationsByResource('barcode123');
+ expect(routerSpy.navigate).toHaveBeenCalledWith(
+ ['/staff', 'booking', 'manage_reservations', 'by_resource', 'barcode123']);
+ });
+ it('recognizes 3 as one unique value', () => {
+ expect(service.notOneUniqueSelected([3])).toBe(false);
+ });
+ it('recognizes 1 1 as one unique value', () => {
+ expect(service.notOneUniqueSelected([1, 1])).toBe(false);
+ });
+ it('recognizes 2 3 as more than one unique value', () => {
+ expect(service.notOneUniqueSelected([2, 3])).toBe(true);
+ });
+});
--- /dev/null
+<eg-grid #grid [dataSource]="gridSource"
+ (onRowActivate)="handleRowActivate($event)"
+ [sortable]="true"
+ [useLocalSort]="true"
+ persistKey="booking.{{persistSuffix}}" >
+ <eg-grid-toolbar-action label="Edit Selected" i18n-label (onClick)="editSelected($event)" [disableOnRows]="editNotAppropriate"></eg-grid-toolbar-action>
+ <eg-grid-toolbar-action label="Cancel Selected" i18n-label (onClick)="cancelSelected($event)" [disableOnRows]="cancelNotAppropriate"></eg-grid-toolbar-action>
+ <eg-grid-toolbar-action label="Pick Up Selected" i18n-label (onClick)="pickupSelected($event)" [disableOnRows]="pickupNotAppropriate"></eg-grid-toolbar-action>
+ <eg-grid-toolbar-action label="Return Selected" i18n-label (onClick)="returnSelected($event)" [disableOnRows]="returnNotAppropriate"></eg-grid-toolbar-action>
+ <eg-grid-toolbar-action label="View Patron Record" i18n-label (onClick)="viewPatronRecord($event)" [disableOnRows]="notOnePatronSelected"></eg-grid-toolbar-action>
+ <eg-grid-toolbar-action label="View Reservations for This Patron" i18n-label (onClick)="viewByPatron($event)" [disableOnRows]="notOnePatronSelected"></eg-grid-toolbar-action>
+ <eg-grid-toolbar-action label="View Item Status" i18n-label (onClick)="viewItemStatus($event)" [disableOnRows]="notOneCatalogedItemSelected"></eg-grid-toolbar-action>
+ <eg-grid-toolbar-action label="View Reservations for This Resource" i18n-label (onClick)="viewByResource($event)" [disableOnRows]="notOneResourceSelected"></eg-grid-toolbar-action>
+ <eg-grid-toolbar-button *ngIf="!status" label="Create New Reservation" i18n-label (onClick)="redirectToCreate($event)"></eg-grid-toolbar-button>
+
+ <eg-grid-column name="id" [hidden]="true" [index]="true" i18n-label label="ID" path="id"></eg-grid-column>
+ <eg-grid-column label="Patron username" [hidden]="true" i18n-label path="usr.usrname"></eg-grid-column>
+ <eg-grid-column label="Patron barcode" i18n-label path="usr.card.barcode"></eg-grid-column>
+ <eg-grid-column label="Patron first name" i18n-label path="usr.first_given_name"></eg-grid-column>
+ <eg-grid-column label="Patron middle name" i18n-label [hidden]="true" path="usr.second_given_name"></eg-grid-column>
+ <eg-grid-column label="Patron family name" i18n-label path="usr.family_name"></eg-grid-column>
+ <eg-grid-column name="start_time" label="Start Time" [datePlusTime]="true" i18n-label path="start_time" datatype="timestamp"></eg-grid-column>
+ <eg-grid-column name="end_time" label="End Time" [datePlusTime]="true" i18n-label path="end_time" datatype="timestamp"></eg-grid-column>
+ <eg-grid-column name="request_time" label="Request Time" [datePlusTime]="true" i18n-label path="request_time" datatype="timestamp"></eg-grid-column>
+ <eg-grid-column name="capture_time" label="Capture Time" [datePlusTime]="true" i18n-label path="capture_time" datatype="timestamp"></eg-grid-column>
+ <eg-grid-column name="pickup_time" label="Pickup Time" [datePlusTime]="true" i18n-label path="pickup_time" datatype="timestamp"></eg-grid-column>
+ <eg-grid-column label="Email notify" i18n-label [hidden]="true" path="email_notify" datatype="bool"></eg-grid-column>
+ <eg-grid-column i18n-label [hidden]="true" path="unrecovered" datatype="bool"></eg-grid-column>
+ <eg-grid-column label="Billing total" i18n-label path="billing_total" datatype="money"></eg-grid-column>
+ <eg-grid-column label="Payment total" i18n-label path="payment_total" datatype="money"></eg-grid-column>
+ <eg-grid-column label="Booking interval" i18n-label [hidden]="true" path="booking_interval" [hidden]="true"></eg-grid-column>
+ <eg-grid-column label="Fine interval" i18n-label [hidden]="true" path="fine_interval" [hidden]="true"></eg-grid-column>
+ <eg-grid-column label="Fine amount" i18n-label [hidden]="true" path="fine_amount" datatype="money"></eg-grid-column>
+ <eg-grid-column label="Maximum fine" i18n-label [hidden]="true" path="max_fine" datatype="money"></eg-grid-column>
+ <eg-grid-column i18n-label label="Resource Barcode" path="current_resource.barcode"></eg-grid-column>
+ <eg-grid-column i18n-label label="Note" path="note"></eg-grid-column>
+ <eg-grid-column i18n-label label="Resource Type" path="target_resource_type.name"></eg-grid-column>
+ <eg-grid-column label="Reservation length" i18n-label path="length"></eg-grid-column>
+ <eg-grid-column label="Request library" i18n-label path="request_lib.name"></eg-grid-column>
+ <eg-grid-column label="Pickup library" i18n-label path="pickup_lib.name"></eg-grid-column>
+ <eg-grid-column label="Pickup library timezone" i18n-label path="timezone"></eg-grid-column>
+
+</eg-grid>
+
+<eg-fm-record-editor #editDialog
+ idlClass="bresv"
+ datetimeFields="start_time,end_time"
+ [fieldOptions]="{end_time:{customTemplate:{template:endTimeTemplate}}}"
+ hiddenFields="xact_start,xact_finish,cancel_time,booking_interval"
+ [readonlyFields]="listReadOnlyFields()">
+</eg-fm-record-editor>
+<eg-cancel-reservation-dialog #confirmCancelReservationDialog
+ (onSuccessfulCancel)="grid.reload()">
+</eg-cancel-reservation-dialog>
+<eg-no-timezone-set-dialog #noTimezoneSetDialog>
+</eg-no-timezone-set-dialog>
+
+<ng-template #endTimeTemplate let-field="field" let-record="record">
+ <eg-datetime-select
+ domId="endTime"
+ ngModel
+ [showTZ]="editDialog.timezone"
+ [timezone]="editDialog.timezone"
+ [egNotBeforeMoment]="momentizeIsoString(record['start_time'](), editDialog.timezone)"
+ [readOnly]="field.readOnly"
+ (onChangeAsIso)="record[field.name]($event)"
+ initialIso="{{record[field.name]()}}">
+ </eg-datetime-select>
+</ng-template>
--- /dev/null
+import {Component, EventEmitter, Input, Output, OnInit, ViewChild} from '@angular/core';
+import {Router} from '@angular/router';
+import {Observable, from, of} from 'rxjs';
+import {tap, switchMap, mergeMap} from 'rxjs/operators';
+import {AuthService} from '@eg/core/auth.service';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {FormatService} from '@eg/core/format.service';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {GridDataSource} from '@eg/share/grid/grid';
+import {IdlObject} from '@eg/core/idl.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {Pager} from '@eg/share/util/pager';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {NetService} from '@eg/core/net.service';
+import {OrgService} from '@eg/core/org.service';
+import {NoTimezoneSetComponent} from './no-timezone-set.component';
+import {ReservationActionsService} from './reservation-actions.service';
+import {CancelReservationDialogComponent} from './cancel-reservation-dialog.component';
+
+import * as Moment from 'moment-timezone';
+
+@Component({
+ selector: 'eg-reservations-grid',
+ templateUrl: './reservations-grid.component.html',
+})
+export class ReservationsGridComponent implements OnInit {
+
+ @Input() patron: number;
+ @Input() resourceBarcode: string;
+ @Input() resourceType: number;
+ @Input() pickupLibIds: number[];
+ @Input() status: 'pickupReady' | 'pickedUp' | 'returnReady' | 'returnedToday';
+ @Input() persistSuffix: string;
+ @Input() onlyCaptured = false;
+
+ @Output() onPickup = new EventEmitter<IdlObject>();
+
+ gridSource: GridDataSource;
+ patronBarcode: string;
+ numRowsSelected: number;
+
+ @ViewChild('grid') grid: GridComponent;
+ @ViewChild('editDialog') editDialog: FmRecordEditorComponent;
+ @ViewChild('confirmCancelReservationDialog')
+ private cancelReservationDialog: CancelReservationDialogComponent;
+ @ViewChild('noTimezoneSetDialog') noTimezoneSetDialog: NoTimezoneSetComponent;
+
+ editSelected: (rows: IdlObject[]) => void;
+ pickupSelected: (rows: IdlObject[]) => void;
+ pickupResource: (rows: IdlObject) => Observable<any>;
+ returnSelected: (rows: IdlObject[]) => void;
+ returnResource: (rows: IdlObject) => Observable<any>;
+ cancelSelected: (rows: IdlObject[]) => void;
+ viewByPatron: (rows: IdlObject[]) => void;
+ viewByResource: (rows: IdlObject[]) => void;
+ viewItemStatus: (rows: IdlObject[]) => void;
+ viewPatronRecord: (rows: IdlObject[]) => void;
+ listReadOnlyFields: () => string;
+
+ handleRowActivate: (row: IdlObject) => void;
+ redirectToCreate: () => void;
+
+ reloadGrid: () => void;
+
+ noSelectedRows: (rows: IdlObject[]) => boolean;
+ notOnePatronSelected: (rows: IdlObject[]) => boolean;
+ notOneResourceSelected: (rows: IdlObject[]) => boolean;
+ notOneCatalogedItemSelected: (rows: IdlObject[]) => boolean;
+ cancelNotAppropriate: (rows: IdlObject[]) => boolean;
+ pickupNotAppropriate: (rows: IdlObject[]) => boolean;
+ editNotAppropriate: (rows: IdlObject[]) => boolean;
+ returnNotAppropriate: (rows: IdlObject[]) => boolean;
+
+ constructor(
+ private auth: AuthService,
+ private format: FormatService,
+ private pcrud: PcrudService,
+ private router: Router,
+ private toast: ToastService,
+ private net: NetService,
+ private org: OrgService,
+ private actions: ReservationActionsService,
+ ) {
+
+ }
+
+ ngOnInit() {
+ if (!(this.format.wsOrgTimezone)) {
+ this.noTimezoneSetDialog.open();
+ }
+
+ this.gridSource = new GridDataSource();
+
+ this.gridSource.getRows = (pager: Pager, sort: any[]): Observable<IdlObject> => {
+ const orderBy: any = {};
+ const where = {
+ 'usr' : (this.patron ? this.patron : {'>' : 0}),
+ 'target_resource_type' : (this.resourceType ? this.resourceType : {'>' : 0}),
+ 'cancel_time' : null,
+ 'xact_finish' : null,
+ };
+ if (this.resourceBarcode) {
+ where['current_resource'] = {'in':
+ {'from': 'brsrc', 'select': {'brsrc': ['id']}, 'where': {'barcode': this.resourceBarcode}}};
+ }
+ if (this.pickupLibIds) {
+ where['pickup_lib'] = this.pickupLibIds;
+ }
+ if (this.onlyCaptured) {
+ where['capture_time'] = {'!=': null};
+ }
+
+ if (this.status) {
+ if ('pickupReady' === this.status) {
+ where['pickup_time'] = null;
+ where['start_time'] = {'!=': null};
+ } else if ('pickedUp' === this.status || 'returnReady' === this.status) {
+ where['pickup_time'] = {'!=': null};
+ where['return_time'] = null;
+ } else if ('returnedToday' === this.status) {
+ where['return_time'] = {'>': Moment().startOf('day').toISOString()};
+ }
+ } else {
+ where['return_time'] = null;
+ }
+ if (sort.length) {
+ orderBy.bresv = sort[0].name + ' ' + sort[0].dir;
+ }
+ return this.pcrud.search('bresv', where, {
+ order_by: orderBy,
+ limit: pager.limit,
+ offset: pager.offset,
+ flesh: 2,
+ flesh_fields: {'bresv' : [
+ 'usr', 'capture_staff', 'target_resource', 'target_resource_type', 'current_resource', 'request_lib', 'pickup_lib'
+ ], 'au': ['card'] }
+ }).pipe(mergeMap((row) => this.enrichRow$(row)));
+ };
+
+ this.editDialog.mode = 'update';
+ this.editSelected = (idlThings: IdlObject[]) => {
+ const editOneThing = (thing: IdlObject) => {
+ if (!thing) { return; }
+ this.showEditDialog(thing).subscribe(
+ () => editOneThing(idlThings.shift()));
+ };
+ editOneThing(idlThings.shift()); };
+
+ this.cancelSelected = (reservations: IdlObject[]) => {
+ this.cancelReservationDialog.open(reservations.map(reservation => reservation.id()));
+ };
+
+ this.viewByResource = (reservations: IdlObject[]) => {
+ this.actions.manageReservationsByResource(reservations[0].current_resource().barcode());
+ };
+
+ this.viewByPatron = (reservations: IdlObject[]) => {
+ const patronIds = reservations.map(reservation => reservation.usr().id());
+ this.router.navigate(['/staff', 'booking', 'manage_reservations', 'by_patron', patronIds[0]]);
+ };
+
+ this.viewItemStatus = (reservations: IdlObject[]) => {
+ this.actions.viewItemStatus(reservations[0].current_resource().barcode());
+ };
+
+ this.viewPatronRecord = (reservations: IdlObject[]) => {
+ const patronIds = reservations.map(reservation => reservation.usr().id());
+ window.open('/eg/staff/circ/patron/' + patronIds[0] + '/checkout');
+ };
+
+ this.noSelectedRows = (rows: IdlObject[]) => (rows.length === 0);
+ this.notOnePatronSelected = (rows: IdlObject[]) => this.actions.notOneUniqueSelected(rows.map(row => row.usr().id()));
+ this.notOneResourceSelected = (rows: IdlObject[]) => {
+ return this.actions.notOneUniqueSelected(
+ rows.map(row => { if (row.current_resource()) { return row.current_resource().id(); }}));
+ };
+ this.notOneCatalogedItemSelected = (rows: IdlObject[]) => {
+ return this.actions.notOneUniqueSelected(
+ rows.filter(row => (row.current_resource() && 't' === row.target_resource_type().catalog_item()))
+ .map(row => row.current_resource().id())
+ );
+ };
+ this.cancelNotAppropriate = (rows: IdlObject[]) =>
+ (this.noSelectedRows(rows) || ['pickedUp', 'returnReady', 'returnedToday'].includes(this.status));
+ this.pickupNotAppropriate = (rows: IdlObject[]) => (this.noSelectedRows(rows) || ('pickupReady' !== this.status));
+ this.editNotAppropriate = (rows: IdlObject[]) => (this.noSelectedRows(rows) || ('returnedToday' === this.status));
+ this.returnNotAppropriate = (rows: IdlObject[]) => {
+ if (this.noSelectedRows(rows)) {
+ return true;
+ } else if (this.status && ('pickupReady' === this.status)) {
+ return true;
+ } else {
+ rows.forEach(row => {
+ if ((null == row.pickup_time()) || row.return_time()) { return true; }
+ });
+ }
+ return false;
+ };
+
+ this.reloadGrid = () => { this.grid.reload(); };
+
+ this.pickupSelected = (reservations: IdlObject[]) => {
+ const pickupOne = (thing: IdlObject) => {
+ if (!thing) { return; }
+ this.pickupResource(thing).subscribe(
+ () => pickupOne(reservations.shift()));
+ };
+ pickupOne(reservations.shift());
+ };
+
+ this.returnSelected = (reservations: IdlObject[]) => {
+ const returnOne = (thing: IdlObject) => {
+ if (!thing) { return; }
+ this.returnResource(thing).subscribe(
+ () => returnOne(reservations.shift()));
+ };
+ returnOne(reservations.shift());
+ };
+
+ this.pickupResource = (reservation: IdlObject) => {
+ return this.net.request(
+ 'open-ils.circ',
+ 'open-ils.circ.reservation.pickup',
+ this.auth.token(),
+ {'patron_barcode': reservation.usr().card().barcode(), 'reservation': reservation})
+ .pipe(tap(
+ () => {
+ this.onPickup.emit(reservation);
+ this.grid.reload(); },
+ ));
+ };
+
+ this.returnResource = (reservation: IdlObject) => {
+ return this.net.request(
+ 'open-ils.circ',
+ 'open-ils.circ.reservation.return',
+ this.auth.token(),
+ {'patron_barcode': this.patronBarcode, 'reservation': reservation})
+ .pipe(tap(
+ () => { this.grid.reload(); },
+ ));
+ };
+
+ this.listReadOnlyFields = () => {
+ let list = 'usr,xact_start,request_time,capture_time,pickup_time,return_time,capture_staff,target_resource_type,' +
+ 'current_resource,target_resource,unrecovered,request_library,pickup_library,fine_interval,fine_amount,max_fine';
+ if (this.status && ('pickupReady' !== this.status)) { list = list + ',start_time'; }
+ if (this.status && ('returnedToday' === this.status)) { list = list + ',end_time'; }
+ return list;
+ };
+
+ this.handleRowActivate = (row: IdlObject) => {
+ if (this.status) {
+ if ('returnReady' === this.status) {
+ this.returnResource(row).subscribe();
+ } else if ('pickupReady' === this.status) {
+ this.pickupResource(row).subscribe();
+ } else if ('returnedToday' === this.status) {
+ this.toast.warning('Cannot edit this reservation');
+ } else {
+ this.showEditDialog(row);
+ }
+ } else {
+ this.showEditDialog(row);
+ }
+ };
+
+ this.redirectToCreate = () => {
+ this.router.navigate(['/staff', 'booking', 'create_reservation']);
+ };
+ }
+
+ enrichRow$ = (row: IdlObject): Observable<IdlObject> => {
+ return from(this.org.settings('lib.timezone', row.pickup_lib().id())).pipe(
+ switchMap((tz) => {
+ row['length'] = Moment(row['end_time']()).from(Moment(row['start_time']()), true);
+ row['timezone'] = tz['lib.timezone'];
+ return of(row);
+ })
+ );
+ }
+
+ showEditDialog(idlThing: IdlObject) {
+ this.editDialog.recId = idlThing.id();
+ this.editDialog.timezone = idlThing['timezone'];
+ return this.editDialog.open({size: 'lg'}).pipe(tap(
+ () => {
+ this.toast.success('Reservation successfully updated'); // TODO: needs i18n, pluralization
+ this.grid.reload();
+ }
+ ));
+ }
+
+ filterByResourceBarcode(barcode: string) {
+ this.router.navigate(['/staff', 'booking', 'manage_reservations', 'by_resource', barcode]);
+ }
+
+ momentizeIsoString(isoString: string, timezone: string): Moment {
+ return this.format.momentizeIsoString(isoString, timezone);
+ }
+}
+
--- /dev/null
+<eg-staff-banner bannerText="Booking Return" i18n-bannerText>
+</eg-staff-banner>
+<eg-title i18n-prefix i18n-suffix prefix="Booking" suffix="Return"></eg-title>
+
+<form [formGroup]="findPatron">
+ <ngb-tabset (tabChange)="handleTabChange($event)" activeId="patron" #tabs>
+ <ngb-tab title="By patron" i18n-title id="patron">
+ <ng-template ngbTabContent>
+ <div class="row">
+ <div class="col-md-4">
+ <div class="input-group flex-nowrap">
+ <div class="input-group-prepend">
+ <label class="input-group-text" for="patron-barcode" i18n>Patron barcode</label>
+ <input type="text" id="patron-barcode" class="form-control" i18n-placeholder placeholder="Patron barcode" formControlName="patronBarcode">
+ </div>
+ </div>
+ </div>
+ </div>
+ <div *ngIf="patronId">
+ <h2 class="text-center" i18n>Ready for return</h2>
+ <eg-reservations-grid #readyGrid [patron]="patronId" status="returnReady" (onReturn)="refreshGrids()" persistSuffix="return.patron.picked_up"></eg-reservations-grid>
+
+ <h2 class="text-center" i18n>Returned today</h2>
+ <eg-reservations-grid #returnedGrid [patron]="patronId" status="returnedToday" persistSuffix="return.patron.returned"></eg-reservations-grid>
+ </div>
+ </ng-template>
+ </ngb-tab>
+ <ngb-tab title="By resource" i18n-title id="resource">
+ <ng-template ngbTabContent>
+ <div class="input-group flex-nowrap">
+ <div class="input-group-prepend">
+ <label class="input-group-text" for="resource-barcode" i18n>Resource barcode</label>
+ <input type="text" id="resource-barcode" class="form-control" i18n-placeholder placeholder="Resource barcode" formControlName="resourceBarcode">
+ </div>
+ </div>
+ <div *ngIf="patronId">
+ <h2 class="text-center" i18n>Ready for return</h2>
+ <eg-reservations-grid #readyGrid [patron]="patronId" status="returnReady" (onReturn)="this.returnedGrid.reloadGrid()" persistSuffix="return.resource.picked_up"></eg-reservations-grid>
+
+ <h2 class="text-center" i18n>Returned today</h2>
+ <eg-reservations-grid #returnedGrid [patron]="patronId" status="returnedToday" persistSuffix="return.resource.returned"></eg-reservations-grid>
+ </div>
+ </ng-template>
+ </ngb-tab>
+ </ngb-tabset>
+</form>
--- /dev/null
+import {Component, OnInit, OnDestroy, QueryList, ViewChildren, ViewChild} from '@angular/core';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {FormGroup, FormControl, Validators} from '@angular/forms';
+import {NgbTabChangeEvent, NgbTabset} from '@ng-bootstrap/ng-bootstrap';
+import {Observable, from, of, Subscription} from 'rxjs';
+import { single, switchMap, tap, debounceTime } from 'rxjs/operators';
+import {PatronService} from '@eg/staff/share/patron.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {IdlObject} from '@eg/core/idl.service';
+import {ReservationsGridComponent} from './reservations-grid.component';
+import {ServerStoreService} from '@eg/core/server-store.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {PatronBarcodeValidator} from '@eg/share/validators/patron_barcode_validator.directive';
+
+
+@Component({
+ templateUrl: './return.component.html'
+})
+
+export class ReturnComponent implements OnInit, OnDestroy {
+ patronId: number;
+ findPatron: FormGroup;
+ subscriptions: Subscription[] = [];
+
+ noSelectedRows: (rows: IdlObject[]) => boolean;
+ handleTabChange: ($event: NgbTabChangeEvent) => void;
+ @ViewChild('tabs') tabs: NgbTabset;
+ @ViewChildren(ReservationsGridComponent) grids: QueryList<ReservationsGridComponent>;
+
+ constructor(
+ private pcrud: PcrudService,
+ private patron: PatronService,
+ private pbv: PatronBarcodeValidator,
+ private route: ActivatedRoute,
+ private router: Router,
+ private store: ServerStoreService,
+ private toast: ToastService
+ ) {
+ }
+
+
+ ngOnInit() {
+ this.route.paramMap.pipe(switchMap((params: ParamMap) => {
+ return this.handleParams$(params);
+ })).subscribe();
+
+ this.findPatron = new FormGroup({
+ 'patronBarcode': new FormControl(null,
+ [Validators.required],
+ [this.pbv.validate]),
+ 'resourceBarcode': new FormControl(null,
+ [Validators.required])
+ });
+
+ const debouncing = 1500;
+ this.subscriptions.push(
+ this.patronBarcode.valueChanges.pipe(
+ debounceTime(debouncing),
+ switchMap((val) => {
+ if ('INVALID' === this.patronBarcode.status) {
+ this.toast.danger('No patron found with this barcode');
+ return of();
+ } else {
+ return this.patron.bcSearch(val).pipe(
+ single(),
+ tap((resp) => { this.router.navigate(['/staff', 'booking', 'return', 'by_patron', resp[0].id]); })
+ );
+ }
+ })
+ )
+ .subscribe());
+
+ this.subscriptions.push(
+ this.resourceBarcode.valueChanges.pipe(
+ debounceTime(debouncing),
+ switchMap((val) => {
+ if ('INVALID' !== this.resourceBarcode.status) {
+ return this.pcrud.search('brsrc', {'barcode': val}, {
+ order_by: {'curr_rsrcs': 'pickup_time DESC'},
+ limit: 1,
+ flesh: 1,
+ flesh_fields: {'brsrc': ['curr_rsrcs']},
+ select: {'curr_rsrcs': {'return_time': null, 'pickup_time': {'!=': null}}}
+ }).pipe(tap((resp) => {
+ if (resp.curr_rsrcs()[0].usr()) {
+ this.patronId = resp.curr_rsrcs()[0].usr();
+ this.refreshGrids();
+ }
+ }));
+ } else {
+ return of();
+ }
+ })
+ ).subscribe()
+ );
+ this.noSelectedRows = (rows: IdlObject[]) => (rows.length === 0);
+
+ this.handleTabChange = ($event) => {
+ this.store.setItem('eg.booking.return.tab', $event.nextId)
+ .then(() => {
+ this.router.navigate(['/staff', 'booking', 'return']);
+ this.findPatron.patchValue({resourceBarcode: ''});
+ this.patronId = null;
+ });
+ };
+ }
+
+ handleParams$ = (params: ParamMap): Observable<any> => {
+ this.patronId = +params.get('patron_id');
+ if (this.patronId) {
+ return this.pcrud.search('au', {
+ 'id': this.patronId,
+ }, {
+ limit: 1,
+ flesh: 1,
+ flesh_fields: {'au': ['card']}
+ }).pipe(tap(
+ (resp) => {
+ this.findPatron.patchValue({patronBarcode: resp.card().barcode()});
+ this.refreshGrids();
+ }, (err) => { console.debug(err); }
+ ));
+ } else {
+ return from(this.store.getItem('eg.booking.return.tab'))
+ .pipe(tap(tab => {
+ if (tab) { this.tabs.select(tab); }
+ }));
+ }
+ }
+ refreshGrids = (): void => {
+ this.grids.forEach (grid => grid.reloadGrid());
+ }
+ get patronBarcode() {
+ return this.findPatron.get('patronBarcode');
+ }
+ get resourceBarcode() {
+ return this.findPatron.get('resourceBarcode');
+ }
+
+ ngOnDestroy(): void {
+ this.subscriptions.forEach((subscription) => {
+ subscription.unsubscribe();
+ });
+ }
+}
--- /dev/null
+import {NgModule} from '@angular/core';
+import {RouterModule, Routes} from '@angular/router';
+import {CreateReservationComponent} from './create-reservation.component';
+import {ManageReservationsComponent} from './manage-reservations.component';
+import {PickupComponent} from './pickup.component';
+import {PullListComponent} from './pull-list.component';
+import {ReturnComponent} from './return.component';
+
+const routes: Routes = [{
+ path: 'create_reservation',
+ children: [
+ {path: '', component: CreateReservationComponent},
+ {path: 'for_patron/:patron_id', component: CreateReservationComponent},
+ {path: 'for_resource/:resource_barcode', component: CreateReservationComponent},
+ ]}, {
+ path: 'manage_reservations',
+ children: [
+ {path: '', component: ManageReservationsComponent},
+ {path: 'by_patron/:patron_id', component: ManageReservationsComponent},
+ {path: 'by_resource/:resource_barcode', component: ManageReservationsComponent},
+ {path: 'by_resource_type/:resource_type_id', component: ManageReservationsComponent},
+ ]}, {
+ path: 'pickup',
+ children: [
+ {path: '', component: PickupComponent},
+ {path: 'by_patron/:patron_id', component: PickupComponent},
+ ]}, {
+ path: 'pull_list',
+ component: PullListComponent
+ }, {
+ path: 'return',
+ children: [
+ {path: '', component: ReturnComponent},
+ {path: 'by_patron/:patron_id', component: ReturnComponent},
+ ]},
+ ];
+
+@NgModule({
+ imports: [RouterModule.forChild(routes)],
+ exports: [RouterModule],
+ providers: []
+})
+
+export class BookingRoutingModule {}
--- /dev/null
+import {Injectable} from '@angular/core';
+import {Observable, of} from 'rxjs';
+import {switchMap} from 'rxjs/operators';
+import {NgbTimeStruct} from '@ng-bootstrap/ng-bootstrap';
+import {AuthService} from '@eg/core/auth.service';
+import {IdlObject} from '@eg/core/idl.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {GridRowFlairEntry} from '@eg/share/grid/grid';
+import {DateRange} from '@eg/share/daterange-select/daterange-select.component';
+
+import * as Moment from 'moment-timezone';
+
+export interface ReservationPatron {
+ patronId: number;
+ patronLabel: string;
+ reservationId: number;
+}
+
+export interface ScheduleRow {
+ time: Moment;
+ [key: string]: ReservationPatron[];
+}
+
+// Various methods that fetch data for and process the schedule of reservations
+
+@Injectable({providedIn: 'root'})
+export class ScheduleGridService {
+
+ constructor(
+ private auth: AuthService,
+ private pcrud: PcrudService,
+ ) {
+ }
+ hoursOfOperation = (date: Date): Observable<{startOfDay: NgbTimeStruct, endOfDay: NgbTimeStruct}> => {
+ const defaultStartHour = 9;
+ const defaultEndHour = 17;
+ return this.pcrud.retrieve('aouhoo', this.auth.user().ws_ou())
+ .pipe(switchMap((hours) => {
+ const startArray = hours[this.evergreenStyleDow(date) + '_open']().split(':');
+ const endArray = hours[this.evergreenStyleDow(date) + '_close']().split(':');
+ return of({
+ startOfDay: {
+ hour: ('00' === startArray[0]) ? defaultStartHour : +startArray[0],
+ minute: +startArray[1],
+ second: 0},
+ endOfDay: {
+ hour: ('00' === endArray[0]) ? defaultEndHour : +endArray[0],
+ minute: +endArray[1],
+ second: 0}
+ });
+ }));
+ }
+
+ resourceAvailabilityIcon = (row: ScheduleRow, numResources: number): GridRowFlairEntry => {
+ let icon = {icon: 'event_busy', title: 'All resources are reserved at this time'};
+ let busyColumns = 0;
+ for (const key in row) {
+ if (row[key] instanceof Array && row[key].length) {
+ busyColumns += 1;
+ }
+ }
+ if (busyColumns < numResources) {
+ icon = {icon: 'event_available', title: 'Resources are available at this time'};
+ }
+ return icon;
+ }
+
+ fetchRelevantResources = (resourceTypeId: number, owningLibraries: number[], selectedAttributes: number[]): Observable<IdlObject> => {
+ const where = {
+ type: resourceTypeId,
+ owner: owningLibraries,
+ };
+
+ if (selectedAttributes.length) {
+ where['id'] = {'in':
+ {'from': 'bram', 'select': {'bram': ['resource']},
+ 'where': {'value': selectedAttributes}}};
+ }
+ return this.pcrud.search('brsrc', where, {
+ order_by: 'barcode ASC',
+ flesh: 1,
+ flesh_fields: {'brsrc': ['attr_maps']},
+ });
+ }
+
+ momentizeDateRange = (range: DateRange, timezone: string): {startTime: Moment, endTime: Moment} => {
+ return {
+ startTime: Moment.tz([
+ range.fromDate.year,
+ range.fromDate.month - 1,
+ range.fromDate.day],
+ timezone),
+ endTime: Moment.tz([
+ range.toDate.year,
+ range.toDate.month - 1,
+ range.toDate.day + 1],
+ timezone)
+ };
+ }
+ momentizeDay = (date: Date, start: NgbTimeStruct, end: NgbTimeStruct, timezone: string): {startTime: Moment, endTime: Moment} => {
+ return {
+ startTime: Moment.tz([
+ date.getFullYear(),
+ date.getMonth(),
+ date.getDate(),
+ start.hour,
+ start.minute],
+ timezone),
+ endTime: Moment.tz([
+ date.getFullYear(),
+ date.getMonth(),
+ date.getDate(),
+ end.hour,
+ end.minute],
+ timezone)
+ };
+ }
+
+ createBasicSchedule = (range: {startTime: Moment, endTime: Moment}, granularity: number): ScheduleRow[] => {
+ const currentTime = range.startTime.clone();
+ const schedule = [];
+ while (currentTime < range.endTime) {
+ schedule.push({'time': currentTime.clone()});
+ currentTime.add(granularity, 'minutes');
+ }
+ return schedule;
+ }
+
+ fetchReservations = (range: {startTime: Moment, endTime: Moment}, resourceIds: number[]): Observable<IdlObject> => {
+ return this.pcrud.search('bresv', {
+ '-or': {'target_resource': resourceIds, 'current_resource': resourceIds},
+ 'end_time': {'>': range.startTime.toISOString()},
+ 'start_time': {'<': range.endTime.toISOString()},
+ 'return_time': null,
+ 'cancel_time': null },
+ {'flesh': 1, 'flesh_fields': {'bresv': ['current_resource', 'usr']}});
+ }
+
+ addReservationToSchedule = (reservation: IdlObject, schedule: ScheduleRow[], granularity: number, timezone: string): ScheduleRow[] => {
+ for (let index = 0; index < schedule.length; index++) {
+ const start = schedule[index].time;
+ const end = (index + 1 < schedule.length) ?
+ schedule[index + 1].time :
+ schedule[index].time.clone().add(granularity, 'minutes');
+ if ((Moment.tz(reservation.start_time(), timezone).isBefore(end)) &&
+ (Moment.tz(reservation.end_time(), timezone).isAfter(start))) {
+ if (!schedule[index][reservation.current_resource().barcode()]) {
+ schedule[index][reservation.current_resource().barcode()] = [];
+ }
+ if (schedule[index][reservation.current_resource().barcode()]
+ .findIndex(patron => patron.patronId === reservation.usr().id()) === -1) {
+ schedule[index][reservation.current_resource().barcode()].push(
+ {'patronLabel': reservation.usr().usrname(),
+ 'patronId': reservation.usr().id(),
+ 'reservationId': reservation.id()});
+ }
+ }
+
+ }
+ return schedule;
+
+ }
+
+ // Evergreen uses its own day of week style, where dow_0 = Monday and dow_6 = Sunday
+ private evergreenStyleDow = (original: Date): string => {
+ const daysInAWeek = 7;
+ const offset = 6;
+ return 'dow_' + (original.getDay() + offset) % daysInAWeek;
+ }
+
+
+}
+
--- /dev/null
+import { TestBed } from '@angular/core/testing';
+import { AuthService } from '@eg/core/auth.service';
+import { PcrudService } from '@eg/core/pcrud.service';
+import { ScheduleGridService, ScheduleRow } from './schedule-grid.service';
+import * as Moment from 'moment-timezone';
+
+describe('ScheduleGridService', () => {
+ let service: ScheduleGridService;
+ beforeEach(() => {
+ const authServiceStub = {};
+ const pcrudServiceStub = {};
+ TestBed.configureTestingModule({
+ providers: [
+ ScheduleGridService,
+ { provide: AuthService, useValue: authServiceStub },
+ { provide: PcrudService, useValue: pcrudServiceStub }
+ ]
+ });
+ service = TestBed.get(ScheduleGridService);
+ });
+
+ it('should recognize when a row is completely busy', () => {
+ const busyRow: ScheduleRow = {
+ 'time': Moment(),
+ 'barcode1': [{patronLabel: 'Joe', patronId: 1, reservationId: 3}],
+ 'barcode2': [{patronLabel: 'Jill', patronId: 2, reservationId: 5}],
+ 'barcode3': [{patronLabel: 'James', patronId: 3, reservationId: 12},
+ {patronLabel: 'Juanes', patronId: 4, reservationId: 18}]
+ };
+ expect(service.resourceAvailabilityIcon(busyRow, 3).icon).toBe('event_busy');
+ });
+
+ it('should recognize when a row has some availability', () => {
+ const rowWithAvailability: ScheduleRow = {
+ 'time': Moment(),
+ 'barcode3': [{patronLabel: 'James', patronId: 3, reservationId: 11},
+ {patronLabel: 'Juanes', patronId: 4, reservationId: 17}]
+ };
+ expect(service.resourceAvailabilityIcon(rowWithAvailability, 3).icon).toBe('event_available');
+ });
+
+ it('should recognize 4 February 2019 as a Monday', () => {
+ const date = new Date(2019, 1, 4);
+ expect(service['evergreenStyleDow'](date)).toBe('dow_0');
+ });
+
+ it('should recognize 3 February 2019 as a Sunday', () => {
+ const date = new Date(2019, 1, 3);
+ expect(service['evergreenStyleDow'](date)).toBe('dow_6');
+ });
+});
i18n-group group="Booking" i18n-label label="Make Items Bookable"
(onClick)="makeBookable($event)">
</eg-grid-toolbar-action>
+
+ <eg-grid-toolbar-action
+ i18n-group group="Booking" i18n-label label="Manage Reservations"
+ (onClick)="manageReservations($event)">
+ </eg-grid-toolbar-action>
<!-- row actions: Edit -->
bookItems(rows: HoldingsEntry[]) {
const copyIds = this.selectedCopyIds(rows);
if (copyIds.length > 0) {
- alert('TODO');
+ this.router.navigate(['staff', 'booking', 'create_reservation', 'for_resource', rows.filter(r => Boolean(r.copy))[0].copy.barcode()]);
}
}
this.makeBookableDialog.open({});
}
}
+
+ manageReservations(rows: HoldingsEntry[]) {
+ const copyIds = this.selectedCopyIds(rows);
+ if (copyIds.length > 0) {
+ this.router.navigate(['staff', 'booking', 'manage_reservations', 'by_resource', rows.filter(r => Boolean(r.copy))[0].copy.barcode()]);
+ }
+ }
}
import {AdminPageComponent} from '@eg/staff/share/admin-page/admin-page.component';
import {EgHelpPopoverComponent} from '@eg/share/eg-help-popover/eg-help-popover.component';
import {DatetimeValidatorDirective} from '@eg/share/validators/datetime_validator.directive';
-import {ReactiveFormsModule} from '@angular/forms';
import {MultiSelectComponent} from '@eg/share/multi-select/multi-select.component';
+import {NotBeforeMomentValidatorDirective} from '@eg/share/validators/not_before_moment_validator.directive';
+import {PatronBarcodeValidatorDirective} from '@eg/share/validators/patron_barcode_validator.directive';
/**
* Imports the EG common modules and adds modules common to all staff UI's.
AdminPageComponent,
EgHelpPopoverComponent,
DatetimeValidatorDirective,
- MultiSelectComponent
+ MultiSelectComponent,
+ NotBeforeMomentValidatorDirective,
+ PatronBarcodeValidatorDirective,
],
imports: [
EgCommonModule,
AdminPageComponent,
EgHelpPopoverComponent,
DatetimeValidatorDirective,
- MultiSelectComponent
+ MultiSelectComponent,
+ NotBeforeMomentValidatorDirective,
+ PatronBarcodeValidatorDirective
]
})
Booking
</a>
<div class="dropdown-menu" ngbDropdownMenu>
- <a class="dropdown-item" href="/eg/staff/booking/legacy/booking/reservation">
+ <a class="dropdown-item" href="staff/booking/create_reservation">
<span class="material-icons">add</span>
<span i18n>Create Reservations</span>
</a>
- <a class="dropdown-item" href="/eg/staff/booking/legacy/booking/pull_list">
+ <a class="dropdown-item" href="staff/booking/pull_list">
<span class="material-icons">list</span>
<span i18n>Pull List</span>
</a>
<span class="material-icons">pin_drop</span>
<span i18n>Capture Resources</span>
</a>
- <a class="dropdown-item" href="/eg/staff/booking/legacy/booking/pickup">
+ <a class="dropdown-item" href="staff/booking/pickup">
<span class="material-icons">trending_up</span>
<span i18n>Pick Up Reservations</span>
</a>
- <a class="dropdown-item" href="/eg/staff/booking/legacy/booking/return">
+ <a class="dropdown-item" href="staff/booking/return">
<span class="material-icons">trending_down</span>
<span i18n>Return Reservations</span>
</a>
+ <a class="dropdown-item" href="staff/booking/manage_reservations">
+ <span class="material-icons">edit_attributes</span>
+ <span i18n>Manage Reservations</span>
+ </a>
</div>
</div>
</div>
redirectTo: 'splash',
pathMatch: 'full',
}, {
+ path: 'booking',
+ loadChildren : '@eg/staff/booking/booking.module#BookingModule'
+ }, {
path: 'about',
component: AboutComponent
}, {
--- /dev/null
+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);
+ }
+
+}
+
// PhantomJS needs these
import 'core-js/es6/array';
import 'core-js/es6/string';
+import 'core-js/es6/symbol'; // needed by app/staff/booking/reservation-actions.spec.ts
/** IE10 and IE11 requires the following for NgClass support on SVG elements */
// import 'classlist.js'; // Run `npm install --save classlist.js`.
*/
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}
sub create_bresv {
my ($self, $client, $authtoken,
$target_user_barcode, $datetime_range, $pickup_lib,
- $brt, $brsrc_list, $attr_values, $email_notify) = @_;
+ $brt, $brsrc_list, $attr_values, $email_notify, $note) = @_;
$brsrc_list = [ undef ] if not defined $brsrc_list;
return undef if scalar(@$brsrc_list) < 1; # Empty list not ok.
$bresv->start_time($datetime_range->[0]);
$bresv->end_time($datetime_range->[1]);
$bresv->email_notify(1) if $email_notify;
+ $bresv->note($note) if $note;
# A little sanity checking: don't agree to put a reservation on a
# brsrc and a brt when they don't match. In fact, bomb out of
{type => 'list', desc => 'Booking resource (undef ok; empty not ok)'},
{type => 'array', desc => 'Attribute values selected'},
{type => 'bool', desc => 'Email notification?'},
+ {type => 'string', desc => 'Optional note'},
],
return => { desc => "A hash containing the new bresv and a list " .
"of new bravm"}
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);
-- Allow for 1k stock templates
SELECT SETVAL('config.print_template_id_seq'::TEXT, 1000);
+
+INSERT INTO config.workstation_setting_type
+ (name, grp, datatype, label)
+VALUES (
+ 'eg.grid.circ.patron.group_members', 'gui', 'object',
+ oils_i18n_gettext(
+ 'eg.grid.circ.patron.group_members',
+ 'Grid Config: circ.patron.group_members',
+ 'cwst', 'label')
+);
+
+INSERT INTO config.workstation_setting_type (name,label,grp,datatype)
+VALUES ('eg.circ.bills.annotatepayment','Bills: Annotate Payment', 'circ', 'bool');
+
+INSERT INTO config.workstation_setting_type (name, grp, datatype, label)
+VALUES (
+ 'eg.grid.booking.manage', 'gui', 'object',
+ oils_i18n_gettext(
+ 'booking.manage',
+ 'Grid Config: Booking Manage Reservations',
+ 'cwst', 'label')
+), (
+ 'eg.grid.booking.pickup.ready', 'gui', 'object',
+ oils_i18n_gettext(
+ 'booking.pickup.ready',
+ 'Grid Config: Booking Ready to pick up grid',
+ 'cwst', 'label')
+), (
+ 'eg.grid.booking.pickup.picked_up', 'gui', 'object',
+ oils_i18n_gettext(
+ 'booking.pickup.picked_up',
+ 'Grid Config: Booking Already Picked Up grid',
+ 'cwst', 'label')
+), (
+ 'eg.grid.booking.return.patron.picked_up', 'gui', 'object',
+ oils_i18n_gettext(
+ 'booking.return.patron.picked_up',
+ 'Grid Config: Booking Return Patron tab Already Picked Up grid',
+ 'cwst', 'label')
+), (
+ 'eg.grid.booking.return.patron.returned', 'gui', 'object',
+ oils_i18n_gettext(
+ 'booking.return.patron.returned',
+ 'Grid Config: Booking Return Patron tab Returned Today grid',
+ 'cwst', 'label')
+), (
+ 'eg.grid.booking.return.resource.picked_up', 'gui', 'object',
+ oils_i18n_gettext(
+ 'booking.return.resourcce.picked_up',
+ 'Grid Config: Booking Return Resource tab Already Picked Up grid',
+ 'cwst', 'label')
+), (
+ 'eg.grid.booking.return.resource.returned', 'gui', 'object',
+ oils_i18n_gettext(
+ 'booking.return.resource.returned',
+ 'Grid Config: Booking Return Resource tab Returned Today grid',
+ 'cwst', 'label')
+), (
+ 'eg.booking.manage.selected_org_family', 'gui', 'object',
+ oils_i18n_gettext(
+ 'booking.manage.selected_org_family',
+ 'Sticky setting for pickup ou family in Manage Reservations screen',
+ 'cwst', 'label')
+), (
+ 'eg.booking.return.tab', 'gui', 'string',
+ oils_i18n_gettext(
+ 'booking.return.tab',
+ 'Sticky setting for tab in Booking Return',
+ 'cwst', 'label')
+), (
+ 'eg.booking.create.granularity', 'gui', 'integer',
+ oils_i18n_gettext(
+ 'booking.create.granularity',
+ 'Sticky setting for granularity combobox in Booking Create',
+ 'cwst', 'label')
+), (
+ 'eg.booking.create.multiday', 'gui', 'bool',
+ oils_i18n_gettext(
+ 'booking.create.multiday',
+ 'Default to creating multiday booking reservations',
+ 'cwst', 'label')
+), (
+ 'eg.booking.pickup.ready.only_show_captured', 'gui', 'bool',
+ oils_i18n_gettext(
+ 'booking.pickup.ready.only_show_captured',
+ 'Include only resources that have been captured in the Ready grid in the Pickup screen',
+ 'cwst', 'label')
+);
--- /dev/null
+BEGIN;
+--SELECT evergreen.upgrade_deps_block_check('XXXX', :eg_version);
+INSERT INTO config.workstation_setting_type (name, grp, datatype, label)
+VALUES (
+ 'eg.grid.booking.manage', 'gui', 'object',
+ oils_i18n_gettext(
+ 'booking.manage',
+ 'Grid Config: Booking Manage Reservations',
+ 'cwst', 'label')
+), (
+ 'eg.grid.booking.pickup.ready', 'gui', 'object',
+ oils_i18n_gettext(
+ 'booking.pickup.ready',
+ 'Grid Config: Booking Ready to pick up grid',
+ 'cwst', 'label')
+), (
+ 'eg.grid.booking.pickup.picked_up', 'gui', 'object',
+ oils_i18n_gettext(
+ 'booking.pickup.picked_up',
+ 'Grid Config: Booking Already Picked Up grid',
+ 'cwst', 'label')
+), (
+ 'eg.grid.booking.return.patron.picked_up', 'gui', 'object',
+ oils_i18n_gettext(
+ 'booking.return.patron.picked_up',
+ 'Grid Config: Booking Return Patron tab Already Picked Up grid',
+ 'cwst', 'label')
+), (
+ 'eg.grid.booking.return.patron.returned', 'gui', 'object',
+ oils_i18n_gettext(
+ 'booking.return.patron.returned',
+ 'Grid Config: Booking Return Patron tab Returned Today grid',
+ 'cwst', 'label')
+), (
+ 'eg.grid.booking.return.resource.picked_up', 'gui', 'object',
+ oils_i18n_gettext(
+ 'booking.return.resourcce.picked_up',
+ 'Grid Config: Booking Return Resource tab Already Picked Up grid',
+ 'cwst', 'label')
+), (
+ 'eg.grid.booking.return.resource.returned', 'gui', 'object',
+ oils_i18n_gettext(
+ 'booking.return.resource.returned',
+ 'Grid Config: Booking Return Resource tab Returned Today grid',
+ 'cwst', 'label')
+), (
+ 'eg.booking.manage.selected_org_family', 'gui', 'object',
+ oils_i18n_gettext(
+ 'booking.manage.selected_org_family',
+ 'Sticky setting for pickup ou family in Manage Reservations screen',
+ 'cwst', 'label')
+), (
+ 'eg.booking.return.tab', 'gui', 'string',
+ oils_i18n_gettext(
+ 'booking.return.tab',
+ 'Sticky setting for tab in Booking Return',
+ 'cwst', 'label')
+), (
+ 'eg.booking.create.granularity', 'gui', 'integer',
+ oils_i18n_gettext(
+ 'booking.create.granularity',
+ 'Sticky setting for granularity combobox in Booking Create',
+ 'cwst', 'label')
+), (
+ 'eg.booking.create.multiday', 'gui', 'bool',
+ oils_i18n_gettext(
+ 'booking.create.multiday',
+ 'Default to creating multiday booking reservations',
+ 'cwst', 'label')
+), (
+ 'eg.booking.pickup.ready.only_show_captured', 'gui', 'bool',
+ oils_i18n_gettext(
+ 'booking.pickup.ready.only_show_captured',
+ 'Include only resources that have been captured in the Ready grid in the Pickup screen',
+ 'cwst', 'label')
+);
+
+COMMIT;
--- /dev/null
+BEGIN;
+
+ALTER TABLE booking.reservation
+ ADD COLUMN note TEXT;
+
+COMMIT;
<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"
<li><a href ng-click="show_in_catalog()">[% l('Show in Catalog') %]</a></li>
<li><a href ng-click="make_copies_bookable()">[% l('Make Items Bookable') %]</a></li>
<li><a href ng-click="book_copies_now()">[% l('Book Item Now') %]</a></li>
+ <li><a href ng-click="manage_reservations()">[% l('Manage Reservations') %]</a></li>
<li><a href ng-click="requestItems()">[% l('Request Items') %]</a></li>
<li><a href ng-click="attach_to_peer_bib()">[% l('Link as Conjoined to Previously Marked Bib Record') %]</a></li>
<li><a href ng-click="selectedHoldingsCopyDelete()">[% l('Delete Items') %]</a></li>
<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"
</a>
</li>
<li>
- <a href="./booking/legacy/booking/reservation?patron_barcode={{patron().card().barcode()}}" target="_top">
- [% l('Booking: Create or Cancel Reservations') %]
+ <a href="/eg2/staff/booking/manage_reservations/by_patron/{{patron().id()}}" target="_top">
+ [% l('Booking: Manage Reservations') %]
</a>
</li>
<li>
- <a href="./booking/legacy/booking/pickup?patron_barcode={{patron().card().barcode()}}" target="_top">
+ <a href="/eg2/staff/booking/create_reservation/for_patron/{{patron().id()}}" target="_top">
+ [% l('Booking: Create Reservation') %]
+ </a>
+ </li>
+ <li>
+ <a href="/eg2/staff/booking/pickup/by_patron/{{patron().id()}}" target="_top">
[% l('Booking: Pick Up Reservations') %]
</a>
</li>
<li>
- <a href="./booking/legacy/booking/return?patron_barcode={{patron().card().barcode()}}" target="_top">
+ <a href="/eg2/staff/booking/return/by_patron/{{patron().id()}}" target="_top">
[% l('Booking: Return Reservations') %]
</a>
</li>
</a>
<ul uib-dropdown-menu>
<li>
- <a href="./booking/legacy/booking/reservation" target="_self">
+ <a href="/eg2/staff/booking/create_reservation" target="_self">
<span class="glyphicon glyphicon-plus"></span>
[% l('Create Reservations') %]
</a>
</li>
<li>
- <a href="./booking/legacy/booking/pull_list" target="_self">
+ <a href="/eg2/staff/booking/pull_list" target="_self">
<span class="glyphicon glyphicon-th-list"></span>
[% l('Pull List') %]
</a>
</a>
</li>
<li>
- <a href="./booking/legacy/booking/pickup" target="_self">
+ <a href="/eg2/staff/booking/pickup" target="_self">
<span class="glyphicon glyphicon-export"></span>
[% l('Pick Up Reservations') %]
</a>
</li>
<li>
- <a href="./booking/legacy/booking/return" target="_self">
+ <a href="/eg2/staff/booking/return" target="_self">
<span class="glyphicon glyphicon-import"></span>
[% l('Return Reservations') %]
</a>
</li>
+ <li>
+ <a href="/eg2/staff/booking/manage_reservations" target="_self">
+ <span class="glyphicon glyphicon-wrench"></span>
+ [% l('Manage Reservations') %]
+ </a>
+ </li>
</ul>
</li>
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> " +
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));
});
}
- $scope.book_copies_now = function() {
- var copies_by_record = {};
- var record_list = [];
- angular.forEach(
- $scope.holdingsGridControls.selectedItems(),
- function (item) {
- var record_id = item['call_number.record.id'];
- if (typeof copies_by_record[ record_id ] == 'undefined') {
- copies_by_record[ record_id ] = [];
- record_list.push( record_id );
- }
- copies_by_record[ record_id ].push(item.id);
- }
- );
-
- var promises = [];
- var combined_brt = [];
- var combined_brsrc = [];
- angular.forEach(record_list, function(record_id) {
- promises.push(
- egCore.net.request(
- 'open-ils.booking',
- 'open-ils.booking.resources.create_from_copies',
- egCore.auth.token(),
- copies_by_record[record_id]
- ).then(function(results) {
- if (results && results['brt']) {
- combined_brt = combined_brt.concat(results['brt']);
- }
- if (results && results['brsrc']) {
- combined_brsrc = combined_brsrc.concat(results['brsrc']);
- }
- })
- );
- });
-
- $q.all(promises).then(function() {
- if (combined_brt.length > 0 || combined_brsrc.length > 0) {
- $uibModal.open({
- template: '<eg-embed-frame url="booking_admin_url" handlers="funcs"></eg-embed-frame>',
- backdrop: 'static',
- animation: true,
- size: 'md',
- controller:
- ['$scope','$location','egCore','$uibModalInstance',
- function($scope , $location , egCore , $uibModalInstance) {
-
- $scope.funcs = {
- ses : egCore.auth.token(),
- bresv_interface_opts : {
- booking_results : {
- brt : combined_brt
- ,brsrc : combined_brsrc
- }
- }
- }
-
- var booking_path = '/eg/booking/reservation';
-
- $scope.booking_admin_url =
- $location.absUrl().replace(/\/eg\/staff.*/, booking_path);
-
- }]
- });
- }
- });
+ $scope.book_copies_now = function(items) {
+ location.href = "/eg2/staff/booking/create_reservation/for_resource/" + items[0]['barcode'];
}
-
$scope.requestItems = function() {
var copy_list = gatherSelectedHoldingsIds();
if (copy_list.length == 0) return;
});
}
+ $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';
}
$scope.book_copies_now = function() {
- itemSvc.book_copies_now([{
- id : $scope.args.copyId,
- 'call_number.record.id' : $scope.args.recordId
- }]);
+ itemSvc.book_copies_now([$scope.args.copyBarcode]);
}
$scope.findAcquisition = function() {
});
}
+ $scope.manage_reservations = function() {
+ itemSvc.manage_reservations([$scope.args.copyBarcode]);
+ }
+
$scope.requestItems = function() {
itemSvc.requestItems([$scope.args.copyId]);
}
}
$scope.book_copies_now = function() {
- itemSvc.book_copies_now(copyGrid.selectedItems());
+ var item = copyGrid.selectedItems()[0];
+ if (item)
+ itemSvc.book_copies_now(item.barcode);
+ }
+
+ $scope.manage_reservations = function() {
+ var item = copyGrid.selectedItems()[0];
+ if (item)
+ itemSvc.manage_reservations(item.barcode);
}
$scope.requestItems = function() {
});
}
- service.book_copies_now = function(items) {
- var copies_by_record = {};
- var record_list = [];
- angular.forEach(
- items,
- function (item) {
- var record_id = item['call_number.record.id'];
- if (typeof copies_by_record[ record_id ] == 'undefined') {
- copies_by_record[ record_id ] = [];
- record_list.push( record_id );
- }
- copies_by_record[ record_id ].push(item.id);
- }
- );
-
- var promises = [];
- var combined_brt = [];
- var combined_brsrc = [];
- angular.forEach(record_list, function(record_id) {
- promises.push(
- egCore.net.request(
- 'open-ils.booking',
- 'open-ils.booking.resources.create_from_copies',
- egCore.auth.token(),
- copies_by_record[record_id]
- ).then(function(results) {
- if (results && results['brt']) {
- combined_brt = combined_brt.concat(results['brt']);
- }
- if (results && results['brsrc']) {
- combined_brsrc = combined_brsrc.concat(results['brsrc']);
- }
- })
- );
- });
-
- $q.all(promises).then(function() {
- if (combined_brt.length > 0 || combined_brsrc.length > 0) {
- $uibModal.open({
- template: '<eg-embed-frame url="booking_admin_url" handlers="funcs"></eg-embed-frame>',
- backdrop: 'static',
- animation: true,
- size: 'md',
- controller:
- ['$scope','$location','egCore','$uibModalInstance',
- function($scope , $location , egCore , $uibModalInstance) {
-
- $scope.funcs = {
- ses : egCore.auth.token(),
- bresv_interface_opts : {
- booking_results : {
- brt : combined_brt
- ,brsrc : combined_brsrc
- }
- }
- }
-
- var booking_path = '/eg/booking/reservation';
-
- $scope.booking_admin_url =
- $location.absUrl().replace(/\/eg\/staff.*/, booking_path);
+ service.book_copies_now = function(barcode) {
+ location.href = "/eg2/staff/booking/create_reservation/for_resource/" + barcode;
+ }
- }]
- });
- }
- });
+ service.manage_reservations = function(barcode) {
+ location.href = "/eg2/staff/booking/manage_reservations/by_resource/" + barcode;
}
service.requestItems = function(copy_list) {