summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJérome Maes <jem@openerp.com>2017-05-04 16:35:01 +0200
committerJérome Maes <jem@openerp.com>2017-05-04 16:35:01 +0200
commit34e4f995489b2a626d30aff7dd2aa6a600cee312 (patch)
tree20665a13bfacba4dcea44504ce84c6ba96df7109
parent4b074ca11c840d5df9a2bb9eaedd677f11f7b686 (diff)
parent11b14ff77c259230f16e4394313752ed07bad9f3 (diff)
[MERGE] sale_timesheet: revenue and overview
These commit are the 3rd part of improvements of Services flows. It introduces 2 important parts - timesheet revenue: how much the timesheet bring back money. - timesheet overview: a kind of dashboad to see the financial state of a project, grouped by employee or billable type Other improvements are UX, adding missing settings, hide some technical fields, ... to ease the configuration and day-to-day usage.
-rw-r--r--addons/hr_timesheet/views/hr_views.xml2
-rw-r--r--addons/project/views/res_config_views.xml5
-rw-r--r--addons/project_timesheet_holidays/views/hr_holidays_views.xml4
-rw-r--r--addons/sale/models/sale.py14
-rw-r--r--addons/sale_timesheet/__init__.py1
-rw-r--r--addons/sale_timesheet/__manifest__.py4
-rw-r--r--addons/sale_timesheet/controllers/__init__.py4
-rw-r--r--addons/sale_timesheet/controllers/main.py86
-rw-r--r--addons/sale_timesheet/models/__init__.py1
-rw-r--r--addons/sale_timesheet/models/account.py84
-rw-r--r--addons/sale_timesheet/models/account_invoice.py108
-rw-r--r--addons/sale_timesheet/models/project.py46
-rw-r--r--addons/sale_timesheet/static/src/js/timesheet_plan.js203
-rw-r--r--addons/sale_timesheet/static/src/less/sale_timesheet.less61
-rw-r--r--addons/sale_timesheet/views/account_invoice_views.xml28
-rw-r--r--addons/sale_timesheet/views/hr_timesheet_templates.xml143
-rw-r--r--addons/sale_timesheet/views/hr_timesheet_views.xml70
-rw-r--r--addons/sale_timesheet/views/product_views.xml30
-rw-r--r--addons/sale_timesheet/views/project_task_views.xml14
-rw-r--r--addons/sale_timesheet/views/res_config_views.xml64
20 files changed, 954 insertions, 18 deletions
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 @@
<field name="arch" type="xml">
<xpath expr="//group[@name='active_group']" position="before">
<!-- Empty invisible group to be filled by timesheet_grid and sale_timesheet -->
- <group string="Timesheets" name="timesheet" invisible="1">
+ <group string="Timesheets" name="timesheet" invisible="1" groups="hr_timesheet.group_timesheet_manager">
</group>
</xpath>
<xpath expr="//button[@name='toggle_active']" position="before">
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 @@
<div class="text-muted">
Sell services and invoice time spent
</div>
+ <div class="content-group" name="msg_module_sale_timesheet">
+ <div class="text-warning mt16" attrs="{'invisible': [('module_sale_timesheet', '=', False)]}">
+ Save this page and come back here to set up the feature.
+ </div>
+ </div>
</div>
</div>
<div class="col-xs-12 col-md-6 o_setting_box">
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 @@
<xpath expr="//group[@name='description']" position="after">
<group name="timesheet" string="Timesheet">
<field name="timesheet_generate"/>
- <field name="timesheet_project_id" attrs="{'invisible': [('timesheet_generate', '!=', True)], 'required': [('timesheet_generate', '=', True)]}" context="{'active_test': False}"/>
- <field name="timesheet_task_id" attrs="{'invisible': [('timesheet_generate', '!=', True)], 'required': [('timesheet_generate', '=', True)]}" context="{'active_test': False}"/>
+ <field name="timesheet_project_id" attrs="{'invisible': [('timesheet_generate', '!=', True)], 'required': [('timesheet_generate', '=', True)]}" context="{'active_test': False}" groups="base.group_no_one"/>
+ <field name="timesheet_task_id" attrs="{'invisible': [('timesheet_generate', '!=', True)], 'required': [('timesheet_generate', '=', True)]}" context="{'active_test': False}" groups="base.group_no_one"/>
</group>
</xpath>
</field>
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': _("""
+ <p class="oe_view_nocontent_create">
+ Click to record timesheets.
+ </p><p>
+ 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.
+ </p>
+ """),
+ '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': _("""
+ <p class="oe_view_nocontent_create">
+ Click to record timesheets.
+ </p><p>
+ 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.
+ </p>
+ """),
+ '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: $("<div>"),
+ 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($("<div>")).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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+
+ <record id="action_timesheet_from_invoice" model="ir.actions.act_window">
+ <field name="name">Timesheet</field>
+ <field name="type">ir.actions.act_window</field>
+ <field name="res_model">account.analytic.line</field>
+ <field name="view_type">form</field>
+ <field name="view_mode">tree,form,graph</field>
+ <field name="context">{'search_default_timesheet_invoice_id': active_id}</field>
+ </record>
+
+ <record id="account_invoice_view_form_inherit_sale_timesheet" model="ir.ui.view">
+ <field name="name">account.invoice.form.inherit.timesheet</field>
+ <field name="model">account.invoice</field>
+ <field name="inherit_id" ref="account.invoice_form"/>
+ <field name="arch" type="xml">
+ <xpath expr="//label" position="before">
+ <div class="oe_button_box" name="button_box" groups="base.group_user">
+ <button name="%(sale_timesheet.action_timesheet_from_invoice)d" type="action" class="oe_stat_button" icon="fa-calendar">
+ <field name="timesheet_count" widget="statinfo" string="Timesheets"/>
+ </button>
+ </div>
+ </xpath>
+ </field>
+ </record>
+
+</odoo>
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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+
+ <template id="assets_backend" name="sale timesheet assets" inherit_id="web.assets_backend">
+ <xpath expr="." position="inside">
+ <script type="text/javascript" src="/sale_timesheet/static/src/js/timesheet_plan.js"></script>
+ <link rel="stylesheet" href="/sale_timesheet/static/src/less/sale_timesheet.less"/>
+ </xpath>
+ </template>
+
+ <template id="timesheet_plan" name="Timesheet Plan">
+ <div class="o_form_view o_form_readonly o_project_plan">
+ <div class="o_form_sheet_bg">
+ <div class="o_form_sheet o_timesheet_plan_content">
+ <div class="o_timesheet_plan_sale_timesheet">
+ <div class="o_timesheet_plan_sale_timesheet_dashboard">
+
+ <h3>Overview</h3>
+ <table class="table">
+ <tbody>
+ <tr>
+ <th rowspan="4">
+ <a type="action" t-att-domain="json.dumps(domain)" context="{'pivot_row_group_by': ['date:month'],'pivot_column_group_by': ['timesheet_invoice_type'], 'measures': ['unit_amount']}">Hours</a>
+ </th>
+ <td class="o_timesheet_plan_dashboard_cell">
+ <t t-esc="dashboard['hours']['billable_time']"/> h
+ </td>
+ <td>Time and material</td>
+ <th rowspan="4">
+ <a type="action" t-att-domain="json.dumps(domain)" context="{'pivot_row_group_by': ['date:month', 'employee_id'], 'pivot_column_group_by': ['timesheet_invoice_type'],'pivot_measures': ['unit_amount']}">Rates</a>
+ </th>
+ <td class="o_timesheet_plan_dashboard_cell">
+ <t t-esc="dashboard['rates']['billable_time']"/> %
+ </td>
+ <td>Time and material</td>
+ <th rowspan="4">
+ <a type="action" t-att-domain="json.dumps(domain)" context="{'pivot_row_group_by': ['timesheet_invoice_id'], 'pivot_column_group_by': ['date:month'],'pivot_measures': ['amount', 'timesheet_revenue']}">Profitability</a>
+ </th>
+ <td class="o_timesheet_plan_dashboard_cell">
+ <t t-if="currency.position == 'before'" t-esc="currency.symbol"/>
+ <t t-esc="dashboard['money_amount']['invoiced']"/>
+ <t t-if="currency.position == 'after'" t-esc="currency.symbol"/>
+ </td>
+ <td>Invoiced</td>
+ </tr>
+ <tr>
+ <td class="o_timesheet_plan_dashboard_cell">
+ <t t-esc="dashboard['hours']['billable_fixed']"/> h
+ </td>
+ <td>Fixed</td>
+ <td class="o_timesheet_plan_dashboard_cell">
+ <t t-esc="dashboard['rates']['billable_fixed']"/> %
+ </td>
+ <td>Fixed</td>
+ <td class="o_timesheet_plan_dashboard_cell">
+ <t t-if="currency.position == 'before'" t-esc="currency.symbol"/>
+ <t t-esc="dashboard['money_amount']['to_invoice']"/>
+ <t t-if="currency.position == 'after'" t-esc="currency.symbol"/>
+ </td>
+ <td>To invoice</td>
+ </tr>
+ <tr>
+ <td class="o_timesheet_plan_dashboard_cell">
+ <t t-esc="dashboard['hours']['non_billable']"/> h
+ </td>
+ <td>Non Billable</td>
+ <td class="o_timesheet_plan_dashboard_cell">
+ <t t-esc="dashboard['rates']['non_billable']"/> %
+ </td>
+ <td>Non Billable</td>
+ <td class="o_timesheet_plan_dashboard_cell">
+ <t t-if="currency.position == 'before'" t-esc="currency.symbol"/>
+ <t t-esc="dashboard['money_amount']['cost']"/>
+ <t t-if="currency.position == 'after'" t-esc="currency.symbol"/>
+ </td>
+ <td>Cost</td>
+ </tr>
+ <tr>
+ <td class="o_timesheet_plan_dashboard_total">
+ <t t-esc="dashboard['hours']['total']"/> h
+ </td>
+ <td><b>Total</b></td>
+ <td class="o_timesheet_plan_dashboard_total">
+ <t t-esc="dashboard['rates']['total']"/> %
+ </td>
+ <td><b>Total</b></td>
+ <td class="o_timesheet_plan_dashboard_total">
+ <t t-if="currency.position == 'before'" t-esc="currency.symbol"/>
+ <t t-esc="dashboard['money_amount']['total']"/>
+ <t t-if="currency.position == 'after'" t-esc="currency.symbol"/>
+ </td>
+ <td><b>Total</b></td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+
+ <div class="o_timesheet_plan_sale_timesheet_people_time">
+ <h3>Time by people</h3>
+ <t t-if="not repartition_employee">
+ <p>There is no timesheet for now.</p>
+ </t>
+ <t t-if="repartition_employee">
+ <div class="pull-right o_timesheet_plan_badge">
+ <span class="badge o_progress_billable_fixed">Billable fixed</span>
+ <span class="badge o_progress_billable_time">Billable time</span>
+ <span class="badge o_progress_non_billable">Non billable</span>
+ </div>
+ <table class="table ">
+ <tbody>
+ <t t-foreach="repartition_employee" t-as="employee_id">
+ <tr>
+ <td style="width: 15%">
+ <a type="action" t-att-domain="json.dumps(domain)" t-att-context="{'search_default_employee_id': employee_id}" views="[[0, 'list']]">
+ <t t-esc="repartition_employee[employee_id]['employee_name']"/>
+ </a>
+ </td>
+ <td style="width: 10%">
+ <t t-esc="repartition_employee[employee_id]['total']"/> hours
+ </td>
+ <td>
+ <div class="progress" t-att-style="'width: ' + str(repartition_employee[employee_id]['total'] / repartition_employee_max * 100) +'%'">
+ <div t-if="repartition_employee[employee_id]['billable_fixed']" class="progress-bar o_progress_billable_fixed" t-att-style="'width: ' + str(repartition_employee[employee_id]['billable_fixed'] / repartition_employee[employee_id]['total'] * 100) + '%'" t-att-title="str(repartition_employee[employee_id]['billable_fixed']) + ' hours'">
+ </div>
+ <div t-if="repartition_employee[employee_id]['billable_time']" class="progress-bar o_progress_billable_time" t-att-style="'width: ' + str(repartition_employee[employee_id]['billable_time'] / repartition_employee[employee_id]['total'] * 100) + '%'" t-att-title="str(repartition_employee[employee_id]['billable_time']) + ' hours'">
+ </div>
+ <div t-if="repartition_employee[employee_id]['non_billable']" class="progress-bar o_progress_non_billable" t-att-style="'width: ' + str(repartition_employee[employee_id]['non_billable'] / repartition_employee[employee_id]['total'] * 100) + '%'" t-att-title="str(repartition_employee[employee_id]['non_billable']) + ' hours'">
+ </div>
+ </div>
+ </td>
+ </tr>
+ </t>
+ </tbody>
+ </table>
+ </t>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </template>
+
+</odoo>
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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+
+ <record id="timesheet_view_search" model="ir.ui.view">
+ <field name="name">account.analytic.line.search</field>
+ <field name="model">account.analytic.line</field>
+ <field name="inherit_id" ref="hr_timesheet.hr_timesheet_line_search"/>
+ <field name="arch" type="xml">
+ <xpath expr="//filter[@name='month']" position="after">
+ <field name="timesheet_invoice_type"/>
+ <field name="timesheet_invoice_id"/>
+ <separator/>
+ <filter name="billable_time" string="Billable Hours" domain="[('timesheet_invoice_type', '=', 'billable_time')]"/>
+ <filter name="billable_fixed" string="Fixed Price Projects" domain="[('timesheet_invoice_type', '=', 'billable_fixed')]"/>
+ <filter name="non_billable" string="Non Billable Hours" domain="[('timesheet_invoice_type', '=', 'non_billable')]"/>
+ </xpath>
+ </field>
+ </record>
+
+ <!--
+ Reporting
+ -->
+ <record id="timesheet_action_billing_report" model="ir.actions.act_window">
+ <field name="name">Billing Rate Analysis</field>
+ <field name="res_model">account.analytic.line</field>
+ <field name="view_mode">pivot,graph</field>
+ <field name="domain">[('project_id', '!=', False)]</field>
+ <field name="context">{"search_default_week":1}</field>
+ <field name="search_view_id" ref="hr_timesheet.hr_timesheet_line_search"/>
+ </record>
+
+ <record id="timesheet_filter_billing" model="ir.filters">
+ <field name="name">Billing Rate</field>
+ <field name="model_id">account.analytic.line</field>
+ <field name="user_id" eval="False"/>
+ <field name="domain">[('project_id', '!=', False)]</field>
+ <field name="is_default" eval="True"/>
+ <field name="context">{
+ 'col_group_by': ['date:month', 'timesheet_invoice_type'],
+ 'group_by': ['project_id', 'employee_id'],
+ 'measures': ['timesheet_revenue', 'amount_currency', 'unit_amount', '__count']
+ }</field>
+ <field name="action_id" ref="timesheet_action_billing_report"/>
+ </record>
+
+ <menuitem id="menu_timesheet_billing_analysis"
+ parent="hr_timesheet.menu_timesheets_reports"
+ action="timesheet_action_billing_report"
+ name="Billing Rate Analysis"/>
+
+ <!--
+ Plan
+ -->
+ <record id="timesheet_action_plan_pivot" model="ir.actions.act_window">
+ <field name="name">Timesheet</field>
+ <field name="res_model">account.analytic.line</field>
+ <field name="view_mode">pivot,tree,form</field>
+ <field name="domain">[('project_id', '!=', False)]</field>
+ <field name="search_view_id" ref="hr_timesheet.hr_timesheet_line_search"/>
+ </record>
+
+ <record id="timesheet_action_from_plan" model="ir.actions.act_window">
+ <field name="name">Timesheet</field>
+ <field name="res_model">account.analytic.line</field>
+ <field name="view_mode">tree,form</field>
+ <field name="domain">[('project_id', '!=', False)]</field>
+ <field name="search_view_id" ref="hr_timesheet.hr_timesheet_line_search"/>
+ </record>
+
+</odoo>
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 @@
</field>
</field>
</record>
+
+ <record id="product_template_search_view_sale_timesheet" model="ir.ui.view">
+ <field name="name">product.template.search.timesheet</field>
+ <field name="model">product.template</field>
+ <field name="inherit_id" ref="product.product_template_search_view"/>
+ <field name="arch" type="xml">
+ <xpath expr="//filter[@name='consumable']" position="after">
+ <separator/>
+ <filter string="Time-based services" name="product_time_based" domain="[('type', '=', 'service'), ('invoice_policy', '=', 'delivery')]"/>
+ <filter string="Fixed price services" name="product_service_fixed" domain="[('type', '=', 'service'), ('invoice_policy', '=', 'order')]"/>
+ </xpath>
+ </field>
+ </record>
+
+ <record id="product_template_action_time_based" model="ir.actions.act_window">
+ <field name="name">Products</field>
+ <field name="res_model">product.template</field>
+ <field name="view_type">form</field>
+ <field name="view_mode">tree,form</field>
+ <field name="context">{'search_default_product_time_based':1, 'default_type': 'service', 'default_invoice_policy': 'delivery'}</field>
+ </record>
+
+ <record id="product_template_action_fixed" model="ir.actions.act_window">
+ <field name="name">Products</field>
+ <field name="res_model">product.template</field>
+ <field name="view_type">form</field>
+ <field name="view_mode">tree,form</field>
+ <field name="context">{'search_default_product_service_fixed':1, 'default_type': 'service', 'default_invoice_policy': 'order'}</field>
+ </record>
+
</odoo>
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 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
+
+ <record id="project_project_view_kanban_inherit_sale_timesheet" model="ir.ui.view">
+ <field name="name">project.project.kanban.inherit.sale.timesheet</field>
+ <field name="model">project.project</field>
+ <field name="inherit_id" ref="hr_timesheet.view_project_kanban_inherited"/>
+ <field name="arch" type="xml">
+ <xpath expr="//a[@t-if='record.allow_timesheets.raw_value']" position="replace">
+ <a t-if="record.allow_timesheets.raw_value" name="action_view_timesheet" type="object">
+ <span class="o_label">Overview</span>
+ </a>
+ </xpath>
+ </field>
+ </record>
+
<record id="view_sale_service_inherit_form2" model="ir.ui.view">
<field name="name">sale.service.form.view.inherit</field>
<field name="model">project.task</field>
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 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<odoo>
+
+ <record id="hr_timesheet_config_settings_view_form" model="ir.ui.view">
+ <field name="name">hr.timesheet.config.settings.view.form</field>
+ <field name="model">hr.timesheet.config.settings</field>
+ <field name="inherit_id" ref="hr_timesheet.hr_timesheet_config_settings_view_form"/>
+ <field name="arch" type="xml">
+ <xpath expr="//div[@name='msg_module_sale_timesheet']" position="replace">
+ <div class="content-group" attrs="{'invisible': [('module_sale_timesheet', '=', False)]}">
+ <div class="mt16">
+ <div>
+ <button name="%(sale_timesheet.product_template_action_time_based)d" string="Setup your time-based services" type="action" class="btn-link" icon="fa-arrow-right"/>
+ </div>
+ <div>
+ <button name="%(sale_timesheet.product_template_action_fixed)d" string="Setup your fixed price services" type="action" class="btn-link" icon="fa-arrow-right"/>
+ </div>
+ </div>
+ </div>
+ </xpath>
+ </field>
+ </record>
+
+ <record id="sale_config_settings_view_form_inherit_sale_timesheet" model="ir.ui.view">
+ <field name="name">sale.config.settings.view.form</field>
+ <field name="model">sale.config.settings</field>
+ <field name="inherit_id" ref="sale.sale_config_settings_view_form_inherit_sale"/>
+ <field name="arch" type="xml">
+ <xpath expr="//div[@name='msg_module_sale_timesheet']" position="replace">
+ <div class="content-group" attrs="{'invisible': [('module_sale_timesheet', '=', False)]}">
+ <div class="mt16">
+ <div>
+ <button name="%(sale_timesheet.product_template_action_time_based)d" string="Setup your time-based services" type="action" class="btn-link" icon="fa-arrow-right"/>
+ </div>
+ <div>
+ <button name="%(sale_timesheet.product_template_action_fixed)d" string="Setup your fixed price services" type="action" class="btn-link" icon="fa-arrow-right"/>
+ </div>
+ </div>
+ </div>
+ </xpath>
+ </field>
+ </record>
+
+ <record id="project_config_settings_view_form" model="ir.ui.view">
+ <field name="name">project.config.settings.view.form</field>
+ <field name="model">project.config.settings</field>
+ <field name="inherit_id" ref="project.project_config_settings_view_form"/>
+ <field name="arch" type="xml">
+ <xpath expr="//div[@name='msg_module_sale_timesheet']" position="replace">
+ <div class="content-group" attrs="{'invisible': [('module_sale_timesheet', '=', False)]}">
+ <div class="mt16">
+ <div>
+ <button name="%(sale_timesheet.product_template_action_time_based)d" string="Setup your time-based services" type="action" class="btn-link" icon="fa-arrow-right"/>
+ </div>
+ <div>
+ <button name="%(sale_timesheet.product_template_action_fixed)d" string="Setup your fixed price services" type="action" class="btn-link" icon="fa-arrow-right"/>
+ </div>
+ </div>
+ </div>
+ </xpath>
+ </field>
+ </record>
+
+</odoo>