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