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