From 2d9ba44f750b1090ff487649257d037c4c99848d Mon Sep 17 00:00:00 2001 From: Galen Charlton Date: Tue, 22 Oct 2019 15:29:40 -0400 Subject: [PATCH] LP#1850547: Angular Acquistions Search This patch adds the Angular application for acquisitions search and links the Angular and AngularJS navbars to it. Includes contributions by Mike Rylander and Jason Etheridge. I would like to also specifically acknowledge feedback from Bill Erickson and Mike Risher on this patch series. Signed-off-by: Galen Charlton Signed-off-by: Mike Rylander Signed-off-by: Tiffany Little Signed-off-by: Bill Erickson --- Open-ILS/src/eg2/src/app/routing.module.ts | 4 +- .../src/eg2/src/app/staff/acq/routing.module.ts | 16 ++ .../staff/acq/search/acq-search-form.component.css | 5 + .../acq/search/acq-search-form.component.html | 165 ++++++++++++ .../staff/acq/search/acq-search-form.component.ts | 248 ++++++++++++++++++ .../app/staff/acq/search/acq-search.component.html | 29 +++ .../app/staff/acq/search/acq-search.component.ts | 146 +++++++++++ .../src/app/staff/acq/search/acq-search.module.ts | 35 +++ .../src/app/staff/acq/search/acq-search.service.ts | 284 +++++++++++++++++++++ .../src/app/staff/acq/search/attr-defs.service.ts | 34 +++ .../acq/search/invoice-results.component.html | 51 ++++ .../staff/acq/search/invoice-results.component.ts | 106 ++++++++ .../acq/search/lineitem-results.component.html | 91 +++++++ .../staff/acq/search/lineitem-results.component.ts | 74 ++++++ .../search/picklist-clone-dialog.component.html | 27 ++ .../acq/search/picklist-clone-dialog.component.ts | 77 ++++++ .../search/picklist-create-dialog.component.html | 30 +++ .../acq/search/picklist-create-dialog.component.ts | 79 ++++++ .../search/picklist-delete-dialog.component.html | 24 ++ .../acq/search/picklist-delete-dialog.component.ts | 77 ++++++ .../search/picklist-merge-dialog.component.html | 32 +++ .../acq/search/picklist-merge-dialog.component.ts | 73 ++++++ .../acq/search/picklist-results.component.html | 63 +++++ .../staff/acq/search/picklist-results.component.ts | 143 +++++++++++ .../search/purchase-order-results.component.html | 44 ++++ .../acq/search/purchase-order-results.component.ts | 68 +++++ .../src/app/staff/acq/search/resolver.service.ts | 25 ++ .../eg2/src/app/staff/acq/search/routing.module.ts | 26 ++ Open-ILS/src/eg2/src/app/staff/nav.component.html | 18 +- Open-ILS/src/eg2/src/app/staff/nav.component.ts | 8 + Open-ILS/src/eg2/src/app/staff/routing.module.ts | 4 + Open-ILS/src/eg2/src/app/staff/staff.component.css | 1 + Open-ILS/src/eg2/src/styles.css | 18 ++ Open-ILS/src/templates/staff/navbar.tt2 | 12 +- .../web/js/ui/default/staff/services/navbar.js | 2 + 35 files changed, 2123 insertions(+), 16 deletions(-) create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/routing.module.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/search/acq-search-form.component.css create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/search/acq-search-form.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/search/acq-search-form.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/search/acq-search.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/search/acq-search.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/search/acq-search.module.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/search/acq-search.service.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/search/attr-defs.service.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/search/invoice-results.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/search/invoice-results.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/search/lineitem-results.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/search/lineitem-results.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/search/picklist-clone-dialog.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/search/picklist-clone-dialog.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/search/picklist-create-dialog.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/search/picklist-create-dialog.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/search/picklist-delete-dialog.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/search/picklist-delete-dialog.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/search/picklist-merge-dialog.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/search/picklist-merge-dialog.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/search/picklist-results.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/search/picklist-results.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/search/purchase-order-results.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/search/purchase-order-results.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/search/resolver.service.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/acq/search/routing.module.ts diff --git a/Open-ILS/src/eg2/src/app/routing.module.ts b/Open-ILS/src/eg2/src/app/routing.module.ts index 3b7d2e312a..4187c85bb2 100644 --- a/Open-ILS/src/eg2/src/app/routing.module.ts +++ b/Open-ILS/src/eg2/src/app/routing.module.ts @@ -21,7 +21,9 @@ const routes: Routes = [ ]; @NgModule({ - imports: [RouterModule.forRoot(routes)], + imports: [RouterModule.forRoot(routes, { + onSameUrlNavigation: 'reload' + })], exports: [RouterModule], providers: [BaseResolver] }) diff --git a/Open-ILS/src/eg2/src/app/staff/acq/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/acq/routing.module.ts new file mode 100644 index 0000000000..2230dd71d5 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/routing.module.ts @@ -0,0 +1,16 @@ +import {NgModule} from '@angular/core'; +import {RouterModule, Routes} from '@angular/router'; + +const routes: Routes = [ + { path: 'search', + loadChildren: () => + import('./search/acq-search.module').then(m => m.AcqSearchModule) + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) + +export class AcqRoutingModule {} diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search-form.component.css b/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search-form.component.css new file mode 100644 index 0000000000..8842d11421 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search-form.component.css @@ -0,0 +1,5 @@ +#acq-search-form { + border-radius: 0px 0px 7px 7px; + background-color: rgb(247, 247, 247); + box-shadow: 1px 2px 3px -1px rgba(0, 0, 0, .2); +} diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search-form.component.html b/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search-form.component.html new file mode 100644 index 0000000000..6382a8c4be --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search-form.component.html @@ -0,0 +1,165 @@ + + + +
+
+
+
+ +
+ +
+
+
+ +
+
+ +
+
+ + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + and + + + + + + +
+
+
+ + +
+
+
+
+ +
+
+
+ + +
+
+ + +
+
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search-form.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search-form.component.ts new file mode 100644 index 0000000000..19d4a1f9c8 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search-form.component.ts @@ -0,0 +1,248 @@ +import {Component, OnInit, AfterViewInit, Input, Output, EventEmitter, ViewChild, + OnChanges, SimpleChanges} from '@angular/core'; +import {Router, ActivatedRoute} from '@angular/router'; +import {StaffCommonModule} from '@eg/staff/common.module'; +import {IdlService, IdlObject} from '@eg/core/idl.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {StringComponent} from '@eg/share/string/string.component'; +import {ToastService} from '@eg/share/toast/toast.service'; +import {AcqSearchTerm, AcqSearch} from './acq-search.service'; +import {ServerStoreService} from '@eg/core/server-store.service'; + +@Component({ + selector: 'eg-acq-search-form', + styleUrls: ['acq-search-form.component.css'], + templateUrl: './acq-search-form.component.html' +}) + +export class AcqSearchFormComponent implements OnInit, AfterViewInit, OnChanges { + + @Input() initialSearchTerms: AcqSearchTerm[] = []; + @Input() fallbackSearchTerms: AcqSearchTerm[] = []; + @Input() defaultSearchSetting = ''; + @Input() runImmediatelySetting = ''; + @Input() searchTypeLabel = ''; + + @Output() searchSubmitted = new EventEmitter(); + + @ViewChild('defaultSearchSavedString', { static: true}) defaultSearchSavedString: StringComponent; + @ViewChild('defaultSearchResetString', { static: true}) defaultSearchResetString: StringComponent; + + showForm = true; + + hints = ['jub', 'acqpl', 'acqpo', 'acqinv', 'acqlid']; + availableSearchFields = {}; + dateLikeSearchFields = {}; + searchTermDatatypes = {}; + searchTermFieldIsRequired = {}; + searchFieldLinkedClasses = {}; + validSearchTypes = ['lineitems', 'purchaseorders', 'invoices', 'selectionlists']; + defaultSearchType = 'lineitems'; + searchConjunction = 'all'; + runImmediately = false; + hasDefaultSearch = false; + + searchTerms: AcqSearchTerm[] = []; + + constructor( + private router: Router, + private route: ActivatedRoute, + private pcrud: PcrudService, + private store: ServerStoreService, + private idl: IdlService, + private toast: ToastService, + ) {} + + ngOnInit() { + const self = this; + + this.store.getItem(this.runImmediatelySetting).then(val => { + this.runImmediately = val; + + this.hints.forEach( + function(hint) { + const o = {}; + o['__label'] = self.idl.classes[hint].label; + o['__fields'] = []; + self.idl.classes[hint].fields.forEach( + function(field) { + if (!field.virtual) { + o['__fields'].push(field.name); + o[field.name] = { + label: field.label, + datatype: field.datatype + }; + self.searchTermDatatypes[hint + ':' + field.name] = field.datatype; + self.searchTermFieldIsRequired[hint + ':' + field.name] = field.required; + if (field.datatype === 'link') { + self.searchFieldLinkedClasses[hint + ':' + field.name] = field.class; + } + } + } + ); + self.availableSearchFields[hint] = o; + } + ); + + this.hints.push('acqlia'); + this.availableSearchFields['acqlia'] = {'__label': this.idl.classes.acqlia.label, '__fields': []}; + this.pcrud.retrieveAll('acqliad', {'order_by': {'acqliad': 'id'}}) + .subscribe(liad => { + this.availableSearchFields['acqlia']['__fields'].push('' + liad.id()); + this.availableSearchFields['acqlia'][liad.id()] = { + label: liad.description(), + datatype: 'text' + }; + this.searchTermDatatypes['acqlia:' + liad.id()] = 'text'; + if (liad.code().match(/date/)) { + this.dateLikeSearchFields['acqlia:' + liad.id()] = true; + } + }); + + if (this.initialSearchTerms.length > 0) { + this.searchTerms = JSON.parse(JSON.stringify(this.initialSearchTerms)); // deep copy + this.submitSearch(); // if we've been passed an initial search, e.g., via a URL, assume + // we want the results immediately regardless of the workstation + // setting + } else { + this.store.getItem(this.defaultSearchSetting).then( + defaultSearch => { + if (defaultSearch) { + this.searchTerms = JSON.parse(JSON.stringify(defaultSearch.terms)); + this.searchConjunction = defaultSearch.conjunction; + this.hasDefaultSearch = true; + } else if (this.fallbackSearchTerms.length) { + this.searchTerms.length = 0; + JSON.parse(JSON.stringify(this.fallbackSearchTerms)) + .forEach(term => this.searchTerms.push(term)); // need a copy + } else { + this.addSearchTerm(); + } + if (this.runImmediately) { + if ((this.searchTerms.length > 0) && + (this.searchTerms[0].field !== '')) { + this.submitSearch(); + } + } + } + ); + } + }); + } + + ngAfterViewInit() {} + + ngOnChanges(changes: SimpleChanges) { + if ('initialSearchTerms' in changes && !changes.initialSearchTerms.firstChange) { + this.ngOnInit(); + } + } + + addSearchTerm() { + this.searchTerms.push({ field: '', op: '', value1: '', value2: '' }); + } + delSearchTerm(index: number) { + if (this.searchTerms.length < 2) { + this.clearSearchTerm(this.searchTerms[0]); + // special case for org_unit + if (this.searchTerms[0].field && this.searchTermDatatypes[this.searchTerms[0].field] === 'org_unit') { + this.searchTerms = [{ field: this.searchTerms[0].field, op: this.searchTerms[0].op, value1: '', value2: ''}]; + } + // and timestamps + if (this.searchTerms[0].field && this.searchTermDatatypes[this.searchTerms[0].field] === 'timestamp') { + this.searchTerms = [{ field: this.searchTerms[0].field, op: this.searchTerms[0].op, value1: '', value2: ''}]; + } + } else { + this.searchTerms.splice(index, 1); + } + } + clearSearchTerm(term: AcqSearchTerm, old?) { + // work around fact that org selector doesn't implement ngModel + // and we don't use it for eg-date-select + if (old && this.searchTermDatatypes[old] === this.searchTermDatatypes[term.field] && + (this.searchTermDatatypes[old] === 'org_unit' || this.searchTermDatatypes[old] === 'timestamp')) { + // don't change values if we're moving from one + // org_unit or timestamp field to another + } else { + term.value1 = ''; + term.value2 = ''; + term.is_date = false; + } + + // handle change of field type + if (old && this.searchTermDatatypes[old] !== this.searchTermDatatypes[term.field]) { + term.op = ''; + } + if (old && this.searchTermDatatypes[old] === this.searchTermDatatypes[term.field] && + this.searchTermDatatypes[term.field] === 'link' && + (this.searchFieldLinkedClasses[old] !== this.searchFieldLinkedClasses[term.field]) + ) { + term.op = ''; + } + if (term.field.startsWith('acqlia:') && term.op === '') { + // default operator for line item attributes should be "contains" + term.op = '__fuzzy'; + } else if (this.searchTermDatatypes[term.field] !== 'text' && term.op.endsWith('__fuzzy')) { + // avoid trying to use the "contains" operator for non-text fields + term.op = ''; + } + } + // conditionally clear the search term after changing + // to selected search operators + clearSearchTermValueAfterOpChange(term: AcqSearchTerm, oldOp?) { + if (term.op === '__age') { + term.value1 = ''; + term.value2 = ''; + } + if (this.searchTermDatatypes[term.field] === 'link') { + if (oldOp === '__fuzzy' || term.op === '__fuzzy' || + oldOp === '__not,__fuzzy' || term.op === '__not,__fuzzy' + ) { + term.value1 = ''; + term.value2 = ''; + } + } + } + + setOrgUnitSearchValue(org: IdlObject, term: AcqSearchTerm) { + if (org == null) { + term.value1 = ''; + } else { + term.value1 = org.id(); + } + } + + submitSearch() { + // tossing setTimeout here to ensure that the + // grid data source is fully initialized + setTimeout(() => { + this.searchSubmitted.emit({ + terms: this.searchTerms, + conjunction: this.searchConjunction + }); + }); + } + + saveSearchAsDefault() { + return this.store.setItem(this.defaultSearchSetting, { + terms: this.searchTerms, + conjunction: this.searchConjunction + }).then(() => { + this.hasDefaultSearch = true; + this.defaultSearchSavedString.current().then(msg => + this.toast.success(msg) + ); + }); + } + clearDefaultSearch() { + return this.store.removeItem(this.defaultSearchSetting).then(() => { + this.hasDefaultSearch = false; + this.defaultSearchResetString.current().then(msg => + this.toast.success(msg) + ); + }); + } + saveRunImmediately() { + return this.store.setItem(this.runImmediatelySetting, this.runImmediately); + } +} diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search.component.html b/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search.component.html new file mode 100644 index 0000000000..1decc23a07 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search.component.html @@ -0,0 +1,29 @@ + + + + + diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search.component.ts new file mode 100644 index 0000000000..959963f0ac --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search.component.ts @@ -0,0 +1,146 @@ +import {Component, OnInit, AfterViewInit, ViewChild, ViewChildren, QueryList, OnDestroy} from '@angular/core'; +import {NgbNav, NgbNavChangeEvent} from '@ng-bootstrap/ng-bootstrap'; +import {Router, ActivatedRoute, ParamMap, RouterEvent, NavigationEnd} from '@angular/router'; +import {filter, takeUntil} from 'rxjs/operators'; +import {Subject} from 'rxjs'; +import {StaffCommonModule} from '@eg/staff/common.module'; +import {IdlService, IdlObject} from '@eg/core/idl.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {AcqSearchTerm} from './acq-search.service'; +import {LineitemResultsComponent} from './lineitem-results.component'; +import {PurchaseOrderResultsComponent} from './purchase-order-results.component'; +import {InvoiceResultsComponent} from './invoice-results.component'; +import {PicklistResultsComponent} from './picklist-results.component'; + +@Component({ + templateUrl: './acq-search.component.html' +}) + +export class AcqSearchComponent implements OnInit, AfterViewInit, OnDestroy { + + searchType = ''; + validSearchTypes = ['lineitems', 'purchaseorders', 'invoices', 'selectionlists']; + defaultSearchType = 'lineitems'; + + urlSearchTerms: AcqSearchTerm[] = []; + + onTabChange: ($event: NgbNavChangeEvent) => void; + @ViewChild('acqSearchTabs', { static: true }) tabs: NgbNav; + @ViewChildren(LineitemResultsComponent) liResults: QueryList; + @ViewChildren(PurchaseOrderResultsComponent) poResults: QueryList; + @ViewChildren(InvoiceResultsComponent) invResults: QueryList; + @ViewChildren(PicklistResultsComponent) plResults: QueryList; + + previousUrl: string = null; + public destroyed = new Subject(); + + constructor( + private router: Router, + private route: ActivatedRoute, + private pcrud: PcrudService, + private idl: IdlService, + ) { + this.route.queryParamMap.subscribe((params: ParamMap) => { + this.urlSearchTerms = []; + const fields = params.getAll('f'); + const ops = params.getAll('op'); + const values1 = params.getAll('val1'); + const values2 = params.getAll('val2'); + fields.forEach((f, idx) => { + const term: AcqSearchTerm = { + field: f, + op: '', + value1: '', + value2: '' + }; + if (idx < ops.length) { + term.op = ops[idx]; + } + if (idx < values1.length) { + term.value1 = values1[idx]; + if (term.value1 === 'null') { + // convert the string 'null' to a true + // null value, mostly for the benefit of the + // open invoices navigation link + term.value1 = null; + } + } + if (idx < values2.length) { + term.value2 = values2[idx]; + } + this.urlSearchTerms.push(term); + this.ngOnInit(); // TODO: probably overkill + }); + }); + this.router.events.pipe( + filter((event: RouterEvent) => event instanceof NavigationEnd), + takeUntil(this.destroyed) + ).subscribe(routeEvent => { + if (routeEvent instanceof NavigationEnd) { + // force reset of grid data source if we're navigating from + // a search tab to the same search tab + if (this.previousUrl != null) { + const prevRoute = this.previousUrl.match(/acq\/search\/([a-z]+)/); + const newRoute = routeEvent.url.match(/acq\/search\/([a-z]+)/); + const prevTab = prevRoute == null ? 'lineitems' : prevRoute[1]; + const newTab = newRoute == null ? 'lineitems' : newRoute[1]; + if (prevTab === newTab) { + switch (newTab) { + case 'lineitems': + this.liResults.toArray()[0].gridSource.reset(); + this.liResults.toArray()[0].acqSearchForm.ngOnInit(); + break; + case 'purchaseorders': + this.poResults.toArray()[0].gridSource.reset(); + this.poResults.toArray()[0].acqSearchForm.ngOnInit(); + break; + case 'invoices': + this.invResults.toArray()[0].gridSource.reset(); + this.invResults.toArray()[0].acqSearchForm.ngOnInit(); + break; + case 'selectionlists': + this.plResults.toArray()[0].gridSource.reset(); + this.plResults.toArray()[0].acqSearchForm.ngOnInit(); + break; + } + } + } + this.previousUrl = routeEvent.url; + this.ngOnInit(); // TODO: probably overkill + } + }); + } + + ngOnInit() { + const self = this; + + const searchTypeParam = this.route.snapshot.paramMap.get('searchtype'); + + if (searchTypeParam) { + if (this.validSearchTypes.includes(searchTypeParam)) { + this.searchType = searchTypeParam; + } else { + this.searchType = this.defaultSearchType; + this.router.navigate(['/staff', 'acq', 'search', this.searchType]); + } + } else { + this.searchType = this.defaultSearchType; + } + + this.onTabChange = ($event) => { + if (this.validSearchTypes.includes($event.nextId)) { + this.searchType = $event.nextId; + this.urlSearchTerms = []; + this.router.navigate(['/staff', 'acq', 'search', $event.nextId]); + } + }; + } + + ngAfterViewInit() {} + + ngOnDestroy(): void { + this.destroyed.next(); + this.destroyed.complete(); + } + +} diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search.module.ts b/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search.module.ts new file mode 100644 index 0000000000..4dc22517f1 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search.module.ts @@ -0,0 +1,35 @@ +import {NgModule} from '@angular/core'; +import {StaffCommonModule} from '@eg/staff/common.module'; +import {AcqSearchRoutingModule} from './routing.module'; +import {AcqSearchComponent} from './acq-search.component'; +import {AcqSearchFormComponent} from './acq-search-form.component'; +import {LineitemResultsComponent} from './lineitem-results.component'; +import {PurchaseOrderResultsComponent} from './purchase-order-results.component'; +import {InvoiceResultsComponent} from './invoice-results.component'; +import {PicklistResultsComponent} from './picklist-results.component'; +import {PicklistCreateDialogComponent} from './picklist-create-dialog.component'; +import {PicklistCloneDialogComponent} from './picklist-clone-dialog.component'; +import {PicklistDeleteDialogComponent} from './picklist-delete-dialog.component'; +import {PicklistMergeDialogComponent} from './picklist-merge-dialog.component'; + +@NgModule({ + declarations: [ + AcqSearchComponent, + AcqSearchFormComponent, + LineitemResultsComponent, + PurchaseOrderResultsComponent, + InvoiceResultsComponent, + PicklistResultsComponent, + PicklistCreateDialogComponent, + PicklistCloneDialogComponent, + PicklistDeleteDialogComponent, + PicklistMergeDialogComponent + ], + imports: [ + StaffCommonModule, + AcqSearchRoutingModule + ] +}) + +export class AcqSearchModule { +} diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search.service.ts b/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search.service.ts new file mode 100644 index 0000000000..0a96183d84 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/search/acq-search.service.ts @@ -0,0 +1,284 @@ +import {Injectable} from '@angular/core'; +import {empty, throwError} from 'rxjs'; +import {map} from 'rxjs/operators'; +import {NetService} from '@eg/core/net.service'; +import {AuthService} from '@eg/core/auth.service'; +import {GridDataSource} from '@eg/share/grid/grid'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {Pager} from '@eg/share/util/pager'; +import {IdlObject} from '@eg/core/idl.service'; +import {EventService} from '@eg/core/event.service'; +import {AttrDefsService} from './attr-defs.service'; + +const baseIdlClass = { + lineitem: 'jub', + purchase_order: 'acqpo', + picklist: 'acqpl', + invoice: 'acqinv' +}; + +const defaultSearch = { + lineitem: { + jub: [{ + id: '0', + __gte: true + }] + }, + purchase_order: { + acqpo: [{ + id: '0', + __gte: true + }] + }, + picklist: { + acqpl: [{ + id: '0', + __gte: true + }] + }, + invoice: { + acqinv: [{ + id: '0', + __gte: true + }] + }, +}; + +const searchOptions = { + lineitem: { + flesh_attrs: true, + flesh_cancel_reason: true, + flesh_notes: true, + flesh_provider: true, + flesh_claim_policy: true, + flesh_queued_record: true, + flesh_creator: true, + flesh_editor: true, + flesh_selector: true, + flesh_po: true, + flesh_pl: true, + }, + purchase_order: { + no_flesh_cancel_reason: true, + flesh_provider: true, + flesh_owner: true, + flesh_creator: true, + flesh_editor: true + }, + picklist: { + flesh_lineitem_count: true, + flesh_owner: true, + flesh_creator: true, + flesh_editor: true + }, + invoice: { + no_flesh_misc: false, + flesh_provider: true // and shipper, which is also a provider + } +}; + +const operatorMap = { + '!=': '__not', + '>': '__gt', + '>=': '__gte', + '<=': '__lte', + '<': '__lt', + 'startswith': '__starts', + 'endswith': '__ends', + 'like': '__fuzzy', +}; + +export interface AcqSearchTerm { + field: string; + op: string; + value1: string; + value2: string; + is_date?: boolean; +} + +export interface AcqSearch { + terms: AcqSearchTerm[]; + conjunction: string; +} + +@Injectable() +export class AcqSearchService { + + _terms: AcqSearchTerm[] = []; + _conjunction = 'all'; + firstRun = true; + + constructor( + private net: NetService, + private evt: EventService, + private auth: AuthService, + private pcrud: PcrudService, + private attrDefs: AttrDefsService + ) { + this.firstRun = true; + } + + setSearch(search: AcqSearch) { + this._terms = search.terms; + this._conjunction = search.conjunction; + this.firstRun = false; + } + + generateAcqSearch(searchType, filters): any { + const andTerms = JSON.parse(JSON.stringify(defaultSearch[searchType])); // deep copy + const orTerms = {}; + const coreRecType = Object.keys(defaultSearch[searchType])[0]; + + // handle supplied search terms + this._terms.forEach(term => { + if (term.value1 === '' && !(term.op === '__isnull' || term.op === '__isnotnull')) { + return; + } + const searchTerm: Object = {}; + const recType = term.field.split(':')[0]; + const searchField = term.field.split(':')[1]; + if (term.op === '__isnull') { + searchTerm[searchField] = null; + } else if (term.op === '__isnotnull') { + searchTerm[searchField] = { '!=' : null }; + } else if (term.op === '__between') { + searchTerm[searchField] = [term.value1, term.value2]; + } else { + searchTerm[searchField] = term.value1; + } + if (term.op !== '') { + if (term.op === '__not,__fuzzy') { + searchTerm['__not'] = true; + searchTerm['__fuzzy'] = true; + } else { + searchTerm[term.op] = true; + } + } + if (term.is_date) { + searchTerm['__castdate'] = true; + } + if (this._conjunction === 'any') { + if (!(recType in orTerms)) { + orTerms[recType] = []; + } + orTerms[recType].push(searchTerm); + } else { + if (!(recType in andTerms)) { + andTerms[recType] = []; + } + andTerms[recType].push(searchTerm); + } + }); + + // handle grid filters + // note that date filters coming from the grid do not need + // to worry about __castdate because the grid filter supplies + // both the start and end times + const observables = []; + Object.keys(filters).forEach(filterField => { + filters[filterField].forEach(condition => { + const searchTerm: Object = {}; + let filterOp = '='; + let filterVal = ''; + if (Object.keys(condition).some(x => x === '-not')) { + filterOp = Object.keys(condition['-not'][filterField])[0]; + filterVal = condition['-not'][filterField][filterOp]; + searchTerm['__not'] = true; + } else { + filterOp = Object.keys(condition[filterField])[0]; + filterVal = condition[filterField][filterOp]; + if (filterOp === 'like' && filterVal.length > 1) { + if (filterVal[0] === '%' && filterVal[filterVal.length - 1] === '%') { + filterVal = filterVal.slice(1, filterVal.length - 1); + } else if (filterVal[filterVal.length - 1] === '%') { + filterVal = filterVal.slice(0, filterVal.length - 1); + filterOp = 'startswith'; + } else if (filterVal[0] === '%') { + filterVal = filterVal.slice(1); + filterOp = 'endswith'; + } + } + } + + if (filterOp in operatorMap) { + searchTerm[operatorMap[filterOp]] = true; + } + if ((['title', 'author'].indexOf(filterField) > -1) && + (filterField in this.attrDefs.attrDefs)) { + if (!('acqlia' in andTerms)) { + andTerms['acqlia'] = []; + } + searchTerm[this.attrDefs.attrDefs[filterField].id()] = filterVal; + andTerms['acqlia'].push(searchTerm); + } else { + searchTerm[filterField] = filterVal; + andTerms[coreRecType].push(searchTerm); + } + }); + }); + return { andTerms: andTerms, orTerms: orTerms }; + } + + getAcqSearchDataSource(searchType: string): GridDataSource { + const gridSource = new GridDataSource(); + + gridSource.getRows = (pager: Pager, sort: any[]) => { + + // don't do a search the very first time we + // get invoked, which is during initialization; we'll + // let components higher up the change decide whether + // to submit a search + if (this.firstRun) { + this.firstRun = false; + return empty(); + } + + const currentSearch = this.generateAcqSearch(searchType, gridSource.filters); + + const opts = { ...searchOptions[searchType] }; + opts['offset'] = pager.offset; + opts['limit'] = pager.limit; + opts['au_by_id'] = true; + + if (sort.length > 0) { + opts['order_by'] = []; + sort.forEach(sort_clause => { + if (searchType === 'lineitem' && + ['title', 'author'].indexOf(sort_clause.name) > -1) { + opts['order_by'].push({ + class: 'acqlia', + field: 'attr_value', + direction: sort_clause.dir + }); + opts['order_by_attr'] = sort_clause.name; + } else { + opts['order_by'].push({ + class: baseIdlClass[searchType], + field: sort_clause.name, + direction: sort_clause.dir + }); + } + }); + } + + return this.net.request( + 'open-ils.acq', + 'open-ils.acq.' + searchType + '.unified_search', + this.auth.token(), + currentSearch.andTerms, + currentSearch.orTerms, + null, + opts + ).pipe( + map(res => { + if (this.evt.parse(res)) { + throw throwError(res); + } else { + return res; + } + }), + ); + }; + return gridSource; + } +} diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/attr-defs.service.ts b/Open-ILS/src/eg2/src/app/staff/acq/search/attr-defs.service.ts new file mode 100644 index 0000000000..4a040dae38 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/search/attr-defs.service.ts @@ -0,0 +1,34 @@ +import {Injectable} from '@angular/core'; +import {empty, throwError} from 'rxjs'; +import {map} from 'rxjs/operators'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {IdlObject} from '@eg/core/idl.service'; + +@Injectable() +export class AttrDefsService { + + attrDefs: {[code: string]: IdlObject}; + + constructor( + private pcrud: PcrudService + ) { + this.attrDefs = {}; + } + + fetchAttrDefs(): Promise { + if (Object.keys(this.attrDefs).length) { + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + this.pcrud.retrieveAll('acqliad', {}, + {atomic: true} + ).subscribe(list => { + list.forEach(acqliad => { + this.attrDefs[acqliad.code()] = acqliad; + }); + resolve(); + }); + }); + } + +} diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/invoice-results.component.html b/Open-ILS/src/eg2/src/app/staff/acq/search/invoice-results.component.html new file mode 100644 index 0000000000..18d8caf70d --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/search/invoice-results.component.html @@ -0,0 +1,51 @@ + + + + + {{invoice.inv_ident()}} + + + + + {{invoice.provider().code()}} + + + + + {{invoice.shipper().code()}} + + + + + + + + + + + + + + + + + + + + + diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/invoice-results.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/search/invoice-results.component.ts new file mode 100644 index 0000000000..e187963710 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/search/invoice-results.component.ts @@ -0,0 +1,106 @@ +import {Component, OnInit, Input, ViewChild} from '@angular/core'; +import {Observable} from 'rxjs'; +import {map} from 'rxjs/operators'; +import {Router, ActivatedRoute, ParamMap} from '@angular/router'; +import {Pager} from '@eg/share/util/pager'; +import {IdlObject} from '@eg/core/idl.service'; +import {EventService} from '@eg/core/event.service'; +import {AlertDialogComponent} from '@eg/share/dialog/alert.component'; +import {PrintService} from '@eg/share/print/print.service'; +import {NetService} from '@eg/core/net.service'; +import {AuthService} from '@eg/core/auth.service'; +import {GridComponent} from '@eg/share/grid/grid.component'; +import {GridDataSource, GridCellTextGenerator} from '@eg/share/grid/grid'; +import {AcqSearchService, AcqSearchTerm, AcqSearch} from './acq-search.service'; +import {AcqSearchFormComponent} from './acq-search-form.component'; + +@Component({ + selector: 'eg-invoice-results', + templateUrl: 'invoice-results.component.html', + providers: [AcqSearchService] +}) +export class InvoiceResultsComponent implements OnInit { + + @Input() initialSearchTerms: AcqSearchTerm[] = []; + + gridSource: GridDataSource; + @ViewChild('acqSearchForm', { static: true}) acqSearchForm: AcqSearchFormComponent; + @ViewChild('acqSearchInvoicesGrid', { static: true }) invoiceResultsGrid: GridComponent; + @ViewChild('printfail', { static: true }) private printfail: AlertDialogComponent; + + noSelectedRows: (rows: IdlObject[]) => boolean; + + cellTextGenerator: GridCellTextGenerator; + + fallbackSearchTerms: AcqSearchTerm[] = [{ + field: 'acqinv:receiver', + op: '', + value1: this.auth.user() ? this.auth.user().ws_ou() : '', + value2: '' + }, { + field: 'acqinv:close_date', + op: '__isnull', + value1: null, + value2: '' + }]; + + constructor( + private router: Router, + private route: ActivatedRoute, + private printer: PrintService, + private evt: EventService, + private net: NetService, + private auth: AuthService, + private acqSearch: AcqSearchService) { + } + + ngOnInit() { + this.gridSource = this.acqSearch.getAcqSearchDataSource('invoice'); + this.noSelectedRows = (rows: IdlObject[]) => (rows.length === 0); + this.cellTextGenerator = { + inv_ident: row => row.inv_ident(), + provider: row => row.provider().code(), + shipper: row => row.shipper().code(), + }; + } + + printSelectedInvoices(rows: IdlObject[]) { + const that = this; + let html = '\n'; + this.net.request( + 'open-ils.acq', + 'open-ils.acq.invoice.print.html', + this.auth.token(), rows.map( invoice => invoice.id() ) + ).subscribe( + (res) => { + if (this.evt.parse(res)) { + console.error(res); + this.printfail.open(); + } else { + html += res.template_output().data(); + } + }, + (err) => { + console.error(err); + this.printfail.open(); + }, + () => this.printer.print({ + text: html, + printContext: 'default' + }) + ); + } + + showRow(row: any) { + window.open('/eg/staff/acq/legacy/invoice/view/' + row.id(), '_blank'); + } + + doSearch(search: AcqSearch) { + setTimeout(() => { + this.acqSearch.setSearch(search); + this.invoiceResultsGrid.reload(); + }); + } +} diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/lineitem-results.component.html b/Open-ILS/src/eg2/src/app/staff/acq/search/lineitem-results.component.html new file mode 100644 index 0000000000..2da1a97ff8 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/search/lineitem-results.component.html @@ -0,0 +1,91 @@ + + + + + {{lineitem.id()}} + + + {{lineitem.id()}} + + + + + + {{lineitem.purchase_order().name()}} + + + + + + {{lineitem.picklist().name()}} + + + + + + + {{lia.attr_value()}} + + + + + + + {{lineitem.provider().code()}} + + + + + + + + + + + + + + + + + + + + + diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/lineitem-results.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/search/lineitem-results.component.ts new file mode 100644 index 0000000000..044b3e23e2 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/search/lineitem-results.component.ts @@ -0,0 +1,74 @@ +import {Component, OnInit, Input, ViewChild} from '@angular/core'; +import {Observable} from 'rxjs'; +import {map} from 'rxjs/operators'; +import {Router, ActivatedRoute, ParamMap} from '@angular/router'; +import {Pager} from '@eg/share/util/pager'; +import {IdlObject} from '@eg/core/idl.service'; +import {NetService} from '@eg/core/net.service'; +import {AuthService} from '@eg/core/auth.service'; +import {GridComponent} from '@eg/share/grid/grid.component'; +import {GridDataSource, GridCellTextGenerator} from '@eg/share/grid/grid'; +import {AcqSearchService, AcqSearchTerm, AcqSearch} from './acq-search.service'; +import {AcqSearchFormComponent} from './acq-search-form.component'; + +@Component({ + selector: 'eg-lineitem-results', + templateUrl: 'lineitem-results.component.html', + providers: [AcqSearchService] +}) +export class LineitemResultsComponent implements OnInit { + + @Input() initialSearchTerms: AcqSearchTerm[] = []; + + gridSource: GridDataSource; + @ViewChild('acqSearchForm', { static: true}) acqSearchForm: AcqSearchFormComponent; + @ViewChild('acqSearchLineitemsGrid', { static: true }) lineitemResultsGrid: GridComponent; + + cellTextGenerator: GridCellTextGenerator; + + constructor( + private router: Router, + private route: ActivatedRoute, + private net: NetService, + private auth: AuthService, + private acqSearch: AcqSearchService) { + } + + ngOnInit() { + this.gridSource = this.acqSearch.getAcqSearchDataSource('lineitem'); + this.cellTextGenerator = { + id: row => row.id(), + title: row => { + const filtered = row.attributes().filter(lia => lia.attr_name() === 'title'); + if (filtered.length > 0) { + return filtered[0].attr_value(); + } else { + return ''; + } + }, + author: row => { + const filtered = row.attributes().filter(lia => lia.attr_name() === 'author'); + if (filtered.length > 0) { + return filtered[0].attr_value(); + } else { + return ''; + } + }, + provider: row => row.provider() ? row.provider().code() : '', + _links: row => '', + purchase_order: row => row.purchase_order() ? row.purchase_order().name() : '', + picklist: row => row.picklist() ? row.picklist().name() : '', + }; + } + + doSearch(search: AcqSearch) { + setTimeout(() => { + this.acqSearch.setSearch(search); + this.lineitemResultsGrid.reload(); + }); + } + + showRow(row: any) { + window.open('/eg/staff/acq/legacy/lineitem/worksheet/' + row.id(), '_blank'); + } +} diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-clone-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-clone-dialog.component.html new file mode 100644 index 0000000000..662aaca27b --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-clone-dialog.component.html @@ -0,0 +1,27 @@ + +
+ + + +
+
+ + diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-clone-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-clone-dialog.component.ts new file mode 100644 index 0000000000..aceca1e309 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-clone-dialog.component.ts @@ -0,0 +1,77 @@ +import {Component, Input, ViewChild, TemplateRef, OnInit, Renderer2} from '@angular/core'; +import {Observable, from, empty, throwError} from 'rxjs'; +import {DialogComponent} from '@eg/share/dialog/dialog.component'; +import {AlertDialogComponent} from '@eg/share/dialog/alert.component'; +import {IdlService, IdlObject} from '@eg/core/idl.service'; +import {EventService} from '@eg/core/event.service'; +import {NetService} from '@eg/core/net.service'; +import {AuthService} from '@eg/core/auth.service'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {ComboboxEntry} from '@eg/share/combobox/combobox.component'; + +@Component({ + selector: 'eg-picklist-clone-dialog', + templateUrl: './picklist-clone-dialog.component.html' +}) + +export class PicklistCloneDialogComponent + extends DialogComponent implements OnInit { + + @Input() grid: any; + selectionListName: String; + leadListName: String; + selections: IdlObject[]; + + @ViewChild('fail', { static: true }) private fail: AlertDialogComponent; + + constructor( + private renderer: Renderer2, + private idl: IdlService, + private evt: EventService, + private net: NetService, + private auth: AuthService, + private modal: NgbModal + ) { + super(modal); + } + + ngOnInit() { + } + + update() { + this.leadListName = this.grid.context.getSelectedRows()[0].name(); + this.renderer.selectRootElement('#create-picklist-name').focus(); + this.selectionListName = 'Copy of ' + this.leadListName; + } + + cloneList() { + const picklist = this.idl.create('acqpl'); + picklist.owner(this.auth.user().id()); + picklist.name(this.selectionListName); + this.net.request( + 'open-ils.acq', + 'open-ils.acq.picklist.clone', + this.auth.token(), + this.grid.context.getSelectedRows()[0].id(), + this.selectionListName + ).subscribe( + (res) => { + if (this.evt.parse(res)) { + console.error(res); + this.fail.open(); + this.close(false); + } else { + console.log(res); + } + }, + (err) => { + console.error(err); + this.fail.open(); + this.close(false); + }, + () => this.close(true) + ); + } +} + + diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-create-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-create-dialog.component.html new file mode 100644 index 0000000000..0f36ab7ca6 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-create-dialog.component.html @@ -0,0 +1,30 @@ + +
+ + + +
+
+ + + + diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-create-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-create-dialog.component.ts new file mode 100644 index 0000000000..f4f81b3585 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-create-dialog.component.ts @@ -0,0 +1,79 @@ +import {Component, Input, ViewChild, TemplateRef, OnInit, Renderer2} from '@angular/core'; +import {Observable, from, empty, throwError} from 'rxjs'; +import {DialogComponent} from '@eg/share/dialog/dialog.component'; +import {AlertDialogComponent} from '@eg/share/dialog/alert.component'; +import {IdlService, IdlObject} from '@eg/core/idl.service'; +import {EventService} from '@eg/core/event.service'; +import {NetService} from '@eg/core/net.service'; +import {AuthService} from '@eg/core/auth.service'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {ComboboxEntry} from '@eg/share/combobox/combobox.component'; + +@Component({ + selector: 'eg-picklist-create-dialog', + templateUrl: './picklist-create-dialog.component.html' +}) + +export class PicklistCreateDialogComponent + extends DialogComponent implements OnInit { + + selectionListName: String; + + @ViewChild('fail', { static: true }) private fail: AlertDialogComponent; + @ViewChild('dupe', { static: true }) private dupe: AlertDialogComponent; + + constructor( + private renderer: Renderer2, + private idl: IdlService, + private evt: EventService, + private net: NetService, + private auth: AuthService, + private modal: NgbModal + ) { + super(modal); + } + + ngOnInit() { + this.selectionListName = ''; + } + + update() { + this.selectionListName = ''; + this.renderer.selectRootElement('#create-picklist-name').focus(); + } + + createList() { + const picklist = this.idl.create('acqpl'); + picklist.owner(this.auth.user().id()); + picklist.name(this.selectionListName); + this.net.request( + 'open-ils.acq', + 'open-ils.acq.picklist.create', + this.auth.token(), picklist + ).subscribe( + (res) => { + if (this.evt.parse(res)) { + console.error(res); + if (res.textcode === 'DATABASE_UPDATE_FAILED') { + // a duplicate name is not the only reason it could have failed, + // but that's the way to bet + this.dupe.open(); + } else { + this.fail.open(); + } + this.close(false); + } else { + console.log(res); + } + }, + (err) => { + console.error(err); + this.fail.open(); + this.close(false); + }, + () => this.close(true) + ); + } +} + + diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-delete-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-delete-dialog.component.html new file mode 100644 index 0000000000..bd30a9d383 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-delete-dialog.component.html @@ -0,0 +1,24 @@ + + + + + + + diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-delete-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-delete-dialog.component.ts new file mode 100644 index 0000000000..f7cfd49349 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-delete-dialog.component.ts @@ -0,0 +1,77 @@ +import {Component, Input, ViewChild, TemplateRef, OnInit} from '@angular/core'; +import {Observable, forkJoin, from, empty, throwError} from 'rxjs'; +import {DialogComponent} from '@eg/share/dialog/dialog.component'; +import {AlertDialogComponent} from '@eg/share/dialog/alert.component'; +import {IdlService, IdlObject} from '@eg/core/idl.service'; +import {EventService} from '@eg/core/event.service'; +import {NetService} from '@eg/core/net.service'; +import {AuthService} from '@eg/core/auth.service'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {ComboboxEntry} from '@eg/share/combobox/combobox.component'; + +@Component({ + selector: 'eg-picklist-delete-dialog', + templateUrl: './picklist-delete-dialog.component.html' +}) + +export class PicklistDeleteDialogComponent + extends DialogComponent implements OnInit { + + @Input() grid: any; + listNames: string[]; + + @ViewChild('fail', { static: true }) private fail: AlertDialogComponent; + + constructor( + private idl: IdlService, + private evt: EventService, + private net: NetService, + private auth: AuthService, + private modal: NgbModal + ) { + super(modal); + } + + ngOnInit() { + } + + update() { + this.listNames = this.grid.context.getSelectedRows().map( r => r.name() ); + } + + deleteList(list) { + return this.net.request( + 'open-ils.acq', + 'open-ils.acq.picklist.delete', + this.auth.token(), + list.id() + ); + } + + deleteLists() { + const that = this; + const observables = []; + this.grid.context.getSelectedRows().forEach(function(r) { + observables.push( that.deleteList(r) ); + }); + forkJoin(observables).subscribe( + (res) => { + if (this.evt.parse(res)) { + console.error(res); + this.fail.open(); + this.close(false); + } else { + console.log(res); + } + }, + (err) => { + console.error(err); + this.fail.open(); + this.close(false); + }, + () => this.close(true) + ); + } +} + + diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-merge-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-merge-dialog.component.html new file mode 100644 index 0000000000..6cdf54e72f --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-merge-dialog.component.html @@ -0,0 +1,32 @@ + +
+ + + +
+
+ + diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-merge-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-merge-dialog.component.ts new file mode 100644 index 0000000000..60ed6d6060 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-merge-dialog.component.ts @@ -0,0 +1,73 @@ +import {Component, Input, ViewChild, TemplateRef, OnInit} from '@angular/core'; +import {Observable, forkJoin, from, empty, throwError} from 'rxjs'; +import {DialogComponent} from '@eg/share/dialog/dialog.component'; +import {AlertDialogComponent} from '@eg/share/dialog/alert.component'; +import {IdlService, IdlObject} from '@eg/core/idl.service'; +import {EventService} from '@eg/core/event.service'; +import {NetService} from '@eg/core/net.service'; +import {AuthService} from '@eg/core/auth.service'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {ComboboxEntry} from '@eg/share/combobox/combobox.component'; + +@Component({ + selector: 'eg-picklist-merge-dialog', + templateUrl: './picklist-merge-dialog.component.html' +}) + +export class PicklistMergeDialogComponent + extends DialogComponent implements OnInit { + + @Input() grid: any; + listNames: string[]; + leadList: number; + selectedLists: IdlObject[]; + + @ViewChild('fail', { static: true }) private fail: AlertDialogComponent; + + constructor( + private idl: IdlService, + private evt: EventService, + private net: NetService, + private auth: AuthService, + private modal: NgbModal + ) { + super(modal); + } + + ngOnInit() { + } + + update() { + this.selectedLists = this.grid.context.getSelectedRows(); + this.listNames = this.selectedLists.map( r => r.name() ); + } + + mergeLists() { + const that = this; + this.net.request( + 'open-ils.acq', + 'open-ils.acq.picklist.merge', + this.auth.token(), this.leadList, + this.selectedLists.map( list => list.id() ).filter(function(p) { return Number(p) !== Number(that.leadList); }) + ).subscribe( + (res) => { + if (this.evt.parse(res)) { + console.error(res); + this.fail.open(); + this.close(false); + } else { + console.log(res); + } + }, + (err) => { + console.error(err); + this.fail.open(); + this.close(false); + }, + () => this.close(true) + ); + } + +} + + diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-results.component.html b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-results.component.html new file mode 100644 index 0000000000..9bb5d72e09 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-results.component.html @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + {{selectionlist.name()}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-results.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-results.component.ts new file mode 100644 index 0000000000..ba5cdb0a08 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/search/picklist-results.component.ts @@ -0,0 +1,143 @@ +import {Component, OnInit, Input, ViewChild} from '@angular/core'; +import {Observable} from 'rxjs'; +import {map} from 'rxjs/operators'; +import {Router, ActivatedRoute, ParamMap} from '@angular/router'; +import {Pager} from '@eg/share/util/pager'; +import {ToastService} from '@eg/share/toast/toast.service'; +import {StringComponent} from '@eg/share/string/string.component'; +import {IdlObject} from '@eg/core/idl.service'; +import {NetService} from '@eg/core/net.service'; +import {AuthService} from '@eg/core/auth.service'; +import {PermService} from '@eg/core/perm.service'; +import {GridComponent} from '@eg/share/grid/grid.component'; +import {GridDataSource, GridCellTextGenerator} from '@eg/share/grid/grid'; +import {AcqSearchService, AcqSearchTerm, AcqSearch} from './acq-search.service'; +import {PicklistCreateDialogComponent} from './picklist-create-dialog.component'; +import {PicklistCloneDialogComponent} from './picklist-clone-dialog.component'; +import {PicklistDeleteDialogComponent} from './picklist-delete-dialog.component'; +import {PicklistMergeDialogComponent} from './picklist-merge-dialog.component'; +import {AcqSearchFormComponent} from './acq-search-form.component'; + +@Component({ + selector: 'eg-picklist-results', + templateUrl: 'picklist-results.component.html', + providers: [AcqSearchService] +}) +export class PicklistResultsComponent implements OnInit { + + @Input() initialSearchTerms: AcqSearchTerm[] = []; + + gridSource: GridDataSource; + @ViewChild('acqSearchForm', { static: true}) acqSearchForm: AcqSearchFormComponent; + @ViewChild('acqSearchPicklistsGrid', { static: true }) picklistResultsGrid: GridComponent; + @ViewChild('picklistCreateDialog', { static: true }) picklistCreateDialog: PicklistCreateDialogComponent; + @ViewChild('picklistCloneDialog', { static: true }) picklistCloneDialog: PicklistCloneDialogComponent; + @ViewChild('picklistDeleteDialog', { static: true }) picklistDeleteDialog: PicklistDeleteDialogComponent; + @ViewChild('picklistMergeDialog', { static: true }) picklistMergeDialog: PicklistMergeDialogComponent; + @ViewChild('createSelectionListString', { static: true }) createSelectionListString: StringComponent; + @ViewChild('cloneSelectionListString', { static: true }) cloneSelectionListString: StringComponent; + @ViewChild('deleteSelectionListString', { static: true }) deleteSelectionListString: StringComponent; + @ViewChild('mergeSelectionListString', { static: true }) mergeSelectionListString: StringComponent; + + permissions: {[name: string]: boolean}; + noSelectedRows: (rows: IdlObject[]) => boolean; + oneSelectedRows: (rows: IdlObject[]) => boolean; + createNotAppropriate: (rows: IdlObject[]) => boolean; + cloneNotAppropriate: (rows: IdlObject[]) => boolean; + mergeNotAppropriate: (rows: IdlObject[]) => boolean; + deleteNotAppropriate: (rows: IdlObject[]) => boolean; + + cellTextGenerator: GridCellTextGenerator; + + fallbackSearchTerms: AcqSearchTerm[] = [{ + field: 'acqpl:owner', + op: '', + value1: this.auth.user() ? this.auth.user().id() : '', + value2: '' + }]; + + constructor( + private router: Router, + private route: ActivatedRoute, + private toast: ToastService, + private net: NetService, + private auth: AuthService, + private acqSearch: AcqSearchService, + private perm: PermService + ) { + this.permissions = {}; + } + + ngOnInit() { + this.gridSource = this.acqSearch.getAcqSearchDataSource('picklist'); + + this.perm.hasWorkPermHere(['CREATE_PICKLIST', 'UPDATE_PICKLIST', 'VIEW_PICKLIST']). + then(perms => this.permissions = perms); + + this.noSelectedRows = (rows: IdlObject[]) => (rows.length === 0); + this.oneSelectedRows = (rows: IdlObject[]) => (rows.length === 1); + this.createNotAppropriate = (rows: IdlObject[]) => (!this.permissions.CREATE_PICKLIST); + this.cloneNotAppropriate = (rows: IdlObject[]) => (!this.permissions.CREATE_PICKLIST || !this.oneSelectedRows(rows)); + this.mergeNotAppropriate = (rows: IdlObject[]) => (!this.permissions.UPDATE_PICKLIST || this.noSelectedRows(rows)); + this.deleteNotAppropriate = (rows: IdlObject[]) => (!this.permissions.UPDATE_PICKLIST || this.noSelectedRows(rows)); + + this.cellTextGenerator = { + name: row => row.name(), + }; + } + + openCreateDialog() { + this.picklistCreateDialog.open().subscribe( + modified => { + if (!modified) { return; } + this.createSelectionListString.current().then(msg => this.toast.success(msg)); + this.picklistResultsGrid.reload(); // FIXME - spec calls for inserted grid row and not refresh + } + ); + this.picklistCreateDialog.update(); // clear and focus the textbox + } + + openCloneDialog(rows: IdlObject[]) { + this.picklistCloneDialog.open().subscribe( + modified => { + if (!modified) { return; } + this.cloneSelectionListString.current().then(msg => this.toast.success(msg)); + this.picklistResultsGrid.reload(); // FIXME - spec calls for inserted grid row and not refresh + } + ); + this.picklistCloneDialog.update(); // update the dialog UI with selections + } + + openDeleteDialog(rows: IdlObject[]) { + this.picklistDeleteDialog.open().subscribe( + modified => { + if (!modified) { return; } + this.deleteSelectionListString.current().then(msg => this.toast.success(msg)); + this.picklistResultsGrid.reload(); // FIXME - spec calls for removed grid rows and not refresh + } + ); + this.picklistDeleteDialog.update(); // update the dialog UI with selections + } + + openMergeDialog(rows: IdlObject[]) { + this.picklistMergeDialog.open().subscribe( + modified => { + if (!modified) { return; } + this.mergeSelectionListString.current().then(msg => this.toast.success(msg)); + this.picklistResultsGrid.reload(); // FIXME - spec calls for removed grid rows and not refresh + } + ); + this.picklistMergeDialog.update(); // update the dialog UI with selections + } + + showRow(row: any) { + window.open('/eg/staff/acq/legacy/picklist/view/' + row.id(), '_blank'); + } + + doSearch(search: AcqSearch) { + setTimeout(() => { + this.acqSearch.setSearch(search); + this.picklistResultsGrid.reload(); + }); + } +} diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/purchase-order-results.component.html b/Open-ILS/src/eg2/src/app/staff/acq/search/purchase-order-results.component.html new file mode 100644 index 0000000000..5c3c28bde3 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/search/purchase-order-results.component.html @@ -0,0 +1,44 @@ + + + + + {{purchaseorder.name()}} + + + + + + {{purchaseorder.provider().code()}} + + + + + + + + + + + + + + + + + + + + + diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/purchase-order-results.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/search/purchase-order-results.component.ts new file mode 100644 index 0000000000..81e7db51d5 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/search/purchase-order-results.component.ts @@ -0,0 +1,68 @@ +import {Component, OnInit, Input, ViewChild} from '@angular/core'; +import {Observable} from 'rxjs'; +import {map} from 'rxjs/operators'; +import {Router, ActivatedRoute, ParamMap} from '@angular/router'; +import {Pager} from '@eg/share/util/pager'; +import {IdlObject} from '@eg/core/idl.service'; +import {NetService} from '@eg/core/net.service'; +import {AuthService} from '@eg/core/auth.service'; +import {GridComponent} from '@eg/share/grid/grid.component'; +import {GridDataSource, GridCellTextGenerator} from '@eg/share/grid/grid'; +import {AcqSearchService, AcqSearchTerm, AcqSearch} from './acq-search.service'; +import {AcqSearchFormComponent} from './acq-search-form.component'; + +@Component({ + selector: 'eg-purchase-order-results', + templateUrl: 'purchase-order-results.component.html', + providers: [AcqSearchService] +}) +export class PurchaseOrderResultsComponent implements OnInit { + + @Input() initialSearchTerms: AcqSearchTerm[] = []; + + gridSource: GridDataSource; + @ViewChild('acqSearchForm', { static: true}) acqSearchForm: AcqSearchFormComponent; + @ViewChild('acqSearchPurchaseOrdersGrid', { static: true }) purchaseOrderResultsGrid: GridComponent; + + cellTextGenerator: GridCellTextGenerator; + + fallbackSearchTerms: AcqSearchTerm[] = [{ + field: 'acqpo:ordering_agency', + op: '', + value1: this.auth.user() ? this.auth.user().ws_ou() : '', + value2: '' + }, { + field: 'acqpo:state', + op: '', + value1: 'on-order', + value2: '' + }]; + + constructor( + private router: Router, + private route: ActivatedRoute, + private net: NetService, + private auth: AuthService, + private acqSearch: AcqSearchService) { + } + + ngOnInit() { + this.gridSource = this.acqSearch.getAcqSearchDataSource('purchase_order'); + + this.cellTextGenerator = { + provider: row => row.provider().code(), + name: row => row.name(), + }; + } + + showRow(row: any) { + window.open('/eg/staff/acq/legacy/po/view/' + row.id(), '_blank'); + } + + doSearch(search: AcqSearch) { + setTimeout(() => { + this.acqSearch.setSearch(search); + this.purchaseOrderResultsGrid.reload(); + }); + } +} diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/resolver.service.ts b/Open-ILS/src/eg2/src/app/staff/acq/search/resolver.service.ts new file mode 100644 index 0000000000..d155e52e18 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/search/resolver.service.ts @@ -0,0 +1,25 @@ +import {Injectable} from '@angular/core'; +import {Router, Resolve, RouterStateSnapshot, + ActivatedRouteSnapshot} from '@angular/router'; +import {AttrDefsService} from './attr-defs.service'; + +@Injectable() +export class AttrDefsResolver implements Resolve> { + + savedId: number = null; + + constructor( + private router: Router, + private attrDefs: AttrDefsService, + ) {} + + resolve( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot): Promise { + + return Promise.all([ + this.attrDefs.fetchAttrDefs() + ]); + } + +} diff --git a/Open-ILS/src/eg2/src/app/staff/acq/search/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/acq/search/routing.module.ts new file mode 100644 index 0000000000..e05e58f3ce --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/search/routing.module.ts @@ -0,0 +1,26 @@ +import {NgModule} from '@angular/core'; +import {RouterModule, Routes} from '@angular/router'; +import {AcqSearchComponent} from './acq-search.component'; +import {AttrDefsResolver} from './resolver.service'; +import {AttrDefsService} from './attr-defs.service'; + +const routes: Routes = [ + { path: '', + component: AcqSearchComponent, + resolve: { attrDefsResolver : AttrDefsResolver }, + runGuardsAndResolvers: 'always' + }, + { path: ':searchtype', + component: AcqSearchComponent, + resolve: { attrDefsResolver : AttrDefsResolver }, + runGuardsAndResolvers: 'always' + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], + providers: [AttrDefsResolver, AttrDefsService] +}) + +export class AcqSearchRoutingModule {} 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 7e78ea48a2..372299acb0 100644 --- a/Open-ILS/src/eg2/src/app/staff/nav.component.html +++ b/Open-ILS/src/eg2/src/app/staff/nav.component.html @@ -237,15 +237,15 @@