diff --git a/addons/hr_timesheet/views/hr_views.xml b/addons/hr_timesheet/views/hr_views.xml index 82403ffd01e..d92c9680c66 100644 --- a/addons/hr_timesheet/views/hr_views.xml +++ b/addons/hr_timesheet/views/hr_views.xml @@ -20,7 +20,7 @@ - + diff --git a/addons/project/views/res_config_views.xml b/addons/project/views/res_config_views.xml index de26444ad29..c56bf1bf247 100644 --- a/addons/project/views/res_config_views.xml +++ b/addons/project/views/res_config_views.xml @@ -113,6 +113,11 @@
Sell services and invoice time spent
+
+
+ Save this page and come back here to set up the feature. +
+
diff --git a/addons/project_timesheet_holidays/views/hr_holidays_views.xml b/addons/project_timesheet_holidays/views/hr_holidays_views.xml index ca1ae705bda..27af36b85f8 100644 --- a/addons/project_timesheet_holidays/views/hr_holidays_views.xml +++ b/addons/project_timesheet_holidays/views/hr_holidays_views.xml @@ -9,8 +9,8 @@ - - + + diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index 81ddeea08d8..06cdd3abec2 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -829,19 +829,19 @@ class SaleOrderLine(models.Model): @api.multi def invoice_line_create(self, invoice_id, qty): + """ Create an invoice line. The quantity to invoice can be positive (invoice) or negative (refund). + :param invoice_id: integer + :param qty: float quantity to invoice + :returns recordset of account.invoice.line created """ - Create an invoice line. The quantity to invoice can be positive (invoice) or negative - (refund). - - :param invoice_id: integer - :param qty: float quantity to invoice - """ + invoice_lines = self.env['account.invoice.line'] precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') for line in self: if not float_is_zero(qty, precision_digits=precision): vals = line._prepare_invoice_line(qty=qty) vals.update({'invoice_id': invoice_id, 'sale_line_ids': [(6, 0, [line.id])]}) - self.env['account.invoice.line'].create(vals) + invoice_lines |= self.env['account.invoice.line'].create(vals) + return invoice_lines @api.multi def _get_display_price(self, product): diff --git a/addons/sale_timesheet/__init__.py b/addons/sale_timesheet/__init__.py index dc5e6b693d1..8c67f893b37 100644 --- a/addons/sale_timesheet/__init__.py +++ b/addons/sale_timesheet/__init__.py @@ -2,3 +2,4 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import models +from . import controllers diff --git a/addons/sale_timesheet/__manifest__.py b/addons/sale_timesheet/__manifest__.py index 34cb04b6546..89e5f4eb9cb 100644 --- a/addons/sale_timesheet/__manifest__.py +++ b/addons/sale_timesheet/__manifest__.py @@ -17,11 +17,15 @@ have real delivered quantities in sales orders. 'depends': ['sale', 'hr_timesheet'], 'data': [ 'data/sale_timesheet_data.xml', + 'views/account_invoice_views.xml', 'views/sale_order_views.xml', 'views/product_views.xml', 'views/hr_views.xml', 'views/project_task_views.xml', 'views/procurement_views.xml', + 'views/hr_timesheet_views.xml', + 'views/res_config_views.xml', + 'views/hr_timesheet_templates.xml', ], 'demo': [ 'data/sale_service_demo.xml', diff --git a/addons/sale_timesheet/controllers/__init__.py b/addons/sale_timesheet/controllers/__init__.py new file mode 100644 index 00000000000..5d4b25db9c0 --- /dev/null +++ b/addons/sale_timesheet/controllers/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import main diff --git a/addons/sale_timesheet/controllers/main.py b/addons/sale_timesheet/controllers/main.py new file mode 100644 index 00000000000..7aff42ff99d --- /dev/null +++ b/addons/sale_timesheet/controllers/main.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import http +from odoo.http import request + +from odoo.tools import float_round + + +class SaleTimesheetController(http.Controller): + + @http.route('/timesheet/plan', type='json', auth="user") + def plan(self, domain): + values = self._prepare_plan_values(domain) + view = request.env['ir.model.data'].get_object('sale_timesheet', 'timesheet_plan') + return { + 'html_content': view.render(values) + } + + def _prepare_plan_values(self, domain): + values = { + 'currency': request.env.user.company_id.currency_id, + 'timesheet_lines': request.env['account.analytic.line'].search(domain), + 'domain': domain, + } + hour_rounding = request.env.ref('product.product_uom_hour').rounding + billable_types = ['non_billable', 'billable_time', 'billable_fixed'] + + # -- Dashboard (per billable type) + dashboard_values = { + 'hours': dict.fromkeys(billable_types + ['total'], 0.0), + 'rates': dict.fromkeys(billable_types + ['total'], 0.0), + 'money_amount': { + 'invoiced': 0.0, + 'to_invoiced': 0.0, + 'cost': 0.0, + 'total': 0.0, + } + } + dashboard_domain = domain + [('timesheet_invoice_type', '!=', False)] # force billable type + dashboard_data = request.env['account.analytic.line'].read_group(dashboard_domain, ['unit_amount', 'timesheet_revenue', 'timesheet_invoice_type'], ['timesheet_invoice_type']) + + dashboard_total_hours = sum([data['unit_amount'] for data in dashboard_data]) + for data in dashboard_data: + billable_type = data['timesheet_invoice_type'] + # hours + dashboard_values['hours'][billable_type] = float_round(data.get('unit_amount'), precision_rounding=hour_rounding) + dashboard_values['hours']['total'] += float_round(data.get('unit_amount'), precision_rounding=hour_rounding) + # rates + dashboard_values['rates'][billable_type] = float_round(data.get('unit_amount') / dashboard_total_hours * 100, precision_rounding=hour_rounding) + dashboard_values['rates']['total'] += float_round(data.get('unit_amount') / dashboard_total_hours * 100, precision_rounding=hour_rounding) + + # money_amount + dashboard_values['money_amount']['invoiced'] = sum(values['timesheet_lines'].filtered(lambda l: l.timesheet_invoice_id).mapped('timesheet_revenue')) + dashboard_values['money_amount']['to_invoice'] = sum(values['timesheet_lines'].filtered(lambda l: not l.timesheet_invoice_id).mapped('timesheet_revenue')) + dashboard_values['money_amount']['cost'] = sum(values['timesheet_lines'].mapped('amount')) + dashboard_values['money_amount']['total'] = sum([dashboard_values['money_amount'][item] for item in dashboard_values['money_amount'].keys()]) + + values['dashboard'] = dashboard_values + + # -- Time Repartition (per employee) + repartition_domain = domain + [('timesheet_invoice_type', '!=', False)] # force billable type + repartition_data = request.env['account.analytic.line'].read_group(repartition_domain, ['employee_id', 'timesheet_invoice_type', 'unit_amount'], ['employee_id', 'timesheet_invoice_type'], lazy=False) + + # set repartition per type per employee + repartition_employee = {} + for data in repartition_data: + employee_id = data['employee_id'][0] + repartition_employee.setdefault(employee_id, dict( + employee_id=data['employee_id'][0], + employee_name=data['employee_id'][1], + non_billable=0.0, + billable_time=0.0, + billable_fixed=0.0, + total=0.0 + ))[data['timesheet_invoice_type']] = float_round(data.get('unit_amount', 0.0), precision_rounding=hour_rounding) + + # compute total + for employee_id, vals in repartition_employee.items(): + repartition_employee[employee_id]['total'] = sum([vals[inv_type] for inv_type in billable_types]) + + hours_per_employee = [repartition_employee[employee_id]['total'] for employee_id in repartition_employee] + values['repartition_employee_max'] = max(hours_per_employee) if hours_per_employee else 1 + values['repartition_employee'] = repartition_employee + + return values diff --git a/addons/sale_timesheet/models/__init__.py b/addons/sale_timesheet/models/__init__.py index de19de2fbaf..17cb3f027f9 100644 --- a/addons/sale_timesheet/models/__init__.py +++ b/addons/sale_timesheet/models/__init__.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import account +from . import account_invoice from . import hr from . import procurement from . import product diff --git a/addons/sale_timesheet/models/account.py b/addons/sale_timesheet/models/account.py index 7f319ead699..f544091a1f9 100644 --- a/addons/sale_timesheet/models/account.py +++ b/addons/sale_timesheet/models/account.py @@ -1,28 +1,43 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, models +from odoo.exceptions import UserError +from odoo import api, fields, models, _ class AccountAnalyticLine(models.Model): _inherit = 'account.analytic.line' + timesheet_invoice_type = fields.Selection([ + ('billable_time', 'Billable Time'), + ('billable_fixed', 'Billable Fixed'), + ('non_billable', 'Non Billable')], string="Billable Type", readonly=True, copy=False) + timesheet_invoice_id = fields.Many2one('account.invoice', string="Invoice", readonly=True, copy=False, help="Invoice created from the timesheet") + timesheet_revenue = fields.Monetary("Revenue", default=0.0, readonly=True, currency_field='company_currency_id', copy=False) + @api.model def create(self, values): if values.get('task_id'): task = self.env['project.task'].browse(values['task_id']) values['so_line'] = task.sale_line_id.id or values.get('so_line', False) - values.update(self._get_timesheet_cost(values)) + values.update(self._get_timesheet_values(values)) + values.update(self._get_timesheet_billing_values(values)) return super(AccountAnalyticLine, self).create(values) @api.multi def write(self, values): + # prevent to update invoiced timesheets + if self.filtered(lambda timesheet: timesheet.timesheet_invoice_id): + if any([field_name in values for field_name in ['unit_amount', 'employee_id', 'task_id', 'timesheet_revenue', 'so_line', 'amount', 'date']]): + raise UserError(_('You can not modify already invoiced timesheets.')) + so_lines = self.mapped('so_line') if values.get('task_id'): task = self.env['project.task'].browse(values['task_id']) values['so_line'] = task.sale_line_id.id or values.get('so_line', False) for line in self: - values.update(line._get_timesheet_cost(values)) + values.update(line._get_timesheet_values(values)) + values.update(line._get_timesheet_billing_values(values)) super(AccountAnalyticLine, line).write(values) # Update delivered quantity on SO lines which are not linked to the analytic lines anymore @@ -31,7 +46,8 @@ class AccountAnalyticLine(models.Model): so_lines.with_context(force_so_lines=so_lines).sudo()._compute_analytic() return True - def _get_timesheet_cost(self, values): + def _get_timesheet_values(self, values): + result = {} values = values if values is not None else {} if values.get('project_id') or self.project_id: if values.get('amount'): @@ -55,12 +71,64 @@ class AccountAnalyticLine(models.Model): amount = -unit_amount * cost amount_converted = employee.currency_id.compute(amount, analytic_account.currency_id) - return { + result.update({ 'amount': amount_converted, 'product_uom_id': uom.id, 'account_id': account_id, - } - return {} + }) + return result + + def _get_timesheet_billing_values(self, values): + """ + If invoice on delivered quantity: + timesheet hours * (SO Line Price) * (1- discount), + elif invoice on ordered quantities & create task: + min ( + timesheet hours * (SO Line unit price) * (1- discount), + TOTAL SO - TOTAL INVOICED - sum(timesheet revenues with invoice_id=False) + ) + else: + 0 + """ + result = {} + if self.env.context.get('create'): # avoid bad loop + return result + + unit_amount = values.get('unit_amount', 0.0) or self.unit_amount + billable_type = 'non_billable' + revenue = 0.0 + + # set the revenue and billable type according to the product and the SO line + so_line_id = values.get('so_line') or self.so_line.id + so_line = self.env['sale.order.line'].browse(so_line_id) if so_line_id else self.env['sale.order.line'].browse() + if so_line.product_id.type == 'service': + # find the analytic account to convert revenue into its currency + account_id = values.get('account_id') or self.account_id.id + analytic_account = self.env['account.analytic.account'].browse(account_id) + # calculate the revenue on the timesheet + if so_line.product_id.invoice_policy == 'delivery': + delivered_revenue = unit_amount * so_line.price_unit * (1-so_line.discount) + revenue = so_line.currency_id.compute(delivered_revenue, analytic_account.currency_id) # amount from SO should be convert into analytic account currency + billable_type = 'billable_time' + elif so_line.product_id.invoice_policy == 'order' and so_line.product_id.track_service == 'task': + # compute the total revenue the SO since we are in fixed price + total_revenue_so = so_line.product_uom_qty * so_line.price_unit * (1-so_line.discount) + total_revenue_so = so_line.currency_id.compute(total_revenue_so, analytic_account.currency_id) + # compute the total revenue already existing (without the current timesheet line) + domain = [('so_line', '=', so_line.id)] + if self.ids: + domain += [('id', 'not in', self.ids)] + analytic_lines = self.sudo().search(domain) + total_revenue_invoiced = sum(analytic_lines.mapped('timesheet_revenue')) + # compute (new) revenue of current timesheet line + revenue = min( + so_line.currency_id.compute(unit_amount * (so_line.price_unit) * (1-so_line.discount), analytic_account.currency_id), + total_revenue_so - total_revenue_invoiced + ) + billable_type = 'billable_fixed' + result['timesheet_revenue'] = revenue + result['timesheet_invoice_type'] = billable_type + return result def _get_sale_order_line(self, vals=None): result = dict(vals or {}) @@ -81,6 +149,6 @@ class AccountAnalyticLine(models.Model): 'so_line': sol.id, 'product_id': sol.product_id.id, }) - result.update(self._get_timesheet_cost(result)) + result.update(self._get_timesheet_values(result)) return super(AccountAnalyticLine, self)._get_sale_order_line(vals=result) diff --git a/addons/sale_timesheet/models/account_invoice.py b/addons/sale_timesheet/models/account_invoice.py new file mode 100644 index 00000000000..2aabf31ac63 --- /dev/null +++ b/addons/sale_timesheet/models/account_invoice.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.tools.float_utils import float_round + + +class AccountInvoice(models.Model): + _inherit = "account.invoice" + + timesheet_ids = fields.One2many('account.analytic.line', 'timesheet_invoice_id', string='Timesheets', readonly=True, copy=False) + timesheet_count = fields.Integer("Number of timesheets", compute='_compute_timesheet_count') + + @api.multi + @api.depends('timesheet_ids') + def _compute_timesheet_count(self): + timesheet_data = self.env['account.analytic.line'].read_group([('timesheet_invoice_id', 'in', self.ids)], ['timesheet_invoice_id'], ['timesheet_invoice_id']) + mapped_data = dict([(t['timesheet_invoice_id'][0], t['timesheet_invoice_id_count']) for t in timesheet_data]) + for invoice in self: + invoice.timesheet_count = mapped_data.get(invoice.id, 0) + + def action_view_timesheet(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': _('Timesheets'), + 'domain': [('project_id', '!=', False)], + 'res_model': 'account.analytic.line', + 'view_id': False, + 'view_mode': 'tree,form', + 'view_type': 'form', + 'help': _(""" +

