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