ACQPRO final squash
authorGalen Charlton <gmc@equinoxinitiative.org>
Tue, 3 Mar 2020 23:18:55 +0000 (18:18 -0500)
committerGalen Charlton <gmc@equinoxinitiative.org>
Wed, 2 Sep 2020 17:09:39 +0000 (13:09 -0400)
ACQPRO squash #11

ACQPRO squash #10

ang providers: create base modules and components

acq providers: allow search form and details view to share a tab

start work on AcqProviderSearch

ang provider search form component

fix thinko

more work on hooking up search service and search form

TODO:
- contact name searching
- get the org-family-selector to show the starting value
  of the WS OU
- refactor how form parameters are managed?

WIP: complex, cross-table searches

Require classes for search; be very verbose for typescript

make ng lint happier

toward summary pane

add summary pane component to module and a missing decorator

initial stab at provider record service

set active provider ID when a row is selected

Note - not changing the route for now

address and contact stubs

actually push out initial version of provider record service

hook up contact and provider components to record service

add contact and address components to their tabs

refresh record service upon ID change

provider address modal

provider record service updates

- fetch() now returns a promise
- added refreshCurrent();
- added batchUpdate()

update provider address component to reflect updates to provider record service

ACQPRO squash #8

default to valid == true when creating new provider addresses

contacts modal

update provider contacts component to reflect updates in provider record service

provider record service: add currentProviderId

This works around some race conditions fetching the current
provider upon initialization; we should fix this so that
current() can account for in-flight initialization or
refreshes.

add provider invoices component

provider purchase orders component

ACQPRO squash #7

hide provider column by default in contact and address grids...

... but if the user insists on displaying that column, redundantly,
flesh it so that the code is displayed

move provider search form

The provider search form is now outside of the tabset and its
visibility is controlled by a button. Also, clicking on a row
on the search results will now hide the search form.

after selecting a provider from the search form, update the route

hide the provider info tabset if there's no selected provider

add a bit of separation

anq holdings component

fix adding new holdings subfield mappings

ACQPRO squash #12

new provder modal

start work on provider details tab

TODO: teach the record editor how to do a two-column layout
TODO: add delete button

Attributes tab

XXX: Stub function to allow compilation

toggle for provider summary pane

stretch provider UI when summary is collapsed

also hide Delete Provider button when collapsed to give more opportunity for stretch

use materials icons expand_less/expand_more

contact addresses component

ACQPRO squash #6

ensure that provider contact addresses component gets found before reloading its grid

Refresh contact addresses from updated service data

basic EDI account management

update summary after editing provider

add grid for displaying EDI messages for a selected account

adjust display of URL in summary pane

* hyperlink URL
* do not include an empty anchor if no URL is supplied

summary: display active flag using eg-bool

always pass through the state of the 'active' field on the search form

add placeholders for the eg-comboboxes on the search form

tweak display of contact role in sidebar

add field help to the EDI account modal

add a route resolver to ensure that the provider has been fetched when linking by ID

update summary when contacts are edited

Make provider addresses, contacts, and contact addresses filterable

add 'View EDI Messages' grid toolbar button

implement set default view button

fix

display provider name and code even when summary is hidden

use experimental remain open on error

ACQPRO squash #5

WIP: edit tag on holdings tab

Alphabetize the Holdings Definition dropdown (32)

Relabel holdings definitions for consistency (26)

Fix thinko to allow Currency Type searching (30)

Direct provider (and shipper) links in search and provider interfaces at the new Angular UI (24)

Fix thinko with non-pcrud grid paging (31)

adjust links to providers so that user-specified default tab is displayed

add human-friendly labels for the name column in the holdings definitions grid

improve handling of currency type (LH#30)

- display currency label in the summary
- fix ability to edit it in the details fm-editor
- preload the currency drop-down

improve display of the EDI Default field (LH#23)

flesh provider in the provider EDI account grid (LH#23)

(though this is a bit redundant since the list of EDI accounts
 displayed is restricted to the ones owned by the current
 provider)

add grid config to ang

ACQPRO squash #4

tweak columns in addresses tab (LH#25)

- set a default column order
- don't display the ID column by default

ACQPRO squash #3

tweak provider contact addresses

Following LH#25

change input for Active? on search form to a tri-value select (LH#16)

fix styling of clear form button

ACQPRO squash #2

sort by provider name by default (LH#14)

submit search form immediately (LH#14)

TODO: see if we can defer this if all we've done is gone
      directly to a specific provider

immediately run search only of search form starts off being visble (LH#14)

LH#38: add mechanism to detect if provider can be deleted

LH#6: change "Clear Form" => "Reset Form"

LH#41: add column to EDI account table to indicate which account is the provider's default

LH#12 various styling improvements

- clean up how the hide search form and new provider column flows
- add a bit of padding below the Set Default View button
- change the label of the Owning Library search input to Owner
- keep the Owner label and OU search input on same line for narrower screens

LH#8 turn on asyncSupportsEmptyTermClick for some fields in search form

Specifically: EDI Default and Currency

LH#8 preload EDI default default claim policy on provider create/update forms

LH#1: match responsive breakpoint of summary pane and main page

LH#1 tweaks to display of search form and summary

- search form now displays above the summary and vendor
- Delete Provider button now only displayed where there is an active provider
- no traces of the summary pane appear when entering the interface fresh

improve record retrieval and refreshes

- reduce the number of redundant fetches
- use subscriptions to inform child components when
  they need to refresh themselves

update PO and LI components to use AttrDefsService

wire up functionality for primary contact

don't show the primary contact field on the create modal

send toast and navigate away upon deleting provider

quell some console noise

clear search form and redo default search if same-URL-navigated

account for staff users who do not have the ADMIN_PROVIDER permission

TODO: make the Delete Provider and New Provider buttons be deactivated
      or not appear as relevant.

LH#15: tweaks to primary contact functionality

- added unset as primary action
- fixed confirmation modals
- more selectively enable and disable the set and unset actions

LH#9: tweak click actions on provider results form

- single click: retrieve provider but do not hide search form
- double click: retrieve provider and hide search form

LH#9: add explicit 'Retrieve Provider' action

LH#4: add sticky expand/collapse of search form

LH#21: tweak holding tag form

LH#17: warn if attempting to leave provider edits in flight

acqpro SQUASH #1

avoid glitch in low-permission mode

more tweaks for low permissions scenarios

LH#29: ensure that we don't search for providers that we can't retrieve

handle permissions coming in late

LH#27: navigate to search page if given direct link to provider that cannot be retrieved

37 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

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..d4fa8d1
--- /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)" (desireSummaryOnly)="onDesireSummarize($event, true)"></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..7771c97
--- /dev/null
@@ -0,0 +1,192 @@
+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) => 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) => {
+            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();
+                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..ddef6e0
--- /dev/null
@@ -0,0 +1,325 @@
+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 = {};
+        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..7b2ae2f
--- /dev/null
@@ -0,0 +1,223 @@
+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 = {};
+        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..c3ce484
--- /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, true)"
+  (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..2851a2c
--- /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, updateSummaryOnly?: boolean) => void;
+    @Output() desireSummarize: EventEmitter<number> = new EventEmitter<number>();
+    @Output() desireSummaryOnly: 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, updateSummaryOnly = false) => {
+            if (updateSummaryOnly) {
+                this.desireSummaryOnly.emit(row.id());
+            } else {
+                this.desireSummarize.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>