LP#1857150: Angular provider search and management interface
authorGalen Charlton <gmc@equinoxinitiative.org>
Tue, 3 Mar 2020 23:18:55 +0000 (18:18 -0500)
committerJason Etheridge <jason@EquinoxInitiative.org>
Thu, 10 Sep 2020 20:36:26 +0000 (16:36 -0400)
The interfaces for searching for and managing acquisitions provider
records have been rewritten in Angular. This rewrite includes the
following significant changes:

* The provider search interface is now available directly from the
  Acquisitions menu, supplementing its longstanding availability from
  the Acquisitions Administration page.
* The search interface is modeled after the patron interface, including
  a search form that can be hidden or displayed, a provider summary box,
  and a multi-tabbed interface for managing the provider itself.
* The grid displaying search results is filterable and sortable.
* The provider display tabs are
 ** Details, allowing the user to view, and if permitted, edit
    the base provider record.
 ** Addresses
 ** Contacts
 ** Attribute Definitions
 ** Holdings Definitions
 ** EDI
 ** Invoices, providing an interface for viewing the invoices
    associated with the provider.
 ** POs, providing an interface for viewing the purchase orders
    associated with the provider.
* The new interface makes it possible to edit contact addresses.
* The base provider record now has an optional primary contact field.
  Selecting a contact as the primary one is managed on the Contacts
  tab.  The primary contact, if set, is displayed on the provider
  summary box.

Interfaces that used to link to the Dojo provider interface now link
to the Angular one instead.

This patch also includes a mechanism for stopping navigation away
from a dirty provider or holdings tag form unless the user expressly
confirms that they want to abandon the unsaved changes.

This patch includes contributions by Mike Rylander and Jason Etheridge.

