]> git.evergreen-ils.org Git - working/Evergreen.git/blob - Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.ts
LP2042879 Shelving Location Groups Admin accessibility
[working/Evergreen.git] / Open-ILS / src / eg2 / src / app / staff / catalog / record / holdings.component.ts
1 import {Component, OnInit, Input, ViewChild, ViewEncapsulation
2 } from '@angular/core';
3 import {Router} from '@angular/router';
4 import {Observable, Observer, of, EMPTY} from 'rxjs';
5 import {map, tap, concatMap} from 'rxjs/operators';
6 import {Pager} from '@eg/share/util/pager';
7 import {IdlObject, IdlService} from '@eg/core/idl.service';
8 import {StaffCatalogService} from '../catalog.service';
9 import {OrgService} from '@eg/core/org.service';
10 import {NetService} from '@eg/core/net.service';
11 import {PcrudService} from '@eg/core/pcrud.service';
12 import {AuthService} from '@eg/core/auth.service';
13 import {GridDataSource, GridCellTextGenerator} from '@eg/share/grid/grid';
14 import {GridComponent} from '@eg/share/grid/grid.component';
15 import {GridToolbarCheckboxComponent
16 } from '@eg/share/grid/grid-toolbar-checkbox.component';
17 import {StoreService} from '@eg/core/store.service';
18 import {ServerStoreService} from '@eg/core/server-store.service';
19 import {MarkDamagedDialogComponent
20 } from '@eg/staff/share/holdings/mark-damaged-dialog.component';
21 import {MarkMissingDialogComponent
22 } from '@eg/staff/share/holdings/mark-missing-dialog.component';
23 import {AnonCacheService} from '@eg/share/util/anon-cache.service';
24 import {HoldingsService} from '@eg/staff/share/holdings/holdings.service';
25 import {CopyAlertsDialogComponent
26 } from '@eg/staff/share/holdings/copy-alerts-dialog.component';
27 import {CopyTagsDialogComponent
28 } from '@eg/staff/share/holdings/copy-tags-dialog.component';
29 import {CopyNotesDialogComponent
30 } from '@eg/staff/share/holdings/copy-notes-dialog.component';
31 import {ReplaceBarcodeDialogComponent
32 } from '@eg/staff/share/holdings/replace-barcode-dialog.component';
33 import {DeleteHoldingDialogComponent
34 } from '@eg/staff/share/holdings/delete-volcopy-dialog.component';
35 import {BucketDialogComponent
36 } from '@eg/staff/share/buckets/bucket-dialog.component';
37 import {ConjoinedItemsDialogComponent
38 } from '@eg/staff/share/holdings/conjoined-items-dialog.component';
39 import {MakeBookableDialogComponent
40 } from '@eg/staff/share/booking/make-bookable-dialog.component';
41 import {TransferItemsComponent
42 } from '@eg/staff/share/holdings/transfer-items.component';
43 import {TransferHoldingsComponent
44 } from '@eg/staff/share/holdings/transfer-holdings.component';
45 import {AlertDialogComponent} from '@eg/share/dialog/alert.component';
46 import {BroadcastService} from '@eg/share/util/broadcast.service';
47
48
49 // The holdings grid models a single HoldingsTree, composed of HoldingsTreeNodes
50 // flattened on-demand into a list of HoldingEntry objects.
51 export class HoldingsTreeNode {
52     children: HoldingsTreeNode[];
53     nodeType: 'org' | 'callNum' | 'copy';
54     target: any;
55     parentNode: HoldingsTreeNode;
56     expanded: boolean;
57     copyCount: number;
58     callNumCount: number;
59     constructor() {
60         this.children = [];
61     }
62 }
63
64 class HoldingsTree {
65     root: HoldingsTreeNode;
66     constructor() {
67         this.root = new HoldingsTreeNode();
68     }
69 }
70
71 export class HoldingsEntry {
72     index: number;
73     // org unit shortname, call number label, or copy barcode
74     locationLabel: string;
75     // location label indentation depth
76     locationDepth: number | null;
77     callNumCount: number | null;
78     copyCount: number | null;
79     callNumberLabel: string;
80     copy: IdlObject;
81     callNum: IdlObject;
82     circ: IdlObject;
83     treeNode: HoldingsTreeNode;
84 }
85
86 @Component({
87     selector: 'eg-holdings-maintenance',
88     templateUrl: 'holdings.component.html',
89     styleUrls: ['holdings.component.css'],
90     encapsulation: ViewEncapsulation.None
91 })
92 export class HoldingsMaintenanceComponent implements OnInit {
93
94     initDone = false;
95     gridDataSource: GridDataSource;
96     gridTemplateContext: any;
97     @ViewChild('holdingsGrid', { static: true }) holdingsGrid: GridComponent;
98
99     // Manage visibility of various sub-sections
100     @ViewChild('callNumsCheckbox', { static: true })
101     private callNumsCheckbox: GridToolbarCheckboxComponent;
102     @ViewChild('copiesCheckbox', { static: true })
103     private copiesCheckbox: GridToolbarCheckboxComponent;
104     @ViewChild('emptyCallNumsCheckbox', { static: true })
105     private emptyCallNumsCheckbox: GridToolbarCheckboxComponent;
106     @ViewChild('emptyLibsCheckbox', { static: true })
107     private emptyLibsCheckbox: GridToolbarCheckboxComponent;
108     @ViewChild('markDamagedDialog', { static: true })
109     private markDamagedDialog: MarkDamagedDialogComponent;
110     @ViewChild('markMissingDialog', { static: true })
111     private markMissingDialog: MarkMissingDialogComponent;
112     @ViewChild('copyAlertsDialog', { static: true })
113     private copyAlertsDialog: CopyAlertsDialogComponent;
114     @ViewChild('copyTagsDialog', {static: false})
115     private copyTagsDialog: CopyTagsDialogComponent;
116     @ViewChild('copyNotesDialog', {static: false})
117     private copyNotesDialog: CopyNotesDialogComponent;
118     @ViewChild('replaceBarcode', { static: true })
119     private replaceBarcode: ReplaceBarcodeDialogComponent;
120     @ViewChild('deleteHolding', { static: true })
121     private deleteHolding: DeleteHoldingDialogComponent;
122     @ViewChild('bucketDialog', { static: true })
123     private bucketDialog: BucketDialogComponent;
124     @ViewChild('conjoinedDialog', { static: true })
125     private conjoinedDialog: ConjoinedItemsDialogComponent;
126     @ViewChild('makeBookableDialog', { static: true })
127     private makeBookableDialog: MakeBookableDialogComponent;
128     @ViewChild('transferItems', {static: false})
129     private transferItems: TransferItemsComponent;
130     @ViewChild('transferHoldings', {static: false})
131     private transferHoldings: TransferHoldingsComponent;
132     @ViewChild('transferAlert', {static: false})
133     private transferAlert: AlertDialogComponent;
134
135     holdingsTree: HoldingsTree;
136
137     // nodeType => id => tree node cache
138     treeNodeCache: {[nodeType: string]: {[id: number]: HoldingsTreeNode}};
139
140     // When true and a grid reload is called, the holdings data will be
141     // re-fetched from the server.
142     refreshHoldings: boolean;
143
144     // Used as a row identifier in th grid, since we're mixing object types.
145     gridIndex: number;
146
147     // List of copies whose due date we need to retrieve.
148     itemCircsNeeded: IdlObject[];
149
150     // When true draw the grid based on the stored preferences.
151     // When not true, render based on the current "expanded" state of each node.
152     // Rendering from prefs happens on initial load and when any prefs change.
153     renderFromPrefs: boolean;
154
155     rowClassCallback: (row: any) => string;
156
157     cellTextGenerator: GridCellTextGenerator;
158     orgClassCallback: (orgId: number) => string;
159     marked_orgs: number[] = [];
160
161     copyCounts: {[orgId: number]: {}} = {};
162
163     private _recId: number;
164     @Input() set recordId(id: number) {
165         this._recId = id;
166         // Only force new data collection when recordId()
167         // is invoked after ngInit() has already run.
168         if (this.initDone) {
169             this.hardRefresh();
170         }
171     }
172     get recordId(): number {
173         return this._recId;
174     }
175
176     contextOrg: IdlObject;
177
178     // The context org may come from a workstation setting.
179     // Wait for confirmation from the org-select (via onchange in this
180     // case) that the desired context org unit has been found.
181     contextOrgLoaded = false;
182
183     constructor(
184         private router: Router,
185         private org: OrgService,
186         private idl: IdlService,
187         private pcrud: PcrudService,
188         private net: NetService,
189         private auth: AuthService,
190         private staffCat: StaffCatalogService,
191         private store: ServerStoreService,
192         private localStore: StoreService,
193         private holdings: HoldingsService,
194         private broadcaster: BroadcastService,
195         private anonCache: AnonCacheService
196     ) {
197         // Set some sane defaults before settings are loaded.
198         this.gridDataSource = new GridDataSource();
199         this.refreshHoldings = true;
200         this.renderFromPrefs = true;
201
202         // TODO: need a separate setting for this?
203         this.contextOrg = this.staffCat.searchContext.searchOrg;
204
205         this.rowClassCallback = (row: any): string => {
206             if (row.callNum) {
207                 if (row.copy) {
208                     return 'holdings-copy-row';
209                 } else {
210                     return 'holdings-callNum-row';
211                 }
212             } else {
213                 // Add a generic org unit class and a depth-specific
214                 // class for styling different levels of the org tree.
215                 return 'holdings-org-row holdings-org-row-' +
216                     row.treeNode.target.ou_type().depth();
217             }
218         };
219
220
221         // Text-ify function for cells that use display templates.
222         this.cellTextGenerator = {
223             owner_label: row => row.locationLabel,
224             holdable: row => row.copy ?
225                 this.gridTemplateContext.copyIsHoldable(row.copy) : ''
226         };
227
228         this.orgClassCallback = (orgId: number): string => {
229             if (this.marked_orgs.includes(orgId)) { return 'font-weight-bold'; }
230             return '';
231         };
232
233         this.gridTemplateContext = {
234             toggleExpandRow: (row: HoldingsEntry) => {
235                 row.treeNode.expanded = !row.treeNode.expanded;
236
237                 if (!row.treeNode.expanded) {
238                     // When collapsing a node, all child nodes should be
239                     // collapsed as well.
240                     const traverse = (node: HoldingsTreeNode) => {
241                         node.expanded = false;
242                         node.children.forEach(traverse);
243                     };
244                     traverse(row.treeNode);
245                 }
246
247                 this.holdingsGrid.reload();
248             },
249
250             copyIsHoldable: (copy: IdlObject): boolean => {
251                 return copy.holdable() === 't'
252                     && copy.location().holdable() === 't'
253                     && copy.status().holdable() === 't';
254             }
255         };
256     }
257
258     ngOnInit() {
259         this.initDone = true;
260
261         this.broadcaster.listen('eg.holdings.update').subscribe(data => {
262             if (data && data.records && data.records.includes(this.recordId)) {
263                 this.hardRefresh();
264                 // A hard refresh is needed to accommodate cases where
265                 // a new call number is created for a subset of copies.
266                 // We may revisit this later and use soft refresh
267                 // (below) vs. hard refresh (above) depending on what
268                 // specifically is changed.
269                 // this.refreshHoldings = true;
270                 // this.holdingsGrid.reload();
271             }
272         });
273
274         // These are pre-cached via the catalog resolver.
275         const settings = this.store.getItemBatchCached([
276             'cat.holdings_show_empty_org',
277             'cat.holdings_show_empty',
278             'cat.holdings_show_copies',
279             'cat.holdings_show_vols'
280         ]);
281
282         // Show call numbers by default when no preference is set.
283         let showCallNums = settings['cat.holdings_show_vols'];
284         if (showCallNums === null) { showCallNums = true; }
285
286         this.callNumsCheckbox.checked(showCallNums);
287         this.copiesCheckbox.checked(settings['cat.holdings_show_copies']);
288         this.emptyCallNumsCheckbox.checked(settings['cat.holdings_show_empty']);
289         this.emptyLibsCheckbox.checked(settings['cat.holdings_show_empty_org']);
290
291         this.gridDataSource.getRows = (pager: Pager, sort: any[]) => {
292             if (!this.contextOrgLoaded) { return EMPTY; }
293             return this.fetchHoldings(pager);
294         };
295
296         this.net.request(
297             'open-ils.search',
298             'open-ils.search.biblio.copy_counts.retrieve.staff',
299             this.recordId
300         ).toPromise().then(result => {
301             result.forEach(copy_count => {
302                 this.marked_orgs.push(copy_count[0]);
303             });
304         });
305     }
306
307     // No data is loaded until the first occurrence of the org change handler
308     contextOrgChanged(org: IdlObject) {
309         this.contextOrgLoaded = true;
310         this.contextOrg = org;
311         this.hardRefresh();
312     }
313
314     hardRefresh() {
315         this.renderFromPrefs = true;
316         this.refreshHoldings = true;
317         this.initHoldingsTree();
318         this.holdingsGrid.reload();
319     }
320
321     toggleShowCopies(value: boolean) {
322         this.store.setItem('cat.holdings_show_copies', value);
323         if (value) {
324             // Showing copies implies showing call numbers
325             this.callNumsCheckbox.checked(true);
326         }
327         this.renderFromPrefs = true;
328         this.holdingsGrid.reload();
329     }
330
331     toggleShowCallNums(value: boolean) {
332         this.store.setItem('cat.holdings_show_vols', value);
333         if (!value) {
334             // Hiding call numbers implies hiding empty call numbers and copies.
335             this.copiesCheckbox.checked(false);
336             this.emptyCallNumsCheckbox.checked(false);
337         }
338         this.renderFromPrefs = true;
339         this.holdingsGrid.reload();
340     }
341
342     toggleShowEmptyCallNums(value: boolean) {
343         this.store.setItem('cat.holdings_show_empty', value);
344         if (value) {
345             this.callNumsCheckbox.checked(true);
346         }
347         this.renderFromPrefs = true;
348         this.holdingsGrid.reload();
349     }
350
351     toggleShowEmptyLibs(value: boolean) {
352         this.store.setItem('cat.holdings_show_empty_org', value);
353         this.renderFromPrefs = true;
354         this.holdingsGrid.reload();
355     }
356
357     onRowActivate(row: any) {
358         if (row.copy) {
359             this.openHoldingEdit([row], true, false);
360         } else {
361             this.gridTemplateContext.toggleExpandRow(row);
362         }
363     }
364
365     initHoldingsTree() {
366
367         const visibleOrgs = this.org.fullPath(this.contextOrg, true);
368
369         // The initial tree simply matches the org unit tree
370         const traverseOrg = (node: HoldingsTreeNode) => {
371             node.target.children().forEach((org: IdlObject) => {
372                 if (visibleOrgs.indexOf(org.id()) === -1) {
373                     return; // Org is outside of scope
374                 }
375                 const nodeChild = new HoldingsTreeNode();
376                 nodeChild.nodeType = 'org';
377                 nodeChild.target = org;
378                 nodeChild.parentNode = node;
379                 node.children.push(nodeChild);
380                 this.treeNodeCache.org[org.id()] = nodeChild;
381                 traverseOrg(nodeChild);
382             });
383         };
384
385         this.treeNodeCache = {
386             org: {},
387             callNum: {},
388             copy: {}
389         };
390
391         this.holdingsTree = new HoldingsTree();
392         this.holdingsTree.root.nodeType = 'org';
393         this.holdingsTree.root.target = this.org.root();
394         this.treeNodeCache.org[this.org.root().id()] = this.holdingsTree.root;
395
396         traverseOrg(this.holdingsTree.root);
397     }
398
399     // Org node children are sorted with any child org nodes pushed to the
400     // front, followed by the call number nodes sorted alphabetcially by label.
401     sortOrgNodeChildren(node: HoldingsTreeNode) {
402         node.children = node.children.sort((a, b) => {
403             if (a.nodeType === 'org') {
404                 if (b.nodeType === 'org') {
405                     return a.target.shortname() < b.target.shortname() ? -1 : 1;
406                 } else {
407                     return -1;
408                 }
409             } else if (b.nodeType === 'org') {
410                 return 1;
411             } else {
412                 // TODO: should this use label sortkey instead of
413                 // the compiled call number label?
414                 return a.target._label < b.target._label ? -1 : 1;
415             }
416         });
417     }
418
419     // Sets call number and copy count sums to nodes that need it.
420     // Applies the initial expansed state of each container node.
421     setTreeCounts(node: HoldingsTreeNode) {
422
423         if (node.nodeType === 'org') {
424             node.copyCount = this.copyCounts[node.target.id() + ''].copies;
425             node.callNumCount = this.copyCounts[node.target.id() + ''].call_numbers;
426         } else if (node.nodeType === 'callNum') {
427             node.copyCount = 0;
428         }
429
430         let hasChildOrgWithData = false;
431         let hasChildOrgSansData = false;
432         node.children.forEach(child => {
433             this.setTreeCounts(child);
434             if (node.nodeType === 'org') {
435                 if (child.nodeType !== 'callNum') {
436                     hasChildOrgWithData = child.callNumCount > 0;
437                     hasChildOrgSansData = child.callNumCount === 0;
438                 }
439             } else if (node.nodeType === 'callNum') {
440                 node.copyCount = node.children.length;
441                 if (this.renderFromPrefs) {
442                     node.expanded = this.copiesCheckbox.checked();
443                 }
444             }
445         });
446
447         if (this.renderFromPrefs && node.nodeType === 'org') {
448             if (node.copyCount > 0 && this.callNumsCheckbox.checked()) {
449                 node.expanded = true;
450             } else if (node.callNumCount > 0 && this.emptyCallNumsCheckbox.checked()) {
451                 node.expanded = true;
452             } else if (hasChildOrgWithData) {
453                 node.expanded = true;
454             } else if (hasChildOrgSansData && this.emptyLibsCheckbox.checked()) {
455                 node.expanded = true;
456             } else {
457                 node.expanded = false;
458             }
459         }
460     }
461
462     // Create HoldingsEntry objects for tree nodes that should be displayed
463     // and relays them to the grid via the observer.
464     propagateTreeEntries(observer: Observer<HoldingsEntry>, node: HoldingsTreeNode) {
465         const entry = new HoldingsEntry();
466         entry.treeNode = node;
467         entry.index = this.gridIndex++;
468
469         switch (node.nodeType) {
470             case 'org':
471                 if (node.callNumCount === 0
472                     && !this.emptyLibsCheckbox.checked()) {
473                     return;
474                 }
475                 entry.locationLabel = node.target.shortname();
476                 entry.locationDepth = node.target.ou_type().depth();
477                 entry.copyCount = node.copyCount;
478                 entry.callNumCount = node.callNumCount;
479                 this.sortOrgNodeChildren(node);
480                 break;
481
482             case 'callNum':
483                 if (this.renderFromPrefs) {
484                     if (!this.callNumsCheckbox.checked()) {
485                         return;
486                     }
487                     if (node.copyCount === 0
488                         && !this.emptyCallNumsCheckbox.checked()) {
489                         return;
490                     }
491                 }
492                 entry.locationLabel = node.target._label;
493                 entry.locationDepth = node.parentNode.target.ou_type().depth() + 1;
494                 entry.callNumberLabel = entry.locationLabel;
495                 entry.callNum = node.target;
496                 entry.copyCount = node.copyCount;
497                 break;
498
499             case 'copy':
500                 entry.locationLabel = node.target.barcode();
501                 entry.locationDepth = node.parentNode.parentNode.target.ou_type().depth() + 2;
502                 entry.callNumberLabel = node.parentNode.target.label(); // TODO
503                 entry.callNum = node.parentNode.target;
504                 entry.copy = node.target;
505                 entry.circ = node.target._circ;
506                 break;
507         }
508
509         // Tell the grid about the node entry
510         observer.next(entry);
511
512         if (node.expanded) {
513             // Process the child nodes.
514             node.children.forEach(child =>
515                 this.propagateTreeEntries(observer, child));
516         }
517     }
518
519     // Turns the tree into a list of entries for grid display
520     flattenHoldingsTree(observer: Observer<HoldingsEntry>) {
521         this.gridIndex = 0;
522         this.setTreeCounts(this.holdingsTree.root);
523         this.propagateTreeEntries(observer, this.holdingsTree.root);
524         observer.complete();
525         this.renderFromPrefs = false;
526     }
527
528     // Grab call numbers, copies, and related data.
529     fetchHoldings(pager: Pager): Observable<any> {
530         if (!this.recordId || this.recordId === -1) { return of([]); }
531
532         return new Observable<any>(observer => {
533
534             if (!this.refreshHoldings) {
535                 this.flattenHoldingsTree(observer);
536                 return;
537             }
538
539             this.itemCircsNeeded = [];
540             // Track vol IDs for the current fetch so we can prune
541             // any that were deleted in an out-of-band update.
542             const volsFetched: number[] = [];
543
544             return this.net.request(
545                 'open-ils.search',
546                 'open-ils.search.biblio.record.copy_counts.global.staff',
547                 this.recordId
548             ).pipe(
549                 tap(counts => this.copyCounts = counts),
550                 concatMap(_ => {
551
552                     return this.pcrud.search('acn',
553                         {   record: this.recordId,
554                             owning_lib: this.org.fullPath(this.contextOrg, true),
555                             deleted: 'f',
556                             label: {'!=' : '##URI##'}
557                         }, {
558                             flesh: 3,
559                             flesh_fields: {
560                                 acp: ['status', 'location', 'circ_lib', 'parts', 'notes',
561                                     'tags', 'age_protect', 'copy_alerts', 'latest_inventory',
562                                     'total_circ_count', 'last_circ'],
563                                 acn: ['prefix', 'suffix', 'copies'],
564                                 acli: ['inventory_workstation']
565                             }
566                         },
567                         {authoritative: true}
568                     );
569                 })
570             ).subscribe(
571                 callNum => {
572                     this.appendCallNum(callNum);
573                     volsFetched.push(callNum.id());
574                 },
575                 (err: unknown) => {},
576                 ()  => {
577                     this.refreshHoldings = false;
578                     this.pruneVols(volsFetched);
579                     this.fetchCircs().then(
580                         ok => this.flattenHoldingsTree(observer)
581                     );
582                 }
583             );
584         });
585     }
586
587     // Remove vols that were deleted out-of-band, via edit, merge, etc.
588     pruneVols(volsFetched: number[]) {
589
590         const toRemove: number[] = []; // avoid modifying mid-loop
591         Object.keys(this.treeNodeCache.callNum).forEach(volId => {
592             const id = Number(volId);
593             if (!volsFetched.includes(id)) {
594                 toRemove.push(id);
595             }
596         });
597
598         if (toRemove.length === 0) { return; }
599
600         const pruneNodes = (node: HoldingsTreeNode) => {
601             if (node.nodeType === 'callNum' &&
602                 toRemove.includes(node.target.id())) {
603
604                 console.debug('pruning deleted vol:', node.target.id());
605
606                 // Remove this node from the parents list of children
607                 node.parentNode.children =
608                     node.parentNode.children.filter(
609                         c => c.target.id() !== node.target.id());
610
611             } else {
612                 node.children.forEach(c => pruneNodes(c));
613             }
614         };
615
616         // remove from cache
617         toRemove.forEach(volId => delete this.treeNodeCache.callNum[volId]);
618
619         // remove from tree
620         pruneNodes(this.holdingsTree.root);
621
622         // refresh tree / grid
623         this.holdingsGrid.reload();
624     }
625
626     // Retrieve circulation objects for checked out items.
627     fetchCircs(): Promise<any> {
628         const copyIds = this.itemCircsNeeded.map(copy => copy.id());
629         if (copyIds.length === 0) { return Promise.resolve(); }
630
631         return this.pcrud.search('circ', {
632             target_copy: copyIds,
633             checkin_time: null
634         }).pipe(map(circ => {
635             const copy = this.itemCircsNeeded.filter(
636                 c => Number(c.id()) === Number(circ.target_copy()))[0];
637             copy._circ = circ;
638         })).toPromise();
639     }
640
641     // Compile prefix + label + suffix into field callNum._label;
642     setCallNumLabel(callNum: IdlObject) {
643         const pfx = callNum.prefix() ? callNum.prefix().label() : '';
644         const sfx = callNum.suffix() ? callNum.suffix().label() : '';
645         callNum._label = pfx ? pfx + ' ' : '';
646         callNum._label += callNum.label();
647         callNum._label += sfx ? ' ' + sfx : '';
648     }
649
650     // Create the tree node for the call number if it doesn't already exist.
651     // Do the same for its linked copies.
652     appendCallNum(callNum: IdlObject) {
653         let callNumNode = this.treeNodeCache.callNum[callNum.id()];
654         this.setCallNumLabel(callNum);
655
656         if (callNumNode) {
657             const pNode = this.treeNodeCache.org[callNum.owning_lib()];
658             if (callNumNode.parentNode.target.id() !== pNode.target.id()) {
659                 callNumNode.parentNode = pNode;
660                 callNumNode.parentNode.children.push(callNumNode);
661             }
662         } else {
663             callNumNode = new HoldingsTreeNode();
664             callNumNode.nodeType = 'callNum';
665             callNumNode.parentNode = this.treeNodeCache.org[callNum.owning_lib()];
666             callNumNode.parentNode.children.push(callNumNode);
667             this.treeNodeCache.callNum[callNum.id()] = callNumNode;
668         }
669
670         callNumNode.target = callNum;
671
672         callNum.copies()
673             .filter((copy: IdlObject) => (copy.deleted() !== 't'))
674             .sort((a: IdlObject, b: IdlObject) => a.barcode() < b.barcode() ? -1 : 1)
675             .forEach((copy: IdlObject) => this.appendCopy(callNumNode, copy));
676     }
677
678     // Find or create a copy node.
679     appendCopy(callNumNode: HoldingsTreeNode, copy: IdlObject) {
680         let copyNode = this.treeNodeCache.copy[copy.id()];
681
682         if (copyNode) {
683             const oldParent = copyNode.parentNode;
684             if (oldParent.target.id() !== callNumNode.target.id()) {
685                 // TODO: copy changed owning call number.  Remove it from
686                 // the previous call number before adding to the new call number.
687                 copyNode.parentNode = callNumNode;
688                 callNumNode.children.push(copyNode);
689             }
690         } else {
691             // New node required
692             copyNode = new HoldingsTreeNode();
693             copyNode.nodeType = 'copy';
694             callNumNode.children.push(copyNode);
695             copyNode.parentNode = callNumNode;
696             this.treeNodeCache.copy[copy.id()] = copyNode;
697         }
698
699         copyNode.target = copy;
700         const stat = Number(copy.status().id());
701         copy._monograph_parts = '';
702         if (copy.parts().length > 0) {
703             copy._monograph_parts =
704                 copy.parts().map(p => p.label()).join(',');
705         }
706
707         // Ignore alerts that have already been ACK'ed
708         // Over a long enough time, this list could grow large, so
709         // consider fetching non-ack'ed copy alerts separately.
710         copy.copy_alerts(copy.copy_alerts().filter(a => !a.ack_time()));
711
712         // eslint-disable-next-line no-magic-numbers
713         if (stat === 1 /* checked out */ || stat === 16 /* long overdue */) {
714             // Avoid looking up circs on items that are not checked out.
715             this.itemCircsNeeded.push(copy);
716         }
717     }
718
719     // Which copies in the grid are selected.
720     selectedCopyIds(rows: HoldingsEntry[], skipStatus?: number): number[] {
721         return this.selectedCopies(rows, skipStatus).map(c => Number(c.id()));
722     }
723
724     selectedVolIds(rows: HoldingsEntry[]): number[] {
725         return rows
726             .filter(r => Boolean(r.callNum))
727             .map(r => Number(r.callNum.id()));
728     }
729
730     selectedCopies(rows: HoldingsEntry[], skipStatus?: number): IdlObject[] {
731         let copyRows = rows.filter(r => Boolean(r.copy)).map(r => r.copy);
732         if (skipStatus) {
733             copyRows = copyRows.filter(
734                 c => Number(c.status().id()) !== Number(skipStatus));
735         }
736         return copyRows;
737     }
738
739     selectedCallNumIds(rows: HoldingsEntry[]): number[] {
740         return this.selectedCallNums(rows).map(cn => cn.id());
741     }
742
743     selectedCallNums(rows: HoldingsEntry[]): IdlObject[] {
744         return rows
745             .filter(r => r.treeNode.nodeType === 'callNum')
746             .map(r => r.callNum);
747     }
748
749
750     async showMarkDamagedDialog(rows: HoldingsEntry[]) {
751         // eslint-disable-next-line no-magic-numbers
752         const copyIds = this.selectedCopyIds(rows, 14 /* ignore damaged */);
753
754         if (copyIds.length === 0) { return; }
755
756         let rowsModified = false;
757
758         const markNext = async(ids: number[]) => {
759             if (ids.length === 0) {
760                 return Promise.resolve();
761             }
762
763             this.markDamagedDialog.copyId = ids.pop();
764             return this.markDamagedDialog.open({size: 'lg'}).subscribe(
765                 ok => {
766                     if (ok) { rowsModified = true; }
767                     return markNext(ids);
768                 },
769                 (dismiss: unknown) => markNext(ids)
770             );
771         };
772
773         await markNext(copyIds);
774         if (rowsModified) {
775             this.refreshHoldings = true;
776             this.holdingsGrid.reload();
777         }
778     }
779
780     showMarkMissingDialog(rows: any[]) {
781         // eslint-disable-next-line no-magic-numbers
782         const copyIds = this.selectedCopyIds(rows, 4 /* ignore missing */);
783         if (copyIds.length > 0) {
784             this.markMissingDialog.copyIds = copyIds;
785             this.markMissingDialog.open({}).subscribe(
786                 rowsModified => {
787                     if (rowsModified) {
788                         this.refreshHoldings = true;
789                         this.holdingsGrid.reload();
790                     }
791                 },
792                 (dismissed: unknown) => {} // avoid console errors
793             );
794         }
795     }
796
797     // Mark record, library, and potentially the selected call number
798     // as the current transfer target.
799     markLibCnForTransfer(rows: HoldingsEntry[]) {
800         if (rows.length === 0) {
801             return;
802         }
803
804         // Action may only apply to a single org or call number row.
805         const node = rows[0].treeNode;
806         if (node.nodeType === 'copy') { return; }
807
808         let orgId: number;
809
810         if (node.nodeType === 'org') {
811             orgId = node.target.id();
812
813             // Clear call number target when performed on an org unit row
814             this.localStore.removeLocalItem('eg.cat.transfer_target_vol');
815
816         } else if (node.nodeType === 'callNum') {
817
818             // All call number nodes are children of org nodes.
819             orgId = node.parentNode.target.id();
820
821             // Add call number target when performed on a call number row.
822             this.localStore.setLocalItem(
823                 'eg.cat.transfer_target_vol', node.target.id());
824         }
825
826         // Track lib and record to support transfering items from
827         // a different bib record to this record at the selected
828         // owning lib.
829         this.localStore.setLocalItem('eg.cat.transfer_target_lib', orgId);
830         this.localStore.setLocalItem('eg.cat.transfer_target_record', this.recordId);
831     }
832
833     openAngJsWindow(path: string) {
834         const url = `/eg/staff/${path}`;
835         window.open(url, '_blank');
836     }
837
838     openItemHolds(rows: HoldingsEntry[]) {
839         if (rows.length > 0 && rows[0].copy) {
840             this.openAngJsWindow(`cat/item/${rows[0].copy.id()}/holds`);
841         }
842     }
843
844     openItemStatusList(rows: HoldingsEntry[]) {
845         const ids = this.selectedCopyIds(rows);
846         if (ids.length > 0) {
847             return this.openAngJsWindow(`cat/item/search/${ids.join(',')}`);
848         }
849     }
850
851     openItemStatus(rows: HoldingsEntry[]) {
852         if (rows.length > 0 && rows[0].copy) {
853             return this.openAngJsWindow(`cat/item/${rows[0].copy.id()}`);
854         }
855     }
856
857     openItemTriggeredEvents(rows: HoldingsEntry[]) {
858         if (rows.length > 0 && rows[0].copy) {
859             return this.openAngJsWindow(
860                 `cat/item/${rows[0].copy.id()}/triggered_events`);
861         }
862     }
863
864     openItemPrintLabels(rows: HoldingsEntry[]) {
865         const ids = this.selectedCopyIds(rows);
866         if (ids.length === 0) { return; }
867
868         this.anonCache.setItem(null, 'print-labels-these-copies', {copies: ids})
869             .then(key => this.openAngJsWindow(`cat/printlabels/${key}`));
870     }
871
872     openHoldingEdit(rows: HoldingsEntry[], hideVols: boolean, hideCopies: boolean) {
873
874         // Avoid adding call number edit entries for call numbers
875         // that are already represented by selected items.
876
877         const copies = this.selectedCopies(rows);
878         const copyVols = copies.map(c => Number(c.call_number()));
879
880         const volIds = [];
881         this.selectedVolIds(rows).forEach(id => {
882             if (!copyVols.includes(id)) {
883                 volIds.push(id);
884             }
885         });
886
887         this.holdings.spawnAddHoldingsUi(
888             this.recordId,
889             volIds,
890             null,
891             copies.map(c => Number(c.id())),
892             hideCopies,
893             hideVols
894         );
895     }
896
897     openHoldingAdd(rows: HoldingsEntry[], addCallNums: boolean, addCopies: boolean) {
898
899         // The user may select a set of call numbers by selecting call
900         // number and/or item rows.  Owning libs for new call numbers may
901         // also come from org unit row selection.
902         const orgs = {};
903         const callNums = [];
904         rows.forEach(r => {
905             if (r.treeNode.nodeType === 'callNum') {
906                 callNums.push(r.callNum);
907
908             } else if (r.treeNode.nodeType === 'copy') {
909                 callNums.push(r.treeNode.parentNode.target);
910
911             } else if (r.treeNode.nodeType === 'org') {
912                 const org = r.treeNode.target;
913                 if (org.ou_type().can_have_vols() === 't') {
914                     orgs[org.id()] = true;
915                 }
916             }
917         });
918
919         if (addCopies && !addCallNums) {
920             // Adding copies to an existing set of call numbers.
921             if (callNums.length > 0) {
922                 const callNumIds = callNums.map(v => Number(v.id()));
923                 this.holdings.spawnAddHoldingsUi(this.recordId, callNumIds);
924             }
925
926         } else if (addCallNums) {
927             const entries = [];
928
929             // Use selected call numbers as basis for new call numbers.
930             callNums.forEach(v =>
931                 entries.push({label: v.label(), owner: v.owning_lib()}));
932
933             // Use selected org units as owning libs for new call numbers
934             Object.keys(orgs).forEach(id => entries.push({owner: id}));
935
936             if (entries.length === 0) {
937                 // Otherwise create new call numbers for "here"
938                 entries.push({owner: this.auth.user().ws_ou()});
939             }
940
941             this.holdings.spawnAddHoldingsUi(
942                 this.recordId, null, entries, null, !addCopies);
943         }
944     }
945
946     openItemAlerts(rows: HoldingsEntry[]) {
947         const copyIds = this.selectedCopyIds(rows);
948         if (copyIds.length === 0) { return; }
949
950         this.copyAlertsDialog.copyIds = copyIds;
951         this.copyAlertsDialog.open({size: 'lg'}).subscribe(
952             changes => {
953                 if (!changes) { return; }
954                 if (changes.newAlerts.length > 0 || changes.changedAlerts.length > 0) {
955                     this.hardRefresh();
956                 }
957             }
958         );
959     }
960
961     openItemTags(rows: HoldingsEntry[]) {
962         const copyIds = this.selectedCopyIds(rows);
963         if (copyIds.length === 0) { return; }
964
965         this.copyTagsDialog.copyIds = copyIds;
966         this.copyTagsDialog.open({size: 'lg'}).subscribe(
967             changes => {
968                 if (changes.newTags.length > 0 || changes.deletedMaps.length > 0) {
969                     this.hardRefresh();
970                 }
971             }
972         );
973     }
974
975     openItemNotes(rows: HoldingsEntry[]) {
976         const copyIds = this.selectedCopyIds(rows);
977         if (copyIds.length === 0) { return; }
978
979         this.copyNotesDialog.copyIds = copyIds;
980         this.copyNotesDialog.open({size: 'lg'}).subscribe(
981             changes => {
982                 if (!changes) { return; }
983                 if (changes.newNotes.length > 0 || changes.delNotes.length > 0) {
984                     this.hardRefresh();
985                 }
986             }
987         );
988     }
989
990     openReplaceBarcodeDialog(rows: HoldingsEntry[]) {
991         const ids = this.selectedCopyIds(rows);
992         if (ids.length === 0) { return; }
993         this.replaceBarcode.copyIds = ids;
994         this.replaceBarcode.open({}).subscribe(
995             modified => {
996                 if (modified) {
997                     this.hardRefresh();
998                 }
999             }
1000         );
1001     }
1002
1003     // mode 'callNums' -- only delete empty call numbers
1004     // mode 'copies' -- only delete selected copies
1005     // mode 'both' -- delete selected copies and selected call numbers, plus all
1006     // copies linked to selected call numbers, regardless of whether they are selected.
1007     deleteHoldings(rows: HoldingsEntry[], mode: 'callNums' | 'copies' | 'both') {
1008         const callNumHash: any = {};
1009
1010         if (mode === 'callNums' || mode === 'both') {
1011             // Collect the call numbers to be deleted.
1012             rows.filter(r => r.treeNode.nodeType === 'callNum').forEach(r => {
1013                 const callNum = this.idl.clone(r.callNum);
1014                 if (mode === 'callNums') {
1015                     if (callNum.copies().length > 0) {
1016                         // cannot delete non-empty call number in this mode.
1017                         return;
1018                     }
1019                 } else {
1020                     callNum.copies().forEach(c => c.isdeleted(true));
1021                 }
1022                 callNum.isdeleted(true);
1023                 callNumHash[callNum.id()] = callNum;
1024             });
1025         }
1026
1027         if (mode === 'copies' || mode === 'both') {
1028             // Collect the copies to be deleted, including their call numbers
1029             // since the API expects fleshed call number objects.
1030             rows.filter(r => r.treeNode.nodeType === 'copy').forEach(r => {
1031                 const callNum = r.treeNode.parentNode.target;
1032                 if (!callNumHash[callNum.id()]) {
1033                     callNumHash[callNum.id()] = this.idl.clone(callNum);
1034                     callNumHash[callNum.id()].copies([]);
1035                 }
1036                 const copy = this.idl.clone(r.copy);
1037                 copy.isdeleted(true);
1038                 callNumHash[callNum.id()].copies().push(copy);
1039             });
1040         }
1041
1042         if (Object.keys(callNumHash).length === 0) {
1043             // No data to process.
1044             return;
1045         }
1046
1047         // Note forceDeleteCopies should not be necessary here, since we
1048         // manually marked all copies as deleted on deleted call numbers in
1049         // "both" mode.
1050         this.deleteHolding.forceDeleteCopies = mode === 'both';
1051         this.deleteHolding.callNums = Object.values(callNumHash);
1052         this.deleteHolding.open({size: 'sm'}).subscribe(
1053             modified => {
1054                 if (modified) {
1055                     this.hardRefresh();
1056                 }
1057             }
1058         );
1059     }
1060
1061     requestItems(rows: HoldingsEntry[]) {
1062         const copyIds = this.selectedCopyIds(rows);
1063         if (copyIds.length === 0) { return; }
1064         const params = {target: copyIds, holdFor: 'staff'};
1065         this.router.navigate(['/staff/catalog/hold/C'], {queryParams: params});
1066     }
1067
1068     openBucketDialog(rows: HoldingsEntry[]) {
1069         const copyIds = this.selectedCopyIds(rows);
1070         if (copyIds.length > 0) {
1071             this.bucketDialog.bucketClass = 'copy';
1072             this.bucketDialog.itemIds = copyIds;
1073             this.bucketDialog.open({size: 'lg'});
1074         }
1075     }
1076
1077     openConjoinedDialog(rows: HoldingsEntry[]) {
1078         const copyIds = this.selectedCopyIds(rows);
1079         if (copyIds.length > 0) {
1080             this.conjoinedDialog.copyIds = copyIds;
1081             this.conjoinedDialog.open({size: 'sm'});
1082         }
1083     }
1084
1085     bookItems(rows: HoldingsEntry[]) {
1086         const copyIds = this.selectedCopyIds(rows);
1087         if (copyIds.length > 0) {
1088             this.router.navigate(
1089                 ['staff', 'booking', 'create_reservation', 'for_resource', rows.filter(r => Boolean(r.copy))[0].copy.barcode()]
1090             );
1091         }
1092     }
1093
1094     makeBookable(rows: HoldingsEntry[]) {
1095         const copyIds = this.selectedCopyIds(rows);
1096         if (copyIds.length > 0) {
1097             this.makeBookableDialog.copyIds = copyIds;
1098             this.makeBookableDialog.open({});
1099         }
1100     }
1101
1102     manageReservations(rows: HoldingsEntry[]) {
1103         const copyIds = this.selectedCopyIds(rows);
1104         if (copyIds.length > 0) {
1105             this.router.navigate(
1106                 ['staff', 'booking', 'manage_reservations', 'by_resource', rows.filter(r => Boolean(r.copy))[0].copy.barcode()]
1107             );
1108         }
1109     }
1110
1111     transferSelectedItems(rows: HoldingsEntry[]) {
1112         if (rows.length === 0) { return; }
1113
1114         const cnId =
1115             this.localStore.getLocalItem('eg.cat.transfer_target_vol');
1116
1117         const orgId =
1118             this.localStore.getLocalItem('eg.cat.transfer_target_lib');
1119
1120         const recId =
1121             this.localStore.getLocalItem('eg.cat.transfer_target_record');
1122
1123         let promise;
1124
1125         if (cnId) { // Direct call number transfer
1126
1127             const itemIds = this.selectedCopyIds(rows);
1128             promise = this.transferItems.transferItems(itemIds, cnId);
1129
1130         } else if (orgId && recId) { // "Auto" transfer
1131
1132             // Clone the items to be modified to avoid any unexpected
1133             // modifications and fesh the call numbers.
1134             const items = this.idl.clone(this.selectedCopies(rows));
1135             items.forEach(i => i.call_number(
1136                 this.treeNodeCache.callNum[i.call_number()].target));
1137
1138             console.log(items);
1139             promise = this.transferItems.autoTransferItems(items, recId, orgId);
1140
1141         } else {
1142             promise = this.transferAlert.open().toPromise();
1143         }
1144
1145         promise.then(success => success ?  this.hardRefresh() : null);
1146     }
1147
1148     transferSelectedHoldings(rows: HoldingsEntry[]) {
1149         const callNums = this.selectedCallNums(rows);
1150         if (callNums.length === 0) { return; }
1151
1152         const orgId =
1153             this.localStore.getLocalItem('eg.cat.transfer_target_lib');
1154
1155         let recId =
1156             this.localStore.getLocalItem('eg.cat.transfer_target_record');
1157
1158         if (orgId) {
1159             // When transferring holdings (call numbers) between org units,
1160             // limit transfers to within the current record.
1161             recId = this.recordId;
1162
1163         } else if (!recId) {
1164             // No destinations applied.
1165             return this.transferAlert.open();
1166         }
1167
1168         this.transferHoldings.targetRecId = recId;
1169         this.transferHoldings.targetOrgId = orgId;
1170         this.transferHoldings.callNums = callNums;
1171
1172         this.transferHoldings.transferHoldings()
1173             .then(success => success ?  this.hardRefresh() : null);
1174     }
1175 }
1176