Compare commits

...

12 Commits

Author SHA1 Message Date
gsinghpal
b70fff01e1 feat(fusion_claims): bump version to 19.0.9.0.0 for dashboard rewrite
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:53:25 -04:00
gsinghpal
07f9bcf79b feat(fusion_claims): add OWL countdown widget for posting deadline
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:53:18 -04:00
gsinghpal
1420a5c445 feat(fusion_claims): add dashboard SCSS with dual-bundle theming
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:52:57 -04:00
gsinghpal
2bfb1015ea feat(fusion_claims): rewrite dashboard form view with action-oriented layout
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:51:59 -04:00
gsinghpal
ace82de88c feat(fusion_claims): add dashboard create-SO hotlinks
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:50:58 -04:00
gsinghpal
1b1e9fdb9e feat(fusion_claims): add dashboard open-list action methods
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:50:32 -04:00
gsinghpal
95e0e2d9bd feat(fusion_claims): add dashboard ADP + MOD workflow tile counts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:49:48 -04:00
gsinghpal
cdc9f864b2 feat(fusion_claims): add dashboard other-funder counts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:49:10 -04:00
gsinghpal
a00c891277 feat(fusion_claims): add dashboard activities and bottlenecks
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:48:41 -04:00
gsinghpal
f45883233c feat(fusion_claims): add dashboard KPI tiles (ready/claimed/AR)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:48:08 -04:00
gsinghpal
d5e79cdc10 feat(fusion_claims): add dashboard banner fields
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:47:24 -04:00
gsinghpal
1a8a96d94e feat(fusion_claims): scaffold dashboard model with role filter
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:46:17 -04:00
8 changed files with 1546 additions and 251 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Claims',
'version': '19.0.8.0.7',
'version': '19.0.9.0.0',
'category': 'Sales',
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
'description': """
@@ -175,6 +175,18 @@
'fusion_claims/static/src/js/attachment_image_compress.js',
'fusion_claims/static/src/js/debug_required_fields.js',
'fusion_claims/static/src/xml/document_preview.xml',
# Dashboard: tokens MUST load before dashboard layout
'fusion_claims/static/src/scss/_fc_dashboard_tokens.scss',
'fusion_claims/static/src/scss/fc_dashboard.scss',
# Dashboard OWL countdown widget
'fusion_claims/static/src/js/fc_posting_countdown.js',
'fusion_claims/static/src/xml/fc_posting_countdown.xml',
],
'web.assets_web_dark': [
# Dark bundle recompiles the same SCSS with the dark
# $o-webclient-color-scheme default so tokens branch correctly.
'fusion_claims/static/src/scss/_fc_dashboard_tokens.scss',
'fusion_claims/static/src/scss/fc_dashboard.scss',
],
},
'images': ['static/description/icon.png'],

View File

@@ -4,159 +4,544 @@
from odoo import api, fields, models
CASE_TYPE_SELECTION = [
('adp', 'ADP'),
('odsp', 'ODSP'),
('march_of_dimes', 'March of Dimes'),
('hardship', 'Hardship Funding'),
('acsd', 'ACSD'),
('muscular_dystrophy', 'Muscular Dystrophy'),
('insurance', 'Insurance'),
('wsib', 'WSIB'),
]
TYPE_DOMAINS = {
'adp': [('x_fc_sale_type', 'in', ['adp', 'adp_odsp'])],
'odsp': [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp'])],
'march_of_dimes': [('x_fc_sale_type', '=', 'march_of_dimes')],
'hardship': [('x_fc_sale_type', '=', 'hardship')],
'acsd': [('x_fc_client_type', '=', 'ACS')],
'muscular_dystrophy': [('x_fc_sale_type', '=', 'muscular_dystrophy')],
'insurance': [('x_fc_sale_type', '=', 'insurance')],
'wsib': [('x_fc_sale_type', '=', 'wsib')],
}
TYPE_LABELS = dict(CASE_TYPE_SELECTION)
class FusionClaimsDashboard(models.TransientModel):
_name = 'fusion.claims.dashboard'
_inherit = 'fusion_claims.adp.posting.schedule.mixin'
_description = 'Fusion Claims Dashboard'
_rec_name = 'name'
name = fields.Char(default='Dashboard', readonly=True)
# Case counts by funding type
adp_count = fields.Integer(compute='_compute_stats')
odsp_count = fields.Integer(compute='_compute_stats')
march_of_dimes_count = fields.Integer(compute='_compute_stats')
hardship_count = fields.Integer(compute='_compute_stats')
acsd_count = fields.Integer(compute='_compute_stats')
muscular_dystrophy_count = fields.Integer(compute='_compute_stats')
insurance_count = fields.Integer(compute='_compute_stats')
wsib_count = fields.Integer(compute='_compute_stats')
total_profiles = fields.Integer(compute='_compute_stats')
# =========================================================================
# Role-aware filter
# =========================================================================
is_manager = fields.Boolean(compute='_compute_is_manager')
# Panel selectors (4 panels)
panel1_type = fields.Selection(CASE_TYPE_SELECTION, string='Window 1', default='adp')
panel2_type = fields.Selection(CASE_TYPE_SELECTION, string='Window 2', default='odsp')
panel3_type = fields.Selection(CASE_TYPE_SELECTION, string='Window 3', default='march_of_dimes')
panel4_type = fields.Selection(CASE_TYPE_SELECTION, string='Window 4', default='hardship')
# Panel HTML
panel1_html = fields.Html(compute='_compute_panels', sanitize=False)
panel2_html = fields.Html(compute='_compute_panels', sanitize=False)
panel3_html = fields.Html(compute='_compute_panels', sanitize=False)
panel4_html = fields.Html(compute='_compute_panels', sanitize=False)
panel1_title = fields.Char(compute='_compute_panels')
panel2_title = fields.Char(compute='_compute_panels')
panel3_title = fields.Char(compute='_compute_panels')
panel4_title = fields.Char(compute='_compute_panels')
def _compute_stats(self):
SO = self.env['sale.order'].sudo()
Profile = self.env['fusion.client.profile'].sudo()
def _compute_is_manager(self):
manager_group = self.env.ref('fusion_claims.group_fusion_claims_manager',
raise_if_not_found=False)
sale_mgr_group = self.env.ref('sales_team.group_sale_manager',
raise_if_not_found=False)
for rec in self:
rec.adp_count = SO.search_count(TYPE_DOMAINS['adp'])
rec.odsp_count = SO.search_count(TYPE_DOMAINS['odsp'])
rec.march_of_dimes_count = SO.search_count(TYPE_DOMAINS['march_of_dimes'])
rec.hardship_count = SO.search_count(TYPE_DOMAINS['hardship'])
rec.acsd_count = SO.search_count(TYPE_DOMAINS['acsd'])
rec.muscular_dystrophy_count = SO.search_count(TYPE_DOMAINS['muscular_dystrophy'])
rec.insurance_count = SO.search_count(TYPE_DOMAINS['insurance'])
rec.wsib_count = SO.search_count(TYPE_DOMAINS['wsib'])
rec.total_profiles = Profile.search_count([])
@api.depends('panel1_type', 'panel2_type', 'panel3_type', 'panel4_type')
def _compute_panels(self):
SO = self.env['sale.order'].sudo()
for rec in self:
for i in range(1, 5):
ptype = getattr(rec, f'panel{i}_type') or 'adp'
domain = TYPE_DOMAINS.get(ptype, [])
orders = SO.search(domain, order='create_date desc', limit=50)
count = SO.search_count(domain)
title = f'Window {i} - {TYPE_LABELS.get(ptype, ptype)} ({count} cases)'
html = rec._build_top_list(orders)
setattr(rec, f'panel{i}_title', title)
setattr(rec, f'panel{i}_html', html)
def _build_top_list(self, orders):
if not orders:
return '<p class="text-muted text-center py-4">No cases found</p>'
rows = []
for o in orders:
status = o.x_fc_adp_application_status or ''
status_label = dict(o._fields['x_fc_adp_application_status'].selection).get(status, status)
rows.append(
f'<tr>'
f'<td><a href="/odoo/sales/{o.id}">{o.name}</a></td>'
f'<td>{o.partner_id.name or ""}</td>'
f'<td>{status_label}</td>'
f'<td class="text-end">${o.amount_total:,.2f}</td>'
f'</tr>'
user = rec.env.user
rec.is_manager = bool(
(manager_group and user.has_group('fusion_claims.group_fusion_claims_manager'))
or (sale_mgr_group and user.has_group('sales_team.group_sale_manager'))
)
return (
'<table class="table table-sm table-hover mb-0">'
'<thead><tr><th>Order</th><th>Client</th><th>Status</th><th class="text-end">Total</th></tr></thead>'
'<tbody>' + ''.join(rows) + '</tbody></table>'
)
def action_open_order(self, order_id):
"""Open a specific sale order with breadcrumbs."""
def _role_filter_domain(self):
"""Common domain prefix for SO-based counts.
Managers (fusion_claims.group_fusion_claims_manager or
sales_team.group_sale_manager) see everything.
Other users see only SOs where they are the salesperson.
"""
self.ensure_one()
if self.is_manager:
return []
return [('user_id', '=', self.env.user.id)]
# =========================================================================
# Header banner
# =========================================================================
posting_period_label = fields.Char(compute='_compute_banner')
posting_period_start = fields.Date(compute='_compute_banner')
posting_period_end = fields.Date(compute='_compute_banner')
submission_deadline_dt = fields.Datetime(compute='_compute_banner')
is_pre_first_posting = fields.Boolean(compute='_compute_banner')
def _compute_banner(self):
from datetime import date, datetime, time, timedelta
import pytz
today = date.today()
for rec in self:
base_date = rec._get_adp_posting_base_date()
rec.is_pre_first_posting = today < base_date
current = rec._get_current_posting_date(today)
nxt = rec._get_next_posting_date(today)
# If we're sitting on a posting date, current == next; treat
# the period as the one starting today.
if current == nxt:
period_start = current
period_end = current + timedelta(days=rec._get_adp_posting_frequency())
else:
period_start = current
period_end = nxt
rec.posting_period_start = period_start
rec.posting_period_end = period_end
if rec.is_pre_first_posting:
rec.posting_period_label = f"Posting starts {base_date.strftime('%b %d')}"
else:
rec.posting_period_label = (
f"{period_start.strftime('%b %d')} "
f"{period_end.strftime('%b %d')}"
)
wednesday = rec._get_posting_week_wednesday(nxt)
naive_deadline = datetime.combine(wednesday, time(18, 0, 0))
# Store as UTC; users see it in their TZ; OWL widget computes in local TZ.
tz = pytz.timezone(rec.env.user.tz or 'America/Toronto')
local_deadline = tz.localize(naive_deadline)
rec.submission_deadline_dt = local_deadline.astimezone(pytz.UTC).replace(tzinfo=None)
# =========================================================================
# KPI tiles (3-up)
# =========================================================================
currency_id = fields.Many2one('res.currency', compute='_compute_kpis')
kpi_ready_amount = fields.Monetary(compute='_compute_kpis',
currency_field='currency_id')
kpi_ready_count = fields.Integer(compute='_compute_kpis')
kpi_claimed_amount = fields.Monetary(compute='_compute_kpis',
currency_field='currency_id')
kpi_claimed_count = fields.Integer(compute='_compute_kpis')
kpi_ar_amount = fields.Monetary(compute='_compute_kpis',
currency_field='currency_id')
kpi_ar_count = fields.Integer(compute='_compute_kpis')
def _invoice_role_filter(self):
"""Role filter for invoices — applied through linked SO's user_id."""
self.ensure_one()
if self.is_manager:
return []
return [('x_fc_source_sale_order_id.user_id', '=', self.env.user.id)]
def _compute_kpis(self):
Move = self.env['account.move'].sudo()
for rec in self:
rec.currency_id = rec.env.company.currency_id
inv_filter = rec._invoice_role_filter()
# KPI 1: Ready to Claim
ready_domain = inv_filter + [
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('x_fc_adp_billing_status', '=', 'waiting'),
('adp_exported', '=', False),
]
ready_invoices = Move.search(ready_domain)
rec.kpi_ready_count = len(ready_invoices)
rec.kpi_ready_amount = sum(ready_invoices.mapped('amount_total'))
# KPI 2: Claimed This Period
claimed_domain = inv_filter + [
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('x_fc_adp_billing_status', 'in', ['submitted', 'resubmitted']),
('adp_export_date', '>=', rec.posting_period_start),
]
claimed_invoices = Move.search(claimed_domain)
rec.kpi_claimed_count = len(claimed_invoices)
rec.kpi_claimed_amount = sum(claimed_invoices.mapped('amount_total'))
# KPI 3: Total AR (ADP-portion invoices, unpaid)
ar_domain = inv_filter + [
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('x_fc_invoice_type', '=', 'adp'),
('payment_state', 'in', ['not_paid', 'partial']),
]
ar_invoices = Move.search(ar_domain)
rec.kpi_ar_count = len(ar_invoices)
rec.kpi_ar_amount = sum(ar_invoices.mapped('amount_total'))
# =========================================================================
# Activities (left column)
# =========================================================================
my_activities_count = fields.Integer(compute='_compute_activities')
my_activities_html = fields.Html(compute='_compute_activities', sanitize=False)
def _compute_activities(self):
Activity = self.env['mail.activity'].sudo()
domain = [
('user_id', '=', self.env.user.id),
('res_model', 'in', ['sale.order', 'account.move', 'fusion.technician.task']),
]
for rec in self:
activities = Activity.search(domain, order='date_deadline asc', limit=10)
rec.my_activities_count = Activity.search_count(domain)
if not activities:
rec.my_activities_html = (
'<p class="o_fc_empty">No activities assigned.</p>'
)
continue
from datetime import date
today = date.today()
rows = []
for act in activities:
overdue = act.date_deadline and act.date_deadline < today
row_class = 'o_fc_activity_row o_fc_activity_overdue' if overdue else 'o_fc_activity_row'
deadline_text = act.date_deadline.strftime('%b %d') if act.date_deadline else ''
url = f'/odoo/{act.res_model.replace(".", "_")}/{act.res_id}'
rows.append(
f'<div class="{row_class}">'
f'<a href="{url}"><b>{act.summary or act.activity_type_id.name or "Activity"}</b></a>'
f'<span class="o_fc_activity_deadline">{deadline_text}</span>'
f'</div>'
)
rec.my_activities_html = '\n'.join(rows)
# =========================================================================
# Bottlenecks (left column) + Other funder counts
# =========================================================================
bottleneck_no_pod_count = fields.Integer(compute='_compute_secondary_counts')
bottleneck_no_response_count = fields.Integer(compute='_compute_secondary_counts')
count_odsp = fields.Integer(compute='_compute_secondary_counts')
count_wsib = fields.Integer(compute='_compute_secondary_counts')
count_insurance = fields.Integer(compute='_compute_secondary_counts')
count_mdc = fields.Integer(compute='_compute_secondary_counts')
count_hardship = fields.Integer(compute='_compute_secondary_counts')
count_acsd = fields.Integer(compute='_compute_secondary_counts')
def _compute_secondary_counts(self):
from datetime import date, timedelta
SO = self.env['sale.order'].sudo()
cutoff_14d_ago = date.today() - timedelta(days=14)
for rec in self:
base = rec._role_filter_domain()
active = base + [('state', '!=', 'cancel')]
rec.bottleneck_no_pod_count = SO.search_count(base + [
('x_fc_adp_application_status', 'in', ['approved', 'approved_deduction']),
('x_fc_proof_of_delivery', '=', False),
])
rec.bottleneck_no_response_count = SO.search_count(base + [
('x_fc_adp_application_status', 'in', ['submitted', 'resubmitted']),
('x_fc_claim_submission_date', '<', cutoff_14d_ago),
])
rec.count_odsp = SO.search_count(active + [
('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']),
])
rec.count_wsib = SO.search_count(active + [('x_fc_sale_type', '=', 'wsib')])
rec.count_insurance = SO.search_count(active + [('x_fc_sale_type', '=', 'insurance')])
rec.count_mdc = SO.search_count(active + [('x_fc_sale_type', '=', 'muscular_dystrophy')])
rec.count_hardship = SO.search_count(active + [('x_fc_sale_type', '=', 'hardship')])
rec.count_acsd = SO.search_count(active + [('x_fc_client_type', '=', 'ACS')])
# =========================================================================
# ADP Pre-Approval (right column, 4 tiles)
# =========================================================================
adp_waiting_app_count = fields.Integer(compute='_compute_workflow_counts')
adp_app_received_count = fields.Integer(compute='_compute_workflow_counts')
adp_ready_submit_count = fields.Integer(compute='_compute_workflow_counts')
adp_needs_correction_count = fields.Integer(compute='_compute_workflow_counts')
# =========================================================================
# ADP Post-Approval (right column, 4 tiles)
# =========================================================================
adp_approved_count = fields.Integer(compute='_compute_workflow_counts')
adp_ready_delivery_count = fields.Integer(compute='_compute_workflow_counts')
adp_ready_bill_count = fields.Integer(compute='_compute_workflow_counts')
adp_on_hold_count = fields.Integer(compute='_compute_workflow_counts')
# =========================================================================
# MOD (right column, 5 tiles)
# =========================================================================
mod_awaiting_funding_count = fields.Integer(compute='_compute_workflow_counts')
mod_funding_approved_count = fields.Integer(compute='_compute_workflow_counts')
mod_pca_received_count = fields.Integer(compute='_compute_workflow_counts')
mod_project_complete_count = fields.Integer(compute='_compute_workflow_counts')
mod_pod_submitted_count = fields.Integer(compute='_compute_workflow_counts')
def _compute_workflow_counts(self):
SO = self.env['sale.order'].sudo()
for rec in self:
base = rec._role_filter_domain()
# ADP Pre-Approval
rec.adp_waiting_app_count = SO.search_count(base + [
('x_fc_adp_application_status', 'in',
['waiting_for_application', 'assessment_completed']),
])
rec.adp_app_received_count = SO.search_count(base + [
('x_fc_adp_application_status', '=', 'application_received'),
])
rec.adp_ready_submit_count = SO.search_count(base + [
('x_fc_adp_application_status', '=', 'ready_submission'),
])
rec.adp_needs_correction_count = SO.search_count(base + [
('x_fc_adp_application_status', '=', 'needs_correction'),
])
# ADP Post-Approval
rec.adp_approved_count = SO.search_count(base + [
('x_fc_adp_application_status', 'in', ['approved', 'approved_deduction']),
])
rec.adp_ready_delivery_count = SO.search_count(base + [
('x_fc_adp_application_status', '=', 'ready_delivery'),
])
rec.adp_ready_bill_count = SO.search_count(base + [
('x_fc_adp_application_status', '=', 'ready_bill'),
])
rec.adp_on_hold_count = SO.search_count(base + [
('x_fc_adp_application_status', '=', 'on_hold'),
])
# MOD
rec.mod_awaiting_funding_count = SO.search_count(base + [
('x_fc_mod_status', '=', 'awaiting_funding'),
])
rec.mod_funding_approved_count = SO.search_count(base + [
('x_fc_mod_status', '=', 'funding_approved'),
])
rec.mod_pca_received_count = SO.search_count(base + [
('x_fc_mod_status', '=', 'contract_received'),
])
rec.mod_project_complete_count = SO.search_count(base + [
('x_fc_mod_status', '=', 'project_complete'),
])
rec.mod_pod_submitted_count = SO.search_count(base + [
('x_fc_mod_status', '=', 'pod_submitted'),
])
# =========================================================================
# Open-list action methods
# =========================================================================
def _so_list_action(self, name, domain):
return {
'type': 'ir.actions.act_window',
'name': 'Sale Order',
'name': name,
'res_model': 'sale.order',
'view_mode': 'form',
'res_id': order_id,
'view_mode': 'list,form',
'domain': self._role_filter_domain() + domain,
'target': 'current',
}
def action_open_adp(self):
return self._open_type_action('adp')
# ----- ADP Pre-Approval -----
def action_open_adp_waiting_app(self):
return self._so_list_action('ADP — Waiting for Application', [
('x_fc_adp_application_status', 'in',
['waiting_for_application', 'assessment_completed']),
])
def action_open_odsp(self):
return self._open_type_action('odsp')
def action_open_adp_app_received(self):
return self._so_list_action('ADP — Application Received', [
('x_fc_adp_application_status', '=', 'application_received'),
])
def action_open_march(self):
return self._open_type_action('march_of_dimes')
def action_open_adp_ready_submit(self):
return self._so_list_action('ADP — Ready for Submission', [
('x_fc_adp_application_status', '=', 'ready_submission'),
])
def action_open_hardship(self):
return self._open_type_action('hardship')
def action_open_adp_needs_correction(self):
return self._so_list_action('ADP — Needs Correction', [
('x_fc_adp_application_status', '=', 'needs_correction'),
])
def action_open_acsd(self):
return self._open_type_action('acsd')
# ----- ADP Post-Approval -----
def action_open_adp_approved(self):
return self._so_list_action('ADP — Approved', [
('x_fc_adp_application_status', 'in', ['approved', 'approved_deduction']),
])
def action_open_muscular(self):
return self._open_type_action('muscular_dystrophy')
def action_open_adp_ready_delivery(self):
return self._so_list_action('ADP — Ready for Delivery', [
('x_fc_adp_application_status', '=', 'ready_delivery'),
])
def action_open_insurance(self):
return self._open_type_action('insurance')
def action_open_adp_ready_bill(self):
return self._so_list_action('ADP — Ready to Bill', [
('x_fc_adp_application_status', '=', 'ready_bill'),
])
def action_open_wsib(self):
return self._open_type_action('wsib')
def action_open_adp_on_hold(self):
return self._so_list_action('ADP — On Hold', [
('x_fc_adp_application_status', '=', 'on_hold'),
])
def action_open_profiles(self):
return {
'type': 'ir.actions.act_window', 'name': 'Client Profiles',
'res_model': 'fusion.client.profile', 'view_mode': 'list,form',
}
# ----- MOD -----
def action_open_mod_awaiting_funding(self):
return self._so_list_action('MOD — Awaiting Funding', [
('x_fc_mod_status', '=', 'awaiting_funding'),
])
def _open_type_action(self, type_key):
def action_open_mod_funding_approved(self):
return self._so_list_action('MOD — Funding Approved', [
('x_fc_mod_status', '=', 'funding_approved'),
])
def action_open_mod_pca_received(self):
return self._so_list_action('MOD — PCA Received', [
('x_fc_mod_status', '=', 'contract_received'),
])
def action_open_mod_project_complete(self):
return self._so_list_action('MOD — Project Complete', [
('x_fc_mod_status', '=', 'project_complete'),
])
def action_open_mod_pod_submitted(self):
return self._so_list_action('MOD — POD Submitted', [
('x_fc_mod_status', '=', 'pod_submitted'),
])
# ----- Other funders -----
def action_open_odsp_cases(self):
return self._so_list_action('ODSP Cases', [
('state', '!=', 'cancel'),
('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']),
])
def action_open_wsib_cases(self):
return self._so_list_action('WSIB Cases', [
('state', '!=', 'cancel'),
('x_fc_sale_type', '=', 'wsib'),
])
def action_open_insurance_cases(self):
return self._so_list_action('Insurance Cases', [
('state', '!=', 'cancel'),
('x_fc_sale_type', '=', 'insurance'),
])
def action_open_mdc_cases(self):
return self._so_list_action('Muscular Dystrophy Cases', [
('state', '!=', 'cancel'),
('x_fc_sale_type', '=', 'muscular_dystrophy'),
])
def action_open_hardship_cases(self):
return self._so_list_action('Hardship Cases', [
('state', '!=', 'cancel'),
('x_fc_sale_type', '=', 'hardship'),
])
def action_open_acsd_cases(self):
return self._so_list_action('ACSD Cases', [
('state', '!=', 'cancel'),
('x_fc_client_type', '=', 'ACS'),
])
# ----- Bottlenecks -----
def action_open_bottleneck_no_pod(self):
return self._so_list_action('Bottleneck — Approved without POD', [
('x_fc_adp_application_status', 'in', ['approved', 'approved_deduction']),
('x_fc_proof_of_delivery', '=', False),
])
def action_open_bottleneck_no_response(self):
from datetime import date, timedelta
cutoff = date.today() - timedelta(days=14)
return self._so_list_action('Bottleneck — Submitted, no response', [
('x_fc_adp_application_status', 'in', ['submitted', 'resubmitted']),
('x_fc_claim_submission_date', '<', cutoff),
])
# ----- Activities -----
def action_open_my_activities(self):
return {
'type': 'ir.actions.act_window',
'name': f'{TYPE_LABELS.get(type_key, type_key)} Cases',
'res_model': 'sale.order', 'view_mode': 'list,form',
'domain': TYPE_DOMAINS.get(type_key, []),
'name': 'My Activities',
'res_model': 'mail.activity',
'view_mode': 'list,form',
'domain': [
('user_id', '=', self.env.user.id),
('res_model', 'in', ['sale.order', 'account.move',
'fusion.technician.task']),
],
'target': 'current',
}
# ----- KPI drill-downs -----
def action_open_kpi_ready(self):
return {
'type': 'ir.actions.act_window',
'name': 'Ready to Claim (ADP)',
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': self._invoice_role_filter() + [
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('x_fc_adp_billing_status', '=', 'waiting'),
('adp_exported', '=', False),
],
'target': 'current',
}
def action_open_kpi_claimed(self):
return {
'type': 'ir.actions.act_window',
'name': 'Claimed This Period',
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': self._invoice_role_filter() + [
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('x_fc_adp_billing_status', 'in', ['submitted', 'resubmitted']),
('adp_export_date', '>=', self.posting_period_start),
],
'target': 'current',
}
def action_open_kpi_ar(self):
return {
'type': 'ir.actions.act_window',
'name': 'Total AR (ADP)',
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': self._invoice_role_filter() + [
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('x_fc_invoice_type', '=', 'adp'),
('payment_state', 'in', ['not_paid', 'partial']),
],
'target': 'current',
}
# =========================================================================
# Create-SO hotlinks
# =========================================================================
def _create_so_action(self, name, ctx_extra):
context = dict(self.env.context)
context.update(ctx_extra)
return {
'type': 'ir.actions.act_window',
'name': name,
'res_model': 'sale.order',
'view_mode': 'form',
'view_id': False,
'context': context,
'target': 'current',
}
def action_create_adp_so(self):
return self._create_so_action('New ADP Order',
{'default_x_fc_sale_type': 'adp'})
def action_create_mod_so(self):
return self._create_so_action('New MOD Order',
{'default_x_fc_sale_type': 'march_of_dimes'})
def action_create_odsp_so(self):
return self._create_so_action('New ODSP Order', {
'default_x_fc_sale_type': 'odsp',
'default_x_fc_odsp_division': 'standard',
})
def action_create_wsib_so(self):
return self._create_so_action('New WSIB Order',
{'default_x_fc_sale_type': 'wsib'})
def action_create_insurance_so(self):
return self._create_so_action('New Insurance Order',
{'default_x_fc_sale_type': 'insurance'})
def action_create_mdc_so(self):
return self._create_so_action('New MDC Order',
{'default_x_fc_sale_type': 'muscular_dystrophy'})
def action_create_hardship_so(self):
return self._create_so_action('New Hardship Order',
{'default_x_fc_sale_type': 'hardship'})
def action_create_private_so(self):
return self._create_so_action('New Private Order',
{'default_x_fc_sale_type': 'direct_private'})

