LPXXX Angular patron search component
authorBill Erickson <berickxx@gmail.com>
Fri, 10 Jan 2020 18:02:48 +0000 (13:02 -0500)
committerBill Erickson <berickxx@gmail.com>
Fri, 10 Jan 2020 18:02:48 +0000 (13:02 -0500)
Signed-off-by: Bill Erickson <berickxx@gmail.com>
Open-ILS/src/eg2/src/app/staff/share/patron/patron.module.ts
Open-ILS/src/eg2/src/app/staff/share/patron/profile-select.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/patron/profile-select.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/patron/search.component.html
Open-ILS/src/eg2/src/app/staff/share/patron/search.component.ts
Open-ILS/src/eg2/src/styles.css

index 55aa368..5406b12 100644 (file)
@@ -1,19 +1,22 @@
 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 {PatronService} from './patron.service';
 import {PatronSearchComponent} from './search.component';
+import {ProfileSelectComponent} from './profile-select.component';
 
 @NgModule({
     declarations: [
-        PatronSearchComponent
+        PatronSearchComponent,
+        ProfileSelectComponent
     ],
     imports: [
         StaffCommonModule,
         GridModule
     ],
     exports: [
-        PatronSearchComponent
+        PatronSearchComponent,
+        ProfileSelectComponent
     ],
     providers: [
         PatronService
diff --git a/Open-ILS/src/eg2/src/app/staff/share/patron/profile-select.component.html b/Open-ILS/src/eg2/src/app/staff/share/patron/profile-select.component.html
new file mode 100644 (file)
index 0000000..d5a3666
--- /dev/null
@@ -0,0 +1,6 @@
+
+<eg-combobox #combobox 
+  [startId]="initialValue" [entries]="cboxEntries"
+  (onChange)="propagateCboxChange($event)"
+  i18n-placeholder placeholder="Profile Group">
+</eg-combobox>
diff --git a/Open-ILS/src/eg2/src/app/staff/share/patron/profile-select.component.ts b/Open-ILS/src/eg2/src/app/staff/share/patron/profile-select.component.ts
new file mode 100644 (file)
index 0000000..9e81026
--- /dev/null
@@ -0,0 +1,170 @@
+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())
+            .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);
+        }
+    }
+}
+
index 18d64c9..040c420 100644 (file)
@@ -1,15 +1,12 @@
 
-
 <div class="patron-search-form">
-  <div class="row">
-
+  <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"
         [(ngModel)]="search.first_given_name"/>
     </div>
     <div class="col-lg-2">
-      <input class="form-control" type="text" id='focus-this-input'
-        i18n-aria-label aria-label="Middle Name" (keyup.enter)="go()"
+      <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" id='focus-this-input'
-        i18n-aria-label aria-label="Name Keywords" (keyup.enter)="go()"
+      <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 class="col-lg-2"></div>
-  </div>
-  <div class="row mt-2">
-    <div class="col-lg-2"></div>
-    <div class="col-lg-2"></div>
-    <div class="col-lg-2"></div>
-    <div class="col-lg-2"></div>
-    <div class="col-lg-2"></div>
-    <div class="col-lg-2"></div>
-  </div>
-  <div class="row mt-2">
-    <div class="col-lg-2"></div>
-    <div class="col-lg-2"></div>
-    <div class="col-lg-2"></div>
-    <div class="col-lg-2"></div>
-    <div class="col-lg-2"></div>
-    <div class="col-lg-2"></div>
-  </div>
-  <div class="row mt-2">
-    <div class="col-lg-2"></div>
-    <div class="col-lg-2"></div>
-    <div class="col-lg-2"></div>
-    <div class="col-lg-2"></div>
-    <div class="col-lg-2"></div>
-    <div class="col-lg-2"></div>
-  </div>
-  <div class="row mt-2">
-    <div class="col-lg-2"></div>
-    <div class="col-lg-2"></div>
-    <div class="col-lg-2"></div>
-    <div class="col-lg-2"></div>
-    <div class="col-lg-2"></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>
+        <!-- profile -->
+      </div>
+      <div class="col-lg-2">
+        <!-- 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" [dataSource]="dataSource"
-    [showDeclaredFieldsOnly]="true" persistKey="circ.patron.search">
+  <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 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="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-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>
 
index dba36c3..481d8b3 100644 (file)
@@ -8,6 +8,7 @@ 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';
@@ -16,18 +17,21 @@ 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"
+   '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"
+    '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'
@@ -39,13 +43,17 @@ export class PatronSearchComponent implements OnInit, AfterViewInit {
     @Output() patronsSelected: EventEmitter<any>;
 
     search: any = {};
+    searchOrg: number;
+    expandForm: boolean;
     dataSource: GridDataSource;
+    profileGroups: IdlObject[] = [];
 
     constructor(
         private renderer: Renderer2,
         private net: NetService,
         private org: OrgService,
-        private auth: AuthService
+        private auth: AuthService,
+        private store: ServerStoreService
     ) {
         this.patronsSelected = new EventEmitter<any>();
         this.dataSource = new GridDataSource();
@@ -55,22 +63,72 @@ export class PatronSearchComponent implements OnInit, AfterViewInit {
     }
 
     ngOnInit() {
+        this.searchOrg = this.org.root().id();
+        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();
     }
 
-    rowsSelected(rows: any) {
-        this.patronsSelected.emit(rows);
+    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));
     }
 
     go() {
         this.searchGrid.reload();
     }
 
+    clear() {
+        this.search = {};
+        this.searchOrg = this.org.root().id();
+    }
+
+    homeOrgChange(orgId: number) {
+        this.searchOrg = orgId;
+    }
+
     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(); }
 
@@ -84,15 +142,18 @@ export class PatronSearchComponent implements OnInit, AfterViewInit {
             pager.limit,
             sorter,
             null, // ?
-            this.auth.user().ws_ou(),
+            this.searchOrg,
             DEFAULT_FLESH,
             pager.offset
-        ).pipe(map(user => this.localFleshUser(user)));
+        );
     }
 
-    localFleshUser(user: IdlObject): IdlObject {
-        user.home_ou(this.org.get(user.home_ou()));
-        return user;
+    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[] {
@@ -106,13 +167,8 @@ export class PatronSearchComponent implements OnInit, AfterViewInit {
         const search: Object = {};
 
         Object.keys(this.search).forEach(field => {
-            const val = this.search[field];
-
-            if (this.isValue(val)) {
-                hasSearch = true;
-                search[field] = this.mapSearchField(field, val);
-            }
-
+            search[field] = this.mapSearchField(field);
+            if (search[field]) { hasSearch = true; }
         });
 
         return hasSearch ? search : null;
@@ -122,18 +178,18 @@ export class PatronSearchComponent implements OnInit, AfterViewInit {
         return (val !== null && val !== undefined && val !== '');
     }
 
-    mapSearchField(field: string, value: any): any {
+    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':
-                delete chunk.group;
-                break;
 
-            case 'phone': // thunk
-            case 'ident':
-                chunk.group = 2;
+            case 'name': // name keywords
+            case 'inactive':
+                delete chunk.group;
                 break;
 
             case 'street1':
@@ -144,11 +200,36 @@ export class PatronSearchComponent implements OnInit, AfterViewInit {
                 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;
     }
 }
index ef97e2a..f80282f 100644 (file)
@@ -89,6 +89,11 @@ h5 {font-size: .95rem}
     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 */
 }