diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/sale/static/src | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/sale/static/src')
| -rw-r--r-- | addons/sale/static/src/img/onboarding_quotation_order_tooltip.jpg | bin | 0 -> 12084 bytes | |||
| -rw-r--r-- | addons/sale/static/src/img/sale_quotation_onboarding_bg.jpg | bin | 0 -> 35910 bytes | |||
| -rw-r--r-- | addons/sale/static/src/js/product_configurator_widget.js | 276 | ||||
| -rw-r--r-- | addons/sale/static/src/js/product_discount_widget.js | 37 | ||||
| -rw-r--r-- | addons/sale/static/src/js/sale.js | 60 | ||||
| -rw-r--r-- | addons/sale/static/src/js/sale_order_view.js | 56 | ||||
| -rw-r--r-- | addons/sale/static/src/js/sale_portal_sidebar.js | 117 | ||||
| -rw-r--r-- | addons/sale/static/src/js/tours/sale.js | 126 | ||||
| -rw-r--r-- | addons/sale/static/src/js/variant_mixin.js | 637 | ||||
| -rw-r--r-- | addons/sale/static/src/scss/product_configurator.scss | 227 | ||||
| -rw-r--r-- | addons/sale/static/src/scss/sale_onboarding.scss | 11 | ||||
| -rw-r--r-- | addons/sale/static/src/scss/sale_portal.scss | 42 | ||||
| -rw-r--r-- | addons/sale/static/src/scss/sale_report.scss | 3 |
13 files changed, 1592 insertions, 0 deletions
diff --git a/addons/sale/static/src/img/onboarding_quotation_order_tooltip.jpg b/addons/sale/static/src/img/onboarding_quotation_order_tooltip.jpg Binary files differnew file mode 100644 index 00000000..548a1d35 --- /dev/null +++ b/addons/sale/static/src/img/onboarding_quotation_order_tooltip.jpg diff --git a/addons/sale/static/src/img/sale_quotation_onboarding_bg.jpg b/addons/sale/static/src/img/sale_quotation_onboarding_bg.jpg Binary files differnew file mode 100644 index 00000000..3c0f7a34 --- /dev/null +++ b/addons/sale/static/src/img/sale_quotation_onboarding_bg.jpg diff --git a/addons/sale/static/src/js/product_configurator_widget.js b/addons/sale/static/src/js/product_configurator_widget.js new file mode 100644 index 00000000..54bca050 --- /dev/null +++ b/addons/sale/static/src/js/product_configurator_widget.js @@ -0,0 +1,276 @@ +odoo.define('sale.product_configurator', function (require) { +var relationalFields = require('web.relational_fields'); +var FieldsRegistry = require('web.field_registry'); +var core = require('web.core'); +var _t = core._t; + +/** + * The sale.product_configurator widget is a simple widget extending FieldMany2One + * It allows the development of configuration strategies in other modules through + * widget extensions. + * + * + * !!! WARNING !!! + * + * This widget is only designed for sale_order_line creation/updates. + * !!! It should only be used on a product_product or product_template field !!! + */ +var ProductConfiguratorWidget = relationalFields.FieldMany2One.extend({ + events: _.extend({}, relationalFields.FieldMany2One.prototype.events, { + 'click .o_edit_product_configuration': '_onEditConfiguration' + }), + + /** + * @override + */ + _render: function () { + this._super.apply(this, arguments); + if (this.mode === 'edit' && this.value && + (this._isConfigurableProduct() || this._isConfigurableLine())) { + this._addProductLinkButton(); + this._addConfigurationEditButton(); + } else if (this.mode === 'edit' && this.value) { + this._addProductLinkButton(); + this.$('.o_edit_product_configuration').hide(); + } else { + this.$('.o_external_button').hide(); + this.$('.o_edit_product_configuration').hide(); + } + }, + + /** + * Add button linking to product_id/product_template_id form. + */ + _addProductLinkButton: function () { + if (this.$('.o_external_button').length === 0) { + var $productLinkButton = $('<button>', { + type: 'button', + class: 'fa fa-external-link btn btn-secondary o_external_button', + tabindex: '-1', + draggable: false, + 'aria-label': _t('External Link'), + title: _t('External Link') + }); + + var $inputDropdown = this.$('.o_input_dropdown'); + $inputDropdown.after($productLinkButton); + } + }, + + /** + * If current product is configurable, + * Show edit button (in Edit Mode) after the product/product_template + */ + _addConfigurationEditButton: function () { + var $inputDropdown = this.$('.o_input_dropdown'); + + if ($inputDropdown.length !== 0 && + this.$('.o_edit_product_configuration').length === 0) { + var $editConfigurationButton = $('<button>', { + type: 'button', + class: 'fa fa-pencil btn btn-secondary o_edit_product_configuration', + tabindex: '-1', + draggable: false, + 'aria-label': _t('Edit Configuration'), + title: _t('Edit Configuration') + }); + + $inputDropdown.after($editConfigurationButton); + } + }, + + /** + * Hook to override with _onEditProductConfiguration + * to know if edit pencil button has to be put next to the field + * + * @private + */ + _isConfigurableProduct: function () { + return false; + }, + + /** + * Hook to override with _onEditProductConfiguration + * to know if edit pencil button has to be put next to the field + * + * @private + */ + _isConfigurableLine: function () { + return false; + }, + + /** + * Override catching changes on product_id or product_template_id. + * Calls _onTemplateChange in case of product_template change. + * Calls _onProductChange in case of product change. + * Shouldn't be overridden by product configurators + * or only to setup some data for further computation + * before calling super. + * + * @override + * @param {OdooEvent} ev + * @param {boolean} ev.data.preventProductIdCheck prevent the product configurator widget + * from looping forever when it needs to change the 'product_template_id' + * + * @private + */ + reset: async function (record, ev) { + await this._super(...arguments); + if (ev && ev.target === this) { + if (ev.data.changes && !ev.data.preventProductIdCheck && ev.data.changes.product_template_id) { + this._onTemplateChange(record.data.product_template_id.data.id, ev.data.dataPointID); + } else if (ev.data.changes && ev.data.changes.product_id) { + this._onProductChange(record.data.product_id.data && record.data.product_id.data.id, ev.data.dataPointID).then(wizardOpened => { + if (!wizardOpened) { + this._onLineConfigured(); + } + }); + } + } + }, + + /** + * Hook for product_template based configurators + * (product configurator, matrix, ...). + * + * @param {integer} productTemplateId + * @param {String} dataPointID + * + * @private + */ + _onTemplateChange: function (productTemplateId, dataPointId) { + return Promise.resolve(false); + }, + + /** + * Hook for product_product based configurators + * (event, rental, ...). + * Should return + * true if product has been configured through wizard or + * the result of the super call for other wizard extensions + * false if the product wasn't configurable through the wizard + * + * @param {integer} productId + * @param {String} dataPointID + * @returns {Promise<Boolean>} stopPropagation true if a suitable configurator has been found. + * + * @private + */ + _onProductChange: function (productId, dataPointId) { + return Promise.resolve(false); + }, + + /** + * Hook for configurator happening after line has been set + * (options, ...). + * Allows sale_product_configurator module to apply its options + * after line configuration has been done. + * + * @private + */ + _onLineConfigured: function () { + + }, + + /** + * Triggered on click of the configuration button. + * It is only shown in Edit mode, + * when _isConfigurableProduct or _isConfigurableLine is True. + * + * After reflexion, when a line was configured through two wizards, + * only the line configuration will open. + * + * Two hooks are available depending on configurator category: + * _onEditLineConfiguration : line configurators + * _onEditProductConfiguration : product configurators + * + * @private + */ + _onEditConfiguration: function () { + if (this._isConfigurableLine()) { + this._onEditLineConfiguration(); + } else if (this._isConfigurableProduct()) { + this._onEditProductConfiguration(); + } + }, + + /** + * Hook for line configurators (rental, event) + * on line edition (pencil icon inside product field) + */ + _onEditLineConfiguration: function () { + + }, + + /** + * Hook for product configurators (matrix, product) + * on line edition (pencil icon inside product field) + */ + _onEditProductConfiguration: function () { + + }, + + /** + * Utilities for recordData conversion + */ + + /** + * Will convert the values contained in the recordData parameter to + * a list of '4' operations that can be passed as a 'default_' parameter. + * + * @param {Object} recordData + * + * @private + */ + _convertFromMany2Many: function (recordData) { + if (recordData) { + var convertedValues = []; + _.each(recordData.res_ids, function (resId) { + convertedValues.push([4, parseInt(resId)]); + }); + + return convertedValues; + } + + return null; + }, + + /** + * Will convert the values contained in the recordData parameter to + * a list of '0' or '4' operations (based on wether the record is already persisted or not) + * that can be passed as a 'default_' parameter. + * + * @param {Object} recordData + * + * @private + */ + _convertFromOne2Many: function (recordData) { + if (recordData) { + var convertedValues = []; + _.each(recordData.res_ids, function (resId) { + if (isNaN(resId)) { + _.each(recordData.data, function (record) { + if (record.ref === resId) { + convertedValues.push([0, 0, { + custom_product_template_attribute_value_id: record.data.custom_product_template_attribute_value_id.data.id, + custom_value: record.data.custom_value + }]); + } + }); + } else { + convertedValues.push([4, resId]); + } + }); + + return convertedValues; + } + + return null; + } +}); + +FieldsRegistry.add('product_configurator', ProductConfiguratorWidget); + +return ProductConfiguratorWidget; + +}); diff --git a/addons/sale/static/src/js/product_discount_widget.js b/addons/sale/static/src/js/product_discount_widget.js new file mode 100644 index 00000000..ede11800 --- /dev/null +++ b/addons/sale/static/src/js/product_discount_widget.js @@ -0,0 +1,37 @@ +odoo.define('sale.product_discount', function (require) { + "use strict"; + + const BasicFields = require('web.basic_fields'); + const FieldsRegistry = require('web.field_registry'); + + /** + * The sale.product_discount widget is a simple widget extending FieldFloat + * + * + * !!! WARNING !!! + * + * This widget is only designed for sale_order_line creation/updates. + * !!! It should only be used on a discount field !!! + */ + const ProductDiscountWidget = BasicFields.FieldFloat.extend({ + + /** + * Override changes at a discount. + * + * @override + * @param {OdooEvent} ev + * + */ + async reset(record, ev) { + if (ev && ev.data.changes && ev.data.changes.discount >= 0) { + this.trigger_up('open_discount_wizard'); + } + this._super(...arguments); + }, + }); + + FieldsRegistry.add('product_discount', ProductDiscountWidget); + + return ProductDiscountWidget; + +}); diff --git a/addons/sale/static/src/js/sale.js b/addons/sale/static/src/js/sale.js new file mode 100644 index 00000000..556c9fd2 --- /dev/null +++ b/addons/sale/static/src/js/sale.js @@ -0,0 +1,60 @@ +odoo.define('sale.sales_team_dashboard', function (require) { +"use strict"; + +var core = require('web.core'); +var KanbanRecord = require('web.KanbanRecord'); +var _t = core._t; + +KanbanRecord.include({ + events: _.defaults({ + 'click .sales_team_target_definition': '_onSalesTeamTargetClick', + }, KanbanRecord.prototype.events), + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @param {MouseEvent} ev + */ + _onSalesTeamTargetClick: function (ev) { + ev.preventDefault(); + var self = this; + + this.$target_input = $('<input>'); + this.$('.o_kanban_primary_bottom:last').html(this.$target_input); + this.$('.o_kanban_primary_bottom:last').prepend(_t("Set an invoicing target: ")); + this.$target_input.focus(); + + this.$target_input.on({ + blur: this._onSalesTeamTargetSet.bind(this), + keydown: function (ev) { + if (ev.keyCode === $.ui.keyCode.ENTER) { + self._onSalesTeamTargetSet(); + } + }, + }); + }, + /** + * Mostly a handler for what happens to the input "this.$target_input" + * + * @private + * + */ + _onSalesTeamTargetSet: function () { + var self = this; + var value = Number(this.$target_input.val()); + if (isNaN(value)) { + this.do_warn(false, _t("Please enter an integer value")); + } else { + this.trigger_up('kanban_record_update', { + invoiced_target: value, + onSuccess: function () { + self.trigger_up('reload'); + }, + }); + } + }, +}); + +}); diff --git a/addons/sale/static/src/js/sale_order_view.js b/addons/sale/static/src/js/sale_order_view.js new file mode 100644 index 00000000..ef74cee9 --- /dev/null +++ b/addons/sale/static/src/js/sale_order_view.js @@ -0,0 +1,56 @@ +odoo.define('sale.SaleOrderView', function (require) { + "use strict"; + + const FormController = require('web.FormController'); + const FormView = require('web.FormView'); + const viewRegistry = require('web.view_registry'); + const Dialog = require('web.Dialog'); + const core = require('web.core'); + const _t = core._t; + + const SaleOrderFormController = FormController.extend({ + custom_events: _.extend({}, FormController.prototype.custom_events, { + open_discount_wizard: '_onOpenDiscountWizard', + }), + + // ------------------------------------------------------------------------- + // Handlers + // ------------------------------------------------------------------------- + + /** + * Handler called if user changes the discount field in the sale order line. + * The wizard will open only if + * (1) Sale order line is 3 or more + * (2) First sale order line is changed to discount + * (3) Discount is the same in all sale order line + */ + _onOpenDiscountWizard(ev) { + const orderLines = this.renderer.state.data.order_line.data.filter(line => !line.data.display_type); + const recordData = ev.target.recordData; + const isEqualDiscount = orderLines.slice(1).every(line => line.data.discount === recordData.discount); + if (orderLines.length >= 3 && recordData.sequence === orderLines[0].data.sequence && isEqualDiscount) { + Dialog.confirm(this, _t("Do you want to apply this discount to all order lines?"), { + confirm_callback: () => { + orderLines.slice(1).forEach((line) => { + this.trigger_up('field_changed', { + dataPointID: this.renderer.state.id, + changes: {order_line: {operation: "UPDATE", id: line.id, data: {discount: orderLines[0].data.discount}}}, + }); + }); + }, + }); + } + }, + }); + + const SaleOrderView = FormView.extend({ + config: _.extend({}, FormView.prototype.config, { + Controller: SaleOrderFormController, + }), + }); + + viewRegistry.add('sale_discount_form', SaleOrderView); + + return SaleOrderView; + +}); diff --git a/addons/sale/static/src/js/sale_portal_sidebar.js b/addons/sale/static/src/js/sale_portal_sidebar.js new file mode 100644 index 00000000..bc18d3c9 --- /dev/null +++ b/addons/sale/static/src/js/sale_portal_sidebar.js @@ -0,0 +1,117 @@ +odoo.define('sale.SalePortalSidebar', function (require) { +'use strict'; + +var publicWidget = require('web.public.widget'); +var PortalSidebar = require('portal.PortalSidebar'); + +publicWidget.registry.SalePortalSidebar = PortalSidebar.extend({ + selector: '.o_portal_sale_sidebar', + + /** + * @constructor + */ + init: function (parent, options) { + this._super.apply(this, arguments); + this.authorizedTextTag = ['em', 'b', 'i', 'u']; + this.spyWatched = $('body[data-target=".navspy"]'); + }, + /** + * @override + */ + start: function () { + var def = this._super.apply(this, arguments); + var $spyWatcheElement = this.$el.find('[data-id="portal_sidebar"]'); + this._setElementId($spyWatcheElement); + // Nav Menu ScrollSpy + this._generateMenu(); + // After singature, automatically open the popup for payment + if ($.bbq.getState('allow_payment') === 'yes' && this.$('#o_sale_portal_paynow').length) { + this.$('#o_sale_portal_paynow').trigger('click'); + $.bbq.removeState('allow_payment'); + } + return def; + }, + + //-------------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------------- + + /** + * create an unique id and added as a attribute of spyWatched element + * + * @private + * @param {string} prefix + * @param {Object} $el + * + */ + _setElementId: function (prefix, $el) { + var id = _.uniqueId(prefix); + this.spyWatched.find($el).attr('id', id); + return id; + }, + /** + * generate the new spy menu + * + * @private + * + */ + _generateMenu: function () { + var self = this, + lastLI = false, + lastUL = null, + $bsSidenav = this.$el.find('.bs-sidenav'); + + $("#quote_content [id^=quote_header_], #quote_content [id^=quote_]", this.spyWatched).attr("id", ""); + _.each(this.spyWatched.find("#quote_content h2, #quote_content h3"), function (el) { + var id, text; + switch (el.tagName.toLowerCase()) { + case "h2": + id = self._setElementId('quote_header_', el); + text = self._extractText($(el)); + if (!text) { + break; + } + lastLI = $("<li class='nav-item'>").append($('<a class="nav-link" style="max-width: 200px;" href="#' + id + '"/>').text(text)).appendTo($bsSidenav); + lastUL = false; + break; + case "h3": + id = self._setElementId('quote_', el); + text = self._extractText($(el)); + if (!text) { + break; + } + if (lastLI) { + if (!lastUL) { + lastUL = $("<ul class='nav flex-column'>").appendTo(lastLI); + } + $("<li class='nav-item'>").append($('<a class="nav-link" style="max-width: 200px;" href="#' + id + '"/>').text(text)).appendTo(lastUL); + } + break; + } + el.setAttribute('data-anchor', true); + }); + this.trigger_up('widgets_start_request', {$target: $bsSidenav}); + }, + /** + * extract text of menu title for sidebar + * + * @private + * @param {Object} $node + * + */ + _extractText: function ($node) { + var self = this; + var rawText = []; + _.each($node.contents(), function (el) { + var current = $(el); + if ($.trim(current.text())) { + var tagName = current.prop("tagName"); + if (_.isUndefined(tagName) || (!_.isUndefined(tagName) && _.contains(self.authorizedTextTag, tagName.toLowerCase()))) { + rawText.push($.trim(current.text())); + } + } + }); + return rawText.join(' '); + }, +}); +}); diff --git a/addons/sale/static/src/js/tours/sale.js b/addons/sale/static/src/js/tours/sale.js new file mode 100644 index 00000000..d01b0ad6 --- /dev/null +++ b/addons/sale/static/src/js/tours/sale.js @@ -0,0 +1,126 @@ +odoo.define('sale.tour', function(require) { +"use strict"; + +var core = require('web.core'); +var tour = require('web_tour.tour'); + +var _t = core._t; + +tour.register("sale_tour", { + url: "/web", + rainbowMan: false, + sequence: 20, +}, [tour.stepUtils.showAppsMenuItem(), { + trigger: ".o_app[data-menu-xmlid='sale.sale_menu_root']", + content: _t("Open Sales app to send your first quotation in a few clicks."), + position: "right", + edition: "community" +}, { + trigger: ".o_app[data-menu-xmlid='sale.sale_menu_root']", + content: _t("Open Sales app to send your first quotation in a few clicks."), + position: "bottom", + edition: "enterprise" +}, { + trigger: 'a.o_onboarding_step_action.btn[data-method=action_open_base_onboarding_company]', + extra_trigger: ".o_sale_order", + content: _t("Start by checking your company's data."), + position: "bottom", +}, { + trigger: ".modal-content button[name='action_save_onboarding_company_step']", + content: _t("Looks good. Let's continue."), + position: "left", +}, { + trigger: 'a.o_onboarding_step_action.btn[data-method=action_open_base_document_layout]', + extra_trigger: ".o_sale_order", + content: _t("Customize your quotes and orders."), + position: "bottom", +}, { + trigger: "button[name='document_layout_save']", + extra_trigger: ".o_sale_order", + content: _t("Good job, let's continue."), + position: "top", // dot NOT move to bottom, it would cause a resize flicker +}, { + trigger: 'a.o_onboarding_step_action.btn[data-method=action_open_sale_onboarding_payment_acquirer]', + extra_trigger: ".o_sale_order", + content: _t("To speed up order confirmation, we can activate electronic signatures or payments."), + position: "bottom", +}, { + trigger: "button[name='add_payment_methods']", + extra_trigger: ".o_sale_order", + content: _t("Lets keep electronic signature for now."), + position: "bottom", +}, { + trigger: 'a.o_onboarding_step_action.btn[data-method=action_open_sale_onboarding_sample_quotation]', + extra_trigger: ".o_sale_order", + content: _t("Now, we'll create a sample quote."), + position: "bottom", +}]); + +tour.register("sale_quote_tour", { + url: "/web#action=sale.action_quotations_with_onboarding&view_type=form", + rainbowMan: true, + rainbowManMessage: "<b>Congratulations</b>, your first quotation is sent!<br>Check your email to validate the quote.", + sequence: 30, + }, [{ + trigger: ".o_form_editable .o_field_many2one[name='partner_id']", + extra_trigger: ".o_sale_order", + content: _t("Write a company name to create one, or see suggestions."), + position: "bottom", + run: function (actions) { + actions.text("Agrolait", this.$anchor.find("input")); + }, + }, { + trigger: ".ui-menu-item > a", + auto: true, + in_modal: false, + }, { + trigger: ".o_field_x2many_list_row_add > a", + extra_trigger: ".o_field_many2one[name='partner_id'] .o_external_button", + content: _t("Click here to add some products or services to your quotation."), + position: "bottom", + }, { + trigger: ".o_field_widget[name='product_id'], .o_field_widget[name='product_template_id']", + extra_trigger: ".o_sale_order", + content: _t("Select a product, or create a new one on the fly."), + position: "right", + run: function (actions) { + var $input = this.$anchor.find("input"); + actions.text("DESK0001", $input.length === 0 ? this.$anchor : $input); + // fake keydown to trigger search + var keyDownEvent = jQuery.Event("keydown"); + keyDownEvent.which = 42; + this.$anchor.trigger(keyDownEvent); + var $descriptionElement = $(".o_form_editable textarea[name='name']"); + // when description changes, we know the product has been created + $descriptionElement.change(function () { + $descriptionElement.addClass("product_creation_success"); + }); + }, + id: "product_selection_step" + }, { + trigger: ".ui-menu.ui-widget .ui-menu-item a:contains('DESK0001')", + auto: true, + }, { + trigger: ".o_form_editable textarea[name='name'].product_creation_success", + auto: true, + run: function () { + } // wait for product creation + }, { + trigger: ".o_field_widget[name='price_unit'] ", + extra_trigger: ".o_sale_order", + content: _t("<b>Set a price</b>."), + position: "right", + run: "text 10.0" + }, + ...tour.stepUtils.statusbarButtonsSteps("Send by Email", _t("<b>Send the quote</b> to yourself and check what the customer will receive."), ".o_statusbar_buttons button[name='action_quotation_send']"), + { + trigger: ".modal-footer button.btn-primary", + auto: true, + }, { + trigger: ".modal-footer button[name='action_send_mail']", + extra_trigger: ".modal-footer button[name='action_send_mail']", + content: _t("Let's send the quote."), + position: "bottom", + }]); + +}); diff --git a/addons/sale/static/src/js/variant_mixin.js b/addons/sale/static/src/js/variant_mixin.js new file mode 100644 index 00000000..3167be11 --- /dev/null +++ b/addons/sale/static/src/js/variant_mixin.js @@ -0,0 +1,637 @@ +odoo.define('sale.VariantMixin', function (require) { +'use strict'; + +var concurrency = require('web.concurrency'); +var core = require('web.core'); +var utils = require('web.utils'); +var ajax = require('web.ajax'); +var _t = core._t; + +var VariantMixin = { + events: { + 'change .css_attribute_color input': '_onChangeColorAttribute', + 'change .main_product:not(.in_cart) input.js_quantity': 'onChangeAddQuantity', + 'change [data-attribute_exclusions]': 'onChangeVariant' + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * When a variant is changed, this will check: + * - If the selected combination is available or not + * - The extra price if applicable + * - The display name of the product ("Customizable desk (White, Steel)") + * - The new total price + * - The need of adding a "custom value" input + * If the custom value is the only available value + * (defined by its data 'is_single_and_custom'), + * the custom value will have it's own input & label + * + * 'change' events triggered by the user entered custom values are ignored since they + * are not relevant + * + * @param {MouseEvent} ev + */ + onChangeVariant: function (ev) { + var $parent = $(ev.target).closest('.js_product'); + if (!$parent.data('uniqueId')) { + $parent.data('uniqueId', _.uniqueId()); + } + this._throttledGetCombinationInfo($parent.data('uniqueId'))(ev); + }, + /** + * @see onChangeVariant + * + * @private + * @param {Event} ev + * @returns {Deferred} + */ + _getCombinationInfo: function (ev) { + var self = this; + + if ($(ev.target).hasClass('variant_custom_value')) { + return Promise.resolve(); + } + + var $parent = $(ev.target).closest('.js_product'); + var qty = $parent.find('input[name="add_qty"]').val(); + var combination = this.getSelectedVariantValues($parent); + var parentCombination = $parent.find('ul[data-attribute_exclusions]').data('attribute_exclusions').parent_combination; + var productTemplateId = parseInt($parent.find('.product_template_id').val()); + + self._checkExclusions($parent, combination); + + return ajax.jsonRpc(this._getUri('/sale/get_combination_info'), 'call', { + 'product_template_id': productTemplateId, + 'product_id': this._getProductId($parent), + 'combination': combination, + 'add_qty': parseInt(qty), + 'pricelist_id': this.pricelistId || false, + 'parent_combination': parentCombination, + }).then(function (combinationData) { + self._onChangeCombination(ev, $parent, combinationData); + }); + }, + + /** + * Will add the "custom value" input for this attribute value if + * the attribute value is configured as "custom" (see product_attribute_value.is_custom) + * + * @private + * @param {MouseEvent} ev + */ + handleCustomValues: function ($target) { + var $variantContainer; + var $customInput = false; + if ($target.is('input[type=radio]') && $target.is(':checked')) { + $variantContainer = $target.closest('ul').closest('li'); + $customInput = $target; + } else if ($target.is('select')) { + $variantContainer = $target.closest('li'); + $customInput = $target + .find('option[value="' + $target.val() + '"]'); + } + + if ($variantContainer) { + if ($customInput && $customInput.data('is_custom') === 'True') { + var attributeValueId = $customInput.data('value_id'); + var attributeValueName = $customInput.data('value_name'); + + if ($variantContainer.find('.variant_custom_value').length === 0 + || $variantContainer + .find('.variant_custom_value') + .data('custom_product_template_attribute_value_id') !== parseInt(attributeValueId)) { + $variantContainer.find('.variant_custom_value').remove(); + + var $input = $('<input>', { + type: 'text', + 'data-custom_product_template_attribute_value_id': attributeValueId, + 'data-attribute_value_name': attributeValueName, + class: 'variant_custom_value form-control' + }); + + var isRadioInput = $target.is('input[type=radio]') && + $target.closest('label.css_attribute_color').length === 0; + + if (isRadioInput && $customInput.data('is_single_and_custom') !== 'True') { + $input.addClass('custom_value_radio'); + $target.closest('div').after($input); + } else { + $input.attr('placeholder', attributeValueName); + $input.addClass('custom_value_own_line'); + $variantContainer.append($input); + } + } + } else { + $variantContainer.find('.variant_custom_value').remove(); + } + } + }, + + /** + * Hack to add and remove from cart with json + * + * @param {MouseEvent} ev + */ + onClickAddCartJSON: function (ev) { + ev.preventDefault(); + var $link = $(ev.currentTarget); + var $input = $link.closest('.input-group').find("input"); + var min = parseFloat($input.data("min") || 0); + var max = parseFloat($input.data("max") || Infinity); + var previousQty = parseFloat($input.val() || 0, 10); + var quantity = ($link.has(".fa-minus").length ? -1 : 1) + previousQty; + var newQty = quantity > min ? (quantity < max ? quantity : max) : min; + + if (newQty !== previousQty) { + $input.val(newQty).trigger('change'); + } + return false; + }, + + /** + * When the quantity is changed, we need to query the new price of the product. + * Based on the price list, the price might change when quantity exceeds X + * + * @param {MouseEvent} ev + */ + onChangeAddQuantity: function (ev) { + var $parent; + + if ($(ev.currentTarget).closest('.oe_optional_products_modal').length > 0){ + $parent = $(ev.currentTarget).closest('.oe_optional_products_modal'); + } else if ($(ev.currentTarget).closest('form').length > 0){ + $parent = $(ev.currentTarget).closest('form'); + } else { + $parent = $(ev.currentTarget).closest('.o_product_configurator'); + } + + this.triggerVariantChange($parent); + }, + + /** + * Triggers the price computation and other variant specific changes + * + * @param {$.Element} $container + */ + triggerVariantChange: function ($container) { + var self = this; + $container.find('ul[data-attribute_exclusions]').trigger('change'); + $container.find('input.js_variant_change:checked, select.js_variant_change').each(function () { + self.handleCustomValues($(this)); + }); + }, + + /** + * Will look for user custom attribute values + * in the provided container + * + * @param {$.Element} $container + * @returns {Array} array of custom values with the following format + * {integer} custom_product_template_attribute_value_id + * {string} attribute_value_name + * {string} custom_value + */ + getCustomVariantValues: function ($container) { + var variantCustomValues = []; + $container.find('.variant_custom_value').each(function (){ + var $variantCustomValueInput = $(this); + if ($variantCustomValueInput.length !== 0){ + variantCustomValues.push({ + 'custom_product_template_attribute_value_id': $variantCustomValueInput.data('custom_product_template_attribute_value_id'), + 'attribute_value_name': $variantCustomValueInput.data('attribute_value_name'), + 'custom_value': $variantCustomValueInput.val(), + }); + } + }); + + return variantCustomValues; + }, + + /** + * Will look for attribute values that do not create product variant + * (see product_attribute.create_variant "dynamic") + * + * @param {$.Element} $container + * @returns {Array} array of attribute values with the following format + * {integer} custom_product_template_attribute_value_id + * {string} attribute_value_name + * {integer} value + * {string} attribute_name + * {boolean} is_custom + */ + getNoVariantAttributeValues: function ($container) { + var noVariantAttributeValues = []; + var variantsValuesSelectors = [ + 'input.no_variant.js_variant_change:checked', + 'select.no_variant.js_variant_change' + ]; + + $container.find(variantsValuesSelectors.join(',')).each(function (){ + var $variantValueInput = $(this); + var singleNoCustom = $variantValueInput.data('is_single') && !$variantValueInput.data('is_custom'); + + if ($variantValueInput.is('select')){ + $variantValueInput = $variantValueInput.find('option[value=' + $variantValueInput.val() + ']'); + } + + if ($variantValueInput.length !== 0 && !singleNoCustom){ + noVariantAttributeValues.push({ + 'custom_product_template_attribute_value_id': $variantValueInput.data('value_id'), + 'attribute_value_name': $variantValueInput.data('value_name'), + 'value': $variantValueInput.val(), + 'attribute_name': $variantValueInput.data('attribute_name'), + 'is_custom': $variantValueInput.data('is_custom') + }); + } + }); + + return noVariantAttributeValues; + }, + + /** + * Will return the list of selected product.template.attribute.value ids + * For the modal, the "main product"'s attribute values are stored in the + * "unchanged_value_ids" data + * + * @param {$.Element} $container the container to look into + */ + getSelectedVariantValues: function ($container) { + var values = []; + var unchangedValues = $container + .find('div.oe_unchanged_value_ids') + .data('unchanged_value_ids') || []; + + var variantsValuesSelectors = [ + 'input.js_variant_change:checked', + 'select.js_variant_change' + ]; + _.each($container.find(variantsValuesSelectors.join(', ')), function (el) { + values.push(+$(el).val()); + }); + + return values.concat(unchangedValues); + }, + + /** + * Will return a promise: + * + * - If the product already exists, immediately resolves it with the product_id + * - If the product does not exist yet ("dynamic" variant creation), this method will + * create the product first and then resolve the promise with the created product's id + * + * @param {$.Element} $container the container to look into + * @param {integer} productId the product id + * @param {integer} productTemplateId the corresponding product template id + * @param {boolean} useAjax wether the rpc call should be done using ajax.jsonRpc or using _rpc + * @returns {Promise} the promise that will be resolved with a {integer} productId + */ + selectOrCreateProduct: function ($container, productId, productTemplateId, useAjax) { + var self = this; + productId = parseInt(productId); + productTemplateId = parseInt(productTemplateId); + var productReady = Promise.resolve(); + if (productId) { + productReady = Promise.resolve(productId); + } else { + var params = { + product_template_id: productTemplateId, + product_template_attribute_value_ids: + JSON.stringify(self.getSelectedVariantValues($container)), + }; + + var route = '/sale/create_product_variant'; + if (useAjax) { + productReady = ajax.jsonRpc(route, 'call', params); + } else { + productReady = this._rpc({route: route, params: params}); + } + } + + return productReady; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Will disable attribute value's inputs based on combination exclusions + * and will disable the "add" button if the selected combination + * is not available + * + * This will check both the exclusions within the product itself and + * the exclusions coming from the parent product (meaning that this product + * is an option of the parent product) + * + * It will also check that the selected combination does not exactly + * match a manually archived product + * + * @private + * @param {$.Element} $parent the parent container to apply exclusions + * @param {Array} combination the selected combination of product attribute values + */ + _checkExclusions: function ($parent, combination) { + var self = this; + var combinationData = $parent + .find('ul[data-attribute_exclusions]') + .data('attribute_exclusions'); + + $parent + .find('option, input, label') + .removeClass('css_not_available') + .attr('title', function () { return $(this).data('value_name') || ''; }) + .data('excluded-by', ''); + + // exclusion rules: array of ptav + // for each of them, contains array with the other ptav they exclude + if (combinationData.exclusions) { + // browse all the currently selected attributes + _.each(combination, function (current_ptav) { + if (combinationData.exclusions.hasOwnProperty(current_ptav)) { + // for each exclusion of the current attribute: + _.each(combinationData.exclusions[current_ptav], function (excluded_ptav) { + // disable the excluded input (even when not already selected) + // to give a visual feedback before click + self._disableInput( + $parent, + excluded_ptav, + current_ptav, + combinationData.mapped_attribute_names + ); + }); + } + }); + } + + // parent exclusions (tell which attributes are excluded from parent) + _.each(combinationData.parent_exclusions, function (exclusions, excluded_by){ + // check that the selected combination is in the parent exclusions + _.each(exclusions, function (ptav) { + + // disable the excluded input (even when not already selected) + // to give a visual feedback before click + self._disableInput( + $parent, + ptav, + excluded_by, + combinationData.mapped_attribute_names, + combinationData.parent_product_name + ); + }); + }); + }, + /** + * Extracted to a method to be extendable by other modules + * + * @param {$.Element} $parent + */ + _getProductId: function ($parent) { + return parseInt($parent.find('.product_id').val()); + }, + /** + * Will disable the input/option that refers to the passed attributeValueId. + * This is used for showing the user that some combinations are not available. + * + * It will also display a message explaining why the input is not selectable. + * Based on the "excludedBy" and the "productName" params. + * e.g: Not available with Color: Black + * + * @private + * @param {$.Element} $parent + * @param {integer} attributeValueId + * @param {integer} excludedBy The attribute value that excludes this input + * @param {Object} attributeNames A dict containing all the names of the attribute values + * to show a human readable message explaining why the input is disabled. + * @param {string} [productName] The parent product. If provided, it will be appended before + * the name of the attribute value that excludes this input + * e.g: Not available with Customizable Desk (Color: Black) + */ + _disableInput: function ($parent, attributeValueId, excludedBy, attributeNames, productName) { + var $input = $parent + .find('option[value=' + attributeValueId + '], input[value=' + attributeValueId + ']'); + $input.addClass('css_not_available'); + $input.closest('label').addClass('css_not_available'); + + if (excludedBy && attributeNames) { + var $target = $input.is('option') ? $input : $input.closest('label').add($input); + var excludedByData = []; + if ($target.data('excluded-by')) { + excludedByData = JSON.parse($target.data('excluded-by')); + } + + var excludedByName = attributeNames[excludedBy]; + if (productName) { + excludedByName = productName + ' (' + excludedByName + ')'; + } + excludedByData.push(excludedByName); + + $target.attr('title', _.str.sprintf(_t('Not available with %s'), excludedByData.join(', '))); + $target.data('excluded-by', JSON.stringify(excludedByData)); + } + }, + /** + * @see onChangeVariant + * + * @private + * @param {MouseEvent} ev + * @param {$.Element} $parent + * @param {Array} combination + */ + _onChangeCombination: function (ev, $parent, combination) { + var self = this; + var $price = $parent.find(".oe_price:first .oe_currency_value"); + var $default_price = $parent.find(".oe_default_price:first .oe_currency_value"); + var $optional_price = $parent.find(".oe_optional:first .oe_currency_value"); + $price.text(self._priceToStr(combination.price)); + $default_price.text(self._priceToStr(combination.list_price)); + + var isCombinationPossible = true; + if (!_.isUndefined(combination.is_combination_possible)) { + isCombinationPossible = combination.is_combination_possible; + } + this._toggleDisable($parent, isCombinationPossible); + + if (combination.has_discounted_price) { + $default_price + .closest('.oe_website_sale') + .addClass("discount"); + $optional_price + .closest('.oe_optional') + .removeClass('d-none') + .css('text-decoration', 'line-through'); + $default_price.parent().removeClass('d-none'); + } else { + $default_price + .closest('.oe_website_sale') + .removeClass("discount"); + $optional_price.closest('.oe_optional').addClass('d-none'); + $default_price.parent().addClass('d-none'); + } + + var rootComponentSelectors = [ + 'tr.js_product', + '.oe_website_sale', + '.o_product_configurator' + ]; + + // update images only when changing product + // or when either ids are 'false', meaning dynamic products. + // Dynamic products don't have images BUT they may have invalid + // combinations that need to disable the image. + if (!combination.product_id || + !this.last_product_id || + combination.product_id !== this.last_product_id) { + this.last_product_id = combination.product_id; + self._updateProductImage( + $parent.closest(rootComponentSelectors.join(', ')), + combination.display_image, + combination.product_id, + combination.product_template_id, + combination.carousel, + isCombinationPossible + ); + } + + $parent + .find('.product_id') + .first() + .val(combination.product_id || 0) + .trigger('change'); + + $parent + .find('.product_display_name') + .first() + .text(combination.display_name); + + $parent + .find('.js_raw_price') + .first() + .text(combination.price) + .trigger('change'); + + this.handleCustomValues($(ev.target)); + }, + + /** + * returns the formatted price + * + * @private + * @param {float} price + */ + _priceToStr: function (price) { + var l10n = _t.database.parameters; + var precision = 2; + + if ($('.decimal_precision').length) { + precision = parseInt($('.decimal_precision').last().data('precision')); + } + var formatted = _.str.sprintf('%.' + precision + 'f', price).split('.'); + formatted[0] = utils.insert_thousand_seps(formatted[0]); + return formatted.join(l10n.decimal_point); + }, + /** + * Returns a throttled `_getCombinationInfo` with a leading and a trailing + * call, which is memoized per `uniqueId`, and for which previous results + * are dropped. + * + * The uniqueId is needed because on the configurator modal there might be + * multiple elements triggering the rpc at the same time, and we need each + * individual product rpc to be executed, but only once per individual + * product. + * + * The leading execution is to keep good reactivity on the first call, for + * a better user experience. The trailing is because ultimately only the + * information about the last selected combination is useful. All + * intermediary rpc can be ignored and are therefore best not done at all. + * + * The DropMisordered is to make sure slower rpc are ignored if the result + * of a newer rpc has already been received. + * + * @private + * @param {string} uniqueId + * @returns {function} + */ + _throttledGetCombinationInfo: _.memoize(function (uniqueId) { + var dropMisordered = new concurrency.DropMisordered(); + var _getCombinationInfo = _.throttle(this._getCombinationInfo.bind(this), 500); + return function (ev, params) { + return dropMisordered.add(_getCombinationInfo(ev, params)); + }; + }), + /** + * Toggles the disabled class depending on the $parent element + * and the possibility of the current combination. + * + * @private + * @param {$.Element} $parent + * @param {boolean} isCombinationPossible + */ + _toggleDisable: function ($parent, isCombinationPossible) { + $parent.toggleClass('css_not_available', !isCombinationPossible); + }, + /** + * Updates the product image. + * This will use the productId if available or will fallback to the productTemplateId. + * + * @private + * @param {$.Element} $productContainer + * @param {boolean} displayImage will hide the image if true. It will use the 'invisible' class + * instead of d-none to prevent layout change + * @param {integer} product_id + * @param {integer} productTemplateId + */ + _updateProductImage: function ($productContainer, displayImage, productId, productTemplateId) { + var model = productId ? 'product.product' : 'product.template'; + var modelId = productId || productTemplateId; + var imageUrl = '/web/image/{0}/{1}/' + (this._productImageField ? this._productImageField : 'image_1024'); + var imageSrc = imageUrl + .replace("{0}", model) + .replace("{1}", modelId); + + var imagesSelectors = [ + 'span[data-oe-model^="product."][data-oe-type="image"] img:first', + 'img.product_detail_img', + 'span.variant_image img', + 'img.variant_image', + ]; + + var $img = $productContainer.find(imagesSelectors.join(', ')); + + if (displayImage) { + $img.removeClass('invisible').attr('src', imageSrc); + } else { + $img.addClass('invisible'); + } + }, + + /** + * Highlight selected color + * + * @private + * @param {MouseEvent} ev + */ + _onChangeColorAttribute: function (ev) { + var $parent = $(ev.target).closest('.js_product'); + $parent.find('.css_attribute_color') + .removeClass("active") + .filter(':has(input:checked)') + .addClass("active"); + }, + + /** + * Extension point for website_sale + * + * @private + * @param {string} uri The uri to adapt + */ + _getUri: function (uri) { + return uri; + } +}; + +return VariantMixin; + +}); diff --git a/addons/sale/static/src/scss/product_configurator.scss b/addons/sale/static/src/scss/product_configurator.scss new file mode 100644 index 00000000..65a3e0a4 --- /dev/null +++ b/addons/sale/static/src/scss/product_configurator.scss @@ -0,0 +1,227 @@ +.css_attribute_color { + display: inline-block; + border: 1px solid #999999; + text-align: center; + + input { + margin: 8px; + height: 13px; + opacity: 0; + } + + &.active { + border: 3px ridge #66ee66; + } + + &.active input { + margin: 6px; + } + + &.custom_value { + background-image: linear-gradient(to bottom right, #FF0000, #FFF200, #1E9600); + } +} + +.css_not_available_msg { + display: none; +} + +.css_not_available.js_product { + .css_quantity, + .product_price { + display: none; + } + + .css_not_available_msg { + display: block; + } + + .js_add, + .oe_price, + .oe_default_price, + .oe_optional { + display: none; + } +} + +.css_quantity { + width: initial; // We don't want the quantity form to be full-width + + input[name="add_qty"] { + max-width: 50px; + text-align: center; + } +} + +option.css_not_available { + color: #ccc; +} + +label.css_not_available { + opacity: 0.6; +} + +label.css_attribute_color.css_not_available { + opacity: 1; + background-image: url("/website_sale/static/src/img/redcross.png"); + background-size: cover; +} + +.variant_attribute { + padding-bottom: 0.5rem; + + .attribute_name { + padding-bottom: 0.5rem; + display: block; + } + + .radio_input { + margin-right: 0.7rem; + vertical-align: middle; + } + + .radio_input_value { + display: inline-block; + vertical-align: middle; + line-height: 1; + } + + .variant_custom_value { + margin-bottom: 0.7rem; + + &.custom_value_own_line { + display: inline-block; + } + } + + .custom_value_radio { + margin: 0.3rem 0rem 0.3rem 1.6rem; + } + + select { + margin-bottom: 0.5rem; + } +} + +.o_product_configurator { + .product_detail_img { + max-height: 240px; + } + + .variant_attribute { + .custom_value_radio { + margin: 0.3rem 0rem 0.3rem 2.1rem; + } + } +} + +.oe_optional_products_modal { + .table-striped tbody tr:nth-of-type(odd) { + background-color: rgba(0, 0, 0, 0.025); + } + + .o_total_row { + font-size: 1.2rem; + } +} + +.modal.o_technical_modal .oe_optional_products_modal .btn.js_add_cart_json { + padding: 0.075rem 0.75rem; +} + +.js_product { + &.in_cart { + .js_add_cart_variants { + display: none; + } + } + + select { + -webkit-appearance: menulist; + -moz-appearance: menulist; + appearance: menulist; + background-image: none; + } + + .td-product_name { + word-wrap: break-word; + } + + .td-product_name { + min-width: 140px; + } + + .td-img { + width: 100px; + } + + .td-qty { + width: 200px; + a.input-group-addon { + background-color: transparent; + border: 0px; + } + + .input-group { + display: inline-flex; + } + } + .td-action { + width: 30px; + } + + .td-price, + .td-price-total { + width: 120px; + } + + @include media-breakpoint-down(sm) { + .td-img, + .td-price-total { + display: none; + } + + .td-qty { + width: 60px; + } + + .td-price { + width: 80px; + } + } + + @media (max-width: 476px) { + .td-qty { + width: 60px; + } + + #modal_optional_products table thead, + .oe_cart table thead { + display: none; + } + + #modal_optional_products table td.td-img, + .oe_cart table td.td-img { + display: none; + } + } +} + +.o_total_row { + height: 50px; +} + +.oe_striked_price { + text-decoration: line-through; + white-space: nowrap; +} + +.o_list_view { + .o_data_row.o_selected_row > .o_data_cell:not(.o_readonly_modifier) { + .o_field_widget .o_edit_product_configuration { + padding: 0; + background-color: inherit; + margin-left: 3px; + } + } +} diff --git a/addons/sale/static/src/scss/sale_onboarding.scss b/addons/sale/static/src/scss/sale_onboarding.scss new file mode 100644 index 00000000..6f00b2a6 --- /dev/null +++ b/addons/sale/static/src/scss/sale_onboarding.scss @@ -0,0 +1,11 @@ +.o_onboarding_order_confirmation { + & span.o_onboarding_order_confirmation_help img { + display: none; + position: absolute; + bottom:0; + } + & span.o_onboarding_order_confirmation_help:hover img { + display: block + } + +}
\ No newline at end of file diff --git a/addons/sale/static/src/scss/sale_portal.scss b/addons/sale/static/src/scss/sale_portal.scss new file mode 100644 index 00000000..56b7883a --- /dev/null +++ b/addons/sale/static/src/scss/sale_portal.scss @@ -0,0 +1,42 @@ +/* ---- My Orders page ---- */ + +.orders_vertical_align { + display: flex; + align-items: center; +} + +.orders_label_text_align { + vertical-align: 15%; +} + +/* ---- Order page ---- */ + +.sale_tbody .o_line_note { + word-break: break-word; + word-wrap: break-word; + overflow-wrap: break-word; +} + +.sale_tbody input.js_quantity { + min-width: 48px; + text-align: center; +} + +.sale_tbody div.input-group.w-50.pull-right { + width: 100% !important; +} + +.o_portal .sale_tbody .js_quantity_container { + + .js_quantity { + padding: 0; + } + + .input-group-text { + padding: 0.2rem 0.4rem; + } + + @include media-breakpoint-down(sm) { + width: 100%; + } +} diff --git a/addons/sale/static/src/scss/sale_report.scss b/addons/sale/static/src/scss/sale_report.scss new file mode 100644 index 00000000..59380518 --- /dev/null +++ b/addons/sale/static/src/scss/sale_report.scss @@ -0,0 +1,3 @@ +.sale_tbody .o_line_note { + word-break: break-word; +} |
