diff --git a/addons/account/models/account_analytic_line.py b/addons/account/models/account_analytic_line.py index 3b02d93e5b2..bfce767699f 100644 --- a/addons/account/models/account_analytic_line.py +++ b/addons/account/models/account_analytic_line.py @@ -54,7 +54,6 @@ class AccountAnalyticTag(models.Model): class AccountAnalyticLine(models.Model): _inherit = 'account.analytic.line' _description = 'Analytic Line' - _order = 'date desc' product_id = fields.Many2one('product.product', string='Product', domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]") general_account_id = fields.Many2one('account.account', string='Financial Account', ondelete='restrict', readonly=True, diff --git a/addons/account/security/account_security.xml b/addons/account/security/account_security.xml index 7b67561fc6d..986c3563b21 100644 --- a/addons/account/security/account_security.xml +++ b/addons/account/security/account_security.xml @@ -65,10 +65,6 @@ [(1, '=', 1)] - - - - diff --git a/addons/hr_timesheet/__init__.py b/addons/hr_timesheet/__init__.py index 5905766d867..4e9b019bd7a 100644 --- a/addons/hr_timesheet/__init__.py +++ b/addons/hr_timesheet/__init__.py @@ -4,3 +4,4 @@ from . import controllers from . import models from . import report +from . import wizard diff --git a/addons/hr_timesheet/__manifest__.py b/addons/hr_timesheet/__manifest__.py index d80c3769250..c9e8db62f0f 100644 --- a/addons/hr_timesheet/__manifest__.py +++ b/addons/hr_timesheet/__manifest__.py @@ -34,6 +34,7 @@ up a management by affair. 'report/report_timesheet_templates.xml', 'views/hr_views.xml', 'data/hr_timesheet_data.xml', + 'wizard/project_task_create_timesheet_views.xml', ], 'demo': [ 'data/hr_timesheet_demo.xml', diff --git a/addons/hr_timesheet/data/hr_timesheet_data.xml b/addons/hr_timesheet/data/hr_timesheet_data.xml index 2ae8070b787..8fb4eb76cf6 100644 --- a/addons/hr_timesheet/data/hr_timesheet_data.xml +++ b/addons/hr_timesheet/data/hr_timesheet_data.xml @@ -1,13 +1,5 @@ - - - - project_tour - - - float_toggle @@ -23,4 +15,12 @@ name="_init_data_analytic_account" eval="[]"/> + + hr_timesheet.timesheet_rounding + 15 + + + hr_timesheet.timesheet_min_duration + 15 + diff --git a/addons/hr_timesheet/data/hr_timesheet_demo.xml b/addons/hr_timesheet/data/hr_timesheet_demo.xml index b0ce33ec82a..643a064cc68 100644 --- a/addons/hr_timesheet/data/hr_timesheet_demo.xml +++ b/addons/hr_timesheet/data/hr_timesheet_demo.xml @@ -1,6 +1,11 @@ + + + + + 100 diff --git a/addons/hr_timesheet/models/__init__.py b/addons/hr_timesheet/models/__init__.py index ee021539a0f..343cff74edd 100644 --- a/addons/hr_timesheet/models/__init__.py +++ b/addons/hr_timesheet/models/__init__.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import timer_mixin from . import hr_employee from . import hr_timesheet from . import ir_http diff --git a/addons/hr_timesheet/models/hr_timesheet.py b/addons/hr_timesheet/models/hr_timesheet.py index ddd0f05a64e..4e4fa8e20a1 100644 --- a/addons/hr_timesheet/models/hr_timesheet.py +++ b/addons/hr_timesheet/models/hr_timesheet.py @@ -5,11 +5,12 @@ from lxml import etree import re from odoo import api, fields, models, _ -from odoo.exceptions import UserError - +from odoo.exceptions import UserError, AccessError +from odoo.osv import expression class AccountAnalyticLine(models.Model): - _inherit = 'account.analytic.line' + _name = 'account.analytic.line' + _inherit = ['account.analytic.line', 'timer.mixin'] @api.model def default_get(self, field_list): @@ -20,15 +21,36 @@ class AccountAnalyticLine(models.Model): result['employee_id'] = self.env['hr.employee'].search([('user_id', '=', result['user_id'])], limit=1).id return result + def _domain_project_id(self): + domain = [('allow_timesheets', '=', True)] + if not self.user_has_groups('hr_timesheet.group_timesheet_manager'): + return expression.AND([domain, + ['|', ('privacy_visibility', '!=', 'followers'), ('message_partner_ids', 'in', [self.env.user.partner_id.id])] + ]) + return domain + + def _domain_employee_id(self): + if not self.user_has_groups('hr_timesheet.group_hr_timesheet_approver'): + return [('user_id', '=', self.env.user.id)] + return [] + + def _domain_task_id(self): + if not self.user_has_groups('hr_timesheet.group_hr_timesheet_approver'): + return ['|', ('privacy_visibility', '!=', 'followers'), ('message_partner_ids', 'in', [self.env.user.partner_id.id])] + return [] + task_id = fields.Many2one( 'project.task', 'Task', index=True, domain="[('company_id', '=', company_id), ('project_id.allow_timesheets', '=', True), ('project_id', '=?', project_id)]" ) - project_id = fields.Many2one('project.project', 'Project', domain=[('allow_timesheets', '=', True)]) + project_id = fields.Many2one('project.project', 'Project', domain=_domain_project_id) - employee_id = fields.Many2one('hr.employee', "Employee", check_company=True) + employee_id = fields.Many2one('hr.employee', "Employee", check_company=True, domain=_domain_employee_id) department_id = fields.Many2one('hr.department', "Department", compute='_compute_department_id', store=True, compute_sudo=True) encoding_uom_id = fields.Many2one('uom.uom', compute='_compute_encoding_uom_id') + display_timer = fields.Boolean( + compute='_compute_display_timer', + help="Technical field used to display the timer if the encoding unit is 'Hours'.") def _compute_encoding_uom_id(self): for analytic_line in self: @@ -57,10 +79,6 @@ class AccountAnalyticLine(models.Model): for line in self: line.department_id = line.employee_id.department_id - # ---------------------------------------------------- - # ORM overrides - # ---------------------------------------------------- - @api.model_create_multi def create(self, vals_list): default_user_id = self._default_user() @@ -81,6 +99,10 @@ class AccountAnalyticLine(models.Model): return lines def write(self, values): + # If it's a basic user then check if the timesheet is his own. + if not self.user_has_groups('hr_timesheet.group_hr_timesheet_approver') and any(self.env.user.id != analytic_line.user_id.id for analytic_line in self): + raise AccessError(_("You cannot access timesheets that are not yours.")) + values = self._timesheet_preprocess(values) result = super(AccountAnalyticLine, self).write(values) # applied only for timesheet @@ -105,10 +127,6 @@ class AccountAnalyticLine(models.Model): node.set('string', _('Duration (%s)') % (re.sub(r'[\(\)]', '', encoding_uom.name or ''))) return etree.tostring(doc, encoding='unicode') - # ---------------------------------------------------- - # Business Methods - # ---------------------------------------------------- - def _timesheet_get_portal_domain(self): return ['|', '&', ('task_id.project_id.privacy_visibility', '=', 'portal'), @@ -177,3 +195,46 @@ class AccountAnalyticLine(models.Model): 'amount': amount_converted, }) return result + + def _compute_display_timer(self): + uom_hour = self.env.ref('uom.product_uom_hour') + for analytic_line in self: + analytic_line.display_timer = analytic_line.encoding_uom_id == uom_hour + + def action_timer_start(self): + """ Start timer and search if another timer hasn't been launched. + If yes, then stop the timer before launch this timer. + """ + if not self.timer_start and self.display_timer: + self._stop_running_timers() + super().action_timer_start() + + def _stop_running_timers(self): + """ Search if a timesheet has a timer activated and stop the timer. + Check if a timer is activated for another timesheet + if yes, then update unit_amount field and stop timer, + otherwise, do nothing. + """ + analytic_line = self.search([('timer_start', '!=', False), ('user_id', '=', self.env.uid)]) + if analytic_line: + analytic_line.action_timer_stop() + + def action_timer_stop(self): + """ Action stop the timer of the current timesheet. + When the timer must be stopped, we must calculate the new + unit_amount based on the timer and the previous value of + unit_amount for the current timesheet. + """ + if self.timer_start and self.display_timer: + minutes_spent = self._get_minutes_spent() + if self.unit_amount == 0 and minutes_spent < 1: + # Check if unit_amount equals 0 and minutes_spent is less than 1 minute, + # if yes, then remove the timesheet + self.unlink() + else: + if minutes_spent < 1: + amount = self.unit_amount + else: + amount = self.unit_amount + minutes_spent * 60 / 3600 + self.write({'unit_amount': amount}) + super().action_timer_stop() diff --git a/addons/hr_timesheet/models/project.py b/addons/hr_timesheet/models/project.py index e4ebc4d3389..a475536aae7 100644 --- a/addons/hr_timesheet/models/project.py +++ b/addons/hr_timesheet/models/project.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from math import ceil from odoo import models, fields, api, _ from odoo.exceptions import UserError, ValidationError @@ -16,6 +17,15 @@ class Project(models.Model): ('partner_id', '=?', partner_id), ]""" ) + allow_timesheet_timer = fields.Boolean('Timesheet Timer', default=False, help="Use a timer to record timesheets on tasks") + + _sql_constraints = [ + ('timer_only_when_timesheet', "CHECK((allow_timesheets = 'f' AND allow_timesheet_timer = 'f') OR (allow_timesheets = 't'))", 'The timesheet timer can only be activated on project allowing timesheets.'), + ] + + @api.onchange('partner_id') + def _onchange_partner_id(self): + return {'domain': {'analytic_account_id': [('partner_id', '=', self.partner_id.id)] if self.partner_id else []}} @api.onchange('analytic_account_id') def _onchange_analytic_account(self): @@ -28,6 +38,11 @@ class Project(models.Model): if project.allow_timesheets and not project.analytic_account_id: raise ValidationError(_('To allow timesheet, your project %s should have an analytic account set.' % (project.name,))) + @api.onchange('allow_timesheets') + def _onchange_allow_timesheets(self): + if not self.allow_timesheets: + self.allow_timesheet_timer = False + @api.model def name_create(self, name): """ Create a project with name_create should generate analytic account creation """ @@ -55,19 +70,21 @@ class Project(models.Model): if not project.analytic_account_id and not values.get('analytic_account_id'): project._create_analytic_account() result = super(Project, self).write(values) + if 'allow_timesheet_timer' in values and not values.get('allow_timesheet_timer'): + self.with_context(active_test=False).mapped('task_ids').write({ + 'timer_start': False, + 'timer_pause': False, + }) return result - # --------------------------------------------------- - # Business Methods - # --------------------------------------------------- - @api.model def _init_data_analytic_account(self): self.search([('analytic_account_id', '=', False), ('allow_timesheets', '=', True)])._create_analytic_account() class Task(models.Model): - _inherit = "project.task" + _name = "project.task" + _inherit = ["project.task", "timer.mixin"] analytic_account_active = fields.Boolean("Analytic Account", related='project_id.analytic_account_id.active', readonly=True) allow_timesheets = fields.Boolean("Allow timesheets", related='project_id.allow_timesheets', help="Timesheets can be logged on this task.", readonly=True) @@ -78,6 +95,13 @@ class Task(models.Model): subtask_effective_hours = fields.Float("Sub-tasks Hours Spent", compute='_compute_subtask_effective_hours', store=True, help="Sum of actually spent hours on the subtask(s)") timesheet_ids = fields.One2many('account.analytic.line', 'task_id', 'Timesheets') + timer_start = fields.Datetime("Timesheet Timer Start") + timer_pause = fields.Datetime("Timesheet Timer Last Pause") + # YTI FIXME: Those field seems quite useless + timesheet_timer_first_start = fields.Datetime("Timesheet Timer First Use", readonly=True) + timesheet_timer_last_stop = fields.Datetime("Timesheet Timer Last Use", readonly=True) + display_timesheet_timer = fields.Boolean("Display Timesheet Time", compute='_compute_display_timesheet_timer') + @api.depends('timesheet_ids.unit_amount') def _compute_effective_hours(self): for task in self: @@ -110,14 +134,15 @@ class Task(models.Model): for task in self: task.subtask_effective_hours = sum(child_task.effective_hours + child_task.subtask_effective_hours for child_task in task.child_ids) - # --------------------------------------------------------- - # ORM - # --------------------------------------------------------- + @api.depends('allow_timesheets', 'project_id.allow_timesheet_timer', 'analytic_account_active') + def _compute_display_timesheet_timer(self): + for task in self: + task.display_timesheet_timer = task.allow_timesheets and task.project_id.allow_timesheet_timer and task.analytic_account_active def write(self, values): # a timesheet must have an analytic account (and a project) if 'project_id' in values and self and not values.get('project_id'): - raise UserError(_('This task must be part of a project because there are some timesheets linked to it.')) + raise UserError(_('This task must be part of a project because there are some timesheets linked to it.')) return super(Task, self).write(values) def name_get(self): @@ -137,3 +162,40 @@ class Task(models.Model): result = super(Task, self)._fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu) result['arch'] = self.env['account.analytic.line']._apply_timesheet_label(result['arch']) return result + + def action_timer_start(self): + self.ensure_one() + if not self.timesheet_timer_first_start: + self.write({'timesheet_timer_first_start': fields.Datetime.now()}) + super(Task, self).action_timer_start() + + def action_timer_stop(self): + self.ensure_one() + if self.timer_start: # timer was either running or paused + minutes_spent = self._get_minutes_spent() + minutes_spent = self._timer_rounding(minutes_spent) + return self._action_create_timesheet(minutes_spent * 60 / 3600) + return False + + def _timer_rounding(self, minutes_spent): + minimum_duration = int(self.env['ir.config_parameter'].sudo().get_param('hr_timesheet.timesheet_min_duration', 0)) + rounding = int(self.env['ir.config_parameter'].sudo().get_param('hr_timesheet.timesheet_rounding', 0)) + minutes_spent = max(minimum_duration, minutes_spent) + if rounding and ceil(minutes_spent % rounding) != 0: + minutes_spent = ceil(minutes_spent / rounding) * rounding + return minutes_spent + + def _action_create_timesheet(self, time_spent): + return { + "name": _("Validate Spent Time"), + "type": 'ir.actions.act_window', + "res_model": 'project.task.create.timesheet', + "views": [[False, "form"]], + "target": 'new', + "context": { + **self.env.context, + 'active_id': self.id, + 'active_model': 'project.task', + 'default_time_spent': time_spent, + }, + } diff --git a/addons/hr_timesheet/models/res_config_settings.py b/addons/hr_timesheet/models/res_config_settings.py index 2dc6c852589..f8e8f00621c 100644 --- a/addons/hr_timesheet/models/res_config_settings.py +++ b/addons/hr_timesheet/models/res_config_settings.py @@ -18,3 +18,5 @@ class ResConfigSettings(models.TransientModel): related='company_id.timesheet_encode_uom_id', readonly=False, help="""This will set the unit of measure used to encode timesheet. This will simply provide tools and widgets to help the encoding. All reporting will still be expressed in hours (default value).""") + timesheet_min_duration = fields.Integer('Minimal duration', default=15, config_parameter='hr_timesheet.timesheet_min_duration') + timesheet_rounding = fields.Integer('Rounding up', default=15, config_parameter='hr_timesheet.timesheet_rounding') diff --git a/addons/hr_timesheet/models/timer_mixin.py b/addons/hr_timesheet/models/timer_mixin.py new file mode 100644 index 00000000000..e4070d9b0a3 --- /dev/null +++ b/addons/hr_timesheet/models/timer_mixin.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, fields, api + + +class TimerMixin(models.AbstractModel): + _name = 'timer.mixin' + _description = 'Timer Mixin' + # YTI Note: This mixin is supposed to be extended to + # any models + + timer_start = fields.Datetime("Timer Start") + timer_pause = fields.Datetime("Timer Last Pause") + is_timer_running = fields.Boolean(compute="_compute_timer") + + @api.depends('timer_start') + def _compute_timer(self): + for record in self: + record.is_timer_running = bool(record.timer_start) + + def action_timer_start(self): + """ Action start the timer. + Start timer and search if another timer hasn't been launched. + If yes, then stop the timer before launch this timer. + """ + self.ensure_one() + if not self.timer_start: + self.write({'timer_start': fields.Datetime.now()}) + + def action_timer_stop(self): + """ Stop the timer and return the spent minutes since it started + :return minutes_spent if the timer is started, + otherwise return False + """ + self.ensure_one() + if not self.timer_start: + return False + minutes_spent = self._get_minutes_spent() + self.write({'timer_start': False, 'timer_pause': False}) + return minutes_spent + + def _get_minutes_spent(self): + start_time = self.timer_start + stop_time = fields.Datetime.now() + # timer was either running or paused + if self.timer_pause: + start_time += (stop_time - self.timer_pause) + return (stop_time - start_time).total_seconds() / 60 + + def action_timer_pause(self): + self.write({'timer_pause': fields.Datetime.now()}) + + def action_timer_resume(self): + new_start = self.timer_start + (fields.Datetime.now() - self.timer_pause) + self.write({'timer_start': new_start, 'timer_pause': False}) + + @api.model + def get_server_time(self): + """ Returns the server time. + The timer widget needs the server time instead of the client time + to avoid time desynchronization issues like the timer beginning at 0:00 + and not 23:59 and so on. + """ + return fields.Datetime.now() diff --git a/addons/hr_timesheet/security/hr_timesheet_security.xml b/addons/hr_timesheet/security/hr_timesheet_security.xml index 64033b482ec..0b7970610f7 100644 --- a/addons/hr_timesheet/security/hr_timesheet_security.xml +++ b/addons/hr_timesheet/security/hr_timesheet_security.xml @@ -7,32 +7,14 @@ - See all Timesheets + See own timesheets - - - - - - - - - account.analytic.line.timesheet.user - - [('user_id', '=', user.id), ('project_id', '!=', False)] - - - - - - - - - Team Approver + + See all timesheets @@ -40,24 +22,46 @@ Administrator - + - - + + + + + + account.analytic.line.timesheet.user + + [ + ('user_id', '=', user.id), + ('project_id', '!=', False), + '|', '|', + ('project_id.privacy_visibility', '!=', 'followers'), + ('project_id.message_partner_ids', 'in', [user.partner_id.id]), + ('task_id.message_partner_ids', 'in', [user.partner_id.id]) + ] + + + + + account.analytic.line.timesheet.approver + + [ + ('project_id', '!=', False), + '|', + ('project_id.privacy_visibility', '!=', 'followers'), + ('project_id.message_partner_ids', 'in', [user.partner_id.id]) + ] + + account.analytic.line.timesheet.manager [('project_id', '!=', False)] - - - - - diff --git a/addons/hr_timesheet/static/src/js/Timer.js b/addons/hr_timesheet/static/src/js/Timer.js new file mode 100644 index 00000000000..2a2c993dd91 --- /dev/null +++ b/addons/hr_timesheet/static/src/js/Timer.js @@ -0,0 +1,115 @@ +odoo.define('hr_timesheet.Timer', function (require) { +"use strict"; + + /** + * This class improves the timer display using the + * unit_amount field on account.analytic.line. + */ + class Timer { + constructor(hours, minutes, seconds) { + this.hours = hours; + this.minutes = minutes; + this.seconds = seconds; + } + + /** + * Convert float to time + * @param {number} float + */ + static convertFloatToTime(float) { + if (float === 0) { + return new Timer(0, 0, 0); + } + + let minutes = float % 1; + const hours = float - minutes; + minutes *= 60; + + return new Timer(hours, Math.round(minutes), 0); + } + + /** + * Create timer + * @param {number} unit_amount + * @param {String} timer_start + * @param {String} serverTime + */ + static createTimer(unit_amount, timer_start, serverTime) { + const timer = this.convertFloatToTime(unit_amount); + + timer.addTime( + moment.utc( + moment.utc(serverTime) + .diff(moment.utc(timer_start)) + ).format("HH:mm:ss") + ); + + return timer; + } + + addHours(hours) { + this.hours += hours; + } + + addMinutes(minutes) { + minutes += this.minutes; + + this.minutes = minutes % 60; + this.addHours(Math.floor(minutes / 60)); + } + + addSeconds(seconds) { + seconds += this.seconds; + this.seconds = seconds % 60; + this.addMinutes(Math.floor(seconds / 60)); + } + + display2digits(number) { + return number > 9 ? "" + number : "0" + number; + } + + addSecond() { + this.seconds += 1; + if (this.seconds === 60) { + this.minutes += 1; + this.seconds = 0; + + if (this.minutes === 60) { + this.hours += 1; + this.minutes = 0; + } + } + } + + addTime(time) { + if (typeof time == 'string' && time.indexOf(':') !== -1) { + let [hour, minute, second] = time.split(':'); + + hour = parseInt(hour); + minute = parseInt(minute); + second = parseInt(second); + + this.addSeconds(second); + this.addMinutes(minute); + this.addHours(hour); + } + } + + convertToFloat() { + return (this.hours * 60 + this.minutes) * 60 / 3600; + } + + toString() { + const time = { + hours: this.display2digits(this.hours), + minutes: this.display2digits(this.minutes), + seconds: this.display2digits(this.seconds) + }; + + return `${time.hours}:${time.minutes}:${time.seconds}`; + } + } + + return Timer; + +}); diff --git a/addons/hr_timesheet/static/src/js/timer.js b/addons/hr_timesheet/static/src/js/timer.js new file mode 100644 index 00000000000..5e7e4a1b2fb --- /dev/null +++ b/addons/hr_timesheet/static/src/js/timer.js @@ -0,0 +1,63 @@ +odoo.define('hr_timesheet.timer', function (require) { +"use strict"; + +var fieldRegistry = require('web.field_registry'); +var AbstractField = require('web.AbstractField'); +var Timer = require('hr_timesheet.Timer'); + +var TimerFieldWidget = AbstractField.extend({ + + /** + * @override + * @private + */ + isSet: function () { + return true; + }, + /** + * @override + * @private + */ + _render: function () { + this._super.apply(this, arguments); + this._startTimeCounter(); + }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + clearInterval(this.timer); + }, + /** + * @private + */ + _startTimeCounter: async function () { + if (this.record.data.timer_start) { + const serverTime = this.record.data.timer_pause || await this._getServerTime(); + this.time = Timer.createTimer(0, this.record.data.timer_start, serverTime); + this.$el.text(this.time.toString()); + this.timer = setInterval(() => { + if (this.record.data.timer_pause) { + clearInterval(this.timer); + } else { + this.time.addSecond(); + this.$el.text(this.time.toString()); + } + }, 1000); + } else if (!this.record.data.timer_pause){ + clearInterval(this.timer); + } + }, + _getServerTime: function () { + return this._rpc({ + model: 'account.analytic.line', + method: 'get_server_time', + args: [] + }); + } +}); + +fieldRegistry.add('timesheet_timer', TimerFieldWidget); + +}); diff --git a/addons/hr_timesheet/static/src/js/timer_toggle_button.js b/addons/hr_timesheet/static/src/js/timer_toggle_button.js new file mode 100644 index 00000000000..3a0fabd28c9 --- /dev/null +++ b/addons/hr_timesheet/static/src/js/timer_toggle_button.js @@ -0,0 +1,77 @@ +odoo.define('hr_timesheet.timer_toggle_button', function (require) { +"use strict"; + +const fieldRegistry = require('web.field_registry'); +const { FieldToggleBoolean } = require("web.basic_fields"); + +/** + * The TimerToggleButton is used to display correctly the button + * to start or stop a timer for a timesheet in kanban, list and grid + * views. + */ +const TimerToggleButton = FieldToggleBoolean.extend({ + /** + * @override + * @private + */ + _render: function () { + // When the is_timer_running field is false, then the button is used to start the timer + const title = this.value ? 'stop' : 'play'; + const name = this.value ? 'action_timer_stop' : 'action_timer_start'; + const label = this.value ? 'stop' : 'start'; + + this.$('i') + .addClass('fa') + .toggleClass('fa-stop-circle o-timer-stop-button', this.value) + .toggleClass('fa-play-circle o-timer-play-button', !this.value) + .attr('title', title); + + this.$el.addClass('o-timer-button'); + this.$el.attr('title', title); + this.$el.attr('name', name); + this.$el.attr('aria-label', label); + this.$el.attr('aria-pressed', this.value); + this.$el.attr('type', 'button'); + this.$el.attr('role', 'button'); + }, + /** + * Toggle the button + * + * When the user click on this button, + * - the action "action_timer_start" is called + * into the account.analytic.line model, + * if the value of is_timer_running field is set on false. + * - the action "action_timer_stop" is called + * into the account.analytic.line model, + * if the value of is_timer_running field is set on true. + * Then we change the value of the is_timer_running. + * @override + * @private + * @param {MouseEvent} event + */ + _onToggleButton: async function (event) { + event.stopPropagation(); + + const result = await this._rpc({ + model: 'account.analytic.line', + method: this._getActionButton(), + args: [this.res_id] + }); + + this.trigger_up('timer_changed', { + id: this.res_id, + is_timer_running: !this.value + }); + + this._setValue(!this.value); + }, + _getActionButton: function () { + return this.value ? 'action_timer_stop' : 'action_timer_start'; + } +}); + +fieldRegistry.add('timer_toggle_button', TimerToggleButton); + +return TimerToggleButton; + +}); diff --git a/addons/hr_timesheet/static/src/js/timesheet_kanban_view.js b/addons/hr_timesheet/static/src/js/timesheet_kanban_view.js new file mode 100644 index 00000000000..9a577a9895d --- /dev/null +++ b/addons/hr_timesheet/static/src/js/timesheet_kanban_view.js @@ -0,0 +1,34 @@ +odoo.define('hr_timesheet.timesheet_kanban_view', function (require) { +"use strict"; + +const KanbanController = require('web.KanbanController'); +const KanbanView = require('web.KanbanView'); +const viewRegistry = require('web.view_registry'); + +/** + * @override the KanbanController to add a trigger when a timer is launched or stopped + */ +const TimesheetKanbanController = KanbanController.extend({ + custom_events: _.extend({}, KanbanController.prototype.custom_events, { + 'timer_changed': '_onTimerChanged', + }), + /** + * When a timer is launched or stopped, we reload the view to see the updating. + * @param {Object} event + */ + _onTimerChanged: function (event) { + this.reload(); + } +}); + +const TimesheetKanbanView = KanbanView.extend({ + config: _.extend({}, KanbanView.prototype.config, { + Controller: TimesheetKanbanController + }) +}); + +viewRegistry.add('timesheet_kanban_view', TimesheetKanbanView); + +return { TimesheetKanbanController, TimesheetKanbanView }; + +}); diff --git a/addons/hr_timesheet/static/src/js/timesheet_list_view.js b/addons/hr_timesheet/static/src/js/timesheet_list_view.js new file mode 100644 index 00000000000..903352a35de --- /dev/null +++ b/addons/hr_timesheet/static/src/js/timesheet_list_view.js @@ -0,0 +1,34 @@ +odoo.define('hr_timesheet.timesheet_list_view', function (require) { +"use strict"; + +const ListController = require('web.ListController'); +var ListView = require('web.ListView'); +var viewRegistry = require('web.view_registry'); + +/** + * @override the ListController to add a trigger when the timer is launched or stopped + */ +const TimesheetListController = ListController.extend({ + custom_events: _.extend({}, ListController.prototype.custom_events, { + 'timer_changed': '_onTimerChanged', + }), + /** + * When a timer is launched or stopped, we reload the view to see the updating. + * @param {Object} event + */ + _onTimerChanged: function (event) { + this.reload(); + } +}); + +const TimesheetListView = ListView.extend({ + config: _.extend({}, ListView.prototype.config, { + Controller: TimesheetListController + }) +}); + +viewRegistry.add('timesheet_tree', TimesheetListView); + +return { TimesheetListController, TimesheetListView }; + +}); diff --git a/addons/hr_timesheet/static/src/js/timesheet_uom.js b/addons/hr_timesheet/static/src/js/timesheet_uom.js index 89945ef164a..a462b1d463b 100644 --- a/addons/hr_timesheet/static/src/js/timesheet_uom.js +++ b/addons/hr_timesheet/static/src/js/timesheet_uom.js @@ -1,24 +1,25 @@ odoo.define('hr_timesheet.timesheet_uom', function (require) { 'use strict'; -var AbstractField = require('web.AbstractField'); -var basicFields = require('web.basic_fields'); -var fieldUtils = require('web.field_utils'); +const basicFields = require('web.basic_fields'); +const fieldUtils = require('web.field_utils'); -var fieldRegistry = require('web.field_registry'); +const fieldRegistry = require('web.field_registry'); + +const Timer = require('hr_timesheet.Timer'); // We need the field registry to be populated, as we bind the // timesheet_uom widget on existing field widgets. require('web._field_registry'); -var session = require('web.session'); +const session = require('web.session'); /** * Extend the float factor widget to set default value for timesheet * use case. The 'factor' is forced to be the UoM timesheet * conversion from the session info. **/ -var FieldTimesheetFactor = basicFields.FieldFloatFactor.extend({ +const FieldTimesheetFactor = basicFields.FieldFloatFactor.extend({ formatType: 'float_factor', /** * Override init to tweak options depending on the session_info @@ -44,7 +45,7 @@ var FieldTimesheetFactor = basicFields.FieldFloatFactor.extend({ * native widget, and the 'factor' is forced to be the UoM timesheet * conversion. **/ -var FieldTimesheetToggle = basicFields.FieldFloatToggle.extend({ +const FieldTimesheetToggle = basicFields.FieldFloatToggle.extend({ formatType: 'float_factor', /** * Override init to tweak options depending on the session_info @@ -72,6 +73,61 @@ var FieldTimesheetToggle = basicFields.FieldFloatToggle.extend({ }); +/** + * Extend float time widget to add the using of a timer for duration + * (unit_amount) field. + */ +const FieldTimesheetTime = basicFields.FieldFloatTime.extend({ + init: function () { + this._super.apply(this, arguments); + + if (session.timesheet_uom_factor) { + this.nodeOptions.factor = session.timesheet_uom_factor; + this.parseOptions.factor = session.timesheet_uom_factor; + } + }, + _render: async function () { + this._super.apply(this, arguments); + + // Check if the timer_start exists and it's not false + // In other word, when user clicks on play button, this button + // launches the "action_timer_start". + if (this.recordData.timer_start) { + const time = await this._rpc({ + model: this.model, + method: 'get_server_time', + args: [] + }); + this.time = Timer.createTimer(this.recordData.unit_amount, this.recordData.timer_start, time); + return this._startTimeCounter(); + } else if (this.mode === "edit") { + return this._renderEdit(); + } else if (this.mode === 'readonly') { + return this._renderReadonly(); + } + }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + clearTimeout(this.timer); + }, + _startTimeCounter: function () { + if (this.time) { + this.timer = setInterval(() => { + this.time.addSecond(); + this.$el.text(this.time.toString()); + this.$el.addClass('font-weight-bold text-danger'); + }, 1000); + } else { + clearTimeout(this.timer); + this.$el.removeClass('font-weight-bold text-danger'); + } + }, +}); + + /** * Binding depending on Company Preference * @@ -80,22 +136,27 @@ var FieldTimesheetToggle = basicFields.FieldFloatToggle.extend({ * implementation (float_time, float_toggle, ...). The default * value will be 'float_factor'. **/ -var widgetName = 'timesheet_uom' in session ? +const widgetName = 'timesheet_uom' in session ? session.timesheet_uom.timesheet_widget : 'float_factor'; -var FieldTimesheetUom = widgetName === 'float_toggle' ? FieldTimesheetToggle - : ( - ( - fieldRegistry.get(widgetName) && - fieldRegistry.get(widgetName).extend({}) - ) || - FieldTimesheetFactor - ); + +let FieldTimesheetUom = null; + +if (widgetName === 'float_toggle') { + FieldTimesheetUom = FieldTimesheetToggle; +} else if (widgetName === 'float_time') { + FieldTimesheetUom = FieldTimesheetTime; +} else { + FieldTimesheetUom = ( + fieldRegistry.get(widgetName) && + fieldRegistry.get(widgetName).extend({}) + ) || FieldTimesheetFactor; +} fieldRegistry.add('timesheet_uom', FieldTimesheetUom); // bind the formatter and parser method, and tweak the options -var _tweak_options = function(options) { +const _tweak_options = function(options) { if (!_.contains(options, 'factor')) { options.factor = session.timesheet_uom_factor; } @@ -104,15 +165,21 @@ var _tweak_options = function(options) { fieldUtils.format.timesheet_uom = function(value, field, options) { options = _tweak_options(options || {}); - var formatter = fieldUtils.format[FieldTimesheetUom.prototype.formatType]; + const formatter = fieldUtils.format[FieldTimesheetUom.prototype.formatType]; return formatter(value, field, options); }; fieldUtils.parse.timesheet_uom = function(value, field, options) { options = _tweak_options(options || {}); - var parser = fieldUtils.parse[FieldTimesheetUom.prototype.formatType]; + const parser = fieldUtils.parse[FieldTimesheetUom.prototype.formatType]; return parser(value, field, options); }; -return FieldTimesheetUom; +return { + FieldTimesheetUom, + FieldTimesheetFactor, + FieldTimesheetTime, + FieldTimesheetToggle +}; + }); diff --git a/addons/hr_timesheet/static/src/scss/timer_button.scss b/addons/hr_timesheet/static/src/scss/timer_button.scss new file mode 100644 index 00000000000..8b269fa8b20 --- /dev/null +++ b/addons/hr_timesheet/static/src/scss/timer_button.scss @@ -0,0 +1,26 @@ +.o-timer-button { + &:focus { + border: none !important; + outline: 0 !important; + } + + & > i { + transition: 0.3s; + + &.o-timer-play-button { + color: $link-color !important; + } + + &.o-timer-stop-button { + color: $red !important; + } + } + + &:hover > i.o-timer-play-button { + color: $link-hover-color !important; + } + + &:hover > i.o-timer-stop-button { + color: darken($red, 15%) !important; + } +} diff --git a/addons/hr_timesheet/tests/test_timesheet.py b/addons/hr_timesheet/tests/test_timesheet.py index 2245c40707e..8fd1bde9d0e 100644 --- a/addons/hr_timesheet/tests/test_timesheet.py +++ b/addons/hr_timesheet/tests/test_timesheet.py @@ -16,7 +16,7 @@ class TestCommonTimesheet(TransactionCase): rule = self.env.ref('timesheet_grid.hr_timesheet_rule_approver_update', raise_if_not_found=False) if rule: rule.active = False - + # customer partner self.partner = self.env['res.partner'].create({ 'name': 'Customer Task', @@ -85,6 +85,16 @@ class TestCommonTimesheet(TransactionCase): class TestTimesheet(TestCommonTimesheet): + def setUp(self): + super(TestTimesheet, self).setUp() + + # Crappy hack to disable the rule from timesheet grid, if it exists + # The registry doesn't contain the field timesheet_manager_id. + # but there is an ir.rule about it, crashing during its evaluation + rule = self.env.ref('timesheet_grid.timesheet_line_rule_user_update-unlink', raise_if_not_found=False) + if rule: + rule.active = False + def test_log_timesheet(self): """ Test when log timesheet : check analytic account, user and employee are correctly set. """ Timesheet = self.env['account.analytic.line'] @@ -260,12 +270,18 @@ class TestTimesheet(TestCommonTimesheet): timesheets = timesheet_1 + timesheet_2 # increase unit_amount to trigger amount recomputation - timesheets.sudo().write({ + with self.assertRaises(AccessError): + # because the employee 1 is the sudo and he doesn't have the access right to update timesheet of employee 2 + timesheets.sudo().write({ + 'unit_amount': 2, + }) + + timesheets.with_user(self.user_manager).write({ 'unit_amount': 2, }) # since timesheet costs are different for both employees, we should get different amounts - self.assertRecordValues(timesheets, [{ + self.assertRecordValues(timesheets.with_user(self.user_manager), [{ 'amount': -10.0, }, { 'amount': -12.0, diff --git a/addons/hr_timesheet/views/assets.xml b/addons/hr_timesheet/views/assets.xml index 54a2b0af002..b55907ac4ed 100644 --- a/addons/hr_timesheet/views/assets.xml +++ b/addons/hr_timesheet/views/assets.xml @@ -2,8 +2,14 @@