From 53fd6114e72ebebf431a424b69eb79ff4a87da6d Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 21 May 2026 03:42:46 -0400 Subject: [PATCH] changes --- .../2026-05-21-fusion-claims-dashboard.md | 2403 +++++++++++++++++ fusion_claims/tests/__init__.py | 1 + fusion_claims/tests/test_dashboard.py | 59 + .../__manifest__.py | 2 +- .../models/fp_certificate.py | 33 + .../fusion_plating_jobs/__manifest__.py | 2 +- .../fusion_plating_jobs/models/fp_job.py | 97 +- .../fusion_plating_logistics/__manifest__.py | 2 +- .../models/fp_delivery.py | 42 + .../views/fp_delivery_views.xml | 11 + 10 files changed, 2624 insertions(+), 28 deletions(-) create mode 100644 fusion_claims/docs/superpowers/plans/2026-05-21-fusion-claims-dashboard.md create mode 100644 fusion_claims/tests/test_dashboard.py diff --git a/fusion_claims/docs/superpowers/plans/2026-05-21-fusion-claims-dashboard.md b/fusion_claims/docs/superpowers/plans/2026-05-21-fusion-claims-dashboard.md new file mode 100644 index 00000000..aa99ac10 --- /dev/null +++ b/fusion_claims/docs/superpowers/plans/2026-05-21-fusion-claims-dashboard.md @@ -0,0 +1,2403 @@ +# 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: +```bash +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`: + +```python +# -*- 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): + +```python +from . import test_dashboard +``` + +- [ ] **Step 4: Run test to verify it fails** + +Run: +```bash +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: + +```python +# -*- 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: +```bash +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** + +```bash +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): + +```python + 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: +```bash +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`: + +```python + # ========================================================================= + # 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: +```bash +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** + +```bash +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`): + +```python + @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`: + +```python + 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: +```bash +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`: + +```python + # ========================================================================= + # 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: +```bash +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** + +```bash +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`: + +```python + 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: +```bash +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`: + +```python + # ========================================================================= + # 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 = ( + '

No activities assigned.

