CRM 2013 Editable Pricing Grid

Problem

Microsoft Dynamics CRM 2013 requires users to navigate to multiple streams in order to add pricing to products and services for any given opportunity. For companies which break out multiple services to sell to clients, this takes a significant amount of time and frustrates their sales staff.

Technologies

Assumptions/Dependencies

  • Experience with developing Microsoft Dynamics CRM 2013 or the ability to learn.
  • Warning: CRM development is a pain in the butt; it is not for the faint of heart. Microsoft does not provide a style guide or reusable scripts to provide the same functionality for edit controls. Reverse engineering was the only solution I was able to come up with.
  • Experience or familiarity with web development, jquery, and databinding with frameworks like AngularJS.
  • Use the version of jQuery that comes with CRM 2013
  • Download jQuery UI version 1.9.2 including the companion CSS file
  • Download AngularJS 1.3
  • A default price list has been created with associated services.
  • A custom field needs to be added to products called new_TransactionType with options for One-Time Fee, Monthly Fee, or Per Item.

Solution

I created an inline editable grid to preload available products and services as well as enter pricing for those services without leaving the opportunity form. The records below are records in the Opportunity Pricing entity which is a child relationship from an Opportunity. The solution I created looks like this…

You can click on any cell in this grid and modify the value with the exception of the Annual Revenue. It is automatically calculated based on the Frequency, Unit Price, and Monthly Volume entered.

  • To start, create a new CRM project in Visual Studio 2012.

  • Now, right click on the project, and select Add > New Item…
  • Select HTML Page and enter a name for the page. (i.e. new_PricingEditor.html)

Follow the same steps as adding the HTML page to add the following files:

  • Style Sheet:        new_PricingStyles.css
  • Style Sheet:        new_jquery_ui_1.9.2.custom.min.css
  • Jscript File:        new_PricingAngularScript.js
  • Jscript File:        new_PricingInlineEdit.js
  • Jscript File:        new_PricingMessage.js
  • Jscript File:        new_PricingModel.js
  • Jscript File:        new_angular.min.js
  • Jscript File:        new_jquery_ui_1.9.2.custom.min.js

For the jQuery and Angular javascript and CSS files, copy and pasted the content downloaded (per Assumptions above) and paste into the appropriate files that you just created (i.e. new_jquery_ui_1.9.2.custom.min.css, new_jquery_ui_1.9.2.custom.min.js, and new_angular.min.js)

Loading AngularJS

Open the new_PricingAngularScript.js file. The following line is added at the top so that we can use the $scope object reference later in a separate thread

var $pricingScope = null;

At the bottom, add the following lines to set up the AngularJS application and controller…

var pricingApplication = angular.module(“PricingApplication”, []);

pricingApplication.controller(“PricingController”, PricingController);

The Controller

In the controller, we do the following…

  • Get a reference to the $scope and save it for later
  • Set the PricingModel reference used for binding to the grid
  • Set the PricingSorter reference to use for sorting the grid
  • Load the UnitOfMeasure lookup record used when saving a service/product
  • Load the products/services we’re going to populate in a drop down from which to choose to add pricing to the opportunity
  • Bind to the Opportunity save event if it’s a new opportunity so that we can preload all of the services to save the user from manually adding them.
  • If the Opportunity is an existing one, then we simply load the services already associated with the Opportunity and the pricing information.
function PricingController($scope) {
    try {
        $pricingScope = $scope;
        
        $scope.PricingModel = PricingModel;
        $scope.PricingSorter = PricingSorter;
        
        UnitOfMeasureLoader.Load();

        ProductsLoader.Load();

        if (PricingModel.getOpportunityId() == null || PricingModel.getOpportunityId() == "") {
            parent.Xrm.Page.data.entity.addOnSave(OpportunitySaved);
        } else {
            PricingLoader.Load();
        }

    } catch (exception) {
        PricingMessage.displayError(exception);
    }
}


Using the XrmServiceToolkit, we load the UnitOfMeasure entity to use later when saving services with the Opportunity. In this case, I filter for the “Primary Unit” unit of measure record. It is a required field when saving the opportunity pricing.

var UnitOfMeasureLoader = {
    Load: function () {
        XrmSvcToolkit.retrieveMultiple({
            entityName: "UoM",
            odataQuery: "?$filter=Name eq 'Primary Unit'",
            async: true,
            successCallback: function (result) {
                if (result == null || result.length == null || result.length <= 0) {
                    PricingMessage.displayWarning("No units of measure were found in order to add new pricings.");
                    return;
                }
	
                PricingModel.UnitOfMeasure = result[0];
            },
            errorCallback: function(error) {
                PricingMessage.displayError(error);
            }
        });
    }
}

Using the XrmServiceToolkit, we load the list of products/services in the desired Price List to use in the drop down so users can select which product/service to add pricing to. I pull from the Price Level entity and filter by a price list called “Opportunity Product Price List” as well as only fetching active products/services. This query also really expands on the Price Level Products entity, which is what really contains the product names, the transaction type custom field (i.e. One Time Fee, Monthly Fee, etc.), etc. I load these products into a “ServicesOptions” array in the PricingModel.

var ProductsLoader = {
    Load: function() {
        XrmSvcToolkit.retrieveMultiple({
            entityName: "PriceLevel",
            odataQuery: "?$select=Name,PriceLevelId,price_level_products/ProductNumber,price_level_products/ProductId,price_level_products/Name,price_level_products/new_TransactionType&$expand=price_level_products&$filter=Name eq 'Opportunity Product Price List' and StateCode/Value eq 0&$orderby=Name",
            async: false,
            successCallback: function (result) {
                try {
                    if (result == null || result.length == null || result.length <= 0) {
                        PricingMessage.displayWarning("No products found from which to choose for pricing");
                        return;
                    }
	
                    var products = result[0].price_level_products.results;

                    for (var i = 0; i < products.length; i++) {
                        var product = products[i];
                        PricingModel.ServiceOptions.push({ Id: product.ProductId, Name: product.Name, TransactionType: product.new_TransactionType.Value });
                    }

                } catch (exception) {
                    PricingMessage.displayError("Error getting products from price list.  " + exception);
                }
            },
            errorCallback: function(error) {
                PricingMessage.displayError(error);
            }
        });


    }
}

The PricingLoader class also uses the XrmServiceToolkit to load services already containing pricing information for the Opportunity (for existing Opportunities). The PricingLoader also contains a function to load the services for a new Opportunity. Here we load the opportunity pricing (services) into a Services array in the PricingModel.

var PricingLoader = {
    Load: function () {
        if (PricingModel.getOpportunityId() == null || PricingModel.getOpportunityId() == "")
            return;
	
        XrmSvcToolkit.retrieveMultiple({
            entityName: "OpportunityProduct",
            odataQuery: "?$filter=OpportunityId/Id eq (guid'" + PricingModel.getOpportunityId() + "')",
            async: false,
            successCallback: function(result) {
                if (result == null || result.length == null || result.length <= 0) {
                    PricingLoader.AddDefaultServices();
                    return;
                }

                for (var i = 0; i < result.length; i++) {
                    var pricing = result[i];

                    if (pricing.PricePerUnit.Value == null || pricing.PricePerUnit.Value == "")
                        pricing.PricePerUnit.Value = "0";
                    if (pricing.new_MonthlyVolume == null || pricing.new_MonthlyVolume == "")
                        pricing.new_MonthlyVolume = "0";
                    if (pricing.Quantity == null || pricing.Quantity == "")
                        pricing.Quantity = "0";
                    if (pricing.ExtendedAmount.Value == null || pricing.ExtendedAmount.Value == "")
                        pricing.ExtendedAmount.Value = "0";

                    var product = PricingModel.GetService(pricing.ProductId.Id);

                    var service = Object.create(ServiceModel);
                    service.Id = pricing.OpportunityProductId;
                    service.Service = product;
                    service.OriginalService = product;
                    service.Frequency = PricingModel.GetFrequency(pricing.new_TransactionType.Value);
                    service.UnitPrice = parseFloat(pricing.PricePerUnit.Value);
                    service.MonthlyVolume = parseInt(pricing.new_MonthlyVolume);
                    service.AnnualVolume = parseInt(pricing.Quantity);
                    service.AnnualRevenue = parseFloat(pricing.ExtendedAmount.Value);
                    
                    PricingModel.Services.push(service);
                }

                PricingModel.SortByServiceName();
            },
            errorCallback: function(error) {
                PricingMessage.displayError(error);
            }
        });
    },
    AddDefaultServices: function() {
        for (var i = 0; i < PricingModel.ServiceOptions.length; i++) {
            var serviceOption = PricingModel.ServiceOptions[i];

            var service = Object.create(ServiceModel);
            service.Service = serviceOption;
            service.Frequency = PricingModel.GetFrequency(serviceOption.TransactionType);

            PricingModel.Services.push(service);
        }

        PricingModel.SortByServiceName();
    }
}

Finally, this function is used when the Opportunity is saved to automatically populate the Services array in the PricingModel so that the grid is automatically populated with the services so the user doesn’t have to add them…

function OpportunitySaved(execContext) {
    $pricingScope.$apply(function() {
        PricingModel.NewRecord = false;
        PricingLoader.AddDefaultServices();
    });
    
    parent.Xrm.Page.data.entity.removeOnSave(OpportunitySaved);
}

Data Model – PricingModel

The PricingModel class is used to store all of the data from the grid as well as perform functions calculations, saving, etc. I would like to refactor this class because it has too much functionality in a single class, but for time sake, I have not done so yet. This class is in the new_PricingModel.js file.

First, I created an Enum to make it easier to set up the “Frequencies” in the model…

var FrequencyEnum = {
    SetupFee: 100000000,
    MonthlyFee: 100000001,
    TransactionFee: 100000002
}

Next, I will show the properties of the model used in binding the data in the grid…

var PricingModel = {
    OpportunityId: null,
    NewRecord: true,
    Services: new Array(),
    ServiceOptions: new Array(),
    Frequencies: [
        { Name: "One-time Fee (i.e. Setup Fee)", Value: FrequencyEnum.SetupFee },
        { Name: "Monthly Fee (i.e. Maintenance Fee)", Value: FrequencyEnum.MonthlyFee },
        { Name: "Per Item (i.e. Statement) ", Value: FrequencyEnum.TransactionFee }
    ],
    UnitOfMeasure: null,
    //... functions to follow
}

The getOpportunityId function wraps trying to get the Opportunity ID. There was some weird behavior depending on whether it was a new or existing opportunity, as well as the timing of getting the ID when a new opportunity is being saved since the save occurs asynchronously.

