Initial commit: Sale packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:49 +02:00
commit 14e3d26998
6469 changed files with 2479670 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View file

@ -0,0 +1,37 @@
odoo.define('sale.payment_form', require => {
'use strict';
const checkoutForm = require('payment.checkout_form');
const manageForm = require('payment.manage_form');
const salePaymentMixin = {
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Add `sale_order_id` to the transaction route params if it is provided.
*
* @override method from payment.payment_form_mixin
* @private
* @param {string} code - The code of the selected payment option's provider
* @param {number} paymentOptionId - The id of the selected payment option
* @param {string} flow - The online payment flow of the selected payment option
* @return {object} The extended transaction route params
*/
_prepareTransactionRouteParams: function (code, paymentOptionId, flow) {
const transactionRouteParams = this._super(...arguments);
return {
...transactionRouteParams,
'sale_order_id': this.txContext.saleOrderId
? parseInt(this.txContext.saleOrderId) : undefined,
};
},
};
checkoutForm.include(salePaymentMixin);
manageForm.include(salePaymentMixin);
});

View file

@ -0,0 +1,65 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { FloatField } from "@web/views/fields/float/float_field";
import { _lt } from "@web/core/l10n/translation";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
/**
* Dialog called if user changes a value 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
* (3) value is the same in all other sale order line
*/
export class ProductDiscountField extends FloatField {
setup() {
super.setup();
this.dialogService = useService("dialog");
}
onChange(ev) {
if (!("order_line" in this.props.record.model.root.data)) {
return;
}
const x2mList = this.props.record.model.root.data.order_line;
const orderLines = x2mList.records.filter(line => !line.data.display_type);
if (orderLines.length < 3) {
return;
}
const isFirstOrderLine = this.props.record.data.id === orderLines[0].data.id;
if (isFirstOrderLine && sameValue(orderLines)) {
this.dialogService.add(ConfirmationDialog, {
body: _lt("Do you want to apply this value to all lines ?"),
confirm: () => {
const commands = orderLines.slice(1).map((line) => {
return {
operation: "UPDATE",
record: line,
data: {["discount"]: this.props.value},
};
});
x2mList.applyCommands('order_line', commands);
},
});
}
}
}
// TODO remove this function, no need to export it anymore.
export function sameValue(orderLines) {
const compareValue = orderLines[1].data.discount;
return orderLines.slice(1).every(line => line.data.discount === compareValue);
}
ProductDiscountField.components = { ConfirmationDialog };
ProductDiscountField.template = "sale.ProductDiscountField";
ProductDiscountField.displayName = _lt("Disc.%");
registry.category("fields").add("sol_discount", ProductDiscountField)

View file

@ -0,0 +1,13 @@
/** @odoo-module */
import publicWidget from 'web.public.widget';
import "portal.portal"; // force dependencies
publicWidget.registry.PortalHomeCounters.include({
/**
* @override
*/
_getCountersAlwaysDisplayed() {
return this._super(...arguments).concat(['quotation_count', 'order_count']);
},
});

View file

@ -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 signature, automatically open the popup for payment
if ($.bbq.getState('allow_payment') === 'yes' && this.$('#o_sale_portal_paynow').length) {
this.el.querySelector('#o_sale_portal_paynow').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(' ');
},
});
});

View file

