payments UI now accepts payments
authorBill Erickson <berick@esilibrary.com>
Wed, 21 May 2014 16:37:38 +0000 (12:37 -0400)
committerBill Erickson <berick@esilibrary.com>
Wed, 21 May 2014 16:37:38 +0000 (12:37 -0400)
Signed-off-by: Bill Erickson <berick@esilibrary.com>
Open-ILS/src/templates/staff/circ/patron/index.tt2
Open-ILS/src/templates/staff/circ/patron/t_bills.tt2
Open-ILS/src/templates/staff/circ/patron/t_bills_list.tt2
Open-ILS/src/templates/staff/css/style.css.tt2
Open-ILS/src/templates/staff/share/t_autogrid.tt2
Open-ILS/src/templates/staff/share/t_prompt_dialog.tt2 [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/circ/patron/app.js
Open-ILS/web/js/ui/default/staff/circ/patron/bills.js
Open-ILS/web/js/ui/default/staff/services/grid.js
Open-ILS/web/js/ui/default/staff/services/ui.js

index c17bce0..f4d2fab 100644 (file)
@@ -27,8 +27,7 @@
 
 <script>
 angular.module('egCoreMod').run(['egStrings', function(s) {
-  s.OPT_IN_DIALOG = 
-"[% l("Does patron {{user.family_name()}}, {{user.first_given_name()}} from {{org.name()}} ({{org.shortname()}}) consent to having their personal information shared with your library?") %]"
+  s.ANNOTATE_PAYMENT_MSG = "[% l('Please annotate this payment') %]";
 }]);
 </script>
 
index 48a050a..0c57c78 100644 (file)
     <div class="row"><hr/></div>
     <div class="row">
       <div class="col-md-4">[% l('Owed for Selected:') %]</div>
-      <div class="col-md-2">{{owed_selected | currency}}</div>
+      <div class="col-md-2">{{owed_selected() | currency}}</div>
       <div class="col-md-4">[% l('Pending Payment:') %]</div>
       <div class="col-md-2 strong-text">{{pending_payment() | currency}}</div>
     </div>
     <div class="row">
       <div class="col-md-4">[% l('Billed for Selected:') %]</div>
-      <div class="col-md-2">{{billed_selected | currency}}</div>
+      <div class="col-md-2">{{billed_selected() | currency}}</div>
       <div class="col-md-4">[% l('Pending Change:') %]</div>
       <div class="col-md-2 strong-text">{{pending_change() | currency}}</div>
     </div>
     <div class="row">
       <div class="col-md-4">[% l('Paid for Selected:') %]</div>
-      <div class="col-md-2">{{paid_selected | currency}}</div>
+      <div class="col-md-2">{{paid_selected() | currency}}</div>
     </div>
   </div><!-- col -->
 
           <label for="type-input" class="col-md-6 control-label">[% l('Payment Type') %]</label>
           <div class="col-md-6">
             <select ng-model="payment_type" class="form-control">
-              <option value="cash" selected="selected">[% l('Cash') %]</option>
-              <option value="check" selected="selected">[% l('Check') %]</option>
+              <option value="cash_payment" selected="selected">[% l('Cash') %]</option>
+              <option value="check_payment">[% l('Check') %]</option>
+              <option value="credit_card_payment">[% l('Credit Card') %]</option>
+              <option value="credit_payment">[% l('Patron Credit') %]</option>
+              <option value="work_payment">[% l('Work') %]</option>
+              <option value="forgive_payment">[% l('Forgive') %]</option>
+              <option value="goods_payment">[% l('Goods') %]</option>
             </select>
           </div>
         </div>
             [% l('Payment Received') %]
           </label>
           <div class="col-md-6">
-            <input type="number" min="0" id="amount-input" ng-model="payment_amount"
-              focus-me="focus_payment" value="0" class="form-control col-md-6 "/>
+            <input type="number" min="0" step="any" id="amount-input" 
+              ng-model="payment_amount" focus-me="focus_payment" 
+              value="0" class="form-control col-md-6 "/>
           </div>
         </div>
-        <div class="form-group xpull-right">
-          <label for="annotate-payment" class="control-label col-md-6">[% l('Annotate') %]</label>
-          <div class="col-md-6">
+        <div class="form-group">
+          <label for="annotate-payment" class="control-label col-md-5">[% l('Annotate') %]</label>
+          <div class="col-md-1">
             <input id="annotate-payment" type="checkbox" ng-model="annotate_payment"/>
           </div>
-        </div>
-        <div class="form-group">
-          <div class="col-md-6 col-md-offset-6">
+          <div class="col-md-6">
             <button type="submit" class="btn btn-default">[% l('Apply Payment') %]</button>
           </div>
         </div>
-
       </fieldset>
     </form>
   </div>
-
 </div>
 
-
 <div class="pad-vert">
 [% INCLUDE 'staff/circ/patron/t_bills_list.tt2' %]
 </div>
index 5392af1..2f59fde 100644 (file)
@@ -2,11 +2,24 @@
 <eg-grid
   idl-class="mobts"
   query="gridQuery"
-  main-label="[% l('Patron Bills') %]"
   on-item-retrieved="gridItemRetrieved"
   on-item-selected="gridItemSelected"
+  revision="gridRevision"
   persist-key="circ.patron.bills">
 
+  <eg-grid-menu-item label="[% l('Bill Patron') %]" 
+    handler="billPatron"></eg-grid-menu-item>
+
+  <eg-grid-menu-item label="[% l('History') %]" 
+    handler="showHistory"></eg-grid-menu-item>
+
+  <eg-grid-menu-item label="[% l('Check All Refunds') %]" 
+    handler="selectRefunds"></eg-grid-menu-item>
+
+  <eg-grid-menu-item label="[% l('Print Bills') %]" 
+    handler="printBills"></eg-grid-menu-item>
+
+
   <eg-grid-field label="[% ('Balance Owed') %]" path='balance_owed'></eg-grid-field>
   <eg-grid-field label="[% ('Bill #') %]" path='id'></eg-grid-field>
   <eg-grid-field label="[% ('Start') %]" path='xact_start'></eg-grid-field>
index e040782..e848693 100644 (file)
@@ -305,6 +305,10 @@ table.list tr.selected td {
   cursor: e-resize;
 }
 
+.eg-grid-menu-item {
+  margin-right: 10px;
+}
+
 
 /* hack to make the header columns line up with the content columns
    when the scroll bar is visible along the right side of the content
index 7960624..237a5fc 100644 (file)
@@ -6,11 +6,9 @@
 
 <div class="eg-grid-row eg-grid-action-row">
 
-  <div style="flex:1">
-    <div class="eg-grid-primary-label">{{mainLabel}}</div>
-  </div>
+  <div class="eg-grid-primary-label">{{mainLabel}}</div>
 
-  <div class="btn-group" ng-if="menuLabel" dropdown style="margin-right: 10px">
+  <div class="btn-group eg-grid-menuiitem" ng-if="menuLabel" dropdown>
     <button type="button" class="btn btn-default dropdown-toggle">
       {{menuLabel}}<span class="caret"></span>
     </button>
     </ul>
   </div>
 
-  <!-- if we have a single menu item, present it as a single button -->
-  <button class="btn btn-default" style="margin-right: 10px"
-    ng-if="!menuLabel && menuItems.length == 1"
-    ng-click="menuItems[0].handler(menuItems[0], menuItems[0].handlerData)">
-    {{menuItems[0].label}}
-  </button>
+  <!-- if no menu label is present, present menu-items as a 
+       horizontal row of buttons -->
+  <div class="btn-group" ng-if="!menuLabel">
+    <button class="btn btn-default eg-grid-menu-item"
+      ng-repeat="item in menuItems"
+      ng-click="item.handler(item, item.handlerData)">
+      {{item.label}}
+    </button>
+  </div>
+
+  <!-- putting a flex div here forces the remaining content to float right -->
+  <div class="flex-cell"></div>
 
   <!-- column picker, pager, etc. -->
   <div class="btn-group column-picker">
diff --git a/Open-ILS/src/templates/staff/share/t_prompt_dialog.tt2 b/Open-ILS/src/templates/staff/share/t_prompt_dialog.tt2
new file mode 100644 (file)
index 0000000..ce19832
--- /dev/null
@@ -0,0 +1,21 @@
+<!--
+  Generic confirmation dialog
+-->
+<div>
+  <div class="modal-header">
+    <button type="button" class="close" 
+      ng-click="cancel()" aria-hidden="true">&times;</button>
+    <h4 class="modal-title alert alert-info">{{message}}</h4> 
+  </div>
+  <div class="modal-body">
+    <div class="col-md-12">
+      <input type='text' ng-model="args.value" class="form-control" focus-me="focus"/>
+    </div>
+  </div>
+  <div class="modal-footer">
+    [% dialog_footer %]
+    <input type="submit" class="btn btn-primary" 
+      ng-click="ok()" value="[% l('OK/Continue') %]"/>
+    <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+  </div>
+</div>
index 5d265cc..75a4874 100644 (file)
@@ -4,7 +4,7 @@
  * Search, checkout, items out, holds, bills, edit, etc.
  */
 
-angular.module('egPatronApp', ['ngRoute', 'ui.bootstrap', 'ngLocale',
+angular.module('egPatronApp', ['ngRoute', 'ui.bootstrap', 
     'egCoreMod', 'egUiMod', 'egGridMod', 'egUserMod'])
 
 .config(function($routeProvider, $locationProvider, $compileProvider) {
index 4841ccf..bc74f52 100644 (file)
@@ -4,8 +4,8 @@
 angular.module('egPatronApp')
 
 .factory('billSvc', 
-       ['$q','egCore',
-function($q , egCore) {
+       ['$q','egCore','patronSvc',
+function($q , egCore , patronSvc) {
 
     var service = {};
 
@@ -16,6 +16,29 @@ function($q , egCore) {
         .then(function(summary) {return service.summary = summary})
     }
 
+    service.applyPayment = function(type, payments, note) {
+        return egCore.net.request(
+            'open-ils.circ',
+            'open-ils.circ.money.payment',
+            egCore.auth.token(), {
+                userid : service.userId,
+                note : note || '', 
+                payment_type : type,
+                payments : payments,
+                patron_credit : 0
+            },
+            patronSvc.current.last_xact_id()
+        ).then(function(resp) {
+            console.debug('payments: ' + js2JSON(resp));
+            if (evt = egCore.evt.parse(resp)) 
+                return alert(evt);
+
+            // payment API returns the update xact id so we can track it
+            // for future payments without having to refresh the user.
+            patronSvc.current.last_xact_id(resp.last_xact_id);
+        });
+    }
+
     return service;
 }])
 
@@ -24,24 +47,27 @@ function($q , egCore) {
  * Manages Bills
  */
 .controller('PatronBillsCtrl',
-       ['$scope','$q','$routeParams','$locale','egCore','egGridDataProvider','billSvc',
-function($scope,  $q , $routeParams,  $locale , egCore , egGridDataProvider , billSvc) {
+       ['$scope','$q','$routeParams','$locale','egCore','egGridDataProvider','billSvc','egPromptDialog',
+function($scope,  $q , $routeParams,  $locale , egCore , egGridDataProvider , billSvc , egPromptDialog) {
     $scope.initTab('bills', $routeParams.id);
     billSvc.userId = $routeParams.id;
 
+    // set up some defaults
     $scope.payment_amount = 0;
-    $scope.owed_selected = 0;
-    $scope.billed_selected = 0;
-    $scope.paid_selected = 0;
-    $scope.payment_type = 'cash';
+    $scope.payment_type = 'cash_payment';
     $scope.focus_payment = true;
     $scope.annotate_payment = false;
+    $scope.gridRevision = 0;
+
+    billSvc.fetchSummary().then(function(s) {$scope.summary = s});
 
+    // given a payment amount, determines how much of that is applied
+    // to selected transactions and how much is left over (change).
     function pending_payment_info() {
-        if ($scope.payment_amount >= $scope.owed_selected) {
+        if ($scope.payment_amount >= $scope.owed_selected()) {
             return {
-                payment : $scope.owed_selected,
-                change : $scope.payment_amount - $scope.owed_selected
+                payment : $scope.owed_selected(),
+                change : $scope.payment_amount - $scope.owed_selected()
             }
         } 
         return {
@@ -49,12 +75,33 @@ function($scope,  $q , $routeParams,  $locale , egCore , egGridDataProvider , bi
             change : 0
         };
     }
+
+    // calculates amount owed, billed, and paid for selected items
+    function selected_payment_info() {
+        var info = {owed : 0, billed : 0, paid : 0};
+        angular.forEach(selectedItems, function(item) {
+            info.owed   += Number(item.balance_owed);
+            info.billed += Number(item.total_owed);
+            info.paid   += Number(item.total_paid);
+        });
+        return info;
+    }
+
     $scope.pending_payment = function() {
         return pending_payment_info().payment;
     }
     $scope.pending_change = function() {
         return pending_payment_info().change;
     }
+    $scope.owed_selected = function() {
+        return selected_payment_info().owed; 
+    }
+    $scope.billed_selected = function() {
+        return selected_payment_info().billed;
+    }
+    $scope.paid_selected = function() {
+        return selected_payment_info().paid;
+    }
 
     // Avoid using parens [e.g. (1.23)] to indicate negative numbers, 
     // which is the Angular default.
@@ -70,19 +117,75 @@ function($scope,  $q , $routeParams,  $locale , egCore , egGridDataProvider , bi
         item.payment_pending = 0;
     }
 
-    $scope.gridItemSelected = function(selected, deSelected) {
-        if (selected) {
-            $scope.owed_selected   += Number(selected.balance_owed);
-            $scope.billed_selected += Number(selected.total_owed);
-            $scope.paid_selected   += Number(selected.total_paid);
-        } else {
-            $scope.owed_selected   -= Number(deSelected.balance_owed);
-            $scope.billed_selected -= Number(deSelected.total_owed);
-            $scope.paid_selected   -= Number(deSelected.total_paid);
+    var selectedItems = [];
+    $scope.gridItemSelected = function(sel, desel, all) {
+        // keep a local cache of the selected items so we can 
+        // calculate payment info from them.
+        selectedItems = all;
+    }
+
+    function generatePayments() {
+        var payments = [];
+        var paymentAmount = $scope.pending_payment();
+
+        for (var i = 0; i < selectedItems.length; i++) { // for/break
+            var item = selectedItems[i];
+            var owed = Number(item.balance_owed);
+
+            if (paymentAmount > owed) {
+                // pending payment exceeds balance of current item.
+                // pay the entire item.
+                payments.push([item.id, owed]);
+                paymentAmount -= owed;
+
+            } else {
+                // balance owed on the current item matches or exceeds
+                // the pending payment.  Apply the full remainder of
+                // the payment to this item.. and we're done.
+                payments.push([item.id, paymentAmount]);
+                break;
+            }
         }
+
+        return payments;
     }
 
-    billSvc.fetchSummary().then(function(s) {$scope.summary = s});
+    function sendPayment(note) {
+        billSvc.applyPayment(
+            $scope.payment_type, generatePayments(), note)
+        .then(function() {
+            billSvc.fetchSummary().then(function(s) {$scope.summary = s});
+            $scope.payment_amount = 0;
+            $scope.gridRevision++; // tell the grid to refresh itself
+        })
+    }
+
+    $scope.billPatron = function() {
+        // launch billing dialog
+    }
+
+    $scope.showHistory = function() {
+        // go to bills/history
+    }
+
+    $scope.selectRefunds = function() {
+        // select grid items where refunds are due
+    }
+
+    $scope.printBills = function() {
+        // print selected bills using the bills print template
+    }
+
+    $scope.applyPayment = function() {
+        if ($scope.annotate_payment) {
+            egPromptDialog.open(
+                egCore.strings.ANNOTATE_PAYMENT_MSG, 
+                {ok : function(value) {sendPayment(value)}}
+            );
+        } else {
+            sendPayment();
+        }
+    }
 
 }])
 
index a555b68..ca2b441 100644 (file)
@@ -73,7 +73,11 @@ angular.module('egGridMod',
             // function;  if set, row index values will be hyperlinked and
             // the onclick for an item will call activateItem with the item
             // as the argument.
-            activateItem : '='
+            activateItem : '=',
+
+            // if set, we watch this scope variable for changes.  If it
+            // changes, we refresh the grid.
+            revision : '='
         },
 
         // TODO: avoid hard-coded url
@@ -165,6 +169,7 @@ angular.module('egGridMod',
                     delete $scope.autoFields;
                 }
 
+   
                 if (!grid.dataProvider) {
 
                     grid.selfManagedData = true;
@@ -191,6 +196,13 @@ angular.module('egGridMod',
                     delete $scope.query;
                 }
 
+                if (angular.isDefined($scope.revision)) {
+                    $scope.$watch('revision', function(newVal, oldVal) {
+                        if (newVal != oldVal) 
+                            grid.dataProvider.load(true);
+                    });
+                }
+
                 grid.dataProvider.load = function(reset) { 
                     if (reset) grid.offset = 0;
                     grid.collect();
@@ -369,16 +381,18 @@ angular.module('egGridMod',
             }
 
             // returns the unique identifier value for the provided item
+            // for internal consistency, indexValue is always coerced 
+            // into a string.
             grid.indexValue = function(item) {
                 if (angular.isObject(item)) {
                     if (item !== null) {
                         if (angular.isFunction(item[grid.indexField]))
-                            return item[grid.indexField]();
-                        return item[grid.indexField]; // flat data
+                            return ''+item[grid.indexField]();
+                        return ''+item[grid.indexField]; // flat data
                     }
                 }
                 // passed a non-object; assume it's an index
-                return item; 
+                return ''+item; 
             }
 
             // fires the action handler function
@@ -395,6 +409,14 @@ angular.module('egGridMod',
                 );
             }
 
+            grid.getItemByIndex = function(index) {
+                for (var i = 0; i < $scope.items.length; i++) {
+                    var item = $scope.items[i];
+                    if (grid.indexValue(item) == index) 
+                        return item;
+                }
+            }
+
             // selects one row after deselecting all of the others
             grid.selectOneItem = function(index) {
                 $scope.selected = {};
@@ -467,7 +489,8 @@ angular.module('egGridMod',
             // handles click, control-click, and shift-click
             $scope.handleRowClick = function($event, item) {
                 var index = grid.indexValue(item);
-                var wasSelected = Boolean($scope.selected[index]);
+
+                var origSelected = Object.keys($scope.selected);
 
                 if ($event.ctrlKey || $event.metaKey /* mac command */) {
                     // control-click
@@ -512,17 +535,26 @@ angular.module('egGridMod',
 
 
                 if (grid.onItemSelected) {
-                    var isSelected = Boolean($scope.selected[index]);
+                    // multiple items may be selected / de-selected within a
+                    // single click action.  Find all that have changed state.
+
+                    var all = grid.getSelectedItems();
+                    
+                    // look for items that were originally selected
+                    // which are no longer selected
+                    angular.forEach(origSelected, function(index) {
+                        if (!$scope.selected[index]) {
+                            grid.onItemSelected(
+                                null, grid.getItemByIndex(index), all);
+                        }
+                    });
 
-                    if (isSelected != wasSelected) { 
-                        // something changed state; report it
-                        var all = grid.getSelectedItems();
-                        if (isSelected) {
+                    // look for items which are selected now, but were
+                    // not originally selected
+                    angular.forEach(all, function(item) {
+                        if (origSelected.indexOf(grid.indexValue(item)) < 0)
                             grid.onItemSelected(item, null, all);
-                        } else {
-                            grid.onItemSelected(null, item, all);
-                        }
-                    }
+                    });
                 }
             }
 
index 8605e26..ac090b1 100644 (file)
@@ -141,6 +141,43 @@ function($modal, $interpolate) {
 }])
 
 /**
+ * egPromptDialog.open("some message goes {{here}}", {
+ *    here : 'foo',  
+ *    ok : function(value) {console.log(value)}, 
+ *    cancel : function() {}});
+ */
+.factory('egPromptDialog', 
+    
+       ['$modal','$interpolate',
+function($modal, $interpolate) {
+    var service = {};
+
+    service.open = function(message, msg_scope) {
+        return $modal.open({
+            templateUrl: './share/t_prompt_dialog',
+            controller: ['$scope', '$modalInstance',
+                function($scope, $modalInstance) {
+                    $scope.message = $interpolate(message)(msg_scope);
+                    $scope.args = {value : ''};
+                    $scope.focus = true;
+                    $scope.ok = function() {
+                        if (msg_scope.ok) msg_scope.ok($scope.args.value);
+                        $modalInstance.close()
+                    }
+                    $scope.cancel = function() {
+                        if (msg_scope.cancel) msg_scope.cancel();
+                        $modalInstance.close()
+                    }
+                }
+            ]
+        })
+    }
+
+    return service;
+}])
+
+
+/**
  * Nested org unit selector modeled as a Bootstrap dropdown button.
  */
 .directive('egOrgSelector', function() {