[IMP] hr_expense: usability improvements
This commit contains a few small improvements to the Expense app. - "View Report" button moved to the left if an attachment has been uploaded. - Reports are auto saved when created from an expense. - "Attach Receipt" button now sets the uploaded attachment as the main attachment. - Expense lines in a report are no longer editable. When a line is clicked, the main attachment for that expense (if any) is shown in the attachment previewer. task-3539382 closes odoo/odoo#138387 Related: odoo/enterprise#50136 Signed-off-by: Habib Ayob (ayh) <ayh@odoo.com>
This commit is contained in:
@@ -69,6 +69,9 @@ This module also uses analytic accounting and is compatible with the invoice on
|
||||
'web.report_assets_common': [
|
||||
'hr_expense/static/src/scss/hr_expense.scss',
|
||||
],
|
||||
'web.qunit_suite_tests': [
|
||||
'hr_expense/static/tests/**/*.js',
|
||||
],
|
||||
},
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
|
||||
@@ -480,8 +480,8 @@ class HrExpense(models.Model):
|
||||
)
|
||||
|
||||
def attach_document(self, **kwargs):
|
||||
# To override
|
||||
pass
|
||||
"""When an attachment is uploaded as a receipt, set it as the main attachment."""
|
||||
self.message_main_attachment_id = kwargs['attachment_ids'][-1]
|
||||
|
||||
def create_expense_from_attachments(self, attachment_ids=None, view_type='list'):
|
||||
"""
|
||||
@@ -653,29 +653,16 @@ class HrExpense(models.Model):
|
||||
def action_submit_expenses(self):
|
||||
if self.filtered(lambda expense: not expense.is_editable):
|
||||
raise UserError(_('You are not authorized to edit this expense.'))
|
||||
context_vals = self._get_default_expense_sheet_values()
|
||||
action_values = {
|
||||
sheets = self.env['hr.expense.sheet'].create(self._get_default_expense_sheet_values())
|
||||
return {
|
||||
'name': _('New Expense Reports'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'hr.expense.sheet',
|
||||
'context': self.env.context,
|
||||
'views': [[False, "list"], [False, "form"]] if len(sheets) > 1 else [[False, "form"]],
|
||||
'domain': [('id', 'in', sheets.ids)],
|
||||
'res_id': sheets.id if len(sheets) == 1 else False,
|
||||
}
|
||||
if len(context_vals) > 1:
|
||||
sheets = self.env['hr.expense.sheet'].create(context_vals)
|
||||
action_values.update({
|
||||
'views': [[False, "list"], [False, "form"]],
|
||||
'domain': [('id', 'in', sheets.ids)],
|
||||
'context': self.env.context,
|
||||
})
|
||||
else:
|
||||
context_vals_def = {}
|
||||
for key in context_vals[0]:
|
||||
context_vals_def['default_' + key] = context_vals[0][key]
|
||||
action_values.update({
|
||||
'views': [[False, "form"]],
|
||||
'target': 'current',
|
||||
'context': {f'default_{key}': value for key, value in context_vals[0].items()},
|
||||
})
|
||||
return action_values
|
||||
|
||||
def action_get_attachment_view(self):
|
||||
self.ensure_one()
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
import { ListRenderer } from "@web/views/list/list_renderer";
|
||||
import { X2ManyField, x2ManyField } from "@web/views/fields/x2many/x2many_field";
|
||||
|
||||
export class ExpenseLinesListRenderer extends ListRenderer {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.threadService = useService("mail.thread");
|
||||
}
|
||||
|
||||
/** @override **/
|
||||
async onCellClicked(record, column, ev) {
|
||||
const sheetId = this.env.model.root.resId;
|
||||
const sheetThread = this.threadService.getThread('hr.expense.sheet', sheetId);
|
||||
const attachmentId = record.data.message_main_attachment_id[0]
|
||||
|
||||
if (attachmentId) {
|
||||
sheetThread.update({ mainAttachment: sheetThread.attachments.find((attachment) => attachment.id === attachmentId) });
|
||||
}
|
||||
super.onCellClicked(record, column, ev);
|
||||
}
|
||||
}
|
||||
|
||||
export class ExpenseLinesWidget extends X2ManyField {
|
||||
static components = {
|
||||
...X2ManyField.components,
|
||||
ListRenderer: ExpenseLinesListRenderer,
|
||||
};
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.canOpenRecord = false;
|
||||
}
|
||||
|
||||
get isMany2Many() {
|
||||
// The field is used like a many2many to allow for adding existing lines to the sheet.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export const expenseLinesWidget = {
|
||||
...x2ManyField,
|
||||
component: ExpenseLinesWidget,
|
||||
};
|
||||
|
||||
registry.category("fields").add("expense_lines_widget", expenseLinesWidget);
|
||||
@@ -0,0 +1,29 @@
|
||||
/* @odoo-module */
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { MockServer } from "@web/../tests/helpers/mock_server";
|
||||
|
||||
patch(MockServer.prototype, {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async _mockRouteMailThreadData(thread_model, thread_id, request_list) {
|
||||
if (thread_model === "hr.expense.sheet" && request_list.includes("attachments")) {
|
||||
const res = await super._mockRouteMailThreadData(thread_model, thread_id, request_list);
|
||||
const sheet = this.pyEnv["hr.expense.sheet"].searchRead([
|
||||
["id", "=", thread_id],
|
||||
]);
|
||||
const attachments = this.pyEnv["ir.attachment"].searchRead([
|
||||
["res_id", "in", sheet[0].expense_line_ids],
|
||||
["res_model", "=", "hr.expense"],
|
||||
]);
|
||||
if (Array.isArray(res["attachments"])) {
|
||||
res["attachments"] = this._mockIrAttachment_attachmentFormat(attachments.map((attachment) => attachment.id));
|
||||
} else {
|
||||
res["attachments"].push(this._mockIrAttachment_attachmentFormat(attachments.map((attachment) => attachment.id)));
|
||||
}
|
||||
return res;
|
||||
}
|
||||
return super._mockRouteMailThreadData(thread_model, thread_id, request_list);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { addModelNamesToFetch } from '@bus/../tests/helpers/model_definitions_helpers';
|
||||
|
||||
addModelNamesToFetch(['hr.expense', 'hr.expense.sheet']);
|
||||
@@ -0,0 +1,128 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { startServer } from "@bus/../tests/helpers/mock_python_environment";
|
||||
|
||||
import { getOrigin } from "@web/core/utils/urls";
|
||||
import { click, contains } from "@web/../tests/utils";
|
||||
import { nextTick } from "@web/../tests/helpers/utils";
|
||||
|
||||
import { start } from "@mail/../tests/helpers/test_utils";
|
||||
import { patchUiSize, SIZES } from "@mail/../tests/helpers/patch_ui_size";
|
||||
import { ROUTES_TO_IGNORE as MAIL_ROUTES_TO_IGNORE } from "@mail/../tests/helpers/webclient_setup";
|
||||
|
||||
const ROUTES_TO_IGNORE = [
|
||||
"/mail/init_messaging",
|
||||
"/mail/thread/messages",
|
||||
"/mail/load_message_failures",
|
||||
"/web/dataset/call_kw/ir.attachment/register_as_main_attachment",
|
||||
"/mail/thread/data",
|
||||
...MAIL_ROUTES_TO_IGNORE,
|
||||
];
|
||||
|
||||
QUnit.module("Views", {}, function () {
|
||||
QUnit.module("ExpenseLineWidget");
|
||||
|
||||
const OpenPreparedView = async (assert, size, sheet) => {
|
||||
const views = {
|
||||
"hr.expense.sheet,false,form":
|
||||
`<form>
|
||||
<sheet name="Expenses">
|
||||
<notebook>
|
||||
<page name="expenses" string="Expense">
|
||||
<field name="expense_line_ids" widget="expense_lines_widget">
|
||||
<tree>
|
||||
<field name="name"/>
|
||||
<field name="message_main_attachment_id"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<div class="o_attachment_preview"/>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids"/>
|
||||
<field name="activity_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>`,
|
||||
};
|
||||
patchUiSize({ size: size });
|
||||
const { openView } = await start({
|
||||
serverData: { views },
|
||||
mockRPC: function (route, args) {
|
||||
if (ROUTES_TO_IGNORE.includes(route)) {
|
||||
return;
|
||||
}
|
||||
if (route.includes("/web/static/lib/pdfjs/web/viewer.html")) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const method = args.method || route;
|
||||
const model = args.model !== undefined ? args.model : args.thread_model;
|
||||
assert.step(`${method}/${model}`);
|
||||
},
|
||||
});
|
||||
await openView({
|
||||
res_model: "hr.expense.sheet",
|
||||
res_id: sheet,
|
||||
views: [[false, "form"]],
|
||||
});
|
||||
};
|
||||
|
||||
QUnit.test("ExpenseLineWidget test attachments change on expense line click", async (assert) => {
|
||||
const pyEnv = await startServer();
|
||||
const sheet = pyEnv["hr.expense.sheet"].create({ name: "Expense Sheet"})
|
||||
const expense_lines = pyEnv["hr.expense"].create([
|
||||
{ name: "Lunch", sheet_id: sheet},
|
||||
{ name: "Taxi", sheet_id: sheet},
|
||||
{ name: "Misc", sheet_id: sheet},
|
||||
])
|
||||
const attachmentIds = pyEnv["ir.attachment"].create([
|
||||
{ res_id: expense_lines[0], res_model: "hr.expense", mimetype: "application/pdf" },
|
||||
{ res_id: expense_lines[0], res_model: "hr.expense", mimetype: "application/pdf" },
|
||||
{ res_id: expense_lines[1], res_model: "hr.expense", mimetype: "application/pdf" },
|
||||
{ res_id: expense_lines[1], res_model: "hr.expense", mimetype: "application/pdf" },
|
||||
]);
|
||||
pyEnv['hr.expense.sheet'].write([sheet], {expense_line_ids: expense_lines})
|
||||
pyEnv['hr.expense'].write([expense_lines[0]], {message_main_attachment_id: attachmentIds[1]})
|
||||
pyEnv['hr.expense'].write([expense_lines[1]], {message_main_attachment_id: attachmentIds[2]})
|
||||
|
||||
await OpenPreparedView(assert, SIZES.XXL, sheet);
|
||||
|
||||
await contains(".o_data_row", { count: 3 });
|
||||
await nextTick();
|
||||
assert.verifySteps([
|
||||
"get_views/hr.expense.sheet",
|
||||
"web_read/hr.expense.sheet",
|
||||
]);
|
||||
// Default attachment is the last one.
|
||||
await contains(
|
||||
`.o_attachment_preview iframe[data-src='/web/static/lib/pdfjs/web/viewer.html?file=${encodeURIComponent(
|
||||
getOrigin() + "/web/content/4"
|
||||
)}#pagemode=none']`
|
||||
);
|
||||
await click(":nth-child(1 of .o_data_row) :nth-child(1 of .o_data_cell)");
|
||||
// Attachment is switched to the main attachment in expense line one.
|
||||
await contains(
|
||||
`.o_attachment_preview iframe[data-src='/web/static/lib/pdfjs/web/viewer.html?file=${encodeURIComponent(
|
||||
getOrigin() + "/web/content/2"
|
||||
)}#pagemode=none']`
|
||||
);
|
||||
assert.verifySteps([], "no extra rpc should be done");
|
||||
// No change since line three has no attachments.
|
||||
await click(":nth-child(3 of .o_data_row) :nth-child(1 of .o_data_cell)");
|
||||
await contains(
|
||||
`.o_attachment_preview iframe[data-src='/web/static/lib/pdfjs/web/viewer.html?file=${encodeURIComponent(
|
||||
getOrigin() + "/web/content/2"
|
||||
)}#pagemode=none']`
|
||||
);
|
||||
assert.verifySteps([], "no extra rpc should be done");
|
||||
await click(":nth-child(2 of .o_data_row) :nth-child(1 of .o_data_cell)");
|
||||
// Attachment is switched to the main attachment in expense line two.
|
||||
await contains(
|
||||
`.o_attachment_preview iframe[data-src='/web/static/lib/pdfjs/web/viewer.html?file=${encodeURIComponent(
|
||||
getOrigin() + "/web/content/3"
|
||||
)}#pagemode=none']`
|
||||
);
|
||||
assert.verifySteps([], "no extra rpc should be done");
|
||||
});
|
||||
});
|
||||
@@ -127,6 +127,7 @@
|
||||
<header>
|
||||
<button name="action_submit_expenses" string="Create Report" type="object"
|
||||
class="oe_highlight o_expense_submit" invisible="nb_attachment <= 0 or sheet_id" data-hotkey="v"/>
|
||||
<button name="action_view_sheet" type="object" string="View Report" class="oe_highlight" invisible="not sheet_id or nb_attachment < 1" data-hotkey="w"/>
|
||||
<widget name="attach_document" string="Attach Receipt" action="attach_document" invisible="nb_attachment < 1"/>
|
||||
<widget name="attach_document" string="Attach Receipt" action="attach_document" highlight="1" invisible="nb_attachment >= 1"/>
|
||||
<button name="action_submit_expenses" string="Create Report" type="object" class="o_expense_submit"
|
||||
@@ -135,7 +136,7 @@
|
||||
invisible="state == 'refused'"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="draft,reported,submitted,refused"
|
||||
invisible="state != 'refused'"/>
|
||||
<button name="action_view_sheet" type="object" string="View Report" class="oe_highlight" invisible="not sheet_id" data-hotkey="w"/>
|
||||
<button name="action_view_sheet" type="object" string="View Report" class="oe_highlight" invisible="not sheet_id or nb_attachment >= 1" data-hotkey="w"/>
|
||||
<button name="action_split_wizard" string="Split Expense" type="object" invisible="sheet_id or product_has_cost"/>
|
||||
</header>
|
||||
<sheet>
|
||||
@@ -862,7 +863,7 @@
|
||||
<page name="expenses" string="Expense">
|
||||
<field name="expense_line_ids"
|
||||
nolabel="1"
|
||||
widget="many2many"
|
||||
widget="expense_lines_widget"
|
||||
mode="tree,kanban"
|
||||
domain="[
|
||||
('sheet_id', '=', False),
|
||||
@@ -879,10 +880,11 @@
|
||||
}"
|
||||
readonly="not is_editable"
|
||||
force_save="1">
|
||||
<tree editable="bottom">
|
||||
<tree>
|
||||
<field name="employee_id" column_invisible="True"/>
|
||||
<field name="state" column_invisible="True"/>
|
||||
<field name="nb_attachment" column_invisible="True"/>
|
||||
<field name="message_main_attachment_id" column_invisible="True"/>
|
||||
<field name="company_id" column_invisible="True"/>
|
||||
<field name="company_currency_id" column_invisible="True"/>
|
||||
<field name="is_multiple_currency" column_invisible="True"/>
|
||||
|
||||
Reference in New Issue
Block a user