19.0 vanilla
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 2.4 KiB |
|
|
@ -1 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="70" height="70"><defs><path id="a" d="M4 0h61c4 0 5 1 5 5v60c0 4-1 5-5 5H4c-3 0-4-1-4-5V5c0-4 1-5 4-5z"/><linearGradient id="c" x1="100%" x2="0%" y1="0%" y2="100%"><stop offset="0%" stop-color="#B06161"/><stop offset="45.785%" stop-color="#984E4E"/><stop offset="100%" stop-color="#7C3838"/></linearGradient><path id="d" d="M48.466 42.033c0 .837-.277 1.548-.831 2.133L36.606 55.823c-.584.585-1.265.877-2.044.877-.793 0-1.467-.292-2.021-.877l-16.06-16.965c-.569-.585-1.051-1.382-1.448-2.393s-.595-1.935-.595-2.773v-9.857c0-.821.284-1.532.853-2.132.569-.6 1.243-.9 2.021-.9h9.344c.794 0 1.67.209 2.628.627.959.419 1.722.928 2.291 1.528l16.06 16.919c.554.616.83 1.335.83 2.156zM24.5 28.385c0-.838-.28-1.552-.842-2.145-.562-.592-1.24-.888-2.033-.888-.794 0-1.471.296-2.033.888-.561.593-.842 1.307-.842 2.145 0 .837.28 1.552.842 2.144.562.592 1.24.889 2.033.889.794 0 1.471-.297 2.033-.889.561-.592.842-1.307.842-2.144zm32.59 13.648c0 .837-.276 1.548-.83 2.133L45.23 55.823c-.584.585-1.265.877-2.044.877-.539 0-.98-.11-1.325-.332-.344-.22-.74-.576-1.19-1.066l10.557-11.136c.554-.585.83-1.296.83-2.133 0-.821-.276-1.54-.83-2.156l-16.06-16.919c-.57-.6-1.333-1.11-2.291-1.528-.958-.418-1.834-.628-2.628-.628h5.031c.794 0 1.67.21 2.628.628.959.419 1.722.928 2.291 1.528l16.06 16.919c.554.616.83 1.335.83 2.156z"/><path id="e" d="M48.466 40.033c0 .837-.277 1.548-.831 2.133L36.606 53.823c-.584.585-1.265.877-2.044.877-.793 0-1.467-.292-2.021-.877l-16.06-16.965c-.569-.585-1.051-1.382-1.448-2.393s-.595-1.935-.595-2.773v-9.857c0-.821.284-1.532.853-2.132.569-.6 1.243-.9 2.021-.9h9.344c.794 0 1.67.209 2.628.627.959.419 1.722.928 2.291 1.528l16.06 16.919c.554.616.83 1.335.83 2.156zM24.5 26.385c0-.838-.28-1.552-.842-2.145-.562-.592-1.24-.888-2.033-.888-.794 0-1.471.296-2.033.888-.561.593-.842 1.307-.842 2.145 0 .837.28 1.552.842 2.144.562.592 1.24.889 2.033.889.794 0 1.471-.297 2.033-.889.561-.592.842-1.307.842-2.144zm32.59 13.648c0 .837-.276 1.548-.83 2.133L45.23 53.823c-.584.585-1.265.877-2.044.877-.539 0-.98-.11-1.325-.332-.344-.22-.74-.576-1.19-1.066l10.557-11.136c.554-.585.83-1.296.83-2.133 0-.821-.276-1.54-.83-2.156l-16.06-16.919c-.57-.6-1.333-1.11-2.291-1.528-.958-.418-1.834-.628-2.628-.628h5.031c.794 0 1.67.21 2.628.628.959.419 1.722.928 2.291 1.528l16.06 16.919c.554.616.83 1.335.83 2.156z"/></defs><g fill="none" fill-rule="evenodd"><mask id="b" fill="#fff"><use xlink:href="#a"/></mask><g mask="url(#b)"><path fill="url(#c)" d="M0 0H70V70H0z"/><path fill="#FFF" fill-opacity=".383" d="M4 1h61c2.667 0 4.333.667 5 2V0H0v3c.667-1.333 2-2 4-2z"/><path fill="#393939" d="M4 69c-2 0-4-1-4-4V33.916L16.402 19h19.682L56.79 41 39.224 69H4z" opacity=".324"/><path fill="#000" fill-opacity=".383" d="M4 69h61c2.667 0 4.333-1 5-3v4H0v-4c.667 2 2 3 4 3z"/><use fill="#000" fill-rule="nonzero" opacity=".3" xlink:href="#d"/><use fill="#FFF" fill-rule="nonzero" xlink:href="#e"/></g></g></svg>
|
||||
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M28.235 7.242a6.487 6.487 0 0 0-7.242 2.823l-3.884 6.252a2.418 2.418 0 0 0-.198 2.159l10.002 25.523 17.025-6.636a3.228 3.228 0 0 0 1.839-4.185l-8.823-22.515a2.427 2.427 0 0 0-1.613-1.452l-7.106-1.969Zm-1.092 7.865a1.787 1.787 0 0 0 1.017-2.317 1.795 1.795 0 0 0-2.323-1.015 1.787 1.787 0 0 0-1.017 2.317c.36.92 1.4 1.374 2.323 1.015Z" fill="#2EBCFA"/><path fill-rule="evenodd" clip-rule="evenodd" d="M32.423 10.924a6.482 6.482 0 0 0-6.734-3.877l-7.322.88a2.43 2.43 0 0 0-1.814 1.194L4.435 30.055a3.226 3.226 0 0 0 1.185 4.413l15.831 9.115L35.19 19.852c.382-.66.43-1.462.13-2.163l-2.897-6.764Zm-6.84 4.062c.857.493 1.954.2 2.45-.655a1.786 1.786 0 0 0-.657-2.443 1.796 1.796 0 0 0-2.45.655 1.786 1.786 0 0 0 .657 2.443Z" fill="#985184"/><path fill-rule="evenodd" clip-rule="evenodd" d="M27.78 7.134a6.476 6.476 0 0 1 4.643 3.789l2.897 6.764c.3.701.252 1.503-.13 2.163L24.61 38.124l-7.7-19.649a2.418 2.418 0 0 1 .198-2.158l3.885-6.252a6.487 6.487 0 0 1 6.788-2.931Zm-2.027 7.937a1.786 1.786 0 0 1-.827-2.53 1.796 1.796 0 0 1 2.322-.721 1.787 1.787 0 0 1-.105 3.287 1.792 1.792 0 0 1-1.39-.036Z" fill="#144496"/></svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 36 KiB |
BIN
odoo-bringout-oca-ocb-product/product/static/img/desk_pad.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 74 KiB |
BIN
odoo-bringout-oca-ocb-product/product/static/img/glass.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
odoo-bringout-oca-ocb-product/product/static/img/leather.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
odoo-bringout-oca-ocb-product/product/static/img/linen.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
odoo-bringout-oca-ocb-product/product/static/img/maroon.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
odoo-bringout-oca-ocb-product/product/static/img/metal.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
BIN
odoo-bringout-oca-ocb-product/product/static/img/purple.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 66 KiB |
BIN
odoo-bringout-oca-ocb-product/product/static/img/velvet.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
odoo-bringout-oca-ocb-product/product/static/img/wood.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
|
|
@ -0,0 +1,281 @@
|
|||
import { Component, markup, onRendered, onWillStart, useState } from "@odoo/owl";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { download } from "@web/core/network/download";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { useSetupAction } from "@web/search/action_hook";
|
||||
import { Layout } from "@web/search/layout";
|
||||
import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog";
|
||||
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
|
||||
|
||||
function sendCustomNotification(type, message) {
|
||||
return {
|
||||
type: "ir.actions.client",
|
||||
tag: "display_notification",
|
||||
params: {
|
||||
"type": type,
|
||||
"message": message
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export class ProductPricelistReport extends Component {
|
||||
static props = { ...standardActionServiceProps };
|
||||
static components = { Layout };
|
||||
static template = "product.ProductPricelistReport";
|
||||
|
||||
setup() {
|
||||
this.action = useService("action");
|
||||
this.orm = useService("orm");
|
||||
this.dialog = useService("dialog");
|
||||
|
||||
this.MAX_QTY = 5;
|
||||
const pastState = this.props.state || {};
|
||||
|
||||
const active_model = pastState.activeModel || this.props.action.context.active_model;
|
||||
this.noProducts = active_model === 'product.pricelist';
|
||||
this.activeIds = this.noProducts ? [] : pastState.activeIds || this.props.action.context.active_ids;
|
||||
this.activeModel = this.noProducts ? 'product.template' : active_model;
|
||||
this.defaultPricelistId = this.noProducts ? this.props.action.context.active_id : false;
|
||||
|
||||
this.state = useState({
|
||||
displayPricelistTitle: pastState.displayPricelistTitle || false,
|
||||
html: "",
|
||||
pricelists: [],
|
||||
_quantities: pastState.quantities || [1, 5, 10],
|
||||
selectedPricelist: {},
|
||||
});
|
||||
|
||||
onWillStart(async () => {
|
||||
this.state.pricelists = await this.getPricelists();
|
||||
if (this.defaultPricelistId) {
|
||||
this.state.selectedPricelist = this.pricelists.find(p => p.id === this.defaultPricelistId) || this.pricelists[0];
|
||||
} else {
|
||||
this.state.selectedPricelist = pastState.selectedPricelist || this.pricelists[0];
|
||||
}
|
||||
if(this.noProducts){
|
||||
await this.onClickAddProducts();
|
||||
}
|
||||
this.renderHtml();
|
||||
});
|
||||
|
||||
onRendered(() => {
|
||||
this.env.config.setDisplayName(_t("Pricelist Report"));
|
||||
});
|
||||
|
||||
/*
|
||||
When following the link of a product and coming back we need to keep the
|
||||
precedent state:
|
||||
- if the pricelist was being showed
|
||||
- wich pricelist is selected at the moment
|
||||
- which quantities
|
||||
*/
|
||||
useSetupAction({
|
||||
getLocalState: () => {
|
||||
return {
|
||||
displayPricelistTitle: this.displayPricelistTitle,
|
||||
quantities: this.quantities,
|
||||
selectedPricelist: this.selectedPricelist,
|
||||
activeModel: this.activeModel,
|
||||
activeIds: this.activeIds,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// getters and setters
|
||||
|
||||
get displayPricelistTitle() {
|
||||
return this.state.displayPricelistTitle;
|
||||
}
|
||||
|
||||
get html() {
|
||||
return this.state.html;
|
||||
}
|
||||
|
||||
get pricelists() {
|
||||
return this.state.pricelists;
|
||||
}
|
||||
|
||||
get quantities() {
|
||||
return this.state._quantities;
|
||||
}
|
||||
|
||||
set quantities(value) {
|
||||
this.state._quantities = value;
|
||||
}
|
||||
|
||||
get reportParams() {
|
||||
return {
|
||||
active_model: this.activeModel || 'product.template',
|
||||
active_ids: this.activeIds || [],
|
||||
display_pricelist_title: this.displayPricelistTitle || '',
|
||||
pricelist_id: this.selectedPricelist.id || '',
|
||||
quantities: this.quantities || [1],
|
||||
};
|
||||
}
|
||||
|
||||
get selectedPricelist() {
|
||||
return this.state.selectedPricelist;
|
||||
}
|
||||
|
||||
// orm calls
|
||||
|
||||
getPricelists() {
|
||||
return this.orm.searchRead("product.pricelist", [], ["id", "name"]);
|
||||
}
|
||||
|
||||
async renderHtml() {
|
||||
if (this.noProducts) {
|
||||
// do not make an rpc to get empty report data
|
||||
this.state.html = "";
|
||||
return
|
||||
}
|
||||
let html = await this.orm.call(
|
||||
"report.product.report_pricelist", "get_html", [], {data: this.reportParams}
|
||||
);
|
||||
this.state.html = markup(html);
|
||||
}
|
||||
|
||||
// events
|
||||
|
||||
async onClickAddQty(ev) {
|
||||
ev.preventDefault(); // avoid automatic reloading of the page
|
||||
|
||||
if (this.quantities.length >= this.MAX_QTY) {
|
||||
let message = _t(
|
||||
"At most %s quantities can be displayed simultaneously. Remove a selected quantity to add others.",
|
||||
this.MAX_QTY
|
||||
);
|
||||
await this.action.doAction(sendCustomNotification("warning", message));
|
||||
return;
|
||||
}
|
||||
|
||||
const qty = parseInt(ev.target.previousSibling.value);
|
||||
if (qty > 0) {
|
||||
// Check qty already exist.
|
||||
if (this.quantities.indexOf(qty) === -1) {
|
||||
this.quantities.push(qty);
|
||||
this.quantities = this.quantities.sort((a, b) => a - b);
|
||||
this.renderHtml();
|
||||
} else {
|
||||
let message = _t("Quantity already present (%s).", qty);
|
||||
await this.action.doAction(sendCustomNotification("info", message));
|
||||
}
|
||||
} else {
|
||||
await this.action.doAction(
|
||||
sendCustomNotification("info", _t("Please enter a positive whole number."))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onClickLink(ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
const parent = ev.target.parentElement;
|
||||
|
||||
let classes = parent.getAttribute("class", "");
|
||||
let resModel = parent.getAttribute("data-model", "");
|
||||
let resId = parent.getAttribute("data-res-id", "");
|
||||
|
||||
if (classes && classes.includes("o_action") && resModel && resId) {
|
||||
this.action.doAction({
|
||||
type: 'ir.actions.act_window',
|
||||
res_model: resModel,
|
||||
res_id: parseInt(resId),
|
||||
views: [[false, 'form']],
|
||||
target: 'self',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async onClickPrint() {
|
||||
if (this.noProducts) {
|
||||
this.action.doAction(
|
||||
sendCustomNotification("warning", _t("Please select some products first."))
|
||||
);
|
||||
return;
|
||||
}
|
||||
const selectedFormat = document.getElementById('formats').value;
|
||||
if (selectedFormat === 'pdf') {
|
||||
this.export_pdf();
|
||||
} else {
|
||||
await this.export_pricelist_csv_xlsx(selectedFormat);
|
||||
}
|
||||
}
|
||||
|
||||
export_pdf() {
|
||||
this.action.doAction({
|
||||
type: 'ir.actions.report',
|
||||
report_type: 'qweb-pdf',
|
||||
report_name: 'product.report_pricelist',
|
||||
report_file: 'product.report_pricelist',
|
||||
data: this.reportParams,
|
||||
});
|
||||
}
|
||||
|
||||
async export_pricelist_csv_xlsx(format) {
|
||||
try {
|
||||
await download({
|
||||
url: `/product/export/pricelist/`,
|
||||
data: {
|
||||
report_data: JSON.stringify(this.reportParams),
|
||||
export_format: format,
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error exporting ${format.toUpperCase()} file:`, error);
|
||||
await this.action.doAction(
|
||||
sendCustomNotification(
|
||||
"danger",
|
||||
_t("Error exporting file. Please try again.")
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async onClickAddProducts() {
|
||||
this.dialog.add(SelectCreateDialog, {
|
||||
resModel: this.activeModel || 'product.template',
|
||||
title: _t("Add Products to pricelist report"),
|
||||
noCreate: true,
|
||||
onSelected: async (resIds) => {
|
||||
resIds.forEach((id) => {
|
||||
if (!this.activeIds.includes(id)) {
|
||||
this.activeIds.push(id);
|
||||
}
|
||||
});
|
||||
this.noProducts = false;
|
||||
await this.renderHtml();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async onClickRemoveQty(ev) {
|
||||
if (this.quantities.length <= 1) {
|
||||
await this.action.doAction(
|
||||
sendCustomNotification("warning", _t("You must leave at least one quantity."))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const qty = parseInt(ev.srcElement.parentElement.childNodes[0].data);
|
||||
this.quantities = this.quantities.filter(q => q !== qty);
|
||||
this.renderHtml();
|
||||
}
|
||||
|
||||
onSelectPricelist(ev) {
|
||||
this.state.selectedPricelist = this.pricelists.filter(pricelist =>
|
||||
pricelist.id === parseInt(ev.target.value)
|
||||
)[0];
|
||||
|
||||
this.renderHtml();
|
||||
}
|
||||
|
||||
onToggleDisplayPricelist() {
|
||||
this.state.displayPricelistTitle = !this.displayPricelistTitle;
|
||||
this.renderHtml();
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("generate_pricelist_report", ProductPricelistReport);
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates>
|
||||
|
||||
<t t-name="product.ProductPricelistReport">
|
||||
<div class="o_action">
|
||||
<Layout display="{ controlPanel: {} }">
|
||||
<t t-set-slot="control-panel-always-buttons">
|
||||
<button t-on-click="onClickPrint" type="button" class="btn btn-primary" title="Print">Print</button>
|
||||
<select name="formats" id="formats" class="form-select border-1 w-auto">
|
||||
<option value="pdf">pdf</option>
|
||||
<option value="csv">csv</option>
|
||||
<option value="xlsx">xlsx</option>
|
||||
</select>
|
||||
</t>
|
||||
<t t-set-slot="layout-actions">
|
||||
<form class="o_pricelist_report_form d-flex flex-row gap-1">
|
||||
<div class="d-flex align-items-center gap-3 w-100">
|
||||
<label class="visually-hidden" for="pricelists">Username</label>
|
||||
<div class="input-group w-auto">
|
||||
<div class="input-group-text fw-bold">Pricelist</div>
|
||||
<select
|
||||
name="pricelists"
|
||||
id="pricelists"
|
||||
class="o_select form-select border-0"
|
||||
t-on-change="onSelectPricelist"
|
||||
>
|
||||
<option t-out="selectedPricelist.name" t-att-value="selectedPricelist.id"/>
|
||||
<t t-foreach="pricelists" t-as="pricelist" t-key="pricelist.id">
|
||||
<t t-if="pricelist.id != selectedPricelist.id">
|
||||
<option t-out="pricelist.name" t-att-value="pricelist.id"/>
|
||||
</t>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-check w-auto">
|
||||
<input class="o_display_pricelist_title form-check-input ms-0 me-2"
|
||||
id="display_pricelist_title"
|
||||
type="checkbox"
|
||||
t-att-checked="displayPricelistTitle"
|
||||
t-on-click="onToggleDisplayPricelist"/>
|
||||
<label for="display_pricelist_title" class="form-check-label">Show Name</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-3 w-100">
|
||||
<div class="input-group d-flex flex-nowrap w-50" style="min-width:210px;">
|
||||
<span class="input-group-text fw-bold">
|
||||
Quantities
|
||||
</span>
|
||||
<input type="number"
|
||||
class="form-control add-quantity-input"
|
||||
value="1"
|
||||
min="1"/>
|
||||
<button class="o_add_qty btn btn-primary fa fa-plus"
|
||||
type="submit"
|
||||
t-on-click="onClickAddQty"
|
||||
title="Add a quantity"/>
|
||||
</div>
|
||||
<div class="d-flex align-items-center w-50">
|
||||
<span class="o_badges_list d-flex">
|
||||
<t t-foreach="quantities" t-as="qty" t-key="qty">
|
||||
<span class="text-bg-300 o_remove_qty badge rounded-pill me-2 py-1 border " t-att-value="qty">
|
||||
<t class="me-2" t-esc="qty"/>
|
||||
<i class="oi oi-close ms-1 opacity-50 opacity-100-hover text-900 cursor-pointer"
|
||||
title="Remove quantity"
|
||||
t-on-click="onClickRemoveQty"/>
|
||||
</span>
|
||||
</t>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div style="margin-left:300px;">
|
||||
<button t-on-click="onClickAddProducts" string="All Products" class="btn btn-link">
|
||||
<i class="fa fa-pencil"/>
|
||||
Add Products
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
<div t-on-click="onClickLink">
|
||||
<t t-out="html"/>
|
||||
</div>
|
||||
<t t-if="noProducts">
|
||||
<div class="o_view_nocontent" role="alert">
|
||||
<div class="o_nocontent_help">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No products found in the report
|
||||
</p>
|
||||
<p class="fs-5">
|
||||
Add products using the "Add Products" button at the top right to
|
||||
include them in the report.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</Layout>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { _t } from '@web/core/l10n/translation';
|
||||
import { ConfirmationDialog, deleteConfirmationMessage } from '@web/core/confirmation_dialog/confirmation_dialog';
|
||||
import { ListRenderer } from '@web/views/list/list_renderer';
|
||||
import { registry } from '@web/core/registry';
|
||||
import { useService } from '@web/core/utils/hooks';
|
||||
import { X2ManyField, x2ManyField } from '@web/views/fields/x2many/x2many_field';
|
||||
|
||||
|
||||
export class PAVListRenderer extends ListRenderer {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.dialog = useService("dialog");
|
||||
this.orm = useService("orm");
|
||||
}
|
||||
|
||||
async onDeleteRecord(record) {
|
||||
const message = await this.orm.call(
|
||||
'product.attribute.value',
|
||||
'check_is_used_on_products',
|
||||
[record.resId],
|
||||
)
|
||||
if (message) {
|
||||
return this.dialog.add(ConfirmationDialog, {
|
||||
title: _t("Invalid Operation"),
|
||||
body: message,
|
||||
});
|
||||
}
|
||||
if (record.isNew) {
|
||||
return super.onDeleteRecord(...arguments);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
this.dialog.add(ConfirmationDialog, {
|
||||
title: _t("Bye-bye, record!"),
|
||||
body: deleteConfirmationMessage,
|
||||
confirmLabel: _t("Delete"),
|
||||
confirm: () => this.onConfirmDelete(record).then(resolve),
|
||||
cancel: resolve,
|
||||
cancelLabel: _t("No, keep it"),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async onConfirmDelete(record) {
|
||||
await this.orm.unlink('product.attribute.value', [record.resId])
|
||||
const res = await super.onDeleteRecord(record);
|
||||
await this.props.list.model.root.save();
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
export class PAVOne2ManyField extends X2ManyField {
|
||||
static components = {
|
||||
...X2ManyField.components,
|
||||
ListRenderer: PAVListRenderer,
|
||||
};
|
||||
}
|
||||
|
||||
export const pavOne2ManyField = {
|
||||
...x2ManyField,
|
||||
component: PAVOne2ManyField,
|
||||
}
|
||||
|
||||
registry.category("fields").add("pavs_one2many", pavOne2ManyField);
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { UploadButton } from '@product/js/product_document_kanban/upload_button/upload_button';
|
||||
import { KanbanController } from '@web/views/kanban/kanban_controller';
|
||||
|
||||
export class ProductDocumentKanbanController extends KanbanController {
|
||||
static components = { ...KanbanController.components, UploadButton };
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.uploadRoute = '/product/document/upload';
|
||||
this.formData = {
|
||||
'res_model': this.props.context.default_res_model,
|
||||
'res_id': this.props.context.default_res_id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<t
|
||||
t-name="product.ProductDocumentKanbanView.Buttons"
|
||||
t-inherit="web.KanbanView.Buttons"
|
||||
t-inherit-mode="primary"
|
||||
>
|
||||
<xpath expr="." position="inside">
|
||||
<UploadButton
|
||||
formData="formData"
|
||||
allowedMIMETypes="allowedMIMETypes"
|
||||
load.bind="() => this.model.root.load()"
|
||||
uploadRoute="uploadRoute"
|
||||
/>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { CANCEL_GLOBAL_CLICK, KanbanRecord } from "@web/views/kanban/kanban_record";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { useFileViewer } from "@web/core/file_viewer/file_viewer_hook";
|
||||
|
||||
export class ProductDocumentKanbanRecord extends KanbanRecord {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.store = useService("mail.store");
|
||||
this.fileViewer = useFileViewer();
|
||||
}
|
||||
/**
|
||||
* @override
|
||||
*
|
||||
* Override to open the preview upon clicking the image, if compatible.
|
||||
*/
|
||||
onGlobalClick(ev) {
|
||||
if (ev.target.closest(CANCEL_GLOBAL_CLICK)) {
|
||||
return;
|
||||
} else if (ev.target.closest(".o_kanban_previewer")) {
|
||||
const attachment = this.store["ir.attachment"].insert({
|
||||
id: this.props.record.data.ir_attachment_id.id,
|
||||
name: this.props.record.data.name,
|
||||
mimetype: this.props.record.data.mimetype,
|
||||
});
|
||||
this.fileViewer.open(attachment);
|
||||
return;
|
||||
}
|
||||
return super.onGlobalClick(...arguments);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { useService } from "@web/core/utils/hooks";
|
||||
import { KanbanRenderer } from "@web/views/kanban/kanban_renderer";
|
||||
import { ProductDocumentKanbanRecord } from "@product/js/product_document_kanban/product_document_kanban_record";
|
||||
import { FileUploadProgressContainer } from "@web/core/file_upload/file_upload_progress_container";
|
||||
import { FileUploadProgressKanbanRecord } from "@web/core/file_upload/file_upload_progress_record";
|
||||
|
||||
export class ProductDocumentKanbanRenderer extends KanbanRenderer {
|
||||
static components = {
|
||||
...KanbanRenderer.components,
|
||||
FileUploadProgressContainer,
|
||||
FileUploadProgressKanbanRecord,
|
||||
KanbanRecord: ProductDocumentKanbanRecord,
|
||||
};
|
||||
static template = "product.ProductDocumentKanbanRenderer";
|
||||
setup() {
|
||||
super.setup();
|
||||
this.fileUploadService = useService("file_upload");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<t t-name="product.ProductDocumentKanbanRenderer" t-inherit-mode="primary" t-inherit="web.KanbanRenderer">
|
||||
<!-- Before the first t-foreach -->
|
||||
<xpath expr="//t[@t-key='groupOrRecord.key']" position="before">
|
||||
<FileUploadProgressContainer fileUploads="fileUploadService.uploads" Component="constructor.components.FileUploadProgressKanbanRecord"/>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { registry } from "@web/core/registry";
|
||||
|
||||
import { kanbanView } from "@web/views/kanban/kanban_view";
|
||||
import { ProductDocumentKanbanController } from "@product/js/product_document_kanban/product_document_kanban_controller";
|
||||
import { ProductDocumentKanbanRenderer } from "@product/js/product_document_kanban/product_document_kanban_renderer";
|
||||
|
||||
export const productDocumentKanbanView = {
|
||||
...kanbanView,
|
||||
Controller: ProductDocumentKanbanController,
|
||||
Renderer: ProductDocumentKanbanRenderer,
|
||||
buttonTemplate: "product.ProductDocumentKanbanView.Buttons",
|
||||
};
|
||||
|
||||
registry.category("views").add("product_documents_kanban", productDocumentKanbanView);
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.o_kanban_previewer:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import { _t } from "@web/core/l10n/translation";
|
||||
import { Component, useRef } from "@odoo/owl";
|
||||
import { useBus, useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class UploadButton extends Component {
|
||||
static template = "product.UploadButton";
|
||||
static props = {
|
||||
formData: { type: Object, optional: true},
|
||||
// See https://www.iana.org/assignments/media-types/media-types.xhtml
|
||||
allowedMIMETypes: { type: String, optional: true},
|
||||
load: Function,
|
||||
uploadRoute: String,
|
||||
}
|
||||
static defaultProps = {
|
||||
formData: {},
|
||||
}
|
||||
|
||||
setup() {
|
||||
this.uploadFileInputRef = useRef("uploadFileInput");
|
||||
this.fileUploadService = useService("file_upload");
|
||||
this.notification = useService('notification');
|
||||
useBus(
|
||||
this.fileUploadService.bus,
|
||||
"FILE_UPLOAD_LOADED",
|
||||
async () => {
|
||||
await this.props.load();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async onFileInputChange(ev) {
|
||||
const files = [...ev.target.files].filter(file => this.validFileType(file));
|
||||
if (!files.length) {
|
||||
return;
|
||||
}
|
||||
await this.fileUploadService.upload(
|
||||
this.props.uploadRoute,
|
||||
files,
|
||||
{
|
||||
buildFormData: (formData) => this.buildFormData(formData)
|
||||
},
|
||||
);
|
||||
// Reset the file input's value so that the same file may be uploaded twice.
|
||||
ev.target.value = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* The `allowedMIMETypes` prop can restrict the file types users are guided to select. However,
|
||||
* the `accept` attribute doesn't enforce strict validation; it only suggests file types for
|
||||
* browsers.
|
||||
*
|
||||
* @param {File} file
|
||||
* @returns Whether the upload file's type is in the whitelist (`allowedMIMETypes`).
|
||||
*/
|
||||
validFileType(file) {
|
||||
if (this.props.allowedMIMETypes && !this.props.allowedMIMETypes.includes(file.type)) {
|
||||
this.notification.add(
|
||||
_t(`Oops! '%(fileName)s' didn’t upload since its format isn’t allowed.`, {
|
||||
fileName: file.name,
|
||||
}),
|
||||
{
|
||||
type: "danger",
|
||||
}
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
buildFormData(formData) {
|
||||
for (const [key, value] of Object.entries(this.props.formData)) {
|
||||
formData.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<t t-name="product.UploadButton">
|
||||
<input
|
||||
type="file"
|
||||
multiple="true"
|
||||
t-ref="uploadFileInput"
|
||||
t-att-accept="props.allowedMIMETypes"
|
||||
class="o_input_file o_hidden"
|
||||
t-on-change.stop="onFileInputChange"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
name="product_upload_document"
|
||||
t-attf-class="btn btn-primary"
|
||||
t-on-click.stop.prevent="() => this.uploadFileInputRef.el.click()"
|
||||
>
|
||||
Upload
|
||||
</button>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -1,317 +0,0 @@
|
|||
odoo.define('product.generate_pricelist', function (require) {
|
||||
'use strict';
|
||||
|
||||
var AbstractAction = require('web.AbstractAction');
|
||||
var core = require('web.core');
|
||||
var FieldMany2One = require('web.relational_fields').FieldMany2One;
|
||||
var StandaloneFieldManagerMixin = require('web.StandaloneFieldManagerMixin');
|
||||
var Widget = require('web.Widget');
|
||||
|
||||
var QWeb = core.qweb;
|
||||
var _t = core._t;
|
||||
|
||||
var QtyTagWidget = Widget.extend({
|
||||
template: 'product.report_pricelist_qty',
|
||||
events: {
|
||||
'click .o_remove_qty': '_onClickRemoveQty',
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
init: function (parent, defaulQuantities) {
|
||||
this._super.apply(this, arguments);
|
||||
this.quantities = defaulQuantities;
|
||||
this.MAX_QTY = 5;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Add a quantity when add(+) button clicked.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onClickAddQty: function () {
|
||||
if (this.quantities.length >= this.MAX_QTY) {
|
||||
this.displayNotification({ message: _.str.sprintf(
|
||||
_t("At most %d quantities can be displayed simultaneously. Remove a selected quantity to add others."),
|
||||
this.MAX_QTY
|
||||
) });
|
||||
return;
|
||||
}
|
||||
const qty = parseInt(this.$('.o_product_qty').val());
|
||||
if (qty && qty > 0) {
|
||||
// Check qty already exist
|
||||
if (this.quantities.indexOf(qty) === -1) {
|
||||
this.quantities.push(qty);
|
||||
this.quantities = this.quantities.sort((a, b) => a - b);
|
||||
this.trigger_up('qty_changed', {quantities: this.quantities});
|
||||
this.renderElement();
|
||||
} else {
|
||||
this.displayNotification({
|
||||
message: _.str.sprintf(_t("Quantity already present (%d)."), qty),
|
||||
type: 'info'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.displayNotification({ message: _t("Please enter a positive whole number") });
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Remove quantity.
|
||||
*
|
||||
* @private
|
||||
* @param {jQueryEvent} ev
|
||||
*/
|
||||
_onClickRemoveQty: function (ev) {
|
||||
const qty = parseInt($(ev.currentTarget).closest('.badge').data('qty'));
|
||||
this.quantities = this.quantities.filter(q => q !== qty);
|
||||
this.trigger_up('qty_changed', {quantities: this.quantities});
|
||||
this.renderElement();
|
||||
},
|
||||
});
|
||||
|
||||
var GeneratePriceList = AbstractAction.extend(StandaloneFieldManagerMixin, {
|
||||
hasControlPanel: true,
|
||||
events: {
|
||||
'click .o_action': '_onClickAction',
|
||||
'submit form': '_onSubmitForm',
|
||||
},
|
||||
custom_events: Object.assign({}, StandaloneFieldManagerMixin.custom_events, {
|
||||
field_changed: '_onFieldChanged',
|
||||
qty_changed: '_onQtyChanged',
|
||||
}),
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
init: function (parent, params) {
|
||||
this._super.apply(this, arguments);
|
||||
StandaloneFieldManagerMixin.init.call(this);
|
||||
this.context = params.context;
|
||||
// in case the window got refreshed
|
||||
if (params.params && params.params.active_ids && typeof(params.params.active_ids === 'string')) {
|
||||
try {
|
||||
this.context.active_ids = params.params.active_ids.split(',').map(id => parseInt(id));
|
||||
this.context.active_model = params.params.active_model;
|
||||
} catch(_e) {
|
||||
console.log('unable to load ids from the url fragment 🙁');
|
||||
}
|
||||
}
|
||||
if (!this.context.active_model) {
|
||||
// started without an active module, assume product templates
|
||||
this.context.active_model = 'product.template';
|
||||
}
|
||||
this.context.quantities = [1, 5, 10];
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
willStart: function () {
|
||||
let getPricelist;
|
||||
// started without a selected pricelist in context? just get the first one
|
||||
if (this.context.default_pricelist) {
|
||||
getPricelist = Promise.resolve([this.context.default_pricelist]);
|
||||
} else {
|
||||
getPricelist = this._rpc({
|
||||
model: 'product.pricelist',
|
||||
method: 'search',
|
||||
args: [[]],
|
||||
kwargs: {limit: 1}
|
||||
});
|
||||
}
|
||||
const fieldSetup = getPricelist.then(pricelistIds => {
|
||||
return this.model.makeRecord('report.product.report_pricelist', [{
|
||||
name: 'pricelist_id',
|
||||
type: 'many2one',
|
||||
relation: 'product.pricelist',
|
||||
value: pricelistIds[0],
|
||||
}]);
|
||||
}).then(recordID => {
|
||||
const record = this.model.get(recordID);
|
||||
this.many2one = new FieldMany2One(this, 'pricelist_id', record, {
|
||||
mode: 'edit',
|
||||
attrs: {
|
||||
can_create: false,
|
||||
can_write: false,
|
||||
options: {no_open: true},
|
||||
},
|
||||
});
|
||||
this._registerWidget(recordID, 'pricelist_id', this.many2one);
|
||||
});
|
||||
return Promise.all([fieldSetup, this._getHtml(), this._super()]);
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
this.controlPanelProps.cp_content = this._renderComponent();
|
||||
const $content = this.controlPanelProps.cp_content;
|
||||
$content["$searchview"][0].querySelector('.o_is_visible_title').addEventListener('click', this._onClickVisibleTitle.bind(this));
|
||||
return this._super.apply(this, arguments).then(() => {
|
||||
this.$('.o_content').html(this.reportHtml);
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Include the current model (template/variant) in the state to allow refreshing without losing
|
||||
* the proper context.
|
||||
* @override
|
||||
*/
|
||||
getState: function () {
|
||||
return {
|
||||
active_model: this.context.active_model,
|
||||
};
|
||||
},
|
||||
getTitle: function () {
|
||||
return _t('Pricelist Report');
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the expected data for the report rendering call (html or pdf)
|
||||
*
|
||||
* @private
|
||||
* @returns {Object}
|
||||
*/
|
||||
_prepareActionReportParams: function () {
|
||||
return {
|
||||
active_model: this.context.active_model,
|
||||
active_ids: this.context.active_ids || '',
|
||||
is_visible_title: this.context.is_visible_title || '',
|
||||
pricelist_id: this.context.pricelist_id || '',
|
||||
quantities: this.context.quantities || [1],
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Get template to display report.
|
||||
*
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_getHtml: function () {
|
||||
return this._rpc({
|
||||
model: 'report.product.report_pricelist',
|
||||
method: 'get_html',
|
||||
kwargs: {
|
||||
data: this._prepareActionReportParams(),
|
||||
context: this.context,
|
||||
},
|
||||
}).then(result => {
|
||||
this.reportHtml = result;
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Reload report.
|
||||
*
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_reload: function () {
|
||||
return this._getHtml().then(() => {
|
||||
this.$('.o_content').html(this.reportHtml);
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Render search view and print button.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_renderComponent: function () {
|
||||
const $buttons = $('<button>', {
|
||||
class: 'btn btn-primary',
|
||||
text: _t("Print"),
|
||||
}).on('click', this._onClickPrint.bind(this));
|
||||
|
||||
const $searchview = $(QWeb.render('product.report_pricelist_search'));
|
||||
this.many2one.appendTo($searchview.find('.o_pricelist'));
|
||||
|
||||
this.qtyTagWidget = new QtyTagWidget(this, this.context.quantities);
|
||||
this.qtyTagWidget.replace($searchview.find('.o_product_qty'));
|
||||
return { $buttons, $searchview };
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Checkbox is checked, the report title will show.
|
||||
*
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onClickVisibleTitle(ev) {
|
||||
this.context.is_visible_title = ev.currentTarget.checked;
|
||||
this._reload();
|
||||
},
|
||||
|
||||
/**
|
||||
* Open form view of particular record when link clicked.
|
||||
*
|
||||
* @private
|
||||
* @param {jQueryEvent} ev
|
||||
*/
|
||||
_onClickAction: function (ev) {
|
||||
ev.preventDefault();
|
||||
this.do_action({
|
||||
type: 'ir.actions.act_window',
|
||||
res_model: $(ev.currentTarget).data('model'),
|
||||
res_id: $(ev.currentTarget).data('res-id'),
|
||||
views: [[false, 'form']],
|
||||
target: 'self',
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Print report in PDF when button clicked.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onClickPrint: function () {
|
||||
return this.do_action({
|
||||
type: 'ir.actions.report',
|
||||
report_type: 'qweb-pdf',
|
||||
report_name: 'product.report_pricelist',
|
||||
report_file: 'product.report_pricelist',
|
||||
data: this._prepareActionReportParams(),
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Reload report when pricelist changed.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
_onFieldChanged: function (event) {
|
||||
this.context.pricelist_id = event.data.changes.pricelist_id.id;
|
||||
StandaloneFieldManagerMixin._onFieldChanged.apply(this, arguments);
|
||||
this._reload();
|
||||
},
|
||||
/**
|
||||
* Reload report when quantities changed.
|
||||
*
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
* @param {integer[]} event.data.quantities
|
||||
*/
|
||||
_onQtyChanged: function (ev) {
|
||||
this.context.quantities = ev.data.quantities;
|
||||
this._reload();
|
||||
},
|
||||
_onSubmitForm: function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.qtyTagWidget._onClickAddQty();
|
||||
},
|
||||
});
|
||||
|
||||
core.action_registry.add('generate_pricelist', GeneratePriceList);
|
||||
|
||||
return {
|
||||
GeneratePriceList,
|
||||
QtyTagWidget
|
||||
};
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { KanbanController } from "@web/views/kanban/kanban_controller";
|
||||
import { onWillStart } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { useDebounced } from "@web/core/utils/timing";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
export class ProductCatalogKanbanController extends KanbanController {
|
||||
static template = "ProductCatalogKanbanController";
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.orm = useService("orm");
|
||||
this.orderId = this.props.context.order_id;
|
||||
this.orderResModel = this.props.context.product_catalog_order_model;
|
||||
this.backToQuotationDebounced = useDebounced(this.backToQuotation, 500)
|
||||
|
||||
onWillStart(() => this.onWillStart());
|
||||
}
|
||||
|
||||
async onWillStart() {
|
||||
await this.setOrderStateInfo();
|
||||
this._defineButtonContent();
|
||||
}
|
||||
|
||||
// Force the slot for the "Back to Quotation" button to always be shown.
|
||||
get canCreate() {
|
||||
return true;
|
||||
}
|
||||
|
||||
get stateFiels() {
|
||||
return ["state"];
|
||||
}
|
||||
|
||||
async setOrderStateInfo() {
|
||||
const orderData = await this.orm.searchRead(
|
||||
this.orderResModel, [["id", "=", this.orderId]], this.stateFiels
|
||||
);
|
||||
this.orderStateInfo = orderData[0] || {};
|
||||
}
|
||||
|
||||
_defineButtonContent() {
|
||||
// Define the button's label depending of the order's state.
|
||||
const orderIsQuotation = ["draft", "sent"].includes(this.orderStateInfo.state);
|
||||
if (orderIsQuotation) {
|
||||
this.buttonString = _t("Back to Quotation");
|
||||
} else {
|
||||
this.buttonString = _t("Back to Order");
|
||||
}
|
||||
}
|
||||
|
||||
async backToQuotation() {
|
||||
// Restore the last form view from the breadcrumbs if breadcrumbs are available.
|
||||
// If, for some weird reason, the user reloads the page then the breadcrumbs are
|
||||
// lost, and we fall back to the form view ourselves.
|
||||
if (this.env.config.breadcrumbs.length > 1) {
|
||||
await this.actionService.restore();
|
||||
} else {
|
||||
await this.actionService.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: this.orderResModel,
|
||||
views: [[false, "form"]],
|
||||
view_mode: "form",
|
||||
res_id: this.orderId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="ProductCatalogKanbanController" t-inherit="web.KanbanView" t-inherit-mode="primary">
|
||||
<xpath expr="//button[hasclass('o-kanban-button-new')]" position="replace">
|
||||
<button t-out="this.buttonString" type="button" class="btn btn-primary o-kanban-button-back" t-on-click="this.backToQuotationDebounced"/>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import { _t } from "@web/core/l10n/translation";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { Record } from "@web/model/relational_model/record";
|
||||
import { RelationalModel } from "@web/model/relational_model/relational_model";
|
||||
|
||||
class ProductCatalogRecord extends Record {
|
||||
setup(config, data, options = {}) {
|
||||
this.productCatalogData = data.productCatalogData;
|
||||
data = { ...data };
|
||||
delete data.productCatalogData;
|
||||
super.setup(config, data, options);
|
||||
}
|
||||
}
|
||||
|
||||
export class ProductCatalogKanbanModel extends RelationalModel {
|
||||
static Record = ProductCatalogRecord;
|
||||
static withCache = false;
|
||||
|
||||
async _loadData(params) {
|
||||
// if orm have isSample field and its value set to be true then we have sample data as there is no product found for selected vendor, show sample data
|
||||
const isSample = this.orm.isSample !== undefined ? this.orm.isSample : false;
|
||||
const result = await super._loadData(...arguments);
|
||||
if (!params.isMonoRecord) {
|
||||
let records;
|
||||
if (params.groupBy?.length) {
|
||||
// web_read_group: find all opened records from (sub)group
|
||||
records = [];
|
||||
const stackGroups = [...result.groups];
|
||||
while (stackGroups.length) {
|
||||
const group = stackGroups.pop();
|
||||
if (group.groups?.length) {
|
||||
stackGroups.push(...group.groups);
|
||||
}
|
||||
if (group.records?.length) {
|
||||
records.push(...group.records);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
records = result.records;
|
||||
}
|
||||
|
||||
let orderLinesInfo;
|
||||
if (!isSample) {
|
||||
orderLinesInfo = await rpc("/product/catalog/order_lines_info", this._getOrderLinesInfoParams(params, records.map((rec) => rec.id)));
|
||||
} else {
|
||||
orderLinesInfo = this._getSampleOrderLineInfo();
|
||||
}
|
||||
for (const record of records) {
|
||||
record.productCatalogData = orderLinesInfo[record.id];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
_getOrderLinesInfoParams(params, productIds) {
|
||||
return {
|
||||
order_id: params.context.order_id,
|
||||
product_ids: productIds,
|
||||
res_model: params.context.product_catalog_order_model,
|
||||
child_field: params.context.child_field,
|
||||
}
|
||||
}
|
||||
|
||||
_getSampleOrderLineInfo() {
|
||||
// this function only returns data for sample view similar to rpc call ("/product/catalog/order_lines_info) made in _loadData
|
||||
const sampleOrderLineInfo = {};
|
||||
const numRecords = 10; // Number of records to generate
|
||||
for (let i = 1; i <= numRecords; i++) {
|
||||
sampleOrderLineInfo[i] = {
|
||||
isSample: true,
|
||||
quantity: Math.floor(Math.random() * 10),
|
||||
min_qty: 0,
|
||||
price: Math.floor(Math.random() * 500) + 100,
|
||||
productType: "consu",
|
||||
readOnly: false,
|
||||
uomDisplayName: _t("Units"),
|
||||
};
|
||||
}
|
||||
return sampleOrderLineInfo;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
import { useSubEnv } from "@odoo/owl";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useDebounced } from "@web/core/utils/timing";
|
||||
import { KanbanRecord } from "@web/views/kanban/kanban_record";
|
||||
import { ProductCatalogOrderLine } from "./order_line/order_line";
|
||||
|
||||
export class ProductCatalogKanbanRecord extends KanbanRecord {
|
||||
static template = "ProductCatalogKanbanRecord";
|
||||
static components = {
|
||||
...KanbanRecord.components,
|
||||
ProductCatalogOrderLine,
|
||||
};
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.debouncedUpdateQuantity = useDebounced(this._updateQuantity, 500, {
|
||||
execBeforeUnmount: true,
|
||||
});
|
||||
this._pendingUpdate = Promise.resolve();
|
||||
|
||||
useSubEnv({
|
||||
currencyId: this.props.record.context.product_catalog_currency_id,
|
||||
orderId: this.props.record.context.product_catalog_order_id,
|
||||
orderResModel: this.props.record.context.product_catalog_order_model,
|
||||
digits: this.props.record.context.product_catalog_digits,
|
||||
displayUoM: this.props.record.context.display_uom,
|
||||
precision: this.props.record.context.precision,
|
||||
productId: this.props.record.resId,
|
||||
addProduct: this.addProduct.bind(this),
|
||||
removeProduct: this.removeProduct.bind(this),
|
||||
increaseQuantity: this.increaseQuantity.bind(this),
|
||||
setQuantity: this.setQuantity.bind(this),
|
||||
decreaseQuantity: this.decreaseQuantity.bind(this),
|
||||
childField: this.props.record.context.child_field,
|
||||
});
|
||||
}
|
||||
|
||||
get orderLineComponent() {
|
||||
return ProductCatalogOrderLine;
|
||||
}
|
||||
|
||||
get productCatalogData() {
|
||||
return this.props.record.productCatalogData;
|
||||
}
|
||||
|
||||
onGlobalClick(ev) {
|
||||
// avoid a concurrent update when clicking on the buttons (that are inside the record)
|
||||
if (ev.target.closest(".o_product_catalog_cancel_global_click")) {
|
||||
return;
|
||||
}
|
||||
if (this.productCatalogData.quantity === 0) {
|
||||
this.addProduct();
|
||||
} else {
|
||||
this.increaseQuantity();
|
||||
}
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Data Exchanges
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
async _updateQuantity() {
|
||||
const price = await this._updateQuantityAndGetPrice();
|
||||
this.productCatalogData.price = parseFloat(price);
|
||||
}
|
||||
|
||||
_updateQuantityAndGetPrice() {
|
||||
// Chain RPC calls to ensure that each request is completed before starting the next one.
|
||||
// This prevents race conditions and ensures the server processes updates sequentially.
|
||||
this._pendingUpdate = this._pendingUpdate.then(() => rpc(
|
||||
"/product/catalog/update_order_line_info",
|
||||
this._getUpdateQuantityAndGetPriceParams(),
|
||||
));
|
||||
return this._pendingUpdate;
|
||||
}
|
||||
|
||||
_getUpdateQuantityAndGetPriceParams() {
|
||||
return {
|
||||
order_id: this.env.orderId,
|
||||
product_id: this.env.productId,
|
||||
quantity: this.productCatalogData.quantity,
|
||||
res_model: this.env.orderResModel,
|
||||
child_field: this.env.childField,
|
||||
}
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
updateQuantity(quantity) {
|
||||
if (this.productCatalogData.readOnly) {
|
||||
return;
|
||||
}
|
||||
this.productCatalogData.quantity = quantity || 0;
|
||||
this.debouncedUpdateQuantity();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the product to the order
|
||||
*/
|
||||
addProduct(qty=1) {
|
||||
this.updateQuantity(qty);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the product to the order
|
||||
*/
|
||||
removeProduct() {
|
||||
this.updateQuantity(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increase the quantity of the product on the order line.
|
||||
*/
|
||||
increaseQuantity(qty=1) {
|
||||
this.updateQuantity(this.productCatalogData.quantity + qty);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the quantity of the product on the order line.
|
||||
*
|
||||
* @param {Event} event
|
||||
*/
|
||||
setQuantity(event) {
|
||||
this.updateQuantity(parseFloat(event.target.value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrease the quantity of the product on the order line.
|
||||
*/
|
||||
decreaseQuantity() {
|
||||
this.updateQuantity(parseFloat(this.productCatalogData.quantity - 1));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="ProductCatalogKanbanRecord">
|
||||
<article
|
||||
t-att-class="getRecordClasses() + (productCatalogData.quantity ? ' o_product_added' : '')"
|
||||
t-att-data-id="props.record.id"
|
||||
t-att-tabindex="props.record.model.useSampleModel ? -1 : 0"
|
||||
t-on-click="onGlobalClick"
|
||||
t-ref="root">
|
||||
<div class="d-flex flex-column h-100">
|
||||
<t t-call="{{ templates[this.constructor.KANBAN_CARD_ATTRIBUTE] }}"
|
||||
t-call-context="this.renderingContext"/>
|
||||
<t t-component="orderLineComponent" productId="props.record.resId" t-props="productCatalogData"/>
|
||||
</div>
|
||||
<t t-call="{{ this.constructor.menuTemplate }}"/>
|
||||
</article>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { KanbanRenderer } from "@web/views/kanban/kanban_renderer";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
import { ProductCatalogKanbanRecord } from "./kanban_record";
|
||||
|
||||
export class ProductCatalogKanbanRenderer extends KanbanRenderer {
|
||||
static template = "ProductCatalogKanbanRenderer";
|
||||
static components = {
|
||||
...KanbanRenderer.components,
|
||||
KanbanRecord: ProductCatalogKanbanRecord,
|
||||
};
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.action = useService("action");
|
||||
}
|
||||
|
||||
get createProductContext() {
|
||||
return {};
|
||||
}
|
||||
|
||||
async createProduct() {
|
||||
await this.action.doAction(
|
||||
{
|
||||
type: "ir.actions.act_window",
|
||||
res_model: "product.product",
|
||||
target: "new",
|
||||
views: [[false, "form"]],
|
||||
view_mode: "form",
|
||||
context: this.createProductContext,
|
||||
},
|
||||
{
|
||||
onClose: () => this.props.list.model.load(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="ProductCatalogKanbanRenderer" t-inherit="web.KanbanRenderer" t-inherit-mode="primary">
|
||||
<t t-if="showNoContentHelper" position="replace">
|
||||
<t t-if="showNoContentHelper">
|
||||
<div class="o_view_nocontent" role="alert">
|
||||
<div class="o_nocontent_help">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No products could be found.<br/>
|
||||
<button class="mt-2 btn btn-primary" t-on-click="this.createProduct">Create a product</button>
|
||||
</p>
|
||||
<p>
|
||||
You must define a product for everything you sell or purchase,
|
||||
whether it's a storable product, a consumable or a service.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { kanbanView } from "@web/views/kanban/kanban_view";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
import { ProductCatalogKanbanController } from "./kanban_controller";
|
||||
import { ProductCatalogKanbanModel } from "./kanban_model";
|
||||
import { ProductCatalogKanbanRenderer } from "./kanban_renderer";
|
||||
|
||||
|
||||
export const productCatalogKanbanView = {
|
||||
...kanbanView,
|
||||
Controller: ProductCatalogKanbanController,
|
||||
Model: ProductCatalogKanbanModel,
|
||||
Renderer: ProductCatalogKanbanRenderer,
|
||||
};
|
||||
|
||||
registry.category("views").add("product_kanban_catalog", productCatalogKanbanView);
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import { Component } from "@odoo/owl";
|
||||
import { formatFloat, formatMonetary } from "@web/views/fields/formatters";
|
||||
|
||||
export class ProductCatalogOrderLine extends Component {
|
||||
static template = "product.ProductCatalogOrderLine";
|
||||
static props = {
|
||||
isSample: { type: Boolean, optional: true},
|
||||
productId: Number,
|
||||
quantity: Number,
|
||||
price: Number,
|
||||
productType: String,
|
||||
uomDisplayName: String,
|
||||
uomFactor: { type: Number, optional: true },
|
||||
code: { type: String, optional: true},
|
||||
readOnly: { type: Boolean, optional: true },
|
||||
warning: { type: String, optional: true},
|
||||
};
|
||||
|
||||
/**
|
||||
* Focus input text when clicked
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onFocus(ev) {
|
||||
ev.target.select();
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
isInOrder() {
|
||||
return this.props.quantity !== 0;
|
||||
}
|
||||
|
||||
get disableRemove() {
|
||||
return false;
|
||||
}
|
||||
|
||||
get disabledButtonTooltip() {
|
||||
return "";
|
||||
}
|
||||
|
||||
get price() {
|
||||
const { currencyId, digits } = this.env;
|
||||
return formatMonetary(this.props.price, { currencyId, digits });
|
||||
}
|
||||
|
||||
get quantity() {
|
||||
const digits = [false, this.env.precision];
|
||||
const options = { digits, decimalPoint: ".", thousandsSep: "" };
|
||||
return parseFloat(formatFloat(this.props.quantity, options));
|
||||
}
|
||||
|
||||
get showPrice() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
div.o_product_catalog_quantity {
|
||||
input[type="number"] {
|
||||
// Remove arrow buttons input type="number"
|
||||
appearance: textfield;
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_kanban_view .o_kanban_renderer .o_kanban_record.o_product_added {
|
||||
background-color: $o-component-active-bg;
|
||||
border-color: $o-component-active-border;
|
||||
z-index: 3; // Makes sure bottom border in groupby view is above
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="product.ProductCatalogOrderLine">
|
||||
<!-- Replace the element found using the css selector by the content of the portalled
|
||||
template. -->
|
||||
<t t-portal="`#product-${props.productId}-price`">
|
||||
<div class="d-inline-flex align-items-baseline">
|
||||
<span t-if="showPrice" t-out="price" class="o_product_catalog_price fw-bold me-4"/>
|
||||
<span t-if="props.code" t-out="props.code" class="text-muted"/>
|
||||
</div>
|
||||
</t>
|
||||
<div
|
||||
t-if="props.readOnly and props.warning"
|
||||
class="text-danger text-truncate my-2 pt-3 border-top">
|
||||
<i class="fa fa-warning" t-att-title="props.warning"/>
|
||||
<span
|
||||
class="px-1"
|
||||
t-att-title="props.warning"
|
||||
t-out="props.warning"/>
|
||||
</div>
|
||||
<span t-elif="props.readOnly" class="m-2 pt-3 border-top" t-out="props.warning">
|
||||
You can't edit this product in the catalog.
|
||||
</span>
|
||||
<div t-else="" class="d-flex justify-content-end align-items-center m-2">
|
||||
<div t-if="isInOrder()"
|
||||
class="input-group o_product_catalog_quantity o_product_catalog_cancel_global_click w-50">
|
||||
<div class="d-flex">
|
||||
<button class="btn btn-primary border"
|
||||
t-on-click.stop="this.env.decreaseQuantity"
|
||||
t-att-disabled="disableRemove"
|
||||
t-att-data-tooltip="disabledButtonTooltip">
|
||||
<i class="fa fa-minus center"/>
|
||||
</button>
|
||||
<div class="d-flex w-100">
|
||||
<input class="o_input text-center text-bg-light rounded-0 border border-end-0"
|
||||
type="number"
|
||||
t-att-class="this.env.displayUoM ? 'w-50' : 'w-100'"
|
||||
t-att-value="quantity"
|
||||
t-on-change="this.env.setQuantity"
|
||||
t-on-focus="_onFocus"/>
|
||||
<span class="fst-italic text-muted text-bg-light text-truncate w-50 border border-start-0 py-1" t-if="this.env.displayUoM" t-out="props.uomDisplayName"/>
|
||||
</div>
|
||||
<button class="btn btn-primary border"
|
||||
t-on-click.stop="(ev) => this.env.increaseQuantity()">
|
||||
<i class="fa fa-plus"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<t t-elif="props.warning">
|
||||
<i class="fa fa-warning text-warning" t-att-title="props.warning"/>
|
||||
<span
|
||||
class="text-truncate text-warning px-1"
|
||||
t-att-title="props.warning"
|
||||
t-out="props.warning"/>
|
||||
</t>
|
||||
<div
|
||||
class="ms-auto o_product_catalog_buttons o_product_catalog_cancel_global_click"
|
||||
style="min-width: max-content;">
|
||||
<button t-if="!isInOrder()"
|
||||
t-on-click.stop="() => this.env.addProduct()"
|
||||
class="btn btn-secondary">
|
||||
<i class="fa fa-shopping-cart"/>
|
||||
Add
|
||||
</button>
|
||||
<div t-else="" class="o_tooltip_div_remove" t-att-data-tooltip="this.disabledButtonTooltip">
|
||||
<button t-on-click.stop="this.env.removeProduct"
|
||||
t-att-disabled="disableRemove"
|
||||
class="btn btn-light border">
|
||||
<i class="fa fa-trash"/>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { useAutoresize } from "@web/core/utils/autoresize";
|
||||
|
||||
/**
|
||||
* This overriden version of the resizeTextArea method is specificly done for the product_label_section_and_note widget
|
||||
* His necessity is found in the fact that the cell of said widget doesn't contain only the input or textarea to resize
|
||||
* but also another node containing the name of the product if said data is available. This means that the autoresize
|
||||
* method which sets the height of the parent cell should sometimes add an additional row to the parent cell so that
|
||||
* no text overflows
|
||||
*
|
||||
* @param {Ref} ref
|
||||
*/
|
||||
export function useProductAndLabelAutoresize(ref, options = {}) {
|
||||
useAutoresize(ref, {
|
||||
onMounted: productAndLabelResizeTextArea,
|
||||
onResize: productAndLabelResizeTextArea,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export function productAndLabelResizeTextArea(textarea, options = {}) {
|
||||
const style = window.getComputedStyle(textarea);
|
||||
if (options.targetParentName) {
|
||||
let target = textarea.parentElement;
|
||||
let shouldContinue = true;
|
||||
while (target && shouldContinue) {
|
||||
const totalParentHeight = Array.from(target.children).reduce((total, child) => {
|
||||
const childHeight = child.style.height || style.lineHeight;
|
||||
return total + parseFloat(childHeight);
|
||||
}, 0);
|
||||
target.style.height = `${totalParentHeight}px`;
|
||||
if (target.getAttribute("name") === options.targetParentName) {
|
||||
shouldContinue = false;
|
||||
}
|
||||
target = target.parentElement;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service";
|
||||
import { Component, onMounted, onPatched, onWillUnmount, useEffect, useRef, useState } from "@odoo/owl";
|
||||
import { Many2OneField } from "@web/views/fields/many2one/many2one_field";
|
||||
import { useProductAndLabelAutoresize } from "./product_and_label_autoresize";
|
||||
import { computeM2OProps, Many2One } from "@web/views/fields/many2one/many2one";
|
||||
import { useInputField } from "@web/views/fields/input_field_hook";
|
||||
|
||||
export const ProductNameAndDescriptionListRendererMixin = {
|
||||
getCellTitle(column, record) {
|
||||
// When using this list renderer, we don't want the product_id cell to have a tooltip with its label.
|
||||
if (this.productColumns.includes(column.name)) {
|
||||
return;
|
||||
}
|
||||
return super.getCellTitle(column, record);
|
||||
},
|
||||
|
||||
getActiveColumns() {
|
||||
let activeColumns = super.getActiveColumns();
|
||||
const productCol = activeColumns.find((col) => this.productColumns.includes(col.name));
|
||||
const labelCol = activeColumns.find((col) => col.name === this.descriptionColumn);
|
||||
|
||||
if (productCol) {
|
||||
if (labelCol) {
|
||||
this.props.list.records.forEach((record) => (record.columnIsProductAndLabel = true));
|
||||
} else {
|
||||
this.props.list.records.forEach((record) => (record.columnIsProductAndLabel = false));
|
||||
}
|
||||
activeColumns = activeColumns.filter((col) => col.name !== this.descriptionColumn);
|
||||
this.titleField = productCol.name;
|
||||
} else {
|
||||
this.titleField = "name";
|
||||
}
|
||||
|
||||
return activeColumns;
|
||||
}
|
||||
};
|
||||
|
||||
export class ProductNameAndDescriptionField extends Component {
|
||||
static components = { Many2One };
|
||||
static props = { ...Many2OneField.props };
|
||||
static template = Many2One.template;
|
||||
|
||||
static descriptionColumn = "";
|
||||
|
||||
setup() {
|
||||
this.isPrintMode = useState({ value: false });
|
||||
this.labelVisibility = useState({ value: false });
|
||||
this.switchToLabel = false;
|
||||
this.columnIsProductAndLabel = useState({ value: this.props.record.columnIsProductAndLabel });
|
||||
this.labelNode = useRef("labelNodeRef");
|
||||
useProductAndLabelAutoresize(this.labelNode, { targetParentName: this.props.name });
|
||||
this.productNode = useRef("productNodeRef");
|
||||
useProductAndLabelAutoresize(this.productNode, { targetParentName: this.props.name });
|
||||
|
||||
this.descriptionColumn = this.constructor.descriptionColumn;
|
||||
useInputField({
|
||||
ref: this.labelNode,
|
||||
fieldName: this.descriptionColumn,
|
||||
getValue: () => this.label,
|
||||
parse: (v) => this.parseLabel(v),
|
||||
});
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
this.columnIsProductAndLabel.value = this.props.record.columnIsProductAndLabel;
|
||||
},
|
||||
() => [this.props.record.columnIsProductAndLabel]
|
||||
);
|
||||
|
||||
onPatched(() => {
|
||||
if (this.labelNode.el && this.switchToLabel) {
|
||||
this.switchToLabel = false;
|
||||
this.labelNode.el.focus();
|
||||
}
|
||||
});
|
||||
|
||||
this.onBeforePrint = () => {
|
||||
this.isPrintMode.value = true;
|
||||
};
|
||||
|
||||
this.onAfterPrint = () => {
|
||||
this.isPrintMode.value = false;
|
||||
};
|
||||
|
||||
// The following hooks are used to make a div visible only in the print view. This div is necessary in the
|
||||
// print view in order not to have scroll bars but can't be displayed in the normal view because it adds
|
||||
// an empty line. This is done by switching an attribute to true only during the print view life cycle and
|
||||
// including the said div in a t-if depending on that attribute.
|
||||
onMounted(() => {
|
||||
window.addEventListener("beforeprint", this.onBeforePrint);
|
||||
window.addEventListener("afterprint", this.onAfterPrint);
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
window.removeEventListener("beforeprint", this.onBeforePrint);
|
||||
window.removeEventListener("afterprint", this.onAfterPrint);
|
||||
});
|
||||
}
|
||||
|
||||
get productName() {
|
||||
return this.props.record.data[this.props.name].display_name || "";
|
||||
}
|
||||
|
||||
get label() {
|
||||
let label = this.props.record.data[this.descriptionColumn];
|
||||
if (label.includes(this.productName)) {
|
||||
label = label.replace(this.productName, "");
|
||||
}
|
||||
return label.trim();
|
||||
}
|
||||
|
||||
get m2oProps() {
|
||||
const p = computeM2OProps(this.props);
|
||||
let value = p.value && { ...p.value };
|
||||
if (this.props.readonly && this.productName) {
|
||||
value = { ...value, display_name: this.productName };
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
canOpen: !this.props.readonly || this.isProductClickable,
|
||||
placeholder: _t("Search a product"),
|
||||
preventMemoization: true,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
get isProductClickable() {
|
||||
return this.props.record.evalContext.parent.state !== "draft";
|
||||
}
|
||||
|
||||
get showLabelVisibilityToggler() {
|
||||
return !this.props.readonly && this.columnIsProductAndLabel.value && !this.label;
|
||||
}
|
||||
|
||||
switchLabelVisibility() {
|
||||
this.labelVisibility.value = !this.labelVisibility.value;
|
||||
this.switchToLabel = true;
|
||||
}
|
||||
|
||||
parseLabel(value) {
|
||||
return value || this.productName;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
onM2oInputKeydown(ev) {
|
||||
const hotkey = getActiveHotkey(ev);
|
||||
if (hotkey === "enter" && this.showLabelVisibilityToggler) {
|
||||
this.switchLabelVisibility();
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.oe_title .o_favorite i.fa {
|
||||
font-size: inherit;
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
|
||||
<t t-name="product.report_pricelist_qty">
|
||||
<span>
|
||||
<div class="input-group flex-nowrap w-75">
|
||||
<input type="number" name="qty_to_add" class="o_input o_product_qty form-control text-end w-auto" value="1" min="1"/>
|
||||
<button class="btn btn-secondary o_add_qty text-end form-control" type="submit" title="Add a quantity">
|
||||
<i class="fa fa-plus"/>
|
||||
</button>
|
||||
</div>
|
||||
<span class="o_badges">
|
||||
<t t-set="quantities" t-value="widget.quantities"/>
|
||||
<t t-call="product.report_pricelist_qty_badges"/>
|
||||
</span>
|
||||
</span>
|
||||
</t>
|
||||
|
||||
<t t-name="product.report_pricelist_search">
|
||||
<form class="d-flex justify-content-around align-items-center o_pricelist_report_form">
|
||||
<div>
|
||||
<label class="fw-bold">Pricelist:</label>
|
||||
<span class="o_pricelist"/>
|
||||
</div>
|
||||
<div class="d-flex align-items-center">
|
||||
<label class="fw-bold mb-4" for="qty_to_add">Quantities:</label>
|
||||
<div class="o_product_qty"/>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input o_is_visible_title ms-2" type="checkbox"/>
|
||||
<label class="form-check-label">Display Pricelist</label>
|
||||
</div>
|
||||
</form>
|
||||
</t>
|
||||
|
||||
<t t-name="product.report_pricelist_qty_badges">
|
||||
<t t-foreach="quantities" t-as="qty">
|
||||
<span class="badge rounded-pill border" t-att-data-qty="qty">
|
||||
<t t-esc="qty"/>
|
||||
<i class="fa fa-close o_remove_qty" title="Remove quantity"/>
|
||||
</span>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { models } from '@web/../tests/web_test_helpers';
|
||||
import { ProductProduct as ProductModel } from './product_product';
|
||||
|
||||
export class ProductProduct extends ProductModel {
|
||||
_records = [
|
||||
{ id: 1, name: "Black chair", type: 'goods', list_price: 50.0 },
|
||||
{ id: 2, name: "Blue chair", type: 'goods', list_price: 60.0 },
|
||||
{ id: 3, name: "Black table", type: 'goods', list_price: 70.0 },
|
||||
{ id: 4, name: "Blue table", type: 'goods', list_price: 80.0 },
|
||||
{ id: 5, name: "Test Combo", type: 'combo', combo_ids: [1, 2] },
|
||||
];
|
||||
}
|
||||
|
||||
export class ProductComboItem extends models.ServerModel {
|
||||
_name = 'product.combo.item';
|
||||
_records = [
|
||||
{ id: 1, product_id: 1 },
|
||||
{ id: 2, product_id: 2 },
|
||||
{ id: 3, product_id: 3 },
|
||||
{ id: 4, product_id: 4 },
|
||||
];
|
||||
}
|
||||
|
||||
export class ProductCombo extends models.ServerModel {
|
||||
_name = 'product.combo';
|
||||
_records = [
|
||||
{ id: 1, name: "Chair combo", combo_item_ids: [1, 2] },
|
||||
{ id: 2, name: "Table combo", combo_item_ids: [3, 4] },
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { models } from "@web/../tests/web_test_helpers";
|
||||
|
||||
|
||||
export class ProductProduct extends models.ServerModel {
|
||||
_name = "product.product";
|
||||
|
||||
_records = [
|
||||
{id: 1, name: "Test Product", type: "consu", list_price: 20.0},
|
||||
{id: 2, name: "Test Service Product", type: "service", list_price: 50.0},
|
||||
{id: 14, name: "desk"},
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { models } from "@web/../tests/web_test_helpers";
|
||||
|
||||
|
||||
export class ProductTemplate extends models.ServerModel {
|
||||
_name = "product.template";
|
||||
|
||||
get_single_product_variant() {
|
||||
return { product_id: 14, product_name: "desk" };
|
||||
}
|
||||
|
||||
_records = [{ id: 12, name: "desk" }];
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { defineModels } from '@web/../tests/web_test_helpers';
|
||||
import {
|
||||
ProductCombo,
|
||||
ProductComboItem,
|
||||
ProductProduct,
|
||||
} from './mock_server/mock_models/product_combo';
|
||||
|
||||
export const comboModels = {
|
||||
ProductCombo,
|
||||
ProductComboItem,
|
||||
ProductProduct,
|
||||
}
|
||||
|
||||
export function defineComboModels() {
|
||||
defineModels(comboModels);
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import { defineMailModels } from "@mail/../tests/mail_test_helpers";
|
||||
import { expect, test } from "@odoo/hoot";
|
||||
import { queryAllTexts } from "@odoo/hoot-dom";
|
||||
import { contains, defineModels, fields, getService, models, mountWebClient, onRpc } from "@web/../tests/web_test_helpers";
|
||||
|
||||
class ProductProduct extends models.Model {
|
||||
_records = [{ id: 42, name: "Customizable Desk" }];
|
||||
|
||||
name = fields.Char();
|
||||
}
|
||||
|
||||
class ProductPricelist extends models.Model {
|
||||
_records = [
|
||||
{ id: 1, name: "Public Pricelist" },
|
||||
{ id: 2, name: "Test" },
|
||||
];
|
||||
|
||||
name = fields.Char();
|
||||
}
|
||||
|
||||
defineModels([ProductProduct, ProductPricelist]);
|
||||
defineMailModels();
|
||||
|
||||
test(`Pricelist Client Action`, async () => {
|
||||
onRpc("report.product.report_pricelist", "get_html", async () => "");
|
||||
|
||||
await mountWebClient();
|
||||
await getService("action").doAction({
|
||||
id: 1,
|
||||
name: "Generate Pricelist Report",
|
||||
tag: "generate_pricelist_report",
|
||||
type: "ir.actions.client",
|
||||
context: {
|
||||
active_ids: [42],
|
||||
active_model: "product.product",
|
||||
},
|
||||
});
|
||||
|
||||
// checking default pricelist
|
||||
expect(`select#pricelists > option:eq(0)`).toHaveText("Public Pricelist", {
|
||||
message: "should have default pricelist",
|
||||
});
|
||||
|
||||
// changing pricelist
|
||||
await contains(`select#pricelists`).select("2");
|
||||
|
||||
// check whether pricelist value has been updated or not
|
||||
expect(`select#pricelists > option:eq(0)`).toHaveText("Test", {
|
||||
message: "After pricelist change, the pricelist_id field should be updated",
|
||||
});
|
||||
|
||||
// check default quantities should be there
|
||||
expect(queryAllTexts(`.o_badges_list .badge`)).toEqual(["1", "5", "10"]);
|
||||
|
||||
// existing quantity can not be added.
|
||||
await contains(`.o_add_qty`).click();
|
||||
expect(queryAllTexts(`.o_badges_list .badge`)).toEqual(["1", "5", "10"]);
|
||||
expect(`.o_notification`).toHaveCount(1);
|
||||
expect(`.o_notification .o_notification_content`).toHaveText(
|
||||
"Quantity already present (1).",
|
||||
{ message: "Existing Quantity can not be added" }
|
||||
);
|
||||
expect(`.o_notification .o_notification_bar`).toHaveClass("bg-info");
|
||||
await contains(`.o_notification_close`).click();
|
||||
expect(`.o_notification`).toHaveCount(0);
|
||||
|
||||
// adding few more quantities to check.
|
||||
await contains(`.add-quantity-input`).edit("2", { confirm: false });
|
||||
await contains(`.o_add_qty`).click();
|
||||
expect(queryAllTexts(`.o_badges_list .badge`)).toEqual(["1", "2", "5", "10"]);
|
||||
expect(`.o_notification`).toHaveCount(0);
|
||||
|
||||
await contains(`.add-quantity-input`).edit("3", { confirm: false });
|
||||
await contains(`.o_add_qty`).click();
|
||||
expect(queryAllTexts(`.o_badges_list .badge`)).toEqual(["1", "2", "3", "5", "10"]);
|
||||
expect(`.o_notification`).toHaveCount(0);
|
||||
|
||||
// no more than 5 quantities can be used at a time
|
||||
await contains(`.add-quantity-input`).edit("4", { confirm: false });
|
||||
await contains(`.o_add_qty`).click();
|
||||
expect(queryAllTexts(`.o_badges_list .badge`)).toEqual(["1", "2", "3", "5", "10"]);
|
||||
expect(`.o_notification`).toHaveCount(1);
|
||||
expect(`.o_notification .o_notification_content`).toHaveText(
|
||||
"At most 5 quantities can be displayed simultaneously. Remove a selected quantity to add others.",
|
||||
{ message: "Can not add more then 5 quantities" }
|
||||
);
|
||||
expect(`.o_notification .o_notification_bar`).toHaveClass("bg-warning");
|
||||
});
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
odoo.define('product.pricelist.report.tests', function (require) {
|
||||
"use strict";
|
||||
const GeneratePriceList = require('product.generate_pricelist').GeneratePriceList;
|
||||
const testUtils = require('web.test_utils');
|
||||
|
||||
const { createWebClient, doAction } = require('@web/../tests/webclient/helpers');
|
||||
const { getFixture, patchWithCleanup } = require("@web/../tests/helpers/utils");
|
||||
|
||||
let serverData;
|
||||
|
||||
QUnit.module('Product Pricelist', {
|
||||
beforeEach: function () {
|
||||
this.data = {
|
||||
'product.product': {
|
||||
fields: {
|
||||
id: {type: 'integer'}
|
||||
},
|
||||
records: [{
|
||||
id: 42,
|
||||
display_name: "Customizable Desk"
|
||||
}]
|
||||
},
|
||||
'product.pricelist': {
|
||||
fields: {
|
||||
id: {type: 'integer'}
|
||||
},
|
||||
records: [{
|
||||
id: 1,
|
||||
display_name: "Public Pricelist"
|
||||
}, {
|
||||
id: 2,
|
||||
display_name: "Test"
|
||||
}]
|
||||
}
|
||||
};
|
||||
serverData = { models: this.data };
|
||||
},
|
||||
}, function () {
|
||||
QUnit.test('Pricelist Client Action', async function (assert) {
|
||||
assert.expect(23);
|
||||
|
||||
let Qty = [1, 5, 10]; // default quantities
|
||||
patchWithCleanup(GeneratePriceList.prototype, {
|
||||
_onFieldChanged: function (event) {
|
||||
assert.step('field_changed');
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
_onQtyChanged: function (event) {
|
||||
assert.deepEqual(event.data.quantities, Qty.sort((a, b) => a - b), "changed quantity should be same.");
|
||||
assert.step('qty_changed');
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
const mockRPC = (route, args) => {
|
||||
if (route === '/web/dataset/call_kw/report.product.report_pricelist/get_html') {
|
||||
return Promise.resolve("");
|
||||
}
|
||||
};
|
||||
|
||||
const target = getFixture();
|
||||
const webClient = await createWebClient({ serverData, mockRPC });
|
||||
await doAction(webClient, {
|
||||
id: 1,
|
||||
name: 'Generate Pricelist',
|
||||
tag: 'generate_pricelist',
|
||||
type: 'ir.actions.client',
|
||||
context: {
|
||||
'default_pricelist': 1,
|
||||
'active_ids': [42],
|
||||
'active_id': 42,
|
||||
'active_model': 'product.product'
|
||||
}
|
||||
});
|
||||
|
||||
// checking default pricelist
|
||||
assert.strictEqual($(target).find('.o_field_many2one input').val(), "Public Pricelist",
|
||||
"should have default pricelist");
|
||||
|
||||
// changing pricelist
|
||||
await testUtils.fields.many2one.clickOpenDropdown("pricelist_id");
|
||||
await testUtils.fields.many2one.clickItem("pricelist_id", "Test");
|
||||
|
||||
// check wherther pricelist value has been updated or not. along with that check default quantities should be there.
|
||||
assert.strictEqual($(target).find('.o_field_many2one input').val(), "Test",
|
||||
"After pricelist change, the pricelist_id field should be updated");
|
||||
assert.strictEqual($(target).find('.o_badges > .badge').length, 3,
|
||||
"There should be 3 default Quantities");
|
||||
|
||||
// existing quantity can not be added.
|
||||
await testUtils.dom.click($(target).find('.o_add_qty'));
|
||||
let notificationElement = document.body.querySelector('.o_notification_manager .o_notification');
|
||||
assert.strictEqual(notificationElement.querySelector('.o_notification_content').textContent,
|
||||
"Quantity already present (1).", "Existing Quantity can not be added");
|
||||
assert.hasClass(notificationElement, "border-info");
|
||||
|
||||
// adding few more quantities to check.
|
||||
$(target).find('.o_product_qty').val(2);
|
||||
Qty.push(2);
|
||||
await testUtils.dom.click($(target).find('.o_add_qty'));
|
||||
$(target).find('.o_product_qty').val(3);
|
||||
Qty.push(3);
|
||||
await testUtils.dom.click($(target).find('.o_add_qty'));
|
||||
|
||||
// should not be added more then 5 quantities.
|
||||
$(target).find('.o_product_qty').val(4);
|
||||
await testUtils.dom.click($(target).find('.o_add_qty'));
|
||||
|
||||
notificationElement = document.body.querySelector('.o_notification_manager .o_notification:nth-child(2)');
|
||||
assert.strictEqual(notificationElement.querySelector('.o_notification_content').textContent,
|
||||
"At most 5 quantities can be displayed simultaneously. Remove a selected quantity to add others.",
|
||||
"Can not add more then 5 quantities");
|
||||
assert.hasClass(notificationElement, "border-warning");
|
||||
// removing all the quantities should work
|
||||
Qty.pop(10);
|
||||
await testUtils.dom.click($(target).find('.o_badges .badge:contains("10") .o_remove_qty'));
|
||||
Qty.pop(5);
|
||||
await testUtils.dom.click($(target).find('.o_badges .badge:contains("5") .o_remove_qty'));
|
||||
Qty.pop(3);
|
||||
await testUtils.dom.click($(target).find('.o_badges .badge:contains("3") .o_remove_qty'));
|
||||
Qty.pop(2);
|
||||
await testUtils.dom.click($(target).find('.o_badges .badge:contains("2") .o_remove_qty'));
|
||||
Qty.pop(1);
|
||||
await testUtils.dom.click($(target).find('.o_badges .badge:contains("1") .o_remove_qty'));
|
||||
|
||||
assert.verifySteps([
|
||||
'field_changed',
|
||||
'qty_changed',
|
||||
'qty_changed',
|
||||
'qty_changed',
|
||||
'qty_changed',
|
||||
'qty_changed',
|
||||
'qty_changed',
|
||||
'qty_changed'
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { defineModels } from '@web/../tests/web_test_helpers';
|
||||
import { ProductProduct } from './mock_server/mock_models/product_product';
|
||||
import { ProductTemplate } from './mock_server/mock_models/product_template';
|
||||
|
||||
|
||||
export const productModels = {
|
||||
ProductProduct,
|
||||
ProductTemplate,
|
||||
};
|
||||
|
||||
export function defineProductModels() {
|
||||
defineModels(productModels);
|
||||
}
|
||||