LP#1850547: Angular Acquistions Search
authorGalen Charlton <gmc@equinoxinitiative.org>
Tue, 22 Oct 2019 19:29:40 +0000 (15:29 -0400)
committerBill Erickson <berickxx@gmail.com>
Thu, 3 Sep 2020 15:52:30 +0000 (11:52 -0400)
This patch adds the Angular application for acquisitions search
and links the Angular and AngularJS navbars to it.

Includes contributions by Mike Rylander and Jason Etheridge.

I would like to also specifically acknowledge feedback from
Bill Erickson and Mike Risher on this patch series.

Signed-off-by: Galen Charlton <gmc@equinoxinitiative.org>
Signed-off-by: Mike Rylander <mrylander@gmail.com>
Signed-off-by: Tiffany Little <tlittle@georgialibraries.org>
Signed-off-by: Bill Erickson <berickxx@gmail.com>
35 files changed:
Open-ILS/src/eg2/src/app/routing.module.ts
Open-ILS/src/eg2/src/app/staff/acq/routing.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/search/acq-search-form.component.css [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/search/acq-search-form.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/search/acq-search-form.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/search/acq-search.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/search/acq-search.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/search/acq-search.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/search/acq-search.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/search/attr-defs.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/search/invoice-results.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/search/invoice-results.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/search/lineitem-results.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/search/lineitem-results.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/search/picklist-clone-dialog.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/search/picklist-clone-dialog.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/search/picklist-create-dialog.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/search/picklist-create-dialog.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/search/picklist-delete-dialog.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/search/picklist-delete-dialog.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/search/picklist-merge-dialog.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/search/picklist-merge-dialog.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/search/picklist-results.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/search/picklist-results.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/search/purchase-order-results.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/search/purchase-order-results.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/search/resolver.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/search/routing.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/nav.component.html
Open-ILS/src/eg2/src/app/staff/nav.component.ts
Open-ILS/src/eg2/src/app/staff/routing.module.ts
Open-ILS/src/eg2/src/app/staff/staff.component.css
Open-ILS/src/eg2/src/styles.css
Open-ILS/src/templates/staff/navbar.tt2
Open-ILS/web/js/ui/default/staff/services/navbar.js

index 3b7d2e3..4187c85 100644 (file)
@@ -21,7 +21,9 @@ const routes: Routes = [
 ];
 
 @NgModule({
-  imports: [RouterModule.forRoot(routes)],
+  imports: [RouterModule.forRoot(routes, {
+    onSameUrlNavigation: 'reload'
+  })],
   exports: [RouterModule],
   providers: [BaseResolver]
 })
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/acq/routing.module.ts
new file mode 100644 (file)
index 0000000..2230dd7
--- /dev/null
@@ -0,0 +1,16 @@
+import {NgModule} from '@angular/core';
+import {RouterModule, Routes} from '@angular/router';
+
+const routes: Routes = [
+  { path: 'search',
+    loadChildren: () =>
+      import('./search/acq-search.module').then(m => m.AcqSearchModule)
+  }
+];
+
+@NgModule({
+  imports: [RouterModule.forChild(routes)],
+  exports: [RouterModule]
+})
+
+export class AcqRoutingModule {}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search-form.component.css b/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search-form.component.css
new file mode 100644 (file)
index 0000000..8842d11
--- /dev/null
@@ -0,0 +1,5 @@
+#acq-search-form {
+  border-radius: 0px 0px 7px 7px;
+  background-color: rgb(247, 247, 247);
+  box-shadow: 1px 2px 3px -1px rgba(0, 0, 0, .2);
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search-form.component.html b/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search-form.component.html
new file mode 100644 (file)
index 0000000..6382a8c
--- /dev/null
@@ -0,0 +1,165 @@
+<eg-string #defaultSearchSavedString i18n-text text="Default search saved"></eg-string>
+<eg-string #defaultSearchResetString i18n-text text="Default search reset"></eg-string>
+
+<div id="acq-search-form" class="pl-3 pr-3 pt-3 pb-3 mb-3">
+<form>
+  <div class="row mb-1">
+    <div class="col form-group form-inline">
+      <label i18n>Search for records matching
+      <select class="form-inline ml-1 mr-1" id="acq-search-conjunction" [ngModelOptions]="{standalone: true}" [(ngModel)]="searchConjunction">
+        <option value="all">all</option>
+        <option value="any">any</option>
+      </select>
+      of the following terms:</label>
+    </div>
+    <div class="col-auto">
+      <a class="with-material-icon no-href text-primary"
+        title="Show Form" i18n-title
+        tabindex="0"
+        *ngIf="!showForm" (click)="showForm=true"><span class="sr-only" i18n>Show Form</span>
+        <span class="material-icons" aria-hidden="true">expand_more</span>
+      </a>
+      <a class="with-material-icon no-href text-primary"
+        title="Hide Form" i18n-title
+        tabindex="0"
+        *ngIf="showForm" (click)="showForm=false"><span class="sr-only" i18n>Hide Form</span>
+        <span class="material-icons" aria-hidden="true">expand_less</span>
+      </a>
+    </div>
+  </div>
+  <div class="row mb-1" *ngFor="let t of searchTerms; let idx=index" [hidden]="!showForm">
+    <div class="col-md-5 col-lg-3">
+      <select class="form-control" id="selected-search-term" [ngModelOptions]="{standalone: true}" [ngModel]="t.field"
+        (ngModelChange)="old = t.field; t.field = $event"
+        (change)="clearSearchTerm(t, old)">
+        <option disabled="disabled" i18n>Select Search Field</option>
+        <optgroup *ngFor="let g of hints" label="{{availableSearchFields[g]['__label']}}">
+          <option *ngFor="let o of availableSearchFields[g]['__fields']" value="{{g}}:{{o}}">
+            {{availableSearchFields[g]['__label']}} - {{availableSearchFields[g][o].label}}
+          </option>
+        </optgroup>
+      </select>
+    </div>
+    <div class="col-xs-2 pl-3">
+      <select class="form-control" id="selected-search-op" [ngModelOptions]="{standalone: true}" [(ngModel)]="t.op"
+        (ngModelChange)="oldOp = t.op; t.op = $event"
+        (change)="clearSearchTermValueAfterOpChange(t, oldOp)">
+        <option i18n value="">is</option>
+        <option i18n value="__not">is NOT</option>
+        <option i18n value="__fuzzy" [hidden]="searchTermDatatypes[t.field] != 'text' && searchFieldLinkedClasses[t.field] !== 'acqpro' && searchFieldLinkedClasses[t.field] !== 'au'">contains</option>
+        <option i18n value="__not,__fuzzy" [hidden]="searchTermDatatypes[t.field] != 'text' && searchFieldLinkedClasses[t.field] !== 'acqpro' && searchFieldLinkedClasses[t.field] !== 'au'">does NOT contain</option>
+        <option i18n value="__starts" [hidden]="searchTermDatatypes[t.field] != 'text'">STARTS with</option>
+        <option i18n value="__ends" [hidden]="searchTermDatatypes[t.field] != 'text'">ENDS with</option>
+        <option i18n value="__lte" [hidden]="searchTermDatatypes[t.field] != 'timestamp' && !dateLikeSearchFields[t.field]">is on or BEFORE</option>
+        <option i18n value="__gte" [hidden]="searchTermDatatypes[t.field] != 'timestamp' && !dateLikeSearchFields[t.field]">is on or AFTER</option>
+        <option i18n value="__between" [hidden]="searchTermDatatypes[t.field] != 'timestamp'">is BETWEEN</option>
+        <option i18n value="__age" [hidden]="searchTermDatatypes[t.field] != 'timestamp'">age (relative date)</option>
+        <option i18n value="__isnotnull" [hidden]="searchTermDatatypes[t.field] == 'id' || searchTermFieldIsRequired[t.field]">exists</option>
+        <option i18n value="__isnull" [hidden]="searchTermDatatypes[t.field] == 'id' || searchTermFieldIsRequired[t.field]">does NOT exist</option>
+        <option i18n value="__in">matches a term from a file</option>
+      </select>
+    </div>
+    <div class="col-sm-3">
+      <ng-container *ngIf="t.op == '__in' || t.op == '__isnull' || t.op == '__isnotnull'">
+        <ng-container *ngIf="t.op == '__in'">
+          <eg-file-reader [(ngModel)]="t.value1" [ngModelOptions]="{standalone: true}"></eg-file-reader>
+        </ng-container>
+      </ng-container>
+      <ng-container *ngIf="t.op !== '__in' && t.op !== '__isnull' && t.op !== '__isnotnull'">
+       <div *ngIf="t.field.endsWith(':state') && (t.op === '' || t.op === '__not'); else notStateField">
+         <eg-combobox *ngIf="t.op != '__fuzzy'"
+          [asyncSupportsEmptyTermClick]="true"
+           [idlClass]="searchFieldLinkedClasses[t.field]"
+           [selectedId]="t.value1"
+           (onChange)="t.value1 = $event ? $event.id : ''">
+         </eg-combobox>
+       </div>
+       <ng-template #notStateField>
+        <input [ngModelOptions]="{standalone: true}" [(ngModel)]="t.value1" type="text" *ngIf="searchTermDatatypes[t.field] == 'id'" class="form-control" />
+        <input [ngModelOptions]="{standalone: true}" [(ngModel)]="t.value1" type="text" *ngIf="searchTermDatatypes[t.field] == 'text'" class="form-control" />
+        <input [ngModelOptions]="{standalone: true}" [(ngModel)]="t.value1" type="number" *ngIf="searchTermDatatypes[t.field] == 'int'" class="form-control" />
+        <input [ngModelOptions]="{standalone: true}" [(ngModel)]="t.value1" type="number" *ngIf="searchTermDatatypes[t.field] == 'money'" class="form-control" />
+        <select [ngModelOptions]="{standalone: true}" [(ngModel)]="t.value1" *ngIf="searchTermDatatypes[t.field] == 'bool'" class="form-control">
+          <option i18n value="t">Yes</option>
+          <option i18n value="f">No</option>
+        </select>
+        <eg-org-select *ngIf="searchTermDatatypes[t.field] == 'org_unit'"
+          [initialOrgId]="t.value1"
+          (onChange)="setOrgUnitSearchValue($event, t)">
+        </eg-org-select>
+        <ng-container *ngIf="searchTermDatatypes[t.field] == 'link'">
+          <ng-container *ngIf="searchFieldLinkedClasses[t.field] === 'acqpro'">
+            <eg-combobox *ngIf="t.op != '__fuzzy' && t.op != '__not,__fuzzy'"
+              [idlClass]="searchFieldLinkedClasses[t.field]"
+              [selectedId]="t.value1"
+              (onChange)="t.value1 = $event ? $event.id : ''">
+            </eg-combobox>
+            <input [ngModelOptions]="{standalone: true}" [(ngModel)]="t.value1" type="text" *ngIf="t.op == '__fuzzy' || t.op == '__not,__fuzzy'" class="form-control" />
+          </ng-container>
+          <ng-container *ngIf="searchFieldLinkedClasses[t.field] === 'au'">
+            <eg-combobox *ngIf="!t.op.includes('__fuzzy')"
+              [idlClass]="searchFieldLinkedClasses[t.field]"
+              [selectedId]="t.value1"
+              (onChange)="t.value1 = $event ? $event.id : ''">
+            </eg-combobox>
+            <input [ngModelOptions]="{standalone: true}" [(ngModel)]="t.value1" type="text" *ngIf="t.op.includes('__fuzzy')" class="form-control" />
+          </ng-container>
+          <ng-container *ngIf="searchFieldLinkedClasses[t.field] !== 'acqpro' && searchFieldLinkedClasses[t.field] !== 'au'">
+            <eg-combobox
+              [asyncSupportsEmptyTermClick]="t.field.endsWith('cancel_reason') || t.field.endsWith(':claim_policy') || t.field.endsWith('_method')"
+              [idlClass]="searchFieldLinkedClasses[t.field]"
+              [selectedId]="t.value1"
+              (onChange)="t.value1 = $event ? $event.id : ''">
+            </eg-combobox>
+          </ng-container>
+        </ng-container>
+        <eg-date-select *ngIf="searchTermDatatypes[t.field] == 'timestamp' && t.op != '__age'"
+          [initialIso]="t.value1"
+          (onChangeAsIso)="t.value1 = $event ? $event : ''; t.is_date = true">
+        </eg-date-select>
+        <ng-container *ngIf="searchTermDatatypes[t.field] == 'timestamp' && t.op == '__between'">
+          <span i18n>and</span>
+          <eg-date-select
+            [initialIso]="t.value2"
+            (onChangeAsIso)="t.value2 = $event ? $event : ''; t.is_date = true">
+          </eg-date-select>
+        </ng-container>
+        <eg-interval-input *ngIf="searchTermDatatypes[t.field] == 'timestamp' && t.op == '__age'"
+          [ngModelOptions]="{standalone: true}" [(ngModel)]="t.value1">
+        </eg-interval-input>
+       </ng-template>
+      </ng-container>
+    </div>
+    <div class="col-xs-2 pl-3 pr-1">
+      <button class="btn btn-sm material-icon-button" type="button"
+        (click)="addSearchTerm()"
+        i18n-title title="Add Search Row"><span class="sr-only">Add Search Row</span>
+        <span class="material-icons" aria-hidden="true">add_circle_outline</span>
+      </button>
+      <button class="btn btn-sm material-icon-button" type="button"
+        (click)="delSearchTerm(idx)"
+        i18n-title title="Remove Search Row"><span class="sr-only">Remove Search Row</span>
+        <span class="material-icons" aria-hidden="true">remove_circle_outline</span>
+      </button>
+    </div>
+  </div>
+  <div class="row" [hidden]="!showForm">
+    <div class="col-sm-2">
+      <button class="btn btn-success" (click)="submitSearch()" type="submit" i18n>Search</button>
+    </div>
+    <div class="col-xs-3"></div>
+    <div class="col-xs-5 pl-3">
+      <button class="btn btn-primary" (click)="saveSearchAsDefault()" type="button" i18n>Set As Default {{searchTypeLabel}} Search</button>
+      <button class="btn btn-secondary" (click)="clearDefaultSearch()" type="button" [disabled]="!hasDefaultSearch" i18n>
+        Reset Default Search
+      </button>
+    </div>
+    <div class="col-xs-3 pl-5">
+      <input class="form-check-input" type="checkbox" id="retrieve-immediately"
+             (change)="saveRunImmediately()"
+             [ngModelOptions]="{standalone: true}" [(ngModel)]="runImmediately"/>
+      <label for="retrieve-immediately" class="form-check-label" i18n>Retrieve Results Immediately</label>
+    </div>
+  </div>
+</form>
+</div>
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search-form.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search-form.component.ts
new file mode 100644 (file)
index 0000000..19d4a1f
--- /dev/null
@@ -0,0 +1,248 @@
+import {Component, OnInit, AfterViewInit, Input, Output, EventEmitter, ViewChild,
+        OnChanges, SimpleChanges} from '@angular/core';
+import {Router, ActivatedRoute} from '@angular/router';
+import {StaffCommonModule} from '@eg/staff/common.module';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {StringComponent} from '@eg/share/string/string.component';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {AcqSearchTerm, AcqSearch} from './acq-search.service';
+import {ServerStoreService} from '@eg/core/server-store.service';
+
+@Component({
+  selector: 'eg-acq-search-form',
+  styleUrls: ['acq-search-form.component.css'],
+  templateUrl: './acq-search-form.component.html'
+})
+
+export class AcqSearchFormComponent implements OnInit, AfterViewInit, OnChanges {
+
+    @Input() initialSearchTerms: AcqSearchTerm[] = [];
+    @Input() fallbackSearchTerms: AcqSearchTerm[] = [];
+    @Input() defaultSearchSetting = '';
+    @Input() runImmediatelySetting = '';
+    @Input() searchTypeLabel = '';
+
+    @Output() searchSubmitted = new EventEmitter<AcqSearch>();
+
+    @ViewChild('defaultSearchSavedString', { static: true}) defaultSearchSavedString: StringComponent;
+    @ViewChild('defaultSearchResetString', { static: true}) defaultSearchResetString: StringComponent;
+
+    showForm = true;
+
+    hints = ['jub', 'acqpl', 'acqpo', 'acqinv', 'acqlid'];
+    availableSearchFields = {};
+    dateLikeSearchFields = {};
+    searchTermDatatypes = {};
+    searchTermFieldIsRequired = {};
+    searchFieldLinkedClasses = {};
+    validSearchTypes = ['lineitems', 'purchaseorders', 'invoices', 'selectionlists'];
+    defaultSearchType = 'lineitems';
+    searchConjunction = 'all';
+    runImmediately = false;
+    hasDefaultSearch = false;
+
+    searchTerms: AcqSearchTerm[] = [];
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private pcrud: PcrudService,
+        private store: ServerStoreService,
+        private idl: IdlService,
+        private toast: ToastService,
+    ) {}
+
+    ngOnInit() {
+        const self = this;
+
+        this.store.getItem(this.runImmediatelySetting).then(val => {
+            this.runImmediately = val;
+
+            this.hints.forEach(
+                function(hint) {
+                    const o = {};
+                    o['__label'] = self.idl.classes[hint].label;
+                    o['__fields'] = [];
+                    self.idl.classes[hint].fields.forEach(
+                        function(field) {
+                            if (!field.virtual) {
+                                o['__fields'].push(field.name);
+                                o[field.name] = {
+                                    label: field.label,
+                                    datatype: field.datatype
+                                };
+                                self.searchTermDatatypes[hint + ':' + field.name] = field.datatype;
+                                self.searchTermFieldIsRequired[hint + ':' + field.name] = field.required;
+                                if (field.datatype === 'link') {
+                                    self.searchFieldLinkedClasses[hint + ':' + field.name] = field.class;
+                                }
+                            }
+                        }
+                    );
+                    self.availableSearchFields[hint] = o;
+                }
+            );
+
+            this.hints.push('acqlia');
+            this.availableSearchFields['acqlia'] = {'__label': this.idl.classes.acqlia.label, '__fields': []};
+            this.pcrud.retrieveAll('acqliad', {'order_by': {'acqliad': 'id'}})
+            .subscribe(liad => {
+                this.availableSearchFields['acqlia']['__fields'].push('' + liad.id());
+                this.availableSearchFields['acqlia'][liad.id()] = {
+                    label: liad.description(),
+                    datatype: 'text'
+                };
+                this.searchTermDatatypes['acqlia:' + liad.id()] = 'text';
+                if (liad.code().match(/date/)) {
+                    this.dateLikeSearchFields['acqlia:' + liad.id()] = true;
+                }
+            });
+
+            if (this.initialSearchTerms.length > 0) {
+                this.searchTerms = JSON.parse(JSON.stringify(this.initialSearchTerms)); // deep copy
+                this.submitSearch(); // if we've been passed an initial search, e.g., via a URL, assume
+                                     // we want the results immediately regardless of the workstation
+                                     // setting
+            } else {
+                this.store.getItem(this.defaultSearchSetting).then(
+                    defaultSearch => {
+                        if (defaultSearch) {
+                            this.searchTerms = JSON.parse(JSON.stringify(defaultSearch.terms));
+                            this.searchConjunction = defaultSearch.conjunction;
+                            this.hasDefaultSearch = true;
+                        } else if (this.fallbackSearchTerms.length) {
+                            this.searchTerms.length = 0;
+                            JSON.parse(JSON.stringify(this.fallbackSearchTerms))
+                                .forEach(term => this.searchTerms.push(term)); // need a copy
+                        } else {
+                            this.addSearchTerm();
+                        }
+                        if (this.runImmediately) {
+                            if ((this.searchTerms.length > 0) &&
+                                (this.searchTerms[0].field !== '')) {
+                                this.submitSearch();
+                            }
+                        }
+                    }
+                );
+            }
+        });
+    }
+
+    ngAfterViewInit() {}
+
+    ngOnChanges(changes: SimpleChanges) {
+        if ('initialSearchTerms' in changes && !changes.initialSearchTerms.firstChange) {
+            this.ngOnInit();
+        }
+    }
+
+    addSearchTerm() {
+        this.searchTerms.push({ field: '', op: '', value1: '', value2: '' });
+    }
+    delSearchTerm(index: number) {
+        if (this.searchTerms.length < 2) {
+            this.clearSearchTerm(this.searchTerms[0]);
+            // special case for org_unit
+            if (this.searchTerms[0].field && this.searchTermDatatypes[this.searchTerms[0].field] === 'org_unit') {
+                this.searchTerms = [{ field: this.searchTerms[0].field, op: this.searchTerms[0].op, value1: '', value2: ''}];
+            }
+            // and timestamps
+            if (this.searchTerms[0].field && this.searchTermDatatypes[this.searchTerms[0].field] === 'timestamp') {
+                this.searchTerms = [{ field: this.searchTerms[0].field, op: this.searchTerms[0].op, value1: '', value2: ''}];
+            }
+        } else {
+            this.searchTerms.splice(index, 1);
+        }
+    }
+    clearSearchTerm(term: AcqSearchTerm, old?) {
+        // work around fact that org selector doesn't implement ngModel
+        // and we don't use it for eg-date-select
+        if (old && this.searchTermDatatypes[old] === this.searchTermDatatypes[term.field] &&
+            (this.searchTermDatatypes[old] === 'org_unit' || this.searchTermDatatypes[old] === 'timestamp')) {
+            // don't change values if we're moving from one
+            // org_unit or timestamp field to another
+        } else {
+            term.value1 = '';
+            term.value2 = '';
+            term.is_date = false;
+        }
+
+        // handle change of field type
+        if (old && this.searchTermDatatypes[old] !== this.searchTermDatatypes[term.field]) {
+            term.op = '';
+        }
+        if (old && this.searchTermDatatypes[old] === this.searchTermDatatypes[term.field] &&
+            this.searchTermDatatypes[term.field] === 'link' &&
+            (this.searchFieldLinkedClasses[old] !== this.searchFieldLinkedClasses[term.field])
+           ) {
+            term.op = '';
+        }
+        if (term.field.startsWith('acqlia:') && term.op === '') {
+            // default operator for line item attributes should be "contains"
+            term.op = '__fuzzy';
+        } else if (this.searchTermDatatypes[term.field] !== 'text' && term.op.endsWith('__fuzzy')) {
+            // avoid trying to use the "contains" operator for non-text fields
+            term.op = '';
+        }
+    }
+    // conditionally clear the search term after changing
+    // to selected search operators
+    clearSearchTermValueAfterOpChange(term: AcqSearchTerm, oldOp?) {
+        if (term.op === '__age') {
+            term.value1 = '';
+            term.value2 = '';
+        }
+        if (this.searchTermDatatypes[term.field] === 'link') {
+            if (oldOp === '__fuzzy' || term.op === '__fuzzy' ||
+                oldOp === '__not,__fuzzy' || term.op === '__not,__fuzzy'
+               ) {
+                term.value1 = '';
+                term.value2 = '';
+            }
+        }
+    }
+
+    setOrgUnitSearchValue(org: IdlObject, term: AcqSearchTerm) {
+        if (org == null) {
+            term.value1 = '';
+        } else {
+            term.value1 = org.id();
+        }
+    }
+
+    submitSearch() {
+        // tossing setTimeout here to ensure that the
+        // grid data source is fully initialized
+        setTimeout(() => {
+            this.searchSubmitted.emit({
+                terms: this.searchTerms,
+                conjunction: this.searchConjunction
+            });
+        });
+    }
+
+    saveSearchAsDefault() {
+        return this.store.setItem(this.defaultSearchSetting, {
+            terms: this.searchTerms,
+            conjunction: this.searchConjunction
+        }).then(() => {
+            this.hasDefaultSearch = true;
+            this.defaultSearchSavedString.current().then(msg =>
+                this.toast.success(msg)
+            );
+        });
+    }
+    clearDefaultSearch() {
+        return this.store.removeItem(this.defaultSearchSetting).then(() => {
+            this.hasDefaultSearch = false;
+            this.defaultSearchResetString.current().then(msg =>
+                this.toast.success(msg)
+            );
+        });
+    }
+    saveRunImmediately() {
+        return this.store.setItem(this.runImmediatelySetting, this.runImmediately);
+    }
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search.component.html b/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search.component.html
new file mode 100644 (file)
index 0000000..1decc23
--- /dev/null
@@ -0,0 +1,29 @@
+<eg-staff-banner bannerText="Acquisitions Search" i18n-bannerText>
+</eg-staff-banner>
+
+<div class="row">
+  <div class="ml-auto mr-3"><a i18n href="/eg/staff/acq/legacy/search/unified">Legacy Search Interface</a></div>
+</div>
+<div class="row" id="acq-search-page">
+  <div class="col-lg-12">
+    <ul ngbNav #acqSearchTabs="ngbNav" class="nav-tabs" [(activeId)]="searchType" (navChange)="onTabChange($event)">
+      <li [ngbNavItem]="'lineitems'">
+        <a ngbNavLink i18n>Line Items Search</a>
+        <ng-template ngbNavContent><eg-lineitem-results [initialSearchTerms]="urlSearchTerms"></eg-lineitem-results></ng-template>
+      </li>
+      <li [ngbNavItem]="'purchaseorders'">
+        <a ngbNavLink i18n>Purchase Orders Search</a>
+        <ng-template ngbNavContent><eg-purchase-order-results [initialSearchTerms]="urlSearchTerms"></eg-purchase-order-results></ng-template>
+      </li>
+      <li [ngbNavItem]="'invoices'">
+        <a ngbNavLink i18n>Invoices Search</a>
+        <ng-template ngbNavContent><eg-invoice-results [initialSearchTerms]="urlSearchTerms"></eg-invoice-results></ng-template>
+      </li>
+      <li [ngbNavItem]="'selectionlists'">
+        <a ngbNavLink i18n>Selection Lists Search</a>
+        <ng-template ngbNavContent><eg-picklist-results [initialSearchTerms]="urlSearchTerms"></eg-picklist-results></ng-template>
+      </li>
+    </ul>
+    <div [ngbNavOutlet]="acqSearchTabs"></div>
+  </div>
+</div>
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search.component.ts
new file mode 100644 (file)
index 0000000..959963f
--- /dev/null
@@ -0,0 +1,146 @@
+import {Component, OnInit, AfterViewInit, ViewChild, ViewChildren, QueryList, OnDestroy} from '@angular/core';
+import {NgbNav, NgbNavChangeEvent} from '@ng-bootstrap/ng-bootstrap';
+import {Router, ActivatedRoute, ParamMap, RouterEvent, NavigationEnd} from '@angular/router';
+import {filter, takeUntil} from 'rxjs/operators';
+import {Subject} from 'rxjs';
+import {StaffCommonModule} from '@eg/staff/common.module';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {AcqSearchTerm} from './acq-search.service';
+import {LineitemResultsComponent} from './lineitem-results.component';
+import {PurchaseOrderResultsComponent} from './purchase-order-results.component';
+import {InvoiceResultsComponent} from './invoice-results.component';
+import {PicklistResultsComponent} from './picklist-results.component';
+
+@Component({
+  templateUrl: './acq-search.component.html'
+})
+
+export class AcqSearchComponent implements OnInit, AfterViewInit, OnDestroy {
+
+    searchType = '';
+    validSearchTypes = ['lineitems', 'purchaseorders', 'invoices', 'selectionlists'];
+    defaultSearchType = 'lineitems';
+
+    urlSearchTerms: AcqSearchTerm[] = [];
+
+    onTabChange: ($event: NgbNavChangeEvent) => void;
+    @ViewChild('acqSearchTabs', { static: true }) tabs: NgbNav;
+    @ViewChildren(LineitemResultsComponent) liResults: QueryList<PurchaseOrderResultsComponent>;
+    @ViewChildren(PurchaseOrderResultsComponent) poResults: QueryList<PurchaseOrderResultsComponent>;
+    @ViewChildren(InvoiceResultsComponent) invResults: QueryList<PurchaseOrderResultsComponent>;
+    @ViewChildren(PicklistResultsComponent) plResults: QueryList<PicklistResultsComponent>;
+
+    previousUrl: string = null;
+    public destroyed = new Subject<any>();
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private pcrud: PcrudService,
+        private idl: IdlService,
+    ) {
+        this.route.queryParamMap.subscribe((params: ParamMap) => {
+            this.urlSearchTerms = [];
+            const fields = params.getAll('f');
+            const ops = params.getAll('op');
+            const values1 = params.getAll('val1');
+            const values2 = params.getAll('val2');
+            fields.forEach((f, idx) => {
+                const term: AcqSearchTerm = {
+                    field:  f,
+                    op:     '',
+                    value1: '',
+                    value2: ''
+                };
+                if (idx < ops.length) {
+                    term.op = ops[idx];
+                }
+                if (idx < values1.length) {
+                    term.value1 = values1[idx];
+                    if (term.value1 === 'null') {
+                        // convert the string 'null' to a true
+                        // null value, mostly for the benefit of the
+                        // open invoices navigation link
+                        term.value1 = null;
+                    }
+                }
+                if (idx < values2.length) {
+                    term.value2 = values2[idx];
+                }
+                this.urlSearchTerms.push(term);
+                this.ngOnInit(); // TODO: probably overkill
+            });
+        });
+        this.router.events.pipe(
+            filter((event: RouterEvent) => event instanceof NavigationEnd),
+            takeUntil(this.destroyed)
+        ).subscribe(routeEvent => {
+            if (routeEvent instanceof NavigationEnd) {
+                // force reset of grid data source if we're navigating from
+                // a search tab to the same search tab
+                if (this.previousUrl != null) {
+                    const prevRoute = this.previousUrl.match(/acq\/search\/([a-z]+)/);
+                    const newRoute = routeEvent.url.match(/acq\/search\/([a-z]+)/);
+                    const prevTab = prevRoute  == null ? 'lineitems' : prevRoute[1];
+                    const newTab = newRoute  == null ? 'lineitems' : newRoute[1];
+                    if (prevTab === newTab) {
+                        switch (newTab) {
+                            case 'lineitems':
+                                this.liResults.toArray()[0].gridSource.reset();
+                                this.liResults.toArray()[0].acqSearchForm.ngOnInit();
+                                break;
+                            case 'purchaseorders':
+                                this.poResults.toArray()[0].gridSource.reset();
+                                this.poResults.toArray()[0].acqSearchForm.ngOnInit();
+                                break;
+                            case 'invoices':
+                                this.invResults.toArray()[0].gridSource.reset();
+                                this.invResults.toArray()[0].acqSearchForm.ngOnInit();
+                                break;
+                            case 'selectionlists':
+                                this.plResults.toArray()[0].gridSource.reset();
+                                this.plResults.toArray()[0].acqSearchForm.ngOnInit();
+                                break;
+                        }
+                    }
+                }
+                this.previousUrl = routeEvent.url;
+                this.ngOnInit(); // TODO: probably overkill
+            }
+        });
+    }
+
+    ngOnInit() {
+        const self = this;
+
+        const searchTypeParam = this.route.snapshot.paramMap.get('searchtype');
+
+        if (searchTypeParam) {
+            if (this.validSearchTypes.includes(searchTypeParam)) {
+                this.searchType = searchTypeParam;
+            } else {
+                this.searchType = this.defaultSearchType;
+                this.router.navigate(['/staff', 'acq', 'search', this.searchType]);
+            }
+        } else {
+            this.searchType = this.defaultSearchType;
+        }
+
+        this.onTabChange = ($event) => {
+            if (this.validSearchTypes.includes($event.nextId)) {
+                this.searchType = $event.nextId;
+                this.urlSearchTerms = [];
+                this.router.navigate(['/staff', 'acq', 'search', $event.nextId]);
+            }
+        };
+    }
+
+    ngAfterViewInit() {}
+
+    ngOnDestroy(): void {
+        this.destroyed.next();
+        this.destroyed.complete();
+    }
+
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search.module.ts b/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search.module.ts
new file mode 100644 (file)
index 0000000..4dc2251
--- /dev/null
@@ -0,0 +1,35 @@
+import {NgModule} from '@angular/core';
+import {StaffCommonModule} from '@eg/staff/common.module';
+import {AcqSearchRoutingModule} from './routing.module';
+import {AcqSearchComponent} from './acq-search.component';
+import {AcqSearchFormComponent} from './acq-search-form.component';
+import {LineitemResultsComponent} from './lineitem-results.component';
+import {PurchaseOrderResultsComponent} from './purchase-order-results.component';
+import {InvoiceResultsComponent} from './invoice-results.component';
+import {PicklistResultsComponent} from './picklist-results.component';
+import {PicklistCreateDialogComponent} from './picklist-create-dialog.component';
+import {PicklistCloneDialogComponent} from './picklist-clone-dialog.component';
+import {PicklistDeleteDialogComponent} from './picklist-delete-dialog.component';
+import {PicklistMergeDialogComponent} from './picklist-merge-dialog.component';
+
+@NgModule({
+  declarations: [
+    AcqSearchComponent,
+    AcqSearchFormComponent,
+    LineitemResultsComponent,
+    PurchaseOrderResultsComponent,
+    InvoiceResultsComponent,
+    PicklistResultsComponent,
+    PicklistCreateDialogComponent,
+    PicklistCloneDialogComponent,
+    PicklistDeleteDialogComponent,
+    PicklistMergeDialogComponent
+  ],
+  imports: [
+    StaffCommonModule,
+    AcqSearchRoutingModule
+  ]
+})
+
+export class AcqSearchModule {
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search.service.ts b/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search.service.ts
new file mode 100644 (file)
index 0000000..0a96183
--- /dev/null
@@ -0,0 +1,284 @@
+import {Injectable} from '@angular/core';
+import {empty, throwError} from 'rxjs';
+import {map} from 'rxjs/operators';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {GridDataSource} from '@eg/share/grid/grid';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {Pager} from '@eg/share/util/pager';
+import {IdlObject} from '@eg/core/idl.service';
+import {EventService} from '@eg/core/event.service';
+import {AttrDefsService} from './attr-defs.service';
+
+const baseIdlClass = {
+    lineitem: 'jub',
+    purchase_order: 'acqpo',
+    picklist: 'acqpl',
+    invoice: 'acqinv'
+};
+
+const defaultSearch = {
+    lineitem: {
+        jub: [{
+            id: '0',
+            __gte: true
+        }]
+    },
+    purchase_order: {
+        acqpo: [{
+            id: '0',
+            __gte: true
+        }]
+    },
+    picklist: {
+        acqpl: [{
+            id: '0',
+            __gte: true
+        }]
+    },
+    invoice: {
+        acqinv: [{
+            id: '0',
+            __gte: true
+        }]
+    },
+};
+
+const searchOptions = {
+    lineitem: {
+        flesh_attrs: true,
+        flesh_cancel_reason: true,
+        flesh_notes: true,
+        flesh_provider: true,
+        flesh_claim_policy: true,
+        flesh_queued_record: true,
+        flesh_creator: true,
+        flesh_editor: true,
+        flesh_selector: true,
+        flesh_po: true,
+        flesh_pl: true,
+    },
+    purchase_order: {
+        no_flesh_cancel_reason: true,
+        flesh_provider: true,
+        flesh_owner: true,
+        flesh_creator: true,
+        flesh_editor: true
+    },
+    picklist: {
+        flesh_lineitem_count: true,
+        flesh_owner: true,
+        flesh_creator: true,
+        flesh_editor: true
+    },
+    invoice: {
+        no_flesh_misc: false,
+        flesh_provider: true // and shipper, which is also a provider
+    }
+};
+
+const operatorMap = {
+    '!=': '__not',
+    '>': '__gt',
+    '>=': '__gte',
+    '<=': '__lte',
+    '<': '__lt',
+    'startswith': '__starts',
+    'endswith': '__ends',
+    'like': '__fuzzy',
+};
+
+export interface AcqSearchTerm {
+    field: string;
+    op: string;
+    value1: string;
+    value2: string;
+    is_date?: boolean;
+}
+
+export interface AcqSearch {
+    terms: AcqSearchTerm[];
+    conjunction: string;
+}
+
+@Injectable()
+export class AcqSearchService {
+
+    _terms: AcqSearchTerm[] = [];
+    _conjunction = 'all';
+    firstRun = true;
+
+    constructor(
+        private net: NetService,
+        private evt: EventService,
+        private auth: AuthService,
+        private pcrud: PcrudService,
+        private attrDefs: AttrDefsService
+    ) {
+        this.firstRun = true;
+    }
+
+    setSearch(search: AcqSearch) {
+        this._terms = search.terms;
+        this._conjunction = search.conjunction;
+        this.firstRun = false;
+    }
+
+    generateAcqSearch(searchType, filters): any {
+        const andTerms = JSON.parse(JSON.stringify(defaultSearch[searchType])); // deep copy
+        const orTerms = {};
+        const coreRecType = Object.keys(defaultSearch[searchType])[0];
+
+        // handle supplied search terms
+        this._terms.forEach(term => {
+            if (term.value1 === '' && !(term.op === '__isnull' || term.op === '__isnotnull')) {
+                return;
+            }
+            const searchTerm: Object = {};
+            const recType = term.field.split(':')[0];
+            const searchField = term.field.split(':')[1];
+            if (term.op === '__isnull') {
+                searchTerm[searchField] = null;
+            } else if (term.op === '__isnotnull') {
+                searchTerm[searchField] = { '!=' : null };
+            } else if (term.op === '__between') {
+                searchTerm[searchField] = [term.value1, term.value2];
+            } else {
+                searchTerm[searchField] = term.value1;
+            }
+            if (term.op !== '') {
+                if (term.op === '__not,__fuzzy') {
+                    searchTerm['__not'] = true;
+                    searchTerm['__fuzzy'] = true;
+                } else {
+                    searchTerm[term.op] = true;
+                }
+            }
+            if (term.is_date) {
+                searchTerm['__castdate'] = true;
+            }
+            if (this._conjunction === 'any') {
+                if (!(recType in orTerms)) {
+                    orTerms[recType] = [];
+                }
+                orTerms[recType].push(searchTerm);
+            } else {
+                if (!(recType in andTerms)) {
+                    andTerms[recType] = [];
+                }
+                andTerms[recType].push(searchTerm);
+            }
+        });
+
+        // handle grid filters
+        // note that date filters coming from the grid do not need
+        // to worry about __castdate because the grid filter supplies
+        // both the start and end times
+        const observables = [];
+        Object.keys(filters).forEach(filterField => {
+            filters[filterField].forEach(condition => {
+                const searchTerm: Object = {};
+                let filterOp = '=';
+                let filterVal = '';
+                if (Object.keys(condition).some(x => x === '-not')) {
+                    filterOp = Object.keys(condition['-not'][filterField])[0];
+                    filterVal = condition['-not'][filterField][filterOp];
+                    searchTerm['__not'] = true;
+                } else {
+                    filterOp = Object.keys(condition[filterField])[0];
+                    filterVal = condition[filterField][filterOp];
+                    if (filterOp === 'like' && filterVal.length > 1) {
+                        if (filterVal[0] === '%' && filterVal[filterVal.length - 1] === '%') {
+                            filterVal = filterVal.slice(1, filterVal.length - 1);
+                        } else if (filterVal[filterVal.length - 1] === '%') {
+                            filterVal = filterVal.slice(0, filterVal.length - 1);
+                            filterOp = 'startswith';
+                        } else if (filterVal[0] === '%') {
+                            filterVal = filterVal.slice(1);
+                            filterOp = 'endswith';
+                        }
+                    }
+                }
+
+                if (filterOp in operatorMap) {
+                    searchTerm[operatorMap[filterOp]] = true;
+                }
+                if ((['title', 'author'].indexOf(filterField) > -1) &&
+                     (filterField in this.attrDefs.attrDefs)) {
+                        if (!('acqlia' in andTerms)) {
+                            andTerms['acqlia'] = [];
+                        }
+                        searchTerm[this.attrDefs.attrDefs[filterField].id()] = filterVal;
+                        andTerms['acqlia'].push(searchTerm);
+                } else {
+                    searchTerm[filterField] = filterVal;
+                    andTerms[coreRecType].push(searchTerm);
+                }
+            });
+        });
+        return { andTerms: andTerms, orTerms: orTerms };
+    }
+
+    getAcqSearchDataSource(searchType: string): GridDataSource {
+        const gridSource = new GridDataSource();
+
+        gridSource.getRows = (pager: Pager, sort: any[]) => {
+
+            // don't do a search the very first time we
+            // get invoked, which is during initialization; we'll
+            // let components higher up the change decide whether
+            // to submit a search
+            if (this.firstRun) {
+                this.firstRun = false;
+                return empty();
+            }
+
+            const currentSearch = this.generateAcqSearch(searchType, gridSource.filters);
+
+            const opts = { ...searchOptions[searchType] };
+            opts['offset'] = pager.offset;
+            opts['limit'] = pager.limit;
+            opts['au_by_id'] = true;
+
+            if (sort.length > 0) {
+                opts['order_by'] = [];
+                sort.forEach(sort_clause => {
+                    if (searchType === 'lineitem' &&
+                        ['title', 'author'].indexOf(sort_clause.name) > -1) {
+                        opts['order_by'].push({
+                            class: 'acqlia',
+                            field: 'attr_value',
+                            direction: sort_clause.dir
+                        });
+                        opts['order_by_attr'] = sort_clause.name;
+                    } else {
+                        opts['order_by'].push({
+                            class: baseIdlClass[searchType],
+                            field: sort_clause.name,
+                            direction: sort_clause.dir
+                        });
+                    }
+                });
+            }
+
+            return this.net.request(
+                'open-ils.acq',
+                'open-ils.acq.' + searchType + '.unified_search',
+                    this.auth.token(),
+                    currentSearch.andTerms,
+                    currentSearch.orTerms,
+                null,
+                    opts
+            ).pipe(
+                map(res => {
+                    if (this.evt.parse(res)) {
+                        throw throwError(res);
+                    } else {
+                        return res;
+                    }
+                }),
+            );
+        };
+        return gridSource;
+    }
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/attr-defs.service.ts b/Open-ILS/src/eg2/src/app/staff/acq/search/attr-defs.service.ts
new file mode 100644 (file)
index 0000000..4a040da
--- /dev/null
@@ -0,0 +1,34 @@
+import {Injectable} from '@angular/core';
+import {empty, throwError} from 'rxjs';
+import {map} from 'rxjs/operators';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {IdlObject} from '@eg/core/idl.service';
+
+@Injectable()
+export class AttrDefsService {
+
+    attrDefs: {[code: string]: IdlObject};
+
+    constructor(
+        private pcrud: PcrudService
+    ) {
+        this.attrDefs = {};
+    }
+
+    fetchAttrDefs(): Promise<void> {
+        if (Object.keys(this.attrDefs).length) {
+            return Promise.resolve();
+        }
+        return new Promise((resolve, reject) => {
+            this.pcrud.retrieveAll('acqliad', {},
+                {atomic: true}
+            ).subscribe(list => {
+                list.forEach(acqliad => {
+                    this.attrDefs[acqliad.code()] = acqliad;
+                });
+                resolve();
+            });
+        });
+    }
+
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/invoice-results.component.html b/Open-ILS/src/eg2/src/app/staff/acq/search/invoice-results.component.html
new file mode 100644 (file)
index 0000000..18d8caf
--- /dev/null
@@ -0,0 +1,51 @@
+<eg-acq-search-form #acqSearchForm (searchSubmitted)="doSearch($event)" [initialSearchTerms]="initialSearchTerms"
+  i18n-searchTypeLabel searchTypeLabel="Invoice" runImmediatelySetting="eg.acq.search.invoices.run_immediately"
+  [fallbackSearchTerms]="fallbackSearchTerms"
+  defaultSearchSetting="eg.acq.search.default.invoices"></eg-acq-search-form>
+
+<ng-template #inv_identTmpl let-invoice="row">
+  <a href="/eg/staff/acq/legacy/invoice/view/{{invoice.id()}}"
+     target="_blank">
+    {{invoice.inv_ident()}}
+  </a>
+</ng-template>
+<ng-template #providerTmpl let-invoice="row">
+  <a href="/eg/staff/admin/acq/conify/provider/{{invoice.provider().id()}}"
+     target="_blank">
+    {{invoice.provider().code()}}
+  </a>
+</ng-template>
+<ng-template #shipperTmpl let-invoice="row">
+  <a href="/eg/staff/admin/acq/conify/provider/{{invoice.shipper().id()}}"
+     target="_blank">
+    {{invoice.shipper().code()}}
+  </a>
+</ng-template>
+
+<eg-grid #acqSearchInvoicesGrid
+  persistKey="acq.search.invoices"
+  [stickyHeader]="true"
+  [filterable]="true"
+  [sortable]="true"
+  [cellTextGenerator]="cellTextGenerator"
+  (onRowActivate)="showRow($event)"
+  idlClass="acqinv" [dataSource]="gridSource">
+
+  <eg-grid-toolbar-action label="Print Selected Invoices" i18n-label
+    (onClick)="printSelectedInvoices($event)" [disableOnRows]="noSelectedRows">
+  </eg-grid-toolbar-action>
+
+  <eg-grid-column path="inv_ident" [cellTemplate]="inv_identTmpl"></eg-grid-column>
+  <eg-grid-column path="provider" [cellTemplate]="providerTmpl"></eg-grid-column>
+  <eg-grid-column path="shipper" [cellTemplate]="shipperTmpl"></eg-grid-column>
+
+  <eg-grid-column path="id" [hidden]="true"></eg-grid-column>
+  <eg-grid-column path="recv_date" [hidden]="true"></eg-grid-column>
+  <eg-grid-column [asyncSupportsEmptyTermClick]="true" path="recv_method"></eg-grid-column>
+  <eg-grid-column [asyncSupportsEmptyTermClick]="true" path="payment_method"></eg-grid-column>
+
+</eg-grid>
+
+<eg-alert-dialog #printfail i18n-dialogBody
+  dialogBody="Could not print the selected invoices.">
+</eg-alert-dialog>
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/invoice-results.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/search/invoice-results.component.ts
new file mode 100644 (file)
index 0000000..e187963
--- /dev/null
@@ -0,0 +1,106 @@
+import {Component, OnInit, Input, ViewChild} from '@angular/core';
+import {Observable} from 'rxjs';
+import {map} from 'rxjs/operators';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {Pager} from '@eg/share/util/pager';
+import {IdlObject} from '@eg/core/idl.service';
+import {EventService} from '@eg/core/event.service';
+import {AlertDialogComponent} from '@eg/share/dialog/alert.component';
+import {PrintService} from '@eg/share/print/print.service';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {GridDataSource, GridCellTextGenerator} from '@eg/share/grid/grid';
+import {AcqSearchService, AcqSearchTerm, AcqSearch} from './acq-search.service';
+import {AcqSearchFormComponent} from './acq-search-form.component';
+
+@Component({
+  selector: 'eg-invoice-results',
+  templateUrl: 'invoice-results.component.html',
+  providers: [AcqSearchService]
+})
+export class InvoiceResultsComponent implements OnInit {
+
+    @Input() initialSearchTerms: AcqSearchTerm[] = [];
+
+    gridSource: GridDataSource;
+    @ViewChild('acqSearchForm', { static: true}) acqSearchForm: AcqSearchFormComponent;
+    @ViewChild('acqSearchInvoicesGrid', { static: true }) invoiceResultsGrid: GridComponent;
+    @ViewChild('printfail', { static: true }) private printfail: AlertDialogComponent;
+
+    noSelectedRows: (rows: IdlObject[]) => boolean;
+
+    cellTextGenerator: GridCellTextGenerator;
+
+    fallbackSearchTerms: AcqSearchTerm[] = [{
+        field:  'acqinv:receiver',
+        op:     '',
+        value1: this.auth.user() ? this.auth.user().ws_ou() : '',
+        value2: ''
+    }, {
+        field:  'acqinv:close_date',
+        op:     '__isnull',
+        value1: null,
+        value2: ''
+    }];
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private printer: PrintService,
+        private evt: EventService,
+        private net: NetService,
+        private auth: AuthService,
+        private acqSearch: AcqSearchService) {
+    }
+
+    ngOnInit() {
+        this.gridSource = this.acqSearch.getAcqSearchDataSource('invoice');
+        this.noSelectedRows = (rows: IdlObject[]) => (rows.length === 0);
+        this.cellTextGenerator = {
+            inv_ident: row => row.inv_ident(),
+            provider: row => row.provider().code(),
+            shipper: row => row.shipper().code(),
+        };
+    }
+
+    printSelectedInvoices(rows: IdlObject[]) {
+      const that = this;
+      let html = '<style type="text/css">.acq-invoice-' +
+        'voucher {page-break-after:always;}' +
+        '</style>\n';
+      this.net.request(
+        'open-ils.acq',
+        'open-ils.acq.invoice.print.html',
+        this.auth.token(), rows.map( invoice => invoice.id() )
+      ).subscribe(
+        (res) => {
+          if (this.evt.parse(res)) {
+            console.error(res);
+            this.printfail.open();
+          } else {
+            html +=  res.template_output().data();
+          }
+        },
+        (err) => {
+          console.error(err);
+          this.printfail.open();
+        },
+        () => this.printer.print({
+          text: html,
+          printContext: 'default'
+        })
+      );
+    }
+
+    showRow(row: any) {
+        window.open('/eg/staff/acq/legacy/invoice/view/' + row.id(), '_blank');
+    }
+
+    doSearch(search: AcqSearch) {
+        setTimeout(() => {
+            this.acqSearch.setSearch(search);
+            this.invoiceResultsGrid.reload();
+        });
+    }
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/lineitem-results.component.html b/Open-ILS/src/eg2/src/app/staff/acq/search/lineitem-results.component.html
new file mode 100644 (file)
index 0000000..2da1a97
--- /dev/null
@@ -0,0 +1,91 @@
+<eg-acq-search-form #acqSearchForm (searchSubmitted)="doSearch($event)" [initialSearchTerms]="initialSearchTerms"
+  i18n-searchTypeLabel searchTypeLabel="Line Item" runImmediatelySetting="eg.acq.search.lineitems.run_immediately"
+  defaultSearchSetting="eg.acq.search.default.lineitems"></eg-acq-search-form>
+
+<ng-template #idTmpl let-lineitem="row">
+  <a *ngIf="lineitem.purchase_order()" href="/eg/staff/acq/legacy/po/view/{{lineitem.purchase_order().id()}}?focus_li={{lineitem.id()}}"
+     target="_blank">
+    {{lineitem.id()}}
+  </a>
+  <a *ngIf="lineitem.picklist() && !lineitem.purchase_order()" href="/eg/staff/acq/legacy/picklist/view/{{lineitem.picklist().id()}}?focus_li={{lineitem.id()}}"
+     target="_blank">
+    {{lineitem.id()}}
+  </a>
+</ng-template>
+
+<ng-template #poTmpl let-lineitem="row">
+  <a *ngIf="lineitem.purchase_order()" href="/eg/staff/acq/legacy/po/view/{{lineitem.purchase_order().id()}}?focus_li={{lineitem.id()}}"
+     target="_blank">
+    {{lineitem.purchase_order().name()}}
+  </a>
+</ng-template>
+
+<ng-template #plTmpl let-lineitem="row">
+  <a *ngIf="lineitem.picklist()" href="/eg/staff/acq/legacy/picklist/view/{{lineitem.picklist().id()}}?focus_li={{lineitem.id()}}"
+     target="_blank">
+    {{lineitem.picklist().name()}}
+  </a>
+</ng-template>
+
+<ng-template #liAttrTmpl let-lineitem="row" let-col="col">
+  <ng-container *ngFor="let lia of lineitem.attributes()">
+    <ng-container *ngIf="lia.attr_name() === col.path">
+      {{lia.attr_value()}}
+    </ng-container>
+  </ng-container>
+</ng-template>
+
+<ng-template #providerTmpl let-lineitem="row">
+  <a *ngIf="lineitem.provider()" href="/eg/staff/admin/acq/conify/provider/{{lineitem.provider().id()}}"
+     target="_blank">
+    {{lineitem.provider().code()}}
+  </a>
+</ng-template>
+
+<ng-template #liLinksTmpl let-lineitem="row">
+  <ul>
+    <li *ngIf="lineitem.eg_bib_id()">
+      <a routerLink="/staff/catalog/record/{{lineitem.eg_bib_id()}}"
+         target="_blank" i18n>Catalog</a></li>
+    <li><a href="/eg/staff/acq/legacy/lineitem/worksheet/{{lineitem.id()}}"
+           target="_blank" i18n>Worksheet</a></li>
+    <li *ngIf="lineitem.purchase_order()">
+      <a href="/eg/staff/acq/legacy/po/view/{{lineitem.purchase_order().id()}}"
+          target="_blank" i18n>Purchase Order</a></li>
+    <li><a href="/eg/staff/acq/requests/lineitem/{{lineitem.id()}}"
+           target="_blank" i18n>Requests</a></li>
+    <li>
+      <a routerLink="/staff/acq/search/invoices" [queryParams]="{f: 'jub:id', val1: lineitem.id()}"
+        target="_blank" i18n>Invoices</a></li>
+    <li *ngIf="lineitem.queued_record()">
+      <a routerLink="/staff/cat/vandelay/queue/bib/{{lineitem.queued_record().queue()}}"
+        target="_blank" i18n>Queue</a></li>
+    <li *ngIf="lineitem.picklist()">
+      <a href="/eg/staff/acq/legacy/picklist/view/{{lineitem.picklist().id()}}"
+        target="_blank" i18n>Selection List</a></li>
+  </ul>
+</ng-template>
+
+<eg-grid #acqSearchLineitemsGrid
+  persistKey="acq.search.lineitems"
+  idlClass="jub" [dataSource]="gridSource"
+  ignoreFields="marc"
+  [stickyHeader]="true"
+  [filterable]="true"
+  [sortable]="true"
+  [cellTextGenerator]="cellTextGenerator"
+  (onRowActivate)="showRow($event)"
+  [showDeclaredFieldsOnly]="true">
+
+  <eg-grid-column path="id" [cellTemplate]="idTmpl" [disableTooltip]="true"></eg-grid-column>
+  <eg-grid-column i18n-label label="Title" path="title" [cellTemplate]="liAttrTmpl"></eg-grid-column>
+  <eg-grid-column i18n-label label="Author" path="author" [cellTemplate]="liAttrTmpl"></eg-grid-column>
+  <eg-grid-column path="provider" [cellTemplate]="providerTmpl"></eg-grid-column>
+  <eg-grid-column i18n-label label="Links" path="_links" [cellTemplate]="liLinksTmpl" [disableTooltip]="true" [filterable]="false" [sortable]="false"></eg-grid-column>
+  <eg-grid-column [asyncSupportsEmptyTermClick]="true" path="claim_policy" [sortable]="false"></eg-grid-column>
+  <eg-grid-column [asyncSupportsEmptyTermClick]="true" i18n-label label="Status" path="state" [disableTooltip]="true"></eg-grid-column>
+  <eg-grid-column path="estimated_unit_price" [disableTooltip]="true"></eg-grid-column>
+  <eg-grid-column path="purchase_order" [cellTemplate]="poTmpl" [disableTooltip]="true" [hidden]="true"></eg-grid-column>
+  <eg-grid-column path="picklist" [cellTemplate]="plTmpl" [disableTooltip]="true" [hidden]="true"></eg-grid-column>
+  <eg-grid-column [asyncSupportsEmptyTermClick]="true" path="cancel_reason" [hidden]="true"></eg-grid-column>
+</eg-grid>
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/lineitem-results.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/search/lineitem-results.component.ts
new file mode 100644 (file)
index 0000000..044b3e2
--- /dev/null
@@ -0,0 +1,74 @@
+import {Component, OnInit, Input, ViewChild} from '@angular/core';
+import {Observable} from 'rxjs';
+import {map} from 'rxjs/operators';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {Pager} from '@eg/share/util/pager';
+import {IdlObject} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {GridDataSource, GridCellTextGenerator} from '@eg/share/grid/grid';
+import {AcqSearchService, AcqSearchTerm, AcqSearch} from './acq-search.service';
+import {AcqSearchFormComponent} from './acq-search-form.component';
+
+@Component({
+  selector: 'eg-lineitem-results',
+  templateUrl: 'lineitem-results.component.html',
+  providers: [AcqSearchService]
+})
+export class LineitemResultsComponent implements OnInit {
+
+    @Input() initialSearchTerms: AcqSearchTerm[] = [];
+
+    gridSource: GridDataSource;
+    @ViewChild('acqSearchForm', { static: true}) acqSearchForm: AcqSearchFormComponent;
+    @ViewChild('acqSearchLineitemsGrid', { static: true }) lineitemResultsGrid: GridComponent;
+
+    cellTextGenerator: GridCellTextGenerator;
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private net: NetService,
+        private auth: AuthService,
+        private acqSearch: AcqSearchService) {
+    }
+
+    ngOnInit() {
+        this.gridSource = this.acqSearch.getAcqSearchDataSource('lineitem');
+        this.cellTextGenerator = {
+            id: row => row.id(),
+            title: row => {
+                const filtered = row.attributes().filter(lia => lia.attr_name() === 'title');
+                if (filtered.length > 0) {
+                    return filtered[0].attr_value();
+                } else {
+                    return '';
+                }
+            },
+            author: row => {
+                const filtered = row.attributes().filter(lia => lia.attr_name() === 'author');
+                if (filtered.length > 0) {
+                    return filtered[0].attr_value();
+                } else {
+                    return '';
+                }
+            },
+            provider: row => row.provider() ? row.provider().code() : '',
+            _links: row => '',
+            purchase_order: row => row.purchase_order() ? row.purchase_order().name() : '',
+            picklist: row => row.picklist() ? row.picklist().name() : '',
+        };
+    }
+
+    doSearch(search: AcqSearch) {
+        setTimeout(() => {
+            this.acqSearch.setSearch(search);
+            this.lineitemResultsGrid.reload();
+        });
+    }
+
+    showRow(row: any) {
+        window.open('/eg/staff/acq/legacy/lineitem/worksheet/' + row.id(), '_blank');
+    }
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-clone-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-clone-dialog.component.html
new file mode 100644 (file)
index 0000000..662aaca
--- /dev/null
@@ -0,0 +1,27 @@
+<ng-template #dialogContent>
+<form class="form-validated">
+  <div class="modal-header bg-info">
+    <h3 class="modal-title" i18n>Clone Selection List: {{leadListName}}</h3>
+    <button type="button" class="close"
+      i18n-aria-label aria-label="Close" (click)="close()">
+      <span aria-hidden="true">&times;</span>
+    </button>
+  </div>
+  <div class="modal-body">
+    <h4 i18n>Selection list name:</h4>
+    <input type="text" id="create-picklist-name" required
+      [ngModelOptions]="{standalone: true}"
+      class="form-control" [(ngModel)]="selectionListName">
+  </div>
+  <div class="modal-footer">
+    <button type="button" class="btn btn-success"
+      [disabled]="!selectionListName"
+      (click)="cloneList()" i18n>Clone</button>
+    <button type="button" class="btn btn-warning"
+      (click)="close()" i18n>Cancel</button>
+  </div>
+</form>
+</ng-template>
+<eg-alert-dialog #fail i18n-dialogBody
+  dialogBody="Could not create this selection list.">
+</eg-alert-dialog>
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-clone-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-clone-dialog.component.ts
new file mode 100644 (file)
index 0000000..aceca1e
--- /dev/null
@@ -0,0 +1,77 @@
+import {Component, Input, ViewChild, TemplateRef, OnInit, Renderer2} from '@angular/core';
+import {Observable, from, empty, throwError} from 'rxjs';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {AlertDialogComponent} from '@eg/share/dialog/alert.component';
+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 {NgbModal} from '@ng-bootstrap/ng-bootstrap';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+
+@Component({
+  selector: 'eg-picklist-clone-dialog',
+  templateUrl: './picklist-clone-dialog.component.html'
+})
+
+export class PicklistCloneDialogComponent
+  extends DialogComponent implements OnInit {
+
+  @Input() grid: any;
+  selectionListName: String;
+  leadListName: String;
+  selections: IdlObject[];
+
+  @ViewChild('fail', { static: true }) private fail: AlertDialogComponent;
+
+  constructor(
+    private renderer: Renderer2,
+    private idl: IdlService,
+    private evt: EventService,
+    private net: NetService,
+    private auth: AuthService,
+    private modal: NgbModal
+  ) {
+    super(modal);
+  }
+
+  ngOnInit() {
+  }
+
+  update() {
+    this.leadListName = this.grid.context.getSelectedRows()[0].name();
+    this.renderer.selectRootElement('#create-picklist-name').focus();
+    this.selectionListName = 'Copy of ' + this.leadListName;
+  }
+
+  cloneList() {
+    const picklist = this.idl.create('acqpl');
+    picklist.owner(this.auth.user().id());
+    picklist.name(this.selectionListName);
+    this.net.request(
+      'open-ils.acq',
+      'open-ils.acq.picklist.clone',
+      this.auth.token(),
+      this.grid.context.getSelectedRows()[0].id(),
+      this.selectionListName
+    ).subscribe(
+      (res) => {
+        if (this.evt.parse(res)) {
+          console.error(res);
+          this.fail.open();
+          this.close(false);
+        } else {
+          console.log(res);
+        }
+      },
+      (err) => {
+        console.error(err);
+        this.fail.open();
+        this.close(false);
+      },
+      () => this.close(true)
+    );
+  }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-create-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-create-dialog.component.html
new file mode 100644 (file)
index 0000000..0f36ab7
--- /dev/null
@@ -0,0 +1,30 @@
+<ng-template #dialogContent>
+<form class="form-validated">
+  <div class="modal-header bg-info">
+    <h3 class="modal-title" i18n>Create New Selection List</h3>
+    <button type="button" class="close"
+      i18n-aria-label aria-label="Close" (click)="close()">
+      <span aria-hidden="true">&times;</span>
+    </button>
+  </div>
+  <div class="modal-body">
+    <h4 i18n>Selection list name:</h4>
+    <input type="text" id="create-picklist-name" required
+      [ngModelOptions]="{standalone: true}" required
+      class="form-control col-lg-7" [(ngModel)]="selectionListName">
+  </div>
+  <div class="modal-footer">
+    <button type="button" class="btn btn-success"
+      [disabled]="!selectionListName"
+      (click)="createList()" i18n>Create</button>
+    <button type="button" class="btn btn-warning"
+      (click)="close()" i18n>Cancel</button>
+  </div>
+</form>
+</ng-template>
+<eg-alert-dialog #fail i18n-dialogBody
+  dialogBody="Could not create this selection list.">
+</eg-alert-dialog>
+<eg-alert-dialog #dupe i18n-dialogBody
+  dialogBody="Could not create this selection list: name already in use.">
+</eg-alert-dialog>
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-create-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-create-dialog.component.ts
new file mode 100644 (file)
index 0000000..f4f81b3
--- /dev/null
@@ -0,0 +1,79 @@
+import {Component, Input, ViewChild, TemplateRef, OnInit, Renderer2} from '@angular/core';
+import {Observable, from, empty, throwError} from 'rxjs';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {AlertDialogComponent} from '@eg/share/dialog/alert.component';
+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 {NgbModal} from '@ng-bootstrap/ng-bootstrap';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+
+@Component({
+  selector: 'eg-picklist-create-dialog',
+  templateUrl: './picklist-create-dialog.component.html'
+})
+
+export class PicklistCreateDialogComponent
+  extends DialogComponent implements OnInit {
+
+  selectionListName: String;
+
+  @ViewChild('fail', { static: true }) private fail: AlertDialogComponent;
+  @ViewChild('dupe', { static: true }) private dupe: AlertDialogComponent;
+
+  constructor(
+    private renderer: Renderer2,
+    private idl: IdlService,
+    private evt: EventService,
+    private net: NetService,
+    private auth: AuthService,
+    private modal: NgbModal
+  ) {
+    super(modal);
+  }
+
+  ngOnInit() {
+    this.selectionListName = '';
+  }
+
+  update() {
+    this.selectionListName = '';
+    this.renderer.selectRootElement('#create-picklist-name').focus();
+  }
+
+  createList() {
+    const picklist = this.idl.create('acqpl');
+    picklist.owner(this.auth.user().id());
+    picklist.name(this.selectionListName);
+    this.net.request(
+      'open-ils.acq',
+      'open-ils.acq.picklist.create',
+      this.auth.token(), picklist
+    ).subscribe(
+      (res) => {
+        if (this.evt.parse(res)) {
+          console.error(res);
+          if (res.textcode === 'DATABASE_UPDATE_FAILED') {
+            // a duplicate name is not the only reason it could have failed,
+            // but that's the way to bet
+            this.dupe.open();
+          } else {
+            this.fail.open();
+          }
+          this.close(false);
+        } else {
+          console.log(res);
+        }
+      },
+      (err) => {
+        console.error(err);
+        this.fail.open();
+        this.close(false);
+      },
+      () => this.close(true)
+    );
+  }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-delete-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-delete-dialog.component.html
new file mode 100644 (file)
index 0000000..bd30a9d
--- /dev/null
@@ -0,0 +1,24 @@
+<ng-template #dialogContent>
+  <div class="modal-header bg-info">
+    <h3 class="modal-title" i18n>Confirm Delete</h3>
+    <button type="button" class="close"
+      i18n-aria-label aria-label="Close" (click)="close()">
+      <span aria-hidden="true">&times;</span>
+    </button>
+  </div>
+  <div class="modal-body">
+    <h4 i18n>Delete the following selection lists?</h4>
+    <ul>
+      <li *ngFor="let listName of listNames">{{listName}}</li>
+    </ul>
+  </div>
+  <div class="modal-footer">
+    <button type="button" class="btn btn-success"
+      (click)="deleteLists()" i18n>Delete</button>
+    <button type="button" class="btn btn-warning"
+      (click)="close()" i18n>Cancel</button>
+  </div>
+</ng-template>
+<eg-alert-dialog #fail i18n-dialogBody
+  dialogBody="Could not delete the selection list(s).">
+</eg-alert-dialog>
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-delete-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-delete-dialog.component.ts
new file mode 100644 (file)
index 0000000..f7cfd49
--- /dev/null
@@ -0,0 +1,77 @@
+import {Component, Input, ViewChild, TemplateRef, OnInit} from '@angular/core';
+import {Observable, forkJoin, from, empty, throwError} from 'rxjs';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {AlertDialogComponent} from '@eg/share/dialog/alert.component';
+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 {NgbModal} from '@ng-bootstrap/ng-bootstrap';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+
+@Component({
+  selector: 'eg-picklist-delete-dialog',
+  templateUrl: './picklist-delete-dialog.component.html'
+})
+
+export class PicklistDeleteDialogComponent
+  extends DialogComponent implements OnInit {
+
+  @Input() grid: any;
+  listNames: string[];
+
+  @ViewChild('fail', { static: true }) private fail: AlertDialogComponent;
+
+  constructor(
+    private idl: IdlService,
+    private evt: EventService,
+    private net: NetService,
+    private auth: AuthService,
+    private modal: NgbModal
+  ) {
+    super(modal);
+  }
+
+  ngOnInit() {
+  }
+
+  update() {
+    this.listNames = this.grid.context.getSelectedRows().map( r => r.name() );
+  }
+
+  deleteList(list) {
+    return this.net.request(
+      'open-ils.acq',
+      'open-ils.acq.picklist.delete',
+      this.auth.token(),
+      list.id()
+    );
+  }
+
+  deleteLists() {
+    const that = this;
+    const observables = [];
+    this.grid.context.getSelectedRows().forEach(function(r) {
+      observables.push( that.deleteList(r) );
+    });
+    forkJoin(observables).subscribe(
+      (res) => {
+        if (this.evt.parse(res)) {
+          console.error(res);
+          this.fail.open();
+          this.close(false);
+        } else {
+          console.log(res);
+        }
+      },
+      (err) => {
+        console.error(err);
+        this.fail.open();
+        this.close(false);
+      },
+      () => this.close(true)
+    );
+  }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-merge-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-merge-dialog.component.html
new file mode 100644 (file)
index 0000000..6cdf54e
--- /dev/null
@@ -0,0 +1,32 @@
+<ng-template #dialogContent>
+<form class="form-validated">
+  <div class="modal-header bg-info">
+    <h3 class="modal-title" i18n>Merge Selection Lists</h3>
+    <button type="button" class="close"
+      i18n-aria-label aria-label="Close" (click)="close()">
+      <span aria-hidden="true">&times;</span>
+    </button>
+  </div>
+  <div class="modal-body">
+    <h4 i18n>Lead Selection List:</h4>
+      <select [(ngModel)]="leadList" [ngModelOptions]="{standalone: true}" required>
+        <option *ngFor="let list of selectedLists"
+          value="{{list.id()}}">{{list.name()}}</option>
+      </select>
+    <h4 i18n>Merge the following selection lists?</h4>
+    <ul>
+      <li *ngFor="let listName of listNames">{{listName}}</li>
+    </ul>
+  </div>
+  <div class="modal-footer">
+    <button type="button" class="btn btn-success"
+      [disabled]="!leadList"
+      (click)="mergeLists()" i18n>Merge</button>
+    <button type="button" class="btn btn-warning"
+      (click)="close()" i18n>Cancel</button>
+  </div>
+</form>
+</ng-template>
+<eg-alert-dialog #fail i18n-dialogBody
+  dialogBody="Could not merge the selection lists.">
+</eg-alert-dialog>
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-merge-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-merge-dialog.component.ts
new file mode 100644 (file)
index 0000000..60ed6d6
--- /dev/null
@@ -0,0 +1,73 @@
+import {Component, Input, ViewChild, TemplateRef, OnInit} from '@angular/core';
+import {Observable, forkJoin, from, empty, throwError} from 'rxjs';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {AlertDialogComponent} from '@eg/share/dialog/alert.component';
+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 {NgbModal} from '@ng-bootstrap/ng-bootstrap';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+
+@Component({
+  selector: 'eg-picklist-merge-dialog',
+  templateUrl: './picklist-merge-dialog.component.html'
+})
+
+export class PicklistMergeDialogComponent
+  extends DialogComponent implements OnInit {
+
+  @Input() grid: any;
+  listNames: string[];
+  leadList: number;
+  selectedLists: IdlObject[];
+
+  @ViewChild('fail', { static: true }) private fail: AlertDialogComponent;
+
+  constructor(
+    private idl: IdlService,
+    private evt: EventService,
+    private net: NetService,
+    private auth: AuthService,
+    private modal: NgbModal
+  ) {
+    super(modal);
+  }
+
+  ngOnInit() {
+  }
+
+  update() {
+    this.selectedLists = this.grid.context.getSelectedRows();
+    this.listNames = this.selectedLists.map( r => r.name() );
+  }
+
+  mergeLists() {
+    const that = this;
+    this.net.request(
+      'open-ils.acq',
+      'open-ils.acq.picklist.merge',
+      this.auth.token(), this.leadList,
+      this.selectedLists.map( list => list.id() ).filter(function(p) { return Number(p) !== Number(that.leadList); })
+    ).subscribe(
+      (res) => {
+        if (this.evt.parse(res)) {
+          console.error(res);
+          this.fail.open();
+          this.close(false);
+        } else {
+          console.log(res);
+        }
+      },
+      (err) => {
+        console.error(err);
+        this.fail.open();
+        this.close(false);
+      },
+      () => this.close(true)
+    );
+  }
+
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-results.component.html b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-results.component.html
new file mode 100644 (file)
index 0000000..9bb5d72
--- /dev/null
@@ -0,0 +1,63 @@
+<eg-acq-search-form #acqSearchForm (searchSubmitted)="doSearch($event)" [initialSearchTerms]="initialSearchTerms"
+  i18n-searchTypeLabel searchTypeLabel="Selection List" runImmediatelySetting="eg.acq.search.selectionlists.run_immediately"
+  [fallbackSearchTerms]="fallbackSearchTerms"
+  defaultSearchSetting="eg.acq.search.default.selectionlists"></eg-acq-search-form>
+
+<eg-string #createSelectionListString i18n-text text="Selection List Created">
+</eg-string>
+<eg-string #cloneSelectionListString i18n-text text="Selection List Cloned">
+</eg-string>
+<eg-string #deleteSelectionListString i18n-text text="Selection List(s) Deleted">
+</eg-string>
+<eg-string #mergeSelectionListString i18n-text text="Selection Lists Merged">
+</eg-string>
+
+<ng-template #nameTmpl let-selectionlist="row">
+  <a href="/eg/staff/acq/legacy/picklist/view/{{selectionlist.id()}}"
+     target="_blank">
+    {{selectionlist.name()}}
+  </a>
+</ng-template>
+
+<eg-picklist-create-dialog #picklistCreateDialog>
+</eg-picklist-create-dialog>
+
+<eg-picklist-clone-dialog #picklistCloneDialog [grid]="picklistResultsGrid">
+</eg-picklist-clone-dialog>
+
+<eg-picklist-delete-dialog #picklistDeleteDialog [grid]="picklistResultsGrid">
+</eg-picklist-delete-dialog>
+
+<eg-picklist-merge-dialog #picklistMergeDialog [grid]="picklistResultsGrid">
+</eg-picklist-merge-dialog>
+
+<eg-grid #acqSearchPicklistsGrid
+  persistKey="acq.search.selectionlists"
+  [stickyHeader]="true"
+  [filterable]="true"
+  [sortable]="true"
+  [cellTextGenerator]="cellTextGenerator"
+  (onRowActivate)="showRow($event)"
+  idlClass="acqpl" [dataSource]="gridSource">
+
+  <eg-grid-toolbar-button label="New Selection List" i18n-label
+    (onClick)="openCreateDialog()" [disableOnRows]="createNotAppropriate">
+  </eg-grid-toolbar-button>
+  <eg-grid-toolbar-action label="Clone Selected" i18n-label
+    (onClick)="openCloneDialog($event)" [disableOnRows]="cloneNotAppropriate">
+  </eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="Merge Selected" i18n-label
+    (onClick)="openMergeDialog($event)" [disableOnRows]="mergeNotAppropriate">
+  </eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="Delete Selected" i18n-label
+    (onClick)="openDeleteDialog($event)" [disableOnRows]="deleteNotAppropriate">
+  </eg-grid-toolbar-action>
+
+  <eg-grid-column path="name" [cellTemplate]="nameTmpl"></eg-grid-column>
+  <eg-grid-column path="entry_count" [filterable]="false" [sortable]="false"></eg-grid-column>
+
+  <eg-grid-column path="id" [hidden]="true"></eg-grid-column>
+  <eg-grid-column path="creator" [hidden]="true"></eg-grid-column>
+  <eg-grid-column path="editor" [hidden]="true"></eg-grid-column>
+
+</eg-grid>
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-results.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-results.component.ts
new file mode 100644 (file)
index 0000000..ba5cdb0
--- /dev/null
@@ -0,0 +1,143 @@
+import {Component, OnInit, Input, ViewChild} from '@angular/core';
+import {Observable} from 'rxjs';
+import {map} from 'rxjs/operators';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {Pager} from '@eg/share/util/pager';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {StringComponent} from '@eg/share/string/string.component';
+import {IdlObject} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {PermService} from '@eg/core/perm.service';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {GridDataSource, GridCellTextGenerator} from '@eg/share/grid/grid';
+import {AcqSearchService, AcqSearchTerm, AcqSearch} from './acq-search.service';
+import {PicklistCreateDialogComponent} from './picklist-create-dialog.component';
+import {PicklistCloneDialogComponent} from './picklist-clone-dialog.component';
+import {PicklistDeleteDialogComponent} from './picklist-delete-dialog.component';
+import {PicklistMergeDialogComponent} from './picklist-merge-dialog.component';
+import {AcqSearchFormComponent} from './acq-search-form.component';
+
+@Component({
+  selector: 'eg-picklist-results',
+  templateUrl: 'picklist-results.component.html',
+  providers: [AcqSearchService]
+})
+export class PicklistResultsComponent implements OnInit {
+
+    @Input() initialSearchTerms: AcqSearchTerm[] = [];
+
+    gridSource: GridDataSource;
+    @ViewChild('acqSearchForm', { static: true}) acqSearchForm: AcqSearchFormComponent;
+    @ViewChild('acqSearchPicklistsGrid', { static: true }) picklistResultsGrid: GridComponent;
+    @ViewChild('picklistCreateDialog', { static: true }) picklistCreateDialog: PicklistCreateDialogComponent;
+    @ViewChild('picklistCloneDialog', { static: true }) picklistCloneDialog: PicklistCloneDialogComponent;
+    @ViewChild('picklistDeleteDialog', { static: true }) picklistDeleteDialog: PicklistDeleteDialogComponent;
+    @ViewChild('picklistMergeDialog', { static: true }) picklistMergeDialog: PicklistMergeDialogComponent;
+    @ViewChild('createSelectionListString', { static: true }) createSelectionListString: StringComponent;
+    @ViewChild('cloneSelectionListString', { static: true }) cloneSelectionListString: StringComponent;
+    @ViewChild('deleteSelectionListString', { static: true }) deleteSelectionListString: StringComponent;
+    @ViewChild('mergeSelectionListString', { static: true }) mergeSelectionListString: StringComponent;
+
+    permissions: {[name: string]: boolean};
+    noSelectedRows: (rows: IdlObject[]) => boolean;
+    oneSelectedRows: (rows: IdlObject[]) => boolean;
+    createNotAppropriate: (rows: IdlObject[]) => boolean;
+    cloneNotAppropriate: (rows: IdlObject[]) => boolean;
+    mergeNotAppropriate: (rows: IdlObject[]) => boolean;
+    deleteNotAppropriate: (rows: IdlObject[]) => boolean;
+
+    cellTextGenerator: GridCellTextGenerator;
+
+    fallbackSearchTerms: AcqSearchTerm[] = [{
+        field:  'acqpl:owner',
+        op:     '',
+        value1: this.auth.user() ? this.auth.user().id() : '',
+        value2: ''
+    }];
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private toast: ToastService,
+        private net: NetService,
+        private auth: AuthService,
+        private acqSearch: AcqSearchService,
+        private perm: PermService
+    ) {
+      this.permissions = {};
+    }
+
+    ngOnInit() {
+        this.gridSource = this.acqSearch.getAcqSearchDataSource('picklist');
+
+        this.perm.hasWorkPermHere(['CREATE_PICKLIST', 'UPDATE_PICKLIST', 'VIEW_PICKLIST']).
+          then(perms => this.permissions = perms);
+
+        this.noSelectedRows = (rows: IdlObject[]) => (rows.length === 0);
+        this.oneSelectedRows = (rows: IdlObject[]) => (rows.length === 1);
+        this.createNotAppropriate = (rows: IdlObject[]) => (!this.permissions.CREATE_PICKLIST);
+        this.cloneNotAppropriate = (rows: IdlObject[]) => (!this.permissions.CREATE_PICKLIST || !this.oneSelectedRows(rows));
+        this.mergeNotAppropriate = (rows: IdlObject[]) => (!this.permissions.UPDATE_PICKLIST || this.noSelectedRows(rows));
+        this.deleteNotAppropriate = (rows: IdlObject[]) => (!this.permissions.UPDATE_PICKLIST || this.noSelectedRows(rows));
+
+        this.cellTextGenerator = {
+            name: row => row.name(),
+        };
+    }
+
+    openCreateDialog() {
+        this.picklistCreateDialog.open().subscribe(
+            modified => {
+                if (!modified) { return; }
+                this.createSelectionListString.current().then(msg => this.toast.success(msg));
+                this.picklistResultsGrid.reload(); // FIXME - spec calls for inserted grid row and not refresh
+            }
+        );
+        this.picklistCreateDialog.update(); // clear and focus the textbox
+    }
+
+    openCloneDialog(rows: IdlObject[]) {
+        this.picklistCloneDialog.open().subscribe(
+            modified => {
+                if (!modified) { return; }
+                this.cloneSelectionListString.current().then(msg => this.toast.success(msg));
+                this.picklistResultsGrid.reload(); // FIXME - spec calls for inserted grid row and not refresh
+            }
+        );
+        this.picklistCloneDialog.update(); // update the dialog UI with selections
+    }
+
+    openDeleteDialog(rows: IdlObject[]) {
+        this.picklistDeleteDialog.open().subscribe(
+            modified => {
+                if (!modified) { return; }
+                this.deleteSelectionListString.current().then(msg => this.toast.success(msg));
+                this.picklistResultsGrid.reload(); // FIXME - spec calls for removed grid rows and not refresh
+            }
+        );
+        this.picklistDeleteDialog.update(); // update the dialog UI with selections
+    }
+
+    openMergeDialog(rows: IdlObject[]) {
+        this.picklistMergeDialog.open().subscribe(
+            modified => {
+                if (!modified) { return; }
+                this.mergeSelectionListString.current().then(msg => this.toast.success(msg));
+                this.picklistResultsGrid.reload(); // FIXME - spec calls for removed grid rows and not refresh
+            }
+        );
+        this.picklistMergeDialog.update(); // update the dialog UI with selections
+    }
+
+    showRow(row: any) {
+        window.open('/eg/staff/acq/legacy/picklist/view/' + row.id(), '_blank');
+    }
+
+    doSearch(search: AcqSearch) {
+        setTimeout(() => {
+            this.acqSearch.setSearch(search);
+            this.picklistResultsGrid.reload();
+        });
+    }
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/purchase-order-results.component.html b/Open-ILS/src/eg2/src/app/staff/acq/search/purchase-order-results.component.html
new file mode 100644 (file)
index 0000000..5c3c28b
--- /dev/null
@@ -0,0 +1,44 @@
+<eg-acq-search-form #acqSearchForm (searchSubmitted)="doSearch($event)" [initialSearchTerms]="initialSearchTerms"
+  i18n-searchTypeLabel searchTypeLabel="Purchase Order" runImmediatelySetting="eg.acq.search.purchaseorders.run_immediately"
+  [fallbackSearchTerms]="fallbackSearchTerms"
+  defaultSearchSetting="eg.acq.search.default.purchaseorders"></eg-acq-search-form>
+
+<ng-template #nameTmpl let-purchaseorder="row">
+  <a href="/eg/staff/acq/legacy/po/view/{{purchaseorder.id()}}"
+     target="_blank">
+    {{purchaseorder.name()}}
+  </a>
+</ng-template>
+
+<ng-template #providerTmpl let-purchaseorder="row">
+  <a href="/eg/staff/admin/acq/conify/provider/{{purchaseorder.provider().id()}}"
+     target="_blank">
+    {{purchaseorder.provider().code()}}
+  </a>
+</ng-template>
+
+<eg-grid #acqSearchPurchaseOrdersGrid
+  persistKey="acq.search.purchaseorders"
+  [stickyHeader]="true"
+  [filterable]="true"
+  [sortable]="true"
+  [cellTextGenerator]="cellTextGenerator"
+  (onRowActivate)="showRow($event)"
+  idlClass="acqpo" [dataSource]="gridSource">
+
+  <eg-grid-column path="name" [cellTemplate]="nameTmpl"></eg-grid-column>
+  <eg-grid-column path="id"></eg-grid-column>
+  <eg-grid-column path="provider" [asyncSupportsEmptyTermClick]="true" [cellTemplate]="providerTmpl"></eg-grid-column>
+  <eg-grid-column path="ordering_agency"></eg-grid-column>
+  <eg-grid-column path="create_time"></eg-grid-column>
+  <eg-grid-column path="edit_time"></eg-grid-column>
+  <eg-grid-column path="order_date"></eg-grid-column>
+
+  <eg-grid-column path="creator" [hidden]="true"></eg-grid-column>
+  <eg-grid-column path="editor" [hidden]="true"></eg-grid-column>
+  <eg-grid-column path="owner" [hidden]="true"></eg-grid-column>
+  <eg-grid-column [asyncSupportsEmptyTermClick]="true" i18n-label label="Status" path="state" [disableTooltip]="true"></eg-grid-column>
+  <eg-grid-column [asyncSupportsEmptyTermClick]="true" path="cancel_reason"></eg-grid-column>
+  <eg-grid-column path="prepayment_required" [sortable]="false"></eg-grid-column>
+
+</eg-grid>
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/purchase-order-results.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/search/purchase-order-results.component.ts
new file mode 100644 (file)
index 0000000..81e7db5
--- /dev/null
@@ -0,0 +1,68 @@
+import {Component, OnInit, Input, ViewChild} from '@angular/core';
+import {Observable} from 'rxjs';
+import {map} from 'rxjs/operators';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {Pager} from '@eg/share/util/pager';
+import {IdlObject} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {GridDataSource, GridCellTextGenerator} from '@eg/share/grid/grid';
+import {AcqSearchService, AcqSearchTerm, AcqSearch} from './acq-search.service';
+import {AcqSearchFormComponent} from './acq-search-form.component';
+
+@Component({
+  selector: 'eg-purchase-order-results',
+  templateUrl: 'purchase-order-results.component.html',
+  providers: [AcqSearchService]
+})
+export class PurchaseOrderResultsComponent implements OnInit {
+
+    @Input() initialSearchTerms: AcqSearchTerm[] = [];
+
+    gridSource: GridDataSource;
+    @ViewChild('acqSearchForm', { static: true}) acqSearchForm: AcqSearchFormComponent;
+    @ViewChild('acqSearchPurchaseOrdersGrid', { static: true }) purchaseOrderResultsGrid: GridComponent;
+
+    cellTextGenerator: GridCellTextGenerator;
+
+    fallbackSearchTerms: AcqSearchTerm[] = [{
+        field:  'acqpo:ordering_agency',
+        op:     '',
+        value1: this.auth.user() ? this.auth.user().ws_ou() : '',
+        value2: ''
+    }, {
+        field:  'acqpo:state',
+        op:     '',
+        value1: 'on-order',
+        value2: ''
+    }];
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private net: NetService,
+        private auth: AuthService,
+        private acqSearch: AcqSearchService) {
+    }
+
+    ngOnInit() {
+        this.gridSource = this.acqSearch.getAcqSearchDataSource('purchase_order');
+
+        this.cellTextGenerator = {
+            provider: row => row.provider().code(),
+            name: row => row.name(),
+        };
+    }
+
+    showRow(row: any) {
+        window.open('/eg/staff/acq/legacy/po/view/' + row.id(), '_blank');
+    }
+
+    doSearch(search: AcqSearch) {
+        setTimeout(() => {
+            this.acqSearch.setSearch(search);
+            this.purchaseOrderResultsGrid.reload();
+        });
+    }
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/resolver.service.ts b/Open-ILS/src/eg2/src/app/staff/acq/search/resolver.service.ts
new file mode 100644 (file)
index 0000000..d155e52
--- /dev/null
@@ -0,0 +1,25 @@
+import {Injectable} from '@angular/core';
+import {Router, Resolve, RouterStateSnapshot,
+        ActivatedRouteSnapshot} from '@angular/router';
+import {AttrDefsService} from './attr-defs.service';
+
+@Injectable()
+export class AttrDefsResolver implements Resolve<Promise<any[]>> {
+
+    savedId: number = null;
+
+    constructor(
+        private router: Router,
+        private attrDefs: AttrDefsService,
+    ) {}
+
+    resolve(
+        route: ActivatedRouteSnapshot,
+        state: RouterStateSnapshot): Promise<any[]> {
+
+        return Promise.all([
+            this.attrDefs.fetchAttrDefs()
+        ]);
+    }
+
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/acq/search/routing.module.ts
new file mode 100644 (file)
index 0000000..e05e58f
--- /dev/null
@@ -0,0 +1,26 @@
+import {NgModule} from '@angular/core';
+import {RouterModule, Routes} from '@angular/router';
+import {AcqSearchComponent} from './acq-search.component';
+import {AttrDefsResolver} from './resolver.service';
+import {AttrDefsService} from './attr-defs.service';
+
+const routes: Routes = [
+  { path: '',
+    component: AcqSearchComponent,
+    resolve: { attrDefsResolver : AttrDefsResolver },
+    runGuardsAndResolvers: 'always'
+  },
+  { path: ':searchtype',
+    component: AcqSearchComponent,
+    resolve: { attrDefsResolver : AttrDefsResolver },
+    runGuardsAndResolvers: 'always'
+  }
+];
+
+@NgModule({
+  imports: [RouterModule.forChild(routes)],
+  exports: [RouterModule],
+  providers: [AttrDefsResolver, AttrDefsService]
+})
+
+export class AcqSearchRoutingModule {}
index 7e78ea4..372299a 100644 (file)
         </a>
         <div class="dropdown-menu" ngbDropdownMenu>
           <a class="dropdown-item" 
-            href="/eg/staff/acq/legacy/search/unified">
+            routerLink="/staff/acq/search">
             <span class="material-icons" aria-hidden="true">search</span>
             <span i18n>General Search</span>
           </a>
           <div class="dropdown-divider"></div>
-          <a class="dropdown-item" 
-            href="/eg/staff/acq/legacy/search/unified?ca=pl">
+          <a class="dropdown-item"
+            routerLink="/staff/acq/search/selectionlists">
             <span class="material-icons" aria-hidden="true">view_list</span>
-            <span i18n>My Selection Lists</span>
+            <span i18n>Selection Lists</span>
           </a>
           <a class="dropdown-item" 
             href="/eg/staff/acq/legacy/picklist/brief_record">
             <span class="material-icons" aria-hidden="true">cloud_upload</span>
             <span i18n>Load MARC Order Records</span>
           </a>
-          <a class="dropdown-item" 
-            href="/eg/staff/acq/legacy/search/unified?ca=po">
+          <a class="dropdown-item"
+            routerLink="/staff/acq/search/purchaseorders">
             <span class="material-icons" aria-hidden="true">shopping_cart</span>
             <span i18n>Purchase Orders</span>
           </a>
             <span class="material-icons" aria-hidden="true">contact_phone</span>
             <span i18n>Claim-Ready Items</span>
           </a>
-          <a class="dropdown-item" 
-            href="/eg/staff/acq/legacy/search/unified?ca=inv">
+          <a class="dropdown-item"
+            routerLink="/staff/acq/search/invoices">
             <span class="material-icons" aria-hidden="true">attach_money</span>
-            <span i18n>Open Invoices</span>
+            <span i18n>Invoices</span>
           </a>
           <a class="dropdown-item" 
             href="/eg/staff/acq/legacy/invoice/view?create=1">
index 6825464..497cd30 100644 (file)
@@ -80,10 +80,18 @@ export class StaffNavComponent implements OnInit, OnDestroy {
         return this.auth.user() ? this.auth.user().usrname() : '';
     }
 
+    user_id() {
+        return this.auth.user() ? this.auth.user().id() : '';
+    }
+
     workstation() {
         return this.auth.user() ? this.auth.workstation() : '';
     }
 
+    ws_ou() {
+        return this.auth.user() ? this.auth.user().ws_ou() : '';
+    }
+
     setLocale(locale: any) {
         this.locale.setLocale(locale.code());
     }
index 16c21c9..86428ba 100644 (file)
@@ -19,6 +19,10 @@ const routes: Routes = [{
     redirectTo: 'splash',
     pathMatch: 'full',
   }, {
+    path: 'acq',
+    loadChildren: () =>
+      import('@eg/staff/acq/routing.module').then(m => m.AcqRoutingModule)
+  }, {
     path: 'booking',
     loadChildren: () =>
       import('./booking/booking.module').then(m => m.BookingModule)
index 508d879..24b94fc 100644 (file)
@@ -1,6 +1,7 @@
 #staff-content-container {
   width: 95%;
   margin-top:56px;
+  margin-bottom: 50px;
   padding-right: 10px;
   padding-left: 10px;
   margin-right: auto;
index d1144fd..9ec9f67 100644 (file)
@@ -230,3 +230,21 @@ body>.dropdown-menu {z-index: 2100;}
 @media (min-width: 1600px) { .modal-xl { max-width: 1500px; } }
 @media (min-width: 1700px) { .modal-xl { max-width: 1600px; } }
 
+/**
+ * Make the acquisitions search form's navigation tabs match
+ * those of the staff interface. This is a global rule because
+ * various approaches to doing it local to the acq search component
+ * don't work:
+ *
+ * 1. A rule bound to the container of the acq search tabset
+ *    would make the entire background be grey.
+ * 2. ":host ::ng-deep" for a local rule works, but depends on a
+ *    mechanism that is deprecated.
+ * 4. ng-tabset provides no hooks for custom styles for the nav-tab
+ *    background.
+ * 5. Turning off view encapsulation for the acq search component
+ *    breaks a lot of styles.
+ */
+#acq-search-page ngb-tabset .nav.nav-tabs {
+  background-color: rgb(247, 247, 247);
+}
index b0efc91..cea8d68 100644 (file)
         </a>
         <ul uib-dropdown-menu>
           <li>
-            <a href="./acq/legacy/search/unified" target="_self">
+            <a href="/eg2/staff/acq/search" target="_self">
               <span class="glyphicon glyphicon-search" aria-hidden="true"></span>
               [% l('General Search') %]
             </a>
           </li>
           <li class="divider"></li>
           <li>
-            <a href="./acq/legacy/search/unified?ca=pl" target="_self">
+            <a href="/eg2/staff/acq/search/selectionlists" target="_self">
               <span class="glyphicon glyphicon-list" aria-hidden="true"></span>
-              [% l('My Selection Lists') %]
+              [% l('Selection Lists') %]
             </a>
           </li>
           <li>
             </a>
           </li>
           <li>
-            <a href="./acq/legacy/search/unified?ca=po" target="_self">
+            <a href="/eg2/staff/acq/search/purchaseorders" target="_self">
               <span class="glyphicon glyphicon-shopping-cart" aria-hidden="true"></span>
               [% l('Purchase Orders') %]
             </a>
             </a>
           </li>
           <li>
-            <a href="./acq/legacy/search/unified?ca=inv" target="_self">
+            <a href="/eg2/staff/acq/search/invoices" target="_self">
               <span class="glyphicon glyphicon-usd" aria-hidden="true"></span>
-              [% l('Open Invoices') %]
+              [% l('Invoices') %]
             </a>
           </li>
           <li>
index 755dcbb..06ba380 100644 (file)
@@ -116,6 +116,8 @@ angular.module('egCoreMod')
                         if (egCore.auth.user()) {
                             $scope.op_changed = egCore.auth.OCtoken() ? true : false;
                             $scope.username = egCore.auth.user().usrname();
+                            $scope.user_id = egCore.auth.user().id();
+                            $scope.ws_ou = egCore.auth.user().ws_ou();
                             $scope.workstation = egCore.auth.workstation();
 
                             egCore.org.settings([