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} 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';
27 // The holdings grid models a single HoldingsTree, composed of HoldingsTreeNodes
28 // flattened on-demand into a list of HoldingEntry objects.
29 class HoldingsTreeNode {
30 children: HoldingsTreeNode[];
31 nodeType: 'org' | 'volume' | 'copy';
33 parentNode: HoldingsTreeNode;
43 root: HoldingsTreeNode;
45 this.root = new HoldingsTreeNode();
51 // org unit shortname, call number label, or copy barcode
52 locationLabel: string;
53 // location label indentation depth
54 locationDepth: number | null;
55 volumeCount: number | null;
56 copyCount: number | null;
57 callNumberLabel: string;
61 treeNode: HoldingsTreeNode;
65 selector: 'eg-holdings-maintenance',
66 templateUrl: 'holdings.component.html',
67 styleUrls: ['holdings.component.css']
69 export class HoldingsMaintenanceComponent implements OnInit {
72 gridDataSource: GridDataSource;
73 gridTemplateContext: any;
74 @ViewChild('holdingsGrid') holdingsGrid: GridComponent;
76 // Manage visibility of various sub-sections
77 @ViewChild('volsCheckbox')
78 private volsCheckbox: GridToolbarCheckboxComponent;
79 @ViewChild('copiesCheckbox')
80 private copiesCheckbox: GridToolbarCheckboxComponent;
81 @ViewChild('emptyVolsCheckbox')
82 private emptyVolsCheckbox: GridToolbarCheckboxComponent;
83 @ViewChild('emptyLibsCheckbox')
84 private emptyLibsCheckbox: GridToolbarCheckboxComponent;
85 @ViewChild('markDamagedDialog')
86 private markDamagedDialog: MarkDamagedDialogComponent;
87 @ViewChild('markMissingDialog')
88 private markMissingDialog: MarkMissingDialogComponent;
89 @ViewChild('copyAlertsDialog')
90 private copyAlertsDialog: CopyAlertsDialogComponent;
91 @ViewChild('replaceBarcode')
92 private replaceBarcode: ReplaceBarcodeDialogComponent;
94 holdingsTree: HoldingsTree;
96 // nodeType => id => tree node cache
97 treeNodeCache: {[nodeType: string]: {[id: number]: HoldingsTreeNode}};
99 // When true and a grid reload is called, the holdings data will be
100 // re-fetched from the server.
101 refreshHoldings: boolean;
103 // Used as a row identifier in th grid, since we're mixing object types.
106 // List of copies whose due date we need to retrieve.
107 itemCircsNeeded: IdlObject[];
109 // When true draw the grid based on the stored preferences.
110 // When not true, render based on the current "expanded" state of each node.
111 // Rendering from prefs happens on initial load and when any prefs change.
112 renderFromPrefs: boolean;
114 rowClassCallback: (row: any) => string;
116 private _recId: number;
117 @Input() set recordId(id: number) {
119 // Only force new data collection when recordId()
120 // is invoked after ngInit() has already run.
125 get recordId(): number {
129 contextOrg: IdlObject;
132 private org: OrgService,
133 private pcrud: PcrudService,
134 private auth: AuthService,
135 private staffCat: StaffCatalogService,
136 private store: ServerStoreService,
137 private localStore: StoreService,
138 private holdings: HoldingsService,
139 private anonCache: AnonCacheService
141 // Set some sane defaults before settings are loaded.
142 this.gridDataSource = new GridDataSource();
143 this.refreshHoldings = true;
144 this.renderFromPrefs = true;
146 // TODO: need a separate setting for this?
147 this.contextOrg = this.staffCat.searchContext.searchOrg;
149 this.rowClassCallback = (row: any): string => {
152 return 'holdings-copy-row';
154 return 'holdings-volume-row';
157 // Add a generic org unit class and a depth-specific
158 // class for styling different levels of the org tree.
159 return 'holdings-org-row holdings-org-row-' +
160 row.treeNode.target.ou_type().depth();
164 this.gridTemplateContext = {
165 toggleExpandRow: (row: HoldingsEntry) => {
166 row.treeNode.expanded = !row.treeNode.expanded;
168 if (!row.treeNode.expanded) {
169 // When collapsing a node, all child nodes should be
170 // collapsed as well.
171 const traverse = (node: HoldingsTreeNode) => {
172 node.expanded = false;
173 node.children.forEach(traverse);
175 traverse(row.treeNode);
178 this.holdingsGrid.reload();
181 copyIsHoldable: (copy: IdlObject): boolean => {
182 return copy.holdable() === 't'
183 && copy.location().holdable() === 't'
184 && copy.status().holdable() === 't';
190 this.initDone = true;
192 // These are pre-cached via the catalog resolver.
193 const settings = this.store.getItemBatchCached([
194 'cat.holdings_show_empty_org',
195 'cat.holdings_show_empty',
196 'cat.holdings_show_copies',
197 'cat.holdings_show_vols'
200 // Show volumes by default when no preference is set.
201 let showVols = settings['cat.holdings_show_vols'];
202 if (showVols === null) { showVols = true; }
204 this.volsCheckbox.checked(showVols);
205 this.copiesCheckbox.checked(settings['cat.holdings_show_copies']);
206 this.emptyVolsCheckbox.checked(settings['cat.holdings_show_empty']);
207 this.emptyLibsCheckbox.checked(settings['cat.holdings_show_empty_org']);
209 this.initHoldingsTree();
210 this.gridDataSource.getRows = (pager: Pager, sort: any[]) => {
211 return this.fetchHoldings(pager);
215 contextOrgChanged(org: IdlObject) {
216 this.contextOrg = org;
221 this.renderFromPrefs = true;
222 this.refreshHoldings = true;
223 this.initHoldingsTree();
224 this.holdingsGrid.reload();
227 toggleShowCopies(value: boolean) {
228 this.store.setItem('cat.holdings_show_copies', value);
230 // Showing copies implies showing volumes
231 this.volsCheckbox.checked(true);
233 this.renderFromPrefs = true;
234 this.holdingsGrid.reload();
237 toggleShowVolumes(value: boolean) {
238 this.store.setItem('cat.holdings_show_vols', value);
240 // Hiding volumes implies hiding empty vols and copies.
241 this.copiesCheckbox.checked(false);
242 this.emptyVolsCheckbox.checked(false);
244 this.renderFromPrefs = true;
245 this.holdingsGrid.reload();
248 toggleShowEmptyVolumes(value: boolean) {
249 this.store.setItem('cat.holdings_show_empty', value);
251 this.volsCheckbox.checked(true);
253 this.renderFromPrefs = true;
254 this.holdingsGrid.reload();
257 toggleShowEmptyLibs(value: boolean) {
258 this.store.setItem('cat.holdings_show_empty_org', value);
259 this.renderFromPrefs = true;
260 this.holdingsGrid.reload();
263 onRowActivate(row: any) {
265 // Launch copy editor?
267 this.gridTemplateContext.toggleExpandRow(row);
273 const visibleOrgs = this.org.fullPath(this.contextOrg, true);
275 // The initial tree simply matches the org unit tree
276 const traverseOrg = (node: HoldingsTreeNode) => {
277 node.target.children().forEach((org: IdlObject) => {
278 if (visibleOrgs.indexOf(org.id()) == -1) {
279 return; // Org is outside of scope
281 const nodeChild = new HoldingsTreeNode();
282 nodeChild.nodeType = 'org';
283 nodeChild.target = org;
284 nodeChild.parentNode = node;
285 node.children.push(nodeChild);
286 this.treeNodeCache.org[org.id()] = nodeChild;
287 traverseOrg(nodeChild);
291 this.treeNodeCache = {
297 this.holdingsTree = new HoldingsTree();
298 this.holdingsTree.root.nodeType = 'org';
299 this.holdingsTree.root.target = this.org.root();
300 this.treeNodeCache.org[this.org.root().id()] = this.holdingsTree.root;
302 traverseOrg(this.holdingsTree.root);
305 // Org node children are sorted with any child org nodes pushed to the
306 // front, followed by the call number nodes sorted alphabetcially by label.
307 sortOrgNodeChildren(node: HoldingsTreeNode) {
308 node.children = node.children.sort((a, b) => {
309 if (a.nodeType === 'org') {
310 if (b.nodeType === 'org') {
311 return a.target.shortname() < b.target.shortname() ? -1 : 1;
315 } else if (b.nodeType === 'org') {
318 // TODO: should this use label sortkey instead of
319 // the compiled volume label?
320 return a.target._label < b.target._label ? -1 : 1;
325 // Sets call number and copy count sums to nodes that need it.
326 // Applies the initial expansed state of each container node.
327 setTreeCounts(node: HoldingsTreeNode) {
329 if (node.nodeType === 'org') {
331 node.volumeCount = 0;
332 } else if(node.nodeType === 'volume') {
336 let hasChildOrgWithData = false;
337 let hasChildOrgSansData = false;
338 node.children.forEach(child => {
339 this.setTreeCounts(child);
340 if (node.nodeType === 'org') {
341 node.copyCount += child.copyCount;
342 if (child.nodeType === 'volume') {
345 hasChildOrgWithData = child.volumeCount > 0;
346 hasChildOrgSansData = child.volumeCount === 0;
347 node.volumeCount += child.volumeCount;
349 } else if (node.nodeType === 'volume') {
350 node.copyCount = node.children.length;
351 if (this.renderFromPrefs) {
352 node.expanded = this.copiesCheckbox.checked();
357 if (this.renderFromPrefs && node.nodeType === 'org') {
358 if (node.copyCount > 0 && this.volsCheckbox.checked()) {
359 node.expanded = true;
360 } else if (node.volumeCount > 0 && this.emptyVolsCheckbox.checked()) {
361 node.expanded = true;
362 } else if (hasChildOrgWithData) {
363 node.expanded = true;
364 } else if (hasChildOrgSansData && this.emptyLibsCheckbox.checked()) {
365 node.expanded = true;
367 node.expanded = false;
372 // Create HoldingsEntry objects for tree nodes that should be displayed
373 // and relays them to the grid via the observer.
374 propagateTreeEntries(observer: Observer<HoldingsEntry>, node: HoldingsTreeNode) {
375 const entry = new HoldingsEntry();
376 entry.treeNode = node;
377 entry.index = this.gridIndex++;
379 switch(node.nodeType) {
381 if (node.volumeCount === 0
382 && !this.emptyLibsCheckbox.checked()) {
385 entry.locationLabel = node.target.shortname();
386 entry.locationDepth = node.target.ou_type().depth();
387 entry.copyCount = node.copyCount;
388 entry.volumeCount = node.volumeCount;
389 this.sortOrgNodeChildren(node);
393 if (this.renderFromPrefs) {
394 if (!this.volsCheckbox.checked()) {
397 if (node.copyCount === 0
398 && !this.emptyVolsCheckbox.checked()) {
402 entry.locationLabel = node.target._label;
403 entry.locationDepth = node.parentNode.target.ou_type().depth() + 1;
404 entry.callNumberLabel = entry.locationLabel;
405 entry.volume = node.target;
406 entry.copyCount = node.copyCount;
410 entry.locationLabel = node.target.barcode();
411 entry.locationDepth = node.parentNode.parentNode.target.ou_type().depth() + 2;
412 entry.callNumberLabel = node.parentNode.target.label() // TODO
413 entry.volume = node.parentNode.target;
414 entry.copy = node.target;
415 entry.circ = node.target._circ;
419 // Tell the grid about the node entry
420 observer.next(entry);
423 // Process the child nodes.
424 node.children.forEach(child =>
425 this.propagateTreeEntries(observer, child));
429 // Turns the tree into a list of entries for grid display
430 flattenHoldingsTree(observer: Observer<HoldingsEntry>) {
432 this.setTreeCounts(this.holdingsTree.root);
433 this.propagateTreeEntries(observer, this.holdingsTree.root);
435 this.renderFromPrefs = false;
438 // Grab volumes, copies, and related data.
439 fetchHoldings(pager: Pager): Observable<any> {
440 if (!this.recordId) { return of([]); }
442 return new Observable<any>(observer => {
444 if (!this.refreshHoldings) {
445 this.flattenHoldingsTree(observer);
449 this.itemCircsNeeded = [];
451 this.pcrud.search('acn',
452 { record: this.recordId,
453 owning_lib: this.org.fullPath(this.contextOrg, true),
455 label: {'!=' : '##URI##'}
459 acp: ['status', 'location', 'circ_lib', 'parts',
460 'age_protect', 'copy_alerts', 'latest_inventory'],
461 acn: ['prefix', 'suffix', 'copies'],
462 acli: ['inventory_workstation']
465 {authoritative: true}
467 vol => this.appendVolume(vol),
470 this.refreshHoldings = false;
471 this.fetchCircs().then(
472 ok => this.flattenHoldingsTree(observer)
479 // Retrieve circulation objects for checked out items.
480 fetchCircs(): Promise<any> {
481 const copyIds = this.itemCircsNeeded.map(copy => copy.id());
482 if (copyIds.length === 0) { return Promise.resolve(); }
484 return this.pcrud.search('circ', {
485 target_copy: copyIds,
487 }).pipe(map(circ => {
488 const copy = this.itemCircsNeeded.filter(
489 c => Number(c.id()) === Number(circ.target_copy()))[0];
494 // Compile prefix + label + suffix into field volume._label;
495 setVolumeLabel(volume: IdlObject) {
496 const pfx = volume.prefix() ? volume.prefix().label() : '';
497 const sfx = volume.suffix() ? volume.suffix().label() : '';
498 volume._label = pfx ? pfx + ' ' : '';
499 volume._label += volume.label();
500 volume._label += sfx ? ' ' + sfx : '';
503 // Create the tree node for the volume if it doesn't already exist.
504 // Do the same for its linked copies.
505 appendVolume(volume: IdlObject) {
506 let volNode = this.treeNodeCache.volume[volume.id()];
507 this.setVolumeLabel(volume);
510 const pNode = this.treeNodeCache.org[volume.owning_lib()]
511 if (volNode.parentNode.target.id() !== pNode.target.id()) {
512 // Volume owning library changed. Un-link it from the previous
513 // org unit collection before adding to the new one.
515 volNode.parentNode = pNode;
516 volNode.parentNode.children.push(volNode);
519 volNode = new HoldingsTreeNode();
520 volNode.nodeType = 'volume';
521 volNode.parentNode = this.treeNodeCache.org[volume.owning_lib()]
522 volNode.parentNode.children.push(volNode);
523 this.treeNodeCache.volume[volume.id()] = volNode;
526 volNode.target = volume;
529 .sort((a: IdlObject, b: IdlObject) => a.barcode() < b.barcode() ? -1 : 1)
530 .forEach((copy: IdlObject) => this.appendCopy(volNode, copy));
533 // Find or create a copy node.
534 appendCopy(volNode: HoldingsTreeNode, copy: IdlObject) {
535 let copyNode = this.treeNodeCache.copy[copy.id()];
538 const oldParent = copyNode.parentNode;
539 if (oldParent.target.id() !== volNode.target.id()) {
540 // TODO: copy changed owning volume. Remove it from
541 // the previous volume before adding to the new volume.
542 copyNode.parentNode = volNode;
543 volNode.children.push(copyNode);
547 copyNode = new HoldingsTreeNode();
548 copyNode.nodeType = 'copy';
549 volNode.children.push(copyNode);
550 copyNode.parentNode = volNode;
551 this.treeNodeCache.copy[copy.id()] = copyNode;
554 copyNode.target = copy;
555 const stat = Number(copy.status().id());
557 if (stat === 1 /* checked out */ || stat === 16 /* long overdue */) {
558 // Avoid looking up circs on items that are not checked out.
559 this.itemCircsNeeded.push(copy);
563 // Which copies in the grid are selected.
564 selectedCopyIds(rows: HoldingsEntry[], skipStatus?: number): number[] {
565 let copyRows = rows.filter(r => Boolean(r.copy)).map(r => r.copy);
567 copyRows = copyRows.filter(
568 c => Number(c.status().id()) !== Number(skipStatus));
570 return copyRows.map(c => Number(c.id()));
573 selectedVolumeIds(rows: HoldingsEntry[]): number[] {
575 .filter(r => r.treeNode.nodeType === 'volume')
576 .map(r => Number(r.volume.id()));
579 async showMarkDamagedDialog(rows: HoldingsEntry[]) {
580 const copyIds = this.selectedCopyIds(rows, 14 /* ignore damaged */);
582 if (copyIds.length === 0) { return; }
584 let rowsModified = false;
586 const markNext = async(ids: number[]) => {
587 if (ids.length === 0) {
588 return Promise.resolve();
591 this.markDamagedDialog.copyId = ids.pop();
592 return this.markDamagedDialog.open({size: 'lg'}).then(
594 if (ok) { rowsModified = true; }
595 return markNext(ids);
597 dismiss => markNext(ids)
601 await markNext(copyIds);
603 this.refreshHoldings = true;
604 this.holdingsGrid.reload();
608 showMarkMissingDialog(rows: any[]) {
609 const copyIds = this.selectedCopyIds(rows, 4 /* ignore missing */);
610 if (copyIds.length > 0) {
611 this.markMissingDialog.copyIds = copyIds;
612 this.markMissingDialog.open({}).then(
615 this.refreshHoldings = true;
616 this.holdingsGrid.reload();
619 dismissed => {} // avoid console errors
624 // Mark record, library, and potentially the selected call number
625 // as the current transfer target.
626 markLibCnForTransfer(rows: HoldingsEntry[]) {
627 if (rows.length === 0) {
631 // Action may only apply to a single org or volume row.
632 const node = rows[0].treeNode;
633 if (node.nodeType === 'copy') {
639 if (node.nodeType === 'org') {
640 orgId = node.target.id();
642 // Clear volume target when performed on an org unit row
643 this.localStore.removeLocalItem('eg.cat.transfer_target_vol');
645 } else if (node.nodeType === 'volume') {
647 // All volume nodes are children of org nodes.
648 orgId = node.parentNode.target.id();
650 // Add volume target when performed on a volume row.
651 this.localStore.setLocalItem(
652 'eg.cat.transfer_target_vol', node.target.id())
655 this.localStore.setLocalItem('eg.cat.transfer_target_record', this.recordId);
656 this.localStore.setLocalItem('eg.cat.transfer_target_lib', orgId);
659 openAngJsWindow(path: string) {
660 const url = `/eg/staff/${path}`;
661 window.open(url, '_blank');
664 openItemHolds(rows: HoldingsEntry[]) {
665 if (rows.length > 0 && rows[0].copy) {
666 this.openAngJsWindow(`cat/item/${rows[0].copy.id()}/holds`);
670 openItemStatusList(rows: HoldingsEntry[]) {
671 const ids = this.selectedCopyIds(rows);
672 if (ids.length > 0) {
673 return this.openAngJsWindow(`cat/item/search/${ids.join(',')}`);
677 openItemStatus(rows: HoldingsEntry[]) {
678 if (rows.length > 0 && rows[0].copy) {
679 return this.openAngJsWindow(`cat/item/${rows[0].copy.id()}`);
683 openItemTriggeredEvents(rows: HoldingsEntry[]) {
684 if (rows.length > 0 && rows[0].copy) {
685 return this.openAngJsWindow(
686 `cat/item/${rows[0].copy.id()}/triggered_events`);
690 openItemPrintLabels(rows: HoldingsEntry[]) {
691 const ids = this.selectedCopyIds(rows);
692 if (ids.length === 0) { return; }
694 this.anonCache.setItem(null, 'print-labels-these-copies', {copies: ids})
695 .then(key => this.openAngJsWindow(`cat/printlabels/${key}`));
698 openVolCopyEdit(rows: HoldingsEntry[], addVols: boolean, addCopies: boolean) {
700 // The user may select a set of volumes by selecting volume and/or
704 if (r.treeNode.nodeType === 'volume') {
705 volumes.push(r.volume);
706 } else if (r.treeNode.nodeType === 'copy') {
707 volumes.push(r.treeNode.parentNode.target);
711 if (addCopies && !addVols) {
712 // Adding copies to an existing set of volumes.
713 if (volumes.length > 0) {
714 const volIds = volumes.map(v => Number(v.id()));
715 this.holdings.spawnAddHoldingsUi(this.recordId, volIds);
718 } else if (addVols) {
721 if (volumes.length > 0) {
723 // When adding volumes, if any are selected in the grid,
724 // create volumes that have the same label and owner.
726 entries.push({label: v.label(), owner: v.owning_lib()}));
730 // Otherwise create new volumes from scratch.
731 entries.push({owner: this.auth.user().ws_ou()})
734 this.holdings.spawnAddHoldingsUi(
735 this.recordId, null, entries, !addCopies);
739 openItemNotes(rows: HoldingsEntry[], mode: string) {
740 const copyIds = this.selectedCopyIds(rows);
741 if (copyIds.length === 0) { return; }
743 this.copyAlertsDialog.copyIds = copyIds;
744 this.copyAlertsDialog.mode = mode;
745 this.copyAlertsDialog.open({size: 'lg'}).then(
755 openReplaceBarcodeDialog(rows: HoldingsEntry[]) {
756 const ids = this.selectedCopyIds(rows);
757 if (ids.length === 0) { return; }
758 this.replaceBarcode.copyIds = ids;
759 this.replaceBarcode.open({}).then(