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