1 import {Component, OnInit, Input, ViewChild, ViewEncapsulation
2 } from '@angular/core';
3 import {Router} from '@angular/router';
4 import {Observable, Observer, of, empty} from 'rxjs';
5 import {map} from 'rxjs/operators';
6 import {Pager} from '@eg/share/util/pager';
7 import {IdlObject, IdlService} from '@eg/core/idl.service';
8 import {StaffCatalogService} from '../catalog.service';
9 import {OrgService} from '@eg/core/org.service';
10 import {PcrudService} from '@eg/core/pcrud.service';
11 import {AuthService} from '@eg/core/auth.service';
12 import {GridDataSource, GridColumn, GridCellTextGenerator} from '@eg/share/grid/grid';
13 import {GridComponent} from '@eg/share/grid/grid.component';
14 import {GridToolbarCheckboxComponent
15 } from '@eg/share/grid/grid-toolbar-checkbox.component';
16 import {StoreService} from '@eg/core/store.service';
17 import {ServerStoreService} from '@eg/core/server-store.service';
18 import {MarkDamagedDialogComponent
19 } from '@eg/staff/share/holdings/mark-damaged-dialog.component';
20 import {MarkMissingDialogComponent
21 } from '@eg/staff/share/holdings/mark-missing-dialog.component';
22 import {AnonCacheService} from '@eg/share/util/anon-cache.service';
23 import {HoldingsService} from '@eg/staff/share/holdings/holdings.service';
24 import {CopyAlertsDialogComponent
25 } from '@eg/staff/share/holdings/copy-alerts-dialog.component';
26 import {CopyTagsDialogComponent
27 } from '@eg/staff/share/holdings/copy-tags-dialog.component';
28 import {CopyNotesDialogComponent
29 } from '@eg/staff/share/holdings/copy-notes-dialog.component';
30 import {ReplaceBarcodeDialogComponent
31 } from '@eg/staff/share/holdings/replace-barcode-dialog.component';
32 import {DeleteHoldingDialogComponent
33 } from '@eg/staff/share/holdings/delete-volcopy-dialog.component';
34 import {BucketDialogComponent
35 } from '@eg/staff/share/buckets/bucket-dialog.component';
36 import {ConjoinedItemsDialogComponent
37 } from '@eg/staff/share/holdings/conjoined-items-dialog.component';
38 import {MakeBookableDialogComponent
39 } from '@eg/staff/share/booking/make-bookable-dialog.component';
40 import {TransferItemsComponent
41 } from '@eg/staff/share/holdings/transfer-items.component';
42 import {TransferHoldingsComponent
43 } from '@eg/staff/share/holdings/transfer-holdings.component';
44 import {AlertDialogComponent} from '@eg/share/dialog/alert.component';
45 import {BroadcastService} from '@eg/share/util/broadcast.service';
48 // The holdings grid models a single HoldingsTree, composed of HoldingsTreeNodes
49 // flattened on-demand into a list of HoldingEntry objects.
50 export class HoldingsTreeNode {
51 children: HoldingsTreeNode[];
52 nodeType: 'org' | 'callNum' | 'copy';
54 parentNode: HoldingsTreeNode;
64 root: HoldingsTreeNode;
66 this.root = new HoldingsTreeNode();
70 export class HoldingsEntry {
72 // org unit shortname, call number label, or copy barcode
73 locationLabel: string;
74 // location label indentation depth
75 locationDepth: number | null;
76 callNumCount: number | null;
77 copyCount: number | null;
78 callNumberLabel: string;
82 treeNode: HoldingsTreeNode;
86 selector: 'eg-holdings-maintenance',
87 templateUrl: 'holdings.component.html',
88 styleUrls: ['holdings.component.css'],
89 encapsulation: ViewEncapsulation.None
91 export class HoldingsMaintenanceComponent implements OnInit {
94 gridDataSource: GridDataSource;
95 gridTemplateContext: any;
96 @ViewChild('holdingsGrid', { static: true }) holdingsGrid: GridComponent;
98 // Manage visibility of various sub-sections
99 @ViewChild('callNumsCheckbox', { static: true })
100 private callNumsCheckbox: GridToolbarCheckboxComponent;
101 @ViewChild('copiesCheckbox', { static: true })
102 private copiesCheckbox: GridToolbarCheckboxComponent;
103 @ViewChild('emptyCallNumsCheckbox', { static: true })
104 private emptyCallNumsCheckbox: GridToolbarCheckboxComponent;
105 @ViewChild('emptyLibsCheckbox', { static: true })
106 private emptyLibsCheckbox: GridToolbarCheckboxComponent;
107 @ViewChild('markDamagedDialog', { static: true })
108 private markDamagedDialog: MarkDamagedDialogComponent;
109 @ViewChild('markMissingDialog', { static: true })
110 private markMissingDialog: MarkMissingDialogComponent;
111 @ViewChild('copyAlertsDialog', { static: true })
112 private copyAlertsDialog: CopyAlertsDialogComponent;
113 @ViewChild('copyTagsDialog', {static: false})
114 private copyTagsDialog: CopyTagsDialogComponent;
115 @ViewChild('copyNotesDialog', {static: false})
116 private copyNotesDialog: CopyNotesDialogComponent;
117 @ViewChild('replaceBarcode', { static: true })
118 private replaceBarcode: ReplaceBarcodeDialogComponent;
119 @ViewChild('deleteHolding', { static: true })
120 private deleteHolding: DeleteHoldingDialogComponent;
121 @ViewChild('bucketDialog', { static: true })
122 private bucketDialog: BucketDialogComponent;
123 @ViewChild('conjoinedDialog', { static: true })
124 private conjoinedDialog: ConjoinedItemsDialogComponent;
125 @ViewChild('makeBookableDialog', { static: true })
126 private makeBookableDialog: MakeBookableDialogComponent;
127 @ViewChild('transferItems', {static: false})
128 private transferItems: TransferItemsComponent;
129 @ViewChild('transferHoldings', {static: false})
130 private transferHoldings: TransferHoldingsComponent;
131 @ViewChild('transferAlert', {static: false})
132 private transferAlert: AlertDialogComponent;
134 holdingsTree: HoldingsTree;
136 // nodeType => id => tree node cache
137 treeNodeCache: {[nodeType: string]: {[id: number]: HoldingsTreeNode}};
139 // When true and a grid reload is called, the holdings data will be
140 // re-fetched from the server.
141 refreshHoldings: boolean;
143 // Used as a row identifier in th grid, since we're mixing object types.
146 // List of copies whose due date we need to retrieve.
147 itemCircsNeeded: IdlObject[];
149 // When true draw the grid based on the stored preferences.
150 // When not true, render based on the current "expanded" state of each node.
151 // Rendering from prefs happens on initial load and when any prefs change.
152 renderFromPrefs: boolean;
154 rowClassCallback: (row: any) => string;
155 cellTextGenerator: GridCellTextGenerator;
157 private _recId: number;
158 @Input() set recordId(id: number) {
160 // Only force new data collection when recordId()
161 // is invoked after ngInit() has already run.
166 get recordId(): number {
170 contextOrg: IdlObject;
172 // The context org may come from a workstation setting.
173 // Wait for confirmation from the org-select (via onchange in this
174 // case) that the desired context org unit has been found.
175 contextOrgLoaded = false;
178 private router: Router,
179 private org: OrgService,
180 private idl: IdlService,
181 private pcrud: PcrudService,
182 private auth: AuthService,
183 private staffCat: StaffCatalogService,
184 private store: ServerStoreService,
185 private localStore: StoreService,
186 private holdings: HoldingsService,
187 private broadcaster: BroadcastService,
188 private anonCache: AnonCacheService
190 // Set some sane defaults before settings are loaded.
191 this.gridDataSource = new GridDataSource();
192 this.refreshHoldings = true;
193 this.renderFromPrefs = true;
195 // TODO: need a separate setting for this?
196 this.contextOrg = this.staffCat.searchContext.searchOrg;
198 this.rowClassCallback = (row: any): string => {
201 return 'holdings-copy-row';
203 return 'holdings-callNum-row';
206 // Add a generic org unit class and a depth-specific
207 // class for styling different levels of the org tree.
208 return 'holdings-org-row holdings-org-row-' +
209 row.treeNode.target.ou_type().depth();
213 // Text-ify function for cells that use display templates.
214 this.cellTextGenerator = {
215 owner_label: row => row.locationLabel,
216 holdable: row => row.copy ?
217 this.gridTemplateContext.copyIsHoldable(row.copy) : ''
220 this.gridTemplateContext = {
221 toggleExpandRow: (row: HoldingsEntry) => {
222 row.treeNode.expanded = !row.treeNode.expanded;
224 if (!row.treeNode.expanded) {
225 // When collapsing a node, all child nodes should be
226 // collapsed as well.
227 const traverse = (node: HoldingsTreeNode) => {
228 node.expanded = false;
229 node.children.forEach(traverse);
231 traverse(row.treeNode);
234 this.holdingsGrid.reload();
237 copyIsHoldable: (copy: IdlObject): boolean => {
238 return copy.holdable() === 't'
239 && copy.location().holdable() === 't'
240 && copy.status().holdable() === 't';
246 this.initDone = true;
248 this.broadcaster.listen('eg.holdings.update').subscribe(data => {
249 if (data && data.records && data.records.includes(this.recordId)) {
251 // A hard refresh is needed to accommodate cases where
252 // a new call number is created for a subset of copies.
253 // We may revisit this later and use soft refresh
254 // (below) vs. hard refresh (above) depending on what
255 // specifically is changed.
256 // this.refreshHoldings = true;
257 // this.holdingsGrid.reload();
261 // These are pre-cached via the catalog resolver.
262 const settings = this.store.getItemBatchCached([
263 'cat.holdings_show_empty_org',
264 'cat.holdings_show_empty',
265 'cat.holdings_show_copies',
266 'cat.holdings_show_vols'
269 // Show call numbers by default when no preference is set.
270 let showCallNums = settings['cat.holdings_show_vols'];
271 if (showCallNums === null) { showCallNums = true; }
273 this.callNumsCheckbox.checked(showCallNums);
274 this.copiesCheckbox.checked(settings['cat.holdings_show_copies']);
275 this.emptyCallNumsCheckbox.checked(settings['cat.holdings_show_empty']);
276 this.emptyLibsCheckbox.checked(settings['cat.holdings_show_empty_org']);
278 this.gridDataSource.getRows = (pager: Pager, sort: any[]) => {
279 if (!this.contextOrgLoaded) { return empty(); }
280 return this.fetchHoldings(pager);
284 // No data is loaded until the first occurrence of the org change handler
285 contextOrgChanged(org: IdlObject) {
286 this.contextOrgLoaded = true;
287 this.contextOrg = org;
292 this.renderFromPrefs = true;
293 this.refreshHoldings = true;
294 this.initHoldingsTree();
295 this.holdingsGrid.reload();
298 toggleShowCopies(value: boolean) {
299 this.store.setItem('cat.holdings_show_copies', value);
301 // Showing copies implies showing call numbers
302 this.callNumsCheckbox.checked(true);
304 this.renderFromPrefs = true;
305 this.holdingsGrid.reload();
308 toggleShowCallNums(value: boolean) {
309 this.store.setItem('cat.holdings_show_vols', value);
311 // Hiding call numbers implies hiding empty call numbers and copies.
312 this.copiesCheckbox.checked(false);
313 this.emptyCallNumsCheckbox.checked(false);
315 this.renderFromPrefs = true;
316 this.holdingsGrid.reload();
319 toggleShowEmptyCallNums(value: boolean) {
320 this.store.setItem('cat.holdings_show_empty', value);
322 this.callNumsCheckbox.checked(true);
324 this.renderFromPrefs = true;
325 this.holdingsGrid.reload();
328 toggleShowEmptyLibs(value: boolean) {
329 this.store.setItem('cat.holdings_show_empty_org', value);
330 this.renderFromPrefs = true;
331 this.holdingsGrid.reload();
334 onRowActivate(row: any) {
336 // Launch copy editor?
338 this.gridTemplateContext.toggleExpandRow(row);
344 const visibleOrgs = this.org.fullPath(this.contextOrg, true);
346 // The initial tree simply matches the org unit tree
347 const traverseOrg = (node: HoldingsTreeNode) => {
348 node.target.children().forEach((org: IdlObject) => {
349 if (visibleOrgs.indexOf(org.id()) === -1) {
350 return; // Org is outside of scope
352 const nodeChild = new HoldingsTreeNode();
353 nodeChild.nodeType = 'org';
354 nodeChild.target = org;
355 nodeChild.parentNode = node;
356 node.children.push(nodeChild);
357 this.treeNodeCache.org[org.id()] = nodeChild;
358 traverseOrg(nodeChild);
362 this.treeNodeCache = {
368 this.holdingsTree = new HoldingsTree();
369 this.holdingsTree.root.nodeType = 'org';
370 this.holdingsTree.root.target = this.org.root();
371 this.treeNodeCache.org[this.org.root().id()] = this.holdingsTree.root;
373 traverseOrg(this.holdingsTree.root);
376 // Org node children are sorted with any child org nodes pushed to the
377 // front, followed by the call number nodes sorted alphabetcially by label.
378 sortOrgNodeChildren(node: HoldingsTreeNode) {
379 node.children = node.children.sort((a, b) => {
380 if (a.nodeType === 'org') {
381 if (b.nodeType === 'org') {
382 return a.target.shortname() < b.target.shortname() ? -1 : 1;
386 } else if (b.nodeType === 'org') {
389 // TODO: should this use label sortkey instead of
390 // the compiled call number label?
391 return a.target._label < b.target._label ? -1 : 1;
396 // Sets call number and copy count sums to nodes that need it.
397 // Applies the initial expansed state of each container node.
398 setTreeCounts(node: HoldingsTreeNode) {
400 if (node.nodeType === 'org') {
402 node.callNumCount = 0;
403 } else if (node.nodeType === 'callNum') {
407 let hasChildOrgWithData = false;
408 let hasChildOrgSansData = false;
409 node.children.forEach(child => {
410 this.setTreeCounts(child);
411 if (node.nodeType === 'org') {
412 node.copyCount += child.copyCount;
413 if (child.nodeType === 'callNum') {
416 hasChildOrgWithData = child.callNumCount > 0;
417 hasChildOrgSansData = child.callNumCount === 0;
418 node.callNumCount += child.callNumCount;
420 } else if (node.nodeType === 'callNum') {
421 node.copyCount = node.children.length;
422 if (this.renderFromPrefs) {
423 node.expanded = this.copiesCheckbox.checked();
428 if (this.renderFromPrefs && node.nodeType === 'org') {
429 if (node.copyCount > 0 && this.callNumsCheckbox.checked()) {
430 node.expanded = true;
431 } else if (node.callNumCount > 0 && this.emptyCallNumsCheckbox.checked()) {
432 node.expanded = true;
433 } else if (hasChildOrgWithData) {
434 node.expanded = true;
435 } else if (hasChildOrgSansData && this.emptyLibsCheckbox.checked()) {
436 node.expanded = true;
438 node.expanded = false;
443 // Create HoldingsEntry objects for tree nodes that should be displayed
444 // and relays them to the grid via the observer.
445 propagateTreeEntries(observer: Observer<HoldingsEntry>, node: HoldingsTreeNode) {
446 const entry = new HoldingsEntry();
447 entry.treeNode = node;
448 entry.index = this.gridIndex++;
450 switch (node.nodeType) {
452 if (node.callNumCount === 0
453 && !this.emptyLibsCheckbox.checked()) {
456 entry.locationLabel = node.target.shortname();
457 entry.locationDepth = node.target.ou_type().depth();
458 entry.copyCount = node.copyCount;
459 entry.callNumCount = node.callNumCount;
460 this.sortOrgNodeChildren(node);
464 if (this.renderFromPrefs) {
465 if (!this.callNumsCheckbox.checked()) {
468 if (node.copyCount === 0
469 && !this.emptyCallNumsCheckbox.checked()) {
473 entry.locationLabel = node.target._label;
474 entry.locationDepth = node.parentNode.target.ou_type().depth() + 1;
475 entry.callNumberLabel = entry.locationLabel;
476 entry.callNum = node.target;
477 entry.copyCount = node.copyCount;
481 entry.locationLabel = node.target.barcode();
482 entry.locationDepth = node.parentNode.parentNode.target.ou_type().depth() + 2;
483 entry.callNumberLabel = node.parentNode.target.label(); // TODO
484 entry.callNum = node.parentNode.target;
485 entry.copy = node.target;
486 entry.circ = node.target._circ;
490 // Tell the grid about the node entry
491 observer.next(entry);
494 // Process the child nodes.
495 node.children.forEach(child =>
496 this.propagateTreeEntries(observer, child));
500 // Turns the tree into a list of entries for grid display
501 flattenHoldingsTree(observer: Observer<HoldingsEntry>) {
503 this.setTreeCounts(this.holdingsTree.root);
504 this.propagateTreeEntries(observer, this.holdingsTree.root);
506 this.renderFromPrefs = false;
509 // Grab call numbers, copies, and related data.
510 fetchHoldings(pager: Pager): Observable<any> {
511 if (!this.recordId) { return of([]); }
513 return new Observable<any>(observer => {
515 if (!this.refreshHoldings) {
516 this.flattenHoldingsTree(observer);
520 this.itemCircsNeeded = [];
521 // Track vol IDs for the current fetch so we can prune
522 // any that were deleted in an out-of-band update.
523 const volsFetched: number[] = [];
525 this.pcrud.search('acn',
526 { record: this.recordId,
527 owning_lib: this.org.fullPath(this.contextOrg, true),
529 label: {'!=' : '##URI##'}
533 acp: ['status', 'location', 'circ_lib', 'parts', 'notes',
534 'tags', 'age_protect', 'copy_alerts', 'latest_inventory',
535 'total_circ_count', 'last_circ'],
536 acn: ['prefix', 'suffix', 'copies'],
537 acli: ['inventory_workstation']
540 {authoritative: true}
543 this.appendCallNum(callNum);
544 volsFetched.push(callNum.id());
548 this.refreshHoldings = false;
549 this.pruneVols(volsFetched);
550 this.fetchCircs().then(
551 ok => this.flattenHoldingsTree(observer)
558 // Remove vols that were deleted out-of-band, via edit, merge, etc.
559 pruneVols(volsFetched: number[]) {
561 const toRemove: number[] = []; // avoid modifying mid-loop
562 Object.keys(this.treeNodeCache.callNum).forEach(volId => {
563 const id = Number(volId);
564 if (!volsFetched.includes(id)) {
569 if (toRemove.length === 0) { return; }
571 const pruneNodes = (node: HoldingsTreeNode) => {
572 if (node.nodeType === 'callNum' &&
573 toRemove.includes(node.target.id())) {
575 console.debug('pruning deleted vol:', node.target.id());
577 // Remove this node from the parents list of children
578 node.parentNode.children =
579 node.parentNode.children.filter(
580 c => c.target.id() !== node.target.id());
583 node.children.forEach(c => pruneNodes(c));
588 toRemove.forEach(volId => delete this.treeNodeCache.callNum[volId]);
591 pruneNodes(this.holdingsTree.root);
593 // refresh tree / grid
594 this.holdingsGrid.reload();
597 // Retrieve circulation objects for checked out items.
598 fetchCircs(): Promise<any> {
599 const copyIds = this.itemCircsNeeded.map(copy => copy.id());
600 if (copyIds.length === 0) { return Promise.resolve(); }
602 return this.pcrud.search('circ', {
603 target_copy: copyIds,
605 }).pipe(map(circ => {
606 const copy = this.itemCircsNeeded.filter(
607 c => Number(c.id()) === Number(circ.target_copy()))[0];
612 // Compile prefix + label + suffix into field callNum._label;
613 setCallNumLabel(callNum: IdlObject) {
614 const pfx = callNum.prefix() ? callNum.prefix().label() : '';
615 const sfx = callNum.suffix() ? callNum.suffix().label() : '';
616 callNum._label = pfx ? pfx + ' ' : '';
617 callNum._label += callNum.label();
618 callNum._label += sfx ? ' ' + sfx : '';
621 // Create the tree node for the call number if it doesn't already exist.
622 // Do the same for its linked copies.
623 appendCallNum(callNum: IdlObject) {
624 let callNumNode = this.treeNodeCache.callNum[callNum.id()];
625 this.setCallNumLabel(callNum);
628 const pNode = this.treeNodeCache.org[callNum.owning_lib()];
629 if (callNumNode.parentNode.target.id() !== pNode.target.id()) {
630 callNumNode.parentNode = pNode;
631 callNumNode.parentNode.children.push(callNumNode);
634 callNumNode = new HoldingsTreeNode();
635 callNumNode.nodeType = 'callNum';
636 callNumNode.parentNode = this.treeNodeCache.org[callNum.owning_lib()];
637 callNumNode.parentNode.children.push(callNumNode);
638 this.treeNodeCache.callNum[callNum.id()] = callNumNode;
641 callNumNode.target = callNum;
644 .filter((copy: IdlObject) => (copy.deleted() !== 't'))
645 .sort((a: IdlObject, b: IdlObject) => a.barcode() < b.barcode() ? -1 : 1)
646 .forEach((copy: IdlObject) => this.appendCopy(callNumNode, copy));
649 // Find or create a copy node.
650 appendCopy(callNumNode: HoldingsTreeNode, copy: IdlObject) {
651 let copyNode = this.treeNodeCache.copy[copy.id()];
654 const oldParent = copyNode.parentNode;
655 if (oldParent.target.id() !== callNumNode.target.id()) {
656 // TODO: copy changed owning call number. Remove it from
657 // the previous call number before adding to the new call number.
658 copyNode.parentNode = callNumNode;
659 callNumNode.children.push(copyNode);
663 copyNode = new HoldingsTreeNode();
664 copyNode.nodeType = 'copy';
665 callNumNode.children.push(copyNode);
666 copyNode.parentNode = callNumNode;
667 this.treeNodeCache.copy[copy.id()] = copyNode;
670 copyNode.target = copy;
671 const stat = Number(copy.status().id());
672 copy._monograph_parts = '';
673 if (copy.parts().length > 0) {
674 copy._monograph_parts =
675 copy.parts().map(p => p.label()).join(',');
678 if (stat === 1 /* checked out */ || stat === 16 /* long overdue */) {
679 // Avoid looking up circs on items that are not checked out.
680 this.itemCircsNeeded.push(copy);
684 // Which copies in the grid are selected.
685 selectedCopyIds(rows: HoldingsEntry[], skipStatus?: number): number[] {
686 return this.selectedCopies(rows, skipStatus).map(c => Number(c.id()));
689 selectedVolIds(rows: HoldingsEntry[]): number[] {
691 .filter(r => Boolean(r.callNum))
692 .map(r => Number(r.callNum.id()));
695 selectedCopies(rows: HoldingsEntry[], skipStatus?: number): IdlObject[] {
696 let copyRows = rows.filter(r => Boolean(r.copy)).map(r => r.copy);
698 copyRows = copyRows.filter(
699 c => Number(c.status().id()) !== Number(skipStatus));
704 selectedCallNumIds(rows: HoldingsEntry[]): number[] {
705 return this.selectedCallNums(rows).map(cn => cn.id());
708 selectedCallNums(rows: HoldingsEntry[]): IdlObject[] {
710 .filter(r => r.treeNode.nodeType === 'callNum')
711 .map(r => r.callNum);
715 async showMarkDamagedDialog(rows: HoldingsEntry[]) {
716 const copyIds = this.selectedCopyIds(rows, 14 /* ignore damaged */);
718 if (copyIds.length === 0) { return; }
720 let rowsModified = false;
722 const markNext = async(ids: number[]) => {
723 if (ids.length === 0) {
724 return Promise.resolve();
727 this.markDamagedDialog.copyId = ids.pop();
728 return this.markDamagedDialog.open({size: 'lg'}).subscribe(
730 if (ok) { rowsModified = true; }
731 return markNext(ids);
733 dismiss => markNext(ids)
737 await markNext(copyIds);
739 this.refreshHoldings = true;
740 this.holdingsGrid.reload();
744 showMarkMissingDialog(rows: any[]) {
745 const copyIds = this.selectedCopyIds(rows, 4 /* ignore missing */);
746 if (copyIds.length > 0) {
747 this.markMissingDialog.copyIds = copyIds;
748 this.markMissingDialog.open({}).subscribe(
751 this.refreshHoldings = true;
752 this.holdingsGrid.reload();
755 dismissed => {} // avoid console errors
760 // Mark record, library, and potentially the selected call number
761 // as the current transfer target.
762 markLibCnForTransfer(rows: HoldingsEntry[]) {
763 if (rows.length === 0) {
767 // Action may only apply to a single org or call number row.
768 const node = rows[0].treeNode;
769 if (node.nodeType === 'copy') { return; }
773 if (node.nodeType === 'org') {
774 orgId = node.target.id();
776 // Clear call number target when performed on an org unit row
777 this.localStore.removeLocalItem('eg.cat.transfer_target_vol');
779 } else if (node.nodeType === 'callNum') {
781 // All call number nodes are children of org nodes.
782 orgId = node.parentNode.target.id();
784 // Add call number target when performed on a call number row.
785 this.localStore.setLocalItem(
786 'eg.cat.transfer_target_vol', node.target.id());
789 // Track lib and record to support transfering items from
790 // a different bib record to this record at the selected
792 this.localStore.setLocalItem('eg.cat.transfer_target_lib', orgId);
793 this.localStore.setLocalItem('eg.cat.transfer_target_record', this.recordId);
796 openAngJsWindow(path: string) {
797 const url = `/eg/staff/${path}`;
798 window.open(url, '_blank');
801 openItemHolds(rows: HoldingsEntry[]) {
802 if (rows.length > 0 && rows[0].copy) {
803 this.openAngJsWindow(`cat/item/${rows[0].copy.id()}/holds`);
807 openItemStatusList(rows: HoldingsEntry[]) {
808 const ids = this.selectedCopyIds(rows);
809 if (ids.length > 0) {
810 return this.openAngJsWindow(`cat/item/search/${ids.join(',')}`);
814 openItemStatus(rows: HoldingsEntry[]) {
815 if (rows.length > 0 && rows[0].copy) {
816 return this.openAngJsWindow(`cat/item/${rows[0].copy.id()}`);
820 openItemTriggeredEvents(rows: HoldingsEntry[]) {
821 if (rows.length > 0 && rows[0].copy) {
822 return this.openAngJsWindow(
823 `cat/item/${rows[0].copy.id()}/triggered_events`);
827 openItemPrintLabels(rows: HoldingsEntry[]) {
828 const ids = this.selectedCopyIds(rows);
829 if (ids.length === 0) { return; }
831 this.anonCache.setItem(null, 'print-labels-these-copies', {copies: ids})
832 .then(key => this.openAngJsWindow(`cat/printlabels/${key}`));
835 openHoldingEdit(rows: HoldingsEntry[], hideVols: boolean, hideCopies: boolean) {
837 // Avoid adding call number edit entries for call numbers
838 // that are already represented by selected items.
840 const copies = this.selectedCopies(rows);
841 const copyVols = copies.map(c => Number(c.call_number()));
844 this.selectedVolIds(rows).forEach(id => {
845 if (!copyVols.includes(id)) {
850 this.holdings.spawnAddHoldingsUi(
854 copies.map(c => Number(c.id())),
860 openHoldingAdd(rows: HoldingsEntry[], addCallNums: boolean, addCopies: boolean) {
862 // The user may select a set of call numbers by selecting call
863 // number and/or item rows. Owning libs for new call numbers may
864 // also come from org unit row selection.
868 if (r.treeNode.nodeType === 'callNum') {
869 callNums.push(r.callNum);
871 } else if (r.treeNode.nodeType === 'copy') {
872 callNums.push(r.treeNode.parentNode.target);
874 } else if (r.treeNode.nodeType === 'org') {
875 const org = r.treeNode.target;
876 if (org.ou_type().can_have_vols() === 't') {
877 orgs[org.id()] = true;
882 if (addCopies && !addCallNums) {
883 // Adding copies to an existing set of call numbers.
884 if (callNums.length > 0) {
885 const callNumIds = callNums.map(v => Number(v.id()));
886 this.holdings.spawnAddHoldingsUi(this.recordId, callNumIds);
889 } else if (addCallNums) {
892 // Use selected call numbers as basis for new call numbers.
893 callNums.forEach(v =>
894 entries.push({label: v.label(), owner: v.owning_lib()}));
896 // Use selected org units as owning libs for new call numbers
897 Object.keys(orgs).forEach(id => entries.push({owner: id}));
899 if (entries.length === 0) {
900 // Otherwise create new call numbers for "here"
901 entries.push({owner: this.auth.user().ws_ou()});
904 this.holdings.spawnAddHoldingsUi(
905 this.recordId, null, entries, null, !addCopies);
909 openItemAlerts(rows: HoldingsEntry[], mode: string) {
910 const copyIds = this.selectedCopyIds(rows);
911 if (copyIds.length === 0) { return; }
913 this.copyAlertsDialog.copyIds = copyIds;
914 this.copyAlertsDialog.mode = mode;
915 this.copyAlertsDialog.open({size: 'lg'}).subscribe(
924 openItemTags(rows: HoldingsEntry[]) {
925 const copyIds = this.selectedCopyIds(rows);
926 if (copyIds.length === 0) { return; }
928 this.copyTagsDialog.copyIds = copyIds;
929 this.copyTagsDialog.open({size: 'lg'}).subscribe(
938 openItemNotes(rows: HoldingsEntry[]) {
939 const copyIds = this.selectedCopyIds(rows);
940 if (copyIds.length === 0) { return; }
942 this.copyNotesDialog.copyIds = copyIds;
943 this.copyNotesDialog.open({size: 'lg'}).subscribe(
952 openReplaceBarcodeDialog(rows: HoldingsEntry[]) {
953 const ids = this.selectedCopyIds(rows);
954 if (ids.length === 0) { return; }
955 this.replaceBarcode.copyIds = ids;
956 this.replaceBarcode.open({}).subscribe(
965 // mode 'callNums' -- only delete empty call numbers
966 // mode 'copies' -- only delete selected copies
967 // mode 'both' -- delete selected copies and selected call numbers, plus all
968 // copies linked to selected call numbers, regardless of whether they are selected.
969 deleteHoldings(rows: HoldingsEntry[], mode: 'callNums' | 'copies' | 'both') {
970 const callNumHash: any = {};
972 if (mode === 'callNums' || mode === 'both') {
973 // Collect the call numbers to be deleted.
974 rows.filter(r => r.treeNode.nodeType === 'callNum').forEach(r => {
975 const callNum = this.idl.clone(r.callNum);
976 if (mode === 'callNums') {
977 if (callNum.copies().length > 0) {
978 // cannot delete non-empty call number in this mode.
982 callNum.copies().forEach(c => c.isdeleted(true));
984 callNum.isdeleted(true);
985 callNumHash[callNum.id()] = callNum;
989 if (mode === 'copies' || mode === 'both') {
990 // Collect the copies to be deleted, including their call numbers
991 // since the API expects fleshed call number objects.
992 rows.filter(r => r.treeNode.nodeType === 'copy').forEach(r => {
993 const callNum = r.treeNode.parentNode.target;
994 if (!callNumHash[callNum.id()]) {
995 callNumHash[callNum.id()] = this.idl.clone(callNum);
996 callNumHash[callNum.id()].copies([]);
998 const copy = this.idl.clone(r.copy);
999 copy.isdeleted(true);
1000 callNumHash[callNum.id()].copies().push(copy);
1004 if (Object.keys(callNumHash).length === 0) {
1005 // No data to process.
1009 // Note forceDeleteCopies should not be necessary here, since we
1010 // manually marked all copies as deleted on deleted call numbers in
1012 this.deleteHolding.forceDeleteCopies = mode === 'both';
1013 this.deleteHolding.callNums = Object.values(callNumHash);
1014 this.deleteHolding.open({size: 'sm'}).subscribe(
1023 requestItems(rows: HoldingsEntry[]) {
1024 const copyIds = this.selectedCopyIds(rows);
1025 if (copyIds.length === 0) { return; }
1026 const params = {target: copyIds, holdFor: 'staff'};
1027 this.router.navigate(['/staff/catalog/hold/C'], {queryParams: params});
1030 openBucketDialog(rows: HoldingsEntry[]) {
1031 const copyIds = this.selectedCopyIds(rows);
1032 if (copyIds.length > 0) {
1033 this.bucketDialog.bucketClass = 'copy';
1034 this.bucketDialog.itemIds = copyIds;
1035 this.bucketDialog.open({size: 'lg'});
1039 openConjoinedDialog(rows: HoldingsEntry[]) {
1040 const copyIds = this.selectedCopyIds(rows);
1041 if (copyIds.length > 0) {
1042 this.conjoinedDialog.copyIds = copyIds;
1043 this.conjoinedDialog.open({size: 'sm'});
1047 bookItems(rows: HoldingsEntry[]) {
1048 const copyIds = this.selectedCopyIds(rows);
1049 if (copyIds.length > 0) {
1050 this.router.navigate(
1051 ['staff', 'booking', 'create_reservation', 'for_resource', rows.filter(r => Boolean(r.copy))[0].copy.barcode()]
1056 makeBookable(rows: HoldingsEntry[]) {
1057 const copyIds = this.selectedCopyIds(rows);
1058 if (copyIds.length > 0) {
1059 this.makeBookableDialog.copyIds = copyIds;
1060 this.makeBookableDialog.open({});
1064 manageReservations(rows: HoldingsEntry[]) {
1065 const copyIds = this.selectedCopyIds(rows);
1066 if (copyIds.length > 0) {
1067 this.router.navigate(
1068 ['staff', 'booking', 'manage_reservations', 'by_resource', rows.filter(r => Boolean(r.copy))[0].copy.barcode()]
1073 transferSelectedItems(rows: HoldingsEntry[]) {
1074 if (rows.length === 0) { return; }
1077 this.localStore.getLocalItem('eg.cat.transfer_target_vol');
1080 this.localStore.getLocalItem('eg.cat.transfer_target_lib');
1083 this.localStore.getLocalItem('eg.cat.transfer_target_record');
1087 if (cnId) { // Direct call number transfer
1089 const itemIds = this.selectedCopyIds(rows);
1090 promise = this.transferItems.transferItems(itemIds, cnId);
1092 } else if (orgId && recId) { // "Auto" transfer
1094 // Clone the items to be modified to avoid any unexpected
1095 // modifications and fesh the call numbers.
1096 const items = this.idl.clone(this.selectedCopies(rows));
1097 items.forEach(i => i.call_number(
1098 this.treeNodeCache.callNum[i.call_number()].target));
1101 promise = this.transferItems.autoTransferItems(items, recId, orgId);
1104 promise = this.transferAlert.open().toPromise();
1107 promise.then(success => success ? this.hardRefresh() : null);
1110 transferSelectedHoldings(rows: HoldingsEntry[]) {
1111 const callNums = this.selectedCallNums(rows);
1112 if (callNums.length === 0) { return; }
1115 this.localStore.getLocalItem('eg.cat.transfer_target_lib');
1118 this.localStore.getLocalItem('eg.cat.transfer_target_record');
1121 // When transferring holdings (call numbers) between org units,
1122 // limit transfers to within the current record.
1123 recId = this.recordId;
1125 } else if (!recId) {
1126 // No destinations applied.
1127 return this.transferAlert.open();
1130 this.transferHoldings.targetRecId = recId;
1131 this.transferHoldings.targetOrgId = orgId;
1132 this.transferHoldings.callNums = callNums;
1134 this.transferHoldings.transferHoldings()
1135 .then(success => success ? this.hardRefresh() : null);