+ Click to record timesheets. +

+ You can register and track your workings hours by project every + day. Every time spent on a project will become a cost and can be re-invoiced to + customers if required. +

+ """), + 'limit': 80, + 'context': { + 'default_project_id': self.id, + 'search_default_project_id': [self.id] + } + } + + @api.multi + def invoice_validate(self): + result = super(AccountInvoice, self).invoice_validate() + + for invoice in self: + for invoice_line in invoice.invoice_line_ids.filtered(lambda line: line.product_id.type == 'service'): + uninvoiced_timesheet_lines = self.env['account.analytic.line'].sudo().search([ + ('so_line', 'in', invoice_line.sale_line_ids.ids), + ('project_id', '!=', False), + ('timesheet_invoice_id', '=', False), + ('timesheet_invoice_type', 'in', ['billable_time', 'billable_fixed']) + ]).with_context(create=True) # context key required to avoid loop + + # NOTE JEM : changing quantity (or unit price) of invoice line does not impact the revenue calculation. (FP specs) + if uninvoiced_timesheet_lines: + precision = invoice_line.currency_id.decimal_places + # delivered : update revenue with the prorata of number of hours on the timesheet line + if invoice_line.product_id.invoice_policy == 'delivery': + invoiced_price_per_hour = float_round(invoice_line.price_subtotal / float(sum(uninvoiced_timesheet_lines.mapped('unit_amount'))), precision) + # invoicing analytic lines of different currency + total_revenue_per_currency = dict.fromkeys(uninvoiced_timesheet_lines.mapped('company_currency_id').ids, 0.0) + for index, timesheet_line in enumerate(uninvoiced_timesheet_lines): + if index+1 != len(uninvoiced_timesheet_lines): + line_revenue = invoiced_price_per_hour * timesheet_line.unit_amount + total_revenue_per_currency[timesheet_line.company_currency_id.id] += line_revenue + else: # last line: add the difference to avoid rounding problem + total_revenue = sum([self.env['res.currency'].browse(currency_id).compute(amount, timesheet_line.company_currency_id) for currency_id, amount in total_revenue_per_currency.items()]) + line_revenue = invoice_line.price_subtotal - total_revenue + timesheet_line.write({ + 'timesheet_invoice_id': invoice.id, + 'timesheet_revenue': float_round(line_revenue, precision), + }) + + # ordered : update revenue with the prorata of theorical revenue + elif invoice_line.product_id.invoice_policy == 'order': + zero_timesheet_revenue = uninvoiced_timesheet_lines.filtered(lambda line: line.timesheet_revenue == 0.0) + no_zero_timesheet_revenue = uninvoiced_timesheet_lines.filtered(lambda line: line.timesheet_revenue != 0.0) + + # timesheet with zero theorical revenue keep the same revenue, but become invoiced (invoice_id set) + zero_timesheet_revenue.write({'timesheet_invoice_id': invoice.id}) + + # invoicing analytic lines of different currency + total_revenue_per_currency = dict.fromkeys(no_zero_timesheet_revenue.mapped('company_currency_id').ids, 0.0) + + for index, timesheet_line in enumerate(no_zero_timesheet_revenue): + if index+1 != len(no_zero_timesheet_revenue): + price_subtotal_inv = invoice_line.currency_id.compute(invoice_line.price_subtotal, timesheet_line.company_currency_id) + price_subtotal_sol = timesheet_line.so_line.currency_id.compute(timesheet_line.so_line.price_subtotal, timesheet_line.company_currency_id) + line_revenue = timesheet_line.timesheet_revenue * price_subtotal_inv / price_subtotal_sol + total_revenue_per_currency[timesheet_line.company_currency_id.id] += line_revenue + else: # last line: add the difference to avoid rounding problem + last_price_subtotal_inv = invoice_line.currency_id.compute(invoice_line.price_subtotal, timesheet_line.company_currency_id) + total_revenue = sum([self.env['res.currency'].browse(currency_id).compute(amount, timesheet_line.company_currency_id) for currency_id, amount in total_revenue_per_currency.items()]) + line_revenue = last_price_subtotal_inv - total_revenue + + timesheet_line.write({ + 'timesheet_invoice_id': invoice.id, + 'timesheet_revenue': float_round(line_revenue, precision), + }) + + return result diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index 40567c5fc5f..d8a15aec580 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -5,6 +5,52 @@ from odoo import api, fields, models, _ from odoo.exceptions import ValidationError +class Project(models.Model): + _inherit = 'project.project' + + @api.multi + def action_view_timesheet(self): + self.ensure_one() + if self.allow_timesheets: + return self.action_view_timesheet_plan() + return { + 'type': 'ir.actions.act_window', + 'name': _('Timesheets of %s') % self.name, + 'domain': [('project_id', '!=', False)], + 'res_model': 'account.analytic.line', + 'view_id': False, + 'view_mode': 'tree,form', + 'view_type': 'form', + 'help': _(""" +

