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