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