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