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