1 import {Component, OnInit, Input, ViewChild} from '@angular/core';
2 import {Observable, Observer, of} from 'rxjs';
3 import {map} from 'rxjs/operators';
4 import {Pager} from '@eg/share/util/pager';
5 import {IdlObject, IdlService} from '@eg/core/idl.service';
6 import {StaffCatalogService} from '../catalog.service';
7 import {OrgService} from '@eg/core/org.service';
8 import {PcrudService} from '@eg/core/pcrud.service';
9 import {AuthService} from '@eg/core/auth.service';
10 import {GridDataSource} from '@eg/share/grid/grid';
11 import {GridComponent} from '@eg/share/grid/grid.component';
12 import {GridToolbarCheckboxComponent
13 } from '@eg/share/grid/grid-toolbar-checkbox.component';
14 import {StoreService} from '@eg/core/store.service';
15 import {ServerStoreService} from '@eg/core/server-store.service';
16 import {MarkDamagedDialogComponent
17 } from '@eg/staff/share/holdings/mark-damaged-dialog.component';
18 import {MarkMissingDialogComponent
19 } from '@eg/staff/share/holdings/mark-missing-dialog.component';
20 import {AnonCacheService} from '@eg/share/util/anon-cache.service';
21 import {HoldingsService} from '@eg/staff/share/holdings/holdings.service';
22 import {CopyAlertsDialogComponent
23 } from '@eg/staff/share/holdings/copy-alerts-dialog.component';
24 import {ReplaceBarcodeDialogComponent
25 } from '@eg/staff/share/holdings/replace-barcode-dialog.component';
26 import {DeleteVolcopyDialogComponent
27 } from '@eg/staff/share/holdings/delete-volcopy-dialog.component';
29 // The holdings grid models a single HoldingsTree, composed of HoldingsTreeNodes
30 // flattened on-demand into a list of HoldingEntry objects.
31 class HoldingsTreeNode {
32 children: HoldingsTreeNode[];
33 nodeType: 'org' | 'volume' | 'copy';
35 parentNode: HoldingsTreeNode;
45 root: HoldingsTreeNode;
47 this.root = new HoldingsTreeNode();
53 // org unit shortname, call number label, or copy barcode
54 locationLabel: string;
55 // location label indentation depth
56 locationDepth: number | null;
57 volumeCount: number | null;
58 copyCount: number | null;
59 callNumberLabel: string;
63 treeNode: HoldingsTreeNode;
67 selector: 'eg-holdings-maintenance',
68 templateUrl: 'holdings.component.html',
69 styleUrls: ['holdings.component.css']
71 export class HoldingsMaintenanceComponent implements OnInit {
74 gridDataSource: GridDataSource;
75 gridTemplateContext: any;
76 @ViewChild('holdingsGrid') holdingsGrid: GridComponent;
78 // Manage visibility of various sub-sections
79 @ViewChild('volsCheckbox')
80 private volsCheckbox: GridToolbarCheckboxComponent;
81 @ViewChild('copiesCheckbox')
82 private copiesCheckbox: GridToolbarCheckboxComponent;
83 @ViewChild('emptyVolsCheckbox')
84 private emptyVolsCheckbox: GridToolbarCheckboxComponent;
85 @ViewChild('emptyLibsCheckbox')
86 private emptyLibsCheckbox: GridToolbarCheckboxComponent;
87 @ViewChild('markDamagedDialog')
88 private markDamagedDialog: MarkDamagedDialogComponent;
89 @ViewChild('markMissingDialog')
90 private markMissingDialog: MarkMissingDialogComponent;
91 @ViewChild('copyAlertsDialog')
92 private copyAlertsDialog: CopyAlertsDialogComponent;
93 @ViewChild('replaceBarcode')
94 private replaceBarcode: ReplaceBarcodeDialogComponent;
95 @ViewChild('deleteVolcopy')
96 private deleteVolcopy: DeleteVolcopyDialogComponent;
98 holdingsTree: HoldingsTree;
100 // nodeType => id => tree node cache
101 treeNodeCache: {[nodeType: string]: {[id: number]: HoldingsTreeNode}};
103 // When true and a grid reload is called, the holdings data will be
104 // re-fetched from the server.
105 refreshHoldings: boolean;
107 // Used as a row identifier in th grid, since we're mixing object types.
110 // List of copies whose due date we need to retrieve.
111 itemCircsNeeded: IdlObject[];
113 // When true draw the grid based on the stored preferences.
114 // When not true, render based on the current "expanded" state of each node.
115 // Rendering from prefs happens on initial load and when any prefs change.
116 renderFromPrefs: boolean;
118 rowClassCallback: (row: any) => string;
120 private _recId: number;
121 @Input() set recordId(id: number) {
123 // Only force new data collection when recordId()
124 // is invoked after ngInit() has already run.
129 get recordId(): number {
133 contextOrg: IdlObject;
136 private org: OrgService,
137 private idl: IdlService,
138 private pcrud: PcrudService,
139 private auth: AuthService,
140 private staffCat: StaffCatalogService,
141 private store: ServerStoreService,
142 private localStore: StoreService,
143 private holdings: HoldingsService,
144 private anonCache: AnonCacheService
146 // Set some sane defaults before settings are loaded.
147 this.gridDataSource = new GridDataSource();
148 this.refreshHoldings = true;
149 this.renderFromPrefs = true;
151 // TODO: need a separate setting for this?
152 this.contextOrg = this.staffCat.searchContext.searchOrg;
154 this.rowClassCallback = (row: any): string => {
157 return 'holdings-copy-row';
159 return 'holdings-volume-row';
162 // Add a generic org unit class and a depth-specific
163 // class for styling different levels of the org tree.
164 return 'holdings-org-row holdings-org-row-' +
165 row.treeNode.target.ou_type().depth();
169 this.gridTemplateContext = {
170 toggleExpandRow: (row: HoldingsEntry) => {
171 row.treeNode.expanded = !row.treeNode.expanded;
173 if (!row.treeNode.expanded) {
174 // When collapsing a node, all child nodes should be
175 // collapsed as well.
176 const traverse = (node: HoldingsTreeNode) => {
177 node.expanded = false;
178 node.children.forEach(traverse);
180 traverse(row.treeNode);
183 this.holdingsGrid.reload();
186 copyIsHoldable: (copy: IdlObject): boolean => {
187 return copy.holdable() === 't'
188 && copy.location().holdable() === 't'
189 && copy.status().holdable() === 't';
195 this.initDone = true;
197 // These are pre-cached via the catalog resolver.
198 const settings = this.store.getItemBatchCached([
199 'cat.holdings_show_empty_org',
200 'cat.holdings_show_empty',
201 'cat.holdings_show_copies',
202 'cat.holdings_show_vols'
205 // Show volumes by default when no preference is set.
206 let showVols = settings['cat.holdings_show_vols'];
207 if (showVols === null) { showVols = true; }
209 this.volsCheckbox.checked(showVols);
210 this.copiesCheckbox.checked(settings['cat.holdings_show_copies']);
211 this.emptyVolsCheckbox.checked(settings['cat.holdings_show_empty']);
212 this.emptyLibsCheckbox.checked(settings['cat.holdings_show_empty_org']);
214 this.initHoldingsTree();
215 this.gridDataSource.getRows = (pager: Pager, sort: any[]) => {
216 return this.fetchHoldings(pager);
220 contextOrgChanged(org: IdlObject) {
221 this.contextOrg = org;
226 this.renderFromPrefs = true;
227 this.refreshHoldings = true;
228 this.initHoldingsTree();
229 this.holdingsGrid.reload();
232 toggleShowCopies(value: boolean) {
233 this.store.setItem('cat.holdings_show_copies', value);
235 // Showing copies implies showing volumes
236 this.volsCheckbox.checked(true);
238 this.renderFromPrefs = true;
239 this.holdingsGrid.reload();
242 toggleShowVolumes(value: boolean) {
243 this.store.setItem('cat.holdings_show_vols', value);
245 // Hiding volumes implies hiding empty vols and copies.
246 this.copiesCheckbox.checked(false);
247 this.emptyVolsCheckbox.checked(false);
249 this.renderFromPrefs = true;
250 this.holdingsGrid.reload();
253 toggleShowEmptyVolumes(value: boolean) {
254 this.store.setItem('cat.holdings_show_empty', value);
256 this.volsCheckbox.checked(true);
258 this.renderFromPrefs = true;
259 this.holdingsGrid.reload();
262 toggleShowEmptyLibs(value: boolean) {
263 this.store.setItem('cat.holdings_show_empty_org', value);
264 this.renderFromPrefs = true;
265 this.holdingsGrid.reload();
268 onRowActivate(row: any) {
270 // Launch copy editor?
272 this.gridTemplateContext.toggleExpandRow(row);
278 const visibleOrgs = this.org.fullPath(this.contextOrg, true);
280 // The initial tree simply matches the org unit tree
281 const traverseOrg = (node: HoldingsTreeNode) => {
282 node.target.children().forEach((org: IdlObject) => {
283 if (visibleOrgs.indexOf(org.id()) == -1) {
284 return; // Org is outside of scope
286 const nodeChild = new HoldingsTreeNode();
287 nodeChild.nodeType = 'org';
288 nodeChild.target = org;
289 nodeChild.parentNode = node;
290 node.children.push(nodeChild);
291 this.treeNodeCache.org[org.id()] = nodeChild;
292 traverseOrg(nodeChild);
296 this.treeNodeCache = {
302 this.holdingsTree = new HoldingsTree();
303 this.holdingsTree.root.nodeType = 'org';
304 this.holdingsTree.root.target = this.org.root();
305 this.treeNodeCache.org[this.org.root().id()] = this.holdingsTree.root;
307 traverseOrg(this.holdingsTree.root);
310 // Org node children are sorted with any child org nodes pushed to the
311 // front, followed by the call number nodes sorted alphabetcially by label.
312 sortOrgNodeChildren(node: HoldingsTreeNode) {
313 node.children = node.children.sort((a, b) => {
314 if (a.nodeType === 'org') {
315 if (b.nodeType === 'org') {
316 return a.target.shortname() < b.target.shortname() ? -1 : 1;
320 } else if (b.nodeType === 'org') {
323 // TODO: should this use label sortkey instead of
324 // the compiled volume label?
325 return a.target._label < b.target._label ? -1 : 1;
330 // Sets call number and copy count sums to nodes that need it.
331 // Applies the initial expansed state of each container node.
332 setTreeCounts(node: HoldingsTreeNode) {
334 if (node.nodeType === 'org') {
336 node.volumeCount = 0;
337 } else if(node.nodeType === 'volume') {
341 let hasChildOrgWithData = false;
342 let hasChildOrgSansData = false;
343 node.children.forEach(child => {
344 this.setTreeCounts(child);
345 if (node.nodeType === 'org') {
346 node.copyCount += child.copyCount;
347 if (child.nodeType === 'volume') {
350 hasChildOrgWithData = child.volumeCount > 0;
351 hasChildOrgSansData = child.volumeCount === 0;
352 node.volumeCount += child.volumeCount;
354 } else if (node.nodeType === 'volume') {
355 node.copyCount = node.children.length;
356 if (this.renderFromPrefs) {
357 node.expanded = this.copiesCheckbox.checked();
362 if (this.renderFromPrefs && node.nodeType === 'org') {
363 if (node.copyCount > 0 && this.volsCheckbox.checked()) {
364 node.expanded = true;
365 } else if (node.volumeCount > 0 && this.emptyVolsCheckbox.checked()) {
366 node.expanded = true;
367 } else if (hasChildOrgWithData) {
368 node.expanded = true;
369 } else if (hasChildOrgSansData && this.emptyLibsCheckbox.checked()) {
370 node.expanded = true;
372 node.expanded = false;
377 // Create HoldingsEntry objects for tree nodes that should be displayed
378 // and relays them to the grid via the observer.
379 propagateTreeEntries(observer: Observer<HoldingsEntry>, node: HoldingsTreeNode) {
380 const entry = new HoldingsEntry();
381 entry.treeNode = node;
382 entry.index = this.gridIndex++;
384 switch(node.nodeType) {
386 if (node.volumeCount === 0
387 && !this.emptyLibsCheckbox.checked()) {
390 entry.locationLabel = node.target.shortname();
391 entry.locationDepth = node.target.ou_type().depth();
392 entry.copyCount = node.copyCount;
393 entry.volumeCount = node.volumeCount;
394 this.sortOrgNodeChildren(node);
398 if (this.renderFromPrefs) {
399 if (!this.volsCheckbox.checked()) {
402 if (node.copyCount === 0
403 && !this.emptyVolsCheckbox.checked()) {
407 entry.locationLabel = node.target._label;
408 entry.locationDepth = node.parentNode.target.ou_type().depth() + 1;
409 entry.callNumberLabel = entry.locationLabel;
410 entry.volume = node.target;
411 entry.copyCount = node.copyCount;
415 entry.locationLabel = node.target.barcode();
416 entry.locationDepth = node.parentNode.parentNode.target.ou_type().depth() + 2;
417 entry.callNumberLabel = node.parentNode.target.label() // TODO
418 entry.volume = node.parentNode.target;
419 entry.copy = node.target;
420 entry.circ = node.target._circ;
424 // Tell the grid about the node entry
425 observer.next(entry);
428 // Process the child nodes.
429 node.children.forEach(child =>
430 this.propagateTreeEntries(observer, child));
434 // Turns the tree into a list of entries for grid display
435 flattenHoldingsTree(observer: Observer<HoldingsEntry>) {
437 this.setTreeCounts(this.holdingsTree.root);
438 this.propagateTreeEntries(observer, this.holdingsTree.root);
440 this.renderFromPrefs = false;
443 // Grab volumes, copies, and related data.
444 fetchHoldings(pager: Pager): Observable<any> {
445 if (!this.recordId) { return of([]); }
447 return new Observable<any>(observer => {
449 if (!this.refreshHoldings) {
450 this.flattenHoldingsTree(observer);
454 this.itemCircsNeeded = [];
456 this.pcrud.search('acn',
457 { record: this.recordId,
458 owning_lib: this.org.fullPath(this.contextOrg, true),
460 label: {'!=' : '##URI##'}
464 acp: ['status', 'location', 'circ_lib', 'parts',
465 'age_protect', 'copy_alerts', 'latest_inventory'],
466 acn: ['prefix', 'suffix', 'copies'],
467 acli: ['inventory_workstation']
470 {authoritative: true}
472 vol => this.appendVolume(vol),
475 this.refreshHoldings = false;
476 this.fetchCircs().then(
477 ok => this.flattenHoldingsTree(observer)
484 // Retrieve circulation objects for checked out items.
485 fetchCircs(): Promise<any> {
486 const copyIds = this.itemCircsNeeded.map(copy => copy.id());
487 if (copyIds.length === 0) { return Promise.resolve(); }
489 return this.pcrud.search('circ', {
490 target_copy: copyIds,
492 }).pipe(map(circ => {
493 const copy = this.itemCircsNeeded.filter(
494 c => Number(c.id()) === Number(circ.target_copy()))[0];
499 // Compile prefix + label + suffix into field volume._label;
500 setVolumeLabel(volume: IdlObject) {
501 const pfx = volume.prefix() ? volume.prefix().label() : '';
502 const sfx = volume.suffix() ? volume.suffix().label() : '';
503 volume._label = pfx ? pfx + ' ' : '';
504 volume._label += volume.label();
505 volume._label += sfx ? ' ' + sfx : '';
508 // Create the tree node for the volume if it doesn't already exist.
509 // Do the same for its linked copies.
510 appendVolume(volume: IdlObject) {
511 let volNode = this.treeNodeCache.volume[volume.id()];
512 this.setVolumeLabel(volume);
515 const pNode = this.treeNodeCache.org[volume.owning_lib()]
516 if (volNode.parentNode.target.id() !== pNode.target.id()) {
517 // Volume owning library changed. Un-link it from the previous
518 // org unit collection before adding to the new one.
520 volNode.parentNode = pNode;
521 volNode.parentNode.children.push(volNode);
524 volNode = new HoldingsTreeNode();
525 volNode.nodeType = 'volume';
526 volNode.parentNode = this.treeNodeCache.org[volume.owning_lib()]
527 volNode.parentNode.children.push(volNode);
528 this.treeNodeCache.volume[volume.id()] = volNode;
531 volNode.target = volume;
534 .filter((copy: IdlObject) => (copy.deleted() !== 't'))
535 .sort((a: IdlObject, b: IdlObject) => a.barcode() < b.barcode() ? -1 : 1)
536 .forEach((copy: IdlObject) => this.appendCopy(volNode, copy));
539 // Find or create a copy node.
540 appendCopy(volNode: HoldingsTreeNode, copy: IdlObject) {
541 let copyNode = this.treeNodeCache.copy[copy.id()];
544 const oldParent = copyNode.parentNode;
545 if (oldParent.target.id() !== volNode.target.id()) {
546 // TODO: copy changed owning volume. Remove it from
547 // the previous volume before adding to the new volume.
548 copyNode.parentNode = volNode;
549 volNode.children.push(copyNode);
553 copyNode = new HoldingsTreeNode();
554 copyNode.nodeType = 'copy';
555 volNode.children.push(copyNode);
556 copyNode.parentNode = volNode;
557 this.treeNodeCache.copy[copy.id()] = copyNode;
560 copyNode.target = copy;
561 const stat = Number(copy.status().id());
563 if (stat === 1 /* checked out */ || stat === 16 /* long overdue */) {
564 // Avoid looking up circs on items that are not checked out.
565 this.itemCircsNeeded.push(copy);
569 // Which copies in the grid are selected.
570 selectedCopyIds(rows: HoldingsEntry[], skipStatus?: number): number[] {
571 let copyRows = rows.filter(r => Boolean(r.copy)).map(r => r.copy);
573 copyRows = copyRows.filter(
574 c => Number(c.status().id()) !== Number(skipStatus));
576 return copyRows.map(c => Number(c.id()));
579 selectedVolumeIds(rows: HoldingsEntry[]): number[] {
581 .filter(r => r.treeNode.nodeType === 'volume')
582 .map(r => Number(r.volume.id()));
585 async showMarkDamagedDialog(rows: HoldingsEntry[]) {
586 const copyIds = this.selectedCopyIds(rows, 14 /* ignore damaged */);
588 if (copyIds.length === 0) { return; }
590 let rowsModified = false;
592 const markNext = async(ids: number[]) => {
593 if (ids.length === 0) {
594 return Promise.resolve();
597 this.markDamagedDialog.copyId = ids.pop();
598 return this.markDamagedDialog.open({size: 'lg'}).then(
600 if (ok) { rowsModified = true; }
601 return markNext(ids);
603 dismiss => markNext(ids)
607 await markNext(copyIds);
609 this.refreshHoldings = true;
610 this.holdingsGrid.reload();
614 showMarkMissingDialog(rows: any[]) {
615 const copyIds = this.selectedCopyIds(rows, 4 /* ignore missing */);
616 if (copyIds.length > 0) {
617 this.markMissingDialog.copyIds = copyIds;
618 this.markMissingDialog.open({}).then(
621 this.refreshHoldings = true;
622 this.holdingsGrid.reload();
625 dismissed => {} // avoid console errors
630 // Mark record, library, and potentially the selected call number
631 // as the current transfer target.
632 markLibCnForTransfer(rows: HoldingsEntry[]) {
633 if (rows.length === 0) {
637 // Action may only apply to a single org or volume row.
638 const node = rows[0].treeNode;
639 if (node.nodeType === 'copy') {
645 if (node.nodeType === 'org') {
646 orgId = node.target.id();
648 // Clear volume target when performed on an org unit row
649 this.localStore.removeLocalItem('eg.cat.transfer_target_vol');
651 } else if (node.nodeType === 'volume') {
653 // All volume nodes are children of org nodes.
654 orgId = node.parentNode.target.id();
656 // Add volume target when performed on a volume row.
657 this.localStore.setLocalItem(
658 'eg.cat.transfer_target_vol', node.target.id())
661 this.localStore.setLocalItem('eg.cat.transfer_target_record', this.recordId);
662 this.localStore.setLocalItem('eg.cat.transfer_target_lib', orgId);
665 openAngJsWindow(path: string) {
666 const url = `/eg/staff/${path}`;
667 window.open(url, '_blank');
670 openItemHolds(rows: HoldingsEntry[]) {
671 if (rows.length > 0 && rows[0].copy) {
672 this.openAngJsWindow(`cat/item/${rows[0].copy.id()}/holds`);
676 openItemStatusList(rows: HoldingsEntry[]) {
677 const ids = this.selectedCopyIds(rows);
678 if (ids.length > 0) {
679 return this.openAngJsWindow(`cat/item/search/${ids.join(',')}`);
683 openItemStatus(rows: HoldingsEntry[]) {
684 if (rows.length > 0 && rows[0].copy) {
685 return this.openAngJsWindow(`cat/item/${rows[0].copy.id()}`);
689 openItemTriggeredEvents(rows: HoldingsEntry[]) {
690 if (rows.length > 0 && rows[0].copy) {
691 return this.openAngJsWindow(
692 `cat/item/${rows[0].copy.id()}/triggered_events`);
696 openItemPrintLabels(rows: HoldingsEntry[]) {
697 const ids = this.selectedCopyIds(rows);
698 if (ids.length === 0) { return; }
700 this.anonCache.setItem(null, 'print-labels-these-copies', {copies: ids})
701 .then(key => this.openAngJsWindow(`cat/printlabels/${key}`));
704 openVolCopyEdit(rows: HoldingsEntry[], addVols: boolean, addCopies: boolean) {
706 // The user may select a set of volumes by selecting volume and/or
710 if (r.treeNode.nodeType === 'volume') {
711 volumes.push(r.volume);
712 } else if (r.treeNode.nodeType === 'copy') {
713 volumes.push(r.treeNode.parentNode.target);
717 if (addCopies && !addVols) {
718 // Adding copies to an existing set of volumes.
719 if (volumes.length > 0) {
720 const volIds = volumes.map(v => Number(v.id()));
721 this.holdings.spawnAddHoldingsUi(this.recordId, volIds);
724 } else if (addVols) {
727 if (volumes.length > 0) {
729 // When adding volumes, if any are selected in the grid,
730 // create volumes that have the same label and owner.
732 entries.push({label: v.label(), owner: v.owning_lib()}));
736 // Otherwise create new volumes from scratch.
737 entries.push({owner: this.auth.user().ws_ou()})
740 this.holdings.spawnAddHoldingsUi(
741 this.recordId, null, entries, !addCopies);
745 openItemNotes(rows: HoldingsEntry[], mode: string) {
746 const copyIds = this.selectedCopyIds(rows);
747 if (copyIds.length === 0) { return; }
749 this.copyAlertsDialog.copyIds = copyIds;
750 this.copyAlertsDialog.mode = mode;
751 this.copyAlertsDialog.open({size: 'lg'}).then(
761 openReplaceBarcodeDialog(rows: HoldingsEntry[]) {
762 const ids = this.selectedCopyIds(rows);
763 if (ids.length === 0) { return; }
764 this.replaceBarcode.copyIds = ids;
765 this.replaceBarcode.open({}).then(
775 // mode 'vols' -- only delete empty volumes
776 // mode 'copies' -- only delete selected copies
777 // mode 'both' -- delete selected copies and selected volumes, plus all
778 // copies linked to selected volumes, regardless of whether they are selected.
779 deleteHoldings(rows: HoldingsEntry[], mode: 'vols' | 'copies' | 'both') {
780 const volHash: any = {};
782 if (mode === 'vols' || mode === 'both') {
783 // Collect the volumes to be deleted.
784 rows.filter(r => r.treeNode.nodeType === 'volume').forEach(r => {
785 const vol = this.idl.clone(r.volume);
786 if (mode === 'vols') {
787 if (vol.copies().length > 0) {
788 // cannot delete non-empty volume in this mode.
792 vol.copies().forEach(c => c.isdeleted(true));
795 volHash[vol.id()] = vol;
799 if (mode === 'copies' || mode === 'both') {
800 // Collect the copies to be deleted, including their volumes
801 // since the API expects fleshed volume objects.
802 rows.filter(r => r.treeNode.nodeType === 'copy').forEach(r => {
803 const vol = r.treeNode.parentNode.target;
804 if (!volHash[vol.id()]) {
805 volHash[vol.id()] = this.idl.clone(vol);
806 volHash[vol.id()].copies([]);
808 const copy = this.idl.clone(r.copy);
809 copy.isdeleted(true);
810 volHash[vol.id()].copies().push(copy);
814 if (Object.keys(volHash).length === 0) {
815 // No data to process.
819 // Note forceDeleteCopies should not be necessary here, since we
820 // manually marked all copies as deleted on deleted volumes in
822 this.deleteVolcopy.forceDeleteCopies = mode === 'both';
823 this.deleteVolcopy.volumes = Object.values(volHash);
824 this.deleteVolcopy.open({size: 'sm'}).then(