diff --git a/fusion_repairs/__manifest__.py b/fusion_repairs/__manifest__.py index 1e81bce9..b1533966 100644 --- a/fusion_repairs/__manifest__.py +++ b/fusion_repairs/__manifest__.py @@ -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', diff --git a/fusion_repairs/models/__init__.py b/fusion_repairs/models/__init__.py index 5a1daec6..a166e8b2 100644 --- a/fusion_repairs/models/__init__.py +++ b/fusion_repairs/models/__init__.py @@ -17,3 +17,4 @@ from . import technician_task from . import repair_order from . import sale_order from . import intake_service +from . import repair_dashboard diff --git a/fusion_repairs/models/repair_dashboard.py b/fusion_repairs/models/repair_dashboard.py new file mode 100644 index 00000000..c15b7407 --- /dev/null +++ b/fusion_repairs/models/repair_dashboard.py @@ -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, + } diff --git a/fusion_repairs/static/src/components/dashboard/dashboard.js b/fusion_repairs/static/src/components/dashboard/dashboard.js new file mode 100644 index 00000000..7b62aade --- /dev/null +++ b/fusion_repairs/static/src/components/dashboard/dashboard.js @@ -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); diff --git a/fusion_repairs/static/src/components/dashboard/dashboard.xml b/fusion_repairs/static/src/components/dashboard/dashboard.xml new file mode 100644 index 00000000..a59f2f02 --- /dev/null +++ b/fusion_repairs/static/src/components/dashboard/dashboard.xml @@ -0,0 +1,253 @@ + + + + +
+ + +
+ +
Loading dashboard...
+
+ + + + + +
+

Fusion Repairs

+

Service calls, technician dispatch, maintenance and self-service in one place.

+
+ + +
Quick Actions
+
+ + + + + + + +
+ + +
Right Now
+
+
+ Open Service Calls + + Not yet closed +
+
+ Urgent + Safety + + High-priority queue +
+
+ Awaiting Dispatch + + No technician task yet +
+
+ Needs Re-Quote + + Over variance threshold +
+
+ New This Month + + Across all intake surfaces +
+
+ Maintenance Due (30d) + + Contracts to ring this month +
+
+ + +
Self-Service Portals
+
+
+
+ Public Client Portal +
+
+ Share this link in your voicemail or on equipment QR stickers. + Clients can submit a service request 24/7 without logging in. +
+
+
+ + +
+
+ +
+
+ Sales Rep Portal +
+
+ Mobile-friendly intake form for sales reps in the field. + Sales reps with portal access only see repairs they submitted. +
+
+
+ + +
+
+
+ + +
Activity
+
+
+

Recent Service Calls

+ +
No service calls yet
+
+ +
+
+ + + + + + + + + · + +
+ + + +
+
+
+ +
+

Upcoming Maintenance

+ +
No upcoming maintenance
+
+ +
+
+ + + + + d + + + + + · + +
+ + + +
+
+
+
+ + +
Configuration
+
+ + + +
+ +
+ +
+ + +
+ + + diff --git a/fusion_repairs/static/src/scss/_fr_tokens.scss b/fusion_repairs/static/src/scss/_fr_tokens.scss new file mode 100644 index 00000000..a0b34409 --- /dev/null +++ b/fusion_repairs/static/src/scss/_fr_tokens.scss @@ -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}); diff --git a/fusion_repairs/static/src/scss/dashboard.scss b/fusion_repairs/static/src/scss/dashboard.scss new file mode 100644 index 00000000..76dd8197 --- /dev/null +++ b/fusion_repairs/static/src/scss/dashboard.scss @@ -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; } + } +} diff --git a/fusion_repairs/views/menus.xml b/fusion_repairs/views/menus.xml index aed3c908..0d370453 100644 --- a/fusion_repairs/views/menus.xml +++ b/fusion_repairs/views/menus.xml @@ -1,14 +1,20 @@ - + + + + + + + + Fusion Repairs + fusion_repairs.dashboard + + +