getOpportunityId: function () {
        try {
            if (this.OpportunityId == null || this.OpportunityId <= 0 || this.OpportunityId == "") {
                if (parent != null && parent.Xrm != null && parent.Xrm.Page != null && parent.Xrm.Page.data != null && parent.Xrm.Page.data.entity != null) {
                    this.OpportunityId = parent.Xrm.Page.data.entity.getId().replace("{", "").replace("}", "");
                    if (this.OpportunityId.toString().length > 0)
                        this.NewRecord = false;
                }
            }
            return this.OpportunityId;
        } catch (exception) {
            return null;
        }
    }

The AddNewService function simply adds a new ServiceModel (down further) object to the Services array. A code smell here is that it also scrolls the page down. New rows are added to the bottom. If the grid is larger than the IFrame in which it is contained, the user can’t see the new record. I would like to move this out into the UI code, but haven’t done so yet. Leave a comment if you have a good idea on this one.

AddNewService: function () {
        PricingSorter.Predicate = "";   //remove any sorting so new records goes to the bottom
        var service = Object.create(ServiceModel);
        this.Services.push(service);

        //scroll to bottom
        var $target = $("html,body");
        $target.animate({ scrollTop: $target.height() }, 500);
    }

The RemoveService function will loop through the Services array and remove any items that have Selected = true. Before it deletes it though, it will prompt the user to ensure they want to delete the record. If the record doesn’t have an ID, then I assume it was a new record, but was never actually saved so I simply remove it from the array.

RemoveServices: function () {
        var itemsSelected = false;
        
        for (var i = 0; i < PricingModel.Services.length; i++) {
            var service = PricingModel.Services[i];
	
            if (!service.Selected) continue;
            if (service.Id == null) continue;

            itemsSelected = true;
            break;
        }

        if (!itemsSelected) {
            PricingMessage.displayInformation("You must have at least one service selected in order to remove it from the opportunity.");
            return;
        }

        Xrm.Utility.confirmDialog("Are you sure that you want to remove the selected services?",
            function () {
                var count = PricingModel.Services.length - 1;

                for (var i = count; i >= 0; i--) {
                    var service = PricingModel.Services[i];

                    if (!service.Selected)
                        continue;

                    if (service.Id == null || service.Id == "") {
                        PricingModel.Services.splice(i, 1);
                        continue;
                    }

                    XrmSvcToolkit.deleteRecord({
                        entityName: "OpportunityProduct",
                        id: service.Id,
                        async: false,
                        successCallback: function(result) {
                            PricingModel.Services.splice(i, 1);
                        },
                        errorCallback: function(error) {
                            PricingMessage.displayError("Error removing services. \r\n" + exception);
                        }
                });
                }
            },
            null);
    }

The PricingModel also calculates the monthly and annual volumes and revenue depending on the type of fee. A One-Time setup fee assumes a monthly volume of 0 and annual volume of 1. A monthly fee assumes a monthly volume of 1 and annual volume of 12. The Transaction Fee uses the monthly volume entered by the user as well as the unit price to calculate the monthly and annual volumes and revenues.

CalculateTotals: function (service) {
        if (service == null || service.Frequency == null || service.Frequency.Value == null)
            return;
	
        switch (service.Frequency.Value) {
            case FrequencyEnum.SetupFee:
                this.CalculateSetupFeeTotals(service);
                break;
            case FrequencyEnum.MonthlyFee:
                this.CalculateMonthlyFeeTotals(service);
                break;
            case FrequencyEnum.TransactionFee:
                this.CalculateTransactionFeeTotals(service);
                break;
        }
    },
    CalculateSetupFeeTotals: function(service) {
        service.MonthlyVolume = 0;
        service.AnnualVolume = 1;
        service.AnnualRevenue = Number(service.UnitPrice).toFixed(4);
    },
    CalculateMonthlyFeeTotals: function (service) {
        if (service.UnitPrice > 0.0 && service.MonthlyVolume == 0)
            service.MonthlyVolume = 1;
        service.AnnualVolume = service.MonthlyVolume * 12;
        service.AnnualRevenue = Number(service.AnnualVolume * service.UnitPrice).toFixed(4);
    },
    CalculateTransactionFeeTotals: function(service) {
        service.AnnualVolume = service.MonthlyVolume * 12;
        service.AnnualRevenue = Number(service.AnnualVolume * service.UnitPrice).toFixed(4);
    }

The meat and potatoes of this solution is here. I have a main function called SaveService which determines if it’s a new record or existing record then calls the appropriate CreateService or UpdateService functions accordingly. Here we create a JSON object representing the Opportunity Pricing entity and then use the XrmServiceToolkit to create or update the record in CRM.

SaveService: function (service, fieldName) {
        if (PricingModel.getOpportunityId() == null ||
            PricingModel.getOpportunityId() == "") {
            PricingMessage.displayWarning("Can't find Opportunity ID in order to save services.");
            return;
        }
	
        PricingMessage.displayProgress("Saving...");
        
        PricingModel.CalculateTotals(service);

        if (service == null ||
            service.Id == null ||
            service.Id <= 0 ||
            service.toString().trim() == "") {
            PricingModel.CreateService(service, fieldName);
        } else {
            PricingModel.UpdateService(service, fieldName);
        }
    },
    CreateService: function (service, fieldName) {
        try {
            if (service.Service == null ||
                service.Service.Name == null ||
                service.Service.Name == "") {
                if (fieldName != null && fieldName.toString().length > 0)
                    PricingMessage.displayError("You must select a service first before any of the other fields will be saved.");
                return;
            }
            
            var opportunityProduct = {
                OpportunityId: { Id: this.getOpportunityId() },
                UoMId: { Id: this.UnitOfMeasure.UoMId },
                ProductId: { Id: service.Service.Id },
                Quantity: Number(service.AnnualVolume).toFixed(0),
                new_TransactionType: { Value: service.Service.TransactionType },
                PricePerUnit: { Value: Number(service.UnitPrice).toFixed(4) },
                new_MonthlyVolume: Number(service.MonthlyVolume),
                ExtendedAmount: { Value: Number(service.AnnualRevenue).toFixed(4) }
            };

            XrmSvcToolkit.createRecord({
                entityName: "OpportunityProduct",
                entity: opportunityProduct,
                async: false,
                successCallback: function (result) {
                    try {
                        PricingMessage.hideProgress();

                        service.Id = result.OpportunityProductId;

                        if (fieldName != null && fieldName.toString().length > 0) {
                            parent.Xrm.Page.getAttribute("estimatedvalue").setValue(Number(PricingModel.GetTotalAnnualRevenue()));
                        }
                    } catch (error) {
                        PricingMessage.displayError(error);
                    }
                },
                errorCallback: function (exception) {
                    PricingMessage.hideProgress();
                    if (fieldName != null && fieldName.toString().length > 0)
                        PricingMessage.displayError("Error saving service.  \r\n" + exception);
                    console.log(exception);
                }
            });
        } catch (exception) {
            PricingMessage.hideProgress();
            PricingMessage.displayError(exception);
        }
    },
    UpdateService: function (service, fieldName) {
        try {
            if (service.Service == null ||
                service.Service.Name == null ||
                service.Service.Name == "") {
                if (fieldName != null && fieldName.toString().length > 0)
                    PricingMessage.displayError("You must select a service first before any of the other fields will be saved.");
                return;
            }

            var opportunityProduct = {
                OpportunityId: { Id: this.getOpportunityId() },
                UoMId: { Id: this.UnitOfMeasure.UoMId },
                ProductId: { Id: service.Service.Id },
                Quantity: Number(service.AnnualVolume).toFixed(0),
                new_TransactionType: { Value: service.Frequency.Value },
                PricePerUnit: { Value: Number(service.UnitPrice).toFixed(4) },
                new_MonthlyVolume: Number(service.MonthlyVolume),
                ExtendedAmount: { Value: Number(service.AnnualRevenue).toFixed(4) }
            };

            service.Selected = false;

            XrmSvcToolkit.updateRecord({
                entityName: "OpportunityProduct",
                id: service.Id,
                entity: opportunityProduct,
                async: true,
                successCallback: function (result) {
                    try {
                        PricingMessage.hideProgress();

                        if (fieldName != null && fieldName.toString().length > 0) {
                            parent.Xrm.Page.getAttribute("estimatedvalue").setValue(Number(PricingModel.GetTotalAnnualRevenue()));
                        }
                    } catch (error) {
                        PricingMessage.displayError(error);
                    }
                },
                errorCallback: function (exception) {
                    PricingMessage.hideProgress();
                    if (fieldName != null && fieldName.toString().length > 0)
                        PricingMessage.displayError("Error updating " + fieldName + ".  \r\n" + exception);
                    console.log(exception);
                }
            });
        } catch (exception) {
            PricingMessage.hideProgress();
            PricingMessage.displayError(exception);
        }
    }

There are additional functions in the PricingModel class that I will include in the source code with this post, but aren’t critical to discuss at this time.

ServiceModel

The ServiceModel is used in the PricingModel.Services array which represents a single record to display in the grid. It contains the ID, Service (object representing the name), Frequency, Pricing, Volumes, etc. It also has a few helper functions like toggling the Selected property when clicked, or cleaning the UnitPrice field for the proper decimals.

var ServiceModel = {
    Id: 0,
    Selected: false,
    Hovered: false,
    Service: { Id: 0, Name: "" },
    OriginalService: { Id: 0, Name: "" },
    Frequency: {Name: "", Value: 0},
    UnitPrice: 0.0000,
    MonthlyVolume: 0,
    AnnualVolume: 0,
    AnnualRevenue: 0.0,
    UnitPriceDecimalPlaces: 4,
    ToggleSelected: function() {
        this.Selected = !this.Selected;
    },	
    RowClicked: function($event) {
        if ($event.ctrlKey) {
            this.ToggleSelected();
            return;
        }

        for (var i = 0; i < PricingModel.Services.length; i++)
            PricingModel.Services[i].Selected = false;

        this.Selected = true;
    },
    CleanUnitPrice: function () {
        if (this.UnitPrice == null ||
            this.UnitPrice.toString() == null ||
            this.UnitPrice.toString().trim() == "")
            this.UnitPrice = 0.0000;
        
        this.UnitPrice = $filter('currency')(this.UnitPrice, '$', 4);

        this.CalculateUnitPriceDecimalPlaces();

        this.UnitPrice = $filter('currency')(this.UnitPrice, '$', this.UnitPriceDecimalPlaces);

        this.UnitPrice = Number(this.UnitPrice);
    },
    CalculateUnitPriceDecimalPlaces: function () {
        try {
            var unitPrice = this.UnitPrice.toString().replace(',', '');

            if (unitPrice.toString().indexOf('.') < 0) {
                this.UnitPriceDecimalPlaces = 0;
                return;
            }

            var fraction = unitPrice.substring(unitPrice.toString().indexOf('.') + 1);

            for (var i = fraction.length - 1; i >= 0; i--) {
                if (fraction.charAt(i) != "0")
                    break;
                fraction = fraction.substring(0, i);
            }

            this.UnitPriceDecimalPlaces = fraction.length;
        }
        catch (error){}
    }
}

