Add CEO dashboard V2 operating system

This commit is contained in:
metatroncubeswdev 2026-08-30 10:47:59 -04:00
parent a70dc1fcc0
commit 85a1987f1d
24 changed files with 1983 additions and 3 deletions

View File

@ -1 +1,3 @@
from . import models
from . import hooks
from .hooks import post_init_hook

View File

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

View File

@ -0,0 +1,3 @@
<odoo>
<function model="mcs.operating.dashboard" name="action_mcs_seed_v2_operating_data"/>
</odoo>

View File

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

View File

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

View File

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

View File

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

View File

@ -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"])],
},
}

View File

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

View File

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

View File

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

View File

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

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
9 access_mcs_sales_target_manager mcs.sales.target.manager model_mcs_sales_target mcs_operating_system.group_mcs_operating_manager 1 1 1 1
10 access_mcs_client_profitability_user mcs.client.profitability.user model_mcs_client_profitability mcs_operating_system.group_mcs_operating_user 1 1 1 0
11 access_mcs_client_profitability_manager mcs.client.profitability.manager model_mcs_client_profitability mcs_operating_system.group_mcs_operating_manager 1 1 1 1
12 access_mcs_operating_dashboard_user access_mcs_ceo_action_user mcs.operating.dashboard.user mcs.ceo.action.user model_mcs_operating_dashboard model_mcs_ceo_action mcs_operating_system.group_mcs_operating_user 1 0 0 0
13 access_mcs_ceo_action_manager mcs.ceo.action.manager model_mcs_ceo_action mcs_operating_system.group_mcs_operating_manager 1 1 1 1
14 access_mcs_operating_dashboard_manager mcs.operating.dashboard.manager model_mcs_operating_dashboard mcs_operating_system.group_mcs_operating_manager 1 0 0 0

View File

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

View File

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

View File

