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