<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>
<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>
<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>
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
<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">
--- /dev/null
+<!--
+ Generic confirmation dialog
+-->
+<div>
+ <div class="modal-header">
+ <button type="button" class="close"
+ ng-click="cancel()" aria-hidden="true">×</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>
* 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) {
angular.module('egPatronApp')
.factory('billSvc',
- ['$q','egCore',
-function($q , egCore) {
+ ['$q','egCore','patronSvc',
+function($q , egCore , patronSvc) {
var service = {};
.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;
}])
* 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 {
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.
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();
+ }
+ }
}])
// 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
delete $scope.autoFields;
}
+
if (!grid.dataProvider) {
grid.selfManagedData = true;
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();
}
// 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
);
}
+ 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 = {};
// 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
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);
- }
- }
+ });
}
}
}])
/**
+ * 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() {