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