[IMP] hr_timesheet: Improve the usability of timesheet portal
1. Generic timesheets improvements
- Portal /my/timesheets
- Add a group bys task, date, employee
- Add search in project, name, employee, task
* The task column should not be visible if none of the timesheets have the Task field set
- If a group by task, date, employee or project is applied, the corresponding column should not be visible
2. Timesheets from sub-tasks on parent tasks
- Portal /my/tasks
- Display the 'Total Hours' spent on a task at the end of the list of timesheets
- Below the list of timesheets for the task, display another table detailing the respective timesheets (and total hours) of each sub-task linked to this task (= 1 table/list per sub-task)
* Title should be: 'Timesheets for sub-task: xyz'
* only visible if there are sub-tasks linked to this task
* only visible if total hours > 0
3. Timesheets on Invoices
- Portal /my/invoices
- Add Project + task fields
* Task field only visible if set on one of the timesheet entries
* Project field only visible if there are at least 2 different projects in these timesheet entries
4. Correct visibility for the portal users
- Portal users is allowed to see timesheets if the users are added in allowed portal users list in the project
- When a portal user is added as the message follower of the project, the user is added to allowed portal users as well
- When a portal user is removed from allowed portal users, the user is also removed from message followers
closes odoo/odoo#47248
Taskid: 2201908
Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
This commit is contained in:
@@ -4,11 +4,12 @@
|
||||
from collections import OrderedDict
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from operator import itemgetter
|
||||
from datetime import datetime
|
||||
|
||||
from odoo import fields, http, _
|
||||
from odoo.http import request
|
||||
from odoo.tools import date_utils, groupby as groupbyelem
|
||||
from odoo.osv.expression import AND
|
||||
from odoo.osv.expression import AND, OR
|
||||
|
||||
from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager
|
||||
|
||||
@@ -22,23 +23,30 @@ class TimesheetCustomerPortal(CustomerPortal):
|
||||
return values
|
||||
|
||||
@http.route(['/my/timesheets', '/my/timesheets/page/<int:page>'], type='http', auth="user", website=True)
|
||||
def portal_my_timesheets(self, page=1, sortby=None, filterby=None, search=None, search_in='all', groupby='project', **kw):
|
||||
def portal_my_timesheets(self, page=1, sortby=None, filterby=None, search=None, search_in='all', groupby='none', **kw):
|
||||
Timesheet_sudo = request.env['account.analytic.line'].sudo()
|
||||
values = self._prepare_portal_layout_values()
|
||||
domain = request.env['account.analytic.line']._timesheet_get_portal_domain()
|
||||
|
||||
searchbar_sortings = {
|
||||
'date': {'label': _('Newest'), 'order': 'date desc'},
|
||||
'name': {'label': _('Name'), 'order': 'name'},
|
||||
'name': {'label': _('Description'), 'order': 'name'},
|
||||
}
|
||||
|
||||
searchbar_inputs = {
|
||||
'all': {'input': 'all', 'label': _('Search in All')},
|
||||
'project': {'input': 'project', 'label': _('Search in Project')},
|
||||
'name': {'input': 'name', 'label': _('Search in Description')},
|
||||
'employee': {'input': 'employee', 'label': _('Search in Employee')},
|
||||
'task': {'input': 'task', 'label': _('Search in Task')},
|
||||
}
|
||||
|
||||
searchbar_groupby = {
|
||||
'none': {'input': 'none', 'label': _('None')},
|
||||
'project': {'input': 'project', 'label': _('Project')},
|
||||
'task': {'input': 'task', 'label': _('Task')},
|
||||
'date': {'input': 'date', 'label': _('Date')},
|
||||
'employee': {'input': 'employee', 'label': _('Employee')},
|
||||
}
|
||||
|
||||
today = fields.Date.today()
|
||||
@@ -68,7 +76,16 @@ class TimesheetCustomerPortal(CustomerPortal):
|
||||
domain = AND([domain, searchbar_filters[filterby]['domain']])
|
||||
|
||||
if search and search_in:
|
||||
domain = AND([domain, [('name', 'ilike', search)]])
|
||||
search_domain = []
|
||||
if search_in in ('project', 'all'):
|
||||
search_domain = OR([search_domain, [('project_id', 'ilike', search)]])
|
||||
if search_in in ('name', 'all'):
|
||||
search_domain = OR([search_domain, [('name', 'ilike', search)]])
|
||||
if search_in in ('employee', 'all'):
|
||||
search_domain = OR([search_domain, [('employee_id', 'ilike', search)]])
|
||||
if search_in in ('task', 'all'):
|
||||
search_domain = OR([search_domain, [('task_id', 'ilike', search)]])
|
||||
domain += search_domain
|
||||
|
||||
timesheet_count = Timesheet_sudo.search_count(domain)
|
||||
# pager
|
||||
@@ -80,13 +97,34 @@ class TimesheetCustomerPortal(CustomerPortal):
|
||||
step=self._items_per_page
|
||||
)
|
||||
|
||||
if groupby == 'project':
|
||||
order = "project_id, %s" % order
|
||||
timesheets = Timesheet_sudo.search(domain, order=order, limit=self._items_per_page, offset=pager['offset'])
|
||||
if groupby == 'project':
|
||||
grouped_timesheets = [Timesheet_sudo.concat(*g) for k, g in groupbyelem(timesheets, itemgetter('project_id'))]
|
||||
else:
|
||||
grouped_timesheets = [timesheets]
|
||||
def get_timesheets():
|
||||
groupby_mapping = {
|
||||
'project': 'project_id',
|
||||
'task': 'task_id',
|
||||
'employee': 'employee_id',
|
||||
'date': 'date',
|
||||
}
|
||||
field = groupby_mapping.get(groupby, None)
|
||||
orderby = '%s, %s' % (field, order) if field else order
|
||||
timesheets = Timesheet_sudo.search(domain, order=orderby, limit=self._items_per_page, offset=pager['offset'])
|
||||
if field:
|
||||
if groupby == 'date':
|
||||
time_data = Timesheet_sudo.read_group(domain, ['date', 'unit_amount:sum'], ['date:day'])
|
||||
mapped_time = dict([(datetime.strptime(m['date:day'], '%d %b %Y').date(), m['unit_amount']) for m in time_data])
|
||||
grouped_timesheets = [(Timesheet_sudo.concat(*g), mapped_time[k]) for k, g in groupbyelem(timesheets, itemgetter('date'))]
|
||||
else:
|
||||
time_data = time_data = Timesheet_sudo.read_group(domain, [field, 'unit_amount:sum'], [field])
|
||||
mapped_time = dict([(m[field][0], m['unit_amount']) for m in time_data])
|
||||
grouped_timesheets = [(Timesheet_sudo.concat(*g), mapped_time[k.id]) for k, g in groupbyelem(timesheets, itemgetter(field))]
|
||||
return timesheets, grouped_timesheets
|
||||
|
||||
grouped_timesheets = [(
|
||||
timesheets,
|
||||
sum(Timesheet_sudo.search(domain).mapped('unit_amount'))
|
||||
)] if timesheets else []
|
||||
return timesheets, grouped_timesheets
|
||||
|
||||
timesheets, grouped_timesheets = get_timesheets()
|
||||
|
||||
values.update({
|
||||
'timesheets': timesheets,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from collections import defaultdict
|
||||
from odoo.http import request
|
||||
from odoo.osv import expression
|
||||
|
||||
@@ -12,7 +13,13 @@ class ProjectCustomerPortal(CustomerPortal):
|
||||
def _task_get_page_view_values(self, task, access_token, **kwargs):
|
||||
values = super(ProjectCustomerPortal, self)._task_get_page_view_values(task, access_token, **kwargs)
|
||||
domain = request.env['account.analytic.line']._timesheet_get_portal_domain()
|
||||
domain = expression.AND([domain, [('task_id', '=', task.id)]])
|
||||
timesheets = request.env['account.analytic.line'].sudo().search(domain)
|
||||
task_domain = expression.AND([domain, [('task_id', '=', task.id)]])
|
||||
subtask_domain = expression.AND([domain, [('task_id', 'in', task.child_ids.ids)]])
|
||||
timesheets = request.env['account.analytic.line'].sudo().search(task_domain)
|
||||
subtasks_timesheets = request.env['account.analytic.line'].sudo().search(subtask_domain)
|
||||
timesheets_by_subtask = defaultdict(lambda: request.env['account.analytic.line'].sudo())
|
||||
for timesheet in subtasks_timesheets:
|
||||
timesheets_by_subtask[timesheet.task_id] |= timesheet
|
||||
values['timesheets'] = timesheets
|
||||
values['timesheets_by_subtask'] = timesheets_by_subtask
|
||||
return values
|
||||
|
||||
@@ -126,12 +126,12 @@ class AccountAnalyticLine(models.Model):
|
||||
return etree.tostring(doc, encoding='unicode')
|
||||
|
||||
def _timesheet_get_portal_domain(self):
|
||||
return ['|', '&',
|
||||
('task_id.project_id.privacy_visibility', '=', 'portal'),
|
||||
('task_id.project_id.message_partner_ids', 'child_of', [self.env.user.partner_id.commercial_partner_id.id]),
|
||||
'&',
|
||||
('task_id.project_id.privacy_visibility', '=', 'portal'),
|
||||
('task_id.message_partner_ids', 'child_of', [self.env.user.partner_id.commercial_partner_id.id])]
|
||||
return ['&',
|
||||
'|', '|',
|
||||
('task_id.project_id.message_partner_ids', 'child_of', [self.env.user.partner_id.commercial_partner_id.id]),
|
||||
('task_id.message_partner_ids', 'child_of', [self.env.user.partner_id.commercial_partner_id.id]),
|
||||
('task_id.project_id.allowed_portal_user_ids', 'child_of', [self.env.user.id]),
|
||||
('task_id.project_id.privacy_visibility', '=', 'portal')]
|
||||
|
||||
def _timesheet_preprocess(self, vals):
|
||||
""" Deduce other field values from the one given.
|
||||
|
||||
@@ -33,24 +33,45 @@
|
||||
</t>
|
||||
<t t-if="grouped_timesheets">
|
||||
<t t-call="portal.portal_table">
|
||||
<t t-foreach="grouped_timesheets" t-as="timesheets">
|
||||
<t t-foreach="grouped_timesheets" t-as="timesheets_with_hours">
|
||||
<t t-set="timesheets" t-value="timesheets_with_hours[0]"/>
|
||||
<t t-set="hours_spent" t-value="timesheets_with_hours[1]"/>
|
||||
<thead>
|
||||
<tr t-attf-class="{{'thead-light' if not groupby == 'none' else ''}}">
|
||||
<th t-if="groupby == 'none'">Description</th>
|
||||
<th t-else="">
|
||||
<tr t-if="not groupby =='none'" t-attf-class="{{'thead-light'}}">
|
||||
<th t-if="groupby == 'project'" colspan="5">
|
||||
<em class="font-weight-normal text-muted">Timesheets for project:</em>
|
||||
<span t-field="timesheets[0].project_id.name"/></th>
|
||||
<th>Date</th>
|
||||
<th>Employee</th>
|
||||
<th class="text-right">Duration</th>
|
||||
<span t-field="timesheets[0].project_id.name"/>
|
||||
</th>
|
||||
<th t-elif="groupby == 'task'" colspan="5">
|
||||
<em class="font-weight-normal text-muted">Timesheets for task:</em>
|
||||
<span t-field="timesheets[0].task_id.name"/>
|
||||
</th>
|
||||
<th t-elif="groupby == 'date'" colspan="5">
|
||||
<em class="font-weight-normal text-muted">Timesheets on </em>
|
||||
<span t-field="timesheets[0].date"/>
|
||||
</th>
|
||||
<th t-elif="groupby == 'employee'" colspan="5">
|
||||
<em class="font-weight-normal text-muted">Timesheets for employee:</em>
|
||||
<span t-field="timesheets[0].employee_id.name"/>
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th t-if="not groupby == 'date'">Date</th>
|
||||
<th t-if="not groupby == 'employee'">Employee</th>
|
||||
<th t-if="not groupby == 'project'">Project</th>
|
||||
<th t-if="not groupby == 'task'">Task</th>
|
||||
<th>Description</th>
|
||||
<th class="text-right">Hours Spent <small class="text-muted">(Total: <span t-esc="hours_spent" t-options='{"widget": "float_time"}'/>)</small></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="timesheets" t-as="timesheet">
|
||||
<tr>
|
||||
<td t-if="not groupby == 'date'"><span t-field="timesheet.date" t-options='{"widget": "date"}'/></td>
|
||||
<td t-if="not groupby == 'employee'"><span t-field="timesheet.employee_id"/></td>
|
||||
<td t-if="not groupby == 'project'"><span t-field="timesheet.project_id"/></td>
|
||||
<td t-if="not groupby == 'task'"><span t-field="timesheet.task_id"/></td>
|
||||
<td><span t-esc="timesheet.name"/></td>
|
||||
<td><span t-field="timesheet.date" t-options='{"widget": "date"}'/></td>
|
||||
<td><span t-field="timesheet.employee_id"/></td>
|
||||
<td class="text-right"><span t-field="timesheet.unit_amount" t-options='{"widget": "float_time"}'/></td>
|
||||
</tr>
|
||||
</t>
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<h5 class="mt-2 mb-2">Timesheets</h5>
|
||||
<t t-call="hr_timesheet.portal_timesheet_table"/>
|
||||
</div>
|
||||
<t t-if="timesheets_by_subtask">
|
||||
<t t-call="hr_timesheet.portal_subtask_timesheet_tables"/>
|
||||
</t>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
@@ -16,18 +19,58 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Description</th>
|
||||
<th>Employee</th>
|
||||
<th>Description</th>
|
||||
<th class="text-right">Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr t-foreach="timesheets" t-as="timesheet">
|
||||
<td><t t-esc="timesheet.date" t-options='{"widget": "date"}'/></td>
|
||||
<td><t t-esc="timesheet.name"/></td>
|
||||
<td><t t-esc="timesheet.employee_id.name"/></td>
|
||||
<td><t t-esc="timesheet.name"/></td>
|
||||
<td class="text-right"><span t-field="timesheet.unit_amount" t-options='{"widget": "float_time"}'/></td>
|
||||
</tr>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th colspan="3"></th>
|
||||
<th class="text-right">Total Hours: <span t-esc="round(sum(timesheets.mapped('unit_amount')), 2)" t-options='{"widget": "float_time"}'/></th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<template id="portal_subtask_timesheet_tables" name="Portal Subtask Timesheet Tables">
|
||||
<t t-foreach="timesheets_by_subtask.values()" t-as="timesheets">
|
||||
<t t-set="total_hours" t-value="round(sum(timesheets.mapped('unit_amount')), 2)"/>
|
||||
<t t-if="total_hours > 0">
|
||||
<div class="container">
|
||||
<hr class="mt-4 mb-1"/>
|
||||
<h5 class="mt-2 mb-2">Timesheets for sub-task: <t t-esc="timesheets[0].task_id.name" /></h5>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Employee</th>
|
||||
<th>Description</th>
|
||||
<th class="text-right">Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr t-foreach="timesheets" t-as="timesheet">
|
||||
<td><t t-esc="timesheet.date" t-options='{"widget": "date"}'/></td>
|
||||
<td><t t-esc="timesheet.employee_id.name"/></td>
|
||||
<td><t t-esc="timesheet.name"/></td>
|
||||
<td class="text-right"><span t-field="timesheet.unit_amount" t-options='{"widget": "float_time"}'/></td>
|
||||
</tr>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th class="text-right" colspan="3"></th>
|
||||
<th class="text-right">Total Hours: <span t-esc="total_hours" t-options='{"widget": "float_time"}'/></th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -9,12 +9,14 @@ BasicView.include({
|
||||
init: function () {
|
||||
this._super.apply(this, arguments);
|
||||
const post_refresh = this._getFieldOption('message_ids', 'post_refresh', false);
|
||||
const followers_post_refresh = this._getFieldOption('message_follower_ids', 'post_refresh', false);
|
||||
this.chatterFields = {
|
||||
hasActivityIds: this._hasField('activity_ids'),
|
||||
hasMessageFollowerIds: this._hasField('message_follower_ids'),
|
||||
hasMessageIds: this._hasField('message_ids'),
|
||||
hasRecordReloadOnAttachmentsChanged: post_refresh === 'always',
|
||||
hasRecordReloadOnMessagePosted: !!post_refresh,
|
||||
hasRecordReloadOnFollowersUpdate: !!followers_post_refresh,
|
||||
isActivityBoxVisible: this._getFieldOption('message_ids', 'open_attachments', false),
|
||||
};
|
||||
const fieldsInfo = this.fieldsInfo[this.viewType];
|
||||
|
||||
@@ -1045,6 +1045,7 @@ function factory(dependencies) {
|
||||
* @param {boolean} [param0.mail_invite_follower_channel_only=false]
|
||||
*/
|
||||
_promptAddFollower({ mail_invite_follower_channel_only = false } = {}) {
|
||||
const self = this;
|
||||
const action = {
|
||||
type: 'ir.actions.act_window',
|
||||
res_model: 'mail.wizard.invite',
|
||||
@@ -1061,7 +1062,10 @@ function factory(dependencies) {
|
||||
this.env.bus.trigger('do-action', {
|
||||
action,
|
||||
options: {
|
||||
on_close: () => this.refreshFollowers(),
|
||||
on_close: async () => {
|
||||
await this.async(() => this.refreshFollowers());
|
||||
this.env.bus.trigger('mail.thread:promptAddFollower-closed');
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ FormRenderer.include({
|
||||
this.off('o_attachments_changed', this);
|
||||
this.off('o_chatter_rendered', this);
|
||||
this.off('o_message_posted', this);
|
||||
owl.Component.env.bus.off('mail.thread:promptAddFollower-closed', this);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
@@ -75,6 +76,9 @@ FormRenderer.include({
|
||||
if (this.chatterFields.hasRecordReloadOnAttachmentsChanged) {
|
||||
this.on('o_attachments_changed', this, ev => this.trigger_up('reload'));
|
||||
}
|
||||
if (this.chatterFields.hasRecordReloadOnFollowersUpdate) {
|
||||
owl.Component.env.bus.on('mail.thread:promptAddFollower-closed', this, ev => this.trigger_up('reload'));
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
|
||||
@@ -341,6 +341,8 @@ class Project(models.Model):
|
||||
if allowed_users_changed:
|
||||
for project in self:
|
||||
permission_removed = allowed_users.get(project) - project.allowed_user_ids
|
||||
allowed_portal_users_removed = permission_removed.filtered('share')
|
||||
project.message_unsubscribe(allowed_portal_users_removed.partner_id.commercial_partner_id.ids)
|
||||
for task in project.task_ids:
|
||||
task.allowed_user_ids -= permission_removed
|
||||
|
||||
@@ -384,13 +386,18 @@ class Project(models.Model):
|
||||
return result
|
||||
|
||||
def message_subscribe(self, partner_ids=None, channel_ids=None, subtype_ids=None):
|
||||
""" Subscribe to all existing active tasks when subscribing to a project """
|
||||
"""
|
||||
Subscribe to all existing active tasks when subscribing to a project
|
||||
And add the portal user subscribed to allowed portal users
|
||||
"""
|
||||
res = super(Project, self).message_subscribe(partner_ids=partner_ids, channel_ids=channel_ids, subtype_ids=subtype_ids)
|
||||
project_subtypes = self.env['mail.message.subtype'].browse(subtype_ids) if subtype_ids else None
|
||||
task_subtypes = project_subtypes.mapped('parent_id').ids if project_subtypes else None
|
||||
if not subtype_ids or task_subtypes:
|
||||
self.mapped('tasks').message_subscribe(
|
||||
partner_ids=partner_ids, channel_ids=channel_ids, subtype_ids=task_subtypes)
|
||||
if partner_ids:
|
||||
self.allowed_portal_user_ids |= self.env['res.partner'].browse(partner_ids).user_ids.filtered('share')
|
||||
return res
|
||||
|
||||
def message_unsubscribe(self, partner_ids=None, channel_ids=None):
|
||||
@@ -780,6 +787,17 @@ class Task(models.Model):
|
||||
)
|
||||
return super(Task, self).get_empty_list_help(help)
|
||||
|
||||
def message_subscribe(self, partner_ids=None, channel_ids=None, subtype_ids=None):
|
||||
"""
|
||||
Add the users subscribed to allowed portal users
|
||||
"""
|
||||
res = super(Task, self).message_subscribe(partner_ids=partner_ids, channel_ids=channel_ids, subtype_ids=subtype_ids)
|
||||
if partner_ids:
|
||||
new_allowed_users = self.env['res.partner'].browse(partner_ids).user_ids.filtered('share')
|
||||
tasks = self.filtered(lambda task: task.project_id.privacy_visibility == 'portal')
|
||||
tasks.write({'allowed_user_ids': [(4, user.id) for user in new_allowed_users]})
|
||||
return res
|
||||
|
||||
# ----------------------------------------
|
||||
# Case management
|
||||
# ----------------------------------------
|
||||
|
||||
@@ -357,7 +357,7 @@
|
||||
</page>
|
||||
</notebook>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" help="Follow this project to automatically track the events associated to tasks and issues of this project." groups="base.group_user"/>
|
||||
<field name="message_follower_ids" options="{'post_refresh':True}" help="Follow this project to automatically track the events associated to tasks and issues of this project." groups="base.group_user"/>
|
||||
</div>
|
||||
</sheet>
|
||||
</form>
|
||||
@@ -695,7 +695,7 @@
|
||||
</notebook>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" groups="base.group_user"/>
|
||||
<field name="message_follower_ids" options="{'post_refresh':True}" groups="base.group_user"/>
|
||||
<field name="activity_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
|
||||
@@ -66,6 +66,7 @@ class SaleOrder(models.Model):
|
||||
if len(task_projects) == 1 and len(self.tasks_ids) > 1: # redirect to task of the project (with kanban stage, ...)
|
||||
action = self.with_context(active_id=task_projects.id).env.ref(
|
||||
'project.act_project_project_2_project_task_all').read()[0]
|
||||
action['domain'] = [('project_id', '=', task_projects.id)]
|
||||
if action.get('context'):
|
||||
eval_context = self.env['ir.actions.actions']._get_eval_context()
|
||||
eval_context.update({'active_id': task_projects.id})
|
||||
|
||||
@@ -26,7 +26,28 @@
|
||||
</h5>
|
||||
</div>
|
||||
<div id="collapseTimesheet" class="card-body show" data-parent="#accordion">
|
||||
<t t-call="hr_timesheet.portal_timesheet_table"/>
|
||||
<t t-set="nr_tasks" t-value="len(timesheets.mapped('task_id'))"/>
|
||||
<t t-set="nr_projects" t-value="len(timesheets.mapped('project_id'))"/>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Employee</th>
|
||||
<th t-if="nr_projects > 1">Project</th>
|
||||
<th t-if="nr_tasks > 0">Task</th>
|
||||
<th>Description</th>
|
||||
<th class="text-right">Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr t-foreach="timesheets" t-as="timesheet">
|
||||
<td><t t-esc="timesheet.date" t-options='{"widget": "date"}'/></td>
|
||||
<td><t t-esc="timesheet.employee_id.name"/></td>
|
||||
<td t-if="nr_projects > 1"><span t-field="timesheet.project_id"/></td>
|
||||
<td t-if="nr_tasks > 0"><span t-field="timesheet.task_id"/></td>
|
||||
<td><t t-esc="timesheet.name"/></td>
|
||||
<td class="text-right"><span t-field="timesheet.unit_amount" t-options='{"widget": "float_time"}'/></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user