LP1929741 ACQ Selection List & PO Angluar Port
authorBill Erickson <berickxx@gmail.com>
Thu, 8 Oct 2020 22:22:12 +0000 (18:22 -0400)
committerJane Sandberg <js7389@princeton.edu>
Sun, 2 Oct 2022 15:02:49 +0000 (08:02 -0700)
New selection list UI
New PO UI
New Lineitem worksheet UI with stub print template
New PO print UI with stub print template
New brief record UI

Signed-off-by: Bill Erickson <berickxx@gmail.com>
Signed-off-by: Galen Charlton <gmc@equinoxOLI.org>
Signed-off-by: Jane Sandberg <js7389@princeton.edu>
76 files changed:
Open-ILS/examples/fm_IDL.xml
Open-ILS/src/eg2/src/app/share/catalog/marc-html.component.ts
Open-ILS/src/eg2/src/app/share/combobox/combobox.component.html
Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts
Open-ILS/src/eg2/src/app/share/item-location-select/item-location-select.component.html
Open-ILS/src/eg2/src/app/share/item-location-select/item-location-select.component.ts
Open-ILS/src/eg2/src/app/share/item-location-select/item-location-select.module.ts
Open-ILS/src/eg2/src/app/share/item-location-select/item-location-select.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts
Open-ILS/src/eg2/src/app/share/print/print.component.ts
Open-ILS/src/eg2/src/app/share/print/print.service.ts
Open-ILS/src/eg2/src/app/staff/acq/lineitem/batch-copies.component.css [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/lineitem/batch-copies.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/lineitem/batch-copies.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/lineitem/brief-record.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/lineitem/brief-record.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/lineitem/cancel-dialog.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/lineitem/cancel-dialog.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/lineitem/copies.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/lineitem/copies.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/lineitem/copy-attrs.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/lineitem/copy-attrs.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/lineitem/detail.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/lineitem/detail.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/lineitem/history.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/lineitem/history.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem-list.component.css [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem-list.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem-list.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/lineitem/notes.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/lineitem/notes.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/lineitem/order-summary.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/lineitem/order-summary.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/lineitem/worksheet.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/lineitem/worksheet.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/picklist/picklist.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/picklist/picklist.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/picklist/picklist.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/picklist/routing.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/picklist/summary.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/picklist/summary.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/po/charges.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/po/charges.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/po/create.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/po/create.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/po/edi.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/po/edi.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/po/history.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/po/history.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/po/notes.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/po/notes.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/po/po.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/po/po.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/po/po.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/po/po.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/po/print.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/po/print.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/po/routing.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/po/summary.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/po/summary.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/lineitem-results.component.html
Open-ILS/src/eg2/src/app/staff/acq/search/picklist-results.component.html
Open-ILS/src/eg2/src/app/staff/nav.component.html
Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts
Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-dialog.component.ts
Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts
Open-ILS/src/eg2/src/styles.css
Open-ILS/src/perlmods/lib/OpenILS/Application/Acq/Common.pm [new file with mode: 0644]
Open-ILS/src/perlmods/lib/OpenILS/Application/Acq/Lineitem.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Acq/Order.pm

index 832011b..947e7af 100644 (file)
@@ -10470,7 +10470,7 @@ SELECT  usr,
 
        <class id="acqliat" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="acq::lineitem_alert_text" oils_persist:tablename="acq.lineitem_alert_text" reporter:label="Line Item Alert Text">
                <fields oils_persist:primary="id" oils_persist:sequence="acq.lineitem_alert_text_id_seq">
-                       <field reporter:label="Alert Text ID" name="id" reporter:datatype="id"/>
+                       <field reporter:label="Alert Text ID" name="id" reporter:datatype="id" reporter:selector="code"/>
                        <field reporter:label="Code" name="code" reporter:datatype="text"/>
                        <field reporter:label="Description" name="description" reporter:datatype="text"/>
                        <field reporter:label="Owning Library" name="owning_lib" reporter:datatype="link"/>
index 38b1da7..03ef6d0 100644 (file)
@@ -22,9 +22,29 @@ export class MarcHtmlComponent implements OnInit {
         }
     }
 
-    recType: string;
+    get recordId(): number {
+        return this.recId;
+    }
+
+    private _recordXml: string;
+    @Input() set recordXml(xml: string) {
+        this._recordXml = xml;
+         if (this.initDone) {
+            this.collectData();
+        }
+    }
+
+    get recordXml(): string {
+        return this._recordXml;
+    }
+
+    private _recordType: string;
     @Input() set recordType(rtype: string) {
-        this.recType = rtype;
+        this._recordType = rtype;
+    }
+
+    get recordType(): string {
+        return this._recordType;
     }
 
     constructor(
@@ -34,18 +54,17 @@ export class MarcHtmlComponent implements OnInit {
     ) {}
 
     ngOnInit() {
-        this.initDone = true;
-        this.collectData();
+        this.collectData().then(_ => this.initDone = true);
     }
 
-    collectData() {
-        if (!this.recId) { return; }
+    collectData(): Promise<any> {
+        if (!this.recordId && !this.recordXml) { return Promise.resolve(); }
 
         let service = 'open-ils.search';
         let method = 'open-ils.search.biblio.record.html';
-        const params: any[] = [this.recId];
+        let params: any[] = [this.recordId];
 
-        switch (this.recType) {
+        switch (this.recordType) {
 
             case 'authority':
                 method = 'open-ils.search.authority.to_html';
@@ -64,7 +83,13 @@ export class MarcHtmlComponent implements OnInit {
                 break;
         }
 
-        this.net.requestWithParamList(service, method, params)
+        // Bib/auth variants support generating HTML directly from MARC XML
+        if (!this.recordId && (
+            this.recordType === 'bib' || this.recordType === 'authority')) {
+            params = [null, null, this.recordXml];
+        }
+
+        return this.net.requestWithParamList(service, method, params)
         .toPromise().then(html => this.injectHtml(html));
     }
 
index c48d981..e498670 100644 (file)
   </span>
 </ng-template>
 
-<div class="d-flex">
-  <input type="text" 
-    class="form-control"
-    [id]="domId"
-    [ngClass]="{
-      'text-success font-italic font-weight-bold': selected && selected.freetext,
-      'form-control-sm': smallFormControl
-    }"
-    [placeholder]="placeholder"
-    [name]="name"
-    [disabled]="isDisabled"
-    [required]="isRequired"
-    [(ngModel)]="selected" 
-    [ngbTypeahead]="filter"
-    [resultTemplate]="getResultTemplate()"
-    [inputFormatter]="formatDisplayString"
-    (click)="onClick($event)"
-    (blur)="onBlur()"
-    (keyup.arrowdown)="onClick($event)"
-    container="body"
-    (selectItem)="selectorChanged($event)"
-    #instance="ngbTypeahead"/>
-  <div class="d-flex flex-column icons" (click)="openMe($event)">
-    <span class="material-icons">keyboard_arrow_up</span>
-    <span class="material-icons">keyboard_arrow_down</span>
+<ng-container *ngIf="readOnly && selected">
+  <ng-container *ngTemplateOutlet="getResultTemplate();context:{result: selected}">
+  </ng-container>
+</ng-container>
+
+<ng-container *ngIf="!readOnly">
+  <div class="d-flex">
+    <input type="text" 
+      class="form-control"
+      [id]="domId"
+      [ngClass]="{
+        'text-success font-italic font-weight-bold': selected && selected.freetext,
+        'form-control-sm': smallFormControl
+      }"
+      [placeholder]="placeholder"
+      [name]="name"
+      [disabled]="isDisabled"
+      [required]="isRequired"
+      [(ngModel)]="selected" 
+      [ngbTypeahead]="filter"
+      [resultTemplate]="getResultTemplate()"
+      [inputFormatter]="formatDisplayString"
+      (click)="onClick($event)"
+      (blur)="onBlur()"
+      container="body"
+      (selectItem)="selectorChanged($event)"
+      #instance="ngbTypeahead"/>
+    <div class="d-flex flex-column icons" (click)="openMe($event)">
+      <span class="material-icons">keyboard_arrow_up</span>
+      <span class="material-icons">keyboard_arrow_down</span>
+    </div>
   </div>
-</div>
+</ng-container>
index dd46395..da4ad68 100644 (file)
@@ -54,8 +54,8 @@ export class ComboboxComponent implements ControlValueAccessor, OnInit, AfterVie
     click$: Subject<string>;
     entrylist: ComboboxEntry[];
 
-    @ViewChild('instance', { static: true }) instance: NgbTypeahead;
-    @ViewChild('defaultDisplayTemplate', { static: true}) defaultDisplayTemplate: TemplateRef<any>;
+    @ViewChild('instance', {static: false}) instance: NgbTypeahead;
+    @ViewChild('defaultDisplayTemplate', {static: true}) defaultDisplayTemplate: TemplateRef<any>;
     @ViewChildren(IdlClassTemplateDirective) idlClassTemplates: QueryList<IdlClassTemplateDirective>;
 
     @Input() domId = 'eg-combobox-' + ComboboxComponent.domIdAuto++;
@@ -111,6 +111,10 @@ export class ComboboxComponent implements ControlValueAccessor, OnInit, AfterVie
     // when fetching objects by idlClass.
     @Input() idlQueryAnd: {[field: string]: any};
 
+    // Display the selected value as text instead of within
+    // the typeahead
+    @Input() readOnly = false;
+
     // Allow the selected entry ID to be passed via the template
     // This does NOT not emit onChange events.
     @Input() set selectedId(id: any) {
index 5f7e388..d452fa7 100644 (file)
@@ -7,8 +7,11 @@
 <eg-string #unsetString text="<Unset>" i18n-text></eg-string>
 
 <eg-combobox #comboBox
+  [asyncDataSource]="loadAsync ? getLocationsAsyncHandler : null"
   [domId]="domId"
   [startId]="startId"
+  [disabled]="disabled"
+  [readOnly]="readOnly"
   [displayTemplate]="displayTemplate"
   (onChange)="cboxChanged($event)"
   [required]="required"
index 1cced45..cd99267 100644 (file)
@@ -1,8 +1,8 @@
 import {Component, OnInit, AfterViewInit, Input, Output, ViewChild,
     EventEmitter, forwardRef} from '@angular/core';
 import {ControlValueAccessor, FormGroup, FormControl, NG_VALUE_ACCESSOR} from '@angular/forms';
-import {Observable} from 'rxjs';
-import {map} from 'rxjs/operators';
+import {Observable, from, of} from 'rxjs';
+import {tap, map, switchMap} from 'rxjs/operators';
 import {IdlObject} from '@eg/core/idl.service';
 import {OrgService} from '@eg/core/org.service';
 import {AuthService} from '@eg/core/auth.service';
@@ -10,6 +10,7 @@ import {PermService} from '@eg/core/perm.service';
 import {PcrudService} from '@eg/core/pcrud.service';
 import {ComboboxComponent, ComboboxEntry} from '@eg/share/combobox/combobox.component';
 import {StringComponent} from '@eg/share/string/string.component';
+import {ItemLocationService} from './item-location-select.service';
 
 /**
  * Item (Copy) Location Selector.
@@ -67,30 +68,46 @@ export class ItemLocationSelectComponent
     @Input() domId = 'eg-item-location-select-' +
         ItemLocationSelectComponent.domIdAuto++;
 
+    // If false, selector will be click-able
+    @Input() loadAsync = true;
+
+    @Input() disabled = false;
+
+    // Display the selected value as text instead of within
+    // the typeahead
+    @Input() readOnly = false;
+
     @ViewChild('comboBox', {static: false}) comboBox: ComboboxComponent;
     @ViewChild('unsetString', {static: false}) unsetString: StringComponent;
 
-    startId: number = null;
+    @Input() startId: number = null;
     filterOrgs: number[] = [];
-    cache: {[id: number]: IdlObject} = {};
+    filterOrgsApplied = false;
 
     initDone = false; // true after first data load
     propagateChange = (id: number) => {};
     propagateTouch = () => {};
 
+    getLocationsAsyncHandler = term => this.getLocationsAsync(term);
+
     constructor(
         private org: OrgService,
         private auth: AuthService,
         private perm: PermService,
-        private pcrud: PcrudService
+        private pcrud: PcrudService,
+        private loc: ItemLocationService
     ) {
         this.valueChange = new EventEmitter<IdlObject>();
     }
 
     ngOnInit() {
-        this.setFilterOrgs()
-        .then(_ => this.getLocations())
-        .then(_ => this.initDone = true);
+        if (this.loadAsync) {
+            this.initDone = true;
+        } else {
+            this.setFilterOrgs()
+            .then(_ => this.getLocations())
+            .then(_ => this.initDone = true);
+        }
     }
 
     ngAfterViewInit() {
@@ -136,13 +153,65 @@ export class ItemLocationSelectComponent
 
         return this.pcrud.search('acpl', search, {order_by: {acpl: 'name'}}
         ).pipe(map(loc => {
-            this.cache[loc.id()] = loc;
+            this.loc.locationCache[loc.id()] = loc;
             entries.push({id: loc.id(), label: loc.name(), userdata: loc});
         })).toPromise().then(_ => {
             this.comboBox.entries = entries;
         });
     }
 
+    getLocationsAsync(term: string): Observable<ComboboxEntry> {
+
+        let obs = of();
+        if (!this.filterOrgsApplied) {
+            // Apply filter orgs the first time they are needed.
+            obs = from(this.setFilterOrgs());
+        }
+
+        return obs.pipe(switchMap(_ => this.getLocationsAsync2(term)));
+    }
+
+    getLocationsAsync2(term: string): Observable<ComboboxEntry> {
+
+        if (this.filterOrgs.length === 0) {
+            return of();
+        }
+
+        const search: any = {
+            deleted: 'f',
+            name: {'ilike': `%${term}%`}
+        };
+
+        if (this.startId) {
+            // Guarantee we have the load-time copy location, which
+            // may not be included in the org-scoped set of locations
+            // we fetch by default.
+            search['-or'] = [
+                {id: this.startId},
+                {owning_lib: this.filterOrgs}
+            ];
+        } else {
+            search.owning_lib = this.filterOrgs;
+        }
+
+        return new Observable<ComboboxEntry>(observer => {
+            if (!this.required) {
+                observer.next({id: null, label: this.unsetString.text});
+            }
+
+            this.pcrud.search('acpl', search, {order_by: {acpl: 'name'}}
+            ).subscribe(
+                loc => {
+                    this.loc.locationCache[loc.id()] = loc;
+                    observer.next({id: loc.id(), label: loc.name(), userdata: loc});
+                },
+                err => {},
+                () => observer.complete()
+            );
+        });
+    }
+
+
     registerOnChange(fn) {
         this.propagateChange = fn;
     }
@@ -154,7 +223,7 @@ export class ItemLocationSelectComponent
     cboxChanged(entry: ComboboxEntry) {
         const id = entry ? entry.id : null;
         this.propagateChange(id);
-        this.valueChange.emit(id ? this.cache[id] : null);
+        this.valueChange.emit(id ? this.loc.locationCache[id] : null);
     }
 
     writeValue(id: number) {
@@ -166,13 +235,23 @@ export class ItemLocationSelectComponent
     }
 
     getOneLocation(id: number) {
-        if (!id || this.cache[id]) { return Promise.resolve(); }
+        if (!id) { return Promise.resolve(); }
+
+        const promise = this.loc.locationCache[id] ?
+            Promise.resolve(this.loc.locationCache[id]) :
+            this.pcrud.retrieve('acpl', id).toPromise();
+
+        return promise.then(loc => {
+
+            this.loc.locationCache[loc.id()] = loc;
+            const entry: ComboboxEntry = {
+                id: loc.id(), label: loc.name(), userdata: loc};
 
-        return this.pcrud.retrieve('acpl', id).toPromise()
-        .then(loc => {
-            this.cache[loc.id()] = loc;
-            this.comboBox.addAsyncEntry(
-                {id: loc.id(), label: loc.name(), userdata: loc});
+            if (this.comboBox.entries) {
+                this.comboBox.entries.push(entry);
+            } else {
+                this.comboBox.entries = [entry];
+            }
         });
     }
 
@@ -188,10 +267,17 @@ export class ItemLocationSelectComponent
         let orgIds = [];
         contextOrgIds.forEach(id => orgIds = orgIds.concat(this.org.ancestors(id, true)));
 
+        this.filterOrgsApplied = true;
+
         if (!this.permFilter) {
             return Promise.resolve(this.filterOrgs = [...new Set(orgIds)]);
         }
 
+        const orgsFromCache = this.loc.filterOrgsCache[this.permFilter];
+        if (orgsFromCache) {
+            return Promise.resolve(this.filterOrgs = orgsFromCache);
+        }
+
         return this.perm.hasWorkPermAt([this.permFilter], true)
         .then(values => {
             // Include ancestors of perm-approved org units (shared item locations)
@@ -204,7 +290,10 @@ export class ItemLocationSelectComponent
                 }
             });
 
-            return this.filterOrgs = [...new Set(trimmedOrgIds)];
+            this.filterOrgs = [...new Set(trimmedOrgIds)];
+            this.loc.filterOrgsCache[this.permFilter] = this.filterOrgs;
+
+            return this.filterOrgs;
         });
     }
 
index f82989a..cf68791 100644 (file)
@@ -4,6 +4,7 @@ import {EgCoreModule} from '@eg/core/core.module';
 import {CommonWidgetsModule} from '@eg/share/common-widgets.module';
 import {ItemLocationSelectComponent} from './item-location-select.component';
 import {ReactiveFormsModule} from '@angular/forms';
+import {ItemLocationService} from './item-location-select.service';
 
 @NgModule({
     declarations: [
@@ -19,6 +20,7 @@ import {ReactiveFormsModule} from '@angular/forms';
         ItemLocationSelectComponent
     ],
     providers: [
+        ItemLocationService
     ]
 })
 
diff --git a/Open-ILS/src/eg2/src/app/share/item-location-select/item-location-select.service.ts b/Open-ILS/src/eg2/src/app/share/item-location-select/item-location-select.service.ts
new file mode 100644 (file)
index 0000000..228b3a7
--- /dev/null
@@ -0,0 +1,14 @@
+import {Injectable, EventEmitter} from '@angular/core';
+import {Observable} from 'rxjs';
+import {switchMap, map} from 'rxjs/operators';
+import {IdlObject, IdlService} 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';
+
+@Injectable()
+export class ItemLocationService {
+
+    filterOrgsCache: {[perm: string]: number[]} = {};
+    locationCache: {[id: number]: IdlObject} = {};
+}
index 93dee29..e0a4e8f 100644 (file)
@@ -146,6 +146,10 @@ export class OrgSelectComponent implements OnInit {
         return this.org.get(this.selected.id);
     }
 
+    selectedOrgId(): number {
+        return this.selected ? this.selected.id : null;
+    }
+
     constructor(
       private auth: AuthService,
       private store: StoreService,
index faf8acd..33554e3 100644 (file)
@@ -116,7 +116,10 @@ export class PrintComponent implements OnInit {
             this.applyTemplate(printReq).then(() => {
                 // Give templates a chance to render before printing
                 setTimeout(() => {
-                    this.dispatchPrint(printReq).then(__ => this.reset());
+                    this.dispatchPrint(printReq).then(__ => {
+                        this.reset();
+                        this.printer.printJobQueued$.emit(printReq);
+                    });
                 });
             });
         });
index 25ef206..5723a4c 100644 (file)
@@ -31,12 +31,18 @@ export class PrintService {
 
     onPrintRequest$: EventEmitter<PrintRequest>;
 
+    // Emitted after a print request has been delivered to Hatch or
+    // window.print() has completed.  Note window.print() returning
+    // is not necessarily an indication the job has completed.
+    printJobQueued$: EventEmitter<PrintRequest>;
+
     constructor(
         private locale: LocaleService,
         private auth: AuthService,
         private store: StoreService
     ) {
         this.onPrintRequest$ = new EventEmitter<PrintRequest>();
+        this.printJobQueued$ = new EventEmitter<PrintRequest>();
     }
 
     print(printReq: PrintRequest) {
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/lineitem/batch-copies.component.css b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/batch-copies.component.css
new file mode 100644 (file)
index 0000000..1dbbab3
--- /dev/null
@@ -0,0 +1,5 @@
+
+
+.batch-copy-row:nth-child(even) {
+  background-color: rgba(0,0,0,.03);
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/lineitem/batch-copies.component.html b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/batch-copies.component.html
new file mode 100644 (file)
index 0000000..615ddb0
--- /dev/null
@@ -0,0 +1,57 @@
+
+<eg-confirm-dialog #confirmAlertsDialog
+  i18n-dialogTitle i18n-dialogBody
+  dialogTitle="Confirm Alert" dialogBody="{{alertText ? alertText.code() : ''}}">
+</eg-confirm-dialog>
+
+<eg-acq-cancel-dialog #cancelDialog></eg-acq-cancel-dialog>
+
+<!-- Note the flex values are set so they also match the layout
+     of the list of copies in the copies component. -->
+
+<ng-template #copyAttrsHeader let-hideBarcode="hideBarcode" let-moreCss="moreCss">
+  <div class="div d-flex font-weight-bold {{moreCss}}">
+    <div class="flex-1 p-1" i18n>Owning Branch</div>  
+    <div class="flex-1 p-1" i18n>Copy Location</div>
+    <div class="flex-1 p-1" i18n>Collection Code</div>
+    <div class="flex-1 p-1" i18n>Fund</div>
+    <div class="flex-1 p-1" i18n>Circ Modifier</div>
+    <div class="flex-1 p-1" i18n>Callnumber</div>
+    <div class="flex-1 p-1" i18n>
+      <ng-container *ngIf="!hideBarcode">Barcode</ng-container>
+    </div>
+    <div class="flex-1 p-1"></div>
+    <div class="flex-1 p-1"></div>
+  </div>
+</ng-template>
+
+<ng-container
+  *ngTemplateOutlet="copyAttrsHeader;context:{
+    moreCss:'mt-3 bg-light border border-secondary',
+    hideBarcode: true
+  }">
+</ng-container>
+
+<div class="pt-2 bg-light border border-secondary border-top-0 rounded-bottom">
+  <eg-lineitem-copy-attrs (batchApplyRequested)="batchApplyAttrs($event)"
+    [batchMode]="true"> </eg-lineitem-copy-attrs>
+</div>
+
+<hr/>
+
+<ng-container *ngTemplateOutlet="copyAttrsHeader"> </ng-container>
+
+<div class="mt-1 pt-1 border-top">
+  <div class="batch-copy-row" *ngFor="let copy of lineitem.lineitem_details()">
+    <eg-lineitem-copy-attrs 
+      (receiveRequested)="receiveCopy($event)"
+      (unReceiveRequested)="unReceiveCopy($event)"
+      (deleteRequested)="deleteCopy($event)" 
+      (cancelRequested)="cancelCopy($event)"
+      [lineitem]="lineitem" [copy]="copy"></eg-lineitem-copy-attrs>
+  </div>
+</div>
+
+
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/lineitem/batch-copies.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/batch-copies.component.ts
new file mode 100644 (file)
index 0000000..592d723
--- /dev/null
@@ -0,0 +1,138 @@
+import {Component, OnInit, Input, Output, EventEmitter, ViewChild} from '@angular/core';
+import {tap} from 'rxjs/operators';
+import {Pager} from '@eg/share/util/pager';
+import {IdlObject, IdlService} from '@eg/core/idl.service';
+import {EventService} from '@eg/core/event.service';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {LineitemService} from './lineitem.service';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {LineitemCopyAttrsComponent} from './copy-attrs.component';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+import {CancelDialogComponent} from './cancel-dialog.component';
+
+const BATCH_FIELDS = [
+    'owning_lib',
+    'location',
+    'collection_code',
+    'fund',
+    'circ_modifier',
+    'cn_label'
+];
+
+@Component({
+  templateUrl: 'batch-copies.component.html',
+  selector: 'eg-lineitem-batch-copies',
+  styleUrls: ['batch-copies.component.css']
+})
+export class LineitemBatchCopiesComponent implements OnInit {
+
+    @Input() lineitem: IdlObject;
+
+    @ViewChild('confirmAlertsDialog') confirmAlertsDialog: ConfirmDialogComponent;
+    @ViewChild('cancelDialog') cancelDialog: CancelDialogComponent;
+
+    // Current alert that needs confirming
+    alertText: IdlObject;
+
+    constructor(
+        private evt: EventService,
+        private idl: IdlService,
+        private net: NetService,
+        private auth: AuthService,
+        private liService: LineitemService
+    ) {}
+
+    ngOnInit() {}
+
+    // Propagate values from the batch edit bar into the indivudual LID's
+    batchApplyAttrs(copyTemplate: IdlObject) {
+        BATCH_FIELDS.forEach(field => {
+            const val = copyTemplate[field]();
+            if (val === undefined) { return; }
+            this.lineitem.lineitem_details().forEach(copy => {
+                copy[field](val);
+                copy.ischanged(true); // isnew() takes precedence
+            });
+        });
+    }
+
+    deleteCopy(copy: IdlObject) {
+        if (copy.isnew()) {
+            // Brand new copies can be discarded
+            this.lineitem.lineitem_details(
+                this.lineitem.lineitem_details().filter(c => c.id() !== copy.id())
+            );
+        } else {
+            // Requires a Save Changes action.
+            copy.isdeleted(true);
+        }
+    }
+
+    refreshLineitem() {
+        this.liService.getFleshedLineitems([this.lineitem.id()], {toCache: true})
+        .subscribe(liStruct => this.lineitem = liStruct.lineitem);
+    }
+
+    handleActionResponse(resp: any) {
+        const evt = this.evt.parse(resp);
+        if (evt) {
+          alert(evt);
+        } else if (resp) {
+            this.refreshLineitem();
+        }
+    }
+
+    cancelCopy(copy: IdlObject) {
+        this.cancelDialog.open().subscribe(reason => {
+            if (!reason) { return; }
+            this.net.request('open-ils.acq',
+                'open-ils.acq.lineitem_detail.cancel',
+                this.auth.token(), copy.id(), reason
+            ).subscribe(ok => this.handleActionResponse(ok));
+        });
+    }
+
+    receiveCopy(copy: IdlObject) {
+        this.checkLiAlerts().then(ok => {
+            this.net.request(
+                'open-ils.acq',
+                'open-ils.acq.lineitem_detail.receive',
+                this.auth.token(), copy.id()
+            ).subscribe(ok2 => this.handleActionResponse(ok2));
+        }, err => {}); // avoid console errors
+    }
+
+    unReceiveCopy(copy: IdlObject) {
+        this.net.request(
+            'open-ils.acq',
+            'open-ils.acq.lineitem_detail.receive.rollback',
+            this.auth.token(), copy.id()
+        ).subscribe(ok => this.handleActionResponse(ok));
+    }
+
+    checkLiAlerts(): Promise<boolean> {
+
+        let promise = Promise.resolve(true);
+
+        const notes = this.lineitem.lineitem_notes().filter(note =>
+            note.alert_text() && !this.liService.alertAcks[note.id()]);
+
+        if (notes.length === 0) { return promise; }
+
+        notes.forEach(n => {
+            promise = promise.then(_ => {
+                this.alertText = n.alert_text();
+                return this.confirmAlertsDialog.open().toPromise().then(ok => {
+                    if (!ok) { return Promise.reject(); }
+                    this.liService.alertAcks[n.id()] = true;
+                    return true;
+                });
+            });
+        });
+
+        return promise;
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/lineitem/brief-record.component.html b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/brief-record.component.html
new file mode 100644 (file)
index 0000000..0dbe6db
--- /dev/null
@@ -0,0 +1,11 @@
+
+<h3 class="m-2" i18n>Add A Brief Record</h3>
+
+<div class="d-flex w-50 m-2" *ngFor="let attr of attrs">
+  <div class="flex-1">{{attr.description()}}</div>
+  <div class="flex-3">
+    <input class="form-control" type="text" [(ngModel)]="values[attr.id()]"/>
+  </div>
+</div>
+
+<button class="btn btn-success mt-2" (click)="save()" i18n>Add Record</button>
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/lineitem/brief-record.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/brief-record.component.ts
new file mode 100644 (file)
index 0000000..f1384da
--- /dev/null
@@ -0,0 +1,113 @@
+import {Component, OnInit, Input, Output} from '@angular/core';
+import {ActivatedRoute, Router, ParamMap} from '@angular/router';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {AuthService} from '@eg/core/auth.service';
+
+const MARC_NS = 'http://www.loc.gov/MARC21/slim';
+
+const MARC_XML_BASE = `
+<record xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xmlns="http://www.loc.gov/MARC21/slim"
+    xmlns:marc="http://www.loc.gov/MARC21/slim"
+    xsi:schemaLocation="http://www.loc.gov/MARC21/slim http://www.loc.gov/ standards/marcxml/schema/MARC21slim.xsd">
+    <leader>00000nam a22000007a 4500</leader>
+</record>
+`;
+
+@Component({
+  templateUrl: 'brief-record.component.html',
+  selector: 'eg-lineitem-brief-record'
+})
+export class BriefRecordComponent implements OnInit {
+
+    targetPicklist: number;
+    targetPo: number;
+
+    attrs: IdlObject[] = [];
+    values: {[attr: string]: string} = {};
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private idl: IdlService,
+        private auth: AuthService,
+        private net: NetService,
+        private pcrud: PcrudService
+    ) { }
+
+    ngOnInit() {
+
+        this.route.parent.paramMap.subscribe((params: ParamMap) => {
+            this.targetPicklist = +params.get('picklistId');
+            this.targetPo = +params.get('poId');
+        });
+
+        this.pcrud.retrieveAll('acqlimad')
+        .subscribe(attr => this.attrs.push(attr));
+    }
+
+    compile(): string {
+
+        const doc = new DOMParser().parseFromString(MARC_XML_BASE, 'text/xml');
+
+        this.attrs.forEach(attr => {
+            const value = this.values[attr.id()];
+            if (value === undefined) { return; }
+
+            const expr = attr.xpath();
+
+            // Logic copied from openils/MarcXPathParser.js
+            // Any 3 numbers are a 'tag'.
+            // Any letters are a subfield.
+            // Always use the first.
+            const tags = expr.match(/\d{3}/g);
+            let subfields = expr.match(/['"]([a-z]+)['"]/);
+            if (subfields) { subfields = subfields[1].split(''); }
+
+            const dfNode = doc.createElementNS(MARC_NS, 'marc:datafield');
+            const sfNode = doc.createElementNS(MARC_NS, 'marc:subfield');
+
+            // Append fields to the document
+            dfNode.setAttribute('tag', '' + tags[0]);
+            dfNode.setAttribute('ind1', ' ');
+            dfNode.setAttribute('ind2', ' ');
+            sfNode.setAttribute('code', '' + subfields[0]);
+            const tNode = doc.createTextNode(value);
+
+            sfNode.appendChild(tNode);
+            dfNode.appendChild(sfNode);
+            doc.documentElement.appendChild(dfNode);
+        });
+
+        return new XMLSerializer().serializeToString(doc);
+    }
+
+    save() {
+        const xml = this.compile();
+
+        const li = this.idl.create('jub');
+        li.marc(xml);
+
+        if (this.targetPicklist) {
+            li.picklist(this.targetPicklist);
+        } else if (this.targetPo) {
+            li.purchase_order(this.targetPo);
+        }
+
+        li.selector(this.auth.user().id());
+        li.creator(this.auth.user().id());
+        li.editor(this.auth.user().id());
+
+        this.net.request('open-ils.acq',
+            'open-ils.acq.lineitem.create', this.auth.token(), li
+        ).toPromise().then(_ => {
+            this.router.navigate(['../'], {
+                relativeTo: this.route,
+                queryParamsHandling: 'merge'
+            });
+        });
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/lineitem/cancel-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/cancel-dialog.component.html
new file mode 100644 (file)
index 0000000..23ae488
--- /dev/null
@@ -0,0 +1,23 @@
+<ng-template #dialogContent>
+  <form class="form-validated">
+    <div class="modal-header bg-info">
+      <h3 class="modal-title" i18n>Cancel</h3>
+      <button type="button" class="close"
+        i18n-aria-label aria-label="Close" (click)="close()">
+        <span aria-hidden="true">&times;</span>
+      </button>
+    </div>
+    <div class="modal-body">
+      <h4 i18n>Select a cancel reason:</h4>
+      <eg-combobox domId="acq-cancel-dialog" name="acq-cancel-dialog" 
+        idlClass="acqcr" [(ngModel)]="cancelReason"></eg-combobox>
+    </div>
+    <div class="modal-footer">
+      <button type="button" class="btn btn-success" [disabled]="!cancelReason" 
+        (click)="close(cancelReason.id)" i18n>Apply</button>
+      <button type="button" class="btn btn-warning"
+        (click)="close()" i18n>Exit Dialog</button>
+    </div>
+  </form>
+</ng-template>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/lineitem/cancel-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/cancel-dialog.component.ts
new file mode 100644 (file)
index 0000000..630f57d
--- /dev/null
@@ -0,0 +1,17 @@
+import {Component, Input, ViewChild, TemplateRef, OnInit} from '@angular/core';
+import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+
+@Component({
+  selector: 'eg-acq-cancel-dialog',
+  templateUrl: './cancel-dialog.component.html'
+})
+
+export class CancelDialogComponent extends DialogComponent {
+    cancelReason: number;
+    constructor(private modal: NgbModal) { super(modal); }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/lineitem/copies.component.html b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/copies.component.html
new file mode 100644 (file)
index 0000000..0f768d8
--- /dev/null
@@ -0,0 +1,63 @@
+
+<div class="row mt-3 mb-1">
+  <div class="col-lg-12 form-inline">
+
+    <label class="ml-3" for='copy-count-input' i18n>Item Count: </label>
+    <input class="form-control-sm ml-3 small" 
+      id='copy-count-input' [disabled]="liLocked"
+      [(ngModel)]="copyCount" type="text" (keyup.enter)="applyCount()"/>
+
+    <button class="btn btn-sm btn-outline-dark ml-3" 
+      [disabled]="liLocked" (click)="applyCount()" i18n>Apply</button>
+
+    <span class="ml-3" i18n> | </span>
+
+    <label class="ml-3" for='distrib-formula-cbox' i18n>Distribution Formulas</label>
+    <span class="ml-3">
+      <eg-combobox idlClass="acqdf" [idlQueryAnd]="formulaFilter" 
+        #distribFormCbox domId="distrib-formula-cbox">
+      </eg-combobox>
+    </span>
+    <button class="btn btn-sm btn-outline-dark ml-3" 
+      [disabled]="!distribFormCbox.selectedId || liLocked"
+      (click)="applyFormula(distribFormCbox.selectedId)" i18n>Apply</button>
+
+    <button class="btn btn-sm btn-success ml-auto" [disabled]="liLocked"
+      (click)="save()" i18n>Save Changes</button>
+
+  </div>
+</div>
+
+<hr class="m-1 p-1"/>
+
+<div class="col-lg-6 offset-lg-3" *ngIf="saving">
+  <eg-progress-inline [max]="progressMax" [value]="progressValue">
+  </eg-progress-inline>
+</div>
+
+<ng-container *ngIf="lineitem && !saving">
+
+  <div class="card tight-card" *ngIf="lineitem.distribution_formulas().length">
+    <div class="card-header" i18n>Distribution formulas applied to this lineitem</div>
+    <div class="card-body">
+      <ul class="p-0 m-0">
+        <li class="list-group-item p-0 m-0 border-0" 
+          *ngFor="let formula of lineitem.distribution_formulas()">
+          <div class="d-flex">
+            <button class="btn btn-outline-danger material-icon-button p-0 m-0"
+              (click)="deleteFormula(formula)" title="Delete Formula" i18n-title>
+              <span class="material-icons">delete</span>
+            </button>
+            <div class="ml-2">{{formula.create_time() | date:'short'}}</div>
+            <div class="ml-2">{{formula.creator().usrname()}}</div>
+            <div class="ml-2 flex-1">{{formula.formula().name()}}</div>
+          </div>
+        </li>
+      </ul>
+    </div>
+  </div>
+
+  <eg-lineitem-batch-copies [lineitem]="lineitem"></eg-lineitem-batch-copies>
+</ng-container>
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/lineitem/copies.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/copies.component.ts
new file mode 100644 (file)
index 0000000..efc64c6
--- /dev/null
@@ -0,0 +1,285 @@
+import {Component, OnInit, AfterViewInit, Input, Output, EventEmitter,
+  ViewChild} from '@angular/core';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {tap} from 'rxjs/operators';
+import {Pager} from '@eg/share/util/pager';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {OrgService} from '@eg/core/org.service';
+import {NetService} from '@eg/core/net.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {AuthService} from '@eg/core/auth.service';
+import {LineitemService} from './lineitem.service';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {ItemLocationService} from '@eg/share/item-location-select/item-location-select.service';
+
+const FORMULA_FIELDS = [
+    'owning_lib',
+    'location',
+    'fund',
+    'circ_modifier',
+    'collection_code'
+];
+
+interface FormulaApplication {
+    formula: IdlObject;
+    count: number;
+}
+
+@Component({
+  templateUrl: 'copies.component.html'
+})
+export class LineitemCopiesComponent implements OnInit, AfterViewInit {
+    static newCopyId = -1;
+
+    lineitemId: number;
+    lineitem: IdlObject;
+    copyCount = 1;
+    batchOwningLib: IdlObject;
+    batchFund: ComboboxEntry;
+    batchCopyLocId: number;
+    saving = false;
+    progressMax = 0;
+    progressValue = 0;
+    formulaFilter = {owner: []};
+    formulaOffset = 0;
+    formulaValues: {[field: string]: {[val: string]: boolean}} = {};
+
+    // Can any changes be applied?
+    liLocked = false;
+
+    constructor(
+        private route: ActivatedRoute,
+        private idl: IdlService,
+        private org: OrgService,
+        private net: NetService,
+        private pcrud: PcrudService,
+        private auth: AuthService,
+        private loc: ItemLocationService,
+        private liService: LineitemService
+    ) {}
+
+    ngOnInit() {
+
+        this.formulaFilter.owner =
+            this.org.fullPath(this.auth.user().ws_ou(), true);
+
+        this.route.paramMap.subscribe((params: ParamMap) => {
+            const id = +params.get('lineitemId');
+            if (id !== this.lineitemId) {
+                this.lineitemId = id;
+                if (id) { this.load(); }
+            }
+        });
+
+        this.liService.getLiAttrDefs();
+    }
+
+    load(): Promise<any> {
+        this.lineitem = null;
+        this.copyCount = 1;
+        return this.liService.getFleshedLineitems(
+            [this.lineitemId], {toCache: true, fromCache: true})
+        .pipe(tap(liStruct => this.lineitem = liStruct.lineitem)).toPromise()
+        .then(_ => {
+            this.liLocked =
+              this.lineitem.state().match(/on-order|received|cancelled/);
+        })
+        .then(_ => this.applyCount());
+    }
+
+    ngAfterViewInit() {
+        setTimeout(() => {
+            const node = document.getElementById('copy-count-input');
+            if (node) { (node as HTMLInputElement).select(); }
+        });
+    }
+
+    applyCount() {
+        const copies = this.lineitem.lineitem_details();
+        while (copies.length < this.copyCount) {
+            const copy = this.idl.create('acqlid');
+            copy.id(LineitemCopiesComponent.newCopyId--);
+            copy.isnew(true);
+            copy.lineitem(this.lineitem.id());
+            copies.push(copy);
+        }
+
+        if (copies.length > this.copyCount) {
+            this.copyCount = copies.length;
+        }
+    }
+
+    applyFormula(id: number) {
+
+        const copies = this.lineitem.lineitem_details();
+        if (this.formulaOffset >= copies.length) {
+            // We have already applied a formula entry to every item.
+            return;
+        }
+
+        this.formulaValues = {};
+
+        this.pcrud.retrieve('acqdf', id,
+            {flesh: 1, flesh_fields: {acqdf: ['entries']}})
+        .subscribe(formula => {
+
+            formula.entries(
+                formula.entries().sort((e1, e2) =>
+                    e1.position() < e2.position() ? -1 : 1));
+
+            let rowIdx = this.formulaOffset - 1;
+
+            while (++rowIdx < copies.length) {
+                this.formulateOneCopy(formula, rowIdx, true);
+            }
+
+            // No new values will be applied
+            if (!Object.keys(this.formulaValues)) { return; }
+
+            this.fetchFormulaValues().then(_ => {
+
+                let applied = 0;
+                let rowIdx2 = this.formulaOffset - 1;
+
+                while (++rowIdx2 < copies.length) {
+                    applied += this.formulateOneCopy(formula, rowIdx2);
+                }
+
+                if (applied) {
+                    this.formulaOffset += applied;
+                    this.saveAppliedFormula(formula);
+                }
+            });
+        });
+    }
+
+    saveAppliedFormula(formula: IdlObject) {
+        const app = this.idl.create('acqdfa');
+        app.lineitem(this.lineitem.id());
+        app.creator(this.auth.user().id());
+        app.formula(formula.id());
+
+        this.pcrud.create(app).toPromise().then(a => {
+            a.creator(this.auth.user());
+            a.formula(formula);
+            this.lineitem.distribution_formulas().push(a);
+        });
+    }
+
+    // Grab values applied by distribution formulas and cache them before
+    // applying them to their target copies, so the comboboxes, etc.
+    // are not required to go fetch them en masse / en duplicato.
+    fetchFormulaValues(): Promise<any> {
+
+        const funds = Object.keys(this.formulaValues.fund);
+        const mods = Object.keys(this.formulaValues.circ_modifier);
+        const locs = Object.keys(this.formulaValues.location);
+
+        let promise = Promise.resolve();
+
+        if (funds.length > 0) {
+            promise = promise.then(_ => {
+                return this.pcrud.search('acqf', {id: funds})
+                .pipe(tap(fund => {
+                    this.liService.fundCache[fund.id()] = fund;
+                    this.liService.batchOptionWanted.emit(
+                        {fund: {id: fund.id(), label: fund.code(), fm: fund}});
+                })).toPromise();
+            });
+        }
+
+        if (mods.length > 0) {
+            promise = promise.then(_ => {
+                return this.pcrud.search('ccm', {code: mods})
+                .pipe(tap(mod => {
+                    this.liService.circModCache[mod.code()] = mod;
+                    this.liService.batchOptionWanted.emit({circ_modifier:
+                        {id: mod.code(), label: mod.code(), fm: mod}});
+                })).toPromise();
+            });
+        }
+
+        if (locs.length > 0) {
+            promise = promise.then(_ => {
+                return this.pcrud.search('acpl', {id: locs})
+                .pipe(tap(loc => {
+                    this.loc.locationCache[loc.id()] = loc;
+                    this.liService.batchOptionWanted.emit({location:
+                        {id: loc.id(), label: loc.name(), fm: loc}});
+                })).toPromise();
+            });
+        }
+
+        return promise;
+    }
+
+    // Apply a formula entry to a single copy.
+    // extracOnly means we are only collecting the new values we wish to
+    // apply from the formula w/o applying them to the copy in question.
+    formulateOneCopy(formula: IdlObject,
+        rowIdx: number, extractOnly?: boolean): number {
+
+        let targetEntry = null;
+        let entryIdx = this.formulaOffset;
+        const copy = this.lineitem.lineitem_details()[rowIdx];
+
+        // Find the correct entry for the current copy.
+        formula.entries().forEach(entry => {
+            if (!targetEntry) {
+                entryIdx += entry.item_count();
+                if (entryIdx > rowIdx) {
+                    targetEntry = entry;
+                }
+            }
+        });
+
+        // We ran out of copies.
+        if (!targetEntry) { return 0; }
+
+        FORMULA_FIELDS.forEach(field => {
+            const val = targetEntry[field]();
+            if (val === undefined || val === null) { return; }
+
+            if (extractOnly) {
+                if (!this.formulaValues[field]) {
+                    this.formulaValues[field] = {};
+                }
+                this.formulaValues[field][val] = true;
+
+            } else {
+                copy[field](val);
+            }
+        });
+
+        return 1;
+    }
+
+    save() {
+        this.saving = true;
+        this.progressMax = null;
+        this.progressValue = 0;
+
+        this.liService.updateLiDetails(this.lineitem).subscribe(
+            struct => {
+                this.progressMax = struct.total;
+                this.progressValue++;
+            },
+            err => {},
+            () => this.load().then(_ => {
+                this.liService.activateStateChange.emit(this.lineitem.id());
+                this.saving = false;
+            })
+        );
+    }
+
+    deleteFormula(formula: IdlObject) {
+        this.pcrud.remove(formula).subscribe(_ => {
+            this.lineitem.distribution_formulas(
+                this.lineitem.distribution_formulas()
+                .filter(f => f.id() !== formula.id())
+            );
+        });
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/lineitem/copy-attrs.component.html b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/copy-attrs.component.html
new file mode 100644 (file)
index 0000000..5e24456
--- /dev/null
@@ -0,0 +1,116 @@
+<!-- Flex values are set to align with lineitem copies UI 
+    and the batch copy editor component -->
+
+<div class="div d-flex batch-copy-row" *ngIf="copy">
+  <div class="flex-1 p-1">
+    <eg-org-select #owningLibSelect placeholder="Owning Branch..." 
+      i18n-placeholder [readOnly]="fieldIsDisabled('owning_lib')"
+      [applyOrgId]="copy.owning_lib()"
+      (onChange)="valueChange('owning_lib', $event)">
+    </eg-org-select>
+  </div>  
+  <div class="flex-1 p-1">
+    <eg-item-location-select  [readOnly]="fieldIsDisabled('location')"
+      #locationSelector [ngModel]="copy.location()"
+      (valueChange)="valueChange('location', $event)"
+      permFilter="CREATE_PICKLIST">                         
+    </eg-item-location-select>
+  </div>
+  <div class="flex-1 p-1">
+    <ng-container *ngIf="fieldIsDisabled('collection_code')">
+      <span>{{copy.collection_code()}}</span>
+    </ng-container>
+    <ng-container *ngIf="!fieldIsDisabled('collection_code')">
+      <input type="text" class="form-control" 
+        placeholder="Collection Code..." i18n-placeholder 
+        (ngModelChange)="valueChange('collection_code', $event)"
+        [ngModel]="copy.collection_code()"/>
+    </ng-container>
+  </div>
+  <div class="flex-1 p-1">
+    <eg-combobox idlClass="acqf" placeholder="Fund..." i18n-placeholder
+      [readOnly]="fieldIsDisabled('fund')"
+      #fundSelector [entries]="fundEntries"
+      [selectedId]="copy.fund()" (onChange)="valueChange('fund', $event)"
+      [idlQueryAnd]="{active: 't'}">
+    </eg-combobox>
+  </div>
+  <div class="flex-1 p-1">
+    <eg-combobox idlClass="ccm" placeholder="Circ Modifier..." i18n-placeholder
+      [readOnly]="fieldIsDisabled('circ_modifier')"
+      #circModSelector [entries]="circModEntries"
+      [selectedId]="copy.circ_modifier()"
+      (onChange)="valueChange('circ_modifier', $event)">
+    </eg-combobox>
+  </div>
+  <div class="flex-1 p-1">
+    <ng-container *ngIf="fieldIsDisabled('cn_label')">
+      <span>{{copy.cn_label()}}</span>
+    </ng-container>
+    <ng-container *ngIf="!fieldIsDisabled('cn_label')">
+      <input type="text" class="form-control" 
+        placeholder="Call Number..." i18n-placeholder
+        [ngModel]="copy.cn_label()" 
+        (ngModelChange)="valueChange('cn_label', $event)">
+    </ng-container>
+  </div>
+  <div class="flex-1 p-1">
+    <ng-container *ngIf="batchMode">
+      <button class="btn btn-outline-dark" 
+        (click)="batchApplyRequested.emit(copy)" i18n>Batch Update</button>
+    </ng-container>
+    <ng-container *ngIf="!batchMode">
+      <ng-container *ngIf="fieldIsDisabled('barcode')">
+        <span>{{copy.barcode()}}</span>
+      </ng-container>
+      <ng-container *ngIf="!fieldIsDisabled('barcode')">
+        <input type="text" class="form-control" 
+          [disabled]="fieldIsDisabled('barcode')" [ngModel]="copy.barcode()" 
+          placeholder="Barcode..." i18n-placeholder
+          (ngModelChange)="valueChange('barcode', $event)">
+      </ng-container>
+    </ng-container>
+  </div>
+  <ng-container *ngIf="!embedded">
+    <div class="flex-2 p-1 pr-2 pl-2">
+      <ng-container *ngIf="!batchMode">
+        <ng-container *ngIf="disposition() == 'pre-order'">
+          <button *ngIf="!copy.isdeleted()"
+            class="btn btn-outline-danger material-icon-button"
+            (click)="deleteRequested.emit(copy)" title="Delete Item" i18n-title>
+            <span class="material-icons">delete</span>
+          </button>
+          <button  *ngIf="copy.isdeleted()"
+            class="btn btn-outline-info material-icon-button"
+            (click)="copy.isdeleted(false)" title="Un-Delete Item" i18n-title>
+            <span class="material-icons">restore_page</span>
+          </button>
+        </ng-container>
+        <ng-container *ngIf="disposition() == 'on-order'">
+          <a href="javascript:;" (click)="receiveRequested.emit(copy)" i18n>Mark Received</a>
+        </ng-container>
+        <ng-container *ngIf="disposition() == 'received'">
+          <a href="javascript:;" (click)="unReceiveRequested.emit(copy)" i18n>Un-Receive</a>
+        </ng-container>
+        <ng-container *ngIf="disposition() == 'on-order'">
+          <a href="javascript:;" class="ml-2" (click)="cancelRequested.emit(copy)" i18n>Cancel</a>
+        </ng-container>
+        <ng-container *ngIf="disposition() == 'delayed'">
+          <a href="javascript:;" (click)="cancelRequested.emit(copy)" i18n>Cancel</a>
+        </ng-container>
+        <ng-container *ngIf="disposition() == 'delayed'">
+          <span class="font-italic ml-2" title="{{copy.cancel_reason().description()}}">
+            {{copy.cancel_reason().label()}}
+          </span>
+        </ng-container>
+        <ng-container *ngIf="disposition() == 'canceled'">
+          <span class="font-italic" title="{{copy.cancel_reason().description()}}">
+            {{copy.cancel_reason().label()}}
+          </span>
+        </ng-container>
+
+      </ng-container>
+    </div>
+  </ng-container>
+</div>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/lineitem/copy-attrs.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/copy-attrs.component.ts
new file mode 100644 (file)
index 0000000..94b9e6d
--- /dev/null
@@ -0,0 +1,172 @@
+import {Component, OnInit, AfterViewInit, ViewChild, Input, Output, EventEmitter} from '@angular/core';
+import {tap} from 'rxjs/operators';
+import {Pager} from '@eg/share/util/pager';
+import {IdlObject, IdlService} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {LineitemService} from './lineitem.service';
+import {ComboboxComponent, ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {ItemLocationService} from '@eg/share/item-location-select/item-location-select.service';
+import {ItemLocationSelectComponent} from '@eg/share/item-location-select/item-location-select.component';
+
+@Component({
+  templateUrl: 'copy-attrs.component.html',
+  selector: 'eg-lineitem-copy-attrs'
+})
+export class LineitemCopyAttrsComponent implements OnInit {
+
+    @Input() lineitem: IdlObject;
+    fundEntries: ComboboxEntry[];
+    circModEntries: ComboboxEntry[];
+
+    private _copy: IdlObject;
+    @Input() set copy(c: IdlObject) { // acqlid
+        if (c === undefined) {
+            return;
+        } else if (c === null) {
+            this._copy = null;
+        } else {
+            // Enture cbox entries are populated before the copy is
+            // applied so the cbox has the minimal set of values it
+            // needs at copy render time.
+            this.setInitialOptions(c);
+            this._copy = c;
+        }
+    }
+
+    get copy(): IdlObject {
+        return this._copy;
+    }
+
+    // A row of batch edit inputs
+    @Input() batchMode = false;
+
+    // One of several rows embedded in the main LI list page.
+    // Always read-only.
+    @Input() embedded = false;
+
+    // Emits an 'acqlid' object;
+    @Output() batchApplyRequested: EventEmitter<IdlObject> = new EventEmitter<IdlObject>();
+    @Output() deleteRequested: EventEmitter<IdlObject> = new EventEmitter<IdlObject>();
+    @Output() receiveRequested: EventEmitter<IdlObject> = new EventEmitter<IdlObject>();
+    @Output() unReceiveRequested: EventEmitter<IdlObject> = new EventEmitter<IdlObject>();
+    @Output() cancelRequested: EventEmitter<IdlObject> = new EventEmitter<IdlObject>();
+
+    @ViewChild('locationSelector') locationSelector: ItemLocationSelectComponent;
+    @ViewChild('circModSelector') circModSelector: ComboboxComponent;
+    @ViewChild('fundSelector') fundSelector: ComboboxComponent;
+
+    constructor(
+        private idl: IdlService,
+        private net: NetService,
+        private auth: AuthService,
+        private loc: ItemLocationService,
+        private liService: LineitemService
+    ) {}
+
+    ngOnInit() {
+
+        if (this.batchMode) { // stub batch copy
+            this.copy = this.idl.create('acqlid');
+            this.copy.isnew(true);
+
+        } else {
+
+            // When a batch selector value changes, duplicate the selected
+            // value into our selector entries, so if/when the value is
+            // chosen we (and our pile of siblings) are not required to
+            // re-fetch them from the server.
+            this.liService.batchOptionWanted.subscribe(option => {
+                const field = Object.keys(option)[0];
+                if (field === 'location') {
+                    this.locationSelector.comboBox.addAsyncEntry(option[field]);
+                } else if (field === 'circ_modifier') {
+                    this.circModSelector.addAsyncEntry(option[field]);
+                } else if (field === 'fund') {
+                    this.fundSelector.addAsyncEntry(option[field]);
+                }
+            });
+        }
+    }
+
+    valueChange(field: string, entry: ComboboxEntry) {
+
+        const announce: any = {};
+        this.copy.ischanged(true);
+
+        switch (field) {
+
+            case 'cn_label':
+            case 'barcode':
+            case 'collection_code':
+                this.copy[field](entry);
+                break;
+
+            case 'owning_lib':
+                this.copy[field](entry ? entry.id() : null);
+                break;
+
+            case 'location':
+                this.copy[field](entry ? entry.id() : null);
+                if (this.batchMode) {
+                    announce[field] = entry;
+                    this.liService.batchOptionWanted.emit(announce);
+                }
+                break;
+
+            case 'circ_modifier':
+            case 'fund':
+                this.copy[field](entry ? entry.id : null);
+                if (this.batchMode) {
+                    announce[field] = entry;
+                    this.liService.batchOptionWanted.emit(announce);
+                }
+                break;
+        }
+    }
+
+    // Tell our inputs about the values we know we need
+    // Values will be pre-cached in the liService
+    setInitialOptions(copy: IdlObject) {
+
+        if (copy.fund()) {
+            const fund = this.liService.fundCache[copy.fund()];
+            this.fundEntries = [{id: fund.id(), label: fund.code(), fm: fund}];
+        }
+
+        if (copy.circ_modifier()) {
+            const mod = this.liService.circModCache[copy.circ_modifier()];
+            this.circModEntries = [{id: mod.code(), label: mod.name(), fm: mod}];
+        }
+    }
+
+    fieldIsDisabled(field: string) {
+        if (this.batchMode) { return false; }
+
+        if (this.embedded || // inline expandy view
+            this.copy.isdeleted() ||
+            this.disposition() !== 'pre-order') {
+            return true;
+        }
+
+        return false;
+    }
+
+    disposition(): 'canceled' | 'delayed' | 'received' | 'on-order' | 'pre-order' {
+        if (!this.copy || !this.lineitem) {
+            return null;
+        } else if (this.copy.cancel_reason()) {
+            if (this.copy.cancel_reason().keep_debits() === 't') {
+                return 'delayed';
+            } else {
+                return 'canceled';
+            }
+        } else if (this.copy.recv_time()) {
+            return 'received';
+        } else if (this.lineitem.state() === 'on-order') {
+            return 'on-order';
+        } else { return 'pre-order'; }
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/lineitem/detail.component.html b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/detail.component.html
new file mode 100644 (file)
index 0000000..5bcba98
--- /dev/null
@@ -0,0 +1,48 @@
+<hr class="p-1"/>
+
+<ul ngbNav #liDetailNav="ngbNav" class="nav-tabs">
+  <li ngbNavItem="attrs">
+    <a ngbNavLink i18n>Attributes</a>
+    <ng-template ngbNavContent>
+      <ng-container *ngIf="lineitem">
+        <div class="mt-3">
+          <div class="row" *ngFor="let attr of lineitem.attributes()">
+            <div class="col-lg-2">{{attrLabel(attr)}}</div>
+            <div class="col-lg-10 border-left">{{attr.attr_value()}}</div>
+          </div>
+        </div>
+      </ng-container>
+    </ng-template>
+  </li>
+  <li ngbNavItem="marc-html">
+    <a ngbNavLink i18n>MARC View</a>
+    <ng-template ngbNavContent>
+      <ng-container *ngIf="lineitem">
+        <div class="mt-3">
+          <eg-marc-html recordType="bib" [recordId]="lineitem.eg_bib_id()" 
+            [recordXml]="lineitem.marc()"> </eg-marc-html>
+        </div>
+      </ng-container>
+    </ng-template>
+  </li>
+  <li ngbNavItem="marc-edit">
+    <a ngbNavLink i18n>MARC Edit</a>
+    <ng-template ngbNavContent>
+      <ng-container *ngIf="lineitem">
+        <div class="mt-3">
+          <div *ngIf="lineitem.eg_bib_id()" class="alert alert-warning" i18n>
+            Changes to lineitems that are linked to catalog records will
+            not result in changes to the cataloged record.
+          </div>
+          <eg-marc-editor [recordXml]="lineitem.marc()" [inPlaceMode]="true"
+            [recordType]="lineitem" (recordSaved)="saveMarcChanges($event)">
+          </eg-marc-editor>
+        </div>
+      </ng-container>
+    </ng-template>
+  </li>
+</ul>
+<div [ngbNavOutlet]="liDetailNav"></div>
+
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/lineitem/detail.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/detail.component.ts
new file mode 100644 (file)
index 0000000..4c4ff5e
--- /dev/null
@@ -0,0 +1,67 @@
+import {Component, OnInit, AfterViewInit, Input, Output, EventEmitter} from '@angular/core';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {tap} from 'rxjs/operators';
+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 {LineitemService, BatchLineitemStruct} from './lineitem.service';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+
+@Component({
+  templateUrl: 'detail.component.html'
+})
+export class LineitemDetailComponent implements OnInit {
+
+    lineitemId: number;
+    lineitem: IdlObject;
+    tab: string;
+
+    constructor(
+        private route: ActivatedRoute,
+        private net: NetService,
+        private auth: AuthService,
+        private liService: LineitemService
+    ) {}
+
+    ngOnInit() {
+
+        this.route.paramMap.subscribe((params: ParamMap) => {
+            const id = +params.get('lineitemId');
+            if (id !== this.lineitemId) {
+                this.lineitemId = id;
+                if (id) { this.load(); }
+            }
+        });
+
+        this.liService.getLiAttrDefs();
+    }
+
+    load() {
+        this.lineitem = null;
+        // Avoid pulling from cache since li's do not have marc()
+        // fleshed by default.
+        return this.liService.getFleshedLineitems([this.lineitemId], {
+            toCache: true, // OK to cache with marc()
+            fleshMore: {clear_marc: false}
+        }).pipe(tap(liStruct => this.lineitem = liStruct.lineitem)).toPromise();
+    }
+
+    attrLabel(attr: IdlObject): string {
+        if (!this.liService.liAttrDefs) { return; }
+
+        const def = this.liService.liAttrDefs.filter(
+            d => d.id() === attr.definition())[0];
+
+        return def ? def.description() : '';
+    }
+
+    saveMarcChanges(changes) { // MarcSavedEvent
+        const xml = changes.marcXml;
+        this.lineitem.marc(xml);
+        this.liService.updateLineitems([this.lineitem]).toPromise()
+        .then(_ => this.load());
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/lineitem/history.component.html b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/history.component.html
new file mode 100644 (file)
index 0000000..dda7ce5
--- /dev/null
@@ -0,0 +1,9 @@
+
+<!-- TODO: workstation setting -->
+
+<div class="mt-3">
+  <eg-grid idlClass="acqlih" [dataSource]="dataSource" [sortable]="true"
+    persistKey="acq.lineitem.history"
+    hideFields="id,audit_id,marc,audit_time,audit_action,queued_record">
+  </eg-grid>
+</div>
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/lineitem/history.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/history.component.ts
new file mode 100644 (file)
index 0000000..3478b81
--- /dev/null
@@ -0,0 +1,52 @@
+import {Component, OnInit, Input, Output} from '@angular/core';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {empty} from 'rxjs';
+import {Pager} from '@eg/share/util/pager';
+import {IdlObject} from '@eg/core/idl.service';
+import {GridDataSource} from '@eg/share/grid/grid';
+import {PcrudService} from '@eg/core/pcrud.service';
+
+@Component({
+  templateUrl: 'history.component.html',
+  selector: 'eg-lineitem-history'
+})
+export class LineitemHistoryComponent implements OnInit {
+
+    lineitemId: number;
+    dataSource: GridDataSource = new GridDataSource();
+
+    constructor(
+        private route: ActivatedRoute,
+        private pcrud: PcrudService
+    ) {}
+
+    ngOnInit() {
+
+        this.dataSource.getRows = (pager: Pager, sort: any) =>
+            this.getHistory(pager, sort);
+
+        this.route.paramMap.subscribe((params: ParamMap) => {
+            this.lineitemId = +params.get('lineitemId');
+        });
+   }
+
+    getHistory(pager: Pager, sort: any) {
+        if (!this.lineitemId) { return empty(); }
+
+        const orderBy: any = {acqlih: 'edit_time DESC'};
+        if (sort.length) {
+            orderBy.acqlih = sort[0].name + ' ' + sort[0].dir;
+        }
+
+        return this.pcrud.search('acqlih', {id: this.lineitemId}, {
+            offset: pager.offset,
+            limit: pager.limit,
+            order_by: orderBy,
+            flesh: 1,
+            flesh_fields: {
+                acqlih: ['creator', 'editor', 'provider', 'cancel_reason']
+            }
+        });
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem-list.component.css b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem-list.component.css
new file mode 100644 (file)
index 0000000..ac88314
--- /dev/null
@@ -0,0 +1,33 @@
+
+.jacket-wrapper {
+  width: 70px;
+}
+.jacket {
+  width: 65px;
+}
+
+input[type="text"].form-control-sm { border-width: 1px; }
+
+.toolbar .form-check-label {
+  font-size: 115%;
+}
+
+.batch-copy-row:nth-child(even) {
+  background-color: rgba(0,0,0,.03);
+}
+
+/* Kind of hacky -- only way to get a toolbar button with no 
+ * mat icon to line up horizontally with mat icon buttons */
+.toolbar .text-button {
+  padding-top: 11px;
+  padding-bottom: 11px;
+}
+
+
+.li-state-new { background-color: #FFFFEE; }
+.li-state-selector-ready { background-color: #FFEEEE; }
+.li-state-order-ready { background-color: #EEEEEE; }
+.li-state-pending-order { background-color: #EEEEDD; }
+.li-state-on-order { background-color: #EEDDDD; }
+.li-state-received { background-color: #DDDDDD; }
+.li-state-delayed { background-color: #99CCFF; }
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem-list.component.html b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem-list.component.html
new file mode 100644 (file)
index 0000000..ce7c5e8
--- /dev/null
@@ -0,0 +1,385 @@
+
+<!-- BATCH ACTIONS -->
+<eg-acq-cancel-dialog #cancelDialog></eg-acq-cancel-dialog>
+
+<div class="row mt-3">
+  <div class="col-lg-1">
+    <div ngbDropdown>
+      <button class="btn btn-info btn-sm" ngbDropdownToggle i18n>Actions</button>
+      <div ngbDropdownMenu>
+        <a ngbDropdownItem routerLink="../brief-record"
+          queryParamsHandling="merge" i18n>Add Brief Record</a>
+        <div class="dropdown-divider"></div>
+        <h6 class="dropdown-header" i18n>Selection List Actions</h6>
+        <button ngbDropdownItem (click)="deleteLineitems()" 
+          [disabled]="!picklistId" i18n>Delete Selected Lineitems</button>
+        <button ngbDropdownItem (click)="createPo()" 
+          [disabled]="!picklistId" i18n>Create Purchase Order from Selected Lineitems</button>
+        <button ngbDropdownItem (click)="createPo(true)"
+          [disabled]="!picklistId" i18n>Create Purchase Order from All Lineitems</button>
+        <div class="dropdown-divider"></div>
+        <h6 class="dropdown-header" i18n>Purchase Order Actions</h6>
+        <button ngbDropdownItem (click)="receiveSelected()" 
+          [disabled]="!poId" i18n>Mark Selected Lineitems as Received</button>
+        <button ngbDropdownItem (click)="unReceiveSelected()" 
+          [disabled]="!poId" i18n>Un-Receive Selected Lineitems</button>
+        <button ngbDropdownItem (click)="cancelSelected()" 
+          [disabled]="!poId" i18n>Cancel Selected Lineitems</button>
+      </div>
+    </div>
+  </div>
+  <div class="col-lg-5">
+    <input type="text" class="form-control" [(ngModel)]="batchNote"
+      placeholder="New Line Item Note..." i18n-placeholder/>
+  </div>
+  <div class="col-lg-4 form-inline">
+    <div class="form-check mr-2">
+      <input class="form-check-input" type="checkbox"
+        id="vendor-public" [(ngModel)]="noteIsPublic">
+      <label class="form-check-label" for="vendor-public">
+        Note is vendor-public
+      </label>
+    </div>
+    <button class="btn btn-outline-dark" (click)="applyBatchNote()"
+      [disabled]="!selectedIds().length" i18n>
+      Apply To Selected
+    </button>
+  </div>
+</div>
+
+<div *ngIf="batchFailure" class="row mt-2 p-2">
+  <div class="col-lg-12 p-2 border border-danger label-with-material-icon" i18n>
+    <span class="material-icons text-danger pr-2">report</span>
+    Batch operation failed: 
+    {{batchFailure.textcode}} {{batchFailure.desc}}
+
+    <a class="ml-auto" href="javascript:;" 
+      (click)="batchFailure = null" title="Close" i18n-title>
+      <span class="material-icons text-danger">close</span>
+    </a>
+  </div>
+</div>
+
+<!-- NAVIGATION / EXPANDY -->
+
+<div class="row mt-3 mb-1 border border-info rounded toolbar">
+  <div class="col-lg-12 d-flex">
+    <div class="d-flex justify-content-center flex-column h-100">
+      <div class="form-check">
+        <input class="form-check-input" id='toggle-page-cbox'
+          [(ngModel)]="batchSelectPage" (change)="toggleSelectAll(false)" type="checkbox"/>
+        <label class="form-check-label" for='toggle-page-cbox' i18n>Items In Page</label>
+      </div>
+    </div>
+
+    <div class="d-flex justify-content-center flex-column h-100 ml-3">
+      <div class="form-check">
+        <input class="form-check-input" id='toggle-all-cbox'
+          [(ngModel)]="batchSelectAll" (change)="toggleSelectAll(true)" type="checkbox"/>
+        <label class="form-check-label" for='toggle-all-cbox' i18n>All Items</label>
+      </div>
+    </div>
+
+    <div class="d-flex ml-3 justify-content-center flex-column h-100">
+      <span class="font-italic" style="font-size:90%" i18n>
+        {{selectedIds().length}} Selected
+      </span>
+    </div>
+
+    <div class="flex-1"></div>
+
+    <div class="btn-toolbar">
+      <button type="button" (click)="toggleExpandAll()"
+        class="btn btn-sm btn-outline-dark mr-1">
+        <span title="Expand All" i18n-title *ngIf="!expandAll"
+          class="material-icons mat-icon-in-button">unfold_more</span>
+        <span title="Collapse All" i18n-title *ngIf="expandAll"
+          class="material-icons mat-icon-in-button">unfold_less</span>
+      </button>
+      <button [disabled]="pager.isFirstPage()" type="button"
+        class="btn btn-sm btn-outline-dark mr-1" (click)="pager.toFirst(); goToPage()">
+        <span title="First Page" i18n-title
+          class="material-icons mat-icon-in-button">first_page</span>
+      </button>
+      <button [disabled]="pager.isFirstPage()" type="button"
+        class="btn btn-sm btn-outline-dark mr-1" (click)="pager.decrement(); goToPage()">
+        <span title="Previous Page" i18n-title
+            class="material-icons mat-icon-in-button">keyboard_arrow_left</span>
+      </button>
+      <button [disabled]="pager.isLastPage()" type="button"
+        class="btn btn-sm btn-outline-dark mr-1" (click)="pager.increment(); goToPage()">
+        <span title="Next Page" i18n-title
+          class="material-icons mat-icon-in-button">keyboard_arrow_right</span>
+      </button>
+      <div ngbDropdown class="mr-1" placement="bottom-right">
+        <button ngbDropdownToggle class="btn btn-outline-dark text-button">
+          <span title="Select Row Count" i18n-title i18n>
+            Rows {{pager.limit}}
+          </span>
+        </button>
+        <div class="dropdown-menu" ngbDropdownMenu>
+          <a class="dropdown-item" (click)="pageSizeChange(count)"
+            *ngFor="let count of [5, 10, 25, 50, 100, 500, 1000, 10000]">
+            <span class="ml-2">{{count}}</span>
+          </a>
+        </div>
+      </div>
+    </div><!-- buttons -->
+  </div>
+</div>
+
+<!-- LINEITEM LIST -->
+
+<ng-container *ngFor="let li of pageOfLineitems">
+  <div class="row mt-2 border-bottom pt-2 pb-2 li-state-{{li.state()}}">
+    <div class="col-lg-12 d-flex">
+      <div class="jacket-wrapper">
+        <ng-container *ngIf="jacketIdent(li)">
+          <a href="/opac/extras/ac/jacket/large/{{jacketIdent(li)}}">
+            <img class="jacket"
+              src='/opac/extras/ac/jacket/small/{{jacketIdent(li)}}'/>
+          </a>
+        </ng-container>
+        <ng-container *ngIf="!jacketIdent(li)"><img class="jacket"/></ng-container>
+      </div>
+
+      <div class="ml-2 flex-1"> <!-- lineitem summary info -->
+        <div class="row">
+          <div class="col-lg-12">
+            <input type="checkbox" [(ngModel)]="selected[li.id()]"/>
+            <a class="ml-2" queryParamsHandling="merge" [id]="li.id()"
+              routerLink="./lineitem/{{li.id()}}/detail">
+              {{displayAttr(li, 'title')}}
+            </a>
+          </div>
+        </div>
+        <div class="row">
+          <div class="col-lg-12">
+            <span class="pr-1">{{displayAttr(li, 'author')}}</span>
+            <span class="pr-1">{{displayAttr(li, 'isbn')}}</span>
+            <span class="pr-1">{{displayAttr(li, 'issn')}}</span>
+            <span class="pr-1">{{displayAttr(li, 'edition')}}</span>
+            <span class="pr-1">{{displayAttr(li, 'pubdate')}}</span>
+            <span class="pr-1">{{displayAttr(li, 'publisher')}}</span>
+            <span class="pr-1">{{li.source_label()}}</span>
+          </div>
+        </div>
+        <div class="row" *ngIf="li.purchase_order()">
+          <div class="col-lg-12">
+            <eg-lineitem-order-summary [li]="li"></eg-lineitem-order-summary>
+          </div>
+        </div>
+        <div class="row">
+          <div class="col-lg-12">
+            <span title="Lineitem ID" i18n-title i18n># {{li.id()}}</span>
+            <span class="ml-1 mr-1" i18n> | </span>
+            <span title="Existing Item Count" i18n-title i18n
+              [ngClass]="{'text-danger font-weight-bold': existingCopyCounts[li.id()] > 0}">
+              {{existingCopyCounts[li.id()]}}</span>
+            <span class="ml-1 mr-1" i18n> | </span>
+            <a class="label-with-material-icon" title="Items" i18n-title
+              routerLink="./lineitem/{{li.id()}}/items" queryParamsHandling="merge">
+              <span class="material-icons small mr-1">shopping_basket</span>
+              <span i18n>Items ({{li.lineitem_details().length}})</span>
+            </a>
+            <span class="ml-1 mr-1" i18n> | </span>
+            <a class="label-with-material-icon" title="Expand" i18n-title
+              href="javascript:;" (click)="toggleShowExpand(li.id())">
+              <ng-container *ngIf="showExpandFor != li.id()">
+                <span class="material-icons small mr-1">unfold_more</span>
+                <span i18n>Expand</span>
+              </ng-container>
+              <ng-container *ngIf="showExpandFor == li.id()">
+                <span class="material-icons small mr-1">unfold_less</span>
+                <span i18n>Collapse</span>
+              </ng-container>
+            </a>
+            <span class="ml-1 mr-1" i18n> | </span>
+            <a class="label-with-material-icon" title="Notes" i18n-title
+              href="javascript:;" (click)="toggleShowNotes(li.id())">
+              <span class="material-icons small mr-1">event_note</span>
+              <span i18n>Notes ({{li.lineitem_notes().length}})</span>
+              <span *ngIf="liHasAlerts(li)" class="text-danger material-icons"
+                title="Has Alerts" i18n-title>flag</span>
+            </a>
+            <span class="ml-1 mr-1" i18n> | </span>
+            <a class="label-with-material-icon"
+              routerLink="lineitem/{{li.id()}}/worksheet/">
+              <span class="material-icons small mr-1">create</span>
+              <span i18n>Worksheet</span>
+            </a>
+            <span class="ml-1 mr-1" i18n> | </span>
+            <a class="label-with-material-icon"
+              [queryParams]="{f: 'jub:id', val1: li.id()}"
+              routerLink="/staff/acq/search/invoices">
+              <span class="material-icons small mr-1">list</span>
+              <span i18n>Invoice(s)</span>
+            </a>
+            <ng-container *ngIf="li.eg_bib_id()">
+              <span class="ml-1 mr-1" i18n> | </span>
+              <a class="label-with-material-icon mr-2"
+                routerLink="/staff/catalog/record/{{li.eg_bib_id()}}">
+                <span class="material-icons small mr-1">library_books</span>
+                <span i18n>Catalog</span>
+              </a>
+            </ng-container>
+
+            <ng-container *ngIf="!poId && li.purchase_order()">
+              <span class="ml-1 mr-1" i18n> | </span>
+              <a class="label-with-material-icon"
+                title="Purchase Order" i18n-title
+                routerLink="/staff/acq/po/{{li.purchase_order().id()}}">
+                <span class="material-icons small mr-1">center_focus_weak</span>
+                <span i18n>{{li.purchase_order().id()}}</span>
+              </a>
+            </ng-container>
+            <ng-container *ngIf="!picklistId && li.picklist()">
+              <span class="ml-1 mr-1" i18n> | </span>
+              <a class="label-with-material-icon"
+                title="Selection List" i18n-title 
+                routerLink="/staff/acq/picklist/{{li.picklist().id()}}">
+                <span class="material-icons small mr-1">widgets</span>
+                <span i18n>{{li.picklist().name()}}</span>
+              </a>
+            </ng-container>
+            <ng-container *ngIf="li.provider()">
+              <span class="ml-1 mr-1" i18n> | </span>
+              <a class="label-with-material-icon"
+                title="Selection List" i18n-title 
+                routerLink="/staff/acq/provider/{{li.provider().id()}}/details">
+                <span class="material-icons small mr-1">store</span>
+                <span i18n>{{li.provider().name()}}</span>
+              </a>
+            </ng-container>
+          </div>
+        </div>
+      </div>
+
+      <!-- actions along the right -->
+      <div class="d-flex flex-column justify-content-end">
+        <div class="row">
+          <div class="col-lg-12 d-flex">
+          <div class="flex-1"> </div>
+            <!-- w-auto allows the input group to stick to the right 
+                 as the status label grows -->
+            <div class="input-group w-auto">
+              <div class="input-group-prepend">
+                <div ngbDropdown>
+                  <button class="btn btn-outline-dark btn-sm" ngbDropdownToggle 
+                    title="Order Identifier Type" i18n-title
+                    [ngClass]="{'btn-warning': !selectedIdent(li)}">
+                    <ng-container *ngIf="orderIdentTypes[li.id()]=='isbn'" i18n>ISBN</ng-container>
+                    <ng-container *ngIf="orderIdentTypes[li.id()]=='upc'" i18n>UPC</ng-container>
+                    <ng-container *ngIf="orderIdentTypes[li.id()]=='issn'" i18n>ISSN</ng-container>
+                  </button>
+                  <div ngbDropdownMenu>
+                    <button class="btn-sm" ngbDropdownItem
+                      (click)="orderIdentTypes[li.id()]='isbn'" i18n>ISBN</button>
+                    <button class="btn-sm" ngbDropdownItem
+                      (click)="orderIdentTypes[li.id()]='upc'" i18n>UPC</button>
+                    <button class="btn-sm" ngbDropdownItem
+                      (click)="orderIdentTypes[li.id()]='issn'" i18n>ISSN</button>
+                  </div>
+                </div>
+              </div>
+              <eg-combobox [entries]="identOptions(li)" [smallFormControl]="true"
+                placeholder="Order Identifer..." i18n-placeholder
+                [allowFreeText]="true" [selectedId]="selectedIdent(li)"
+                (onChange)="orderIdentChanged(li, $event)">
+              </eg-combobox>
+            </div>
+          </div>
+        </div>
+        <div class="row mt-2">
+          <div class="col-lg-12 d-flex">
+            <div class="flex-1"></div>
+            <div class="mr-2">
+              <ng-container [ngSwitch]="li.state()">   
+                <div i18n 
+                  class="p-1 text-dark border border-dark bg-light rounded-lg" 
+                  *ngSwitchCase="'new'">New</div>
+                <div i18n 
+                  class="p-1 text-dark border border-dark bg-light rounded-lg" 
+                  *ngSwitchCase="'selector-ready'">Selector-Ready</div>
+                <div i18n 
+                  class="p-1 text-dark border border-dark bg-light rounded-lg" 
+                  *ngSwitchCase="'order-ready'">Order-Ready</div>
+                <div i18n 
+                  class="p-1 text-dark border border-dark bg-light rounded-lg" 
+                  *ngSwitchCase="'approved'">Approved</div>
+                <div i18n 
+                  class="p-1 text-dark border border-dark bg-light rounded-lg" 
+                  *ngSwitchCase="'pending-order'">Pending-Order</div>
+                <div i18n 
+                  class="p-1 text-primary border border-primary bg-light rounded-lg" 
+                  *ngSwitchCase="'on-order'">On-Order</div>
+                <div i18n 
+                  class="p-1 text-success border border-success bg-light rounded-lg" 
+                  *ngSwitchCase="'received'">Received</div>
+                <div i18n 
+                  class="p-1 text-danger border border-danger bg-light rounded-lg" 
+                  *ngSwitchCase="'cancelled'">Canceled</div>
+              </ng-container>
+            </div>
+            <div class="mr-2">
+              <div ngbDropdown>
+                <button class="btn btn-info btn-sm" ngbDropdownToggle i18n>Actions</button>
+                <div ngbDropdownMenu>
+                  <button ngbDropdownItem [disabled]="li.state() != 'on-order'"
+                    (click)="markReceived([li.id()])" i18n>Mark Received</button>
+                  <button ngbDropdownItem [disabled]="li.state() != 'received'"
+                    (click)="markUnReceived([li.id()])" i18n>Mark Un-Received</button>
+                  <button ngbDropdownItem [disabled]="!liHasRealCopies(li)"
+                    (click)="editHoldings(li)" i18n>Holdings Maintenance</button>
+                  <a ngbDropdownItem routerLink="lineitem/{{li.id()}}/history"
+                    queryParamsHandling="merge" i18n>View History</a>
+                </div>
+              </div>
+            </div>
+            <div>
+              <input type="text" class="form-control-sm medium"
+                [ngClass]="{'border border-danger text-danger': !liPriceIsValid(li)}"
+                placeholder='Price...' i18n-placeholder
+                (change)="liPriceChange(li)" [ngModel]="li.estimated_unit_price()"
+                (ngModelChange)="li.estimated_unit_price($event)"/>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <div class="row" *ngIf="showNotesFor == li.id()">
+    <div class="col-lg-10 offset-lg-1 p-2 mt-2">
+      <eg-lineitem-notes [lineitem]="li" (closeRequested)="showNotesFor = null">
+      </eg-lineitem-notes>
+    </div>
+  </div>
+  <div class="row" *ngIf="showExpandFor == li.id() || expandAll">
+    <div class="col-lg-10 offset-lg-1 p-2 mt-2 shadow">
+
+      <!-- Note the flex values are set so they also match the layout
+           of the list of copies in the copies component. -->
+      <div class="div d-flex font-weight-bold">
+        <div class="flex-1 p-1" i18n>Owning Branch</div>  
+        <div class="flex-1 p-1" i18n>Copy Location</div>
+        <div class="flex-1 p-1" i18n>Collection Code</div>
+        <div class="flex-1 p-1" i18n>Fund</div>
+        <div class="flex-1 p-1" i18n>Circ Modifier</div>
+        <div class="flex-1 p-1" i18n>Callnumber</div>
+        <div class="flex-1 p-1" i18n>Barcode</div>
+      </div>
+      <div class="batch-copy-row" *ngFor="let copy of li.lineitem_details()">
+        <eg-lineitem-copy-attrs [embedded]="true" [copy]="copy">
+        </eg-lineitem-copy-attrs>
+      </div>
+    </div>
+  </div>
+</ng-container>
+
+<div class="row" *ngIf="loading">
+  <div class="offset-lg-3 col-lg-6">
+    <eg-progress-inline *ngIf="loading"></eg-progress-inline>
+  </div>
+</div>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem-list.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem-list.component.ts
new file mode 100644 (file)
index 0000000..46765a2
--- /dev/null
@@ -0,0 +1,511 @@
+import {Component, OnInit, Input, Output, ViewChild} from '@angular/core';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {Observable} from 'rxjs';
+import {tap} from 'rxjs/operators';
+import {Pager} from '@eg/share/util/pager';
+import {EgEvent, EventService} from '@eg/core/event.service';
+import {IdlObject} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {ServerStoreService} from '@eg/core/server-store.service';
+import {LineitemService} from './lineitem.service';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {HoldingsService} from '@eg/staff/share/holdings/holdings.service';
+import {CancelDialogComponent} from './cancel-dialog.component';
+
+@Component({
+  templateUrl: 'lineitem-list.component.html',
+  selector: 'eg-lineitem-list',
+  styleUrls: ['lineitem-list.component.css']
+})
+export class LineitemListComponent implements OnInit {
+
+    picklistId: number = null;
+    poId: number = null;
+
+    loading = false;
+    pager: Pager = new Pager();
+    pageOfLineitems: IdlObject[] = [];
+    lineitemIds: number[] = [];
+
+    // Selected lineitems
+    selected: {[id: number]: boolean} = {};
+
+    // Order identifier type per lineitem
+    orderIdentTypes: {[id: number]: 'isbn' | 'issn' | 'upc'} = {};
+
+    // Copy counts per lineitem
+    existingCopyCounts: {[id: number]: number} = {};
+
+    // Squash these down to an easily traversable data set to avoid
+    // a lot of repetitive looping.
+    liMarcAttrs: {[id: number]: {[name: string]: IdlObject[]}} = {};
+
+    batchNote: string;
+    noteIsPublic = false;
+    batchSelectPage = false;
+    batchSelectAll = false;
+    showNotesFor: number;
+    showExpandFor: number; // 'Expand'
+    expandAll = false;
+    action = '';
+    batchFailure: EgEvent;
+    focusLi: number;
+
+    @ViewChild('cancelDialog') cancelDialog: CancelDialogComponent;
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private evt: EventService,
+        private net: NetService,
+        private auth: AuthService,
+        private store: ServerStoreService,
+        private holdings: HoldingsService,
+        private liService: LineitemService
+    ) {}
+
+    ngOnInit() {
+
+        this.route.queryParamMap.subscribe((params: ParamMap) => {
+            this.pager.offset = +params.get('offset');
+            this.pager.limit = +params.get('limit');
+            this.load();
+        });
+
+        this.route.fragment.subscribe((fragment: string) => {
+            const id = Number(fragment);
+            if (id > 0) { this.focusLineitem(id); }
+        });
+
+        this.route.parent.paramMap.subscribe((params: ParamMap) => {
+            this.picklistId = +params.get('picklistId');
+            this.poId = +params.get('poId');
+            this.load();
+        });
+
+        this.store.getItem('acq.lineitem.page_size').then(count => {
+            this.pager.setLimit(count || 20);
+            this.load();
+        });
+    }
+
+    pageSizeChange(count: number) {
+        this.store.setItem('acq.lineitem.page_size', count).then(_ => {
+            this.pager.setLimit(count);
+            this.pager.toFirst();
+            this.goToPage();
+        });
+    }
+
+    // Focus the selected lineitem, which may not yet exist in the
+    // DOM for focusing.
+    focusLineitem(id?: number) {
+        if (id !== undefined) { this.focusLi = id; }
+        if (this.focusLi) {
+            const node = document.getElementById('' + this.focusLi);
+            if (node) { node.scrollIntoView(true); }
+        }
+    }
+
+    load(): Promise<any> {
+        this.pageOfLineitems = [];
+
+        if (!this.loading &&
+            this.pager.limit && (this.poId || this.picklistId)) {
+
+            this.loading = true;
+
+            return this.loadIds()
+                .then(_ => this.loadPage())
+                .then(_ => this.loading = false)
+                .catch(_ => {}); // re-route while page is loading
+        }
+
+        // We have not collected enough data to proceed.
+        return Promise.resolve();
+
+    }
+
+    loadIds(): Promise<any> {
+        this.lineitemIds = [];
+
+        let id = this.poId;
+        let options: any = {flesh_lineitem_ids: true, li_limit: 10000};
+        let method = 'open-ils.acq.purchase_order.retrieve';
+        let handler = (po) => po.lineitems();
+
+        if (this.picklistId) {
+            id = this.picklistId;
+            options = {idlist: true, limit: 1000};
+            method = 'open-ils.acq.lineitem.picklist.retrieve.atomic';
+            handler = (ids) => ids;
+        }
+
+        return this.net.request(
+            'open-ils.acq', method, this.auth.token(), id, options
+        ).toPromise().then(resp => {
+            const ids = handler(resp);
+
+            this.lineitemIds = ids
+                .map(i => Number(i))
+                .sort((id1, id2) => id1 < id2 ? -1 : 1);
+
+            this.pager.resultCount = ids.length;
+        });
+    }
+
+    goToPage() {
+        this.focusLi = null;
+        this.router.navigate([], {
+            relativeTo: this.route,
+            queryParamsHandling: 'merge',
+            fragment: null,
+            queryParams: {
+                offset: this.pager.offset,
+                limit: this.pager.limit
+            }
+        });
+    }
+
+    loadPage(): Promise<any> {
+        return this.jumpToLiPage()
+            .then(_ => this.loadPageOfLis())
+            .then(_ => this.setBatchSelect())
+            .then(_ => setTimeout(() => this.focusLineitem()));
+    }
+
+    jumpToLiPage(): Promise<boolean> {
+        if (!this.focusLi) { return Promise.resolve(true); }
+
+        const idx = this.lineitemIds.indexOf(this.focusLi);
+        if (idx === -1) { return Promise.resolve(true); }
+
+        const offset = Math.floor(idx / this.pager.limit) * this.pager.limit;
+
+        return this.router.navigate(['./'], {
+            relativeTo: this.route,
+            queryParams: {offset: offset, limit: this.pager.limit},
+            fragment: '' + this.focusLi
+        });
+    }
+
+    loadPageOfLis(): Promise<any> {
+        this.pageOfLineitems = [];
+
+        const ids = this.lineitemIds.slice(
+            this.pager.offset, this.pager.offset + this.pager.limit)
+            .filter(id => id !== undefined);
+
+        if (ids.length === 0) { return Promise.resolve(); }
+
+        if (this.pageOfLineitems.length === ids.length) {
+            // All entries found in the cache
+            return Promise.resolve();
+        }
+
+        this.pageOfLineitems = []; // reset
+
+        return this.liService.getFleshedLineitems(
+            ids, {fromCache: true, toCache: true})
+        .pipe(tap(struct => {
+            this.ingestOneLi(struct.lineitem);
+            this.existingCopyCounts[struct.id] = struct.existing_copies;
+        })).toPromise();
+    }
+
+    ingestOneLi(li: IdlObject, replace?: boolean) {
+        this.liMarcAttrs[li.id()] = {};
+
+        li.attributes().forEach(attr => {
+            const name = attr.attr_name();
+            this.liMarcAttrs[li.id()][name] =
+                this.liService.getAttributes(
+                    li, name, 'lineitem_marc_attr_definition');
+        });
+
+        const ident = this.liService.getOrderIdent(li);
+        this.orderIdentTypes[li.id()] = ident ? ident.attr_name() : 'isbn';
+
+        // newest to oldest
+        li.lineitem_notes(li.lineitem_notes().sort(
+            (n1, n2) => n1.create_time() < n2.create_time() ? 1 : -1));
+
+        if (replace) {
+            for (let idx = 0; idx < this.pageOfLineitems.length; idx++) {
+                if (this.pageOfLineitems[idx].id() === li.id()) {
+                    this.pageOfLineitems[idx] = li;
+                    break;
+                }
+            }
+        } else {
+            this.pageOfLineitems.push(li);
+        }
+    }
+
+    // First matching attr
+    displayAttr(li: IdlObject, name: string): string {
+        return (
+            this.liMarcAttrs[li.id()][name] &&
+            this.liMarcAttrs[li.id()][name][0]
+        ) ? this.liMarcAttrs[li.id()][name][0].attr_value() : '';
+    }
+
+    // All matching attrs
+    attrs(li: IdlObject, name: string, attrType?: string): IdlObject[] {
+        return this.liService.getAttributes(li, name, attrType);
+    }
+
+    jacketIdent(li: IdlObject): string {
+        return this.displayAttr(li, 'isbn') || this.displayAttr(li, 'upc');
+    }
+
+    // Order ident options are pulled from the MARC, but the ident
+    // value proper is stored as a local attr def.
+    identOptions(li: IdlObject): ComboboxEntry[] {
+        const otype = this.orderIdentTypes[li.id()];
+
+        if (this.liMarcAttrs[li.id()][otype]) {
+            return this.liMarcAttrs[li.id()][otype].map(
+                attr => ({id: attr.id(), label: attr.attr_value()}));
+        }
+
+        return [];
+    }
+
+    // Returns the MARC attr with the same type and value as the applied
+    // order identifier (which is a local attr)
+    selectedIdent(li: IdlObject): number {
+        const ident = this.liService.getOrderIdent(li);
+        if (!ident) { return null; }
+
+        const attr = this.identOptions(li).filter(
+            (entry: ComboboxEntry) => entry.label === ident.attr_value())[0];
+        return attr ? attr.id : null;
+    }
+
+    currentIdent(li: IdlObject): IdlObject {
+        return this.liService.getOrderIdent(li);
+    }
+
+    orderIdentChanged(li: IdlObject, entry: ComboboxEntry) {
+        if (entry === null) { return; }
+
+        this.liService.changeOrderIdent(
+            li, entry.id, this.orderIdentTypes[li.id()], entry.label
+        ).subscribe(freshLi => this.ingestOneLi(freshLi, true));
+    }
+
+    addBriefRecord() {
+    }
+
+    selectedIds(): number[] {
+        return Object.keys(this.selected)
+            .filter(id => this.selected[id] === true)
+            .map(id => Number(id));
+    }
+
+
+    // After a page of LI's are loaded, see if the batch-select checkbox
+    // needs to be on or off.
+    setBatchSelect() {
+        let on = true;
+        const ids = this.selectedIds();
+        this.pageOfLineitems.forEach(li => {
+            if (!ids.includes(li.id())) { on = false; }
+        });
+
+        this.batchSelectPage = on;
+
+        on = true;
+
+        this.lineitemIds.forEach(id => {
+            if (!this.selected[id]) { on = false; }
+        });
+
+        this.batchSelectAll = on;
+    }
+
+    toggleSelectAll(allItems: boolean) {
+
+        if (allItems) {
+            this.lineitemIds.forEach(
+                id => this.selected[id] = this.batchSelectAll);
+
+            this.batchSelectPage = this.batchSelectAll;
+
+        } else {
+
+            this.pageOfLineitems.forEach(
+                li => this.selected[li.id()] = this.batchSelectPage);
+
+            if (!this.batchSelectPage) {
+                // When deselecting items in the page, we're no longer
+                // selecting all items.
+                this.batchSelectAll = false;
+            }
+        }
+    }
+
+    applyBatchNote() {
+        const ids = this.selectedIds();
+        if (ids.length === 0 || !this.batchNote) { return; }
+
+        this.liService.applyBatchNote(ids, this.batchNote, this.noteIsPublic)
+        .then(resp => this.load());
+    }
+
+    liPriceIsValid(li: IdlObject): boolean {
+        const price = li.estimated_unit_price();
+        if (price === null || price === undefined || price === '') {
+            return true;
+        }
+        return !Number.isNaN(Number(price)) && Number(price) >= 0;
+    }
+
+    liPriceChange(li: IdlObject) {
+        const price = li.estimated_unit_price();
+        if (this.liPriceIsValid(li)) {
+            li.estimated_unit_price(Number(price).toFixed(2));
+
+            this.net.request(
+                'open-ils.acq',
+                'open-ils.acq.lineitem.update',
+                this.auth.token(), li
+            ).subscribe(resp =>
+                this.liService.activateStateChange.emit(li.id()));
+        }
+    }
+
+    toggleShowNotes(liId: number) {
+        this.showExpandFor = null;
+        this.showNotesFor = this.showNotesFor === liId ? null : liId;
+    }
+
+    toggleShowExpand(liId: number) {
+        this.showNotesFor = null;
+        this.showExpandFor = this.showExpandFor === liId ? null : liId;
+    }
+
+    toggleExpandAll() {
+        this.showNotesFor = null;
+        this.showExpandFor = null;
+        this.expandAll = !this.expandAll;
+    }
+
+    liHasAlerts(li: IdlObject): boolean {
+        return li.lineitem_notes().filter(n => n.alert_text()).length > 0;
+    }
+
+    deleteLineitems() {
+        const ids = Object.keys(this.selected).filter(id => this.selected[id]);
+
+        const method = this.poId ?
+            'open-ils.acq.purchase_order.lineitem.delete' :
+            'open-ils.acq.picklist.lineitem.delete';
+
+        let promise = Promise.resolve();
+
+        this.loading = true;
+
+        ids.forEach(id => {
+            promise = promise
+            .then(_ => this.net.request(
+                'open-ils.acq', method, this.auth.token(), id).toPromise()
+            );
+        });
+
+        promise.then(_ => this.load());
+    }
+
+    liHasRealCopies(li: IdlObject): boolean {
+        for (let idx = 0; idx < li.lineitem_details().length; idx++) {
+            if (li.lineitem_details()[idx].eg_copy_id()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    editHoldings(li: IdlObject) {
+
+        const copies = li.lineitem_details()
+            .filter(lid => lid.eg_copy_id()).map(lid => lid.eg_copy_id());
+
+        if (copies.length === 0) { return; }
+
+        this.holdings.spawnAddHoldingsUi(
+            li.eg_bib_id(),
+            copies.map(c => c.call_number()),
+            null,
+            copies.map(c => c.id())
+        );
+    }
+
+    receiveSelected() {
+        this.markReceived(this.selectedIds());
+    }
+
+    unReceiveSelected() {
+        this.markUnReceived(this.selectedIds());
+    }
+
+    cancelSelected() {
+        const liIds = this.selectedIds();
+        if (liIds.length === 0) { return; }
+
+        this.cancelDialog.open().subscribe(reason => {
+            if (!reason) { return; }
+
+            this.net.request('open-ils.acq',
+                'open-ils.acq.lineitem.cancel.batch',
+                this.auth.token(), liIds, reason
+            ).toPromise().then(resp => this.postBatchAction(resp, liIds));
+        });
+    }
+
+    markReceived(liIds: number[]) {
+        if (liIds.length === 0) { return; }
+
+        this.net.request(
+            'open-ils.acq',
+            'open-ils.acq.lineitem.receive.batch',
+            this.auth.token(), liIds
+        ).toPromise().then(resp => this.postBatchAction(resp, liIds));
+    }
+
+    markUnReceived(liIds: number[]) {
+        if (liIds.length === 0) { return; }
+
+        this.net.request(
+            'open-ils.acq',
+            'open-ils.acq.lineitem.receive.rollback.batch',
+            this.auth.token(), liIds
+        ).toPromise().then(resp => this.postBatchAction(resp, liIds));
+    }
+
+    postBatchAction(response: any, liIds: number[]) {
+        const evt = this.evt.parse(response);
+
+        if (evt) {
+            console.warn('Batch operation failed', evt);
+            this.batchFailure = evt;
+            return;
+        }
+
+        this.batchFailure = null;
+
+        // Remove the modified LI's from the cache so we are
+        // forced to re-fetch them.
+        liIds.forEach(id => delete this.liService.liCache[id]);
+
+        this.loadPageOfLis();
+    }
+
+    createPo(fromAll?: boolean) {
+        this.router.navigate(['/staff/acq/po/create'], {
+            queryParams: {li: fromAll ? this.lineitemIds : this.selectedIds()}
+        });
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem.component.html b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem.component.html
new file mode 100644 (file)
index 0000000..678b34e
--- /dev/null
@@ -0,0 +1,3 @@
+
+<router-outlet></router-outlet>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem.component.ts
new file mode 100644 (file)
index 0000000..7d7c2d5
--- /dev/null
@@ -0,0 +1,9 @@
+import {Component, OnInit} from '@angular/core';
+
+@Component({
+  templateUrl: 'lineitem.component.html'
+})
+export class LineitemComponent implements OnInit {
+    ngOnInit() {}
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem.module.ts b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem.module.ts
new file mode 100644 (file)
index 0000000..888830f
--- /dev/null
@@ -0,0 +1,51 @@
+import {NgModule} from '@angular/core';
+import {StaffCommonModule} from '@eg/staff/common.module';
+import {HttpClientModule} from '@angular/common/http';
+import {ItemLocationSelectModule
+    } from '@eg/share/item-location-select/item-location-select.module';
+import {LineitemWorksheetComponent} from './worksheet.component';
+import {LineitemService} from './lineitem.service';
+import {LineitemComponent} from './lineitem.component';
+import {LineitemNotesComponent} from './notes.component';
+import {LineitemDetailComponent} from './detail.component';
+import {LineitemOrderSummaryComponent} from './order-summary.component';
+import {LineitemListComponent} from './lineitem-list.component';
+import {LineitemCopiesComponent} from './copies.component';
+import {LineitemBatchCopiesComponent} from './batch-copies.component';
+import {LineitemCopyAttrsComponent} from './copy-attrs.component';
+import {LineitemHistoryComponent} from './history.component';
+import {BriefRecordComponent} from './brief-record.component';
+import {CancelDialogComponent} from './cancel-dialog.component';
+import {MarcEditModule} from '@eg/staff/share/marc-edit/marc-edit.module';
+
+@NgModule({
+  declarations: [
+    LineitemComponent,
+    LineitemListComponent,
+    LineitemNotesComponent,
+    LineitemDetailComponent,
+    LineitemCopiesComponent,
+    LineitemOrderSummaryComponent,
+    LineitemBatchCopiesComponent,
+    LineitemCopyAttrsComponent,
+    LineitemHistoryComponent,
+    CancelDialogComponent,
+    BriefRecordComponent,
+    LineitemWorksheetComponent
+  ],
+  exports: [
+    LineitemListComponent,
+    CancelDialogComponent
+  ],
+  imports: [
+    StaffCommonModule,
+    ItemLocationSelectModule,
+    MarcEditModule
+  ],
+  providers: [
+    LineitemService
+  ]
+})
+
+export class LineitemModule {
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem.service.ts b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem.service.ts
new file mode 100644 (file)
index 0000000..a4e49a1
--- /dev/null
@@ -0,0 +1,300 @@
+import {Injectable, EventEmitter} from '@angular/core';
+import {Observable, from, concat, empty} from 'rxjs';
+import {switchMap, map, tap, merge} from 'rxjs/operators';
+import {IdlObject, IdlService} 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 {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {ItemLocationService} from '@eg/share/item-location-select/item-location-select.service';
+
+export interface BatchLineitemStruct {
+    id: number;
+    lineitem: IdlObject;
+    existing_copies: number;
+    all_locations: IdlObject[];
+    all_funds: IdlObject[];
+    all_circ_modifiers: IdlObject[];
+}
+
+export interface BatchLineitemUpdateStruct {
+    lineitem: IdlObject;
+    lid: number;
+    max: number;
+    progress: number;
+    complete: number; // Perl bool
+    total: number;
+    [key: string]: any; // Perl Acq::BatchManager
+}
+
+interface FleshedLineitemParams {
+
+    // Flesh data beyond the default.
+    fleshMore?: any;
+
+    // OK to pull the requested LI from the cache.
+    fromCache?: boolean;
+
+    // OK to add this LI to the cache.
+    // Generally a good thing, but if you are fetching an LI with
+    // fewer fleshed fields than the default, this could break code.
+    toCache?: boolean;
+}
+
+@Injectable()
+export class LineitemService {
+
+    liAttrDefs: IdlObject[];
+
+    // Emitted when our copy batch editor wants to apply a value
+    // to a set of inputs.  This allows the the copy input comboboxes, etc.
+    // to add the entry before it's forced to grab the value from the
+    // server, often in large parallel batches.
+    batchOptionWanted: EventEmitter<{[field: string]: ComboboxEntry}>
+        = new EventEmitter<{[field: string]: ComboboxEntry}> ();
+
+    // Emits a LI ID if the LI was edited in a way that could impact
+    // its activatability of its linked PO.
+    activateStateChange: EventEmitter<number> = new EventEmitter<number>();
+
+    // Cache for circ modifiers and funds; locations are cached in the
+    // item location select service.
+    circModCache: {[code: string]: IdlObject} = {};
+    fundCache: {[id: number]: IdlObject} = {};
+    liCache: {[id: number]: BatchLineitemStruct} = {};
+
+    // Alerts the user has already confirmed are OK.
+    alertAcks: {[id: number]: boolean} = {};
+
+    constructor(
+        private idl: IdlService,
+        private net: NetService,
+        private auth: AuthService,
+        private pcrud: PcrudService,
+        private loc: ItemLocationService
+    ) {}
+
+    getFleshedLineitems(ids: number[],
+        params: FleshedLineitemParams = {}): Observable<BatchLineitemStruct> {
+
+        if (params.fromCache) {
+            const fromCache = this.getLineitemsFromCache(ids);
+            if (fromCache) { return from(fromCache); }
+        }
+
+        const flesh: any = Object.assign({
+            flesh_attrs: true,
+            flesh_provider: true,
+            flesh_order_summary: true,
+            flesh_cancel_reason: true,
+            flesh_li_details: true,
+            flesh_notes: true,
+            flesh_fund: true,
+            flesh_circ_modifier: true,
+            flesh_location: true,
+            flesh_fund_debit: true,
+            flesh_po: true,
+            flesh_pl: true,
+            flesh_formulas: true,
+            flesh_copies: true,
+            clear_marc: false
+        }, params.fleshMore || {});
+
+        return this.net.request(
+            'open-ils.acq', 'open-ils.acq.lineitem.retrieve.batch',
+            this.auth.token(), ids, flesh
+        ).pipe(tap(liStruct =>
+            this.ingestLineitem(liStruct, params.toCache)));
+    }
+
+    getLineitemsFromCache(ids: number[]): BatchLineitemStruct[] {
+
+        const fromCache: BatchLineitemStruct[] = [];
+
+        ids.forEach(id => {
+            if (this.liCache[id]) { fromCache.push(this.liCache[id]); }
+        });
+
+        // Only return LI's from cache if all of the requested LI's
+        // are cached, otherwise they would be returned in the wrong
+        // order.  Typically it will be all or none so I'm not
+        // fussing with interleaving cached and uncached lineitems
+        // to fix the sorting.
+        if (fromCache.length === ids.length) { return fromCache; }
+
+        return null;
+    }
+
+    ingestLineitem(
+        liStruct: BatchLineitemStruct, toCache: boolean): BatchLineitemStruct {
+
+        const li = liStruct.lineitem;
+
+        // These values come through as NULL
+        const summary = li.order_summary();
+        if (!summary.estimated_amount()) { summary.estimated_amount(0); }
+        if (!summary.encumbrance_amount()) { summary.encumbrance_amount(0); }
+        if (!summary.paid_amount()) { summary.paid_amount(0); }
+
+        // Sort the formula applications
+        li.distribution_formulas(
+            li.distribution_formulas().sort((f1, f2) =>
+                f1.create_time() < f2.create_time() ? -1 : 1)
+        );
+
+        // consistent sorting
+        li.lineitem_details(
+            li.lineitem_details().sort((d1, d2) =>
+                d1.id() < d2.id() ? -1 : 1)
+        );
+
+        // De-flesh some values we don't want living directly on
+        // the copy.  Cache the values so our comboboxes, etc.
+        // can use them without have to re-fetch them .
+        li.lineitem_details().forEach(copy => {
+            let val;
+            if ((val = copy.circ_modifier())) { // assignment
+                this.circModCache[val.code()] = copy.circ_modifier();
+                copy.circ_modifier(val.code());
+            }
+            if ((val = copy.fund())) {
+                this.fundCache[val.id()] = copy.fund();
+                copy.fund(val.id());
+            }
+            if ((val = copy.location())) {
+                this.loc.locationCache[val.id()] = copy.location();
+                copy.location(val.id());
+            }
+        });
+
+        if (toCache) { this.liCache[li.id()] = liStruct; }
+        return liStruct;
+    }
+
+    // Returns all matching attributes
+    // 'li' should be fleshed with attributes()
+    getAttributes(li: IdlObject, name: string, attrType?: string): IdlObject[] {
+        const values: IdlObject[] = [];
+        li.attributes().forEach(attr => {
+            if (attr.attr_name() === name) {
+                if (!attrType || attrType === attr.attr_type()) {
+                    values.push(attr);
+                }
+            }
+        });
+
+        return values;
+    }
+
+    getAttributeValues(li: IdlObject, name: string, attrType?: string): string[] {
+        return this.getAttributes(li, name, attrType).map(attr => attr.attr_value());
+    }
+
+    // Returns the first matching attribute
+    // 'li' should be fleshed with attributes()
+    getFirstAttribute(li: IdlObject, name: string, attrType?: string): IdlObject {
+        return this.getAttributes(li, name, attrType)[0];
+    }
+
+    getFirstAttributeValue(li: IdlObject, name: string, attrType?: string): string {
+        const attr = this.getFirstAttribute(li, name, attrType);
+        return attr ? attr.attr_value() : '';
+    }
+
+    getOrderIdent(li: IdlObject): IdlObject {
+        for (let idx = 0; idx < li.attributes().length; idx++) {
+            const attr = li.attributes()[idx];
+            if (attr.order_ident() === 't' &&
+                attr.attr_type() === 'lineitem_local_attr_definition') {
+                return attr;
+            }
+        }
+        return null;
+    }
+
+    // Returns an updated copy of the lineitem
+    changeOrderIdent(li: IdlObject,
+        id: number, attrType: string, attrValue: string): Observable<IdlObject> {
+
+        const args: any = {lineitem_id: li.id()};
+
+        if (id) {
+            // Order ident set to an existing attribute.
+            args.source_attr_id = id;
+        } else {
+            // Order ident set to a new free text value
+            args.attr_name = attrType;
+            args.attr_value = attrValue;
+        }
+
+        return this.net.request(
+            'open-ils.acq',
+            'open-ils.acq.lineitem.order_identifier.set',
+            this.auth.token(), args
+        ).pipe(switchMap(_ => this.getFleshedLineitems([li.id()]))
+        ).pipe(map(struct => struct.lineitem));
+    }
+
+    applyBatchNote(liIds: number[],
+        noteValue: string, vendorPublic: boolean): Promise<any> {
+
+        if (!noteValue || liIds.length === 0) { return Promise.resolve(); }
+
+        const notes = [];
+        liIds.forEach(id => {
+            const note = this.idl.create('acqlin');
+            note.isnew(true);
+            note.lineitem(id);
+            note.value(noteValue);
+            note.vendor_public(vendorPublic ? 't' : 'f');
+            notes.push(note);
+        });
+
+        return this.net.request('open-ils.acq',
+            'open-ils.acq.lineitem_note.cud.batch',
+            this.auth.token(), notes
+        ).pipe(tap(resp => {
+            if (resp && resp.note) {
+                const li = this.liCache[resp.note.lineitem()].lineitem;
+                li.lineitem_notes().unshift(resp.note);
+            }
+        })).toPromise();
+    }
+
+    getLiAttrDefs(): Promise<IdlObject[]> {
+        if (this.liAttrDefs) {
+            return Promise.resolve(this.liAttrDefs);
+        }
+
+        return this.pcrud.retrieveAll('acqliad', {}, {atomic: true})
+        .toPromise().then(defs => this.liAttrDefs = defs);
+    }
+
+    updateLiDetails(li: IdlObject): Observable<BatchLineitemUpdateStruct> {
+        const lids = li.lineitem_details().filter(copy =>
+            (copy.isnew() || copy.ischanged() || copy.isdeleted()));
+
+        return this.net.request(
+            'open-ils.acq',
+            'open-ils.acq.lineitem_detail.cud.batch', this.auth.token(), lids);
+    }
+
+    updateLineitems(lis: IdlObject[]): Observable<BatchLineitemUpdateStruct> {
+
+        // Fire updates one LI at a time.  Note the API allows passing
+        // multiple LI's, but does not stream responses.  This approach
+        // allows the caller to get a stream of responses instead of a
+        // final "all done".
+        let obs: Observable<any> = empty();
+        lis.forEach(li => {
+            obs = concat(obs, this.net.request(
+                'open-ils.acq',
+                'open-ils.acq.lineitem.update',
+                this.auth.token(), li
+            ));
+        });
+
+        return obs;
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/lineitem/notes.component.html b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/notes.component.html
new file mode 100644 (file)
index 0000000..25f393c
--- /dev/null
@@ -0,0 +1,51 @@
+
+<div class="shadow border-top w-100">
+
+  <div class="p-1 m-1 form-inline">
+    <input type="text" class="form-control form-control-sm" id="note-text-input"
+      [(ngModel)]="noteText" placeholder="Note Text" i18n-placeholder/>
+    <div class="form-check ml-2">
+      <input class="form-check-input" type="checkbox" 
+        [(ngModel)]="vendorPublic" id="vendor-public-cbox">
+      <label class="form-check-label" for="vendor-public-cbox" i18n>
+        Vendor Public
+      </label>
+    </div>
+    <button class="btn btn-sm btn-success ml-2" [disabled]="!noteText" 
+      (click)="newNote()" i18n>New Note</button>
+    <span class="ml-2">
+      <eg-combobox idlClass="acqliat" [(ngModel)]="alertEntry" 
+        [asyncSupportsEmptyTermClick]="true">
+      </eg-combobox>
+    </span>
+    <button class="btn btn-sm btn-info ml-2" [disabled]="!alertEntry"
+      (click)="newNote(true)" i18n>New Alert</button>
+    <a class="ml-auto" href="javascript:;" (click)="close()" title="Close" i18n-title>
+      <span class="material-icons text-danger">close</span>
+    </a>
+  </div>
+
+  <div *ngFor="let note of lineitem.lineitem_notes()">
+    <div class="d-flex m-1 p-2 border">
+      <div class="flex-1 p-1">
+        <ng-container *ngIf="note.vendor_public() == 't'">
+          <div class="text-primary" i18n>VENDOR PUBLIC</div>
+        </ng-container>
+        <ng-container *ngIf="note.alert_text()">
+          <span class="text-danger" i18n>
+            [{{orgSn(note.alert_text().owning_lib())}}] {{note.alert_text().code()}}
+          </span>
+        </ng-container>
+      </div>
+      <div class="flex-5 ml-2 p-1">{{note.value()}}</div>
+      <div class="ml-2 p-1">{{note.create_time() | date:'short'}}</div>
+      <div class="ml-2 p-1">
+        <a href="javascript:;" class="text-danger" 
+          (click)="deleteNote(note)" i18n>Delete</a>
+      </div>
+    </div>
+  </div>
+</div>
+
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/lineitem/notes.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/notes.component.ts
new file mode 100644 (file)
index 0000000..963470d
--- /dev/null
@@ -0,0 +1,81 @@
+import {Component, OnInit, AfterViewInit, Input, Output, EventEmitter} from '@angular/core';
+import {Observable} from 'rxjs';
+import {IdlObject, IdlService} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {OrgService} from '@eg/core/org.service';
+import {AuthService} from '@eg/core/auth.service';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+
+@Component({
+  templateUrl: 'notes.component.html',
+  selector: 'eg-lineitem-notes'
+})
+export class LineitemNotesComponent implements OnInit, AfterViewInit {
+
+    @Input() lineitem: IdlObject;
+    noteText: string;
+    vendorPublic = false;
+    alertEntry: ComboboxEntry;
+
+    @Output() closeRequested: EventEmitter<void> = new EventEmitter<void>();
+
+    constructor(
+        private idl: IdlService,
+        private org: OrgService,
+        private auth: AuthService,
+        private net: NetService
+    ) {}
+
+    ngOnInit() {
+    }
+
+    ngAfterViewInit() {
+        const node = document.getElementById('note-text-input');
+        if (node) { node.focus(); }
+    }
+
+    orgSn(id: number): string {
+        return this.org.get(id).shortname();
+    }
+
+    close() {
+        this.closeRequested.emit();
+    }
+
+    newNote(isAlert?: boolean) {
+        const note = this.idl.create('acqlin');
+        note.isnew(true);
+        note.lineitem(this.lineitem.id());
+        note.value(this.noteText || '');
+        if (isAlert) {
+            note.alert_text(this.alertEntry.id);
+        } else {
+            note.vendor_public(this.vendorPublic ? 't' : 'f');
+        }
+
+        this.modifyNotes(note).subscribe(resp => {
+            if (resp.note) {
+                this.lineitem.lineitem_notes().unshift(resp.note);
+            }
+        });
+    }
+
+    deleteNote(note: IdlObject) {
+        note.isdeleted(true);
+        this.modifyNotes(note).toPromise().then(_ => {
+            this.lineitem.lineitem_notes(
+                this.lineitem.lineitem_notes().filter(n => n.id() !== note.id())
+            );
+        });
+    }
+
+    modifyNotes(notes: IdlObject | IdlObject[]): Observable<any> {
+        notes = [].concat(notes);
+
+        return this.net.request(
+            'open-ils.acq',
+            'open-ils.acq.lineitem_note.cud.batch',
+            this.auth.token(), notes);
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/lineitem/order-summary.component.html b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/order-summary.component.html
new file mode 100644 (file)
index 0000000..d4b7482
--- /dev/null
@@ -0,0 +1,20 @@
+<ng-container *ngIf="li">
+  <span i18n>{{li.order_summary().item_count()}} Items</span>
+  <span class="ml-1 mr-1" i18n> | </span>
+  <span i18n>{{li.order_summary().recv_count()}} Received</span>
+  <span class="ml-1 mr-1" i18n> | </span>
+  <span i18n>{{li.order_summary().invoice_count()}} Invoiced</span>
+  <span class="ml-1 mr-1" i18n> | </span>
+  <span i18n>{{li.order_summary().cancel_count()}} Canceled</span>
+  <span class="ml-1 mr-1" i18n> | </span>
+  <span i18n>{{li.order_summary().delay_count()}} Delayed</span>
+  <span class="ml-1 mr-1" i18n> | </span>
+  <span i18n>{{li.order_summary().estimated_amount() | currency}} Estimated</span>
+  <span class="ml-1 mr-1" i18n> | </span>
+  <span i18n>{{li.order_summary().encumbrance_amount() | currency}} Encumbered</span>
+  <span class="ml-1 mr-1" i18n> | </span>
+  <span [ngClass]="{'text-danger': paidOff()}" i18n>
+    {{li.order_summary().paid_amount() | currency}} Paid
+  </span>
+</ng-container>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/lineitem/order-summary.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/order-summary.component.ts
new file mode 100644 (file)
index 0000000..93a4c80
--- /dev/null
@@ -0,0 +1,22 @@
+import {Component, OnInit, Input, Output} from '@angular/core';
+import {IdlObject} from '@eg/core/idl.service';
+
+@Component({
+  templateUrl: 'order-summary.component.html',
+  selector: 'eg-lineitem-order-summary'
+})
+export class LineitemOrderSummaryComponent {
+    @Input() li: IdlObject;
+
+    // True if at least one item has been invoiced and all items are either
+    // invoiced or canceled.
+    paidOff(): boolean {
+        const sum = this.li.order_summary();
+        return (
+            sum.invoice_count() > 0 && (
+                sum.item_count() === (sum.invoice_count() + sum.cancel_count())
+            )
+        );
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/lineitem/worksheet.component.html b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/worksheet.component.html
new file mode 100644 (file)
index 0000000..7261e57
--- /dev/null
@@ -0,0 +1,10 @@
+<div class="row">
+  <div class="p-2 m-2 ml-4">
+    <button class="btn btn-outline-dark" (click)="printWorksheet()" i18n>
+      Print Worksheet
+    </button>
+  </div>
+</div>
+
+<div id="worksheet-outlet" class="m-3 p-3 w-90 border border-dark"></div>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/lineitem/worksheet.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/worksheet.component.ts
new file mode 100644 (file)
index 0000000..21c2a13
--- /dev/null
@@ -0,0 +1,125 @@
+import {Component, OnInit, AfterViewInit} from '@angular/core';
+import {map, take} from 'rxjs/operators';
+import {ActivatedRoute, ParamMap} from '@angular/router';
+import {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 {OrgService} from '@eg/core/org.service';
+import {LineitemService} from './lineitem.service';
+import {PrintService} from '@eg/share/print/print.service';
+
+@Component({
+  templateUrl: 'worksheet.component.html'
+})
+export class LineitemWorksheetComponent implements OnInit, AfterViewInit {
+
+    outlet: Element;
+    lineitemId: number;
+    lineitem: IdlObject;
+    holdCount: number;
+    printing: boolean;
+    closing: boolean;
+
+    constructor(
+        private route: ActivatedRoute,
+        private org: OrgService,
+        private net: NetService,
+        private auth: AuthService,
+        private pcrud: PcrudService,
+        private printer: PrintService,
+        private liService: LineitemService
+    ) { }
+
+    ngOnInit() {
+
+        this.route.paramMap.subscribe((params: ParamMap) => {
+            const id = +params.get('lineitemId');
+            if (id !== this.lineitemId) {
+                this.lineitemId = id;
+                if (id) { this.load(); }
+            }
+        });
+    }
+
+    ngAfterViewInit() {
+        this.outlet = document.getElementById('worksheet-outlet');
+    }
+
+    load() {
+        if (!this.lineitemId) { return; }
+
+        this.net.request(
+            'open-ils.acq', 'open-ils.acq.lineitem.retrieve',
+            this.auth.token(), this.lineitemId, {
+                flesh_attrs: true,
+                flesh_notes: true,
+                flesh_cancel_reason: true,
+                flesh_li_details: true,
+                flesh_fund: true,
+                flesh_li_details_copy: true,
+                flesh_li_details_location: true,
+                flesh_li_details_receiver: true,
+                distribution_formulas: true
+            }
+        ).toPromise()
+        .then(li => this.lineitem = li)
+        .then(_ => this.getRemainingData())
+        .then(_ => this.populatePreview());
+    }
+
+    getRemainingData(): Promise<any> {
+
+        // Flesh owning lib
+        this.lineitem.lineitem_details().forEach(lid => {
+            lid.owning_lib(this.org.get(lid.owning_lib()));
+        });
+
+        return this.net.request(
+            'open-ils.circ',
+            'open-ils.circ.bre.holds.count', this.lineitem.eg_bib_id()
+        ).toPromise().then(count => this.holdCount = count);
+
+    }
+
+    populatePreview(): Promise<any> {
+
+        return this.printer.compileRemoteTemplate({
+            templateName: 'lineitem_worksheet',
+            printContext: 'default',
+            contextData: {
+                lineitem: this.lineitem,
+                hold_count: this.holdCount
+            }
+
+        }).then(response => {
+            this.outlet.innerHTML = response.content;
+        });
+    }
+
+    printWorksheet(closeTab?: boolean) {
+
+        if (closeTab || this.closing) {
+            const sub: any = this.printer.printJobQueued$.subscribe(
+                req => {
+                    if (req.templateName === 'lineitem_worksheet') {
+                        setTimeout(() => {
+                            window.close();
+                            sub.unsubscribe();
+                        }, 2000); // allow for a time cushion past queueing.
+                    }
+                }
+            );
+        }
+
+        this.printer.print({
+            templateName: 'lineitem_worksheet',
+            contextData: {
+                lineitem: this.lineitem,
+                hold_count: this.holdCount
+            },
+            printContext: 'default'
+        });
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/picklist/picklist.component.html b/Open-ILS/src/eg2/src/app/staff/acq/picklist/picklist.component.html
new file mode 100644 (file)
index 0000000..c4fe1b6
--- /dev/null
@@ -0,0 +1,14 @@
+<eg-staff-banner bannerText="Selection List" i18n-bannerText></eg-staff-banner>
+
+<eg-acq-picklist-summary [picklistId]="picklistId"></eg-acq-picklist-summary>
+
+<ng-container *ngIf="!isBasePage()">
+  <div class="mt-2">
+    <a routerLink="/staff/acq/picklist/{{picklistId}}" queryParamsHandling="merge">
+      <button class="btn btn-sm btn-info" i18n>Return</button>
+    </a>
+  </div>
+</ng-container>
+
+<router-outlet></router-outlet>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/picklist/picklist.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/picklist/picklist.component.ts
new file mode 100644 (file)
index 0000000..52da433
--- /dev/null
@@ -0,0 +1,28 @@
+import {Component, OnInit} from '@angular/core';
+import {ActivatedRoute, ParamMap} from '@angular/router';
+
+/**
+ * Parent component for all Selection List sub-displays.
+ */
+
+
+@Component({
+  templateUrl: 'picklist.component.html'
+})
+export class PicklistComponent implements OnInit {
+
+    picklistId: number;
+
+    constructor(private route: ActivatedRoute) {}
+
+    ngOnInit() {
+        this.route.paramMap.subscribe((params: ParamMap) => {
+            this.picklistId = +params.get('picklistId');
+        });
+    }
+
+    isBasePage(): boolean {
+        return !this.route.firstChild ||
+            this.route.firstChild.snapshot.url.length === 0;
+    }
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/picklist/picklist.module.ts b/Open-ILS/src/eg2/src/app/staff/acq/picklist/picklist.module.ts
new file mode 100644 (file)
index 0000000..f39c96f
--- /dev/null
@@ -0,0 +1,25 @@
+import {NgModule} from '@angular/core';
+import {StaffCommonModule} from '@eg/staff/common.module';
+import {CatalogCommonModule} from '@eg/share/catalog/catalog-common.module';
+import {LineitemModule} from '@eg/staff/acq/lineitem/lineitem.module';
+import {HoldingsModule} from '@eg/staff/share/holdings/holdings.module';
+import {PicklistRoutingModule} from './routing.module';
+import {PicklistComponent} from './picklist.component';
+import {PicklistSummaryComponent} from './summary.component';
+
+@NgModule({
+  declarations: [
+    PicklistComponent,
+    PicklistSummaryComponent
+  ],
+  imports: [
+    StaffCommonModule,
+    CatalogCommonModule,
+    LineitemModule,
+    HoldingsModule,
+    PicklistRoutingModule
+  ],
+  providers: []
+})
+
+export class PicklistModule {}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/picklist/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/acq/picklist/routing.module.ts
new file mode 100644 (file)
index 0000000..f641e51
--- /dev/null
@@ -0,0 +1,42 @@
+import {NgModule} from '@angular/core';
+import {RouterModule, Routes} from '@angular/router';
+import {PicklistComponent} from './picklist.component';
+import {PicklistSummaryComponent} from './summary.component';
+import {LineitemListComponent} from '../lineitem/lineitem-list.component';
+import {LineitemDetailComponent} from '../lineitem/detail.component';
+import {LineitemCopiesComponent} from '../lineitem/copies.component';
+import {LineitemWorksheetComponent} from '../lineitem/worksheet.component';
+import {BriefRecordComponent} from '../lineitem/brief-record.component';
+import {LineitemHistoryComponent} from '../lineitem/history.component';
+
+const routes: Routes = [{
+  path: ':picklistId',
+  component: PicklistComponent,
+  children : [{
+    path: '',
+    component: LineitemListComponent
+  }, {
+    path: 'brief-record',
+    component: BriefRecordComponent
+  }, {
+    path: 'lineitem/:lineitemId/detail',
+    component: LineitemDetailComponent
+  }, {
+    path: 'lineitem/:lineitemId/history',
+    component: LineitemHistoryComponent
+  }, {
+    path: 'lineitem/:lineitemId/items',
+    component: LineitemCopiesComponent
+  }, {
+    path: 'lineitem/:lineitemId/worksheet',
+    component: LineitemWorksheetComponent
+  }]
+}];
+
+@NgModule({
+  imports: [RouterModule.forChild(routes)],
+  exports: [RouterModule],
+  providers: []
+})
+
+export class PicklistRoutingModule {}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/picklist/summary.component.html b/Open-ILS/src/eg2/src/app/staff/acq/picklist/summary.component.html
new file mode 100644 (file)
index 0000000..2582d9d
--- /dev/null
@@ -0,0 +1,26 @@
+
+<div *ngIf="picklist" class="row pt-1 pb-1 border border-secondary rounded">
+  <div class="col-lg-4 form-inline">
+    <span class="mr-2" i18n>Selection List:</span>
+    <ng-container *ngIf="editPlName">
+      <input id='pl-name-input' type="text" class="form-control" 
+        [(ngModel)]="newPlName" (blur)="toggleNameEdit()"/>
+    </ng-container>
+    <ng-container *ngIf="!editPlName">
+      <a (click)="toggleNameEdit()" href='javascript:;' 
+        class='font-weight-bold' i18n>{{picklist.name()}} (#{{picklist.id()}})</a>
+    </ng-container>
+  </div>
+  <div class="col-lg-2" i18n>
+    Create Date: {{picklist.create_time() | date:'shortDate'}}
+  </div>
+  <div class="col-lg-2" i18n>
+    Last Updated: {{picklist.edit_time() | date:'shortDate'}}
+  </div>
+  <div class="col-lg-2" i18n>
+    Selector: {{picklist.owner().usrname()}}
+  </div>
+  <div class="col-lg-2" i18n>
+    Entry Count: {{picklist.entry_count()}}
+  </div>
+</div>
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/picklist/summary.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/picklist/summary.component.ts
new file mode 100644 (file)
index 0000000..9ee226c
--- /dev/null
@@ -0,0 +1,117 @@
+import {Component, Input, OnInit, AfterViewInit, ViewChild} from '@angular/core';
+import {of, Observable} from 'rxjs';
+import {tap, take, map} from 'rxjs/operators';
+import {IdlObject, IdlService} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {FormatService} from '@eg/core/format.service';
+import {AuthService} from '@eg/core/auth.service';
+import {OrgService} from '@eg/core/org.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {StoreService} from '@eg/core/store.service';
+import {ServerStoreService} from '@eg/core/server-store.service';
+import {ComboboxEntry, ComboboxComponent} from '@eg/share/combobox/combobox.component';
+import {ProgressDialogComponent} from '@eg/share/dialog/progress.component';
+import {EventService} from '@eg/core/event.service';
+import {HoldingsService} from '@eg/staff/share/holdings/holdings.service';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+import {BroadcastService} from '@eg/share/util/broadcast.service';
+
+
+@Component({
+  templateUrl: 'summary.component.html',
+  selector: 'eg-acq-picklist-summary'
+})
+export class PicklistSummaryComponent implements OnInit, AfterViewInit {
+
+    private _picklistId: number;
+    @Input() set picklistId(id: number) {
+        if (id !== this._picklistId) {
+            this._picklistId = id;
+            if (this.initDone) {
+                this.load();
+            }
+        }
+    }
+
+    get picklistId(): number {
+        return this._picklistId;
+    }
+
+    picklist: IdlObject;
+    newPlName: string;
+    editPlName = false;
+    initDone = false;
+
+    constructor(
+        private idl: IdlService,
+        private net: NetService,
+        private format: FormatService,
+        private evt: EventService,
+        private org: OrgService,
+        private pcrud: PcrudService,
+        private auth: AuthService,
+        private store: StoreService,
+        private serverStore: ServerStoreService,
+        private broadcaster: BroadcastService,
+        private holdingSvc: HoldingsService
+    ) {}
+
+    ngOnInit() {
+        this.load().then(_ => this.initDone = true);
+    }
+
+    ngAfterViewInit() {
+    }
+
+    load(): Promise<any> {
+        this.picklist = null;
+        if (!this.picklistId) { return Promise.resolve(); }
+
+        return this.net.request(
+            'open-ils.acq',
+            'open-ils.acq.picklist.retrieve.authoritative',
+            this.auth.token(), this.picklistId,
+            {flesh_lineitem_count: true, flesh_owner: true}
+        ).toPromise().then(list => {
+
+            const evt = this.evt.parse(list);
+            if (evt) {
+                console.error('API returned ', evt);
+                return Promise.reject();
+            }
+
+            this.picklist = list;
+        });
+    }
+
+    toggleNameEdit() {
+        this.editPlName = !this.editPlName;
+
+        if (this.editPlName) {
+            this.newPlName = this.picklist.name();
+            setTimeout(() => {
+                const node =
+                    document.getElementById('pl-name-input') as HTMLInputElement;
+                if (node) { node.select(); }
+            });
+
+        } else if (this.newPlName && this.newPlName !== this.picklist.name()) {
+
+            const prevName = this.picklist.name();
+            this.picklist.name(this.newPlName);
+            this.newPlName = null;
+
+            this.net.request(
+                'open-ils.acq',
+                'open-ils.acq.picklist.update',
+                this.auth.token(), this.picklist
+            ).subscribe(resp => {
+                const evt = this.evt.parse(resp);
+                if (evt) {
+                    alert(evt);
+                    this.picklist.name(prevName);
+                }
+            });
+        }
+    }
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/po/charges.component.html b/Open-ILS/src/eg2/src/app/staff/acq/po/charges.component.html
new file mode 100644 (file)
index 0000000..753f730
--- /dev/null
@@ -0,0 +1,65 @@
+
+<h4 i18n>Direct Charges, Taxes, Fees, etc. 
+  <button class="btn btn-info btn-sm" (click)="newCharge()">New Charge</button>
+</h4>
+
+<ng-container *ngIf="showBody">
+  <div class="row d-flex">
+    <div class="flex-2 p-2 font-weight-bold">Charge Type</div>
+    <div class="flex-2 p-2 font-weight-bold">Fund</div>
+    <div class="flex-2 p-2 font-weight-bold">Title/Description</div>
+    <div class="flex-2 p-2 font-weight-bold">Author</div>
+    <div class="flex-2 p-2 font-weight-bold">Note</div>
+    <div class="flex-2 p-2 font-weight-bold">Estimated Cost</div>
+    <div class="flex-2 p-2"> </div>
+  </div>
+  <div class="row mt-2 pt-2 d-flex border-top form-validated" 
+    *ngFor="let charge of po().po_items()">
+    <div class="flex-2 p-2">
+      <eg-combobox idlClass="aiit" [selectedId]="charge.inv_item_type()"
+        (onChange)="charge.inv_item_type($event ? $event.id : null)"
+        i18n-placeholder placeholder="Charge Type..."
+        [required]="true" [readOnly]="!charge.isnew()"></eg-combobox>
+    </div>
+    <div class="flex-2 p-2">
+      <!--  the IDL does not require a fund, but the Perl code assumes
+            one is present -->
+      <eg-combobox idlClass="acqf" [selectedId]="charge.fund()"
+        (onChange)="charge.fund($event ? $event.id : null)"
+        i18n-placeholder placeholder="Fund..."
+        [required]="true" [readOnly]="!charge.isnew()"></eg-combobox>
+    </div>
+    <div class="flex-2 p-2">
+      <span *ngIf="!charge.isnew()">{{charge.title()}}</span>
+      <input *ngIf="charge.isnew()" type="text" class="form-control" 
+        i18n-placeholder placeholder="Title..."
+        [ngModel]="charge.title()" (ngModelChange)="charge.title($event)"/>
+    </div>
+    <div class="flex-2 p-2">
+      <span *ngIf="!charge.isnew()">{{charge.author()}}</span>
+      <input *ngIf="charge.isnew()" type="text" class="form-control" 
+        i18n-placeholder placeholder="Author..."
+        [ngModel]="charge.author()" (ngModelChange)="charge.author($event)"/>
+    </div>
+    <div class="flex-2 p-2">
+      <span *ngIf="!charge.isnew()">{{charge.note()}}</span>
+      <input *ngIf="charge.isnew()" type="text" class="form-control" 
+        i18n-placeholder placeholder="Note..."
+        [ngModel]="charge.note()" (ngModelChange)="charge.note($event)"/>
+    </div>
+    <div class="flex-2 p-2">
+      <span *ngIf="!charge.isnew()">{{charge.estimated_cost() | currency}}</span>
+      <input *ngIf="charge.isnew()" type="number" min="0" class="form-control" 
+        i18n-placeholder placeholder="Esimated Cost..."
+        [ngModel]="charge.estimated_cost()" (ngModelChange)="charge.estimated_cost($event)"/>
+    </div>
+    <div class="flex-1 p-1">
+      <button *ngIf="charge.isnew()" class="btn btn-success btn-sm" 
+        (click)="saveCharge(charge)" i18n>Save</button>
+    </div>
+    <div class="flex-1 p-1">
+      <button class="btn btn-danger btn-sm" 
+        (click)="removeCharge(charge)" i18n>Remove</button>
+    </div>
+  </div>
+</ng-container>
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/po/charges.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/po/charges.component.ts
new file mode 100644 (file)
index 0000000..646b98c
--- /dev/null
@@ -0,0 +1,67 @@
+import {Component, OnInit, Input} from '@angular/core';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {PoService} from './po.service';
+
+@Component({
+  templateUrl: 'charges.component.html',
+  selector: 'eg-acq-po-charges'
+})
+export class PoChargesComponent implements OnInit {
+
+    showBody = false;
+    autoId = -1;
+
+    constructor(
+        private idl: IdlService,
+        private pcrud: PcrudService,
+        public  poService: PoService
+    ) {}
+
+    ngOnInit() {
+        this.poService.poRetrieved.subscribe(() => {
+            if (this.po().po_items().length > 0) {
+                this.showBody = true;
+            }
+        });
+    }
+
+    po(): IdlObject {
+        return this.poService.currentPo;
+    }
+
+    newCharge() {
+        this.showBody = true;
+        const chg = this.idl.create('acqpoi');
+        chg.isnew(true);
+        chg.purchase_order(this.po().id());
+        chg.id(this.autoId--);
+        this.po().po_items().push(chg);
+    }
+
+    saveCharge(charge: IdlObject) {
+        if (!charge.inv_item_type()) { return; }
+
+        charge.id(undefined);
+        this.pcrud.create(charge).toPromise()
+        .then(item => {
+            charge.id(item.id());
+            charge.isnew(false);
+        })
+        .then(_ => this.poService.refreshOrderSummary());
+    }
+
+    removeCharge(charge: IdlObject) {
+        this.po().po_items( // remove local copy
+            this.po().po_items().filter(item => item.id() !== charge.id())
+        );
+
+        if (!charge.isnew()) {
+            this.pcrud.remove(charge).toPromise()
+            .then(_ => this.poService.refreshOrderSummary());
+        }
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/po/create.component.html b/Open-ILS/src/eg2/src/app/staff/acq/po/create.component.html
new file mode 100644 (file)
index 0000000..7f7b218
--- /dev/null
@@ -0,0 +1,38 @@
+<eg-staff-banner bannerText="Create Purchase Order" i18n-bannerText>
+</eg-staff-banner>
+
+<div class="col-lg-4 offset-lg-4">
+  <div *ngIf="lineitems.length">
+    <span i18n>Creating for {{lineitems.length}} lineitems.</span>
+    <hr class="p-1" />
+  </div>
+  <div class="form-group">
+    <label for="order-agency-input" i18n>Ordering Agency</label>
+    <eg-org-select (onChange)="orgChange($event)" domId="order-agency-input">
+    </eg-org-select>
+  </div>
+  <div class="form-group">
+    <label for="name-input" i18n>Name (optional)</label>
+    <input id="name-input" class="form-control" type="text" [(ngModel)]="poName"/>
+  </div>
+  <div class="form-group">
+    <label for="name-input" i18n>Provider</label>
+    <eg-combobox domId="provider-input" [(ngModel)]="provider" idlClass="acqpro">
+    </eg-combobox>
+  </div>
+  <div class="form-group form-check">
+    <input type="checkbox" class="form-check-input" 
+      [(ngModel)]="prepaymentRequired" id="prepayment-required">
+    <label class="form-check-label" for="prepayment-required" i18n>Prepayment Required</label>
+  </div>
+  <div class="form-group form-check">
+    <input type="checkbox" class="form-check-input" 
+      [(ngModel)]="createAssets" id="create-assets">
+    <label class="form-check-label" for="create-assets" i18n>
+      Import Bibs and Create Copies
+    </label>
+  </div>
+  <hr class="p-1" />
+  <button [disabled]="!canCreate()" (click)="create()" 
+    type="submit" class="btn btn-primary" i18n>Create</button>
+</div>
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/po/create.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/po/create.component.ts
new file mode 100644 (file)
index 0000000..fc287f2
--- /dev/null
@@ -0,0 +1,102 @@
+import {Component, Input, OnInit, ViewChild} from '@angular/core';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {of, Observable} from 'rxjs';
+import {tap, take, map} from 'rxjs/operators';
+import {IdlObject, IdlService} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {OrgService} from '@eg/core/org.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {ServerStoreService} from '@eg/core/server-store.service';
+import {ComboboxEntry, ComboboxComponent} from '@eg/share/combobox/combobox.component';
+import {ProgressDialogComponent} from '@eg/share/dialog/progress.component';
+import {EventService, EgEvent} from '@eg/core/event.service';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+import {PoService} from './po.service';
+import {LineitemService} from '../lineitem/lineitem.service';
+import {CancelDialogComponent} from '../lineitem/cancel-dialog.component';
+
+
+@Component({
+  templateUrl: 'create.component.html',
+  selector: 'eg-acq-po-create'
+})
+export class PoCreateComponent implements OnInit {
+
+    initDone = false;
+    lineitems: number[] = [];
+    poName: string;
+    orderAgency: number;
+    provider: ComboboxEntry;
+    prepaymentRequired = false;
+    createAssets = false;
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private evt: EventService,
+        private idl: IdlService,
+        private net: NetService,
+        private org: OrgService,
+        private pcrud: PcrudService,
+        private auth: AuthService,
+        private store: ServerStoreService,
+        private liService: LineitemService,
+        private poService: PoService
+    ) {}
+
+    ngOnInit() {
+        this.route.queryParamMap.subscribe((params: ParamMap) => {
+            this.lineitems = params.getAll('li').map(id => Number(id));
+        });
+
+        this.load().then(_ => this.initDone = true);
+    }
+
+    load(): Promise<any> {
+        return Promise.resolve();
+    }
+
+    orgChange(org: IdlObject) {
+        this.orderAgency = org ? org.id() : null;
+    }
+
+    canCreate(): boolean {
+        return (Boolean(this.orderAgency) && Boolean(this.provider));
+    }
+
+    create() {
+
+        const po = this.idl.create('acqpo');
+        po.ordering_agency(this.orderAgency);
+        po.provider(this.provider.id);
+        po.name(this.poName || null);
+        po.prepayment_required(this.prepaymentRequired ? 't' : 'f');
+
+        const args: any = {};
+        if (this.lineitems.length > 0) {
+            args.lineitems = this.lineitems;
+        }
+
+        if (this.createAssets) {
+            // This version simply creates all records sans Vandelay merging, etc.
+            // TODO: go to asset creator.
+            args.vandelay = {
+                import_no_match: true,
+                queue_name: `ACQ ${new Date().toISOString()}`
+            };
+        }
+
+        this.net.request('open-ils.acq',
+            'open-ils.acq.purchase_order.create',
+            this.auth.token(), po, args
+        ).toPromise().then(resp => {
+            if (resp && resp.purchase_order) {
+                this.router.navigate(
+                    ['/staff/acq/po/' + resp.purchase_order.id()]);
+            }
+        });
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/po/edi.component.html b/Open-ILS/src/eg2/src/app/staff/acq/po/edi.component.html
new file mode 100644 (file)
index 0000000..2a21b5a
--- /dev/null
@@ -0,0 +1,9 @@
+
+<!-- TODO: workstation setting -->
+
+<div class="mt-3">
+  <eg-grid idlClass="acqedim" [dataSource]="dataSource" [sortable]="true"
+    persistKey="acq.po.edi_messages"
+    hideFields="id">
+  </eg-grid>
+</div>
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/po/edi.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/po/edi.component.ts
new file mode 100644 (file)
index 0000000..d6206a1
--- /dev/null
@@ -0,0 +1,46 @@
+import {Component, OnInit, Input, Output} from '@angular/core';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {empty} from 'rxjs';
+import {Pager} from '@eg/share/util/pager';
+import {IdlObject} from '@eg/core/idl.service';
+import {GridDataSource} from '@eg/share/grid/grid';
+import {PcrudService} from '@eg/core/pcrud.service';
+
+@Component({templateUrl: 'edi.component.html'})
+export class PoEdiMessagesComponent implements OnInit {
+
+    poId: number;
+    dataSource: GridDataSource = new GridDataSource();
+
+    constructor(
+        private route: ActivatedRoute,
+        private pcrud: PcrudService
+    ) {}
+
+    ngOnInit() {
+        this.dataSource.getRows = (pager: Pager, sort: any) =>
+            this.getEdiMessages(pager, sort);
+
+        this.route.parent.paramMap.subscribe((params: ParamMap) => {
+            this.poId = +params.get('poId');
+        });
+   }
+
+    getEdiMessages(pager: Pager, sort: any) {
+        if (!this.poId) { return empty(); }
+
+        const orderBy: any = {acqedim: 'create_time DESC'};
+        if (sort.length) {
+            orderBy.acqedim = sort[0].name + ' ' + sort[0].dir;
+        }
+
+        return this.pcrud.search('acqedim', {purchase_order: this.poId}, {
+            offset: pager.offset,
+            limit: pager.limit,
+            order_by: orderBy,
+            flesh: 1,
+            flesh_fields: {acqedim: ['account', 'purchase_order']}
+        });
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/po/history.component.html b/Open-ILS/src/eg2/src/app/staff/acq/po/history.component.html
new file mode 100644 (file)
index 0000000..04a9b12
--- /dev/null
@@ -0,0 +1,12 @@
+
+<!-- TODO: workstation setting -->
+
+<div class="mt-3">
+  <eg-grid idlClass="acqpoh" [dataSource]="dataSource" [sortable]="true"
+    persistKey="acq.po.history"
+    hideFields="id,audit_id,audit_time,audit_action">
+    <eg-grid-column name="create_time" [datePlusTime]="true"></eg-grid-column>
+    <eg-grid-column name="edit_time" [datePlusTime]="true"></eg-grid-column>
+    <eg-grid-column name="order_date" [datePlusTime]="true"></eg-grid-column>
+  </eg-grid>
+</div>
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/po/history.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/po/history.component.ts
new file mode 100644 (file)
index 0000000..92755d8
--- /dev/null
@@ -0,0 +1,48 @@
+import {Component, OnInit, Input, Output} from '@angular/core';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {empty} from 'rxjs';
+import {Pager} from '@eg/share/util/pager';
+import {IdlObject} from '@eg/core/idl.service';
+import {GridDataSource} from '@eg/share/grid/grid';
+import {PcrudService} from '@eg/core/pcrud.service';
+
+@Component({templateUrl: 'history.component.html'})
+export class PoHistoryComponent implements OnInit {
+
+    poId: number;
+    dataSource: GridDataSource = new GridDataSource();
+
+    constructor(
+        private route: ActivatedRoute,
+        private pcrud: PcrudService
+    ) {}
+
+    ngOnInit() {
+        this.dataSource.getRows = (pager: Pager, sort: any) =>
+            this.getHistory(pager, sort);
+
+        this.route.parent.paramMap.subscribe((params: ParamMap) => {
+            this.poId = +params.get('poId');
+        });
+   }
+
+    getHistory(pager: Pager, sort: any) {
+        if (!this.poId) { return empty(); }
+
+        const orderBy: any = {acqpoh: 'edit_time DESC'};
+        if (sort.length) {
+            orderBy.acqpoh = sort[0].name + ' ' + sort[0].dir;
+        }
+
+        return this.pcrud.search('acqpoh', {id: this.poId}, {
+            offset: pager.offset,
+            limit: pager.limit,
+            order_by: orderBy,
+            flesh: 1,
+            flesh_fields: {
+                acqpoh: ['owner', 'creator', 'editor', 'provider', 'cancel_reason']
+            }
+        });
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/po/notes.component.html b/Open-ILS/src/eg2/src/app/staff/acq/po/notes.component.html
new file mode 100644 (file)
index 0000000..8227a7c
--- /dev/null
@@ -0,0 +1,39 @@
+
+<div class="shadow border-top w-100">
+
+  <div class="p-1 m-1 form-inline">
+    <input type="text" class="form-control form-control-sm" id="note-text-input"
+      [(ngModel)]="noteText" placeholder="Note Text" i18n-placeholder/>
+    <div class="form-check ml-2">
+      <input class="form-check-input" type="checkbox" 
+        [(ngModel)]="vendorPublic" id="vendor-public-cbox">
+      <label class="form-check-label" for="vendor-public-cbox" i18n>
+        Vendor Public
+      </label>
+    </div>
+    <button class="btn btn-sm btn-success ml-2" [disabled]="!noteText" 
+      (click)="newNote()" i18n>New Note</button>
+    <a class="ml-auto" href="javascript:;" (click)="close()" title="Close" i18n-title>
+      <span class="material-icons text-danger">close</span>
+    </a>
+  </div>
+
+  <div *ngFor="let note of po.notes()">
+    <div class="d-flex m-1 p-2 border">
+      <div class="flex-1 p-1">
+        <ng-container *ngIf="note.vendor_public() == 't'">
+          <div class="text-primary" i18n>VENDOR PUBLIC</div>
+        </ng-container>
+      </div>
+      <div class="flex-5 ml-2 p-1">{{note.value()}}</div>
+      <div class="ml-2 p-1">{{note.create_time() | date:'short'}}</div>
+      <div class="ml-2 p-1">
+        <a href="javascript:;" class="text-danger" 
+          (click)="deleteNote(note)" i18n>Delete</a>
+      </div>
+    </div>
+  </div>
+</div>
+
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/po/notes.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/po/notes.component.ts
new file mode 100644 (file)
index 0000000..75bbaaf
--- /dev/null
@@ -0,0 +1,76 @@
+import {Component, OnInit, AfterViewInit, Input, Output, EventEmitter} from '@angular/core';
+import {Observable} from 'rxjs';
+import {IdlObject, IdlService} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {OrgService} from '@eg/core/org.service';
+import {AuthService} from '@eg/core/auth.service';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+
+@Component({
+  templateUrl: 'notes.component.html',
+  selector: 'eg-po-notes'
+})
+export class PoNotesComponent implements OnInit, AfterViewInit {
+
+    @Input() po: IdlObject;
+    noteText: string;
+    vendorPublic = false;
+
+    @Output() closeRequested: EventEmitter<void> = new EventEmitter<void>();
+
+    constructor(
+        private idl: IdlService,
+        private org: OrgService,
+        private auth: AuthService,
+        private net: NetService
+    ) {}
+
+    ngOnInit() {
+    }
+
+    ngAfterViewInit() {
+        const node = document.getElementById('note-text-input');
+        if (node) { node.focus(); }
+    }
+
+    orgSn(id: number): string {
+        return this.org.get(id).shortname();
+    }
+
+    close() {
+        this.closeRequested.emit();
+    }
+
+    newNote() {
+        const note = this.idl.create('acqpon');
+        note.isnew(true);
+        note.purchase_order(this.po.id());
+        note.value(this.noteText || '');
+        note.vendor_public(this.vendorPublic ? 't' : 'f');
+
+        this.modifyNotes(note).subscribe(resp => {
+            if (resp.note) {
+                this.po.notes().unshift(resp.note);
+            }
+        });
+    }
+
+    deleteNote(note: IdlObject) {
+        note.isdeleted(true);
+        this.modifyNotes(note).toPromise().then(_ => {
+            this.po.notes(
+                this.po.notes().filter(n => n.id() !== note.id())
+            );
+        });
+    }
+
+    modifyNotes(notes: IdlObject | IdlObject[]): Observable<any> {
+        notes = [].concat(notes);
+
+        return this.net.request(
+            'open-ils.acq',
+            'open-ils.acq.po_note.cud.batch',
+            this.auth.token(), notes);
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/po/po.component.html b/Open-ILS/src/eg2/src/app/staff/acq/po/po.component.html
new file mode 100644 (file)
index 0000000..585ebab
--- /dev/null
@@ -0,0 +1,24 @@
+<ng-container *ngIf="poService.currentPo">
+  <eg-staff-banner i18n-bannerText 
+    bannerText="Purchase Order #{{poId}} ({{poService.currentPo.state()}})">
+  </eg-staff-banner>
+</ng-container>
+
+<eg-acq-po-summary [poId]="poId"></eg-acq-po-summary>
+
+<ng-container *ngIf="!isBasePage()">
+  <div class="mt-2">
+    <a routerLink="/staff/acq/po/{{poId}}" queryParamsHandling="merge">
+      <button class="btn btn-sm btn-info" i18n>Return</button>
+    </a>
+  </div>
+</ng-container>
+
+<router-outlet></router-outlet>
+
+<hr class="mt-3 pt-3"/>
+
+<!-- gets its po from poService -->
+<ng-container *ngIf="isBasePage()">
+  <eg-acq-po-charges></eg-acq-po-charges>
+</ng-container>
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/po/po.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/po/po.component.ts
new file mode 100644 (file)
index 0000000..04e8a0c
--- /dev/null
@@ -0,0 +1,29 @@
+import {Component, OnInit} from '@angular/core';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {IdlObject} from '@eg/core/idl.service';
+import {PoService} from './po.service';
+
+@Component({
+  templateUrl: 'po.component.html'
+})
+export class PoComponent implements OnInit {
+
+    poId: number;
+
+    constructor(
+        private route: ActivatedRoute,
+        public  poService: PoService
+    ) {}
+
+    ngOnInit() {
+        this.route.paramMap.subscribe((params: ParamMap) => {
+            this.poId = +params.get('poId');
+        });
+    }
+
+    isBasePage(): boolean {
+        return !this.route.firstChild ||
+            this.route.firstChild.snapshot.url.length === 0;
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/po/po.module.ts b/Open-ILS/src/eg2/src/app/staff/acq/po/po.module.ts
new file mode 100644 (file)
index 0000000..3d27b94
--- /dev/null
@@ -0,0 +1,43 @@
+import {NgModule} from '@angular/core';
+import {StaffCommonModule} from '@eg/staff/common.module';
+import {HttpClientModule} from '@angular/common/http';
+import {CatalogCommonModule} from '@eg/share/catalog/catalog-common.module';
+import {LineitemModule} from '@eg/staff/acq/lineitem/lineitem.module';
+import {HoldingsModule} from '@eg/staff/share/holdings/holdings.module';
+import {PoRoutingModule} from './routing.module';
+import {PoService} from './po.service';
+import {PoComponent} from './po.component';
+import {PrintComponent} from './print.component';
+import {PoSummaryComponent} from './summary.component';
+import {PoHistoryComponent} from './history.component';
+import {PoEdiMessagesComponent} from './edi.component';
+import {PoNotesComponent} from './notes.component';
+import {PoCreateComponent} from './create.component';
+import {PoChargesComponent} from './charges.component';
+
+
+@NgModule({
+  declarations: [
+    PoComponent,
+    PoSummaryComponent,
+    PoHistoryComponent,
+    PoEdiMessagesComponent,
+    PoNotesComponent,
+    PoCreateComponent,
+    PoChargesComponent,
+    PrintComponent
+  ],
+  imports: [
+    StaffCommonModule,
+    CatalogCommonModule,
+    LineitemModule,
+    HoldingsModule,
+    PoRoutingModule
+  ],
+  providers: [
+    PoService
+  ]
+})
+
+export class PoModule {
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/po/po.service.ts b/Open-ILS/src/eg2/src/app/staff/acq/po/po.service.ts
new file mode 100644 (file)
index 0000000..4678189
--- /dev/null
@@ -0,0 +1,74 @@
+import {Injectable, EventEmitter} from '@angular/core';
+import {Observable, from} from 'rxjs';
+import {switchMap, map, tap, merge} from 'rxjs/operators';
+import {IdlObject, IdlService} from '@eg/core/idl.service';
+import {EventService} from '@eg/core/event.service';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+
+@Injectable()
+export class PoService {
+
+    currentPo: IdlObject;
+
+    poRetrieved: EventEmitter<IdlObject> = new EventEmitter<IdlObject>();
+
+    constructor(
+        private evt: EventService,
+        private net: NetService,
+        private auth: AuthService
+    ) {}
+
+    getFleshedPo(id: number, fleshMore?: any, noCache?: boolean): Promise<IdlObject> {
+
+        if (!noCache) {
+            if (this.currentPo && id === this.currentPo.id()) {
+                // Set poService.currentPo = null to bypass the cache
+                return Promise.resolve(this.currentPo);
+            }
+        }
+
+        const flesh = Object.assign({
+            flesh_provider: true,
+            flesh_notes: true,
+            flesh_po_items: true,
+            flesh_price_summary: true,
+            flesh_lineitem_count: true
+        }, fleshMore || {});
+
+        return this.net.request(
+            'open-ils.acq',
+            'open-ils.acq.purchase_order.retrieve',
+            this.auth.token(), id, flesh
+        ).toPromise().then(po => {
+
+            const evt = this.evt.parse(po);
+            if (evt) { return Promise.reject(evt + ''); }
+
+            if (!noCache) { this.currentPo = po; }
+
+            this.poRetrieved.emit(po);
+            return po;
+        });
+    }
+
+    // Fetch the PO again (with less fleshing) and update the
+    // order summary totals our main fully-fleshed PO.
+    refreshOrderSummary(): Promise<any> {
+
+        return this.net.request('open-ils.acq',
+            'open-ils.acq.purchase_order.retrieve.authoritative',
+            this.auth.token(), this.currentPo.id(),
+            {flesh_price_summary: true}
+
+        ).toPromise().then(po => {
+
+            this.currentPo.amount_encumbered(po.amount_encumbered());
+            this.currentPo.amount_spent(po.amount_spent());
+            this.currentPo.amount_estimated(po.amount_estimated());
+        });
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/po/print.component.html b/Open-ILS/src/eg2/src/app/staff/acq/po/print.component.html
new file mode 100644 (file)
index 0000000..d903617
--- /dev/null
@@ -0,0 +1,10 @@
+<div class="row">
+  <div class="p-2 m-2 ml-4">
+    <button class="btn btn-outline-dark" (click)="printPo()" i18n>
+      Print Purchase Order
+    </button>
+  </div>
+</div>
+
+<div id="print-outlet" class="m-3 p-3 w-90 border border-dark"></div>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/po/print.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/po/print.component.ts
new file mode 100644 (file)
index 0000000..2ed659a
--- /dev/null
@@ -0,0 +1,129 @@
+import {Component, OnInit, AfterViewInit} from '@angular/core';
+import {Observable} from 'rxjs';
+import {map, take} from 'rxjs/operators';
+import {ActivatedRoute, ParamMap} from '@angular/router';
+import {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 {IdlService} from '@eg/core/idl.service';
+import {OrgService} from '@eg/core/org.service';
+import {PrintService} from '@eg/share/print/print.service';
+import {BroadcastService} from '@eg/share/util/broadcast.service';
+import {PoService} from './po.service';
+
+@Component({
+  templateUrl: 'print.component.html'
+})
+export class PrintComponent implements OnInit, AfterViewInit {
+
+    poId: number;
+    outlet: Element;
+    po: IdlObject;
+    printing: boolean;
+    closing: boolean;
+    initDone = false;
+
+    constructor(
+        private route: ActivatedRoute,
+        private idl: IdlService,
+        private org: OrgService,
+        private net: NetService,
+        private auth: AuthService,
+        private pcrud: PcrudService,
+        private poService: PoService,
+        private broadcaster: BroadcastService,
+        private printer: PrintService) {
+    }
+
+    ngOnInit() {
+        this.route.parent.paramMap.subscribe((params: ParamMap) => {
+            const poId = +params.get('poId');
+            if (poId !== this.poId) {
+                this.poId = poId;
+                if (poId && this.initDone) { this.load(); }
+            }
+        });
+
+        this.load();
+    }
+
+    ngAfterViewInit() {
+        this.outlet = document.getElementById('print-outlet');
+    }
+
+    load() {
+        if (!this.poId) { return; }
+
+        this.po = null;
+        this.poService.getFleshedPo(this.poId, {
+            flesh_provider_addresses: true,
+            flesh_lineitems: true,
+            flesh_lineitem_attrs: true,
+            flesh_lineitem_notes: true,
+            flesh_lineitem_details: true,
+            clear_marc: true,
+            flesh_notes: true
+        }, true)
+        .then(po => this.po = po)
+        .then(_ => this.populatePreview())
+        .then(_ => this.initDone = true);
+    }
+
+    populatePreview(): Promise<any> {
+
+        return this.printer.compileRemoteTemplate({
+            templateName: 'purchase_order',
+            printContext: 'default',
+            contextData: {po: this.po}
+
+        }).then(response => {
+            this.outlet.innerHTML = response.content;
+        });
+    }
+
+    addLiPrintNotes(): Promise<any> {
+
+        const notes = [];
+        this.po.lineitems().forEach(li => {
+            const note = this.idl.create('acqlin');
+            note.isnew(true);
+            note.lineitem(li.id());
+            note.value('printed: ' + this.auth.user().usrname());
+            notes.push(note);
+        });
+
+        return this.net.request('open-ils.acq',
+            'open-ils.acq.lineitem_note.cud.batch', this.auth.token(), notes)
+        .toPromise().then(_ => {
+            this.broadcaster.broadcast(
+                'eg.acq.lineitem.notes.update', {
+                lineitems: notes.map(n => Number(n.lineitem()))
+            });
+        });
+    }
+
+    printPo(closeTab?: boolean) {
+        this.addLiPrintNotes().then(_ => this.printPo2(closeTab));
+    }
+
+    printPo2(closeTab?: boolean) {
+        if (closeTab || this.closing) {
+            const sub: any = this.printer.printJobQueued$.subscribe(req => {
+                if (req.templateName === 'purchase_order') {
+                    setTimeout(() => {
+                        window.close();
+                        sub.unsubscribe();
+                    }, 2000); // allow for a time cushion past queueing.
+                }
+            });
+        }
+
+        this.printer.print({
+            templateName: 'purchase_order',
+            printContext: 'default',
+            contextData: {po: this.po}
+        });
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/po/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/acq/po/routing.module.ts
new file mode 100644 (file)
index 0000000..02a7918
--- /dev/null
@@ -0,0 +1,64 @@
+import {NgModule} from '@angular/core';
+import {RouterModule, Routes} from '@angular/router';
+import {PoComponent} from './po.component';
+import {PrintComponent} from './print.component';
+import {PoSummaryComponent} from './summary.component';
+import {LineitemListComponent} from '../lineitem/lineitem-list.component';
+import {LineitemDetailComponent} from '../lineitem/detail.component';
+import {LineitemCopiesComponent} from '../lineitem/copies.component';
+import {BriefRecordComponent} from '../lineitem/brief-record.component';
+import {LineitemHistoryComponent} from '../lineitem/history.component';
+import {LineitemWorksheetComponent} from '../lineitem/worksheet.component';
+import {PoHistoryComponent} from './history.component';
+import {PoEdiMessagesComponent} from './edi.component';
+import {PoCreateComponent} from './create.component';
+
+const routes: Routes = [{
+  path: 'create',
+  component: PoCreateComponent
+}, {
+  path: ':poId',
+  component: PoComponent,
+  children : [{
+    path: '',
+    component: LineitemListComponent
+  }, {
+    path: 'history',
+    component: PoHistoryComponent
+  }, {
+    path: 'edi',
+    component: PoEdiMessagesComponent
+  }, {
+    path: 'brief-record',
+    component: BriefRecordComponent
+  }, {
+    path: 'lineitem/:lineitemId/detail',
+    component: LineitemDetailComponent
+  }, {
+    path: 'lineitem/:lineitemId/history',
+    component: LineitemHistoryComponent
+  }, {
+    path: 'lineitem/:lineitemId/items',
+    component: LineitemCopiesComponent
+  }, {
+    path: 'lineitem/:lineitemId/worksheet',
+    component: LineitemWorksheetComponent
+  }, {
+    path: 'printer',
+    component: PrintComponent
+  }, {
+    path: 'printer/print',
+    component: PrintComponent
+  }, {
+    path: 'printer/print/close',
+    component: PrintComponent
+  }]
+}];
+
+@NgModule({
+  imports: [RouterModule.forChild(routes)],
+  exports: [RouterModule],
+  providers: []
+})
+
+export class PoRoutingModule {}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/po/summary.component.html b/Open-ILS/src/eg2/src/app/staff/acq/po/summary.component.html
new file mode 100644 (file)
index 0000000..d7442b9
--- /dev/null
@@ -0,0 +1,168 @@
+
+<eg-acq-cancel-dialog #cancelDialog></eg-acq-cancel-dialog>
+<eg-progress-dialog #progressDialog></eg-progress-dialog>
+
+<div *ngIf="po()" class="p-1 border border-secondary rounded">
+
+  <div class="row">
+    <div class="col-lg-9">
+
+      <div class="row">
+        <div class="col-lg-3 d-flex">
+          <div class="flex-2" i18n>PO ID:</div>
+          <div class="flex-3">{{poId}}</div>
+        </div>
+        <div class="col-lg-9 d-flex">
+          <div class="flex-1" i18n>PO Name:</div>
+          <div class="flex-6">
+            <ng-container *ngIf="editPoName">
+              <input id='pl-name-input' type="text" class="form-control"
+                [(ngModel)]="newPoName" (keyup.enter)="toggleNameEdit(true)" 
+                (blur)="toggleNameEdit()"/>
+            </ng-container>
+            <ng-container *ngIf="!editPoName">
+              <a (click)="toggleNameEdit()" href='javascript:;'
+                class='font-weight-bold'>{{po().name()}}</a>
+            </ng-container>
+          </div>
+        </div>
+      </div>
+
+      <div class="row">
+        <div class="col-lg-3 d-flex">
+          <div class="flex-2" i18n>Lineitems:</div>
+          <div class="flex-3">{{po().lineitem_count()}}</div>
+        </div>
+        <div class="col-lg-9 d-flex">
+          <div class="flex-1" i18n>Provider:</div>
+          <div class="flex-6">
+            <a routerLink="/staff/acq/provider/{{po().provider().id()}}/details">
+              {{po().provider().name()}}
+            </a>
+          </div>
+        </div>
+      </div>
+      <div class="row">
+        <div class="col-lg-3 d-flex">
+          <div class="flex-2" i18n>Activated:</div>
+          <div class="flex-3">
+            <span *ngIf="po().order_date()">{{po().order_date() | date:'short'}}</span>
+            <span *ngIf="!po().order_date()" i18n>N/A</span>
+          </div>
+        </div>
+        <div class="col-lg-9 d-flex">
+          <div class="flex-1" i18n>Status:</div>
+          <div class="flex-6">
+            <div class="w-50" *ngIf="canActivate === null">
+              <eg-progress-inline></eg-progress-inline>
+            </div>
+
+            <span *ngIf="po().state() == 'on-order'" i18n>On Order</span>
+            <ng-container *ngIf="canActivate">
+              <span *ngIf="!activationEvent" i18n>Pending / Activatable</span>
+              <span *ngIf="activationEvent" i18n>
+                Activation Error: {{activationEvent.textcode}} {{activationEvent.desc}}
+              </span>
+            </ng-container>
+
+            <!-- canceled -->
+            <ng-container *ngIf="po().cancel_reason()">
+              <span class="text-danger" i18n>
+                {{po().cancel_reason().label()}} =&gt; {{po().cancel_reason().description()}}
+              </span>
+            </ng-container>
+
+            <!-- activation blocks -->
+            <div class="text-danger" *ngFor="let evt of activationBlocks">
+              <ng-container 
+                *ngIf="evt.textcode == 'ACQ_FUND_EXCEEDS_STOP_PERCENT'; else fundWarn">
+                <span i18n>
+                  Fund exceeds stop percent: 
+                  {{evt.payload.fund.code()}} ({{evt.payload.fund.year()}}).
+                </span>
+              </ng-container>
+              <ng-template #fundWarn>
+                <ng-container 
+                  *ngIf="evt.textcode == 'ACQ_FUND_EXCEEDS_WARN_PERCENT'; else noPrice">
+                  <span i18n>
+                    Fund exceeds warning percent: 
+                    {{evt.payload.fund.code()}} ({{evt.payload.fund.year()}}).
+                  </span>
+                </ng-container>
+              </ng-template>
+              <ng-template #noPrice>
+                <ng-container 
+                  *ngIf="evt.textcode == 'ACQ_LINEITEM_NO_PRICE'; else noCopies">
+                  <span i18n>One or more lineitems have no price.</span>
+                </ng-container>
+              </ng-template>
+              <ng-template #noCopies>
+                <ng-container 
+                  *ngIf="evt.textcode == 'ACQ_LINEITEM_NO_COPIES'; else otherBlock">
+                  <span i18n>One or more lineitems have no items attached.</span>
+                </ng-container>
+              </ng-template>
+              <ng-template #otherBlock>
+                <span i18n>{{evt.textcode}} : {{evt.desc}}</span>
+              </ng-template>
+            </div>
+          </div>
+        </div>
+      </div>
+      <hr class="p-0 m-0 mt-1"/>
+      <div class="row mt-1">
+        <div class="col-lg-12">
+          <a class="" href="javascript:;" (click)="showNotes=!showNotes" 
+            i18n>Notes ({{po().notes().length}})</a>
+          <span class="pl-2 pr-2" i18n> | </span>
+          <a [queryParams]="{f: 'acqpo:id', val1: poId}"
+            routerLink="/staff/acq/search/invoices" i18n>Invoices ({{invoiceCount}})</a>
+          <span class="pl-2 pr-2" i18n> | </span>
+          <a href="/eg/acq/invoice/view?create=1&attach_po={{poId}}"
+            i18n>Create Invoice</a>
+          <span class="pl-2 pr-2" i18n> | </span>
+          <a routerLink="./edi" i18n>EDI Messages ({{ediMessageCount}})</a>
+          <span class="pl-2 pr-2" i18n> | </span>
+          <a routerLink="./history" i18n>History</a>
+          <span class="pl-2 pr-2" i18n> | </span>
+          <a routerLink="./printer" i18n>Print</a>
+          <ng-container *ngIf="po().state() == 'on-order' || po().state() == 'pending'">
+            <span class="pl-2 pr-2" i18n> | </span>
+            <a (click)="cancelPo()" href="javascript:;" i18n>Cancel Order</a>
+          </ng-container>
+          <ng-container *ngIf="canActivate === true">
+            <span class="pl-2 pr-2" i18n> | </span>
+            <a (click)="activatePo()" href="javascript:;" i18n>Activate Order</a>
+          </ng-container>
+        </div>
+      </div>
+    </div>
+    <div class="col-lg-3">
+      <div class="row">
+        <div class="col-lg-8" i18n>Estimated Amount:</div>
+        <div class="col-lg-4">{{po().amount_estimated() | currency}}</div>
+      </div>
+      <div class="row">
+        <div class="col-lg-8" i18n>Encumbered Amount:</div>
+        <div class="col-lg-4">{{po().amount_encumbered() | currency}}</div>
+      </div>
+      <div class="row">
+        <div class="col-lg-8" i18n>Spent Amount:</div>
+        <div class="col-lg-4">{{po().amount_spent() | currency}}</div>
+      </div>
+      <div class="row">
+        <div class="col-lg-8" i18n>Prepayment Required?</div>
+        <div class="col-lg-4">
+          <eg-bool [value]="po().provider().prepayment_required()"></eg-bool>
+        </div>
+      </div>
+    </div>
+  </div>
+  <div class="row" *ngIf="showNotes">
+    <div class="col-lg-10 offset-lg-1 p-2 mt-2 shadow">
+      <eg-po-notes [po]="po()" (closeRequested)="showNotes = false">
+      </eg-po-notes>
+    </div>
+  </div>
+</div>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/po/summary.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/po/summary.component.ts
new file mode 100644 (file)
index 0000000..0a77bb8
--- /dev/null
@@ -0,0 +1,222 @@
+import {Component, Input, OnInit, ViewChild} from '@angular/core';
+import {Router} from '@angular/router';
+import {of, Observable} from 'rxjs';
+import {tap, take, map} from 'rxjs/operators';
+import {IdlObject, IdlService} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {OrgService} from '@eg/core/org.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {ServerStoreService} from '@eg/core/server-store.service';
+import {ComboboxEntry, ComboboxComponent} from '@eg/share/combobox/combobox.component';
+import {ProgressDialogComponent} from '@eg/share/dialog/progress.component';
+import {EventService, EgEvent} from '@eg/core/event.service';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+import {PoService} from './po.service';
+import {LineitemService} from '../lineitem/lineitem.service';
+import {CancelDialogComponent} from '../lineitem/cancel-dialog.component';
+
+
+@Component({
+  templateUrl: 'summary.component.html',
+  selector: 'eg-acq-po-summary'
+})
+export class PoSummaryComponent implements OnInit {
+
+    private _poId: number;
+    @Input() set poId(id: number) {
+        if (id === this._poId) { return; }
+        this._poId = id;
+        if (this.initDone) { this.load(); }
+    }
+    get poId(): number { return this._poId; }
+
+    newPoName: string;
+    editPoName = false;
+    initDone = false;
+    ediMessageCount = 0;
+    invoiceCount = 0;
+    showNotes = false;
+    canActivate: boolean = null;
+
+    activationBlocks: EgEvent[] = [];
+    activationEvent: EgEvent;
+    nameEditEnterToggled = false;
+
+    @ViewChild('cancelDialog') cancelDialog: CancelDialogComponent;
+    @ViewChild('progressDialog') progressDialog: ProgressDialogComponent;
+
+    constructor(
+        private router: Router,
+        private evt: EventService,
+        private idl: IdlService,
+        private net: NetService,
+        private org: OrgService,
+        private pcrud: PcrudService,
+        private auth: AuthService,
+        private store: ServerStoreService,
+        private liService: LineitemService,
+        private poService: PoService
+    ) {}
+
+    ngOnInit() {
+        this.load().then(_ => this.initDone = true);
+
+        // Re-check for activation blocks if the LI service tells us
+        // something significant happened.
+        this.liService.activateStateChange
+        .subscribe(_ => this.setCanActivate());
+    }
+
+    po(): IdlObject {
+        return this.poService.currentPo;
+    }
+
+    load(): Promise<any> {
+        if (!this.poId) { return Promise.resolve(); }
+
+        return this.poService.getFleshedPo(this.poId)
+        .then(po => {
+
+            // EDI message count
+            return this.pcrud.search('acqedim',
+                {purchase_order: this.poId}, {}, {idlist: true, atomic: true}
+            ).toPromise().then(ids => this.ediMessageCount = ids.length);
+
+        }).then(_ => {
+
+            // Invoice count
+            return this.net.request('open-ils.acq',
+                'open-ils.acq.invoice.unified_search.atomic',
+                this.auth.token(), {acqpo: [{id: this.poId}]},
+                null, null, {id_list: true}
+            ).toPromise().then(ids => this.invoiceCount = ids.length);
+
+        }).then(_ => this.setCanActivate());
+    }
+
+    // Can run via Enter or blur.  If it just ran via Enter, avoid
+    // running it again on the blur, which will happen directly after
+    // the Enter.
+    toggleNameEdit(fromEnter?: boolean) {
+        if (fromEnter) {
+            this.nameEditEnterToggled = true;
+        } else {
+            if (this.nameEditEnterToggled) {
+                this.nameEditEnterToggled = false;
+                return;
+            }
+        }
+
+        this.editPoName = !this.editPoName;
+
+        if (this.editPoName) {
+            this.newPoName = this.po().name();
+            setTimeout(() => {
+                const node =
+                    document.getElementById('pl-name-input') as HTMLInputElement;
+                if (node) { node.select(); }
+            });
+
+        } else if (this.newPoName && this.newPoName !== this.po().name()) {
+
+            const prevName = this.po().name();
+            this.po().name(this.newPoName);
+            this.newPoName = null;
+
+            this.pcrud.update(this.po()).subscribe(resp => {
+                const evt = this.evt.parse(resp);
+                if (evt) {
+                    alert(evt);
+                    this.po().name(prevName);
+                }
+            });
+        }
+    }
+
+    cancelPo() {
+        this.cancelDialog.open().subscribe(reason => {
+            if (!reason) { return; }
+
+            this.progressDialog.reset();
+            this.progressDialog.open();
+            this.net.request('open-ils.acq',
+                'open-ils.acq.purchase_order.cancel',
+                this.auth.token(), this.poId, reason
+            ).subscribe(ok => {
+                this.progressDialog.close();
+                location.href = location.href;
+            });
+        });
+    }
+
+    setCanActivate() {
+        this.canActivate = null;
+        this.activationBlocks = [];
+
+        if (!(this.po().state().match(/new|pending/))) {
+            this.canActivate = false;
+            return;
+        }
+
+        this.net.request('open-ils.acq',
+            'open-ils.acq.purchase_order.activate.dry_run',
+            this.auth.token(), this.poId
+
+        ).pipe(tap(resp => {
+
+            const evt = this.evt.parse(resp);
+            if (evt) { this.activationBlocks.push(evt); }
+
+        })).toPromise().then(_ => {
+
+            if (this.activationBlocks.length === 0) {
+                this.canActivate = true;
+                return;
+            }
+
+            this.canActivate = false;
+
+            // TODO More logic likely needed here to handle zero-copy
+            // activation / ACQ_LINEITEM_NO_COPIES
+        });
+    }
+
+    activatePo() {
+        // TODO This code bypasses the Vandelay UI and force-loads the records.
+
+        this.activationEvent = null;
+        this.progressDialog.open();
+        this.progressDialog.update({max: this.po().lineitem_count() * 3});
+
+        this.net.request(
+            'open-ils.acq',
+            'open-ils.acq.purchase_order.activate',
+            this.auth.token(), this.poId, {
+                // Import all records, no merging, etc.
+                import_no_match: true,
+                queue_name: `ACQ ${new Date().toISOString()}`
+            }
+        ).subscribe(resp => {
+            const evt = this.evt.parse(resp);
+
+            if (evt) {
+                this.progressDialog.close();
+                this.activationEvent = evt;
+                return;
+            }
+
+            if (Number(resp) === 1) {
+                this.progressDialog.close();
+                // Refresh everything.
+                location.href = location.href;
+
+            } else {
+                this.progressDialog.update(
+                    {value: resp.bibs + resp.li + resp.vqbr});
+            }
+        });
+    }
+}
+
+
index 8cde151..7848bcc 100644 (file)
@@ -1,16 +1,22 @@
 import {NgModule} from '@angular/core';
 import {RouterModule, Routes} from '@angular/router';
 
-const routes: Routes = [
-  { path: 'search',
-    loadChildren: () =>
-      import('./search/acq-search.module').then(m => m.AcqSearchModule)
-  },
-  { path: 'provider',
-    loadChildren: () =>
-      import('./provider/acq-provider.module').then(m => m.AcqProviderModule)
-  }
-];
+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)
+}, {
+  path: 'po',
+  loadChildren: () => import('./po/po.module').then(m => m.PoModule)
+}, {
+  path: 'picklist',
+  loadChildren: () =>
+    import('./picklist/picklist.module').then(m => m.PicklistModule)
+}];
 
 @NgModule({
   imports: [RouterModule.forChild(routes)],
index 84bd8bd..bfd1652 100644 (file)
@@ -3,26 +3,30 @@
   defaultSearchSetting="eg.acq.search.default.lineitems"></eg-acq-search-form>
 
 <ng-template #idTmpl let-lineitem="row">
-  <a *ngIf="lineitem.purchase_order()" href="/eg/staff/acq/legacy/po/view/{{lineitem.purchase_order().id()}}?focus_li={{lineitem.id()}}"
-     target="_blank">
+  <a *ngIf="lineitem.purchase_order()" 
+     routerLink="/staff/acq/po/{{lineitem.purchase_order().id()}}"
+     fragment="{{lineitem.id()}}" target="_blank">
     {{lineitem.id()}}
   </a>
-  <a *ngIf="lineitem.picklist() && !lineitem.purchase_order()" href="/eg/staff/acq/legacy/picklist/view/{{lineitem.picklist().id()}}?focus_li={{lineitem.id()}}"
-     target="_blank">
+  <a *ngIf="lineitem.picklist() && !lineitem.purchase_order()" 
+     routerLink="/staff/acq/picklist/{{lineitem.picklist().id()}}"
+     fragment="{{lineitem.id()}}" target="_blank">
     {{lineitem.id()}}
   </a>
 </ng-template>
 
 <ng-template #poTmpl let-lineitem="row">
-  <a *ngIf="lineitem.purchase_order()" href="/eg/staff/acq/legacy/po/view/{{lineitem.purchase_order().id()}}?focus_li={{lineitem.id()}}"
-     target="_blank">
+  <a *ngIf="lineitem.purchase_order()" 
+     routerLink="/staff/acq/po/{{lineitem.purchase_order().id()}}"
+     fragment="{{lineitem.id()}}" target="_blank">
     {{lineitem.purchase_order().name()}}
   </a>
 </ng-template>
 
 <ng-template #plTmpl let-lineitem="row">
-  <a *ngIf="lineitem.picklist()" href="/eg/staff/acq/legacy/picklist/view/{{lineitem.picklist().id()}}?focus_li={{lineitem.id()}}"
-     target="_blank">
+  <a *ngIf="lineitem.picklist()"
+     routerLink="/staff/acq/picklist/{{lineitem.picklist().id()}}"
+     fragment="{{lineitem.id()}}" target="_blank">
     {{lineitem.picklist().name()}}
   </a>
 </ng-template>
     <li *ngIf="lineitem.eg_bib_id()">
       <a routerLink="/staff/catalog/record/{{lineitem.eg_bib_id()}}"
          target="_blank" i18n>Catalog</a></li>
-    <li><a href="/eg/staff/acq/legacy/lineitem/worksheet/{{lineitem.id()}}"
+    <li><a routerLink="/staff/acq/lineitem/{{lineitem.id()}}/worksheet"
            target="_blank" i18n>Worksheet</a></li>
     <li *ngIf="lineitem.purchase_order()">
-      <a href="/eg/staff/acq/legacy/po/view/{{lineitem.purchase_order().id()}}"
+      <a routerLink="/staff/acq/po/{{lineitem.purchase_order().id()}}"
           target="_blank" i18n>Purchase Order</a></li>
     <li><a href="/eg/staff/acq/requests/lineitem/{{lineitem.id()}}"
            target="_blank" i18n>Requests</a></li>
@@ -61,7 +65,7 @@
       <a routerLink="/staff/cat/vandelay/queue/bib/{{lineitem.queued_record().queue()}}"
         target="_blank" i18n>Queue</a></li>
     <li *ngIf="lineitem.picklist()">
-      <a href="/eg/staff/acq/legacy/picklist/view/{{lineitem.picklist().id()}}"
+      <a routerLink="/staff/acq/picklist/{{lineitem.picklist().id()}}"
         target="_blank" i18n>Selection List</a></li>
   </ul>
 </ng-template>
index 0edc50f..612b5a0 100644 (file)
@@ -13,8 +13,7 @@
 </eg-string>
 
 <ng-template #nameTmpl let-selectionlist="row">
-  <a href="/eg/staff/acq/legacy/picklist/view/{{selectionlist.id()}}"
-     target="_blank">
+  <a routerLink="/staff/acq/picklist/{{selectionlist.id()}}" target="_blank">
     {{selectionlist.name()}}
   </a>
 </ng-template>
index 2e03b20..af3a9fb 100644 (file)
             <span class="material-icons" aria-hidden="true">shopping_cart</span>
             <span i18n>Purchase Orders</span>
           </a>
-          <a class="dropdown-item" href="/eg/staff/acq/legacy/po/create">
+          <a class="dropdown-item" routerLink="/staff/acq/po/create">
             <span class="material-icons" aria-hidden="true">add_shopping_cart</span>
             <span i18n>Create Purchase Order</span>
           </a>
index 49ed524..2a67b8c 100644 (file)
@@ -3,6 +3,10 @@ import {MarcRecord, MarcField, MarcSubfield} from './marcrecord';
 import {NgbPopover} from '@ng-bootstrap/ng-bootstrap';
 import {TagTable} from './tagtable.service';
 
+const MARC_RECORD_TYPES: 'biblio' | 'authority' | 'serial' | 'lineitem' = null;
+
+export type MARC_RECORD_TYPE = typeof MARC_RECORD_TYPES;
+
 /* Per-instance MARC editor context. */
 
 const STUB_DATA_00X = '                                        ';
@@ -64,7 +68,7 @@ export class MarcEditContext {
     recordChange: EventEmitter<MarcRecord>;
     fieldFocusRequest: EventEmitter<FieldFocusRequest>;
     textUndoRedoRequest: EventEmitter<TextUndoRedoAction>;
-    recordType: 'biblio' | 'authority' = 'biblio';
+    recordType: MARC_RECORD_TYPE;
 
     lastFocused: FieldFocusRequest = null;
 
index a0304e1..07f6567 100644 (file)
@@ -5,7 +5,7 @@ import {AuthService} from '@eg/core/auth.service';
 import {PcrudService} from '@eg/core/pcrud.service';
 import {DialogComponent} from '@eg/share/dialog/dialog.component';
 import {NgbModal, NgbModalRef, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
-import {MarcEditContext} from './editor-context';
+import {MarcEditContext, MARC_RECORD_TYPE} from './editor-context';
 
 
 /**
@@ -22,7 +22,7 @@ export class MarcEditorDialogComponent
 
     @Input() context: MarcEditContext;
     @Input() recordXml: string;
-    @Input() recordType: 'biblio' | 'authority' = 'biblio';
+    @Input() recordType: MARC_RECORD_TYPE = 'biblio';
 
     constructor(
         private modal: NgbModal,
index cbcf5ed..2960ad3 100644 (file)
@@ -12,10 +12,11 @@ import {MarcRecord} from './marcrecord';
 import {ComboboxEntry, ComboboxComponent
   } from '@eg/share/combobox/combobox.component';
 import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
-import {MarcEditContext} from './editor-context';
+import {MarcEditContext, MARC_RECORD_TYPE} from './editor-context';
 import {NgbNav, NgbNavChangeEvent} from '@ng-bootstrap/ng-bootstrap';
 import {HoldingsService} from '@eg/staff/share/holdings/holdings.service';
 
+
 export interface MarcSavedEvent {
     marcXml: string;
     bibSource?: number;
@@ -40,7 +41,7 @@ export class MarcEditorComponent implements OnInit {
     // True if the save request is in flight
     dataSaving: boolean;
 
-    @Input() recordType: 'biblio' | 'authority' = 'biblio';
+    @Input() recordType: MARC_RECORD_TYPE = 'biblio';
 
     _pendingRecordId: number;
     @Input() set recordId(id: number) {
index 8b867a8..9862d67 100644 (file)
@@ -7,12 +7,13 @@ import {AuthService} from '@eg/core/auth.service';
 import {NetService} from '@eg/core/net.service';
 import {PcrudService} from '@eg/core/pcrud.service';
 import {ContextMenuEntry} from '@eg/share/context-menu/context-menu.service';
+import {MARC_RECORD_TYPE} from './editor-context';
 
 const DEFAULT_MARC_FORMAT = 'marc21';
 
 interface TagTableSelector {
     marcFormat?: string;
-    marcRecordType: 'biblio' | 'authority' | 'serial';
+    marcRecordType: MARC_RECORD_TYPE;
 
     // MARC record fixed field "Type" value.
     ffType: string;
index 9c4ad96..4ca5538 100644 (file)
@@ -47,6 +47,7 @@ h5 {font-size: .95rem}
 .flex-3 {flex: 3}
 .flex-4 {flex: 4}
 .flex-5 {flex: 5}
+.flex-6 {flex: 6}
 
 /** BS deprecated the well, but it's replacement is not quite the same.
  * Define our own version and expand it to a full "table".
@@ -105,6 +106,10 @@ h5 {font-size: .95rem}
     font-size: 18px;
 }
 
+.material-icons.small {
+  font-size: 18px;
+}
+
 .input-group .mat-icon-in-button {
     font-size: .88rem !important; /* useful for buttons that cuddle up with inputs */
 }
@@ -285,3 +290,12 @@ body>.dropdown-menu {z-index: 2100;}
 .negative-money-amount {
     color: red;
 }
+
+input.medium {
+  width: 6em;
+}
+
+input.small {
+  width: 4em;
+}
+
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Acq/Common.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Acq/Common.pm
new file mode 100644 (file)
index 0000000..7b94177
--- /dev/null
@@ -0,0 +1,80 @@
+package OpenILS::Application::Acq::Common;
+use strict; use warnings;
+use OpenILS::Application::AppUtils;
+my $U = 'OpenILS::Application::AppUtils';
+
+# retrieves a lineitem, fleshes its PO and PL, checks perms
+# returns ($li, $evt, $org)
+sub fetch_and_check_li {
+    my ($class, $e, $li_id, $perm_mode) = @_;
+    $perm_mode ||= 'read';
+
+    my $li = $e->retrieve_acq_lineitem([
+        $li_id,
+        {   flesh => 1,
+            flesh_fields => {jub => ['purchase_order', 'picklist']}
+        }
+    ]) or return (undef, $e->die_event);
+
+    my $org;
+    if(my $po = $li->purchase_order) {
+        $org = $po->ordering_agency;
+        my $perms = ($perm_mode eq 'read') ? 'VIEW_PURCHASE_ORDER' : 'CREATE_PURCHASE_ORDER';
+        return ($li, $e->die_event) unless $e->allowed($perms, $org);
+
+    } elsif(my $pl = $li->picklist) {
+        $org = $pl->org_unit;
+        my $perms = ($perm_mode eq 'read') ? 'VIEW_PICKLIST' : 'CREATE_PICKLIST';
+        return ($li, $e->die_event) unless $e->allowed($perms, $org);
+    }
+
+    return ($li, undef, $org);
+}
+
+sub li_existing_copies {
+    my ($class, $e, $li_id) = @_;
+
+    my ($li, $evt, $org) = $class->fetch_and_check_li($e, $li_id);
+    return 0 if $evt;
+
+    # No fuzzy matching here (e.g. on ISBN).  Only exact matches are supported.
+    return 0 unless $li->eg_bib_id;
+
+    my $counts = $e->json_query({
+        select => {acp => [{
+            column => 'id', 
+            transform => 'count', 
+            aggregate => 1
+        }]},
+        from => {
+            acp => {
+                acqlid => {
+                    fkey => 'id',
+                    field => 'eg_copy_id',
+                    type => 'left'
+                },
+                acn => {join => {bre => {}}}
+            }
+        },
+        where => {
+            '+bre' => {id => $li->eg_bib_id},
+            # don't count copies linked to the lineitem in question
+            '+acqlid' => {
+                '-or' => [
+                    {lineitem => undef},
+                    {lineitem => {'<>' => $li_id}}
+                ]
+            },
+            '+acn' => {
+                owning_lib => $U->get_org_descendants($org)
+            },
+            # NOTE: should the excluded copy statuses be an AOUS?
+            '+acp' => {status => {'not in' => [3, 4, 13, 17]}}
+        }
+    });
+
+    return $counts->[0]->{id};
+}
+
+
+
index d3178d6..cfc2fcc 100644 (file)
@@ -13,7 +13,9 @@ use OpenILS::Application::Acq::Financials;
 use OpenILS::Application::Cat::BibCommon;
 use OpenILS::Application::Cat::AssetCommon;
 use OpenILS::Application::Acq::Lineitem::BatchUpdate;
+use OpenILS::Application::Acq::Common;
 my $U = 'OpenILS::Application::AppUtils';
+my $AC = 'OpenILS::Application::Acq::Common';
 
 
 __PACKAGE__->register_method(
@@ -173,11 +175,20 @@ sub retrieve_lineitem_impl {
     push(@{$fields->{jub}   },        'editor') if $$options{flesh_editor};
     push(@{$fields->{jub}   },      'selector') if $$options{flesh_selector};
 
+    if ($$options{flesh_formulas}) {
+        push(@{$fields->{jub}},    'distribution_formulas');
+        push(@{$fields->{acqdfa}}, 'formula');
+        push(@{$fields->{acqdfa}}, 'creator');
+    }
+
     if($$options{flesh_li_details}) {
         push(@{$fields->{jub}   }, 'lineitem_details');
         push(@{$fields->{acqlid}}, 'fund'         ) if $$options{flesh_fund};
         push(@{$fields->{acqlid}}, 'fund_debit'   ) if $$options{flesh_fund_debit};
         push(@{$fields->{acqlid}}, 'cancel_reason') if $$options{flesh_cancel_reason};
+        push(@{$fields->{acqlid}}, 'circ_modifier') if $$options{flesh_circ_modifier};
+        push(@{$fields->{acqlid}}, 'location')      if $$options{flesh_location};
+        push(@{$fields->{acqlid}}, 'eg_copy_id')    if $$options{flesh_copies};
     }
 
     if($$options{clear_marc}) { # avoid fetching marc blob
@@ -229,6 +240,43 @@ sub retrieve_lineitem_impl {
     return $li;
 }
 
+__PACKAGE__->register_method(
+    method    => 'retrieve_lineitem_batch',
+    api_name  => 'open-ils.acq.lineitem.retrieve.batch',
+    stream => 1,
+    max_bundle_count => 1,
+    signature => {
+        desc   => q/
+            Retrieves a set of lineitems.  
+            See open-ils.acq.lineitem.retrieve/,
+        params => [
+            {desc => 'Authentication token',    type => 'string'},
+            {desc => 'Array of lineitem IDs to retrieve', type => 'array'},
+            {options => q/See open-ils.acq.lineitem.retrieve/}
+        ],
+        return => {desc => 'Stream of lineitems, Event on error'}
+    }
+);
+
+
+sub retrieve_lineitem_batch {
+    my($self, $client, $auth, $li_ids, $options) = @_;
+    my $e = new_editor(authtoken => $auth, xact => 1);
+    return $e->die_event unless $e->checkauth;
+
+    for my $li_id (@$li_ids) {
+        $client->respond({
+            id => $li_id,
+            lineitem => retrieve_lineitem_impl($e, $li_id, $options),
+            existing_copies => $AC->li_existing_copies($e, $li_id)
+        });
+    }
+
+    $e->rollback;
+
+    return undef;
+}
+
 
 
 __PACKAGE__->register_method(
index 377a9b4..d9d52fd 100644 (file)
@@ -181,6 +181,8 @@ use MARC::Batch;
 use MARC::File::XML (BinaryEncoding => 'UTF-8');
 use Digest::MD5 qw(md5_hex);
 use Data::Dumper;
+use OpenILS::Application::Acq::Common;
+my $AC = 'OpenILS::Application::Acq::Common';
 $Data::Dumper::Indent = 0;
 my $U = 'OpenILS::Application::AppUtils';
 
@@ -660,6 +662,8 @@ sub rollback_receive_lineitem {
     my($mgr, $li_id) = @_;
     my $li = $mgr->editor->retrieve_acq_lineitem($li_id) or return 0;
 
+    return 0 unless ($li->state eq 'received' || $li->state eq 'on-order');
+
     my $lid_ids = $mgr->editor->search_acq_lineitem_detail(
         {lineitem => $li_id, recv_time => {'!=' => undef}}, {idlist => 1});
 
@@ -2302,7 +2306,10 @@ sub receive_lineitem_batch_api {
             'RECEIVE_PURCHASE_ORDER', $li->purchase_order->ordering_agency
         );
 
-        receive_lineitem($mgr, $li_id) or return $e->die_event;
+        # Editor may have no die_event to return
+        receive_lineitem($mgr, $li_id) or return 
+            $e->die_event || OpenILS::Event->new('ACQ_LI_RECEIVE_FAILED');
+
         $mgr->respond;
     }
 
@@ -2490,7 +2497,12 @@ sub rollback_receive_lineitem_batch_api {
         return $e->die_event unless
             $e->allowed('RECEIVE_PURCHASE_ORDER', $po->ordering_agency);
 
-        $li = rollback_receive_lineitem($mgr, $li_id) or return $e->die_event;
+        unless ($li = rollback_receive_lineitem($mgr, $li_id)) {
+            return (
+                $e->die_event || # may not be an event here
+                OpenILS::Event->new('ACQ_LI_ROLLBACK_RECEIVE_FAILED')
+            );
+        }
 
         my $result = {"li" => {$li->id => {"state" => $li->state}}};
         if ($po->state eq "received") { # should happen first time, not after
@@ -2692,12 +2704,14 @@ sub delete_picklist_api {
 
 __PACKAGE__->register_method(
     method   => 'activate_purchase_order',
-    api_name => 'open-ils.acq.purchase_order.activate.dry_run'
+    api_name => 'open-ils.acq.purchase_order.activate.dry_run',
+    max_bundle_count => 1
 );
 
 __PACKAGE__->register_method(
     method    => 'activate_purchase_order',
     api_name  => 'open-ils.acq.purchase_order.activate',
+    max_bundle_count => 1,
     signature => {
         desc => q/Activates a purchase order.  This updates the status of the PO / .
                 q/and Lineitems to 'on-order'.  Activated PO's are ready for EDI delivery if appropriate./,
@@ -3939,30 +3953,8 @@ sub po_note_CUD_batch {
 # retrieves a lineitem, fleshes its PO and PL, checks perms
 # returns ($li, $evt, $org)
 sub fetch_and_check_li {
-    my $e = shift;
-    my $li_id = shift;
-    my $perm_mode = shift || 'read';
-
-    my $li = $e->retrieve_acq_lineitem([
-        $li_id,
-        {   flesh => 1,
-            flesh_fields => {jub => ['purchase_order', 'picklist']}
-        }
-    ]) or return (undef, $e->die_event);
-
-    my $org;
-    if(my $po = $li->purchase_order) {
-        $org = $po->ordering_agency;
-        my $perms = ($perm_mode eq 'read') ? 'VIEW_PURCHASE_ORDER' : 'CREATE_PURCHASE_ORDER';
-        return ($li, $e->die_event) unless $e->allowed($perms, $org);
-
-    } elsif(my $pl = $li->picklist) {
-        $org = $pl->org_unit;
-        my $perms = ($perm_mode eq 'read') ? 'VIEW_PICKLIST' : 'CREATE_PICKLIST';
-        return ($li, $e->die_event) unless $e->allowed($perms, $org);
-    }
-
-    return ($li, undef, $org);
+    my ($e, $li_id, $perm_mode) = @_;
+    return $AC->fetch_and_check_li($e, $li_id, $perm_mode);
 }
 
 
@@ -4317,47 +4309,7 @@ sub li_existing_copies {
     my ($self, $client, $auth, $li_id) = @_;
     my $e = new_editor("authtoken" => $auth);
     return $e->die_event unless $e->checkauth;
-
-    my ($li, $evt, $org) = fetch_and_check_li($e, $li_id);
-    return 0 if $evt;
-
-    # No fuzzy matching here (e.g. on ISBN).  Only exact matches are supported.
-    return 0 unless $li->eg_bib_id;
-
-    my $counts = $e->json_query({
-        select => {acp => [{
-            column => 'id', 
-            transform => 'count', 
-            aggregate => 1
-        }]},
-        from => {
-            acp => {
-                acqlid => {
-                    fkey => 'id',
-                    field => 'eg_copy_id',
-                    type => 'left'
-                },
-                acn => {join => {bre => {}}}
-            }
-        },
-        where => {
-            '+bre' => {id => $li->eg_bib_id},
-            # don't count copies linked to the lineitem in question
-            '+acqlid' => {
-                '-or' => [
-                    {lineitem => undef},
-                    {lineitem => {'<>' => $li_id}}
-                ]
-            },
-            '+acn' => {
-                owning_lib => $U->get_org_descendants($org)
-            },
-            # NOTE: should the excluded copy statuses be an AOUS?
-            '+acp' => {status => {'not in' => [3, 4, 13, 17]}}
-        }
-    });
-
-    return $counts->[0]->{id};
+    return $AC->li_existing_copies($e, $li_id);
 }