2404 lines
102 KiB
Markdown
2404 lines
102 KiB
Markdown
# 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 = (
|
||
'<p class="o_fc_empty">No activities assigned.</p>'
|
||
)
|
||
continue
|
||
rows = []
|
||
from datetime import date
|
||
today = date.today()
|
||
for act in activities:
|
||
overdue = act.date_deadline and act.date_deadline < today
|
||
row_class = 'o_fc_activity_row o_fc_activity_overdue' if overdue else 'o_fc_activity_row'
|
||
deadline_text = act.date_deadline.strftime('%b %d') if act.date_deadline else '—'
|
||
url = f'/odoo/{act.res_model.replace(".", "_")}/{act.res_id}'
|
||
rows.append(
|
||
f'<div class="{row_class}">'
|
||
f'<a href="{url}"><b>{act.summary or act.activity_type_id.name or "Activity"}</b></a>'
|
||
f'<span class="o_fc_activity_deadline">{deadline_text}</span>'
|
||
f'</div>'
|
||
)
|
||
rec.my_activities_html = '\n'.join(rows)
|
||
|
||
# =========================================================================
|
||
# Bottlenecks (left column)
|
||
# =========================================================================
|
||
bottleneck_no_pod_count = fields.Integer(compute='_compute_secondary_counts')
|
||
bottleneck_no_response_count = fields.Integer(compute='_compute_secondary_counts')
|
||
|
||
def _compute_secondary_counts(self):
|
||
from datetime import date, timedelta
|
||
SO = self.env['sale.order'].sudo()
|
||
cutoff_14d_ago = date.today() - timedelta(days=14)
|
||
for rec in self:
|
||
base = rec._role_filter_domain()
|
||
|
||
rec.bottleneck_no_pod_count = SO.search_count(base + [
|
||
('x_fc_adp_application_status', 'in', ['approved', 'approved_deduction']),
|
||
('x_fc_proof_of_delivery', '=', False),
|
||
])
|
||
rec.bottleneck_no_response_count = SO.search_count(base + [
|
||
('x_fc_adp_application_status', 'in', ['submitted', 'resubmitted']),
|
||
('x_fc_claim_submission_date', '<', cutoff_14d_ago),
|
||
])
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run:
|
||
```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
|
||
<?xml version="1.0" encoding="UTF-8"?>
|
||
<odoo>
|
||
<record id="view_fusion_claims_dashboard_form" model="ir.ui.view">
|
||
<field name="name">fusion.claims.dashboard.form</field>
|
||
<field name="model">fusion.claims.dashboard</field>
|
||
<field name="arch" type="xml">
|
||
<form string="Dashboard" create="0" delete="0" edit="0"
|
||
class="o_fc_dashboard">
|
||
<sheet>
|
||
|
||
<!-- Hidden invariants used by buttons + widgets -->
|
||
<field name="currency_id" invisible="1"/>
|
||
<field name="posting_period_start" invisible="1"/>
|
||
<field name="is_manager" invisible="1"/>
|
||
<field name="is_pre_first_posting" invisible="1"/>
|
||
|
||
<!-- BANNER -->
|
||
<div class="o_fc_banner mb-3">
|
||
<div class="o_fc_banner__label">
|
||
<i class="fa fa-calendar me-2"/>
|
||
<span>Posting Period: </span>
|
||
<field name="posting_period_label" nolabel="1"
|
||
class="fw-bold"/>
|
||
</div>
|
||
<div class="o_fc_banner__deadline">
|
||
<field name="submission_deadline_dt"
|
||
widget="fc_posting_countdown"
|
||
nolabel="1" readonly="1"/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- "Showing your cases" hint when role-filtered -->
|
||
<div class="alert alert-info py-2 mb-2"
|
||
invisible="is_manager">
|
||
Showing your assigned cases only.
|
||
</div>
|
||
|
||
<!-- KPI TILES (3-up) -->
|
||
<div class="row g-2 mb-3">
|
||
<div class="col-12 col-md-4">
|
||
<button name="action_open_kpi_ready" type="object"
|
||
class="btn p-0 w-100 border-0">
|
||
<div class="o_fc_kpi">
|
||
<span class="o_fc_kpi__num">
|
||
<field name="kpi_ready_amount"
|
||
widget="monetary" nolabel="1"
|
||
options="{'currency_field': 'currency_id'}"/>
|
||
</span>
|
||
<span class="o_fc_kpi__lbl">Ready to Claim
|
||
(<field name="kpi_ready_count" nolabel="1"/>)
|
||
</span>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
<div class="col-12 col-md-4">
|
||
<button name="action_open_kpi_claimed" type="object"
|
||
class="btn p-0 w-100 border-0">
|
||
<div class="o_fc_kpi">
|
||
<span class="o_fc_kpi__num">
|
||
<field name="kpi_claimed_amount"
|
||
widget="monetary" nolabel="1"
|
||
options="{'currency_field': 'currency_id'}"/>
|
||
</span>
|
||
<span class="o_fc_kpi__lbl">Claimed This Period
|
||
(<field name="kpi_claimed_count" nolabel="1"/>)
|
||
</span>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
<div class="col-12 col-md-4">
|
||
<button name="action_open_kpi_ar" type="object"
|
||
class="btn p-0 w-100 border-0">
|
||
<div class="o_fc_kpi">
|
||
<span class="o_fc_kpi__num">
|
||
<field name="kpi_ar_amount"
|
||
widget="monetary" nolabel="1"
|
||
options="{'currency_field': 'currency_id'}"/>
|
||
</span>
|
||
<span class="o_fc_kpi__lbl">Total AR
|
||
(<field name="kpi_ar_count" nolabel="1"/>)
|
||
</span>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- QUICK ACTION PILLS -->
|
||
<div class="o_fc_actions mb-3">
|
||
<button name="action_create_adp_so" type="object"
|
||
class="o_fc_pill">+ ADP</button>
|
||
<button name="action_create_mod_so" type="object"
|
||
class="o_fc_pill">+ MOD</button>
|
||
<button name="action_create_odsp_so" type="object"
|
||
class="o_fc_pill">+ ODSP</button>
|
||
<button name="action_create_wsib_so" type="object"
|
||
class="o_fc_pill">+ WSIB</button>
|
||
<button name="action_create_insurance_so" type="object"
|
||
class="o_fc_pill">+ Insurance</button>
|
||
<button name="action_create_mdc_so" type="object"
|
||
class="o_fc_pill">+ MDC</button>
|
||
<button name="action_create_hardship_so" type="object"
|
||
class="o_fc_pill">+ Hardship</button>
|
||
<button name="action_create_private_so" type="object"
|
||
class="o_fc_pill">+ Private</button>
|
||
</div>
|
||
|
||
<!-- 2-COLUMN GRID -->
|
||
<div class="row g-3">
|
||
|
||
<!-- LEFT COLUMN -->
|
||
<div class="col-12 col-lg-5">
|
||
|
||
<!-- Your Activities -->
|
||
<div class="o_fc_activities mb-3">
|
||
<h6 class="o_fc_h6">
|
||
<i class="fa fa-thumb-tack me-2"/>
|
||
Your Activities
|
||
<span class="o_fc_tag">
|
||
<field name="my_activities_count" nolabel="1"/>
|
||
</span>
|
||
<button name="action_open_my_activities" type="object"
|
||
class="btn btn-link btn-sm ms-auto p-0">
|
||
View all
|
||
</button>
|
||
</h6>
|
||
<field name="my_activities_html" nolabel="1"/>
|
||
</div>
|
||
|
||
<!-- Bottlenecks -->
|
||
<div class="o_fc_bottleneck mb-3">
|
||
<h6 class="o_fc_h6">
|
||
<i class="fa fa-exclamation-triangle me-2"/>
|
||
Bottlenecks
|
||
</h6>
|
||
<button name="action_open_bottleneck_no_pod" type="object"
|
||
class="o_fc_bottleneck_row btn btn-link p-0">
|
||
Approved without POD:
|
||
<span class="fw-bold ms-1">
|
||
<field name="bottleneck_no_pod_count" nolabel="1"/>
|
||
</span>
|
||
</button>
|
||
<button name="action_open_bottleneck_no_response" type="object"
|
||
class="o_fc_bottleneck_row btn btn-link p-0">
|
||
Submitted > 14d, no response:
|
||
<span class="fw-bold ms-1">
|
||
<field name="bottleneck_no_response_count" nolabel="1"/>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Other Funders -->
|
||
<div class="o_fc_section mb-3">
|
||
<h6 class="o_fc_h6">Other Funders</h6>
|
||
<div class="row g-2">
|
||
<div class="col-4">
|
||
<button name="action_open_odsp_cases" type="object"
|
||
class="btn p-0 w-100 border-0">
|
||
<div class="o_fc_tile">
|
||
<span class="o_fc_tile__num">
|
||
<field name="count_odsp" nolabel="1"/>
|
||
</span>ODSP
|
||
</div>
|
||
</button>
|
||
</div>
|
||
<div class="col-4">
|
||
<button name="action_open_wsib_cases" type="object"
|
||
class="btn p-0 w-100 border-0">
|
||
<div class="o_fc_tile">
|
||
<span class="o_fc_tile__num">
|
||
<field name="count_wsib" nolabel="1"/>
|
||
</span>WSIB
|
||
</div>
|
||
</button>
|
||
</div>
|
||
<div class="col-4">
|
||
<button name="action_open_insurance_cases" type="object"
|
||
class="btn p-0 w-100 border-0">
|
||
<div class="o_fc_tile">
|
||
<span class="o_fc_tile__num">
|
||
<field name="count_insurance" nolabel="1"/>
|
||
</span>Insurance
|
||
</div>
|
||
</button>
|
||
</div>
|
||
<div class="col-4">
|
||
<button name="action_open_mdc_cases" type="object"
|
||
class="btn p-0 w-100 border-0">
|
||
<div class="o_fc_tile">
|
||
<span class="o_fc_tile__num">
|
||
<field name="count_mdc" nolabel="1"/>
|
||
</span>MDC
|
||
</div>
|
||
</button>
|
||
</div>
|
||
<div class="col-4">
|
||
<button name="action_open_hardship_cases" type="object"
|
||
class="btn p-0 w-100 border-0">
|
||
<div class="o_fc_tile">
|
||
<span class="o_fc_tile__num">
|
||
<field name="count_hardship" nolabel="1"/>
|
||
</span>Hardship
|
||
</div>
|
||
</button>
|
||
</div>
|
||
<div class="col-4">
|
||
<button name="action_open_acsd_cases" type="object"
|
||
class="btn p-0 w-100 border-0">
|
||
<div class="o_fc_tile">
|
||
<span class="o_fc_tile__num">
|
||
<field name="count_acsd" nolabel="1"/>
|
||
</span>ACSD
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- RIGHT COLUMN -->
|
||
<div class="col-12 col-lg-7">
|
||
|
||
<!-- ADP Pre-Approval -->
|
||
<div class="o_fc_section mb-3">
|
||
<h6 class="o_fc_h6">ADP
|
||
<span class="o_fc_tag">Pre-Approval</span>
|
||
</h6>
|
||
<div class="row g-2">
|
||
<div class="col-6 col-md-3">
|
||
<button name="action_open_adp_waiting_app" type="object"
|
||
class="btn p-0 w-100 border-0">
|
||
<div class="o_fc_tile o_fc_tile--urgent">
|
||
<span class="o_fc_tile__num">
|
||
<field name="adp_waiting_app_count" nolabel="1"/>
|
||
</span>Waiting App
|
||
</div>
|
||
</button>
|
||
</div>
|
||
<div class="col-6 col-md-3">
|
||
<button name="action_open_adp_app_received" type="object"
|
||
class="btn p-0 w-100 border-0">
|
||
<div class="o_fc_tile">
|
||
<span class="o_fc_tile__num">
|
||
<field name="adp_app_received_count" nolabel="1"/>
|
||
</span>App Received
|
||
</div>
|
||
</button>
|
||
</div>
|
||
<div class="col-6 col-md-3">
|
||
<button name="action_open_adp_ready_submit" type="object"
|
||
class="btn p-0 w-100 border-0">
|
||
<div class="o_fc_tile">
|
||
<span class="o_fc_tile__num">
|
||
<field name="adp_ready_submit_count" nolabel="1"/>
|
||
</span>Ready Submit
|
||
</div>
|
||
</button>
|
||
</div>
|
||
<div class="col-6 col-md-3">
|
||
<button name="action_open_adp_needs_correction" type="object"
|
||
class="btn p-0 w-100 border-0">
|
||
<div class="o_fc_tile o_fc_tile--urgent">
|
||
<span class="o_fc_tile__num">
|
||
<field name="adp_needs_correction_count" nolabel="1"/>
|
||
</span>Needs Correction
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ADP Post-Approval -->
|
||
<div class="o_fc_section mb-3">
|
||
<h6 class="o_fc_h6">ADP
|
||
<span class="o_fc_tag">Post-Approval</span>
|
||
</h6>
|
||
<div class="row g-2">
|
||
<div class="col-6 col-md-3">
|
||
<button name="action_open_adp_approved" type="object"
|
||
class="btn p-0 w-100 border-0">
|
||
<div class="o_fc_tile">
|
||
<span class="o_fc_tile__num">
|
||
<field name="adp_approved_count" nolabel="1"/>
|
||
</span>Approved
|
||
</div>
|
||
</button>
|
||
</div>
|
||
<div class="col-6 col-md-3">
|
||
<button name="action_open_adp_ready_delivery" type="object"
|
||
class="btn p-0 w-100 border-0">
|
||
<div class="o_fc_tile">
|
||
<span class="o_fc_tile__num">
|
||
<field name="adp_ready_delivery_count" nolabel="1"/>
|
||
</span>Ready Delivery
|
||
</div>
|
||
</button>
|
||
</div>
|
||
<div class="col-6 col-md-3">
|
||
<button name="action_open_adp_ready_bill" type="object"
|
||
class="btn p-0 w-100 border-0">
|
||
<div class="o_fc_tile">
|
||
<span class="o_fc_tile__num">
|
||
<field name="adp_ready_bill_count" nolabel="1"/>
|
||
</span>Ready Bill
|
||
</div>
|
||
</button>
|
||
</div>
|
||
<div class="col-6 col-md-3">
|
||
<button name="action_open_adp_on_hold" type="object"
|
||
class="btn p-0 w-100 border-0">
|
||
<div class="o_fc_tile o_fc_tile--urgent">
|
||
<span class="o_fc_tile__num">
|
||
<field name="adp_on_hold_count" nolabel="1"/>
|
||
</span>On Hold
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- MOD -->
|
||
<div class="o_fc_section mb-3">
|
||
<h6 class="o_fc_h6">MOD</h6>
|
||
<div class="row g-2">
|
||
<div class="col-6 col-md-2">
|
||
<button name="action_open_mod_awaiting_funding" type="object"
|
||
class="btn p-0 w-100 border-0">
|
||
<div class="o_fc_tile">
|
||
<span class="o_fc_tile__num">
|
||
<field name="mod_awaiting_funding_count" nolabel="1"/>
|
||
</span>Awaiting
|
||
</div>
|
||
</button>
|
||
</div>
|
||
<div class="col-6 col-md-2">
|
||
<button name="action_open_mod_funding_approved" type="object"
|
||
class="btn p-0 w-100 border-0">
|
||
<div class="o_fc_tile">
|
||
<span class="o_fc_tile__num">
|
||
<field name="mod_funding_approved_count" nolabel="1"/>
|
||
</span>Approved
|
||
</div>
|
||
</button>
|
||
</div>
|
||
<div class="col-6 col-md-2">
|
||
<button name="action_open_mod_pca_received" type="object"
|
||
class="btn p-0 w-100 border-0">
|
||
<div class="o_fc_tile">
|
||
<span class="o_fc_tile__num">
|
||
<field name="mod_pca_received_count" nolabel="1"/>
|
||
</span>PCA
|
||
</div>
|
||
</button>
|
||
</div>
|
||
<div class="col-6 col-md-3">
|
||
<button name="action_open_mod_project_complete" type="object"
|
||
class="btn p-0 w-100 border-0">
|
||
<div class="o_fc_tile">
|
||
<span class="o_fc_tile__num">
|
||
<field name="mod_project_complete_count" nolabel="1"/>
|
||
</span>Proj. Done
|
||
</div>
|
||
</button>
|
||
</div>
|
||
<div class="col-6 col-md-3">
|
||
<button name="action_open_mod_pod_submitted" type="object"
|
||
class="btn p-0 w-100 border-0">
|
||
<div class="o_fc_tile">
|
||
<span class="o_fc_tile__num">
|
||
<field name="mod_pod_submitted_count" nolabel="1"/>
|
||
</span>POD Submitted
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</sheet>
|
||
</form>
|
||
</field>
|
||
</record>
|
||
|
||
<!-- Dashboard Action — preserved id so existing menu items resolve -->
|
||
<record id="action_fusion_claims_dashboard" model="ir.actions.act_window">
|
||
<field name="name">Dashboard</field>
|
||
<field name="res_model">fusion.claims.dashboard</field>
|
||
<field name="view_mode">form</field>
|
||
<field name="view_id" ref="view_fusion_claims_dashboard_form"/>
|
||
<field name="target">current</field>
|
||
</record>
|
||
</odoo>
|
||
```
|
||
|
||
- [ ] **Step 2: Upgrade the module to load the new view**
|
||
|
||
Run:
|
||
```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
|
||
<?xml version="1.0" encoding="UTF-8"?>
|
||
<templates xml:space="preserve">
|
||
<t t-name="fusion_claims.PostingCountdown">
|
||
<span t-attf-class="o_fc_countdown o_fc_countdown--{{state.level}}"
|
||
t-esc="state.text"/>
|
||
</t>
|
||
</templates>
|
||
```
|
||
|
||
- [ ] **Step 3: Register the JS + XML in the manifest**
|
||
|
||
In `fusion_claims/__manifest__.py`, extend the `web.assets_backend` list (add these two lines AFTER the SCSS dashboard files added in Task 10):
|
||
|
||
```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`.
|