]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.component.ts
LP#1956986: fix and refactor item alert handing in Angular editor
[Evergreen.git] / Open-ILS / src / eg2 / src / app / staff / cat / volcopy / volcopy.component.ts
1 import {Component, OnInit, AfterViewInit, ViewChild, HostListener} from '@angular/core';
2 import {Router, ActivatedRoute, ParamMap} from '@angular/router';
3 import {tap} from 'rxjs/operators';
4 import {IdlObject, IdlService} from '@eg/core/idl.service';
5 import {EventService} from '@eg/core/event.service';
6 import {OrgService} from '@eg/core/org.service';
7 import {NetService} from '@eg/core/net.service';
8 import {AuthService} from '@eg/core/auth.service';
9 import {PcrudService} from '@eg/core/pcrud.service';
10 import {HoldingsService, CallNumData} from '@eg/staff/share/holdings/holdings.service';
11 import {VolCopyContext} from './volcopy';
12 import {ProgressInlineComponent} from '@eg/share/dialog/progress-inline.component';
13 import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
14 import {AnonCacheService} from '@eg/share/util/anon-cache.service';
15 import {VolCopyService} from './volcopy.service';
16 import {NgbNav, NgbNavChangeEvent} from '@ng-bootstrap/ng-bootstrap';
17 import {BroadcastService} from '@eg/share/util/broadcast.service';
18 import {CopyAttrsComponent} from './copy-attrs.component';
19
20 const COPY_FLESH = {
21     flesh: 1,
22     flesh_fields: {
23         acp: [
24             'call_number', 'location', 'parts', 'tags',
25             'creator', 'editor', 'stat_cat_entries', 'notes',
26             'copy_alerts'
27         ],
28         acptcm: ['tag'],
29         acpt: ['tag_type']
30     }
31 };
32
33 interface EditSession {
34
35     // Unset if editing in multi-record mode
36     record_id: number;
37
38     // list of copy IDs
39     copies: number[];
40
41     // Adding to or creating new call numbers
42     raw: CallNumData[];
43
44     // Hide the volumes editor
45     hide_vols: boolean;
46
47     // Hide the copy attrs editor.
48     hide_copies: boolean;
49 }
50
51 @Component({
52   templateUrl: 'volcopy.component.html'
53 })
54 export class VolCopyComponent implements OnInit {
55
56     context: VolCopyContext;
57     loading = true;
58     sessionExpired = false;
59
60     tab = 'holdings'; // holdings | attrs | config
61     target: string;   // item | callnumber | record | session
62     targetId: string; // id value or session string
63
64     volsCanSave = true;
65     attrsCanSave = true;
66     changesPending = false;
67     routingAllowed = false;
68
69     @ViewChild('pendingChangesDialog', {static: false})
70         pendingChangesDialog: ConfirmDialogComponent;
71
72     @ViewChild('copyAttrs', {static: false}) copyAttrs: CopyAttrsComponent;
73
74     constructor(
75         private router: Router,
76         private route: ActivatedRoute,
77         private evt: EventService,
78         private idl: IdlService,
79         private org: OrgService,
80         private net: NetService,
81         private auth: AuthService,
82         private pcrud: PcrudService,
83         private cache: AnonCacheService,
84         private broadcaster: BroadcastService,
85         private holdings: HoldingsService,
86         private volcopy: VolCopyService
87     ) { }
88
89     ngOnInit() {
90         this.route.paramMap.subscribe(
91             (params: ParamMap) => this.negotiateRoute(params));
92     }
93
94     negotiateRoute(params: ParamMap) {
95         this.tab = params.get('tab') || 'holdings';
96         this.target = params.get('target');
97         this.targetId = params.get('target_id');
98
99         if (this.volcopy.currentContext) {
100             // Avoid clobbering the context on route change.
101             this.context = this.volcopy.currentContext;
102         } else {
103             this.context = new VolCopyContext();
104             this.context.org = this.org; // inject;
105         }
106
107         switch (this.target) {
108             case 'item':
109                 this.context.copyId = +this.targetId;
110                 break;
111             case 'callnumber':
112                 this.context.volId = +this.targetId;
113                 break;
114             case 'record':
115                 this.context.recordId = +this.targetId;
116                 break;
117             case 'session':
118                 this.context.session = this.targetId;
119                 break;
120         }
121
122         if (this.volcopy.currentContext) {
123             this.loading = false;
124
125         } else {
126             // Avoid refetching the data during route changes.
127             this.volcopy.currentContext = this.context;
128             this.load();
129         }
130     }
131
132     load(copyIds?: number[]): Promise<any> {
133         this.sessionExpired = false;
134         this.loading = true;
135         this.context.reset();
136
137         return this.volcopy.load()
138         .then(_ => this.fetchHoldings(copyIds))
139         .then(_ => this.volcopy.applyVolLabels(
140             this.context.volNodes().map(n => n.target)))
141         .then(_ => this.context.sortHoldings())
142         .then(_ => this.context.setRecordId())
143         .then(_ => {
144             // unified display has no 'attrs' tab
145             if (this.volcopy.defaults.values.unified_display
146                 && this.tab === 'attrs') {
147                 this.tab = 'holdings';
148                 this.routeToTab();
149             }
150         })
151         .then(_ => this.loading = false);
152     }
153
154     fetchHoldings(copyIds?: number[]): Promise<any> {
155
156         if (copyIds && copyIds.length > 0) {
157             // Reloading copies that were just edited.
158             return this.fetchCopies(copyIds);
159
160         } else if (this.context.session) {
161             this.context.sessionType = 'mixed';
162             return this.fetchSession(this.context.session);
163
164         } else if (this.context.copyId) {
165             this.context.sessionType = 'copy';
166             return this.fetchCopies(this.context.copyId);
167
168         } else if (this.context.volId) {
169             this.context.sessionType = 'vol';
170             return this.fetchVols(this.context.volId);
171
172         } else if (this.context.recordId) {
173             this.context.sessionType = 'record';
174             return this.fetchRecords(this.context.recordId);
175         }
176     }
177
178     // Changing a tab in the UI means changing the route.
179     // Changing the route ultimately results in changing the tab.
180     beforeTabChange(evt: NgbNavChangeEvent) {
181         evt.preventDefault();
182
183         // Always allow routing between tabs since no changes are lost
184         // in the process.  In some cases, this is necessary to avoid
185         // "pending changes" alerts while you are trying to resolve
186         // other issues (e.g. applying values for required fields).
187         this.routingAllowed = true;
188         this.tab = evt.nextId;
189         this.routeToTab();
190     }
191
192     routeToTab() {
193         const url =
194             `/staff/cat/volcopy/${this.tab}/${this.target}/${this.targetId}`;
195
196         // Retain search parameters
197         this.router.navigate([url], {queryParamsHandling: 'merge'});
198     }
199
200     fetchSession(session: string): Promise<any> {
201
202         return this.cache.getItem(session, 'edit-these-copies')
203         .then((editSession: EditSession) => {
204
205             if (!editSession) {
206                 this.loading = false;
207                 this.sessionExpired = true;
208                 return Promise.reject('Session Expired');
209             }
210
211             console.debug('Edit Session', editSession);
212
213             this.context.recordId = editSession.record_id;
214
215             if (editSession.copies && editSession.copies.length > 0) {
216                 return this.fetchCopies(editSession.copies);
217             }
218
219             const volsToFetch = [];
220             const volsToCreate = [];
221             editSession.raw.forEach((volData: CallNumData) => {
222                 this.context.fastAdd = volData.fast_add === true;
223
224                 if (volData.callnumber > 0) {
225                     volsToFetch.push(volData);
226                 } else {
227                     volsToCreate.push(volData);
228                 }
229             });
230
231             let promise = Promise.resolve();
232             if (volsToFetch.length > 0) {
233                 promise = promise.then(_ =>
234                     this.fetchVolsStubCopies(volsToFetch));
235             }
236
237             if (volsToCreate.length > 0) {
238                 promise = promise.then(_ =>
239                     this.createVolsStubCopies(volsToCreate));
240             }
241
242             return promise;
243         });
244     }
245
246     // Creating new vols.  Each gets a stub copy.
247     createVolsStubCopies(volDataList: CallNumData[]): Promise<any> {
248
249         const vols = [];
250         volDataList.forEach(volData => {
251
252             const vol = this.volcopy.createStubVol(
253                 this.context.recordId,
254                 volData.owner || this.auth.user().ws_ou()
255             );
256
257             if (volData.label) {vol.label(volData.label); }
258
259             volData.callnumber = vol.id(); // wanted by addStubCopies
260             vols.push(vol);
261             this.context.findOrCreateVolNode(vol);
262         });
263
264         return this.addStubCopies(vols, volDataList)
265         .then(_ => this.volcopy.setVolClassLabels(vols));
266     }
267
268     // Fetch vols by ID, but instead of retrieving their copies
269     // add a stub copy to each.
270     fetchVolsStubCopies(volDataList: CallNumData[]): Promise<any> {
271
272         const volIds = volDataList.map(volData => volData.callnumber);
273         const vols = [];
274
275         return this.pcrud.search('acn', {id: volIds})
276         .pipe(tap((vol: IdlObject) => vols.push(vol))).toPromise()
277         .then(_ => this.addStubCopies(vols, volDataList));
278     }
279
280     // Add a stub copy to each vol using data from the edit session.
281     addStubCopies(vols: IdlObject[], volDataList: CallNumData[]): Promise<any> {
282
283         const copies = [];
284         vols.forEach(vol => {
285             const volData = volDataList.filter(
286                 vData => vData.callnumber === vol.id())[0];
287
288             const copy =
289                 this.volcopy.createStubCopy(vol, {circLib: volData.owner});
290
291             this.context.findOrCreateCopyNode(copy);
292             copies.push(copy);
293         });
294
295         return this.volcopy.setCopyStatus(copies);
296     }
297
298     fetchCopies(copyIds: number | number[]): Promise<any> {
299         const ids = [].concat(copyIds);
300         if (ids.length === 0) { return Promise.resolve(); }
301         return this.pcrud.search('acp', {id: ids}, COPY_FLESH)
302         .pipe(tap(copy => this.context.findOrCreateCopyNode(copy)))
303         .toPromise();
304     }
305
306     // Fetch call numbers and linked copies by call number ids.
307     fetchVols(volIds?: number | number[]): Promise<any> {
308         const ids = [].concat(volIds);
309         if (ids.length === 0) { return Promise.resolve(); }
310
311         return this.pcrud.search('acn', {id: ids})
312         .pipe(tap(vol => this.context.findOrCreateVolNode(vol)))
313         .toPromise().then(_ => {
314              return this.pcrud.search('acp',
315                 {call_number: ids, deleted: 'f'}, COPY_FLESH
316             ).pipe(tap(copy => this.context.findOrCreateCopyNode(copy))
317             ).toPromise();
318         });
319     }
320
321     // Fetch call numbers and copies by record ids.
322     fetchRecords(recordIds: number | number[]): Promise<any> {
323         const ids = [].concat(recordIds);
324
325         return this.pcrud.search('acn',
326             {record: ids, deleted: 'f', label: {'!=' : '##URI##'}},
327             {}, {idlist: true, atomic: true}
328         ).toPromise().then(volIds => this.fetchVols(volIds));
329     }
330
331
332     save(close?: boolean): Promise<any> {
333         this.loading = true;
334
335         if (this.copyAttrs) {
336             // Won't exist on any non-attrs page.
337             this.copyAttrs.applyPendingChanges();
338         }
339
340         // Volume update API wants volumes fleshed with copies, instead
341         // of the other way around, which is what we have here.
342         const volumes: IdlObject[] = [];
343
344         this.context.volNodes().forEach(volNode => {
345             const newVol = this.idl.clone(volNode.target);
346             const copies: IdlObject[] = [];
347
348             volNode.children.forEach(copyNode => {
349                 const copy = copyNode.target;
350
351                 if (copy.isnew() && !copy.barcode()) {
352                     // A new copy w/ no barcode is a stub copy sitting
353                     // on an empty call number.  Ignore it.
354                     return;
355                 }
356
357                 // Be sure to include copies when the volume is changed
358                 // without any changes to the copies.  This ensures the
359                 // API knows when we are modifying a subset of the total
360                 // copies on a volume, e.g. when changing volume labels
361                 if (newVol.ischanged()) { copy.ischanged(true); }
362
363                 if (copy.ischanged() || copy.isnew() || copy.isdeleted()) {
364                     const copyClone = this.idl.clone(copy);
365                     // De-flesh call number
366                     copyClone.call_number(copy.call_number().id());
367                     copies.push(copyClone);
368                 }
369             });
370
371            newVol.copies(copies);
372
373             if (newVol.ischanged() || newVol.isnew() || copies.length > 0) {
374                 volumes.push(newVol);
375             }
376         });
377
378         this.context.volsToDelete.forEach(vol => {
379             const cloneVol = this.idl.clone(vol);
380             // No need to flesh copies -- they'll be force deleted.
381             cloneVol.copies([]);
382             volumes.push(cloneVol);
383         });
384
385         this.context.copiesToDelete.forEach(copy => {
386             const cloneCopy = this.idl.clone(copy);
387             const copyVol = cloneCopy.call_number();
388             cloneCopy.call_number(copyVol.id()); // de-flesh
389
390             let vol = volumes.filter(v => v.id() === copyVol.id())[0];
391
392             if (vol) {
393                 vol.copies().push(cloneCopy);
394             } else {
395                 vol = this.idl.clone(copyVol);
396                 vol.copies([cloneCopy]);
397             }
398
399             volumes.push(vol);
400         });
401
402         // De-flesh before posting
403         volumes.forEach(vol => {
404             vol.copies().forEach(copy => {
405                 ['editor', 'creator', 'location'].forEach(field => {
406                     if (typeof copy[field]() === 'object') {
407                         copy[field](copy[field]().id());
408                     }
409                 });
410             });
411         });
412
413         let promise: Promise<number[]> = Promise.resolve([]);
414
415         if (volumes.length > 0) {
416             promise = this.saveApi(volumes, false, close);
417         }
418
419         return promise.then(copyIds => {
420
421             // In addition to the copies edited in this update call,
422             // reload any other copies that were previously loaded.
423             const ids: any = {}; // dedupe
424             this.context.copyList()
425                 .map(c => c.id())
426                 .filter(id => id > 0) // scrub the new copy IDs
427                 .concat(copyIds)
428                 .forEach(id => ids[id] = true);
429
430             copyIds = Object.keys(ids).map(id => Number(id));
431
432             if (close) {
433                 return this.openPrintLabels(copyIds)
434                     .then(_ => setTimeout(() => window.close()));
435             }
436
437             return this.load(Object.keys(ids).map(id => Number(id)));
438
439         }).then(_ => {
440             this.loading = false;
441             this.changesPending = false;
442         });
443     }
444
445     broadcastChanges(volumes: IdlObject[]) {
446
447         const volIds = volumes.map(v => v.id());
448         const copyIds = [];
449         const recIds = [];
450
451         volumes.forEach(vol => {
452             if (!recIds.includes(vol.record())) {
453                 recIds.push(vol.record());
454             }
455             vol.copies().forEach(copy => copyIds.push(copy.id()));
456         });
457
458         this.broadcaster.broadcast('eg.holdings.update', {
459             copies : copyIds,
460             volumes: volIds,
461             records: recIds
462         });
463     }
464
465     saveApi(volumes: IdlObject[], override?:
466         boolean, close?: boolean): Promise<number[]> {
467
468         let method = 'open-ils.cat.asset.volume.fleshed.batch.update';
469         if (override) { method += '.override'; }
470
471         return this.net.request('open-ils.cat',
472             method, this.auth.token(), volumes, true,
473             {   auto_merge_vols: true,
474                 create_parts: true,
475                 return_copy_ids: true,
476                 force_delete_copies: true
477             }
478
479         ).toPromise().then(copyIds => {
480
481             const evt = this.evt.parse(copyIds);
482
483             if (evt) {
484                 // TODO: handle overrides?
485                 // return this.saveApi(volumes, true, close);
486                 this.loading = false;
487                 alert(evt);
488                 return Promise.reject();
489             }
490
491             this.broadcastChanges(volumes);
492
493             return copyIds;
494         });
495     }
496
497     toggleCheckbox(field: string) {
498         this.volcopy.defaults.values[field] =
499             !this.volcopy.defaults.values[field];
500         this.volcopy.saveDefaults();
501     }
502
503     openPrintLabels(copyIds?: number[]): Promise<any> {
504         if (!this.volcopy.defaults.values.print_labels) {
505             return Promise.resolve();
506         }
507
508         if (!copyIds || copyIds.length === 0) {
509             copyIds = this.context.copyList()
510                 .map(c => c.id()).filter(id => id > 0);
511         }
512
513         return this.net.request(
514             'open-ils.actor',
515             'open-ils.actor.anon_cache.set_value',
516             null, 'print-labels-these-copies', {copies : copyIds}
517
518         ).toPromise().then(key => {
519
520             const url = '/eg/staff/cat/printlabels/' + key;
521             setTimeout(() => window.open(url, '_blank'));
522         });
523     }
524
525     isNotSaveable(): boolean {
526
527         if (!this.volsCanSave) { return true; }
528         if (!this.attrsCanSave) { return true; }
529
530         // This can happen regardless of whether we are modifying
531         // volumes vs. copies.
532         if (this.volcopy.missingRequiredStatCat()) { return true; }
533
534         return false;
535     }
536
537     volsCanSaveChange(can: boolean) {
538         this.volsCanSave = can;
539         this.changesPending = true;
540     }
541
542     attrsCanSaveChange(can: boolean) {
543         this.attrsCanSave = can;
544         this.changesPending = true;
545     }
546
547     @HostListener('window:beforeunload', ['$event'])
548     canDeactivate($event?: Event): boolean | Promise<boolean> {
549
550         if (this.routingAllowed) {
551             // We call canDeactive manually when routing between volcopy
552             // tabs.  If routingAllowed, it means we'ave already confirmed
553             // the tag change is OK.
554             this.routingAllowed = false;
555             return true;
556         }
557
558         const editing = this.copyAttrs ? this.copyAttrs.hasActiveInput() : false;
559
560         if (!editing && !this.changesPending) { return true; }
561
562         // Each warning dialog clears the current "changes are pending"
563         // flag so the user is not presented with the dialog again
564         // unless new changes are made.  The 'editing' value will reset
565         // since the attrs component is getting destroyed.
566         this.changesPending = false;
567
568         if ($event) { // window.onbeforeunload
569             $event.preventDefault();
570             $event.returnValue = true;
571
572         } else { // tab OR route change.
573             return this.pendingChangesDialog.open().toPromise();
574         }
575     }
576 }
577
578
579