var Service = {
    Id: 0,
    Name: "",
    TransactionType: 0
}

HTML Page

Open the HTML page created above. Here are some of the basic pieces I used.

HTML 5 DOCTYPE

<!DOCTYPE
html>

Empty title

<title></title>

Add the CSS files (both CRM and custom CSS).

<link
href=”new_javascriptTableStyle.css”
rel=”stylesheet”
type=”text/css”>

<link
href=”new_PricingStyles.css”
rel=”stylesheet”
type=”text/css”>

<link
href=”new_jquery_ui_1.9.2.custom.min.css”
rel=”stylesheet”
type=”text/css”>

<link
href=”/_common/styles/fonts.css.aspx”
rel=”stylesheet”
type=”text/css”>

<link
href=”/_common/styles/global.css.aspx”
rel=”stylesheet”
type=”text/css”>

<link
href=”/_common/styles/theme.css.aspx”
rel=”stylesheet”
type=”text/css”>

<link
href=”/_common/styles/select.css.aspx”
rel=”stylesheet”
type=”text/css”>

<link
href=”/_forms/controls/form.css.aspx”
rel=”stylesheet”
type=”text/css”>

<link
href=”/_forms/controls/controls.css.aspx”
rel=”stylesheet”
type=”text/css”>

<link
href=”/_grid/appgrid.css.aspx”
rel=”stylesheet”
type=”text/css”>

Add the JavaScript files

<script
src=”ClientGlobalContext.js.aspx”
type=”text/javascript”></script>

<script
src=”ksw_XrmSvcToolkit.js”
type=”text/javascript”></script>

<script
src=”ksw_json2.js”
type=”text/javascript”></script>

<script
src=”../../../_static/_common/scripts/jquery1.7.2.min.js”
type=”text/javascript”></script>

<script
src=”new_jquery_ui_1.9.2.custom.min.js”
type=”text/javascript”></script>

<script
src=”new_angular.min.js”
type=”text/javascript”></script>

<script
src=”new_PricingModel.js”
type=”text/javascript”></script>

<script
src=”new_PricingAngularScript.js”
type=”text/javascript”></script>

<script
src=”new_PricingInlineEdit.js”
type=”text/javascript”></script>

<script
src=”new_PricingMessage.js”
type=”text/javascript”></script>

<script
src=”new_PricingModel.js”
type=”text/javascript”></script>

Make the body transparent

<body
style=”background: none;“>

Set up AngularJS for data binding… single application and controller…

<div
id=”PricingApplication”
data-ng-app=”PricingApplication”
>


<div
data-ng-controller=”PricingController”
>

Hide/Show sections

I like to hide the grid for a new opportunity and display a message saying that the user needs to save before adding pricing. In order to add pricing, you need to have an ID for the opportunity so the pricing can be associated with it. Angular has a few nice directives to dynamically hide and show content based on your data model… If PricingModel.NewRecord = true, then display this message…

<div
class=”ms-crm-InlineEditLabelText” data-ng-show=”(PricingModel.NewRecord)”>

Save Opportunity before adding services

</div>

Pricing Menu

I created Add and Remove buttons to enable adding new services or remove existing ones from the grid. These only show up once the Opportunity is saved.

The important attribute to pay attention to is the data-ng-click event for both buttons. They call functions on the PricingModel to add or remove items to the array to which the table is bound.

The buttons are reversed engineered to appear the same style when static as well as when hovered (Note the onmouseover and onmouseout events. I created a PricingClasses object simply to store the constants used for triggering the CRM CSS classes used for the styles).

<div
id=”pricingMenu”
style=”float: left”
class=”ms-crm-TopBarContainer” data-ng-show=”(!PricingModel.NewRecord)”>


<div
id=”AddPricing”
tabindex=”-1″
class=”ms-crm-CommandBarItem” title=”New Service Add a service and pricing to the opportunity”
onmouseover=”$(this).addClass(PricingClasses.MS_CRM_MENU_LABEL_HOVERED);$(this).removeClass(PricingClasses.MS_CRM_MENU_LABEL);”
onmouseout=”$(this).addClass(PricingClasses.MS_CRM_MENU_LABEL);$(this).removeClass(PricingClasses.MS_CRM_MENU_LABEL_HOVERED);”
data-ng-click=”PricingModel.AddNewService();”>


<a
tabindex=”0″
onclick=”return false”
class=”ms-crm-CommandBarLink”>


<img
tabindex=”-1″
class=”ms-crm-ImageStrip-NewRecord_16 ms-crm-commandbar-image16by16 ms-crm-CommandBar-Image”
alt=”Add Pricing”
src=”/_imgs/imagestrips/transparent_spacer.gif”/>


<span
tabindex=”-1″
class=”ms-crm-CommandBar-Menu”> Add Service </span>


</a>


</div>


<div
id=”RemovePricing”
tabindex=”-1″
class=”ms-crm-CommandBarItem” title=”Remove Service(s) Remove the selected services from the opportunity”
onmouseover=”$(this).addClass(PricingClasses.MS_CRM_MENU_LABEL_HOVERED);$(this).removeClass(PricingClasses.MS_CRM_MENU_LABEL);”
onmouseout=”$(this).addClass(PricingClasses.MS_CRM_MENU_LABEL);$(this).removeClass(PricingClasses.MS_CRM_MENU_LABEL_HOVERED);”
data-ng-click=”PricingModel.RemoveServices();”
style=”margin-left: 10px”>


<a
tabindex=”0″
onclick=”return false”
class=”ms-crm-CommandBarLink”>    


<img
tabindex=”-1″
class=”ms-crm-ImageStrip-NewRecord_16 ms-crm-commandbar-image16by16 ms-crm-CommandBar-Image”
alt=”Add Pricing”
src=”/_imgs/imagestrips/transparent_spacer.gif”/>


<span
tabindex=”-1″
class=”ms-crm-CommandBar-Menu”> Remove Service(s) </span>


</a>


</div>

</div>

The Grid

Just like the menu, the grid only appears when the Opportunity has been saved. The following snippets are the key pieces of the grid. I will show the entire table at the end…

<div
style=”clear: both; margin-left: 10px”
data-ng-show=”(!PricingModel.NewRecord)”>

Select All Rows

Clicking on the first column header will select or un-select all of the rows. The data-ng-click event simply calls the SelectAll function on the model to set a Boolean property on the data class called Selected.

<th
>


<div
class=”pricingTableHeaderLabel”>


<img
id=”crmGrid_gridBodyTable_checkBox_Image_All”
title=”Select/clear all records on this page”
class=”ms-crm-grid-checkbox-image-header ms-crm-ImageStrip-checkbox”
style=”visibility: visible; display: block; margin-left: auto; margin-right: auto;
alt=”Select/clear all records on this page”
src=”/_imgs/imagestrips/transparent_spacer.gif”
data-ng-click=”PricingModel.SelectAll();”>


</div>


<div
class=”pricingTableHeaderDivider”>


<img
alt=”resize column”
src=”/_imgs/imagestrips/transparent_spacer.gif”
class=”pricingTableHeaderDividerImage”/>


</div>

</th>

Sorting columns

The data-ng-click event will call a class which sorts the array to which the table is point and tells it which property to sort on. The data-ng-show attributes use AngularJS to determine when to show the sort ascending or descending arrows.

<th
style=”width: 300px;“>


<div
class=”pricingTableHeaderLabel”
data-ng-click=”PricingSorter.Sort(‘Service.Name’)”>


<span
style=”margin-left: 8px”>Service</span>


<img
data-ng-show=”(PricingSorter.Predicate == ‘Service.Name’ && !PricingSorter.Descending) “
class=”ms-crm-ImageStrip-sorting_up”
alt=””/>


<img
data-ng-show=”(PricingSorter.Predicate == ‘Service.Name’ && PricingSorter.Descending) “
class=”ms-crm-ImageStrip-sorting_down”
alt=””
/>


</div>


<div
class=”pricingTableHeaderDivider”>


<img
alt=”resize column”
src=”/_imgs/imagestrips/transparent_spacer.gif”
class=”pricingTableHeaderDividerImage”/>


</div>

</th>

The class used to sort the Services array is the following…

var PricingSorter = {

Predicate: “”,

Descending: false,

Sort: function(name) {


if (this.Predicate == name) {


this.Descending = !this.Descending;

} else {


this.Predicate = name;


this.Descending = false;

}

}

}

The PricingSorter class is used in the data-ng-repeat directive in the <tr> of the gird and looks like…

data-ng-repeat=”service in PricingModel.Services | orderBy:PricingSorter.Predicate:PricingSorter.Descending

The grid body

  • The data-ng-repeat attribute uses Angular to bind to the array of Services on the PricingModel. The beauty with AngularJS is that you create a single row with HTML and all of the controls that you expect and Angular will create a row with the same HTML for every item in the array and already bound to the desired properties. It’s a thing of beauty. No more DOM manipulation or writing HTML for every row yourself.
  • For the sake of time, I’m going to skip the data-ng-mouseover and data-ng-mouseleave as well as the other mouse events for styling. I will save it for another post.

<tbody>


<tr
class=”ms-crm-List-Row”
style=”border-style: none;
data-ng-repeat=”service in PricingModel.Services | orderBy:PricingSorter.Predicate:PricingSorter.Descending”
data-ng-mouseover=”service.Hovered = true”
data-ng-mouseleave=”service.Hovered = false”
data-ng-class=”{ pricingTableSelectedRow: service.Selected }”
onmouseover=”if(typeof(Mscrm) == ‘undefined’ || typeof(Mscrm.GridControl) == ‘undefined’) { return; } Mscrm.GridControl.mouseOver(this);”
onmouseout=”if(typeof(Mscrm) == ‘undefined’ || typeof(Mscrm.GridControl) == ‘undefined’) { return; } Mscrm.GridControl.mouseOut(this);”
>

The selected column

If the row is selected, display a check box. This is handled by changing the class (via the data-ng-click and data-ng-class attributes below).

<td
style=”margin-left: auto; margin-right: auto;data-ng-click=”service.ToggleSelected()”>


<div
style=”display: table-cell; width: 100%”>


<div
class=”pricingTableCheckBox”
style=”display: block; margin-left: auto; margin-right: auto”
data-ng-class=”{ pricingTableCheckBoxHovered: service.Hovered, pricingTableCheckBoxSelected: service.Selected }”>

</div>


</div>


<div
style=”display: table-cell; width: 2px”> </div>

</td>

Editable drop down

