1 import {Component, OnInit, Input, ViewChild} from '@angular/core';
2 import {Router} from '@angular/router';
3 import {Observable, Observer, of} from 'rxjs';
4 import {map} from 'rxjs/operators';
5 import {Pager} from '@eg/share/util/pager';
6 import {IdlObject, IdlService} from '@eg/core/idl.service';
7 import {StaffCatalogService} from '../catalog.service';
8 import {OrgService} from '@eg/core/org.service';
9 import {PcrudService} from '@eg/core/pcrud.service';
10 import {AuthService} from '@eg/core/auth.service';
11 import {GridDataSource} from '@eg/share/grid/grid';
12 import {GridComponent} from '@eg/share/grid/grid.component';
13 import {GridToolbarCheckboxComponent
14 } from '@eg/share/grid/grid-toolbar-checkbox.component';
15 import {StoreService} from '@eg/core/store.service';
16 import {ServerStoreService} from '@eg/core/server-store.service';
17 import {MarkDamagedDialogComponent
18 } from '@eg/staff/share/holdings/mark-damaged-dialog.component';
19 import {MarkMissingDialogComponent
20 } from '@eg/staff/share/holdings/mark-missing-dialog.component';
21 import {AnonCacheService} from '@eg/share/util/anon-cache.service';
22 import {HoldingsService} from '@eg/staff/share/holdings/holdings.service';
23 import {CopyAlertsDialogComponent
24 } from '@eg/staff/share/holdings/copy-alerts-dialog.component';
25 import {ReplaceBarcodeDialogComponent
26 } from '@eg/staff/share/holdings/replace-barcode-dialog.component';
27 import {DeleteVolcopyDialogComponent
28 } from '@eg/staff/share/holdings/delete-volcopy-dialog.component';
30 // The holdings grid models a single HoldingsTree, composed of HoldingsTreeNodes
31 // flattened on-demand into a list of HoldingEntry objects.
32 class HoldingsTreeNode {
33 children: HoldingsTreeNode[];
34 nodeType: 'org' | 'volume' | 'copy';
36 parentNode: HoldingsTreeNode;
46 root: HoldingsTreeNode;
48 this.root = new HoldingsTreeNode();
54 // org unit shortname, call number label, or copy barcode
55 locationLabel: string;
56 // location label indentation depth
57 locationDepth: number | null;
58 volumeCount: number | null;
59 copyCount: number | null;
60 callNumberLabel: string;
64 treeNode: HoldingsTreeNode;
68 selector: 'eg-holdings-maintenance',
69 templateUrl: 'holdings.component.html',
70 styleUrls: ['holdings.component.css']
72 export class HoldingsMaintenanceComponent implements OnInit {
75 gridDataSource: GridDataSource;
76 gridTemplateContext: any;
77 @ViewChild('holdingsGrid') holdingsGrid: GridComponent;
79 // Manage visibility of various sub-sections
80 @ViewChild('volsCheckbox')
81 private volsCheckbox: GridToolbarCheckboxComponent;
82 @ViewChild('copiesCheckbox')
83 private copiesCheckbox: GridToolbarCheckboxComponent;
84 @ViewChild('emptyVolsCheckbox')
85 private emptyVolsCheckbox: GridToolbarCheckboxComponent;
86 @ViewChild('emptyLibsCheckbox')
87 private emptyLibsCheckbox: GridToolbarCheckboxComponent;
88 @ViewChild('markDamagedDialog')
89 private markDamagedDialog: MarkDamagedDialogComponent;
90 @ViewChild('markMissingDialog')
91 private markMissingDialog: MarkMissingDialogComponent;
92 @ViewChild('copyAlertsDialog')
93 private copyAlertsDialog: CopyAlertsDialogComponent;
94 @ViewChild('replaceBarcode')
95 private replaceBarcode: ReplaceBarcodeDialogComponent;
96 @ViewChild('deleteVolcopy')
97 private deleteVolcopy: DeleteVolcopyDialogComponent;
99 holdingsTree: HoldingsTree;
101 // nodeType => id => tree node cache
102 treeNodeCache: {[nodeType: string]: {[id: number]: HoldingsTreeNode}};
104 // When true and a grid reload is called, the holdings data will be
105 // re-fetched from the server.
106 refreshHoldings: boolean;
108 // Used as a row identifier in th grid, since we're mixing object types.
111 // List of copies whose due date we need to retrieve.
112 itemCircsNeeded: IdlObject[];
114 // When true draw the grid based on the stored preferences.
115 // When not true, render based on the current "expanded" state of each node.
116 // Rendering from prefs happens on initial load and when any prefs change.
117 renderFromPrefs: boolean;
119 rowClassCallback: (row: any) => string;
121 private _recId: number;
122 @Input() set recordId(id: number) {
124 // Only force new data collection when recordId()
125 // is invoked after ngInit() has already run.
130 get recordId(): number {
134 contextOrg: IdlObject;
137 private router: Router,
138 private org: OrgService,
139 private idl: IdlService,
140 private pcrud: PcrudService,
141 private auth: AuthService,
142 private staffCat: StaffCatalogService,
143 private store: ServerStoreService,
144 private localStore: StoreService,
145 private holdings: HoldingsService,
146 private anonCache: AnonCacheService
148 // Set some sane defaults before settings are loaded.
149 this.gridDataSource = new GridDataSource();
150 this.refreshHoldings = true;
151 this.renderFromPrefs = true;
153 // TODO: need a separate setting for this?
154 this.contextOrg = this.staffCat.searchContext.searchOrg;
156 this.rowClassCallback = (row: any): string => {
159 return 'holdings-copy-row';
161 return 'holdings-volume-row';
164 // Add a generic org unit class and a depth-specific
165 // class for styling different levels of the org tree.
166 return 'holdings-org-row holdings-org-row-' +
167 row.treeNode.target.ou_type().depth();
171 this.gridTemplateContext = {
172 toggleExpandRow: (row: HoldingsEntry) => {
173 row.treeNode.expanded = !row.treeNode.expanded;
175 if (!row.treeNode.expanded) {
176 // When collapsing a node, all child nodes should be
177 // collapsed as well.
178 const traverse = (node: HoldingsTreeNode) => {
179 node.expanded = false;
180 node.children.forEach(traverse);
182 traverse(row.treeNode);
185 this.holdingsGrid.reload();
188 copyIsHoldable: (copy: IdlObject): boolean => {
189 return copy.holdable() === 't'
190 && copy.location().holdable() === 't'
191 && copy.status().holdable() === 't';
197 this.initDone = true;
199 // These are pre-cached via the catalog resolver.
200 const settings = this.store.getItemBatchCached([
201 'cat.holdings_show_empty_org',
202 'cat.holdings_show_empty',
203 'cat.holdings_show_copies',
204 'cat.holdings_show_vols'
207 // Show volumes by default when no preference is set.
208 let showVols = settings['cat.holdings_show_vols'];
209 if (showVols === null) { showVols = true; }
211 this.volsCheckbox.checked(showVols);
212 this.copiesCheckbox.checked(settings['cat.holdings_show_copies']);
213 this.emptyVolsCheckbox.checked(settings['cat.holdings_show_empty']);
214 this.emptyLibsCheckbox.checked(settings['cat.holdings_show_empty_org']);
216 this.initHoldingsTree();
217 this.gridDataSource.getRows = (pager: Pager, sort: any[]) => {
218 return this.fetchHoldings(pager);
222 contextOrgChanged(org: IdlObject) {
223 this.contextOrg = org;
228 this.renderFromPrefs = true;
229 this.refreshHoldings = true;
230 this.initHoldingsTree();
231 this.holdingsGrid.reload();
234 toggleShowCopies(value: boolean) {
235 this.store.setItem('cat.holdings_show_copies', value);
237 // Showing copies implies showing volumes
238 this.volsCheckbox.checked(true);
240 this.renderFromPrefs = true;
241 this.holdingsGrid.reload();
244 toggleShowVolumes(value: boolean) {
245 this.store.setItem('cat.holdings_show_vols', value);
247 // Hiding volumes implies hiding empty vols and copies.
248 this.copiesCheckbox.checked(false);
249 this.emptyVolsCheckbox.checked(false);
251 this.renderFromPrefs = true;
252 this.holdingsGrid.reload();
255 toggleShowEmptyVolumes(value: boolean) {
256 this.store.setItem('cat.holdings_show_empty', value);
258 this.volsCheckbox.checked(true);
260 this.renderFromPrefs = true;
261 this.holdingsGrid.reload();
264 toggleShowEmptyLibs(value: boolean) {
265 this.store.setItem('cat.holdings_show_empty_org', value);
266 this.renderFromPrefs = true;
267 this.holdingsGrid.reload();
270 onRowActivate(row: any) {
272 // Launch copy editor?
274 this.gridTemplateContext.toggleExpandRow(row);
280 const visibleOrgs = this.org.fullPath(this.contextOrg, true);
282 // The initial tree simply matches the org unit tree
283 const traverseOrg = (node: HoldingsTreeNode) => {
284 node.target.children().forEach((org: IdlObject) => {
285 if (visibleOrgs.indexOf(org.id()) === -1) {
286 return; // Org is outside of scope
288 const nodeChild = new HoldingsTreeNode();
289 nodeChild.nodeType = 'org';
290 nodeChild.target = org;
291 nodeChild.parentNode = node;
292 node.children.push(nodeChild);
293 this.treeNodeCache.org[org.id()] = nodeChild;
294 traverseOrg(nodeChild);
298 this.treeNodeCache = {
304 this.holdingsTree = new HoldingsTree();
305 this.holdingsTree.root.nodeType = 'org';
306 this.holdingsTree.root.target = this.org.root();
307 this.treeNodeCache.org[this.org.root().id()] = this.holdingsTree.root;
309 traverseOrg(this.holdingsTree.root);
312 // Org node children are sorted with any child org nodes pushed to the
313 // front, followed by the call number nodes sorted alphabetcially by label.
314 sortOrgNodeChildren(node: HoldingsTreeNode) {
315 node.children = node.children.sort((a, b) => {
316 if (a.nodeType === 'org') {
317 if (b.nodeType === 'org') {
318 return a.target.shortname() < b.target.shortname() ? -1 : 1;
322 } else if (b.nodeType === 'org') {
325 // TODO: should this use label sortkey instead of
326 // the compiled volume label?
327 return a.target._label < b.target._label ? -1 : 1;
332 // Sets call number and copy count sums to nodes that need it.
333 // Applies the initial expansed state of each container node.
334 setTreeCounts(node: HoldingsTreeNode) {
336 if (node.nodeType === 'org') {
338 node.volumeCount = 0;
339 } else if (node.nodeType === 'volume') {
343 let hasChildOrgWithData = false;
344 let hasChildOrgSansData = false;
345 node.children.forEach(child => {
346 this.setTreeCounts(child);
347 if (node.nodeType === 'org') {
348 node.copyCount += child.copyCount;
349 if (child.nodeType === 'volume') {
352 hasChildOrgWithData = child.volumeCount > 0;
353 hasChildOrgSansData = child.volumeCount === 0;
354 node.volumeCount += child.volumeCount;
356 } else if (node.nodeType === 'volume') {
357 node.copyCount = node.children.length;
358 if (this.renderFromPrefs) {
359 node.expanded = this.copiesCheckbox.checked();
364 if (this.renderFromPrefs && node.nodeType === 'org') {
365 if (node.copyCount > 0 && this.volsCheckbox.checked()) {
366 node.expanded = true;
367 } else if (node.volumeCount > 0 && this.emptyVolsCheckbox.checked()) {
368 node.expanded = true;
369 } else if (hasChildOrgWithData) {
370 node.expanded = true;
371 } else if (hasChildOrgSansData && this.emptyLibsCheckbox.checked()) {
372 node.expanded = true;
374 node.expanded = false;
379 // Create HoldingsEntry objects for tree nodes that should be displayed
380 // and relays them to the grid via the observer.
381 propagateTreeEntries(observer: Observer<HoldingsEntry>, node: HoldingsTreeNode) {
382 const entry = new HoldingsEntry();
383 entry.treeNode = node;
384 entry.index = this.gridIndex++;
386 switch (node.nodeType) {
388 if (node.volumeCount === 0
389 && !this.emptyLibsCheckbox.checked()) {
392 entry.locationLabel = node.target.shortname();
393 entry.locationDepth = node.target.ou_type().depth();
394 entry.copyCount = node.copyCount;
395 entry.volumeCount = node.volumeCount;
396 this.sortOrgNodeChildren(node);
400 if (this.renderFromPrefs) {
401 if (!this.volsCheckbox.checked()) {
404 if (node.copyCount === 0
405 && !this.emptyVolsCheckbox.checked()) {
409 entry.locationLabel = node.target._label;
410 entry.locationDepth = node.parentNode.target.ou_type().depth() + 1;
411 entry.callNumberLabel = entry.locationLabel;
412 entry.volume = node.target;
413 entry.copyCount = node.copyCount;
417 entry.locationLabel = node.target.barcode();
418 entry.locationDepth = node.parentNode.parentNode.target.ou_type().depth() + 2;
419 entry.callNumberLabel = node.parentNode.target.label(); // TODO
420 entry.volume = node.parentNode.target;
421 entry.copy = node.target;
422 entry.circ = node.target._circ;
426 // Tell the grid about the node entry
427 observer.next(entry);
430 // Process the child nodes.
431 node.children.forEach(child =>
432 this.propagateTreeEntries(observer, child));
436 // Turns the tree into a list of entries for grid display
437 flattenHoldingsTree(observer: Observer<HoldingsEntry>) {
439 this.setTreeCounts(this.holdingsTree.root);
440 this.propagateTreeEntries(observer, this.holdingsTree.root);
442 this.renderFromPrefs = false;
445 // Grab volumes, copies, and related data.
446 fetchHoldings(pager: Pager): Observable<any> {
447 if (!this.recordId) { return of([]); }
449 return new Observable<any>(observer => {
451 if (!this.refreshHoldings) {
452 this.flattenHoldingsTree(observer);
456 this.itemCircsNeeded = [];
458 this.pcrud.search('acn',
459 { record: this.recordId,
460 owning_lib: this.org.fullPath(this.contextOrg, true),
462 label: {'!=' : '##URI##'}
466 acp: ['status', 'location', 'circ_lib', 'parts',
467 'age_protect', 'copy_alerts', 'latest_inventory'],
468 acn: ['prefix', 'suffix', 'copies'],
469 acli: ['inventory_workstation']
472 {authoritative: true}
474 vol => this.appendVolume(vol),
477 this.refreshHoldings = false;
478 this.fetchCircs().then(
479 ok => this.flattenHoldingsTree(observer)
486 // Retrieve circulation objects for checked out items.
487 fetchCircs(): Promise<any> {
488 const copyIds = this.itemCircsNeeded.map(copy => copy.id());
489 if (copyIds.length === 0) { return Promise.resolve(); }
491 return this.pcrud.search('circ', {
492 target_copy: copyIds,
494 }).pipe(map(circ => {
495 const copy = this.itemCircsNeeded.filter(
496 c => Number(c.id()) === Number(circ.target_copy()))[0];
501 // Compile prefix + label + suffix into field volume._label;
502 setVolumeLabel(volume: IdlObject) {
503 const pfx = volume.prefix() ? volume.prefix().label() : '';
504 const sfx = volume.suffix() ? volume.suffix().label() : '';
505 volume._label = pfx ? pfx + ' ' : '';
506 volume._label += volume.label();
507 volume._label += sfx ? ' ' + sfx : '';
510 // Create the tree node for the volume if it doesn't already exist.
511 // Do the same for its linked copies.
512 appendVolume(volume: IdlObject) {
513 let volNode = this.treeNodeCache.volume[volume.id()];
514 this.setVolumeLabel(volume);
517 const pNode = this.treeNodeCache.org[volume.owning_lib()];
518 if (volNode.parentNode.target.id() !== pNode.target.id()) {
519 // Volume owning library changed. Un-link it from the previous
520 // org unit collection before adding to the new one.
522 volNode.parentNode = pNode;
523 volNode.parentNode.children.push(volNode);
526 volNode = new HoldingsTreeNode();
527 volNode.nodeType = 'volume';
528 volNode.parentNode = this.treeNodeCache.org[volume.owning_lib()];
529 volNode.parentNode.children.push(volNode);
530 this.treeNodeCache.volume[volume.id()] = volNode;
533 volNode.target = volume;
536 .filter((copy: IdlObject) => (copy.deleted() !== 't'))
537 .sort((a: IdlObject, b: IdlObject) => a.barcode() < b.barcode() ? -1 : 1)
538 .forEach((copy: IdlObject) => this.appendCopy(volNode, copy));
541 // Find or create a copy node.
542 appendCopy(volNode: HoldingsTreeNode, copy: IdlObject) {
543 let copyNode = this.treeNodeCache.copy[copy.id()];
546 const oldParent = copyNode.parentNode;
547 if (oldParent.target.id() !== volNode.target.id()) {
548 // TODO: copy changed owning volume. Remove it from
549 // the previous volume before adding to the new volume.
550 copyNode.parentNode = volNode;
551 volNode.children.push(copyNode);
555 copyNode = new HoldingsTreeNode();
556 copyNode.nodeType = 'copy';
557 volNode.children.push(copyNode);
558 copyNode.parentNode = volNode;
559 this.treeNodeCache.copy[copy.id()] = copyNode;
562 copyNode.target = copy;
563 const stat = Number(copy.status().id());
565 if (stat === 1 /* checked out */ || stat === 16 /* long overdue */) {
566 // Avoid looking up circs on items that are not checked out.
567 this.itemCircsNeeded.push(copy);
571 // Which copies in the grid are selected.
572 selectedCopyIds(rows: HoldingsEntry[], skipStatus?: number): number[] {
573 let copyRows = rows.filter(r => Boolean(r.copy)).map(r => r.copy);
575 copyRows = copyRows.filter(
576 c => Number(c.status().id()) !== Number(skipStatus));
578 return copyRows.map(c => Number(c.id()));
581 selectedVolumeIds(rows: HoldingsEntry[]): number[] {
583 .filter(r => r.treeNode.nodeType === 'volume')
584 .map(r => Number(r.volume.id()));
587 async showMarkDamagedDialog(rows: HoldingsEntry[]) {
588 const copyIds = this.selectedCopyIds(rows, 14 /* ignore damaged */);
590 if (copyIds.length === 0) { return; }
592 let rowsModified = false;
594 const markNext = async(ids: number[]) => {
595 if (ids.length === 0) {
596 return Promise.resolve();
599 this.markDamagedDialog.copyId = ids.pop();
600 return this.markDamagedDialog.open({size: 'lg'}).then(
602 if (ok) { rowsModified = true; }
603 return markNext(ids);
605 dismiss => markNext(ids)
609 await markNext(copyIds);
611 this.refreshHoldings = true;
612 this.holdingsGrid.reload();
616 showMarkMissingDialog(rows: any[]) {
617 const copyIds = this.selectedCopyIds(rows, 4 /* ignore missing */);
618 if (copyIds.length > 0) {
619 this.markMissingDialog.copyIds = copyIds;
620 this.markMissingDialog.open({}).then(
623 this.refreshHoldings = true;
624 this.holdingsGrid.reload();
627 dismissed => {} // avoid console errors
632 // Mark record, library, and potentially the selected call number
633 // as the current transfer target.
634 markLibCnForTransfer(rows: HoldingsEntry[]) {
635 if (rows.length === 0) {
639 // Action may only apply to a single org or volume row.
640 const node = rows[0].treeNode;
641 if (node.nodeType === 'copy') {
647 if (node.nodeType === 'org') {
648 orgId = node.target.id();
650 // Clear volume target when performed on an org unit row
651 this.localStore.removeLocalItem('eg.cat.transfer_target_vol');
653 } else if (node.nodeType === 'volume') {
655 // All volume nodes are children of org nodes.
656 orgId = node.parentNode.target.id();
658 // Add volume target when performed on a volume row.
659 this.localStore.setLocalItem(
660 'eg.cat.transfer_target_vol', node.target.id());
663 this.localStore.setLocalItem('eg.cat.transfer_target_record', this.recordId);
664 this.localStore.setLocalItem('eg.cat.transfer_target_lib', orgId);
667 openAngJsWindow(path: string) {
668 const url = `/eg/staff/${path}`;
669 window.open(url, '_blank');
672 openItemHolds(rows: HoldingsEntry[]) {
673 if (rows.length > 0 && rows[0].copy) {
674 this.openAngJsWindow(`cat/item/${rows[0].copy.id()}/holds`);
678 openItemStatusList(rows: HoldingsEntry[]) {
679 const ids = this.selectedCopyIds(rows);
680 if (ids.length > 0) {
681 return this.openAngJsWindow(`cat/item/search/${ids.join(',')}`);
685 openItemStatus(rows: HoldingsEntry[]) {
686 if (rows.length > 0 && rows[0].copy) {
687 return this.openAngJsWindow(`cat/item/${rows[0].copy.id()}`);
691 openItemTriggeredEvents(rows: HoldingsEntry[]) {
692 if (rows.length > 0 && rows[0].copy) {
693 return this.openAngJsWindow(
694 `cat/item/${rows[0].copy.id()}/triggered_events`);
698 openItemPrintLabels(rows: HoldingsEntry[]) {
699 const ids = this.selectedCopyIds(rows);
700 if (ids.length === 0) { return; }
702 this.anonCache.setItem(null, 'print-labels-these-copies', {copies: ids})
703 .then(key => this.openAngJsWindow(`cat/printlabels/${key}`));
706 openVolCopyEdit(rows: HoldingsEntry[], addVols: boolean, addCopies: boolean) {
708 // The user may select a set of volumes by selecting volume and/or
712 if (r.treeNode.nodeType === 'volume') {
713 volumes.push(r.volume);
714 } else if (r.treeNode.nodeType === 'copy') {
715 volumes.push(r.treeNode.parentNode.target);
719 if (addCopies && !addVols) {
720 // Adding copies to an existing set of volumes.
721 if (volumes.length > 0) {
722 const volIds = volumes.map(v => Number(v.id()));
723 this.holdings.spawnAddHoldingsUi(this.recordId, volIds);
726 } else if (addVols) {
729 if (volumes.length > 0) {
731 // When adding volumes, if any are selected in the grid,
732 // create volumes that have the same label and owner.
734 entries.push({label: v.label(), owner: v.owning_lib()}));
738 // Otherwise create new volumes from scratch.
739 entries.push({owner: this.auth.user().ws_ou()});
742 this.holdings.spawnAddHoldingsUi(
743 this.recordId, null, entries, !addCopies);
747 openItemNotes(rows: HoldingsEntry[], mode: string) {
748 const copyIds = this.selectedCopyIds(rows);
749 if (copyIds.length === 0) { return; }
751 this.copyAlertsDialog.copyIds = copyIds;
752 this.copyAlertsDialog.mode = mode;
753 this.copyAlertsDialog.open({size: 'lg'}).then(
763 openReplaceBarcodeDialog(rows: HoldingsEntry[]) {
764 const ids = this.selectedCopyIds(rows);
765 if (ids.length === 0) { return; }
766 this.replaceBarcode.copyIds = ids;
767 this.replaceBarcode.open({}).then(
777 // mode 'vols' -- only delete empty volumes
778 // mode 'copies' -- only delete selected copies
779 // mode 'both' -- delete selected copies and selected volumes, plus all
780 // copies linked to selected volumes, regardless of whether they are selected.
781 deleteHoldings(rows: HoldingsEntry[], mode: 'vols' | 'copies' | 'both') {
782 const volHash: any = {};
784 if (mode === 'vols' || mode === 'both') {
785 // Collect the volumes to be deleted.
786 rows.filter(r => r.treeNode.nodeType === 'volume').forEach(r => {
787 const vol = this.idl.clone(r.volume);
788 if (mode === 'vols') {
789 if (vol.copies().length > 0) {
790 // cannot delete non-empty volume in this mode.
794 vol.copies().forEach(c => c.isdeleted(true));
797 volHash[vol.id()] = vol;
801 if (mode === 'copies' || mode === 'both') {
802 // Collect the copies to be deleted, including their volumes
803 // since the API expects fleshed volume objects.
804 rows.filter(r => r.treeNode.nodeType === 'copy').forEach(r => {
805 const vol = r.treeNode.parentNode.target;
806 if (!volHash[vol.id()]) {
807 volHash[vol.id()] = this.idl.clone(vol);
808 volHash[vol.id()].copies([]);
810 const copy = this.idl.clone(r.copy);
811 copy.isdeleted(true);
812 volHash[vol.id()].copies().push(copy);
816 if (Object.keys(volHash).length === 0) {
817 // No data to process.
821 // Note forceDeleteCopies should not be necessary here, since we
822 // manually marked all copies as deleted on deleted volumes in
824 this.deleteVolcopy.forceDeleteCopies = mode === 'both';
825 this.deleteVolcopy.volumes = Object.values(volHash);
826 this.deleteVolcopy.open({size: 'sm'}).then(
836 requestItems(rows: HoldingsEntry[]) {
837 const copyIds = this.selectedCopyIds(rows);
838 if (copyIds.length === 0) { return; }
839 const params = {target: copyIds, holdFor: 'staff'};
840 this.router.navigate(['/staff/catalog/hold/C'], {queryParams: params});