@ -0,0 +1,102 @@
/** @odoo-module **/
import { registry } from '@web/core/registry';
import { Many2OneField } from '@web/views/fields/many2one/many2one_field';
import { useEffect } from '@odoo/owl';
export class SaleOrderLineProductField extends Many2OneField {
setup() {
super.setup();
let isMounted = false;
let isInternalUpdate = false;
const super_update = this.update;
this.update = (recordlist) => {
isInternalUpdate = true;
super_update(recordlist);
};
if (this.props.canQuickCreate) {
this.quickCreate = (name) => {
isInternalUpdate = true;
return this.props.update([false, name]);
};
}
useEffect(value => {
if (!isMounted) {
isMounted = true;
} else if (value && isInternalUpdate) {
// we don't want to trigger product update when update comes from an external sources,
// such as an onchange, or the product configuration dialog itself
if (this.props.relation === 'product.template') {
this._onProductTemplateUpdate();
} else {
this._onProductUpdate();
}
}
isInternalUpdate = false;
}, () => [Array.isArray(this.value) && this.value[0]]);
}
get isProductClickable() {
// product form should be accessible if the widget field is readonly
// or if the line cannot be edited (e.g. locked SO)
return (
this.props.record.isReadonly(this.props.name)
|| this.props.record.model.root.isReadonly
&& this.props.record.model.root.activeFields.order_line
&& this.props.record.model.root.isReadonly('order_line')
)
}
get hasExternalButton() {
// Keep external button, even if field is specified as 'no_open' so that the user is not
// redirected to the product when clicking on the field content
const res = super.hasExternalButton;
return res || (!!this.props.value && !this.state.isFloating);
}
get hasConfigurationButton() {
return this.isConfigurableLine || this.isConfigurableTemplate;
}
get isConfigurableLine() { return false; }
get isConfigurableTemplate() { return false; }
get configurationButtonHelp() {
return this.env._t("Edit Configuration");
}
get configurationButtonIcon() {
return 'btn btn-secondary fa ' + this.configurationButtonFAIcon();
}
configurationButtonFAIcon() {
return 'fa-pencil';
}
onClick(ev) {
// Override to get internal link to products in SOL that cannot be edited
if (this.props.readonly) {
ev.stopPropagation();
this.openAction();
}
else {
super.onClick(ev);
}
}
async _onProductTemplateUpdate() { }
async _onProductUpdate() { } // event_booth_sale, event_sale, sale_renting
onEditConfiguration() {
if (this.isConfigurableLine) {
this._editLineConfiguration();
} else {
this._editProductConfiguration();
}
}
_editLineConfiguration() { } // event_booth_sale, event_sale, sale_renting
_editProductConfiguration() { } // sale_product_configurator, sale_product_matrix
}
SaleOrderLineProductField.template = "sale.SaleProductField";
registry.category("fields").add("sol_product_many2one", SaleOrderLineProductField);

View file

@ -0,0 +1,45 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { KanbanProgressBarField } from "@web/views/fields/progress_bar/kanban_progress_bar_field";
const { useEffect } = owl;
/**
* A custom Component for the view of sales teams on the kanban view in the CRM app.
*
* The wanted behavior is to show a progress bar when an invoicing target is defined or show
* a link redirecting to the record's form view otherwise.
*/
export class SaleProgressBarField extends KanbanProgressBarField {
/**
* Anything used by the component is defined on the setup method.
*/
setup() {
super.setup();
this.actionService = useService("action");
this.orm = useService("orm");
useEffect(() => {
this.state.isInvoicingTargetDefined = this.props.record.data[this.props.maxValueField];
});
}
/**
* Display the form view of the record on click.
*/
async defineInvoicingTarget() {
const { resId, resModel } = this.props.record;
const action = await this.orm.call(resModel, "get_formview_action", [[resId]]);
this.actionService.doAction(action, { props: { mode: "edit" } });
}
}
/**
* Define the template name used on the component.
*/
SaleProgressBarField.template = "sale.SaleProgressBarField";
registry.category("fields").add("sales_team_progressbar", SaleProgressBarField);

View file