Sponsored-by: Evergreen Community Development Initiative
Signed-off-by: Galen Charlton <gmc@equinoxinitiative.org>
Signed-off-by: Ruth Frasur <rfrasur@library.in.gov>
Signed-off-by: Bill Erickson <berickxx@gmail.com>
Signed-off-by: Jason Etheridge <jason@EquinoxInitiative.org>
42 files changed:
Open-ILS/src/eg2/src/app/staff/acq/provider/acq-provider-search-form.component.css [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/acq-provider-search-form.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/acq-provider-search-form.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/acq-provider-search.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/acq-provider.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/acq-provider.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/acq-provider.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/provider-addresses.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/provider-addresses.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/provider-attributes.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/provider-attributes.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/provider-contact-addresses.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/provider-contact-addresses.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/provider-contacts.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/provider-contacts.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/provider-details.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/provider-details.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/provider-edi-accounts.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/provider-edi-accounts.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/provider-holdings.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/provider-holdings.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/provider-invoices.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/provider-invoices.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/provider-purchase-orders.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/provider-purchase-orders.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/provider-record.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/provider-results.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/provider-results.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/resolver.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/routing.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/summary-pane.component.css [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/summary-pane.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/provider/summary-pane.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/routing.module.ts
Open-ILS/src/eg2/src/app/staff/acq/search/invoice-results.component.html
Open-ILS/src/eg2/src/app/staff/acq/search/lineitem-results.component.html
Open-ILS/src/eg2/src/app/staff/acq/search/purchase-order-results.component.html
Open-ILS/src/eg2/src/app/staff/admin/acq/admin-acq-splash.component.html
Open-ILS/src/eg2/src/app/staff/nav.component.html
Open-ILS/src/templates/menu.tt2
Open-ILS/src/templates/staff/admin/acq/t_splash.tt2
Open-ILS/src/templates/staff/navbar.tt2

diff --git a/Open-ILS/src/eg2/src/app/staff/acq/provider/acq-provider-search-form.component.css b/Open-ILS/src/eg2/src/app/staff/acq/provider/acq-provider-search-form.component.css
new file mode 100644 (file)
index 0000000..435a067
--- /dev/null
@@ -0,0 +1,5 @@
+#acq-provider-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/provider/acq-provider-search-form.component.html b/Open-ILS/src/eg2/src/app/staff/acq/provider/acq-provider-search-form.component.html
new file mode 100644 (file)
index 0000000..1dbe407
--- /dev/null
@@ -0,0 +1,64 @@
+<div id="acq-provider-search-form" class="pl-3 pr-3 pt-3 pb-3 mb-3">
+<form>
+  <div class="row mb-1">
+    <div class="col-lg">
+      <input i18n-placeholder placeholder="Provider Name" [ngModelOptions]="{standalone: true}" [(ngModel)]="providerName" type="text" class="form-control" />
+    </div>
+    <div class="col-lg">
+      <input i18n-placeholder placeholder="Code" [ngModelOptions]="{standalone: true}" [(ngModel)]="providerCode" type="text" class="form-control" />
+    </div>
+    <div class="col-lg-4">
+      <eg-org-family-select i18n-labelText labelText="Owner"
+        [limitPerms]="['VIEW_PROVIDER','MANAGE_PROVIDER','ADMIN_PROVIDER']"
+        [ngModelOptions]="{standalone: true}" [(ngModel)]="providerOwners">
+      </eg-org-family-select>
+    </div>
+    <div class="col-lg-2 text-right">
+      <button class="btn btn-primary mr-1" (click)="submitSearch()" type="submit" i18n>Search</button>
+      <button class="btn btn-secondary" (click)="clearSearch()" type="button" i18n>Reset Form</button>
+      <button class="btn" [hidden]="collapsed" (click)="toggleCollapse()" type="submit" i18n><span class="material-icons">expand_less</span></button>
+      <button class="btn" [hidden]="!collapsed" (click)="toggleCollapse()" type="submit" i18n><span class="material-icons">expand_more</span></button>
+    </div>
+  </div>
+
+  <div class="row mb-1" [hidden]="collapsed">
+    <div class="col-lg">
+      <input i18n-placeholder placeholder="Contact Name" [ngModelOptions]="{standalone: true}" [(ngModel)]="contactName" type="text" class="form-control" />
+    </div>
+    <div class="col-lg">
+      <input i18n-placeholder placeholder="Provider Email" [ngModelOptions]="{standalone: true}" [(ngModel)]="providerEmail" type="text" class="form-control" />
+    </div>
+    <div class="col-lg">
+      <input i18n-placeholder placeholder="Provider Phone" [ngModelOptions]="{standalone: true}" [(ngModel)]="providerPhone" type="text" class="form-control" />
+    </div>
+    <div class="col-lg">
+      <eg-combobox i18n-placeholder placeholder="Currency"
+        idlClass="acqct" [asyncSupportsEmptyTermClick]="true"
+        (onChange)="providerCurrencyType = $event ? $event.id : null" [asyncSupportsEmptyTermClick]="true"></eg-combobox>
+    </div>
+  </div>
+
+  <div class="row mb-1" [hidden]="collapsed">
+    <div class="col-lg">
+      <input i18n-placeholder placeholder="SAN" [ngModelOptions]="{standalone: true}" [(ngModel)]="providerSAN" type="text" class="form-control" />
+    </div>
+    <div class="col-lg">
+      <!-- edi default account link -->
+      <eg-combobox i18n-placeholder placeholder="EDI Default"
+        idlClass="acqedi" [asyncSupportsEmptyTermClick]="true"
+        (onChange)="providerEDIDefault = $event ? $event.id : null"></eg-combobox>
+    </div>
+    <div class="col-lg">
+      <input i18n-placeholder placeholder="URL" [ngModelOptions]="{standalone: true}" [(ngModel)]="providerURL" type="text" class="form-control" />
+    </div>
+    <div class="col-lg form-group">
+      <label for="acqproIsActive" i18n>Active?</label>
+      <select class="form-control" id="acqproIsActive" [ngModelOptions]="{standalone: true}" [(ngModel)]="providerIsActive">
+        <option i18n value="active">Yes</option>
+        <option i18n value="inactive">No</option>
+        <option i18n value="any">Any</option>
+      </select>
+    </div>
+  </div>
+</form>
+</div>
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/provider/acq-provider-search-form.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/provider/acq-provider-search-form.component.ts
new file mode 100644 (file)
index 0000000..618cb44
--- /dev/null
@@ -0,0 +1,134 @@
+import {Component, OnInit, AfterViewInit, Input, Output, EventEmitter, ViewChild} from '@angular/core';
+import {NgbTabset, NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap';
+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 {AuthService} from '@eg/core/auth.service';
+import {AcqProviderSearchTerm, AcqProviderSearch} from './acq-provider-search.service';
+import {StoreService} from '@eg/core/store.service';
+import {OrgFamily} from '@eg/share/org-family-select/org-family-select.component';
+
+@Component({
+  selector: 'eg-acq-provider-search-form',
+  styleUrls: ['acq-provider-search-form.component.css'],
+  templateUrl: './acq-provider-search-form.component.html'
+})
+
+export class AcqProviderSearchFormComponent implements OnInit, AfterViewInit {
+
+    @Output() searchSubmitted = new EventEmitter<AcqProviderSearch>();
+
+    collapsed = false;
+
+    providerName = '';
+    providerCode = '';
+    providerOwners: OrgFamily;
+    contactName = '';
+    providerEmail = '';
+    providerPhone = '';
+    providerCurrencyType = '';
+    providerSAN = '';
+    providerEDIDefault = null;
+    providerURL = '';
+    providerIsActive = 'active';
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private pcrud: PcrudService,
+        private localStore: StoreService,
+        private idl: IdlService,
+        private toast: ToastService,
+        private auth: AuthService,
+    ) {}
+
+    ngOnInit() {
+        const self = this;
+        this.providerOwners = {primaryOrgId: this.auth.user().ws_ou(), includeDescendants: true};
+        this.collapsed = this.localStore.getLocalItem('eg.acq.provider.search.collapse_form') || false;
+    }
+
+    ngAfterViewInit() {}
+
+    clearSearch() {
+        this.providerName = '';
+        this.providerCode = '';
+        this.providerOwners = {primaryOrgId: this.auth.user().ws_ou(), includeDescendants: true};
+        this.contactName = '';
+        this.providerEmail = '';
+        this.providerPhone = '';
+        this.providerCurrencyType = '';
+        this.providerSAN = '';
+        this.providerEDIDefault = null;
+        this.providerURL = '';
+        this.providerIsActive = 'active';
+    }
+
+    submitSearch() {
+
+        const searchTerms: AcqProviderSearchTerm[] = [];
+        if (this.providerName) {
+            searchTerms.push({ classes: ['acqpro'], fields: ['name'], op: 'ilike', value: this.providerName });
+        }
+        if (this.providerCode) {
+            searchTerms.push({ classes: ['acqpro'], fields: ['code'], op: 'ilike', value: this.providerCode });
+        }
+        if (this.providerOwners) {
+            searchTerms.push({ classes: ['acqpro'], fields: ['owner'], op: 'in', value: this.providerOwners.orgIds });
+        }
+        if (this.contactName) {
+            searchTerms.push({ classes: ['acqpc'], fields: ['name'], op: 'ilike', value: this.contactName });
+        }
+        if (this.providerEmail) {
+            searchTerms.push({ classes: ['acqpro', 'acqpc'], fields: ['email', 'email'], op: 'ilike', value: this.providerEmail });
+        }
+        if (this.providerPhone) {
+            // this requires the flesh hash to contain: { ... join: { acqpc: { type: 'left' } } ... }
+            searchTerms.push({
+                classes: ['acqpc', 'acqpro', 'acqpro'],
+                fields: ['phone', 'phone', 'fax_phone'],
+                op: 'ilike',
+                value: this.providerPhone,
+            });
+        }
+        if (this.providerCurrencyType) {
+            searchTerms.push({ classes: ['acqpro'], fields: ['currency_type'], op: '=', value: this.providerCurrencyType });
+        }
+        if (this.providerSAN) {
+            searchTerms.push({ classes: ['acqpro'], fields: ['san'], op: 'ilike', value: this.providerSAN });
+        }
+        if (this.providerEDIDefault) {
+            searchTerms.push({ classes: ['acqpro'], fields: ['edi_default'], op: '=', value: this.providerEDIDefault });
+        }
+        if (this.providerURL) {
+            searchTerms.push({ classes: ['acqpro'], fields: ['url'], op: 'ilike', value: this.providerURL });
+        }
+        switch (this.providerIsActive) {
+            case 'active': {
+                searchTerms.push({ classes: ['acqpro'], fields: ['active'], op: '=', value: 't' });
+                break;
+            }
+            case 'inactive': {
+                searchTerms.push({ classes: ['acqpro'], fields: ['active'], op: '=', value: 'f' });
+                break;
+            }
+        }
+
+        // tossing setTimeout here to ensure that the
+        // grid data source is fully initialized
+        setTimeout(() => {
+            this.searchSubmitted.emit({
+                terms: searchTerms,
+            });
+        });
+    }
+
+    toggleCollapse() {
+        this.collapsed = ! this.collapsed;
+        this.localStore.setLocalItem('eg.acq.provider.search.collapse_form', this.collapsed);
+    }
+
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/provider/acq-provider-search.service.ts b/Open-ILS/src/eg2/src/app/staff/acq/provider/acq-provider-search.service.ts
new file mode 100644 (file)
index 0000000..38b61d3
--- /dev/null
@@ -0,0 +1,205 @@
+import {Injectable} from '@angular/core';
+import {empty, throwError} from 'rxjs';
+import {map} from 'rxjs/operators';
+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 {EventService} from '@eg/core/event.service';
+import {ProviderRecordService} from './provider-record.service';
+
+export interface AcqProviderSearchTerm {
+    classes: string[];
+    fields: string[];
+    op: string;
+    value: any;
+}
+
+export interface AcqProviderSearch {
+    terms: AcqProviderSearchTerm[];
+}
+
+@Injectable()
+export class AcqProviderSearchService {
+
+    _terms: AcqProviderSearchTerm[] = [];
+    firstRun = true;
+
+    constructor(
+        private evt: EventService,
+        private auth: AuthService,
+        private pcrud: PcrudService,
+        private providerRecord: ProviderRecordService
+    ) {
+        this.firstRun = true;
+    }
+
+    setSearch(search: AcqProviderSearch) {
+        this._terms = search.terms;
+        this.firstRun = false;
+    }
+
+    generateSearchJoins(): any {
+        const joinPart = new Object();
+        let class_list = new Array();
+
+        // get all the classes used
+        this._terms.forEach(term => { class_list = class_list.concat(term.classes); });
+
+        // filter out acqpro, empty names, and make unique
+        class_list = class_list.filter((x, i, a) => x && x !== 'acqpro' && a.indexOf(x) === i);
+
+        // build a join clause for use in the "opts" part of a pcrud query
+        class_list.forEach(cls => { joinPart[cls] = {type : 'left' }; });
+
+        if (Object.keys(joinPart).length === 0) { return null; }
+        return joinPart;
+    }
+
+    generateSearch(filters): any {
+        // base query to grab all providers
+        const base = { id: { '!=': null } };
+        const query: any = new Array();
+        query.push(base);
+
+        // handle supplied search terms
+        this._terms.forEach(term => {
+            if (term.value === '') {
+                return;
+            }
+
+            // not const because we may want an array
+            let query_obj = new Object();
+            const query_arr = new Array();
+
+            let op = term.op;
+            if (!op) { op = '='; } // just in case
+
+            let val = term.value;
+            if (op === 'ilike') {
+                val = '%' + val + '%';
+            }
+
+            let isOR = false;
+            term.fields.forEach( (field, ind) => {
+                const curr_cls = term.classes[ind];
+
+                // remove any OUs that the user doesn't have provider view
+                // permission for
+                if (curr_cls === 'acqpro' && field === 'owner' && op === 'in') {
+                    val = val.filter(ou => {
+                        return this.providerRecord.getViewOUs().includes(ou);
+                    });
+                }
+
+                if (ind === 1) {
+                    // we're OR'ing in other classes/fields
+                    // and this is the first so restructure
+                    const first_cls = term.classes[0];
+                    isOR = true;
+                    let tmp = new Object();
+                    if (first_cls) {
+                        tmp['+' + first_cls] = query_obj;
+                    } else {
+                        tmp = query_obj;
+                    }
+
+                    query_arr.push(tmp);
+                }
+
+                if (curr_cls) {
+                    if (isOR) {
+                        const tmp = new Object();
+                        tmp['+' + curr_cls] = new Object();
+                        tmp['+' + curr_cls][field] = new Object();
+                        tmp['+' + curr_cls][field][op] = val;
+                        query_arr.push(tmp);
+                    } else {
+                        query_obj['+' + curr_cls] = new Object();
+                        query_obj['+' + curr_cls][field] = new Object();
+                        query_obj['+' + curr_cls][field][op] = val;
+                    }
+                } else {
+                    if (isOR) {
+                        const tmp = new Object();
+                        tmp[field] = new Object();
+                        tmp[field][op] = val;
+                        query_arr.push(tmp);
+                    } else {
+                        query_obj[field] = new Object();
+                        query_obj[field][op] = val;
+                    }
+                }
+
+            });
+
+            if (isOR) { query_obj = {'-or': query_arr}; }
+            query.push(query_obj);
+        });
+
+        // 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 => {
+                query.push(condition);
+            });
+        });
+        return query;
+    }
+
+    getDataSource(): GridDataSource {
+        const gridSource = new GridDataSource();
+
+        // we'll sort by provder name by default
+        gridSource.sort = [{ name: 'name', dir: 'ASC' }];
+
+        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 joins = this.generateSearchJoins();
+            const query = this.generateSearch(gridSource.filters);
+
+            const opts = {};
+            if (joins) { opts['join'] = joins; }
+            opts['offset'] = pager.offset;
+            opts['limit'] = pager.limit;
+            opts['au_by_id'] = true;
+
+            if (sort.length > 0) {
+                opts['order_by'] = [];
+                sort.forEach(sort_clause => {
+                    opts['order_by'].push({
+                        class: 'acqpro',
+                        field: sort_clause.name,
+                        direction: sort_clause.dir
+                    });
+                });
+            }
+
+            return this.pcrud.search('acqpro',
+                query,
+                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/provider/acq-provider.component.html b/Open-ILS/src/eg2/src/app/staff/acq/provider/acq-provider.component.html
new file mode 100644 (file)
index 0000000..69aebda
--- /dev/null
@@ -0,0 +1,152 @@
+<eg-staff-banner bannerText="Providers" i18n-bannerText>
+</eg-staff-banner>
+<eg-string #createString i18n-text text="New Provider Added"></eg-string>
+<eg-string #createErrString i18n-text text="Failed to Create New Provider"></eg-string>
+
+<eg-confirm-dialog #leaveConfirm
+  i18n-dialogTitle i18n-dialogBody
+  dialogTitle="Unsaved Changes Warning"
+  dialogBody="There are unsaved changes. Are you sure you want to leave?">
+</eg-confirm-dialog>
+
+<div><div class="row">
+
+<div class="col">
+<div class="row mb-2">
+  <div class="col-lg">
+    <h3 i18n *ngIf="id && !showSearchForm">{{providerRecord.currentProvider?.record.name()}} ({{providerRecord.currentProvider?.record.code()}})</h3>
+  </div>
+  <div class="col-lg-auto">
+    <div class="btn-toolbar" role="toolbar">
+      <div class="button-grp mr-2">
+        <button class="btn btn-primary" [hidden]="showSearchForm" (click)="showSearchForm = !showSearchForm" i18n>Show Search Form</button>
+        <button class="btn btn-primary" [hidden]="!showSearchForm"  (click)="showSearchForm = !showSearchForm" i18n>Hide Search Form</button>
+      </div>
+      <div class="button-grp mr-2">
+        <button class="btn btn-primary" (click)="createNew()" [disabled]="!providerRecord.checkIfCanAdminAtAll()" i18n>New Provider</button>
+      </div>
+    </div>
+  </div>
+</div>
+<div class="row mb-5" [hidden]="!showSearchForm">
+  <div class="col-lg-12">
+    <eg-provider-results #acqProviderResults (desireSummarize)="onDesireSummarize($event)" (summarizeSearchFormOpen)="onDesireSummarize($event, false, false)"></eg-provider-results>
+  </div>
+</div>
+
+<h3 i18n *ngIf="id && showSearchForm">{{providerRecord.currentProvider?.record.name()}} ({{providerRecord.currentProvider?.record.code()}})</h3>
+<div class="row">
+<div class="col-lg-auto">
+  <eg-acq-provider-summary-pane #acqSearchProviderSummary
+    (summaryToggled)="onSummaryToggled($event)" [providerId]="id">
+  </eg-acq-provider-summary-pane>
+</div>
+
+<div class="col">
+<div class="row" id="acq-provider-page" [hidden]="!id">
+  <div class="col-lg-12">
+    <ngb-tabset #acqProviderTabs [activeId]="activeTab" (tabChange)="onTabChange($event)">
+      <ngb-tab title="Provider" i18n-title id="details" [disabled]="!id">
+        <ng-template ngbTabContent>
+          <div class="row mt-3">
+            <div class="col-lg-12 text-right pb-1">
+              <button class="btn btn-secondary btn-sm" [disabled]="activeTab == defaultTabType"
+                (click)="setDefaultTab()" i18n>Set Default View</button>
+            </div>
+          </div>
+          <eg-provider-details #providerDetails (desireSummarize)="onDesireSummarize($event, true)"></eg-provider-details>
+        </ng-template>
+      </ngb-tab>
+      <ngb-tab title="Addresses" i18n-title id="addresses" [disabled]="!id || !this.providerRecord.currentProvider || !this.providerRecord.currentProvider.canAdmin">
+        <ng-template ngbTabContent>
+          <div class="row mt-3">
+            <div class="col-lg-12 text-right pb-1">
+              <button class="btn btn-secondary btn-sm" [disabled]="activeTab == defaultTabType"
+                (click)="setDefaultTab()" i18n>Set Default View</button>
+            </div>
+          </div>
+          <eg-provider-addresses></eg-provider-addresses>
+        </ng-template>
+      </ngb-tab>
+      <ngb-tab title="Contacts" i18n-title id="contacts" [disabled]="!id || !this.providerRecord.currentProvider || !this.providerRecord.currentProvider.canAdmin">
+        <ng-template ngbTabContent>
+          <div class="row mt-3">
+            <div class="col-lg-12 text-right pb-1">
+              <button class="btn btn-secondary btn-sm" [disabled]="activeTab == defaultTabType"
+                (click)="setDefaultTab()" i18n>Set Default View</button>
+            </div>
+          </div>
+          <eg-provider-contacts (desireSummarize)="onDesireSummarize($event, true)"></eg-provider-contacts>
+        </ng-template>
+      </ngb-tab>
+      <ngb-tab title="Attribute Definitions" i18n-title id="attributes" [disabled]="!id || !this.providerRecord.currentProvider || !this.providerRecord.currentProvider.canAdmin">
+        <ng-template ngbTabContent>
+          <div class="row mt-3">
+            <div class="col-lg-12 text-right pb-1">
+              <button class="btn btn-secondary btn-sm" [disabled]="activeTab == defaultTabType"
+                (click)="setDefaultTab()" i18n>Set Default View</button>
+            </div>
+          </div>
+          <eg-provider-attributes></eg-provider-attributes>
+        </ng-template>
+      </ngb-tab>
+      <ngb-tab title="Holdings Definitions" i18n-title id="holdings" [disabled]="!id || !this.providerRecord.currentProvider || !this.providerRecord.currentProvider.canAdmin">
+        <ng-template ngbTabContent>
+          <div class="row mt-3">
+            <div class="col-lg-12 text-right pb-1">
+              <button class="btn btn-secondary btn-sm" [disabled]="activeTab == defaultTabType"
+                (click)="setDefaultTab()" i18n>Set Default View</button>
+            </div>
+          </div>
+          <eg-provider-holdings #providerHoldings></eg-provider-holdings>
+        </ng-template>
+      </ngb-tab>
+      <ngb-tab title="EDI" i18n-title id="edi_accounts" [disabled]="!id || !this.providerRecord.currentProvider || !this.providerRecord.currentProvider.canAdmin">
+        <ng-template ngbTabContent>
+          <div class="row mt-3">
+            <div class="col-lg-12 text-right pb-1">
+              <button class="btn btn-secondary btn-sm" [disabled]="activeTab == defaultTabType"
+                (click)="setDefaultTab()" i18n>Set Default View</button>
+            </div>
+          </div>
+          <eg-provider-edi-accounts (desireSummarize)="onDesireSummarize($event, true)"></eg-provider-edi-accounts>
+        </ng-template>
+      </ngb-tab>
+      <ngb-tab title="Invoices" i18n-title id="invoices" [disabled]="!id">
+        <ng-template ngbTabContent>
+          <div class="row mt-3">
+            <div class="col-lg-12 text-right pb-1">
+              <button class="btn btn-secondary btn-sm" [disabled]="activeTab == defaultTabType"
+                (click)="setDefaultTab()" i18n>Set Default View</button>
+            </div>
+          </div>
+          <eg-provider-invoices></eg-provider-invoices>
+        </ng-template>
+      </ngb-tab>
+      <ngb-tab title="POs" i18n-title id="purchase_orders" [disabled]="!id">
+        <ng-template ngbTabContent>
+          <div class="row mt-3">
+            <div class="col-lg-12 text-right pb-1">
+              <button class="btn btn-secondary btn-sm" [disabled]="activeTab == defaultTabType"
+                (click)="setDefaultTab()" i18n>Set Default View</button>
+            </div>
+          </div>
+          <eg-provider-purchase-orders></eg-provider-purchase-orders>
+        </ng-template>
+      </ngb-tab>
+    </ngb-tabset>
+  </div>
+</div>
+</div>
+</div>
+</div>
+
+</div></div>
+
+<eg-fm-record-editor #createDialog
+  idlClass="acqpro"
+  fieldOrder="active,name,code,currency_type,default_claim_policy,default_copy_count,edi_default,owner,url,san,prepayment_required"
+  [remainOpenOnError]="true"
+  [fieldOptions]="{currency_type:{preloadLinkedValues:true},edi_default:{preloadLinkedValues:true},default_claim_policy:{preloadLinkedValues:true}}"
+  hiddenFields="id,email,phone,fax_phone,holding_tag,primary_contact">
+</eg-fm-record-editor>
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/provider/acq-provider.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/provider/acq-provider.component.ts
new file mode 100644 (file)
index 0000000..895407b
--- /dev/null
@@ -0,0 +1,194 @@
+import {Component, OnInit, AfterViewInit, ViewChild, ChangeDetectorRef, OnDestroy} from '@angular/core';
+import {filter, takeUntil} from 'rxjs/operators';
+import {Subject, Observable, of} from 'rxjs';
+import {NgbTabset, NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap';
+import {Router, ActivatedRoute, ParamMap, RouterEvent, NavigationEnd} 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 {AcqProviderSummaryPaneComponent} from './summary-pane.component';
+import {ProviderDetailsComponent} from './provider-details.component';
+import {ProviderHoldingsComponent} from './provider-holdings.component';
+import {ProviderResultsComponent} from './provider-results.component';
+import {ProviderRecordService} from './provider-record.service';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {StringComponent} from '@eg/share/string/string.component';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {AuthService} from '@eg/core/auth.service';
+import {StoreService} from '@eg/core/store.service';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+
+@Component({
+  templateUrl: './acq-provider.component.html'
+})
+
+export class AcqProviderComponent implements OnInit, AfterViewInit, OnDestroy {
+
+    activeTab = '';
+    showSearchForm = false;
+    id = null;
+    validTabTypes = ['details', 'addresses', 'contacts', 'attributes', 'holdings', 'edi_accounts', 'purchase_orders', 'invoices'];
+    defaultTabType = 'details';
+    @ViewChild('acqSearchProviderSummary', { static: true }) providerSummaryPane: AcqProviderSummaryPaneComponent;
+    @ViewChild('acqProviderResults', { static: true }) acqProviderResults: ProviderResultsComponent;
+    @ViewChild('providerDetails', { static: false }) providerDetails: ProviderDetailsComponent;
+    @ViewChild('providerHoldings', { static: false }) providerHoldings: ProviderHoldingsComponent;
+    @ViewChild('createDialog', { static: true }) createDialog: FmRecordEditorComponent;
+    @ViewChild('createString', { static: false }) createString: StringComponent;
+    @ViewChild('createErrString', { static: false }) createErrString: StringComponent;
+    @ViewChild('leaveConfirm', { static: true }) leaveConfirm: ConfirmDialogComponent;
+
+    onTabChange: ($event: NgbTabChangeEvent) => void;
+
+    onDesireSummarize: ($event: number, updateSummaryOnly?: boolean, hideSearchForm?: boolean) => void;
+    onSummaryToggled: ($event: boolean) => void;
+
+    previousUrl: string = null;
+    public destroyed = new Subject<any>();
+    _alreadyDeactivated = false;
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private auth: AuthService,
+        private pcrud: PcrudService,
+        private idl: IdlService,
+        private providerRecord: ProviderRecordService,
+        private toast: ToastService,
+        private store: StoreService,
+        private changeDetector: ChangeDetectorRef
+    ) {
+        this.router.events.pipe(
+            filter((event: RouterEvent) => event instanceof NavigationEnd),
+            takeUntil(this.destroyed)
+        ).subscribe(routeEvent => {
+            if (routeEvent instanceof NavigationEnd) {
+                if (this.previousUrl != null &&
+                    routeEvent.url === '/staff/acq/provider' &&
+                    this.previousUrl === routeEvent.url) {
+                    this.acqProviderResults.resetSearch();
+                }
+                this.previousUrl = routeEvent.url;
+            }
+        });
+    }
+
+    ngOnInit() {
+        const self = this;
+
+        const tabTypeParam = this.route.snapshot.paramMap.get('tab');
+        const idParam = this.route.snapshot.paramMap.get('id');
+
+        this.defaultTabType =
+            this.store.getLocalItem('eg.acq.provider.default_tab') || 'details';
+
+        if (idParam) {
+            this.showSearchForm = false;
+            this.id = idParam;
+            if (!tabTypeParam) {
+                this.activeTab = this.defaultTabType;
+                this.router.navigate(['/staff', 'acq', 'provider', this.id, this.activeTab]);
+            }
+        }
+
+        if (tabTypeParam) {
+            this.showSearchForm = false;
+            if (this.validTabTypes.includes(tabTypeParam)) {
+                this.activeTab = tabTypeParam;
+            } else {
+                this.activeTab = this.defaultTabType;
+                this.router.navigate(['/staff', 'acq', 'provider', this.id, this.activeTab]);
+            }
+        } else {
+            this.showSearchForm = true;
+        }
+
+        this.onTabChange = ($event) => {
+            $event.preventDefault();
+            this.canDeactivate().subscribe(canLeave => {
+                if (!canLeave) { return; }
+                this._alreadyDeactivated = true; // don't trigger again on the route change
+                if (this.validTabTypes.includes($event.nextId)) {
+                    this.activeTab = $event.nextId;
+                    const id = this.route.snapshot.paramMap.get('id');
+                    this.router.navigate(['/staff', 'acq', 'provider', this.id, $event.nextId]);
+                }
+            });
+        };
+
+        this.onDesireSummarize = ($event, updateSummaryOnly = false, hideSearchForm = true) => {
+            this.id = $event;
+            this.providerRecord.fetch(this.id).then(() => {
+                // $event is a provider ID
+                this.providerSummaryPane.update($event);
+                if (this.providerDetails) {
+                    this.providerDetails.refresh();
+                }
+                if (updateSummaryOnly) {
+                    return;
+                }
+                this.providerRecord.announceProviderUpdated();
+                if (hideSearchForm) {
+                    this.showSearchForm = false;
+                }
+                this.activeTab = this.defaultTabType;
+                this.router.navigate(['/staff', 'acq', 'provider', this.id, this.activeTab]);
+            });
+        };
+
+        this.onSummaryToggled = ($event) => {
+            // in case this is useful for a better implementation of reflowing the UI
+        };
+    }
+
+    ngAfterViewInit() {
+        this.changeDetector.detectChanges();
+    }
+
+    ngOnDestroy(): void {
+        this.destroyed.next();
+        this.destroyed.complete();
+    }
+
+    setDefaultTab() {
+        this.defaultTabType = this.activeTab;
+        this.store.setLocalItem('eg.acq.provider.default_tab', this.activeTab);
+    }
+
+    createNew() {
+        this.createDialog.mode = 'create';
+        const provider = this.idl.create('acqpro');
+        provider.active(true);
+        provider.owner(this.auth.user().ws_ou());
+        provider.default_copy_count(1);
+        this.createDialog.record = provider;
+        this.createDialog.recordId = null;
+        this.createDialog.open({size: 'lg'}).subscribe(
+            ok => {
+                this.createString.current()
+                    .then(str => this.toast.success(str));
+                this.onDesireSummarize(ok.id());
+            },
+            rejection => {
+                if (!rejection.dismissed) {
+                    this.createErrString.current()
+                        .then(str => this.toast.danger(str));
+                }
+            }
+        );
+    }
+
+    canDeactivate(): Observable<boolean> {
+        if (this._alreadyDeactivated) {
+            // one freebie
+            this._alreadyDeactivated = false;
+            return of(true);
+        }
+        if ((this.providerHoldings && this.providerHoldings.isDirty()) ||
+            (this.providerDetails && this.providerDetails.isDirty())) {
+            return this.leaveConfirm.open();
+        } else {
+            return of(true);
+        }
+    }
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/provider/acq-provider.module.ts b/Open-ILS/src/eg2/src/app/staff/acq/provider/acq-provider.module.ts
new file mode 100644 (file)
index 0000000..cbfd46d
--- /dev/null
@@ -0,0 +1,50 @@
+import {NgModule} from '@angular/core';
+import {StaffCommonModule} from '@eg/staff/common.module';
+import {AcqProviderRoutingModule} from './routing.module';
+import {AcqProviderComponent} from './acq-provider.component';
+import {AcqProviderSearchFormComponent} from './acq-provider-search-form.component';
+import {AcqProviderSummaryPaneComponent} from './summary-pane.component';
+import {ProviderResultsComponent} from './provider-results.component';
+import {ProviderDetailsComponent} from './provider-details.component';
+import {ProviderAddressesComponent} from './provider-addresses.component';
+import {ProviderContactsComponent} from './provider-contacts.component';
+import {ProviderContactAddressesComponent} from './provider-contact-addresses.component';
+import {ProviderHoldingsComponent} from './provider-holdings.component';
+import {ProviderAttributesComponent} from './provider-attributes.component';
+import {ProviderEdiAccountsComponent} from './provider-edi-accounts.component';
+import {ProviderInvoicesComponent} from './provider-invoices.component';
+import {ProviderPurchaseOrdersComponent} from './provider-purchase-orders.component';
+import {OrgFamilySelectModule} from '@eg/share/org-family-select/org-family-select.module';
+import {FmRecordEditorModule} from '@eg/share/fm-editor/fm-editor.module';
+import {ProviderRecordService} from './provider-record.service';
+
+@NgModule({
+  declarations: [
+    AcqProviderComponent,
+    AcqProviderSearchFormComponent,
+    AcqProviderSummaryPaneComponent,
+    ProviderResultsComponent,
+    ProviderDetailsComponent,
+    ProviderAddressesComponent,
+    ProviderContactsComponent,
+    ProviderContactAddressesComponent,
+    ProviderHoldingsComponent,
+    ProviderAttributesComponent,
+    ProviderEdiAccountsComponent,
+    ProviderInvoicesComponent,
+    ProviderPurchaseOrdersComponent,
+    AcqProviderSummaryPaneComponent
+  ],
+  imports: [
+    StaffCommonModule,
+    OrgFamilySelectModule,
+    FmRecordEditorModule,
+    AcqProviderRoutingModule
+  ],
+  providers: [
+    ProviderRecordService
+  ],
+})
+
+export class AcqProviderModule {
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-addresses.component.html b/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-addresses.component.html
new file mode 100644 (file)
index 0000000..e64f98d
--- /dev/null
@@ -0,0 +1,39 @@
+<eg-string #createString i18n-text text="New Provider Address Added"></eg-string>
+<eg-string #createErrString i18n-text text="Failed to Create New Provider Address"></eg-string>
+<eg-string #successString i18n-text text="Provider Address Update Succeeded"></eg-string>
+<eg-string #updateFailedString i18n-text text="Provider Address Update Failed"></eg-string>
+<eg-string #deleteFailedString i18n-text text="Delete of Provider Address failed or was not allowed"></eg-string>
+<eg-string #deleteSuccessString i18n-text text="Delete of Provider Address succeeded"></eg-string>
+
+<eg-grid #acqProviderAddressesGrid
+  persistKey="acq.provider.addresses"
+  idlClass="acqpa" [dataSource]="gridSource"
+  [sortable]="true"
+  [filterable]="true"
+  hideFields="provider,id"
+  [cellTextGenerator]="cellTextGenerator">
+    <eg-grid-toolbar-button 
+      label="New Provider Address" i18n-label (onClick)="createNew()">
+    </eg-grid-toolbar-button>
+    <eg-grid-toolbar-action label="Edit Selected" i18n-label (onClick)="editSelected($event)">
+    </eg-grid-toolbar-action>
+    <eg-grid-toolbar-action label="Delete Selected" i18n-label (onClick)="deleteSelected($event)">
+    </eg-grid-toolbar-action>
+  <eg-grid-column name="address_type"></eg-grid-column>
+  <eg-grid-column name="street1"></eg-grid-column>
+  <eg-grid-column name="street2"></eg-grid-column>
+  <eg-grid-column name="city"></eg-grid-column>
+  <eg-grid-column name="county"></eg-grid-column>
+  <eg-grid-column name="state"></eg-grid-column>
+  <eg-grid-column name="country"></eg-grid-column>
+  <eg-grid-column name="post_code"></eg-grid-column>
+  <eg-grid-column name="fax_phone"></eg-grid-column>
+  <eg-grid-column name="valid"></eg-grid-column>
+</eg-grid>
+
+<eg-fm-record-editor #editDialog
+  idlClass="acqpa"
+  readonlyFields="id,provider"
+  fieldOrder="id,valid,address_type,provider,street1,street2,city,county,state,country,post_code,fax_phone">
+</eg-fm-record-editor>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-addresses.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-addresses.component.ts
new file mode 100644 (file)
index 0000000..08eb4b2
--- /dev/null
@@ -0,0 +1,236 @@
+import {Component, OnInit, AfterViewInit, OnDestroy, Input, ViewChild} from '@angular/core';
+import {empty, throwError, Observable, from, Subscription} from 'rxjs';
+import {map} from 'rxjs/operators';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {Pager} from '@eg/share/util/pager';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {EventService} from '@eg/core/event.service';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {GridDataSource, GridCellTextGenerator} from '@eg/share/grid/grid';
+import {ProviderRecord, ProviderRecordService} from './provider-record.service';
+import {AcqProviderSearchFormComponent} from './acq-provider-search-form.component';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {StringComponent} from '@eg/share/string/string.component';
+import {ToastService} from '@eg/share/toast/toast.service';
+
+@Component({
+  selector: 'eg-provider-addresses',
+  templateUrl: 'provider-addresses.component.html',
+})
+export class ProviderAddressesComponent implements OnInit, AfterViewInit, OnDestroy {
+
+    addresses: any[] = [];
+
+    gridSource: GridDataSource;
+    @ViewChild('editDialog', { static: true }) editDialog: FmRecordEditorComponent;
+    @ViewChild('acqProviderAddressesGrid', { static: true }) providerAddressesGrid: GridComponent;
+    @ViewChild('successString', { static: true }) successString: StringComponent;
+    @ViewChild('createString', { static: false }) createString: StringComponent;
+    @ViewChild('createErrString', { static: false }) createErrString: StringComponent;
+    @ViewChild('updateFailedString', { static: false }) updateFailedString: StringComponent;
+    @ViewChild('deleteFailedString', { static: true }) deleteFailedString: StringComponent;
+    @ViewChild('deleteSuccessString', { static: true }) deleteSuccessString: StringComponent;
+
+    cellTextGenerator: GridCellTextGenerator;
+    provider: IdlObject;
+
+    canCreate: boolean;
+    canDelete: boolean;
+    deleteSelected: (rows: IdlObject[]) => void;
+
+    permissions: {[name: string]: boolean};
+
+    subscription: Subscription;
+
+    // Size of create/edito dialog.  Uses large by default.
+    @Input() dialogSize: 'sm' | 'lg' = 'lg';
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private net: NetService,
+        private evt: EventService,
+        private pcrud: PcrudService,
+        private idl: IdlService,
+        private auth: AuthService,
+        private providerRecord: ProviderRecordService,
+        private toast: ToastService) {
+    }
+
+    ngOnInit() {
+        this.gridSource = this.getDataSource();
+        this.cellTextGenerator = {};
+        this.deleteSelected = (idlThings: IdlObject[]) => {
+            idlThings.forEach(idlThing => idlThing.isdeleted(true));
+            this.providerRecord.batchUpdate(idlThings).subscribe(
+                val => {
+                    console.debug('deleted: ' + val);
+                    this.deleteSuccessString.current()
+                        .then(str => this.toast.success(str));
+                },
+                err => {
+                    this.deleteFailedString.current()
+                        .then(str => this.toast.danger(str));
+                },
+                ()  => {
+                    this.providerRecord.refreshCurrent().then(
+                        () => this.providerAddressesGrid.reload()
+                    );
+                }
+            );
+        };
+        this.providerAddressesGrid.onRowActivate.subscribe(
+            (idlThing: IdlObject) => this.showEditDialog(idlThing)
+        );
+        this.subscription = this.providerRecord.providerUpdated$.subscribe(
+            id => {
+                this.providerAddressesGrid.reload();
+            }
+        );
+    }
+
+    ngAfterViewInit() {
+        console.log('this.providerRecord', this.providerRecord);
+    }
+
+    ngOnDestroy() {
+        this.subscription.unsubscribe();
+    }
+
+    generateSearch(filters): any {
+        const query: any = new Array();
+
+        Object.keys(filters).forEach(filterField => {
+            filters[filterField].forEach(condition => {
+                query.push(condition);
+            });
+        });
+        return query;
+    }
+
+    getDataSource(): GridDataSource {
+        const gridSource = new GridDataSource();
+
+        gridSource.getRows = (pager: Pager, sort: any[]) => {
+            this.provider = this.providerRecord.current();
+            if (!this.provider) {
+                return empty();
+            }
+            let addresses = this.provider.addresses();
+
+            const query = this.generateSearch(gridSource.filters);
+            if (query.length) {
+                query.unshift( { id: addresses.map(a => a.id()) } );
+
+                const opts = {};
+                opts['offset'] = pager.offset;
+                opts['limit'] = pager.limit;
+                opts['au_by_id'] = true;
+
+                if (sort.length > 0) {
+                    opts['order_by'] = [];
+                    sort.forEach(sort_clause => {
+                        opts['order_by'].push({
+                            class: 'acqpa',
+                            field: sort_clause.name,
+                            direction: sort_clause.dir
+                        });
+                    });
+                }
+
+                return this.pcrud.search('acqpa',
+                    query,
+                    opts
+                ).pipe(
+                    map(res => {
+                        if (this.evt.parse(res)) {
+                            throw throwError(res);
+                        } else {
+                            return res;
+                        }
+                    }),
+                );
+            }
+
+            if (sort.length > 0) {
+                addresses = addresses.sort((a, b) => {
+                    for (let i = 0; i < sort.length; i++) {
+                        let lt = -1;
+                        const sfield = sort[i].name;
+                        if (sort[i].dir.substring(0, 1).toLowerCase() === 'd') {
+                            lt *= -1;
+                        }
+                        if (a[sfield]() < b[sfield]()) { return lt; }
+                        if (a[sfield]() > b[sfield]()) { return lt * -1; }
+                    }
+                    return 0;
+                });
+
+            }
+
+            return from(addresses.slice(pager.offset, pager.offset + pager.limit));
+        };
+        return gridSource;
+    }
+
+    showEditDialog(providerAddress: IdlObject): Promise<any> {
+        this.editDialog.mode = 'update';
+        this.editDialog.recordId = providerAddress['id']();
+        return new Promise((resolve, reject) => {
+            this.editDialog.open({size: this.dialogSize}).subscribe(
+                result => {
+                    this.successString.current()
+                        .then(str => this.toast.success(str));
+                    this.providerRecord.refreshCurrent().then(
+                        () => this.providerAddressesGrid.reload()
+                    );
+                    resolve(result);
+                },
+                error => {
+                    this.updateFailedString.current()
+                        .then(str => this.toast.danger(str));
+                    reject(error);
+                }
+            );
+        });
+    }
+
+    editSelected(providerAddressFields: IdlObject[]) {
+        // Edit each IDL thing one at a time
+        const editOneThing = (providerAddress: IdlObject) => {
+            if (!providerAddress) { return; }
+
+            this.showEditDialog(providerAddress).then(
+                () => editOneThing(providerAddressFields.shift()));
+        };
+
+        editOneThing(providerAddressFields.shift());
+    }
+
+    createNew() {
+        this.editDialog.mode = 'create';
+        const address = this.idl.create('acqpa');
+        address.provider(this.provider.id());
+        address.valid(true);
+        this.editDialog.record = address;
+        this.editDialog.recordId = null;
+        this.editDialog.open({size: this.dialogSize}).subscribe(
+            ok => {
+                this.createString.current()
+                    .then(str => this.toast.success(str));
+                this.providerRecord.refreshCurrent().then(
+                    () => this.providerAddressesGrid.reload()
+                );
+            },
+            rejection => {
+                if (!rejection.dismissed) {
+                    this.createErrString.current()
+                        .then(str => this.toast.danger(str));
+                }
+            }
+        );
+    }
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-attributes.component.html b/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-attributes.component.html
new file mode 100644 (file)
index 0000000..a728c41
--- /dev/null
@@ -0,0 +1,26 @@
+<eg-string #createString i18n-text text="New Provider Attributes Added"></eg-string>
+<eg-string #createErrString i18n-text text="Failed to Create New Provider Attributes"></eg-string>
+<eg-string #successString i18n-text text="Provider Attributes Update Succeeded"></eg-string>
+<eg-string #updateFailedString i18n-text text="Provider Attributes Update Failed"></eg-string>
+<eg-string #deleteFailedString i18n-text text="Delete of Provider Attributes failed or was not allowed"></eg-string>
+<eg-string #deleteSuccessString i18n-text text="Delete of Provider Attributes succeeded"></eg-string>
+
+<eg-grid #acqProviderAttributesGrid
+  persistKey="acq.provider.attributes"
+  idlClass="acqlipad" [dataSource]="gridSource"
+  [sortable]="true"
+  hideFields="provider"
+  [cellTextGenerator]="cellTextGenerator">
+
+  <eg-grid-toolbar-button label="New Attributes" i18n-label (onClick)="createNew()"></eg-grid-toolbar-button>
+  <eg-grid-toolbar-action label="Edit Selected" i18n-label (onClick)="editSelected($event)"></eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="Delete Selected" i18n-label (onClick)="deleteSelected($event)"></eg-grid-toolbar-action>
+
+</eg-grid>
+
+<eg-fm-record-editor #editDialog
+  idlClass="acqlipad"
+  readonlyFields="id,provider"
+  fieldOrder="id,provider,code,description,xpath,remove,ident">
+</eg-fm-record-editor>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-attributes.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-attributes.component.ts
new file mode 100644 (file)
index 0000000..c02955b
--- /dev/null
@@ -0,0 +1,190 @@
+import {Component, OnInit, AfterViewInit, OnDestroy, Input, ViewChild} from '@angular/core';
+import {empty, throwError, Observable, from, Subscription} from 'rxjs';
+import {map} from 'rxjs/operators';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {Pager} from '@eg/share/util/pager';
+import {IdlService, 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 {ProviderRecordService} from './provider-record.service';
+import {AcqProviderSearchFormComponent} from './acq-provider-search-form.component';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {StringComponent} from '@eg/share/string/string.component';
+import {ToastService} from '@eg/share/toast/toast.service';
+
+
+@Component({
+  selector: 'eg-provider-attributes',
+  templateUrl: 'provider-attributes.component.html',
+})
+export class ProviderAttributesComponent implements OnInit, AfterViewInit, OnDestroy {
+
+    @Input() providerId: any;
+    attributes: any[] = [];
+
+    gridSource: GridDataSource;
+    @ViewChild('editDialog', { static: true }) editDialog: FmRecordEditorComponent;
+    @ViewChild('acqProviderAttributesGrid', { static: true }) providerAttributesGrid: GridComponent;
+    @ViewChild('successString', { static: true }) successString: StringComponent;
+    @ViewChild('createString', { static: false }) createString: StringComponent;
+    @ViewChild('createErrString', { static: false }) createErrString: StringComponent;
+    @ViewChild('updateFailedString', { static: false }) updateFailedString: StringComponent;
+    @ViewChild('deleteFailedString', { static: true }) deleteFailedString: StringComponent;
+    @ViewChild('deleteSuccessString', { static: true }) deleteSuccessString: StringComponent;
+
+    cellTextGenerator: GridCellTextGenerator;
+    provider: IdlObject;
+
+    canCreate: boolean;
+    canDelete: boolean;
+    deleteSelected: (rows: IdlObject[]) => void;
+
+    permissions: {[name: string]: boolean};
+
+    subscription: Subscription;
+
+    // Size of create/edito dialog.  Uses large by default.
+    @Input() dialogSize: 'sm' | 'lg' = 'lg';
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private net: NetService,
+        private auth: AuthService,
+        private idl: IdlService,
+        private providerRecord: ProviderRecordService,
+        private toast: ToastService) {
+
+    }
+
+    ngOnInit() {
+        this.gridSource = this.getDataSource();
+        this.cellTextGenerator = {};
+        this.deleteSelected = (idlThings: IdlObject[]) => {
+            idlThings.forEach(idlThing => idlThing.isdeleted(true));
+            this.providerRecord.batchUpdate(idlThings).subscribe(
+                val => {
+                    console.debug('deleted: ' + val);
+                    this.deleteSuccessString.current()
+                        .then(str => this.toast.success(str));
+                },
+                err => {
+                    this.deleteFailedString.current()
+                        .then(str => this.toast.danger(str));
+                },
+                ()  => {
+                    this.providerRecord.refreshCurrent().then(
+                        () => this.providerAttributesGrid.reload()
+                    );
+                }
+            );
+        };
+        this.providerAttributesGrid.onRowActivate.subscribe(
+            (idlThing: IdlObject) => this.showEditDialog(idlThing)
+        );
+        this.subscription = this.providerRecord.providerUpdated$.subscribe(
+            id => {
+                this.providerAttributesGrid.reload();
+            }
+        );
+
+    }
+
+    ngAfterViewInit() {
+        console.log('this.providerRecord', this.providerRecord);
+    }
+
+    ngOnDestroy() {
+        this.subscription.unsubscribe();
+    }
+
+    getDataSource(): GridDataSource {
+        const gridSource = new GridDataSource();
+
+        gridSource.getRows = (pager: Pager, sort: any[]) => {
+            this.provider = this.providerRecord.current();
+            if (!this.provider) {
+                return empty();
+            }
+            let attributes = this.provider.attributes();
+
+            if (sort.length > 0) {
+                attributes = attributes.sort((a, b) => {
+                    for (let i = 0; i < sort.length; i++) {
+                        let lt = -1;
+                        const sfield = sort[i].name;
+                        if (sort[i].dir.substring(0, 1).toLowerCase() === 'd') {
+                            lt *= -1;
+                        }
+                        if (a[sfield]() < b[sfield]()) { return lt; }
+                        if (a[sfield]() > b[sfield]()) { return lt * -1; }
+                    }
+                    return 0;
+                });
+
+            }
+
+            return from(attributes.slice(pager.offset, pager.offset + pager.limit));
+        };
+        return gridSource;
+    }
+
+    showEditDialog(providerAttribute: IdlObject): Promise<any> {
+        this.editDialog.mode = 'update';
+        this.editDialog.recordId = providerAttribute['id']();
+        return new Promise((resolve, reject) => {
+            this.editDialog.open({size: this.dialogSize}).subscribe(
+                result => {
+                    this.successString.current()
+                        .then(str => this.toast.success(str));
+                    this.providerRecord.refreshCurrent().then(
+                        () => this.providerAttributesGrid.reload()
+                    );
+                    resolve(result);
+                },
+                error => {
+                    this.updateFailedString.current()
+                        .then(str => this.toast.danger(str));
+                    reject(error);
+                }
+            );
+        });
+    }
+
+    editSelected(providerAttributesFields: IdlObject[]) {
+        // Edit each IDL thing one at a time
+        const editOneThing = (providerAttributes: IdlObject) => {
+            if (!providerAttributes) { return; }
+
+            this.showEditDialog(providerAttributes).then(
+                () => editOneThing(providerAttributesFields.shift()));
+        };
+
+        editOneThing(providerAttributesFields.shift());
+    }
+
+    createNew() {
+        this.editDialog.mode = 'create';
+        const attributes = this.idl.create('acqlipad');
+        attributes.provider(this.provider.id());
+        this.editDialog.record = attributes;
+        this.editDialog.recordId = null;
+        this.editDialog.open({size: this.dialogSize}).subscribe(
+            ok => {
+                this.createString.current()
+                    .then(str => this.toast.success(str));
+                this.providerRecord.refreshCurrent().then(
+                    () => this.providerAttributesGrid.reload()
+                );
+            },
+            rejection => {
+                if (!rejection.dismissed) {
+                    this.createErrString.current()
+                        .then(str => this.toast.danger(str));
+                }
+            }
+        );
+    }
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-contact-addresses.component.html b/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-contact-addresses.component.html
new file mode 100644 (file)
index 0000000..c2ec30b
--- /dev/null
@@ -0,0 +1,39 @@
+<eg-string #createString i18n-text text="New Contact Address Added"></eg-string>
+<eg-string #createErrString i18n-text text="Failed to Create New Contact Address"></eg-string>
+<eg-string #successString i18n-text text="Contact Address Update Succeeded"></eg-string>
+<eg-string #updateFailedString i18n-text text="Contact Address Update Failed"></eg-string>
+<eg-string #deleteFailedString i18n-text text="Delete of Contact Address failed or was not allowed"></eg-string>
+<eg-string #deleteSuccessString i18n-text text="Delete of Contact Address succeeded"></eg-string>
+
+<eg-grid #acqProviderContactAddressesGrid
+  persistKey="acq.provider.contact.addresses"
+  idlClass="acqpca" [dataSource]="gridSource"
+  [sortable]="true"
+  [filterable]="true"
+  hideFields="contact,id"
+  [cellTextGenerator]="cellTextGenerator">
+    <eg-grid-toolbar-button 
+      label="New Contact Address" i18n-label (onClick)="createNew()">
+    </eg-grid-toolbar-button>
+    <eg-grid-toolbar-action label="Edit Selected" i18n-label (onClick)="editSelected($event)">
+    </eg-grid-toolbar-action>
+    <eg-grid-toolbar-action label="Delete Selected" i18n-label (onClick)="deleteSelected($event)">
+    </eg-grid-toolbar-action>
+  <eg-grid-column name="address_type"></eg-grid-column>
+  <eg-grid-column name="street1"></eg-grid-column>
+  <eg-grid-column name="street2"></eg-grid-column>
+  <eg-grid-column name="city"></eg-grid-column>
+  <eg-grid-column name="county"></eg-grid-column>
+  <eg-grid-column name="state"></eg-grid-column>
+  <eg-grid-column name="country"></eg-grid-column>
+  <eg-grid-column name="post_code"></eg-grid-column>
+  <eg-grid-column name="fax_phone"></eg-grid-column>
+  <eg-grid-column name="valid"></eg-grid-column>
+</eg-grid>
+
+<eg-fm-record-editor #editDialog
+  idlClass="acqpca"
+  readonlyFields="id,contact"
+  fieldOrder="id,valid,address_type,contact,street1,street2,city,county,state,country,post_code,fax_phone">
+</eg-fm-record-editor>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-contact-addresses.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-contact-addresses.component.ts
new file mode 100644 (file)
index 0000000..06d81e2
--- /dev/null
@@ -0,0 +1,229 @@
+import {Component, OnInit, AfterViewInit, Input, ViewChild} from '@angular/core';
+import {empty, throwError, Observable, from} from 'rxjs';
+import {map} from 'rxjs/operators';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {Pager} from '@eg/share/util/pager';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {EventService} from '@eg/core/event.service';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {GridDataSource, GridCellTextGenerator} from '@eg/share/grid/grid';
+import {ProviderRecord, ProviderRecordService} from './provider-record.service';
+import {AcqProviderSearchFormComponent} from './acq-provider-search-form.component';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {StringComponent} from '@eg/share/string/string.component';
+import {ToastService} from '@eg/share/toast/toast.service';
+
+@Component({
+  selector: 'eg-provider-contact-addresses',
+  templateUrl: 'provider-contact-addresses.component.html',
+})
+export class ProviderContactAddressesComponent implements OnInit, AfterViewInit {
+
+    addresses: any[] = [];
+
+    gridSource: GridDataSource;
+    @ViewChild('editDialog', { static: true }) editDialog: FmRecordEditorComponent;
+    @ViewChild('acqProviderContactAddressesGrid', { static: true }) providerContactAddressesGrid: GridComponent;
+    @ViewChild('successString', { static: true }) successString: StringComponent;
+    @ViewChild('createString', { static: false }) createString: StringComponent;
+    @ViewChild('createErrString', { static: false }) createErrString: StringComponent;
+    @ViewChild('updateFailedString', { static: false }) updateFailedString: StringComponent;
+    @ViewChild('deleteFailedString', { static: true }) deleteFailedString: StringComponent;
+    @ViewChild('deleteSuccessString', { static: true }) deleteSuccessString: StringComponent;
+
+    cellTextGenerator: GridCellTextGenerator;
+
+    canCreate: boolean;
+    canDelete: boolean;
+    deleteSelected: (rows: IdlObject[]) => void;
+    reloadGrid: () => void;
+
+    permissions: {[name: string]: boolean};
+
+    // Size of create/edito dialog.  Uses large by default.
+    @Input() dialogSize: 'sm' | 'lg' = 'lg';
+    @Input() contactId: any;
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private net: NetService,
+        private evt: EventService,
+        private idl: IdlService,
+        private auth: AuthService,
+        private providerRecord: ProviderRecordService,
+        private toast: ToastService,
+        private pcrud: PcrudService) {
+    }
+
+    ngOnInit() {
+        this.gridSource = this.getDataSource();
+        this.cellTextGenerator = {};
+        this.reloadGrid = () => this.providerContactAddressesGrid.reload();
+        this.deleteSelected = (idlThings: IdlObject[]) => {
+            idlThings.forEach(idlThing => idlThing.isdeleted(true));
+            this.pcrud.autoApply(idlThings).subscribe(
+                val => {
+                    console.debug('deleted: ' + val);
+                    this.deleteSuccessString.current()
+                        .then(str => this.toast.success(str));
+                },
+                err => {
+                    this.deleteFailedString.current()
+                        .then(str => this.toast.danger(str));
+                },
+                ()  => {
+                    this.providerRecord.refreshCurrent().then(
+                        () => this.providerContactAddressesGrid.reload()
+                    );
+                }
+            );
+        };
+        this.providerContactAddressesGrid.onRowActivate.subscribe(
+            (idlThing: IdlObject) => this.showEditDialog(idlThing)
+        );
+    }
+
+
+    ngAfterViewInit() {
+        console.log('this.contactId', this.contactId);
+    }
+
+    generateSearch(filters): any {
+        const query: any = new Array();
+
+        Object.keys(filters).forEach(filterField => {
+            filters[filterField].forEach(condition => {
+                query.push(condition);
+            });
+        });
+        return query;
+    }
+
+    getDataSource(): GridDataSource {
+        const gridSource = new GridDataSource();
+
+        gridSource.getRows = (pager: Pager, sort: any[]) => {
+            if (!this.contactId) {
+                return empty();
+            }
+            const cid = this.contactId;
+            const contact = this.providerRecord.current().contacts().filter( c => c.id() === cid)[0];
+            let addresses = contact.addresses();
+
+            const query = this.generateSearch(gridSource.filters);
+            if (query.length) {
+                query.unshift( { id: addresses.map(a => a.id()) } );
+
+                const opts = {};
+                opts['offset'] = pager.offset;
+                opts['limit'] = pager.limit;
+                opts['au_by_id'] = true;
+
+                if (sort.length > 0) {
+                    opts['order_by'] = [];
+                    sort.forEach(sort_clause => {
+                        opts['order_by'].push({
+                            class: 'acqpca',
+                            field: sort_clause.name,
+                            direction: sort_clause.dir
+                        });
+                    });
+                }
+
+                return this.pcrud.search('acqpca',
+                    query,
+                    opts
+                ).pipe(
+                    map(res => {
+                        if (this.evt.parse(res)) {
+                            throw throwError(res);
+                        } else {
+                            return res;
+                        }
+                    }),
+                );
+            }
+
+            if (sort.length > 0) {
+                addresses = addresses.sort((a, b) => {
+                    for (let i = 0; i < sort.length; i++) {
+                        let lt = -1;
+                        const sfield = sort[i].name;
+                        if (sort[i].dir.substring(0, 1).toLowerCase() === 'd') {
+                            lt *= -1;
+                        }
+                        if (a[sfield]() < b[sfield]()) { return lt; }
+                        if (a[sfield]() > b[sfield]()) { return lt * -1; }
+                    }
+                    return 0;
+                });
+
+            }
+
+            return from(addresses.slice(pager.offset, pager.offset + pager.limit));
+        };
+        return gridSource;
+    }
+
+    showEditDialog(providerContactAddress: IdlObject): Promise<any> {
+        this.editDialog.mode = 'update';
+        this.editDialog.recordId = providerContactAddress['id']();
+        return new Promise((resolve, reject) => {
+            this.editDialog.open({size: this.dialogSize}).subscribe(
+                result => {
+                    this.successString.current()
+                        .then(str => this.toast.success(str));
+                    this.providerRecord.refreshCurrent().then(
+                        () => this.providerContactAddressesGrid.reload()
+                    );
+                    resolve(result);
+                },
+                error => {
+                    this.updateFailedString.current()
+                        .then(str => this.toast.danger(str));
+                    reject(error);
+                }
+            );
+        });
+    }
+
+    editSelected(providerContactAddressFields: IdlObject[]) {
+        // Edit each IDL thing one at a time
+        const editOneThing = (providerContactAddress: IdlObject) => {
+            if (!providerContactAddress) { return; }
+
+            this.showEditDialog(providerContactAddress).then(
+                () => editOneThing(providerContactAddressFields.shift()));
+        };
+
+        editOneThing(providerContactAddressFields.shift());
+    }
+
+    createNew() {
+        this.editDialog.mode = 'create';
+        const address = this.idl.create('acqpca');
+        address.contact(this.contactId);
+        address.valid(true);
+        this.editDialog.record = address;
+        this.editDialog.recordId = null;
+        this.editDialog.open({size: this.dialogSize}).subscribe(
+            ok => {
+                this.createString.current()
+                    .then(str => this.toast.success(str));
+                this.providerRecord.refreshCurrent().then(
+                    () => this.providerContactAddressesGrid.reload()
+                );
+            },
+            rejection => {
+                if (!rejection.dismissed) {
+                    this.createErrString.current()
+                        .then(str => this.toast.danger(str));
+                }
+            }
+        );
+    }
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-contacts.component.html b/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-contacts.component.html
new file mode 100644 (file)
index 0000000..694ca44
--- /dev/null
@@ -0,0 +1,68 @@
+<eg-string #createString i18n-text text="New Provider Contact Added"></eg-string>
+<eg-string #createErrString i18n-text text="Failed to Create New Provider Contact"></eg-string>
+<eg-string #successString i18n-text text="Provider Contact Update Succeeded"></eg-string>
+<eg-string #updateFailedString i18n-text text="Provider Contact Update Failed"></eg-string>
+<eg-string #deleteFailedString i18n-text text="Delete of Provider Contact failed or was not allowed"></eg-string>
+<eg-string #deleteSuccessString i18n-text text="Delete of Provider Contact succeeded"></eg-string>
+<eg-string #setAsPrimarySuccessString i18n-text text="Successfully set primary contact"></eg-string>
+<eg-string #setAsPrimaryFailedtring i18n-text text="Failed to set primary contact"></eg-string>
+<eg-string #unsetAsPrimarySuccessString i18n-text text="Successfully removed primary contact"></eg-string>
+<eg-string #unsetAsPrimaryFailedtring i18n-text text="Failed to remove primary contact"></eg-string>
+
+<ng-template #emailTmpl let-contact="row">
+  <a href="mailto:{{contact.email()}}">{{contact.email()}}</a>
+</ng-template>
+
+<ng-template #phoneTmpl let-contact="row">
+  <a href="tel:{{contact.phone()}}">{{contact.phone()}}</a>
+</ng-template>
+
+<eg-confirm-dialog #confirmSetAsPrimary
+  i18n-dialogTitle i18n-dialogBody
+  dialogTitle="Confirm Setting Primary Contact"
+  dialogBody="Set {{selectedContact ? selectedContact.name() : ''}} as the primary contact for {{provider ? provider.name() : ''}}?">
+</eg-confirm-dialog>
+
+<eg-confirm-dialog #confirmUnsetAsPrimary
+  i18n-dialogTitle i18n-dialogBody
+  dialogTitle="Confirm Unsetting Primary Contact"
+  dialogBody="Unset {{selectedContact ? selectedContact.name() : ''}} as the primary contact for {{provider ? provider.name() : ''}}?">
+</eg-confirm-dialog>
+
+<eg-grid #acqProviderContactsGrid
+  persistKey="acq.provider.contacts"
+  idlClass="acqpc" [dataSource]="gridSource"
+  [sortable]="true"
+  [disableMultiSelect]="true"
+  [filterable]="true"
+  hideFields="provider"
+  [cellTextGenerator]="cellTextGenerator">
+
+  <eg-grid-toolbar-button label="New Provider Contact" i18n-label (onClick)="createNew()"></eg-grid-toolbar-button>
+  <eg-grid-toolbar-action label="Edit Selected" i18n-label (onClick)="editSelected($event)"></eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="Delete Selected" i18n-label (onClick)="deleteSelected($event)"></eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="Set as Primary Contact" i18n-label (onClick)="setAsPrimary($event)" [disableOnRows]="cannotSetPrimaryContact">
+  </eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="Unset as Primary Contact" i18n-label (onClick)="unsetAsPrimary($event)" [disableOnRows]="cannotUnsetPrimaryContact">
+  </eg-grid-toolbar-action>
+
+
+  <eg-grid-column path="email" [cellTemplate]="emailTmpl" [disableTooltip]="true"></eg-grid-column>
+  <eg-grid-column path="phone" [cellTemplate]="phoneTmpl" [disableTooltip]="true"></eg-grid-column>
+  <eg-grid-column [filterable]="false" [sortable]="false" i18n-label label="Is Primary?" path="_is_primary" datatype="bool"></eg-grid-column>
+</eg-grid>
+
+<ng-container *ngIf="selectedContact">
+  <hr><h3 i18n>Addresses for: {{selectedContact.name()}}</h3>
+  <eg-provider-contact-addresses
+    #providerContactAddresses
+    [contactId]="selectedContact.id()">
+  </eg-provider-contact-addresses>
+</ng-container>
+
+<eg-fm-record-editor #editDialog
+  idlClass="acqpc"
+  readonlyFields="id,provider"
+  fieldOrder="id,provider,name,role,email,phone">
+</eg-fm-record-editor>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-contacts.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-contacts.component.ts
new file mode 100644 (file)
index 0000000..7fd53c2
--- /dev/null
@@ -0,0 +1,328 @@
+import {Component, OnInit, AfterViewInit, OnDestroy, Input, Output, ViewChild, EventEmitter, ChangeDetectorRef} from '@angular/core';
+import {empty, throwError, Observable, from, Subscription} from 'rxjs';
+import {map} from 'rxjs/operators';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {Pager} from '@eg/share/util/pager';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {EventService} from '@eg/core/event.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 {ProviderRecordService} from './provider-record.service';
+import {ProviderContactAddressesComponent} from './provider-contact-addresses.component';
+import {AcqProviderSearchFormComponent} from './acq-provider-search-form.component';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {StringComponent} from '@eg/share/string/string.component';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+import {ToastService} from '@eg/share/toast/toast.service';
+
+
+@Component({
+  selector: 'eg-provider-contacts',
+  templateUrl: 'provider-contacts.component.html',
+})
+export class ProviderContactsComponent implements OnInit, AfterViewInit, OnDestroy {
+
+    @Input() providerId: any;
+    contacts: any[] = [];
+
+    gridSource: GridDataSource;
+    @ViewChild('editDialog', { static: true }) editDialog: FmRecordEditorComponent;
+    @ViewChild('providerContactAddresses', { static: false }) providerContactAddresses: ProviderContactAddressesComponent;
+    @ViewChild('acqProviderContactsGrid', { static: true }) providerContactsGrid: GridComponent;
+    @ViewChild('confirmSetAsPrimary', { static: true }) confirmSetAsPrimary: ConfirmDialogComponent;
+    @ViewChild('confirmUnsetAsPrimary', { static: true }) confirmUnsetAsPrimary: ConfirmDialogComponent;
+    @ViewChild('successString', { static: true }) successString: StringComponent;
+    @ViewChild('createString', { static: false }) createString: StringComponent;
+    @ViewChild('createErrString', { static: false }) createErrString: StringComponent;
+    @ViewChild('updateFailedString', { static: false }) updateFailedString: StringComponent;
+    @ViewChild('deleteFailedString', { static: true }) deleteFailedString: StringComponent;
+    @ViewChild('deleteSuccessString', { static: true }) deleteSuccessString: StringComponent;
+    @ViewChild('setAsPrimarySuccessString', { static: true }) setAsPrimarySuccessString: StringComponent;
+    @ViewChild('setAsPrimaryFailedString', { static: true }) setAsPrimaryFailedString: StringComponent;
+    @ViewChild('unsetAsPrimarySuccessString', { static: true }) unsetAsPrimarySuccessString: StringComponent;
+    @ViewChild('unsetAsPrimaryFailedString', { static: true }) unsetAsPrimaryFailedString: StringComponent;
+
+    @Output() desireSummarize: EventEmitter<number> = new EventEmitter<number>();
+
+    cellTextGenerator: GridCellTextGenerator;
+    provider: IdlObject;
+    selectedContact: IdlObject;
+
+    canCreate: boolean;
+    canDelete: boolean;
+    deleteSelected: (rows: IdlObject[]) => void;
+    cannotSetPrimaryContact: (rows: IdlObject[]) => boolean;
+    cannotUnsetPrimaryContact: (rows: IdlObject[]) => boolean;
+
+    permissions: {[name: string]: boolean};
+
+    subscription: Subscription;
+
+    // Size of create/edito dialog.  Uses large by default.
+    @Input() dialogSize: 'sm' | 'lg' = 'lg';
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private changeDetector: ChangeDetectorRef,
+        private net: NetService,
+        private pcrud: PcrudService,
+        private evt: EventService,
+        private auth: AuthService,
+        private idl: IdlService,
+        private providerRecord: ProviderRecordService,
+        private toast: ToastService) {
+    }
+
+    ngOnInit() {
+        this.gridSource = this.getDataSource();
+        this.cellTextGenerator = {
+            email: row => row.email(),
+            phone: row => row.phone(),
+        };
+        this.cannotSetPrimaryContact = (rows: IdlObject[]) => (rows.length !== 1 || (rows.length === 1 && rows[0]._is_primary));
+        this.cannotUnsetPrimaryContact = (rows: IdlObject[]) => (rows.length !== 1 || (rows.length === 1 && !rows[0]._is_primary));
+        this.deleteSelected = (idlThings: IdlObject[]) => {
+            idlThings.forEach(idlThing => idlThing.isdeleted(true));
+            this.providerRecord.batchUpdate(idlThings).subscribe(
+                val => {
+                    console.debug('deleted: ' + val);
+                    this.deleteSuccessString.current()
+                        .then(str => this.toast.success(str));
+                    this.desireSummarize.emit(this.provider.id());
+                },
+                err => {
+                    this.deleteFailedString.current()
+                        .then(str => this.toast.danger(str));
+                },
+                ()  => {
+                    this.providerRecord.refreshCurrent().then(
+                        () => this.providerContactsGrid.reload()
+                    );
+                }
+            );
+        };
+        this.providerContactsGrid.onRowActivate.subscribe(
+            (idlThing: IdlObject) => this.showEditDialog(idlThing)
+        );
+        this.subscription = this.providerRecord.providerUpdated$.subscribe(
+            id => {
+                this.providerContactsGrid.reload();
+            }
+        );
+    }
+
+    ngAfterViewInit() {
+        console.log('this.providerRecord', this.providerRecord);
+        console.log('this.providerContactAddresses', this.providerContactAddresses);
+        this.providerContactsGrid.onRowClick.subscribe(
+            (idlThing: IdlObject) => {
+                this.selectedContact = idlThing;
+                console.debug('selected contact', this.selectedContact);
+                // ensure that the contact address grid is instantiated
+                this.changeDetector.detectChanges();
+                this.providerContactAddresses.reloadGrid();
+            }
+        );
+    }
+
+    ngOnDestroy() {
+        this.subscription.unsubscribe();
+    }
+
+    generateSearch(filters): any {
+        const query: any = new Array();
+
+        Object.keys(filters).forEach(filterField => {
+            filters[filterField].forEach(condition => {
+                query.push(condition);
+            });
+        });
+        return query;
+    }
+
+    getDataSource(): GridDataSource {
+        const gridSource = new GridDataSource();
+
+        gridSource.getRows = (pager: Pager, sort: any[]) => {
+            this.provider = this.providerRecord.current();
+            if (!this.provider) {
+                return empty();
+            }
+            let contacts = this.provider.contacts();
+
+            const query = this.generateSearch(gridSource.filters);
+            if (query.length) {
+                query.unshift( { id: contacts.map(a => a.id()) } );
+
+                const opts = {};
+                opts['offset'] = pager.offset;
+                opts['limit'] = pager.limit;
+                opts['au_by_id'] = true;
+
+                if (sort.length > 0) {
+                    opts['order_by'] = [];
+                    sort.forEach(sort_clause => {
+                        opts['order_by'].push({
+                            class: 'acqpc',
+                            field: sort_clause.name,
+                            direction: sort_clause.dir
+                        });
+                    });
+                }
+
+                return this.pcrud.search('acqpc',
+                    query,
+                    opts
+                ).pipe(
+                    map(res => {
+                        if (this.evt.parse(res)) {
+                            throw throwError(res);
+                        } else {
+                            return res;
+                        }
+                    }),
+                );
+            }
+
+            if (sort.length > 0) {
+                contacts = contacts.sort((a, b) => {
+                    for (let i = 0; i < sort.length; i++) {
+                        let lt = -1;
+                        const sfield = sort[i].name;
+                        if (sort[i].dir.substring(0, 1).toLowerCase() === 'd') {
+                            lt *= -1;
+                        }
+                        if (a[sfield]() < b[sfield]()) { return lt; }
+                        if (a[sfield]() > b[sfield]()) { return lt * -1; }
+                    }
+                    return 0;
+                });
+
+            }
+
+            return from(contacts.slice(pager.offset, pager.offset + pager.limit));
+        };
+        return gridSource;
+    }
+
+    showEditDialog(providerContact: IdlObject): Promise<any> {
+        this.editDialog.mode = 'update';
+        this.editDialog.recordId = providerContact['id']();
+        return new Promise((resolve, reject) => {
+            this.editDialog.open({size: this.dialogSize}).subscribe(
+                result => {
+                    this.successString.current()
+                        .then(str => this.toast.success(str));
+                    this.providerRecord.refreshCurrent().then(
+                        () => this.providerContactsGrid.reload()
+                    );
+                    this.desireSummarize.emit(this.provider.id());
+                    resolve(result);
+                },
+                error => {
+                    this.updateFailedString.current()
+                        .then(str => this.toast.danger(str));
+                    reject(error);
+                }
+            );
+        });
+    }
+
+    editSelected(providerContactFields: IdlObject[]) {
+        // Edit each IDL thing one at a time
+        const editOneThing = (providerContact: IdlObject) => {
+            if (!providerContact) { return; }
+
+            this.showEditDialog(providerContact).then(
+                () => editOneThing(providerContactFields.shift()));
+        };
+
+        editOneThing(providerContactFields.shift());
+    }
+
+    createNew() {
+        this.editDialog.mode = 'create';
+        const contact = this.idl.create('acqpc');
+        contact.provider(this.provider.id());
+        this.editDialog.record = contact;
+        this.editDialog.recordId = null;
+        this.editDialog.open({size: this.dialogSize}).subscribe(
+            ok => {
+                this.createString.current()
+                    .then(str => this.toast.success(str));
+                this.providerRecord.refreshCurrent().then(
+                    () => this.providerContactsGrid.reload()
+                );
+                this.desireSummarize.emit(this.provider.id());
+            },
+            rejection => {
+                if (!rejection.dismissed) {
+                    this.createErrString.current()
+                        .then(str => this.toast.danger(str));
+                }
+            }
+        );
+    }
+
+    setAsPrimary(providerContacts: IdlObject[]) {
+        this.selectedContact = providerContacts[0];
+        this.confirmSetAsPrimary.open().subscribe(confirmed => {
+            if (!confirmed) { return; }
+            this.providerRecord.refreshCurrent().then(() => {
+                this.provider.primary_contact(providerContacts[0].id());
+                this.provider.ischanged(true);
+                this.providerRecord.batchUpdate(this.provider).subscribe(
+                    val => {
+                        this.setAsPrimarySuccessString.current()
+                            .then(str => this.toast.success(str));
+                    },
+                    err => {
+                        this.setAsPrimaryFailedString.current()
+                            .then(str => this.toast.danger(str));
+                    },
+                    () => {
+                        this.providerRecord.refreshCurrent().then(
+                            () => {
+                                this.providerContactsGrid.reload();
+                                this.desireSummarize.emit(this.provider.id());
+                            }
+                        );
+                    }
+                );
+            });
+        });
+    }
+
+    unsetAsPrimary(providerContacts: IdlObject[]) {
+        this.selectedContact = providerContacts[0];
+        this.confirmUnsetAsPrimary.open().subscribe(confirmed => {
+            if (!confirmed) { return; }
+            this.providerRecord.refreshCurrent().then(() => {
+                this.provider.primary_contact(null);
+                this.provider.ischanged(true);
+                this.providerRecord.batchUpdate(this.provider).subscribe(
+                    val => {
+                        this.unsetAsPrimarySuccessString.current()
+                            .then(str => this.toast.success(str));
+                    },
+                    err => {
+                        this.unsetAsPrimaryFailedString.current()
+                            .then(str => this.toast.danger(str));
+                    },
+                    () => {
+                        this.providerRecord.refreshCurrent().then(
+                            () => {
+                                this.providerContactsGrid.reload();
+                                this.desireSummarize.emit(this.provider.id());
+                            }
+                        );
+                    }
+                );
+            });
+        });
+    }
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-details.component.html b/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-details.component.html
new file mode 100644 (file)
index 0000000..189e57c
--- /dev/null
@@ -0,0 +1,17 @@
+<eg-string #successString i18n-text text="Provider Update Succeeded"></eg-string>
+<eg-string #updateFailedString i18n-text text="Provider Update Failed"></eg-string>
+<eg-string #deleteFailedString i18n-text text="Delete of Provider failed or was not allowed"></eg-string>
+<eg-string #deleteSuccessString i18n-text text="Delete of Provider succeeded"></eg-string>
+
+<eg-fm-record-editor #editDialog
+  idlClass="acqpro"
+  [mode]="permittedMode()"
+  [hideBanner]="true" displayMode="inline"
+  [record]="provider"
+  (recordSaved)="updateProvider($event)"
+  readonlyFields="id"
+  hiddenFields="holding_tag,primary_contact"
+  [fieldOptions]="{currency_type:{preloadLinkedValues:true},edi_default:{preloadLinkedValues:true},default_claim_policy:{preloadLinkedValues:true}}"
+  fieldOrder="active,name,code,id,currency_type,default_claim_policy,default_copy_count,edi_default,owner,phone,fax_phone,email,url,san,prepayment_required"
+>
+</eg-fm-record-editor>
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-details.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-details.component.ts
new file mode 100644 (file)
index 0000000..4e53726
--- /dev/null
@@ -0,0 +1,82 @@
+import {Component, OnInit, Output, EventEmitter, ViewChild} from '@angular/core';
+import {empty, throwError, Observable, from} from 'rxjs';
+import {map} from 'rxjs/operators';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {ProviderRecord, ProviderRecordService} from './provider-record.service';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {StringComponent} from '@eg/share/string/string.component';
+import {ToastService} from '@eg/share/toast/toast.service';
+
+@Component({
+  selector: 'eg-provider-details',
+  templateUrl: 'provider-details.component.html',
+})
+export class ProviderDetailsComponent implements OnInit {
+
+    @ViewChild('successString', { static: true }) successString: StringComponent;
+    @ViewChild('updateFailedString', { static: false }) updateFailedString: StringComponent;
+    @ViewChild('deleteFailedString', { static: true }) deleteFailedString: StringComponent;
+    @ViewChild('deleteSuccessString', { static: true }) deleteSuccessString: StringComponent;
+    @ViewChild('editDialog', { static: false}) editDialog: FmRecordEditorComponent;
+
+    provider: IdlObject;
+
+    permissions: {[name: string]: boolean};
+
+    @Output() desireSummarize: EventEmitter<number> = new EventEmitter<number>();
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private net: NetService,
+        private idl: IdlService,
+        private auth: AuthService,
+        private providerRecord: ProviderRecordService,
+        private toast: ToastService) {
+    }
+
+    ngOnInit() {
+        this.refresh();
+    }
+
+    _deflesh() {
+        if (!this.provider) {
+            return;
+        }
+        // unflesh the currency type and edi_default fields so that the
+        // record editor can display them
+        // TODO: make fm-editor be able to handle fleshed linked fields
+        if (this.provider.currency_type()) {
+            this.provider.currency_type(this.provider.currency_type().code());
+        }
+        if (this.provider.edi_default() && typeof this.provider.edi_default() !== 'number') {
+            this.provider.edi_default(this.provider.edi_default().id());
+        }
+    }
+
+    updateProvider(providerId: any) {
+        this.desireSummarize.emit(this.provider.id());
+    }
+
+    refresh() {
+        this.provider = this.idl.clone(this.providerRecord.current());
+        this._deflesh();
+    }
+
+    permittedMode(): string {
+        // TODO - looks like fm-editor may have (via its modePerms) incompletely-implemented
+        //        work to vary the mode depending on whether the user has permission
+        //        to update a record, which would make this moot.
+        if (!this.providerRecord.currentProviderRecord()) {
+            return 'view';
+        }
+        return this.providerRecord.currentProviderRecord().canAdmin ? 'update' : 'view';
+    }
+
+    isDirty(): boolean {
+        return (this.editDialog) ? this.editDialog.isDirty() : false;
+    }
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-edi-accounts.component.html b/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-edi-accounts.component.html
new file mode 100644 (file)
index 0000000..af7bb1d
--- /dev/null
@@ -0,0 +1,74 @@
+<eg-string #createString i18n-text text="New EDI Account Added"></eg-string>
+<eg-string #createErrString i18n-text text="Failed to Create New EDI Account"></eg-string>
+<eg-string #successString i18n-text text="EDI Account Update Succeeded"></eg-string>
+<eg-string #updateFailedString i18n-text text="EDI Account Update Failed"></eg-string>
+<eg-string #deleteFailedString i18n-text text="Delete of EDI Account failed or was not allowed"></eg-string>
+<eg-string #deleteSuccessString i18n-text text="Delete of EDI Account succeeded"></eg-string>
+<eg-string #setAsDefaultSuccessString i18n-text text="Successfully set EDI default account"></eg-string>
+<eg-string #setAsDefaultFailedtring i18n-text text="Failed to set EDI default account"></eg-string>
+
+<eg-confirm-dialog #confirmSetAsDefault
+  i18n-dialogTitle i18n-dialogBody
+  dialogTitle="Confirm Setting Default EDI Account"
+  dialogBody="Set {{selected ? selected.label() : ''}} as the default EDI account for {{provider ? provider.name() : ''}}?">
+</eg-confirm-dialog>
+
+<eg-grid #acqProviderEdiAccountsGrid
+  persistKey="acq.provider.edi_accounts"
+  idlClass="acqedi" [dataSource]="gridSource"
+  [sortable]="true"
+  hideFields="provider"
+  [cellTextGenerator]="cellTextGenerator">
+  <eg-grid-toolbar-button 
+    label="New EDI Account" i18n-label (onClick)="createNew()">
+  </eg-grid-toolbar-button>
+  <eg-grid-toolbar-button
+    label="View EDI Messages" i18n-label (onClick)="displayEdiMessages($event)" [disabled]="acqProviderEdiAccountsGrid.context.getSelectedRows().length !== 1">
+  </eg-grid-toolbar-button>
+  <eg-grid-toolbar-action label="Edit Selected" i18n-label (onClick)="editSelected($event)">
+  </eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="Delete Selected" i18n-label (onClick)="deleteSelected($event)">
+  </eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="Set as Default" i18n-label (onClick)="setAsDefault($event)" [disableOnRows]="notOneSelectedRow">
+  </eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="View EDI Messages" i18n-label (onClick)="displayEdiMessages($event)" [disableOnRows]="notOneSelectedRow">
+  </eg-grid-toolbar-action>
+  <eg-grid-column [filterable]="false" [sortable]="false" i18n-label label="Is Default?" path="_is_default" datatype="bool"></eg-grid-column>
+</eg-grid>
+
+<ng-container *ngIf="viewEdiMessages">
+  <h2 i18n>EDI messages for account {{selectedEdiAccountLabel}}</h2>
+  <eg-grid #acqProviderEdiMessagesGrid
+    persistKey="acq.provider.edi_messages"
+    idlClass="acqedim" [dataSource]="ediMessagesSource"
+    [sortable]="true"
+    [filterable]="true">
+  </eg-grid>
+</ng-container>
+
+<eg-string #hostHelpStr text="EDI FTP or SCP server, including protocol. For example, ftp://ftp.example.org." i18n-text></eg-string>
+<eg-string #usernameHelpStr text="Username supplied by provider."></eg-string>
+<eg-string #passwordHelpStr text="Password supplied by provider."></eg-string>
+<eg-string #pathHelpStr text="Directory on the provider's server where Evergreen should deposit order files."></eg-string>
+<eg-string #indirHelpStr text="Directory on the provider's server where Evergreen should retrieve order responses and invoices."></eg-string>
+<eg-string #vendacctHelpStr text="Supplied by provider."></eg-string>
+<eg-string #vendcodeHelpStr text="Supplied by provider."></eg-string>
+<eg-string #accountHelpStr text="Supplied by provider."></eg-string>
+
+<eg-fm-record-editor #editDialog
+  idlClass="acqedi"
+  readonlyFields="id,provider"
+  hiddenFields="provider,last_activity"
+  [fieldOptions]="{
+                    host: {helpText: hostHelpStr},
+                    username: {helpText: usernameHelpStr},
+                    password: {helpText: passwordHelpStr},
+                    path: {helpText: pathHelpStr},
+                    in_dir: {helpText: indirHelpStr},
+                    vendacct: {helpText: vendacctHelpStr},
+                    vendcode: {helpText: vendcodeHelpStr},
+                    account: {helpText: accountHelpStr}
+                  }"
+  fieldOrder="id,label,host,username,password,account,owner,path,in_dir,vendacct,vendcode,attr_set,use_attrs">
+</eg-fm-record-editor>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-edi-accounts.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-edi-accounts.component.ts
new file mode 100644 (file)
index 0000000..9ecf4f2
--- /dev/null
@@ -0,0 +1,281 @@
+import {Component, OnInit, AfterViewInit, OnDestroy, Input, Output, EventEmitter, ViewChild, ChangeDetectorRef} from '@angular/core';
+import {empty, throwError, Observable, from, Subscription} from 'rxjs';
+import {map} from 'rxjs/operators';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {Pager} from '@eg/share/util/pager';
+import {IdlService, 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 {ProviderRecord, ProviderRecordService} from './provider-record.service';
+import {AcqProviderSearchFormComponent} from './acq-provider-search-form.component';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {StringComponent} from '@eg/share/string/string.component';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+import {PcrudService} from '@eg/core/pcrud.service';
+
+@Component({
+  selector: 'eg-provider-edi-accounts',
+  templateUrl: 'provider-edi-accounts.component.html',
+})
+export class ProviderEdiAccountsComponent implements OnInit, AfterViewInit, OnDestroy {
+
+    edi_accounts: any[] = [];
+
+    gridSource: GridDataSource;
+    ediMessagesSource: GridDataSource;
+    @ViewChild('editDialog', { static: true }) editDialog: FmRecordEditorComponent;
+    @ViewChild('acqProviderEdiAccountsGrid', { static: true }) providerEdiAccountsGrid: GridComponent;
+    @ViewChild('acqProviderEdiMessagesGrid', { static: false }) providerEdiMessagesGrid: GridComponent;
+    @ViewChild('confirmSetAsDefault', { static: true }) confirmSetAsDefault: ConfirmDialogComponent;
+    @ViewChild('successString', { static: true }) successString: StringComponent;
+    @ViewChild('createString', { static: false }) createString: StringComponent;
+    @ViewChild('createErrString', { static: false }) createErrString: StringComponent;
+    @ViewChild('updateFailedString', { static: false }) updateFailedString: StringComponent;
+    @ViewChild('deleteFailedString', { static: true }) deleteFailedString: StringComponent;
+    @ViewChild('deleteSuccessString', { static: true }) deleteSuccessString: StringComponent;
+    @ViewChild('setAsDefaultSuccessString', { static: true }) setAsDefaultSuccessString: StringComponent;
+    @ViewChild('setAsDefaultFailedString', { static: true }) setAsDefaultFailedString: StringComponent;
+
+    cellTextGenerator: GridCellTextGenerator;
+    provider: IdlObject;
+    selected: IdlObject;
+
+    canCreate: boolean;
+    canDelete: boolean;
+    notOneSelectedRow: (rows: IdlObject[]) => boolean;
+    deleteSelected: (rows: IdlObject[]) => void;
+
+    viewEdiMessages: boolean;
+    selectedEdiAccountId: number;
+    selectedEdiAccountLabel = '';
+
+    permissions: {[name: string]: boolean};
+
+    subscription: Subscription;
+
+    // Size of create/edito dialog.  Uses large by default.
+    @Input() dialogSize: 'sm' | 'lg' = 'lg';
+    @Output() desireSummarize: EventEmitter<number> = new EventEmitter<number>();
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private changeDetector: ChangeDetectorRef,
+        private net: NetService,
+        private idl: IdlService,
+        private auth: AuthService,
+        private pcrud: PcrudService,
+        private providerRecord: ProviderRecordService,
+        private toast: ToastService) {
+    }
+
+    ngOnInit() {
+        this.gridSource = this.getDataSource();
+        this.ediMessagesSource = this.getEdiMessagesSource();
+        this.viewEdiMessages = false;
+        this.selectedEdiAccountId = null;
+        this.cellTextGenerator = {};
+        this.notOneSelectedRow = (rows: IdlObject[]) => (rows.length !== 1);
+        this.deleteSelected = (idlThings: IdlObject[]) => {
+            idlThings.forEach(idlThing => idlThing.isdeleted(true));
+            this.providerRecord.batchUpdate(idlThings).subscribe(
+                val => {
+                    console.debug('deleted: ' + val);
+                    this.deleteSuccessString.current()
+                        .then(str => this.toast.success(str));
+                },
+                err => {
+                    this.deleteFailedString.current()
+                        .then(str => this.toast.danger(str));
+                },
+                ()  => {
+                    this.providerRecord.refreshCurrent().then(
+                        () => {
+                            this.providerEdiAccountsGrid.reload();
+                        }
+                    );
+                }
+            );
+        };
+        this.providerEdiAccountsGrid.onRowActivate.subscribe(
+            (idlThing: IdlObject) => this.showEditDialog(idlThing)
+        );
+        this.subscription = this.providerRecord.providerUpdated$.subscribe(
+            id => {
+                this.providerEdiAccountsGrid.reload();
+            }
+        );
+    }
+
+    ngAfterViewInit() {
+        console.log('this.providerRecord', this.providerRecord);
+    }
+
+    ngOnDestroy() {
+        this.subscription.unsubscribe();
+    }
+
+    getDataSource(): GridDataSource {
+        const gridSource = new GridDataSource();
+
+        gridSource.getRows = (pager: Pager, sort: any[]) => {
+            this.provider = this.providerRecord.current();
+            if (!this.provider) {
+                return empty();
+            }
+            let edi_accounts = this.provider.edi_accounts();
+
+            if (sort.length > 0) {
+                edi_accounts = edi_accounts.sort((a, b) => {
+                    for (let i = 0; i < sort.length; i++) {
+                        let lt = -1;
+                        const sfield = sort[i].name;
+                        if (sort[i].dir.substring(0, 1).toLowerCase() === 'd') {
+                            lt *= -1;
+                        }
+                        if (a[sfield]() < b[sfield]()) { return lt; }
+                        if (a[sfield]() > b[sfield]()) { return lt * -1; }
+                    }
+                    return 0;
+                });
+
+            }
+
+            return from(edi_accounts.slice(pager.offset, pager.offset + pager.limit));
+        };
+        return gridSource;
+    }
+
+    getEdiMessagesSource(): GridDataSource {
+        const gridSource = new GridDataSource();
+        gridSource.getRows = (pager: Pager, sort: any[]) => {
+            const orderBy: any = {acqedim: 'create_time desc'};
+            if (sort.length) {
+                orderBy.acqedim = sort[0].name + ' ' + sort[0].dir;
+            }
+
+            // base query to grab everything
+            const base: Object = {
+                account: this.selectedEdiAccountId
+            };
+            const query: any = new Array();
+            query.push(base);
+
+            // and add any filters
+            Object.keys(gridSource.filters).forEach(key => {
+                Object.keys(gridSource.filters[key]).forEach(key2 => {
+                    query.push(gridSource.filters[key][key2]);
+                });
+            });
+            return this.pcrud.search('acqedim',
+                query, {
+                flesh: 3,
+                flesh_fields: {acqedim: ['account', 'purchase_order']},
+                offset: pager.offset,
+                limit: pager.limit,
+                order_by: orderBy
+            });
+        };
+        return gridSource;
+    }
+
+    showEditDialog(providerEdiAccount: IdlObject): Promise<any> {
+        this.editDialog.mode = 'update';
+        this.editDialog.recordId = providerEdiAccount['id']();
+        return new Promise((resolve, reject) => {
+            this.editDialog.open({size: this.dialogSize}).subscribe(
+                result => {
+                    this.successString.current()
+                        .then(str => this.toast.success(str));
+                    this.providerRecord.refreshCurrent().then(
+                        () => this.providerEdiAccountsGrid.reload()
+                    );
+                    resolve(result);
+                },
+                error => {
+                    this.updateFailedString.current()
+                        .then(str => this.toast.danger(str));
+                    reject(error);
+                }
+            );
+        });
+    }
+
+    editSelected(providerEdiAccountFields: IdlObject[]) {
+        // Edit each IDL thing one at a time
+        const editOneThing = (providerEdiAccount: IdlObject) => {
+            if (!providerEdiAccount) { return; }
+
+            this.showEditDialog(providerEdiAccount).then(
+                () => editOneThing(providerEdiAccountFields.shift()));
+        };
+
+        editOneThing(providerEdiAccountFields.shift());
+    }
+
+    setAsDefault(providerEdiAccountFields: IdlObject[]) {
+        this.selected = providerEdiAccountFields[0];
+        this.confirmSetAsDefault.open().subscribe(confirmed => {
+            if (!confirmed) { return; }
+            this.providerRecord.refreshCurrent().then(() => {
+                this.provider.edi_default(providerEdiAccountFields[0].id());
+                this.provider.ischanged(true);
+                this.providerRecord.batchUpdate(this.provider).subscribe(
+                    val => {
+                        this.setAsDefaultSuccessString.current()
+                            .then(str => this.toast.success(str));
+                    },
+                    err => {
+                        this.setAsDefaultFailedString.current()
+                            .then(str => this.toast.danger(str));
+                    },
+                    () => {
+                        this.providerRecord.refreshCurrent().then(
+                            () => {
+                                this.providerEdiAccountsGrid.reload();
+                                this.desireSummarize.emit(this.provider.id());
+                            }
+                        );
+                    }
+                );
+            });
+        });
+    }
+
+    displayEdiMessages(providerEdiAccountFields: IdlObject[]) {
+        this.selectedEdiAccountId = providerEdiAccountFields[0].id();
+        this.selectedEdiAccountLabel = providerEdiAccountFields[0].label();
+        this.viewEdiMessages = true;
+        this.changeDetector.detectChanges();
+        this.providerEdiMessagesGrid.reload();
+    }
+
+    createNew() {
+        this.editDialog.mode = 'create';
+        const edi_account = this.idl.create('acqedi');
+        edi_account.provider(this.provider.id());
+        edi_account.owner(this.auth.user().ws_ou());
+        edi_account.use_attrs(true);
+        this.editDialog.record = edi_account;
+        this.editDialog.recordId = null;
+        this.editDialog.open({size: this.dialogSize}).subscribe(
+            ok => {
+                this.createString.current()
+                    .then(str => this.toast.success(str));
+                this.providerRecord.refreshCurrent().then(
+                    () => this.providerEdiAccountsGrid.reload()
+                );
+            },
+            rejection => {
+                if (!rejection.dismissed) {
+                    this.createErrString.current()
+                        .then(str => this.toast.danger(str));
+                }
+            }
+        );
+    }
+
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-holdings.component.html b/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-holdings.component.html
new file mode 100644 (file)
index 0000000..897ddf8
--- /dev/null
@@ -0,0 +1,83 @@
+<eg-string #createString i18n-text text="New Provider Holdings Subfield Added"></eg-string>
+<eg-string #createErrString i18n-text text="Failed to Create New Provider Holdings Subfield"></eg-string>
+<eg-string #successString i18n-text text="Provider Holdings Subfield Update Succeeded"></eg-string>
+<eg-string #updateFailedString i18n-text text="Provider Holdings Subfield Update Failed"></eg-string>
+<eg-string #deleteFailedString i18n-text text="Delete of Provider Holdings Subfield failed or was not allowed"></eg-string>
+<eg-string #deleteSuccessString i18n-text text="Delete of Provider Holdings Subfield succeeded"></eg-string>
+<eg-string #successTagString i18n-text text="Provider Holdings Tag Update Succeeded"></eg-string>
+<eg-string #updateFailedTagString i18n-text text="Provider Holdings Subfield Update Failed"></eg-string>
+
+<ng-template #nameCellTemplate let-row="row">
+  <ng-container [ngSwitch]="row.name()">
+    <ng-container *ngSwitchCase="'barcode'" i18n>Barcode</ng-container>
+    <ng-container *ngSwitchCase="'call_number'" i18n>Call Number</ng-container>
+    <ng-container *ngSwitchCase="'circ_modifier'" i18n>Circulation Modifier</ng-container>
+    <ng-container *ngSwitchCase="'collection_code'" i18n>Collection Code</ng-container>
+    <ng-container *ngSwitchCase="'estimated_price'" i18n>Estimated Price</ng-container>
+    <ng-container *ngSwitchCase="'fund_code'" i18n>Fund Code</ng-container>
+    <ng-container *ngSwitchCase="'note'" i18n>Note</ng-container>
+    <ng-container *ngSwitchCase="'owning_lib'" i18n>Owning Library</ng-container>
+    <ng-container *ngSwitchCase="'quantity'" i18n>Quantity</ng-container>
+    <ng-container *ngSwitchCase="'copy_location'" i18n>Shelving Location</ng-container>
+    <ng-container *ngSwitchDefault i18n>{{row.name()}}</ng-container>
+  </ng-container>
+</ng-template>
+
+<form *ngIf="provider" #holdingTagForm="ngForm">
+  <div class="form-group row">
+    <label for="holdings-tag" class="col-auto col-form-label" i18n>Holdings Tag</label>
+    <div class="col-auto">
+      <input id="holdings-tag" type="text" [(ngModel)]="provider._holding_tag" name="holding_tag" class="form-control" />
+    </div>
+    <div class="col-auto">
+      <button type="submit" class="btn btn-info"
+        (click)="updateProvider($event) && holdingTagForm.markAsPristine()" 
+        [disabled]="!holdingTagForm.dirty || (provider && provider._holding_tag == provider.holding_tag())" i18n>Save</button>
+    </div>
+  </div>
+</form>
+
+<eg-grid #acqProviderHoldingsGrid
+  persistKey="acq.provider.holdings"
+  idlClass="acqphsm" [dataSource]="gridSource"
+  [sortable]="true"
+  hideFields="provider"
+  [cellTextGenerator]="cellTextGenerator">
+
+  <eg-grid-toolbar-button label="New Holdings Subfield" i18n-label (onClick)="createNew()"
+    [disabled]="!(provider && provider.holding_tag())"></eg-grid-toolbar-button>
+  <eg-grid-toolbar-action label="Edit Selected" i18n-label (onClick)="editSelected($event)"></eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="Delete Selected" i18n-label (onClick)="deleteSelected($event)"></eg-grid-toolbar-action>
+
+  <eg-grid-column path="id"></eg-grid-column>
+  <eg-grid-column path="name" [cellTemplate]="nameCellTemplate"></eg-grid-column>
+  <eg-grid-column path="subfield"></eg-grid-column>
+
+</eg-grid>
+
+<ng-template #nameTemplate let-field="field" let-record="record">
+  <eg-combobox
+    [startId]="record[field.name]()"
+    [required]="field.isRequired()"
+    (onChange)="record[field.name]($event.id)"
+    [allowFreeText]="false">
+    <eg-combobox-entry entryId="barcode" entryLabel="Barcode" i18n-entryLabel></eg-combobox-entry>
+    <eg-combobox-entry entryId="call_number" entryLabel="Call Number" i18n-entryLabel></eg-combobox-entry>
+    <eg-combobox-entry entryId="circ_modifier" entryLabel="Circulation Modifier" i18n-entryLabel></eg-combobox-entry>
+    <eg-combobox-entry entryId="collection_code" entryLabel="Collection Code" i18n-entryLabel></eg-combobox-entry>
+    <eg-combobox-entry entryId="estimated_price" entryLabel="Estimated Price" i18n-entryLabel></eg-combobox-entry>
+    <eg-combobox-entry entryId="fund_code" entryLabel="Fund Code" i18n-entryLabel></eg-combobox-entry>
+    <eg-combobox-entry entryId="note" entryLabel="Note" i18n-entryLabel></eg-combobox-entry>
+    <eg-combobox-entry entryId="owning_lib" entryLabel="Owning Library" i18n-entryLabel></eg-combobox-entry>
+    <eg-combobox-entry entryId="quantity" entryLabel="Quantity" i18n-entryLabel></eg-combobox-entry>
+    <eg-combobox-entry entryId="copy_location" entryLabel="Shelving Location" i18n-entryLabel></eg-combobox-entry>
+  </eg-combobox>
+</ng-template>
+
+<eg-fm-record-editor #editDialog
+  idlClass="acqphsm"
+  readonlyFields="id,provider"
+  [fieldOptions]="{name:{customTemplate:{template:nameTemplate}}}"
+  fieldOrder="id,provider,name,subfield">
+</eg-fm-record-editor>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-holdings.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-holdings.component.ts
new file mode 100644 (file)
index 0000000..2c1c2b2
--- /dev/null
@@ -0,0 +1,225 @@
+import {Component, OnInit, AfterViewInit, OnDestroy, Input, ViewChild} from '@angular/core';
+import {NgForm} from '@angular/forms';
+import {empty, throwError, Observable, from, Subscription} from 'rxjs';
+import {map} from 'rxjs/operators';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {Pager} from '@eg/share/util/pager';
+import {IdlService, 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 {ProviderRecordService} from './provider-record.service';
+import {AcqProviderSearchFormComponent} from './acq-provider-search-form.component';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {StringComponent} from '@eg/share/string/string.component';
+import {ToastService} from '@eg/share/toast/toast.service';
+
+
+@Component({
+  selector: 'eg-provider-holdings',
+  templateUrl: 'provider-holdings.component.html',
+})
+export class ProviderHoldingsComponent implements OnInit, AfterViewInit, OnDestroy {
+
+    @Input() providerId: any;
+    holdings: any[] = [];
+
+    gridSource: GridDataSource;
+    @ViewChild('editDialog', { static: true }) editDialog: FmRecordEditorComponent;
+    @ViewChild('acqProviderHoldingsGrid', { static: true }) providerHoldingsGrid: GridComponent;
+    @ViewChild('successString', { static: true }) successString: StringComponent;
+    @ViewChild('createString', { static: false }) createString: StringComponent;
+    @ViewChild('createErrString', { static: false }) createErrString: StringComponent;
+    @ViewChild('updateFailedString', { static: false }) updateFailedString: StringComponent;
+    @ViewChild('deleteFailedString', { static: true }) deleteFailedString: StringComponent;
+    @ViewChild('deleteSuccessString', { static: true }) deleteSuccessString: StringComponent;
+    @ViewChild('successTagString', { static: true }) successTagString: StringComponent;
+    @ViewChild('updateFailedTagString', { static: false }) updateFailedTagString: StringComponent;
+    @ViewChild('holdingTagForm', { static: false}) holdingTagForm: NgForm;
+
+    cellTextGenerator: GridCellTextGenerator;
+    provider: IdlObject;
+
+    canCreate: boolean;
+    canDelete: boolean;
+    deleteSelected: (rows: IdlObject[]) => void;
+
+    permissions: {[name: string]: boolean};
+
+    subscription: Subscription;
+
+    // Size of create/edito dialog.  Uses large by default.
+    @Input() dialogSize: 'sm' | 'lg' = 'lg';
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private net: NetService,
+        private auth: AuthService,
+        private idl: IdlService,
+        private providerRecord: ProviderRecordService,
+        private toast: ToastService) {
+
+    }
+
+    ngOnInit() {
+        this.gridSource = this.getDataSource();
+        this.cellTextGenerator = {
+            name: row => row.name(),
+        };
+        this.deleteSelected = (idlThings: IdlObject[]) => {
+            idlThings.forEach(idlThing => idlThing.isdeleted(true));
+            this.providerRecord.batchUpdate(idlThings).subscribe(
+                val => {
+                    console.debug('deleted: ' + val);
+                    this.deleteSuccessString.current()
+                        .then(str => this.toast.success(str));
+                },
+                err => {
+                    this.deleteFailedString.current()
+                        .then(str => this.toast.danger(str));
+                },
+                ()  => {
+                    this.providerRecord.refreshCurrent().then(
+                        () => this.providerHoldingsGrid.reload()
+                    );
+                }
+            );
+        };
+        this.providerHoldingsGrid.onRowActivate.subscribe(
+            (idlThing: IdlObject) => this.showEditDialog(idlThing)
+        );
+        this.subscription = this.providerRecord.providerUpdated$.subscribe(
+            id => {
+                this.providerHoldingsGrid.reload();
+            }
+        );
+    }
+
+    ngAfterViewInit() {
+        if (this.providerRecord.current()) {
+            // sometimes needs to force a refresh in case we updated that tag,
+            // navigated away (and confirmed that we wanted to abandon the change),
+            // then navigated back
+            this.providerRecord.current()['_holding_tag'] = this.providerRecord.current().holding_tag();
+        }
+    }
+
+    ngOnDestroy() {
+        this.subscription.unsubscribe();
+    }
+
+    updateProvider(providerId: any) {
+        this.provider.holding_tag(this.provider._holding_tag);
+        this.provider.ischanged(true);
+        this.providerRecord.batchUpdate([this.provider]).subscribe(
+            val => {
+                this.successTagString.current()
+                    .then(str => this.toast.success(str));
+            },
+            err => {
+                this.updateFailedTagString.current()
+                    .then(str => this.toast.danger(str));
+            },
+            ()  => {
+                this.providerRecord.refreshCurrent().then(
+                    () => { this.provider = this.providerRecord.current(); }
+                );
+            }
+        );
+    }
+
+    getDataSource(): GridDataSource {
+        const gridSource = new GridDataSource();
+
+        gridSource.getRows = (pager: Pager, sort: any[]) => {
+            this.provider = this.providerRecord.current();
+            if (!this.provider) {
+                return empty();
+            }
+            let holdings = this.provider.holdings_subfields();
+
+            if (sort.length > 0) {
+                holdings = holdings.sort((a, b) => {
+                    for (let i = 0; i < sort.length; i++) {
+                        let lt = -1;
+                        const sfield = sort[i].name;
+                        if (sort[i].dir.substring(0, 1).toLowerCase() === 'd') {
+                            lt *= -1;
+                        }
+                        if (a[sfield]() < b[sfield]()) { return lt; }
+                        if (a[sfield]() > b[sfield]()) { return lt * -1; }
+                    }
+                    return 0;
+                });
+
+            }
+
+            return from(holdings.slice(pager.offset, pager.offset + pager.limit));
+        };
+        return gridSource;
+    }
+
+    showEditDialog(providerHolding: IdlObject): Promise<any> {
+        this.editDialog.mode = 'update';
+        this.editDialog.recordId = providerHolding['id']();
+        return new Promise((resolve, reject) => {
+            this.editDialog.open({size: this.dialogSize}).subscribe(
+                result => {
+                    this.successString.current()
+                        .then(str => this.toast.success(str));
+                    this.providerRecord.refreshCurrent().then(
+                        () => this.providerHoldingsGrid.reload()
+                    );
+                    resolve(result);
+                },
+                error => {
+                    this.updateFailedString.current()
+                        .then(str => this.toast.danger(str));
+                    reject(error);
+                }
+            );
+        });
+    }
+
+    editSelected(providerHoldingsFields: IdlObject[]) {
+        // Edit each IDL thing one at a time
+        const editOneThing = (providerHoldings: IdlObject) => {
+            if (!providerHoldings) { return; }
+
+            this.showEditDialog(providerHoldings).then(
+                () => editOneThing(providerHoldingsFields.shift()));
+        };
+
+        editOneThing(providerHoldingsFields.shift());
+    }
+
+    createNew() {
+        this.editDialog.mode = 'create';
+        const holdings = this.idl.create('acqphsm');
+        holdings.provider(this.provider.id());
+        this.editDialog.record = holdings;
+        this.editDialog.recordId = null;
+        this.editDialog.open({size: this.dialogSize}).subscribe(
+            ok => {
+                this.createString.current()
+                    .then(str => this.toast.success(str));
+                this.providerRecord.refreshCurrent().then(
+                    () => this.providerHoldingsGrid.reload()
+                );
+            },
+            rejection => {
+                if (!rejection.dismissed) {
+                    this.createErrString.current()
+                        .then(str => this.toast.danger(str));
+                }
+            }
+        );
+    }
+
+    isDirty(): boolean {
+        return (this.providerRecord.current()['_holding_tag'] === this.providerRecord.current().holding_tag()) ? false :
+               (this.holdingTagForm && this.holdingTagForm.dirty) ? this.holdingTagForm.dirty : false;
+    }
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-invoices.component.html b/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-invoices.component.html
new file mode 100644 (file)
index 0000000..feda594
--- /dev/null
@@ -0,0 +1,45 @@
+<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 routerLink="/staff/acq/provider/{{invoice.provider().id()}}"
+     target="_blank">
+    {{invoice.provider().code()}}
+  </a>
+</ng-template>
+<ng-template #shipperTmpl let-invoice="row">
+  <a routerLink="/staff/acq/provider/{{invoice.shipper().id()}}"
+     target="_blank">
+    {{invoice.shipper().code()}}
+  </a>
+</ng-template>
+
+<eg-grid #acqProviderInvoicesGrid
+  persistKey="acq.provider.invoices"
+  [stickyHeader]="true"
+  [filterable]="true"
+  [sortable]="true"
+  [cellTextGenerator]="cellTextGenerator"
+  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" [filterable]="false" [hidden]="true"></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/provider/provider-invoices.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-invoices.component.ts
new file mode 100644 (file)
index 0000000..c6a85e0
--- /dev/null
@@ -0,0 +1,120 @@
+import {Component, OnInit, AfterViewInit, OnDestroy, Input, ViewChild} from '@angular/core';
+import {Observable, Subscription} 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 '../search/acq-search.service';
+import {AttrDefsService} from '../search/attr-defs.service';
+import {ProviderRecord, ProviderRecordService} from './provider-record.service';
+
+@Component({
+  selector: 'eg-provider-invoices',
+  templateUrl: 'provider-invoices.component.html',
+  providers: [AcqSearchService, AttrDefsService]
+})
+export class ProviderInvoicesComponent implements OnInit, AfterViewInit, OnDestroy {
+
+    @Input() initialSearchTerms: AcqSearchTerm[] = [];
+
+    gridSource: GridDataSource;
+    @ViewChild('acqProviderInvoicesGrid', { static: true }) providerInvoicesGrid: GridComponent;
+    @ViewChild('printfail', { static: true }) private printfail: AlertDialogComponent;
+
+    noSelectedRows: (rows: IdlObject[]) => boolean;
+
+    cellTextGenerator: GridCellTextGenerator;
+
+    subscription: Subscription;
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private printer: PrintService,
+        private evt: EventService,
+        private net: NetService,
+        private auth: AuthService,
+        private providerRecord: ProviderRecordService,
+        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(),
+        };
+        this.subscription = this.providerRecord.providerUpdated$.subscribe(
+            id => {
+                this.resetSearch();
+            }
+        );
+    }
+
+    ngAfterViewInit() {
+        this.resetSearch();
+    }
+
+    ngOnDestroy() {
+        this.subscription.unsubscribe();
+    }
+
+    resetSearch() {
+        const provider = this.providerRecord.current();
+        if (provider) {
+            setTimeout(() => {
+                this.acqSearch.setSearch({
+                    terms: [{
+                        field:  'acqinv:provider',
+                        op:     '',
+                        value1: provider.id(),
+                        value2: '',
+                    }],
+                    conjunction: 'all',
+                });
+                this.providerInvoicesGrid.reload();
+            });
+        }
+    }
+
+    // TODO - copied from InvoiceResultsComponent, could be
+    // consolidated
+    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'
+        })
+      );
+    }
+
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-purchase-orders.component.html b/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-purchase-orders.component.html
new file mode 100644 (file)
index 0000000..caed7ae
--- /dev/null
@@ -0,0 +1,38 @@
+<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 routerLink="/staff/acq/provider/{{purchaseorder.provider().id()}}"
+     target="_blank">
+    {{purchaseorder.provider().code()}}
+  </a>
+</ng-template>
+
+<eg-grid #acqProviderPurchaseOrdersGrid
+  persistKey="acq.provider.purchaseorders"
+  [stickyHeader]="true"
+  [filterable]="true"
+  [sortable]="true"
+  [cellTextGenerator]="cellTextGenerator"
+  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" [hidden]="true" [filterable]="false" [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/provider/provider-purchase-orders.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-purchase-orders.component.ts
new file mode 100644 (file)
index 0000000..521a31f
--- /dev/null
@@ -0,0 +1,89 @@
+import {Component, OnInit, AfterViewInit, OnDestroy, Input, ViewChild} from '@angular/core';
+import {Observable, Subscription} 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 '../search/acq-search.service';
+import {AttrDefsService} from '../search/attr-defs.service';
+import {ProviderRecord, ProviderRecordService} from './provider-record.service';
+
+@Component({
+  selector: 'eg-provider-purchase-orders',
+  templateUrl: 'provider-purchase-orders.component.html',
+  providers: [AcqSearchService, AttrDefsService]
+})
+export class ProviderPurchaseOrdersComponent implements OnInit, AfterViewInit, OnDestroy {
+
+    @Input() initialSearchTerms: AcqSearchTerm[] = [];
+
+    gridSource: GridDataSource;
+    @ViewChild('acqProviderPurchaseOrdersGrid', { static: true }) providerPurchaseOrdersGrid: GridComponent;
+    @ViewChild('printfail', { static: true }) private printfail: AlertDialogComponent;
+
+    noSelectedRows: (rows: IdlObject[]) => boolean;
+
+    cellTextGenerator: GridCellTextGenerator;
+
+    subscription: Subscription;
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private printer: PrintService,
+        private evt: EventService,
+        private net: NetService,
+        private auth: AuthService,
+        private providerRecord: ProviderRecordService,
+        private acqSearch: AcqSearchService) {
+    }
+
+    ngOnInit() {
+        this.gridSource = this.acqSearch.getAcqSearchDataSource('purchase_order');
+        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(),
+        };
+        this.subscription = this.providerRecord.providerUpdated$.subscribe(
+            id => {
+                this.resetSearch();
+            }
+        );
+    }
+
+    ngAfterViewInit() {
+        this.resetSearch();
+    }
+
+    resetSearch() {
+        const provider = this.providerRecord.current();
+        if (provider) {
+            setTimeout(() => {
+                this.acqSearch.setSearch({
+                    terms: [{
+                        field:  'acqpo:provider',
+                        op:     '',
+                        value1: provider.id(),
+                        value2: '',
+                    }],
+                    conjunction: 'all',
+                });
+                this.providerPurchaseOrdersGrid.reload();
+            });
+        }
+    }
+
+    ngOnDestroy() {
+        this.subscription.unsubscribe();
+    }
+
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-record.service.ts b/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-record.service.ts
new file mode 100644 (file)
index 0000000..4f46b51
--- /dev/null
@@ -0,0 +1,198 @@
+import {Injectable} from '@angular/core';
+import {Observable, from} from 'rxjs';
+import {empty, throwError, Subject} from 'rxjs';
+import {map, defaultIfEmpty} from 'rxjs/operators';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {PermService} from '@eg/core/perm.service';
+
+export class ProviderSummary {
+}
+
+export class ProviderRecord {
+    id: number;
+    record: IdlObject;
+    canDelete: boolean;
+    canAdmin: boolean;
+
+    constructor(record: IdlObject) {
+        this.id = Number(record.id());
+        this.record = record;
+        this.canDelete = false;
+        this.canAdmin = false;
+    }
+}
+
+@Injectable()
+export class ProviderRecordService {
+
+    private currentProvider: ProviderRecord;
+    private currentProviderId: number = null;
+
+    private providerUpdatedSource = new Subject<number>();
+    providerUpdated$ = this.providerUpdatedSource.asObservable();
+
+    private permissions: any;
+    private viewOUs: number[] = [];
+
+    constructor(
+        private idl: IdlService,
+        private net: NetService,
+        private pcrud: PcrudService,
+        private perm: PermService
+    ) {
+        this.currentProvider = null;
+        this.loadPerms();
+    }
+
+    loadPerms(): Promise<any> {
+        if (this.permissions) {
+            return Promise.resolve();
+        }
+        return this.perm.hasWorkPermAt(['ADMIN_PROVIDER', 'MANAGE_PROVIDER', 'VIEW_PROVIDER'], true).then(permMap => {
+            this.permissions = permMap;
+            this.viewOUs.concat(permMap['VIEW_PROVIDER']);
+            this.permissions['ADMIN_PROVIDER'].forEach(ou => {
+                if (!this.viewOUs.includes(ou)) {
+                    this.viewOUs.push(ou);
+                }
+            });
+            this.permissions['MANAGE_PROVIDER'].forEach(ou => {
+                if (!this.viewOUs.includes(ou)) {
+                    this.viewOUs.push(ou);
+                }
+            });
+        });
+    }
+
+    getProviderRecord(id: number): Observable<ProviderRecord> {
+        console.debug('fetching provider ' + id);
+        this.currentProviderId = id;
+        const emptyGuard = this.idl.create('acqpro');
+        emptyGuard.id('no_provider_fetched');
+        return this.pcrud.search('acqpro', { id: id },
+            {
+                flesh: 3,
+                flesh_fields: { acqpro:   [
+                                            'attributes', 'holdings_subfields', 'contacts',
+                                            'addresses', 'provider_notes',
+                                            'edi_accounts', 'currency_type', 'edi_default'
+                                          ],
+                                acqpa:    ['provider'],
+                                acqpc:    ['provider', 'addresses'],
+                                acqphsm:  ['provider'],
+                                acqlipad: ['provider'],
+                                acqedi:   ['attr_set', 'provider'],
+                              }
+            },
+            {}
+        ).pipe(defaultIfEmpty(emptyGuard), map(acqpro => {
+            if (acqpro.id() === 'no_provider_fetched') {
+                throw new Error('no provider to fetch');
+            }
+            const provider = new ProviderRecord(acqpro);
+            // make a copy of holding_tag for use by the holdings definitions tab
+            acqpro['_holding_tag'] = acqpro.holding_tag();
+            acqpro.edi_accounts().forEach(acct => {
+                acct['_is_default'] = false;
+                if (acqpro.edi_default()) {
+                    if (acct.id() === acqpro.edi_default().id()) {
+                        acct['_is_default'] = true;
+                    }
+                }
+            });
+            acqpro.contacts().forEach(acct => {
+                acct['_is_primary'] = false;
+                if (acqpro.primary_contact()) {
+                    if (acct.id() === acqpro.primary_contact()) {
+                        acct['_is_primary'] = true;
+                    }
+                }
+            });
+            this.currentProvider = provider;
+            this.checkIfCanDelete(provider);
+            this.checkIfCanAdmin(provider);
+            return provider;
+        }));
+    }
+
+    checkIfCanDelete(prov: ProviderRecord) {
+        this.pcrud.search('acqpo', { provider: prov.id }, { limit: 1 }).toPromise()
+        .then(acqpo => {
+            if (!acqpo || acqpo.length === 0) {
+                this.pcrud.search('jub', { provider: prov.id }, { limit: 1 }).toPromise()
+                .then(jub => {
+                    if (!jub || jub.length === 0) {
+                        this.pcrud.search('acqinv', { provider: prov.id }, { limit: 1 }).toPromise()
+                        .then(acqinv => {
+                            prov.canDelete = true;
+                        });
+                    }
+                });
+            }
+        });
+    }
+
+    checkIfCanAdmin(prov: ProviderRecord) {
+        this.loadPerms().then(x => {
+            if (Object.keys(this.permissions).length > 0 &&
+                this.permissions['ADMIN_PROVIDER'].includes(prov.record.owner())) {
+                prov.canAdmin = true;
+            }
+        });
+    }
+
+    checkIfCanAdminAtAll(): boolean {
+        if (typeof this.permissions === 'undefined') {
+            return false;
+        }
+        if (Object.keys(this.permissions).length > 0 &&
+            this.permissions['ADMIN_PROVIDER'].length > 0) {
+            return true;
+        } else {
+            return false;
+        }
+    }
+
+    getViewOUs(): number[] {
+        return this.viewOUs;
+    }
+
+    current(): IdlObject {
+        return this.currentProvider ? this.currentProvider.record : null;
+    }
+    currentProviderRecord(): ProviderRecord {
+        return this.currentProvider ? this.currentProvider : null;
+    }
+
+    fetch(id: number): Promise<any> {
+        return new Promise((resolve, reject) => {
+            this.getProviderRecord(id).subscribe(
+                result => {
+                    resolve();
+                },
+                error => {
+                    reject();
+                },
+            );
+        });
+    }
+
+    refreshCurrent(): Promise<any> {
+        if (this.currentProviderId) {
+            return this.fetch(this.currentProviderId);
+        } else {
+            return Promise.reject();
+        }
+    }
+
+    batchUpdate(list: IdlObject | IdlObject[]): Observable<any> {
+        return this.pcrud.autoApply(list);
+    }
+
+    announceProviderUpdated() {
+        this.providerUpdatedSource.next(this.currentProviderId);
+    }
+
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-results.component.html b/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-results.component.html
new file mode 100644 (file)
index 0000000..acd696f
--- /dev/null
@@ -0,0 +1,25 @@
+<eg-acq-provider-search-form #providerSearchForm (searchSubmitted)="doSearch($event)"></eg-acq-provider-search-form>
+
+<ng-template #contactTmpl let-provider="row">
+  <ul>
+    <li *ngFor="let c of provider.contacts()">{{c.name()}}</li>
+  </ul>
+</ng-template>
+
+<eg-grid #acqSearchProviderGrid
+  persistKey="acq.provider.search.results"
+  idlClass="acqpro" [dataSource]="gridSource"
+  [stickyHeader]="true"
+  [filterable]="true"
+  [sortable]="true"
+  [disableMultiSelect]="true"
+  (onRowClick)="previewRow($event, false)"
+  (onRowActivate)="previewRow($event)"
+  [cellTextGenerator]="cellTextGenerator">
+
+  <eg-grid-toolbar-action label="Retrieve Provider" i18n-label (onClick)="retrieveRow($event)"></eg-grid-toolbar-action>
+  <eg-grid-column [asyncSupportsEmptyTermClick]="true" path="currency_type" [hidden]="true"></eg-grid-column>
+  <eg-grid-column [asyncSupportsEmptyTermClick]="true" path="default_claim_policy" [hidden]="true"></eg-grid-column>
+  <eg-grid-column path="contacts" [cellTemplate]="contactTmpl" [filterable]="false" [sortable]="false" [hidden]="true" [disableTooltip]="true"></eg-grid-column>
+</eg-grid>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-results.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/provider/provider-results.component.ts
new file mode 100644 (file)
index 0000000..27ef5d3
--- /dev/null
@@ -0,0 +1,83 @@
+import {Component, OnInit, AfterViewInit, Input, Output, EventEmitter, ViewChild, ElementRef} 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 {AcqProviderSearchService, AcqProviderSearchTerm, AcqProviderSearch} from './acq-provider-search.service';
+import {AcqProviderSearchFormComponent} from './acq-provider-search-form.component';
+
+@Component({
+  selector: 'eg-provider-results',
+  templateUrl: 'provider-results.component.html',
+  providers: [AcqProviderSearchService]
+})
+export class ProviderResultsComponent implements OnInit, AfterViewInit {
+
+    gridSource: GridDataSource;
+    @ViewChild('acqSearchProviderGrid', { static: true }) providerResultsGrid: GridComponent;
+    @ViewChild('providerSearchForm', { static: true }) providerSearchForm: AcqProviderSearchFormComponent;
+
+    cellTextGenerator: GridCellTextGenerator;
+    @Output() previewRow: (row: any, hideSearchForm?: boolean) => void;
+    @Output() desireSummarize: EventEmitter<number> = new EventEmitter<number>();
+    @Output() summarizeSearchFormOpen: EventEmitter<number> = new EventEmitter<number>();
+
+    constructor(
+        private elementRef: ElementRef,
+        private router: Router,
+        private route: ActivatedRoute,
+        private net: NetService,
+        private auth: AuthService,
+        private providerSearch: AcqProviderSearchService) {
+    }
+
+    ngOnInit() {
+        this.gridSource = this.providerSearch.getDataSource();
+
+        this.cellTextGenerator = {
+            provider: row => row.provider().code(),
+            name: row => row.name(),
+        };
+
+        this.previewRow = (row: any, hideSearchForm = true) => {
+            if (hideSearchForm) {
+                this.desireSummarize.emit(row.id());
+            } else {
+                this.summarizeSearchFormOpen.emit(row.id());
+            }
+        };
+    }
+
+    ngAfterViewInit() {
+        // check if we're visible; if we are, we've
+        // likely come in directly from the main Provider Search
+        // menu item and should go ahead and submit the
+        // form with default values
+        // see: https://stackoverflow.com/questions/37843907/angular2-is-there-a-way-to-know-when-a-component-is-hidden
+        const elm = this.elementRef.nativeElement;
+        if (elm.offsetParent !== null) {
+            setTimeout(x => this.providerSearchForm.submitSearch());
+        }
+    }
+
+    retrieveRow(rows: IdlObject[]) {
+        this.desireSummarize.emit(rows[0].id());
+    }
+
+    resetSearch() {
+        this.providerSearchForm.clearSearch();
+        setTimeout(x => this.providerSearchForm.submitSearch());
+    }
+
+    doSearch(search: AcqProviderSearch) {
+        setTimeout(() => {
+            this.providerSearch.setSearch(search);
+            this.providerResultsGrid.reload();
+        });
+    }
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/provider/resolver.service.ts b/Open-ILS/src/eg2/src/app/staff/acq/provider/resolver.service.ts
new file mode 100644 (file)
index 0000000..f993b47
--- /dev/null
@@ -0,0 +1,57 @@
+import {Injectable} from '@angular/core';
+import {Observable} from 'rxjs';
+import {Router, Resolve, RouterStateSnapshot,
+        ActivatedRouteSnapshot, CanDeactivate} from '@angular/router';
+import {ProviderRecordService} from './provider-record.service';
+
+@Injectable()
+export class ProviderResolver implements Resolve<Promise<any[]>> {
+
+    savedId: number = null;
+
+    constructor(
+        private router: Router,
+        private providerRecord: ProviderRecordService,
+    ) {}
+
+    resolve(
+        route: ActivatedRouteSnapshot,
+        state: RouterStateSnapshot): Promise<any[]> {
+
+        console.debug('ProviderResolver:resolve()');
+
+        const id = Number(route.paramMap.get('id'));
+
+        if (this.savedId !== null && this.savedId === id) {
+            // don't refetch
+            return Promise.all([
+                Promise.resolve(),
+            ]);
+        } else {
+            this.savedId = id;
+            return Promise.all([
+                this.providerRecord.fetch(id).then(
+                    ok => {
+                        console.debug(this.providerRecord.current());
+                    },
+                    err => {
+                        this.router.navigate(['/staff', 'acq', 'provider']);
+                    }
+                ),
+            ]);
+        }
+    }
+
+}
+
+// following example of https://www.concretepage.com/angular-2/angular-candeactivate-guard-example
+export interface DeactivationGuarded {
+    canDeactivate(): Observable<boolean> | Promise<boolean> | boolean;
+}
+
+@Injectable()
+export class CanLeaveAcqProviderGuard implements CanDeactivate<DeactivationGuarded> {
+    canDeactivate(component: DeactivationGuarded):  Observable<boolean> | Promise<boolean> | boolean {
+        return component.canDeactivate ? component.canDeactivate() : true;
+    }
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/provider/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/acq/provider/routing.module.ts
new file mode 100644 (file)
index 0000000..c8e0686
--- /dev/null
@@ -0,0 +1,30 @@
+import {NgModule} from '@angular/core';
+import {RouterModule, Routes} from '@angular/router';
+import {AcqProviderComponent} from './acq-provider.component';
+import {ProviderResolver, CanLeaveAcqProviderGuard} from './resolver.service';
+
+const routes: Routes = [
+  { path: '',
+    component: AcqProviderComponent,
+    runGuardsAndResolvers: 'always'
+  },
+  { path: ':id',
+    component: AcqProviderComponent,
+    resolve: { providerResolver : ProviderResolver },
+    runGuardsAndResolvers: 'always'
+  },
+  { path: ':id/:tab',
+    component: AcqProviderComponent,
+    resolve: { providerResolver : ProviderResolver },
+    canDeactivate: [CanLeaveAcqProviderGuard],
+    runGuardsAndResolvers: 'always'
+  }
+];
+
+@NgModule({
+  imports: [RouterModule.forChild(routes)],
+  exports: [RouterModule],
+  providers: [ProviderResolver, CanLeaveAcqProviderGuard]
+})
+
+export class AcqProviderRoutingModule {}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/provider/summary-pane.component.css b/Open-ILS/src/eg2/src/app/staff/acq/provider/summary-pane.component.css
new file mode 100644 (file)
index 0000000..de3c24b
--- /dev/null
@@ -0,0 +1,27 @@
+#acq-provider-summary-pane {
+  border-radius: 0px 0px 7px 7px;
+  background-color: rgb(247, 247, 247);
+  box-shadow: 1px 2px 3px -1px rgba(0, 0, 0, .2);
+}
+.row { border-bottom: dotted thin; }
+.row.provider_id { }
+.row.provider_name { }
+.row.provider_code { }
+.row.provider_owner { }
+.row.provider_currency_type { }
+.row.provider_holding_tag { display: none; }
+.row.provider_addresses { display: none; }
+.row.provider_san { }
+.row.provider_edi_default { }
+.row.provider_active { }
+.row.provider_prepayment_required { display: none; }
+.row.provider_url { }
+.row.provider_email { display: none; }
+.row.provider_phone { display: none; }
+.row.provider_fax_phone { display: none; }
+.row.provider_default_claim_policy { display: none; }
+.row.provider_default_copy_count { display: none; }
+.row.provider_contacts { }
+.provider_contact_role { font-style: italic; }
+.provider_primary_contact { color: red; }
+.row.provider_provider_notes { display: none; }
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/provider/summary-pane.component.html b/Open-ILS/src/eg2/src/app/staff/acq/provider/summary-pane.component.html
new file mode 100644 (file)
index 0000000..e3e848b
--- /dev/null
@@ -0,0 +1,125 @@
+<button class="btn" *ngIf="provider_id" [hidden]="!collapsed" (click)="toggleCollapse()" type="submit" i18n><span class="material-icons">expand_more</span></button>
+<button class="btn" *ngIf="provider_id" [hidden]="collapsed" (click)="toggleCollapse()" type="submit" i18n><span class="material-icons">expand_less</span></button>
+
+<div id="acq-provider-summary-pane" [hidden]="!provider_id || collapsed" class="pl-3 pr-3 pt-3 pb-3 mb-3">
+
+<ng-template #errorStrTmpl i18n>Provider Deletion Failed</ng-template>
+<eg-string #deleteSuccessString i18n-text text="Successfully deleted provider."></eg-string>
+<eg-string #errorString [template]="errorStrTmpl"></eg-string>
+
+<eg-confirm-dialog #delConfirm
+  i18n-dialogTitle i18n-dialogBody
+  dialogTitle="Confirm Delete"
+  dialogBody="Delete Provider {{provider ? provider.code() : ''}}?">
+</eg-confirm-dialog>
+
+<div class="row provider_name">
+<div class="col">{{provider_name_label}}</div>
+<div class="col">{{provider_name}}</div>
+</div>
+
+<div class="row provider_code">
+<div class="col">{{provider_code_label}}</div>
+<div class="col">{{provider_code}}</div>
+</div>
+
+<div class="row provider_owner">
+<div class="col">{{provider_owner_label}}</div>
+<div class="col">{{provider_owner}}</div>
+</div>
+
+<div class="row provider_id">
+<div class="col">{{provider_id_label}}</div>
+<div class="col">{{provider_id}}</div>
+</div>
+
+<div class="row provider_currency_type">
+<div class="col">{{provider_currency_type_label}}</div>
+<div class="col">{{provider_currency_type}}</div>
+</div>
+
+<div class="row provider_contacts">
+<div class="col">{{provider_contacts_label}}</div>
+<div class="col">
+  <div *ngFor="let contact of provider_contacts">
+    <span *ngIf="contact.role()"><span class="provider_contact_role">{{contact.role()}} : </span><span>{{contact.name()}}</span></span>
+    <span *ngIf="!contact.role()">{{contact.name()}}</span>
+    <span *ngIf="contact._is_primary"><span class="provider_primary_contact"> (primary)</span></span>
+  </div>
+</div>
+</div>
+
+<div class="row provider_san_tag">
+<div class="col">{{provider_san_label}}</div>
+<div class="col">{{provider_san}}</div>
+</div>
+
+<div class="row provider_edi_default">
+<div class="col">{{provider_edi_default_label}}</div>
+<div class="col">{{provider_edi_default}}</div>
+</div>
+
+<div class="row provider_url">
+<div class="col">{{provider_url_label}}</div>
+<div class="col" *ngIf="provider_url"><a target="_blank" href="{{provider_url}}">{{provider_url}}</a></div>
+</div>
+
+<div class="row provider_holding_tag">
+<div class="col">{{provider_holding_tag_label}}</div>
+<div class="col">{{provider_holding_tag}}</div>
+</div>
+
+<div class="row provider_addresses">
+<div class="col">{{provider_addresses_label}}</div>
+<div class="col">{{provider_addresses}}</div>
+</div>
+
+<div class="row provider_active">
+<div class="col">{{provider_active_label}}</div>
+<div class="col"><eg-bool [value]="provider_active == 't'"></eg-bool></div>
+</div>
+
+<div class="row provider_prepayment_required">
+<div class="col">{{provider_prepayment_required_label}}</div>
+<div class="col">{{provider_prepayment_required}}</div>
+</div>
+
+<div class="row provider_email">
+<div class="col">{{provider_email_label}}</div>
+<div class="col">{{provider_email}}</div>
+</div>
+
+<div class="row provider_phone">
+<div class="col">{{provider_phone_label}}</div>
+<div class="col">{{provider_phone}}</div>
+</div>
+
+<div class="row provider_fax_phone">
+<div class="col">{{provider_fax_phone_label}}</div>
+<div class="col">{{provider_fax_phone}}</div>
+</div>
+
+<div class="row provider_default_claim_policy">
+<div class="col">{{provider_default_claim_policy_label}}</div>
+<div class="col">{{provider_default_claim_policy}}</div>
+</div>
+
+<div class="row provider_default_copy_count">
+<div class="col">{{provider_default_copy_count_label}}</div>
+<div class="col">{{provider_default_copy_count}}</div>
+</div>
+
+<div class="row provider_provider_notes">
+<div class="col">{{provider_provider_notes_label}}</div>
+<div class="col">{{provider_provider_notes}}</div>
+</div>
+
+</div>
+
+<button class="btn btn-primary"
+  [hidden]="collapsed"
+  *ngIf="provider_id"
+  [disabled]="!canDeleteProvider()"
+  (click)="deleteProvider()"
+  type="submit" i18n>
+Delete Provider</button>
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/provider/summary-pane.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/provider/summary-pane.component.ts
new file mode 100644 (file)
index 0000000..0e84332
--- /dev/null
@@ -0,0 +1,211 @@
+import {Component, OnInit, AfterViewInit, Input, Output, EventEmitter, ViewChild} from '@angular/core';
+import {Router} from '@angular/router';
+import {StaffCommonModule} from '@eg/staff/common.module';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {OrgService} from '@eg/core/org.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 {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+import {AuthService} from '@eg/core/auth.service';
+import {ProviderRecord, ProviderRecordService} from './provider-record.service';
+
+@Component({
+    selector: 'eg-acq-provider-summary-pane',
+    styleUrls: ['summary-pane.component.css'],
+    templateUrl: './summary-pane.component.html'
+})
+
+export class AcqProviderSummaryPaneComponent implements OnInit, AfterViewInit {
+
+    @ViewChild('deleteSuccessString', { static: true }) deleteSuccessString: StringComponent;
+
+    collapsed = false;
+
+    provider_id = '';
+    provider_name = '';
+    provider_code = '';
+    provider_owner = '';
+    provider_currency_type = '';
+    provider_holding_tag = '';
+    provider_addresses = '';
+    provider_san = '';
+    provider_edi_default = '';
+    provider_active = '';
+    provider_prepayment_required = '';
+    provider_url = '';
+    provider_email = '';
+    provider_phone = '';
+    provider_fax_phone = '';
+    provider_default_claim_policy = '';
+    provider_default_copy_count = '';
+    provider_contacts = '';
+    provider_provider_notes = '';
+
+    provider_id_label;
+    provider_name_label;
+    provider_code_label;
+    provider_owner_label;
+    provider_currency_type_label;
+    provider_holding_tag_label;
+    provider_addresses_label;
+    provider_san_label;
+    provider_edi_default_label;
+    provider_active_label;
+    provider_prepayment_required_label;
+    provider_url_label;
+    provider_email_label;
+    provider_phone_label;
+    provider_fax_phone_label;
+    provider_default_claim_policy_label;
+    provider_default_copy_count_label;
+    provider_contacts_label;
+    provider_provider_notes_label;
+
+    @Input() providerId: any;
+    @ViewChild('errorString', { static: true }) errorString: StringComponent;
+    @ViewChild('delConfirm', { static: true }) delConfirm: ConfirmDialogComponent;
+    @Output() summaryToggled: EventEmitter<boolean> = new EventEmitter<boolean>();
+
+    provider: IdlObject;
+    provRec: ProviderRecord;
+
+    constructor(
+        private router: Router,
+        private pcrud: PcrudService,
+        private idl: IdlService,
+        private org: OrgService,
+        private toast: ToastService,
+        private auth: AuthService,
+        private prov: ProviderRecordService,
+    ) {}
+
+    ngOnInit() {
+        this.provider_id_label = this.idl.classes['acqpro'].field_map['id'].label;
+        this.provider_name_label = this.idl.classes['acqpro'].field_map['name'].label;
+        this.provider_code_label = this.idl.classes['acqpro'].field_map['code'].label;
+        this.provider_owner_label = this.idl.classes['acqpro'].field_map['owner'].label;
+        this.provider_currency_type_label = this.idl.classes['acqpro'].field_map['currency_type'].label;
+        this.provider_holding_tag_label = this.idl.classes['acqpro'].field_map['holding_tag'].label;
+        this.provider_addresses_label = this.idl.classes['acqpro'].field_map['addresses'].label;
+        this.provider_san_label = this.idl.classes['acqpro'].field_map['san'].label;
+        this.provider_edi_default_label = this.idl.classes['acqpro'].field_map['edi_default'].label;
+        this.provider_active_label = this.idl.classes['acqpro'].field_map['active'].label;
+        this.provider_prepayment_required_label = this.idl.classes['acqpro'].field_map['prepayment_required'].label;
+        this.provider_url_label = this.idl.classes['acqpro'].field_map['url'].label;
+        this.provider_email_label = this.idl.classes['acqpro'].field_map['email'].label;
+        this.provider_phone_label = this.idl.classes['acqpro'].field_map['phone'].label;
+        this.provider_fax_phone_label = this.idl.classes['acqpro'].field_map['fax_phone'].label;
+        this.provider_default_claim_policy_label = this.idl.classes['acqpro'].field_map['default_claim_policy'].label;
+        this.provider_default_copy_count_label = this.idl.classes['acqpro'].field_map['default_copy_count'].label;
+        this.provider_contacts_label = this.idl.classes['acqpro'].field_map['contacts'].label;
+        this.provider_provider_notes_label = this.idl.classes['acqpro'].field_map['provider_notes'].label;
+    }
+
+    ngAfterViewInit() {
+        if (this.providerId) {
+            this.update(this.providerId);
+        }
+    }
+
+    update(newProvider: any) {
+        function no_provider() {
+            // FIXME: empty the pane or keep last summarized view?
+            this.provider_id = '';
+            this.provider_name = '';
+            this.provider_code = '';
+            this.provider_owner = '';
+            this.provider_currency_type = '';
+            this.provider_holding_tag = '';
+            this.provider_addresses = '';
+            this.provider_san = '';
+            this.provider_edi_default = '';
+            this.provider_active = '';
+            this.provider_prepayment_required = '';
+            this.provider_url = '';
+            this.provider_email = '';
+            this.provider_phone = '';
+            this.provider_fax_phone = '';
+            this.provider_default_claim_policy = '';
+            this.provider_default_copy_count = '';
+            this.provider_contacts = '';
+            this.provider_provider_notes = '';
+        }
+
+        if (newProvider) {
+            const providerRecord = this.prov.currentProviderRecord();
+            const provider = providerRecord.record;
+            if (provider) {
+                this.provRec = providerRecord;
+                this.provider = provider;
+                this.provider_id = provider.id();
+                this.provider_name = provider.name();
+                this.provider_code = provider.code();
+                this.provider_owner = this.org.get(provider.owner()).shortname();
+                this.provider_currency_type = provider.currency_type() ? provider.currency_type().label() : '';
+                this.provider_holding_tag = provider.holding_tag();
+                this.provider_addresses = provider.addresses();
+                this.provider_san = provider.san();
+                if (typeof provider.edi_default() === 'object') {
+                    this.provider_edi_default = provider.edi_default() ? provider.edi_default().label() : '';
+                } else {
+                    // not fleshed, presumably because user doesn't have
+                    // permission to retrieve EDI accounts
+                    this.provider_edi_default = '';
+                }
+                this.provider_active = provider.active();
+                this.provider_prepayment_required = provider.prepayment_required();
+                this.provider_url = provider.url();
+                this.provider_email = provider.email();
+                this.provider_phone = provider.phone();
+                this.provider_fax_phone = provider.fax_phone();
+                this.provider_default_claim_policy = provider.default_claim_policy();
+                this.provider_default_copy_count = provider.default_copy_count();
+                this.provider_contacts = provider.contacts();
+                this.provider_provider_notes = provider.provider_notes();
+            } else {
+                this.provider = null;
+                no_provider();
+            }
+        } else {
+            no_provider();
+        }
+    }
+
+    deleteProvider() {
+        this.delConfirm.open().subscribe(confirmed => {
+            if (!confirmed) { return; }
+
+            this.pcrud.remove(this.provider)
+            .subscribe(
+                ok2 => {
+                    this.deleteSuccessString.current()
+                        .then(str => this.toast.success(str));
+                    this.router.navigate(['/staff', 'acq', 'provider']);
+                },
+                err => {
+                    this.errorString.current()
+                      .then(str => this.toast.danger(str));
+                },
+                ()  => {
+                    console.log('deleteProvider, what is this?');
+                }
+            );
+        });
+
+    }
+
+    canDeleteProvider() {
+        if (this.provider && this.provider.id()) {
+            return this.provRec.canAdmin && this.provRec.canDelete;
+        } else {
+            return false;
+        }
+    }
+
+    toggleCollapse() {
+        this.collapsed = ! this.collapsed;
+        this.summaryToggled.emit(this.collapsed);
+    }
+
+}
index 2230dd7..8cde151 100644 (file)
@@ -5,6 +5,10 @@ const routes: Routes = [
   { path: 'search',
     loadChildren: () =>
       import('./search/acq-search.module').then(m => m.AcqSearchModule)
+  },
+  { path: 'provider',
+    loadChildren: () =>
+      import('./provider/acq-provider.module').then(m => m.AcqProviderModule)
   }
 ];
 
index 18d8caf..25e1ef6 100644 (file)
   </a>
 </ng-template>
 <ng-template #providerTmpl let-invoice="row">
-  <a href="/eg/staff/admin/acq/conify/provider/{{invoice.provider().id()}}"
+  <a routerLink="/staff/acq/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()}}"
+  <a routerLink="/staff/acq/provider/{{invoice.shipper().id()}}"
      target="_blank">
     {{invoice.shipper().code()}}
   </a>
index 2da1a97..d7fc3ab 100644 (file)
@@ -36,7 +36,7 @@
 </ng-template>
 
 <ng-template #providerTmpl let-lineitem="row">
-  <a *ngIf="lineitem.provider()" href="/eg/staff/admin/acq/conify/provider/{{lineitem.provider().id()}}"
+  <a *ngIf="lineitem.provider()" routerLink="/staff/acq/provider/{{lineitem.provider().id()}}"
      target="_blank">
     {{lineitem.provider().code()}}
   </a>
index 5c3c28b..f1ca170 100644 (file)
@@ -11,7 +11,7 @@
 </ng-template>
 
 <ng-template #providerTmpl let-purchaseorder="row">
-  <a href="/eg/staff/admin/acq/conify/provider/{{purchaseorder.provider().id()}}"
+  <a routerLink="/staff/acq/provider/{{purchaseorder.provider().id()}}"
      target="_blank">
     {{purchaseorder.provider().code()}}
   </a>
index c6d7307..c0f8961 100644 (file)
     <eg-link-table-link i18n-label label="Line Item MARC Attribute Definitions"
       routerLink="/staff/admin/acq/lineitem_marc_attr_definition"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Providers"
-      url="/eg/staff/admin/acq/conify/provider"></eg-link-table-link>
-      <!-- TODO
-      routerLink="/staff/admin/acq/provider"></eg-link-table-link>
-      -->
+      routerLink="/staff/acq/provider"></eg-link-table-link>
   </eg-link-table>
 </div>
 
index 372299a..ad4a3d8 100644 (file)
             <span i18n>General Search</span>
           </a>
           <div class="dropdown-divider"></div>
+          <a class="dropdown-item" 
+            routerLink="/staff/acq/provider">
+            <span class="material-icons" aria-hidden="true">search</span>
+            <span i18n>Provider Search</span>
+          </a>
+          <div class="dropdown-divider"></div>
           <a class="dropdown-item"
             routerLink="/staff/acq/search/selectionlists">
             <span class="material-icons" aria-hidden="true">view_list</span>
index fab8407..62e1fb0 100644 (file)
@@ -79,6 +79,7 @@
                                 onClick="location.href = '[% ctx.base_path %]/acq/funding_source/list';">[% l('Funding Sources') %]</div>
                         <div dojoType="dijit.MenuItem" iconClass="dijitEditorIcon dijitEditorIconCopy" 
                                 onClick="location.href = '[% ctx.base_path %]/conify/global/acq/provider';">[% l('Providers') %]</div>
+                                <!-- what about this one? onClick="location.href = '/eg2/staff/acq/provider';">[% l('Providers') %]</div> -->
                         <div dojoType="dijit.MenuItem" iconClass="dijitEditorIcon dijitEditorIconCopy" 
                                 onClick="location.href = '[% ctx.base_path %]/acq/currency_type/list';">[% l('Currency Types') %]</div>
                         <div dojoType="dijit.MenuItem" iconClass="dijitEditorIcon dijitEditorIconCopy" 
index 76ca28a..d51eb7a 100644 (file)
@@ -27,7 +27,7 @@
     ,[ l('Invoice Payment Method'), "./admin/acq/conify/invoice_payment_method" ]
     ,[ l('Line Item Alerts'), "./admin/acq/conify/lineitem_alert" ]
     ,[ l('Line Item MARC Attribute Definitions'), "./admin/acq/conify/lineitem_marc_attr_def" ]
-    ,[ l('Providers'), "./admin/acq/conify/provider" ]
+    ,[ l('Providers'), "/eg2/admin/acq/provider" ]
    ];
 
    USE table(interfaces, cols=3);
index cea8d68..6ccd5b4 100644 (file)
               <span class="glyphicon glyphicon-search" aria-hidden="true"></span>
               [% l('General Search') %]
             </a>
+          <li class="divider"></li>
+          <li>
+            <a href="/eg2/staff/acq/provider" target="_self">
+              <span class="glyphicon glyphicon-search" aria-hidden="true"></span>
+              [% l('Provider Search') %]
+            </a>
           </li>
           <li class="divider"></li>
           <li>