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, tap, concatMap} 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 {NetService} from '@eg/core/net.service';
11 import {PcrudService} from '@eg/core/pcrud.service';
12 import {AuthService} from '@eg/core/auth.service';
13 import {GridDataSource, GridColumn, GridCellTextGenerator} from '@eg/share/grid/grid';
14 import {GridComponent} from '@eg/share/grid/grid.component';
15 import {GridToolbarCheckboxComponent
16 } from '@eg/share/grid/grid-toolbar-checkbox.component';
17 import {StoreService} from '@eg/core/store.service';
18 import {ServerStoreService} from '@eg/core/server-store.service';
19 import {MarkDamagedDialogComponent
20 } from '@eg/staff/share/holdings/mark-damaged-dialog.component';
21 import {MarkMissingDialogComponent
22 } from '@eg/staff/share/holdings/mark-missing-dialog.component';
23 import {AnonCacheService} from '@eg/share/util/anon-cache.service';
24 import {HoldingsService} from '@eg/staff/share/holdings/holdings.service';
25 import {CopyAlertsDialogComponent
26 } from '@eg/staff/share/holdings/copy-alerts-dialog.component';
27 import {CopyTagsDialogComponent
28 } from '@eg/staff/share/holdings/copy-tags-dialog.component';
29 import {CopyNotesDialogComponent
30 } from '@eg/staff/share/holdings/copy-notes-dialog.component';
31 import {ReplaceBarcodeDialogComponent
32 } from '@eg/staff/share/holdings/replace-barcode-dialog.component';
33 import {DeleteHoldingDialogComponent
34 } from '@eg/staff/share/holdings/delete-volcopy-dialog.component';
35 import {BucketDialogComponent
36 } from '@eg/staff/share/buckets/bucket-dialog.component';
37 import {ConjoinedItemsDialogComponent
38 } from '@eg/staff/share/holdings/conjoined-items-dialog.component';
39 import {MakeBookableDialogComponent
40 } from '@eg/staff/share/booking/make-bookable-dialog.component';
41 import {TransferItemsComponent
42 } from '@eg/staff/share/holdings/transfer-items.component';
43 import {TransferHoldingsComponent
44 } from '@eg/staff/share/holdings/transfer-holdings.component';
45 import {AlertDialogComponent} from '@eg/share/dialog/alert.component';
46 import {BroadcastService} from '@eg/share/util/broadcast.service';
49 // The holdings grid models a single HoldingsTree, composed of HoldingsTreeNodes
50 // flattened on-demand into a list of HoldingEntry objects.
51 export class HoldingsTreeNode {
52 children: HoldingsTreeNode[];
53 nodeType: 'org' | 'callNum' | 'copy';
55 parentNode: HoldingsTreeNode;
65 root: HoldingsTreeNode;
67 this.root = new HoldingsTreeNode();
71 export class HoldingsEntry {
73 // org unit shortname, call number label, or copy barcode
74 locationLabel: string;
75 // location label indentation depth
76 locationDepth: number | null;
77 callNumCount: number | null;
78 copyCount: number | null;
79 callNumberLabel: string;
83 treeNode: HoldingsTreeNode;
87 selector: 'eg-holdings-maintenance',
88 templateUrl: 'holdings.component.html',
89 styleUrls: ['holdings.component.css'],
90 encapsulation: ViewEncapsulation.None
92 export class HoldingsMaintenanceComponent implements OnInit {
95 gridDataSource: GridDataSource;
96 gridTemplateContext: any;
97 @ViewChild('holdingsGrid', { static: true }) holdingsGrid: GridComponent;
99 // Manage visibility of various sub-sections
100 @ViewChild('callNumsCheckbox', { static: true })
101 private callNumsCheckbox: GridToolbarCheckboxComponent;
102 @ViewChild('copiesCheckbox', { static: true })
103 private copiesCheckbox: GridToolbarCheckboxComponent;
104 @ViewChild('emptyCallNumsCheckbox', { static: true })
105 private emptyCallNumsCheckbox: GridToolbarCheckboxComponent;
106 @ViewChild('emptyLibsCheckbox', { static: true })
107 private emptyLibsCheckbox: GridToolbarCheckboxComponent;
108 @ViewChild('markDamagedDialog', { static: true })
109 private markDamagedDialog: MarkDamagedDialogComponent;
110 @ViewChild('markMissingDialog', { static: true })
111 private markMissingDialog: MarkMissingDialogComponent;
112 @ViewChild('copyAlertsDialog', { static: true })
113 private copyAlertsDialog: CopyAlertsDialogComponent;
114 @ViewChild('copyTagsDialog', {static: false})
115 private copyTagsDialog: CopyTagsDialogComponent;
116 @ViewChild('copyNotesDialog', {static: false})
117 private copyNotesDialog: CopyNotesDialogComponent;
118 @ViewChild('replaceBarcode', { static: true })
119 private replaceBarcode: ReplaceBarcodeDialogComponent;
120 @ViewChild('deleteHolding', { static: true })
121 private deleteHolding: DeleteHoldingDialogComponent;
122 @ViewChild('bucketDialog', { static: true })
123 private bucketDialog: BucketDialogComponent;
124 @ViewChild('conjoinedDialog', { static: true })
125 private conjoinedDialog: ConjoinedItemsDialogComponent;
126 @ViewChild('makeBookableDialog', { static: true })
127 private makeBookableDialog: MakeBookableDialogComponent;
128 @ViewChild('transferItems', {static: false})
129 private transferItems: TransferItemsComponent;
130 @ViewChild('transferHoldings', {static: false})
131 private transferHoldings: TransferHoldingsComponent;
132 @ViewChild('transferAlert', {static: false})
133 private transferAlert: AlertDialogComponent;
135 holdingsTree: HoldingsTree;
137 // nodeType => id => tree node cache
138 treeNodeCache: {[nodeType: string]: {[id: number]: HoldingsTreeNode}};
140 // When true and a grid reload is called, the holdings data will be
141 // re-fetched from the server.
142 refreshHoldings: boolean;
144 // Used as a row identifier in th grid, since we're mixing object types.
147 // List of copies whose due date we need to retrieve.
148 itemCircsNeeded: IdlObject[];
150 // When true draw the grid based on the stored preferences.
151 // When not true, render based on the current "expanded" state of each node.
152 // Rendering from prefs happens on initial load and when any prefs change.
153 renderFromPrefs: boolean;
155 rowClassCallback: (row: any) => string;
157 cellTextGenerator: GridCellTextGenerator;
158 orgClassCallback: (orgId: number) => string;
159 marked_orgs: number[] = [];
161 copyCounts: {[orgId: number]: {}} = {};
163 private _recId: number;
164 @Input() set recordId(id: number) {
166 // Only force new data collection when recordId()
167 // is invoked after ngInit() has already run.
172 get recordId(): number {
176 contextOrg: IdlObject;
178 // The context org may come from a workstation setting.
179 // Wait for confirmation from the org-select (via onchange in this
180 // case) that the desired context org unit has been found.
181 contextOrgLoaded = false;
184 private router: Router,
185 private org: OrgService,
186 private idl: IdlService,
187 private pcrud: PcrudService,
188 private net: NetService,
189 private auth: AuthService,
190 private staffCat: StaffCatalogService,
191 private store: ServerStoreService,
192 private localStore: StoreService,
193 private holdings: HoldingsService,
194 private broadcaster: BroadcastService,
195 private anonCache: AnonCacheService
197 // Set some sane defaults before settings are loaded.
198 this.gridDataSource = new GridDataSource();
199 this.refreshHoldings = true;
200 this.renderFromPrefs = true;
202 // TODO: need a separate setting for this?
203 this.contextOrg = this.staffCat.searchContext.searchOrg;
205 this.rowClassCallback = (row: any): string => {
208 return 'holdings-copy-row';
210 return 'holdings-callNum-row';
213 // Add a generic org unit class and a depth-specific
214 // class for styling different levels of the org tree.
215 return 'holdings-org-row holdings-org-row-' +
216 row.treeNode.target.ou_type().depth();
221 // Text-ify function for cells that use display templates.
222 this.cellTextGenerator = {
223 owner_label: row => row.locationLabel,
224 holdable: row => row.copy ?
225 this.gridTemplateContext.copyIsHoldable(row.copy) : ''
228 this.orgClassCallback = (orgId: number): string => {
229 if (this.marked_orgs.includes(orgId)) { return 'font-weight-bold'; }
233 this.gridTemplateContext = {
234 toggleExpandRow: (row: HoldingsEntry) => {
235 row.treeNode.expanded = !row.treeNode.expanded;
237 if (!row.treeNode.expanded) {
238 // When collapsing a node, all child nodes should be
239 // collapsed as well.
240 const traverse = (node: HoldingsTreeNode) => {
241 node.expanded = false;
242 node.children.forEach(traverse);
244 traverse(row.treeNode);
247 this.holdingsGrid.reload();
250 copyIsHoldable: (copy: IdlObject): boolean => {
251 return copy.holdable() === 't'
252 && copy.location().holdable() === 't'
253 && copy.status().holdable() === 't';
259 this.initDone = true;
261 this.broadcaster.listen('eg.holdings.update').subscribe(data => {
262 if (data && data.records && data.records.includes(this.recordId)) {
264 // A hard refresh is needed to accommodate cases where
265 // a new call number is created for a subset of copies.
266 // We may revisit this later and use soft refresh
267 // (below) vs. hard refresh (above) depending on what
268 // specifically is changed.
269 // this.refreshHoldings = true;
270 // this.holdingsGrid.reload();
274 // These are pre-cached via the catalog resolver.
275 const settings = this.store.getItemBatchCached([
276 'cat.holdings_show_empty_org',
277 'cat.holdings_show_empty',
278 'cat.holdings_show_copies',
279 'cat.holdings_show_vols'
282 // Show call numbers by default when no preference is set.
283 let showCallNums = settings['cat.holdings_show_vols'];
284 if (showCallNums === null) { showCallNums = true; }
286 this.callNumsCheckbox.checked(showCallNums);
287 this.copiesCheckbox.checked(settings['cat.holdings_show_copies']);
288 this.emptyCallNumsCheckbox.checked(settings['cat.holdings_show_empty']);
289 this.emptyLibsCheckbox.checked(settings['cat.holdings_show_empty_org']);
291 this.gridDataSource.getRows = (pager: Pager, sort: any[]) => {
292 if (!this.contextOrgLoaded) { return empty(); }
293 return this.fetchHoldings(pager);
298 'open-ils.search.biblio.copy_counts.retrieve.staff',
300 ).toPromise().then(result => {
301 result.forEach(copy_count => {
302 this.marked_orgs.push(copy_count[0]);
307 // No data is loaded until the first occurrence of the org change handler
308 contextOrgChanged(org: IdlObject) {
309 this.contextOrgLoaded = true;
310 this.contextOrg = org;
315 this.renderFromPrefs = true;
316 this.refreshHoldings = true;
317 this.initHoldingsTree();
318 this.holdingsGrid.reload();
321 toggleShowCopies(value: boolean) {
322 this.store.setItem('cat.holdings_show_copies', value);
324 // Showing copies implies showing call numbers
325 this.callNumsCheckbox.checked(true);
327 this.renderFromPrefs = true;
328 this.holdingsGrid.reload();
331 toggleShowCallNums(value: boolean) {
332 this.store.setItem('cat.holdings_show_vols', value);
334 // Hiding call numbers implies hiding empty call numbers and copies.
335 this.copiesCheckbox.checked(false);
336 this.emptyCallNumsCheckbox.checked(false);
338 this.renderFromPrefs = true;
339 this.holdingsGrid.reload();
342 toggleShowEmptyCallNums(value: boolean) {
343 this.store.setItem('cat.holdings_show_empty', value);
345 this.callNumsCheckbox.checked(true);
347 this.renderFromPrefs = true;
348 this.holdingsGrid.reload();
351 toggleShowEmptyLibs(value: boolean) {
352 this.store.setItem('cat.holdings_show_empty_org', value);
353 this.renderFromPrefs = true;
354 this.holdingsGrid.reload();
357 onRowActivate(row: any) {
359 // Launch copy editor?
361 this.gridTemplateContext.toggleExpandRow(row);
367 const visibleOrgs = this.org.fullPath(this.contextOrg, true);
369 // The initial tree simply matches the org unit tree
370 const traverseOrg = (node: HoldingsTreeNode) => {
371 node.target.children().forEach((org: IdlObject) => {
372 if (visibleOrgs.indexOf(org.id()) === -1) {
373 return; // Org is outside of scope
375 const nodeChild = new HoldingsTreeNode();
376 nodeChild.nodeType = 'org';
377 nodeChild.target = org;
378 nodeChild.parentNode = node;
379 node.children.push(nodeChild);
380 this.treeNodeCache.org[org.id()] = nodeChild;
381 traverseOrg(nodeChild);
385 this.treeNodeCache = {
391 this.holdingsTree = new HoldingsTree();
392 this.holdingsTree.root.nodeType = 'org';
393 this.holdingsTree.root.target = this.org.root();
394 this.treeNodeCache.org[this.org.root().id()] = this.holdingsTree.root;
396 traverseOrg(this.holdingsTree.root);
399 // Org node children are sorted with any child org nodes pushed to the
400 // front, followed by the call number nodes sorted alphabetcially by label.
401 sortOrgNodeChildren(node: HoldingsTreeNode) {
402 node.children = node.children.sort((a, b) => {
403 if (a.nodeType === 'org') {
404 if (b.nodeType === 'org') {
405 return a.target.shortname() < b.target.shortname() ? -1 : 1;
409 } else if (b.nodeType === 'org') {
412 // TODO: should this use label sortkey instead of
413 // the compiled call number label?
414 return a.target._label < b.target._label ? -1 : 1;
419 // Sets call number and copy count sums to nodes that need it.
420 // Applies the initial expansed state of each container node.
421 setTreeCounts(node: HoldingsTreeNode) {
423 if (node.nodeType === 'org') {
424 node.copyCount = this.copyCounts[node.target.id() + ''].copies;
425 node.callNumCount = this.copyCounts[node.target.id() + ''].call_numbers;
426 } else if (node.nodeType === 'callNum') {
430 let hasChildOrgWithData = false;
431 let hasChildOrgSansData = false;
432 node.children.forEach(child => {
433 this.setTreeCounts(child);
434 if (node.nodeType === 'org') {
435 if (child.nodeType !== 'callNum') {
436 hasChildOrgWithData = child.callNumCount > 0;
437 hasChildOrgSansData = child.callNumCount === 0;
439 } else if (node.nodeType === 'callNum') {
440 node.copyCount = node.children.length;
441 if (this.renderFromPrefs) {
442 node.expanded = this.copiesCheckbox.checked();
447 if (this.renderFromPrefs && node.nodeType === 'org') {
448 if (node.copyCount > 0 && this.callNumsCheckbox.checked()) {
449 node.expanded = true;
450 } else if (node.callNumCount > 0 && this.emptyCallNumsCheckbox.checked()) {
451 node.expanded = true;
452 } else if (hasChildOrgWithData) {
453 node.expanded = true;
454 } else if (hasChildOrgSansData && this.emptyLibsCheckbox.checked()) {
455 node.expanded = true;
457 node.expanded = false;
462 // Create HoldingsEntry objects for tree nodes that should be displayed
463 // and relays them to the grid via the observer.
464 propagateTreeEntries(observer: Observer<HoldingsEntry>, node: HoldingsTreeNode) {
465 const entry = new HoldingsEntry();
466 entry.treeNode = node;
467 entry.index = this.gridIndex++;
469 switch (node.nodeType) {
471 if (node.callNumCount === 0
472 && !this.emptyLibsCheckbox.checked()) {
475 entry.locationLabel = node.target.shortname();
476 entry.locationDepth = node.target.ou_type().depth();
477 entry.copyCount = node.copyCount;
478 entry.callNumCount = node.callNumCount;
479 this.sortOrgNodeChildren(node);
483 if (this.renderFromPrefs) {
484 if (!this.callNumsCheckbox.checked()) {
487 if (node.copyCount === 0
488 && !this.emptyCallNumsCheckbox.checked()) {
492 entry.locationLabel = node.target._label;
493 entry.locationDepth = node.parentNode.target.ou_type().depth() + 1;
494 entry.callNumberLabel = entry.locationLabel;
495 entry.callNum = node.target;
496 entry.copyCount = node.copyCount;
500 entry.locationLabel = node.target.barcode();
501 entry.locationDepth = node.parentNode.parentNode.target.ou_type().depth() + 2;
502 entry.callNumberLabel = node.parentNode.target.label(); // TODO
503 entry.callNum = node.parentNode.target;
504 entry.copy = node.target;
505 entry.circ = node.target._circ;
509 // Tell the grid about the node entry
510 observer.next(entry);
513 // Process the child nodes.
514 node.children.forEach(child =>
515 this.propagateTreeEntries(observer, child));
519 // Turns the tree into a list of entries for grid display
520 flattenHoldingsTree(observer: Observer<HoldingsEntry>) {
522 this.setTreeCounts(this.holdingsTree.root);
523 this.propagateTreeEntries(observer, this.holdingsTree.root);
525 this.renderFromPrefs = false;
528 // Grab call numbers, copies, and related data.
529 fetchHoldings(pager: Pager): Observable<any> {
530 if (!this.recordId || this.recordId === -1) { return of([]); }
532 return new Observable<any>(observer => {
534 if (!this.refreshHoldings) {
535 this.flattenHoldingsTree(observer);
539 this.itemCircsNeeded = [];
540 // Track vol IDs for the current fetch so we can prune
541 // any that were deleted in an out-of-band update.
542 const volsFetched: number[] = [];
544 return this.net.request(
546 'open-ils.search.biblio.record.copy_counts.global.staff',
549 tap(counts => this.copyCounts = counts),
552 return this.pcrud.search('acn',
553 { record: this.recordId,
554 owning_lib: this.org.fullPath(this.contextOrg, true),
556 label: {'!=' : '##URI##'}
560 acp: ['status', 'location', 'circ_lib', 'parts', 'notes',
561 'tags', 'age_protect', 'copy_alerts', 'latest_inventory',
562 'total_circ_count', 'last_circ'],
563 acn: ['prefix', 'suffix', 'copies'],
564 acli: ['inventory_workstation']
567 {authoritative: true}
572 this.appendCallNum(callNum);
573 volsFetched.push(callNum.id());
577 this.refreshHoldings = false;
578 this.pruneVols(volsFetched);
579 this.fetchCircs().then(
580 ok => this.flattenHoldingsTree(observer)
587 // Remove vols that were deleted out-of-band, via edit, merge, etc.
588 pruneVols(volsFetched: number[]) {
590 const toRemove: number[] = []; // avoid modifying mid-loop
591 Object.keys(this.treeNodeCache.callNum).forEach(volId => {
592 const id = Number(volId);
593 if (!volsFetched.includes(id)) {
598 if (toRemove.length === 0) { return; }
600 const pruneNodes = (node: HoldingsTreeNode) => {
601 if (node.nodeType === 'callNum' &&
602 toRemove.includes(node.target.id())) {
604 console.debug('pruning deleted vol:', node.target.id());
606 // Remove this node from the parents list of children
607 node.parentNode.children =
608 node.parentNode.children.filter(
609 c => c.target.id() !== node.target.id());
612 node.children.forEach(c => pruneNodes(c));
617 toRemove.forEach(volId => delete this.treeNodeCache.callNum[volId]);
620 pruneNodes(this.holdingsTree.root);
622 // refresh tree / grid
623 this.holdingsGrid.reload();
626 // Retrieve circulation objects for checked out items.
627 fetchCircs(): Promise<any> {
628 const copyIds = this.itemCircsNeeded.map(copy => copy.id());
629 if (copyIds.length === 0) { return Promise.resolve(); }
631 return this.pcrud.search('circ', {
632 target_copy: copyIds,
634 }).pipe(map(circ => {
635 const copy = this.itemCircsNeeded.filter(
636 c => Number(c.id()) === Number(circ.target_copy()))[0];
641 // Compile prefix + label + suffix into field callNum._label;
642 setCallNumLabel(callNum: IdlObject) {
643 const pfx = callNum.prefix() ? callNum.prefix().label() : '';
644 const sfx = callNum.suffix() ? callNum.suffix().label() : '';
645 callNum._label = pfx ? pfx + ' ' : '';
646 callNum._label += callNum.label();
647 callNum._label += sfx ? ' ' + sfx : '';
650 // Create the tree node for the call number if it doesn't already exist.
651 // Do the same for its linked copies.
652 appendCallNum(callNum: IdlObject) {
653 let callNumNode = this.treeNodeCache.callNum[callNum.id()];
654 this.setCallNumLabel(callNum);
657 const pNode = this.treeNodeCache.org[callNum.owning_lib()];
658 if (callNumNode.parentNode.target.id() !== pNode.target.id()) {
659 callNumNode.parentNode = pNode;
660 callNumNode.parentNode.children.push(callNumNode);
663 callNumNode = new HoldingsTreeNode();
664 callNumNode.nodeType = 'callNum';
665 callNumNode.parentNode = this.treeNodeCache.org[callNum.owning_lib()];
666 callNumNode.parentNode.children.push(callNumNode);
667 this.treeNodeCache.callNum[callNum.id()] = callNumNode;
670 callNumNode.target = callNum;
673 .filter((copy: IdlObject) => (copy.deleted() !== 't'))
674 .sort((a: IdlObject, b: IdlObject) => a.barcode() < b.barcode() ? -1 : 1)
675 .forEach((copy: IdlObject) => this.appendCopy(callNumNode, copy));
678 // Find or create a copy node.
679 appendCopy(callNumNode: HoldingsTreeNode, copy: IdlObject) {
680 let copyNode = this.treeNodeCache.copy[copy.id()];
683 const oldParent = copyNode.parentNode;
684 if (oldParent.target.id() !== callNumNode.target.id()) {
685 // TODO: copy changed owning call number. Remove it from
686 // the previous call number before adding to the new call number.
687 copyNode.parentNode = callNumNode;
688 callNumNode.children.push(copyNode);
692 copyNode = new HoldingsTreeNode();
693 copyNode.nodeType = 'copy';
694 callNumNode.children.push(copyNode);
695 copyNode.parentNode = callNumNode;
696 this.treeNodeCache.copy[copy.id()] = copyNode;
699 copyNode.target = copy;
700 const stat = Number(copy.status().id());
701 copy._monograph_parts = '';
702 if (copy.parts().length > 0) {
703 copy._monograph_parts =
704 copy.parts().map(p => p.label()).join(',');
707 if (stat === 1 /* checked out */ || stat === 16 /* long overdue */) {
708 // Avoid looking up circs on items that are not checked out.
709 this.itemCircsNeeded.push(copy);
713 // Which copies in the grid are selected.
714 selectedCopyIds(rows: HoldingsEntry[], skipStatus?: number): number[] {
715 return this.selectedCopies(rows, skipStatus).map(c => Number(c.id()));
718 selectedVolIds(rows: HoldingsEntry[]): number[] {
720 .filter(r => Boolean(r.callNum))
721 .map(r => Number(r.callNum.id()));
724 selectedCopies(rows: HoldingsEntry[], skipStatus?: number): IdlObject[] {
725 let copyRows = rows.filter(r => Boolean(r.copy)).map(r => r.copy);
727 copyRows = copyRows.filter(
728 c => Number(c.status().id()) !== Number(skipStatus));
733 selectedCallNumIds(rows: HoldingsEntry[]): number[] {
734 return this.selectedCallNums(rows).map(cn => cn.id());
737 selectedCallNums(rows: HoldingsEntry[]): IdlObject[] {
739 .filter(r => r.treeNode.nodeType === 'callNum')
740 .map(r => r.callNum);
744 async showMarkDamagedDialog(rows: HoldingsEntry[]) {
745 const copyIds = this.selectedCopyIds(rows, 14 /* ignore damaged */);
747 if (copyIds.length === 0) { return; }
749 let rowsModified = false;
751 const markNext = async(ids: number[]) => {
752 if (ids.length === 0) {
753 return Promise.resolve();
756 this.markDamagedDialog.copyId = ids.pop();
757 return this.markDamagedDialog.open({size: 'lg'}).subscribe(
759 if (ok) { rowsModified = true; }
760 return markNext(ids);
762 dismiss => markNext(ids)
766 await markNext(copyIds);
768 this.refreshHoldings = true;
769 this.holdingsGrid.reload();
773 showMarkMissingDialog(rows: any[]) {
774 const copyIds = this.selectedCopyIds(rows, 4 /* ignore missing */);
775 if (copyIds.length > 0) {
776 this.markMissingDialog.copyIds = copyIds;
777 this.markMissingDialog.open({}).subscribe(
780 this.refreshHoldings = true;
781 this.holdingsGrid.reload();
784 dismissed => {} // avoid console errors
789 // Mark record, library, and potentially the selected call number
790 // as the current transfer target.
791 markLibCnForTransfer(rows: HoldingsEntry[]) {
792 if (rows.length === 0) {
796 // Action may only apply to a single org or call number row.
797 const node = rows[0].treeNode;
798 if (node.nodeType === 'copy') { return; }
802 if (node.nodeType === 'org') {
803 orgId = node.target.id();
805 // Clear call number target when performed on an org unit row
806 this.localStore.removeLocalItem('eg.cat.transfer_target_vol');
808 } else if (node.nodeType === 'callNum') {
810 // All call number nodes are children of org nodes.
811 orgId = node.parentNode.target.id();
813 // Add call number target when performed on a call number row.
814 this.localStore.setLocalItem(
815 'eg.cat.transfer_target_vol', node.target.id());
818 // Track lib and record to support transfering items from
819 // a different bib record to this record at the selected
821 this.localStore.setLocalItem('eg.cat.transfer_target_lib', orgId);
822 this.localStore.setLocalItem('eg.cat.transfer_target_record', this.recordId);
825 openAngJsWindow(path: string) {
826 const url = `/eg/staff/${path}`;
827 window.open(url, '_blank');
830 openItemHolds(rows: HoldingsEntry[]) {
831 if (rows.length > 0 && rows[0].copy) {
832 this.openAngJsWindow(`cat/item/${rows[0].copy.id()}/holds`);
836 openItemStatusList(rows: HoldingsEntry[]) {
837 const ids = this.selectedCopyIds(rows);
838 if (ids.length > 0) {
839 return this.openAngJsWindow(`cat/item/search/${ids.join(',')}`);
843 openItemStatus(rows: HoldingsEntry[]) {
844 if (rows.length > 0 && rows[0].copy) {
845 return this.openAngJsWindow(`cat/item/${rows[0].copy.id()}`);
849 openItemTriggeredEvents(rows: HoldingsEntry[]) {
850 if (rows.length > 0 && rows[0].copy) {
851 return this.openAngJsWindow(
852 `cat/item/${rows[0].copy.id()}/triggered_events`);
856 openItemPrintLabels(rows: HoldingsEntry[]) {
857 const ids = this.selectedCopyIds(rows);
858 if (ids.length === 0) { return; }
860 this.anonCache.setItem(null, 'print-labels-these-copies', {copies: ids})
861 .then(key => this.openAngJsWindow(`cat/printlabels/${key}`));
864 openHoldingEdit(rows: HoldingsEntry[], hideVols: boolean, hideCopies: boolean) {
866 // Avoid adding call number edit entries for call numbers
867 // that are already represented by selected items.
869 const copies = this.selectedCopies(rows);
870 const copyVols = copies.map(c => Number(c.call_number()));
873 this.selectedVolIds(rows).forEach(id => {
874 if (!copyVols.includes(id)) {
879 this.holdings.spawnAddHoldingsUi(
883 copies.map(c => Number(c.id())),
889 openHoldingAdd(rows: HoldingsEntry[], addCallNums: boolean, addCopies: boolean) {
891 // The user may select a set of call numbers by selecting call
892 // number and/or item rows. Owning libs for new call numbers may
893 // also come from org unit row selection.
897 if (r.treeNode.nodeType === 'callNum') {
898 callNums.push(r.callNum);
900 } else if (r.treeNode.nodeType === 'copy') {
901 callNums.push(r.treeNode.parentNode.target);
903 } else if (r.treeNode.nodeType === 'org') {
904 const org = r.treeNode.target;
905 if (org.ou_type().can_have_vols() === 't') {
906 orgs[org.id()] = true;
911 if (addCopies && !addCallNums) {
912 // Adding copies to an existing set of call numbers.
913 if (callNums.length > 0) {
914 const callNumIds = callNums.map(v => Number(v.id()));
915 this.holdings.spawnAddHoldingsUi(this.recordId, callNumIds);
918 } else if (addCallNums) {
921 // Use selected call numbers as basis for new call numbers.
922 callNums.forEach(v =>
923 entries.push({label: v.label(), owner: v.owning_lib()}));
925 // Use selected org units as owning libs for new call numbers
926 Object.keys(orgs).forEach(id => entries.push({owner: id}));
928 if (entries.length === 0) {
929 // Otherwise create new call numbers for "here"
930 entries.push({owner: this.auth.user().ws_ou()});
933 this.holdings.spawnAddHoldingsUi(
934 this.recordId, null, entries, null, !addCopies);
938 openItemAlerts(rows: HoldingsEntry[]) {
939 const copyIds = this.selectedCopyIds(rows);
940 if (copyIds.length === 0) { return; }
942 this.copyAlertsDialog.copyIds = copyIds;
943 this.copyAlertsDialog.open({size: 'lg'}).subscribe(
945 if (!changes) { return; }
946 if (changes.newAlerts.length > 0 || changes.changedAlerts.length > 0) {
953 openItemTags(rows: HoldingsEntry[]) {
954 const copyIds = this.selectedCopyIds(rows);
955 if (copyIds.length === 0) { return; }
957 this.copyTagsDialog.copyIds = copyIds;
958 this.copyTagsDialog.open({size: 'lg'}).subscribe(
960 if (changes.newTags.length > 0 || changes.deletedMaps.length > 0) {
967 openItemNotes(rows: HoldingsEntry[]) {
968 const copyIds = this.selectedCopyIds(rows);
969 if (copyIds.length === 0) { return; }
971 this.copyNotesDialog.copyIds = copyIds;
972 this.copyNotesDialog.open({size: 'lg'}).subscribe(
974 if (!changes) { return; }
975 if (changes.newNotes.length > 0 || changes.delNotes.length > 0) {
982 openReplaceBarcodeDialog(rows: HoldingsEntry[]) {
983 const ids = this.selectedCopyIds(rows);
984 if (ids.length === 0) { return; }
985 this.replaceBarcode.copyIds = ids;
986 this.replaceBarcode.open({}).subscribe(
995 // mode 'callNums' -- only delete empty call numbers
996 // mode 'copies' -- only delete selected copies
997 // mode 'both' -- delete selected copies and selected call numbers, plus all
998 // copies linked to selected call numbers, regardless of whether they are selected.
999 deleteHoldings(rows: HoldingsEntry[], mode: 'callNums' | 'copies' | 'both') {
1000 const callNumHash: any = {};
1002 if (mode === 'callNums' || mode === 'both') {
1003 // Collect the call numbers to be deleted.
1004 rows.filter(r => r.treeNode.nodeType === 'callNum').forEach(r => {
1005 const callNum = this.idl.clone(r.callNum);
1006 if (mode === 'callNums') {
1007 if (callNum.copies().length > 0) {
1008 // cannot delete non-empty call number in this mode.
1012 callNum.copies().forEach(c => c.isdeleted(true));
1014 callNum.isdeleted(true);
1015 callNumHash[callNum.id()] = callNum;
1019 if (mode === 'copies' || mode === 'both') {
1020 // Collect the copies to be deleted, including their call numbers
1021 // since the API expects fleshed call number objects.
1022 rows.filter(r => r.treeNode.nodeType === 'copy').forEach(r => {
1023 const callNum = r.treeNode.parentNode.target;
1024 if (!callNumHash[callNum.id()]) {
1025 callNumHash[callNum.id()] = this.idl.clone(callNum);
1026 callNumHash[callNum.id()].copies([]);
1028 const copy = this.idl.clone(r.copy);
1029 copy.isdeleted(true);
1030 callNumHash[callNum.id()].copies().push(copy);
1034 if (Object.keys(callNumHash).length === 0) {
1035 // No data to process.
1039 // Note forceDeleteCopies should not be necessary here, since we
1040 // manually marked all copies as deleted on deleted call numbers in
1042 this.deleteHolding.forceDeleteCopies = mode === 'both';
1043 this.deleteHolding.callNums = Object.values(callNumHash);
1044 this.deleteHolding.open({size: 'sm'}).subscribe(
1053 requestItems(rows: HoldingsEntry[]) {
1054 const copyIds = this.selectedCopyIds(rows);
1055 if (copyIds.length === 0) { return; }
1056 const params = {target: copyIds, holdFor: 'staff'};
1057 this.router.navigate(['/staff/catalog/hold/C'], {queryParams: params});
1060 openBucketDialog(rows: HoldingsEntry[]) {
1061 const copyIds = this.selectedCopyIds(rows);
1062 if (copyIds.length > 0) {
1063 this.bucketDialog.bucketClass = 'copy';
1064 this.bucketDialog.itemIds = copyIds;
1065 this.bucketDialog.open({size: 'lg'});
1069 openConjoinedDialog(rows: HoldingsEntry[]) {
1070 const copyIds = this.selectedCopyIds(rows);
1071 if (copyIds.length > 0) {
1072 this.conjoinedDialog.copyIds = copyIds;
1073 this.conjoinedDialog.open({size: 'sm'});
1077 bookItems(rows: HoldingsEntry[]) {
1078 const copyIds = this.selectedCopyIds(rows);
1079 if (copyIds.length > 0) {
1080 this.router.navigate(
1081 ['staff', 'booking', 'create_reservation', 'for_resource', rows.filter(r => Boolean(r.copy))[0].copy.barcode()]
1086 makeBookable(rows: HoldingsEntry[]) {
1087 const copyIds = this.selectedCopyIds(rows);
1088 if (copyIds.length > 0) {
1089 this.makeBookableDialog.copyIds = copyIds;
1090 this.makeBookableDialog.open({});
1094 manageReservations(rows: HoldingsEntry[]) {
1095 const copyIds = this.selectedCopyIds(rows);
1096 if (copyIds.length > 0) {
1097 this.router.navigate(
1098 ['staff', 'booking', 'manage_reservations', 'by_resource', rows.filter(r => Boolean(r.copy))[0].copy.barcode()]
1103 transferSelectedItems(rows: HoldingsEntry[]) {
1104 if (rows.length === 0) { return; }
1107 this.localStore.getLocalItem('eg.cat.transfer_target_vol');
1110 this.localStore.getLocalItem('eg.cat.transfer_target_lib');
1113 this.localStore.getLocalItem('eg.cat.transfer_target_record');
1117 if (cnId) { // Direct call number transfer
1119 const itemIds = this.selectedCopyIds(rows);
1120 promise = this.transferItems.transferItems(itemIds, cnId);
1122 } else if (orgId && recId) { // "Auto" transfer
1124 // Clone the items to be modified to avoid any unexpected
1125 // modifications and fesh the call numbers.
1126 const items = this.idl.clone(this.selectedCopies(rows));
1127 items.forEach(i => i.call_number(
1128 this.treeNodeCache.callNum[i.call_number()].target));
1131 promise = this.transferItems.autoTransferItems(items, recId, orgId);
1134 promise = this.transferAlert.open().toPromise();
1137 promise.then(success => success ? this.hardRefresh() : null);
1140 transferSelectedHoldings(rows: HoldingsEntry[]) {
1141 const callNums = this.selectedCallNums(rows);
1142 if (callNums.length === 0) { return; }
1145 this.localStore.getLocalItem('eg.cat.transfer_target_lib');
1148 this.localStore.getLocalItem('eg.cat.transfer_target_record');
1151 // When transferring holdings (call numbers) between org units,
1152 // limit transfers to within the current record.
1153 recId = this.recordId;
1155 } else if (!recId) {
1156 // No destinations applied.
1157 return this.transferAlert.open();
1160 this.transferHoldings.targetRecId = recId;
1161 this.transferHoldings.targetOrgId = orgId;
1162 this.transferHoldings.callNums = callNums;
1164 this.transferHoldings.transferHoldings()
1165 .then(success => success ? this.hardRefresh() : null);