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