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