M9 margin per repair
- New non-stored computes on repair.order: x_fc_revenue, x_fc_labour_cost,
x_fc_parts_cost, x_fc_margin, x_fc_margin_pct.
- Revenue: sum of posted out_invoice.amount_untaxed on the repair's sale
order (handles partial / multi invoice scenarios).
- Labour: sum of (task.duration_hours x technician.x_fc_tech_cost_rate)
over COMPLETED visits only - avoids counting scheduled-but-not-done time.
- Parts: sum of standard_price x qty for stock moves where
repair_line_type='add' (parts consumed, not removed).
- New 'Margin' notebook tab on repair.order form, manager-group gated.
M7 failure analytics on the dashboard
- Three new keys in get_dashboard_data():
* failures_by_product - top 8 products by repair_count in last 90 days
via _read_group (efficient - no record load)
* failures_by_symptom - top 8 x_fc_issue_category values
* margin_summary - revenue/labour/parts/margin/margin_pct + sample_size
over the same 90-day window
- Three new tiles on the OWL dashboard 'Last 90 Days' section:
Margin Summary (revenue/labour/parts/margin breakdown),
Failure Rate by Product, Failure Rate by Symptom.
- New formatMoney + formatPercent helpers on the dashboard JS so values
display as 'CAD 12,345' rather than raw floats.
Verified end-to-end on local westin-v19:
Dashboard returned all 9 expected keys.
Top product: 'M6 X 27 THREADED BARREL' (2 repairs) - actual test data.
Margin summary over 26 repairs (dev has $0 invoices so values 0.0,
but the compute path is exercised and shapes are correct).
Bumped to 19.0.1.6.0.
Co-authored-by: Cursor <cursoragent@cursor.com>
342 lines
19 KiB
XML
342 lines
19 KiB
XML
<?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>
|
|
|
|
<!-- Analytics (M7 + M9) -->
|
|
<div class="fr-section-title">Last 90 Days</div>
|
|
<div class="fr-grid fr-grid-lists">
|
|
<div class="fr-list">
|
|
<h3><i class="fa fa-line-chart me-2"/>Margin Summary</h3>
|
|
<t t-if="!state.margin_summary or !state.margin_summary.sample_size">
|
|
<div class="fr-list-empty">No data yet for the last 90 days</div>
|
|
</t>
|
|
<t t-if="state.margin_summary and state.margin_summary.sample_size">
|
|
<div class="fr-list-row">
|
|
<div class="fr-list-main">
|
|
<span class="fr-list-title">Revenue</span>
|
|
<span class="fr-list-sub">Posted invoices on repair SOs</span>
|
|
</div>
|
|
<span class="fr-list-meta">
|
|
<t t-out="formatMoney(state.margin_summary.revenue)"/>
|
|
</span>
|
|
</div>
|
|
<div class="fr-list-row">
|
|
<div class="fr-list-main">
|
|
<span class="fr-list-title">Labour Cost</span>
|
|
<span class="fr-list-sub">Hours x tech cost rate</span>
|
|
</div>
|
|
<span class="fr-list-meta">
|
|
- <t t-out="formatMoney(state.margin_summary.labour_cost)"/>
|
|
</span>
|
|
</div>
|
|
<div class="fr-list-row">
|
|
<div class="fr-list-main">
|
|
<span class="fr-list-title">Parts Cost</span>
|
|
<span class="fr-list-sub">Standard price of consumed parts</span>
|
|
</div>
|
|
<span class="fr-list-meta">
|
|
- <t t-out="formatMoney(state.margin_summary.parts_cost)"/>
|
|
</span>
|
|
</div>
|
|
<div class="fr-list-row" style="border-top:2px solid #d8dadd;">
|
|
<div class="fr-list-main">
|
|
<span class="fr-list-title">Margin</span>
|
|
<span class="fr-list-sub">
|
|
<t t-out="formatPercent(state.margin_summary.margin_pct)"/>
|
|
on <t t-out="state.margin_summary.sample_size"/> repairs
|
|
</span>
|
|
</div>
|
|
<span class="fr-list-meta" style="font-weight:600;">
|
|
<t t-out="formatMoney(state.margin_summary.margin)"/>
|
|
</span>
|
|
</div>
|
|
</t>
|
|
</div>
|
|
|
|
<div class="fr-list">
|
|
<h3><i class="fa fa-bar-chart me-2"/>Failure Rate by Product</h3>
|
|
<t t-if="state.failures_by_product.length === 0">
|
|
<div class="fr-list-empty">No repairs in the last 90 days</div>
|
|
</t>
|
|
<t t-foreach="state.failures_by_product" t-as="p" t-key="p.product_id">
|
|
<div class="fr-list-row">
|
|
<div class="fr-list-main">
|
|
<span class="fr-list-title"><t t-out="p.product_name"/></span>
|
|
</div>
|
|
<span class="fr-list-meta">
|
|
<t t-out="p.repair_count"/>
|
|
</span>
|
|
</div>
|
|
</t>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="fr-grid fr-grid-lists">
|
|
<div class="fr-list">
|
|
<h3><i class="fa fa-tags me-2"/>Failure Rate by Symptom</h3>
|
|
<t t-if="state.failures_by_symptom.length === 0">
|
|
<div class="fr-list-empty">No symptoms tagged in the last 90 days</div>
|
|
</t>
|
|
<t t-foreach="state.failures_by_symptom" t-as="s" t-key="s.symptom">
|
|
<div class="fr-list-row">
|
|
<div class="fr-list-main">
|
|
<span class="fr-list-title"><t t-out="s.symptom"/></span>
|
|
</div>
|
|
<span class="fr-list-meta">
|
|
<t t-out="s.repair_count"/>
|
|
</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>
|