+ Click to record timesheets. +

+ You can register and track your workings hours by project every + day. Every time spent on a project will become a cost and can be re-invoiced to + customers if required. +

+ """), + 'limit': 80, + 'context': { + 'default_project_id': self.id, + 'search_default_project_id': [self.id] + } + } + + @api.multi + def action_view_timesheet_plan(self): + return { + 'name': _('Overview of %s') % self.name, + 'type': 'ir.actions.client', + 'tag': 'timesheet.plan', + 'context': { + 'active_id': self.id, + 'active_ids': self.ids, + 'search_default_project_id': self.id, + } + } + + class ProjectTask(models.Model): _inherit = "project.task" diff --git a/addons/sale_timesheet/static/src/js/timesheet_plan.js b/addons/sale_timesheet/static/src/js/timesheet_plan.js new file mode 100644 index 00000000000..0ee353240c2 --- /dev/null +++ b/addons/sale_timesheet/static/src/js/timesheet_plan.js @@ -0,0 +1,203 @@ +odoo.define('project_timesheet.project_plan', function (require) { +'use strict'; + +var ajax = require('web.ajax'); +var ControlPanelMixin = require('web.ControlPanelMixin'); +var Context = require('web.Context'); +var core = require('web.core'); +var data = require('web.data'); +var pyeval = require('web.pyeval'); +var SearchView = require('web.SearchView'); +var session = require('web.session'); +var Widget = require('web.Widget'); + +var QWeb = core.qweb; +var _t = core._t; + +var PlanAction = Widget.extend(ControlPanelMixin, { + events: { + "click a[type='action']": "_onClickAction", + "click .o_timesheet_plan_redirect": '_onRedirect', + }, + init: function(parent, action, options) { + this._super.apply(this, arguments); + this.action = action; + this.action_manager = parent; + }, + willStart: function () { + var self = this; + var view_id = this.action && this.action.search_view_id && this.action.search_view_id[0]; + var def = this + .loadViews('account.analytic.line', new Context(this.action.context || {}), [[view_id, 'search']]) + .then(function (result) { + self.fields_view = result.search; + }); + return $.when(this._super(), def); + }, + start: function(){ + var self = this; + + // find default search from context + var search_defaults = {}; + var context = this.action.context || []; + _.each(context, function (value, key) { + var match = /^search_default_(.*)$/.exec(key); + if (match) { + search_defaults[match[1]] = value; + } + }); + + // create searchview + var options = { + $buttons: $("
"), + action: this.action, + disable_groupby: true, + search_defaults: search_defaults, + }; + + var dataset = new data.DataSetSearch(this, 'account.analytic.line'); + this.searchview = new SearchView(this, dataset, this.fields_view, options); + this.searchview.on('search', this, this._onSearch); + + var def1 = this._super.apply(this, arguments); + var def2 = this.searchview.appendTo($("
")).then(function () { + self.$searchview_buttons = self.searchview.$buttons.contents(); + }); + + return $.when(def1, def2).then(function(){ + self.searchview.do_search(); + self.update_cp(); + }); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + do_show: function () { + this._super.apply(this, arguments); + this.update_cp(); + this.action_manager.do_push_state({ + action: this.action.id, + active_id: this.action.context.active_id, + }); + }, + update_cp: function () { + this.update_control_panel({ + breadcrumbs: this.action_manager.get_breadcrumbs(), + cp_content: { + $buttons: this.$buttons, + $searchview: this.searchview.$el, + $searchview_buttons: this.$searchview_buttons, + }, + searchview: this.searchview, + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + /** + * Refresh the DOM html + * @param {string|html} + * @private + */ + _refreshPlan: function(dom){ + this.$el.html(dom); + }, + /** + * Call controller to get the html content + * @private + * @returns {Deferred} + */ + _fetchPlan: function(domain){ + var self = this; + return this._rpc({ + route:"/timesheet/plan", + params: {domain: domain}, + }).then(function(result){ + self._refreshPlan(result['html_content']); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Generate the action to execute based on the clicked target + * + * @param {MouseEvent} event + * @private + */ + _onClickAction: function(ev){ + var $target = this.$(ev.currentTarget); + + var action = false; + if($target.attr('name')){ // classical case : name="action_id" type="action" + action = $target.attr('name') + }else{ // custom case : build custom action dict + action = { + 'name': _t('Timesheet'), + 'type': 'ir.actions.act_window', + 'target': 'current', + 'res_model': 'account.analytic.line', + } + // find action views + var views = [[false, 'pivot'], [false, 'list']]; + if($target.attr('views')){ + views = JSON.parse($target.attr('views').replace(/\'/g, '"')); + } + action['views'] = views; + action['view_mode'] = _.map(views, function(view_array){return view_array[1];}); + // custom domain + var domain = []; + if($target.attr('domain')){ + domain = JSON.parse($target.attr('domain').replace(/\'/g, '"')); + } + action['domain'] = domain; + } + + // additionnal context + var context = { + active_id: this.action.context.active_id, + active_ids: this.action.context.active_ids, + active_model: this.action.context.active_model, + }; + + if($target.attr('context')){ + var ctx_str = $target.attr('context').replace(/\'/g, '"'); + context = _.extend(context, JSON.parse(ctx_str)); + } + + this.do_action(action, { + additional_context: context + }); + }, + _onRedirect: function (event) { + event.preventDefault(); + var $target = $(event.target); + this.do_action({ + type: 'ir.actions.act_window', + view_type: 'form', + view_mode: 'form', + res_model: $target.data('oe-model'), + views: [[false, 'form']], + res_id: $target.data('oe-id'), + }); + }, + _onSearch: function (search_event) { + var session = this.getSession(); + // group by are disabled, so we don't take care of them + var result = pyeval.eval_domains_and_contexts({ + domains: search_event.data.domains, + contexts: [session.user_context].concat(search_event.data.contexts) + }); + + this._fetchPlan(result.domain); + }, +}); + +core.action_registry.add('timesheet.plan', PlanAction); + +}); diff --git a/addons/sale_timesheet/static/src/less/sale_timesheet.less b/addons/sale_timesheet/static/src/less/sale_timesheet.less new file mode 100644 index 00000000000..b420a53a2a2 --- /dev/null +++ b/addons/sale_timesheet/static/src/less/sale_timesheet.less @@ -0,0 +1,61 @@ + +.o_timesheet_plan_sale_timesheet { + + [type='action'] { + color: @odoo-brand-optional; + cursor: pointer; + } + + .o_timesheet_plan_sale_timesheet_dashboard { + + .table { + margin-top: 15px; + } + .table > thead > tr > th, .table > thead > tr > td, + .table > tbody > tr > th, .table > tbody > tr > td { + border: none; + } + .table > tbody > tr > td.o_timesheet_plan_dashboard_cell { + text-align: right; + } + .table > tbody > tr > td.o_timesheet_plan_dashboard_total { + border-top: 1px solid; + text-align: right; + } + + } + + .o_timesheet_plan_sale_timesheet_people_time { + + .o_progress_billable_time { + background-color: #f0ad4e; + color: white; + } + .o_progress_billable_fixed { + background-color: #5bc0de; + color: white; + } + .o_progress_non_billable { + background-color: gray; + color: white; + } + + >.o_timesheet_plan_badge { + margin-bottom: 10px; + > .badge { + border: none; + } + } + + .table { + margin-top:15px; + } + .table > thead > tr > th, .table > thead > tr > td, + .table > tbody > tr > th, .table > tbody > tr > td { + border: none; + padding: 0px; + } + } + + +} diff --git a/addons/sale_timesheet/views/account_invoice_views.xml b/addons/sale_timesheet/views/account_invoice_views.xml new file mode 100644 index 00000000000..ab9ee8388f1 --- /dev/null +++ b/addons/sale_timesheet/views/account_invoice_views.xml @@ -0,0 +1,28 @@ + + + + + Timesheet + ir.actions.act_window + account.analytic.line + form + tree,form,graph + {'search_default_timesheet_invoice_id': active_id} + + + + account.invoice.form.inherit.timesheet + account.invoice + + + +
+ +
+
+
+
+ +
diff --git a/addons/sale_timesheet/views/hr_timesheet_templates.xml b/addons/sale_timesheet/views/hr_timesheet_templates.xml new file mode 100644 index 00000000000..22f82e1ed28 --- /dev/null +++ b/addons/sale_timesheet/views/hr_timesheet_templates.xml @@ -0,0 +1,143 @@ + + + + + + + + diff --git a/addons/sale_timesheet/views/hr_timesheet_views.xml b/addons/sale_timesheet/views/hr_timesheet_views.xml new file mode 100644 index 00000000000..70f3be7ac24 --- /dev/null +++ b/addons/sale_timesheet/views/hr_timesheet_views.xml @@ -0,0 +1,70 @@ + + + + + account.analytic.line.search + account.analytic.line + + + + + + + + + + + + + + + + Billing Rate Analysis + account.analytic.line + pivot,graph + [('project_id', '!=', False)] + {"search_default_week":1} + + + + + Billing Rate + account.analytic.line + + [('project_id', '!=', False)] + + { + 'col_group_by': ['date:month', 'timesheet_invoice_type'], + 'group_by': ['project_id', 'employee_id'], + 'measures': ['timesheet_revenue', 'amount_currency', 'unit_amount', '__count'] + } + + + + + + + + Timesheet + account.analytic.line + pivot,tree,form + [('project_id', '!=', False)] + + + + + Timesheet + account.analytic.line + tree,form + [('project_id', '!=', False)] + + + + diff --git a/addons/sale_timesheet/views/product_views.xml b/addons/sale_timesheet/views/product_views.xml index 9e054c83f92..0c9903233a3 100644 --- a/addons/sale_timesheet/views/product_views.xml +++ b/addons/sale_timesheet/views/product_views.xml @@ -16,4 +16,34 @@ + + + product.template.search.timesheet + product.template + + + + + + + + + + + + Products + product.template + form + tree,form + {'search_default_product_time_based':1, 'default_type': 'service', 'default_invoice_policy': 'delivery'} + + + + Products + product.template + form + tree,form + {'search_default_product_service_fixed':1, 'default_type': 'service', 'default_invoice_policy': 'order'} + + diff --git a/addons/sale_timesheet/views/project_task_views.xml b/addons/sale_timesheet/views/project_task_views.xml index c68e03991a4..b8b02461c44 100644 --- a/addons/sale_timesheet/views/project_task_views.xml +++ b/addons/sale_timesheet/views/project_task_views.xml @@ -1,5 +1,19 @@ + + + project.project.kanban.inherit.sale.timesheet + project.project + + + + + Overview + + + + + sale.service.form.view.inherit project.task diff --git a/addons/sale_timesheet/views/res_config_views.xml b/addons/sale_timesheet/views/res_config_views.xml new file mode 100644 index 00000000000..1be92384b48 --- /dev/null +++ b/addons/sale_timesheet/views/res_config_views.xml @@ -0,0 +1,64 @@ + + + + + hr.timesheet.config.settings.view.form + hr.timesheet.config.settings + + + +
+
+
+
+
+
+
+
+
+
+
+ + + sale.config.settings.view.form + sale.config.settings + + + +
+
+
+
+
+
+
+
+
+
+
+ + + project.config.settings.view.form + project.config.settings + + + +
+
+
+
+
+
+
+
+
+
+
+ +