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>
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Repairs',
|
||||
'version': '19.0.1.0.4',
|
||||
'version': '19.0.1.0.5',
|
||||
'category': 'Inventory/Repairs',
|
||||
'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
|
||||
'description': """
|
||||
@@ -80,6 +80,7 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
|
||||
'views/service_catalog_views.xml',
|
||||
'views/repair_warranty_views.xml',
|
||||
'views/maintenance_contract_views.xml',
|
||||
'views/repair_dashboard_views.xml',
|
||||
'views/repair_order_views.xml',
|
||||
'views/sale_order_views.xml',
|
||||
'views/res_partner_views.xml',
|
||||
@@ -97,7 +98,11 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
# Phase 2+: history_sidebar.js, signature_pad.js, etc.
|
||||
# Tokens MUST load first - dashboard.scss references its variables.
|
||||
'fusion_repairs/static/src/scss/_fr_tokens.scss',
|
||||
'fusion_repairs/static/src/scss/dashboard.scss',
|
||||
'fusion_repairs/static/src/components/dashboard/dashboard.js',
|
||||
'fusion_repairs/static/src/components/dashboard/dashboard.xml',
|
||||
],
|
||||
'web.assets_frontend': [
|
||||
'fusion_repairs/static/src/scss/portal_repair_mobile.scss',
|
||||
|
||||
@@ -17,3 +17,4 @@ from . import technician_task
|
||||
from . import repair_order
|
||||
from . import sale_order
|
||||
from . import intake_service
|
||||
from . import repair_dashboard
|
||||
|
||||
131
fusion_repairs/models/repair_dashboard.py
Normal file
131
fusion_repairs/models/repair_dashboard.py
Normal file
@@ -0,0 +1,131 @@
|
||||
# -*- 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,
|
||||
}
|
||||
129
fusion_repairs/static/src/components/dashboard/dashboard.js
Normal file
129
fusion_repairs/static/src/components/dashboard/dashboard.js
Normal file
@@ -0,0 +1,129 @@
|
||||
/** @odoo-module **/
|
||||
// Fusion Repairs dashboard - OWL client action.
|
||||
// Uses standalone rpc() from @web/core/network/rpc per project rule #3
|
||||
// and useService("action") to navigate to backend act_window actions.
|
||||
|
||||
import { Component, useState, onWillStart } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
export class FusionRepairsDashboard extends Component {
|
||||
static template = "fusion_repairs.Dashboard";
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.action = useService("action");
|
||||
this.notification = useService("notification");
|
||||
this.state = useState({
|
||||
loading: true,
|
||||
stats: {},
|
||||
urgency_breakdown: [],
|
||||
source_breakdown: [],
|
||||
recent: [],
|
||||
upcoming: [],
|
||||
portals: {},
|
||||
});
|
||||
|
||||
onWillStart(async () => {
|
||||
await this._loadData();
|
||||
});
|
||||
}
|
||||
|
||||
async _loadData() {
|
||||
try {
|
||||
const data = await rpc("/web/dataset/call_kw", {
|
||||
model: "fusion.repair.dashboard",
|
||||
method: "get_dashboard_data",
|
||||
args: [],
|
||||
kwargs: {},
|
||||
});
|
||||
this.state.stats = data.stats || {};
|
||||
this.state.urgency_breakdown = data.urgency_breakdown || [];
|
||||
this.state.source_breakdown = data.source_breakdown || [];
|
||||
this.state.recent = data.recent || [];
|
||||
this.state.upcoming = data.upcoming || [];
|
||||
this.state.portals = data.portals || {};
|
||||
} catch (e) {
|
||||
this.notification.add(_t("Could not load dashboard data."), {
|
||||
type: "danger",
|
||||
});
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.state.loading = true;
|
||||
await this._loadData();
|
||||
}
|
||||
|
||||
openAction(xmlId, extraContext) {
|
||||
return this.action.doAction(xmlId, {
|
||||
additionalContext: extraContext || {},
|
||||
});
|
||||
}
|
||||
|
||||
openWizard() {
|
||||
return this.action.doAction("fusion_repairs.action_open_repair_intake_wizard");
|
||||
}
|
||||
|
||||
openRepair(repairId) {
|
||||
return this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: "repair.order",
|
||||
res_id: repairId,
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
openContract(contractId) {
|
||||
return this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: "fusion.repair.maintenance.contract",
|
||||
res_id: contractId,
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
openUrl(url) {
|
||||
if (url) {
|
||||
window.open(url, "_blank", "noopener");
|
||||
}
|
||||
}
|
||||
|
||||
async copyUrl(url) {
|
||||
if (!url) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
this.notification.add(_t("Copied to clipboard."), { type: "success" });
|
||||
} catch (e) {
|
||||
this.notification.add(_t("Could not copy URL. Select and copy manually."), {
|
||||
type: "warning",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
formatDate(value) {
|
||||
if (!value) return "";
|
||||
return value.slice(0, 10);
|
||||
}
|
||||
|
||||
urgencyPillClass(urgency) {
|
||||
if (urgency === "safety") return "fr-pill fr-pill-safety";
|
||||
if (urgency === "urgent") return "fr-pill fr-pill-urgent";
|
||||
return "fr-pill fr-pill-normal";
|
||||
}
|
||||
|
||||
urgencyLabel(urgency) {
|
||||
const map = { safety: "Safety", urgent: "Urgent", normal: "Normal" };
|
||||
return map[urgency] || "Normal";
|
||||
}
|
||||
}
|
||||
|
||||
registry
|
||||
.category("actions")
|
||||
.add("fusion_repairs.dashboard", FusionRepairsDashboard);
|
||||
253
fusion_repairs/static/src/components/dashboard/dashboard.xml
Normal file
253
fusion_repairs/static/src/components/dashboard/dashboard.xml
Normal file
@@ -0,0 +1,253 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_repairs.Dashboard">
|
||||
<div class="o_fusion_repairs_dashboard">
|
||||
|
||||
<!-- Loading state -->
|
||||
<div t-if="state.loading" class="fr-loading">
|
||||
<i class="fa fa-spinner fa-spin fa-2x"/>
|
||||
<div class="mt-3">Loading dashboard...</div>
|
||||
</div>
|
||||
|
||||
<!-- Loaded -->
|
||||
<t t-if="!state.loading">
|
||||
|
||||
<!-- Hero header -->
|
||||
<div class="fr-hero">
|
||||
<h1>Fusion Repairs</h1>
|
||||
<p>Service calls, technician dispatch, maintenance and self-service in one place.</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick actions -->
|
||||
<div class="fr-section-title">Quick Actions</div>
|
||||
<div class="fr-grid fr-grid-actions">
|
||||
<button class="fr-action fr-action-primary"
|
||||
t-on-click="() => this.openWizard()">
|
||||
<span class="fr-action-icon"><i class="fa fa-plus-circle"/></span>
|
||||
<span class="fr-action-text">
|
||||
<span class="fr-action-title">New Service Call</span>
|
||||
<span class="fr-action-sub">Open the guided intake wizard</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button class="fr-action"
|
||||
t-on-click="() => this.openAction('fusion_repairs.action_fusion_repair_dashboard')">
|
||||
<span class="fr-action-icon"><i class="fa fa-th-large"/></span>
|
||||
<span class="fr-action-text">
|
||||
<span class="fr-action-title">Service Calls</span>
|
||||
<span class="fr-action-sub">Kanban of every repair</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button class="fr-action"
|
||||
t-on-click="() => this.openAction('fusion_repairs.action_maintenance_contract')">
|
||||
<span class="fr-action-icon"><i class="fa fa-calendar-check-o"/></span>
|
||||
<span class="fr-action-text">
|
||||
<span class="fr-action-title">Maintenance Contracts</span>
|
||||
<span class="fr-action-sub">
|
||||
<t t-out="state.stats.maintenance_active_total"/> active
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button class="fr-action"
|
||||
t-on-click="() => this.openAction('fusion_repairs.action_repair_warranty_coverage')">
|
||||
<span class="fr-action-icon"><i class="fa fa-shield"/></span>
|
||||
<span class="fr-action-text">
|
||||
<span class="fr-action-title">Repair Warranties</span>
|
||||
<span class="fr-action-sub">Our 30 / 90-day coverage</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- KPI tiles -->
|
||||
<div class="fr-section-title">Right Now</div>
|
||||
<div class="fr-grid fr-grid-stats">
|
||||
<div class="fr-stat fr-stat-accent"
|
||||
t-on-click="() => this.openAction('fusion_repairs.action_fusion_repair_dashboard', {search_default_open: 1})"
|
||||
style="cursor:pointer;">
|
||||
<span class="fr-stat-label">Open Service Calls</span>
|
||||
<span class="fr-stat-value"><t t-out="state.stats.open_count or 0"/></span>
|
||||
<span class="fr-stat-sub">Not yet closed</span>
|
||||
</div>
|
||||
<div class="fr-stat fr-stat-danger"
|
||||
t-on-click="() => this.openAction('fusion_repairs.action_fusion_repair_dashboard', {search_default_safety: 1, search_default_urgent: 1, search_default_open: 1})"
|
||||
style="cursor:pointer;">
|
||||
<span class="fr-stat-label">Urgent + Safety</span>
|
||||
<span class="fr-stat-value"><t t-out="state.stats.urgent_count or 0"/></span>
|
||||
<span class="fr-stat-sub">High-priority queue</span>
|
||||
</div>
|
||||
<div class="fr-stat fr-stat-warning"
|
||||
t-on-click="() => this.openAction('fusion_repairs.action_fusion_repair_dashboard', {search_default_open: 1})"
|
||||
style="cursor:pointer;">
|
||||
<span class="fr-stat-label">Awaiting Dispatch</span>
|
||||
<span class="fr-stat-value"><t t-out="state.stats.awaiting_dispatch or 0"/></span>
|
||||
<span class="fr-stat-sub">No technician task yet</span>
|
||||
</div>
|
||||
<div class="fr-stat fr-stat-warning"
|
||||
t-on-click="() => this.openAction('fusion_repairs.action_fusion_repair_dashboard', {search_default_open: 1})"
|
||||
style="cursor:pointer;">
|
||||
<span class="fr-stat-label">Needs Re-Quote</span>
|
||||
<span class="fr-stat-value"><t t-out="state.stats.requires_requote or 0"/></span>
|
||||
<span class="fr-stat-sub">Over variance threshold</span>
|
||||
</div>
|
||||
<div class="fr-stat fr-stat-accent"
|
||||
t-on-click="() => this.openAction('fusion_repairs.action_fusion_repair_dashboard', {search_default_week: 1})"
|
||||
style="cursor:pointer;">
|
||||
<span class="fr-stat-label">New This Month</span>
|
||||
<span class="fr-stat-value"><t t-out="state.stats.new_this_month or 0"/></span>
|
||||
<span class="fr-stat-sub">Across all intake surfaces</span>
|
||||
</div>
|
||||
<div class="fr-stat fr-stat-success"
|
||||
t-on-click="() => this.openAction('fusion_repairs.action_maintenance_contract')"
|
||||
style="cursor:pointer;">
|
||||
<span class="fr-stat-label">Maintenance Due (30d)</span>
|
||||
<span class="fr-stat-value"><t t-out="state.stats.maintenance_due_30d or 0"/></span>
|
||||
<span class="fr-stat-sub">Contracts to ring this month</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Self-service portals to share -->
|
||||
<div class="fr-section-title">Self-Service Portals</div>
|
||||
<div class="fr-grid fr-grid-portals">
|
||||
<div class="fr-portal">
|
||||
<div class="fr-portal-head">
|
||||
<i class="fa fa-globe"/> Public Client Portal
|
||||
</div>
|
||||
<div class="fr-portal-sub">
|
||||
Share this link in your voicemail or on equipment QR stickers.
|
||||
Clients can submit a service request 24/7 without logging in.
|
||||
</div>
|
||||
<div class="fr-portal-url" t-out="state.portals.client_portal_url"/>
|
||||
<div class="fr-portal-actions">
|
||||
<button class="btn btn-primary btn-sm"
|
||||
t-on-click="() => this.openUrl(state.portals.client_portal_url)">
|
||||
<i class="fa fa-external-link me-1"/> Open
|
||||
</button>
|
||||
<button class="btn btn-light btn-sm"
|
||||
t-on-click="() => this.copyUrl(state.portals.client_portal_url)">
|
||||
<i class="fa fa-clipboard me-1"/> Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fr-portal">
|
||||
<div class="fr-portal-head">
|
||||
<i class="fa fa-mobile"/> Sales Rep Portal
|
||||
</div>
|
||||
<div class="fr-portal-sub">
|
||||
Mobile-friendly intake form for sales reps in the field.
|
||||
Sales reps with portal access only see repairs they submitted.
|
||||
</div>
|
||||
<div class="fr-portal-url" t-out="state.portals.sales_rep_portal_url"/>
|
||||
<div class="fr-portal-actions">
|
||||
<button class="btn btn-primary btn-sm"
|
||||
t-on-click="() => this.openUrl(state.portals.sales_rep_portal_url)">
|
||||
<i class="fa fa-external-link me-1"/> Open
|
||||
</button>
|
||||
<button class="btn btn-light btn-sm"
|
||||
t-on-click="() => this.copyUrl(state.portals.sales_rep_portal_url)">
|
||||
<i class="fa fa-clipboard me-1"/> Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent + Upcoming -->
|
||||
<div class="fr-section-title">Activity</div>
|
||||
<div class="fr-grid fr-grid-lists">
|
||||
<div class="fr-list">
|
||||
<h3><i class="fa fa-clock-o me-2"/>Recent Service Calls</h3>
|
||||
<t t-if="state.recent.length === 0">
|
||||
<div class="fr-list-empty">No service calls yet</div>
|
||||
</t>
|
||||
<t t-foreach="state.recent" t-as="r" t-key="r.id">
|
||||
<div class="fr-list-row" t-on-click="() => this.openRepair(r.id)">
|
||||
<div class="fr-list-main">
|
||||
<span class="fr-list-title">
|
||||
<t t-out="r.name"/>
|
||||
<span t-att-class="urgencyPillClass(r.urgency)" class="ms-2">
|
||||
<t t-out="urgencyLabel(r.urgency)"/>
|
||||
</span>
|
||||
</span>
|
||||
<span class="fr-list-sub">
|
||||
<t t-out="r.partner_name"/>
|
||||
<t t-if="r.category"> · <t t-out="r.category"/></t>
|
||||
</span>
|
||||
</div>
|
||||
<span class="fr-list-meta">
|
||||
<span class="fr-pill fr-pill-state"><t t-out="r.state_label"/></span>
|
||||
</span>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<div class="fr-list">
|
||||
<h3><i class="fa fa-calendar me-2"/>Upcoming Maintenance</h3>
|
||||
<t t-if="state.upcoming.length === 0">
|
||||
<div class="fr-list-empty">No upcoming maintenance</div>
|
||||
</t>
|
||||
<t t-foreach="state.upcoming" t-as="c" t-key="c.id">
|
||||
<div class="fr-list-row" t-on-click="() => this.openContract(c.id)">
|
||||
<div class="fr-list-main">
|
||||
<span class="fr-list-title">
|
||||
<t t-out="c.name"/>
|
||||
<t t-if="c.days_until !== undefined and c.days_until <= 7">
|
||||
<span class="fr-pill fr-pill-urgent ms-2">
|
||||
<t t-out="c.days_until"/>d
|
||||
</span>
|
||||
</t>
|
||||
</span>
|
||||
<span class="fr-list-sub">
|
||||
<t t-out="c.partner_name"/> · <t t-out="c.product_name"/>
|
||||
</span>
|
||||
</div>
|
||||
<span class="fr-list-meta">
|
||||
<t t-out="formatDate(c.next_due_date)"/>
|
||||
</span>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuration -->
|
||||
<div class="fr-section-title">Configuration</div>
|
||||
<div class="fr-grid fr-grid-config">
|
||||
<button class="fr-action"
|
||||
t-on-click="() => this.openAction('fusion_repairs.action_repair_product_category')">
|
||||
<span class="fr-action-icon"><i class="fa fa-tags"/></span>
|
||||
<span class="fr-action-text">
|
||||
<span class="fr-action-title">Equipment Categories</span>
|
||||
<span class="fr-action-sub">Hospital beds, stairlifts...</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="fr-action"
|
||||
t-on-click="() => this.openAction('fusion_repairs.action_repair_intake_template')">
|
||||
<span class="fr-action-icon"><i class="fa fa-question-circle"/></span>
|
||||
<span class="fr-action-text">
|
||||
<span class="fr-action-title">Intake Templates</span>
|
||||
<span class="fr-action-sub">Question banks per category</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="fr-action"
|
||||
t-on-click="() => this.openAction('fusion_repairs.action_repair_service_catalog')">
|
||||
<span class="fr-action-icon"><i class="fa fa-wrench"/></span>
|
||||
<span class="fr-action-text">
|
||||
<span class="fr-action-title">Service Catalogue</span>
|
||||
<span class="fr-action-sub">Auto-match + estimated cost</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-end">
|
||||
<button class="btn btn-sm btn-light" t-on-click="() => this.refresh()">
|
||||
<i class="fa fa-refresh me-1"/> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
63
fusion_repairs/static/src/scss/_fr_tokens.scss
Normal file
63
fusion_repairs/static/src/scss/_fr_tokens.scss
Normal file
@@ -0,0 +1,63 @@
|
||||
// Fusion Repairs design tokens.
|
||||
// Compile-time branching on $o-webclient-color-scheme makes the SAME SCSS file
|
||||
// produce different values for the light bundle (web.assets_backend) and the
|
||||
// dark bundle (web.assets_web_dark). Each token is wrapped in a CSS custom
|
||||
// property so runtime overrides are still possible if ever needed.
|
||||
//
|
||||
// IMPORTANT: do NOT @import this file - per project Odoo 19 rule, register
|
||||
// it as a separate entry in web.assets_backend BEFORE dashboard.scss so the
|
||||
// variables are in scope when the dashboard file is compiled.
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
// Default (light) palette.
|
||||
$_fr-page-hex: #f3f4f6;
|
||||
$_fr-card-hex: #ffffff;
|
||||
$_fr-card-elevated-hex: #ffffff;
|
||||
$_fr-border-hex: #d8dadd;
|
||||
$_fr-border-soft-hex: #e5e7eb;
|
||||
$_fr-text-hex: #1f2937;
|
||||
$_fr-muted-hex: #6b7280;
|
||||
$_fr-accent-hex: #2b6cb0;
|
||||
$_fr-success-hex: #16a34a;
|
||||
$_fr-warning-hex: #d97706;
|
||||
$_fr-danger-hex: #dc2626;
|
||||
$_fr-info-bg-hex: #eff6ff;
|
||||
$_fr-success-bg-hex: #ecfdf5;
|
||||
$_fr-warning-bg-hex: #fffbeb;
|
||||
$_fr-danger-bg-hex: #fef2f2;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_fr-page-hex: #14181d !global;
|
||||
$_fr-card-hex: #1f242b !global;
|
||||
$_fr-card-elevated-hex: #262c34 !global;
|
||||
$_fr-border-hex: #2d333b !global;
|
||||
$_fr-border-soft-hex: #242a31 !global;
|
||||
$_fr-text-hex: #e6e8eb !global;
|
||||
$_fr-muted-hex: #9aa3ad !global;
|
||||
$_fr-accent-hex: #60a5fa !global;
|
||||
$_fr-success-hex: #34d399 !global;
|
||||
$_fr-warning-hex: #fbbf24 !global;
|
||||
$_fr-danger-hex: #f87171 !global;
|
||||
$_fr-info-bg-hex: #1e3a5f !global;
|
||||
$_fr-success-bg-hex: #14342a !global;
|
||||
$_fr-warning-bg-hex: #3b2f15 !global;
|
||||
$_fr-danger-bg-hex: #3c1d1d !global;
|
||||
}
|
||||
|
||||
// CSS-variable-wrapped tokens. Use these everywhere in dashboard.scss.
|
||||
$fr-page: var(--fr-page-bg, #{$_fr-page-hex});
|
||||
$fr-card: var(--fr-card-bg, #{$_fr-card-hex});
|
||||
$fr-card-elevated: var(--fr-card-elevated-bg, #{$_fr-card-elevated-hex});
|
||||
$fr-border: var(--fr-border, #{$_fr-border-hex});
|
||||
$fr-border-soft: var(--fr-border-soft, #{$_fr-border-soft-hex});
|
||||
$fr-text: var(--fr-text, #{$_fr-text-hex});
|
||||
$fr-muted: var(--fr-muted, #{$_fr-muted-hex});
|
||||
$fr-accent: var(--fr-accent, #{$_fr-accent-hex});
|
||||
$fr-success: var(--fr-success, #{$_fr-success-hex});
|
||||
$fr-warning: var(--fr-warning, #{$_fr-warning-hex});
|
||||
$fr-danger: var(--fr-danger, #{$_fr-danger-hex});
|
||||
$fr-info-bg: var(--fr-info-bg, #{$_fr-info-bg-hex});
|
||||
$fr-success-bg: var(--fr-success-bg, #{$_fr-success-bg-hex});
|
||||
$fr-warning-bg: var(--fr-warning-bg, #{$_fr-warning-bg-hex});
|
||||
$fr-danger-bg: var(--fr-danger-bg, #{$_fr-danger-bg-hex});
|
||||
320
fusion_repairs/static/src/scss/dashboard.scss
Normal file
320
fusion_repairs/static/src/scss/dashboard.scss
Normal file
@@ -0,0 +1,320 @@
|
||||
// Fusion Repairs dashboard.
|
||||
// Uses tokens from _fr_tokens.scss (registered first in the bundle).
|
||||
// Three-layer contrast: page (grayest) -> section -> card (brightest).
|
||||
|
||||
.o_fusion_repairs_dashboard {
|
||||
background-color: $fr-page;
|
||||
color: $fr-text;
|
||||
min-height: calc(100vh - 46px);
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
|
||||
.fr-hero {
|
||||
background: linear-gradient(135deg, $fr-accent 0%, color-mix(in srgb, $fr-accent 60%, $fr-success) 100%);
|
||||
color: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 28px 32px;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
h1 {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: #ffffff;
|
||||
}
|
||||
p {
|
||||
opacity: 0.9;
|
||||
margin: 0;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.fr-section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: $fr-muted;
|
||||
margin: 24px 0 12px 0;
|
||||
}
|
||||
|
||||
.fr-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
&.fr-grid-stats {
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
&.fr-grid-actions {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
&.fr-grid-portals {
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
&.fr-grid-config {
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
&.fr-grid-lists {
|
||||
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.fr-stat {
|
||||
background-color: $fr-card;
|
||||
border: 1px solid $fr-border;
|
||||
border-radius: 10px;
|
||||
padding: 18px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.fr-stat-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: $fr-muted;
|
||||
}
|
||||
.fr-stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
color: $fr-text;
|
||||
}
|
||||
.fr-stat-sub {
|
||||
font-size: 12px;
|
||||
color: $fr-muted;
|
||||
}
|
||||
|
||||
&.fr-stat-accent .fr-stat-value { color: $fr-accent; }
|
||||
&.fr-stat-warning .fr-stat-value { color: $fr-warning; }
|
||||
&.fr-stat-danger .fr-stat-value { color: $fr-danger; }
|
||||
&.fr-stat-success .fr-stat-value { color: $fr-success; }
|
||||
}
|
||||
|
||||
.fr-action {
|
||||
background-color: $fr-card;
|
||||
border: 1px solid $fr-border;
|
||||
border-radius: 10px;
|
||||
padding: 18px 20px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
color: $fr-text;
|
||||
font: inherit;
|
||||
transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: $fr-accent;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.fr-action-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
min-width: 44px;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: $fr-info-bg;
|
||||
color: $fr-accent;
|
||||
font-size: 18px;
|
||||
}
|
||||
.fr-action-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.fr-action-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: $fr-text;
|
||||
}
|
||||
.fr-action-sub {
|
||||
font-size: 12px;
|
||||
color: $fr-muted;
|
||||
}
|
||||
|
||||
&.fr-action-primary {
|
||||
background: linear-gradient(135deg, $fr-accent 0%, color-mix(in srgb, $fr-accent 65%, $fr-success) 100%);
|
||||
border-color: transparent;
|
||||
color: #ffffff;
|
||||
|
||||
.fr-action-icon {
|
||||
background-color: rgba(255, 255, 255, 0.18);
|
||||
color: #ffffff;
|
||||
}
|
||||
.fr-action-title,
|
||||
.fr-action-sub {
|
||||
color: #ffffff;
|
||||
}
|
||||
.fr-action-sub { opacity: 0.85; }
|
||||
|
||||
&:hover { box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18); }
|
||||
}
|
||||
}
|
||||
|
||||
.fr-portal {
|
||||
background-color: $fr-card;
|
||||
border: 1px solid $fr-border;
|
||||
border-radius: 10px;
|
||||
padding: 18px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
.fr-portal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
|
||||
i {
|
||||
color: $fr-accent;
|
||||
}
|
||||
}
|
||||
.fr-portal-sub {
|
||||
font-size: 12px;
|
||||
color: $fr-muted;
|
||||
}
|
||||
.fr-portal-url {
|
||||
background-color: $fr-info-bg;
|
||||
color: $fr-text;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.fr-portal-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
|
||||
.btn {
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fr-list {
|
||||
background-color: $fr-card;
|
||||
border: 1px solid $fr-border;
|
||||
border-radius: 10px;
|
||||
padding: 18px 20px;
|
||||
|
||||
h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 12px 0;
|
||||
color: $fr-text;
|
||||
}
|
||||
.fr-list-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid $fr-border-soft;
|
||||
cursor: pointer;
|
||||
gap: 12px;
|
||||
|
||||
&:first-of-type {
|
||||
border-top: none;
|
||||
}
|
||||
&:hover {
|
||||
background-color: $fr-info-bg;
|
||||
margin: 0 -8px;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.fr-list-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.fr-list-title {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: $fr-text;
|
||||
}
|
||||
.fr-list-sub {
|
||||
font-size: 12px;
|
||||
color: $fr-muted;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.fr-list-meta {
|
||||
font-size: 11px;
|
||||
color: $fr-muted;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
.fr-list-empty {
|
||||
text-align: center;
|
||||
color: $fr-muted;
|
||||
font-size: 13px;
|
||||
padding: 24px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fr-pill {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
|
||||
&.fr-pill-normal {
|
||||
background-color: $fr-border-soft;
|
||||
color: $fr-text;
|
||||
}
|
||||
&.fr-pill-urgent {
|
||||
background-color: $fr-warning-bg;
|
||||
color: $fr-warning;
|
||||
}
|
||||
&.fr-pill-safety {
|
||||
background-color: $fr-danger-bg;
|
||||
color: $fr-danger;
|
||||
}
|
||||
&.fr-pill-state {
|
||||
background-color: $fr-info-bg;
|
||||
color: $fr-accent;
|
||||
}
|
||||
}
|
||||
|
||||
.fr-loading {
|
||||
text-align: center;
|
||||
padding: 60px 0;
|
||||
color: $fr-muted;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
padding: 16px;
|
||||
|
||||
.fr-hero { padding: 20px 22px; }
|
||||
.fr-hero h1 { font-size: 22px; }
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Top-level app menu - lands directly on the dashboard kanban -->
|
||||
<!-- Top-level app menu - lands on the OWL dashboard with quick-actions and KPIs -->
|
||||
<menuitem id="menu_fusion_repairs_root"
|
||||
name="Fusion Repairs"
|
||||
sequence="48"
|
||||
web_icon="fusion_repairs,static/description/icon.png"
|
||||
action="action_fusion_repair_dashboard"
|
||||
action="action_fusion_repairs_home_dashboard"
|
||||
groups="fusion_repairs.group_fusion_repairs_user"/>
|
||||
|
||||
<menuitem id="menu_fusion_repairs_home"
|
||||
name="Dashboard"
|
||||
parent="menu_fusion_repairs_root"
|
||||
action="action_fusion_repairs_home_dashboard"
|
||||
sequence="5"/>
|
||||
|
||||
<menuitem id="menu_fusion_repairs_dashboard"
|
||||
name="Service Calls"
|
||||
parent="menu_fusion_repairs_root"
|
||||
|
||||
10
fusion_repairs/views/repair_dashboard_views.xml
Normal file
10
fusion_repairs/views/repair_dashboard_views.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Client action that mounts the OWL FusionRepairsDashboard component. -->
|
||||
<record id="action_fusion_repairs_home_dashboard" model="ir.actions.client">
|
||||
<field name="name">Fusion Repairs</field>
|
||||
<field name="tag">fusion_repairs.dashboard</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user