View File

@@ -0,0 +1,63 @@
/** @odoo-module **/
// Fusion Claims — Posting Period Countdown
// Reads the submission_deadline_dt field, computes "Nd Xh to cutoff" client-side,
// re-renders every 60 seconds, swaps colour class as the deadline approaches.
// Copyright 2026 Nexa Systems Inc.
// License OPL-1
import { Component, useState, onWillDestroy } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
class FcPostingCountdown extends Component {
static template = "fusion_claims.PostingCountdown";
static props = { ...standardFieldProps };
setup() {
this.state = useState({ text: "", level: "info" });
this._render();
this._timer = setInterval(() => this._render(), 60_000);
onWillDestroy(() => {
if (this._timer) {
clearInterval(this._timer);
this._timer = null;
}
});
}
_render() {
const deadline = this.props.record.data[this.props.name];
if (!deadline) {
this.state.text = "";
this.state.level = "muted";
return;
}
// Odoo provides a luxon DateTime for Datetime fields
const now = luxon.DateTime.now();
const diff = deadline.diff(now, ["days", "hours", "minutes"]).toObject();
if (diff.days < 0 || (diff.days === 0 && diff.hours < 0)) {
this.state.text = "Cutoff passed";
this.state.level = "muted";
return;
}
const days = Math.floor(diff.days);
const hours = Math.floor(diff.hours);
if (days < 1) {
this.state.text = `${hours}h to cutoff`;
this.state.level = "danger";
} else if (days < 3) {
this.state.text = `${days}d ${hours}h to cutoff`;
this.state.level = "warning";
} else {
this.state.text = `${days} days to cutoff`;
this.state.level = "info";
}
}
}
registry.category("fields").add("fc_posting_countdown", {
component: FcPostingCountdown,
});

