diff --git a/addons_extensions/project_task_timesheet_extended/__manifest__.py b/addons_extensions/project_task_timesheet_extended/__manifest__.py index 8ba47b4a5..47537a154 100644 --- a/addons_extensions/project_task_timesheet_extended/__manifest__.py +++ b/addons_extensions/project_task_timesheet_extended/__manifest__.py @@ -27,6 +27,7 @@ Key Features: 'project_gantt', 'hr', 'hr_contract', + 'user_timelines', ], 'data': [ 'security/security.xml', @@ -54,6 +55,7 @@ Key Features: 'view/timesheets.xml', 'view/pro_task_gantt.xml', 'view/user_availability.xml', + 'view/user_timelines_project.xml', 'view/project_task_view.xml', # 'view/project_task_gantt.xml', 'view/stage_approval_wizard.xml', diff --git a/addons_extensions/project_task_timesheet_extended/models/__init__.py b/addons_extensions/project_task_timesheet_extended/models/__init__.py index 992c6beeb..da2039261 100644 --- a/addons_extensions/project_task_timesheet_extended/models/__init__.py +++ b/addons_extensions/project_task_timesheet_extended/models/__init__.py @@ -1,3 +1,4 @@ +from . import project_task_module from . import teams from . import project_roles_master from . import project_attachments @@ -16,11 +17,14 @@ from . import project_closer from . import project from . import project_actual_costing from . import project_portfolio +from . import project_portfolio_timeline from . import project_portfolio_dashboard from . import project_task from . import timesheets # from . import project_task_gantt +from . import project_team_timeline from . import user_availability +from . import user_timeline_entry from . import stage_visibility from . import account_analytic_line -from . import stage_approval_wizard \ No newline at end of file +from . import stage_approval_wizard diff --git a/addons_extensions/project_task_timesheet_extended/models/project_portfolio_timeline.py b/addons_extensions/project_task_timesheet_extended/models/project_portfolio_timeline.py new file mode 100644 index 000000000..83aed1d31 --- /dev/null +++ b/addons_extensions/project_task_timesheet_extended/models/project_portfolio_timeline.py @@ -0,0 +1,106 @@ +from odoo import _, api, fields, models + + +class ProjectPortfolio(models.Model): + _inherit = "project.portfolio" + + portfolio_member_ids = fields.Many2many( + "res.users", + compute="_compute_portfolio_timeline_data", + string="Portfolio Team Members", + readonly=True, + ) + portfolio_timeline_entry_ids = fields.Many2many( + "user.timeline.entry", + compute="_compute_portfolio_timeline_data", + string="Portfolio Timeline Entries", + readonly=True, + ) + portfolio_member_count = fields.Integer( + compute="_compute_portfolio_timeline_data", + string="Team Members", + ) + portfolio_timeline_entry_count = fields.Integer( + compute="_compute_portfolio_timeline_data", + string="Timeline Entries", + ) + portfolio_timeline_task_count = fields.Integer( + compute="_compute_portfolio_timeline_data", + string="Task Blocks", + ) + portfolio_timeline_leave_count = fields.Integer( + compute="_compute_portfolio_timeline_data", + string="Leave Blocks", + ) + portfolio_timeline_public_holidays_count = fields.Integer( + compute="_compute_portfolio_timeline_data", + string="Leave Blocks", + ) + + @api.depends( + "project_ids", + "project_ids.members_ids", + "project_ids.user_id", + "project_ids.project_lead", + ) + def _compute_portfolio_timeline_data(self): + Timeline = self.env["user.timeline.entry"] + for portfolio in self: + projects = portfolio.project_ids.filtered("active") + members = projects.mapped("members_ids") | projects.mapped("user_id") | projects.mapped("project_lead") + member_ids = members.ids + portfolio.portfolio_member_ids = members + portfolio.portfolio_member_count = len(members) + if not projects: + portfolio.portfolio_timeline_entry_ids = Timeline + portfolio.portfolio_timeline_entry_count = 0 + portfolio.portfolio_timeline_task_count = 0 + portfolio.portfolio_timeline_leave_count = 0 + portfolio.portfolio_timeline_public_holidays_count = 0 + continue + entries = Timeline.search( + [ + "|", + ("project_id", "in", projects.ids), + "&", + ("entry_type", "=", "leave"), + ("user_id", "in", member_ids), + ], + order="date_start desc", + ) + portfolio.portfolio_timeline_entry_ids = entries + portfolio.portfolio_timeline_entry_count = len(entries) + portfolio.portfolio_timeline_task_count = len( + entries.filtered(lambda entry: entry.entry_type == "task") + ) + portfolio.portfolio_timeline_leave_count = len( + entries.filtered(lambda entry: entry.entry_type == "leave" and entry.source_label != 'Public Holiday') + ) + portfolio.portfolio_timeline_public_holidays_count = len( + entries.filtered(lambda entry: entry.entry_type == "leave" and entry.source_label == 'Public Holiday') + ) + + def action_open_portfolio_timelines(self): + self.ensure_one() + projects = self.project_ids.filtered("active") + members = self.portfolio_member_ids + action = self.env["ir.actions.act_window"]._for_xml_id( + "user_timelines.action_user_timeline_entries" + ) + action["name"] = _("Portfolio Timelines: %s", self.name) + action["domain"] = [ + "|", + ("project_id", "in", projects.ids), + "&", + ("entry_type", "=", "leave"), + ("user_id", "in", members.ids), + ] + action["context"] = { + "search_default_group_employee": 1, + "search_default_group_user": 0, + "search_default_group_project": 0, + "search_default_public_holidays_remove": 1, + "default_is_public_holiday": 0, + "default_portfolio_id": self.id, + } + return action diff --git a/addons_extensions/project_task_timesheet_extended/models/project_task.py b/addons_extensions/project_task_timesheet_extended/models/project_task.py index 265fd0de5..f3564789d 100644 --- a/addons_extensions/project_task_timesheet_extended/models/project_task.py +++ b/addons_extensions/project_task_timesheet_extended/models/project_task.py @@ -33,6 +33,8 @@ class projectTask(models.Model): ('normal', 'Normal'), ], compute='_compute_deadline_status') + model_id = fields.Many2one('project.module.source') + @api.depends('date_deadline') def _compute_deadline_status(self): today = fields.datetime.today() @@ -227,7 +229,10 @@ class projectTask(models.Model): @api.onchange('user_ids') def _onchange_user_ids(self): if self.project_id and (self.project_id.user_id or self.project_id.project_lead): - if (self.project_id.user_id.id != self.env.user.id) and (self.project_id.project_lead.id != self.env.user.id): + administrative_users = self.env['project.role'].search([('role_level','=','administrative')]) + first_stage = self.project_id.type_ids.sorted(key=lambda r: r.sequence)[0] + create_access_users = first_stage.team_id.team_lead + first_stage.involved_user_ids + administrative_users.user_ids + if (self.project_id.user_id.id != self.env.user.id) and (self.project_id.project_lead.id != self.env.user.id) and self.env.user.id not in list(set(create_access_users.ids)): raise ValidationError( "Only Project Manager/Lead can assign/remove assignees" ) @@ -460,7 +465,11 @@ class projectTask(models.Model): 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: + administrative_users = self.env['project.role'].search([('role_level', '=', 'administrative')]) + first_stage = self.project_id.type_ids.sorted(key=lambda r: r.sequence)[0] + create_access_users = first_stage.team_id.team_lead + first_stage.involved_user_ids + administrative_users.user_ids + + 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 or (current_user.id in list(set(create_access_users.ids)) and task.stage_id.id == first_stage.id): task.has_supervisor_access = True @api.depends('assignees_timelines.estimated_time', 'show_approval_flow') diff --git a/addons_extensions/project_task_timesheet_extended/models/project_task_module.py b/addons_extensions/project_task_timesheet_extended/models/project_task_module.py new file mode 100644 index 000000000..2d18960c4 --- /dev/null +++ b/addons_extensions/project_task_timesheet_extended/models/project_task_module.py @@ -0,0 +1,8 @@ +from odoo import _, api, fields, models + + +class ProjectModuleSource(models.Model): + _name = "project.module.source" + _description = "Project Source" + + name = fields.Char(required=True) diff --git a/addons_extensions/project_task_timesheet_extended/models/project_team_timeline.py b/addons_extensions/project_task_timesheet_extended/models/project_team_timeline.py new file mode 100644 index 000000000..225635a8d --- /dev/null +++ b/addons_extensions/project_task_timesheet_extended/models/project_team_timeline.py @@ -0,0 +1,88 @@ +from odoo import _, api, fields, models + + +class ProjectProject(models.Model): + _inherit = "project.project" + + team_timeline_entry_ids = fields.Many2many( + "user.timeline.entry", + compute="_compute_team_timeline_entries", + string="Team Timeline Entries", + readonly=True, + ) + team_timeline_entry_count = fields.Integer( + compute="_compute_team_timeline_entries", + string="Timeline Entries", + ) + team_timeline_member_count = fields.Integer( + compute="_compute_team_timeline_entries", + string="Team Members", + ) + team_timeline_leave_count = fields.Integer( + compute="_compute_team_timeline_entries", + string="Leave Blocks", + ) + team_timeline_public_holidays_count = fields.Integer( + compute="_compute_team_timeline_entries", + string="Leave Blocks", + ) + team_timeline_task_count = fields.Integer( + compute="_compute_team_timeline_entries", + string="Task Blocks", + ) + + @api.depends("members_ids", "user_id", "project_lead") + def _compute_team_timeline_entries(self): + Timeline = self.env["user.timeline.entry"] + for project in self: + users = project.members_ids | project.user_id | project.project_lead + user_ids = users.ids + if not user_ids: + project.team_timeline_entry_ids = Timeline + project.team_timeline_entry_count = 0 + project.team_timeline_member_count = 0 + project.team_timeline_leave_count = 0 + project.team_timeline_public_holidays_count = 0 + project.team_timeline_task_count = 0 + continue + entries = Timeline.search( + [ + "|", + ("project_id", "=", project.id), + "&",'&', + ("entry_type", "=", "leave"), + ("is_public_holiday","=", False), + ("user_id", "in", user_ids), + ], + order="date_start desc", + ) + project.team_timeline_entry_ids = entries + project.team_timeline_entry_count = len(entries) + project.team_timeline_member_count = len(entries.mapped("user_id")) + project.team_timeline_leave_count = len(entries.filtered(lambda entry: entry.entry_type == "leave" and entry.source_label != 'Public Holiday')) + project.team_timeline_public_holidays_count = len(entries.filtered(lambda entry: entry.entry_type == "leave" and entry.source_label == 'Public Holiday')) + project.team_timeline_task_count = len(entries.filtered(lambda entry: entry.entry_type == "task")) + + def action_open_team_timelines(self): + self.ensure_one() + users = self.members_ids | self.user_id | self.project_lead + action = self.env["ir.actions.act_window"]._for_xml_id( + "user_timelines.action_user_timeline_entries" + ) + action["name"] = _("Team Timelines: %s", self.name) + action["domain"] = [ + "|", + ("project_id", "=", self.id), + "&", + ("entry_type", "=", "leave"), + ("user_id", "in", users.ids), + ] + action["context"] = { + "search_default_group_employee": 1, + "search_default_group_user": 0, + "search_default_group_project": 0, + "search_default_public_holidays_remove": 1, + "default_is_public_holiday": 0, + "default_project_id": self.id, + } + return action diff --git a/addons_extensions/project_task_timesheet_extended/models/user_timeline_entry.py b/addons_extensions/project_task_timesheet_extended/models/user_timeline_entry.py new file mode 100644 index 000000000..f03aab4ff --- /dev/null +++ b/addons_extensions/project_task_timesheet_extended/models/user_timeline_entry.py @@ -0,0 +1,99 @@ +from odoo import models + + +class UserTimelineEntry(models.Model): + _inherit = "user.timeline.entry" + + def _get_normal_task_select_sql(self): + project_color_case = self._color_case_sql("COALESCE(project.color, 0)") + return f""" + SELECT + CONCAT('timeline-', timeline.id::varchar) AS source_key, + CONCAT(COALESCE(task.sequence_name, task.name), ' - ', stage.name) AS name, + employee.id AS employee_id, + timeline.assigned_to AS user_id, + task.project_id AS project_id, + task.id AS task_id, + timeline.id AS timeline_id, + timeline.stage_id AS stage_id, + NULL::integer AS leave_id, + NULL::integer AS leave_type_id, + timeline.estimated_start_datetime AS date_start, + timeline.estimated_end_datetime AS date_stop, + 'task'::varchar AS entry_type, + COALESCE(project.color, 0) AS project_color, + NULL::integer AS leave_color, + COALESCE(project.color, 0) AS display_color, + COALESCE(NULLIF(project.timeline_color_hex, ''), {project_color_case}) AS display_color_hex, + task.name::varchar AS description, + 'Project Timeline'::varchar AS source_label, + task.state::varchar AS state, + FALSE AS is_public_holiday, + COALESCE(employee.name, assigned_partner.name, task.name)::varchar AS focus_label + FROM project_task_time_lines timeline + JOIN project_task task + ON task.id = timeline.task_id + LEFT JOIN project_project project + ON project.id = task.project_id + LEFT JOIN project_task_type stage + ON stage.id = timeline.stage_id + LEFT JOIN hr_employee employee + ON employee.user_id = timeline.assigned_to + LEFT JOIN res_users assigned_user + ON assigned_user.id = timeline.assigned_to + LEFT JOIN res_partner assigned_partner + ON assigned_partner.id = assigned_user.partner_id + WHERE timeline.assigned_to IS NOT NULL + AND timeline.estimated_start_datetime IS NOT NULL + AND timeline.estimated_end_datetime IS NOT NULL + + UNION ALL + + SELECT + CONCAT('task-', task.id::varchar, '-', rel.user_id::varchar) AS source_key, + COALESCE(task.sequence_name, task.name) AS name, + employee.id AS employee_id, + rel.user_id AS user_id, + task.project_id AS project_id, + task.id AS task_id, + NULL::integer AS timeline_id, + task.stage_id AS stage_id, + NULL::integer AS leave_id, + NULL::integer AS leave_type_id, + COALESCE(task.date_assign, task.create_date) AS date_start, + GREATEST( + COALESCE(task.date_deadline, task.date_assign, task.create_date), + COALESCE(task.date_assign, task.create_date) + ) AS date_stop, + 'task'::varchar AS entry_type, + COALESCE(project.color, 0) AS project_color, + NULL::integer AS leave_color, + COALESCE(project.color, 0) AS display_color, + COALESCE(NULLIF(project.timeline_color_hex, ''), {project_color_case}) AS display_color_hex, + task.name::varchar AS description, + 'Project Task'::varchar AS source_label, + task.state::varchar AS state, + FALSE AS is_public_holiday, + COALESCE(employee.name, user_partner.name, task.name)::varchar AS focus_label + FROM project_task task + JOIN project_task_user_rel rel + ON rel.task_id = task.id + LEFT JOIN project_project project + ON project.id = task.project_id + LEFT JOIN hr_employee employee + ON employee.user_id = rel.user_id + LEFT JOIN res_users users + ON users.id = rel.user_id + LEFT JOIN res_partner user_partner + ON user_partner.id = users.partner_id + WHERE rel.user_id IS NOT NULL + AND COALESCE(task.date_assign, task.create_date) IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM project_task_time_lines timeline + WHERE timeline.task_id = task.id + AND timeline.assigned_to = rel.user_id + AND timeline.estimated_start_datetime IS NOT NULL + AND timeline.estimated_end_datetime IS NOT NULL + ) + """ diff --git a/addons_extensions/project_task_timesheet_extended/security/ir.model.access.csv b/addons_extensions/project_task_timesheet_extended/security/ir.model.access.csv index e96120ffc..91ecad012 100644 --- a/addons_extensions/project_task_timesheet_extended/security/ir.model.access.csv +++ b/addons_extensions/project_task_timesheet_extended/security/ir.model.access.csv @@ -2,7 +2,7 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink internal_teams_admin,internal.teams.admin,model_internal_teams,project.group_project_manager,1,1,1,1 internal_teams_manager,internal.teams.manager,model_internal_teams,project.group_project_user,1,1,1,0 internal_teams_user,internal.teams.user,model_internal_teams,base.group_user,1,0,0,0 - +project_module_source_user,project.module.source.user,model_project_module_source,,1,1,1,1 access_project_portfolio_employee_performance_user,project.portfolio.employee.performance.user,model_project_portfolio_employee_performance,base.group_user,1,1,1,1 access_project_portfolio_dashboard,project.portfolio.dashboard,model_project_portfolio_dashboard,base.group_user,1,0,0,0 diff --git a/addons_extensions/project_task_timesheet_extended/view/project.xml b/addons_extensions/project_task_timesheet_extended/view/project.xml index a233a7e7f..b117a9bdf 100644 --- a/addons_extensions/project_task_timesheet_extended/view/project.xml +++ b/addons_extensions/project_task_timesheet_extended/view/project.xml @@ -18,10 +18,10 @@ - + - + @@ -113,21 +113,21 @@ - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + +
@@ -144,7 +150,7 @@ - + @@ -198,6 +204,94 @@ + + + + + + +
+
+
+
Portfolio Timeline
+

+ +

+

+ Combined visibility of all people working across the projects in this portfolio, + together with their task timelines and leave context for better planning. +

+
+ + + + Team Members +
+ +
+
+
+ + + + Total Blocks +
+
+ + + + Task Blocks +
+
+ + + + Leave Blocks +
+
+ + + + Public Holidays +
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + +
+
+ @@ -525,4 +619,4 @@ - \ No newline at end of file + diff --git a/addons_extensions/project_task_timesheet_extended/view/project_task.xml b/addons_extensions/project_task_timesheet_extended/view/project_task.xml index d79df99cc..fceb2e099 100644 --- a/addons_extensions/project_task_timesheet_extended/view/project_task.xml +++ b/addons_extensions/project_task_timesheet_extended/view/project_task.xml @@ -58,6 +58,7 @@ + diff --git a/addons_extensions/project_task_timesheet_extended/view/user_availability.xml b/addons_extensions/project_task_timesheet_extended/view/user_availability.xml index c044edc1c..84ee46bfb 100644 --- a/addons_extensions/project_task_timesheet_extended/view/user_availability.xml +++ b/addons_extensions/project_task_timesheet_extended/view/user_availability.xml @@ -9,7 +9,7 @@ user.task.availability - + diff --git a/addons_extensions/project_task_timesheet_extended/view/user_timelines_project.xml b/addons_extensions/project_task_timesheet_extended/view/user_timelines_project.xml new file mode 100644 index 000000000..07264e6b9 --- /dev/null +++ b/addons_extensions/project_task_timesheet_extended/view/user_timelines_project.xml @@ -0,0 +1,99 @@ + + + + project.project.form.team.timelines + project.project + + + + + + + + + + +
+
+
+
Project Timeline
+

+ +

+
+ Accent + + +
+

+ A polished team timeline focused on this project only, combining assigned work, + detailed stage timelines, and leave overlays for quick planning visibility. +

+
+ + + + Team Members +
+ +
+
+
+ + + + Total Blocks +
+
+ + + + Task Blocks +
+
+ + + + Leave Blocks +
+
+ + + + Public Holidays +
+
+
+ +
+
+ + + + + + + + + + + + + + +
+
+
+
+
+
diff --git a/addons_extensions/user_timelines/__init__.py b/addons_extensions/user_timelines/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/addons_extensions/user_timelines/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/addons_extensions/user_timelines/__manifest__.py b/addons_extensions/user_timelines/__manifest__.py new file mode 100644 index 000000000..52d0c4b3b --- /dev/null +++ b/addons_extensions/user_timelines/__manifest__.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +{ + "name": "User Timelines", + "version": "18.0.1.0.0", + "category": "Human Resources", + "summary": "Employee and user timeline views powered by project timelines and leaves", + "author": "OpenAI", + "license": "LGPL-3", + "depends": [ + "hr", + "hr_holidays", + "project", + "project_gantt", + "web", + ], + "data": [ + "security/ir.model.access.csv", + "views/user_timeline_views.xml", + "views/hr_employee_views.xml", + "views/res_users_views.xml", + "views/project_views.xml", + ], + "assets": { + "web.assets_backend": [ + "user_timelines/static/src/js/user_timeline_field.js", + "user_timelines/static/src/xml/user_timeline_field.xml", + "user_timelines/static/src/scss/user_timeline.scss", + ], + }, + "installable": True, + "application": False, +} diff --git a/addons_extensions/user_timelines/models/__init__.py b/addons_extensions/user_timelines/models/__init__.py new file mode 100644 index 000000000..20184a9cb --- /dev/null +++ b/addons_extensions/user_timelines/models/__init__.py @@ -0,0 +1,4 @@ +from . import hr_employee +from . import project_project +from . import res_users +from . import user_timeline_entry diff --git a/addons_extensions/user_timelines/models/hr_employee.py b/addons_extensions/user_timelines/models/hr_employee.py new file mode 100644 index 000000000..5ca5ee18b --- /dev/null +++ b/addons_extensions/user_timelines/models/hr_employee.py @@ -0,0 +1,22 @@ +from odoo import _, models + + +class HrEmployee(models.Model): + _inherit = "hr.employee" + + def action_open_timeline(self): + self.ensure_one() + action = self.env["ir.actions.act_window"]._for_xml_id( + "user_timelines.action_user_timeline_entries" + ) + action["name"] = _("Timeline: %s", self.name) + action["domain"] = [("employee_id", "=", self.id)] + action["context"] = { + "search_default_group_employee": 0, + "search_default_group_user": 0, + "search_default_group_project": 0, + "search_default_public_holidays_remove": 1, + "default_is_public_holiday": 0, + "timeline_focus_employee_id": self.id, + } + return action diff --git a/addons_extensions/user_timelines/models/project_project.py b/addons_extensions/user_timelines/models/project_project.py new file mode 100644 index 000000000..a485d21f0 --- /dev/null +++ b/addons_extensions/user_timelines/models/project_project.py @@ -0,0 +1,65 @@ +from odoo import _, api, fields, models + + +class ProjectProject(models.Model): + _inherit = "project.project" + + timeline_color_hex = fields.Char( + string="Timeline Color", + default="#5794dd", + help="Custom color used for this project's timeline bars.", + ) + timeline_entry_ids = fields.One2many( + "user.timeline.entry", + "project_id", + string="Timeline Entries", + readonly=True, + ) + timeline_entry_count = fields.Integer( + compute="_compute_timeline_metrics", + string="Timeline Entries", + ) + timeline_member_count = fields.Integer( + compute="_compute_timeline_metrics", + string="Team Members in Timeline", + ) + timeline_leave_count = fields.Integer( + compute="_compute_timeline_metrics", + string="Leave Blocks", + ) + timeline_task_count = fields.Integer( + compute="_compute_timeline_metrics", + string="Task Blocks", + ) + + @api.depends("timeline_entry_ids") + def _compute_timeline_metrics(self): + grouped = {} + if self.ids: + entries = self.env["user.timeline.entry"].search([("project_id", "in", self.ids)]) + for project in self: + project_entries = entries.filtered(lambda entry: entry.project_id.id == project.id) + grouped[project.id] = project_entries + for project in self: + project_entries = grouped.get(project.id, self.env["user.timeline.entry"]) + project.timeline_entry_count = len(project_entries) + project.timeline_member_count = len(project_entries.mapped("user_id")) + project.timeline_leave_count = len(project_entries.filtered(lambda entry: entry.entry_type == "leave")) + project.timeline_task_count = len(project_entries.filtered(lambda entry: entry.entry_type == "task")) + + def action_open_team_timelines(self): + self.ensure_one() + action = self.env["ir.actions.act_window"]._for_xml_id( + "user_timelines.action_user_timeline_entries" + ) + action["name"] = _("Team Timelines: %s", self.name) + action["domain"] = [("project_id", "=", self.id)] + action["context"] = { + "search_default_group_employee": 1, + "search_default_group_user": 0, + "search_default_group_project": 0, + "search_default_public_holidays_remove": 1, + "default_is_public_holiday": 0, + "default_project_id": self.id, + } + return action diff --git a/addons_extensions/user_timelines/models/res_users.py b/addons_extensions/user_timelines/models/res_users.py new file mode 100644 index 000000000..15601a030 --- /dev/null +++ b/addons_extensions/user_timelines/models/res_users.py @@ -0,0 +1,22 @@ +from odoo import _, models + + +class ResUsers(models.Model): + _inherit = "res.users" + + def action_open_timeline(self): + self.ensure_one() + action = self.env["ir.actions.act_window"]._for_xml_id( + "user_timelines.action_user_timeline_entries" + ) + action["name"] = _("Timeline: %s", self.name) + action["domain"] = [("user_id", "=", self.id)] + action["context"] = { + "search_default_group_employee": 0, + "search_default_group_user": 0, + "search_default_group_project": 0, + "search_default_public_holidays_remove": 1, + "default_is_public_holiday": 0, + "timeline_focus_user_id": self.id, + } + return action diff --git a/addons_extensions/user_timelines/models/user_timeline_entry.py b/addons_extensions/user_timelines/models/user_timeline_entry.py new file mode 100644 index 000000000..add71d0a7 --- /dev/null +++ b/addons_extensions/user_timelines/models/user_timeline_entry.py @@ -0,0 +1,227 @@ +from odoo import fields, models, tools + + +ODOO_COLOR_MAP = { + 0: "#a2a2a2", + 1: "#ee2d2d", + 2: "#dc8534", + 3: "#e8bb1d", + 4: "#5794dd", + 5: "#9f628f", + 6: "#db8865", + 7: "#41a9a2", + 8: "#304be0", + 9: "#ee2f8a", + 10: "#61c36e", + 11: "#9872e6", +} + + +class UserTimelineEntry(models.Model): + _name = "user.timeline.entry" + _description = "User Timeline Entry" + _auto = False + _order = "date_start, employee_id, user_id, id" + _rec_name = "name" + + name = fields.Char(readonly=True) + employee_id = fields.Many2one("hr.employee", readonly=True) + user_id = fields.Many2one("res.users", readonly=True) + project_id = fields.Many2one("project.project", readonly=True) + task_id = fields.Many2one("project.task", readonly=True) + timeline_id = fields.Integer(readonly=True) + stage_id = fields.Many2one("project.task.type", readonly=True) + leave_id = fields.Many2one("hr.leave", readonly=True) + leave_type_id = fields.Many2one("hr.leave.type", readonly=True) + date_start = fields.Datetime(string="Start", readonly=True) + date_stop = fields.Datetime(string="End", readonly=True) + entry_type = fields.Selection( + [("task", "Task"), ("leave", "Leave")], + string="Timeline Type", + readonly=True, + ) + project_color = fields.Integer(readonly=True) + leave_color = fields.Integer(readonly=True) + display_color = fields.Integer(readonly=True) + display_color_hex = fields.Char(readonly=True) + description = fields.Char(readonly=True) + source_label = fields.Char(readonly=True) + state = fields.Char(readonly=True) + is_public_holiday = fields.Boolean(readonly=True) + focus_label = fields.Char(readonly=True) + + def _color_case_sql(self, field_name): + return "CASE {field} {cases} ELSE '{default}' END".format( + field=field_name, + cases=" ".join( + f"WHEN {index} THEN '{color}'" for index, color in ODOO_COLOR_MAP.items() + ), + default=ODOO_COLOR_MAP[4], + ) + + def _get_normal_task_select_sql(self): + project_color_case = self._color_case_sql("COALESCE(project.color, 0)") + return f""" + SELECT + CONCAT('task-', task.id::varchar, '-', rel.user_id::varchar) AS source_key, + COALESCE(task.sequence_name, task.name) AS name, + employee.id AS employee_id, + rel.user_id AS user_id, + task.project_id AS project_id, + task.id AS task_id, + NULL::integer AS timeline_id, + task.stage_id AS stage_id, + NULL::integer AS leave_id, + NULL::integer AS leave_type_id, + COALESCE(task.date_assign, task.create_date) AS date_start, + GREATEST( + COALESCE(task.date_deadline, task.date_assign, task.create_date), + COALESCE(task.date_assign, task.create_date) + ) AS date_stop, + 'task'::varchar AS entry_type, + COALESCE(project.color, 0) AS project_color, + NULL::integer AS leave_color, + COALESCE(project.color, 0) AS display_color, + COALESCE(NULLIF(project.timeline_color_hex, ''), {project_color_case}) AS display_color_hex, + task.name::varchar AS description, + 'Project Task'::varchar AS source_label, + task.state::varchar AS state, + FALSE AS is_public_holiday, + COALESCE(employee.name, user_partner.name, task.name)::varchar AS focus_label + FROM project_task task + JOIN project_task_user_rel rel + ON rel.task_id = task.id + LEFT JOIN project_project project + ON project.id = task.project_id + LEFT JOIN hr_employee employee + ON employee.user_id = rel.user_id + LEFT JOIN res_users users + ON users.id = rel.user_id + LEFT JOIN res_partner user_partner + ON user_partner.id = users.partner_id + WHERE rel.user_id IS NOT NULL + AND COALESCE(task.date_assign, task.create_date) IS NOT NULL + """ + + def _get_leave_select_sql(self): + leave_color_case = self._color_case_sql("COALESCE(leave_type.color, 0)") + return f""" + SELECT + CONCAT('leave-', leave.id::varchar) AS source_key, + CONCAT('Leave - ', leave_type.name) AS name, + leave.employee_id AS employee_id, + employee.user_id AS user_id, + NULL::integer AS project_id, + NULL::integer AS task_id, + NULL::integer AS timeline_id, + NULL::integer AS stage_id, + leave.id AS leave_id, + leave.holiday_status_id AS leave_type_id, + leave.date_from AS date_start, + leave.date_to AS date_stop, + 'leave'::varchar AS entry_type, + NULL::integer AS project_color, + COALESCE(leave_type.color, 0) AS leave_color, + COALESCE(leave_type.color, 0) AS display_color, + {leave_color_case} AS display_color_hex, + leave_type.name::varchar AS description, + 'Approved Time Off'::varchar AS source_label, + leave.state::varchar AS state, + FALSE AS is_public_holiday, + employee.name::varchar AS focus_label + FROM hr_leave leave + JOIN hr_employee employee + ON employee.id = leave.employee_id + JOIN hr_leave_type leave_type + ON leave_type.id = leave.holiday_status_id + WHERE leave.state IN ('confirm', 'validate1', 'validate') + AND leave.date_from IS NOT NULL + AND leave.date_to IS NOT NULL + """ + + def _get_public_holiday_select_sql(self): + holiday_color_case = self._color_case_sql("3") + return f""" + SELECT + CONCAT('public-holiday-', holiday.id::varchar, '-', employee.id::varchar) AS source_key, + CONCAT('Public Holiday - ', COALESCE(holiday.name, 'Company Holiday')) AS name, + employee.id AS employee_id, + employee.user_id AS user_id, + NULL::integer AS project_id, + NULL::integer AS task_id, + NULL::integer AS timeline_id, + NULL::integer AS stage_id, + NULL::integer AS leave_id, + NULL::integer AS leave_type_id, + holiday.date_from AS date_start, + holiday.date_to AS date_stop, + 'leave'::varchar AS entry_type, + NULL::integer AS project_color, + 3 AS leave_color, + 3 AS display_color, + {holiday_color_case} AS display_color_hex, + holiday.name::varchar AS description, + 'Public Holiday'::varchar AS source_label, + 'public_holiday'::varchar AS state, + TRUE AS is_public_holiday, + employee.name::varchar AS focus_label + FROM resource_calendar_leaves holiday + JOIN hr_employee employee + ON employee.active = TRUE + AND ( + holiday.company_id IS NULL + OR holiday.company_id = employee.company_id + ) + WHERE holiday.resource_id IS NULL + AND holiday.time_type = 'leave' + AND holiday.date_from IS NOT NULL + AND holiday.date_to IS NOT NULL + """ + + def _get_source_selects_sql(self): + return [ + self._get_normal_task_select_sql(), + self._get_leave_select_sql(), + self._get_public_holiday_select_sql(), + ] + + def init(self): + tools.drop_view_if_exists(self.env.cr, self._table) + self.env.cr.execute( + f""" + CREATE OR REPLACE VIEW {self._table} AS ( + SELECT + ROW_NUMBER() OVER ( + ORDER BY + entry_order.date_start, + entry_order.employee_id, + entry_order.user_id, + entry_order.source_key + ) AS id, + entry_order.name, + entry_order.employee_id, + entry_order.user_id, + entry_order.project_id, + entry_order.task_id, + entry_order.timeline_id, + entry_order.stage_id, + entry_order.leave_id, + entry_order.leave_type_id, + entry_order.date_start, + entry_order.date_stop, + entry_order.entry_type, + entry_order.project_color, + entry_order.leave_color, + entry_order.display_color, + entry_order.display_color_hex, + entry_order.description, + entry_order.source_label, + entry_order.state, + entry_order.is_public_holiday, + entry_order.focus_label + FROM ( + {" UNION ALL ".join(self._get_source_selects_sql())} + ) entry_order + ) + """ + ) diff --git a/addons_extensions/user_timelines/security/ir.model.access.csv b/addons_extensions/user_timelines/security/ir.model.access.csv new file mode 100644 index 000000000..cfca24b12 --- /dev/null +++ b/addons_extensions/user_timelines/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_user_timeline_entry_user,user.timeline.entry user,model_user_timeline_entry,base.group_user,1,0,0,0 diff --git a/addons_extensions/user_timelines/static/src/js/user_timeline_field.js b/addons_extensions/user_timelines/static/src/js/user_timeline_field.js new file mode 100644 index 000000000..b70a72554 --- /dev/null +++ b/addons_extensions/user_timelines/static/src/js/user_timeline_field.js @@ -0,0 +1,32 @@ +/** @odoo-module **/ + +import { _t } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; +import { Many2OneAvatarField, many2OneAvatarField } from "@web/views/fields/many2one_avatar/many2one_avatar_field"; + +export class UserTimelineField extends Many2OneAvatarField { + static template = "user_timelines.UserTimelineField"; + + get hasTimelineButton() { + return !!this.resId && ["hr.employee", "res.users"].includes(this.relation); + } + + async onTimelineBtnClick(ev) { + ev.preventDefault(); + ev.stopPropagation(); + if (!this.hasTimelineButton) { + return; + } + const action = await this.orm.call(this.relation, "action_open_timeline", [[this.resId]]); + await this.action.doAction(action); + } +} + +export const userTimelineField = { + ...many2OneAvatarField, + component: UserTimelineField, + displayName: _t("User Timeline"), +}; + +registry.category("fields").add("user_timeline", userTimelineField); +registry.category("fields").add("list.user_timeline", userTimelineField); diff --git a/addons_extensions/user_timelines/static/src/scss/user_timeline.scss b/addons_extensions/user_timelines/static/src/scss/user_timeline.scss new file mode 100644 index 000000000..94160c101 --- /dev/null +++ b/addons_extensions/user_timelines/static/src/scss/user_timeline.scss @@ -0,0 +1,183 @@ +.o_user_timeline_button { + border-radius: 999px; + transition: background-color 0.2s ease, color 0.2s ease; + white-space: nowrap; + + &:hover { + background: rgba(48, 75, 224, 0.08); + color: #304be0; + } +} + +.o_gantt_view { + .o_gantt_pill_wrapper { + padding-block: 2px; + } + + .o_user_timeline_pill { + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12); + } +} + +.o_user_timeline_project_panel { + display: grid; + gap: 1rem; +} + +.o_user_timeline_project_hero { + background: + radial-gradient(circle at top right, rgba(87, 148, 221, 0.18), transparent 30%), + linear-gradient(135deg, #f8fbff 0%, #eef4ff 52%, #f8fafc 100%); + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 20px; + display: grid; + gap: 1rem; + grid-template-columns: minmax(0, 1.5fr) minmax(280px, 1fr); + padding: 1.25rem; +} + +.o_user_timeline_project_heading h2 { + color: #0f172a; + font-size: 1.55rem; + font-weight: 700; + margin: 0.15rem 0 0.45rem; +} + +.o_user_timeline_project_heading p { + color: #475569; + line-height: 1.6; + margin: 0; + max-width: 68ch; +} + +.o_user_timeline_project_kicker { + color: #304be0; + font-size: 0.8rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.o_user_timeline_project_color_row { + align-items: center; + color: #475569; + display: flex; + gap: 0.6rem; + margin-bottom: 0.65rem; +} + +.o_user_timeline_project_color_label { + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.o_user_timeline_project_metrics { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.o_user_timeline_metric_card { + background: rgba(255, 255, 255, 0.78); + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 16px; + box-shadow: 0 12px 30px rgba(15, 23, 42, 0.06); + display: grid; + gap: 0.2rem; + min-height: 92px; + padding: 1rem; +} + +.o_user_timeline_metric_value { + color: #0f172a; + font-size: 1.6rem; + font-weight: 800; + line-height: 1; +} + +.o_user_timeline_metric_label { + color: #475569; + font-size: 0.82rem; + font-weight: 600; +} + +.o_user_timeline_project_toolbar { + align-items: center; + background: #fff; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 16px; + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.04); + display: flex; + gap: 1rem; + justify-content: space-between; + padding: 0.9rem 1rem; +} + +.o_user_timeline_preview_list { + .o_data_row td { + vertical-align: middle; + } +} + +.o_user_timeline_pill { + --user-timeline-color: #5794dd; + align-items: center; + backdrop-filter: blur(8px); + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--user-timeline-color) 90%, #ffffff) 0%, + color-mix(in srgb, var(--user-timeline-color) 72%, #0f172a) 100% + ); + border: 1px solid color-mix(in srgb, var(--user-timeline-color) 65%, #0f172a); + border-radius: 10px; + color: #fff; + display: flex; + gap: 0.45rem; + min-height: 100%; + overflow: hidden; + padding: 0.35rem 0.65rem; + width: 100%; +} + +.o_user_timeline_leave { + background-image: repeating-linear-gradient( + 135deg, + color-mix(in srgb, var(--user-timeline-color) 92%, #ffffff) 0, + color-mix(in srgb, var(--user-timeline-color) 92%, #ffffff) 8px, + color-mix(in srgb, var(--user-timeline-color) 62%, #ffffff) 8px, + color-mix(in srgb, var(--user-timeline-color) 62%, #ffffff) 16px + ); +} + +.o_user_timeline_badge { + background: rgba(255, 255, 255, 0.18); + border: 1px solid rgba(255, 255, 255, 0.22); + border-radius: 999px; + flex-shrink: 0; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + padding: 0.15rem 0.45rem; + text-transform: uppercase; +} + +.o_user_timeline_title { + font-size: 12px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: 991px) { + .o_user_timeline_project_hero { + grid-template-columns: 1fr; + } + + .o_user_timeline_project_toolbar { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/addons_extensions/user_timelines/static/src/xml/user_timeline_field.xml b/addons_extensions/user_timelines/static/src/xml/user_timeline_field.xml new file mode 100644 index 000000000..663fb0b4d --- /dev/null +++ b/addons_extensions/user_timelines/static/src/xml/user_timeline_field.xml @@ -0,0 +1,77 @@ + + + +
+ + + + +
+ + + + + +
+ +
+
+
+ + + + +
+ +
+
+
+
+ +
+ + +
+
+ +
+ +
+
+
+
+ +
+
+
diff --git a/addons_extensions/user_timelines/views/hr_employee_views.xml b/addons_extensions/user_timelines/views/hr_employee_views.xml new file mode 100644 index 000000000..121f5264c --- /dev/null +++ b/addons_extensions/user_timelines/views/hr_employee_views.xml @@ -0,0 +1,34 @@ + + + + hr.employee.form.user.timeline + hr.employee + + + + + + + + + + + + + + + + + + diff --git a/addons_extensions/user_timelines/views/project_views.xml b/addons_extensions/user_timelines/views/project_views.xml new file mode 100644 index 000000000..edbcde631 --- /dev/null +++ b/addons_extensions/user_timelines/views/project_views.xml @@ -0,0 +1,17 @@ + + + + project.project.form.user.timeline.color + project.project + + + + + + + diff --git a/addons_extensions/user_timelines/views/res_users_views.xml b/addons_extensions/user_timelines/views/res_users_views.xml new file mode 100644 index 000000000..d0b3e79fc --- /dev/null +++ b/addons_extensions/user_timelines/views/res_users_views.xml @@ -0,0 +1,43 @@ + + + + res.users.form.profile.user.timeline + res.users + + + + + + + + + + + + + + + + + res.users.form.user.timeline + res.users + + + + + + + + diff --git a/addons_extensions/user_timelines/views/user_timeline_views.xml b/addons_extensions/user_timelines/views/user_timeline_views.xml new file mode 100644 index 000000000..5c67b39c4 --- /dev/null +++ b/addons_extensions/user_timelines/views/user_timeline_views.xml @@ -0,0 +1,199 @@ + + + + user.timeline.entry.search + user.timeline.entry + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + user.timeline.entry.list + user.timeline.entry + + + + + + + + + + + + + + + + + + + user.timeline.entry.calendar + user.timeline.entry + + + + + + + + + + + + + user.timeline.entry.gantt + user.timeline.entry + + + +
+
+ Timeline - + +
+
+ Focus - + +
+
+ Employee - + +
+
+ Project - + +
+
+ Task - + +
+
+ Leave Type - + +
+
Scope -Company Holiday +
+
+ Type - + +
+
+ + + +
+
+
+
+ Task + +
+
+ + +
+
+
+ + + + + + + + + +
+
+
+ + + Employee Timelines + user.timeline.entry + gantt,calendar,list + + {'search_default_group_employee': 1,'search_default_public_holidays_remove': 1, + 'default_is_public_holiday': 0} + + + + + + gantt + + + + + + + calendar + + + + + + + list + + + + + +