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