Skip to content
Snippets Groups Projects
Commit 11b14ff7 authored by Jérome Maes's avatar Jérome Maes
Browse files

[IMP] sale_timesheet: add timesheet overview

This commits add the timesheet overview on the project
kanban. Instead of redirecting to timesheet list, the user
will see a more detailled view.
The timesheet dashboard shows the amount per billable types,
the numbers of hours provided per employee and per billable
types, ...

This view is integrated with the search view : changing the
filter will shows you the informations for your selection.

The purpose is to give the user a global view of the project
(or other timesheet) financial situation in the same place.

This overview is made to be extended in enterprise to add
a section to compare past (timesheets) and futures (forecasts)
activities.
parent 7c3f774d
Branches
Tags
No related merge requests found
......@@ -2,3 +2,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
from . import controllers
......@@ -25,6 +25,7 @@ have real delivered quantities in sales orders.
'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',
......
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main
# -*- 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
......@@ -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"
......
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);
});
.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;
}
}
}
<?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>
......@@ -48,4 +48,23 @@
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>
<?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% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment