5 angular.module('egPatronSearchMod', ['ngRoute', 'ui.bootstrap',
6 'egCoreMod', 'egUiMod', 'egGridMod', 'egUserMod'])
12 ['$q','$timeout','$location','egCore','egUser','egConfirmDialog','$locale',
13 function($q , $timeout , $location , egCore, egUser , egConfirmDialog , $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 egCore.startup.go().then(
53 // Max recents setting is loaded and scrubbed during egStartup.
54 // Copy it to a local variable here for ease of local access
55 // after startup has run.
56 egCore.org.settings('ui.staff.max_recent_patrons')
58 service.maxRecentPatrons = s['ui.staff.max_recent_patrons'];
61 // This call requires orgs to be loaded, because it
62 // calls egCore.org.ancestors(), so call it after startup
63 egCore.pcrud.search('actsc',
64 {owner : egCore.org.ancestors(
65 egCore.auth.user().ws_ou(), true)},
67 ).then(function(cats) {
68 egCore.env.absorbList(cats, 'actsc');
73 // Returns true if the last alerted patron matches the current
74 // patron. Otherwise, the last alerted patron is set to the
75 // current patron and false is returned.
76 service.alertsShown = function() {
77 var key = 'eg.circ.last_alerted_patron';
78 var last_id = egCore.hatch.getSessionItem(key);
79 if (last_id && last_id == service.current.id()) return true;
80 egCore.hatch.setSessionItem(key, service.current.id());
84 // shortcut to force-reload the current primary
85 service.refreshPrimary = function() {
86 if (!service.current) return $q.when();
87 return service.setPrimary(service.current.id(), null, true);
90 // clear the currently focused user
91 service.clearPrimary = function() {
92 // reset with no patron
93 service.resetPatronLists();
94 service.current = null;
95 service.patron_stats = null;
99 service.getRecentPatrons = function() {
100 // avoid getting stuck in a show-recent loop
101 service.showRecent = false;
103 if (service.maxRecentPatrons < 1) return $q.when();
105 egCore.hatch.getLoginSessionItem('eg.circ.recent_patrons') || [];
107 // Ensure the cached list is no bigger than the current config.
108 // This can happen if the setting changes while logged in.
109 patrons = patrons.slice(0, service.maxRecentPatrons);
111 // add home_ou to the list of fleshed fields for recent patrons
112 var fleshFields = egUser.defaultFleshFields.slice(0);
113 fleshFields.push('home_ou');
115 var deferred = $q.defer();
117 if (patrons.length == 0) {
121 egUser.get(patrons[0], {useFields : fleshFields}).then(
122 function(usr) { // fetch first user
123 deferred.notify(usr);
124 patrons.splice(0, 1); // remove first user from list
131 return deferred.promise;
134 service.addRecentPatron = function(user_id) {
135 if (service.maxRecentPatrons < 1) return;
137 // ensure ID is a number if pulled from route data
138 user_id = Number(user_id);
140 // no need to re-track same user
141 if (service.current && service.current.id() == user_id) return;
144 egCore.hatch.getLoginSessionItem('eg.circ.recent_patrons') || [];
146 // remove potential existing duplicates
147 patrons = patrons.filter(function(id) {
148 return user_id !== id
150 patrons.splice(0, 0, user_id); // put this user at front
151 patrons.splice(service.maxRecentPatrons); // remove excess
153 egCore.hatch.setLoginSessionItem('eg.circ.recent_patrons', patrons);
156 // sets the primary display user, fetching data as necessary.
157 service.setPrimary = function(id, user, force) {
158 var user_id = id ? id : (user ? user.id() : null);
160 console.debug('setting primary user to: ' + user_id);
162 if (!user_id) return $q.reject();
164 service.addRecentPatron(user_id);
166 // avoid running multiple retrievals for the same patron, which
167 // can happen during dbl-click by maintaining a single running
168 // data retrieval promise
169 if (service.primaryUserPromise) {
170 if (service.primaryUserId == user_id) {
171 return service.primaryUserPromise.promise;
173 service.primaryUserPromise = null;
177 service.primaryUserPromise = $q.defer();
178 service.primaryUserId = user_id;
180 service.getPrimary(id, user, force)
182 service.checkAlerts();
183 var p = service.primaryUserPromise;
184 service.primaryUserId = null;
185 // clear before resolution just to be safe.
186 service.primaryUserPromise = null;
190 return service.primaryUserPromise.promise;
193 service.getPrimary = function(id, user, force) {
196 if (!force && service.current &&
197 service.current.id() == user.id()) {
198 if (service.patron_stats) {
201 return service.fetchUserStats();
205 service.resetPatronLists();
207 return service.checkOptIn(user).then(
209 service.current = user;
210 service.localFlesh(user);
211 return service.fetchUserStats();
219 if (!force && service.current && service.current.id() == id) {
220 if (service.patron_stats) {
223 return service.fetchUserStats();
227 service.resetPatronLists();
229 return egUser.get(id).then(
231 return service.checkOptIn(user).then(
233 service.current = user;
234 service.localFlesh(user);
235 return service.fetchUserStats();
244 "unable to fetch user "+id+': '+js2JSON(err))
249 // fetching a null user clears the primary user.
250 // NOTE: this should probably reject() and log an error,
251 // but calling clear for backwards compat for now.
252 return service.clearPrimary();
256 // flesh some additional user fields locally
257 service.localFlesh = function(user) {
258 if (!angular.isObject(typeof user.home_ou()))
259 user.home_ou(egCore.org.get(user.home_ou()));
262 user.standing_penalties(),
264 if (!angular.isObject(penalty.org_unit()))
265 // clone the org unit IDL object and set children to an empty array
266 org_unit = egCore.idl.Clone(egCore.org.get(penalty.org_unit()));
267 org_unit.children([])
268 penalty.org_unit(org_unit);
272 // stat_cat_entries == stat_cat_entry_user_map
273 angular.forEach(user.stat_cat_entries(), function(map) {
274 if (angular.isObject(map.stat_cat())) return;
275 // At page load, we only retrieve org-visible stat cats.
276 // For the common case, ignore entries for remote stat cats.
277 var cat = egCore.env.actsc.map[map.stat_cat()];
280 cat.owner(egCore.org.get(cat.owner()));
285 // resolves to true if the patron account has expired or will
286 // expire soon, based on YAOUS circ.patron_expires_soon_warning
287 // note: returning a promise is no longer strictly necessary
288 // (no more async activity) if the calling function is changed too.
289 service.testExpire = function() {
291 var expire = Date.parse(service.current.expire_date());
292 if (expire < new Date()) {
293 return $q.when(service.patronExpired = true);
296 var soon = egCore.env.aous['circ.patron_expires_soon_warning'];
298 var preExpire = new Date();
299 preExpire.setDate(preExpire.getDate() + Number(soon));
300 if (expire < preExpire)
301 return $q.when(service.patronExpiresSoon = true);
304 return $q.when(false);
307 // resolves to true if the patron account has any invalid addresses.
308 service.testInvalidAddrs = function() {
310 if (service.invalidAddresses)
311 return $q.when(true);
316 service.current.addresses(),
317 function(addr) { if (addr.valid() == 'f') fail = true }
320 return $q.when(fail);
322 //resolves to true if the patron was fetched with an inactive card
323 service.fetchedWithInactiveCard = function() {
324 var bc = service.search_barcode
325 var cards = service.current.cards();
326 var card = cards.filter(function(c) { return c.barcode() == bc })[0];
327 return (card && card.active() == 'f');
329 // resolves to true if there is any aspect of the patron account
330 // which should produce a message in the alerts panel
331 service.checkAlerts = function() {
333 if (service.hasAlerts) // already checked
334 return $q.when(true);
336 var deferred = $q.defer();
337 var p = service.current;
339 if (service.alert_penalties.length ||
342 service.patron_stats.holds.ready) {
344 service.hasAlerts = true;
347 // see if the user was retrieved with an inactive card
348 if(service.fetchedWithInactiveCard()){
349 service.hasAlerts = true;
352 // regardless of whether we know of alerts, we still need
353 // to test/fetch the expire data for display
354 service.testExpire().then(function(bool) {
355 if (bool) service.hasAlerts = true;
356 deferred.resolve(service.hasAlerts);
359 service.testInvalidAddrs().then(function(bool) {
360 if (bool) service.invalidAddresses = true;
361 deferred.resolve(service.invalidAddresses);
364 return deferred.promise;
367 service.fetchGroupFines = function() {
368 return egCore.net.request(
370 'open-ils.actor.usergroup.members.balance_owed',
371 egCore.auth.token(), service.current.usrgroup()
372 ).then(function(list) {
374 angular.forEach(list, function(u) {
375 total += 100 * Number(u.balance_owed)
377 service.patron_stats.fines.group_balance_owed = total / 100;
381 service.getUserStats = function(id) {
382 return egCore.net.request(
384 'open-ils.actor.user.opac.vital_stats.authoritative',
385 egCore.auth.token(), id
388 // force numeric to ensure correct boolean handling in templates
389 stats.fines.balance_owed = Number(stats.fines.balance_owed);
390 stats.checkouts.overdue = Number(stats.checkouts.overdue);
391 stats.checkouts.claims_returned =
392 Number(stats.checkouts.claims_returned);
393 stats.checkouts.lost = Number(stats.checkouts.lost);
394 stats.checkouts.out = Number(stats.checkouts.out);
395 stats.checkouts.total_out =
396 stats.checkouts.out + stats.checkouts.overdue;
398 stats.checkouts.total_out += Number(stats.checkouts.long_overdue);
400 if (!egCore.env.aous['circ.do_not_tally_claims_returned'])
401 stats.checkouts.total_out += stats.checkouts.claims_returned;
403 if (egCore.env.aous['circ.tally_lost'])
404 stats.checkouts.total_out += stats.checkouts.lost
411 // Fetches the IDs of any active non-cat checkouts for the current
412 // user. Also sets the patron_stats non_cat count value to match.
413 service.getUserNonCats = function(id) {
414 return egCore.net.request(
416 'open-ils.circ.open_non_cataloged_circulation.user.authoritative',
417 egCore.auth.token(), id
418 ).then(function(noncat_ids) {
419 service.noncat_ids = noncat_ids;
420 service.patron_stats.checkouts.noncat = noncat_ids.length;
424 // grab additional circ info
425 service.fetchUserStats = function() {
426 return service.getUserStats(service.current.id())
427 .then(function(stats) {
428 service.patron_stats = stats
429 service.alert_penalties = service.current.standing_penalties()
430 .filter(function(pen) {
431 return pen.standing_penalty().staff_alert() == 't'
434 service.summary_stat_cats = [];
435 angular.forEach(service.current.stat_cat_entries(),
437 if (angular.isObject(map.stat_cat()) &&
438 map.stat_cat().usr_summary() == 't') {
439 service.summary_stat_cats.push(map);
444 // run these two in parallel
445 var p1 = service.getUserNonCats(service.current.id());
446 var p2 = service.fetchGroupFines();
447 return $q.all([p1, p2]);
451 service.createOptIn = function(user_id) {
452 return egCore.net.request(
454 'open-ils.actor.user.org_unit_opt_in.create',
455 egCore.auth.token(), user_id);
458 service.checkOptIn = function(user) {
459 var deferred = $q.defer();
462 'open-ils.actor.user.org_unit_opt_in.check',
463 egCore.auth.token(), user.id())
464 .then(function(optInResp) {
465 if (eg_evt = egCore.evt.parse(optInResp)) {
467 console.log('error on opt-in check: ' + eg_evt);
468 } else if (optInResp == 2) {
469 // opt-in disallowed at this location by patron's home library
471 alert(egCore.strings.OPT_IN_RESTRICTED);
472 } else if (optInResp == 1) {
473 // opt-in handled or not needed, do nothing
476 // opt-in needed, show the opt-in dialog
477 var org = egCore.org.get(user.home_ou());
478 egConfirmDialog.open(
479 egCore.strings.OPT_IN_DIALOG_TITLE,
480 egCore.strings.OPT_IN_DIALOG,
481 { family_name : user.family_name(),
482 first_given_name : user.first_given_name(),
483 org_name : org.name(),
484 org_shortname : org.shortname(),
486 service.createOptIn(user.id())
487 .then(function(resp) {
488 if (evt = egCore.evt.parse(resp)) {
496 cancel : function() { deferred.reject(); }
501 return deferred.promise;
504 // Avoid using parens [e.g. (1.23)] to indicate negative numbers,
505 // which is the Angular default.
506 // http://stackoverflow.com/questions/17441254/why-angularjs-currency-filter-formats-negative-numbers-with-parenthesis
507 // FIXME: This change needs to be moved into a project-wide collection
508 // of locale overrides.
509 $locale.NUMBER_FORMATS.PATTERNS[1].negPre = '-';
510 $locale.NUMBER_FORMATS.PATTERNS[1].negSuf = '';
516 * Manages patron search
518 .controller('BasePatronSearchCtrl',
519 ['$scope','$q','$routeParams','$timeout','$window','$location','egCore',
520 '$filter','egUser', 'patronSvc','egGridDataProvider','$document',
522 function($scope, $q, $routeParams, $timeout, $window, $location, egCore,
523 $filter, egUser, patronSvc , egGridDataProvider , $document,
526 $scope.focusMe = true;
527 $scope.searchArgs = {
528 // default to searching globally
529 home_ou : egCore.org.tree()
532 // last used patron search form element
535 $scope.gridControls = {
536 selectedItems : function() {return []}
539 // The first time we encounter the show-recent CGI param, put the
540 // service into show-recent mode. The first time recents are shown,
541 // the service is taken out of show-recent mode so the page does not
542 // get stuck in a show-recent loop.
543 if (patronSvc.showRecent === undefined
544 && Boolean($location.path().match(/search/))
545 && Boolean($location.search().show_recent)) {
546 patronSvc.showRecent = true;
549 // Handle URL-encoded searches
550 if ($location.search().search) {
551 console.log('URL search = ' + $location.search().search);
552 patronSvc.urlSearch = {search : JSON2js($location.search().search)};
554 // why the double-JSON encoded sort?
555 if (patronSvc.urlSearch.search.search_sort) {
556 patronSvc.urlSearch.sort =
557 JSON2js(patronSvc.urlSearch.search.search_sort);
559 patronSvc.urlSearch.sort = [];
561 delete patronSvc.urlSearch.search.search_sort;
563 // include inactive patrons if "inactive" param
564 if ($location.search().inactive) {
565 patronSvc.urlSearch.inactive = $location.search().inactive;
570 var propagate_inactive;
571 if (patronSvc.lastSearch && !patronSvc.showRecent) {
572 propagate = patronSvc.lastSearch.search;
573 // home_ou needs to be treated specially
574 propagate.home_ou = {
575 value : patronSvc.lastSearch.home_ou,
578 } else if (patronSvc.urlSearch) {
579 propagate = patronSvc.urlSearch.search;
580 if (patronSvc.urlSearch.inactive) {
581 propagate_inactive = patronSvc.urlSearch.inactive;
585 if (egCore.env.pgt) {
586 $scope.profiles = egCore.env.pgt.list;
588 egCore.pcrud.search('pgt', {parent : null},
589 {flesh : -1, flesh_fields : {pgt : ['children']}}
592 egCore.env.absorbTree(tree, 'pgt')
593 $scope.profiles = egCore.env.pgt.list;
599 // populate the search form with our cached / preexisting search info
600 angular.forEach(propagate, function(val, key) {
601 if (key == 'profile')
602 val.value = $scope.profiles.filter(function(p) { return p.id() == val.value })[0];
603 if (key == 'home_ou')
604 val.value = egCore.org.get(val.value);
605 $scope.searchArgs[key] = val.value;
607 if (propagate_inactive) {
608 $scope.searchArgs.inactive = propagate_inactive;
612 var provider = egGridDataProvider.instance({});
614 provider.get = function(offset, count) {
615 var deferred = $q.defer();
617 if (patronSvc.showRecent) {
618 // avoid getting stuck in show-recent mode
619 return patronSvc.getRecentPatrons();
623 if (patronSvc.urlSearch) {
624 fullSearch = patronSvc.urlSearch;
625 // enusre the urlSearch only runs once.
626 delete patronSvc.urlSearch;
629 patronSvc.search_barcode = $scope.searchArgs.card;
631 var search = compileSearch($scope.searchArgs);
632 if (Object.keys(search) == 0) return $q.when();
634 var home_ou = search.home_ou;
635 delete search.home_ou;
636 var inactive = search.inactive;
637 delete search.inactive;
641 sort : compileSort(),
647 fullSearch.count = count;
648 fullSearch.offset = offset;
650 if (patronSvc.lastSearch) {
651 // search repeated, return the cached results
652 if (angular.equals(fullSearch, patronSvc.lastSearch)) {
653 console.log('patron search returning ' +
654 patronSvc.patrons.length + ' cached results');
656 // notify has to happen after returning the promise
659 angular.forEach(patronSvc.patrons, function(user) {
660 deferred.notify(user);
665 return deferred.promise;
669 patronSvc.lastSearch = fullSearch;
671 if (fullSearch.search.id) {
672 // search by user id performs a direct ID lookup
673 var userId = fullSearch.search.id.value;
676 egUser.get(userId).then(function(user) {
677 patronSvc.localFlesh(user);
678 patronSvc.patrons = [user];
679 deferred.notify(user);
684 return deferred.promise;
687 if (!Object.keys(fullSearch.search).length) {
688 // Empty searches are rejected by the server. Avoid
689 // running the the empty search that runs on page load.
693 var fleshFields = egUser.defaultFleshFields.slice(0);
694 if (fleshFields.indexOf('profile') == -1)
695 fleshFields.push('profile');
697 egProgressDialog.open(); // Indeterminate
699 patronSvc.patrons = [];
700 var which_sound = 'success';
703 'open-ils.actor.patron.search.advanced.fleshed',
717 function() { // onerror
718 which_sound = 'error';
721 // hide progress bar as soon as the first result appears.
722 egProgressDialog.close();
723 patronSvc.localFlesh(user); // inline
724 patronSvc.patrons.push(user);
725 deferred.notify(user);
727 )['finally'](function() { // close on 0-hits or error
728 if (which_sound == 'success' && patronSvc.patrons.length == 0) {
729 which_sound = 'warning';
731 egCore.audio.play(which_sound + '.patron.by_search');
732 egProgressDialog.close();
735 return deferred.promise;
738 $scope.patronSearchGridProvider = provider;
740 // determine the tree depth of the profile group
741 $scope.pgt_depth = function(grp) {
743 while (grp = egCore.env.pgt.map[grp.parent()]) d++;
747 $scope.clearForm = function () {
748 $scope.searchArgs={};
749 if (lastFormElement) lastFormElement.focus();
752 $scope.applyShowExtras = function($event, bool) {
754 $scope.showExtras = true;
755 egCore.hatch.setItem('eg.circ.patron.search.show_extras', true);
757 $scope.showExtras = false;
758 egCore.hatch.removeItem('eg.circ.patron.search.show_extras');
760 if (lastFormElement) lastFormElement.focus();
761 $event.preventDefault();
764 egCore.hatch.getItem('eg.circ.patron.search.show_extras')
765 .then(function(val) {$scope.showExtras = val});
767 // check searchArgs.inactive setting
768 egCore.hatch.getItem('eg.circ.patron.search.include_inactive')
769 .then(function(searchInactive){
770 if (searchInactive) $scope.searchArgs.inactive = searchInactive;
773 $scope.onSearchInactiveChanged = function() {
774 egCore.hatch.setItem('eg.circ.patron.search.include_inactive', $scope.searchArgs.inactive);
777 // map form arguments into search params
778 function compileSearch(args) {
780 angular.forEach(args, function(val, key) {
782 if (key == 'profile' && args.profile) {
783 search.profile = {value : args.profile.id(), group : 5};
784 } else if (key == 'home_ou' && args.home_ou) {
785 search.home_ou = args.home_ou.id(); // passed separately
786 } else if (key == 'inactive') {
787 search.inactive = val;
788 } else if (key == 'name') { // name keywords search
789 search.name = {value: val};
791 search[key] = {value : val, group : 0};
793 if (key.match(/phone|ident/)) {
794 search[key].group = 2;
796 if (key.match(/street|city|state|post_code/)) {
797 search[key].group = 1;
798 } else if (key == 'card') {
799 search[key].group = 3
800 } else if (key.match(/dob_/)) {
801 // DOB should always be numeric
802 search[key].value = search[key].value.replace(/\D/g,'');
803 if (search[key].value.length == 0) {
807 if (!key.match(/year/)) {
808 search[key].value = ('0'+search[key].value).slice(-2);
810 search[key].group = 4;
819 function compileSort() {
821 if (!provider.sort.length) {
824 "first_given_name ASC",
825 "second_given_name ASC",
834 if (angular.isObject(sortdef)) {
835 var name = Object.keys(sortdef)[0];
836 var dir = sortdef[name];
837 sort.push(name + ' ' + dir);
847 $scope.setLastFormElement = function() {
848 lastFormElement = $document[0].activeElement;
851 // search form submit action; tells the results grid to
853 $scope.search = function(args) { // args === $scope.searchArgs
854 if (args && Object.keys(args).length)
855 $scope.gridControls.refresh();
856 if (lastFormElement) lastFormElement.focus();
859 // TODO: move this into the (forthcoming) grid row activate action
860 $scope.onPatronDblClick = function($event, user) {
861 $location.path('/circ/patron/' + user.id() + '/checkout');
864 if (patronSvc.urlSearch) {
865 // force the grid to load the url-based search on page load
869 $scope.need_two_selected = function() {
870 var items = $scope.gridControls.selectedItems();
871 return (items.length == 2) ? false : true;