19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:31:00 +01:00
parent a1137a1456
commit e1d89e11e3
2789 changed files with 1093187 additions and 605897 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before After
Before After

View file

@ -1,24 +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="icon-a" d="M4,5.35309892e-14 C36.4160122,9.87060235e-15 58.0836068,-3.97961823e-14 65,5.07020818e-14 C69,6.733808e-14 70,1 70,5 C70,43.0488877 70,62.4235458 70,65 C70,69 69,70 65,70 C61,70 9,70 4,70 C1,70 7.10542736e-15,69 7.10542736e-15,65 C7.25721566e-15,62.4676575 3.83358709e-14,41.8005206 3.60818146e-14,5 C-1.13686838e-13,1 1,5.75716207e-14 4,5.35309892e-14 Z"/>
<linearGradient id="icon-c" x1="100%" x2="0%" y1="0%" y2="98.616%">
<stop offset="0%" stop-color="#797C79"/>
<stop offset="100%" stop-color="#545554"/>
</linearGradient>
<path id="icon-d" d="M29.2777778,17.2348485 C34.4714536,17.2348485 38.681713,21.506348 38.681713,26.7755682 C38.681713,32.0447884 34.4714536,36.3162879 29.2777778,36.3162879 C24.0841019,36.3162879 19.8738426,32.0447884 19.8738426,26.7755682 C19.8738426,21.506348 24.0841019,17.2348485 29.2777778,17.2348485 Z M42.7933813,37.84729 C42.7933813,41.2291455 53.1142387,40.370791 53.1141828,46.7993457 C53.1141828,49.8785938 50.8543359,52.5088721 47.1832578,53.0575293 L47.1832578,55.6054688 C47.1832578,55.9614111 46.8831254,56.25 46.5129453,56.25 L44.2785703,56.25 C43.9083902,56.25 43.6082578,55.9614111 43.6082578,55.6054688 L43.6082578,53.0156885 C41.4017008,52.6548584 39.4354508,51.6754395 38.0920887,50.4390137 C37.8475922,50.2139648 37.8167578,49.8485693 38.0193598,49.5878564 L39.7168703,47.4035937 C39.944609,47.1106006 40.3787481,47.0596289 40.6753613,47.2886523 C42.0671535,48.3632471 43.8647641,49.2161768 45.5824957,49.2161768 C47.5829875,49.2161768 48.4941098,48.0702539 48.4941098,47.005542 C48.4941098,43.8578662 38.1732523,44.5412305 38.1732523,37.9061572 C38.1732523,35.0744092 40.3334461,32.784873 43.6083137,32.0710547 L43.6083137,29.3945312 C43.6083137,29.0385889 43.9084461,28.75 44.2786262,28.75 L46.5130012,28.75 C46.8831813,28.75 47.1833137,29.0385889 47.1833137,29.3945312 L47.1833137,31.9317822 C48.979416,32.1313721 50.9263387,32.8088281 52.2826602,33.9623779 C52.5156496,34.1605176 52.5744695,34.4877783 52.4264981,34.7508545 L51.1108981,37.0899121 C50.9184625,37.4321045 50.4576227,37.5328125 50.1291695,37.3056689 C48.8809918,36.4425342 47.3510035,35.783877 45.8581059,35.783877 C43.9963688,35.783877 42.7933813,36.5938379 42.7933813,37.84729 Z M35.2962963,36.7272727 C35.2962963,36.3141183 26.1175854,38.5736818 22.6967864,36.0773525 L18.5053937,37.1404272 C15.9936026,37.7775087 14.2314815,40.0671622 14.2314815,42.6939608 L14.2314815,44.9029356 C14.2314815,46.4837136 15.4945475,47.7651515 17.052662,47.7651515 C29.017554,47.7651515 35,47.7651515 35,47.7651515 C33.1481481,41.4242424 35.2962963,37.1404272 35.2962963,36.7272727 Z"/>
<path id="icon-e" d="M29.2777778,15.2348485 C34.4714536,15.2348485 38.681713,19.506348 38.681713,24.7755682 C38.681713,30.0447884 34.4714536,34.3162879 29.2777778,34.3162879 C24.0841019,34.3162879 19.8738426,30.0447884 19.8738426,24.7755682 C19.8738426,19.506348 24.0841019,15.2348485 29.2777778,15.2348485 Z M42.7933813,35.84729 C42.7933813,39.2291455 53.1142387,38.370791 53.1141828,44.7993457 C53.1141828,47.8785938 50.8543359,50.5088721 47.1832578,51.0575293 L47.1832578,53.6054688 C47.1832578,53.9614111 46.8831254,54.25 46.5129453,54.25 L44.2785703,54.25 C43.9083902,54.25 43.6082578,53.9614111 43.6082578,53.6054688 L43.6082578,51.0156885 C41.4017008,50.6548584 39.4354508,49.6754395 38.0920887,48.4390137 C37.8475922,48.2139648 37.8167578,47.8485693 38.0193598,47.5878564 L39.7168703,45.4035937 C39.944609,45.1106006 40.3787481,45.0596289 40.6753613,45.2886523 C42.0671535,46.3632471 43.8647641,47.2161768 45.5824957,47.2161768 C47.5829875,47.2161768 48.4941098,46.0702539 48.4941098,45.005542 C48.4941098,41.8578662 38.1732523,42.5412305 38.1732523,35.9061572 C38.1732523,33.0744092 40.3334461,30.784873 43.6083137,30.0710547 L43.6083137,27.3945312 C43.6083137,27.0385889 43.9084461,26.75 44.2786262,26.75 L46.5130012,26.75 C46.8831813,26.75 47.1833137,27.0385889 47.1833137,27.3945312 L47.1833137,29.9317822 C48.979416,30.1313721 50.9263387,30.8088281 52.2826602,31.9623779 C52.5156496,32.1605176 52.5744695,32.4877783 52.4264981,32.7508545 L51.1108981,35.0899121 C50.9184625,35.4321045 50.4576227,35.5328125 50.1291695,35.3056689 C48.8809918,34.4425342 47.3510035,33.783877 45.8581059,33.783877 C43.9963688,33.783877 42.7933813,34.5938379 42.7933813,35.84729 Z M35.2962963,34.7272727 C35.2962963,34.3141183 26.1175854,36.5736818 22.6967864,34.0773525 L18.5053937,35.1404272 C15.9936026,35.7775087 14.2314815,38.0671622 14.2314815,40.6939608 L14.2314815,42.9029356 C14.2314815,44.4837136 15.4945475,45.7651515 17.052662,45.7651515 C29.017554,45.7651515 35,45.7651515 35,45.7651515 C33.1481481,39.4242424 35.2962963,35.1404272 35.2962963,34.7272727 Z"/>
</defs>
<g fill="none" fill-rule="evenodd">
<mask id="icon-b" fill="#fff">
<use xlink:href="#icon-a"/>
</mask>
<g mask="url(#icon-b)">
<rect width="70" height="70" fill="url(#icon-c)"/>
<path fill="#FFF" fill-opacity=".383" d="M4,1.8 L65,1.8 C67.6666667,1.8 69.3333333,1.13333333 70,-0.2 C70,2.46666667 70,3.46666667 70,2.8 L1.10547097e-14,2.8 C-1.65952376e-14,3.46666667 -2.9161925e-14,2.46666667 -2.66453526e-14,-0.2 C0.666666667,1.13333333 2,1.8 4,1.8 Z" transform="matrix(1 0 0 -1 0 2.8)"/>
<path fill="#393939" d="M40.2790698,48 L4,48 C2,48 -7.10542736e-15,47.8556793 0,43.9590217 L2.0734159e-16,21.6246135 L20.6658658,0.0963982452 L37.783693,7.00604553 L34.121375,14.2273604 L43.799671,6.01962237 L50.8814479,14.2273604 L46.6827849,19.2273136 L52.6062041,25.9680455 L40.2790698,48 Z" opacity=".324" transform="translate(0 21)"/>
<path fill="#000" fill-opacity=".383" d="M4,4 L65,4 C67.6666667,4 69.3333333,3 70,1 C70,3.66666667 70,5 70,5 L1.77635684e-15,5 C1.77635684e-15,5 1.77635684e-15,3.66666667 1.77635684e-15,1 C0.666666667,3 2,4 4,4 Z" transform="translate(0 65)"/>
<use fill="#000" fill-rule="nonzero" opacity=".3" xlink:href="#icon-d"/>
<use fill="#FFF" fill-rule="nonzero" xlink:href="#icon-e"/>
</g>
</g>
</svg>
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><path d="M28 16c0 5.523-4.477 10-10 10S8 21.523 8 16 12.477 6 18 6s10 4.477 10 10Z" fill="#2EBCFA"/><path d="M43 24a5 5 0 1 1-10 0 5 5 0 0 1 10 0Zm-27 7h26a4 4 0 0 1 4 4v5a4 4 0 0 1-4 4H20a4 4 0 0 1-4-4v-9Z" fill="#144496"/><path d="M4 31h16c7.18 0 13 5.82 13 13H17C9.82 44 4 38.18 4 31Z" fill="#2EBCFA"/><path d="M17.617 22h.787v-1.138C20.558 20.73 22 19.668 22 17.905v-.014c0-1.554-.997-2.332-2.962-2.762l-.634-.132v-2.013c.69.111 1.143.493 1.24 1.139l.008.014 2.188-.007.007-.007c-.077-1.687-1.338-2.811-3.443-2.957V10h-.787v1.16c-2.05.096-3.52 1.151-3.52 2.886v.014c0 1.562 1.032 2.402 2.893 2.811l.627.132v2.054c-.816-.076-1.29-.416-1.401-1.027l-.007-.014-2.195.007-.014.007c.056 1.777 1.491 2.755 3.617 2.839V22Zm-1.185-8.127v-.014c0-.5.404-.82 1.185-.889v1.847c-.809-.216-1.185-.486-1.185-.944Zm3.234 4.282v.014c0 .534-.454.819-1.262.888v-1.88c.906.228 1.262.464 1.262.978Z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 985 B

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -0,0 +1,11 @@
import { patch } from "@web/core/utils/patch";
import { AttachmentView } from "@mail/core/common/attachment_view";
patch(AttachmentView.prototype, {
get displayName() {
if (this.state.thread.model === 'hr.expense') {
return this.state.thread.message_main_attachment_id.res_name || this.state.thread.name;
}
return super.displayName;
}
});

