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