LP1821382 Angular staff catalog Holdings Maintenance
authorBill Erickson <berickxx@gmail.com>
Fri, 15 Mar 2019 21:01:13 +0000 (17:01 -0400)
committerDan Wells <dbw2@calvin.edu>
Wed, 29 May 2019 19:30:49 +0000 (15:30 -0400)
Signed-off-by: Bill Erickson <berickxx@gmail.com>
Signed-off-by: Dan Wells <dbw2@calvin.edu>

12 files changed:
Open-ILS/src/eg2/src/app/core/server-store.service.ts
Open-ILS/src/eg2/src/app/share/grid/grid-body-cell.component.html
Open-ILS/src/eg2/src/app/share/grid/grid-column.component.ts
Open-ILS/src/eg2/src/app/share/grid/grid-toolbar-checkbox.component.ts
Open-ILS/src/eg2/src/app/share/grid/grid.component.html
Open-ILS/src/eg2/src/app/share/grid/grid.component.ts
Open-ILS/src/eg2/src/app/share/grid/grid.ts
Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts
Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
Open-ILS/src/eg2/src/app/staff/catalog/resolver.service.ts

index ea2d93d..b54a4c9 100644 (file)
@@ -60,6 +60,22 @@ export class ServerStoreService {
         );
     }
 
