Files
Odoo-Modules/fusion_repairs/models/repair_dashboard.py
gsinghpal 38a79a4b04 feat(fusion_repairs): OWL dashboard - quick actions, KPIs, portal share
A real landing dashboard for the Fusion Repairs app so users see at a
glance what is open, what is urgent, and where to click. Built as an
OWL client action, theme-aware (light AND dark) at SCSS compile time,
zero hardcoded user-facing colours.

What's on it
- Hero banner with gradient accent
- 4 quick-action tiles (New Service Call, Service Calls, Maintenance
  Contracts, Repair Warranties)
- 6 KPI stat tiles (Open / Urgent+Safety / Awaiting Dispatch /
  Needs Re-Quote / New This Month / Maintenance Due 30d) - each is
  clickable and lands in the right filtered list
- Self-service portal cards with copy-to-clipboard for the public
  client portal URL and the sales rep portal URL (so office can
  share them on voicemail / printed materials / training)
- Recent Service Calls list (last 5) - click jumps to repair form
- Upcoming Maintenance list (next 5 due) - red pill when <=7 days out
- Configuration tiles (Equipment Categories / Intake Templates /
  Service Catalogue)
- Refresh button

Architecture
- fusion.repair.dashboard AbstractModel exposes get_dashboard_data():
  returns stats + urgency_breakdown + source_breakdown + recent[5] +
  upcoming[5] + portals (URLs resolved via web.base.url +
  fusion_repairs.client_portal_url)
- FusionRepairsDashboard OWL component (registry actions
  'fusion_repairs.dashboard') uses standalone rpc() per project rule
  #3, useService('action') for navigation, useService('notification')
  for copy feedback. static props = ['*'] to accept the client-action
  props envelope.
- _fr_tokens.scss registered FIRST in web.assets_backend so its
  variables are in scope when dashboard.scss compiles. NO @import (per
  project rule). Branches on $o-webclient-color-scheme at compile time
  so the dark bundle (web.assets_web_dark) gets dark hex values
  automatically - per project CLAUDE.md rule on dark mode.
- All visible colours come from CSS-variable-wrapped SCSS tokens
  (--fr-page-bg, --fr-card-bg, --fr-border, --fr-accent, ...) which
  fall back to the SCSS hex value. Three-layer contrast: page (grayest)
  -> card (mid) -> elevated (brightest).
- New ir.actions.client action_fusion_repairs_home_dashboard with
  tag='fusion_repairs.dashboard'.
- Top-level menu now lands on this dashboard. 'Dashboard' added as
  the first sub-menu; 'Service Calls' (the kanban) is still right
  below it.

Verified on local westin-v19:
  STATS: open=15, urgent=4, new_this_month=13, awaiting_dispatch=9,
         requires_requote=1, maintenance_due_30d=1, active_total=2
  PORTALS: client=http://192.168.139.165:8069/repair
           sales_rep=http://192.168.139.165:8069/my/repair/new
  RECENT count: 5
  UPCOMING count: 2
  SOURCE breakdown: backend_wizard 9, client_portal 3, manual 2, sales_rep_portal 1
  Web /web/login: 200, no SCSS compile errors in logs.

Bumped to 19.0.1.0.5 so the asset bundle hash refreshes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 22:58:06 -04:00

132 lines
5.1 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Repair dashboard data provider.
Feeds the OWL client action `fusion_repairs.dashboard` with KPI counts,
recent activity, and upcoming maintenance. Lives as an AbstractModel
because it stores nothing - all values are computed on demand.
"""
from datetime import timedelta
from odoo import api, fields, models
class FusionRepairDashboard(models.AbstractModel):
_name = 'fusion.repair.dashboard'
_description = 'Repair Dashboard Data Provider'
@api.model
def get_dashboard_data(self):
"""Return everything the dashboard needs in a single call."""
Repair = self.env['repair.order']
Contract = self.env['fusion.repair.maintenance.contract']
today = fields.Date.context_today(self)
month_start = today.replace(day=1)
thirty_days = today + timedelta(days=30)
# ---------------- KPI counters ----------------
open_domain = [('state', 'not in', ('done', 'cancel'))]
urgent_domain = open_domain + [('x_fc_urgency', 'in', ('urgent', 'safety'))]
new_this_month_domain = [('create_date', '>=', month_start)]
no_task_domain = open_domain + [
('x_fc_technician_task_ids', '=', False),
]
requote_domain = open_domain + [('x_fc_requires_requote', '=', True)]
stats = {
'open_count': Repair.search_count(open_domain),
'urgent_count': Repair.search_count(urgent_domain),
'new_this_month': Repair.search_count(new_this_month_domain),
'awaiting_dispatch': Repair.search_count(no_task_domain),
'requires_requote': Repair.search_count(requote_domain),
'maintenance_due_30d': Contract.search_count([
('state', '=', 'active'),
('next_due_date', '<=', thirty_days),
]),
'maintenance_active_total': Contract.search_count([
('state', '=', 'active'),
]),
}
# ---------------- Source breakdown for the doughnut ----------------
source_rows = Repair._read_group(
open_domain,
['x_fc_intake_source'],
['__count'],
)
source_breakdown = []
source_labels = dict(Repair._fields['x_fc_intake_source'].selection)
for src, count in source_rows:
source_breakdown.append({
'key': src or 'manual',
'label': source_labels.get(src or 'manual', src or 'Other'),
'count': count,
})
# ---------------- Urgency breakdown ----------------
urgency_rows = Repair._read_group(
open_domain,
['x_fc_urgency'],
['__count'],
)
urgency_labels = dict(Repair._fields['x_fc_urgency'].selection)
urgency_breakdown = [{
'key': u or 'normal',
'label': urgency_labels.get(u or 'normal', 'Normal'),
'count': c,
} for u, c in urgency_rows]
# ---------------- Recent service calls (last 5) ----------------
recent = []
for r in Repair.search([], order='create_date desc', limit=5):
recent.append({
'id': r.id,
'name': r.name,
'partner_name': r.partner_id.name or '',
'category': r.x_fc_repair_category_id.name or '',
'urgency': r.x_fc_urgency,
'state': r.state,
'state_label': dict(Repair._fields['state'].selection).get(r.state, r.state),
'create_date': fields.Datetime.to_string(r.create_date),
'source': r.x_fc_intake_source or '',
'source_label': source_labels.get(r.x_fc_intake_source, ''),
})
# ---------------- Upcoming maintenance (next 5 due) ----------------
upcoming = []
for c in Contract.search(
[('state', '=', 'active'), ('next_due_date', '!=', False)],
order='next_due_date asc', limit=5,
):
upcoming.append({
'id': c.id,
'name': c.name,
'partner_name': c.partner_id.name or '',
'product_name': c.product_id.display_name or '',
'next_due_date': fields.Date.to_string(c.next_due_date),
'days_until': (c.next_due_date - today).days if c.next_due_date else 0,
'reminder_band': c.last_reminder_band or '',
})
# ---------------- Portal URLs (resolved server-side) ----------------
ICP = self.env['ir.config_parameter'].sudo()
base_url = ICP.get_param('web.base.url', '').rstrip('/')
portals = {
'client_portal_url': base_url + (ICP.get_param(
'fusion_repairs.client_portal_url', '/repair'
) or '/repair'),
'sales_rep_portal_url': base_url + '/my/repair/new',
}
return {
'stats': stats,
'urgency_breakdown': urgency_breakdown,
'source_breakdown': source_breakdown,
'recent': recent,
'upcoming': upcoming,
'portals': portals,
}