LP#1845706 (follow-up): Fix callback
[working/Evergreen.git] / Open-ILS / web / js / ui / default / staff / circ / patron / items_out.js
1 /**
2  * List of patron items checked out
3  */
4
5 angular.module('egPatronApp')
6
7 .controller('PatronItemsOutCtrl',
8        ['$scope','$q','$routeParams','$timeout','egCore','egUser','patronSvc',
9         '$location','egGridDataProvider','$uibModal','egCirc','egConfirmDialog',
10         'egBilling','$window','egBibDisplay',
11 function($scope , $q , $routeParams , $timeout , egCore , egUser , patronSvc , 
12          $location , egGridDataProvider , $uibModal , egCirc , egConfirmDialog , 
13          egBilling , $window , egBibDisplay) {
14
15     // list of noncatatloged circulations. Define before initTab to 
16     // avoid any possibility of race condition, since they are loaded
17     // during init, but may be referenced before init completes.
18     $scope.noncat_list = [];
19
20     $scope.initTab('items_out', $routeParams.id).then(function() {
21         // sort inline to support paging
22         $scope.noncat_list = patronSvc.noncat_ids.sort();
23     });
24
25     // cache of circ objects for grid display
26     patronSvc.items_out = [];
27
28     // main list of checked out items
29     $scope.main_list = [];
30
31     // list of alt circs (lost, etc.) and/or check-in with fines circs
32     $scope.alt_list = []; 
33     
34     egCore.org.settings([
35         'ui.circ.suppress_checkin_popups' // add other settings as needed
36     ]).then(function(set) {
37         $scope.suppress_popups = set['ui.circ.suppress_checkin_popups'];
38     });
39
40     // these are fetched during startup (i.e. .configure())
41     // By default, show lost/lo/cr items in the alt list
42     var display_lost = Number(
43         egCore.env.aous['ui.circ.items_out.lost']) || 2;
44     var display_lo = Number(
45         egCore.env.aous['ui.circ.items_out.longoverdue']) || 2;
46     var display_cr = Number(
47         egCore.env.aous['ui.circ.items_out.claimsreturned']) || 2;
48
49     var fetch_checked_in = true;
50     $scope.show_alt_circs = true;
51     if (display_lost & 4 && display_lo & 4 && display_cr & 4) {
52         // all special types are configured to be hidden once
53         // checked in, so there's no need to fetch checked-in circs.
54         fetch_checked_in = false;
55
56         if (display_lost & 1 && display_lo & 1 && display_cr & 1) {                 
57             // additionally, if all types are configured to display    
58             // in the main list while checked out, nothing will         
59             // ever appear in the alternate list, so we can hide          
60             // the alternate list from the UI.  
61             $scope.show_alt_circs = false;
62         }
63     }
64
65     $scope.items_out_display = 'main';
66     $scope.show_main_list = function(refresh_grid) {
67         // don't need a full reset_page() to swap tabs
68         $scope.items_out_display = 'main';
69         patronSvc.items_out = [];
70         // only refresh the grid when navigating from a tab that 
71         // shares the same grid.
72         if (refresh_grid) provider.refresh();
73     }
74
75     $scope.show_alt_list = function(refresh_grid) {
76         // don't need a full reset_page() to swap tabs
77         $scope.items_out_display = 'alt';
78         patronSvc.items_out = [];
79         // only refresh the grid when navigating from a tab that 
80         // shares the same grid.
81         if (refresh_grid) provider.refresh();
82     }
83
84     $scope.show_noncat_list = function() {
85         // don't need a full reset_page() to swap tabs
86         $scope.items_out_display = 'noncat';
87         patronSvc.items_out = [];
88         // Grid refresh is not necessary because switching to the
89         // noncat_list always involves instantiating a new grid.
90     }
91
92     // Reload the user to pick up changes in items out, fines, etc.
93     // Reload circs since the contents of the main vs. alt list may
94     // have changed.
95     function reset_page() {
96         patronSvc.refreshPrimary();
97         patronSvc.items_out = []; 
98         $scope.main_list = [];
99         $scope.alt_list = [];
100         $timeout(provider.refresh);  // allow scope changes to propagate
101     }
102
103     var provider = egGridDataProvider.instance({});
104     $scope.gridDataProvider = provider;
105
106     function fetch_circs(id_list, offset, count) {
107         if (!id_list.length || id_list.length < offset + 1) return $q.when();
108
109         var deferred = $q.defer();
110         var rendered = 0;
111
112         // fetch the lot of circs and stream the results back via notify
113         egCore.pcrud.search('circ', {id : id_list},
114             {   flesh : 4,
115                 flesh_fields : {
116                     circ : ['target_copy', 'workstation', 'checkin_workstation'],
117                     acp : ['call_number', 'holds_count', 'status', 'circ_lib', 'location', 'floating', 'age_protect', 'parts'],
118                     acpm : ['part'],
119                     acn : ['record', 'owning_lib', 'prefix', 'suffix'],
120                     bre : ['wide_display_entry']
121                 },
122                 // avoid fetching the MARC blob by specifying which 
123                 // fields on the bre to select.  More may be needed.
124                 // note that fleshed fields are explicitly selected.
125                 select : { bre : ['id'] },
126                 // TODO: LP#1697954 Fetch all circs on grid render 
127                 // to support client-side sorting.  Migrate to server-side
128                 // sorting to avoid the need for fetching all items.
129                 //limit  : count,
130                 //offset : offset,
131                 // we need an order-by to support paging
132                 order_by : {circ : ['xact_start']} 
133
134         }).then(deferred.resolve, null, function(circ) {
135             circ.circ_lib(egCore.org.get(circ.circ_lib())); // local fleshing
136
137             // Translate bib display field JSON blobs to JS.
138             // Collapse multi/array fields down to comma-separated strings.
139             egBibDisplay.mwdeJSONToJS(
140                 circ.target_copy().call_number().record().wide_display_entry(), true);
141
142             if (circ.target_copy().call_number().id() == -1) {
143                 // dummy-up a record for precat items
144                 circ.target_copy().call_number().record().wide_display_entry({
145                     title : function() {return circ.target_copy().dummy_title()},
146                     author : function() {return circ.target_copy().dummy_author()},
147                     isbn : function() {return circ.target_copy().dummy_isbn()}
148                 })
149             }
150             circ._parts = circ.target_copy().parts().map(function(part) {
151                 return part.label()
152             }).join(',');
153
154             // call open-ils to get overdue notice count and  Last notice date
155             
156            egCore.net.request(
157                'open-ils.actor',
158                'open-ils.actor.user.itemsout.notices',
159                egCore.auth.token(), circ.id(), $scope.patron_id)
160            .then(function(notice){
161                if (notice.numNotices){
162                    circ.action_trigger_event_count = notice.numNotices;
163                    circ.action_trigger_latest_event_date = notice.lastDt;
164                }
165                patronSvc.items_out.push(circ);
166            });
167
168                if (rendered++ >= offset && rendered <= count){ deferred.notify(circ) };
169         });
170
171         return deferred.promise;
172     }
173
174     function fetch_noncat_circs(id_list, offset, count) {
175         if (!id_list.length) return $q.when();
176
177         var deferred = $q.defer();
178         var rendered = 0;
179
180         egCore.pcrud.search('ancc', {id : id_list},
181             {   flesh : 1,
182                 flesh_fields : {ancc : ['item_type','staff']},
183                 // TODO: LP#1697954 Fetch all circs on grid render 
184                 // to support client-side sorting.  Migrate to server-side
185                 // sorting to avoid the need for fetching all items.
186                 //limit  : count,
187                 //offset : offset,
188                 // we need an order-by to support paging
189                 order_by : {circ : ['circ_time']} 
190
191         }).then(deferred.resolve, null, function(noncat_circ) {
192
193             // calculate the virtual due date from the item type duration
194             var seconds = egCore.date.intervalToSeconds(
195                 noncat_circ.item_type().circ_duration());
196             var d = new Date(Date.parse(noncat_circ.circ_time()));
197             d.setSeconds(d.getSeconds() + seconds);
198             noncat_circ.duedate(d.toISOString());
199
200             // local flesh org unit
201             noncat_circ.circ_lib(egCore.org.get(noncat_circ.circ_lib()));
202
203             patronSvc.items_out.push(noncat_circ); // cache it
204
205             // We fetch all noncat circs for client-side sorting, but
206             // only notify the caller for the page of requested circs.  
207             if (rendered++ >= offset && rendered <= count)
208                 deferred.notify(noncat_circ);
209         });
210
211         return deferred.promise;
212     }
213
214
215     // decide which list each circ belongs to
216     function promote_circs(list, display_code, open) {
217         if (open) {                                                    
218             if (1 & display_code) { // bitflag 1 == top list                   
219                 $scope.main_list = $scope.main_list.concat(list);
220             } else {                                                   
221                 $scope.alt_list = $scope.alt_list.concat(list);
222             }                                                          
223         } else {                                                       
224             if (4 & display_code) return;  // bitflag 4 == hide on checkin     
225             $scope.alt_list = $scope.alt_list.concat(list);
226         } 
227     }
228
229     // fetch IDs for circs we care about
230     function get_circ_ids() {
231         $scope.main_list = [];
232         $scope.alt_list = [];
233
234         // we can fetch these in parallel
235         var promise1 = egCore.net.request(
236             'open-ils.actor',
237             'open-ils.actor.user.checked_out.authoritative',
238             egCore.auth.token(), $scope.patron_id
239         ).then(function(outs) {
240             $scope.main_list = outs.overdue.concat(outs.out);
241             promote_circs(outs.lost, display_lost, true);                            
242             promote_circs(outs.long_overdue, display_lo, true);             
243             promote_circs(outs.claims_returned, display_cr, true);
244         });
245
246         // only fetched checked-in-with-bills circs if configured to display
247         var promise2 = !fetch_checked_in ? $q.when() : egCore.net.request(
248             'open-ils.actor',
249             'open-ils.actor.user.checked_in_with_fines.authoritative',
250             egCore.auth.token(), $scope.patron_id
251         ).then(function(outs) {
252             promote_circs(outs.lost, display_lost);
253             promote_circs(outs.long_overdue, display_lo);
254             promote_circs(outs.claims_returned, display_cr);
255         });
256
257         return $q.all([promise1, promise2]);
258     }
259
260     provider.get = function(offset, count) {
261
262         var id_list = $scope[$scope.items_out_display + '_list'];
263
264         // see if we have the requested range cached
265         // Note this items_out list is reset w/ each items-out tab change
266         if (patronSvc.items_out[offset]) {
267             return provider.arrayNotifier(
268                 patronSvc.items_out, offset, count);
269         }
270
271         if ($scope.items_out_display == 'noncat') {
272             // if there are any noncat circ IDs, we already have them
273             return fetch_noncat_circs(id_list, offset, count);
274         }
275
276         // See if we have the circ IDs for this range already loaded.
277         // this would happen navigating to a subsequent page.
278         if (id_list[offset]) {
279             return fetch_circs(id_list, offset, count);
280         }
281
282         // avoid returning the request directly to the caller so the
283         // notify()'s from egCore.net.request don't leak into the 
284         // final set of notifies (i.e. the real responses);
285
286         var deferred = $q.defer();
287         get_circ_ids().then(function() {
288
289             id_list = $scope[$scope.items_out_display + '_list'];
290             $scope.gridDataProvider.grid.totalCount = id_list.length;
291             // relay the notified circs back to the grid through our promise
292             fetch_circs(id_list, offset, count).then(
293                 deferred.resolve, null, deferred.notify);
294         });
295
296         return deferred.promise;
297     }
298
299
300     // true if circ is overdue, false otherwise
301     $scope.circIsOverdue = function(circ) {
302         // circ may not exist yet for rendered row
303         if (!circ) return false;
304
305         var date = new Date();
306         date.setTime(Date.parse(circ.due_date()));
307         return date < new Date();
308     }
309
310     $scope.edit_due_date = function(items) {
311         if (!items.length) return;
312
313         $uibModal.open({
314             templateUrl : './circ/patron/t_edit_due_date_dialog',
315             backdrop: 'static',
316             controller : [
317                         '$scope','$uibModalInstance',
318                 function($scope , $uibModalInstance) {
319
320                     // if there is only one circ, default to the due date
321                     // of that circ.  Otherwise, default to today.
322                     var due_date = items.length == 1 ? 
323                         Date.parse(items[0].due_date()) : new Date();
324
325                     $scope.args = {
326                         num_circs : items.length,
327                         due_date : due_date
328                     }
329
330                     // Fire off the due-date updater for each circ.
331                     // When all is done, close the dialog
332                     $scope.ok = function(args) {
333                         var due = $scope.args.due_date.toISOString();
334                         console.debug("applying due date of " + due);
335
336                         var promises = [];
337                         angular.forEach(items, function(circ) {
338                             promises.push(
339                                 egCore.net.request(
340                                     'open-ils.circ',
341                                     'open-ils.circ.circulation.due_date.update',
342                                     egCore.auth.token(), circ.id(), due
343
344                                 ).then(function(new_circ) {
345                                     // update the grid circ with the canonical 
346                                     // date from the modified circulation.
347                                     circ.due_date(new_circ.due_date());
348                                 })
349                             );
350                         });
351
352                         $q.all(promises).then(function() {
353                             $uibModalInstance.close();
354                             provider.refresh();
355                         });
356                     }
357                     $scope.cancel = function($event) {
358                         $uibModalInstance.dismiss();
359                         $event.preventDefault();
360                     }
361                 }
362             ]
363         });
364     }
365
366     $scope.print_receipt = function(items) {
367         if (items.length == 0) return $q.when();
368         var print_data = {circulations : []};
369         var cusr = patronSvc.current;
370
371         angular.forEach(items, function(circ) {
372             print_data.circulations.push({
373                 circ : egCore.idl.toHash(circ),
374                 copy : egCore.idl.toHash(circ.target_copy()),
375                 call_number : egCore.idl.toHash(circ.target_copy().call_number()),
376                 title : circ.target_copy().call_number().record().wide_display_entry().title(),
377                 author : circ.target_copy().call_number().record().wide_display_entry().author()
378             })
379         });
380
381         print_data.patron = {
382             prefix : cusr.prefix(),
383             first_given_name : cusr.first_given_name(),
384             second_given_name : cusr.second_given_name(),
385             family_name : cusr.family_name(),
386             suffix : cusr.suffix(),
387             pref_prefix : cusr.pref_prefix(),
388             pref_first_given_name : cusr.pref_first_given_name(),
389             pref_second_given_name : cusr.pref_second_given_name(),
390             pref_family_name : cusr.pref_family_name(),
391             pref_suffix : cusr.pref_suffix(),
392             card : { barcode : cusr.card().barcode() },
393             money_summary : patronSvc.patron_stats.fines,
394             expire_date : cusr.expire_date(),
395             alias : cusr.alias(),
396             has_email : Boolean(patronSvc.current.email() && patronSvc.current.email().match(/.*@.*/)),
397             has_phone : Boolean(cusr.day_phone() || cusr.evening_phone() || cusr.other_phone())
398         };
399
400         return egCore.print.print({
401             context : 'default', 
402             template : 'items_out', 
403             scope : print_data,
404         });
405     }
406
407     function batch_action_with_flat_copies(items, action) {
408         if (!items.length) return;
409         var copies = items.map(function(circ) 
410             { return egCore.idl.toHash(circ.target_copy()) });
411         action(copies).then(reset_page);
412     }
413     function batch_action_with_barcodes(items, action) {
414         if (!items.length) return;
415         var barcodes = items.map(function(circ) 
416             { return circ.target_copy().barcode() });
417         action(barcodes).then(reset_page);
418     }
419     $scope.mark_damaged = function(items) {
420         if (items.length == 0) return;
421
422         angular.forEach(items, function(circ) {
423             egCirc.mark_damaged({
424                 id: circ.target_copy().id(),
425                 barcode: circ.target_copy().barcode(),
426                 circ_lib: circ.target_copy().circ_lib().id()
427             }).then(() => $timeout(reset_page,1000)) // reset after each, because rejecting one stops the $q.all() chain
428         });
429     }
430     $scope.mark_missing = function(items) {
431         batch_action_with_flat_copies(items, egCirc.mark_missing);
432     }
433     $scope.mark_lost = function(items) {
434         batch_action_with_barcodes(items, egCirc.mark_lost);
435     }
436     $scope.mark_claims_returned = function(items) {
437         batch_action_with_barcodes(items, egCirc.mark_claims_returned_dialog);
438     }
439     $scope.mark_claims_never_checked_out = function(items) {
440         batch_action_with_barcodes(items, egCirc.mark_claims_never_checked_out);
441     }
442
443     $scope.show_recent_circs = function(items) {
444         var focus = items.length == 1;
445         angular.forEach(items, function(item) {
446             var url = egCore.env.basePath +
447                       '/cat/item/' +
448                       item.target_copy().id() +
449                       '/circ_list';
450             $timeout(function() { var x = $window.open(url, '_blank'); if (focus) x.focus() });
451         });
452     }
453
454     $scope.show_triggered_events = function(items) {
455         var focus = items.length == 1;
456         angular.forEach(items, function(item) {
457             var url = egCore.env.basePath +
458                       '/cat/item/' +
459                       item.target_copy().id() +
460                       '/triggered_events';
461             $timeout(function() { var x = $window.open(url, '_blank'); if (focus) x.focus() });
462         });
463     }
464
465     $scope.renew = function(items, msg) {
466         if (!items.length) return;
467         var barcodes = items.map(function(circ) 
468             { return circ.target_copy().barcode() });
469
470         if (!msg) msg = egCore.strings.RENEW_ITEMS;
471
472         return egConfirmDialog.open(msg, barcodes.join(' '), {}).result
473         .then(function() {
474             function do_one() {
475                 var bc = barcodes.pop();
476                 if (!bc) { reset_page(); return }
477                 // finally -> continue even when one fails
478                 egCirc.renew({copy_barcode : bc}).finally(do_one);
479             }
480             do_one();
481         });
482     }
483
484     $scope.renew_all = function() {
485         var circs = patronSvc.items_out.filter(function(circ) {
486             return (
487                 // all others will be rejected at the server
488                 !circ.stop_fines() ||
489                 circ.stop_fines() == 'MAXFINES'
490             );
491         });
492         $scope.renew(circs, egCore.strings.RENEW_ALL_ITEMS);
493     }
494
495     $scope.renew_with_date = function(items) {
496         if (!items.length) return;
497         var barcodes = items.map(function(circ) 
498             { return circ.target_copy().barcode() });
499
500         return $uibModal.open({
501             templateUrl : './circ/patron/t_renew_with_date_dialog',
502             backdrop: 'static',
503             controller : [
504                         '$scope','$uibModalInstance',
505                 function($scope , $uibModalInstance) {
506                     $scope.outOfRange = false;
507                     $scope.minDate = new Date();
508                     $scope.args = {
509                         barcodes : barcodes,
510                         date : new Date()
511                     }
512                     $scope.cancel = function() {$uibModalInstance.dismiss()}
513
514                     // Fire off the due-date updater for each circ.
515                     // When all is done, close the dialog
516                     $scope.ok = function() {
517                         var due = $scope.args.date.toISOString().replace(/T.*/,'');
518                         console.debug("renewing with due date: " + due);
519
520                         function do_one() {
521                             if (bc = barcodes.pop()) {
522                                 egCirc.renew({copy_barcode : bc, due_date : due})
523                                 .finally(do_one);
524                             } else {
525                                 $uibModalInstance.close(); 
526                                 reset_page();
527                             }
528                         }
529                        do_one(); // kick it off
530                     }
531                 }
532             ]
533         }).result;
534     }
535
536     $scope.checkin = function(items) {
537         if (!items.length) return;
538         var copies = items.map(function(circ) { return circ.target_copy() });
539         var barcodes = copies.map(function(copy) { return copy.barcode() });
540         
541         var copy;
542         function do_one() {
543             if (copy = copies.pop()) {
544                 // Checkin expects a barcode, but will pass other
545                 // parameters too.  Passing the copy ID allows
546                 // for the checkin of deleted copies on the server.
547                 egCirc.checkin(
548                     {copy_barcode: copy.barcode(), copy_id: copy.id()},
549                     {suppress_popups: $scope.suppress_popups})
550                 .finally(do_one);
551             } else {
552                 reset_page();
553             }
554         }
555         if ($scope.suppress_popups) {
556             do_one();
557         } else {
558             return egConfirmDialog.open(
559                 egCore.strings.CHECK_IN_CONFIRM, barcodes.join(' '), {
560
561             }).result.then(function() {
562                 do_one(); // kick it off
563             });
564         }
565     }
566
567     $scope.add_billing = function(items) {
568         if (!items.length) return;
569         var circs = items.concat(); // don't pop from grid array
570         function do_one() {
571             var circ; // don't clobber window.circ!
572             if (circ = circs.pop()) {
573                 egBilling.showBillDialog({
574                     // let the dialog fetch the transaction, since it's
575                     // not sufficiently fleshed here.
576                     xact_id : circ.id(),
577                     patron : patronSvc.current
578                 }).finally(do_one);
579             } else {
580                 reset_page();
581             }
582         }
583         do_one();
584     }
585
586 }]);
587