@ -0,0 +1,129 @@
odoo.define('sale.tour', function(require) {
"use strict";
const {_t} = require('web.core');
const {Markup} = require('web.utils');
var tour = require('web_tour.tour');
const { markup } = owl;
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",
skip_trigger: 'a[data-method=action_open_base_onboarding_company].o_onboarding_step_action__done',
}, {
trigger: 'input[id=street]',
content: _t("Complete your company's data"),
position: "bottom",
skip_trigger: 'a[data-method=action_open_base_onboarding_company].o_onboarding_step_action__done',
}, {
trigger: ".modal-content button[name='action_save_onboarding_company_step']",
content: _t("Looks good. Let's continue."),
position: "left",
skip_trigger: 'a[data-method=action_open_base_onboarding_company].o_onboarding_step_action__done',
}, {
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",
skip_trigger: 'a[data-method=action_open_base_document_layout].o_onboarding_step_action__done',
}, {
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
skip_trigger: 'a[data-method=action_open_base_document_layout].o_onboarding_step_action__done',
}, {
trigger: 'a.o_onboarding_step_action.btn[data-method=action_open_sale_onboarding_payment_provider]',
extra_trigger: ".o_sale_order",
content: _t("To speed up order confirmation, we can activate electronic signatures or payments."),
position: "bottom",
skip_trigger: 'a[data-method=action_open_sale_onboarding_payment_provider].o_onboarding_step_action__done',
}, {
trigger: "button[name='add_payment_methods']",
extra_trigger: ".o_sale_order",
content: _t("Lets keep electronic signature for now."),
position: "bottom",
skip_trigger: 'a[data-method=action_open_sale_onboarding_payment_provider].o_onboarding_step_action__done',
}, {
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: markup(_t("<b>Congratulations</b>, your first quotation is sent!<br>Check your email to validate the quote.")),
sequence: 30,
}, [{
trigger: ".o_field_res_partner_many2one[name='partner_id']",
extra_trigger: ".o_sale_order",
content: _t("Write a company name to create one, or see suggestions."),
position: "right",
run: function (actions) {
actions.text("Agrolait", this.$anchor.find("input"));
},
}, {
trigger: ".ui-menu-item > a:contains('Agrolait')",
auto: true,
in_modal: false,
}, {
trigger: ".o_field_x2many_list_row_add > a",
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: "a:contains('DESK0001')",
auto: true,
}, {
trigger: ".o_field_widget[name='price_unit'] ",
extra_trigger: ".fa-arrow-right", // Wait for product creation
content: Markup(_t("<b>Set a price</b>.")),
position: "right",
run: "text 10.0"
},
...tour.stepUtils.statusbarButtonsSteps("Send by Email", Markup(_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[name='action_send_mail']",
extra_trigger: ".modal-footer button[name='action_send_mail']",
content: _t("Let's send the quote."),
position: "bottom",
}]);
});

View file