@ -0,0 +1,192 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mcs_operating_system.CeoDashboardV2">
<div class="o_mcs_ceo_dashboard">
<t t-if="state.loading">
<div class="o_mcs_loading">Loading CEO dashboard...</div>
</t>
<t t-else="">
<div class="o_mcs_dashboard_header">
<div>
<h1>CEO Command Center</h1>
<span>
<t t-esc="state.data.period.month_start"/>
to
<t t-esc="state.data.period.month_end"/>
</span>
</div>
<button class="btn btn-primary" t-on-click="loadDashboard">Refresh</button>
</div>
<section class="o_mcs_pulse">
<div t-attf-class="o_mcs_coverage o_mcs_status_{{ state.data.company_pulse.revenue_coverage_status }}">
<span>Revenue Coverage</span>
<strong><t t-esc="state.data.company_pulse.revenue_coverage"/>%</strong>
<small>Remaining gap: <t t-esc="state.data.company_pulse.remaining_gap"/></small>
</div>
<t t-foreach="state.data.kpis" t-as="kpi" t-key="kpi.label">
<button class="o_mcs_kpi" t-on-click="() => this.openAction(kpi.action_key)">
<span t-esc="kpi.label"/>
<strong t-esc="kpi.value"/>
</button>
</t>
</section>
<section class="o_mcs_grid o_mcs_grid_two">
<div class="o_mcs_panel">
<header>
<h2>Revenue</h2>
<button class="btn btn-link" t-on-click="() => this.openAction('pipeline')">Open Pipeline</button>
</header>
<div class="o_mcs_revenue_bars">
<t t-foreach="['target', 'contracted', 'invoiced', 'collected', 'forecast']" t-as="key" t-key="key">
<div class="o_mcs_bar_row">
<span class="o_mcs_bar_label" t-esc="key"/>
<div class="o_mcs_bar_track">
<div class="o_mcs_bar_fill" t-att-style="this.barStyle(state.data.revenue[key], state.data.revenue.target)"/>
</div>
<strong t-esc="state.data.revenue[key]"/>
</div>
</t>
</div>
</div>
<div class="o_mcs_panel">
<header>
<h2>Pipeline Coverage</h2>
<span t-attf-class="o_mcs_badge o_mcs_status_{{ state.data.revenue.pipeline_coverage_status }}">
<t t-esc="state.data.revenue.pipeline_coverage"/>%
</span>
</header>
<div class="o_mcs_funnel">
<t t-foreach="state.data.revenue.funnel" t-as="stage" t-key="stage.stage">
<button class="o_mcs_funnel_stage" t-on-click="() => this.openAction('pipeline')">
<span t-esc="stage.stage"/>
<strong><t t-esc="stage.count"/> / <t t-esc="stage.value"/></strong>
</button>
</t>
</div>
</div>
</section>
<section class="o_mcs_grid o_mcs_grid_two">
<div class="o_mcs_panel">
<header>
<h2>Client Portfolio</h2>
<button class="btn btn-link" t-on-click="() => this.openAction('projects_at_risk')">Risk View</button>
</header>
<div class="o_mcs_table">
<div class="o_mcs_table_head">
<span>Client</span><span>Health</span><span>Streams</span><span>Revenue</span><span>Hours</span><span>Next Action</span>
</div>
<t t-foreach="state.data.portfolio" t-as="row" t-key="row.id">
<button class="o_mcs_table_row" t-on-click="() => this.openClient(row.id)">
<span t-esc="row.name"/>
<span t-attf-class="o_mcs_badge o_mcs_status_{{ row.health }}" t-esc="row.health"/>
<span t-esc="row.active_workstreams"/>
<span t-esc="row.revenue"/>
<span t-esc="row.hours"/>
<span t-esc="row.next_action"/>
</button>
</t>
</div>
</div>
<div class="o_mcs_panel">
<header><h2>Project Health</h2></header>
<div class="o_mcs_health_grid">
<t t-foreach="state.data.project_health" t-as="item" t-key="item.label">
<button class="o_mcs_health_item" t-on-click="() => this.openAction('projects_at_risk')">
<strong t-esc="item.value"/>
<span t-esc="item.label"/>
</button>
</t>
</div>
</div>
</section>
<section class="o_mcs_grid o_mcs_grid_two">
<div class="o_mcs_panel">
<header>
<h2>Team Capacity</h2>
<button class="btn btn-link" t-on-click="() => this.openAction('capacity')">Timesheets</button>
</header>
<div class="o_mcs_capacity">
<t t-foreach="state.data.capacity" t-as="employee" t-key="employee.id">
<div class="o_mcs_capacity_row">
<span t-esc="employee.name"/>
<div class="o_mcs_bar_track">
<div t-attf-class="o_mcs_bar_fill o_mcs_capacity_{{ employee.status }}" t-att-style="this.barStyle(employee.utilization, 100)"/>
</div>
<strong><t t-esc="employee.utilization"/>%</strong>
</div>
</t>
</div>
</div>
<div class="o_mcs_panel">
<header><h2>Where Time Went This Week</h2></header>
<div class="o_mcs_distribution">
<t t-foreach="state.data.time_distribution.by_category" t-as="item" t-key="item.label">
<div class="o_mcs_distribution_row">
<span t-esc="item.label"/>
<strong><t t-esc="item.hours"/>h</strong>
</div>
</t>
</div>
</div>
</section>
<section class="o_mcs_grid o_mcs_grid_two">
<div class="o_mcs_panel">
<header>
<h2>CEO Attention</h2>
<button class="btn btn-link" t-on-click="() => this.openAction('ceo_actions')">All Actions</button>
</header>
<div class="o_mcs_actions">
<t t-if="!state.data.ceo_actions.length">
<div class="o_mcs_empty">No open CEO actions.</div>
</t>
<t t-foreach="state.data.ceo_actions" t-as="action" t-key="action.id">
<button class="o_mcs_action_item" t-on-click="() => this.openAction('ceo_actions')">
<strong t-esc="action.title"/>
<span><t t-esc="action.category"/> · <t t-esc="action.priority"/> · <t t-esc="action.state"/></span>
<small t-esc="action.next_action"/>
</button>
</t>
</div>
</div>
<div class="o_mcs_panel">
<header>
<h2>Internal Bets</h2>
<button class="btn btn-link" t-on-click="() => this.openAction('internal_bets')">Open</button>
</header>
<div class="o_mcs_internal">
<t t-foreach="state.data.internal_bets" t-as="project" t-key="project.id">
<button class="o_mcs_internal_item" t-on-click="() => this.openProject(project.id)">
<strong t-esc="project.name"/>
<span><t t-esc="project.stage"/> · <t t-esc="project.hours"/>h invested</span>
<small>Next: <t t-esc="project.next_milestone"/></small>
</button>
</t>
</div>
</div>
</section>
<section class="o_mcs_panel">
<header><h2>Resource Allocation Guardrail</h2></header>
<div class="o_mcs_guardrail">
<t t-foreach="state.data.allocation_guardrail" t-as="row" t-key="row.label">
<div>
<span t-esc="row.label"/>
<strong><t t-esc="row.percentage"/>%</strong>
<small>Target ~<t t-esc="row.target"/>%</small>
</div>
</t>
</div>
</section>
</t>
</div>
</t>
</templates>

