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''
+ )
+ 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
+
+
+
+
+
+
+
+ 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')"/>
+
+