From: Bill Erickson Date: Thu, 4 Jun 2020 16:36:09 +0000 (-0400) Subject: LP1888723 Angular Holdings Maintenance / Item Attributes Editor X-Git-Url: https://old-git.evergreen-ils.org/?a=commitdiff_plain;h=e089f5d284c4f3b8746eb3e1198c399f60cd44d4;p=evergreen%2Ftadl.git LP1888723 Angular Holdings Maintenance / Item Attributes Editor Angular port of the holdings and item attributes editors interfaces. Signed-off-by: Bill Erickson Signed-off-by: Ruth Frasur Signed-off-by: Galen Charlton --- diff --git a/Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.html b/Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.html index 021e451ecc..ed86303436 100644 --- a/Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.html +++ b/Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.html @@ -1,7 +1,19 @@ - - {{joiner}} - + + + + {{joiner}} + + + + + + + + {{joiner}} + + diff --git a/Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.ts b/Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.ts index abcbb4630a..83176d9995 100644 --- a/Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.ts +++ b/Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.ts @@ -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() {} diff --git a/Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts b/Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts index 798f5491ed..1c861fa40c 100644 --- a/Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts +++ b/Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts @@ -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) { diff --git a/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.html b/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.html index e3f750990e..deb7464be3 100644 --- a/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.html +++ b/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.html @@ -14,7 +14,11 @@
; @@ -56,6 +57,8 @@ export class ComboboxComponent implements ControlValueAccessor, OnInit, AfterVie @ViewChild('defaultDisplayTemplate', { static: true}) defaultDisplayTemplate: TemplateRef; @ViewChildren(IdlClassTemplateDirective) idlClassTemplates: QueryList; + @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) { diff --git a/Open-ILS/src/eg2/src/app/share/item-location-select/item-location-select.component.html b/Open-ILS/src/eg2/src/app/share/item-location-select/item-location-select.component.html index 99bd3906f9..5f7e388a6a 100644 --- a/Open-ILS/src/eg2/src/app/share/item-location-select/item-location-select.component.html +++ b/Open-ILS/src/eg2/src/app/share/item-location-select/item-location-select.component.html @@ -7,6 +7,7 @@ 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 index 0000000000..22e75b1713 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/config.component.html @@ -0,0 +1,552 @@ +
+

Holdings Preferences

+
+
Changes are saved automatically.
+
+ +
+
+
+
+
+
Holdings Display Preferences
+
    +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • + +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
+
+
+
+
+
+
+
+
+
Holdings Creation Defaults
+
    +
  • +
    +
    + +
    +
    + + + + +
    +
    +
  • +
  • +
    +
    + +
    +
    + + + + + + +
    +
    +
  • +
  • +
    +
    + +
    +
    + + + + + + +
    +
    +
  • +
+
+
+
+
+
+ +
+

Item Attribute Settings

+ +
+
+
+
Item Attributes Behavior
+
    +
  • +
    + + +
    +
  • +
  • +
    + + + +
    +
  • +
+
+
+
+ + +
+ +

Hide Item Attributes

+ + Selected Fields Will be Hidden + from the Item Attributes Form. + + +
+ + +
+
+
Identification
+
    +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
+
+
+ + +
+
+
Location
+
    +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
+
+
+ + + +
+
+
Circulation
+
    +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
+
+
+ + + +
+
+
Miscellaneous
+
    +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
+
+
+ + + +
+
+
Statistics
+
    +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
  • +
    + + +
    +
  • +
+
+
+
+ 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 index 0000000000..f5d648f5b0 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/config.component.ts @@ -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 index 0000000000..af6c51eb3d --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/copy-attrs.component.html @@ -0,0 +1,473 @@ + + + + + + + + + + + + + + + + + + + +
+
Templates:
+
+ + +
+
+ + + + + + + + + + +
+ +
+
+ + +
+ + +
+

Identification

+ + +
+ + + + + + + + + + + + + + + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+

Location

+ +
+ + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + + +
+ +
+ + + + + +
+
+ + + +
+

Circulation

+ +
+ + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + + + +
+ +
+ + + + + + + + +
+ +
+ + + + + + + + + +
+ +
+ + + + + + + + + +
+ +
+ + + + + + + + +
+ +
+ + + + + +
+ +
+ + + +
+

Miscellaneous

+ + + +
+ +
Add Item Alerts
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + +
+ +
+ + + + + + + + +
+ +
+ + +
+

Statistics

+ +
+ +
Add Item Tags
+
+ +
+
+ +
+
Stat Cat Filter
+
+ + +
+
+ + +
+ + + + + + + + +
+
+
+
+ + 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 index 0000000000..8284840a8d --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/copy-attrs.component.ts @@ -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; + + // Emitted when the save-ability of this form changes. + @Output() canSaveChange: EventEmitter = new EventEmitter(); + + 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 index 0000000000..c5d3f67d2d --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/routing.module.ts @@ -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 index 0000000000..de37f46a49 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/vol-edit.component.css @@ -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 index 0000000000..6f3db70df5 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/vol-edit.component.html @@ -0,0 +1,424 @@ + + + + + + +
+
+
+
+
+
+ + + + +
+
+
+
+
+ + + + +
+
+
+
+ +
+
+ + + + +
+
+
+
+
+ + + + +
+
+
+
+
+ +
+
+
+ +
+ +
+
+
+ +
+
+ + +
+
+
+
+ + + +
+
+ Owning Library + + + + + + + +
+
+ +
+
+ Classification + + + + + + + +
+
+ Prefix + + + + + + + +
+
+ Call Number Label + + + + + + + +
+
+ Suffix + + + + + + + +
+
+ +
+ +
+ Barcode + + + + + + + +
+
+ +
+
+ Part + + + + + + + +
+
+ + + + +
+
+ + {{orgNode.target.shortname()}} + {{sessionType}} + + + + +
+
+ + + +
+
+ + + + + + +
+
+ + + + + + + + +
+
+ + + +
+
+ + + + + + + + +
+
+ + + +
+
+
+ + + + + + +
+
+ Duplicate Barcode +
+
+
+ +
+
+ + + + + + + + + +
+
+
+
+
+ +
+ +
+ +
+ + +
+ +
+ +
+ +
+
+
+ +
+ + + +
+ +
+ +
+ + +
+
+
+
+ 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 index 0000000000..c8aa4d0669 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/vol-edit.component.ts @@ -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 = new EventEmitter(); + + 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 { + + 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 index 0000000000..be7ff284c8 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.component.html @@ -0,0 +1,84 @@ + + +
+
+ Holdings Editor Session Expired +
+
+ + + + + +
+ + +
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ +
+ + +
+
+ +
+
+
+ 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 index 0000000000..f782effb89 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.component.ts @@ -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 { + 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 { + + 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 { + + 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 { + + 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 { + + 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 { + + 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 { + 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 { + 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 { + 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 { + 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 = 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 { + + 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 { + 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 index 0000000000..5c4289de57 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.module.ts @@ -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 index 0000000000..891a46c61c --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.service.ts @@ -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 { + + 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 { + 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 { + + // 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 { + this.store.setLocalItem('cat.copy.templates', this.templates); + // Re-sort, etc. + return this.fetchTemplates(); + } + + fetchDefaults(): Promise { + 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 { + 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 { + + 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 { + + 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 { + + 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 { + + 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 { + + // 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 index 0000000000..c76e78659c --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.ts @@ -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; + } +} diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.html index 5f3ec56743..48bece53cb 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.html +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.html @@ -47,6 +47,7 @@ + @@ -121,6 +122,11 @@ + + + diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.ts index 2a58ef605c..8ba0ccb86d 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.ts @@ -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; } diff --git a/Open-ILS/src/eg2/src/app/staff/common.module.ts b/Open-ILS/src/eg2/src/app/staff/common.module.ts index feb159d985..7ddef79691 100644 --- a/Open-ILS/src/eg2/src/app/staff/common.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/common.module.ts @@ -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 ] }; } diff --git a/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts index 874e76933e..396b431805 100644 --- a/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts @@ -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]); diff --git a/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.html b/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.html index a325481dfa..171c696e04 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.html +++ b/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.html @@ -51,7 +51,8 @@
Title:
- +
Edition:
diff --git a/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.ts b/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.ts index 3a09fd83d5..4c0564d8fd 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.ts @@ -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 { + 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 { + 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 index 0000000000..64c985fad6 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/batch-item-attr.component.html @@ -0,0 +1,68 @@ + +
+
+ {{label}} * + +
+ + unfold_more + + + unfold_less + +
+
+
+
+ + ... + + + +
+ +
+
+
+ + + <Unset> + + + Yes + No + + + + + <Unset> + + {{count.key | currency}} + + + + <Unset> + + {{count.key}} + +
+
{{count.value}} copies
+
+
+
+ + +
+ + + +
+
+
+ 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 index 0000000000..7a33d0d7a8 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/batch-item-attr.component.ts @@ -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; + + @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 = + new EventEmitter(); + + @Output() changesCanceled: EventEmitter = new EventEmitter(); + @Output() valueCleared: EventEmitter = new EventEmitter(); + + // 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(); } + }); + } + } +} + + + diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-dialog.component.html index 25fe9195d8..5ccf7ff28d 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-dialog.component.html +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-dialog.component.html @@ -5,7 +5,7 @@