LPXXX Angular Volcopy
authorBill Erickson <berickxx@gmail.com>
Thu, 4 Jun 2020 16:36:09 +0000 (12:36 -0400)
committerBill Erickson <berickxx@gmail.com>
Thu, 25 Jun 2020 14:36:17 +0000 (10:36 -0400)
Signed-off-by: Bill Erickson <berickxx@gmail.com>
14 files changed:
Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts
Open-ILS/src/eg2/src/app/share/combobox/combobox.component.html
Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts
Open-ILS/src/eg2/src/app/staff/cat/routing.module.ts
Open-ILS/src/eg2/src/app/staff/cat/volcopy/routing.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/volcopy/vol-edit.component.css [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/volcopy/vol-edit.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/volcopy/vol-edit.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.ts
Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.service.ts

index c80d0b2..39126c2 100644 (file)
@@ -361,7 +361,7 @@ export class CatalogService {
     }
 
     iconFormatLabel(code: string): string {
-        if (this.ccvmMap) {
+        if (this.ccvmMap && this.ccvmMap.icon_format) {
             const ccvm = this.ccvmMap.icon_format.filter(
                 format => format.code() === code)[0];
             if (ccvm) {
index 83df22e..c456f80 100644 (file)
@@ -7,7 +7,10 @@
 <div class="d-flex">
   <input type="text" 
     class="form-control"
-    [ngClass]="{'text-success font-italic font-weight-bold': selected && selected.freetext}"
+    [ngClass]="{
+      'text-success font-italic font-weight-bold': selected && selected.freetext,
+      'form-control-sm': smallFormControl
+    }"
     [placeholder]="placeholder"
     [name]="name"
     [disabled]="isDisabled"
index 41d2b30..d7f58f6 100644 (file)
@@ -54,6 +54,9 @@ export class ComboboxComponent implements ControlValueAccessor, OnInit {
 
     @Input() inputSize: number = null;
 
+    // If true, applies form-control-sm CSS
+    @Input() smallFormControl = false;
+
     // Add a 'required' attribute to the input
     isRequired: boolean;
     @Input() set required(r: boolean) {
index 67fb59b..c0ab1b2 100644 (file)
@@ -7,6 +7,9 @@ const routes: Routes = [
   }, {
     path: 'authority',
     loadChildren: '@eg/staff/cat/authority/authority.module#AuthorityModule'
+  }, {
+    path: 'volcopy',
+    loadChildren: '@eg/staff/cat/volcopy/volcopy.module#VolCopyModule'
   }
 ];
 
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/routing.module.ts
new file mode 100644 (file)
index 0000000..fb4f44d
--- /dev/null
@@ -0,0 +1,34 @@
+import {NgModule} from '@angular/core';
+import {RouterModule, Routes} from '@angular/router';
+import {VolCopyComponent} from './volcopy.component';
+
+const routes: Routes = [{
+    path: 'edit/item/:copy_id',
+    component: VolCopyComponent
+  }, {
+    path: 'edit/callnumber/:vol_id',
+    component: VolCopyComponent
+  }, {
+    path: 'edit/record/:record_id',
+    component: VolCopyComponent
+  }, {
+    path: 'edit/session/:session',
+    component: VolCopyComponent
+  /*
+  }, {
+    path: 'templates'
+    component: VolCopyComponent
+  }, {
+    path: 'configure'
+    component: VolCopyComponent
+    */
+}];
+
+@NgModule({
+  imports: [RouterModule.forChild(routes)],
+  exports: [RouterModule],
+  providers: []
+})
+
+export class VolCopyRoutingModule {}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/vol-edit.component.css b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/vol-edit.component.css
new file mode 100644 (file)
index 0000000..c109cac
--- /dev/null
@@ -0,0 +1,11 @@
+
+input[type="number"] {
+  /* visually accomodates numbers in the hundreds */
+  width: 4.5em;
+}
+
+.vol-row {
+  background-color: rgba(0,0,0,.03);
+  border-top: 1px solid rgba(0,0,0,.125);
+  border-bottom: 1px solid rgba(0,0,0,.125);
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/vol-edit.component.html b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/vol-edit.component.html
new file mode 100644 (file)
index 0000000..42fd354
--- /dev/null
@@ -0,0 +1,208 @@
+
+<div class="row d-flex vol-row border border-info mb-2">
+  <div class="p-1" [ngStyle]="{flex: flexAt(1)}">
+  </div>
+  <div class="p-1" [ngStyle]="{flex: flexAt(2)}">
+  </div>
+  <div class="p-1" [ngStyle]="{flex: flexAt(3)}">
+    <label class="font-weight-bold" i18n>Classification</label>
+    <div>
+      <eg-combobox [smallFormControl]="true" [(ngModel)]="batchVolClass">
+        <eg-combobox-entry *ngFor="let cls of volClasses" 
+          [entryId]="cls.id()" [entryLabel]="cls.name()">
+        </eg-combobox-entry>
+      </eg-combobox>
+    </div>
+  </div>
+  <div class="p-1" [ngStyle]="{flex: flexAt(4)}">
+    <label class="font-weight-bold" i18n>Prefix</label>
+    <div>
+      <eg-combobox [smallFormControl]="true" [(ngModel)]="batchVolPrefix">
+        <eg-combobox-entry *ngFor="let pfx of volPrefixes" 
+          [entryId]="pfx.id()" [entryLabel]="pfx.label()">
+        </eg-combobox-entry>
+      </eg-combobox>
+    </div>
+  </div>
+  <div class="p-1" [ngStyle]="{flex: flexAt(5)}">
+    <label class="font-weight-bold" i18n>Call Number Label</label>
+    <div>
+      <eg-combobox [smallFormControl]="true" [(ngModel)]="batchVolLabel">
+        <eg-combobox-entry *ngFor="let label of recordVolLabels" [entryId]="label">
+        </eg-combobox-entry>
+      </eg-combobox>
+    </div>
+  </div>
+  <div class="p-1" [ngStyle]="{flex: flexAt(6)}">
+    <label class="font-weight-bold" i18n>Suffix</label>
+    <div>
+      <eg-combobox [smallFormControl]="true" [(ngModel)]="batchVolSuffix">
+        <eg-combobox-entry *ngFor="let sfx of volSuffixes" 
+          [entryId]="sfx.id()" [entryLabel]="sfx.label()">
+        </eg-combobox-entry>
+      </eg-combobox>
+    </div>
+  </div>
+  <div class="p-1" [ngStyle]="{flex: flexAt(7)}">
+    <label class="font-weight-bold" i18n>Batch</label>
+    <div>
+      <button class="btn btn-sm btn-outline-dark label-with-material-icon" 
+        (click)="batchVolApply()">
+        <span i18n>Apply</span>
+        <span class="material-icons">arrow_downward</span>
+      </button>
+    </div>
+  </div>
+  <div class="p-1" [ngStyle]="{flex: flexAt(8)}">
+    <label class="font-weight-bold" i18n>Generate Barcodes</label>
+    <button class="btn btn-sm btn-outline-dark label-with-material-icon" 
+      (click)="generateBarcodes()">
+      <span i18n>Generate</span>
+      <span class="material-icons">arrow_downward</span>
+    </button>
+  </div>
+  <div class="p-1" [ngStyle]="{flex: flexAt(9)}"></div>
+  <div class="p-1" [ngStyle]="{flex: flexAt(10)}"></div>
+</div>
+
+
+<div class="row d-flex mt-2 mb-2">
+  <div class="p-1" [ngStyle]="{flex: flexAt(1)}">
+    <label class="font-weight-bold" i18n>Owning Library</label>
+  </div>
+  <div class="p-1" [ngStyle]="{flex: flexAt(2)}">
+    <label class="font-weight-bold" i18n>Call Numbers</label>
+  </div>
+  <div class="p-1" [ngStyle]="{flex: flexAt(3)}">
+    <label class="font-weight-bold" i18n>Classification</label>
+  </div>
+  <div class="p-1" [ngStyle]="{flex: flexAt(4)}">
+    <label class="font-weight-bold" i18n>Prefix</label>
+  </div>
+  <div class="p-1" [ngStyle]="{flex: flexAt(5)}">
+    <label class="font-weight-bold" i18n>Call Number Label</label>
+  </div>
+  <div class="p-1" [ngStyle]="{flex: flexAt(6)}">
+    <label class="font-weight-bold" i18n>Suffix</label>
+  </div>
+  <div class="p-1" [ngStyle]="{flex: flexAt(7)}">
+    <label class="font-weight-bold" i18n>Items</label>
+  </div>
+  <div class="p-1" [ngStyle]="{flex: flexAt(8)}">
+    <label class="font-weight-bold" i18n>Barcode</label>
+  </div>
+  <div class="p-1" [ngStyle]="{flex: flexAt(9)}">
+    <label class="font-weight-bold" i18n>Item #</label>
+  </div>
+  <div class="p-1" [ngStyle]="{flex: flexAt(10)}">
+    <label class="font-weight-bold" i18n>Part</label>
+  </div>
+</div>
+
+
+<ng-container *ngFor="let orgNode of context.orgNodes(); let orgIdx = index">
+  <ng-container *ngFor="let volNode of orgNode.children; let volIdx = index">
+    <ng-container *ngFor="let copyNode of volNode.children; let copyIdx = index">
+      <div class="row d-flex mt-1" [ngClass]="{'vol-row': copyIdx == 0}">
+        <div class="p-1" [ngStyle]="{flex: flexAt(1)}">
+          <span *ngIf="copyIdx == 0">{{orgNode.target.shortname()}}</span>
+        </div>
+        <div class="p-1" [ngStyle]="{flex: flexAt(2)}">
+          <ng-container *ngIf="copyIdx == 0">
+            <input type="number" class="form-control form-control-sm"
+              [required]="true"
+              [ngModel]="orgNode.children.length"
+              (ngModelChange)="volCountChanged(orgNode, $event)"/>
+          </ng-container>
+        </div>
+        <div class="p-1" [ngStyle]="{flex: flexAt(3)}">
+          <ng-container *ngIf="copyIdx == 0">
+            <eg-combobox 
+              [selectedId]="volNode.target.label_class()"
+              [smallFormControl]="true"
+              [required]="true" 
+              (onChange)="applyVolValue(volNode.target, 'label_class', $event ? $event.id : null)">
+              <eg-combobox-entry *ngFor="let cls of volClasses" 
+                [entryId]="cls.id()" [entryLabel]="cls.name()">
+              </eg-combobox-entry>
+            </eg-combobox>
+          </ng-container>
+        </div>
+        <div class="p-1" [ngStyle]="{flex: flexAt(4)}">
+          <ng-container *ngIf="copyIdx == 0">
+            <eg-combobox 
+              [selectedId]="volNode.target.prefix()"
+              [required]="true" 
+              [smallFormControl]="true"
+              (onChange)="applyVolValue(volNode.target, 'prefix', $event ? $event.id : null)">
+              <eg-combobox-entry *ngFor="let pfx of volPrefixes" 
+                [entryId]="pfx.id()" [entryLabel]="pfx.label()">
+              </eg-combobox-entry>
+            </eg-combobox>
+          </ng-container>
+        </div>
+        <div class="p-1" [ngStyle]="{flex: flexAt(5)}">
+          <ng-container *ngIf="copyIdx == 0">
+            <input class="form-control form-control-sm" type="text"
+              spellcheck="false"
+              [required]="true"
+              [ngModel]="volNode.target.label()"
+              (onChange)="applyVolValue(volNode.target, 'label', $event)">
+          </ng-container>
+        </div>
+        <div class="p-1" [ngStyle]="{flex: flexAt(6)}">
+          <ng-container *ngIf="copyIdx == 0">
+            <eg-combobox 
+              [selectedId]="volNode.target.suffix()"
+              [required]="true" 
+              [smallFormControl]="true"
+              (onChange)="applyVolValue(volNode.target, 'suffix', $event ? $event.id : null)">
+              <eg-combobox-entry *ngFor="let sfx of volSuffixes" 
+                [entryId]="sfx.id()" [entryLabel]="sfx.label()">
+              </eg-combobox-entry>
+            </eg-combobox>
+          </ng-container>
+        </div>
+        <div class="p-1" [ngStyle]="{flex: flexAt(7)}">
+          <ng-container *ngIf="copyIdx == 0">
+            <input type="number" class="form-control form-control-sm"
+              [required]="true"
+              [ngModel]="volNode.children.length"
+              (ngModelChange)="copyCountChanged(volNode, $event)"/>
+          </ng-container>
+        </div>
+        <div class="p-1" [ngStyle]="{flex: flexAt(8)}">
+          <input type="text" class="form-control form-control-sm"
+            id="barcode-input-{{copyNode.target.id()}}"
+            spellcheck="false"
+            [required]="true"
+            (keyup.enter)="selectNextBarcode(copyNode.target.id())"
+            (keyup.shift.enter)="selectNextBarcode(copyNode.target.id(), true)"
+            [ngModel]="copyNode.target.barcode()"
+            (ngModelChange)="applyCopyValue(copyNode.target, 'barcode', $event)"/>
+        </div>
+        <div class="p-1" [ngStyle]="{flex: flexAt(9)}">
+          <input type="number" class="form-control form-control-sm"
+            [ngModel]="copyNode.target.copy_number()"
+            (ngModelChange)="applyCopyValue(copyNode.target, 'copy_number', $event)"/>
+        </div>
+        <div class="p-1" [ngStyle]="{flex: flexAt(10)}">
+          <ng-container *ngIf="!recordHasParts(volNode.target.record())">
+            <label i18n>N/A</label>
+          </ng-container>
+          <ng-container *ngIf="recordHasParts(volNode.target.record())">
+            <eg-combobox 
+              [disabled]="bibParts[volNode.target.record()].length == 0"
+              [selectedId]="copyNode.target.parts()[0] ? copyNode.target.parts()[0].id() : null"
+              [smallFormControl]="true"
+              (onChange)="copyPartChanged(copyNode, $event)">
+              <eg-combobox-entry *ngFor="let part of bibParts[volNode.target.record()]"
+                [entryId]="part.id()" [entryLabel]="part.label()">
+              </eg-combobox-entry>
+            </eg-combobox>
+          </ng-container>
+        </div>
+      </div>
+    </ng-container>
+  </ng-container>
+</ng-container>
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/vol-edit.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/vol-edit.component.ts
new file mode 100644 (file)
index 0000000..6b9c33a
--- /dev/null
@@ -0,0 +1,207 @@
+import {Component, OnInit, AfterViewInit, ViewChild, Input, Renderer2} from '@angular/core';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {IdlObject} from '@eg/core/idl.service';
+import {OrgService} from '@eg/core/org.service';
+import {NetService} from '@eg/core/net.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {VolCopyContext, HoldingsTreeNode} from './volcopy';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {HoldingsService} from '@eg/staff/share/holdings/holdings.service';
+
+@Component({
+  selector: 'eg-vol-edit',
+  templateUrl: 'vol-edit.component.html',
+  styleUrls: ['vol-edit.component.css']
+})
+
+
+export class VolEditComponent implements OnInit {
+
+    @Input() context: VolCopyContext;
+
+    // There are 10 columns in the editor form.  Set the flex values
+    // here so they don't have to be hard-coded and repeated in the
+    // markup.  Changing a flex value here will propagate to all
+    // rows in the form.
+    flexSettings: {[column: number]: number} = {
+        1: 1, 2: 1, 3: 2, 4: 1, 5: 2, 6: 1, 7: 1, 8: 2, 9: 1, 10: 1};
+
+    volClasses: IdlObject[] = null;
+    volPrefixes: IdlObject[] = null;
+    volSuffixes: IdlObject[] = null;
+    bibParts: {[bibId: number]: IdlObject[]} = {};
+
+    batchVolClass: ComboboxEntry;
+    batchVolPrefix: ComboboxEntry;
+    batchVolSuffix: ComboboxEntry;
+    batchVolLabel: ComboboxEntry;
+
+    recordVolLabels: string[] = [];
+
+    constructor(
+        private renderer: Renderer2,
+        private pcrud: PcrudService,
+        private net: NetService,
+        private holdings: HoldingsService
+    ) {}
+
+    ngOnInit() {
+
+        this.fetchRecordVolLabels()
+        .then(_ => this.fetchBibParts());
+
+        // TODO: Filter these to only show org-scoped values
+        // plus any values otherwise needed for the current
+        // holdings tree.
+
+        this.holdings.fetchCallNumberClasses().then(
+            classes => this.volClasses = classes);
+
+        this.holdings.fetchCallNumberPrefixes().then(prefixes => {
+            this.volPrefixes = prefixes.filter(pfx => pfx.id() !== -1)
+        });
+
+        this.holdings.fetchCallNumberSuffixes().then(suffixes =>
+            this.volSuffixes = suffixes.filter(pfx => pfx.id() !== -1));
+    }
+
+    fetchRecordVolLabels(): Promise<any> {
+        // NOTE: see https://bugs.launchpad.net/evergreen/+bug/1874897
+        // for more on MARC call numbers and classification scheme.
+
+        this.recordVolLabels = [];
+        const ids = this.context.getRecordIds();
+
+        // It only makes sense to fetch bib call numbers when we are
+        // working with exactly one record.
+        if (ids.length !== 1) { return Promise.resolve(); }
+
+        return this.net.request(
+            'open-ils.cat',
+            'open-ils.cat.biblio.record.marc_cn.retrieve', ids[0]
+        ).toPromise().then(res => {
+            this.recordVolLabels = Object.values(res)
+                .map(blob => Object.values(blob)[0]).sort();
+        });
+    }
+
+    fetchBibParts() {
+
+        this.context.orgNodes().forEach(orgNode => {
+            orgNode.children.forEach(volNode =>
+                this.bibParts[volNode.target.record()] = []
+            );
+        });
+
+        this.pcrud.search('bmp',
+            {record: Object.keys(this.bibParts), deleted: 'f'})
+        .subscribe(
+            part => {
+                if (!this.bibParts[part.record()]) {
+                    this.bibParts[part.record()] = [];
+                }
+                this.bibParts[part.record()].push(part);
+            },
+            err => {},
+            () => {
+                Object.keys(this.bibParts).forEach(bibId => {
+                    this.bibParts[bibId] = this.bibParts[bibId]
+                    .sort((p1, p2) =>
+                        p1.label_sortkey() < p2.label_sortkey() ? -1 : 1)
+                });
+            }
+        );
+    }
+
+    recordHasParts(bibId: number): boolean {
+        return this.bibParts[bibId] && this.bibParts[bibId].length > 0;
+    }
+
+    flexAt(column: number): number {
+        return this.flexSettings[column];
+    }
+
+    volCountChanged(orgNode: HoldingsTreeNode, count: number) {
+        console.log('vol set set to ', count);
+    }
+
+    copyCountChanged(volNode: HoldingsTreeNode, count: number) {
+        console.log('vol set set to ', count);
+    }
+
+    applyVolValue(vol: IdlObject, key: string, value: any) {
+
+        if (value === null && (key === 'prefix' || key === 'suffix')) {
+            // -1 is the empty prefix/suffix value.
+            value = -1;
+        }
+
+        if (vol[key]() !== value) {
+            vol[key](value);
+            vol.ischanged(true);
+        }
+    }
+
+    applyCopyValue(copy: IdlObject, key: string, value: any) {
+        if (copy[key]() !== value) {
+            copy[key](value);
+            copy.ischanged(true);
+        }
+    }
+
+    copyPartChanged(copyNode: HoldingsTreeNode, entry: ComboboxEntry) {
+        // TODO
+    }
+
+    batchVolApply() {
+        this.context.volNodes().forEach(volNode => {
+            const vol = volNode.target;
+            console.log('batch vol class', this.batchVolClass.id);
+            if (this.batchVolClass) {
+                this.applyVolValue(vol, 'label_class', this.batchVolClass.id);
+            }
+            if (this.batchVolPrefix) {
+                this.applyVolValue(vol, 'prefix', this.batchVolPrefix.id);
+            }
+            if (this.batchVolSuffix) {
+                this.applyVolValue(vol, 'suffix', this.batchVolSuffix.id);
+            }
+            if (this.batchVolLabel) {
+                this.applyVolValue(vol, 'label', this.batchVolLabel.id);
+            }
+        });
+    }
+
+    selectNextBarcode(id: number, previous?: boolean) {
+        let found = false;
+        let nextId: number = null;
+        let firstId: number = null;
+
+        let copies = this.context.copyList();
+        if (previous) { copies = copies.reverse(); }
+
+        // Find the ID of the next item.  If this is the last item,
+        // loop back to the first item.
+        copies.forEach(copy => {
+            if (nextId !== null) { return; }
+
+            // In case we have to loop back to the first copy.
+            if (firstId === null) { firstId = copy.id(); }
+
+            if (found) {
+                if (nextId === null) {
+                    nextId = copy.id();
+                }
+            } else if (copy.id() === id) {
+                found = true;
+            }
+        });
+
+        this.renderer.selectRootElement(
+                '#barcode-input-' + (nextId || firstId)).select();
+    }
+
+    generateBarcodes() {
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.component.html b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.component.html
new file mode 100644 (file)
index 0000000..df54f57
--- /dev/null
@@ -0,0 +1,11 @@
+<eg-staff-banner bannerText="Holdings Editor" i18n-bannerText></eg-staff-banner>
+
+<ng-container *ngIf="!loading">
+
+  <eg-bib-summary *ngIf="recordId" [recordId]="recordId"></eg-bib-summary>
+  
+  <div class="mt-3">
+    <eg-vol-edit [context]="context"></eg-vol-edit>
+  </div>
+
+</ng-container>
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.component.ts
new file mode 100644 (file)
index 0000000..8a5999e
--- /dev/null
@@ -0,0 +1,126 @@
+import {Component, OnInit, AfterViewInit, ViewChild, Renderer2} from '@angular/core';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {tap} from 'rxjs/operators';
+import {IdlObject} from '@eg/core/idl.service';
+import {OrgService} from '@eg/core/org.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {HoldingsService} from '@eg/staff/share/holdings/holdings.service';
+import {VolCopyContext} from './volcopy';
+
+const COPY_FLESH = {
+    flesh: 1,
+    flesh_fields: {
+        acp: ['call_number', 'location', 'parts']
+    }
+}
+
+@Component({
+  templateUrl: 'volcopy.component.html'
+})
+export class VolCopyComponent implements OnInit {
+
+    context: VolCopyContext;
+
+    // Note in multi-record mode this value will be unset.
+    recordId: number;
+
+    // Load specific call number by ID.
+    volId: number;
+
+    // Load specific copy by ID.
+    copyId: number;
+
+    session: string;
+    loading = true;
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private renderer: Renderer2,
+        private org: OrgService,
+        private pcrud: PcrudService,
+        private holdings: HoldingsService
+    ) { }
+
+    ngOnInit() {
+        this.context = new VolCopyContext();
+        this.context.org = this.org; // inject;
+
+        this.route.paramMap.subscribe(
+            (params: ParamMap) => this.negotiateRoute(params));
+    }
+
+    negotiateRoute(params: ParamMap) {
+        this.recordId = +params.get('record_id') || null;
+        this.volId    = +params.get('vol_id')    || null;
+        this.copyId   = +params.get('copy_id')   || null;
+        this.session  =  params.get('session')   || null;
+        this.load();
+    }
+
+    load() {
+        this.loading = true;
+        this.context.reset();
+        this.fetchHoldings()
+        .then(_ => this.holdings.fetchCallNumberClasses())
+        .then(_ => this.holdings.fetchCallNumberPrefixes())
+        .then(_ => this.holdings.fetchCallNumberSuffixes())
+        .then(_ => this.context.sortHoldings())
+        .then(_ => this.setRecordId())
+        .then(_ => this.loading = false);
+    }
+
+    setRecordId() {
+        if (!this.recordId) {
+            const ids = this.context.getRecordIds();
+            if (ids.length === 1) {
+                this.recordId = ids[0];
+            }
+        }
+    }
+
+    fetchHoldings(): Promise<any> {
+        if (this.copyId) {
+            return this.fetchCopies(this.copyId);
+        } else if (this.volId) {
+            return this.fetchVols(this.volId);
+        } else if (this.recordId) {
+            return this.fetchRecords(this.recordId);
+        }
+    }
+
+
+    fetchCopies(copyIds: number | number[]): Promise<any> {
+        const ids = [].concat(copyIds);
+        return this.pcrud.search('acp', {id: ids}, COPY_FLESH)
+        .pipe(tap(copy => this.context.findOrCreateCopyNode(copy)))
+        .toPromise();
+    }
+
+    // Fetch call numbers and copies by call number ids.
+    fetchVols(volIds?: number | number[]): Promise<any> {
+        const ids = [].concat(volIds);
+
+        return this.pcrud.search('acn', {id: ids})
+        .pipe(tap(vol => this.context.findOrCreateVolNode(vol)))
+        .pipe(tap(vol => {
+             return this.pcrud.search('acp',
+                {call_number: ids, deleted: 'f'}, COPY_FLESH
+            ).pipe(tap(copy => this.context.findOrCreateCopyNode(copy))
+            ).toPromise();
+        })).toPromise();
+    }
+
+    // Fetch call numbers and copies by record ids.
+    fetchRecords(recordIds: number | number[]): Promise<any> {
+        const ids = [].concat(recordIds);
+
+        return this.pcrud.search('acn',
+            {record: ids, deleted: 'f'},
+            {}, {idlist: true, atomic: true}
+        ).toPromise().then(volIds =>this.fetchVols(volIds));
+    }
+}
+
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.module.ts b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.module.ts
new file mode 100644 (file)
index 0000000..cdd9e19
--- /dev/null
@@ -0,0 +1,25 @@
+import {NgModule} from '@angular/core';
+import {StaffCommonModule} from '@eg/staff/common.module';
+import {CommonWidgetsModule} from '@eg/share/common-widgets.module';
+import {HoldingsModule} from '@eg/staff/share/holdings/holdings.module';
+import {VolCopyRoutingModule} from './routing.module';
+import {VolCopyComponent} from './volcopy.component';
+import {VolEditComponent} from './vol-edit.component';
+
+@NgModule({
+  declarations: [
+    VolCopyComponent,
+    VolEditComponent
+  ],
+  imports: [
+    StaffCommonModule,
+    CommonWidgetsModule,
+    HoldingsModule,
+    VolCopyRoutingModule
+  ],
+  providers: [
+  ]
+})
+
+export class VolCopyModule {
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.ts b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.ts
new file mode 100644 (file)
index 0000000..aa07966
--- /dev/null
@@ -0,0 +1,157 @@
+import {IdlObject} from '@eg/core/idl.service';
+import {OrgService} from '@eg/core/org.service';
+
+export class HoldingsTreeNode {
+    children: HoldingsTreeNode[];
+    nodeType: 'org' | 'vol' | 'copy';
+    target: any;
+    parentNode: HoldingsTreeNode;
+    constructor() {
+        this.children = [];
+    }
+}
+
+class HoldingsTree {
+    root: HoldingsTreeNode;
+    constructor() {
+        this.root = new HoldingsTreeNode();
+    }
+}
+
+export class VolCopyContext {
+
+    autoId = -1;
+    holdings: HoldingsTree = new HoldingsTree();
+    org: OrgService; // injected
+
+    reset() {
+        this.holdings = new HoldingsTree();
+    }
+
+    orgNodes(): HoldingsTreeNode[] {
+        return this.holdings.root.children;
+    }
+
+    volNodes(): HoldingsTreeNode[] {
+        let vols = [];
+        this.orgNodes().forEach(orgNode =>
+            vols = vols.concat(orgNode.children));
+        return vols;
+    }
+
+    copyList(): IdlObject[] {
+        let copies = [];
+        this.volNodes().forEach(volNode => {
+            copies = copies.concat(volNode.children.map(c => c.target));
+        });
+        return copies;
+    }
+
+    // Returns IDs for all bib records represented in our holdings tree.
+    getRecordIds(): number[] {
+        const idHash: {[id: number]: boolean} = {};
+        this.orgNodes().forEach(orgNode => {
+            orgNode.children.forEach(
+                volNode => idHash[volNode.target.record()] = true)
+        });
+
+        return Object.keys(idHash).map(id => Number(id));
+    }
+
+    // Adds an org unit node; unsorted.
+    findOrCreateOrgNode(orgId: number): HoldingsTreeNode {
+
+        const existing: HoldingsTreeNode =
+            this.orgNodes().filter(n => n.target.id() === orgId)[0];
+
+        if (existing) { return existing; }
+
+        const node: HoldingsTreeNode = new HoldingsTreeNode();
+        node.nodeType = 'org';
+        node.target = this.org.get(orgId);
+        node.parentNode = this.holdings.root;
+
+        this.orgNodes().push(node);
+
+        return node;
+    }
+
+    findOrCreateVolNode(vol: IdlObject): HoldingsTreeNode {
+        const orgId = vol.owning_lib();
+        const orgNode = this.findOrCreateOrgNode(orgId);
+
+        const existing = orgNode.children.filter(
+            n => n.target.id() === vol.id())[0];
+
+        if (existing) { return existing; }
+
+        const node: HoldingsTreeNode = new HoldingsTreeNode();
+        node.nodeType = 'vol';
+        node.target = vol;
+        node.parentNode = orgNode;
+
+        orgNode.children.push(node);
+
+        return node;
+    }
+
+
+    findOrCreateCopyNode(copy: IdlObject): HoldingsTreeNode {
+
+        const volNode = this.findOrCreateVolNode(copy.call_number());
+
+        const existing = volNode.children.filter(
+            c => c.target.id() === copy.id())[0];
+
+        if (existing) { return existing; }
+
+        const node: HoldingsTreeNode = new HoldingsTreeNode();
+        node.nodeType = 'copy';
+        node.target = copy;
+        node.parentNode = volNode;
+
+        volNode.children.push(node);
+
+        return node;
+    }
+
+
+    sortHoldings() {
+
+        this.orgNodes().forEach(orgNode => {
+            orgNode.children.forEach(volNode => {
+
+                // Sort copys by barcode code
+                volNode.children = volNode.children.sort((c1, c2) =>
+                    c1.target.barcode() < c2.target.barcode() ? -1 : 1);
+
+            });
+
+            // Sort call numbers by label
+            orgNode.children = orgNode.children.sort((c1, c2) =>
+                c1.target.label() < c2.target.label() ? -1 : 1);
+        });
+
+        // sort org units by shortname
+        this.holdings.root.children = this.orgNodes().sort((o1, o2) =>
+            o1.target.shortname() < o2.target.shortname() ? -1 : 1);
+    }
+
+    // Sorted list of holdings tree nodes
+    /*
+    flattenHoldings(): HoldingsTreeNode[] {
+        this.sortHoldings();
+        let nodes: HoldingsTreeNode[] = [];
+
+        this.orgNodes().forEach(orgNode => {
+            nodes.push(orgNode);
+            orgNode.children.forEach(volNode => {
+                nodes.push(volNode);
+                nodes = nodes.concat(volNode.children);
+            });
+        });
+
+        return nodes;
+    }
+    */
+}
index 39b8944..4b1a4b8 100644 (file)
@@ -45,30 +45,30 @@ export class BibSummaryComponent implements OnInit {
 
     ngOnInit() {
 
-        if (this.summary) {
-            this.summary.getBibCallNumber();
-        } else {
-            if (this.recordId) {
-                this.loadSummary();
-            }
-        }
-
         this.store.getItem('eg.cat.record.summary.collapse')
         .then(value => this.expand = !value)
-        .then(() => this.initDone = true);
+        .then(_ => this.cat.fetchCcvms())
+        .then(_ => {
+            if (this.summary) {
+                return this.summary.getBibCallNumber();
+            } else {
+                if (this.recordId) {
+                    return this.loadSummary();
+                }
+            }
+        }).then(_ => this.initDone = true);
     }
 
     saveExpandState() {
         this.store.setItem('eg.cat.record.summary.collapse', !this.expand);
     }
 
-    loadSummary(): void {
-        this.bib.getBibSummary(this.recordId).toPromise()
+    loadSummary(): Promise<any> {
+        return this.bib.getBibSummary(this.recordId).toPromise()
         .then(summary => {
-            summary.getBibCallNumber();
-            this.bib.fleshBibUsers([summary.record]);
             this.summary = summary;
-        });
+            return summary.getBibCallNumber();
+        }).then(_ => this.bib.fleshBibUsers([this.summary.record]));
     }
 
     orgName(orgId: number): string {
index 5c91a68..6b42543 100644 (file)
@@ -2,10 +2,12 @@
  * Common code for mananging holdings
  */
 import {Injectable, EventEmitter} from '@angular/core';
+import {IdlObject, IdlService} from '@eg/core/idl.service';
 import {NetService} from '@eg/core/net.service';
 import {AnonCacheService} from '@eg/share/util/anon-cache.service';
 import {AuthService} from '@eg/core/auth.service';
 import {EventService} from '@eg/core/event.service';
+import {PcrudService} from '@eg/core/pcrud.service';
 
 interface NewCallNumData {
     owner?: number;
@@ -17,9 +19,14 @@ interface NewCallNumData {
 @Injectable()
 export class HoldingsService {
 
+    callNumberClasses: IdlObject[];
+    callNumberPrefixes: IdlObject[];
+    callNumberSuffixes: IdlObject[];
+
     constructor(
         private net: NetService,
         private auth: AuthService,
+        private pcrud: PcrudService,
         private evt: EventService,
         private anonCache: AnonCacheService
     ) {}
@@ -57,5 +64,47 @@ export class HoldingsService {
             });
         });
     }
+
+    // Returns a sorted list of call number classes
+    fetchCallNumberClasses(): Promise<IdlObject[]> {
+        if (this.callNumberClasses) {
+            return Promise.resolve(this.callNumberClasses);
+        }
+
+        return this.pcrud.retrieveAll('acnc', {}, {atomic: true})
+        .toPromise().then(classes => {
+            this.callNumberClasses = classes.sort(
+                (c1, c2) => c1.name() < c2.name() ? -1 : 1);
+            return this.callNumberClasses;
+        });
+    }
+
+    // Returns a sorted list of call number prefixes
+    fetchCallNumberPrefixes(): Promise<IdlObject[]> {
+        if (this.callNumberPrefixes) {
+            return Promise.resolve(this.callNumberPrefixes);
+        }
+
+        return this.pcrud.retrieveAll('acnp', {}, {atomic: true})
+        .toPromise().then(prefixes => {
+            this.callNumberPrefixes = prefixes.sort(
+                (c1, c2) => c1.label_sortkey() < c2.label_sortkey() ? -1 : 1);
+            return this.callNumberPrefixes;
+        });
+    }
+
+    // Returns a sorted list of call number suffixes
+    fetchCallNumberSuffixes(): Promise<IdlObject[]> {
+        if (this.callNumberSuffixes) {
+            return Promise.resolve(this.callNumberSuffixes);
+        }
+
+        return this.pcrud.retrieveAll('acns', {}, {atomic: true})
+        .toPromise().then(suffixes => {
+            this.callNumberSuffixes = suffixes.sort(
+                (c1, c2) => c1.label_sortkey() < c2.label_sortkey() ? -1 : 1);
+            return this.callNumberSuffixes;
+        });
+    }
 }