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