summaryrefslogtreecommitdiff
path: root/addons/sale/static/src
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/sale/static/src
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/sale/static/src')
-rw-r--r--addons/sale/static/src/img/onboarding_quotation_order_tooltip.jpgbin0 -> 12084 bytes
-rw-r--r--addons/sale/static/src/img/sale_quotation_onboarding_bg.jpgbin0 -> 35910 bytes
-rw-r--r--addons/sale/static/src/js/product_configurator_widget.js276
-rw-r--r--addons/sale/static/src/js/product_discount_widget.js37
-rw-r--r--addons/sale/static/src/js/sale.js60
-rw-r--r--addons/sale/static/src/js/sale_order_view.js56
-rw-r--r--addons/sale/static/src/js/sale_portal_sidebar.js117
-rw-r--r--addons/sale/static/src/js/tours/sale.js126
-rw-r--r--addons/sale/static/src/js/variant_mixin.js637
-rw-r--r--addons/sale/static/src/scss/product_configurator.scss227
-rw-r--r--addons/sale/static/src/scss/sale_onboarding.scss11
-rw-r--r--addons/sale/static/src/scss/sale_portal.scss42
-rw-r--r--addons/sale/static/src/scss/sale_report.scss3
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
new file mode 100644
index 00000000..548a1d35
--- /dev/null
+++ b/addons/sale/static/src/img/onboarding_quotation_order_tooltip.jpg
Binary files differ
diff --git a/addons/sale/static/src/img/sale_quotation_onboarding_bg.jpg b/addons/sale/static/src/img/sale_quotation_onboarding_bg.jpg
new file mode 100644
index 00000000..3c0f7a34
--- /dev/null
+++ b/addons/sale/static/src/img/sale_quotation_onboarding_bg.jpg
Binary files differ
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;
+}