From 051e64c221e0a28ab7968b91956afdd192941b60 Mon Sep 17 00:00:00 2001 From: Galen Charlton Date: Tue, 26 May 2020 17:17:48 -0400 Subject: [PATCH] LP#1879983: AngularJS staff interface for curbside pickup This adds a new AngularJS page for curbside appointment management. The page has several tabs: * To Be Staged appointments. This displays upcoming appointments; from here, staff can mark appointments as "staged". Depending on the library's curbside workflow, that may mean that the staff member places the items in a bag or on a delivery table. This tab also allows a staff member to claim (or unclaim) responsibility for staging items for an appointment. * Staged and Ready. This displays staged appointments; from here, staff can mark that the patron has arrived, check out the items and mark the appointment delivered, or un-stage the appointment. * Patron Is Outside: from here, staff can check out the items and mark the appointment delivered. * Delivered Today: This displays appointments that were marked as delivered. * Schedule Pickup: This allows staff members to create and modify curbside appointments on behalf of a patron. AngularJS was chosen for this interface to permit backporting the feature to older versions of Evergreen without having to deal with variations in the version of Angular that is supported in past releases. It also better meshes with the patron and circulation staff interfaces that have not yet been rewritten in Angular. The curbside pickup page only handles appointments at the workstation library of the current staff user, as it assumes that the curbside process is not centralized. In addition to Galen Charlton, significant contributions to this patch were made by Mike Rylander. Sponsored-by: PaILS Signed-off-by: Galen Charlton Signed-off-by: Mike Rylander Signed-off-by: Michele Morgan --- .../templates/staff/circ/curbside/index.tt2 | 56 +++ .../staff/circ/curbside/t_arrived_manager.tt2 | 44 ++ .../circ/curbside/t_delivered_manager.tt2 | 31 ++ .../staff/circ/curbside/t_holds_list.tt2 | 12 + .../templates/staff/circ/curbside/t_main.tt2 | 39 ++ .../staff/circ/curbside/t_schedule_pickup.tt2 | 91 ++++ .../staff/circ/curbside/t_staged_manager.tt2 | 63 +++ .../circ/curbside/t_to_be_staged_manager.tt2 | 57 +++ Open-ILS/src/templates/staff/navbar.tt2 | 7 + .../js/ui/default/staff/circ/curbside/app.js | 59 +++ .../curbside/directives/arrived_manager.js | 122 ++++++ .../curbside/directives/delivered_manager.js | 63 +++ .../curbside/directives/schedule_pickup.js | 396 ++++++++++++++++++ .../curbside/directives/staged_manager.js | 145 +++++++ .../directives/to_be_staged_manager.js | 192 +++++++++ .../staff/circ/curbside/services/core.js | 192 +++++++++ .../js/ui/default/staff/services/navbar.js | 5 +- 17 files changed, 1573 insertions(+), 1 deletion(-) create mode 100644 Open-ILS/src/templates/staff/circ/curbside/index.tt2 create mode 100644 Open-ILS/src/templates/staff/circ/curbside/t_arrived_manager.tt2 create mode 100644 Open-ILS/src/templates/staff/circ/curbside/t_delivered_manager.tt2 create mode 100644 Open-ILS/src/templates/staff/circ/curbside/t_holds_list.tt2 create mode 100644 Open-ILS/src/templates/staff/circ/curbside/t_main.tt2 create mode 100644 Open-ILS/src/templates/staff/circ/curbside/t_schedule_pickup.tt2 create mode 100644 Open-ILS/src/templates/staff/circ/curbside/t_staged_manager.tt2 create mode 100644 Open-ILS/src/templates/staff/circ/curbside/t_to_be_staged_manager.tt2 create mode 100644 Open-ILS/web/js/ui/default/staff/circ/curbside/app.js create mode 100644 Open-ILS/web/js/ui/default/staff/circ/curbside/directives/arrived_manager.js create mode 100644 Open-ILS/web/js/ui/default/staff/circ/curbside/directives/delivered_manager.js create mode 100644 Open-ILS/web/js/ui/default/staff/circ/curbside/directives/schedule_pickup.js create mode 100644 Open-ILS/web/js/ui/default/staff/circ/curbside/directives/staged_manager.js create mode 100644 Open-ILS/web/js/ui/default/staff/circ/curbside/directives/to_be_staged_manager.js create mode 100644 Open-ILS/web/js/ui/default/staff/circ/curbside/services/core.js diff --git a/Open-ILS/src/templates/staff/circ/curbside/index.tt2 b/Open-ILS/src/templates/staff/circ/curbside/index.tt2 new file mode 100644 index 0000000000..5aee3bc470 --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/curbside/index.tt2 @@ -0,0 +1,56 @@ +[% + WRAPPER "staff/base.tt2"; + ctx.page_title = l("Curbside Pickup"); + ctx.page_app = "egCurbsideApp"; +%] + +[% BLOCK APP_JS %] + + + + + + + + + + + +[% END %] + +
+ +[% END %] + diff --git a/Open-ILS/src/templates/staff/circ/curbside/t_arrived_manager.tt2 b/Open-ILS/src/templates/staff/circ/curbside/t_arrived_manager.tt2 new file mode 100644 index 0000000000..0690e73bf4 --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/curbside/t_arrived_manager.tt2 @@ -0,0 +1,44 @@ +
+
+ [% l('Updates to the list of appointments whose patron has arrived are available. Please refresh.') %] +
+ + + + + + + + {{item.slot.patron().family_name()}} / {{item.slot.patron().card().barcode()}} + + +
+ + [% l('Notes:') %] {{item.slot.notes()}} + +
+ [% l('Patron is blocked from checkouts.') %] +
+
+ + +
+ [% l('Items are not yet staged!') %] +
+ +
+ + + +
+
diff --git a/Open-ILS/src/templates/staff/circ/curbside/t_delivered_manager.tt2 b/Open-ILS/src/templates/staff/circ/curbside/t_delivered_manager.tt2 new file mode 100644 index 0000000000..f6014dae2e --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/curbside/t_delivered_manager.tt2 @@ -0,0 +1,31 @@ +
+
+ [% l('Updates to the list of appointments whose items were delivered are available. Please refresh.') %] +
+ + + + + + + + {{item.slot.patron().family_name()}} / {{item.slot.patron().card().barcode()}} + + +
+ + [% l('Notes:') %] {{item.notes()}} + +
+ + + + +
+
diff --git a/Open-ILS/src/templates/staff/circ/curbside/t_holds_list.tt2 b/Open-ILS/src/templates/staff/circ/curbside/t_holds_list.tt2 new file mode 100644 index 0000000000..d9c068265d --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/curbside/t_holds_list.tt2 @@ -0,0 +1,12 @@ + diff --git a/Open-ILS/src/templates/staff/circ/curbside/t_main.tt2 b/Open-ILS/src/templates/staff/circ/curbside/t_main.tt2 new file mode 100644 index 0000000000..afc56ecebd --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/curbside/t_main.tt2 @@ -0,0 +1,39 @@ +
+
+ [% l('Curbside Pickup') %] +
+
+ +
+
+ + + +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+
+
diff --git a/Open-ILS/src/templates/staff/circ/curbside/t_schedule_pickup.tt2 b/Open-ILS/src/templates/staff/circ/curbside/t_schedule_pickup.tt2 new file mode 100644 index 0000000000..a0cb2b3bef --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/curbside/t_schedule_pickup.tt2 @@ -0,0 +1,91 @@ +
+
+
+ + + + + +
+ + + +
+
+ +
+
+ [% l('Barcode Not Found: [_1]', '{{bcNotFound}}') %] +
+
+ [% l("This patron's record is not viewable at your library.") %] +
+ + + +
+ [% l('Patron: [_1] [_2], [_3] [_4] [_5]', + '{{patron().pref_prefix() || patron().prefix()}}', + '{{patron().pref_family_name() || patron().family_name()}}', + '{{patron().pref_first_given_name() || patron().first_given_name()}}', + '{{patron().pref_second_given_name() || patron().second_given_name()}}', + '{{patron().pref_suffix() || patron().suffix()}}') + %] +
+
+ [% l('Patron has [_1] ready holds at this location.', '{{readyHolds}}') %] +
+ +
+ +
+
+
+ +
+
+ +
{{appt.id}}
+
+
+ + + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+
+
+ [% l('Appointment is in the past and may need to be rescheduled.') %] +
+
+
+
+
diff --git a/Open-ILS/src/templates/staff/circ/curbside/t_staged_manager.tt2 b/Open-ILS/src/templates/staff/circ/curbside/t_staged_manager.tt2 new file mode 100644 index 0000000000..1615553c0f --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/curbside/t_staged_manager.tt2 @@ -0,0 +1,63 @@ +
+
+ [% l('Updates to the list of staged and ready appointments are available. Please refresh.') %] +
+ + + + + + + + {{item.slot.patron().family_name()}} / {{item.slot.patron().card().barcode()}} + + +
+ + [% l('Notes:') %] {{item.slot.notes()}} + +
+ [% l('Patron is blocked from checkouts.') %] +
+
+ + + + + +
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+
diff --git a/Open-ILS/src/templates/staff/circ/curbside/t_to_be_staged_manager.tt2 b/Open-ILS/src/templates/staff/circ/curbside/t_to_be_staged_manager.tt2 new file mode 100644 index 0000000000..096c5b731e --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/curbside/t_to_be_staged_manager.tt2 @@ -0,0 +1,57 @@ +
+
+ [% l('Updates to the curbside appointment list are available. Please refresh.') %] +
+ + + + + + + + {{item.slot.patron().family_name()}} / {{item.slot.patron().card().barcode()}} + + +
+ + [% l('Notes:') %] {{item.slot.notes()}} + +
+ [% l('Patron is blocked from checkouts.') %] +
+
+ [% l('Patron has already arrived!') %] +
+
+ + + + + + {{item.slot.stage_staff().usrname()}} + + + + + + +
+
diff --git a/Open-ILS/src/templates/staff/navbar.tt2 b/Open-ILS/src/templates/staff/navbar.tt2 index ac25e566a6..d8287d87cb 100644 --- a/Open-ILS/src/templates/staff/navbar.tt2 +++ b/Open-ILS/src/templates/staff/navbar.tt2 @@ -238,6 +238,13 @@ [% l('Offline Circulation') %] +
  • +
  • + + + [% l('Curbside Pickup') %] + +
  • diff --git a/Open-ILS/web/js/ui/default/staff/circ/curbside/app.js b/Open-ILS/web/js/ui/default/staff/circ/curbside/app.js new file mode 100644 index 0000000000..1ec634b304 --- /dev/null +++ b/Open-ILS/web/js/ui/default/staff/circ/curbside/app.js @@ -0,0 +1,59 @@ +angular.module('egCurbsideApp', ['ui.bootstrap','ngRoute','egCoreMod','egGridMod','ngToast','egCurbsideMod','egCurbsideAppDep']); +angular.module('egCurbsideAppDep', ['egPatronSearchMod','egUserMod']); + +angular.module('egCurbsideApp') +.config(['ngToastProvider', function(ngToastProvider) { + ngToastProvider.configure({ + verticalPosition: 'bottom', + animation: 'fade' + }); +}]) + +.config(function($routeProvider, $locationProvider, $compileProvider) { + $locationProvider.html5Mode(true); + $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|mailto|blob):/); // grid export + + var resolver = {delay : ['egCore', function(egCore) { + egCore.env.classLoaders.aous = function() { + return egCore.org.settings([ + 'circ.do_not_tally_claims_returned', + 'circ.tally_lost', + ]).then(function(settings) { + // local settings are cached within egOrg. Caching them + // again in egEnv just simplifies the syntax for access. + egCore.env.aous = settings; + }); + }; + egCore.env.loadClasses.push('aous'); + + return egCore.startup.go() + }]}; + + $routeProvider.when('/circ/curbside/index', { + templateUrl: './circ/curbside/t_main', + controller: 'CurbsideCtrl', + resolve : resolver + }); + + $routeProvider.when('/circ/curbside/:active_tab', { + templateUrl: './circ/curbside/t_main', + controller: 'CurbsideCtrl', + resolve : resolver + }); + + // default page + $routeProvider.otherwise({redirectTo : '/circ/curbside/index'}); +}) + +.controller('CurbsideCtrl', + ['$scope','$routeParams','$location','egCurbsideCoreSvc', +function($scope , $routeParams , $location , egCurbsideCoreSvc ) { + $scope.active_tab = $routeParams.active_tab ? $routeParams.active_tab : 'to-be-staged'; + + $scope.$watch('active_tab', function(newVal, oldVal) { + if (oldVal != newVal) { + var new_path = '/circ/curbside/' + $scope.active_tab; + $location.path(new_path); + } + }); +}]) diff --git a/Open-ILS/web/js/ui/default/staff/circ/curbside/directives/arrived_manager.js b/Open-ILS/web/js/ui/default/staff/circ/curbside/directives/arrived_manager.js new file mode 100644 index 0000000000..6205aeaf52 --- /dev/null +++ b/Open-ILS/web/js/ui/default/staff/circ/curbside/directives/arrived_manager.js @@ -0,0 +1,122 @@ +angular.module('egCurbsideAppDep') + +.directive('egCurbsideArrivedManager', function() { + return { + transclude: true, + restrict: 'E', + scope: { }, + templateUrl: './circ/curbside/t_arrived_manager', + controller: + ['$scope','$q','egCurbsideCoreSvc','egCore','egGridDataProvider','egProgressDialog', + '$uibModal','$timeout','$location','egConfirmDialog','ngToast','$interval', +function($scope , $q , egCurbsideCoreSvc , egCore , egGridDataProvider , egProgressDialog , + $uibModal , $timeout , $location , egConfirmDialog , ngToast , $interval) { + + $scope.gridControls = {}; + + $scope.wasHandled = {}; + $scope.refreshNeeded = false; + + latestTime = undefined; + var checkRefresh = undefined; + function startRefreshCheck() { + if (!angular.isDefined(checkRefresh)) { + checkRefresh = $interval(function() { + egCurbsideCoreSvc.get_latest_arrived().then(function(latest) { + if (angular.isDefined(latest)) { + if (angular.isDefined(latestTime) && latestTime != latest) { + $scope.refreshNeeded = true; + stopRefreshCheck(); + } + latestTime = latest; + } + }); + }, 15000); + } + } + function stopRefreshCheck() { + if (angular.isDefined(checkRefresh)) { + $interval.cancel(checkRefresh); + checkRefresh = undefined; + } + } + this.$onInit = function() { + startRefreshCheck(); + } + this.$onDestroy = function() { + stopRefreshCheck(); + } + + $scope.gridDataProvider = egGridDataProvider.instance({ + get : function(offset, count) { + $scope.wasHandled = {}; + $scope.refreshNeeded = false; + startRefreshCheck(); + return egCurbsideCoreSvc.get_arrived(offset, count); + } + }); + + $scope.refresh_arrived = function() { + $scope.gridControls.refresh(); + } + + $scope.gridCellHandlers = { }; + $scope.gridCellHandlers.mark_delivered = function(id) { + var events_to_handle_later = []; + egProgressDialog.open(); + egCurbsideCoreSvc.mark_delivered(id).then(function(resp) { + egProgressDialog.close(); + + events_to_handle_later.pop(); // last element is resp, our param + if (events_to_handle_later.length) { // this means we got at least one CO attempt + + var bad_event; + angular.forEach(events_to_handle_later, function (evt) { + if (bad_event) return; // already warned staff, leave + if (angular.isArray(evt)) evt = evt[0]; // we only need to look at the first event from each CO response + + evt = egCore.evt.parse(evt); + if (!bad_event && evt && evt.textcode != 'SUCCESS') { // at least one non-success event, show the first event. + bad_event = evt; + ngToast.danger(egCore.strings.$replace( + egCore.strings.FAILED_CURBSIDE_CHECKOUT, + { slot_id : id, evt_code : bad_event.code } + )); + } + }); + } + + if (evt = egCore.evt.parse(resp)) { + ngToast.danger(egCore.strings.$replace( + egCore.strings.FAILED_CURBSIDE_MARK_DELIVERED, + { slot_id : id, evt_code : evt.code } + )); + return; + } + + if (!angular.isDefined(resp)) { + ngToast.warning(egCore.strings.$replace( + egCore.strings.NOTFOUND_CURBSIDE_MARK_DELIVERED, + { slot_id : id } + )); + return; + } + + ngToast.success(egCore.strings.$replace( + egCore.strings.SUCCESS_CURBSIDE_MARK_DELIVERED, + { slot_id : id } + )); + $scope.wasHandled[id] = true; + $timeout(function() { $scope.refresh_arrived() }, 500); + },null, function (resp) { + events_to_handle_later.push(resp); + }); + } + $scope.gridCellHandlers.wasHandled = function(id) { + return $scope.wasHandled[id]; + } + $scope.gridCellHandlers.patronIsBlocked = function(usr) { + return egCurbsideCoreSvc.patron_blocked(usr); + } + +}]}}); diff --git a/Open-ILS/web/js/ui/default/staff/circ/curbside/directives/delivered_manager.js b/Open-ILS/web/js/ui/default/staff/circ/curbside/directives/delivered_manager.js new file mode 100644 index 0000000000..447725b360 --- /dev/null +++ b/Open-ILS/web/js/ui/default/staff/circ/curbside/directives/delivered_manager.js @@ -0,0 +1,63 @@ +angular.module('egCurbsideAppDep') + +.directive('egCurbsideDeliveredManager', function() { + return { + transclude: true, + restrict: 'E', + scope: { }, + templateUrl: './circ/curbside/t_delivered_manager', + controller: + ['$scope','$q','egCurbsideCoreSvc','egCore','egGridDataProvider', + '$uibModal','$timeout','$location','egConfirmDialog','ngToast','$interval', +function($scope , $q , egCurbsideCoreSvc , egCore , egGridDataProvider , + $uibModal , $timeout , $location , egConfirmDialog , ngToast , $interval) { + + $scope.gridControls = {}; + + $scope.refreshNeeded = false; + + latestTime = undefined; + var checkRefresh = undefined; + function startRefreshCheck() { + if (!angular.isDefined(checkRefresh)) { + checkRefresh = $interval(function() { + egCurbsideCoreSvc.get_latest_delivered().then(function(latest) { + if (angular.isDefined(latest)) { + if (angular.isDefined(latestTime) && latestTime != latest) { + $scope.refreshNeeded = true; + stopRefreshCheck(); + } + latestTime = latest; + } + }); + }, 15000); + } + } + function stopRefreshCheck() { + if (angular.isDefined(checkRefresh)) { + $interval.cancel(checkRefresh); + checkRefresh = undefined; + } + } + this.$onInit = function() { + startRefreshCheck(); + } + this.$onDestroy = function() { + stopRefreshCheck(); + } + + $scope.gridDataProvider = egGridDataProvider.instance({ + get : function(offset, count) { + $scope.refreshNeeded = false; + startRefreshCheck(); + return egCurbsideCoreSvc.get_delivered(offset, count); + } + }); + + $scope.refresh_delivered = function() { + $scope.gridControls.refresh(); + } + + $scope.gridCellHandlers = { }; + +}]}}); diff --git a/Open-ILS/web/js/ui/default/staff/circ/curbside/directives/schedule_pickup.js b/Open-ILS/web/js/ui/default/staff/circ/curbside/directives/schedule_pickup.js new file mode 100644 index 0000000000..6d8ae65e0a --- /dev/null +++ b/Open-ILS/web/js/ui/default/staff/circ/curbside/directives/schedule_pickup.js @@ -0,0 +1,396 @@ +angular.module('egCurbsideAppDep') + +.directive('egCurbsideSchedulePickup', function() { + return { + transclude: true, + restrict: 'E', + scope: { }, + templateUrl: './circ/curbside/t_schedule_pickup', + controller: + ['$scope','$q','egCurbsideCoreSvc','egCore','patronSvc', + '$uibModal','$timeout','$location','egConfirmDialog','ngToast', +function($scope , $q , egCurbsideCoreSvc , egCore , patronSvc , + $uibModal , $timeout , $location , egConfirmDialog , ngToast) { + + $scope.clear = function() { + $scope.user_id = undefined; + $scope.args = {}; + $scope.readyHolds = 0; + $scope.openAppointments = []; + $scope.forms = []; + } + $scope.clear(); + + patron_search_dialog = function() { + return $uibModal.open({ + templateUrl: './share/t_patron_selector', + backdrop: 'static', + size: 'lg', + animation: true, + controller: + ['$scope','$uibModalInstance','$controller', + function($scope , $uibModalInstance , $controller) { + angular.extend(this, $controller('BasePatronSearchCtrl', {$scope : $scope})); + $scope.clearForm(); + $scope.need_one_selected = function() { + var items = $scope.gridControls.selectedItems(); + return (items.length == 1) ? false : true + } + $scope.ok = function() { + var items = $scope.gridControls.selectedItems(); + if (items.length == 1) { + $uibModalInstance.close(items[0].card().barcode()); + } else { + $uibModalInstance.close() + } + } + $scope.cancel = function($event) { + $uibModalInstance.dismiss(); + $event.preventDefault(); + } + }] + }); + } + + $scope.patron_search = function() { + patron_search_dialog().result.then(function(barcode) { + $scope.args.barcode = barcode; + }); + } + + // this is blatantly copied from the patron app; if the AngularJS + // code had a longer life-expectancy, this would have been moved + // to a service. + $scope.submitBarcode = function(args) { + $scope.bcNotFound = null; + $scope.optInRestricted = false; + if (!args.barcode) return; + args.barcode = args.barcode.replace(/\s/g,''); + // blur so next time it's set to true it will re-apply select() + $scope.selectMe = false; + + var user_id; + + // given a scanned barcode, this function finds any matching users + // and handles multiple matches due to barcode completion + function handleBarcodeCompletion(scanned_barcode) { + var deferred = $q.defer(); + + egCore.net.request( + 'open-ils.actor', + 'open-ils.actor.get_barcodes', + egCore.auth.token(), egCore.auth.user().ws_ou(), + 'actor', scanned_barcode) + + .then(function(resp) { // get_barcodes + + if (evt = egCore.evt.parse(resp)) { + alert(evt); // FIXME + deferred.reject(); + return; + } + + if (!resp || !resp[0]) { + $scope.bcNotFound = args.barcode; + $scope.selectMe = true; + egCore.audio.play('warning.patron.not_found'); + deferred.reject(); + return; + } + + if (resp.length == 1) { + // exactly one matching barcode: return it + deferred.resolve(); + user_id = resp[0].id; + } else { + // multiple matching barcodes: let the user pick one + var barcode_map = {}; + var matches = []; + var promises = []; + var selected_barcode; + angular.forEach(resp, function(match) { + promises.push( + egUser.get(match.id, {useFields : ['home_ou']}).then(function(user) { + barcode_map[match.barcode] = user.id(); + matches.push( { + barcode: match.barcode, + title: user.first_given_name() + ' ' + user.family_name(), + org_name: user.home_ou().name(), + org_shortname: user.home_ou().shortname() + }); + }) + ); + }); + return $q.all(promises) + .then(function() { + $uibModal.open({ + templateUrl: './circ/share/t_barcode_choice_dialog', + controller: + ['$scope', '$uibModalInstance', + function($scope, $uibModalInstance) { + $scope.matches = matches; + $scope.ok = function(barcode) { + $uibModalInstance.close(); + selected_barcode = barcode; + } + $scope.cancel = function() {$uibModalInstance.dismiss()} + }], + }).result.then(function() { + deferred.resolve(); + user_id = barcode_map[selected_barcode]; + }); + }); + } + }); + return deferred.promise; + } + + // call our function to lookup matching users for the scanned barcode + handleBarcodeCompletion(args.barcode).then(function() { + + // see if an opt-in request is needed + return egCore.net.request( + 'open-ils.actor', + 'open-ils.actor.user.org_unit_opt_in.check', + egCore.auth.token(), user_id + ).then(function(optInResp) { // opt_in_check + + if (evt = egCore.evt.parse(optInResp)) { + alert(evt); // FIXME + return; + } + + if (optInResp == 2) { + // opt-in disallowed at this location by patron's home library + $scope.optInRestricted = true; + $scope.selectMe = true; + egCore.audio.play('warning.patron.opt_in_restricted'); + return; + } + + if (optInResp == 1) { + // opt-in handled or not needed + return loadPatron(user_id); + } + + // opt-in needed, show the opt-in dialog + egUser.get(user_id, {useFields : []}) + + .then(function(user) { // retrieve user + var org = egCore.org.get(user.home_ou()); + egConfirmDialog.open( + egCore.strings.OPT_IN_DIALOG_TITLE, + egCore.strings.OPT_IN_DIALOG, + { family_name : user.family_name(), + first_given_name : user.first_given_name(), + org_name : org.name(), + org_shortname : org.shortname(), + ok : function() { createOptIn(user.id()) }, + cancel : function() {} + } + ); + }) + }) + }) + } + + function countReadyHolds(user_id) { + return egCore.net.request( + 'open-ils.curbside', + 'open-ils.curbside.patron.ready_holds_at_lib.count', + egCore.auth.token(), + user_id + ).then(function(resp) { + if (evt = egCore.evt.parse(resp)) { + return 0; + } else { + return resp; + } + }); + } + + function fetchOpenAppointments(user_id) { + return egCore.net.request( + 'open-ils.curbside', + 'open-ils.curbside.open_user_appointments_at_lib.atomic', + egCore.auth.token(), + user_id + ).then(function(resp) { + if (evt = egCore.evt.parse(resp)) { + return 0; + } else { + return resp; + } + }); + } + + function mungeAvailableTimes(hash, times) { + var existing_present = false; + if (angular.isDefined(hash.slot_time) && hash.slot_time !== null) { + hash.original_slot_time = hash.slot_time; + } + hash.available_times = times.map(function(t) { + if (angular.isDefined(hash.slot_time) && hash.slot_time !== null && hash.slot_time === t[0]) { + existing_present = true; + } + return { + time: t[0], + available: t[1], + time_fmt: moment(t[0], [moment.ISO_8601, 'HH:mm:ss']).format('LT') + }; + }); + if (angular.isDefined(hash.slot_time) && hash.slot_time !== null && !existing_present) { + hash.available_times.unshift({ + time: hash.slot_time, + available: 0, + time_fmt: moment(hash.slot_time, [moment.ISO_8601, 'HH:mm:ss']).format('LT') + }); + } + } + + function mungeOneAppointment(c, isNew) { + var hash = egCore.idl.toHash(c); + if (hash.slot === null) { + // coerce to today for the purpose of the + // form if no slot time has been set yet + hash.slot = new Date().toISOString(); + hash.slot_time = null; + } else { + if (!isNew) { + hash.slot_time = hash.slot.substring(11, 19); + } + } + hash.slot_date = new Date(hash.slot); + if (!isNew) { + hash.is_past = (hash.slot_date < new Date()); + } + hash.available_times = []; + egCore.net.request ( + 'open-ils.curbside', + 'open-ils.curbside.times_for_date.atomic', + egCore.auth.token(), + hash.slot.substring(0, 10), + ).then(function(times) { + mungeAvailableTimes(hash, times); + }); + return hash; + } + + function mungeAppointmentList(list) { + $scope.openAppointments = list.map(function(c) { + var hash = mungeOneAppointment(c); + return hash; + }); + } + + function loadPatron(user_id) { + $scope.user_id = user_id; + patronSvc.getPrimary(user_id); + countReadyHolds(user_id).then(function(ct) { $scope.readyHolds = ct }); + fetchOpenAppointments(user_id).then(function(list) { + mungeAppointmentList(list); + }); + } + + + $scope.minDate = new Date(); + $scope.refreshAvailableTimes = function(hash) { + var dateStr = (new Date(hash.slot_date)).toISOString().substring(0, 10); + egCore.net.request ( + 'open-ils.curbside', + 'open-ils.curbside.times_for_date.atomic', + egCore.auth.token(), + dateStr, + ).then(function(times) { + mungeAvailableTimes(hash, times); + }); + } + + $scope.startNewAppointment = function() { + var slot = new egCore.idl.acsp(); + slot.slot = new Date().toISOString(); + slot.patron = $scope.user_id; + slot.org = egCore.auth.user().ws_ou(); + $scope.openAppointments = [ mungeOneAppointment(slot, true) ]; + } + + $scope.updateAppointment = function(appt) { + var op = angular.isDefined(appt.id) ? 'update' : 'create'; + egCore.net.request( + 'open-ils.curbside', + 'open-ils.curbside.' + op + '_appointment', + egCore.auth.token(), + $scope.user_id, + (new Date(appt.slot_date)).toISOString().substring(0, 10), + appt.slot_time, + egCore.auth.user().ws_ou(), + appt.notes + ).then(function(resp) { + if (evt = egCore.evt.parse(resp)) { + if (evt.textcode === 'CURBSIDE_MAX_FOR_TIME') { + ngToast.danger(egCore.strings.$replace( + egCore.strings.FAILED_SAVE_APPOINTMENT_TOO_MANY, + { evt_code : evt.code } + )); + } else { + ngToast.danger(egCore.strings.$replace( + egCore.strings.FAILED_SAVE_APPOINTMENT, + { evt_code : evt.code } + )); + } + } else { + ngToast.success(egCore.strings.$replace( + egCore.strings.SUCCESS_SAVE_APPOINTMENT, + { slot_id : resp.id() } + )); + } + fetchOpenAppointments($scope.user_id).then(function(list) { + mungeAppointmentList(list); + }); + }); + } + + function doCancel(id) { + egCore.net.request ( + 'open-ils.curbside', + 'open-ils.curbside.delete_appointment', + egCore.auth.token(), + id + ).then(function(resp) { + if (!angular.isDefined(resp)) { + ngToast.danger(egCore.strings.$replace( + egCore.strings.FAILED_CANCEL_APPOINTMENT, + { slot_id : id, evt_code : 'NO_SUCH_APPOINTMENT' } + )); + } else if (evt = egCore.evt.parse(resp)) { + ngToast.danger(egCore.strings.$replace( + egCore.strings.FAILED_CANCEL_APPOINTMENT, + { slot_id : id, evt_code : evt.code } + )); + } else { + ngToast.success(egCore.strings.$replace( + egCore.strings.SUCCESS_CANCEL_APPOINTMENT, + { slot_id : id } + )); + } + fetchOpenAppointments($scope.user_id).then(function(list) { + mungeAppointmentList(list); + }); + }); + } + $scope.cancelAppointment = function(id) { + egConfirmDialog.open( + egCore.strings.CONFIRM_CANCEL_TITLE, + egCore.strings.CONFIRM_CANCEL_BODY, + { slot_id : id, + ok : function() { doCancel(id) }, + cancel : function() {} + } + ); + } + + $scope.patron = function() { + return patronSvc.current; + } + +}]}}); diff --git a/Open-ILS/web/js/ui/default/staff/circ/curbside/directives/staged_manager.js b/Open-ILS/web/js/ui/default/staff/circ/curbside/directives/staged_manager.js new file mode 100644 index 0000000000..2f0050cfbf --- /dev/null +++ b/Open-ILS/web/js/ui/default/staff/circ/curbside/directives/staged_manager.js @@ -0,0 +1,145 @@ +angular.module('egCurbsideAppDep') + +.directive('egCurbsideStagedManager', function() { + return { + transclude: true, + restrict: 'E', + scope: { }, + templateUrl: './circ/curbside/t_staged_manager', + controller: + ['$scope','$q','egCurbsideCoreSvc','egCore','egGridDataProvider','egProgressDialog', + '$uibModal','$timeout','$location','egConfirmDialog','ngToast','$interval', +function($scope , $q , egCurbsideCoreSvc , egCore , egGridDataProvider , egProgressDialog , + $uibModal , $timeout , $location , egConfirmDialog , ngToast , $interval) { + + $scope.gridControls = {}; + + $scope.wasHandled = {}; + $scope.refreshNeeded = false; + + latestTime = undefined; + var checkRefresh = undefined; + function startRefreshCheck() { + if (!angular.isDefined(checkRefresh)) { + checkRefresh = $interval(function() { + egCurbsideCoreSvc.get_latest_staged().then(function(latest) { + if (angular.isDefined(latest)) { + if (angular.isDefined(latestTime) && latestTime != latest) { + $scope.refreshNeeded = true; + stopRefreshCheck(); + } + latestTime = latest; + } + }); + }, 15000); + } + } + function stopRefreshCheck() { + if (angular.isDefined(checkRefresh)) { + $interval.cancel(checkRefresh); + checkRefresh = undefined; + } + } + this.$onInit = function() { + startRefreshCheck(); + } + this.$onDestroy = function() { + stopRefreshCheck(); + } + + $scope.gridDataProvider = egGridDataProvider.instance({ + get : function(offset, count) { + $scope.wasHandled = {}; + $scope.refreshNeeded = false; + startRefreshCheck(); + return egCurbsideCoreSvc.get_staged(offset, count); + } + }); + + $scope.refresh_staged = function() { + $scope.gridControls.refresh(); + } + + $scope.gridCellHandlers = { }; + $scope.gridCellHandlers.mark_arrived = function(id) { + egCurbsideCoreSvc.mark_arrived(id).then(function(resp) { + if (evt = egCore.evt.parse(resp)) { + ngToast.danger(egCore.strings.$replace( + egCore.strings.FAILED_CURBSIDE_MARK_ARRIVED, + { slot_id : id, evt_code : evt.code } + )); + return; + } + if (!angular.isDefined(resp)) { + ngToast.warning(egCore.strings.$replace( + egCore.strings.NOTFOUND_CURBSIDE_MARK_ARRIVED, + { slot_id : id } + )); + return; + } + ngToast.success(egCore.strings.$replace( + egCore.strings.SUCCESS_CURBSIDE_MARK_ARRIVED, + { slot_id : id } + )); + $scope.wasHandled[id] = true; + $timeout(function() { $scope.refresh_staged() }, 500); + }); + } + $scope.gridCellHandlers.mark_unstaged = function(id) { + egCurbsideCoreSvc.mark_unstaged(id).then(function(resp) { + if (evt = egCore.evt.parse(resp)) { + ngToast.danger(egCore.strings.$replace( + egCore.strings.FAILED_CURBSIDE_MARK_UNSTAGED, + { slot_id : id, evt_code : evt.code } + )); + return; + } + if (!angular.isDefined(resp)) { + ngToast.warning(egCore.strings.$replace( + egCore.strings.NOTFOUND_CURBSIDE_MARK_UNSTAGED, + { slot_id : id } + )); + return; + } + ngToast.success(egCore.strings.$replace( + egCore.strings.SUCCESS_CURBSIDE_MARK_UNSTAGED, + { slot_id : id } + )); + $scope.wasHandled[id] = true; + $timeout(function() { $scope.refresh_staged() }, 500); + }); + } + $scope.gridCellHandlers.mark_delivered = function(id) { + egProgressDialog.open(); + egCurbsideCoreSvc.mark_delivered(id).then(function(resp) { + egProgressDialog.close(); + if (evt = egCore.evt.parse(resp)) { + ngToast.danger(egCore.strings.$replace( + egCore.strings.FAILED_CURBSIDE_MARK_DELIVERED, + { slot_id : id, evt_code : evt.code } + )); + return; + } + if (!angular.isDefined(resp)) { + ngToast.warning(egCore.strings.$replace( + egCore.strings.NOTFOUND_CURBSIDE_MARK_DELIVERED, + { slot_id : id } + )); + return; + } + ngToast.success(egCore.strings.$replace( + egCore.strings.SUCCESS_CURBSIDE_MARK_DELIVERED, + { slot_id : id } + )); + $scope.wasHandled[id] = true; + $timeout(function() { $scope.refresh_staged() }, 500); + }); + } + $scope.gridCellHandlers.wasHandled = function(id) { + return $scope.wasHandled[id]; + } + $scope.gridCellHandlers.patronIsBlocked = function(usr) { + return egCurbsideCoreSvc.patron_blocked(usr); + } + +}]}}); diff --git a/Open-ILS/web/js/ui/default/staff/circ/curbside/directives/to_be_staged_manager.js b/Open-ILS/web/js/ui/default/staff/circ/curbside/directives/to_be_staged_manager.js new file mode 100644 index 0000000000..993ecac1c0 --- /dev/null +++ b/Open-ILS/web/js/ui/default/staff/circ/curbside/directives/to_be_staged_manager.js @@ -0,0 +1,192 @@ +angular.module('egCurbsideAppDep') + +.directive('egCurbsideToBeStagedManager', function() { + return { + transclude: true, + restrict: 'E', + scope: { }, + templateUrl: './circ/curbside/t_to_be_staged_manager', + controller: + ['$scope','$q','egCurbsideCoreSvc','egCore','egGridDataProvider', + '$uibModal','$timeout','$location','egConfirmDialog','ngToast','$interval', +function($scope , $q , egCurbsideCoreSvc , egCore , egGridDataProvider , + $uibModal , $timeout , $location , egConfirmDialog , ngToast , $interval) { + + $scope.gridControls = {}; + + $scope.wasHandled = {}; + $scope.refreshNeeded = false; + + latestTime = undefined; + var checkRefresh = undefined; + function startRefreshCheck() { + if (!angular.isDefined(checkRefresh)) { + checkRefresh = $interval(function() { + egCurbsideCoreSvc.get_latest_to_be_staged().then(function(latest) { + if (angular.isDefined(latest)) { + if (angular.isDefined(latestTime) && latestTime != latest) { + $scope.refreshNeeded = true; + stopRefreshCheck(); + } + latestTime = latest; + } + }); + }, 5000); + } + } + function stopRefreshCheck() { + if (angular.isDefined(checkRefresh)) { + $interval.cancel(checkRefresh); + checkRefresh = undefined; + } + } + this.$onInit = function() { + startRefreshCheck(); + } + this.$onDestroy = function() { + stopRefreshCheck(); + } + + $scope.gridDataProvider = egGridDataProvider.instance({ + get : function(offset, count) { + $scope.wasHandled = {}; + $scope.refreshNeeded = false; + startRefreshCheck(); + return egCurbsideCoreSvc.get_to_be_staged(offset, count); + } + }); + + $scope.refresh_staging = function() { + $scope.gridControls.refresh(); + } + + $scope.gridCellHandlers = { }; + $scope.gridCellHandlers.mark_staged = function(id) { + egCurbsideCoreSvc.mark_staged(id).then(function(resp) { + if (evt = egCore.evt.parse(resp)) { + ngToast.danger(egCore.strings.$replace( + egCore.strings.FAILED_CURBSIDE_MARK_STAGED, + { slot_id : id, evt_code : evt.code } + )); + return; + } + if (!angular.isDefined(resp)) { + ngToast.warning(egCore.strings.$replace( + egCore.strings.NOTFOUND_CURBSIDE_MARK_STAGED, + { slot_id : id } + )); + return; + } + ngToast.success(egCore.strings.$replace( + egCore.strings.SUCCESS_CURBSIDE_MARK_STAGED, + { slot_id : id } + )); + $scope.wasHandled[id] = true; + $timeout(function() { $scope.refresh_staging() }, 500); + }); + } + $scope.gridCellHandlers.wasHandled = function(id) { + return $scope.wasHandled[id]; + } + $scope.gridCellHandlers.patronIsBlocked = function(usr) { + return egCurbsideCoreSvc.patron_blocked(usr); + } + $scope.gridCellHandlers.canClaimStaging = function(item) { + if ($scope.wasHandled[item.slot_id]) return false; + if (!item.slot.stage_staff()) return true; + if (item.slot.stage_staff().id() == egCore.auth.user().id()) return false; + return true; + } + $scope.gridCellHandlers.canUnclaimStaging = function(item) { + if ($scope.wasHandled[item.slot_id]) return false; + if (!item.slot.stage_staff()) return false; + if (item.slot.stage_staff().id() == egCore.auth.user().id()) return true; + return false; + } + $scope.gridCellHandlers.claim_staging = function(item) { + console.debug('claim'); + } + doClaimStaging = function(item) { + var id = item.slot_id; + egCurbsideCoreSvc.claim_staging(id).then(function(resp) { + if (evt = egCore.evt.parse(resp)) { + ngToast.danger(egCore.strings.$replace( + egCore.strings.FAILED_CURBSIDE_CLAIM_STAGING, + { slot_id : id, evt_code : evt.code } + )); + return; + } + if (!angular.isDefined(resp)) { + ngToast.warning(egCore.strings.$replace( + egCore.strings.NOTFOUND_CURBSIDE_CLAIM_STAGING, + { slot_id : id } + )); + return; + } + + item.slot = resp; + + // attempt to avoid a spurious refresh prompt + egCurbsideCoreSvc.get_latest_to_be_staged().then(function(latest) { + if (angular.isDefined(latest)) { + latestTime = latest + } + }); + + ngToast.success(egCore.strings.$replace( + egCore.strings.SUCCESS_CURBSIDE_CLAIM_STAGING, + { slot_id : id } + )); + }); + } + $scope.gridCellHandlers.claim_staging = function(item) { + if (item.slot.stage_staff() && + item.slot.stage_staff().id() !== egCore.auth.user().id()) { + egConfirmDialog.open( + egCore.strings.CONFIRM_TAKE_OVER_STAGING_TITLE, + egCore.strings.CONFIRM_TAKE_OVER_STAGING_BODY, + { slot_id : item.slot_id, + other_staff : item.slot.stage_staff().usrname(), + ok : function() { doClaimStaging(item) }, + cancel : function() {} + } + ); + } else { + doClaimStaging(item); + } + } + $scope.gridCellHandlers.unclaim_staging = function(item) { + var id = item.slot_id; + egCurbsideCoreSvc.unclaim_staging(id).then(function(resp) { + if (evt = egCore.evt.parse(resp)) { + ngToast.danger(egCore.strings.$replace( + egCore.strings.FAILED_CURBSIDE_UNCLAIM_STAGING, + { slot_id : id, evt_code : evt.code } + )); + return; + } + if (!angular.isDefined(resp)) { + ngToast.warning(egCore.strings.$replace( + egCore.strings.NOTFOUND_CURBSIDE_UNCLAIM_STAGING, + { slot_id : id } + )); + return; + } + + item.slot = resp; + + // attempt to avoid a spurious refresh prompt + egCurbsideCoreSvc.get_latest_to_be_staged().then(function(latest) { + if (angular.isDefined(latest)) { + latestTime = latest + } + }); + + ngToast.success(egCore.strings.$replace( + egCore.strings.SUCCESS_CURBSIDE_UNCLAIM_STAGING, + { slot_id : id } + )); + }); + } + +}]}}); diff --git a/Open-ILS/web/js/ui/default/staff/circ/curbside/services/core.js b/Open-ILS/web/js/ui/default/staff/circ/curbside/services/core.js new file mode 100644 index 0000000000..e2b1e6c240 --- /dev/null +++ b/Open-ILS/web/js/ui/default/staff/circ/curbside/services/core.js @@ -0,0 +1,192 @@ +angular.module('egCurbsideMod', ['egCoreMod']) +.factory('egCurbsideCoreSvc', + ['egCore','orderByFilter','$q','$filter','$uibModal','ngToast','egConfirmDialog', +function(egCore , orderByFilter , $q , $filter , $uibModal , ngToast , egConfirmDialog) { + var service = { }; + + service.get_to_be_staged = function(offset, count) { + return egCore.net.request( + 'open-ils.curbside', + 'open-ils.curbside.fetch_to_be_staged', + egCore.auth.token(), + egCore.auth.user().ws_ou(), + count, // yep, count first + offset + ); + }; + service.get_latest_to_be_staged = function() { + return egCore.net.request( + 'open-ils.curbside', + 'open-ils.curbside.fetch_to_be_staged.latest', + egCore.auth.token() + ).then(function(resp) { + if (evt = egCore.evt.parse(resp)) { + return undefined; + } else { + return resp; + } + }); + } + + service.get_staged = function(offset, count) { + return egCore.net.request( + 'open-ils.curbside', + 'open-ils.curbside.fetch_staged', + egCore.auth.token(), + egCore.auth.user().ws_ou(), + count, // yep, count first + offset + ); + }; + service.get_latest_staged = function() { + return egCore.net.request( + 'open-ils.curbside', + 'open-ils.curbside.fetch_staged.latest', + egCore.auth.token() + ).then(function(resp) { + if (evt = egCore.evt.parse(resp)) { + return undefined; + } else { + return resp; + } + }); + } + + service.get_arrived = function(offset, count) { + return egCore.net.request( + 'open-ils.curbside', + 'open-ils.curbside.fetch_arrived', + egCore.auth.token(), + egCore.auth.user().ws_ou(), + count, // yep, count first + offset + ); + }; + service.get_latest_arrived = function() { + return egCore.net.request( + 'open-ils.curbside', + 'open-ils.curbside.fetch_arrived.latest', + egCore.auth.token() + ).then(function(resp) { + if (evt = egCore.evt.parse(resp)) { + return undefined; + } else { + return resp; + } + }); + } + + service.get_delivered = function(offset, count) { + return egCore.net.request( + 'open-ils.curbside', + 'open-ils.curbside.fetch_delivered', + egCore.auth.token(), + egCore.auth.user().ws_ou(), + count, // yep, count first + offset + ); + }; + service.get_latest_delivered = function() { + return egCore.net.request( + 'open-ils.curbside', + 'open-ils.curbside.fetch_delivered.latest', + egCore.auth.token() + ).then(function(resp) { + if (evt = egCore.evt.parse(resp)) { + return undefined; + } else { + return resp; + } + }); + } + + service.mark_staged = function(slot_id) { + return egCore.net.request( + 'open-ils.curbside', + 'open-ils.curbside.mark_staged', + egCore.auth.token(), + slot_id + ); + } + service.mark_unstaged = function(slot_id) { + return egCore.net.request( + 'open-ils.curbside', + 'open-ils.curbside.mark_unstaged', + egCore.auth.token(), + slot_id + ); + } + service.mark_arrived = function(slot_id) { + return egCore.net.request( + 'open-ils.curbside', + 'open-ils.curbside.mark_arrived', + egCore.auth.token(), + slot_id + ); + } + service.mark_delivered = function(slot_id) { + return egCore.net.request( + 'open-ils.curbside', + 'open-ils.curbside.mark_delivered', + egCore.auth.token(), + slot_id + ); + } + + service.claim_staging = function(slot_id) { + return egCore.net.request( + 'open-ils.curbside', + 'open-ils.curbside.claim_staging', + egCore.auth.token(), + slot_id + ); + } + service.unclaim_staging = function(slot_id) { + return egCore.net.request( + 'open-ils.curbside', + 'open-ils.curbside.unclaim_staging', + egCore.auth.token(), + slot_id + ); + } + + service.patron_blocked = function(usr) { + if (usr.barred() == 't' || + usr.active() == 'f') { + return true; + } + var expire = Date.parse(usr.expire_date()); + if (expire < new Date()) { + return true; + } + var blocked_by_penalty = false; + angular.forEach(usr.standing_penalties(), function(penalty) { + if (blocked_by_penalty) return; + if (penalty.stop_date()) return; + if (!penalty.standing_penalty().block_list()) return; + if (penalty.standing_penalty().block_list().match(/CIRC/)) + blocked_by_penalty = true; + }); + return blocked_by_penalty; + } + + return service; +}]) + +.directive('egCurbsideHoldsList', function() { + return { + restrict : 'E', + transclude: true, + templateUrl : './circ/curbside/t_holds_list', + scope : { + slot : '=', + holds : '=', + bibData : '=' + }, + controller : [ + '$scope','egCore', + function($scope , egCore) { + } + ] + } +}); 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 06ba380c2e..58e2d3aaae 100644 --- a/Open-ILS/web/js/ui/default/staff/services/navbar.js +++ b/Open-ILS/web/js/ui/default/staff/services/navbar.js @@ -122,7 +122,8 @@ angular.module('egCoreMod') egCore.org.settings([ 'ui.staff.max_recent_patrons', - 'ui.staff.angular_catalog.enabled' + 'ui.staff.angular_catalog.enabled', + 'circ.curbside' ]).then(function(s) { var val = s['ui.staff.max_recent_patrons']; $scope.showRecentPatron = val > 0; @@ -130,6 +131,8 @@ angular.module('egCoreMod') $scope.showAngularCatalog = s['ui.staff.angular_catalog.enabled']; + $scope.enableCurbside = + s['circ.curbside']; }).then(function() { // need to defer initialization of hotkeys to this point // as it depends on various settings. -- 2.43.2