View File

@@ -0,0 +1,81 @@
// =============================================================================
// Fusion Claims Dashboard — Palette Tokens
// Compile-time branch on $o-webclient-color-scheme so the same SCSS file
// produces different palettes in web.assets_backend (light) and
// web.assets_web_dark (dark). Tokens load FIRST in each bundle.
// =============================================================================
$o-webclient-color-scheme: bright !default;
// ---------- LIGHT (defaults) ----------
$_fc-page-bg: #f7f7f8 !default;
$_fc-card-bg: #ffffff !default;
$_fc-card-border: #d8dadd !default;
$_fc-text: #2b2b2b !default;
$_fc-text-muted: #6c7480 !default;
$_fc-banner-from: #eef2ff !default;
$_fc-banner-to: #fce7f3 !default;
$_fc-banner-border: #c7d2fe !default;
$_fc-banner-text: #3730a3 !default;
$_fc-deadline-text: #b91c1c !default;
$_fc-kpi-bg: #f0f4ff !default;
$_fc-kpi-border: #c7d2fe !default;
$_fc-kpi-num: #1e3a8a !default;
$_fc-action-bg: #ecfdf5 !default;
$_fc-action-border: #6ee7b7 !default;
$_fc-action-text: #047857 !default;
$_fc-tile-bg: #f3f4f6 !default;
$_fc-tile-border: #e5e7eb !default;
$_fc-tile-num: #111827 !default;
$_fc-urgent-bg: #fee2e2 !default;
$_fc-urgent-border: #fca5a5 !default;
$_fc-urgent-num: #991b1b !default;
$_fc-urgent-text: #7f1d1d !default;
$_fc-activity-bg: #fefce8 !default;
$_fc-activity-border: #fde047 !default;
$_fc-bottleneck-bg: #fef2f2 !default;
$_fc-bottleneck-border: #fecaca !default;
// ---------- DARK overrides ----------
@if $o-webclient-color-scheme == dark {
$_fc-page-bg: #1a1d21 !global;
$_fc-card-bg: #22262d !global;
$_fc-card-border: #3a3f47 !global;
$_fc-text: #e5e7eb !global;
$_fc-text-muted: #9ca3af !global;
// Cool blue monochrome banner (selected option A from brainstorm)
$_fc-banner-from: #1e293b !global;
$_fc-banner-to: #1e3a5f !global;
$_fc-banner-border: #3b82f6 !global;
$_fc-banner-text: #93c5fd !global;
$_fc-deadline-text: #fca5a5 !global;
$_fc-kpi-bg: #1e293b !global;
$_fc-kpi-border: #334155 !global;
$_fc-kpi-num: #93c5fd !global;
$_fc-action-bg: #064e3b !global;
$_fc-action-border: #047857 !global;
$_fc-action-text: #6ee7b7 !global;
$_fc-tile-bg: #2d3138 !global;
$_fc-tile-border: #3a3f47 !global;
$_fc-tile-num: #f3f4f6 !global;
$_fc-urgent-bg: #4a1414 !global;
$_fc-urgent-border: #7f1d1d !global;
$_fc-urgent-num: #fca5a5 !global;
$_fc-urgent-text: #fecaca !global;
$_fc-activity-bg: #3a2e0a !global;
$_fc-activity-border: #854d0e !global;
$_fc-bottleneck-bg: #3a1414 !global;
$_fc-bottleneck-border: #7f1d1d !global;
}

