From bb5784f7d9748c6b41f13ada5505d6378880424b Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Sun, 9 Jan 2022 14:28:47 -0800 Subject: [PATCH] [IMP] timesheet_grid_work_entry: dynamic ui for project task timer WIP for grid views overrides to add work_type_id --- timesheet_grid_work_entry/__init__.py | 3 + timesheet_grid_work_entry/__manifest__.py | 19 +- .../timesheet_grid/timer_header_component.js | 288 ++++++++++++++ .../static/src/js/timesheet_grid/timer_m2o.js | 118 ++++++ .../timesheet_timer_grid_renderer.js | 357 ++++++++++++++++++ .../timesheet_timer_grid_view.js | 37 ++ .../static/src/xml/timer_m2o.xml | 15 + .../static/src/xml/timesheet_grid.xml | 23 ++ .../views/timesheet_views.xml | 15 + timesheet_grid_work_entry/wizard/__init__.py | 4 + .../wizard/project_task_create_timesheet.py | 24 ++ .../project_task_create_timesheet_views.xml | 17 + .../wizard/timesheet_merge_wizard.py | 61 +++ .../wizard/timesheet_merge_wizard_views.xml | 17 + 14 files changed, 996 insertions(+), 2 deletions(-) create mode 100644 timesheet_grid_work_entry/static/src/js/timesheet_grid/timer_header_component.js create mode 100644 timesheet_grid_work_entry/static/src/js/timesheet_grid/timer_m2o.js create mode 100644 timesheet_grid_work_entry/static/src/js/timesheet_grid/timesheet_timer_grid_renderer.js create mode 100644 timesheet_grid_work_entry/static/src/js/timesheet_grid/timesheet_timer_grid_view.js create mode 100644 timesheet_grid_work_entry/static/src/xml/timer_m2o.xml create mode 100644 timesheet_grid_work_entry/static/src/xml/timesheet_grid.xml create mode 100644 timesheet_grid_work_entry/wizard/__init__.py create mode 100644 timesheet_grid_work_entry/wizard/project_task_create_timesheet.py create mode 100644 timesheet_grid_work_entry/wizard/project_task_create_timesheet_views.xml create mode 100644 timesheet_grid_work_entry/wizard/timesheet_merge_wizard.py create mode 100644 timesheet_grid_work_entry/wizard/timesheet_merge_wizard_views.xml diff --git a/timesheet_grid_work_entry/__init__.py b/timesheet_grid_work_entry/__init__.py index e69de29b..455a4c33 100644 --- a/timesheet_grid_work_entry/__init__.py +++ b/timesheet_grid_work_entry/__init__.py @@ -0,0 +1,3 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import wizard diff --git a/timesheet_grid_work_entry/__manifest__.py b/timesheet_grid_work_entry/__manifest__.py index 4bc7115c..d0dc90c9 100755 --- a/timesheet_grid_work_entry/__manifest__.py +++ b/timesheet_grid_work_entry/__manifest__.py @@ -1,10 +1,12 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + { 'name': 'Timesheet Grid Work Entry', 'description': 'bridge', - 'version': '15.0.1.0.0', + 'version': '15.0.1.0.1', 'website': 'https://hibou.io/', 'author': 'Hibou Corp. ', - 'license': 'AGPL-3', + 'license': 'OPL-1', 'category': 'Human Resources', 'depends': [ 'hr_timesheet_work_entry', @@ -12,9 +14,22 @@ ], 'data': [ 'views/timesheet_views.xml', + 'wizard/project_task_create_timesheet_views.xml', + 'wizard/timesheet_merge_wizard_views.xml', ], 'demo': [ ], + 'assets': { + 'web.assets_backend': [ +# 'timesheet_grid_work_entry/static/src/js/timesheet_grid/timesheet_timer_grid_renderer.js', +# 'timesheet_grid_work_entry/static/src/js/timesheet_grid/timesheet_timer_grid_view.js', +# 'timesheet_grid_work_entry/static/src/js/timesheet_grid/timer_m2o.js', +# 'timesheet_grid_work_entry/static/src/js/timesheet_grid/timer_header_component.js', + ], + 'web.assets_qweb': [ + # 'timesheet_grid_work_entry/static/src/xml/**/*', + ], + }, 'installable': True, 'auto_install': True, 'application': False, diff --git a/timesheet_grid_work_entry/static/src/js/timesheet_grid/timer_header_component.js b/timesheet_grid_work_entry/static/src/js/timesheet_grid/timer_header_component.js new file mode 100644 index 00000000..1f033ce4 --- /dev/null +++ b/timesheet_grid_work_entry/static/src/js/timesheet_grid/timer_header_component.js @@ -0,0 +1,288 @@ +odoo.define('timesheet_grid_work_entry.TimerHeaderComponent', function (require) { + "use strict"; + + console.log('timesheet_grid_work_entry.TimerHeaderComponent v1'); + + const fieldUtils = require('web.field_utils'); + const TimerHeaderM2O = require('timesheet_grid_work_entry.TimerHeaderM2O'); + const TimerHeaderComponent = require('timesheet_grid.TimerHeaderComponent'); + + const { useState, useRef } = owl.hooks; + const { ComponentAdapter } = require('web.OwlCompatibility'); + + class TimerHeaderM2OAdapter extends ComponentAdapter { + async updateWidget(nextProps) { + console.log(nextProps); + if (this.widget.workTypeId !== nextProps.widgetArgs[2]) { + this.widget.workTypeId = nextProps.widgetArgs[2]; + const workType = this.widget.workTypeId || false; + await this.widget.workTypeMany2one.reinitialize(workType); + } + // Original + if (this.widget.projectId !== nextProps.widgetArgs[0] || + this.widget.taskId !== nextProps.widgetArgs[1]) { + this.widget.projectId = nextProps.widgetArgs[0]; + this.widget.taskId = nextProps.widgetArgs[1]; + this.widget._updateRequiredField(); + const project = this.widget.projectId || false; + await this.widget.projectMany2one.reinitialize(project); + this.widget.taskMany2one.field.domain = [['project_id', '=?', project]]; + const task = this.widget.taskId || false; + await this.widget.taskMany2one.reinitialize(task); + } else if (nextProps.widgetArgs[3]) { + this.widget._updateRequiredField(); + } + } + } + + + TimerHeaderComponent.props['workTypeId'] = { + type: Number, + optional: true + }; + TimerHeaderComponent.props['workTypeName'] = { + type: String, + optional: true + }; + TimerHeaderComponent.components = { TimerHeaderM2OAdapter }; + + class TimerHeaderComponentWorkEntry extends TimerHeaderComponent { + constructor() { + super(...arguments); + this.TimerHeaderM2O = TimerHeaderM2O; + } + } + + return TimerHeaderComponentWorkEntry; + + // class TimerHeaderComponent extends owl.Component { + // constructor() { + // super(...arguments); + + // this.state = useState({ + // time: null, + // manualTimeInput: false, + // errorManualTimeInput: false, + // }); + // this.TimerHeaderM2O = TimerHeaderM2O; + // this.manualTimerAmount = "00:00"; + // this.manualTimeInput = useRef("manualTimerInput"); + // this.descriptionInput = useRef("inputDescription"); + // this.startButton = useRef("startButton"); + // this.stopButton = useRef("stopButton"); + // this.timerStarted = false; + + // if (this.props.timerRunning === true) { + // this.timerStarted = true; + // this.state.time = Math.floor(Date.now() / 1000) - this.props.timer; + // this.timer = setInterval(() => { + // this.state.time = Math.floor(Date.now() / 1000) - this.props.timer; + // }, 1000); + // } + // } + // async willUpdateProps(nextProps) { + // if (nextProps.description !== this.props.description && this.descriptionInput.el) { + // this.descriptionInput.el.value = nextProps.description; + // } + // return super.willUpdateProps(...arguments); + // } + // patched() { + // if (this.state.manualTimeInput && !this.state.errorManualTimeInput && this.manualTimeInput.el !== document.activeElement) { + // this.manualTimeInput.el.focus(); + // this.manualTimeInput.el.select(); + // } + // if (this.props.timerRunning && !this.timerStarted) { + // this.timerStarted = true; + // this.state.time = Math.floor(Date.now() / 1000) - this.props.timer; + // this.timer = setInterval(() => { + // this.state.time = Math.floor(Date.now() / 1000) - this.props.timer; + // }, 1000); + // this.stopButton.el.focus(); + // } else if (!this.props.timerRunning && this.timerStarted) { + // this.timerStarted = false; + // clearInterval(this.timer); + // this.startButton.el.focus(); + // } + // } + // mounted() { + // if (this.stopButton.el) { + // this.stopButton.el.focus(); + // } else { + // this.startButton.el.focus(); + // } + + // } + + // //---------------------------------------------------------------------- + // // Getters + // //---------------------------------------------------------------------- + + // get timerMode() { + // return this.props.addTimeMode; + // } + // get timeInput() { + // return this.manualTimerAmount; + // } + // get _timerIsRunning() { + // return this.props.timerRunning; + // } + // get _timerReadOnly() { + // return this.props.timerReadOnly; + // } + // get _manualTimeInput() { + // return this.state.manualTimeInput; + // } + // get _timerString() { + // if (this.state.time) { + // const hours = Math.floor(this.state.time / 3600); + // const secondsLeft = this.state.time % 3600; + // const seconds = this._display2digits(secondsLeft % 60); + // const minutes = this._display2digits(Math.floor(secondsLeft / 60)); + + // return `${this._display2digits(hours)}:${minutes}:${seconds}`; + // } + // return "00:00:00"; + // } + // get isMobile() { + // return this.env.device.isMobile; + // } + + // //-------------------------------------------------------------------------- + // // Private + // //-------------------------------------------------------------------------- + + // _display2digits(number) { + // return number > 9 ? "" + number : "0" + number; + // } + + // //-------------------------------------------------------------------------- + // // Handlers + // //-------------------------------------------------------------------------- + + // /** + // * @private + // * @param {KeyboardEvent} ev + // */ + // async _onKeydown(ev) { + // if (ev.key === 'Enter') { + // ev.preventDefault(); + // if (this.state.manualTimeInput) { + // this._onFocusoutTimer(ev); + // } else { + // this.trigger('new-description', ev.target.value); + // this.trigger('timer-stopped'); + // } + // } + // } + // /** + // * @private + // * @param {MouseEvent} ev + // */ + // _onFocusoutTimer(ev) { + // try { + // const value = fieldUtils.parse['float_time'](ev.target.value); + // this.state.errorManualTimeInput = (value < 0); + // if (!this.state.errorManualTimeInput) { + // this.trigger('new-timer-value', value); + // this.state.time = value * 3600; + // this.state.manualTimeInput = false; + // } + // } catch (_) { + // this.state.errorManualTimeInput = true; + // } + // } + // /** + // * @private + // * @param {KeyboardEvent} ev + // */ + // _onInputTimer(ev) { + // try { + // const value = fieldUtils.parse['float_time'](ev.target.value); + // this.state.errorManualTimeInput = (value < 0); + // } catch (_) { + // this.state.errorManualTimeInput = true; + // } + // } + // /** + // * @private + // * @param {MouseEvent} ev + // */ + // _onClickStopTimer(ev) { + // ev.stopPropagation(); + // this.trigger('timer-stopped'); + // } + // /** + // * @private + // * @param {MouseEvent} ev + // */ + // _onClickStartTimer(ev) { + // ev.stopPropagation(); + // this.trigger('timer-started'); + // } + // /** + // * @private + // * @param {Event} ev + // */ + // _onInputDescription(ev) { + // this.trigger('new-description', ev.target.value); + // } + // /** + // * @private + // * @param {MouseEvent} ev + // */ + // async _onClickManualTime(ev) { + // if (this.props.timerReadOnly) { + // return; + // } + // const rounded_minutes = await this.rpc({ + // model: 'account.analytic.line', + // method: 'get_rounded_time', + // args: [this.state.time/60], + // }); + // this.manualTimerAmount = fieldUtils.format['float_time'](rounded_minutes); + // this.state.manualTimeInput = true; + // } + // /** + // * @private + // * @param {MouseEvent} ev + // */ + // async _onUnlinkTimer(ev) { + // this.trigger('timer-unlink'); + // } + // } + // TimerHeaderComponent.template = 'timesheet_grid.timer_header'; + // TimerHeaderComponent.props = { + // taskId: { + // type: Number, + // optional: true + // }, + // projectId: { + // type: Number, + // optional: true + // }, + // taskName: { + // type: String, + // optional: true + // }, + // projectName: { + // type: String, + // optional: true + // }, + // stepTimer: Number, + // timer: Number, + // description: { + // type: String, + // optional: true + // }, + // timerRunning: Boolean, + // addTimeMode: Boolean, + // timerReadOnly: { + // type: Boolean, + // optional: true + // }, + // projectWarning: Boolean, + // }; + // TimerHeaderComponent.components = { TimerHeaderM2OAdapter }; + + // return TimerHeaderComponent; +}); diff --git a/timesheet_grid_work_entry/static/src/js/timesheet_grid/timer_m2o.js b/timesheet_grid_work_entry/static/src/js/timesheet_grid/timer_m2o.js new file mode 100644 index 00000000..186649a0 --- /dev/null +++ b/timesheet_grid_work_entry/static/src/js/timesheet_grid/timer_m2o.js @@ -0,0 +1,118 @@ +odoo.define('timesheet_grid_work_entry.TimerHeaderM2O', function (require) { +"use strict"; + +console.log('timesheet_grid_work_entry.TimerHeaderM2OWorkEntry v1'); + +const config = require('web.config'); +const core = require('web.core'); +const relational_fields = require('web.relational_fields'); +const Widget = require('web.Widget'); + +const Many2One = relational_fields.FieldMany2One; +const _t = core._t; +const TimerHeaderM2O = require('timesheet_grid.TimerHeaderM2O'); + +const TimerHeaderM2OWorkEntry = Widget.include(TimerHeaderM2O, { + + /** + * @constructor + * @param {Widget} parent + * @param {Object} params + */ + init: function (parent, params) { + console.log('TimerHeaderM2OWorkEntry.init v1'); + this._super(...arguments); + // StandaloneFieldManagerMixin.init.call(this); + // this.projectId = arguments[1]; + // this.taskId = arguments[2]; + this.workTypeId = arguments[3]; + }, + + /** + * @override + */ + willStart: async function () { + await this._super(...arguments); + + const workTypeDomain = [['allow_timesheet', '=', true]]; + this.workType = await this.model.makeRecord('account.analytic.line', [{ + name: 'work_type_id', + relation: 'hr.work.entry.type', + type: 'many2one', + value: this.projectTypeId, + domain: workTypeDomain, + }]); + + }, + /** + * @override + */ + start: async function () { + const _super = this._super.bind(this); + let placeholderWorkType; + + if (config.device.isMobile) { + placeholderWorkType = _t('Work Type'); + } else { + placeholderWorkType = _t('Select a Work Type'); + } + const workTypeRecord = this.model.get(this.workType); + const workTypeMany2one = new Many2One(this, 'work_type_id', workTypeRecord, { + attrs: { + placeholder: placeholderWorkType, + }, + noOpen: true, + noCreate: true, + mode: 'edit', + required: true, + }); + workTypeMany2one.field['required'] = true; + this._registerWidget(this.workType, 'work_type_id', workTypeMany2one); + await workTypeMany2one.appendTo(this.$('.timer_work_type_id')); + this.workTypeMany2one = workTypeMany2one; + + _super.apply(...arguments); + }, + /** + * @private + * @override + * @param {OdooEvent} ev + */ + _onFieldChanged: async function (ev) { + await this._super(...arguments); + + const workType = this.workTypeId; + const fieldName = ev.target.name; + + if (fieldName === 'work_type_id') { + record = this.model.get(this.workType); + var newId = record.data.work_type_id.res_id; + if (workType !== newId) { + this.workTypeId = newId; + } + } + + // } else if (fieldName === 'task_id') { + // record = this.model.get(this.task); + // const newId = record.data.task_id && record.data.task_id.res_id; + // if (task !== newId) { + // let project_id = this.projectId; + // if (!project_id) { + // const task_data = await this._rpc({ + // model: 'project.task', + // method: 'search_read', + // args: [[['id', '=', newId]], ['project_id']], + // }); + // project_id = task_data[0].project_id[0]; + // } + + // this.taskId = false; + // this.trigger_up('timer-edit-task', {'taskId': newId, 'projectId': project_id}); + // } + // } + }, +}); + +return TimerHeaderM2OWorkEntry + +}); diff --git a/timesheet_grid_work_entry/static/src/js/timesheet_grid/timesheet_timer_grid_renderer.js b/timesheet_grid_work_entry/static/src/js/timesheet_grid/timesheet_timer_grid_renderer.js new file mode 100644 index 00000000..f954d8b8 --- /dev/null +++ b/timesheet_grid_work_entry/static/src/js/timesheet_grid/timesheet_timer_grid_renderer.js @@ -0,0 +1,357 @@ +odoo.define('timesheet_grid_work_entry.TimerGridRenderer', function (require) { + "use strict"; + + console.log('timesheet_grid_work_entry.TimerGridRenderer v1'); + + const utils = require('web.utils'); + const GridRenderer = require('web_grid.GridRenderer'); + const TimerHeaderComponent = require('timesheet_grid_work_entry.TimerHeaderComponent'); + const TimerStartComponent = require('timesheet_grid.TimerStartComponent'); + const { useState, useExternalListener, useRef } = owl.hooks; + + class TimerGridRenderer extends GridRenderer { + constructor(parent, props) { + super(...arguments); + useExternalListener(window, 'keydown', this._onKeydown); + useExternalListener(window, 'keyup', this._onKeyup); + + this.initialGridAnchor = props.context.grid_anchor; + this.initialGroupBy = props.groupBy; + + this.stateTimer = useState({ + taskId: undefined, + taskName: '', + projectId: undefined, + projectName: '', + workTypeId: undefined, + workTypeName: '', + addTimeMode: false, + description: '', + startSeconds: 0, + timerRunning: false, + indexRunning: -1, + readOnly: false, + projectWarning: false, + }); + this.timerHeader = useRef('timerHeader'); + this.timesheetId = false; + this._onChangeProjectTaskDebounce = _.debounce(this._setProjectTask.bind(this), 500); + } + mounted() { + super.mounted(...arguments); + if (this.formatType === 'float_time') { + this._get_running_timer(); + } + } + async willUpdateProps(nextProps) { + if (nextProps.data !== this.props.data) { + this._match_line(nextProps.data); + } + return super.willUpdateProps(...arguments); + } + + //---------------------------------------------------------------------- + // Getters + //---------------------------------------------------------------------- + + /** + * @returns {boolean} returns true if when we need to display the timer button + * + */ + get showTimerButton() { + return ((this.formatType === 'float_time') && ( + this.props.groupBy.includes('project_id') + )); + } + /** + * @returns {boolean} returns always true if timesheet in hours, that way we know we're on a timesheet grid and + * we can show the timer header. + * + */ + get showTimer() { + return this.formatType === 'float_time'; + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + _match_line(new_grid) { + const grid = (new_grid) ? new_grid[0].rows : this.props.data[0].rows; + let current_value; + for (let i = 0; i < grid.length; i++) { + current_value = grid[i].values; + if (current_value.project_id && current_value.project_id[0] === this.stateTimer.projectId + && ((!current_value.task_id && !this.stateTimer.taskId) || + (current_value.task_id && current_value.task_id[0] === this.stateTimer.taskId))) { + this.stateTimer.indexRunning = i; + return; + } + } + this.stateTimer.indexRunning = -1; + } + async _get_running_timer() { + const result = await this.rpc({ + model: 'account.analytic.line', + method: 'get_running_timer', + args: [] + }); + if (result.id !== undefined) { + this.stateTimer.timerRunning = true; + this.timesheetId = result.id; + this.stateTimer.readOnly = result.readonly; + this.stateTimer.projectId = result.project_id; + this.stateTimer.taskId = result.task_id || undefined; + + // In case of read-only timer + this.stateTimer.projectName = (result.project_name) ? result.project_name : ''; + this.stateTimer.taskName = (result.task_name) ? result.task_name : ''; + + this.stateTimer.timerRunning = true; + this.stateTimer.description = (result.description === '/') ? '' : result.description; + this.stateTimer.startSeconds = Math.floor(Date.now() / 1000) - result.start; + } else if (this.stateTimer.timerRunning && this.stateTimer.projectId) { + this.timesheetId = false; + this.stateTimer.readOnly = false; + this.stateTimer.projectId = false; + this.stateTimer.taskId = undefined; + + this.stateTimer.timerRunning = false; + this.stateTimer.description = ''; + } + if (this.timerHeader.comp.startButton.el) { + this.timerHeader.comp.startButton.el.focus(); + } + this._match_line(); + } + async _onSetProject(data) { + this.stateTimer.projectId = data.detail.projectId; + this.stateTimer.taskId = undefined; + this._onChangeProjectTaskDebounce(data.detail.projectId, undefined); + } + async _onSetWorkType(data) { + this.stateTimer.workTypeId = data.detail.workTypeId; + } + async _onSetTask(data) { + this.stateTimer.projectId = data.detail.projectId; + this.stateTimer.taskId = data.detail.taskId || undefined; + this._onChangeProjectTaskDebounce(this.stateTimer.projectId, data.detail.taskId); + } + async _setProjectTask(projectId, taskId) { + if (!this.stateTimer.projectId) { + return; + } + if (this.timesheetId) { + const timesheetId = await this.rpc({ + model: 'account.analytic.line', + method: 'action_change_project_task', + args: [[this.timesheetId], this.stateTimer.projectId, this.stateTimer.taskId], + }); + if (this.timesheetId !== timesheetId) { + this.timesheetId = timesheetId; + await this._get_running_timer(); + } + } else { + const seconds = Math.floor(Date.now() / 1000) - this.stateTimer.startSeconds; + this.timesheetId = await this.rpc({ + model: 'account.analytic.line', + method: 'create', + args: [{ + 'name': this.stateTimer.description, + 'project_id': this.stateTimer.projectId, + 'task_id': this.stateTimer.taskId, + }], + }); + // Add already runned time and start timer if doesn't running yet in DB + this.trigger('add_time_timer', { + timesheetId: this.timesheetId, + time: seconds + }); + } + this._match_line(); + } + async _onClickLineButton(taskId, projectId) { + // Check that we can create timers for the selected project. + // This is an edge case in multi-company environment. + const canStartTimerResult = await this.rpc({ + model: 'project.project', + method: 'check_can_start_timer', + args: [[projectId]], + }); + if (canStartTimerResult !== true) { + this.trigger('do_action', {action: canStartTimerResult}) + return; + } + if (this.stateTimer.addTimeMode === true) { + this.timesheetId = await this.rpc({ + model: 'account.analytic.line', + method: 'action_add_time_to_timesheet', + args: [[this.timesheetId], projectId, taskId, this.props.stepTimer * 60], + }); + this.trigger('update_timer'); + } else if (! this.timesheetId && this.stateTimer.timerRunning) { + this.stateTimer.projectId = projectId; + this.stateTimer.taskId = (taskId) ? taskId : undefined; + await this._onChangeProjectTaskDebounce(projectId, taskId); + } else { + if (this.stateTimer.projectId === projectId && this.stateTimer.taskId === taskId) { + await this._stop_timer(); + return; + } + await this._stop_timer(); + this.stateTimer.projectId = projectId; + this.stateTimer.taskId = (taskId) ? taskId : undefined; + await this._onTimerStarted(); + await this._onChangeProjectTaskDebounce(projectId, taskId); + } + if (this.timerHeader.comp.stopButton.el) { + this.timerHeader.comp.stopButton.el.focus(); + } + } + async _onTimerStarted() { + this.stateTimer.timerRunning = true; + this.stateTimer.addTimeMode = false; + this.stateTimer.startSeconds = Math.floor(Date.now() / 1000); + if (this.props.defaultProject && ! this.stateTimer.projectId) { + this.stateTimer.projectId = this.props.defaultProject; + this._onChangeProjectTaskDebounce(this.props.defaultProject, undefined); + } + } + async _stop_timer() { + if (!this.timesheetId) { + this.stateTimer.projectWarning = true; + return; + } + let timesheetId = this.timesheetId; + this.timesheetId = false; + this.trigger('stop_timer', { + timesheetId: timesheetId, + }); + this.stateTimer.description = ''; + this.stateTimer.timerRunning = false; + this.timesheetId = false; + this.stateTimer.projectId = undefined; + this.stateTimer.taskId = undefined; + + this.stateTimer.timerRunning = false; + this.stateTimer.projectWarning = false; + + this._match_line(); + this.stateTimer.readOnly = false; + } + async _onTimerUnlink() { + if (this.timesheetId !== false) { + this.trigger('unlink_timer', { + timesheetId: this.timesheetId, + }); + } + this.timesheetId = false; + this.stateTimer.projectId = undefined; + this.stateTimer.taskId = undefined; + + this.stateTimer.timerRunning = false; + this.stateTimer.description = ''; + this.stateTimer.manualTimeInput = false; + this._match_line(); + this.stateTimer.readOnly = false; + this.stateTimer.projectWarning = false; + } + _onNewDescription(data) { + this.stateTimer.description = data.detail; + if (this.timesheetId) { + this.trigger('update_timer_description', { + timesheetId: this.timesheetId, + description: data.detail + }); + } + } + async _onNewTimerValue(data) { + const seconds = Math.floor(Date.now() / 1000) - this.stateTimer.startSeconds; + const toAdd = data.detail * 3600 - seconds; + this.stateTimer.startSeconds = this.stateTimer.startSeconds - toAdd; + if (this.timesheetId && typeof toAdd === 'number') { + this.trigger('add_time_timer', { + timesheetId: this.timesheetId, + time: toAdd + }); + } + this.timerHeader.comp.stopButton.el.focus(); + } + + //---------------------------------------------------------------------- + // Handlers + //---------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + async _onClickStartTimerFromLine(ev) { + if (! ev.detail) { + return; + } + const cell_path = ev.detail.split('.'); + const grid_path = cell_path.slice(0, -2); + const row_path = grid_path.concat(['rows'], cell_path.slice(-1)); + const row = utils.into(this.props.data, row_path); + const data = row.values; + const task = (data.task_id) ? data.task_id[0] : undefined; + this._onClickLineButton(task, data.project_id[0]); + } + /** + * @private + * @param {KeyboardEvent} ev + */ + async _onKeydown(ev) { + if (ev.key === 'Shift' && !this.stateTimer.timerRunning && !this.state.editMode) { + this.stateTimer.addTimeMode = true; + } else if (!ev.altKey && !ev.ctrlKey && !ev.metaKey && this.showTimerButton && ! ['input', 'textarea'].includes(ev.target.tagName.toLowerCase())) { + if (ev.key === 'Escape' && this.stateTimer.timerRunning) { + this._onTimerUnlink(); + } + const index = ev.keyCode - 65; + if (index >= 0 && index <= 26 && index < this.props.data[0].rows.length) { + const data = this.props.data[0].rows[index].values; + const projectId = data.project_id[0]; + const taskId = data.task_id && data.task_id[0]; + this._onClickLineButton(taskId, projectId); + } + } + } + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeyup(ev) { + if (ev.key === 'Shift' && !this.state.editMode) { + this.stateTimer.addTimeMode = false; + } + } + } + + TimerGridRenderer.props = Object.assign({}, GridRenderer.props, { + serverTime: { + type: String, + optional: true + }, + stepTimer: { + type: Number, + optional: true + }, + defaultProject: { + type: [Boolean, Number], + optional: true + }, + Component: { + type: Object, + optional: true + }, + }); + + TimerGridRenderer.components = { + TimerHeaderComponent, + TimerStartComponent, + }; + + return TimerGridRenderer; +}); diff --git a/timesheet_grid_work_entry/static/src/js/timesheet_grid/timesheet_timer_grid_view.js b/timesheet_grid_work_entry/static/src/js/timesheet_grid/timesheet_timer_grid_view.js new file mode 100644 index 00000000..7425bd3d --- /dev/null +++ b/timesheet_grid_work_entry/static/src/js/timesheet_grid/timesheet_timer_grid_view.js @@ -0,0 +1,37 @@ +odoo.define('timesheet_grid_work_entry.TimerGridView', function (require) { + "use strict"; + + console.log('timesheet_grid_work_entry.TimerGridView v1'); + + const viewRegistry = require('web.view_registry'); + const WebGridView = require('web_grid.GridView'); + const TimerGridController = require('timesheet_grid.TimerGridController'); + const TimerGridModel = require('timesheet_grid.TimerGridModel'); + const GridRenderer = require('timesheet_grid_work_entry.TimerGridRenderer'); + const TimesheetConfigQRCodeMixin = require('timesheet_grid.TimesheetConfigQRCodeMixin'); + const { onMounted, onPatched } = owl.hooks; + + class TimerGridRenderer extends GridRenderer { + constructor() { + console.log('TimerGridRenderer constructor called'); + super(...arguments); + onMounted(() => this._bindPlayStoreIcon()); + onPatched(() => this._bindPlayStoreIcon()); + } + } + + // QRCode mixin to bind event on play store icon + Object.assign(TimerGridRenderer.prototype, TimesheetConfigQRCodeMixin); + + const TimerGridView = WebGridView.extend({ + config: Object.assign({}, WebGridView.prototype.config, { + Model: TimerGridModel, + Controller: TimerGridController, + Renderer: TimerGridRenderer + }) + }); + + viewRegistry.add('timesheet_timer_grid', TimerGridView); + + return TimerGridView; +}); diff --git a/timesheet_grid_work_entry/static/src/xml/timer_m2o.xml b/timesheet_grid_work_entry/static/src/xml/timer_m2o.xml new file mode 100644 index 00000000..f913248e --- /dev/null +++ b/timesheet_grid_work_entry/static/src/xml/timer_m2o.xml @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/timesheet_grid_work_entry/static/src/xml/timesheet_grid.xml b/timesheet_grid_work_entry/static/src/xml/timesheet_grid.xml new file mode 100644 index 00000000..586ad555 --- /dev/null +++ b/timesheet_grid_work_entry/static/src/xml/timesheet_grid.xml @@ -0,0 +1,23 @@ + + + + + + stateTimer.workTypeId + _onSetWorkType + + + + + +
+ +
+
+ + TimerHeaderM2OWorkEntry + [props.projectId, props.taskId, props.workTypeId, props.projectWarning] + +
+ +
diff --git a/timesheet_grid_work_entry/views/timesheet_views.xml b/timesheet_grid_work_entry/views/timesheet_views.xml index 1c6c2175..8d10bd5d 100644 --- a/timesheet_grid_work_entry/views/timesheet_views.xml +++ b/timesheet_grid_work_entry/views/timesheet_views.xml @@ -14,4 +14,19 @@ + + account.analytic.line.grid.project.inherit + account.analytic.line + + + + + + + + + diff --git a/timesheet_grid_work_entry/wizard/__init__.py b/timesheet_grid_work_entry/wizard/__init__.py new file mode 100644 index 00000000..0f1e5dc2 --- /dev/null +++ b/timesheet_grid_work_entry/wizard/__init__.py @@ -0,0 +1,4 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import project_task_create_timesheet +from . import timesheet_merge_wizard diff --git a/timesheet_grid_work_entry/wizard/project_task_create_timesheet.py b/timesheet_grid_work_entry/wizard/project_task_create_timesheet.py new file mode 100644 index 00000000..111583d7 --- /dev/null +++ b/timesheet_grid_work_entry/wizard/project_task_create_timesheet.py @@ -0,0 +1,24 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import models, fields + + +class ProjectTaskCreateTimesheet(models.TransientModel): + _inherit = 'project.task.create.timesheet' + + work_type_id = fields.Many2one('hr.work.entry.type', string='Work Type', + default=lambda self: self.env.ref('hr_timesheet_work_entry.work_input_timesheet', + raise_if_not_found=False)) + + def save_timesheet(self): + """ + super() (timesheet_grid.wizard.project_task_create_timesheet) + is CLOSED to values modification (builds internally) + # It does however expose the created object, so at the cost of an + # additional write at flush we can just write here... + """ + timesheets = super().save_timesheet() + timesheets.write({ + 'work_type_id': self.work_type_id.id, + }) + return timesheets diff --git a/timesheet_grid_work_entry/wizard/project_task_create_timesheet_views.xml b/timesheet_grid_work_entry/wizard/project_task_create_timesheet_views.xml new file mode 100644 index 00000000..d8a68084 --- /dev/null +++ b/timesheet_grid_work_entry/wizard/project_task_create_timesheet_views.xml @@ -0,0 +1,17 @@ + + + + + project.task.create.timesheet.wizard.form.inherit + project.task.create.timesheet + + + + + + + + + diff --git a/timesheet_grid_work_entry/wizard/timesheet_merge_wizard.py b/timesheet_grid_work_entry/wizard/timesheet_merge_wizard.py new file mode 100644 index 00000000..70a091bb --- /dev/null +++ b/timesheet_grid_work_entry/wizard/timesheet_merge_wizard.py @@ -0,0 +1,61 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError + + +class MergeTimesheets(models.TransientModel): + _inherit = 'hr_timesheet.merge.wizard' + + work_type_id = fields.Many2one('hr.work.entry.type', string='Work Type') + + @api.constrains('timesheet_ids') + def _check_timesheet_ids(self): + for wizard in self: + if len(set(wizard.timesheet_ids.mapped('work_type_id'))) > 1: + raise ValidationError('The timesheets must have the same work type.') + super()._check_timesheet_ids() + + @api.model + def default_get(self, fields_list): + res = super(MergeTimesheets, self).default_get(fields_list) + + if 'timesheet_ids' in fields_list and res.get('timesheet_ids'): + timesheets = self.env['account.analytic.line'].browse(res.get('timesheet_ids')) + if timesheets and 'work_type_id' in fields_list: + res['work_type_id'] = timesheets.mapped('work_type_id.id')[0] + + return res + + def action_merge(self): + """ + super() (timesheet_grid.wizard.timesheet_merge_wizard.action_merge) is CLOSED + to values injection. It is also closed to post-create modification because it + returns a closed window instead of an action with the new timesheet's id (e.g. a redirect) + + Thus a direct inline patch... + """ + self.ensure_one() + + self.env['account.analytic.line'].create({ + 'name': self.name, + 'date': self.date, + 'unit_amount': self.unit_amount, + 'encoding_uom_id': self.encoding_uom_id.id, + 'project_id': self.project_id.id, + 'task_id': self.task_id.id, + 'employee_id': self.employee_id.id, + 'work_type_id': self.work_type_id.id, + }) + self.timesheet_ids.unlink() + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'message': _("The timesheet entries have successfully been merged."), + 'type': 'success', + 'sticky': False, + 'next': {'type': 'ir.actions.act_window_close'}, + } + } diff --git a/timesheet_grid_work_entry/wizard/timesheet_merge_wizard_views.xml b/timesheet_grid_work_entry/wizard/timesheet_merge_wizard_views.xml new file mode 100644 index 00000000..a9d175b7 --- /dev/null +++ b/timesheet_grid_work_entry/wizard/timesheet_merge_wizard_views.xml @@ -0,0 +1,17 @@ + + + + + hr_timesheet.merge.wizard.form.inherit + hr_timesheet.merge.wizard + + + + + + + + +