19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:31:47 +01:00
parent accf5918df
commit 6e65e8c877
688 changed files with 225434 additions and 199401 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before After
Before After

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="70" height="70" viewBox="0 0 70 70"><defs><path id="a" d="M4 0h61c4 0 5 1 5 5v60c0 4-1 5-5 5H4c-3 0-4-1-4-5V5c0-4 1-5 4-5z"/><linearGradient id="c" x1="100%" x2="0%" y1="0%" y2="100%"><stop offset="0%" stop-color="#7CC098"/><stop offset="100%" stop-color="#5F8A71"/></linearGradient><path id="d" d="M53.373 32.454c.742 0 1.206.784.824 1.4-2.009 3.235-5.668 5.4-9.848 5.4-6.318 0-11.445-4.944-11.484-11.056C32.825 22.058 38.012 17 44.349 17c4.176 0 7.831 2.16 9.842 5.389.385.62-.07 1.41-.818 1.41h-8.386l-3.19 4.328 3.19 4.327h8.386zM39.684 39.64l-15.97 15.473c-1.994 1.931-5.226 1.931-7.22 0a4.837 4.837 0 0 1 0-6.994l15.971-15.472c1.289 3.183 3.932 5.744 7.219 6.993zm-16.39 10.74c0-1.024-.857-1.854-1.914-1.854s-1.914.83-1.914 1.854.857 1.854 1.914 1.854 1.914-.83 1.914-1.854z"/><path id="e" d="M53.373 30.454c.742 0 1.206.784.824 1.4-2.009 3.235-5.668 5.4-9.848 5.4-6.318 0-11.445-4.944-11.484-11.056C32.825 20.058 38.012 15 44.349 15c4.176 0 7.831 2.16 9.842 5.389.385.62-.07 1.41-.818 1.41h-8.386l-3.19 4.328 3.19 4.327h8.386zM39.684 37.64l-15.97 15.473c-1.994 1.931-5.226 1.931-7.22 0a4.837 4.837 0 0 1 0-6.994l15.971-15.472c1.289 3.183 3.932 5.744 7.219 6.993zm-16.39 10.74c0-1.024-.857-1.854-1.914-1.854s-1.914.83-1.914 1.854.857 1.854 1.914 1.854 1.914-.83 1.914-1.854z"/></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-4V36.075l15.053-15.2 18.452.218 2.734 6.93v4.524L53.62 51.98 42.667 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" transform="matrix(-1 0 0 1 69.334 0)" xlink:href="#d"/><use fill="#FFF" fill-rule="nonzero" transform="matrix(-1 0 0 1 69.334 0)" xlink:href="#e"/></g></g></svg>
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><path d="M50 19.023c0-.735-.737-1.23-1.398-.938l-28 12.403a1.026 1.026 0 0 0-.602.939v9.55c0 .565.448 1.023 1 1.023h25c2.21 0 4-1.83 4-4.089V19.023Z" fill="#FBB945"/><path d="M42 14.018c0-.722-.719-1.214-1.376-.942l-30 12.394a1.017 1.017 0 0 0-.624.943v14.57c0 .562.448 1.017 1 1.017h27c2.21 0 4-1.821 4-4.068V14.018Z" fill="#FC868B"/><path d="m42 21.01-21.398 9.478c-.365.162-.602.53-.602.938v9.552c0 .564.448 1.022 1 1.022h17c2.21 0 4-1.822 4-4.068V21.01Z" fill="#F86126"/><path d="M32 9.015c0-.72-.719-1.21-1.376-.94l-30 12.364a1.015 1.015 0 0 0-.624.94v19.607C0 41.546.448 42 1 42h27c2.21 0 4-1.817 4-4.058V9.015Z" fill="#1AD3BB"/><path d="M32 16.639 10.624 25.47a1.017 1.017 0 0 0-.624.943v14.57c0 .562.448 1.017 1 1.017h17c2.21 0 4-1.817 4-4.058V16.64Z" fill="#1A6F66"/></svg>

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 866 B

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

View file

@ -0,0 +1,5 @@
<svg width="1920" height="1024" fill="none" id="svg" xmlns="http://www.w3.org/2000/svg">
<rect x="62" y="227" width="196" height="173" rx="10" ry="10" fill="grey" transform="skewY(-25.5),skewX(25.5)"/>
<polygon points="254,272 190,250 255,220" rx="10" ry="10" stroke-linejoin="round" stroke-width="5" stroke="grey"/>
</svg>

After

Width:  |  Height:  |  Size: 325 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View file

