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