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