@ -1,13 +1,17 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { useService, useBus } from "@web/core/utils/hooks";
import { BomOverviewControlPanel } from "../bom_overview_control_panel/mrp_bom_overview_control_panel";
import { BomOverviewTable } from "../bom_overview_table/mrp_bom_overview_table";
const { Component, EventBus, onWillStart, useSubEnv, useState } = owl;
import { Component, EventBus, onWillStart, useSubEnv, useState } from "@odoo/owl";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
export class BomOverviewComponent extends Component {
static template = "mrp.BomOverviewComponent";
static components = {
BomOverviewControlPanel,
BomOverviewTable,
};
static props = { ...standardActionServiceProps };
setup() {
this.orm = useService("orm");
this.context = this.props.action.context;
@ -17,16 +21,12 @@ export class BomOverviewComponent extends Component {
this.warehouses = [];
this.showVariants = false;
this.uomName = "";
this.extraColumnCount = 0;
this.unfoldedIds = new Set();
this.state = useState({
showOptions: {
mode: this.props.action.context.mode || 'overview',
uom: false,
availabilities: true,
costs: true,
operations: true,
leadTimes: true,
attachments: false,
},
currentWarehouse: null,
@ -34,12 +34,20 @@ export class BomOverviewComponent extends Component {
bomData: {},
precision: 2,
bomQuantity: null,
foldable: true,
allFolded: true,
});
useSubEnv({
overviewBus: new EventBus(),
});
useBus(
this.env.overviewBus,
"toggle-fold-all",
() => (this.state.allFolded = !this.state.allFolded)
);
onWillStart(async () => {
await this.getWarehouses();
await this.initBomData();
@ -49,6 +57,13 @@ export class BomOverviewComponent extends Component {
//---- Data ----
async initBomData() {
const variantId = this.props.action.context.active_product_id;
const resModel = this.props.action.context.active_model;
this.state.currentVariantId = false;
if (resModel === 'product.product' && variantId !== undefined) {
this.state.currentVariantId = variantId;
}
const bomData = await this.getBomData();
this.state.bomQuantity = bomData["bom_qty"];
this.state.showOptions.uom = bomData["is_uom_applied"];
@ -56,9 +71,10 @@ export class BomOverviewComponent extends Component {
this.variants = bomData["variants"];
this.showVariants = bomData["is_variant_applied"];
if (this.showVariants) {
this.state.currentVariantId = Object.keys(this.variants)[0];
this.state.currentVariantId ||= this.state.bomData.product_id;
}
this.state.precision = bomData["precision"];
this.state.foldable = bomData["lines"]["foldable"];
}
async getBomData() {
@ -69,7 +85,7 @@ export class BomOverviewComponent extends Component {
];
const context = { ...this.context};
if (this.state.currentWarehouse) {
context.warehouse = this.state.currentWarehouse.id;
context.warehouse_id = this.state.currentWarehouse.id;
}
const bomData = await this.orm.call(
"report.mrp.report_bom_structure",
@ -99,8 +115,8 @@ export class BomOverviewComponent extends Component {
ids.forEach(id => this.unfoldedIds[operation](id));
}
onChangeDisplay(displayInfo) {
this.state.showOptions[displayInfo] = !this.state.showOptions[displayInfo];
onChangeMode(mode) {
this.state.showOptions.mode = mode;
}
async onChangeBomQuantity(newQuantity) {
@ -109,7 +125,7 @@ export class BomOverviewComponent extends Component {
await this.getBomData();
}
}
async onChangeVariant(variantId) {
if (this.state.currentVariantId != variantId) {
this.state.currentVariantId = variantId;
@ -143,10 +159,7 @@ export class BomOverviewComponent extends Component {
getReportName(printAll) {
let reportName = "mrp.report_bom_structure?docids=" + this.activeId +
"&availabilities=" + this.state.showOptions.availabilities +
"&costs=" + this.state.showOptions.costs +
"&operations=" + this.state.showOptions.operations +
"&lead_times=" + this.state.showOptions.leadTimes +
"&mode=" + this.state.showOptions.mode +
"&quantity=" + (this.state.bomQuantity || 1) +
"&unfolded_ids=" + JSON.stringify(Array.from(this.unfoldedIds)) +
"&warehouse_id=" + (this.state.currentWarehouse ? this.state.currentWarehouse.id : false);
@ -159,10 +172,4 @@ export class BomOverviewComponent extends Component {
}
}
BomOverviewComponent.template = "mrp.BomOverviewComponent";
BomOverviewComponent.components = {
BomOverviewControlPanel,
BomOverviewTable,
};
registry.category("actions").add("mrp_bom_report", BomOverviewComponent);

View file

@ -1,11 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<div t-name="mrp.BomOverviewComponent" class="o_action" owl="1">
<div t-name="mrp.BomOverviewComponent" class="o_action o_mrp_overview">
<BomOverviewControlPanel
bomQuantity="state.bomQuantity"
uomName="uomName"
variants="variants"
data="state.bomData"
showOptions="state.showOptions"
showVariants="showVariants"
currentWarehouse="state.currentWarehouse"
@ -14,8 +16,10 @@
changeWarehouse.bind="onChangeWarehouse"
changeVariant.bind="onChangeVariant"
changeBomQuantity.bind="onChangeBomQuantity"
changeDisplay.bind="onChangeDisplay"
changeMode.bind="onChangeMode"
precision="state.precision"
foldable="state.foldable"
allFolded="state.allFolded"
/>
<BomOverviewTable
@ -24,6 +28,7 @@
currentWarehouseId="state.currentWarehouse.id"
data="state.bomData"
precision="state.precision"
bomQuantity="state.bomQuantity"
changeFolded.bind="onChangeFolded"/>
</div>

View file

@ -1,12 +1,27 @@
/** @odoo-module **/
import { useBus } from "@web/core/utils/hooks";
import { BomOverviewLine } from "../bom_overview_line/mrp_bom_overview_line";
import { BomOverviewExtraBlock } from "../bom_overview_extra_block/mrp_bom_overview_extra_block";
const { Component, onWillUnmount, onWillUpdateProps, useState } = owl;
import { Component, onWillUnmount, onWillUpdateProps, useState } from "@odoo/owl";
export class BomOverviewComponentsBlock extends Component {
static template = "mrp.BomOverviewComponentsBlock";
static components = {
BomOverviewLine,
BomOverviewComponentsBlock,
BomOverviewExtraBlock,
};
static props = {
unfoldAll: { type: Boolean, optional: true },
showOptions: Object,
currentWarehouseId: { type: Number, optional: true },
data: Object,
precision: Number,
changeFolded: Function,
};
static defaultProps = {
unfoldAll: false,
};
setup() {
const childFoldstate = this.childIds.reduce((prev, curr) => ({ ...prev, [curr]: !this.props.unfoldAll}), {});
this.state = useState({
@ -18,7 +33,7 @@ export class BomOverviewComponentsBlock extends Component {
}
if (this.hasComponents) {
useBus(this.env.overviewBus, "unfold-all", () => this._unfoldAll());
useBus(this.env.overviewBus, "toggle-fold-all", () => this._toggleFoldAll());
}
onWillUpdateProps(newProps => {
@ -45,11 +60,12 @@ export class BomOverviewComponentsBlock extends Component {
this.props.changeFolded({ ids: [foldId], isFolded: newState });
}
_unfoldAll() {
_toggleFoldAll() {
const allChildIds = this.childIds;
this.state.unfoldAll = true;
allChildIds.forEach(id => this.state[id] = false);
this.props.changeFolded({ ids: allChildIds, isFolded: false });
this.state.unfoldAll = !this.state.unfoldAll;
allChildIds.forEach(id => this.state[id] = !this.state.unfoldAll);
this.props.changeFolded({ ids: allChildIds, isFolded: !this.state.unfoldAll });
}
//---- Getters ----
@ -64,16 +80,12 @@ export class BomOverviewComponentsBlock extends Component {
get childIds() {
return this.hasComponents ? this.data.components.map(c => this.getIdentifier(c)) : [];
}
}
get identifier() {
return this.getIdentifier(this.data);
}
get showOperations() {
return this.props.showOptions.operations;
}
//---- Utils ----
getHasComponents(data) {
@ -84,21 +96,3 @@ export class BomOverviewComponentsBlock extends Component {
return `${type ? type : data.type}_${data.index}`;
}
}
BomOverviewComponentsBlock.template = "mrp.BomOverviewComponentsBlock";
BomOverviewComponentsBlock.components = {
BomOverviewLine,
BomOverviewComponentsBlock,
BomOverviewExtraBlock,
};
BomOverviewComponentsBlock.props = {
unfoldAll: { type: Boolean, optional: true },
showOptions: Object,
currentWarehouseId: { type: Number, optional: true },
data: Object,
precision: Number,
changeFolded: Function,
};
BomOverviewComponentsBlock.defaultProps = {
unfoldAll: false,
};

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mrp.BomOverviewComponentsBlock" owl="1">
<t t-name="mrp.BomOverviewComponentsBlock">
<t name="components" t-if="hasComponents">
<t t-foreach="data.components" t-as="line" t-key="line.index">
<BomOverviewLine
@ -22,7 +22,7 @@
</t>
</t>
</t>
<t name="operations" t-if="showOperations &amp;&amp; !!data.operations &amp;&amp; data.operations.length > 0">
<t name="operations" t-if="!!data.operations &amp;&amp; data.operations.length > 0">
<BomOverviewExtraBlock
unfoldAll="state.unfoldAll"
type="'operations'"

View file

@ -1,17 +1,53 @@
/** @odoo-module **/
import { ControlPanel } from "@web/search/control_panel/control_panel";
import { BomOverviewDisplayFilter } from "../bom_overview_display_filter/mrp_bom_overview_display_filter";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
const { Component } = owl;
import { _t } from "@web/core/l10n/translation";
import { Many2XAutocomplete } from "@web/views/fields/relational_utils";
import { Component, onMounted, useRef } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class BomOverviewControlPanel extends Component {
static template = "mrp.BomOverviewControlPanel";
static components = {
Dropdown,
DropdownItem,
ControlPanel,
BomOverviewDisplayFilter,
Many2XAutocomplete,
};
static props = {
bomQuantity: Number,
showOptions: Object,
showVariants: { type: Boolean, optional: true },
variants: { type: Object, optional: true },
data: { type: Object, optional: true },
uomName: { type: String, optional: true },
currentWarehouse: Object,
warehouses: { type: Array, optional: true },
print: Function,
changeWarehouse: Function,
changeVariant: Function,
changeBomQuantity: Function,
changeMode: Function,
precision: Number,
foldable: Boolean,
allFolded: Boolean,
};
static defaultProps = {
variants: {},
warehouses: [],
};
setup() {
this.action = useService("action");
this.controlPanelDisplay = {};
// Cannot use 'control-panel-bottom-right' slot without this, as viewSwitcherEntries doesn't exist in this.env.config here.
this.env.config.viewSwitcherEntries = [];
if(this.props.showOptions.mode == "forecast") {
this.quantity = useRef("quantity");
onMounted(() => {
this.quantity.el.focus();
});
}
}
//---- Handlers ----
@ -22,45 +58,51 @@ export class BomOverviewControlPanel extends Component {
}
onKeyPress(ev) {
if (ev.keyCode === 13 || ev.which === 13) {
if (ev.key === "Enter") {
ev.preventDefault();
this.updateQuantity(ev);
}
}
clickUnfold() {
this.env.overviewBus.trigger("unfold-all");
clickTogglefold() {
this.env.overviewBus.trigger("toggle-fold-all");
}
getDomain() {
const keys = Object.keys(this.props.variants);
return [['id', 'in', keys]];
}
async manufactureFromBoM() {
const action = {
res_model: "mrp.production",
name: "Manufacture Orders",
type: "ir.actions.act_window",
views: [[false, "form"]],
target: "current",
context: {
default_bom_id: this.props.data.bom_id,
bom_overview_picking_type_id: this.props.currentWarehouse.manu_type_id[0],
bom_overview_product_qty: this.props.bomQuantity,
},
};
return this.action.doAction(action);
}
get foldButtonText() {
return this.props.allFolded ? _t("Unfold") : _t("Fold");
}
get precision() {
return this.props.precision;
}
}
BomOverviewControlPanel.template = "mrp.BomOverviewControlPanel";
BomOverviewControlPanel.components = {
Dropdown,
DropdownItem,
ControlPanel,
BomOverviewDisplayFilter,
};
BomOverviewControlPanel.props = {
bomQuantity: Number,
showOptions: Object,
showVariants: { type: Boolean, optional: true },
variants: { type: Object, optional: true },
showUom: { type: Boolean, optional: true },
uomName: { type: String, optional: true },
currentWarehouse: Object,
warehouses: { type: Array, optional: true },
print: Function,
changeWarehouse: Function,
changeVariant: Function,
changeBomQuantity: Function,
changeDisplay: Function,
precision: Number,
};
BomOverviewControlPanel.defaultProps = {
variants: {},
warehouses: [],
};
get warehousesItems() {
return this.props.warehouses.map(wh => ({
id: wh.id,
label: wh.name,
class: { selected: wh.name === this.props.currentWarehouse.name },
onSelected: () => this.props.changeWarehouse(wh.id)
}));
}
}

View file

@ -1,62 +1,70 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mrp.BomOverviewControlPanel" owl="1">
<t t-name="mrp.BomOverviewControlPanel">
<ControlPanel display="controlPanelDisplay">
<t t-set-slot="control-panel-bottom-left-buttons">
<div class="o_cp_buttons">
<div class="o_list_buttons o_mrp_bom_report_buttons">
<button t-on-click="() => this.props.print()" type="button" class="btn btn-primary">Print</button>
<t t-if="props.showVariants">
<button t-on-click="() => this.props.print(true)" type="button" class="btn btn-primary ms-1">Print All Variants</button>
</t>
<button t-on-click="clickUnfold" type="button" class="btn btn-primary ms-1">Unfold</button>
<t t-set-slot="control-panel-create-button">
<button t-if="props.showOptions.mode == 'forecast'" t-on-click="manufactureFromBoM" type="button" class="btn btn-primary">Manufacture</button>
</t>
<t t-set-slot="control-panel-always-buttons">
<button t-on-click="() => this.props.print()" type="button" class="btn btn-secondary">Print</button>
<!-- <t t-if="props.showVariants"> commented, waiting for ui update
<button t-on-click="() => this.props.print(true)" type="button" class="btn btn-secondary text-nowrap">Print All Variants</button>
</t>
</t> -->
<button t-if="props.foldable" t-on-click="clickTogglefold" type="button" class="btn btn-secondary" t-esc="foldButtonText"/>
</t>
<t t-set-slot="layout-actions">
<div t-if="props.showVariants" class="input-group align-items-center">
<div class="col-4 col-md-auto pe-2 fw-bold">Variant</div>
<div class="col">
<Many2XAutocomplete
value="props.data.name"
getDomain.bind="getDomain"
resModel="'product.product'"
fieldString="props.data.name"
activeActions="{}"
update.bind="(ev) => this.props.changeVariant(ev[0]?.id)"
/>
</div>
</div>
</t>
<t t-set-slot="control-panel-top-right">
<form class="row gx-0">
<div class="col-lg-8 row">
<div class="col-lg-11 row gx-1 mb-2">
<label for="bom_quantity" class="col-xl-2 col-lg-3 col-sm-4">Quantity:</label>
<div t-attf-class="{{ props.showOptions.uom ? 'col-xl-7 col-lg-6 col-sm-5' : 'col-xl-10 col-lg-9 col-sm-8' }}">
<input id="bom_quantity" type="number" step="any" t-on-change="ev => this.updateQuantity(ev)" t-on-keypress="ev => this.onKeyPress(ev)" t-att-value="props.bomQuantity" min="1" class="o_input"/>
</div>
<div t-if="props.showOptions.uom" class="col-xl-3 col-lg-3 col-sm-3">
<span t-esc="props.uomName"/>
</div>
<div class="d-flex gap-1">
<t t-if="props.showOptions.mode == 'forecast'">
<div>
<form class="d-flex flex-grow-1 gap-3 flex-column flex-md-row">
<label class="visually-hidden" for="bom_quantity"/>
<div t-attf-class="input-group align-items-center">
<div class="col-4 col-md-auto px-2 fw-bold">Quantity</div>
<input id="bom_quantity" type="number" step="any" t-on-change="ev => this.updateQuantity(ev)" t-on-keypress="ev => this.onKeyPress(ev)" t-att-value="props.bomQuantity" min="1" size="7" class="o_input form-control rounded-0" t-ref="quantity"/>
<div t-if="props.showOptions.uom" t-out="props.uomName" class="d-flex align-items-center text-muted small lh-sm"/>
</div>
</form>
</div>
<div t-if="props.showVariants" class="col-lg-11 row gx-1 mb-1">
<label class="col-xl-2 col-lg-3 col-sm-4">Variant:</label>
<div class="col-xl-10 col-lg-9 col-sm-8">
<select class="o_input" t-on-change="(ev) => this.props.changeVariant(ev.target.value)">
<option t-foreach="props.variants" t-as="variant" t-att-value="variant" t-key="variant">
<t t-esc="props.variants[variant]"/>
</option>
</select>
</div>
</div>
</div>
</form>
</t>
</div>
</t>
<t t-set-slot="control-panel-bottom-right">
<div class="o_search_options">
<t t-if="props.warehouses.length > 1">
<Dropdown class="'btn-group'" togglerClass="'btn btn-secondary'">
<t t-set-slot="toggler">
<span class="fa fa-home"/> Warehouse: <span t-esc="props.currentWarehouse.name"/>
</t>
<t t-foreach="props.warehouses" t-as="wh" t-key="wh.id">
<DropdownItem onSelected="() => this.props.changeWarehouse(wh.id)" t-esc="wh.name"/>
</t>
<t t-set-slot="control-panel-navigation-additional">
<t t-if="props.showOptions.mode == 'forecast'">
<t t-if="props.warehouses.length > 1" class="btn-group flex-grow-1 flex-md-grow-0">
<Dropdown items="warehousesItems">
<button class="btn btn-secondary o-dropdown-caret">
<span class="fa fa-home"/> Warehouse: <t t-out="props.currentWarehouse.name"/>
</button>
</Dropdown>
</t>
<BomOverviewDisplayFilter
showOptions="props.showOptions"
changeDisplay.bind="props.changeDisplay"/>
</div>
</t>
<t t-if="props.showOptions.mode == 'overview'">
<div class="o_row" t-on-click="() => this.props.changeMode('forecast')" >
<i class="btn fa fa-toggle-off fa-lg pe-1"/>
<span>Forecast</span>
</div>
</t>
<t t-else="">
<div class="o_row" t-on-click="() => this.props.changeMode('overview')" >
<i class="btn fa fa-toggle-on fa-lg pe-1"/>
<span>Forecast</span>
</div>
</t>
</t>
</ControlPanel>
</t>

View file

@ -1,46 +1,33 @@
/** @odoo-module **/
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
const { Component } = owl;
import { Component } from "@odoo/owl";
export class BomOverviewDisplayFilter extends Component {
static template = "mrp.BomOverviewDisplayFilter";
static components = {
Dropdown,
DropdownItem,
};
static props = {
showOptions: {
type: Object,
},
changeDisplay: Function,
};
setup() {
this.displayOptions = {
availabilities: this.env._t('Availabilities'),
leadTimes: this.env._t('Lead Times'),
costs: this.env._t('Costs'),
operations: this.env._t('Operations'),
};
this.displayOptions = {};
}
//---- Getters ----
get displayableOptions() {
return Object.keys(this.displayOptions);
}
get currentDisplayedNames() {
return this.displayableOptions.filter(key => this.props.showOptions[key]).map(key => this.displayOptions[key]).join(", ");
return Object.keys(this.displayOptions).map(optionKey => ({
id: optionKey,
label: this.displayOptions[optionKey],
onSelected: () => this.props.changeDisplay(optionKey),
class: { o_menu_item: true, selected: this.props.showOptions[optionKey] },
closingMode: "none",
}));
}
}
BomOverviewDisplayFilter.template = "mrp.BomOverviewDisplayFilter";
BomOverviewDisplayFilter.components = {
Dropdown,
DropdownItem,
}
BomOverviewDisplayFilter.props = {
showOptions: {
type: Object,
shape: {
availabilities: Boolean,
costs: Boolean,
operations: Boolean,
leadTimes: Boolean,
uom: Boolean,
attachments: Boolean,
},
},
changeDisplay: Function,
};

View file

@ -1,16 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mrp.BomOverviewDisplayFilter" owl="1">
<Dropdown class="'btn-group'" togglerClass="'btn btn-secondary'">
<t t-set-slot="toggler">
<span class="fa fa-filter"/>
Display: <span t-esc="currentDisplayedNames"/>
</t>
<t t-foreach="displayableOptions" t-as="optionKey" t-key="optionKey">
<DropdownItem parentClosingMode="'none'" class="{ o_menu_item: true, selected: props.showOptions[optionKey] }" onSelected="() => this.props.changeDisplay(optionKey)" t-esc="displayOptions[optionKey]"/>
</t>
</Dropdown>
<t t-name="mrp.BomOverviewDisplayFilter">
<div class="btn-group flex-grow-1 flex-md-grow-0'">
<Dropdown items="displayableOptions">
<button class="btn btn-secondary o-dropdown-caret">
<span class="fa fa-filter"/>
Display
</button>
</Dropdown>
</div>
</t>
</templates>

View file

@ -1,12 +1,26 @@
/** @odoo-module **/
import { useBus } from "@web/core/utils/hooks";
import { BomOverviewLine } from "../bom_overview_line/mrp_bom_overview_line";
import { BomOverviewSpecialLine } from "../bom_overview_special_line/mrp_bom_overview_special_line";
const { Component, onWillUnmount, onWillUpdateProps, useState } = owl;
import { Component, onWillUnmount, onWillUpdateProps, useState } from "@odoo/owl";
export class BomOverviewExtraBlock extends Component {
static template = "mrp.BomOverviewExtraBlock";
static components = {
BomOverviewLine,
BomOverviewSpecialLine,
};
static props = {
unfoldAll: { type: Boolean, optional: true },
type: {
type: String,
validate: (t) => ["operations", "byproducts"].includes(t),
},
showOptions: Object,
data: Object,
precision: Number,
changeFolded: Function,
};
setup() {
this.state = useState({
isFolded: !this.props.unfoldAll,
@ -15,7 +29,7 @@ export class BomOverviewExtraBlock extends Component {
this.props.changeFolded({ ids: [this.identifier], isFolded: false });
}
useBus(this.env.overviewBus, "unfold-all", () => this._unfold());
useBus(this.env.overviewBus, "toggle-fold-all", () => this._toggleFoldAll());
onWillUpdateProps(newProps => {
if (this.props.data.product_id != newProps.data.product_id) {
@ -37,9 +51,9 @@ export class BomOverviewExtraBlock extends Component {
this.props.changeFolded({ ids: [this.identifier], isFolded: newState });
}
_unfold() {
this.state.isFolded = false;
this.props.changeFolded({ ids: [this.identifier], isFolded: false })
_toggleFoldAll() {
this.state.isFolded = !this.state.isFolded;
this.props.changeFolded({ ids: [this.identifier], isFolded: this.state.isFolded });
}
//---- Getters ----
@ -48,25 +62,3 @@ export class BomOverviewExtraBlock extends Component {
return `${this.props.type}_${this.props.data.index}`;
}
}
BomOverviewExtraBlock.template = "mrp.BomOverviewExtraBlock";
BomOverviewExtraBlock.components = {
BomOverviewLine,
BomOverviewSpecialLine,
};
BomOverviewExtraBlock.props = {
unfoldAll: { type: Boolean, optional: true },
type: {
type: String,
validate: t => ["operations", "byproducts"].includes(t),
},
showOptions: Object,
data: Object,
precision: Number,
changeFolded: Function,
};
BomOverviewExtraBlock.defaultProps = {
showAvailabilities: false,
showCosts: false,
extraColumnCount: 0,
};

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mrp.BomOverviewExtraBlock" owl="1">
<t t-name="mrp.BomOverviewExtraBlock">
<BomOverviewSpecialLine
type="props.type"
isFolded="state.isFolded"

View file

@ -1,11 +1,30 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { useService } from "@web/core/utils/hooks";
import { formatFloat, formatFloatTime, formatMonetary } from "@web/views/fields/formatters";
const { Component } = owl;
import { Component } from "@odoo/owl";
export class BomOverviewLine extends Component {
static template = "mrp.BomOverviewLine";
static props = {
isFolded: { type: Boolean, optional: true },
showOptions: {
type: Object,
shape: {
mode: String,
uom: Boolean,
attachments: Boolean,
},
},
currentWarehouseId: { type: Number, optional: true },
data: Object,
precision: Number,
toggleFolded: { type: Function, optional: true },
};
static defaultProps = {
isFolded: true,
toggleFolded: () => {},
};
setup() {
this.actionService = useService("action");
this.ormService = useService("orm");
@ -46,20 +65,27 @@ export class BomOverviewLine extends Component {
active_id: this.data.link_id,
};
if (this.props.currentWarehouseId) {
action.context["warehouse"] = this.props.currentWarehouseId;
action.context["warehouse_id"] = this.props.currentWarehouseId;
}
return this.actionService.doAction(action);
}
async goToAttachment() {
return this.actionService.doAction({
name: this.env._t("Attachments"),
name: _t("Attachments"),
type: "ir.actions.act_window",
res_model: "mrp.document",
domain: [["id", "in", this.data.attachment_ids]],
res_model: "product.document",
domain: ['&', ["attached_on_mrp", "=", "bom"], '|',
'&',["res_model", "=", "product.product"],["res_id", "in", [this.data.product_id]],
'&',["res_model", "=", "product.template"],["res_id", "in", [this.data.product_template_id]]],
views: [[false, "kanban"], [false, "list"], [false, "form"]],
view_mode: "kanban,list,form",
target: "current",
context:{
'bom_id': true,
'default_res_id': this.data.product_id,
'default_res_model': "product.product"
}
});
}
@ -81,8 +107,12 @@ export class BomOverviewLine extends Component {
return this.data.components && this.data.components.length > 0;
}
get hasOperations() {
return this.data.operations && this.data.operations.length > 0;
}
get hasQuantity() {
return this.data.hasOwnProperty('quantity_available') && this.data.quantity_available !== false;
return this.data.is_storable && this.data.hasOwnProperty('quantity_available') && this.data.quantity_available !== false;
}
get hasLeadTime() {
@ -90,23 +120,15 @@ export class BomOverviewLine extends Component {
}
get hasFoldButton() {
return this.data.level > 0 && this.hasComponents;
return this.data.level > 0 && (this.hasComponents || this.hasOperations);
}
get marginMultiplicator() {
return this.data.level - (this.hasFoldButton ? 1 : 0);
}
get showAvailabilities() {
return this.props.showOptions.availabilities;
}
get showCosts() {
return this.props.showOptions.costs;
}
get showLeadTimes() {
return this.props.showOptions.leadTimes;
get forecastMode() {
return this.props.showOptions.mode == "forecast";
}
get showUom() {
@ -146,28 +168,11 @@ export class BomOverviewLine extends Component {
return "action_product_tmpl_forecast_report";
}
}
}
BomOverviewLine.template = "mrp.BomOverviewLine";
BomOverviewLine.props = {
isFolded: { type: Boolean, optional: true },
showOptions: {
type: Object,
shape: {
availabilities: Boolean,
costs: Boolean,
operations: Boolean,
leadTimes: Boolean,
uom: Boolean,
attachments: Boolean,
},
},
currentWarehouseId: { type: Number, optional: true },
data: Object,
precision: Number,
toggleFolded: { type: Function, optional: true },
};
BomOverviewLine.defaultProps = {
isFolded: true,
toggleFolded: () => {},
};
get statusBackgroundClass() {
if(this.data.index == "0") {
return "text-bg-info";
}
return "text-bg-danger";
}
}

View file

@ -1,17 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<tr t-name="mrp.BomOverviewLine" owl="1">
<tr t-name="mrp.BomOverviewLine" t-on-click="() => this.props.toggleFolded(identifier)">
<td name="td_mrp_bom">
<div t-attf-style="margin-left: {{ marginMultiplicator * 20 }}px">
<t t-if="data.level > 0 &amp;&amp; hasComponents">
<button t-on-click="() => this.props.toggleFolded(identifier)" class="o_mrp_bom_unfoldable btn btn-light p-0" t-attf-aria-label="{{ props.isFolded ? 'Unfold' : 'Fold' }}" t-attf-title="{{ props.isFolded ? 'Unfold' : 'Fold' }}" style="margin-right: 1px">
<div class="text-truncate" t-attf-style="margin-left: {{ marginMultiplicator * 20 }}px">
<t t-if="hasFoldButton">
<t t-set="title_fold">Fold</t>
<t t-set="title_unfold">Unfold</t>
<span class="o_mrp_bom_unfoldable btn btn-light p-0" t-att-aria-label="props.isFolded ? title_unfold : title_fold" t-att-title="props.isFolded ? title_unfold : title_fold" style="margin-right: 1px">
<i t-attf-class="fa fa-fw fa-caret-{{ props.isFolded ? 'right' : 'down' }}" role="img"/>
</button>
</span>
</t>
<div t-attf-class="d-inline-block">
<a href="#" t-on-click.prevent="() => this.goToAction(data.link_id, data.link_model)" t-esc="data.name"/>
</div>
<a href="#" t-on-click.prevent="() => this.goToAction(data.link_id, data.link_model)" t-esc="data.name"/>
<t t-if="data.phantom_bom">
<div class="fa fa-dropbox" title="This is a BoM of type Kit!" role="img" aria-label="This is a BoM of type Kit!"/>
</t>
@ -22,36 +22,38 @@
<t t-else="" t-esc="formatFloat(data.quantity, {'digits': [false, precision]})"/>
</td>
<td t-if="showUom" class="text-start" t-esc="data.uom_name"/>
<td t-if="showAvailabilities" class="text-end">
<t t-if="data.hasOwnProperty('producible_qty')" t-esc="data.producible_qty"/>
</td>
<td t-if="showAvailabilities" class="text-end">
<td t-if="forecastMode" class="text-end">
<t t-if="hasQuantity">
<t t-esc="formatFloat(data.quantity_available, {'digits': [false, precision]})"/> /
<t t-esc="formatFloat(data.quantity_on_hand, {'digits': [false, precision]})"/>
</t>
</td>
<td t-if="showAvailabilities" class="text-center">
<td t-if="forecastMode" class="text-center">
<div t-if="data.hasOwnProperty('status')" class="o_field_badge">
<span t-attf-class="badge rounded-pill o_mrp_overview_badge {{statusBackgroundClass}}" t-out="data.status"/>
</div>
</td>
<td t-if="forecastMode" class="text-center">
<t t-if="data.hasOwnProperty('availability_state')">
<span t-attf-class="{{availabilityColorClass}}" t-esc="data.availability_display"/>
<a href="#" role="button" t-on-click.prevent="goToForecast" title="Forecast Report" t-attf-class="fa fa-fw fa-area-chart o_mrp_bom_forecast ms-1 {{availabilityColorClass}}"/>
<t t-if="['bom', 'component'].includes(data.type)">
<a href="#" role="button" t-on-click.prevent="goToForecast" title="Forecast Report" t-attf-class="fa fa-fw fa-area-chart o_mrp_bom_forecast ms-1 {{availabilityColorClass}}"/>
</t>
</t>
</td>
<td t-if="showLeadTimes" class="text-end">
<td t-if="forecastMode" class="text-end">
<span t-if="hasLeadTime"><t t-esc="data.lead_time"/> Days</span>
</td>
<td>
<td t-if="forecastMode">
<div t-if="data.route_name">
<span t-attf-class="{{ data.route_alert ? 'text-danger' : '' }}"><t t-esc="data.route_name"/>: </span>
<a href="#" t-on-click.prevent="() => this.goToRoute(data.route_type)" t-esc="data.route_detail"/>
</div>
</td>
<td t-if="showCosts" t-attf-class="text-end {{ data.type == 'component' ? 'opacity-50' : '' }}" t-esc="formatMonetary(data.bom_cost)"/>
<td t-if="showCosts" class="text-end">
<span t-if="data.hasOwnProperty('prod_cost')" t-esc="formatMonetary(data.prod_cost)"/>
</td>
<td t-else=""/>
<td t-attf-class="text-end {{ data.type == 'component' ? 'opacity-50' : '' }}" t-esc="formatMonetary(data.bom_cost)"/>
<td t-if="showAttachments" class="text-center">
<span t-if="!!data.attachment_ids &amp;&amp; data.attachment_ids.length > 0">
<span t-if="data.has_attachments">
<a href="#" role="button" t-on-click.prevent="goToAttachment" class="fa fa-fw o_button_icon fa-files-o"/>
</span>
</td>

View file

@ -1,10 +1,28 @@
/** @odoo-module **/
import { formatFloat, formatFloatTime, formatMonetary } from "@web/views/fields/formatters";
const { Component } = owl;
import { Component } from "@odoo/owl";
export class BomOverviewSpecialLine extends Component {
static template = "mrp.BomOverviewSpecialLine";
static props = {
type: String,
isFolded: { type: Boolean, optional: true },
showOptions: {
type: Object,
shape: {
mode: String,
uom: Boolean,
attachments: Boolean,
},
},
data: Object,
precision: Number,
toggleFolded: { type: Function, optional: true },
};
static defaultProps = {
isFolded: true,
toggleFolded: () => {},
};
setup() {
this.formatFloat = formatFloat;
this.formatFloatTime = formatFloatTime;
@ -25,16 +43,8 @@ export class BomOverviewSpecialLine extends Component {
return ["operations", "byproducts"].includes(this.props.type);
}
get showAvailabilities() {
return this.props.showOptions.availabilities;
}
get showCosts() {
return this.props.showOptions.costs;
}
get showLeadTimes() {
return this.props.showOptions.leadTimes;
get forecastMode() {
return this.props.showOptions.mode == "forecast";
}
get showUom() {
@ -42,30 +52,6 @@ export class BomOverviewSpecialLine extends Component {
}
get showAttachments() {
return this.props.showOptions.attachments;
return this.data.has_attachments;
}
}
BomOverviewSpecialLine.template = "mrp.BomOverviewSpecialLine";
BomOverviewSpecialLine.props = {
type: String,
isFolded: { type: Boolean, optional: true },
showOptions: {
type: Object,
shape: {
availabilities: Boolean,
costs: Boolean,
operations: Boolean,
leadTimes: Boolean,
uom: Boolean,
attachments: Boolean,
},
},
data: Object,
precision: Number,
toggleFolded: { type: Function, optional: true },
};
BomOverviewSpecialLine.defaultProps = {
isFolded: true,
toggleFolded: () => {},
};

View file

@ -1,14 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<tr t-name="mrp.BomOverviewSpecialLine" owl="1">
<tr t-name="mrp.BomOverviewSpecialLine" t-on-click="props.toggleFolded" >
<td name="td_mrp_bom">
<span t-attf-style="margin-left: {{ data.level * 20 }}px"/>
<button t-if="hasFoldButton" t-on-click="props.toggleFolded" t-attf-class="o_mrp_bom_{{ props.isFolded ? 'unfoldable' : 'foldable' }} btn btn-light ps-0 py-0" t-attf-aria-label="{{ props.isFolded ? 'Unfold' : 'Fold' }}" t-attf-title="{{ props.isFolded ? 'Unfold' : 'Fold' }}">
<t t-set="title_fold">Fold</t>
<t t-set="title_unfold">Unfold</t>
<span t-if="hasFoldButton" t-attf-class="o_mrp_bom_{{ props.isFolded ? 'unfoldable' : 'foldable' }} btn btn-light ps-0 py-0" t-att-aria-label="props.isFolded ? title_unfold : title_fold" t-att-title="props.isFolded ? title_unfold : title_fold">
<i t-attf-class="fa fa-fw fa-caret-{{ props.isFolded ? 'right' : 'down' }}" role="img"/>
<t t-if="props.type == 'operations'">Operations</t>
<t t-elif="props.type == 'byproducts'">By-Products</t>
</button>
</span>
</td>
<td name="quantity" class="text-end">
<span t-if="props.type == 'operations'" t-esc="formatFloatTime(data.operations_time)"/>
@ -17,16 +19,16 @@
<td name="uom" t-if="showUom" class="text-start">
<span t-if="props.type == 'operations'">Minutes</span>
</td>
<td t-if="showAvailabilities"/>
<td t-if="showAvailabilities"/>
<td t-if="showAvailabilities"/>
<td t-if="showLeadTimes"/>
<td/>
<td name="bom_cost" t-if="showCosts" class="text-end">
<td t-if="forecastMode"/>
<td t-if="forecastMode"/>
<td t-if="forecastMode"/>
<td t-if="forecastMode"/>
<td t-if="forecastMode"/>
<td t-else=""/>
<td name="bom_cost" class="text-end">
<span t-if="props.type == 'operations'" t-esc="formatMonetary(data.operations_cost)"/>
<span t-elif="props.type == 'byproducts'" t-esc="formatMonetary(data.byproducts_cost)"/>
</td>
<td name="prod_cost" t-if="showCosts" class="text-end"/>
<td t-if="showAttachments"/>
</tr>

View file

@ -1,13 +1,32 @@
/** @odoo-module **/
import { formatMonetary, formatFloat } from "@web/views/fields/formatters";
import { formatFloat, formatMonetary } from "@web/views/fields/formatters";
import { useService } from "@web/core/utils/hooks";
import { BomOverviewLine } from "../bom_overview_line/mrp_bom_overview_line";
import { BomOverviewComponentsBlock } from "../bom_overview_components_block/mrp_bom_overview_components_block";
const { Component } = owl;
import { Component } from "@odoo/owl";
export class BomOverviewTable extends Component {
static template = "mrp.BomOverviewTable";
static components = {
BomOverviewLine,
BomOverviewComponentsBlock,
};
static props = {
showOptions: {
type: Object,
shape: {
mode: String,
uom: Boolean,
attachments: Boolean,
},
},
uomName: { type: String, optional: true },
currentWarehouseId: { type: Number, optional: true },
data: Object,
precision: Number,
bomQuantity: Number,
changeFolded: Function,
};
setup() {
this.actionService = useService("action");
this.formatFloat = formatFloat;
@ -39,20 +58,12 @@ export class BomOverviewTable extends Component {
return this.props.precision;
}
get showAvailabilities() {
return this.props.showOptions.availabilities;
get forecastMode() {
return this.props.showOptions.mode == "forecast";
}
get showCosts() {
return this.props.showOptions.costs;
}
get showOperations() {
return this.props.showOptions.operations;
}
get showLeadTimes() {
return this.props.showOptions.leadTimes;
get showUnitCosts() {
return this.props.bomQuantity > 1;
}
get showUom() {
@ -63,27 +74,3 @@ export class BomOverviewTable extends Component {
return this.props.showOptions.attachments;
}
}
BomOverviewTable.template = "mrp.BomOverviewTable";
BomOverviewTable.components = {
BomOverviewLine,
BomOverviewComponentsBlock,
};
BomOverviewTable.props = {
showOptions: {
type: Object,
shape: {
availabilities: Boolean,
costs: Boolean,
operations: Boolean,
leadTimes: Boolean,
uom: Boolean,
attachments: Boolean,
},
},
uomName: { type: String, optional: true },
currentWarehouseId: { type: Number, optional: true },
data: Object,
precision: Number,
changeFolded: Function,
};

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<div t-name="mrp.BomOverviewTable" class="o_content" owl="1">
<div t-name="mrp.BomOverviewTable" class="o_content">
<div class="o_mrp_bom_report_page py-3 py-lg-5 px-0 overflow-auto border-bottom bg-view">
<div t-if="!!data.components || !!data.lines || !!data.operations" class="container-fluid">
<div class="d-flex mb-5">
@ -10,31 +10,19 @@
<hr t-if="data.bom_code"/>
<h6 t-if="data.bom_code">Reference: <t t-esc="data.bom_code"/></h6>
</div>
<div class="text-center">
<h3><t t-esc="formatFloat(data.quantity_available, {'digits': [false, precision]})"/> <t t-if="showUom" t-esc="props.uomName"/></h3>
<span>Free to Use</span>
</div>
<div t-if="data.hasOwnProperty('earliest_capacity')" class="ps-5 text-center">
<h3><t t-esc="formatFloat(data.earliest_capacity)"/> <t t-if="showUom" t-esc="props.uomName"/></h3>
<span t-esc="data.earliest_date"/>
</div>
<div t-if="data.hasOwnProperty('leftover_capacity')" class="ps-5 text-center">
<h3><t t-esc="formatFloat(data.leftover_capacity)"/> <t t-if="showUom" t-esc="props.uomName"/></h3>
<span t-esc="data.leftover_date"/>
</div>
</div>
<table class="o_mrp_bom_expandable table">
<thead>
<tr>
<th name="th_mrp_bom_h">Product</th>
<th t-attf-class="{{ showUom ? 'text-center' : 'text-end' }}" t-attf-colspan="{{ showUom ? 2 : 1 }}">Quantity</th>
<th t-if="showAvailabilities" class="text-end">Ready to Produce</th>
<th t-if="showAvailabilities" class="text-end" title="Availabilities on products.">Free to Use / On Hand</th>
<th t-if="showAvailabilities" class="text-center" title="Reception time estimation.">Availability</th>
<th t-if="showLeadTimes" class="text-end" title="Resupply lead time.">Lead Time</th>
<th>Route</th>
<th t-if="showCosts" class="text-end" title="This is the cost based on the BoM of the product. It is computed by summing the costs of the components and operations needed to build the product.">BoM Cost</th>
<th t-if="showCosts" class="text-end" title="This is the cost defined on the product.">Product Cost</th>
<th t-if="forecastMode" class="text-end" title="Availabilities on products.">Free to Use / On Hand</th>
<th t-if="forecastMode" class="text-center">Status</th>
<th t-if="forecastMode" class="text-center" title="Reception time estimation.">Availability</th>
<th t-if="forecastMode" class="text-end" title="Resupply lead time.">Lead Time</th>
<th t-if="forecastMode">Route</th>
<th t-else=""/>
<th class="text-end" title="This is the cost based on the BoM of the product. It is computed by summing the costs of the components and operations needed to build the product.">Cost</th>
<th t-if="showAttachments" class="text-center" title="Files attached to the product.">Attachments</th>
</tr>
</thead>
@ -53,34 +41,32 @@
precision="props.precision"
changeFolded.bind="props.changeFolded"/>
</tbody>
<tfoot t-if="showCosts">
<tfoot t-if="showUnitCosts">
<tr>
<td name="td_mrp_bom_f" class="text-end">
<span t-if="!!data.byproducts &amp;&amp; data.byproducts.length > 0" t-esc="data.name"/>
</td>
<td class="text-end"><strong>Unit Cost</strong></td>
<td t-if="showUom"/>
<td t-if="showAvailabilities"/>
<td t-if="showAvailabilities"/>
<td t-if="showAvailabilities"/>
<td t-if="showLeadTimes"/>
<td/>
<td class="text-end" t-esc="formatMonetary(data.bom_cost / data.quantity)"/>
<td class="text-end" t-esc="formatMonetary(data.prod_cost / data.quantity)"/>
<td t-if="showUom"><t t-esc="data.uom_name"/></td>
<td t-if="forecastMode"/>
<td t-if="forecastMode"/>
<td t-if="forecastMode"/>
<td t-if="forecastMode"/>
<td class="text-end"><strong>Unit Cost</strong></td>
<td class="text-end"><t t-esc="formatMonetary(data.bom_cost / data.quantity)"/></td>
<td t-if="showAttachments"/>
</tr>
<t t-if="data.byproducts &amp;&amp; data.byproducts.length > 0" t-foreach="data.byproducts" t-as="byproduct" t-key="byproduct.id">
<tr>
<td name="td_mrp_bom_b" class="text-end" t-esc="byproduct.name"/>
<td class="text-end"><strong>Unit Cost</strong></td>
<td t-if="showUom"/>
<td t-if="showAvailabilities"/>
<td t-if="showAvailabilities"/>
<td t-if="showAvailabilities"/>
<td t-if="showLeadTimes"/>
<td/>
<td class="text-end" t-esc="formatMonetary(byproduct.bom_cost / byproduct.quantity)"/>
<td class="text-end" t-esc="formatMonetary(byproduct.prod_cost / byproduct.quantity)"/>
<td t-if="showUom"><t t-esc="data.uom_name"/></td>
<td t-if="forecastMode"/>
<td t-if="forecastMode"/>
<td t-if="forecastMode"/>
<td t-if="forecastMode"/>
<td class="text-end"><strong>Unit Cost</strong></td>
<td class="text-end"><t t-esc="formatMonetary(byproduct.bom_cost / byproduct.quantity)"/></td>
<td t-if="showAttachments"/>
</tr>
</t>

View file

@ -0,0 +1,206 @@
import { Component, EventBus, onWillStart, useSubEnv, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useBus, useService } from "@web/core/utils/hooks";
import { Layout } from "@web/search/layout";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
import { MoOverviewLine } from "../mo_overview_line/mrp_mo_overview_line";
import { MoOverviewDisplayFilter } from "../mo_overview_display_filter/mrp_mo_overview_display_filter";
import { MoOverviewComponentsBlock } from "../mo_overview_components_block/mrp_mo_overview_components_block";
import { formatMonetary } from "@web/views/fields/formatters";
export class MoOverview extends Component {
static components = {
Layout,
MoOverviewLine,
MoOverviewDisplayFilter,
MoOverviewComponentsBlock,
};
static props = { ...standardActionServiceProps };
static template = "mrp.MoOverview";
setup() {
this.actionService = useService("action");
this.ormService = useService("orm");
this.unfoldedIds = new Set();
this.context = {};
this.state = useState({
data: {},
showOptions: this.getDefaultConfig(),
});
useSubEnv({ overviewBus: new EventBus() });
onWillStart(async () => {
await this.getManufacturingData();
});
useBus(this.env.overviewBus, "update-folded", (ev) => this.onChangeFolded(ev.detail));
useBus(this.env.overviewBus, "reload", () => this.getManufacturingData());
}
async getManufacturingData() {
const reportValues = await this.ormService.call(
"report.mrp.report_mo_overview",
"get_report_values",
[this.activeId],
);
this.state.data = reportValues.data;
if (this.isProductionStarted) {
this.state.showOptions.bomCosts = false;
} else {
this.state.showOptions.realCosts = false;
}
if (this.isProductionDone) {
// Hide Availabilities / Receipts / Status / MO Cost columns when the MO is done.
this.state.showOptions.availabilities = false;
this.state.showOptions.receipts = false;
this.state.showOptions.replenishments = false;
this.state.showOptions.unitCosts = true;
this.state.showOptions.moCosts = false;
}
this.state.showOptions.uom = reportValues.context.show_uom;
this.context = reportValues.context;
// Main MO's operations & byproducts are always unfolded by default.
if (reportValues.data?.operations?.summary?.index) {
this.unfoldedIds.add(reportValues.data.operations.summary.index);
}
if (reportValues.data?.byproducts?.summary?.index) {
this.unfoldedIds.add(reportValues.data.byproducts.summary.index);
}
}
//---- Handlers ----
onChangeDisplay(displayInfo) {
this.state.showOptions[displayInfo] = !this.state.showOptions[displayInfo];
}
onChangeFolded(foldInfo) {
const { indexes, isFolded } = foldInfo;
const operation = isFolded ? "delete" : "add";
indexes.forEach(index => this.unfoldedIds[operation](index));
}
async onPrint() {
return this.actionService.doAction({
type: "ir.actions.report",
report_type: "qweb-pdf",
report_name: this.reportName,
report_file: "mrp.report_mo_overview",
});
}
onUnfold() {
this.env.overviewBus.trigger("unfold-all")
}
//---- Helpers ----
getDefaultConfig() {
return {
uom: false,
replenishments: true,
availabilities: true,
receipts: true,
unitCosts: false,
moCosts: true,
bomCosts: true,
realCosts: true,
};
}
getColorClass(decorator) {
return decorator ? `text-${decorator}` : "";
}
formatCost(cost) {
return formatMonetary(cost, { currencyId: this.state.data.summary.currency_id });
}
//---- Getters ----
get activeId() {
return this.props.action.context.active_id;
}
get showUom() {
return this.state.showOptions.uom;
}
get showReplenishments() {
return this.state.showOptions.replenishments;
}
get showAvailabilities() {
return this.state.showOptions.availabilities;
}
get showReceipts() {
return this.state.showOptions.receipts;
}
get showUnitCosts() {
return this.state.showOptions.unitCosts;
}
get showMoCosts() {
return this.state.showOptions.moCosts;
}
get showBomCosts() {
return this.state.showOptions.bomCosts;
}
get showRealCosts() {
return this.state.showOptions.realCosts;
}
get hasBom() {
return this.state.data?.summary?.has_bom;
}
get isProductionStarted() {
return !["draft", "confirmed"].includes(this.state.data?.summary?.state);
}
get isProductionDraft() {
return this.state.data?.summary?.state === "draft";
}
get isProductionDone() {
return this.state.data?.summary?.state === "done";
}
get hasOperations() {
return this.state.data?.operations?.details?.length > 0;
}
get hasBreakdown() {
return this.state.data?.cost_breakdown?.length > 0;
}
get totalColspan() {
let colspan = 2; // Name & Quantity
if (this.showReplenishments) colspan++;
if (this.showAvailabilities) colspan += 2; // Free to use / On Hand & Reserved
if (this.showUom) colspan++;
if (this.showReceipts) colspan++;
if (this.showUnitCosts) colspan++;
return colspan;
}
get reportName() {
return `mrp.report_mo_overview?docids=${this.activeId}`
+ `&replenishments=${+this.state.showOptions.replenishments}`
+ `&availabilities=${+this.state.showOptions.availabilities}`
+ `&receipts=${+this.state.showOptions.receipts}`
+ `&unitCosts=${+this.state.showOptions.unitCosts}`
+ `&moCosts=${+this.state.showOptions.moCosts}`
+ `&bomCosts=${+this.state.showOptions.bomCosts}`
+ `&realCosts=${+this.state.showOptions.realCosts}`
+ `&unfoldedIds=${JSON.stringify(Array.from(this.unfoldedIds))}`;
}
}
registry.category("actions").add("mrp_mo_overview", MoOverview);

View file

@ -0,0 +1,117 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<div t-name="mrp.MoOverview" class="o_action">
<Layout display="{ controlPanel: {} }">
<t t-set-slot="layout-actions">
<MoOverviewDisplayFilter showOptions="state.showOptions" limited="isProductionDone" changeDisplay.bind="onChangeDisplay" isProductionDraft="isProductionDraft"/>
</t>
<t t-set-slot="control-panel-create-button">
<div class="d-flex gap-1">
<button class="btn btn-primary" t-on-click="onPrint">Print</button>
<button class="btn btn-primary" t-on-click="onUnfold">Unfold</button>
</div>
</t>
<div class="overflow-auto border-bottom bg-view container-fluid">
<table name="overview" class="table">
<thead class="o_mrp_mo_overview_thead">
<tr>
<th class="text-start"/>
<th class="text-center" t-if="showReplenishments">Status</th>
<th t-attf-class="{{ showUom ? 'text-center' : 'text-end' }}" t-attf-colspan="{{ showUom ? 2 : 1 }}">Quantity</th>
<th class="text-end" t-if="showAvailabilities">Free to use / On Hand</th>
<th class="text-end" t-if="showAvailabilities">Reserved</th>
<th class="text-end" t-if="showReceipts">Receipt</th>
<th class="text-end" t-if="showUnitCosts">Unit Cost</th>
<th class="text-end" t-if="showMoCosts" title="Cost based on related replenishments. Otherwise cost from product form">MO Cost</th>
<th class="text-end" t-if="showBomCosts" title="Cost based on the BoM">BoM Cost</th>
<th class="text-end" t-if="showRealCosts and realInDraft" title="Cost based on cost projection">Real Cost</th>
<th class="text-end" t-elif="showRealCosts" title="Cost as it is currently accumulated">Real Cost</th>
</tr>
</thead>
<tbody>
<MoOverviewLine data="state.data.summary" showOptions="state.showOptions"/>
<MoOverviewComponentsBlock
components="state.data.components"
operations="state.data.operations"
byproducts="state.data.byproducts"
showOptions="state.showOptions"/>
</tbody>
<tfoot t-if="showMoCosts or showRealCosts">
<tr name="unitCost">
<td class="text-end" t-att-colspan="totalColspan">Unit Cost</td>
<td t-attf-class="text-end" t-if="showMoCosts" t-out="formatCost(state.data.extras.unit_mo_cost)"/>
<td class="text-end" t-if="showBomCosts" t-out="formatCost(state.data.extras.unit_bom_cost)"/>
<td t-attf-class="text-end" t-if="showRealCosts" t-out="formatCost(state.data.extras.unit_real_cost)"/>
</tr>
<t t-if="isProductionDone">
<tr>
<td class="text-end" t-att-colspan="totalColspan">Total Cost of Components</td>
<td t-attf-class="text-end" t-if="showMoCosts" t-out="formatCost(state.data.extras.total_mo_cost_components)"/>
<td class="text-end" t-if="showBomCosts" t-out="formatCost(state.data.extras.total_bom_cost_components)"/>
<td t-attf-class="text-end {{ getColorClass(state.data.extras.total_real_cost_components_decorator) }}" t-if="showRealCosts" t-out="formatCost(state.data.extras.total_real_cost_components)"/>
</tr>
<tr t-if="state.data.summary.quantity != 1">
<td class="text-end" t-att-colspan="totalColspan">
Cost of Components per unit
<t t-if="showUom">(in <t t-out='state.data.summary.uom_name'/>)</t>
</td>
<td t-attf-class="text-end" t-if="showMoCosts" t-out="formatCost(state.data.extras.unit_mo_cost_components)"/>
<td class="text-end" t-if="showBomCosts" t-out="formatCost(state.data.extras.unit_bom_cost_components)"/>
<td t-attf-class="text-end {{ getColorClass(state.data.extras.total_real_cost_components_decorator) }}" t-if="showRealCosts" t-out="formatCost(state.data.extras.unit_real_cost_components)"/>
</tr>
<tr t-if="hasOperations">
<td class="text-end" t-att-colspan="totalColspan">Total Cost of Operations</td>
<td t-attf-class="text-end {{ getColorClass(state.data.extras.total_mo_cost_operations_decorator) }}" t-if="showMoCosts" t-out="formatCost(state.data.extras.total_mo_cost_operations)"/>
<td class="text-end" t-if="showBomCosts" t-out="formatCost(state.data.extras.total_bom_cost_operations)"/>
<td t-attf-class="text-end" t-if="showRealCosts" t-out="formatCost(state.data.extras.total_real_cost_operations)"/>
</tr>
<tr t-if="hasOperations and state.data.summary.quantity != 1">
<td class="text-end" t-att-colspan="totalColspan">
Cost of Operations per unit
<t t-if="showUom">(in <t t-out='state.data.summary.uom_name'/>)</t>
</td>
<td t-attf-class="text-end {{ getColorClass(state.data.extras.total_mo_cost_operations_decorator) }}" t-if="showMoCosts" t-out="formatCost(state.data.extras.unit_mo_cost_operations)"/>
<td class="text-end" t-if="showBomCosts" t-out="formatCost(state.data.extras.unit_bom_cost_operations)"/>
<td t-attf-class="text-end" t-if="showRealCosts" t-out="formatCost(state.data.extras.unit_real_cost_operations)"/>
</tr>
<tr t-if="hasBreakdown and hasOperations">
<td class="text-end" t-att-colspan="totalColspan">Total Cost of Production</td>
<td t-attf-class="text-end" t-if="showMoCosts" t-out="formatCost(state.data.extras.total_mo_cost)"/>
<td class="text-end" t-if="showBomCosts" t-out="formatCost(state.data.extras.total_bom_cost)"/>
<td t-attf-class="text-end" t-if="showRealCosts" t-out="formatCost(state.data.extras.total_real_cost)"/>
</tr>
</t>
</tfoot>
</table>
<t t-if="hasBreakdown">
<hr/>
<h2 class="pt-3">Cost Breakdown of Products</h2>
<table name="breakdown" class="table">
<thead>
<tr>
<th class="text-start">Product</th>
<th class="text-end">Avg Cost of Components per Unit</th>
<th t-if="hasOperations" class="text-end">Avg Cost of Operations per Unit</th>
<th class="text-end">Avg Total Cost per Unit</th>
<th t-if="showUom" class="text-end">Unit of Measure</th>
</tr>
</thead>
<tbody>
<tr t-foreach="state.data.cost_breakdown" t-as="line" t-key="line.index">
<td class="text-start" t-out="line.name"/>
<td class="text-end" t-out="formatCost(line.unit_avg_cost_component)"/>
<td t-if="hasOperations" class="text-end" t-out="formatCost(line.unit_avg_cost_operation)"/>
<td class="text-end" t-out="formatCost(line.unit_avg_total_cost)"/>
<td t-if="showUom" class="text-end" t-out="line.uom_name"/>
</tr>
</tbody>
</table>
</t>
</div>
</Layout>
</div>
</templates>

View file

@ -0,0 +1,29 @@
import { MoOverviewOperationsBlock } from "../mo_overview_operations_block/mrp_mo_overview_operations_block";
import { MoOverviewLine } from "../mo_overview_line/mrp_mo_overview_line";
export class MoOverviewByproductsBlock extends MoOverviewOperationsBlock {
static components = {
MoOverviewLine,
};
static props = {
// Keep all props except "operations"
...(({ operations, ...props }) => props)(MoOverviewOperationsBlock.props),
byproducts: Array,
};
static template = "mrp.MoOverviewByproductsBlock";
//---- Getters ----
get hasByproducts() {
return this.props?.byproducts?.length > 0;
}
get level() {
return this.hasByproducts ? this.props.byproducts[0].level - 1 : 0;
}
}
MoOverviewByproductsBlock.props.summary.shape = {
...MoOverviewByproductsBlock.props.summary.shape,
product_cost: { type: Number, optional: true },
};

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mrp.MoOverviewByproductsBlock">
<t t-if="hasByproducts">
<tr>
<td class="text-start">
<span t-attf-style="margin-left: {{ level * 20 }}px"/>
<button t-on-click="toggleFolded" class="btn btn-light ps-0 py-0" t-attf-aria-label="{{ state.isFolded ? 'Unfold' : 'Fold' }}" t-attf-title="{{ state.isFolded ? 'Unfold' : 'Fold' }}">
<i t-attf-class="fa fa-fw fa-caret-{{ state.isFolded ? 'right' : 'down' }}" role="img"/>
By-Products
</button>
</td>
<td class="text-center" t-if="props.showOptions.replenishments"/>
<td class="text-end"/>
<td class="text-start" t-if="props.showOptions.uom"/>
<td class="text-end" t-if="props.showOptions.availabilities"/>
<td class="text-end" t-if="props.showOptions.availabilities"/>
<td class="text-end" t-if="props.showOptions.receipts"/>
<td class="text-end" t-if="props.showOptions.unitCosts"/>
<td t-attf-class="text-end {{ getColorClass(props.summary.mo_cost_decorator) }}" t-if="props.showOptions.moCosts" t-out="formatMonetary(props.summary.mo_cost)"/>
<td class="text-end" t-if="props.showOptions.bomCosts" t-out="formatMonetary(props.summary.bom_cost)"/>
<td t-attf-class="text-end {{ getColorClass(props.summary.real_cost_decorator) }}" t-if="props.showOptions.realCosts" t-out="formatMonetary(props.summary.real_cost)"/>
</tr>
<t t-if="!state.isFolded" t-foreach="props.byproducts" t-as="byproduct" t-key="byproduct.index">
<MoOverviewLine data="byproduct" showOptions="props.showOptions"/>
</t>
</t>
</t>
</templates>

View file

@ -0,0 +1,110 @@
import { Component, onWillUpdateProps, useState } from "@odoo/owl";
import { useBus } from "@web/core/utils/hooks";
import { MoOverviewLine } from "../mo_overview_line/mrp_mo_overview_line";
import { MoOverviewOperationsBlock } from "../mo_overview_operations_block/mrp_mo_overview_operations_block";
import { MoOverviewByproductsBlock } from "../mo_overview_byproducts_block/mrp_mo_overview_byproducts_block";
import { SHOW_OPTIONS } from "../mo_overview_display_filter/mrp_mo_overview_display_filter";
export class MoOverviewComponentsBlock extends Component {
static components = {
MoOverviewLine,
MoOverviewOperationsBlock,
MoOverviewByproductsBlock,
MoOverviewComponentsBlock,
};
static props = {
unfoldAll: { type: Boolean, optional: true },
components: { type: Array, optional: true },
operations: {
type: Object,
shape: {
summary: Object,
details: Array,
},
optional: true,
},
byproducts: {
type: Object,
shape: {
summary: Object,
details: Array,
},
optional: true,
},
showOptions: SHOW_OPTIONS,
};
static defaultProps = {
unfoldAll: false,
};
static template = "mrp.MoOverviewComponentsBlock";
setup() {
this.state = useState({
fold: this.getIndexStates(this.props),
unfoldAll: this.props.unfoldAll || false,
});
if (this.props.unfoldAll) {
this.env.overviewBus.trigger("update-folded", { indexes: Object.keys(this.state.fold), isFolded: false });
}
useBus(this.env.overviewBus, "unfold-all", () => this.unfoldAll());
onWillUpdateProps(newProps => {
// Update the fold indexes so it matches the newly added lines.
this.state.fold = { ...this.getIndexStates(newProps), ...this.state.fold };
});
}
//---- Handlers ----
onToggleFolded(foldIndex) {
this.state.unfoldAll = false;
const newState = !this.state.fold[foldIndex];
if (newState) {
// If a line is folded, its children lines must be folded as well
Object.keys(this.state.fold).filter(key => key.startsWith(foldIndex)).forEach(index => {
this.state.fold[index] = newState;
});
}
this.state.fold[foldIndex] = newState;
this.env.overviewBus.trigger("update-folded", { indexes: [foldIndex], isFolded: newState });
}
unfoldAll() {
this.state.unfoldAll = true;
const foldIndexes = Object.keys(this.state.fold);
foldIndexes.forEach(index => this.state.fold[index] = false);
this.env.overviewBus.trigger("update-folded", { indexes: foldIndexes, isFolded: false });
}
//---- Helpers ----
getIndexStates(props) {
const indexStates = {};
(props?.components ?? []).forEach(component => {
indexStates[component?.summary.index] = !props.unfoldAll;
(component?.replenishments ?? []).forEach(replenishment => {
indexStates[replenishment?.summary.index] = !props.unfoldAll;
});
});
return indexStates;
}
hasReplenishments(component) {
return component?.replenishments?.length > 0;
}
hasReplenishmentsBlock(component) {
return this.hasReplenishments(component) && !this.state.fold[component?.summary.index];
}
hasComponents(replenishment) {
return replenishment?.components?.length > 0 || replenishment?.operations?.details?.length > 0;
}
hasComponentsBlock(replenishment) {
return this.hasComponents(replenishment) && !this.state.fold[replenishment?.summary.index];
}
}

View file

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mrp.MoOverviewComponentsBlock">
<t t-foreach="props.components" t-as="component" t-key="component.summary.index">
<MoOverviewLine
data="component.summary"
hasFoldButton="hasReplenishments(component)"
isFolded="state.fold[component.summary.index]"
showOptions="props.showOptions"
toggleFolded.bind="onToggleFolded"/>
<!-- Display Replenishment block -->
<t t-if="hasReplenishmentsBlock(component)" t-foreach="component.replenishments" t-as="replenishment" t-key="replenishment.summary.index">
<MoOverviewLine
data="replenishment.summary"
hasFoldButton="hasComponents(replenishment)"
isFolded="state.fold[replenishment.summary.index]"
showOptions="props.showOptions"
toggleFolded.bind="onToggleFolded"/>
<t t-if="hasComponentsBlock(replenishment)">
<MoOverviewComponentsBlock
unfoldAll="state.unfoldAll"
components="replenishment.components"
operations="replenishment.operations"
byproducts="replenishment.byproducts"
showOptions="props.showOptions"/>
</t>
</t>
</t>
<MoOverviewOperationsBlock
unfoldAll="state.unfoldAll"
summary="props.operations.summary"
operations="props.operations.details"
showOptions="props.showOptions"/>
<MoOverviewByproductsBlock
unfoldAll="state.unfoldAll"
summary="props.byproducts.summary"
byproducts="props.byproducts.details"
showOptions="props.showOptions"/>
</t>
</templates>

View file

@ -0,0 +1,51 @@
import { _t } from "@web/core/l10n/translation";
import { BomOverviewDisplayFilter } from "../bom_overview_display_filter/mrp_bom_overview_display_filter";
export const SHOW_OPTIONS = {
type: Object,
shape: {
uom: Boolean,
replenishments: Boolean,
availabilities: Boolean,
receipts: Boolean,
unitCosts: Boolean,
moCosts: Boolean,
bomCosts: Boolean,
realCosts: Boolean,
},
};
export class MoOverviewDisplayFilter extends BomOverviewDisplayFilter {
static props = {
showOptions: SHOW_OPTIONS,
changeDisplay: Function,
limited: { type: Boolean, optional: true },
isProductionDraft: { type: Boolean, optional: true},
};
static defaultProps = {
limited: false,
isProductionDraft: false,
};
setup() {
this.displayOptions = {
unitCosts: _t("Unit Costs"),
moCosts: _t("MO Costs"),
bomCosts: _t("BoM Costs"),
};
if (!this.props.limited) {
this.displayOptions = {
...this.displayOptions,
replenishments: _t("Replenishments"),
availabilities: _t("Availabilities"),
receipts: _t("Receipts"),
};
}
if (!this.props.isProductionDraft) {
this.displayOptions = {
...this.displayOptions,
realCosts: _t("Real Costs"),
};
}
}
}

View file

@ -0,0 +1,59 @@
const PRODUCTION_DECORATORS = {
draft: "secondary",
confirmed: "info",
progress: "warning",
done: "success",
to_close: "success",
cancel: "danger",
};
const PURCHASE_DECORATORS = {
draft: "info",
sent: "info",
['to approve']: "info",
purchase: "info",
cancel: "secondary",
};
const PICKING_DECORATORS = {
draft: "secondary",
waiting: "warning",
confirmed: "warning",
assigned: "info",
done: "success",
cancel: "danger",
};
const OPERATION_DECORATORS = {
blocked: "warning",
ready: "muted",
progress: "info",
done: "success",
cancel: "danger",
};
const PRODUCT_DECORATORS = {
to_order: "danger",
};
export function getStateDecorator(model, state) {
let decorators = null;
switch (model) {
case "mrp.production":
decorators = PRODUCTION_DECORATORS;
break;
case "mrp.workorder":
decorators = OPERATION_DECORATORS;
break;
case "stock.picking":
decorators = PICKING_DECORATORS;
break;
case "purchase.order":
decorators = PURCHASE_DECORATORS;
break;
case "product.product":
decorators = PRODUCT_DECORATORS;
break;
}
return decorators ? `text-bg-${decorators[state]}` : "";
}

View file

@ -0,0 +1,175 @@
import { _t } from "@web/core/l10n/translation";
import { Component } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { formatFloat, formatFloatTime, formatMonetary } from "@web/views/fields/formatters";
import { getStateDecorator } from "./mo_overview_colors";
import { SHOW_OPTIONS } from "../mo_overview_display_filter/mrp_mo_overview_display_filter";
export class MoOverviewLine extends Component {
static props = {
data: {
type: Object,
shape: {
level: Number,
index: { type: String, optional: true },
id: { type: Number, optional: true },
model: { type: String, optional: true },
name: String,
product_model: { type: String, optional: true },
product: { type: String, optional: true },
product_id: { type: Number, optional: true },
state: { type: String, optional: true },
formatted_state: { type: String, optional: true },
has_bom: { type: Boolean, optional: true },
quantity: Number,
replenish_quantity: { type: Number, optional: true },
uom: { type: String, optional: true },
uom_name: { type: String, optional: true },
uom_precision: { type: Number, optional: true },
quantity_free: { type: [Number, Boolean], optional: true },
quantity_on_hand: { type: [Number, Boolean], optional: true },
quantity_reserved: { type: Number, optional: true },
receipt: {
type: Object,
shape: {
display: String,
type: String,
decorator: [String, Boolean],
date: [String, Boolean],
},
optional: true,
},
unit_cost: { type: Number, optional: true },
mo_cost: { type: [Number, Boolean], optional: true },
mo_cost_decorator: { type: [String, Boolean], optional: true },
bom_cost: { type: [Number, Boolean], optional: true },
real_cost: { type: [Number, Boolean], optional: true },
real_cost_decorator: { type: [String, Boolean], optional: true },
currency_id: Number,
currency: { type: String, optional: true },
production_id: { type: Number, optional: true },
},
},
showOptions: SHOW_OPTIONS,
hasFoldButton: { type: Boolean, optional: true },
isFolded: { type: Boolean, optional: true },
toggleFolded: { type: Function, optional: true },
};
static template = "mrp.MoOverviewLine";
setup() {
this.actionService = useService("action");
this.ormService = useService("orm");
this.formatFloat = (val) => formatFloat(val, { digits: [false, this.data.uom_precision || undefined] });
this.formatFloatTime = formatFloatTime;
this.formatMonetary = (val) => formatMonetary(val, { currencyId: this.data.currency_id });
}
//---- Handlers ----
async openForm() {
const model = this.data.level === 0 ? this.data.product_model : this.data.model;
const id = this.data.level === 0 ? this.data.product_id : this.data.id;
return this.actionService.doAction({
type: "ir.actions.act_window",
res_model: model,
res_id: id,
views: [[false, "form"]],
target: "current",
context: {
active_id: id,
},
});
}
async openForecast() {
const action = await this.ormService.call(
this.data.product_model,
this.forecastAction,
[[this.data.product_id]],
);
action.context = {
active_model: this.data.product_model,
active_id: this.data.product_id,
};
return this.actionService.doAction(action);
}
async openReplenish() {
return this.actionService.doAction("stock.action_product_replenish", {
additionalContext: { default_product_id: this.data.product_id, default_quantity: this.data.replenish_quantity || this.data.quantity },
onClose: (closeInfo) => {
if (closeInfo?.done) {
// Trigger the reload only if a replenishment was done.
this.env.overviewBus.trigger("reload");
}
},
});
}
async openWorkorder() {
return this.actionService.doAction({
name: this.data.name,
type: "ir.actions.act_window",
res_model: "mrp.workorder",
views: [[false, "list"]],
context: {
search_default_ready: true,
search_default_waiting: true,
search_default_progress: true,
search_default_blocked: true,
search_default_name: this.data.name,
search_default_production_id: this.data.production_id,
},
});
}
//---- Helpers ----
getColorClass(decorator) {
return decorator ? `text-${decorator}` : "";
}
hasQuantity(keyName) {
return this.data.hasOwnProperty(keyName) && this.data[keyName] !== false;
}
//---- Getters ----
get data() {
return this.props.data;
}
get stateDecorator() {
return getStateDecorator(this.data.model, this.data.state);
}
get formattedQuantity() {
if (this.data.model === "mrp.workorder") {
return this.formatFloatTime(this.data.quantity);
}
return this.formatFloat(this.data.quantity);
}
get hasFoldButton() {
return false;
}
get marginMultiplicator() {
return this.data.level - (this.props.hasFoldButton ? 1 : 0);
}
get foldButtonTitle() {
return this.props.isFolded ? _t("Unfold") : _t("Fold");
}
get forecastAction() {
switch (this.data.product_model) {
case "product.product":
return "action_product_forecast_report";
case "product.template":
return "action_product_tmpl_forecast_report";
}
}
}

View file

@ -0,0 +1,8 @@
.o_mrp_overview_badge {
font-size: $badge-font-size * 1.1;
}
.o_mrp_overview_badge.text-bg-muted {
@include o-print-color(map-get($o-grays, 300), background-color, bg-opacity);
@include o-print-color(color-contrast(map-get($o-grays, 300)), color, text-opacity);
}

View file

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<tr t-name="mrp.MoOverviewLine" t-on-click="() => this.props.hasFoldButton? this.props.toggleFolded(data.index) : false">
<td class="text-start">
<div t-attf-style="margin-left: {{ marginMultiplicator * 20 }}px" class="d-inline-block">
<span t-if="props.hasFoldButton" class="btn btn-light p-0" t-attf-aria-label="{{ foldButtonTitle }}" t-attf-title="{{ foldButtonTitle }}" style="margin-right: 1px">
<i t-attf-class="fa fa-fw fa-caret-{{ props.isFolded ? 'right' : 'down' }}" role="img"/>
</span>
<a t-if="data.id and data.model === 'mrp.workorder'" href="#" t-on-click.prevent="openWorkorder" t-out="data.name"/>
<a t-elif="data.id and data.model" href="#" t-on-click.prevent="openForm" t-out="data.name"/>
<t t-else="" t-out="data.name"/>
<button t-if="data.model === 'to_order'" class="ms-2 py-0 btn btn-secondary" t-on-click="openReplenish">Replenish</button>
</div>
</td>
<td class="text-center" t-if="props.showOptions.replenishments">
<div class="o_field_badge">
<span t-attf-class="badge rounded-pill o_mrp_overview_badge {{ stateDecorator }}" t-out="data.formatted_state"/>
</div>
</td>
<td t-attf-class="text-end" t-out="formattedQuantity"/>
<td t-if="props.showOptions.uom" class="text-start" t-out="data.uom_name"/>
<td class="text-end" t-if="props.showOptions.availabilities">
<t t-if="hasQuantity('quantity_on_hand')">
<t t-out="formatFloat(data.quantity_free)"/> /
<t t-out="formatFloat(data.quantity_on_hand)"/>
</t>
</td>
<td class="text-end" t-if="props.showOptions.availabilities">
<t t-if="hasQuantity('quantity_reserved')" t-out="formatFloat(data.quantity_reserved)"/>
</td>
<td class="text-end" t-if="props.showOptions.receipts">
<t t-if="data.receipt">
<span t-attf-class="{{ getColorClass(data.receipt.decorator) }}" t-out="data.receipt.display"/>
<a href="#" role="button" t-on-click.prevent="openForecast" title="Forecast Report" t-attf-class="fa fa-fw fa-area-chart ms-1 {{getColorClass(data.receipt.decorator)}}"/>
</t>
</td>
<td class="text-end" t-if="props.showOptions.unitCosts" t-out="formatMonetary(data.unit_cost)"/>
<td t-attf-class="text-end {{ getColorClass(data.mo_cost_decorator) }}" t-if="props.showOptions.moCosts" t-out="formatMonetary(data.mo_cost)"/>
<td class="text-end" t-if="props.showOptions.bomCosts" t-out="formatMonetary(data.bom_cost)"/>
<td t-attf-class="text-end {{ getColorClass(data.real_cost_decorator) }}" t-if="props.showOptions.realCosts" t-out="formatMonetary(data.real_cost)"/>
</tr>
</templates>

View file

@ -0,0 +1,93 @@
import { Component, useState } from "@odoo/owl";
import { useBus } from "@web/core/utils/hooks";
import { formatFloat, formatFloatTime, formatMonetary } from "@web/views/fields/formatters";
import { MoOverviewLine } from "../mo_overview_line/mrp_mo_overview_line";
import { SHOW_OPTIONS } from "../mo_overview_display_filter/mrp_mo_overview_display_filter";
export class MoOverviewOperationsBlock extends Component {
static template = "mrp.MoOverviewOperationsBlock";
static components = {
MoOverviewLine,
};
static props = {
unfoldAll: { type: Boolean, optional: true },
operations: Array,
summary: {
type: Object,
shape: {
index: String,
quantity: { type: Number, optional: true },
quantity_decorator: { type: [String, Boolean], optional: true },
mo_cost: { type: Number, optional: true },
mo_cost_decorator: { type: [String, Boolean], optional: true },
bom_cost: { type: [Number, Boolean], optional: true },
real_cost: { type: Number, optional: true },
real_cost_decorator: { type: [String, Boolean], optional: true },
uom_name: { type: String, optional: true },
currency_id: { type: Number, optional: true },
currency: { type: String, optional: true },
done: { type: Boolean, optional: true },
},
},
showOptions: SHOW_OPTIONS,
};
static defaultProps = {
unfoldAll: false,
};
setup() {
this.formatFloatTime = formatFloatTime;
this.state = useState({
// Unfold the main MO's operations by default
isFolded: this.level > 0 && !this.props.unfoldAll,
});
if (this.props.unfoldAll) {
this.env.overviewBus.trigger("update-folded", { indexes: [this.index], isFolded: false });
}
useBus(this.env.overviewBus, "unfold-all", () => this.unfold());
}
//---- Handlers ----
toggleFolded() {
this.state.isFolded = !this.state.isFolded;
this.env.overviewBus.trigger("update-folded", { indexes: [this.index], isFolded: this.state.isFolded });
}
unfold() {
this.state.isFolded = false;
this.env.overviewBus.trigger("update-folded", { indexes: [this.index], isFolded: false });
}
//---- Helpers ----
formatMonetary(val) {
return formatMonetary(val, { currencyId: this.props.summary.currency_id });
}
getColorClass(decorator) {
return decorator ? `text-${decorator}` : "";
}
//---- Getters ----
get hasOperations() {
return this.props?.operations?.length > 0;
}
get level() {
return this.hasOperations ? this.props.operations[0].level - 1 : 0;
}
get index() {
return this.props.summary.index;
}
get totalQuantity() {
// Float for Hours when displaying done productions, FloatTime for Minutes otherwise.
return this.props.summary?.done ?
formatFloat(this.props.summary.quantity, { digits: [false, this.props.operations[0].uom_precision || undefined] }) :
formatFloatTime(this.props.summary.quantity)
}
}

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mrp.MoOverviewOperationsBlock">
<t t-if="hasOperations">
<tr t-on-click="toggleFolded">
<td class="text-start">
<span t-attf-style="margin-left: {{ level * 20 }}px"/>
<span class="btn btn-light ps-0 py-0" t-attf-aria-label="{{ state.isFolded ? 'Unfold' : 'Fold' }}" t-attf-title="{{ state.isFolded ? 'Unfold' : 'Fold' }}">
<i t-attf-class="fa fa-fw fa-caret-{{ state.isFolded ? 'right' : 'down' }}" role="img"/>
Operations
</span>
</td>
<td class="text-center" t-if="props.showOptions.replenishments"/>
<td t-attf-class="text-end" t-out="totalQuantity"/>
<td class="text-start" t-if="props.showOptions.uom" t-out="props.summary.uom_name"/>
<td class="text-end" t-if="props.showOptions.availabilities"/>
<td class="text-end" t-if="props.showOptions.availabilities"/>
<td class="text-end" t-if="props.showOptions.receipts"/>
<td class="text-end" t-if="props.showOptions.unitCosts"/>
<td t-attf-class="text-end {{ getColorClass(props.summary.mo_cost_decorator) }}" t-if="props.showOptions.moCosts" t-out="formatMonetary(props.summary.mo_cost)"/>
<td class="text-end" t-if="props.showOptions.bomCosts" t-out="formatMonetary(props.summary.bom_cost)"/>
<td t-attf-class="text-end {{ getColorClass(props.summary.real_cost_decorator) }}" t-if="props.showOptions.realCosts" t-out="formatMonetary(props.summary.real_cost)"/>
</tr>
<t t-if="!state.isFolded" t-foreach="props.operations" t-as="operation" t-key="operation.index">
<MoOverviewLine data="operation" showOptions="props.showOptions"/>
</t>
</t>
</t>
</templates>

View file

@ -0,0 +1,22 @@
import { ProductCatalogKanbanController } from "@product/product_catalog/kanban_controller";
import { patch } from "@web/core/utils/patch";
import { _t } from "@web/core/l10n/translation";
patch(ProductCatalogKanbanController.prototype, {
setOrderStateInfo() {
if (["mrp.bom", "mrp.production"].includes(this.orderResModel)) {
return {};
}
return super.setOrderStateInfo();
},
_defineButtonContent() {
if (this.orderResModel === "mrp.bom") {
this.buttonString = _t("Back to BoM");
} else if (this.orderResModel === "mrp.production") {
this.buttonString = _t("Back to Production");
} else {
super._defineButtonContent();
}
},
});

View file

@ -0,0 +1,76 @@
import { Component, useState } from "@odoo/owl";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { registry } from "@web/core/registry";
import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
import { useService } from "@web/core/utils/hooks";
export class MOListViewDropdown extends Component {
static template = "mrp.MOViewListDropdown";
static components = {
Dropdown,
DropdownItem,
};
static props = { ...standardWidgetProps };
setup() {
this.orm = useService("orm");
this.action = useService("action");
this.workorderState = useState({
state: this.props.record.data.state,
});
this.colorIcons = {
"blocked": "bg-warning",
"ready": "bg-muted",
"progress": "bg-info",
"cancel": "bg-danger",
"done": "bg-success",
};
}
async reload(){
await this.env.model.root.load();
this.env.model.notify();
}
get statusColor() {
const state = this.workorderState.state;
return this.colorIcons[state] || "";
}
async setState(state) {
let selectedWorkorders = this.props.record.model.root.selection;
if (!selectedWorkorders || selectedWorkorders.length == 0) {
selectedWorkorders = [this.props.record];
}
let ids = selectedWorkorders.filter((wo) => !([state, 'done'].includes(wo.data.state) || wo.data.production_state == 'done')).map((wo) => wo.resId)
if (ids && ids.length > 0) {
await this.callOrm("set_state", [state], ids);
}
}
async callOrm(functionName, args, ids = undefined) {
if (!ids){
ids = this.props.record.model.root.selection?.map((element) => element.evalContext.id);
}
// if no records selected, take the current clicked one
if (!ids || ids.length == 0) {
ids = [this.props.record.resId];
}
if (args !== undefined) {
await this.orm.call("mrp.workorder", functionName, [ids, ...args]);
} else {
await this.orm.call("mrp.workorder", functionName, [ids]);
}
await this.reload();
}
}
export const moListViewDropdown = {
listViewWidth: 20,
component: MOListViewDropdown,
};
registry.category("view_widgets").add("mo_view_list_dropdown", moListViewDropdown);

View file

@ -0,0 +1,13 @@
.o_widget_mo_view_list_dropdown {
display: inline-block;
vertical-align: middle;
height: 100%;
.o_status {
width: 16px;
height: 16px;
}
}
td.o_data_cell:has(> div.o_widget_mo_view_list_dropdown) {
overflow: visible !important;
}

View file

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mrp.MOViewListDropdown" style="btn" t-if="this.props.record.data.state !== 'done' and !['draft', 'done', 'cancel'].includes(this.props.record.data.production_state)">
<Dropdown>
<button t-attf-class="btn btn-link d-flex p-0 pb-1 {{!this.props.record.resId ? 'invisible': ''}}">
<div class="d-flex align-items-center">
<span t-attf-class="o_status {{statusColor}}"/>
</div>
</button>
<t t-set-slot="content">
<DropdownItem
class="`d-flex align-items-center`"
onSelected="() => this.setState('blocked')">
<span t-attf-class="fa fa-lg fa-exclamation-circle text-warning me-2"/>
<span>Blocked</span>
</DropdownItem>
<DropdownItem
class="`d-flex align-items-center`"
onSelected="() => this.setState('ready')">
<span t-attf-class="fa fa-lg fa-circle text-muted me-2"/>
<span>To Do</span>
</DropdownItem>
<DropdownItem
class="`d-flex align-items-center`"
onSelected="() => this.setState('progress')">
<span t-attf-class="fa fa-lg fa-play-circle text-info me-2"/>
<span>In Progress</span>
</DropdownItem>
<DropdownItem
class="`d-flex align-items-center`"
onSelected="() => this.setState('cancel')">
<span t-attf-class="fa fa-lg fa-times-circle text-danger me-2"/>
<span>Cancelled</span>
</DropdownItem>
<DropdownItem
class="`d-flex align-items-center`"
onSelected="() => this.setState('done')">
<span t-attf-class="fa fa-lg fa-check-circle text-success me-2"/>
<span>Done</span>
</DropdownItem>
</t>
</Dropdown>
</t>
</templates>

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View file

@ -1,12 +1,10 @@
/** @odoo-module **/
import { ForecastedButtons } from "@stock/stock_forecasted/forecasted_buttons";
import { patch } from '@web/core/utils/patch';
import { patch } from "@web/core/utils/patch";
import { onWillStart } from "@odoo/owl";
const { onWillStart } = owl;
patch(ForecastedButtons.prototype, 'mrp.ForecastedButtons',{
patch(ForecastedButtons.prototype, {
setup() {
this._super.apply();
super.setup();
onWillStart(async () =>{
const fields = this.resModel === "product.template" ? ['bom_ids'] : ['bom_ids', 'variant_bom_ids'];
const res = (await this.orm.call(this.resModel, 'read', [this.productId], { fields }))[0];
@ -18,6 +16,9 @@ patch(ForecastedButtons.prototype, 'mrp.ForecastedButtons',{
return this.actionService.doAction("mrp.action_report_mrp_bom", {
additionalContext: {
active_id: this.bomId,
active_product_id: this.productId,
active_model: this.resModel,
mode: "forecast",
},
});
}

View file

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<templates xml:space="preserve">
<t t-name="mrp.ForecastedButtons" owl="1" t-inherit="stock.ForecastedButtons" t-inherit-mode="extension">
<t t-name="mrp.ForecastedButtons" t-inherit="stock.ForecastedButtons" t-inherit-mode="extension">
<xpath expr="//button[@title='Replenish']" position="after">
<button t-if="bomId" t-name="mrp_replenish_report_buttons"
class="btn btn-primary o_bom_overview_report"
class="btn btn-secondary o_bom_overview_report"
type="button" title="Manufacturing Forecast"
t-on-click="_onClickBom">
Manufacturing Forecast

View file

@ -0,0 +1,9 @@
import { ForecastedDetails } from '@stock/stock_forecasted/forecasted_details';
import { patch } from "@web/core/utils/patch";
patch(ForecastedDetails.prototype, {
canReserveOperation(line){
return super.canReserveOperation(line) || line.move_out?.raw_material_production_id;
}
});

View file

@ -1,35 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template">
<t t-name="mrp.ForecastedDetails" owl="1" t-inherit="stock.ForecastedDetails" t-inherit-mode="extension">
<t t-name="mrp.ForecastedDetails" t-inherit="stock.ForecastedDetails" t-inherit-mode="extension">
<xpath expr="//tr[@name='draft_picking_in']" position="after">
<tr t-if="props.docs.draft_production_qty.in" name="draft_mo_in">
<td colspan="2">Production of Draft MO</td>
<td t-esc="_formatFloat(props.docs.draft_production_qty.in)" class="text-end"/>
<tr t-if="currentProduct.draft_production_qty.in" name="draft_mo_in" t-attf-class="#{multipleProducts and 'collapse show' or ''} collapseGroup_#{line.product.id}">
<td/>
<td>Production of Draft MO</td>
<td class="text-end text-success">
<t t-out="_formatFloat(currentProduct.draft_production_qty.in)"/> <t t-out="line.uom_id.display_name"/>
</td>
</tr>
</xpath>
<xpath expr="//tr[@name='draft_picking_out']" position="after">
<tr t-if="props.docs.draft_production_qty.out" name="draft_mo_out">
<td colspan="2">Component of Draft MO</td>
<td t-esc="_formatFloat(-props.docs.draft_production_qty.out)" class="text-end"/>
<tr t-if="currentProduct.draft_production_qty.out" name="draft_mo_out" t-attf-class="#{multipleProducts and 'collapse show' or ''} collapseGroup_#{line.product.id}">
<td/>
<td>Component of Draft MO</td>
<td class="text-end">
<span class="text-danger">
<t t-out="_formatFloat(-currentProduct.draft_production_qty.out)"/> <t t-out="line.uom_id.display_name"/>
</span>
</td>
</tr>
</xpath>
<xpath expr="//button[@name='unreserve_link']" position="after">
<button t-if="line.move_out and line.move_out.raw_material_production_id and line.move_out.raw_material_production_id.unreserve_visible"
class="btn btn-sm btn-primary o_report_replenish_unreserve"
t-on-click="() => this._unreserve('mrp.production', line.move_out.raw_material_production_id.id)">
Unreserve
</button>
</xpath>
<xpath expr="//button[@name='reserve_link']" position="after">
<button t-if="line.move_out and line.move_out.raw_material_production_id and line.move_out.raw_material_production_id.reserve_visible"
class="btn btn-sm btn-primary o_report_replenish_reserve"
t-on-click="() => this._reserve('mrp.production', line.move_out.raw_material_production_id.id)">
Reserve
</button>
</xpath>
<xpath expr="//button[@name='change_priority_link']" position="after">
<button t-if="line.move_out and line.move_out.raw_material_production_id"
t-attf-class="o_priority o_priority_star o_report_replenish_change_priority fa fa-star#{line.move_out.raw_material_production_id.priority=='1' ? ' one' : '-o zero'}"
t-attf-class="o_priority o_priority_star fa fa-star#{line.move_out.raw_material_production_id.priority=='1' ? ' one' : '-o zero'}"
t-on-click="() => this._onClickChangePriority('mrp.production', line.move_out.raw_material_production_id)"/>
</xpath>
</t>

View file

@ -0,0 +1,8 @@
import { ProductCatalogOrderLine } from "@product/product_catalog/order_line/order_line";
import { patch } from "@web/core/utils/patch";
patch(ProductCatalogOrderLine.prototype, {
get showPrice() {
return super.showPrice && this.env.orderResModel !== "mrp.production";
}
});

View file

@ -0,0 +1,21 @@
.o_mrp_overview {
.o_control_panel_breadcrumbs {
max-width: 30%;
}
.o_control_panel_actions {
justify-content: start!important;
max-width: 100%;
}
.o_control_panel_navigation {
max-width: 25%;
}
.input-group {
max-width: 250px;
}
input.o_input.o-autocomplete--input {
max-width: 150px;
}
input.o_input {
max-width: 75px;
}
}

View file

@ -1,19 +0,0 @@
@mixin gantt-decoration-color($color) {
background-image: linear-gradient($color, $color);
background-color: lighten($color, 10%);
&:before {
content: none;
}
}
.o_mrp_workorder_gantt .o_gantt_view .o_gantt_row_container .o_gantt_row .o_gantt_cell .o_gantt_pill_wrapper {
div.o_gantt_pill.decoration-success {
@include gantt-decoration-color($success);
}
div.o_gantt_pill.decoration-warning {
@include gantt-decoration-color(map-get($grays, '500'));
}
div.o_gantt_pill.decoration-danger {
@include gantt-decoration-color($danger);
}
}

View file

@ -0,0 +1,3 @@
.o_mrp_mo_overview_thead {
background-color: #{$o-webclient-background-color};
}

View file

@ -0,0 +1,153 @@
div.o_mrp_routing_form div.o_field_integer.o_small_integer {
&, input {
max-width: 2rem;
}
}
.o_mrp_routing_image {
@extend %o-nocontent-init-image;
overflow: hidden;
min-width: 650px;
margin-top: 50px;
background: url('/mrp/static/img/routing.svg') no-repeat top left;
@include media-breakpoint-down(md) {
margin-left: 22rem;
}
img {
width: 260px;
}
div.separator {
background-color: gray;
}
.o_annotation {
color: gray;
}
.o_annotation:before {
content: "";
@include o-position-absolute($top: 50%);
border: $border-width * 2 solid gray;
width: 2rem;
}
.o_annotation_end:before {
right: 105%;
border-top-left-radius: $border-radius-lg;
border-bottom: none;
border-right: none;
}
.o_annotation_start:before {
left: 105%;
border-top-right-radius: $border-radius-lg;
border-bottom: none;
border-left: none;
}
.o_annotation_1 {
top: 0;
&:before {
height: 7rem;
}
}
.o_annotation_2 {
&:before {
height: 4rem;
}
}
.o_annotation_3 {
&:before {
height: 4rem;
}
}
.o_annotation_4 {
&:before {
height: 4rem;
}
}
.o_annotation_5 {
&:before {
height: 3rem;
}
}
.o_text {
color: gray;
margin: auto;
}
}
.o_mrp_routing_image.bill_of_material {
min-height: 300px;
img {
left: 180px;
top: 60px;
}
// div.separator {
// left: 490px;
// top: 40px;
// height: 250px;
// width: 2px;
// }
.o_annotation_1 {
left: 323px;
}
.o_annotation_2 {
top: 27px;
left: 360px;
}
.o_annotation_3 {
top: 40px;
left: 133px;
}
.o_annotation_4 {
top: 70px;
left: 90px;
}
.o_annotation_5 {
top: 97px;
left: 58px;
}
.o_text {
left: 510px;
top: 80px;
}
}
.o_mrp_routing_image.settings {
min-height: 600px;
img {
left: 179px;
top: 61px;
}
div.separator {
left: 125px;
top: 300px;
height: 2px;
width: 350px;
}
.o_annotation_1 {
left: 325px;
}
.o_annotation_2 {
top: 29px;
left: 360px;
}
.o_annotation_3 {
top: 35px;
left: 120px;
}
.o_annotation_4 {
top: 65px;
left: 70px;
}
.o_annotation_5 {
top: 95px;
left: 45px;
}
.o_text {
left: 15%;
top: 55%;
right: 25%;
}
}

View file

@ -1,27 +1,36 @@
.o_kanban_dashboard{
&.o_mrp_workorder_kanban {
.o_kanban_record {
flex-basis: 40%;
@include media-breakpoint-down(lg) {
flex-basis: 100%
}
}
.o_kanban_group .o_kanban_record_top {
flex-direction: column;
}
.o_kanban_record_top {
justify-content: space-between;
.o_kanban_workorder_title {
flex-basis: 40%;
}
.o_kanban_workorder_date {
}
.o_kanban_workorder_status {
flex-basis: 20%;
}
.o_mrp_workorder_kanban {
.o_kanban_record {
flex-basis: 40%;
@include media-breakpoint-down(lg) {
flex-basis: 100%
}
}
&.o_workcenter_kanban {
--KanbanRecord-width: 400px;
.o_kanban_group .o_kanban_record_top {
flex-direction: column;
}
.o_kanban_record_top {
justify-content: space-between;
.o_kanban_workorder_title {
flex-basis: 40%;
}
.o_kanban_workorder_status {
flex-basis: 20%;
}
}
}
.o_workcenter_kanban {
--KanbanRecord-width: 400px;
.o_kanban_renderer {
--KanbanRecord-width: 480px;
@include media-only(screen) {
--KanbanGroup-width: 480px;
}
@include media-only(print) {
--KanbanGroup-width: 400px;
}
}
.o_dashboard_graph {
margin: 0px -8px;
}
}

View file

@ -1,57 +0,0 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { CharField } from "@web/views/fields/char/char_field";
const { useState } = owl;
export class SlidesViewer extends CharField {
setup() {
super.setup();
this.notification = useService("notification");
this.page = 1;
this.state = useState({
isValid: true,
});
}
get fileName() {
return this.state.fileName || this.props.value || "";
}
_get_slide_page() {
return this.props.record.data[this.props.name+'_page'] ? this.props.record.data[this.props.name+'_page'] : this.page;
}
get url() {
var src = false;
if (this.props.value) {
// check given google slide url is valid or not
var googleRegExp = /(^https:\/\/docs.google.com).*(\/d\/e\/|\/d\/)([A-Za-z0-9-_]+)/;
var google = this.props.value.match(googleRegExp);
if (google && google[3]) {
src =
"https://docs.google.com/presentation" +
google[2] +
google[3] +
"/preview?slide=" +
this._get_slide_page();
}
}
return src || this.props.value;
}
onLoadFailed() {
this.state.isValid = false;
this.notification.add(this.env._t("Could not display the selected spreadsheet"), {
type: "danger",
});
}
}
SlidesViewer.template = "mrp.SlidesViewer";
SlidesViewer.displayName = _lt("Google Slides Viewer");
registry.category("fields").add("embed_viewer", SlidesViewer);

View file

@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mrp.SlidesViewer" t-inherit="web.CharField">
<xpath expr="//t[@t-else='']" position="after">
<t t-if="url">
<iframe class="o_embed_iframe w-100"
alt="Slides viewer"
t-att-src="url"
t-att-name="props.name"
t-on-error="onLoadFailed"
/>
</t>
</xpath>
</t>
</templates>

View file

@ -1,40 +1,11 @@
/** @odoo-module **/
import { patch } from "@web/core/utils/patch";
import { ProductDocumentKanbanController } from "@product/js/product_document_kanban/product_document_kanban_controller";
import { KanbanController } from "@web/views/kanban/kanban_controller";
import { useBus, useService } from "@web/core/utils/hooks";
const { useRef } = owl;
export class MrpDocumentsKanbanController extends KanbanController {
patch(ProductDocumentKanbanController.prototype, {
setup() {
super.setup();
this.uploadFileInputRef = useRef("uploadFileInput");
this.fileUploadService = useService("file_upload");
useBus(
this.fileUploadService.bus,
"FILE_UPLOAD_LOADED",
async () => {
await this.model.root.load();
this.model.notify();
},
);
}
async onFileInputChange(ev) {
if (!ev.target.files.length) {
return;
super.setup(...arguments);
if (this.props.context.attached_on_bom) {
this.formData.attached_on_bom = this.props.context.bom_id;
}
await this.fileUploadService.upload(
"/mrp/upload_attachment",
ev.target.files,
{
buildFormData: (formData) => {
formData.append("res_model", this.props.context.default_res_model);
formData.append("res_id", this.props.context.default_res_id);
},
},
);
// Reset the file input's value so that the same file may be uploaded twice.
ev.target.value = "";
}
}
},
});

View file

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="mrp.MrpDocumentsKanbanView.Buttons" t-inherit="web.KanbanView.Buttons" t-inherit-mode="primary" owl="1">
<div role="toolbar" position="inside">
<input type="file" multiple="true" t-ref="uploadFileInput" class="o_input_file o_hidden" t-on-change.stop="onFileInputChange"/>
<button type="button" t-attf-class="btn btn-primary o_mrp_documents_kanban_upload"
t-on-click.stop.prevent="() => this.uploadFileInputRef.el.click()">
Upload
</button>
</div>
</t>
</templates>

View file

@ -1,41 +0,0 @@
/** @odoo-module **/
import { CANCEL_GLOBAL_CLICK, KanbanRecord } from "@web/views/kanban/kanban_record";
import { useService } from "@web/core/utils/hooks";
export class MrpDocumentsKanbanRecord extends KanbanRecord {
setup() {
super.setup();
this.messaging = useService("messaging");
}
/**
* @override
*
* Override to open the preview upon clicking the image, if compatible.
*/
onGlobalClick(ev) {
if (ev.target.closest(CANCEL_GLOBAL_CLICK) && !ev.target.classList.contains("o_mrp_download")) {
return;
}
if (ev.target.classList.contains("o_mrp_download")) {
window.location = `/web/content/mrp.document/${this.props.record.resId}/datas?download=true`;
return;
} else if (ev.target.closest(".o_kanban_previewer")) {
this.messaging.get().then((messaging) => {
const attachmentList = messaging.models["AttachmentList"].insert({
selectedAttachment: messaging.models["Attachment"].insert({
id: this.props.record.data.ir_attachment_id[0],
filename: this.props.record.data.name,
name: this.props.record.data.name,
mimetype: this.props.record.data.mimetype,
}),
});
this.dialog = messaging.models["Dialog"].insert({
attachmentListOwnerAsAttachmentView: attachmentList,
});
});
return;
}
return super.onGlobalClick(...arguments);
}
}

View file

@ -1,22 +0,0 @@
/** @odoo-module **/
import { useService } from "@web/core/utils/hooks";
import { KanbanRenderer } from "@web/views/kanban/kanban_renderer";
import { MrpDocumentsKanbanRecord } from "@mrp/views/mrp_documents_kanban/mrp_documents_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 MrpDocumentsKanbanRenderer extends KanbanRenderer {
setup() {
super.setup();
this.fileUploadService = useService("file_upload");
}
}
MrpDocumentsKanbanRenderer.components = {
...KanbanRenderer.components,
FileUploadProgressContainer,
FileUploadProgressKanbanRecord,
KanbanRecord: MrpDocumentsKanbanRecord,
};
MrpDocumentsKanbanRenderer.template = "mrp.MrpDocumentsKanbanRenderer";

View file

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="mrp.MrpDocumentsKanbanRenderer" owl="1" 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>

View file

@ -1,16 +0,0 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { kanbanView } from "@web/views/kanban/kanban_view";
import { MrpDocumentsKanbanController } from "@mrp/views/mrp_documents_kanban/mrp_documents_kanban_controller";
import { MrpDocumentsKanbanRenderer } from "@mrp/views/mrp_documents_kanban/mrp_documents_kanban_renderer";
export const mrpDocumentsKanbanView = {
...kanbanView,
Controller: MrpDocumentsKanbanController,
Renderer: MrpDocumentsKanbanRenderer,
buttonTemplate: "mrp.MrpDocumentsKanbanView.Buttons",
};
registry.category("views").add("mrp_documents_kanban", mrpDocumentsKanbanView);

View file

@ -1,32 +0,0 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { FloatField } from '@web/views/fields/float/float_field';
const { useRef, useEffect } = owl;
export class MrpConsumed extends FloatField {
setup() {
super.setup();
const inputRef = useRef("numpadDecimal");
useEffect(
(inputEl) => {
if (inputEl) {
inputEl.addEventListener("input", this.onInput.bind(this));
return () => {
inputEl.removeEventListener("input", this.onInput.bind(this));
};
}
},
() => [inputRef.el]
);
}
onInput(ev) {
this.props.setDirty(true);
return this.props.record.update({ manual_consumption: true });
}
}
registry.category('fields').add('mrp_consumed', MrpConsumed);

View file

@ -1,21 +0,0 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { X2ManyField } from "@web/views/fields/x2many/x2many_field";
import { ListRenderer } from "@web/views/list/list_renderer";
export class MrpProductionComponentsListRenderer extends ListRenderer {
getCellClass(column, record) {
let classNames = super.getCellClass(...arguments);
if (column.name == "quantity_done" && !record.data.manual_consumption) {
classNames += ' o_non_manual_consumption';
}
return classNames;
}
}
export class MrpProductionComponentsX2ManyField extends X2ManyField {}
MrpProductionComponentsX2ManyField.components = { ...X2ManyField.components, ListRenderer: MrpProductionComponentsListRenderer };
MrpProductionComponentsX2ManyField.additionalClasses = ['o_field_many2many'];
registry.category("fields").add("mrp_production_components_x2many", MrpProductionComponentsX2ManyField);

View file

@ -1,3 +0,0 @@
td.o_non_manual_consumption {
background-color: rgba($gray-200, .5) !important;
}

View file

@ -0,0 +1,15 @@
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { RemainingDaysField } from "@web/views/fields/remaining_days/remaining_days_field";
export class MrpRemainingDaysUnformattedField extends RemainingDaysField {
static template = "mrp.MrpRemainingDaysUnformattedField"
}
export const mrpRemainingDaysUnformattedField = {
component: MrpRemainingDaysUnformattedField,
displayName: _t("Remaining Days"),
supportedTypes: ["date", "datetime"],
};
registry.category("fields").add("mrp_remaining_days_unformatted", mrpRemainingDaysUnformattedField);

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mrp.MrpRemainingDaysUnformattedField" t-inherit="web.RemainingDaysField">
<xpath expr="//div" position="attributes">
<attribute name="t-att-class"></attribute>
</xpath>
</t>
</templates>

View file

@ -1,8 +1,7 @@
/** @odoo-module **/
import { FloatField } from "@web/views/fields/float/float_field";
import { registry } from "@web/core/registry";
import { FloatField, floatField } from "@web/views/fields/float/float_field";
import { formatFloat } from "@web/views/fields/formatters";
import { registry } from "@web/core/registry";
import { useRef, onPatched, onMounted, useState } from "@odoo/owl";
/**
* This widget is used to display alongside the total quantity to consume of a production order,
@ -12,8 +11,8 @@ import { formatFloat } from "@web/views/fields/formatters";
* The widget will be '3.000 / 5.000'.
*/
const { useRef, onPatched, onMounted, useState } = owl;
export class MrpShouldConsumeOwl extends FloatField {
static template = "mrp.ShouldConsume";
setup() {
super.setup();
this.fields = this.props.record.fields;
@ -41,9 +40,12 @@ export class MrpShouldConsumeOwl extends FloatField {
...this.nodeOptions,
});
}
}
}
MrpShouldConsumeOwl.template = "mrp.ShouldConsume";
MrpShouldConsumeOwl.displayName = "MRP Should Consume";
export const mrpShouldConsumeOwl = {
...floatField,
component: MrpShouldConsumeOwl,
displayName: "MRP Should Consume",
};
registry.category("fields").add("mrp_should_consume", MrpShouldConsumeOwl);
registry.category("fields").add("mrp_should_consume", mrpShouldConsumeOwl);

View file

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mrp.ShouldConsume" owl="1">
<t t-name="mrp.ShouldConsume">
<t t-if="displayShouldConsume">
<span t-attf-class="o_should_consume ps-1 {{!props.readonly ? 'o_row mx-0' : ''}}">
<span><t t-esc="shouldConsumeQty"/> / </span>
<span t-attf-class="o_should_consume">
<span t-if="props.readonly"><t t-esc="shouldConsumeQty"/> / </span>
<t t-call="web.FloatField"/>
</span>
</t>

View file

@ -1,7 +1,10 @@
/** @odoo-module */
import { useService } from "@web/core/utils/hooks";
import { registry } from "@web/core/registry";
import { PopoverComponent, PopoverWidgetField } from '@stock/widgets/popover_widget';
import {
PopoverComponent,
PopoverWidgetField,
popoverWidgetField,
} from "@stock/widgets/popover_widget";
/**
* Link to a Char field representing a JSON:
@ -30,10 +33,13 @@ class WorkOrderPopover extends PopoverComponent {
}
};
class WorkOrderPopoverField extends PopoverWidgetField {};
class WorkOrderPopoverField extends PopoverWidgetField {
static components = {
Popover: WorkOrderPopover,
};
}
WorkOrderPopoverField.components = {
Popover: WorkOrderPopover
};
registry.category("fields").add("mrp_workorder_popover", WorkOrderPopoverField);
registry.category("fields").add("mrp_workorder_popover", {
...popoverWidgetField,
component: WorkOrderPopoverField,
});

View file

@ -1,10 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<div t-name="mrp.workorderPopover" owl="1">
<div t-name="mrp.workorderPopover">
<h6>Scheduling Information</h6>
<t t-foreach="props.infos" t-as="info" t-key="info_index">
<i t-attf-class="fa fa-arrow-right me-2 #{ info.color }"></i><t t-esc="info.msg"/><br/>
<i t-attf-class="oi oi-arrow-right me-2 #{ info.color }"></i><t t-esc="info.msg"/><br/>
</t>
<button t-if="props.replan" t-on-click="onReplanClick" class="btn btn-sm btn-primary m-1 float-end action_replan_button">Replan</button>
</div>

View file

@ -1,11 +1,10 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { parseFloatTime } from "@web/views/fields/parsers";
import { useInputField } from "@web/views/fields/input_field_hook";
const { Component, useState, onWillUpdateProps, onWillStart, onWillDestroy } = owl;
import { useRecordObserver } from "@web/model/relational_model/utils";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { Component, useState, onWillUpdateProps, onWillStart, onWillDestroy } from "@odoo/owl";
function formatMinutes(value) {
if (value === false) {
@ -23,53 +22,39 @@ function formatMinutes(value) {
}
export class MrpTimer extends Component {
static template = "mrp.MrpTimer";
static props = {
value: { type: Number },
ongoing: { type: Boolean, optional: true },
};
static defaultProps = { ongoing: false };
setup() {
this.orm = useService('orm');
this.state = useState({
// duration is expected to be given in minutes
duration:
this.props.value !== undefined ? this.props.value : this.props.record.data.duration,
duration: this.props.value,
});
useInputField({
getValue: () => this.durationFormatted,
refName: "numpadDecimal",
parse: (v) => parseFloatTime(v),
});
this.ongoing =
this.props.ongoing !== undefined
? this.props.ongoing
: this.props.record.data.is_user_working;
onWillStart(async () => {
if(this.props.ongoing === undefined && !this.props.record.model.useSampleModel && this.props.record.data.state == "progress") {
const additionalDuration = await this.orm.call('mrp.workorder', 'get_working_duration', [this.props.record.resId]);
this.state.duration += additionalDuration;
}
this.lastDateTime = Date.now();
this.ongoing = this.props.ongoing;
onWillStart(() => {
if (this.ongoing) {
this._runTimer();
this._runSleepTimer();
}
});
onWillUpdateProps((nextProps) => {
const newOngoing =
"ongoing" in nextProps
? nextProps.ongoing
: "record" in nextProps && nextProps.record.data.is_user_working;
const rerun = !this.ongoing && newOngoing;
this.ongoing = newOngoing;
const rerun = !this.ongoing && nextProps.ongoing;
this.ongoing = nextProps.ongoing;
if (rerun) {
this.state.duration = nextProps.value;
this._runTimer();
this._runSleepTimer();
}
});
onWillDestroy(() => clearTimeout(this.timer));
}
get durationFormatted() {
if(this.props.value!=this.state.duration && this.props.record && this.props.record.isDirty){
if (typeof this.props.setDirty==='function')this.props.setDirty(false);
this.state.duration=this.props.value
}
return formatMinutes(this.state.duration);
}
@ -81,10 +66,64 @@ export class MrpTimer extends Component {
}
}, 1000);
}
//updates the time when the computer wakes from sleep mode
_runSleepTimer() {
this.timer = setTimeout(async () => {
const diff = Date.now() - this.lastDateTime - 10000;
if (diff > 1000) {
this.state.duration += diff / (1000 * 60);
}
this.lastDateTime = Date.now();
this._runSleepTimer();
}, 10000);
}
}
MrpTimer.supportedTypes = ["float"];
MrpTimer.template = "mrp.MrpTimer";
class MrpTimerField extends Component {
static template = "mrp.MrpTimerField";
static components = { MrpTimer };
static props = standardFieldProps;
registry.category("fields").add("mrp_timer", MrpTimer);
registry.category("formatters").add("mrp_timer", formatMinutes);
setup() {
this.orm = useService("orm");
useInputField({
getValue: () => this.durationFormatted,
refName: "numpadDecimal",
parse: (v) => parseFloatTime(v),
});
useRecordObserver(async (record) => {
if (!this.props.record.model.useSampleModel && record.data.state === "progress") {
this.duration = await this.orm.call(
"mrp.workorder",
"get_duration",
[this.props.record.resId]
);
} else {
this.duration = record.data[this.props.name];
}
})
onWillDestroy(() => clearTimeout(this.timer));
}
get durationFormatted() {
if (this.props.record.data[this.props.name] != this.duration && this.props.record.dirty) {
this.duration = this.props.record.data[this.props.name];
}
return formatMinutes(this.duration);
}
get ongoing() {
return this.props.record.data.is_user_working;
}
}
export const mrpTimerField = {
component: MrpTimerField,
supportedTypes: ["float"],
};
registry.category("fields").add("mrp_timer", mrpTimerField);
registry.category("formatters").add("mrp_timer", formatMinutes);

View file

@ -1,7 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="mrp.MrpTimer" owl="1">
<span t-if="props.readonly" t-esc="durationFormatted"/>
<t t-name="mrp.MrpTimer">
<span t-esc="durationFormatted"/>
</t>
<t t-name="mrp.MrpTimerField">
<MrpTimer t-if="props.readonly" value="duration" ongoing="ongoing"/>
<input t-else="" t-att-id="props.id" t-ref="numpadDecimal" t-att-placeholder="props.placeholder" inputmode="numeric" class="o_input" />
</t>
</templates>

View file

@ -0,0 +1,123 @@
import { _t } from "@web/core/l10n/translation";
import { cookie } from "@web/core/browser/cookie";
import { getColor, hexToRGBA, darkenColor } from "@web/core/colors/colors";
import { registry } from "@web/core/registry";
import { JournalDashboardGraphField } from "@web/views/fields/journal_dashboard_graph/journal_dashboard_graph_field";
export class WorkcenterDashboardGraphField extends JournalDashboardGraphField{
getBarChartConfig() {
const labels = this.data[0].labels;
const color19 = getColor(1, cookie.get("color_scheme"), "odoo");
const color13 = getColor(2, cookie.get("color_scheme"), "odoo");
const color10 = getColor(3, cookie.get("color_scheme"), "odoo");
const loadBarColor = this.data[0].is_sample_data ? hexToRGBA(color19, 0.1) : color19;
const excessBarColor = this.data[0].is_sample_data ? hexToRGBA(color13, 0.1) : color13;
const maxLoadLineColor = this.data[0].is_sample_data ? hexToRGBA(color10, 0.1) : hexToRGBA(color10, 0.5);
const darkColorOnHover = this.data[0].is_sample_data ? loadBarColor : darkenColor(loadBarColor, 0.1);
return {
type: 'scatter',
data: {
datasets: [
{
type: 'line',
borderColor: maxLoadLineColor,
// normally the line stops in the middle of the first and last columns, which is ugly
// to make it go through all the graph, the single point has been configured as a line
// in the middle of the graph and extended. The real line is not shown.
data: [,,this.data[0].values[1]],
showLine: false,
pointStyle: 'line',
pointRadius: 500,
pointBorderWidth: 2,
// this is so that hovering on the 'line' does not change its appearance
pointHoverRadius: 500,
pointHoverBorderWidth: 2,
},
{
type: 'bar',
backgroundColor: loadBarColor,
data: this.data[0].values[0],
label: "Total Load",
borderWidth: 0,
stack: 'mainStack',
hoverBackgroundColor: function(ctx, options) {
return (ctx.parsed._stacks.y[2] !== 0) ? excessBarColor : darkColorOnHover;
},
},
{
type: 'bar',
backgroundColor: excessBarColor,
data: this.data[0].values[2],
label: "Excess Load",
borderWidth: 0,
stack: 'mainStack',
}
],
labels,
},
options: {
layout: {
autoPadding: false,
},
interaction: {
includeInvisible: true,
axis: 'x',
},
plugins: {
legend: { display: false },
tooltip: {
enabled: !this.data[0].is_sample_data,
intersect: true,
mode: 'nearest',
position: 'nearest',
caretSize: 0,
filter: function(ctx) {
if (ctx.dataset.type !== 'line') {
return ctx;
}
},
callbacks: {
label: function(ctx) {
if (ctx.datasetIndex === 1) {
const totalLoadValue = Object.values(ctx.parsed._stacks.y._visualValues).reduce((accu, curr) => {return accu + curr}, 0);
return _t(ctx.dataset.label + ": " + totalLoadValue + " hours");
}
return _t(ctx.dataset.label + ": " + ctx.parsed.y + " hours");
},
title: function(ctx) {
return "";
},
},
},
},
scales: {
y: {
display: false,
suggestedMax: this.data[0].values[1] * 1.5,
stacked: true,
},
x: {
type: 'category',
grid: {
display: false,
},
border: {
display: false,
},
},
},
maintainAspectRatio: false,
},
};
}
}
export const workcenterDashboardGraphField = {
component: WorkcenterDashboardGraphField,
supportedTypes: ["text"],
extractProps: ({ attrs }) => ({
graphType: attrs.graph_type,
}),
};
registry.category("fields").add("workcenter_dashboard_graph", workcenterDashboardGraphField);

View file

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<div t-name="MrpDocumentsKanbanView.buttons">
<button type="button" t-attf-class="btn btn-primary o_mrp_documents_kanban_upload">
Upload
</button>
</div>
</templates>

View file

@ -0,0 +1,5 @@
import { models } from "@web/../tests/web_test_helpers";
export class ProductDocument extends models.ServerModel {
_name = "product.document";
}

View file

@ -0,0 +1,13 @@
import { mailModels } from "@mail/../tests/mail_test_helpers";
import { fields } from "@web/../tests/web_test_helpers";
export class ResFake extends mailModels.ResFake {
duration = fields.Float({ string: "duration" });
_views = {
form: /* xml */ `
<form>
<field name="duration" widget="mrp_timer" readonly="1"/>
</form>`,
};
}

View file

@ -0,0 +1,145 @@
import {
click,
contains,
openView,
registerArchs,
start,
startServer
} from "@mail/../tests/mail_test_helpers";
import { defineMrpModels } from "@mrp/../tests/mrp_test_helpers";
import { describe, test } from "@odoo/hoot";
import { inputFiles } from "@web/../tests/utils";
import { asyncStep, getService, patchWithCleanup, waitForSteps } from "@web/../tests/web_test_helpers";
import { fileUploadService } from "@web/core/file_upload/file_upload_service";
describe.current.tags("desktop");
defineMrpModels();
const newArchs = {
"product.document,false,kanban": `<kanban js_class="product_documents_kanban" create="false"><templates><t t-name="card">
<field name="name"/>
</t></templates></kanban>`,
};
test("MRP documents kanban basic rendering", async () => {
const pyEnv = await startServer();
const irAttachment = pyEnv["ir.attachment"].create({
mimetype: "image/png",
name: "test.png",
});
pyEnv["product.document"].create([
{ name: "test1", ir_attachment_id: irAttachment, mimetype: "image/png" },
{ name: "test2" },
{ name: "test3" },
]);
registerArchs(newArchs);
await start();
await openView({ res_model: "product.document", views: [[false, "kanban"]] });
await contains("button[name='product_upload_document']");
await contains(".o_kanban_renderer .o_kanban_record:not(.o_kanban_ghost)", { count: 3 });
// check control panel buttons
await contains(".o_control_panel_main_buttons .btn-primary", { text: "Upload" });
});
test("mrp: upload multiple files", async () => {
const pyEnv = await startServer();
const irAttachment = pyEnv["ir.attachment"].create({
mimetype: "image/png",
name: "test.png",
});
const text1 = new File(["hello, world"], "text1.txt", { type: "text/plain" });
const text2 = new File(["hello, world"], "text2.txt", { type: "text/plain" });
const text3 = new File(["hello, world"], "text3.txt", { type: "text/plain" });
pyEnv["product.document"].create([
{ name: "test1", ir_attachment_id: irAttachment, mimetype: "image/png" },
{ name: "test2" },
{ name: "test3" },
]);
registerArchs(newArchs);
await start();
await openView({ res_model: "product.document", views: [[false, "kanban"]] });
getService("file_upload").bus.addEventListener("FILE_UPLOAD_ADDED", () => asyncStep("xhrSend"));
await inputFiles(".o_control_panel_main_buttons .o_input_file", [text1]);
await waitForSteps(["xhrSend"]);
await inputFiles(".o_control_panel_main_buttons .o_input_file", [text2, text3]);
await waitForSteps(["xhrSend"]);
});
test("mrp: click on image opens attachment viewer", async () => {
const newArchs = {
"product.document,false,kanban": `
<kanban js_class="product_documents_kanban" create="false">
<templates>
<t t-name="card">
<div class="o_kanban_previewer" t-if="record.ir_attachment_id.raw_value">
<field name="ir_attachment_id" invisible="1"/>
<img t-attf-src="/web/image/#{record.ir_attachment_id.raw_value}" width="100" height="100" alt="Document" class="o_attachment_image"/>
</div>
<field name="name"/>
<field name="mimetype"/>
</t>
</templates>
</kanban>`,
};
const pyEnv = await startServer();
const irAttachment = pyEnv["ir.attachment"].create({
mimetype: "image/png",
name: "test.png",
});
pyEnv["product.document"].create([
{ name: "test1", ir_attachment_id: irAttachment, mimetype: "image/png" },
{ name: "test2" },
{ name: "test3" },
]);
registerArchs(newArchs);
await start();
await openView({ res_model: "product.document", views: [[false, "kanban"]] });
await click(".o_kanban_previewer");
await contains(".o-FileViewer");
await click(".o-FileViewer-headerButton .fa-times");
await contains(".o-FileViewer", { count: 0 });
});
test("mrp: upload progress bars", async () => {
const pyEnv = await startServer();
const irAttachment = pyEnv["ir.attachment"].create({
mimetype: "image/png",
name: "test.png",
});
const text1 = new File(["hello, world"], "text1.txt", { type: "text/plain" });
pyEnv["product.document"].create([
{ name: "test1", ir_attachment_id: irAttachment, mimetype: "image/png" },
{ name: "test2" },
{ name: "test3" },
]);
registerArchs(newArchs);
await start();
await openView({ res_model: "product.document", views: [[false, "kanban"]] });
let xhr;
patchWithCleanup(fileUploadService, {
createXhr() {
xhr = super.createXhr(...arguments);
xhr.send = () => {};
return xhr;
},
});
await inputFiles(".o_control_panel_main_buttons .o_input_file", [text1]);
const progressEvent = new Event("progress", { bubbles: true });
progressEvent.loaded = 250000000;
progressEvent.total = 500000000;
progressEvent.lengthComputable = true;
xhr.upload.dispatchEvent(progressEvent);
await contains(".o_file_upload_progress_text_left", { text: "Uploading... (50%)" });
progressEvent.loaded = 350000000;
xhr.upload.dispatchEvent(progressEvent);
await contains(".o_file_upload_progress_text_right", { text: "(350/500MB)" });
});

View file

@ -1,245 +0,0 @@
/** @odoo-module **/
import testUtils from 'web.test_utils';
import { registry } from "@web/core/registry";
import {
click,
getFixture,
nextTick,
} from '@web/../tests/helpers/utils';
import { setupViewRegistries } from "@web/../tests/views/helpers";
import {
start,
startServer,
} from '@mail/../tests/helpers/test_utils';
import { fileUploadService } from "@web/core/file_upload/file_upload_service";
import { addModelNamesToFetch } from '@bus/../tests/helpers/model_definitions_helpers';
addModelNamesToFetch([
'mrp.document',
]);
const serviceRegistry = registry.category("services");
let target;
let pyEnv;
QUnit.module('Views', {}, function () {
QUnit.module('MrpDocumentsKanbanView', {
beforeEach: async function () {
serviceRegistry.add("file_upload", fileUploadService);
this.ORIGINAL_CREATE_XHR = fileUploadService.createXhr;
this.patchDocumentXHR = (mockedXHRs, customSend) => {
fileUploadService.createXhr = () => {
const xhr = new window.EventTarget();
Object.assign(xhr, {
upload: new window.EventTarget(),
open() {},
send(data) { customSend && customSend(data); },
});
mockedXHRs.push(xhr);
return xhr;
};
};
pyEnv = await startServer();
const irAttachment = pyEnv['ir.attachment'].create({
mimetype: 'image/png',
name: 'test.png',
})
pyEnv['mrp.document'].create([
{name: 'test1', priority: 2, ir_attachment_id: irAttachment},
{name: 'test2', priority: 1},
{name: 'test3', priority: 3},
]);
target = getFixture();
setupViewRegistries();
},
afterEach() {
fileUploadService.createXhr = this.ORIGINAL_CREATE_XHR;
},
}, function () {
QUnit.test('MRP documents kanban basic rendering', async function (assert) {
assert.expect(4);
const views = {
'mrp.document,false,kanban':
`<kanban js_class="mrp_documents_kanban" create="false"><templates><t t-name="kanban-box">
<div>
<field name="name"/>
</div>
</t></templates></kanban>`
};
const { openView } = await start({
serverData: { views },
});
await openView({
res_model: 'mrp.document',
views: [[false, 'kanban']],
});
assert.ok(target.querySelector('.o_mrp_documents_kanban_upload'),
"should have upload button in kanban buttons");
assert.containsN(target, '.o_kanban_renderer .o_kanban_record:not(.o_kanban_ghost)', 3,
"should have 3 records in the renderer");
// check control panel buttons
assert.containsN(target, '.o_cp_buttons .btn-primary', 1,
"should have only 1 primary button i.e. Upload button");
assert.equal(target.querySelector(".o_cp_buttons .btn-primary").innerText.trim().toUpperCase(), 'UPLOAD',
"should have a primary 'Upload' button");
});
QUnit.test('mrp: upload multiple files', async function (assert) {
assert.expect(4);
const file1 = await testUtils.file.createFile({
name: 'text1.txt',
content: 'hello, world',
contentType: 'text/plain',
});
const file2 = await testUtils.file.createFile({
name: 'text2.txt',
content: 'hello, world',
contentType: 'text/plain',
});
const file3 = await testUtils.file.createFile({
name: 'text3.txt',
content: 'hello, world',
contentType: 'text/plain',
});
const mockedXHRs = [];
this.patchDocumentXHR(mockedXHRs, data => assert.step('xhrSend'));
const views = {
'mrp.document,false,kanban':
`<kanban js_class="mrp_documents_kanban" create="false"><templates><t t-name="kanban-box">
<div>
<field name="name"/>
</div>
</t></templates></kanban>`
};
const { openView } = await start({
serverData: { views },
});
await openView({
res_model: 'mrp.document',
views: [[false, 'kanban']],
});
const fileInput = target.querySelector(".o_input_file");
let dataTransfer = new DataTransfer();
dataTransfer.items.add(file1);
fileInput.files = dataTransfer.files;
fileInput.dispatchEvent(new Event('change', { bubbles: true }));
assert.verifySteps(['xhrSend']);
dataTransfer = new DataTransfer();
dataTransfer.items.add(file2);
dataTransfer.items.add(file3);
fileInput.files = dataTransfer.files;
fileInput.dispatchEvent(new Event('change', { bubbles: true }));
assert.verifySteps(['xhrSend']);
});
QUnit.test('mrp: upload progress bars', async function (assert) {
assert.expect(4);
const file1 = await testUtils.file.createFile({
name: 'text1.txt',
content: 'hello, world',
contentType: 'text/plain',
});
const mockedXHRs = [];
this.patchDocumentXHR(mockedXHRs, data => assert.step('xhrSend'));
const views = {
'mrp.document,false,kanban':
`<kanban js_class="mrp_documents_kanban" create="false"><templates><t t-name="kanban-box">
<div>
<field name="name"/>
</div>
</t></templates></kanban>`
};
const { openView } = await start({
serverData: { views },
});
await openView({
res_model: 'mrp.document',
views: [[false, 'kanban']],
});
const fileInput = target.querySelector(".o_input_file");
let dataTransfer = new DataTransfer();
dataTransfer.items.add(file1);
fileInput.files = dataTransfer.files;
fileInput.dispatchEvent(new Event('change', { bubbles: true }));
assert.verifySteps(['xhrSend']);
const progressEvent = new Event('progress', { bubbles: true });
progressEvent.loaded = 250000000;
progressEvent.total = 500000000;
progressEvent.lengthComputable = true;
mockedXHRs[0].upload.dispatchEvent(progressEvent);
await nextTick();
assert.strictEqual(
target.querySelector('.o_file_upload_progress_text_left').innerText,
"Uploading... (50%)",
"the current upload progress should be at 50%"
);
progressEvent.loaded = 350000000;
mockedXHRs[0].upload.dispatchEvent(progressEvent);
await nextTick();
assert.strictEqual(
target.querySelector('.o_file_upload_progress_text_right').innerText,
"(350/500MB)",
"the current upload progress should be at (350/500Mb)"
);
});
QUnit.test("mrp: click on image opens attachment viewer", async function (assert) {
assert.expect(4);
const views = {
'mrp.document,false,kanban':
`<kanban js_class="mrp_documents_kanban" create="false"><templates><t t-name="kanban-box">
<div class="o_kanban_image" t-if="record.ir_attachment_id.raw_value">
<div class="o_kanban_previewer">
<field name="ir_attachment_id" invisible="1"/>
<img t-attf-src="/web/image/#{record.ir_attachment_id.raw_value}" width="100" height="100" alt="Document" class="o_attachment_image"/>
</div>
</div>
<div>
<field name="name"/>
</div>
</t></templates></kanban>`
};
const { openView } = await start({
serverData: { views },
});
await openView({
res_model: 'mrp.document',
views: [[false, 'kanban']],
});
assert.containsOnce(target, ".o_kanban_previewer");
await click(target.querySelector(".o_kanban_previewer"));
await nextTick();
assert.containsOnce(target, '.o_AttachmentViewer',
"should have a document preview");
assert.containsOnce(target, '.o_AttachmentViewer_headerItemButtonClose',
"should have a close button");
await click(target, '.o_AttachmentViewer_headerItemButtonClose');
assert.containsNone(target, '.o_AttachmentViewer',
"should not have a document preview");
});
});
});

View file

@ -0,0 +1,14 @@
import { mailModels } from "@mail/../tests/mail_test_helpers";
import { defineModels } from "@web/../tests/web_test_helpers";
import { ProductDocument } from "@mrp/../tests/mock_server/mock_models/product_document";
import { ResFake } from "@mrp/../tests/mock_server/mock_models/res_fake";
export function defineMrpModels() {
return defineModels(mrpModels);
}
export const mrpModels = {
...mailModels,
ProductDocument,
ResFake,
};

View file

@ -1,42 +0,0 @@
/** @odoo-module **/
import { getFixture } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData;
let target;
QUnit.module("Mrp", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
foo: {
fields: {
duration: { string: "Duration", type: "float" },
},
records: [{ id: 1, duration: 150.5 }],
},
},
};
setupViewRegistries();
});
QUnit.module("MrpTimer");
QUnit.test("ensure the rendering is based on minutes and seconds", async function (assert) {
await makeView({
type: "form",
serverData,
resModel: "foo",
resId: 1,
arch: '<form><field name="duration" widget="mrp_timer" readonly="1"/></form>',
});
assert.strictEqual(
target.querySelector(".o_field_mrp_timer").textContent,
"150:30",
"should not contain hours and be correctly set base on minutes seconds"
);
});
});

View file

@ -0,0 +1,14 @@
import { openFormView, start, startServer } from "@mail/../tests/mail_test_helpers";
import { defineMrpModels } from "@mrp/../tests/mrp_test_helpers";
import { describe, expect, test } from "@odoo/hoot";
describe.current.tags("desktop");
defineMrpModels();
test("ensure the rendering is based on minutes and seconds", async () => {
const pyEnv = await startServer();
const fakeId = pyEnv["res.fake"].create({ duration: 150.5 });
await start();
await openFormView("res.fake", fakeId);
expect(".o_field_mrp_timer").toHaveText("150:30");
});

View file

@ -1,70 +1,34 @@
/** @odoo-module **/
import tour from 'web_tour.tour';
tour.register('test_mrp_manual_consumption', {test: true}, [
{
trigger: 'div[name=move_raw_ids] td[name="quantity_done"]:last:contains("5.00")',
run: () => {},
},
{
trigger: 'div[name=move_raw_ids] td[name="quantity_done"]:last',
run: 'click',
},
{
trigger: 'div[name="quantity_done"] input',
run: 'text 6.0'
},
{
content: "Click Pager",
trigger: ".o_pager_value:first()",
},
{
trigger: "input[id='qty_producing']",
run: 'text 8.0',
},
{
content: "Click Pager",
trigger: ".o_pager_value:first()",
},
{
trigger: 'div[name=move_raw_ids] td[name="quantity_done"]:last:contains("6.00")',
run: () => {},
},
{
trigger: 'button[name=button_mark_done]',
run: 'click',
},
{
trigger: 'button[name=action_confirm]',
extra_trigger: '.o_technical_modal',
run: 'click',
},
{
trigger: 'button[name=action_backorder]',
run: 'click',
},
{
trigger: "input[id='qty_producing']",
run: 'text 2.0',
},
{
content: "Click Pager",
trigger: ".o_pager_value:first()",
},
{
trigger: 'div[name=move_raw_ids] td[name="quantity_done"]:last:contains("2.00")',
run: () => {},
},
{
trigger: 'button[name=button_mark_done]',
run: 'click',
},
{
trigger: 'button[name=action_confirm]',
extra_trigger: '.o_technical_modal',
run: 'click',
},
...tour.stepUtils.saveForm(),
]);
import { registry } from "@web/core/registry";
import { stepUtils } from '@web_tour/tour_utils';
registry.category("web_tour.tours").add('test_mrp_manual_consumption_02', {
steps: () => [
{
trigger: 'div[name=move_raw_ids] td[name="quantity"]:last:contains("0.00")',
},
{
trigger: 'div[name=move_raw_ids] td[name="quantity"]:last',
run: 'click',
},
{
trigger: 'div[name="quantity"] input',
run: "edit 16.0 && click body",
},
{
content: "Click Pager",
trigger: ".o_pager_value:first()",
run: "click",
},
{
trigger: "input[id='qty_producing_0']",
run: "edit 8.0 && click body",
},
{
content: "Click Pager",
trigger: ".o_pager_value:first()",
run: "click",
},
{
trigger: 'div[name=move_raw_ids] td[name="quantity"]:last:contains("16.00")',
},
...stepUtils.saveForm(),
]});

View file

@ -0,0 +1,25 @@
import { registry } from "@web/core/registry";
registry.category("web_tour.tours").add("test_manufacture_from_bom", {
steps: () => [
{
trigger: '[name="product_tmpl_id"]',
run: "click",
},
{
trigger: '.o_stat_text:contains("BoM Overview")',
run: "click",
},
{
trigger: '.fa-toggle-off',
run: "click",
},
{
trigger: 'button.btn-primary:contains("Manufacture")',
run: "click",
},
{
trigger: 'button[aria-checked="true"]:contains("Draft")',
},
],
});

View file

@ -0,0 +1,47 @@
import { registry } from "@web/core/registry";
registry.category("web_tour.tours").add('test_mrp_bom_product_catalog', {
steps: () => [
{
trigger: 'button[name=action_add_from_catalog]',
run: "click",
},
{
trigger: '.o_kanban_record:nth-child(1)',
run: "click",
},
{
trigger: '.o_product_added',
run: "click",
},
{
trigger: 'button:contains("Back to BoM")',
run: "click",
},
{
trigger: 'div.o_field_one2many:contains("Component")',
},
]});
registry.category("web_tour.tours").add('test_mrp_production_product_catalog', {
steps: () => [
{
trigger: 'button[name=action_add_from_catalog_raw]',
run: "click",
},
{
trigger: '.o_kanban_record:nth-child(1)',
run: "click",
},
{
trigger: '.o_product_added',
run: "click",
},
{
trigger: 'button:contains("Back to Production")',
run: "click",
},
{
trigger: 'div.o_field_widget:contains("WH/MO/")',
},
]});

View file

@ -0,0 +1,27 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
registry.category("web_tour.tours").add("mrp_bom_report_tour", {
steps: () => [
{
content: "Check the current displayed variant",
trigger: ".o_mrp_bom_report_page h2 a:contains('[alpaca] Product Test Sync (L)')",
run: () => {},
},
{
content: "Open dropdown menu",
trigger: ".o-autocomplete--input",
run: "click",
},
{
content: "Select the other variant",
trigger: ".o-autocomplete--dropdown-menu.show li.o-autocomplete--dropdown-item:eq(1)",
run: "click",
},
{
content: "Ensure the second variant is displayed",
trigger: ".o_mrp_bom_report_page h2 a:contains('[zebra] Product Test Sync (S)')",
run: () => {},
},
],
});

View file

@ -0,0 +1,163 @@
import { registry } from "@web/core/registry";
registry.category("web_tour.tours").add('test_manufacturing_and_byproduct_sm_to_sml_synchronization', {
steps: () => [
{
trigger: ".btn-primary[name=action_confirm]",
run: "click",
},
{
trigger: ".o_data_row:has([name=quantity]:contains(5.00)) > td:contains(product2)",
run: "click",
},
{
trigger: "button:contains('Details')",
run: "click",
},
{
trigger: "h4:contains('Components')",
run: "click",
},
{
trigger: ".modal .o_list_number:contains(5)",
},
{
content: "Click Save",
trigger: ".modal .modal-footer .o_form_button_save",
run: "click",
},
{
trigger: ".o_data_row:has([name=quantity]:contains(5.00)) > td:contains(product2)",
run: "click",
},
{
trigger: ".o_field_widget[name=quantity] input",
run: 'edit 21',
},
{
trigger: "button:contains('Details')",
run: "click",
},
{
trigger: "h4:contains('Components')",
run: "click",
},
{
trigger: ".modal .modal-body .o_data_row > td:contains('WH/Stock')",
run: "click",
},
{
trigger: ".modal .modal-body .o_field_widget[name=quantity] input",
run: 'edit 25',
},
{
content: "Click Save",
trigger: ".modal .modal-footer .o_form_button_save",
run: "click",
},
{
trigger: ".o_data_row:has([name=product_uom_qty]:contains(5.00)) > td:contains(25)",
run: "click",
},
{
trigger: ".o_field_widget[name=quantity] input",
run: 'edit 7',
},
{
trigger: "button:contains('Details')",
run: "click",
},
{
trigger: ".modal .o_data_row > td:contains('7')",
run: "click",
},
{
content: "Click Save",
trigger: ".modal .modal-footer .o_form_button_save",
run: "click",
},
{
trigger: ".nav-link[name=finished_products]",
run: "click",
},
{
trigger: ".o_data_row:has([name=quantity]:contains(2.00)) > td:contains(product2)",
run: "click",
},
{
trigger: ".fa-list",
run: "click",
},
{
trigger: "h4:contains('Move Byproduct')",
run: "click",
},
{
trigger: ".modal .modal-body .o_data_row > td:contains('WH/Stock')",
run: "click",
},
{
trigger: ".modal .modal-body .o_field_widget[name=quantity] input",
run: 'edit 2',
},
{
content: "Click Save",
trigger: ".modal .modal-footer .o_form_button_save",
run: "click",
},
{
trigger: ".o_data_row:has([name=quantity]:contains(2.00)) > td[name=product_id]:contains(product2)",
run: "dblclick",
},
{
trigger: ".o_field_widget[name=quantity] input",
run: 'edit 5',
},
{
trigger: ".fa-list",
run: "click",
},
{
trigger: "h4:contains('Move Byproduct')",
run: "click",
},
{
trigger: ".modal .modal-body .o_data_row > td:contains('WH/Stock')",
run: "click",
},
{
trigger: ".modal .modal-body .o_field_widget[name=quantity] input",
run: 'edit 7',
},
{
content: "Click Save",
trigger: ".modal .modal-footer .o_form_button_save",
run: "click",
},
{
trigger: ".o_data_row:has([name=product_uom_qty]:contains(2.00)) > td:contains(10)",
run: "click",
},
{
trigger: ".o_field_widget[name=quantity] input",
run: 'edit 7',
},
{
trigger: ".fa-list",
run: "click",
},
{
trigger: ".o_list_footer .o_list_number > span:contains('7')",
run: "click",
},
{
content: "Click Save",
trigger: ".modal .modal-footer .o_form_button_save",
run: "click",
},
{
content: "wait for save completion",
trigger: ".o_form_readonly, .o_form_saved",
},
]
});