102 KiB
Fusion Claims Dashboard Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Rewrite the existing fusion.claims.dashboard TransientModel + view as an action-oriented dashboard with posting-week banner, live deadline countdown, 3 KPI tiles, 8 funder hotlinks, ADP + MOD workflow flag tiles, role-aware filtering, and dual-bundle light/dark themed SCSS.
Architecture: Hybrid — server-rendered Bootstrap-grid form view on a TransientModel with ~36 computed fields, plus one OWL field-widget for the live countdown that ticks every 60s. SCSS palette tokens branch on $o-webclient-color-scheme at compile time and the file is registered in both web.assets_backend and web.assets_web_dark.
Tech Stack: Odoo 19, Python 3.11, OWL 2 (Odoo Web Library), SCSS, Bootstrap 5.
Reference spec: docs/superpowers/specs/2026-05-21-fusion-claims-dashboard-design.md.
Test environment (per CLAUDE.md §26):
- Docker container:
odoo-modsdev-app - Database:
modsdev - Run all module tests:
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims --stop-after-init - Run a specific class: append
:TestFusionClaimsDashboardto the tag. - Run a specific test: append
:TestFusionClaimsDashboard.test_xxx.
File structure:
fusion_claims/
├── __manifest__.py (modify)
├── models/dashboard.py (rewrite)
├── views/dashboard_views.xml (rewrite)
├── static/src/scss/_fc_dashboard_tokens.scss (new)
├── static/src/scss/fc_dashboard.scss (new)
├── static/src/js/fc_posting_countdown.js (new)
├── static/src/xml/fc_posting_countdown.xml (new)
└── tests/test_dashboard.py (new)
Cross-cutting conventions:
- All new fields use
x_fc_*prefix only where they live on existing models (sale.order,account.move). Fields on the dashboard model itself don't need the prefix because it's a private TransientModel. - All tests inherit
TransactionCaseand use@tagged('-at_install', 'post_install', 'fusion_claims'). - Test data setup uses
with_context(skip_status_validation=True)whenever writing controlled statuses, per CLAUDE.md gotcha #25. - Commits use the existing module convention:
feat(fusion_claims): ...ortest(fusion_claims): ...(style matches the project's recent commits likefix(fusion_schedule): ...).
Task 1: Scaffold model + role-filter helper + first test
Files:
-
Modify:
fusion_claims/models/dashboard.py(rewrite from scratch) -
Create:
fusion_claims/tests/test_dashboard.py -
Create:
fusion_claims/tests/__init__.pyif missing (check first) -
Step 1: Confirm tests/init.py exists
Run:
cat K:/Github/Odoo-Modules/fusion_claims/tests/__init__.py
Expected: file lists existing test modules. If from . import test_dashboard is not yet present, we'll add it in step 5 below.
- Step 2: Write the failing test (test class skeleton)
Create fusion_claims/tests/test_dashboard.py:
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase, tagged
@tagged('-at_install', 'post_install', 'fusion_claims')
class TestFusionClaimsDashboard(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.Dashboard = cls.env['fusion.claims.dashboard']
cls.User = cls.env['res.users']
cls.Partner = cls.env['res.partner']
# Manager user (sees everything)
cls.manager = cls.User.create({
'name': 'Test Dashboard Manager',
'login': 'test_dash_mgr',
'group_ids': [
(4, cls.env.ref('fusion_claims.group_fusion_claims_manager').id),
(4, cls.env.ref('sales_team.group_sale_salesman').id),
],
})
# Sales rep (sees only own cases)
cls.salesrep = cls.User.create({
'name': 'Test Dashboard Salesrep',
'login': 'test_dash_rep',
'group_ids': [
(4, cls.env.ref('fusion_claims.group_fusion_claims_user').id),
(4, cls.env.ref('sales_team.group_sale_salesman').id),
],
})
cls.partner = cls.Partner.create({'name': 'Test Client'})
def test_dashboard_record_creates(self):
dashboard = self.Dashboard.create({})
self.assertTrue(dashboard.id, "Dashboard record should be creatable")
self.assertEqual(dashboard.name, 'Dashboard')
def test_role_filter_empty_for_manager(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard._role_filter_domain(), [],
"Manager should see all cases (empty domain)")
def test_role_filter_restricts_for_salesrep(self):
dashboard = self.Dashboard.with_user(self.salesrep).create({})
domain = dashboard._role_filter_domain()
self.assertEqual(domain, [('user_id', '=', self.salesrep.id)],
"Sales rep should see only their own SOs")
def test_is_manager_true_for_manager(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertTrue(dashboard.is_manager)
def test_is_manager_false_for_salesrep(self):
dashboard = self.Dashboard.with_user(self.salesrep).create({})
self.assertFalse(dashboard.is_manager)
- Step 3: Register the test in tests/init.py
Edit fusion_claims/tests/__init__.py to add the line (preserve existing imports):
from . import test_dashboard
- Step 4: Run test to verify it fails
Run:
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -40
Expected: tests fail because the new model doesn't have _role_filter_domain or is_manager. Errors like AttributeError: 'fusion.claims.dashboard' object has no attribute '_role_filter_domain'.
- Step 5: Replace models/dashboard.py with the new skeleton
Replace the entire contents of fusion_claims/models/dashboard.py with:
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
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)
# =========================================================================
# Role-aware filter
# =========================================================================
is_manager = fields.Boolean(compute='_compute_is_manager')
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:
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'))
)
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)]
- Step 6: Run test to verify it passes
Run:
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -20
Expected: 5 passed, 0 failed.
- Step 7: Commit
git -C K:/Github/Odoo-Modules add fusion_claims/models/dashboard.py fusion_claims/tests/test_dashboard.py fusion_claims/tests/__init__.py
git -C K:/Github/Odoo-Modules commit -m "feat(fusion_claims): scaffold dashboard model with role filter"
Task 2: Banner fields & _compute_banner
Files:
-
Modify:
fusion_claims/models/dashboard.py -
Modify:
fusion_claims/tests/test_dashboard.py -
Step 1: Write failing tests for banner fields
Append to test_dashboard.py (inside the existing class):
def test_banner_posting_period_label_format(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
label = dashboard.posting_period_label
self.assertIn(' – ', label,
"Label should use en dash separator between start and end")
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)
# end = start + 14 days (one cycle)
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")
self.assertEqual(deadline.weekday(), 2, "Deadline should be Wednesday")
self.assertEqual(deadline.hour, 18, "Deadline should be 18:00 (6 PM)")
def test_is_pre_first_posting_false_when_today_is_past_base_date(self):
# In the test environment, today is past 2026-01-23 (the default base date).
# If this ever runs before the base date, the assertion will need adjusting.
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertFalse(dashboard.is_pre_first_posting)
- Step 2: Run tests to verify they fail
Run:
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -30
Expected: 4 new tests fail with AttributeError for posting_period_label, posting_period_start, etc.
- Step 3: Add banner fields and
_compute_bannerto the model
Insert immediately AFTER the role-filter block in models/dashboard.py:
# =========================================================================
# 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)
- Step 4: Run tests to verify they pass
Run:
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -20
Expected: all 9 tests pass.
- Step 5: Commit
git -C K:/Github/Odoo-Modules add fusion_claims/models/dashboard.py fusion_claims/tests/test_dashboard.py
git -C K:/Github/Odoo-Modules commit -m "feat(fusion_claims): add dashboard banner fields"
Task 3: KPI fields & _compute_kpis
Files:
-
Modify:
fusion_claims/models/dashboard.py -
Modify:
fusion_claims/tests/test_dashboard.py -
Step 1: Add test data setup for invoices
Append a new helper to test_dashboard.py (add inside the class, after setUpClass):
@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',
})
# Use vals dict so unset fields are False; avoid auto-create of lines
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,
})],
})
invoice.action_post()
# Force-set payment_state since it's normally computed
invoice.with_context(skip_sync=True).write({'payment_state': payment_state})
return invoice
- Step 2: Write the failing tests for KPIs
Add to test_dashboard.py:
def test_kpi_ready_counts_waiting_invoices_not_exported(self):
# Create one "ready" invoice owned by manager
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):
# Build dashboard first to read the current period
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):
# Manager's invoice; salesrep should NOT see it
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")
- Step 3: Run tests to verify they fail
Run:
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -30
Expected: 5 new tests fail (kpi_ready_count etc. don't exist).
- Step 4: Add KPI fields and
_compute_kpis
Insert after the banner block in models/dashboard.py:
# =========================================================================
# 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'))
- Step 5: Run tests to verify they pass
Run:
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -20
Expected: 14 tests pass.
- Step 6: Commit
git -C K:/Github/Odoo-Modules add fusion_claims/models/dashboard.py fusion_claims/tests/test_dashboard.py
git -C K:/Github/Odoo-Modules commit -m "feat(fusion_claims): add dashboard KPI tiles (ready/claimed/AR)"
Task 4: Activities + bottlenecks (left column)
Files:
-
Modify:
fusion_claims/models/dashboard.py -
Modify:
fusion_claims/tests/test_dashboard.py -
Step 1: Write failing tests
Add to test_dashboard.py:
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):
# Approved SO with no POD
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)
- Step 2: Run tests to verify they fail
Run:
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -30
Expected: 4 new tests fail.
- Step 3: Add activities + bottleneck fields and compute methods
Insert after the KPI block in models/dashboard.py:
# =========================================================================
# 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
rows = []
from datetime import date
today = date.today()
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)
# =========================================================================
bottleneck_no_pod_count = fields.Integer(compute='_compute_secondary_counts')
bottleneck_no_response_count = 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()
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),
])
- Step 4: Run tests to verify they pass
Run:
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -20
Expected: 18 tests pass.
- Step 5: Commit
git -C K:/Github/Odoo-Modules add fusion_claims/models/dashboard.py fusion_claims/tests/test_dashboard.py
git -C K:/Github/Odoo-Modules commit -m "feat(fusion_claims): add dashboard activities and bottlenecks"
Task 5: Other-funder counts
Files:
-
Modify:
fusion_claims/models/dashboard.py -
Modify:
fusion_claims/tests/test_dashboard.py -
Step 1: Write failing tests
Add to test_dashboard.py:
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'})
# ACSD = client_type ACS, regardless of sale_type
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)
- Step 2: Run tests to verify they fail
Run:
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -20
Expected: 2 tests fail (missing fields).
- Step 3: Add other-funder count fields
Inside models/dashboard.py, add these fields under the "Bottlenecks" comment block and extend the existing _compute_secondary_counts method:
# =========================================================================
# Other funders (left column count cards)
# =========================================================================
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')
Then extend _compute_secondary_counts — replace the existing method body with the version below (preserves the two bottleneck assignments and adds six more):
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')])
- Step 4: Run tests to verify they pass
Run:
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -20
Expected: 20 tests pass.
- Step 5: Commit
git -C K:/Github/Odoo-Modules add fusion_claims/models/dashboard.py fusion_claims/tests/test_dashboard.py
git -C K:/Github/Odoo-Modules commit -m "feat(fusion_claims): add dashboard other-funder counts"
Task 6: ADP + MOD workflow counts
Files:
-
Modify:
fusion_claims/models/dashboard.py -
Modify:
fusion_claims/tests/test_dashboard.py -
Step 1: Write failing tests
Add to test_dashboard.py:
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)
- Step 2: Run tests to verify they fail
Run:
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -30
Expected: 3 tests fail with AttributeError for the new count fields.
- Step 3: Add workflow count fields and
_compute_workflow_counts
Insert after the other-funder counts in models/dashboard.py:
# =========================================================================
# 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'),
])
- Step 4: Run tests to verify they pass
Run:
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -20
Expected: 23 tests pass.
- Step 5: Commit
git -C K:/Github/Odoo-Modules add fusion_claims/models/dashboard.py fusion_claims/tests/test_dashboard.py
git -C K:/Github/Odoo-Modules commit -m "feat(fusion_claims): add dashboard ADP + MOD workflow tile counts"
Task 7: Open-list action methods
Files:
-
Modify:
fusion_claims/models/dashboard.py -
Modify:
fusion_claims/tests/test_dashboard.py -
Step 1: Write failing tests
Add to test_dashboard.py:
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')
- Step 2: Run tests to verify they fail
Run:
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -20
Expected: 4 tests fail.
- Step 3: Add open-list action methods
Append the following section to models/dashboard.py:
# =========================================================================
# Open-list action methods
# =========================================================================
def _so_list_action(self, name, domain):
return {
'type': 'ir.actions.act_window',
'name': name,
'res_model': 'sale.order',
'view_mode': 'list,form',
'domain': self._role_filter_domain() + domain,
'target': 'current',
}
# ----- 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_adp_app_received(self):
return self._so_list_action('ADP — Application Received', [
('x_fc_adp_application_status', '=', 'application_received'),
])
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_adp_needs_correction(self):
return self._so_list_action('ADP — Needs Correction', [
('x_fc_adp_application_status', '=', 'needs_correction'),
])
# ----- 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_adp_ready_delivery(self):
return self._so_list_action('ADP — Ready for Delivery', [
('x_fc_adp_application_status', '=', 'ready_delivery'),
])
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_adp_on_hold(self):
return self._so_list_action('ADP — On Hold', [
('x_fc_adp_application_status', '=', 'on_hold'),
])
# ----- MOD -----
def action_open_mod_awaiting_funding(self):
return self._so_list_action('MOD — Awaiting Funding', [
('x_fc_mod_status', '=', 'awaiting_funding'),
])
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': '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',
}
- Step 4: Run tests to verify they pass
Run:
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -20
Expected: 27 tests pass.
- Step 5: Commit
git -C K:/Github/Odoo-Modules add fusion_claims/models/dashboard.py fusion_claims/tests/test_dashboard.py
git -C K:/Github/Odoo-Modules commit -m "feat(fusion_claims): add dashboard open-list action methods"
Task 8: Create-SO action methods (8 hotlinks)
Files:
-
Modify:
fusion_claims/models/dashboard.py -
Modify:
fusion_claims/tests/test_dashboard.py -
Step 1: Write failing tests
Add to test_dashboard.py:
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({})
# Smoke check: every hotlink method returns a valid action dict
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")
- Step 2: Run tests to verify they fail
Run:
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -30
Expected: 4 new tests fail (missing methods).
- Step 3: Add create-SO action methods
Append to models/dashboard.py:
# =========================================================================
# 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'})
- Step 4: Run tests to verify they pass
Run:
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -20
Expected: 31 tests pass.
- Step 5: Commit
git -C K:/Github/Odoo-Modules add fusion_claims/models/dashboard.py fusion_claims/tests/test_dashboard.py
git -C K:/Github/Odoo-Modules commit -m "feat(fusion_claims): add dashboard create-SO hotlinks"
Task 9: Form view rewrite
Files:
- Modify:
fusion_claims/views/dashboard_views.xml(full rewrite)
This task has no Python TDD — the view is verified by loading the module and rendering the page. Test step is a module-upgrade smoke check.
- Step 1: Rewrite
views/dashboard_views.xml
Replace the entire contents with:
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_fusion_claims_dashboard_form" model="ir.ui.view">
<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" edit="0"
class="o_fc_dashboard">
<sheet>
<!-- 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 class="o_fc_banner__deadline">
<field name="submission_deadline_dt"
widget="fc_posting_countdown"
nolabel="1" readonly="1"/>
</div>
</div>
<!-- "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-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-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-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>
<!-- 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>
<!-- 2-COLUMN GRID -->
<div class="row g-3">
<!-- 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 > 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>
<!-- 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>
<!-- 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 — 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>
<field name="view_mode">form</field>
<field name="view_id" ref="view_fusion_claims_dashboard_form"/>
<field name="target">current</field>
</record>
</odoo>
- Step 2: Upgrade the module to load the new view
Run:
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --stop-after-init 2>&1 | tail -20
Expected: no ParseError or AttributeError lines. Last line should be similar to INFO ... Modules loaded.
Note: the
widget="fc_posting_countdown"reference does not exist yet — Odoo will render the field with the default datetime widget until Task 11 ships the widget. This is intentional; the rest of the page must load successfully without it.
- Step 3: Run all dashboard tests to confirm no Python regression
Run:
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -15
Expected: 31 tests still pass.
- Step 4: Commit
git -C K:/Github/Odoo-Modules add fusion_claims/views/dashboard_views.xml
git -C K:/Github/Odoo-Modules commit -m "feat(fusion_claims): rewrite dashboard form view with action-oriented layout"
Task 10: SCSS palette tokens + layout + manifest asset registration
Files:
- Create:
fusion_claims/static/src/scss/_fc_dashboard_tokens.scss - Create:
fusion_claims/static/src/scss/fc_dashboard.scss - Modify:
fusion_claims/__manifest__.py
This task is verified by upgrade-and-render rather than unit tests. The acceptance check is comparing the asset bundle URLs.
- Step 1: Create the palette tokens file
Create fusion_claims/static/src/scss/_fc_dashboard_tokens.scss:
// =============================================================================
// 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;
}
- Step 2: Create the layout file
Create fusion_claims/static/src/scss/fc_dashboard.scss:
// =============================================================================
// 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 so devtools can inspect them
--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; }
}
- Step 3: Register both SCSS files in the manifest, in both bundles
Open fusion_claims/__manifest__.py. Replace the 'assets': block with:
'assets': {
'web.assets_backend': [
# Existing module styles + JS — preserve order
'fusion_claims/static/src/scss/fusion_claims.scss',
'fusion_claims/static/src/js/document_preview.js',
'fusion_claims/static/src/js/preview_button_widget.js',
'fusion_claims/static/src/js/status_selection_filter.js',
'fusion_claims/static/src/js/gallery_preview.js',
'fusion_claims/static/src/js/tax_totals_patch.js',
'fusion_claims/static/src/js/google_address_autocomplete.js',
'fusion_claims/static/src/js/calendar_store_hours.js',
'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',
# NEW: dashboard tokens MUST load before dashboard layout
'fusion_claims/static/src/scss/_fc_dashboard_tokens.scss',
'fusion_claims/static/src/scss/fc_dashboard.scss',
],
'web.assets_web_dark': [
# NEW: dark bundle re-compiles 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',
],
},
- Step 4: Upgrade the module and inspect for SCSS errors
Run:
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --stop-after-init 2>&1 | tail -40
Expected: no SCSS compile error lines; final lines show Modules loaded.
- Step 5: Verify both bundles compile distinct URLs
Run via odoo-shell. Note: this is a one-off verification, not automated:
docker exec -i odoo-modsdev-app odoo shell -d modsdev --no-http <<'PY' 2>&1 | tail -10
backend = env['ir.qweb']._get_asset_bundle('web.assets_backend')
dark = env['ir.qweb']._get_asset_bundle('web.assets_web_dark')
print('LIGHT_URL:', backend.get_files_info()[0] if backend.get_files_info() else None)
print('DARK_URL:', dark.get_files_info()[0] if dark.get_files_info() else None)
PY
Expected: two different URLs printed. If identical, see CLAUDE.md §Asset Cache Busting for fixes (delete ir.attachment rows under /web/assets/%, restart).
- Step 6: Commit
git -C K:/Github/Odoo-Modules add fusion_claims/static/src/scss/_fc_dashboard_tokens.scss fusion_claims/static/src/scss/fc_dashboard.scss fusion_claims/__manifest__.py
git -C K:/Github/Odoo-Modules commit -m "feat(fusion_claims): add dashboard SCSS with dual-bundle theming"
Task 11: OWL countdown widget
Files:
-
Create:
fusion_claims/static/src/js/fc_posting_countdown.js -
Create:
fusion_claims/static/src/xml/fc_posting_countdown.xml -
Modify:
fusion_claims/__manifest__.py(register the new JS + XML) -
Step 1: Create the OWL widget JS
Create fusion_claims/static/src/js/fc_posting_countdown.js:
/** @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, onWillStart, 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 gives 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,
});
- Step 2: Create the OWL template
Create fusion_claims/static/src/xml/fc_posting_countdown.xml:
<?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>
- Step 3: Register the JS + XML in the manifest
In fusion_claims/__manifest__.py, extend the web.assets_backend list (add these two lines AFTER the SCSS dashboard files added in Task 10):
'web.assets_backend': [
# ...existing entries plus Task 10 SCSS lines...
'fusion_claims/static/src/scss/_fc_dashboard_tokens.scss',
'fusion_claims/static/src/scss/fc_dashboard.scss',
# NEW: countdown widget
'fusion_claims/static/src/js/fc_posting_countdown.js',
'fusion_claims/static/src/xml/fc_posting_countdown.xml',
],
Do NOT add the JS/XML to
web.assets_web_dark— Odoo loads JS once from the backend bundle; only SCSS goes in both.
- Step 4: Upgrade the module to register the widget
Run:
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --stop-after-init 2>&1 | tail -20
Expected: no template or JS errors. Last line Modules loaded.
- Step 5: Manual browser smoke test
Open the dashboard menu in the browser (http://localhost:8069 → ADP Claims → Dashboard). Confirm:
-
The deadline area now shows text like
3 days to cutoff(info colour) or2d 5h to cutoff(warning) instead of the raw datetime. -
The text colour matches the level (info = banner-text colour, warning = amber, danger = red).
-
Leave the page open for ~60 seconds and verify the displayed minutes shift by one (the widget re-renders).
-
Step 6: Run all tests to confirm no Python regression
Run:
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims:TestFusionClaimsDashboard --stop-after-init 2>&1 | tail -15
Expected: 31 tests still pass.
- Step 7: Commit
git -C K:/Github/Odoo-Modules add fusion_claims/static/src/js/fc_posting_countdown.js fusion_claims/static/src/xml/fc_posting_countdown.xml fusion_claims/__manifest__.py
git -C K:/Github/Odoo-Modules commit -m "feat(fusion_claims): add OWL countdown widget for posting deadline"
Task 12: Manifest version bump + end-to-end smoke test
Files:
-
Modify:
fusion_claims/__manifest__.py(version bump) -
Step 1: Bump module version for asset cache-bust
In fusion_claims/__manifest__.py, change:
'version': '19.0.8.0.7',
to:
'version': '19.0.9.0.0',
Per CLAUDE.md §Asset Cache Busting: bump the minor version so browsers don't serve stale CSS/JS.
- Step 2: Upgrade the module clean
Run:
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --stop-after-init 2>&1 | tail -10
Expected: Modules loaded. and no errors.
- Step 3: Run the full module test suite (not just our new tests)
Run:
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_claims --test-enable --test-tags fusion_claims --stop-after-init 2>&1 | tail -30
Expected: all fusion_claims-tagged tests pass — existing test_signed_pages_gate.py (11 tests) + test_application_received_wizard.py (17 tests) + new test_dashboard.py (31 tests). Total ~59 tests.
- Step 4: Manual end-to-end light-mode smoke test
In a browser (http://localhost:8069):
-
Log in as admin.
-
Navigate ADP Claims → Dashboard.
-
Verify visually:
- Banner gradient is purple → pink wash with dark-indigo text.
- 3 KPI tiles render with $ amounts and the small uppercase labels below.
- 8 quick-action pills are green-tinted on white.
- Left column shows yellow-tinted Activities, red-tinted Bottlenecks, white Other Funders section.
- Right column shows three white ADP/MOD sections.
- Two ADP tiles (Waiting App, Needs Correction) and the On Hold tile are red-tinted (urgent).
- Countdown widget text reflects "days to cutoff".
-
Click any workflow tile — confirm it opens a filtered SO list with the matching status.
-
Click
+ ADP— confirm a fresh SO form opens withSale Typealready set toadp. -
Step 5: Manual end-to-end dark-mode smoke test
-
Click user profile (top right) → Color Scheme → Dark.
-
Page reloads.
-
Navigate ADP Claims → Dashboard.
-
Verify visually:
- Banner is the cool blue monochrome (#1e293b → #1e3a5f) with sky-blue text.
- Card surfaces are #22262d-ish dark, borders subtle.
- Urgent tiles are dark red (#4a1414) with light coral numbers.
- KPI numbers are sky blue, readable on the dark slate KPI tile background.
- No element appears invisible / mid-grey on mid-grey.
-
Step 6: Manual role-filter check
-
Create or use an existing user who is in
fusion_claims.group_fusion_claims_userbut NOT infusion_claims.group_fusion_claims_manager. -
Log in as that user.
-
Open the dashboard.
-
Verify:
- The "Showing your assigned cases only" alert is visible.
- All tile counts reflect only the SOs where
user_id = this user. - Switching back to admin (manager) — alert disappears, counts go back to full.
-
Step 7: Final commit
git -C K:/Github/Odoo-Modules add fusion_claims/__manifest__.py
git -C K:/Github/Odoo-Modules commit -m "feat(fusion_claims): bump version to 19.0.9.0.0 for dashboard rewrite"
Plan Self-Review Notes
Spec coverage check:
| Spec section | Implementing task |
|---|---|
| §2 audience / role filter | Task 1, verified across all subsequent count tasks |
| §3 scope — banner | Task 2 |
| §3 scope — 3 KPI tiles | Task 3 |
| §3 scope — 8 hotlinks | Task 8 |
| §3 scope — activities | Task 4 |
| §3 scope — bottlenecks | Task 4 |
| §3 scope — ADP pre/post | Task 6 |
| §3 scope — MOD | Task 6 |
| §3 scope — other funders | Task 5 |
| §3 scope — dark/light SCSS | Task 10 |
| §4.2 file list | Each task creates/modifies its file |
| §5 role filter | Task 1 |
| §6 field inventory (36) | Tasks 1–6 |
| §7 compute clustering (5 methods) | Distributed across Tasks 1–6 |
| §8 action methods (~24) | Tasks 7 + 8 |
| §9 SCSS structure | Task 10 |
| §10 OWL countdown | Task 11 |
| §11 manifest changes | Task 10 (assets) + Task 12 (version) |
| §12 edge cases | §12.1 pre-first-posting handled in Task 2; §12.2 empty system handled by empty-state HTML in Task 4; §12.3 salesrep-no-cases handled by role filter; §12.4 portal user gating preserved by reusing the existing menu item; §12.5 multi-currency limitation documented in the spec, not enforced in code |
| §14 acceptance criteria | Task 12 manual smoke test covers AC #1, #2, #3, #4, #5, #7, #8, #9, #10. AC #6 is exercised by clicking the bottleneck tile in Step 4. |
Placeholder scan: none found. All steps contain runnable code or commands.
Type consistency: field names match across tasks. Method names match between definition (Task 1, 4, 5, 6, 7, 8) and consumers (form view in Task 9). The widget name fc_posting_countdown is consistent between Task 9 (use site) and Task 11 (definition).
Plan complete and saved to docs/superpowers/plans/2026-05-21-fusion-claims-dashboard.md.