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