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 // no need to re-track same user
138 if (service.current && service.current.id() == user_id) return;
141 egCore.hatch.getLoginSessionItem('eg.circ.recent_patrons') || [];
142 patrons.splice(0, 0, user_id); // put this user at front
143 patrons.splice(service.maxRecentPatrons, 1); // remove excess
145 // remove any other occurrences of this user, which may have been
146 // added before the most recent user.
147 var idx = patrons.indexOf(user_id, 1);
148 if (idx > 0) patrons.splice(idx, 1);
150 egCore.hatch.setLoginSessionItem('eg.circ.recent_patrons', patrons);
153 // sets the primary display user, fetching data as necessary.
154 service.setPrimary = function(id, user, force) {
155 var user_id = id ? id : (user ? user.id() : null);
157 console.debug('setting primary user to: ' + user_id);
159 if (!user_id) return $q.reject();
161 service.addRecentPatron(user_id);
163 // avoid running multiple retrievals for the same patron, which
164 // can happen during dbl-click by maintaining a single running
165 // data retrieval promise
166 if (service.primaryUserPromise) {
167 if (service.primaryUserId == user_id) {
168 return service.primaryUserPromise.promise;
170 service.primaryUserPromise = null;
174 service.primaryUserPromise = $q.defer();
175 service.primaryUserId = user_id;
177 service.getPrimary(id, user, force)
179 service.checkAlerts();
180 var p = service.primaryUserPromise;
181 service.primaryUserId = null;
182 // clear before resolution just to be safe.
183 service.primaryUserPromise = null;
187 return service.primaryUserPromise.promise;
190 service.getPrimary = function(id, user, force) {
193 if (!force && service.current &&
194 service.current.id() == user.id()) {
195 if (service.patron_stats) {
198 return service.fetchUserStats();
202 service.resetPatronLists();
204 return service.checkOptIn(user).then(
206 service.current = user;
207 service.localFlesh(user);
208 return service.fetchUserStats();
216 if (!force && service.current && service.current.id() == id) {
217 if (service.patron_stats) {
220 return service.fetchUserStats();
224 service.resetPatronLists();
226 return egUser.get(id).then(
228 return service.checkOptIn(user).then(
230 service.current = user;
231 service.localFlesh(user);
232 return service.fetchUserStats();
241 "unable to fetch user "+id+': '+js2JSON(err))
246 // fetching a null user clears the primary user.
247 // NOTE: this should probably reject() and log an error,
248 // but calling clear for backwards compat for now.
249 return service.clearPrimary();
253 // flesh some additional user fields locally
254 service.localFlesh = function(user) {
255 if (!angular.isObject(typeof user.home_ou()))
256 user.home_ou(egCore.org.get(user.home_ou()));
259 user.standing_penalties(),
261 if (!angular.isObject(penalty.org_unit()))
262 // clone the org unit IDL object and set children to an empty array
263 org_unit = egCore.idl.Clone(egCore.org.get(penalty.org_unit()));
264 org_unit.children([])
265 penalty.org_unit(org_unit);
269 // stat_cat_entries == stat_cat_entry_user_map
270 angular.forEach(user.stat_cat_entries(), function(map) {
271 if (angular.isObject(map.stat_cat())) return;
272 // At page load, we only retrieve org-visible stat cats.
273 // For the common case, ignore entries for remote stat cats.
274 var cat = egCore.env.actsc.map[map.stat_cat()];
277 cat.owner(egCore.org.get(cat.owner()));
282 // resolves to true if the patron account has expired or will
283 // expire soon, based on YAOUS circ.patron_expires_soon_warning
284 // note: returning a promise is no longer strictly necessary
285 // (no more async activity) if the calling function is changed too.
286 service.testExpire = function() {
288 var expire = Date.parse(service.current.expire_date());
289 if (expire < new Date()) {
290 return $q.when(service.patronExpired = true);
293 var soon = egCore.env.aous['circ.patron_expires_soon_warning'];
295 var preExpire = new Date();
296 preExpire.setDate(preExpire.getDate() + Number(soon));
297 if (expire < preExpire)
298 return $q.when(service.patronExpiresSoon = true);
301 return $q.when(false);
304 // resolves to true if the patron account has any invalid addresses.
305 service.testInvalidAddrs = function() {
307 if (service.invalidAddresses)
308 return $q.when(true);
313 service.current.addresses(),
314 function(addr) { if (addr.valid() == 'f') fail = true }
317 return $q.when(fail);
319 //resolves to true if the patron was fetched with an inactive card
320 service.fetchedWithInactiveCard = function() {
321 var bc = service.search_barcode
322 var cards = service.current.cards();
323 var card = cards.filter(function(c) { return c.barcode() == bc })[0];
324 return (card && card.active() == 'f');
326 // resolves to true if there is any aspect of the patron account
327 // which should produce a message in the alerts panel
328 service.checkAlerts = function() {
330 if (service.hasAlerts) // already checked
331 return $q.when(true);
333 var deferred = $q.defer();
334 var p = service.current;
336 if (service.alert_penalties.length ||
339 service.patron_stats.holds.ready) {
341 service.hasAlerts = true;
344 // see if the user was retrieved with an inactive card
345 if(service.fetchedWithInactiveCard()){
346 service.hasAlerts = true;
349 // regardless of whether we know of alerts, we still need
350 // to test/fetch the expire data for display
351 service.testExpire().then(function(bool) {
352 if (bool) service.hasAlerts = true;
353 deferred.resolve(service.hasAlerts);
356 service.testInvalidAddrs().then(function(bool) {
357 if (bool) service.invalidAddresses = true;
358 deferred.resolve(service.invalidAddresses);
361 return deferred.promise;
364 service.fetchGroupFines = function() {
365 return egCore.net.request(
367 'open-ils.actor.usergroup.members.balance_owed',
368 egCore.auth.token(), service.current.usrgroup()
369 ).then(function(list) {
371 angular.forEach(list, function(u) {
372 total += 100 * Number(u.balance_owed)
374 service.patron_stats.fines.group_balance_owed = total / 100;
378 service.getUserStats = function(id) {
379 return egCore.net.request(
381 'open-ils.actor.user.opac.vital_stats.authoritative',
382 egCore.auth.token(), id
385 // force numeric to ensure correct boolean handling in templates
386 stats.fines.balance_owed = Number(stats.fines.balance_owed);
387 stats.checkouts.overdue = Number(stats.checkouts.overdue);
388 stats.checkouts.claims_returned =
389 Number(stats.checkouts.claims_returned);
390 stats.checkouts.lost = Number(stats.checkouts.lost);
391 stats.checkouts.out = Number(stats.checkouts.out);
392 stats.checkouts.total_out =
393 stats.checkouts.out + stats.checkouts.overdue;
395 stats.checkouts.total_out += Number(stats.checkouts.long_overdue);
397 if (!egCore.env.aous['circ.do_not_tally_claims_returned'])
398 stats.checkouts.total_out += stats.checkouts.claims_returned;
400 if (egCore.env.aous['circ.tally_lost'])
401 stats.checkouts.total_out += stats.checkouts.lost
408 // Fetches the IDs of any active non-cat checkouts for the current
409 // user. Also sets the patron_stats non_cat count value to match.
410 service.getUserNonCats = function(id) {
411 return egCore.net.request(
413 'open-ils.circ.open_non_cataloged_circulation.user.authoritative',
414 egCore.auth.token(), id
415 ).then(function(noncat_ids) {
416 service.noncat_ids = noncat_ids;
417 service.patron_stats.checkouts.noncat = noncat_ids.length;
421 // grab additional circ info
422 service.fetchUserStats = function() {
423 return service.getUserStats(service.current.id())
424 .then(function(stats) {
425 service.patron_stats = stats
426 service.alert_penalties = service.current.standing_penalties()
427 .filter(function(pen) {
428 return pen.standing_penalty().staff_alert() == 't'
431 service.summary_stat_cats = [];
432 angular.forEach(service.current.stat_cat_entries(),
434 if (angular.isObject(map.stat_cat()) &&
435 map.stat_cat().usr_summary() == 't') {
436 service.summary_stat_cats.push(map);
441 // run these two in parallel
442 var p1 = service.getUserNonCats(service.current.id());
443 var p2 = service.fetchGroupFines();
444 return $q.all([p1, p2]);
448 service.createOptIn = function(user_id) {
449 return egCore.net.request(
451 'open-ils.actor.user.org_unit_opt_in.create',
452 egCore.auth.token(), user_id);
455 service.checkOptIn = function(user) {
456 var deferred = $q.defer();
459 'open-ils.actor.user.org_unit_opt_in.check',
460 egCore.auth.token(), user.id())
461 .then(function(optInResp) {
462 if (eg_evt = egCore.evt.parse(optInResp)) {
464 console.log('error on opt-in check: ' + eg_evt);
465 } else if (optInResp == 2) {
466 // opt-in disallowed at this location by patron's home library
468 alert(egCore.strings.OPT_IN_RESTRICTED);
469 } else if (optInResp == 1) {
470 // opt-in handled or not needed, do nothing
473 // opt-in needed, show the opt-in dialog
474 var org = egCore.org.get(user.home_ou());
475 egConfirmDialog.open(
476 egCore.strings.OPT_IN_DIALOG_TITLE,
477 egCore.strings.OPT_IN_DIALOG,
478 { family_name : user.family_name(),
479 first_given_name : user.first_given_name(),
480 org_name : org.name(),
481 org_shortname : org.shortname(),
483 service.createOptIn(user.id())
484 .then(function(resp) {
485 if (evt = egCore.evt.parse(resp)) {
493 cancel : function() { deferred.reject(); }
498 return deferred.promise;
501 // Avoid using parens [e.g. (1.23)] to indicate negative numbers,
502 // which is the Angular default.
503 // http://stackoverflow.com/questions/17441254/why-angularjs-currency-filter-formats-negative-numbers-with-parenthesis
504 // FIXME: This change needs to be moved into a project-wide collection
505 // of locale overrides.
506 $locale.NUMBER_FORMATS.PATTERNS[1].negPre = '-';
507 $locale.NUMBER_FORMATS.PATTERNS[1].negSuf = '';
513 * Manages patron search
515 .controller('BasePatronSearchCtrl',
516 ['$scope','$q','$routeParams','$timeout','$window','$location','egCore',
517 '$filter','egUser', 'patronSvc','egGridDataProvider','$document',
519 function($scope, $q, $routeParams, $timeout, $window, $location, egCore,
520 $filter, egUser, patronSvc , egGridDataProvider , $document,
523 $scope.focusMe = true;
524 $scope.searchArgs = {
525 // default to searching globally
526 home_ou : egCore.org.tree()
529 // last used patron search form element
532 $scope.gridControls = {
533 selectedItems : function() {return []}
536 // The first time we encounter the show-recent CGI param, put the
537 // service into show-recent mode. The first time recents are shown,
538 // the service is taken out of show-recent mode so the page does not
539 // get stuck in a show-recent loop.
540 if (patronSvc.showRecent === undefined
541 && Boolean($location.path().match(/search/))
542 && Boolean($location.search().show_recent)) {
543 patronSvc.showRecent = true;
546 // Handle URL-encoded searches
547 if ($location.search().search) {
548 console.log('URL search = ' + $location.search().search);
549 patronSvc.urlSearch = {search : JSON2js($location.search().search)};
551 // why the double-JSON encoded sort?
552 if (patronSvc.urlSearch.search.search_sort) {
553 patronSvc.urlSearch.sort =
554 JSON2js(patronSvc.urlSearch.search.search_sort);
556 patronSvc.urlSearch.sort = [];
558 delete patronSvc.urlSearch.search.search_sort;
560 // include inactive patrons if "inactive" param
561 if ($location.search().inactive) {
562 patronSvc.urlSearch.inactive = $location.search().inactive;
567 var propagate_inactive;
568 if (patronSvc.lastSearch && !patronSvc.showRecent) {
569 propagate = patronSvc.lastSearch.search;
570 // home_ou needs to be treated specially
571 propagate.home_ou = {
572 value : patronSvc.lastSearch.home_ou,
575 } else if (patronSvc.urlSearch) {
576 propagate = patronSvc.urlSearch.search;
577 if (patronSvc.urlSearch.inactive) {
578 propagate_inactive = patronSvc.urlSearch.inactive;
582 if (egCore.env.pgt) {
583 $scope.profiles = egCore.env.pgt.list;
585 egCore.pcrud.search('pgt', {parent : null},
586 {flesh : -1, flesh_fields : {pgt : ['children']}}
589 egCore.env.absorbTree(tree, 'pgt')
590 $scope.profiles = egCore.env.pgt.list;
596 // populate the search form with our cached / preexisting search info
597 angular.forEach(propagate, function(val, key) {
598 if (key == 'profile')
599 val.value = $scope.profiles.filter(function(p) { return p.id() == val.value })[0];
600 if (key == 'home_ou')
601 val.value = egCore.org.get(val.value);
602 $scope.searchArgs[key] = val.value;
604 if (propagate_inactive) {
605 $scope.searchArgs.inactive = propagate_inactive;
609 var provider = egGridDataProvider.instance({});
611 provider.get = function(offset, count) {
612 var deferred = $q.defer();
614 if (patronSvc.showRecent) {
615 // avoid getting stuck in show-recent mode
616 return patronSvc.getRecentPatrons();
620 if (patronSvc.urlSearch) {
621 fullSearch = patronSvc.urlSearch;
622 // enusre the urlSearch only runs once.
623 delete patronSvc.urlSearch;
626 patronSvc.search_barcode = $scope.searchArgs.card;
628 var search = compileSearch($scope.searchArgs);
629 if (Object.keys(search) == 0) return $q.when();
631 var home_ou = search.home_ou;
632 delete search.home_ou;
633 var inactive = search.inactive;
634 delete search.inactive;
638 sort : compileSort(),
644 fullSearch.count = count;
645 fullSearch.offset = offset;
647 if (patronSvc.lastSearch) {
648 // search repeated, return the cached results
649 if (angular.equals(fullSearch, patronSvc.lastSearch)) {
650 console.log('patron search returning ' +
651 patronSvc.patrons.length + ' cached results');
653 // notify has to happen after returning the promise
656 angular.forEach(patronSvc.patrons, function(user) {
657 deferred.notify(user);
662 return deferred.promise;
666 patronSvc.lastSearch = fullSearch;
668 if (fullSearch.search.id) {
669 // search by user id performs a direct ID lookup
670 var userId = fullSearch.search.id.value;
673 egUser.get(userId).then(function(user) {
674 patronSvc.localFlesh(user);
675 patronSvc.patrons = [user];
676 deferred.notify(user);
681 return deferred.promise;
684 if (!Object.keys(fullSearch.search).length) {
685 // Empty searches are rejected by the server. Avoid
686 // running the the empty search that runs on page load.
690 var fleshFields = egUser.defaultFleshFields.slice(0);
691 if (fleshFields.indexOf('profile') == -1)
692 fleshFields.push('profile');
694 egProgressDialog.open(); // Indeterminate
696 patronSvc.patrons = [];
697 var which_sound = 'success';
700 'open-ils.actor.patron.search.advanced.fleshed',
714 function() { // onerror
715 which_sound = 'error';
718 // hide progress bar as soon as the first result appears.
719 egProgressDialog.close();
720 patronSvc.localFlesh(user); // inline
721 patronSvc.patrons.push(user);
722 deferred.notify(user);
724 )['finally'](function() { // close on 0-hits or error
725 if (which_sound == 'success' && patronSvc.patrons.length == 0) {
726 which_sound = 'warning';
728 egCore.audio.play(which_sound + '.patron.by_search');
729 egProgressDialog.close();
732 return deferred.promise;
735 $scope.patronSearchGridProvider = provider;
737 // determine the tree depth of the profile group
738 $scope.pgt_depth = function(grp) {
740 while (grp = egCore.env.pgt.map[grp.parent()]) d++;
744 $scope.clearForm = function () {
745 $scope.searchArgs={};
746 if (lastFormElement) lastFormElement.focus();
749 $scope.applyShowExtras = function($event, bool) {
751 $scope.showExtras = true;
752 egCore.hatch.setItem('eg.circ.patron.search.show_extras', true);
754 $scope.showExtras = false;
755 egCore.hatch.removeItem('eg.circ.patron.search.show_extras');
757 if (lastFormElement) lastFormElement.focus();
758 $event.preventDefault();
761 egCore.hatch.getItem('eg.circ.patron.search.show_extras')
762 .then(function(val) {$scope.showExtras = val});
764 // check searchArgs.inactive setting
765 egCore.hatch.getItem('eg.circ.patron.search.include_inactive')
766 .then(function(searchInactive){
767 if (searchInactive) $scope.searchArgs.inactive = searchInactive;
770 $scope.onSearchInactiveChanged = function() {
771 egCore.hatch.setItem('eg.circ.patron.search.include_inactive', $scope.searchArgs.inactive);
774 // map form arguments into search params
775 function compileSearch(args) {
777 angular.forEach(args, function(val, key) {
779 if (key == 'profile' && args.profile) {
780 search.profile = {value : args.profile.id(), group : 5};
781 } else if (key == 'home_ou' && args.home_ou) {
782 search.home_ou = args.home_ou.id(); // passed separately
783 } else if (key == 'inactive') {
784 search.inactive = val;
785 } else if (key == 'name') { // name keywords search
786 search.name = {value: val};
788 search[key] = {value : val, group : 0};
790 if (key.match(/phone|ident/)) {
791 search[key].group = 2;
793 if (key.match(/street|city|state|post_code/)) {
794 search[key].group = 1;
795 } else if (key == 'card') {
796 search[key].group = 3
797 } else if (key.match(/dob_/)) {
798 // DOB should always be numeric
799 search[key].value = search[key].value.replace(/\D/g,'');
800 if (search[key].value.length == 0) {
804 if (!key.match(/year/)) {
805 search[key].value = ('0'+search[key].value).slice(-2);
807 search[key].group = 4;
816 function compileSort() {
818 if (!provider.sort.length) {
821 "first_given_name ASC",
822 "second_given_name ASC",
831 if (angular.isObject(sortdef)) {
832 var name = Object.keys(sortdef)[0];
833 var dir = sortdef[name];
834 sort.push(name + ' ' + dir);
844 $scope.setLastFormElement = function() {
845 lastFormElement = $document[0].activeElement;
848 // search form submit action; tells the results grid to
850 $scope.search = function(args) { // args === $scope.searchArgs
851 if (args && Object.keys(args).length)
852 $scope.gridControls.refresh();
853 if (lastFormElement) lastFormElement.focus();
856 // TODO: move this into the (forthcoming) grid row activate action
857 $scope.onPatronDblClick = function($event, user) {
858 $location.path('/circ/patron/' + user.id() + '/checkout');
861 if (patronSvc.urlSearch) {
862 // force the grid to load the url-based search on page load
866 $scope.need_two_selected = function() {
867 var items = $scope.gridControls.selectedItems();
868 return (items.length == 2) ? false : true;