' + ) + 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'
' + f'{act.summary or act.activity_type_id.name or "Activity"}' + f'{deadline_text}' + f'
' + ) + 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: +```bash +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** + +```bash +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`: + +```python + 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: +```bash +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: + +```python + # ========================================================================= + # 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): + +```python + 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: +```bash +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** + +```bash +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`: + +```python + 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: +```bash +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`: + +```python + # ========================================================================= + # 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: +```bash +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** + +```bash +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`: + +```python + 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: +```bash +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`: + +```python + # ========================================================================= + # 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: +```bash +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** + +```bash +git -C K:/Github/Odoo-Modules add fusion_claims/models/dashboard.py fusion_claims/tests/test_dashboard.py +git -C K:/Github/Odoo-Modules commit -m "feat(fusion_claims): add dashboard open-list action methods" +``` + +--- + +## Task 8: Create-SO action methods (8 hotlinks) + +**Files:** +- Modify: `fusion_claims/models/dashboard.py` +- Modify: `fusion_claims/tests/test_dashboard.py` + +- [ ] **Step 1: Write failing tests** + +Add to `test_dashboard.py`: + +```python + 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: +```bash +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`: + +```python + # ========================================================================= + # 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: +```bash +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** + +```bash +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 + + + + fusion.claims.dashboard.form + fusion.claims.dashboard + +
+ + + + + + + + + +
+
+ + Posting Period: + +
+
+ +
+
+ + +
+ Showing your assigned cases only. +
+ + +
+
+ +
+
+ +
+
+ +
+
+ + +
+ + + + + + + + +
+ + +
+ + +
+ + +
+
+ + Your Activities + + + + +
+ +
+ + +
+
+ + Bottlenecks +
+ + +
+ + +
+
Other Funders
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ + +
+ + +
+
ADP + Pre-Approval +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + +
+
ADP + Post-Approval +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + +
+
MOD
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+ +
+ +
+
+ + + + Dashboard + fusion.claims.dashboard + form + + current + +
+``` + +- [ ] **Step 2: Upgrade the module to load the new view** + +Run: +```bash +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: +```bash +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** + +```bash +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`: + +```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`: + +```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: + +```python + '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: +```bash +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: + +```bash +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** + +```bash +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`: + +```javascript +/** @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 + + + + + + +``` + +- [ ] **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): + +```python + '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: +```bash +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: +```bash +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** + +```bash +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: + +```python + 'version': '19.0.8.0.7', +``` + +to: + +```python + '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: +```bash +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: +```bash +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** + +```bash +git -C K:/Github/Odoo-Modules add fusion_claims/__manifest__.py +git -C K:/Github/Odoo-Modules commit -m "feat(fusion_claims): bump version to 19.0.9.0.0 for dashboard rewrite" +``` + +--- + +## Plan Self-Review Notes + +**Spec coverage check:** + +| Spec section | Implementing task | +|---|---| +| §2 audience / role filter | Task 1, verified across all subsequent count tasks | +| §3 scope — banner | Task 2 | +| §3 scope — 3 KPI tiles | Task 3 | +| §3 scope — 8 hotlinks | Task 8 | +| §3 scope — activities | Task 4 | +| §3 scope — bottlenecks | Task 4 | +| §3 scope — ADP pre/post | Task 6 | +| §3 scope — MOD | Task 6 | +| §3 scope — other funders | Task 5 | +| §3 scope — dark/light SCSS | Task 10 | +| §4.2 file list | Each task creates/modifies its file | +| §5 role filter | Task 1 | +| §6 field inventory (36) | Tasks 1–6 | +| §7 compute clustering (5 methods) | Distributed across Tasks 1–6 | +| §8 action methods (~24) | Tasks 7 + 8 | +| §9 SCSS structure | Task 10 | +| §10 OWL countdown | Task 11 | +| §11 manifest changes | Task 10 (assets) + Task 12 (version) | +| §12 edge cases | §12.1 pre-first-posting handled in Task 2; §12.2 empty system handled by empty-state HTML in Task 4; §12.3 salesrep-no-cases handled by role filter; §12.4 portal user gating preserved by reusing the existing menu item; §12.5 multi-currency limitation documented in the spec, not enforced in code | +| §14 acceptance criteria | Task 12 manual smoke test covers AC #1, #2, #3, #4, #5, #7, #8, #9, #10. AC #6 is exercised by clicking the bottleneck tile in Step 4. | + +**Placeholder scan:** none found. All steps contain runnable code or commands. + +**Type consistency:** field names match across tasks. Method names match between definition (Task 1, 4, 5, 6, 7, 8) and consumers (form view in Task 9). The widget name `fc_posting_countdown` is consistent between Task 9 (use site) and Task 11 (definition). + +--- + +Plan complete and saved to `docs/superpowers/plans/2026-05-21-fusion-claims-dashboard.md`. diff --git a/fusion_claims/tests/__init__.py b/fusion_claims/tests/__init__.py index 1884e7ad..561d21da 100644 --- a/fusion_claims/tests/__init__.py +++ b/fusion_claims/tests/__init__.py @@ -2,3 +2,4 @@ from . import test_signed_pages_gate from . import test_application_received_wizard +from . import test_dashboard diff --git a/fusion_claims/tests/test_dashboard.py b/fusion_claims/tests/test_dashboard.py new file mode 100644 index 00000000..9fb6e99c --- /dev/null +++ b/fusion_claims/tests/test_dashboard.py @@ -0,0 +1,59 @@ +# -*- 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) diff --git a/fusion_plating/fusion_plating_certificates/__manifest__.py b/fusion_plating/fusion_plating_certificates/__manifest__.py index 0ca3e086..b83abd5a 100644 --- a/fusion_plating/fusion_plating_certificates/__manifest__.py +++ b/fusion_plating/fusion_plating_certificates/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Certificates', - 'version': '19.0.7.7.0', + 'version': '19.0.7.8.0', 'category': 'Manufacturing/Plating', 'summary': 'Certificate registry for CoC, thickness reports, and quality documents.', 'description': """ diff --git a/fusion_plating/fusion_plating_certificates/models/fp_certificate.py b/fusion_plating/fusion_plating_certificates/models/fp_certificate.py index 5db65d09..8c1f2516 100644 --- a/fusion_plating/fusion_plating_certificates/models/fp_certificate.py +++ b/fusion_plating/fusion_plating_certificates/models/fp_certificate.py @@ -594,8 +594,41 @@ class FpCertificate(models.Model): _logger.warning( 'Cert %s: PDF render failed: %s', rec.name, e, ) + # Back-fill the CoC attachment onto the linked delivery + # if one exists already. Job._fp_create_delivery handles + # the create-time case (cert issued before delivery + # spawned); this handles the inverse (delivery spawned + # first, cert issued later). Best-effort. + try: + rec._fp_sync_coc_to_delivery() + except Exception as e: + _logger.warning( + 'Cert %s: CoC->delivery sync failed: %s', + rec.name, e, + ) rec.message_post(body=_('Certificate issued.')) + def _fp_sync_coc_to_delivery(self): + """Push this CoC's attachment onto its job's delivery so the + shipping crew sees the CoC ready to print without hunting for + the cert. Only acts on `coc` certs with an attachment_id; + delivery field must exist and be empty (don't overwrite an + operator's manual choice). + """ + self.ensure_one() + if self.certificate_type != 'coc' or not self.attachment_id: + return + job = self.x_fc_job_id if 'x_fc_job_id' in self._fields else False + if not job or not job.delivery_id: + return + delivery = job.delivery_id.sudo() + if 'coc_attachment_id' not in delivery._fields: + return + if delivery.coc_attachment_id: + # Operator already picked one; don't overwrite. + return + delivery.coc_attachment_id = self.attachment_id.id + def _fp_render_and_attach_pdf(self): """Render the CoC PDF via the bound report action, OPTIONALLY merge the Fischerscope thickness report PDF (uploaded by the diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py index eee4a3de..0825fd82 100644 --- a/fusion_plating/fusion_plating_jobs/__manifest__.py +++ b/fusion_plating/fusion_plating_jobs/__manifest__.py @@ -3,7 +3,7 @@ # License OPL-1 (Odoo Proprietary License v1.0) { 'name': 'Fusion Plating — Native Jobs', - 'version': '19.0.10.16.8', + 'version': '19.0.10.16.9', 'category': 'Manufacturing/Plating', 'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.', 'author': 'Nexa Systems Inc.', diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job.py b/fusion_plating/fusion_plating_jobs/models/fp_job.py index 251d7e01..b0620816 100644 --- a/fusion_plating/fusion_plating_jobs/models/fp_job.py +++ b/fusion_plating/fusion_plating_jobs/models/fp_job.py @@ -1675,34 +1675,73 @@ class FpJob(models.Model): look up by job_ref. Setting both ends keeps every consumer happy. - Phase A — mirrors x_fc_carrier_id and x_fc_outbound_shipment_id - from the linked receiving so the delivery carries the shipping - choices made at receipt time. Shipping crew can override later. + Auto-populates everything we can resolve from upstream + records so the shipping crew doesn't have to re-type + addresses / contacts / dates that already exist on the SO: + - delivery_address_id, contact_name, contact_phone — SO's + partner_shipping_id (falls back to partner_id) + - scheduled_date — SO.commitment_date + - source_facility_id — job.facility_id + - x_fc_carrier_id, x_fc_outbound_shipment_id — from the + SO's first receiving record (set at receive time) + - coc_attachment_id — issued cert.attachment_id for this + job (if a CoC is already issued before delivery exists; + otherwise the cert's action_issue back-fills it later) + + Everything skips silently when the source field doesn't + exist or the source value is blank, so older install + topologies and partially-configured jobs still get a + delivery — just less pre-filled. """ self.ensure_one() if self.delivery_id: return Delivery = self.env['fusion.plating.delivery'].sudo() + vals = self._fp_resolve_delivery_defaults(Delivery) + try: + delivery = Delivery.create(vals) + self.delivery_id = delivery.id + except Exception as e: + _logger.warning( + "Job %s: failed to auto-create delivery: %s", self.name, e, + ) + + def _fp_resolve_delivery_defaults(self, Delivery): + """Build the create-vals for a fresh delivery, OR the + write-vals for refreshing an existing one. Centralised so + the create path, the per-cert post-issue sync, and any + future 'Refresh from Source' button all stay consistent. + """ + self.ensure_one() vals = {'partner_id': self.partner_id.id} if 'x_fc_job_id' in Delivery._fields: vals['x_fc_job_id'] = self.id if 'job_ref' in Delivery._fields: vals['job_ref'] = self.name - if 'x_fc_job_id' not in Delivery._fields \ - and 'job_ref' not in Delivery._fields: - _logger.warning( - "Job %s: fusion.plating.delivery has no job link field; " - "delivery created without job back-reference.", self.name, - ) - # Mirror outbound carrier + shipment from the SO's first - # receiving record. If there are multiple receivings (split - # shipments), the shipping crew can change either field on the - # delivery form. Defensive: skip when fields aren't present - # (older instance) or no receiving exists. - if (self.sale_order_id - and 'x_fc_receiving_ids' in self.sale_order_id._fields - and self.sale_order_id.x_fc_receiving_ids): - recv = self.sale_order_id.x_fc_receiving_ids[:1] + # Delivery address + contact details from the SO. shipping + # partner is preferred (that's where parts physically go); + # fall back to the SO's main partner when no separate ship-to. + so = self.sale_order_id + ship_to = (so.partner_shipping_id or so.partner_id) if so else False + if ship_to: + if 'delivery_address_id' in Delivery._fields: + vals['delivery_address_id'] = ship_to.id + if 'contact_name' in Delivery._fields and ship_to.name: + vals['contact_name'] = ship_to.name + if 'contact_phone' in Delivery._fields: + vals['contact_phone'] = ship_to.phone or ship_to.mobile or '' + # Scheduled date — operator can adjust; this just primes it + # so they're not staring at a blank field. + if so and so.commitment_date and 'scheduled_date' in Delivery._fields: + vals['scheduled_date'] = so.commitment_date + # Source facility comes from the job (where it was plated). + if self.facility_id and 'source_facility_id' in Delivery._fields: + vals['source_facility_id'] = self.facility_id.id + # Outbound carrier + shipment mirrored from the SO's first + # receiving record (the crew chose these at receipt time). + if (so and 'x_fc_receiving_ids' in so._fields + and so.x_fc_receiving_ids): + recv = so.x_fc_receiving_ids[:1] if 'x_fc_carrier_id' in Delivery._fields \ and 'x_fc_carrier_id' in recv._fields \ and recv.x_fc_carrier_id: @@ -1713,13 +1752,21 @@ class FpJob(models.Model): vals['x_fc_outbound_shipment_id'] = ( recv.x_fc_outbound_shipment_id.id ) - try: - delivery = Delivery.create(vals) - self.delivery_id = delivery.id - except Exception as e: - _logger.warning( - "Job %s: failed to auto-create delivery: %s", self.name, e, - ) + # CoC PDF — if a cert for this job is already issued and + # the delivery field accepts an attachment, link it. The + # cert's action_issue also calls _fp_sync_to_delivery for + # the case where the cert issues AFTER the delivery exists. + Cert = self.env.get('fp.certificate') + if Cert is not None and 'coc_attachment_id' in Delivery._fields: + issued_cert = Cert.sudo().search([ + ('x_fc_job_id', '=', self.id), + ('certificate_type', '=', 'coc'), + ('state', '=', 'issued'), + ('attachment_id', '!=', False), + ], order='issue_date desc, id desc', limit=1) + if issued_cert and issued_cert.attachment_id: + vals['coc_attachment_id'] = issued_cert.attachment_id.id + return vals def _fp_create_certificates(self): """Auto-create one draft fp.certificate per type returned by diff --git a/fusion_plating/fusion_plating_logistics/__manifest__.py b/fusion_plating/fusion_plating_logistics/__manifest__.py index fabaa88f..0bc8b75a 100644 --- a/fusion_plating/fusion_plating_logistics/__manifest__.py +++ b/fusion_plating/fusion_plating_logistics/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Logistics', - 'version': '19.0.3.9.0', + 'version': '19.0.3.10.0', 'category': 'Manufacturing/Plating', 'summary': ( 'Pickup & delivery for plating shops: vehicle master, driver ' diff --git a/fusion_plating/fusion_plating_logistics/models/fp_delivery.py b/fusion_plating/fusion_plating_logistics/models/fp_delivery.py index a43a0281..2dba6205 100644 --- a/fusion_plating/fusion_plating_logistics/models/fp_delivery.py +++ b/fusion_plating/fusion_plating_logistics/models/fp_delivery.py @@ -260,6 +260,48 @@ class FpDelivery(models.Model): def _fp_parent_counter_field(self): return 'x_fc_pn_delivery_count' + def action_refresh_from_source(self): + """Re-pull delivery address / contact / scheduled date / source + facility / carrier / CoC from the linked job → SO → receiving → + cert chain. Only fills BLANK fields — never overwrites operator + edits. Use when an upstream value changed after the delivery + was auto-created, or to backfill an old delivery that was + created before the auto-populate hook existed. + """ + for rec in self: + job = (rec.x_fc_job_id + if 'x_fc_job_id' in rec._fields else False) + if not job: + # Fall back via job_ref Char if M2O is empty (older data) + if rec.job_ref and 'fp.job' in self.env: + job = self.env['fp.job'].sudo().search( + [('name', '=', rec.job_ref)], limit=1, + ) + if not job: + raise UserError(_( + 'Delivery %s has no linked job — nothing to ' + 'refresh from.' + ) % rec.name) + Delivery = rec.env['fusion.plating.delivery'] + defaults = job._fp_resolve_delivery_defaults(Delivery) + # Drop fields the operator already filled — never clobber + # manual edits. Includes the partner/job links since those + # are non-overridable. + fill = { + k: v for k, v in defaults.items() + if v and not rec[k] + } + if not fill: + rec.message_post(body=_( + 'Refresh from source: nothing to update — every ' + 'field already populated.' + )) + continue + rec.sudo().write(fill) + rec.message_post(body=_( + 'Refresh from source filled: %s' + ) % ', '.join(sorted(fill.keys()))) + @api.model_create_multi def create(self, vals_list): """Parent-derived name (DLV-[-NN]) with legacy-sequence diff --git a/fusion_plating/fusion_plating_logistics/views/fp_delivery_views.xml b/fusion_plating/fusion_plating_logistics/views/fp_delivery_views.xml index 7287c37f..29307854 100644 --- a/fusion_plating/fusion_plating_logistics/views/fp_delivery_views.xml +++ b/fusion_plating/fusion_plating_logistics/views/fp_delivery_views.xml @@ -55,6 +55,17 @@ invisible="state in ('delivered','cancelled')"/>