]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.ts
LP1821382 Angular holdings maintenance continued.
[Evergreen.git] / Open-ILS / src / eg2 / src / app / staff / catalog / record / holdings.component.ts
1 import {Component, OnInit, Input, ViewChild} from '@angular/core';
2 import {Observable, Observer, of} from 'rxjs';
3 import {map} from 'rxjs/operators';
4 import {Pager} from '@eg/share/util/pager';
5 import {IdlObject} from '@eg/core/idl.service';
6 import {StaffCatalogService} from '../catalog.service';
7 import {OrgService} from '@eg/core/org.service';
8 import {PcrudService} from '@eg/core/pcrud.service';
9 import {AuthService} from '@eg/core/auth.service';
10 import {GridDataSource} from '@eg/share/grid/grid';
11 import {GridComponent} from '@eg/share/grid/grid.component';
12 import {GridToolbarCheckboxComponent
13     } from '@eg/share/grid/grid-toolbar-checkbox.component';
14 import {StoreService} from '@eg/core/store.service';
15 import {ServerStoreService} from '@eg/core/server-store.service';
16 import {MarkDamagedDialogComponent
17     } from '@eg/staff/share/holdings/mark-damaged-dialog.component';
18 import {MarkMissingDialogComponent
19     } from '@eg/staff/share/holdings/mark-missing-dialog.component';
20 import {AnonCacheService} from '@eg/share/util/anon-cache.service';
21 import {HoldingsService} from '@eg/staff/share/holdings/holdings.service';
22 import {CopyAlertsDialogComponent
23     } from '@eg/staff/share/holdings/copy-alerts-dialog.component';
24 import {ReplaceBarcodeDialogComponent
25     } from '@eg/staff/share/holdings/replace-barcode-dialog.component';
26
27 // The holdings grid models a single HoldingsTree, composed of HoldingsTreeNodes
28 // flattened on-demand into a list of HoldingEntry objects.
29 class HoldingsTreeNode {
30     children: HoldingsTreeNode[];
31     nodeType: 'org' | 'volume' | 'copy';
32     target: any;
33     parentNode: HoldingsTreeNode;
34     expanded: boolean;
35     copyCount: number;
36     volumeCount: number;
37     constructor() {
38         this.children = [];
39     }
40 }
41
42 class HoldingsTree {
43     root: HoldingsTreeNode;
44     constructor() {
45         this.root = new HoldingsTreeNode();
46     }
47 }
48
49 class HoldingsEntry {
50     index: number;
51     // org unit shortname, call number label, or copy barcode
52     locationLabel: string;
53     // location label indentation depth
54     locationDepth: number | null;
55     volumeCount: number | null;
56     copyCount: number | null;
57     callNumberLabel: string;
58     copy: IdlObject;
59     volume: IdlObject;
60     circ: IdlObject;
61     treeNode: HoldingsTreeNode;
62 }
63
64 @Component({
65   selector: 'eg-holdings-maintenance',
66   templateUrl: 'holdings.component.html',
67   styleUrls: ['holdings.component.css']
68 })
69 export class HoldingsMaintenanceComponent implements OnInit {
70
71     initDone = false;
72     gridDataSource: GridDataSource;
73     gridTemplateContext: any;
74     @ViewChild('holdingsGrid') holdingsGrid: GridComponent;
75
76     // Manage visibility of various sub-sections
77     @ViewChild('volsCheckbox')
78         private volsCheckbox: GridToolbarCheckboxComponent;
79     @ViewChild('copiesCheckbox')
80         private copiesCheckbox: GridToolbarCheckboxComponent;
81     @ViewChild('emptyVolsCheckbox')
82         private emptyVolsCheckbox: GridToolbarCheckboxComponent;
83     @ViewChild('emptyLibsCheckbox')
84         private emptyLibsCheckbox: GridToolbarCheckboxComponent;
85     @ViewChild('markDamagedDialog')
86         private markDamagedDialog: MarkDamagedDialogComponent;
87     @ViewChild('markMissingDialog')
88         private markMissingDialog: MarkMissingDialogComponent;
89     @ViewChild('copyAlertsDialog')
90         private copyAlertsDialog: CopyAlertsDialogComponent;
91     @ViewChild('replaceBarcode')
92         private replaceBarcode: ReplaceBarcodeDialogComponent;
93
94     holdingsTree: HoldingsTree;
95
96     // nodeType => id => tree node cache
97     treeNodeCache: {[nodeType: string]: {[id: number]: HoldingsTreeNode}};
98
99     // When true and a grid reload is called, the holdings data will be
100     // re-fetched from the server.
101     refreshHoldings: boolean;
102
103     // Used as a row identifier in th grid, since we're mixing object types.
104     gridIndex: number;
105
106     // List of copies whose due date we need to retrieve.
107     itemCircsNeeded: IdlObject[];
108
109     // When true draw the grid based on the stored preferences.
110     // When not true, render based on the current "expanded" state of each node.
111     // Rendering from prefs happens on initial load and when any prefs change.
112     renderFromPrefs: boolean;
113
114     rowClassCallback: (row: any) => string;
115
116     private _recId: number;
117     @Input() set recordId(id: number) {
118         this._recId = id;
119         // Only force new data collection when recordId()
120         // is invoked after ngInit() has already run.
121         if (this.initDone) {
122             this.hardRefresh();
123         }
124     }
125     get recordId(): number {
126         return this._recId;
127     }
128
129     contextOrg: IdlObject;
130
131     constructor(
132         private org: OrgService,
133         private pcrud: PcrudService,
134         private auth: AuthService,
135         private staffCat: StaffCatalogService,
136         private store: ServerStoreService,
137         private localStore: StoreService,
138         private holdings: HoldingsService,
139         private anonCache: AnonCacheService
140     ) {
141         // Set some sane defaults before settings are loaded.
142         this.gridDataSource = new GridDataSource();
143         this.refreshHoldings = true;
144         this.renderFromPrefs = true;
145
146         // TODO: need a separate setting for this?
147         this.contextOrg = this.staffCat.searchContext.searchOrg;
148
149         this.rowClassCallback = (row: any): string => {
150             if (row.volume) {
151                 if (row.copy) {
152                     return 'holdings-copy-row';
153                 } else {
154                     return 'holdings-volume-row';
155                 }
156             } else {
157                 // Add a generic org unit class and a depth-specific
158                 // class for styling different levels of the org tree.
159                 return 'holdings-org-row holdings-org-row-' +
160                     row.treeNode.target.ou_type().depth();
161             }
162         }
163
164         this.gridTemplateContext = {
165             toggleExpandRow: (row: HoldingsEntry) => {
166                 row.treeNode.expanded = !row.treeNode.expanded;
167
168                 if (!row.treeNode.expanded) {
169                     // When collapsing a node, all child nodes should be
170                     // collapsed as well.
171                     const traverse = (node: HoldingsTreeNode) => {
172                         node.expanded = false;
173                         node.children.forEach(traverse);
174                     }
175                     traverse(row.treeNode);
176                 }
177
178                 this.holdingsGrid.reload();
179             },
180
181             copyIsHoldable: (copy: IdlObject): boolean => {
182                 return copy.holdable() === 't'
183                     && copy.location().holdable() === 't'
184                     && copy.status().holdable() === 't';
185             }
186         }
187     }
188
189     ngOnInit() {
190         this.initDone = true;
191
192         // These are pre-cached via the catalog resolver.
193         const settings = this.store.getItemBatchCached([
194             'cat.holdings_show_empty_org',
195             'cat.holdings_show_empty',
196             'cat.holdings_show_copies',
197             'cat.holdings_show_vols'
198         ]);
199
200         // Show volumes by default when no preference is set.
201         let showVols = settings['cat.holdings_show_vols'];
202         if (showVols === null) { showVols = true; }
203
204         this.volsCheckbox.checked(showVols);
205         this.copiesCheckbox.checked(settings['cat.holdings_show_copies']);
206         this.emptyVolsCheckbox.checked(settings['cat.holdings_show_empty']);
207         this.emptyLibsCheckbox.checked(settings['cat.holdings_show_empty_org']);
208
209         this.initHoldingsTree();
210         this.gridDataSource.getRows = (pager: Pager, sort: any[]) => {
211             return this.fetchHoldings(pager);
212         };
213     }
214
215     contextOrgChanged(org: IdlObject) {
216         this.contextOrg = org;
217         this.hardRefresh();
218     }
219
220     hardRefresh() {
221         this.renderFromPrefs = true;
222         this.refreshHoldings = true;
223         this.initHoldingsTree();
224         this.holdingsGrid.reload();
225     }
226
227     toggleShowCopies(value: boolean) {
228         this.store.setItem('cat.holdings_show_copies', value);
229         if (value) {
230             // Showing copies implies showing volumes
231             this.volsCheckbox.checked(true);
232         }
233         this.renderFromPrefs = true;
234         this.holdingsGrid.reload();
235     }
236
237     toggleShowVolumes(value: boolean) {
238         this.store.setItem('cat.holdings_show_vols', value);
239         if (!value) {
240             // Hiding volumes implies hiding empty vols and copies.
241             this.copiesCheckbox.checked(false);
242             this.emptyVolsCheckbox.checked(false);
243         }
244         this.renderFromPrefs = true;
245         this.holdingsGrid.reload();
246     }
247
248     toggleShowEmptyVolumes(value: boolean) {
249         this.store.setItem('cat.holdings_show_empty', value);
250         if (value) {
251             this.volsCheckbox.checked(true);
252         }
253         this.renderFromPrefs = true;
254         this.holdingsGrid.reload();
255     }
256
257     toggleShowEmptyLibs(value: boolean) {
258         this.store.setItem('cat.holdings_show_empty_org', value);
259         this.renderFromPrefs = true;
260         this.holdingsGrid.reload();
261     }
262
263     onRowActivate(row: any) {
264         if (row.copy) {
265             // Launch copy editor?
266         } else {
267             this.gridTemplateContext.toggleExpandRow(row);
268         }
269     }
270
271     initHoldingsTree() {
272
273         const visibleOrgs = this.org.fullPath(this.contextOrg, true);
274
275         // The initial tree simply matches the org unit tree
276         const traverseOrg = (node: HoldingsTreeNode) => {
277             node.target.children().forEach((org: IdlObject) => {
278                 if (visibleOrgs.indexOf(org.id()) == -1) {
279                     return; // Org is outside of scope
280                 }
281                 const nodeChild = new HoldingsTreeNode();
282                 nodeChild.nodeType = 'org';
283                 nodeChild.target = org;
284                 nodeChild.parentNode = node;
285                 node.children.push(nodeChild);
286                 this.treeNodeCache.org[org.id()] = nodeChild;
287                 traverseOrg(nodeChild);
288             });
289         }
290
291         this.treeNodeCache = {
292             org: {},
293             volume: {},
294             copy: {}
295         };
296
297         this.holdingsTree = new HoldingsTree();
298         this.holdingsTree.root.nodeType = 'org';
299         this.holdingsTree.root.target = this.org.root();
300         this.treeNodeCache.org[this.org.root().id()] = this.holdingsTree.root;
301
302         traverseOrg(this.holdingsTree.root);
303     }
304
305     // Org node children are sorted with any child org nodes pushed to the
306     // front, followed by the call number nodes sorted alphabetcially by label.
307     sortOrgNodeChildren(node: HoldingsTreeNode) {
308         node.children = node.children.sort((a, b) => {
309             if (a.nodeType === 'org') {
310                 if (b.nodeType === 'org') {
311                     return a.target.shortname() < b.target.shortname() ? -1 : 1;
312                 } else {
313                     return -1;
314                 }
315             } else if (b.nodeType === 'org') {
316                 return 1;
317             } else {
318                 // TODO: should this use label sortkey instead of
319                 // the compiled volume label?
320                 return a.target._label < b.target._label ? -1 : 1;
321             }
322         });
323     }
324
325     // Sets call number and copy count sums to nodes that need it.
326     // Applies the initial expansed state of each container node.
327     setTreeCounts(node: HoldingsTreeNode) {
328
329         if (node.nodeType === 'org') {
330             node.copyCount = 0;
331             node.volumeCount = 0;
332         } else if(node.nodeType === 'volume') {
333             node.copyCount = 0;
334         }
335
336         let hasChildOrgWithData = false;
337         let hasChildOrgSansData = false;
338         node.children.forEach(child => {
339             this.setTreeCounts(child);
340             if (node.nodeType === 'org') {
341                 node.copyCount += child.copyCount;
342                 if (child.nodeType === 'volume') {
343                     node.volumeCount++;
344                 } else {
345                     hasChildOrgWithData = child.volumeCount > 0;
346                     hasChildOrgSansData = child.volumeCount === 0;
347                     node.volumeCount += child.volumeCount;
348                 }
349             } else if (node.nodeType === 'volume') {
350                 node.copyCount = node.children.length;
351                 if (this.renderFromPrefs) {
352                     node.expanded = this.copiesCheckbox.checked();
353                 }
354             }
355         });
356
357         if (this.renderFromPrefs && node.nodeType === 'org') {
358             if (node.copyCount > 0 && this.volsCheckbox.checked()) {
359                 node.expanded = true;
360             } else if (node.volumeCount > 0 && this.emptyVolsCheckbox.checked()) {
361                 node.expanded = true;
362             } else if (hasChildOrgWithData) {
363                 node.expanded = true;
364             } else if (hasChildOrgSansData && this.emptyLibsCheckbox.checked()) {
365                 node.expanded = true;
366             } else {
367                 node.expanded = false;
368             }
369         }
370     }
371
372     // Create HoldingsEntry objects for tree nodes that should be displayed
373     // and relays them to the grid via the observer.
374     propagateTreeEntries(observer: Observer<HoldingsEntry>, node: HoldingsTreeNode) {
375         const entry = new HoldingsEntry();
376         entry.treeNode = node;
377         entry.index = this.gridIndex++;
378
379         switch(node.nodeType) {
380             case 'org':
381                 if (node.volumeCount === 0
382                     && !this.emptyLibsCheckbox.checked()) {
383                     return;
384                 }
385                 entry.locationLabel = node.target.shortname();
386                 entry.locationDepth = node.target.ou_type().depth();
387                 entry.copyCount = node.copyCount;
388                 entry.volumeCount = node.volumeCount;
389                 this.sortOrgNodeChildren(node);
390                 break;
391
392             case 'volume':
393                 if (this.renderFromPrefs) {
394                     if (!this.volsCheckbox.checked()) {
395                         return;
396                     }
397                     if (node.copyCount === 0
398                         && !this.emptyVolsCheckbox.checked()) {
399                         return;
400                     }
401                 }
402                 entry.locationLabel = node.target._label;
403                 entry.locationDepth = node.parentNode.target.ou_type().depth() + 1;
404                 entry.callNumberLabel = entry.locationLabel;
405                 entry.volume = node.target;
406                 entry.copyCount = node.copyCount;
407                 break;
408
409             case 'copy':
410                 entry.locationLabel = node.target.barcode();
411                 entry.locationDepth = node.parentNode.parentNode.target.ou_type().depth() + 2;
412                 entry.callNumberLabel = node.parentNode.target.label() // TODO
413                 entry.volume = node.parentNode.target;
414                 entry.copy = node.target;
415                 entry.circ = node.target._circ;
416                 break;
417         }
418
419         // Tell the grid about the node entry
420         observer.next(entry);
421
422         if (node.expanded) {
423             // Process the child nodes.
424             node.children.forEach(child =>
425                 this.propagateTreeEntries(observer, child));
426         }
427     }
428
429     // Turns the tree into a list of entries for grid display
430     flattenHoldingsTree(observer: Observer<HoldingsEntry>) {
431         this.gridIndex = 0;
432         this.setTreeCounts(this.holdingsTree.root);
433         this.propagateTreeEntries(observer, this.holdingsTree.root);
434         observer.complete();
435         this.renderFromPrefs = false;
436     }
437
438     // Grab volumes, copies, and related data.
439     fetchHoldings(pager: Pager): Observable<any> {
440         if (!this.recordId) { return of([]); }
441
442         return new Observable<any>(observer => {
443
444             if (!this.refreshHoldings) {
445                 this.flattenHoldingsTree(observer);
446                 return;
447             }
448
449             this.itemCircsNeeded = [];
450
451             this.pcrud.search('acn',
452                 {   record: this.recordId,
453                     owning_lib: this.org.fullPath(this.contextOrg, true),
454                     deleted: 'f',
455                     label: {'!=' : '##URI##'}
456                 }, {
457                     flesh: 3,
458                     flesh_fields: {
459                         acp: ['status', 'location', 'circ_lib', 'parts',
460                             'age_protect', 'copy_alerts', 'latest_inventory'],
461                         acn: ['prefix', 'suffix', 'copies'],
462                         acli: ['inventory_workstation']
463                     }
464                 },
465                 {authoritative: true}
466             ).subscribe(
467                 vol => this.appendVolume(vol),
468                 err => {},
469                 ()  => {
470                     this.refreshHoldings = false;
471                     this.fetchCircs().then(
472                         ok => this.flattenHoldingsTree(observer)
473                     );
474                 }
475             );
476         });
477     }
478
479     // Retrieve circulation objects for checked out items.
480     fetchCircs(): Promise<any> {
481         const copyIds = this.itemCircsNeeded.map(copy => copy.id());
482         if (copyIds.length === 0) { return Promise.resolve(); }
483
484         return this.pcrud.search('circ', {
485             target_copy: copyIds,
486             checkin_time: null
487         }).pipe(map(circ => {
488             const copy = this.itemCircsNeeded.filter(
489                 c => Number(c.id()) === Number(circ.target_copy()))[0];
490             copy._circ = circ;
491         })).toPromise();
492     }
493
494     // Compile prefix + label + suffix into field volume._label;
495     setVolumeLabel(volume: IdlObject) {
496         const pfx = volume.prefix() ? volume.prefix().label() : '';
497         const sfx = volume.suffix() ? volume.suffix().label() : '';
498         volume._label = pfx ? pfx + ' ' : '';
499         volume._label += volume.label();
500         volume._label += sfx ? ' ' + sfx : '';
501     }
502
503     // Create the tree node for the volume if it doesn't already exist.
504     // Do the same for its linked copies.
505     appendVolume(volume: IdlObject) {
506         let volNode = this.treeNodeCache.volume[volume.id()];
507         this.setVolumeLabel(volume);
508
509         if (volNode) {
510             const pNode = this.treeNodeCache.org[volume.owning_lib()]
511             if (volNode.parentNode.target.id() !== pNode.target.id()) {
512                 // Volume owning library changed.  Un-link it from the previous
513                 // org unit collection before adding to the new one.
514                 // XXX TODO: ^--
515                 volNode.parentNode = pNode;
516                 volNode.parentNode.children.push(volNode);
517             }
518         } else {
519             volNode = new HoldingsTreeNode();
520             volNode.nodeType = 'volume';
521             volNode.parentNode = this.treeNodeCache.org[volume.owning_lib()]
522             volNode.parentNode.children.push(volNode);
523             this.treeNodeCache.volume[volume.id()] = volNode;
524         }
525
526         volNode.target = volume;
527
528         volume.copies()
529             .sort((a: IdlObject, b: IdlObject) => a.barcode() < b.barcode() ? -1 : 1)
530             .forEach((copy: IdlObject) => this.appendCopy(volNode, copy));
531     }
532
533     // Find or create a copy node.
534     appendCopy(volNode: HoldingsTreeNode, copy: IdlObject) {
535         let copyNode = this.treeNodeCache.copy[copy.id()];
536
537         if (copyNode) {
538             const oldParent = copyNode.parentNode;
539             if (oldParent.target.id() !== volNode.target.id()) {
540                 // TODO: copy changed owning volume.  Remove it from
541                 // the previous volume before adding to the new volume.
542                 copyNode.parentNode = volNode;
543                 volNode.children.push(copyNode);
544             }
545         } else {
546             // New node required
547             copyNode = new HoldingsTreeNode();
548             copyNode.nodeType = 'copy';
549             volNode.children.push(copyNode);
550             copyNode.parentNode = volNode;
551             this.treeNodeCache.copy[copy.id()] = copyNode;
552         }
553
554         copyNode.target = copy;
555         const stat = Number(copy.status().id());
556
557         if (stat === 1 /* checked out */ || stat === 16 /* long overdue */) {
558             // Avoid looking up circs on items that are not checked out.
559             this.itemCircsNeeded.push(copy);
560         }
561     }
562
563     // Which copies in the grid are selected.
564     selectedCopyIds(rows: HoldingsEntry[], skipStatus?: number): number[] {
565         let copyRows = rows.filter(r => Boolean(r.copy)).map(r => r.copy);
566         if (skipStatus) {
567             copyRows = copyRows.filter(
568                 c => Number(c.status().id()) !== Number(skipStatus));
569         }
570         return copyRows.map(c => Number(c.id()));
571     }
572
573     selectedVolumeIds(rows: HoldingsEntry[]): number[] {
574         return rows
575             .filter(r => r.treeNode.nodeType === 'volume')
576             .map(r => Number(r.volume.id()));
577     }
578
579     async showMarkDamagedDialog(rows: HoldingsEntry[]) {
580         const copyIds = this.selectedCopyIds(rows, 14 /* ignore damaged */);
581
582         if (copyIds.length === 0) { return; }
583
584         let rowsModified = false;
585
586         const markNext = async(ids: number[]) => {
587             if (ids.length === 0) {
588                 return Promise.resolve();
589             }
590
591             this.markDamagedDialog.copyId = ids.pop();
592             return this.markDamagedDialog.open({size: 'lg'}).then(
593                 ok => {
594                     if (ok) { rowsModified = true; }
595                     return markNext(ids);
596                 },
597                 dismiss => markNext(ids)
598             );
599         };
600
601         await markNext(copyIds);
602         if (rowsModified) {
603             this.refreshHoldings = true;
604             this.holdingsGrid.reload();
605         }
606     }
607
608     showMarkMissingDialog(rows: any[]) {
609         const copyIds = this.selectedCopyIds(rows, 4 /* ignore missing */);
610         if (copyIds.length > 0) {
611             this.markMissingDialog.copyIds = copyIds;
612             this.markMissingDialog.open({}).then(
613                 rowsModified => {
614                     if (rowsModified) {
615                         this.refreshHoldings = true;
616                         this.holdingsGrid.reload();
617                     }
618                 },
619                 dismissed => {} // avoid console errors
620             );
621         }
622     }
623
624     // Mark record, library, and potentially the selected call number
625     // as the current transfer target.
626     markLibCnForTransfer(rows: HoldingsEntry[]) {
627         if (rows.length === 0) {
628             return;
629         }
630
631         // Action may only apply to a single org or volume row.
632         const node = rows[0].treeNode;
633         if (node.nodeType === 'copy') {
634             return;
635         }
636
637         let orgId: number;
638
639         if (node.nodeType === 'org') {
640             orgId = node.target.id();
641
642             // Clear volume target when performed on an org unit row
643             this.localStore.removeLocalItem('eg.cat.transfer_target_vol');
644
645         } else if (node.nodeType === 'volume') {
646
647             // All volume nodes are children of org nodes.
648             orgId = node.parentNode.target.id();
649
650             // Add volume target when performed on a volume row.
651             this.localStore.setLocalItem(
652                 'eg.cat.transfer_target_vol', node.target.id())
653         }
654
655         this.localStore.setLocalItem('eg.cat.transfer_target_record', this.recordId);
656         this.localStore.setLocalItem('eg.cat.transfer_target_lib', orgId);
657     }
658
659     openAngJsWindow(path: string) {
660         const url = `/eg/staff/${path}`;
661         window.open(url, '_blank');
662     }
663
664     openItemHolds(rows: HoldingsEntry[]) {
665         if (rows.length > 0 && rows[0].copy) {
666             this.openAngJsWindow(`cat/item/${rows[0].copy.id()}/holds`);
667         }
668     }
669
670     openItemStatusList(rows: HoldingsEntry[]) {
671         const ids = this.selectedCopyIds(rows);
672         if (ids.length > 0) {
673             return this.openAngJsWindow(`cat/item/search/${ids.join(',')}`);
674         }
675     }
676
677     openItemStatus(rows: HoldingsEntry[]) {
678         if (rows.length > 0 && rows[0].copy) {
679            return this.openAngJsWindow(`cat/item/${rows[0].copy.id()}`);
680         }
681     }
682
683     openItemTriggeredEvents(rows: HoldingsEntry[]) {
684         if (rows.length > 0 && rows[0].copy) {
685            return this.openAngJsWindow(
686                `cat/item/${rows[0].copy.id()}/triggered_events`);
687         }
688     }
689
690     openItemPrintLabels(rows: HoldingsEntry[]) {
691         const ids = this.selectedCopyIds(rows);
692         if (ids.length === 0) { return; }
693
694         this.anonCache.setItem(null, 'print-labels-these-copies', {copies: ids})
695         .then(key => this.openAngJsWindow(`cat/printlabels/${key}`));
696     }
697
698     openVolCopyEdit(rows: HoldingsEntry[], addVols: boolean, addCopies: boolean) {
699
700         // The user may select a set of volumes by selecting volume and/or
701         // copy rows.
702         const volumes = [];
703         rows.forEach(r => {
704             if (r.treeNode.nodeType === 'volume') {
705                 volumes.push(r.volume);
706             } else if (r.treeNode.nodeType === 'copy') {
707                 volumes.push(r.treeNode.parentNode.target);
708             }
709         });
710
711         if (addCopies && !addVols) {
712             // Adding copies to an existing set of volumes.
713             if (volumes.length > 0) {
714                 const volIds = volumes.map(v => Number(v.id()));
715                 this.holdings.spawnAddHoldingsUi(this.recordId, volIds);
716             }
717
718         } else if (addVols) {
719             const entries = [];
720
721             if (volumes.length > 0) {
722
723                 // When adding volumes, if any are selected in the grid,
724                 // create volumes that have the same label and owner.
725                 volumes.forEach(v =>
726                     entries.push({label: v.label(), owner: v.owning_lib()}));
727
728                 } else {
729
730                 // Otherwise create new volumes from scratch.
731                 entries.push({owner: this.auth.user().ws_ou()})
732             }
733
734             this.holdings.spawnAddHoldingsUi(
735                 this.recordId, null, entries, !addCopies);
736         }
737     }
738
739     openItemNotes(rows: HoldingsEntry[], mode: string) {
740         const copyIds = this.selectedCopyIds(rows);
741         if (copyIds.length === 0) { return; }
742
743         this.copyAlertsDialog.copyIds = copyIds;
744         this.copyAlertsDialog.mode = mode;
745         this.copyAlertsDialog.open({size: 'lg'}).then(
746             modified => {
747                 if (modified) {
748                     this.hardRefresh();
749                 }
750             },
751             dismissed => {}
752         )
753     }
754
755     openReplaceBarcodeDialog(rows: HoldingsEntry[]) {
756         const ids = this.selectedCopyIds(rows);
757         if (ids.length === 0) { return; }
758         this.replaceBarcode.copyIds = ids;
759         this.replaceBarcode.open({}).then(
760             modified => {
761                 if (modified) {
762                     this.hardRefresh();
763                 }
764             },
765             dismissed => {}
766         );
767     }
768 }