Initial commit: Hr packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:50 +02:00
commit 62531cd146
2820 changed files with 1432848 additions and 0 deletions

View file

@ -0,0 +1,28 @@
/** @odoo-module */
import { useService } from '@web/core/utils/hooks';
import { formatMonetary } from "@web/views/fields/formatters";
const { Component, onWillStart, useState } = owl;
export class ExpenseDashboard extends Component {
setup() {
super.setup();
this.orm = useService('orm');
this.state = useState({
expenses: {}
});
onWillStart(async () => {
const expense_states = await this.orm.call("hr.expense", 'get_expense_dashboard', []);
this.state.expenses = expense_states;
});
}
renderMonetaryField(value, currency_id) {
return formatMonetary(value, { currencyId: currency_id});;
}
}
ExpenseDashboard.template = 'hr_expense.ExpenseDashboard';

View file

@ -0,0 +1,19 @@
<?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-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>
<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"/>
</div>
</t>
<div class="fa fa-question-circle-o flex-grow-0.5 d-flex flex-column p-3 border-bottom text-center" t-if="env.debug" data-tooltip="Numbers computed from your personal expenses."/>
</div>
</t>
</templates>

View file

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

View file

@ -0,0 +1,11 @@
<?xml version="1.0"?>
<templates>
<t t-name="hr_expense.QRModalComponent" owl="1">
<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/>
<img class="border border-dark rounded" t-att-src="url"/>
</t>
</div>
</t>
</templates>

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View file

@ -0,0 +1,82 @@
odoo.define('hr_expense.tour', function(require) {
"use strict";
const {_t} = require('web.core');
const {Markup} = require('web.utils');
var tour = require('web_tour.tour');
tour.register('hr_expense_tour' , {
url: "/web",
rainbowManMessage: _t("There you go - expense management in a nutshell!"),
}, [tour.stepUtils.showAppsMenuItem(), {
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'
}, {
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",
),
{
trigger: '.breadcrumb > li.breadcrumb-item:first',
extra_trigger: ".o_expense_form",
content: _t("Let's go back to your expenses."),
position: 'bottom',
mobile: false,
}, {
trigger: '.o_expense_container',
content: _t("The status of all your current expenses is visible from here."),
position: 'bottom',
},
tour.stepUtils.openBuggerMenu(),
{
trigger: "[data-menu-xmlid='hr_expense.menu_hr_expense_report']",
extra_trigger: '.o_main_navbar',
content: _t("Let's check out where you can manage all your employees expenses"),
position: "bottom"
}, {
trigger: '.o_list_renderer tbody tr[data-id]',
content: _t('Managers can inspect all expenses from here.'),
position: 'bottom',
mobile: false,
}, {
trigger: '.o_kanban_renderer .oe_kanban_card',
content: _t('Managers can inspect all expenses from here.'),
position: 'bottom',
mobile: true,
},
...tour.stepUtils.statusbarButtonsSteps(_t("Approve"), _t("Managers can approve the report here, then an accountant can post the accounting entries.")),
]);
});

View file

@ -0,0 +1,116 @@
/** @odoo-module */
import { useBus, useService } from '@web/core/utils/hooks';
const { useRef, useEffect, useState } = owl;
export const ExpenseDocumentDropZone = {
setup() {
this._super();
this.dragState = useState({
showDragZone: false,
});
this.root = useRef("root");
useEffect(
(el) => {
if (!el) {
return;
}
const highlight = this.highlight.bind(this);
const unhighlight = this.unhighlight.bind(this);
const drop = this.onDrop.bind(this);
el.addEventListener("dragover", highlight);
el.addEventListener("dragleave", unhighlight);
el.addEventListener("drop", drop);
return () => {
el.removeEventListener("dragover", highlight);
el.removeEventListener("dragleave", unhighlight);
el.removeEventListener("drop", drop);
};
},
() => [document.querySelector('.o_content')]
);
},
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,
});
},
};
export const ExpenseDocumentUpload = {
setup() {
this._super();
this.actionService = useService('action');
this.notification = useService('notification');
this.orm = useService('orm');
this.http = useService('http');
this.fileInput = useRef('fileInput');
this.root = useRef("root");
this.isExpense = this.model.rootParams.resModel === "hr.expense";
useBus(this.env.bus, "change_file_input", async (ev) => {
this.fileInput.el.files = ev.detail.files;
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);
},
uploadDocument() {
this.fileInput.el.click();
},
async onChangeFileInput() {
const params = {
csrf_token: odoo.csrf_token,
ufile: [...this.fileInput.el.files],
model: 'hr.expense',
id: 0,
};
const fileData = await this.http.post('/web/binary/upload_attachment', params, "text");
const attachments = JSON.parse(fileData);
if (attachments.error) {
throw new Error(attachments.error);
}
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')
);
return;
}
const action = await this.orm.call('hr.expense', 'create_expense_from_attachments', ["", attachmentIds]);
this.actionService.doAction(action);
},
};

View file

@ -0,0 +1,46 @@
/** @odoo-module */
import { useService } from "@web/core/utils/hooks";
const { onMounted, onPatched, useRef } = owl;
export const ExpenseMobileQRCode = {
setup() {
this._super();
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');
if (!apps) {
return;
}
const handler = this.handleClick.bind(this);
for (const app of apps) {
app.addEventListener('click', handler);
}
},
handleClick(ev) {
ev.preventDefault();
ev.stopPropagation();
const url = ev.currentTarget && ev.currentTarget.href;
if (!this.env.isSmall) {
this.actionService.doAction({
name: this.env._t("Download our App"),
type: "ir.actions.client",
tag: 'expense_qr_code_modal',
target: "new",
params: { url },
});
} else {
this.actionService.doAction({ type: "ir.actions.act_url", url });
}
}
};

