PMS Updates

This commit is contained in:
pranay 2025-11-20 10:06:10 +05:30
parent 4ce02e58fa
commit 0fa84c6d43
16 changed files with 2286 additions and 77 deletions

View File

@ -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': {
}, },

View File

@ -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>

View File

@ -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

View File

@ -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',

View File

@ -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):

View File

@ -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)

View File

@ -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."))

View File

@ -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
)
);
""")

View File

@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
19
20
21
22
23

View File

@ -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"/>-->

View File

@ -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>

View File

@ -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>

View File

@ -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 &amp; 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 &amp; 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 &amp; 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 &amp; 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>

View File

@ -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 &lt; 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 &gt; 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 &lt; 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 &gt; 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>

View File

@ -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>

View File

@ -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="['&amp;',('work_start_datetime','&gt;=',context_today()),('work_start_datetime','&lt;=',(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>