LP#1701001: carve out a reusable patron search service
[working/Evergreen.git] / Open-ILS / web / js / ui / default / staff / services / patron_search.js
1 /**
2  * Patron Search module
3  */
4
5 angular.module('egPatronSearchMod', ['ngRoute', 'ui.bootstrap', 
6     'egCoreMod', 'egUiMod', 'egGridMod', 'egUserMod'])
7
8 /**
9  * Patron service
10  */
11 .factory('patronSvc',
12        ['$q','$timeout','$location','egCore','egUser','$locale',
13 function($q , $timeout , $location , egCore,  egUser , $locale) {
14
15     var service = {
16         // cached patron search results
17         patrons : [],
18
19         // currently selected patron object
20         current : null, 
21
22         // patron circ stats (overdues, fines, holds)
23         patron_stats : null,
24
25         // event types manually overridden, which should always be
26         // overridden for checkouts to this patron for this instance of
27         // the interface.
28         checkout_overrides : {},        
29         //holds the searched barcode
30         search_barcode : null,      
31     };
32
33     // when we change the default patron, we need to clear out any
34     // data collected on that patron
35     service.resetPatronLists = function() {
36         service.checkouts = [];
37         service.items_out = []
38         service.items_out_ids = [];
39         service.holds = [];
40         service.hold_ids = [];
41         service.checkout_overrides = {};
42         service.patron_stats = null;
43         service.noncat_ids = [];
44         service.hasAlerts = false;
45         service.patronExpired = false;
46         service.patronExpiresSoon = false;
47         service.invalidAddresses = false;
48     }
49     service.resetPatronLists();  // initialize
50
51     // Returns true if the last alerted patron matches the current
52     // patron.  Otherwise, the last alerted patron is set to the 
53     // current patron and false is returned.
54     service.alertsShown = function() {
55         var key = 'eg.circ.last_alerted_patron';
56         var last_id = egCore.hatch.getSessionItem(key);
57         if (last_id && last_id == service.current.id()) return true;
58         egCore.hatch.setSessionItem(key, service.current.id());
59         return false;
60     }
61
62     // shortcut to force-reload the current primary
63     service.refreshPrimary = function() {
64         if (!service.current) return $q.when();
65         return service.setPrimary(service.current.id(), null, true);
66     }
67
68     // clear the currently focused user
69     service.clearPrimary = function() {
70         // reset with no patron
71         service.resetPatronLists();
72         service.current = null;
73         service.patron_stats = null;
74         return $q.when();
75     }
76
77     // sets the primary display user, fetching data as necessary.
78     service.setPrimary = function(id, user, force) {
79         var user_id = id ? id : (user ? user.id() : null);
80
81         console.debug('setting primary user to: ' + user_id);
82
83         if (!user_id) return $q.reject();
84
85         // when loading a new patron, update the last patron setting
86         if (!service.current || service.current.id() != user_id)
87             egCore.hatch.setLoginSessionItem('eg.circ.last_patron', user_id);
88
89         // avoid running multiple retrievals for the same patron, which
90         // can happen during dbl-click by maintaining a single running
91         // data retrieval promise
92         if (service.primaryUserPromise) {
93             if (service.primaryUserId == user_id) {
94                 return service.primaryUserPromise.promise;
95             } else {
96                 service.primaryUserPromise = null;
97             }
98         }
99
100         service.primaryUserPromise = $q.defer();
101         service.primaryUserId = user_id;
102
103         service.getPrimary(id, user, force)
104         .then(function() {
105             service.checkAlerts();
106             var p = service.primaryUserPromise;
107             service.primaryUserId = null;
108             // clear before resolution just to be safe.
109             service.primaryUserPromise = null;
110             p.resolve();
111         });
112
113         return service.primaryUserPromise.promise;
114     }
115
116     service.getPrimary = function(id, user, force) {
117
118         if (user) {
119             if (!force && service.current && 
120                 service.current.id() == user.id()) {
121                 if (service.patron_stats) {
122                     return $q.when();
123                 } else {
124                     return service.fetchUserStats();
125                 }
126             }
127
128             service.resetPatronLists();
129             service.current = user;
130             service.localFlesh(user);
131             return service.fetchUserStats();
132
133         } else if (id) {
134             if (!force && service.current && service.current.id() == id) {
135                 if (service.patron_stats) {
136                     return $q.when();
137                 } else {
138                     return service.fetchUserStats();
139                 }
140             }
141
142             service.resetPatronLists();
143
144             return egUser.get(id).then(
145                 function(user) {
146                     service.current = user;
147                     service.localFlesh(user);
148                     return service.fetchUserStats();
149                 },
150                 function(err) {
151                     console.error(
152                         "unable to fetch user "+id+': '+js2JSON(err))
153                 }
154             );
155         } else {
156
157             // fetching a null user clears the primary user.
158             // NOTE: this should probably reject() and log an error, 
159             // but calling clear for backwards compat for now.
160             return service.clearPrimary();
161         }
162     }
163
164     // flesh some additional user fields locally
165     service.localFlesh = function(user) {
166         if (!angular.isObject(typeof user.home_ou()))
167             user.home_ou(egCore.org.get(user.home_ou()));
168
169         angular.forEach(
170             user.standing_penalties(),
171             function(penalty) {
172                 if (!angular.isObject(penalty.org_unit()))
173                     penalty.org_unit(egCore.org.get(penalty.org_unit()));
174             }
175         );
176
177         // stat_cat_entries == stat_cat_entry_user_map
178         angular.forEach(user.stat_cat_entries(), function(map) {
179             if (angular.isObject(map.stat_cat())) return;
180             // At page load, we only retrieve org-visible stat cats.
181             // For the common case, ignore entries for remote stat cats.
182             var cat = egCore.env.actsc.map[map.stat_cat()];
183             if (cat) {
184                 map.stat_cat(cat);
185                 cat.owner(egCore.org.get(cat.owner()));
186             }
187         });
188     }
189
190     // resolves to true if the patron account has expired or will
191     // expire soon, based on YAOUS circ.patron_expires_soon_warning
192     // note: returning a promise is no longer strictly necessary
193     // (no more async activity) if the calling function is changed too.
194     service.testExpire = function() {
195
196         var expire = Date.parse(service.current.expire_date());
197         if (expire < new Date()) {
198             return $q.when(service.patronExpired = true);
199         }
200
201         var soon = egCore.env.aous['circ.patron_expires_soon_warning'];
202         if (Number(soon)) {
203             var preExpire = new Date();
204             preExpire.setDate(preExpire.getDate() + Number(soon));
205             if (expire < preExpire) 
206                 return $q.when(service.patronExpiresSoon = true);
207         }
208
209         return $q.when(false);
210     }
211
212     // resolves to true if the patron account has any invalid addresses.
213     service.testInvalidAddrs = function() {
214
215         if (service.invalidAddresses)
216             return $q.when(true);
217
218         var fail = false;
219
220         angular.forEach(
221             service.current.addresses(), 
222             function(addr) { if (addr.valid() == 'f') fail = true }
223         );
224
225         return $q.when(fail);
226     }
227     //resolves to true if the patron was fetched with an inactive card
228     service.fetchedWithInactiveCard = function() {
229         var bc = service.search_barcode
230         var cards = service.current.cards();
231         var card = cards.filter(function(c) { return c.barcode() == bc })[0];
232         return (card && card.active() == 'f');
233     }   
234     // resolves to true if there is any aspect of the patron account
235     // which should produce a message in the alerts panel
236     service.checkAlerts = function() {
237
238         if (service.hasAlerts) // already checked
239             return $q.when(true); 
240
241         var deferred = $q.defer();
242         var p = service.current;
243
244         if (service.alert_penalties.length ||
245             p.alert_message() ||
246             p.active() == 'f' ||
247             p.barred() == 't' ||
248             service.patron_stats.holds.ready) {
249
250             service.hasAlerts = true;
251         }
252
253         // see if the user was retrieved with an inactive card
254         if(service.fetchedWithInactiveCard()){
255             service.hasAlerts = true;
256         }
257
258         // regardless of whether we know of alerts, we still need 
259         // to test/fetch the expire data for display
260         service.testExpire().then(function(bool) {
261             if (bool) service.hasAlerts = true;
262             deferred.resolve(service.hasAlerts);
263         });
264
265         service.testInvalidAddrs().then(function(bool) {
266             if (bool) service.invalidAddresses = true;
267             deferred.resolve(service.invalidAddresses);
268         });
269
270         return deferred.promise;
271     }
272
273     service.fetchGroupFines = function() {
274         return egCore.net.request(
275             'open-ils.actor',
276             'open-ils.actor.usergroup.members.balance_owed',
277             egCore.auth.token(), service.current.usrgroup()
278         ).then(function(list) {
279             var total = 0;
280             angular.forEach(list, function(u) { 
281                 total += 100 * Number(u.balance_owed)
282             });
283             service.patron_stats.fines.group_balance_owed = total / 100;
284         });
285     }
286
287     service.getUserStats = function(id) {
288         return egCore.net.request(
289             'open-ils.actor',
290             'open-ils.actor.user.opac.vital_stats.authoritative', 
291             egCore.auth.token(), id
292         ).then(
293             function(stats) {
294                 // force numeric to ensure correct boolean handling in templates
295                 stats.fines.balance_owed = Number(stats.fines.balance_owed);
296                 stats.checkouts.overdue = Number(stats.checkouts.overdue);
297                 stats.checkouts.claims_returned = 
298                     Number(stats.checkouts.claims_returned);
299                 stats.checkouts.lost = Number(stats.checkouts.lost);
300                 stats.checkouts.out = Number(stats.checkouts.out);
301                 stats.checkouts.total_out = 
302                     stats.checkouts.out + stats.checkouts.overdue;
303
304                 stats.checkouts.total_out += Number(stats.checkouts.long_overdue);
305
306                 if (!egCore.env.aous['circ.do_not_tally_claims_returned'])
307                     stats.checkouts.total_out += stats.checkouts.claims_returned;
308
309                 if (egCore.env.aous['circ.tally_lost'])
310                     stats.checkouts.total_out += stats.checkouts.lost
311
312                 return stats;
313             }
314         );
315     }
316
317     // Fetches the IDs of any active non-cat checkouts for the current
318     // user.  Also sets the patron_stats non_cat count value to match.
319     service.getUserNonCats = function(id) {
320         return egCore.net.request(
321             'open-ils.circ',
322             'open-ils.circ.open_non_cataloged_circulation.user.authoritative',
323             egCore.auth.token(), id
324         ).then(function(noncat_ids) {
325             service.noncat_ids = noncat_ids;
326             service.patron_stats.checkouts.noncat = noncat_ids.length;
327         });
328     }
329
330     // grab additional circ info
331     service.fetchUserStats = function() {
332         return service.getUserStats(service.current.id())
333         .then(function(stats) {
334             service.patron_stats = stats
335             service.alert_penalties = service.current.standing_penalties()
336                 .filter(function(pen) { 
337                 return pen.standing_penalty().staff_alert() == 't' 
338             });
339
340             service.summary_stat_cats = [];
341             angular.forEach(service.current.stat_cat_entries(), 
342                 function(map) {
343                     if (angular.isObject(map.stat_cat()) &&
344                         map.stat_cat().usr_summary() == 't') {
345                         service.summary_stat_cats.push(map);
346                     }
347                 }
348             );
349
350             // run these two in parallel
351             var p1 = service.getUserNonCats(service.current.id());
352             var p2 = service.fetchGroupFines();
353             return $q.all([p1, p2]);
354         });
355     }
356
357     // Avoid using parens [e.g. (1.23)] to indicate negative numbers, 
358     // which is the Angular default.
359     // http://stackoverflow.com/questions/17441254/why-angularjs-currency-filter-formats-negative-numbers-with-parenthesis
360     // FIXME: This change needs to be moved into a project-wide collection
361     // of locale overrides.
362     $locale.NUMBER_FORMATS.PATTERNS[1].negPre = '-';
363     $locale.NUMBER_FORMATS.PATTERNS[1].negSuf = '';
364
365     return service;
366 }])
367
368 /**
369  * Manages patron search
370  */
371 .controller('BasePatronSearchCtrl',
372        ['$scope','$q','$routeParams','$timeout','$window','$location','egCore',
373        '$filter','egUser', 'patronSvc','egGridDataProvider','$document',
374        'egProgressDialog',
375 function($scope,  $q,  $routeParams,  $timeout,  $window,  $location,  egCore,
376         $filter,  egUser,  patronSvc , egGridDataProvider , $document,
377         egProgressDialog) {
378
379     $scope.focusMe = true;
380     $scope.searchArgs = {
381         // default to searching globally
382         home_ou : egCore.org.tree()
383     };
384
385     // last used patron search form element
386     var lastFormElement;
387
388     $scope.gridControls = {
389         selectedItems : function() {return []}
390     }
391
392     // Handle URL-encoded searches
393     if ($location.search().search) {
394         console.log('URL search = ' + $location.search().search);
395         patronSvc.urlSearch = {search : JSON2js($location.search().search)};
396
397         // why the double-JSON encoded sort?
398         if (patronSvc.urlSearch.search.search_sort) {
399             patronSvc.urlSearch.sort = 
400                 JSON2js(patronSvc.urlSearch.search.search_sort);
401         } else {
402             patronSvc.urlSearch.sort = [];
403         }
404         delete patronSvc.urlSearch.search.search_sort;
405
406         // include inactive patrons if "inactive" param
407         if ($location.search().inactive) {
408             patronSvc.urlSearch.inactive = $location.search().inactive;
409         }
410     }
411
412     var propagate;
413     var propagate_inactive;
414     if (patronSvc.lastSearch) {
415         propagate = patronSvc.lastSearch.search;
416         // home_ou needs to be treated specially
417         propagate.home_ou = {
418             value : patronSvc.lastSearch.home_ou,
419             group : 0
420         };
421     } else if (patronSvc.urlSearch) {
422         propagate = patronSvc.urlSearch.search;
423         if (patronSvc.urlSearch.inactive) {
424             propagate_inactive = patronSvc.urlSearch.inactive;
425         }
426     }
427
428     if (egCore.env.pgt) {
429         $scope.profiles = egCore.env.pgt.list;
430     } else {
431         egCore.pcrud.search('pgt', {parent : null}, 
432             {flesh : -1, flesh_fields : {pgt : ['children']}}
433         ).then(
434             function(tree) {
435                 egCore.env.absorbTree(tree, 'pgt')
436                 $scope.profiles = egCore.env.pgt.list;
437             }
438         );
439     }
440
441     if (propagate) {
442         // populate the search form with our cached / preexisting search info
443         angular.forEach(propagate, function(val, key) {
444             if (key == 'profile')
445                 val.value = $scope.profiles.filter(function(p) { return p.id() == val.value })[0];
446             if (key == 'home_ou')
447                 val.value = egCore.org.get(val.value);
448             $scope.searchArgs[key] = val.value;
449         });
450         if (propagate_inactive) {
451             $scope.searchArgs.inactive = propagate_inactive;
452         }
453     }
454
455     var provider = egGridDataProvider.instance({});
456
457     provider.get = function(offset, count) {
458         var deferred = $q.defer();
459
460         var fullSearch;
461         if (patronSvc.urlSearch) {
462             fullSearch = patronSvc.urlSearch;
463             // enusre the urlSearch only runs once.
464             delete patronSvc.urlSearch;
465
466         } else {
467             patronSvc.search_barcode = $scope.searchArgs.card;
468             
469             var search = compileSearch($scope.searchArgs);
470             if (Object.keys(search) == 0) return $q.when();
471
472             var home_ou = search.home_ou;
473             delete search.home_ou;
474             var inactive = search.inactive;
475             delete search.inactive;
476
477             fullSearch = {
478                 search : search,
479                 sort : compileSort(),
480                 inactive : inactive,
481                 home_ou : home_ou,
482             };
483         }
484
485         fullSearch.count = count;
486         fullSearch.offset = offset;
487
488         if (patronSvc.lastSearch) {
489             // search repeated, return the cached results
490             if (angular.equals(fullSearch, patronSvc.lastSearch)) {
491                 console.log('patron search returning ' + 
492                     patronSvc.patrons.length + ' cached results');
493                 
494                 // notify has to happen after returning the promise
495                 $timeout(
496                     function() {
497                         angular.forEach(patronSvc.patrons, function(user) {
498                             deferred.notify(user);
499                         });
500                         deferred.resolve();
501                     }
502                 );
503                 return deferred.promise;
504             }
505         }
506
507         patronSvc.lastSearch = fullSearch;
508
509         if (fullSearch.search.id) {
510             // search by user id performs a direct ID lookup
511             var userId = fullSearch.search.id.value;
512             $timeout(
513                 function() {
514                     egUser.get(userId).then(function(user) {
515                         patronSvc.localFlesh(user);
516                         patronSvc.patrons = [user];
517                         deferred.notify(user);
518                         deferred.resolve();
519                     });
520                 }
521             );
522             return deferred.promise;
523         }
524
525         if (!Object.keys(fullSearch.search).length) {
526             // Empty searches are rejected by the server.  Avoid 
527             // running the the empty search that runs on page load. 
528             return $q.when();
529         }
530
531         egProgressDialog.open(); // Indeterminate
532
533         patronSvc.patrons = [];
534         var which_sound = 'success';
535         egCore.net.request(
536             'open-ils.actor',
537             'open-ils.actor.patron.search.advanced.fleshed',
538             egCore.auth.token(), 
539             fullSearch.search, 
540             fullSearch.count,
541             fullSearch.sort,
542             fullSearch.inactive,
543             fullSearch.home_ou,
544             egUser.defaultFleshFields,
545             fullSearch.offset
546
547         ).then(
548             function() {
549                 deferred.resolve();
550             },
551             function() { // onerror
552                 which_sound = 'error';
553             },
554             function(user) {
555                 // hide progress bar as soon as the first result appears.
556                 egProgressDialog.close();
557                 patronSvc.localFlesh(user); // inline
558                 patronSvc.patrons.push(user);
559                 deferred.notify(user);
560             }
561         )['finally'](function() { // close on 0-hits or error
562             if (which_sound == 'success' && patronSvc.patrons.length == 0) {
563                 which_sound = 'warning';
564             }
565             egCore.audio.play(which_sound + '.patron.by_search');
566             egProgressDialog.close();
567         });
568
569         return deferred.promise;
570     };
571
572     $scope.patronSearchGridProvider = provider;
573
574     // determine the tree depth of the profile group
575     $scope.pgt_depth = function(grp) {
576         var d = 0;
577         while (grp = egCore.env.pgt.map[grp.parent()]) d++;
578         return d;
579     }
580
581     $scope.clearForm = function () {
582         $scope.searchArgs={};
583         if (lastFormElement) lastFormElement.focus();
584     }
585
586     $scope.applyShowExtras = function($event, bool) {
587         if (bool) {
588             $scope.showExtras = true;
589             egCore.hatch.setItem('eg.circ.patron.search.show_extras', true);
590         } else {
591             $scope.showExtras = false;
592             egCore.hatch.removeItem('eg.circ.patron.search.show_extras');
593         }
594         if (lastFormElement) lastFormElement.focus();
595         $event.preventDefault();
596     }
597
598     egCore.hatch.getItem('eg.circ.patron.search.show_extras')
599     .then(function(val) {$scope.showExtras = val});
600
601     // map form arguments into search params
602     function compileSearch(args) {
603         var search = {};
604         angular.forEach(args, function(val, key) {
605             if (!val) return;
606             if (key == 'profile' && args.profile) {
607                 search.profile = {value : args.profile.id(), group : 0};
608             } else if (key == 'home_ou' && args.home_ou) {
609                 search.home_ou = args.home_ou.id(); // passed separately
610             } else if (key == 'inactive') {
611                 search.inactive = val;
612             } else {
613                 search[key] = {value : val, group : 0};
614             }
615             if (key.match(/phone|ident/)) {
616                 search[key].group = 2;
617             } else {
618                 if (key.match(/street|city|state|post_code/)) {
619                     search[key].group = 1;
620                 } else if (key == 'card') {
621                     search[key].group = 3
622                 }
623             }
624         });
625
626         return search;
627     }
628
629     function compileSort() {
630
631         if (!provider.sort.length) {
632             return [ // default
633                 "family_name ASC",
634                 "first_given_name ASC",
635                 "second_given_name ASC",
636                 "dob DESC"
637             ];
638         }
639
640         var sort = [];
641         angular.forEach(
642             provider.sort,
643             function(sortdef) {
644                 if (angular.isObject(sortdef)) {
645                     var name = Object.keys(sortdef)[0];
646                     var dir = sortdef[name];
647                     sort.push(name + ' ' + dir);
648                 } else {
649                     sort.push(sortdef);
650                 }
651             }
652         );
653
654         return sort;
655     }
656
657     $scope.setLastFormElement = function() {
658         lastFormElement = $document[0].activeElement;
659     }
660
661     // search form submit action; tells the results grid to
662     // refresh itself.
663     $scope.search = function(args) { // args === $scope.searchArgs
664         if (args && Object.keys(args).length) 
665             $scope.gridControls.refresh();
666         if (lastFormElement) lastFormElement.focus();
667     }
668
669     // TODO: move this into the (forthcoming) grid row activate action
670     $scope.onPatronDblClick = function($event, user) {
671         $location.path('/circ/patron/' + user.id() + '/checkout');
672     }
673
674     if (patronSvc.urlSearch) {
675         // force the grid to load the url-based search on page load
676         provider.refresh();
677     }
678
679     $scope.need_two_selected = function() {
680         var items = $scope.gridControls.selectedItems();
681         return (items.length == 2) ? false : true;
682     }
683    
684 }])
685