diff --git a/tailbone/forms/custorders.py b/tailbone/forms/custorders.py new file mode 100644 index 00000000..f749714c --- /dev/null +++ b/tailbone/forms/custorders.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2015 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for +# more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Rattail. If not, see . +# +################################################################################ +""" +Form Objects for Customer Orders +""" + +from __future__ import unicode_literals + +from rattail import enum +from rattail.db import model + +import formencode +from formencode import validators + +from tailbone.forms.validators import ValidCustomer, ValidProduct, ValidUser + + +class ValidCustomerInfo(validators.FormValidator): + """ + Custom validator to ensure we have either a proper customer reference, or + at least a customer name, when creating a new customer order. + """ + + def validate_python(self, field_dict, state): + if not field_dict['customer'] and not field_dict['customer_name']: + raise formencode.Invalid("Customer name is required", field_dict, state) + + +class NewCustomerOrderItem(formencode.Schema): + """ + Form schema to which individual items on a new customer order must adhere. + """ + allow_extra_fields = True + + product = ValidProduct() + product_description = validators.NotEmpty() + + quantity = validators.Int() + unit_of_measure = validators.OneOf(enum.UNIT_OF_MEASURE) + discount = validators.Int() + + notes = validators.String() + + +class NewCustomerOrder(formencode.Schema): + """ + Form schema for creating a new customer order. + """ + allow_extra_fields = True + pre_validators = [formencode.NestedVariables()] + + user = formencode.Pipe(validators=[ + validators.NotEmpty(), + ValidUser()]) + + customer = ValidCustomer() + customer_name = validators.String() + customer_phone = validators.NotEmpty() + + products = formencode.ForEach(NewCustomerOrderItem()) + + chained_validators = [ValidCustomerInfo()] diff --git a/tailbone/forms/renderers/__init__.py b/tailbone/forms/renderers/__init__.py index 984e40ca..e1ad83fe 100644 --- a/tailbone/forms/renderers/__init__.py +++ b/tailbone/forms/renderers/__init__.py @@ -46,4 +46,6 @@ from .products import (GPCFieldRenderer, ScancodeFieldRenderer, BrandFieldRenderer, ProductFieldRenderer, PriceFieldRenderer, PriceWithExpirationFieldRenderer) +from .custorders import CustomerOrderFieldRenderer + from .batch import BatchIDFieldRenderer, HandheldBatchFieldRenderer diff --git a/tailbone/forms/renderers/core.py b/tailbone/forms/renderers/core.py index ac80404a..f64e33f1 100644 --- a/tailbone/forms/renderers/core.py +++ b/tailbone/forms/renderers/core.py @@ -60,16 +60,24 @@ class CustomFieldRenderer(fa.FieldRenderer): return self.request.rattail_config -class DateFieldRenderer(CustomFieldRenderer, fa.DateFieldRenderer): +class DateFieldRenderer(CustomFieldRenderer): """ Date field renderer which uses jQuery UI datepicker widget when rendering in edit mode. """ + date_format = None change_year = False - def init(self, change_year=False): + def init(self, date_format=None, change_year=False): + self.date_format = date_format self.change_year = change_year + def render_readonly(self, **kwargs): + value = self.raw_value + if value is None: + return '' + return value.strftime(self.date_format) + def render(self, **kwargs): kwargs['name'] = self.name kwargs['value'] = self.value diff --git a/tailbone/forms/renderers/custorders.py b/tailbone/forms/renderers/custorders.py new file mode 100644 index 00000000..5b5794f6 --- /dev/null +++ b/tailbone/forms/renderers/custorders.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2016 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for +# more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Rattail. If not, see . +# +################################################################################ +""" +Customer order field renderers +""" + +from __future__ import unicode_literals, absolute_import + +import formalchemy as fa +from webhelpers.html import tags + + +class CustomerOrderFieldRenderer(fa.fields.SelectFieldRenderer): + """ + Renders a link to the customer order + """ + + def render_readonly(self, **kwargs): + order = self.raw_value + if not order: + return '' + return tags.link_to(order, self.request.route_url('custorders.view', uuid=order.uuid)) diff --git a/tailbone/newgrids/core.py b/tailbone/newgrids/core.py index 0beba6de..ad2985ce 100644 --- a/tailbone/newgrids/core.py +++ b/tailbone/newgrids/core.py @@ -517,6 +517,7 @@ class Grid(object): Render the complete grid, including filters. """ kwargs['grid'] = self + kwargs.setdefault('div_class', '') kwargs.setdefault('allow_save_defaults', True) return render(template, kwargs) diff --git a/tailbone/static/css/custorders.new.css b/tailbone/static/css/custorders.new.css new file mode 100644 index 00000000..bf6122ed --- /dev/null +++ b/tailbone/static/css/custorders.new.css @@ -0,0 +1,56 @@ + +#order-tabs { + margin-top: 25px; +} + +.field-wrapper.update-phone { + display: none; + overflow: visible; +} + +.field-wrapper.customer-notes { + display: none; +} + +#customer-notes { + border: 1px solid black; + padding: 5px; + width: 320px; +} + +#order-note-text { + height: 200px; + width: 85%; +} + +.customer-info, +.product-info { + margin: 15px 0px; +} + +.customer-info > div, +.product-info > div { + padding-left: 15px; + overflow: auto; +} + +.buttons { + margin-top: 20px; +} + +#items { + width: 100%; +} + +#items tbody td.price { + text-align: right; +} + +#item-dialog #item-quantity-tab { + overflow: auto; +} + +#item-dialog #item-note-tab textarea { + height: 210px; + width: 100%; +} diff --git a/tailbone/static/js/custorders.new.js b/tailbone/static/js/custorders.new.js new file mode 100644 index 00000000..464e9dfa --- /dev/null +++ b/tailbone/static/js/custorders.new.js @@ -0,0 +1,1037 @@ + +/********************************************************************** + * JS for New Customer Order page + **********************************************************************/ + +/********************************************************************** + * Global Variables / Config + **********************************************************************/ + +/* + * Keep global references to some Backbone objects. These will be instantiated + * on document load. + */ +var App, ItemDialog, OrderItems; + +/* + * Keep track of which phone number was provided by customer's database record, + * so we can determine if the user has truly provided a different one. + */ +var customer_phone_stored = null; + +/* + * The default template syntax for underscore.js conflicts with Mako, so we + * must override it here. + */ +_.templateSettings = { + interpolate: /\{\{(.+?)\}\}/g +}; + +/* + * Prompt user if they attempt to leave this page before creating the order. + */ +var okay_to_leave = true; // FIXME + +window.onbeforeunload = function() { + if (! okay_to_leave) { + return "You have unsaved changes, are you sure you want to leave this page?"; + } +} + + +/********************************************************************** + * Functions + **********************************************************************/ + +/* + * Convenience function which updates the customer phone autocomplete elements, + * whenever a customer is identified via either name or phone autocomplete. + */ +function update_customer_phone(data) { + $('#customer_phone_known-textbox').autocomplete('option', 'disabled', true); + $('#customer_phone_known').val(data.phone_number); + $('#customer_phone_known-textbox').val(data.phone_number); + $('#customer_phone_known-textbox').select(); + $('#customer_phone_known-textbox').focus(); + customer_phone_stored = data.phone_number; + if (customer_phone_stored == '') { + $('div.field-wrapper.update-phone').show(); + $('#update-phone').prop('checked', true); + $('#update-phone').attr('disabled', true); + } + if (data.note) { + $('#customer-notes').text(data.note); + $('div.field-wrapper.customer-notes').show(); + } +} + + +/* + * This is the callback function for the customer name autocomplete field. + */ +function customer_selected(uuid, name) { + // FIXME url + $.get(urls.customer_info, {'uuid': uuid}, function(data) { + update_customer_phone(data); + }); +} + + +/* + * This is the callback function for when the user clicks the "change" button + * next to the customer name autocomplete field. + */ +function clear_customer() { + $('#customer_phone_known').val(''); + $('#customer_phone_known-textbox').val(''); + $('#customer_phone_known-textbox').autocomplete('option', 'disabled', false); + $('div.field-wrapper.update-phone').hide(); + $('#update-phone').prop('checked', false); + $('#update-phone').attr('disabled', false); + customer_phone_stored = null; + $('div.field-wrapper.customer-notes').hide(); + $('#customer-notes').text(''); +} + + +/* + * This is the callback function for the customer phone autocomplete field. + */ +function customer_phone_selected(event, ui) { + var uuid = ui.item.value; + // FIXME url + $.get(urls.customer_info, {'uuid': uuid}, function(data) { + update_customer_phone(data); + $('#customer_uuid').val(uuid); + $('#customer_uuid-display span:first').text(data.name); + $('#customer_uuid-textbox').hide(); + $('#customer_uuid-display').show(); + }); + return false; +} + + +/* + * This function determines if a given phone number is valid, i.e. contains + * exactly 7 or 10 digits. + */ +function validate_phone_number(text) { + var digits = text.replace(/\D/g, ''); + return (digits.length == 7) || (digits.length == 10); +} + + +// /* +// * This function prompts an employee to login, if there is no user logged in. +// */ +// function authenticate_employee() { +// var dialog = $('#employee-login-dialog'); +// if (! dialog.length) { +// dialog = $('
', { +// id: 'employee-login-dialog' +// }); +// } + +// function init_dialog() { +// dialog.find('input[type="submit"]').button(); +// dialog.find('button').button(); + +// dialog.find('form').submit(function() { +// $.post($(this).attr('action'), $(this).serialize(), function(data) { +// if (typeof(data) == 'object') { +// if (data.result == 'success') { +// current_user = data.user_uuid; +// dialog.dialog('close'); +// submit_form(); +// } else if (data.result == 'no_user') { +// var msg = "Hello, " + data.employee_name + ".\n\n" +// + "I see you are an employee, but you do not yet have a Dtail user account.\n\n" +// + "You will not be able to create special orders until you have this. Please\n" +// + "talk to the IT department so they can create your account.\n\n"; +// alert(msg); +// dialog.load(urls.employee_login, function() { +// init_dialog(); +// }); +// } else { +// alert("Unexpected result:\n\n" + data.result); +// } +// } else { +// dialog.html(data); +// init_dialog(); +// } +// }); +// return false; +// }); + +// dialog.find('button.cancel').click(function() { +// dialog.dialog('close'); +// }); + +// with (dialog.find('#employee')) { +// select(); +// focus(); +// } +// } + +// dialog.load(urls.employee_login, function() { +// init_dialog(); +// dialog.dialog({ +// title: "Employee Login", +// width: 300, +// height: 225 +// }); +// }); +// } + + +/* + * This function assembles the data hash used to submit the special order(s) to + * the server for creation. + */ +function collect_data() { + var data = {user: current_user}; + + if ($('#customer_exists_true').is(':checked')) { + data.customer = $('#customer_uuid').val(); + data.customer_name = null; + data.customer_phone = $('#customer_phone_known-textbox').val(); + if ($('#update-phone').is(':checked')) { + data.update_customer_phone = 'true'; + } + } else { + data.customer = null; + data.customer_name = $('#customer_name').val(); + data.customer_phone = $('#customer_phone_unknown').val(); + } + + // We must use FormEncode-compatible keys for the product data. It would + // be nice if we could just send nested JSON data structures, but oh well. + var i = 0; + OrderItems.each(function(item) { + data['item-' + i + '.product'] = item.get('product_uuid'); + data['item-' + i + '.product_brand'] = item.get('product_brand'); + data['item-' + i + '.product_description'] = item.get('product_description'); + data['item-' + i + '.product_size'] = item.get('product_size'); + data['item-' + i + '.quantity'] = item.get('quantity'); + data['item-' + i + '.unit_of_measure'] = item.get('unit_of_measure'); + data['item-' + i + '.discount'] = item.get('discount'); + data['item-' + i + '.note'] = item.get('note'); + ++i; + }); + + return data; +} + + +/* + * This function finally submits the form data to create the new customer order. + */ +function submit_form() { + $('body').mask("Creating Order..."); + // FIXME url + $.post(urls.create_orders, collect_data(), function(data) { + if (data.result == 'success') { + okay_to_leave = true; + // location.href = urls.orders_created; // FIXME url + location.href = urls.create_orders; // FIXME url + } else { + alert("Unexpected result:\n\n" + data.result.toString()); + } + }); +} + + +/********************************************************************** + * Document Load Event + **********************************************************************/ + +$(function() { + + /************************************************************ + * OrderItem model + ************************************************************/ + + var OrderItem = Backbone.Model.extend({ + + defaults: function() { + return { + product_exists: true, + product_uuid: null, + product_brand: '', + // product_description: default_ambiguous_product_description, + product_description: '', // FIXME + product_size: '', + product_unit_of_measure: null, + quantity: 1, + unit_of_measure: null, + // discount: default_discount, + discount: 0, // FIXME + note: '' + }; + }, + + validate: function(attrs, options) { + var value; + + if (attrs.product_exists !== undefined) { + + if (attrs.product_exists) { + if (! attrs.product_uuid) { + return { + attr: 'product_uuid', + error: "Product is required." + }; + } + + } else { + if (! attrs.product_description) { + return { + attr: 'product_description', + error: "Product description is required." + }; + } + } + } + + if (attrs.quantity !== undefined) { + value = parseInt(attrs.quantity); + if (isNaN(value) || value != attrs.quantity) { + return { + attr: 'quantity', + error: "Quantity must be numeric." + }; + } + attrs.quantity = value; + } + + if (attrs.discount !== undefined) { + if (attrs.discount) { + value = parseInt(attrs.discount); + if (isNaN(value) || value != attrs.discount || value < 0 || value > 100) { + return { + attr: 'discount', + error: "Discount must be a number between 0 and 100." + }; + } + } + attrs.discount = value; + } + } + + }); + + + /* + * We use local storage for the OrderItem collection; this avoids + * cluttering the user's session (or worse, the database) on the server. + */ + var OrderItemList = Backbone.Collection.extend({ + + model: OrderItem, + + localStorage: new Backbone.LocalStorage('tailbone.new_customer_order') + + }); + + // Create global instance of item list. + OrderItems = new OrderItemList(); + + + /* + * The editable view of an OrderItem takes the form of a dialog. + */ + var OrderItemDialogView = Backbone.View.extend({ + + el: $('#item-dialog'), + + /* + * Cached product attributes; fetched whenever a product is selected in + * the autocomplete field. + */ + productAttrs: {}, + + isVisible: function() { + return this.$el.is(':visible'); + }, + + setModel: function(model) { + if (this.model) { + this.stopListening(this.model); + } + if (model === undefined) { + model = new OrderItem(); + } + this.model = model; + this.listenTo(this.model, 'invalid', this.showValidationError); + }, + + showValidationError: function(model) { + var attr = model.validationError.attr; + if (attr == 'product_uuid') { + attr = 'product_uuid-textbox'; + } + alert(model.validationError.error); + with (this.$('#' + attr)) { + select(); + focus(); + } + }, + + updateDisplay: function() { + var that = this; + + this.$('#product_uuid-textbox').val(''); + this.$('#upc-textbox').val(''); + + if (this.model.get('product_exists')) { + this.$('#product_exists_true').click(); + + if (this.model.get('product_uuid')) { + this.$('#product_uuid').val(this.model.get('product_uuid')); + + // Mimic the pretty description returned by autocomplete. + var words = []; + if (this.model.get('product_brand')) { + words.push(this.model.get('product_brand')); + } + if (this.model.get('product_description')) { + words.push(this.model.get('product_description')); + } + if (this.model.get('product_size')) { + words.push(this.model.get('product_size')); + } + this.$('#product_uuid-display span:first').text(words.join(' ')); + + this.$('#product_uuid-textbox').hide(); + this.$('#product_uuid-display').show(); + + this.$('#upc-input').hide(); + var upc = this.model.get('product_upc'); + upc = upc.slice(0, -1) + '-' + upc.slice(-1); + this.$('#upc-display span:first').text(upc); + this.$('#upc-display').show(); + + } else { + this.$('#product_uuid').val(''); + this.$('#product_uuid-display span:first').text(''); + this.$('#product_uuid-display').hide(); + this.$('#product_uuid-textbox').show(); + this.$('#upc-display span:first').text(''); + this.$('#upc-display').hide(); + this.$('#upc-input').show(); + } + + // this.$('#product_description').val(default_ambiguous_product_description); + + } else { + this.$('#product_exists_false').click(); + + this.$('#product_uuid').val(''); + this.$('#product_uuid-display span:first').text(''); + this.$('#product_uuid-display').hide(); + this.$('#product_uuid-textbox').show(); + + this.$('#upc-display span:first').text(''); + this.$('#upc-display').hide(); + this.$('#upc-input').show(); + + this.$('#product_description').val(this.model.get('product_description')); + } + + this.$('#quantity').val(this.model.get('quantity')); + this.$('#discount').val(this.model.get('discount')); + this.$('#note').val(this.model.get('note')); + + var updateMoreStuff = function() { + that.updateUOMChoices(); + + // Unit of measure selection must happen after we're certain + // the available choices are accurate. + that.$('#unit_of_measure').val(that.model.get('unit_of_measure')); + + that.updateDiscountField(); + that.updatePriceFields(); + } + + if (this.model.get('product_exists') && this.model.get('product_uuid')) { + this.fetchProductAttrs(this.model.get('product_uuid'), updateMoreStuff); + } else { + updateMoreStuff(); + } + }, + + updateModel: function() { + var attrs = {}; + + if (this.$('#product_exists_true').is(':checked')) { + attrs.product_exists = true; + attrs.product_uuid = this.$('#product_uuid').val(); + if (attrs.product_uuid) { + _.extend(attrs, this.productAttrs); + } + } else { + attrs.product_exists = false; + attrs.product_uuid = null; + attrs.product_brand = ''; + attrs.product_description = $.trim(this.$('#product_description').val()); + attrs.product_size = ''; + } + + attrs.quantity = this.$('#quantity').val(); + attrs.unit_of_measure = this.$('#unit_of_measure').val(); + attrs.discount = this.$('#discount').val(); + + // Set core attributes requiring validation first. + if (! this.model.set(attrs, {validate: true})) { + return false; + } + + this.model.set({ + quantity_display: attrs.quantity + " " + this.$('#unit_of_measure option:selected').text(), + price_display: this.$('#price_after_discount').val(), + note: this.$('#note').val() + }); + + return true; + }, + + showDialog: function(model) { + var that = this; + + this.setModel(model); + this.updateDisplay(); + + options = { + title: this.model.isNew() ? "Add Item" : "Edit Item", + width: 700, + height: 500, + buttons: [ + { + text: "OK", + click: function() { + if (that.updateModel()) { + if (that.model.isNew()) { + OrderItems.create(that.model); + } + that.$el.dialog('close'); + } + } + }, + { + text: "Cancel", + click: function() { + that.$el.dialog('close'); + } + } + ] + }; + + this.$el.dialog(options); + this.$('#item-tabs').tabs('option', 'active', 0); + this.setFocus(); + }, + + setFocus: function(panel) { + if (panel == undefined) { + panel = this.$('#item-product-tab'); + if (! panel.is(':visible')) { + panel = this.$('#item-quantity-tab'); + if (! panel.is(':visible')) { + panel = this.$('#item-note-tab'); + } + } + } + + if (panel.attr('id') == 'item-product-tab') { + if (this.$('#product_exists_true').is(':checked')) { + if (! this.$('#product_uuid').val()) { + with (this.$('#product_uuid-textbox')) { + select(); + focus(); + } + } + } else { + with (this.$('#product_description')) { + select(); + focus(); + } + } + + } else if (panel.attr('id') == 'item-quantity-tab') { + with (this.$('#quantity')) { + select(); + focus(); + } + } else { // item-note-tab + with (this.$('#note')) { + select(); + focus(); + } + } + }, + + fetchProductAttrs: function(uuid, success) { + var that = this; + // FIXME url + $.get(urls.product_info, {uuid: uuid}, function(data) { + that.productAttrs = data; + if (success !== undefined) { + success(data); + } + }); + }, + + productSelected: function(uuid) { + var that = this; + this.fetchProductAttrs(uuid, function(data) { + var upc = data.product_upc; + upc = upc.slice(0, -1) + '-' + upc.slice(-1); + that.$('#upc-display span:first').text(upc); + that.$('#upc-input').hide(); + that.$('#upc-display').show(); + that.updateUOMChoices(); + that.updateDiscountField(); + that.updatePriceFields(); + }); + }, + + productCleared: function() { + this.$('#upc-textbox').val(''); + this.$('#upc-display').hide(); + this.$('#upc-input').show(); + }, + + updateUOMChoices: function(product_exists, product_uuid) { + var uom = this.$('#unit_of_measure'); + uom.empty(); + + if (product_exists === undefined) { + product_exists = this.$('#product_exists_true').is(':checked'); + } + if (product_exists && product_uuid === undefined) { + product_uuid = this.$('#product_uuid').val(); + } + + var uoms = null; + if (! product_exists) { + uoms = default_units_of_measure; + } else if (product_uuid) { + uoms = this.productAttrs.units_of_measure; + } + + if (uoms) { + _.each(uoms, function(pair) { + uom.append($('