0f9e4ad990d88972d9bcbb95dc20770f0102e366
[working/Evergreen.git] / Open-ILS / src / eg2 / src / app / staff / catalog / record / holdings.component.ts
1 import {Component, OnInit, Input, ViewChild} from '@angular/core';
2 import {Observable, Observer, of} from 'rxjs';
3 import {map} from 'rxjs/operators';
4 import {Pager} from '@eg/share/util/pager';
5 import {IdlObject} from '@eg/core/idl.service';
6 import {NetService} from '@eg/core/net.service';
7 import {StaffCatalogService} from '../catalog.service';
8 import {OrgService} from '@eg/core/org.service';
9 import {AuthService} from '@eg/core/auth.service';
10 import {PcrudService} from '@eg/core/pcrud.service';
11 import {GridDataSource} from '@eg/share/grid/grid';
12 import {GridComponent} from '@eg/share/grid/grid.component';
13 import {GridToolbarCheckboxComponent} from '@eg/share/grid/grid-toolbar-checkbox.component';
14 import {ServerStoreService} from '@eg/core/server-store.service';
15
16
17 // The holdings grid models a single HoldingsTree, composed of HoldingsTreeNodes
18 // flattened on-demand into a list of HoldingEntry objects.
19 class HoldingsTreeNode {
20     children: HoldingsTreeNode[];
21     nodeType: 'org' | 'volume' | 'copy';
22     target: any;
23     parentNode: HoldingsTreeNode;
24     expanded: boolean;
25     copyCount: number;
26     volumeCount: number;
27     constructor() {
28         this.children = [];
29     }
30 }
31
32 class HoldingsTree {
33     root: HoldingsTreeNode;
34     constructor() {
35         this.root = new HoldingsTreeNode();
36     }
37 }
38
39 class HoldingsEntry {
40     index: number;
41     // org unit shortname, call number label, or copy barcode
42     locationLabel: string;
43     // location label indentation depth
44     locationDepth: number | null;
45     volumeCount: number | null;
46     copyCount: number | null;
47     callNumberLabel: string;
48     copy: IdlObject;
49     volume: IdlObject;
50     circ: IdlObject;
51     treeNode: HoldingsTreeNode;
52 }
53
54 @Component({
55   selector: 'eg-holdings-maintenance',
56   templateUrl: 'holdings.component.html'
57 })
58 export class HoldingsMaintenanceComponent implements OnInit {
59
60     recId: number;
61     initDone = false;
62     gridDataSource: GridDataSource;
63     gridTemplateContext: any;
64     @ViewChild('holdingsGrid') holdingsGrid: GridComponent;
65
66     // Manage visibility of various sub-sections
67     @ViewChild('volsCheckbox') volsCheckbox: GridToolbarCheckboxComponent;
68     @ViewChild('copiesCheckbox') copiesCheckbox: GridToolbarCheckboxComponent;
69     @ViewChild('emptyVolsCheckbox') emptyVolsCheckbox: GridToolbarCheckboxComponent;
70     @ViewChild('emptyLibsCheckbox') emptyLibsCheckbox: GridToolbarCheckboxComponent;
71
72     contextOrg: IdlObject;
73     holdingsTree: HoldingsTree;
74     holdingsTreeOrgCache: {[id: number]: HoldingsTreeNode};
75     refreshHoldings: boolean;
76     gridIndex: number;
77
78     // List of copies whose due date we need to retrieve.
79     itemCircsNeeded: IdlObject[];
80
81     // When true draw the grid based on the stored preferences.
82     // When not true, render based on the current "expanded" state of each node.
83     // Rendering from prefs happens on initial load and when any prefs change.
84     renderFromPrefs: boolean;
85     rowClassCallback: (row: any) => string;
86
87     @Input() set recordId(id: number) {
88         this.recId = id;
89         // Only force new data collection when recordId()
90         // is invoked after ngInit() has already run.
91         if (this.initDone) {
92             this.refreshHoldings = true;
93             this.holdingsGrid.reload();
94         }
95     }
96
97     constructor(
98         private net: NetService,
99         private org: OrgService,
100         private auth: AuthService,
101         private pcrud: PcrudService,
102         private staffCat: StaffCatalogService,
103         private store: ServerStoreService
104     ) {
105         // Set some sane defaults before settings are loaded.
106         this.contextOrg = this.org.get(this.auth.user().ws_ou());
107         this.gridDataSource = new GridDataSource();
108         this.refreshHoldings = true;
109         this.renderFromPrefs = true;
110
111         this.rowClassCallback = (row: any): string => {
112              if (row.volume && !row.copy) {
113                 return 'bg-info';
114             }
115         }
116
117         this.gridTemplateContext = {
118             toggleExpandRow: (row: HoldingsEntry) => {
119                 row.treeNode.expanded = !row.treeNode.expanded;
120
121                 if (!row.treeNode.expanded) {
122                     // When collapsing a node, all child nodes should be
123                     // collapsed as well.
124                     const traverse = (node: HoldingsTreeNode) => {
125                         node.expanded = false;
126                         node.children.forEach(traverse);
127                     }
128                     traverse(row.treeNode);
129                 }
130
131                 this.holdingsGrid.reload();
132             },
133
134             copyIsHoldable: (copy: IdlObject): boolean => {
135                 return copy.holdable() === 't'
136                     && copy.location().holdable() === 't'
137                     && copy.status().holdable() === 't';
138             }
139         }
140     }
141
142     ngOnInit() {
143         this.initDone = true;
144
145         // These are pre-cached via the resolver.
146         const settings = this.store.getItemBatchCached([
147             'cat.holdings_show_empty_org',
148             'cat.holdings_show_empty',
149             'cat.holdings_show_copies',
150             'cat.holdings_show_vols'
151         ]);
152
153         this.volsCheckbox.checked(settings['cat.holdings_show_vols']);
154         this.copiesCheckbox.checked(settings['cat.holdings_show_copies']);
155         this.emptyVolsCheckbox.checked(settings['cat.holdings_show_empty']);
156         this.emptyLibsCheckbox.checked(settings['cat.holdings_show_empty_org']);
157
158         this.gridDataSource.getRows = (pager: Pager, sort: any[]) => {
159             return this.fetchHoldings(pager);
160         };
161     }
162
163     ngAfterViewInit() {
164
165     }
166
167     toggleShowCopies(value: boolean) {
168         this.store.setItem('cat.holdings_show_copies', value);
169         if (value) {
170             // Showing copies implies showing volumes
171             this.volsCheckbox.checked(true);
172         }
173         this.renderFromPrefs = true;
174         this.holdingsGrid.reload();
175     }
176
177     toggleShowVolumes(value: boolean) {
178         this.store.setItem('cat.holdings_show_vols', value);
179         if (!value) {
180             // Hiding volumes implies hiding empty vols and copies.
181             this.copiesCheckbox.checked(false);
182             this.emptyVolsCheckbox.checked(false);
183         }
184         this.renderFromPrefs = true;
185         this.holdingsGrid.reload();
186     }
187
188     toggleShowEmptyVolumes(value: boolean) {
189         this.store.setItem('cat.holdings_show_empty', value);
190         if (value) {
191             this.volsCheckbox.checked(true);
192         }
193         this.renderFromPrefs = true;
194         this.holdingsGrid.reload();
195     }
196
197     toggleShowEmptyLibs(value: boolean) {
198         this.store.setItem('cat.holdings_show_empty_org', value);
199         this.renderFromPrefs = true;
200         this.holdingsGrid.reload();
201     }
202
203     onRowActivate(row: any) {
204         if (row.copy) {
205             // Launch copy editor?
206         } else {
207             this.gridTemplateContext.toggleExpandRow(row);
208         }
209     }
210
211     initHoldingsTree() {
212
213         // The initial tree simply matches the org unit tree
214         const traverseOrg = (node: HoldingsTreeNode) => {
215             node.expanded = true;
216             node.target.children().forEach((org: IdlObject) => {
217                 const nodeChild = new HoldingsTreeNode();
218                 nodeChild.nodeType = 'org';
219                 nodeChild.target = org;
220                 nodeChild.parentNode = node;
221                 node.children.push(nodeChild);
222                 this.holdingsTreeOrgCache[org.id()] = nodeChild;
223                 traverseOrg(nodeChild);
224             });
225         }
226
227         this.holdingsTree = new HoldingsTree();
228         this.holdingsTree.root.nodeType = 'org';
229         this.holdingsTree.root.target = this.org.root();
230
231         this.holdingsTreeOrgCache = {};
232         this.holdingsTreeOrgCache[this.org.root().id()] = this.holdingsTree.root;
233
234         traverseOrg(this.holdingsTree.root);
235     }
236
237     // Org node children are sorted with any child org nodes pushed to the
238     // front, followed by the call number nodes sorted alphabetcially by label.
239     // TODO: prefix/suffix
240     sortOrgNodeChildren(node: HoldingsTreeNode) {
241         node.children = node.children.sort((a, b) => {
242             if (a.nodeType === 'org') {
243                 if (b.nodeType === 'org') {
244                     return a.target.shortname() < b.target.shortname() ? -1 : 1;
245                 } else {
246                     return -1;
247                 }
248             } else if (b.nodeType === 'org') {
249                 return 1;
250             } else {
251                 return a.target.label() < b.target.label() ? -1 : 1;
252             }
253         });
254     }
255
256     // Sets call number and copy count sums to nodes that need it.
257     // Applies the initial expansed state of each container node.
258     setTreeCounts(node: HoldingsTreeNode) {
259
260         if (node.nodeType === 'org') {
261             node.copyCount = 0;
262             node.volumeCount = 0;
263         } else if(node.nodeType === 'volume') {
264             node.copyCount = 0;
265         }
266
267         let hasChildOrgWithData = false;
268         let hasChildOrgSansData = false;
269         node.children.forEach(child => {
270             this.setTreeCounts(child);
271             if (node.nodeType === 'org') {
272                 node.copyCount += child.copyCount;
273                 if (child.nodeType === 'volume') {
274                     node.volumeCount++;
275                 } else {
276                     hasChildOrgWithData = child.volumeCount > 0;
277                     hasChildOrgSansData = child.volumeCount === 0;
278                     node.volumeCount += child.volumeCount;
279                 }
280             } else if (node.nodeType === 'volume') {
281                 node.copyCount = node.children.length;
282                 if (this.renderFromPrefs) {
283                     node.expanded = this.copiesCheckbox.checked();
284                 }
285             }
286         });
287
288         if (this.renderFromPrefs && node.nodeType === 'org') {
289             if (node.copyCount > 0 && this.volsCheckbox.checked()) {
290                 node.expanded = true;
291             } else if (node.volumeCount > 0 && this.emptyVolsCheckbox.checked()) {
292                 node.expanded = true;
293             } else if (hasChildOrgWithData) {
294                 node.expanded = true;
295             } else if (hasChildOrgSansData && this.emptyLibsCheckbox.checked()) {
296                 node.expanded = true;
297             } else {
298                 node.expanded = false;
299             }
300         }
301     }
302
303     // Create HoldingsEntry objects for tree nodes that should be displayed
304     // and relays them to the grid via the observer.
305     propagateTreeEntries(observer: Observer<HoldingsEntry>, node: HoldingsTreeNode) {
306         const entry = new HoldingsEntry();
307         entry.treeNode = node;
308         entry.index = this.gridIndex++;
309
310         switch(node.nodeType) {
311             case 'org':
312                 if (this.renderFromPrefs && node.volumeCount === 0
313                     && !this.emptyLibsCheckbox.checked()) {
314                     return;
315                 }
316                 entry.locationLabel = node.target.shortname();
317                 entry.locationDepth = node.target.ou_type().depth();
318                 entry.copyCount = node.copyCount;
319                 entry.volumeCount = node.volumeCount;
320                 this.sortOrgNodeChildren(node);
321                 break;
322
323             case 'volume':
324                 entry.locationLabel = node.target.label(); // TODO prefix/suffix
325                 entry.locationDepth = node.parentNode.target.ou_type().depth() + 1;
326                 entry.callNumberLabel = entry.locationLabel;
327                 entry.volume = node.target;
328                 entry.copyCount = node.copyCount;
329                 break;
330
331             case 'copy':
332                 entry.locationLabel = node.target.barcode();
333                 entry.locationDepth = node.parentNode.parentNode.target.ou_type().depth() + 2;
334                 entry.callNumberLabel = node.parentNode.target.label() // TODO
335                 entry.volume = node.parentNode.target;
336                 entry.copy = node.target;
337                 entry.circ = node.target._circ;
338                 break;
339         }
340
341         // Tell the grid about the node entry
342         observer.next(entry);
343
344         if (node.expanded) {
345             // Process the child nodes.
346             node.children.forEach(child =>
347                 this.propagateTreeEntries(observer, child));
348         }
349     }
350
351     // Turns the tree into a list of entries for grid display
352     flattenHoldingsTree(observer: Observer<HoldingsEntry>) {
353         this.gridIndex = 0;
354         this.setTreeCounts(this.holdingsTree.root);
355         this.propagateTreeEntries(observer, this.holdingsTree.root);
356         observer.complete();
357         this.renderFromPrefs = false;
358     }
359
360
361     fetchHoldings(pager: Pager): Observable<any> {
362         if (!this.recId) { return of([]); }
363
364         return new Observable<any>(observer => {
365
366             if (!this.refreshHoldings) {
367                 this.flattenHoldingsTree(observer);
368                 return;
369             }
370
371             this.initHoldingsTree();
372             this.itemCircsNeeded = [];
373
374             this.pcrud.search('acn',
375                 {   record: this.recId,
376                     owning_lib: this.org.ancestors(this.contextOrg, true),
377                     deleted: 'f',
378                     label: {'!=' : '##URI##'}
379                 }, {
380                     flesh: 3,
381                     flesh_fields: {
382                         acp: ['status', 'location', 'circ_lib', 'parts',
383                             'age_protect', 'copy_alerts', 'latest_inventory'],
384                         acn: ['prefix', 'suffix', 'copies'],
385                         acli: ['inventory_workstation']
386                     }
387                 }
388             ).subscribe(
389                 vol => this.appendVolume(vol),
390                 err => {},
391                 ()  => {
392                     this.refreshHoldings = false;
393                     this.fetchCircs().then(
394                         ok => this.flattenHoldingsTree(observer)
395                     );
396                 }
397             );
398         });
399     }
400
401     // Retrieve circulation objects for checked out items.
402     fetchCircs(): Promise<any> {
403         const copyIds = this.itemCircsNeeded.map(copy => copy.id());
404         if (copyIds.length === 0) { return Promise.resolve(); }
405
406         return this.pcrud.search('circ', {
407             target_copy: copyIds,
408             checkin_time: null
409         }).pipe(map(circ => {
410             const copy = this.itemCircsNeeded.filter(
411                 c => Number(c.id()) === Number(circ.target_copy()))[0];
412             copy._circ = circ;
413         })).toPromise();
414     }
415
416     appendVolume(volume: IdlObject) {
417
418         const volNode = new HoldingsTreeNode();
419         volNode.parentNode = this.holdingsTreeOrgCache[volume.owning_lib()];
420         volNode.parentNode.children.push(volNode);
421         volNode.nodeType = 'volume';
422         volNode.target = volume;
423
424         volume.copies()
425             .sort((a: IdlObject, b: IdlObject) => a.barcode() < b.barcode() ? -1 : 1)
426             .forEach((copy: IdlObject) => {
427                 const copyNode = new HoldingsTreeNode();
428                 copyNode.parentNode = volNode;
429                 volNode.children.push(copyNode);
430                 copyNode.nodeType = 'copy';
431                 copyNode.target = copy;
432                 const stat = Number(copy.status().id());
433                 if (stat === 1 /* checked out */ || stat === 16 /* long overdue */) {
434                     this.itemCircsNeeded.push(copy);
435                 }
436             });
437     }
438 }
439
440