diff options
| author | Lucas Lefèvre <lul@odoo.com> | 2019-05-02 15:35:09 +0200 |
|---|---|---|
| committer | Yannick Tivisse <yti@odoo.com> | 2019-05-17 11:22:10 +0200 |
| commit | 2b9aec24155e2ecc6164ad44a0b3d2dde14ea7f4 (patch) | |
| tree | 2d1d0c41884a16b37ee9f4ca48769ca1d21b4352 /addons | |
| parent | 09e515eecc5610c8af0c3989b5b023ad435ca437 (diff) | |
[IMP] hr_holidays: Add leave stats widget
When approving a leave, to make an informed decision, it might
be needed to check leaves already taken by the employee or leaves
of its collaborators. This commit adds a widget displaying some stats
in a leave form view. Stats displayed are:
- the number of days taken
by the employee this year, grouped by leave type
- Leaves taken by employees in the same department in the month
of the leave.
This widget is only displayed in the context of the action leading
to leaves to approve.
Diffstat (limited to 'addons')
| -rw-r--r-- | addons/hr_holidays/models/hr.py | 1 | ||||
| -rw-r--r-- | addons/hr_holidays/static/src/js/leave_stats_widget.js | 152 | ||||
| -rw-r--r-- | addons/hr_holidays/static/src/xml/leave_stats_templates.xml | 64 | ||||
| -rw-r--r-- | addons/hr_holidays/static/tests/test_leave_stats_widget.js | 156 | ||||
| -rw-r--r-- | addons/hr_holidays/views/hr_leave_views.xml | 23 |
5 files changed, 396 insertions, 0 deletions
diff --git a/addons/hr_holidays/models/hr.py b/addons/hr_holidays/models/hr.py index 6a0dae5d5a2..f870d61892a 100644 --- a/addons/hr_holidays/models/hr.py +++ b/addons/hr_holidays/models/hr.py @@ -9,6 +9,7 @@ from odoo.exceptions import UserError from odoo.tools.float_utils import float_round +# YTI TODO: Split into 2 files class Department(models.Model): _inherit = 'hr.department' diff --git a/addons/hr_holidays/static/src/js/leave_stats_widget.js b/addons/hr_holidays/static/src/js/leave_stats_widget.js new file mode 100644 index 00000000000..d224fe6ddca --- /dev/null +++ b/addons/hr_holidays/static/src/js/leave_stats_widget.js @@ -0,0 +1,152 @@ +odoo.define('hr_holidays.LeaveStatsWidget', function (require) { + "use strict"; + + var time = require('web.time'); + var Widget = require('web.Widget'); + var widget_registry = require('web.widget_registry'); + + var LeaveStatsWidget = Widget.extend({ + template: 'hr_holidays.leave_stats', + + /** + * @override + * @param {Widget|null} parent + * @param {Object} params + */ + init: function (parent, params) { + this._setState(params); + this._super(parent); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override to fetch data before rendering. + */ + willStart: function () { + return Promise.all([this._super(), this._fetchLeaveTypesData(), this._fetchDepartmentLeaves()]); + }, + + /** + * Fetch new data if needed (according to updated fields) and re-render the widget. + * Called by the basic renderer when the view changes. + * @param {Object} state + * @returns {Promise} + */ + updateState: function (state) { + var self = this; + var to_await = []; + var updatedFields = this._setState(state); + + if (_.intersection(updatedFields, ['employee', 'date']).length) { + to_await.push(this._fetchLeaveTypesData()); + } + if (_.intersection(updatedFields, ['department', 'date']).length) { + to_await.push(this._fetchDepartmentLeaves()); + } + return Promise.all(to_await).then(function () { + self.renderElement(); + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Update the state + * @param {Object} state + * @returns {String[]} list of updated fields + */ + _setState(state) { + var updatedFields = []; + if (state.data.employee_id.res_id !== (this.employee && this.employee.res_id)) { + updatedFields.push('employee'); + this.employee = state.data.employee_id; + } + if (state.data.department_id.res_id !== (this.department && this.department.res_id)) { + updatedFields.push('department'); + this.department = state.data.department_id; + } + if (state.data.date_from !== this.date) { + updatedFields.push('date'); + this.date = state.data.date_from; + } + return updatedFields; + }, + + /** + * Fetch leaves taken by members of ``this.department`` in the + * month of ``this.date``. + * Three fields are fetched for each leave, namely: employee_id, date_from + * and date_to. + * The resulting data is assigned to ``this.departmentLeaves`` + * @private + * @returns {Promise} + */ + _fetchDepartmentLeaves: function () { + if (!this.date || !this.department) { + this.departmentLeaves = null; + return Promise.resolve(); + } + var self = this; + var month_date_from = this.date.clone().startOf('month'); + var month_date_to = this.date.clone().endOf('month'); + return this._rpc({ + model: 'hr.leave', + method: 'search_read', + args: [ + [['department_id', '=', this.department.res_id], + ['state', '=', 'validate'], + ['holiday_type', '=', 'employee'], + ['date_from', '<=', month_date_to], + ['date_to', '>=', month_date_from]], + ['employee_id', 'date_from', 'date_to'], + ], + }).then(function (data) { + var dateFormat = time.getLangDateFormat(); + self.departmentLeaves = data.map(function (leave) { + // Format datetimes to date (in the user's format) + return _.extend(leave, { + date_from: moment(leave.date_from).format(dateFormat), + date_to: moment(leave.date_to).format(dateFormat), + }); + }); + }); + }, + + /** + * Fetch the number of leaves, grouped by leave type, taken by ``this.employee`` + * in the year of ``this.date``. + * The resulting data is assigned to ``this.leavesPerType`` + * @private + * @returns {Promise} + */ + _fetchLeaveTypesData: function () { + if (!this.date || !this.employee) { + this.leavesPerType = null; + return Promise.resolve(); + } + var self = this; + var year_date_from = this.date.clone().startOf('year'); + var year_date_to = this.date.clone().endOf('year'); + return this._rpc({ + model: 'hr.leave', + method: 'read_group', + kwargs: { + domain: [['employee_id', '=', this.employee.res_id], ['state', '=', 'validate'], ['date_from', '<=', year_date_to], ['date_to', '>=', year_date_from]], + fields: ['holiday_status_id', 'number_of_days:sum'], + groupby: ['holiday_status_id'], + }, + }).then(function (data) { + self.leavesPerType = data; + }); + } + }); + + widget_registry.add('hr_leave_stats', LeaveStatsWidget); + + return LeaveStatsWidget; +}); diff --git a/addons/hr_holidays/static/src/xml/leave_stats_templates.xml b/addons/hr_holidays/static/src/xml/leave_stats_templates.xml new file mode 100644 index 00000000000..594ff0e6b61 --- /dev/null +++ b/addons/hr_holidays/static/src/xml/leave_stats_templates.xml @@ -0,0 +1,64 @@ +<?xml version="1.0" encoding="UTF-8"?> + +<templates id="template" xml:space="preserve"> + <t t-name="hr_holidays.leave_per_type"> + <table class="o_group o_inner_group table-striped"> + <thead> + <tr> + <td colspan="2"> + <div class="o_horizontal_separator">Time Off taken by <t t-esc="widget.employee.data.display_name"/> in <t t-esc="widget.date.format('YYYY')"/></div> + </td> + </tr> + </thead> + <tbody> + <t t-if="widget.leavesPerType.length === 0"> + <tr> + <td>None</td> + </tr> + </t> + <t t-foreach="widget.leavesPerType" t-as="leave_type"> + <tr> + <td><t t-esc="leave_type.holiday_status_id[1]"/></td> + <td class="w-50"><t t-esc="leave_type.number_of_days"/></td> + </tr> + </t> + </tbody> + </table> + </t> + + <t t-name="hr_holidays.department_leave"> + <table class="o_group o_inner_group table-striped"> + <thead> + <tr> + <td colspan="2"> + <div class="o_horizontal_separator">In <t t-esc="widget.date.format('MMMM')"/> for <t t-esc="widget.department.data.display_name"/> Department + </div> + </td> + </tr> + </thead> + <tbody> + <t t-if="widget.departmentLeaves.length === 0"> + <tr> + <td>None</td> + </tr> + </t> + <t t-foreach="widget.departmentLeaves" t-as="leave"> + <tr t-attf-class="{{leave.employee_id[0] === widget.employee.res_id ? 'font-weight-bold' : ''}}"> + <td><t t-esc="leave.employee_id[1]"/></td> + <td class="w-50"><t t-esc="leave.date_from"/> - <t t-esc="leave.date_to"/></td> + </tr> + </t> + </tbody> + </table> + </t> + + <div t-name="hr_holidays.leave_stats" class="o_leave_stats"> + <t t-if="widget.leavesPerType"> + <t t-call="hr_holidays.leave_per_type"/> + </t> + <t t-if="widget.departmentLeaves"> + <t t-call="hr_holidays.department_leave"/> + </t> + </div> + +</templates> diff --git a/addons/hr_holidays/static/tests/test_leave_stats_widget.js b/addons/hr_holidays/static/tests/test_leave_stats_widget.js new file mode 100644 index 00000000000..157459dc61d --- /dev/null +++ b/addons/hr_holidays/static/tests/test_leave_stats_widget.js @@ -0,0 +1,156 @@ +odoo.define('hr_holidays.leave_stats_widget_tests', function (require) { + "use strict"; + + var FormView = require("web.FormView"); + var testUtils = require('web.test_utils'); + + var createView = testUtils.createView; + + QUnit.module('leave_stats_widget', { + beforeEach: function () { + this.data = { + department: { + fields: { + name: { string: "Name", type: "char" }, + }, + records: [{id:11, name: "R&D"}], + }, + employee: { + fields: { + name: { string: "Name", type: "char" }, + department_id: { string: "Department", type: "many2one", relation: 'department' }, + }, + records: [{ + id: 100, + name: "Richard", + department_id: 11, + },{ + id: 200, + name: "Jesus", + department_id: 11, + }], + }, + 'hr.leave.type': { + fields: { + name: { string: "Name", type: "char" } + }, + records: [{ + id: 55, + name: "Legal Leave", + }] + }, + 'hr.leave': { + fields: { + employee_id: { string: "Employee", type: "many2one", relation: 'employee' }, + department_id: { string: "Department", type: "many2one", relation: 'department' }, + date_from: { string: "From", type: "datetime" }, + date_to: { string: "To", type: "datetime" }, + holiday_status_id: { string: "Leave type", type: "many2one", relation: 'hr.leave.type' }, + state: { string: "State", type: "char" }, + holiday_type: { string: "Holiday Type", type: "char" }, + number_of_days: { string: "State", type: "integer" }, + }, + records: [{ + id: 12, + employee_id: 100, + department_id: 11, + date_from: "2016-10-20 09:00:00", + date_to: "2016-10-25 18:00:00", + holiday_status_id: 55, + state: 'validate', + number_of_days: 5, + holiday_type: 'employee', + },{ + id: 13, + employee_id: 100, + department_id: 11, + date_from: "2016-10-2 09:00:00", + date_to: "2016-10-2 18:00:00", + holiday_status_id: 55, + state: 'validate', + number_of_days: 1, + holiday_type: 'employee', + },{ + id: 14, + employee_id: 200, + department_id: 11, + date_from: "2016-10-15 09:00:00", + date_to: "2016-10-20 18:00:00", + holiday_status_id: 55, + state: 'validate', + number_of_days: 8, + holiday_type: 'employee', + }] + } + }; + } + }, function () { + QUnit.test('leave stats renders correctly', async function (assert) { + assert.expect(5); + var self = this; + var form = await createView({ + View: FormView, + model: 'hr.leave', + data: this.data, + arch: '<form string="Leave">' + + '<field name="employee_id"/>' + + '<field name="department_id"/>' + + '<field name="date_from"/>' + + '<widget name="hr_leave_stats"/>' + + '</form>', + res_id: 12, + mockRPC: function (route, args) { + if (args.model === 'hr.leave' && args.method === 'search') { + return Promise.resolve(self.data['hr.leave'].records.map(function (record) { return record.id; })); + } + return this._super.apply(this, arguments); + }, + }); + var $leaveTypeBody = form.$('.o_leave_stats table:first > tbody'); + var $leavesDepartmentBody = form.$('.o_leave_stats table:nth-child(2) > tbody'); + var $leavesDepartmentHeader = form.$('.o_leave_stats table:nth-child(2) > thead'); + + assert.strictEqual($leaveTypeBody.find('td:contains(Legal Leave)').length, 1, "it should have leave type"); + assert.strictEqual($leaveTypeBody.find('td:contains(6)').length, 1, "it should have 6 days"); + + assert.strictEqual($leavesDepartmentBody.find('td:contains(Richard)').length, 2, "it should have 2 leaves for Richard"); + assert.strictEqual($leavesDepartmentBody.find('td:contains(Jesus)').length, 1, "it should have 1 leaves for Jesus"); + assert.strictEqual($leavesDepartmentHeader.find('td:contains(R&D)').length, 1, "it should have R&D title"); + form.destroy(); + }); + QUnit.test('leave stats reload when employee/department changes', async function (assert) { + assert.expect(2); + var form = await createView({ + View: FormView, + model: 'hr.leave', + mode: 'edit', + data: this.data, + arch: '<form string="Leave">' + + '<field name="employee_id"/>' + + '<field name="department_id"/>' + + '<field name="date_from"/>' + + '<widget name="hr_leave_stats"/>' + + '</form>', + mockRPC: function (route, args) { + if (args.model === 'hr.leave' && args.method === 'search_read') { + assert.ok(_.some(args.args[0], ['department_id', '=', 11]), "It should load department's leaves data"); + } + if (args.model === 'hr.leave' && args.method === 'read_group') { + assert.ok(_.some(args.kwargs.domain, ['employee_id', '=', 200]), "It should load employee's leaves data"); + } + return this._super.apply(this, arguments); + }, + }); + // Set date => shouldn't load data yet (no employee nor department defined) + await testUtils.fields.editSelect($('input[name="date_from"]'), '2016-10-12 09:00:00'); + // Set employee => should load employee's date + await testUtils.fields.many2one.clickOpenDropdown("employee_id"); + await testUtils.fields.many2one.clickItem("employee_id", "Jesus"); + // Set department => should load department's data + await testUtils.fields.many2one.clickOpenDropdown("department_id"); + await testUtils.fields.many2one.clickItem("department_id", "R&D"); + + form.destroy(); + }); + }); +}); diff --git a/addons/hr_holidays/views/hr_leave_views.xml b/addons/hr_holidays/views/hr_leave_views.xml index 93381f54285..a87889e25a9 100644 --- a/addons/hr_holidays/views/hr_leave_views.xml +++ b/addons/hr_holidays/views/hr_leave_views.xml @@ -362,6 +362,29 @@ </field> </record> + <record id="hr_leave_view_form_manager_approve" model="ir.ui.view"> + <field name="name">hr.leave.view.form.manager.approve</field> + <field name="model">hr.leave</field> + <field name="inherit_id" ref="hr_leave_view_form"/> + <field name="mode">primary</field> + <field name="priority">16</field> + <field name="arch" type="xml"> + <xpath expr="//field[@name='holiday_status_id']" position="before"> + <field name="employee_id"/> + </xpath> + <xpath expr="//group[@name='col_right']" position="replace"> + <field name="department_id" invisible="1"/> + <group> + <widget name="hr_leave_stats"/> + </group> + <div groups="hr_holidays.group_hr_holidays_manager"> + <separator string="Comment by Manager"/> + <field name="report_note" placeholder="e.g. Report to the next month..."/> + </div> + </xpath> + </field> + </record> + <record id="hr_leave_view_tree" model="ir.ui.view"> <field name="name">hr.holidays.view.tree</field> <field name="model">hr.leave</field> |