View File

@ -0,0 +1 @@
from . import test_ceo_dashboard_v2

View File

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

View File

@ -0,0 +1,103 @@
<odoo>
<record id="view_mcs_ceo_action_tree" model="ir.ui.view">
<field name="name">mcs.ceo.action.tree</field>
<field name="model">mcs.ceo.action</field>
<field name="arch" type="xml">
<tree decoration-danger="priority == 'critical' or state == 'blocked'" decoration-warning="priority == 'high' or state == 'waiting'" decoration-muted="state in ('completed','cancelled')">
<field name="title"/>
<field name="category"/>
<field name="priority"/>
<field name="state"/>
<field name="date_due"/>
<field name="responsible_user_id"/>
<field name="partner_id"/>
<field name="project_id"/>
<field name="expected_financial_impact"/>
<field name="next_action"/>
</tree>
</field>
</record>
<record id="view_mcs_ceo_action_form" model="ir.ui.view">
<field name="name">mcs.ceo.action.form</field>
<field name="model">mcs.ceo.action</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_open" type="object" string="Open" invisible="state == 'open'"/>
<button name="action_waiting" type="object" string="Waiting" invisible="state == 'waiting'"/>
<button name="action_blocked" type="object" string="Blocked" invisible="state == 'blocked'"/>
<button name="action_completed" type="object" string="Complete" class="btn-primary" invisible="state == 'completed'"/>
<button name="action_cancelled" type="object" string="Cancel" invisible="state == 'cancelled'"/>
<field name="state" widget="statusbar" statusbar_visible="open,waiting,blocked,completed"/>
</header>
<sheet>
<group>
<group>
<field name="title"/>
<field name="category"/>
<field name="priority"/>
<field name="date_due"/>
<field name="responsible_user_id"/>
</group>
<group>
<field name="currency_id" invisible="1"/>
<field name="expected_financial_impact"/>
<field name="date_created"/>
<field name="date_completed"/>
</group>
</group>
<group>
<field name="partner_id"/>
<field name="project_id"/>
<field name="lead_id"/>
</group>
<group string="Decision Context">
<field name="business_impact"/>
<field name="next_action"/>
<field name="blocker"/>
</group>
<field name="notes"/>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="activity_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<record id="view_mcs_ceo_action_search" model="ir.ui.view">
<field name="name">mcs.ceo.action.search</field>
<field name="model">mcs.ceo.action</field>
<field name="arch" type="xml">
<search>
<field name="title"/>
<field name="category"/>
<field name="priority"/>
<field name="state"/>
<field name="responsible_user_id"/>
<field name="partner_id"/>
<field name="project_id"/>
<filter string="Open" name="open" domain="[('state','in',('open','waiting','blocked'))]"/>
<filter string="Must Do Today" name="due_today" domain="[('date_due','&lt;=',context_today().strftime('%Y-%m-%d')),('state','in',('open','waiting','blocked'))]"/>
<filter string="Revenue Actions" name="revenue" domain="[('category','in',('sales','finance','collection'))]"/>
<filter string="Strategic Decisions" name="strategy" domain="[('category','in',('strategy','approval','partnership'))]"/>
<group expand="0" string="Group By">
<filter string="Category" name="group_category" context="{'group_by':'category'}"/>
<filter string="Priority" name="group_priority" context="{'group_by':'priority'}"/>
<filter string="State" name="group_state" context="{'group_by':'state'}"/>
<filter string="Responsible" name="group_responsible" context="{'group_by':'responsible_user_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_mcs_ceo_action" model="ir.actions.act_window">
<field name="name">CEO Actions</field>
<field name="res_model">mcs.ceo.action</field>
<field name="view_mode">tree,form,pivot,graph</field>
<field name="context">{'search_default_open': 1}</field>
</record>
</odoo>

