]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/eg2/src/app/staff/acq/lineitem/lineitem-list.component.ts
LP#1357037: add sorting option to Angular line item lists
[Evergreen.git] / Open-ILS / src / eg2 / src / app / staff / acq / lineitem / lineitem-list.component.ts
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';
15
16 const DELETABLE_STATES = [
17     'new', 'selector-ready', 'order-ready', 'approved', 'pending-order'
18 ];
19
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'] },
34 };
35
36 @Component({
37   templateUrl: 'lineitem-list.component.html',
38   selector: 'eg-lineitem-list',
39   styleUrls: ['lineitem-list.component.css']
40 })
41 export class LineitemListComponent implements OnInit {
42
43     picklistId: number = null;
44     poId: number = null;
45     recordId: number = null; // lineitems related to a bib.
46
47     loading = false;
48     pager: Pager = new Pager();
49     pageOfLineitems: IdlObject[] = [];
50     lineitemIds: number[] = [];
51
52     // Selected lineitems
53     selected: {[id: number]: boolean} = {};
54
55     // Order identifier type per lineitem
56     orderIdentTypes: {[id: number]: 'isbn' | 'issn' | 'upc'} = {};
57
58     // Copy counts per lineitem
59     existingCopyCounts: {[id: number]: number} = {};
60
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[]}} = {};
64
65     // sorting and filtering
66     sortOrder = DEFAULT_SORT_ORDER;
67     showFilterSort = false;
68
69     batchNote: string;
70     noteIsPublic = false;
71     batchSelectPage = false;
72     batchSelectAll = false;
73     showNotesFor: number;
74     showExpandFor: number; // 'Expand'
75     expandAll = false;
76     action = '';
77     batchFailure: EgEvent;
78     focusLi: number;
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
82
83     @ViewChild('cancelDialog') cancelDialog: CancelDialogComponent;
84
85     constructor(
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
94     ) {}
95
96     ngOnInit() {
97
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) {
102                 this.load();
103             }
104         });
105
106         this.route.fragment.subscribe((fragment: string) => {
107             const id = Number(fragment);
108             if (id > 0) { this.focusLineitem(id); }
109         });
110
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) {
116                 this.load();
117             }
118         });
119
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;
125                 } else {
126                     this.sortOrder = DEFAULT_SORT_ORDER;
127                 }
128                 this.load();
129                 this.firstLoad = false;
130             });
131         });
132
133     }
134
135     pageSizeChange(count: number) {
136         this.store.setItem('acq.lineitem.page_size', count).then(_ => {
137             this.pager.setLimit(count);
138             this.pager.toFirst();
139             this.goToPage();
140         });
141     }
142
143     sortOrderChange(sortOrder: string) {
144         this.store.setItem('acq.lineitem.sort_order', sortOrder).then(_ => {
145             this.sortOrder = sortOrder;
146             if (this.pager.isFirstPage()) {
147                 this.load();
148             } else {
149                 this.pager.toFirst();
150                 this.goToPage();
151             }
152         });
153     }
154
155     // Focus the selected lineitem, which may not yet exist in the
156     // DOM for focusing.
157     focusLineitem(id?: number) {
158         if (id !== undefined) { this.focusLi = id; }
159         if (this.focusLi) {
160             const node = document.getElementById('' + this.focusLi);
161             if (node) { node.scrollIntoView(true); }
162         }
163     }
164
165     load(): Promise<any> {
166         this.pageOfLineitems = [];
167
168         if (!this.loading && this.pager.limit &&
169             (this.poId || this.picklistId || this.recordId)) {
170
171             this.loading = true;
172
173             return this.loadIds()
174                 .then(_ => this.loadPage())
175                 .then(_ => this.loading = false)
176                 .catch(_ => {}); // re-route while page is loading
177         }
178
179         // We have not collected enough data to proceed.
180         return Promise.resolve();
181
182     }
183
184     loadIds(): Promise<any> {
185         this.lineitemIds = [];
186
187         const searchTerms = {};
188         const opts = { id_list: true, limit: 1000 };
189
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 } ] });
194         } else {
195             Object.assign(searchTerms, { jub: [ { purchase_order: this.poId } ] });
196         }
197
198         if (!(this.sortOrder in SORT_ORDER_MAP)) {
199             this.sortOrder = DEFAULT_SORT_ORDER;
200         }
201         Object.assign(opts, SORT_ORDER_MAP[this.sortOrder]);
202
203         return this.net.request(
204             'open-ils.acq',
205             'open-ils.acq.lineitem.unified_search.atomic',
206             this.auth.token(),
207             searchTerms, // "and" terms
208             {},          // "or" terms
209             null,
210             opts
211         ).toPromise().then(resp => {
212             this.lineitemIds = resp.map(i => Number(i));
213             this.pager.resultCount = resp.length;
214         });
215     }
216
217     goToPage() {
218         this.focusLi = null;
219         this.router.navigate([], {
220             relativeTo: this.route,
221             queryParamsHandling: 'merge',
222             fragment: null,
223             queryParams: {
224                 offset: this.pager.offset,
225                 limit: this.pager.limit
226             }
227         });
228     }
229
230     loadPage(): Promise<any> {
231         return this.jumpToLiPage()
232             .then(_ => this.loadPageOfLis())
233             .then(_ => this.setBatchSelect())
234             .then(_ => setTimeout(() => this.focusLineitem()));
235     }
236
237     jumpToLiPage(): Promise<boolean> {
238         if (!this.focusLi) { return Promise.resolve(true); }
239
240         const idx = this.lineitemIds.indexOf(this.focusLi);
241         if (idx === -1) { return Promise.resolve(true); }
242
243         const offset = Math.floor(idx / this.pager.limit) * this.pager.limit;
244
245         return this.router.navigate(['./'], {
246             relativeTo: this.route,
247             queryParams: {offset: offset, limit: this.pager.limit},
248             fragment: '' + this.focusLi
249         });
250     }
251
252     loadPageOfLis(): Promise<any> {
253         this.pageOfLineitems = [];
254
255         const ids = this.lineitemIds.slice(
256             this.pager.offset, this.pager.offset + this.pager.limit)
257             .filter(id => id !== undefined);
258
259         if (ids.length === 0) { return Promise.resolve(); }
260
261         if (this.pageOfLineitems.length === ids.length) {
262             // All entries found in the cache
263             return Promise.resolve();
264         }
265
266         this.pageOfLineitems = []; // reset
267
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;
273         })).toPromise();
274     }
275
276     ingestOneLi(li: IdlObject, replace?: boolean) {
277         this.liMarcAttrs[li.id()] = {};
278
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');
284         });
285
286         const ident = this.liService.getOrderIdent(li);
287         this.orderIdentTypes[li.id()] = ident ? ident.attr_name() : 'isbn';
288
289         // newest to oldest
290         li.lineitem_notes(li.lineitem_notes().sort(
291             (n1, n2) => n1.create_time() < n2.create_time() ? 1 : -1));
292
293         if (replace) {
294             for (let idx = 0; idx < this.pageOfLineitems.length; idx++) {
295                 if (this.pageOfLineitems[idx].id() === li.id()) {
296                     this.pageOfLineitems[idx] = li;
297                     break;
298                 }
299             }
300         } else {
301             this.pageOfLineitems.push(li);
302         }
303
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()));
307     }
308
309     // First matching attr
310     displayAttr(li: IdlObject, name: string): string {
311         return (
312             this.liMarcAttrs[li.id()][name] &&
313             this.liMarcAttrs[li.id()][name][0]
314         ) ? this.liMarcAttrs[li.id()][name][0].attr_value() : '';
315     }
316
317     // All matching attrs
318     attrs(li: IdlObject, name: string, attrType?: string): IdlObject[] {
319         return this.liService.getAttributes(li, name, attrType);
320     }
321
322     jacketIdent(li: IdlObject): string {
323         return this.displayAttr(li, 'isbn') || this.displayAttr(li, 'upc');
324     }
325
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()];
330
331         if (this.liMarcAttrs[li.id()][otype]) {
332             return this.liMarcAttrs[li.id()][otype].map(
333                 attr => ({id: attr.id(), label: attr.attr_value()}));
334         }
335
336         return [];
337     }
338
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; }
344
345         const attr = this.identOptions(li).filter(
346             (entry: ComboboxEntry) => entry.label === ident.attr_value())[0];
347         return attr ? attr.id : null;
348     }
349
350     currentIdent(li: IdlObject): IdlObject {
351         return this.liService.getOrderIdent(li);
352     }
353
354     orderIdentChanged(li: IdlObject, entry: ComboboxEntry) {
355         if (entry === null) { return; }
356
357         this.liService.changeOrderIdent(
358             li, entry.id, this.orderIdentTypes[li.id()], entry.label
359         ).subscribe(freshLi => this.ingestOneLi(freshLi, true));
360     }
361
362     canEditIdent(li: IdlObject): boolean {
363         return DELETABLE_STATES.includes(li.state());
364     }
365
366     addBriefRecord() {
367     }
368
369     selectedIds(): number[] {
370         return Object.keys(this.selected)
371             .filter(id => this.selected[id] === true)
372             .map(id => Number(id));
373     }
374
375
376     // After a page of LI's are loaded, see if the batch-select checkbox
377     // needs to be on or off.
378     setBatchSelect() {
379         let on = true;
380         const ids = this.selectedIds();
381         this.pageOfLineitems.forEach(li => {
382             if (!ids.includes(li.id())) { on = false; }
383         });
384
385         this.batchSelectPage = on;
386
387         on = true;
388
389         this.lineitemIds.forEach(id => {
390             if (!this.selected[id]) { on = false; }
391         });
392
393         this.batchSelectAll = on;
394     }
395
396     toggleSelectAll(allItems: boolean) {
397
398         if (allItems) {
399             this.lineitemIds.forEach(
400                 id => this.selected[id] = this.batchSelectAll);
401
402             this.batchSelectPage = this.batchSelectAll;
403
404         } else {
405
406             this.pageOfLineitems.forEach(
407                 li => this.selected[li.id()] = this.batchSelectPage);
408
409             if (!this.batchSelectPage) {
410                 // When deselecting items in the page, we're no longer
411                 // selecting all items.
412                 this.batchSelectAll = false;
413             }
414         }
415     }
416
417     applyBatchNote() {
418         const ids = this.selectedIds();
419         if (ids.length === 0 || !this.batchNote) { return; }
420
421         this.liService.applyBatchNote(ids, this.batchNote, this.noteIsPublic)
422         .then(resp => this.load());
423     }
424
425     liPriceIsValid(li: IdlObject): boolean {
426         const price = li.estimated_unit_price();
427         if (price === null || price === undefined || price === '') {
428             return true;
429         }
430         return !Number.isNaN(Number(price)) && Number(price) >= 0;
431     }
432
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));
437
438             this.net.request(
439                 'open-ils.acq',
440                 'open-ils.acq.lineitem.update',
441                 this.auth.token(), li
442             ).subscribe(resp =>
443                 this.liService.activateStateChange.emit(li.id()));
444         }
445     }
446
447     toggleShowNotes(liId: number) {
448         this.showExpandFor = null;
449         this.showNotesFor = this.showNotesFor === liId ? null : liId;
450     }
451
452     toggleShowExpand(liId: number) {
453         this.showNotesFor = null;
454         this.showExpandFor = this.showExpandFor === liId ? null : liId;
455     }
456
457     toggleExpandAll() {
458         this.showNotesFor = null;
459         this.showExpandFor = null;
460         this.expandAll = !this.expandAll;
461     }
462
463     toggleFilterSort() {
464         this.showFilterSort = !this.showFilterSort;
465     }
466
467     liHasAlerts(li: IdlObject): boolean {
468         return li.lineitem_notes().filter(n => n.alert_text()).length > 0;
469     }
470
471     deleteLineitems() {
472         const ids = Object.keys(this.selected).filter(id => this.selected[id]);
473
474         const method = this.poId ?
475             'open-ils.acq.purchase_order.lineitem.delete' :
476             'open-ils.acq.picklist.lineitem.delete';
477
478         from(ids)
479         .pipe(concatMap(id =>
480             this.net.request('open-ils.acq', method, this.auth.token(), id)
481         ))
482         .pipe(concatMap(_ => from(this.load())))
483         .subscribe();
484     }
485
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()) {
489                 return true;
490             }
491         }
492         return false;
493     }
494
495     editHoldings(li: IdlObject) {
496
497         const copies = li.lineitem_details()
498             .filter(lid => lid.eg_copy_id()).map(lid => lid.eg_copy_id());
499
500         if (copies.length === 0) { return; }
501
502         this.holdings.spawnAddHoldingsUi(
503             li.eg_bib_id(),
504             copies.map(c => c.call_number()),
505             null,
506             copies.map(c => c.id())
507         );
508     }
509
510     receiveSelected() {
511         this.markReceived(this.selectedIds());
512     }
513
514     unReceiveSelected() {
515         this.markUnReceived(this.selectedIds());
516     }
517
518     cancelSelected() {
519         const liIds = this.selectedIds();
520         if (liIds.length === 0) { return; }
521
522         this.cancelDialog.open().subscribe(reason => {
523             if (!reason) { return; }
524
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));
529         });
530     }
531
532     markReceived(liIds: number[]) {
533         if (liIds.length === 0) { return; }
534
535         this.net.request(
536             'open-ils.acq',
537             'open-ils.acq.lineitem.receive.batch',
538             this.auth.token(), liIds
539         ).toPromise().then(resp => this.postBatchAction(resp, liIds));
540     }
541
542     markUnReceived(liIds: number[]) {
543         if (liIds.length === 0) { return; }
544
545         this.net.request(
546             'open-ils.acq',
547             'open-ils.acq.lineitem.receive.rollback.batch',
548             this.auth.token(), liIds
549         ).toPromise().then(resp => this.postBatchAction(resp, liIds));
550     }
551
552     postBatchAction(response: any, liIds: number[]) {
553         const evt = this.evt.parse(response);
554
555         if (evt) {
556             console.warn('Batch operation failed', evt);
557             this.batchFailure = evt;
558             return;
559         }
560
561         this.batchFailure = null;
562
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]);
566
567         this.loadPageOfLis();
568     }
569
570     createPo(fromAll?: boolean) {
571         this.router.navigate(['/staff/acq/po/create'], {
572             queryParams: {li: fromAll ? this.lineitemIds : this.selectedIds()}
573         });
574     }
575
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) || (
580             Boolean(li) &&
581             DELETABLE_STATES.includes(li.state()) &&
582             Boolean(this.poId)
583         );
584     }
585 }
586