[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:
Ali Alfie (alal)
2023-11-17 02:45:45 +00:00
parent e4155471bf
commit b01cd6ff48
7 changed files with 228 additions and 24 deletions
+3
View File
@@ -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',
}
+8 -21
View File
@@ -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");
});
});
+5 -3
View File
@@ -127,6 +127,7 @@
<header>
<button name="action_submit_expenses" string="Create Report" type="object"
class="oe_highlight o_expense_submit" invisible="nb_attachment &lt;= 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 &lt; 1" data-hotkey="w"/>
<widget name="attach_document" string="Attach Receipt" action="attach_document" invisible="nb_attachment &lt; 1"/>
<widget name="attach_document" string="Attach Receipt" action="attach_document" highlight="1" invisible="nb_attachment &gt;= 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 &gt;= 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"/>