View File

@@ -0,0 +1,212 @@
// =============================================================================
// Fusion Claims Dashboard — Layout & Section Styles
// Consumes tokens from _fc_dashboard_tokens.scss (must load FIRST in bundle).
// =============================================================================
.o_fc_dashboard {
// Re-export tokens as CSS custom properties for devtools inspection
--fc-page-bg: #{$_fc-page-bg};
--fc-card-bg: #{$_fc-card-bg};
--fc-card-border: #{$_fc-card-border};
--fc-text: #{$_fc-text};
--fc-text-muted: #{$_fc-text-muted};
--fc-banner-from: #{$_fc-banner-from};
--fc-banner-to: #{$_fc-banner-to};
--fc-banner-border: #{$_fc-banner-border};
--fc-banner-text: #{$_fc-banner-text};
--fc-deadline-text: #{$_fc-deadline-text};
--fc-kpi-bg: #{$_fc-kpi-bg};
--fc-kpi-border: #{$_fc-kpi-border};
--fc-kpi-num: #{$_fc-kpi-num};
--fc-action-bg: #{$_fc-action-bg};
--fc-action-border: #{$_fc-action-border};
--fc-action-text: #{$_fc-action-text};
--fc-tile-bg: #{$_fc-tile-bg};
--fc-tile-border: #{$_fc-tile-border};
--fc-tile-num: #{$_fc-tile-num};
--fc-urgent-bg: #{$_fc-urgent-bg};
--fc-urgent-border: #{$_fc-urgent-border};
--fc-urgent-num: #{$_fc-urgent-num};
--fc-urgent-text: #{$_fc-urgent-text};
--fc-activity-bg: #{$_fc-activity-bg};
--fc-activity-border: #{$_fc-activity-border};
--fc-bottleneck-bg: #{$_fc-bottleneck-bg};
--fc-bottleneck-border: #{$_fc-bottleneck-border};
background: var(--fc-page-bg);
color: $_fc-text;
.o_fc_banner {
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(90deg, var(--fc-banner-from), var(--fc-banner-to));
border: 1px solid var(--fc-banner-border);
border-radius: 8px;
padding: 10px 14px;
font-weight: 600;
color: var(--fc-banner-text);
}
.o_fc_banner__deadline { font-weight: 700; }
.o_fc_kpi {
background: var(--fc-kpi-bg);
border: 1px solid var(--fc-kpi-border);
border-radius: 8px;
padding: 14px 10px;
text-align: center;
transition: transform 0.15s ease;
&:hover { transform: translateY(-2px); }
}
.o_fc_kpi__num {
display: block;
font-size: 1.6rem;
font-weight: 700;
color: var(--fc-kpi-num);
}
.o_fc_kpi__lbl {
display: block;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--fc-text-muted);
margin-top: 2px;
}
.o_fc_actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.o_fc_pill {
background: var(--fc-action-bg);
border: 1px solid var(--fc-action-border);
color: var(--fc-action-text);
border-radius: 16px;
padding: 5px 12px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
&:hover { background: var(--fc-action-border); }
}
.o_fc_section {
background: var(--fc-card-bg);
border: 1px solid var(--fc-card-border);
border-radius: 8px;
padding: 10px 12px;
}
.o_fc_h6 {
display: flex;
align-items: center;
font-size: 0.9rem;
font-weight: 700;
margin-bottom: 8px;
color: var(--fc-text);
}
.o_fc_tag {
display: inline-block;
font-size: 0.65rem;
padding: 2px 7px;
border-radius: 4px;
background: var(--fc-banner-border);
color: var(--fc-banner-text);
margin-left: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
.o_fc_tile {
background: var(--fc-tile-bg);
border: 1px solid var(--fc-tile-border);
border-radius: 6px;
padding: 8px 6px;
text-align: center;
font-size: 0.75rem;
line-height: 1.3;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
}
.o_fc_tile__num {
display: block;
font-size: 1.3rem;
font-weight: 700;
color: var(--fc-tile-num);
margin-bottom: 2px;
}
.o_fc_tile--urgent {
background: var(--fc-urgent-bg);
border-color: var(--fc-urgent-border);
color: var(--fc-urgent-text);
.o_fc_tile__num { color: var(--fc-urgent-num); }
}
.o_fc_activities {
background: var(--fc-activity-bg);
border: 1px solid var(--fc-activity-border);
border-radius: 8px;
padding: 10px 12px;
}
.o_fc_activity_row {
display: flex;
justify-content: space-between;
padding: 4px 0;
border-bottom: 1px dashed var(--fc-card-border);
font-size: 0.85rem;
&:last-child { border-bottom: none; }
}
.o_fc_activity_overdue {
color: var(--fc-urgent-text);
font-weight: 600;
}
.o_fc_activity_deadline { color: var(--fc-text-muted); }
.o_fc_empty {
color: var(--fc-text-muted);
font-style: italic;
text-align: center;
padding: 12px;
margin: 0;
}
.o_fc_bottleneck {
background: var(--fc-bottleneck-bg);
border: 1px solid var(--fc-bottleneck-border);
border-radius: 8px;
padding: 10px 12px;
}
.o_fc_bottleneck_row {
display: block;
width: 100%;
text-align: left;
padding: 4px 0;
color: var(--fc-text);
text-decoration: none;
&:hover { color: var(--fc-urgent-num); text-decoration: underline; }
}
// Countdown widget colour levels (driven by OWL state)
.o_fc_countdown {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-weight: 700;
font-size: 0.85rem;
}
.o_fc_countdown--info { color: var(--fc-banner-text); }
.o_fc_countdown--warning { color: #d97706; } // amber (intentional fixed hex)
.o_fc_countdown--danger { color: var(--fc-urgent-num); }
.o_fc_countdown--muted { color: var(--fc-text-muted); font-style: italic; }
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_claims.PostingCountdown">
<span t-attf-class="o_fc_countdown o_fc_countdown--{{state.level}}"
t-esc="state.text"/>
</t>
</templates>

View File

@@ -34,6 +34,36 @@ class TestFusionClaimsDashboard(TransactionCase):
cls.partner = cls.Partner.create({'name': 'Test Client'})
@classmethod
def _make_invoice(cls, user, billing_status, amount=1000.0,
exported=False, export_date=None,
invoice_type='adp', payment_state='not_paid'):
"""Helper: create a posted ADP invoice linked to an SO owned by `user`."""
so = cls.env['sale.order'].with_context(skip_status_validation=True).create({
'partner_id': cls.partner.id,
'user_id': user.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'approved',
})
invoice = cls.env['account.move'].with_context(skip_sync=True).create({
'move_type': 'out_invoice',
'partner_id': cls.partner.id,
'x_fc_source_sale_order_id': so.id,
'x_fc_invoice_type': invoice_type,
'x_fc_adp_billing_status': billing_status,
'adp_exported': exported,
'adp_export_date': export_date,
'invoice_line_ids': [(0, 0, {
'name': 'Test line',
'quantity': 1.0,
'price_unit': amount,
'tax_ids': [(5, 0)], # clear taxes so amount_total == price_unit
})],
})
invoice.action_post()
invoice.with_context(skip_sync=True).write({'payment_state': payment_state})
return invoice
def test_dashboard_record_creates(self):
dashboard = self.Dashboard.create({})
self.assertTrue(dashboard.id, "Dashboard record should be creatable")
@@ -57,3 +87,281 @@ class TestFusionClaimsDashboard(TransactionCase):
def test_is_manager_false_for_salesrep(self):
dashboard = self.Dashboard.with_user(self.salesrep).create({})
self.assertFalse(dashboard.is_manager)
# -------------------------------------------------------------------------
# Task 2 — Banner
# -------------------------------------------------------------------------
def test_banner_posting_period_label_format(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
label = dashboard.posting_period_label
self.assertTrue(any(month in label
for month in ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']),
"Label should contain a month abbreviation")
def test_banner_posting_period_start_and_end_are_dates(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertTrue(dashboard.posting_period_start)
self.assertTrue(dashboard.posting_period_end)
delta = (dashboard.posting_period_end - dashboard.posting_period_start).days
self.assertEqual(delta, 14)
def test_banner_submission_deadline_is_wednesday_6pm(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
deadline = dashboard.submission_deadline_dt
self.assertTrue(deadline, "Deadline should be set")
# Stored in UTC; convert to user's TZ to assert the wall-clock weekday/hour
import pytz
tz = pytz.timezone(self.manager.tz or 'America/Toronto')
local = pytz.UTC.localize(deadline).astimezone(tz)
self.assertEqual(local.weekday(), 2, "Deadline should be Wednesday")
self.assertEqual(local.hour, 18, "Deadline should be 18:00 (6 PM)")
def test_is_pre_first_posting_false_when_today_is_past_base_date(self):
# Test runs after 2026-01-23 by default.
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertFalse(dashboard.is_pre_first_posting)
# -------------------------------------------------------------------------
# Task 3 — KPI tiles
# -------------------------------------------------------------------------
def test_kpi_ready_counts_waiting_invoices_not_exported(self):
self._make_invoice(self.manager, 'waiting', amount=500.0, exported=False)
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.kpi_ready_count, 1)
self.assertAlmostEqual(dashboard.kpi_ready_amount, 500.0, places=2)
def test_kpi_ready_excludes_already_exported(self):
from datetime import date
self._make_invoice(self.manager, 'waiting', amount=500.0,
exported=True, export_date=date.today())
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.kpi_ready_count, 0)
self.assertAlmostEqual(dashboard.kpi_ready_amount, 0.0, places=2)
def test_kpi_claimed_counts_exported_in_current_period(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
in_period_date = dashboard.posting_period_start
self._make_invoice(self.manager, 'submitted', amount=700.0,
exported=True, export_date=in_period_date)
dashboard2 = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard2.kpi_claimed_count, 1)
self.assertAlmostEqual(dashboard2.kpi_claimed_amount, 700.0, places=2)
def test_kpi_ar_counts_posted_unpaid_adp_invoices(self):
self._make_invoice(self.manager, 'submitted', amount=2000.0,
exported=True, payment_state='not_paid')
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.kpi_ar_count, 1)
self.assertAlmostEqual(dashboard.kpi_ar_amount, 2000.0, places=2)
def test_kpi_ready_respects_role_filter(self):
self._make_invoice(self.manager, 'waiting', amount=500.0)
dashboard_rep = self.Dashboard.with_user(self.salesrep).create({})
self.assertEqual(dashboard_rep.kpi_ready_count, 0,
"Salesrep must not see manager's invoice")
# -------------------------------------------------------------------------
# Task 4 — Activities + bottlenecks
# -------------------------------------------------------------------------
def test_my_activities_count_zero_when_none(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.my_activities_count, 0)
def test_my_activities_count_picks_up_user_activity(self):
so = self.env['sale.order'].with_context(skip_status_validation=True).create({
'partner_id': self.partner.id,
'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
})
self.env['mail.activity'].create({
'res_model_id': self.env['ir.model']._get('sale.order').id,
'res_id': so.id,
'res_model': 'sale.order',
'user_id': self.manager.id,
'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id,
'summary': 'Test activity',
})
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.my_activities_count, 1)
self.assertIn('Test activity', dashboard.my_activities_html or '')
def test_bottleneck_no_pod_count(self):
self.env['sale.order'].with_context(skip_status_validation=True).create({
'partner_id': self.partner.id,
'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'approved',
})
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.bottleneck_no_pod_count, 1)
def test_bottleneck_no_response_count(self):
from datetime import date, timedelta
old_date = date.today() - timedelta(days=20)
self.env['sale.order'].with_context(skip_status_validation=True).create({
'partner_id': self.partner.id,
'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'submitted',
'x_fc_claim_submission_date': old_date,
})
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.bottleneck_no_response_count, 1)
# -------------------------------------------------------------------------
# Task 5 — Other funder counts
# -------------------------------------------------------------------------
def test_other_funder_counts_segregate_by_sale_type(self):
SO = self.env['sale.order'].with_context(skip_status_validation=True)
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'odsp'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'wsib'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'insurance'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'muscular_dystrophy'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'hardship'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'adp', 'x_fc_client_type': 'ACS'})
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.count_odsp, 1)
self.assertEqual(dashboard.count_wsib, 1)
self.assertEqual(dashboard.count_insurance, 1)
self.assertEqual(dashboard.count_mdc, 1)
self.assertEqual(dashboard.count_hardship, 1)
self.assertEqual(dashboard.count_acsd, 1)
def test_other_funder_counts_exclude_cancelled(self):
so = self.env['sale.order'].with_context(skip_status_validation=True).create({
'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'wsib',
})
so.with_context(skip_status_validation=True).write({'state': 'cancel'})
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.count_wsib, 0)
# -------------------------------------------------------------------------
# Task 6 — ADP + MOD workflow counts
# -------------------------------------------------------------------------
def test_adp_pre_approval_tile_counts(self):
SO = self.env['sale.order'].with_context(skip_status_validation=True)
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'waiting_for_application'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'application_received'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'ready_submission'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'needs_correction'})
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.adp_waiting_app_count, 1)
self.assertEqual(dashboard.adp_app_received_count, 1)
self.assertEqual(dashboard.adp_ready_submit_count, 1)
self.assertEqual(dashboard.adp_needs_correction_count, 1)
def test_adp_post_approval_tile_counts(self):
SO = self.env['sale.order'].with_context(skip_status_validation=True)
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'approved'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'ready_delivery'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'ready_bill'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'on_hold'})
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.adp_approved_count, 1)
self.assertEqual(dashboard.adp_ready_delivery_count, 1)
self.assertEqual(dashboard.adp_ready_bill_count, 1)
self.assertEqual(dashboard.adp_on_hold_count, 1)
def test_mod_tile_counts(self):
SO = self.env['sale.order'].with_context(skip_status_validation=True)
for status in ('awaiting_funding', 'funding_approved', 'contract_received',
'project_complete', 'pod_submitted'):
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'march_of_dimes',
'x_fc_mod_status': status})
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.mod_awaiting_funding_count, 1)
self.assertEqual(dashboard.mod_funding_approved_count, 1)
self.assertEqual(dashboard.mod_pca_received_count, 1)
self.assertEqual(dashboard.mod_project_complete_count, 1)
self.assertEqual(dashboard.mod_pod_submitted_count, 1)
# -------------------------------------------------------------------------
# Task 7 — Open-list action methods
# -------------------------------------------------------------------------
def test_action_open_adp_waiting_app_returns_correct_domain(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
action = dashboard.action_open_adp_waiting_app()
self.assertEqual(action['res_model'], 'sale.order')
self.assertIn(('x_fc_adp_application_status', 'in',
['waiting_for_application', 'assessment_completed']),
action['domain'])
def test_action_open_bottleneck_no_pod_returns_correct_domain(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
action = dashboard.action_open_bottleneck_no_pod()
self.assertEqual(action['res_model'], 'sale.order')
self.assertIn(('x_fc_proof_of_delivery', '=', False), action['domain'])
def test_action_open_mod_awaiting_funding_returns_correct_domain(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
action = dashboard.action_open_mod_awaiting_funding()
self.assertEqual(action['res_model'], 'sale.order')
self.assertIn(('x_fc_mod_status', '=', 'awaiting_funding'), action['domain'])
def test_action_open_my_activities_returns_activity_model(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
action = dashboard.action_open_my_activities()
self.assertEqual(action['res_model'], 'mail.activity')
# -------------------------------------------------------------------------
# Task 8 — Create-SO hotlinks
# -------------------------------------------------------------------------
def test_action_create_adp_so_has_default_sale_type(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
action = dashboard.action_create_adp_so()
self.assertEqual(action['res_model'], 'sale.order')
self.assertEqual(action['view_mode'], 'form')
self.assertEqual(action['context']['default_x_fc_sale_type'], 'adp')
def test_action_create_mod_so_has_default_sale_type(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
action = dashboard.action_create_mod_so()
self.assertEqual(action['context']['default_x_fc_sale_type'], 'march_of_dimes')
def test_action_create_odsp_so_has_division_default(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
action = dashboard.action_create_odsp_so()
self.assertEqual(action['context']['default_x_fc_sale_type'], 'odsp')
self.assertEqual(action['context']['default_x_fc_odsp_division'], 'standard')
def test_all_create_so_actions_exist(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
for method_name, expected_type in [
('action_create_adp_so', 'adp'),
('action_create_mod_so', 'march_of_dimes'),
('action_create_odsp_so', 'odsp'),
('action_create_wsib_so', 'wsib'),
('action_create_insurance_so', 'insurance'),
('action_create_mdc_so', 'muscular_dystrophy'),
('action_create_hardship_so', 'hardship'),
('action_create_private_so', 'direct_private'),
]:
action = getattr(dashboard, method_name)()
self.assertEqual(action['res_model'], 'sale.order')
self.assertEqual(action['context']['default_x_fc_sale_type'], expected_type,
f"{method_name} returned wrong default sale type")

View File

@@ -4,157 +4,384 @@
<field name="name">fusion.claims.dashboard.form</field>
<field name="model">fusion.claims.dashboard</field>
<field name="arch" type="xml">
<form string="Dashboard" create="0" delete="0">
<form string="Dashboard" create="0" delete="0" edit="0"
class="o_fc_dashboard">
<sheet>
<!-- ===== FUNDING CARDS (one line, bigger) ===== -->
<div class="d-flex flex-nowrap gap-2 mb-4 overflow-auto">
<div invisible="adp_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_adp" type="object" class="btn p-0 w-100 border-0">
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="adp_count"/></div>
<div style="font-size: 0.85rem;">ADP</div>
</div>
</button>
<!-- Hidden invariants used by buttons + widgets -->
<field name="currency_id" invisible="1"/>
<field name="posting_period_start" invisible="1"/>
<field name="is_manager" invisible="1"/>
<field name="is_pre_first_posting" invisible="1"/>
<!-- BANNER -->
<div class="o_fc_banner mb-3">
<div class="o_fc_banner__label">
<i class="fa fa-calendar me-2"/>
<span>Posting Period: </span>
<field name="posting_period_label" nolabel="1"
class="fw-bold"/>
</div>
<div invisible="odsp_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_odsp" type="object" class="btn p-0 w-100 border-0">
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="odsp_count"/></div>
<div style="font-size: 0.85rem;">ODSP</div>
</div>
</button>
</div>
<div invisible="march_of_dimes_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_march" type="object" class="btn p-0 w-100 border-0">
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="march_of_dimes_count"/></div>
<div style="font-size: 0.85rem;">March of Dimes</div>
</div>
</button>
</div>
<div invisible="hardship_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_hardship" type="object" class="btn p-0 w-100 border-0">
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="hardship_count"/></div>
<div style="font-size: 0.85rem;">Hardship</div>
</div>
</button>
</div>
<div invisible="acsd_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_acsd" type="object" class="btn p-0 w-100 border-0">
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #fa709a 0%, #fee140 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="acsd_count"/></div>
<div style="font-size: 0.85rem;">ACSD</div>
</div>
</button>
</div>
<div invisible="muscular_dystrophy_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_muscular" type="object" class="btn p-0 w-100 border-0">
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="muscular_dystrophy_count"/></div>
<div style="font-size: 0.85rem;">Muscular Dystrophy</div>
</div>
</button>
</div>
<div invisible="insurance_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_insurance" type="object" class="btn p-0 w-100 border-0">
<div class="text-dark text-center py-3 px-2" style="background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="insurance_count"/></div>
<div style="font-size: 0.85rem;">Insurance</div>
</div>
</button>
</div>
<div invisible="wsib_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_wsib" type="object" class="btn p-0 w-100 border-0">
<div class="text-dark text-center py-3 px-2" style="background: linear-gradient(135deg, #ff9a9e 0%, #fad0c4 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="wsib_count"/></div>
<div style="font-size: 0.85rem;">WSIB</div>
</div>
</button>
</div>
<div invisible="total_profiles == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_profiles" type="object" class="btn p-0 w-100 border-0">
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #30cfd0 0%, #330867 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="total_profiles"/></div>
<div style="font-size: 0.85rem;">Profiles</div>
</div>
</button>
<div class="o_fc_banner__deadline">
<field name="submission_deadline_dt"
widget="fc_posting_countdown"
nolabel="1" readonly="1"/>
</div>
</div>
<!-- ===== PANEL SELECTORS (4 dropdowns) ===== -->
<!-- "Showing your cases" hint when role-filtered -->
<div class="alert alert-info py-2 mb-2"
invisible="is_manager">
Showing your assigned cases only.
</div>
<!-- KPI TILES (3-up) -->
<div class="row g-2 mb-3">
<div class="col-3">
<div class="fw-bold mb-1" style="font-size: 0.8rem;">Window 1</div>
<field name="panel1_type" nolabel="1"/>
<div class="col-12 col-md-4">
<button name="action_open_kpi_ready" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi">
<span class="o_fc_kpi__num">
<field name="kpi_ready_amount"
widget="monetary" nolabel="1"
options="{'currency_field': 'currency_id'}"/>
</span>
<span class="o_fc_kpi__lbl">Ready to Claim
(<field name="kpi_ready_count" nolabel="1"/>)
</span>
</div>
</button>
</div>
<div class="col-3">
<div class="fw-bold mb-1" style="font-size: 0.8rem;">Window 2</div>
<field name="panel2_type" nolabel="1"/>
<div class="col-12 col-md-4">
<button name="action_open_kpi_claimed" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi">
<span class="o_fc_kpi__num">
<field name="kpi_claimed_amount"
widget="monetary" nolabel="1"
options="{'currency_field': 'currency_id'}"/>
</span>
<span class="o_fc_kpi__lbl">Claimed This Period
(<field name="kpi_claimed_count" nolabel="1"/>)
</span>
</div>
</button>
</div>
<div class="col-3">
<div class="fw-bold mb-1" style="font-size: 0.8rem;">Window 3</div>
<field name="panel3_type" nolabel="1"/>
</div>
<div class="col-3">
<div class="fw-bold mb-1" style="font-size: 0.8rem;">Window 4</div>
<field name="panel4_type" nolabel="1"/>
<div class="col-12 col-md-4">
<button name="action_open_kpi_ar" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi">
<span class="o_fc_kpi__num">
<field name="kpi_ar_amount"
widget="monetary" nolabel="1"
options="{'currency_field': 'currency_id'}"/>
</span>
<span class="o_fc_kpi__lbl">Total AR
(<field name="kpi_ar_count" nolabel="1"/>)
</span>
</div>
</button>
</div>
</div>
<!-- ===== TOP PANELS ROW 1 ===== -->
<div class="row g-3 mb-3">
<div class="col-12 col-lg-6">
<div class="card" style="border-radius: 14px; overflow: hidden;">
<div class="card-header fw-bold text-white py-2" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<field name="panel1_title" nolabel="1"/>
</div>
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
<field name="panel1_html" class="w-100" nolabel="1"/>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card" style="border-radius: 14px; overflow: hidden;">
<div class="card-header fw-bold text-white py-2" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">
<field name="panel2_title" nolabel="1"/>
</div>
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
<field name="panel2_html" class="w-100" nolabel="1"/>
</div>
</div>
</div>
<!-- QUICK ACTION PILLS -->
<div class="o_fc_actions mb-3">
<button name="action_create_adp_so" type="object"
class="o_fc_pill">+ ADP</button>
<button name="action_create_mod_so" type="object"
class="o_fc_pill">+ MOD</button>
<button name="action_create_odsp_so" type="object"
class="o_fc_pill">+ ODSP</button>
<button name="action_create_wsib_so" type="object"
class="o_fc_pill">+ WSIB</button>
<button name="action_create_insurance_so" type="object"
class="o_fc_pill">+ Insurance</button>
<button name="action_create_mdc_so" type="object"
class="o_fc_pill">+ MDC</button>
<button name="action_create_hardship_so" type="object"
class="o_fc_pill">+ Hardship</button>
<button name="action_create_private_so" type="object"
class="o_fc_pill">+ Private</button>
</div>
<!-- ===== TOP PANELS ROW 2 ===== -->
<!-- 2-COLUMN GRID -->
<div class="row g-3">
<div class="col-12 col-lg-6">
<div class="card" style="border-radius: 14px; overflow: hidden;">
<div class="card-header fw-bold text-white py-2" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);">
<field name="panel3_title" nolabel="1"/>
</div>
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
<field name="panel3_html" class="w-100" nolabel="1"/>
<!-- LEFT COLUMN -->
<div class="col-12 col-lg-5">
<!-- Your Activities -->
<div class="o_fc_activities mb-3">
<h6 class="o_fc_h6">
<i class="fa fa-thumb-tack me-2"/>
Your Activities
<span class="o_fc_tag">
<field name="my_activities_count" nolabel="1"/>
</span>
<button name="action_open_my_activities" type="object"
class="btn btn-link btn-sm ms-auto p-0">
View all
</button>
</h6>
<field name="my_activities_html" nolabel="1"/>
</div>
<!-- Bottlenecks -->
<div class="o_fc_bottleneck mb-3">
<h6 class="o_fc_h6">
<i class="fa fa-exclamation-triangle me-2"/>
Bottlenecks
</h6>
<button name="action_open_bottleneck_no_pod" type="object"
class="o_fc_bottleneck_row btn btn-link p-0">
Approved without POD:
<span class="fw-bold ms-1">
<field name="bottleneck_no_pod_count" nolabel="1"/>
</span>
</button>
<button name="action_open_bottleneck_no_response" type="object"
class="o_fc_bottleneck_row btn btn-link p-0">
Submitted &gt; 14d, no response:
<span class="fw-bold ms-1">
<field name="bottleneck_no_response_count" nolabel="1"/>
</span>
</button>
</div>
<!-- Other Funders -->
<div class="o_fc_section mb-3">
<h6 class="o_fc_h6">Other Funders</h6>
<div class="row g-2">
<div class="col-4">
<button name="action_open_odsp_cases" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="count_odsp" nolabel="1"/>
</span>ODSP
</div>
</button>
</div>
<div class="col-4">
<button name="action_open_wsib_cases" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="count_wsib" nolabel="1"/>
</span>WSIB
</div>
</button>
</div>
<div class="col-4">
<button name="action_open_insurance_cases" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="count_insurance" nolabel="1"/>
</span>Insurance
</div>
</button>
</div>
<div class="col-4">
<button name="action_open_mdc_cases" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="count_mdc" nolabel="1"/>
</span>MDC
</div>
</button>
</div>
<div class="col-4">
<button name="action_open_hardship_cases" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="count_hardship" nolabel="1"/>
</span>Hardship
</div>
</button>
</div>
<div class="col-4">
<button name="action_open_acsd_cases" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="count_acsd" nolabel="1"/>
</span>ACSD
</div>
</button>
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card" style="border-radius: 14px; overflow: hidden;">
<div class="card-header fw-bold text-white py-2" style="background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);">
<field name="panel4_title" nolabel="1"/>
<!-- RIGHT COLUMN -->
<div class="col-12 col-lg-7">
<!-- ADP Pre-Approval -->
<div class="o_fc_section mb-3">
<h6 class="o_fc_h6">ADP
<span class="o_fc_tag">Pre-Approval</span>
</h6>
<div class="row g-2">
<div class="col-6 col-md-3">
<button name="action_open_adp_waiting_app" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile o_fc_tile--urgent">
<span class="o_fc_tile__num">
<field name="adp_waiting_app_count" nolabel="1"/>
</span>Waiting App
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_adp_app_received" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="adp_app_received_count" nolabel="1"/>
</span>App Received
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_adp_ready_submit" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="adp_ready_submit_count" nolabel="1"/>
</span>Ready Submit
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_adp_needs_correction" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile o_fc_tile--urgent">
<span class="o_fc_tile__num">
<field name="adp_needs_correction_count" nolabel="1"/>
</span>Needs Correction
</div>
</button>
</div>
</div>
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
<field name="panel4_html" class="w-100" nolabel="1"/>
</div>
<!-- ADP Post-Approval -->
<div class="o_fc_section mb-3">
<h6 class="o_fc_h6">ADP
<span class="o_fc_tag">Post-Approval</span>
</h6>
<div class="row g-2">
<div class="col-6 col-md-3">
<button name="action_open_adp_approved" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="adp_approved_count" nolabel="1"/>
</span>Approved
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_adp_ready_delivery" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="adp_ready_delivery_count" nolabel="1"/>
</span>Ready Delivery
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_adp_ready_bill" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="adp_ready_bill_count" nolabel="1"/>
</span>Ready Bill
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_adp_on_hold" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile o_fc_tile--urgent">
<span class="o_fc_tile__num">
<field name="adp_on_hold_count" nolabel="1"/>
</span>On Hold
</div>
</button>
</div>
</div>
</div>
<!-- MOD -->
<div class="o_fc_section mb-3">
<h6 class="o_fc_h6">MOD</h6>
<div class="row g-2">
<div class="col-6 col-md-2">
<button name="action_open_mod_awaiting_funding" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="mod_awaiting_funding_count" nolabel="1"/>
</span>Awaiting
</div>
</button>
</div>
<div class="col-6 col-md-2">
<button name="action_open_mod_funding_approved" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="mod_funding_approved_count" nolabel="1"/>
</span>Approved
</div>
</button>
</div>
<div class="col-6 col-md-2">
<button name="action_open_mod_pca_received" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="mod_pca_received_count" nolabel="1"/>
</span>PCA
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_mod_project_complete" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="mod_project_complete_count" nolabel="1"/>
</span>Proj. Done
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_mod_pod_submitted" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="mod_pod_submitted_count" nolabel="1"/>
</span>POD Submitted
</div>
</button>
</div>
</div>
</div>
</div>
</div>
</sheet>
</form>
</field>
</record>
<!-- Dashboard Action -->
<!-- Dashboard Action — preserved id so existing menu items resolve -->
<record id="action_fusion_claims_dashboard" model="ir.actions.act_window">
<field name="name">Dashboard</field>
<field name="res_model">fusion.claims.dashboard</field>