]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.ts
LP1888723 Call number modification repair
[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                         acn: ['prefix', 'suffix', 'copies'],
536                         acli: ['inventory_workstation']
537                     }
538                 },
539                 {authoritative: true}
540             ).subscribe(
541                 callNum => {
542                     this.appendCallNum(callNum);
543                     volsFetched.push(callNum.id());
544                 },
545                 err => {},
546                 ()  => {
547                     this.refreshHoldings = false;
548                     this.pruneVols(volsFetched);
549                     this.fetchCircs().then(
550                         ok => this.flattenHoldingsTree(observer)
551                     );
552                 }
553             );
554         });
555     }
556
557     // Remove vols that were deleted out-of-band, via edit, merge, etc.
558     pruneVols(volsFetched: number[]) {
559
560         const toRemove: number[] = []; // avoid modifying mid-loop
561         Object.keys(this.treeNodeCache.callNum).forEach(volId => {
562             const id = Number(volId);
563             if (!volsFetched.includes(id)) {
564                 toRemove.push(id);
565             }
566         });
567
568         if (toRemove.length === 0) { return; }
569
570         const pruneNodes = (node: HoldingsTreeNode) => {
571             if (node.nodeType === 'callNum' &&
572                 toRemove.includes(node.target.id())) {
573
574                 console.debug('pruning deleted vol:', node.target.id());
575
576                 // Remove this node from the parents list of children
577                 node.parentNode.children =
578                     node.parentNode.children.filter(
579                         c => c.target.id() !== node.target.id());
580
581             } else {
582                 node.children.forEach(c => pruneNodes(c));
583             }
584         };
585
586         // remove from cache
587         toRemove.forEach(volId => delete this.treeNodeCache.callNum[volId]);
588
589         // remove from tree
590         pruneNodes(this.holdingsTree.root);
591
592         // refresh tree / grid
593         this.holdingsGrid.reload();
594     }
595
596     // Retrieve circulation objects for checked out items.
597     fetchCircs(): Promise<any> {
598         const copyIds = this.itemCircsNeeded.map(copy => copy.id());
599         if (copyIds.length === 0) { return Promise.resolve(); }
600
601         return this.pcrud.search('circ', {
602             target_copy: copyIds,
603             checkin_time: null
604         }).pipe(map(circ => {
605             const copy = this.itemCircsNeeded.filter(
606                 c => Number(c.id()) === Number(circ.target_copy()))[0];
607             copy._circ = circ;
608         })).toPromise();
609     }
610
611     // Compile prefix + label + suffix into field callNum._label;
612     setCallNumLabel(callNum: IdlObject) {
613         const pfx = callNum.prefix() ? callNum.prefix().label() : '';
614         const sfx = callNum.suffix() ? callNum.suffix().label() : '';
615         callNum._label = pfx ? pfx + ' ' : '';
616         callNum._label += callNum.label();
617         callNum._label += sfx ? ' ' + sfx : '';
618     }
619
620     // Create the tree node for the call number if it doesn't already exist.
621     // Do the same for its linked copies.
622     appendCallNum(callNum: IdlObject) {
623         let callNumNode = this.treeNodeCache.callNum[callNum.id()];
624         this.setCallNumLabel(callNum);
625
626         if (callNumNode) {
627             const pNode = this.treeNodeCache.org[callNum.owning_lib()];
628             if (callNumNode.parentNode.target.id() !== pNode.target.id()) {
629                 callNumNode.parentNode = pNode;
630                 callNumNode.parentNode.children.push(callNumNode);
631             }
632         } else {
633             callNumNode = new HoldingsTreeNode();
634             callNumNode.nodeType = 'callNum';
635             callNumNode.parentNode = this.treeNodeCache.org[callNum.owning_lib()];
636             callNumNode.parentNode.children.push(callNumNode);
637             this.treeNodeCache.callNum[callNum.id()] = callNumNode;
638         }
639
640         callNumNode.target = callNum;
641
642         callNum.copies()
643             .filter((copy: IdlObject) => (copy.deleted() !== 't'))
644             .sort((a: IdlObject, b: IdlObject) => a.barcode() < b.barcode() ? -1 : 1)
645             .forEach((copy: IdlObject) => this.appendCopy(callNumNode, copy));
646     }
647
648     // Find or create a copy node.
649     appendCopy(callNumNode: HoldingsTreeNode, copy: IdlObject) {
650         let copyNode = this.treeNodeCache.copy[copy.id()];
651
652         if (copyNode) {
653             const oldParent = copyNode.parentNode;
654             if (oldParent.target.id() !== callNumNode.target.id()) {
655                 // TODO: copy changed owning call number.  Remove it from
656                 // the previous call number before adding to the new call number.
657                 copyNode.parentNode = callNumNode;
658                 callNumNode.children.push(copyNode);
659             }
660         } else {
661             // New node required
662             copyNode = new HoldingsTreeNode();
663             copyNode.nodeType = 'copy';
664             callNumNode.children.push(copyNode);
665             copyNode.parentNode = callNumNode;
666             this.treeNodeCache.copy[copy.id()] = copyNode;
667         }
668
669         copyNode.target = copy;
670         const stat = Number(copy.status().id());
671         copy._monograph_parts = '';
672         if (copy.parts().length > 0) {
673             copy._monograph_parts =
674                 copy.parts().map(p => p.label()).join(',');
675         }
676
677         if (stat === 1 /* checked out */ || stat === 16 /* long overdue */) {
678             // Avoid looking up circs on items that are not checked out.
679             this.itemCircsNeeded.push(copy);
680         }
681     }
682
683     // Which copies in the grid are selected.
684     selectedCopyIds(rows: HoldingsEntry[], skipStatus?: number): number[] {
685         return this.selectedCopies(rows, skipStatus).map(c => Number(c.id()));
686     }
687
688     selectedVolIds(rows: HoldingsEntry[]): number[] {
689         return rows
690             .filter(r => Boolean(r.callNum))
691             .map(r => Number(r.callNum.id()));
692     }
693
694     selectedCopies(rows: HoldingsEntry[], skipStatus?: number): IdlObject[] {
695         let copyRows = rows.filter(r => Boolean(r.copy)).map(r => r.copy);
696         if (skipStatus) {
697             copyRows = copyRows.filter(
698                 c => Number(c.status().id()) !== Number(skipStatus));
699         }
700         return copyRows;
701     }
702
703     selectedCallNumIds(rows: HoldingsEntry[]): number[] {
704         return this.selectedCallNums(rows).map(cn => cn.id());
705     }
706
707     selectedCallNums(rows: HoldingsEntry[]): IdlObject[] {
708         return rows
709             .filter(r => r.treeNode.nodeType === 'callNum')
710             .map(r => r.callNum);
711     }
712
713
714     async showMarkDamagedDialog(rows: HoldingsEntry[]) {
715         const copyIds = this.selectedCopyIds(rows, 14 /* ignore damaged */);
716
717         if (copyIds.length === 0) { return; }
718
719         let rowsModified = false;
720
721         const markNext = async(ids: number[]) => {
722             if (ids.length === 0) {
723                 return Promise.resolve();
724             }
725
726             this.markDamagedDialog.copyId = ids.pop();
727             return this.markDamagedDialog.open({size: 'lg'}).subscribe(
728                 ok => {
729                     if (ok) { rowsModified = true; }
730                     return markNext(ids);
731                 },
732                 dismiss => markNext(ids)
733             );
734         };
735
736         await markNext(copyIds);
737         if (rowsModified) {
738             this.refreshHoldings = true;
739             this.holdingsGrid.reload();
740         }
741     }
742
743     showMarkMissingDialog(rows: any[]) {
744         const copyIds = this.selectedCopyIds(rows, 4 /* ignore missing */);
745         if (copyIds.length > 0) {
746             this.markMissingDialog.copyIds = copyIds;
747             this.markMissingDialog.open({}).subscribe(
748                 rowsModified => {
749                     if (rowsModified) {
750                         this.refreshHoldings = true;
751                         this.holdingsGrid.reload();
752                     }
753                 },
754                 dismissed => {} // avoid console errors
755             );
756         }
757     }
758
759     // Mark record, library, and potentially the selected call number
760     // as the current transfer target.
761     markLibCnForTransfer(rows: HoldingsEntry[]) {
762         if (rows.length === 0) {
763             return;
764         }
765
766         // Action may only apply to a single org or call number row.
767         const node = rows[0].treeNode;
768         if (node.nodeType === 'copy') { return; }
769
770         let orgId: number;
771
772         if (node.nodeType === 'org') {
773             orgId = node.target.id();
774
775             // Clear call number target when performed on an org unit row
776             this.localStore.removeLocalItem('eg.cat.transfer_target_vol');
777
778         } else if (node.nodeType === 'callNum') {
779
780             // All call number nodes are children of org nodes.
781             orgId = node.parentNode.target.id();
782
783             // Add call number target when performed on a call number row.
784             this.localStore.setLocalItem(
785                 'eg.cat.transfer_target_vol', node.target.id());
786         }
787
788         // Track lib and record to support transfering items from
789         // a different bib record to this record at the selected
790         // owning lib.
791         this.localStore.setLocalItem('eg.cat.transfer_target_lib', orgId);
792         this.localStore.setLocalItem('eg.cat.transfer_target_record', this.recordId);
793     }
794
795     openAngJsWindow(path: string) {
796         const url = `/eg/staff/${path}`;
797         window.open(url, '_blank');
798     }
799
800     openItemHolds(rows: HoldingsEntry[]) {
801         if (rows.length > 0 && rows[0].copy) {
802             this.openAngJsWindow(`cat/item/${rows[0].copy.id()}/holds`);
803         }
804     }
805
806     openItemStatusList(rows: HoldingsEntry[]) {
807         const ids = this.selectedCopyIds(rows);
808         if (ids.length > 0) {
809             return this.openAngJsWindow(`cat/item/search/${ids.join(',')}`);
810         }
811     }
812
813     openItemStatus(rows: HoldingsEntry[]) {
814         if (rows.length > 0 && rows[0].copy) {
815            return this.openAngJsWindow(`cat/item/${rows[0].copy.id()}`);
816         }
817     }
818
819     openItemTriggeredEvents(rows: HoldingsEntry[]) {
820         if (rows.length > 0 && rows[0].copy) {
821            return this.openAngJsWindow(
822                `cat/item/${rows[0].copy.id()}/triggered_events`);
823         }
824     }
825
826     openItemPrintLabels(rows: HoldingsEntry[]) {
827         const ids = this.selectedCopyIds(rows);
828         if (ids.length === 0) { return; }
829
830         this.anonCache.setItem(null, 'print-labels-these-copies', {copies: ids})
831         .then(key => this.openAngJsWindow(`cat/printlabels/${key}`));
832     }
833
834     openHoldingEdit(rows: HoldingsEntry[], hideVols: boolean, hideCopies: boolean) {
835
836         // Avoid adding call number edit entries for call numbers
837         // that are already represented by selected items.
838
839         const copies = this.selectedCopies(rows);
840         const copyVols = copies.map(c => Number(c.call_number()));
841
842         const volIds = [];
843         this.selectedVolIds(rows).forEach(id => {
844             if (!copyVols.includes(id)) {
845                 volIds.push(id);
846             }
847         });
848
849         this.holdings.spawnAddHoldingsUi(
850             this.recordId,
851             volIds,
852             null,
853             copies.map(c => Number(c.id())),
854             hideCopies,
855             hideVols
856         );
857     }
858
859     openHoldingAdd(rows: HoldingsEntry[], addCallNums: boolean, addCopies: boolean) {
860
861         // The user may select a set of call numbers by selecting call
862         // number and/or item rows.  Owning libs for new call numbers may
863         // also come from org unit row selection.
864         const orgs = {};
865         const callNums = [];
866         rows.forEach(r => {
867             if (r.treeNode.nodeType === 'callNum') {
868                 callNums.push(r.callNum);
869
870             } else if (r.treeNode.nodeType === 'copy') {
871                 callNums.push(r.treeNode.parentNode.target);
872
873             } else if (r.treeNode.nodeType === 'org') {
874                 const org = r.treeNode.target;
875                 if (org.ou_type().can_have_vols() === 't') {
876                     orgs[org.id()] = true;
877                 }
878             }
879         });
880
881         if (addCopies && !addCallNums) {
882             // Adding copies to an existing set of call numbers.
883             if (callNums.length > 0) {
884                 const callNumIds = callNums.map(v => Number(v.id()));
885                 this.holdings.spawnAddHoldingsUi(this.recordId, callNumIds);
886             }
887
888         } else if (addCallNums) {
889             const entries = [];
890
891             // Use selected call numbers as basis for new call numbers.
892             callNums.forEach(v =>
893                 entries.push({label: v.label(), owner: v.owning_lib()}));
894
895             // Use selected org units as owning libs for new call numbers
896             Object.keys(orgs).forEach(id => entries.push({owner: id}));
897
898             if (entries.length === 0) {
899                 // Otherwise create new call numbers for "here"
900                 entries.push({owner: this.auth.user().ws_ou()});
901             }
902
903             this.holdings.spawnAddHoldingsUi(
904                 this.recordId, null, entries, null, !addCopies);
905         }
906     }
907
908     openItemAlerts(rows: HoldingsEntry[], mode: string) {
909         const copyIds = this.selectedCopyIds(rows);
910         if (copyIds.length === 0) { return; }
911
912         this.copyAlertsDialog.copyIds = copyIds;
913         this.copyAlertsDialog.mode = mode;
914         this.copyAlertsDialog.open({size: 'lg'}).subscribe(
915             modified => {
916                 if (modified) {
917                     this.hardRefresh();
918                 }
919             }
920         );
921     }
922
923     openItemTags(rows: HoldingsEntry[]) {
924         const copyIds = this.selectedCopyIds(rows);
925         if (copyIds.length === 0) { return; }
926
927         this.copyTagsDialog.copyIds = copyIds;
928         this.copyTagsDialog.open({size: 'lg'}).subscribe(
929             modified => {
930                 if (modified) {
931                     this.hardRefresh();
932                 }
933             }
934         );
935     }
936
937     openItemNotes(rows: HoldingsEntry[]) {
938         const copyIds = this.selectedCopyIds(rows);
939         if (copyIds.length === 0) { return; }
940
941         this.copyNotesDialog.copyIds = copyIds;
942         this.copyNotesDialog.open({size: 'lg'}).subscribe(
943             modified => {
944                 if (modified) {
945                     this.hardRefresh();
946                 }
947             }
948         );
949     }
950
951     openReplaceBarcodeDialog(rows: HoldingsEntry[]) {
952         const ids = this.selectedCopyIds(rows);
953         if (ids.length === 0) { return; }
954         this.replaceBarcode.copyIds = ids;
955         this.replaceBarcode.open({}).subscribe(
956             modified => {
957                 if (modified) {
958                     this.hardRefresh();
959                 }
960             }
961         );
962     }
963
964     // mode 'callNums' -- only delete empty call numbers
965     // mode 'copies' -- only delete selected copies
966     // mode 'both' -- delete selected copies and selected call numbers, plus all
967     // copies linked to selected call numbers, regardless of whether they are selected.
968     deleteHoldings(rows: HoldingsEntry[], mode: 'callNums' | 'copies' | 'both') {
969         const callNumHash: any = {};
970
971         if (mode === 'callNums' || mode === 'both') {
972             // Collect the call numbers to be deleted.
973             rows.filter(r => r.treeNode.nodeType === 'callNum').forEach(r => {
974                 const callNum = this.idl.clone(r.callNum);
975                 if (mode === 'callNums') {
976                     if (callNum.copies().length > 0) {
977                         // cannot delete non-empty call number in this mode.
978                         return;
979                     }
980                 } else {
981                     callNum.copies().forEach(c => c.isdeleted(true));
982                 }
983                 callNum.isdeleted(true);
984                 callNumHash[callNum.id()] = callNum;
985             });
986         }
987
988         if (mode === 'copies' || mode === 'both') {
989             // Collect the copies to be deleted, including their call numbers
990             // since the API expects fleshed call number objects.
991             rows.filter(r => r.treeNode.nodeType === 'copy').forEach(r => {
992                 const callNum = r.treeNode.parentNode.target;
993                 if (!callNumHash[callNum.id()]) {
994                     callNumHash[callNum.id()] = this.idl.clone(callNum);
995                     callNumHash[callNum.id()].copies([]);
996                 }
997                 const copy = this.idl.clone(r.copy);
998                 copy.isdeleted(true);
999                 callNumHash[callNum.id()].copies().push(copy);
1000             });
1001         }
1002
1003         if (Object.keys(callNumHash).length === 0) {
1004             // No data to process.
1005             return;
1006         }
1007
1008         // Note forceDeleteCopies should not be necessary here, since we
1009         // manually marked all copies as deleted on deleted call numbers in
1010         // "both" mode.
1011         this.deleteHolding.forceDeleteCopies = mode === 'both';
1012         this.deleteHolding.callNums = Object.values(callNumHash);
1013         this.deleteHolding.open({size: 'sm'}).subscribe(
1014             modified => {
1015                 if (modified) {
1016                     this.hardRefresh();
1017                 }
1018             }
1019         );
1020     }
1021
1022     requestItems(rows: HoldingsEntry[]) {
1023         const copyIds = this.selectedCopyIds(rows);
1024         if (copyIds.length === 0) { return; }
1025         const params = {target: copyIds, holdFor: 'staff'};
1026         this.router.navigate(['/staff/catalog/hold/C'], {queryParams: params});
1027     }
1028
1029     openBucketDialog(rows: HoldingsEntry[]) {
1030         const copyIds = this.selectedCopyIds(rows);
1031         if (copyIds.length > 0) {
1032             this.bucketDialog.bucketClass = 'copy';
1033             this.bucketDialog.itemIds = copyIds;
1034             this.bucketDialog.open({size: 'lg'});
1035         }
1036     }
1037
1038     openConjoinedDialog(rows: HoldingsEntry[]) {
1039         const copyIds = this.selectedCopyIds(rows);
1040         if (copyIds.length > 0) {
1041             this.conjoinedDialog.copyIds = copyIds;
1042             this.conjoinedDialog.open({size: 'sm'});
1043         }
1044     }
1045
1046     bookItems(rows: HoldingsEntry[]) {
1047         const copyIds = this.selectedCopyIds(rows);
1048         if (copyIds.length > 0) {
1049             this.router.navigate(
1050                 ['staff', 'booking', 'create_reservation', 'for_resource', rows.filter(r => Boolean(r.copy))[0].copy.barcode()]
1051             );
1052         }
1053     }
1054
1055     makeBookable(rows: HoldingsEntry[]) {
1056         const copyIds = this.selectedCopyIds(rows);
1057         if (copyIds.length > 0) {
1058             this.makeBookableDialog.copyIds = copyIds;
1059             this.makeBookableDialog.open({});
1060         }
1061     }
1062
1063     manageReservations(rows: HoldingsEntry[]) {
1064         const copyIds = this.selectedCopyIds(rows);
1065         if (copyIds.length > 0) {
1066             this.router.navigate(
1067                 ['staff', 'booking', 'manage_reservations', 'by_resource', rows.filter(r => Boolean(r.copy))[0].copy.barcode()]
1068             );
1069         }
1070     }
1071
1072     transferSelectedItems(rows: HoldingsEntry[]) {
1073         if (rows.length === 0) { return; }
1074
1075         const cnId =
1076             this.localStore.getLocalItem('eg.cat.transfer_target_vol');
1077
1078         const orgId =
1079             this.localStore.getLocalItem('eg.cat.transfer_target_lib');
1080
1081         const recId =
1082             this.localStore.getLocalItem('eg.cat.transfer_target_record');
1083
1084         let promise;
1085
1086         if (cnId) { // Direct call number transfer
1087
1088             const itemIds = this.selectedCopyIds(rows);
1089             promise = this.transferItems.transferItems(itemIds, cnId);
1090
1091         } else if (orgId && recId) { // "Auto" transfer
1092
1093             // Clone the items to be modified to avoid any unexpected
1094             // modifications and fesh the call numbers.
1095             const items = this.idl.clone(this.selectedCopies(rows));
1096             items.forEach(i => i.call_number(
1097                 this.treeNodeCache.callNum[i.call_number()].target));
1098
1099             console.log(items);
1100             promise = this.transferItems.autoTransferItems(items, recId, orgId);
1101
1102         } else {
1103             promise = this.transferAlert.open().toPromise();
1104         }
1105
1106         promise.then(success => success ?  this.hardRefresh() : null);
1107     }
1108
1109     transferSelectedHoldings(rows: HoldingsEntry[]) {
1110         const callNums = this.selectedCallNums(rows);
1111         if (callNums.length === 0) { return; }
1112
1113         const orgId =
1114             this.localStore.getLocalItem('eg.cat.transfer_target_lib');
1115
1116         let recId =
1117             this.localStore.getLocalItem('eg.cat.transfer_target_record');
1118
1119         if (orgId) {
1120             // When transferring holdings (call numbers) between org units,
1121             // limit transfers to within the current record.
1122             recId = this.recordId;
1123
1124         } else if (!recId) {
1125             // No destinations applied.
1126             return this.transferAlert.open();
1127         }
1128
1129         this.transferHoldings.targetRecId = recId;
1130         this.transferHoldings.targetOrgId = orgId;
1131         this.transferHoldings.callNums = callNums;
1132
1133         this.transferHoldings.transferHoldings()
1134         .then(success => success ?  this.hardRefresh() : null);
1135     }
1136 }
1137