By far, the most challenging piece to replicate from CRM is the editable drop down box. The PricingInlineEdit class has the bulk of the code to handle the events. The code in bold are the key pieces to focus on. There is too much to explain in this blog. I may write another blog just about this functionality, but this will help get you started…

  • The data-ng-click event which calls service.RowClicked, just toggles the Selected property which is used to select the row
  • Notice in the title attribute, the AngularJS binding to the service.Service.Name property. The “service” is a reference to the object in the array for when the row was bound using the data-ng-repeat directive. The “Service.Name” is a reference to the object property which has the actual name of the service/product. (The PricingModel is displayed later in this blog so you can see the entire object model)
  • Notice in the “select” element, the data-ng-options directive binds to all of the services in ServiceOptions from which the user can select.
  • The data-ng-model for the “select” element is what actually binds the control to the selected option and is used later for saving back to CRM.

<td
style=”width: 300px”
data-ng-click=”service.RowClicked($event)”>


<div
title=”{{service.Service.Name}}”
class=”ms-crm-Inline-Chrome picklist”
tabindex=”0″
onfocus=”PricingInlineEdit.TurnOnEditHint($(this))”
onblur=”PricingInlineEdit.TurnOffEditHint($(this))”
onkeypress=”javascript: if (event.keyCode == KEY_CODE_RETURN || event.keyCode == KEY_CODE_SPACE) PricingInlineEdit.TurnOnEdit($(this));”
>


<div
class=”ms-crm-Inline-Value”
style=”cursor: default; display: block”
onmouseover=”PricingInlineEdit.TurnOnEditHint($(this))”
onmouseout=”PricingInlineEdit.TurnOffEditHint($(this))”
onclick=”PricingInlineEdit.TurnOnEdit($(this))”
>


<span
style=”width: 100%”
>


<input
contenteditable=”false”
tabindex=”-1″ placeholder=”Click here to select a service to add…”
value=”{{service.Service.Name}}”
onfocus=”PricingInlineEdit.TurnOnEdit($(this))”

style=”border-width: 0; cursor: default; width: 100%; background: transparent; font-weight: 700; font-size: 12px”/>


</span>


</div>


<div
class=”ms-crm-Inline-Edit ms-crm-Inline-OptionSet noScroll ms-crm-Inline-HideByZeroHeight” onkeypress=”javascript: if (event.keyCode == KEY_CODE_RETURN || event.keyCode == KEY_CODE_TAB) PricingInlineEdit.TurnOffEdit($(this));”>


<select
data-ng-model=”service.Service”
data-ng-options=”serviceOption.Name for serviceOption in PricingModel.ServiceOptions”
data-ng-blur=”PricingModel.SaveServiceName(service);”

tabindex=”-1″
size=”12″
class=”ms-crm-SelectBox ms-crm-Inline-OptionSet-AutoOpen” onblur=”PricingInlineEdit.TurnOffEdit($(this)); PricingInlineEdit.TurnOffEditHint($(this));”
onclick=”PricingInlineEdit.TurnOffEdit($(this)); PricingInlineEdit.TurnOffEditHint($(this));”
onkeypress=”javascript: if (event.keyCode == KEY_CODE_RETURN ) PricingInlineEdit.TurnOffEdit($(this));”

>

</select>


</div>


</div>

</td>

Saving when closed

In order to ensure that any changes are saved when the opportunity is closed, add this script to the page as well and call a function in the model called SaveAllServices.

$(window).unload(function () {

PricingModel.SaveOpportunity();

});

Resize Columns

Using jQuery UI, the following snippet will allow users to resize the columns in the grid…

$(document).ready(function () {

$(‘.pricingTable th’).resizable({

handles: ‘e’

});

});

Summary

These are all of the pieces and parts which are significant in developing this solution. All of the source code is pasted in this blog at the bottom. I have also zipped it and attached it for download. Feel free to leave comments for suggestions or questions and I’ll do my best to help in any way I can.

Files

new_PricingModel.js

var FrequencyEnum = {

SetupFee: 100000000,

MonthlyFee: 100000001,

TransactionFee: 100000002

}

var PricingModel = {

OpportunityId: null,

NewRecord: true,

Services: new Array(),

ServiceOptions: new Array(),

Frequencies: [

{ Name: “One-time Fee (i.e. Setup Fee)”, Value: FrequencyEnum.SetupFee },

{ Name: “Monthly Fee (i.e. Maintenance Fee)”, Value: FrequencyEnum.MonthlyFee },

{ Name: “Per Item (i.e. Statement) “, Value: FrequencyEnum.TransactionFee }

],

UnitOfMeasure: null,

getOpportunityId: function () {


try {


if (this.OpportunityId == null || this.OpportunityId <= 0 || this.OpportunityId == “”) {


if (parent != null && parent.Xrm != null && parent.Xrm.Page != null && parent.Xrm.Page.data != null && parent.Xrm.Page.data.entity != null) {


this.OpportunityId = parent.Xrm.Page.data.entity.getId().replace(“{“, “”).replace(“}”, “”);


if (this.OpportunityId.toString().length > 0)


this.NewRecord = false;

}

}


return
this.OpportunityId;

} catch (exception) {


return
null;

}

},

AddNewService: function () {

PricingSorter.Predicate = “”; //remove any sorting so new records goes to the bottom


var service = Object.create(ServiceModel);


this.Services.push(service);


//scroll to bottom


var $target = $(“html,body”);

$target.animate({ scrollTop: $target.height() }, 500);

},

RemoveServices: function () {


var itemsSelected = false;


for (var i = 0; i < PricingModel.Services.length; i++) {


var service = PricingModel.Services[i];


if (!service.Selected) continue;


if (service.Id == null) continue;

itemsSelected = true;


break;

}


if (!itemsSelected) {

PricingMessage.displayInformation(“You must have at least one service selected in order to remove it from the opportunity.”);


return;

}

Xrm.Utility.confirmDialog(“Are you sure that you want to remove the selected services?”,


function () {


var count = PricingModel.Services.length – 1;


for (var i = count; i >= 0; i–) {


var service = PricingModel.Services[i];


if (!service.Selected)


continue;


if (service.Id == null || service.Id == “”) {

PricingModel.Services.splice(i, 1);


continue;

}

XrmSvcToolkit.deleteRecord({

entityName: “OpportunityProduct”,

id: service.Id,

async: false,

successCallback: function(result) {

PricingModel.Services.splice(i, 1);

},

errorCallback: function(error) {

PricingMessage.displayError(“Error removing services. \r\n” + exception);

}

});

}

},


null);

},

SelectAll: function() {


var allSelected = true;


for (var i = 0; i < this.Services.length; i++) {


if (!this.Services[i].Selected) {

allSelected = false;


break;

}

}


for (var j = 0; j < this.Services.length; j++)


this.Services[j].Selected = !allSelected;

},

GetFrequency: function(value) {


for (var i = 0; i < this.Frequencies.length; i++) {


if (this.Frequencies[i].Value == value)


return
this.Frequencies[i];

}


return
null;

},

GetService: function(id) {


for (var i = 0; i < this.ServiceOptions.length; i++) {


if (this.ServiceOptions[i].Id == id)


return
this.ServiceOptions[i];

}


return
null;

},

SaveAllServices: function () {


try {

parent.Xrm.Page.data.save();

} catch (exception) {

console.log(exception);

}

},

SaveServiceName: function (service) {


try {


if (service != null && service.Service != null && service.Service.TransactionType != null)

service.Frequency = this.GetFrequency(service.Service.TransactionType);


this.SaveService(service);

} catch (exception) {

PricingMessage.displayError(“Error trying to save the service. \r\n” + exception);

}

},

SaveFrequency: function (service) {


this.SaveService(service, “Frequency”);

},

SaveUnitPrice: function (service) {

service.UnitPrice = Number(service.UnitPrice).toFixed(4);


this.SaveService(service, “Unit Price”);

service.CalculateUnitPriceDecimalPlaces();

},

SaveMonthlyVolume: function (service) {

service.MonthlyVolume = Number(service.MonthlyVolume);


this.SaveService(service, “Monthly Volume”);

},

CalculateTotals: function (service) {


if (service == null || service.Frequency == null || service.Frequency.Value == null)


return;


switch (service.Frequency.Value) {


case FrequencyEnum.SetupFee:


this.CalculateSetupFeeTotals(service);


break;


case FrequencyEnum.MonthlyFee:


this.CalculateMonthlyFeeTotals(service);


break;


case FrequencyEnum.TransactionFee:


this.CalculateTransactionFeeTotals(service);


break;

}

},

CalculateSetupFeeTotals: function(service) {

service.MonthlyVolume = 0;

service.AnnualVolume = 1;

service.AnnualRevenue = Number(service.UnitPrice).toFixed(4);

},

CalculateMonthlyFeeTotals: function (service) {


if (service.UnitPrice > 0.0 && service.MonthlyVolume == 0)

service.MonthlyVolume = 1;

service.AnnualVolume = service.MonthlyVolume * 12;

service.AnnualRevenue = Number(service.AnnualVolume * service.UnitPrice).toFixed(4);

},

CalculateTransactionFeeTotals: function(service) {

service.AnnualVolume = service.MonthlyVolume * 12;

service.AnnualRevenue = Number(service.AnnualVolume * service.UnitPrice).toFixed(4);

},

GetTotalAnnualRevenue: function () {


var total = Number(0);


if (this.Services == null || this.Services.length <= 0)


return total;


for (var i = 0; i < this.Services.length; i++) {


try {


var service = this.Services[i];


if (Number(service.AnnualRevenue) > 0)

total += Number(service.AnnualRevenue);

} catch (exception) {

console.log(exception);

}

}


return Number(total);

},

SaveService: function (service, fieldName) {


if (PricingModel.getOpportunityId() == null ||

PricingModel.getOpportunityId() == “”) {

PricingMessage.displayWarning(“Can’t find Opportunity ID in order to save services.”);


return;

}

PricingMessage.displayProgress(“Saving…”);

PricingModel.CalculateTotals(service);


if (service == null ||

service.Id == null ||

service.Id <= 0 ||

service.toString().trim() == “”) {

PricingModel.CreateService(service, fieldName);

} else {

PricingModel.UpdateService(service, fieldName);

}

},

CreateService: function (service, fieldName) {


try {


if (service.Service == null ||

service.Service.Name == null ||

service.Service.Name == “”) {


if (fieldName != null && fieldName.toString().length > 0)

PricingMessage.displayError(“You must select a service first before any of the other fields will be saved.”);


return;

}


var opportunityProduct = {

OpportunityId: { Id: this.getOpportunityId() },

UoMId: { Id: this.UnitOfMeasure.UoMId },

ProductId: { Id: service.Service.Id },

Quantity: Number(service.AnnualVolume).toFixed(0),

new_TransactionType: { Value: service.Service.TransactionType },

PricePerUnit: { Value: Number(service.UnitPrice).toFixed(4) },

new_MonthlyVolume: Number(service.MonthlyVolume),

ExtendedAmount: { Value: Number(service.AnnualRevenue).toFixed(4) }

};

XrmSvcToolkit.createRecord({

entityName: “OpportunityProduct”,

entity: opportunityProduct,

async: false,

successCallback: function (result) {


try {

PricingMessage.hideProgress();

service.Id = result.OpportunityProductId;


if (fieldName != null && fieldName.toString().length > 0) {

parent.Xrm.Page.getAttribute(“estimatedvalue”).setValue(Number(PricingModel.GetTotalAnnualRevenue()));

}

} catch (error) {

PricingMessage.displayError(error);

}

},

errorCallback: function (exception) {

PricingMessage.hideProgress();


if (fieldName != null && fieldName.toString().length > 0)

PricingMessage.displayError(“Error saving service. \r\n” + exception);

console.log(exception);

}

});

} catch (exception) {

PricingMessage.hideProgress();

PricingMessage.displayError(exception);

}

},

UpdateService: function (service, fieldName) {


try {


if (service.Service == null ||

service.Service.Name == null ||

service.Service.Name == “”) {


if (fieldName != null && fieldName.toString().length > 0)

PricingMessage.displayError(“You must select a service first before any of the other fields will be saved.”);


return;

}


var opportunityProduct = {

OpportunityId: { Id: this.getOpportunityId() },

UoMId: { Id: this.UnitOfMeasure.UoMId },

ProductId: { Id: service.Service.Id },

Quantity: Number(service.AnnualVolume).toFixed(0),

new_TransactionType: { Value: service.Frequency.Value },

PricePerUnit: { Value: Number(service.UnitPrice).toFixed(4) },

new_MonthlyVolume: Number(service.MonthlyVolume),

ExtendedAmount: { Value: Number(service.AnnualRevenue).toFixed(4) }

};

service.Selected = false;

XrmSvcToolkit.updateRecord({

entityName: “OpportunityProduct”,

id: service.Id,

entity: opportunityProduct,

async: true,

successCallback: function (result) {


try {

PricingMessage.hideProgress();


if (fieldName != null && fieldName.toString().length > 0) {

parent.Xrm.Page.getAttribute(“estimatedvalue”).setValue(Number(PricingModel.GetTotalAnnualRevenue()));

}

} catch (error) {

PricingMessage.displayError(error);

}

},

errorCallback: function (exception) {

PricingMessage.hideProgress();


if (fieldName != null && fieldName.toString().length > 0)

PricingMessage.displayError(“Error updating “ + fieldName + “. \r\n” + exception);

console.log(exception);

}

});

} catch (exception) {

PricingMessage.hideProgress();

PricingMessage.displayError(exception);

}

},

SortByServiceName: function() {

PricingModel.Services = PricingModel.Services.sort(function (svc1, svc2) {


var name1 = svc1.Service.Name.toLowerCase();


var name2 = svc2.Service.Name.toLowerCase();


return ((name1 name2) ? 1 : 0));

});

}

};

