<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}}"
+ (onChangeAsMoment)="field.validate(field.name, $event, record)"
+ (onChangeAsIso)="record[field.name]($event)"
+ [validatorError]="field.validatorError"
+ i18n-validatorError
+ [readOnly]="field.readOnly"
+ initialIso="{{record[field.name]()}}">
+ </eg-datetime-select>
+ </ng-container>
+
<ng-container *ngSwitchCase="'org_unit'">
<eg-org-select
placeholder="{{field.label}}..."
(ngModelChange)="record[field.name]($event)"/>
</ng-container>
+ <ng-container *ngSwitchCase="'readonly-au'">
+ <ng-container *ngIf="field.linkedValues">
+ <a href="/eg/staff/circ/patron/{{field.linkedValues[0].id}}/checkout" target="_blank">{{field.linkedValues[0].label}}
+ <span class="material-icons" i18n-title title="Open user record in new tab">open_in_new</span></a>
+ </ng-container>
+ </ng-container>
+
<ng-container *ngSwitchCase="'list'">
<eg-combobox
id="{{idPrefix}}-{{field.name}}" name="{{field.name}}"
import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
import {TranslateComponent} from '@eg/staff/share/translate/translate.component';
+import {FormatService} from '@eg/core/format.service';
interface CustomFieldTemplate {
// This supersedes all other isRequired specifiers.
isRequiredOverride?: (field: string, record: IdlObject) => boolean;
+ // If this function is defined, the function will be called
+ // when fields change their values, to check if users are entering
+ // valid values, and delivering an error message if not.
+ //
+ // Currently only implemented for the datetime-select widget
+ validator?: (field: string, value: any, record: IdlObject) => string;
+
// Directly apply the readonly status of the field.
// This only has an affect if the value is true.
isReadonly?: boolean;
mode: 'create' | 'update' | 'view' = 'create';
recId: any;
+ // Show datetime fields in this particular timezone
+ timezone: string = this.format.wsOrgTimezone;
+
// IDL record we are editing
record: IdlObject;
@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 modal: NgbModal, // required for passing to parent
private idl: IdlService,
private auth: AuthService,
+ 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(/,/);
}
|| fieldOptions.isReadonly === true
|| this.readonlyFieldsList.includes(field.name);
+ if (fieldOptions.validator) {
+ field.validator = fieldOptions.validator;
+ } else {
+ field.validator = (fieldName: string, value: any, record: IdlObject) => '';
+ }
+
+ field.validate = (fieldName: string, value: any, record: IdlObject) => {
+ field.validatorError = field.validator(fieldName, value, record); };
+
+
if (fieldOptions.isRequiredOverride) {
field.isRequired = () => {
return fieldOptions.isRequiredOverride(field.name, this.record);
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>
-
-
-
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 {NgModule} from '@angular/core';
+import {ReactiveFormsModule} from '@angular/forms';
+import {StaffCommonModule} from '@eg/staff/common.module';
+import {BookingRoutingModule} from './routing.module';
+import {CreateReservationComponent} from './create-reservation.component';
+import {ManageReservationsComponent} from './manage-reservations.component';
+import {OrgSelectWithDescendantsComponent} from './org-select-with-descendants.component';
+import {ReservationsGridComponent} from './reservations-grid.component';
+import {PickupComponent} from './pickup.component';
+import {PullListComponent} from './pull-list.component';
+import {ResourceTypeComboboxComponent} from './resource-type-combobox.component';
+import {ReturnComponent} from './return.component';
+import {NoTimezoneSetComponent} from './no-timezone-set.component';
+import {PatronService} from '@eg/staff/share/patron.service';
+
+
+@NgModule({
+ imports: [
+ StaffCommonModule,
+ BookingRoutingModule,
+ ReactiveFormsModule,
+ ],
+ providers: [PatronService],
+ declarations: [
+ CreateReservationComponent,
+ ManageReservationsComponent,
+ NoTimezoneSetComponent,
+ OrgSelectWithDescendantsComponent,
+ PickupComponent,
+ PullListComponent,
+ ReservationsGridComponent,
+ ResourceTypeComboboxComponent,
+ ReturnComponent]
+})
+export class BookingModule { }
+
--- /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>
+
+<div class="row">
+ <div class="col">
+ <eg-org-select-with-descendants labelText="Owning library" i18n-labelText (ouChange)="handleOwnerChange($event)">
+ </eg-org-select-with-descendants>
+ </div>
+ <div class="col">
+ <div class="input-group">
+ <div class="input-group-prepend">
+ <label class="input-group-text" for="ideal-reservation-type" i18n>Reservation type</label>
+ </div>
+ <div ngbDropdown>
+ <button *ngIf="!multiday" class="btn btn-outline-primary" ngbDropdownToggle><span class="material-icons">event</span><span i18n>Single day reservation</span></button>
+ <button *ngIf="multiday" class="btn btn-outline-primary" ngbDropdownToggle><span class="material-icons">date_range</span><span i18n>Multiple day reservation</span></button>
+ <div ngbDropdownMenu id="ideal-reservation-type">
+ <button (click)="handleSingleDayReservation()" class="btn btn-outline-primary" ngbDropdownItem><span class="material-icons">event</span><span i18n>Single day reservation</span></button>
+ <button (click)="handleMultiDayReservation()" class="btn btn-outline-primary" ngbDropdownItem><span class="material-icons">date_range</span><span i18n>Multiple day reservation</span></button>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="col">
+ <div class="input-group">
+ <div class="input-group-prepend">
+ <label class="input-group-text" for="ideal-reservation-date" i18n>Reservation date</label>
+ </div>
+ <eg-date-select *ngIf="!multiday" #dateLimiter domId="ideal-reservation-date" (onChangeAsDate)="handleDateChange($event)" [initialDate]="idealDate"></eg-date-select>
+ <eg-daterange-select *ngIf="multiday" #dateRangeLimiter (onChange)="fetchData()"></eg-daterange-select>
+ </div>
+ </div>
+ <div class="col">
+ <div class="input-group">
+ <div class="input-group-prepend">
+ <label class="input-group-text" for="ideal-resource-barcode" i18n>Resource barcode</label>
+ </div>
+ <input type="text" id="ideal-resource-barcode" class="form-control" i18n-placeholder placeholder="Resource barcode" [(ngModel)]="resourceBarcode" (change)="useCurrentResourceBarcode()">
+ </div>
+ </div>
+ <div class="col">
+ <div class="input-group">
+ <div class="input-group-prepend">
+ <label class="input-group-text" for="ideal-resource-type" i18n>Resource type</label>
+ </div>
+ <eg-resource-type-combobox #rt domId="ideal-resource-type" (typeChanged)="handleResourceTypeChange($event)"></eg-resource-type-combobox>
+ </div>
+ </div>
+</div>
+<hr class="mt1" />
+<button
+ class="btn btn-primary"
+ (click)="advancedCollapsed = !advancedCollapsed"
+ [attr.aria-expanded]="!advancedCollapsed"
+ aria-controls="advanced">
+ <span *ngIf="advancedCollapsed" class="material-icons">lock</span>
+ <span *ngIf="!advancedCollapsed" class="material-icons">lock_open</span>
+ <span *ngIf="advancedCollapsed" i18n>Show advanced options</span>
+ <span *ngIf="!advancedCollapsed" i18n>Hide advanced options</span>
+</button>
+
+<div id="advanced" class="row" [ngbCollapse]="advancedCollapsed">
+ <div class="card col-md-6">
+ <div class="card-header" i18n>Display options</div>
+ <ul class="list-group list-group-flush">
+ <li class="list-group-item">
+ <span class="input-group">
+ <span class="input-group-prepend">
+ <label class="input-group-text" for="start-time" i18n>Start time</label>
+ </span>
+ <ngb-timepicker [(ngModel)]="startOfDay" (ngModelChange)="fetchData()" [minuteStep]="minuteStep()" [meridian]="true"></ngb-timepicker>
+ </span>
+ </li>
+ <li class="list-group-item">
+ <span class="input-group">
+ <span class="input-group-prepend">
+ <label class="input-group-text" for="end-time" i18n>End time</label>
+ </span>
+ <ngb-timepicker [(ngModel)]="endOfDay" (ngModelChange)="fetchData()" [minuteStep]="minuteStep()" [meridian]="true"></ngb-timepicker>
+ </span>
+ </li>
+ <li class="list-group-item">
+ <span class="input-group">
+ <span class="input-group-prepend">
+ <label class="input-group-text" for="granularity" i18n>Granularity</label>
+ </span>
+ <eg-combobox (onChange)="changeGranularity($event)" [startId]="granularity ? granularity : 30">
+ <eg-combobox-entry entryId="15" entryLabel="15 minutes"
+ i18n-entryLabel></eg-combobox-entry>
+ <eg-combobox-entry entryId="30" entryLabel="30 minutes"
+ i18n-entryLabel></eg-combobox-entry>
+ <eg-combobox-entry entryId="60" entryLabel="60 minutes"
+ i18n-entryLabel></eg-combobox-entry>
+ </eg-combobox>
+ </span>
+ </li>
+ </ul>
+ </div>
+ <div *ngIf="attributes.length" class="card col-md-6">
+ <div class="card-header" i18n>Filter by attributes</div>
+ <ul class="list-group list-group-flush">
+ <li *ngFor="let attribute of attributes" class="list-group-item">
+ <span class="input-group">
+ <span class="input-group-prepend">
+ <label class="input-group-text" for="attribute-{{attribute.id()}}" i18n>{{attribute.name()}}</label>
+ </span>
+ <eg-combobox (onChange)="limitByAttr(attribute.id(), $event)">
+ <eg-combobox-entry *ngFor="let value of attribute.valid_values()"
+ [entryId]="value.id()" [entryLabel]="value.valid_value()">
+ </eg-combobox-entry>
+ </eg-combobox>
+ </span>
+ </li>
+ </ul>
+ </div>
+</div>
+
+<eg-grid *ngIf="resources.length" #scheduleGrid
+ [sortable]="false"
+ (onRowActivate)="openCreateDialog([$event])"
+ [dataSource]="scheduleSource"
+ [rowFlairIsEnabled]="true"
+ [rowFlairCallback]="resourceAvailabilityIcon"
+ [disablePaging]="true"
+ persistKey="disabled">
+ <eg-grid-toolbar-action label="Create Reservation" i18n-label [action]="openCreateDialog"></eg-grid-toolbar-action>
+ <eg-grid-column path="time" [index]="true" ></eg-grid-column>
+ <eg-grid-column *ngFor="let resource of resources" path="{{resource.barcode()}}" [cellTemplate]="reservationsTemplate" [disableTooltip]="true"></eg-grid-column>
+</eg-grid>
+
+<eg-fm-record-editor #newDialog
+ idlClass="bresv"
+ [fieldOptions]="{usr:{customTemplate:{template:patronTemplate}},start_time:{customTemplate:{template:datetimeWithDefaults}},end_time:{customTemplate:{template:datetimeWithDefaults}},pickup_lib:{customTemplate:{template:pickupLibrary}},target_resource:{customTemplate:{template:targetResource}}}"
+ hiddenFields="id,xact_start,request_time,capture_time,pickup_time,return_time,capture_staff,xact_finish,cancel_time,booking_interval,unrecovered,request_lib,fine_interval,fine_amount,max_fine,current_resource,target_resource_type">
+</eg-fm-record-editor>
+
+<ng-template #reservationsTemplate let-row="row" let-col="col">
+ <ng-container *ngIf="row[col.name]">
+ <ul class="alert alert-primary">
+ <li *ngFor="let reservation of row[col.name]">
+ <a href="staff/booking/manage_reservations/by_patron/{{reservation['patronId']}}">{{reservation['patronLabel']}}</a>
+ </li>
+ </ul>
+ </ng-container>
+</ng-template>
+<ng-template #patronTemplate let-record="record">
+<input type="hidden" value="{{record.request_lib(auth.user().ws_ou())}}">
+ <ng-container *ngIf="patronId">
+ <input *ngIf="patronId" type="text" disabled value="{{record.usr(patronId)}}" class="form-control" name="usr">
+ </ng-container>
+ <div *ngIf="!patronId" class="input-group flex-nowrap">
+ <div class="input-group-prepend">
+ <label class="input-group-text" for="patron-barcode" i18n>Patron barcode</label>
+ <input type="text" id="patron-barcode" class="form-control" i18n-placeholder placeholder="Patron barcode" [(ngModel)]="patronBarcode" (change)="findPatronByBarcode()">
+ </div>
+ </div>
+</ng-template>
+<ng-template #datetimeWithDefaults let-record="record" let-field="field">
+ <input type="hidden" value="{{record[field.name](defaultTimes[field.name].toISOString())}}">
+ <eg-datetime-select
+ [showTZ]="true"
+ [minuteStep]="minuteStep()"
+ [timezone]="pickupLibUsesDifferentTz ? pickupLibUsesDifferentTz : format.wsOrgTimezone"
+ (onChangeAsIso)="record[field.name]($event)"
+ (onChangeAsMoment)="field.validatorError = reservationValidate[field.name](field.name, $event, record)"
+ [validatorError]="field.validatorError"
+ [initialMoment]="defaultTimes[field.name]">
+ </eg-datetime-select>
+</ng-template>
+<ng-template #pickupLibrary let-record="record" let-field="field">
+ <input type="hidden" value="{{record.pickup_lib(auth.user().ws_ou())}}">
+ <eg-org-select
+ [initialOrgId]="auth.user().ws_ou()"
+ (onChange)="handlePickupLibChange($event)">
+ </eg-org-select>
+ <div *ngIf="pickupLibUsesDifferentTz" class="alert alert-primary" i18n>Pickup library uses a different timezone than your library does. Please choose times in the pickup library's timezone.</div>
+</ng-template>
+<ng-template #targetResource let-record="record">
+ <input type="hidden" value="{{record.target_resource_type(resourceTypeId)}}">
+ <ng-container *ngIf="resourceId">
+ <input type="text" disabled value="{{resourceBarcode}}" class="form-control">
+ <input type="hidden" value="{{record.target_resource(resourceId)}}">
+ <input type="hidden" value="{{record.current_resource(resourceId)}}">
+ </ng-container>
+ <ng-container *ngIf="!resourceId">
+ <eg-combobox (onChange)="handleTargetResourceChange($event.id)" startId="any">
+ <eg-combobox-entry entryId="any" entryLabel="Any resource"
+ i18n-entryLabel></eg-combobox-entry>
+ <eg-combobox-entry *ngFor="let r of resources" entryId="{{r.id()}}" entryLabel="{{r.barcode()}}">
+ </eg-combobox-entry>
+ </eg-combobox>
+ </ng-container>
+</ng-template>
--- /dev/null
+import {Component, Input, OnInit, AfterViewInit, QueryList, ViewChildren, ViewChild} from '@angular/core';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {forkJoin} from 'rxjs';
+import {single} from 'rxjs/operators';
+import {NgbDateStruct, NgbTimeStruct} from '@ng-bootstrap/ng-bootstrap';
+import {AuthService} from '@eg/core/auth.service';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {DateSelectComponent} from '@eg/share/date-select/date-select.component';
+import {DateRangeSelectComponent} from '@eg/share/daterange-select/daterange-select.component';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {FormatService} from '@eg/core/format.service';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {GridDataSource, GridRowFlairEntry} from '@eg/share/grid/grid';
+import {IdlObject, IdlService} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {OrgService} from '@eg/core/org.service';
+import {PatronService} from '@eg/staff/share/patron.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {ResourceTypeComboboxComponent} from './resource-type-combobox.component';
+import {ServerStoreService} from '@eg/core/server-store.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {ReservationValidateService} from './reservation-validate.service';
+
+import * as Moment from 'moment-timezone';
+
+
+@Component({
+ templateUrl: './create-reservation.component.html'
+})
+
+export class CreateReservationComponent implements OnInit, AfterViewInit {
+
+ advancedCollapsed = true;
+ attributes: IdlObject[] = [];
+ selectedAttributes: number[] = [];
+ multiday = false;
+ handleDateChange: ($event: Date) => void;
+ handleOwnerChange: ($event: number[]) => void;
+ resourceAvailabilityIcon: (row: any) => GridRowFlairEntry;
+
+ owningLibraries: number[] = [];
+
+ patronBarcode: string;
+ patronId: number;
+ resourceBarcode: string;
+ resourceId: number;
+ resourceTypeId: number;
+ transferable: boolean;
+ resourceOwner: number;
+
+ pickupLibUsesDifferentTz: string;
+
+ startOfDay: NgbTimeStruct = {hour: 9, minute: 0, second: 0};
+ endOfDay: NgbTimeStruct = {hour: 17, minute: 0, second: 0};
+ granularity: 15 | 30 | 60 | 1440 = 30; // 1440 minutes = 24 hours
+
+ defaultTimes: {start_time: Moment, end_time: Moment};
+
+ scheduleSource: GridDataSource = new GridDataSource();
+
+ minuteStep: () => number;
+
+ openCreateDialog: (rows: IdlObject[]) => void;
+ openTheDialog: (rows: IdlObject[]) => any;
+
+ resources: IdlObject[] = [];
+ limitByAttr: (attributeId: number, $event: ComboboxEntry) => void;
+ useCurrentResourceBarcode: () => void;
+ findPatronByBarcode: () => void;
+
+ setGranularity: () => void;
+ handleMultiDayReservation: () => void;
+ handleSingleDayReservation: () => void;
+ changeGranularity: ($event: ComboboxEntry) => void;
+ handlePickupLibChange: ($event: IdlObject) => void;
+ handleTargetResourceChange: ($event: string | number) => void;
+
+ @ViewChildren('dateLimiter') dateLimiters: QueryList<DateSelectComponent>;
+ @ViewChildren('dateRangeLimiter') dateRangeLimiters: QueryList<DateRangeSelectComponent>;
+ @ViewChildren('scheduleGrid') scheduleGrids: QueryList<GridComponent>;
+ @ViewChild('newDialog') newDialog: FmRecordEditorComponent;
+ @ViewChild('rt') rt: ResourceTypeComboboxComponent;
+
+ idealDate = new Date();
+
+ constructor(
+ private auth: AuthService,
+ private format: FormatService,
+ private idl: IdlService,
+ private net: NetService,
+ private org: OrgService,
+ private patron: PatronService,
+ private pcrud: PcrudService,
+ private route: ActivatedRoute,
+ private router: Router,
+ private store: ServerStoreService,
+ private toast: ToastService,
+ public reservationValidate: ReservationValidateService
+ ) {
+ this.resourceAvailabilityIcon = (row: any) => {
+ let icon = {icon: 'event_busy', title: 'All resources are reserved at this time'};
+ let busy_columns = 0;
+ for (const key in row) {
+ if (row[key]) { busy_columns = busy_columns + 1; }
+ }
+ if (busy_columns <= this.resources.length) { // equal or less than, since it counts the time column
+ icon = {icon: 'event_available', title: 'Resources are available at this time'};
+ }
+ return icon;
+ };
+ }
+
+
+ ngOnInit() {
+ this.owningLibraries = [this.auth.user().ws_ou()];
+
+ this.defaultTimes = {
+ 'start_time': Moment.tz([], this.format.wsOrgTimezone),
+ 'end_time': Moment.tz([], this.format.wsOrgTimezone).add(this.granularity, 'minutes')
+ };
+
+ this.store.getItem('eg.booking.create.multiday').then(multiday => {
+ if (multiday) { this.multiday = multiday; }});
+
+ this.route.paramMap.subscribe((params: ParamMap) => {
+ this.patronId = +params.get('patron_id');
+ this.resourceBarcode = params.get('resource_barcode');
+
+ if (this.resourceBarcode) {
+ this.pcrud.search('brsrc',
+ {'barcode' : this.resourceBarcode}, {'limit': 1})
+ .pipe(single())
+ .subscribe((res) => {
+ this.resourceId = res.id();
+ this.fetchData();
+ }, (err) => {
+ this.pcrud.search('acp',
+ {'barcode' : this.resourceBarcode}, {'limit': 1})
+ .pipe(single())
+ .subscribe((item) => {
+ this.net.request( 'open-ils.booking',
+ 'open-ils.booking.resources.create_from_copies',
+ this.auth.token(), [item.id()])
+ .subscribe((response) => {
+ this.toast.info('Made this barcode bookable');
+ this.resourceId = response['brsrc'][0][0];
+ }, (error) => {
+ this.toast.danger('Cannot make this barcode bookable');
+ });
+ }, (acperror) => {
+ this.toast.danger('No resource found with this barcode');
+ this.resourceId = -1;
+ });
+ });
+ }
+ });
+
+ this.limitByAttr = (attributeId: number, $event: ComboboxEntry) => {
+ this.selectedAttributes[attributeId] = $event.id;
+ this.fetchData();
+ };
+
+ this.setGranularity = () => {
+ if (this.multiday) { // multiday reservations always use day granularity
+ this.granularity = 1440;
+ } else {
+ this.store.getItem('eg.booking.create.granularity').then(granularity => {
+ if (granularity) {
+ this.granularity = granularity;
+ } else {
+ this.granularity = 30;
+ }
+ });
+ }
+ };
+
+ this.handleDateChange = ($event: Date) => {
+ this.idealDate = $event;
+ this.pcrud.retrieve('aouhoo', this.auth.user().ws_ou())
+ .subscribe(hours => {
+ const startArray = hours['dow_' + (this.idealDate.getDay() + 6) % 7 + '_open']().split(':');
+ const endArray = hours['dow_' + (this.idealDate.getDay() + 6) % 7 + '_close']().split(':');
+ this.startOfDay = {
+ hour: ('00' === startArray[0]) ? 9 : +startArray[0],
+ minute: +startArray[1],
+ second: 0};
+ this.endOfDay = {
+ hour: ('00' === endArray[0]) ? 17 : +endArray[0],
+ minute: +endArray[1],
+ second: 0};
+ this.fetchData();
+ });
+ };
+ this.handleOwnerChange = ($event: number[]) => {
+ this.owningLibraries = $event;
+ this.fetchData();
+ };
+
+ this.handleMultiDayReservation = () => {
+ this.multiday = true;
+ this.store.setItem('eg.booking.create.multiday', true);
+ this.fetchData();
+ };
+
+ this.handleSingleDayReservation = () => {
+ this.multiday = false;
+ this.store.setItem('eg.booking.create.multiday', false);
+ this.handleDateChange(new Date());
+ };
+
+ this.changeGranularity = ($event) => {
+ this.granularity = $event.id;
+ this.store.setItem('eg.booking.create.granularity', $event.id)
+ .then(() => this.fetchData());
+ };
+
+ this.handlePickupLibChange = ($event) => {
+ this.newDialog.record.pickup_lib($event);
+ this.org.settings('lib.timezone', $event.id()).then((tz) => {
+ if (tz['lib.timezone'] && (this.format.wsOrgTimezone !== tz['lib.timezone'])) {
+ this.pickupLibUsesDifferentTz = tz['lib.timezone'];
+ } else {
+ this.pickupLibUsesDifferentTz = null;
+ }
+ });
+ };
+
+ this.handleTargetResourceChange = ($event) => {
+ if ('any' !== $event) {
+ this.newDialog.record.current_resource($event);
+ this.newDialog.record.target_resource($event);
+ }
+ };
+
+ this.useCurrentResourceBarcode = () => {
+ if (this.resourceBarcode) {
+ this.router.navigate(['/staff', 'booking', 'create_reservation', 'for_resource', this.resourceBarcode]);
+ }
+ };
+
+ this.findPatronByBarcode = () => {
+ if (this.patronBarcode) {
+ this.patron.bcSearch(this.patronBarcode).pipe(single()).subscribe(
+ resp => { this.newDialog.record.usr(resp[0].id); },
+ err => { this.toast.danger('No patron found with this barcode'); },
+ );
+ }
+ };
+
+ this.minuteStep = () => {
+ return (this.granularity < 60) ? this.granularity : 30;
+ };
+
+ }
+
+ ngAfterViewInit() {
+ this.dateLimiters.forEach((dl) => dl.initialDate = new Date());
+ this.fetchData();
+
+ this.openTheDialog = (rows: IdlObject[]) => {
+ return this.newDialog.open({size: 'lg'}).subscribe(
+ response => {
+ this.toast.success('Reservation successfully created'); // TODO: needs i18n, pluralization
+ this.fetchData();
+ return response.id();
+ }
+ );
+ };
+
+ this.openCreateDialog = (rows: IdlObject[]) => {
+ if (rows.length) {
+ if (this.multiday) {
+ this.defaultTimes['start_time'] = this.format.momentizeDateString(rows[0]['time'], this.format.wsOrgTimezone);
+ this.defaultTimes['end_time'] = this.format.momentizeDateString(
+ rows[rows.length - 1]['time'], this.format.wsOrgTimezone).clone()
+ .add(this.granularity, 'minutes');
+ } else {
+ this.defaultTimes['start_time'] = Moment.tz('' +
+ this.idealDate.getFullYear() + '-' +
+ (this.idealDate.getMonth() + 1) + '-' +
+ (this.idealDate.getDate()) + ' ' + rows[0]['time'],
+ 'YYYY-MM-DD LT', this.format.wsOrgTimezone);
+ this.defaultTimes['end_time'] = Moment.tz('' +
+ this.idealDate.getFullYear() + '-' +
+ (this.idealDate.getMonth() + 1) + '-' +
+ (this.idealDate.getDate()) + ' ' + rows[rows.length - 1]['time'],
+ 'YYYY-MM-DD LT', this.format.wsOrgTimezone).clone().add(this.granularity, 'minutes');
+ }
+ } else {
+ if (this.multiday) { this.defaultTimes['end_time'] = this.defaultTimes['start_time'].clone().add(1, 'days'); }
+ }
+ if (this.resourceId && !this.resourceTypeId) {
+ this.pcrud.search('brsrc', {id: this.resourceId}, {
+ flesh: 1,
+ limit: 1,
+ flesh_fields: {'brsrc': ['type']}
+ }).subscribe( r => {
+ this.transferable = r.type().transferable();
+ this.resourceTypeId = r.type().id();
+ this.resourceOwner = r.owner();
+ this.openTheDialog(rows);
+ });
+ } else if (this.resourceTypeId) {
+ this.pcrud.search('brt', {id: this.resourceTypeId}, {
+ }).subscribe( t => {
+ this.transferable = t.transferable();
+ this.openTheDialog(rows).then(newId => {
+ if (this.selectedAttributes.length) {
+ const creates$ = [];
+ this.selectedAttributes.forEach(attrValue => {
+ if (attrValue) {
+ const bravm = this.idl.create('bravm');
+ bravm.attr_value(attrValue);
+ bravm.reservation(newId);
+ creates$.push(this.pcrud.create(bravm));
+ }
+ });
+ forkJoin(...creates$).subscribe(() => {
+ this.net.request('open-ils.storage', 'open-ils.storage.booking.reservation.resource_targeter', [newId]); });
+ } else {
+ this.net.request('open-ils.storage', 'open-ils.storage.booking.reservation.resource_targeter', [newId]);
+ }
+ });
+ });
+ }
+ };
+ }
+ handleResourceTypeChange($event: ComboboxEntry) {
+ this.resourceBarcode = null;
+ this.resourceId = null;
+ this.resourceTypeId = $event.id;
+ this.attributes = [];
+ this.selectedAttributes = [];
+ if (this.resourceTypeId) {
+ this.pcrud.search('bra', {resource_type : this.resourceTypeId}, {
+ order_by: 'name ASC',
+ flesh: 1,
+ flesh_fields: {'bra' : ['valid_values']}
+ }).subscribe(
+ a => { this.attributes.push(a);
+ }, err => {
+ console.debug(err);
+ }, () => {
+ this.fetchData();
+ });
+ }
+ }
+
+ fetchData () {
+ this.setGranularity();
+ this.resources = [];
+ const where = {'owner': this.owningLibraries};
+
+ if (this.resourceId) {
+ where['id'] = this.resourceId;
+ } else if (this.resourceTypeId) {
+ where['type'] = this.resourceTypeId;
+ } else {
+ return;
+ }
+
+ if (this.selectedAttributes.length) {
+ where['id'] = {'in': {'from': 'bram', 'select': {'bram': ['resource']}, 'where': {'value': this.selectedAttributes.filter((a) => (a !== null))}}};
+ }
+ this.scheduleSource.data = [];
+ this.pcrud.search('brsrc', where, {
+ order_by: 'barcode ASC',
+ flesh: 1,
+ flesh_fields: {'brsrc': ['attr_maps']},
+ }).subscribe(
+ r => {
+ this.resources.push(r);
+
+ let startTime = Moment();
+ let endTime = Moment();
+ const reservations = [];
+
+ if (this.multiday) {
+ this.dateRangeLimiters.forEach((drl) => {
+ startTime = Moment.tz([drl.fromDate.year, drl.fromDate.month - 1, drl.fromDate.day],
+ this.format.wsOrgTimezone).startOf('day');
+ endTime = Moment.tz([drl.toDate.year, drl.toDate.month - 1, drl.toDate.day],
+ this.format.wsOrgTimezone).endOf('day');
+ });
+ } else {
+ this.dateLimiters.forEach((dl) => {
+ startTime = Moment.tz([
+ dl.current.year,
+ dl.current.month - 1,
+ dl.current.day,
+ this.startOfDay.hour,
+ this.startOfDay.minute],
+ this.format.wsOrgTimezone);
+ endTime = Moment.tz([
+ dl.current.year,
+ dl.current.month - 1,
+ dl.current.day,
+ this.endOfDay.hour,
+ this.endOfDay.minute],
+ this.format.wsOrgTimezone);
+ });
+ }
+ this.pcrud.search('bresv', {
+ '-or': {'target_resource': r.id(), 'current_resource': r.id()},
+ 'end_time': {'>': startTime.toISOString()},
+ 'start_time': {'<': endTime.toISOString()},
+ 'return_time': null,
+ 'cancel_time': null },
+ {'flesh': 1, 'flesh_fields': {'bresv': ['usr']}})
+ .subscribe((res) => { reservations.push(res); },
+ (err) => { console.warn(err); },
+ () => {
+ const currentTime = startTime;
+ while (currentTime < endTime) {
+ let idx: number;
+ let existingRow: number;
+ if (this.multiday) {
+ existingRow = this.scheduleSource.data.findIndex(
+ (row) => row['time'] === this.format.transform({value: currentTime, datatype: 'timestamp'}));
+ idx = (existingRow > -1) ? existingRow :
+ (this.scheduleSource.data.push(
+ {'time': this.format.transform({value: currentTime, datatype: 'timestamp'})}) - 1);
+ } else {
+ existingRow = this.scheduleSource.data.findIndex((row) => row['time'] === currentTime.format('LT')) ;
+ idx = (existingRow > -1) ? existingRow : (this.scheduleSource.data.push({'time': currentTime.format('LT')}) - 1);
+ }
+ reservations.forEach((reservation) => {
+ if ((Moment.tz(reservation.start_time(), this.format.wsOrgTimezone) <
+ (currentTime.clone().add(this.granularity, 'minutes'))) &&
+ (Moment.tz(reservation.end_time(), this.format.wsOrgTimezone) > currentTime)) {
+ if (!this.scheduleSource.data[idx][r.barcode()]) { this.scheduleSource.data[idx][r.barcode()] = []; }
+ this.scheduleSource.data[idx][r.barcode()].push(
+ {'patronLabel': reservation.usr().usrname(), 'patronId': reservation.usr().id()});
+ }
+ });
+ currentTime.add(this.granularity, 'minutes');
+ }
+ });
+ });
+ }
+
+}
+
--- /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>
+
+<div class="card-body row">
+ <div class="col-sm-3">
+ <eg-org-select-with-descendants labelText="Pickup library" i18n-labelText (ouChange)="handlePickupLibChange($event)">
+ </eg-org-select-with-descendants>
+ </div>
+ <div class="col-sm-6 offset-sm-3">
+ <div class="card">
+ <h2 class="card-header" i18n>Filter reservations</h2>
+ <ngb-tabset #filters [activeId]="selectedFilter" (tabChange)="setStickyFilter($event)" class="mt-1">
+ <ngb-tab id="patron">
+ <ng-template ngbTabTitle>
+ <span class="material-icons" *ngIf="patronId">filter_list</span> <span i18n>Filter by patron</span>
+ </ng-template>
+ <ng-template ngbTabContent>
+ <div class="m-2">
+ <div class="input-group m-2">
+ <div class="input-group-prepend">
+ <label class="input-group-text" for="patron-barcode-value" i18n>Patron barcode</label>
+ </div>
+ <input type="text" id="patron-barcode-value" class="form-control" i18n-placeholder placeholder="Patron barcode" [(ngModel)]="patronBarcode" (change)="filterByCurrentPatronBarcode()">
+ <div class="input-group-button">
+ <button *ngIf="patronBarcode" class="btn btn-warning" (click)="removeFilters()" i18n><span class="material-icons">delete</span> Remove filter</button>
+ </div>
+ </div>
+ </div>
+ </ng-template>
+ </ngb-tab>
+ <ngb-tab id="resource">
+ <ng-template ngbTabTitle>
+ <span class="material-icons" *ngIf="resourceId">filter_list</span> <span i18n>Filter by resource</span>
+ </ng-template>
+ <ng-template ngbTabContent>
+ <div class="m-2">
+ <div class="input-group m-2">
+ <div class="input-group-prepend">
+ <label class="input-group-text" for="resource-barcode-value" i18n>Resource barcode</label>
+ </div>
+ <input type="text" id="resource-barcode-value" class="form-control" i18n-placeholder placeholder="Resource barcode" [(ngModel)]="resourceBarcode" (change)="filterByCurrentResourceBarcode()">
+ <div class="input-group-button">
+ <button *ngIf="resourceBarcode" class="btn btn-warning" (click)="removeFilters()" i18n><span class="material-icons">delete</span> Remove filter</button>
+ </div>
+ </div>
+ </div>
+ </ng-template>
+ </ngb-tab>
+ <ngb-tab id="type">
+ <ng-template ngbTabTitle>
+ <span class="material-icons" *ngIf="resourceTypeId">filter_list</span> <span i18n>Filter by resource type</span>
+ </ng-template>
+ <ng-template ngbTabContent>
+ <div class="m-2">
+ <div class="input-group m-2">
+ <div class="input-group-prepend">
+ <label class="input-group-text" for="resource-type-value" i18n>Resource type</label>
+ </div>
+ <eg-resource-type-combobox domId="resource-type-value" (typeChanged)="filterByResourceType($event)" [startId]="resourceTypeId"></eg-resource-type-combobox>
+ <div class="input-group-button">
+ <button class="btn btn-warning" (click)="removeFilters()" i18n><span class="material-icons">delete</span> Remove filter</button>
+ </div>
+ </div>
+ </div>
+ </ng-template>
+ </ngb-tab>
+ </ngb-tabset>
+ </div>
+ </div>
+</div>
+<eg-reservations-grid #reservationsGrid [patron]="patronId" [resource]="resourceId" [resourceType]="resourceTypeId" [pickupLibIds]="pickupLibIds" persistSuffix="manage"></eg-reservations-grid>
+
--- /dev/null
+import {Component, OnInit, ViewChild} from '@angular/core';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {single} from 'rxjs/operators';
+import {NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {PatronService} from '@eg/staff/share/patron.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {ReservationsGridComponent} from './reservations-grid.component';
+import {ServerStoreService} from '@eg/core/server-store.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+
+@Component({
+ selector: 'eg-manage-reservations',
+ templateUrl: './manage-reservations.component.html',
+})
+export class ManageReservationsComponent implements OnInit {
+
+ pickupLibIds: number[];
+ patronBarcode: string;
+ patronId: number;
+ resourceBarcode: string;
+ resourceId: number;
+ resourceTypeId: number;
+ selectedFilter: 'patron' | 'resource' | 'type' = 'patron';
+
+ @ViewChild('reservationsGrid') reservationsGrid: ReservationsGridComponent;
+
+ handlePickupLibChange: ($event: number[]) => void;
+ filterByCurrentPatronBarcode: () => void;
+ filterByCurrentResourceBarcode: () => void;
+ filterByResourceType: (selected: ComboboxEntry) => void;
+ removeFilters: () => void;
+ setStickyFilter: ($event: NgbTabChangeEvent) => void;
+
+ constructor(
+ private route: ActivatedRoute,
+ private router: Router,
+ private pcrud: PcrudService,
+ private patron: PatronService,
+ private store: ServerStoreService,
+ private toast: ToastService
+ ) {
+ }
+
+ ngOnInit() {
+ this.route.paramMap.subscribe((params: ParamMap) => {
+ this.patronId = +params.get('patron_id');
+ this.resourceBarcode = params.get('resource_barcode');
+ this.resourceTypeId = +params.get('resource_type_id');
+
+ if (this.patronId) {
+ this.pcrud.search('au', {
+ 'id': this.patronId,
+ }, {
+ limit: 1,
+ flesh: 1,
+ flesh_fields: {'au': ['card']}
+ }).subscribe(
+ (resp) => {
+ this.reservationsGrid.reloadGrid();
+ this.patronBarcode = resp.card().barcode(); },
+ (err) => { console.debug(err); }
+ );
+ } else if (this.resourceBarcode) {
+ this.selectedFilter = 'resource';
+ this.pcrud.search('brsrc',
+ {'barcode' : this.resourceBarcode}, {'limit': 1})
+ .pipe(single())
+ .subscribe((res) => {
+ this.resourceId = res.id();
+ this.reservationsGrid.reloadGrid();
+ }, (err) => {
+ this.resourceId = -1;
+ this.toast.danger('No resource found with this barcode');
+ });
+ } else if (this.resourceTypeId) {
+ this.selectedFilter = 'type';
+ this.reservationsGrid.reloadGrid();
+ }
+
+ if (!(this.patronId)) {
+ this.store.getItem('eg.booking.manage.filter').then(filter => {
+ if (filter) { this.selectedFilter = filter; }
+ });
+ }
+ });
+
+ this.handlePickupLibChange = ($event: number[]) => {
+ this.pickupLibIds = $event;
+ this.reservationsGrid.reloadGrid();
+ };
+
+ this.setStickyFilter = ($event: NgbTabChangeEvent) => {
+ this.store.setItem('eg.booking.manage.filter', $event.nextId);
+ };
+
+ this.removeFilters = () => {
+ this.router.navigate(['/staff', 'booking', 'manage_reservations']);
+ };
+
+ this.filterByCurrentPatronBarcode = () => {
+ if (this.patronBarcode) {
+ this.patron.bcSearch(this.patronBarcode).pipe(single()).subscribe(
+ (response) => {
+ this.router.navigate(['/staff', 'booking', 'manage_reservations', 'by_patron', response[0].id]);
+ }, (error) => {
+ this.toast.danger('No patron found with this barcode');
+ });
+ } else {
+ this.removeFilters();
+ }
+ };
+
+ this.filterByCurrentResourceBarcode = () => {
+ if (this.resourceBarcode) {
+ this.router.navigate(['/staff', 'booking', 'manage_reservations', 'by_resource', this.resourceBarcode]);
+ } else {
+ this.removeFilters();
+ }
+ };
+
+ this.filterByResourceType = (selected: ComboboxEntry) => {
+ if (selected.id) {
+ this.router.navigate(['/staff', 'booking', 'manage_reservations', 'by_resource_type', selected.id]);
+ }
+ };
+
+ }
+}
+
--- /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, Input, ViewChild, TemplateRef} from '@angular/core';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+
+@Component({
+ selector: 'eg-no-timezone-set-dialog',
+ templateUrl: './no-timezone-set.component.html'
+})
+
+/**
+ * Dialog that warns users that there is no valid lib.timezone setting
+ */
+export class NoTimezoneSetComponent extends DialogComponent {
+ openLSE(): void {
+ window.open('/eg/staff/admin/local/asset/org_unit_settings', '_blank');
+ }
+}
--- /dev/null
+// TODO: Combine with the OU Selector from AdminPage to create a reusable component
+import {Component, EventEmitter, OnInit, Input, Output, ViewChild} from '@angular/core';
+import {AuthService} from '@eg/core/auth.service';
+import {IdlObject} from '@eg/core/idl.service';
+import {OrgService} from '@eg/core/org.service';
+
+@Component({
+ selector: 'eg-org-select-with-descendants',
+ template: ` <div class="input-group">
+ <div class="input-group-prepend">
+ <label [for]="domId" class="input-group-text">{{labelText}}</label>
+ </div>
+ <eg-org-select [domId]="domId" (onChange)="orgOnChange($event)"
+ [initialOrgId]="selectedOrg">
+ </eg-org-select>
+ </div>
+ <div class="form-check">
+ <input type="checkbox" class="form-check-input" id="{{domId}}-include-descendants"
+ (click)="emitArray()" [(ngModel)]="includeOrgDescendants">
+ <label class="form-check-label" for="{{domId}}-include-descendants" i18n>+ Descendants</label>
+ </div>`
+})
+export class OrgSelectWithDescendantsComponent implements OnInit {
+
+ @Input() labelText = 'Library';
+ @Output() ouChange: EventEmitter<number[]>;
+ domId: string;
+
+ selectedOrg: number;
+ includeOrgDescendants = true;
+
+ orgOnChange: ($event: IdlObject) => void;
+ emitArray: () => void;
+
+ constructor(
+ private auth: AuthService,
+ private org: OrgService
+ ) {
+ this.ouChange = new EventEmitter<number[]>();
+ }
+
+ ngOnInit() {
+ this.domId = 'org-select-' + Math.floor(Math.random() * 100000);
+ this.selectedOrg = this.auth.user().ws_ou();
+
+ this.orgOnChange = ($event: IdlObject) => {
+ this.selectedOrg = $event.id();
+ this.emitArray();
+ };
+
+ this.emitArray = () => {
+ if (this.includeOrgDescendants) {
+ this.ouChange.emit(this.org.descendants(this.selectedOrg, true));
+ } else {
+ this.ouChange.emit([this.selectedOrg]);
+ }
+ };
+ }
+
+}
+
--- /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>
+
+<div class="row">
+ <div class="col-md-4">
+ <div class="input-group flex-nowrap">
+ <div class="input-group-prepend">
+ <label class="input-group-text" for="patron-barcode" i18n>Patron barcode</label>
+ <input type="text" id="patron-barcode" class="form-control" i18n-placeholder placeholder="Patron barcode" [(ngModel)]="patronBarcode" (change)="retrievePatron()">
+ </div>
+ </div>
+ </div>
+</div>
+<div *ngIf="patronId">
+ <h2 class="text-center" i18n>Ready for pickup</h2>
+ <div class="form-check">
+ <input class="form-check-input" type="checkbox" [checked]="onlyShowCaptured" id="only-show-captured" (change)="handleShowCapturedChange()">
+ <label class="form-check-label" for="only-show-captured" i18n>Show only captured resources</label>
+ </div>
+ <eg-reservations-grid #readyGrid [patron]="patronId" status="pickupReady" [onlyCaptured]="onlyShowCaptured" persistSuffix="pickup.ready" (onPickup)="this.pickedUpGrid.reloadGrid()"></eg-reservations-grid>
+
+ <h2 class="text-center mt-2" i18n>Already picked up</h2>
+ <eg-reservations-grid #pickedUpGrid [patron]="patronId" status="pickedUp" persistSuffix="pickup.picked_up"></eg-reservations-grid>
+
+</div>
+
--- /dev/null
+import {Component, Input, OnInit, ViewChild} from '@angular/core';
+import {single, tap} from 'rxjs/operators';
+import {Pager} from '@eg/share/util/pager';
+import {PatronService} from '@eg/staff/share/patron.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {AuthService} from '@eg/core/auth.service';
+import {IdlObject} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {ReservationsGridComponent} from './reservations-grid.component';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {ServerStoreService} from '@eg/core/server-store.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+
+
+@Component({
+ templateUrl: './pickup.component.html'
+})
+
+export class PickupComponent implements OnInit {
+ patronBarcode: string;
+ patronId: number;
+ retrievePatron: () => void;
+
+ @ViewChild('readyGrid') readyGrid: ReservationsGridComponent;
+ @ViewChild('pickedUpGrid') pickedUpGrid: ReservationsGridComponent;
+
+ noSelectedRows: (rows: IdlObject[]) => boolean;
+
+ onlyShowCaptured = true;
+ handleShowCapturedChange: () => void;
+
+ constructor(
+ private auth: AuthService,
+ private net: NetService,
+ private pcrud: PcrudService,
+ private patron: PatronService,
+ private route: ActivatedRoute,
+ private router: Router,
+ private store: ServerStoreService,
+ private toast: ToastService
+ ) {
+ }
+
+
+ ngOnInit() {
+ this.route.paramMap.subscribe((params: ParamMap) => {
+ this.patronId = +params.get('patron_id');
+ this.pcrud.search('au', {
+ 'id': this.patronId,
+ }, {
+ limit: 1,
+ flesh: 1,
+ flesh_fields: {'au': ['card']}
+ }).subscribe(
+ (resp) => {
+ this.patronBarcode = resp.card().barcode();
+ this.readyGrid.reloadGrid();
+ this.pickedUpGrid.reloadGrid();
+ }, (err) => { console.debug(err); }
+ );
+ });
+
+ this.retrievePatron = () => {
+ if (this.patronBarcode) {
+ this.patron.bcSearch(this.patronBarcode).pipe(single()).subscribe(
+ resp => { this.router.navigate(['/staff', 'booking', 'pickup', 'by_patron', resp[0].id]); },
+ err => { this.toast.danger('No patron found with this barcode'); },
+ );
+ }
+ };
+
+ this.store.getItem('eg.booking.pickup.ready.only_show_captured').then(onlyCaptured => {
+ if (onlyCaptured != null) { this.onlyShowCaptured = onlyCaptured; }
+ });
+ this.handleShowCapturedChange = () => {
+ this.onlyShowCaptured = !this.onlyShowCaptured;
+ this.readyGrid.reloadGrid();
+ this.store.setItem('eg.booking.pickup.ready.only_show_captured', this.onlyShowCaptured);
+ };
+
+
+ }
+}
+
--- /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)="fill_grid($event.id())"
+ [disableOrgs]="disableOrgs()" [hideOrgs]="disableOrgs()">
+ </eg-org-select>
+ </div>
+ </div>
+ <div class="col-md-4">
+ <div class="input-group">
+ <div class="input-group-prepend">
+ <label for="days-hence" class="input-group-text" i18n>Number of days to fetch:</label>
+ </div>
+ <input type="number" min="1" class="form-control" formControlName="daysHence">
+ </div>
+ </div>
+</form>
+<eg-grid [dataSource]="dataSource" [useLocalSort]="true"
+ [sortable]="true" persistKey="booking.pull_list">
+ <eg-grid-column name="id" [hidden]="true" [index]="true" i18n-label label="ID" path="id"></eg-grid-column>
+ <eg-grid-column label="Shelving location" path="shelving_location" i18n-label></eg-grid-column>
+ <eg-grid-column label="Call number" path="call_number" i18n-label></eg-grid-column>
+ <eg-grid-column label="Call number sortkey" path="call_number_sortkey" i18n-label></eg-grid-column>
+ <eg-grid-column name="barcode" label="Barcode" i18n-label path="current_resource.barcode"></eg-grid-column>
+ <eg-grid-column name="title" label="Title or name" i18n-label path="target_resource_type.name"></eg-grid-column>
+ <eg-grid-column label="Reservation start time" [datePlusTime]="true" path="reservations.0.start_time" i18n-label></eg-grid-column>
+ <eg-grid-column label="Reservation end time" [datePlusTime]="true" path="reservations.0.end_time" i18n-label></eg-grid-column>
+ <eg-grid-column label="Patron first name" path="reservations.0.usr.first_given_name" i18n-label></eg-grid-column>
+ <eg-grid-column label="Patron last name" path="reservations.0.usr.family_name" i18n-label></eg-grid-column>
+
+</eg-grid>
+
--- /dev/null
+import {Component, Input, OnInit} from '@angular/core';
+import {FormControl, FormGroup, Validators} from '@angular/forms';
+import {AuthService} from '@eg/core/auth.service';
+import {GridColumn, GridDataSource} from '@eg/share/grid/grid';
+import {IdlObject} from '@eg/core/idl.service';
+import {NetRequest, NetService} from '@eg/core/net.service';
+import {OrgService} from '@eg/core/org.service';
+import {Pager} from '@eg/share/util/pager';
+import {PcrudService} from '@eg/core/pcrud.service';
+
+
+@Component({
+ templateUrl: './pull-list.component.html'
+})
+
+export class PullListComponent implements OnInit {
+ public dataSource: GridDataSource;
+
+ public disableOrgs: () => number[];
+ public fill_grid: (orgId?: number) => void;
+ pullListCriteria: FormGroup;
+
+ constructor(
+ private auth: AuthService,
+ private net: NetService,
+ private org: OrgService,
+ private pcrud: PcrudService
+ ) { }
+
+
+ ngOnInit() {
+ this.pullListCriteria = new FormGroup({
+ 'daysHence': new FormControl(5, [
+ Validators.required,
+ Validators.min(1)])
+ });
+
+ this.pullListCriteria.valueChanges.subscribe(() => { this.fill_grid(); });
+
+ this.disableOrgs = () => this.org.filterList( { canHaveVolumes : false }, true);
+
+ this.fill_grid = (orgId = this.auth.user().ws_ou()) => {
+ this.net.request(
+ 'open-ils.booking', 'open-ils.booking.reservations.get_pull_list',
+ this.auth.token(), null,
+ (86400 * this.daysHence.value), // convert seconds to days
+ orgId
+ ).subscribe( data => {
+ data.forEach(resource => { // shouldn't this be streamable?
+ if (resource['target_resource_type'].catalog_item()) {
+ this.pcrud.search('acp', {
+ 'barcode': resource['current_resource'].barcode()
+ }, {
+ limit: 1,
+ flesh: 1,
+ flesh_fields: {'acp' : ['call_number', 'location' ]}
+ }).subscribe( (acp) => {
+ resource['call_number'] = acp.call_number().label();
+ resource['call_number_sortkey'] = acp.call_number().label_sortkey();
+ resource['shelving_location'] = acp.location().name();
+ });
+ }
+ });
+ this.dataSource.data = data;
+ });
+ };
+ this.dataSource = new GridDataSource();
+ this.fill_grid(this.auth.user().ws_ou());
+ }
+ get daysHence() {
+ return this.pullListCriteria.get('daysHence');
+ }
+}
+
--- /dev/null
+import {Injectable} from '@angular/core';
+import {IdlObject} from '@eg/core/idl.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import * as Moment from 'moment-timezone';
+
+@Injectable({providedIn: 'root'})
+export class ReservationValidateService {
+
+ constructor(
+ private pcrud: PcrudService,
+ ) {
+ }
+ errorMessage = '';
+
+ private duringExistingReservation = (value: Moment, record: IdlObject) => {
+ if (record.target_resource()) {
+ this.pcrud.search('bresv', {
+ 'cancel_time': null,
+ 'return_time': null,
+ 'start_time': {'<': value.toISOString()},
+ 'end_time': {'>': value.toISOString()},
+ '-or': {'current_resource': record.target_resource(), 'target_resource': record.target_resource()}})
+ .subscribe((foundOne) => {this.errorMessage = 'There is already a reservation for this resource at this time.'});
+ }
+ }
+
+ start_time = (fieldName: string, value: Moment, record: IdlObject) => {
+ this.errorMessage = '';
+ this.duringExistingReservation(value, record);
+ if (record.target_resource_type() && record.target_resource()) {
+ this.pcrud.retrieve('brt', record.target_resource_type())
+ .subscribe((brt) => {
+ if (brt.catalog_item()) {
+ this.pcrud.retrieve('brsrc', record.target_resource())
+ .subscribe((brsrc) => {
+ this.pcrud.search('circ', {
+ 'checkin_time': 'null',
+ 'target_copy': {'barcode': brsrc.barcode()},
+ 'due_date': {'>': value.toISOString()}},
+ {'flesh': 1, 'flesh_fields': {'circ': ['target_copy']}})
+ .subscribe(() => {this.errorMessage = 'Start time conflicts with an existing circulation';});
+ });
+ }
+ });
+ }
+ if (Moment(value) < Moment()) {
+ this.errorMessage = 'Start time must be in the future';
+ }
+ return this.errorMessage;
+ }
+
+ end_time = (fieldName: string, value: Moment, record: IdlObject) => {
+ this.errorMessage = '';
+ this.duringExistingReservation(value, record);
+ if (Moment(value) <= Moment(record.start_time())) {
+ return 'End time must be after start time';
+ }
+ return '';
+ }
+
+}
+
--- /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 [action]="editSelected" [disableOnRows]="editNotAppropriate"></eg-grid-toolbar-action>
+ <eg-grid-toolbar-action label="Cancel Selected" i18n-label [action]="cancelSelected" [disableOnRows]="cancelNotAppropriate"></eg-grid-toolbar-action>
+ <eg-grid-toolbar-action label="Pick Up Selected" i18n-label [action]="pickupSelected" [disableOnRows]="pickupNotAppropriate"></eg-grid-toolbar-action>
+ <eg-grid-toolbar-action label="Return Selected" i18n-label [action]="returnSelected" [disableOnRows]="returnNotAppropriate"></eg-grid-toolbar-action>
+ <eg-grid-toolbar-action label="View Reservations for This Patron" i18n-label [action]="viewByPatron" [disableOnRows]="notOnePatronSelected"></eg-grid-toolbar-action>
+ <eg-grid-toolbar-action label="View Reservations for This Resource" i18n-label [action]="viewByResource" [disableOnRows]="notOneResourceSelected"></eg-grid-toolbar-action>
+ <eg-grid-toolbar-button *ngIf="!status" label="Create New Reservation" i18n-label [action]="redirectToCreate"></eg-grid-toolbar-button>
+
+ <eg-grid-column name="id" [hidden]="true" [index]="true" i18n-label label="ID" path="id"></eg-grid-column>
+ <eg-grid-column label="Patron username" [hidden]="true" i18n-label path="usr.usrname"></eg-grid-column>
+ <eg-grid-column label="Patron barcode" i18n-label path="usr.card.barcode"></eg-grid-column>
+ <eg-grid-column label="Patron first name" i18n-label path="usr.first_given_name"></eg-grid-column>
+ <eg-grid-column label="Patron middle name" i18n-label [hidden]="true" path="usr.second_given_name"></eg-grid-column>
+ <eg-grid-column label="Patron family name" i18n-label path="usr.family_name"></eg-grid-column>
+ <eg-grid-column name="start_time" label="Start Time" [datePlusTime]="true" i18n-label path="start_time" datatype="timestamp"></eg-grid-column>
+ <eg-grid-column name="end_time" label="End Time" [datePlusTime]="true" i18n-label path="end_time" datatype="timestamp"></eg-grid-column>
+ <eg-grid-column name="request_time" label="Request Time" [datePlusTime]="true" i18n-label path="request_time" datatype="timestamp"></eg-grid-column>
+ <eg-grid-column name="capture_time" label="Capture Time" [datePlusTime]="true" i18n-label path="capture_time" datatype="timestamp"></eg-grid-column>
+ <eg-grid-column name="pickup_time" label="Pickup Time" [datePlusTime]="true" i18n-label path="pickup_time" datatype="timestamp"></eg-grid-column>
+ <eg-grid-column label="Email notify" i18n-label [hidden]="true" path="email_notify" datatype="bool"></eg-grid-column>
+ <eg-grid-column i18n-label [hidden]="true" path="unrecovered" datatype="bool"></eg-grid-column>
+ <eg-grid-column label="Billing total" i18n-label path="billing_total" datatype="money"></eg-grid-column>
+ <eg-grid-column label="Payment total" i18n-label path="payment_total" datatype="money"></eg-grid-column>
+ <eg-grid-column label="Booking interval" i18n-label [hidden]="true" path="booking_interval" [hidden]="true"></eg-grid-column>
+ <eg-grid-column label="Fine interval" i18n-label [hidden]="true" path="fine_interval" [hidden]="true"></eg-grid-column>
+ <eg-grid-column label="Fine amount" i18n-label [hidden]="true" path="fine_amount" datatype="money"></eg-grid-column>
+ <eg-grid-column label="Maximum fine" i18n-label [hidden]="true" path="max_fine" datatype="money"></eg-grid-column>
+ <eg-grid-column i18n-label label="Resource Barcode" path="current_resource.barcode"></eg-grid-column>
+ <eg-grid-column i18n-label label="Note" path="note"></eg-grid-column>
+ <eg-grid-column i18n-label label="Resource Type" path="target_resource_type.name"></eg-grid-column>
+ <eg-grid-column label="Reservation length" i18n-label path="length"></eg-grid-column>
+ <eg-grid-column label="Request library" i18n-label path="request_lib.name"></eg-grid-column>
+ <eg-grid-column label="Pickup library" i18n-label path="pickup_lib.name"></eg-grid-column>
+ <eg-grid-column label="Pickup library timezone" i18n-label path="timezone"></eg-grid-column>
+
+</eg-grid>
+
+<eg-fm-record-editor #editDialog
+ idlClass="bresv"
+ datetimeFields="start_time,end_time"
+ hiddenFields="xact_finish,cancel_time,booking_interval"
+ [fieldOptions]="{start_time:{validator:reservationValidate.start_time},end_time:{validator:reservationValidate.end_time}}"
+ [readonlyFields]="listReadOnlyFields()">
+</eg-fm-record-editor>
+<eg-confirm-dialog #confirmCancelReservationDialog
+ i18n-dialogTitle i18n-dialogBody
+ dialogTitle="Confirm Cancelation"
+ dialogBody="Are you sure you want to cancel {numRowsSelected, plural, =1 {this reservation} other {these {{numRowsSelected}} reservations}}?">
+</eg-confirm-dialog>
+<eg-no-timezone-set-dialog #noTimezoneSetDialog>
+</eg-no-timezone-set-dialog>
+
--- /dev/null
+import {Component, EventEmitter, Input, Output, OnInit, ViewChild} from '@angular/core';
+import {Observable} from 'rxjs';
+import {tap} from 'rxjs/operators';
+import {AuthService} from '@eg/core/auth.service';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {FormatService} from '@eg/core/format.service';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {GridDataSource} from '@eg/share/grid/grid';
+import {IdlObject} from '@eg/core/idl.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {Pager} from '@eg/share/util/pager';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {NetService} from '@eg/core/net.service';
+import {NoTimezoneSetComponent} from './no-timezone-set.component';
+import {OrgService} from '@eg/core/org.service';
+import {PatronService} from '@eg/staff/share/patron.service';
+import {ReservationValidateService} from './reservation-validate.service';
+
+import * as Moment from 'moment-timezone';
+
+@Component({
+ selector: 'eg-reservations-grid',
+ templateUrl: './reservations-grid.component.html',
+})
+export class ReservationsGridComponent implements OnInit {
+
+ @Input() patron: number;
+ @Input() resource: number;
+ @Input() resourceType: number;
+ @Input() pickupLibIds: number[];
+ @Input() status: 'pickupReady' | 'pickedUp' | 'returnReady' | 'returnedToday';
+ @Input() persistSuffix: string;
+ @Input() onlyCaptured = false;
+
+ @Output() onPickup = new EventEmitter<IdlObject>();
+
+ gridSource: GridDataSource;
+ patronBarcode: string;
+ numRowsSelected: number;
+
+ @ViewChild('grid') grid: GridComponent;
+ @ViewChild('editDialog') editDialog: FmRecordEditorComponent;
+ @ViewChild('confirmCancelReservationDialog')
+ private _cancelReservationDialog: ConfirmDialogComponent;
+ @ViewChild('noTimezoneSetDialog') noTimezoneSetDialog: NoTimezoneSetComponent;
+
+ editSelected: (rows: IdlObject[]) => void;
+ pickupSelected: (rows: IdlObject[]) => void;
+ pickupResource: (rows: IdlObject) => Observable<any>;
+ returnSelected: (rows: IdlObject[]) => void;
+ returnResource: (rows: IdlObject) => Observable<any>;
+ cancelSelected: (rows: IdlObject[]) => void;
+ viewByPatron: (rows: IdlObject[]) => void;
+ viewByResource: (rows: IdlObject[]) => void;
+ filterByCurrentPatronBarcode: () => void;
+ filterByCurrentResourceBarcode: () => void;
+ listReadOnlyFields: () => string;
+
+ handleRowActivate: (row: IdlObject) => void;
+ redirectToCreate: () => void;
+
+ reloadGrid: () => void;
+
+ noSelectedRows: (rows: IdlObject[]) => boolean;
+ notOnePatronSelected: (rows: IdlObject[]) => boolean;
+ notOneResourceSelected: (rows: IdlObject[]) => boolean;
+ cancelNotAppropriate: (rows: IdlObject[]) => boolean;
+ pickupNotAppropriate: (rows: IdlObject[]) => boolean;
+ editNotAppropriate: (rows: IdlObject[]) => boolean;
+ returnNotAppropriate: (rows: IdlObject[]) => boolean;
+
+ constructor(
+ private auth: AuthService,
+ private format: FormatService,
+ private pcrud: PcrudService,
+ private route: ActivatedRoute,
+ private router: Router,
+ private toast: ToastService,
+ private net: NetService,
+ private org: OrgService,
+ private patronService: PatronService,
+ public reservationValidate: ReservationValidateService
+ ) {
+
+ }
+
+ ngOnInit() {
+ if (!(this.format.wsOrgTimezone)) {
+ this.noTimezoneSetDialog.open();
+ }
+
+
+ this.gridSource = new GridDataSource();
+
+ this.gridSource.getRows = (pager: Pager, sort: any[]) => {
+ const orderBy: any = {};
+ const where = {
+ 'usr' : (this.patron ? this.patron : {'>' : 0}),
+ 'target_resource_type' : (this.resourceType ? this.resourceType : {'>' : 0}),
+ 'cancel_time' : null,
+ 'xact_finish' : null,
+ };
+ if (this.resource) {
+ where['current_resource'] = this.resource;
+ }
+ if (this.pickupLibIds) {
+ where['pickup_lib'] = this.pickupLibIds;
+ }
+ if (this.onlyCaptured) {
+ where['capture_time'] = {'!=': null};
+ }
+
+ if (this.status) {
+ if ('pickupReady' === this.status) {
+ where['pickup_time'] = null;
+ where['start_time'] = {'!=': null};
+ } else if ('pickedUp' === this.status || 'returnReady' === this.status) {
+ where['pickup_time'] = {'!=': null};
+ where['return_time'] = null;
+ } else if ('returnedToday' === this.status) {
+ where['return_time'] = {'>': Moment().startOf('day').toISOString()};
+ }
+ }
+ if (sort.length) {
+ orderBy.bresv = sort[0].name + ' ' + sort[0].dir;
+ }
+ return this.pcrud.search('bresv', where, {
+ order_by: orderBy,
+ limit: pager.limit,
+ offset: pager.offset,
+ flesh: 2,
+ flesh_fields: {'bresv' : [
+ 'usr', 'capture_staff', 'target_resource', 'target_resource_type', 'current_resource', 'request_lib', 'pickup_lib'
+ ], 'au': ['card'] }
+ }).pipe(tap((row) => {
+ row['length'] = Moment(row['end_time']()).from(Moment(row['start_time']()), true);
+ this.org.settings('lib.timezone', row['pickup_lib']()).then((tz) => { row['timezone'] = tz['lib.timezone']; });
+ }));
+ };
+
+ this.editDialog.mode = 'update';
+ this.editSelected = (idlThings: IdlObject[]) => {
+ const editOneThing = (thing: IdlObject) => {
+ if (!thing) { return; }
+ this.showEditDialog(thing).subscribe(
+ () => editOneThing(idlThings.shift()));
+ };
+ editOneThing(idlThings.shift()); };
+
+ this.cancelSelected = (reservations: IdlObject[]) => {
+ const reservationIds = reservations.map(reservation => reservation.id());
+ this.numRowsSelected = reservationIds.length;
+ this._cancelReservationDialog.open()
+ .subscribe(
+ confirmed => {this.net.request(
+ 'open-ils.booking',
+ 'open-ils.booking.reservations.cancel',
+ this.auth.token(), reservationIds)
+ .subscribe(
+ (res) => this.handleSuccessfulCancel(res),
+ (err) => alert('ERR: ' + JSON.stringify(err))
+ );
+ });
+ };
+
+ this.viewByPatron = (reservations: IdlObject[]) => {
+ const patronIds = reservations.map(reservation => reservation.usr().id());
+ this.router.navigate(['/staff', 'booking', 'manage_reservations', 'by_patron', patronIds[0]]);
+ };
+
+ this.viewByResource = (reservations: IdlObject[]) => {
+ const resourceBarcodes = reservations.map(reservation => reservation.current_resource().barcode());
+ this.filterByResourceBarcode(resourceBarcodes[0]);
+ };
+
+ this.noSelectedRows = (rows: IdlObject[]) => (rows.length === 0);
+ this.notOnePatronSelected = (rows: IdlObject[]) => (new Set(rows.map(row => row.usr().id())).size !== 1);
+ this.notOneResourceSelected = (rows: IdlObject[]) =>
+ (new Set(rows.map(row => { if (row.current_resource()) { return row.current_resource().id(); }})).size !== 1);
+ this.cancelNotAppropriate = (rows: IdlObject[]) =>
+ (this.noSelectedRows(rows) || ['pickedUp', 'returnReady', 'returnedToday'].includes(this.status));
+ this.pickupNotAppropriate = (rows: IdlObject[]) => (this.noSelectedRows(rows) || ('pickupReady' !== this.status));
+ this.editNotAppropriate = (rows: IdlObject[]) => (this.noSelectedRows(rows) || ('returnedToday' === this.status));
+ this.returnNotAppropriate = (rows: IdlObject[]) => {
+ if (this.noSelectedRows(rows)) {
+ return true;
+ } else if (this.status && ('pickupReady' === this.status)) {
+ return true;
+ } else {
+ rows.forEach(row => {
+ if ((null == row.pickup_time()) || row.return_time()) { return true; }
+ });
+ }
+ return false;
+ };
+
+ this.reloadGrid = () => { this.grid.reload(); };
+
+ this.pickupSelected = (reservations: IdlObject[]) => {
+ const pickupOne = (thing: IdlObject) => {
+ if (!thing) { return; }
+ this.pickupResource(thing).subscribe(
+ () => pickupOne(reservations.shift()));
+ };
+ pickupOne(reservations.shift());
+ };
+
+ this.returnSelected = (reservations: IdlObject[]) => {
+ const returnOne = (thing: IdlObject) => {
+ if (!thing) { return; }
+ this.returnResource(thing).subscribe(
+ () => returnOne(reservations.shift()));
+ };
+ returnOne(reservations.shift());
+ };
+
+ this.pickupResource = (reservation: IdlObject) => {
+ return this.net.request(
+ 'open-ils.circ',
+ 'open-ils.circ.reservation.pickup',
+ this.auth.token(),
+ {'patron_barcode': reservation.usr().card().barcode(), 'reservation': reservation})
+ .pipe(tap(
+ (success) => {
+ this.onPickup.emit(reservation);
+ this.grid.reload(); },
+ (error) => { console.debug(error); }
+ ));
+ };
+
+ this.returnResource = (reservation: IdlObject) => {
+ return this.net.request(
+ 'open-ils.circ',
+ 'open-ils.circ.reservation.return',
+ this.auth.token(),
+ {'patron_barcode': this.patronBarcode, 'reservation': reservation})
+ .pipe(tap(
+ (success) => { this.grid.reload(); },
+ (error) => { console.debug(error); }
+ ));
+ };
+
+ this.listReadOnlyFields = () => {
+ let list = 'usr,xact_start,request_time,capture_time,pickup_time,return_time,capture_staff,target_resource_type,' +
+ 'current_resource,target_resource,unrecovered,request_library,pickup_library,fine_interval,fine_amount,max_fine';
+ if (this.status && ('pickupReady' !== this.status)) { list = list + ',start_time'; }
+ if (this.status && ('returnedToday' === this.status)) { list = list + ',end_time'; }
+ return list;
+ };
+
+ this.handleRowActivate = (row: IdlObject) => {
+ if (this.status) {
+ if ('returnReady' === this.status) {
+ this.returnResource(row).subscribe();
+ } else if ('pickupReady' === this.status) {
+ this.pickupResource(row).subscribe();
+ } else if ('returnedToday' === this.status) {
+ this.toast.warning('Cannot edit this reservation');
+ } else {
+ this.showEditDialog(row);
+ }
+ } else {
+ this.showEditDialog(row);
+ }
+ };
+
+ this.redirectToCreate = () => {
+ this.router.navigate(['/staff', 'booking', 'create_reservation']);
+ };
+ }
+
+ showEditDialog(idlThing: IdlObject) {
+ this.editDialog.recId = idlThing.id();
+ this.editDialog.timezone = idlThing['timezone'];
+ return this.editDialog.open({size: 'lg'}).pipe(tap(
+ ok => {
+ this.toast.success('Reservation successfully updated'); // TODO: needs i18n, pluralization
+ this.grid.reload();
+ }
+ ));
+ }
+
+ handleSuccessfulCancel(res: any) {
+ this.toast.success('Reservation successfully canceled'); // TODO: needs i18n, pluralization
+ this.grid.reload();
+ }
+ filterByResourceBarcode(barcode: string) {
+ this.router.navigate(['/staff', 'booking', 'manage_reservations', 'by_resource', barcode]);
+ }
+}
+
--- /dev/null
+import {Component, EventEmitter, OnInit, Input, Output, ViewChild} from '@angular/core';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {ComboboxComponent} from '@eg/share/combobox/combobox.component';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+
+@Component({
+ selector: 'eg-resource-type-combobox',
+ template: `<eg-combobox
+ #resourceTypeCombobox
+ [attr.id]="domId"
+ placeholder="Resource type" i18n-placeholder
+ [entries]="resourceTypes"
+ (onChange)="typeChanged.emit($event)"
+ [startId]="startId"></eg-combobox>`
+})
+export class ResourceTypeComboboxComponent implements OnInit {
+
+ resourceTypes: ComboboxEntry[];
+
+ clear: () => void;
+
+ @Input() domId = '';
+ @Input() startId: number;
+ @Output() typeChanged: EventEmitter<ComboboxEntry>;
+
+ @ViewChild('resourceTypeCombobox') resourceTypeCombobox: ComboboxComponent;
+
+ constructor(private pcrud: PcrudService) {
+ this.typeChanged = new EventEmitter<ComboboxEntry>();
+ }
+
+ ngOnInit() {
+ this.pcrud.retrieveAll('brt', {order_by: {brt: 'name'}})
+ .subscribe(type => {
+ if (!this.resourceTypes) { this.resourceTypes = []; }
+ this.resourceTypes.push({id: type.id(), label: type.name()});
+ }, (err) => {},
+ () => {this.resourceTypes.sort((a, b) => a.label.localeCompare(b.label)); });
+ this.clear = () => {
+ this.resourceTypeCombobox.selected = {id: '', label: ''};
+ };
+ }
+
+}
+
--- /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>
+
+<ngb-tabset (tabChange)="handleTabChange($event)" [activeId]="selectedTab">
+ <ngb-tab title="By patron" i18n-title id="patron">
+ <ng-template ngbTabContent>
+ <div class="row">
+ <div class="col-md-4">
+ <div class="input-group flex-nowrap">
+ <div class="input-group-prepend">
+ <label class="input-group-text" for="patron-barcode" i18n>Patron barcode</label>
+ <input type="text" id="patron-barcode" class="form-control" i18n-placeholder placeholder="Patron barcode" [(ngModel)]="patronBarcode" (change)="retrievePatronByBarcode()">
+ </div>
+ </div>
+ </div>
+ </div>
+ <div *ngIf="patronId">
+ <h2 class="text-center" i18n>Ready for return</h2>
+ <eg-reservations-grid #readyGrid [patron]="patronId" status="returnReady" (onReturn)="this.returnedGrid.reloadGrid()" persistSuffix="return.patron.picked_up"></eg-reservations-grid>
+
+ <h2 class="text-center" i18n>Returned today</h2>
+ <eg-reservations-grid #returnedGrid [patron]="patronId" status="returnedToday" persistSuffix="return.patron.returned"></eg-reservations-grid>
+ </div>
+ </ng-template>
+ </ngb-tab>
+ <ngb-tab title="By resource" i18n-title id="resource">
+ <ng-template ngbTabContent>
+ <div class="input-group flex-nowrap">
+ <div class="input-group-prepend">
+ <label class="input-group-text" for="resource-barcode" i18n>Resource barcode</label>
+ <input type="text" id="resource-barcode" class="form-control" i18n-placeholder placeholder="Resource barcode" [(ngModel)]="resourceBarcode" (change)="retrievePatronByResource()">
+ </div>
+ </div>
+ <div *ngIf="patronId">
+ <h2 class="text-center" i18n>Ready for return</h2>
+ <eg-reservations-grid #readyGrid [patron]="patronId" status="returnReady" (onReturn)="this.returnedGrid.reloadGrid()" persistSuffix="return.resource.picked_up"></eg-reservations-grid>
+
+ <h2 class="text-center" i18n>Returned today</h2>
+ <eg-reservations-grid #returnedGrid [patron]="patronId" status="returnedToday" persistSuffix="return.resource.returned"></eg-reservations-grid>
+ </div>
+ </ng-template>
+ </ngb-tab>
+</ngb-tabset>
+
--- /dev/null
+import {Component, Input, OnInit, QueryList, ViewChildren} from '@angular/core';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap';
+import {single} from 'rxjs/operators';
+import {GridDataSource} from '@eg/share/grid/grid';
+import {Pager} from '@eg/share/util/pager';
+import {PatronService} from '@eg/staff/share/patron.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {AuthService} from '@eg/core/auth.service';
+import {IdlObject} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {ReservationsGridComponent} from './reservations-grid.component';
+import {ServerStoreService} from '@eg/core/server-store.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+
+
+@Component({
+ templateUrl: './return.component.html'
+})
+
+export class ReturnComponent implements OnInit {
+ resourceBarcode: string;
+ patronBarcode: string;
+ patronId: number;
+ retrievePatronByBarcode: () => void;
+ retrievePatronByResource: () => void;
+ selectedTab: 'patron' | 'resource' = 'patron';
+
+ noSelectedRows: (rows: IdlObject[]) => boolean;
+ handleTabChange: ($event: NgbTabChangeEvent) => void;
+ @ViewChildren('readyGrid') readyGrids: QueryList<ReservationsGridComponent>;
+ @ViewChildren('returnedGrid') returnedGrids: QueryList<ReservationsGridComponent>;
+
+ constructor(
+ private auth: AuthService,
+ private net: NetService,
+ private pcrud: PcrudService,
+ private patron: PatronService,
+ private route: ActivatedRoute,
+ private router: Router,
+ private store: ServerStoreService,
+ private toast: ToastService
+ ) {
+ }
+
+
+ ngOnInit() {
+ this.route.paramMap.subscribe((params: ParamMap) => {
+ this.patronId = +params.get('patron_id');
+ if (this.patronId) {
+ this.pcrud.search('au', {
+ 'id': this.patronId,
+ }, {
+ limit: 1,
+ flesh: 1,
+ flesh_fields: {'au': ['card']}
+ }).subscribe(
+ (resp) => {
+ this.patronBarcode = resp.card().barcode();
+ this.readyGrids.forEach (readyGrid => readyGrid.reloadGrid());
+ this.returnedGrids.forEach (returnedGrid => returnedGrid.reloadGrid());
+ }, (err) => { console.debug(err); }
+ );
+ } else {
+ this.store.getItem('eg.booking.return.tab').then(tab => {
+ if (tab) { this.selectedTab = tab; }
+ });
+ }
+ });
+
+ this.retrievePatronByBarcode = () => {
+ if (this.patronBarcode) {
+ this.patron.bcSearch(this.patronBarcode).pipe(single()).subscribe(
+ resp => { this.router.navigate(['/staff', 'booking', 'return', 'by_patron', resp[0].id]); },
+ err => { this.toast.danger('No patron found with this barcode'); },
+ );
+ }
+ };
+
+ this.retrievePatronByResource = () => {
+ if (this.resourceBarcode) {
+ this.pcrud.search('brsrc', {'barcode': this.resourceBarcode}, {
+ order_by: {'curr_rsrcs': 'pickup_time DESC'},
+ limit: 1,
+ flesh: 1,
+ flesh_fields: {'brsrc': ['curr_rsrcs']},
+ select: {'curr_rsrcs': {'return_time': null, 'pickup_time': {'!=': null}}}
+ }).subscribe((resp) => {
+ if (resp.curr_rsrcs()[0].usr()) {
+ this.patronId = resp.curr_rsrcs()[0].usr();
+ this.readyGrids.forEach (readyGrid => readyGrid.reloadGrid());
+ this.returnedGrids.forEach (returnedGrid => returnedGrid.reloadGrid());
+ }
+ });
+ }
+ };
+ this.noSelectedRows = (rows: IdlObject[]) => (rows.length === 0);
+
+ this.handleTabChange = ($event) => {
+ this.store.setItem('eg.booking.return.tab', $event.nextId)
+ .then(() => {
+ this.router.navigate(['/staff', 'booking', 'return']);
+ this.resourceBarcode = null;
+ this.patronBarcode = null;
+ this.patronId = null;
+ });
+ };
+
+ }
+}
+
--- /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 {}
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);
+ }
+
+}
+
*/
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}
* Required valid fields are left-border styled in green-ish.
* Invalid fields are left-border styled in red-ish.
*/
-.form-validated .ng-valid[required], .form-validated .ng-valid.required {
+.form-validated .ng-valid[required], .form-validated .ng-valid.required, input[formcontrolname].ng-valid {
border-left: 5px solid #78FA89;
}
-.form-validated .ng-invalid:not(form) {
+.form-validated .ng-invalid:not(form), input[formcontrolname].ng-invalid {
border-left: 5px solid #FA787E;
}
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);
INSERT INTO config.workstation_setting_type (name,label,grp,datatype)
VALUES ('eg.circ.bills.annotatepayment','Bills: Annotate Payment', 'circ', 'bool');
+
+INSERT INTO config.workstation_setting_type (name, grp, datatype, label)
+VALUES (
+ 'eg.grid.booking.manage', 'gui', 'object',
+ oils_i18n_gettext(
+ 'booking.manage',
+ 'Grid Config: Booking Manage Reservations',
+ 'cwst', 'label')
+), (
+ 'eg.grid.booking.pickup.ready', 'gui', 'object',
+ oils_i18n_gettext(
+ 'booking.pickup.ready',
+ 'Grid Config: Booking Ready to pick up grid',
+ 'cwst', 'label')
+), (
+ 'eg.grid.booking.pickup.picked_up', 'gui', 'object',
+ oils_i18n_gettext(
+ 'booking.pickup.picked_up',
+ 'Grid Config: Booking Already Picked Up grid',
+ 'cwst', 'label')
+), (
+ 'eg.grid.booking.return.patron.picked_up', 'gui', 'object',
+ oils_i18n_gettext(
+ 'booking.return.patron.picked_up',
+ 'Grid Config: Booking Return Patron tab Already Picked Up grid',
+ 'cwst', 'label')
+), (
+ 'eg.grid.booking.return.patron.returned', 'gui', 'object',
+ oils_i18n_gettext(
+ 'booking.return.patron.returned',
+ 'Grid Config: Booking Return Patron tab Returned Today grid',
+ 'cwst', 'label')
+), (
+ 'eg.grid.booking.return.resource.picked_up', 'gui', 'object',
+ oils_i18n_gettext(
+ 'booking.return.resourcce.picked_up',
+ 'Grid Config: Booking Return Resource tab Already Picked Up grid',
+ 'cwst', 'label')
+), (
+ 'eg.grid.booking.return.resource.returned', 'gui', 'object',
+ oils_i18n_gettext(
+ 'booking.return.resource.returned',
+ 'Grid Config: Booking Return Resource tab Returned Today grid',
+ 'cwst', 'label')
+), (
+ 'eg.booking.manage.filter', 'gui', 'string',
+ oils_i18n_gettext(
+ 'booking.manage.filter',
+ 'Sticky setting for filter tab in Manage Reservations',
+ 'cwst', 'label')
+), (
+ 'eg.booking.return.tab', 'gui', 'string',
+ oils_i18n_gettext(
+ 'booking.return.tab',
+ 'Sticky setting for tab in Booking Return',
+ 'cwst', 'label')
+), (
+ 'eg.booking.create.granularity', 'gui', 'integer',
+ oils_i18n_gettext(
+ 'booking.create.granularity',
+ 'Sticky setting for granularity combobox in Booking Create',
+ 'cwst', 'label')
+), (
+ 'eg.booking.create.multiday', 'gui', 'bool',
+ oils_i18n_gettext(
+ 'booking.create.multiday',
+ 'Default to creating multiday booking reservations',
+ 'cwst', 'label')
+), (
+ 'eg.booking.pickup.ready.only_show_captured', 'gui', 'bool',
+ oils_i18n_gettext(
+ 'booking.pickup.ready.only_show_captured',
+ 'Include only resources that have been captured in the Ready grid in the Pickup screen',
+ 'cwst', 'label')
+);
+>>>>>>> 6ad3870841... LP1816475: Booking module refresh
--- /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.filter', 'gui', 'string',
+ oils_i18n_gettext(
+ 'booking.manage.filter',
+ 'Sticky setting for filter tab in Manage Reservations',
+ 'cwst', 'label')
+), (
+ 'eg.booking.return.tab', 'gui', 'string',
+ oils_i18n_gettext(
+ 'booking.return.tab',
+ 'Sticky setting for tab in Booking Return',
+ 'cwst', 'label')
+), (
+ 'eg.booking.create.granularity', 'gui', 'integer',
+ oils_i18n_gettext(
+ 'booking.create.granularity',
+ 'Sticky setting for granularity combobox in Booking Create',
+ 'cwst', 'label')
+), (
+ 'eg.booking.create.multiday', 'gui', 'bool',
+ oils_i18n_gettext(
+ 'booking.create.multiday',
+ 'Default to creating multiday booking reservations',
+ 'cwst', 'label')
+), (
+ 'eg.booking.pickup.ready.only_show_captured', 'gui', 'bool',
+ oils_i18n_gettext(
+ 'booking.pickup.ready.only_show_captured',
+ 'Include only resources that have been captured in the Ready grid in the Pickup screen',
+ 'cwst', 'label')
+);
+
+COMMIT;
--- /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="add_copies_to_bucket()">[% l('Add Items to Bucket') %]</a></li>
<li><a href ng-click="make_copies_bookable()">[% l('Make Items Bookable') %]</a></li>
<li><a href ng-click="book_copies_now()">[% l('Book Item Now') %]</a></li>
+ <li><a href ng-click="manage_reservations()">[% l('Manage Reservations') %]</a></li>
<li><a href ng-click="requestItems()">[% l('Request Items') %]</a></li>
<li><a href ng-click="attach_to_peer_bib()">[% l('Link as Conjoined to Previously Marked Bib Record') %]</a></li>
<li><a href ng-click="selectedHoldingsCopyDelete()">[% l('Delete Items') %]</a></li>
<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) {
--- /dev/null
+Booking Module Refresh
+^^^^^^^^^^^^^^^^^^^^^^
+
+The Booking module has been redesigned, with many of its interfaces being
+redesigned in Angular.
+
+This adds a new screen called "Manage Reservations", where staff can check details about
+all outstanding reservations, including those that have been recently placed, captured,
+picked up, or recently returned.
+
+On many screens within the new booking module, staff are able to edit reservations. Previously,
+they would have needed to cancel and recreate those reservations with the new data.
+
+There is a new notes field attached to reservations, where staff can leave notes about the
+reservation. One use case is to alert staff that a particular resource is being stored in
+an unfamiliar location. This field is visible on all screens within the booking module.
+
+The Create Reservations UI is completely re-designed, and now includes a calendar-like view
+on which staff can view existing reservations and availability.
+
+Upgrade considerations
+++++++++++++++++++++++
+
+The Booking Module Refresh requires some new dependencies for the Angular
+client. To install these, you will have to run the following commands:
+
+[source,bash]
+----
+cd $EVERGREEN_ROOT/Open-ILS/src/eg2/
+npm install
+----
+
Creating a Booking Reservation
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+[NOTE]
+The "Create a booking reservation" screen uses your library's timezone. If you create a reservation at a library
+in a different timezone, Evergreen will alert you and provide the time in both your timezone and the other library's
+timezone.
+
Only staff members may create reservations. A reservation can be started from a patron record, or a booking resource. To reserve catalogued items, you may start from searching the catalogue, if you do not know the booking item's barcode.
To create a reservation from a patron record