]> git.evergreen-ils.org Git - working/Evergreen.git/blob - Open-ILS/web/js/ui/default/staff/circ/patron/app.js
630ca9ccb46cac5b96c2f939ef58d6a9babd17b5
[working/Evergreen.git] / Open-ILS / web / js / ui / default / staff / circ / patron / app.js
1 /**
2  * Patron App
3  *
4  * Search, checkout, items out, holds, bills, edit, etc.
5  */
6
7 angular.module('egPatronApp', ['ngRoute', 'ui.bootstrap', 
8     'egCoreMod', 'egUiMod', 'egGridMod', 'egUserMod', 'ngToast'])
9
10 .config(['ngToastProvider', function(ngToastProvider) {
11     ngToastProvider.configure({
12         verticalPosition: 'bottom',
13         animation: 'fade'
14     });
15 }])
16
17 .config(function($routeProvider, $locationProvider, $compileProvider) {
18     $locationProvider.html5Mode(true);
19     $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
20
21     // data loaded at startup which only requires an authtoken goes
22     // here. this allows the requests to be run in parallel instead of
23     // waiting until startup has completed.
24     var resolver = {delay : ['egCore','egUser', function(egCore , egUser) {
25
26         // fetch the org settings we care about during egStartup
27         // and toss them into egCore.env as egCore.env.aous[name] = value.
28         // note: only load settings here needed by all tabs; load tab-
29         // specific settings from within their respective controllers
30         egCore.env.classLoaders.aous = function() {
31             return egCore.org.settings([
32                 'ui.staff.require_initials.patron_info_notes',
33                 'circ.do_not_tally_claims_returned',
34                 'circ.tally_lost',
35                 'circ.obscure_dob',
36                 'ui.circ.show_billing_tab_on_bills',
37                 'circ.patron_expires_soon_warning',
38                 'ui.circ.items_out.lost',
39                 'ui.circ.items_out.longoverdue',
40                 'ui.circ.items_out.claimsreturned'
41             ]).then(function(settings) { 
42                 // local settings are cached within egOrg.  Caching them
43                 // again in egEnv just simplifies the syntax for access.
44                 egCore.env.aous = settings;
45             });
46         }
47
48         egCore.env.loadClasses.push('aous');
49
50         // app-globally modify the default flesh fields for 
51         // fleshed user retrieval.
52         if (egUser.defaultFleshFields.indexOf('profile') == -1) {
53             egUser.defaultFleshFields = egUser.defaultFleshFields.concat([
54                 'profile',
55                 'net_access_level',
56                 'ident_type',
57                 'ident_type2',
58                 'cards',
59                 'groups'
60             ]);
61         }
62
63         return egCore.startup.go().then(function() {
64
65             // This call requires orgs to be loaded, because it
66             // calls egCore.org.ancestors(), so call it after startup
67             return egCore.pcrud.search('actsc', 
68                 {owner : egCore.org.ancestors(
69                     egCore.auth.user().ws_ou(), true)},
70                 {}, {atomic : true}
71             ).then(function(cats) {
72                 egCore.env.absorbList(cats, 'actsc');
73             });
74         });
75     }]};
76
77     $routeProvider.when('/circ/patron/search', {
78         templateUrl: './circ/patron/t_search',
79         controller: 'PatronSearchCtrl',
80         resolve : resolver
81     });
82
83     $routeProvider.when('/circ/patron/bcsearch', {
84         templateUrl: './circ/patron/t_bcsearch',
85         controller: 'PatronBarcodeSearchCtrl',
86         resolve : resolver
87     });
88
89     $routeProvider.when('/circ/patron/credentials', {
90         templateUrl: './circ/patron/t_credentials',
91         controller: 'PatronVerifyCredentialsCtrl',
92         resolve : resolver
93     });
94
95     $routeProvider.when('/circ/patron/last', {
96         templateUrl: './circ/patron/t_last_patron',
97         controller: 'PatronFetchLastCtrl',
98         resolve : resolver
99     });
100
101     // the following require a patron ID
102
103     $routeProvider.when('/circ/patron/:id/alerts', {
104         templateUrl: './circ/patron/t_alerts',
105         controller: 'PatronAlertsCtrl',
106         resolve : resolver
107     });
108
109     $routeProvider.when('/circ/patron/:id/checkout', {
110         templateUrl: './circ/patron/t_checkout',
111         controller: 'PatronCheckoutCtrl',
112         resolve : resolver
113     });
114
115     $routeProvider.when('/circ/patron/:id/items_out', {
116         templateUrl: './circ/patron/t_items_out',
117         controller: 'PatronItemsOutCtrl',
118         resolve : resolver
119     });
120
121     $routeProvider.when('/circ/patron/:id/holds', {
122         templateUrl: './circ/patron/t_holds',
123         controller: 'PatronHoldsCtrl',
124         resolve : resolver
125     });
126
127     $routeProvider.when('/circ/patron/:id/holds/create', {
128         templateUrl: './circ/patron/t_holds_create',
129         controller: 'PatronHoldsCreateCtrl',
130         resolve : resolver
131     });
132
133     $routeProvider.when('/circ/patron/:id/holds/:hold_id', {
134         templateUrl: './circ/patron/t_holds',
135         controller: 'PatronHoldsCtrl',
136         resolve : resolver
137     });
138
139     $routeProvider.when('/circ/patron/:id/hold/:hold_id', {
140         templateUrl: './circ/patron/t_hold_details',
141         controller: 'PatronHoldDetailsCtrl',
142         resolve : resolver
143     });
144
145     $routeProvider.when('/circ/patron/:id/bills', {
146         templateUrl: './circ/patron/t_bills',
147         controller: 'PatronBillsCtrl',
148         resolve : resolver
149     });
150
151     $routeProvider.when('/circ/patron/:id/bill/:xact_id', {
152         templateUrl: './circ/patron/t_xact_details',
153         controller: 'XactDetailsCtrl',
154         resolve : resolver
155     });
156
157     $routeProvider.when('/circ/patron/:id/bill_history/:history_tab', {
158         templateUrl: './circ/patron/t_bill_history',
159         controller: 'BillHistoryCtrl',
160         resolve : resolver
161     });
162
163     $routeProvider.when('/circ/patron/:id/messages', {
164         templateUrl: './circ/patron/t_messages',
165         controller: 'PatronMessagesCtrl',
166         resolve : resolver
167     });
168
169     $routeProvider.when('/circ/patron/:id/edit', {
170         templateUrl: './circ/patron/t_edit',
171         controller: 'PatronRegCtrl',
172         resolve : resolver
173     });
174
175     $routeProvider.when('/circ/patron/:id/credentials', {
176         templateUrl: './circ/patron/t_credentials',
177         controller: 'PatronVerifyCredentialsCtrl',
178         resolve : resolver
179     });
180
181     $routeProvider.when('/circ/patron/:id/notes', {
182         templateUrl: './circ/patron/t_notes',
183         controller: 'PatronNotesCtrl',
184         resolve : resolver
185     });
186
187     $routeProvider.when('/circ/patron/:id/triggered_events', {
188         templateUrl: './circ/patron/t_triggered_events',
189         controller: 'PatronTriggeredEventsCtrl',
190         resolve : resolver
191     });
192
193     $routeProvider.when('/circ/patron/:id/edit_perms', {
194         templateUrl: './circ/patron/t_edit_perms',
195         controller: 'PatronPermsCtrl',
196         resolve : resolver
197     });
198
199     $routeProvider.when('/circ/patron/:id/group', {
200         templateUrl: './circ/patron/t_group',
201         controller: 'PatronGroupCtrl',
202         resolve : resolver
203     });
204
205     $routeProvider.when('/circ/patron/:id/stat_cats', {
206         templateUrl: './circ/patron/t_stat_cats',
207         controller: 'PatronStatCatsCtrl',
208         resolve : resolver
209     });
210
211     $routeProvider.otherwise({redirectTo : '/circ/patron/search'});
212 })
213
214 /**
215  * Patron service
216  */
217 .factory('patronSvc',
218        ['$q','$timeout','$location','egCore','egUser','$locale',
219 function($q , $timeout , $location , egCore,  egUser , $locale) {
220
221     var service = {
222         // cached patron search results
223         patrons : [],
224
225         // currently selected patron object
226         current : null, 
227
228         // patron circ stats (overdues, fines, holds)
229         patron_stats : null,
230
231         // event types manually overridden, which should always be
232         // overridden for checkouts to this patron for this instance of
233         // the interface.
234         checkout_overrides : {},        
235         //holds the searched barcode
236         search_barcode : null,      
237     };
238
239     // when we change the default patron, we need to clear out any
240     // data collected on that patron
241     service.resetPatronLists = function() {
242         service.checkouts = [];
243         service.items_out = []
244         service.items_out_ids = [];
245         service.holds = [];
246         service.hold_ids = [];
247         service.checkout_overrides = {};
248         service.patron_stats = null;
249         service.noncat_ids = [];
250         service.hasAlerts = false;
251         service.patronExpired = false;
252         service.patronExpiresSoon = false;
253         service.invalidAddresses = false;
254     }
255     service.resetPatronLists();  // initialize
256
257     // Returns true if the last alerted patron matches the current
258     // patron.  Otherwise, the last alerted patron is set to the 
259     // current patron and false is returned.
260     service.alertsShown = function() {
261         var key = 'eg.circ.last_alerted_patron';
262         var last_id = egCore.hatch.getSessionItem(key);
263         if (last_id && last_id == service.current.id()) return true;
264         egCore.hatch.setSessionItem(key, service.current.id());
265         return false;
266     }
267
268     // shortcut to force-reload the current primary
269     service.refreshPrimary = function() {
270         if (!service.current) return $q.when();
271         return service.setPrimary(service.current.id(), null, true);
272     }
273
274     // clear the currently focused user
275     service.clearPrimary = function() {
276         // reset with no patron
277         service.resetPatronLists();
278         service.current = null;
279         service.patron_stats = null;
280         return $q.when();
281     }
282
283     // sets the primary display user, fetching data as necessary.
284     service.setPrimary = function(id, user, force) {
285         var user_id = id ? id : (user ? user.id() : null);
286
287         console.debug('setting primary user to: ' + user_id);
288
289         if (!user_id) return $q.reject();
290
291         // when loading a new patron, update the last patron setting
292         if (!service.current || service.current.id() != user_id)
293             egCore.hatch.setLoginSessionItem('eg.circ.last_patron', user_id);
294
295         // avoid running multiple retrievals for the same patron, which
296         // can happen during dbl-click by maintaining a single running
297         // data retrieval promise
298         if (service.primaryUserPromise) {
299             if (service.primaryUserId == user_id) {
300                 return service.primaryUserPromise.promise;
301             } else {
302                 service.primaryUserPromise = null;
303             }
304         }
305
306         service.primaryUserPromise = $q.defer();
307         service.primaryUserId = user_id;
308
309         service.getPrimary(id, user, force)
310         .then(function() {
311             service.checkAlerts();
312             var p = service.primaryUserPromise;
313             service.primaryUserId = null;
314             // clear before resolution just to be safe.
315             service.primaryUserPromise = null;
316             p.resolve();
317         });
318
319         return service.primaryUserPromise.promise;
320     }
321
322     service.getPrimary = function(id, user, force) {
323
324         if (user) {
325             if (!force && service.current && 
326                 service.current.id() == user.id()) {
327                 if (service.patron_stats) {
328                     return $q.when();
329                 } else {
330                     return service.fetchUserStats();
331                 }
332             }
333
334             service.resetPatronLists();
335             service.current = user;
336             service.localFlesh(user);
337             return service.fetchUserStats();
338
339         } else if (id) {
340             if (!force && service.current && service.current.id() == id) {
341                 if (service.patron_stats) {
342                     return $q.when();
343                 } else {
344                     return service.fetchUserStats();
345                 }
346             }
347
348             service.resetPatronLists();
349
350             return egUser.get(id).then(
351                 function(user) {
352                     service.current = user;
353                     service.localFlesh(user);
354                     return service.fetchUserStats();
355                 },
356                 function(err) {
357                     console.error(
358                         "unable to fetch user "+id+': '+js2JSON(err))
359                 }
360             );
361         } else {
362
363             // fetching a null user clears the primary user.
364             // NOTE: this should probably reject() and log an error, 
365             // but calling clear for backwards compat for now.
366             return service.clearPrimary();
367         }
368     }
369
370     // flesh some additional user fields locally
371     service.localFlesh = function(user) {
372         if (!angular.isObject(typeof user.home_ou()))
373             user.home_ou(egCore.org.get(user.home_ou()));
374
375         angular.forEach(
376             user.standing_penalties(),
377             function(penalty) {
378                 if (!angular.isObject(penalty.org_unit()))
379                     penalty.org_unit(egCore.org.get(penalty.org_unit()));
380             }
381         );
382
383         // stat_cat_entries == stat_cat_entry_user_map
384         angular.forEach(user.stat_cat_entries(), function(map) {
385             if (angular.isObject(map.stat_cat())) return;
386             // At page load, we only retrieve org-visible stat cats.
387             // For the common case, ignore entries for remote stat cats.
388             var cat = egCore.env.actsc.map[map.stat_cat()];
389             if (cat) {
390                 map.stat_cat(cat);
391                 cat.owner(egCore.org.get(cat.owner()));
392             }
393         });
394     }
395
396     // resolves to true if the patron account has expired or will
397     // expire soon, based on YAOUS circ.patron_expires_soon_warning
398     // note: returning a promise is no longer strictly necessary
399     // (no more async activity) if the calling function is changed too.
400     service.testExpire = function() {
401
402         var expire = Date.parse(service.current.expire_date());
403         if (expire < new Date()) {
404             return $q.when(service.patronExpired = true);
405         }
406
407         var soon = egCore.env.aous['circ.patron_expires_soon_warning'];
408         if (Number(soon)) {
409             var preExpire = new Date();
410             preExpire.setDate(preExpire.getDate() + Number(soon));
411             if (expire < preExpire) 
412                 return $q.when(service.patronExpiresSoon = true);
413         }
414
415         return $q.when(false);
416     }
417
418     // resolves to true if the patron account has any invalid addresses.
419     service.testInvalidAddrs = function() {
420
421         if (service.invalidAddresses)
422             return $q.when(true);
423
424         var fail = false;
425
426         angular.forEach(
427             service.current.addresses(), 
428             function(addr) { if (addr.valid() == 'f') fail = true }
429         );
430
431         return $q.when(fail);
432     }
433     //resolves to true if the patron was fetched with an inactive card
434     service.fetchedWithInactiveCard = function() {
435         var bc = service.search_barcode
436         var cards = service.current.cards();
437         var card = cards.filter(function(c) { return c.barcode() == bc })[0];
438         return (card && card.active() == 'f');
439     }   
440     // resolves to true if there is any aspect of the patron account
441     // which should produce a message in the alerts panel
442     service.checkAlerts = function() {
443
444         if (service.hasAlerts) // already checked
445             return $q.when(true); 
446
447         var deferred = $q.defer();
448         var p = service.current;
449
450         if (service.alert_penalties.length ||
451             p.alert_message() ||
452             p.active() == 'f' ||
453             p.barred() == 't' ||
454             service.patron_stats.holds.ready) {
455
456             service.hasAlerts = true;
457         }
458
459         // see if the user was retrieved with an inactive card
460         if(service.fetchedWithInactiveCard()){
461             service.hasAlerts = true;
462         }
463
464         // regardless of whether we know of alerts, we still need 
465         // to test/fetch the expire data for display
466         service.testExpire().then(function(bool) {
467             if (bool) service.hasAlerts = true;
468             deferred.resolve(service.hasAlerts);
469         });
470
471         service.testInvalidAddrs().then(function(bool) {
472             if (bool) service.invalidAddresses = true;
473             deferred.resolve(service.invalidAddresses);
474         });
475
476         return deferred.promise;
477     }
478
479     service.fetchGroupFines = function() {
480         return egCore.net.request(
481             'open-ils.actor',
482             'open-ils.actor.usergroup.members.balance_owed',
483             egCore.auth.token(), service.current.usrgroup()
484         ).then(function(list) {
485             var total = 0;
486             angular.forEach(list, function(u) { 
487                 total += 100 * Number(u.balance_owed)
488             });
489             service.patron_stats.fines.group_balance_owed = total / 100;
490         });
491     }
492
493     service.getUserStats = function(id) {
494         return egCore.net.request(
495             'open-ils.actor',
496             'open-ils.actor.user.opac.vital_stats.authoritative', 
497             egCore.auth.token(), id
498         ).then(
499             function(stats) {
500                 // force numeric to ensure correct boolean handling in templates
501                 stats.fines.balance_owed = Number(stats.fines.balance_owed);
502                 stats.checkouts.overdue = Number(stats.checkouts.overdue);
503                 stats.checkouts.claims_returned = 
504                     Number(stats.checkouts.claims_returned);
505                 stats.checkouts.lost = Number(stats.checkouts.lost);
506                 stats.checkouts.out = Number(stats.checkouts.out);
507                 stats.checkouts.total_out = 
508                     stats.checkouts.out + stats.checkouts.overdue;
509
510                 if (!egCore.env.aous['circ.do_not_tally_claims_returned'])
511                     stats.checkouts.total_out += stats.checkouts.claims_returned;
512
513                 if (egCore.env.aous['circ.tally_lost'])
514                     stats.checkouts.total_out += stats.checkouts.lost
515
516                 return stats;
517             }
518         );
519     }
520
521     // Fetches the IDs of any active non-cat checkouts for the current
522     // user.  Also sets the patron_stats non_cat count value to match.
523     service.getUserNonCats = function(id) {
524         return egCore.net.request(
525             'open-ils.circ',
526             'open-ils.circ.open_non_cataloged_circulation.user.authoritative',
527             egCore.auth.token(), id
528         ).then(function(noncat_ids) {
529             service.noncat_ids = noncat_ids;
530             service.patron_stats.checkouts.noncat = noncat_ids.length;
531         });
532     }
533
534     // grab additional circ info
535     service.fetchUserStats = function() {
536         return service.getUserStats(service.current.id())
537         .then(function(stats) {
538             service.patron_stats = stats
539             service.alert_penalties = service.current.standing_penalties()
540                 .filter(function(pen) { 
541                 return pen.standing_penalty().staff_alert() == 't' 
542             });
543
544             service.summary_stat_cats = [];
545             angular.forEach(service.current.stat_cat_entries(), 
546                 function(map) {
547                     if (angular.isObject(map.stat_cat()) &&
548                         map.stat_cat().usr_summary() == 't') {
549                         service.summary_stat_cats.push(map);
550                     }
551                 }
552             );
553
554             // run these two in parallel
555             var p1 = service.getUserNonCats(service.current.id());
556             var p2 = service.fetchGroupFines();
557             return $q.all([p1, p2]);
558         });
559     }
560
561     // Avoid using parens [e.g. (1.23)] to indicate negative numbers, 
562     // which is the Angular default.
563     // http://stackoverflow.com/questions/17441254/why-angularjs-currency-filter-formats-negative-numbers-with-parenthesis
564     // FIXME: This change needs to be moved into a project-wide collection
565     // of locale overrides.
566     $locale.NUMBER_FORMATS.PATTERNS[1].negPre = '-';
567     $locale.NUMBER_FORMATS.PATTERNS[1].negSuf = '';
568
569     return service;
570 }])
571
572 /**
573  * Manages tabbed patron view.
574  * This is the parent scope of all patron tab scopes.
575  *
576  * */
577 .controller('PatronCtrl',
578        ['$scope','$q','$location','$filter','egCore','egUser','patronSvc',
579 function($scope,  $q,  $location , $filter,  egCore,  egUser,  patronSvc) {
580
581     $scope.is_patron_edit = function() {
582         return Boolean($location.path().match(/patron\/\d+\/edit$/));
583     }
584
585     // To support the fixed position patron edit actions bar,
586     // its markup has to live outside the scope of the patron 
587     // edit controller.  Insert a scope blob here that can be
588     // modifed from within the patron edit controller.
589     $scope.edit_passthru = {};
590
591     // returns true if a redirect occurs
592     function redirectToAlertPanel() {
593
594         $scope.alert_penalties = 
595             function() {return patronSvc.alert_penalties}
596
597         if (patronSvc.alertsShown()) return false;
598
599         // if the patron has any unshown alerts, show them now
600         if (patronSvc.hasAlerts && 
601             !$location.path().match(/alerts$/)) {
602
603             $location
604                 .path('/circ/patron/' + patronSvc.current.id() + '/alerts')
605                 .search('card', null);
606             return true;
607         }
608
609         // no alert required.  If the patron has fines and the show-bills
610         // OUS is applied, direct to the bills page.
611         if ($scope.patron_stats().fines.balance_owed > 0 // TODO: != 0 ?
612             && egCore.env.aous['ui.circ.show_billing_tab_on_bills']
613             && !$location.path().match(/bills$/)) {
614
615             $scope.tab = 'bills';
616             $location
617                 .path('/circ/patron/' + patronSvc.current.id() + '/bills')
618                 .search('card', null);
619
620             return true;
621         }
622
623         return false;
624     }
625
626     // called after each route-specified controller is instantiated.
627     // this doubles as a way to inform the top-level controller that
628     // egStartup.go() has completed, which means we are clear to 
629     // fetch the patron, etc.
630     $scope.initTab = function(tab, patron_id) {
631         console.log('init tab ' + tab);
632         $scope.tab = tab;
633         $scope.aous = egCore.env.aous;
634
635         if (patron_id) {
636             $scope.patron_id = patron_id;
637             return patronSvc.setPrimary($scope.patron_id)
638             .then(function() {return patronSvc.checkAlerts()})
639             .then(redirectToAlertPanel);
640         }
641         return $q.when();
642     }
643
644     $scope._show_dob = {};
645     $scope.show_dob = function (val) {
646         if ($scope.patron()) {
647             if (typeof val != 'undefined') $scope._show_dob[$scope.patron().id()] = val;
648             return $scope._show_dob[$scope.patron().id()];
649         }
650         return !egCore.env.aous['circ.obscure_dob'];
651     }
652         
653     $scope.obscure_dob = function() { 
654         return egCore.env.aous && egCore.env.aous['circ.obscure_dob'];
655     }
656     $scope.now_show_dob = function() { 
657         return egCore.env.aous && egCore.env.aous['circ.obscure_dob'] ?
658             $scope.show_dob() : true; 
659     }
660
661     $scope.patron = function() { return patronSvc.current }
662     $scope.patron_stats = function() { return patronSvc.patron_stats }
663     $scope.summary_stat_cats = function() { return patronSvc.summary_stat_cats }
664     $scope.hasAlerts = function() { return patronSvc.hasAlerts }
665     $scope.isPatronExpired = function() { return patronSvc.patronExpired }
666
667     $scope.print_address = function(addr) {
668         egCore.print.print({
669             context : 'default', 
670             template : 'patron_address', 
671             scope : {
672                 patron : egCore.idl.toHash(patronSvc.current),
673                 address : egCore.idl.toHash(addr)
674             }
675         });
676     }
677
678     $scope.toggle_expand_summary = function() {
679         if ($scope.collapsePatronSummary) {
680             $scope.collapsePatronSummary = false;
681             egCore.hatch.removeItem('eg.circ.patron.summary.collapse');
682         } else {
683             $scope.collapsePatronSummary = true;
684             egCore.hatch.setItem('eg.circ.patron.summary.collapse', true);
685         }
686     }
687     
688     // always expand the patron summary in the search UI, regardless
689     // of stored preference.
690     $scope.collapse_summary = function() {
691         return $scope.tab != 'search' && $scope.collapsePatronSummary;
692     }
693
694     egCore.hatch.getItem('eg.circ.patron.summary.collapse')
695     .then(function(val) {$scope.collapsePatronSummary = Boolean(val)});
696 }])
697
698 .controller('PatronBarcodeSearchCtrl',
699        ['$scope','$location','egCore','egConfirmDialog','egUser','patronSvc',
700 function($scope , $location , egCore , egConfirmDialog , egUser , patronSvc) {
701     $scope.selectMe = true; // focus text input
702     patronSvc.clearPrimary(); // clear the default user
703
704     // jump to the patron checkout UI
705     function loadPatron(user_id) {
706         egCore.audio.play('success.patron.by_barcode');
707         $location
708         .path('/circ/patron/' + user_id + '/checkout')
709         .search('card', $scope.args.barcode);
710         patronSvc.search_barcode = $scope.args.barcode;
711     }
712
713     // create an opt-in=yes response for the loaded user
714     function createOptIn(user_id) {
715         egCore.net.request(
716             'open-ils.actor',
717             'open-ils.actor.user.org_unit_opt_in.create',
718             egCore.auth.token(), user_id).then(function(resp) {
719                 if (evt = egCore.evt.parse(resp)) return alert(evt);
720                 loadPatron(user_id);
721             }
722         );
723     }
724
725     $scope.submitBarcode = function(args) {
726         $scope.bcNotFound = null;
727         $scope.optInRestricted = false;
728         if (!args.barcode) return;
729
730         // blur so next time it's set to true it will re-apply select()
731         $scope.selectMe = false;
732
733         var user_id;
734
735         // lookup barcode
736         egCore.net.request(
737             'open-ils.actor',
738             'open-ils.actor.get_barcodes',
739             egCore.auth.token(), egCore.auth.user().ws_ou(), 
740             'actor', args.barcode)
741
742         .then(function(resp) { // get_barcodes
743
744             if (evt = egCore.evt.parse(resp)) {
745                 alert(evt); // FIXME
746                 return;
747             }
748
749             if (!resp || !resp[0]) {
750                 $scope.bcNotFound = args.barcode;
751                 $scope.selectMe = true;
752                 egCore.audio.play('warning.patron.not_found');
753                 return;
754             }
755
756             // see if an opt-in request is needed
757             user_id = resp[0].id;
758             return egCore.net.request(
759                 'open-ils.actor',
760                 'open-ils.actor.user.org_unit_opt_in.check',
761                 egCore.auth.token(), user_id);
762
763         }).then(function(optInResp) { // opt_in_check
764
765             if (evt = egCore.evt.parse(optInResp)) {
766                 alert(evt); // FIXME
767                 return;
768             }
769
770             if (optInResp == 2) {
771                 // opt-in disallowed at this location by patron's home library
772                 $scope.optInRestricted = true;
773                 $scope.selectMe = true;
774                 egCore.audio.play('warning.patron.opt_in_restricted');
775                 return;
776             }
777            
778             if (optInResp == 1) {
779                 // opt-in handled or not needed
780                 return loadPatron(user_id);
781             }
782
783             // opt-in needed, show the opt-in dialog
784             egUser.get(user_id, {useFields : []})
785
786             .then(function(user) { // retrieve user
787                 egConfirmDialog.open(
788                     egCore.strings.OPT_IN_DIALOG, '',
789                     {   org : egCore.org.get(user.home_ou()),
790                         user : user,
791                         ok : function() { createOptIn(user.id()) },
792                         cancel : function() {}
793                     }
794                 );
795             })
796         });
797     }
798 }])
799
800
801 /**
802  * Manages patron search
803  */
804 .controller('PatronSearchCtrl',
805        ['$scope','$q','$routeParams','$timeout','$window','$location','egCore',
806        '$filter','egUser', 'patronSvc','egGridDataProvider','$document',
807        'egPatronMerge',
808 function($scope,  $q,  $routeParams,  $timeout,  $window,  $location,  egCore,
809         $filter,  egUser,  patronSvc , egGridDataProvider , $document,
810         egPatronMerge) {
811
812     $scope.initTab('search');
813     $scope.focusMe = true;
814     $scope.searchArgs = {
815         // default to searching globally
816         home_ou : egCore.org.tree()
817     };
818
819     // last used patron search form element
820     var lastFormElement;
821
822     $scope.gridControls = {
823         activateItem : function(item) {
824             $location.path('/circ/patron/' + item.id() + '/checkout');
825         },
826         selectedItems : function() {return []}
827     }
828
829     // Handle URL-encoded searches
830     if ($location.search().search) {
831         console.log('URL search = ' + $location.search().search);
832         patronSvc.urlSearch = {search : JSON2js($location.search().search)};
833
834         // why the double-JSON encoded sort?
835         if (patronSvc.urlSearch.search.search_sort) {
836             patronSvc.urlSearch.sort = 
837                 JSON2js(patronSvc.urlSearch.search.search_sort);
838         } else {
839             patronSvc.urlSearch.sort = [];
840         }
841         delete patronSvc.urlSearch.search.search_sort;
842     }
843
844     var propagate;
845     if (patronSvc.lastSearch) {
846         propagate = patronSvc.lastSearch.search;
847         // home_ou needs to be treated specially
848         propagate.home_ou = {
849             value : patronSvc.lastSearch.home_ou,
850             group : 0
851         };
852     } else if (patronSvc.urlSearch) {
853         propagate = patronSvc.urlSearch.search;
854     }
855
856     if (egCore.env.pgt) {
857         $scope.profiles = egCore.env.pgt.list;
858     } else {
859         egCore.pcrud.search('pgt', {parent : null}, 
860             {flesh : -1, flesh_fields : {pgt : ['children']}}
861         ).then(
862             function(tree) {
863                 egCore.env.absorbTree(tree, 'pgt')
864                 $scope.profiles = egCore.env.pgt.list;
865             }
866         );
867     }
868
869     if (propagate) {
870         // populate the search form with our cached / preexisting search info
871         angular.forEach(propagate, function(val, key) {
872             if (key == 'profile')
873                 val.value = $scope.profiles.filter(function(p) { return p.id() == val.value })[0];
874             if (key == 'home_ou')
875                 val.value = egCore.org.get(val.value);
876             $scope.searchArgs[key] = val.value;
877         });
878     }
879
880     var provider = egGridDataProvider.instance({});
881
882     $scope.$watch(
883         function() {return $scope.gridControls.selectedItems()},
884         function(list) {
885             if (list[0]) 
886                 patronSvc.setPrimary(null, list[0]);
887         },
888         true
889     );
890         
891     provider.get = function(offset, count) {
892         var deferred = $q.defer();
893
894         var fullSearch;
895         if (patronSvc.urlSearch) {
896             fullSearch = patronSvc.urlSearch;
897             // enusre the urlSearch only runs once.
898             delete patronSvc.urlSearch;
899
900         } else {
901             patronSvc.search_barcode = $scope.searchArgs.card;
902             
903             var search = compileSearch($scope.searchArgs);
904             if (Object.keys(search) == 0) return $q.when();
905
906             var home_ou = search.home_ou;
907             delete search.home_ou;
908             var inactive = search.inactive;
909             delete search.inactive;
910
911             fullSearch = {
912                 search : search,
913                 sort : compileSort(),
914                 inactive : inactive,
915                 home_ou : home_ou,
916             };
917         }
918
919         fullSearch.count = count;
920         fullSearch.offset = offset;
921
922         if (patronSvc.lastSearch) {
923             // search repeated, return the cached results
924             if (angular.equals(fullSearch, patronSvc.lastSearch)) {
925                 console.log('patron search returning ' + 
926                     patronSvc.patrons.length + ' cached results');
927                 
928                 // notify has to happen after returning the promise
929                 $timeout(
930                     function() {
931                         angular.forEach(patronSvc.patrons, function(user) {
932                             deferred.notify(user);
933                         });
934                         deferred.resolve();
935                     }
936                 );
937                 return deferred.promise;
938             }
939         }
940
941         patronSvc.lastSearch = fullSearch;
942
943         if (fullSearch.search.id) {
944             // search by user id performs a direct ID lookup
945             var userId = fullSearch.search.id.value;
946             $timeout(
947                 function() {
948                     egUser.get(userId).then(function(user) {
949                         patronSvc.localFlesh(user);
950                         patronSvc.patrons = [user];
951                         deferred.notify(user);
952                         deferred.resolve();
953                     });
954                 }
955             );
956             return deferred.promise;
957         }
958
959         // Dispay the search progress bar to indicate a search is in progress
960         $scope.show_search_progress = true;
961
962         patronSvc.patrons = [];
963         egCore.net.request(
964             'open-ils.actor',
965             'open-ils.actor.patron.search.advanced.fleshed',
966             egCore.auth.token(), 
967             fullSearch.search, 
968             fullSearch.count,
969             fullSearch.sort,
970             fullSearch.inactive,
971             fullSearch.home_ou,
972             egUser.defaultFleshFields,
973             fullSearch.offset
974
975         ).then(
976             function() {
977                 // hide progress bar on 0-hits searches
978                 $scope.show_search_progress = false;
979                 deferred.resolve();
980             },
981             null, // onerror
982             function(user) {
983                 // hide progress bar as soon as the first result appears.
984                 $scope.show_search_progress = false;
985                 patronSvc.localFlesh(user); // inline
986                 patronSvc.patrons.push(user);
987                 deferred.notify(user);
988             }
989         );
990
991         return deferred.promise;
992     };
993
994     $scope.patronSearchGridProvider = provider;
995
996     // determine the tree depth of the profile group
997     $scope.pgt_depth = function(grp) {
998         var d = 0;
999         while (grp = egCore.env.pgt.map[grp.parent()]) d++;
1000         return d;
1001     }
1002
1003     $scope.clearForm = function () {
1004         $scope.searchArgs={};
1005         if (lastFormElement) lastFormElement.focus();
1006     }
1007
1008     $scope.applyShowExtras = function($event, bool) {
1009         if (bool) {
1010             $scope.showExtras = true;
1011             egCore.hatch.setItem('eg.circ.patron.search.show_extras', true);
1012         } else {
1013             $scope.showExtras = false;
1014             egCore.hatch.removeItem('eg.circ.patron.search.show_extras');
1015         }
1016         if (lastFormElement) lastFormElement.focus();
1017         $event.preventDefault();
1018     }
1019
1020     egCore.hatch.getItem('eg.circ.patron.search.show_extras')
1021     .then(function(val) {$scope.showExtras = val});
1022
1023     // map form arguments into search params
1024     function compileSearch(args) {
1025         var search = {};
1026         angular.forEach(args, function(val, key) {
1027             if (!val) return;
1028             if (key == 'profile' && args.profile) {
1029                 search.profile = {value : args.profile.id(), group : 0};
1030             } else if (key == 'home_ou' && args.home_ou) {
1031                 search.home_ou = args.home_ou.id(); // passed separately
1032             } else if (key == 'inactive') {
1033                 search.inactive = val;
1034             } else {
1035                 search[key] = {value : val, group : 0};
1036             }
1037             if (key.match(/phone|ident/)) {
1038                 search[key].group = 2;
1039             } else {
1040                 if (key.match(/street|city|state|post_code/)) {
1041                     search[key].group = 1;
1042                 } else if (key == 'card') {
1043                     search[key].group = 3
1044                 }
1045             }
1046         });
1047
1048         return search;
1049     }
1050
1051     function compileSort() {
1052
1053         if (!provider.sort.length) {
1054             return [ // default
1055                 "family_name ASC",
1056                 "first_given_name ASC",
1057                 "second_given_name ASC",
1058                 "dob DESC"
1059             ];
1060         }
1061
1062         var sort = [];
1063         angular.forEach(
1064             provider.sort,
1065             function(sortdef) {
1066                 if (angular.isObject(sortdef)) {
1067                     var name = Object.keys(sortdef)[0];
1068                     var dir = sortdef[name];
1069                     sort.push(name + ' ' + dir);
1070                 } else {
1071                     sort.push(sortdef);
1072                 }
1073             }
1074         );
1075
1076         return sort;
1077     }
1078
1079     $scope.setLastFormElement = function() {
1080         lastFormElement = $document[0].activeElement;
1081     }
1082
1083     // search form submit action; tells the results grid to
1084     // refresh itself.
1085     $scope.search = function(args) { // args === $scope.searchArgs
1086         if (args && Object.keys(args).length) 
1087             $scope.gridControls.refresh();
1088         if (lastFormElement) lastFormElement.focus();
1089     }
1090
1091     // TODO: move this into the (forthcoming) grid row activate action
1092     $scope.onPatronDblClick = function($event, user) {
1093         $location.path('/circ/patron/' + user.id() + '/checkout');
1094     }
1095
1096     if (patronSvc.urlSearch) {
1097         // force the grid to load the url-based search on page load
1098         provider.refresh();
1099     }
1100
1101     $scope.need_two_selected = function() {
1102         var items = $scope.gridControls.selectedItems();
1103         return (items.length == 2) ? false : true;
1104     }
1105     $scope.merge_patrons = function() {
1106         var items = $scope.gridControls.selectedItems();
1107         if (items.length != 2) return false;
1108
1109         var patron_ids = [];
1110         angular.forEach(items, function(i) {
1111             patron_ids.push(i.id());
1112         });
1113         egPatronMerge.do_merge(patron_ids).then(function() {
1114             // ensure that we're not drawing from cached
1115             // resuts, as a successful merge just deleted a
1116             // record
1117             delete patronSvc.lastSearch;
1118             $scope.gridControls.refresh();
1119         });
1120     }
1121    
1122 }])
1123
1124 /**
1125  * Manages messages
1126  */
1127 .controller('PatronMessagesCtrl',
1128        ['$scope','$q','$routeParams','egCore','$uibModal','patronSvc','egCirc',
1129 function($scope , $q , $routeParams,  egCore , $uibModal , patronSvc , egCirc) {
1130     $scope.initTab('messages', $routeParams.id);
1131     var usr_id = $routeParams.id;
1132
1133     // setup date filters
1134     var start = new Date(); // now - 1 year
1135     start.setFullYear(start.getFullYear() - 1),
1136     $scope.dates = {
1137         start_date : start,
1138         end_date : new Date()
1139     }
1140
1141     function date_range() {
1142         var start = $scope.dates.start_date.toISOString().replace(/T.*/,'');
1143         var end = $scope.dates.end_date.toISOString().replace(/T.*/,'');
1144         var today = new Date().toISOString().replace(/T.*/,'');
1145         if (end == today) end = 'now';
1146         return [start, end];
1147     }
1148
1149     // grid queries
1150    
1151     var activeGrid = $scope.activeGridControls = {
1152         setSort : function() {
1153             return ['set_date'];
1154         },
1155         setQuery : function() {
1156             return {
1157                 usr : usr_id,
1158                 '-or' : [
1159                     {stop_date : null},
1160                     {stop_date : {'>' : 'now'}}
1161                 ]
1162             }
1163         }
1164     }
1165
1166     var archiveGrid = $scope.archiveGridControls = {
1167         setSort : function() {
1168             return ['set_date'];
1169         },
1170         setQuery : function() {
1171             return {
1172                 usr : usr_id, 
1173                 stop_date : {'<=' : 'now'},
1174                 set_date : {between : date_range()}
1175             };
1176         }
1177     };
1178
1179     $scope.removePenalty = function(selected) {
1180         // the grid stores flattened penalties.  Fetch penalty objects first
1181
1182         var ids = selected.map(function(s){ return s.id });
1183         egCore.pcrud.search('ausp', 
1184             {id : ids}, {}, 
1185             {atomic : true, authoritative : true}
1186
1187         // then delete them
1188         ).then(function(penalties) {
1189             return egCore.pcrud.remove(penalties);
1190
1191         // then refresh the grid
1192         }).then(function() {
1193             activeGrid.refresh();
1194         });
1195     }
1196
1197     $scope.archivePenalty = function(selected) {
1198         // the grid stores flattened penalties.  Fetch penalty objects first
1199
1200         var ids = selected.map(function(s){ return s.id });
1201         egCore.pcrud.search('ausp', 
1202             {id : ids}, {}, 
1203             {atomic : true, authoritative : true}
1204
1205         // then delete them
1206         ).then(function(penalties) {
1207             angular.forEach(penalties, function(p){ p.stop_date('now') });
1208             return egCore.pcrud.update(penalties);
1209
1210         // then refresh the grid
1211         }).then(function() {
1212             activeGrid.refresh();
1213             archiveGrid.refresh();
1214         });
1215     }
1216
1217     // leverage egEnv for caching
1218     function fetchPenaltyTypes() {
1219         if (egCore.env.csp) 
1220             return $q.when(egCore.env.csp.list);
1221         return egCore.pcrud.search(
1222             // id <= 100 are reserved for system use
1223             'csp', {id : {'>': 100}}, {}, {atomic : true})
1224         .then(function(penalties) {
1225             egCore.env.absorbList(penalties, 'csp');
1226             return penalties;
1227         });
1228     }
1229
1230     $scope.createPenalty = function() {
1231         egCirc.create_penalty(usr_id).then(function() {
1232             activeGrid.refresh();
1233             // force a refresh of the user, since they may now
1234             // have blocking penalties, etc.
1235             patronSvc.setPrimary(patronSvc.current.id(), null, true);
1236         });
1237     }
1238
1239     $scope.editPenalty = function(selected) {
1240         if (selected.length == 0) return;
1241
1242         // grab the penalty from the user object
1243         var penalty = patronSvc.current.standing_penalties().filter(
1244             function(p) {return p.id() == selected[0].id})[0];
1245
1246         egCirc.edit_penalty(penalty).then(function() {
1247             activeGrid.refresh();
1248             // force a refresh of the user, since they may now
1249             // have blocking penalties, etc.
1250             patronSvc.setPrimary(patronSvc.current.id(), null, true);
1251         });
1252     }
1253 }])
1254
1255
1256 /**
1257  * Credentials tester
1258  */
1259 .controller('PatronVerifyCredentialsCtrl',
1260        ['$scope','$routeParams','$location','egCore',
1261 function($scope,  $routeParams , $location , egCore) {
1262     $scope.verified = null;
1263     $scope.focusMe = true;
1264
1265     // called with a patron, pre-populate the form args
1266     $scope.initTab('other', $routeParams.id).then(
1267         function() {
1268             if ($routeParams.id && $scope.patron()) {
1269                 $scope.prepop = true;
1270                 $scope.username = $scope.patron().usrname();
1271                 $scope.barcode = $scope.patron().card().barcode();
1272             } else {
1273                 $scope.username = '';
1274                 $scope.barcode = '';
1275                 $scope.password = '';
1276             }
1277         }
1278     );
1279
1280     // verify login credentials
1281     $scope.verify = function() {
1282         $scope.verified = null;
1283         $scope.notFound = false;
1284
1285         egCore.net.request(
1286             'open-ils.actor',
1287             'open-ils.actor.verify_user_password',
1288             egCore.auth.token(), $scope.barcode,
1289             $scope.username, hex_md5($scope.password || '')
1290
1291         ).then(function(resp) {
1292             $scope.focusMe = true;
1293             if (evt = egCore.evt.parse(resp)) {
1294                 alert(evt);
1295             } else if (resp == 1) {
1296                 $scope.verified = true;
1297             } else {
1298                 $scope.verified = false;
1299             }
1300         });
1301     }
1302
1303     // load the main patron UI for the provided username or barcode
1304     $scope.load = function($event) {
1305         $scope.notFound = false;
1306         $scope.verified = null;
1307
1308         egCore.net.request(
1309             'open-ils.actor',
1310             'open-ils.actor.user.retrieve_id_by_barcode_or_username',
1311             egCore.auth.token(), $scope.barcode, $scope.username
1312
1313         ).then(function(resp) {
1314
1315             if (Number(resp)) {
1316                 $location.path('/circ/patron/' + resp + '/checkout');
1317                 return;
1318             }
1319
1320             // something went wrong...
1321             $scope.focusMe = true;
1322             if (evt = egCore.evt.parse(resp)) {
1323                 if (evt.textcode == 'ACTOR_USR_NOT_FOUND') {
1324                     $scope.notFound = true;
1325                     return;
1326                 }
1327                 return alert(evt);
1328             } else {
1329                 alert(resp);
1330             }
1331         });
1332
1333         // load() button sits within the verify form.  
1334         // avoid submitting the verify() form action on load()
1335         $event.preventDefault();
1336     }
1337 }])
1338
1339 .controller('PatronAlertsCtrl',
1340        ['$scope','$routeParams','$location','egCore','patronSvc',
1341 function($scope,  $routeParams , $location , egCore , patronSvc) {
1342
1343     $scope.initTab('other', $routeParams.id)
1344     .then(function() {
1345         $scope.patronExpired = patronSvc.patronExpired;
1346         $scope.patronExpiresSoon = patronSvc.patronExpiresSoon;
1347         $scope.retrievedWithInactive = patronSvc.fetchedWithInactiveCard();
1348         $scope.invalidAddresses = patronSvc.invalidAddresses;
1349     });
1350
1351 }])
1352
1353 .controller('PatronNotesCtrl',
1354        ['$scope', '$filter', '$routeParams','$location','egCore','patronSvc','$uibModal',
1355 function($scope,  $filter, $routeParams , $location , egCore , patronSvc , $uibModal) {
1356     $scope.initTab('other', $routeParams.id);
1357     var usr_id = $routeParams.id;
1358
1359     // fetch the notes
1360     function refreshPage() {
1361         $scope.notes = [];
1362         egCore.pcrud.search('aun', 
1363             {usr : usr_id}, 
1364             {flesh : 1, flesh_fields : {aun : ['creator']}}, 
1365             {authoritative : true})
1366         .then(null, null, function(note) {
1367             $scope.notes.push(note);
1368         });
1369     }
1370
1371     // open the new-note dialog and create the note
1372     $scope.newNote = function() {
1373         $uibModal.open({
1374             templateUrl: './circ/patron/t_new_note_dialog',
1375             controller: 
1376                 ['$scope', '$uibModalInstance',
1377             function($scope, $uibModalInstance) {
1378                 $scope.focusNote = true;
1379                 $scope.args = {};
1380                 $scope.require_initials = egCore.env.aous['ui.staff.require_initials.patron_info_notes'];
1381                 $scope.ok = function(count) { $uibModalInstance.close($scope.args) }
1382                 $scope.cancel = function () { $uibModalInstance.dismiss() }
1383             }],
1384         }).result.then(
1385             function(args) {
1386                 if (!args.value) return;
1387                 var note = new egCore.idl.aun();
1388                 note.usr(usr_id);
1389                 note.title(args.title);
1390                 note.value(args.value);
1391                 note.pub(args.pub ? 't' : 'f');
1392                 note.creator(egCore.auth.user().id());
1393                 if (args.initials) 
1394                     note.value(note.value() + ' [' + args.initials + ']');
1395                 egCore.pcrud.create(note).then(function() {refreshPage()});
1396             }
1397         );
1398     }
1399
1400     // delete the selected note
1401     $scope.deleteNote = function(note) {
1402         egCore.pcrud.remove(note).then(function() {refreshPage()});
1403     }
1404
1405     // print the selected note
1406     $scope.printNote = function(note) {
1407         var hash = egCore.idl.toHash(note);
1408         hash.usr = egCore.idl.toHash($scope.patron());
1409         egCore.print.print({
1410             context : 'default', 
1411             template : 'patron_note', 
1412             scope : {note : hash}
1413         });
1414     }
1415
1416     // perform the initial note fetch
1417     refreshPage();
1418 }])
1419
1420 .controller('PatronGroupCtrl',
1421        ['$scope','$routeParams','$q','$window','$timeout','$location','egCore',
1422         'patronSvc','$uibModal','egPromptDialog','egConfirmDialog',
1423 function($scope,  $routeParams , $q , $window , $timeout,  $location , egCore ,
1424          patronSvc , $uibModal , egPromptDialog , egConfirmDialog) {
1425
1426     var usr_id = $routeParams.id;
1427
1428     $scope.totals = {owed : 0, total_out : 0, overdue : 0}
1429
1430     var grid = $scope.gridControls = {
1431         activateItem : function(item) {
1432             $location.path('/circ/patron/' + item.id + '/checkout');
1433         },
1434         itemRetrieved : function(item) {
1435
1436             if (item.id == patronSvc.current.id()) {
1437                 item.stats = patronSvc.patron_stats;
1438
1439             } else {
1440                 // flesh stats for other group members
1441                 patronSvc.getUserStats(item.id).then(function(stats) {
1442                     item.stats = stats;
1443                     $scope.totals.total_out += stats.checkouts.total_out; 
1444                     $scope.totals.overdue += stats.checkouts.overdue; 
1445                 });
1446             }
1447         },
1448         setSort : function() {
1449             return ['create_date'];
1450         }
1451     }
1452
1453     $scope.initTab('other', $routeParams.id)
1454     .then(function(redirect) {
1455         // if we are redirecting to the alerts page, avoid updating the
1456         // grid query.
1457         if (redirect) return;
1458         // let initTab() fetch the user first so we can know the usrgroup
1459
1460         grid.setQuery({
1461             usrgroup : patronSvc.current.usrgroup(),
1462             deleted : 'f'
1463         });
1464         $scope.totals.owed = patronSvc.patron_stats.fines.group_balance_owed;
1465     });
1466
1467     $scope.removeFromGroup = function(selected) {
1468         var promises = [];
1469         angular.forEach(selected, function(user) {
1470             console.debug('removing user ' + user.id + ' from group');
1471
1472             promises.push(
1473                 egCore.net.request(
1474                     'open-ils.actor',
1475                     'open-ils.actor.usergroup.new',
1476                     egCore.auth.token(), user.id, true
1477                 )
1478             );
1479         });
1480
1481         $q.all(promises).then(function() {grid.refresh()});
1482     }
1483
1484     function addUserToGroup(user) {
1485         user.usrgroup(patronSvc.current.usrgroup());
1486         user.ischanged(true);
1487         egCore.net.request(
1488             'open-ils.actor',
1489             'open-ils.actor.patron.update',
1490             egCore.auth.token(), user
1491
1492         ).then(function() {grid.refresh()});
1493     }
1494
1495     // fetch each user ("selected" has flattened users)
1496     // update the usrgroup, then update the user object
1497     // After all updates are complete, refresh the grid.
1498     function moveUsersToGroup(target_user, selected) {
1499         var promises = [];
1500
1501         angular.forEach(selected, function(user) {
1502             promises.push(
1503                 egCore.pcrud.retrieve('au', user.id)
1504                 .then(function(u) {
1505                     u.usrgroup(target_user.usrgroup());
1506                     u.ischanged(true);
1507                     return egCore.net.request(
1508                         'open-ils.actor',
1509                         'open-ils.actor.patron.update',
1510                         egCore.auth.token(), u
1511                     );
1512                 })
1513             );
1514         });
1515
1516         $q.all(promises).then(function() {grid.refresh()});
1517     }
1518
1519     function showMoveToGroupConfirm(barcode, selected, outbound) {
1520
1521         // find the user
1522         egCore.pcrud.search('ac', {barcode : barcode})
1523
1524         // fetch the fleshed user
1525         .then(function(card) {
1526
1527             if (!card) return; // TODO: warn user
1528
1529             egCore.pcrud.retrieve('au', card.usr())
1530             .then(function(user) {
1531                 user.card(card);
1532                 $uibModal.open({
1533                     templateUrl: './circ/patron/t_move_to_group_dialog',
1534                     controller: [
1535                                 '$scope','$uibModalInstance',
1536                         function($scope , $uibModalInstance) {
1537                             $scope.user = user;
1538                             $scope.selected = selected;
1539                             $scope.outbound = outbound;
1540                             $scope.ok = 
1541                                 function(count) { $uibModalInstance.close() }
1542                             $scope.cancel = 
1543                                 function () { $uibModalInstance.dismiss() }
1544                         }
1545                     ]
1546                 }).result.then(function() {
1547                     if (outbound) {
1548                         moveUsersToGroup(user, selected);
1549                     } else {
1550                         addUserToGroup(user);
1551                     }
1552                 });
1553             });
1554         });
1555     }
1556
1557     // selected == move selected patrons to another patron's group
1558     // !selected == patron from a different group moves into our group
1559     function moveToGroup(selected, outbound) {
1560         egPromptDialog.open(
1561             egCore.strings.GROUP_ADD_USER, '',
1562             {ok : function(value) {
1563                 if (value) 
1564                     showMoveToGroupConfirm(value, selected, outbound);
1565             }}
1566         );
1567     }
1568
1569     $scope.moveToGroup = function() { moveToGroup([], false) };
1570     $scope.moveToAnotherGroup = function(selected) { moveToGroup(selected, true) };
1571
1572     $scope.cloneUser = function(selected) {
1573         if (!selected.length) return;
1574         var url = $location.absUrl().replace(
1575             /\/patron\/.*/, 
1576             '/patron/register/clone/' + selected[0].id);
1577         $window.open(url, '_blank').focus();
1578     }
1579
1580     $scope.retrieveSelected = function(selected) {
1581         if (!selected.length) return;
1582         angular.forEach(selected, function(usr) {
1583             $timeout(function() {
1584                 var url = $location.absUrl().replace(
1585                     /\/patron\/.*/,
1586                     '/patron/' + usr.id + '/checkout');
1587                 $window.open(url, '_blank')
1588             });
1589         });
1590     }
1591
1592 }])
1593
1594 .controller('PatronStatCatsCtrl',
1595        ['$scope','$routeParams','$q','egCore','patronSvc',
1596 function($scope,  $routeParams , $q , egCore , patronSvc) {
1597     $scope.initTab('other', $routeParams.id)
1598     .then(function(redirect) {
1599         // Entries for org-visible stat cats are fleshed.  Any others
1600         // have to be fleshed within.
1601
1602         var to_flesh = {};
1603         angular.forEach(patronSvc.current.stat_cat_entries(), 
1604             function(entry) {
1605                 if (!angular.isObject(entry.stat_cat())) {
1606                     to_flesh[entry.stat_cat()] = entry;
1607                 }
1608             }
1609         );
1610
1611         if (!Object.keys(to_flesh).length) return;
1612
1613         egCore.pcrud.search('actsc', {id : Object.keys(to_flesh)})
1614         .then(null, null, function(cat) { // stream
1615             cat.owner(egCore.org.get(cat.owner())); // owner flesh
1616             to_flesh[cat.id()].stat_cat(cat);
1617         });
1618     });
1619 }])
1620
1621 .controller('PatronFetchLastCtrl',
1622        ['$scope','$location','egCore',
1623 function($scope , $location , egCore) {
1624
1625     var id = egCore.hatch.getLoginSessionItem('eg.circ.last_patron');
1626     if (id) return $location.path('/circ/patron/' + id + '/checkout');
1627
1628     $scope.no_last = true;
1629 }])
1630
1631 .controller('PatronTriggeredEventsCtrl',
1632        ['$scope','$routeParams','$location','egCore','patronSvc',
1633 function($scope,  $routeParams,  $location , egCore , patronSvc) {
1634     $scope.initTab('other', $routeParams.id);
1635
1636     var url = $location.absUrl().replace(/\/staff.*/, '/actor/user/event_log');
1637     url += '?patron_id=' + encodeURIComponent($routeParams.id);
1638
1639     $scope.triggered_events_url = url;
1640     $scope.funcs = {};
1641 }])
1642
1643 .controller('PatronPermsCtrl',
1644        ['$scope','$routeParams','$window','$location','egCore',
1645 function($scope , $routeParams , $window , $location , egCore) {
1646     $scope.initTab('other', $routeParams.id);
1647
1648     var url = $location.absUrl().replace(
1649         /\/eg\/staff.*/, '/xul/server/patron/user_edit.xhtml');
1650
1651     url += '?usr=' + encodeURIComponent($routeParams.id);
1652
1653     // user_edit does not load the session via cookie.  It uses URL 
1654     // params or xulG instead.  Pass via xulG.
1655     $scope.funcs = {
1656         ses : egCore.auth.token(),
1657         on_patron_save : function() {
1658             $scope.funcs.reload();
1659         }
1660     }
1661
1662     $scope.user_perms_url = url;
1663 }])
1664