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