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