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