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