5 angular.module('egPatronSearchMod', ['ngRoute', 'ui.bootstrap',
6 'egCoreMod', 'egUiMod', 'egGridMod', 'egUserMod'])
12 ['$q','$timeout','$location','egCore','egUser','$locale',
13 function($q , $timeout , $location , egCore, egUser , $locale) {
16 // cached patron search results
19 // currently selected patron object
22 // patron circ stats (overdues, fines, holds)
25 // event types manually overridden, which should always be
26 // overridden for checkouts to this patron for this instance of
28 checkout_overrides : {},
29 //holds the searched barcode
30 search_barcode : null,
33 // when we change the default patron, we need to clear out any
34 // data collected on that patron
35 service.resetPatronLists = function() {
36 service.checkouts = [];
37 service.items_out = []
38 service.items_out_ids = [];
40 service.hold_ids = [];
41 service.checkout_overrides = {};
42 service.patron_stats = null;
43 service.noncat_ids = [];
44 service.hasAlerts = false;
45 service.patronExpired = false;
46 service.patronExpiresSoon = false;
47 service.invalidAddresses = false;
49 service.resetPatronLists(); // initialize
51 // Returns true if the last alerted patron matches the current
52 // patron. Otherwise, the last alerted patron is set to the
53 // current patron and false is returned.
54 service.alertsShown = function() {
55 var key = 'eg.circ.last_alerted_patron';
56 var last_id = egCore.hatch.getSessionItem(key);
57 if (last_id && last_id == service.current.id()) return true;
58 egCore.hatch.setSessionItem(key, service.current.id());
62 // shortcut to force-reload the current primary
63 service.refreshPrimary = function() {
64 if (!service.current) return $q.when();
65 return service.setPrimary(service.current.id(), null, true);
68 // clear the currently focused user
69 service.clearPrimary = function() {
70 // reset with no patron
71 service.resetPatronLists();
72 service.current = null;
73 service.patron_stats = null;
77 // sets the primary display user, fetching data as necessary.
78 service.setPrimary = function(id, user, force) {
79 var user_id = id ? id : (user ? user.id() : null);
81 console.debug('setting primary user to: ' + user_id);
83 if (!user_id) return $q.reject();
85 // when loading a new patron, update the last patron setting
86 if (!service.current || service.current.id() != user_id)
87 egCore.hatch.setLoginSessionItem('eg.circ.last_patron', user_id);
89 // avoid running multiple retrievals for the same patron, which
90 // can happen during dbl-click by maintaining a single running
91 // data retrieval promise
92 if (service.primaryUserPromise) {
93 if (service.primaryUserId == user_id) {
94 return service.primaryUserPromise.promise;
96 service.primaryUserPromise = null;
100 service.primaryUserPromise = $q.defer();
101 service.primaryUserId = user_id;
103 service.getPrimary(id, user, force)
105 service.checkAlerts();
106 var p = service.primaryUserPromise;
107 service.primaryUserId = null;
108 // clear before resolution just to be safe.
109 service.primaryUserPromise = null;
113 return service.primaryUserPromise.promise;
116 service.getPrimary = function(id, user, force) {
119 if (!force && service.current &&
120 service.current.id() == user.id()) {
121 if (service.patron_stats) {
124 return service.fetchUserStats();
128 service.resetPatronLists();
129 service.current = user;
130 service.localFlesh(user);
131 return service.fetchUserStats();
134 if (!force && service.current && service.current.id() == id) {
135 if (service.patron_stats) {
138 return service.fetchUserStats();
142 service.resetPatronLists();
144 return egUser.get(id).then(
146 service.current = user;
147 service.localFlesh(user);
148 return service.fetchUserStats();
152 "unable to fetch user "+id+': '+js2JSON(err))
157 // fetching a null user clears the primary user.
158 // NOTE: this should probably reject() and log an error,
159 // but calling clear for backwards compat for now.
160 return service.clearPrimary();
164 // flesh some additional user fields locally
165 service.localFlesh = function(user) {
166 if (!angular.isObject(typeof user.home_ou()))
167 user.home_ou(egCore.org.get(user.home_ou()));
170 user.standing_penalties(),
172 if (!angular.isObject(penalty.org_unit()))
173 penalty.org_unit(egCore.org.get(penalty.org_unit()));
177 // stat_cat_entries == stat_cat_entry_user_map
178 angular.forEach(user.stat_cat_entries(), function(map) {
179 if (angular.isObject(map.stat_cat())) return;
180 // At page load, we only retrieve org-visible stat cats.
181 // For the common case, ignore entries for remote stat cats.
182 var cat = egCore.env.actsc.map[map.stat_cat()];
185 cat.owner(egCore.org.get(cat.owner()));
190 // resolves to true if the patron account has expired or will
191 // expire soon, based on YAOUS circ.patron_expires_soon_warning
192 // note: returning a promise is no longer strictly necessary
193 // (no more async activity) if the calling function is changed too.
194 service.testExpire = function() {
196 var expire = Date.parse(service.current.expire_date());
197 if (expire < new Date()) {
198 return $q.when(service.patronExpired = true);
201 var soon = egCore.env.aous['circ.patron_expires_soon_warning'];
203 var preExpire = new Date();
204 preExpire.setDate(preExpire.getDate() + Number(soon));
205 if (expire < preExpire)
206 return $q.when(service.patronExpiresSoon = true);
209 return $q.when(false);
212 // resolves to true if the patron account has any invalid addresses.
213 service.testInvalidAddrs = function() {
215 if (service.invalidAddresses)
216 return $q.when(true);
221 service.current.addresses(),
222 function(addr) { if (addr.valid() == 'f') fail = true }
225 return $q.when(fail);
227 //resolves to true if the patron was fetched with an inactive card
228 service.fetchedWithInactiveCard = function() {
229 var bc = service.search_barcode
230 var cards = service.current.cards();
231 var card = cards.filter(function(c) { return c.barcode() == bc })[0];
232 return (card && card.active() == 'f');
234 // resolves to true if there is any aspect of the patron account
235 // which should produce a message in the alerts panel
236 service.checkAlerts = function() {
238 if (service.hasAlerts) // already checked
239 return $q.when(true);
241 var deferred = $q.defer();
242 var p = service.current;
244 if (service.alert_penalties.length ||
248 service.patron_stats.holds.ready) {
250 service.hasAlerts = true;
253 // see if the user was retrieved with an inactive card
254 if(service.fetchedWithInactiveCard()){
255 service.hasAlerts = true;
258 // regardless of whether we know of alerts, we still need
259 // to test/fetch the expire data for display
260 service.testExpire().then(function(bool) {
261 if (bool) service.hasAlerts = true;
262 deferred.resolve(service.hasAlerts);
265 service.testInvalidAddrs().then(function(bool) {
266 if (bool) service.invalidAddresses = true;
267 deferred.resolve(service.invalidAddresses);
270 return deferred.promise;
273 service.fetchGroupFines = function() {
274 return egCore.net.request(
276 'open-ils.actor.usergroup.members.balance_owed',
277 egCore.auth.token(), service.current.usrgroup()
278 ).then(function(list) {
280 angular.forEach(list, function(u) {
281 total += 100 * Number(u.balance_owed)
283 service.patron_stats.fines.group_balance_owed = total / 100;
287 service.getUserStats = function(id) {
288 return egCore.net.request(
290 'open-ils.actor.user.opac.vital_stats.authoritative',
291 egCore.auth.token(), id
294 // force numeric to ensure correct boolean handling in templates
295 stats.fines.balance_owed = Number(stats.fines.balance_owed);
296 stats.checkouts.overdue = Number(stats.checkouts.overdue);
297 stats.checkouts.claims_returned =
298 Number(stats.checkouts.claims_returned);
299 stats.checkouts.lost = Number(stats.checkouts.lost);
300 stats.checkouts.out = Number(stats.checkouts.out);
301 stats.checkouts.total_out =
302 stats.checkouts.out + stats.checkouts.overdue;
304 stats.checkouts.total_out += Number(stats.checkouts.long_overdue);
306 if (!egCore.env.aous['circ.do_not_tally_claims_returned'])
307 stats.checkouts.total_out += stats.checkouts.claims_returned;
309 if (egCore.env.aous['circ.tally_lost'])
310 stats.checkouts.total_out += stats.checkouts.lost
317 // Fetches the IDs of any active non-cat checkouts for the current
318 // user. Also sets the patron_stats non_cat count value to match.
319 service.getUserNonCats = function(id) {
320 return egCore.net.request(
322 'open-ils.circ.open_non_cataloged_circulation.user.authoritative',
323 egCore.auth.token(), id
324 ).then(function(noncat_ids) {
325 service.noncat_ids = noncat_ids;
326 service.patron_stats.checkouts.noncat = noncat_ids.length;
330 // grab additional circ info
331 service.fetchUserStats = function() {
332 return service.getUserStats(service.current.id())
333 .then(function(stats) {
334 service.patron_stats = stats
335 service.alert_penalties = service.current.standing_penalties()
336 .filter(function(pen) {
337 return pen.standing_penalty().staff_alert() == 't'
340 service.summary_stat_cats = [];
341 angular.forEach(service.current.stat_cat_entries(),
343 if (angular.isObject(map.stat_cat()) &&
344 map.stat_cat().usr_summary() == 't') {
345 service.summary_stat_cats.push(map);
350 // run these two in parallel
351 var p1 = service.getUserNonCats(service.current.id());
352 var p2 = service.fetchGroupFines();
353 return $q.all([p1, p2]);
357 // Avoid using parens [e.g. (1.23)] to indicate negative numbers,
358 // which is the Angular default.
359 // http://stackoverflow.com/questions/17441254/why-angularjs-currency-filter-formats-negative-numbers-with-parenthesis
360 // FIXME: This change needs to be moved into a project-wide collection
361 // of locale overrides.
362 $locale.NUMBER_FORMATS.PATTERNS[1].negPre = '-';
363 $locale.NUMBER_FORMATS.PATTERNS[1].negSuf = '';
369 * Manages patron search
371 .controller('BasePatronSearchCtrl',
372 ['$scope','$q','$routeParams','$timeout','$window','$location','egCore',
373 '$filter','egUser', 'patronSvc','egGridDataProvider','$document',
375 function($scope, $q, $routeParams, $timeout, $window, $location, egCore,
376 $filter, egUser, patronSvc , egGridDataProvider , $document,
379 $scope.focusMe = true;
380 $scope.searchArgs = {
381 // default to searching globally
382 home_ou : egCore.org.tree()
385 // last used patron search form element
388 $scope.gridControls = {
389 selectedItems : function() {return []}
392 // Handle URL-encoded searches
393 if ($location.search().search) {
394 console.log('URL search = ' + $location.search().search);
395 patronSvc.urlSearch = {search : JSON2js($location.search().search)};
397 // why the double-JSON encoded sort?
398 if (patronSvc.urlSearch.search.search_sort) {
399 patronSvc.urlSearch.sort =
400 JSON2js(patronSvc.urlSearch.search.search_sort);
402 patronSvc.urlSearch.sort = [];
404 delete patronSvc.urlSearch.search.search_sort;
406 // include inactive patrons if "inactive" param
407 if ($location.search().inactive) {
408 patronSvc.urlSearch.inactive = $location.search().inactive;
413 var propagate_inactive;
414 if (patronSvc.lastSearch) {
415 propagate = patronSvc.lastSearch.search;
416 // home_ou needs to be treated specially
417 propagate.home_ou = {
418 value : patronSvc.lastSearch.home_ou,
421 } else if (patronSvc.urlSearch) {
422 propagate = patronSvc.urlSearch.search;
423 if (patronSvc.urlSearch.inactive) {
424 propagate_inactive = patronSvc.urlSearch.inactive;
428 if (egCore.env.pgt) {
429 $scope.profiles = egCore.env.pgt.list;
431 egCore.pcrud.search('pgt', {parent : null},
432 {flesh : -1, flesh_fields : {pgt : ['children']}}
435 egCore.env.absorbTree(tree, 'pgt')
436 $scope.profiles = egCore.env.pgt.list;
442 // populate the search form with our cached / preexisting search info
443 angular.forEach(propagate, function(val, key) {
444 if (key == 'profile')
445 val.value = $scope.profiles.filter(function(p) { return p.id() == val.value })[0];
446 if (key == 'home_ou')
447 val.value = egCore.org.get(val.value);
448 $scope.searchArgs[key] = val.value;
450 if (propagate_inactive) {
451 $scope.searchArgs.inactive = propagate_inactive;
455 var provider = egGridDataProvider.instance({});
457 provider.get = function(offset, count) {
458 var deferred = $q.defer();
461 if (patronSvc.urlSearch) {
462 fullSearch = patronSvc.urlSearch;
463 // enusre the urlSearch only runs once.
464 delete patronSvc.urlSearch;
467 patronSvc.search_barcode = $scope.searchArgs.card;
469 var search = compileSearch($scope.searchArgs);
470 if (Object.keys(search) == 0) return $q.when();
472 var home_ou = search.home_ou;
473 delete search.home_ou;
474 var inactive = search.inactive;
475 delete search.inactive;
479 sort : compileSort(),
485 fullSearch.count = count;
486 fullSearch.offset = offset;
488 if (patronSvc.lastSearch) {
489 // search repeated, return the cached results
490 if (angular.equals(fullSearch, patronSvc.lastSearch)) {
491 console.log('patron search returning ' +
492 patronSvc.patrons.length + ' cached results');
494 // notify has to happen after returning the promise
497 angular.forEach(patronSvc.patrons, function(user) {
498 deferred.notify(user);
503 return deferred.promise;
507 patronSvc.lastSearch = fullSearch;
509 if (fullSearch.search.id) {
510 // search by user id performs a direct ID lookup
511 var userId = fullSearch.search.id.value;
514 egUser.get(userId).then(function(user) {
515 patronSvc.localFlesh(user);
516 patronSvc.patrons = [user];
517 deferred.notify(user);
522 return deferred.promise;
525 if (!Object.keys(fullSearch.search).length) {
526 // Empty searches are rejected by the server. Avoid
527 // running the the empty search that runs on page load.
531 egProgressDialog.open(); // Indeterminate
533 patronSvc.patrons = [];
534 var which_sound = 'success';
537 'open-ils.actor.patron.search.advanced.fleshed',
544 egUser.defaultFleshFields,
551 function() { // onerror
552 which_sound = 'error';
555 // hide progress bar as soon as the first result appears.
556 egProgressDialog.close();
557 patronSvc.localFlesh(user); // inline
558 patronSvc.patrons.push(user);
559 deferred.notify(user);
561 )['finally'](function() { // close on 0-hits or error
562 if (which_sound == 'success' && patronSvc.patrons.length == 0) {
563 which_sound = 'warning';
565 egCore.audio.play(which_sound + '.patron.by_search');
566 egProgressDialog.close();
569 return deferred.promise;
572 $scope.patronSearchGridProvider = provider;
574 // determine the tree depth of the profile group
575 $scope.pgt_depth = function(grp) {
577 while (grp = egCore.env.pgt.map[grp.parent()]) d++;
581 $scope.clearForm = function () {
582 $scope.searchArgs={};
583 if (lastFormElement) lastFormElement.focus();
586 $scope.applyShowExtras = function($event, bool) {
588 $scope.showExtras = true;
589 egCore.hatch.setItem('eg.circ.patron.search.show_extras', true);
591 $scope.showExtras = false;
592 egCore.hatch.removeItem('eg.circ.patron.search.show_extras');
594 if (lastFormElement) lastFormElement.focus();
595 $event.preventDefault();
598 egCore.hatch.getItem('eg.circ.patron.search.show_extras')
599 .then(function(val) {$scope.showExtras = val});
601 // map form arguments into search params
602 function compileSearch(args) {
604 angular.forEach(args, function(val, key) {
606 if (key == 'profile' && args.profile) {
607 search.profile = {value : args.profile.id(), group : 0};
608 } else if (key == 'home_ou' && args.home_ou) {
609 search.home_ou = args.home_ou.id(); // passed separately
610 } else if (key == 'inactive') {
611 search.inactive = val;
613 search[key] = {value : val, group : 0};
615 if (key.match(/phone|ident/)) {
616 search[key].group = 2;
618 if (key.match(/street|city|state|post_code/)) {
619 search[key].group = 1;
620 } else if (key == 'card') {
621 search[key].group = 3
629 function compileSort() {
631 if (!provider.sort.length) {
634 "first_given_name ASC",
635 "second_given_name ASC",
644 if (angular.isObject(sortdef)) {
645 var name = Object.keys(sortdef)[0];
646 var dir = sortdef[name];
647 sort.push(name + ' ' + dir);
657 $scope.setLastFormElement = function() {
658 lastFormElement = $document[0].activeElement;
661 // search form submit action; tells the results grid to
663 $scope.search = function(args) { // args === $scope.searchArgs
664 if (args && Object.keys(args).length)
665 $scope.gridControls.refresh();
666 if (lastFormElement) lastFormElement.focus();
669 // TODO: move this into the (forthcoming) grid row activate action
670 $scope.onPatronDblClick = function($event, user) {
671 $location.path('/circ/patron/' + user.id() + '/checkout');
674 if (patronSvc.urlSearch) {
675 // force the grid to load the url-based search on page load
679 $scope.need_two_selected = function() {
680 var items = $scope.gridControls.selectedItems();
681 return (items.length == 2) ? false : true;