LP#1709521 Webstaff show recent patrons
[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','egConfirmDialog','$locale',
13 function($q , $timeout , $location , egCore,  egUser , egConfirmDialog , $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     service.getRecentPatrons = function() {
78         // avoid getting stuck in a show-recent loop
79         service.showRecent = false;
80
81         if (service.maxRecentPatrons < 1) return $q.when();
82         var patrons = 
83             egCore.hatch.getLoginSessionItem('eg.circ.recent_patrons') || [];
84
85         // Ensure the cached list is no bigger than the current config.
86         // This can happen if the setting changes while logged in.
87         patrons = patrons.slice(0, service.maxRecentPatrons);
88
89         // add home_ou to the list of fleshed fields for recent patrons
90         var fleshFields = egUser.defaultFleshFields.slice(0);
91         fleshFields.push('home_ou');
92
93         var deferred = $q.defer();
94         function getNext() {
95             if (patrons.length == 0) {
96                 deferred.resolve();
97                 return;
98             }
99             egUser.get(patrons[0], {useFields : fleshFields}).then(
100                 function(usr) { // fetch first user
101                     deferred.notify(usr);
102                     patrons.splice(0, 1); // remove first user from list
103                     getNext();
104                 }
105             );
106         }
107
108         getNext();
109         return deferred.promise;
110     }
111
112     service.addRecentPatron = function(user_id) {
113         if (service.maxRecentPatrons < 1) return;
114
115         // no need to re-track same user
116         if (service.current && service.current.id() == user_id) return;
117
118         var patrons = 
119             egCore.hatch.getLoginSessionItem('eg.circ.recent_patrons') || [];
120         patrons.splice(0, 0, user_id);  // put this user at front
121         patrons.splice(service.maxRecentPatrons, 1); // remove excess
122
123         // remove any other occurrences of this user, which may have been
124         // added before the most recent user.
125         var idx = patrons.indexOf(user_id, 1);
126         if (idx > 0) patrons.splice(idx, 1);
127
128         egCore.hatch.setLoginSessionItem('eg.circ.recent_patrons', patrons);
129     }
130
131     // sets the primary display user, fetching data as necessary.
132     service.setPrimary = function(id, user, force) {
133         var user_id = id ? id : (user ? user.id() : null);
134
135         console.debug('setting primary user to: ' + user_id);
136
137         if (!user_id) return $q.reject();
138
139         service.addRecentPatron(user_id);
140
141         // avoid running multiple retrievals for the same patron, which
142         // can happen during dbl-click by maintaining a single running
143         // data retrieval promise
144         if (service.primaryUserPromise) {
145             if (service.primaryUserId == user_id) {
146                 return service.primaryUserPromise.promise;
147             } else {
148                 service.primaryUserPromise = null;
149             }
150         }
151
152         service.primaryUserPromise = $q.defer();
153         service.primaryUserId = user_id;
154
155         service.getPrimary(id, user, force)
156         .then(function() {
157             service.checkAlerts();
158             var p = service.primaryUserPromise;
159             service.primaryUserId = null;
160             // clear before resolution just to be safe.
161             service.primaryUserPromise = null;
162             p.resolve();
163         });
164
165         return service.primaryUserPromise.promise;
166     }
167
168     service.getPrimary = function(id, user, force) {
169
170         if (user) {
171             if (!force && service.current && 
172                 service.current.id() == user.id()) {
173                 if (service.patron_stats) {
174                     return $q.when();
175                 } else {
176                     return service.fetchUserStats();
177                 }
178             }
179
180             service.resetPatronLists();
181
182             return service.checkOptIn(user).then(
183                 function() {
184                     service.current = user;
185                     service.localFlesh(user);
186                     return service.fetchUserStats();
187                 },
188                 function() {
189                     return $q.reject();
190                 }
191             );
192
193         } else if (id) {
194             if (!force && service.current && service.current.id() == id) {
195                 if (service.patron_stats) {
196                     return $q.when();
197                 } else {
198                     return service.fetchUserStats();
199                 }
200             }
201
202             service.resetPatronLists();
203
204             return egUser.get(id).then(
205                 function(user) {
206                     return service.checkOptIn(user).then(
207                         function() {
208                             service.current = user;
209                             service.localFlesh(user);
210                             return service.fetchUserStats();
211                         },
212                         function() {
213                             return $q.reject();
214                         }
215                     );
216                 },
217                 function(err) {
218                     console.error(
219                         "unable to fetch user "+id+': '+js2JSON(err))
220                 }
221             );
222         } else {
223
224             // fetching a null user clears the primary user.
225             // NOTE: this should probably reject() and log an error, 
226             // but calling clear for backwards compat for now.
227             return service.clearPrimary();
228         }
229     }
230
231     // flesh some additional user fields locally
232     service.localFlesh = function(user) {
233         if (!angular.isObject(typeof user.home_ou()))
234             user.home_ou(egCore.org.get(user.home_ou()));
235
236         angular.forEach(
237             user.standing_penalties(),
238             function(penalty) {
239                 if (!angular.isObject(penalty.org_unit()))
240                     penalty.org_unit(egCore.org.get(penalty.org_unit()));
241             }
242         );
243
244         // stat_cat_entries == stat_cat_entry_user_map
245         angular.forEach(user.stat_cat_entries(), function(map) {
246             if (angular.isObject(map.stat_cat())) return;
247             // At page load, we only retrieve org-visible stat cats.
248             // For the common case, ignore entries for remote stat cats.
249             var cat = egCore.env.actsc.map[map.stat_cat()];
250             if (cat) {
251                 map.stat_cat(cat);
252                 cat.owner(egCore.org.get(cat.owner()));
253             }
254         });
255     }
256
257     // resolves to true if the patron account has expired or will
258     // expire soon, based on YAOUS circ.patron_expires_soon_warning
259     // note: returning a promise is no longer strictly necessary
260     // (no more async activity) if the calling function is changed too.
261     service.testExpire = function() {
262
263         var expire = Date.parse(service.current.expire_date());
264         if (expire < new Date()) {
265             return $q.when(service.patronExpired = true);
266         }
267
268         var soon = egCore.env.aous['circ.patron_expires_soon_warning'];
269         if (Number(soon)) {
270             var preExpire = new Date();
271             preExpire.setDate(preExpire.getDate() + Number(soon));
272             if (expire < preExpire) 
273                 return $q.when(service.patronExpiresSoon = true);
274         }
275
276         return $q.when(false);
277     }
278
279     // resolves to true if the patron account has any invalid addresses.
280     service.testInvalidAddrs = function() {
281
282         if (service.invalidAddresses)
283             return $q.when(true);
284
285         var fail = false;
286
287         angular.forEach(
288             service.current.addresses(), 
289             function(addr) { if (addr.valid() == 'f') fail = true }
290         );
291
292         return $q.when(fail);
293     }
294     //resolves to true if the patron was fetched with an inactive card
295     service.fetchedWithInactiveCard = function() {
296         var bc = service.search_barcode
297         var cards = service.current.cards();
298         var card = cards.filter(function(c) { return c.barcode() == bc })[0];
299         return (card && card.active() == 'f');
300     }   
301     // resolves to true if there is any aspect of the patron account
302     // which should produce a message in the alerts panel
303     service.checkAlerts = function() {
304
305         if (service.hasAlerts) // already checked
306             return $q.when(true); 
307
308         var deferred = $q.defer();
309         var p = service.current;
310
311         if (service.alert_penalties.length ||
312             p.alert_message() ||
313             p.active() == 'f' ||
314             p.barred() == 't' ||
315             service.patron_stats.holds.ready) {
316
317             service.hasAlerts = true;
318         }
319
320         // see if the user was retrieved with an inactive card
321         if(service.fetchedWithInactiveCard()){
322             service.hasAlerts = true;
323         }
324
325         // regardless of whether we know of alerts, we still need 
326         // to test/fetch the expire data for display
327         service.testExpire().then(function(bool) {
328             if (bool) service.hasAlerts = true;
329             deferred.resolve(service.hasAlerts);
330         });
331
332         service.testInvalidAddrs().then(function(bool) {
333             if (bool) service.invalidAddresses = true;
334             deferred.resolve(service.invalidAddresses);
335         });
336
337         return deferred.promise;
338     }
339
340     service.fetchGroupFines = function() {
341         return egCore.net.request(
342             'open-ils.actor',
343             'open-ils.actor.usergroup.members.balance_owed',
344             egCore.auth.token(), service.current.usrgroup()
345         ).then(function(list) {
346             var total = 0;
347             angular.forEach(list, function(u) { 
348                 total += 100 * Number(u.balance_owed)
349             });
350             service.patron_stats.fines.group_balance_owed = total / 100;
351         });
352     }
353
354     service.getUserStats = function(id) {
355         return egCore.net.request(
356             'open-ils.actor',
357             'open-ils.actor.user.opac.vital_stats.authoritative', 
358             egCore.auth.token(), id
359         ).then(
360             function(stats) {
361                 // force numeric to ensure correct boolean handling in templates
362                 stats.fines.balance_owed = Number(stats.fines.balance_owed);
363                 stats.checkouts.overdue = Number(stats.checkouts.overdue);
364                 stats.checkouts.claims_returned = 
365                     Number(stats.checkouts.claims_returned);
366                 stats.checkouts.lost = Number(stats.checkouts.lost);
367                 stats.checkouts.out = Number(stats.checkouts.out);
368                 stats.checkouts.total_out = 
369                     stats.checkouts.out + stats.checkouts.overdue;
370
371                 stats.checkouts.total_out += Number(stats.checkouts.long_overdue);
372
373                 if (!egCore.env.aous['circ.do_not_tally_claims_returned'])
374                     stats.checkouts.total_out += stats.checkouts.claims_returned;
375
376                 if (egCore.env.aous['circ.tally_lost'])
377                     stats.checkouts.total_out += stats.checkouts.lost
378
379                 return stats;
380             }
381         );
382     }
383
384     // Fetches the IDs of any active non-cat checkouts for the current
385     // user.  Also sets the patron_stats non_cat count value to match.
386     service.getUserNonCats = function(id) {
387         return egCore.net.request(
388             'open-ils.circ',
389             'open-ils.circ.open_non_cataloged_circulation.user.authoritative',
390             egCore.auth.token(), id
391         ).then(function(noncat_ids) {
392             service.noncat_ids = noncat_ids;
393             service.patron_stats.checkouts.noncat = noncat_ids.length;
394         });
395     }
396
397     // grab additional circ info
398     service.fetchUserStats = function() {
399         return service.getUserStats(service.current.id())
400         .then(function(stats) {
401             service.patron_stats = stats
402             service.alert_penalties = service.current.standing_penalties()
403                 .filter(function(pen) { 
404                 return pen.standing_penalty().staff_alert() == 't' 
405             });
406
407             service.summary_stat_cats = [];
408             angular.forEach(service.current.stat_cat_entries(), 
409                 function(map) {
410                     if (angular.isObject(map.stat_cat()) &&
411                         map.stat_cat().usr_summary() == 't') {
412                         service.summary_stat_cats.push(map);
413                     }
414                 }
415             );
416
417             // run these two in parallel
418             var p1 = service.getUserNonCats(service.current.id());
419             var p2 = service.fetchGroupFines();
420             return $q.all([p1, p2]);
421         });
422     }
423
424     service.createOptIn = function(user_id) {
425         return egCore.net.request(
426             'open-ils.actor',
427             'open-ils.actor.user.org_unit_opt_in.create',
428             egCore.auth.token(), user_id);
429     }
430
431     service.checkOptIn = function(user) {
432         var deferred = $q.defer();
433         egCore.net.request(
434             'open-ils.actor',
435             'open-ils.actor.user.org_unit_opt_in.check',
436             egCore.auth.token(), user.id())
437         .then(function(optInResp) {
438             if (eg_evt = egCore.evt.parse(optInResp)) {
439                 deferred.reject();
440                 console.log('error on opt-in check: ' + eg_evt);
441             } else if (optInResp == 2) {
442                 // opt-in disallowed at this location by patron's home library
443                 deferred.reject();
444                 alert(egCore.strings.OPT_IN_RESTRICTED);
445             } else if (optInResp == 1) {
446                 // opt-in handled or not needed, do nothing
447                 deferred.resolve();
448             } else {
449                 // opt-in needed, show the opt-in dialog
450                 var org = egCore.org.get(user.home_ou());
451                 egConfirmDialog.open(
452                     egCore.strings.OPT_IN_DIALOG_TITLE,
453                     egCore.strings.OPT_IN_DIALOG,
454                     {   family_name : user.family_name(),
455                         first_given_name : user.first_given_name(),
456                         org_name : org.name(),
457                         org_shortname : org.shortname(),
458                         ok : function() {
459                             service.createOptIn(user.id())
460                             .then(function(resp) {
461                                 if (evt = egCore.evt.parse(resp)) {
462                                     deferred.reject();
463                                     alert(evt);
464                                 } else {
465                                     deferred.resolve();
466                                 }
467                             });
468                         },
469                         cancel : function() { deferred.reject(); }
470                     }
471                 );
472             }
473         });
474         return deferred.promise;
475     }
476
477     // Avoid using parens [e.g. (1.23)] to indicate negative numbers, 
478     // which is the Angular default.
479     // http://stackoverflow.com/questions/17441254/why-angularjs-currency-filter-formats-negative-numbers-with-parenthesis
480     // FIXME: This change needs to be moved into a project-wide collection
481     // of locale overrides.
482     $locale.NUMBER_FORMATS.PATTERNS[1].negPre = '-';
483     $locale.NUMBER_FORMATS.PATTERNS[1].negSuf = '';
484
485     return service;
486 }])
487
488 /**
489  * Manages patron search
490  */
491 .controller('BasePatronSearchCtrl',
492        ['$scope','$q','$routeParams','$timeout','$window','$location','egCore',
493        '$filter','egUser', 'patronSvc','egGridDataProvider','$document',
494        'egProgressDialog',
495 function($scope,  $q,  $routeParams,  $timeout,  $window,  $location,  egCore,
496         $filter,  egUser,  patronSvc , egGridDataProvider , $document,
497         egProgressDialog) {
498
499     $scope.focusMe = true;
500     $scope.searchArgs = {
501         // default to searching globally
502         home_ou : egCore.org.tree()
503     };
504
505     // last used patron search form element
506     var lastFormElement;
507
508     $scope.gridControls = {
509         selectedItems : function() {return []}
510     }
511
512     // Max recents setting is loaded and scrubbed during egStartup.
513     // Copy it to a local variable here for ease of local access.
514     egCore.org.settings('ui.staff.max_recent_patrons').then(function(s) {
515         patronSvc.maxRecentPatrons = s['ui.staff.max_recent_patrons'];
516     });
517
518     // The first time we encounter the show-recent CGI param, put the
519     // service into show-recent mode.  The first time recents are shown,
520     // the service is taken out of show-recent mode so the page does not
521     // get stuck in a show-recent loop.
522     if (patronSvc.showRecent === undefined 
523         && Boolean($location.path().match(/search/))
524         && Boolean($location.search().show_recent)) {
525         patronSvc.showRecent = true;
526     }
527
528     // Handle URL-encoded searches
529     if ($location.search().search) {
530         console.log('URL search = ' + $location.search().search);
531         patronSvc.urlSearch = {search : JSON2js($location.search().search)};
532
533         // why the double-JSON encoded sort?
534         if (patronSvc.urlSearch.search.search_sort) {
535             patronSvc.urlSearch.sort = 
536                 JSON2js(patronSvc.urlSearch.search.search_sort);
537         } else {
538             patronSvc.urlSearch.sort = [];
539         }
540         delete patronSvc.urlSearch.search.search_sort;
541
542         // include inactive patrons if "inactive" param
543         if ($location.search().inactive) {
544             patronSvc.urlSearch.inactive = $location.search().inactive;
545         }
546     }
547
548     var propagate;
549     var propagate_inactive;
550     if (patronSvc.lastSearch && !patronSvc.showRecent) {
551         propagate = patronSvc.lastSearch.search;
552         // home_ou needs to be treated specially
553         propagate.home_ou = {
554             value : patronSvc.lastSearch.home_ou,
555             group : 0
556         };
557     } else if (patronSvc.urlSearch) {
558         propagate = patronSvc.urlSearch.search;
559         if (patronSvc.urlSearch.inactive) {
560             propagate_inactive = patronSvc.urlSearch.inactive;
561         }
562     }
563
564     if (egCore.env.pgt) {
565         $scope.profiles = egCore.env.pgt.list;
566     } else {
567         egCore.pcrud.search('pgt', {parent : null}, 
568             {flesh : -1, flesh_fields : {pgt : ['children']}}
569         ).then(
570             function(tree) {
571                 egCore.env.absorbTree(tree, 'pgt')
572                 $scope.profiles = egCore.env.pgt.list;
573             }
574         );
575     }
576
577     if (propagate) {
578         // populate the search form with our cached / preexisting search info
579         angular.forEach(propagate, function(val, key) {
580             if (key == 'profile')
581                 val.value = $scope.profiles.filter(function(p) { return p.id() == val.value })[0];
582             if (key == 'home_ou')
583                 val.value = egCore.org.get(val.value);
584             $scope.searchArgs[key] = val.value;
585         });
586         if (propagate_inactive) {
587             $scope.searchArgs.inactive = propagate_inactive;
588         }
589     }
590
591     var provider = egGridDataProvider.instance({});
592
593     provider.get = function(offset, count) {
594         var deferred = $q.defer();
595
596         if (patronSvc.showRecent) {
597             // avoid getting stuck in show-recent mode
598             return patronSvc.getRecentPatrons();
599         }
600
601         var fullSearch;
602         if (patronSvc.urlSearch) {
603             fullSearch = patronSvc.urlSearch;
604             // enusre the urlSearch only runs once.
605             delete patronSvc.urlSearch;
606
607         } else {
608             patronSvc.search_barcode = $scope.searchArgs.card;
609             
610             var search = compileSearch($scope.searchArgs);
611             if (Object.keys(search) == 0) return $q.when();
612
613             var home_ou = search.home_ou;
614             delete search.home_ou;
615             var inactive = search.inactive;
616             delete search.inactive;
617
618             fullSearch = {
619                 search : search,
620                 sort : compileSort(),
621                 inactive : inactive,
622                 home_ou : home_ou,
623             };
624         }
625
626         fullSearch.count = count;
627         fullSearch.offset = offset;
628
629         if (patronSvc.lastSearch) {
630             // search repeated, return the cached results
631             if (angular.equals(fullSearch, patronSvc.lastSearch)) {
632                 console.log('patron search returning ' + 
633                     patronSvc.patrons.length + ' cached results');
634                 
635                 // notify has to happen after returning the promise
636                 $timeout(
637                     function() {
638                         angular.forEach(patronSvc.patrons, function(user) {
639                             deferred.notify(user);
640                         });
641                         deferred.resolve();
642                     }
643                 );
644                 return deferred.promise;
645             }
646         }
647
648         patronSvc.lastSearch = fullSearch;
649
650         if (fullSearch.search.id) {
651             // search by user id performs a direct ID lookup
652             var userId = fullSearch.search.id.value;
653             $timeout(
654                 function() {
655                     egUser.get(userId).then(function(user) {
656                         patronSvc.localFlesh(user);
657                         patronSvc.patrons = [user];
658                         deferred.notify(user);
659                         deferred.resolve();
660                     });
661                 }
662             );
663             return deferred.promise;
664         }
665
666         if (!Object.keys(fullSearch.search).length) {
667             // Empty searches are rejected by the server.  Avoid 
668             // running the the empty search that runs on page load. 
669             return $q.when();
670         }
671
672         egProgressDialog.open(); // Indeterminate
673
674         patronSvc.patrons = [];
675         var which_sound = 'success';
676         egCore.net.request(
677             'open-ils.actor',
678             'open-ils.actor.patron.search.advanced.fleshed',
679             egCore.auth.token(), 
680             fullSearch.search, 
681             fullSearch.count,
682             fullSearch.sort,
683             fullSearch.inactive,
684             fullSearch.home_ou,
685             egUser.defaultFleshFields,
686             fullSearch.offset
687
688         ).then(
689             function() {
690                 deferred.resolve();
691             },
692             function() { // onerror
693                 which_sound = 'error';
694             },
695             function(user) {
696                 // hide progress bar as soon as the first result appears.
697                 egProgressDialog.close();
698                 patronSvc.localFlesh(user); // inline
699                 patronSvc.patrons.push(user);
700                 deferred.notify(user);
701             }
702         )['finally'](function() { // close on 0-hits or error
703             if (which_sound == 'success' && patronSvc.patrons.length == 0) {
704                 which_sound = 'warning';
705             }
706             egCore.audio.play(which_sound + '.patron.by_search');
707             egProgressDialog.close();
708         });
709
710         return deferred.promise;
711     };
712
713     $scope.patronSearchGridProvider = provider;
714
715     // determine the tree depth of the profile group
716     $scope.pgt_depth = function(grp) {
717         var d = 0;
718         while (grp = egCore.env.pgt.map[grp.parent()]) d++;
719         return d;
720     }
721
722     $scope.clearForm = function () {
723         $scope.searchArgs={};
724         if (lastFormElement) lastFormElement.focus();
725     }
726
727     $scope.applyShowExtras = function($event, bool) {
728         if (bool) {
729             $scope.showExtras = true;
730             egCore.hatch.setItem('eg.circ.patron.search.show_extras', true);
731         } else {
732             $scope.showExtras = false;
733             egCore.hatch.removeItem('eg.circ.patron.search.show_extras');
734         }
735         if (lastFormElement) lastFormElement.focus();
736         $event.preventDefault();
737     }
738
739     egCore.hatch.getItem('eg.circ.patron.search.show_extras')
740     .then(function(val) {$scope.showExtras = val});
741
742     // map form arguments into search params
743     function compileSearch(args) {
744         var search = {};
745         angular.forEach(args, function(val, key) {
746             if (!val) return;
747             if (key == 'profile' && args.profile) {
748                 search.profile = {value : args.profile.id(), group : 0};
749             } else if (key == 'home_ou' && args.home_ou) {
750                 search.home_ou = args.home_ou.id(); // passed separately
751             } else if (key == 'inactive') {
752                 search.inactive = val;
753             } else {
754                 search[key] = {value : val, group : 0};
755             }
756             if (key.match(/phone|ident/)) {
757                 search[key].group = 2;
758             } else {
759                 if (key.match(/street|city|state|post_code/)) {
760                     search[key].group = 1;
761                 } else if (key == 'card') {
762                     search[key].group = 3
763                 }
764             }
765         });
766
767         return search;
768     }
769
770     function compileSort() {
771
772         if (!provider.sort.length) {
773             return [ // default
774                 "family_name ASC",
775                 "first_given_name ASC",
776                 "second_given_name ASC",
777                 "dob DESC"
778             ];
779         }
780
781         var sort = [];
782         angular.forEach(
783             provider.sort,
784             function(sortdef) {
785                 if (angular.isObject(sortdef)) {
786                     var name = Object.keys(sortdef)[0];
787                     var dir = sortdef[name];
788                     sort.push(name + ' ' + dir);
789                 } else {
790                     sort.push(sortdef);
791                 }
792             }
793         );
794
795         return sort;
796     }
797
798     $scope.setLastFormElement = function() {
799         lastFormElement = $document[0].activeElement;
800     }
801
802     // search form submit action; tells the results grid to
803     // refresh itself.
804     $scope.search = function(args) { // args === $scope.searchArgs
805         if (args && Object.keys(args).length) 
806             $scope.gridControls.refresh();
807         if (lastFormElement) lastFormElement.focus();
808     }
809
810     // TODO: move this into the (forthcoming) grid row activate action
811     $scope.onPatronDblClick = function($event, user) {
812         $location.path('/circ/patron/' + user.id() + '/checkout');
813     }
814
815     if (patronSvc.urlSearch) {
816         // force the grid to load the url-based search on page load
817         provider.refresh();
818     }
819
820     $scope.need_two_selected = function() {
821         var items = $scope.gridControls.selectedItems();
822         return (items.length == 2) ? false : true;
823     }
824    
825 }])
826