@ -0,0 +1,758 @@
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 session = require('web.session');
var _t = core._t;
var VariantMixin = {
events: {
'change .css_attribute_color input': '_onChangeColorAttribute',
'click .o_variant_pills': '_onChangePillsAttribute',
'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) {
if ($(ev.target).hasClass('variant_custom_value')) {
return Promise.resolve();
}
const $parent = $(ev.target).closest('.js_product');
if(!$parent.length){
return Promise.resolve();
}
const combination = this.getSelectedVariantValues($parent);
let parentCombination;
if ($parent.hasClass('main_product')) {
parentCombination = $parent.find('ul[data-attribute_exclusions]').data('attribute_exclusions').parent_combination;
const $optProducts = $parent.parent().find(`[data-parent-unique-id='${$parent.data('uniqueId')}']`);
for (const optionalProduct of $optProducts) {
const $currentOptionalProduct = $(optionalProduct);
const childCombination = this.getSelectedVariantValues($currentOptionalProduct);
const productTemplateId = parseInt($currentOptionalProduct.find('.product_template_id').val());
ajax.jsonRpc(this._getUri('/sale/get_combination_info'), 'call', {
'product_template_id': productTemplateId,
'product_id': this._getProductId($currentOptionalProduct),
'combination': childCombination,
'add_qty': parseInt($currentOptionalProduct.find('input[name="add_qty"]').val()),
'pricelist_id': this.pricelistId || false,
'parent_combination': combination,
'context': session.user_context,
...this._getOptionalCombinationInfoParam($currentOptionalProduct),
}).then((combinationData) => {
if (this._shouldIgnoreRpcResult()) {
return;
}
this._onChangeCombination(ev, $currentOptionalProduct, combinationData);
this._checkExclusions($currentOptionalProduct, childCombination, combinationData.parent_exclusions);
});
}
} else {
parentCombination = this.getSelectedVariantValues(
$parent.parent().find('.js_product.in_cart.main_product')
);
}
return ajax.jsonRpc(this._getUri('/sale/get_combination_info'), 'call', {
'product_template_id': parseInt($parent.find('.product_template_id').val()),
'product_id': this._getProductId($parent),
'combination': combination,
'add_qty': parseInt($parent.find('input[name="add_qty"]').val()),
'pricelist_id': this.pricelistId || false,
'parent_combination': parentCombination,
'context': session.user_context,
...this._getOptionalCombinationInfoParam($parent),
}).then((combinationData) => {
if (this._shouldIgnoreRpcResult()) {
return;
}
this._onChangeCombination(ev, $parent, combinationData);
this._checkExclusions($parent, combination, combinationData.parent_exclusions);
});
},
/**
* Hook to add optional info to the combination info call.
*
* @param {$.Element} $product
*/
_getOptionalCombinationInfoParam($product) {
return {};
},
/**
* 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();
const previousCustomValue = $customInput.attr("previous_custom_value");
var $input = $('<input>', {
type: 'text',
'data-custom_product_template_attribute_value_id': attributeValueId,
'data-attribute_value_name': attributeValueName,
class: 'variant_custom_value form-control mt-2'
});
$input.attr('placeholder', attributeValueName);
$input.addClass('custom_value_radio');
$variantContainer.append($input);
if (previousCustomValue) {
$input.val(previousCustomValue);
}
}
} 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_advanced_configurator_modal').length > 0){
$parent = $(ev.currentTarget).closest('.oe_advanced_configurator_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) {
$container.find('ul[data-attribute_exclusions]').trigger('change');
$container.find('input.js_variant_change:checked, select.js_variant_change').each(function () {
VariantMixin.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) {
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(VariantMixin.getSelectedVariantValues($container)),
};
var route = '/sale/create_product_variant';
if (useAjax) {
productReady = ajax.jsonRpc(route, 'call', params);
} else if (Boolean(this._rpc)) {
// HACK to combine owl and non owl calls
productReady = this._rpc({route: route, params: params});
} else {
productReady = this.rpc(route, 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
* @param {Array} parentExclusions the exclusions induced by the variant selection of the parent product
* For example chair cannot have steel legs if the parent Desk doesn't have steel legs
*/
_checkExclusions: function ($parent, combination, parentExclusions) {
var self = this;
var combinationData = $parent
.find('ul[data-attribute_exclusions]')
.data('attribute_exclusions');
if (parentExclusions && combinationData.parent_exclusions) {
combinationData.parent_exclusions = parentExclusions;
}
$parent
.find('option, input, label, .o_variant_pills')
.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
);
});
}
});
}
// combination exclusions: array of array of ptav
// for example a product with 3 variation and one specific variation is disabled (archived)
// requires the first 2 to be selected for the third to be disabled
if (combinationData.archived_combinations) {
combinationData.archived_combinations.forEach((excludedCombination) => {
const ptavCommon = excludedCombination.filter((ptav) => combination.includes(ptav));
if (
!!ptavCommon
&& (combination.length === excludedCombination.length)
&& (ptavCommon.length === combination.length)
) {
// Selected combination is archived, all attributes must be disabled from each other
combination.forEach((ptav) => {
combination.forEach((ptavOther) => {
if (ptav === ptavOther) {
return;
}
self._disableInput(
$parent,
ptav,
ptavOther,
combinationData.mapped_attribute_names,
);
})
})
} else if (
!!ptavCommon
&& (combination.length === excludedCombination.length)
&& (ptavCommon.length === (combination.length - 1))
) {
// In this case we only need to disable the remaining ptav
const disabledPtav = excludedCombination.find((ptav) => !combination.includes(ptav));
excludedCombination.forEach((ptav) => {
if (ptav === disabledPtav) {
return;
}
self._disableInput(
$parent,
disabledPtav,
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');
$input.closest('.o_variant_pills').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 && !combination.compare_list_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);
if ($parent.hasClass('in_cart')) {
const primaryButton = $parent.parents('.modal-content').find('.modal-footer .btn-primary');
primaryButton.prop('disabled', !isCombinationPossible);
primaryButton.toggleClass('disabled', !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");
},
_onChangePillsAttribute: function (ev) {
const radio = ev.target.closest('.o_variant_pills').querySelector("input");
radio.click(); // Trigger onChangeVariant.
var $parent = $(ev.target).closest('.js_product');
$parent.find('.o_variant_pills')
.removeClass("active")
.filter(':has(input:checked)')
.addClass("active");
},
/**
* Return true if the current object has been destroyed.
* This function has been added as a fix to know if the result of a rpc
* should be handled. Indeed, "this._rpc()" can not be used as it is not
* supported by some elements that use this mixin.
*
* @private
*/
_shouldIgnoreRpcResult() {
return (typeof this.isDestroyed === "function" && this.isDestroyed());
},
/**
* Extension point for website_sale
*
* @private
* @param {string} uri The uri to adapt
*/
_getUri: function (uri) {
return uri;
}
};
return VariantMixin;
});

View file

@ -0,0 +1,307 @@
.css_attribute_color {
position: relative;
display: inline-block;
border: 5px solid $input-border-color;
border-radius: 50%;
text-align: center;
transition: $input-transition;
@include o-field-pointer();
&:before {
content: "";
display: block;
@include o-position-absolute(-3px, -3px, -3px, -3px);
border: 4px solid white;
border-radius: 50%;
box-shadow: inset 0 0 3px rgba(black, 0.3);
}
input {
margin: 8px;
height: 13px;
width: 13px;
opacity: 0;
}
&.active {
border: 5px solid map-get($theme-colors, 'primary');
}
&.custom_value {
background-image: linear-gradient(to bottom right, #FF0000, #FFF200, #1E9600);
}
&.transparent {
background-image: url(/web/static/img/transparent.png);
}
}
.css_not_available_msg {
display: none;
}
.css_not_available.js_product {
.css_quantity {
display: none !important;
}
.css_not_available_msg {
display: block;
}
.availability_messages {
display: none;
}
.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
.btn, input {
border-color: $input-border-color;
}
input {
// Needs !important because themes customize btns' padding direclty
// rather than change '$input-btn-padding-X' (shared with inputs).
height: auto !important;
max-width: 5ch;
}
}
option.css_not_available {
color: #ccc;
}
select.form-select.css_attribute_select {
background-image: str-replace(url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='175' height='100' fill='#{theme-color('primary')}'><polygon points='0,0 100,0 50,50'/></svg>"), "#", "%23") ;
background-size: 20px;
background-position: 100% 65%;
background-repeat: no-repeat;
max-width: 400px;
}
label, .o_variant_pills {
&.css_not_available {
opacity: 0.6;
}
}
label.css_attribute_color.css_not_available {
opacity: 1;
&:after {
content: "";
@include o-position-absolute(-5px, -5px, -5px, -5px);
border: 2px solid map-get($theme-colors, 'danger');
border-radius: 50%;
background: str-replace(url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='39' height='39'><line y2='0' x2='39' y1='39' x1='0' style='stroke:#{map-get($theme-colors, 'danger')};stroke-width:2'/><line y2='1' x2='40' y1='40' x1='1' style='stroke:rgb(255,255,255);stroke-width:1'/></svg>"), "#", "%23") ;
background-position: center;
background-repeat: no-repeat;
}
}
.variant_attribute {
padding-bottom: 1rem;
.attribute_name {
display: flex;
align-items: center;
font-size: 0.9rem;
text-transform: uppercase;
padding-bottom: 0.5rem;
&:after {
content: '';
margin-left: $spacer;
flex-grow: 1;
border-bottom: 1px solid map-get($grays, '400');
}
}
.radio_input_value {
font-weight: 400;
&:not(.o_variant_pills_input_value) {
margin-right: $spacer;
&, > span {
vertical-align: middle;
}
}
&.o_variant_pills_input_value {
.badge {
color: map-get($grays, '600');
background: white;
border: 1px solid map-get($theme-colors, 'primary');
&, > span {
vertical-align: middle;
}
.sign_badge_price_extra {
font-size: 1.1rem;
}
}
}
}
.variant_custom_value {
margin-bottom: 0.7rem;
&.custom_value_radio {
display: inline-block;
}
}
select {
margin-bottom: 0.5rem;
}
ul.list-inline {
margin-left: 0;
}
.o_variant_pills {
padding: $spacer/2 $spacer;
margin-right: 0.2rem;
border: none;
cursor: default !important;
&.btn.active {
background-color: map-get($theme-colors, 'primary');
}
&:not(.active) {
color: map-get($grays, '600');
background-color: map-get($grays, '200');
}
input {
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
opacity: 0;
position: absolute !important;
}
}
.radio_input_value, select, label {
.badge {
margin-left: 3px;
padding-left: 3px;
}
.sign_badge_price_extra {
display: inline-block;
width: 1rem;
height: 1rem;
color: map-get($theme-colors, 'primary');
background: white;
line-height: 1rem;
border-radius: 50%;
}
}
}
.o_product_configurator {
.product_detail_img {
max-height: 240px;
}
}
.table-striped tbody tr:nth-of-type(odd) {
.o_select_options {
background-color: rgba(0, 0, 0, 0.025);
}
.o_total_row {
font-size: 1.2rem;
}
}
.modal.o_technical_modal .oe_advanced_configurator_modal .btn.js_add_cart_json {
padding: 0.075rem 0.75rem;
}
.js_product {
.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(md) {
.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;
}

View file

@ -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
}
}

View file

@ -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(md) {
width: 100%;
}
}

View file

@ -0,0 +1,3 @@
.sale_tbody .o_line_note {
word-break: break-word;
}

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="sale.ProductDiscountField" t-inherit="web.FloatField" t-inherit-mode="primary" owl="1">
<xpath expr="//input" position="attributes">
<attribute name="t-on-change">onChange</attribute>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="sale.SaleProductField" t-inherit="web.Many2OneField" t-inherit-mode="primary" owl="1">
<!-- Make the product label clickable (to open its form view) when the user cannot
access it through the external button (because the product/line is readonly) -->
<xpath expr="//t[@t-if='!props.canOpen']" position="attributes">
<attribute name="t-if">
!isProductClickable
</attribute>
</xpath>
<!-- Show configuration button for custom lines/products -->
<xpath expr="//t[@t-if='hasExternalButton']" position="before">
<t t-if="hasConfigurationButton">
<button
type="button"
t-att-class="configurationButtonIcon"
tabindex="-1"
draggable="false"
t-att-aria-label="configurationButtonHelp"
t-att-data-tooltip="configurationButtonHelp"
t-on-click="onEditConfiguration"/>
</t>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="sale.SaleProgressBarField" owl="1">
<t t-if="state.isInvoicingTargetDefined">
<t t-call="web.ProgressBarField"/>
</t>
<t t-else="">
<!-- TODO is this class needed here ? -->
<a t-on-click.prevent="defineInvoicingTarget" href="#" class="sale_progressbar_form_link">
Click to define an invoicing target
</a>
</t>
</t>
</templates>