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