border-top: 1px solid rgba(0,0,0,.125);
border-bottom: 1px solid rgba(0,0,0,.125);
}
+
+
+.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;
+}
+
+<eg-confirm-dialog
+ #confirmDelVol
+ i18n-dialogTitle i18n-dialogBody
+ dialogTitle="Delete Call Number?"
+ dialogBody="Delete {{deleteVolCount}} Call Number(s) and {{deleteCopyCount}} 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 vol-row border border-info mb-2">
- <div class="p-1" [ngStyle]="{flex: flexAt(1)}">
- </div>
- <div class="p-1" [ngStyle]="{flex: flexAt(2)}">
- </div>
+ <div class="p-1" [ngStyle]="{flex: flexAt(1)}"> </div>
+ <div class="p-1" [ngStyle]="{flex: flexAt(2)}"> </div>
<div class="p-1" [ngStyle]="{flex: flexAt(3)}">
- <label class="font-weight-bold" i18n>Classification</label>
+ <div><label class="font-weight-bold" i18n>Classification</label></div>
<div>
<eg-combobox [smallFormControl]="true" [(ngModel)]="batchVolClass">
- <eg-combobox-entry *ngFor="let cls of volClasses"
+ <eg-combobox-entry *ngFor="let cls of volClasses"
[entryId]="cls.id()" [entryLabel]="cls.name()">
</eg-combobox-entry>
</eg-combobox>
</div>
</div>
<div class="p-1" [ngStyle]="{flex: flexAt(4)}">
- <label class="font-weight-bold" i18n>Prefix</label>
+ <div><label class="font-weight-bold" i18n>Prefix</label></div>
<div>
<eg-combobox [smallFormControl]="true" [(ngModel)]="batchVolPrefix">
- <eg-combobox-entry *ngFor="let pfx of volPrefixes"
+ <eg-combobox-entry *ngFor="let pfx of volPrefixes"
[entryId]="pfx.id()" [entryLabel]="pfx.label()">
</eg-combobox-entry>
</eg-combobox>
</div>
</div>
<div class="p-1" [ngStyle]="{flex: flexAt(5)}">
- <label class="font-weight-bold" i18n>Call Number Label</label>
+ <div><label class="font-weight-bold" i18n>Call Number Label</label></div>
<div>
<eg-combobox [smallFormControl]="true" [(ngModel)]="batchVolLabel">
<eg-combobox-entry *ngFor="let label of recordVolLabels" [entryId]="label">
</div>
</div>
<div class="p-1" [ngStyle]="{flex: flexAt(6)}">
- <label class="font-weight-bold" i18n>Suffix</label>
+ <div><label class="font-weight-bold" i18n>Suffix</label></div>
<div>
<eg-combobox [smallFormControl]="true" [(ngModel)]="batchVolSuffix">
- <eg-combobox-entry *ngFor="let sfx of volSuffixes"
+ <eg-combobox-entry *ngFor="let sfx of volSuffixes"
[entryId]="sfx.id()" [entryLabel]="sfx.label()">
</eg-combobox-entry>
</eg-combobox>
</div>
</div>
<div class="p-1" [ngStyle]="{flex: flexAt(7)}">
- <label class="font-weight-bold" i18n>Batch</label>
+ <div><label class="font-weight-bold" i18n>Batch</label></div>
<div>
- <button class="btn btn-sm btn-outline-dark label-with-material-icon"
+ <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>
</div>
</div>
<div class="p-1" [ngStyle]="{flex: flexAt(8)}">
- <label class="font-weight-bold" i18n>Generate Barcodes</label>
- <button class="btn btn-sm btn-outline-dark label-with-material-icon"
+ <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>
</div>
- <div class="p-1" [ngStyle]="{flex: flexAt(9)}"></div>
- <div class="p-1" [ngStyle]="{flex: flexAt(10)}"></div>
+ <div class="p-1" [ngStyle]="{flex: flexSpan(9, 10)}">
+ <div><label class="font-weight-bold" i18n>Checkdigit</label></div>
+ <div class="form-check form-check-inline">
+ <input class="form-check-input" type="checkbox"
+ id="use-checkdigit" [(ngModel)]="useCheckdigit">
+ <label class="form-check-label" for="use-checkdigit" i18n>
+ Use Checkdigit
+ </label>
+ </div>
+ </div>
</div>
<ng-container *ngFor="let copyNode of volNode.children; let copyIdx = index">
<div class="row d-flex mt-1" [ngClass]="{'vol-row': copyIdx == 0}">
<div class="p-1" [ngStyle]="{flex: flexAt(1)}">
- <span *ngIf="copyIdx == 0">{{orgNode.target.shortname()}}</span>
+ <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">
+ <ng-container *ngIf="copyIdx == 0 && volIdx == 0">
<input type="number" class="form-control form-control-sm"
- [required]="true"
+ [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)}">
<ng-container *ngIf="copyIdx == 0">
- <eg-combobox
+ <eg-combobox
[selectedId]="volNode.target.label_class()"
[smallFormControl]="true"
- [required]="true"
+ [required]="true"
(onChange)="applyVolValue(volNode.target, 'label_class', $event ? $event.id : null)">
- <eg-combobox-entry *ngFor="let cls of volClasses"
+ <eg-combobox-entry *ngFor="let cls of volClasses"
[entryId]="cls.id()" [entryLabel]="cls.name()">
</eg-combobox-entry>
</eg-combobox>
</div>
<div class="p-1" [ngStyle]="{flex: flexAt(4)}">
<ng-container *ngIf="copyIdx == 0">
- <eg-combobox
+ <eg-combobox
[selectedId]="volNode.target.prefix()"
- [required]="true"
+ [required]="true"
[smallFormControl]="true"
(onChange)="applyVolValue(volNode.target, 'prefix', $event ? $event.id : null)">
- <eg-combobox-entry *ngFor="let pfx of volPrefixes"
+ <eg-combobox-entry *ngFor="let pfx of volPrefixes"
[entryId]="pfx.id()" [entryLabel]="pfx.label()">
</eg-combobox-entry>
</eg-combobox>
spellcheck="false"
[required]="true"
[ngModel]="volNode.target.label()"
- (onChange)="applyVolValue(volNode.target, 'label', $event)">
+ (change)="applyVolValue(volNode.target, 'label', $event.target.value)">
</ng-container>
</div>
<div class="p-1" [ngStyle]="{flex: flexAt(6)}">
<ng-container *ngIf="copyIdx == 0">
- <eg-combobox
+ <eg-combobox
[selectedId]="volNode.target.suffix()"
- [required]="true"
+ [required]="true"
[smallFormControl]="true"
(onChange)="applyVolValue(volNode.target, 'suffix', $event ? $event.id : null)">
- <eg-combobox-entry *ngFor="let sfx of volSuffixes"
+ <eg-combobox-entry *ngFor="let sfx of volSuffixes"
[entryId]="sfx.id()" [entryLabel]="sfx.label()">
</eg-combobox-entry>
</eg-combobox>
<div class="p-1" [ngStyle]="{flex: flexAt(7)}">
<ng-container *ngIf="copyIdx == 0">
<input type="number" class="form-control form-control-sm"
- [required]="true"
+ [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: flexAt(8)}">
- <input type="text" class="form-control form-control-sm"
- id="barcode-input-{{copyNode.target.id()}}"
- spellcheck="false"
- [required]="true"
- (keyup.enter)="selectNextBarcode(copyNode.target.id())"
- (keyup.shift.enter)="selectNextBarcode(copyNode.target.id(), true)"
- [ngModel]="copyNode.target.barcode()"
- (ngModelChange)="applyCopyValue(copyNode.target, 'barcode', $event)"/>
+
+ <div class="d-flex">
+ <ng-container *ngIf="context.sessionType != 'copy'">
+ <button class="clear-button" (click)="deleteCopy(copyNode)"
+ title="Delete Item {{copyNode.target.barcode()}}" i18n-title>
+ <span class="material-icons">clear</span>
+ </button>
+ </ng-container>
+
+ <input type="text" class="form-control form-control-sm"
+ id="barcode-input-{{copyNode.target.id()}}"
+ spellcheck="false" [required]="true"
+ [ngClass]="{'text-danger': copyNode.target._dupe_barcode}"
+ (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)}">
- <input type="number" class="form-control form-control-sm"
+ <input type="number" min="1" class="form-control form-control-sm"
[ngModel]="copyNode.target.copy_number()"
(ngModelChange)="applyCopyValue(copyNode.target, 'copy_number', $event)"/>
</div>
<label i18n>N/A</label>
</ng-container>
<ng-container *ngIf="recordHasParts(volNode.target.record())">
- <eg-combobox
+ <eg-combobox
[disabled]="bibParts[volNode.target.record()].length == 0"
[selectedId]="copyNode.target.parts()[0] ? copyNode.target.parts()[0].id() : null"
[smallFormControl]="true"
import {Component, OnInit, AfterViewInit, ViewChild, Input, Renderer2} from '@angular/core';
import {Router, ActivatedRoute, ParamMap} from '@angular/router';
-import {IdlObject} from '@eg/core/idl.service';
+import {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',
batchVolSuffix: ComboboxEntry;
batchVolLabel: ComboboxEntry;
+ autoBarcodeInProgress = false;
+ useCheckdigit = false;
+
+ autoId = -1;
+
+ deleteVolCount: number = null;
+ deleteCopyCount: number = null;
+
recordVolLabels: string[] = [];
+ @ViewChild('confirmDelVol', {static: false})
+ confirmDelVol: ConfirmDialogComponent;
+
+ @ViewChild('confirmDelCopy', {static: false})
+ confirmDelCopy: ConfirmDialogComponent;
+
constructor(
private renderer: Renderer2,
+ private idl: IdlService,
private pcrud: PcrudService,
private net: NetService,
- private holdings: HoldingsService
+ private auth: AuthService,
+ private holdings: HoldingsService,
+ private volcopy: VolCopyService
) {}
ngOnInit() {
- this.fetchRecordVolLabels()
- .then(_ => this.fetchBibParts());
+ this.deleteVolCount = null;
+ this.deleteCopyCount = null;
+
+ this.volcopy.fetchRecordVolLabels(this.context.recordId)
+ .then(labels => this.recordVolLabels = labels)
+ .then(_ => this.fetchBibParts())
+ .then(_ => this.addStubCopies());
// TODO: Filter these to only show org-scoped values
// plus any values otherwise needed for the current
this.volSuffixes = suffixes.filter(pfx => pfx.id() !== -1));
}
- fetchRecordVolLabels(): Promise<any> {
- // NOTE: see https://bugs.launchpad.net/evergreen/+bug/1874897
- // for more on MARC call numbers and classification scheme.
-
- this.recordVolLabels = [];
- const ids = this.context.getRecordIds();
-
- // It only makes sense to fetch bib call numbers when we are
- // working with exactly one record.
- if (ids.length !== 1) { return Promise.resolve(); }
-
- return this.net.request(
- 'open-ils.cat',
- 'open-ils.cat.biblio.record.marc_cn.retrieve', ids[0]
- ).toPromise().then(res => {
- this.recordVolLabels = Object.values(res)
- .map(blob => Object.values(blob)[0]).sort();
- });
- }
fetchBibParts() {
return this.flexSettings[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++) {
+ flex += this.flexSettings[i];
+ }
+ return flex;
+ }
+
volCountChanged(orgNode: HoldingsTreeNode, count: number) {
- console.log('vol set set to ', count);
+ 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);
+ }
+ }
+
+ 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) {
- console.log('vol set set to ', count);
+ 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) {
+ 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())
+
+ // Our context assumes copies are fleshed with volumes
+ const copy = this.volcopy.createStubCopy(vol);
+ copy.call_number(vol);
+ this.context.findOrCreateCopyNode(copy);
+ }
+ }
+
+ // 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;
+ }
+ }
+ }
+
+ // Empty volumes get a stub copy
+ addStubCopies(volNode?: HoldingsTreeNode) {
+ const nodes = volNode ? [volNode] : this.context.volNodes();
+
+ nodes.forEach(volNode => {
+ if (volNode.children.length == 0) {
+ const vol = volNode.target;
+ const copy = this.volcopy.createStubCopy(vol);
+ copy.call_number(vol);
+ this.context.findOrCreateCopyNode(copy);
+ }
+ });
}
applyVolValue(vol: IdlObject, key: string, value: any) {
batchVolApply() {
this.context.volNodes().forEach(volNode => {
const vol = volNode.target;
- console.log('batch vol class', this.batchVolClass.id);
if (this.batchVolClass) {
this.applyVolValue(vol, 'label_class', this.batchVolClass.id);
}
'#barcode-input-' + (nextId || firstId)).select();
}
+ barcodeCanChange(copy: IdlObject): boolean {
+ // TODO
+ return true;
+ }
+
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) {
+ copy.barcode(barcode);
+ copy.ischanged(true);
+ copy._dupe_barcode = false;
+
+ if (barcode && !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; }
+ });
+ }
+ }
+
+ 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);
+
+ volNode.children.forEach(copyNode => {
+ const copy = copyNode.target;
+ if (copy.isnew()) {
+ // New copies can simply be discarded.
+ } else {
+ copy.isdeleted(true);
+ this.context.copiesToDelete.push(copy);
+ }
+ });
+ }
+
+ if (deleteVolIdx !== null) { break; }
+ }
+
+ if (deleteVolIdx !== null) {
+ orgNode.children.splice(deleteVolIdx, 1);
+ break;
+ }
+ }
}
}
<eg-staff-banner bannerText="Holdings Editor" i18n-bannerText></eg-staff-banner>
+<div class="row" [hidden]="!loading">
+ <div class="col-lg-6 offset-lg-3">
+ <eg-progress-inline #loadingProgress></eg-progress-inline>
+ </div>
+</div>
+
<ng-container *ngIf="!loading">
- <eg-bib-summary *ngIf="recordId" [recordId]="recordId"></eg-bib-summary>
+ <eg-bib-summary *ngIf="context.recordId" [recordId]="context.recordId"></eg-bib-summary>
<div class="mt-3">
<eg-vol-edit [context]="context"></eg-vol-edit>
</div>
+
+ <div class="row m-2 p-2 border border-info">
+ <div class="col-lg-12 d-flex">
+ <div class="flex-1"> </div>
+ <button class="btn btn-outline-dark"
+ [disabled]="!context.isSaveable()" (click)="save()" i18n>Save</button>
+ <button class="btn btn-outline-dark ml-2"
+ [disabled]="!context.isSaveable()"
+ (click)="save(true)" i18n>Save & Exit</button>
+ </div>
+ </div>
+
</ng-container>
+
import {Component, OnInit, AfterViewInit, ViewChild, Renderer2} from '@angular/core';
import {Router, ActivatedRoute, ParamMap} from '@angular/router';
import {tap} from 'rxjs/operators';
-import {IdlObject} from '@eg/core/idl.service';
+import {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} from '@eg/staff/share/holdings/holdings.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';
const COPY_FLESH = {
flesh: 1,
}
}
+interface EditSession {
+
+ // Unset if editing in multi-record mode
+ record_id: 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;
-
- // Note in multi-record mode this value will be unset.
- recordId: number;
-
- // Load specific call number by ID.
- volId: number;
-
- // Load specific copy by ID.
- copyId: number;
-
- session: string;
loading = true;
+ @ViewChild('loadingProgress', {static: false})
+ loadingProgress: ProgressInlineComponent;
+
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 holdings: HoldingsService
+ private cache: AnonCacheService,
+ private holdings: HoldingsService,
+ private volcopy: VolCopyService
) { }
ngOnInit() {
}
negotiateRoute(params: ParamMap) {
- this.recordId = +params.get('record_id') || null;
- this.volId = +params.get('vol_id') || null;
- this.copyId = +params.get('copy_id') || null;
- this.session = params.get('session') || null;
+ this.context.recordId = +params.get('record_id') || null;
+ this.context.volId = +params.get('vol_id') || null;
+ this.context.copyId = +params.get('copy_id') || null;
+ this.context.session = params.get('session') || null;
this.load();
}
load() {
+
this.loading = true;
this.context.reset();
+
this.fetchHoldings()
+ .then(_ => this.volcopy.applyVolLabels(
+ this.context.volNodes().map(n => n.target)))
.then(_ => this.holdings.fetchCallNumberClasses())
.then(_ => this.holdings.fetchCallNumberPrefixes())
.then(_ => this.holdings.fetchCallNumberSuffixes())
.then(_ => this.context.sortHoldings())
- .then(_ => this.setRecordId())
+ .then(_ => this.context.setRecordId())
.then(_ => this.loading = false);
}
- setRecordId() {
- if (!this.recordId) {
- const ids = this.context.getRecordIds();
- if (ids.length === 1) {
- this.recordId = ids[0];
- }
+ fetchHoldings(): Promise<any> {
+
+ if (this.context.session) {
+ this.context.sessionType = 'mixed';
+ return this.fetchSession(this.context.session);
+
+ } else if (this.context.recordId) {
+ this.context.sessionType = 'record';
+ return this.fetchRecords(this.context.recordId);
+
+ } else if (this.context.volId) {
+ this.context.sessionType = 'vol';
+ return this.fetchVols(this.context.volId);
+
+ } else if (this.context.copyId) {
+ this.context.sessionType = 'copy';
+ return this.fetchCopies(this.context.copyId);
}
}
- fetchHoldings(): Promise<any> {
- if (this.copyId) {
- return this.fetchCopies(this.copyId);
- } else if (this.volId) {
- return this.fetchVols(this.volId);
- } else if (this.recordId) {
- return this.fetchRecords(this.recordId);
- }
+ fetchSession(session: string): Promise<any> {
+
+ return this.cache.getItem(session, 'edit-these-copies')
+ .then((editSession: EditSession) => {
+
+ if (!editSession) { return; }
+
+ this.context.recordId = editSession.record_id;
+ this.context.hideVols = editSession.hide_vols === true;
+ this.context.hideCopies = editSession.hide_copies === true;
+
+ 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(
+ volData => volData.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);
}
.toPromise();
}
- // Fetch call numbers and copies by call number ids.
+ // 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)))
- .pipe(tap(vol => {
+ .toPromise().then(_ => {
return this.pcrud.search('acp',
{call_number: ids, deleted: 'f'}, COPY_FLESH
).pipe(tap(copy => this.context.findOrCreateCopyNode(copy))
).toPromise();
- })).toPromise();
+ });
}
// Fetch call numbers and copies by record ids.
const ids = [].concat(recordIds);
return this.pcrud.search('acn',
- {record: ids, deleted: 'f'},
+ {record: ids, deleted: 'f', label: {'!=' : '##URI##'}},
{}, {idlist: true, atomic: true}
).toPromise().then(volIds =>this.fetchVols(volIds));
}
+
+
+ save() {
+ this.loadingProgress.reset();
+ 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);
+ // If a deleted volume also has deleted copies, they will
+ // be appended below.
+ 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);
+ })
+
+ if (volumes.length > 0) {
+ this.saveApi(volumes);
+ } else {
+ this.loading = false;
+ }
+ }
+
+ saveApi(volumes: IdlObject[], override?: boolean) {
+
+ let method = 'open-ils.cat.asset.volume.fleshed.batch.update';
+ if (override) { method += '.override'; }
+
+ this.net.request('open-ils.cat', method, this.auth.token(),
+ volumes, 1, {auto_merge_vols: 1, create_parts: 1}).toPromise()
+
+ .then(resp => {
+
+ const evt = this.evt.parse(resp);
+
+ // TODO: confirm / handle overrides
+
+ if (evt) {
+ alert(evt);
+ return;
+ }
+
+ return this.load();
+ });
+ }
}
import {VolCopyRoutingModule} from './routing.module';
import {VolCopyComponent} from './volcopy.component';
import {VolEditComponent} from './vol-edit.component';
+import {VolCopyService} from './volcopy.service';
@NgModule({
declarations: [
VolCopyRoutingModule
],
providers: [
+ VolCopyService
]
})
--- /dev/null
+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';
+
+/* Managing volcopy data */
+
+@Injectable()
+export class VolCopyService {
+
+ autoId = -1;
+
+ constructor(
+ private evt: EventService,
+ private net: NetService,
+ private idl: IdlService,
+ private org: OrgService,
+ private auth: AuthService,
+ private holdings: HoldingsService
+ ) {}
+
+
+ // Fetch vol labels for a single record
+ 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.
+ return this.net.request(
+ 'open-ils.cat',
+ 'open-ils.cat.biblio.record.marc_cn.retrieve', id
+ ).toPromise().then(res => {
+ return Object.values(res)
+ .map(blob => Object.values(blob)[0]).sort();
+ });
+ }
+
+ createStubVol(recordId: number, orgId: number): IdlObject {
+
+ const vol = this.idl.create('acn');
+ vol.id(this.autoId--);
+ vol.isnew(true);
+ vol.record(recordId);
+ vol.label(null);
+ vol.owning_lib(Number(orgId));
+
+ 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.circ_lib(Number(options.circLib || vol.owning_lib()));
+ copy.call_number(vol);
+ copy.deposit(0);
+ copy.price(0);
+ copy.deposit_amount(0);
+ copy.fine_level(2); // Normal
+ copy.loan_duration(2); // Normal
+ copy.location(1); // Stacks
+ copy.circulate('t');
+ copy.holdable('t');
+ copy.opac_visible('t');
+ copy.ref('f');
+ copy.mint_condition('t');
+ copy.parts([]);
+
+ // TODO: defaults?
+
+ 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> {
+
+ const orgIds: any = {};
+ vols.forEach(vol => orgIds[vol.owning_lib()] = true);
+
+ // Serialize
+ let promise = Promise.resolve();
+
+ // TODO: if there is a local default value (ws setting?)
+ // apply it here and bypass the network call.
+
+ const volsWantLabels = [];
+ Object.keys(orgIds).forEach(orgId => {
+ promise = promise.then(_ => {
+
+ return this.org.settings(
+ 'cat.default_classification_scheme', Number(orgId))
+ .then(sets => {
+
+ const orgVols = vols.filter(v => v.owning_lib() === orgId);
+ orgVols.forEach(vol => {
+ vol.label_class(
+ sets['cat.default_classification_scheme'] || 1
+ );
+ if (!vol.label()) { volsWantLabels.push(vol); }
+ });
+ });
+ });
+ });
+
+ return promise;
+ }
+
+ // Apply labels to volumes based on the appropriate MARC call number.
+ applyVolLabels(vols: IdlObject[]): Promise<any> {
+
+ // 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'
+
+ const orgIds: any = {};
+ copies.forEach(copy => orgIds[copy.circ_lib()] = true);
+
+ let promise = Promise.resolve();
+ Object.keys(orgIds).forEach(orgId => {
+
+ promise = promise.then(_ =>
+ this.org.settings(setting, Number(orgId))
+ ).then(sets => {
+ // 0 == Available; 5 == In Process
+ const stat = sets[setting] || (fastAdd ? 0 : 5);
+ const orgCopies =
+ copies.filter(copy => copy.circ_lib() === orgId);
+ orgCopies.forEach(copy => copy.status(stat));
+ });
+ });
+
+ return promise;
+ }
+}
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';
export class VolCopyContext {
- autoId = -1;
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[] = [];
+
+ hideVols: boolean;
+ hideCopies: boolean;
+
reset() {
this.holdings = new HoldingsTree();
+ this.volsToDelete = [];
+ this.copiesToDelete = [];
}
orgNodes(): HoldingsTreeNode[] {
// Returns IDs for all bib records represented in our holdings tree.
getRecordIds(): number[] {
const idHash: {[id: number]: boolean} = {};
- this.orgNodes().forEach(orgNode => {
- orgNode.children.forEach(
- volNode => idHash[volNode.target.record()] = true)
- });
+
+ 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 {
findOrCreateCopyNode(copy: IdlObject): HoldingsTreeNode {
-
const volNode = this.findOrCreateVolNode(copy.call_number());
const existing = volNode.children.filter(
return node;
}
-
sortHoldings() {
this.orgNodes().forEach(orgNode => {
o1.target.shortname() < o2.target.shortname() ? -1 : 1);
}
- // Sorted list of holdings tree nodes
- /*
- flattenHoldings(): HoldingsTreeNode[] {
- this.sortHoldings();
- let nodes: HoldingsTreeNode[] = [];
+ isSaveable(): boolean {
+ const dupeBc = this.copyList().filter(c => c._dupe_barcode).length;
- this.orgNodes().forEach(orgNode => {
- nodes.push(orgNode);
- orgNode.children.forEach(volNode => {
- nodes.push(volNode);
- nodes = nodes.concat(volNode.children);
- });
- });
+ if (dupeBc) { return false; }
- return nodes;
+ return true;
}
- */
}
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]);
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()
spawnAddHoldingsUi(
recordId: number, // Bib record ID
addToCallNums?: number[], // Add copies to / modify existing CNs
- callNumData?: NewCallNumData[], // Creating new call numbers
+ callNumData?: CallNumData[], // Creating new call numbers
hideCopies?: boolean) { // Hide the copy edit pane
const raw: any[] = [];
return;
}
setTimeout(() => {
- const url = `/eg/staff/cat/volcopy/${key}`;
+ const url = `/eg2/staff/cat/volcopy/edit/session/${key}`;
window.open(url, '_blank');
});
});
}
).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!');