]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/eg2/src/app/staff/cat/volcopy/copy-attrs.component.ts
LP#1956986: fix and refactor item alert handing in Angular editor
[Evergreen.git] / Open-ILS / src / eg2 / src / app / staff / cat / volcopy / copy-attrs.component.ts
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';
28
29 @Component({
30   selector: 'eg-copy-attrs',
31   templateUrl: 'copy-attrs.component.html',
32
33   // Match the header of the batch attrs component
34   styles: [
35     `.batch-header {background-color: #EBF4FA;}`,
36     `.template-row {background-color: #EBF4FA;}`
37   ]
38 })
39 export class CopyAttrsComponent implements OnInit, AfterViewInit {
40
41     @Input() context: VolCopyContext;
42
43     // Batch values applied from the form.
44     // Some values are scalar, some IdlObjects depending on copy fleshyness.
45     values: {[field: string]: any} = {};
46
47     // Map of stat ID to entry ID.
48     statCatValues: {[statId: number]: number} = {};
49
50     loanDurationLabelMap: {[level: number]: string} = {};
51     fineLevelLabelMap: {[level: number]: string} = {};
52
53     statCatFilter: number;
54
55     @ViewChild('loanDurationShort', {static: false})
56         loanDurationShort: StringComponent;
57     @ViewChild('loanDurationNormal', {static: false})
58         loanDurationNormal: StringComponent;
59     @ViewChild('loanDurationLong', {static: false})
60         loanDurationLong: StringComponent;
61
62     @ViewChild('fineLevelLow', {static: false})
63         fineLevelLow: StringComponent;
64     @ViewChild('fineLevelNormal', {static: false})
65         fineLevelNormal: StringComponent;
66     @ViewChild('fineLevelHigh', {static: false})
67         fineLevelHigh: StringComponent;
68
69     @ViewChild('mintConditionYes', {static: false})
70         mintConditionYes: StringComponent;
71     @ViewChild('mintConditionNo', {static: false})
72         mintConditionNo: StringComponent;
73
74     @ViewChild('copyAlertsDialog', {static: false})
75         private copyAlertsDialog: CopyAlertsDialogComponent;
76
77     @ViewChild('copyTagsDialog', {static: false})
78         private copyTagsDialog: CopyTagsDialogComponent;
79
80     @ViewChild('copyNotesDialog', {static: false})
81         private copyNotesDialog: CopyNotesDialogComponent;
82
83     @ViewChild('copyTemplateCbox', {static: false})
84         copyTemplateCbox: ComboboxComponent;
85
86     @ViewChildren(BatchItemAttrComponent)
87         batchAttrs: QueryList<BatchItemAttrComponent>;
88
89     // Emitted when the save-ability of this form changes.
90     @Output() canSaveChange: EventEmitter<boolean> = new EventEmitter<boolean>();
91
92     constructor(
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
106     ) { }
107
108     ngOnInit() {
109         this.statCatFilter = this.volcopy.defaults.values.statcat_filter;
110     }
111
112     ngAfterViewInit() {
113
114         const tmpl = this.store.getLocalItem('cat.copy.last_template');
115         if (tmpl) {
116             // avoid Express Changed warning w/ timeout
117             setTimeout(() => this.copyTemplateCbox.selectedId = tmpl);
118         }
119
120         this.loanDurationLabelMap[1] = this.loanDurationShort.text;
121         this.loanDurationLabelMap[2] = this.loanDurationNormal.text;
122         this.loanDurationLabelMap[3] = this.loanDurationLong.text;
123
124         this.fineLevelLabelMap[1] = this.fineLevelLow.text;
125         this.fineLevelLabelMap[2] = this.fineLevelNormal.text;
126         this.fineLevelLabelMap[3] = this.fineLevelHigh.text;
127
128     }
129
130     statCats(): IdlObject[] {
131         if (this.statCatFilter) {
132             const orgs = this.org.descendants(this.statCatFilter, true);
133
134             return this.volcopy.commonData.acp_stat_cat.filter(
135                 sc => orgs.includes(sc.owner()));
136
137         } else {
138
139             return this.volcopy.commonData.acp_stat_cat;
140         }
141     }
142
143
144     orgSn(orgId: number): string {
145         return orgId ? this.org.get(orgId).shortname() : '';
146     }
147
148     statCatCounts(catId: number): {[value: string]: number} {
149         catId = Number(catId);
150         const counts = {};
151
152         this.context.copyList().forEach(copy => {
153             const entry = copy.stat_cat_entries()
154                 .filter(e => e.stat_cat() === catId)[0];
155
156             let value = '';
157             if (entry) {
158                 if (this.volcopy.statCatEntryMap[entry.id()]) {
159                     value = this.volcopy.statCatEntryMap[entry.id()].value();
160                 } else {
161                     // Map to a remote stat cat.  Ignore.
162                     return;
163                 }
164             }
165
166             if (counts[value] === undefined) {
167                 counts[value] = 0;
168             }
169             counts[value]++;
170         });
171
172         return counts;
173     }
174
175     itemAttrCounts(field: string): {[value: string]: number} {
176
177         const counts = {};
178         this.context.copyList().forEach(copy => {
179             const value = this.getFieldDisplayValue(field, copy);
180
181             if (counts[value] === undefined) {
182                 counts[value] = 0;
183             }
184             counts[value]++;
185         });
186
187         return counts;
188     }
189
190     getFieldDisplayValue(field: string, copy: IdlObject): string {
191
192         // Some fields don't live directly on the copy.
193         if (field === 'owning_lib') {
194             return this.org.get(
195                 copy.call_number().owning_lib()).shortname() +
196                 ' : ' + copy.call_number().label();
197         }
198
199         const value = copy[field]();
200
201         if (!value && value !== 0) { return ''; }
202
203         switch (field) {
204
205             case 'status':
206                 return this.volcopy.copyStatuses[value].name();
207
208             case 'location':
209                 return value.name() +
210                     ' (' + this.org.get(value.owning_lib()).shortname() + ')';
211
212             case 'edit_date':
213             case 'create_date':
214             case 'active_date':
215                 return this.format.transform(
216                     {datatype: 'timestamp', value: value});
217
218             case 'editor':
219             case 'creator':
220                 return value.usrname();
221
222             case 'circ_lib':
223                 return this.org.get(value).shortname();
224
225             case 'age_protect':
226                 const rule = this.volcopy.commonData.acp_age_protect.filter(
227                     r => r.id() === Number(value))[0];
228                 return rule ? rule.name() : '';
229
230             case 'floating':
231                 const grp = this.volcopy.commonData.acp_floating_group.filter(
232                     g => g.id() === Number(value))[0];
233                 return grp ? grp.name() : '';
234
235             case 'loan_duration':
236                 return this.loanDurationLabelMap[value];
237
238             case 'fine_level':
239                 return this.fineLevelLabelMap[value];
240
241             case 'circ_as_type':
242                 const map = this.volcopy.commonData.acp_item_type_map.filter(
243                     m => m.code() === value)[0];
244                 return map ? map.value() : '';
245
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() : '';
250
251             case 'mint_condition':
252                 if (!this.mintConditionYes) { return ''; }
253                 return value === 't' ?
254                     this.mintConditionYes.text : this.mintConditionNo.text;
255         }
256
257         return value;
258     }
259
260     copyWantsChange(copy: IdlObject, field: string,
261             changeSelection: BatchChangeSelection): boolean {
262         const disValue = this.getFieldDisplayValue(field, copy);
263         return changeSelection[disValue] === true;
264     }
265
266     applyCopyValue(field: string, value?: any, changeSelection?: BatchChangeSelection) {
267         if (value === undefined) {
268             value = this.values[field];
269         } else {
270             this.values[field] = value;
271         }
272
273         if (field === 'owning_lib') {
274             this.owningLibChanged(value, changeSelection);
275
276         } else {
277
278             this.context.copyList().forEach(copy => {
279                 if (!copy[field] || copy[field]() === value) { return; }
280
281                 // Change selection indicates which items should be modified
282                 // based on the display value for the selected field at
283                 // time of editing.
284                 if (changeSelection &&
285                     !this.copyWantsChange(copy, field, changeSelection)) {
286                     return;
287                 }
288
289                 copy[field](value);
290                 copy.ischanged(true);
291             });
292         }
293
294         this.emitSaveChange();
295     }
296
297     owningLibChanged(orgId: number, changeSelection?: BatchChangeSelection) {
298         if (!orgId) { return; }
299
300         // Map existing vol IDs to their replacments.
301         const newVols: any = {};
302
303         this.context.copyList().forEach(copy => {
304
305             if (changeSelection &&
306                 !this.copyWantsChange(copy, 'owning_lib', changeSelection)) {
307                 return;
308             }
309
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);
316
317                     this.batchAttrs
318                         .filter(ba => ba.name === 'circ_lib')
319                         .forEach(attr => attr.hasChanged = true);
320                 }
321             }
322
323             const vol = copy.call_number();
324
325             if (vol.owning_lib() === orgId) { return; } // No change needed
326
327             let newVol;
328             if (newVols[vol.id()]) {
329                 newVol = newVols[vol.id()];
330
331             } else {
332
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--);
339                 newVol.isnew(true);
340                 newVols[vol.id()] = newVol;
341             }
342
343             copy.call_number(newVol);
344             copy.ischanged(true);
345
346             this.context.removeCopyNode(copy.id());
347             this.context.findOrCreateCopyNode(copy);
348         });
349
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 => {
355
356             const volNode = this.context.volNodes().filter(
357                 node => node.target.id() === +volId)[0];
358
359             if (volNode && volNode.children.length === 0) {
360                 this.context.removeVolNode(+volId);
361             }
362         });
363     }
364
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);
369
370         const entryId = this.statCatValues[catId];
371
372         if (!clear && (!entryId || !this.volcopy.statCatEntryMap[entryId])) {
373             console.warn(
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}`);
377
378             return;
379         }
380
381         this.context.copyList().forEach(copy => {
382
383             let entry = copy.stat_cat_entries()
384                 .filter(e => e.stat_cat() === catId)[0];
385
386             if (clear) {
387
388                 if (entry) {
389                     // Removing the entry map (and setting copy.ishanged) is
390                     // enough to tell the API to delete it.
391
392                     copy.stat_cat_entries(copy.stat_cat_entries()
393                         .filter(e => e.stat_cat() !== catId));
394                 }
395
396             } else {
397
398                 if (entry) {
399                     if (entry.id() === entryId) {
400                         // Requested mapping already exists.
401                         return;
402                     }
403                 } else {
404
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);
409                 }
410
411                 entry.id(entryId);
412                 entry.value(this.volcopy.statCatEntryMap[entryId].value());
413             }
414
415             copy.ischanged(true);
416         });
417
418         this.emitSaveChange();
419     }
420
421     openCopyAlerts() {
422         this.copyAlertsDialog.inPlaceCreateMode = true;
423         this.copyAlertsDialog.copyIds = this.context.copyList().map(c => c.id());
424
425         this.copyAlertsDialog.open({size: 'lg'}).subscribe(changes => {
426             if (!changes) { return; }
427
428             if ((!changes.newAlerts || changes.newAlerts.length === 0) &&
429                 (!changes.changedAlerts || changes.changedAlerts.length === 0)
430                ) {
431                 return;
432             }
433
434             if (changes.newAlerts) {
435                 this.context.copyList().forEach(copy => {
436                     changes.newAlerts.forEach(newAlert => {
437                         const a = this.idl.clone(newAlert);
438                         a.isnew(true);
439                         a.copy(copy.id());
440                         if (!copy.copy_alerts()) { copy.copy_alerts([]); }
441                         copy.copy_alerts().push(a);
442                         copy.ischanged(true);
443                     });
444                 });
445             }
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];
450                     if (existing) {
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());
457                         }
458                         copy.ischanged(true);
459                     }
460                 });
461             }
462         });
463     }
464
465     openCopyTags() {
466         this.copyTagsDialog.inPlaceCreateMode = true;
467         this.copyTagsDialog.copyIds = this.context.copyList().map(c => c.id());
468
469         this.copyTagsDialog.open({size: 'lg'}).subscribe(changes => {
470             if ((!changes.newTags || changes.newTags.length === 0) &&
471                 (!changes.deletedMaps || changes.deletedMaps.length === 0)) {
472                 return;
473             }
474
475             changes.newTags.forEach(tag => {
476                 this.context.copyList().forEach(copy => {
477
478                     if (copy.tags().filter(
479                         m => m.tag() === tag.id()).length > 0) {
480                         return; // map already exists
481                     }
482
483                     const map = this.idl.create('acptcm');
484                     map.isnew(true);
485                     map.copy(copy.id());
486                     map.tag(tag);
487
488                     copy.tags().push(map);
489                     copy.ischanged(true);
490                 });
491             });
492
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];
497                     if (existing) {
498                         existing.isdeleted(true);
499                         copy.ischanged(true);
500                     }
501                 });
502             }
503         });
504     }
505
506     openCopyNotes() {
507         this.copyNotesDialog.inPlaceCreateMode = true;
508         this.copyNotesDialog.copyIds = this.context.copyList().map(c => c.id());
509
510         this.copyNotesDialog.open({size: 'lg'}).subscribe(changes => {
511             if (!changes) { return; }
512
513             if ((!changes.newNotes || changes.newNotes.length === 0) &&
514                 (!changes.delNotes || changes.delNotes.length === 0)
515                ) {
516                 return;
517             }
518
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);
525                 });
526             });
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];
531                     if (existing) {
532                         existing.isdeleted(true);
533                         copy.ischanged(true);
534                     }
535                 });
536             }
537         });
538     }
539
540     applyTemplate() {
541         const entry = this.copyTemplateCbox.selected;
542         if (!entry) { return; }
543
544         this.store.setLocalItem('cat.copy.last_template', entry.id);
545
546         const template = this.volcopy.templates[entry.id];
547
548         Object.keys(template).forEach(field => {
549             const value = template[field];
550
551             if (value === null || value === undefined) { return; }
552
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);
558                     }
559                 });
560                 return;
561             }
562
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)) {
566                 value.forEach(a => {
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');
576
577                         if (Array.isArray(copy.copy_alerts())) {
578                             copy.copy_alerts().push(newAlert);
579                         } else {
580                             copy.copy_alerts([newAlert]);
581                         }
582
583                         copy.ischanged(true);
584                     });
585                 });
586
587                 return;
588             }
589
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);
593
594             if (field === 'location') {
595                 // May be a 'remote' location.  Fetch as needed.
596                 promise = this.volcopy.getLocation(value);
597             }
598
599             promise.then(val => {
600                 this.applyCopyValue(field, val);
601
602                 // Indicate in the form these values have changed
603                 this.batchAttrs
604                     .filter(ba => ba.name === field)
605                     .forEach(attr => attr.hasChanged = true);
606             });
607         });
608     }
609
610     saveTemplate() {
611         const entry: ComboboxEntry = this.copyTemplateCbox.selected;
612         if (!entry) { return; }
613
614         let name;
615         let template;
616
617         if (entry.freetext) {
618             name = entry.label;
619             // freetext entries don't have an ID, but we may need one later.
620             entry.id = entry.label;
621             template = {};
622         } else {
623             name = entry.id;
624             template = this.volcopy.templates[name];
625         }
626
627         this.batchAttrs.forEach(comp => {
628             if (!comp.hasChanged) { return; }
629
630             const field = comp.name;
631             const value = this.values[field];
632
633             if (value === null) {
634                 delete template[field];
635                 return;
636             }
637
638             if (field.match(/stat_cat_/)) {
639                 const statId = field.match(/stat_cat_(\d+)/)[1];
640                 if (!template.statcats) { template.statcats = {}; }
641
642                 template.statcats[statId] = value;
643
644             } else {
645
646                 // Some values are fleshed. this assumes fleshed objects
647                 // have an 'id' value, which is true so far.
648                 template[field] =
649                     typeof value === 'object' ?  value.id() : value;
650             }
651         });
652
653         this.volcopy.templates[name] = template;
654         this.volcopy.saveTemplates();
655     }
656
657     exportTemplate($event) {
658         if (this.fileExport.inProgress()) { return; }
659
660         this.fileExport.exportFile(
661             $event, JSON.stringify(this.volcopy.templates), 'text/json');
662     }
663
664     importTemplate($event) {
665         const file: File = $event.target.files[0];
666         if (!file) { return; }
667
668         const reader = new FileReader();
669
670         reader.addEventListener('load', () => {
671
672             try {
673                 const template = JSON.parse(reader.result as string);
674                 const name = Object.keys(template)[0];
675                 this.volcopy.templates[name] = template[name];
676             } catch (E) {
677                 console.error('Invalid Item Attribute template', E);
678                 return;
679             }
680
681             this.volcopy.saveTemplates();
682             // Adds the new one to the list and re-sorts the labels.
683             this.volcopy.fetchTemplates();
684         });
685
686         reader.readAsText(file);
687     }
688
689     // Returns null when no export is in progress.
690     exportTemplateUrl(): SafeUrl {
691         return this.fileExport.safeUrl;
692     }
693
694     deleteTemplate() {
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;
700     }
701
702     displayAttr(field: string): boolean {
703         return this.volcopy.defaults.hidden[field] !== true;
704     }
705
706     copyFieldLabel(field: string): string {
707         const def = this.idl.classes.acp.field_map[field];
708         return def ? def.label : '';
709     }
710
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())) {
716                 return false;
717             }
718         }
719         return true;
720     }
721
722     // Called any time a change occurs that could affect the
723     // save-ability of the form.
724     emitSaveChange() {
725         setTimeout(() => {
726             const canSave = this.batchAttrs.filter(
727                 attr => attr.warnOnRequired()).length === 0;
728
729             this.canSaveChange.emit(canSave);
730         });
731     }
732
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;
737     }
738
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());
744     }
745
746     copyLocationOrgs(): number[] {
747         if (!this.context) { return []; }
748
749         // Make sure every org unit represented by the edit batch
750         // is represented.
751         const ids = this.context.orgNodes().map(n => n.target.id());
752
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()));
756     }
757 }
758
759
760