Eliminate Python dependency: embed frontend assets in odoo-go
- Copy all OWL frontend assets (JS/CSS/XML/fonts/images) into frontend/ directory (2925 files, 43MB) — no more runtime reads from Python Odoo - Replace OdooAddonsPath config with FrontendDir pointing to local frontend/ - Rewire bundle.go, static.go, templates.go, webclient.go to read from frontend/ instead of external Python Odoo addons directory - Auto-detect frontend/ and build/ dirs relative to binary in main.go - Delete obsolete Python helper scripts (tools/*.py) The Go server is now fully self-contained: single binary + frontend/ folder. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
BIN
frontend/sale/static/description/icon.png
Normal file
|
After Width: | Height: | Size: 861 B |
1
frontend/sale/static/description/icon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 511 B |
BIN
frontend/sale/static/description/icon_hi.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
frontend/sale/static/img/advance_product_0-image.jpg
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
frontend/sale/static/img/btn_paynowcc_lg.gif
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
frontend/sale/static/img/floor_protection-image.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
9
frontend/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 |
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/sale/static/src/img/sales_quotation_thumbnail.webp
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
63
frontend/sale/static/src/interactions/portal_prepayment.js
Normal file
@@ -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);
|
||||
11
frontend/sale/static/src/interactions/sale_portal.js
Normal file
@@ -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']);
|
||||
},
|
||||
});
|
||||
26
frontend/sale/static/src/interactions/sale_sidebar.js
Normal file
@@ -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>
|
||||
41
frontend/sale/static/src/js/models/product_combo.js
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
42
frontend/sale/static/src/js/models/product_combo_item.js
Normal file
@@ -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)));
|
||||
}
|
||||
}
|
||||
77
frontend/sale/static/src/js/models/product_product.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
95
frontend/sale/static/src/js/product/product.js
Normal file
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
48
frontend/sale/static/src/js/product/product.scss
Normal file
@@ -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);
|
||||
}
|
||||
138
frontend/sale/static/src/js/product/product.xml
Normal file
@@ -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>
|
||||
28
frontend/sale/static/src/js/product_card/product_card.js
Normal file
@@ -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')
|
||||
);
|
||||
}
|
||||
}
|
||||
17
frontend/sale/static/src/js/product_card/product_card.scss
Normal file
@@ -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
|
||||
}
|
||||
40
frontend/sale/static/src/js/product_card/product_card.xml
Normal file
@@ -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>
|
||||
20
frontend/sale/static/src/js/product_list/product_list.js
Normal file
@@ -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%);
|
||||
}
|
||||
28
frontend/sale/static/src/js/product_list/product_list.xml
Normal file
@@ -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>
|
||||
470
frontend/sale/static/src/js/sale_product_field.js
Normal file
@@ -0,0 +1,470 @@
|
||||
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";
|
||||
|
||||
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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
useEffect(value => {
|
||||
if (!isMounted) {
|
||||
isMounted = true;
|
||||
} 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.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();
|
||||
}
|
||||
}
|
||||
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.readonlyField ||
|
||||
(this.props.record.model.root.activeFields.order_line &&
|
||||
this.props.record.model.root._isReadonly("order_line"))
|
||||
);
|
||||
}
|
||||
get hasConfigurationButton() {
|
||||
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 configurationButtonHelp() {
|
||||
return _t("Edit Configuration");
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get sectionAndNoteClasses() {
|
||||
return {
|
||||
...super.sectionAndNoteClasses,
|
||||
"text-warning":
|
||||
!this.isSectionOrSubSection && !this.isNote() && !this.productName && !this.isDownpayment,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
_openGridConfigurator(edit = false) {} // sale_product_matrix
|
||||
|
||||
async _onProductUpdate() {} // event_booth_sale, event_sale, sale_renting
|
||||
|
||||
onEditConfiguration() {
|
||||
if (this.isCombo) {
|
||||
this._openComboConfigurator(true);
|
||||
} else if (this.isConfigurableTemplate) {
|
||||
this._openProductConfigurator(true);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
4
frontend/sale/static/src/js/sale_product_field.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
.o_field_sol_o2m ~ .row.o_group {
|
||||
clear: both;
|
||||
justify-content: space-between;
|
||||
}
|
||||
46
frontend/sale/static/src/js/sale_progressbar_field.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useEffect } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
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.
|
||||
*
|
||||
* The wanted behavior is to show a progress bar when an invoicing target is defined or show
|
||||
* a link redirecting to the record's form view otherwise.
|
||||
*/
|
||||
export class SaleProgressBarField extends KanbanProgressBarField {
|
||||
static template = "sale.SaleProgressBarField";
|
||||
/**
|
||||
* Anything used by the component is defined on the setup method.
|
||||
*/
|
||||
setup() {
|
||||
super.setup();
|
||||
|
||||
this.actionService = useService("action");
|
||||
this.orm = useService("orm");
|
||||
|
||||
useEffect(() => {
|
||||
this.state.isInvoicingTargetDefined = this.props.record.data[this.props.maxValueField];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the form view of the record on click.
|
||||
*/
|
||||
async defineInvoicingTarget() {
|
||||
const { resId, resModel } = this.props.record;
|
||||
const action = await this.orm.call(resModel, "get_formview_action", [[resId]]);
|
||||
this.actionService.doAction(action);
|
||||
}
|
||||
}
|
||||
|
||||
export const saleProgressBarField = {
|
||||
...kanbanProgressBarField,
|
||||
component: SaleProgressBarField,
|
||||
};
|
||||
|
||||
registry.category("fields").add("sales_team_progressbar", saleProgressBarField);
|
||||
65
frontend/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,
|
||||
};
|
||||
111
frontend/sale/static/src/js/tours/sale.js
Normal file
@@ -0,0 +1,111 @@
|
||||
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";
|
||||
|
||||
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",
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
],
|
||||
});
|
||||
97
frontend/sale/static/src/js/tours/tour_utils.js
Normal file
@@ -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>
|
||||
11
frontend/sale/static/src/scss/sale_onboarding.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
.o_onboarding_order_confirmation {
|
||||
& span.o_onboarding_order_confirmation_help img {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom:0;
|
||||
}
|
||||
& span.o_onboarding_order_confirmation_help:hover img {
|
||||
display: block
|
||||
}
|
||||
|
||||
}
|
||||
46
frontend/sale/static/src/scss/sale_portal.scss
Normal file
@@ -0,0 +1,46 @@
|
||||
/* ---- My Orders page ---- */
|
||||
|
||||
.orders_vertical_align {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.orders_label_text_align {
|
||||
vertical-align: 15%;
|
||||
}
|
||||
|
||||
/* ---- Order page ---- */
|
||||
|
||||
.sale_tbody .o_line_note {
|
||||
word-break: break-word;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.sale_tbody input.js_quantity {
|
||||
width: 48px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sale_tbody div.input-group.w-50.pull-right {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.o_portal .sale_tbody .js_quantity_container {
|
||||
|
||||
.js_quantity {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.input-group-text {
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(md) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.o_sale_payment_terms p{
|
||||
margin: 0;
|
||||
}
|
||||
3
frontend/sale/static/src/scss/sale_report.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.sale_tbody .o_line_note {
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -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);
|
||||
21
frontend/sale/static/src/xml/sale_product_field.xml
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<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>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="sale.SaleProgressBarField">
|
||||
<t t-if="state.isInvoicingTargetDefined">
|
||||
<t t-call="web.ProgressBarField"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<!-- TODO is this class needed here ? -->
|
||||
<a t-on-click.prevent="defineInvoicingTarget" href="#" class="sale_progressbar_form_link">
|
||||
Click to define an invoicing target
|
||||
</a>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -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});
|
||||
}
|
||||
251
frontend/sale/static/tests/sale_order_line_field.test.js
Normal file
@@ -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'
|
||||
});
|
||||
})
|
||||
201
frontend/sale/static/tests/sale_product_field.test.js
Normal file
@@ -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();
|
||||
});
|
||||
17
frontend/sale/static/tests/sale_test_helpers.js
Normal file
@@ -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);
|
||||
}
|
||||
72
frontend/sale/static/tests/sales_team_dashboard.test.js
Normal file
@@ -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"]);
|
||||
});
|
||||
@@ -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")',
|
||||
}
|
||||
]
|
||||
});
|
||||
98
frontend/sale/static/tests/tours/sale_catalog.js
Normal file
@@ -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(),
|
||||
]
|
||||
});
|
||||
136
frontend/sale/static/tests/tours/sale_combo_configurator.js
Normal file
@@ -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(),
|
||||
],
|
||||
});
|
||||
99
frontend/sale/static/tests/tours/sale_signature.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import { registry } from "@web/core/registry";
|
||||
import { redirect } from "@web/core/utils/urls";
|
||||
|
||||
// This tour relies on data created on the Python test.
|
||||
registry.category("web_tour.tours").add('sale_signature', {
|
||||
url: '/my/quotes',
|
||||
steps: () => [
|
||||
{
|
||||
content: "open the 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',
|
||||
},
|
||||
{
|
||||
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: '.modal .o_web_sign_auto_select_style button',
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
content: "click style 4",
|
||||
trigger: ".o-dropdown-item:eq(3)",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
content: "click submit",
|
||||
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 () {
|
||||
redirect("/odoo");
|
||||
}, // Avoid race condition at the end of the tour by returning to the home page.
|
||||
expectUnloadPage: true,
|
||||
},
|
||||
{
|
||||
trigger: 'nav',
|
||||
}
|
||||
]});
|
||||
|
||||
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.")',
|
||||
},
|
||||
],
|
||||
});
|
||||