1 import {Component, OnInit, Input, Output, ViewChild} from '@angular/core';
2 import {Router, ActivatedRoute, ParamMap} from '@angular/router';
3 import {Observable, from} from 'rxjs';
4 import {tap, concatMap} from 'rxjs/operators';
5 import {Pager} from '@eg/share/util/pager';
6 import {EgEvent, EventService} from '@eg/core/event.service';
7 import {IdlObject} from '@eg/core/idl.service';
8 import {NetService} from '@eg/core/net.service';
9 import {AuthService} from '@eg/core/auth.service';
10 import {ServerStoreService} from '@eg/core/server-store.service';
11 import {LineitemService} from './lineitem.service';
12 import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
13 import {HoldingsService} from '@eg/staff/share/holdings/holdings.service';
14 import {CancelDialogComponent} from './cancel-dialog.component';
16 const DELETABLE_STATES = [
17 'new', 'selector-ready', 'order-ready', 'approved', 'pending-order'
20 const DEFAULT_SORT_ORDER = 'li_id_asc';
21 const SORT_ORDER_MAP = {
22 li_id_asc: { 'order_by': [{'class': 'jub', 'field': 'id', 'direction': 'ASC'}] },
23 li_id_desc: { 'order_by': [{'class': 'jub', 'field': 'id', 'direction': 'DESC'}] },
24 title_asc: { 'order_by': [{'class': 'acqlia', 'field': 'attr_value', 'direction': 'ASC'}], 'order_by_attr': 'title' },
25 title_desc: { 'order_by': [{'class': 'acqlia', 'field': 'attr_value', 'direction': 'DESC'}], 'order_by_attr': 'title' },
26 author_asc: { 'order_by': [{'class': 'acqlia', 'field': 'attr_value', 'direction': 'ASC'}], 'order_by_attr': 'author' },
27 author_desc: { 'order_by': [{'class': 'acqlia', 'field': 'attr_value', 'direction': 'DESC'}], 'order_by_attr': 'author' },
28 publisher_asc: { 'order_by': [{'class': 'acqlia', 'field': 'attr_value', 'direction': 'ASC'}], 'order_by_attr': 'publisher' },
29 publisher_desc: { 'order_by': [{'class': 'acqlia', 'field': 'attr_value', 'direction': 'DESC'}], 'order_by_attr': 'publisher' },
30 order_ident_asc: { 'order_by': [{'class': 'acqlia', 'field': 'attr_value', 'direction': 'ASC'}],
31 'order_by_attr': ['isbn', 'issn', 'upc'] },
32 order_ident_desc: { 'order_by': [{'class': 'acqlia', 'field': 'attr_value', 'direction': 'DESC'}],
33 'order_by_attr': ['isbn', 'issn', 'upc'] },
37 templateUrl: 'lineitem-list.component.html',
38 selector: 'eg-lineitem-list',
39 styleUrls: ['lineitem-list.component.css']
41 export class LineitemListComponent implements OnInit {
43 picklistId: number = null;
45 recordId: number = null; // lineitems related to a bib.
48 pager: Pager = new Pager();
49 pageOfLineitems: IdlObject[] = [];
50 lineitemIds: number[] = [];
53 selected: {[id: number]: boolean} = {};
55 // Order identifier type per lineitem
56 orderIdentTypes: {[id: number]: 'isbn' | 'issn' | 'upc'} = {};
58 // Copy counts per lineitem
59 existingCopyCounts: {[id: number]: number} = {};
61 // Squash these down to an easily traversable data set to avoid
62 // a lot of repetitive looping.
63 liMarcAttrs: {[id: number]: {[name: string]: IdlObject[]}} = {};
65 // sorting and filtering
66 sortOrder = DEFAULT_SORT_ORDER;
67 showFilterSort = false;
71 batchSelectPage = false;
72 batchSelectAll = false;
74 showExpandFor: number; // 'Expand'
77 batchFailure: EgEvent;
79 firstLoad = true; // using this to ensure that we avoid loading the LI table
80 // until the page size and sort order WS settings have been fetched
81 // TODO: route guard might be better
83 @ViewChild('cancelDialog') cancelDialog: CancelDialogComponent;
86 private router: Router,
87 private route: ActivatedRoute,
88 private evt: EventService,
89 private net: NetService,
90 private auth: AuthService,
91 private store: ServerStoreService,
92 private holdings: HoldingsService,
93 private liService: LineitemService
98 this.route.queryParamMap.subscribe((params: ParamMap) => {
99 this.pager.offset = +params.get('offset');
100 this.pager.limit = +params.get('limit');
101 if (!this.firstLoad) {
106 this.route.fragment.subscribe((fragment: string) => {
107 const id = Number(fragment);
108 if (id > 0) { this.focusLineitem(id); }
111 this.route.parent.paramMap.subscribe((params: ParamMap) => {
112 this.picklistId = +params.get('picklistId');
113 this.poId = +params.get('poId');
114 this.recordId = +params.get('recordId');
115 if (!this.firstLoad) {
120 this.store.getItem('acq.lineitem.page_size').then(count => {
121 this.pager.setLimit(count || 20);
122 this.store.getItem('acq.lineitem.sort_order').then(sortOrder => {
123 if (sortOrder && (sortOrder in SORT_ORDER_MAP)) {
124 this.sortOrder = sortOrder;
126 this.sortOrder = DEFAULT_SORT_ORDER;
129 this.firstLoad = false;
135 pageSizeChange(count: number) {
136 this.store.setItem('acq.lineitem.page_size', count).then(_ => {
137 this.pager.setLimit(count);
138 this.pager.toFirst();
143 sortOrderChange(sortOrder: string) {
144 this.store.setItem('acq.lineitem.sort_order', sortOrder).then(_ => {
145 this.sortOrder = sortOrder;
146 if (this.pager.isFirstPage()) {
149 this.pager.toFirst();
155 // Focus the selected lineitem, which may not yet exist in the
157 focusLineitem(id?: number) {
158 if (id !== undefined) { this.focusLi = id; }
160 const node = document.getElementById('' + this.focusLi);
161 if (node) { node.scrollIntoView(true); }
165 load(): Promise<any> {
166 this.pageOfLineitems = [];
168 if (!this.loading && this.pager.limit &&
169 (this.poId || this.picklistId || this.recordId)) {
173 return this.loadIds()
174 .then(_ => this.loadPage())
175 .then(_ => this.loading = false)
176 .catch(_ => {}); // re-route while page is loading
179 // We have not collected enough data to proceed.
180 return Promise.resolve();
184 loadIds(): Promise<any> {
185 this.lineitemIds = [];
187 const searchTerms = {};
188 const opts = { id_list: true, limit: 1000 };
190 if (this.picklistId) {
191 Object.assign(searchTerms, { jub: [ { picklist: this.picklistId } ] });
192 } else if (this.recordId) {
193 Object.assign(searchTerms, { jub: [ { eg_bib_id: this.recordId } ] });
195 Object.assign(searchTerms, { jub: [ { purchase_order: this.poId } ] });
198 if (!(this.sortOrder in SORT_ORDER_MAP)) {
199 this.sortOrder = DEFAULT_SORT_ORDER;
201 Object.assign(opts, SORT_ORDER_MAP[this.sortOrder]);
203 return this.net.request(
205 'open-ils.acq.lineitem.unified_search.atomic',
207 searchTerms, // "and" terms
211 ).toPromise().then(resp => {
212 this.lineitemIds = resp.map(i => Number(i));
213 this.pager.resultCount = resp.length;
219 this.router.navigate([], {
220 relativeTo: this.route,
221 queryParamsHandling: 'merge',
224 offset: this.pager.offset,
225 limit: this.pager.limit
230 loadPage(): Promise<any> {
231 return this.jumpToLiPage()
232 .then(_ => this.loadPageOfLis())
233 .then(_ => this.setBatchSelect())
234 .then(_ => setTimeout(() => this.focusLineitem()));
237 jumpToLiPage(): Promise<boolean> {
238 if (!this.focusLi) { return Promise.resolve(true); }
240 const idx = this.lineitemIds.indexOf(this.focusLi);
241 if (idx === -1) { return Promise.resolve(true); }
243 const offset = Math.floor(idx / this.pager.limit) * this.pager.limit;
245 return this.router.navigate(['./'], {
246 relativeTo: this.route,
247 queryParams: {offset: offset, limit: this.pager.limit},
248 fragment: '' + this.focusLi
252 loadPageOfLis(): Promise<any> {
253 this.pageOfLineitems = [];
255 const ids = this.lineitemIds.slice(
256 this.pager.offset, this.pager.offset + this.pager.limit)
257 .filter(id => id !== undefined);
259 if (ids.length === 0) { return Promise.resolve(); }
261 if (this.pageOfLineitems.length === ids.length) {
262 // All entries found in the cache
263 return Promise.resolve();
266 this.pageOfLineitems = []; // reset
268 return this.liService.getFleshedLineitems(
269 ids, {fromCache: true, toCache: true})
270 .pipe(tap(struct => {
271 this.ingestOneLi(struct.lineitem);
272 this.existingCopyCounts[struct.id] = struct.existing_copies;
276 ingestOneLi(li: IdlObject, replace?: boolean) {
277 this.liMarcAttrs[li.id()] = {};
279 li.attributes().forEach(attr => {
280 const name = attr.attr_name();
281 this.liMarcAttrs[li.id()][name] =
282 this.liService.getAttributes(
283 li, name, 'lineitem_marc_attr_definition');
286 const ident = this.liService.getOrderIdent(li);
287 this.orderIdentTypes[li.id()] = ident ? ident.attr_name() : 'isbn';
290 li.lineitem_notes(li.lineitem_notes().sort(
291 (n1, n2) => n1.create_time() < n2.create_time() ? 1 : -1));
294 for (let idx = 0; idx < this.pageOfLineitems.length; idx++) {
295 if (this.pageOfLineitems[idx].id() === li.id()) {
296 this.pageOfLineitems[idx] = li;
301 this.pageOfLineitems.push(li);
304 // Remove any 'new' lineitem details which may have been added
305 // and left unsaved on the copies page
306 li.lineitem_details(li.lineitem_details().filter(d => !d.isnew()));
309 // First matching attr
310 displayAttr(li: IdlObject, name: string): string {
312 this.liMarcAttrs[li.id()][name] &&
313 this.liMarcAttrs[li.id()][name][0]
314 ) ? this.liMarcAttrs[li.id()][name][0].attr_value() : '';
317 // All matching attrs
318 attrs(li: IdlObject, name: string, attrType?: string): IdlObject[] {
319 return this.liService.getAttributes(li, name, attrType);
322 jacketIdent(li: IdlObject): string {
323 return this.displayAttr(li, 'isbn') || this.displayAttr(li, 'upc');
326 // Order ident options are pulled from the MARC, but the ident
327 // value proper is stored as a local attr def.
328 identOptions(li: IdlObject): ComboboxEntry[] {
329 const otype = this.orderIdentTypes[li.id()];
331 if (this.liMarcAttrs[li.id()][otype]) {
332 return this.liMarcAttrs[li.id()][otype].map(
333 attr => ({id: attr.id(), label: attr.attr_value()}));
339 // Returns the MARC attr with the same type and value as the applied
340 // order identifier (which is a local attr)
341 selectedIdent(li: IdlObject): number {
342 const ident = this.liService.getOrderIdent(li);
343 if (!ident) { return null; }
345 const attr = this.identOptions(li).filter(
346 (entry: ComboboxEntry) => entry.label === ident.attr_value())[0];
347 return attr ? attr.id : null;
350 currentIdent(li: IdlObject): IdlObject {
351 return this.liService.getOrderIdent(li);
354 orderIdentChanged(li: IdlObject, entry: ComboboxEntry) {
355 if (entry === null) { return; }
357 this.liService.changeOrderIdent(
358 li, entry.id, this.orderIdentTypes[li.id()], entry.label
359 ).subscribe(freshLi => this.ingestOneLi(freshLi, true));
362 canEditIdent(li: IdlObject): boolean {
363 return DELETABLE_STATES.includes(li.state());
369 selectedIds(): number[] {
370 return Object.keys(this.selected)
371 .filter(id => this.selected[id] === true)
372 .map(id => Number(id));
376 // After a page of LI's are loaded, see if the batch-select checkbox
377 // needs to be on or off.
380 const ids = this.selectedIds();
381 this.pageOfLineitems.forEach(li => {
382 if (!ids.includes(li.id())) { on = false; }
385 this.batchSelectPage = on;
389 this.lineitemIds.forEach(id => {
390 if (!this.selected[id]) { on = false; }
393 this.batchSelectAll = on;
396 toggleSelectAll(allItems: boolean) {
399 this.lineitemIds.forEach(
400 id => this.selected[id] = this.batchSelectAll);
402 this.batchSelectPage = this.batchSelectAll;
406 this.pageOfLineitems.forEach(
407 li => this.selected[li.id()] = this.batchSelectPage);
409 if (!this.batchSelectPage) {
410 // When deselecting items in the page, we're no longer
411 // selecting all items.
412 this.batchSelectAll = false;
418 const ids = this.selectedIds();
419 if (ids.length === 0 || !this.batchNote) { return; }
421 this.liService.applyBatchNote(ids, this.batchNote, this.noteIsPublic)
422 .then(resp => this.load());
425 liPriceIsValid(li: IdlObject): boolean {
426 const price = li.estimated_unit_price();
427 if (price === null || price === undefined || price === '') {
430 return !Number.isNaN(Number(price)) && Number(price) >= 0;
433 liPriceChange(li: IdlObject) {
434 const price = li.estimated_unit_price();
435 if (this.liPriceIsValid(li)) {
436 li.estimated_unit_price(Number(price).toFixed(2));
440 'open-ils.acq.lineitem.update',
441 this.auth.token(), li
443 this.liService.activateStateChange.emit(li.id()));
447 toggleShowNotes(liId: number) {
448 this.showExpandFor = null;
449 this.showNotesFor = this.showNotesFor === liId ? null : liId;
452 toggleShowExpand(liId: number) {
453 this.showNotesFor = null;
454 this.showExpandFor = this.showExpandFor === liId ? null : liId;
458 this.showNotesFor = null;
459 this.showExpandFor = null;
460 this.expandAll = !this.expandAll;
464 this.showFilterSort = !this.showFilterSort;
467 liHasAlerts(li: IdlObject): boolean {
468 return li.lineitem_notes().filter(n => n.alert_text()).length > 0;
472 const ids = Object.keys(this.selected).filter(id => this.selected[id]);
474 const method = this.poId ?
475 'open-ils.acq.purchase_order.lineitem.delete' :
476 'open-ils.acq.picklist.lineitem.delete';
479 .pipe(concatMap(id =>
480 this.net.request('open-ils.acq', method, this.auth.token(), id)
482 .pipe(concatMap(_ => from(this.load())))
486 liHasRealCopies(li: IdlObject): boolean {
487 for (let idx = 0; idx < li.lineitem_details().length; idx++) {
488 if (li.lineitem_details()[idx].eg_copy_id()) {
495 editHoldings(li: IdlObject) {
497 const copies = li.lineitem_details()
498 .filter(lid => lid.eg_copy_id()).map(lid => lid.eg_copy_id());
500 if (copies.length === 0) { return; }
502 this.holdings.spawnAddHoldingsUi(
504 copies.map(c => c.call_number()),
506 copies.map(c => c.id())
511 this.markReceived(this.selectedIds());
514 unReceiveSelected() {
515 this.markUnReceived(this.selectedIds());
519 const liIds = this.selectedIds();
520 if (liIds.length === 0) { return; }
522 this.cancelDialog.open().subscribe(reason => {
523 if (!reason) { return; }
525 this.net.request('open-ils.acq',
526 'open-ils.acq.lineitem.cancel.batch',
527 this.auth.token(), liIds, reason
528 ).toPromise().then(resp => this.postBatchAction(resp, liIds));
532 markReceived(liIds: number[]) {
533 if (liIds.length === 0) { return; }
537 'open-ils.acq.lineitem.receive.batch',
538 this.auth.token(), liIds
539 ).toPromise().then(resp => this.postBatchAction(resp, liIds));
542 markUnReceived(liIds: number[]) {
543 if (liIds.length === 0) { return; }
547 'open-ils.acq.lineitem.receive.rollback.batch',
548 this.auth.token(), liIds
549 ).toPromise().then(resp => this.postBatchAction(resp, liIds));
552 postBatchAction(response: any, liIds: number[]) {
553 const evt = this.evt.parse(response);
556 console.warn('Batch operation failed', evt);
557 this.batchFailure = evt;
561 this.batchFailure = null;
563 // Remove the modified LI's from the cache so we are
564 // forced to re-fetch them.
565 liIds.forEach(id => delete this.liService.liCache[id]);
567 this.loadPageOfLis();
570 createPo(fromAll?: boolean) {
571 this.router.navigate(['/staff/acq/po/create'], {
572 queryParams: {li: fromAll ? this.lineitemIds : this.selectedIds()}
576 // For PO's, lineitems can only be deleted if they are pending order.
577 canDeleteLis(): boolean {
578 const li = this.pageOfLineitems[0];
579 return Boolean(this.picklistId) || (
581 DELETABLE_STATES.includes(li.state()) &&