// Allow the selected entry ID to be passed via the template
// This does NOT not emit onChange events.
@Input() set selectedId(id: any) {
- if (id) {
- if (this.entrylist.length) {
- this.selected = this.entrylist.filter(e => e.id === id)[0];
- }
+ if (id === undefined) { return; }
- if (!this.selected) {
- // It's possible the selected ID lives in a set of entries
- // that are yet to be provided.
- this.startId = id;
- }
+ // clear on explicit null
+ if (id === null) { this.selected = null; }
+
+ if (this.entrylist.length) {
+ this.selected = this.entrylist.filter(e => e.id === id)[0];
+ }
+
+ if (!this.selected) {
+ // It's possible the selected ID lives in a set of entries
+ // that are yet to be provided.
+ this.startId = id;
}
}
if (idlInfo) {
col.idlFieldDef = idlInfo.idlField;
col.idlClass = idlInfo.idlClass.name;
+ if (!col.datatype) {
+ col.datatype = col.idlFieldDef.datatype;
+ }
if (!col.label) {
col.label = col.idlFieldDef.label || col.idlFieldDef.name;
- col.datatype = col.idlFieldDef.datatype;
}
}
}
import {PullListComponent} from './pull-list.component';
import {ReturnComponent} from './return.component';
import {NoTimezoneSetComponent} from './no-timezone-set.component';
-import {PatronService} from '@eg/staff/share/patron.service';
+import {PatronModule} from '@eg/staff/share/patron/patron.module';
import {BookingResourceBarcodeValidatorDirective} from './booking_resource_validator.directive';
import {FmRecordEditorModule} from '@eg/share/fm-editor/fm-editor.module';
import {OrgFamilySelectModule} from '@eg/share/org-family-select/org-family-select.module';
BookingRoutingModule,
ReactiveFormsModule,
FmRecordEditorModule,
- OrgFamilySelectModule
+ OrgFamilySelectModule,
+ PatronModule
],
- providers: [PatronService],
declarations: [
CancelReservationDialogComponent,
CreateReservationComponent,
import {Router, ActivatedRoute, ParamMap} from '@angular/router';
import {Subscription, of} from 'rxjs';
import {single, filter, switchMap, debounceTime, tap} from 'rxjs/operators';
-import {PatronService} from '@eg/staff/share/patron.service';
+import {PatronService} from '@eg/staff/share/patron/patron.service';
import {PcrudService} from '@eg/core/pcrud.service';
import {IdlObject} from '@eg/core/idl.service';
import {ReservationsGridComponent} from './reservations-grid.component';
import {NgbTabChangeEvent, NgbTabset} from '@ng-bootstrap/ng-bootstrap';
import {Observable, from, of, Subscription} from 'rxjs';
import { single, switchMap, tap, debounceTime } from 'rxjs/operators';
-import {PatronService} from '@eg/staff/share/patron.service';
+import {PatronService} from '@eg/staff/share/patron/patron.service';
import {PcrudService} from '@eg/core/pcrud.service';
import {IdlObject} from '@eg/core/idl.service';
import {ReservationsGridComponent} from './reservations-grid.component';
import {HoldsModule} from '@eg/staff/share/holds/holds.module';
import {HoldingsModule} from '@eg/staff/share/holdings/holdings.module';
import {BookingModule} from '@eg/staff/share/booking/booking.module';
+import {PatronModule} from '@eg/staff/share/patron/patron.module';
import {CatalogComponent} from './catalog.component';
import {SearchFormComponent} from './search-form.component';
import {ResultsComponent} from './result/results.component';
HoldsModule,
HoldingsModule,
BookingModule,
+ PatronModule,
MarcEditModule
],
providers: [
+
+<eg-patron-search-dialog #patronSearch>
+</eg-patron-search-dialog>
+
<div class="row">
<div class="col-lg-4">
<h3 i18n>Place Hold
</h3>
</div>
<div class="col-lg-2 text-right">
- <button class="btn btn-outline-dark btn-sm" [disabled]="true">
+ <button class="btn btn-outline-dark btn-sm" (click)="searchPatrons()">
<span class="material-icons mat-icon-in-button align-middle"
i18n-title title="Search for Patron">search</span>
<span class="align-middle" i18n>Search for Patron</span>
import {HoldsService, HoldRequest,
HoldRequestTarget} from '@eg/staff/share/holds/holds.service';
import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {PatronSearchDialogComponent
+ } from '@eg/staff/share/patron/search-dialog.component';
class HoldContext {
holdMeta: HoldRequestTarget;
smsEnabled: boolean;
placeHoldsClicked: boolean;
+ @ViewChild('patronSearch', {static: false})
+ patronSearch: PatronSearchDialogComponent;
+
constructor(
private router: Router,
private route: ActivatedRoute,
)
);
}
+
+ searchPatrons() {
+ this.patronSearch.open({size: 'xl'}).toPromise().then(
+ patrons => {
+ if (!patrons || patrons.length === 0) { return; }
+
+ const user = patrons[0];
+
+ this.user = user;
+ this.userBarcode =
+ this.currentUserBarcode = user.card().barcode();
+ user.home_ou(this.org.get(user.home_ou()).id()); // de-flesh
+ this.applyUserSettings();
+ }
+ );
+ }
}
}
ngOnDestroy() {
- this.routeSub.unsubscribe();
- this.searchSub.unsubscribe();
- this.basketSub.unsubscribe();
+ if (this.routeSub) {
+ this.routeSub.unsubscribe();
+ this.searchSub.unsubscribe();
+ this.basketSub.unsubscribe();
+ }
}
// Apply the select-all checkbox when all visible records
+++ /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.trim());
- }
-
-}
-
--- /dev/null
+import {NgModule} from '@angular/core';
+import {StaffCommonModule} from '@eg/staff/common.module';
+import {GridModule} from '@eg/share/grid/grid.module';
+import {PatronService} from './patron.service';
+import {PatronSearchComponent} from './search.component';
+import {PatronSearchDialogComponent} from './search-dialog.component';
+import {ProfileSelectComponent} from './profile-select.component';
+
+@NgModule({
+ declarations: [
+ PatronSearchComponent,
+ PatronSearchDialogComponent,
+ ProfileSelectComponent
+ ],
+ imports: [
+ StaffCommonModule,
+ GridModule
+ ],
+ exports: [
+ PatronSearchComponent,
+ PatronSearchDialogComponent,
+ ProfileSelectComponent
+ ],
+ providers: [
+ PatronService
+ ]
+})
+
+export class PatronModule {}
+
--- /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.trim());
+ }
+
+}
+
--- /dev/null
+
+<eg-combobox #combobox
+ [startId]="initialValue" [entries]="cboxEntries"
+ (onChange)="propagateCboxChange($event)"
+ i18n-placeholder placeholder="Profile Group">
+</eg-combobox>
--- /dev/null
+import {Component, Input, Output, OnInit,
+ EventEmitter, ViewChild, forwardRef} from '@angular/core';
+import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
+import {Observable, of} from 'rxjs';
+import {map} from 'rxjs/operators';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {OrgService} from '@eg/core/org.service';
+import {AuthService} from '@eg/core/auth.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {ComboboxEntry, ComboboxComponent
+ } from '@eg/share/combobox/combobox.component';
+
+/* User permission group select comoboxbox.
+ *
+ * <eg-profile-select
+ * [(ngModel)]="pgtObject" [useDisplayEntries]="true">
+ * </eg-profile-select>
+ */
+
+// Use a unicode char for spacing instead of ASCII=32 so the browser
+// won't collapse the nested display entries down to a single space.
+const PAD_SPACE = ' '; // U+2007
+
+@Component({
+ selector: 'eg-profile-select',
+ templateUrl: './profile-select.component.html',
+ providers: [{
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => ProfileSelectComponent),
+ multi: true
+ }]
+})
+export class ProfileSelectComponent implements ControlValueAccessor, OnInit {
+
+ // If true, attempt to build the selector from
+ // permission.grp_tree_display_entry's for the current org unit.
+ // If false OR if no permission.grp_tree_display_entry's exist
+ // build the selector from the full permission.grp_tree
+ @Input() useDisplayEntries: boolean;
+
+ // Emits the selected 'pgt' object or null if the selector is cleared.
+ @Output() profileChange: EventEmitter<IdlObject>;
+
+ @ViewChild('combobox', {static: false}) cbox: ComboboxComponent;
+
+ initialValue: number;
+ cboxEntries: ComboboxEntry[] = [];
+ profiles: {[id: number]: IdlObject} = {};
+
+ // Stub functions required by ControlValueAccessor
+ propagateChange = (_: any) => {};
+ propagateTouch = () => {};
+
+ constructor(
+ private org: OrgService,
+ private auth: AuthService,
+ private pcrud: PcrudService) {
+ this.profileChange = new EventEmitter<IdlObject>();
+ }
+
+ ngOnInit() {
+ this.collectGroups().then(grps => this.sortGroups(grps));
+ }
+
+ collectGroups(): Promise<IdlObject[]> {
+
+ if (!this.useDisplayEntries) {
+ return this.fetchPgt();
+ }
+
+ return this.pcrud.search('pgtde',
+ {org: this.org.ancestors(this.auth.user().ws_ou(), true)},
+ {flesh: 1, flesh_fields: {'pgtde': ['grp']}},
+ {atomic: true}
+
+ ).toPromise().then(groups => {
+
+ if (groups.length === 0) { return this.fetchPgt(); }
+
+ // In the query above, we fetch display entries for our org
+ // unit plus ancestors. However, we only want to use one
+ // collection of display entries, those owned at our org
+ // unit or our closest ancestor.
+ let closestOrg = this.org.get(groups[0].org());
+ groups.forEach(g => {
+ const org = this.org.get(g.org());
+ if (closestOrg.ou_type().depth() < org.ou_type().depth()) {
+ closestOrg = org;
+ }
+ });
+ groups = groups.filter(g => g.org() === closestOrg.id());
+
+ // Link the display entry to its pgt.
+ const pgtList = [];
+ groups.forEach(display => {
+ const pgt = display.grp();
+ pgt._display = display;
+ pgtList.push(pgt);
+ });
+
+ return pgtList;
+ });
+ }
+
+ fetchPgt(): Promise<IdlObject[]> {
+ return this.pcrud.retrieveAll('pgt', {}, {atomic: true}).toPromise();
+ }
+
+ grpLabel(groups: IdlObject[], grp: IdlObject): string {
+ let tmp = grp;
+ let depth = 0;
+
+ do {
+ const pid = tmp._display ? tmp._display.parent() : tmp.parent();
+ if (!pid) { break; } // top of the tree
+
+ // Should always produce a value unless a perm group
+ // display tree is poorly structured.
+ tmp = groups.filter(g => g.id() === pid)[0];
+
+ depth++;
+
+ } while (tmp);
+
+ return PAD_SPACE.repeat(depth) + grp.name();
+ }
+
+ sortGroups(groups: IdlObject[], grp?: IdlObject) {
+ if (!grp) {
+ grp = groups.filter(g => g.parent() === null)[0];
+ }
+
+ this.profiles[grp.id()] = grp;
+ this.cboxEntries.push(
+ {id: grp.id(), label: this.grpLabel(groups, grp)});
+
+ groups
+ .filter(g => g.parent() === grp.id())
+ .sort((a, b) => {
+ if (a._display) {
+ return a._display.position() < b._display.position() ? -1 : 1;
+ } else {
+ return a.name() < b.name() ? -1 : 1;
+ }
+ })
+ .forEach(child => this.sortGroups(groups, child));
+ }
+
+ writeValue(pgt: IdlObject) {
+ const id = pgt ? pgt.id() : null;
+ if (this.cbox) {
+ this.cbox.selectedId = id;
+ } else {
+ // Will propagate to cbox after its instantiated.
+ this.initialValue = id;
+ }
+ }
+
+ registerOnChange(fn) {
+ this.propagateChange = fn;
+ }
+
+ registerOnTouched(fn) {
+ this.propagateTouch = fn;
+ }
+
+ propagateCboxChange(entry: ComboboxEntry) {
+ if (entry) {
+ const grp = this.profiles[entry.id];
+ this.propagateChange(grp);
+ this.profileChange.emit(grp);
+ } else {
+ this.profileChange.emit(null);
+ this.propagateChange(null);
+ }
+ }
+}
+
--- /dev/null
+<ng-template #dialogContent>
+ <div class="modal-header bg-info">
+ <h4 class="modal-title"><span i18n>Patron Search</span></h4>
+ <button type="button" class="close"
+ i18n-aria-label aria-label="Close" (click)="close()">
+ <span aria-hidden="true">×</span>
+ </button>
+ </div>
+ <div class="modal-body">
+ <eg-patron-search #searchForm (patronsSelected)="patronsSelected($event)">
+ </eg-patron-search>
+ </div>
+ <div class="modal-footer">
+ <ng-container>
+ <button type="button" class="btn btn-warning"
+ (click)="close()" i18n>Cancel</button>
+ <button type="button" class="btn btn-success"
+ [disabled]="searchForm ? searchForm.getSelected().length === 0 : true"
+ (click)="close(searchForm.getSelected())" i18n>Select</button>
+ </ng-container>
+ </div>
+</ng-template>
+
--- /dev/null
+import {Component, OnInit, Input, Output, ViewChild} from '@angular/core';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {PatronSearchComponent} from './search.component';
+
+/**
+ * Dialog container for patron search component
+ *
+ * <eg-patron-search-dialog (patronsSelected)="process($event)">
+ * </eg-patron-search-dialog>
+ */
+
+@Component({
+ selector: 'eg-patron-search-dialog',
+ templateUrl: 'search-dialog.component.html'
+})
+
+export class PatronSearchDialogComponent
+ extends DialogComponent implements OnInit {
+
+ @ViewChild('searchForm', {static: false})
+ searchForm: PatronSearchComponent;
+
+ constructor(private modal: NgbModal) { super(modal); }
+
+ ngOnInit() {}
+
+ // Fired when a row in the search grid is dbl-clicked / activated
+ patronsSelected(patrons: IdlObject[]) {
+ this.close(patrons);
+ }
+}
+
+
+
--- /dev/null
+
+<div class="patron-search-form">
+ <div class="row mb-2">
+ <div class="col-lg-2">
+ <input class="form-control" type="text" id='focus-this-input'
+ i18n-aria-label aria-label="Last Name" (keyup.enter)="go()"
+ i18n-placeholder placeholder="Last Name"
+ [(ngModel)]="search.family_name"/>
+ </div>
+ <div class="col-lg-2">
+ <input class="form-control" type="text" (keyup.enter)="go()"
+ i18n-aria-label aria-label="First Name"
+ i18n-placeholder placeholder="First Name"
+ [(ngModel)]="search.first_given_name"/>
+ </div>
+ <div class="col-lg-2">
+ <input class="form-control" type="text" (keyup.enter)="go()"
+ i18n-aria-label aria-label="Middle Name"
+ i18n-placeholder placeholder="Middle Name"
+ [(ngModel)]="search.second_given_name"/>
+ </div>
+ <div class="col-lg-2">
+ <input class="form-control" type="text" (keyup.enter)="go()"
+ i18n-aria-label aria-label="Name Keywords"
+ i18n-placeholder placeholder="Name Keywords"
+ [(ngModel)]="search.name"/>
+ </div>
+ <div class="col-lg-2">
+ <button class="btn btn-success" (click)="go()" i18n>Search</button>
+ <button (click)="toggleExpandForm()"
+ class="btn btn-outline-dark ml-2 label-with-material-icon"
+ i18n-title title="Toggle Expanded Form Display">
+ <span *ngIf="!expandForm" class="material-icons">arrow_drop_down</span>
+ <span *ngIf="expandForm" class="material-icons">arrow_drop_up</span>
+ </button>
+ </div>
+ <div class="col-lg-2">
+ </div>
+ </div>
+
+ <ng-container *ngIf="expandForm">
+ <div class="row mb-2">
+ <div class="col-lg-2">
+ <input class="form-control" type="text" (keyup.enter)="go()"
+ i18n-aria-label aria-label="Barcode"
+ i18n-placeholder placeholder="Barcode"
+ [(ngModel)]="search.barcode"/>
+ </div>
+ <div class="col-lg-2">
+ <input class="form-control" type="text" (keyup.enter)="go()"
+ i18n-aria-label aria-label="Alias"
+ i18n-placeholder placeholder="Alias"
+ [(ngModel)]="search.alias"/>
+ </div>
+ <div class="col-lg-2">
+ <input class="form-control" type="text" (keyup.enter)="go()"
+ i18n-aria-label aria-label="Username"
+ i18n-placeholder placeholder="Username"
+ [(ngModel)]="search.usrname"/>
+ </div>
+ <div class="col-lg-2">
+ <input class="form-control" type="text" (keyup.enter)="go()"
+ i18n-aria-label aria-label="Email"
+ i18n-placeholder placeholder="Email"
+ [(ngModel)]="search.email"/>
+ </div>
+ <div class="col-lg-2">
+ <button class="btn btn-warning" (click)="clear()" i18n>Clear Form</button>
+ </div>
+ <div class="col-lg-2">
+ </div>
+ </div>
+ <div class="row mb-2">
+ <div class="col-lg-2">
+ <input class="form-control" type="text" (keyup.enter)="go()"
+ i18n-aria-label aria-label="Identification"
+ i18n-placeholder placeholder="Identification"
+ [(ngModel)]="search.ident"/>
+ </div>
+ <div class="col-lg-2">
+ <input class="form-control" type="text" (keyup.enter)="go()"
+ i18n-aria-label aria-label="Phone"
+ i18n-placeholder placeholder="Phone"
+ [(ngModel)]="search.phone"/>
+ </div>
+ <div class="col-lg-2">
+ <input class="form-control" type="text" (keyup.enter)="go()"
+ i18n-aria-label aria-label="Street 1"
+ i18n-placeholder placeholder="Street 1"
+ [(ngModel)]="search.street1"/>
+ </div>
+ <div class="col-lg-2">
+ <input class="form-control" type="text" (keyup.enter)="go()"
+ i18n-aria-label aria-label="Street 2"
+ i18n-placeholder placeholder="Street 2"
+ [(ngModel)]="search.street2"/>
+ </div>
+ <div class="col-lg-2">
+ <input class="form-control" type="text" (keyup.enter)="go()"
+ i18n-aria-label aria-label="City"
+ i18n-placeholder placeholder="City"
+ [(ngModel)]="search.city"/>
+ </div>
+ <div class="col-lg-2"></div>
+ </div>
+ <div class="row mb-2">
+ <div class="col-lg-2">
+ <input class="form-control" type="text" (keyup.enter)="go()"
+ i18n-aria-label aria-label="State"
+ i18n-placeholder placeholder="State"
+ [(ngModel)]="search.state"/>
+ </div>
+ <div class="col-lg-2">
+ <input class="form-control" type="text" (keyup.enter)="go()"
+ i18n-aria-label aria-label="Post Code"
+ i18n-placeholder placeholder="Post Code"
+ [(ngModel)]="search.post_code"/>
+ </div>
+ <div class="col-lg-2">
+ <eg-profile-select [useDisplayEntries]="true"
+ [(ngModel)]="search.profile">
+ </eg-profile-select>
+ </div>
+ <div class="col-lg-2">
+ <eg-org-select (onChange)="searchOrg = $event"
+ [applyOrgId]="searchOrg ? searchOrg.id() : null"
+ i18n-placeholder placeholder="Home Library">
+ </eg-org-select>
+ <!-- home org -->
+ </div>
+ <div class="col-lg-2">
+ <input class="form-control" type="text" (keyup.enter)="go()"
+ i18n-aria-label aria-label="Guardian"
+ i18n-placeholder placeholder="Guardian"
+ [(ngModel)]="search.guardian"/>
+ </div>
+ <div class="col-lg-2"></div>
+ </div>
+ <div class="row mb-2">
+ <div class="col-lg-2">
+ <input class="form-control" type="text" (keyup.enter)="go()"
+ i18n-aria-label aria-label="DOB Year"
+ i18n-placeholder placeholder="DOB Year"
+ [(ngModel)]="search.dob_year"/>
+ </div>
+ <div class="col-lg-2">
+ <input class="form-control" type="text" (keyup.enter)="go()"
+ i18n-aria-label aria-label="DOB Month"
+ i18n-placeholder placeholder="DOB Month"
+ [(ngModel)]="search.dob_month"/>
+ </div>
+ <div class="col-lg-2">
+ <input class="form-control" type="text" (keyup.enter)="go()"
+ i18n-aria-label aria-label="DOB Day"
+ i18n-placeholder placeholder="DOB Day"
+ [(ngModel)]="search.dob_day"/>
+ </div>
+ <div class="col-lg-2">
+ <input class="form-control" type="text" (keyup.enter)="go()"
+ i18n-aria-label aria-label="Database ID"
+ i18n-placeholder placeholder="Database ID"
+ [(ngModel)]="search.id"/>
+ </div>
+ <div class="col-lg-2">
+ <div class="form-check form-check-inline">
+ <input class="form-check-input" type="checkbox"
+ (change)="toggleIncludeInactive()"
+ id="include-inactive" [(ngModel)]="search.inactive">
+ <label class="form-check-label" for="include-inactive" i18n>
+ Include Inactive
+ ?</label>
+ </div>
+ </div>
+ <div class="col-lg-2"></div>
+ </div>
+ </ng-container><!-- expand form -->
+</div>
+
+<div class="patron-search-grid">
+ <eg-grid #searchGrid idlClass="au"
+ persistKey="circ.patron.search"
+ (onRowActivate)="rowsSelected($event)"
+ [dataSource]="dataSource"
+ [showDeclaredFieldsOnly]="true">
+
+ <eg-grid-column path='id'
+ i18n-label label="ID"></eg-grid-column>
+ <eg-grid-column path='card.barcode'
+ i18n-label label="Card"></eg-grid-column>
+ <eg-grid-column path='profile.name'
+ i18n-label label="Profile"></eg-grid-column>
+ <eg-grid-column path='family_name'
+ [sortable]="true" [multiSortable]="true"></eg-grid-column>
+ <eg-grid-column path='first_given_name'
+ [sortable]="true" [multiSortable]="true"></eg-grid-column>
+ <eg-grid-column path='second_given_name'
+ [sortable]="true" [multiSortable]="true"></eg-grid-column>
+ <eg-grid-column path='dob'
+ [sortable]="true" [multiSortable]="true"></eg-grid-column>
+ <eg-grid-column path='home_ou.shortname'
+ i18n-label label="Home Library"></eg-grid-column>
+ <eg-grid-column path='create_date' i18n-label label="Created On"
+ [sortable]="true" [multiSortable]="true"></eg-grid-column>
+
+ <eg-grid-column i18n-label label="Mailing:Street 1"
+ path='mailing_address.street1' visible></eg-grid-column>
+ <eg-grid-column i18n-label label="Mailing:Street 2"
+ path='mailing_address.street2'></eg-grid-column>
+ <eg-grid-column i18n-label label="Mailing:City"
+ path='mailing_address.city'></eg-grid-column>
+ <eg-grid-column i18n-label label="Mailing:County"
+ path='mailing_address.county'></eg-grid-column>
+ <eg-grid-column i18n-label label="Mailing:State"
+ path='mailing_address.state'></eg-grid-column>
+ <eg-grid-column i18n-label label="Mailing:Zip"
+ path='mailing_address.post_code'></eg-grid-column>
+
+ <eg-grid-column i18n-label label="Billing:Street 1"
+ path='billing_address.street1'></eg-grid-column>
+ <eg-grid-column i18n-label label="Billing:Street 2"
+ path='billing_address.street2'></eg-grid-column>
+ <eg-grid-column i18n-label label="Billing:City"
+ path='billing_address.city'></eg-grid-column>
+ <eg-grid-column i18n-label label="Billing:County"
+ path='billing_address.county'></eg-grid-column>
+ <eg-grid-column i18n-label label="Billing:State"
+ path='billing_address.state'></eg-grid-column>
+ <eg-grid-column i18n-label label="Billing:Zip"
+ path='billing_address.post_code'></eg-grid-column>
+ </eg-grid>
+
+</div>
+
--- /dev/null
+import {Component, Input, Output, OnInit, AfterViewInit,
+ EventEmitter, ViewChild, Renderer2} from '@angular/core';
+import {Observable, of} from 'rxjs';
+import {map} from 'rxjs/operators';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {EventService} from '@eg/core/event.service';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {OrgService} from '@eg/core/org.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {ServerStoreService} from '@eg/core/server-store.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {StringComponent} from '@eg/share/string/string.component';
+import {ComboboxEntry, ComboboxComponent} from '@eg/share/combobox/combobox.component';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {GridDataSource} from '@eg/share/grid/grid';
+import {Pager} from '@eg/share/util/pager';
+
+const DEFAULT_SORT = [
+ 'family_name ASC',
+ 'first_given_name ASC',
+ 'second_given_name ASC',
+ 'dob DESC'
+];
+
+const DEFAULT_FLESH = [
+ 'card', 'settings', 'standing_penalties', 'addresses', 'billing_address',
+ 'mailing_address', 'stat_cat_entries', 'waiver_entries', 'usr_activity',
+ 'notes', 'profile'
+];
+
+const EXPAND_FORM = 'eg.circ.patron.search.show_extras';
+const INCLUDE_INACTIVE = 'eg.circ.patron.search.include_inactive';
+
+@Component({
+ selector: 'eg-patron-search',
+ templateUrl: './search.component.html'
+})
+
+export class PatronSearchComponent implements OnInit, AfterViewInit {
+
+ @ViewChild('searchGrid', {static: false}) searchGrid: GridComponent;
+
+ // Fired on dbl-click of a search result row.
+ @Output() patronsSelected: EventEmitter<any>;
+
+ search: any = {};
+ searchOrg: IdlObject;
+ expandForm: boolean;
+ dataSource: GridDataSource;
+ profileGroups: IdlObject[] = [];
+
+ constructor(
+ private renderer: Renderer2,
+ private net: NetService,
+ private org: OrgService,
+ private auth: AuthService,
+ private store: ServerStoreService
+ ) {
+ this.patronsSelected = new EventEmitter<any>();
+ this.dataSource = new GridDataSource();
+ this.dataSource.getRows = (pager: Pager, sort: any[]) => {
+ return this.getRows(pager, sort);
+ };
+ }
+
+ ngOnInit() {
+ this.searchOrg = this.org.root();
+ this.store.getItemBatch([EXPAND_FORM, INCLUDE_INACTIVE])
+ .then(settings => {
+ this.expandForm = settings[EXPAND_FORM];
+ this.search.inactive = settings[INCLUDE_INACTIVE];
+ });
+ }
+
+ ngAfterViewInit() {
+ this.renderer.selectRootElement('#focus-this-input').focus();
+ }
+
+ toggleExpandForm() {
+ this.expandForm = !this.expandForm;
+ if (this.expandForm) {
+ this.store.setItem(EXPAND_FORM, true);
+ } else {
+ this.store.removeItem(EXPAND_FORM);
+ }
+ }
+
+ toggleIncludeInactive() {
+ if (this.search.inactive) { // value set by ngModel
+ this.store.setItem(INCLUDE_INACTIVE, true);
+ } else {
+ this.store.removeItem(INCLUDE_INACTIVE);
+ }
+ }
+
+ rowsSelected(rows: IdlObject | IdlObject[]) {
+ this.patronsSelected.emit([].concat(rows));
+ }
+
+ getSelected(): IdlObject[] {
+ return this.searchGrid ?
+ this.searchGrid.context.getSelectedRows() : [];
+ }
+
+ go() {
+ this.searchGrid.reload();
+ }
+
+ clear() {
+ this.search = {profile: null};
+ this.searchOrg = this.org.root();
+ }
+
+ getRows(pager: Pager, sort: any[]): Observable<any> {
+
+ let observable: Observable<IdlObject>;
+
+ if (this.search.id) {
+ observable = this.searchById();
+ } else {
+ observable = this.searchByForm(pager, sort);
+ }
+
+ return observable.pipe(map(user => this.localFleshUser(user)));
+ }
+
+ localFleshUser(user: IdlObject): IdlObject {
+ user.home_ou(this.org.get(user.home_ou()));
+ return user;
+ }
+
+ searchByForm(pager: Pager, sort: any[]): Observable<IdlObject> {
+
+ const search = this.compileSearch();
+ if (!search) { return of(); }
+
+ const sorter = this.compileSort(sort);
+
+ return this.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.patron.search.advanced.fleshed',
+ this.auth.token(),
+ this.compileSearch(),
+ pager.limit,
+ sorter,
+ null, // ?
+ this.searchOrg.id(),
+ DEFAULT_FLESH,
+ pager.offset
+ );
+ }
+
+ searchById(): Observable<IdlObject> {
+ return this.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.user.fleshed.retrieve',
+ this.auth.token(), this.search.id, DEFAULT_FLESH
+ );
+ }
+
+ compileSort(sort: any[]): string[] {
+ if (!sort || sort.length === 0) { return DEFAULT_SORT; }
+ return sort.map(def => `${def.name} ${def.dir}`);
+ }
+
+ compileSearch(): any {
+
+ let hasSearch = false;
+ const search: Object = {};
+
+ Object.keys(this.search).forEach(field => {
+ search[field] = this.mapSearchField(field);
+ if (search[field]) { hasSearch = true; }
+ });
+
+ return hasSearch ? search : null;
+ }
+
+ isValue(val: any): boolean {
+ return (val !== null && val !== undefined && val !== '');
+ }
+
+ mapSearchField(field: string): any {
+
+ const value = this.search[field];
+ if (!this.isValue(value)) { return null; }
+
+ const chunk = {value: value, group: 0};
+
+ switch (field) {
+
+ case 'name': // name keywords
+ case 'inactive':
+ delete chunk.group;
+ break;
+
+ case 'street1':
+ case 'street2':
+ case 'city':
+ case 'state':
+ case 'post_code':
+ chunk.group = 1;
+ break;
+
+ case 'phone':
+ case 'ident':
+ chunk.group = 2;
+ break;
+
+ case 'card':
+ chunk.group = 3;
+ break;
+
+ case 'profile':
+ chunk.group = 5;
+ chunk.value = chunk.value.id(); // pgt object
+ break;
+
+ case 'dob_day':
+ case 'dob_month':
+ case 'dob_year':
+ chunk.group = 4;
+ chunk.value = chunk.value.replace(/\D/g, '');
+
+ if (!field.match(/year/)) {
+ // force day/month to be 2 digits
+ chunk[field].value = ('0' + value).slice(-2);
+ }
+ break;
+ }
+
+ // Confirm the value wasn't scrubbed away above
+ if (!this.isValue(chunk.value)) { return null; }
+
+ return chunk;
+ }
+}
+
line-height: inherit;
}
+.mat-icon-shrunk-in-button {
+ line-height: inherit;
+ font-size: 18px;
+}
+
.input-group .mat-icon-in-button {
font-size: .88rem !important; /* useful for buttons that cuddle up with inputs */
}