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',
'base',
'analytic',
'project_gantt',
],
'data': [
'security/security.xml',
@ -37,6 +38,10 @@ Key Features:
'view/task_stages.xml',
'view/project.xml',
'view/project_task.xml',
'view/timesheets.xml',
'view/pro_task_gantt.xml',
'view/user_availability.xml',
# 'view/project_task_gantt.xml',
],
'assets': {
},

View File

@ -11,6 +11,20 @@
action = records.action_toggle_pause()
</field>
</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">
<record id="default_projects_channel" model="discuss.channel">
<field name="name">Projects Channel</field>

View File

@ -2,3 +2,6 @@ from . import teams
from . import task_stages
from . import project
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',
string="Project Channel",
string="Channel",
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."
)
@ -159,6 +159,21 @@ class ProjectProject(models.Model):
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):
return {
'type': 'ir.actions.act_window',

View File

@ -1,7 +1,19 @@
from odoo import api, fields, models, _
from markupsafe import Markup
from datetime import datetime
from datetime import datetime, timedelta
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 = {
'1_done': 'Done',
@ -27,8 +39,277 @@ class projectTask(models.Model):
show_refuse_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)
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):
"""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):
"""Toggle pause state for the record"""
for record in self:
record.can_edit_approval_flow_stages = True
current_user = self.env.user
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(
@ -111,6 +393,8 @@ class projectTask(models.Model):
record._post_to_project_channel(channel_message)
# Add to activity log
record.can_edit_approval_flow_stages = False
record._add_activity_log(f"Task {action} by {self.env.user.name}")
@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_refuse_button = False
task.show_back_button = False
if task.project_id:
user = self.env.user
project_manager = task.project_id.user_id
project_lead = task.project_id.project_lead
@ -174,6 +458,7 @@ class projectTask(models.Model):
task.show_approval_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'))
if is_first_stage:
task.show_back_button = False
@ -206,6 +491,7 @@ class projectTask(models.Model):
def back_button(self):
for task in self:
task.can_edit_approval_flow_stages = True
task.approval_status = False
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',
subtype_xmlid='mail.mt_comment',
)
task.can_edit_approval_flow_stages = False
def submit_for_approval(self):
for task in self:
task.can_edit_approval_flow_stages = True
task.approval_status = "submitted"
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
@ -291,8 +580,11 @@ class projectTask(models.Model):
subtype_xmlid='mail.mt_comment',
)
task.can_edit_approval_flow_stages = False
def proceed_further(self):
for task in self:
task.can_edit_approval_flow_stages = True
current_stage = task.stage_id
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)
@ -370,8 +662,11 @@ class projectTask(models.Model):
if is_last_stage:
task.state = '1_done'
task.can_edit_approval_flow_stages = False
def reject_and_return(self, reason=None):
for task in self:
task.can_edit_approval_flow_stages = True
if not reason:
reason = ""
task.approval_status = "refused"
@ -423,6 +718,8 @@ class projectTask(models.Model):
subtype_xmlid='mail.mt_comment',
)
task.can_edit_approval_flow_stages = False
def action_open_reject_wizard(self):
"""Open rejection wizard"""
self.ensure_one()
@ -438,6 +735,7 @@ class projectTask(models.Model):
def request_timelines(self):
"""Populate task timelines with all relevant project stages."""
for task in self:
task.can_edit_approval_flow_stages = True
task.timelines_requested = True
# Clear existing timelines if needed
task.assignees_timelines.unlink()
@ -472,6 +770,7 @@ class projectTask(models.Model):
# Post to project channel about timeline request
channel_message = _("Timelines requested for task %s") % (task.sequence_name or task.name)
task._post_to_project_channel(channel_message)
task.can_edit_approval_flow_stages = False
@api.model_create_multi
def create(self, vals_list):
@ -507,6 +806,20 @@ class projectTask(models.Model):
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):
for task in self:
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)
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):
_name = 'project.task.time.lines'
@ -538,8 +1284,6 @@ class projectTaskTimelines(models.Model):
, compute="_compute_team_members"
)
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")
project_id = fields.Many2one("project.project", related="task_id.project_id")
stage_ids = fields.Many2many(related="project_id.type_ids")
@ -549,8 +1293,52 @@ class projectTaskTimelines(models.Model):
compute="_compute_allowed_teams",
store=False
)
request_date = fields.Date(string="Request Date")
done_date = fields.Date(string="Done Date")
# request_date = fields.Date(string="Request 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')
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_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"/>
</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">-->
<!-- <field name="name">timesheet: users: see own tasks</field>-->
<!-- <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>
</group>
</xpath>
<xpath expr="//field[@name='user_id']" position="after">
<field name="project_lead" widget="many2one_avatar_user"/>
</xpath>
@ -80,4 +81,17 @@
</page>
</field>
</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>

View File

@ -23,23 +23,33 @@
<!-- <field name="assigned_team"/>-->
<!-- </xpath>-->
<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"/>
</xpath>
<!-- <xpath expr="//field[@name='allocated_hours']" position="after">-->
<!-- <field name="estimated_hours"/>-->
<!-- <field name="actual_hours"/>-->
<!-- </xpath>-->
<xpath expr="//sheet/notebook" position="inside">
<page string="Assignees Timelines" invisible="not show_approval_flow">
<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}" >
<list editable="bottom">
<field name="stage_id"/>
<field name="responsible_lead"/>
<field name="team_id"/>
<field name="assigned_to"/>
<field name="stage_id" readonly="not has_edit_access"/>
<field name="responsible_lead" readonly="not has_edit_access"/>
<field name="team_id" readonly="not has_edit_access"/>
<field name="assigned_to" readonly="not has_edit_access"/>
<!-- <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="request_date" readonly="1" optional="hide"/>
<field name="done_date" readonly="1" optional="hide"/>
<field name="has_edit_access" column_invisible="True" optional="hide" readonly="1"/>
<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>
</field>
</page>
@ -48,11 +58,11 @@
</page>
</xpath>
<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="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="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="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="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="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 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 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 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 or record_paused"/>
</xpath>
<xpath expr="//form" position="inside">
<field name="approval_status" invisible="1"/>
@ -62,6 +72,7 @@
<field name="show_approval_button" invisible="1"/>
<field name="show_refuse_button" invisible="1"/>
<field name="show_back_button" invisible="1"/>
<field name="has_supervisor_access" invisible="1"/>
</xpath>
<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>
@ -76,6 +87,47 @@
</field>
</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">
<field name="name">project.task.kanban.inherit</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>