PricingModel.Services.push = function () {


for (var i = 0; i < arguments.length; i++) {


this[this.length] = arguments[i];


this[this.length – 1].CalculateUnitPriceDecimalPlaces();

}


return
this.length;

}

var PricingSorter = {

Predicate: “”,

Descending: false,

Sort: function(name) {


if (this.Predicate == name) {


this.Descending = !this.Descending;

} else {


this.Predicate = name;


this.Descending = false;

}

}

}

var ServiceModel = {

Id: 0,

Selected: false,

Hovered: false,

Service: { Id: 0, Name: “” },

OriginalService: { Id: 0, Name: “” },

Frequency: {Name: “”, Value: 0},

UnitPrice: 0.0000,

MonthlyVolume: 0,

AnnualVolume: 0,

AnnualRevenue: 0.0,

UnitPriceDecimalPlaces: 4,

ToggleSelected: function() {


this.Selected = !this.Selected;

},

RowClicked: function($event) {


if ($event.ctrlKey) {


this.ToggleSelected();


return;

}


for (var i = 0; i < PricingModel.Services.length; i++)

PricingModel.Services[i].Selected = false;


this.Selected = true;

},

CleanUnitPrice: function () {


if (this.UnitPrice == null ||


this.UnitPrice.toString() == null ||


this.UnitPrice.toString().trim() == “”)


this.UnitPrice = 0.0000;


this.UnitPrice = $filter(‘currency’)(this.UnitPrice, ‘$’, 4);


this.CalculateUnitPriceDecimalPlaces();


this.UnitPrice = $filter(‘currency’)(this.UnitPrice, ‘$’, this.UnitPriceDecimalPlaces);


this.UnitPrice = Number(this.UnitPrice);

},

CalculateUnitPriceDecimalPlaces: function () {


try {


var unitPrice = this.UnitPrice.toString().replace(‘,’, );


if (unitPrice.toString().indexOf(‘.’) < 0) {


this.UnitPriceDecimalPlaces = 0;


return;

}


var fraction = unitPrice.substring(unitPrice.toString().indexOf(‘.’) + 1);


for (var i = fraction.length – 1; i >= 0; i–) {


if (fraction.charAt(i) != “0”)


break;

fraction = fraction.substring(0, i);

}


this.UnitPriceDecimalPlaces = fraction.length;

}


catch (error){}

}

}

var Service = {

Id: 0,

Name: “”,

TransactionType: 0

}

new_PricingAngularScript.js

var $pricingScope = null;

function PricingController($scope) {


try {

$pricingScope = $scope;

$scope.PricingModel = PricingModel;

$scope.PricingSorter = PricingSorter;

UnitOfMeasureLoader.Load();

ProductsLoader.Load();


if (PricingModel.getOpportunityId() == null || PricingModel.getOpportunityId() == “”) {

parent.Xrm.Page.data.entity.addOnSave(OpportunitySaved);

} else {

PricingLoader.Load();

}

} catch (exception) {

PricingMessage.displayError(exception);

}

}

function OpportunitySaved(execContext) {

$pricingScope.$apply(function() {

PricingModel.NewRecord = false;

PricingLoader.AddDefaultServices();

});

parent.Xrm.Page.data.entity.removeOnSave(OpportunitySaved);

}

var UnitOfMeasureLoader = {

Load: function () {

XrmSvcToolkit.retrieveMultiple({

entityName: “UoM”,

odataQuery: “?$filter=Name eq ‘Primary Unit'”,

async: true,

successCallback: function (result) {


if (result == null || result.length == null || result.length <= 0) {

PricingMessage.displayWarning(“No units of measure were found in order to add new pricings.”);


return;

}

PricingModel.UnitOfMeasure = result[0];

},

errorCallback: function(error) {

PricingMessage.displayError(error);

}

});

}

}

var ProductsLoader = {

Load: function() {

XrmSvcToolkit.retrieveMultiple({

entityName: “PriceLevel”,

odataQuery: “?$select=Name,PriceLevelId,price_level_products/ProductNumber,price_level_products/ProductId,price_level_products/Name,price_level_products/new_TransactionType&$expand=price_level_products&$filter=Name eq ‘Opportunity Product Price List’ and StateCode/Value eq 0&$orderby=Name”,

async: false,

successCallback: function (result) {


try {


if (result == null || result.length == null || result.length <= 0) {

PricingMessage.displayWarning(“No products found from which to choose for pricing”);


return;

}


var products = result[0].price_level_products.results;


for (var i = 0; i < products.length; i++) {


var product = products[i];

PricingModel.ServiceOptions.push({ Id: product.ProductId, Name: product.Name, TransactionType: product.new_TransactionType.Value });

}

} catch (exception) {

PricingMessage.displayError(“Error getting products from price list. “ + exception);

}

},

errorCallback: function(error) {

PricingMessage.displayError(error);

}

});

}

}