View file

@ -0,0 +1,50 @@
.hr_expense {
@include media-breakpoint-up(md) {
&.o_list_view, &.o_kanban_renderer {
min-height: auto;
}
}
.o_view_nocontent {
top: 10%;
.o_view_nocontent_expense_receipt:before {
@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;
}
}
}
.o_kanban_view .o_cp_bottom_left:has(.o_button_create_report) {
align-items: baseline;
}
.o_expense_container {
@include media-breakpoint-down(sm) {
overflow: auto visible;
}
}
.o_dropzone {
width: 100%;
height: 100%;
position: absolute;
background-color: #AAAA;
z-index: 2;
left: 0;
top: 0;
i {
justify-content: center;
display: flex;
align-items: center;
height: 100%;
}
}
.o_expense_categories td[name="description"] p:last-child {
margin-bottom: 0;
}

View file

@ -0,0 +1,47 @@
/** @odoo-module **/
import { useService } from "@web/core/utils/hooks";
import { registry } from "@web/core/registry";
import { FormController } from "@web/views/form/form_controller";
import { formView } from "@web/views/form/form_view";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
export class ExpenseFormController extends FormController {
setup() {
super.setup();
this.dialogService = useService("dialog");
this.orm = useService("orm");
}
/**
* @override
*/
async beforeExecuteActionButton(clickParams) {
const record = this.model.root;
if (
clickParams.name === "action_submit_expenses" &&
record.data.duplicate_expense_ids.count
) {
return new Promise((resolve) => {
this.dialogService.add(ConfirmationDialog, {
body: this.env._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);
},
}, {
onClose: resolve.bind(null, false),
});
});
}
return super.beforeExecuteActionButton(...arguments);
}
}
export const ExpenseFormView = {
...formView,
Controller: ExpenseFormController,
};
registry.category("views").add("hr_expense_form_view", ExpenseFormView);

View file

@ -0,0 +1,38 @@
/** @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';
import { ExpenseDocumentUpload, ExpenseDocumentDropZone } from '../mixins/document_upload';
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 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 ExpenseDashboardKanbanRenderer extends ExpenseKanbanRenderer {}
ExpenseDashboardKanbanRenderer.components = { ...ExpenseDashboardKanbanRenderer.components, ExpenseDashboard};
ExpenseDashboardKanbanRenderer.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

@ -0,0 +1,39 @@
<?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">
<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>
</div>
</xpath>
</t>
<t t-name="hr_expense.DashboardKanbanRenderer" t-inherit="hr_expense.KanbanRenderer" t-inherit-mode="primary" owl="1">
<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">
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>
</t>
</templates>

View file

@ -0,0 +1,90 @@
/** @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 { listView } from "@web/views/list/list_view";
import { ListController } from "@web/views/list/list_controller";
import { ListRenderer } from "@web/views/list/list_renderer";
const { onWillStart } = owl;
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");
});
}
displaySubmit() {
const records = this.model.root.selection;
return records.length && records.every(record => record.data.state === 'draft') && this.isExpenseSheet;
}
displayApprove() {
const records = this.model.root.selection;
return this.is_expense_team_approver && records.length && records.every(record => record.data.state === 'submit') && this.isExpenseSheet;
}
displayPost() {
const records = this.model.root.selection;
return this.is_account_invoicing && records.length && records.every(record => record.data.state === 'approve') && this.isExpenseSheet;
}
async onClick (action) {
const records = this.model.root.selection;
const recordIds = records.map((a) => a.resId);
const model = this.model.rootParams.resModel;
const context = {};
if (action === 'approve_expense_sheets') {
context['validate_analytic'] = true;
}
await this.orm.call(model, action, [recordIds], {context: context});
// sgv note: we tried this.model.notify(); and does not work
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 {}
ExpenseDashboardListRenderer.components = { ...ExpenseDashboardListRenderer.components, ExpenseDashboard};
ExpenseDashboardListRenderer.template = 'hr_expense.DashboardListRenderer';
registry.category('views').add('hr_expense_tree', {
...listView,
buttonTemplate: 'hr_expense.ListButtons',
Controller: ExpenseListController,
Renderer: ExpenseListRenderer,
});
registry.category('views').add('hr_expense_dashboard_tree', {
...listView,
buttonTemplate: 'hr_expense.ListButtons',
Controller: ExpenseListController,
Renderer: ExpenseDashboardListRenderer,
});

View file

@ -0,0 +1,56 @@
<?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')">
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>
</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')">
Post Entries
</button>
</xpath>
</t>
<t t-name="hr_expense.ListRenderer" t-inherit="web.ListRenderer" t-inherit-mode="primary" owl="1">
<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>
</t>
<t t-name="hr_expense.DashboardListRenderer" t-inherit="hr_expense.ListRenderer" t-inherit-mode="primary" owl="1">
<xpath expr="//div[hasclass('o_list_renderer')]" position="before">
<ExpenseDashboard/>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,48 @@
<?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

@ -0,0 +1,17 @@
<?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

@ -0,0 +1,11 @@
<?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>