1 import {Component, OnInit, Input, ViewChild, ViewEncapsulation
2 } from '@angular/core';
3 import {Router} from '@angular/router';
4 import {Observable, Observer, of, empty} from 'rxjs';
5 import {map} from 'rxjs/operators';
6 import {Pager} from '@eg/share/util/pager';
7 import {IdlObject, IdlService} from '@eg/core/idl.service';
8 import {StaffCatalogService} from '../catalog.service';
9 import {OrgService} from '@eg/core/org.service';
10 import {PcrudService} from '@eg/core/pcrud.service';
11 import {AuthService} from '@eg/core/auth.service';
12 import {GridDataSource, GridColumn, GridCellTextGenerator} from '@eg/share/grid/grid';
13 import {GridComponent} from '@eg/share/grid/grid.component';
14 import {GridToolbarCheckboxComponent
15 } from '@eg/share/grid/grid-toolbar-checkbox.component';
16 import {StoreService} from '@eg/core/store.service';
17 import {ServerStoreService} from '@eg/core/server-store.service';
18 import {MarkDamagedDialogComponent
19 } from '@eg/staff/share/holdings/mark-damaged-dialog.component';
20 import {MarkMissingDialogComponent
21 } from '@eg/staff/share/holdings/mark-missing-dialog.component';
22 import {AnonCacheService} from '@eg/share/util/anon-cache.service';
23 import {HoldingsService} from '@eg/staff/share/holdings/holdings.service';
24 import {CopyAlertsDialogComponent
25 } from '@eg/staff/share/holdings/copy-alerts-dialog.component';
26 import {ReplaceBarcodeDialogComponent
27 } from '@eg/staff/share/holdings/replace-barcode-dialog.component';
28 import {DeleteHoldingDialogComponent
29 } from '@eg/staff/share/holdings/delete-volcopy-dialog.component';
30 import {BucketDialogComponent
31 } from '@eg/staff/share/buckets/bucket-dialog.component';
32 import {ConjoinedItemsDialogComponent
33 } from '@eg/staff/share/holdings/conjoined-items-dialog.component';
34 import {MakeBookableDialogComponent
35 } from '@eg/staff/share/booking/make-bookable-dialog.component';
36 import {TransferItemsComponent
37 } from '@eg/staff/share/holdings/transfer-items.component';
38 import {TransferHoldingsComponent
39 } from '@eg/staff/share/holdings/transfer-holdings.component';
40 import {AlertDialogComponent} from '@eg/share/dialog/alert.component';
41 import {BroadcastService} from '@eg/share/util/broadcast.service';
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';
50 parentNode: HoldingsTreeNode;
60 root: HoldingsTreeNode;
62 this.root = new HoldingsTreeNode();
66 export class HoldingsEntry {
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;
78 treeNode: HoldingsTreeNode;
82 selector: 'eg-holdings-maintenance',
83 templateUrl: 'holdings.component.html',
84 styleUrls: ['holdings.component.css'],
85 encapsulation: ViewEncapsulation.None
87 export class HoldingsMaintenanceComponent implements OnInit {
90 gridDataSource: GridDataSource;
91 gridTemplateContext: any;
92 @ViewChild('holdingsGrid', { static: true }) holdingsGrid: GridComponent;
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;
126 holdingsTree: HoldingsTree;
128 // nodeType => id => tree node cache
129 treeNodeCache: {[nodeType: string]: {[id: number]: HoldingsTreeNode}};
131 // When true and a grid reload is called, the holdings data will be
132 // re-fetched from the server.
133 refreshHoldings: boolean;
135 // Used as a row identifier in th grid, since we're mixing object types.
138 // List of copies whose due date we need to retrieve.
139 itemCircsNeeded: IdlObject[];
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;
146 rowClassCallback: (row: any) => string;
147 cellTextGenerator: GridCellTextGenerator;
149 private _recId: number;
150 @Input() set recordId(id: number) {
152 // Only force new data collection when recordId()
153 // is invoked after ngInit() has already run.
158 get recordId(): number {
162 contextOrg: IdlObject;
164 // The context org may come from a workstation setting.
165 // Wait for confirmation from the org-select (via onchange in this
166 // case) that the desired context org unit has been found.
167 contextOrgLoaded = false;
170 private router: Router,
171 private org: OrgService,
172 private idl: IdlService,
173 private pcrud: PcrudService,
174 private auth: AuthService,
175 private staffCat: StaffCatalogService,
176 private store: ServerStoreService,
177 private localStore: StoreService,
178 private holdings: HoldingsService,
179 private broadcaster: BroadcastService,
180 private anonCache: AnonCacheService
182 // Set some sane defaults before settings are loaded.
183 this.gridDataSource = new GridDataSource();
184 this.refreshHoldings = true;
185 this.renderFromPrefs = true;
187 // TODO: need a separate setting for this?
188 this.contextOrg = this.staffCat.searchContext.searchOrg;
190 this.rowClassCallback = (row: any): string => {
193 return 'holdings-copy-row';
195 return 'holdings-callNum-row';
198 // Add a generic org unit class and a depth-specific
199 // class for styling different levels of the org tree.
200 return 'holdings-org-row holdings-org-row-' +
201 row.treeNode.target.ou_type().depth();
205 // Text-ify function for cells that use display templates.
206 this.cellTextGenerator = {
207 owner_label: row => row.locationLabel,
208 holdable: row => row.copy ?
209 this.gridTemplateContext.copyIsHoldable(row.copy) : ''
212 this.gridTemplateContext = {
213 toggleExpandRow: (row: HoldingsEntry) => {
214 row.treeNode.expanded = !row.treeNode.expanded;
216 if (!row.treeNode.expanded) {
217 // When collapsing a node, all child nodes should be
218 // collapsed as well.
219 const traverse = (node: HoldingsTreeNode) => {
220 node.expanded = false;
221 node.children.forEach(traverse);
223 traverse(row.treeNode);
226 this.holdingsGrid.reload();
229 copyIsHoldable: (copy: IdlObject): boolean => {
230 return copy.holdable() === 't'
231 && copy.location().holdable() === 't'
232 && copy.status().holdable() === 't';
238 this.initDone = true;
240 this.broadcaster.listen('eg.holdings.update').subscribe(data => {
241 if (data && data.records && data.records.includes(this.recordId)) {
242 this.refreshHoldings = true;
243 this.holdingsGrid.reload();
247 // These are pre-cached via the catalog resolver.
248 const settings = this.store.getItemBatchCached([
249 'cat.holdings_show_empty_org',
250 'cat.holdings_show_empty',
251 'cat.holdings_show_copies',
252 'cat.holdings_show_vols'
255 // Show call numbers by default when no preference is set.
256 let showCallNums = settings['cat.holdings_show_vols'];
257 if (showCallNums === null) { showCallNums = true; }
259 this.callNumsCheckbox.checked(showCallNums);
260 this.copiesCheckbox.checked(settings['cat.holdings_show_copies']);
261 this.emptyCallNumsCheckbox.checked(settings['cat.holdings_show_empty']);
262 this.emptyLibsCheckbox.checked(settings['cat.holdings_show_empty_org']);
264 this.gridDataSource.getRows = (pager: Pager, sort: any[]) => {
265 if (!this.contextOrgLoaded) { return empty(); }
266 return this.fetchHoldings(pager);
270 // No data is loaded until the first occurrence of the org change handler
271 contextOrgChanged(org: IdlObject) {
272 this.contextOrgLoaded = true;
273 this.contextOrg = org;
278 this.renderFromPrefs = true;
279 this.refreshHoldings = true;
280 this.initHoldingsTree();
281 this.holdingsGrid.reload();
284 toggleShowCopies(value: boolean) {
285 this.store.setItem('cat.holdings_show_copies', value);
287 // Showing copies implies showing call numbers
288 this.callNumsCheckbox.checked(true);
290 this.renderFromPrefs = true;
291 this.holdingsGrid.reload();
294 toggleShowCallNums(value: boolean) {
295 this.store.setItem('cat.holdings_show_vols', value);
297 // Hiding call numbers implies hiding empty call numbers and copies.
298 this.copiesCheckbox.checked(false);
299 this.emptyCallNumsCheckbox.checked(false);
301 this.renderFromPrefs = true;
302 this.holdingsGrid.reload();
305 toggleShowEmptyCallNums(value: boolean) {
306 this.store.setItem('cat.holdings_show_empty', value);
308 this.callNumsCheckbox.checked(true);
310 this.renderFromPrefs = true;
311 this.holdingsGrid.reload();
314 toggleShowEmptyLibs(value: boolean) {
315 this.store.setItem('cat.holdings_show_empty_org', value);
316 this.renderFromPrefs = true;
317 this.holdingsGrid.reload();
320 onRowActivate(row: any) {
322 // Launch copy editor?
324 this.gridTemplateContext.toggleExpandRow(row);
330 const visibleOrgs = this.org.fullPath(this.contextOrg, true);
332 // The initial tree simply matches the org unit tree
333 const traverseOrg = (node: HoldingsTreeNode) => {
334 node.target.children().forEach((org: IdlObject) => {
335 if (visibleOrgs.indexOf(org.id()) === -1) {
336 return; // Org is outside of scope
338 const nodeChild = new HoldingsTreeNode();
339 nodeChild.nodeType = 'org';
340 nodeChild.target = org;
341 nodeChild.parentNode = node;
342 node.children.push(nodeChild);
343 this.treeNodeCache.org[org.id()] = nodeChild;
344 traverseOrg(nodeChild);
348 this.treeNodeCache = {
354 this.holdingsTree = new HoldingsTree();
355 this.holdingsTree.root.nodeType = 'org';
356 this.holdingsTree.root.target = this.org.root();
357 this.treeNodeCache.org[this.org.root().id()] = this.holdingsTree.root;
359 traverseOrg(this.holdingsTree.root);
362 // Org node children are sorted with any child org nodes pushed to the
363 // front, followed by the call number nodes sorted alphabetcially by label.
364 sortOrgNodeChildren(node: HoldingsTreeNode) {
365 node.children = node.children.sort((a, b) => {
366 if (a.nodeType === 'org') {
367 if (b.nodeType === 'org') {
368 return a.target.shortname() < b.target.shortname() ? -1 : 1;
372 } else if (b.nodeType === 'org') {
375 // TODO: should this use label sortkey instead of
376 // the compiled call number label?
377 return a.target._label < b.target._label ? -1 : 1;
382 // Sets call number and copy count sums to nodes that need it.
383 // Applies the initial expansed state of each container node.
384 setTreeCounts(node: HoldingsTreeNode) {
386 if (node.nodeType === 'org') {
388 node.callNumCount = 0;
389 } else if (node.nodeType === 'callNum') {
393 let hasChildOrgWithData = false;
394 let hasChildOrgSansData = false;
395 node.children.forEach(child => {
396 this.setTreeCounts(child);
397 if (node.nodeType === 'org') {
398 node.copyCount += child.copyCount;
399 if (child.nodeType === 'callNum') {
402 hasChildOrgWithData = child.callNumCount > 0;
403 hasChildOrgSansData = child.callNumCount === 0;
404 node.callNumCount += child.callNumCount;
406 } else if (node.nodeType === 'callNum') {
407 node.copyCount = node.children.length;
408 if (this.renderFromPrefs) {
409 node.expanded = this.copiesCheckbox.checked();
414 if (this.renderFromPrefs && node.nodeType === 'org') {
415 if (node.copyCount > 0 && this.callNumsCheckbox.checked()) {
416 node.expanded = true;
417 } else if (node.callNumCount > 0 && this.emptyCallNumsCheckbox.checked()) {
418 node.expanded = true;
419 } else if (hasChildOrgWithData) {
420 node.expanded = true;
421 } else if (hasChildOrgSansData && this.emptyLibsCheckbox.checked()) {
422 node.expanded = true;
424 node.expanded = false;
429 // Create HoldingsEntry objects for tree nodes that should be displayed
430 // and relays them to the grid via the observer.
431 propagateTreeEntries(observer: Observer<HoldingsEntry>, node: HoldingsTreeNode) {
432 const entry = new HoldingsEntry();
433 entry.treeNode = node;
434 entry.index = this.gridIndex++;
436 switch (node.nodeType) {
438 if (node.callNumCount === 0
439 && !this.emptyLibsCheckbox.checked()) {
442 entry.locationLabel = node.target.shortname();
443 entry.locationDepth = node.target.ou_type().depth();
444 entry.copyCount = node.copyCount;
445 entry.callNumCount = node.callNumCount;
446 this.sortOrgNodeChildren(node);
450 if (this.renderFromPrefs) {
451 if (!this.callNumsCheckbox.checked()) {
454 if (node.copyCount === 0
455 && !this.emptyCallNumsCheckbox.checked()) {
459 entry.locationLabel = node.target._label;
460 entry.locationDepth = node.parentNode.target.ou_type().depth() + 1;
461 entry.callNumberLabel = entry.locationLabel;
462 entry.callNum = node.target;
463 entry.copyCount = node.copyCount;
467 entry.locationLabel = node.target.barcode();
468 entry.locationDepth = node.parentNode.parentNode.target.ou_type().depth() + 2;
469 entry.callNumberLabel = node.parentNode.target.label(); // TODO
470 entry.callNum = node.parentNode.target;
471 entry.copy = node.target;
472 entry.circ = node.target._circ;
476 // Tell the grid about the node entry
477 observer.next(entry);
480 // Process the child nodes.
481 node.children.forEach(child =>
482 this.propagateTreeEntries(observer, child));
486 // Turns the tree into a list of entries for grid display
487 flattenHoldingsTree(observer: Observer<HoldingsEntry>) {
489 this.setTreeCounts(this.holdingsTree.root);
490 this.propagateTreeEntries(observer, this.holdingsTree.root);
492 this.renderFromPrefs = false;
495 // Grab call numbers, copies, and related data.
496 fetchHoldings(pager: Pager): Observable<any> {
497 if (!this.recordId) { return of([]); }
499 return new Observable<any>(observer => {
501 if (!this.refreshHoldings) {
502 this.flattenHoldingsTree(observer);
506 this.itemCircsNeeded = [];
508 this.pcrud.search('acn',
509 { record: this.recordId,
510 owning_lib: this.org.fullPath(this.contextOrg, true),
512 label: {'!=' : '##URI##'}
516 acp: ['status', 'location', 'circ_lib', 'parts', 'notes',
517 'tags', 'age_protect', 'copy_alerts', 'latest_inventory'],
518 acn: ['prefix', 'suffix', 'copies'],
519 acli: ['inventory_workstation']
522 {authoritative: true}
524 callNum => this.appendCallNum(callNum),
527 this.refreshHoldings = false;
528 this.fetchCircs().then(
529 ok => this.flattenHoldingsTree(observer)
536 // Retrieve circulation objects for checked out items.
537 fetchCircs(): Promise<any> {
538 const copyIds = this.itemCircsNeeded.map(copy => copy.id());
539 if (copyIds.length === 0) { return Promise.resolve(); }
541 return this.pcrud.search('circ', {
542 target_copy: copyIds,
544 }).pipe(map(circ => {
545 const copy = this.itemCircsNeeded.filter(
546 c => Number(c.id()) === Number(circ.target_copy()))[0];
551 // Compile prefix + label + suffix into field callNum._label;
552 setCallNumLabel(callNum: IdlObject) {
553 const pfx = callNum.prefix() ? callNum.prefix().label() : '';
554 const sfx = callNum.suffix() ? callNum.suffix().label() : '';
555 callNum._label = pfx ? pfx + ' ' : '';
556 callNum._label += callNum.label();
557 callNum._label += sfx ? ' ' + sfx : '';
560 // Create the tree node for the call number if it doesn't already exist.
561 // Do the same for its linked copies.
562 appendCallNum(callNum: IdlObject) {
563 let callNumNode = this.treeNodeCache.callNum[callNum.id()];
564 this.setCallNumLabel(callNum);
567 const pNode = this.treeNodeCache.org[callNum.owning_lib()];
568 if (callNumNode.parentNode.target.id() !== pNode.target.id()) {
569 // Call number owning library changed. Un-link it from the
570 // previous org unit collection before adding to the new one.
572 callNumNode.parentNode = pNode;
573 callNumNode.parentNode.children.push(callNumNode);
576 callNumNode = new HoldingsTreeNode();
577 callNumNode.nodeType = 'callNum';
578 callNumNode.parentNode = this.treeNodeCache.org[callNum.owning_lib()];
579 callNumNode.parentNode.children.push(callNumNode);
580 this.treeNodeCache.callNum[callNum.id()] = callNumNode;
583 callNumNode.target = callNum;
586 .filter((copy: IdlObject) => (copy.deleted() !== 't'))
587 .sort((a: IdlObject, b: IdlObject) => a.barcode() < b.barcode() ? -1 : 1)
588 .forEach((copy: IdlObject) => this.appendCopy(callNumNode, copy));
591 // Find or create a copy node.
592 appendCopy(callNumNode: HoldingsTreeNode, copy: IdlObject) {
593 let copyNode = this.treeNodeCache.copy[copy.id()];
596 const oldParent = copyNode.parentNode;
597 if (oldParent.target.id() !== callNumNode.target.id()) {
598 // TODO: copy changed owning call number. Remove it from
599 // the previous call number before adding to the new call number.
600 copyNode.parentNode = callNumNode;
601 callNumNode.children.push(copyNode);
605 copyNode = new HoldingsTreeNode();
606 copyNode.nodeType = 'copy';
607 callNumNode.children.push(copyNode);
608 copyNode.parentNode = callNumNode;
609 this.treeNodeCache.copy[copy.id()] = copyNode;
612 copyNode.target = copy;
613 const stat = Number(copy.status().id());
614 copy._monograph_parts = '';
615 if (copy.parts().length > 0) {
616 copy._monograph_parts =
617 copy.parts().map(p => p.label()).join(',');
620 if (stat === 1 /* checked out */ || stat === 16 /* long overdue */) {
621 // Avoid looking up circs on items that are not checked out.
622 this.itemCircsNeeded.push(copy);
626 // Which copies in the grid are selected.
627 selectedCopyIds(rows: HoldingsEntry[], skipStatus?: number): number[] {
628 return this.selectedCopies(rows, skipStatus).map(c => Number(c.id()));
631 selectedVolIds(rows: HoldingsEntry[]): number[] {
633 .filter(r => Boolean(r.callNum))
634 .map(r => Number(r.callNum.id()));
637 selectedCopies(rows: HoldingsEntry[], skipStatus?: number): IdlObject[] {
638 let copyRows = rows.filter(r => Boolean(r.copy)).map(r => r.copy);
640 copyRows = copyRows.filter(
641 c => Number(c.status().id()) !== Number(skipStatus));
646 selectedCallNumIds(rows: HoldingsEntry[]): number[] {
647 return this.selectedCallNums(rows).map(cn => cn.id());
650 selectedCallNums(rows: HoldingsEntry[]): IdlObject[] {
652 .filter(r => r.treeNode.nodeType === 'callNum')
653 .map(r => r.callNum);
657 async showMarkDamagedDialog(rows: HoldingsEntry[]) {
658 const copyIds = this.selectedCopyIds(rows, 14 /* ignore damaged */);
660 if (copyIds.length === 0) { return; }
662 let rowsModified = false;
664 const markNext = async(ids: number[]) => {
665 if (ids.length === 0) {
666 return Promise.resolve();
669 this.markDamagedDialog.copyId = ids.pop();
670 return this.markDamagedDialog.open({size: 'lg'}).subscribe(
672 if (ok) { rowsModified = true; }
673 return markNext(ids);
675 dismiss => markNext(ids)
679 await markNext(copyIds);
681 this.refreshHoldings = true;
682 this.holdingsGrid.reload();
686 showMarkMissingDialog(rows: any[]) {
687 const copyIds = this.selectedCopyIds(rows, 4 /* ignore missing */);
688 if (copyIds.length > 0) {
689 this.markMissingDialog.copyIds = copyIds;
690 this.markMissingDialog.open({}).subscribe(
693 this.refreshHoldings = true;
694 this.holdingsGrid.reload();
697 dismissed => {} // avoid console errors
702 // Mark record, library, and potentially the selected call number
703 // as the current transfer target.
704 markLibCnForTransfer(rows: HoldingsEntry[]) {
705 if (rows.length === 0) {
709 // Action may only apply to a single org or call number row.
710 const node = rows[0].treeNode;
711 if (node.nodeType === 'copy') { return; }
715 if (node.nodeType === 'org') {
716 orgId = node.target.id();
718 // Clear call number target when performed on an org unit row
719 this.localStore.removeLocalItem('eg.cat.transfer_target_vol');
721 } else if (node.nodeType === 'callNum') {
723 // All call number nodes are children of org nodes.
724 orgId = node.parentNode.target.id();
726 // Add call number target when performed on a call number row.
727 this.localStore.setLocalItem(
728 'eg.cat.transfer_target_vol', node.target.id());
731 // Track lib and record to support transfering items from
732 // a different bib record to this record at the selected
734 this.localStore.setLocalItem('eg.cat.transfer_target_lib', orgId);
735 this.localStore.setLocalItem('eg.cat.transfer_target_record', this.recordId);
738 openAngJsWindow(path: string) {
739 const url = `/eg/staff/${path}`;
740 window.open(url, '_blank');
743 openItemHolds(rows: HoldingsEntry[]) {
744 if (rows.length > 0 && rows[0].copy) {
745 this.openAngJsWindow(`cat/item/${rows[0].copy.id()}/holds`);
749 openItemStatusList(rows: HoldingsEntry[]) {
750 const ids = this.selectedCopyIds(rows);
751 if (ids.length > 0) {
752 return this.openAngJsWindow(`cat/item/search/${ids.join(',')}`);
756 openItemStatus(rows: HoldingsEntry[]) {
757 if (rows.length > 0 && rows[0].copy) {
758 return this.openAngJsWindow(`cat/item/${rows[0].copy.id()}`);
762 openItemTriggeredEvents(rows: HoldingsEntry[]) {
763 if (rows.length > 0 && rows[0].copy) {
764 return this.openAngJsWindow(
765 `cat/item/${rows[0].copy.id()}/triggered_events`);
769 openItemPrintLabels(rows: HoldingsEntry[]) {
770 const ids = this.selectedCopyIds(rows);
771 if (ids.length === 0) { return; }
773 this.anonCache.setItem(null, 'print-labels-these-copies', {copies: ids})
774 .then(key => this.openAngJsWindow(`cat/printlabels/${key}`));
777 openHoldingEdit(rows: HoldingsEntry[], hideVols: boolean, hideCopies: boolean) {
779 // Avoid adding call number edit entries for call numbers
780 // that are already represented by selected items.
782 const copies = this.selectedCopies(rows);
783 const copyVols = copies.map(c => Number(c.call_number()));
786 this.selectedVolIds(rows).forEach(id => {
787 if (!copyVols.includes(id)) {
792 this.holdings.spawnAddHoldingsUi(
796 copies.map(c => Number(c.id())),
802 openHoldingAdd(rows: HoldingsEntry[], addCallNums: boolean, addCopies: boolean) {
804 // The user may select a set of call numbers by selecting call
805 // number and/or item rows. Owning libs for new call numbers may
806 // also come from org unit row selection.
810 if (r.treeNode.nodeType === 'callNum') {
811 callNums.push(r.callNum);
813 } else if (r.treeNode.nodeType === 'copy') {
814 callNums.push(r.treeNode.parentNode.target);
816 } else if (r.treeNode.nodeType === 'org') {
817 const org = r.treeNode.target;
818 if (org.ou_type().can_have_vols() === 't') {
819 orgs[org.id()] = true;
824 if (addCopies && !addCallNums) {
825 // Adding copies to an existing set of call numbers.
826 if (callNums.length > 0) {
827 const callNumIds = callNums.map(v => Number(v.id()));
828 this.holdings.spawnAddHoldingsUi(this.recordId, callNumIds);
831 } else if (addCallNums) {
834 // Use selected call numbers as basis for new call numbers.
835 callNums.forEach(v =>
836 entries.push({label: v.label(), owner: v.owning_lib()}));
838 // Use selected org units as owning libs for new call numbers
839 Object.keys(orgs).forEach(id => entries.push({owner: id}));
841 if (entries.length === 0) {
842 // Otherwise create new call numbers for "here"
843 entries.push({owner: this.auth.user().ws_ou()});
846 this.holdings.spawnAddHoldingsUi(
847 this.recordId, null, entries, null, !addCopies);
851 openItemNotes(rows: HoldingsEntry[], mode: string) {
852 const copyIds = this.selectedCopyIds(rows);
853 if (copyIds.length === 0) { return; }
855 this.copyAlertsDialog.copyIds = copyIds;
856 this.copyAlertsDialog.mode = mode;
857 this.copyAlertsDialog.open({size: 'lg'}).subscribe(
866 openReplaceBarcodeDialog(rows: HoldingsEntry[]) {
867 const ids = this.selectedCopyIds(rows);
868 if (ids.length === 0) { return; }
869 this.replaceBarcode.copyIds = ids;
870 this.replaceBarcode.open({}).subscribe(
879 // mode 'callNums' -- only delete empty call numbers
880 // mode 'copies' -- only delete selected copies
881 // mode 'both' -- delete selected copies and selected call numbers, plus all
882 // copies linked to selected call numbers, regardless of whether they are selected.
883 deleteHoldings(rows: HoldingsEntry[], mode: 'callNums' | 'copies' | 'both') {
884 const callNumHash: any = {};
886 if (mode === 'callNums' || mode === 'both') {
887 // Collect the call numbers to be deleted.
888 rows.filter(r => r.treeNode.nodeType === 'callNum').forEach(r => {
889 const callNum = this.idl.clone(r.callNum);
890 if (mode === 'callNums') {
891 if (callNum.copies().length > 0) {
892 // cannot delete non-empty call number in this mode.
896 callNum.copies().forEach(c => c.isdeleted(true));
898 callNum.isdeleted(true);
899 callNumHash[callNum.id()] = callNum;
903 if (mode === 'copies' || mode === 'both') {
904 // Collect the copies to be deleted, including their call numbers
905 // since the API expects fleshed call number objects.
906 rows.filter(r => r.treeNode.nodeType === 'copy').forEach(r => {
907 const callNum = r.treeNode.parentNode.target;
908 if (!callNumHash[callNum.id()]) {
909 callNumHash[callNum.id()] = this.idl.clone(callNum);
910 callNumHash[callNum.id()].copies([]);
912 const copy = this.idl.clone(r.copy);
913 copy.isdeleted(true);
914 callNumHash[callNum.id()].copies().push(copy);
918 if (Object.keys(callNumHash).length === 0) {
919 // No data to process.
923 // Note forceDeleteCopies should not be necessary here, since we
924 // manually marked all copies as deleted on deleted call numbers in
926 this.deleteHolding.forceDeleteCopies = mode === 'both';
927 this.deleteHolding.callNums = Object.values(callNumHash);
928 this.deleteHolding.open({size: 'sm'}).subscribe(
937 requestItems(rows: HoldingsEntry[]) {
938 const copyIds = this.selectedCopyIds(rows);
939 if (copyIds.length === 0) { return; }
940 const params = {target: copyIds, holdFor: 'staff'};
941 this.router.navigate(['/staff/catalog/hold/C'], {queryParams: params});
944 openBucketDialog(rows: HoldingsEntry[]) {
945 const copyIds = this.selectedCopyIds(rows);
946 if (copyIds.length > 0) {
947 this.bucketDialog.bucketClass = 'copy';
948 this.bucketDialog.itemIds = copyIds;
949 this.bucketDialog.open({size: 'lg'});
953 openConjoinedDialog(rows: HoldingsEntry[]) {
954 const copyIds = this.selectedCopyIds(rows);
955 if (copyIds.length > 0) {
956 this.conjoinedDialog.copyIds = copyIds;
957 this.conjoinedDialog.open({size: 'sm'});
961 bookItems(rows: HoldingsEntry[]) {
962 const copyIds = this.selectedCopyIds(rows);
963 if (copyIds.length > 0) {
964 this.router.navigate(
965 ['staff', 'booking', 'create_reservation', 'for_resource', rows.filter(r => Boolean(r.copy))[0].copy.barcode()]
970 makeBookable(rows: HoldingsEntry[]) {
971 const copyIds = this.selectedCopyIds(rows);
972 if (copyIds.length > 0) {
973 this.makeBookableDialog.copyIds = copyIds;
974 this.makeBookableDialog.open({});
978 manageReservations(rows: HoldingsEntry[]) {
979 const copyIds = this.selectedCopyIds(rows);
980 if (copyIds.length > 0) {
981 this.router.navigate(
982 ['staff', 'booking', 'manage_reservations', 'by_resource', rows.filter(r => Boolean(r.copy))[0].copy.barcode()]
987 transferSelectedItems(rows: HoldingsEntry[]) {
988 if (rows.length === 0) { return; }
991 this.localStore.getLocalItem('eg.cat.transfer_target_vol');
994 this.localStore.getLocalItem('eg.cat.transfer_target_lib');
997 this.localStore.getLocalItem('eg.cat.transfer_target_record');
1001 if (cnId) { // Direct call number transfer
1003 const itemIds = this.selectedCopyIds(rows);
1004 promise = this.transferItems.transferItems(itemIds, cnId);
1006 } else if (orgId && recId) { // "Auto" transfer
1008 // Clone the items to be modified to avoid any unexpected
1009 // modifications and fesh the call numbers.
1010 const items = this.idl.clone(this.selectedCopies(rows));
1011 items.forEach(i => i.call_number(
1012 this.treeNodeCache.callNum[i.call_number()].target));
1015 promise = this.transferItems.autoTransferItems(items, recId, orgId);
1018 promise = this.transferAlert.open().toPromise();
1021 promise.then(success => success ? this.hardRefresh() : null);
1024 transferSelectedHoldings(rows: HoldingsEntry[]) {
1025 const callNums = this.selectedCallNums(rows);
1026 if (callNums.length === 0) { return; }
1029 this.localStore.getLocalItem('eg.cat.transfer_target_lib');
1032 this.localStore.getLocalItem('eg.cat.transfer_target_record');
1035 // When transferring holdings (call numbers) between org units,
1036 // limit transfers to within the current record.
1037 recId = this.recordId;
1039 } else if (!recId) {
1040 // No destinations applied.
1041 return this.transferAlert.open();
1044 this.transferHoldings.targetRecId = recId;
1045 this.transferHoldings.targetOrgId = orgId;
1046 this.transferHoldings.callNums = callNums;
1048 this.transferHoldings.transferHoldings()
1049 .then(success => success ? this.hardRefresh() : null);