From 85a1987f1dbd536aa3f6921bc8577839a83ff500 Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Sun, 30 Aug 2026 10:47:59 -0400 Subject: [PATCH] Add CEO dashboard V2 operating system --- addons/mcs_operating_system/__init__.py | 2 + addons/mcs_operating_system/__manifest__.py | 11 + .../data/v2_seed_data.xml | 3 + addons/mcs_operating_system/hooks.py | 139 +++++ .../mcs_operating_system/models/__init__.py | 2 + .../mcs_operating_system/models/ceo_action.py | 103 ++++ .../mcs_operating_system/models/crm_lead.py | 43 ++ .../mcs_operating_system/models/dashboard.py | 508 ++++++++++++++++++ .../mcs_operating_system/models/operating.py | 43 ++ addons/mcs_operating_system/models/project.py | 154 ++++++ .../models/res_partner.py | 98 ++++ .../security/ir.model.access.csv | 4 +- .../static/src/js/ceo_dashboard_v2.js | 68 +++ .../static/src/scss/ceo_dashboard_v2.scss | 315 +++++++++++ .../static/src/xml/ceo_dashboard_v2.xml | 192 +++++++ addons/mcs_operating_system/tests/__init__.py | 1 + .../tests/test_ceo_dashboard_v2.py | 83 +++ .../views/ceo_action_views.xml | 103 ++++ .../views/crm_lead_views.xml | 1 + .../views/dashboard_views.xml | 7 +- addons/mcs_operating_system/views/menu.xml | 4 +- .../views/operating_views.xml | 3 + .../views/project_views.xml | 31 ++ .../views/res_partner_views.xml | 68 +++ 24 files changed, 1983 insertions(+), 3 deletions(-) create mode 100644 addons/mcs_operating_system/data/v2_seed_data.xml create mode 100644 addons/mcs_operating_system/hooks.py create mode 100644 addons/mcs_operating_system/models/ceo_action.py create mode 100644 addons/mcs_operating_system/models/res_partner.py create mode 100644 addons/mcs_operating_system/static/src/js/ceo_dashboard_v2.js create mode 100644 addons/mcs_operating_system/static/src/scss/ceo_dashboard_v2.scss create mode 100644 addons/mcs_operating_system/static/src/xml/ceo_dashboard_v2.xml create mode 100644 addons/mcs_operating_system/tests/__init__.py create mode 100644 addons/mcs_operating_system/tests/test_ceo_dashboard_v2.py create mode 100644 addons/mcs_operating_system/views/ceo_action_views.xml create mode 100644 addons/mcs_operating_system/views/res_partner_views.xml diff --git a/addons/mcs_operating_system/__init__.py b/addons/mcs_operating_system/__init__.py index 0650744..efce4ad 100644 --- a/addons/mcs_operating_system/__init__.py +++ b/addons/mcs_operating_system/__init__.py @@ -1 +1,3 @@ from . import models +from . import hooks +from .hooks import post_init_hook diff --git a/addons/mcs_operating_system/__manifest__.py b/addons/mcs_operating_system/__manifest__.py index f54e264..d62d6ba 100644 --- a/addons/mcs_operating_system/__manifest__.py +++ b/addons/mcs_operating_system/__manifest__.py @@ -22,14 +22,25 @@ "data/project_data.xml", "data/operating_data.xml", "data/cron_data.xml", + "data/v2_seed_data.xml", + "views/res_partner_views.xml", "views/crm_lead_views.xml", "views/project_views.xml", "views/timesheet_views.xml", "views/hr_employee_views.xml", + "views/ceo_action_views.xml", "views/operating_views.xml", "views/dashboard_views.xml", "views/menu.xml", ], + "assets": { + "web.assets_backend": [ + "mcs_operating_system/static/src/js/ceo_dashboard_v2.js", + "mcs_operating_system/static/src/xml/ceo_dashboard_v2.xml", + "mcs_operating_system/static/src/scss/ceo_dashboard_v2.scss", + ], + }, + "post_init_hook": "post_init_hook", "installable": True, "application": True, } diff --git a/addons/mcs_operating_system/data/v2_seed_data.xml b/addons/mcs_operating_system/data/v2_seed_data.xml new file mode 100644 index 0000000..6f35346 --- /dev/null +++ b/addons/mcs_operating_system/data/v2_seed_data.xml @@ -0,0 +1,3 @@ + + + diff --git a/addons/mcs_operating_system/hooks.py b/addons/mcs_operating_system/hooks.py new file mode 100644 index 0000000..5df4685 --- /dev/null +++ b/addons/mcs_operating_system/hooks.py @@ -0,0 +1,139 @@ +from odoo import api, SUPERUSER_ID + + +def post_init_hook(env_or_cr, registry=None): + if registry is not None: + env = api.Environment(env_or_cr, SUPERUSER_ID, {}) + else: + env = env_or_cr + OperatingSystemSetup(env).run() + + +class OperatingSystemSetup: + def __init__(self, env): + self.env = env + self.Partner = env["res.partner"].sudo() + self.Project = env["project.project"].sudo() + + def run(self): + self._ensure_active_portfolio() + self._ensure_followup_cron_active() + + def _ensure_active_portfolio(self): + specs = { + "ClicksToCart": [ + ("ERP Implementation", "fixed_project"), + ("App Development", "fixed_project"), + ("Social Media Handling", "recurring"), + ], + "Wallaceton": [ + ("SEO", "recurring"), + ("Social Media Handling", "recurring"), + ("Ads Management", "recurring"), + ], + "Maison de Treats": [("Social Media Handling", "recurring")], + "Lens and Frames": [ + ("SEO", "recurring"), + ("Social Media Handling", "recurring"), + ("Ads Management", "recurring"), + ], + "TNCSC": [ + ("Ads Management", "recurring"), + ("Videos", "fixed_project"), + ("ERP", "fixed_project"), + ], + "Infini": [("Social Media Handling", "recurring")], + "Organic Healthy Family": [("Shopify Store Development", "fixed_project")], + "Hondavert": [ + ("Website Development", "fixed_project"), + ("App Development", "fixed_project"), + ], + "RaceNation": [("Website Development", "fixed_project")], + "Racewerks": [("Website Development", "fixed_project")], + "Internal": [ + ("Shopify App for Revenue Generation", "internal_product"), + ("Custom Product Development", "internal_r_and_d"), + ], + } + for account_name, workstreams in specs.items(): + partner = self._ensure_client_account(account_name) + for workstream, delivery_type in workstreams: + self._ensure_project(partner, workstream, delivery_type) + + def _ensure_client_account(self, name): + partner = self.Partner.search([("name", "=", name)], limit=1) + if not partner: + partner = self.Partner.create( + { + "name": name, + "company_type": "company", + "mcs_is_client_account": True, + "mcs_account_status": "active", + } + ) + else: + values = {} + if not partner.mcs_is_client_account: + values["mcs_is_client_account"] = True + if not partner.mcs_account_status: + values["mcs_account_status"] = "active" + if values: + partner.write(values) + return partner + + def _ensure_project(self, partner, workstream, delivery_type): + project_name = "%s - %s" % (partner.name, workstream) + project = self.Project.search([("name", "=", project_name)], limit=1) + if not project: + project = self.Project.create( + { + "name": project_name, + "partner_id": partner.id if partner.name != "Internal" else False, + "allow_timesheets": True, + "allow_milestones": True, + "mcs_delivery_type": delivery_type, + "mcs_initiative_type": self._initiative_type(delivery_type), + "mcs_classification": "active" + if delivery_type != "internal_r_and_d" + else "parked", + "mcs_revenue_connected": delivery_type + not in ("internal_product", "internal_r_and_d", "sales_initiative"), + "mcs_next_action": "Confirm owner, target, scope, and next milestone.", + "mcs_next_milestone": "Operating setup", + } + ) + return project + + values = {} + if not project.partner_id and partner.name != "Internal": + values["partner_id"] = partner.id + if not project.mcs_delivery_type: + values["mcs_delivery_type"] = delivery_type + if not project.mcs_initiative_type: + values["mcs_initiative_type"] = self._initiative_type(delivery_type) + if not project.mcs_next_action: + values["mcs_next_action"] = "Confirm owner, target, scope, and next milestone." + if not project.mcs_next_milestone: + values["mcs_next_milestone"] = "Operating setup" + if values: + project.write(values) + return project + + def _initiative_type(self, delivery_type): + mapping = { + "recurring": "client", + "fixed_project": "client", + "internal_product": "product", + "internal_r_and_d": "internal", + "sales_initiative": "sales", + "internal_operations": "internal", + } + return mapping.get(delivery_type, "client") + + def _ensure_followup_cron_active(self): + cron = self.env.ref( + "mcs_operating_system.ir_cron_mcs_schedule_missing_followups", + raise_if_not_found=False, + ) + if cron and not cron.active: + cron.sudo().write({"active": True}) diff --git a/addons/mcs_operating_system/models/__init__.py b/addons/mcs_operating_system/models/__init__.py index 1291471..c837eaa 100644 --- a/addons/mcs_operating_system/models/__init__.py +++ b/addons/mcs_operating_system/models/__init__.py @@ -1,7 +1,9 @@ from . import crm_lead +from . import ceo_action from . import dashboard from . import hr_employee from . import operating from . import project +from . import res_partner from . import sale_order from . import timesheet diff --git a/addons/mcs_operating_system/models/ceo_action.py b/addons/mcs_operating_system/models/ceo_action.py new file mode 100644 index 0000000..c88e466 --- /dev/null +++ b/addons/mcs_operating_system/models/ceo_action.py @@ -0,0 +1,103 @@ +from odoo import api, fields, models + + +class McsCeoAction(models.Model): + _name = "mcs.ceo.action" + _description = "CEO Action" + _inherit = ["mail.thread", "mail.activity.mixin"] + _order = "priority_rank, date_due, id desc" + + title = fields.Char(required=True, tracking=True) + category = fields.Selection( + [ + ("sales", "Sales"), + ("finance", "Finance"), + ("client", "Client"), + ("people", "People"), + ("strategy", "Strategy"), + ("approval", "Approval"), + ("collection", "Collection"), + ("partnership", "Partnership"), + ], + required=True, + default="strategy", + tracking=True, + ) + priority = fields.Selection( + [ + ("critical", "Critical"), + ("high", "High"), + ("normal", "Normal"), + ("low", "Low"), + ], + required=True, + default="normal", + tracking=True, + ) + priority_rank = fields.Integer(compute="_compute_priority_rank", store=True) + state = fields.Selection( + [ + ("open", "Open"), + ("waiting", "Waiting"), + ("blocked", "Blocked"), + ("completed", "Completed"), + ("cancelled", "Cancelled"), + ], + required=True, + default="open", + tracking=True, + ) + date_due = fields.Date(string="Due Date", tracking=True) + responsible_user_id = fields.Many2one( + "res.users", string="Responsible User", default=lambda self: self.env.user + ) + partner_id = fields.Many2one( + "res.partner", + string="Related Client", + domain=[("mcs_is_client_account", "=", True)], + ) + project_id = fields.Many2one("project.project", string="Related Project") + lead_id = fields.Many2one("crm.lead", string="Related CRM Opportunity") + currency_id = fields.Many2one( + "res.currency", default=lambda self: self.env.company.currency_id + ) + expected_financial_impact = fields.Monetary(string="Expected Financial Impact") + business_impact = fields.Text(string="Business Impact") + next_action = fields.Char(string="Next Action", tracking=True) + blocker = fields.Text(tracking=True) + notes = fields.Html() + date_created = fields.Date( + string="Created Date", default=fields.Date.context_today, readonly=True + ) + date_completed = fields.Date(string="Completed Date", readonly=True, tracking=True) + + @api.depends("priority") + def _compute_priority_rank(self): + ranks = {"critical": 0, "high": 1, "normal": 2, "low": 3} + for action in self: + action.priority_rank = ranks.get(action.priority, 9) + + def action_open(self): + self.write({"state": "open"}) + return True + + def action_waiting(self): + self.write({"state": "waiting"}) + return True + + def action_blocked(self): + self.write({"state": "blocked"}) + return True + + def action_completed(self): + self.write( + { + "state": "completed", + "date_completed": fields.Date.context_today(self), + } + ) + return True + + def action_cancelled(self): + self.write({"state": "cancelled"}) + return True diff --git a/addons/mcs_operating_system/models/crm_lead.py b/addons/mcs_operating_system/models/crm_lead.py index 6e726d4..f928848 100644 --- a/addons/mcs_operating_system/models/crm_lead.py +++ b/addons/mcs_operating_system/models/crm_lead.py @@ -101,6 +101,49 @@ class CrmLead(models.Model): ) return True + def action_mcs_create_delivery_project(self): + Project = self.env["project.project"] + for lead in self: + partner = lead.partner_id + if partner: + values = {"mcs_is_client_account": True} + if not partner.mcs_account_owner_id and lead.user_id: + values["mcs_account_owner_id"] = lead.user_id.id + partner.write(values) + + project_name = "%s - %s" % ( + partner.name if partner else lead.name, + dict(lead._fields["mcs_service_offer"].selection).get( + lead.mcs_service_offer, "Delivery Project" + ), + ) + project = Project.search([("name", "=", project_name)], limit=1) + if not project: + project = Project.create( + { + "name": project_name, + "partner_id": partner.id if partner else False, + "user_id": lead.user_id.id if lead.user_id else False, + "allow_timesheets": True, + "allow_milestones": True, + "mcs_initiative_type": "client", + "mcs_delivery_type": "recurring" + if lead.mcs_expected_monthly_revenue + else "fixed_project", + "mcs_revenue_connected": True, + "mcs_monthly_revenue_target": lead.mcs_expected_monthly_revenue, + "mcs_contracted_revenue": lead.mcs_expected_monthly_revenue + or lead.mcs_one_time_project_value, + "mcs_next_action": lead.mcs_next_action + or "Confirm delivery owner, scope, milestone, and kickoff date.", + } + ) + lead.mcs_operating_notes = "%s\nDelivery project: %s" % ( + lead.mcs_operating_notes or "", + project.display_name, + ) + return True + def _cron_mcs_schedule_missing_followups(self): leads = self.search( [ diff --git a/addons/mcs_operating_system/models/dashboard.py b/addons/mcs_operating_system/models/dashboard.py index 434f8f8..e755c6d 100644 --- a/addons/mcs_operating_system/models/dashboard.py +++ b/addons/mcs_operating_system/models/dashboard.py @@ -172,3 +172,511 @@ class McsOperatingDashboard(models.Model): "target": "current", "context": {"create": False}, } + + @api.model + def action_mcs_seed_v2_operating_data(self): + from odoo.addons.mcs_operating_system.hooks import OperatingSystemSetup + + OperatingSystemSetup(self.env).run() + return True + + def get_ceo_dashboard_v2_data(self): + today = fields.Date.context_today(self) + month_start = today.replace(day=1) + month_end = month_start + relativedelta(months=1, days=-1) + week_start = today - relativedelta(days=today.weekday()) + week_end = week_start + relativedelta(days=6) + + Lead = self.env["crm.lead"] + Project = self.env["project.project"] + Task = self.env["project.task"] + Timesheet = self.env["account.analytic.line"] + Invoice = self.env["account.move"] + Employee = self.env["hr.employee"] + Partner = self.env["res.partner"] + CeoAction = self.env["mcs.ceo.action"] + + active_projects = Project.search([("active", "=", True)]) + client_projects = active_projects.filtered( + lambda project: project.mcs_delivery_type + not in ("internal_product", "internal_r_and_d", "sales_initiative") + ) + internal_projects = active_projects.filtered( + lambda project: project.mcs_delivery_type + in ("internal_product", "internal_r_and_d") + ) + open_pipeline = Lead.search( + [("type", "=", "opportunity"), ("active", "=", True), ("probability", "<", 100)] + ) + won_month = Lead.search( + [ + ("type", "=", "opportunity"), + ("active", "=", True), + ("probability", "=", 100), + ("date_closed", ">=", month_start), + ("date_closed", "<=", month_end), + ] + ) + invoices_month = Invoice.search( + [ + ("move_type", "=", "out_invoice"), + ("state", "=", "posted"), + ("invoice_date", ">=", month_start), + ("invoice_date", "<=", month_end), + ] + ) + receivables = Invoice.search( + [ + ("move_type", "=", "out_invoice"), + ("state", "=", "posted"), + ("payment_state", "not in", ["paid", "in_payment", "reversed"]), + ] + ) + overdue_receivables = receivables.filtered( + lambda invoice: invoice.invoice_date_due and invoice.invoice_date_due < today + ) + timesheets_month = Timesheet.search( + [("date", ">=", month_start), ("date", "<=", month_end)] + ) + timesheets_week = Timesheet.search( + [("date", ">=", week_start), ("date", "<=", week_end)] + ) + active_employees = Employee.search([("active", "=", True)]) + active_tasks = Task.search([("active", "=", True)]) + + monthly_target = sum(active_projects.mapped("mcs_monthly_revenue_target")) + contracted_revenue = sum(client_projects.mapped("mcs_contracted_revenue")) + invoiced_month = sum(invoices_month.mapped("amount_total")) + cash_collected = sum( + invoice.amount_total - invoice.amount_residual for invoice in invoices_month + ) + operating_cost = self._monthly_operating_cost() + revenue_gap = max(monthly_target - contracted_revenue, 0) + open_pipeline_value = sum(open_pipeline.mapped("expected_revenue")) + weighted_pipeline = sum( + lead.expected_revenue * lead.probability / 100 for lead in open_pipeline + ) + qualified_stage_names = ["Qualified", "Proposal", "Negotiation"] + qualified_pipeline = sum( + open_pipeline.filtered( + lambda lead: lead.stage_id.name in qualified_stage_names + ).mapped("expected_revenue") + ) + required_pipeline = revenue_gap * 3 + pipeline_coverage = ( + qualified_pipeline / required_pipeline * 100 if required_pipeline else 100 + ) + revenue_coverage = ( + contracted_revenue / operating_cost * 100 if operating_cost else 0 + ) + remaining_gap = max(operating_cost - contracted_revenue, 0) + + utilization_capacity = sum(active_employees.mapped("mcs_available_hours_week")) + billable_hours = sum( + timesheets_week.filtered( + lambda line: line.mcs_billable_classification == "billable" + ).mapped("unit_amount") + ) + total_week_hours = sum(timesheets_week.mapped("unit_amount")) + billable_utilization = ( + billable_hours / utilization_capacity * 100 if utilization_capacity else 0 + ) + utilization = ( + total_week_hours / utilization_capacity * 100 if utilization_capacity else 0 + ) + + return { + "period": { + "today": fields.Date.to_string(today), + "month_start": fields.Date.to_string(month_start), + "month_end": fields.Date.to_string(month_end), + "week_start": fields.Date.to_string(week_start), + "week_end": fields.Date.to_string(week_end), + }, + "company_pulse": { + "revenue_coverage": self._format_percent(revenue_coverage), + "revenue_coverage_status": self._coverage_status(revenue_coverage), + "remaining_gap": remaining_gap, + "monthly_revenue_target": monthly_target, + "contracted_monthly_revenue": contracted_revenue, + "revenue_invoiced_month": invoiced_month, + "cash_collected_month": cash_collected, + "operating_cost": operating_cost, + "revenue_gap": revenue_gap, + "new_mrr_won": sum(won_month.mapped("mcs_expected_monthly_revenue")), + "open_pipeline": open_pipeline_value, + "weighted_pipeline": weighted_pipeline, + "outstanding_receivables": sum(receivables.mapped("amount_residual")), + "overdue_receivables": sum(overdue_receivables.mapped("amount_residual")), + "team_utilization": self._format_percent(utilization), + "billable_utilization": self._format_percent(billable_utilization), + "projects_at_risk": len(active_projects.filtered(lambda project: project.mcs_portfolio_health == "at_risk")), + "blocked_projects": len(active_projects.filtered(lambda project: project.mcs_portfolio_health == "blocked")), + }, + "kpis": self._dashboard_kpis( + monthly_target, + contracted_revenue, + revenue_gap, + open_pipeline_value, + weighted_pipeline, + overdue_receivables, + utilization, + active_projects, + ), + "revenue": { + "target": monthly_target, + "contracted": contracted_revenue, + "invoiced": invoiced_month, + "collected": cash_collected, + "forecast": contracted_revenue + weighted_pipeline, + "qualified_pipeline": qualified_pipeline, + "required_pipeline": required_pipeline, + "pipeline_coverage": self._format_percent(pipeline_coverage), + "pipeline_coverage_status": self._coverage_status(pipeline_coverage), + "funnel": self._crm_funnel_data(open_pipeline), + }, + "portfolio": self._portfolio_rows(Partner, client_projects), + "project_health": self._project_health_distribution(active_projects), + "capacity": self._capacity_rows(active_employees, active_tasks, timesheets_week), + "time_distribution": self._time_distribution(timesheets_week), + "allocation_guardrail": self._allocation_guardrail(timesheets_week), + "ceo_actions": self._ceo_action_rows(CeoAction), + "internal_bets": self._internal_bet_rows(internal_projects, timesheets_month), + "actions": self._dashboard_actions(today, month_start, month_end, week_start, week_end), + } + + def _format_percent(self, value): + return round(value or 0, 1) + + def _monthly_operating_cost(self): + try: + Contract = self.env["hr.contract"] + except KeyError: + return 0 + contracts = Contract.sudo().search( + [("state", "=", "open"), ("employee_id.active", "=", True)] + ) + return sum(contracts.mapped("wage")) + + def _coverage_status(self, value): + if value >= 100: + return "covered" + if value >= 80: + return "watch" + return "critical" + + def _dashboard_kpis( + self, + monthly_target, + contracted_revenue, + revenue_gap, + open_pipeline_value, + weighted_pipeline, + overdue_receivables, + utilization, + active_projects, + ): + return [ + { + "label": "Revenue Target", + "value": monthly_target, + "action_key": "monthly_revenue_projects", + }, + { + "label": "Contracted Revenue", + "value": contracted_revenue, + "action_key": "contracted_projects", + }, + {"label": "Revenue Gap", "value": revenue_gap, "action_key": "pipeline"}, + {"label": "Open Pipeline", "value": open_pipeline_value, "action_key": "pipeline"}, + { + "label": "Weighted Pipeline", + "value": weighted_pipeline, + "action_key": "pipeline", + }, + { + "label": "Overdue Receivables", + "value": sum(overdue_receivables.mapped("amount_residual")), + "action_key": "overdue_receivables", + }, + { + "label": "Team Utilization", + "value": "%s%%" % self._format_percent(utilization), + "action_key": "capacity", + }, + { + "label": "At Risk Projects", + "value": len(active_projects.filtered(lambda project: project.mcs_portfolio_health == "at_risk")), + "action_key": "projects_at_risk", + }, + ] + + def _crm_funnel_data(self, open_pipeline): + stage_names = [ + "Target", + "Contacted", + "Replied", + "Discovery", + "Qualified", + "Proposal", + "Negotiation", + "Won", + ] + rows = [] + for stage_name in stage_names: + leads = open_pipeline.filtered(lambda lead: lead.stage_id.name == stage_name) + rows.append( + { + "stage": stage_name, + "count": len(leads), + "value": sum(leads.mapped("expected_revenue")), + } + ) + return rows + + def _portfolio_rows(self, Partner, client_projects): + partners = Partner.search([("mcs_is_client_account", "=", True)]) + rows = [] + for partner in partners: + projects = client_projects.filtered(lambda project: project.partner_id == partner) + if not projects: + continue + rows.append( + { + "id": partner.id, + "name": partner.display_name, + "health": partner.mcs_account_health, + "active_workstreams": len(projects), + "revenue": sum(projects.mapped("mcs_monthly_revenue_target")), + "hours": sum(projects.mapped("mcs_actual_hours")), + "next_milestone": next( + (project.mcs_next_milestone for project in projects if project.mcs_next_milestone), + "", + ), + "nearest_deadline": self._nearest_deadline(projects), + "risk": max(projects.mapped("mcs_risk_score") or [0]), + "next_action": partner.mcs_next_account_action + or next((project.mcs_next_action for project in projects if project.mcs_next_action), ""), + } + ) + return sorted(rows, key=lambda row: row["risk"], reverse=True) + + def _nearest_deadline(self, projects): + deadlines = projects.mapped("tasks.date_deadline") + deadlines = [deadline for deadline in deadlines if deadline] + return fields.Date.to_string(min(deadlines)) if deadlines else "" + + def _project_health_distribution(self, projects): + return [ + {"label": "Healthy", "value": len(projects.filtered(lambda project: project.mcs_portfolio_health == "healthy"))}, + {"label": "Watch", "value": len(projects.filtered(lambda project: project.mcs_portfolio_health == "watch"))}, + {"label": "At Risk", "value": len(projects.filtered(lambda project: project.mcs_portfolio_health == "at_risk"))}, + {"label": "Blocked", "value": len(projects.filtered(lambda project: project.mcs_portfolio_health == "blocked"))}, + {"label": "Stale", "value": len(projects.filtered("mcs_stale"))}, + {"label": "No Next Action", "value": len(projects.filtered(lambda project: not project.mcs_next_action))}, + {"label": "Missing Planned Hours", "value": len(projects.filtered(lambda project: not project.mcs_planned_hours))}, + {"label": "Over Planned Hours", "value": len(projects.filtered(lambda project: project.mcs_planned_hours and project.mcs_actual_hours > project.mcs_planned_hours))}, + ] + + def _capacity_rows(self, employees, active_tasks, timesheets_week): + rows = [] + for employee in employees: + employee_lines = timesheets_week.filtered(lambda line: line.employee_id == employee) + employee_tasks = active_tasks.filtered( + lambda task: employee.user_id and employee.user_id in task.user_ids + ) + available = employee.mcs_available_hours_week + actual = sum(employee_lines.mapped("unit_amount")) + billable = sum( + employee_lines.filtered( + lambda line: line.mcs_billable_classification == "billable" + ).mapped("unit_amount") + ) + utilization = actual / available * 100 if available else 0 + billable_utilization = billable / available * 100 if available else 0 + rows.append( + { + "id": employee.id, + "name": employee.name, + "available": available, + "allocated": sum(employee_tasks.mapped("allocated_hours")), + "actual": actual, + "billable": billable, + "internal": sum( + employee_lines.filtered( + lambda line: line.mcs_billable_classification == "internal" + ).mapped("unit_amount") + ), + "rework": sum(employee_lines.filtered("mcs_is_rework").mapped("unit_amount")), + "utilization": self._format_percent(utilization), + "billable_utilization": self._format_percent(billable_utilization), + "status": self._capacity_status(utilization), + } + ) + return sorted(rows, key=lambda row: row["utilization"], reverse=True) + + def _capacity_status(self, utilization): + if utilization > 100: + return "overloaded" + if utilization >= 90: + return "near_capacity" + if utilization >= 50: + return "normal" + return "underutilized" + + def _time_distribution(self, timesheets): + category_labels = dict(self.env["account.analytic.line"]._fields["mcs_work_category"].selection) + billable_labels = dict(self.env["account.analytic.line"]._fields["mcs_billable_classification"].selection) + by_category = [] + by_billable = [] + for key, label in category_labels.items(): + by_category.append( + { + "label": label, + "hours": sum(timesheets.filtered(lambda line: line.mcs_work_category == key).mapped("unit_amount")), + } + ) + for key, label in billable_labels.items(): + by_billable.append( + { + "label": label, + "hours": sum(timesheets.filtered(lambda line: line.mcs_billable_classification == key).mapped("unit_amount")), + } + ) + return {"by_category": by_category, "by_billable": by_billable} + + def _allocation_guardrail(self, timesheets): + total = sum(timesheets.mapped("unit_amount")) + groups = [ + ("Client Delivery", ["billable_delivery", "client_support"], 75), + ("Sales Support", ["sales_support"], 10), + ("Internal Product/R&D", ["internal_product", "research"], 10), + ("Admin/Other", ["admin", "meetings", "qa", "rework"], 5), + ] + rows = [] + for label, categories, target in groups: + hours = sum( + timesheets.filtered( + lambda line, categories=categories: line.mcs_work_category in categories + ).mapped("unit_amount") + ) + rows.append( + { + "label": label, + "hours": hours, + "percentage": self._format_percent(hours / total * 100 if total else 0), + "target": target, + } + ) + return rows + + def _ceo_action_rows(self, CeoAction): + actions = CeoAction.search( + [("state", "in", ["open", "waiting", "blocked"])], + order="priority_rank, date_due, id desc", + limit=12, + ) + return [ + { + "id": action.id, + "title": action.title, + "category": action.category, + "priority": action.priority, + "state": action.state, + "date_due": fields.Date.to_string(action.date_due) if action.date_due else "", + "next_action": action.next_action or "", + } + for action in actions + ] + + def _internal_bet_rows(self, projects, timesheets_month): + rows = [] + for project in projects: + project_lines = timesheets_month.filtered(lambda line: line.project_id == project) + hours = sum(project_lines.mapped("unit_amount")) + rows.append( + { + "id": project.id, + "name": project.name, + "stage": project.mcs_internal_product_stage, + "hours": hours, + "estimated_cost": 0, + "expected_revenue": project.mcs_internal_expected_revenue, + "actual_revenue": project.mcs_internal_actual_revenue, + "owner": project.user_id.name or "", + "next_milestone": project.mcs_next_milestone or "", + "next_decision_date": fields.Date.to_string(project.mcs_next_decision_date) + if project.mcs_next_decision_date + else "", + } + ) + return rows + + def _dashboard_actions(self, today, month_start, month_end, week_start, week_end): + return { + "monthly_revenue_projects": { + "type": "ir.actions.act_window", + "name": "Monthly Revenue Projects", + "res_model": "project.project", + "view_mode": "tree,form,kanban", + "domain": [("active", "=", True), ("mcs_monthly_revenue_target", ">", 0)], + }, + "contracted_projects": { + "type": "ir.actions.act_window", + "name": "Contracted Projects", + "res_model": "project.project", + "view_mode": "tree,form,kanban", + "domain": [("active", "=", True), ("mcs_contracted_revenue", ">", 0)], + }, + "pipeline": { + "type": "ir.actions.act_window", + "name": "Open Pipeline", + "res_model": "crm.lead", + "view_mode": "kanban,tree,form,pivot,graph", + "domain": [("type", "=", "opportunity"), ("active", "=", True), ("probability", "<", 100)], + }, + "overdue_receivables": { + "type": "ir.actions.act_window", + "name": "Overdue Receivables", + "res_model": "account.move", + "view_mode": "tree,form", + "domain": [ + ("move_type", "=", "out_invoice"), + ("state", "=", "posted"), + ("payment_state", "not in", ["paid", "in_payment", "reversed"]), + ("invoice_date_due", "<", fields.Date.to_string(today)), + ], + }, + "capacity": { + "type": "ir.actions.act_window", + "name": "This Week Timesheets", + "res_model": "account.analytic.line", + "view_mode": "tree,pivot,graph,form", + "domain": [ + ("date", ">=", fields.Date.to_string(week_start)), + ("date", "<=", fields.Date.to_string(week_end)), + ], + "context": {"group_by": "employee_id"}, + }, + "projects_at_risk": { + "type": "ir.actions.act_window", + "name": "Projects At Risk", + "res_model": "project.project", + "view_mode": "tree,form,kanban", + "domain": [("active", "=", True), ("mcs_portfolio_health", "in", ["at_risk", "blocked"])], + }, + "ceo_actions": { + "type": "ir.actions.act_window", + "name": "CEO Actions", + "res_model": "mcs.ceo.action", + "view_mode": "tree,form,kanban", + "domain": [("state", "in", ["open", "waiting", "blocked"])], + }, + "internal_bets": { + "type": "ir.actions.act_window", + "name": "Internal Bets", + "res_model": "project.project", + "view_mode": "tree,form,kanban", + "domain": [("mcs_delivery_type", "in", ["internal_product", "internal_r_and_d"])], + }, + } diff --git a/addons/mcs_operating_system/models/operating.py b/addons/mcs_operating_system/models/operating.py index c4c9f78..5213d4b 100644 --- a/addons/mcs_operating_system/models/operating.py +++ b/addons/mcs_operating_system/models/operating.py @@ -233,3 +233,46 @@ class McsClientProfitability(models.Model): if record.client_revenue else 0 ) + + @api.onchange("project_id", "period_start", "period_end") + def _onchange_project_profitability_source(self): + for record in self: + record._apply_project_profitability_values() + + def action_recompute_from_project(self): + for record in self: + record._apply_project_profitability_values() + return True + + def _apply_project_profitability_values(self): + if not self.project_id or not self.period_start or not self.period_end: + return + lines = self.env["account.analytic.line"].search( + [ + ("project_id", "=", self.project_id.id), + ("date", ">=", self.period_start), + ("date", "<=", self.period_end), + ] + ) + self.partner_id = self.project_id.partner_id + self.client_revenue = ( + self.project_id.mcs_contracted_revenue + or self.project_id.mcs_monthly_revenue_target + ) + self.delivery_hours = sum( + lines.filtered( + lambda line: line.mcs_work_category + in ("billable_delivery", "client_support", "qa") + ).mapped("unit_amount") + ) + self.account_management_hours = sum( + lines.filtered( + lambda line: line.mcs_work_category in ("meetings", "admin") + ).mapped("unit_amount") + ) + self.rework_hours = sum(lines.filtered("mcs_is_rework").mapped("unit_amount")) + self.revision_hours = sum( + lines.filtered(lambda line: line.mcs_work_category == "rework").mapped( + "unit_amount" + ) + ) diff --git a/addons/mcs_operating_system/models/project.py b/addons/mcs_operating_system/models/project.py index e0925ea..c8aeb81 100644 --- a/addons/mcs_operating_system/models/project.py +++ b/addons/mcs_operating_system/models/project.py @@ -57,6 +57,78 @@ class ProjectProject(models.Model): mcs_at_risk = fields.Boolean( string="At Risk", compute="_compute_mcs_at_risk", store=True ) + mcs_delivery_type = fields.Selection( + [ + ("recurring", "Recurring"), + ("fixed_project", "Fixed Project"), + ("internal_product", "Internal Product"), + ("internal_r_and_d", "Internal R&D"), + ("sales_initiative", "Sales Initiative"), + ("internal_operations", "Internal Operations"), + ], + string="Delivery Type", + tracking=True, + ) + mcs_portfolio_health = fields.Selection( + [ + ("healthy", "Healthy"), + ("watch", "Watch"), + ("at_risk", "At Risk"), + ("blocked", "Blocked"), + ("completed", "Completed"), + ], + string="Portfolio Health", + compute="_compute_mcs_portfolio_health", + store=True, + readonly=False, + tracking=True, + ) + mcs_risk_score = fields.Integer( + string="Risk Score", compute="_compute_mcs_portfolio_health", store=True + ) + mcs_last_meaningful_progress_date = fields.Date( + string="Last Meaningful Progress", + compute="_compute_mcs_progress_health", + store=True, + ) + mcs_days_since_meaningful_progress = fields.Integer( + string="Days Since Progress", compute="_compute_mcs_progress_health", store=True + ) + mcs_stale = fields.Boolean( + string="Stale", compute="_compute_mcs_progress_health", store=True + ) + mcs_stale_threshold_days = fields.Integer(string="Stale Threshold Days", default=7) + mcs_launch_completion_date = fields.Date(string="Launch / Completion Date") + mcs_revenue_impact_score = fields.Integer(default=0) + mcs_client_importance_score = fields.Integer(default=0) + mcs_urgency_score = fields.Integer(default=0) + mcs_completion_leverage_score = fields.Integer(default=0) + mcs_resource_cost_score = fields.Integer(default=0) + mcs_commercial_priority_score = fields.Integer( + string="Commercial Priority Score", + compute="_compute_mcs_commercial_priority_score", + store=True, + ) + mcs_internal_product_stage = fields.Selection( + [ + ("idea", "Idea"), + ("validating", "Validating"), + ("approved", "Approved"), + ("building", "Building"), + ("beta", "Beta"), + ("launched", "Launched"), + ("revenue", "Revenue"), + ("parked", "Parked"), + ("killed", "Killed"), + ], + string="Internal Product Stage", + default="idea", + tracking=True, + ) + mcs_internal_expected_revenue = fields.Monetary(string="Expected Revenue") + mcs_internal_actual_revenue = fields.Monetary(string="Actual Revenue") + mcs_next_milestone = fields.Char(string="Next Milestone") + mcs_next_decision_date = fields.Date(string="Next Decision Date") @api.depends("mcs_monthly_revenue_target", "mcs_contracted_revenue") def _compute_mcs_cash_gap(self): @@ -81,6 +153,88 @@ class ProjectProject(models.Model): project.mcs_blocker or not project.mcs_next_action or any(project.tasks.mapped("mcs_at_risk")) ) + @api.depends( + "mcs_revenue_impact_score", + "mcs_client_importance_score", + "mcs_urgency_score", + "mcs_completion_leverage_score", + "mcs_resource_cost_score", + ) + def _compute_mcs_commercial_priority_score(self): + for project in self: + project.mcs_commercial_priority_score = ( + project.mcs_revenue_impact_score + + project.mcs_client_importance_score + + project.mcs_urgency_score + + project.mcs_completion_leverage_score + - project.mcs_resource_cost_score + ) + + @api.depends( + "write_date", + "tasks.write_date", + "tasks.stage_id.fold", + "timesheet_ids.date", + "mcs_stale_threshold_days", + ) + def _compute_mcs_progress_health(self): + today = fields.Date.context_today(self) + for project in self: + progress_dates = [] + if project.write_date: + progress_dates.append(fields.Date.to_date(project.write_date)) + for task in project.tasks: + if task.write_date: + progress_dates.append(fields.Date.to_date(task.write_date)) + progress_dates.extend(project.timesheet_ids.mapped("date")) + last_progress = max(progress_dates) if progress_dates else False + project.mcs_last_meaningful_progress_date = last_progress + project.mcs_days_since_meaningful_progress = ( + (today - last_progress).days if last_progress else 0 + ) + threshold = project.mcs_stale_threshold_days or 7 + project.mcs_stale = bool( + project.active + and last_progress + and project.mcs_days_since_meaningful_progress > threshold + ) + + @api.depends( + "active", + "mcs_at_risk", + "mcs_blocker", + "mcs_next_action", + "mcs_stale", + "mcs_planned_hours", + "mcs_actual_hours", + "mcs_launch_completion_date", + ) + def _compute_mcs_portfolio_health(self): + for project in self: + risk_score = 0 + if project.mcs_blocker: + risk_score += 4 + if not project.mcs_next_action: + risk_score += 2 + if project.mcs_stale: + risk_score += 2 + if project.mcs_planned_hours and project.mcs_actual_hours > project.mcs_planned_hours: + risk_score += 2 + if project.mcs_at_risk: + risk_score += 2 + project.mcs_risk_score = risk_score + + if project.mcs_launch_completion_date: + project.mcs_portfolio_health = "completed" + elif project.mcs_blocker: + project.mcs_portfolio_health = "blocked" + elif risk_score >= 5: + project.mcs_portfolio_health = "at_risk" + elif risk_score: + project.mcs_portfolio_health = "watch" + else: + project.mcs_portfolio_health = "healthy" + class ProjectTask(models.Model): _inherit = "project.task" diff --git a/addons/mcs_operating_system/models/res_partner.py b/addons/mcs_operating_system/models/res_partner.py new file mode 100644 index 0000000..4a27815 --- /dev/null +++ b/addons/mcs_operating_system/models/res_partner.py @@ -0,0 +1,98 @@ +from odoo import api, fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + mcs_is_client_account = fields.Boolean(string="MCS Client Account", tracking=True) + mcs_account_status = fields.Selection( + [ + ("active", "Active"), + ("watch", "Watch"), + ("paused", "Paused"), + ("completed", "Completed"), + ("lost", "Lost"), + ], + string="Account Status", + default="active", + tracking=True, + ) + mcs_account_health = fields.Selection( + [ + ("healthy", "Healthy"), + ("watch", "Watch"), + ("at_risk", "At Risk"), + ("blocked", "Blocked"), + ("completed", "Completed"), + ], + string="Account Health", + compute="_compute_mcs_account_metrics", + store=True, + tracking=True, + ) + mcs_currency_id = fields.Many2one( + "res.currency", + string="MCS Currency", + default=lambda self: self.env.company.currency_id, + ) + mcs_monthly_revenue = fields.Monetary( + string="Monthly Revenue", + currency_field="mcs_currency_id", + compute="_compute_mcs_account_metrics", + store=True, + ) + mcs_contracted_value = fields.Monetary( + string="Contracted Value", + currency_field="mcs_currency_id", + compute="_compute_mcs_account_metrics", + store=True, + ) + mcs_monthly_hours = fields.Float( + string="Monthly Hours", + compute="_compute_mcs_account_metrics", + store=True, + ) + mcs_effective_hourly_revenue = fields.Monetary( + string="Effective Hourly Revenue", + currency_field="mcs_currency_id", + compute="_compute_mcs_account_metrics", + store=True, + ) + mcs_account_owner_id = fields.Many2one( + "res.users", string="Account Owner", tracking=True + ) + mcs_next_account_action = fields.Char(string="Next Account Action", tracking=True) + mcs_account_risk_reason = fields.Text(string="Account Risk Reason", tracking=True) + mcs_project_ids = fields.One2many( + "project.project", "partner_id", string="MCS Workstreams" + ) + + @api.depends( + "mcs_project_ids.mcs_monthly_revenue_target", + "mcs_project_ids.mcs_contracted_revenue", + "mcs_project_ids.mcs_actual_hours", + "mcs_project_ids.mcs_portfolio_health", + "mcs_project_ids.active", + ) + def _compute_mcs_account_metrics(self): + for partner in self: + projects = partner.mcs_project_ids.filtered(lambda project: project.active) + partner.mcs_monthly_revenue = sum(projects.mapped("mcs_monthly_revenue_target")) + partner.mcs_contracted_value = sum(projects.mapped("mcs_contracted_revenue")) + partner.mcs_monthly_hours = sum(projects.mapped("mcs_actual_hours")) + partner.mcs_effective_hourly_revenue = ( + partner.mcs_monthly_revenue / partner.mcs_monthly_hours + if partner.mcs_monthly_hours + else 0 + ) + health_values = set(projects.mapped("mcs_portfolio_health")) + if "blocked" in health_values: + partner.mcs_account_health = "blocked" + elif "at_risk" in health_values: + partner.mcs_account_health = "at_risk" + elif "watch" in health_values: + partner.mcs_account_health = "watch" + elif projects and health_values == {"completed"}: + partner.mcs_account_health = "completed" + else: + partner.mcs_account_health = "healthy" diff --git a/addons/mcs_operating_system/security/ir.model.access.csv b/addons/mcs_operating_system/security/ir.model.access.csv index 96fce5d..4d59f72 100644 --- a/addons/mcs_operating_system/security/ir.model.access.csv +++ b/addons/mcs_operating_system/security/ir.model.access.csv @@ -9,4 +9,6 @@ access_mcs_sales_target_user,mcs.sales.target.user,model_mcs_sales_target,mcs_op access_mcs_sales_target_manager,mcs.sales.target.manager,model_mcs_sales_target,mcs_operating_system.group_mcs_operating_manager,1,1,1,1 access_mcs_client_profitability_user,mcs.client.profitability.user,model_mcs_client_profitability,mcs_operating_system.group_mcs_operating_user,1,1,1,0 access_mcs_client_profitability_manager,mcs.client.profitability.manager,model_mcs_client_profitability,mcs_operating_system.group_mcs_operating_manager,1,1,1,1 -access_mcs_operating_dashboard_user,mcs.operating.dashboard.user,model_mcs_operating_dashboard,mcs_operating_system.group_mcs_operating_user,1,0,0,0 +access_mcs_ceo_action_user,mcs.ceo.action.user,model_mcs_ceo_action,mcs_operating_system.group_mcs_operating_user,1,0,0,0 +access_mcs_ceo_action_manager,mcs.ceo.action.manager,model_mcs_ceo_action,mcs_operating_system.group_mcs_operating_manager,1,1,1,1 +access_mcs_operating_dashboard_manager,mcs.operating.dashboard.manager,model_mcs_operating_dashboard,mcs_operating_system.group_mcs_operating_manager,1,0,0,0 diff --git a/addons/mcs_operating_system/static/src/js/ceo_dashboard_v2.js b/addons/mcs_operating_system/static/src/js/ceo_dashboard_v2.js new file mode 100644 index 0000000..12694d6 --- /dev/null +++ b/addons/mcs_operating_system/static/src/js/ceo_dashboard_v2.js @@ -0,0 +1,68 @@ +/** @odoo-module **/ + +import { Component, onWillStart, useState } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; + +export class McsCeoDashboardV2 extends Component { + setup() { + this.action = useService("action"); + this.orm = useService("orm"); + this.state = useState({ data: null, loading: true }); + + onWillStart(async () => { + await this.loadDashboard(); + }); + } + + async loadDashboard() { + this.state.loading = true; + this.state.data = await this.orm.call( + "mcs.operating.dashboard", + "get_ceo_dashboard_v2_data", + [] + ); + this.state.loading = false; + } + + openAction(actionKey) { + const action = this.state.data?.actions?.[actionKey]; + if (action) { + this.action.doAction(action); + } + } + + openProject(projectId) { + this.action.doAction({ + type: "ir.actions.act_window", + name: "Project", + res_model: "project.project", + res_id: projectId, + view_mode: "form", + views: [[false, "form"]], + target: "current", + }); + } + + openClient(partnerId) { + this.action.doAction({ + type: "ir.actions.act_window", + name: "Client Account", + res_model: "res.partner", + res_id: partnerId, + view_mode: "form", + views: [[false, "form"]], + target: "current", + }); + } + + barStyle(value, maxValue) { + const max = maxValue || 1; + const width = Math.max(0, Math.min(100, (value || 0) / max * 100)); + return `width: ${width}%;`; + } +} + +McsCeoDashboardV2.template = "mcs_operating_system.CeoDashboardV2"; + +registry.category("actions").add("mcs_ceo_dashboard_v2", McsCeoDashboardV2); diff --git a/addons/mcs_operating_system/static/src/scss/ceo_dashboard_v2.scss b/addons/mcs_operating_system/static/src/scss/ceo_dashboard_v2.scss new file mode 100644 index 0000000..f6cd910 --- /dev/null +++ b/addons/mcs_operating_system/static/src/scss/ceo_dashboard_v2.scss @@ -0,0 +1,315 @@ +.o_mcs_ceo_dashboard { + min-height: 100%; + padding: 24px; + background: #f6f7f9; + color: #111827; + + button { + text-align: left; + } +} + +.o_mcs_loading { + padding: 40px; + font-size: 16px; +} + +.o_mcs_dashboard_header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; + + h1 { + margin: 0; + font-size: 26px; + font-weight: 700; + letter-spacing: 0; + } + + span { + color: #667085; + } +} + +.o_mcs_pulse { + display: grid; + grid-template-columns: minmax(220px, 1.4fr) repeat(4, minmax(140px, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.o_mcs_coverage, +.o_mcs_kpi, +.o_mcs_panel { + border: 1px solid #d9dee7; + border-radius: 8px; + background: #ffffff; + box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04); +} + +.o_mcs_coverage, +.o_mcs_kpi { + display: flex; + flex-direction: column; + justify-content: center; + min-height: 96px; + padding: 14px; +} + +.o_mcs_kpi { + border-color: #e5e7eb; + color: inherit; + + &:hover { + border-color: #2f6fed; + } +} + +.o_mcs_coverage span, +.o_mcs_kpi span { + color: #667085; + font-size: 12px; + text-transform: uppercase; +} + +.o_mcs_coverage strong { + font-size: 34px; + line-height: 1.1; +} + +.o_mcs_kpi strong { + margin-top: 6px; + font-size: 22px; +} + +.o_mcs_coverage small { + color: #667085; +} + +.o_mcs_grid { + display: grid; + gap: 16px; + margin-bottom: 16px; +} + +.o_mcs_grid_two { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); +} + +.o_mcs_panel { + padding: 16px; + min-width: 0; + + header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + } + + h2 { + margin: 0; + font-size: 17px; + font-weight: 700; + letter-spacing: 0; + } +} + +.o_mcs_badge { + display: inline-flex; + align-items: center; + width: fit-content; + min-height: 24px; + padding: 3px 8px; + border-radius: 6px; + font-size: 12px; + font-weight: 700; + text-transform: capitalize; +} + +.o_mcs_status_covered, +.o_mcs_status_healthy { + background: #e7f6ec; + color: #087443; +} + +.o_mcs_status_watch { + background: #fff6db; + color: #8a5a00; +} + +.o_mcs_status_critical, +.o_mcs_status_at_risk, +.o_mcs_status_blocked { + background: #fdecec; + color: #b42318; +} + +.o_mcs_revenue_bars, +.o_mcs_capacity, +.o_mcs_distribution, +.o_mcs_actions, +.o_mcs_internal { + display: grid; + gap: 10px; +} + +.o_mcs_bar_row, +.o_mcs_capacity_row, +.o_mcs_distribution_row { + display: grid; + grid-template-columns: 120px minmax(80px, 1fr) 80px; + align-items: center; + gap: 10px; +} + +.o_mcs_bar_label { + text-transform: capitalize; +} + +.o_mcs_bar_track { + height: 10px; + overflow: hidden; + border-radius: 999px; + background: #eef2f6; +} + +.o_mcs_bar_fill { + height: 100%; + border-radius: 999px; + background: #2f6fed; +} + +.o_mcs_capacity_underutilized { + background: #9aa4b2; +} + +.o_mcs_capacity_normal { + background: #16a34a; +} + +.o_mcs_capacity_near_capacity { + background: #f59e0b; +} + +.o_mcs_capacity_overloaded { + background: #dc2626; +} + +.o_mcs_funnel, +.o_mcs_health_grid, +.o_mcs_guardrail { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 10px; +} + +.o_mcs_funnel_stage, +.o_mcs_health_item, +.o_mcs_action_item, +.o_mcs_internal_item { + display: flex; + flex-direction: column; + gap: 4px; + min-height: 70px; + padding: 10px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #ffffff; + color: inherit; + + &:hover { + border-color: #2f6fed; + } +} + +.o_mcs_health_item strong { + font-size: 24px; +} + +.o_mcs_table { + overflow-x: auto; +} + +.o_mcs_table_head, +.o_mcs_table_row { + display: grid; + grid-template-columns: 1.3fr 92px 70px 90px 70px minmax(180px, 1.2fr); + gap: 10px; + align-items: center; + min-width: 760px; +} + +.o_mcs_table_head { + padding: 0 10px 8px; + color: #667085; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; +} + +.o_mcs_table_row { + width: 100%; + margin-bottom: 6px; + padding: 10px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #ffffff; + color: inherit; + + &:hover { + border-color: #2f6fed; + } +} + +.o_mcs_empty { + padding: 16px; + color: #667085; + border: 1px dashed #d9dee7; + border-radius: 8px; +} + +.o_mcs_guardrail > div { + padding: 12px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #ffffff; + + span, + small { + display: block; + color: #667085; + } + + strong { + display: block; + font-size: 24px; + } +} + +@media (max-width: 1100px) { + .o_mcs_pulse, + .o_mcs_grid_two { + grid-template-columns: 1fr; + } +} + +@media (max-width: 640px) { + .o_mcs_ceo_dashboard { + padding: 14px; + } + + .o_mcs_dashboard_header { + align-items: flex-start; + flex-direction: column; + } + + .o_mcs_bar_row, + .o_mcs_capacity_row, + .o_mcs_distribution_row { + grid-template-columns: 1fr; + } +} diff --git a/addons/mcs_operating_system/static/src/xml/ceo_dashboard_v2.xml b/addons/mcs_operating_system/static/src/xml/ceo_dashboard_v2.xml new file mode 100644 index 0000000..db4af5e --- /dev/null +++ b/addons/mcs_operating_system/static/src/xml/ceo_dashboard_v2.xml @@ -0,0 +1,192 @@ + + + +
+ +
Loading CEO dashboard...
+
+ +
+
+

CEO Command Center

+ + + to + + +
+ +
+ +
+
+ Revenue Coverage + % + Remaining gap: +
+ + + +
+ +
+
+
+

Revenue

+ +
+
+ +
+ +
+
+
+ +
+ +
+
+ +
+
+

Pipeline Coverage

+ + % + +
+
+ + + +
+
+
+ +
+
+
+

Client Portfolio

+ +
+
+
+ ClientHealthStreamsRevenueHoursNext Action +
+ + + +
+
+ +
+

Project Health

+
+ + + +
+
+
+ +
+
+
+

Team Capacity

+ +
+
+ +
+ +
+
+
+ % +
+ +
+
+ +
+

Where Time Went This Week

+
+ +
+ + h +
+
+
+
+
+ +
+
+
+

CEO Attention

+ +
+
+ +
No open CEO actions.
+
+ + + +
+
+ +
+
+

Internal Bets

+ +
+
+ + + +
+
+
+ +
+

Resource Allocation Guardrail

+
+ +
+ + % + Target ~% +
+
+
+
+
+
+
+
diff --git a/addons/mcs_operating_system/tests/__init__.py b/addons/mcs_operating_system/tests/__init__.py new file mode 100644 index 0000000..be82170 --- /dev/null +++ b/addons/mcs_operating_system/tests/__init__.py @@ -0,0 +1 @@ +from . import test_ceo_dashboard_v2 diff --git a/addons/mcs_operating_system/tests/test_ceo_dashboard_v2.py b/addons/mcs_operating_system/tests/test_ceo_dashboard_v2.py new file mode 100644 index 0000000..c638ddc --- /dev/null +++ b/addons/mcs_operating_system/tests/test_ceo_dashboard_v2.py @@ -0,0 +1,83 @@ +from odoo import fields +from odoo.tests.common import TransactionCase + + +class TestCeoDashboardV2(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env["res.partner"].create( + {"name": "V2 Test Client", "mcs_is_client_account": True} + ) + cls.project = cls.env["project.project"].create( + { + "name": "V2 Test Client - SEO", + "partner_id": cls.partner.id, + "allow_timesheets": True, + "mcs_delivery_type": "recurring", + "mcs_initiative_type": "client", + "mcs_monthly_revenue_target": 100000, + "mcs_contracted_revenue": 80000, + "mcs_next_action": "Ship next milestone", + "mcs_revenue_impact_score": 5, + "mcs_client_importance_score": 4, + "mcs_urgency_score": 3, + "mcs_completion_leverage_score": 2, + "mcs_resource_cost_score": 1, + } + ) + + def test_project_commercial_priority_score(self): + self.assertEqual(self.project.mcs_commercial_priority_score, 13) + + def test_client_account_rollup(self): + self.partner.invalidate_recordset() + self.assertEqual(self.partner.mcs_monthly_revenue, 100000) + self.assertEqual(self.partner.mcs_contracted_value, 80000) + + def test_ceo_action_workflow(self): + action = self.env["mcs.ceo.action"].create( + { + "title": "Approve V2 Test Proposal", + "category": "approval", + "priority": "critical", + "date_due": fields.Date.context_today(self.env.user), + } + ) + self.assertEqual(action.priority_rank, 0) + action.action_completed() + self.assertEqual(action.state, "completed") + self.assertTrue(action.date_completed) + + def test_dashboard_payload_contains_v2_sections(self): + data = self.env["mcs.operating.dashboard"].get_ceo_dashboard_v2_data() + for key in [ + "company_pulse", + "revenue", + "portfolio", + "project_health", + "capacity", + "time_distribution", + "allocation_guardrail", + "ceo_actions", + "internal_bets", + "actions", + ]: + self.assertIn(key, data) + self.assertIn("revenue_coverage", data["company_pulse"]) + self.assertIn("pipeline_coverage", data["revenue"]) + + def test_crm_followup_automation_is_idempotent(self): + lead = self.env["crm.lead"].create( + { + "name": "V2 Test Opportunity", + "type": "opportunity", + "partner_id": self.partner.id, + "user_id": self.env.user.id, + "expected_revenue": 25000, + "mcs_next_action": "Follow up", + } + ) + lead.action_mcs_schedule_followup() + lead.action_mcs_schedule_followup() + self.assertEqual(len(lead.activity_ids), 1) diff --git a/addons/mcs_operating_system/views/ceo_action_views.xml b/addons/mcs_operating_system/views/ceo_action_views.xml new file mode 100644 index 0000000..d883205 --- /dev/null +++ b/addons/mcs_operating_system/views/ceo_action_views.xml @@ -0,0 +1,103 @@ + + + mcs.ceo.action.tree + mcs.ceo.action + + + + + + + + + + + + + + + + + + mcs.ceo.action.form + mcs.ceo.action + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+
+
+ + + mcs.ceo.action.search + mcs.ceo.action + + + + + + + + + + + + + + + + + + + + + + + + + CEO Actions + mcs.ceo.action + tree,form,pivot,graph + {'search_default_open': 1} + +
diff --git a/addons/mcs_operating_system/views/crm_lead_views.xml b/addons/mcs_operating_system/views/crm_lead_views.xml index 966bbea..6e6188f 100644 --- a/addons/mcs_operating_system/views/crm_lead_views.xml +++ b/addons/mcs_operating_system/views/crm_lead_views.xml @@ -6,6 +6,7 @@