View file

@ -1,15 +1,15 @@
/** @odoo-module */
import { useService } from '@web/core/utils/hooks';
import { formatMonetary } from "@web/views/fields/formatters";
const { Component, onWillStart, useState } = owl;
import { Component, onWillStart, useState } from "@odoo/owl";
export class ExpenseDashboard extends Component {
static template = "hr_expense.ExpenseDashboard";
static props = {};
setup() {
super.setup();
this.orm = useService('orm');
this.actionService = useService("action");
this.state = useState({
expenses: {}
@ -24,5 +24,12 @@ export class ExpenseDashboard extends Component {
renderMonetaryField(value, currency_id) {
return formatMonetary(value, { currencyId: currency_id});;
}
async applyFilter(filterName) {
const { actionId } = this.env.config;
const action = actionId ? await this.actionService.loadAction(actionId) : {};
action['context'] = { [`search_default_${filterName}`]: 1, [`search_default_my_open_expenses`]: 1 };
return this.actionService.doAction(action, {clearBreadcrumbs: true});
}
}
ExpenseDashboard.template = 'hr_expense.ExpenseDashboard';

View file

@ -1,13 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="hr_expense.ExpenseDashboard" owl="1">
<div class="o_expense_container position-sticky start-0 d-flex o_form_statusbar">
<t t-name="hr_expense.ExpenseDashboard">
<div class="d-none d-md-flex o_expense_container position-sticky start-0 d-flex o_form_statusbar">
<t t-foreach="Object.entries(state.expenses)" t-as="expense" t-key="expense[0]">
<t t-set="name" t-value="expense[0]"/>
<t t-set="data" t-value="expense[1]"/>
<div t-attf-class="o_expense_card o_arrow_button flex-grow-1 d-flex flex-column p-3 border-bottom text-center">
<span t-esc="renderMonetaryField(data['amount'], data['currency'])" class="h2 m-0 text-odoo"/>
<b class="mx-2" t-esc="data['description']"/>
<div t-attf-class="o_expense_card o_arrow_button flex-grow-1 d-flex flex-column p-3 border-bottom text-center cursor-pointer"
t-att-data-tooltip="data['tooltip']" t-on-click="() => this.applyFilter(name)">
<span t-out="renderMonetaryField(data['amount'], data['currency'])" class="h2 m-0 text-primary"/>
<b class="mx-2" t-out="data['description']"/>
</div>
<div t-if="name !== 'approved'" t-attf-class="o_expense_card o_arrow_button flex-grow-1 d-flex flex-column p-3 border-bottom text-center">
<i class="fa fa-angle-right fa-3x"/>

View file

@ -0,0 +1,15 @@
import { registry } from "@web/core/registry";
import { Component } from "@odoo/owl";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
class AttachmentNumber extends Component {
static template = "hr_expense.AttachmentNumber";
static props = {...standardFieldProps};
setup() {
super.setup();
this.nb_attachment = this.props.record.data.nb_attachment
}
}
registry.category("fields").add("nb_attachment", {component: AttachmentNumber});

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-if="nb_attachment > 0" t-name="hr_expense.AttachmentNumber" >
<div>
<span class="fa fa-paperclip pe-1 align-middle"/>
<sup><t class="text-center" t-out="nb_attachment"/></sup>
</div>
</t>
</templates>

View file

@ -1,18 +1,28 @@
/** @odoo-module */
import { Component } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { Component } from "@odoo/owl";
import { url } from "@web/core/utils/urls";
const actionRegistry = registry.category("actions");
class QRModalComponent extends Component {
static props = {
action: Object,
actionId: { type: Number, optional: true },
className: { type: String, optional: true },
};
static template = "hr_expense.QRModalComponent";
setup() {
this.url = _.str.sprintf(
"/report/barcode/?barcode_type=QR&value=%s&width=256&height=256&humanreadable=1",
this.props.action.params.url);
this.url = url("/report/barcode", {
barcode_type: "QR",
value: this.props.action.params.url,
width: 256,
height: 256,
humanreadable: 1,
quiet: 0,
});
}
}
QRModalComponent.template = "hr_expense.QRModalComponent"
actionRegistry.add("expense_qr_code_modal", QRModalComponent);

View file

@ -1,6 +1,6 @@
<?xml version="1.0"?>
<templates>
<t t-name="hr_expense.QRModalComponent" owl="1">
<t t-name="hr_expense.QRModalComponent">
<div style="text-align:center;" class="o_expense_modal">
<t t-if="url">
<h3>Scan this QR code to get the Odoo app:</h3><br/><br/>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View file

@ -1,82 +1,117 @@
odoo.define('hr_expense.tour', function(require) {
"use strict";
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { stepUtils } from "@web_tour/tour_utils";
const {_t} = require('web.core');
const {Markup} = require('web.utils');
var tour = require('web_tour.tour');
import { markup } from "@odoo/owl";
tour.register('hr_expense_tour' , {
url: "/web",
rainbowManMessage: _t("There you go - expense management in a nutshell!"),
}, [tour.stepUtils.showAppsMenuItem(), {
registry.category("web_tour.tours").add('hr_expense_tour' , {
url: "/odoo",
steps: () => [stepUtils.showAppsMenuItem(), {
isActive: ["community"],
trigger: '.o_app[data-menu-xmlid="hr_expense.menu_hr_expense_root"]',
content: _t("Wasting time recording your receipts? Lets try a better way."),
position: 'right',
edition: 'community'
content: markup(_t("<b>Wasting time recording your receipts?</b> Lets try a better way.")),
tooltipPosition: 'right',
run: "click",
}, {
isActive: ["enterprise"],
trigger: '.o_app[data-menu-xmlid="hr_expense.menu_hr_expense_root"]',
content: _t("Wasting time recording your receipts? Lets try a better way."),
position: 'bottom',
edition: 'enterprise'
}, {
trigger: '.o_list_button_add',
extra_trigger: '.o_button_upload_expense',
content: _t("It all begins here - let's go!"),
position: 'bottom',
mobile: false,
}, {
trigger: '.o-kanban-button-new',
extra_trigger: '.o_button_upload_expense',
content: _t("It all begins here - let's go!"),
position: 'bottom',
mobile: true,
}, {
trigger: '.o_field_widget[name="product_id"] .o_input_dropdown',
extra_trigger: '.o_expense_form',
content: _t("Enter a name then choose a category and configure the amount of your expense."),
position: 'bottom',
}, {
trigger: '.o_form_status_indicator_dirty .o_form_button_save',
extra_trigger: '.o_expense_form',
content: Markup(_t("Ready? You can save it manually or discard modifications from here. You don't <em>need to save</em> - Odoo will save eveyrthing for you when you navigate.")),
position: 'bottom',
}, ...tour.stepUtils.statusbarButtonsSteps(_t("Attach Receipt"), _t("Attach a receipt - usually an image or a PDF file.")),
...tour.stepUtils.statusbarButtonsSteps(_t("Create Report"), _t("Create a report to submit one or more expenses to your manager.")),
...tour.stepUtils.statusbarButtonsSteps(_t("Submit to Manager"), Markup(_t('Once your <b>Expense Report</b> is ready, you can submit it to your manager and wait for approval.'))),
...tour.stepUtils.goBackBreadcrumbsMobile(
_t("Use the breadcrumbs to go back to the list of expenses."),
undefined,
".o_expense_form",
),
content: markup(_t("<b>Wasting time recording your receipts?</b> Lets try a better way.")),
tooltipPosition: 'bottom',
run: "click",
},
{
isActive: ["desktop"],
trigger: ".o_button_upload_expense",
},
{
isActive: ["desktop"],
trigger: '.o_list_button_add',
content: _t("It all begins here - let's go!"),
tooltipPosition: 'bottom',
run: "click",
},
{
isActive: ["mobile"],
trigger: ".o_button_upload_expense",
},
{
isActive: ["mobile"],
trigger: '.o-kanban-button-new',
content: _t("It all begins here - let's go!"),
tooltipPosition: 'bottom',
run: "click",
},
{
trigger: ".o_hr_expense_form_view_view",
},
{
trigger: '.o_field_widget[name="product_id"] .o_input_dropdown',
content: _t("Enter a name then choose a category and configure the amount of your expense."),
tooltipPosition: 'bottom',
run: "click",
},
{
trigger: ".o_hr_expense_form_view_view",
},
{
trigger: '.o_form_status_indicator_dirty .o_form_button_save',
content: markup(_t("Ready? You can save it manually or discard modifications from here. You don't <em>need to save</em> - Odoo will save eveyrthing for you when you navigate.")),
tooltipPosition: 'bottom',
run: "click",
}, ...stepUtils.statusbarButtonsSteps(_t("Attach Receipt"), _t("Attach a receipt - usually an image or a PDF file.")),
...stepUtils.statusbarButtonsSteps(_t("Submit to Manager"), markup(_t('Once your <b>Expense</b> is ready, you can submit it to your manager and wait for approval.'))),
{
isActive: ["mobile"],
trigger: ".o_hr_expense_form_view_view",
},
{
isActive: ["mobile"],
trigger: ".o_back_button",
content: _t("Use the breadcrumbs to go back to the list of expenses."),
tooltipPosition: "bottom",
run: "click",
},
{
trigger: ".o_hr_expense_form_view_view",
}, {
isActive: ["desktop"],
trigger: '.breadcrumb > li.breadcrumb-item:first',
extra_trigger: ".o_expense_form",
content: _t("Let's go back to your expenses."),
position: 'bottom',
mobile: false,
tooltipPosition: 'bottom',
run: "click",
}, {
trigger: '.o_expense_container',
content: _t("The status of all your current expenses is visible from here."),
position: 'bottom',
tooltipPosition: 'bottom',
run: "click",
},
tour.stepUtils.openBuggerMenu(),
{
trigger: "[data-menu-xmlid='hr_expense.menu_hr_expense_report']",
extra_trigger: '.o_main_navbar',
isActive: ["mobile"],
trigger: ".o_mobile_menu_toggle",
content: _t("Open bugger menu."),
tooltipPosition: "bottom",
run: "click",
},
{
trigger: ".o_main_navbar",
},
{
trigger: "[data-menu-xmlid='hr_expense.menu_hr_expense_all_expenses']",
content: _t("Let's check out where you can manage all your employees expenses"),
position: "bottom"
tooltipPosition: "bottom",
run: "click",
}, {
isActive: ["desktop"],
trigger: '.o_list_renderer tbody tr[data-id]',
content: _t('Managers can inspect all expenses from here.'),
position: 'bottom',
mobile: false,
tooltipPosition: 'bottom',
run: "click",
}, {
trigger: '.o_kanban_renderer .oe_kanban_card',
isActive: ["mobile"],
trigger: '.o_kanban_renderer .o_kanban_record',
content: _t('Managers can inspect all expenses from here.'),
position: 'bottom',
mobile: true,
tooltipPosition: 'bottom',
run: "click",
},
...tour.stepUtils.statusbarButtonsSteps(_t("Approve"), _t("Managers can approve the report here, then an accountant can post the accounting entries.")),
]);
});
...stepUtils.statusbarButtonsSteps(_t("Approve"), _t("Managers can approve the expense here, then an accountant can post the accounting entries.")),
]});

View file

@ -0,0 +1,17 @@
import { ActivityMenu } from "@mail/core/web/activity_menu";
import { MEDIAS_BREAKPOINTS, SIZES } from "@web/core/ui/ui_service";
import { patch } from "@web/core/utils/patch";
patch(ActivityMenu.prototype, {
openActivityGroup(group) {
if (group.model === "hr.expense") {
const mobileMaxWidth = MEDIAS_BREAKPOINTS[SIZES.MD].minWidth;
const onMobile = window.innerWidth <= mobileMaxWidth;
if (onMobile) {
group.view_type = "kanban";
}
}
return super.openActivityGroup(...arguments);
},
});

View file

@ -1,12 +1,16 @@
/** @odoo-module */
import { _t } from "@web/core/l10n/translation";
import { Domain } from "@web/core/domain";
import { useBus, useRefListener, useService } from '@web/core/utils/hooks';
import { onWillStart, useRef, useEffect, useState } from "@odoo/owl";
import { useBus, useService } from '@web/core/utils/hooks';
export const ExpenseDocumentDropZone = (T) => class ExpenseDocumentDropZone extends T {
static props = [
...T.props,
'uploadDocument',
];
const { useRef, useEffect, useState } = owl;
export const ExpenseDocumentDropZone = {
setup() {
this._super();
super.setup();
this.dragState = useState({
showDragZone: false,
});
@ -31,64 +35,99 @@ export const ExpenseDocumentDropZone = {
},
() => [document.querySelector('.o_content')]
);
},
useRefListener(this.root, 'click', (ev) => {
let targetElement = ev.target;
if (targetElement.closest('.o_view_nocontent_expense_receipt')) {
this.props.uploadDocument();
}
});
}
highlight(ev) {
ev.stopPropagation();
ev.preventDefault();
this.dragState.showDragZone = true;
},
}
unhighlight(ev) {
ev.stopPropagation();
ev.preventDefault();
this.dragState.showDragZone = false;
},
}
async onDrop(ev) {
ev.preventDefault();
await this.env.bus.trigger("change_file_input", {
files: ev.dataTransfer.files,
});
},
this.dragState.showDragZone = false;
}
};
export const ExpenseDocumentUpload = {
export const ExpenseDocumentUpload = (T) => class ExpenseDocumentUpload extends T {
setup() {
this._super();
super.setup();
this.actionService = useService('action');
this.notification = useService('notification');
this.orm = useService('orm');
this.http = useService('http');
this.shareTarget = useService("shareTarget");
this.fileInput = useRef('fileInput');
this.root = useRef("root");
this.isExpense = this.model.rootParams.resModel === "hr.expense";
this.uploadsProcessing = 0;
this.createdExpenseIds = [];
useBus(this.env.bus, "change_file_input", async (ev) => {
this.fileInput.el.files = ev.detail.files;
this.uploadsProcessing++;
await this.onChangeFileInput();
});
},
displayCreateReport() {
return this.isExpense;
},
async onCreateReportClick() {
const records = this.model.root.selection;
const recordIds = records.map((a) => a.resId);
const action = await this.orm.call('hr.expense', 'get_expenses_to_submit', [recordIds]);
this.actionService.doAction(action);
},
onWillStart(async () => {
if (this.shareTarget.hasSharedFiles()) {
const files = this.shareTarget.getSharedFilesToUpload();
await this._onChangeFileInput(files);
}
});
}
uploadDocument() {
this.uploadsProcessing++;
this.fileInput.el.click();
},
}
async onChangeFileInput() {
try {
await this._onChangeFileInput([...this.fileInput.el.files]);
if (this.uploadsProcessing === 1) {
const actionName = _t("Generate Expenses");
const currentAction = this.actionService.currentController.action;
let domain = [['id', 'in', this.createdExpenseIds]];
let options = {}
if (currentAction.name === actionName) {
domain = Domain.or([domain, currentAction.domain]).toList();
options['stackPosition'] = 'replaceCurrentAction';
}
await this.actionService.doAction({
'name': actionName,
'res_model': 'hr.expense',
'type': 'ir.actions.act_window',
'views': [[false, this.env.config.viewType], [false, 'form']],
'domain': domain,
'context': this.props.context,
}, options);
}
} finally {
this.uploadsProcessing--;
}
}
async _onChangeFileInput(files) {
const params = {
csrf_token: odoo.csrf_token,
ufile: [...this.fileInput.el.files],
ufile : files,
model: 'hr.expense',
id: 0,
};
@ -98,19 +137,24 @@ export const ExpenseDocumentUpload = {
if (attachments.error) {
throw new Error(attachments.error);
}
this.onUpload(attachments);
},
await this.onUpload(attachments);
}
async onUpload(attachments) {
const attachmentIds = attachments.map((a) => a.id);
if (!attachmentIds.length) {
this.notification.add(
this.env._t('An error occurred during the upload')
_t('An error occurred during the upload')
);
return;
}
const action = await this.orm.call('hr.expense', 'create_expense_from_attachments', ["", attachmentIds]);
this.actionService.doAction(action);
},
const createdExpenseIds = await this.orm.call(
'hr.expense',
'create_expense_from_attachments',
[attachmentIds, this.env.config.viewType],
{ context: this.props.context },
);
this.createdExpenseIds = [...this.createdExpenseIds, ...createdExpenseIds];
}
};