View File

@ -6,6 +6,7 @@
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<button name="action_mcs_schedule_followup" type="object" string="Schedule Follow-Up" invisible="type != 'opportunity' or activity_ids"/>
<button name="action_mcs_create_delivery_project" type="object" string="Create Delivery Project" invisible="type != 'opportunity' or probability &lt; 100"/>
</xpath>
<xpath expr="//notebook" position="inside">
<page string="Operating System" name="mcs_operating_system">

View File

@ -56,7 +56,7 @@
</record>
<record id="action_mcs_operating_dashboard" model="ir.actions.act_window">
<field name="name">CEO Dashboard</field>
<field name="name">CEO Dashboard Classic</field>
<field name="res_model">mcs.operating.dashboard</field>
<field name="view_mode">form</field>
<field name="view_id" ref="view_mcs_operating_dashboard_form"/>
@ -64,4 +64,9 @@
<field name="target">current</field>
<field name="context">{'create': False}</field>
</record>
<record id="action_mcs_ceo_dashboard_v2" model="ir.actions.client">
<field name="name">CEO Dashboard</field>
<field name="tag">mcs_ceo_dashboard_v2</field>
</record>
</odoo>

View File

@ -1,16 +1,18 @@
<odoo>
<menuitem id="menu_mcs_operating_root" name="MCS Operating System" sequence="5" groups="mcs_operating_system.group_mcs_operating_user"/>
<menuitem id="menu_mcs_operating_dashboard" name="CEO Dashboard" parent="menu_mcs_operating_root" action="action_mcs_operating_dashboard" sequence="10"/>
<menuitem id="menu_mcs_operating_dashboard" name="CEO Dashboard" parent="menu_mcs_operating_root" action="action_mcs_ceo_dashboard_v2" sequence="10" groups="mcs_operating_system.group_mcs_operating_manager"/>
<menuitem id="menu_mcs_operating_revenue" name="Revenue Board" parent="menu_mcs_operating_root" action="action_mcs_revenue_board" sequence="20"/>
<menuitem id="menu_mcs_operating_ship" name="Ship Board" parent="menu_mcs_operating_root" action="action_mcs_ship_board" sequence="30"/>
<menuitem id="menu_mcs_operating_capacity" name="Capacity Board" parent="menu_mcs_operating_root" action="action_mcs_capacity_board" sequence="40"/>
<menuitem id="menu_mcs_operating_management" name="Management" parent="menu_mcs_operating_root" sequence="50"/>
<menuitem id="menu_mcs_client_accounts" name="Client Accounts" parent="menu_mcs_operating_management" action="action_mcs_client_accounts" sequence="5"/>
<menuitem id="menu_mcs_company_goals" name="Company Goals" parent="menu_mcs_operating_management" action="action_mcs_company_goal" sequence="10"/>
<menuitem id="menu_mcs_sales_targets" name="Sales Targets" parent="menu_mcs_operating_management" action="action_mcs_sales_target" sequence="20"/>
<menuitem id="menu_mcs_operating_reviews" name="Operating Reviews" parent="menu_mcs_operating_management" action="action_mcs_operating_review" sequence="30"/>
<menuitem id="menu_mcs_idea_parking" name="Idea Parking Lot" parent="menu_mcs_operating_management" action="action_mcs_idea_parking" sequence="40"/>
<menuitem id="menu_mcs_client_profitability" name="Client Profitability" parent="menu_mcs_operating_management" action="action_mcs_client_profitability" sequence="50"/>
<menuitem id="menu_mcs_operating_projects" name="Operating Projects" parent="menu_mcs_operating_management" action="action_mcs_projects" sequence="60"/>
<menuitem id="menu_mcs_ceo_actions" name="CEO Actions" parent="menu_mcs_operating_management" action="action_mcs_ceo_action" sequence="70" groups="mcs_operating_system.group_mcs_operating_manager"/>
</odoo>

View File

