From ab460435d42af8012278e5d2299846ca2f023a03 Mon Sep 17 00:00:00 2001 From: Bill Erickson Date: Thu, 8 Oct 2020 18:22:12 -0400 Subject: [PATCH] LP1929741 ACQ Selection List & PO Angluar Port 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 Signed-off-by: Galen Charlton Signed-off-by: Jane Sandberg --- Open-ILS/examples/fm_IDL.xml | 2 +- .../src/app/share/catalog/marc-html.component.ts | 43 +- .../src/app/share/combobox/combobox.component.html | 58 +-- .../src/app/share/combobox/combobox.component.ts | 8 +- .../item-location-select.component.html | 3 + .../item-location-select.component.ts | 123 ++++- .../item-location-select.module.ts | 2 + .../item-location-select.service.ts | 14 + .../app/share/org-select/org-select.component.ts | 4 + .../src/eg2/src/app/share/print/print.component.ts | 5 +- .../src/eg2/src/app/share/print/print.service.ts | 6 + .../staff/acq/lineitem/batch-copies.component.css | 5 + .../staff/acq/lineitem/batch-copies.component.html | 57 +++ .../staff/acq/lineitem/batch-copies.component.ts | 138 ++++++ .../staff/acq/lineitem/brief-record.component.html | 11 + .../staff/acq/lineitem/brief-record.component.ts | 113 +++++ .../acq/lineitem/cancel-dialog.component.html | 23 + .../staff/acq/lineitem/cancel-dialog.component.ts | 17 + .../app/staff/acq/lineitem/copies.component.html | 63 +++ .../src/app/staff/acq/lineitem/copies.component.ts | 285 ++++++++++++ .../staff/acq/lineitem/copy-attrs.component.html | 116 +++++ .../app/staff/acq/lineitem/copy-attrs.component.ts | 172 +++++++ .../app/staff/acq/lineitem/detail.component.html | 48 ++ .../src/app/staff/acq/lineitem/detail.component.ts | 67 +++ .../app/staff/acq/lineitem/history.component.html | 9 + .../app/staff/acq/lineitem/history.component.ts | 52 +++ .../staff/acq/lineitem/lineitem-list.component.css | 33 ++ .../acq/lineitem/lineitem-list.component.html | 385 ++++++++++++++++ .../staff/acq/lineitem/lineitem-list.component.ts | 511 +++++++++++++++++++++ .../app/staff/acq/lineitem/lineitem.component.html | 3 + .../app/staff/acq/lineitem/lineitem.component.ts | 9 + .../src/app/staff/acq/lineitem/lineitem.module.ts | 51 ++ .../src/app/staff/acq/lineitem/lineitem.service.ts | 300 ++++++++++++ .../app/staff/acq/lineitem/notes.component.html | 51 ++ .../src/app/staff/acq/lineitem/notes.component.ts | 81 ++++ .../acq/lineitem/order-summary.component.html | 20 + .../staff/acq/lineitem/order-summary.component.ts | 22 + .../staff/acq/lineitem/worksheet.component.html | 10 + .../app/staff/acq/lineitem/worksheet.component.ts | 125 +++++ .../app/staff/acq/picklist/picklist.component.html | 14 + .../app/staff/acq/picklist/picklist.component.ts | 28 ++ .../src/app/staff/acq/picklist/picklist.module.ts | 25 + .../src/app/staff/acq/picklist/routing.module.ts | 42 ++ .../app/staff/acq/picklist/summary.component.html | 26 ++ .../app/staff/acq/picklist/summary.component.ts | 117 +++++ .../src/app/staff/acq/po/charges.component.html | 65 +++ .../eg2/src/app/staff/acq/po/charges.component.ts | 67 +++ .../eg2/src/app/staff/acq/po/create.component.html | 38 ++ .../eg2/src/app/staff/acq/po/create.component.ts | 102 ++++ .../eg2/src/app/staff/acq/po/edi.component.html | 9 + .../src/eg2/src/app/staff/acq/po/edi.component.ts | 46 ++ .../src/app/staff/acq/po/history.component.html | 12 + .../eg2/src/app/staff/acq/po/history.component.ts | 48 ++ .../eg2/src/app/staff/acq/po/notes.component.html | 39 ++ .../eg2/src/app/staff/acq/po/notes.component.ts | 76 +++ .../src/eg2/src/app/staff/acq/po/po.component.html | 24 + .../src/eg2/src/app/staff/acq/po/po.component.ts | 29 ++ Open-ILS/src/eg2/src/app/staff/acq/po/po.module.ts | 43 ++ .../src/eg2/src/app/staff/acq/po/po.service.ts | 74 +++ .../eg2/src/app/staff/acq/po/print.component.html | 10 + .../eg2/src/app/staff/acq/po/print.component.ts | 129 ++++++ .../src/eg2/src/app/staff/acq/po/routing.module.ts | 64 +++ .../src/app/staff/acq/po/summary.component.html | 168 +++++++ .../eg2/src/app/staff/acq/po/summary.component.ts | 222 +++++++++ .../src/eg2/src/app/staff/acq/routing.module.ts | 26 +- .../acq/search/lineitem-results.component.html | 26 +- .../acq/search/picklist-results.component.html | 3 +- Open-ILS/src/eg2/src/app/staff/nav.component.html | 2 +- .../app/staff/share/marc-edit/editor-context.ts | 6 +- .../share/marc-edit/editor-dialog.component.ts | 4 +- .../app/staff/share/marc-edit/editor.component.ts | 5 +- .../app/staff/share/marc-edit/tagtable.service.ts | 3 +- Open-ILS/src/eg2/src/styles.css | 14 + .../perlmods/lib/OpenILS/Application/Acq/Common.pm | 80 ++++ .../lib/OpenILS/Application/Acq/Lineitem.pm | 48 ++ .../perlmods/lib/OpenILS/Application/Acq/Order.pm | 88 +--- 76 files changed, 4713 insertions(+), 154 deletions(-) create mode 100644 Open-ILS/src/eg2/src/app/share/item-location-select/item-location-select.service.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/lineitem/batch-copies.component.css create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/lineitem/batch-copies.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/lineitem/batch-copies.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/lineitem/brief-record.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/lineitem/brief-record.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/lineitem/cancel-dialog.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/lineitem/cancel-dialog.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/lineitem/copies.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/lineitem/copies.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/lineitem/copy-attrs.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/lineitem/copy-attrs.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/lineitem/detail.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/lineitem/detail.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/lineitem/history.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/lineitem/history.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem-list.component.css create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem-list.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem-list.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem.module.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem.service.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/lineitem/notes.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/lineitem/notes.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/lineitem/order-summary.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/lineitem/order-summary.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/lineitem/worksheet.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/lineitem/worksheet.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/picklist/picklist.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/picklist/picklist.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/picklist/picklist.module.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/picklist/routing.module.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/picklist/summary.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/picklist/summary.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/po/charges.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/po/charges.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/po/create.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/po/create.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/po/edi.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/po/edi.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/po/history.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/po/history.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/po/notes.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/po/notes.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/po/po.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/po/po.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/po/po.module.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/po/po.service.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/po/print.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/po/print.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/po/routing.module.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/po/summary.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/po/summary.component.ts create mode 100644 Open-ILS/src/perlmods/lib/OpenILS/Application/Acq/Common.pm diff --git a/Open-ILS/examples/fm_IDL.xml b/Open-ILS/examples/fm_IDL.xml index 832011bb37..947e7af936 100644 --- a/Open-ILS/examples/fm_IDL.xml +++ b/Open-ILS/examples/fm_IDL.xml @@ -10470,7 +10470,7 @@ SELECT usr, - + diff --git a/Open-ILS/src/eg2/src/app/share/catalog/marc-html.component.ts b/Open-ILS/src/eg2/src/app/share/catalog/marc-html.component.ts index 38b1da779b..03ef6d0d69 100644 --- a/Open-ILS/src/eg2/src/app/share/catalog/marc-html.component.ts +++ b/Open-ILS/src/eg2/src/app/share/catalog/marc-html.component.ts @@ -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 { + 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)); } diff --git a/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.html b/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.html index c48d981fd4..e498670032 100644 --- a/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.html +++ b/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.html @@ -12,30 +12,36 @@ -
- -
- keyboard_arrow_up - keyboard_arrow_down + + + + + + +
+ +
+ keyboard_arrow_up + keyboard_arrow_down +
-
+ diff --git a/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts b/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts index dd4639556a..da4ad68b5d 100644 --- a/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts +++ b/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts @@ -54,8 +54,8 @@ export class ComboboxComponent implements ControlValueAccessor, OnInit, AfterVie click$: Subject; entrylist: ComboboxEntry[]; - @ViewChild('instance', { static: true }) instance: NgbTypeahead; - @ViewChild('defaultDisplayTemplate', { static: true}) defaultDisplayTemplate: TemplateRef; + @ViewChild('instance', {static: false}) instance: NgbTypeahead; + @ViewChild('defaultDisplayTemplate', {static: true}) defaultDisplayTemplate: TemplateRef; @ViewChildren(IdlClassTemplateDirective) idlClassTemplates: QueryList; @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) { diff --git a/Open-ILS/src/eg2/src/app/share/item-location-select/item-location-select.component.html b/Open-ILS/src/eg2/src/app/share/item-location-select/item-location-select.component.html index 5f7e388a6a..d452fa797c 100644 --- a/Open-ILS/src/eg2/src/app/share/item-location-select/item-location-select.component.html +++ b/Open-ILS/src/eg2/src/app/share/item-location-select/item-location-select.component.html @@ -7,8 +7,11 @@ {}; 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(); } 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 { + + 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 { + + 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(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; }); } diff --git a/Open-ILS/src/eg2/src/app/share/item-location-select/item-location-select.module.ts b/Open-ILS/src/eg2/src/app/share/item-location-select/item-location-select.module.ts index f82989a852..cf687912fe 100644 --- a/Open-ILS/src/eg2/src/app/share/item-location-select/item-location-select.module.ts +++ b/Open-ILS/src/eg2/src/app/share/item-location-select/item-location-select.module.ts @@ -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 index 0000000000..228b3a7905 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/item-location-select/item-location-select.service.ts @@ -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} = {}; +} diff --git a/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts b/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts index 93dee2944d..e0a4e8fc5b 100644 --- a/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts +++ b/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts @@ -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, diff --git a/Open-ILS/src/eg2/src/app/share/print/print.component.ts b/Open-ILS/src/eg2/src/app/share/print/print.component.ts index faf8acd462..33554e3671 100644 --- a/Open-ILS/src/eg2/src/app/share/print/print.component.ts +++ b/Open-ILS/src/eg2/src/app/share/print/print.component.ts @@ -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); + }); }); }); }); diff --git a/Open-ILS/src/eg2/src/app/share/print/print.service.ts b/Open-ILS/src/eg2/src/app/share/print/print.service.ts index 25ef20606e..5723a4cfd9 100644 --- a/Open-ILS/src/eg2/src/app/share/print/print.service.ts +++ b/Open-ILS/src/eg2/src/app/share/print/print.service.ts @@ -31,12 +31,18 @@ export class PrintService { onPrintRequest$: EventEmitter; + // 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; + constructor( private locale: LocaleService, private auth: AuthService, private store: StoreService ) { this.onPrintRequest$ = new EventEmitter(); + this.printJobQueued$ = new EventEmitter(); } 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 index 0000000000..1dbbab34e4 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/batch-copies.component.css @@ -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 index 0000000000..615ddb0b81 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/batch-copies.component.html @@ -0,0 +1,57 @@ + + + + + + + + + +
+
Owning Branch
+
Copy Location
+
Collection Code
+
Fund
+
Circ Modifier
+
Callnumber
+
+ Barcode +
+
+
+
+
+ + + + +
+ +
+ +
+ + + +
+
+ +
+
+ + + + 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 index 0000000000..592d7230fa --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/batch-copies.component.ts @@ -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 { + + 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 index 0000000000..0dbe6db09c --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/brief-record.component.html @@ -0,0 +1,11 @@ + +

Add A Brief Record

+ +
+
{{attr.description()}}
+
+ +
+
+ + 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 index 0000000000..f1384da7cc --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/brief-record.component.ts @@ -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 = ` + + 00000nam a22000007a 4500 + +`; + +@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 index 0000000000..23ae488701 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/cancel-dialog.component.html @@ -0,0 +1,23 @@ + +
+ + + +
+
+ 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 index 0000000000..630f57d389 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/cancel-dialog.component.ts @@ -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 index 0000000000..0f768d83f3 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/copies.component.html @@ -0,0 +1,63 @@ + +
+
+ + + + + + + | + + + + + + + + + + +
+
+ +
+ +
+ + +
+ + + +
+
Distribution formulas applied to this lineitem
+
+
    +
  • +
    + +
    {{formula.create_time() | date:'short'}}
    +
    {{formula.creator().usrname()}}
    +
    {{formula.formula().name()}}
    +
    +
  • +
+
+
+ + +
+ + 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 index 0000000000..efc64c6dd1 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/copies.component.ts @@ -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 { + 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 { + + 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 index 0000000000..5e24456115 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/copy-attrs.component.html @@ -0,0 +1,116 @@ + + +
+
+ + +
+
+ + +
+
+ + {{copy.collection_code()}} + + + + +
+
+ + +
+
+ + +
+
+ + {{copy.cn_label()}} + + + + +
+
+ + + + + + {{copy.barcode()}} + + + + + +
+ +
+ + + + + + + Mark Received + + + Un-Receive + + + Cancel + + + Cancel + + + + {{copy.cancel_reason().label()}} + + + + + {{copy.cancel_reason().label()}} + + + + +
+
+
+ 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 index 0000000000..94b9e6d987 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/copy-attrs.component.ts @@ -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 = new EventEmitter(); + @Output() deleteRequested: EventEmitter = new EventEmitter(); + @Output() receiveRequested: EventEmitter = new EventEmitter(); + @Output() unReceiveRequested: EventEmitter = new EventEmitter(); + @Output() cancelRequested: EventEmitter = new EventEmitter(); + + @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 index 0000000000..5bcba98f0d --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/detail.component.html @@ -0,0 +1,48 @@ +
+ + +
+ + + 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 index 0000000000..4c4ff5e44e --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/detail.component.ts @@ -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 index 0000000000..dda7ce5531 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/history.component.html @@ -0,0 +1,9 @@ + + + +
+ + +
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 index 0000000000..3478b81063 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/history.component.ts @@ -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 index 0000000000..ac883144f5 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem-list.component.css @@ -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 index 0000000000..ce7c5e8273 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem-list.component.html @@ -0,0 +1,385 @@ + + + + +
+
+
+ +
+ Add Brief Record + + + + + + + + + + +
+
+
+
+ +
+
+
+ + +
+ +
+
+ +
+
+ report + Batch operation failed: + {{batchFailure.textcode}} {{batchFailure.desc}} + + + close + +
+
+ + + +
+
+
+
+ + +
+
+ +
+
+ + +
+
+ +
+ + {{selectedIds().length}} Selected + +
+ +
+ +
+ + + + +
+ + +
+
+
+
+ + + + +
+
+
+ + + + + + +
+ +
+ +
+
+ {{displayAttr(li, 'author')}} + {{displayAttr(li, 'isbn')}} + {{displayAttr(li, 'issn')}} + {{displayAttr(li, 'edition')}} + {{displayAttr(li, 'pubdate')}} + {{displayAttr(li, 'publisher')}} + {{li.source_label()}} +
+
+
+
+ +
+
+ +
+ + +
+
+
+
+ +
+
+
+ +
+ + + +
+
+
+ + +
+
+
+
+
+
+
+ +
New
+
Selector-Ready
+
Order-Ready
+
Approved
+
Pending-Order
+
On-Order
+
Received
+
Canceled
+
+
+
+
+ +
+ + + + View History +
+
+
+
+ +
+
+
+
+
+
+ +
+
+ + +
+
+
+
+ + +
+
Owning Branch
+
Copy Location
+
Collection Code
+
Fund
+
Circ Modifier
+
Callnumber
+
Barcode
+
+
+ + +
+
+
+
+ +
+
+ +
+
+ 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 index 0000000000..46765a2257 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem-list.component.ts @@ -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 { + 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 { + 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 { + return this.jumpToLiPage() + .then(_ => this.loadPageOfLis()) + .then(_ => this.setBatchSelect()) + .then(_ => setTimeout(() => this.focusLineitem())); + } + + jumpToLiPage(): Promise { + 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 { + 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 index 0000000000..678b34e9a2 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem.component.html @@ -0,0 +1,3 @@ + + + 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 index 0000000000..7d7c2d5ac8 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem.component.ts @@ -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 index 0000000000..888830f9dc --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem.module.ts @@ -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 index 0000000000..a4e49a1dbd --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem.service.ts @@ -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 = new EventEmitter(); + + // 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 { + + 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 { + + 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 { + + 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 { + 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 { + 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 { + + // 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 = 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 index 0000000000..25f393cad1 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/notes.component.html @@ -0,0 +1,51 @@ + +
+ +
+ +
+ + +
+ + + + + + + + close + +
+ +
+
+
+ +
VENDOR PUBLIC
+
+ + + [{{orgSn(note.alert_text().owning_lib())}}] {{note.alert_text().code()}} + + +
+
{{note.value()}}
+
{{note.create_time() | date:'short'}}
+
+ Delete +
+
+
+
+ + + 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 index 0000000000..963470d39d --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/notes.component.ts @@ -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 = new EventEmitter(); + + 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 { + 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 index 0000000000..d4b74828f5 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/order-summary.component.html @@ -0,0 +1,20 @@ + + {{li.order_summary().item_count()}} Items + | + {{li.order_summary().recv_count()}} Received + | + {{li.order_summary().invoice_count()}} Invoiced + | + {{li.order_summary().cancel_count()}} Canceled + | + {{li.order_summary().delay_count()}} Delayed + | + {{li.order_summary().estimated_amount() | currency}} Estimated + | + {{li.order_summary().encumbrance_amount() | currency}} Encumbered + | + + {{li.order_summary().paid_amount() | currency}} Paid + + + 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 index 0000000000..93a4c8079f --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/order-summary.component.ts @@ -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 index 0000000000..7261e574b1 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/worksheet.component.html @@ -0,0 +1,10 @@ +
+
+ +
+
+ +
+ 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 index 0000000000..21c2a13ab0 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/lineitem/worksheet.component.ts @@ -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 { + + // 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 { + + 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 index 0000000000..c4fe1b64ce --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/picklist/picklist.component.html @@ -0,0 +1,14 @@ + + + + + + + + + + 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 index 0000000000..52da433de0 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/picklist/picklist.component.ts @@ -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 index 0000000000..f39c96f313 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/picklist/picklist.module.ts @@ -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 index 0000000000..f641e5127c --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/picklist/routing.module.ts @@ -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 index 0000000000..2582d9d762 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/picklist/summary.component.html @@ -0,0 +1,26 @@ + +
+
+ Selection List: + + + + + {{picklist.name()}} (#{{picklist.id()}}) + +
+
+ Create Date: {{picklist.create_time() | date:'shortDate'}} +
+
+ Last Updated: {{picklist.edit_time() | date:'shortDate'}} +
+
+ Selector: {{picklist.owner().usrname()}} +
+
+ Entry Count: {{picklist.entry_count()}} +
+
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 index 0000000000..9ee226c198 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/picklist/summary.component.ts @@ -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 { + 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 index 0000000000..753f7309cf --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/po/charges.component.html @@ -0,0 +1,65 @@ + +

Direct Charges, Taxes, Fees, etc. + +

+ + +
+
Charge Type
+
Fund
+
Title/Description
+
Author
+
Note
+
Estimated Cost
+
+
+
+
+ +
+
+ + +
+
+ {{charge.title()}} + +
+
+ {{charge.author()}} + +
+
+ {{charge.note()}} + +
+
+ {{charge.estimated_cost() | currency}} + +
+
+ +
+
+ +
+
+
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 index 0000000000..646b98c419 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/po/charges.component.ts @@ -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 index 0000000000..7f7b218050 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/po/create.component.html @@ -0,0 +1,38 @@ + + + +
+
+ Creating for {{lineitems.length}} lineitems. +
+
+
+ + + +
+
+ + +
+
+ + + +
+
+ + +
+
+ + +
+
+ +
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 index 0000000000..fc287f2b3f --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/po/create.component.ts @@ -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 { + 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 index 0000000000..2a21b5af9e --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/po/edi.component.html @@ -0,0 +1,9 @@ + + + +
+ + +
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 index 0000000000..d6206a111e --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/po/edi.component.ts @@ -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 index 0000000000..04a9b12c4f --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/po/history.component.html @@ -0,0 +1,12 @@ + + + +
+ + + + + +
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 index 0000000000..92755d89f4 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/po/history.component.ts @@ -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 index 0000000000..8227a7cb96 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/po/notes.component.html @@ -0,0 +1,39 @@ + +
+ +
+ +
+ + +
+ + + close + +
+ +
+
+
+ +
VENDOR PUBLIC
+
+
+
{{note.value()}}
+
{{note.create_time() | date:'short'}}
+
+ Delete +
+
+
+
+ + + 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 index 0000000000..75bbaafd19 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/po/notes.component.ts @@ -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 = new EventEmitter(); + + 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 { + 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 index 0000000000..585ebab94c --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/po/po.component.html @@ -0,0 +1,24 @@ + + + + + + + + + + + + + +
+ + + + + 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 index 0000000000..04e8a0c1b6 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/po/po.component.ts @@ -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 index 0000000000..3d27b946c6 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/po/po.module.ts @@ -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 index 0000000000..46781894f7 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/po/po.service.ts @@ -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 = new EventEmitter(); + + constructor( + private evt: EventService, + private net: NetService, + private auth: AuthService + ) {} + + getFleshedPo(id: number, fleshMore?: any, noCache?: boolean): Promise { + + 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 { + + 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 index 0000000000..d903617308 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/po/print.component.html @@ -0,0 +1,10 @@ +
+
+ +
+
+ + + 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 index 0000000000..2ed659a640 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/po/print.component.ts @@ -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 { + + return this.printer.compileRemoteTemplate({ + templateName: 'purchase_order', + printContext: 'default', + contextData: {po: this.po} + + }).then(response => { + this.outlet.innerHTML = response.content; + }); + } + + addLiPrintNotes(): Promise { + + 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 index 0000000000..02a7918154 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/po/routing.module.ts @@ -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 index 0000000000..d7442b90da --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/po/summary.component.html @@ -0,0 +1,168 @@ + + + + +
+ +
+
+ +
+
+
PO ID:
+
{{poId}}
+
+
+
PO Name:
+
+ + + + + {{po().name()}} + +
+
+
+ +
+
+
Lineitems:
+
{{po().lineitem_count()}}
+
+ +
+
+
+
Activated:
+
+ {{po().order_date() | date:'short'}} + N/A +
+
+
+
Status:
+
+
+ +
+ + On Order + + Pending / Activatable + + Activation Error: {{activationEvent.textcode}} {{activationEvent.desc}} + + + + + + + {{po().cancel_reason().label()}} => {{po().cancel_reason().description()}} + + + + +
+ + + Fund exceeds stop percent: + {{evt.payload.fund.code()}} ({{evt.payload.fund.year()}}). + + + + + + Fund exceeds warning percent: + {{evt.payload.fund.code()}} ({{evt.payload.fund.year()}}). + + + + + + One or more lineitems have no price. + + + + + One or more lineitems have no items attached. + + + + {{evt.textcode}} : {{evt.desc}} + +
+
+
+
+
+ +
+
+
+
Estimated Amount:
+
{{po().amount_estimated() | currency}}
+
+
+
Encumbered Amount:
+
{{po().amount_encumbered() | currency}}
+
+
+
Spent Amount:
+
{{po().amount_spent() | currency}}
+
+
+
Prepayment Required?
+
+ +
+
+
+
+
+
+ + +
+
+
+ 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 index 0000000000..0a77bb827b --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/po/summary.component.ts @@ -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 { + 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}); + } + }); + } +} + + diff --git a/Open-ILS/src/eg2/src/app/staff/acq/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/acq/routing.module.ts index 8cde15192f..7848bccc2b 100644 --- a/Open-ILS/src/eg2/src/app/staff/acq/routing.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/acq/routing.module.ts @@ -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)], diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/lineitem-results.component.html b/Open-ILS/src/eg2/src/app/staff/acq/search/lineitem-results.component.html index 84bd8bdf58..bfd16526e2 100644 --- a/Open-ILS/src/eg2/src/app/staff/acq/search/lineitem-results.component.html +++ b/Open-ILS/src/eg2/src/app/staff/acq/search/lineitem-results.component.html @@ -3,26 +3,30 @@ defaultSearchSetting="eg.acq.search.default.lineitems"> - + {{lineitem.id()}} - + {{lineitem.id()}} - + {{lineitem.purchase_order().name()}} - + {{lineitem.picklist().name()}} @@ -47,10 +51,10 @@
  • Catalog
  • -
  • Worksheet
  • - Purchase Order
  • Requests
  • @@ -61,7 +65,7 @@ Queue
  • - Selection List
  • diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-results.component.html b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-results.component.html index 0edc50ff60..612b5a09da 100644 --- a/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-results.component.html +++ b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-results.component.html @@ -13,8 +13,7 @@ - + {{selectionlist.name()}} diff --git a/Open-ILS/src/eg2/src/app/staff/nav.component.html b/Open-ILS/src/eg2/src/app/staff/nav.component.html index 2e03b20adb..af3a9fbd5a 100644 --- a/Open-ILS/src/eg2/src/app/staff/nav.component.html +++ b/Open-ILS/src/eg2/src/app/staff/nav.component.html @@ -292,7 +292,7 @@ Purchase Orders - + Create Purchase Order diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts index 49ed524e1f..2a67b8c190 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts @@ -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; fieldFocusRequest: EventEmitter; textUndoRedoRequest: EventEmitter; - recordType: 'biblio' | 'authority' = 'biblio'; + recordType: MARC_RECORD_TYPE; lastFocused: FieldFocusRequest = null; diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-dialog.component.ts index a0304e14ee..07f6567f0c 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-dialog.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-dialog.component.ts @@ -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, diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts index cbcf5edc1d..2960ad3366 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts @@ -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) { diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts index 8b867a88eb..9862d67f3d 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts @@ -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; diff --git a/Open-ILS/src/eg2/src/styles.css b/Open-ILS/src/eg2/src/styles.css index 9c4ad967f0..4ca5538e2b 100644 --- a/Open-ILS/src/eg2/src/styles.css +++ b/Open-ILS/src/eg2/src/styles.css @@ -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 index 0000000000..7b94177801 --- /dev/null +++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Acq/Common.pm @@ -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}; +} + + + diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Acq/Lineitem.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Acq/Lineitem.pm index d3178d6993..cfc2fcc635 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Acq/Lineitem.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Acq/Lineitem.pm @@ -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( diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Acq/Order.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Acq/Order.pm index 377a9b45e0..d9d52fd1b3 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Acq/Order.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Acq/Order.pm @@ -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); } -- 2.11.0