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