LP1888723 Angular Holdings Maintenance / Item Attributes Editor
authorBill Erickson <berickxx@gmail.com>
Thu, 4 Jun 2020 16:36:09 +0000 (12:36 -0400)
committerGalen Charlton <gmc@equinoxOLI.org>
Sun, 15 Aug 2021 23:54:44 +0000 (19:54 -0400)
Angular port of the holdings and item attributes editors interfaces.

Signed-off-by: Bill Erickson <berickxx@gmail.com>
Signed-off-by: Ruth Frasur <rfrasur@library.in.gov>
Signed-off-by: Galen Charlton <gmc@equinoxOLI.org>
39 files changed:
Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.html
Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.ts
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/share/item-location-select/item-location-select.component.html
Open-ILS/src/eg2/src/app/share/item-location-select/item-location-select.component.ts
Open-ILS/src/eg2/src/app/staff/cat/routing.module.ts
Open-ILS/src/eg2/src/app/staff/cat/volcopy/config.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/volcopy/config.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/volcopy/copy-attrs.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/volcopy/copy-attrs.component.ts [new file with mode: 0644]
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.service.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/catalog/record/holdings.component.html
Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.ts
Open-ILS/src/eg2/src/app/staff/common.module.ts
Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts
Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.html
Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.ts
Open-ILS/src/eg2/src/app/staff/share/holdings/batch-item-attr.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holdings/batch-item-attr.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-dialog.component.html
Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-dialog.component.ts
Open-ILS/src/eg2/src/app/staff/share/holdings/copy-tags-dialog.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holdings/copy-tags-dialog.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.module.ts
Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.service.ts
Open-ILS/src/eg2/src/styles.css
Open-ILS/src/perlmods/lib/OpenILS/Application/Cat.pm
Open-ILS/src/sql/Pg/upgrade/XXXX.data.volcopy-settings.sql [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/cat/catalog/app.js

index 021e451..ed86303 100644 (file)
@@ -1,7 +1,19 @@
 
-<ng-container 
-  *ngFor="let val of getDisplayStrings(); let first = first">
-  <ng-container *ngIf="joiner && !first">{{joiner}} </ng-container>
-  <span [innerHTML]="val"></span>
+<ng-container *ngIf="routerLink">
+  <a [routerLink]="routerLink">
+    <ng-container 
+      *ngFor="let val of getDisplayStrings(); let first = first">
+      <ng-container *ngIf="joiner && !first">{{joiner}} </ng-container>
+      <span [innerHTML]="val"></span>
+    </ng-container>
+  </a>
+</ng-container>
+
+<ng-container *ngIf="!routerLink">
+  <ng-container 
+    *ngFor="let val of getDisplayStrings(); let first = first">
+    <ng-container *ngIf="joiner && !first">{{joiner}} </ng-container>
+    <span [innerHTML]="val"></span>
+  </ng-container>
 </ng-container>
 
index abcbb46..83176d9 100644 (file)
@@ -32,6 +32,9 @@ export class BibDisplayFieldComponent implements OnInit {
     // If true, replace empty values with a non-collapsing space.
     @Input() usePlaceholder: boolean;
 
+    // If provided, turn the display value into a link
+    @Input() routerLink: string;
+
     constructor() {}
 
     ngOnInit() {}
index 798f549..1c861fa 100644 (file)
@@ -393,7 +393,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 e3f7509..deb7464 100644 (file)
 <div class="d-flex">
   <input type="text" 
     class="form-control"
-    [ngClass]="{'text-success font-italic font-weight-bold': selected && selected.freetext}"
+    [id]="domId"
+    [ngClass]="{
+      'text-success font-italic font-weight-bold': selected && selected.freetext,
+      'form-control-sm': smallFormControl
+    }"
     [placeholder]="placeholder"
     [name]="name"
     [disabled]="isDisabled"
index 1de3652..a5ef960 100644 (file)
@@ -47,6 +47,7 @@ export class IdlClassTemplateDirective {
   }]
 })
 export class ComboboxComponent implements ControlValueAccessor, OnInit, AfterViewInit, OnChanges {
+    static domIdAuto = 0;
 
     selected: ComboboxEntry;
     click$: Subject<string>;
@@ -56,6 +57,8 @@ export class ComboboxComponent implements ControlValueAccessor, OnInit, AfterVie
     @ViewChild('defaultDisplayTemplate', { static: true}) defaultDisplayTemplate: TemplateRef<any>;
     @ViewChildren(IdlClassTemplateDirective) idlClassTemplates: QueryList<IdlClassTemplateDirective>;
 
+    @Input() domId = 'eg-combobox-' + ComboboxComponent.domIdAuto++;
+
     // Applies a name attribute to the input.
     // Useful in forms.
     @Input() name: string;
@@ -69,6 +72,9 @@ export class ComboboxComponent implements ControlValueAccessor, OnInit, AfterVie
 
     @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 99bd390..5f7e388 100644 (file)
@@ -7,6 +7,7 @@
 <eg-string #unsetString text="<Unset>" i18n-text></eg-string>
 
 <eg-combobox #comboBox
+  [domId]="domId"
   [startId]="startId"
   [displayTemplate]="displayTemplate"
   (onChange)="cboxChanged($event)"
index ceea201..f07e856 100644 (file)
@@ -30,6 +30,7 @@ import {StringComponent} from '@eg/share/string/string.component';
 })
 export class ItemLocationSelectComponent
     implements OnInit, AfterViewInit, ControlValueAccessor {
+    static domIdAuto = 0;
 
     // Limit copy locations to those owned at or above org units where
     // the user has work permissions for the provided permission code.
@@ -45,6 +46,9 @@ export class ItemLocationSelectComponent
 
     @Input() required: boolean;
 
+    @Input() domId = 'eg-item-location-select-' +
+        ItemLocationSelectComponent.domIdAuto++;
+
     @ViewChild('comboBox', {static: false}) comboBox: ComboboxComponent;
     @ViewChild('unsetString', {static: false}) unsetString: StringComponent;
 
index 084c01f..cc0d4eb 100644 (file)
@@ -18,6 +18,10 @@ const routes: Routes = [
     path: 'item',
     loadChildren: () => import('./item/item.module').then(m => m.ItemModule)
   }, {
+    path: 'volcopy',
+    loadChildren: () =>
+      import('./volcopy/volcopy.module').then(m => m.VolCopyModule)
+  }, {
     path: 'bib-from/:identType',
     component: BibByIdentComponent
   }
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/config.component.html b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/config.component.html
new file mode 100644 (file)
index 0000000..22e75b1
--- /dev/null
@@ -0,0 +1,552 @@
+<div class="d-flex">
+  <h3 class="mt-3" i18n>Holdings Preferences</h3>
+  <div class="flex-1"></div>
+  <div i18n class="font-italic">Changes are saved automatically.</div>
+</div>
+
+<div class="row">
+  <div class="col-lg-6">
+    <div class="row">
+      <div class="col-lg-12">
+        <div class="card">
+          <div class="card-header" i18n>Holdings Display Preferences</div>
+          <ul class="list-group list-group-flush">
+            <li class="list-group-item">
+              <div class="form-check form-check-inline">
+                <input class="form-check-input" type="checkbox" 
+                  id="hide-classification-column" 
+                  [(ngModel)]="volcopy.defaults.hidden.classification">
+                <label class="form-check-label" for="hide-classification-column" i18n>
+                  Hide Call Number Classification Column
+                </label>
+              </div>
+            </li>
+            <li class="list-group-item">
+              <div class="form-check form-check-inline">
+                <input class="form-check-input" type="checkbox" 
+                  id="hide-prefix-column" 
+                  [(ngModel)]="volcopy.defaults.hidden.prefix">
+                <label class="form-check-label" for="hide-prefix-column" i18n>
+                  Hide Call Number Prefix Column
+                </label>
+              </div>
+            </li>
+            <li class="list-group-item">
+              <div class="form-check form-check-inline">
+                <input class="form-check-input" type="checkbox" 
+                  id="hide-suffix-column" 
+                  [(ngModel)]="volcopy.defaults.hidden.suffix">
+                <label class="form-check-label" for="hide-suffix-column" i18n>
+                  Hide Call Number Suffix Column
+                </label>
+              </div>
+            </li>
+            <li class="list-group-item">
+              <div class="form-check form-check-inline">
+                <input class="form-check-input" type="checkbox" 
+                  id="hide-generate_barcodes-column" 
+                  [(ngModel)]="volcopy.defaults.hidden.generate_barcodes">
+                <label class="form-check-label" for="hide-generate_barcodes-column" i18n>
+                  Hide Generate Barcodes
+                </label>
+              </div>
+            </li>
+            <li class="list-group-item">
+              <!--
+                Copy_number_vc distinguishes from copy_number so the field
+                can appear in the volcopy UI and/or attr editor independently.
+              -->
+              <div class="form-check form-check-inline">
+                <input class="form-check-input" type="checkbox" 
+                  id="hide-copy_number_vc-column" 
+                  [(ngModel)]="volcopy.defaults.hidden.copy_number_vc">
+                <label class="form-check-label" for="hide-copy_number_vc-column" i18n>
+                  Hide Item Number
+                </label>
+              </div>
+            </li>
+            <li class="list-group-item">
+              <div class="form-check form-check-inline">
+                <input class="form-check-input" type="checkbox" 
+                  id="volcopy-unified-interface" 
+                  [(ngModel)]="volcopy.defaults.values.unified_display">
+                <label class="form-check-label" for="volcopy-unified-interface" i18n>
+                  Unified Holdings and Item Attributes Display
+                </label>
+              </div>
+            </li>
+          </ul>
+        </div>
+      </div>
+    </div>
+  </div>
+  <div class="col-lg-6">
+    <div class="row">
+      <div class="col-lg-12">
+        <div class="card">
+          <div class="card-header" i18n>Holdings Creation Defaults</div>
+          <ul class="list-group list-group-flush p-2">
+            <li class="list-group-item">
+              <div class="row">
+                <div class="col-lg-4" i18n>
+                  <label for="default-classification" i18n>Default Classification</label>
+                </div>
+                <div class="col-lg-8">
+                  <eg-combobox
+                    domId="default-classification"
+                    [selectedId]="volcopy.defaults.values.classification || 1"
+                    [smallFormControl]="true"
+                    (onChange)="volcopy.defaults.values.classification = $event ? $event.id : null">
+                    <eg-combobox-entry *ngFor="let cls of volcopy.commonData.acn_class"
+                      [entryId]="cls.id()" [entryLabel]="cls.name()">
+                    </eg-combobox-entry>
+                  </eg-combobox>
+                </div>
+              </div>
+            </li>
+            <li class="list-group-item">
+              <div class="row">
+                <div class="col-lg-4" i18n>
+                  <label for="default-prefix" i18n>Default Prefix</label>
+                </div>
+                <div class="col-lg-8">
+                  <eg-combobox
+                    domId="default-prefix"
+                    [smallFormControl]="true"
+                    [startId]="volcopy.defaults.values.prefix || -1"
+                    (onChange)="volcopy.defaults.values.prefix = $event ? $event.id : null">
+                    <eg-combobox-entry 
+                      entryLabel="<None>" i18n-entryLabel [entryId]="-1">
+                    </eg-combobox-entry>
+                    <eg-combobox-entry *ngFor="let pfx of volcopy.commonData.acn_prefix"
+                      [entryId]="pfx.id()" [entryLabel]="pfx.label()">
+                    </eg-combobox-entry>
+                  </eg-combobox>
+                </div>
+              </div>
+            </li>
+            <li class="list-group-item">
+              <div class="row">
+                <div class="col-lg-4" i18n>
+                  <label for="default-suffix" i18n>Default Suffix</label>
+                </div>
+                <div class="col-lg-8">
+                  <eg-combobox
+                    domId="default-suffix"
+                    [selectedId]="volcopy.defaults.values.suffix || -1"
+                    [smallFormControl]="true"
+                    (onChange)="volcopy.defaults.values.suffix = $event ? $event.id : null">
+                    <eg-combobox-entry 
+                      entryLabel="<None>" i18n-entryLabel [entryId]="-1">
+                    </eg-combobox-entry>
+                    <eg-combobox-entry *ngFor="let sfx of volcopy.commonData.acn_suffix"
+                      [entryId]="sfx.id()" [entryLabel]="sfx.label()">
+                    </eg-combobox-entry>
+                  </eg-combobox>
+                </div>
+              </div>
+            </li>
+          </ul>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+
+<hr class="p-2"/>
+<h3 i18n>Item Attribute Settings</h3>
+
+<div class="row">
+  <div class="col-lg-6">
+    <div class="card">
+      <div class="card-header" i18n>Item Attributes Behavior</div>
+      <ul class="list-group list-group-flush">
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="circ_lib_mod_with_owning_lib-column" 
+              [(ngModel)]="volcopy.defaults.values.circ_lib_mod_with_owning_lib">
+            <label class="form-check-label" 
+              for="circ_lib_mod_with_owning_lib-column" i18n>
+              Change Circ Lib When Owning Lib Changes
+            </label>
+          </div>
+        </li>
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <eg-org-select
+              domId="statcat_filter"
+              placeholder="Stat Cat Filter..." i18n-placeholder
+              [initialOrgId]="volcopy.defaults.values.statcat_filter"
+              (onChange)="volcopy.defaults.values.statcat_filter = $event ? $event.id() : null">
+            </eg-org-select>
+            <label class="ml-2" for="statcat_filter" i18n>
+              Default Stat Cat Library Filter
+            </label>
+          </div>
+        </li>
+      </ul>
+    </div>
+  </div>
+</div>
+
+
+<hr class="p-2"/>
+
+<h3 i18n>Hide Item Attributes</h3>
+<span class="font-italic" i18n>
+  Selected Fields Will be <span class="font-weight-bold">Hidden</span>
+  from the Item Attributes Form.
+</span>
+
+<div class="row d-flex pb-5">
+
+  <!-- COLUMN 1 -->
+  <div class="flex-1 p-1">
+    <div class="card">
+      <div class="card-header" i18n>Identification</div>
+      <ul class="list-group list-group-flush">
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="show-status-attr" 
+              [(ngModel)]="volcopy.defaults.hidden.status">
+            <label class="form-check-label" for="show-status-attr" i18n>
+              Status
+            </label>
+          </div>
+        </li>
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="show-barcode-attr" 
+              [(ngModel)]="volcopy.defaults.hidden.barcode">
+            <label class="form-check-label" for="show-barcode-attr" i18n>
+              Barcode
+            </label>
+          </div>
+        </li>
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="show-create_date-attr" 
+              [(ngModel)]="volcopy.defaults.hidden.create_date">
+            <label class="form-check-label" for="show-create_date-attr" i18n>
+              Creation Date
+            </label>
+          </div>
+        </li>
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="show-active_date-attr" 
+              [(ngModel)]="volcopy.defaults.hidden.active_date">
+            <label class="form-check-label" for="show-active_date-attr" i18n>
+              Activation Date
+            </label>
+          </div>
+        </li>
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="show-creator-attr" 
+              [(ngModel)]="volcopy.defaults.hidden.creator">
+            <label class="form-check-label" for="show-creator-attr" i18n>
+              Creator
+            </label>
+          </div>
+        </li>
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="show-edit_date-attr" 
+              [(ngModel)]="volcopy.defaults.hidden.edit_date">
+            <label class="form-check-label" for="show-edit_date-attr" i18n>
+              Last Edit Date
+            </label>
+          </div>
+        </li>
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="show-editor-attr" 
+              [(ngModel)]="volcopy.defaults.hidden.editor">
+            <label class="form-check-label" for="show-editor-attr" i18n>
+              Last Editor
+            </label>
+          </div>
+        </li>
+      </ul>
+    </div>
+  </div>
+
+  <!-- COLUMN 2 -->
+  <div class="flex-1 p-1">
+    <div class="card">
+      <div class="card-header" i18n>Location</div>
+      <ul class="list-group list-group-flush">
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="show-location-attr" 
+              [(ngModel)]="volcopy.defaults.hidden.location">
+            <label class="form-check-label" for="show-location-attr" i18n>
+              Location
+            </label>
+          </div>
+        </li>
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="show-circ_lib-attr" 
+              [(ngModel)]="volcopy.defaults.hidden.circ_lib">
+            <label class="form-check-label" for="show-circ_lib-attr" i18n>
+              Circulating Library
+            </label>
+          </div>
+        </li>
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="show-owning_lib-attr" 
+              [(ngModel)]="volcopy.defaults.hidden.owning_lib">
+            <label class="form-check-label" for="show-owning_lib-attr" i18n>
+              Owning Library
+            </label>
+          </div>
+        </li>
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="show-copy_number-attr" 
+              [(ngModel)]="volcopy.defaults.hidden.copy_number">
+            <label class="form-check-label" for="show-copy_number-attr" i18n>
+              Copy Number 
+            </label>
+          </div>
+        </li>
+      </ul>
+    </div>
+  </div>
+
+  <!-- COLUMN 3 -->
+
+  <div class="flex-1 p-1">
+    <div class="card">
+      <div class="card-header" i18n>Circulation</div>
+      <ul class="list-group list-group-flush">
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="show-circulate-attr" 
+              [(ngModel)]="volcopy.defaults.hidden.circulate">
+            <label class="form-check-label" for="show-circulate-attr" i18n>
+              Circulate
+            </label>
+          </div>
+        </li>
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="show-holdable-attr" 
+              [(ngModel)]="volcopy.defaults.hidden.holdable">
+            <label class="form-check-label" for="show-holdable-attr" i18n>
+              Holdable
+            </label>
+          </div>
+        </li>
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="show-age_protect-attr" 
+              [(ngModel)]="volcopy.defaults.hidden.age_protect">
+            <label class="form-check-label" for="show-age_protect-attr" i18n>
+              Aged-Based Hold Protection
+            </label>
+          </div>
+        </li>
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="show-floating-attr" 
+              [(ngModel)]="volcopy.defaults.hidden.floating">
+            <label class="form-check-label" for="show-floating-attr" i18n>
+              Floating
+            </label>
+          </div>
+        </li>
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="show-loan_duration-attr" 
+              [(ngModel)]="volcopy.defaults.hidden.loan_duration">
+            <label class="form-check-label" for="show-loan_duration-attr" i18n>
+              Loan Duration
+            </label>
+          </div>
+        </li>
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="show-fine_level-attr" 
+              [(ngModel)]="volcopy.defaults.hidden.fine_level">
+            <label class="form-check-label" for="show-fine_level-attr" i18n>
+              Fine Level
+            </label>
+          </div>
+        </li>
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="show-circ_as_type-attr" 
+              [(ngModel)]="volcopy.defaults.hidden.circ_as_type">
+            <label class="form-check-label" for="show-circ_as_type-attr" i18n>
+              Circulate As Type
+            </label>
+          </div>
+        </li>
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="show-circ_modifier-attr" 
+              [(ngModel)]="volcopy.defaults.hidden.circ_modifier">
+            <label class="form-check-label" for="show-circ_modifier-attr" i18n>
+              Circulation Modifier
+            </label>
+          </div>
+        </li>
+      </ul>
+    </div>
+  </div>
+
+  <!-- COLUMN 4 -->
+
+  <div class="flex-1 p-1">
+    <div class="card">
+      <div class="card-header" i18n>Miscellaneous</div>
+      <ul class="list-group list-group-flush">
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="show-copy_alerts-attr" 
+              [(ngModel)]="volcopy.defaults.hidden.copy_alerts">
+            <label class="form-check-label" for="show-copy_alerts-attr" i18n>
+              Item Alerts
+            </label>
+          </div>
+        </li>
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="show-deposit-attr" 
+              [(ngModel)]="volcopy.defaults.hidden.deposit">
+            <label class="form-check-label" for="show-deposit-attr" i18n>
+              Deposit
+            </label>
+          </div>
+        </li>
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="show-deposit_amount-attr" 
+              [(ngModel)]="volcopy.defaults.hidden.deposit_amount">
+            <label class="form-check-label" for="show-deposit_amount-attr" i18n>
+              Deposit Amount
+            </label>
+          </div>
+        </li>
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="show-price-attr" 
+              [(ngModel)]="volcopy.defaults.hidden.price">
+            <label class="form-check-label" for="show-price-attr" i18n>
+              Price
+            </label>
+          </div>
+        </li>
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="show-opac_visible-attr" 
+              [(ngModel)]="volcopy.defaults.hidden.opac_visible">
+            <label class="form-check-label" for="show-opac_visible-attr" i18n>
+              OPAC Visible
+            </label>
+          </div>
+        </li>
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="show-ref-attr" 
+              [(ngModel)]="volcopy.defaults.hidden.ref">
+            <label class="form-check-label" for="show-ref-attr" i18n>
+              Reference
+            </label>
+          </div>
+        </li>
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="show-cost-attr" 
+              [(ngModel)]="volcopy.defaults.hidden.cost">
+            <label class="form-check-label" for="show-cost-attr" i18n>
+              Cost 
+            </label>
+          </div>
+        </li>
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="show-mint_condition-attr" 
+              [(ngModel)]="volcopy.defaults.hidden.mint_condition">
+            <label class="form-check-label" for="show-mint_condition-attr" i18n>
+              Quality
+            </label>
+          </div>
+        </li>
+      </ul>
+    </div>
+  </div>
+
+  <!-- COLUMN 5 -->
+
+  <div class="flex-1 p-1">
+    <div class="card">
+      <div class="card-header" i18n>Statistics</div>
+      <ul class="list-group list-group-flush">
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="show-copy_tags-attr" 
+              [(ngModel)]="volcopy.defaults.hidden.copy_tags">
+            <label class="form-check-label" for="show-copy_tags-attr" i18n>
+              Add Item Tags
+            </label>
+          </div>
+        </li>
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="show-statcat_filter-attr" 
+              [(ngModel)]="volcopy.defaults.hidden.statcat_filter">
+            <label class="form-check-label" for="show-statcat_filter-attr" i18n>
+              Stat Cat Filter
+            </label>
+          </div>
+        </li>
+        <li class="list-group-item">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" type="checkbox" 
+              id="show-statcats-attr" 
+              [(ngModel)]="volcopy.defaults.hidden.statcats">
+            <label class="form-check-label" for="show-statcats-attr" i18n>
+              Statistical Categories
+            </label>
+          </div>
+        </li>
+      </ul>
+    </div>
+  </div>
+</div>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/config.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/config.component.ts
new file mode 100644 (file)
index 0000000..f5d648f
--- /dev/null
@@ -0,0 +1,58 @@
+import {Component, Input, OnInit, ViewChild, DoCheck} from '@angular/core';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {tap} from 'rxjs/operators';
+import {IdlObject, IdlService} from '@eg/core/idl.service';
+import {VolCopyContext} from './volcopy';
+import {VolCopyService} from './volcopy.service';
+
+@Component({
+  selector: 'eg-volcopy-config',
+  templateUrl: 'config.component.html'
+})
+export class VolCopyConfigComponent implements OnInit, DoCheck {
+
+    @Input() context: VolCopyContext;
+
+    defaultsCopy: any;
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private idl: IdlService,
+        public  volcopy: VolCopyService
+    ) {}
+
+    ngOnInit() {
+        console.debug('DEFAULTS', this.volcopy.defaults);
+
+        // Not an IDL object, but clones just the same
+        this.defaultsCopy = this.idl.clone(this.volcopy.defaults);
+    }
+
+    // Watch for changes in the form and auto-save them.
+    ngDoCheck() {
+        const hidden = this.volcopy.defaults.hidden;
+        for (const key in hidden) {
+            if (hidden[key] !== this.defaultsCopy.hidden[key]) {
+                this.save();
+                return;
+            }
+        }
+
+        const values = this.volcopy.defaults.values;
+        for (const key in values) {
+            if (values[key] !== this.defaultsCopy.values[key]) {
+                this.save();
+                return;
+            }
+        }
+    }
+
+    save() {
+        this.volcopy.saveDefaults().then(_ =>
+            this.defaultsCopy = this.idl.clone(this.volcopy.defaults)
+        );
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/copy-attrs.component.html b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/copy-attrs.component.html
new file mode 100644 (file)
index 0000000..af6c51e
--- /dev/null
@@ -0,0 +1,473 @@
+
+
+<!-- We ask this question a lot.  Here's a handy template -->
+<ng-template #yesNoSelect let-field="field">
+  <eg-combobox domId="{{field}}-input" 
+    [required]="true" [ngModel]="values['field']" 
+    (ngModelChange)="values[field] = $event ? $event.id : null">
+    <eg-combobox-entry entryId="t" entryLabel="Yes" i18n-entryLabel>
+    </eg-combobox-entry>
+    <eg-combobox-entry entryId="f" entryLabel="No" i18n-entryLabel>
+    </eg-combobox-entry>
+  </eg-combobox>
+</ng-template>
+
+<!-- this one is also repeated a lot -->
+<ng-template #batchAttr let-field="field" let-required="required"
+  let-label="label" let-template="template" let-displayAs="displayAs">
+  <eg-batch-item-attr 
+    [name]="field" 
+    [label]="label || copyFieldLabel(field)"
+    [valueRequired]="required"
+    [displayAs]="displayAs"
+    [editInputDomId]="field + '-input'"
+    [editTemplate]="template"
+    [labelCounts]="itemAttrCounts(field)"
+    (valueCleared)="applyCopyValue(field, null)"
+    (changesSaved)="applyCopyValue(field, undefined, $event)">
+  </eg-batch-item-attr>
+</ng-template>
+
+<!-- Copy Templates -->
+<div class="row border rounded border-dark pt-2 pb-2 bg-faint">
+  <div class="col-lg-1 font-weight-bold" i18n>Templates:</div>
+  <div class="col-lg-4">
+    <eg-combobox #copyTemplateCbox domId="template-select" 
+      [allowFreeText]="true" [entries]="volcopy.templateNames">
+    </eg-combobox>
+  </div>
+  <div class="col-lg-7 d-flex">
+    <button class="btn btn-outline-dark mr-2" (click)="applyTemplate()" i18n>Apply</button>
+    <button class="btn btn-outline-dark mr-2" (click)="saveTemplate()" i18n>Save</button>
+
+    <!-- 
+      The typical approach of wrapping a file input in a <label> results
+      in button-ish things that have slightly different dimensions.
+      Instead have a button activate a hidden file input.
+    -->
+    <button class="btn btn-outline-dark mr-2" (click)="templateFile.click()">
+      <input type="file" class="d-none" #templateFile
+        (change)="importTemplate($event)" id="template-file-upload"/>
+      <span i18n>Import</span>
+    </button>
+
+    <a (click)="exportTemplate($event)"
+      download="export_copy_template.json" [href]="exportTemplateUrl()">
+      <button class="btn btn-outline-dark mr-2" i18n>Export</button>
+    </a>
+    
+    <div class="flex-1"> </div>
+    <button class="btn btn-outline-danger mr-2" 
+      (click)="deleteTemplate()" i18n>Delete Template</button>
+  </div>
+</div>
+
+
+<div class="row d-flex">
+
+  <!-- COLUMN 1 -->
+  <div class="flex-1 p-1">
+    <div class="p-1"><h4 class="font-weight-bold" i18n>Identification</h4></div>
+
+
+    <div class="mb-1" *ngIf="displayAttr('status')">
+
+      <ng-container *ngIf="statusEditable(); else noEditStat">
+        <ng-template #statusTemplate>
+          <eg-combobox domId="status-input"
+            (ngModelChange)="values['status'] = $event ? $event.id : null"
+            [ngModel]="values['status']">
+            <eg-combobox-entry 
+              *ngFor="let stat of volcopy.commonData.acp_status"
+              [entryId]="stat.id()" [entryLabel]="stat.name()">
+            </eg-combobox-entry>
+          </eg-combobox>
+        </ng-template>
+        <ng-container *ngTemplateOutlet="batchAttr;
+          context:{field:'status',template:statusTemplate}">
+        </ng-container>
+      </ng-container>
+
+      <ng-template #noEditStat>
+        <eg-batch-item-attr label="Status" i18n-label [readOnly]="true"
+          [labelCounts]="itemAttrCounts('status')">
+        </eg-batch-item-attr>
+      </ng-template>
+    </div>
+
+    <div class="mb-1" *ngIf="displayAttr('barcode')">
+      <eg-batch-item-attr label="Barcode" i18n-label
+        [readOnly]="true" [labelCounts]="itemAttrCounts('barcode')">
+      </eg-batch-item-attr>
+    </div>
+
+    <div class="mb-1" *ngIf="displayAttr('create_date')">
+      <eg-batch-item-attr label="Creation Date" i18n-label [readOnly]="true"
+        [labelCounts]="itemAttrCounts('create_date')">
+      </eg-batch-item-attr>
+    </div>
+
+    <div class="mb-1" *ngIf="displayAttr('active_date')">
+      <eg-batch-item-attr label="Active Date" i18n-label [readOnly]="true"
+        [labelCounts]="itemAttrCounts('active_date')">
+      </eg-batch-item-attr>
+    </div>
+
+    <div class="mb-1" *ngIf="displayAttr('creator')">
+      <eg-batch-item-attr label="Creator" i18n-label [readOnly]="true"
+        [labelCounts]="itemAttrCounts('creator')">
+      </eg-batch-item-attr>
+    </div>
+
+    <div class="mb-1" *ngIf="displayAttr('edit_date')">
+      <eg-batch-item-attr label="Last Edit Date" i18n-label [readOnly]="true"
+        [labelCounts]="itemAttrCounts('edit_date')">
+      </eg-batch-item-attr>
+    </div>
+
+    <div class="mb-1" *ngIf="displayAttr('editor')">
+      <eg-batch-item-attr label="Last Editor" i18n-label [readOnly]="true"
+        [labelCounts]="itemAttrCounts('editor')">
+      </eg-batch-item-attr>
+    </div>
+
+  </div>
+
+  <!-- COLUMN 2 -->
+  <div class="flex-1 p-1">
+    <div class="p-1"><h4 class="font-weight-bold" i18n>Location</h4></div>
+
+    <div *ngIf="displayAttr('location')">
+      <ng-template #locationTemplate>
+        <eg-item-location-select (valueChange)="values['location'] = $event"
+          domId='location-input' [required]="true" permFilter="UPDATE_COPY">
+        </eg-item-location-select>
+      </ng-template>
+      <ng-container *ngTemplateOutlet="batchAttr;
+        context:{field:'location',required:true,template:locationTemplate}">
+      </ng-container>
+    </div>
+
+    <div *ngIf="displayAttr('circ_lib')">
+      <ng-template #circLibTemplate>
+        <eg-org-select 
+          domId="circ_lib-input"
+          (onChange)="values['circ_lib'] = $event ? $event.id() : null"
+          [hideOrgs]="volcopy.hideVolOrgs"
+          [limitPerms]="['UPDATE_COPY']">
+        </eg-org-select>
+      </ng-template>
+      <ng-container *ngTemplateOutlet="batchAttr;
+        context:{field:'circ_lib',required:true,template:circLibTemplate}">
+      </ng-container>
+    </div>
+
+    <div *ngIf="displayAttr('owning_lib')">
+      <eg-string #olLabel text="Owning Library" i18n-text></eg-string>
+      <ng-template #owningLibTemplate>
+        <eg-org-select 
+          domId="owning_lib-input"
+          (onChange)="values['owning_lib'] = $event ? $event.id() : null"
+          [hideOrgs]="volcopy.hideVolOrgs"
+          [limitPerms]="['UPDATE_COPY']">
+        </eg-org-select>
+      </ng-template>
+      <ng-container *ngTemplateOutlet="batchAttr;
+        context:{field:'owning_lib',required:true,template:owningLibTemplate,label:olLabel.text}">
+      </ng-container>
+    </div>
+
+    <div *ngIf="displayAttr('copy_number')">
+      <ng-template #copyNumberTemplate>
+        <input type="number" class="form-control"
+          id="copy_number-input" [(ngModel)]="values['copy_number']"/>
+      </ng-template>
+      <ng-container *ngTemplateOutlet="batchAttr;
+        context:{field:'copy_number',template:copyNumberTemplate}">
+      </ng-container>
+    </div>
+  </div>
+
+  <!-- COLUMN 3 -->
+
+  <div class="flex-1 p-1">
+    <div class="p-1"><h4 class="font-weight-bold" i18n>Circulation</h4></div>
+
+    <div *ngIf="displayAttr('circulate')">
+      <ng-template #circulateTemplate>
+        <ng-container *ngTemplateOutlet="yesNoSelect;context:{field:'circulate'}">
+        </ng-container>
+      </ng-template>
+      <ng-container *ngTemplateOutlet="batchAttr;
+        context:{field:'circulate',required:true,template:circulateTemplate,displayAs:'bool'}">
+      </ng-container>
+    </div>
+
+    <div *ngIf="displayAttr('holdable')">
+      <ng-template #holdableTemplate>
+        <ng-container *ngTemplateOutlet="yesNoSelect;context:{field:'holdable'}">
+        </ng-container>
+      </ng-template>
+      <ng-container *ngTemplateOutlet="batchAttr;
+        context:{field:'holdable',required:true,template:holdableTemplate,displayAs:'bool'}">
+      </ng-container>
+    </div>
+
+    <div *ngIf="displayAttr('age_protect')">
+      <ng-template #ageProtectTemplate>
+        <eg-combobox domId="age_protect-input"
+          (ngModelChange)="values['age_protect'] = $event ? $event.id : null"
+          [ngModel]="values['age_protect']">
+          <eg-combobox-entry 
+            *ngFor="let rule of volcopy.commonData.acp_age_protect"
+            [entryId]="rule.id()" [entryLabel]="rule.name()">
+          </eg-combobox-entry>
+        </eg-combobox>
+      </ng-template>
+      <ng-container *ngTemplateOutlet="batchAttr;
+        context:{field:'age_protect',template:ageProtectTemplate}">
+      </ng-container>
+    </div>
+
+    <div *ngIf="displayAttr('floating')">
+      <ng-template #floatingTemplate>
+        <eg-combobox domId="floating-input"
+          (ngModelChange)="values['floating'] = $event ? $event.id : null"
+          [ngModel]="values['floating']">
+          <eg-combobox-entry 
+            *ngFor="let grp of volcopy.commonData.acp_floating_group"
+            [entryId]="grp.id()" [entryLabel]="grp.name()">
+          </eg-combobox-entry>
+        </eg-combobox>
+      </ng-template>
+      <ng-container *ngTemplateOutlet="batchAttr;
+        context:{field:'floating',template:floatingTemplate}">
+      </ng-container>
+    </div>
+
+    <div *ngIf="displayAttr('loan_duration')">
+      <eg-string #loanDurationShort i18n-text text="Short"></eg-string>
+      <eg-string #loanDurationNormal i18n-text text="Normal"></eg-string>
+      <eg-string #loanDurationLong i18n-text text="Long"></eg-string>
+
+      <ng-template #loanDurationTemplate>
+        <select class="form-control" 
+          id="loan_duration-input" [(ngModel)]="values['loan_duration']">
+          <option value="1" i18n>{{loanDurationShort.text}}</option>
+          <option value="2" i18n>{{loanDurationNormal.text}}</option>
+          <option value="3" i18n>{{loanDurationLong.text}}</option>
+        </select>
+      </ng-template>
+      <ng-container *ngTemplateOutlet="batchAttr;
+        context:{field:'loan_duration',required:true,template:loanDurationTemplate}">
+      </ng-container>
+    </div>
+
+    <div *ngIf="displayAttr('fine_level')">
+      <eg-string #fineLevelLow i18n-text text="Low"></eg-string>
+      <eg-string #fineLevelNormal i18n-text text="Normal"></eg-string>
+      <eg-string #fineLevelHigh i18n-text text="High"></eg-string>
+
+      <ng-template #fineLevelTemplate>
+        <select class="form-control" 
+          id="fine_level-input" [(ngModel)]="values['fine_level']">
+          <option value="1" i18n>{{fineLevelLow.text}}</option>
+          <option value="2" i18n>{{fineLevelNormal.text}}</option>
+          <option value="3" i18n>{{fineLevelHigh.text}}</option>
+        </select>
+      </ng-template>
+      <ng-container *ngTemplateOutlet="batchAttr;
+        context:{field:'fine_level',required:true,template:fineLevelTemplate}">
+      </ng-container>
+    </div>
+
+    <div *ngIf="displayAttr('circ_as_type')">
+      <ng-template #circAsTypeTemplate>
+        <eg-combobox domId="circ_as_type-input"
+          (ngModelChange)="values['circ_as_type'] = $event ? $event.id : null"
+          [ngModel]="values['circ_as_type']">
+          <eg-combobox-entry *ngFor="let map of volcopy.commonData.acp_item_type_map"
+            [entryId]="map.code()" [entryLabel]="map.value()">
+          </eg-combobox-entry>
+        </eg-combobox>
+      </ng-template>
+      <ng-container *ngTemplateOutlet="batchAttr;
+        context:{field:'circ_as_type',template:circAsTypeTemplate}">
+      </ng-container>
+    </div>
+
+    <div *ngIf="displayAttr('circ_modifier')">
+      <ng-template #circModifierTemplate>
+        <select class="form-control" id='circ_modifier-input' 
+          [(ngModel)]="values['circ_modifier']">
+          <option [value]="null" i18n>&lt;Unset&gt;</option>
+          <option *ngFor="let mod of volcopy.commonData.acp_circ_modifier"
+            value="{{mod.code()}}">{{mod.name()}}</option>
+        </select>
+      </ng-template>
+      <ng-container *ngTemplateOutlet="batchAttr;
+        context:{field:'circ_modifier',template:circModifierTemplate}">
+      </ng-container>
+    </div>
+
+  </div>
+
+  <!-- COLUMN 4 -->
+
+  <div class="flex-1 p-1">
+    <div class="p-1"><h4 class="font-weight-bold" i18n>Miscellaneous</h4></div>
+
+    <!-- Adding this for sites that still use alert messages (we do)
+    <div>
+      <ng-template #alertMessageTemplate>
+        <textarea rows="3" class="form-control" id="alert-message-input"
+          [(ngModel)]="values['alert_message']">
+        </textarea>
+      </ng-template>
+      <eg-batch-item-attr label="Alert Message" i18n-label
+        editInputDomId="alert-message-input"
+        [editTemplate]="alertMessageTemplate"
+        [labelCounts]="itemAttrCounts('alert_message')"
+        (changesSaved)="applyCopyValue('alert_message')">
+      </eg-batch-item-attr>
+    </div>
+    -->
+
+    <div class="border rounded m-1" *ngIf="displayAttr('copy_alerts')">
+      <eg-copy-alerts-dialog #copyAlertsDialog></eg-copy-alerts-dialog>
+      <div class="batch-header font-weight-bold p-2" i18n>Add Item Alerts</div>
+      <div class="p-1">
+        <button class="btn btn-outline-dark" (click)="openCopyAlerts()" i18n>
+          Item Alerts
+        </button>
+      </div>
+    </div>
+
+    <div *ngIf="displayAttr('deposit')">
+      <ng-template #depositTemplate>
+        <ng-container *ngTemplateOutlet="yesNoSelect;context:{field:'deposit'}">
+        </ng-container>
+      </ng-template>
+      <ng-container *ngTemplateOutlet="batchAttr;
+        context:{field:'deposit',required:true,template:depositTemplate,displayAs:'bool'}">
+      </ng-container>
+    </div>
+
+    <div *ngIf="displayAttr('deposit_amount')">
+      <ng-template #depositAmountTemplate>
+        <input type="number" class="form-control" 
+          id="deposit_amount-input" [(ngModel)]="values['deposit_amount']"/>
+      </ng-template>
+      <ng-container *ngTemplateOutlet="batchAttr;
+        context:{field:'deposit_amount',required:true,template:depositAmountTemplate,displayAs:'currency'}">
+      </ng-container>
+    </div>
+
+    <div *ngIf="displayAttr('price')">
+      <ng-template #priceTemplate>
+        <input type="number" class="form-control" 
+          id="price-input" [(ngModel)]="values['price']"/>
+      </ng-template>
+      <ng-container *ngTemplateOutlet="batchAttr;
+        context:{field:'price',template:priceTemplate,displayAs:'currency'}">
+      </ng-container>
+    </div>
+
+    <div *ngIf="displayAttr('opac_visible')">
+      <ng-template #opacVisibleTemplate>
+        <ng-container *ngTemplateOutlet="yesNoSelect;context:{field:'opac_visible'}">
+        </ng-container>
+      </ng-template>
+      <ng-container *ngTemplateOutlet="batchAttr;
+        context:{field:'opac_visible',required:true,template:opacVisibleTemplate,displayAs:'bool'}">
+      </ng-container>
+    </div>
+
+    <div *ngIf="displayAttr('ref')">
+      <ng-template #refTemplate>
+        <ng-container *ngTemplateOutlet="yesNoSelect;context:{field:'ref'}">
+        </ng-container>
+      </ng-template>
+      <ng-container *ngTemplateOutlet="batchAttr;
+        context:{field:'ref',required:true,template:refTemplate,displayAs:'bool'}">
+      </ng-container>
+    </div>
+
+    <div *ngIf="displayAttr('cost')">
+      <ng-template #costTemplate>
+        <input type="number" class="form-control" 
+          id="cost-input" [(ngModel)]="values['cost']"/>
+      </ng-template>
+      <ng-container *ngTemplateOutlet="batchAttr;
+        context:{field:'cost',template:costTemplate,displayAs:'currency'}">
+      </ng-container>
+    </div>
+
+    <div *ngIf="displayAttr('mint_condition')">
+      <eg-string #mintConditionYes i18n-text text="Good"></eg-string>
+      <eg-string #mintConditionNo i18n-text text="Damaged"></eg-string>
+
+      <ng-template #mintConditionTemplate>
+        <select class="form-control" 
+          id="mint_condition-input" [(ngModel)]="values['mint_condition']">
+          <option value="t" i18n>{{mintConditionYes.text}}</option>
+          <option value="f" i18n>{{mintConditionNo.text}}</option>
+        </select>
+      </ng-template>
+      <ng-container *ngTemplateOutlet="batchAttr;
+        context:{field:'mint_condition',template:mintConditionTemplate}">
+      </ng-container>
+    </div>
+
+  </div>
+
+  <!-- COLUMN 5 -->
+  <div class="flex-1 p-1">
+    <div class="p-1"><h4 class="font-weight-bold" i18n>Statistics</h4></div>
+
+    <div class="border rounded m-1" *ngIf="displayAttr('copy_tags')">
+      <eg-copy-tags-dialog #copyTagsDialog></eg-copy-tags-dialog>
+      <div class="batch-header font-weight-bold p-2" i18n>Add Item Tags</div>
+      <div class="p-1">
+        <button class="btn btn-outline-dark" (click)="openCopyTags()" i18n>
+          Item Tags
+        </button>
+      </div>
+    </div>
+
+    <div class="border rounded m-1" *ngIf="displayAttr('statcat_filter')">
+      <div class="batch-header font-weight-bold p-2" i18n>Stat Cat Filter</div>
+      <div class="p-1">
+        <eg-org-select
+          domId="statcat_filter-select"
+          placeholder="Stat Cat Filter..." i18n-placeholder
+          [initialOrgId]="statCatFilter"
+          (onChange)="statCatFilter = $event ? $event.id() : null">
+        </eg-org-select>
+      </div>
+    </div>
+
+    <ng-container *ngIf="displayAttr('statcats')">
+      <div *ngFor="let cat of statCats()">
+        <ng-template #statCatTemplate>
+          <eg-combobox domId="stat-cat-input-{{cat.id()}}"
+            (ngModelChange)="statCatValues[cat.id()] = $event ? $event.id : null"
+            [ngModel]="statCatValues[cat.id()]">
+            <eg-combobox-entry *ngFor="let entry of cat.entries()"
+              [entryId]="entry.id()" [entryLabel]="entry.value()">
+            </eg-combobox-entry>
+          </eg-combobox>
+        </ng-template>
+        <eg-batch-item-attr label="{{cat.name()}} ({{orgSn(cat.owner())}})" i18n-label
+          name="stat_cat_{{cat.id()}}" editInputDomId="stat-cat-input-{{cat.id()}}"
+          [editTemplate]="statCatTemplate"
+          [labelCounts]="statCatCounts(cat.id())"
+          (valueCleared)="statCatChanged(cat.id(), true)"
+          (changesSaved)="statCatChanged(cat.id())">
+        </eg-batch-item-attr>
+      </div>
+    </ng-container>
+  </div>
+</div>
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/copy-attrs.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/copy-attrs.component.ts
new file mode 100644 (file)
index 0000000..8284840
--- /dev/null
@@ -0,0 +1,614 @@
+import {Component, Input, OnInit, AfterViewInit, ViewChild,
+    EventEmitter, Output, QueryList, ViewChildren} from '@angular/core';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {SafeUrl} from '@angular/platform-browser';
+import {tap} from 'rxjs/operators';
+import {IdlObject, IdlService} from '@eg/core/idl.service';
+import {EventService} from '@eg/core/event.service';
+import {OrgService} from '@eg/core/org.service';
+import {StoreService} from '@eg/core/store.service';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {HoldingsService} from '@eg/staff/share/holdings/holdings.service';
+import {VolCopyContext} from './volcopy';
+import {VolCopyService} from './volcopy.service';
+import {FormatService} from '@eg/core/format.service';
+import {StringComponent} from '@eg/share/string/string.component';
+import {CopyAlertsDialogComponent
+    } from '@eg/staff/share/holdings/copy-alerts-dialog.component';
+import {CopyTagsDialogComponent
+    } from '@eg/staff/share/holdings/copy-tags-dialog.component';
+import {ComboboxComponent, ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {BatchItemAttrComponent, BatchChangeSelection
+    } from '@eg/staff/share/holdings/batch-item-attr.component';
+import {FileExportService} from '@eg/share/util/file-export.service';
+
+@Component({
+  selector: 'eg-copy-attrs',
+  templateUrl: 'copy-attrs.component.html',
+
+  // Match the header of the batch attrs component
+  styles: [
+    `.batch-header {background-color: #EBF4FA;}`,
+    `.template-row {background-color: #EBF4FA;}`
+  ]
+})
+export class CopyAttrsComponent implements OnInit, AfterViewInit {
+
+    @Input() context: VolCopyContext;
+
+    // Batch values applied from the form.
+    // Some values are scalar, some IdlObjects depending on copy fleshyness.
+    values: {[field: string]: any} = {};
+
+    // Map of stat ID to entry ID.
+    statCatValues: {[statId: number]: number} = {};
+
+    loanDurationLabelMap: {[level: number]: string} = {};
+    fineLevelLabelMap: {[level: number]: string} = {};
+
+    statCatFilter: number;
+
+    @ViewChild('loanDurationShort', {static: false})
+        loanDurationShort: StringComponent;
+    @ViewChild('loanDurationNormal', {static: false})
+        loanDurationNormal: StringComponent;
+    @ViewChild('loanDurationLong', {static: false})
+        loanDurationLong: StringComponent;
+
+    @ViewChild('fineLevelLow', {static: false})
+        fineLevelLow: StringComponent;
+    @ViewChild('fineLevelNormal', {static: false})
+        fineLevelNormal: StringComponent;
+    @ViewChild('fineLevelHigh', {static: false})
+        fineLevelHigh: StringComponent;
+
+    @ViewChild('mintConditionYes', {static: false})
+        mintConditionYes: StringComponent;
+    @ViewChild('mintConditionNo', {static: false})
+        mintConditionNo: StringComponent;
+
+    @ViewChild('copyAlertsDialog', {static: false})
+        private copyAlertsDialog: CopyAlertsDialogComponent;
+
+    @ViewChild('copyTagsDialog', {static: false})
+        private copyTagsDialog: CopyTagsDialogComponent;
+
+    @ViewChild('copyTemplateCbox', {static: false})
+        copyTemplateCbox: ComboboxComponent;
+
+    @ViewChildren(BatchItemAttrComponent)
+        batchAttrs: QueryList<BatchItemAttrComponent>;
+
+    // Emitted when the save-ability of this form changes.
+    @Output() canSaveChange: EventEmitter<boolean> = new EventEmitter<boolean>();
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private evt: EventService,
+        private idl: IdlService,
+        private org: OrgService,
+        private net: NetService,
+        private auth: AuthService,
+        private pcrud: PcrudService,
+        private holdings: HoldingsService,
+        private format: FormatService,
+        private store: StoreService,
+        private fileExport: FileExportService,
+        public  volcopy: VolCopyService
+    ) { }
+
+    ngOnInit() {
+        this.statCatFilter = this.volcopy.defaults.values.statcat_filter;
+    }
+
+    ngAfterViewInit() {
+
+        const tmpl = this.store.getLocalItem('cat.copy.last_template');
+        if (tmpl) {
+            // avoid Express Changed warning w/ timeout
+            setTimeout(() => this.copyTemplateCbox.selectedId = tmpl);
+        }
+
+        this.loanDurationLabelMap[1] = this.loanDurationShort.text;
+        this.loanDurationLabelMap[2] = this.loanDurationNormal.text;
+        this.loanDurationLabelMap[3] = this.loanDurationLong.text;
+
+        this.fineLevelLabelMap[1] = this.fineLevelLow.text;
+        this.fineLevelLabelMap[2] = this.fineLevelNormal.text;
+        this.fineLevelLabelMap[3] = this.fineLevelHigh.text;
+
+    }
+
+    statCats(): IdlObject[] {
+        if (this.statCatFilter) {
+            const orgs = this.org.descendants(this.statCatFilter, true);
+
+            return this.volcopy.commonData.acp_stat_cat.filter(
+                sc => orgs.includes(sc.owner()));
+
+        } else {
+
+            return this.volcopy.commonData.acp_stat_cat;
+        }
+    }
+
+
+    orgSn(orgId: number): string {
+        return orgId ? this.org.get(orgId).shortname() : '';
+    }
+
+    statCatCounts(catId: number): {[value: string]: number} {
+        catId = Number(catId);
+        const counts = {};
+
+        this.context.copyList().forEach(copy => {
+            const entry = copy.stat_cat_entries()
+                .filter(e => e.stat_cat() === catId)[0];
+
+            let value = '';
+            if (entry) {
+                if (this.volcopy.statCatEntryMap[entry.id()]) {
+                    value = this.volcopy.statCatEntryMap[entry.id()].value();
+                } else {
+                    // Map to a remote stat cat.  Ignore.
+                    return;
+                }
+            }
+
+            if (counts[value] === undefined) {
+                counts[value] = 0;
+            }
+            counts[value]++;
+        });
+
+        return counts;
+    }
+
+    itemAttrCounts(field: string): {[value: string]: number} {
+
+        const counts = {};
+        this.context.copyList().forEach(copy => {
+            const value = this.getFieldDisplayValue(field, copy);
+
+            if (counts[value] === undefined) {
+                counts[value] = 0;
+            }
+            counts[value]++;
+        });
+
+        return counts;
+    }
+
+    getFieldDisplayValue(field: string, copy: IdlObject): string {
+
+        // Some fields don't live directly on the copy.
+        if (field === 'owning_lib') {
+            return this.org.get(
+                copy.call_number().owning_lib()).shortname() +
+                ' : ' + copy.call_number().label();
+        }
+
+        const value = copy[field]();
+
+        if (!value && value !== 0) { return ''; }
+
+        switch (field) {
+
+            case 'status':
+                return this.volcopy.copyStatuses[value].name();
+
+            case 'location':
+                return value.name() +
+                    ' (' + this.org.get(value.owning_lib()).shortname() + ')';
+
+            case 'edit_date':
+            case 'create_date':
+            case 'active_date':
+                return this.format.transform(
+                    {datatype: 'timestamp', value: value});
+
+            case 'editor':
+            case 'creator':
+                return value.usrname();
+
+            case 'circ_lib':
+                return this.org.get(value).shortname();
+
+            case 'age_protect':
+                const rule = this.volcopy.commonData.acp_age_protect.filter(
+                    r => r.id() === Number(value))[0];
+                return rule ? rule.name() : '';
+
+            case 'floating':
+                const grp = this.volcopy.commonData.acp_floating_group.filter(
+                    g => g.id() === Number(value))[0];
+                return grp ? grp.name() : '';
+
+            case 'loan_duration':
+                return this.loanDurationLabelMap[value];
+
+            case 'fine_level':
+                return this.fineLevelLabelMap[value];
+
+            case 'circ_as_type':
+                const map = this.volcopy.commonData.acp_item_type_map.filter(
+                    m => m.code() === value)[0];
+                return map ? map.value() : '';
+
+            case 'circ_modifier':
+                const mod = this.volcopy.commonData.acp_circ_modifier.filter(
+                    m => m.code() === value)[0];
+                return mod ? mod.name() : '';
+
+            case 'mint_condition':
+                if (!this.mintConditionYes) { return ''; }
+                return value === 't' ?
+                    this.mintConditionYes.text : this.mintConditionNo.text;
+        }
+
+        return value;
+    }
+
+    copyWantsChange(copy: IdlObject, field: string,
+            changeSelection: BatchChangeSelection): boolean {
+        const disValue = this.getFieldDisplayValue(field, copy);
+        return changeSelection[disValue] === true;
+    }
+
+    applyCopyValue(field: string, value?: any, changeSelection?: BatchChangeSelection) {
+        if (value === undefined) {
+            value = this.values[field];
+        } else {
+            this.values[field] = value;
+        }
+
+        if (field === 'owning_lib') {
+            this.owningLibChanged(value, changeSelection);
+
+        } else {
+
+            this.context.copyList().forEach(copy => {
+                if (!copy[field] || copy[field]() === value) { return; }
+
+                // Change selection indicates which items should be modified
+                // based on the display value for the selected field at
+                // time of editing.
+                if (changeSelection &&
+                    !this.copyWantsChange(copy, field, changeSelection)) {
+                    return;
+                }
+
+                copy[field](value);
+                copy.ischanged(true);
+            });
+        }
+
+        this.emitSaveChange();
+    }
+
+    owningLibChanged(orgId: number, changeSelection?: BatchChangeSelection) {
+        if (!orgId) { return; }
+
+        // Map existing vol IDs to their replacments.
+        const newVols: any = {};
+
+        this.context.copyList().forEach(copy => {
+
+            if (changeSelection &&
+                !this.copyWantsChange(copy, 'owning_lib', changeSelection)) {
+                return;
+            }
+
+            // Change the copy circ lib to match the new owning lib
+            // if configured to do so.
+            if (this.volcopy.defaults.values.circ_lib_mod_with_owning_lib) {
+                if (copy.circ_lib() !== orgId) {
+                    copy.circ_lib(orgId);
+                    copy.ischanged(true);
+
+                    this.batchAttrs
+                        .filter(ba => ba.name === 'circ_lib')
+                        .forEach(attr => attr.hasChanged = true);
+                }
+            }
+
+            const vol = copy.call_number();
+
+            if (vol.owning_lib() === orgId) { return; } // No change needed
+
+            let newVol;
+            if (newVols[vol.id()]) {
+                newVol = newVols[vol.id()];
+
+            } else {
+
+                // The open-ils.cat.asset.volume.fleshed.batch.update API
+                // will use the existing volume when trying to create a
+                // new volume with the same parameters as an existing volume.
+                newVol = this.idl.clone(vol);
+                newVol.owning_lib(orgId);
+                newVol.id(this.volcopy.autoId--);
+                newVol.isnew(true);
+                newVols[vol.id()] = newVol;
+            }
+
+            copy.call_number(newVol);
+            copy.ischanged();
+
+            this.context.removeCopyNode(copy.id());
+            this.context.findOrCreateCopyNode(copy);
+        });
+
+        // If any of the above actions results in an empty volume
+        // remove it from the tree.  Note this does not delete the
+        // volume at the server, since other items could be attached
+        // of which this instance of the editor is not aware.
+        Object.keys(newVols).forEach(volId => {
+
+            const volNode = this.context.volNodes().filter(
+                node => node.target.id() === +volId)[0];
+
+            if (volNode && volNode.children.length === 0) {
+                this.context.removeVolNode(+volId);
+            }
+        });
+    }
+
+    // Create or modify a stat cat entry for each copy that does not
+    // already match the new value.
+    statCatChanged(catId: number, clear?: boolean) {
+        catId = Number(catId);
+
+        const entryId = this.statCatValues[catId];
+
+        if (!entryId || !this.volcopy.statCatEntryMap[entryId]) {
+            console.warn(
+                `Attempt to apply stat cat value which does not exist.
+                This is likely the result of a stale copy template.
+                stat_cat=${catId} entry=${entryId}`);
+
+            return;
+        }
+
+        this.context.copyList().forEach(copy => {
+
+            let entry = copy.stat_cat_entries()
+                .filter(e => e.stat_cat() === catId)[0];
+
+            if (entry) {
+                if (entry.id() === entryId) {
+                    // Requested mapping already exists.
+                    return;
+                }
+            } else {
+
+                // Copy has no entry for this stat cat yet.
+                entry = this.idl.create('asce');
+                entry.stat_cat(catId);
+                copy.stat_cat_entries().push(entry);
+            }
+
+            entry.id(entryId);
+            entry.value(this.volcopy.statCatEntryMap[entryId].value());
+
+            copy.ischanged(true);
+        });
+    }
+
+    openCopyAlerts() {
+        this.copyAlertsDialog.inPlaceMode = true;
+        this.copyAlertsDialog.copyIds = this.context.copyList().map(c => c.id());
+
+        this.copyAlertsDialog.open({size: 'lg'}).subscribe(
+            newAlert => {
+                if (newAlert) {
+                    this.context.copyList().forEach(copy => {
+                        const a = this.idl.clone(newAlert);
+                        a.isnew(true);
+                        a.copy(copy.id());
+                        if (!copy.copy_alerts()) { copy.copy_alerts([]); }
+                        copy.copy_alerts().push(a);
+                        copy.ischanged(true);
+                    });
+                }
+            }
+        );
+    }
+
+    openCopyTags() {
+        this.copyTagsDialog.inPlaceMode = true;
+        this.copyTagsDialog.copyIds = this.context.copyList().map(c => c.id());
+
+        this.copyTagsDialog.open({size: 'lg'}).subscribe(newTags => {
+            if (!newTags || newTags.length === 0) { return; }
+
+            newTags.forEach(tag => {
+                this.context.copyList().forEach(copy => {
+
+                    if (copy.tags().filter(
+                        m => m.tag().id() === tag.id()).length > 0) {
+                        return; // map already exists
+                    }
+
+                    const map = this.idl.create('acptcm');
+                    map.isnew(true);
+                    map.copy(copy.id());
+                    map.tag(tag);
+
+                    copy.tags().push(map);
+                    copy.ischanged(true);
+                });
+            });
+        });
+    }
+
+    applyTemplate() {
+        const entry = this.copyTemplateCbox.selected;
+        if (!entry) { return; }
+
+        this.store.setLocalItem('cat.copy.last_template', entry.id);
+
+        const template = this.volcopy.templates[entry.id];
+
+        Object.keys(template).forEach(field => {
+            const value = template[field];
+
+            if (value === null || value === undefined) { return; }
+
+            if (field === 'statcats') {
+                Object.keys(value).forEach(catId => {
+                    this.statCatValues[+catId] = value[+catId];
+                    this.statCatChanged(+catId);
+                });
+                return;
+            }
+
+            // In some cases, we may have to fetch the data since
+            // the local code assumes copy field is fleshed.
+            let promise = Promise.resolve(value);
+
+            if (field === 'location') {
+                // May be a 'remote' location.  Fetch as needed.
+                promise = this.volcopy.getLocation(value);
+            }
+
+            promise.then(val => {
+                this.applyCopyValue(field, val);
+
+                // Indicate in the form these values have changed
+                this.batchAttrs
+                    .filter(ba => ba.name === field)
+                    .forEach(attr => attr.hasChanged = true);
+            });
+        });
+    }
+
+    saveTemplate() {
+        const entry: ComboboxEntry = this.copyTemplateCbox.selected;
+        if (!entry) { return; }
+
+        let name;
+        let template;
+
+        if (entry.freetext) {
+            name = entry.label;
+            // freetext entries don't have an ID, but we may need one later.
+            entry.id = entry.label;
+            template = {};
+        } else {
+            name = entry.id;
+            template = this.volcopy.templates[name];
+        }
+
+        this.batchAttrs.forEach(comp => {
+            if (!comp.hasChanged) { return; }
+
+            const field = comp.name;
+            const value = this.values[field];
+
+            if (value === null) {
+                delete template[field];
+                return;
+            }
+
+            if (field.match(/stat_cat_/)) {
+                const statId = field.match(/stat_cat_(\d+)/)[1];
+                if (!template.statcats) { template.statcats = {}; }
+
+                template.statcats[statId] = value;
+
+            } else {
+
+                // Some values are fleshed. this assumes fleshed objects
+                // have an 'id' value, which is true so far.
+                template[field] =
+                    typeof value === 'object' ?  value.id() : value;
+            }
+        });
+
+        this.volcopy.templates[name] = template;
+        this.volcopy.saveTemplates();
+    }
+
+    exportTemplate($event) {
+        if (this.fileExport.inProgress()) { return; }
+
+        this.fileExport.exportFile(
+            $event, JSON.stringify(this.volcopy.templates), 'text/json');
+    }
+
+    importTemplate($event) {
+        const file: File = $event.target.files[0];
+        if (!file) { return; }
+
+        const reader = new FileReader();
+
+        reader.addEventListener('load', () => {
+
+            try {
+                const template = JSON.parse(reader.result as string);
+                const name = Object.keys(template)[0];
+                this.volcopy.templates[name] = template[name];
+            } catch (E) {
+                console.error('Invalid Item Attribute template', E);
+                return;
+            }
+
+            this.volcopy.saveTemplates();
+            // Adds the new one to the list and re-sorts the labels.
+            this.volcopy.fetchTemplates();
+        });
+
+        reader.readAsText(file);
+    }
+
+    // Returns null when no export is in progress.
+    exportTemplateUrl(): SafeUrl {
+        return this.fileExport.safeUrl;
+    }
+
+    deleteTemplate() {
+        const entry: ComboboxEntry = this.copyTemplateCbox.selected;
+        if (!entry) { return; }
+        delete this.volcopy.templates[entry.id];
+        this.volcopy.saveTemplates();
+        this.copyTemplateCbox.selected = null;
+    }
+
+    displayAttr(field: string): boolean {
+        return this.volcopy.defaults.hidden[field] !== true;
+    }
+
+    copyFieldLabel(field: string): string {
+        const def = this.idl.classes.acp.field_map[field];
+        return def ? def.label : '';
+    }
+
+    // Returns false if any items are in magic statuses
+    statusEditable(): boolean {
+        const copies = this.context.copyList();
+        for (let idx = 0; idx < copies.length; idx++) {
+            if (this.volcopy.copyStatIsMagic(copies[idx].status())) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    // Called any time a change occurs that could affect the
+    // save-ability of the form.
+    emitSaveChange() {
+        setTimeout(() => {
+            const canSave = this.batchAttrs.filter(
+                attr => attr.warnOnRequired()).length === 0;
+
+            this.canSaveChange.emit(canSave);
+        });
+    }
+}
+
+
+
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..c5d3f67
--- /dev/null
@@ -0,0 +1,25 @@
+import {NgModule} from '@angular/core';
+import {RouterModule, Routes} from '@angular/router';
+import {VolCopyComponent} from './volcopy.component';
+
+const routes: Routes = [{
+    path: ':tab/:target/:target_id',
+    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..de37f46
--- /dev/null
@@ -0,0 +1,25 @@
+
+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 #d9edf7;
+  border-bottom: 1px solid #d9edf7;
+}
+
+.clear-button {
+  border: none;
+  background-color: rgba(0, 0, 0, 0.0);
+  padding-left: .25rem;
+  padding-right: .25rem;
+  line-height: inherit;
+}
+
+.clear-button .material-icons {
+  font-size: 15px;
+  color: grey;
+}
+
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..6f3db70
--- /dev/null
@@ -0,0 +1,424 @@
+<eg-confirm-dialog 
+  #confirmDelVol
+  i18n-dialogTitle i18n-dialogBody
+  dialogTitle="Delete Call Number?"
+  dialogBody="Delete {{deleteVolCount}} Call Number(s) and All Associated Item(s)?">
+</eg-confirm-dialog>
+
+<eg-confirm-dialog 
+  #confirmDelCopy
+  i18n-dialogTitle i18n-dialogBody
+  dialogTitle="Delete Item?"
+  dialogBody="Delete {{deleteCopyCount}} Item(s)?">
+</eg-confirm-dialog>
+
+<div class="row d-flex bg-faint mb-2 pb-1 pt-1 border border-dark rounded">
+  <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)}" *ngIf="displayColumn('classification')">
+    <div><label class="font-weight-bold" i18n>Classification</label></div>
+    <div>
+      <eg-combobox [smallFormControl]="true" [(ngModel)]="batchVolClass">
+        <eg-combobox-entry *ngFor="let cls of volcopy.commonData.acn_class"
+          [entryId]="cls.id()" [entryLabel]="cls.name()">
+        </eg-combobox-entry>
+      </eg-combobox>
+    </div>
+  </div>
+  <div class="p-1" [ngStyle]="{flex: flexAt(4)}" *ngIf="displayColumn('prefix')">
+    <div><label class="font-weight-bold" i18n>Prefix</label></div>
+    <div>
+      <eg-combobox [smallFormControl]="true" [(ngModel)]="batchVolPrefix">
+        <eg-combobox-entry *ngFor="let pfx of volcopy.commonData.acn_prefix"
+          [entryId]="pfx.id()" [entryLabel]="pfx.label()">
+        </eg-combobox-entry>
+      </eg-combobox>
+    </div>
+  </div>
+  <div class="p-1" [ngStyle]="{flex: flexAt(5)}">
+    <div>
+      <label class="font-weight-bold label-with-material-icon" i18n>
+        Call Number Label
+      </label>
+    </div>
+    <div>
+      <eg-combobox [smallFormControl]="true" 
+        [allowFreeText]="true" [(ngModel)]="batchVolLabel">
+        <eg-combobox-entry *ngFor="let label of recordVolLabels" 
+          [entryId]="label" [entryLabel]="label">
+        </eg-combobox-entry>
+      </eg-combobox>
+    </div>
+  </div>
+  <div class="p-1" [ngStyle]="{flex: flexAt(6)}" *ngIf="displayColumn('suffix')">
+    <div><label class="font-weight-bold" i18n>Suffix</label></div>
+    <div>
+      <eg-combobox [smallFormControl]="true" [(ngModel)]="batchVolSuffix">
+        <eg-combobox-entry *ngFor="let sfx of volcopy.commonData.acn_suffix"
+          [entryId]="sfx.id()" [entryLabel]="sfx.label()">
+        </eg-combobox-entry>
+      </eg-combobox>
+    </div>
+  </div>
+  <div class="p-1" [ngStyle]="{flex: flexAt(7)}">
+    <div><label class="font-weight-bold" i18n>Batch</label></div>
+    <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)}">
+    <ng-container *ngIf="displayColumn('generate_barcodes')">
+      <div><label class="font-weight-bold" i18n>Generate Barcodes</label></div>
+      <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>
+    </ng-container>
+  </div>
+  <div class="p-1" [ngStyle]="{flex: flexSpan(9, 10)}">
+    <ng-container *ngIf="displayColumn('generate_barcodes')">
+      <div><label class="font-weight-bold" i18n>Checkdigit</label></div>
+      <div class="form-check form-check-inline">
+        <input class="form-check-input" type="checkbox" 
+          (change)="saveUseCheckdigit()"
+          id="use-checkdigit" [(ngModel)]="useCheckdigit"/>
+        <label class="form-check-label" for="use-checkdigit" i18n>
+          Use Checkdigit
+        </label>
+      </div>
+    </ng-container>
+  </div>
+</div>
+
+
+
+<div class="row d-flex mt-2 mb-2">
+  <div class="p-1" [ngStyle]="{flex: flexAt(1)}">
+    <span class="font-weight-bold" i18n>Owning Library
+      <ng-container *ngIf="expand !== 1">
+        <button title="Expand Column" i18n-title 
+          class="material-icon-button" (click)="expand = 1" i18n>
+          &#x2197;
+        </button>
+      </ng-container>
+      <ng-container *ngIf="expand === 1">
+        <button title="Shrink Column" i18n-title 
+          class="material-icon-button" (click)="expand = null" i18n>
+          &#x2199;
+        </button>
+      </ng-container>
+    </span>
+  </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)}" *ngIf="displayColumn('classification')">
+    <span class="font-weight-bold" i18n>Classification
+      <ng-container *ngIf="expand !== 3">
+        <button title="Expand Column" i18n-title 
+          class="material-icon-button" (click)="expand = 3" i18n>
+          &#x2197;
+        </button>
+      </ng-container>
+      <ng-container *ngIf="expand === 3">
+        <button title="Shrink Column" i18n-title 
+          class="material-icon-button" (click)="expand = null" i18n>
+          &#x2199;
+        </button>
+      </ng-container>
+    </span>
+  </div>
+  <div class="p-1" [ngStyle]="{flex: flexAt(4)}" *ngIf="displayColumn('prefix')">
+    <span class="font-weight-bold" i18n>Prefix
+      <ng-container *ngIf="expand !== 4">
+        <button title="Expand Column" i18n-title 
+          class="material-icon-button" (click)="expand = 4" i18n>
+          &#x2197;
+        </button>
+      </ng-container>
+      <ng-container *ngIf="expand === 4">
+        <button title="Shrink Column" i18n-title 
+          class="material-icon-button" (click)="expand = null" i18n>
+          &#x2199;
+        </button>
+      </ng-container>
+    </span>
+  </div>
+  <div class="p-1" [ngStyle]="{flex: flexAt(5)}">
+    <span class="font-weight-bold" i18n>Call Number Label
+      <ng-container *ngIf="expand !== 5">
+        <button title="Expand Column" i18n-title 
+          class="material-icon-button" (click)="expand = 5" i18n>
+          &#x2197;
+        </button>
+      </ng-container>
+      <ng-container *ngIf="expand === 5">
+        <button title="Shrink Column" i18n-title
+          class="material-icon-button" (click)="expand = null" i18n>
+          &#x2199;
+        </button>
+      </ng-container>
+    </span>
+  </div>
+  <div class="p-1" [ngStyle]="{flex: flexAt(6)}" *ngIf="displayColumn('suffix')">
+    <span class="font-weight-bold" i18n>Suffix
+      <ng-container *ngIf="expand !== 6">
+        <button title="Expand Column" i18n-title 
+          class="material-icon-button" (click)="expand = 6" i18n>
+          &#x2197;
+        </button>
+      </ng-container>
+      <ng-container *ngIf="expand === 6">
+        <button title="Shrink Column" i18n-title
+          class="material-icon-button" (click)="expand = null" i18n>
+          &#x2199;
+        </button>
+      </ng-container>
+    </span>
+  </div>
+  <div class="p-1" [ngStyle]="{flex: flexAt(7)}">
+    <label class="font-weight-bold" i18n>Items</label>
+  </div>
+  <!-- 
+    When hiding the copy_number column, absorb its colum width to 
+    take advantage of the space and to ensure the main columns still 
+    line up with the batch updater row sitting above
+  -->
+  <div class="p-1" 
+    [ngStyle]="{flex: displayColumn('copy_number_vc') ? flexAt(8) : flexSpan(8, 9)}">
+    <span class="font-weight-bold" i18n>Barcode
+      <ng-container *ngIf="expand !== 8">
+        <button title="Expand Column" i18n-title 
+          class="material-icon-button" (click)="expand = 8" i18n>
+          &#x2197;
+        </button>
+      </ng-container>
+      <ng-container *ngIf="expand === 8">
+        <button title="Shrink Column" i18n-title
+          class="material-icon-button" (click)="expand = null" i18n>
+          &#x2199;
+        </button>
+      </ng-container>
+    </span>
+  </div>
+  <div class="p-1" [ngStyle]="{flex: flexAt(9)}" *ngIf="displayColumn('copy_number_vc')">
+    <label class="font-weight-bold" i18n>Item #</label>
+  </div>
+  <div class="p-1" [ngStyle]="{flex: flexAt(10)}">
+    <span class="font-weight-bold" i18n>Part
+      <ng-container *ngIf="expand !== 10">
+        <button title="Expand Column" i18n-title 
+          class="material-icon-button" (click)="expand = 10" i18n>
+          &#x2197;
+        </button>
+      </ng-container>
+      <ng-container *ngIf="expand === 10">
+        <button title="Shrink Column" i18n-title 
+          class="material-icon-button" (click)="expand = null" i18n>
+          &#x2199;
+        </button>
+      </ng-container>
+    </span>
+  </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)}">
+          <ng-container *ngIf="copyIdx == 0">
+            <span>{{orgNode.target.shortname()}}</span>
+            {{sessionType}}
+            <ng-container *ngIf="context.sessionType == 'record' || context.sessionType == 'mixed'">
+              <button class="clear-button" (click)="deleteVol(volNode)"
+                title="Delete Call Number {{volNode.target.label()}}" i18n-title>
+                <span class="material-icons">clear</span>
+              </button>
+            </ng-container>
+          </ng-container>
+        </div>
+        <div class="p-1" [ngStyle]="{flex: flexAt(2)}">
+          <ng-container *ngIf="copyIdx == 0 && volIdx == 0">
+            <input type="number" class="form-control form-control-sm"
+              [disabled]="context.sessionType == 'copy' || context.sessionType == 'vol'"
+              [required]="true" [min]="existingVolCount(orgNode)"
+              [ngModel]="orgNode.children.length"
+              (ngModelChange)="volCountChanged(orgNode, $event)"/>
+          </ng-container>
+        </div>
+        <div class="p-1" [ngStyle]="{flex: flexAt(3)}" *ngIf="displayColumn('classification')">
+          <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 volcopy.commonData.acn_class"
+                [entryId]="cls.id()" [entryLabel]="cls.name()">
+              </eg-combobox-entry>
+            </eg-combobox>
+          </ng-container>
+        </div>
+        <div class="p-1" [ngStyle]="{flex: flexAt(4)}" *ngIf="displayColumn('prefix')">
+          <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
+                [entryId]="-1" entryLabel="<None>" i18n-entryLabel>
+              </eg-combobox-entry>
+              <eg-combobox-entry *ngFor="let pfx of volcopy.commonData.acn_prefix"
+                [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"
+              [ngClass]="{invalid: !volNode.target.label()}"
+              [ngModel]="volNode.target.label()"
+              (change)="applyVolValue(volNode.target, 'label', $event.target.value)">
+          </ng-container>
+        </div>
+        <div class="p-1" [ngStyle]="{flex: flexAt(6)}" *ngIf="displayColumn('suffix')">
+          <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
+                [entryId]="-1" entryLabel="<None>" i18n-entryLabel>
+              </eg-combobox-entry>
+              <eg-combobox-entry *ngFor="let sfx of volcopy.commonData.acn_suffix"
+                [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"
+              [disabled]="context.sessionType == 'copy'"
+              [required]="true" [min]="existingCopyCount(volNode)"
+              [ngModel]="volNode.children.length"
+              (ngModelChange)="copyCountChanged(volNode, $event)"/>
+          </ng-container>
+        </div>
+        <div class="p-1" 
+          [ngStyle]="{flex: displayColumn('copy_number_vc') ? flexAt(8) : flexSpan(8, 9)}">
+          <div class="d-flex">
+            <ng-container *ngIf="context.sessionType != 'copy'">
+              <button class="clear-button" (click)="deleteCopy(copyNode)"
+                [disabled]="volcopy.restrictCopyDelete(copyNode.target.status())"
+                title="Delete Item {{copyNode.target.barcode()}}" i18n-title>
+                <span class="material-icons">clear</span>
+              </button>
+            </ng-container>
+
+            <!--
+              Barcode value is not required for new copies, since those 
+              without a barcode will be ignored.
+            -->
+            <input type="text" class="form-control form-control-sm"
+              title="{{copyStatLabel(copyNode.target)}}"
+              id="barcode-input-{{copyNode.target.id()}}"
+              spellcheck="false" [required]="true"
+              placeholder="New Barcode..." i18n-placeholder
+              [disabled]="volcopy.copyStatIsMagic(copyNode.target.status())"
+              [ngClass]="{
+                'text-danger': copyNode.target._dupe_barcode,
+                'invalid': !copyNode.target.barcode() && !copyNode.target.isnew()
+              }"
+              (change)="barcodeChanged(copyNode.target, $event.target.value)"  
+              (ngModelChange)="copyNode.target.barcode($event)"
+              (keyup.enter)="selectNextBarcode(copyNode.target.id())"
+              (keyup.shift.enter)="selectNextBarcode(copyNode.target.id(), true)"
+              (focus)="$event.target.select()"
+              [ngModel]="copyNode.target.barcode()"
+              (ngModelChange)="applyCopyValue(copyNode.target, 'barcode', $event)"/>
+          </div>
+          <div *ngIf="copyNode.target._dupe_barcode"
+            class="alert alert-danger font-italic p-1" i18n>
+            Duplicate Barcode
+          </div>
+        </div>
+        <div class="p-1" [ngStyle]="{flex: flexAt(9)}" *ngIf="displayColumn('copy_number_vc')">
+          <input type="number" min="1" 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
+              [selectedId]="copyNode.target.parts()[0] ? copyNode.target.parts()[0].id() : null"
+              [smallFormControl]="true"
+              (onChange)="copyPartChanged(copyNode, $event)">
+              <eg-combobox-entry 
+                *ngFor="let part of volcopy.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>
+
+<hr/>
+
+<div class="row d-flex">
+
+  <div class="p-1" [ngStyle]="{flex: flexSpan(1, 2)}">
+    <eg-org-select #newVolOrg [applyDefault]="true" 
+      [limitPerms]="['CREATE_VOLUME']" [hideOrgs]="volcopy.hideVolOrgs">
+    </eg-org-select>
+  </div>
+
+  <div class="p-1" [ngStyle]="{flex: flexSpan(3, 4)}">
+    <button class="btn btn-outline-dark ml-2" 
+      (click)="addVol(newVolOrg.selectedOrg())" i18n>
+      Add Call Number
+    </button>
+  </div>
+
+  <div class="p-1" [ngStyle]="{flex: flexAt(5)}"></div>
+  <div class="p-1" [ngStyle]="{flex: flexAt(6)}"></div>
+  <div class="p-1" [ngStyle]="{flex: flexAt(7)}"></div>
+
+  <div class="p-1 pl-3" [ngStyle]="{flex: flexAt(8)}">
+    <ng-container *ngIf="displayColumn('generate_barcodes')">
+      <button class="btn btn-sm btn-outline-dark mr-2"
+        (click)="generateBarcodes()" i18n>Generate Barcodes</button>
+    </ng-container>
+  </div>
+
+  <div class="p-1" [ngStyle]="{flex: flexSpan(9, 10)}">
+    <ng-container *ngIf="displayColumn('generate_barcodes')">
+      <div class="form-check form-check-inline mr-2">
+        <input class="form-check-input" type="checkbox" 
+          (change)="saveUseCheckdigit()"
+          id="use-checkdigit-2" [(ngModel)]="useCheckdigit"/>
+        <label class="form-check-label" for="use-checkdigit-2" i18n>
+          Use Checkdigit
+        </label>
+      </div>
+    </ng-container>
+  </div>
+</div>
+  
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..c8aa4d0
--- /dev/null
@@ -0,0 +1,533 @@
+import {Component, OnInit, AfterViewInit, ViewChild, Input, Renderer2, Output, EventEmitter} from '@angular/core';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {tap} from 'rxjs/operators';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {OrgService} from '@eg/core/org.service';
+import {AuthService} from '@eg/core/auth.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';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+import {VolCopyService} from './volcopy.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.  Column numbers are 1-based.
+    flexSettings: {[column: number]: number} = {
+        1: 1, 2: 1, 3: 2, 4: 1, 5: 2, 6: 1, 7: 1, 8: 2, 9: 1, 10: 1};
+
+    // If a column is specified as the expand field, its flex value
+    // will magically grow.
+    expand: number;
+
+    batchVolClass: ComboboxEntry;
+    batchVolPrefix: ComboboxEntry;
+    batchVolSuffix: ComboboxEntry;
+    batchVolLabel: ComboboxEntry;
+
+    autoBarcodeInProgress = false;
+    useCheckdigit = false;
+
+    deleteVolCount: number = null;
+    deleteCopyCount: number = null;
+
+    recordVolLabels: string[] = [];
+
+    @ViewChild('confirmDelVol', {static: false})
+        confirmDelVol: ConfirmDialogComponent;
+
+    @ViewChild('confirmDelCopy', {static: false})
+        confirmDelCopy: ConfirmDialogComponent;
+
+    // Emitted when the save-ability of this form changes.
+    @Output() canSaveChange: EventEmitter<boolean> = new EventEmitter<boolean>();
+
+    constructor(
+        private renderer: Renderer2,
+        private idl: IdlService,
+        private org: OrgService,
+        private pcrud: PcrudService,
+        private net: NetService,
+        private auth: AuthService,
+        private holdings: HoldingsService,
+        public  volcopy: VolCopyService
+    ) {}
+
+    ngOnInit() {
+
+        this.deleteVolCount = null;
+        this.deleteCopyCount = null;
+        this.useCheckdigit = this.volcopy.defaults.values.use_checkdigit;
+
+        this.volcopy.fetchRecordVolLabels(this.context.recordId)
+        .then(labels => this.recordVolLabels = labels)
+        .then(_ => this.volcopy.fetchBibParts(this.context.getRecordIds()))
+        .then(_ => this.addStubCopies());
+    }
+
+    copyStatLabel(copy: IdlObject): string {
+        if (copy) {
+            const statId = copy.status();
+            if (statId in this.volcopy.copyStatuses) {
+                return this.volcopy.copyStatuses[statId].name();
+            }
+        }
+        return '';
+    }
+
+    recordHasParts(bibId: number): boolean {
+        return this.volcopy.bibParts[bibId] &&
+            this.volcopy.bibParts[bibId].length > 0;
+    }
+
+    // Column width (flex:x) for column by column number.
+    flexAt(column: number): number {
+        return this.flexSpan(column, column);
+    }
+
+    // Returns the flex amount occupied by a span of columns.
+    flexSpan(column1: number, column2: number): number {
+        let flex = 0;
+        for (let i = column1; i <= column2; i++) {
+            let value = this.flexSettings[i];
+            if (this.expand === i) { value = value * 3; }
+            flex += value;
+        }
+        return flex;
+    }
+
+    volCountChanged(orgNode: HoldingsTreeNode, count: number) {
+        if (count === null) { return; }
+        const diff = count - orgNode.children.length;
+        if (diff > 0) {
+            this.createVols(orgNode, diff);
+        } else if (diff < 0) {
+            this.deleteVols(orgNode, -diff);
+        }
+    }
+
+
+    addVol(org: IdlObject) {
+        if (!org) { return; }
+        const orgNode = this.context.findOrCreateOrgNode(org.id());
+        this.createVols(orgNode, 1);
+    }
+
+    existingVolCount(orgNode: HoldingsTreeNode): number {
+        return orgNode.children.filter(volNode => !volNode.target.isnew()).length;
+    }
+
+    existingCopyCount(volNode: HoldingsTreeNode): number {
+        return volNode.children.filter(copyNode => !copyNode.target.isnew()).length;
+    }
+
+    copyCountChanged(volNode: HoldingsTreeNode, count: number) {
+        if (count === null) { return; }
+        const diff = count - volNode.children.length;
+        if (diff > 0) {
+            this.createCopies(volNode, diff);
+        } else if (diff < 0) {
+            this.deleteCopies(volNode, -diff);
+        }
+    }
+
+    // This only removes copies that were created during the
+    // current editing session and have not yet been saved in the DB.
+    deleteCopies(volNode: HoldingsTreeNode, count: number) {
+        for (let i = 0;  i < count; i++) {
+            const copyNode = volNode.children[volNode.children.length - 1];
+            if (copyNode && copyNode.target.isnew()) {
+                volNode.children.pop();
+            } else {
+                break;
+            }
+        }
+    }
+
+    createCopies(volNode: HoldingsTreeNode, count: number) {
+        for (let i = 0; i < count; i++) {
+
+            // Our context assumes copies are fleshed with volumes
+            const vol = volNode.target;
+            const copy = this.volcopy.createStubCopy(vol);
+            copy.call_number(vol);
+            this.context.findOrCreateCopyNode(copy);
+        }
+    }
+
+
+    createVols(orgNode: HoldingsTreeNode, count: number) {
+        const vols = [];
+        for (let i = 0; i < count; i++) {
+
+            // This will vivify the volNode if needed.
+            const vol = this.volcopy.createStubVol(
+                this.context.recordId, orgNode.target.id());
+
+            vols.push(vol);
+
+            // Our context assumes copies are fleshed with volumes
+            const copy = this.volcopy.createStubCopy(vol);
+            copy.call_number(vol);
+            this.context.findOrCreateCopyNode(copy);
+        }
+
+        this.volcopy.setVolClassLabels(vols);
+    }
+
+    // This only removes vols that were created during the
+    // current editing session and have not yet been saved in the DB.
+    deleteVols(orgNode: HoldingsTreeNode, count: number) {
+        for (let i = 0;  i < count; i++) {
+            const volNode = orgNode.children[orgNode.children.length - 1];
+            if (volNode && volNode.target.isnew()) {
+                orgNode.children.pop();
+            } else {
+                break;
+            }
+        }
+    }
+
+    // When editing existing vols, be sure each has at least one copy.
+    addStubCopies(volNode?: HoldingsTreeNode) {
+        const nodes = volNode ? [volNode] : this.context.volNodes();
+
+        nodes.forEach(vNode => {
+            if (vNode.children.length === 0) {
+                const vol = vNode.target;
+                const copy = this.volcopy.createStubCopy(vol);
+                copy.call_number(vol);
+                this.context.findOrCreateCopyNode(copy);
+            }
+        });
+    }
+
+    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);
+        }
+
+        this.emitSaveChange();
+    }
+
+    applyCopyValue(copy: IdlObject, key: string, value: any) {
+        if (copy[key]() !== value) {
+            copy[key](value);
+            copy.ischanged(true);
+        }
+    }
+
+    copyPartChanged(copyNode: HoldingsTreeNode, entry: ComboboxEntry) {
+        const copy = copyNode.target;
+        const part = copyNode.target.parts()[0];
+
+        if (entry) {
+
+            const newPart =
+                this.volcopy.bibParts[copy.call_number().record()]
+                .filter(p => p.id() === entry.id)[0];
+
+            // Nothing to change?
+            if (part && part.id() === newPart.id()) { return; }
+
+            copy.parts([newPart]);
+            copy.ischanged(true);
+
+        } else if (part) { // Part map no longer needed.
+
+            copy.parts([]);
+            copy.ischanged(true);
+        }
+    }
+
+    batchVolApply() {
+        this.context.volNodes().forEach(volNode => {
+            const vol = volNode.target;
+            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) {
+                // Use label; could be freetext.
+                this.applyVolValue(vol, 'label', this.batchVolLabel.label);
+            }
+        });
+    }
+
+    // Focus and select the next editable barcode.
+    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 && this.barcodeCanChange(copy)) {
+                firstId = copy.id();
+            }
+
+            if (found) {
+                if (nextId === null && this.barcodeCanChange(copy)) {
+                    nextId = copy.id();
+                }
+            } else if (copy.id() === id) {
+                found = true;
+            }
+        });
+
+        this.renderer.selectRootElement(
+                '#barcode-input-' + (nextId || firstId)).select();
+    }
+
+    barcodeCanChange(copy: IdlObject): boolean {
+        return !this.volcopy.copyStatIsMagic(copy.status());
+    }
+
+    generateBarcodes() {
+        this.autoBarcodeInProgress = true;
+
+        // Autogen only replaces barcodes for items which are in
+        // certain statuses.
+        const copies = this.context.copyList()
+        .filter((copy, idx) => {
+            // During autogen we do not replace the first item,
+            // so it's status is not relevant.
+            return idx === 0 || this.barcodeCanChange(copy);
+        });
+
+        if (copies.length > 1) { // seed barcode will always be present
+            this.proceedWithAutogen(copies)
+            .then(_ => this.autoBarcodeInProgress = false);
+        }
+    }
+
+    proceedWithAutogen(copyList: IdlObject[]): Promise<any> {
+
+        const seedBarcode: string = copyList[0].barcode();
+        copyList.shift(); // Avoid replacing the seed barcode
+
+        const count = copyList.length;
+
+        return this.net.request('open-ils.cat',
+            'open-ils.cat.item.barcode.autogen',
+            this.auth.token(), seedBarcode, count, {
+                checkdigit: this.useCheckdigit,
+                skip_dupes: true
+            }
+        ).pipe(tap(barcodes => {
+
+            copyList.forEach(copy => {
+                if (copy.barcode() !== barcodes[0]) {
+                    copy.barcode(barcodes[0]);
+                    copy.ischanged(true);
+                }
+                barcodes.shift();
+            });
+
+        })).toPromise();
+    }
+
+    barcodeChanged(copy: IdlObject, barcode: string) {
+        // note: copy.barcode(barcode) applied via ngModel
+        copy.ischanged(true);
+        copy._dupe_barcode = false;
+
+        if (!barcode) {
+            this.emitSaveChange();
+            return;
+        }
+
+        if (!this.autoBarcodeInProgress) {
+            // Manual barcode entry requires dupe check
+
+            copy._dupe_barcode = false;
+            this.pcrud.search('acp', {
+                deleted: 'f',
+                barcode: barcode,
+                id: {'!=': copy.id()}
+            }).subscribe(
+                resp => {
+                    if (resp) { copy._dupe_barcode = true; }
+                },
+                err => {},
+                () => this.emitSaveChange()
+            );
+        }
+    }
+
+    deleteCopy(copyNode: HoldingsTreeNode) {
+
+        if (copyNode.target.isnew()) {
+            // Confirmation not required when deleting brand new copies.
+            this.deleteOneCopy(copyNode);
+            return;
+        }
+
+        this.deleteCopyCount = 1;
+        this.confirmDelCopy.open().toPromise().then(confirmed => {
+            if (confirmed) { this.deleteOneCopy(copyNode); }
+        });
+    }
+
+    deleteOneCopy(copyNode: HoldingsTreeNode) {
+        const targetCopy = copyNode.target;
+
+        const orgNodes = this.context.orgNodes();
+        for (let orgIdx = 0; orgIdx < orgNodes.length; orgIdx++) {
+            const orgNode = orgNodes[orgIdx];
+
+            for (let volIdx = 0; volIdx < orgNode.children.length; volIdx++) {
+                const volNode = orgNode.children[volIdx];
+
+                for (let copyIdx = 0; copyIdx < volNode.children.length; copyIdx++) {
+                    const copy = volNode.children[copyIdx].target;
+
+                    if (copy.id() === targetCopy.id()) {
+                        volNode.children.splice(copyIdx, 1);
+                        if (!copy.isnew()) {
+                            copy.isdeleted(true);
+                            this.context.copiesToDelete.push(copy);
+                        }
+
+                        if (volNode.children.length === 0) {
+                            // When removing the last copy, add a stub copy.
+                            this.addStubCopies();
+                        }
+
+                        return;
+                    }
+                }
+            }
+        }
+    }
+
+
+    deleteVol(volNode: HoldingsTreeNode) {
+
+        if (volNode.target.isnew()) {
+            // Confirmation not required when deleting brand new vols.
+            this.deleteOneVol(volNode);
+            return;
+        }
+
+        this.deleteVolCount = 1;
+        this.deleteCopyCount = volNode.children.length;
+
+        this.confirmDelVol.open().toPromise().then(confirmed => {
+            if (confirmed) { this.deleteOneVol(volNode); }
+        });
+    }
+
+    deleteOneVol(volNode: HoldingsTreeNode) {
+
+        let deleteVolIdx = null;
+        const targetVol = volNode.target;
+
+        // FOR loops allow for early exit
+        const orgNodes = this.context.orgNodes();
+        for (let orgIdx = 0; orgIdx < orgNodes.length; orgIdx++) {
+            const orgNode = orgNodes[orgIdx];
+
+            for (let volIdx = 0; volIdx < orgNode.children.length; volIdx++) {
+                const vol = orgNode.children[volIdx].target;
+
+                if (vol.id() === targetVol.id()) {
+                    deleteVolIdx = volIdx;
+
+                    if (vol.isnew()) {
+                        // New volumes, which can only have new copies
+                        // may simply be removed from the holdings
+                        // tree to delete them.
+                        break;
+                    }
+
+                    // Mark volume and attached copies as deleted
+                    // and track for later deletion.
+                    targetVol.isdeleted(true);
+                    this.context.volsToDelete.push(targetVol);
+
+                    // When deleting vols, no need to delete the linked
+                    // copies.  They'll be force deleted via the API.
+                }
+
+                if (deleteVolIdx !== null) { break; }
+            }
+
+            if (deleteVolIdx !== null) {
+                orgNode.children.splice(deleteVolIdx, 1);
+                break;
+            }
+        }
+    }
+
+    displayColumn(field: string): boolean {
+        return this.volcopy.defaults.hidden[field] !== true;
+    }
+
+    saveUseCheckdigit() {
+        this.volcopy.defaults.values.use_checkdigit = this.useCheckdigit === true;
+        this.volcopy.saveDefaults();
+    }
+
+    canSave(): boolean {
+
+        const copies = this.context.copyList();
+
+        const badCopies = copies.filter(copy => {
+            return copy._dupe_barcode || (!copy.isnew() && !copy.barcode());
+        }).length > 0;
+
+        if (badCopies) { return false; }
+
+        const badVols = this.context.volNodes().filter(volNode => {
+            const vol = volNode.target;
+            return !(
+                vol.prefix() && vol.label() && vol.suffix && vol.label_class()
+            );
+        }).length > 0;
+
+        return !badVols;
+    }
+
+    // Called any time a change occurs that could affect the
+    // save-ability of the form.
+    emitSaveChange() {
+        setTimeout(() => {
+            this.canSaveChange.emit(this.canSave());
+        });
+    }
+}
+
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..be7ff28
--- /dev/null
@@ -0,0 +1,84 @@
+<eg-staff-banner bannerText="Holdings Editor" i18n-bannerText></eg-staff-banner>
+
+<div class="row" *ngIf="sessionExpired">
+  <div class="col-lg-6 mt-4 offset-lg-3 alert alert-danger d-flex justify-content-center" i18n>
+    Holdings Editor Session Expired
+  </div>
+</div>
+
+<ng-container *ngIf="!sessionExpired && !loading">
+
+  <eg-bib-summary *ngIf="context.recordId" [recordId]="context.recordId"></eg-bib-summary>
+
+  <div class="mt-3"> </div>
+
+  <ul ngbNav #holdingsNav="ngbNav" class="nav-tabs"
+    [activeId]="tab" (navChange)="beforeTabChange($event)">
+    <li ngbNavItem="holdings">
+      <a ngbNavLink i18n>Holdings</a>
+      <ng-template ngbNavContent>
+        <div class="mt-2">
+          <eg-vol-edit [context]="context"
+            (canSaveChange)="volsCanSave = $event"></eg-vol-edit>
+        </div>
+        <ng-container *ngIf="volcopy.defaults.values.unified_display">
+          <div class="mt-2">
+            <eg-copy-attrs [context]="context" 
+              (canSaveChange)="attrsCanSave = $event"></eg-copy-attrs>
+          </div>
+        </ng-container>
+      </ng-template>
+    </li>
+    <ng-container *ngIf="!volcopy.defaults.values.unified_display">
+      <li ngbNavItem="attrs">
+        <a ngbNavLink i18n>Item Attributes</a>
+        <ng-template ngbNavContent>
+          <div class="mt-2">
+            <eg-copy-attrs [context]="context"
+              (canSaveChange)="attrsCanSave = $event"></eg-copy-attrs>
+          </div>
+        </ng-template>
+      </li>
+    </ng-container>
+    <li ngbNavItem="config">
+      <a ngbNavLink i18n>Preferences</a>
+      <ng-template ngbNavContent>
+        <div class="mt-2">
+          <eg-volcopy-config [context]="context"></eg-volcopy-config>
+        </div>
+      </ng-template>
+    </li>
+  </ul>
+  <div [ngbNavOutlet]="holdingsNav"></div>
+
+  <ng-container *ngIf="tab === 'holdings' || tab === 'attrs'">
+    <hr class="m-2"/>                                                          
+    <div class="row m-2 p-2 border border-dark rounded bg-faint">
+      <div class="col-lg-12 d-flex">
+        <div class="form-check form-check-inline ml-2">                            
+          <input class="form-check-input" id='use-labels-cbox' type="checkbox"     
+            [(ngModel)]="printLabels" (change)="savePrintLabels()">
+          <label class="form-check-label" for='use-labels-cbox'                    
+            i18n>Print Labels?</label>                                             
+        </div>
+        <div class="flex-1"> </div>
+        <button class="btn btn-outline-dark" (click)="save()" 
+          [ngClass]="{'border-danger': isNotSaveable()}"
+          [disabled]="isNotSaveable()" i18n>Save</button>
+        <button class="btn btn-outline-dark ml-2" (click)="save(true)"
+          [ngClass]="{'border-danger': isNotSaveable()}"
+          [disabled]="isNotSaveable()" i18n>Save &amp; Exit</button>
+      </div>
+    </div>
+  </ng-container>
+
+</ng-container>
+
+<ng-container *ngIf="loading">
+  <div class="row">
+    <div class="col-lg-6 offset-lg-3">
+      <eg-progress-inline></eg-progress-inline>
+    </div>
+  </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..f782eff
--- /dev/null
@@ -0,0 +1,476 @@
+import {Component, OnInit, AfterViewInit, ViewChild, Renderer2} from '@angular/core';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {tap} from 'rxjs/operators';
+import {IdlObject, IdlService} from '@eg/core/idl.service';
+import {EventService} from '@eg/core/event.service';
+import {OrgService} from '@eg/core/org.service';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {HoldingsService, CallNumData} from '@eg/staff/share/holdings/holdings.service';
+import {VolCopyContext} from './volcopy';
+import {ProgressInlineComponent} from '@eg/share/dialog/progress-inline.component';
+import {AnonCacheService} from '@eg/share/util/anon-cache.service';
+import {VolCopyService} from './volcopy.service';
+import {NgbNav, NgbNavChangeEvent} from '@ng-bootstrap/ng-bootstrap';
+
+const COPY_FLESH = {
+    flesh: 1,
+    flesh_fields: {
+        acp: [
+            'call_number', 'location', 'parts', 'tags',
+            'creator', 'editor', 'stat_cat_entries'
+        ],
+        acptcm: ['tag'],
+        acpt: ['tag_type']
+    }
+};
+
+interface EditSession {
+
+    // Unset if editing in multi-record mode
+    record_id: number;
+
+    // list of copy IDs
+    copies: number[];
+
+    // Adding to or creating new call numbers
+    raw: CallNumData[];
+
+    // Hide the volumes editor
+    hide_vols: boolean;
+
+    // Hide the copy attrs editor.
+    hide_copies: boolean;
+}
+
+@Component({
+  templateUrl: 'volcopy.component.html'
+})
+export class VolCopyComponent implements OnInit {
+
+    context: VolCopyContext;
+    loading = true;
+    sessionExpired = false;
+    printLabels = false;
+
+    tab = 'holdings'; // holdings | attrs | config
+    target: string;   // item | callnumber | record | session
+    targetId: string; // id value or session string
+
+    volsCanSave = true;
+    attrsCanSave = true;
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private renderer: Renderer2,
+        private evt: EventService,
+        private idl: IdlService,
+        private org: OrgService,
+        private net: NetService,
+        private auth: AuthService,
+        private pcrud: PcrudService,
+        private cache: AnonCacheService,
+        private holdings: HoldingsService,
+        private volcopy: VolCopyService
+    ) { }
+
+    ngOnInit() {
+        this.route.paramMap.subscribe(
+            (params: ParamMap) => this.negotiateRoute(params));
+    }
+
+    negotiateRoute(params: ParamMap) {
+        this.tab = params.get('tab') || 'holdings';
+        this.target = params.get('target');
+        this.targetId = params.get('target_id');
+
+        if (this.volcopy.currentContext) {
+            // Avoid clobbering the context on route change.
+            this.context = this.volcopy.currentContext;
+        } else {
+            this.context = new VolCopyContext();
+            this.context.org = this.org; // inject;
+        }
+
+        switch (this.target) {
+            case 'item':
+                this.context.copyId = +this.targetId;
+                break;
+            case 'callnumber':
+                this.context.volId = +this.targetId;
+                break;
+            case 'record':
+                this.context.recordId = +this.targetId;
+                break;
+            case 'session':
+                this.context.session = this.targetId;
+                break;
+        }
+
+        if (this.volcopy.currentContext) {
+            this.loading = false;
+
+        } else {
+            // Avoid refetching the data during route changes.
+            this.volcopy.currentContext = this.context;
+            this.load();
+        }
+    }
+
+    load(copyIds?: number[]): Promise<any> {
+        this.sessionExpired = false;
+        this.loading = true;
+        this.context.reset();
+
+        return this.volcopy.load()
+        .then(_ => this.fetchHoldings(copyIds))
+        .then(_ => this.volcopy.applyVolLabels(
+            this.context.volNodes().map(n => n.target)))
+        .then(_ => this.context.sortHoldings())
+        .then(_ => this.context.setRecordId())
+        .then(_ => this.printLabels =
+            this.volcopy.defaults.values.print_labels === true)
+        .then(_ => {
+            // unified display has no 'attrs' tab
+            if (this.volcopy.defaults.values.unified_display
+                && this.tab === 'attrs') {
+                this.tab = 'holdings';
+                this.routeToTab();
+            }
+        })
+        .then(_ => this.loading = false);
+    }
+
+    fetchHoldings(copyIds?: number[]): Promise<any> {
+
+        if (copyIds && copyIds.length > 0) {
+            // Reloading copies that were just edited.
+            return this.fetchCopies(copyIds);
+
+        } else if (this.context.session) {
+            this.context.sessionType = 'mixed';
+            return this.fetchSession(this.context.session);
+
+        } else if (this.context.copyId) {
+            this.context.sessionType = 'copy';
+            return this.fetchCopies(this.context.copyId);
+
+        } else if (this.context.volId) {
+            this.context.sessionType = 'vol';
+            return this.fetchVols(this.context.volId);
+
+        } else if (this.context.recordId) {
+            this.context.sessionType = 'record';
+            return this.fetchRecords(this.context.recordId);
+        }
+    }
+
+    // Changing a tab in the UI means changing the route.
+    // Changing the route ultimately results in changing the tab.
+    beforeTabChange(evt: NgbNavChangeEvent) {
+        evt.preventDefault();
+        this.tab = evt.nextId;
+        this.routeToTab();
+    }
+
+    routeToTab() {
+        const url =
+            `/staff/cat/volcopy/${this.tab}/${this.target}/${this.targetId}`;
+
+        // Retain search parameters
+        this.router.navigate([url], {queryParamsHandling: 'merge'});
+    }
+
+    fetchSession(session: string): Promise<any> {
+
+        return this.cache.getItem(session, 'edit-these-copies')
+        .then((editSession: EditSession) => {
+
+            if (!editSession) {
+                this.loading = false;
+                this.sessionExpired = true;
+                return Promise.reject('Session Expired');
+            }
+
+            console.debug('Edit Session', editSession);
+
+            this.context.recordId = editSession.record_id;
+
+            if (editSession.copies && editSession.copies.length > 0) {
+                return this.fetchCopies(editSession.copies);
+            }
+
+            const volsToFetch = [];
+            const volsToCreate = [];
+            editSession.raw.forEach((volData: CallNumData) => {
+                this.context.fastAdd = volData.fast_add === true;
+
+                if (volData.callnumber > 0) {
+                    volsToFetch.push(volData);
+                } else {
+                    volsToCreate.push(volData);
+                }
+            });
+
+            let promise = Promise.resolve();
+            if (volsToFetch.length > 0) {
+                promise = promise.then(_ =>
+                    this.fetchVolsStubCopies(volsToFetch));
+            }
+
+            if (volsToCreate.length > 0) {
+                promise = promise.then(_ =>
+                    this.createVolsStubCopies(volsToCreate));
+            }
+
+            return promise;
+        });
+    }
+
+    // Creating new vols.  Each gets a stub copy.
+    createVolsStubCopies(volDataList: CallNumData[]): Promise<any> {
+
+        const vols = [];
+        volDataList.forEach(volData => {
+
+            const vol = this.volcopy.createStubVol(
+                this.context.recordId,
+                volData.owner || this.auth.user().ws_ou()
+            );
+
+            if (volData.label) {vol.label(volData.label); }
+
+            volData.callnumber = vol.id(); // wanted by addStubCopies
+            vols.push(vol);
+            this.context.findOrCreateVolNode(vol);
+        });
+
+        return this.addStubCopies(vols, volDataList)
+        .then(_ => this.volcopy.setVolClassLabels(vols));
+    }
+
+    // Fetch vols by ID, but instead of retrieving their copies
+    // add a stub copy to each.
+    fetchVolsStubCopies(volDataList: CallNumData[]): Promise<any> {
+
+        const volIds = volDataList.map(volData => volData.callnumber);
+        const vols = [];
+
+        return this.pcrud.search('acn', {id: volIds})
+        .pipe(tap((vol: IdlObject) => vols.push(vol))).toPromise()
+        .then(_ => this.addStubCopies(vols, volDataList));
+    }
+
+    // Add a stub copy to each vol using data from the edit session.
+    addStubCopies(vols: IdlObject[], volDataList: CallNumData[]): Promise<any> {
+
+        const copies = [];
+        vols.forEach(vol => {
+            const volData = volDataList.filter(
+                vData => vData.callnumber === vol.id())[0];
+
+            const copy =
+                this.volcopy.createStubCopy(vol, {circLib: volData.owner});
+
+            this.context.findOrCreateCopyNode(copy);
+            copies.push(copy);
+        });
+
+        return this.volcopy.setCopyStatus(copies, this.context.fastAdd);
+    }
+
+    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 linked 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)))
+        .toPromise().then(_ => {
+             return this.pcrud.search('acp',
+                {call_number: ids, deleted: 'f'}, COPY_FLESH
+            ).pipe(tap(copy => this.context.findOrCreateCopyNode(copy))
+            ).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', label: {'!=' : '##URI##'}},
+            {}, {idlist: true, atomic: true}
+        ).toPromise().then(volIds => this.fetchVols(volIds));
+    }
+
+
+    save(close?: boolean): Promise<any> {
+        this.loading = true;
+
+        // Volume update API wants volumes fleshed with copies, instead
+        // of the other way around, which is what we have here.
+        const volumes: IdlObject[] = [];
+
+        this.context.volNodes().forEach(volNode => {
+            const newVol = this.idl.clone(volNode.target);
+            const copies: IdlObject[] = [];
+
+            volNode.children.forEach(copyNode => {
+                const copy = copyNode.target;
+
+                if (copy.isnew() && !copy.barcode()) {
+                    // A new copy w/ no barcode is a stub copy sitting
+                    // on an empty call number.  Ignore it.
+                    return;
+                }
+
+                if (copy.ischanged() || copy.isnew() || copy.isdeleted()) {
+                    const copyClone = this.idl.clone(copy);
+                    // De-flesh call number
+                    copyClone.call_number(copy.call_number().id());
+                    copies.push(copyClone);
+                }
+            });
+
+           newVol.copies(copies);
+
+            if (newVol.ischanged() || newVol.isnew() || copies.length > 0) {
+                volumes.push(newVol);
+            }
+        });
+
+        this.context.volsToDelete.forEach(vol => {
+            const cloneVol = this.idl.clone(vol);
+            // No need to flesh copies -- they'll be force deleted.
+            cloneVol.copies([]);
+            volumes.push(cloneVol);
+        });
+
+        this.context.copiesToDelete.forEach(copy => {
+            const cloneCopy = this.idl.clone(copy);
+            const copyVol = cloneCopy.call_number();
+            cloneCopy.call_number(copyVol.id()); // de-flesh
+
+            let vol = volumes.filter(v => v.id() === copyVol.id())[0];
+
+            if (vol) {
+                vol.copies().push(cloneCopy);
+            } else {
+                vol = this.idl.clone(copyVol);
+                vol.copies([cloneCopy]);
+            }
+
+            volumes.push(vol);
+        });
+
+        // De-flesh before posting
+        volumes.forEach(vol => {
+            vol.copies().forEach(copy => {
+                ['editor', 'creator', 'location'].forEach(field => {
+                    if (typeof copy[field]() === 'object') {
+                        copy[field](copy[field]().id());
+                    }
+                });
+            });
+        });
+
+        let promise: Promise<number[]> = Promise.resolve([]);
+
+        if (volumes.length > 0) {
+            promise = this.saveApi(volumes, false, close);
+        }
+
+        return promise.then(copyIds => {
+
+            // In addition to the copies edited in this update call,
+            // reload any other copies that were previously loaded.
+            const ids: any = {}; // dedupe
+            this.context.copyList()
+                .map(c => c.id())
+                .filter(id => id > 0) // scrub the new copy IDs
+                .concat(copyIds)
+                .forEach(id => ids[id] = true);
+
+            copyIds = Object.keys(ids).map(id => Number(id));
+
+            if (close) {
+                return this.openPrintLabels(copyIds)
+                    .then(_ => setTimeout(() => window.close()));
+            }
+
+            return this.load(Object.keys(ids).map(id => Number(id)));
+
+        }).then(_ => this.loading = false);
+    }
+
+    saveApi(volumes: IdlObject[], override?:
+        boolean, close?: boolean): Promise<number[]> {
+
+        let method = 'open-ils.cat.asset.volume.fleshed.batch.update';
+        if (override) { method += '.override'; }
+
+        return this.net.request('open-ils.cat',
+            method, this.auth.token(), volumes, true,
+            {   auto_merge_vols: true,
+                create_parts: true,
+                return_copy_ids: true,
+                force_delete_copies: true
+            }
+
+        ).toPromise().then(copyIds => {
+
+            const evt = this.evt.parse(copyIds);
+
+            if (evt) {
+                // TODO: handle overrides?
+                // return this.saveApi(volumes, true, close);
+                this.loading = false;
+                alert(evt);
+                return Promise.reject();
+            }
+
+            return copyIds;
+        });
+    }
+
+    savePrintLabels() {
+        this.volcopy.defaults.values.print_labels = this.printLabels === true;
+        this.volcopy.saveDefaults();
+    }
+
+    openPrintLabels(copyIds?: number[]): Promise<any> {
+        if (!this.printLabels) { return Promise.resolve(); }
+
+        if (!copyIds || copyIds.length === 0) {
+            copyIds = this.context.copyList()
+                .map(c => c.id()).filter(id => id > 0);
+        }
+
+        return this.net.request(
+            'open-ils.actor',
+            'open-ils.actor.anon_cache.set_value',
+            null, 'print-labels-these-copies', {copies : copyIds}
+
+        ).toPromise().then(key => {
+
+            const url = '/eg/staff/cat/printlabels/' + key;
+            setTimeout(() => window.open(url, '_blank'));
+        });
+    }
+
+    isNotSaveable(): boolean {
+        return !(this.volsCanSave && this.attrsCanSave);
+    }
+}
+
+
+
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..5c4289d
--- /dev/null
@@ -0,0 +1,33 @@
+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';
+import {VolCopyService} from './volcopy.service';
+import {CopyAttrsComponent} from './copy-attrs.component';
+import {ItemLocationSelectModule} from '@eg/share/item-location-select/item-location-select.module';
+import {VolCopyConfigComponent} from './config.component';
+
+@NgModule({
+  declarations: [
+    VolCopyComponent,
+    VolEditComponent,
+    CopyAttrsComponent,
+    VolCopyConfigComponent
+  ],
+  imports: [
+    StaffCommonModule,
+    CommonWidgetsModule,
+    HoldingsModule,
+    VolCopyRoutingModule,
+    ItemLocationSelectModule
+  ],
+  providers: [
+    VolCopyService
+  ]
+})
+
+export class VolCopyModule {
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.service.ts b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.service.ts
new file mode 100644 (file)
index 0000000..891a46c
--- /dev/null
@@ -0,0 +1,434 @@
+import {Injectable} from '@angular/core';
+import {Observable} from 'rxjs';
+import {map, tap, mergeMap} from 'rxjs/operators';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {OrgService} from '@eg/core/org.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {EventService, EgEvent} from '@eg/core/event.service';
+import {AuthService} from '@eg/core/auth.service';
+import {VolCopyContext} from './volcopy';
+import {HoldingsService, CallNumData} from '@eg/staff/share/holdings/holdings.service';
+import {ServerStoreService} from '@eg/core/server-store.service';
+import {StoreService} from '@eg/core/store.service';
+import {ComboboxComponent, ComboboxEntry} from '@eg/share/combobox/combobox.component';
+
+/* Managing volcopy data */
+
+interface VolCopyDefaults {
+    values: {[field: string]: any};
+    hidden: {[field: string]: boolean};
+}
+
+@Injectable()
+export class VolCopyService {
+
+    autoId = -1;
+
+    localOrgs: number[];
+    defaults: VolCopyDefaults = null;
+    copyStatuses: {[id: number]: IdlObject} = {};
+    bibParts: {[bibId: number]: IdlObject[]} = {};
+
+    // This will be all 'local' copy locations plus any remote
+    // locations that we are required to interact with.
+    copyLocationMap: {[id: number]: IdlObject} = {};
+
+    // Track this here so it can survive route changes.
+    currentContext: VolCopyContext;
+
+    statCatEntryMap: {[id: number]: IdlObject} = {}; // entry id => entry
+
+    templateNames: ComboboxEntry[] = [];
+    templates: any = {};
+
+    commonData: {[key: string]: IdlObject[]} = {};
+    magicCopyStats: number[] = [];
+
+    hideVolOrgs: number[] = [];
+
+    constructor(
+        private evt: EventService,
+        private net: NetService,
+        private idl: IdlService,
+        private org: OrgService,
+        private auth: AuthService,
+        private pcrud: PcrudService,
+        private holdings: HoldingsService,
+        private store: StoreService,
+        private serverStore: ServerStoreService
+    ) {}
+
+
+    // Fetch the data that is always needed.
+    load(): Promise<any> {
+
+        if (this.commonData.acp_item_type_map) { return Promise.resolve(); }
+
+        this.localOrgs = this.org.fullPath(this.auth.user().ws_ou(), true);
+
+        this.hideVolOrgs = this.org.list()
+            .filter(o => !this.org.canHaveVolumes(o)).map(o => o.id());
+
+        return this.net.request(
+            'open-ils.cat', 'open-ils.cat.volcopy.data', this.auth.token()
+        ).pipe(tap(dataset => {
+            const key = Object.keys(dataset)[0];
+            this.commonData[key] = dataset[key];
+        })).toPromise()
+        .then(_ => this.ingestCommonData())
+
+        // These will come up later -- prefetch.
+        .then(_ => this.serverStore.getItemBatch([
+            'cat.copy.templates',
+            'eg.cat.volcopy.defaults',
+            'eg.cat.record.summary.collapse'
+        ]))
+
+        .then(_ => this.holdings.getMagicCopyStatuses())
+        .then(stats => this.magicCopyStats = stats)
+        .then(_ => this.fetchDefaults())
+        .then(_ => this.fetchTemplates());
+    }
+
+    ingestCommonData() {
+
+        this.commonData.acp_location.forEach(
+            loc => this.copyLocationMap[loc.id()] = loc);
+
+        // Remove the -1 prefix and suffix so they can be treated
+        // specially in the markup.
+        this.commonData.acn_prefix =
+            this.commonData.acn_prefix.filter(pfx => pfx.id() !== -1);
+
+        this.commonData.acn_suffix =
+            this.commonData.acn_suffix.filter(sfx => sfx.id() !== -1);
+
+        this.commonData.acp_status.forEach(
+            stat => this.copyStatuses[stat.id()] = stat);
+
+        this.commonData.acp_stat_cat.forEach(cat => {
+            cat.entries().forEach(
+                entry => this.statCatEntryMap[entry.id()] = entry);
+        });
+    }
+
+    getLocation(id: number): Promise<IdlObject> {
+        if (this.copyLocationMap[id]) {
+            return Promise.resolve(this.copyLocationMap[id]);
+        }
+
+        return this.pcrud.retrieve('acpl', id)
+            .pipe(tap(loc => this.copyLocationMap[loc.id()] = loc))
+            .toPromise();
+    }
+
+    fetchTemplates(): Promise<any> {
+
+        // First check for local copy templates, since server-side
+        // templates are new w/ this code.  Move them to the server.
+        const tmpls = this.store.getLocalItem('cat.copy.templates');
+
+        const promise = tmpls ?
+            this.serverStore.setItem('cat.copy.templates', tmpls) :
+            Promise.resolve();
+
+        return promise
+        .then(_ => this.serverStore.getItem('cat.copy.templates'))
+        .then(templates => {
+
+            if (!templates) { return null; }
+
+            this.templates = templates;
+
+            this.templateNames = Object.keys(templates)
+            .sort((n1, n2) => n1 < n2 ? -1 : 1)
+            .map(name => ({id: name, label: name}));
+
+            this.store.removeLocalItem('cat.copy.templates');
+        });
+    }
+
+
+    saveTemplates(): Promise<any> {
+        this.store.setLocalItem('cat.copy.templates', this.templates);
+        // Re-sort, etc.
+        return this.fetchTemplates();
+    }
+
+    fetchDefaults(): Promise<any> {
+        if (this.defaults) { return Promise.resolve(); }
+
+        return this.serverStore.getItem('eg.cat.volcopy.defaults').then(
+            (defaults: VolCopyDefaults) => {
+                this.defaults = defaults || {values: {}, hidden: {}};
+            }
+        );
+    }
+
+    // Fetch vol labels for a single record based on the defeault
+    // classification scheme
+    fetchRecordVolLabels(id: number): Promise<string[]> {
+        if (!id) { return Promise.resolve([]); }
+
+        // NOTE: see https://bugs.launchpad.net/evergreen/+bug/1874897
+        // for more on MARC call numbers and classification scheme.
+        // If there is no workstation-default value, pass null
+        // to use the org unit default.
+
+        return this.net.request(
+            'open-ils.cat',
+            'open-ils.cat.biblio.record.marc_cn.retrieve',
+            id, this.defaults.values.classification || null
+        ).toPromise().then(res => {
+            return Object.values(res)
+                .map(blob => Object.values(blob)[0]).sort();
+        });
+    }
+
+    createStubVol(recordId: number, orgId: number, options?: any): IdlObject {
+        if (!options) { options = {}; }
+
+        const vol = this.idl.create('acn');
+        vol.id(this.autoId--);
+        vol.isnew(true);
+        vol.record(recordId);
+        vol.label(null);
+        vol.owning_lib(Number(orgId));
+        vol.prefix(this.defaults.values.prefix || -1);
+        vol.suffix(this.defaults.values.suffix || -1);
+
+        return vol;
+    }
+
+    createStubCopy(vol: IdlObject, options?: any): IdlObject {
+        if (!options) { options = {}; }
+
+        const copy = this.idl.create('acp');
+        copy.id(this.autoId--);
+        copy.isnew(true);
+        copy.call_number(vol); // fleshed
+        copy.price('0.00');
+        copy.deposit_amount('0.00');
+        copy.fine_level(2);     // Normal
+        copy.loan_duration(2);  // Normal
+        copy.location(this.commonData.acp_default_location); // fleshed
+        copy.circ_lib(Number(options.circLib || vol.owning_lib()));
+
+        copy.deposit('f');
+        copy.circulate('t');
+        copy.holdable('t');
+        copy.opac_visible('t');
+        copy.ref('f');
+        copy.mint_condition('t');
+
+        copy.parts([]);
+        copy.tags([]);
+        copy.stat_cat_entries([]);
+
+        return copy;
+    }
+
+
+    // Applies label_class values to a batch of volumes, followed by
+    // applying labels to vols that need it.
+    setVolClassLabels(vols: IdlObject[]): Promise<any> {
+
+        return this.applyVolClasses(vols)
+        .then(_ => this.applyVolLabels(vols));
+    }
+
+    // Apply label_class values to any vols that need it based either on
+    // the workstation default value or the org setting for the
+    // owning lib library.
+    applyVolClasses(vols: IdlObject[]): Promise<any> {
+
+        vols = vols.filter(v => !v.label_class());
+
+        const orgIds: any = {};
+        vols.forEach(vol => orgIds[vol.owning_lib()] = true);
+
+        let promise = Promise.resolve(); // Serialization
+
+        if (this.defaults.values.classification) {
+            // Workstation default classification overrides the
+            // classification that might be used at the owning lib.
+
+            vols.forEach(vol =>
+                vol.label_class(this.defaults.values.classification));
+
+            return promise;
+
+        } else {
+
+            // Get the label class default for each owning lib and
+            // apply to the volumes owned by that lib.
+
+            Object.keys(orgIds).map(orgId => Number(orgId))
+            .forEach(orgId => {
+                promise = promise.then(_ => {
+
+                    return this.org.settings(
+                        'cat.default_classification_scheme', orgId)
+                    .then(sets => {
+
+                        const orgVols = vols.filter(v => v.owning_lib() === orgId);
+                        orgVols.forEach(vol => {
+                            vol.label_class(
+                                sets['cat.default_classification_scheme'] || 1
+                            );
+                        });
+                    });
+                });
+            });
+        }
+
+        return promise;
+    }
+
+    // Apply labels to volumes based on the appropriate MARC call number.
+    applyVolLabels(vols: IdlObject[]): Promise<any> {
+
+        vols = vols.filter(v => !v.label());
+
+        // Serialize
+        let promise = Promise.resolve();
+
+        vols.forEach(vol => {
+
+            // Avoid unnecessary lookups.
+            // Note the label may have been applied to this volume
+            // in a previous iteration of this loop.
+            if (vol.label()) { return; }
+
+            promise = promise.then(_ => {
+                return this.net.request(
+                    'open-ils.cat',
+                    'open-ils.cat.biblio.record.marc_cn.retrieve',
+                    vol.record(), vol.label_class()).toPromise()
+
+                .then(cnList => {
+                    // Use '_' as a placeholder to indicate when a
+                    // vol has already been addressed.
+                    let label = '_';
+
+                    if (cnList.length > 0) {
+                        const field = Object.keys(cnList[0])[0];
+                        label = cnList[0][field];
+                    }
+
+                    // Avoid making duplicate marc_cn calls by applying
+                    // the label to all vols that apply.
+                    vols.forEach(vol2 => {
+                        if (vol2.record() === vol.record() &&
+                            vol2.label_class() === vol.label_class()) {
+                            vol.label(label);
+                        }
+                    });
+                });
+            });
+        });
+
+        return promise.then(_ => {
+            // Remove the placeholder label
+            vols.forEach(vol => {
+                if (vol.label() === '_') { vol.label(''); }
+            });
+        });
+    }
+
+    // Sets the default copy status for a batch of copies.
+    setCopyStatus(copies: IdlObject[], fastAdd: boolean): Promise<any> {
+
+        const setting = fastAdd ?
+            'cat.default_copy_status_fast' :
+            'cat.default_copy_status_normal';
+
+        let promise = Promise.resolve(); // Seralize
+
+        copies.forEach(copy => {
+
+            // Avoid unnecessary lookups.  Copy may have been modified
+            // during a previous iteration of this loop.
+            if (!isNaN(copy.status())) { return; }
+
+            promise = promise.then(_ =>
+                this.org.settings(setting, copy.circ_lib())
+
+            ).then(sets => {
+
+                // 0 == Available; 5 == In Process
+                const stat = sets[setting] || (fastAdd ? 0 : 5);
+
+                copies.forEach(copy2 => {
+                    if (copy2.circ_lib() === copy.circ_lib()) {
+                        copy2.status(stat);
+                    }
+                });
+            });
+        });
+
+        return promise;
+    }
+
+
+    saveDefaults(): Promise<any> {
+
+        // Scrub unnecessary content before storing.
+
+        Object.keys(this.defaults.values).forEach(field => {
+            if (this.defaults.values[field] === null) {
+                delete this.defaults.values[field];
+            }
+        });
+
+        Object.keys(this.defaults.hidden).forEach(field => {
+            if (this.defaults.hidden[field] !== true) {
+                delete this.defaults.hidden[field];
+            }
+        });
+
+        return this.serverStore.setItem(
+            'eg.cat.volcopy.defaults', this.defaults);
+    }
+
+    fetchBibParts(recordIds: number[]) {
+
+        if (recordIds.length === 0) { return; }
+
+        // Avoid doubling up
+        if (this.bibParts[recordIds[0]]) { return; }
+
+        this.pcrud.search('bmp',
+            {record: recordIds, deleted: 'f'})
+        .subscribe(
+            part => {
+                if (!this.bibParts[part.record()]) {
+                    this.bibParts[part.record()] = [];
+                }
+                this.bibParts[part.record()].push(part);
+            },
+            err => {},
+            () => {
+                recordIds.forEach(bibId => {
+                    if (this.bibParts[bibId]) {
+                        this.bibParts[bibId] = this.bibParts[bibId]
+                        .sort((p1, p2) =>
+                            p1.label_sortkey() < p2.label_sortkey() ? -1 : 1);
+                    }
+                });
+            }
+        );
+    }
+
+
+    copyStatIsMagic(statId: number): boolean {
+        return this.magicCopyStats.includes(statId);
+    }
+
+    restrictCopyDelete(statId: number): boolean {
+        return this.copyStatuses[statId] &&
+               this.copyStatuses[statId].restrict_copy_delete() === 't';
+    }
+}
+
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..c76e786
--- /dev/null
@@ -0,0 +1,207 @@
+import {IdlObject} from '@eg/core/idl.service';
+import {OrgService} from '@eg/core/org.service';
+
+/* Models the holdings tree and manages related data shared
+ * volcopy across components. */
+
+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 {
+
+    holdings: HoldingsTree = new HoldingsTree();
+    org: OrgService; // injected
+
+    sessionType: 'copy' | 'vol' | 'record' | 'mixed';
+
+    // Edit content comes from a cached session
+    session: string;
+
+    // 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;
+
+    fastAdd: boolean;
+
+    volsToDelete: IdlObject[] = [];
+    copiesToDelete: IdlObject[] = [];
+
+    reset() {
+        this.holdings = new HoldingsTree();
+        this.volsToDelete = [];
+        this.copiesToDelete = [];
+    }
+
+    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.volNodes().forEach(volNode =>
+            idHash[volNode.target.record()] = true);
+
+        return Object.keys(idHash).map(id => Number(id));
+    }
+
+    // When working on exactly one record, set our recordId value.
+    setRecordId() {
+        if (!this.recordId) {
+            const ids = this.getRecordIds();
+            if (ids.length === 1) {
+                this.recordId = ids[0];
+            }
+        }
+    }
+
+    // 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;
+    }
+
+    removeVolNode(volId: number) {
+        this.orgNodes().forEach(orgNode => {
+            for (let idx = 0; idx < orgNode.children.length; idx++) {
+                if (orgNode.children[idx].target.id() === volId) {
+                    orgNode.children.splice(idx, 1);
+                    break;
+                }
+            }
+        });
+    }
+
+    removeCopyNode(copyId: number) {
+        this.volNodes().forEach(volNode => {
+            for (let idx = 0; idx < volNode.children.length; idx++) {
+                if (volNode.children[idx].target.id() === copyId) {
+                    volNode.children.splice(idx, 1);
+                    break;
+                }
+            }
+        });
+    }
+
+    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);
+    }
+
+    changesPending(): boolean {
+        const modified = (o: IdlObject): boolean => {
+            return o.isnew() || o.ischanged() || o.isdeleted();
+        };
+
+        if (this.volNodes().filter(n => modified(n.target)).length > 0) {
+            return true;
+        }
+
+        if (this.copyList().filter(c => modified(c)).length > 0) {
+            return true;
+        }
+
+        return false;
+    }
+}
index 5f3ec56..48bece5 100644 (file)
@@ -47,6 +47,7 @@
 <eg-mark-damaged-dialog #markDamagedDialog></eg-mark-damaged-dialog>
 <eg-mark-missing-dialog #markMissingDialog></eg-mark-missing-dialog>
 <eg-copy-alerts-dialog #copyAlertsDialog></eg-copy-alerts-dialog>
