PMS Updates
This commit is contained in:
parent
4ce02e58fa
commit
0fa84c6d43
|
|
@ -24,6 +24,7 @@ Key Features:
|
||||||
'hr_timesheet',
|
'hr_timesheet',
|
||||||
'base',
|
'base',
|
||||||
'analytic',
|
'analytic',
|
||||||
|
'project_gantt',
|
||||||
],
|
],
|
||||||
'data': [
|
'data': [
|
||||||
'security/security.xml',
|
'security/security.xml',
|
||||||
|
|
@ -37,6 +38,10 @@ Key Features:
|
||||||
'view/task_stages.xml',
|
'view/task_stages.xml',
|
||||||
'view/project.xml',
|
'view/project.xml',
|
||||||
'view/project_task.xml',
|
'view/project_task.xml',
|
||||||
|
'view/timesheets.xml',
|
||||||
|
'view/pro_task_gantt.xml',
|
||||||
|
'view/user_availability.xml',
|
||||||
|
# 'view/project_task_gantt.xml',
|
||||||
],
|
],
|
||||||
'assets': {
|
'assets': {
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,20 @@
|
||||||
action = records.action_toggle_pause()
|
action = records.action_toggle_pause()
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
<!-- <record id="action_reward_user" model="ir.actions.server">-->
|
||||||
|
<!-- <field name="name">Reward User</field>-->
|
||||||
|
<!-- <field name="model_id" ref="project.model_project_task"/>-->
|
||||||
|
<!-- <field name="binding_model_id" ref="project.model_project_task"/>-->
|
||||||
|
<!-- <field name="binding_type">action</field>-->
|
||||||
|
<!-- <field name="binding_view_types">form</field>-->
|
||||||
|
<!-- <field name="state">code</field>-->
|
||||||
|
<!-- <field name="code">-->
|
||||||
|
<!-- if records:-->
|
||||||
|
<!-- action = records.action_reward_user()-->
|
||||||
|
<!-- </field>-->
|
||||||
|
<!-- </record>-->
|
||||||
|
|
||||||
<data noupdate="1">
|
<data noupdate="1">
|
||||||
<record id="default_projects_channel" model="discuss.channel">
|
<record id="default_projects_channel" model="discuss.channel">
|
||||||
<field name="name">Projects Channel</field>
|
<field name="name">Projects Channel</field>
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,6 @@ from . import teams
|
||||||
from . import task_stages
|
from . import task_stages
|
||||||
from . import project
|
from . import project
|
||||||
from . import project_task
|
from . import project_task
|
||||||
|
from . import timesheets
|
||||||
|
# from . import project_task_gantt
|
||||||
|
from . import user_availability
|
||||||
|
|
@ -15,7 +15,7 @@ class ProjectProject(models.Model):
|
||||||
)
|
)
|
||||||
discuss_channel_id = fields.Many2one(
|
discuss_channel_id = fields.Many2one(
|
||||||
'discuss.channel',
|
'discuss.channel',
|
||||||
string="Project Channel",
|
string="Channel",
|
||||||
domain="[('parent_channel_id', '=', default_projects_channel_id)]",
|
domain="[('parent_channel_id', '=', default_projects_channel_id)]",
|
||||||
help="Select a channel for project communications. Channels must be sub-channels of the main Projects Channel."
|
help="Select a channel for project communications. Channels must be sub-channels of the main Projects Channel."
|
||||||
)
|
)
|
||||||
|
|
@ -159,6 +159,21 @@ class ProjectProject(models.Model):
|
||||||
|
|
||||||
type_ids = fields.Many2many(default=lambda self: self._default_type_ids())
|
type_ids = fields.Many2many(default=lambda self: self._default_type_ids())
|
||||||
|
|
||||||
|
|
||||||
|
estimated_hours = fields.Float(string="Estimated Hours")
|
||||||
|
task_estimated_hours = fields.Float(string="Task Estimated Hours", compute="_compute_task_estimated_hours", store=True)
|
||||||
|
actual_hours = fields.Float(string="Actual Hours", compute="_compute_actual_hours", store=True)
|
||||||
|
|
||||||
|
@api.depends('task_ids.estimated_hours')
|
||||||
|
def _compute_task_estimated_hours(self):
|
||||||
|
for project in self:
|
||||||
|
project.task_estimated_hours = sum(project.task_ids.mapped('estimated_hours'))
|
||||||
|
|
||||||
|
@api.depends('task_ids.timesheet_ids.unit_amount')
|
||||||
|
def _compute_actual_hours(self):
|
||||||
|
for project in self:
|
||||||
|
project.actual_hours = sum(project.task_ids.timesheet_ids.mapped('unit_amount'))
|
||||||
|
|
||||||
def add_users(self):
|
def add_users(self):
|
||||||
return {
|
return {
|
||||||
'type': 'ir.actions.act_window',
|
'type': 'ir.actions.act_window',
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,19 @@
|
||||||
from odoo import api, fields, models, _
|
from odoo import api, fields, models, _
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from odoo.exceptions import UserError, ValidationError
|
from odoo.exceptions import UserError, ValidationError
|
||||||
|
import pytz
|
||||||
|
from pytz import utc, timezone
|
||||||
|
from odoo.addons.resource.models.utils import Intervals, sum_intervals
|
||||||
|
from odoo.tools import _, format_list, topological_sort
|
||||||
|
from odoo.tools.sql import SQL
|
||||||
|
from odoo.addons.resource.models.utils import filter_domain_leaf
|
||||||
|
from odoo.osv.expression import is_leaf
|
||||||
|
from odoo.osv import expression
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
CLOSED_STATES = {
|
CLOSED_STATES = {
|
||||||
'1_done': 'Done',
|
'1_done': 'Done',
|
||||||
|
|
@ -27,8 +39,277 @@ class projectTask(models.Model):
|
||||||
show_refuse_button = fields.Boolean(compute="_compute_access_check")
|
show_refuse_button = fields.Boolean(compute="_compute_access_check")
|
||||||
show_back_button = fields.Boolean(compute="_compute_access_check")
|
show_back_button = fields.Boolean(compute="_compute_access_check")
|
||||||
|
|
||||||
show_approval_flow = fields.Boolean(compute="_compute_show_approval_flow")
|
show_approval_flow = fields.Boolean(compute="_compute_show_approval_flow",store=True)
|
||||||
record_paused = fields.Boolean(default=False, tracking=True)
|
record_paused = fields.Boolean(default=False, tracking=True)
|
||||||
|
estimated_hours = fields.Float(
|
||||||
|
string="Estimated Hours",
|
||||||
|
compute="_compute_estimated_hours",
|
||||||
|
inverse="_inverse_estimated_hours",
|
||||||
|
store=True
|
||||||
|
)
|
||||||
|
has_supervisor_access = fields.Boolean(compute="_compute_has_supervisor_access")
|
||||||
|
actual_hours = fields.Float(
|
||||||
|
string="Actual Hours",
|
||||||
|
compute="_compute_actual_hours",
|
||||||
|
store=True
|
||||||
|
)
|
||||||
|
can_edit_approval_flow_stages = fields.Boolean(default=False)
|
||||||
|
|
||||||
|
suggested_deadline = fields.Datetime(string="Suggested Deadline", compute="_compute_suggested_deadline", store=True)
|
||||||
|
is_suggested_deadline_warning = fields.Boolean(
|
||||||
|
compute="_compute_deadline_warning",
|
||||||
|
string="Deadline Warning"
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('suggested_deadline', 'date_deadline','timelines_requested','show_approval_flow')
|
||||||
|
def _compute_deadline_warning(self):
|
||||||
|
for rec in self:
|
||||||
|
if rec.suggested_deadline and rec.date_deadline and rec.timelines_requested and rec.show_approval_flow and rec.estimated_hours > 0:
|
||||||
|
rec.is_suggested_deadline_warning = rec.suggested_deadline > rec.date_deadline
|
||||||
|
else:
|
||||||
|
rec.is_suggested_deadline_warning = False
|
||||||
|
|
||||||
|
@api.depends('assignees_timelines.estimated_end_datetime')
|
||||||
|
def _compute_suggested_deadline(self):
|
||||||
|
"""Compute the suggested deadline based on the latest timeline end date"""
|
||||||
|
for task in self:
|
||||||
|
if task.assignees_timelines:
|
||||||
|
end_dates = [timeline.estimated_end_datetime for timeline in task.assignees_timelines
|
||||||
|
if timeline.estimated_end_datetime]
|
||||||
|
if end_dates:
|
||||||
|
task.suggested_deadline = max(end_dates)
|
||||||
|
else:
|
||||||
|
task.suggested_deadline = False
|
||||||
|
else:
|
||||||
|
task.suggested_deadline = False
|
||||||
|
|
||||||
|
def _is_within_working_hours(self, dt, calendar, resource_id=None):
|
||||||
|
"""Check if the given datetime is within working hours"""
|
||||||
|
if not calendar:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Get the calendar's timezone
|
||||||
|
tz = pytz.timezone(calendar.tz) if calendar.tz else pytz.UTC
|
||||||
|
|
||||||
|
# Convert dt to the calendar's timezone and keep it timezone-aware
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = pytz.UTC.localize(dt)
|
||||||
|
dt_tz = dt.astimezone(tz)
|
||||||
|
|
||||||
|
# Create a resource if resource_id is provided
|
||||||
|
resource = None
|
||||||
|
if resource_id:
|
||||||
|
resource = self.env['resource.resource'].browse(resource_id)
|
||||||
|
|
||||||
|
# Get work intervals for a short period around the datetime
|
||||||
|
# Keep the datetimes timezone-aware
|
||||||
|
start_dt_tz = dt_tz
|
||||||
|
end_dt_tz = dt_tz + timedelta(minutes=1)
|
||||||
|
|
||||||
|
# Use _work_intervals_batch to get work intervals
|
||||||
|
work_intervals_dict = calendar._work_intervals_batch(
|
||||||
|
start_dt_tz,
|
||||||
|
end_dt_tz,
|
||||||
|
resources=resource,
|
||||||
|
domain=None,
|
||||||
|
tz=tz,
|
||||||
|
compute_leaves=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# If no resource is provided, use the default resource (empty resource)
|
||||||
|
if not resource:
|
||||||
|
resource = self.env['resource.resource']
|
||||||
|
|
||||||
|
# Get the work intervals for this resource
|
||||||
|
work_intervals = work_intervals_dict.get(resource.id)
|
||||||
|
|
||||||
|
# Check if any work interval covers the given datetime
|
||||||
|
if work_intervals:
|
||||||
|
# WorkIntervals object can be iterated to get intervals
|
||||||
|
for interval in work_intervals:
|
||||||
|
interval_start, interval_end = interval[0], interval[1]
|
||||||
|
if interval_start <= dt_tz < interval_end:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _get_next_working_datetime(self, start_dt, calendar, resource_id=None):
|
||||||
|
"""Get the next working datetime after the given start datetime"""
|
||||||
|
if not calendar:
|
||||||
|
return start_dt
|
||||||
|
|
||||||
|
# Get the calendar's timezone
|
||||||
|
tz = pytz.timezone(calendar.tz) if calendar.tz else pytz.UTC
|
||||||
|
|
||||||
|
# Convert start_dt to the calendar's timezone
|
||||||
|
if start_dt.tzinfo is None:
|
||||||
|
start_dt = pytz.UTC.localize(start_dt)
|
||||||
|
start_dt_tz = start_dt.astimezone(tz)
|
||||||
|
|
||||||
|
# Check if the current datetime is already within working hours
|
||||||
|
if self._is_within_working_hours(start_dt, calendar, resource_id):
|
||||||
|
return start_dt
|
||||||
|
|
||||||
|
# If not, find the next working datetime
|
||||||
|
# plan_hours returns a naive datetime in the calendar's timezone
|
||||||
|
next_working_dt_tz_naive = calendar.plan_hours(
|
||||||
|
0,
|
||||||
|
start_dt_tz.replace(tzinfo=None),
|
||||||
|
compute_leaves=False,
|
||||||
|
resource=resource_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Localize the result to the calendar's timezone
|
||||||
|
next_working_dt_tz = tz.localize(next_working_dt_tz_naive)
|
||||||
|
|
||||||
|
# Convert back to UTC
|
||||||
|
return next_working_dt_tz.astimezone(pytz.UTC).replace(tzinfo=None)
|
||||||
|
|
||||||
|
def action_assign_approx_deadlines(self):
|
||||||
|
"""Calculate and assign approximate start/end datetimes for timelines based on estimated time and user's existing assignments"""
|
||||||
|
for task in self:
|
||||||
|
# Sort timelines by stage sequence
|
||||||
|
timelines = task.assignees_timelines.sorted(lambda t: t.stage_sequence)
|
||||||
|
|
||||||
|
# Get company calendar
|
||||||
|
calendar = task.company_id.resource_calendar_id or self.env.company.resource_calendar_id
|
||||||
|
|
||||||
|
# Start from current datetime or task planned date
|
||||||
|
current_start = fields.Datetime.now()
|
||||||
|
if task.planned_date_begin:
|
||||||
|
current_start = task.planned_date_begin
|
||||||
|
|
||||||
|
for timeline in timelines:
|
||||||
|
if not timeline.estimated_time or not timeline.assigned_to:
|
||||||
|
# Skip if no estimated time or no assigned user
|
||||||
|
timeline.estimated_start_datetime = False
|
||||||
|
timeline.estimated_end_datetime = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get resource ID if available
|
||||||
|
resource_id = None
|
||||||
|
if timeline.assigned_to and timeline.assigned_to.employee_id:
|
||||||
|
resource_id = timeline.assigned_to.employee_id.resource_id.id
|
||||||
|
|
||||||
|
# Get all existing assignments for this user across all projects
|
||||||
|
user_assignments = self.env['project.task.time.lines'].search([
|
||||||
|
('assigned_to', '=', timeline.assigned_to.id),
|
||||||
|
('estimated_end_datetime', '>=', current_start),
|
||||||
|
('id', '!=', timeline.id) # Exclude current timeline
|
||||||
|
]).sorted('estimated_start_datetime')
|
||||||
|
|
||||||
|
# Find next available slot for the user
|
||||||
|
start_dt = current_start
|
||||||
|
|
||||||
|
# Adjust start_dt to the next working time if outside working hours
|
||||||
|
if calendar:
|
||||||
|
start_dt = self._get_next_working_datetime(start_dt, calendar, resource_id)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# Calculate end datetime based on work schedule
|
||||||
|
if calendar:
|
||||||
|
# Get the calendar's timezone
|
||||||
|
tz = pytz.timezone(calendar.tz) if calendar.tz else pytz.UTC
|
||||||
|
|
||||||
|
# Convert start_dt to the calendar's timezone
|
||||||
|
# Convert start dt to calendar tz, but only once
|
||||||
|
if start_dt.tzinfo is None:
|
||||||
|
start_dt = pytz.UTC.localize(start_dt)
|
||||||
|
|
||||||
|
# Convert UTC → calendar timezone
|
||||||
|
start_dt_tz = start_dt.astimezone(tz)
|
||||||
|
|
||||||
|
# Call plan_hours
|
||||||
|
end_dt_local_naive = calendar.plan_hours(
|
||||||
|
timeline.estimated_time,
|
||||||
|
start_dt_tz,
|
||||||
|
compute_leaves=False,
|
||||||
|
resource=resource_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Now end_dt_local_naive is naive LOCAL calendar-time
|
||||||
|
end_dt_local = end_dt_local_naive
|
||||||
|
|
||||||
|
# Convert to UTC for storage
|
||||||
|
end_dt = end_dt_local.astimezone(pytz.UTC).replace(tzinfo=None)
|
||||||
|
start_dt = start_dt_tz.astimezone(pytz.UTC).replace(tzinfo=None)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Fallback: just add hours as if 24/7
|
||||||
|
end_dt = fields.Datetime.from_string(start_dt) + timedelta(hours=timeline.estimated_time)
|
||||||
|
|
||||||
|
# Check if this slot conflicts with existing assignments
|
||||||
|
conflict = False
|
||||||
|
for assignment in user_assignments:
|
||||||
|
if (start_dt < assignment.estimated_end_datetime and
|
||||||
|
end_dt > assignment.estimated_start_datetime):
|
||||||
|
# Conflict found, move start to after this assignment
|
||||||
|
start_dt = assignment.estimated_end_datetime
|
||||||
|
|
||||||
|
# Adjust to next working time if outside working hours
|
||||||
|
if calendar:
|
||||||
|
start_dt = self._get_next_working_datetime(start_dt, calendar, resource_id)
|
||||||
|
|
||||||
|
conflict = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not conflict:
|
||||||
|
# No conflict, use this slot
|
||||||
|
break
|
||||||
|
|
||||||
|
# Set the timeline dates
|
||||||
|
if start_dt.tzinfo:
|
||||||
|
start_dt = start_dt.astimezone(pytz.UTC).replace(tzinfo=None)
|
||||||
|
|
||||||
|
if end_dt.tzinfo:
|
||||||
|
end_dt = end_dt.astimezone(pytz.UTC).replace(tzinfo=None)
|
||||||
|
|
||||||
|
timeline.estimated_start_datetime = start_dt
|
||||||
|
timeline.estimated_end_datetime = end_dt
|
||||||
|
|
||||||
|
# Update current_start for next timeline
|
||||||
|
current_start = end_dt
|
||||||
|
|
||||||
|
# Post to project channel about deadline assignment
|
||||||
|
channel_message = _("Approximate deadlines assigned for task %s. Suggested deadline: %s") % (
|
||||||
|
task.sequence_name or task.name,
|
||||||
|
task.suggested_deadline.strftime('%Y-%m-%d %H:%M') if task.suggested_deadline else _('Not available')
|
||||||
|
)
|
||||||
|
task._post_to_project_channel(channel_message)
|
||||||
|
|
||||||
|
# Add to activity log
|
||||||
|
task._add_activity_log("Approximate deadlines assigned by %s. Suggested deadline: %s" % (
|
||||||
|
self.env.user.name,
|
||||||
|
task.suggested_deadline.strftime('%Y-%m-%d %H:%M') if task.suggested_deadline else _('Not available')
|
||||||
|
))
|
||||||
|
|
||||||
|
@api.depends("project_id")
|
||||||
|
def _compute_has_supervisor_access(self):
|
||||||
|
for task in self:
|
||||||
|
current_user = self.env.user
|
||||||
|
task.has_supervisor_access = False
|
||||||
|
if current_user.has_group("project.group_project_manager") or current_user == task.project_id.user_id or current_user == task.project_id.project_lead:
|
||||||
|
task.has_supervisor_access = True
|
||||||
|
|
||||||
|
@api.depends('assignees_timelines.estimated_time', 'show_approval_flow')
|
||||||
|
def _compute_estimated_hours(self):
|
||||||
|
for task in self:
|
||||||
|
if task.show_approval_flow:
|
||||||
|
task.estimated_hours = sum(task.assignees_timelines.mapped('estimated_time'))
|
||||||
|
|
||||||
|
def _inverse_estimated_hours(self):
|
||||||
|
"""Allow editing only if approval flow is disabled."""
|
||||||
|
for task in self:
|
||||||
|
# Only check after record is created
|
||||||
|
if not task.id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not task.show_approval_flow:
|
||||||
|
task.write({'estimated_hours': task.estimated_hours})
|
||||||
|
@api.depends('timesheet_ids.unit_amount')
|
||||||
|
def _compute_actual_hours(self):
|
||||||
|
for task in self:
|
||||||
|
task.actual_hours = sum(task.timesheet_ids.mapped('unit_amount'))
|
||||||
|
|
||||||
def _post_to_project_channel(self, message_body, mention_partners=None):
|
def _post_to_project_channel(self, message_body, mention_partners=None):
|
||||||
"""Post message to project's discuss channel with proper Odoo mention format"""
|
"""Post message to project's discuss channel with proper Odoo mention format"""
|
||||||
|
|
@ -94,6 +375,7 @@ class projectTask(models.Model):
|
||||||
def action_toggle_pause(self):
|
def action_toggle_pause(self):
|
||||||
"""Toggle pause state for the record"""
|
"""Toggle pause state for the record"""
|
||||||
for record in self:
|
for record in self:
|
||||||
|
record.can_edit_approval_flow_stages = True
|
||||||
current_user = self.env.user
|
current_user = self.env.user
|
||||||
if record.project_id:
|
if record.project_id:
|
||||||
if record.project_id.user_id != current_user and record.project_id.project_lead != current_user and not current_user.has_group(
|
if record.project_id.user_id != current_user and record.project_id.project_lead != current_user and not current_user.has_group(
|
||||||
|
|
@ -111,6 +393,8 @@ class projectTask(models.Model):
|
||||||
record._post_to_project_channel(channel_message)
|
record._post_to_project_channel(channel_message)
|
||||||
|
|
||||||
# Add to activity log
|
# Add to activity log
|
||||||
|
|
||||||
|
record.can_edit_approval_flow_stages = False
|
||||||
record._add_activity_log(f"Task {action} by {self.env.user.name}")
|
record._add_activity_log(f"Task {action} by {self.env.user.name}")
|
||||||
|
|
||||||
@api.depends("assignees_timelines", "stage_id", "project_id", "approval_status")
|
@api.depends("assignees_timelines", "stage_id", "project_id", "approval_status")
|
||||||
|
|
@ -120,7 +404,7 @@ class projectTask(models.Model):
|
||||||
task.show_approval_button = False
|
task.show_approval_button = False
|
||||||
task.show_refuse_button = False
|
task.show_refuse_button = False
|
||||||
task.show_back_button = False
|
task.show_back_button = False
|
||||||
|
if task.project_id:
|
||||||
user = self.env.user
|
user = self.env.user
|
||||||
project_manager = task.project_id.user_id
|
project_manager = task.project_id.user_id
|
||||||
project_lead = task.project_id.project_lead
|
project_lead = task.project_id.project_lead
|
||||||
|
|
@ -174,6 +458,7 @@ class projectTask(models.Model):
|
||||||
task.show_approval_button = True
|
task.show_approval_button = True
|
||||||
task.show_back_button = True
|
task.show_back_button = True
|
||||||
|
|
||||||
|
if task.stage_id and task.project_id.type_ids:
|
||||||
is_first_stage = task.stage_id.sequence == min(task.project_id.type_ids.mapped('sequence'))
|
is_first_stage = task.stage_id.sequence == min(task.project_id.type_ids.mapped('sequence'))
|
||||||
if is_first_stage:
|
if is_first_stage:
|
||||||
task.show_back_button = False
|
task.show_back_button = False
|
||||||
|
|
@ -206,6 +491,7 @@ class projectTask(models.Model):
|
||||||
|
|
||||||
def back_button(self):
|
def back_button(self):
|
||||||
for task in self:
|
for task in self:
|
||||||
|
task.can_edit_approval_flow_stages = True
|
||||||
task.approval_status = False
|
task.approval_status = False
|
||||||
|
|
||||||
prev_stage = task.project_id.type_ids.filtered(lambda s: s.sequence < task.stage_id.sequence)
|
prev_stage = task.project_id.type_ids.filtered(lambda s: s.sequence < task.stage_id.sequence)
|
||||||
|
|
@ -250,9 +536,12 @@ class projectTask(models.Model):
|
||||||
message_type='notification',
|
message_type='notification',
|
||||||
subtype_xmlid='mail.mt_comment',
|
subtype_xmlid='mail.mt_comment',
|
||||||
)
|
)
|
||||||
|
task.can_edit_approval_flow_stages = False
|
||||||
|
|
||||||
|
|
||||||
def submit_for_approval(self):
|
def submit_for_approval(self):
|
||||||
for task in self:
|
for task in self:
|
||||||
|
task.can_edit_approval_flow_stages = True
|
||||||
task.approval_status = "submitted"
|
task.approval_status = "submitted"
|
||||||
stage = task.assignees_timelines.filtered(lambda s: s.stage_id == task.stage_id)
|
stage = task.assignees_timelines.filtered(lambda s: s.stage_id == task.stage_id)
|
||||||
responsible_user = stage.responsible_lead if stage and stage.responsible_lead else False
|
responsible_user = stage.responsible_lead if stage and stage.responsible_lead else False
|
||||||
|
|
@ -291,8 +580,11 @@ class projectTask(models.Model):
|
||||||
subtype_xmlid='mail.mt_comment',
|
subtype_xmlid='mail.mt_comment',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
task.can_edit_approval_flow_stages = False
|
||||||
|
|
||||||
def proceed_further(self):
|
def proceed_further(self):
|
||||||
for task in self:
|
for task in self:
|
||||||
|
task.can_edit_approval_flow_stages = True
|
||||||
current_stage = task.stage_id
|
current_stage = task.stage_id
|
||||||
current_timeline = task.assignees_timelines.filtered(lambda s: s.stage_id == current_stage)
|
current_timeline = task.assignees_timelines.filtered(lambda s: s.stage_id == current_stage)
|
||||||
next_stage = task.assignees_timelines.filtered(lambda s: s.stage_id.sequence > current_stage.sequence)
|
next_stage = task.assignees_timelines.filtered(lambda s: s.stage_id.sequence > current_stage.sequence)
|
||||||
|
|
@ -370,8 +662,11 @@ class projectTask(models.Model):
|
||||||
if is_last_stage:
|
if is_last_stage:
|
||||||
task.state = '1_done'
|
task.state = '1_done'
|
||||||
|
|
||||||
|
task.can_edit_approval_flow_stages = False
|
||||||
|
|
||||||
def reject_and_return(self, reason=None):
|
def reject_and_return(self, reason=None):
|
||||||
for task in self:
|
for task in self:
|
||||||
|
task.can_edit_approval_flow_stages = True
|
||||||
if not reason:
|
if not reason:
|
||||||
reason = ""
|
reason = ""
|
||||||
task.approval_status = "refused"
|
task.approval_status = "refused"
|
||||||
|
|
@ -423,6 +718,8 @@ class projectTask(models.Model):
|
||||||
subtype_xmlid='mail.mt_comment',
|
subtype_xmlid='mail.mt_comment',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
task.can_edit_approval_flow_stages = False
|
||||||
|
|
||||||
def action_open_reject_wizard(self):
|
def action_open_reject_wizard(self):
|
||||||
"""Open rejection wizard"""
|
"""Open rejection wizard"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
|
|
@ -438,6 +735,7 @@ class projectTask(models.Model):
|
||||||
def request_timelines(self):
|
def request_timelines(self):
|
||||||
"""Populate task timelines with all relevant project stages."""
|
"""Populate task timelines with all relevant project stages."""
|
||||||
for task in self:
|
for task in self:
|
||||||
|
task.can_edit_approval_flow_stages = True
|
||||||
task.timelines_requested = True
|
task.timelines_requested = True
|
||||||
# Clear existing timelines if needed
|
# Clear existing timelines if needed
|
||||||
task.assignees_timelines.unlink()
|
task.assignees_timelines.unlink()
|
||||||
|
|
@ -472,6 +770,7 @@ class projectTask(models.Model):
|
||||||
# Post to project channel about timeline request
|
# Post to project channel about timeline request
|
||||||
channel_message = _("Timelines requested for task %s") % (task.sequence_name or task.name)
|
channel_message = _("Timelines requested for task %s") % (task.sequence_name or task.name)
|
||||||
task._post_to_project_channel(channel_message)
|
task._post_to_project_channel(channel_message)
|
||||||
|
task.can_edit_approval_flow_stages = False
|
||||||
|
|
||||||
@api.model_create_multi
|
@api.model_create_multi
|
||||||
def create(self, vals_list):
|
def create(self, vals_list):
|
||||||
|
|
@ -507,6 +806,20 @@ class projectTask(models.Model):
|
||||||
|
|
||||||
return tasks
|
return tasks
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def write(self, vals):
|
||||||
|
# Check if the stage is being changed
|
||||||
|
if 'stage_id' in vals:
|
||||||
|
for task in self:
|
||||||
|
# If user is not allowed to change stage
|
||||||
|
if task.show_approval_flow and not task.can_edit_approval_flow_stages and not self.env.user.has_group("project.group_project_manager"):
|
||||||
|
raise UserError(_(
|
||||||
|
"You are not allowed to change the stage of this task because stage editing is restricted."
|
||||||
|
))
|
||||||
|
|
||||||
|
return super(projectTask, self).write(vals)
|
||||||
|
|
||||||
|
|
||||||
def button_update_assignees(self):
|
def button_update_assignees(self):
|
||||||
for task in self:
|
for task in self:
|
||||||
if task.assignees_timelines:
|
if task.assignees_timelines:
|
||||||
|
|
@ -518,6 +831,439 @@ class projectTask(models.Model):
|
||||||
channel_message = _("Assignees updated for task %s") % (task.sequence_name or task.name)
|
channel_message = _("Assignees updated for task %s") % (task.sequence_name or task.name)
|
||||||
task._post_to_project_channel(channel_message)
|
task._post_to_project_channel(channel_message)
|
||||||
|
|
||||||
|
def _fetch_planning_overlap(self, additional_domain=None):
|
||||||
|
use_timeline_logic = any(
|
||||||
|
t.timelines_requested and t.show_approval_flow and t.estimated_hours > 0
|
||||||
|
for t in self
|
||||||
|
)
|
||||||
|
if use_timeline_logic:
|
||||||
|
return self._fetch_planning_overlap_timelines(additional_domain)
|
||||||
|
else:
|
||||||
|
return self._fetch_planning_overlap_normal(additional_domain)
|
||||||
|
|
||||||
|
def _fetch_planning_overlap_normal(self, additional_domain=None):
|
||||||
|
domain = [
|
||||||
|
('active', '=', True),
|
||||||
|
('is_closed', '=', False),
|
||||||
|
('planned_date_begin', '!=', False),
|
||||||
|
('date_deadline', '!=', False),
|
||||||
|
('date_deadline', '>', fields.Datetime.now()),
|
||||||
|
('project_id', '!=', False),
|
||||||
|
]
|
||||||
|
if additional_domain:
|
||||||
|
domain = expression.AND([domain, additional_domain])
|
||||||
|
Task = self.env['project.task']
|
||||||
|
planning_overlap_query = Task._where_calc(
|
||||||
|
expression.AND([
|
||||||
|
domain,
|
||||||
|
[('id', 'in', self.ids)]
|
||||||
|
])
|
||||||
|
)
|
||||||
|
tu1_alias = planning_overlap_query.join(Task._table, 'id', 'project_task_user_rel', 'task_id', 'TU1')
|
||||||
|
task2_alias = planning_overlap_query.make_alias(Task._table, 'T2')
|
||||||
|
task2_expression = expression.expression(domain, Task, task2_alias)
|
||||||
|
task2_query = task2_expression.query
|
||||||
|
|
||||||
|
task2_query.add_where(
|
||||||
|
SQL(
|
||||||
|
"%s != %s",
|
||||||
|
SQL.identifier(task2_alias, 'id'),
|
||||||
|
SQL.identifier(self._table, 'id')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
task2_query.add_where(
|
||||||
|
SQL(
|
||||||
|
"(%s::TIMESTAMP, %s::TIMESTAMP) OVERLAPS (%s::TIMESTAMP, %s::TIMESTAMP)",
|
||||||
|
SQL.identifier(Task._table, 'planned_date_begin'),
|
||||||
|
SQL.identifier(Task._table, 'date_deadline'),
|
||||||
|
SQL.identifier(task2_alias, 'planned_date_begin'),
|
||||||
|
SQL.identifier(task2_alias, 'date_deadline')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
planning_overlap_query.add_join(
|
||||||
|
'JOIN',
|
||||||
|
task2_alias,
|
||||||
|
Task._table,
|
||||||
|
task2_query.where_clause
|
||||||
|
)
|
||||||
|
tu2_alias = planning_overlap_query.join(task2_alias, 'id', 'project_task_user_rel', 'task_id', 'TU2')
|
||||||
|
planning_overlap_query.add_where(
|
||||||
|
SQL(
|
||||||
|
"%s = %s",
|
||||||
|
SQL.identifier(tu1_alias, 'user_id'),
|
||||||
|
SQL.identifier(tu2_alias, 'user_id')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
user_alias = planning_overlap_query.join(tu1_alias, 'user_id', 'res_users', 'id', 'U')
|
||||||
|
partner_alias = planning_overlap_query.join(user_alias, 'partner_id', 'res_partner', 'id', 'P')
|
||||||
|
query_str = planning_overlap_query.select(
|
||||||
|
SQL.identifier(Task._table, 'id'),
|
||||||
|
SQL.identifier(Task._table, 'planned_date_begin'),
|
||||||
|
SQL.identifier(Task._table, 'date_deadline'),
|
||||||
|
SQL("ARRAY_AGG(%s) AS task_ids", SQL.identifier(task2_alias, 'id')),
|
||||||
|
SQL("MIN(%s)", SQL.identifier(task2_alias, 'planned_date_begin')),
|
||||||
|
SQL("MAX(%s)", SQL.identifier(task2_alias, 'date_deadline')),
|
||||||
|
SQL("%s AS user_id", SQL.identifier(user_alias, 'id')),
|
||||||
|
SQL("%s AS partner_name", SQL.identifier(partner_alias, 'name')),
|
||||||
|
SQL("%s", SQL.identifier(Task._table, 'allocated_hours')),
|
||||||
|
SQL("SUM(%s)", SQL.identifier(task2_alias, 'allocated_hours')),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.env.cr.execute(
|
||||||
|
SQL(
|
||||||
|
"""
|
||||||
|
%s
|
||||||
|
GROUP BY %s
|
||||||
|
ORDER BY %s
|
||||||
|
""",
|
||||||
|
query_str,
|
||||||
|
SQL(", ").join([
|
||||||
|
SQL.identifier(Task._table, 'id'),
|
||||||
|
SQL.identifier(user_alias, 'id'),
|
||||||
|
SQL.identifier(partner_alias, 'name'),
|
||||||
|
]),
|
||||||
|
SQL.identifier(partner_alias, 'name'),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return self.env.cr.dictfetchall()
|
||||||
|
|
||||||
|
def _fetch_planning_overlap_timelines(self, additional_domain=None):
|
||||||
|
Task = self.env['project.task']._table
|
||||||
|
TL = self.env['project.task.time.lines']._table
|
||||||
|
|
||||||
|
ids_list = list(self.ids) or [0]
|
||||||
|
|
||||||
|
sql = f"""
|
||||||
|
SELECT
|
||||||
|
T1.id,
|
||||||
|
TL1.estimated_start_datetime,
|
||||||
|
TL1.estimated_end_datetime,
|
||||||
|
ARRAY_AGG(T2.id) AS task_ids,
|
||||||
|
MIN(TL2.estimated_start_datetime) AS min_estimated_start,
|
||||||
|
MAX(TL2.estimated_end_datetime) AS max_estimated_end,
|
||||||
|
U.id AS user_id,
|
||||||
|
P.name AS partner_name,
|
||||||
|
T1.allocated_hours,
|
||||||
|
SUM(COALESCE(T2.allocated_hours, 0)) AS sum_allocated_hours
|
||||||
|
FROM {Task} T1
|
||||||
|
JOIN {TL} TL1 ON TL1.task_id = T1.id
|
||||||
|
JOIN {Task} T2 ON T1.id <> T2.id
|
||||||
|
JOIN {TL} TL2 ON TL2.task_id = T2.id
|
||||||
|
JOIN res_users U ON TL1.assigned_to = U.id
|
||||||
|
LEFT JOIN res_partner P ON U.partner_id = P.id
|
||||||
|
WHERE
|
||||||
|
T1.active = TRUE
|
||||||
|
AND T1.project_id IS NOT NULL
|
||||||
|
AND T1.timelines_requested = TRUE
|
||||||
|
AND T1.show_approval_flow = TRUE
|
||||||
|
AND T1.estimated_hours > 0
|
||||||
|
AND TL1.estimated_start_datetime IS NOT NULL
|
||||||
|
AND TL1.estimated_end_datetime IS NOT NULL
|
||||||
|
AND TL2.estimated_start_datetime IS NOT NULL
|
||||||
|
AND TL2.estimated_end_datetime IS NOT NULL
|
||||||
|
AND (TL1.estimated_start_datetime, TL1.estimated_end_datetime) OVERLAPS (TL2.estimated_start_datetime, TL2.estimated_end_datetime)
|
||||||
|
AND TL1.assigned_to = TL2.assigned_to
|
||||||
|
AND T1.id = ANY(%s)
|
||||||
|
GROUP BY T1.id, TL1.estimated_start_datetime, TL1.estimated_end_datetime, U.id, P.name, T1.allocated_hours
|
||||||
|
ORDER BY P.name
|
||||||
|
"""
|
||||||
|
self.env.cr.execute(sql, (ids_list,))
|
||||||
|
return self.env.cr.dictfetchall()
|
||||||
|
|
||||||
|
def _get_planning_overlap_per_task(self):
|
||||||
|
if not self.ids:
|
||||||
|
return {}
|
||||||
|
self.flush_model([
|
||||||
|
'active', 'planned_date_begin', 'date_deadline',
|
||||||
|
'user_ids', 'project_id', 'is_closed',
|
||||||
|
'timelines_requested', 'show_approval_flow', 'estimated_hours'
|
||||||
|
])
|
||||||
|
|
||||||
|
res = defaultdict(lambda: defaultdict(lambda: {
|
||||||
|
'overlapping_tasks_ids': [],
|
||||||
|
'sum_allocated_hours': 0,
|
||||||
|
'min_planned_date_begin': False,
|
||||||
|
'max_date_deadline': False,
|
||||||
|
'min_estimated_start': False,
|
||||||
|
'max_estimated_end': False,
|
||||||
|
}))
|
||||||
|
|
||||||
|
rows = self._fetch_planning_overlap([('allocated_hours', '>', 0)])
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
task_id = row.get('id')
|
||||||
|
user_id = row.get('user_id')
|
||||||
|
if task_id is None or user_id is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
overlapping_ids = row.get('task_ids') or row.get('array_agg') or []
|
||||||
|
allocated_hours = 0.0
|
||||||
|
sum_other = 0.0
|
||||||
|
|
||||||
|
if row.get('allocated_hours') is not None:
|
||||||
|
try:
|
||||||
|
allocated_hours = float(row.get('allocated_hours') or 0.0)
|
||||||
|
except Exception:
|
||||||
|
allocated_hours = 0.0
|
||||||
|
if row.get('sum') is not None:
|
||||||
|
try:
|
||||||
|
sum_other = float(row.get('sum') or 0.0)
|
||||||
|
except Exception:
|
||||||
|
sum_other = 0.0
|
||||||
|
if row.get('sum_allocated_hours') is not None:
|
||||||
|
try:
|
||||||
|
sum_other = float(row.get('sum_allocated_hours') or 0.0)
|
||||||
|
except Exception:
|
||||||
|
sum_other = 0.0
|
||||||
|
|
||||||
|
sum_allocated_hours = sum_other + allocated_hours
|
||||||
|
|
||||||
|
min_planned = None
|
||||||
|
max_deadline = None
|
||||||
|
for k in ('min', 'min_planned_date_begin', 'min_planned_date', 'planned_date_begin'):
|
||||||
|
if k in row and row[k] is not None:
|
||||||
|
min_planned = row[k]
|
||||||
|
break
|
||||||
|
for k in ('max', 'max_date_deadline', 'max_deadline', 'date_deadline'):
|
||||||
|
if k in row and row[k] is not None:
|
||||||
|
max_deadline = row[k]
|
||||||
|
break
|
||||||
|
|
||||||
|
min_estimated = None
|
||||||
|
max_estimated = None
|
||||||
|
for k in ('min_estimated_start', 'min', 'min_estimated_start_datetime', 'estimated_start_datetime'):
|
||||||
|
if k in row and row[k] is not None:
|
||||||
|
min_estimated = row[k]
|
||||||
|
break
|
||||||
|
for k in ('max_estimated_end', 'max', 'max_estimated_end_datetime', 'estimated_end_datetime'):
|
||||||
|
if k in row and row[k] is not None:
|
||||||
|
max_estimated = row[k]
|
||||||
|
break
|
||||||
|
|
||||||
|
if min_estimated is None and min_planned is not None:
|
||||||
|
min_estimated = min_planned
|
||||||
|
if max_estimated is None and max_deadline is not None:
|
||||||
|
max_estimated = max_deadline
|
||||||
|
|
||||||
|
res[task_id][user_id] = {
|
||||||
|
'partner_name': row.get('partner_name') or row.get('name') or '',
|
||||||
|
'overlapping_tasks_ids': overlapping_ids or [],
|
||||||
|
'sum_allocated_hours': sum_allocated_hours,
|
||||||
|
'min_planned_date_begin': min_planned,
|
||||||
|
'max_date_deadline': max_deadline,
|
||||||
|
'min_estimated_start': min_estimated,
|
||||||
|
'max_estimated_end': max_estimated,
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
@api.depends(
|
||||||
|
'planned_date_begin', 'date_deadline', 'user_ids',
|
||||||
|
'timelines_requested', 'show_approval_flow', 'estimated_hours',
|
||||||
|
'assignees_timelines.estimated_start_datetime',
|
||||||
|
'assignees_timelines.estimated_end_datetime',
|
||||||
|
'assignees_timelines.assigned_to'
|
||||||
|
)
|
||||||
|
def _compute_planning_overlap(self):
|
||||||
|
overlap_mapping = self._get_planning_overlap_per_task()
|
||||||
|
|
||||||
|
if not overlap_mapping:
|
||||||
|
for task in self:
|
||||||
|
task.planning_overlap = False
|
||||||
|
return {}
|
||||||
|
|
||||||
|
user_ids = set()
|
||||||
|
first = self[0]
|
||||||
|
absolute_min_start = utc.localize(first.planned_date_begin or datetime.utcnow())
|
||||||
|
absolute_max_end = utc.localize(first.date_deadline or datetime.utcnow())
|
||||||
|
|
||||||
|
for task in self:
|
||||||
|
for user_id, mapping in overlap_mapping.get(task.id, {}).items():
|
||||||
|
timeline_logic = (
|
||||||
|
bool(task.timelines_requested)
|
||||||
|
and bool(task.show_approval_flow)
|
||||||
|
and (bool(task.estimated_hours) and float(task.estimated_hours) > 0)
|
||||||
|
)
|
||||||
|
if timeline_logic:
|
||||||
|
start = mapping.get("min_estimated_start")
|
||||||
|
end = mapping.get("max_estimated_end")
|
||||||
|
else:
|
||||||
|
start = mapping.get("min_planned_date_begin")
|
||||||
|
end = mapping.get("max_date_deadline")
|
||||||
|
if not start or not end:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
start = utc.localize(start) if not getattr(start, 'tzinfo', None) else start
|
||||||
|
except Exception:
|
||||||
|
start = fields.Datetime.context_timestamp(task, fields.Datetime.from_string(start)) if isinstance(
|
||||||
|
start, str) else None
|
||||||
|
try:
|
||||||
|
end = utc.localize(end) if not getattr(end, 'tzinfo', None) else end
|
||||||
|
except Exception:
|
||||||
|
end = fields.Datetime.context_timestamp(task, fields.Datetime.from_string(end)) if isinstance(end,
|
||||||
|
str) else None
|
||||||
|
if not start or not end:
|
||||||
|
continue
|
||||||
|
absolute_min_start = min(absolute_min_start, start)
|
||||||
|
absolute_max_end = max(absolute_max_end, end)
|
||||||
|
user_ids.add(user_id)
|
||||||
|
|
||||||
|
if not user_ids:
|
||||||
|
for task in self:
|
||||||
|
task.planning_overlap = False
|
||||||
|
return {}
|
||||||
|
|
||||||
|
users = self.env['res.users'].browse(list(user_ids))
|
||||||
|
users_work_intervals, _ = users.sudo()._get_valid_work_intervals(absolute_min_start, absolute_max_end)
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
for task in self:
|
||||||
|
messages = []
|
||||||
|
for user_id, mapping in overlap_mapping.get(task.id, {}).items():
|
||||||
|
timeline_logic = (
|
||||||
|
bool(task.timelines_requested)
|
||||||
|
and bool(task.show_approval_flow)
|
||||||
|
and (bool(task.estimated_hours) and float(task.estimated_hours) > 0)
|
||||||
|
)
|
||||||
|
if timeline_logic:
|
||||||
|
start = mapping.get("min_estimated_start")
|
||||||
|
end = mapping.get("max_estimated_end")
|
||||||
|
else:
|
||||||
|
start = mapping.get("min_planned_date_begin")
|
||||||
|
end = mapping.get("max_date_deadline")
|
||||||
|
if not start or not end:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
start = utc.localize(start) if not getattr(start, 'tzinfo', None) else start
|
||||||
|
except Exception:
|
||||||
|
start = fields.Datetime.context_timestamp(task, fields.Datetime.from_string(start)) if isinstance(
|
||||||
|
start, str) else None
|
||||||
|
try:
|
||||||
|
end = utc.localize(end) if not getattr(end, 'tzinfo', None) else end
|
||||||
|
except Exception:
|
||||||
|
end = fields.Datetime.context_timestamp(task, fields.Datetime.from_string(end)) if isinstance(end,
|
||||||
|
str) else None
|
||||||
|
if not start or not end:
|
||||||
|
continue
|
||||||
|
|
||||||
|
task_intervals = Intervals([
|
||||||
|
(start, end, self.env['resource.calendar.attendance'])
|
||||||
|
])
|
||||||
|
|
||||||
|
allocated_hours = mapping.get('sum_allocated_hours', 0) or 0
|
||||||
|
try:
|
||||||
|
allocated_hours = float(allocated_hours)
|
||||||
|
except Exception:
|
||||||
|
allocated_hours = 0.0
|
||||||
|
|
||||||
|
user_intervals = users_work_intervals.get(user_id)
|
||||||
|
available_hours = 0.0 if not user_intervals else sum_intervals(user_intervals & task_intervals)
|
||||||
|
|
||||||
|
if allocated_hours > available_hours:
|
||||||
|
partner = mapping.get('partner_name') or 'User'
|
||||||
|
amount = len(mapping.get('overlapping_tasks_ids') or [])
|
||||||
|
messages.append(f"{partner} has {amount} tasks at the same time.")
|
||||||
|
if task.id not in result:
|
||||||
|
result[task.id] = {}
|
||||||
|
result[task.id][user_id] = mapping
|
||||||
|
|
||||||
|
task.planning_overlap = " ".join(messages) or False
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _search_planning_overlap(self, operator, value):
|
||||||
|
if operator not in ['=', '!='] or not isinstance(value, bool):
|
||||||
|
raise NotImplementedError(
|
||||||
|
_('Operation not supported. Compare planning_overlap to True or False.')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Detect if ANY task in the env uses timeline logic
|
||||||
|
# We cannot check compute fields in SQL, so we do it in Python
|
||||||
|
use_timeline_logic = any(
|
||||||
|
t.timelines_requested
|
||||||
|
and t.show_approval_flow
|
||||||
|
and t.estimated_hours > 0
|
||||||
|
for t in self.env['project.task'].search([])
|
||||||
|
)
|
||||||
|
|
||||||
|
if use_timeline_logic:
|
||||||
|
# ---------- TIMELINE MODE ----------
|
||||||
|
sql = SQL("""
|
||||||
|
(
|
||||||
|
SELECT T1.id
|
||||||
|
FROM project_task T1
|
||||||
|
JOIN project_task_time_lines TL1 ON TL1.task_id = T1.id
|
||||||
|
JOIN project_task_time_lines TL2
|
||||||
|
ON TL1.assigned_to = TL2.assigned_to
|
||||||
|
AND TL1.task_id <> TL2.task_id
|
||||||
|
WHERE
|
||||||
|
TL1.estimated_start_datetime < TL2.estimated_end_datetime
|
||||||
|
AND TL1.estimated_end_datetime > TL2.estimated_start_datetime
|
||||||
|
AND TL1.estimated_start_datetime IS NOT NULL
|
||||||
|
AND TL1.estimated_end_datetime IS NOT NULL
|
||||||
|
AND TL2.estimated_start_datetime IS NOT NULL
|
||||||
|
AND TL2.estimated_end_datetime IS NOT NULL
|
||||||
|
AND T1.active = 't'
|
||||||
|
AND T2.active = 't'
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
else:
|
||||||
|
# ---------- NORMAL MODE ----------
|
||||||
|
sql = SQL("""
|
||||||
|
(
|
||||||
|
SELECT T1.id
|
||||||
|
FROM project_task T1
|
||||||
|
INNER JOIN project_task T2 ON T1.id <> T2.id
|
||||||
|
INNER JOIN project_task_user_rel U1 ON T1.id = U1.task_id
|
||||||
|
INNER JOIN project_task_user_rel U2
|
||||||
|
ON T2.id = U2.task_id
|
||||||
|
AND U1.user_id = U2.user_id
|
||||||
|
WHERE
|
||||||
|
T1.planned_date_begin < T2.date_deadline
|
||||||
|
AND T1.date_deadline > T2.planned_date_begin
|
||||||
|
AND T1.planned_date_begin IS NOT NULL
|
||||||
|
AND T1.date_deadline IS NOT NULL
|
||||||
|
AND T2.planned_date_begin IS NOT NULL
|
||||||
|
AND T2.date_deadline IS NOT NULL
|
||||||
|
AND T1.active = 't'
|
||||||
|
AND T2.active = 't'
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
operator_new = "in" if (
|
||||||
|
(operator == "=" and value) or
|
||||||
|
(operator == "!=" and not value)
|
||||||
|
) else "not in"
|
||||||
|
|
||||||
|
return [('id', operator_new, sql)]
|
||||||
|
|
||||||
|
def action_fsm_view_overlapping_tasks(self):
|
||||||
|
self.ensure_one()
|
||||||
|
action = self.env['ir.actions.act_window']._for_xml_id('project.action_view_all_task')
|
||||||
|
if 'views' in action:
|
||||||
|
gantt_view = self.env.ref("project_gantt.project_task_dependency_view_gantt")
|
||||||
|
# map_view = self.env.ref('project_gantt.project_task_map_view_no_title')
|
||||||
|
action['views'] = [(gantt_view.id, 'gantt')] + [(state, view) for state, view in action['views'] if view not in ['gantt']]
|
||||||
|
name = _('Tasks in Conflict')
|
||||||
|
action.update({
|
||||||
|
'display_name': name,
|
||||||
|
'name': name,
|
||||||
|
'domain' : [
|
||||||
|
('user_ids', 'in', self.user_ids.ids),
|
||||||
|
],
|
||||||
|
'context': {
|
||||||
|
'fsm_mode': False,
|
||||||
|
'task_nameget_with_hours': False,
|
||||||
|
'initialDate': self.planned_date_begin,
|
||||||
|
'search_default_conflict_task': True,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return action
|
||||||
|
|
||||||
|
|
||||||
class projectTaskTimelines(models.Model):
|
class projectTaskTimelines(models.Model):
|
||||||
_name = 'project.task.time.lines'
|
_name = 'project.task.time.lines'
|
||||||
|
|
@ -538,8 +1284,6 @@ class projectTaskTimelines(models.Model):
|
||||||
, compute="_compute_team_members"
|
, compute="_compute_team_members"
|
||||||
)
|
)
|
||||||
assigned_to = fields.Many2one('res.users', string="Assigned To", domain="[('id','in',team_all_member_ids or [])]")
|
assigned_to = fields.Many2one('res.users', string="Assigned To", domain="[('id','in',team_all_member_ids or [])]")
|
||||||
estimated_time = fields.Float(string="Estimated Time")
|
|
||||||
actual_time = fields.Float(string="Actual Time", readonly=True)
|
|
||||||
task_id = fields.Many2one("project.task")
|
task_id = fields.Many2one("project.task")
|
||||||
project_id = fields.Many2one("project.project", related="task_id.project_id")
|
project_id = fields.Many2one("project.project", related="task_id.project_id")
|
||||||
stage_ids = fields.Many2many(related="project_id.type_ids")
|
stage_ids = fields.Many2many(related="project_id.type_ids")
|
||||||
|
|
@ -549,8 +1293,52 @@ class projectTaskTimelines(models.Model):
|
||||||
compute="_compute_allowed_teams",
|
compute="_compute_allowed_teams",
|
||||||
store=False
|
store=False
|
||||||
)
|
)
|
||||||
request_date = fields.Date(string="Request Date")
|
# request_date = fields.Date(string="Request Date")
|
||||||
done_date = fields.Date(string="Done Date")
|
# done_date = fields.Date(string="Done Date")
|
||||||
|
estimated_time = fields.Float(string="Estimated Time")
|
||||||
|
actual_time = fields.Float(string="Actual Time", compute="_compute_actual_time", store=True)
|
||||||
|
has_edit_access = fields.Boolean(compute="_compute_has_edit_access")
|
||||||
|
estimated_time_readonly = fields.Boolean(compute="_compute_estimated_time_readonly")
|
||||||
|
estimated_start_datetime = fields.Datetime(string="Estimated Start Date Time")
|
||||||
|
estimated_end_datetime = fields.Datetime(string="Estimated End Date Time")
|
||||||
|
|
||||||
|
# @api.constrains('estimated_start_datetime', 'estimated_end_datetime')
|
||||||
|
# def _check_dates(self):
|
||||||
|
# for rec in self:
|
||||||
|
# if rec.estimated_start_datetime and rec.estimated_end_datetime:
|
||||||
|
# if rec.estimated_start_datetime >= rec.estimated_end_datetime:
|
||||||
|
# raise ValidationError(_("End datetime must be after start datetime."))
|
||||||
|
|
||||||
|
@api.depends('task_id','task_id.sequence_name','stage_id','stage_id.name','estimated_time')
|
||||||
|
def _compute_display_name(self):
|
||||||
|
for rec in self:
|
||||||
|
rec.display_name = f"{rec.task_id.sequence_name} {rec.stage_id.name}" if rec.task_id.sequence_name and rec.stage_id else rec.stage_id.name
|
||||||
|
|
||||||
|
@api.depends("project_id","responsible_lead")
|
||||||
|
def _compute_has_edit_access(self):
|
||||||
|
for task in self:
|
||||||
|
current_user = self.env.user
|
||||||
|
task.has_edit_access = False
|
||||||
|
if current_user.has_group(
|
||||||
|
"project.group_project_manager") or current_user == task.project_id.user_id or current_user == task.responsible_lead or current_user == task.project_id.project_lead:
|
||||||
|
task.has_edit_access = True
|
||||||
|
|
||||||
|
@api.depends("project_id","task_id")
|
||||||
|
def _compute_estimated_time_readonly(self):
|
||||||
|
for task in self:
|
||||||
|
task.estimated_time_readonly = True
|
||||||
|
current_user = self.env.user
|
||||||
|
if task.task_id and task.project_id:
|
||||||
|
is_first_stage = task.task_id.stage_id.sequence == min(task.task_id.project_id.type_ids.mapped('sequence'))
|
||||||
|
if is_first_stage or current_user == task.project_id.user_id:
|
||||||
|
task.estimated_time_readonly = False
|
||||||
|
|
||||||
|
@api.depends('task_id','task_id.timesheet_ids', 'stage_id')
|
||||||
|
def _compute_actual_time(self):
|
||||||
|
for task in self:
|
||||||
|
stage = task.stage_id
|
||||||
|
task.actual_time = sum(
|
||||||
|
task.task_id.timesheet_ids.filtered(lambda t: t.stage_id == stage).mapped('unit_amount'))
|
||||||
|
|
||||||
@api.depends('team_id', 'project_id')
|
@api.depends('team_id', 'project_id')
|
||||||
def _compute_team_members(self):
|
def _compute_team_members(self):
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,279 @@
|
||||||
|
from odoo import models, fields, api, _
|
||||||
|
from datetime import datetime, date
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
|
class ProjectTask(models.Model):
|
||||||
|
_inherit = 'project.task'
|
||||||
|
|
||||||
|
# Existing fields
|
||||||
|
gantt_color = fields.Char(compute='_compute_gantt_color', store=True)
|
||||||
|
gantt_user_id = fields.Many2one('res.users', compute='_compute_gantt_user_id', store=True)
|
||||||
|
performance_icon = fields.Char(compute='_compute_performance_icon', store=True)
|
||||||
|
is_overdue = fields.Boolean(compute='_compute_is_overdue', store=True)
|
||||||
|
gantt_date_start = fields.Datetime(compute='_compute_gantt_dates', store=True, inverse='_inverse_gantt_date_start')
|
||||||
|
current_stage_performance = fields.Selection([
|
||||||
|
('good', 'Good'),
|
||||||
|
('normal', 'Normal'),
|
||||||
|
('bad', 'Bad')
|
||||||
|
], compute='_compute_current_stage_performance', store=True)
|
||||||
|
formatted_start_date = fields.Char(compute='_compute_formatted_dates')
|
||||||
|
formatted_end_date = fields.Char(compute='_compute_formatted_dates')
|
||||||
|
|
||||||
|
# New fields for better Gantt view
|
||||||
|
progress = fields.Integer(string="Progress", compute='_compute_progress', store=True)
|
||||||
|
task_priority = fields.Selection([
|
||||||
|
('0', 'Normal'),
|
||||||
|
('1', 'High'),
|
||||||
|
('2', 'Urgent')
|
||||||
|
], default='0', string="Priority")
|
||||||
|
is_completed = fields.Boolean(compute='_compute_is_completed', store=True)
|
||||||
|
completion_date = fields.Datetime(string="Completion Date")
|
||||||
|
days_remaining = fields.Integer(compute='_compute_days_remaining', store=True)
|
||||||
|
|
||||||
|
@api.depends('stage_id', 'project_id.type_ids')
|
||||||
|
def _compute_progress(self):
|
||||||
|
for task in self:
|
||||||
|
if not task.stage_id or not task.project_id.type_ids:
|
||||||
|
task.progress = 0
|
||||||
|
continue
|
||||||
|
|
||||||
|
stages = task.project_id.type_ids.sorted('sequence')
|
||||||
|
total_stages = len(stages)
|
||||||
|
if total_stages == 0:
|
||||||
|
task.progress = 0
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_stage_index = next((i for i, stage in enumerate(stages) if stage.id == task.stage_id.id), 0)
|
||||||
|
task.progress = int((current_stage_index + 1) / total_stages * 100)
|
||||||
|
|
||||||
|
@api.depends('state')
|
||||||
|
def _compute_is_completed(self):
|
||||||
|
for task in self:
|
||||||
|
task.is_completed = task.state in ['1_done', 'done']
|
||||||
|
|
||||||
|
@api.depends('date_deadline')
|
||||||
|
def _compute_days_remaining(self):
|
||||||
|
today = date.today()
|
||||||
|
for task in self:
|
||||||
|
if not task.date_deadline:
|
||||||
|
task.days_remaining = 0
|
||||||
|
continue
|
||||||
|
|
||||||
|
if isinstance(task.date_deadline, datetime):
|
||||||
|
deadline_date = task.date_deadline.date()
|
||||||
|
else:
|
||||||
|
deadline_date = task.date_deadline
|
||||||
|
|
||||||
|
delta = deadline_date - today
|
||||||
|
task.days_remaining = delta.days
|
||||||
|
|
||||||
|
@api.depends('planned_date_begin', 'date_deadline')
|
||||||
|
def _compute_formatted_dates(self):
|
||||||
|
for task in self:
|
||||||
|
if task.planned_date_begin:
|
||||||
|
if isinstance(task.planned_date_begin, datetime):
|
||||||
|
task.formatted_start_date = task.planned_date_begin.strftime('%d-%b-%Y %I:%M %p')
|
||||||
|
else:
|
||||||
|
task.formatted_start_date = task.planned_date_begin.strftime('%d-%b-%Y')
|
||||||
|
else:
|
||||||
|
task.formatted_start_date = False
|
||||||
|
|
||||||
|
if task.date_deadline:
|
||||||
|
if isinstance(task.date_deadline, datetime):
|
||||||
|
task.formatted_end_date = task.date_deadline.strftime('%d-%b-%Y %I:%M %p')
|
||||||
|
else:
|
||||||
|
task.formatted_end_date = task.date_deadline.strftime('%d-%b-%Y')
|
||||||
|
else:
|
||||||
|
task.formatted_end_date = False
|
||||||
|
|
||||||
|
@api.depends('show_approval_flow', 'assignees_timelines.actual_time', 'assignees_timelines.estimated_time')
|
||||||
|
def _compute_current_stage_performance(self):
|
||||||
|
for task in self:
|
||||||
|
# Don't compute performance for completed tasks
|
||||||
|
if task.is_completed:
|
||||||
|
task.current_stage_performance = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
if task.show_approval_flow:
|
||||||
|
current_timeline = task.assignees_timelines.filtered(lambda t: t.stage_id == task.stage_id)
|
||||||
|
if current_timeline:
|
||||||
|
timeline = current_timeline[0]
|
||||||
|
actual = timeline.actual_time
|
||||||
|
estimated = timeline.estimated_time
|
||||||
|
|
||||||
|
if actual < estimated:
|
||||||
|
task.current_stage_performance = 'good'
|
||||||
|
elif actual == estimated:
|
||||||
|
task.current_stage_performance = 'normal'
|
||||||
|
else:
|
||||||
|
task.current_stage_performance = 'bad'
|
||||||
|
else:
|
||||||
|
task.current_stage_performance = False
|
||||||
|
else:
|
||||||
|
task.current_stage_performance = False
|
||||||
|
|
||||||
|
@api.depends('planned_date_begin')
|
||||||
|
def _compute_gantt_dates(self):
|
||||||
|
for task in self:
|
||||||
|
if task.planned_date_begin:
|
||||||
|
# Ensure we're working with a datetime object
|
||||||
|
if isinstance(task.planned_date_begin, date) and not isinstance(task.planned_date_begin, datetime):
|
||||||
|
# Convert date to datetime at start of day
|
||||||
|
task.gantt_date_start = datetime.combine(task.planned_date_begin, datetime.min.time())
|
||||||
|
else:
|
||||||
|
task.gantt_date_start = task.planned_date_begin
|
||||||
|
else:
|
||||||
|
task.gantt_date_start = False
|
||||||
|
|
||||||
|
def _inverse_gantt_date_start(self):
|
||||||
|
for task in self:
|
||||||
|
if task.gantt_date_start:
|
||||||
|
task.planned_date_begin = task.gantt_date_start
|
||||||
|
|
||||||
|
@api.depends('assignees_timelines.assigned_to', 'user_ids', 'show_approval_flow', 'stage_id')
|
||||||
|
def _compute_gantt_user_id(self):
|
||||||
|
for task in self:
|
||||||
|
if task.show_approval_flow:
|
||||||
|
current_timeline = task.assignees_timelines.filtered(lambda t: t.stage_id == task.stage_id)
|
||||||
|
if current_timeline:
|
||||||
|
task.gantt_user_id = current_timeline[0].assigned_to
|
||||||
|
else:
|
||||||
|
task.gantt_user_id = task.user_ids[:1] if task.user_ids else self.env.user
|
||||||
|
else:
|
||||||
|
task.gantt_user_id = task.user_ids[:1] if task.user_ids else self.env.user
|
||||||
|
|
||||||
|
@api.depends('show_approval_flow', 'assignees_timelines.actual_time', 'assignees_timelines.estimated_time')
|
||||||
|
def _compute_performance_icon(self):
|
||||||
|
for task in self:
|
||||||
|
# Don't show performance icon for completed tasks
|
||||||
|
if task.is_completed:
|
||||||
|
task.performance_icon = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
if task.show_approval_flow:
|
||||||
|
current_timeline = task.assignees_timelines.filtered(lambda t: t.stage_id == task.stage_id)
|
||||||
|
if current_timeline:
|
||||||
|
timeline = current_timeline[0]
|
||||||
|
actual = timeline.actual_time
|
||||||
|
estimated = timeline.estimated_time
|
||||||
|
|
||||||
|
if actual < estimated:
|
||||||
|
task.performance_icon = 'fa-smile-o'
|
||||||
|
elif actual == estimated:
|
||||||
|
task.performance_icon = 'fa-meh-o'
|
||||||
|
else:
|
||||||
|
task.performance_icon = 'fa-frown-o'
|
||||||
|
else:
|
||||||
|
task.performance_icon = 'fa-question'
|
||||||
|
else:
|
||||||
|
task.performance_icon = False
|
||||||
|
|
||||||
|
@api.depends('date_deadline')
|
||||||
|
def _compute_is_overdue(self):
|
||||||
|
today = date.today()
|
||||||
|
for task in self:
|
||||||
|
# Completed tasks are never overdue
|
||||||
|
if task.is_completed:
|
||||||
|
task.is_overdue = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not task.date_deadline:
|
||||||
|
task.is_overdue = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Convert datetime to date if needed
|
||||||
|
if isinstance(task.date_deadline, datetime):
|
||||||
|
deadline_date = task.date_deadline.date()
|
||||||
|
else:
|
||||||
|
deadline_date = task.date_deadline
|
||||||
|
|
||||||
|
task.is_overdue = deadline_date < today
|
||||||
|
|
||||||
|
@api.depends('show_approval_flow', 'assignees_timelines.actual_time', 'assignees_timelines.estimated_time',
|
||||||
|
'date_deadline', 'state', 'is_completed')
|
||||||
|
def _compute_gantt_color(self):
|
||||||
|
today = date.today()
|
||||||
|
for task in self:
|
||||||
|
# If task is completed, always show green
|
||||||
|
if task.is_completed:
|
||||||
|
task.gantt_color = '#28a745' # Green
|
||||||
|
continue
|
||||||
|
|
||||||
|
if task.show_approval_flow:
|
||||||
|
current_timeline = task.assignees_timelines.filtered(lambda t: t.stage_id == task.stage_id)
|
||||||
|
if current_timeline:
|
||||||
|
timeline = current_timeline[0]
|
||||||
|
actual = timeline.actual_time
|
||||||
|
estimated = timeline.estimated_time
|
||||||
|
|
||||||
|
if actual < estimated:
|
||||||
|
task.gantt_color = '#28a745' # Green
|
||||||
|
elif actual == estimated:
|
||||||
|
task.gantt_color = '#007bff' # Blue
|
||||||
|
else:
|
||||||
|
task.gantt_color = '#dc3545' # Red
|
||||||
|
else:
|
||||||
|
task.gantt_color = '#6c757d' # Gray if no timeline
|
||||||
|
else:
|
||||||
|
# Color based on deadline
|
||||||
|
if task.date_deadline:
|
||||||
|
# Convert datetime to date if needed
|
||||||
|
if isinstance(task.date_deadline, datetime):
|
||||||
|
deadline_date = task.date_deadline.date()
|
||||||
|
else:
|
||||||
|
deadline_date = task.date_deadline
|
||||||
|
|
||||||
|
if deadline_date < today:
|
||||||
|
task.gantt_color = '#dc3545' # Red for overdue
|
||||||
|
else:
|
||||||
|
task.gantt_color = '#28a745' # Green for on time
|
||||||
|
else:
|
||||||
|
task.gantt_color = '#6c757d' # Gray if no deadline
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _expand_domain_dates(self, domain):
|
||||||
|
new_domain = []
|
||||||
|
for dom in domain:
|
||||||
|
if dom[0] in ['date_start', 'date_end', 'date_deadline', 'gantt_date_start'] and dom[1] in ['>=', '<=', '>',
|
||||||
|
'<', '=']:
|
||||||
|
# Handle both datetime and date string formats
|
||||||
|
if isinstance(dom[2], datetime):
|
||||||
|
min_date = dom[2]
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
# Try parsing with time first
|
||||||
|
min_date = datetime.strptime(dom[2], '%Y-%m-%d %H:%M:%S')
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
# If that fails, try parsing without time
|
||||||
|
min_date = datetime.strptime(dom[2], '%Y-%m-%d')
|
||||||
|
except ValueError:
|
||||||
|
# If both fail, use the original value
|
||||||
|
min_date = dom[2]
|
||||||
|
new_domain.append((dom[0], dom[1], min_date))
|
||||||
|
else:
|
||||||
|
new_domain.append(dom)
|
||||||
|
return new_domain
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
# Handle date validation more flexibly
|
||||||
|
if 'planned_date_begin' in vals and 'date_deadline' in vals:
|
||||||
|
if vals['planned_date_begin'] and vals['date_deadline']:
|
||||||
|
if vals['planned_date_begin'] > vals['date_deadline']:
|
||||||
|
raise ValidationError(_("Planned start date must be before planned end date."))
|
||||||
|
elif 'planned_date_begin' in vals and vals['planned_date_begin'] and self.date_deadline:
|
||||||
|
if vals['planned_date_begin'] > self.date_deadline:
|
||||||
|
raise ValidationError(_("Planned start date must be before planned end date."))
|
||||||
|
elif 'date_deadline' in vals and vals['date_deadline'] and self.planned_date_begin:
|
||||||
|
if self.planned_date_begin > vals['date_deadline']:
|
||||||
|
raise ValidationError(_("Planned start date must be before planned end date."))
|
||||||
|
|
||||||
|
# Update gantt_date_start when planned_date_begin changes
|
||||||
|
if 'planned_date_begin' in vals:
|
||||||
|
vals['gantt_date_start'] = vals['planned_date_begin']
|
||||||
|
|
||||||
|
# Set completion date when task is marked as done
|
||||||
|
if 'state' in vals and vals['state'] in ['1_done', 'done']:
|
||||||
|
vals['completion_date'] = fields.Datetime.now()
|
||||||
|
|
||||||
|
return super(ProjectTask, self).write(vals)
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
from odoo import api, fields, models, _
|
||||||
|
from odoo.exceptions import UserError, ValidationError
|
||||||
|
|
||||||
|
class AccountAnalyticLine(models.Model):
|
||||||
|
_inherit = 'account.analytic.line'
|
||||||
|
|
||||||
|
stage_id = fields.Many2one(
|
||||||
|
'project.task.type',
|
||||||
|
string="Stage",
|
||||||
|
domain="[('id', 'in', stage_ids)]"
|
||||||
|
)
|
||||||
|
|
||||||
|
stage_ids = fields.Many2many('project.task.type',related="task_id.project_id.type_ids")
|
||||||
|
|
||||||
|
@api.constrains('stage_id', 'task_id')
|
||||||
|
def _check_stage_required(self):
|
||||||
|
for line in self:
|
||||||
|
if line.task_id.show_approval_flow and not line.stage_id:
|
||||||
|
raise ValidationError(_("Stage is required when Approval Flow is enabled for this task."))
|
||||||
|
|
@ -0,0 +1,202 @@
|
||||||
|
from odoo import models, fields, api, _
|
||||||
|
|
||||||
|
|
||||||
|
class UserTaskAvailability(models.Model):
|
||||||
|
_name = 'user.task.availability'
|
||||||
|
_description = 'User Task Availability'
|
||||||
|
_auto = False
|
||||||
|
_order = 'work_start_datetime'
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# VIEW FIELDS
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
|
||||||
|
user_id = fields.Many2one('res.users', string="User", readonly=True)
|
||||||
|
task_id = fields.Many2one('project.task', string="Task", readonly=True)
|
||||||
|
project_id = fields.Many2one('project.project', string="Project", readonly=True)
|
||||||
|
stage_id = fields.Many2one('project.task.type', string="Stage", readonly=True)
|
||||||
|
task_name = fields.Char(readonly=True)
|
||||||
|
task_sequence = fields.Char(readonly=True)
|
||||||
|
is_generic = fields.Boolean(readonly=True)
|
||||||
|
|
||||||
|
work_start_datetime = fields.Datetime(string="Work Start", readonly=True)
|
||||||
|
work_end_datetime = fields.Datetime(string="Work End", readonly=True)
|
||||||
|
|
||||||
|
estimated_hours = fields.Float(string="Estimated Hours", readonly=True)
|
||||||
|
actual_hours = fields.Float(string="Actual Hours")
|
||||||
|
|
||||||
|
source_type = fields.Selection([
|
||||||
|
('timeline', 'Timeline'),
|
||||||
|
('task', 'Task'),
|
||||||
|
], string="Source", readonly=True)
|
||||||
|
|
||||||
|
# task state options
|
||||||
|
state = fields.Selection([
|
||||||
|
('01_in_progress', 'In Progress'),
|
||||||
|
('02_changes_requested', 'Changes Requested'),
|
||||||
|
('03_approved', 'Approved'),
|
||||||
|
('1_done', 'Done'),
|
||||||
|
('1_canceled', 'Cancelled'),
|
||||||
|
('04_waiting_normal', 'Waiting'),
|
||||||
|
], string="Task State", readonly=True)
|
||||||
|
|
||||||
|
status_label = fields.Char(string="Status Label", readonly=True)
|
||||||
|
|
||||||
|
progress_emoji = fields.Char(compute='_compute_progress_emoji', string='Progress Emoji')
|
||||||
|
task_status_color = fields.Integer(compute='_compute_task_status_color', string='Status Color')
|
||||||
|
progress_percentage = fields.Float(compute='_compute_progress_percentage', string='Progress %')
|
||||||
|
is_overdue = fields.Boolean(compute='_compute_is_overdue', string='Is Overdue')
|
||||||
|
is_future = fields.Boolean(compute='_compute_is_future', string='Is Future')
|
||||||
|
|
||||||
|
@api.depends('actual_hours', 'estimated_hours')
|
||||||
|
def _compute_progress_emoji(self):
|
||||||
|
for record in self:
|
||||||
|
if record.actual_hours <= record.estimated_hours:
|
||||||
|
record.progress_emoji = '😊'
|
||||||
|
elif record.actual_hours > record.estimated_hours:
|
||||||
|
record.progress_emoji = '😞'
|
||||||
|
else:
|
||||||
|
record.progress_emoji = ''
|
||||||
|
|
||||||
|
@api.depends('state', 'work_start_datetime', 'work_end_datetime')
|
||||||
|
def _compute_task_status_color(self):
|
||||||
|
today = fields.Date.today()
|
||||||
|
for record in self:
|
||||||
|
if record.state == '1_done':
|
||||||
|
record.task_status_color = 10 # Green
|
||||||
|
elif record.state == '1_canceled':
|
||||||
|
record.task_status_color = 0
|
||||||
|
elif record.work_start_datetime and record.work_start_datetime.date() > today:
|
||||||
|
record.task_status_color = 0 # Gray
|
||||||
|
elif record.work_end_datetime and record.work_end_datetime.date() < today and record.state != '1_done':
|
||||||
|
record.task_status_color = 1 # Red
|
||||||
|
else:
|
||||||
|
record.task_status_color = 8 # Default blue
|
||||||
|
|
||||||
|
@api.depends('actual_hours', 'estimated_hours')
|
||||||
|
def _compute_progress_percentage(self):
|
||||||
|
for record in self:
|
||||||
|
if record.estimated_hours > 0:
|
||||||
|
record.progress_percentage = min(100, (record.actual_hours / record.estimated_hours) * 100)
|
||||||
|
else:
|
||||||
|
record.progress_percentage = 0
|
||||||
|
|
||||||
|
@api.depends('work_end_datetime', 'state')
|
||||||
|
def _compute_is_overdue(self):
|
||||||
|
today = fields.Date.today()
|
||||||
|
for record in self:
|
||||||
|
record.is_overdue = (record.work_end_datetime and record.work_end_datetime.date() < today and record.state != '1_done') or (record.actual_hours > record.estimated_hours)
|
||||||
|
|
||||||
|
@api.depends('work_start_datetime')
|
||||||
|
def _compute_is_future(self):
|
||||||
|
today = fields.Date.today()
|
||||||
|
for record in self:
|
||||||
|
record.is_future = record.work_start_datetime and record.work_start_datetime.date() > today
|
||||||
|
@api.depends('task_id', 'task_sequence', 'stage_id')
|
||||||
|
def _compute_display_name(self):
|
||||||
|
for rec in self:
|
||||||
|
rec.display_name = f"{rec.task_sequence}" if rec.task_sequence else rec.task_id.name
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# CREATE SQL VIEW
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
def init(self):
|
||||||
|
self.env.cr.execute("""DROP VIEW IF EXISTS user_task_availability CASCADE;""")
|
||||||
|
self.env.cr.execute("""
|
||||||
|
CREATE OR REPLACE VIEW user_task_availability AS (
|
||||||
|
|
||||||
|
-----------------------------------------------------------------
|
||||||
|
-- 1️⃣ MAIN SOURCE: project.task.time.lines
|
||||||
|
-- Only when timelines_requested + show_approval_flow are TRUE
|
||||||
|
-----------------------------------------------------------------
|
||||||
|
SELECT
|
||||||
|
ROW_NUMBER() OVER () AS id,
|
||||||
|
tl.assigned_to AS user_id,
|
||||||
|
tl.task_id AS task_id,
|
||||||
|
t.project_id AS project_id,
|
||||||
|
tl.stage_id AS stage_id,
|
||||||
|
t.name AS task_name,
|
||||||
|
t.sequence_name AS task_sequence,
|
||||||
|
t.is_generic AS is_generic,
|
||||||
|
|
||||||
|
tl.estimated_start_datetime AS work_start_datetime,
|
||||||
|
tl.estimated_end_datetime AS work_end_datetime,
|
||||||
|
|
||||||
|
tl.estimated_time AS estimated_hours,
|
||||||
|
tl.actual_time AS actual_hours,
|
||||||
|
|
||||||
|
'timeline' AS source_type,
|
||||||
|
|
||||||
|
t.state AS state,
|
||||||
|
|
||||||
|
CASE
|
||||||
|
WHEN t.state = '01_in_progress' THEN 'In Progress'
|
||||||
|
WHEN t.state = '02_changes_requested' THEN 'Changes Requested'
|
||||||
|
WHEN t.state = '03_approved' THEN 'Approved'
|
||||||
|
WHEN t.state = '1_done' THEN 'Done'
|
||||||
|
WHEN t.state = '1_canceled' THEN 'Cancelled'
|
||||||
|
WHEN t.state = '04_waiting_normal' THEN 'Waiting'
|
||||||
|
ELSE 'Unknown'
|
||||||
|
END AS status_label
|
||||||
|
|
||||||
|
FROM project_task_time_lines tl
|
||||||
|
JOIN project_task t ON t.id = tl.task_id
|
||||||
|
|
||||||
|
WHERE t.timelines_requested = TRUE
|
||||||
|
AND t.show_approval_flow = TRUE
|
||||||
|
AND tl.assigned_to IS NOT NULL
|
||||||
|
AND tl.estimated_start_datetime IS NOT NULL
|
||||||
|
AND tl.estimated_end_datetime IS NOT NULL
|
||||||
|
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-----------------------------------------------------------------
|
||||||
|
-- 2️⃣ FALLBACK SOURCE: project.task + user_ids (Many2many)
|
||||||
|
-- Only tasks WITHOUT timeline entries for that user
|
||||||
|
-----------------------------------------------------------------
|
||||||
|
SELECT
|
||||||
|
ROW_NUMBER() OVER () + 1000000 AS id, -- avoid overlap
|
||||||
|
rel.user_id AS user_id,
|
||||||
|
t.id AS task_id,
|
||||||
|
t.project_id AS project_id,
|
||||||
|
t.stage_id AS stage_id,
|
||||||
|
t.name AS task_name,
|
||||||
|
t.sequence_name AS task_sequence,
|
||||||
|
t.is_generic AS is_generic,
|
||||||
|
|
||||||
|
COALESCE(t.date_assign, t.create_date) AS work_start_datetime,
|
||||||
|
t.date_deadline AS work_end_datetime,
|
||||||
|
|
||||||
|
t.estimated_hours AS estimated_hours,
|
||||||
|
t.actual_hours AS actual_hours,
|
||||||
|
|
||||||
|
'task' AS source_type,
|
||||||
|
|
||||||
|
t.state AS state,
|
||||||
|
|
||||||
|
CASE
|
||||||
|
WHEN t.state = '01_in_progress' THEN 'In Progress'
|
||||||
|
WHEN t.state = '02_changes_requested' THEN 'Changes Requested'
|
||||||
|
WHEN t.state = '03_approved' THEN 'Approved'
|
||||||
|
WHEN t.state = '1_done' THEN 'Done'
|
||||||
|
WHEN t.state = '1_canceled' THEN 'Cancelled'
|
||||||
|
WHEN t.state = '04_waiting_normal' THEN 'Waiting'
|
||||||
|
ELSE 'Unknown'
|
||||||
|
END AS status_label
|
||||||
|
|
||||||
|
FROM project_task t
|
||||||
|
JOIN project_task_user_rel rel ON rel.task_id = t.id
|
||||||
|
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM project_task_time_lines tl2
|
||||||
|
WHERE tl2.task_id = t.id
|
||||||
|
AND tl2.assigned_to = rel.user_id
|
||||||
|
AND tl2.estimated_start_datetime IS NOT NULL
|
||||||
|
AND tl2.estimated_end_datetime IS NOT NULL
|
||||||
|
)
|
||||||
|
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
@ -19,3 +19,5 @@ access_project_tags_supervisor,project.project_tags_supervisor,project.model_pro
|
||||||
|
|
||||||
access_project_task_time_lines_user,access_project_task_time_lines_user,model_project_task_time_lines,base.group_user,1,1,1,1
|
access_project_task_time_lines_user,access_project_task_time_lines_user,model_project_task_time_lines,base.group_user,1,1,1,1
|
||||||
access_project_task_time_lines_manager,access_project_task_time_lines_manager,model_project_task_time_lines,project.group_project_manager,1,1,1,1
|
access_project_task_time_lines_manager,access_project_task_time_lines_manager,model_project_task_time_lines,project.group_project_manager,1,1,1,1
|
||||||
|
|
||||||
|
access_user_task_availability,user.task.availability.access,model_user_task_availability,base.group_user,1,0,0,0
|
||||||
|
|
|
@ -65,6 +65,19 @@
|
||||||
<field name="perm_unlink" eval="0"/>
|
<field name="perm_unlink" eval="0"/>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
<record model="ir.rule" id="user_task_availability_project_lead_rule">
|
||||||
|
<field name="name">Task Availability: project lead: see all user tasks</field>
|
||||||
|
<field name="model_id" ref="model_user_task_availability"/>
|
||||||
|
<field name="groups" eval="[(4,ref('base.group_user')),(4,ref('project.group_project_user'))]"/>
|
||||||
|
<field name="domain_force">[
|
||||||
|
'|', '|',
|
||||||
|
('project_id.project_lead', '=', user.id),
|
||||||
|
('user_id', '=', user.id),
|
||||||
|
('project_id.user_id', '=', user.id),
|
||||||
|
]
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
<!-- <record model="ir.rule" id="timesheet_users_normal_timesheets">-->
|
<!-- <record model="ir.rule" id="timesheet_users_normal_timesheets">-->
|
||||||
<!-- <field name="name">timesheet: users: see own tasks</field>-->
|
<!-- <field name="name">timesheet: users: see own tasks</field>-->
|
||||||
<!-- <field name="model_id" ref="analytic.model_account_analytic_line"/>-->
|
<!-- <field name="model_id" ref="analytic.model_account_analytic_line"/>-->
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<odoo>
|
||||||
|
<record id="project_gantt.project_task_view_gantt" model="ir.ui.view">
|
||||||
|
<field name="name">project.task.view.gantt</field>
|
||||||
|
<field name="model">project.task</field>
|
||||||
|
<field name="priority">10</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<gantt date_start="planned_date_begin"
|
||||||
|
form_view_id="%(project_gantt.project_task_view_form_in_gantt)d"
|
||||||
|
date_stop="date_deadline"
|
||||||
|
default_scale="month"
|
||||||
|
scales="day,week,month,year"
|
||||||
|
color="project_id"
|
||||||
|
string="Planning"
|
||||||
|
js_class="task_gantt"
|
||||||
|
display_unavailability="1"
|
||||||
|
precision="{'day': 'hour:quarter', 'week': 'day:half', 'month': 'day:half'}"
|
||||||
|
decoration-danger="planning_overlap"
|
||||||
|
default_group_by="user_ids"
|
||||||
|
progress_bar="user_ids"
|
||||||
|
pill_label="True"
|
||||||
|
total_row="True"
|
||||||
|
dependency_field="depend_on_ids"
|
||||||
|
dependency_inverted_field="dependent_ids">
|
||||||
|
<templates>
|
||||||
|
<div t-name="gantt-popover">
|
||||||
|
<div name="project_id">
|
||||||
|
<strong>Project — </strong>
|
||||||
|
<t t-if="project_id" t-esc="project_id[1]"/>
|
||||||
|
<t t-else=""><span class="fst-italic text-muted"><i class="fa fa-lock"></i> Private</span></t>
|
||||||
|
</div>
|
||||||
|
<div t-if="allow_milestones and milestone_id" groups="project.group_project_milestone">
|
||||||
|
<strong>Milestone — </strong> <t t-esc="milestone_id[1]"/>
|
||||||
|
</div>
|
||||||
|
<div t-if="user_names"><strong>Assignees — </strong> <t t-esc="user_names"/></div>
|
||||||
|
<div t-if="partner_id"><strong>Customer — </strong> <t t-esc="partner_id[1]"/></div>
|
||||||
|
<div t-if="project_id" name="allocated_hours"><strong>Allocated Time — </strong> <t t-esc="allocated_hours"/></div>
|
||||||
|
<div t-if="project_id">
|
||||||
|
<t t-esc="planned_date_begin.toFormat('f ')"/>
|
||||||
|
<i class="fa fa-long-arrow-right" title="Arrow"/>
|
||||||
|
<t t-esc="date_deadline.toFormat(' f')"/>
|
||||||
|
</div>
|
||||||
|
<div class="text-danger mt-2" t-if="planning_overlap">
|
||||||
|
<t t-out="planningOverlapHtml"/>
|
||||||
|
</div>
|
||||||
|
<footer replace="0">
|
||||||
|
<button name="action_unschedule_task" type="object" string="Unschedule" class="btn btn-sm btn-secondary"/>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</templates>
|
||||||
|
<field name="project_id"/>
|
||||||
|
<field name="allow_milestones"/>
|
||||||
|
<field name="milestone_id"/>
|
||||||
|
<field name="user_ids"/>
|
||||||
|
<field name="user_names"/>
|
||||||
|
<field name="partner_id"/>
|
||||||
|
<field name="planning_overlap"/>
|
||||||
|
<field name="allocated_hours"/>
|
||||||
|
</gantt>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
</h1>
|
</h1>
|
||||||
</group>
|
</group>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
|
||||||
<xpath expr="//field[@name='user_id']" position="after">
|
<xpath expr="//field[@name='user_id']" position="after">
|
||||||
<field name="project_lead" widget="many2one_avatar_user"/>
|
<field name="project_lead" widget="many2one_avatar_user"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
|
@ -80,4 +81,17 @@
|
||||||
</page>
|
</page>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
<record id="project_invoice_form_inherit" model="ir.ui.view">
|
||||||
|
<field name="name">project.invoice.inherit.form.view</field>
|
||||||
|
<field name="model">project.project</field>
|
||||||
|
<field name="inherit_id" ref="hr_timesheet.project_invoice_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//field[@name='allocated_hours']" position="after">
|
||||||
|
<field name="estimated_hours" widget="timesheet_uom_no_toggle" invisible="not allow_timesheets" groups="hr_timesheet.group_hr_timesheet_user"/>
|
||||||
|
<field name="task_estimated_hours" widget="timesheet_uom_no_toggle" invisible="not allow_timesheets" groups="hr_timesheet.group_hr_timesheet_user"/>
|
||||||
|
<field name="actual_hours" widget="timesheet_uom_no_toggle" invisible="not allow_timesheets" groups="hr_timesheet.group_hr_timesheet_user"/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|
@ -23,23 +23,33 @@
|
||||||
<!-- <field name="assigned_team"/>-->
|
<!-- <field name="assigned_team"/>-->
|
||||||
<!-- </xpath>-->
|
<!-- </xpath>-->
|
||||||
<xpath expr="//field[@name='user_ids']" position="after">
|
<xpath expr="//field[@name='user_ids']" position="after">
|
||||||
<field name="is_generic"/>
|
<field name="is_generic" readonly="not has_supervisor_access"/>
|
||||||
<field name="record_paused" invisible="1"/>
|
<field name="record_paused" invisible="1"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
<!-- <xpath expr="//field[@name='allocated_hours']" position="after">-->
|
||||||
|
<!-- <field name="estimated_hours"/>-->
|
||||||
|
<!-- <field name="actual_hours"/>-->
|
||||||
|
<!-- </xpath>-->
|
||||||
|
|
||||||
<xpath expr="//sheet/notebook" position="inside">
|
<xpath expr="//sheet/notebook" position="inside">
|
||||||
<page string="Assignees Timelines" invisible="not show_approval_flow">
|
<page string="Assignees Timelines" invisible="not show_approval_flow">
|
||||||
<button name="button_update_assignees" type="object" string="Update Assignees" class="oe_highlight"/>
|
<button name="button_update_assignees" type="object" string="Update Assignees" class="oe_highlight"/>
|
||||||
|
<button name="action_assign_approx_deadlines" type="object" string="Assign Approx Timeline" class="oe_highlight"/>
|
||||||
<field name="assignees_timelines" context="{'default_task_id': id}" >
|
<field name="assignees_timelines" context="{'default_task_id': id}" >
|
||||||
<list editable="bottom">
|
<list editable="bottom">
|
||||||
<field name="stage_id"/>
|
<field name="stage_id" readonly="not has_edit_access"/>
|
||||||
<field name="responsible_lead"/>
|
<field name="responsible_lead" readonly="not has_edit_access"/>
|
||||||
<field name="team_id"/>
|
<field name="team_id" readonly="not has_edit_access"/>
|
||||||
<field name="assigned_to"/>
|
<field name="assigned_to" readonly="not has_edit_access"/>
|
||||||
<!-- <field name="team_all_member_ids" widget="many2many_tags"/>-->
|
<!-- <field name="team_all_member_ids" widget="many2many_tags"/>-->
|
||||||
<field name="estimated_time" widget="float_time"/>
|
<field name="estimated_time" widget="float_time" readonly="not has_edit_access or estimated_time_readonly"/>
|
||||||
<field name="actual_time" readonly="1" optional="hide" widget="float_time"/>
|
<field name="actual_time" readonly="1" optional="hide" widget="float_time"/>
|
||||||
<field name="request_date" readonly="1" optional="hide"/>
|
<field name="has_edit_access" column_invisible="True" optional="hide" readonly="1"/>
|
||||||
<field name="done_date" readonly="1" optional="hide"/>
|
<field name="estimated_time_readonly" column_invisible="True" optional="hide" readonly="1"/>
|
||||||
|
<field name="estimated_start_datetime" optional="show" readonly="not has_edit_access"/>
|
||||||
|
<field name="estimated_end_datetime" optional="show" readonly="not has_edit_access"/>
|
||||||
|
<!-- <field name="request_date" readonly="1" optional="hide"/>-->
|
||||||
|
<!-- <field name="done_date" readonly="1" optional="hide"/>-->
|
||||||
</list>
|
</list>
|
||||||
</field>
|
</field>
|
||||||
</page>
|
</page>
|
||||||
|
|
@ -48,11 +58,11 @@
|
||||||
</page>
|
</page>
|
||||||
</xpath>
|
</xpath>
|
||||||
<xpath expr="//header" position="inside">
|
<xpath expr="//header" position="inside">
|
||||||
<button type="object" name="request_timelines" string="Request Timelines" class="oe_highlight" invisible="not show_approval_flow or timelines_requested"/>
|
<button type="object" name="request_timelines" string="Request Timelines" class="oe_highlight" invisible="not show_approval_flow or timelines_requested or record_paused"/>
|
||||||
<button type="object" name="submit_for_approval" string="Request Approval" class="oe_highlight" invisible="not show_approval_flow or not show_submission_button or not timelines_requested"/>
|
<button type="object" name="submit_for_approval" string="Request Approval" class="oe_highlight" invisible="not show_approval_flow or not show_submission_button or not timelines_requested or record_paused"/>
|
||||||
<button type="object" name="proceed_further" string="Approve & Proceed" class="oe_highlight" invisible="not show_approval_flow or not show_approval_button or not timelines_requested"/>
|
<button type="object" name="proceed_further" string="Approve & Proceed" class="oe_highlight" invisible="not show_approval_flow or not show_approval_button or not timelines_requested or record_paused"/>
|
||||||
<button type="object" name="action_open_reject_wizard" string="Reject & Return" class="oe_highlight" invisible="not show_approval_flow or not show_refuse_button or not timelines_requested"/>
|
<button type="object" name="action_open_reject_wizard" string="Reject & Return" class="oe_highlight" invisible="not show_approval_flow or not show_refuse_button or not timelines_requested or record_paused"/>
|
||||||
<button type="object" name="back_button" string="Go Back" class="oe_highlight" invisible="not show_approval_flow or not show_back_button or not timelines_requested"/>
|
<button type="object" name="back_button" string="Go Back" class="oe_highlight" invisible="not show_approval_flow or not show_back_button or not timelines_requested or record_paused"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
<xpath expr="//form" position="inside">
|
<xpath expr="//form" position="inside">
|
||||||
<field name="approval_status" invisible="1"/>
|
<field name="approval_status" invisible="1"/>
|
||||||
|
|
@ -62,6 +72,7 @@
|
||||||
<field name="show_approval_button" invisible="1"/>
|
<field name="show_approval_button" invisible="1"/>
|
||||||
<field name="show_refuse_button" invisible="1"/>
|
<field name="show_refuse_button" invisible="1"/>
|
||||||
<field name="show_back_button" invisible="1"/>
|
<field name="show_back_button" invisible="1"/>
|
||||||
|
<field name="has_supervisor_access" invisible="1"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
<xpath expr="//field[@name='stage_id']" position="attributes">
|
<xpath expr="//field[@name='stage_id']" position="attributes">
|
||||||
<attribute name="readonly">show_approval_flow or state in ['1_canceled','04_waiting_normal'] or record_paused</attribute>
|
<attribute name="readonly">show_approval_flow or state in ['1_canceled','04_waiting_normal'] or record_paused</attribute>
|
||||||
|
|
@ -76,6 +87,47 @@
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
<record id="view_task_form2_inherited_timesheet" model="ir.ui.view">
|
||||||
|
<field name="name">project.task.form.inherit</field>
|
||||||
|
<field name="model">project.task</field>
|
||||||
|
<field name="inherit_id" ref="hr_timesheet.view_task_form2_inherited"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//label[@for='allocated_hours']" position="before">
|
||||||
|
<div role="alert" class="alert alert-warning d-flex flex-wrap gap-3"
|
||||||
|
invisible="not is_suggested_deadline_warning">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
|
||||||
|
<i class="fa fa-exclamation-triangle text-danger me-2" role="img" title="Deadline Issue"/>
|
||||||
|
Based on the timelines, the deadline can't be met
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
Suggested Deadline:
|
||||||
|
<field name="suggested_deadline" widget="date"/>
|
||||||
|
<field name="is_suggested_deadline_warning" invisible="1"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- <div>-->
|
||||||
|
<!-- <field name="suggested_deadline" invisible="not suggested_deadline or not show_approval_flow or not timelines_requested"/>-->
|
||||||
|
<!-- <field name="is_suggested_deadline_warning" invisible="1"/>-->
|
||||||
|
<!-- <label for="suggested_deadline"-->
|
||||||
|
<!-- class="text-warning"-->
|
||||||
|
<!-- invisible="not is_suggested_deadline_warning">-->
|
||||||
|
<!-- ⚠ Based on the timelines, the deadline can't be met-->
|
||||||
|
<!-- </label>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
<field name="estimated_hours" widget="timesheet_uom_no_toggle" readonly="show_approval_flow and timelines_requested"/>
|
||||||
|
<field name="actual_hours" widget="timesheet_uom_no_toggle"/>
|
||||||
|
<field name="is_suggested_deadline_warning" />
|
||||||
|
</xpath>
|
||||||
|
<xpath expr="//page[@name='page_timesheets']/field[@name='timesheet_ids']/list/field[@name='name']" position="after">
|
||||||
|
<field name="stage_id" required="0" readonly="readonly_timesheet" options="{'no_create': True,'no_quick_create': True, 'no_create_edit': True, 'no_open': True}"/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
<record id="view_task_kanban_inherit" model="ir.ui.view">
|
<record id="view_task_kanban_inherit" model="ir.ui.view">
|
||||||
<field name="name">project.task.kanban.inherit</field>
|
<field name="name">project.task.kanban.inherit</field>
|
||||||
<field name="model">project.task</field>
|
<field name="model">project.task</field>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,367 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
|
||||||
|
<odoo>
|
||||||
|
<!-- <record id="project_gantt.project_task_view_gantt" model="ir.ui.view">-->
|
||||||
|
<!-- <field name="name">project.task.view.gantt.custom</field>-->
|
||||||
|
<!-- <field name="model">project.task</field>-->
|
||||||
|
<!-- <field name="priority">20</field>-->
|
||||||
|
<!-- <field name="arch" type="xml">-->
|
||||||
|
<!-- <gantt date_start="gantt_date_start"-->
|
||||||
|
<!-- date_stop="date_deadline"-->
|
||||||
|
<!-- default_scale="month"-->
|
||||||
|
<!-- scales="day,week,month,year"-->
|
||||||
|
<!-- color="gantt_color"-->
|
||||||
|
<!-- string="Task Planning"-->
|
||||||
|
<!-- js_class="task_gantt"-->
|
||||||
|
<!-- display_unavailability="1"-->
|
||||||
|
<!-- precision="{'day': 'hour:quarter', 'week': 'day:half', 'month': 'day:half'}"-->
|
||||||
|
<!-- decoration-danger="is_overdue"-->
|
||||||
|
<!-- decoration-info="priority == '1'"-->
|
||||||
|
<!-- default_group_by="gantt_user_id"-->
|
||||||
|
<!-- progress_bar="user_ids"-->
|
||||||
|
<!-- pill_label="True"-->
|
||||||
|
<!-- total_row="True"-->
|
||||||
|
<!-- dependency_field="depend_on_ids"-->
|
||||||
|
<!-- dependency_inverted_field="dependent_ids">-->
|
||||||
|
<!-- <templates>-->
|
||||||
|
<!-- <div t-name="gantt-popover">-->
|
||||||
|
<!-- <div class="o_gantt_popover_header">-->
|
||||||
|
<!-- <strong t-esc="name"/>-->
|
||||||
|
<!-- <span t-if="priority == '1'" class="float-end text-warning">-->
|
||||||
|
<!-- <i class="fa fa-star"/>-->
|
||||||
|
<!-- High Priority-->
|
||||||
|
<!-- </span>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
|
||||||
|
<!-- <div name="project_id">-->
|
||||||
|
<!-- <strong>Project —</strong>-->
|
||||||
|
<!-- <t t-if="project_id" t-esc="project_id[1]"/>-->
|
||||||
|
<!-- <t t-else="">-->
|
||||||
|
<!-- <span class="fst-italic text-muted">-->
|
||||||
|
<!-- <i class="fa fa-lock"></i>-->
|
||||||
|
<!-- Private-->
|
||||||
|
<!-- </span>-->
|
||||||
|
<!-- </t>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
|
||||||
|
<!-- <div t-if="show_approval_flow and current_stage_performance">-->
|
||||||
|
<!-- <strong>Performance —</strong>-->
|
||||||
|
<!-- <t t-if="current_stage_performance == 'good'">-->
|
||||||
|
<!-- <span class="text-success">-->
|
||||||
|
<!-- <i class="fa fa-smile-o"></i>-->
|
||||||
|
<!-- Good (Actual < Estimated)-->
|
||||||
|
<!-- </span>-->
|
||||||
|
<!-- </t>-->
|
||||||
|
<!-- <t t-elif="current_stage_performance == 'normal'">-->
|
||||||
|
<!-- <span class="text-primary">-->
|
||||||
|
<!-- <i class="fa fa-meh-o"></i>-->
|
||||||
|
<!-- Normal (Actual == Estimated)-->
|
||||||
|
<!-- </span>-->
|
||||||
|
<!-- </t>-->
|
||||||
|
<!-- <t t-elif="current_stage_performance == 'bad'">-->
|
||||||
|
<!-- <span class="text-danger">-->
|
||||||
|
<!-- <i class="fa fa-frown-o"></i>-->
|
||||||
|
<!-- Bad (Actual > Estimated)-->
|
||||||
|
<!-- </span>-->
|
||||||
|
<!-- </t>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
|
||||||
|
<!-- <div t-if="allow_milestones and milestone_id" groups="project.group_project_milestone">-->
|
||||||
|
<!-- <strong>Milestone —</strong>-->
|
||||||
|
<!-- <t t-esc="milestone_id[1]"/>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
|
||||||
|
<!-- <div t-if="user_names">-->
|
||||||
|
<!-- <strong>Assignees —</strong>-->
|
||||||
|
<!-- <t t-esc="user_names"/>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
<!-- <div t-if="partner_id">-->
|
||||||
|
<!-- <strong>Customer —</strong>-->
|
||||||
|
<!-- <t t-esc="partner_id[1]"/>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
|
||||||
|
<!-- <div t-if="show_approval_flow">-->
|
||||||
|
<!-- <strong>Timeline —</strong>-->
|
||||||
|
<!-- <t t-esc="estimated_hours"/>-->
|
||||||
|
<!-- hours estimated /-->
|
||||||
|
<!-- <t t-esc="actual_hours"/>-->
|
||||||
|
<!-- hours actual-->
|
||||||
|
<!-- </div>-->
|
||||||
|
|
||||||
|
<!-- <div t-if="project_id" name="allocated_hours">-->
|
||||||
|
<!-- <strong>Allocated Time —</strong>-->
|
||||||
|
<!-- <t t-esc="allocated_hours"/>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
|
||||||
|
<!-- <div t-if="formatted_start_date and formatted_end_date">-->
|
||||||
|
<!-- <strong>Dates: </strong>-->
|
||||||
|
<!-- <t t-esc="formatted_start_date"/>-->
|
||||||
|
<!-- <i class="fa fa-long-arrow-right" title="Arrow"/>-->
|
||||||
|
<!-- <t t-esc="formatted_end_date"/>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
<!-- <div t-elif="formatted_start_date">-->
|
||||||
|
<!-- <strong>Start Date: </strong>-->
|
||||||
|
<!-- <t t-esc="formatted_start_date"/>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
<!-- <div t-elif="formatted_end_date">-->
|
||||||
|
<!-- <strong>Deadline: </strong>-->
|
||||||
|
<!-- <t t-esc="formatted_end_date"/>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
|
||||||
|
<!-- <div class="text-danger mt-2" t-if="planning_overlap">-->
|
||||||
|
<!-- <t t-out="planningOverlapHtml"/>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
|
||||||
|
<!-- <footer replace="0">-->
|
||||||
|
<!-- <button name="action_unschedule_task" type="object" string="Unschedule"-->
|
||||||
|
<!-- class="btn btn-sm btn-secondary"/>-->
|
||||||
|
<!-- </footer>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
|
||||||
|
<!-- <div t-name="gantt-pulse">-->
|
||||||
|
<!-- <div class="o_gantt_pill">-->
|
||||||
|
<!-- <t t-if="priority == '1'">-->
|
||||||
|
<!-- <i class="fa fa-star text-warning me-1"/>-->
|
||||||
|
<!-- </t>-->
|
||||||
|
<!-- <t t-if="show_approval_flow and performance_icon">-->
|
||||||
|
<!-- <i t-attf-class="fa {{performance_icon}} me-1"/>-->
|
||||||
|
<!-- </t>-->
|
||||||
|
<!-- <t t-esc="name"/>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
<!-- </templates>-->
|
||||||
|
|
||||||
|
<!-- <field name="project_id"/>-->
|
||||||
|
<!-- <field name="allow_milestones"/>-->
|
||||||
|
<!-- <field name="milestone_id"/>-->
|
||||||
|
<!-- <field name="user_ids"/>-->
|
||||||
|
<!-- <field name="user_names"/>-->
|
||||||
|
<!-- <field name="partner_id"/>-->
|
||||||
|
<!-- <field name="planning_overlap"/>-->
|
||||||
|
<!-- <field name="allocated_hours"/>-->
|
||||||
|
<!-- <field name="gantt_color"/>-->
|
||||||
|
<!-- <field name="gantt_user_id"/>-->
|
||||||
|
<!-- <field name="performance_icon"/>-->
|
||||||
|
<!-- <field name="show_approval_flow"/>-->
|
||||||
|
<!-- <field name="priority"/>-->
|
||||||
|
<!-- <field name="current_stage_performance"/>-->
|
||||||
|
<!-- <field name="estimated_hours"/>-->
|
||||||
|
<!-- <field name="actual_hours"/>-->
|
||||||
|
<!-- <field name="is_overdue"/>-->
|
||||||
|
<!-- <field name="gantt_date_start"/>-->
|
||||||
|
<!-- <field name="planned_date_begin"/>-->
|
||||||
|
<!-- <field name="date_deadline"/>-->
|
||||||
|
<!-- <field name="formatted_start_date"/>-->
|
||||||
|
<!-- <field name="formatted_end_date"/>-->
|
||||||
|
<!-- </gantt>-->
|
||||||
|
<!-- </field>-->
|
||||||
|
<!-- </record>-->
|
||||||
|
<record id="project_gantt.project_task_view_gantt" model="ir.ui.view">
|
||||||
|
<field name="name">project.task.view.gantt.enhanced</field>
|
||||||
|
<field name="model">project.task</field>
|
||||||
|
<field name="priority">30</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<gantt date_start="gantt_date_start"
|
||||||
|
date_stop="date_deadline"
|
||||||
|
default_scale="month"
|
||||||
|
scales="day,week,month,year"
|
||||||
|
color="gantt_color"
|
||||||
|
string="Task Planning"
|
||||||
|
js_class="task_gantt"
|
||||||
|
display_unavailability="1"
|
||||||
|
precision="{'day': 'hour:quarter', 'week': 'day:half', 'month': 'day:half'}"
|
||||||
|
decoration-danger="is_overdue"
|
||||||
|
decoration-info="task_priority == '1'"
|
||||||
|
decoration-warning="task_priority == '2'"
|
||||||
|
decoration-success="is_completed"
|
||||||
|
default_group_by="gantt_user_id"
|
||||||
|
progress_bar="progress"
|
||||||
|
pill_label="True"
|
||||||
|
total_row="True"
|
||||||
|
dependency_field="depend_on_ids"
|
||||||
|
dependency_inverted_field="dependent_ids">
|
||||||
|
<templates>
|
||||||
|
<div t-name="gantt-popover">
|
||||||
|
<div class="o_gantt_popover_header d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<strong t-esc="name"/>
|
||||||
|
<span t-if="task_priority == '1'" class="badge bg-warning ms-2">
|
||||||
|
<i class="fa fa-exclamation"/> High Priority
|
||||||
|
</span>
|
||||||
|
<span t-if="task_priority == '2'" class="badge bg-danger ms-2">
|
||||||
|
<i class="fa fa-fire"/> Urgent
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span t-if="is_completed" class="badge bg-success">
|
||||||
|
<i class="fa fa-check-circle"/> Completed
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<div name="project_id">
|
||||||
|
<strong>Project —</strong>
|
||||||
|
<t t-if="project_id" t-esc="project_id[1]"/>
|
||||||
|
<t t-else="">
|
||||||
|
<span class="fst-italic text-muted">
|
||||||
|
<i class="fa fa-lock"></i> Private
|
||||||
|
</span>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div t-if="show_approval_flow and current_stage_performance and not is_completed" class="mt-1">
|
||||||
|
<strong>Performance —</strong>
|
||||||
|
<t t-if="current_stage_performance == 'good'">
|
||||||
|
<span class="text-success">
|
||||||
|
<i class="fa fa-smile-o"></i> Good (Actual < Estimated)
|
||||||
|
</span>
|
||||||
|
</t>
|
||||||
|
<t t-elif="current_stage_performance == 'normal'">
|
||||||
|
<span class="text-primary">
|
||||||
|
<i class="fa fa-meh-o"></i> Normal (Actual == Estimated)
|
||||||
|
</span>
|
||||||
|
</t>
|
||||||
|
<t t-elif="current_stage_performance == 'bad'">
|
||||||
|
<span class="text-danger">
|
||||||
|
<i class="fa fa-frown-o"></i> Bad (Actual > Estimated)
|
||||||
|
</span>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div t-if="allow_milestones and milestone_id" groups="project.group_project_milestone" class="mt-1">
|
||||||
|
<strong>Milestone —</strong> <t t-esc="milestone_id[1]"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div t-if="user_names" class="mt-1">
|
||||||
|
<strong>Assignees —</strong> <t t-esc="user_names"/>
|
||||||
|
</div>
|
||||||
|
<div t-if="partner_id" class="mt-1">
|
||||||
|
<strong>Customer —</strong> <t t-esc="partner_id[1]"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div t-if="show_approval_flow and not is_completed" class="mt-1">
|
||||||
|
<strong>Timeline —</strong>
|
||||||
|
<t t-esc="estimated_hours"/> hours estimated /
|
||||||
|
<t t-esc="actual_hours"/> hours actual
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div t-if="project_id" name="allocated_hours" class="mt-1">
|
||||||
|
<strong>Allocated Time —</strong> <t t-esc="allocated_hours"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<strong>Progress:</strong>
|
||||||
|
<span><t t-esc="progress"/>%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress mt-1">
|
||||||
|
<div class="progress-bar" role="progressbar"
|
||||||
|
t-attf-style="width: {{progress}}%"
|
||||||
|
t-att-aria-valuenow="progress"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div t-if="formatted_start_date and formatted_end_date" class="mt-2">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<strong>Start:</strong> <t t-esc="formatted_start_date"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>End:</strong> <t t-esc="formatted_end_date"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div t-elif="formatted_start_date" class="mt-1">
|
||||||
|
<strong>Start Date: </strong> <t t-esc="formatted_start_date"/>
|
||||||
|
</div>
|
||||||
|
<div t-elif="formatted_end_date" class="mt-1">
|
||||||
|
<strong>Deadline: </strong> <t t-esc="formatted_end_date"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div t-if="days_remaining != 0 and not is_completed" class="mt-1">
|
||||||
|
<t t-if="days_remaining > 0">
|
||||||
|
<span class="text-success">
|
||||||
|
<i class="fa fa-clock-o"></i> <t t-esc="days_remaining"/> days remaining
|
||||||
|
</span>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<span class="text-danger">
|
||||||
|
<i class="fa fa-exclamation-triangle"></i> <t t-esc="abs(days_remaining)"/> days overdue
|
||||||
|
</span>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-danger mt-2" t-if="planning_overlap">
|
||||||
|
<t t-out="planningOverlapHtml"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="mt-3">
|
||||||
|
<button name="action_unschedule_task" type="object" string="Unschedule" class="btn btn-sm btn-secondary"/>
|
||||||
|
<button name="button_update_assignees" type="object" string="Update Assignees" class="btn btn-sm btn-primary"/>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div t-name="gantt-pulse">
|
||||||
|
<div class="o_gantt_pill d-flex align-items-center">
|
||||||
|
<div class="me-2">
|
||||||
|
<t t-if="task_priority == '1'">
|
||||||
|
<i class="fa fa-exclamation text-warning"/>
|
||||||
|
</t>
|
||||||
|
<t t-elif="task_priority == '2'">
|
||||||
|
<i class="fa fa-fire text-danger"/>
|
||||||
|
</t>
|
||||||
|
<t t-if="is_completed">
|
||||||
|
<i class="fa fa-check-circle text-success"/>
|
||||||
|
</t>
|
||||||
|
<t t-if="show_approval_flow and performance_icon and not is_completed">
|
||||||
|
<i t-attf-class="fa {{performance_icon}}"/>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<t t-esc="name"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</templates>
|
||||||
|
|
||||||
|
<field name="project_id"/>
|
||||||
|
<field name="allow_milestones"/>
|
||||||
|
<field name="milestone_id"/>
|
||||||
|
<field name="user_ids"/>
|
||||||
|
<field name="user_names"/>
|
||||||
|
<field name="partner_id"/>
|
||||||
|
<field name="planning_overlap"/>
|
||||||
|
<field name="allocated_hours"/>
|
||||||
|
<field name="gantt_color"/>
|
||||||
|
<field name="gantt_user_id"/>
|
||||||
|
<field name="performance_icon"/>
|
||||||
|
<field name="show_approval_flow"/>
|
||||||
|
<field name="task_priority"/>
|
||||||
|
<field name="current_stage_performance"/>
|
||||||
|
<field name="estimated_hours"/>
|
||||||
|
<field name="actual_hours"/>
|
||||||
|
<field name="is_overdue"/>
|
||||||
|
<field name="gantt_date_start"/>
|
||||||
|
<field name="planned_date_begin"/>
|
||||||
|
<field name="date_deadline"/>
|
||||||
|
<field name="formatted_start_date"/>
|
||||||
|
<field name="formatted_end_date"/>
|
||||||
|
<field name="state"/>
|
||||||
|
<field name="progress"/>
|
||||||
|
<field name="is_completed"/>
|
||||||
|
<field name="days_remaining"/>
|
||||||
|
</gantt>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
<!-- <record id="project_gantt.project_task_action_from_partner_gantt_view" model="ir.actions.act_window.view">-->
|
||||||
|
<!-- <field name="view_mode">gantt</field>-->
|
||||||
|
<!-- <field name="act_window_id" ref="project.project_task_action_from_partner"/>-->
|
||||||
|
<!-- <field name="view_id" ref="project_task_custom_gantt_view"/>-->
|
||||||
|
<!-- </record>-->
|
||||||
|
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="hr_timesheet_line_tree_inherit" model="ir.ui.view">
|
||||||
|
<field name="name">hr.timesheet.tree.inherit</field>
|
||||||
|
<field name="model">account.analytic.line</field>
|
||||||
|
<field name="inherit_id" ref="hr_timesheet.hr_timesheet_line_tree"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//field[@name='name']" position="after">
|
||||||
|
<field name="stage_id" required="0" optional="show" readonly="readonly_timesheet"
|
||||||
|
options="{'no_create': True,'no_quick_create': True, 'no_create_edit': True, 'no_open': True}"/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="hr_timesheet_line_form_inherit" model="ir.ui.view">
|
||||||
|
<field name="name">hr.timesheet.form.inherit</field>
|
||||||
|
<field name="model">account.analytic.line</field>
|
||||||
|
<field name="inherit_id" ref="hr_timesheet.hr_timesheet_line_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//field[@name='task_id']" position="after">
|
||||||
|
<field name="stage_id" required="0" optional="show" readonly="readonly_timesheet"
|
||||||
|
options="{'no_create': True,'no_quick_create': True, 'no_create_edit': True, 'no_open': True}"/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
|
||||||
|
</odoo>
|
||||||
|
|
@ -0,0 +1,344 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- ===================================================== -->
|
||||||
|
<!-- 1. list VIEW -->
|
||||||
|
<!-- ===================================================== -->
|
||||||
|
<record id="view_user_task_availability_list" model="ir.ui.view">
|
||||||
|
<field name="name">user.task.availability.list</field>
|
||||||
|
<field name="model">user.task.availability</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="User Task Availability" create="false" edit="false" delete="false">
|
||||||
|
<field name="user_id"/>
|
||||||
|
<field name="task_id"/>
|
||||||
|
<field name="is_generic"/>
|
||||||
|
<field name="project_id"/>
|
||||||
|
<field name="status_label"/>
|
||||||
|
<field name="work_start_datetime"/>
|
||||||
|
<field name="work_end_datetime"/>
|
||||||
|
<field name="estimated_hours"/>
|
||||||
|
<field name="actual_hours" optional="hide"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ===================================================== -->
|
||||||
|
<!-- 2. SEARCH FILTERS -->
|
||||||
|
<!-- ===================================================== -->
|
||||||
|
<record id="view_user_task_availability_search" model="ir.ui.view">
|
||||||
|
<field name="name">user.task.availability.search</field>
|
||||||
|
<field name="model">user.task.availability</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Search Task Availability">
|
||||||
|
<field name="task_sequence"/>
|
||||||
|
<!-- FILTER BY STATE -->
|
||||||
|
<filter name="state_in_progress"
|
||||||
|
string="In Progress"
|
||||||
|
domain="[('state','=','01_in_progress')]"/>
|
||||||
|
<filter name="state_closed"
|
||||||
|
string="Closed"
|
||||||
|
domain="[('state','in',['1_done','1_canceled'])]"/>
|
||||||
|
|
||||||
|
<!-- FILTER BY USER -->
|
||||||
|
<filter name="user_me"
|
||||||
|
string="My Tasks"
|
||||||
|
domain="[('user_id','=',uid)]"/>
|
||||||
|
<filter name="is_generic_task"
|
||||||
|
string="Generic Tasks"
|
||||||
|
domain="[('is_generic','=',True)]"/>
|
||||||
|
<filter name="non_generic_task"
|
||||||
|
string="Non-Generic Tasks"
|
||||||
|
domain="[('is_generic','=',False)]"/>
|
||||||
|
|
||||||
|
<!-- GROUP BY USER, STATE, PROJECT -->
|
||||||
|
<group expand="0" string="Group By">
|
||||||
|
<filter name="group_user" string="User" context="{'group_by':'user_id'}"/>
|
||||||
|
<filter name="group_project" string="Project" context="{'group_by':'project_id'}"/>
|
||||||
|
<filter name="group_state" string="State" context="{'group_by':'state'}"/>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<!-- DATE RANGE -->
|
||||||
|
<group expand="0" string="Date">
|
||||||
|
<filter string="Today"
|
||||||
|
name="today"
|
||||||
|
domain="[('work_start_datetime','>=',context_today())]"/>
|
||||||
|
|
||||||
|
<filter string="This Week"
|
||||||
|
name="week"
|
||||||
|
domain="['&',('work_start_datetime','>=',context_today()),('work_start_datetime','<=',(context_today() + datetime.timedelta(days=7)))]"/>
|
||||||
|
</group>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ===================================================== -->
|
||||||
|
<!-- 3. CALENDAR VIEW -->
|
||||||
|
<!-- ===================================================== -->
|
||||||
|
<record id="view_user_task_availability_calendar" model="ir.ui.view">
|
||||||
|
<field name="name">user.task.availability.calendar</field>
|
||||||
|
<field name="model">user.task.availability</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<calendar string="Task Calendar"
|
||||||
|
date_start="work_start_datetime"
|
||||||
|
date_stop="work_end_datetime"
|
||||||
|
color="user_id"
|
||||||
|
create="false">
|
||||||
|
<field name="task_id"/>
|
||||||
|
<field name="user_id"/>
|
||||||
|
<field name="project_id"/>
|
||||||
|
<field name="status_label"/>
|
||||||
|
</calendar>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ===================================================== -->
|
||||||
|
<!-- 4. GANTT VIEW -->
|
||||||
|
<!-- ===================================================== -->
|
||||||
|
<record id="view_user_task_availability_gantt" model="ir.ui.view">
|
||||||
|
<field name="name">user.task.availability.gantt</field>
|
||||||
|
<field name="model">user.task.availability</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<gantt string="User Workload Gantt"
|
||||||
|
date_start="work_start_datetime"
|
||||||
|
date_stop="work_end_datetime"
|
||||||
|
default_scale="week"
|
||||||
|
scales="day,week,month,year"
|
||||||
|
color="task_status_color"
|
||||||
|
default_group_by="user_id"
|
||||||
|
progress_bar="progress_percentage"
|
||||||
|
pill_label="True"
|
||||||
|
total_row="True"
|
||||||
|
decoration-success="state == '1_done'"
|
||||||
|
decoration-danger="state == '1_canceled'"
|
||||||
|
|
||||||
|
decoration-warning="is_overdue"
|
||||||
|
decoration-info="is_future"
|
||||||
|
js_class="task_gantt"
|
||||||
|
display_unavailability="1"
|
||||||
|
precision="{'day': 'hour:quarter', 'week': 'day:half', 'month': 'day:half'}">
|
||||||
|
|
||||||
|
<templates>
|
||||||
|
<div t-name="gantt-popover"
|
||||||
|
style="min-width:300px; max-width:340px; padding:16px 20px 16px 16px; border-radius:6px;">
|
||||||
|
|
||||||
|
|
||||||
|
<!-- HEADER -->
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:16px; font-weight:600;" t-esc="task_name"/>
|
||||||
|
<t t-if="progress_emoji">
|
||||||
|
<span class="me-1" t-esc="progress_emoji"/>
|
||||||
|
</t>
|
||||||
|
<!-- <div style="font-size:12px; color:#888;" t-esc="task_id[1]"/>-->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span t-if="state == '1_done'"
|
||||||
|
style="background:#d4edda; color:#155724; padding:2px 6px; border-radius:4px; font-size:11px;">
|
||||||
|
✔ Done
|
||||||
|
</span>
|
||||||
|
<span t-if="is_overdue"
|
||||||
|
style="background:#f8d7da; color:#721c24; padding:2px 6px; border-radius:4px; font-size:11px;">
|
||||||
|
⚠ Overdue
|
||||||
|
</span>
|
||||||
|
<span t-if="is_future"
|
||||||
|
style="background:#d1ecf1; color:#0c5460; padding:2px 6px; border-radius:4px; font-size:11px;">
|
||||||
|
⏱ Scheduled
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GRID (NO BOOTSTRAP) -->
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px; font-size:13px;">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style="color:#888;">Assigned To</div>
|
||||||
|
<div style="font-weight:600;" t-esc="user_id[1]"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style="color:#888;">Project</div>
|
||||||
|
<div style="font-weight:600;" t-esc="project_id[1]"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style="color:#888;">Stage</div>
|
||||||
|
<div style="font-weight:600;" t-esc="stage_id[1]"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style="color:#888;">Status</div>
|
||||||
|
<div style="font-weight:600;" t-esc="status_label"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="border-top:1px solid #eee; margin:14px 0;"></div>
|
||||||
|
|
||||||
|
<!-- TIME SECTION -->
|
||||||
|
<div style="display:flex; justify-content:space-between; font-size:13px;">
|
||||||
|
<div>
|
||||||
|
<div style="color:#888; margin-bottom:2px;">📅 Start</div>
|
||||||
|
<div style="font-weight:600;"
|
||||||
|
t-esc="work_start_datetime.toFormat('MMM dd, yyyy HH:mm')"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align:right;">
|
||||||
|
<div style="color:#888; margin-bottom:2px;">🏁 End</div>
|
||||||
|
<div style="font-weight:600;" t-esc="work_end_datetime.toFormat('MMM dd, yyyy HH:mm')"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="border-top:1px solid #eee; margin:14px 0;"></div>
|
||||||
|
|
||||||
|
<!-- PROGRESS -->
|
||||||
|
<div>
|
||||||
|
<div style="display:flex; justify-content:space-between; font-size:13px; margin-bottom:5px;">
|
||||||
|
<span style="color:#888;">Progress</span>
|
||||||
|
<span style="font-weight:600;"><t t-esc="progress_percentage"/>%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- <div style="background:#eee; border-radius:4px; height:6px; width:100%; margin-top:4px;">-->
|
||||||
|
<!-- <div style="background:#4c8bf5; height:6px; border-radius:4px;"-->
|
||||||
|
<!-- t-att-style="'width: %s%% !important' % progress_percentage">-->
|
||||||
|
<!-- </div>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="border-top:1px solid #eee; margin:14px 0;"></div>
|
||||||
|
|
||||||
|
<!-- FOOTER -->
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; font-size:13px;">
|
||||||
|
<div>
|
||||||
|
<span style="color:#888;">Source:</span>
|
||||||
|
<span style="background:#f1f1f1; padding:2px 6px; border-radius:4px;"
|
||||||
|
t-esc="source_type"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div t-name="gantt-pulse">
|
||||||
|
<div class="o_gantt_pill d-flex align-items-center shadow-sm">
|
||||||
|
<div class="me-2 d-flex align-items-center">
|
||||||
|
<t t-if="progress_emoji">
|
||||||
|
<span class="me-1" t-esc="progress_emoji"/>
|
||||||
|
</t>
|
||||||
|
<t t-if="state == '1_done'">
|
||||||
|
<i class="fa fa-check-circle text-success me-1"/>
|
||||||
|
</t>
|
||||||
|
<t t-if="is_overdue">
|
||||||
|
<i class="fa fa-exclamation-triangle text-danger me-1"/>
|
||||||
|
</t>
|
||||||
|
<t t-if="is_future">
|
||||||
|
<i class="fa fa-clock-o text-info me-1"/>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="fw-bold" t-esc="task_name"/>
|
||||||
|
<div class="small text-muted">
|
||||||
|
<span t-esc="estimated_hours" widget="timesheet_uom_no_toggle"/>h /
|
||||||
|
<span t-esc="actual_hours" widget="timesheet_uom_no_toggle"/>h
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ms-2">
|
||||||
|
<div class="progress" style="height: 5px; width: 60px;">
|
||||||
|
<div class="progress-bar" role="progressbar"
|
||||||
|
t-attf-style="width: {{progress_percentage}}%"
|
||||||
|
t-att-aria-valuenow="progress_percentage"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</templates>
|
||||||
|
|
||||||
|
<field name="task_id"/>
|
||||||
|
<field name="user_id"/>
|
||||||
|
<field name="project_id"/>
|
||||||
|
<field name="stage_id"/>
|
||||||
|
<field name="task_name"/>
|
||||||
|
<field name="status_label"/>
|
||||||
|
<field name="work_start_datetime"/>
|
||||||
|
<field name="work_end_datetime"/>
|
||||||
|
<field name="estimated_hours"/>
|
||||||
|
<field name="actual_hours"/>
|
||||||
|
<field name="progress_percentage"/>
|
||||||
|
<field name="progress_emoji"/>
|
||||||
|
<field name="task_status_color"/>
|
||||||
|
<field name="state"/>
|
||||||
|
<field name="is_overdue"/>
|
||||||
|
<field name="is_future"/>
|
||||||
|
<field name="source_type"/>
|
||||||
|
</gantt>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ===================================================== -->
|
||||||
|
<!-- 5. KANBAN VIEW -->
|
||||||
|
<!-- “Who is Working Now?” -->
|
||||||
|
<!-- ===================================================== -->
|
||||||
|
<record id="view_user_task_availability_kanban" model="ir.ui.view">
|
||||||
|
<field name="name">user.task.availability.kanban</field>
|
||||||
|
<field name="model">user.task.availability</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<kanban class="o_kanban_mobile" create="false" edit="false">
|
||||||
|
<field name="user_id"/>
|
||||||
|
<field name="task_id"/>
|
||||||
|
<field name="project_id"/>
|
||||||
|
<field name="status_label"/>
|
||||||
|
<field name="work_start_datetime"/>
|
||||||
|
<field name="work_end_datetime"/>
|
||||||
|
|
||||||
|
<templates>
|
||||||
|
<t t-name="kanban-box">
|
||||||
|
<div class="oe_kanban_card">
|
||||||
|
<div class="o_kanban_primary_left">
|
||||||
|
<strong>
|
||||||
|
<field name="user_id"/>
|
||||||
|
</strong>
|
||||||
|
<div>
|
||||||
|
<field name="task_id"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Status:</span>
|
||||||
|
<field name="status_label"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>From:</span>
|
||||||
|
<field name="work_start_datetime"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>To:</span>
|
||||||
|
<field name="work_end_datetime"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
|
|
||||||
|
</kanban>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ===================================================== -->
|
||||||
|
<!-- 6. ACTION & MENU -->
|
||||||
|
<!-- ===================================================== -->
|
||||||
|
<record id="action_user_task_availability" model="ir.actions.act_window">
|
||||||
|
<field name="name">User Task Availability</field>
|
||||||
|
<field name="res_model">user.task.availability</field>
|
||||||
|
<field name="view_mode">list,kanban,calendar,gantt</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<menuitem
|
||||||
|
name="User Task Availability"
|
||||||
|
id="menu_user_task_availability"
|
||||||
|
action="action_user_task_availability"
|
||||||
|
parent="project.menu_project_report"
|
||||||
|
sequence="10"
|
||||||
|
/>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
Loading…
Reference in New Issue