19.0 vanilla
|
Before Width: | Height: | Size: 6 KiB After Width: | Height: | Size: 861 B |
|
|
@ -1 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="70" height="70" viewBox="0 0 70 70"><defs><path id="a" d="M4 0h61c4 0 5 1 5 5v60c0 4-1 5-5 5H4c-3 0-4-1-4-5V5c0-4 1-5 4-5z"/><linearGradient id="c" x1="100%" x2="0%" y1="0%" y2="100%"><stop offset="0%" stop-color="#DA956B"/><stop offset="100%" stop-color="#CC7039"/></linearGradient><path id="d" d="M56.243 52.279c.578 0 1.05.537 1.05 1.193v.978c0 .656-.472 1.194-1.05 1.194H13.532c-.578 0-1.05-.538-1.05-1.194v-35.8c0-.657.472-1.194 1.05-1.194h1.5c.578 0 1.05.537 1.05 1.194v33.629h40.161zM39 23.025l4.981 4.963-6.302 7.25-4.866-5.53c-.411-.467-1.068-.467-1.48 0L20.92 41.423a1.31 1.31 0 0 0-.018 1.68l2.494 2.924c.412.477 1.086.487 1.497.01l7.186-8.165 4.857 5.52a.965.965 0 0 0 1.488 0l9.558-10.86L53 37.664 55 20l-16 3.025z"/><path id="e" d="M56.243 50.279c.578 0 1.05.537 1.05 1.193v.978c0 .656-.472 1.194-1.05 1.194H13.532c-.578 0-1.05-.538-1.05-1.194v-35.8c0-.657.472-1.194 1.05-1.194h1.5c.578 0 1.05.537 1.05 1.194v33.629h40.161zM39 21.025l4.981 4.963-6.302 7.25-4.866-5.53c-.411-.467-1.068-.467-1.48 0L20.92 39.423a1.31 1.31 0 0 0-.018 1.68l2.494 2.924c.412.477 1.086.487 1.497.01l7.186-8.165 4.857 5.52a.965.965 0 0 0 1.488 0l9.558-10.86L53 35.664 55 18l-16 3.025z"/></defs><g fill="none" fill-rule="evenodd"><mask id="b" fill="#fff"><use xlink:href="#a"/></mask><g mask="url(#b)"><path fill="url(#c)" d="M0 0H70V70H0z"/><path fill="#FFF" fill-opacity=".383" d="M4 1h61c2.667 0 4.333.667 5 2V0H0v3c.667-1.333 2-2 4-2z"/><path fill="#393939" d="M45.243 69H4c-2 0-4-.146-4-4.077V35.315L13 16h3v26.5l15-14.27.974.024L40 21.096l13 14.27-18 18.346h22L45.243 69z" opacity=".324"/><path fill="#000" fill-opacity=".383" d="M4 69h61c2.667 0 4.333-1 5-3v4H0v-4c.667 2 2 3 4 3z"/><use fill="#000" fill-rule="nonzero" opacity=".3" xlink:href="#d"/><use fill="#FFF" fill-rule="nonzero" xlink:href="#e"/></g></g></svg>
|
||||
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><path d="M4 25a4 4 0 0 1 4-4h7v25H4V25Z" fill="#985184"/><path d="M26 8c0-2.21 1.876-4 4.19-4H46v38c0 2.21-1.876 4-4.19 4H26V8Z" fill="#FBB945"/><path d="M15 17.067C15 14.821 16.876 13 19.19 13H35v28.933C35 44.179 33.124 46 30.81 46H15V17.067Z" fill="#FC868B"/><path d="M26 46h4.81c2.314 0 4.19-1.821 4.19-4.067V13h-9v33Z" fill="#F86126"/><path d="m15 46 4.995-.002A4.005 4.005 0 0 0 24 41.995V21h-9v25Z" fill="#962B48"/></svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 511 B |
BIN
odoo-bringout-oca-ocb-sale/sale/static/description/icon_hi.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
9
odoo-bringout-oca-ocb-sale/sale/static/src/img/bag.svg
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.2233 2.70896C27.6214 1.90182 28.931 1.79727 29.9196 2.35586L29.921 2.35668C30.906 2.92274 31.4769 4.09983 31.4815 5.7074L31.4967 11.0667C31.4971 11.1929 31.395 11.2955 31.2688 11.2959C31.1425 11.2962 31.0399 11.1942 31.0396 11.068L31.0244 5.7087C31.0201 4.2002 30.4888 3.21066 29.694 2.75345C28.8949 2.30233 27.7656 2.34642 26.4519 3.10486C25.1387 3.86303 23.9468 5.16282 23.0753 6.67008C22.204 8.18154 21.6785 9.85639 21.6809 11.3659C21.6809 11.3659 21.6809 11.366 21.6809 11.3659L21.6961 16.725C21.6965 16.8513 21.5944 16.9539 21.4682 16.9543C21.3419 16.9546 21.2393 16.8526 21.239 16.7263L21.2238 11.3668C21.2211 9.75775 21.7785 8.00421 22.6794 6.44156L22.6796 6.44132C23.5805 4.88301 24.8244 3.51663 26.2233 2.70896Z" fill="#374874"/>
|
||||
<path d="M37.7926 2.67932L42.0505 38.2168L11.6401 55.7743L15.6829 15.4444L37.7926 2.67932Z" fill="#C1DBF6"/>
|
||||
<path d="M21.9496 61.7654L11.6401 55.7743L15.6829 15.4443H24.8303L25.9924 21.4355L21.9496 61.7654Z" fill="#FBDBD0"/>
|
||||
<path d="M48.1021 32.7335L37.7926 26.7423V2.67932L39.2776 6.88008L48.1021 8.67052V32.7335Z" fill="#FBDBD0"/>
|
||||
<path d="M48.1021 8.67053L52.36 44.208L21.9496 61.7655L25.9924 21.4356L48.1021 8.67053Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M36.9149 8.14731C38.3129 7.34017 39.6226 7.23562 40.6111 7.79422L40.6126 7.79503C41.5976 8.36109 42.1685 9.53818 42.173 11.1458L42.1882 16.505C42.1886 16.6313 42.0865 16.7339 41.9603 16.7342C41.8341 16.7346 41.7314 16.6326 41.7311 16.5063L41.7159 11.1471C41.7116 9.63855 41.1804 8.64901 40.3855 8.19181C39.5864 7.74068 38.4571 7.78477 37.1434 8.54321C35.8303 9.30138 34.6383 10.6012 33.7669 12.1084C32.8955 13.6199 32.37 15.2947 32.3724 16.8043C32.3724 16.8042 32.3724 16.8043 32.3724 16.8043L32.3876 22.1634C32.388 22.2896 32.2859 22.3923 32.1597 22.3926C32.0335 22.393 31.9309 22.2909 31.9305 22.1647L31.9153 16.8052C31.9127 15.1961 32.47 13.4426 33.3709 11.8799L33.3711 11.8797C34.272 10.3214 35.516 8.95498 36.9149 8.14731Z" fill="#374874"/>
|
||||
<path d="M37.7926 2.6793L39.2776 6.88008L48.1021 8.67048L52.36 44.208L21.9496 61.7654L11.6401 55.7742L15.6829 15.4443L37.7926 2.6793ZM37.7927 1.76501C37.634 1.76501 37.4761 1.80632 37.3355 1.88751L15.2258 14.6525C14.971 14.7996 14.8025 15.0604 14.7732 15.3531L10.7304 55.683C10.6946 56.0398 10.8707 56.3846 11.1807 56.5647L21.4902 62.5559C21.6322 62.6384 21.7909 62.6797 21.9496 62.6797C22.1075 62.6797 22.2653 62.6388 22.4068 62.5572L52.8172 44.9998C53.134 44.8168 53.3113 44.4626 53.2678 44.0992L49.0099 8.56172C48.963 8.1702 48.6704 7.85285 48.2839 7.77445L39.967 6.08707L38.6546 2.37457C38.5641 2.11839 38.3642 1.91576 38.1093 1.82161C38.0068 1.78378 37.8995 1.76501 37.7927 1.76501Z" fill="#374874"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
|
@ -0,0 +1,63 @@
|
|||
import { registry } from "@web/core/registry";
|
||||
import { Interaction } from "@web/public/interaction";
|
||||
|
||||
export class PortalPrepayment extends Interaction {
|
||||
static selector = ".o_portal_sale_sidebar";
|
||||
dynamicSelectors = {
|
||||
...this.dynamicSelectors,
|
||||
_amountPrepaymentButton: () => this.amountPrepaymentButton,
|
||||
_amountTotalButton: () => this.amountTotalButton,
|
||||
};
|
||||
dynamicContent = {
|
||||
_amountPrepaymentButton: {
|
||||
't-on-click': () => this.reloadAmount(true),
|
||||
't-att-class': () => ({ 'active': this.isDownPayment }),
|
||||
},
|
||||
_amountTotalButton: {
|
||||
't-on-click': () => this.reloadAmount(false),
|
||||
't-att-class': () => ({ 'active': !this.isDownPayment }),
|
||||
},
|
||||
'span[id="o_sale_portal_use_amount_prepayment"]': {
|
||||
't-att-class': () => ({ 'd-none': !this.isDownPayment }),
|
||||
},
|
||||
'span[id="o_sale_portal_use_amount_total"]': {
|
||||
't-att-class': () => ({ 'd-none': this.isDownPayment }),
|
||||
},
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.amountPrepaymentButton = document.querySelector(
|
||||
'button[name="o_sale_portal_amount_prepayment_button"]'
|
||||
);
|
||||
this.amountTotalButton = document.querySelector(
|
||||
'button[name="o_sale_portal_amount_total_button"]'
|
||||
);
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.has('amount_selection')) {
|
||||
this.isDownPayment = params.get('amount_selection') === 'down_payment'
|
||||
} else if (params.has('payment_amount')) {
|
||||
const paymentAmount = params.get('payment_amount');
|
||||
this.isDownPayment = Number(paymentAmount) < Number(this.el.dataset.orderAmountTotal);
|
||||
} else {
|
||||
this.isDownPayment = true;
|
||||
}
|
||||
this.showPaymentModal = params.has('payment_amount') || params.has('amount_selection');
|
||||
}
|
||||
|
||||
start() {
|
||||
// When updating the amount re-open the modal.
|
||||
if (this.showPaymentModal) {
|
||||
document.querySelector("#o_sale_portal_paynow")?.click();
|
||||
}
|
||||
}
|
||||
|
||||
reloadAmount(isDownPayment) {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
searchParams.set('amount_selection', isDownPayment ? 'down_payment' : 'full_amount');
|
||||
window.location.search = searchParams.toString();
|
||||
}
|
||||
}
|
||||
|
||||
registry
|
||||
.category("public.interactions")
|
||||
.add("sale.portal_prepayment", PortalPrepayment);
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { patch } from "@web/core/utils/patch";
|
||||
import { PortalHomeCounters } from '@portal/interactions/portal_home_counters';
|
||||
|
||||
patch(PortalHomeCounters.prototype, {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
getCountersAlwaysDisplayed() {
|
||||
return super.getCountersAlwaysDisplayed(...arguments).concat(['order_count']);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { Sidebar } from "@portal/interactions/sidebar";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
export class SaleSidebar extends Sidebar {
|
||||
static selector = ".o_portal_sale_sidebar";
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.spyWatched = document.querySelector("body[data-target='.navspy']");
|
||||
}
|
||||
|
||||
start() {
|
||||
super.start();
|
||||
// Nav Menu ScrollSpy
|
||||
this.generateMenu();
|
||||
// After signature, automatically open the popup for payment
|
||||
const searchParams = new URLSearchParams(window.location.search.substring(1));
|
||||
if (searchParams.get("allow_payment") === "yes") {
|
||||
this.el.querySelector("#o_sale_portal_paynow")?.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registry
|
||||
.category("public.interactions")
|
||||
.add("sale.sale_sidebar", SaleSidebar);
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { Component } from "@odoo/owl";
|
||||
import { formatCurrency } from "@web/core/currency";
|
||||
|
||||
export class BadgeExtraPrice extends Component {
|
||||
static template = "sale.BadgeExtraPrice";
|
||||
static props = {
|
||||
price: Number,
|
||||
currencyId: Number,
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the price, in the format of the given currency.
|
||||
*
|
||||
* @return {String} - The price, in the format of the given currency.
|
||||
*/
|
||||
getFormattedPrice() {
|
||||
return formatCurrency( Math.abs(this.props.price), this.props.currencyId);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="sale.BadgeExtraPrice">
|
||||
<span class="badge rounded-pill border ps-1 text-bg-primary">
|
||||
<span t-out="this.props.price > 0 ? '+' : '-'" class="me-1"/>
|
||||
<span t-out="getFormattedPrice()"/>
|
||||
</span>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
import { Component, useState, useSubEnv } from '@odoo/owl';
|
||||
import { formatCurrency } from '@web/core/currency';
|
||||
import { Dialog } from '@web/core/dialog/dialog';
|
||||
import { _t } from '@web/core/l10n/translation';
|
||||
import { rpc } from '@web/core/network/rpc';
|
||||
import { useService } from '@web/core/utils/hooks';
|
||||
import { ProductCombo } from '../models/product_combo';
|
||||
import { ProductTemplateAttributeLine } from '../models/product_template_attribute_line';
|
||||
import { ProductCard } from '../product_card/product_card';
|
||||
import {
|
||||
ProductConfiguratorDialog
|
||||
} from '../product_configurator_dialog/product_configurator_dialog';
|
||||
import { QuantityButtons } from '../quantity_buttons/quantity_buttons';
|
||||
|
||||
export class ComboConfiguratorDialog extends Component {
|
||||
static template = 'sale.ComboConfiguratorDialog';
|
||||
static components = { Dialog, ProductCard, QuantityButtons };
|
||||
static props = {
|
||||
product_tmpl_id: Number,
|
||||
display_name: String,
|
||||
quantity: Number,
|
||||
price: Number,
|
||||
combos: { type: Array, element: ProductCombo },
|
||||
currency_id: Number,
|
||||
company_id: { type: Number, optional: true },
|
||||
pricelist_id: { type: Number, optional: true },
|
||||
date: String,
|
||||
price_info: { type: String, optional: true },
|
||||
edit: { type: Boolean, optional: true },
|
||||
options: {
|
||||
type: Object,
|
||||
optional: true,
|
||||
shape: {
|
||||
showQuantity : { type: Boolean, optional: true },
|
||||
showPrice : { type: Boolean, optional: true },
|
||||
},
|
||||
},
|
||||
save: Function,
|
||||
discard: Function,
|
||||
close: Function,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.dialog = useService('dialog');
|
||||
this.env.dialogData.dismiss = !this.props.edit && this.props.discard.bind(this);
|
||||
this.state = useState({
|
||||
// Maps combo ids to selected combo items.
|
||||
// Note that selected combo items can be modified (i.e. their `no_variant` PTAVs can be
|
||||
// updated), so this map stores deep copies to avoid modifying the props.
|
||||
selectedComboItems: new Map(),
|
||||
quantity: this.props.quantity,
|
||||
basePrice: this.props.price,
|
||||
isLoading: false,
|
||||
});
|
||||
this._initSelectedComboItems();
|
||||
this.getPriceUrl = '/sale/combo_configurator/get_price';
|
||||
useSubEnv({ currency: { id: this.props.currency_id } });
|
||||
|
||||
this.unconfigurableCombos = this.props.combos.filter(combo => !combo.isConfigurable);
|
||||
this.configurableCombos = this.props.combos.filter(combo => combo.isConfigurable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the provided combo item, and open the product configurator iff the combo item's
|
||||
* product is configurable.
|
||||
*
|
||||
* @param {Number} comboId The id of the combo to which the combo item belongs.
|
||||
* @param {ProductComboItem} comboItem The combo item to select.
|
||||
*/
|
||||
async selectComboItem(comboId, comboItem) {
|
||||
// Use up-to-date selected PTAVs and custom values to populate the product configurator.
|
||||
comboItem = this.getSelectedOrProvidedComboItem(comboId, comboItem);
|
||||
let product = comboItem.product;
|
||||
if (comboItem.is_configurable) {
|
||||
this.dialog.add(ProductConfiguratorDialog, {
|
||||
productTemplateId: product.product_tmpl_id,
|
||||
ptavIds: product.selectedPtavIds,
|
||||
customPtavs: product.selectedCustomPtavs,
|
||||
quantity: 1,
|
||||
companyId: this.props.company_id,
|
||||
pricelistId: this.props.pricelist_id,
|
||||
currencyId: this.props.currency_id,
|
||||
soDate: this.props.date,
|
||||
edit: true, // Hide the optional products, if any.
|
||||
options: {
|
||||
canChangeVariant: false,
|
||||
showQuantity: false,
|
||||
showPrice: false,
|
||||
showPackaging: false,
|
||||
},
|
||||
size: "md",
|
||||
save: async configuredProduct => {
|
||||
const selectedComboItem = comboItem.deepCopy();
|
||||
selectedComboItem.product.ptals = configuredProduct.attribute_lines.map(
|
||||
ProductTemplateAttributeLine.fromProductConfiguratorPtal
|
||||
);
|
||||
this.state.selectedComboItems.set(comboId, selectedComboItem);
|
||||
},
|
||||
discard: () => {},
|
||||
...this._getAdditionalDialogProps(),
|
||||
});
|
||||
} else {
|
||||
this.state.selectedComboItems.set(comboId, comboItem.deepCopy());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the quantity of this combo product.
|
||||
*
|
||||
* @param {Number} quantity The new quantity of this combo product.
|
||||
*/
|
||||
async setQuantity(quantity) {
|
||||
if (quantity <= 0) quantity = 1;
|
||||
this.state.quantity = quantity;
|
||||
this.state.basePrice = await rpc(this.getPriceUrl, {
|
||||
product_tmpl_id: this.props.product_tmpl_id,
|
||||
currency_id: this.props.currency_id,
|
||||
quantity: quantity,
|
||||
date: this.props.date,
|
||||
company_id: this.props.company_id,
|
||||
pricelist_id: this.props.pricelist_id,
|
||||
...this._getAdditionalRpcParams(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the selected or provided combo item.
|
||||
*
|
||||
* If the provided combo item was already selected, then it may contain stale data (i.e.
|
||||
* selected PTAVs, custom values), and we should rely on the data in `state.selectedComboItems`
|
||||
* instead. Otherwise, the data in the provided combo item is up-to-date and can be used.
|
||||
*
|
||||
* @param {Number} comboId The id of the combo to which the combo item belongs.
|
||||
* @param {ProductComboItem} comboItem The provided combo item.
|
||||
* @return {ProductComboItem} The selected or provided combo item.
|
||||
*/
|
||||
getSelectedOrProvidedComboItem(comboId, comboItem) {
|
||||
const selectedComboItem = this.state.selectedComboItems.get(comboId);
|
||||
const isComboItemAlreadySelected = selectedComboItem?.id === comboItem.id;
|
||||
return isComboItemAlreadySelected ? selectedComboItem : comboItem;
|
||||
}
|
||||
|
||||
get totalMessage() {
|
||||
return _t("Total: %s", this.formattedTotalPrice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the total price for all units, formatted using the provided currency.
|
||||
*
|
||||
* @return {String} The formatted total price.
|
||||
*/
|
||||
get formattedTotalPrice() {
|
||||
return formatCurrency(this.state.quantity * this._comboPrice, this.props.currency_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a combo item has been selected for each combo.
|
||||
*
|
||||
* @return {Boolean} Whether a combo item has been selected for each combo.
|
||||
*/
|
||||
get areAllCombosSelected() {
|
||||
return this.state.selectedComboItems.size === this.props.combos.length;
|
||||
}
|
||||
|
||||
async confirm(options) {
|
||||
this.state.isLoading = true;
|
||||
await this.props.save(this._comboProductData, this._selectedComboItems, options).finally(
|
||||
() => this.state.isLoading = false
|
||||
)
|
||||
this.props.close();
|
||||
}
|
||||
|
||||
cancel() {
|
||||
if (!this.props.edit) {
|
||||
this.props.discard();
|
||||
}
|
||||
this.props.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the selected combo item in each combo.
|
||||
*/
|
||||
_initSelectedComboItems() {
|
||||
for (const combo of this.props.combos) {
|
||||
const comboItem = combo.selectedComboItem;
|
||||
if (comboItem) {
|
||||
this.state.selectedComboItems.set(combo.id, comboItem.deepCopy());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the total price per unit.
|
||||
*
|
||||
* The total price is the sum of:
|
||||
* - The combo product's price,
|
||||
* - The selected combo items' extra price,
|
||||
* - The selected `no_variant` attributes' extra price.
|
||||
*
|
||||
* @return {Number} The total price.
|
||||
*/
|
||||
get _comboPrice() {
|
||||
const extraPrice = Array.from(this.state.selectedComboItems.values()).reduce(
|
||||
(price, item) => price + item.totalExtraPrice, 0
|
||||
);
|
||||
return this.state.basePrice + extraPrice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return data about the combo product.
|
||||
*
|
||||
* @return {Object} Data about the combo product.
|
||||
*/
|
||||
get _comboProductData() {
|
||||
return { 'quantity': this.state.quantity };
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the selected combo items, in the same order as the combos given as props.
|
||||
*
|
||||
* @return {ProductComboItem[]} The sorted selected combo items.
|
||||
*/
|
||||
get _selectedComboItems() {
|
||||
const sortedItems = new Map([...this.state.selectedComboItems.entries()].sort(
|
||||
(entry1, entry2) =>
|
||||
this.props.combos.findIndex(combo => combo.id === entry1[0])
|
||||
- this.props.combos.findIndex(combo => combo.id === entry2[0])
|
||||
));
|
||||
return Array.from(sortedItems.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to append additional RPC params in overriding modules.
|
||||
*
|
||||
* @return {Object} The additional RPC params.
|
||||
*/
|
||||
_getAdditionalRpcParams() {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to append additional props in overriding modules.
|
||||
*
|
||||
* @return {Object} The additional props.
|
||||
*/
|
||||
_getAdditionalDialogProps() {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
@include media-breakpoint-down(md) {
|
||||
.sale-combo-configurator-dialog .css_quantity .form-control {
|
||||
max-width: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(lg) {
|
||||
.combo_configurator_quantity {
|
||||
border-left: $border-width solid $border-color;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="sale.ComboConfiguratorDialog">
|
||||
<Dialog
|
||||
title="props.display_name"
|
||||
contentClass="'sale-combo-configurator-dialog'"
|
||||
bodyClass="'d-flex flex-column gap-4'"
|
||||
>
|
||||
<div t-if="unconfigurableCombos.length" class="mb-4">
|
||||
<span class="d-inline-block mb-3 h4">
|
||||
Included
|
||||
</span>
|
||||
<div class="container">
|
||||
<div
|
||||
t-foreach="unconfigurableCombos"
|
||||
t-as="combo"
|
||||
t-key="combo.id"
|
||||
class="row mb-3"
|
||||
>
|
||||
<t t-set="product" t-value="combo.combo_items[0].product"/>
|
||||
<div class="col-2 p-0">
|
||||
<img
|
||||
t-attf-src="/web/image/product.product/{{product.id}}/image_256"
|
||||
alt="Product Image"
|
||||
role="img"
|
||||
class="img-thumbnail rounded"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
name="preselected_product_name"
|
||||
class="col d-flex justify-content-center text-break flex-column"
|
||||
>
|
||||
<span
|
||||
name="preselected_product_title"
|
||||
t-out="product.display_name"
|
||||
class="h5"
|
||||
/>
|
||||
<div
|
||||
t-if="product.description"
|
||||
t-out="product.description"
|
||||
class="text-muted small text-truncate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div t-foreach="configurableCombos" t-as="combo" t-key="combo.id">
|
||||
<span
|
||||
name="sale_combo_configurator_title"
|
||||
t-attf-class="d-inline-block mb-3 h4 {{combo_index !== 0? 'mt-4' : ''}}"
|
||||
t-out="combo.name"
|
||||
/>
|
||||
<div class="row row-cols-1 row-cols-md-3 g-3">
|
||||
<t t-foreach="combo.combo_items" t-as="comboItem" t-key="comboItem.id">
|
||||
<ProductCard
|
||||
product="getSelectedOrProvidedComboItem(combo.id, comboItem).product"
|
||||
extraPrice="comboItem.extra_price"
|
||||
onClick="() => this.selectComboItem(combo.id, comboItem)"
|
||||
isSelected="state.selectedComboItems.get(combo.id)?.id === comboItem.id"
|
||||
isConfigurable="comboItem.is_configurable"
|
||||
/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
<t t-set-slot="footer">
|
||||
<div class="d-flex flex-column-reverse flex-lg-row gap-3 w-100">
|
||||
<div class="d-flex flex-column flex-md-row gap-2">
|
||||
<button
|
||||
name="sale_combo_configurator_confirm_button"
|
||||
class="btn btn-primary w-100 w-lg-auto"
|
||||
t-att-disabled="!areAllCombosSelected || state.isLoading"
|
||||
t-on-click="confirm"
|
||||
>
|
||||
Add to order
|
||||
</button>
|
||||
<button
|
||||
name="sale_combo_configurator_cancel_button"
|
||||
class="btn btn-secondary w-100 w-lg-auto"
|
||||
t-on-click="cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<div class="d-flex gap-3 align-items-center">
|
||||
<div t-if="props.options?.showQuantity ?? true" class="combo_configurator_quantity ps-lg-3">
|
||||
<QuantityButtons
|
||||
quantity="state.quantity"
|
||||
setQuantity="quantity => this.setQuantity(quantity)"
|
||||
isMinusButtonDisabled="state.quantity === 1"
|
||||
btnClasses="'d-inline-block w-auto'"
|
||||
/>
|
||||
</div>
|
||||
<div t-if="props.options?.showPrice ?? true" class="w-100 w-md-auto">
|
||||
<span
|
||||
name="sale_combo_configurator_total"
|
||||
class="h6 mb-0"
|
||||
t-out="totalMessage"
|
||||
/>
|
||||
<span
|
||||
t-if="this.props.price_info"
|
||||
t-out="this.props.price_info"
|
||||
class="text-muted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { ProductComboItem } from './product_combo_item';
|
||||
|
||||
export class ProductCombo {
|
||||
/**
|
||||
* @param {number} id
|
||||
* @param {string} name
|
||||
* @param {ProductComboItem[]|object[]} combo_items
|
||||
*/
|
||||
constructor({id, name, combo_items}) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.combo_items = combo_items.map(item => new ProductComboItem(item));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the selected combo item, if any.
|
||||
*
|
||||
* @return {ProductComboItem|undefined} The selected combo item, if any.
|
||||
*/
|
||||
get selectedComboItem() {
|
||||
return this.combo_items.find(item => item.is_selected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the preselected combo item, if any.
|
||||
*
|
||||
* @return {ProductComboItem|undefined} The preselected combo items, if any.
|
||||
*/
|
||||
get preselectedComboItem() {
|
||||
return this.combo_items.find(item => item.is_preselected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether this combo is configurable.
|
||||
*
|
||||
* @return {Boolean} Whether this combo is configurable.
|
||||
*/
|
||||
get isConfigurable() {
|
||||
return !this.combo_items.some(item => item.is_preselected);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { ProductProduct } from './product_product';
|
||||
|
||||
export class ProductComboItem {
|
||||
/**
|
||||
* @param {number} id
|
||||
* @param {number} extra_price
|
||||
* @param {boolean} is_preselected
|
||||
* @param {boolean} is_selected
|
||||
* @param {boolean} is_configurable
|
||||
* @param {ProductProduct|object} product
|
||||
*/
|
||||
constructor({id, extra_price, is_preselected, is_selected, is_configurable, product}) {
|
||||
this.id = id;
|
||||
this.extra_price = extra_price;
|
||||
this.is_preselected = is_preselected;
|
||||
this.is_selected = is_selected;
|
||||
this.is_configurable = is_configurable;
|
||||
this.product = new ProductProduct(product);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the combo item's "total" extra price.
|
||||
*
|
||||
* The total extra price is the sum of:
|
||||
* - The combo item's extra price,
|
||||
* - The extra price of the selected `no_variant` PTAVs of the combo item's product.
|
||||
*
|
||||
* @return {Number} The combo item's "total" extra price.
|
||||
*/
|
||||
get totalExtraPrice() {
|
||||
return this.extra_price + this.product.selectedNoVariantPtavsPriceExtra;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a deep copy of this combo item.
|
||||
*
|
||||
* @return {ProductComboItem} A deep copy of this combo item.
|
||||
*/
|
||||
deepCopy() {
|
||||
return new ProductComboItem(JSON.parse(JSON.stringify(this)));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { ProductTemplateAttributeLine } from './product_template_attribute_line';
|
||||
|
||||
export class ProductProduct {
|
||||
/**
|
||||
* The instance is initialized in `setup` to allow patching, as constructors can't be patched.
|
||||
*/
|
||||
constructor(...args) {
|
||||
this.setup(...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
* @param {number} product_tmpl_id
|
||||
* @param {string} display_name
|
||||
* @param {ProductTemplateAttributeLine[]|object[]} ptals
|
||||
* @param {string} image_src
|
||||
* @param {string} description
|
||||
*/
|
||||
setup({id, product_tmpl_id, display_name, ptals, image_src, description}) {
|
||||
this.id = id;
|
||||
this.product_tmpl_id = product_tmpl_id;
|
||||
this.display_name = display_name;
|
||||
this.ptals = ptals.map(ptal => new ProductTemplateAttributeLine(ptal));
|
||||
this.image_src = image_src;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the `no_variant` PTALs.
|
||||
*
|
||||
* @return {ProductTemplateAttributeLine[]} The `no_variant` PTALs.
|
||||
*/
|
||||
get noVariantPtals() {
|
||||
return this.ptals.filter(ptal => ptal.create_variant === 'no_variant');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the extra price of the selected `no_variant` PTAVs.
|
||||
*
|
||||
* @return {Number} The extra price of the selected `no_variant` PTAVs.
|
||||
*/
|
||||
get selectedNoVariantPtavsPriceExtra() {
|
||||
return this.noVariantPtals.reduce((price, ptal) => price + ptal.selectedPtavsPriceExtra, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the selected PTAV ids.
|
||||
*
|
||||
* @return {Number[]} The selected PTAV ids.
|
||||
*/
|
||||
get selectedPtavIds() {
|
||||
return this.ptals.flatMap(ptal => ptal.selected_ptavs).map(ptav => ptav.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the selected `no_variant` PTAV ids.
|
||||
*
|
||||
* @return {Number[]} The selected `no_variant` PTAV ids.
|
||||
*/
|
||||
get selectedNoVariantPtavIds() {
|
||||
return this.noVariantPtals.flatMap(ptal => ptal.selected_ptavs).map(ptav => ptav.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the selected custom PTAVs.
|
||||
*
|
||||
* @return {{id: Number, value: String}[]} The selected custom PTAVs.
|
||||
*/
|
||||
get selectedCustomPtavs() {
|
||||
return this.ptals.filter(ptal => ptal.hasSelectedCustomPtav).flatMap(
|
||||
ptal => ptal.selected_ptavs
|
||||
).map(ptav => ({
|
||||
'id': ptav.id,
|
||||
'value': ptav.custom_value,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import { ProductTemplateAttributeValue } from './product_template_attribute_value';
|
||||
|
||||
export class ProductTemplateAttributeLine {
|
||||
/**
|
||||
* @param {number} id
|
||||
* @param {string} name
|
||||
* @param {'always'|'dynamic'|'no_variant'} create_variant
|
||||
* @param {ProductTemplateAttributeValue[]|object[]} selected_ptavs
|
||||
*/
|
||||
constructor({id, name, create_variant, selected_ptavs}) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.create_variant = create_variant;
|
||||
this.selected_ptavs = selected_ptavs.map(ptav => new ProductTemplateAttributeValue(ptav));
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a ProductTemplateAttributeLine from the provided "product configurator"-shaped
|
||||
* PTAL.
|
||||
*
|
||||
* @param productConfiguratorPtal The "product configurator"-shaped PTAL.
|
||||
* @return {ProductTemplateAttributeLine} The corresponding ProductTemplateAttributeLine.
|
||||
*/
|
||||
static fromProductConfiguratorPtal(productConfiguratorPtal) {
|
||||
const selectedPtavIds = new Set(productConfiguratorPtal.selected_attribute_value_ids);
|
||||
const selectedPtavs = productConfiguratorPtal.attribute_values
|
||||
.filter(ptav => selectedPtavIds.has(ptav.id))
|
||||
.map(ptav => new ProductTemplateAttributeValue({
|
||||
id: ptav.id,
|
||||
name: ptav.name,
|
||||
price_extra: ptav.price_extra,
|
||||
custom_value: productConfiguratorPtal.customValue,
|
||||
}));
|
||||
return new ProductTemplateAttributeLine({
|
||||
id: productConfiguratorPtal.id,
|
||||
name: productConfiguratorPtal.attribute.name,
|
||||
create_variant: productConfiguratorPtal.create_variant,
|
||||
selected_ptavs: selectedPtavs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the extra price of the selected PTAVs.
|
||||
*
|
||||
* @return {Number} The extra price of the selected PTAVs.
|
||||
*/
|
||||
get selectedPtavsPriceExtra() {
|
||||
return this.selected_ptavs.reduce((price, ptav) => price + ptav.price_extra, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether this PTAL has selected custom PTAVs.
|
||||
*
|
||||
* @return {Boolean} Whether this PTAL has selected custom PTAVs.
|
||||
*/
|
||||
get hasSelectedCustomPtav() {
|
||||
return this.selected_ptavs.some(ptav => ptav.custom_value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the display name of this PTAL.
|
||||
*
|
||||
* @return {String} The display name of this PTAL.
|
||||
*/
|
||||
get ptalDisplayName() {
|
||||
const selectedPtavNames = this.selected_ptavs.map(ptav => ptav.name).join(', ');
|
||||
let ptalDisplayName = `${this.name}: ${selectedPtavNames}`;
|
||||
if (this.hasSelectedCustomPtav) {
|
||||
ptalDisplayName += ` (${this.selected_ptavs[0].custom_value})`;
|
||||
}
|
||||
return ptalDisplayName;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
export class ProductTemplateAttributeValue {
|
||||
/**
|
||||
* @param {number} id
|
||||
* @param {string} name
|
||||
* @param {number} price_extra
|
||||
* @param {string|undefined} custom_value
|
||||
*/
|
||||
constructor({id, name, price_extra, custom_value}) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.price_extra = price_extra;
|
||||
this.custom_value = custom_value;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
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);
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
|
||||
import { Component } from "@odoo/owl";
|
||||
import { formatCurrency } from "@web/core/currency";
|
||||
import {
|
||||
ProductTemplateAttributeLine as PTAL
|
||||
} from "../product_template_attribute_line/product_template_attribute_line";
|
||||
import { QuantityButtons } from '../quantity_buttons/quantity_buttons';
|
||||
import { getSelectedCustomPtav } from "../sale_utils";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
export class Product extends Component {
|
||||
static components = { PTAL, QuantityButtons };
|
||||
static template = "sale.Product";
|
||||
static props = {
|
||||
id: { type: [Number, {value: false}], optional: true },
|
||||
product_tmpl_id: Number,
|
||||
display_name: String,
|
||||
description_sale: [Boolean, String], // backend sends 'false' when there is no description
|
||||
price: Number,
|
||||
quantity: Number,
|
||||
uom: { type: Object, optional: true },
|
||||
available_uoms: { type: Object, optional: true },
|
||||
attribute_lines: Object,
|
||||
optional: Boolean,
|
||||
imageURL: { type: String, optional: true },
|
||||
archived_combinations: Array,
|
||||
exclusions: Object,
|
||||
parent_exclusions: Object,
|
||||
parent_product_tmpl_id: { type: Number, optional: true },
|
||||
price_info: { type: String, optional: true },
|
||||
selectedComboItems: {
|
||||
type: Array,
|
||||
element: Object,
|
||||
shape: {
|
||||
name: String,
|
||||
},
|
||||
optional: true,
|
||||
},
|
||||
};
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return the price, in the format of the given currency.
|
||||
*
|
||||
* @return {String} - The price, in the format of the given currency.
|
||||
*/
|
||||
getFormattedPrice() {
|
||||
return formatCurrency(this.props.price, this.env.currency.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether this product is the main product.
|
||||
*
|
||||
* @return {Boolean} - Whether this product is the main product.
|
||||
*/
|
||||
get isMainProduct() {
|
||||
return this.env.mainProductTmplId === this.props.product_tmpl_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return this product's image URL.
|
||||
*
|
||||
* @return {String} This product's image URL.
|
||||
*/
|
||||
get imageUrl() {
|
||||
const modelPath = this.props.id
|
||||
? `product.product/${ this.props.id }`
|
||||
: `product.template/${ this.props.product_tmpl_id }`;
|
||||
return `/web/image/${ modelPath }/image_256`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the provided PTAL should be shown.
|
||||
*
|
||||
* @return {Boolean} Whether the PTAL should be shown.
|
||||
*/
|
||||
shouldShowPtal(ptal) {
|
||||
return this.env.canChangeVariant
|
||||
|| ptal.create_variant === 'no_variant'
|
||||
|| !!getSelectedCustomPtav(ptal);
|
||||
}
|
||||
|
||||
|
||||
get UoMTitle() {
|
||||
return _t("Packaging");
|
||||
}
|
||||
|
||||
async selectUoM(event) {
|
||||
this.env.setUoM(this.props.product_tmpl_id, parseInt(event.target.value));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
.table.o_sale_product_configurator_table {
|
||||
& tr:first-child > td {
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
|
||||
> :not(caption) > *:last-child > * {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
&:where(:not(.o_sale_product_configurator_table_optional)) {
|
||||
margin-bottom: $spacer !important;
|
||||
}
|
||||
}
|
||||
|
||||
.o_sale_product_configurator_img {
|
||||
width: 40px;
|
||||
max-height: 240px;
|
||||
|
||||
@include media-breakpoint-up(md) {
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(md) {
|
||||
.o_sale_product_configurator_qty,
|
||||
.o_sale_product_configurator_price {
|
||||
width: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
.product_name_description {
|
||||
max-width: 8rem;
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(lg) {
|
||||
.impossible_combination_alert {
|
||||
margin-left: -3rem;
|
||||
margin-right: -9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.o_sale_product_configurator_uom_choice.active label {
|
||||
$-btn-secondary-design: map-get($o-btns-bs-override, "secondary");
|
||||
|
||||
background-color: map-get($-btn-secondary-design, active-background);
|
||||
border-color: map-get($-btn-secondary-design, active-border);
|
||||
color: map-get($-btn-secondary-design, active-color);
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="sale.Product">
|
||||
<td class="o_sale_product_configurator_img py-3 px-0">
|
||||
<img
|
||||
class="w-75 w-lg-100 border rounded"
|
||||
t-att-src="imageUrl"
|
||||
alt="Product Image"
|
||||
/>
|
||||
</td>
|
||||
<td class="p-0 p-md-3 product_name_description">
|
||||
<div
|
||||
name="o_sale_product_configurator_name"
|
||||
class="mb-1 mb-lg-3 text-break text-truncate"
|
||||
>
|
||||
<span class="h5" t-out="this.props.display_name"/>
|
||||
<div
|
||||
t-if="this.props.description_sale"
|
||||
t-out="this.props.description_sale"
|
||||
class="text-muted small text-truncate"
|
||||
/>
|
||||
<div t-if="this.props.selectedComboItems" class="text-muted small">
|
||||
<div
|
||||
t-foreach="this.props.selectedComboItems"
|
||||
t-as="comboItem"
|
||||
t-key="comboItem_index"
|
||||
t-out="comboItem.name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<t t-foreach="this.props.attribute_lines" t-as="ptal" t-key="ptal.id">
|
||||
<PTAL
|
||||
t-if="shouldShowPtal(ptal)"
|
||||
t-props="ptal"
|
||||
productTmplId="this.props.product_tmpl_id"
|
||||
/>
|
||||
</t>
|
||||
<div t-if="!this.env.isPossibleCombination(this.props)" class="alert alert-warning impossible_combination_alert mt-3">
|
||||
<span>This option or combination of options is not available</span>
|
||||
</div>
|
||||
<t t-if="this.props.available_uoms" t-call="sale.uom_selector"/>
|
||||
</td>
|
||||
<t t-if="!this.props.optional">
|
||||
<td
|
||||
class="o_sale_product_configurator_qty w-25 py-3 px-0 text-end"
|
||||
>
|
||||
<div
|
||||
t-if="env.showPrice"
|
||||
class="d-md-flex gap-2 align-items-baseline justify-content-end"
|
||||
>
|
||||
<t t-call="sale.product_price" name="sale_product_configurator_price"/>
|
||||
</div>
|
||||
<t t-set="isComboProduct" t-value="isMainProduct && this.props.selectedComboItems.length"/>
|
||||
<t t-if="env.showQuantity && !isComboProduct">
|
||||
<QuantityButtons
|
||||
quantity="this.props.quantity"
|
||||
setQuantity="quantity => this.env.setQuantity(this.props.product_tmpl_id, quantity)"
|
||||
isMinusButtonDisabled="this.props.quantity === 1 && isMainProduct"
|
||||
/>
|
||||
</t>
|
||||
<span
|
||||
t-elif="env.showQuantity && isComboProduct"
|
||||
t-out="this.props.quantity"
|
||||
class="h5"
|
||||
/>
|
||||
<a
|
||||
class="d-block mt-2 text-end"
|
||||
role="button"
|
||||
t-if="!isMainProduct"
|
||||
t-on-click="() => this.env.removeProduct(this.props.product_tmpl_id)"
|
||||
>
|
||||
Remove
|
||||
</a>
|
||||
</td>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<td
|
||||
name="price"
|
||||
class="o_sale_product_configurator_price py-3 px-0 text-end"
|
||||
>
|
||||
<div
|
||||
t-if="env.showPrice"
|
||||
class="d-md-flex gap-2 align-items-baseline justify-content-end"
|
||||
>
|
||||
<t t-call="sale.product_price" name="sale_product_configurator_optional_price"/>
|
||||
</div>
|
||||
<button
|
||||
name="sale_product_configurator_add_button"
|
||||
class="btn btn-secondary"
|
||||
t-att-class="{'disabled': !this.env.isPossibleCombination(this.props)}"
|
||||
t-on-click="() => this.env.addProduct(this.props.product_tmpl_id)"
|
||||
>
|
||||
<i class="oi oi-plus" role="img"/>
|
||||
<span name="add_button" class="ms-2 d-none d-md-inline">Add</span>
|
||||
</button>
|
||||
</td>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="sale.product_price">
|
||||
<span
|
||||
name="sale_product_configurator_formatted_price"
|
||||
class="h5 text-nowrap fw-bold text-end h6 mb-2 d-block"
|
||||
t-out="getFormattedPrice()"
|
||||
/>
|
||||
<div t-if="this.props.price_info" t-out="this.props.price_info" class="text-muted"/>
|
||||
</t>
|
||||
|
||||
<t t-name="sale.uom_selector">
|
||||
<div class="d-flex gap-2 flex-column align-items-start justify-content-start mb-2 small">
|
||||
<label t-out="UoMTitle" class="fw-bold me-3"/>
|
||||
<ul class="list-unstyled d-flex flex-column flex-lg-row gap-2 flex-grow-1 mb-0">
|
||||
<li
|
||||
t-foreach="this.props.available_uoms"
|
||||
t-as="uom"
|
||||
t-key="`${this.props.product_tmpl_id}-${uom.id}`"
|
||||
t-att-class="{'active': uom.id == props.uom.id}"
|
||||
class="o_sale_product_configurator_uom_choice"
|
||||
>
|
||||
<label
|
||||
class="btn btn-outline-secondary"
|
||||
t-attf-for="{{this.props.product_tmpl_id}}-{{uom.id}}-input"
|
||||
t-out="uom.display_name"
|
||||
/>
|
||||
<input
|
||||
class="btn-check"
|
||||
type="radio"
|
||||
t-attf-id="{{this.props.product_tmpl_id}}-{{uom.id}}-input"
|
||||
t-att-value="uom.id"
|
||||
t-attf-name="uom-{{this.props.product_tmpl_id}}"
|
||||
t-att-checked="uom.id == props.uom.id"
|
||||
t-on-change="selectUoM"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { Component } from '@odoo/owl';
|
||||
import { BadgeExtraPrice } from '../badge_extra_price/badge_extra_price';
|
||||
import { ProductProduct } from '../models/product_product';
|
||||
|
||||
export class ProductCard extends Component {
|
||||
static template = 'sale.ProductCard';
|
||||
static components = { BadgeExtraPrice };
|
||||
static props = {
|
||||
product: ProductProduct,
|
||||
extraPrice: { type: Number, optional: true },
|
||||
onClick: Function,
|
||||
isSelected: { type: Boolean, optional: true },
|
||||
isConfigurable: { type: Boolean, optional: true }
|
||||
};
|
||||
|
||||
/**
|
||||
* Check whether the provided PTAL should be shown in this card.
|
||||
*
|
||||
* @param {ProductTemplateAttributeLine} ptal The PTAL to check.
|
||||
* @return {Boolean} Whether to show the PTAL.
|
||||
*/
|
||||
shouldShowPtal(ptal) {
|
||||
return (
|
||||
ptal.selected_ptavs.length > 0 &&
|
||||
(ptal.hasSelectedCustomPtav || ptal.create_variant === 'no_variant')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
.product-card {
|
||||
border: $border-width solid $border-color;
|
||||
transition: border-color 0.2s ease-in-out;
|
||||
|
||||
img {
|
||||
aspect-ratio: 1;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: $primary;
|
||||
}
|
||||
}
|
||||
|
||||
.product-card.selected {
|
||||
border-color: $primary
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="sale.ProductCard">
|
||||
<div class="col">
|
||||
<article
|
||||
tabindex="0"
|
||||
t-attf-class="product-card d-flex align-items-start h-100 rounded p-2 cursor-pointer {{props.isSelected ? 'selected' : ''}}"
|
||||
t-on-keypress="(event) => event.code === 'Space' ? props.onClick() : () => {}"
|
||||
t-on-click="props.onClick"
|
||||
>
|
||||
<img
|
||||
name="product_card_image"
|
||||
class="w-25 border rounded"
|
||||
t-att-src="props.product.image_src || `/web/image/product.product/${props.product.id}/image_256`"
|
||||
alt="Product Image"
|
||||
/>
|
||||
<div class="w-75 p-2">
|
||||
<t t-set="ptalsToShow" t-value="props.product.ptals.filter(shouldShowPtal)"/>
|
||||
<h6 name="product_card_title" class="mb-1" t-out="props.product.display_name"/>
|
||||
<div class="text-muted">
|
||||
<t t-if="ptalsToShow.length">
|
||||
<div
|
||||
t-foreach="ptalsToShow"
|
||||
t-as="ptal"
|
||||
t-key="ptal.id"
|
||||
t-out="ptal.ptalDisplayName"
|
||||
/>
|
||||
</t>
|
||||
<small t-elif="props.isConfigurable">Click to configure</small>
|
||||
</div>
|
||||
<BadgeExtraPrice
|
||||
t-if="props.extraPrice"
|
||||
price="props.extraPrice"
|
||||
currencyId="env.currency.id"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,516 @@
|
|||
import { Component, onWillStart, useState, useSubEnv } from "@odoo/owl";
|
||||
import { Dialog } from '@web/core/dialog/dialog';
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { ProductList } from "../product_list/product_list";
|
||||
import { formatCurrency } from '@web/core/currency';
|
||||
|
||||
export class ProductConfiguratorDialog extends Component {
|
||||
static components = { Dialog, ProductList};
|
||||
static template = 'sale.ProductConfiguratorDialog';
|
||||
static props = {
|
||||
productTemplateId: Number,
|
||||
ptavIds: { type: Array, element: Number },
|
||||
customPtavs: {
|
||||
type: Array,
|
||||
element: Object,
|
||||
shape: {
|
||||
id: Number,
|
||||
value: String,
|
||||
}
|
||||
},
|
||||
quantity: Number,
|
||||
productUOMId: { type: Number, optional: true },
|
||||
companyId: { type: Number, optional: true },
|
||||
pricelistId: { type: Number, optional: true },
|
||||
currencyId: { type: Number, optional: true },
|
||||
selectedComboItems: {
|
||||
type: Array,
|
||||
element: Object,
|
||||
shape: {
|
||||
name: String,
|
||||
},
|
||||
optional: true,
|
||||
},
|
||||
soDate: String,
|
||||
size: {
|
||||
type: String,
|
||||
optional: true,
|
||||
validate: (s) => ["sm", "md", "lg", "xl", "fs", "fullscreen"].includes(s),
|
||||
},
|
||||
edit: { type: Boolean, optional: true },
|
||||
options: {
|
||||
type: Object,
|
||||
optional: true,
|
||||
shape: {
|
||||
canChangeVariant: { type: Boolean, optional: true },
|
||||
showQuantity : { type: Boolean, optional: true },
|
||||
showPrice : { type: Boolean, optional: true },
|
||||
showPackaging: { type: Boolean, optional: true },
|
||||
},
|
||||
},
|
||||
save: Function,
|
||||
discard: Function,
|
||||
close: Function, // This is the close from the env of the Dialog Component
|
||||
};
|
||||
static defaultProps = {
|
||||
edit: false,
|
||||
}
|
||||
|
||||
setup() {
|
||||
this.title = _t("Configure your product");
|
||||
this.env.dialogData.dismiss = !this.props.edit && this.props.discard.bind(this);
|
||||
this.state = useState({
|
||||
products: [],
|
||||
optionalProducts: [],
|
||||
});
|
||||
// Nest the currency id in an object so that it stays up to date in the `env`, even if we
|
||||
// modify it in `onWillStart` afterwards.
|
||||
this.currency = { id: this.props.currencyId };
|
||||
this.getValuesUrl = '/sale/product_configurator/get_values';
|
||||
this.createProductUrl = '/sale/product_configurator/create_product';
|
||||
this.updateCombinationUrl = '/sale/product_configurator/update_combination';
|
||||
this.getOptionalProductsUrl = '/sale/product_configurator/get_optional_products';
|
||||
|
||||
useSubEnv({
|
||||
mainProductTmplId: this.props.productTemplateId,
|
||||
currency: this.currency,
|
||||
canChangeVariant: this.props.options?.canChangeVariant ?? true,
|
||||
showQuantity: this.props.options?.showQuantity ?? true,
|
||||
showPackaging: this.props.options?.showPackaging ?? true,
|
||||
showPrice: this.props.options?.showPrice ?? true,
|
||||
addProduct: this._addProduct.bind(this),
|
||||
removeProduct: this._removeProduct.bind(this),
|
||||
setQuantity: this._setQuantity.bind(this),
|
||||
setUoM: this._setUnitOfMeasure.bind(this),
|
||||
updateProductTemplateSelectedPTAV: this._updateProductTemplateSelectedPTAV.bind(this),
|
||||
updatePTAVCustomValue: this._updatePTAVCustomValue.bind(this),
|
||||
isPossibleCombination: this._isPossibleCombination,
|
||||
});
|
||||
|
||||
onWillStart(async () => {
|
||||
const {
|
||||
products,
|
||||
optional_products,
|
||||
currency_id,
|
||||
} = await this._loadData(this.props.edit);
|
||||
|
||||
// If the product configurator is opened after the combo configurator (which happens if
|
||||
// a combo product has optional products), `_loadData` will return a single product
|
||||
// (i.e. the combo product), which should be linked to the previously selected combo
|
||||
// items.
|
||||
products[0].selectedComboItems = this.props.selectedComboItems || [];
|
||||
|
||||
this.state.products = products;
|
||||
this.state.optionalProducts = optional_products;
|
||||
for (const customPtav of this.props.customPtavs) {
|
||||
this._updatePTAVCustomValue(
|
||||
this.env.mainProductTmplId,
|
||||
customPtav.id,
|
||||
customPtav.value
|
||||
);
|
||||
}
|
||||
this._checkExclusions(this.state.products[0]);
|
||||
// Use the currency id retrieved from the server if none was provided in the props.
|
||||
this.currency.id ??= currency_id;
|
||||
});
|
||||
}
|
||||
|
||||
get totalMessage() {
|
||||
return _t("Total: %s", this.getFormattedTotal());
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the total of the product in the list, in the currency of the `sale.order`.
|
||||
*
|
||||
* @return {String} - The sum of all items in the list, in the currency of the `sale.order`.
|
||||
*/
|
||||
getFormattedTotal() {
|
||||
const total = (this.state.products || []).reduce(
|
||||
(sum, product) => sum + product.price * product.quantity,
|
||||
0
|
||||
);
|
||||
return formatCurrency(total, this.currency.id);
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Data Exchanges
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
async _loadData(onlyMainProduct) {
|
||||
return rpc(this.getValuesUrl, {
|
||||
product_template_id: this.props.productTemplateId,
|
||||
quantity: this.props.quantity,
|
||||
currency_id: this.currency.id,
|
||||
so_date: this.props.soDate,
|
||||
product_uom_id: this.props.productUOMId,
|
||||
company_id: this.props.companyId,
|
||||
pricelist_id: this.props.pricelistId,
|
||||
ptav_ids: this.props.ptavIds,
|
||||
only_main_product: onlyMainProduct,
|
||||
show_packaging: this.env.showPackaging,
|
||||
...this._getAdditionalRpcParams(),
|
||||
});
|
||||
}
|
||||
|
||||
async _createProduct(product) {
|
||||
return rpc(this.createProductUrl, {
|
||||
product_template_id: product.product_tmpl_id,
|
||||
ptav_ids: this._getCombination(product),
|
||||
});
|
||||
}
|
||||
|
||||
async _updateCombination(product, quantity, uomId) {
|
||||
return rpc(this.updateCombinationUrl, {
|
||||
product_template_id: product.product_tmpl_id,
|
||||
ptav_ids: this._getCombination(product),
|
||||
currency_id: this.currency.id,
|
||||
so_date: this.props.soDate,
|
||||
quantity: quantity,
|
||||
product_uom_id: uomId,
|
||||
company_id: this.props.companyId,
|
||||
pricelist_id: this.props.pricelistId,
|
||||
...this._getAdditionalRpcParams(),
|
||||
});
|
||||
}
|
||||
|
||||
async _getOptionalProducts(product) {
|
||||
return rpc(this.getOptionalProductsUrl, {
|
||||
product_template_id: product.product_tmpl_id,
|
||||
ptav_ids: this._getCombination(product),
|
||||
parent_ptav_ids: this._getParentsCombination(product),
|
||||
currency_id: this.currency.id,
|
||||
so_date: this.props.soDate,
|
||||
company_id: this.props.companyId,
|
||||
pricelist_id: this.props.pricelistId,
|
||||
...this._getAdditionalRpcParams(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to append additional RPC params in overriding modules.
|
||||
*
|
||||
* @return {Object} - The additional RPC params.
|
||||
*/
|
||||
_getAdditionalRpcParams() {
|
||||
return {};
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Add the product to the list of products and fetch his optional products.
|
||||
*
|
||||
* @param {Number} productTmplId - The product template id, as a `product.template` id.
|
||||
*/
|
||||
async _addProduct(productTmplId) {
|
||||
const index = this.state.optionalProducts.findIndex(
|
||||
p => p.product_tmpl_id === productTmplId
|
||||
);
|
||||
if (index >= 0) {
|
||||
this.state.products.push(...this.state.optionalProducts.splice(index, 1));
|
||||
// Fetch optional product from the server with the parent combination.
|
||||
const product = this._findProduct(productTmplId);
|
||||
// Filter out optional products that are already loaded in the configurator.
|
||||
const newOptionalProducts = (await this._getOptionalProducts(product)).filter(
|
||||
p => !this._findProduct(p.product_tmpl_id)
|
||||
);
|
||||
this.state.optionalProducts.push(...newOptionalProducts);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the product and his optional products from the list of products.
|
||||
*
|
||||
* @param {Number} productTmplId - The product template id, as a `product.template` id.
|
||||
*/
|
||||
_removeProduct(productTmplId) {
|
||||
const index = this.state.products.findIndex(p => p.product_tmpl_id === productTmplId);
|
||||
if (index >= 0) {
|
||||
this.state.optionalProducts.push(...this.state.products.splice(index, 1));
|
||||
for (const childProduct of this._getChildProducts(productTmplId)) {
|
||||
this._removeProduct(childProduct.product_tmpl_id);
|
||||
this.state.optionalProducts.splice(
|
||||
this.state.optionalProducts.findIndex(
|
||||
p => p.product_tmpl_id === childProduct.product_tmpl_id
|
||||
), 1
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the quantity of the product to a given value.
|
||||
*
|
||||
* If the value is less than or equal to zero, the product is removed from the product list
|
||||
* instead, unless it is the main product, in which case the quantity is set to 1.
|
||||
*
|
||||
* @param {Number} productTmplId - The product template id, as a `product.template` id.
|
||||
* @param {Number} quantity - The new quantity of the product.
|
||||
* @return {Boolean} - Whether the quantity was updated.
|
||||
*/
|
||||
async _setQuantity(productTmplId, quantity) {
|
||||
if (quantity <= 0) {
|
||||
if (productTmplId === this.env.mainProductTmplId) {
|
||||
quantity = 1;
|
||||
} else {
|
||||
this._removeProduct(productTmplId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const product = this._findProduct(productTmplId);
|
||||
if (product.quantity === quantity) {
|
||||
return false;
|
||||
}
|
||||
product.quantity = quantity;
|
||||
const { price } = await this._updateCombination(product, quantity, product.uom.id);
|
||||
product.price = parseFloat(price);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the uom of the product to a given value.
|
||||
*
|
||||
* @param {Number} productTmplId - The product template id, as a `product.template` id.
|
||||
* @param {Number} uomId - The new uom of the product, as an `uom.uom` id.
|
||||
*
|
||||
* @return {Boolean} - Whether the uom was updated.
|
||||
*/
|
||||
async _setUnitOfMeasure(productTmplId, uomId) {
|
||||
const product = this._findProduct(productTmplId);
|
||||
if (product.uom.id === uomId) {
|
||||
return false;
|
||||
}
|
||||
const { price } = await this._updateCombination(product, product.quantity, uomId);
|
||||
product.price = parseFloat(price);
|
||||
product.uom = product.available_uoms.find((uom) => uom.id === uomId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the value of `selected_attribute_value_ids` on the given PTAL in the product.
|
||||
*
|
||||
* @param {Number} productTmplId - The product template id, as a `product.template` id.
|
||||
* @param {Number} ptalId - The PTAL id, as a `product.template.attribute.line` id.
|
||||
* @param {Number} ptavId - The PTAV id, as a `product.template.attribute.value` id.
|
||||
* @param {Boolean} isMulti - Whether multiple `product.template.attribute.value` can be selected.
|
||||
*/
|
||||
async _updateProductTemplateSelectedPTAV(productTmplId, ptalId, ptavId, isMulti) {
|
||||
const product = this._findProduct(productTmplId);
|
||||
const ptal = product.attribute_lines.find(line => line.id === ptalId);
|
||||
ptavId = parseInt(ptavId);
|
||||
if (isMulti) {
|
||||
const selectedPtavIds = new Set(ptal.selected_attribute_value_ids);
|
||||
selectedPtavIds.has(ptavId)
|
||||
? selectedPtavIds.delete(ptavId)
|
||||
: selectedPtavIds.add(ptavId);
|
||||
ptal.selected_attribute_value_ids = Array.from(selectedPtavIds);
|
||||
} else {
|
||||
ptal.selected_attribute_value_ids = [ptavId];
|
||||
}
|
||||
this._checkExclusions(product);
|
||||
if (this._isPossibleCombination(product)) {
|
||||
const updatedValues = await this._updateCombination(product, product.quantity, product.uom.id);
|
||||
Object.assign(product, updatedValues);
|
||||
// When a combination should exist but was deleted from the database, it should not be
|
||||
// selectable and considered as an exclusion.
|
||||
if (!product.id && product.attribute_lines.every(ptal => ptal.create_variant === "always")) {
|
||||
const combination = this._getCombination(product);
|
||||
product.archived_combinations = product.archived_combinations.concat([combination]);
|
||||
this._checkExclusions(product);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the custom value for a given custom PTAV.
|
||||
*
|
||||
* @param {Number} productTmplId - The product template id, as a `product.template` id.
|
||||
* @param {Number} ptavId - The PTAV id, as a `product.template.attribute.value` id.
|
||||
* @param {String} customValue - The custom value.
|
||||
*/
|
||||
_updatePTAVCustomValue(productTmplId, ptavId, customValue) {
|
||||
const product = this._findProduct(productTmplId);
|
||||
product.attribute_lines.find(
|
||||
ptal => ptal.selected_attribute_value_ids.includes(ptavId)
|
||||
).customValue = customValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the exclusions of a given product and his child.
|
||||
*
|
||||
* @param {Object} product - The product for which to check the exclusions.
|
||||
*/
|
||||
_checkExclusions(product) {
|
||||
const combination = this._getCombination(product);
|
||||
const exclusions = product.exclusions;
|
||||
const parentExclusions = product.parent_exclusions;
|
||||
const archivedCombinations = product.archived_combinations;
|
||||
const parentCombination = this._getParentsCombination(product);
|
||||
const childProducts = this._getChildProducts(product.product_tmpl_id)
|
||||
const ptavList = product.attribute_lines.flat().flatMap(ptal => ptal.attribute_values)
|
||||
ptavList.map(ptav => ptav.excluded = false); // Reset all the values
|
||||
|
||||
if (exclusions) {
|
||||
for(const ptavId of combination) {
|
||||
for(const excludedPtavId of exclusions[ptavId]) {
|
||||
ptavList.find(ptav => ptav.id === excludedPtavId).excluded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (parentCombination) {
|
||||
for(const ptavId of parentCombination) {
|
||||
for(const excludedPtavId of (parentExclusions[ptavId]||[])) {
|
||||
const ptav = ptavList.find(ptav => ptav.id === excludedPtavId);
|
||||
if (ptav) {
|
||||
ptav.excluded = true; // Assign only if the element exists
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (archivedCombinations) {
|
||||
for(const excludedCombination of archivedCombinations) {
|
||||
const ptavCommon = excludedCombination.filter((ptav) => combination.includes(ptav));
|
||||
if (ptavCommon.length === combination.length) {
|
||||
for(const excludedPtavId of ptavCommon) {
|
||||
ptavList.find(ptav => ptav.id === excludedPtavId).excluded = true;
|
||||
}
|
||||
} else if (ptavCommon.length === (combination.length - 1)) {
|
||||
// In this case we only need to disable the remaining ptav
|
||||
const disabledPtavId = excludedCombination.find(
|
||||
(ptav) => !combination.includes(ptav)
|
||||
);
|
||||
const excludedPtav = ptavList.find(ptav => ptav.id === disabledPtavId)
|
||||
if (excludedPtav) {
|
||||
excludedPtav.excluded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for(const optionalProductTmpl of childProducts) {
|
||||
this._checkExclusions(optionalProductTmpl);
|
||||
}
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return the product given his template id.
|
||||
*
|
||||
* @param {Number} productTmplId - The product template id, as a `product.template` id.
|
||||
* @return {Object} - The product.
|
||||
*/
|
||||
_findProduct(productTmplId) {
|
||||
// The product might be in either of the two lists `products` or `optional_products`.
|
||||
return this.state.products.find(p => p.product_tmpl_id === productTmplId) ||
|
||||
this.state.optionalProducts.find(p => p.product_tmpl_id === productTmplId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the list of dependents products for a given product.
|
||||
*
|
||||
* @param {Number} productTmplId - The product template id for which to find his children, as a
|
||||
* `product.template` id.
|
||||
* @return {Array} - The list of dependents products.
|
||||
*/
|
||||
_getChildProducts(productTmplId) {
|
||||
return [
|
||||
...this.state.products.filter(p => p.parent_product_tmpl_id === productTmplId),
|
||||
...this.state.optionalProducts.filter(p => p.parent_product_tmpl_id === productTmplId)
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the selected PTAV of the product, as a list of `product.template.attribute.value` id.
|
||||
*
|
||||
* @param {Object} product - The product for which to find the combination.
|
||||
* @return {Array} - The combination of the product.
|
||||
*/
|
||||
_getCombination(product) {
|
||||
return product.attribute_lines.flatMap(ptal => ptal.selected_attribute_value_ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the selected PTAVs of the parent product, as a list of
|
||||
* `product.template.attribute.value` ids.
|
||||
*
|
||||
* @param {Object} product - The product for which to find the parent combination.
|
||||
* @return {Array} - The combination of the parent product.
|
||||
*/
|
||||
_getParentsCombination(product) {
|
||||
return product.parent_product_tmpl_id
|
||||
? this._getCombination(this._findProduct(product.parent_product_tmpl_id))
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a product has a valid combination.
|
||||
*
|
||||
* @param {Object} product - The product for which to check the combination.
|
||||
* @return {Boolean} - Whether the combination is valid or not.
|
||||
*/
|
||||
_isPossibleCombination(product) {
|
||||
return product.attribute_lines.every(ptal => {
|
||||
const selectedPtavIds = new Set(ptal.selected_attribute_value_ids);
|
||||
return ptal.attribute_values
|
||||
.filter(ptav => selectedPtavIds.has(ptav.id))
|
||||
.every(ptav => !ptav.excluded);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all the products selected have a valid combination.
|
||||
*
|
||||
* @return {Boolean} - Whether all the products selected have a valid combination or not.
|
||||
*/
|
||||
isPossibleConfiguration() {
|
||||
return [...this.state.products].every(
|
||||
p => this._isPossibleCombination(p)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the current combination(s).
|
||||
*
|
||||
* @return {undefined}
|
||||
*/
|
||||
async onConfirm(options) {
|
||||
if (!this.isPossibleConfiguration()) return;
|
||||
// Create the products with dynamic attributes
|
||||
for (const product of this.state.products) {
|
||||
if (
|
||||
!product.id &&
|
||||
product.attribute_lines.some(ptal => ptal.create_variant === "dynamic")
|
||||
) {
|
||||
const productId = await this._createProduct(product);
|
||||
product.id = parseInt(productId);
|
||||
}
|
||||
}
|
||||
await this.props.save(
|
||||
this.state.products.find(
|
||||
p => p.product_tmpl_id === this.env.mainProductTmplId
|
||||
),
|
||||
this.state.products.filter(
|
||||
p => p.product_tmpl_id !== this.env.mainProductTmplId
|
||||
),
|
||||
options,
|
||||
);
|
||||
this.props.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard the modal.
|
||||
*/
|
||||
onDiscard() {
|
||||
if (!this.props.edit) {
|
||||
this.props.discard(); // clear the line
|
||||
}
|
||||
this.props.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="sale.ProductConfiguratorDialog">
|
||||
<Dialog size="props.size" title="title" contentClass="'o_sale_product_configurator_dialog'">
|
||||
<ProductList t-if="this.state.products.length" products="this.state.products"/>
|
||||
<ProductList
|
||||
t-if="this.state.optionalProducts.length"
|
||||
products="this.state.optionalProducts"
|
||||
areProductsOptional="true"/>
|
||||
<t t-set-slot="footer">
|
||||
<button
|
||||
name="sale_product_configurator_confirm_button"
|
||||
class="btn btn-primary order-2 order-md-1"
|
||||
t-on-click="onConfirm"
|
||||
t-att-disabled="!isPossibleConfiguration()">
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
name="sale_product_configurator_cancel_button"
|
||||
class="btn btn-secondary order-2 order-md-1"
|
||||
t-on-click="onDiscard"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<h6
|
||||
t-if="env.showPrice"
|
||||
class="o_configurator_price_total order-1 order-md-2 d-block d-sm-inline-block w-100 w-md-auto text-end ms-md-3 mb-0"
|
||||
name="sale_product_configurator_list_total"
|
||||
>
|
||||
<t t-out="totalMessage"/>
|
||||
</h6>
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
/** @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)
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
|
||||
import { Component } from "@odoo/owl";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { Product } from "../product/product";
|
||||
|
||||
export class ProductList extends Component {
|
||||
static components = { Product };
|
||||
static template = "sale.ProductList";
|
||||
static props = {
|
||||
products: Array,
|
||||
areProductsOptional: { type: Boolean, optional: true },
|
||||
};
|
||||
static defaultProps = {
|
||||
areProductsOptional: false,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.optionalProductsTitle = _t("Add optional products");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
table.o_sale_product_configurator_table > tbody > tr > td {
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.o_sale_optional_products {
|
||||
background: darken($modal-content-bg, 2%);
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="sale.ProductList">
|
||||
<div t-att-class="this.props.areProductsOptional ? 'o_sale_optional_products m-n3 p-3 border-top' : ''">
|
||||
<span
|
||||
name="sale_product_configurator_list_title"
|
||||
class="d-inline-block mb-3 h4"
|
||||
t-if="this.props.areProductsOptional"
|
||||
t-out="optionalProductsTitle"
|
||||
/>
|
||||
<table
|
||||
class="o_sale_product_configurator_table table table-sm table-borderless position-relative mb-0"
|
||||
t-att-class="{'o_sale_product_configurator_table_optional': this.props.areProductsOptional}"
|
||||
>
|
||||
<thead t-if="!this.props.areProductsOptional">
|
||||
<tr>
|
||||
<th class="px-0 border-bottom-0" colspan="2">Product</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="border-top-0">
|
||||
<tr t-foreach="this.props.products" t-as="product" t-key="product.product_tmpl_id">
|
||||
<Product t-props="product" optional="this.props.areProductsOptional"/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
import { _t } from "@web/core/l10n/translation";
|
||||
import { Component } from "@odoo/owl";
|
||||
import { formatCurrency } from "@web/core/currency";
|
||||
import { BadgeExtraPrice } from "../badge_extra_price/badge_extra_price";
|
||||
import { getSelectedCustomPtav } from "../sale_utils";
|
||||
|
||||
export class ProductTemplateAttributeLine extends Component {
|
||||
static components = { BadgeExtraPrice };
|
||||
static template = "sale.ProductTemplateAttributeLine";
|
||||
static props = {
|
||||
productTmplId: Number,
|
||||
id: Number,
|
||||
attribute: {
|
||||
type: Object,
|
||||
shape: {
|
||||
id: Number,
|
||||
name: String,
|
||||
display_type: {
|
||||
type: String,
|
||||
validate: type => ["color", "multi", "pills", "radio", "select", "image"].includes(type),
|
||||
},
|
||||
},
|
||||
},
|
||||
attribute_values: {
|
||||
type: Array,
|
||||
element: {
|
||||
type: Object,
|
||||
shape: {
|
||||
id: Number,
|
||||
name: String,
|
||||
html_color: [Boolean, String], // backend sends 'false' when there is no color
|
||||
image: [Boolean, String], // backend sends 'false' when there is no image set
|
||||
is_custom: Boolean,
|
||||
price_extra: Number,
|
||||
excluded: { type: Boolean, optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
selected_attribute_value_ids: { type: Array, element: Number },
|
||||
create_variant: {
|
||||
type: String,
|
||||
validate: type => ["always", "dynamic", "no_variant"].includes(type),
|
||||
},
|
||||
customValue: {type: [{value: false}, String], optional: true},
|
||||
};
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Update the selected PTAV in the state.
|
||||
*
|
||||
* @param {Event} event
|
||||
*/
|
||||
updateSelectedPTAV(event) {
|
||||
this.env.updateProductTemplateSelectedPTAV(
|
||||
this.props.productTmplId, this.props.id, event.target.value, this.props.attribute.display_type == 'multi'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update in the state the custom value of the selected PTAV.
|
||||
*
|
||||
* @param {Event} event
|
||||
*/
|
||||
updateCustomValue(event) {
|
||||
this.env.updatePTAVCustomValue(
|
||||
this.props.productTmplId, this.props.selected_attribute_value_ids[0], event.target.value
|
||||
);
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return template name to use by checking the display type in the props.
|
||||
*
|
||||
* Each attribute line can have one of this five display types:
|
||||
* - 'Color' : Display each attribute as a circle filled with said color.
|
||||
* - 'Pills' : Display each attribute as a rectangle-shaped element.
|
||||
* - 'Radio' : Display each attribute as a radio element.
|
||||
* - 'Select' : Display each attribute in a selection tag.
|
||||
* - 'Multi' : Display each attribute in a multi-checkbox tag.
|
||||
*
|
||||
* @return {String} - The template name to use.
|
||||
*/
|
||||
getPTAVTemplate() {
|
||||
switch(this.props.attribute.display_type) {
|
||||
case 'select':
|
||||
return 'sale.ptav_select';
|
||||
case 'radio':
|
||||
return 'sale.ptav_radio';
|
||||
case 'pills':
|
||||
return 'sale.ptav_pills';
|
||||
case 'color':
|
||||
return 'sale.ptav_color';
|
||||
case 'multi':
|
||||
return 'sale.ptav_multi';
|
||||
case 'image':
|
||||
return 'sale.ptav_image';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the name of the PTAV
|
||||
*
|
||||
* In the selection HTML tag, it is impossible to show the component `BadgeExtraPrice`. Append
|
||||
* the extra price to the name to ensure that the extra price will be shown.
|
||||
* Note: used in `sale.ptav_select`.
|
||||
*
|
||||
* @param {Object} ptav - The attribute, as a `product.template.attribute.value` summary dict.
|
||||
* @return {String} - The name of the PTAV.
|
||||
*/
|
||||
getPTAVSelectName(ptav) {
|
||||
if (ptav.price_extra) {
|
||||
const sign = ptav.price_extra > 0 ? '+' : '-';
|
||||
const price = formatCurrency(Math.abs(ptav.price_extra), this.env.currency.id);
|
||||
return ptav.name +" ("+ sign + " " + price + ")";
|
||||
} else {
|
||||
return ptav.name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the selected ptav is custom or not.
|
||||
*
|
||||
* @return {Boolean} - Whether the selected ptav is custom or not.
|
||||
*/
|
||||
isSelectedPTAVCustom() {
|
||||
return !!getSelectedCustomPtav(this.props);
|
||||
}
|
||||
|
||||
get showValuesChoice() {
|
||||
return (this.env.canChangeVariant || this.props.create_variant === 'no_variant') && (
|
||||
this.props.attribute_values.length > 1 || this.props.attribute.display_type === 'multi'
|
||||
)
|
||||
}
|
||||
|
||||
get customValuePlaceholder() {
|
||||
return _t("Enter a customized value");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the line has a custom ptav or not.
|
||||
*
|
||||
* @return {Boolean} - Whether the line has a custom ptav or not.
|
||||
*/
|
||||
hasPTAVCustom() {
|
||||
return this.props.attribute_values.some(
|
||||
ptav => ptav.is_custom
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
.o_sale_product_configurator_table {
|
||||
div[name="ptal"] > div {
|
||||
@include media-breakpoint-up(lg) {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&:has(.form-check) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_sale_product_configurator_ptav_color {
|
||||
border: 5px solid $border-color;
|
||||
transition: $input-transition;
|
||||
|
||||
@include o-field-pointer();
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
display: block;
|
||||
@include o-position-absolute(-3px, -3px, -3px, -3px);
|
||||
border: 4px solid $o-view-background-color;
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_sale_product_configurator_ptav_image {
|
||||
border: $border-width * 5 solid $body-bg;
|
||||
outline: $border-width * 2 solid $border-color;
|
||||
transition: $input-transition;
|
||||
height: 62px;
|
||||
aspect-ratio: 1;
|
||||
|
||||
&:hover, &.active {
|
||||
outline-color: 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 {
|
||||
opacity: 1;
|
||||
outline-color: map-get($theme-colors, 'danger');
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
@include o-position-absolute(-5px, -5px, -5px, -5px);
|
||||
background-image: 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;
|
||||
}
|
||||
|
||||
&:hover, &.active {
|
||||
outline-color: map-get($theme-colors, 'primary');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_sale_product_configurator_ptav_pills.active label {
|
||||
$-btn-secondary-design: map-get($o-btns-bs-override, "secondary");
|
||||
|
||||
background-color: map-get($-btn-secondary-design, active-background);
|
||||
border-color: map-get($-btn-secondary-design, active-border);
|
||||
color: map-get($-btn-secondary-design, active-color);
|
||||
}
|
||||
|
||||
.css_not_available {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
option.css_not_available {
|
||||
opacity: 1;
|
||||
color: #ccc;
|
||||
}
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<!-- Attributes line template -->
|
||||
<t t-name="sale.ProductTemplateAttributeLine">
|
||||
<div
|
||||
name="ptal"
|
||||
t-attf-class="#{this.props.attribute_values.length === 1 && hasPTAVCustom() ? 'd-md-flex' : ''}"
|
||||
>
|
||||
<!-- If the attribute line contains only one attribute value, we don't show the attribute
|
||||
value template or the attribute line title unless the single attribute value is custom,
|
||||
whereas in this case, only the title of the attribute line and the custom value
|
||||
template are rendered. -->
|
||||
<div class="d-flex gap-2 flex-column align-items-start justify-content-start mb-2 small">
|
||||
<label
|
||||
t-if="showValuesChoice || isSelectedPTAVCustom()"
|
||||
t-out="this.props.attribute.name"
|
||||
class="fw-bold me-3"
|
||||
/>
|
||||
<t t-if="showValuesChoice" t-call="{{getPTAVTemplate()}}"/>
|
||||
</div>
|
||||
<input
|
||||
class="o_input form-control w-100 w-md-75 mb-4"
|
||||
type="text"
|
||||
t-att-placeholder="customValuePlaceholder"
|
||||
t-if="hasPTAVCustom && isSelectedPTAVCustom()"
|
||||
t-att-value="this.props.customValue"
|
||||
t-on-change="updateCustomValue"
|
||||
/>
|
||||
</div>
|
||||
</t>
|
||||
<!-- Attributes value templates -->
|
||||
<t t-name="sale.ptav_select">
|
||||
<select
|
||||
class="form-select form-select-sm w-auto"
|
||||
t-on-change="updateSelectedPTAV"
|
||||
t-att-name="'ptal-' + this.props.id"
|
||||
>
|
||||
<option
|
||||
t-foreach="this.props.attribute_values"
|
||||
t-as="ptav"
|
||||
t-key="ptav.id"
|
||||
t-att-value="ptav.id"
|
||||
t-att-selected="this.props.selected_attribute_value_ids.includes(ptav.id)"
|
||||
t-out="getPTAVSelectName(ptav)"
|
||||
t-att-class="{ 'css_not_available': ptav.excluded }"
|
||||
/>
|
||||
</select>
|
||||
</t>
|
||||
<t t-name="sale.ptav_radio">
|
||||
<ul class="d-flex flex-column flex-lg-row flex-wrap gap-3 row-gap-0 row-gap-lg-3 align-items-lg-center list-unstyled mb-0">
|
||||
<li t-foreach="this.props.attribute_values" t-as="ptav" t-key="ptav.id" class="mb-0">
|
||||
<div class="form-check">
|
||||
<label
|
||||
class="form-check-label d-inline-flex align-items-center"
|
||||
t-att-class="{ 'css_not_available': ptav.excluded }"
|
||||
t-attf-for="ptav-{{ptav.id}}-input">
|
||||
<span class="me-1" t-out="ptav.name"/>
|
||||
<BadgeExtraPrice
|
||||
t-if="ptav.price_extra"
|
||||
price="ptav.price_extra"
|
||||
currencyId="this.env.currency.id"
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
type="radio"
|
||||
class="form-check-input"
|
||||
t-attf-id="ptav-{{ptav.id}}-input"
|
||||
t-att-value="ptav.id"
|
||||
t-att-checked="this.props.selected_attribute_value_ids.includes(ptav.id)"
|
||||
t-att-name="'ptal-' + this.props.id"
|
||||
t-on-change="updateSelectedPTAV"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</t>
|
||||
<t t-name="sale.ptav_pills">
|
||||
<ul class="list-inline list-unstyled flex-grow-1 mb-0">
|
||||
<li t-foreach="this.props.attribute_values" t-as="ptav" t-key="ptav.id"
|
||||
t-att-class="{'active': this.props.selected_attribute_value_ids.includes(ptav.id)}"
|
||||
class="o_sale_product_configurator_ptav_pills list-inline-item">
|
||||
<label
|
||||
class="btn btn-outline-secondary"
|
||||
t-att-class="{ 'css_not_available': ptav.excluded }"
|
||||
t-attf-for="ptav-{{ptav.id}}-input"
|
||||
>
|
||||
<span t-out="ptav.name"/>
|
||||
<BadgeExtraPrice
|
||||
t-if="ptav.price_extra"
|
||||
price="ptav.price_extra"
|
||||
currencyId="this.env.currency.id"
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
class="btn-check"
|
||||
type="radio"
|
||||
t-attf-id="ptav-{{ptav.id}}-input"
|
||||
t-att-value="ptav.id"
|
||||
t-att-name="'ptal-' + this.props.id"
|
||||
t-att-checked="this.props.selected_attribute_value_ids.includes(ptav.id)"
|
||||
t-on-change="updateSelectedPTAV"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</t>
|
||||
<t t-name="sale.ptav_color">
|
||||
<ul class="list-inline flex-grow-1 mb-0">
|
||||
<li t-foreach="this.props.attribute_values" t-as="ptav" t-key="ptav.id"
|
||||
class="list-inline-item me-2">
|
||||
<t t-set="img_style" t-value="ptav.image ? 'background:url(/web/image/product.template.attribute.value/'+ptav.id+'/image); background-size:cover;' : ''"/>
|
||||
<t t-set="color_style" t-value="ptav.is_custom ? '' : 'background-color:' + ptav.html_color"/>
|
||||
<label
|
||||
class="position-relative d-inline-block rounded-pill text-center"
|
||||
t-att-title="ptav.name"
|
||||
t-attf-style="#{img_style or color_style}"
|
||||
t-att-class="{'o_sale_product_configurator_ptav_color': true,
|
||||
'active': this.props.selected_attribute_value_ids.includes(ptav.id),
|
||||
'custom_value': ptav.is_custom,
|
||||
'transparent': !ptav.is_custom and !ptav.html_color,
|
||||
'css_not_available': ptav.excluded }">
|
||||
<input
|
||||
type="radio"
|
||||
t-attf-id="ptav-{{ptav.id}}-input"
|
||||
t-att-value="ptav.id"
|
||||
t-att-name="'ptal-' + this.props.id"
|
||||
t-att-checked="this.props.selected_attribute_value_ids.includes(ptav.id)"
|
||||
t-on-change="updateSelectedPTAV"
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</t>
|
||||
<t t-name="sale.ptav_image">
|
||||
<ul class="list-inline flex-grow-1 mb-0">
|
||||
<li
|
||||
t-foreach="this.props.attribute_values"
|
||||
t-as="ptav"
|
||||
t-key="ptav.id"
|
||||
class="list-inline-item me-2"
|
||||
>
|
||||
<label
|
||||
class="position-relative d-inline-block text-center o_sale_product_configurator_ptav_image rounded-3 cursor-pointer"
|
||||
t-att-title="ptav.name"
|
||||
t-attf-style="#{ptav.image ? 'background-image:url(/web/image/product.template.attribute.value/'+ptav.id+'/image); background-size:cover;' : ''}"
|
||||
t-att-class="{
|
||||
'active': this.props.selected_attribute_value_ids.includes(ptav.id),
|
||||
'custom_value': ptav.is_custom,
|
||||
'transparent': !ptav.is_custom and !ptav.html_color,
|
||||
'css_not_available': ptav.excluded
|
||||
}"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
t-attf-id="ptav-{{ptav.id}}-input"
|
||||
t-att-value="ptav.id"
|
||||
t-att-name="'ptal-' + this.props.id"
|
||||
t-att-checked="this.props.selected_attribute_value_ids.includes(ptav.id)"
|
||||
t-on-change="updateSelectedPTAV"
|
||||
class="w-100 h-100 opacity-0 cursor-pointer"
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</t>
|
||||
<t t-name="sale.ptav_multi">
|
||||
<ul class="list-unstyled flex-grow-1 m-0">
|
||||
<li t-foreach="this.props.attribute_values" t-as="ptav" t-key="ptav.id" class="mb-2">
|
||||
<div class="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-check-input"
|
||||
t-attf-id="ptav-{{ptav.id}}-input"
|
||||
t-att-value="ptav.id"
|
||||
t-att-name="'ptal-' + this.props.id"
|
||||
t-att-checked="this.props.selected_attribute_value_ids.includes(ptav.id)"
|
||||
t-on-change="updateSelectedPTAV"
|
||||
/>
|
||||
<label
|
||||
class="form-check-label"
|
||||
t-attf-for="ptav-{{ptav.id}}-input">
|
||||
<span class="me-1" t-out="ptav.name"/>
|
||||
<BadgeExtraPrice
|
||||
t-if="ptav.price_extra"
|
||||
price="ptav.price_extra"
|
||||
currencyId="this.env.currency.id"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
|
||||
import { Component } from '@odoo/owl';
|
||||
|
||||
export class QuantityButtons extends Component {
|
||||
static template = 'sale.QuantityButtons';
|
||||
static props = {
|
||||
quantity: Number,
|
||||
setQuantity: Function,
|
||||
isMinusButtonDisabled: { type: Boolean, optional: true },
|
||||
isPlusButtonDisabled: { type: Boolean, optional: true },
|
||||
btnClasses: { type: String, optional: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* Increase the quantity.
|
||||
*/
|
||||
increaseQuantity() {
|
||||
this.props.setQuantity(this.props.quantity + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrease the quantity.
|
||||
*/
|
||||
decreaseQuantity() {
|
||||
this.props.setQuantity(this.props.quantity - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the quantity to a specified value.
|
||||
*
|
||||
* @param {Event} event The quantity input's `on change` event, containing the new quantity.
|
||||
*/
|
||||
async setQuantity(event) {
|
||||
const quantity = parseFloat(event.target.value);
|
||||
const didUpdateQuantity = await this.props.setQuantity(isNaN(quantity) ? 0 : quantity);
|
||||
// If the quantity wasn't updated, the component won't rerender, and the input will display
|
||||
// a stale value. As a result, we need to manually rerender the input.
|
||||
if (!didUpdateQuantity) {
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
input[name="sale_quantity"] {
|
||||
padding: 0;
|
||||
|
||||
@include media-breakpoint-down(md) {
|
||||
max-width: 3rem;
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(md) {
|
||||
max-width: 4rem;
|
||||
}
|
||||
|
||||
// removing input field=number arrows as their size might
|
||||
// change depending on browser default styling and shift input's position
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
&[type=number] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="sale.QuantityButtons">
|
||||
<div name="quantity_buttons_wrapper" class="input-group justify-content-end ">
|
||||
<button
|
||||
name="sale_quantity_button_minus"
|
||||
t-attf-class="px-2 px-md-3 btn btn-secondary {{ props.btnClasses or 'd-md-inline-block' }}"
|
||||
aria-label="Remove one"
|
||||
t-att-disabled="props.isMinusButtonDisabled"
|
||||
t-on-click="decreaseQuantity"
|
||||
>
|
||||
<i class="oi oi-minus"/>
|
||||
</button>
|
||||
<input
|
||||
class="form-control quantity text-center"
|
||||
name="sale_quantity"
|
||||
type="number"
|
||||
t-att-value="props.quantity"
|
||||
t-on-change="setQuantity"
|
||||
/>
|
||||
<button
|
||||
t-attf-class="px-2 px-md-3 btn btn-secondary {{ props.btnClasses or 'd-md-inline-block' }}"
|
||||
name="sale_quantity_button_plus"
|
||||
aria-label="Add one"
|
||||
t-att-disabled="props.isPlusButtonDisabled"
|
||||
t-on-click="increaseQuantity"
|
||||
>
|
||||
<i class="oi oi-plus"/>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { Component } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { SaleActionHelperDialog } from "./sale_action_helper_dialog";
|
||||
|
||||
export class SaleActionHelper extends Component {
|
||||
static template = "sale.SaleActionHelper";
|
||||
static props = {
|
||||
noContentHelp: String,
|
||||
}
|
||||
|
||||
setup() {
|
||||
this.dialogService = useService("dialog");
|
||||
}
|
||||
|
||||
openVideoPreview() {
|
||||
this.dialogService.add(SaleActionHelperDialog, {
|
||||
url: "https://www.youtube.com/embed/N4zw-2t6spk?autoplay=1",
|
||||
})
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
.o_sale_action_preview {
|
||||
|
||||
i {
|
||||
color: white;
|
||||
}
|
||||
|
||||
> div {
|
||||
background-color: rgba(black, .15);
|
||||
transition: .5s;
|
||||
}
|
||||
|
||||
&:hover > div {
|
||||
background-color: rgba(black, .3);
|
||||
transition: .5s;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="sale.SaleActionHelper">
|
||||
<div class="o_view_nocontent flex-wrap pt-5">
|
||||
<div class="container">
|
||||
<div class="o_nocontent_help">
|
||||
<div>
|
||||
<a
|
||||
class="o_sale_action_preview position-relative overflow-hidden d-inline-block rounded-4"
|
||||
role="button"
|
||||
t-on-click="this.openVideoPreview"
|
||||
>
|
||||
<i class="position-absolute top-50 start-50 translate-middle w-auto h-auto z-1 fa fa-4x fa-play-circle"/>
|
||||
<div class="position-absolute top-0 end-0 w-100 h-100"/>
|
||||
<img src="/sale/static/src/img/sales_quotation_thumbnail.webp" class="img w-100"/>
|
||||
</a>
|
||||
<t t-out="props.noContentHelp"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { Component } from "@odoo/owl";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
|
||||
export class SaleActionHelperDialog extends Component {
|
||||
static components = { Dialog };
|
||||
static template = "sale.SaleActionHelperDialog";
|
||||
static props = {
|
||||
url: String,
|
||||
close: Function,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="sale.SaleActionHelperDialog">
|
||||
<Dialog
|
||||
bodyClass="'shadow'"
|
||||
contentClass="'border-0 bg-transparent shadow-none'"
|
||||
footer="false"
|
||||
size="'xl'"
|
||||
technical="false"
|
||||
withBodyPadding="false"
|
||||
t-on-click="props.close"
|
||||
>
|
||||
<div class="ratio ratio-16x9">
|
||||
<iframe
|
||||
allow="autoplay; encrypted-media; picture-in-picture; web-share"
|
||||
width="1140"
|
||||
height="641"
|
||||
t-att-src="this.props.url"
|
||||
title="Sale"
|
||||
frameborder="0"
|
||||
allowfullscreen="1"
|
||||
>
|
||||
Your browser does not support iframe.
|
||||
</iframe>
|
||||
</div>
|
||||
</Dialog>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,259 @@
|
|||
import {
|
||||
ProductLabelSectionAndNoteListRender,
|
||||
productLabelSectionAndNoteOne2Many,
|
||||
ProductLabelSectionAndNoteOne2Many,
|
||||
} from '@account/components/product_label_section_and_note_field/product_label_section_and_note_field_o2m';
|
||||
import {
|
||||
listSectionAndNoteText,
|
||||
ListSectionAndNoteText,
|
||||
sectionAndNoteFieldOne2Many,
|
||||
sectionAndNoteText,
|
||||
SectionAndNoteText,
|
||||
} from '@account/components/section_and_note_fields_backend/section_and_note_fields_backend';
|
||||
import { useSubEnv } from '@odoo/owl';
|
||||
import { registry } from '@web/core/registry';
|
||||
import { CharField } from '@web/views/fields/char/char_field';
|
||||
|
||||
function getComboRecords(listRecords, record) {
|
||||
const comboRecords = [];
|
||||
|
||||
if (record.data.product_type === 'combo') {
|
||||
// if currernt record is combo then we move forward util we find non combo line
|
||||
comboRecords.push(record);
|
||||
let index = listRecords.indexOf(record) + 1;
|
||||
|
||||
while (index < listRecords.length) {
|
||||
const r = listRecords[index];
|
||||
if (
|
||||
!r.data.combo_item_id?.id
|
||||
|| (
|
||||
r.data.linked_line_id?.id !== record.resId
|
||||
&& r.data.linked_virtual_id !== record.data.virtual_id
|
||||
)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
comboRecords.push(r);
|
||||
index++;
|
||||
}
|
||||
|
||||
} else if (record.data.combo_item_id?.id) {
|
||||
// if current record is combo item then we move backward util we find associated combo line
|
||||
// Here we assume that the record we get is the last item of the combo
|
||||
let index = listRecords.indexOf(record);
|
||||
while (index >= 0) {
|
||||
const r = listRecords[index];
|
||||
comboRecords.unshift(r);
|
||||
|
||||
if (
|
||||
r.data.product_type === 'combo'
|
||||
&& (
|
||||
r.resId === record.data.linked_line_id?.id
|
||||
|| r.data.virtual_id === record.data.linked_virtual_id
|
||||
)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
index--;
|
||||
}
|
||||
}
|
||||
|
||||
return comboRecords;
|
||||
}
|
||||
|
||||
export class SaleOrderLineListRenderer extends ProductLabelSectionAndNoteListRender {
|
||||
static recordRowTemplate = 'sale.ListRenderer.RecordRow';
|
||||
|
||||
setup(){
|
||||
super.setup();
|
||||
this.priceColumns.push('discount');
|
||||
|
||||
useSubEnv({
|
||||
shouldCollapse: this.shouldCollapse.bind(this),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Little hack to make sure we get correct title field everytime
|
||||
* while accessing comboColumns
|
||||
*/
|
||||
get comboColumns() {
|
||||
return [this.titleField, ...this.props.aggregatedFields, 'product_uom_qty', 'discount'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Product description widget logic
|
||||
*/
|
||||
getCellTitle(column, record) {
|
||||
// When using this list renderer, we don't want the product_id cell to have a tooltip with
|
||||
// its label.
|
||||
if (column.name === 'product_id' || column.name === 'product_template_id') {
|
||||
return;
|
||||
}
|
||||
return super.getCellTitle(column, record);
|
||||
}
|
||||
|
||||
getActiveColumns() {
|
||||
let activeColumns = super.getActiveColumns();
|
||||
let productTmplCol = activeColumns.find((col) => col.name === 'product_template_id');
|
||||
let productCol = activeColumns.find((col) => col.name === 'product_id');
|
||||
|
||||
if (productCol && productTmplCol) {
|
||||
// Hide the template column if the variant one is enabled.
|
||||
activeColumns = activeColumns.filter((col) => col.name != 'product_template_id')
|
||||
}
|
||||
|
||||
return activeColumns;
|
||||
}
|
||||
|
||||
getRowClass(record) {
|
||||
let classNames = super.getRowClass(record);
|
||||
if (this.isCombo(record) || this.isComboItem(record)) {
|
||||
classNames = classNames.replace('o_row_draggable', '');
|
||||
}
|
||||
return `${classNames} ${this.isCombo(record) ? 'o_is_line_section o_is_line_section_no_indent' : ''}`;
|
||||
}
|
||||
|
||||
isCellReadonly(column, record) {
|
||||
return super.isCellReadonly(column, record) || (
|
||||
this.isComboItem(record)
|
||||
&& !['name', 'tax_ids', 'qty_delivered'].includes(column.name)
|
||||
);
|
||||
}
|
||||
|
||||
async onDeleteRecord(record) {
|
||||
if (this.isCombo(record)) {
|
||||
await record.update({ selected_combo_items: JSON.stringify([]) });
|
||||
}
|
||||
await super.onDeleteRecord(record);
|
||||
}
|
||||
|
||||
async moveCombo(record, direction) {
|
||||
const canProceed = await this.props.list.leaveEditMode({ canAbandon: false });
|
||||
if (!canProceed) return;
|
||||
|
||||
const { movingRecords, targetRecords } = this.getComboSwapPairs(record, direction);
|
||||
return this.swapSections(movingRecords, targetRecords);
|
||||
}
|
||||
|
||||
getComboSwapPairs(record, direction) {
|
||||
const comboRecords = getComboRecords(this.props.list.records, record);
|
||||
|
||||
if (direction === 'up') {
|
||||
return {
|
||||
movingRecords: this.getPreviousRecords(record),
|
||||
targetRecords: comboRecords,
|
||||
};
|
||||
}
|
||||
if (direction === 'down') {
|
||||
return {
|
||||
movingRecords: comboRecords,
|
||||
targetRecords: this.getNextRecords(record),
|
||||
};
|
||||
}
|
||||
return { movingRecords: [], targetRecords: [] };
|
||||
}
|
||||
|
||||
getPreviousRecords(record) {
|
||||
const { records } = this.props.list;
|
||||
const previousRecord = records[records.indexOf(record) - 1];
|
||||
|
||||
if (previousRecord?.data.combo_item_id?.id){
|
||||
return getComboRecords(records, previousRecord);
|
||||
}
|
||||
return previousRecord ? [previousRecord] : false;
|
||||
}
|
||||
|
||||
getNextRecords(record) {
|
||||
const { records } = this.props.list;
|
||||
const comboRecords = getComboRecords(records, record);
|
||||
|
||||
const nextRecord = records[records.indexOf(record) + comboRecords.length];
|
||||
if (nextRecord?.data.product_type === 'combo'){
|
||||
return getComboRecords(records, nextRecord);
|
||||
}
|
||||
return nextRecord ? [nextRecord] : false;
|
||||
}
|
||||
|
||||
canUseFormatter(column, record) {
|
||||
if (
|
||||
this.isCombo(record) &&
|
||||
this.props.aggregatedFields.includes(column.name)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return super.canUseFormatter(column, record);
|
||||
}
|
||||
|
||||
// For totals on combo lines
|
||||
getFormattedValue(column, record) {
|
||||
if (this.isCombo(record) && this.props.aggregatedFields.includes(column.name)) {
|
||||
const total = getComboRecords(this.props.list.records, record)
|
||||
.reduce((total, record) => total + record.data[column.name], 0);
|
||||
|
||||
const formatter = registry.category('formatters').get(column.fieldType, (val) => val);
|
||||
|
||||
return formatter(total, {
|
||||
...formatter.extractOptions?.(column),
|
||||
data: record.data,
|
||||
field: record.fields[column.name],
|
||||
});
|
||||
}
|
||||
return super.getFormattedValue(column, record);
|
||||
}
|
||||
|
||||
isCombo(record) {
|
||||
return record.data.product_type === 'combo';
|
||||
}
|
||||
|
||||
isComboItem(record) {
|
||||
return !!record.data.combo_item_id;
|
||||
}
|
||||
|
||||
shouldDuplicateSectionItem(record) {
|
||||
return !this.isCombo(record) && !this.isComboItem(record);
|
||||
}
|
||||
|
||||
displayDeleteIcon(record){
|
||||
return super.displayDeleteIcon(record) && !this.isComboItem(record);
|
||||
}
|
||||
}
|
||||
|
||||
export class SaleOrderLineOne2Many extends ProductLabelSectionAndNoteOne2Many {
|
||||
static components = {
|
||||
...ProductLabelSectionAndNoteOne2Many.components,
|
||||
ListRenderer: SaleOrderLineListRenderer,
|
||||
};
|
||||
}
|
||||
export const saleOrderLineOne2Many = {
|
||||
...productLabelSectionAndNoteOne2Many,
|
||||
component: SaleOrderLineOne2Many,
|
||||
additionalClasses: sectionAndNoteFieldOne2Many.additionalClasses,
|
||||
};
|
||||
|
||||
registry.category('fields').add('sol_o2m', saleOrderLineOne2Many);
|
||||
|
||||
export class SaleOrderLineText extends SectionAndNoteText {
|
||||
get componentToUse() {
|
||||
return this.props.record.data.product_type === 'combo' ? CharField : super.componentToUse;
|
||||
}
|
||||
}
|
||||
|
||||
export class ListSaleOrderLineText extends ListSectionAndNoteText {
|
||||
get componentToUse() {
|
||||
return this.props.record.data.product_type === 'combo' ? CharField : super.componentToUse;
|
||||
}
|
||||
}
|
||||
|
||||
export const saleOrderLineText = {
|
||||
...sectionAndNoteText,
|
||||
component: SaleOrderLineText,
|
||||
};
|
||||
|
||||
export const listSaleOrderLineText = {
|
||||
...listSectionAndNoteText,
|
||||
component: ListSaleOrderLineText,
|
||||
};
|
||||
|
||||
registry.category('fields').add('sol_text', saleOrderLineText);
|
||||
registry.category('fields').add('list.sol_text', listSaleOrderLineText);
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates>
|
||||
<t
|
||||
t-name="sale.ListRenderer.RecordRow"
|
||||
t-inherit="account.SectionAndNoteListRenderer.RecordRow"
|
||||
t-inherit-mode="primary"
|
||||
>
|
||||
<t t-set="isInvisible" position="attributes">
|
||||
<attribute
|
||||
name="t-value"
|
||||
separator=" or "
|
||||
add="isCombo(record) and !this.comboColumns.includes(column.name)"
|
||||
/>
|
||||
</t>
|
||||
|
||||
<xpath expr="//td[hasclass('o_list_record_remove')]" position="attributes">
|
||||
<attribute name="t-if" separator=" and " add="!isCombo(record)"/>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//td[hasclass('o_list_section_options')]" position="before">
|
||||
<td t-elif="isCombo(record)" class="o_list_section_options w-print-0 p-print-0 text-center">
|
||||
<Dropdown position="'bottom-end'" t-if="!props.readonly">
|
||||
<button class="btn d-table-cell border-0 py-0 px-1 cursor-pointer">
|
||||
<i class="fa fa-ellipsis-v"/>
|
||||
</button>
|
||||
<t t-set-slot="content">
|
||||
<DropdownItem
|
||||
t-if="this.getPreviousRecords(record)"
|
||||
onSelected="() => this.moveCombo(record, 'up')"
|
||||
>
|
||||
<i class="me-1 fa fa-fw fa-arrow-up"/>
|
||||
<span>Move Up</span>
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
t-if="this.getNextRecords(record)"
|
||||
onSelected="() => this.moveCombo(record, 'down')"
|
||||
>
|
||||
<i class="me-1 fa fa-fw fa-arrow-down"/>
|
||||
<span>Move Down</span>
|
||||
</DropdownItem>
|
||||
<t t-if="hasDeleteButton">
|
||||
<DropdownItem
|
||||
onSelected="() => this.onDeleteRecord(record)"
|
||||
class="'text-danger'"
|
||||
>
|
||||
<i class="me-1 fa fa-fw fa-trash"/>
|
||||
<span>Delete</span>
|
||||
</DropdownItem>
|
||||
</t>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</td>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
/** @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']);
|
||||
},
|
||||
});
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
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(' ');
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
@ -1,102 +1,470 @@
|
|||
/** @odoo-module **/
|
||||
import {
|
||||
ProductLabelSectionAndNoteField,
|
||||
productLabelSectionAndNoteField,
|
||||
} from "@account/components/product_label_section_and_note_field/product_label_section_and_note_field";
|
||||
import { useEffect } from "@odoo/owl";
|
||||
import { serializeDateTime } from "@web/core/l10n/dates";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { x2ManyCommands } from "@web/core/orm_service";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { uuid } from "@web/core/utils/strings";
|
||||
import { ComboConfiguratorDialog } from "./combo_configurator_dialog/combo_configurator_dialog";
|
||||
import { ProductCombo } from "./models/product_combo";
|
||||
import { ProductConfiguratorDialog } from "./product_configurator_dialog/product_configurator_dialog";
|
||||
import { getLinkedSaleOrderLines, serializeComboItem, getSelectedCustomPtav } from "./sale_utils";
|
||||
|
||||
import { registry } from '@web/core/registry';
|
||||
import { Many2OneField } from '@web/views/fields/many2one/many2one_field';
|
||||
import { useEffect } from '@odoo/owl';
|
||||
async function applyProduct(record, product) {
|
||||
// handle custom values & no variants
|
||||
const customAttributesCommands = [
|
||||
x2ManyCommands.set([]), // Command.clear isn't supported in static_list/_applyCommands
|
||||
];
|
||||
for (const ptal of product.attribute_lines) {
|
||||
const selectedCustomPTAV = getSelectedCustomPtav(ptal);
|
||||
if (selectedCustomPTAV) {
|
||||
customAttributesCommands.push(
|
||||
x2ManyCommands.create(undefined, {
|
||||
custom_product_template_attribute_value_id: [
|
||||
selectedCustomPTAV.id,
|
||||
"we don't care",
|
||||
],
|
||||
custom_value: ptal.customValue,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class SaleOrderLineProductField extends Many2OneField {
|
||||
const noVariantPTAVIds = product.attribute_lines
|
||||
.filter((ptal) => ptal.create_variant === "no_variant")
|
||||
.flatMap((ptal) => ptal.selected_attribute_value_ids);
|
||||
|
||||
// We use `_update` (not locked) instead of `update` (locked) so that multiple records can be
|
||||
// updated in parallel (for performance).
|
||||
const update_values = {
|
||||
product_id: { id: product.id, display_name: product.display_name },
|
||||
product_uom_qty: product.quantity,
|
||||
product_no_variant_attribute_value_ids: [x2ManyCommands.set(noVariantPTAVIds)],
|
||||
product_custom_attribute_value_ids: customAttributesCommands,
|
||||
}
|
||||
if (product.uom) {
|
||||
// only update uom field if uom are enabled (uom_data provided), otherwise we don't have the display_name
|
||||
// and the value isn't expected to change anyway.
|
||||
update_values.product_uom_id = product.uom;
|
||||
}
|
||||
await record._update(update_values);
|
||||
}
|
||||
|
||||
export class SaleOrderLineProductField extends ProductLabelSectionAndNoteField {
|
||||
static template = "sale.SaleProductField";
|
||||
static props = {
|
||||
...super.props,
|
||||
readonlyField: { type: Boolean, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.dialog = useService("dialog");
|
||||
this.notification = useService("notification");
|
||||
this.orm = useService("orm");
|
||||
this.isInternalUpdate = false;
|
||||
this.wasCombo = false;
|
||||
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) {
|
||||
} else if (value && this.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') {
|
||||
if (this.wasCombo) {
|
||||
// If the previously selected product was a combo, delete its selected combo
|
||||
// items before changing the product.
|
||||
this.props.record.update({ selected_combo_items: JSON.stringify([]) });
|
||||
}
|
||||
if (this.relation === "product.template" || this.isCombo) {
|
||||
this._onProductTemplateUpdate();
|
||||
} else {
|
||||
this._onProductUpdate();
|
||||
}
|
||||
}
|
||||
isInternalUpdate = false;
|
||||
}, () => [Array.isArray(this.value) && this.value[0]]);
|
||||
this.isInternalUpdate = false;
|
||||
}, () => [this.value && this.value.id]);
|
||||
}
|
||||
|
||||
get productName() {
|
||||
if (this.props.name == 'product_template_id') {
|
||||
const product_id_data = this.props.record.data.product_id;
|
||||
if (product_id_data && product_id_data.display_name) {
|
||||
return product_id_data.display_name.split("\n")[0];
|
||||
}
|
||||
}
|
||||
return super.productName;
|
||||
}
|
||||
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);
|
||||
this.props.readonlyField ||
|
||||
(this.props.record.model.root.activeFields.order_line &&
|
||||
this.props.record.model.root._isReadonly("order_line"))
|
||||
);
|
||||
}
|
||||
get hasConfigurationButton() {
|
||||
return this.isConfigurableLine || this.isConfigurableTemplate;
|
||||
return this.isConfigurableTemplate || this.isCombo;
|
||||
}
|
||||
get isConfigurableTemplate() {
|
||||
return this.props.record.data.is_configurable_product;
|
||||
}
|
||||
get isCombo() {
|
||||
return this.props.record.data.product_template_id && this.props.record.data.product_type === 'combo';
|
||||
}
|
||||
get isDownpayment() {
|
||||
return this.props.record.data.is_downpayment;
|
||||
}
|
||||
get isConfigurableLine() { return false; }
|
||||
get isConfigurableTemplate() { return false; }
|
||||
|
||||
get configurationButtonHelp() {
|
||||
return this.env._t("Edit Configuration");
|
||||
return _t("Edit Configuration");
|
||||
}
|
||||
|
||||
get configurationButtonIcon() {
|
||||
return 'btn btn-secondary fa ' + this.configurationButtonFAIcon();
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get sectionAndNoteClasses() {
|
||||
return {
|
||||
...super.sectionAndNoteClasses,
|
||||
"text-warning":
|
||||
!this.isSectionOrSubSection && !this.isNote() && !this.productName && !this.isDownpayment,
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
get label() {
|
||||
let label = this.props.record.data.name;
|
||||
if (this.translatedProductName && label.startsWith(this.translatedProductName)) {
|
||||
// Remove the translated name as it is already shown to the salesman on the SOL.
|
||||
label = label.slice(this.translatedProductName.length + 1); // + "\n"
|
||||
} else {
|
||||
label = super.label;
|
||||
}
|
||||
else {
|
||||
super.onClick(ev);
|
||||
return label;
|
||||
}
|
||||
|
||||
get translatedProductName() {
|
||||
return this.props.record.data.translated_product_name;
|
||||
}
|
||||
|
||||
parseLabel(value) {
|
||||
if (!this.translatedProductName) {
|
||||
return super.parseLabel(value);
|
||||
}
|
||||
return value && this.translatedProductName.concat("\n", value) || this.translatedProductName;
|
||||
}
|
||||
|
||||
get m2oProps() {
|
||||
const p = super.m2oProps;
|
||||
const value = p.value && { ...p.value };
|
||||
if (this.isCombo && value && value.display_name) {
|
||||
// Show the product quantity next to the product name for combo lines.
|
||||
value.display_name = `${value.display_name} x ${this.props.record.data.product_uom_qty}`;
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
canOpen: this.props.canOpen && (!this.props.readonly || this.isProductClickable),
|
||||
update: (value) => {
|
||||
this.isInternalUpdate = true;
|
||||
this.wasCombo = this.isCombo;
|
||||
return p.update(value);
|
||||
},
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
get relation() {
|
||||
return this.props.record.fields[this.props.name].relation;
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this.props.record.data[this.props.name];
|
||||
}
|
||||
|
||||
async _onProductTemplateUpdate() {
|
||||
const result = await this.orm.call(
|
||||
'product.template',
|
||||
'get_single_product_variant',
|
||||
[this.props.record.data.product_template_id.id],
|
||||
{
|
||||
context: this.context,
|
||||
}
|
||||
);
|
||||
if (result && result.product_id) {
|
||||
if (this.props.record.data.product_id != result.product_id.id) {
|
||||
if (result.is_combo) {
|
||||
await this.props.record.update({
|
||||
product_id: { id: result.product_id, display_name: result.product_name },
|
||||
});
|
||||
this._openComboConfigurator(false, result.has_optional_products);
|
||||
} else if (result.has_optional_products) {
|
||||
this._openProductConfigurator();
|
||||
} else {
|
||||
await this.props.record.update({
|
||||
product_id: { id: result.product_id, display_name: result.product_name },
|
||||
});
|
||||
this._onProductUpdate();
|
||||
}
|
||||
}
|
||||
} else if (!result.mode || result.mode === 'configurator') {
|
||||
this._openProductConfigurator();
|
||||
} else {
|
||||
// only triggered when sale_product_matrix is installed.
|
||||
this._openGridConfigurator();
|
||||
}
|
||||
}
|
||||
|
||||
async _onProductTemplateUpdate() { }
|
||||
async _onProductUpdate() { } // event_booth_sale, event_sale, sale_renting
|
||||
_openGridConfigurator(edit = false) {} // sale_product_matrix
|
||||
|
||||
async _onProductUpdate() {} // event_booth_sale, event_sale, sale_renting
|
||||
|
||||
onEditConfiguration() {
|
||||
if (this.isConfigurableLine) {
|
||||
this._editLineConfiguration();
|
||||
} else {
|
||||
this._editProductConfiguration();
|
||||
if (this.isCombo) {
|
||||
this._openComboConfigurator(true);
|
||||
} else if (this.isConfigurableTemplate) {
|
||||
this._openProductConfigurator(true);
|
||||
}
|
||||
}
|
||||
_editLineConfiguration() { } // event_booth_sale, event_sale, sale_renting
|
||||
_editProductConfiguration() { } // sale_product_configurator, sale_product_matrix
|
||||
|
||||
async _openProductConfigurator(edit = false, selectedComboItems = []) {
|
||||
const saleOrderRecord = this.props.record.model.root;
|
||||
const saleOrderLine = this.props.record.data;
|
||||
const ptavIds = this._getVariantPtavIds(saleOrderLine);
|
||||
let customPtavs = [];
|
||||
|
||||
if (edit) {
|
||||
/**
|
||||
* no_variant and custom attribute don't need to be given to the configurator for new
|
||||
* products.
|
||||
*/
|
||||
ptavIds.push(...this._getNoVariantPtavIds(saleOrderLine));
|
||||
customPtavs = await this._getCustomPtavs(saleOrderLine);
|
||||
}
|
||||
|
||||
this.dialog.add(ProductConfiguratorDialog, {
|
||||
productTemplateId: saleOrderLine.product_template_id.id,
|
||||
ptavIds: ptavIds,
|
||||
customPtavs: customPtavs,
|
||||
quantity: saleOrderLine.product_uom_qty,
|
||||
productUOMId: saleOrderLine.product_uom_id.id,
|
||||
companyId: saleOrderRecord.data.company_id.id,
|
||||
pricelistId: saleOrderRecord.data.pricelist_id.id,
|
||||
currencyId: saleOrderLine.currency_id.id,
|
||||
soDate: serializeDateTime(saleOrderRecord.data.date_order),
|
||||
selectedComboItems: selectedComboItems,
|
||||
edit: edit,
|
||||
save: async (mainProduct, optionalProducts) => {
|
||||
// Don't add main product if it's a combo product as it has already been added
|
||||
// from combo configurator
|
||||
const proms = !selectedComboItems.length
|
||||
? [applyProduct(this.props.record, mainProduct)]
|
||||
: [];
|
||||
|
||||
for (const [i, product] of optionalProducts.entries()) {
|
||||
const index =
|
||||
saleOrderRecord.data.order_line.records.indexOf(this.props.record)
|
||||
+ selectedComboItems.length
|
||||
+ i;
|
||||
const line = await saleOrderRecord.data.order_line.addNewRecordAtIndex(index, {
|
||||
mode: 'readonly',
|
||||
});
|
||||
const productData = this._prepareNewLineData(line, product);
|
||||
proms.push(applyProduct(line, productData));
|
||||
}
|
||||
|
||||
await Promise.all(proms);
|
||||
this._onProductUpdate();
|
||||
saleOrderRecord.data.order_line.leaveEditMode();
|
||||
},
|
||||
discard: () => {
|
||||
if (!selectedComboItems.length) {
|
||||
// Don't delete the main product if it's a combo product as it has been added
|
||||
// from combo configurator
|
||||
saleOrderRecord.data.order_line.delete(this.props.record);
|
||||
}
|
||||
},
|
||||
...this._getAdditionalDialogProps(),
|
||||
});
|
||||
}
|
||||
|
||||
async _openComboConfigurator(edit = false, hasOptionalProducts = false) {
|
||||
const saleOrder = this.props.record.model.root.data;
|
||||
const comboLineRecord = this.props.record;
|
||||
const comboItemLineRecords = getLinkedSaleOrderLines(comboLineRecord).filter(record => !!record.data.combo_item_id);
|
||||
const selectedComboItems = await Promise.all(comboItemLineRecords.map(async record => ({
|
||||
id: record.data.combo_item_id.id,
|
||||
no_variant_ptav_ids: edit ? this._getNoVariantPtavIds(record.data) : [],
|
||||
custom_ptavs: edit ? await this._getCustomPtavs(record.data) : [],
|
||||
})));
|
||||
const { combos, ...remainingData } = await rpc('/sale/combo_configurator/get_data', {
|
||||
product_tmpl_id: comboLineRecord.data.product_template_id.id,
|
||||
currency_id: comboLineRecord.data.currency_id.id,
|
||||
quantity: comboLineRecord.data.product_uom_qty,
|
||||
date: serializeDateTime(saleOrder.date_order),
|
||||
company_id: saleOrder.company_id.id,
|
||||
pricelist_id: saleOrder.pricelist_id.id,
|
||||
selected_combo_items: selectedComboItems,
|
||||
...this._getAdditionalRpcParams(),
|
||||
});
|
||||
|
||||
const comboChoices = combos.map(combo => new ProductCombo(combo));
|
||||
const preselectedComboItems = comboChoices
|
||||
.map(combo => combo.preselectedComboItem)
|
||||
.filter(Boolean);
|
||||
if (preselectedComboItems.length === comboChoices.length) {
|
||||
return this.handleComboSave(
|
||||
{ 'quantity' : remainingData.quantity },
|
||||
preselectedComboItems,
|
||||
edit,
|
||||
hasOptionalProducts
|
||||
);
|
||||
}
|
||||
this.dialog.add(ComboConfiguratorDialog, {
|
||||
combos: comboChoices,
|
||||
...remainingData,
|
||||
company_id: saleOrder.company_id.id,
|
||||
pricelist_id: saleOrder.pricelist_id.id,
|
||||
date: serializeDateTime(saleOrder.date_order),
|
||||
edit: edit,
|
||||
save: async (comboProductData, selectedComboItems) => {
|
||||
this.handleComboSave(
|
||||
comboProductData,
|
||||
selectedComboItems,
|
||||
edit,
|
||||
hasOptionalProducts
|
||||
);
|
||||
},
|
||||
discard: () => saleOrder.order_line.delete(comboLineRecord),
|
||||
...this._getAdditionalDialogProps(),
|
||||
});
|
||||
}
|
||||
|
||||
async handleComboSave(comboProductData, selectedComboItems, edit, hasOptionalProducts) {
|
||||
const saleOrder = this.props.record.model.root.data;
|
||||
const comboLineRecord = this.props.record;
|
||||
saleOrder.order_line.leaveEditMode();
|
||||
const comboLineValues = {
|
||||
product_uom_qty: comboProductData.quantity,
|
||||
selected_combo_items: JSON.stringify(
|
||||
selectedComboItems.map(serializeComboItem)
|
||||
),
|
||||
};
|
||||
if (!edit) {
|
||||
comboLineValues.virtual_id = uuid();
|
||||
}
|
||||
await comboLineRecord.update(comboLineValues);
|
||||
// Ensure that the order lines are sorted according to their sequence.
|
||||
await saleOrder.order_line._sort();
|
||||
|
||||
if (hasOptionalProducts && !edit) {
|
||||
const selectedComboProducts = selectedComboItems.map(
|
||||
item => ({ name: item.product.display_name })
|
||||
);
|
||||
await this._openProductConfigurator(false, selectedComboProducts);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to append additional RPC params in overriding modules.
|
||||
*
|
||||
* @return {Object} The additional RPC params.
|
||||
*/
|
||||
_getAdditionalRpcParams() {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to append additional props in overriding modules.
|
||||
*
|
||||
* @return {Object} The additional props.
|
||||
*/
|
||||
_getAdditionalDialogProps() {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to append extra data in newly created optional product lines.
|
||||
*/
|
||||
_prepareNewLineData(_line, product) {
|
||||
return product;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the PTAV ids of the provided sale order line.
|
||||
*
|
||||
* @param saleOrderLine The sale order line
|
||||
* @return {Number[]} The sale order line's PTAV ids.
|
||||
*/
|
||||
_getVariantPtavIds(saleOrderLine) {
|
||||
return saleOrderLine.product_template_attribute_value_ids.currentIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the `no_variant` PTAV ids of the provided sale order line.
|
||||
*
|
||||
* @param saleOrderLine The sale order line
|
||||
* @return {Number[]} The sale order line's `no_variant` PTAV ids.
|
||||
*/
|
||||
_getNoVariantPtavIds(saleOrderLine) {
|
||||
return saleOrderLine.product_no_variant_attribute_value_ids.currentIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the custom PTAVs of the provided sale order line.
|
||||
*
|
||||
* @param saleOrderLine The sale order line
|
||||
* @return {Promise<CustomPtav[]>} The sale order line's custom PTAVs.
|
||||
*/
|
||||
async _getCustomPtavs(saleOrderLine) {
|
||||
// `product.attribute.custom.value` records are not loaded in the view because sub templates
|
||||
// are not loaded in list views. Therefore, we fetch them from the server if the record was
|
||||
// saved. Otherwise, we use the value stored on the line.
|
||||
const customPtavIds = saleOrderLine.product_custom_attribute_value_ids;
|
||||
let customPtavs = [];
|
||||
if (customPtavIds.records[0]?.isNew) {
|
||||
customPtavs = customPtavIds.records.map(record => record.data);
|
||||
} else if (customPtavIds.currentIds.length) {
|
||||
const specification = {
|
||||
custom_product_template_attribute_value_id: {
|
||||
fields: { id: {} },
|
||||
},
|
||||
custom_value: {},
|
||||
};
|
||||
customPtavs = await this.orm.webRead(
|
||||
'product.attribute.custom.value',
|
||||
customPtavIds.currentIds,
|
||||
{ specification },
|
||||
);
|
||||
}
|
||||
return customPtavs.map(customPtav => ({
|
||||
id: customPtav.custom_product_template_attribute_value_id &&
|
||||
customPtav.custom_product_template_attribute_value_id.id,
|
||||
value: customPtav.custom_value,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
SaleOrderLineProductField.template = "sale.SaleProductField";
|
||||
export const saleOrderLineProductField = {
|
||||
...productLabelSectionAndNoteField,
|
||||
component: SaleOrderLineProductField,
|
||||
extractProps(fieldInfo, dynamicInfo) {
|
||||
return {
|
||||
...productLabelSectionAndNoteField.extractProps(fieldInfo, dynamicInfo),
|
||||
readonlyField: dynamicInfo.readonly,
|
||||
};
|
||||
},
|
||||
fieldDependencies: [
|
||||
{ name: 'is_configurable_product', type: 'boolean' },
|
||||
{ name: 'product_type', type: 'selection' },
|
||||
{ name: 'service_tracking', type: 'selection' },
|
||||
{ name: 'product_template_attribute_value_ids', type: 'many2many' },
|
||||
{ name: 'translated_product_name', type: 'char' },
|
||||
],
|
||||
};
|
||||
|
||||
registry.category("fields").add("sol_product_many2one", SaleOrderLineProductField);
|
||||
registry.category("fields").add("sol_product_many2one", saleOrderLineProductField);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
.o_field_sol_o2m ~ .row.o_group {
|
||||
clear: both;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { useEffect } from "@odoo/owl";
|
||||
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;
|
||||
import {
|
||||
KanbanProgressBarField,
|
||||
kanbanProgressBarField,
|
||||
} from "@web/views/fields/progress_bar/kanban_progress_bar_field";
|
||||
|
||||
/**
|
||||
* A custom Component for the view of sales teams on the kanban view in the CRM app.
|
||||
|
|
@ -13,6 +13,7 @@ const { useEffect } = owl;
|
|||
* a link redirecting to the record's form view otherwise.
|
||||
*/
|
||||
export class SaleProgressBarField extends KanbanProgressBarField {
|
||||
static template = "sale.SaleProgressBarField";
|
||||
/**
|
||||
* Anything used by the component is defined on the setup method.
|
||||
*/
|
||||
|
|
@ -33,13 +34,13 @@ export class SaleProgressBarField extends KanbanProgressBarField {
|
|||
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" } });
|
||||
this.actionService.doAction(action);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the template name used on the component.
|
||||
*/
|
||||
SaleProgressBarField.template = "sale.SaleProgressBarField";
|
||||
export const saleProgressBarField = {
|
||||
...kanbanProgressBarField,
|
||||
component: SaleProgressBarField,
|
||||
};
|
||||
|
||||
registry.category("fields").add("sales_team_progressbar", SaleProgressBarField);
|
||||
registry.category("fields").add("sales_team_progressbar", saleProgressBarField);
|
||||
|
|
|
|||
65
odoo-bringout-oca-ocb-sale/sale/static/src/js/sale_utils.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* Checks whether the 2 provided sale order lines are linked.
|
||||
*
|
||||
* @param linkingSaleOrderLine The line that is linking to the other line.
|
||||
* @param linkedSaleOrderLine The line that is linked by the other line.
|
||||
* @return {Boolean} Whether the 2 lines are linked.
|
||||
*/
|
||||
export function areSaleOrderLinesLinked(linkingSaleOrderLine, linkedSaleOrderLine) {
|
||||
const linkingId = linkedSaleOrderLine.isNew
|
||||
? linkingSaleOrderLine.data.linked_virtual_id
|
||||
: linkingSaleOrderLine.data.linked_line_id.id;
|
||||
const linkedId = linkedSaleOrderLine.isNew
|
||||
? linkedSaleOrderLine.data.virtual_id
|
||||
: linkedSaleOrderLine.resId;
|
||||
return linkingId && linkingId === linkedId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the linked lines of the provided sale order line.
|
||||
*
|
||||
* @param saleOrderLine The line whose linked lines to get.
|
||||
* @return {Object[]} The list of linked lines.
|
||||
*/
|
||||
export function getLinkedSaleOrderLines(saleOrderLine) {
|
||||
const saleOrder = saleOrderLine.model.root;
|
||||
// TODO(loti): this leaves out any combo items that are on another page.
|
||||
return saleOrder.data.order_line.records.filter(
|
||||
record => areSaleOrderLinesLinked(record, saleOrderLine)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a combo item into a format understandable by the server.
|
||||
*
|
||||
* @param {ProductComboItem} comboItem The combo item to serialize.
|
||||
* @return {Object} The serialized combo item.
|
||||
*/
|
||||
export function serializeComboItem(comboItem) {
|
||||
return {
|
||||
combo_item_id: comboItem.id,
|
||||
product_id: comboItem.product.id,
|
||||
no_variant_attribute_value_ids: comboItem.product.selectedNoVariantPtavIds,
|
||||
product_custom_attribute_values: comboItem.product.selectedCustomPtavs.map(
|
||||
customPtav => ({
|
||||
custom_product_template_attribute_value_id: customPtav.id,
|
||||
custom_value: customPtav.value,
|
||||
})
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the selected custom PTAV in the provided PTAL, if any.
|
||||
*
|
||||
* Note: a PTAL can have at most one selected custom PTAV, by design.
|
||||
*
|
||||
* @param {ProductTemplateAttributeLine.props} ptal The PTAL in which to look for the selected
|
||||
* custom PTAV.
|
||||
* @return {Object|undefined} The selected custom PTAV, if any.
|
||||
*
|
||||
*/
|
||||
export function getSelectedCustomPtav(ptal) {
|
||||
const selectedPtavIds = new Set(ptal.selected_attribute_value_ids);
|
||||
return ptal.attribute_values.find(ptav => ptav.is_custom && selectedPtavIds.has(ptav.id));
|
||||
}
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
function comboSelector(comboName) {
|
||||
return `
|
||||
.sale-combo-configurator-dialog
|
||||
[name="sale_combo_configurator_title"]:contains("${comboName}")
|
||||
`;
|
||||
}
|
||||
|
||||
function comboItemSelector(comboItemName, extraClasses=[]) {
|
||||
const extraClassesSelector = extraClasses.map(extraClass => `.${extraClass}`).join('');
|
||||
return `
|
||||
.sale-combo-configurator-dialog
|
||||
.product-card${extraClassesSelector}:has(h6:contains("${comboItemName}"))
|
||||
`;
|
||||
}
|
||||
|
||||
function assertComboCount(count) {
|
||||
return {
|
||||
content: `Assert that there are ${count} combos`,
|
||||
trigger: '.sale-combo-configurator-dialog',
|
||||
run() {
|
||||
const selector = `.sale-combo-configurator-dialog [name="sale_combo_configurator_title"]`;
|
||||
if (document.querySelectorAll(selector).length !== count) {
|
||||
console.error(`Assertion failed`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assertComboItemCount(comboName, count) {
|
||||
return {
|
||||
content: `Assert that there are ${count} combo items in combo ${comboName}`,
|
||||
trigger: comboSelector(comboName),
|
||||
run({ queryAll }) {
|
||||
const selector = `${comboSelector(comboName)} + .row .product-card`;
|
||||
if (queryAll(selector).length !== count) {
|
||||
console.error(`Assertion failed`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assertSelectedComboItemCount(count) {
|
||||
return {
|
||||
content: `Assert that there are ${count} selected combo items`,
|
||||
trigger: '.sale-combo-configurator-dialog',
|
||||
run() {
|
||||
const selector = `.sale-combo-configurator-dialog .row .product-card.selected`;
|
||||
if (document.querySelectorAll(selector).length !== count) {
|
||||
console.error(`Assertion failed`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assertPreselectedComboItemCount(count) {
|
||||
return {
|
||||
content: `Assert that there are ${count} preselected combo items`,
|
||||
trigger: '.sale-combo-configurator-dialog',
|
||||
run() {
|
||||
const selector = '.sale-combo-configurator-dialog div[name="preselected_product_name"]';
|
||||
if (document.querySelectorAll(selector).length !== count) {
|
||||
console.error(`Assertion failed`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function selectComboItem(comboItemName) {
|
||||
return {
|
||||
content: `Select combo item ${comboItemName}`,
|
||||
trigger: comboItemSelector(comboItemName),
|
||||
run: 'click',
|
||||
};
|
||||
}
|
||||
|
||||
function assertComboItemSelected(comboItemName) {
|
||||
return {
|
||||
content: `Assert that combo item ${comboItemName} is selected`,
|
||||
trigger: comboItemSelector(comboItemName, ['selected']),
|
||||
};
|
||||
}
|
||||
|
||||
function assertComboItemPreselected(comboItemName) {
|
||||
return {
|
||||
content: `Assert that combo item ${comboItemName} is preselected`,
|
||||
trigger: `[name="preselected_product_name"]:contains(${comboItemName})`,
|
||||
};
|
||||
}
|
||||
|
||||
function increaseQuantity() {
|
||||
return {
|
||||
content: "Increase the combo quantity",
|
||||
trigger: '.sale-combo-configurator-dialog button[name="sale_quantity_button_plus"]',
|
||||
run: 'click',
|
||||
};
|
||||
}
|
||||
|
||||
function decreaseQuantity() {
|
||||
return {
|
||||
content: "Decrease the combo quantity",
|
||||
trigger: '.sale-combo-configurator-dialog button[name="sale_quantity_button_minus"]',
|
||||
run: 'click',
|
||||
};
|
||||
}
|
||||
|
||||
function setQuantity(quantity) {
|
||||
return {
|
||||
content: `Set the combo quantity to ${quantity}`,
|
||||
trigger: '.sale-combo-configurator-dialog input[name="sale_quantity"]',
|
||||
run: `edit ${quantity} && click .modal-body`,
|
||||
};
|
||||
}
|
||||
|
||||
function assertQuantity(quantity) {
|
||||
return {
|
||||
content: `Assert that the combo quantity is ${quantity}`,
|
||||
trigger: `.sale-combo-configurator-dialog input[name="sale_quantity"]:value(${quantity})`,
|
||||
};
|
||||
}
|
||||
|
||||
function assertPrice(price) {
|
||||
return {
|
||||
content: `Assert that the price is ${price}`,
|
||||
trigger: `
|
||||
.sale-combo-configurator-dialog
|
||||
[name="sale_combo_configurator_total"]:contains("${price}")
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
function assertPriceInfo(priceInfo) {
|
||||
return {
|
||||
content: `Assert that the price info is ${priceInfo}`,
|
||||
trigger: `.sale-combo-configurator-dialog footer.modal-footer:contains("${priceInfo}")`,
|
||||
};
|
||||
}
|
||||
|
||||
function assertFooterButtonsDisabled() {
|
||||
return {
|
||||
content: "Assert that the footer buttons are disabled",
|
||||
trigger: '.sale-combo-configurator-dialog footer.modal-footer button:disabled',
|
||||
};
|
||||
}
|
||||
|
||||
function assertFooterButtonsEnabled() {
|
||||
return {
|
||||
content: "Assert that the footer buttons are enabled",
|
||||
trigger: '.sale-combo-configurator-dialog footer.modal-footer button:enabled',
|
||||
};
|
||||
}
|
||||
|
||||
function assertConfirmButtonDisabled() {
|
||||
return {
|
||||
content: "Assert that the confirm button is disabled",
|
||||
trigger: `
|
||||
.sale-combo-configurator-dialog
|
||||
button[name="sale_combo_configurator_confirm_button"]:disabled
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
function assertConfirmButtonEnabled() {
|
||||
return {
|
||||
content: "Assert that the confirm button is enabled",
|
||||
trigger: `
|
||||
.sale-combo-configurator-dialog
|
||||
button[name="sale_combo_configurator_confirm_button"]:enabled
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
function saveConfigurator() {
|
||||
return [
|
||||
{
|
||||
content: "Confirm the combo configurator",
|
||||
trigger: `
|
||||
.sale-combo-configurator-dialog
|
||||
button[name="sale_combo_configurator_confirm_button"]
|
||||
`,
|
||||
run: 'click',
|
||||
}, {
|
||||
content: "Wait until the modal is closed",
|
||||
trigger: 'body:not(:has(.sale-combo-configurator-dialog))',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default {
|
||||
comboSelector,
|
||||
comboItemSelector,
|
||||
assertComboCount,
|
||||
assertComboItemCount,
|
||||
assertSelectedComboItemCount,
|
||||
assertPreselectedComboItemCount,
|
||||
selectComboItem,
|
||||
assertComboItemSelected,
|
||||
assertComboItemPreselected,
|
||||
increaseQuantity,
|
||||
decreaseQuantity,
|
||||
setQuantity,
|
||||
assertQuantity,
|
||||
assertPrice,
|
||||
assertPriceInfo,
|
||||
assertFooterButtonsDisabled,
|
||||
assertFooterButtonsEnabled,
|
||||
assertConfirmButtonDisabled,
|
||||
assertConfirmButtonEnabled,
|
||||
saveConfigurator,
|
||||
};
|
||||
|
|
@ -0,0 +1,265 @@
|
|||
function productSelector(productName) {
|
||||
return `
|
||||
table.o_sale_product_configurator_table
|
||||
tr:has(td>div[name="o_sale_product_configurator_name"]
|
||||
span:contains("${productName}"))
|
||||
`;
|
||||
}
|
||||
|
||||
function optionalProductSelector(productName) {
|
||||
return `
|
||||
table.o_sale_product_configurator_table_optional
|
||||
tr:has(td>div[name="o_sale_product_configurator_name"]
|
||||
span:contains("${productName}"))
|
||||
`;
|
||||
}
|
||||
|
||||
function optionalProductImageSrc(queryOne, productName) {
|
||||
return queryOne(
|
||||
`${optionalProductSelector(productName)} td.o_sale_product_configurator_img>img`
|
||||
).getAttribute("src");
|
||||
}
|
||||
|
||||
function addOptionalProduct(productName) {
|
||||
return {
|
||||
content: `Add ${productName}`,
|
||||
trigger: `
|
||||
${optionalProductSelector(productName)}
|
||||
td.o_sale_product_configurator_price
|
||||
button:contains("Add")
|
||||
`,
|
||||
run: 'click',
|
||||
};
|
||||
}
|
||||
|
||||
function removeOptionalProduct(productName) {
|
||||
return {
|
||||
content: `Remove ${productName}`,
|
||||
trigger: `
|
||||
${productSelector(productName)}
|
||||
td.o_sale_product_configurator_qty
|
||||
a:contains("Remove")
|
||||
`,
|
||||
run: 'click',
|
||||
};
|
||||
}
|
||||
|
||||
function decreaseProductQuantity(productName) {
|
||||
return {
|
||||
content: `Decrease the quantity of ${productName}`,
|
||||
trigger: `
|
||||
${productSelector(productName)}
|
||||
td.o_sale_product_configurator_qty
|
||||
button:has(i.oi-minus)
|
||||
`,
|
||||
run: 'click',
|
||||
};
|
||||
}
|
||||
|
||||
function increaseProductQuantity(productName) {
|
||||
return {
|
||||
content: `Increase the quantity of ${productName}`,
|
||||
trigger: `
|
||||
${productSelector(productName)}
|
||||
td.o_sale_product_configurator_qty
|
||||
button:has(i.oi-plus)
|
||||
`,
|
||||
run: 'click',
|
||||
};
|
||||
}
|
||||
|
||||
function setProductQuantity(productName, quantity) {
|
||||
return {
|
||||
content: `Set the quantity of ${productName} to ${quantity}`,
|
||||
trigger: `
|
||||
${productSelector(productName)}
|
||||
td.o_sale_product_configurator_qty
|
||||
input[name="sale_quantity"]
|
||||
`,
|
||||
run: `edit ${quantity} && click .modal-body`,
|
||||
};
|
||||
}
|
||||
|
||||
function setProductUoM(productName, uomName) {
|
||||
// UoM must be enabled
|
||||
return {
|
||||
content: `Set the uom of ${productName} to ${uomName}`,
|
||||
trigger: `
|
||||
${productSelector(productName)}
|
||||
label:contains("${uomName}")
|
||||
`,
|
||||
run: `click && click .modal-body`,
|
||||
};
|
||||
}
|
||||
|
||||
function assertProductQuantity(productName, quantity) {
|
||||
return {
|
||||
content: `Assert that the quantity of ${productName} is ${quantity}`,
|
||||
trigger: `
|
||||
${productSelector(productName)}
|
||||
td.o_sale_product_configurator_qty
|
||||
input[name="sale_quantity"]:value(${quantity})
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
function selectAttribute(productName, attributeName, attributeValue, attributeType='radio') {
|
||||
const ptalSelector = `
|
||||
${productSelector(productName)}
|
||||
td>div[name="ptal"]:has(label:contains("${attributeName}"))
|
||||
`;
|
||||
const content = `Select ${attributeValue} for ${productName} ${attributeName}`;
|
||||
switch (attributeType) {
|
||||
case 'color':
|
||||
return {
|
||||
content: content,
|
||||
trigger: `${ptalSelector} label[title="${attributeValue}"]`,
|
||||
run: 'click',
|
||||
};
|
||||
case 'multi':
|
||||
return {
|
||||
content: content,
|
||||
trigger: `${ptalSelector}:has(label:text(${attributeValue})) input[type="checkbox"]`,
|
||||
run: "click",
|
||||
};
|
||||
case 'pills':
|
||||
case 'radio':
|
||||
return {
|
||||
content: content,
|
||||
trigger: `${ptalSelector} span:contains("${attributeValue}")`,
|
||||
run: 'click',
|
||||
};
|
||||
case 'select':
|
||||
return {
|
||||
content: content,
|
||||
trigger: `${ptalSelector} select`,
|
||||
run: `selectByLabel ${attributeValue}`,
|
||||
};
|
||||
default:
|
||||
console.error("Unsupported attribute type");
|
||||
}
|
||||
}
|
||||
|
||||
function setCustomAttribute(productName, attributeName, customValue) {
|
||||
return {
|
||||
content: `Set ${customValue} as a custom attribute for ${productName} ${attributeName}`,
|
||||
trigger: `
|
||||
${productSelector(productName)}
|
||||
td>div[name="ptal"]:has(label:contains("${attributeName}"))
|
||||
input[type="text"]
|
||||
`,
|
||||
run: `edit ${customValue} && click .modal-body`,
|
||||
};
|
||||
}
|
||||
|
||||
function selectAndSetCustomAttribute(
|
||||
productName, attributeName, attributeValue, customValue, attributeType='radio'
|
||||
) {
|
||||
return [
|
||||
selectAttribute(productName, attributeName, attributeValue, attributeType),
|
||||
setCustomAttribute(productName, attributeName, customValue),
|
||||
];
|
||||
}
|
||||
|
||||
function assertPriceTotal(total) {
|
||||
return {
|
||||
content: `Assert that the total is ${total}`,
|
||||
trigger: `
|
||||
.o_sale_product_configurator_dialog .o_configurator_price_total:contains("${total}"),
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
function assertProductPrice(productName, price) {
|
||||
return {
|
||||
content: `Assert that ${productName} costs ${price}`,
|
||||
trigger: `
|
||||
${productSelector(productName)}
|
||||
td.o_sale_product_configurator_qty
|
||||
span:contains("${price}")
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
function assertOptionalProductPrice(productName, price) {
|
||||
return {
|
||||
content: `Assert that ${productName} costs ${price}`,
|
||||
trigger: `
|
||||
${optionalProductSelector(productName)}
|
||||
td.o_sale_product_configurator_price
|
||||
span:contains("${price}")
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
function assertProductPriceInfo(productName, priceInfo) {
|
||||
return {
|
||||
content: `Assert that the price info of ${productName} is ${priceInfo}`,
|
||||
trigger: `
|
||||
${productSelector(productName)}
|
||||
td.o_sale_product_configurator_qty
|
||||
div:contains("${priceInfo}")
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
function assertOptionalProductPriceInfo(productName, priceInfo) {
|
||||
return {
|
||||
content: `Assert that the price info of ${productName} is ${priceInfo}`,
|
||||
trigger: `
|
||||
${optionalProductSelector(productName)}
|
||||
td.o_sale_product_configurator_price
|
||||
div:contains("${priceInfo}")
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
function assertProductNameContains(productName) {
|
||||
return {
|
||||
content: `Assert that the product name contains ${productName}`,
|
||||
trigger: productSelector(productName),
|
||||
};
|
||||
}
|
||||
|
||||
function assertFooterButtonsDisabled() {
|
||||
return {
|
||||
content: "Assert that the footer buttons are disabled",
|
||||
trigger: '.o_sale_product_configurator_dialog footer.modal-footer button:disabled',
|
||||
};
|
||||
}
|
||||
|
||||
function saveConfigurator() {
|
||||
return [
|
||||
{
|
||||
trigger: '.o_sale_product_configurator_dialog button:contains(Confirm)',
|
||||
run: 'click',
|
||||
}, {
|
||||
content: "Wait until the modal is closed",
|
||||
trigger: 'body:not(:has(.o_sale_product_configurator_dialog))',
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
export default {
|
||||
productSelector,
|
||||
optionalProductSelector,
|
||||
optionalProductImageSrc,
|
||||
addOptionalProduct,
|
||||
removeOptionalProduct,
|
||||
increaseProductQuantity,
|
||||
decreaseProductQuantity,
|
||||
setProductQuantity,
|
||||
setProductUoM,
|
||||
assertProductQuantity,
|
||||
selectAttribute,
|
||||
setCustomAttribute,
|
||||
selectAndSetCustomAttribute,
|
||||
assertPriceTotal,
|
||||
assertProductPrice,
|
||||
assertOptionalProductPrice,
|
||||
assertProductPriceInfo,
|
||||
assertOptionalProductPriceInfo,
|
||||
assertProductNameContains,
|
||||
assertFooterButtonsDisabled,
|
||||
saveConfigurator,
|
||||
};
|
||||
|
|
@ -1,129 +1,111 @@
|
|||
odoo.define('sale.tour', function(require) {
|
||||
"use strict";
|
||||
import { markup } from "@odoo/owl";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { stepUtils } from "@web_tour/tour_utils";
|
||||
|
||||
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"));
|
||||
registry.category("web_tour.tours").add("sale_tour", {
|
||||
url: "/odoo",
|
||||
steps: () => [
|
||||
stepUtils.showAppsMenuItem(),
|
||||
{
|
||||
isActive: ["community"],
|
||||
trigger: ".o_app[data-menu-xmlid='sale.sale_menu_root']",
|
||||
content: _t("Let’s create a beautiful quotation in a few clicks ."),
|
||||
tooltipPosition: "right",
|
||||
run: "click",
|
||||
},
|
||||
}, {
|
||||
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");
|
||||
});
|
||||
{
|
||||
isActive: ["enterprise"],
|
||||
trigger: ".o_app[data-menu-xmlid='sale.sale_menu_root']",
|
||||
content: _t("Let’s create a beautiful quotation in a few clicks ."),
|
||||
tooltipPosition: "bottom",
|
||||
run: "click",
|
||||
},
|
||||
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",
|
||||
}]);
|
||||
|
||||
{
|
||||
trigger: ".o_sale_order",
|
||||
},
|
||||
{
|
||||
trigger: "button.o_list_button_add",
|
||||
content: _t("Build your first quotation right here!"),
|
||||
tooltipPosition: "bottom",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: ".o_sale_order",
|
||||
},
|
||||
{
|
||||
trigger: ".o_field_res_partner_many2one[name='partner_id'] input",
|
||||
content: _t("Search a customer name, or create one on the fly."),
|
||||
tooltipPosition: "right",
|
||||
run: "edit Agrolait",
|
||||
},
|
||||
{
|
||||
isActive: ["auto"],
|
||||
trigger: ".ui-menu-item > a:contains('Agrolait')",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: ".o_field_x2many_list_row_add > a",
|
||||
content: _t("Click here to add some products or services to your quotation."),
|
||||
tooltipPosition: "bottom",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: ".o_sale_order",
|
||||
},
|
||||
{
|
||||
trigger: `
|
||||
.o_field_widget[name='product_id'] input,
|
||||
.o_field_widget[name='product_template_id'] input
|
||||
`,
|
||||
content: _t("Select a product, or create a new one on the fly."),
|
||||
tooltipPosition: "right",
|
||||
run: "edit DESK0001",
|
||||
},
|
||||
{
|
||||
isActive: ["auto"],
|
||||
trigger: "a:contains('DESK0001')",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: ".oi-arrow-right", // Wait for product creation
|
||||
},
|
||||
{
|
||||
trigger: ".o_field_widget[name='price_unit'] input",
|
||||
content: _t("add the price of your product."),
|
||||
tooltipPosition: "right",
|
||||
run: "edit 10.0 && click body",
|
||||
},
|
||||
{
|
||||
isActive: ["auto"],
|
||||
trigger: ".o_field_cell[name='price_subtotal']:contains(10.00)",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
isActive: ["auto", "mobile"],
|
||||
trigger: ".o_statusbar_buttons button[name='action_quotation_send']",
|
||||
},
|
||||
...stepUtils.statusbarButtonsSteps(
|
||||
"Send",
|
||||
markup(_t("<b>Send the quote</b> to yourself and check what the customer will receive.")),
|
||||
),
|
||||
{
|
||||
isActive: ["body:not(:has(.modal-footer button.o_mail_send))"],
|
||||
trigger: ".modal-footer button[name='document_layout_save']",
|
||||
content: _t("let's continue"),
|
||||
tooltipPosition: "bottom",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: ".modal-footer button.o_mail_send",
|
||||
content: _t("Go ahead and send the quotation."),
|
||||
tooltipPosition: "bottom",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
isActive: ["auto"],
|
||||
trigger: "body:not(.modal-open)",
|
||||
run: "click",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
function createNewSalesOrder() {
|
||||
return [
|
||||
{
|
||||
trigger: '.o_sale_order',
|
||||
}, {
|
||||
content: "Create new order",
|
||||
trigger: '.o_list_button_add',
|
||||
run: 'click',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function selectCustomer(customerName) {
|
||||
return [
|
||||
{
|
||||
content: `Select customer ${customerName}`,
|
||||
trigger: '.o_field_widget[name=partner_id] input',
|
||||
run: `edit ${customerName}`,
|
||||
},
|
||||
{
|
||||
trigger: `ul.ui-autocomplete > li > a:contains("${customerName}")`,
|
||||
run: 'click',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function addProduct(productName, rowNumber=1) {
|
||||
return [
|
||||
{
|
||||
content: `Add product ${productName}`,
|
||||
trigger: 'a:contains("Add a product")',
|
||||
run: 'click',
|
||||
},
|
||||
{
|
||||
content: 'wait for new row to be created',
|
||||
trigger: `.o_data_row:nth-child(${rowNumber})`,
|
||||
},
|
||||
{
|
||||
trigger: 'div[name="product_template_id"] input', // TODO VFE o_selected_row
|
||||
run: `edit ${productName}`,
|
||||
},
|
||||
{
|
||||
trigger: `ul.ui-autocomplete a:contains("${productName}")`,
|
||||
run: 'click',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function clickSomewhereElse() {
|
||||
return [
|
||||
// TODO find a way for onchange to finish first ?
|
||||
{
|
||||
content: 'click somewhere else to exit cell focus',
|
||||
trigger: 'a[name=order_lines]', // click on notebook tab to stop the sol edit mode.
|
||||
run: 'click',
|
||||
},
|
||||
{
|
||||
content: 'check that the soline is not focused anymore',
|
||||
trigger: 'table.o_section_and_note_list_view:not(:has(.o_selected_row))',
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function checkSOLDescriptionContains(productName, text) {
|
||||
// TODO in the future: look directly into the textarea value
|
||||
let trigger = '.o_field_product_label_section_and_note_cell';
|
||||
if (productName) {
|
||||
trigger = `${trigger}:has(:contains("${productName}"), input:value("${productName}"))`;
|
||||
}
|
||||
if (text) {
|
||||
trigger = `${trigger} .o_input`;
|
||||
}
|
||||
return { trigger };
|
||||
}
|
||||
|
||||
function editLineMatching(productName, text) {
|
||||
let base_step = checkSOLDescriptionContains(productName, text);
|
||||
base_step['run'] = 'click';
|
||||
return base_step;
|
||||
}
|
||||
|
||||
function editConfiguration() {
|
||||
return {
|
||||
trigger: '[name=product_template_id] button.fa-pencil',
|
||||
run: 'click',
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
createNewSalesOrder,
|
||||
selectCustomer,
|
||||
addProduct,
|
||||
checkSOLDescriptionContains,
|
||||
editLineMatching,
|
||||
editConfiguration,
|
||||
clickSomewhereElse,
|
||||
};
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { registry } from '@web/core/registry';
|
||||
import { exprToBoolean } from "@web/core/utils/strings";
|
||||
import { DocumentFileUploader } from '@account/components/document_file_uploader/document_file_uploader';
|
||||
|
||||
const cogMenuRegistry = registry.category('cogMenu');
|
||||
|
||||
/**
|
||||
* 'Upload Request for Quotation' Menu
|
||||
*
|
||||
* This menu allows users to import requests for quotation.
|
||||
*/
|
||||
export class QuotationRequestUploader extends DocumentFileUploader {
|
||||
static template = 'upload_rfq_cog_menu.QuotationRequestUploader';
|
||||
|
||||
getResModel() {
|
||||
return 'sale.order';
|
||||
}
|
||||
}
|
||||
|
||||
export const quotationUploaderMenuItem = {
|
||||
Component: QuotationRequestUploader,
|
||||
groupNumber: 0,
|
||||
isDisplayed: ({ config, searchModel }) =>
|
||||
searchModel.resModel === 'sale.order'
|
||||
&& ['list', 'kanban'].includes(config.viewType)
|
||||
&& exprToBoolean(config.viewArch.getAttribute('create'), true),
|
||||
};
|
||||
|
||||
cogMenuRegistry.add('quotation-upload-menu', quotationUploaderMenuItem);
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="upload_rfq_cog_menu.QuotationRequestUploader">
|
||||
<FileUploader
|
||||
multiUpload="true"
|
||||
onUploaded.bind="onFileUploaded"
|
||||
onUploadComplete.bind="onUploadComplete"
|
||||
>
|
||||
<t t-set-slot="toggler">
|
||||
<span class="mx-3 cursor-pointer">Upload Request For Quotation</span>
|
||||
</t>
|
||||
</FileUploader>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -1,758 +0,0 @@
|
|||
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;
|
||||
|
||||
});
|
||||
|
|
@ -1,307 +0,0 @@
|
|||
.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;
|
||||
}
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
}
|
||||
|
||||
.sale_tbody input.js_quantity {
|
||||
min-width: 48px;
|
||||
width: 48px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
|
@ -40,3 +40,7 @@
|
|||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.o_sale_payment_terms p{
|
||||
margin: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
import { FileUploadKanbanController } from '@account/views/file_upload_kanban/file_upload_kanban_controller';
|
||||
|
||||
export class SaleFileUploadKanbanController extends FileUploadKanbanController {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.hideUploadButton = true;
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { _t } from '@web/core/l10n/translation';
|
||||
import { FileUploadKanbanRenderer } from '@account/views/file_upload_kanban/file_upload_kanban_renderer';
|
||||
|
||||
export class SaleFileUploadKanbanRenderer extends FileUploadKanbanRenderer {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.dropZoneTitle = _t("Import a request for quotation from a customer");
|
||||
this.dropZoneDescription = _t(`
|
||||
If your customer runs on Odoo 18 or higher, customer data and sales order lines
|
||||
will be automatically created. Any other pdf containing an attached
|
||||
UBL-RequestForQuotation file will work as well.
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { registry } from '@web/core/registry';
|
||||
import { fileUploadKanbanView } from '@account/views/file_upload_kanban/file_upload_kanban_view';
|
||||
import { SaleFileUploadKanbanController } from './sale_file_upload_kanban_controller';
|
||||
import { SaleFileUploadKanbanRenderer } from './sale_file_upload_kanban_renderer';
|
||||
|
||||
export const saleFileUploadKanbanView = {
|
||||
...fileUploadKanbanView,
|
||||
Controller: SaleFileUploadKanbanController,
|
||||
Renderer: SaleFileUploadKanbanRenderer,
|
||||
};
|
||||
|
||||
registry.category('views').add('sale_file_upload_kanban', saleFileUploadKanbanView);
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { FileUploadListController } from '@account/views/file_upload_list/file_upload_list_controller';
|
||||
|
||||
export class SaleFileUploadListController extends FileUploadListController {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.hideUploadButton = true;
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { _t } from '@web/core/l10n/translation';
|
||||
import { FileUploadListRenderer } from '@account/views/file_upload_list/file_upload_list_renderer';
|
||||
|
||||
export class SaleFileUploadListRenderer extends FileUploadListRenderer {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.dropZoneTitle = _t("Import a request for quotation from a customer");
|
||||
this.dropZoneDescription = _t(`
|
||||
If your customer runs on Odoo 18 or higher, customer data and sales order lines
|
||||
will be automatically created. Any other pdf containing an attached
|
||||
UBL-RequestForQuotation file will work as well.
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { registry } from '@web/core/registry';
|
||||
import { fileUploadListView } from '@account/views/file_upload_list/file_upload_list_view';
|
||||
import { SaleFileUploadListController } from './sale_file_upload_list_controller';
|
||||
import { SaleFileUploadListRenderer } from './sale_file_upload_list_renderer';
|
||||
|
||||
export const saleFileUploadListView = {
|
||||
...fileUploadListView,
|
||||
Controller: SaleFileUploadListController,
|
||||
Renderer: SaleFileUploadListRenderer,
|
||||
};
|
||||
|
||||
registry.category('views').add('sale_file_upload_list', saleFileUploadListView);
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { SaleFileUploadKanbanRenderer } from '../sale_file_upload_kanban/sale_file_upload_kanban_renderer';
|
||||
import { SaleActionHelper } from "../../js/sale_action_helper/sale_action_helper";
|
||||
|
||||
export class SaleKanbanRenderer extends SaleFileUploadKanbanRenderer {
|
||||
static template = "sale.SaleKanbanRenderer";
|
||||
static components = {
|
||||
...SaleFileUploadKanbanRenderer.components,
|
||||
SaleActionHelper,
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="sale.SaleKanbanRenderer" t-inherit="account.FileUploadKanbanRenderer" t-inherit-mode="primary">
|
||||
<t t-if="showNoContentHelper" position="replace">
|
||||
<t t-if="showNoContentHelper">
|
||||
<SaleActionHelper noContentHelp="props.noContentHelp"/>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { registry } from "@web/core/registry";
|
||||
import { saleFileUploadKanbanView } from "../sale_file_upload_kanban/sale_file_upload_kanban_view";
|
||||
import { SaleKanbanRenderer } from "./sale_onboarding_kanban_renderer";
|
||||
|
||||
export const saleKanbanView = {
|
||||
...saleFileUploadKanbanView,
|
||||
Renderer: SaleKanbanRenderer,
|
||||
};
|
||||
|
||||
registry.category("views").add("sale_onboarding_kanban", saleKanbanView);
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { SaleFileUploadListRenderer } from '../sale_file_upload_list/sale_file_upload_list_renderer';
|
||||
import { SaleActionHelper } from "../../js/sale_action_helper/sale_action_helper";
|
||||
|
||||
export class SaleListRenderer extends SaleFileUploadListRenderer {
|
||||
static template = "sale.SaleListRenderer";
|
||||
static components = {
|
||||
...SaleFileUploadListRenderer.components,
|
||||
SaleActionHelper,
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="sale.SaleListRenderer" t-inherit="account.FileUploadListRenderer" t-inherit-mode="primary">
|
||||
<ActionHelper position="replace">
|
||||
<t t-if="showNoContentHelper">
|
||||
<SaleActionHelper noContentHelp="props.noContentHelp"/>
|
||||
</t>
|
||||
</ActionHelper>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { registry } from "@web/core/registry";
|
||||
import { saleFileUploadListView } from '../sale_file_upload_list/sale_file_upload_list_view';
|
||||
import { SaleListRenderer } from "./sale_onboarding_list_renderer";
|
||||
|
||||
export const SaleListView = {
|
||||
...saleFileUploadListView,
|
||||
Renderer: SaleListRenderer,
|
||||
};
|
||||
|
||||
registry.category("views").add("sale_onboarding_list", SaleListView);
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
<?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>
|
||||
|
|
@ -2,26 +2,19 @@
|
|||
|
||||
<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>
|
||||
<t t-name="sale.SaleProductField" t-inherit="account.ProductLabelSectionAndNoteField" t-inherit-mode="primary">
|
||||
<xpath expr="//Many2One" position="after">
|
||||
<!-- Show configuration button for custom lines/products -->
|
||||
<button
|
||||
t-if="!props.readonly and hasConfigurationButton"
|
||||
type="button"
|
||||
class="btn btn-secondary fa fa-pencil px-2"
|
||||
tabindex="-1"
|
||||
draggable="false"
|
||||
t-att-aria-label="configurationButtonHelp"
|
||||
t-att-data-tooltip="configurationButtonHelp"
|
||||
t-on-click="onEditConfiguration"
|
||||
/>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="sale.SaleProgressBarField" owl="1">
|
||||
<t t-name="sale.SaleProgressBarField">
|
||||
<t t-if="state.isInvoicingTargetDefined">
|
||||
<t t-call="web.ProgressBarField"/>
|
||||
</t>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
import { models } from "@web/../tests/web_test_helpers";
|
||||
|
||||
|
||||
export class SaleOrder extends models.ServerModel {
|
||||
_name = "sale.order";
|
||||
|
||||
_records = [
|
||||
{
|
||||
id: 1,
|
||||
name: "first record",
|
||||
order_line: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { fields, models } from "@web/../tests/web_test_helpers";
|
||||
|
||||
|
||||
export class SaleOrderLine extends models.ServerModel {
|
||||
_name = "sale.order.line";
|
||||
|
||||
// Store the field for testing to be able to set the translation at the record creation.
|
||||
translated_product_name = fields.Char({store: true});
|
||||
}
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
import { defineMailModels } from '@mail/../tests/mail_test_helpers';
|
||||
import { expect, test } from '@odoo/hoot';
|
||||
import { queryAllTexts } from '@odoo/hoot-dom';
|
||||
import {
|
||||
clickCancel,
|
||||
contains,
|
||||
defineModels,
|
||||
fields,
|
||||
mountView,
|
||||
} from '@web/../tests/web_test_helpers';
|
||||
import { defineComboModels } from '@product/../tests/product_combo_test_helpers';
|
||||
import { saleModels } from './sale_test_helpers';
|
||||
|
||||
class SaleOrderLine extends saleModels.SaleOrderLine {
|
||||
_records = [
|
||||
{ id: 1, name: "Non Combo Line1", product_id: 1, sequence: 1 },
|
||||
{ id: 2, name: "Non Combo Line2", product_id: 2, sequence: 2 },
|
||||
{ id: 3, name: "Test Combo1", product_id: 5, sequence: 3, product_type: 'combo' },
|
||||
{ id: 4, name: "Combo1 Item 1", product_id: 3, combo_item_id: 3, linked_line_id: 3, sequence: 4 },
|
||||
{ id: 5, name: "Combo1 Item 2", product_id: 1, combo_item_id: 1, linked_line_id: 3, sequence: 5 },
|
||||
{ id: 6, name: "Test Combo2", product_id: 5, sequence: 6, product_type: 'combo' },
|
||||
{ id: 7, name: "Combo2 Item 1", product_id: 4, combo_item_id: 4, linked_line_id: 6, sequence: 7 },
|
||||
{ id: 8, name: "Combo2 Item 2", product_id: 2, combo_item_id: 2, linked_line_id: 6, sequence: 8 },
|
||||
{ id: 9, name: "Non Combo Line3", product_id: 1, sequence: 9 },
|
||||
];
|
||||
}
|
||||
|
||||
class SaleOrder extends saleModels.SaleOrder {
|
||||
_records = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Combo Sale order",
|
||||
order_line: SaleOrderLine._records.map(record => record.id),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
defineComboModels();
|
||||
defineModels({ SaleOrderLine, SaleOrder });
|
||||
defineMailModels();
|
||||
|
||||
test("test combo move up/down", async () => {
|
||||
await mountView({
|
||||
type: 'form',
|
||||
resModel: 'sale.order',
|
||||
resId: 1,
|
||||
arch: `
|
||||
<form>
|
||||
<field
|
||||
name="order_line"
|
||||
widget="sol_o2m"
|
||||
options="{'subsections': True}"
|
||||
>
|
||||
<list editable="bottom">
|
||||
<control>
|
||||
<create name="add_line_control" string="Add a line"/>
|
||||
<create name="add_section_control" string="Add a section" context="{'default_display_type': 'line_section'}"/>
|
||||
<create name="add_note_control" string="Add a note" context="{'default_display_type': 'line_note'}"/>
|
||||
</control>
|
||||
<field name="sequence" widget="handle" invisible="combo_item_id"/>
|
||||
<field name="name"/>
|
||||
<field name="display_type" column_invisible="1"/>
|
||||
<field name="linked_line_id" column_invisible="1"/>
|
||||
<field name="product_type" column_invisible="1"/>
|
||||
<field name="combo_item_id" column_invisible="1"/>
|
||||
</list>
|
||||
</field>
|
||||
</form>
|
||||
`,
|
||||
});
|
||||
|
||||
expect(queryAllTexts('.o_data_row')).toEqual([
|
||||
"Non Combo Line1",
|
||||
"Non Combo Line2",
|
||||
"Test Combo1",
|
||||
"Combo1 Item 1",
|
||||
"Combo1 Item 2",
|
||||
"Test Combo2",
|
||||
"Combo2 Item 1",
|
||||
"Combo2 Item 2",
|
||||
"Non Combo Line3",
|
||||
]);
|
||||
|
||||
await contains('.o_data_row:contains(Test Combo1) .o_list_section_options button').click();
|
||||
expect('.o-dropdown-item:contains(Move Up)').toHaveCount(1, {
|
||||
message: 'Move up option should be there for Test combo1 line having SO lines before and after'
|
||||
});
|
||||
expect('.o-dropdown-item:contains(Move Down)').toHaveCount(1, {
|
||||
message: 'Move down option should be there for Test combo1 line having SO lines before and after'
|
||||
});
|
||||
|
||||
await contains('.o-dropdown-item:contains(Move Up)').click();
|
||||
await contains('.o_data_row:contains(Test Combo1) .o_list_section_options button').click();
|
||||
await contains('.o-dropdown-item:contains(Move Up)').click();
|
||||
|
||||
await contains('.o_data_row:contains(Test Combo1) .o_list_section_options button').click();
|
||||
expect('.o-dropdown-item:contains(Move Up)').toHaveCount(0, {
|
||||
message: 'Move up option should be invisible for Test combo1 since there aren\'t any non combo SO lines before'
|
||||
});
|
||||
|
||||
await contains('.o_data_row:contains(Test Combo2) .o_list_section_options button').click();
|
||||
expect('.o-dropdown-item:contains(Move Up)').toHaveCount(1, {
|
||||
message: 'Move up option should be there for Test combo2 line having SO lines before and after'
|
||||
});
|
||||
expect('.o-dropdown-item:contains(Move Down)').toHaveCount(1, {
|
||||
message: 'Move down option should be there for Test combo2 line having SO lines before and after'
|
||||
});
|
||||
|
||||
await contains('.o-dropdown-item:contains(Move Down)').click();
|
||||
|
||||
await contains('.o_data_row:contains(Test Combo2) .o_list_section_options button').click();
|
||||
expect('.o-dropdown-item:contains(Move Down)').toHaveCount(0, {
|
||||
message: 'Move down option should be invisible for Test combo2 since there aren\'t any non combo SO lines after'
|
||||
});
|
||||
|
||||
expect(queryAllTexts('.o_data_row')).toEqual([
|
||||
"Test Combo1",
|
||||
"Combo1 Item 1",
|
||||
"Combo1 Item 2",
|
||||
"Non Combo Line1",
|
||||
"Non Combo Line2",
|
||||
"Non Combo Line3",
|
||||
"Test Combo2",
|
||||
"Combo2 Item 1",
|
||||
"Combo2 Item 2",
|
||||
], {
|
||||
message: 'Test combo1 line should be moved up two lines and Test combo2 line should be moved down one line'
|
||||
});
|
||||
|
||||
await clickCancel();
|
||||
|
||||
expect(queryAllTexts('.o_data_row')).toEqual([
|
||||
"Non Combo Line1",
|
||||
"Non Combo Line2",
|
||||
"Test Combo1",
|
||||
"Combo1 Item 1",
|
||||
"Combo1 Item 2",
|
||||
"Test Combo2",
|
||||
"Combo2 Item 1",
|
||||
"Combo2 Item 2",
|
||||
"Non Combo Line3",
|
||||
]);
|
||||
|
||||
await contains('.o_data_row:contains(Test Combo1) .o_list_section_options button').click();
|
||||
await contains('.o-dropdown-item:contains(Move Down)').click();
|
||||
|
||||
expect(queryAllTexts('.o_data_row')).toEqual([
|
||||
"Non Combo Line1",
|
||||
"Non Combo Line2",
|
||||
"Test Combo2",
|
||||
"Combo2 Item 1",
|
||||
"Combo2 Item 2",
|
||||
"Test Combo1",
|
||||
"Combo1 Item 1",
|
||||
"Combo1 Item 2",
|
||||
"Non Combo Line3",
|
||||
], {
|
||||
message: 'Test combo1 and Test combo2 should be swapped when moving Test combo1 down'
|
||||
});
|
||||
|
||||
await clickCancel();
|
||||
|
||||
await contains('.o_data_row:contains(Test Combo2) .o_list_section_options button').click();
|
||||
await contains('.o-dropdown-item:contains(Move Up)').click();
|
||||
|
||||
expect(queryAllTexts('.o_data_row')).toEqual([
|
||||
"Non Combo Line1",
|
||||
"Non Combo Line2",
|
||||
"Test Combo2",
|
||||
"Combo2 Item 1",
|
||||
"Combo2 Item 2",
|
||||
"Test Combo1",
|
||||
"Combo1 Item 1",
|
||||
"Combo1 Item 2",
|
||||
"Non Combo Line3",
|
||||
], {
|
||||
message: 'Test combo1 and Test combo2 should be swapped when moving Test combo2 up'
|
||||
});
|
||||
})
|
||||
|
||||
test("Test combo columns", async () => {
|
||||
// Set different defaults for checking aggregation of columns on combo line
|
||||
SaleOrderLine._fields.price_unit = fields.Float({ default: 3.00 });
|
||||
SaleOrderLine._fields.price_total = fields.Float({ default: 3.00 });
|
||||
SaleOrderLine._fields.product_uom_qty = fields.Float({ default: 3.00 });
|
||||
SaleOrderLine._fields.discount = fields.Integer({ default: 30 });
|
||||
await mountView({
|
||||
type: 'form',
|
||||
resModel: 'sale.order',
|
||||
resId: 1,
|
||||
arch: `
|
||||
<form>
|
||||
<field
|
||||
name="order_line"
|
||||
widget="sol_o2m"
|
||||
options="{'subsections': True}"
|
||||
aggregated_fields="price_total"
|
||||
>
|
||||
<list editable="bottom">
|
||||
<control>
|
||||
<create name="add_line_control" string="Add a line"/>
|
||||
<create name="add_section_control" string="Add a section" context="{'default_display_type': 'line_section'}"/>
|
||||
<create name="add_note_control" string="Add a note" context="{'default_display_type': 'line_note'}"/>
|
||||
</control>
|
||||
<field name="sequence" widget="handle" invisible="combo_item_id"/>
|
||||
<field name="name"/>
|
||||
<field name="price_unit"/>
|
||||
<field name="product_uom_qty"/>
|
||||
<field name="discount"/>
|
||||
<field name="price_total"/>
|
||||
<field name="display_type" column_invisible="1"/>
|
||||
<field name="linked_line_id" column_invisible="1"/>
|
||||
<field name="product_type" column_invisible="1"/>
|
||||
<field name="combo_item_id" column_invisible="1"/>
|
||||
</list>
|
||||
</field>
|
||||
</form>
|
||||
`,
|
||||
});
|
||||
|
||||
expect(queryAllTexts('.o_data_row .o_list_text')).toEqual([
|
||||
"Non Combo Line1",
|
||||
"Non Combo Line2",
|
||||
"Test Combo1",
|
||||
"Combo1 Item 1",
|
||||
"Combo1 Item 2",
|
||||
"Test Combo2",
|
||||
"Combo2 Item 1",
|
||||
"Combo2 Item 2",
|
||||
"Non Combo Line3",
|
||||
]);
|
||||
|
||||
expect(queryAllTexts('.o_data_row:contains(Test Combo1) > td').filter(Boolean)).toEqual([
|
||||
"Test Combo1", // name
|
||||
"3.00", // product_uom_qty
|
||||
"30", // discount
|
||||
"9.00", // price_total
|
||||
], {
|
||||
message: 'combo line should only have name, product_uom_qty, discount and `aggregated_fields` columns'
|
||||
});
|
||||
|
||||
expect(queryAllTexts('.o_data_row:contains(Non Combo Line1) > td').filter(Boolean)).toEqual([
|
||||
"Non Combo Line1", // name
|
||||
"3.00", // price_unit
|
||||
"3.00", // product_uom_qty
|
||||
"30", // discount
|
||||
"3.00", // price_total
|
||||
], {
|
||||
message: 'Non-combo line should have all columns'
|
||||
});
|
||||
})
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
import { expect, test } from "@odoo/hoot";
|
||||
import { press, runAllTimers } from "@odoo/hoot-dom";
|
||||
import {
|
||||
clickSave,
|
||||
Command,
|
||||
contains,
|
||||
defineModels,
|
||||
fields,
|
||||
makeMockServer,
|
||||
models,
|
||||
mountView,
|
||||
onRpc,
|
||||
serverState,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
import { saleModels } from "./sale_test_helpers";
|
||||
|
||||
class SaleOrderLine extends saleModels.SaleOrderLine {
|
||||
product_template_attribute_value_ids = fields.Many2many({
|
||||
string: "Product template attributes values",
|
||||
relation: "product.template.attribute.value",
|
||||
});
|
||||
}
|
||||
|
||||
class ProductTemplateAttributeValue extends models.Model {
|
||||
_name = "product.template.attribute.value";
|
||||
|
||||
name = fields.Char();
|
||||
}
|
||||
|
||||
defineModels({ ...saleModels, SaleOrderLine, ProductTemplateAttributeValue });
|
||||
|
||||
saleModels.SaleOrder._views.form = /* xml */ `
|
||||
<form>
|
||||
<field name="order_line" widget="sol_o2m" mode="list">
|
||||
<list editable="bottom">
|
||||
<field name="product_id" widget="sol_product_many2one"/>
|
||||
<field name="product_template_id" widget="sol_product_many2one"/>
|
||||
<field name="name" widget="sol_text"/>
|
||||
</list>
|
||||
</field>
|
||||
</form>
|
||||
`;
|
||||
|
||||
test.tags("desktop");
|
||||
test("pressing tab with incomplete text will create a product", async () => {
|
||||
onRpc(({ method }) => {
|
||||
expect.step(method);
|
||||
});
|
||||
await mountView({
|
||||
type: "form",
|
||||
resModel: "sale.order",
|
||||
arch: `
|
||||
<form>
|
||||
<sheet>
|
||||
<field name="order_line">
|
||||
<list editable="bottom">
|
||||
<field name="product_template_id" widget="sol_product_many2one"/>
|
||||
<field name="product_id" optional="hide"/>
|
||||
<field name="name" optional="show"/>
|
||||
</list>
|
||||
</field>
|
||||
</sheet>
|
||||
</form>`,
|
||||
});
|
||||
|
||||
// add a line and enter new product name
|
||||
await contains(".o_field_x2many_list .o_field_x2many_list_row_add a").click();
|
||||
await contains("[name='product_template_id'] input").edit("new product");
|
||||
await press("tab");
|
||||
await runAllTimers();
|
||||
expect.verifySteps([
|
||||
"get_views",
|
||||
"onchange",
|
||||
"onchange",
|
||||
"web_name_search",
|
||||
"name_create",
|
||||
"get_single_product_variant",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Hide product name if its not translated", async () => {
|
||||
const { env } = await makeMockServer();
|
||||
const product = env["product.product"][0];
|
||||
const soId = env["sale.order"].create({
|
||||
partner_id: serverState.partnerId,
|
||||
order_line: [
|
||||
Command.create({
|
||||
product_id: product.id,
|
||||
name: [product.name, "A description"].join("\n"),
|
||||
translated_product_name: "Produit de test",
|
||||
}),
|
||||
],
|
||||
});
|
||||
await mountView({
|
||||
type: "form",
|
||||
resModel: "sale.order",
|
||||
resId: soId,
|
||||
});
|
||||
|
||||
expect(".o_field_product_label_section_and_note_cell .o_input").toHaveText("A description");
|
||||
});
|
||||
|
||||
test("If translated product name already in the SOL name, should not hide the translated product name", async () => {
|
||||
const { env } = await makeMockServer();
|
||||
const translatedProductName = "Produit de test";
|
||||
const product = env["product.product"][0];
|
||||
const soId = env["sale.order"].create({
|
||||
partner_id: serverState.partnerId,
|
||||
order_line: [
|
||||
Command.create({
|
||||
product_id: product.id,
|
||||
name: [product.name, translatedProductName, "A description"].join("\n"),
|
||||
translated_product_name: translatedProductName,
|
||||
}),
|
||||
],
|
||||
});
|
||||
await mountView({
|
||||
type: "form",
|
||||
resModel: "sale.order",
|
||||
resId: soId,
|
||||
});
|
||||
|
||||
expect(".o_field_product_label_section_and_note_cell .o_input").toHaveText(
|
||||
[translatedProductName, "A description"].join("\n")
|
||||
);
|
||||
});
|
||||
|
||||
test("Editing the description shouldn't show the translated product name", async () => {
|
||||
const { env } = await makeMockServer();
|
||||
const translatedProductName = "Produit de test";
|
||||
const product = env["product.product"][0];
|
||||
const soId = env["sale.order"].create({
|
||||
partner_id: serverState.partnerId,
|
||||
order_line: [
|
||||
Command.create({
|
||||
product_id: product.id,
|
||||
name: [product.name, "something wrong"].join("\n"),
|
||||
translated_product_name: translatedProductName,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const [so] = env["sale.order"].browse(soId);
|
||||
const [sol] = env["sale.order.line"].browse(so.order_line);
|
||||
await mountView({
|
||||
type: "form",
|
||||
resModel: "sale.order",
|
||||
resId: soId,
|
||||
});
|
||||
await contains(".o_field_product_label_section_and_note_cell").click();
|
||||
await contains(".o_field_product_label_section_and_note_cell textarea").edit("A description");
|
||||
await clickSave();
|
||||
|
||||
expect(".o_field_product_label_section_and_note_cell .o_input").toHaveText("A description");
|
||||
expect(sol.name).toBe([translatedProductName, "A description"].join("\n"));
|
||||
});
|
||||
|
||||
test("No description should be shown if there does not exist one apart from the product name", async () => {
|
||||
const { env } = await makeMockServer();
|
||||
const translatedProductName = "Produit de test";
|
||||
const product = env["product.product"][0];
|
||||
const soId = env["sale.order"].create({
|
||||
partner_id: serverState.partnerId,
|
||||
order_line: [
|
||||
Command.create({
|
||||
product_id: product.id,
|
||||
name: product.name,
|
||||
translated_product_name: translatedProductName,
|
||||
}),
|
||||
],
|
||||
});
|
||||
await mountView({
|
||||
type: "form",
|
||||
resModel: "sale.order",
|
||||
resId: soId,
|
||||
});
|
||||
|
||||
expect(".o_field_product_label_section_and_note_cell .o_input").not.toBeVisible();
|
||||
});
|
||||
|
||||
test("No description should be shown if there does not exist one apart from the translated product name", async () => {
|
||||
const { env } = await makeMockServer();
|
||||
const translatedProductName = "Produit de test";
|
||||
const product = env["product.product"][0];
|
||||
const soId = env["sale.order"].create({
|
||||
partner_id: serverState.partnerId,
|
||||
order_line: [
|
||||
Command.create({
|
||||
product_id: product.id,
|
||||
name: translatedProductName,
|
||||
translated_product_name: translatedProductName,
|
||||
}),
|
||||
],
|
||||
});
|
||||
await mountView({
|
||||
type: "form",
|
||||
resModel: "sale.order",
|
||||
resId: soId,
|
||||
});
|
||||
|
||||
expect(".o_field_product_label_section_and_note_cell .o_input").not.toBeVisible();
|
||||
});
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import {
|
||||
getFixture,
|
||||
patchWithCleanup,
|
||||
addRow,
|
||||
editInput,
|
||||
triggerHotkey,
|
||||
nextTick
|
||||
} from "@web/../tests/helpers/utils";
|
||||
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
|
||||
let serverData;
|
||||
let target;
|
||||
|
||||
QUnit.module("Fields", (hooks) => {
|
||||
hooks.beforeEach(() => {
|
||||
target = getFixture();
|
||||
serverData = {
|
||||
models: {
|
||||
'sale.order': {
|
||||
fields: {
|
||||
display_name: { string: "Displayed name", type: "char" },
|
||||
order_line: {
|
||||
string: "order lines",
|
||||
type: "one2many",
|
||||
relation: "sale.order.line",
|
||||
relation_field: "order_id",
|
||||
},
|
||||
},
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
display_name: "first record",
|
||||
order_line: [],
|
||||
},
|
||||
],
|
||||
onchanges: {},
|
||||
},
|
||||
'sale.order.line': {
|
||||
fields: {
|
||||
product_template_id: {
|
||||
string: "Product",
|
||||
type: "many2one",
|
||||
relation: "product.template",
|
||||
},
|
||||
},
|
||||
records: [
|
||||
],
|
||||
},
|
||||
'product.template': {
|
||||
fields: {
|
||||
display_name: { string: "Partner Type", type: "char" },
|
||||
name: { string: "Partner Type", type: "char" },
|
||||
},
|
||||
records: [
|
||||
{ id: 12, display_name: "desk" },
|
||||
],
|
||||
methods: {
|
||||
get_single_product_variant() {
|
||||
return Promise.resolve({product_id: 12});
|
||||
}
|
||||
}
|
||||
},
|
||||
user: {
|
||||
fields: {
|
||||
name: { string: "Name", type: "char" },
|
||||
partner_ids: {
|
||||
string: "one2many partners field",
|
||||
type: "one2many",
|
||||
relation: "partner",
|
||||
relation_field: "user_id",
|
||||
},
|
||||
},
|
||||
records: [
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
setupViewRegistries();
|
||||
|
||||
patchWithCleanup(browser, {
|
||||
setTimeout: (fn) => fn(),
|
||||
});
|
||||
});
|
||||
|
||||
QUnit.module("Sale product field");
|
||||
|
||||
QUnit.test("pressing tab with incomplete text will create a product", async function (assert) {
|
||||
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "sale.order",
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<sheet>
|
||||
<field name="order_line">
|
||||
<tree editable="bottom" >
|
||||
<field name="product_template_id" widget="sol_product_many2one" />
|
||||
</tree>
|
||||
</field>
|
||||
</sheet>
|
||||
</form>`,
|
||||
mockRPC(route, args) {
|
||||
assert.step(args.method);
|
||||
}
|
||||
});
|
||||
|
||||
// add a line and enter new product name
|
||||
await addRow(target, ".o_field_x2many_list");
|
||||
await editInput(target, "[name='product_template_id'] input", "new product");
|
||||
await triggerHotkey("tab");
|
||||
await nextTick();
|
||||
assert.verifySteps([
|
||||
"get_views",
|
||||
"onchange",
|
||||
"onchange",
|
||||
"name_search",
|
||||
"name_create",
|
||||
"get_single_product_variant",
|
||||
]);
|
||||
});
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { mailModels } from "@mail/../tests/mail_test_helpers";
|
||||
import { defineModels } from "@web/../tests/web_test_helpers";
|
||||
import { productModels } from "@product/../tests/product_test_helpers";
|
||||
import { SaleOrder } from "./mock_server/mock_models/sale_order";
|
||||
import { SaleOrderLine } from "./mock_server/mock_models/sale_order_line";
|
||||
|
||||
|
||||
export const saleModels = {
|
||||
...mailModels,
|
||||
...productModels,
|
||||
SaleOrder,
|
||||
SaleOrderLine,
|
||||
};
|
||||
|
||||
export function defineSaleModels() {
|
||||
defineModels(saleModels);
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { defineMailModels } from "@mail/../tests/mail_test_helpers";
|
||||
import { expect, test } from "@odoo/hoot";
|
||||
import {
|
||||
contains,
|
||||
defineModels,
|
||||
fields,
|
||||
mockService,
|
||||
models,
|
||||
mountView,
|
||||
onRpc,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
|
||||
class CRMTeam extends models.Model {
|
||||
_name = "crm.team";
|
||||
|
||||
foo = fields.Char();
|
||||
invoiced = fields.Integer();
|
||||
invoiced_target = fields.Integer();
|
||||
|
||||
_records = [{ id: 1, foo: "yop", invoiced: 0, invoiced_target: 0 }];
|
||||
}
|
||||
|
||||
defineModels([CRMTeam]);
|
||||
defineMailModels();
|
||||
|
||||
test("edit progressbar target", async () => {
|
||||
mockService("action", {
|
||||
doAction(action) {
|
||||
expect(action).toEqual(
|
||||
{
|
||||
res_model: "crm.team",
|
||||
target: "current",
|
||||
type: "ir.actions.act_window",
|
||||
method: "get_formview_action",
|
||||
},
|
||||
{ message: "should trigger do_action with the correct args" }
|
||||
);
|
||||
expect.step("doAction");
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
onRpc("crm.team", "get_formview_action", ({ method, model }) => ({
|
||||
method,
|
||||
res_model: model,
|
||||
target: "current",
|
||||
type: "ir.actions.act_window",
|
||||
}));
|
||||
|
||||
await mountView({
|
||||
type: "kanban",
|
||||
resModel: "crm.team",
|
||||
arch: /* xml */ `
|
||||
<kanban>
|
||||
<field name="invoiced_target"/>
|
||||
<templates>
|
||||
<div t-name="card">
|
||||
<field name="invoiced" widget="sales_team_progressbar" options="{'current_value': 'invoiced', 'max_value': 'invoiced_target', 'editable': true, 'edit_max_value': true}"/>
|
||||
</div>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
resId: 1,
|
||||
});
|
||||
|
||||
expect(
|
||||
".o_field_sales_team_progressbar:contains(Click to define an invoicing target)"
|
||||
).toHaveCount(1);
|
||||
expect(".o_progressbar input").toHaveCount(0);
|
||||
|
||||
await contains(".sale_progressbar_form_link").click(); // should trigger a do_action
|
||||
expect.verifySteps(["doAction"]);
|
||||
});
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { click, getFixture } from "@web/../tests/helpers/utils";
|
||||
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
const serviceRegistry = registry.category("services");
|
||||
|
||||
let serverData;
|
||||
let target;
|
||||
|
||||
QUnit.module("Sales Team Dashboard", {
|
||||
beforeEach() {
|
||||
target = getFixture();
|
||||
serverData = {
|
||||
models: {
|
||||
"crm.team": {
|
||||
fields: {
|
||||
foo: { string: "Foo", type: "char" },
|
||||
invoiced: { string: "Invoiced", type: "integer" },
|
||||
invoiced_target: { string: "Invoiced_target", type: "integer" },
|
||||
},
|
||||
records: [{ id: 1, foo: "yop", invoiced: 0, invoiced_target: 0 }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
setupViewRegistries();
|
||||
},
|
||||
});
|
||||
|
||||
QUnit.test("edit target with several o_kanban_primary_bottom divs", async (assert) => {
|
||||
assert.expect(4);
|
||||
|
||||
const fakeActionService = {
|
||||
start: () => ({
|
||||
async doAction(action) {
|
||||
assert.deepEqual(
|
||||
action,
|
||||
{
|
||||
res_model: "crm.team",
|
||||
target: "current",
|
||||
type: "ir.actions.act_window",
|
||||
method: "get_formview_action",
|
||||
},
|
||||
"should trigger do_action with the correct args"
|
||||
);
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
};
|
||||
serviceRegistry.add("action", fakeActionService, { force: true });
|
||||
|
||||
await makeView({
|
||||
serverData,
|
||||
type: "kanban",
|
||||
resModel: "crm.team",
|
||||
arch: /* xml */`
|
||||
<kanban>
|
||||
<field name="invoiced_target"/>
|
||||
<templates>
|
||||
<div t-name="kanban-box" class="container o_kanban_card_content">
|
||||
<field name="invoiced" widget="sales_team_progressbar" options="{'current_value': 'invoiced', 'max_value': 'invoiced_target', 'editable': true, 'edit_max_value': true}"/>
|
||||
<div class="col-12 o_kanban_primary_bottom"/>
|
||||
<div class="col-12 o_kanban_primary_bottom bottom_block"/>
|
||||
</div>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
resId: 1,
|
||||
async mockRPC(route, { method, model }) {
|
||||
if (route === "/web/dataset/call_kw/crm.team/get_formview_action") {
|
||||
return {
|
||||
method,
|
||||
res_model: model,
|
||||
target: "current",
|
||||
type: "ir.actions.act_window",
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
assert.containsOnce(
|
||||
target,
|
||||
".o_field_sales_team_progressbar:contains(Click to define an invoicing target)"
|
||||
);
|
||||
assert.containsN(target, ".o_kanban_primary_bottom", 2);
|
||||
assert.containsNone(target, ".o_progressbar input");
|
||||
|
||||
await click(target, ".sale_progressbar_form_link"); // should trigger a do_action
|
||||
});
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { registry } from "@web/core/registry";
|
||||
|
||||
registry.category("web_tour.tours").add("mail_attachment_removal_tour", {
|
||||
steps: () => [
|
||||
|
||||
{
|
||||
content: "click on send by email",
|
||||
trigger: ".o_statusbar_buttons > button[name='action_quotation_send']",
|
||||
run: "click"
|
||||
},
|
||||
{
|
||||
content: "save a new layout",
|
||||
trigger: ".o_technical_modal button[name='document_layout_save']",
|
||||
run: "click"
|
||||
},
|
||||
{
|
||||
content: "delete attachment",
|
||||
trigger: ".o_field_widget[name='attachment_ids'] li > button .fa-times",
|
||||
run: "click"
|
||||
},
|
||||
{
|
||||
content: "send the email",
|
||||
trigger: ".o_mail_send",
|
||||
run: "click"
|
||||
},
|
||||
{
|
||||
content: "confirm quotation",
|
||||
trigger: "button[name='action_confirm']",
|
||||
run: "click"
|
||||
}
|
||||
]
|
||||
})
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import { registry } from "@web/core/registry";
|
||||
import { stepUtils } from "@web_tour/tour_utils";
|
||||
|
||||
const openProductAttribute = (product_attribute) => [
|
||||
...stepUtils.goToAppSteps("sale.sale_menu_root", "Go to the Sales App"),
|
||||
{
|
||||
content: 'Open configuration menu',
|
||||
trigger: '.o-dropdown[data-menu-xmlid="sale.menu_sale_config"]',
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
content: 'Navigate to product attribute list view',
|
||||
trigger: '.o-dropdown-item[data-menu-xmlid="sale.menu_product_attribute_action"]',
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
content: `Navigate to ${product_attribute}`,
|
||||
trigger: `.o_data_cell[data-tooltip=${product_attribute}]`,
|
||||
run: "click",
|
||||
},
|
||||
];
|
||||
const deletePAV = (product_attribute_value, message) => [
|
||||
{
|
||||
content: 'Click delete button',
|
||||
trigger: `.o_data_cell[data-tooltip=${product_attribute_value}] ~ .o_list_record_remove`,
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
content: 'Check correct message in modal',
|
||||
trigger: message || '.modal-title:contains("Bye-bye, record!")',
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
content: 'Close modal',
|
||||
trigger: '.btn-close',
|
||||
run: "click",
|
||||
}
|
||||
]
|
||||
|
||||
// This tour relies on data created on the Python test.
|
||||
registry.category("web_tour.tours").add('delete_product_attribute_value_tour', {
|
||||
url: '/odoo',
|
||||
steps: () => [
|
||||
...openProductAttribute("PA"),
|
||||
// Test error message on a used attribute value
|
||||
...deletePAV("pa_value_1", ".text-prewrap:contains('pa_value_1')"),
|
||||
// Test deletability of a used attribute value on archived product
|
||||
...deletePAV("pa_value_2"),
|
||||
// Test deletability of a removed attribute value on product
|
||||
...deletePAV("pa_value_3"),
|
||||
{
|
||||
content: 'Check test finished',
|
||||
trigger: 'a:contains("Attributes")',
|
||||
}
|
||||
]
|
||||
});
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import { addSectionFromProductCatalog } from "@account/js/tours/tour_utils";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
registry.category("web_tour.tours").add('sale_catalog', {
|
||||
steps: () => [
|
||||
{
|
||||
content: "Create a new SO",
|
||||
trigger: '.o_list_button_add',
|
||||
run: 'click',
|
||||
},
|
||||
{
|
||||
content: "Select the customer field",
|
||||
trigger: ".o_field_res_partner_many2one input.o_input",
|
||||
run: 'click',
|
||||
},
|
||||
{
|
||||
content: "Wait for the field to be active",
|
||||
trigger: ".o_field_res_partner_many2one input[aria-expanded=true]",
|
||||
},
|
||||
{
|
||||
content: "Select a customer from the dropdown",
|
||||
trigger: ".o_field_res_partner_many2one .dropdown-item:not([id$='_loading']):first",
|
||||
run: 'click',
|
||||
},
|
||||
{
|
||||
content: "Open product catalog",
|
||||
trigger: 'button[name="action_add_from_catalog"]',
|
||||
run: 'click',
|
||||
},
|
||||
{
|
||||
content: "Type 'Restricted' into the search bar",
|
||||
trigger: 'input.o_searchview_input',
|
||||
run: "edit Restricted",
|
||||
},
|
||||
{
|
||||
content: "Search for the product",
|
||||
trigger: 'input.o_searchview_input',
|
||||
run: "press Enter",
|
||||
},
|
||||
{
|
||||
content: "Wait for catalog rendering",
|
||||
trigger: '.o_kanban_record:contains("Restricted Product")',
|
||||
},
|
||||
{
|
||||
content: "Wait for filtering",
|
||||
trigger: '.o_kanban_renderer:not(:has(.o_kanban_record:contains("AAA Product")))',
|
||||
},
|
||||
{
|
||||
content: "Add the product to the SO",
|
||||
trigger: '.o_kanban_record:contains("Restricted Product") .fa-shopping-cart',
|
||||
run: 'click',
|
||||
},
|
||||
{
|
||||
content: "Wait for product to be added",
|
||||
trigger: '.o_kanban_record:contains("Restricted Product"):not(:has(.fa-shopping-cart))',
|
||||
},
|
||||
{
|
||||
content: "Input a custom quantity",
|
||||
trigger: '.o_kanban_record:contains("Restricted Product") .o_input',
|
||||
run: "edit 6",
|
||||
},
|
||||
{
|
||||
content: "Increase the quantity",
|
||||
trigger: '.o_kanban_record:contains("Restricted Product") .fa-plus',
|
||||
run: 'click',
|
||||
},
|
||||
{
|
||||
content: "Close the catalog",
|
||||
trigger: '.o-kanban-button-back',
|
||||
run: 'click',
|
||||
},
|
||||
]
|
||||
});
|
||||
|
||||
registry.category("web_tour.tours").add('test_add_section_from_product_catalog_on_sale_order', {
|
||||
steps: () => [
|
||||
{
|
||||
content: "Create a new SO",
|
||||
trigger: '.o_list_button_add',
|
||||
run: 'click',
|
||||
},
|
||||
{
|
||||
content: "Select the customer field",
|
||||
trigger: '.o_field_res_partner_many2one input.o_input',
|
||||
run: 'click',
|
||||
},
|
||||
{
|
||||
content: "Wait for the field to be active",
|
||||
trigger: '.o_field_res_partner_many2one input[aria-expanded=true]',
|
||||
},
|
||||
{
|
||||
content: "Select a customer from the dropdown",
|
||||
trigger: '.o_field_res_partner_many2one .dropdown-item:not([id$="_loading"]):first',
|
||||
run: 'click',
|
||||
},
|
||||
...addSectionFromProductCatalog(),
|
||||
]
|
||||
});
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
import { registry } from '@web/core/registry';
|
||||
import { stepUtils } from '@web_tour/tour_utils';
|
||||
import comboConfiguratorTourUtils from '@sale/js/tours/combo_configurator_tour_utils';
|
||||
import productConfiguratorTourUtils from '@sale/js/tours/product_configurator_tour_utils';
|
||||
import tourUtils from '@sale/js/tours/tour_utils';
|
||||
|
||||
registry
|
||||
.category('web_tour.tours')
|
||||
.add('sale_combo_configurator', {
|
||||
url: '/odoo',
|
||||
steps: () => [
|
||||
...stepUtils.goToAppSteps('sale.sale_menu_root', "Open the sales app"),
|
||||
...tourUtils.createNewSalesOrder(),
|
||||
...tourUtils.selectCustomer("Test Partner"),
|
||||
...tourUtils.addProduct("Combo product"),
|
||||
// Assert that the combo configurator has the correct data.
|
||||
comboConfiguratorTourUtils.assertComboCount(2),
|
||||
comboConfiguratorTourUtils.assertComboItemCount("Combo A", 2),
|
||||
comboConfiguratorTourUtils.assertComboItemCount("Combo B", 2),
|
||||
// Assert that price changes when the quantity is updated.
|
||||
comboConfiguratorTourUtils.assertQuantity(1),
|
||||
comboConfiguratorTourUtils.assertPrice('25.00'),
|
||||
comboConfiguratorTourUtils.increaseQuantity(),
|
||||
comboConfiguratorTourUtils.assertQuantity(2),
|
||||
comboConfiguratorTourUtils.assertPrice('50.00'),
|
||||
comboConfiguratorTourUtils.decreaseQuantity(),
|
||||
comboConfiguratorTourUtils.assertQuantity(1),
|
||||
comboConfiguratorTourUtils.assertPrice('25.00'),
|
||||
comboConfiguratorTourUtils.setQuantity(3),
|
||||
comboConfiguratorTourUtils.assertQuantity(3),
|
||||
comboConfiguratorTourUtils.assertPrice('75.00'),
|
||||
// Assert that the combo configurator can only be saved after selecting an item for each
|
||||
// combo.
|
||||
comboConfiguratorTourUtils.assertConfirmButtonDisabled(),
|
||||
comboConfiguratorTourUtils.selectComboItem("Product A2"),
|
||||
comboConfiguratorTourUtils.selectComboItem("Product B2"),
|
||||
comboConfiguratorTourUtils.assertConfirmButtonEnabled(),
|
||||
// Assert that the product configurator is opened when a product with configurable
|
||||
// `no_variant` PTALs is selected.
|
||||
comboConfiguratorTourUtils.selectComboItem("Product A1"),
|
||||
productConfiguratorTourUtils.selectAttribute("Product A1", "No variant attribute", "A"),
|
||||
...productConfiguratorTourUtils.saveConfigurator(),
|
||||
// Assert that the extra price of a combo item is applied correctly.
|
||||
comboConfiguratorTourUtils.assertPrice('90.00'),
|
||||
// Assert that the extra price of a `no_variant` PTAV is applied correctly.
|
||||
comboConfiguratorTourUtils.selectComboItem("Product A1"),
|
||||
...productConfiguratorTourUtils.selectAndSetCustomAttribute(
|
||||
"Product A1", "No variant attribute", "B", "Some custom value"
|
||||
),
|
||||
...productConfiguratorTourUtils.saveConfigurator(),
|
||||
comboConfiguratorTourUtils.assertPrice('93.00'),
|
||||
// Assert that the order's content is correct.
|
||||
...comboConfiguratorTourUtils.saveConfigurator(),
|
||||
tourUtils.checkSOLDescriptionContains("Combo product x 3"),
|
||||
tourUtils.checkSOLDescriptionContains(
|
||||
"Product A1", "No variant attribute: B: Some custom value"
|
||||
),
|
||||
tourUtils.checkSOLDescriptionContains("Product B2"),
|
||||
{
|
||||
content: "Verify the combo item quantities",
|
||||
trigger: 'td[name="product_uom_qty"]:contains(3.00)',
|
||||
},
|
||||
{
|
||||
content: "Verify the first combo item's unit price",
|
||||
trigger: 'td[name="price_unit"]:contains(18.50)',
|
||||
},
|
||||
{
|
||||
content: "Verify the second combo item's unit price",
|
||||
trigger: 'td[name="price_unit"]:contains(12.50)',
|
||||
},
|
||||
{
|
||||
content: "Verify the order's total price",
|
||||
trigger: 'div.oe_subtotal_footer:contains(93.00)',
|
||||
},
|
||||
// Assert that the combo configurator is opened with the previous selection when the
|
||||
// combo is edited.
|
||||
tourUtils.editLineMatching("Combo product x 3"),
|
||||
tourUtils.editConfiguration(),
|
||||
comboConfiguratorTourUtils.setQuantity(2),
|
||||
comboConfiguratorTourUtils.assertComboItemSelected("Product A1"),
|
||||
comboConfiguratorTourUtils.assertComboItemSelected("Product B2"),
|
||||
comboConfiguratorTourUtils.selectComboItem("Product A2"),
|
||||
// Assert that the order's content has been updated.
|
||||
...comboConfiguratorTourUtils.saveConfigurator(),
|
||||
tourUtils.checkSOLDescriptionContains("Combo product x 2"),
|
||||
tourUtils.checkSOLDescriptionContains("Product A2"),
|
||||
tourUtils.checkSOLDescriptionContains("Product B2"),
|
||||
{
|
||||
content: "Verify the combo item quantities",
|
||||
trigger: 'td[name="product_uom_qty"]:contains(2.00)',
|
||||
},
|
||||
{
|
||||
content: "Verify the first combo item's unit price",
|
||||
trigger: 'td[name="price_unit"]:contains(12.50)',
|
||||
},
|
||||
{
|
||||
content: "Verify the second combo item's unit price",
|
||||
trigger: 'td[name="price_unit"]:contains(12.50)',
|
||||
},
|
||||
{
|
||||
content: "Verify the order's total price",
|
||||
trigger: 'div.oe_subtotal_footer:contains(50.00)',
|
||||
},
|
||||
// Don't end the tour with a form in edition mode.
|
||||
...stepUtils.saveForm(),
|
||||
],
|
||||
});
|
||||
|
||||
registry
|
||||
.category('web_tour.tours')
|
||||
.add('sale_combo_configurator_with_optional_products', {
|
||||
url: '/odoo',
|
||||
steps: () => [
|
||||
...stepUtils.goToAppSteps('sale.sale_menu_root', "Open the sales app"),
|
||||
...tourUtils.createNewSalesOrder(),
|
||||
...tourUtils.selectCustomer("Test Partner"),
|
||||
...tourUtils.addProduct("Combo product"),
|
||||
comboConfiguratorTourUtils.selectComboItem("Product B2"),
|
||||
...comboConfiguratorTourUtils.saveConfigurator(),
|
||||
productConfiguratorTourUtils.addOptionalProduct("Optional product"),
|
||||
{
|
||||
content: "verify that we cannot reduce main product quantity",
|
||||
trigger: ':not(button[name="sale_quantity_button_minus"])',
|
||||
},
|
||||
{
|
||||
content: "verify that we cannot increase main product quantity",
|
||||
trigger: ':not(button[name="sale_quantity_button_plus"])',
|
||||
},
|
||||
...productConfiguratorTourUtils.saveConfigurator(),
|
||||
tourUtils.checkSOLDescriptionContains("Combo product"),
|
||||
tourUtils.checkSOLDescriptionContains("Product B2"),
|
||||
tourUtils.checkSOLDescriptionContains("Optional product"),
|
||||
// Don't end the tour with a form in edition mode.
|
||||
...stepUtils.saveForm(),
|
||||
],
|
||||
});
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { registry } from '@web/core/registry';
|
||||
import { stepUtils } from '@web_tour/tour_utils';
|
||||
import comboConfiguratorTourUtils from '@sale/js/tours/combo_configurator_tour_utils';
|
||||
import productConfiguratorTourUtils from '@sale/js/tours/product_configurator_tour_utils';
|
||||
import tourUtils from '@sale/js/tours/tour_utils';
|
||||
|
||||
registry
|
||||
.category('web_tour.tours')
|
||||
.add('sale_combo_configurator_preconfigure_unconfigurable_ptals', {
|
||||
url: '/odoo',
|
||||
steps: () => [
|
||||
...stepUtils.goToAppSteps('sale.sale_menu_root', "Open the sales app"),
|
||||
...tourUtils.createNewSalesOrder(),
|
||||
...tourUtils.selectCustomer("Test Partner"),
|
||||
...tourUtils.addProduct("Combo product"),
|
||||
{
|
||||
content: "Verify that unconfigurable ptals are preconfigured",
|
||||
trigger: `${comboConfiguratorTourUtils.comboItemSelector("Test product")}:contains("Attribute A: A")`,
|
||||
},
|
||||
{
|
||||
content: "Verify that configurable ptals aren't preconfigured",
|
||||
trigger: `${comboConfiguratorTourUtils.comboItemSelector("Test product")}:not(:contains("Attribute B: B"))`,
|
||||
},
|
||||
comboConfiguratorTourUtils.selectComboItem("Test product"),
|
||||
productConfiguratorTourUtils.selectAttribute(
|
||||
"Test product", "Attribute B", "B", 'multi'
|
||||
),
|
||||
...productConfiguratorTourUtils.saveConfigurator(),
|
||||
{
|
||||
content: "Verify that configurable ptals are now configured",
|
||||
trigger: `${comboConfiguratorTourUtils.comboItemSelector("Test product")}:contains("Attribute B: B")`,
|
||||
},
|
||||
...comboConfiguratorTourUtils.saveConfigurator(),
|
||||
// Don't end the tour with a form in edition mode.
|
||||
...stepUtils.saveForm(),
|
||||
],
|
||||
});
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { registry } from '@web/core/registry';
|
||||
import { stepUtils } from '@web_tour/tour_utils';
|
||||
import comboConfiguratorTourUtils from '@sale/js/tours/combo_configurator_tour_utils';
|
||||
import productConfiguratorTourUtils from '@sale/js/tours/product_configurator_tour_utils';
|
||||
import tourUtils from '@sale/js/tours/tour_utils';
|
||||
|
||||
registry
|
||||
.category('web_tour.tours')
|
||||
.add('sale_combo_configurator_preselect_single_unconfigurable_items', {
|
||||
url: '/odoo',
|
||||
steps: () => [
|
||||
...stepUtils.goToAppSteps('sale.sale_menu_root', "Open the sales app"),
|
||||
...tourUtils.createNewSalesOrder(),
|
||||
...tourUtils.selectCustomer("Test Partner"),
|
||||
...tourUtils.addProduct("Combo product"),
|
||||
// Assert that only single unconfigurable items are preselected.
|
||||
comboConfiguratorTourUtils.assertPreselectedComboItemCount(2),
|
||||
comboConfiguratorTourUtils.assertComboItemPreselected("Product A"),
|
||||
comboConfiguratorTourUtils.assertComboItemPreselected("Product C"),
|
||||
comboConfiguratorTourUtils.assertConfirmButtonDisabled(),
|
||||
// Configure the remaining combos.
|
||||
comboConfiguratorTourUtils.selectComboItem("Product B"),
|
||||
productConfiguratorTourUtils.selectAttribute("Product B", "Attribute B", "B", 'multi'),
|
||||
...productConfiguratorTourUtils.saveConfigurator(),
|
||||
comboConfiguratorTourUtils.selectComboItem("Product D"),
|
||||
productConfiguratorTourUtils.setCustomAttribute(
|
||||
"Product D", "Attribute D", "Test D"
|
||||
),
|
||||
...productConfiguratorTourUtils.saveConfigurator(),
|
||||
comboConfiguratorTourUtils.selectComboItem("Product E1"),
|
||||
comboConfiguratorTourUtils.assertConfirmButtonEnabled(),
|
||||
...comboConfiguratorTourUtils.saveConfigurator(),
|
||||
// Don't end the tour with a form in edition mode.
|
||||
...stepUtils.saveForm(),
|
||||
],
|
||||
});
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { registry } from '@web/core/registry';
|
||||
import { stepUtils } from '@web_tour/tour_utils';
|
||||
import productConfiguratorTourUtils from '@sale/js/tours/product_configurator_tour_utils';
|
||||
import tourUtils from '@sale/js/tours/tour_utils';
|
||||
|
||||
registry.category('web_tour.tours').add('sale_order_keep_uom_on_variant_wizard_quantity_change', {
|
||||
steps: () => [
|
||||
tourUtils.editLineMatching("Sofa"),
|
||||
tourUtils.editConfiguration(),
|
||||
productConfiguratorTourUtils.increaseProductQuantity("Sofa"),
|
||||
...productConfiguratorTourUtils.saveConfigurator(),
|
||||
...stepUtils.saveForm(),
|
||||
],
|
||||
});
|
||||
|
|
@ -1,51 +1,99 @@
|
|||
odoo.define('sale.tour_sale_signature', function (require) {
|
||||
'use strict';
|
||||
|
||||
var tour = require('web_tour.tour');
|
||||
import { registry } from "@web/core/registry";
|
||||
import { redirect } from "@web/core/utils/urls";
|
||||
|
||||
// This tour relies on data created on the Python test.
|
||||
tour.register('sale_signature', {
|
||||
test: true,
|
||||
registry.category("web_tour.tours").add('sale_signature', {
|
||||
url: '/my/quotes',
|
||||
},
|
||||
[
|
||||
steps: () => [
|
||||
{
|
||||
content: "open the test SO",
|
||||
trigger: 'a:containsExact("test SO")',
|
||||
trigger: 'a:text(test SO)',
|
||||
run: "click",
|
||||
expectUnloadPage: true,
|
||||
},
|
||||
{
|
||||
content: "click sign",
|
||||
trigger: 'a:contains("Sign")',
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
content: "clear the signature name",
|
||||
trigger: '.modal .o_web_sign_name_and_signature input',
|
||||
run: "clear",
|
||||
},
|
||||
{
|
||||
content: "check submit is disabled when name is empty",
|
||||
trigger: '.modal .o_portal_sign_submit:disabled',
|
||||
},
|
||||
{
|
||||
content: "reset signature name",
|
||||
trigger: '.modal .o_web_sign_name_and_signature input',
|
||||
run: "fill Joel Willis",
|
||||
},
|
||||
{
|
||||
content: "check submit is enabled",
|
||||
trigger: '.o_portal_sign_submit:enabled',
|
||||
run: function () {},
|
||||
},
|
||||
{
|
||||
trigger: ".modal .o_web_sign_name_and_signature input:value(Joel Willis)"
|
||||
},
|
||||
{
|
||||
trigger: ".modal canvas.o_web_sign_signature",
|
||||
run: "canvasNotEmpty",
|
||||
},
|
||||
{
|
||||
content: "click select style",
|
||||
trigger: '.o_web_sign_auto_select_style a',
|
||||
trigger: '.modal .o_web_sign_auto_select_style button',
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
content: "click style 4",
|
||||
trigger: '.o_web_sign_auto_font_selection a:eq(3)',
|
||||
trigger: ".o-dropdown-item:eq(3)",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
content: "click submit",
|
||||
trigger: '.o_portal_sign_submit:enabled',
|
||||
trigger: '.modal .o_portal_sign_submit:enabled',
|
||||
run: "click",
|
||||
expectUnloadPage: true,
|
||||
},
|
||||
{
|
||||
content: "check it's confirmed",
|
||||
trigger: '#quote_content:contains("Thank You")',
|
||||
run: "click",
|
||||
}, {
|
||||
trigger: '#quote_content',
|
||||
run: function () {
|
||||
window.location.href = window.location.origin + '/web';
|
||||
redirect("/odoo");
|
||||
}, // Avoid race condition at the end of the tour by returning to the home page.
|
||||
expectUnloadPage: true,
|
||||
},
|
||||
{
|
||||
trigger: 'nav',
|
||||
run: function() {},
|
||||
}
|
||||
]);
|
||||
]});
|
||||
|
||||
registry.category("web_tour.tours").add("sale_signature_without_name", {
|
||||
steps: () => [
|
||||
{
|
||||
content: "Wait for interactions to load",
|
||||
trigger: `body[is-ready=true], :iframe body[is-ready=true]`,
|
||||
},
|
||||
{
|
||||
content: "Sign & Pay",
|
||||
trigger:
|
||||
".o_portal_sale_sidebar .btn-primary, :iframe .o_portal_sale_sidebar .btn-primary",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
content: "click submit",
|
||||
trigger: ".o_portal_sign_submit:enabled, :iframe .o_portal_sign_submit:enabled",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
content: "check error because no name",
|
||||
trigger:
|
||||
'.o_portal_sign_error_msg:contains("Signature is missing."), :iframe .o_portal_sign_error_msg:contains("Signature is missing.")',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||