+    // Sync call for items known to be cached locally.
+    getItemCached(key: string): any {
+        return this.cache[key];
+    }
+
+    // Sync batch call for items known to be cached locally
+    getItemBatchCached(keys: string[]): {[key: string]: any} {
+        const values: any = {};
+        keys.forEach(key => {
+            if (key in this.cache) {
+                values[key] = this.cache[key];
+            }
+        });
+        return values;
+    }
+
     // Returns a set of key/value pairs for the requested settings
     getItemBatch(keys: string[]): Promise<any> {
 
index 578bef5..6dc4a99 100644 (file)
@@ -1,6 +1,6 @@
 
 <span *ngIf="!column.cellTemplate"
-  [ngbTooltip]="context.getRowColumnValue(row, column)"
+  [ngbTooltip]="column.disableTooltip ? null : context.getRowColumnValue(row, column)"
   placement="top-left"
   class="{{context.cellClassCallback(row, column)}}"
   triggers="mouseenter:mouseleave">
@@ -8,7 +8,7 @@
 </span>
 <span *ngIf="column.cellTemplate" 
   class="{{context.cellClassCallback(row, column)}}"
-  [ngbTooltip]="column.cellTemplate"
+  [ngbTooltip]="column.disableTooltip ? null : column.cellTemplate"
   placement="top-left"
   #tooltip="ngbTooltip" 
   (mouseenter)="tooltip.open(column.getCellContext(row))"
index 4cebd48..76a89f6 100644 (file)
@@ -31,6 +31,8 @@ export class GridColumnComponent implements OnInit {
     @Input() cellContext: any;
     @Input() cellTemplate: TemplateRef<any>;
 
+    @Input() disableTooltip: boolean;
+
     // get a reference to our container grid.
     constructor(@Host() private grid: GridComponent) {}
 
@@ -50,6 +52,7 @@ export class GridColumnComponent implements OnInit {
         col.isIndex = this.index === true;
         col.cellTemplate = this.cellTemplate;
         col.cellContext = this.cellContext;
+        col.disableTooltip = this.disableTooltip;
         col.isSortable = this.sortable;
         col.isMultiSortable = this.multiSortable;
         col.datatype = this.datatype;
index 7ee3019..24b7616 100644 (file)
@@ -16,29 +16,48 @@ export class GridToolbarCheckboxComponent implements OnInit {
     // This does NOT fire the onChange handler.
     @Input() initialValue: boolean;
 
-    // This is an input instead of an Output because the handler is
-    // passed off to the grid context for maintenance -- events
-    // are not fired directly from this component.
     @Output() onChange: EventEmitter<boolean>;
 
+    private cb: GridToolbarCheckbox;
+
     // get a reference to our container grid.
     constructor(@Host() private grid: GridComponent) {
         this.onChange = new EventEmitter<boolean>();
+
+        // Create in constructor so we can accept values before the
+        // grid is fully rendered.
+        this.cb = new GridToolbarCheckbox();
+        this.cb.isChecked = null;
+        this.initialValue = null;
     }
 
     ngOnInit() {
-
         if (!this.grid) {
             console.warn('GridToolbarCheckboxComponent needs a [grid]');
             return;
         }
 
-        const cb = new GridToolbarCheckbox();
-        cb.label = this.label;
-        cb.onChange = this.onChange;
-        cb.isChecked = this.initialValue;
+        this.cb.label = this.label;
+        this.cb.onChange = this.onChange;
+
+        if (this.cb.isChecked === null && this.initialValue !== null) {
+            this.cb.isChecked = this.initialValue;
+        }
+
+        this.grid.context.toolbarCheckboxes.push(this.cb);
+    }
 
-        this.grid.context.toolbarCheckboxes.push(cb);
+    // Toggle the value.  onChange is not fired.
+    toggle() {
+        this.cb.isChecked = !this.cb.isChecked;
+    }
+
+    // Set/get the value.  onChange is not fired.
+    checked(value?: boolean): boolean {
+        if (value === true || value === false) {
+            this.cb.isChecked = value;
+        }
+        return this.cb.isChecked;
     }
 }
 
index 77ea0e6..dd46246 100644 (file)
   <eg-grid-print #gridPrinter [gridContext]="context">
   </eg-grid-print>
 
-  <!-- move me too -->
-  <div class="row" *ngIf="dataSource.data.length == 0">
-    <div class="col-lg-12 text-center alert alert-light font-italic" i18n>
-      Nothing to Display
+  <ng-container *ngIf="dataSource.data.length == 0">
+    <div class="row">>
+      <ng-container *ngIf="dataSource.requestingData">
+        <div class="col-lg-6 offset-lg-3 text-center mt-3">
+         <eg-progress-inline></eg-progress-inline>
+        </div>
+      </ng-container>
+      <ng-container *ngIf="!dataSource.requestingData">
+        <div class="col-lg-12 text-center alert alert-light font-italic" i18n>
+          Nothing to Display
+        </div>
+      </ng-container>
     </div>
-  </div>
+  </ng-container>
 
   <eg-grid-body [context]="context"></eg-grid-body>
 </div>
index 66686ef..25b8d0d 100644 (file)
@@ -79,6 +79,8 @@ export class GridComponent implements OnInit, AfterViewInit, OnDestroy {
     // Allow the caller to jump directly to a specific page of
     // grid data.
     @Input() pageOffset: number;
+    // Pass in a default page size.  May be overridden by settings.
+    @Input() pageSize: number;
 
     // If true and an idlClass is specificed, the grid assumes
     // datatype=link fields that link to classes which define a selector
@@ -141,6 +143,10 @@ export class GridComponent implements OnInit, AfterViewInit, OnDestroy {
             this.context.pager.offset = this.pageOffset;
         }
 
+        if (this.pageSize) {
+            this.context.pager.limit = this.pageSize;
+        }
+
         // TS doesn't seem to like: let foo = bar || () => '';
         this.context.rowClassCallback =
             this.rowClassCallback || function () { return ''; };
index fbc0208..8017938 100644 (file)
@@ -32,6 +32,7 @@ export class GridColumn {
     isDragTarget: boolean;
     isSortable: boolean;
     isMultiSortable: boolean;
+    disableTooltip: boolean;
     comparator: (valueA: any, valueB: any) => number;
 
     // True if the column was automatically generated.
@@ -1033,6 +1034,7 @@ export class GridDataSource {
     data: any[];
     sort: any[];
     allRowsRetrieved: boolean;
+    requestingData: boolean;
     getRows: (pager: Pager, sort: any[]) => Observable<any>;
 
     constructor() {
@@ -1068,16 +1070,23 @@ export class GridDataSource {
             return Promise.resolve();
         }
 
+        // If we have to call out for data, set inFetch
+        this.requestingData = true;
+
         return new Promise((resolve, reject) => {
             let idx = pager.offset;
             return this.getRows(pager, this.sort).subscribe(
-                row => this.data[idx++] = row,
+                row => {
+                    this.data[idx++] = row;
+                    this.requestingData = false;
+                },
                 err => {
                     console.error(`grid getRows() error ${err}`);
                     reject(err);
                 },
                 ()  => {
                     this.checkAllRetrieved(pager, idx);
+                    this.requestingData = false;
                     resolve();
                 }
             );
index b158ac1..1855000 100644 (file)
@@ -21,6 +21,7 @@ import {PartsComponent} from './record/parts.component';
 import {PartMergeDialogComponent} from './record/part-merge-dialog.component';
 import {BrowseComponent} from './browse.component';
 import {BrowseResultsComponent} from './browse/results.component';
+import {HoldingsMaintenanceComponent} from './record/holdings.component';
 
 @NgModule({
   declarations: [
@@ -40,6 +41,7 @@ import {BrowseResultsComponent} from './browse/results.component';
     PartMergeDialogComponent,
     BrowseComponent,
     BrowseResultsComponent,
+    HoldingsMaintenanceComponent
   ],
   imports: [
     StaffCommonModule,
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.html
new file mode 100644 (file)
index 0000000..3cfcb27
--- /dev/null
@@ -0,0 +1,94 @@
+
+<ng-template #locationTemplate let-row="row" let-userContext="userContext">
+  <!-- pl-* is doubled for added impact -->
+  <div class="pl-{{row.locationDepth}}">
+    <span class="pl-{{row.locationDepth}}">
+      <a class="label-with-material-icon" (click)="userContext.toggleExpandRow(row)">
+        <!--  leave the icons in place for all node types, but make them
+              invisible when they are not needed. -->
+        <span *ngIf="row.treeNode.expanded"
+          [ngClass]="{invisible: row.copy || row.treeNode.children.length == 0}"
+          class="material-icons p-0 m-0">arrow_drop_down</span>
+        <span *ngIf="!row.treeNode.expanded"
+          [ngClass]="{invisible: row.copy || row.treeNode.children.length == 0}"
+          class="material-icons p-0 m-0">arrow_right</span>
+        <span>{{row.locationLabel}}</span>
+      </a>
+    </span>
+  </div>
+</ng-template>
+
+<ng-template #holdableTemplate let-row="row" let-userContext="userContext">
+  <ng-container *ngIf="row.copy">
+    <ng-container *ngIf="userContext.copyIsHoldable(row.copy); else notHoldable">
+      <span i18n>Yes</span>
+    </ng-container>
+    <ng-template #notHoldable><span i18n>No</span></ng-template>
+  </ng-container>
+</ng-template>
+
+
+<div class='eg-copies w-100 mt-3'>
+  <eg-grid #holdingsGrid [dataSource]="gridDataSource"
+    (onRowActivate)="onRowActivate($event)"
+    [pageSize]="50" [rowClassCallback]="rowClassCallback"
+    [sortable]="false" persistKey="cat.holdings">
+
+    <!-- checkboxes -->
+
+    <eg-grid-toolbar-checkbox i18n-label label="Show Volumes" 
+      #volsCheckbox (onChange)="toggleShowVolumes($event)">
+    </eg-grid-toolbar-checkbox> 
+    <eg-grid-toolbar-checkbox i18n-label label="Show Copies" 
+      #copiesCheckbox (onChange)="toggleShowCopies($event)">
+    </eg-grid-toolbar-checkbox> 
+    <eg-grid-toolbar-checkbox i18n-label label="Show Empty Volumes"            
+      #emptyVolsCheckbox (onChange)="toggleShowEmptyVolumes($event)">
+    </eg-grid-toolbar-checkbox> 
+    <eg-grid-toolbar-checkbox i18n-label label="Show Empty Libs"            
+      #emptyLibsCheckbox (onChange)="toggleShowEmptyLibs($event)">
+    </eg-grid-toolbar-checkbox> 
+
+    <!-- fields -->
+    <eg-grid-column path="index" [hidden]="true" [index]="true">
+    </eg-grid-column>
+    <eg-grid-column path="copy.id" [hidden]="true" label="Copy ID" i18n-label>
+    </eg-grid-column>
+    <eg-grid-column path="volume.id" [hidden]="true" label="Volume ID" i18n-label>
+    </eg-grid-column>
+    <eg-grid-column name="location_barcode" [flex]="4"
+      [cellTemplate]="locationTemplate" [cellContext]="gridTemplateContext" 
+      label="Location/Barcode" [disableTooltip]="true" i18n-label>
+    </eg-grid-column>
+    <eg-grid-column path="volumeCount" datatype="number" label="Volumes" i18n-label>
+    </eg-grid-column>
+    <eg-grid-column path="copyCount" datatype="number" label="Copies" i18n-label>
+    </eg-grid-column>
+    <eg-grid-column path="callNumberLabel" label="Call Number" i18n-label>
+    </eg-grid-column>
+    <eg-grid-column i18n-label label="Circ Library" path="copy.circ_lib" 
+      datatype="org_unit"></eg-grid-column>
+    <eg-grid-column i18n-label label="Owning Library" path="volume.owning_lib" 
+      datatype="org_unit"></eg-grid-column>
+    <eg-grid-column i18n-label label="Due Date" path="circ.due_date" 
+      datatype="timestamp"></eg-grid-column>
+    <eg-grid-column i18n-label label="Shelving Location" path="copy.location.name">
+    </eg-grid-column>
+    <eg-grid-column i18n-label label="Circulation Modifier" path="copy.circ_modifier">
+    </eg-grid-column>
+
+    <eg-grid-column i18n-label label="Status" path="copy.status.name">
+    </eg-grid-column>
+    <eg-grid-column i18n-label label="Active/Create Date" 
+      path="copy.active_date" datatype="timestamp">
+    </eg-grid-column>
+    <eg-grid-column i18n-label label="Age Hold Protection" 
+      path="copy.age_protect.name"></eg-grid-column>
+
+    <eg-grid-column i18n-label label="Holdable?" name="holdable" 
+      [cellTemplate]="holdableTemplate" [cellContext]="gridTemplateContext">
+    </eg-grid-column>
+
+  </eg-grid>
+</div>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.ts
new file mode 100644 (file)
index 0000000..0f9e4ad
--- /dev/null
@@ -0,0 +1,440 @@
+import {Component, OnInit, Input, ViewChild} from '@angular/core';
+import {Observable, Observer, of} from 'rxjs';
+import {map} from 'rxjs/operators';
+import {Pager} from '@eg/share/util/pager';
+import {IdlObject} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {StaffCatalogService} from '../catalog.service';
+import {OrgService} from '@eg/core/org.service';
+import {AuthService} from '@eg/core/auth.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {GridDataSource} from '@eg/share/grid/grid';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {GridToolbarCheckboxComponent} from '@eg/share/grid/grid-toolbar-checkbox.component';
+import {ServerStoreService} from '@eg/core/server-store.service';
+
+
+// The holdings grid models a single HoldingsTree, composed of HoldingsTreeNodes
+// flattened on-demand into a list of HoldingEntry objects.
+class HoldingsTreeNode {
+    children: HoldingsTreeNode[];
+    nodeType: 'org' | 'volume' | 'copy';
+    target: any;
+    parentNode: HoldingsTreeNode;
+    expanded: boolean;
+    copyCount: number;
+    volumeCount: number;
+    constructor() {
+        this.children = [];
+    }
+}
+
+class HoldingsTree {
+    root: HoldingsTreeNode;
+    constructor() {
+        this.root = new HoldingsTreeNode();
+    }
+}
+
+class HoldingsEntry {
+    index: number;
+    // org unit shortname, call number label, or copy barcode
+    locationLabel: string;
+    // location label indentation depth
+    locationDepth: number | null;
+    volumeCount: number | null;
+    copyCount: number | null;
+    callNumberLabel: string;
+    copy: IdlObject;
+    volume: IdlObject;
+    circ: IdlObject;
+    treeNode: HoldingsTreeNode;
+}
+
+@Component({
+  selector: 'eg-holdings-maintenance',
+  templateUrl: 'holdings.component.html'
+})
+export class HoldingsMaintenanceComponent implements OnInit {
+
+    recId: number;
+    initDone = false;
+    gridDataSource: GridDataSource;
+    gridTemplateContext: any;
+    @ViewChild('holdingsGrid') holdingsGrid: GridComponent;
+
+    // Manage visibility of various sub-sections
+    @ViewChild('volsCheckbox') volsCheckbox: GridToolbarCheckboxComponent;
+    @ViewChild('copiesCheckbox') copiesCheckbox: GridToolbarCheckboxComponent;
+    @ViewChild('emptyVolsCheckbox') emptyVolsCheckbox: GridToolbarCheckboxComponent;
+    @ViewChild('emptyLibsCheckbox') emptyLibsCheckbox: GridToolbarCheckboxComponent;
+
+    contextOrg: IdlObject;
+    holdingsTree: HoldingsTree;
+    holdingsTreeOrgCache: {[id: number]: HoldingsTreeNode};
+    refreshHoldings: boolean;
+    gridIndex: number;
+
+    // List of copies whose due date we need to retrieve.
+    itemCircsNeeded: IdlObject[];
+
+    // When true draw the grid based on the stored preferences.
+    // When not true, render based on the current "expanded" state of each node.
+    // Rendering from prefs happens on initial load and when any prefs change.
+    renderFromPrefs: boolean;
+    rowClassCallback: (row: any) => string;
+
+    @Input() set recordId(id: number) {
+        this.recId = id;
+        // Only force new data collection when recordId()
+        // is invoked after ngInit() has already run.
+        if (this.initDone) {
+            this.refreshHoldings = true;
+            this.holdingsGrid.reload();
+        }
+    }
+
+    constructor(
+        private net: NetService,
+        private org: OrgService,
+        private auth: AuthService,
+        private pcrud: PcrudService,
+        private staffCat: StaffCatalogService,
+        private store: ServerStoreService
+    ) {
+        // Set some sane defaults before settings are loaded.
+        this.contextOrg = this.org.get(this.auth.user().ws_ou());
+        this.gridDataSource = new GridDataSource();
+        this.refreshHoldings = true;
+        this.renderFromPrefs = true;
+
+        this.rowClassCallback = (row: any): string => {
+             if (row.volume && !row.copy) {
+                return 'bg-info';
+            }
+        }
+
+        this.gridTemplateContext = {
+            toggleExpandRow: (row: HoldingsEntry) => {
+                row.treeNode.expanded = !row.treeNode.expanded;
+
+                if (!row.treeNode.expanded) {
+                    // When collapsing a node, all child nodes should be
+                    // collapsed as well.
+                    const traverse = (node: HoldingsTreeNode) => {
+                        node.expanded = false;
+                        node.children.forEach(traverse);
+                    }
+                    traverse(row.treeNode);
+                }
+
+                this.holdingsGrid.reload();
+            },
+
+            copyIsHoldable: (copy: IdlObject): boolean => {
+                return copy.holdable() === 't'
+                    && copy.location().holdable() === 't'
+                    && copy.status().holdable() === 't';
+            }
+        }
+    }
+
+    ngOnInit() {
+        this.initDone = true;
+
+        // These are pre-cached via the resolver.
+        const settings = this.store.getItemBatchCached([
+            'cat.holdings_show_empty_org',
+            'cat.holdings_show_empty',
+            'cat.holdings_show_copies',
+            'cat.holdings_show_vols'
+        ]);
+
+        this.volsCheckbox.checked(settings['cat.holdings_show_vols']);
+        this.copiesCheckbox.checked(settings['cat.holdings_show_copies']);
+        this.emptyVolsCheckbox.checked(settings['cat.holdings_show_empty']);
+        this.emptyLibsCheckbox.checked(settings['cat.holdings_show_empty_org']);
+
+        this.gridDataSource.getRows = (pager: Pager, sort: any[]) => {
+            return this.fetchHoldings(pager);
+        };
+    }
+
+    ngAfterViewInit() {
+
+    }
+
+    toggleShowCopies(value: boolean) {
+        this.store.setItem('cat.holdings_show_copies', value);
+        if (value) {
+            // Showing copies implies showing volumes
+            this.volsCheckbox.checked(true);
+        }
+        this.renderFromPrefs = true;
+        this.holdingsGrid.reload();
+    }
+
+    toggleShowVolumes(value: boolean) {
+        this.store.setItem('cat.holdings_show_vols', value);
+        if (!value) {
+            // Hiding volumes implies hiding empty vols and copies.
+            this.copiesCheckbox.checked(false);
+            this.emptyVolsCheckbox.checked(false);
+        }
+        this.renderFromPrefs = true;
+        this.holdingsGrid.reload();
+    }
+
+    toggleShowEmptyVolumes(value: boolean) {
+        this.store.setItem('cat.holdings_show_empty', value);
+        if (value) {
+            this.volsCheckbox.checked(true);
+        }
+        this.renderFromPrefs = true;
+        this.holdingsGrid.reload();
+    }
+
+    toggleShowEmptyLibs(value: boolean) {
+        this.store.setItem('cat.holdings_show_empty_org', value);
+        this.renderFromPrefs = true;
+        this.holdingsGrid.reload();
+    }
+
+    onRowActivate(row: any) {
+        if (row.copy) {
+            // Launch copy editor?
+        } else {
+            this.gridTemplateContext.toggleExpandRow(row);
+        }
+    }
+
+    initHoldingsTree() {
+
+        // The initial tree simply matches the org unit tree
+        const traverseOrg = (node: HoldingsTreeNode) => {
+            node.expanded = true;
+            node.target.children().forEach((org: IdlObject) => {
+                const nodeChild = new HoldingsTreeNode();
+                nodeChild.nodeType = 'org';
+                nodeChild.target = org;
+                nodeChild.parentNode = node;
+                node.children.push(nodeChild);
+                this.holdingsTreeOrgCache[org.id()] = nodeChild;
+                traverseOrg(nodeChild);
+            });
+        }
+
+        this.holdingsTree = new HoldingsTree();
+        this.holdingsTree.root.nodeType = 'org';
+        this.holdingsTree.root.target = this.org.root();
+
+        this.holdingsTreeOrgCache = {};
+        this.holdingsTreeOrgCache[this.org.root().id()] = this.holdingsTree.root;
+
+        traverseOrg(this.holdingsTree.root);
+    }
+
+    // Org node children are sorted with any child org nodes pushed to the
+    // front, followed by the call number nodes sorted alphabetcially by label.
+    // TODO: prefix/suffix
+    sortOrgNodeChildren(node: HoldingsTreeNode) {
+        node.children = node.children.sort((a, b) => {
+            if (a.nodeType === 'org') {
+                if (b.nodeType === 'org') {
+                    return a.target.shortname() < b.target.shortname() ? -1 : 1;
+                } else {
+                    return -1;
+                }
+            } else if (b.nodeType === 'org') {
+                return 1;
+            } else {
+                return a.target.label() < b.target.label() ? -1 : 1;
+            }
+        });
+    }
+
+    // Sets call number and copy count sums to nodes that need it.
+    // Applies the initial expansed state of each container node.
+    setTreeCounts(node: HoldingsTreeNode) {
+
+        if (node.nodeType === 'org') {
+            node.copyCount = 0;
+            node.volumeCount = 0;
+        } else if(node.nodeType === 'volume') {
+            node.copyCount = 0;
+        }
+
+        let hasChildOrgWithData = false;
+        let hasChildOrgSansData = false;
+        node.children.forEach(child => {
+            this.setTreeCounts(child);
+            if (node.nodeType === 'org') {
+                node.copyCount += child.copyCount;
+                if (child.nodeType === 'volume') {
+                    node.volumeCount++;
+                } else {
+                    hasChildOrgWithData = child.volumeCount > 0;
+                    hasChildOrgSansData = child.volumeCount === 0;
+                    node.volumeCount += child.volumeCount;
+                }
+            } else if (node.nodeType === 'volume') {
+                node.copyCount = node.children.length;
+                if (this.renderFromPrefs) {
+                    node.expanded = this.copiesCheckbox.checked();
+                }
+            }
+        });
+
+        if (this.renderFromPrefs && node.nodeType === 'org') {
+            if (node.copyCount > 0 && this.volsCheckbox.checked()) {
+                node.expanded = true;
+            } else if (node.volumeCount > 0 && this.emptyVolsCheckbox.checked()) {
+                node.expanded = true;
+            } else if (hasChildOrgWithData) {
+                node.expanded = true;
+            } else if (hasChildOrgSansData && this.emptyLibsCheckbox.checked()) {
+                node.expanded = true;
+            } else {
+                node.expanded = false;
+            }
+        }
+    }
+
+    // Create HoldingsEntry objects for tree nodes that should be displayed
+    // and relays them to the grid via the observer.
+    propagateTreeEntries(observer: Observer<HoldingsEntry>, node: HoldingsTreeNode) {
+        const entry = new HoldingsEntry();
+        entry.treeNode = node;
+        entry.index = this.gridIndex++;
+
+        switch(node.nodeType) {
+            case 'org':
+                if (this.renderFromPrefs && node.volumeCount === 0
+                    && !this.emptyLibsCheckbox.checked()) {
+                    return;
+                }
+                entry.locationLabel = node.target.shortname();
+                entry.locationDepth = node.target.ou_type().depth();
+                entry.copyCount = node.copyCount;
+                entry.volumeCount = node.volumeCount;
+                this.sortOrgNodeChildren(node);
+                break;
+
+            case 'volume':
+                entry.locationLabel = node.target.label(); // TODO prefix/suffix
+                entry.locationDepth = node.parentNode.target.ou_type().depth() + 1;
+                entry.callNumberLabel = entry.locationLabel;
+                entry.volume = node.target;
+                entry.copyCount = node.copyCount;
+                break;
+
+            case 'copy':
+                entry.locationLabel = node.target.barcode();
+                entry.locationDepth = node.parentNode.parentNode.target.ou_type().depth() + 2;
+                entry.callNumberLabel = node.parentNode.target.label() // TODO
+                entry.volume = node.parentNode.target;
+                entry.copy = node.target;
+                entry.circ = node.target._circ;
+                break;
+        }
+
+        // Tell the grid about the node entry
+        observer.next(entry);
+
+        if (node.expanded) {
+            // Process the child nodes.
+            node.children.forEach(child =>
+                this.propagateTreeEntries(observer, child));
+        }
+    }
+
+    // Turns the tree into a list of entries for grid display
+    flattenHoldingsTree(observer: Observer<HoldingsEntry>) {
+        this.gridIndex = 0;
+        this.setTreeCounts(this.holdingsTree.root);
+        this.propagateTreeEntries(observer, this.holdingsTree.root);
+        observer.complete();
+        this.renderFromPrefs = false;
+    }
+
+
+    fetchHoldings(pager: Pager): Observable<any> {
+        if (!this.recId) { return of([]); }
+
+        return new Observable<any>(observer => {
+
+            if (!this.refreshHoldings) {
+                this.flattenHoldingsTree(observer);
+                return;
+            }
+
+            this.initHoldingsTree();
+            this.itemCircsNeeded = [];
+
+            this.pcrud.search('acn',
+                {   record: this.recId,
+                    owning_lib: this.org.ancestors(this.contextOrg, true),
+                    deleted: 'f',
+                    label: {'!=' : '##URI##'}
+                }, {
+                    flesh: 3,
+                    flesh_fields: {
+                        acp: ['status', 'location', 'circ_lib', 'parts',
+                            'age_protect', 'copy_alerts', 'latest_inventory'],
+                        acn: ['prefix', 'suffix', 'copies'],
+                        acli: ['inventory_workstation']
+                    }
+                }
+            ).subscribe(
+                vol => this.appendVolume(vol),
+                err => {},
+                ()  => {
+                    this.refreshHoldings = false;
+                    this.fetchCircs().then(
+                        ok => this.flattenHoldingsTree(observer)
+                    );
+                }
+            );
+        });
+    }
+
+    // Retrieve circulation objects for checked out items.
+    fetchCircs(): Promise<any> {
+        const copyIds = this.itemCircsNeeded.map(copy => copy.id());
+        if (copyIds.length === 0) { return Promise.resolve(); }
+
+        return this.pcrud.search('circ', {
+            target_copy: copyIds,
+            checkin_time: null
+        }).pipe(map(circ => {
+            const copy = this.itemCircsNeeded.filter(
+                c => Number(c.id()) === Number(circ.target_copy()))[0];
+            copy._circ = circ;
+        })).toPromise();
+    }
+
+    appendVolume(volume: IdlObject) {
+
+        const volNode = new HoldingsTreeNode();
+        volNode.parentNode = this.holdingsTreeOrgCache[volume.owning_lib()];
+        volNode.parentNode.children.push(volNode);
+        volNode.nodeType = 'volume';
+        volNode.target = volume;
+
+        volume.copies()
+            .sort((a: IdlObject, b: IdlObject) => a.barcode() < b.barcode() ? -1 : 1)
+            .forEach((copy: IdlObject) => {
+                const copyNode = new HoldingsTreeNode();
+                copyNode.parentNode = volNode;
+                volNode.children.push(copyNode);
+                copyNode.nodeType = 'copy';
+                copyNode.target = copy;
+                const stat = Number(copy.status().id());
+                if (stat === 1 /* checked out */ || stat === 16 /* long overdue */) {
+                    this.itemCircsNeeded.push(copy);
+                }
+            });
+    }
+}
+
+
index ad75118..b583cf7 100644 (file)
       </ngb-tab>
       <ngb-tab title="Holdings View" i18n-title id="holdings">
         <ng-template ngbTabContent>
-          <div class="alert alert-info mt-3" i18n>
-            Holdings not yet implemented.  See the
-            <a target="_blank"
-              href="/eg/staff/cat/catalog/record/{{recordId}}/holdings">
-              AngularJS Holdings Tab.
-            </a>
-          </div>
+          <eg-holdings-maintenance [recordId]="recordId">
+          </eg-holdings-maintenance>
         </ng-template>
       </ngb-tab>
       <ngb-tab title="Conjoined Items" i18n-title id="conjoined">
index 7dde4b4..f4f5d97 100644 (file)
@@ -40,7 +40,14 @@ export class CatalogResolver implements Resolve<Promise<any[]>> {
 
         return this.store.getItemBatch([
             'eg.search.search_lib',
-            'eg.search.pref_lib'
+            'eg.search.pref_lib',
+            'cat.holdings_show_empty_org',
+            'cat.holdings_show_empty',
+            'cat.marcedit.stack_subfields',
+            'cat.marcedit.flateditor',
+            'eg.cat.record.summary.collapse',
+            'cat.holdings_show_copies',
+            'cat.holdings_show_vols'
         ]).then(settings => {
             this.staffCat.defaultSearchOrg =
                 this.org.get(settings['eg.search.search_lib']);