View file

@ -1,18 +1,16 @@
/** @odoo-module */
import { _t } from "@web/core/l10n/translation";
import { useService } from "@web/core/utils/hooks";
import { onMounted, onPatched, useRef } from "@odoo/owl";
const { onMounted, onPatched, useRef } = owl;
export const ExpenseMobileQRCode = {
export const ExpenseMobileQRCode = (T) => class ExpenseMobileQRCode extends T {
setup() {
this._super();
super.setup();
this.root = useRef('root');
this.actionService = useService('action');
onMounted(this.bindAppsIcons);
onPatched(this.bindAppsIcons);
},
}
bindAppsIcons() {
const apps = this.root.el.querySelectorAll('.o_expense_mobile_app');
@ -24,7 +22,7 @@ export const ExpenseMobileQRCode = {
for (const app of apps) {
app.addEventListener('click', handler);
}
},
}
handleClick(ev) {
ev.preventDefault();
@ -33,7 +31,7 @@ export const ExpenseMobileQRCode = {
const url = ev.currentTarget && ev.currentTarget.href;
if (!this.env.isSmall) {
this.actionService.doAction({
name: this.env._t("Download our App"),
name: _t("Download our App"),
type: "ir.actions.client",
tag: 'expense_qr_code_modal',
target: "new",

View file

@ -1,26 +1,77 @@
.hr_expense {
@include media-breakpoint-up(md) {
&.o_list_view, &.o_kanban_renderer {
min-height: auto;
&.o_list_renderer, &.o_kanban_renderer {
min-height: 100% !important;
}
}
.o_view_nocontent {
top: 10%;
z-index: 3;
.o_view_nocontent_expense_receipt:before {
.o_view_nocontent_expense_receipt_image {
@extend %o-nocontent-init-image;
width: 300px;
height: 230px;
background: transparent url(/hr_expense/static/img/nocontent.png) no-repeat center;
background-size: 300px 230px;
margin-bottom: 0.75rem;
margin: 0.65rem auto;
}
@include media-breakpoint-down(md) {
position: fixed;
}
}
.o_view_pink_overlay {
border: $border-width * 2 dashed $border-color;
border-radius: $border-radius-lg;
background-color: $o-view-background-color;
width: 600px;
height: 300px;
@include media-breakpoint-down(md) {
width: 100%;
}
}
.o_view_pink_overlay:hover {
border-color: $primary;
background-color: mix($primary, $o-view-background-color, 10%);
cursor: pointer;
}
.o_view_sample_data .o_kanban_renderer {
@include media-breakpoint-down(md) {
// kanban renderer of sample data should not be scrollable on mobile
position: fixed !important;
}
}
}
.o_kanban_view .o_cp_bottom_left:has(.o_button_create_report) {
align-items: baseline;
.o_graph_view, .o_pivot_view {
.o_view_nocontent {
h2:first-of-type {
visibility: hidden;
&::after {
content: "No expense found. Let's create one!";
visibility: visible;
display: block;
}
}
}
}
.o_center_attachment .o-mail-Attachment-imgContainer {
--o-Mail-Attachment-img-margin: #{map-get($spacers, 1)} auto auto auto;
display: flex;
justify-content: center;
z-index: -1;
padding: 0 map-get($spacers, 3);
img {
max-height: 100%;
}
}
.o_expense_container {
@ -48,3 +99,49 @@
.o_expense_categories td[name="description"] p:last-child {
margin-bottom: 0;
}
/**************
PDF EXPORT
***************/
.o_content_pdf {
.o_header {
vertical-align: middle;
.col-6 {
.row:first-child{
padding-top: 15px;
}
.row:last-child{
padding-bottom: 15px;
}
}
}
}
.o_end_page {
page-break-after: always;
}
.o_attachment_pdf {
max-height: 700px;
max-width: 700px;
}
.o_overflow {
white-space: normal !important;
overflow-wrap: break-word;
}
/**************
MAILS
***************/
.o_expense_button_mail {
background-color: #9E588B;
margin-top: 10px;
padding: 10px;
text-decoration: none;
color: #fff;
border-radius: 5px;
font-size: 16px;
}

View file

@ -1,5 +1,4 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { useService } from "@web/core/utils/hooks";
import { registry } from "@web/core/registry";
import { FormController } from "@web/views/form/form_controller";
@ -20,12 +19,13 @@ export class ExpenseFormController extends FormController {
async beforeExecuteActionButton(clickParams) {
const record = this.model.root;
if (
clickParams.name === "action_submit_expenses" &&
clickParams.name === "action_submit" &&
record.data.duplicate_expense_ids.count
) {
await record.save();
return new Promise((resolve) => {
this.dialogService.add(ConfirmationDialog, {
body: this.env._t("An expense of same category, amount and date already exists."),
body: _t("An expense of same category, amount and date already exists."),
confirm: async () => {
await this.orm.call("hr.expense", "action_approve_duplicates", [record.resId]);
resolve(true);

View file

@ -1,7 +1,4 @@
/** @odoo-module */
import { registry } from '@web/core/registry';
import { patch } from '@web/core/utils/patch';
import { ExpenseDashboard } from '../components/expense_dashboard';
import { ExpenseMobileQRCode } from '../mixins/qrcode';
@ -11,28 +8,29 @@ import { kanbanView } from '@web/views/kanban/kanban_view';
import { KanbanController } from '@web/views/kanban/kanban_controller';
import { KanbanRenderer } from '@web/views/kanban/kanban_renderer';
export class ExpenseKanbanController extends KanbanController {}
patch(ExpenseKanbanController.prototype, 'expense_kanban_controller_upload', ExpenseDocumentUpload);
export class ExpenseKanbanController extends ExpenseDocumentUpload(KanbanController) {
static template = "hr_expense.KanbanView";
}
export class ExpenseKanbanRenderer extends KanbanRenderer {}
patch(ExpenseKanbanRenderer.prototype, 'expense_kanban_renderer_qrcode', ExpenseMobileQRCode);
patch(ExpenseKanbanRenderer.prototype, 'expense_kanban_renderer_qrcode_dzone', ExpenseDocumentDropZone);
ExpenseKanbanRenderer.template = 'hr_expense.KanbanRenderer';
export class ExpenseKanbanRenderer extends ExpenseDocumentDropZone(
ExpenseMobileQRCode(KanbanRenderer)
) {
static template = "hr_expense.KanbanRenderer";
}
export class ExpenseDashboardKanbanRenderer extends ExpenseKanbanRenderer {}
ExpenseDashboardKanbanRenderer.components = { ...ExpenseDashboardKanbanRenderer.components, ExpenseDashboard};
ExpenseDashboardKanbanRenderer.template = 'hr_expense.DashboardKanbanRenderer';
export class ExpenseDashboardKanbanRenderer extends ExpenseKanbanRenderer {
static components = { ...ExpenseDashboardKanbanRenderer.components, ExpenseDashboard };
static template = "hr_expense.DashboardKanbanRenderer";
}
registry.category('views').add('hr_expense_kanban', {
...kanbanView,
buttonTemplate: 'hr_expense.KanbanButtons',
Controller: ExpenseKanbanController,
Renderer: ExpenseKanbanRenderer,
});
registry.category('views').add('hr_expense_dashboard_kanban', {
...kanbanView,
buttonTemplate: 'hr_expense.KanbanButtons',
Controller: ExpenseKanbanController,
Renderer: ExpenseDashboardKanbanRenderer,
});

View file

@ -1,6 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="hr_expense.KanbanRenderer" t-inherit="web.KanbanRenderer" t-inherit-mode="primary" owl="1">
<t t-name="hr_expense.KanbanRenderer" t-inherit="web.KanbanRenderer" t-inherit-mode="primary">
<xpath expr="//div[hasclass('o_kanban_renderer')]" position="attributes">
<attribute name="class" add="position-relative h-auto" separator=" "/>
</xpath>
<xpath expr="//div[hasclass('o_kanban_renderer')]" position="before">
<div t-if="dragState.showDragZone" class="o_dropzone">
<i class="fa fa-upload fa-10x"></i>
@ -8,32 +11,25 @@
</xpath>
</t>
<t t-name="hr_expense.DashboardKanbanRenderer" t-inherit="hr_expense.KanbanRenderer" t-inherit-mode="primary" owl="1">
<t t-name="hr_expense.DashboardKanbanRenderer" t-inherit="hr_expense.KanbanRenderer" t-inherit-mode="primary">
<xpath expr="//div[hasclass('o_kanban_renderer')]" position="before">
<ExpenseDashboard/>
</xpath>
</t>
<t t-name="hr_expense.KanbanButtons" t-inherit="web.KanbanView.Buttons" t-inherit-mode="primary" owl="1">
<!-- Remove class 'align-items-baseline' to ensure consistency with list buttons when adding a third button
(Create Report) on mobile. Instead, align-items: baseline is added to parent div in css -->
<xpath expr="//div[@t-if='props.showButtons']" position="attributes">
<attribute name="class" remove="align-items-baseline" separator=" "/>
</xpath>
<xpath expr="//t[@t-if='canCreate']" position="after">
<button type="button" class="d-inline d-md-none o_button_upload_expense btn btn-primary mx-1" t-on-click.prevent="uploadDocument">
Scan
</button>
<button type="button" class="d-none d-md-inline o_button_upload_expense btn btn-primary mx-1" t-on-click.prevent="uploadDocument">
<t t-name="hr_expense.KanbanView" t-inherit="web.KanbanView" t-inherit-mode="primary">
<xpath expr="//button[hasclass('o-kanban-button-new')]" position="before">
<input type="file" name="ufile" class="d-none" t-ref="fileInput" multiple="1" accept="*" t-on-change="onChangeFileInput" />
<button t-if="!env.isSmall" type="button" class="o_button_upload_expense btn btn-primary" t-on-click.prevent="uploadDocument">
Upload
</button>
<button t-if="displayCreateReport()" class="btn btn-secondary o_button_create_report" t-on-click="onCreateReportClick">
Create Report
<button t-if="env.isSmall" type="button" class="o_button_upload_expense btn btn-primary me-1" t-on-click.prevent="uploadDocument">
Scan
</button>
</xpath>
<xpath expr="//div" position="inside">
<input type="file" name="ufile" class="d-none" t-ref="fileInput" multiple="1" accept="*" t-on-change="onChangeFileInput" />
<xpath expr="//t[@t-component='props.Renderer']" position="attributes">
<attribute name="uploadDocument.bind">uploadDocument</attribute>
</xpath>
</t>
</templates>

View file

@ -1,79 +1,79 @@
/** @odoo-module */
import { ExpenseDashboard } from '../components/expense_dashboard';
import { ExpenseMobileQRCode } from '../mixins/qrcode';
import { ExpenseDocumentUpload, ExpenseDocumentDropZone } from '../mixins/document_upload';
import { registry } from '@web/core/registry';
import { patch } from '@web/core/utils/patch';
import { useService } from '@web/core/utils/hooks';
import { user } from "@web/core/user";
import { listView } from "@web/views/list/list_view";
import { ListController } from "@web/views/list/list_controller";
import { ListRenderer } from "@web/views/list/list_renderer";
import { onWillStart } from "@odoo/owl";
const { onWillStart } = owl;
export class ExpenseListController extends ExpenseDocumentUpload(ListController) {
static template = `hr_expense.ListView`;
export class ExpenseListController extends ListController {
setup() {
super.setup();
this.orm = useService('orm');
this.actionService = useService('action');
this.rpc = useService("rpc");
this.user = useService("user");
this.isExpenseSheet = this.model.rootParams.resModel === "hr.expense.sheet";
onWillStart(async () => {
this.is_expense_team_approver = await this.user.hasGroup("hr_expense.group_hr_expense_team_approver");
this.is_account_invoicing = await this.user.hasGroup("account.group_account_invoice");
this.userIsExpenseTeamApprover = await user.hasGroup("hr_expense.group_hr_expense_team_approver");
this.userIsAccountInvoicing = await user.hasGroup("account.group_account_invoice");
});
}
displaySubmit() {
const records = this.model.root.selection;
return records.length && records.every(record => record.data.state === 'draft') && this.isExpenseSheet;
return records.length && records.every(record => record.data.state === 'draft');
}
displayApprove() {
const records = this.model.root.selection;
return this.is_expense_team_approver && records.length && records.every(record => record.data.state === 'submit') && this.isExpenseSheet;
return this.userIsExpenseTeamApprover && records.length && records.every(record => record.data.state === 'submitted');
}
displayPost() {
const records = this.model.root.selection;
return this.is_account_invoicing && records.length && records.every(record => record.data.state === 'approve') && this.isExpenseSheet;
return this.userIsAccountInvoicing && records.length && records.every(record => record.data.state === 'approved');
}
async onClick (action) {
const records = this.model.root.selection;
const recordIds = records.map((a) => a.resId);
const model = this.model.rootParams.resModel;
const model = this.model.config.resModel;
const context = {};
if (action === 'approve_expense_sheets') {
if (action === 'action_approve') {
context['validate_analytic'] = true;
}
await this.orm.call(model, action, [recordIds], {context: context});
// sgv note: we tried this.model.notify(); and does not work
const res = await this.orm.call(model, action, [recordIds], {context: context});
if (res) {
await this.actionService.doAction(res, {
additionalContext: {
dont_redirect_to_payments: 1,
},
onClose: async () => {
await this.model.root.load();
this.render(true);
}
});
}
await this.model.root.load();
this.render(true);
}
}
patch(ExpenseListController.prototype, 'expense_list_controller_upload', ExpenseDocumentUpload);
export class ExpenseListRenderer extends ListRenderer {
setup() {
super.setup()
}
}
patch(ExpenseListRenderer.prototype, 'expense_list_renderer_qrcode', ExpenseMobileQRCode);
patch(ExpenseListRenderer.prototype, 'expense_list_renderer_qrcode_dzone', ExpenseDocumentDropZone);
ExpenseListRenderer.template = 'hr_expense.ListRenderer';
export class ExpenseDashboardListRenderer extends ExpenseListRenderer {}
export class ExpenseListRenderer extends ExpenseDocumentDropZone(
ExpenseMobileQRCode(ListRenderer)
) {
static template = "hr_expense.ListRenderer";
}
ExpenseDashboardListRenderer.components = { ...ExpenseDashboardListRenderer.components, ExpenseDashboard};
ExpenseDashboardListRenderer.template = 'hr_expense.DashboardListRenderer';
export class ExpenseDashboardListRenderer extends ExpenseListRenderer {
static components = { ...ExpenseDashboardListRenderer.components, ExpenseDashboard };
static template = "hr_expense.DashboardListRenderer";
}
registry.category('views').add('hr_expense_tree', {
...listView,

View file

@ -1,54 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="hr_expense.ListButtons" t-inherit="web.ListView.Buttons" t-inherit-mode="primary" owl="1">
<!-- hr.expense and hr.expense.sheet -->
<xpath expr="//button[hasclass('o_list_button_add')]" position="after">
<button type="button" class="d-inline d-md-none o_button_upload_expense btn btn-primary mx-1" t-on-click.prevent="uploadDocument">
Scan
</button>
<button type="button" class="d-none d-md-inline o_button_upload_expense btn btn-primary mx-1" t-on-click.prevent="uploadDocument">
Upload
</button>
<button t-if="displayCreateReport()" class="btn btn-secondary o_button_create_report" t-on-click="onCreateReportClick">
Create Report
</button>
</xpath>
<xpath expr="//div" position="inside">
<input type="file" name="ufile" class="d-none" t-ref="fileInput" multiple="1" accept="*" t-on-change="onChangeFileInput"/>
</xpath>
<!-- hr.expense.sheet -->
<xpath expr="//button[hasclass('o_button_upload_expense')]" position="after">
<button t-if="displaySubmit()" class="d-none d-md-block btn btn-secondary" t-on-click="() => this.onClick('action_submit_sheet')">
<t t-name="hr_expense.ListButtons" t-inherit="web.ListView.Buttons" t-inherit-mode="primary">
<xpath expr="." position="inside">
<button t-if="displaySubmit()" class="d-none d-md-block btn btn-secondary" t-on-click="() => this.onClick('action_submit')">
Submit
</button>
</xpath>
<xpath expr="//button[hasclass('o_button_upload_expense')]" position="after">
<button t-if="displayApprove()" class="d-none d-md-block btn btn-secondary" t-on-click="() => this.onClick('approve_expense_sheets')">
Approve Report
<button t-if="displayApprove()" class="d-none d-md-block btn btn-secondary" t-on-click="() => this.onClick('action_approve')">
Approve
</button>
</xpath>
<xpath expr="//button[hasclass('o_button_upload_expense')]" position="after">
<button t-if="displayPost()" class="d-none d-md-block btn btn-secondary" t-on-click="() => this.onClick('action_sheet_move_create')">
<button t-if="displayPost()" class="d-none d-md-block btn btn-secondary" t-on-click="() => this.onClick('action_post')">
Post Entries
</button>
</xpath>
</t>
<t t-name="hr_expense.ListRenderer" t-inherit="web.ListRenderer" t-inherit-mode="primary" owl="1">
<t t-name="hr_expense.ListView" t-inherit="web.ListView" t-inherit-mode="primary">
<xpath expr="//button[hasclass('o_list_button_add')]" position="before">
<input type="file" name="ufile" class="d-none" t-ref="fileInput" multiple="1" accept="*" t-on-change="onChangeFileInput"/>
<button t-if="!env.isSmall" type="button" class="o_button_upload_expense btn btn-primary" t-on-click.prevent="uploadDocument">
Upload
</button>
<button t-if="env.isSmall" type="button" class="o_button_upload_expense btn btn-primary me-1" t-on-click.prevent="uploadDocument">
Scan
</button>
</xpath>
<xpath expr="//t[@t-component='props.Renderer']" position="attributes">
<attribute name="uploadDocument.bind">uploadDocument</attribute>
</xpath>
</t>
<t t-name="hr_expense.ListRenderer" t-inherit="web.ListRenderer" t-inherit-mode="primary">
<xpath expr="//div[hasclass('o_list_renderer')]" position="before">
<div t-if="dragState.showDragZone" class="o_dropzone">
<i class="fa fa-upload fa-10x"></i>
</div>
</xpath>
<xpath expr="//div[hasclass('o_list_renderer')]" position="attributes">
<attribute name="class" add="hr_expense h-auto o_forbidden_tooltip_parent" separator=" "/>
</xpath>
</t>
<t t-name="hr_expense.DashboardListRenderer" t-inherit="hr_expense.ListRenderer" t-inherit-mode="primary" owl="1">
<t t-name="hr_expense.DashboardListRenderer" t-inherit="hr_expense.ListRenderer" t-inherit-mode="primary">
<xpath expr="//div[hasclass('o_list_renderer')]" position="before">
<ExpenseDashboard/>
</xpath>

View file

@ -1,48 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="hr.expense.DocumentsHiddenUploadForm">
<div class="d-none o_expense_documents_upload">
<t t-call="HiddenInputFile">
<t t-set="multi_upload" t-value="true"/>
<t t-set="fileupload_id" t-value="widget.fileUploadID"/>
<t t-set="fileupload_action" t-translation="off">/web/binary/upload_attachment</t>
<input type="hidden" name="model" t-att-value="'hr.expense'"/>
<input type="hidden" name="id" t-att-value="0"/>
</t>
</div>
</t>
<t t-name="hr.expense.DocumentDropZone">
<div class="o_drop_area d-none">
<i class="fa fa-upload fa-10x"></i>
</div>
</t>
<t t-extend="ListView.buttons" t-name="ExpensesListView.buttons">
<t t-jquery="button.o_list_button_add" t-operation="after">
<button type="button" t-att-class="'d-none d-md-block btn' + (!widget.isMobile ? ' btn-secondary' : '') + ' o_button_upload_expense'">
Scan
</button>
</t>
<t t-jquery="button.o_list_button_add" t-operation="before">
<button type="button" t-att-class="'d-block d-md-none btn' + (widget.isMobile ? ' btn-primary' : '') + ' o_button_upload_expense'">
Scan
</button>
</t>
<!-- hr.expense buttons -->
<t t-jquery="button.o_list_button_add" t-operation="after">
<button type="button" t-att-class="'btn btn-secondary' + (widget.isExpense ? '' : ' d-none') + ' o_button_create_report'">
Create Report
</button>
</t>
<t t-jquery="button.o_list_button_add" t-operation="replace">
<button type="button" t-att-class="'btn' + (widget.isMobile ? ' btn-secondary' : ' btn-primary') + ' o_list_button_add'" title="Create record" accesskey="c">
Create
</button>
</t>
</t>
</templates>

View file

@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template">
<t t-name="hr_expense.dashboard_list_header">
<div class="o_expense_container position-sticky start-0 d-flex o_form_statusbar">
<t t-foreach="expenses" t-as="expense">
<div t-attf-class="o_expense_card o_arrow_button flex-grow-1 d-flex flex-column p-3 border-bottom text-center">
<span t-esc="render_monetary_field(expenses[expense]['amount'], expenses[expense]['currency'])" class="h2 m-0 text-odoo"/>
<b class="mx-2" t-esc="expenses[expense]['description']"/>
</div>
<div t-if="expense !== 'approved'" t-attf-class="o_expense_card o_arrow_button flex-grow-1 d-flex flex-column p-3 border-bottom text-center">
<i class="fa fa-angle-right fa-3x"/>
</div>
</t>
</div>
</t>
</templates>

View file

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="hr_expense_qr_code">
<div style="text-align:center;" class="o_expense_modal">
<t t-if="widget.url">
<h3>Scan this QR code to get the Odoo app:</h3><br/><br/>
<img class="border border-dark rounded" t-att-src="widget.url"/>
</t>
</div>
</t>
</templates>

View file

@ -1,84 +0,0 @@
/** @odoo-module **/
import { registerCleanup } from "@web/../tests/helpers/cleanup";
import { getFixture, nextTick } from "@web/../tests/helpers/utils";
import { createWebClient, doAction } from "@web/../tests/webclient/helpers";
import { registry } from "@web/core/registry";
import { makeFakeHTTPService } from "@web/../tests/helpers/mock_services";
const serviceRegistry = registry.category("services");
let target;
let serverData;
QUnit.module("Expense", (hooks) => {
hooks.beforeEach(() => {
serviceRegistry.add("http", makeFakeHTTPService());
target = getFixture();
serverData = {
models: {
partner: {
fields: {
display_name: { string: "Displayed name", type: "char" },
},
},
},
};
});
QUnit.test("expense dashboard can horizontally scroll", async function (assert) {
// for this test, we need the elements to be visible in the viewport
target = document.body;
target.classList.add("debug");
registerCleanup(() => target.classList.remove("debug"));
serverData.views = {
"partner,false,search": `<search/>`,
"partner,false,list": `
<tree js_class="hr_expense_dashboard_tree">
<field name="display_name"/>
</tree>
`,
};
const webclient = await createWebClient({
serverData,
target,
async mockRPC(_, { method }) {
if (method === "get_expense_dashboard") {
return {
draft: {
description: "to report",
amount: 1000000000.00,
currency: 2,
},
reported: {
description: "under validation",
amount: 1000000000.00,
currency: 2,
},
approved: {
description: "to be reimbursed",
amount: 1000000000.00,
currency: 2,
},
};
}
},
});
await doAction(webclient, {
res_model: "partner",
type: "ir.actions.act_window",
views: [[false, "list"]],
});
const statusBar = target.querySelector(".o_expense_container");
statusBar.scrollLeft = 20;
await nextTick();
assert.strictEqual(
statusBar.scrollLeft,
20,
"the o_content should be 20 due to the overflow auto"
);
});
});

View file

@ -0,0 +1,124 @@
import { registry } from "@web/core/registry";
import { stepUtils } from "@web_tour/tour_utils";
registry.category("web_tour.tours").add('create_expense_no_employee_access_tour', {
url: "/odoo",
steps: () => [
...stepUtils.goToAppSteps('hr_expense.menu_hr_expense_root', "Go to the Expenses app"),
{
content: "Remove filter for own expenses",
trigger: '.o_facet_value:contains(My Expenses) + button[title="Remove"]',
run: 'click',
},
{
content: "Go to form view of pre-prepared record",
trigger: '.o_data_cell:contains(expense_for_tour_0)',
run: 'click',
},
{
content: "Click employee selection dropdown",
trigger: 'input#employee_id_0',
run: 'click',
},
{
content: "Delete default search",
trigger: 'input#employee_id_0',
run: "clear",
},
{
content: "Select test expense employee",
trigger: 'a.dropdown-item:contains(expense_employee)',
run: 'click',
},
{
content: "Save",
trigger: ".o_form_button_save:enabled",
run: 'click',
},
{
content: "wait until the form is saved",
trigger: "body .o_form_saved",
},
{
content: "Exit form",
trigger: '.o_menu_brand',
run: 'click',
},
stepUtils.showAppsMenuItem(),
{
content: "Check",
trigger: '.o_app[data-menu-xmlid="hr_expense.menu_hr_expense_root"]',
},
]});
registry.category("web_tour.tours").add("do_not_create_zero_amount_expense", {
url: "/odoo",
steps: () => [
...stepUtils.goToAppSteps("hr_expense.menu_hr_expense_root", "Go to the Expenses app"),
{
content: "Remove filter for own expenses",
trigger: '.o_facet_value:contains(My Expenses) + button[title="Remove"]',
run: 'click',
},
{
content: "Go to an expense",
trigger: '.o_data_row .o_data_cell[data-tooltip="expense_for_tour"]',
run: "click",
},
{
content: "Select category to Expense",
trigger: "div[name=product_id] input",
run: "click",
},
{
content: "Choose category to Expense",
trigger:
".o_field_widget[name=product_id] .o-autocomplete--dropdown-menu li:contains(EXP_GEN)",
run: "click",
},
{
content: "Set total amount to zero",
trigger: "div[name=total_amount_currency] input",
run: "edit 0.0",
},
{
content: "Click Submit",
trigger: ".o_expense_submit",
run: "click",
},
{
content: "Close the displayed user error indicating that the expense total cannot be set to zero if non-draft.",
trigger: ".modal .modal-footer .btn-primary.o-default-button",
run: "click",
},
{
content: "Set total amount to ten",
trigger: "div[name=total_amount_currency] input",
run: "edit 10.0",
},
{
content: "Click Submit",
trigger: ".o_expense_submit",
run: "click",
},
// Valid expense was saved on submit
{
content: "Set total amount to zero",
trigger: "div[name=total_amount_currency] input",
run: "edit 0.0",
},
// Save should fail
{
content: "Click Approve",
trigger: ".o_expense_approve",
run: "click",
},
{
content: "Close the displayed user error indicating that the expense total cannot be set to zero if non-draft.",
trigger: ".modal .modal-footer .btn-primary.o-default-button",
run: "click",
},
// Return to the valid expense
...stepUtils.discardForm(),
],
});

View file

@ -1,15 +1,18 @@
odoo.define('hr_expense.tests.tours', function (require) {
"use strict";
import { registry } from "@web/core/registry";
import { stepUtils } from "@web_tour/tour_utils";
var tour = require('web_tour.tour');
tour.register('hr_expense_test_tour', {
test: true,
url: "/web",
}, [tour.stepUtils.showAppsMenuItem(),
registry.category("web_tour.tours").add('hr_expense_test_tour', {
url: "/odoo",
steps: () => [stepUtils.showAppsMenuItem(),
{
content: "Go to Expense",
trigger: '.o_app[data-menu-xmlid="hr_expense.menu_hr_expense_root"]',
run: "click",
},
{
content: "Go to My Expenses",
trigger: 'button[data-menu-xmlid="hr_expense.menu_hr_expense_my_expenses"]',
run: "click",
},
{
content: "Check Upload Button",
@ -17,24 +20,39 @@ odoo.define('hr_expense.tests.tours', function (require) {
run() {
const button = document.querySelector('.o_button_upload_expense');
if(!button) {
console.error('Missing Upload button in My Expenses to Report > List View');
console.error('Missing Upload button in My Expenses > List View');
}
}
},
{
content: "Check Create Report Button, but not click on it",
trigger: "button.o_switch_view.o_list.active",
run() {
const button = Array.from(document.querySelectorAll('.btn-secondary'))
.filter(element => element.textContent.includes('Create Report'));
if(!button) {
console.error('Missing Create Report button in My Expenses to Report > List View');
}
}
content: "Create a new expense",
trigger: "button.o_list_button_add",
run: "click",
},
{
content: "Enter category for new expense in Many2One field",
trigger: ".o_field_widget.o_field_many2one[name=product_id] input",
run: "edit [COMM] Communication",
},
{
isActive: ["auto"],
trigger: ".ui-autocomplete > li > a:contains('[COMM] Communication')",
run: "click",
},
{
content: "Enter a value for the total",
trigger: "div[name=total_amount_currency] input",
run: "edit 100",
},
{
content: "Breadcrumb back to My Expenses",
trigger: ".breadcrumb-item:contains('My Expenses')",
run: "click",
},
{
content: "Go to kanban view",
trigger: "button.o_switch_view.o_kanban",
run: "click",
},
{
content: "Check Upload Button",
@ -42,32 +60,24 @@ odoo.define('hr_expense.tests.tours', function (require) {
run() {
const button = document.querySelector('.o_button_upload_expense');
if(!button) {
console.error('Missing Upload button in My Expenses to Report > Kanban View');
}
}
},
{
content: "Check Create Report Button, but not click on it",
trigger: "button.o_switch_view.o_kanban.active",
run() {
const button = Array.from(document.querySelectorAll('.btn-secondary'))
.filter(element => element.textContent.includes('Create Report'));
if(!button) {
console.error('Missing Create Report button in My Expenses to Report > Kanban View');
console.error('Missing Upload button in My Expenses > Kanban View');
}
}
},
{
content: "Go to Reporting",
trigger: 'button[data-menu-xmlid="hr_expense.menu_hr_expense_reports"]',
run: "click",
},
{
content: "Go to Expenses Analysis",
trigger: 'a[data-menu-xmlid="hr_expense.menu_hr_expense_all_expenses"]',
run: "click",
},
{
content: "Go to list view",
trigger: "button.o_switch_view.o_list",
run: "click",
},
{
content: "Check Upload Button",
@ -79,5 +89,33 @@ odoo.define('hr_expense.tests.tours', function (require) {
}
}
},
]);
});
]});
registry.category("web_tour.tours").add('hr_expense_access_rights_test_tour', {
url: "/odoo",
steps: () => [stepUtils.showAppsMenuItem(),
{
content: "Go to Expense",
trigger: '.o_app[data-menu-xmlid="hr_expense.menu_hr_expense_root"]',
run: "click",
},
{
content: "Go to My Expenses",
trigger: 'button[data-menu-xmlid="hr_expense.menu_hr_expense_my_expenses"]',
run: "click",
},
{
content: "Go to First Expense for employee",
trigger: 'td[data-tooltip="First Expense for employee"]',
run: "click",
},
{
content: "Click Submit to Manager Button",
trigger: '.o_expense_submit',
run: "click",
},
{
content: 'Verify the expense is submitted',
trigger: '.o_arrow_button_current:contains("Submitted")',
},
]});