@ -271,6 +271,9 @@
<field name="model">mcs.client.profitability</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_recompute_from_project" type="object" string="Recompute From Project" class="btn-primary"/>
</header>
<sheet>
<group>
<group>

View File

@ -10,7 +10,10 @@
<group string="Classification">
<field name="mcs_initiative_type"/>
<field name="mcs_classification"/>
<field name="mcs_delivery_type"/>
<field name="mcs_revenue_connected"/>
<field name="mcs_portfolio_health"/>
<field name="mcs_risk_score"/>
<field name="mcs_at_risk"/>
</group>
<group string="Revenue / Capacity">
@ -20,12 +23,40 @@
<field name="mcs_planned_hours"/>
<field name="mcs_actual_hours"/>
<field name="mcs_utilization"/>
<field name="mcs_commercial_priority_score"/>
</group>
</group>
<group>
<field name="mcs_next_action"/>
<field name="mcs_next_milestone"/>
<field name="mcs_blocker"/>
</group>
<group string="Portfolio Health">
<group>
<field name="mcs_last_meaningful_progress_date"/>
<field name="mcs_days_since_meaningful_progress"/>
<field name="mcs_stale"/>
<field name="mcs_stale_threshold_days"/>
<field name="mcs_launch_completion_date"/>
</group>
<group>
<field name="mcs_revenue_impact_score"/>
<field name="mcs_client_importance_score"/>
<field name="mcs_urgency_score"/>
<field name="mcs_completion_leverage_score"/>
<field name="mcs_resource_cost_score"/>
</group>
</group>
<group string="Internal Bet" invisible="mcs_delivery_type not in ('internal_product','internal_r_and_d')">
<group>
<field name="mcs_internal_product_stage"/>
<field name="mcs_next_decision_date"/>
</group>
<group>
<field name="mcs_internal_expected_revenue"/>
<field name="mcs_internal_actual_revenue"/>
</group>
</group>
</page>
</xpath>
</field>

View File

@ -0,0 +1,68 @@
<odoo>
<record id="view_partner_form_mcs_operating" model="ir.ui.view">
<field name="name">res.partner.form.mcs.operating</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page string="MCS Account" name="mcs_account">
<group>
<group string="Account Control">
<field name="mcs_is_client_account"/>
<field name="mcs_account_status"/>
<field name="mcs_account_health"/>
<field name="mcs_account_owner_id"/>
</group>
<group string="Commercial Rollup">
<field name="mcs_currency_id" invisible="1"/>
<field name="mcs_monthly_revenue"/>
<field name="mcs_contracted_value"/>
<field name="mcs_monthly_hours"/>
<field name="mcs_effective_hourly_revenue"/>
</group>
</group>
<group>
<field name="mcs_next_account_action"/>
<field name="mcs_account_risk_reason"/>
</group>
<field name="mcs_project_ids" readonly="1">
<tree>
<field name="name"/>
<field name="mcs_delivery_type"/>
<field name="mcs_portfolio_health"/>
<field name="mcs_monthly_revenue_target"/>
<field name="mcs_actual_hours"/>
<field name="mcs_next_action"/>
</tree>
</field>
</page>
</xpath>
</field>
</record>
<record id="view_partner_tree_mcs_client_account" model="ir.ui.view">
<field name="name">res.partner.tree.mcs.client.account</field>
<field name="model">res.partner</field>
<field name="arch" type="xml">
<tree decoration-danger="mcs_account_health in ('at_risk','blocked')" decoration-warning="mcs_account_health == 'watch'">
<field name="display_name"/>
<field name="mcs_account_status"/>
<field name="mcs_account_health"/>
<field name="mcs_account_owner_id"/>
<field name="mcs_monthly_revenue"/>
<field name="mcs_contracted_value"/>
<field name="mcs_monthly_hours"/>
<field name="mcs_next_account_action"/>
</tree>
</field>
</record>
<record id="action_mcs_client_accounts" model="ir.actions.act_window">
<field name="name">Client Accounts</field>
<field name="res_model">res.partner</field>
<field name="view_mode">tree,form</field>
<field name="view_id" ref="view_partner_tree_mcs_client_account"/>
<field name="domain">[('mcs_is_client_account','=',True)]</field>
<field name="context">{'default_mcs_is_client_account': True}</field>
</record>
</odoo>