From 7c3cdbbd140865e07d08952422c82605ac8c5676 Mon Sep 17 00:00:00 2001 From: Mike Rylander Date: Mon, 20 Mar 2017 16:38:15 -0400 Subject: [PATCH] LP#1706107: Offline mode Here is implemented an offline mode interface for the web staff client. It is made available during both network and server outages by using the UpUp[1] service worker wrapper. We leverage Lovefield[2] for local storage of library settings, configuration data, offline transactions, and the standalone offline block list. In order to make use of the offline interface, users should first log into the web staff client and navigate to the "Search -> Search for Patrons" interface, perform a search, select a user from the results, and open the Patron Editor interface. This will allow the offline interface to collect all the relevant configuration information for the workstation. In addition, the offline interface available from the Circulation menu provides a "Download block list" button when accessed while logged in. [1]https://www.talater.com/upup/ [2]https://google.github.io/lovefield/ Signed-off-by: Mike Rylander Signed-off-by: Kathy Lussier Conflicts: Open-ILS/src/templates/staff/base_js.tt2 Signed-off-by: Kathy Lussier --- Open-ILS/src/offline/offline.pl | 43 +- .../admin/workstation/t_print_templates.tt2 | 4 + Open-ILS/src/templates/staff/base_js.tt2 | 91 + .../staff/circ/patron/reg_actions.tt2 | 2 +- .../templates/staff/circ/patron/t_edit.tt2 | 14 +- Open-ILS/src/templates/staff/config.tt2 | 4 +- Open-ILS/src/templates/staff/index.tt2 | 1 + Open-ILS/src/templates/staff/navbar.tt2 | 57 +- .../src/templates/staff/offline-interface.tt2 | 632 +++++ .../print_templates/t_offline_checkin.tt2 | 25 + .../print_templates/t_offline_checkout.tt2 | 26 + .../t_offline_in_house_use.tt2 | 24 + .../share/print_templates/t_offline_renew.tt2 | 24 + .../src/templates/staff/share/t_datetime.tt2 | 6 + Open-ILS/src/templates/staff/t_login.tt2 | 6 + Open-ILS/web/LICENSE.UpUp | 21 + Open-ILS/web/js/ui/default/staff/Gruntfile.js | 18 +- Open-ILS/web/js/ui/default/staff/app.js | 8 +- .../js/ui/default/staff/circ/patron/regctl.js | 31 +- Open-ILS/web/js/ui/default/staff/offline.js | 2109 +++++++++++++++++ Open-ILS/web/js/ui/default/staff/package.json | 1 + .../web/js/ui/default/staff/services/auth.js | 44 +- .../web/js/ui/default/staff/services/env.js | 97 +- .../web/js/ui/default/staff/services/file.js | 58 +- .../web/js/ui/default/staff/services/hatch.js | 36 + .../web/js/ui/default/staff/services/idl.js | 2 - .../js/ui/default/staff/services/lovefield.js | 406 ++++ .../js/ui/default/staff/services/navbar.js | 51 +- .../web/js/ui/default/staff/services/org.js | 35 +- .../web/js/ui/default/staff/services/print.js | 9 +- .../js/ui/default/staff/services/startup.js | 2 + .../web/js/ui/default/staff/services/ui.js | 96 +- Open-ILS/web/upup.min.js | 7 + Open-ILS/web/upup.sw.min.js | 7 + 34 files changed, 3844 insertions(+), 153 deletions(-) create mode 100644 Open-ILS/src/templates/staff/offline-interface.tt2 create mode 100644 Open-ILS/src/templates/staff/share/print_templates/t_offline_checkin.tt2 create mode 100644 Open-ILS/src/templates/staff/share/print_templates/t_offline_checkout.tt2 create mode 100644 Open-ILS/src/templates/staff/share/print_templates/t_offline_in_house_use.tt2 create mode 100644 Open-ILS/src/templates/staff/share/print_templates/t_offline_renew.tt2 create mode 100644 Open-ILS/web/LICENSE.UpUp create mode 100644 Open-ILS/web/js/ui/default/staff/offline.js create mode 100644 Open-ILS/web/js/ui/default/staff/services/lovefield.js create mode 100644 Open-ILS/web/upup.min.js create mode 100644 Open-ILS/web/upup.sw.min.js diff --git a/Open-ILS/src/offline/offline.pl b/Open-ILS/src/offline/offline.pl index f7719d5741..4bc9e518a7 100755 --- a/Open-ILS/src/offline/offline.pl +++ b/Open-ILS/src/offline/offline.pl @@ -40,6 +40,7 @@ do '##CONFIG##/offline-config.pl'; my $cgi = new CGI; my $basedir = $config{base_dir} || die "Offline config error: no base_dir defined\n"; my $bootstrap = $config{bootstrap} || die "Offline config error: no bootstrap defined\n"; +my $webclient = $cgi->param('wc'); my $wsname = $cgi->param('ws'); my $org = $cgi->param('org'); my $authtoken = $cgi->param('ses') || ""; @@ -877,6 +878,7 @@ sub ol_handle_register { my $barcode = $command->{user}->{card}->{barcode}; delete $command->{user}->{card}; + delete $command->{user}->{cards} if $command->{user}->{cards}; $logger->info("offline: creating new user with barcode $barcode"); @@ -914,8 +916,10 @@ sub ol_handle_register { delete $command->{user}->{survey_responses}; $actor->survey_responses(\@sresp) if @sresp; + my $bid = undef; # extract the billing address if( my $addr = $command->{user}->{billing_address} ) { + $bid = $command->{user}->{billing_address}->{id}; $billing_address = Fieldmapper::actor::user_address->new; $billing_address->$_($addr->{$_}) for keys %$addr; $billing_address->isnew(1); @@ -925,15 +929,26 @@ sub ol_handle_register { $logger->debug("offline: read billing address ".$billing_address->street1); } + my $mid = undef; # extract the mailing address if( my $addr = $command->{user}->{mailing_address} ) { - $mailing_address = Fieldmapper::actor::user_address->new; - $mailing_address->$_($addr->{$_}) for keys %$addr; - $mailing_address->isnew(1); - $mailing_address->id(-2); - $mailing_address->usr(-1); + $mid = $command->{user}->{mailing_address}->{id}; + if ($webclient && $mid != $bid) { + $mailing_address = Fieldmapper::actor::user_address->new; + $mailing_address->$_($addr->{$_}) for keys %$addr; + $mailing_address->isnew(1); + $mailing_address->id(-2); + $mailing_address->usr(-1); + $logger->debug("offline: read mailing address ".$mailing_address->street1); + } elsif (!$webclient) { + $mailing_address = Fieldmapper::actor::user_address->new; + $mailing_address->$_($addr->{$_}) for keys %$addr; + $mailing_address->isnew(1); + $mailing_address->id(-2); + $mailing_address->usr(-1); + $logger->debug("offline: read mailing address ".$mailing_address->street1); + } delete $command->{user}->{mailing_address}; - $logger->debug("offline: read mailing address ".$mailing_address->street1); } # make sure we have values for both @@ -946,9 +961,23 @@ sub ol_handle_register { push( @{$actor->addresses}, $billing_address ) unless $billing_address->id eq $mailing_address->id; + + my $aid = -3; + for my $a ( @{$command->{user}->{addresses}} ) { + next if ($a->{id} == $bid || $a->{id} == $mid); + # extract all other addresses + my $addr = Fieldmapper::actor::user_address->new; + $addr->$_($a->{$_}) for keys %$a; + $addr->isnew(1); + $addr->id($aid); + $addr->usr(-1); + $logger->debug("offline: read other address ".$addr->street1); + $aid--; + push( @{$actor->addresses}, $addr ); + } # pull all of the rest of the data from the command blob - $actor->$_( $command->{user}->{$_} ) for keys %{$command->{user}}; + $actor->$_( $command->{user}->{$_} ) for grep { $_ ne 'addresses' } keys %{$command->{user}}; # calculate the expire date for the patron based on the profile group my ($grp) = grep {$_->id == $actor->profile} @$user_groups; diff --git a/Open-ILS/src/templates/staff/admin/workstation/t_print_templates.tt2 b/Open-ILS/src/templates/staff/admin/workstation/t_print_templates.tt2 index 064bfff811..12eccfba00 100644 --- a/Open-ILS/src/templates/staff/admin/workstation/t_print_templates.tt2 +++ b/Open-ILS/src/templates/staff/admin/workstation/t_print_templates.tt2 @@ -33,6 +33,10 @@ + + + + + +
+ [% l('Input is out of range.') %] +
diff --git a/Open-ILS/src/templates/staff/t_login.tt2 b/Open-ILS/src/templates/staff/t_login.tt2 index ce06e6b097..e4d42cb30f 100644 --- a/Open-ILS/src/templates/staff/t_login.tt2 +++ b/Open-ILS/src/templates/staff/t_login.tt2 @@ -49,6 +49,12 @@ +
+
+ [% l('Unprocessed offline transactions waiting for upload') %] +
+
+ diff --git a/Open-ILS/web/LICENSE.UpUp b/Open-ILS/web/LICENSE.UpUp new file mode 100644 index 0000000000..c7fa58d656 --- /dev/null +++ b/Open-ILS/web/LICENSE.UpUp @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Tal Ater + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Open-ILS/web/js/ui/default/staff/Gruntfile.js b/Open-ILS/web/js/ui/default/staff/Gruntfile.js index 8a885a1f1e..d8af90443c 100644 --- a/Open-ILS/web/js/ui/default/staff/Gruntfile.js +++ b/Open-ILS/web/js/ui/default/staff/Gruntfile.js @@ -37,6 +37,7 @@ module.exports = function(grunt) { 'node_modules/iframe-resizer/js/iframeResizer.map', 'node_modules/iframe-resizer/js/iframeResizer.contentWindow.min.js', 'node_modules/angular-order-object-by/src/ng-order-object-by.js', + 'node_modules/angular-tablesort/js/angular-tablesort.js', 'node_modules/lovefield/dist/lovefield.min.js', 'node_modules/lovefield/dist/lovefield.min.js.map', 'node_modules/moment/min/moment-with-locales.min.js', @@ -58,6 +59,7 @@ module.exports = function(grunt) { 'node_modules/ngtoast/dist/ngToast-animations.min.css', 'node_modules/angular-tree-control/css/tree-control.css', 'node_modules/angular-tree-control/css/tree-control-attribute.css', + 'node_modules/angular-tablesort/tablesort.css' ] }] }, @@ -72,7 +74,8 @@ module.exports = function(grunt) { 'node_modules/bootstrap/dist/fonts/glyphicons-halflings-regular.eot', 'node_modules/bootstrap/dist/fonts/glyphicons-halflings-regular.svg', 'node_modules/bootstrap/dist/fonts/glyphicons-halflings-regular.ttf', - 'node_modules/bootstrap/dist/fonts/glyphicons-halflings-regular.woff' + 'node_modules/bootstrap/dist/fonts/glyphicons-halflings-regular.woff', + 'node_modules/bootstrap/dist/fonts/glyphicons-halflings-regular.woff2' ] }] }, @@ -110,7 +113,7 @@ module.exports = function(grunt) { 'build/css/ngToast.min.css', 'build/css/ngToast-animations.min.css', 'build/css/tree-control.css', - 'build/css/tree-control-attribute.css', + 'build/css/tree-control-attribute.css' ] } } @@ -130,7 +133,7 @@ module.exports = function(grunt) { rename: function (dst, src) { return src.replace('.js', '.min.js'); } - }], + }] }, build: { src: [ @@ -172,6 +175,7 @@ module.exports = function(grunt) { 'services/ui.js', 'services/date.js', 'services/op_change.js', + 'services/file.js' ], dest: 'build/js/<%= pkg.name %>.<%= pkg.version %>.min.js' }, @@ -181,7 +185,7 @@ module.exports = function(grunt) { // to more easily detect if concat order is incorrect concat: { options: { - separator: ';', + separator: ';' } }, @@ -190,19 +194,19 @@ module.exports = function(grunt) { // Generate test/data/IDL2js.js for unit tests. // note: the output of this script is *not* part of the final build. idl2js : { - command : 'cd test/data && perl idl2js.pl', + command : 'cd test/data && perl idl2js.pl' }, // Remove the unit test IDL2js.js file. We don't need it after testing rmidl2js : { - command : 'rm test/data/IDL2js.js', + command : 'rm test/data/IDL2js.js' } }, // unit tests configuration karma : { unit: { - configFile: 'test/karma.conf.js', + configFile: 'test/karma.conf.js' //background: true // for now, visually babysit unit tests } } diff --git a/Open-ILS/web/js/ui/default/staff/app.js b/Open-ILS/web/js/ui/default/staff/app.js index 4cf388ce85..f76c2786fd 100644 --- a/Open-ILS/web/js/ui/default/staff/app.js +++ b/Open-ILS/web/js/ui/default/staff/app.js @@ -40,8 +40,12 @@ function($routeProvider , $locationProvider) { /* inject services into our controller. Spelling them * out like this allows the auto-magic injector to work * even if the code has been minified */ - ['$scope','$location','$window','egCore', - function($scope , $location , $window , egCore) { + ['$scope','$location','$window','egCore','egLovefield', + function($scope , $location , $window , egCore , egLovefield) { + egLovefield.havePendingOfflineXacts() .then(function(eh){ + $scope.pendingXacts = eh; + }); + $scope.focusMe = true; $scope.args = {}; $scope.workstations = []; diff --git a/Open-ILS/web/js/ui/default/staff/circ/patron/regctl.js b/Open-ILS/web/js/ui/default/staff/circ/patron/regctl.js index f40a9c0e04..6d9ddc8f5b 100644 --- a/Open-ILS/web/js/ui/default/staff/circ/patron/regctl.js +++ b/Open-ILS/web/js/ui/default/staff/circ/patron/regctl.js @@ -2,7 +2,7 @@ angular.module('egCoreMod') // toss tihs onto egCoreMod since the page app may vary -.factory('patronRegSvc', ['$q', 'egCore', function($q, egCore) { +.factory('patronRegSvc', ['$q', 'egCore', 'egLovefield', function($q, egCore, egLovefield) { var service = { field_doc : {}, // config.idl_field_doc @@ -251,6 +251,11 @@ angular.module('egCoreMod') }); }); }); + + egLovefield.setListInOfflineCache('asv', service.surveys) + egLovefield.setListInOfflineCache('asvq', service.survey_questions) + egLovefield.setListInOfflineCache('asva', service.survey_answers) + }); } @@ -270,6 +275,7 @@ angular.module('egCoreMod') ); }); service.stat_cats = cats; + return egLovefield.setStatCatsCache(cats); }); }; @@ -364,7 +370,7 @@ angular.module('egCoreMod') // some org settings require the retrieval of additional data service.process_org_settings = function(settings) { - var promises = []; + var promises = [egLovefield.setSettingsCache(settings)]; if (settings['sms.enable']) { // fetch SMS carriers @@ -434,14 +440,23 @@ angular.module('egCoreMod') } service.get_field_doc = function() { + var to_cache = []; return egCore.pcrud.search('fdoc', { fm_class: ['au', 'ac', 'aua', 'actsc', 'asv', 'asvq', 'asva']}) - .then(null, null, function(doc) { - if (!service.field_doc[doc.fm_class()]) { - service.field_doc[doc.fm_class()] = {}; + .then( + function () { + return egLovefield.setListInOfflineCache('fdoc', to_cache) + }, + null, + function(doc) { + if (!service.field_doc[doc.fm_class()]) { + service.field_doc[doc.fm_class()] = {}; + } + service.field_doc[doc.fm_class()][doc.field()] = doc; + to_cache.push(doc); } - service.field_doc[doc.fm_class()][doc.field()] = doc; - }); + ); + }; service.get_user_settings = function() { @@ -471,6 +486,8 @@ angular.module('egCoreMod') ] }, {}, {atomic : true}).then(function(setting_types) { + egCore.env.absorbList(setting_types, 'cust'); // why not... + angular.forEach(setting_types, function(stype) { service.user_setting_types[stype.name()] = stype; if (static_types.indexOf(stype.name()) == -1) { diff --git a/Open-ILS/web/js/ui/default/staff/offline.js b/Open-ILS/web/js/ui/default/staff/offline.js new file mode 100644 index 0000000000..dab4065b51 --- /dev/null +++ b/Open-ILS/web/js/ui/default/staff/offline.js @@ -0,0 +1,2109 @@ +/** + * App to drive the offline UI + */ + +lf.isOffline = true; + +angular.module('egOffline', ['ngRoute', 'ui.bootstrap', 'egCoreMod', 'egUiMod', 'ngToast', 'tableSort']) + +.config( + ['$routeProvider','$locationProvider','$compileProvider', +function($routeProvider , $locationProvider , $compileProvider) { + + $locationProvider.html5Mode(true); + $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); + + /** + * Route resolvers allow us to run async commands + * before the page controller is instantiated. + */ + var resolver = {delay : ['egCore', + function(egCore) { + return egCore.startup.go(); + } + ]}; + + $routeProvider.when('/offline-interface/:tab', { + templateUrl: 'offline-template', + controller: 'OfflineCtrl', + resolve : resolver + }); + + // default page + $routeProvider.otherwise({ + templateUrl : 'offline-template', + controller : 'OfflineCtrl', + resolve : resolver + }); +}]) + +.controller('OfflineSessionCtrl', + ['$scope','$window','egCore','$routeParams','$http','$q','$timeout','egPromptDialog','ngToast','egProgressDialog', + function($scope , $window , egCore , $routeParams , $http , $q , $timeout , egPromptDialog , ngToast , egProgressDialog) { + $scope.active_session_tab = 'pending'; + + $scope.lookupNoncatTypeName = function (type) { + var nc = $scope.noncats.filter(function(n){ return n.id() == type })[0]; + if (nc) return nc.name(); + return ''; + } + + $scope.createDate = function (ts, epoch) { + if (!ts) return ''; + if (epoch) ts = ts * 1000; + return new Date(ts); + } + + $scope.setSession = function (s, ind) { + $scope.current_session = s; + $scope.current_session_index = ind; + + return $scope.refreshExceptions(s); + } + + $scope.createSession = function () { + + return egPromptDialog.open( + egCore.strings.OFFLINE_SESSION_DESC, '', + {ok : function(value) { + if (value) { + + return $http.get(formURL({action:'create',desc:value})).then(function(res) { + if (res.data.ilsevent == "0") return $q.when(res.data.payload); + return $q.reject(); + }).then(function (seskey) { + return $scope.refreshSessions().then(function() { + if (seskey) { + var s = $scope.sessions.filter(function(s){ s.key == seskey })[0]; + var ind = $scope.sessions.length - 1; // sorted by create time, so new one is last + return $scope.setSession(s, ind); + } + }); + }, function() { + ngToast.warning(egCore.strings.OFFLINE_SESSION_CREATE_FAILED); + }); + } + }} + ); + } + + $scope.processSession = function (s, ind) { + return $scope.setSession(s, ind).then(function() { + egProgressDialog.open(); + + return $http.get( + formURL({action:'execute',seskey:$scope.current_session.key}) + ).then(function(res) { + if (res.data.ilsevent == "0") return $q.when(res.data.payload); + return $q.reject(); + }).then(function () { + egProgressDialog.close(); + return $scope.refreshSessions() + .then(function(){ return $scope.refreshExceptions(s) }); + },function () { + egProgressDialog.close(); + return $scope.refreshSessions().then(function() { + ngToast.warning(egCore.strings.OFFLINE_SESSION_PROCESSING_FAILED); + }); + }); + }); + } + + $scope.refreshExceptions = function (s) { + return $http.get( + formURL({ + action : 'status', + status_type : 'exceptions', + seskey : s.key + }) + ).then(function(res) { + if (res.data.ilsevent) { + $scope.current_session.exceptions = []; + } else { + $scope.current_session.exceptions = res.data; + } + return $q.when(); + }); + } + + $scope.refreshSessions = function () { + + return $http.get(formURL({action:'status',status_type:'sessions'})).then(function(res) { + if (res.data) { + $scope.sessions = res.data; + return $q.when(); + } + return $q.reject(); + }).then(function() { + var creator_list = [$q.when()]; + angular.forEach($scope.sessions, function (s) { + s.total = 0; + s.org = egCore.org.get(s.org).shortname(); + creator_list.push(egCore.pcrud.retrieve('au',s.creator).then(function(u) { + s.creator = u.family_name(); + })); + angular.forEach(s.scripts, function(sc) { + s.total += sc.count; + }); + }); + + return $q.all(creator_list); + }); + } + + $scope.reprintLast = function () { + egCore.print.reprintLast(); + } + + + $scope.uploadPending = function (s, ind) { + return $scope.setSession(s, ind).then(function() { + + egProgressDialog.open(); + return $scope.createOfflineXactBlob().then(function(blob) { + + var form = new FormData(); + form.append("ses", egCore.auth.token()); + form.append("org", $scope.org.id()); + form.append("ws", $scope.current_workstation_name()); + form.append("wc", 1); + form.append("action", "load"); + form.append("seskey", $scope.current_session.key); + form.append("file", blob, "file"); + + return $http.post( + '/cgi-bin/offline/offline.pl?' + new Date().getTime(), + form, + { + transformRequest: angular.identity, + headers: {'Content-Type': undefined} + } + ).then(function(res) { + egProgressDialog.close(); + if (res.data.ilsevent == "0") { + return $scope.clear_pending(true).then(function() { + return $scope.refreshSessions(); + }); + } else { + ngToast.warning(egCore.strings.OFFLINE_SESSION_UPLOAD_FAILED); + return $scope.refreshSessions(); + } + },function () { egProgressDialog.close() }); + }); + }); + } + + $scope.retrieveDetails = function (x) { + alert(JSON.stringify(x, null, 2)); // egAlertDialog kills pretty printing + } + + $scope.retrieveItem = function (bc) { + return egCore.pcrud.search('acp',{deleted: 'f', barcode: bc}).then(function(copy) { + if (copy) { + return $window.open( + egCore.env.basePath + + '/cat/item/' + copy.id(), + '_blank' + ).focus(); + } + + ngToast.warning(egCore.strings.ITEM_NOT_FOUND); + }); + } + + $scope.retrievePatron = function (bc) { + return egCore.pcrud.search('ac',{barcode: bc}).then(function(card) { + if (card) { + return $window.open( + egCore.env.basePath + + '/circ/patron/' + card.usr() + '/checkout', + '_blank' + ).focus(); + } + + ngToast.warning(egCore.strings.PATRON_NOT_FOUND); + }); + } + + function formURL (params) { + var url = '/cgi-bin/offline/offline.pl?' + new Date().getTime(); + + var defaults = { + org : $scope.org ? $scope.org.id() : null, + ws : $scope.current_workstation_name(), + wc : 1, + ses : egCore.auth.token() + } + + angular.extend(params, defaults) + + var first = true; + for (var k in params) { + url += '&' + k + '=' + window.encodeURIComponent(params[k]); + } + return url; + } + + $scope.$watch('org',function(n){if (n) $scope.refreshSessions()}); + + } +]) + +.controller('OfflineCtrl', + ['$q','$scope','$window','$location','$rootScope','egCore','egLovefield','$routeParams','$timeout','$http','ngToast','egConfirmDialog','egUnloadPrompt', + function($q , $scope , $window , $location , $rootScope , egCore , egLovefield , $routeParams , $timeout , $http , ngToast , egConfirmDialog , egUnloadPrompt) { + $scope.active_tab = $routeParams.tab || 'checkout'; + + // Immediately redirect if we're really offline + if (!$window.navigator.onLine) { + if ($location.path().match(/session$/)) { + var path = $location.path(); + return $location.path(path.replace('session','checkout')); + } + } + + var today = new Date(); + today.setHours(0); + today.setMinutes(0); + today.setSeconds(0); + today.setMilliseconds(0); + + $scope.minDate = today; + $scope.blocked_patron = null; + $scope.bad_barcode = null; + $scope.barcode_type = 'barcode'; + $scope.focusMe = true; + $scope.shared = { outOfRange : false, due_date : null, due_date_offset : '' }; + $scope.workstation_obj = null; + $scope.workstation = ''; + $scope.workstation_owner = ''; + $scope.workstations = []; + $scope.org = null; + $scope.do_print = Boolean($scope.active_tab == 'checkout'); + $scope.do_print_changed = false; + $scope.printed = false; + + $scope.imported_pending_xacts = { data : '' }; + + $scope.xact_page = { checkin:[], checkout:[], renew:[], in_house_use:[] }; + $scope.all_xact = []; + $scope.noncats = []; + + $scope.checkout = { noncat_type : '' }; + $scope.renew = { noncat_type : '' }; + $scope.in_house_use = {count : 1}; + $scope.checkin = { backdate : new Date() }; + + $scope.current_workstation_owning_lib = function () { + return $scope.workstations.filter(function(w) { + return $scope.workstation == w.id + })[0].owning_lib; + } + + $scope.current_workstation_name = function () { + return $scope.workstations.filter(function(w) { + return $scope.workstation == w.id + })[0].name; + } + + $scope.$watch('workstation', function (n,o) { + if (egCore.env.aou) + $scope.org = egCore.org.get($scope.current_workstation_owning_lib()); + }); + + $scope.changeCheck = function () { + $scope.strict_barcode = !$scope.strict_barcode; + $scope.do_check_changed = true; + egCore.hatch.setItem('eg.offline.strict_barcode', $scope.strict_barcode) + } + + $scope.changePrint = function () { + $scope.do_print = !$scope.do_print; + $scope.do_print_changed = true; + egCore.hatch.setItem('eg.offline.print_receipt', $scope.do_print) + } + + $scope.logged_in = egCore.auth.token() ? true : false; + + if (!$scope.logged_in && $routeParams.tab == 'session') + $scope.active_tab = 'checkout'; + + egCore.hatch.getItem('eg.offline.print_receipt') + .then(function(setting) { + $scope.do_print = setting; + if (setting !== undefined) $scope.do_print_changed = true; + }); + + egCore.hatch.getItem('eg.offline.strict_barcode') + .then(function(setting) { + $scope.strict_barcode = setting; + if (setting !== undefined) $scope.do_check_changed = true; + }); + + egCore.hatch.getItem('eg.workstation.all') + .then(function(all) { + if (all && all.length) { + $scope.workstations = all; + + if (ws = $location.search().ws) { + // user requested a workstation via URL + var match = all.filter( + function(w) {return ws == w.name} )[0]; + + if (match) { + // requested WS registered on this client + $scope.workstation = match.id; + } else { + // the requested WS is not registered on this client + $scope.wsNotRegistered = true; + } + } else { + // no workstation requested; use the default + egCore.hatch.getItem('eg.workstation.default') + .then(function(ws) { + var ws_obj = all.filter(function(w) { + return ws == w.name + })[0]; + + $scope.workstation_obj = ws_obj; + $scope.workstation = ws_obj.id; + $scope.workstation_owner = ws_obj.owning_lib; + + return egLovefield.reconstituteList('cnct').then(function () { + $scope.noncats = egCore.env.cnct.list; + }); + }); + } + } + }); + + $scope.downloadBlockList = function () { + var url = '/standalone/list.txt?ses=' + + egCore.auth.token() + + '&' + new Date().getTime(); + return $http.get(url).then( + function (res) { + if (res.data) { + var lines = res.data.split('\n'); + egLovefield.destroyOfflineBlocks().then(function(){ + angular.forEach(lines, function (l) { + var parts = l.split(' '); + egLovefield.addOfflineBlock(parts[0], parts[1]); + }); + return $q.when(); + }).then(function(){ + ngToast.create(egCore.strings.OFFLINE_BLOCKLIST_SUCCESS); + }); + } + },function(){ + ngToast.warning(egCore.strings.OFFLINE_BLOCKLIST_FAIL); + egCore.audio.play('warning.offline.blocklist_fail'); + } + ); + } + + $scope.createOfflineXactBlob = function () { + return egLovefield.retrievePendingOfflineXacts().then(function(list) { + var flat_list = []; + angular.forEach(list, function (i) { + flat_list.push(JSON.stringify(i) + '\n'); + }); + + var blob = new Blob(flat_list, {type: 'text/plain'}); + + return $q.when(blob) + }); + } + + $scope.pending_xacts = []; + $scope.retrieve_pending = function () { + return egLovefield.retrievePendingOfflineXacts().then(function(list) { + $scope.pending_xacts = list; + return $q.when(list); + }); + } + + $scope.save = function () { + var promises = [$q.when()]; + angular.forEach($scope.all_xact, function (x) { + promises.push(egLovefield.addOfflineXact(x)); + }); + + var prints = [$q.when()]; + if ($scope.do_print) { + angular.forEach(['checkin','checkout','renew','in_house_use'], function(xtype) { + if ($scope.xact_page[xtype].length > 0) { + prints.push(egCore.print.print({ + context : 'offline', + template : 'offline_'+xtype, + scope : { + transactions : $scope.xact_page[xtype] + } + })); + } + }); + } + + return $q.all(promises.concat(prints)).finally(function() { + egUnloadPrompt.clear(); + if (prints.length > 1) $scope.printed = true; + $scope.all_xact = []; + $scope.xact_page = { checkin:[], checkout:[], renew:[], in_house_use:[] }; + angular.forEach(['checkout','renew'], function (xtype) { + $scope[xtype].patron_barcode = ''; + }); + $scope.retrieve_pending(); + }); + } + + $rootScope.save_offline_xacts = function () { return $scope.save() }; + $rootScope.active_tab = function (t) { $scope.active_tab = t }; + + $scope.logout = function () { + egCore.auth.logout(); + $window.location.href = location.href; + } + + $scope.clear_pending = function (skip_confirm) { + if (skip_confirm) { + return egLovefield.destroyPendingOfflineXacts().then(function () { + return $scope.retrieve_pending(); + }); + } + return egConfirmDialog.open( + egCore.strings.CONFIRM_CLEAR_PENDING, + egCore.strings.CONFIRM_CLEAR_PENDING_BODY, + {} + ).result.then(function() { + return egLovefield.destroyPendingOfflineXacts().then(function () { + return $scope.retrieve_pending(); + }); + }); + + } + + $scope.retrieve_pending(); + $scope.$watch('active_tab', function (n,o) { + if (n != o && !$scope.do_check_changed && n != 'checkout') $scope.strict_barcode = false; + if (n != o && !$scope.do_check_changed && n == 'checkout') $scope.strict_barcode = true; + if (n != o && !$scope.do_print_changed && n != 'checkout') $scope.do_print = false; + if (n != o && !$scope.do_print_changed && n == 'checkout') $scope.do_print = true; + if (n != o && n == 'session') $scope.retrieve_pending(); + }); + + $scope.$watch('imported_pending_xacts.data', function (n, o) { + if (n != 0) { + var lines = n.split('\n'); + var promises = []; + + angular.forEach(lines, function (l) { + if (!l) return; + + try { + promises.push( + egLovefield.addOfflineXact(JSON.parse(l)) + ); + } catch (err) { + ngToast.warning(err); + } + }); + + $q.all(promises).then(function () { $scope.retrieve_pending() }); + } + }); + + $scope.resetDueDate = function (xtype) { + $scope.shared.due_date = new Date(); + $scope.shared.due_date.setDate($scope.shared.due_date.getDate() + parseInt($scope.shared.due_date_offset)); + } + + $scope.notEnough = function (xtype) { + + if (xtype == 'checkout') { + if ($scope.shared.outOfRange) return true; + if ( + $scope.checkout.patron_barcode && + ($scope.shared.due_date || $scope.shared.due_date_offset) && + ($scope.checkout.barcode || ($scope.checkout.noncat_type && $scope.checkout.noncat_count)) + ) return false; + return true; + } + + if (xtype == 'renew') { + if ($scope.shared.outOfRange) return true; + if ( + $scope.renew.barcode && + ($scope.shared.due_date || $scope.shared.due_date_offset) + ) return false; + return true; + } + + if (xtype == 'in_house_use') { + if ( + $scope.in_house_use.barcode && $scope.in_house_use.count + ) return false; + return true; + } + + if (xtype == 'checkin') { + if ( + $scope.checkin.barcode && $scope.checkin.backdate + ) return false; + return true; + } + } + + $scope.clear = function (xtype) { + $scope[xtype] = {}; + if (xtype=="in_house_use") $scope[xtype].count = 1; + } + + $scope.add = function (xtype,next_focus) { + + var barcode = $scope[xtype].barcode; + if (barcode) { + if ($scope.xact_page[xtype].filter(function(x){ return x.barcode == barcode }).length > 0) { + ngToast.warning(egCore.strings.DUPLICATE_BARCODE); + egCore.audio.play('warning.offline.duplicate_barcode'); + $scope[xtype].barcode = ''; + if (next_focus) $('#'+next_focus).focus(); + return; + } + } + + var pbarcode = $scope[xtype].patron_barcode; + if (pbarcode) { + egLovefield.testOfflineBlock(pbarcode).then(function (blocked) { + if (blocked) { + egCore.audio.play('warning.offline.blocked_patron'); + egConfirmDialog.open( + egCore.strings.PATRON_BLOCKED, + egCore.strings.PATRON_BLOCKED_WHY[blocked], + {}, egCore.strings.ALLOW, egCore.strings.REJECT + ).result.then( + function(){ // forced + $scope.blocked_patron = null; + _add_impl(xtype,true) + if (next_focus) $('#'+next_focus).focus(); + },function(){ // stopped + $scope.blocked_patron = xtype; + if (next_focus) $('#'+next_focus).focus(); + return; + } + ); + } else { + $scope.blocked_patron = null; + _add_impl(xtype,true) + if (next_focus) $('#'+next_focus).focus(); + } + }); + } else { + _add_impl(xtype); + if (next_focus) $('#'+next_focus).focus(); + } + } + + function _add_impl (xtype,digest) { + var pbarcode = $scope[xtype].patron_barcode; + var backdate = $scope[xtype].backdate; + + if ($scope.strict_barcode && pbarcode) { + if (!check_barcode(pbarcode)) { + $scope.bad_barcode = xtype; + egCore.audio.play('warning.offline.bad_barcode'); + return egConfirmDialog.open( + egCore.strings.BAD_PATRON_BARCODE, + egCore.strings.BAD_PATRON_BARCODE_CD, + {}, egCore.strings.ALLOW, egCore.strings.REJECT + ).result.then( + function(){ // forced + $scope.blocked_patron = null; + return _add_impl2(xtype,digest) + },function(){ // stopped + $scope.blocked_patron = xtype; + } + ); + } + } + + if ($scope.strict_barcode && $scope[xtype].barcode) { + if (!check_barcode($scope[xtype].barcode)) { + $scope.bad_barcode = xtype; + egCore.audio.play('warning.offline.bad_barcode'); + return egConfirmDialog.open( + egCore.strings.BAD_BARCODE, + egCore.strings.BAD_BARCODE_CD, + {}, egCore.strings.ALLOW, egCore.strings.REJECT + ).result.then( + function(){ // forced + $scope.blocked_patron = null; + return _add_impl2(xtype,digest) + },function(){ // stopped + $scope.blocked_patron = xtype; + } + ); + } + } + + return _add_impl2(xtype,digest); + } + + function _add_impl2 (xtype,digest) { + var pbarcode = $scope[xtype].patron_barcode; + var backdate = $scope[xtype].backdate; + + $scope.bad_barcode = null; + + var now = new Date().getTime(); + now = now / 1000; + + if ($scope[xtype].noncat_type) $scope[xtype].noncat = 1; + + if ($scope.shared.due_date && (xtype == 'checkout' || xtype == 'renew')) { + $scope[xtype].due_date = $scope.shared.due_date.toISOString(); + $scope[xtype].checkout_time = new Date().toISOString(); + } + + var xact = { timestamp : parseInt(now), type : xtype, delta : 0 }; + + $scope.xact_page[xtype].push( + angular.extend(xact, $scope[xtype]) + ); + + $scope.all_xact.push(xact) + egUnloadPrompt.attach($rootScope); + + $scope[xtype] = {}; + + if (pbarcode) $scope[xtype].patron_barcode = pbarcode; + if (backdate) $scope[xtype].backdate = backdate; + if (xtype=="in_house_use") $scope[xtype].count = 1; + + if (digest) $timeout(function(){$scope.$apply()}); + } + + check_barcode = function(bc) { + if (bc != Number(bc)) return false; + bc = bc.toString(); + // "16.00" == Number("16.00"), but the . is bad. + // Throw out any barcode that isn't just digits + if (bc.search(/\D/) != -1) return false; + var last_digit = bc.substr(bc.length-1); + var stripped_barcode = bc.substr(0,bc.length-1); + return barcode_checkdigit(stripped_barcode).toString() == last_digit; + } + + barcode_checkdigit = function(bc) { + var reverse_barcode = bc.toString().split('').reverse(); + var check_sum = 0; var multiplier = 2; + for (var i = 0; i < reverse_barcode.length; i++) { + var digit = reverse_barcode[i]; + var product = digit * multiplier; product = product.toString(); + var temp_sum = 0; + for (var j = 0; j < product.length; j++) { + temp_sum += Number( product[j] ); + } + check_sum += Number( temp_sum ); + multiplier = ( multiplier == 2 ? 1 : 2 ); + } + check_sum = check_sum.toString(); + var next_multiple_of_10 = (check_sum.match(/(\d*)\d$/)[1] * 10) + 10; + var check_digit = next_multiple_of_10 - Number(check_sum); + if (check_digit == 10) check_digit = 0; + return check_digit; + } + + } +]) + +// dummy service so standalone patron editor can reference it +.factory('patronSvc', function() { return { /* dummy */ } }) + +.factory('patronRegSvc', ['$q', 'egCore', 'egLovefield', function($q, egCore, egLovefield) { + + egLovefield.isOffline = true; + + var service = { + org : null, // will come from workstation org + field_doc : {}, // config.idl_field_doc + profiles : [], // permission groups + edit_profiles : [], // perm groups we can modify + sms_carriers : [], + user_settings : {}, // applied user settings + user_setting_types : {}, // config.usr_setting_type + opt_in_setting_types : {}, // config.usr_setting_type for event-def opt-in + surveys : [], + survey_questions : {}, + survey_answers : {}, + survey_responses : {}, // survey.responses for loaded patron in progress + stat_cats : [], + stat_cat_entry_maps : {}, // cat.id to selected value + virt_id : -1, // virtual ID for new objects + init_done : false // have we loaded our initialization data? + }; + + service.offlineMode = function () { + return lf.isOffline; + } + + // launch a series of parallel data retrieval calls + service.init = function(scope) { + + // Data loaded here only needs to be retrieved the first time this + // tab becomes active within the current instance of the patron app. + // In other words, navigating between patron tabs will not cause + // all of this data to be reloaded. Navigating to a separate app + // and returning will cause the data to be reloaded. + if (service.init_done) return $q.when(); + service.init_done = true; + + return $q.all([ + service.get_field_doc(), + service.get_perm_groups(), + service.get_ident_types(), + service.get_user_settings(), + service.get_org_settings(), + service.get_stat_cats(), + service.get_surveys(), + service.get_net_access_levels() + ]); + }; + + service.get_linked_addr_users = function(addrs) { + return $q.when(); + } + + service.apply_secondary_groups = function(user_id, group_ids) { + return $q.when(true); + } + + // See note above about not loading egUser. + // TODO: i18n + service.format_name = function(last, first, middle) { + return last + ', ' + first + (middle ? ' ' + middle : ''); + } + + service.check_dupe_username = function(usrname) { + return $q.when(false); + } + + // determine which user groups our user is not allowed to modify + service.set_edit_profiles = function() { + service.edit_profiles = egCore.env.pgt.list.filter( + function (p) { return p.application_perm() == 'group_application.user.patron' } + ); + return $q.when; + } + + // resolves to a hash of perm-name => boolean value indicating + // wether the user has the permission at org_id. + service.has_perms_for_org = function(org_id) { + + var perms_needed = [ + 'UPDATE_USER', + 'CREATE_USER', + 'CREATE_USER_GROUP_LINK', + 'UPDATE_PATRON_COLLECTIONS_EXEMPT', + 'UPDATE_PATRON_CLAIM_RETURN_COUNT', + 'UPDATE_PATRON_CLAIM_NEVER_CHECKED_OUT_COUNT', + 'UPDATE_PATRON_ACTIVE_CARD', + 'UPDATE_PATRON_PRIMARY_CARD' + ]; + + var hash = {}; + angular.forEach(perms_needed, function (p) { + hash[p] = true; + }); + + return $q.when(hash); + } + + service.get_surveys = function() { + return egLovefield.reconstituteList('asv').then(function(offline) { + return egLovefield.reconstituteList('asvq') + .then(function(){ + return egLovefield.reconstituteList('asva'); + }).then(function() { + angular.forEach(egCore.env.asv.list, function (s) { + s.questions( egCore.env.asva.list.filter( function (a) { + return q.survey().id == s.id(); + })); + }); + + angular.forEach(egCore.env.asvq.list, function (q) { + q.survey( egCore.env.asv.map[ q.survey().id ] ); + q.answers( egCore.env.asva.list.filter( function (a) { + return q.id() == a.question(); + })); + }); + + angular.forEach(egCore.env.asva.list, function (a) { + a.question( egCore.env.asvq.map[ a.question().id ] ); + }); + + service.surveys = egCore.env.asv.list; + service.survey_questions = egCore.env.asvq.list; + service.survey_answers = egCore.env.asva.list; + + return $q.when(); + }); + }); + } + + service.get_stat_cats = function() { + return egLovefield.getStatCatsCache().then( + function(cats) { + service.stat_cats = cats; + return $q.when(); + } + ); + }; + + service.get_org_settings = function() { + return egLovefield.getSettingsCache().then( + function (list) { + var hash = {}; + angular.forEach(list, function (s) { + hash[s.name] = s.value; + }); + service.org_settings = hash; + if (egCore && egCore.env && !egCore.env.aous) { + egCore.env.aous = hash; + console.log('setting egCore.env.aous'); + } + return $q.when(); + } + ); + }; + + service.get_ident_types = function() { + return egLovefield.reconstituteList('cit').then(function() { + service.ident_types = egCore.env.cit.list; + return $q.when(); + }); + }; + + service.get_net_access_levels = function() { + return egLovefield.reconstituteList('cnal').then(function() { + service.net_access_levels = egCore.env.cnal.list; + return $q.when(); + }); + } + + service.get_perm_groups = function() { + if (egCore.env.pgt) { + service.profiles = egCore.env.pgt.list; + return service.set_edit_profiles(); + } else { + return egLovefield.reconstituteTree('pgt').then(function(offline) { + service.profiles = egCore.env.pgt.list; + return service.set_edit_profiles(); + }); + } + } + + service.get_field_doc = function() { + return egLovefield.getListFromOfflineCache('fdoc').then(function (list) { + angular.forEach(list, function(doc) { + service.field_doc[doc.fm_class()][doc.field()] = doc; + }); + return $q.when(); + }); + }; + + service.get_user_settings = function() { + var static_types = [ + 'circ.holds_behind_desk', + 'circ.collections.exempt', + 'opac.hold_notify', + 'opac.default_phone', + 'opac.default_pickup_location', + 'opac.default_sms_carrier', + 'opac.default_sms_notify']; + + angular.forEach(static_types, function (t) { + service.user_settings[t] = null; + }); + + return egLovefield.getListFromOfflineCache('cust').then(function (list) { + angular.forEach(list, function(stype) { + service.user_setting_types[stype.name()] = stype; + if (static_types.indexOf(stype.name()) == -1) { + service.opt_in_setting_types[stype.name()] = stype; + } + if (stype.reg_default() != undefined) { + service.user_settings[setting.name()] = + setting.reg_default(); + } + }); + return $q.when(); + }); + } + + service.invalidate_field = function(patron, field) { + return; + } + + service.dupe_patron_search = function(patron, type, value) { + return $q.when({ search : search, count : 0 }); + } + + service.init_patron = function(current) { + + if (!current) + return service.init_new_patron(); + + service.patron = current; + return service.init_existing_patron(current) + } + + service.ingest_address = function(patron, addr) { + addr.valid = addr.valid == 't'; + addr.within_city_limits = addr.within_city_limits == 't'; + addr._is_mailing = (patron.mailing_address && + addr.id == patron.mailing_address.id); + addr._is_billing = (patron.billing_address && + addr.id == patron.billing_address.id); + } + + /* + * Existing patron objects reqire some data munging before insertion + * into the scope. + * + * 1. Turn everything into a hash + * 2. ... Except certain fields (selectors) whose widgets require objects + * 3. Bools must be Boolean, not t/f. + */ + service.init_existing_patron = function(current) { + + service.existing_patron = current; + + var patron = egCore.idl.toHash(current); + + patron.home_ou = egCore.org.get(patron.home_ou.id); + patron.expire_date = new Date(Date.parse(patron.expire_date)); + patron.dob = service.parse_dob(patron.dob); + patron.profile = current.profile(); // pre-hash version + patron.net_access_level = current.net_access_level(); + patron.ident_type = current.ident_type(); + patron.groups = current.groups(); // pre-hash + + angular.forEach( + ['juvenile', 'barred', 'active', 'master_account'], + function(field) { patron[field] = patron[field] == 't'; } + ); + + angular.forEach(patron.cards, function(card) { + card.active = card.active == 't'; + if (card.id == patron.card.id) { + patron.card = card; + card._primary = 'on'; + } + }); + + angular.forEach(patron.addresses, + function(addr) { service.ingest_address(patron, addr) }); + + service.get_linked_addr_users(patron.addresses); + + // Remove stat cat entries that link to out-of-scope stat + // cats. With this, we avoid unnecessarily updating (or worse, + // modifying) stat cat values that are not ours to modify. + patron.stat_cat_entries = patron.stat_cat_entries.filter( + function(map) { + return Boolean( + // service.stat_cats only contains in-scope stat cats. + service.stat_cats.filter(function(cat) { + return (cat.id() == map.stat_cat.id) })[0] + ); + } + ); + + // toss entries for existing stat cat maps into our living + // stat cat entry map, which is modified within the template. + angular.forEach(patron.stat_cat_entries, function(map) { + service.stat_cat_entry_maps[map.stat_cat.id] = map.stat_cat_entry; + }); + + return patron; + } + + service.init_new_patron = function() { + var addr = { + id : service.virt_id--, + isnew : true, + valid : true, + address_type : egCore.strings.REG_ADDR_TYPE, + _is_mailing : true, + _is_billing : true, + within_city_limits : false, + country : service.org_settings['ui.patron.default_country'], + }; + + var card = { + id : service.virt_id--, + isnew : true, + active : true, + _primary : 'on' + }; + + var home_ou = egCore.org.get(service.org); + + var user = { + isnew : true, + active : true, + card : card, + cards : [card], + home_ou : home_ou, + stat_cat_entries : [], + groups : [], + addresses : [addr] + }; + + if (service.clone_user) + service.copy_clone_data(user); + + if (service.stage_user) + service.copy_stage_data(user); + + return user; + } + + // dob is always YYYY-MM-DD + // Dates of birth do not contain timezone info, which can lead to + // inconcistent timezone handling, potentially representing + // different points in time, depending on the implementation. + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse + // See "Differences in assumed time zone" + // TODO: move this into egDate ? + service.parse_dob = function(dob) { + if (!dob) return null; + var parts = dob.split('-'); + var d = new Date(); // always local time zone, yay. + d.setFullYear(parts[0]); + d.setMonth(parts[1] - 1); + d.setDate(parts[2]); + return d; + } + + service.copy_stage_data = function(user) { + var cuser = service.stage_user; + + // copy the data into our new user object + + for (var key in egCore.idl.classes.stgu.field_map) { + if (egCore.idl.classes.au.field_map[key] && + !egCore.idl.classes.stgu.field_map[key].virtual) { + if (cuser.user[key]() !== null) + user[key] = cuser.user[key](); + } + } + + if (user.home_ou) user.home_ou = egCore.org.get(user.home_ou); + if (user.profile) user.profile = egCore.env.pgt.map[user.profile]; + if (user.ident_type) + user.ident_type = egCore.env.cit.map[user.ident_type]; + user.dob = service.parse_dob(user.dob); + + // Clear the usrname if it looks like a UUID + if (user.usrname.replace(/-/g,'').match(/[0-9a-f]{32}/)) + user.usrname = ''; + + // Don't use stub address if we have one from the staged user. + if (cuser.mailing_addresses.length || cuser.billing_addresses.length) + user.addresses = []; + + // is_mailing=false implies is_billing + function addr_from_stage(stage_addr) { + if (!stage_addr) return; + var cls = stage_addr.classname; + + var addr = { + id : service.virt_id--, + usr : user.id, + isnew : true, + valid : true, + _is_mailing : cls == 'stgma', + _is_billing : cls == 'stgba' + }; + + user.mailing_address = addr; + user.addresses.push(addr); + + for (var key in egCore.idl.classes[cls].field_map) { + if (egCore.idl.classes.aua.field_map[key] && + !egCore.idl.classes[cls].field_map[key].virtual) { + if (stage_addr[key]() !== null) + addr[key] = stage_addr[key](); + } + } + } + + addr_from_stage(cuser.mailing_addresses[0]); + addr_from_stage(cuser.billing_addresses[0]); + + if (user.addresses.length == 1) { + // If there is only one address, + // use it as both mailing and billing. + var addr = user.addresses[0]; + addr._is_mailing = addr._is_billing = true; + user.mailing_address = user.billing_address = addr; + } + + if (cuser.cards.length) { + user.card = { + id : service.virt_id--, + barcode : cuser.cards[0].barcode(), + isnew : true, + active : true, + _primary : 'on' + }; + + user.cards.push(user.card); + if (user.usrname == '') + user.usrname = card.barcode; + } + + angular.forEach(cuser.settings, function(setting) { + service.user_settings[setting.setting()] = Boolean(setting.value()); + }); + } + + // copy select values from the cloned user to the new user. + // user is a hash + service.copy_clone_data = function(user) { + var clone_user = service.clone_user; + + // flesh the home org locally + user.home_ou = egCore.org.get(clone_user.home_ou()); + if (user.profile) user.profile = egCore.env.pgt.map[user.profile]; + + if (!clone_user.billing_address() && + !clone_user.mailing_address()) + return; // no addresses to copy or link + + // if the cloned user has any addresses, we don't need + // the stub address created in init_new_patron. + user.addresses = []; + + var copy_addresses = + service.org_settings['circ.patron_edit.clone.copy_address']; + + var clone_fields = [ + 'day_phone', + 'evening_phone', + 'other_phone', + 'usrgroup' + ]; + + angular.forEach(clone_fields, function(field) { + user[field] = clone_user[field](); + }); + + if (copy_addresses) { + var bill_addr, mail_addr; + + // copy the billing and mailing addresses into new addresses + function clone_addr(addr) { + var new_addr = egCore.idl.toHash(addr); + new_addr.id = service.virt_id--; + new_addr.usr = user.id; + new_addr.isnew = true; + new_addr.valid = true; + user.addresses.push(new_addr); + return new_addr; + } + + if (bill_addr = clone_user.billing_address()) { + var addr = clone_addr(bill_addr); + addr._is_billing = true; + user.billing_address = addr; + } + + if (mail_addr = clone_user.mailing_address()) { + + if (bill_addr && bill_addr.id() == mail_addr.id()) { + user.mailing_address = user.billing_address; + user.mailing_address._is_mailing = true; + } else { + var addr = clone_addr(mail_addr); + addr._is_mailing = true; + user.mailing_address = addr; + } + + if (!bill_addr) { + // if there is no billing addr, use the mailing addr + user.billing_address = user.mailing_address; + user.billing_address._is_billing = true; + } + } + + + } else { + + // link the billing and mailing addresses + var addr; + if (addr = clone_user.billing_address()) { + user.billing_address = egCore.idl.toHash(addr); + user.billing_address._is_billing = true; + user.addresses.push(user.billing_address); + user.billing_address._linked_owner_id = clone_user.id(); + user.billing_address._linked_owner = service.format_name( + clone_user.family_name(), + clone_user.first_given_name(), + clone_user.second_given_name() + ); + } + + if (addr = clone_user.mailing_address()) { + if (user.billing_address && + addr.id() == user.billing_address.id) { + // mailing matches billing + user.mailing_address = user.billing_address; + user.mailing_address._is_mailing = true; + } else { + user.mailing_address = egCore.idl.toHash(addr); + user.mailing_address._is_mailing = true; + user.addresses.push(user.mailing_address); + user.mailing_address._linked_owner_id = clone_user.id(); + user.mailing_address._linked_owner = service.format_name( + clone_user.family_name(), + clone_user.first_given_name(), + clone_user.second_given_name() + ); + } + } + } + } + + // translate the patron back into IDL form + service.save_user = function(phash) { + + var patron = egCore.idl.fromHash('au', phash); + + patron.home_ou(patron.home_ou().id()); + patron.expire_date(patron.expire_date().toISOString()); + patron.profile(patron.profile().id()); + if (patron.dob()) + patron.dob(patron.dob().toISOString().replace(/T.*/,'')); + if (patron.ident_type()) + patron.ident_type(patron.ident_type().id()); + if (patron.net_access_level()) + patron.net_access_level(patron.net_access_level().id()); + + angular.forEach( + ['juvenile', 'barred', 'active', 'master_account'], + function(field) { patron[field](phash[field] ? 't' : 'f'); } + ); + + var card_hashes = patron.cards(); + patron.cards([]); + angular.forEach(card_hashes, function(chash) { + var card = egCore.idl.fromHash('ac', chash) + card.usr(patron.id()); + card.active(chash.active ? 't' : 'f'); + patron.cards().push(card); + if (chash._primary) { + patron.card(card); + } + }); + + var addr_hashes = patron.addresses(); + patron.addresses([]); + angular.forEach(addr_hashes, function(addr_hash) { + if (!addr_hash.isnew && !addr_hash.isdeleted) + addr_hash.ischanged = true; + var addr = egCore.idl.fromHash('aua', addr_hash); + patron.addresses().push(addr); + addr.valid(addr.valid() ? 't' : 'f'); + addr.within_city_limits(addr.within_city_limits() ? 't' : 'f'); + if (addr_hash._is_mailing) patron.mailing_address(addr); + if (addr_hash._is_billing) patron.billing_address(addr); + }); + + patron.survey_responses([]); + angular.forEach(service.survey_responses, function(answer) { + var question = service.survey_questions[answer.question()]; + var resp = new egCore.idl.asvr(); + resp.isnew(true); + resp.survey(question.survey()); + resp.question(question.id()); + resp.answer(answer.id()); + resp.usr(patron.id()); + resp.answer_date('now'); + patron.survey_responses().push(resp); + }); + + // re-object-ify the patron stat cat entry maps + var maps = []; + angular.forEach(patron.stat_cat_entries(), function(entry) { + var e = egCore.idl.fromHash('actscecm', entry); + e.stat_cat(e.stat_cat().id); + maps.push(e); + }); + patron.stat_cat_entries(maps); + + // service.stat_cat_entry_maps maps stats to values + // patron.stat_cat_entries is an array of stat_cat_entry_usr_map's + angular.forEach( + service.stat_cat_entry_maps, function(value, cat_id) { + + // see if we already have a mapping for this entry + var existing = patron.stat_cat_entries().filter( + function(e) { return e.stat_cat() == cat_id })[0]; + + if (existing) { // we have a mapping + // if the existing mapping matches the new one, + // there' nothing left to do + if (existing.stat_cat_entry() == value) return; + + // mappings differ. delete the old one and create + // a new one below. + existing.isdeleted(true); + } + + var newmap = new egCore.idl.actscecm(); + newmap.target_usr(patron.id()); + newmap.isnew(true); + newmap.stat_cat(cat_id); + newmap.stat_cat_entry(value); + patron.stat_cat_entries().push(newmap); + }); + + if (!patron.isnew()) patron.ischanged(true); + + return egLovefield.addOfflineXact({ + user : egCore.idl.toHash(patron), + timestamp : parseInt(new Date().getTime() / 1000), + type : 'register', + delta : 0 + }).then(function (success) { + if (success) return patron; + }); + } + + service.remove_staged_user = function() { + if (!service.stage_user) return $q.when(); + return egCore.net.request( + 'open-ils.actor', + 'open-ils.actor.user.stage.delete', + egCore.auth.token(), + service.stage_user.user.row_id() + ); + } + + service.save_user_settings = function(new_user, user_settings) { + return; + } + + // Applies field-specific validation regex's from org settings + // to form fields. Be careful not remove any pattern data we + // are not explicitly over-writing in the provided patterns obj. + service.set_field_patterns = function(patterns) { + if (service.org_settings['opac.username_regex']) { + patterns.au.usrname = + new RegExp(service.org_settings['opac.username_regex']); + } + + if (service.org_settings['opac.barcode_regex']) { + patterns.ac.barcode = + new RegExp(service.org_settings['opac.barcode_regex']); + } + + if (service.org_settings['global.password_regex']) { + patterns.au.passwd = + new RegExp(service.org_settings['global.password_regex']); + } + + var phone_reg = service.org_settings['ui.patron.edit.phone.regex']; + if (phone_reg) { + // apply generic phone regex first, replace below as needed. + patterns.au.day_phone = new RegExp(phone_reg); + patterns.au.evening_phone = new RegExp(phone_reg); + patterns.au.other_phone = new RegExp(phone_reg); + } + + // the remaining patterns fit a well-known key name pattern + + angular.forEach(service.org_settings, function(val, key) { + if (!val) return; + var parts = key.match(/ui.patron.edit\.(\w+)\.(\w+)\.regex/); + if (!parts) return; + var cls = parts[1]; + var name = parts[2]; + patterns[cls][name] = new RegExp(val); + }); + } + + return service; +}]) + +.controller('PatronRegCtrl', + ['$scope','$routeParams','$q','$uibModal','$window','egCore', + 'patronSvc','patronRegSvc','egUnloadPrompt','egAlertDialog', + 'egWorkLog','$timeout','egLovefield','$rootScope', +function($scope , $routeParams , $q , $uibModal , $window , egCore , + patronSvc , patronRegSvc , egUnloadPrompt, egAlertDialog , + egWorkLog , $timeout , egLovefield , $rootScope) { + + $scope.rs = $rootScope; + if ($scope.workstation_obj) patronRegSvc.org = $scope.workstation_obj.owning_lib; + $scope.offline = true; + + $scope.page_data_loaded = false; + $scope.clone_id = patronRegSvc.clone_id = $routeParams.clone_id; + $scope.stage_username = + patronRegSvc.stage_username = $routeParams.stage_username; + $scope.patron_id = + patronRegSvc.patron_id = $routeParams.edit_id || $routeParams.id; + + // for existing patrons, disable barcode input by default + $scope.disable_bc = $scope.focus_usrname = Boolean($scope.patron_id); + $scope.focus_bc = !Boolean($scope.patron_id); + $scope.address_alerts = []; + $scope.dupe_counts = {}; + + // map of perm name to true/false for perms the logged in user + // has at the currently selected patron home org unit. + $scope.perms = {}; + + $scope.edit_passthru = {}; + + // 0=all, 1=suggested, 2=all + $scope.edit_passthru.vis_level = 2; + + // Apply default values for new patrons during initial registration + // prs is shorthand for patronSvc + function set_new_patron_defaults(prs) { + if (!$scope.patron.passwd) { + // passsword may originate from staged user. + $scope.generate_password(); + } + $scope.hold_notify_phone = true; + $scope.hold_notify_email = true; + + // staged users may be loaded w/ a profile. + $scope.set_expire_date(); + + if (prs.org_settings['ui.patron.default_ident_type']) { + // $scope.patron needs this field to be an object + var id = prs.org_settings['ui.patron.default_ident_type']; + var ident_type = $scope.ident_types.filter( + function(type) { return type.id() == id })[0]; + $scope.patron.ident_type = ident_type; + } + if (prs.org_settings['ui.patron.default_inet_access_level']) { + // $scope.patron needs this field to be an object + var id = prs.org_settings['ui.patron.default_inet_access_level']; + var level = $scope.net_access_levels.filter( + function(lvl) { return lvl.id() == id })[0]; + $scope.patron.net_access_level = level; + } + if (prs.org_settings['ui.patron.default_country']) { + $scope.patron.addresses[0].country = + prs.org_settings['ui.patron.default_country']; + } + } + + // A null or undefined pattern leads to exceptions. Before the + // patterns are loaded from the server, default all patterns + // to an innocuous regex. To avoid re-creating numerous + // RegExp objects, cache the stub RegExp after initial creation. + // note: angular docs say ng-pattern accepts a regexp or string, + // but as of writing, it only works with a regexp object. + // (Likely an angular 1.2 vs. 1.4 issue). + var field_patterns = {au : {}, ac : {}, aua : {}}; + $scope.field_pattern = function(cls, field) { + if (!field_patterns[cls][field]) + field_patterns[cls][field] = new RegExp('.*'); + return field_patterns[cls][field]; + } + + patronRegSvc.offlineMode($scope.offline); // force offline if ng-init'd to do so + patronRegSvc.init().then(function() { + // called after initTab and patronRegSvc.init have completed + + var prs = patronRegSvc; // brevity + // in standalone mode, we have no patronSvc + $scope.patron = prs.init_patron(patronSvc ? patronSvc.current : null); + $scope.field_doc = prs.field_doc; + $scope.edit_profiles = prs.edit_profiles; + $scope.ident_types = prs.ident_types; + $scope.net_access_levels = prs.net_access_levels; + $scope.user_setting_types = prs.user_setting_types; + $scope.opt_in_setting_types = prs.opt_in_setting_types; + $scope.org_settings = prs.org_settings; + $scope.sms_carriers = prs.sms_carriers; + $scope.stat_cats = prs.stat_cats; + $scope.surveys = prs.surveys; + $scope.survey_responses = prs.survey_responses; + $scope.stat_cat_entry_maps = prs.stat_cat_entry_maps; + $scope.stage_user = prs.stage_user; + $scope.stage_user_requestor = prs.stage_user_requestor; + + $scope.user_settings = prs.user_settings; + // clone the user settings back into the patronRegSvc so + // we have a copy of the original state of the settings. + prs.user_settings = {}; + angular.forEach($scope.user_settings, function(val, key) { + prs.user_settings[key] = val; + }); + + extract_hold_notify(); + $scope.handle_home_org_changed(); + + if ($scope.org_settings['ui.patron.edit.default_suggested']) + $scope.edit_passthru.vis_level = 1; + + if ($scope.patron.isnew) + set_new_patron_defaults(prs); + + $scope.page_data_loaded = true; + + prs.set_field_patterns(field_patterns); + apply_username_regex(); + }); + + // update the currently displayed field documentation + $scope.set_selected_field_doc = function(cls, field) { + $scope.selected_field_doc = $scope.field_doc[cls][field]; + } + + // returns the tree depth of the selected profile group tree node. + $scope.pgt_depth = function(grp) { + var d = 0; + while (grp = egCore.env.pgt.map[grp.parent()]) d++; + return d; + } + + // IDL fields used for labels in the UI. + $scope.idl_fields = { + au : egCore.idl.classes.au.field_map, + ac : egCore.idl.classes.ac.field_map, + aua : egCore.idl.classes.aua.field_map + }; + + // field visibility cache. Some fields are universally required. + // 3 == value universally required + // 2 == field is visible by default + // 1 == field is suggested by default + var field_visibility = {}; + var default_field_visibility = { + 'ac.barcode' : 3, + 'au.usrname' : 3, + 'au.passwd' : 3, + 'au.first_given_name' : 3, + 'au.family_name' : 3, + 'au.ident_type' : 3, + 'au.home_ou' : 3, + 'au.profile' : 3, + 'au.expire_date' : 3, + 'au.net_access_level' : 3, + 'aua.address_type' : 3, + 'aua.post_code' : 3, + 'aua.street1' : 3, + 'aua.street2' : 2, + 'aua.city' : 3, + 'aua.county' : 2, + 'aua.state' : 2, + 'aua.country' : 3, + 'aua.valid' : 2, + 'aua.within_city_limits' : 2, + 'stat_cats' : 1, + 'surveys' : 1 + }; + + // Returns true if the selected field should be visible + // given the current required/suggested/all setting. + // The visibility flag applied to each field as a result of calling + // this function also sets (via the same flag) the requiredness state. + $scope.show_field = function(field_key) { + // org settings have not been received yet. + if (!$scope.org_settings) return false; + + if (field_visibility[field_key] == undefined) { + // compile and cache the visibility for the selected field + + var req_set = 'ui.patron.edit.' + field_key + '.require'; + var sho_set = 'ui.patron.edit.' + field_key + '.show'; + var sug_set = 'ui.patron.edit.' + field_key + '.suggest'; + + if ($scope.org_settings[req_set]) { + field_visibility[field_key] = 3; + + } else if ($scope.org_settings[sho_set]) { + field_visibility[field_key] = 2; + + } else if ($scope.org_settings[sug_set]) { + field_visibility[field_key] = 1; + } + } + + if (field_visibility[field_key] == undefined) { + // No org settings were applied above. Use the default + // settings if present or assume the field has no + // visibility flags applied. + field_visibility[field_key] = + default_field_visibility[field_key] || 0; + } + + return field_visibility[field_key] >= $scope.edit_passthru.vis_level; + } + + // See $scope.show_field(). + // A field with visbility level 3 means it's required. + $scope.field_required = function(cls, field) { + + // Value in the password field is not required + // for existing patrons. + if (field == 'passwd' && $scope.patron && !$scope.patron.isnew) + return false; + + return (field_visibility[cls + '.' + field] == 3 || default_field_visibility[cls + '.' + field] == 3); + } + + // generates a random 4-digit password + $scope.generate_password = function() { + $scope.patron.passwd = Math.floor(Math.random()*9000) + 1000; + } + + $scope.set_expire_date = function() { + if (!$scope.patron.profile) return; + var seconds = egCore.date.intervalToSeconds( + $scope.patron.profile.perm_interval()); + var now_epoch = new Date().getTime(); + $scope.patron.expire_date = new Date( + now_epoch + (seconds * 1000 /* milliseconds */)) + } + + // grp is the pgt object + $scope.set_profile = function(grp) { + $scope.patron.profile = grp; + $scope.set_expire_date(); + $scope.field_modified(); + } + + $scope.invalid_profile = function() { + return !( + $scope.patron && + $scope.patron.profile && + $scope.patron.profile.usergroup() == 't' + ); + } + + $scope.new_address = function() { + var addr = egCore.idl.toHash(new egCore.idl.aua()); + patronRegSvc.ingest_address($scope.patron, addr); + addr.id = patronRegSvc.virt_id--; + addr.isnew = true; + addr.valid = true; + addr.within_city_limits = true; + addr.country = $scope.org_settings['ui.patron.default_country']; + $scope.patron.addresses.push(addr); + } + + // keep deleted addresses out of the patron object so + // they won't appear in the UI. They'll be re-inserted + // when the patron is updated. + deleted_addresses = []; + $scope.delete_address = function(id) { + + if ($scope.patron.isnew && + $scope.patron.addresses.length == 1 && + $scope.org_settings['ui.patron.registration.require_address']) { + egAlertDialog.open(egCore.strings.REG_ADDR_REQUIRED); + return; + } + + var addresses = []; + angular.forEach($scope.patron.addresses, function(addr) { + if (addr.id == id) { + if (id > 0) { + addr.isdeleted = true; + deleted_addresses.push(addr); + } + } else { + addresses.push(addr); + } + }); + $scope.patron.addresses = addresses; + } + + $scope.post_code_changed = function(addr) { + if ($scope.offline) return; + egCore.net.request( + 'open-ils.search', 'open-ils.search.zip', addr.post_code) + .then(function(resp) { + if (!resp) return; + if (resp.city) addr.city = resp.city; + if (resp.state) addr.state = resp.state; + if (resp.county) addr.county = resp.county; + if (resp.alert) alert(resp.alert); + }); + } + + $scope.replace_card = function() { + $scope.patron.card.active = false; + $scope.patron.card.ischanged = true; + $scope.disable_bc = false; + + var new_card = egCore.idl.toHash(new egCore.idl.ac()); + new_card.id = patronRegSvc.virt_id--; + new_card.isnew = true; + new_card.active = true; + new_card._primary = 'on'; + $scope.patron.card = new_card; + $scope.patron.cards.push(new_card); + } + + $scope.day_phone_changed = function(phone) { + if (phone && $scope.patron.isnew && + $scope.org_settings['patron.password.use_phone']) { + $scope.patron.passwd = phone.substr(-4); + } + } + + $scope.barcode_changed = function(bc) { + if (!bc) return; + if (!$scope.patron.usrname) + $scope.patron.usrname = bc; + } + + $scope.cards_dialog = function() { + $uibModal.open({ + templateUrl: './circ/patron/t_patron_cards_dialog', + controller: + ['$scope','$uibModalInstance','cards','perms', + function($scope , $uibModalInstance , cards , perms) { + // scope here is the modal-level scope + $scope.args = {cards : cards}; + $scope.perms = perms; + $scope.ok = function() { $uibModalInstance.close($scope.args) } + $scope.cancel = function () { $uibModalInstance.dismiss() } + }], + resolve : { + cards : function() { + // scope here is the controller-level scope + return $scope.patron.cards; + }, + perms : function() { + return $scope.perms; + } + } + }).result.then( + function(args) { + angular.forEach(args.cards, function(card) { + card.ischanged = true; // assume cards need updating, OK? + if (card._primary == 'on' && + card.id != $scope.patron.card.id) { + $scope.patron.card = card; + } + }); + } + ); + } + + $scope.set_addr_type = function(addr, type) { + var addrs = $scope.patron.addresses; + if (addr['_is_'+type]) { + angular.forEach(addrs, function(a) { + if (a.id != addr.id) a['_is_'+type] = false; + }); + } else { + // unchecking mailing/billing means we have to randomly + // select another address to fill that role. Select the + // first address in the list (that does not match the + // modifed address) + for (var i = 0; i < addrs.length; i++) { + if (addrs[i].id != addr.id) { + addrs[i]['_is_' + type] = true; + break; + } + } + } + } + + + // Translate hold notify preferences from the form/scope back into a + // single user setting value for opac.hold_notify. + function compress_hold_notify() { + var hold_notify = ''; + var splitter = ''; + if ($scope.hold_notify_phone) { + hold_notify = 'phone'; + splitter = ':'; + } + if ($scope.hold_notify_email) { + hold_notify = splitter + 'email'; + splitter = ':'; + } + if ($scope.hold_notify_sms) { + hold_notify = splitter + 'sms'; + splitter = ':'; + } + $scope.user_settings['opac.hold_notify'] = hold_notify; + } + + // dialog for selecting additional permission groups + $scope.secondary_groups_dialog = function() { + $uibModal.open({ + templateUrl: './circ/patron/t_patron_groups_dialog', + controller: + ['$scope','$uibModalInstance','linked_groups','pgt_depth', + function($scope , $uibModalInstance , linked_groups , pgt_depth) { + + $scope.pgt_depth = pgt_depth; + $scope.args = { + linked_groups : linked_groups, + edit_profiles : patronRegSvc.edit_profiles, + new_profile : patronRegSvc.edit_profiles[0] + }; + + // add a new group to the linked groups list + $scope.link_group = function($event, grp) { + var found = false; // avoid duplicates + angular.forEach($scope.args.linked_groups, + function(g) {if (g.id() == grp.id()) found = true}); + if (!found) $scope.args.linked_groups.push(grp); + $event.preventDefault(); // avoid close + } + + // remove a group from the linked groups list + $scope.unlink_group = function($event, grp) { + $scope.args.linked_groups = + $scope.args.linked_groups.filter(function(g) { + return g.id() != grp.id() + }); + $event.preventDefault(); // avoid close + } + + $scope.ok = function() { $uibModalInstance.close($scope.args) } + $scope.cancel = function () { $uibModalInstance.dismiss() } + }], + resolve : { + linked_groups : function() { return $scope.patron.groups }, + pgt_depth : function() { return $scope.pgt_depth } + } + }).result.then( + function(args) { + + if ($scope.patron.isnew) { + // groups must be linked for new patrons after the + // patron is created. + $scope.patron.groups = args.linked_groups; + return; + } + + // update links groups for existing users in real time. + var ids = args.linked_groups.map(function(g) {return g.id()}); + patronRegSvc.apply_secondary_groups($scope.patron.id, ids) + .then(function(success) { + if (success) + $scope.patron.groups = args.linked_groups; + }); + } + ); + } + + function extract_hold_notify() { + notify = $scope.user_settings['opac.hold_notify']; + if (!notify) return; + $scope.hold_notify_phone = Boolean(notify.match(/phone/)); + $scope.hold_notify_email = Boolean(notify.match(/email/)); + $scope.hold_notify_sms = Boolean(notify.match(/sms/)); + } + + $scope.invalidate_field = function(field) { + patronRegSvc.invalidate_field($scope.patron, field); + } + + address_alert = function(addr) { + if ($scope.offline) return; + var args = { + street1: addr.street1, + street2: addr.street2, + city: addr.city, + state: addr.state, + county: addr.county, + country: addr.country, + post_code: addr.post_code, + mailing_address: addr._is_mailing, + billing_address: addr._is_billing + } + + egCore.net.request( + 'open-ils.actor', + 'open-ils.actor.address_alert.test', + egCore.auth.token(), egCore.auth.user().ws_ou(), args + ).then(function(res) { + $scope.address_alerts = res; + }); + } + + $scope.dupe_value_changed = function(type, value) { + $scope.dupe_counts[type] = 0; + patronRegSvc.dupe_patron_search($scope.patron, type, value) + .then(function(res) { + $scope.dupe_counts[type] = res.count; + if (res.count) { + $scope.dupe_search_encoded = + encodeURIComponent(js2JSON(res.search)); + } else { + $scope.dupe_search_encoded = ''; + } + }); + } + + // Dummy function in offline mode + $scope.handle_home_org_changed = function() {} + + // This is called with every character typed in a form field, + // since that's the only way to gaurantee something has changed. + // See handle_field_changed for ng-change vs. ng-blur. + $scope.field_modified = function() { + // Call attach with every field change, regardless of whether + // it's been called before. This will allow for re-attach after + // the user clicks through the unload warning. egUnloadPrompt + // will ensure we only attach once. + egUnloadPrompt.attach($rootScope); + } + + // also monitor when form is changed *by the user*, as using + // an ng-change handler doesn't work with eg-date-input + $scope.$watch('reg_form.$pristine', function(newVal, oldVal) { + if (!newVal) egUnloadPrompt.attach($rootScope); + }); + + // username regex (if present) must be removed any time + // the username matches the barcode to avoid firing the + // invalid field handlers. + function apply_username_regex() { + var regex = $scope.org_settings['opac.username_regex']; + if (regex) { + if ($scope.patron.card.barcode) { + // username must match the regex or the barcode + field_patterns.au.usrname = + new RegExp( + regex + '|^' + $scope.patron.card.barcode + '$'); + } else { + // username must match the regex + field_patterns.au.usrname = new RegExp(regex); + } + } else { + // username can be any format. + field_patterns.au.usrname = new RegExp('.*'); + } + } + + // obj could be the patron, an address, etc. + // This is called any time a form field achieves then loses focus. + // It does not necessarily mean the field has changed. + // The alternative is ng-change, but it's called with each character + // typed, which would be overkill for many of the actions called here. + $scope.handle_field_changed = function(obj, field_name) { + if (!obj) return; + + var cls = obj.classname; // set by egIdl + var value = obj[field_name]; + + // Hush! + //console.log('changing field ' + field_name + ' to ' + value); + + switch (field_name) { + case 'day_phone' : + if ($scope.patron.day_phone && + $scope.patron.isnew && + $scope.org_settings['patron.password.use_phone']) { + $scope.patron.passwd = phone.substr(-4); + } + break; + + case 'barcode': + apply_username_regex(); + $scope.barcode_changed(value); + break; + + case 'dob': + maintain_juvenile_flag(); + break; + + default: + break; + } + } + + // patron.juvenile is set to true if the user was born after + function maintain_juvenile_flag() { + if ( !($scope.patron && $scope.patron.dob) ) return; + + var juv_interval = + $scope.org_settings['global.juvenile_age_threshold'] + || '18 years'; + + var base = new Date(); + + base.setTime(base.getTime() - + Number(egCore.date.intervalToSeconds(juv_interval) + '000')); + + $scope.patron.juvenile = ($scope.patron.dob > base); + } + + // returns true (disable) for orgs that cannot have users. + $scope.disable_home_org = function(org_id) { + if (!org_id) return; + var org = egCore.org.get(org_id); + return ( + org && + org.ou_type() && + org.ou_type().can_have_users() == 'f' + ); + } + + // Returns true if the Save and Save & Clone buttons should be disabled. + $scope.edit_passthru.hide_save_actions = function() { + return false; + } + + // Returns true if any input elements are tagged as invalid + // via Angular patterns or required attributes. + function form_has_invalid_fields() { + return $('#patron-reg-container .ng-invalid').length > 0; + } + + function form_is_incomplete() { + return ( + $scope.dupe_username || + $scope.dupe_barcode || + form_has_invalid_fields() + ); + + } + + $scope.edit_passthru.save = function(save_args) { + if (!save_args) save_args = {}; + + if (form_is_incomplete()) { + // User has not provided valid values for all required fields. + return egAlertDialog.open(egCore.strings.REG_INVALID_FIELDS); + } + + // remove page unload warning prompt + egUnloadPrompt.clear(); + + // toss the deleted addresses back into the patron's list of + // addresses so it's included in the update + $scope.patron.addresses = + $scope.patron.addresses.concat(deleted_addresses); + + compress_hold_notify(); + + var updated_user; + + patronRegSvc.save_user($scope.patron) + .then($scope.rs.save_offline_xacts) + .then(function(new_user) { + // reload the current page + $window.location.href = location.href; + }); + } +}]) diff --git a/Open-ILS/web/js/ui/default/staff/package.json b/Open-ILS/web/js/ui/default/staff/package.json index cd3a9bb41a..d972491e5f 100644 --- a/Open-ILS/web/js/ui/default/staff/package.json +++ b/Open-ILS/web/js/ui/default/staff/package.json @@ -15,6 +15,7 @@ "angular-mocks": "~1.5.0", "angular-route": "~1.5.0", "angular-tree-control": "~0.2.28", + "angular-tablesort": "^1.4.1", "angular-order-object-by": "rxfork/ngOrderObjectBy#npm", "lovefield": "*", "moment": "*", diff --git a/Open-ILS/web/js/ui/default/staff/services/auth.js b/Open-ILS/web/js/ui/default/staff/services/auth.js index 4e9895642f..3ee2296923 100644 --- a/Open-ILS/web/js/ui/default/staff/services/auth.js +++ b/Open-ILS/web/js/ui/default/staff/services/auth.js @@ -61,23 +61,33 @@ function($q , $timeout , $rootScope , $window , $location , egNet , egHatch) { if (token) { - egNet.request( - 'open-ils.auth', - 'open-ils.auth.session.retrieve', token) - - .then(function(user) { - if (user && user.classname) { - // authtoken test succeeded - service.user(user); - service.poll(); - service.check_workstation(deferred); - - } else { - // authtoken test failed - egHatch.clearLoginSessionItems(); - deferred.reject(); - } - }); + if (lf.isOffline && !$location.path().match(/\/session/) ) { + // Just stop here if we're in the offline interface but not on the session tab + $timeout(function(){deferred.resolve()}); + } else if (lf.isOffline && $location.path().match(/\/session/) && !$window.navigator.onLine) { + // Likewise, if we're in the offline interface on the session tab and the network is down. + // The session tab itself will redirect appropriately due to no network. + $timeout(function(){deferred.resolve()}); + } else { + // Otherwise, check the token. This will freeze all other interfaces, which is what we want. + egNet.request( + 'open-ils.auth', + 'open-ils.auth.session.retrieve', token) + + .then(function(user) { + if (user && user.classname) { + // authtoken test succeeded + service.user(user); + service.poll(); + service.check_workstation(deferred); + + } else { + // authtoken test failed + egHatch.clearLoginSessionItems(); + deferred.reject(); + } + }); + } } else { // no authtoken to test diff --git a/Open-ILS/web/js/ui/default/staff/services/env.js b/Open-ILS/web/js/ui/default/staff/services/env.js index ad41fc12d5..4fe755a209 100644 --- a/Open-ILS/web/js/ui/default/staff/services/env.js +++ b/Open-ILS/web/js/ui/default/staff/services/env.js @@ -46,12 +46,20 @@ angular.module('egCoreMod') // env fetcher .factory('egEnv', - ['$q','$window','egAuth','egPCRUD','egIDL', -function($q, $window , egAuth, egPCRUD, egIDL) { + ['$q','$window','$injector','egAuth','egPCRUD','egIDL', +function($q, $window , $injector , egAuth, egPCRUD, egIDL) { var service = { // collection of custom loader functions - loaders : [] + loaders : [], + + // Add class hints to this list when offline does not need them and + // if they cause "Maximum call stack size exceeded" console errors. + // If offline does need a list that causes problems, a custom loader + // will be necessary. + // We'll start with authority-related classes causing problems in the + // staff catalog. + ignoreOffline : ['at','acs','abaafm','aba','acsbf','acsaf'] }; @@ -87,19 +95,25 @@ function($q, $window , egAuth, egPCRUD, egIDL) { /** given a tree-shaped collection, captures the tree and * flattens the tree for absorption. */ - service.absorbTree = function(tree, class_) { + service.absorbTree = function(tree, class_, noOffline) { + if (service[class_] && service[class_].loaded) return; + var list = []; function squash(node) { list.push(node); angular.forEach(node.children(), squash); } squash(tree); - var blob = service.absorbList(list, class_); + var blob = service.absorbList(list, class_, noOffline); blob.tree = tree; }; + var egLovefield; // we'll inject it manually + /** caches the object list both as the list and an id => object map */ - service.absorbList = function(list, class_) { + service.absorbList = function(list, class_, noOffline) { + if (service[class_] && service[class_].loaded) return service[class_]; + var blob; var pkey = egIDL.classes[class_].pkey; @@ -116,8 +130,19 @@ function($q, $window , egAuth, egPCRUD, egIDL) { blob = {list : list, map : {}}; } + if (!noOffline && service.ignoreOffline.indexOf(class_) < 0) { + if (!egLovefield) { + egLovefield = $injector.get('egLovefield'); + } + console.debug('About to cache a list of ' + class_ + ' objects...'); + egLovefield.isCacheGood(class_).then(function(good) { + if (!good) egLovefield.setListInOfflineCache(class_, blob.list); + }); + } + angular.forEach(list, function(item) {blob.map[item[pkey]()] = item}); service[class_] = blob; + service[class_].loaded = true; return blob; }; @@ -135,35 +160,45 @@ function($q, $window , egAuth, egPCRUD, egIDL) { service.classLoaders = { aou : function() { - // EXPERIMENT: cache the org tree in session storage. - // This means that if the org tree changes, users will have to - // open the client in a new browser tab to clear the cached tree. - var treeJSON = $window.sessionStorage.getItem('eg.env.aou.tree'); - if (treeJSON) { - console.debug('serving org tree from cache'); - var tree = JSON2js(treeJSON); - service.absorbTree(tree, 'aou') - return $q.when(tree); - } - - // sort orgs at each level by shortname - function sort_aou(node) { - node.children(node.children().sort(function(a, b) { - return a.shortname() < b.shortname() ? -1 : 1; - })); - angular.forEach(node.children(), sort_aou); + if (!egLovefield) { + egLovefield = $injector.get('egLovefield'); } - return egPCRUD.search('aou', {parent_ou : null}, - {flesh : -1, flesh_fields : {aou : ['children', 'ou_type']}} - ).then( - function(tree) { - sort_aou(tree); - $window.sessionStorage.setItem( - 'eg.env.aou.tree', js2JSON(tree)); + return egLovefield.reconstituteTree('aou').then(function(offline) { + if (offline) return $q.when(); + if (service.aou && service.aou.loaded) return $q.when(); + + // EXPERIMENT: cache the org tree in session storage. + // This means that if the org tree changes, users will have to + // open the client in a new browser tab to clear the cached tree. + var treeJSON = $window.sessionStorage.getItem('eg.env.aou.tree'); + if (treeJSON) { + console.debug('serving org tree from cache'); + var tree = JSON2js(treeJSON); service.absorbTree(tree, 'aou') + return $q.when(tree); } - ); + + // sort orgs at each level by shortname + function sort_aou(node) { + node.children(node.children().sort(function(a, b) { + return a.shortname() < b.shortname() ? -1 : 1; + })); + angular.forEach(node.children(), sort_aou); + } + + return egPCRUD.search('aou', {parent_ou : null}, + {flesh : -1, flesh_fields : {aou : ['children', 'ou_type']}} + ).then( + function(tree) { + sort_aou(tree); + $window.sessionStorage.setItem( + 'eg.env.aou.tree', js2JSON(tree)); + service.absorbTree(tree, 'aou'); + return $q.when(); + } + ); + }); }, }; diff --git a/Open-ILS/web/js/ui/default/staff/services/file.js b/Open-ILS/web/js/ui/default/staff/services/file.js index 83e0d295d8..829d4ca5ed 100644 --- a/Open-ILS/web/js/ui/default/staff/services/file.js +++ b/Open-ILS/web/js/ui/default/staff/services/file.js @@ -35,18 +35,72 @@ angular.module('egCoreMod') defaultFileName: '=' }, link: function (scope, element, attributes) { + var name = scope.defaultFileName || 'evergreen-json-export'; element.bind('click', function (clickEvent) { if (scope.generator) { scope.generator().then(function(value) { var data = new Blob([JSON.stringify(value)], {type : 'application/json'}); - FileSaver.saveAs(data, scope.defaultFileName); + FileSaver.saveAs(data, name); }); } else { var data = new Blob([JSON.stringify(scope.container)], {type : 'application/json'}); - FileSaver.saveAs(data, scope.defaultFileName); + FileSaver.saveAs(data, name); } }); } } }]) + +// The following directives use a attr instead of binding to get the default file name! +.directive('egStringExporter', ['FileSaver', 'Blob', function(FileSaver, Blob) { + return { + scope: { + contentType: '=', + string: '=', + generator: '=', + defaultFileName: '@' + }, + link: function (scope, element, attributes) { + var type = scope.contentType || 'text/plain'; + var name = scope.defaultFileName || 'evergreen-string-export'; + element.bind('click', function (clickEvent) { + if (scope.generator) { + scope.generator().then(function(value) { + var data = new Blob([value], {type : type}); + FileSaver.saveAs(data, name); + }); + } else { + var data = new Blob([scope.string], {type : type}); + FileSaver.saveAs(data, name); + } + }); + } + } +}]) + +.directive('egLineExporter', ['FileSaver', 'Blob', function(FileSaver, Blob) { + return { + scope: { + contentType: '=', + jsonArray: '=', + defaultFileName: '@' + }, + link: function (scope, element, attributes) { + element.bind('click', function (clickEvent) { + var type = scope.contentType || 'text/plain'; + var fname = scope.defaultFileName || 'evergreen-string-export'; + FileSaver.saveAs( + new Blob( + scope.jsonArray.map(function (line) { + return JSON.stringify(line) + '\n'; + }), + {type : type} + ), + fname + ); + }); + } + } +}]) + ; diff --git a/Open-ILS/web/js/ui/default/staff/services/hatch.js b/Open-ILS/web/js/ui/default/staff/services/hatch.js index 4fee7cb5c9..0a27b152e6 100644 --- a/Open-ILS/web/js/ui/default/staff/services/hatch.js +++ b/Open-ILS/web/js/ui/default/staff/services/hatch.js @@ -310,6 +310,42 @@ angular.module('egCoreMod') $window.localStorage.setItem(key, jsonified); } + service.appendItem = function(key, value) { + if (!service.useSettings()) + return $q.when(service.appendLocalItem(key, value)); + + if (service.hatchAvailable) + return service.appendRemoteItem(key, value); + + if (service.keyIsOnCall(key)) { + console.warn("Unable to appendItem in Hatch: " + + key + ". Setting in local storage instead"); + + return $q.when(service.appendLocalItem(key, value)); + } + + console.error("Unable to appendItem in Hatch: " + key); + return $q.reject(); + } + + // append the value to a stored or new item + service.appendRemoteItem = function(key, value) { + service.keyCache[key] = value; + return service.attemptHatchDelivery({ + key : key, + content : value, + action : 'append', + }); + } + + service.appendLocalItem = function(key, value, jsonified) { + if (jsonified === undefined ) + jsonified = JSON.stringify(value); + + var old_value = $window.localStorage.getItem(key) || ''; + $window.localStorage.setItem( key, old_value + jsonified ); + } + // Set the value for the given key. // "LoginSession" items are removed when the user logs out or the // browser is closed. diff --git a/Open-ILS/web/js/ui/default/staff/services/idl.js b/Open-ILS/web/js/ui/default/staff/services/idl.js index 3e4171543d..1b47ec1443 100644 --- a/Open-ILS/web/js/ui/default/staff/services/idl.js +++ b/Open-ILS/web/js/ui/default/staff/services/idl.js @@ -50,8 +50,6 @@ angular.module('egCoreMod') else obj.a[i][j] = angular.copy(thing[j]); } - } else { - obj.a[i] = angular.copy(thing); } } } diff --git a/Open-ILS/web/js/ui/default/staff/services/lovefield.js b/Open-ILS/web/js/ui/default/staff/services/lovefield.js new file mode 100644 index 0000000000..a05e824d88 --- /dev/null +++ b/Open-ILS/web/js/ui/default/staff/services/lovefield.js @@ -0,0 +1,406 @@ +var osb = lf.schema.create('offline', 2); + +osb.createTable('Object'). + addColumn('type', lf.Type.STRING). // class hint + addColumn('id', lf.Type.STRING). // obj id + addColumn('object', lf.Type.OBJECT). + addPrimaryKey(['type','id']); + +osb.createTable('CacheDate'). + addColumn('type', lf.Type.STRING). // class hint + addColumn('cachedate', lf.Type.DATE_TIME). // when was it last updated + addPrimaryKey(['type']); + +osb.createTable('Setting'). + addColumn('name', lf.Type.STRING). + addColumn('value', lf.Type.STRING). + addPrimaryKey(['name']); + +osb.createTable('StatCat'). + addColumn('id', lf.Type.INTEGER). + addColumn('value', lf.Type.OBJECT). + addPrimaryKey(['id']); + +osb.createTable('OfflineXact'). + addColumn('seq', lf.Type.INTEGER). + addColumn('value', lf.Type.OBJECT). + addPrimaryKey(['seq'], true); + +osb.createTable('OfflineBlocks'). + addColumn('barcode', lf.Type.STRING). + addColumn('reason', lf.Type.STRING). + addPrimaryKey(['barcode']); + +lf.connecting = true; +osb.connect().then(function (db) { + lf.offlineDB = db; + lf.connecting = false; +}); + +/** + * Core Service - egLovefield + * + * Lovefield wrapper factory for low level offline stuff + * + */ +angular.module('egCoreMod') + +.factory('egLovefield', ['$q','$rootScope','egCore','$timeout', + function($q , $rootScope , egCore , $timeout) { + + var service = {}; + + function connectOrGo (resolver) { + if (lf.offlineDB) { + return resolver(); + } + + // apparently, this might take a while... + if (lf.connecting) return $timeout(function() { + return connectOrGo(resolver); + }); + + console.log('egLovefield connecting to offline DB'); + + try { + return osb.connect().then(function (db) { + lf.offlineDB = db; + return resolver(); + }); + } catch (err) { + alert('attempted reconnect failure: ' + err.toString()); + } + } + + service.isCacheGood = function (type) { + + return connectOrGo(function() { + var cacheDate = lf.offlineDB.getSchema().table('CacheDate'); + + return lf.offlineDB. + select(cacheDate.cachedate). + from(cacheDate). + where(cacheDate.type.eq(type)). + exec().then(function(results) { + if (results.length == 0) { + return $q.when(false); + } + + var now = new Date(); + + // hard-coded 1 day offline cache timeout + return $q.when((now.getTime() - results[0]['cachedate'].getTime()) <= 86400000); + }) + }); + } + + service.destroyPendingOfflineXacts = function () { + return connectOrGo(function() { + var table = lf.offlineDB.getSchema().table('OfflineXact'); + return lf.offlineDB. + delete(). + from(table). + exec(); + }); + } + + service.havePendingOfflineXacts = function () { + return connectOrGo(function() { + var table = lf.offlineDB.getSchema().table('OfflineXact'); + return lf.offlineDB. + select(table.reason). + from(table). + exec(). + then(function(list) { + return $q.when(Boolean(list.length > 0)) + }); + }); + } + + service.retrievePendingOfflineXacts = function () { + return connectOrGo(function() { + var table = lf.offlineDB.getSchema().table('OfflineXact'); + return lf.offlineDB. + select(table.value). + from(table). + exec(). + then(function(list) { + return $q.when(list.map(function(x) { return x.value })) + }); + }); + } + + service.destroyOfflineBlocks = function () { + return connectOrGo(function() { + var table = lf.offlineDB.getSchema().table('OfflineBlocks'); + return $q.when( + lf.offlineDB. + delete(). + from(table). + exec() + ); + }); + } + + service.addOfflineBlock = function (barcode, reason) { + return connectOrGo(function() { + var table = lf.offlineDB.getSchema().table('OfflineBlocks'); + return $q.when( + lf.offlineDB. + insertOrReplace(). + into(table). + values([ table.createRow({ barcode : barcode, reason : reason }) ]). + exec() + ); + }); + } + + // Returns a promise with true for blocked, false for not blocked + service.testOfflineBlock = function (barcode) { + return connectOrGo(function() { + var table = lf.offlineDB.getSchema().table('OfflineBlocks'); + return lf.offlineDB. + select(table.reason). + from(table). + where(table.barcode.eq(barcode)). + exec().then(function(list) { + if(list.length > 0) return $q.when(list[0].reason); + return $q.when(null); + }); + }); + } + + service.addOfflineXact = function (obj) { + return connectOrGo(function() { + var table = lf.offlineDB.getSchema().table('OfflineXact'); + return $q.when( + lf.offlineDB. + insertOrReplace(). + into(table). + values([ table.createRow({ value : obj }) ]). + exec() + ); + }); + } + + service.setStatCatsCache = function (statcats) { + if (lf.isOffline) return $q.when(); + + return connectOrGo(function() { + var table = lf.offlineDB.getSchema().table('StatCat'); + var rlist = []; + + angular.forEach(statcats, function (val) { + rlist.push(table.createRow({ + id : val.id(), + value : egCore.idl.toHash(val) + })); + }); + return lf.offlineDB. + insertOrReplace(). + into(table). + values(rlist). + exec(); + }); + } + + service.getStatCatsCache = function () { + return connectOrGo(function() { + + var table = lf.offlineDB.getSchema().table('StatCat'); + var result = []; + return lf.offlineDB. + select(table.value). + from(table). + exec().then(function(list) { + angular.forEach(list, function (s) { + var sc = egCore.idl.fromHash('actsc', s.value); + + if (angular.isArray(sc.default_entries())) { + sc.default_entries( + sc.default_entries().map( function (k) { + return egCore.idl.fromHash('actsced', k); + }) + ); + } + + if (angular.isArray(sc.entries())) { + sc.entries( + sc.entries().map( function (k) { + return egCore.idl.fromHash('actsce', k); + }) + ); + } + + result.push(sc); + }); + return $q.when(result); + }); + + }); + } + + service.setSettingsCache = function (settings) { + if (lf.isOffline) return $q.when(); + + return connectOrGo(function() { + + var table = lf.offlineDB.getSchema().table('Setting'); + var rlist = []; + + angular.forEach(settings, function (val, key) { + rlist.push( + table.createRow({ + name : key, + value : JSON.stringify(val) + }) + ); + }); + + return lf.offlineDB. + insertOrReplace(). + into(table). + values(rlist). + exec(); + }); + } + + service.getSettingsCache = function (settings) { + return connectOrGo(function() { + + var table = lf.offlineDB.getSchema().table('Setting'); + + var search_pred = table.name.isNotNull(); + if (settings && settings.length) { + search_pred = table.name.in(settings); + } + + return lf.offlineDB. + select(table.name, table.value). + from(table). + where(search_pred). + exec().then(function(list) { + angular.forEach(list, function (s) { + s.value = JSON.parse(s.value) + }); + return $q.when(list); + }); + }); + } + + service.setListInOfflineCache = function (type, list) { + if (lf.isOffline) return $q.when(); + + return connectOrGo(function() { + + service.isCacheGood(type).then(function(good) { + if (!good) { + var object = lf.offlineDB.getSchema().table('Object'); + var cacheDate = lf.offlineDB.getSchema().table('CacheDate'); + var pkey = egCore.idl.classes[type].pkey; + + angular.forEach(list, function(item) { + var row = object.createRow({ + type : type, + id : '' + item[pkey](), + object : egCore.idl.toHash(item) + }); + lf.offlineDB.insertOrReplace().into(object).values([row]).exec(); + }); + + var row = cacheDate.createRow({ + type : type, + cachedate : new Date() + }); + + console.log('egLovefield saving ' + type + ' list'); + lf.offlineDB.insertOrReplace().into(cacheDate).values([row]).exec(); + } + }) + }); + } + + service.getListFromOfflineCache = function(type) { + return connectOrGo(function() { + + var object = lf.offlineDB.getSchema().table('Object'); + + return lf.offlineDB. + select(object.object). + from(object). + where(object.type.eq(type)). + exec().then(function(results) { + return $q.when(results.map(function(item) { + return egCore.idl.fromHash(type,item['object']) + })); + }); + }); + } + + service.reconstituteList = function(type) { + if (lf.isOffline) { + console.log('egLovefield reading ' + type + ' list'); + return service.getListFromOfflineCache(type).then(function (list) { + egCore.env.absorbList(list, type, true) + return $q.when(true); + }); + } + return $q.when(false); + } + + service.reconstituteTree = function(type) { + if (lf.isOffline) { + console.log('egLovefield reading ' + type + ' tree'); + + var pkey = egCore.idl.classes[type].pkey; + var parent_field = 'parent'; + + if (type == 'aou') { + parent_field = 'parent_ou'; + } + + return service.getListFromOfflineCache(type).then(function (list) { + var hash = {}; + var top = null; + angular.forEach(list, function (item) { + + // Special case for aou, to reconstitue ou_type + if (type == 'aou') { + if (item.ou_type()) { + item.ou_type( egCore.idl.fromHash('aout', item.ou_type()) ); + } + } + + hash[''+item[pkey]()] = item; + if (!item[parent_field]()) { + top = item; + } else if (angular.isObject(item[parent_field]())) { + // un-objectify the parent + item[parent_field]( + item[parent_field]()[pkey]() + ); + } + }); + + angular.forEach(list, function (item) { + item.children([]); // just clear it out if there's junk in there + + if (item[parent_field]()) { + item[parent_field]( hash[''+item[parent_field]()] ); + } + + item.children( list.filter(function (kid) { + return kid[parent_field]() == item[pkey](); + }) ); + }); + + egCore.env.absorbTree(top, type, true) + return $q.when(true) + }); + } + return $q.when(false); + } + + return service; +}]); + diff --git a/Open-ILS/web/js/ui/default/staff/services/navbar.js b/Open-ILS/web/js/ui/default/staff/services/navbar.js index 713d9f39ec..41d5ed87eb 100644 --- a/Open-ILS/web/js/ui/default/staff/services/navbar.js +++ b/Open-ILS/web/js/ui/default/staff/services/navbar.js @@ -5,47 +5,26 @@ angular.module('egCoreMod') restrict : 'AE', transclude : true, templateUrl : 'eg-navbar-template', - link : function(scope, element, attrs) { + controller:['$scope','$window','$location','$timeout','hotkeys','$rootScope', + 'egCore','$uibModal','ngToast','egOpChange','$element', + function($scope , $window , $location , $timeout , hotkeys , $rootScope , + egCore , $uibModal , ngToast , egOpChange , $element) { - // Find all eg-accesskey entries within the menu and attach - // hotkey handlers for each. - // jqlite doesn't support selectors, so we have to - // manually navigate to the elements we're interested in. - function inspect(elm) { - elm = angular.element(elm); - if (elm.attr('eg-accesskey')) { - scope.addHotkey( - elm.attr('eg-accesskey'), - elm.attr('href'), - elm.attr('eg-accesskey-desc'), - elm - ); - } - angular.forEach(elm.children(), inspect); - } - inspect(element); - }, - - controller:['$scope','$window','$location','$timeout','hotkeys', - 'egCore','$uibModal','ngToast','egOpChange', - function($scope , $window , $location , $timeout , hotkeys , - egCore , $uibModal , ngToast, egOpChange) { + $scope.rs = $rootScope; $scope.reprintLast = function (e) { egCore.print.reprintLast(); return e.preventDefault(); } - function navTo(path) { - // Strip the leading "./" if any. + function navTo(path) { path = path.replace(/^\.\//,''); - var reg = new RegExp($location.path()); $window.location.href = egCore.env.basePath + path; } // adds a keyboard shortcut // http://chieffancypants.github.io/angular-hotkeys/ - $scope.addHotkey = function(key, path, desc, elm) { + $scope.addHotkey = function(key, path, desc, elm) { angular.forEach(key.split(' '), function (k) { hotkeys.add({ combo: k, @@ -53,13 +32,27 @@ angular.module('egCoreMod') description: desc, callback: function(e) { e.preventDefault(); - if (path) return navTo(path); + if (path) return navTo(path,route); return $timeout(function(){$(elm).trigger('click')}); } }); }); }; + function inspect(elm) { + elm = angular.element(elm); + if (elm.attr('eg-accesskey')) { + $scope.addHotkey( + elm.attr('eg-accesskey'), + elm.attr('href'), + elm.attr('eg-accesskey-desc'), + elm + ); + } + angular.forEach(elm.children(), inspect); + } + $timeout(function(){inspect($element)}); + $scope.retrieveLastRecord = function() { var last_record = egCore.hatch.getLocalItem("eg.cat.last_record_retrieved"); if (last_record) { diff --git a/Open-ILS/web/js/ui/default/staff/services/org.js b/Open-ILS/web/js/ui/default/staff/services/org.js index e9fcc2d11c..93efc44048 100644 --- a/Open-ILS/web/js/ui/default/staff/services/org.js +++ b/Open-ILS/web/js/ui/default/staff/services/org.js @@ -18,8 +18,8 @@ angular.module('egCoreMod') .factory('egOrg', - ['$q','egEnv','egAuth','egNet', -function($q, egEnv, egAuth, egNet) { + ['$q','egEnv','egAuth','egNet','$injector', +function($q, egEnv, egAuth, egNet , $injector) { var service = {}; @@ -98,17 +98,34 @@ function($q, egEnv, egAuth, egNet) { return list; } + var egLovefield = null; // returns a promise, resolved with a hash of setting name => // setting value for the selected org unit. Org unit defaults to // auth workstation org unit. service.settings = function(names, ou_id) { + if (!egLovefield) { + egLovefield = $injector.get('egLovefield'); + } + + // allow non-array + if (!angular.isArray(names)) names = [names]; + + if (lf.isOffline) { + return egLovefield.getSettingsCache(names) + .then(function(settings) { + var hash = {}; + angular.forEach(settings, function (s) { + hash[s.name] = s.value; + }); + return $q.when(hash); + }); + } + var deferred = $q.defer(); ou_id = ou_id || egAuth.user().ws_ou(); var here = (ou_id == egAuth.user().ws_ou()); - // allow non-array - if (!angular.isArray(names)) names = [names]; - + if (here) { // only cache org settings retrieved for the current // workstation org unit. @@ -136,9 +153,11 @@ function($q, egEnv, egAuth, egNet) { if (here) service.cachedSettings[key] = settings[key]; }); - // resolve with cached settings if 'here', since 'settings' - // will only contain settings we had to retrieve - deferred.resolve(here ? service.cachedSettings : settings); + return egLovefield.setSettingsCache(settings).then(function() { + // resolve with cached settings if 'here', since 'settings' + // will only contain settings we had to retrieve + deferred.resolve(here ? service.cachedSettings : settings); + }); }); return deferred.promise; } diff --git a/Open-ILS/web/js/ui/default/staff/services/print.js b/Open-ILS/web/js/ui/default/staff/services/print.js index 03ea6b89f8..d6ddf595d6 100644 --- a/Open-ILS/web/js/ui/default/staff/services/print.js +++ b/Open-ILS/web/js/ui/default/staff/services/print.js @@ -58,9 +58,12 @@ function($q , $window , $timeout , $http , egHatch , egAuth , egIDL , egOrg , eg service.fleshPrintScope = function(scope) { if (!scope) scope = {}; scope.today = new Date().toISOString(); - scope.staff = egIDL.toHash(egAuth.user()); - scope.current_location = - egIDL.toHash(egOrg.get(egAuth.user().ws_ou())); + + if (!lf.isOffline) { + scope.staff = egIDL.toHash(egAuth.user()); + scope.current_location = + egIDL.toHash(egOrg.get(egAuth.user().ws_ou())); + } return service.fetch_includes(scope); } diff --git a/Open-ILS/web/js/ui/default/staff/services/startup.js b/Open-ILS/web/js/ui/default/staff/services/startup.js index 038eb2dfc2..958ff9fe31 100644 --- a/Open-ILS/web/js/ui/default/staff/services/startup.js +++ b/Open-ILS/web/js/ui/default/staff/services/startup.js @@ -48,6 +48,8 @@ function($q, $rootScope, $location, $window, egIDL, egAuth, egEnv , egOrg // returns true if we are staying on the current page // false if we are redirecting to login service.expiredAuthHandler = function() { + if (lf.isOffline) return true; // Only set by the offline UI + console.debug('egStartup.expiredAuthHandler()'); egAuth.logout(); // clean up diff --git a/Open-ILS/web/js/ui/default/staff/services/ui.js b/Open-ILS/web/js/ui/default/staff/services/ui.js index f6dc3a0ce2..d1f87a7f62 100644 --- a/Open-ILS/web/js/ui/default/staff/services/ui.js +++ b/Open-ILS/web/js/ui/default/staff/services/ui.js @@ -695,8 +695,8 @@ function($window , egStrings) { + '' + '', - controller : ['$scope','$timeout','egCore','egStartup', - function($scope , $timeout , egCore , egStartup) { + controller : ['$scope','$timeout','egCore','egStartup','egLovefield','$q', + function($scope , $timeout , egCore , egStartup , egLovefield , $q) { if ($scope.alldisabled) { $scope.disable_button = $scope.alldisabled == 'true' ? true : false; @@ -713,32 +713,40 @@ function($window , egStrings) { // // controller() runs before link(). // This post-startup code runs after link(). - egStartup.go().then(function() { - - $scope.orgList = egCore.org.list().map(function(org) { - return { - id : org.id(), - shortname : org.shortname(), - depth : org.ou_type().depth() + egStartup.go( + ).then( + function() { + return egCore.env.classLoaders.aou(); + } + ).then( + function() { + + $scope.orgList = egCore.org.list().map(function(org) { + return { + id : org.id(), + shortname : org.shortname(), + depth : org.ou_type().depth() + } + }); + + + // Apply default values + + if ($scope.stickySetting) { + var orgId = egCore.hatch.getLocalItem($scope.stickySetting); + if (orgId) { + $scope.selected = egCore.org.get(orgId); + } } - }); - - // Apply default values - - if ($scope.stickySetting) { - var orgId = egCore.hatch.getLocalItem($scope.stickySetting); - if (orgId) { - $scope.selected = egCore.org.get(orgId); + + if (!$scope.selected && !$scope.nodefault && egCore.auth.user()) { + $scope.selected = + egCore.org.get(egCore.auth.user().ws_ou()); } + + fire_orgsel_onchange(); // no-op if nothing is selected } - - if (!$scope.selected && !$scope.nodefault) { - $scope.selected = - egCore.org.get(egCore.auth.user().ws_ou()); - } - - fire_orgsel_onchange(); // no-op if nothing is selected - }); + ); /** * Fire onchange handler after a timeout, so the @@ -787,6 +795,17 @@ function($window , egStrings) { } }) +.directive('nextOnEnter', function () { + return function (scope, element, attrs) { + element.bind("keydown keypress", function (event) { + if(event.which === 13) { + $('#'+attrs.nextOnEnter).focus(); + event.preventDefault(); + } + }); + }; +}) + /* http://eric.sau.pe/angularjs-detect-enter-key-ngenter/ */ .directive('egEnter', function () { return function (scope, element, attrs) { @@ -810,18 +829,43 @@ function($window , egStrings) { function(egStrings, egCore) { return { scope : { + id : '@', closeText : '@', ngModel : '=', ngChange : '=', ngBlur : '=', + minDate : '=?', + maxDate : '=?', ngDisabled : '=', ngRequired : '=', hideDatePicker : '=', - dateFormat : '=?' + dateFormat : '=?', + outOfRange : '=?' }, require: 'ngModel', templateUrl: './share/t_datetime', replace: true, + controller : ['$scope', function($scope) { + $scope.options = { + minDate : $scope.minDate, + maxDate : $scope.maxDate + }; + + var maxDateObj = $scope.maxDate ? new Date($scope.maxDate) : null; + var minDateObj = $scope.minDate ? new Date($scope.minDate) : null; + + if ($scope.outOfRange !== undefined && (maxDateObj || minDateObj)) { + $scope.$watch('ngModel', function (n,o) { + if (n && n != o) { + var bad = false; + var newdate = new Date(n); + if (maxDateObj && newdate.getTime() > maxDateObj.getTime()) bad = true; + if (minDateObj && newdate.getTime() < minDateObj.getTime()) bad = true; + $scope.outOfRange = bad; + } + }); + } + }], link : function(scope, elm, attrs) { if (!scope.closeText) scope.closeText = egStrings.EG_DATE_INPUT_CLOSE_TEXT; diff --git a/Open-ILS/web/upup.min.js b/Open-ILS/web/upup.min.js new file mode 100644 index 0000000000..c28fe99fa4 --- /dev/null +++ b/Open-ILS/web/upup.min.js @@ -0,0 +1,7 @@ +//! UpUp +//! version : 0.3.0 +//! author : Tal Ater @TalAter +//! license : MIT +//! https://github.com/TalAter/UpUp +(function(a){"use strict";var b=this,c=navigator.serviceWorker;if(!c)return b.UpUp=null,a;var d={"service-worker-url":"upup.sw.min.js"},e=!1;b.UpUp={start:function(a){this.addSettings(a),c.register(d["service-worker-url"],{scope:"./"}).then(function(a){e&&console.log("Service worker registration successful with scope: %c"+a.scope,"font-weight: bold; color: #00f;"),(a.installing||c.controller).postMessage({action:"set-settings",settings:d})}).catch(function(a){e&&console.log("Service worker registration failed: %c"+a,"font-weight: bold; color: #00f;")})},addSettings:function(b){b=b||{},"string"==typeof b&&(b={content:b}),["content","content-url","assets","service-worker-url","cache-version"].forEach(function(c){b[c]!==a&&(d[c]=b[c])})},debug:function(a){e=!(arguments.length>0)||!!a}}}).call(this); +//# sourceMappingURL=upup.min.js.map \ No newline at end of file diff --git a/Open-ILS/web/upup.sw.min.js b/Open-ILS/web/upup.sw.min.js new file mode 100644 index 0000000000..87f2eced85 --- /dev/null +++ b/Open-ILS/web/upup.sw.min.js @@ -0,0 +1,7 @@ +//! UpUp Service Worker +//! version : 0.3.0 +//! author : Tal Ater @TalAter +//! license : MIT +//! https://github.com/TalAter/UpUp +var _CACHE_NAME_PREFIX="upup-cache",_calculateHash=function(a){a=a.toString();var b,c,d=0,e=a.length;if(0===e)return d;for(b=0;b