+<eg-copy-tags-dialog #copyTagsDialog></eg-copy-tags-dialog>
 <eg-replace-barcode-dialog #replaceBarcode></eg-replace-barcode-dialog>
 <eg-delete-holding-dialog #deleteHolding></eg-delete-holding-dialog>
 <eg-bucket-dialog #bucketDialog></eg-bucket-dialog>
     </eg-grid-toolbar-action>
 
     <eg-grid-toolbar-action
+      i18n-group group="Add" i18n-label label="Add/Manage Item Tags"
+      (onClick)="openItemTags($event)">
+    </eg-grid-toolbar-action>
+
+    <eg-grid-toolbar-action
       i18n-group group="Add" i18n-label label="Add Items To Bucket"
       (onClick)="openBucketDialog($event)">
     </eg-grid-toolbar-action>
index 2a58ef6..8ba0ccb 100644 (file)
@@ -23,6 +23,8 @@ import {AnonCacheService} from '@eg/share/util/anon-cache.service';
 import {HoldingsService} from '@eg/staff/share/holdings/holdings.service';
 import {CopyAlertsDialogComponent
     } from '@eg/staff/share/holdings/copy-alerts-dialog.component';
+import {CopyTagsDialogComponent
+    } from '@eg/staff/share/holdings/copy-tags-dialog.component';
 import {ReplaceBarcodeDialogComponent
     } from '@eg/staff/share/holdings/replace-barcode-dialog.component';
 import {DeleteHoldingDialogComponent
@@ -106,6 +108,8 @@ export class HoldingsMaintenanceComponent implements OnInit {
         private markMissingDialog: MarkMissingDialogComponent;
     @ViewChild('copyAlertsDialog', { static: true })
         private copyAlertsDialog: CopyAlertsDialogComponent;
+    @ViewChild('copyTagsDialog', {static: false})
+        private copyTagsDialog: CopyTagsDialogComponent;
     @ViewChild('replaceBarcode', { static: true })
         private replaceBarcode: ReplaceBarcodeDialogComponent;
     @ViewChild('deleteHolding', { static: true })
@@ -863,6 +867,20 @@ export class HoldingsMaintenanceComponent implements OnInit {
         );
     }
 
+    openItemTags(rows: HoldingsEntry[]) {
+        const copyIds = this.selectedCopyIds(rows);
+        if (copyIds.length === 0) { return; }
+
+        this.copyTagsDialog.copyIds = copyIds;
+        this.copyTagsDialog.open({size: 'lg'}).subscribe(
+            modified => {
+                if (modified) {
+                    this.hardRefresh();
+                }
+            }
+        );
+    }
+
     openReplaceBarcodeDialog(rows: HoldingsEntry[]) {
         const ids = this.selectedCopyIds(rows);
         if (ids.length === 0) { return; }
index feb159d..7ddef79 100644 (file)
@@ -19,6 +19,7 @@ import {NotBeforeMomentValidatorDirective} from '@eg/share/validators/not_before
 import {PatronBarcodeValidatorDirective} from '@eg/share/validators/patron_barcode_validator.directive';
 import {BroadcastService} from '@eg/share/util/broadcast.service';
 import {CourseService} from './share/course.service';
+import {FileExportService} from '@eg/share/util/file-export.service';
 
 /**
  * Imports the EG common modules and adds modules common to all staff UI's.
@@ -73,7 +74,8 @@ export class StaffCommonModule {
                 AccessKeyService,
                 AudioService,
                 BroadcastService,
-                CourseService
+                CourseService,
+                FileExportService
             ]
         };
     }
index 874e769..396b431 100644 (file)
@@ -258,6 +258,8 @@ export class SandboxComponent implements OnInit {
             const query: any = new Array();
             query.push(base);
 
+            console.log(JSON.stringify(this.eventsDataSource.filters));
+
             Object.keys(this.eventsDataSource.filters).forEach(key => {
                 Object.keys(this.eventsDataSource.filters[key]).forEach(key2 => {
                     query.push(this.eventsDataSource.filters[key][key2]);
index a325481..171c696 100644 (file)
@@ -51,7 +51,8 @@
             <div class="d-flex">
               <div class="flex-1 font-weight-bold" i18n>Title:</div>
               <div class="flex-3">
-                <eg-bib-display-field [summary]="summary" field="title">
+                <eg-bib-display-field [summary]="summary" field="title"
+                  routerLink="/staff/catalog/record/{{summary.id}}">
                 </eg-bib-display-field>
               </div>
               <div class="flex-1 font-weight-bold pl-1" i18n>Edition:</div>
index 3a09fd8..4c0564d 100644 (file)
@@ -50,35 +50,39 @@ export class BibSummaryComponent implements OnInit {
 
     ngOnInit() {
 
-        if (this.summary) {
-            this.summary.getBibCallNumber();
-            this.loadCourseInformation(this.summary.record.id());
-        } 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.loadCourseInformation(this.summary.record.id())
+                .then(_ => 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.loadCourseInformation(this.recordId);
-        this.bib.getBibSummary(this.recordId).toPromise()
-        .then(summary => {
-            summary.getBibCallNumber();
-            this.summary = summary;
+    loadSummary(): Promise<any> {
+        return this.loadCourseInformation(this.recordId)
+        .then(_ => {
+            return this.bib.getBibSummary(this.recordId).toPromise()
+            .then(summary => {
+                this.summary = summary;
+                return summary.getBibCallNumber();
+            });
         });
     }
 
-    loadCourseInformation(recordId) {
-        this.org.settings('circ.course_materials_opt_in').then(setting => {
+    loadCourseInformation(recordId): Promise<any> {
+        return this.org.settings('circ.course_materials_opt_in')
+        .then(setting => {
             if (setting['circ.course_materials_opt_in']) {
                 this.course.fetchCoursesForRecord(recordId).then(courseList => {
                     if (courseList) {
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/batch-item-attr.component.html b/Open-ILS/src/eg2/src/app/staff/share/holdings/batch-item-attr.component.html
new file mode 100644 (file)
index 0000000..64c985f
--- /dev/null
@@ -0,0 +1,68 @@
+
+<div class="border rounded m-1">
+  <div class="font-weight-bold header p-2 d-flex" i18n>
+    {{label}} <span *ngIf="hasChanged" class="text-danger">*</span>
+    <ng-container *ngIf="bulky()">
+      <div class="flex-1"></div>
+      <a href='javascript:;' (click)="expanded = true" *ngIf="!expanded">
+        <span class="material-icons">unfold_more</span>
+      </a>
+      <a href='javascript:;' (click)="expanded = false" *ngIf="expanded">
+        <span class="material-icons">unfold_less</span>
+      </a>
+    </ng-container>
+  </div>
+  <div tabindex="0" class="p-2" *ngIf="!editing || multiValue()"
+    (click)="enterEditMode()" (keyup.enter)="enterEditMode()"
+    [ngClass]="{'has-changes': hasChanged, 'bg-warning': warnOnRequired()}">
+    <div class="d-flex" 
+      *ngFor="let count of labelCounts | keyvalue; let idx = index">
+      <ng-container *ngIf="!expanded && !editing && idx === defaultDisplayCount">
+        <span class="text-info" i18n>...</span>
+      </ng-container>
+      <ng-container *ngIf="expanded || editing || idx < defaultDisplayCount">
+        <ng-container *ngIf="editing">
+          <div class="ml-4 mr-2">
+            <input type="checkbox" class="form-check-input" 
+              [(ngModel)]="editValues[count.key]"/>
+          </div>
+        </ng-container>
+        <div class="flex-1">
+          <ng-container *ngIf="displayAs == 'bool'">
+            <ng-container *ngIf="valueIsUnset(count.key); else defaultBool">
+              <span i18n>&lt;Unset&gt;</span>
+            </ng-container>
+            <ng-template #defaultBool>
+              <span *ngIf="count.key == 't'" i18n>Yes</span>
+              <span *ngIf="count.key == 'f'" i18n>No</span>
+            </ng-template>
+          </ng-container>
+          <ng-container *ngIf="displayAs == 'currency'">
+            <ng-container 
+              *ngIf="valueIsUnset(count.key); else defaultCurrency">
+              <span i18n>&lt;Unset&gt;</span>
+            </ng-container>
+            <ng-template #defaultCurrency>{{count.key | currency}}</ng-template>
+          </ng-container>
+          <ng-container *ngIf="displayAs != 'bool' && displayAs != 'currency'">
+            <ng-container 
+              *ngIf="valueIsUnset(count.key); else default">
+              <span i18n>&lt;Unset&gt;</span>
+            </ng-container>
+            <ng-template #default>{{count.key}}</ng-template>
+          </ng-container>
+        </div>
+        <div i18n>{{count.value}} copies</div>
+      </ng-container>
+    </div>
+  </div>
+  <ng-container *ngIf="editing">
+    <ng-container *ngTemplateOutlet="editTemplate"></ng-container>
+    <div class="mt-1">
+      <button class="btn btn-outline-dark" (click)="save()" i18n>Apply</button>
+      <button class="btn btn-outline-dark ml-1" (click)="cancel()" i18n>Cancel</button>
+      <button class="btn btn-outline-dark ml-1" (click)="clear()" i18n>Clear</button>
+    </div>
+  </ng-container>
+</div>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/batch-item-attr.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/batch-item-attr.component.ts
new file mode 100644 (file)
index 0000000..7a33d0d
--- /dev/null
@@ -0,0 +1,144 @@
+import {Component, OnInit, Input, Output, ViewChild, TemplateRef,
+    EventEmitter} from '@angular/core';
+import {StringComponent} from '@eg/share/string/string.component';
+
+/**
+ * Displays attribute values and associated copy counts for managing
+ * updates to batches of items.
+ */
+
+
+// Map of display value to boolean indicating whether a given item
+// should be modified.
+export interface BatchChangeSelection {
+    [value: string]: boolean;
+}
+
+@Component({
+  selector: 'eg-batch-item-attr',
+  templateUrl: 'batch-item-attr.component.html',
+  styles: [
+    `.header { background-color: #d9edf7; }`,
+    `.has-changes { background-color: #dff0d8; }`
+  ]
+})
+
+export class BatchItemAttrComponent {
+
+    // Main display label, e.g. "Circulation Modifier"
+    @Input() label: string;
+
+    // Optional.  Useful for exracting information (i.e. hasChanges)
+    // on a specific field from a set of batch attr components.
+    @Input() name: string;
+
+    // Maps display labels to the number of items that have the label.
+    // e.g. {"Stacks": 4, "Display": 12}
+    @Input() labelCounts: {[label: string]: number} = {};
+
+    // Ref to some type of edit widget for modifying the value.
+    // Note this component simply displays the template, it does not
+    // interact with the template in any way.
+    @Input() editTemplate: TemplateRef<any>;
+
+    @Input() editInputDomId = '';
+
+    // In some cases, we can map display labels to something more
+    // human friendly.
+    @Input() displayAs: 'bool' | 'currency' = null;
+
+    // Display only
+    @Input() readOnly = false;
+
+    // Warn the user when a required field has an empty value
+    @Input() valueRequired = false;
+
+    // If true, a value of '' is considered unset for display and
+    // valueRequired purposes.
+    @Input() emptyStringIsUnset = true;
+
+    // Lists larger than this will be partially hidden behind
+    // and expandy.
+    @Input() defaultDisplayCount = 7;
+
+    @Output() changesSaved: EventEmitter<BatchChangeSelection> =
+        new EventEmitter<BatchChangeSelection>();
+
+    @Output() changesCanceled: EventEmitter<void> = new EventEmitter<void>();
+    @Output() valueCleared: EventEmitter<void> = new EventEmitter<void>();
+
+    // Is the editTtemplate visible?
+    editing = false;
+
+    hasChanged = false;
+
+    // Showing all entries?
+    expanded = false;
+
+    // Indicate which display values the user wants to modify.
+    editValues: BatchChangeSelection = {};
+
+    constructor() {}
+
+    save() {
+        this.hasChanged = true;
+        this.editing = false;
+        this.changesSaved.emit(this.editValues);
+    }
+
+    cancel() {
+        this.editing = false;
+        this.changesCanceled.emit();
+    }
+
+    clear() {
+        this.hasChanged = true;
+        this.editing = false;
+        this.valueCleared.emit();
+    }
+
+    bulky(): boolean {
+        return Object.keys(this.labelCounts).length > this.defaultDisplayCount;
+    }
+
+    multiValue(): boolean {
+        return Object.keys(this.labelCounts).length > 1;
+    }
+
+    // True if a value is required and any value exists that's unset.
+    warnOnRequired(): boolean {
+        if (!this.valueRequired) { return false; }
+
+        return Object.keys(this.labelCounts)
+            .filter(key => this.valueIsUnset(key)).length > 0;
+    }
+
+    valueIsUnset(value: any): boolean {
+        return (
+            value === null ||
+            value === undefined ||
+            (this.emptyStringIsUnset && value === '')
+        );
+    }
+
+    enterEditMode() {
+        if (this.readOnly || this.editing) { return; }
+        this.editing = true;
+
+        // Assume all values should be edited by default
+        Object.keys(this.labelCounts).forEach(
+            key => this.editValues[key] = true);
+
+        if (this.editInputDomId) {
+            setTimeout(() => {
+                // Avoid using selectRootElement to focus.
+                // https://stackoverflow.com/a/36059595
+                const node = document.getElementById(this.editInputDomId);
+                if (node) { node.focus(); }
+            });
+        }
+    }
+}
+
+
+
index 25fe919..5ccf7ff 100644 (file)
@@ -5,7 +5,7 @@
   <div class="modal-header">
     <h4 class="modal-title">
       <ng-container *ngIf="mode == 'create'">
-        <span i18n>Adding alerts for {{copies.length}} item(s).</span>
+        <span i18n>Adding alerts for {{copyIds.length}} item(s).</span>
       </ng-container>
       <ng-container *ngIf="mode == 'manage'">
         <span i18n>Managing alerts for item {{copies[0].barcode()}}</span>
index 439c546..0a55f79 100644 (file)
@@ -25,26 +25,22 @@ import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
 export class CopyAlertsDialogComponent
     extends DialogComponent implements OnInit {
 
-    _copyIds: number[];
-    @Input() set copyIds(ids: number[]) {
-        this._copyIds = [].concat(ids);
-    }
-    get copyIds(): number[] {
-        return this._copyIds;
-    }
+    // If there are multiple copyIds, only new alerts may be applied.
+    // If there is only one copyId, then tags may be applied or removed.
+    @Input() copyIds: number[] = [];
 
-    _mode: string; // create | manage
-    @Input() set mode(m: string) {
-        this._mode = m;
-    }
-    get mode(): string {
-        return this._mode;
-    }
+    mode: string; // create | manage
+
+    // If true, no attempt is made to save the new alerts to the
+    // database.  It's assumed this takes place in the calling code.
+    // This is useful for creating alerts for new copies.
+    @Input() inPlaceMode = false;
 
     // In 'create' mode, we may be adding notes to multiple copies.
     copies: IdlObject[];
     // In 'manage' mode we only handle a single copy.
     copy: IdlObject;
+
     alertTypes: ComboboxEntry[];
     newAlert: IdlObject;
     changesMade: boolean;
@@ -78,18 +74,16 @@ export class CopyAlertsDialogComponent
         this.newAlert = this.idl.create('aca');
         this.newAlert.create_staff(this.auth.user().id());
 
-        if (this.copyIds.length === 0) {
+        if (this.copyIds.length === 0 && !this.inPlaceMode) {
             return throwError('copy ID required');
         }
 
         // In manage mode, we can only manage a single copy.
-        // But in create mode, we can add alerts to multiple copies.
-
-        if (this.mode === 'manage') {
-            if (this.copyIds.length > 1) {
-                console.warn('Attempt to manage alerts for multiple copies.');
-                this.copyIds = [this.copyIds[0]];
-            }
+        // But in create mode, we can add tags to multiple copies.
+        if (this.copyIds.length === 1 && !this.inPlaceMode) {
+            this.mode = 'manage';
+        } else {
+            this.mode = 'create';
         }
 
         // Observerify data loading
@@ -104,9 +98,8 @@ export class CopyAlertsDialogComponent
     }
 
     getAlertTypes(): Promise<any> {
-        if (this.alertTypes) {
-            return Promise.resolve();
-        }
+        if (this.alertTypes) { return Promise.resolve(); }
+
         return this.pcrud.retrieveAll('ccat',
         {   active: true,
             scope_org: this.org.ancestors(this.auth.user().ws_ou(), true)
@@ -117,6 +110,8 @@ export class CopyAlertsDialogComponent
     }
 
     getCopies(): Promise<any> {
+        if (this.inPlaceMode) { return Promise.resolve(); }
+
         return this.pcrud.search('acp', {id: this.copyIds}, {}, {atomic: true})
         .toPromise().then(copies => {
             this.copies = copies;
@@ -149,6 +144,11 @@ export class CopyAlertsDialogComponent
     addNew() {
         if (!this.newAlert.alert_type()) { return; }
 
+        if (this.inPlaceMode) {
+            this.close(this.newAlert);
+            return;
+        }
+
         const alerts: IdlObject[] = [];
         this.copies.forEach(c => {
             const a = this.idl.clone(this.newAlert);
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-tags-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-tags-dialog.component.html
new file mode 100644 (file)
index 0000000..f1b53d8
--- /dev/null
@@ -0,0 +1,72 @@
+<eg-string #successMsg text="Successfully Modified Item Tags" i18n-text></eg-string>
+<eg-string #errorMsg text="Failed To Modify Item Tags" i18n-text></eg-string>
+
+<ng-template #dialogContent>
+  <div class="modal-header">
+    <h4 class="modal-title">
+      <ng-container *ngIf="mode == 'create'">
+        <span i18n>Adding tags for {{copyIds.length}} item(s).</span>
+      </ng-container>
+      <ng-container *ngIf="mode == 'manage'">
+        <span i18n>Managing tags for item {{copy.barcode()}}</span>
+      </ng-container>
+      <span i18n></span>
+    </h4>
+    <button type="button" class="close" 
+      i18n-aria-label aria-label="Close" (click)="close()">
+      <span aria-hidden="true">&times;</span>
+    </button>
+  </div>
+  <div class="modal-body p-4 form-validated">
+
+    <ng-container *ngIf="mode == 'manage' && copy.tags().length">
+      <h4 i18n>Existing Tags</h4>
+      <div class="row mt-2 p-2" *ngFor="let map of copy.tags()">
+        <div class="col-lg-4">{{map.tag().tag_type().label()}}</div>
+        <div class="col-lg-5">{{map.tag().label()}}</div>
+        <div class="col-lg-3">
+          <button class="btn btn-outline-danger" (click)="removeTag(map.tag())" i18n>
+            Remove
+          </button>
+        </div>
+      </div>
+      <hr/>
+    </ng-container>
+
+    <h4 i18n>New Tags</h4>
+    <div class="row mt-2 p-2" *ngFor="let tag of newTags">
+      <ng-container *ngIf="!tag.isdeleted()">
+        <div class="col-lg-4">{{tagTypeMap[tag.tag_type()].label()}}</div>
+        <div class="col-lg-5">{{tag.label()}}</div>
+        <div class="col-lg-3">
+          <button class="btn btn-outline-danger" (click)="removeTag(tag)" i18n>
+            Remove
+          </button>
+        </div>
+      </ng-container>
+    </div>
+
+    <div class="row mt-2 p-2 rounded border border-success">
+      <div class="col-lg-4">
+        <eg-combobox [entries]="tagTypes" [(ngModel)]="curTagType"
+          i18n-placeholder placeholder="Select Tag Type...">
+        </eg-combobox>
+      </div>
+      <div class="col-lg-5">
+        <eg-combobox [asyncDataSource]="tagDataSource" [(ngModel)]="curTag"
+          [allowFreeText]="true"
+          i18n-placeholder placeholder="Select Tag Type...">
+        </eg-combobox>
+      </div>
+      <div class="col-lg-3">
+        <div class="pt-2">
+          <button class="btn btn-success" (click)="addNew()" i18n>Add Tag</button>
+        </div>  
+      </div>
+    </div>
+  </div>
+  <div class="modal-footer">
+    <button type="button" class="btn btn-secondary" (click)="close()" i18n>Cancel</button>
+    <button class="btn btn-success mr-2" (click)="applyChanges()" i18n>Apply Changes</button>
+  </div>
+</ng-template>
diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-tags-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-tags-dialog.component.ts
new file mode 100644 (file)
index 0000000..c58e82d
--- /dev/null
@@ -0,0 +1,237 @@
+import {Component, OnInit, Input, ViewChild} from '@angular/core';
+import {Observable, throwError, from, empty} from 'rxjs';
+import {tap, map, switchMap} from 'rxjs/operators';
+import {NetService} from '@eg/core/net.service';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {EventService} from '@eg/core/event.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {AuthService} from '@eg/core/auth.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {OrgService} from '@eg/core/org.service';
+import {StringComponent} from '@eg/share/string/string.component';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+
+/**
+ * Dialog for managing copy tags.
+ */
+
+@Component({
+  selector: 'eg-copy-tags-dialog',
+  templateUrl: 'copy-tags-dialog.component.html'
+})
+
+export class CopyTagsDialogComponent
+    extends DialogComponent implements OnInit {
+
+    // If there are multiple copyIds, only new tags may be applied.
+    // If there is only one copyId, then tags may be applied or removed.
+    @Input() copyIds: number[] = [];
+
+    mode: string; // create | manage
+
+    // If true, no attempt is made to save the new tags to the
+    // database.  It's assumed this takes place in the calling code.
+    // This is useful for creating tags for new copies.
+    @Input() inPlaceMode = false;
+
+    // In 'create' mode, we may be adding notes to multiple copies.
+    copies: IdlObject[] = [];
+
+    // In 'manage' mode we only handle a single copy.
+    copy: IdlObject;
+
+    tagTypes: ComboboxEntry[];
+
+    curTag: ComboboxEntry = null;
+    curTagType: ComboboxEntry = null;
+    newTags: IdlObject[] = [];
+    deletedMaps: IdlObject[] = [];
+    tagMap: {[id: number]: IdlObject} = {};
+    tagTypeMap: {[id: number]: IdlObject} = {};
+
+    tagDataSource: (term: string) => Observable<ComboboxEntry>;
+
+    @ViewChild('successMsg', { static: true }) private successMsg: StringComponent;
+    @ViewChild('errorMsg', { static: true }) private errorMsg: StringComponent;
+
+    constructor(
+        private modal: NgbModal, // required for passing to parent
+        private toast: ToastService,
+        private net: NetService,
+        private idl: IdlService,
+        private pcrud: PcrudService,
+        private org: OrgService,
+        private auth: AuthService) {
+        super(modal); // required for subclassing
+    }
+
+    ngOnInit() {
+
+       this.tagDataSource = term => {
+            if (!this.curTagType) { return empty(); }
+
+            return this.pcrud.search(
+                'acpt', {
+                    tag_type: this.curTagType.id,
+                    '-or': [
+                        {value: {'ilike': `%${term}%`}},
+                        {label: {'ilike': `%${term}%`}}
+                    ]
+                },
+                {order_by: {acpt: 'label'}}
+            ).pipe(map(copyTag => {
+                this.tagMap[copyTag.id()] = copyTag;
+                return {id: copyTag.id(), label: copyTag.label()};
+            }));
+        };
+    }
+
+    /**
+     */
+    open(args: NgbModalOptions): Observable<IdlObject[]> {
+        this.copy = null;
+        this.copies = [];
+        this.newTags = [];
+        this.deletedMaps = [];
+
+        if (this.copyIds.length === 0 && !this.inPlaceMode) {
+            return throwError('copy ID required');
+        }
+
+        // In manage mode, we can only manage a single copy.
+        // But in create mode, we can add tags to multiple copies.
+
+        if (this.copyIds.length === 1 && !this.inPlaceMode) {
+            this.mode = 'manage';
+        } else {
+            this.mode = 'create';
+        }
+
+        // Observify data loading
+        const obs = from(this.getTagTypes().then(_ => this.getCopies()));
+
+        // Return open() observable to caller
+        return obs.pipe(switchMap(_ => super.open(args)));
+    }
+
+    getTagTypes(): Promise<any> {
+        if (this.tagTypes) { return Promise.resolve(); }
+
+        this.tagTypes = [];
+        return this.pcrud.search('cctt',
+            {owner: this.org.ancestors(this.auth.user().ws_ou(), true)},
+            {order_by: {cctt: 'label'}}
+        ).pipe(tap(tag => {
+            this.tagTypeMap[tag.code()] = tag;
+            this.tagTypes.push({id: tag.code(), label: tag.label()});
+        })).toPromise();
+    }
+
+    getCopies(): Promise<any> {
+        if (this.inPlaceMode) { return Promise.resolve(); }
+
+        return this.pcrud.search('acp', {id: this.copyIds},
+            {flesh: 3, flesh_fields: {
+                acp: ['tags'], acptcm: ['tag'], acpt: ['tag_type']}},
+            {atomic: true}
+        )
+        .toPromise().then(copies => {
+            this.copies = copies;
+            if (copies.length === 1) {
+                this.copy = copies[0];
+            }
+        });
+    }
+
+    removeTag(tag: IdlObject) {
+        this.newTags = this.newTags.filter(t => t.id() !== tag.id());
+
+        if (tag.isnew() || this.mode === 'create') { return; }
+
+        const existing = this.copy.tags().filter(m => m.tag().id() === tag.id())[0];
+        if (!existing) { return; }
+
+        existing.isdeleted(true);
+        this.deletedMaps.push(existing);
+        this.copy.tags(this.copy.tags().filter(m => m.tag().id() !== tag.id()));
+        this.copy.ischanged(true);
+    }
+
+    addNew() {
+        if (!this.curTagType || !this.curTag) { return; }
+
+        let tag;
+
+        if (this.curTag.freetext) {
+            // Create a new tag w/ the provided tag text.
+            tag = this.idl.create('acpt');
+            tag.isnew(true);
+            tag.tag_type(this.curTagType.id);
+            tag.label(this.curTag.label);
+            tag.owner(this.auth.user().ws_ou());
+            tag.pub('t');
+        } else {
+            tag = this.tagMap[this.curTag.id];
+        }
+
+        this.newTags.push(tag);
+    }
+
+    createNewTags(): Promise<any> {
+        let promise = Promise.resolve();
+
+        this.newTags.forEach(tag => {
+            if (!tag.isnew()) { return; }
+
+            promise = promise.then(_ => {
+                return this.pcrud.create(tag).toPromise().then(id => {
+                    console.log('create returned ', id);
+                    tag.id(id);
+                });
+            });
+        });
+
+        return promise;
+    }
+
+    deleteMaps(): Promise<any> {
+        if (this.deletedMaps.length === 0) { return Promise.resolve(); }
+        return this.pcrud.remove(this.deletedMaps).toPromise();
+    }
+
+    applyChanges() {
+
+        if (this.inPlaceMode) {
+            this.close(this.newTags);
+            return;
+        }
+
+        let promise = this.deleteMaps().then(_ => this.createNewTags());
+
+        this.newTags.forEach(tag => {
+            this.copies.forEach(copy => {
+
+                if (copy.tags() && copy.tags().filter(
+                    m => m.tag().id() === tag.id()).length > 0) {
+                    return; // map already exists
+                }
+
+                promise = promise.then(_ => {
+                    const tagMap = this.idl.create('acptcm');
+                    tagMap.isnew(true);
+                    tagMap.copy(copy.id());
+                    tagMap.tag(tag.id());
+                    return this.pcrud.create(tagMap).toPromise();
+                });
+            });
+        });
+
+        promise.then(_ => {
+            this.successMsg.current().then(msg => this.toast.success(msg));
+            this.close(this.newTags.length > 0);
+        });
+    }
+}
+
index bc93932..b84a4ab 100644 (file)
@@ -4,22 +4,26 @@ import {HoldingsService} from './holdings.service';
 import {MarkDamagedDialogComponent} from './mark-damaged-dialog.component';
 import {MarkMissingDialogComponent} from './mark-missing-dialog.component';
 import {CopyAlertsDialogComponent} from './copy-alerts-dialog.component';
+import {CopyTagsDialogComponent} from './copy-tags-dialog.component';
 import {ReplaceBarcodeDialogComponent} from './replace-barcode-dialog.component';
 import {DeleteHoldingDialogComponent} from './delete-volcopy-dialog.component';
 import {ConjoinedItemsDialogComponent} from './conjoined-items-dialog.component';
 import {TransferItemsComponent} from './transfer-items.component';
 import {TransferHoldingsComponent} from './transfer-holdings.component';
+import {BatchItemAttrComponent} from './batch-item-attr.component';
 
 @NgModule({
     declarations: [
       MarkDamagedDialogComponent,
       MarkMissingDialogComponent,
       CopyAlertsDialogComponent,
+      CopyTagsDialogComponent,
       ReplaceBarcodeDialogComponent,
       DeleteHoldingDialogComponent,
       ConjoinedItemsDialogComponent,
       TransferItemsComponent,
-      TransferHoldingsComponent
+      TransferHoldingsComponent,
+      BatchItemAttrComponent
     ],
     imports: [
         StaffCommonModule
@@ -28,11 +32,13 @@ import {TransferHoldingsComponent} from './transfer-holdings.component';
       MarkDamagedDialogComponent,
       MarkMissingDialogComponent,
       CopyAlertsDialogComponent,
+      CopyTagsDialogComponent,
       ReplaceBarcodeDialogComponent,
       DeleteHoldingDialogComponent,
       ConjoinedItemsDialogComponent,
       TransferItemsComponent,
-      TransferHoldingsComponent
+      TransferHoldingsComponent,
+      BatchItemAttrComponent
     ],
     providers: [
         HoldingsService
index 0f2070b..b2aa20b 100644 (file)
@@ -2,18 +2,21 @@
  * 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 {PcrudService} from '@eg/core/pcrud.service';
 import {AuthService} from '@eg/core/auth.service';
 import {IdlObject} from '@eg/core/idl.service';
 import {EventService} from '@eg/core/event.service';
+import {PcrudService} from '@eg/core/pcrud.service';
 
-interface NewCallNumData {
+export interface CallNumData {
     owner?: number;
     label?: string;
     fast_add?: boolean;
     barcode?: string;
+    callnumber?: number;
 }
 
 @Injectable()
@@ -31,7 +34,7 @@ export class HoldingsService {
     spawnAddHoldingsUi(
         recordId: number,                  // Bib record ID
         editExistingCallNums?: number[],   // Add copies to / modify existing CNs
-        newCallNumData?: NewCallNumData[], // Creating new call numbers
+        newCallNumData?: CallNumData[],    // Creating new call numbers
         editCopyIds?: number[],            // Edit existing items
         hideCopies?: boolean,              // Hide the copy edit pane
         hideVols?: boolean) {
@@ -57,7 +60,8 @@ export class HoldingsService {
                 return;
             }
             setTimeout(() => {
-                const url = `/eg/staff/cat/volcopy/${key}`;
+                const tab = hideVols ? 'attrs' : 'holdings';
+                const url = `/eg2/staff/cat/volcopy/${tab}/session/${key}`;
                 window.open(url, '_blank');
             });
         });
@@ -79,5 +83,17 @@ export class HoldingsService {
             }
         });
     }
+
+    /* TODO: make these more configurable per lp1616170 */
+    getMagicCopyStatuses(): Promise<number[]> {
+        return Promise.resolve([
+            1,  // Checked out
+            3,  // Lost
+            6,  // In transit
+            8,  // On holds shelf
+            16, // Long overdue
+            18  // Canceled Transit
+        ]);
+    }
 }
 
index e277568..13506c9 100644 (file)
@@ -162,6 +162,10 @@ h5 {font-size: .95rem}
   border-left: 5px solid #FA787E;
 }
 
+.invalid {
+  border-left: 5px solid #FA787E;
+}
+
 /* Typical form CSS.
  * Brings font size down 5% to squeeze a bit more in.
  * Bold labels
@@ -233,6 +237,18 @@ body>.dropdown-menu {z-index: 2100;}
   color: black;
 }
 
+/* Washed out version of the Bootstrap 'info' background.
+ * Useful for blocking out sections of a page/form without it 
+ * being so intensely colorful */
+.bg-faint {
+  /*background-color: rgb(204, 229, 255, 0.3);*/
+
+  /* d9edf7 */
+  /*background-color: rgb(217, 237, 247, 0.5);*/
+
+  background-color: rgba(0,0,0,.03);
+}
+
 /* Allow for larger XL dialogs */
 @media (min-width: 1300px) { .modal-xl { max-width: 1200px; } }
 @media (min-width: 1600px) { .modal-xl { max-width: 1500px; } }
index 3ea51c7..deb7ad4 100644 (file)
@@ -1958,6 +1958,135 @@ sub retrieve_tag_table {
     }
 }
 
+__PACKAGE__->register_method(
+    method    => "volcopy_data",
+    api_name  => "open-ils.cat.volcopy.data",
+    stream    => 1,
+    argc      => 3,
+    signature => {
+        desc   => q|Returns a batch of org-scoped data types needed by the 
+            volume/copy editor|,
+        params => [
+            {desc => 'Authtoken', type => 'string'}
+        ]
+    },
+    return => {desc => 'Stream of various object type lists', type => 'array'}
+);
+
+sub volcopy_data {
+    my ($self, $client, $auth) = @_;
+    my $e = new_editor(authtoken => $auth);
+
+    $e->checkauth or return $e->event;
+    my $org_ids = $U->get_org_ancestors($e->requestor->ws_ou);
+
+    $client->respond({
+        acp_location => $e->search_asset_copy_location([
+            {deleted => 'f', owning_lib => $org_ids},
+            {order_by => {acpl => 'name'}}
+        ])
+    });
+
+    # Provide a reasonable default copy location.  Typically "Stacks"
+    $client->respond({
+        acp_default_location => $e->search_asset_copy_location([
+            {deleted => 'f', owning_lib => $org_ids},
+            {order_by => {acpl => 'id'}, limit => 1}
+        ])->[0]
+    });
+
+    $client->respond({
+        acp_status => $e->search_config_copy_status([
+            {id => {'!=' => undef}},
+            {order_by => {ccs => 'name'}}
+        ])
+    });
+
+    $client->respond({
+        acp_age_protect => $e->search_config_rules_age_hold_protect([
+            {id => {'!=' => undef}},
+            {order_by => {crahp => 'name'}}
+        ])
+    });
+
+    $client->respond({
+        acp_floating_group => $e->search_config_floating_group([
+            {id => {'!=' => undef}},
+            {order_by => {cfg => 'name'}}
+        ])
+    });
+
+    $client->respond({
+        acp_circ_modifier => $e->search_config_circ_modifier([
+            {code => {'!=' => undef}},
+            {order_by => {ccm => 'name'}}
+        ])
+    });
+
+    $client->respond({
+        acp_item_type_map => $e->search_config_item_type_map([
+            {code => {'!=' => undef}},
+            {order_by => {ccm => 'value'}}
+        ])
+    });
+
+    $client->respond({
+        acn_class => $e->search_asset_call_number_class([
+            {id => {'!=' => undef}},
+            {order_by => {acnc => 'name'}}
+        ])
+    });
+    
+    $client->respond({
+        acn_prefix => $e->search_asset_call_number_prefix([
+            {owning_lib => $org_ids},
+            {order_by => {acnp => 'label_sortkey'}}
+        ])
+    });
+
+    $client->respond({
+        acn_suffix => $e->search_asset_call_number_suffix([
+            {owning_lib => $org_ids},
+            {order_by => {acns => 'label_sortkey'}}
+        ])
+    });
+
+    # Some object types require more complex sorting, etc.
+
+    my $cats = $e->search_asset_stat_cat([
+        {owner => $org_ids},
+        {   flesh => 2, 
+            flesh_fields => {asc => ['owner', 'entries'], aou => ['ou_type']}
+        }
+    ]);
+
+    # Sort stat cats by depth then by name within each depth group.
+    $cats = [
+        sort {
+            my $d1 = $a->owner->ou_type->depth;
+            my $d2 = $b->owner->ou_type->depth;
+            return $a->name cmp $b->name if $d1 == $d2;
+
+            # Sort cats closer to the workstation org unit to the front.
+            return $d1 > $d2 ? -1 : 1;
+        }
+        @$cats
+    ];
+
+    for my $cat (@$cats) {
+        # de-flesh org data
+        $cat->owner($cat->owner->id);
+
+        # sort entries
+        $cat->entries([sort {$a->value cmp $b->value} @{$cat->entries}]);
+    }
+
+    $client->respond({acp_stat_cat => $cats});
+
+    return undef;
+}
+
+
 1;
 
 # vi:et:ts=4:sw=4
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.data.volcopy-settings.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.volcopy-settings.sql
new file mode 100644 (file)
index 0000000..cbac2a5
--- /dev/null
@@ -0,0 +1,19 @@
+
+INSERT INTO config.workstation_setting_type (name, grp, datatype, label)
+VALUES (
+    'eg.cat.volcopy.defaults', 'cat', 'object',
+    oils_i18n_gettext(
+        'eg.cat.volcopy.defaults',
+        'Holdings Editor Default Values and Visibility',
+        'cwst', 'label'
+    )
+), (
+    'cat.copy.templates', 'cat', 'object',
+    oils_i18n_gettext(
+        'cat.copy.templates',
+        'Holdings Editor Copy Templates',
+        'cwst', 'label'
+    )
+);
+
+
index 5789dba..116c2ab 100644 (file)
@@ -1395,7 +1395,8 @@ function($scope , $routeParams , $location , $window , $q , egCore , egHolds , e
             }
         ).then(function(key) {
             if (key) {
-                var url = egCore.env.basePath + 'cat/volcopy/' + key;
+                //var url = egCore.env.basePath + 'cat/volcopy/' + key;
+                var url = '/eg2/staff/cat/volcopy/session/' + key;
                 $timeout(function() { $window.open(url, '_blank') });
             } else {
                 alert('Could not create anonymous cache key!');