1 import {Component, Input, OnInit, AfterViewInit, ViewChild,
2 EventEmitter, Output, QueryList, ViewChildren} from '@angular/core';
3 import {Router, ActivatedRoute, ParamMap} from '@angular/router';
4 import {SafeUrl} from '@angular/platform-browser';
5 import {tap} from 'rxjs/operators';
6 import {IdlObject, IdlService} from '@eg/core/idl.service';
7 import {EventService} from '@eg/core/event.service';
8 import {OrgService} from '@eg/core/org.service';
9 import {StoreService} from '@eg/core/store.service';
10 import {NetService} from '@eg/core/net.service';
11 import {AuthService} from '@eg/core/auth.service';
12 import {PcrudService} from '@eg/core/pcrud.service';
13 import {HoldingsService} from '@eg/staff/share/holdings/holdings.service';
14 import {VolCopyContext} from './volcopy';
15 import {VolCopyService} from './volcopy.service';
16 import {FormatService} from '@eg/core/format.service';
17 import {StringComponent} from '@eg/share/string/string.component';
18 import {CopyAlertsDialogComponent
19 } from '@eg/staff/share/holdings/copy-alerts-dialog.component';
20 import {CopyTagsDialogComponent
21 } from '@eg/staff/share/holdings/copy-tags-dialog.component';
22 import {CopyNotesDialogComponent
23 } from '@eg/staff/share/holdings/copy-notes-dialog.component';
24 import {ComboboxComponent, ComboboxEntry} from '@eg/share/combobox/combobox.component';
25 import {BatchItemAttrComponent, BatchChangeSelection
26 } from '@eg/staff/share/holdings/batch-item-attr.component';
27 import {FileExportService} from '@eg/share/util/file-export.service';
30 selector: 'eg-copy-attrs',
31 templateUrl: 'copy-attrs.component.html',
33 // Match the header of the batch attrs component
35 `.batch-header {background-color: #EBF4FA;}`,
36 `.template-row {background-color: #EBF4FA;}`
39 export class CopyAttrsComponent implements OnInit, AfterViewInit {
41 @Input() context: VolCopyContext;
43 // Batch values applied from the form.
44 // Some values are scalar, some IdlObjects depending on copy fleshyness.
45 values: {[field: string]: any} = {};
47 // Map of stat ID to entry ID.
48 statCatValues: {[statId: number]: number} = {};
50 loanDurationLabelMap: {[level: number]: string} = {};
51 fineLevelLabelMap: {[level: number]: string} = {};
53 statCatFilter: number;
55 @ViewChild('loanDurationShort', {static: false})
56 loanDurationShort: StringComponent;
57 @ViewChild('loanDurationNormal', {static: false})
58 loanDurationNormal: StringComponent;
59 @ViewChild('loanDurationLong', {static: false})
60 loanDurationLong: StringComponent;
62 @ViewChild('fineLevelLow', {static: false})
63 fineLevelLow: StringComponent;
64 @ViewChild('fineLevelNormal', {static: false})
65 fineLevelNormal: StringComponent;
66 @ViewChild('fineLevelHigh', {static: false})
67 fineLevelHigh: StringComponent;
69 @ViewChild('mintConditionYes', {static: false})
70 mintConditionYes: StringComponent;
71 @ViewChild('mintConditionNo', {static: false})
72 mintConditionNo: StringComponent;
74 @ViewChild('copyAlertsDialog', {static: false})
75 private copyAlertsDialog: CopyAlertsDialogComponent;
77 @ViewChild('copyTagsDialog', {static: false})
78 private copyTagsDialog: CopyTagsDialogComponent;
80 @ViewChild('copyNotesDialog', {static: false})
81 private copyNotesDialog: CopyNotesDialogComponent;
83 @ViewChild('copyTemplateCbox', {static: false})
84 copyTemplateCbox: ComboboxComponent;
86 @ViewChildren(BatchItemAttrComponent)
87 batchAttrs: QueryList<BatchItemAttrComponent>;
89 // Emitted when the save-ability of this form changes.
90 @Output() canSaveChange: EventEmitter<boolean> = new EventEmitter<boolean>();
93 private router: Router,
94 private route: ActivatedRoute,
95 private evt: EventService,
96 private idl: IdlService,
97 private org: OrgService,
98 private net: NetService,
99 private auth: AuthService,
100 private pcrud: PcrudService,
101 private holdings: HoldingsService,
102 private format: FormatService,
103 private store: StoreService,
104 private fileExport: FileExportService,
105 public volcopy: VolCopyService
109 this.statCatFilter = this.volcopy.defaults.values.statcat_filter;
114 const tmpl = this.store.getLocalItem('cat.copy.last_template');
116 // avoid Express Changed warning w/ timeout
117 setTimeout(() => this.copyTemplateCbox.selectedId = tmpl);
120 this.loanDurationLabelMap[1] = this.loanDurationShort.text;
121 this.loanDurationLabelMap[2] = this.loanDurationNormal.text;
122 this.loanDurationLabelMap[3] = this.loanDurationLong.text;
124 this.fineLevelLabelMap[1] = this.fineLevelLow.text;
125 this.fineLevelLabelMap[2] = this.fineLevelNormal.text;
126 this.fineLevelLabelMap[3] = this.fineLevelHigh.text;
130 statCats(): IdlObject[] {
131 if (this.statCatFilter) {
132 const orgs = this.org.descendants(this.statCatFilter, true);
134 return this.volcopy.commonData.acp_stat_cat.filter(
135 sc => orgs.includes(sc.owner()));
139 return this.volcopy.commonData.acp_stat_cat;
144 orgSn(orgId: number): string {
145 return orgId ? this.org.get(orgId).shortname() : '';
148 statCatCounts(catId: number): {[value: string]: number} {
149 catId = Number(catId);
152 this.context.copyList().forEach(copy => {
153 const entry = copy.stat_cat_entries()
154 .filter(e => e.stat_cat() === catId)[0];
158 if (this.volcopy.statCatEntryMap[entry.id()]) {
159 value = this.volcopy.statCatEntryMap[entry.id()].value();
161 // Map to a remote stat cat. Ignore.
166 if (counts[value] === undefined) {
175 itemAttrCounts(field: string): {[value: string]: number} {
178 this.context.copyList().forEach(copy => {
179 const value = this.getFieldDisplayValue(field, copy);
181 if (counts[value] === undefined) {
190 getFieldDisplayValue(field: string, copy: IdlObject): string {
192 // Some fields don't live directly on the copy.
193 if (field === 'owning_lib') {
195 copy.call_number().owning_lib()).shortname() +
196 ' : ' + copy.call_number().label();
199 const value = copy[field]();
201 if (!value && value !== 0) { return ''; }
206 return this.volcopy.copyStatuses[value].name();
209 return value.name() +
210 ' (' + this.org.get(value.owning_lib()).shortname() + ')';
215 return this.format.transform(
216 {datatype: 'timestamp', value: value});
220 return value.usrname();
223 return this.org.get(value).shortname();
226 const rule = this.volcopy.commonData.acp_age_protect.filter(
227 r => r.id() === Number(value))[0];
228 return rule ? rule.name() : '';
231 const grp = this.volcopy.commonData.acp_floating_group.filter(
232 g => g.id() === Number(value))[0];
233 return grp ? grp.name() : '';
235 case 'loan_duration':
236 return this.loanDurationLabelMap[value];
239 return this.fineLevelLabelMap[value];
242 const map = this.volcopy.commonData.acp_item_type_map.filter(
243 m => m.code() === value)[0];
244 return map ? map.value() : '';
246 case 'circ_modifier':
247 const mod = this.volcopy.commonData.acp_circ_modifier.filter(
248 m => m.code() === value)[0];
249 return mod ? mod.name() : '';
251 case 'mint_condition':
252 if (!this.mintConditionYes) { return ''; }
253 return value === 't' ?
254 this.mintConditionYes.text : this.mintConditionNo.text;
260 copyWantsChange(copy: IdlObject, field: string,
261 changeSelection: BatchChangeSelection): boolean {
262 const disValue = this.getFieldDisplayValue(field, copy);
263 return changeSelection[disValue] === true;
266 applyCopyValue(field: string, value?: any, changeSelection?: BatchChangeSelection) {
267 if (value === undefined) {
268 value = this.values[field];
270 this.values[field] = value;
273 if (field === 'owning_lib') {
274 this.owningLibChanged(value, changeSelection);
278 this.context.copyList().forEach(copy => {
279 if (!copy[field] || copy[field]() === value) { return; }
281 // Change selection indicates which items should be modified
282 // based on the display value for the selected field at
284 if (changeSelection &&
285 !this.copyWantsChange(copy, field, changeSelection)) {
290 copy.ischanged(true);
294 this.emitSaveChange();
297 owningLibChanged(orgId: number, changeSelection?: BatchChangeSelection) {
298 if (!orgId) { return; }
300 // Map existing vol IDs to their replacments.
301 const newVols: any = {};
303 this.context.copyList().forEach(copy => {
305 if (changeSelection &&
306 !this.copyWantsChange(copy, 'owning_lib', changeSelection)) {
310 // Change the copy circ lib to match the new owning lib
311 // if configured to do so.
312 if (this.volcopy.defaults.values.circ_lib_mod_with_owning_lib) {
313 if (copy.circ_lib() !== orgId) {
314 copy.circ_lib(orgId);
315 copy.ischanged(true);
318 .filter(ba => ba.name === 'circ_lib')
319 .forEach(attr => attr.hasChanged = true);
323 const vol = copy.call_number();
325 if (vol.owning_lib() === orgId) { return; } // No change needed
328 if (newVols[vol.id()]) {
329 newVol = newVols[vol.id()];
333 // The open-ils.cat.asset.volume.fleshed.batch.update API
334 // will use the existing volume when trying to create a
335 // new volume with the same parameters as an existing volume.
336 newVol = this.idl.clone(vol);
337 newVol.owning_lib(orgId);
338 newVol.id(this.volcopy.autoId--);
340 newVols[vol.id()] = newVol;
343 copy.call_number(newVol);
344 copy.ischanged(true);
346 this.context.removeCopyNode(copy.id());
347 this.context.findOrCreateCopyNode(copy);
350 // If any of the above actions results in an empty volume
351 // remove it from the tree. Note this does not delete the
352 // volume at the server, since other items could be attached
353 // of which this instance of the editor is not aware.
354 Object.keys(newVols).forEach(volId => {
356 const volNode = this.context.volNodes().filter(
357 node => node.target.id() === +volId)[0];
359 if (volNode && volNode.children.length === 0) {
360 this.context.removeVolNode(+volId);
365 // Create or modify a stat cat entry for each copy that does not
366 // already match the new value.
367 statCatChanged(catId: number, clear?: boolean) {
368 catId = Number(catId);
370 const entryId = this.statCatValues[catId];
372 if (!clear && (!entryId || !this.volcopy.statCatEntryMap[entryId])) {
374 `Attempt to apply stat cat value which does not exist.
375 This is likely the result of a stale copy template.
376 stat_cat=${catId} entry=${entryId}`);
381 this.context.copyList().forEach(copy => {
383 let entry = copy.stat_cat_entries()
384 .filter(e => e.stat_cat() === catId)[0];
389 // Removing the entry map (and setting copy.ishanged) is
390 // enough to tell the API to delete it.
392 copy.stat_cat_entries(copy.stat_cat_entries()
393 .filter(e => e.stat_cat() !== catId));
399 if (entry.id() === entryId) {
400 // Requested mapping already exists.
405 // Copy has no entry for this stat cat yet.
406 entry = this.idl.create('asce');
407 entry.stat_cat(catId);
408 copy.stat_cat_entries().push(entry);
412 entry.value(this.volcopy.statCatEntryMap[entryId].value());
415 copy.ischanged(true);
418 this.emitSaveChange();
422 this.copyAlertsDialog.inPlaceCreateMode = true;
423 this.copyAlertsDialog.copyIds = this.context.copyList().map(c => c.id());
425 this.copyAlertsDialog.open({size: 'lg'}).subscribe(changes => {
426 if (!changes) { return; }
428 if ((!changes.newAlerts || changes.newAlerts.length === 0) &&
429 (!changes.changedAlerts || changes.changedAlerts.length === 0)
434 if (changes.newAlerts) {
435 this.context.copyList().forEach(copy => {
436 changes.newAlerts.forEach(newAlert => {
437 const a = this.idl.clone(newAlert);
440 if (!copy.copy_alerts()) { copy.copy_alerts([]); }
441 copy.copy_alerts().push(a);
442 copy.ischanged(true);
446 if (changes.changedAlerts && this.context.copyList().length === 1) {
447 const copy = this.context.copyList()[0];
448 changes.changedAlerts.forEach(alert => {
449 const existing = copy.copy_alerts().filter(a => a.id() === alert.id())[0];
451 existing.ischanged(true);
452 existing.alert_type(alert.alert_type());
453 existing.temp(alert.temp());
454 existing.ack_time(alert.ack_time());
455 if (alert.ack_time() === 'now') {
456 existing.ack_staff(this.auth.user().id());
458 copy.ischanged(true);
466 this.copyTagsDialog.inPlaceCreateMode = true;
467 this.copyTagsDialog.copyIds = this.context.copyList().map(c => c.id());
469 this.copyTagsDialog.open({size: 'lg'}).subscribe(changes => {
470 if ((!changes.newTags || changes.newTags.length === 0) &&
471 (!changes.deletedMaps || changes.deletedMaps.length === 0)) {
475 changes.newTags.forEach(tag => {
476 this.context.copyList().forEach(copy => {
478 if (copy.tags().filter(
479 m => m.tag() === tag.id()).length > 0) {
480 return; // map already exists
483 const map = this.idl.create('acptcm');
488 copy.tags().push(map);
489 copy.ischanged(true);
493 if (this.context.copyList().length === 1) {
494 const copy = this.context.copyList()[0];
495 changes.deletedMaps.forEach(tag => {
496 const existing = copy.tags().filter(t => t.id() === tag.id())[0];
498 existing.isdeleted(true);
499 copy.ischanged(true);
507 this.copyNotesDialog.inPlaceCreateMode = true;
508 this.copyNotesDialog.copyIds = this.context.copyList().map(c => c.id());
510 this.copyNotesDialog.open({size: 'lg'}).subscribe(changes => {
511 if (!changes) { return; }
513 if ((!changes.newNotes || changes.newNotes.length === 0) &&
514 (!changes.delNotes || changes.delNotes.length === 0)
519 changes.newNotes.forEach(note => {
520 this.context.copyList().forEach(copy => {
521 const n = this.idl.clone(note);
522 n.owning_copy(copy.id());
523 copy.notes().push(n);
524 copy.ischanged(true);
527 if (this.context.copyList().length === 1) {
528 const copy = this.context.copyList()[0];
529 changes.delNotes.forEach(note => {
530 const existing = copy.notes().filter(n => n.id() === note.id())[0];
532 existing.isdeleted(true);
533 copy.ischanged(true);
541 const entry = this.copyTemplateCbox.selected;
542 if (!entry) { return; }
544 this.store.setLocalItem('cat.copy.last_template', entry.id);
546 const template = this.volcopy.templates[entry.id];
548 Object.keys(template).forEach(field => {
549 const value = template[field];
551 if (value === null || value === undefined) { return; }
553 if (field === 'statcats') {
554 Object.keys(value).forEach(catId => {
555 if (value[+catId] !== null) {
556 this.statCatValues[+catId] = value[+catId];
557 this.statCatChanged(+catId);
563 // Copy alerts are stored as hashes of the bits we need.
564 // Templates can be used to create alerts, but not edit them.
565 if (field === 'copy_alerts' && Array.isArray(value)) {
567 this.context.copyList().forEach(copy => {
568 const newAlert = this.idl.create('aca');
569 newAlert.isnew(true);
570 newAlert.copy(copy.id());
571 newAlert.alert_type(a.alert_type);
572 newAlert.temp(a.temp);
573 newAlert.note(a.note);
574 newAlert.create_staff(this.auth.user().id());
575 newAlert.create_time('now');
577 if (Array.isArray(copy.copy_alerts())) {
578 copy.copy_alerts().push(newAlert);
580 copy.copy_alerts([newAlert]);
583 copy.ischanged(true);
590 // In some cases, we may have to fetch the data since
591 // the local code assumes copy field is fleshed.
592 let promise = Promise.resolve(value);
594 if (field === 'location') {
595 // May be a 'remote' location. Fetch as needed.
596 promise = this.volcopy.getLocation(value);
599 promise.then(val => {
600 this.applyCopyValue(field, val);
602 // Indicate in the form these values have changed
604 .filter(ba => ba.name === field)
605 .forEach(attr => attr.hasChanged = true);
611 const entry: ComboboxEntry = this.copyTemplateCbox.selected;
612 if (!entry) { return; }
617 if (entry.freetext) {
619 // freetext entries don't have an ID, but we may need one later.
620 entry.id = entry.label;
624 template = this.volcopy.templates[name];
627 this.batchAttrs.forEach(comp => {
628 if (!comp.hasChanged) { return; }
630 const field = comp.name;
631 const value = this.values[field];
633 if (value === null) {
634 delete template[field];
638 if (field.match(/stat_cat_/)) {
639 const statId = field.match(/stat_cat_(\d+)/)[1];
640 if (!template.statcats) { template.statcats = {}; }
642 template.statcats[statId] = value;
646 // Some values are fleshed. this assumes fleshed objects
647 // have an 'id' value, which is true so far.
649 typeof value === 'object' ? value.id() : value;
653 this.volcopy.templates[name] = template;
654 this.volcopy.saveTemplates();
657 exportTemplate($event) {
658 if (this.fileExport.inProgress()) { return; }
660 this.fileExport.exportFile(
661 $event, JSON.stringify(this.volcopy.templates), 'text/json');
664 importTemplate($event) {
665 const file: File = $event.target.files[0];
666 if (!file) { return; }
668 const reader = new FileReader();
670 reader.addEventListener('load', () => {
673 const template = JSON.parse(reader.result as string);
674 const name = Object.keys(template)[0];
675 this.volcopy.templates[name] = template[name];
677 console.error('Invalid Item Attribute template', E);
681 this.volcopy.saveTemplates();
682 // Adds the new one to the list and re-sorts the labels.
683 this.volcopy.fetchTemplates();
686 reader.readAsText(file);
689 // Returns null when no export is in progress.
690 exportTemplateUrl(): SafeUrl {
691 return this.fileExport.safeUrl;
695 const entry: ComboboxEntry = this.copyTemplateCbox.selected;
696 if (!entry) { return; }
697 delete this.volcopy.templates[entry.id];
698 this.volcopy.saveTemplates();
699 this.copyTemplateCbox.selected = null;
702 displayAttr(field: string): boolean {
703 return this.volcopy.defaults.hidden[field] !== true;
706 copyFieldLabel(field: string): string {
707 const def = this.idl.classes.acp.field_map[field];
708 return def ? def.label : '';
711 // Returns false if any items are in magic statuses
712 statusEditable(): boolean {
713 const copies = this.context.copyList();
714 for (let idx = 0; idx < copies.length; idx++) {
715 if (this.volcopy.copyStatIsMagic(copies[idx].status())) {
722 // Called any time a change occurs that could affect the
723 // save-ability of the form.
726 const canSave = this.batchAttrs.filter(
727 attr => attr.warnOnRequired()).length === 0;
729 this.canSaveChange.emit(canSave);
733 // True if one of our batch editors has been put into edit
734 // mode and left there without an Apply, Cancel, or Clear
735 hasActiveInput(): boolean {
736 return this.batchAttrs.filter(attr => attr.editing).length > 0;
739 applyPendingChanges() {
740 // If a user has left any changes in the 'editing' state, this
741 // will go through and apply the values so they do not have to
742 // click Apply for every one.
743 this.batchAttrs.filter(attr => attr.editing).forEach(attr => attr.save());
746 copyLocationOrgs(): number[] {
747 if (!this.context) { return []; }
749 // Make sure every org unit represented by the edit batch
751 const ids = this.context.orgNodes().map(n => n.target.id());
753 // Make sure all locations within the "full path" of our
754 // workstation org unit are included.
755 return ids.concat(this.org.fullPath(this.auth.user().ws_ou()));