Files
Odoo-Modules/fusion_claims/docs/superpowers/plans/2026-05-21-fusion-claims-dashboard.md
gsinghpal 53fd6114e7 changes
2026-05-21 03:42:46 -04:00

102 KiB
Raw Blame History

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 :TestFusionClaimsDashboard to 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 TransactionCase and 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): ... or test(fusion_claims): ... (style matches the project's recent commits like fix(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__.py if 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_banner to 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"

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

                        <!-- 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) or 2d 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 with Sale Type already set to adp.

  • 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_user but NOT in fusion_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 16
§7 compute clustering (5 methods) Distributed across Tasks 16
§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.