[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.
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import models
|
||||
from . import controllers
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import main
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user