Add CEO dashboard V2 operating system
This commit is contained in:
parent
a70dc1fcc0
commit
85a1987f1d
@ -1 +1,3 @@
|
|||||||
from . import models
|
from . import models
|
||||||
|
from . import hooks
|
||||||
|
from .hooks import post_init_hook
|
||||||
|
|||||||
@ -22,14 +22,25 @@
|
|||||||
"data/project_data.xml",
|
"data/project_data.xml",
|
||||||
"data/operating_data.xml",
|
"data/operating_data.xml",
|
||||||
"data/cron_data.xml",
|
"data/cron_data.xml",
|
||||||
|
"data/v2_seed_data.xml",
|
||||||
|
"views/res_partner_views.xml",
|
||||||
"views/crm_lead_views.xml",
|
"views/crm_lead_views.xml",
|
||||||
"views/project_views.xml",
|
"views/project_views.xml",
|
||||||
"views/timesheet_views.xml",
|
"views/timesheet_views.xml",
|
||||||
"views/hr_employee_views.xml",
|
"views/hr_employee_views.xml",
|
||||||
|
"views/ceo_action_views.xml",
|
||||||
"views/operating_views.xml",
|
"views/operating_views.xml",
|
||||||
"views/dashboard_views.xml",
|
"views/dashboard_views.xml",
|
||||||
"views/menu.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,
|
"installable": True,
|
||||||
"application": True,
|
"application": True,
|
||||||
}
|
}
|
||||||
|
|||||||
3
addons/mcs_operating_system/data/v2_seed_data.xml
Normal file
3
addons/mcs_operating_system/data/v2_seed_data.xml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<odoo>
|
||||||
|
<function model="mcs.operating.dashboard" name="action_mcs_seed_v2_operating_data"/>
|
||||||
|
</odoo>
|
||||||
139
addons/mcs_operating_system/hooks.py
Normal file
139
addons/mcs_operating_system/hooks.py
Normal 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})
|
||||||
@ -1,7 +1,9 @@
|
|||||||
from . import crm_lead
|
from . import crm_lead
|
||||||
|
from . import ceo_action
|
||||||
from . import dashboard
|
from . import dashboard
|
||||||
from . import hr_employee
|
from . import hr_employee
|
||||||
from . import operating
|
from . import operating
|
||||||
from . import project
|
from . import project
|
||||||
|
from . import res_partner
|
||||||
from . import sale_order
|
from . import sale_order
|
||||||
from . import timesheet
|
from . import timesheet
|
||||||
|
|||||||
103
addons/mcs_operating_system/models/ceo_action.py
Normal file
103
addons/mcs_operating_system/models/ceo_action.py
Normal 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
|
||||||
@ -101,6 +101,49 @@ class CrmLead(models.Model):
|
|||||||
)
|
)
|
||||||
return True
|
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):
|
def _cron_mcs_schedule_missing_followups(self):
|
||||||
leads = self.search(
|
leads = self.search(
|
||||||
[
|
[
|
||||||
|
|||||||
@ -172,3 +172,511 @@ class McsOperatingDashboard(models.Model):
|
|||||||
"target": "current",
|
"target": "current",
|
||||||
"context": {"create": False},
|
"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"])],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@ -233,3 +233,46 @@ class McsClientProfitability(models.Model):
|
|||||||
if record.client_revenue
|
if record.client_revenue
|
||||||
else 0
|
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"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@ -57,6 +57,78 @@ class ProjectProject(models.Model):
|
|||||||
mcs_at_risk = fields.Boolean(
|
mcs_at_risk = fields.Boolean(
|
||||||
string="At Risk", compute="_compute_mcs_at_risk", store=True
|
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")
|
@api.depends("mcs_monthly_revenue_target", "mcs_contracted_revenue")
|
||||||
def _compute_mcs_cash_gap(self):
|
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"))
|
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):
|
class ProjectTask(models.Model):
|
||||||
_inherit = "project.task"
|
_inherit = "project.task"
|
||||||
|
|||||||
98
addons/mcs_operating_system/models/res_partner.py
Normal file
98
addons/mcs_operating_system/models/res_partner.py
Normal 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"
|
||||||
@ -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_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_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_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
|
||||||
|
|||||||
|
@ -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);
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
192
addons/mcs_operating_system/static/src/xml/ceo_dashboard_v2.xml
Normal file
192
addons/mcs_operating_system/static/src/xml/ceo_dashboard_v2.xml
Normal 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>
|
||||||
1
addons/mcs_operating_system/tests/__init__.py
Normal file
1
addons/mcs_operating_system/tests/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import test_ceo_dashboard_v2
|
||||||
83
addons/mcs_operating_system/tests/test_ceo_dashboard_v2.py
Normal file
83
addons/mcs_operating_system/tests/test_ceo_dashboard_v2.py
Normal 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)
|
||||||
103
addons/mcs_operating_system/views/ceo_action_views.xml
Normal file
103
addons/mcs_operating_system/views/ceo_action_views.xml
Normal 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','<=',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>
|
||||||
@ -6,6 +6,7 @@
|
|||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<xpath expr="//header" position="inside">
|
<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_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 < 100"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
<xpath expr="//notebook" position="inside">
|
<xpath expr="//notebook" position="inside">
|
||||||
<page string="Operating System" name="mcs_operating_system">
|
<page string="Operating System" name="mcs_operating_system">
|
||||||
|
|||||||
@ -56,7 +56,7 @@
|
|||||||
</record>
|
</record>
|
||||||
|
|
||||||
<record id="action_mcs_operating_dashboard" model="ir.actions.act_window">
|
<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="res_model">mcs.operating.dashboard</field>
|
||||||
<field name="view_mode">form</field>
|
<field name="view_mode">form</field>
|
||||||
<field name="view_id" ref="view_mcs_operating_dashboard_form"/>
|
<field name="view_id" ref="view_mcs_operating_dashboard_form"/>
|
||||||
@ -64,4 +64,9 @@
|
|||||||
<field name="target">current</field>
|
<field name="target">current</field>
|
||||||
<field name="context">{'create': False}</field>
|
<field name="context">{'create': False}</field>
|
||||||
</record>
|
</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>
|
</odoo>
|
||||||
|
|||||||
@ -1,16 +1,18 @@
|
|||||||
<odoo>
|
<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_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_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_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_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_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_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_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_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_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_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_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>
|
</odoo>
|
||||||
|
|||||||
@ -271,6 +271,9 @@
|
|||||||
<field name="model">mcs.client.profitability</field>
|
<field name="model">mcs.client.profitability</field>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<form>
|
<form>
|
||||||
|
<header>
|
||||||
|
<button name="action_recompute_from_project" type="object" string="Recompute From Project" class="btn-primary"/>
|
||||||
|
</header>
|
||||||
<sheet>
|
<sheet>
|
||||||
<group>
|
<group>
|
||||||
<group>
|
<group>
|
||||||
|
|||||||
@ -10,7 +10,10 @@
|
|||||||
<group string="Classification">
|
<group string="Classification">
|
||||||
<field name="mcs_initiative_type"/>
|
<field name="mcs_initiative_type"/>
|
||||||
<field name="mcs_classification"/>
|
<field name="mcs_classification"/>
|
||||||
|
<field name="mcs_delivery_type"/>
|
||||||
<field name="mcs_revenue_connected"/>
|
<field name="mcs_revenue_connected"/>
|
||||||
|
<field name="mcs_portfolio_health"/>
|
||||||
|
<field name="mcs_risk_score"/>
|
||||||
<field name="mcs_at_risk"/>
|
<field name="mcs_at_risk"/>
|
||||||
</group>
|
</group>
|
||||||
<group string="Revenue / Capacity">
|
<group string="Revenue / Capacity">
|
||||||
@ -20,12 +23,40 @@
|
|||||||
<field name="mcs_planned_hours"/>
|
<field name="mcs_planned_hours"/>
|
||||||
<field name="mcs_actual_hours"/>
|
<field name="mcs_actual_hours"/>
|
||||||
<field name="mcs_utilization"/>
|
<field name="mcs_utilization"/>
|
||||||
|
<field name="mcs_commercial_priority_score"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
<group>
|
<group>
|
||||||
<field name="mcs_next_action"/>
|
<field name="mcs_next_action"/>
|
||||||
|
<field name="mcs_next_milestone"/>
|
||||||
<field name="mcs_blocker"/>
|
<field name="mcs_blocker"/>
|
||||||
</group>
|
</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>
|
</page>
|
||||||
</xpath>
|
</xpath>
|
||||||
</field>
|
</field>
|
||||||
|
|||||||
68
addons/mcs_operating_system/views/res_partner_views.xml
Normal file
68
addons/mcs_operating_system/views/res_partner_views.xml
Normal 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>
|
||||||
Loading…
x
Reference in New Issue
Block a user