var PricingLoader = {

Load: function () {


if (PricingModel.getOpportunityId() == null || PricingModel.getOpportunityId() == “”)


return;

XrmSvcToolkit.retrieveMultiple({

entityName: “OpportunityProduct”,

odataQuery: “?$filter=OpportunityId/Id eq (guid'” + PricingModel.getOpportunityId() + “‘)”,

async: false,

successCallback: function(result) {


if (result == null || result.length == null || result.length <= 0) {

PricingLoader.AddDefaultServices();


return;

}


for (var i = 0; i < result.length; i++) {


var pricing = result[i];


if (pricing.PricePerUnit.Value == null || pricing.PricePerUnit.Value == “”)

pricing.PricePerUnit.Value = “0”;


if (pricing.new_MonthlyVolume == null || pricing.new_MonthlyVolume == “”)

pricing.new_MonthlyVolume = “0”;


if (pricing.Quantity == null || pricing.Quantity == “”)

pricing.Quantity = “0”;


if (pricing.ExtendedAmount.Value == null || pricing.ExtendedAmount.Value == “”)

pricing.ExtendedAmount.Value = “0”;


var product = PricingModel.GetService(pricing.ProductId.Id);


var service = Object.create(ServiceModel);

service.Id = pricing.OpportunityProductId;

service.Service = product;

service.OriginalService = product;

service.Frequency = PricingModel.GetFrequency(pricing.new_TransactionType.Value);

service.UnitPrice = parseFloat(pricing.PricePerUnit.Value);

service.MonthlyVolume = parseInt(pricing.new_MonthlyVolume);

service.AnnualVolume = parseInt(pricing.Quantity);

service.AnnualRevenue = parseFloat(pricing.ExtendedAmount.Value);

PricingModel.Services.push(service);

}

PricingModel.SortByServiceName();

},

errorCallback: function(error) {

PricingMessage.displayError(error);

}

});

},

AddDefaultServices: function() {


for (var i = 0; i < PricingModel.ServiceOptions.length; i++) {


var serviceOption = PricingModel.ServiceOptions[i];


var service = Object.create(ServiceModel);

service.Service = serviceOption;

service.Frequency = PricingModel.GetFrequency(serviceOption.TransactionType);

PricingModel.Services.push(service);

}

PricingModel.SortByServiceName();

}

}

var pricingApplication = angular.module(“PricingApplication”, []);

pricingApplication.controller(“PricingController”, PricingController);

new_PricingMessage.js

I like having a class which wraps displaying messages to the user. It wraps CRM messaging to simplify it. It also enables a timeout on the message so it disappears after a specified amount of time instead of requiring the user to refresh the screen. The “displayError”, “displayWarning”, and “displayInformation” functions wrap the “displayMessage” function so the developer doesn’t have to type the string in as to which message level to display.

The “displayProgress” and corresponding “hideProgress” functions handle displaying a progress message to the user, which I use when saving values. It simply uses jQuery to dynamically create a modal dialog. Here is the code…

var PricingMessage = {

displayError: function (message) {


this.displayMessage(message, “ERROR”);

},

displayWarning: function (message) {


this.displayMessage(message, “WARNING”);

},

displayInformation: function (message) {


this.displayMessage(message, “INFO”);

},

displayMessage: function (message, level) {


try {

window.parent.Xrm.Page.ui.setFormNotification(message, level, message);

window.parent.setTimeout(function () {

window.parent.Xrm.Page.ui.clearFormNotification(message);

}, 10000);

} catch (exception) {

alert(level + “: “ + message);

}

},

displayProgress: function (message) {


try {


var $jparent = window.parent.jQuery.noConflict();


var progressDialog = $jparent(‘#pricingProgress’);


if (!progressDialog.length)

progressDialog = $jparent(

);

progressDialog.html();

progressDialog.dialog({ modal: true, closeOnEscape: true, resizable: false, closeText: “”, buttons: [] });

} catch (exception) {

console.log(exception);

}

},

hideProgress: function() {


try {


var $jparent = window.parent.jQuery.noConflict();


var progressDialog = $jparent(‘#pricingProgress’);


if (!progressDialog.length) return;

progressDialog.dialog(“close”);

} catch (exception) {


//this.displayError(exception);

console.log(exception);

}

}

}

new_PricingInlineEdit.js

var PricingClasses = {

MS_CRM_INLINE_HIDEBYZEROHEIGHT: “ms-crm-Inline-HideByZeroHeight”,

MS_CRM_INLINE_EDITHINTSTATE: “ms-crm-Inline-EditHintState”,

MS_CRM_INLINE_CHROME: “ms-crm-Inline-Chrome”,

MS_CRM_INLINE_VALUE: “ms-crm-Inline-Value”,

MS_CRM_INLINE_EDIT: “ms-crm-Inline-Edit”,

MS_CRM_MENU_LABEL: “ms-crm-Menu-Label”,

MS_CRM_MENU_LABEL_HOVERED: “ms-crm-Menu-Label-Hovered”,

}

var PricingInlineEdit = {

TurnOnEdit: function (selectedElement) {


try {


var parentControl = this.getParentControl(selectedElement);


var valueElement = this.getValueElement(parentControl);


var editElement = this.getEditElement(parentControl);

$(valueElement).addClass(PricingClasses.MS_CRM_INLINE_HIDEBYZEROHEIGHT);

$(valueElement).css(“display”, “none”);

$(editElement).removeClass(PricingClasses.MS_CRM_INLINE_HIDEBYZEROHEIGHT);

$(editElement).css(“display”, “block”);

$(editElement).children(‘select’).focus();

$(editElement).find(‘input’).focus();

} catch (exception) {

PricingMessage.displayError(“Unable to edit pricing. “ + exception);

}

},

TurnOffEdit: function (selectedElement) {


try {


var parentControl = this.getParentControl(selectedElement);


var valueElement = this.getValueElement(parentControl);


var editElement = this.getEditElement(parentControl);

$(valueElement).removeClass(PricingClasses.MS_CRM_INLINE_HIDEBYZEROHEIGHT);

$(valueElement).css(“display”, “block”);

$(editElement).addClass(PricingClasses.MS_CRM_INLINE_HIDEBYZEROHEIGHT);

$(editElement).css(“display”, “none”);

} catch (exception) {

PricingMessage.displayError(“Unable to turn off edit mode. “ + exception);

}

},

TurnOnEditHint: function (selectedElement) {


try {


var parentControl = this.getParentControl(selectedElement);


var valueElement = this.getValueElement(parentControl);

$(valueElement).addClass(PricingClasses.MS_CRM_INLINE_EDITHINTSTATE);

} catch (exception) {

PricingMessage.displayError(“Unable to show edit hint. “ + exception);

}

},

TurnOffEditHint: function (selectedElement) {


try {


var parentControl = this.getParentControl(selectedElement);


var valueElement = this.getValueElement(parentControl);

$(valueElement).removeClass(PricingClasses.MS_CRM_INLINE_EDITHINTSTATE);

} catch (exception) {

PricingMessage.displayError(“Unable to hide edit hint. “ + exception);

}

},

getParentControl: function (element) {


if ($(element).hasClass(PricingClasses.MS_CRM_INLINE_CHROME))


return element;


return $(element).closest(‘div[class^=”‘ + PricingClasses.MS_CRM_INLINE_CHROME + ‘”]’);

},

getValueElement: function (parentElement) {


try {


return parentElement.children(‘div[class^=”‘ + PricingClasses.MS_CRM_INLINE_VALUE + ‘”]’)[0];

} catch (exception) {


return
null;

}

},

getEditElement: function (parentElement) {


try {


return parentElement.children(‘div[class^=”‘ + PricingClasses.MS_CRM_INLINE_EDIT + ‘”]’)[0];

} catch (exception) {


return
null;

}

}

}

new_PricingStyles.css

::-webkit-input-placeholder {


font-style: italic;


color: #CE7200;

}

:-moz-placeholder {


font-style: italic;


color: #CE7200;

}

::-moz-placeholder {


font-style: italic;


color: #CE7200;

}

:-ms-input-placeholder {


font-style: italic;


color: #CE7200;

}

.ms-crm-List-ResizeBar {


margin-top: 4px;

}

.ms-crm-List-Sortable {


vertical-align: middle;


color: #666666;

}

.ms-crm-TopBarContainer {


margin-left: 15px;


margin-top: 15px;


margin-bottom: 15px;


float: left;

}

.ms-crm-CommandBarItem {


height: 28px;


font-size: 12px;


text-transform: uppercase;


float: left;

}

.ms-crm-CommandBar-Button {


height: 22px;


padding-top: 5px;


padding-bottom: 1px;


padding-right: 6px;

}

.ms-crm-CommandBar-Image {


display: inline-block
!important;


vertical-align: middle
!important;

}

.ms-crm-CommandBar-Menu {


display: inline-block
!important;


color: #444444
!important;

}

.ms-crm-CommandBarLink {


margin: 5px
5px
5px
2px;


display: inline-block;

}

.ms-crm-Menu-Label {


color: #ffffff
!important;

}

.ms-crm-Menu-Label-Hovered {


background-color: #D7EBF9
!important;


border-color: #D7EBF9
!important;

}

.ms-crm-Inline-Value {


margin-left: 5px
!important;


font-weight: 600;

}

.ms-crm-Inline-Edit {


margin-left: 5px
!important;


font-weight: 600;

}

.pricingTable {


border-collapse: collapse;


border-top-width: 1px;


border-top-color: #D6D6D6;


border-style: solid
none
none
none;


background-color: transparent;


width: 100%;


white-space: nowrap;

}

.pricingTable
table, td, th {


white-space: nowrap;

}

.pricingTableHeaderLabel {


cursor: pointer;


color: #666666;


width: 100%;


display: table-cell;


vertical-align: middle;

}

.pricingTableHeaderLabel:hover {


background-color: #D7EBF9;

}

.pricingTableSelectedRow {


background-color: #B1D6F0;

}

.pricingTableCheckBox {


width: 12px;


height: 10px;


margin-right: auto;


margin-left: auto;

}

.pricingTableCheckBoxHovered {


background: transparent
url(‘/_imgs/imagestrips/grid_ctrl_imgs.png’)
no-repeat
scroll
-169px
-37px;

}

.pricingTableCheckBoxSelected {


background: transparent
url(‘/_imgs/imagestrips/grid_ctrl_imgs.png’)
no-repeat
scroll
-169px
-49px;

}

.pricingTableHeaderDivider {


display: table-cell;


width: 1px;


vertical-align: middle;

}

.pricingTableHeaderDividerImage {


background: transparent
url(‘/_imgs/imagestrips/grid_ctrl_imgs.png’)
no-repeat
scroll
-171px
-17px;


width: 1px;


height: 14px;


overflow: hidden;


margin-top: 5px;


margin-bottom: 2px;

}

.pricingTableHeaderRow {


border-bottom-style: solid;


border-bottom-width: 1px;


border-bottom-color: #D6D6D6;


vertical-align: middle;

}

.ms-crm-List-Row {


height: 28px;

}

.ms-crm-ImageStrip-sorting_up {


margin-left: 2px;


vertical-align: middle;

}

.ms-crm-ImageStrip-sorting_down {


margin-left: 2px;


vertical-align: middle

}

.ms-crm-InlineEditLabelText {


margin-top: 15px;


margin-left: 15px;

}

.ms-crm-Inline-Chrome {}

.ms-crm-ImageStrip-NewRecord_16 {}

.ms-crm-commandbar-image16by16{}

.ms-crm-grid-checkbox-image-header{}

.ms-crm-ImageStrip-checkbox{}

.ms-crm-Inline-GradientMask{}

.ms-crm-Inline-OptionSet{}

.ms-crm-SelectBox{}

.ms-crm-Inline-OptionSet-AutoOpen{}

.ms-crm-Inline-HideByZeroHeight{}

.ms-crm-Inline-Currency{}

.picklist{}

.noScroll{}

new_PricingEditor.html

<!DOCTYPE
html>

<html>


<head>


<title></title>


</head>


<body
style=”background: none;“>


<link
href=”new_javascriptTableStyle.css”
rel=”stylesheet”
type=”text/css”>


<link
href=”new_PricingStyles.css”
rel=”stylesheet”
type=”text/css”>


<link
href=”new_jquery_ui_1.9.2.custom.min.css”
rel=”stylesheet”
type=”text/css”>


<link
href=”/_common/styles/fonts.css.aspx”
rel=”stylesheet”
type=”text/css”>


<link
href=”/_common/styles/global.css.aspx”
rel=”stylesheet”
type=”text/css”>


<link
href=”/_common/styles/theme.css.aspx”
rel=”stylesheet”
type=”text/css”>


<link
href=”/_common/styles/select.css.aspx”
rel=”stylesheet”
type=”text/css”>


<link
href=”/_forms/controls/form.css.aspx”
rel=”stylesheet”
type=”text/css”>


<link
href=”/_forms/controls/controls.css.aspx”
rel=”stylesheet”
type=”text/css”>


<link
href=”/_grid/appgrid.css.aspx”
rel=”stylesheet”
type=”text/css”>


<script
src=”ClientGlobalContext.js.aspx”
type=”text/javascript”></script>


<script
src=”ksw_XrmSvcToolkit.js”
type=”text/javascript”></script>


<script
src=”ksw_json2.js”
type=”text/javascript”></script>


<script
src=”../../../_static/_common/scripts/jquery1.7.2.min.js”
type=”text/javascript”></script>


<script
src=”new_jquery_ui_1.9.2.custom.min.js”
type=”text/javascript”></script>


<script
src=”new_angular.min.js”
type=”text/javascript”></script>


<script
src=”new_PricingModel.js”
type=”text/javascript”></script>


<script
src=”new_PricingAngularScript.js”
type=”text/javascript”></script>


<script
src=”new_PricingInlineEdit.js”
type=”text/javascript”></script>


<script
src=”new_PricingMessage.js”
type=”text/javascript”></script>


<script
src=”new_PricingModel.js”
type=”text/javascript”></script>


<script
type=”text/javascript”>


//KEY CODE CONSTANTS


var KEY_CODE_TAB = 9;


var KEY_CODE_RETURN = 13;


var KEY_CODE_ESCAPE = 27;


var KEY_CODE_SPACE = 32;

$(window).unload(function () {

PricingModel.SaveOpportunity();

});

$(document).ready(function () {

$(‘.pricingTable th’).resizable({

handles: ‘e’

});

});


</script>


<div
id=”PricingApplication”
data-ng-app=”PricingApplication”
>


<div
data-ng-controller=”PricingController”
>


<div
class=”ms-crm-InlineEditLabelText”


data-ng-show=”(PricingModel.NewRecord)”>

Save Opportunity before adding services


</div>


<div
id=”pricingMenu”
style=”float: left”


class=”ms-crm-TopBarContainer”


data-ng-show=”(!PricingModel.NewRecord)”>


<div
id=”AddPricing”
tabindex=”-1″


class=”ms-crm-CommandBarItem”


title=”New Service Add a service and pricing to the opportunity”


onmouseover=”$(this).addClass(PricingClasses.MS_CRM_MENU_LABEL_HOVERED);$(this).removeClass(PricingClasses.MS_CRM_MENU_LABEL);”


onmouseout=”$(this).addClass(PricingClasses.MS_CRM_MENU_LABEL);$(this).removeClass(PricingClasses.MS_CRM_MENU_LABEL_HOVERED);”


data-ng-click=”PricingModel.AddNewService();”>


<a
tabindex=”0″
onclick=”return false”


class=”ms-crm-CommandBarLink”>


<img
tabindex=”-1″
class=”ms-crm-ImageStrip-NewRecord_16 ms-crm-commandbar-image16by16 ms-crm-CommandBar-Image”


alt=”Add Pricing”
src=”/_imgs/imagestrips/transparent_spacer.gif”/>


<span
tabindex=”-1″


class=”ms-crm-CommandBar-Menu”


> Add Service </span>


</a>


</div>


<div
id=”RemovePricing”
tabindex=”-1″


class=”ms-crm-CommandBarItem”


title=”Remove Service(s) Remove the selected services from the opportunity”


onmouseover=”$(this).addClass(PricingClasses.MS_CRM_MENU_LABEL_HOVERED);$(this).removeClass(PricingClasses.MS_CRM_MENU_LABEL);”


onmouseout=”$(this).addClass(PricingClasses.MS_CRM_MENU_LABEL);$(this).removeClass(PricingClasses.MS_CRM_MENU_LABEL_HOVERED);”


data-ng-click=”PricingModel.RemoveServices();”


style=”margin-left: 10px”>


<a
tabindex=”0″
onclick=”return false”


class=”ms-crm-CommandBarLink”>


<img
tabindex=”-1″
class=”ms-crm-ImageStrip-NewRecord_16 ms-crm-commandbar-image16by16 ms-crm-CommandBar-Image”


alt=”Add Pricing”
src=”/_imgs/imagestrips/transparent_spacer.gif”/>


<span
tabindex=”-1″


class=”ms-crm-CommandBar-Menu”


> Remove Service(s) </span>


</a>


</div>


</div>


<div
style=”clear: both; margin-left: 10px”


data-ng-show=”(!PricingModel.NewRecord)”>


<table
id=”pricingTable”
cellspacing=”0″
class=”pricingTable”>


<colgroup>


<col
style=”width: 25px;“/>


<col
style=”width: 300px;“/>


<col
style=”width: 300px;“/>


<col
style=”width: 100px;“/>


<col
style=”width: 125px;“/>


<col
style=”width: 125px;“/>


<col
/>


</colgroup>


<thead
>


<tr
class=”pricingTableHeaderRow”>


<th
>


<div
class=”pricingTableHeaderLabel”>


<img
id=”crmGrid_gridBodyTable_checkBox_Image_All”


title=”Select/clear all records on this page”


class=”ms-crm-grid-checkbox-image-header ms-crm-ImageStrip-checkbox”


style=”visibility: visible; display: block; margin-left: auto; margin-right: auto;


alt=”Select/clear all records on this page”


src=”/_imgs/imagestrips/transparent_spacer.gif”


data-ng-click=”PricingModel.SelectAll();”>


</div>


<div
class=”pricingTableHeaderDivider”>


<img
alt=”resize column”


src=”/_imgs/imagestrips/transparent_spacer.gif”


class=”pricingTableHeaderDividerImage”/>


</div>


</th>


<th
style=”width: 300px;“>


<div
class=”pricingTableHeaderLabel”


data-ng-click=”PricingSorter.Sort(‘Service.Name’)”>


<span
style=”margin-left: 8px”>Service</span>


<img
data-ng-show=”(PricingSorter.Predicate == ‘Service.Name’ && !PricingSorter.Descending) “
class=”ms-crm-ImageStrip-sorting_up”
alt=””/>


<img
data-ng-show=”(PricingSorter.Predicate == ‘Service.Name’ && PricingSorter.Descending) “
class=”ms-crm-ImageStrip-sorting_down”
alt=””
/>


</div>


<div
class=”pricingTableHeaderDivider”>


<img
alt=”resize column”


src=”/_imgs/imagestrips/transparent_spacer.gif”


class=”pricingTableHeaderDividerImage”/>


</div>


</th>


<th
style=”width: 300px; “>


<div
class=”pricingTableHeaderLabel”


data-ng-click=”PricingSorter.Sort(‘Frequency.Name’)”>


<span
style=”margin-left: 7px”>Frequency</span>


<img
data-ng-show=”(PricingSorter.Predicate == ‘Frequency.Name’ && !PricingSorter.Descending) “
class=”ms-crm-ImageStrip-sorting_up”
alt=””/>


<img
data-ng-show=”(PricingSorter.Predicate == ‘Frequency.Name’ && PricingSorter.Descending) “
class=”ms-crm-ImageStrip-sorting_down”
alt=””
/>


</div>


<div
class=”pricingTableHeaderDivider”>


<img
alt=”resize column”


src=”/_imgs/imagestrips/transparent_spacer.gif”


class=”pricingTableHeaderDividerImage”/>


</div>


</th>


<th>


<div
class=”pricingTableHeaderLabel”


data-ng-click=”PricingSorter.Sort(‘UnitPrice’)”>


<span
style=”margin-left: 5px”>Unit Price</span>


<img
data-ng-show=”(PricingSorter.Predicate == ‘UnitPrice’ && !PricingSorter.Descending) “
class=”ms-crm-ImageStrip-sorting_up”
alt=””/>


<img
data-ng-show=”(PricingSorter.Predicate == ‘UnitPrice’ && PricingSorter.Descending) “
class=”ms-crm-ImageStrip-sorting_down”
alt=””
/>


</div>


<div
class=”pricingTableHeaderDivider”>


<img
alt=”resize column”


src=”/_imgs/imagestrips/transparent_spacer.gif”


class=”pricingTableHeaderDividerImage”/>


</div>


</th>


<th
style=”width: 125px;“>


<div
class=”pricingTableHeaderLabel”


data-ng-click=”PricingSorter.Sort(‘MonthlyVolume’)”>


<span
style=”margin-left: 4px”>Monthly Volume</span>


<img
data-ng-show=”(PricingSorter.Predicate == ‘MonthlyVolume’ && !PricingSorter.Descending) “
class=”ms-crm-ImageStrip-sorting_up”
alt=””/>


<img
data-ng-show=”(PricingSorter.Predicate == ‘MonthlyVolume’ && PricingSorter.Descending) “
class=”ms-crm-ImageStrip-sorting_down”
alt=””
/>


</div>


<div
class=”pricingTableHeaderDivider”>


<img
alt=”resize column”


src=”/_imgs/imagestrips/transparent_spacer.gif”


class=”pricingTableHeaderDividerImage”/>


</div>


</th>


<th
style=”width: 125px”>


<div
class=”pricingTableHeaderLabel”


data-ng-click=”PricingSorter.Sort(‘AnnualVolume’)”>


<span
style=”margin-left: 4px”>Volume</span>


<img
data-ng-show=”(PricingSorter.Predicate == ‘AnnualVolume’ && !PricingSorter.Descending) “
class=”ms-crm-ImageStrip-sorting_up”
alt=””/>


<img
data-ng-show=”(PricingSorter.Predicate == ‘AnnualVolume’ && PricingSorter.Descending) “
class=”ms-crm-ImageStrip-sorting_down”
alt=””
/>


</div>


<div
class=”pricingTableHeaderDivider”>


<img
alt=”resize column”


src=”/_imgs/imagestrips/transparent_spacer.gif”


class=”pricingTableHeaderDividerImage”/>


</div>


</th>


<th>


<div
class=”pricingTableHeaderLabel”


data-ng-click=”PricingSorter.Sort(‘AnnualRevenue’)”>


<span
style=”margin-left: 5px”>Annual Revenue</span>


<img
data-ng-show=”(PricingSorter.Predicate == ‘AnnualRevenue’ && !PricingSorter.Descending) “
class=”ms-crm-ImageStrip-sorting_up”
alt=””/>


<img
data-ng-show=”(PricingSorter.Predicate == ‘AnnualRevenue’ && PricingSorter.Descending) “
class=”ms-crm-ImageStrip-sorting_down”
alt=””
/>


</div>


<div
class=”pricingTableHeaderDivider”>


<img
alt=”resize column”


src=”/_imgs/imagestrips/transparent_spacer.gif”


class=”pricingTableHeaderDividerImage”/>


</div>


</th>


</tr>


</thead>


<tbody>


<tr
class=”ms-crm-List-Row”


style=”border-style: none;


data-ng-repeat=”service in PricingModel.Services | orderBy:PricingSorter.Predicate:PricingSorter.Descending”


data-ng-mouseover=”service.Hovered = true”


data-ng-mouseleave=”service.Hovered = false”


data-ng-class=”{ pricingTableSelectedRow: service.Selected }”


onmouseover=”if(typeof(Mscrm) == ‘undefined’ || typeof(Mscrm.GridControl) == ‘undefined’) { return; } Mscrm.GridControl.mouseOver(this);”


onmouseout=”if(typeof(Mscrm) == ‘undefined’ || typeof(Mscrm.GridControl) == ‘undefined’) { return; } Mscrm.GridControl.mouseOut(this);”


>


<td
style=”margin-left: auto; margin-right: auto;


data-ng-click=”service.ToggleSelected()”>


<div
style=”display: table-cell; width: 100%”>


<div
class=”pricingTableCheckBox”


style=”display: block; margin-left: auto; margin-right: auto”


data-ng-class=”{ pricingTableCheckBoxHovered: service.Hovered, pricingTableCheckBoxSelected: service.Selected }”>


</div>


</div>


<div
style=”display: table-cell; width: 2px”> </div>


</td>


<td
style=”width: 300px”


data-ng-click=”service.RowClicked($event)”>


<div
title=”{{service.Service.Name}}


class=”ms-crm-Inline-Chrome picklist”


tabindex=”0″


onfocus=”PricingInlineEdit.TurnOnEditHint($(this))”


onblur=”PricingInlineEdit.TurnOffEditHint($(this))”


onkeypress=”javascript: if (event.keyCode == KEY_CODE_RETURN || event.keyCode == KEY_CODE_SPACE) PricingInlineEdit.TurnOnEdit($(this));”>


<div
class=”ms-crm-Inline-Value”


style=”cursor: default; display: block”


onmouseover=”PricingInlineEdit.TurnOnEditHint($(this))”


onmouseout=”PricingInlineEdit.TurnOffEditHint($(this))”


onclick=”PricingInlineEdit.TurnOnEdit($(this))”


>


<span
style=”width: 100%”
>


<input
contenteditable=”false”
tabindex=”-1″


placeholder=”Click here to select a service to add…”


value=”{{service.Service.Name}}


onfocus=”PricingInlineEdit.TurnOnEdit($(this))”


style=”border-width: 0; cursor: default; width: 100%; background: transparent; font-weight: 700; font-size: 12px”/>


</span>


</div>


<div
class=”ms-crm-Inline-Edit ms-crm-Inline-OptionSet noScroll ms-crm-Inline-HideByZeroHeight”


onkeypress=”javascript: if (event.keyCode == KEY_CODE_RETURN || event.keyCode == KEY_CODE_TAB) PricingInlineEdit.TurnOffEdit($(this));”>


<select
data-ng-model=”service.Service”


data-ng-options=”serviceOption.Name for serviceOption in PricingModel.ServiceOptions”


data-ng-blur=”PricingModel.SaveServiceName(service);”


tabindex=”-1″


size=”12″


class=”ms-crm-SelectBox ms-crm-Inline-OptionSet-AutoOpen”


onblur=”PricingInlineEdit.TurnOffEdit($(this)); PricingInlineEdit.TurnOffEditHint($(this));”


onclick=”PricingInlineEdit.TurnOffEdit($(this)); PricingInlineEdit.TurnOffEditHint($(this));”


onkeypress=”javascript:

if (event.keyCode == KEY_CODE_RETURN )

PricingInlineEdit.TurnOffEdit($(this));”


>


</select>


</div>


</div>


</td>


<td
style=”width: 300px;


data-ng-click=”service.RowClicked($event)”>




<div
title=”{{service.Frequency.Name}}


class=”ms-crm-Inline-Chrome picklist”


tabindex=”0″


onfocus=”PricingInlineEdit.TurnOnEditHint($(this))”


onblur=”PricingInlineEdit.TurnOffEditHint($(this))”


onkeypress=”javascript: if (event.keyCode == KEY_CODE_RETURN || event.keyCode == KEY_CODE_SPACE) PricingInlineEdit.TurnOnEdit($(this));”>


<div
class=”ms-crm-Inline-Value”


style=”cursor: default; display: block”


onmouseover=”PricingInlineEdit.TurnOnEditHint($(this))”


onmouseout=”PricingInlineEdit.TurnOffEditHint($(this))”


onclick=”PricingInlineEdit.TurnOnEdit($(this))”>


<span
style=”width: 100%”>


<input
contenteditable=”false”
tabindex=”-1″


placeholder=”Click here to select the frequency of billing…”


value=”{{service.Frequency.Name}}


onfocus=”PricingInlineEdit.TurnOnEdit($(this))”


style=”border-width: 0; cursor: default; width: 100%; background: transparent; font-weight: 700; font-size: 12px”/>


</span>


</div>


<div
class=”ms-crm-Inline-Edit ms-crm-Inline-OptionSet noScroll ms-crm-Inline-HideByZeroHeight”


onkeypress=”javascript: if (event.keyCode == KEY_CODE_RETURN || event.keyCode == KEY_CODE_TAB) PricingInlineEdit.TurnOffEdit($(this));”>


<select
data-ng-model=”service.Frequency”


data-ng-options=”frequency.Name for frequency in PricingModel.Frequencies”


data-ng-blur=”PricingModel.SaveFrequency(service);”


tabindex=”-1″


size=”3″


class=”ms-crm-SelectBox ms-crm-Inline-OptionSet-AutoOpen”


onblur=”PricingInlineEdit.TurnOffEdit($(this)); PricingInlineEdit.TurnOffEditHint($(this));”


onclick=”PricingInlineEdit.TurnOffEdit($(this)); PricingInlineEdit.TurnOffEditHint($(this));”


onkeypress=”javascript:

if (event.keyCode == KEY_CODE_RETURN )

PricingInlineEdit.TurnOffEdit($(this));”


>


</select>


</div>


</div>


</td>


<td
data-ng-click=”service.RowClicked($event)”>


<div
title=”{{service.UnitPrice
|
number:4}}


class=”ms-crm-Inline-Chrome “


tabindex=”0″


onfocus=”PricingInlineEdit.TurnOnEditHint($(this))”


onblur=”PricingInlineEdit.TurnOffEditHint($(this))”


onkeypress=”javascript: if (event.keyCode == KEY_CODE_RETURN || event.keyCode == KEY_CODE_SPACE) PricingInlineEdit.TurnOnEdit($(this));”>


<div
class=”ms-crm-Inline-Value”


style=”cursor: default; display: block”


onmouseover=”PricingInlineEdit.TurnOnEditHint($(this))”


onmouseout=”PricingInlineEdit.TurnOffEditHint($(this))”


onclick=”PricingInlineEdit.TurnOnEdit($(this))”>


<span
data-ng-if=”(service.UnitPriceDecimalPlaces > 2)”>${{service.UnitPrice
|
number:4}}</span>


<span
data-ng-if=”(service.UnitPriceDecimalPlaces == 1 || service.UnitPriceDecimalPlaces == 2)”>${{service.UnitPrice
|
number:2}}</span>


<span
data-ng-if=”(service.UnitPriceDecimalPlaces == 0)”>${{service.UnitPrice
|
number:0}}</span>


<span
data-ng-if=”(service.UnitPriceDecimalPlaces $ invalid</span>


</div>


<div
class=”ms-crm-Inline-Edit ms-crm-Inline-Currency”


style=”display: none;“>


<table
style=”border-width: 0; padding: 0; border-spacing: 0″>


<tbody>


<tr>


<td
style=”padding: 0; vertical-align: middle”><span
class=”ms-crm-Money-CurrenctySymbol”
style=”margin-right: 1px”>$</span></td>


<td
style=”padding: 0″>


<input
class=”ms-crm-Money ms-crm-InlineInput money”


name=”unitPriceField”


type=”text”


tabindex=”-1″


title=”Format like 1,000 or 250 or 0.02 or 0.0213″


required


data-ng-model=”service.UnitPrice”


data-ng-blur=”PricingModel.SaveUnitPrice(service);”


onfocus=”this.setSelectionRange(0, this.value.length);”


onblur=”PricingInlineEdit.TurnOffEdit($(this)); PricingInlineEdit.TurnOffEditHint($(this));”


onkeypress=”javascript:

if (event.keyCode == KEY_CODE_RETURN )

PricingInlineEdit.TurnOffEdit($(this));



/>


</td>


</tr>


</tbody>


</table>


</div>


</div>


</td>


<td
style=”width: 125px;


data-ng-click=”service.RowClicked($event)”>


<div
title=”{{service.MonthlyVolume
|
number:0}}


class=”ms-crm-Inline-Chrome “


tabindex=”-1″


data-ng-show=”(service.Frequency.Value == 100000000)”>


<div
class=”ms-crm-Inline-Value”


style=”cursor: default; display: block”>



</div>


</div>


<div
title=”{{service.MonthlyVolume
|
number:0}}


class=”ms-crm-Inline-Chrome “


tabindex=”0″


onfocus=”PricingInlineEdit.TurnOnEditHint($(this))”


onblur=”PricingInlineEdit.TurnOffEditHint($(this))”


onkeypress=”javascript: if (event.keyCode == KEY_CODE_RETURN || event.keyCode == KEY_CODE_SPACE) PricingInlineEdit.TurnOnEdit($(this));”


data-ng-hide=”(service.Frequency.Value == 100000000)”>


<div
class=”ms-crm-Inline-Value”


style=”cursor: default; display: block”


onmouseover=”PricingInlineEdit.TurnOnEditHint($(this))”


onmouseout=”PricingInlineEdit.TurnOffEditHint($(this))”


onclick=”PricingInlineEdit.TurnOnEdit($(this))”>


{{service.MonthlyVolume
|
number:0}}


</div>


<div
class=”ms-crm-Inline-Edit “


style=”display: none;“>


<input
class=”ms-crm-Money ms-crm-InlineInput number”


name=”unitPriceField”


type=”number”


tabindex=”-1″


title=”Monthly quantity”


min=”0″


max=”999999999″


step=”1″


required


data-ng-model=”service.MonthlyVolume”


data-ng-blur=”PricingModel.SaveMonthlyVolume(service);”


onfocus=”this.setSelectionRange(0, this.value.length);”


onblur=”PricingInlineEdit.TurnOffEdit($(this)); PricingInlineEdit.TurnOffEditHint($(this));”


onkeypress=”javascript:

if (event.keyCode == KEY_CODE_RETURN )

PricingInlineEdit.TurnOffEdit($(this));



/>


</div>


</div>


</td>


<td
style=”width: 125px;


data-ng-click=”service.RowClicked($event)”>


<div
title=”{{service.AnnualVolume
|
number:0}}


class=”ms-crm-Inline-Chrome “


tabindex=”-1″>


<div
class=”ms-crm-Inline-Value”


style=”cursor: default; display: block”>


{{service.AnnualVolume
|
number:0}}


</div>


</div>


</td>


<td
data-ng-click=”service.RowClicked($event)”>


<div
title=”{{service.AnnualRevenue
|
number:0}}


class=”ms-crm-Inline-Chrome “


tabindex=”-1″>


<div
class=”ms-crm-Inline-Value”


style=”cursor: default; display: block”>

${{service.AnnualRevenue
|
number:0}}


</div>


</div>


</td>


</tr>


</tbody>


</table>


</div>


</div>


</div>


</body>

</html>


Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google+ photo

You are commenting using your Google+ account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s