Compare commits
10 Commits
d512dfccf0
...
f84c22c743
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f84c22c743 | ||
|
|
46d19fd581 | ||
|
|
56ca82c611 | ||
|
|
d457b86eaa | ||
|
|
92e8a18fcb | ||
|
|
245e551c68 | ||
|
|
a022eaaabe | ||
|
|
0e6bb7b676 | ||
|
|
d5d410f6d0 | ||
|
|
41141a75e8 |
@@ -7,6 +7,7 @@ import logging
|
||||
|
||||
from . import models
|
||||
from . import wizard
|
||||
from . import controllers
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Claims',
|
||||
'version': '19.0.9.2.0',
|
||||
'version': '19.0.9.4.0',
|
||||
'category': 'Sales',
|
||||
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
|
||||
'description': """
|
||||
@@ -103,6 +103,8 @@
|
||||
'wizard/status_change_reason_wizard_views.xml',
|
||||
'views/res_company_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/service_rate_views.xml',
|
||||
'views/service_booking_action.xml',
|
||||
'views/sale_order_views.xml',
|
||||
'views/account_move_views.xml',
|
||||
'views/account_journal_views.xml',
|
||||
@@ -183,12 +185,20 @@
|
||||
# Dashboard OWL countdown widget
|
||||
'fusion_claims/static/src/js/fc_posting_countdown.js',
|
||||
'fusion_claims/static/src/xml/fc_posting_countdown.xml',
|
||||
# Service Booking wizard (client action): tokens MUST load before
|
||||
# the component scss so the --sb-* vars resolve.
|
||||
'fusion_claims/static/src/scss/_service_booking_tokens.scss',
|
||||
'fusion_claims/static/src/scss/service_booking.scss',
|
||||
'fusion_claims/static/src/js/service_booking/service_booking.js',
|
||||
'fusion_claims/static/src/xml/service_booking.xml',
|
||||
],
|
||||
'web.assets_web_dark': [
|
||||
# Dark bundle recompiles 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',
|
||||
'fusion_claims/static/src/scss/_service_booking_tokens.scss',
|
||||
'fusion_claims/static/src/scss/service_booking.scss',
|
||||
],
|
||||
},
|
||||
'images': ['static/description/icon.png'],
|
||||
|
||||
1
fusion_claims/controllers/__init__.py
Normal file
1
fusion_claims/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import service_booking
|
||||
38
fusion_claims/controllers/service_booking.py
Normal file
38
fusion_claims/controllers/service_booking.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class ServiceBookingController(http.Controller):
|
||||
|
||||
@http.route('/fusion_claims/service_booking/refdata', type='jsonrpc', auth='user')
|
||||
def refdata(self, **kw):
|
||||
env = request.env
|
||||
Users = env['res.users']
|
||||
techs = Users.search([('x_fc_is_field_staff', '=', True)]) \
|
||||
if 'x_fc_is_field_staff' in Users._fields else Users.search([])
|
||||
Rate = env['fusion.service.rate']
|
||||
rates = Rate.search([('rate_kind', '=', 'callout'), ('active', '=', True)])
|
||||
per_km = Rate.get_rate('per_km')
|
||||
|
||||
def labour(code):
|
||||
r = Rate.get_rate(code)
|
||||
return r.price if r else 0.0
|
||||
|
||||
return {
|
||||
'technicians': [{'id': t.id, 'name': t.name} for t in techs],
|
||||
'callout_rates': [{
|
||||
'code': r.code, 'category': r.category, 'timing': r.timing,
|
||||
'name': r.name, 'price': r.price, 'adds_per_km': r.adds_per_km,
|
||||
} for r in rates],
|
||||
'per_km': per_km.price if per_km else 0.70,
|
||||
'labour': {'onsite': labour('labour_onsite'), 'inshop': labour('labour_inshop'),
|
||||
'lift': labour('labour_lift')},
|
||||
}
|
||||
|
||||
@http.route('/fusion_claims/service_booking/submit', type='jsonrpc', auth='user')
|
||||
def submit(self, payload=None, **kw):
|
||||
try:
|
||||
return request.env['fusion.technician.task'].action_book_from_wizard(payload or {})
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
@@ -338,6 +338,11 @@ class SaleOrder(models.Model):
|
||||
help='Type of sale for billing purposes. This field determines the workflow and billing rules.',
|
||||
)
|
||||
|
||||
x_fc_is_service_repair = fields.Boolean(
|
||||
string='Service Repair', copy=False,
|
||||
help='Auto-created from the technician service booking wizard.',
|
||||
)
|
||||
|
||||
x_fc_sale_type_locked = fields.Boolean(
|
||||
string='Sale Type Locked',
|
||||
compute='_compute_sale_type_locked',
|
||||
|
||||
@@ -9,7 +9,7 @@ features to the base fusion.technician.task model.
|
||||
"""
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from odoo.exceptions import UserError
|
||||
from markupsafe import Markup
|
||||
import logging
|
||||
|
||||
@@ -72,6 +72,15 @@ class FusionTechnicianTaskClaims(models.Model):
|
||||
default=False,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SERVICE BOOKING FIELDS
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
x_fc_service_call_type = fields.Char(
|
||||
string='Service Call Type',
|
||||
help='Rate code resolved by the booking wizard (e.g. callout_standard_rush).',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ONCHANGES
|
||||
# ------------------------------------------------------------------
|
||||
@@ -104,15 +113,9 @@ class FusionTechnicianTaskClaims(models.Model):
|
||||
|
||||
@api.constrains('sale_order_id', 'purchase_order_id')
|
||||
def _check_order_link(self):
|
||||
for task in self:
|
||||
if task.x_fc_sync_source:
|
||||
continue
|
||||
if task.task_type == 'ltc_visit':
|
||||
continue
|
||||
if not task.sale_order_id and not task.purchase_order_id:
|
||||
raise ValidationError(_(
|
||||
"A task must be linked to either a Sale Order (Case) or a Purchase Order."
|
||||
))
|
||||
# Relaxed 2026-06: service bookings auto-create their SO, and in-shop /
|
||||
# walk-in tasks may legitimately have none. No order link is required anymore.
|
||||
return
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# HOOK OVERRIDES
|
||||
@@ -395,6 +398,166 @@ class FusionTechnicianTaskClaims(models.Model):
|
||||
order.name, e,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SERVICE BOOKING HELPERS
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@api.model
|
||||
def _resolve_service_lines(self, category, timing, in_shop, distance_km):
|
||||
"""Return a list of sale.order.line vals dicts for a service booking,
|
||||
priced from fusion.service.rate. Empty when in-shop (labour-only, added later)."""
|
||||
Rate = self.env['fusion.service.rate']
|
||||
lines = []
|
||||
callout = Rate.get_callout(category, timing, in_shop=in_shop)
|
||||
if not callout:
|
||||
return lines
|
||||
lines.append({
|
||||
'product_id': callout.product_id.id,
|
||||
'name': callout.name,
|
||||
'product_uom_qty': 1.0,
|
||||
'price_unit': callout.price,
|
||||
'name_is_km': False,
|
||||
})
|
||||
if callout.adds_per_km and distance_km:
|
||||
per_km = Rate.get_rate('per_km')
|
||||
if per_km:
|
||||
lines.append({
|
||||
'product_id': per_km.product_id.id,
|
||||
'name': '%s — %.1f km \xd7 2-way' % (per_km.name, distance_km),
|
||||
'product_uom_qty': round(distance_km * 2.0, 1),
|
||||
'price_unit': per_km.price,
|
||||
'name_is_km': True,
|
||||
})
|
||||
return lines
|
||||
|
||||
@api.model
|
||||
def _build_service_so(self, partner, category, timing, in_shop, distance_km):
|
||||
"""Create a draft repair sale.order with the resolved call-out (+per-km) lines.
|
||||
|
||||
Repair-SO identity is the x_fc_is_service_repair boolean (no crm.tag: fusion_claims
|
||||
has no crm dependency). x_fc_sale_type is intentionally left blank — a service repair
|
||||
is not one of the ADP/ODSP funder workflows, and the draft is editable afterwards.
|
||||
"""
|
||||
line_vals = self._resolve_service_lines(category, timing, in_shop, distance_km)
|
||||
order_lines = [(0, 0, {k: v for k, v in l.items() if k != 'name_is_km'}) for l in line_vals]
|
||||
so_vals = {
|
||||
'partner_id': partner.id,
|
||||
'x_fc_is_service_repair': True,
|
||||
'order_line': order_lines,
|
||||
}
|
||||
return self.env['sale.order'].create(so_vals)
|
||||
|
||||
def _service_travel_origin(self):
|
||||
"""Return (lat, lng) of the technician's day-start location, to be used
|
||||
as the ORIGIN for the per-km travel calculation. NEVER returns the job's
|
||||
own address (that would give origin == destination == 0 km).
|
||||
|
||||
Fallback chain (all read-only — no geocoding API calls here):
|
||||
1. The technician's personal start address cached coords
|
||||
(res.users.partner_id.x_fc_start_address_lat/_lng — populated when
|
||||
the start address is saved, see fusion_tasks/models/res_partner.py).
|
||||
2. The company HQ start address cached coords, keyed off the
|
||||
``fusion_claims.technician_start_address`` ICP and cached by
|
||||
fusion_tasks under ``fusion_tasks.hq_coords:<address>``.
|
||||
3. (0.0, 0.0) — the correct graceful fallback. _calculate_travel_time
|
||||
guards on a falsy origin and simply returns False (→ no per-km line).
|
||||
|
||||
Geocoding is deliberately NOT performed here: a freshly typed new-client
|
||||
job address usually has no geocoded destination anyway, so distance is
|
||||
expected to be 0 in v1. We only avoid passing a WRONG origin.
|
||||
"""
|
||||
self.ensure_one()
|
||||
tech = self.technician_id
|
||||
if tech:
|
||||
partner = tech.partner_id
|
||||
if partner and partner.x_fc_start_address_lat and partner.x_fc_start_address_lng:
|
||||
return partner.x_fc_start_address_lat, partner.x_fc_start_address_lng
|
||||
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
hq_addr = (ICP.get_param('fusion_claims.technician_start_address', '') or '').strip()
|
||||
if hq_addr:
|
||||
cached = ICP.get_param('fusion_tasks.hq_coords:%s' % hq_addr, '')
|
||||
if cached and ',' in cached:
|
||||
try:
|
||||
lat_s, lng_s = cached.split(',', 1)
|
||||
return float(lat_s), float(lng_s)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return 0.0, 0.0
|
||||
|
||||
@api.model
|
||||
def action_book_from_wizard(self, payload):
|
||||
"""Single entry point for the OWL booking wizard:
|
||||
resolve/create contact -> create task -> compute distance -> build SO -> link.
|
||||
Returns {'task_id', 'order_id'}."""
|
||||
Partner = self.env['res.partner']
|
||||
cust = payload.get('customer') or {}
|
||||
|
||||
# 1. contact: new -> find-or-create (match email then phone); existing -> chosen partner
|
||||
if payload.get('cust_mode') == 'new':
|
||||
partner = False
|
||||
email = (cust.get('email') or '').strip()
|
||||
phone = (cust.get('phone') or '').strip()
|
||||
if email:
|
||||
partner = Partner.search([('email', '=ilike', email)], limit=1)
|
||||
if not partner and phone:
|
||||
partner = Partner.search([('phone', '=', phone)], limit=1)
|
||||
if not partner:
|
||||
partner = Partner.create({
|
||||
'name': cust.get('name') or 'Walk-in',
|
||||
'phone': phone or False, 'email': email or False,
|
||||
'street': cust.get('street') or False, 'city': cust.get('city') or False,
|
||||
})
|
||||
else:
|
||||
partner = Partner.browse(int(payload['partner_id'])) if payload.get('partner_id') else Partner
|
||||
|
||||
category = payload.get('category', 'standard')
|
||||
timing = payload.get('timing', 'normal')
|
||||
in_shop = bool(payload.get('in_shop'))
|
||||
|
||||
# technician_id is REQUIRED on a task
|
||||
technician_id = payload.get('technician_id')
|
||||
if not technician_id:
|
||||
raise UserError(_("Please choose a technician for this service booking."))
|
||||
technician_id = int(technician_id)
|
||||
|
||||
# 2. task
|
||||
dur = float(payload.get('duration_hr') or 1.0)
|
||||
t_start = float(payload.get('time_start') or 9.0)
|
||||
task_vals = {
|
||||
'task_type': 'repair',
|
||||
'technician_id': technician_id,
|
||||
'scheduled_date': payload.get('date'),
|
||||
'time_start': t_start,
|
||||
'time_end': t_start + dur,
|
||||
'duration_hours': dur,
|
||||
'is_in_store': in_shop,
|
||||
'x_fc_service_call_type': '%s_%s' % (category, timing),
|
||||
'description': payload.get('description') or payload.get('issue') or _('Service booking'),
|
||||
}
|
||||
if partner:
|
||||
task_vals['partner_id'] = partner.id
|
||||
task = self.create(task_vals)
|
||||
|
||||
# 3. per-km distance: only when the rate adds it AND we have a real origin + a
|
||||
# geocoded job destination. Origin is the technician's start, never the job.
|
||||
distance_km = 0.0
|
||||
callout = self.env['fusion.service.rate'].get_callout(category, timing, in_shop=in_shop)
|
||||
if callout and callout.adds_per_km and not in_shop and task.address_lat and task.address_lng:
|
||||
origin_lat, origin_lng = task._service_travel_origin()
|
||||
if origin_lat and origin_lng:
|
||||
try:
|
||||
task._calculate_travel_time(origin_lat, origin_lng) # sets travel_distance_km
|
||||
distance_km = task.travel_distance_km or 0.0
|
||||
except Exception:
|
||||
distance_km = 0.0
|
||||
|
||||
# 4. draft repair SO + link back to the task
|
||||
order = self._build_service_so(partner, category, timing, in_shop, distance_km) if partner else False
|
||||
if order:
|
||||
task.sale_order_id = order.id
|
||||
return {'task_id': task.id, 'order_id': order.id if order else False}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# VIEW ACTIONS
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -63,4 +63,6 @@ access_fusion_page11_sign_request_manager,fusion.page11.sign.request.manager,mod
|
||||
access_fusion_page11_sign_request_public,fusion.page11.sign.request.public,model_fusion_page11_sign_request,base.group_public,1,0,0,0
|
||||
access_fusion_send_page11_wizard_user,fusion_claims.send.page11.wizard.user,model_fusion_claims_send_page11_wizard,sales_team.group_sale_salesman,1,1,1,1
|
||||
access_fusion_send_page11_wizard_manager,fusion_claims.send.page11.wizard.manager,model_fusion_claims_send_page11_wizard,sales_team.group_sale_manager,1,1,1,1
|
||||
access_fusion_adp_import_wizard_user,fusion_claims.adp.import.wizard.user,model_fusion_claims_adp_import_wizard,account.group_account_invoice,1,1,1,1
|
||||
access_fusion_adp_import_wizard_user,fusion_claims.adp.import.wizard.user,model_fusion_claims_adp_import_wizard,account.group_account_invoice,1,1,1,1
|
||||
access_fusion_service_rate_user,fusion.service.rate.user,model_fusion_service_rate,base.group_user,1,0,0,0
|
||||
access_fusion_service_rate_admin,fusion.service.rate.admin,model_fusion_service_rate,base.group_system,1,1,1,1
|
||||
|
||||
|
108
fusion_claims/static/src/js/service_booking/service_booking.js
Normal file
108
fusion_claims/static/src/js/service_booking/service_booking.js
Normal file
@@ -0,0 +1,108 @@
|
||||
/** @odoo-module **/
|
||||
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";
|
||||
|
||||
export class ServiceBookingWizard extends Component {
|
||||
static template = "fusion_claims.ServiceBookingWizard";
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.action = useService("action");
|
||||
this.notification = useService("notification");
|
||||
this.state = useState({
|
||||
custMode: "existing",
|
||||
customer: { name: "", phone: "", email: "", street: "", unit: "", buzz: "", city: "" },
|
||||
partnerId: false, soSearch: "",
|
||||
device: "standard", category: "standard", timing: "normal", inShop: false, issue: "",
|
||||
date: "", hour: 9, minute: 0, ampm: "AM", durationHr: 1.0, technicianId: false,
|
||||
warranty: false, pod: false, emailConfirm: true, googleReview: true,
|
||||
description: "", materials: "",
|
||||
technicians: [], calloutRates: [], perKm: 0.70,
|
||||
labour: { onsite: 85, inshop: 75, lift: 110 }, distanceKm: 13, saving: false,
|
||||
});
|
||||
onWillStart(async () => {
|
||||
const r = await rpc("/fusion_claims/service_booking/refdata", {});
|
||||
Object.assign(this.state, {
|
||||
technicians: r.technicians || [],
|
||||
calloutRates: r.callout_rates || [],
|
||||
perKm: r.per_km ?? 0.70,
|
||||
labour: r.labour || this.state.labour,
|
||||
});
|
||||
});
|
||||
}
|
||||
get callout() {
|
||||
if (this.state.inShop) return null;
|
||||
return this.state.calloutRates.find(
|
||||
r => r.category === this.state.category && r.timing === this.state.timing) || null;
|
||||
}
|
||||
get labourRate() {
|
||||
if (this.state.inShop) return this.state.labour.inshop;
|
||||
return this.state.category === "lift" ? this.state.labour.lift : this.state.labour.onsite;
|
||||
}
|
||||
get estimate() {
|
||||
const c = this.callout;
|
||||
const callout = c ? c.price : 0;
|
||||
const incl = (c && !c.adds_per_km) ? 0.5 : 0;
|
||||
const billHr = Math.max(0, this.state.durationHr - incl);
|
||||
const labour = billHr * this.labourRate;
|
||||
const km = (c && c.adds_per_km) ? this.state.distanceKm * 2 * this.state.perKm : 0;
|
||||
return { callout, labour, billHr, km, total: callout + labour + km, addsKm: !!(c && c.adds_per_km) };
|
||||
}
|
||||
get endLabel() {
|
||||
let h = (this.state.hour % 12) + (this.state.ampm === "PM" ? 12 : 0);
|
||||
let m = h * 60 + this.state.minute + this.state.durationHr * 60;
|
||||
let eh = Math.floor(m / 60) % 24, em = m % 60, ap = eh >= 12 ? "PM" : "AM";
|
||||
return `${eh % 12 || 12}:${String(em).padStart(2, "0")} ${ap}`;
|
||||
}
|
||||
fmt(n) { return (n || 0).toFixed(2); }
|
||||
onDevice(ev) {
|
||||
this.state.device = ev.target.value;
|
||||
this.state.category = ev.target.value === "lift" ? "lift" : "standard";
|
||||
}
|
||||
onCallType(ev) {
|
||||
const r = this.state.calloutRates.find(x => x.code === ev.target.value);
|
||||
if (r) { this.state.category = r.category; this.state.timing = r.timing; }
|
||||
}
|
||||
setCust(m) { this.state.custMode = m; }
|
||||
setAmpm(v) { this.state.ampm = v; }
|
||||
|
||||
toggleInShop() { this.state.inShop = !this.state.inShop; }
|
||||
_timeStartFloat() { return (this.state.hour % 12) + (this.state.ampm === "PM" ? 12 : 0) + this.state.minute / 60; }
|
||||
|
||||
async submit() {
|
||||
if (this.state.saving) return;
|
||||
const s = this.state;
|
||||
if (s.custMode === "new" && (!s.customer.name || !s.customer.phone)) {
|
||||
this.notification.add("Client name and phone are required.", { type: "danger" });
|
||||
return;
|
||||
}
|
||||
if (!s.technicianId) {
|
||||
this.notification.add("Please choose a technician.", { type: "danger" });
|
||||
return;
|
||||
}
|
||||
s.saving = true;
|
||||
const payload = {
|
||||
cust_mode: s.custMode, customer: s.customer, partner_id: s.partnerId, so_search: s.soSearch,
|
||||
category: s.category, timing: s.timing, in_shop: s.inShop, device: s.device, issue: s.issue,
|
||||
date: s.date, time_start: this._timeStartFloat(), duration_hr: s.durationHr,
|
||||
technician_id: s.technicianId, warranty: s.warranty, pod: s.pod,
|
||||
email_confirm: s.emailConfirm, google_review: s.googleReview,
|
||||
description: s.description, materials: s.materials,
|
||||
};
|
||||
try {
|
||||
const res = await rpc("/fusion_claims/service_booking/submit", { payload });
|
||||
if (res.error) { this.notification.add(res.error, { type: "danger" }); s.saving = false; return; }
|
||||
this.notification.add("Service booked — draft repair SO created.", { type: "success" });
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window", res_model: "fusion.technician.task",
|
||||
res_id: res.task_id, views: [[false, "form"]], target: "current",
|
||||
});
|
||||
} catch (e) {
|
||||
this.notification.add("Booking failed: " + (e.message || e), { type: "danger" });
|
||||
s.saving = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
registry.category("actions").add("fusion_claims.service_booking", ServiceBookingWizard);
|
||||
73
fusion_claims/static/src/scss/_service_booking_tokens.scss
Normal file
73
fusion_claims/static/src/scss/_service_booking_tokens.scss
Normal file
@@ -0,0 +1,73 @@
|
||||
// Fusion Claims — Service Booking wizard design tokens.
|
||||
//
|
||||
// Per the repo dark-mode rule (CLAUDE.md "Dark Mode — Branch on
|
||||
// $o-webclient-color-scheme at SCSS Compile Time"): this file is compiled into
|
||||
// BOTH web.assets_backend (bright) and web.assets_web_dark (dark). We branch at
|
||||
// COMPILE TIME on $o-webclient-color-scheme and emit one --sb-* CSS custom
|
||||
// property per token, scoped to .o_service_booking. Do NOT use .o_dark_mode /
|
||||
// [data-bs-theme] / prefers-color-scheme — none fire reliably in Odoo 19.
|
||||
//
|
||||
// Values are copied verbatim from the mockup's :root (light) and
|
||||
// [data-theme="dark"] (dark) blocks — technician-booking-wizard.html.
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
// --- light values (mockup :root / [data-theme="light"]) ---
|
||||
$_page: #eef0f3;
|
||||
$_panel: #e6e9ed;
|
||||
$_card: #ffffff;
|
||||
$_border: #d8dadd;
|
||||
$_text: #1f2430;
|
||||
$_muted: #6b7280;
|
||||
$_faint: #9ca3af;
|
||||
$_field: #ffffff;
|
||||
$_field-border: #cfd3d8;
|
||||
$_field-focus: #3a8fb7;
|
||||
$_chip: #f1f4f7;
|
||||
$_accent: #2e7aad;
|
||||
$_accent-soft: #e8f2f8;
|
||||
$_ok: #16a34a;
|
||||
$_star: #f5b301;
|
||||
$_money: #0f7d4e;
|
||||
$_money-soft: #e7f6ee;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
// --- dark values (mockup [data-theme="dark"]) ---
|
||||
$_page: #14161b !global;
|
||||
$_panel: #1b1e24 !global;
|
||||
$_card: #22262d !global;
|
||||
$_border: #343a42 !global;
|
||||
$_text: #e7eaef !global;
|
||||
$_muted: #9aa3af !global;
|
||||
$_faint: #6b7480 !global;
|
||||
$_field: #1a1d23 !global;
|
||||
$_field-border: #3a4049 !global;
|
||||
$_field-focus: #4aa3cf !global;
|
||||
$_chip: #2a2f37 !global;
|
||||
$_accent: #3a8fb7 !global;
|
||||
$_accent-soft: #19303d !global;
|
||||
$_ok: #22c55e !global;
|
||||
$_star: #f5b301 !global;
|
||||
$_money: #34d27f !global;
|
||||
$_money-soft: #15281f !global;
|
||||
}
|
||||
|
||||
.o_service_booking {
|
||||
--sb-page: #{$_page};
|
||||
--sb-panel: #{$_panel};
|
||||
--sb-card: #{$_card};
|
||||
--sb-border: #{$_border};
|
||||
--sb-text: #{$_text};
|
||||
--sb-muted: #{$_muted};
|
||||
--sb-faint: #{$_faint};
|
||||
--sb-field: #{$_field};
|
||||
--sb-field-border: #{$_field-border};
|
||||
--sb-field-focus: #{$_field-focus};
|
||||
--sb-chip: #{$_chip};
|
||||
--sb-accent: #{$_accent};
|
||||
--sb-accent-soft: #{$_accent-soft};
|
||||
--sb-ok: #{$_ok};
|
||||
--sb-star: #{$_star};
|
||||
--sb-money: #{$_money};
|
||||
--sb-money-soft: #{$_money-soft};
|
||||
}
|
||||
283
fusion_claims/static/src/scss/service_booking.scss
Normal file
283
fusion_claims/static/src/scss/service_booking.scss
Normal file
@@ -0,0 +1,283 @@
|
||||
// Fusion Claims — Service Booking wizard component styles.
|
||||
//
|
||||
// Ported from the mockup (technician-booking-wizard.html) scoped under
|
||||
// .o_service_booking. The mockup's CSS custom properties (--page, --card, …)
|
||||
// are renamed mechanically to the --sb-* tokens emitted by
|
||||
// _service_booking_tokens.scss (which MUST load first in the bundle). The
|
||||
// manual .theme-btn dark toggle is dropped — Odoo serves the dark bundle.
|
||||
//
|
||||
// Surfaces use the explicit-hex tokens (three-layer contrast: page -> card ->
|
||||
// field), never var(--bs-*). color-mix() is used only in standalone
|
||||
// background / box-shadow properties — never inside a border shorthand (the
|
||||
// Odoo 19 SCSS compiler silently drops color-mix in border shorthands).
|
||||
|
||||
.o_service_booking {
|
||||
background: var(--sb-page);
|
||||
color: var(--sb-text);
|
||||
font-family: 'Inter', 'Helvetica Neue', Helvetica, Arial, system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
min-height: 100%;
|
||||
overflow: auto;
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
.wrap { max-width: 1000px; margin: 24px auto; padding: 0 18px; }
|
||||
|
||||
.dialog {
|
||||
background: var(--sb-panel);
|
||||
border: 1px solid var(--sb-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 40px rgba(16, 24, 40, .16);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%);
|
||||
padding: 17px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: #fff;
|
||||
|
||||
h1 { font-size: 19px; font-weight: 700; margin: 0; }
|
||||
.sub { font-size: 12.5px; opacity: .9; margin-top: 2px; }
|
||||
}
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 11px 24px;
|
||||
background: var(--sb-panel);
|
||||
border-bottom: 1px solid var(--sb-border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.step {
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--sb-faint);
|
||||
padding: 5px 13px;
|
||||
border-radius: 20px;
|
||||
background: var(--sb-chip);
|
||||
}
|
||||
.step.active { color: #fff; background: linear-gradient(135deg, #3a8fb7, #2e7aad); }
|
||||
.step.draft { margin-left: auto; color: var(--sb-money); background: var(--sb-money-soft); }
|
||||
|
||||
.body { padding: 20px 24px 6px; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
@media (max-width: 780px) { .grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.card {
|
||||
background: var(--sb-card);
|
||||
border: 1px solid var(--sb-border);
|
||||
border-radius: 13px;
|
||||
padding: 16px 17px;
|
||||
box-shadow: 0 1px 3px rgba(16, 24, 40, .08), 0 1px 2px rgba(16, 24, 40, .06);
|
||||
}
|
||||
.card.span2 { grid-column: 1 / -1; }
|
||||
.card h3 {
|
||||
margin: 0 0 13px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .7px;
|
||||
text-transform: uppercase;
|
||||
color: var(--sb-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
.card h3 .dot { width: 7px; height: 7px; border-radius: 50%; background: linear-gradient(135deg, #5ba848, #2e7aad); }
|
||||
.card h3 .tag {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--sb-money);
|
||||
background: var(--sb-money-soft);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
letter-spacing: .3px;
|
||||
}
|
||||
|
||||
label.fl { display: block; font-size: 12px; font-weight: 600; color: var(--sb-muted); margin: 0 0 5px; }
|
||||
.row { margin-bottom: 12px; }
|
||||
.row:last-child { margin-bottom: 0; }
|
||||
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 11px; }
|
||||
.three { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 9px; }
|
||||
|
||||
input.f, select.f, textarea.f {
|
||||
width: 100%;
|
||||
background: var(--sb-field);
|
||||
color: var(--sb-text);
|
||||
border: 1px solid var(--sb-field-border);
|
||||
border-radius: 9px;
|
||||
padding: 9px 11px;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border .15s, box-shadow .15s;
|
||||
}
|
||||
input.f:focus, select.f:focus, textarea.f:focus {
|
||||
border-color: var(--sb-field-focus);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--sb-field-focus) 22%, transparent);
|
||||
}
|
||||
textarea.f { resize: vertical; min-height: 56px; }
|
||||
|
||||
.hint { font-size: 11px; color: var(--sb-faint); margin-top: 5px; }
|
||||
.with-icon { position: relative; }
|
||||
.with-icon .pin { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); color: #5ba848; font-size: 16px; }
|
||||
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
background: var(--sb-chip);
|
||||
border: 1px solid var(--sb-border);
|
||||
border-radius: 9px;
|
||||
padding: 3px;
|
||||
gap: 3px;
|
||||
}
|
||||
.seg button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--sb-muted);
|
||||
font-weight: 600;
|
||||
font-size: 12.5px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.seg button.on { background: var(--sb-card); color: var(--sb-accent); box-shadow: 0 1px 3px rgba(16, 24, 40, .08), 0 1px 2px rgba(16, 24, 40, .06); }
|
||||
.seg.full { display: flex; }
|
||||
.seg.full button { flex: 1; }
|
||||
|
||||
.timepick { display: inline-flex; align-items: stretch; gap: 7px; }
|
||||
.timepick select.f { width: auto; padding-right: 24px; }
|
||||
.ampm { display: inline-flex; background: var(--sb-chip); border: 1px solid var(--sb-border); border-radius: 9px; padding: 3px; }
|
||||
.ampm button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--sb-muted);
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.ampm button.on { background: var(--sb-accent); color: #fff; }
|
||||
.endtime { font-size: 13px; color: var(--sb-muted); margin-top: 7px; }
|
||||
.endtime b { color: var(--sb-text); }
|
||||
|
||||
.avail {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--sb-ok);
|
||||
background: color-mix(in srgb, var(--sb-ok) 14%, transparent);
|
||||
padding: 3px 9px;
|
||||
border-radius: 20px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 9px 0;
|
||||
border-bottom: 1px solid var(--sb-border);
|
||||
}
|
||||
.opt:last-child { border-bottom: none; }
|
||||
.opt .lab { font-size: 13.5px; font-weight: 500; }
|
||||
.opt .lab small { display: block; color: var(--sb-faint); font-weight: 400; font-size: 11.5px; }
|
||||
|
||||
.sw {
|
||||
width: 42px;
|
||||
height: 24px;
|
||||
border-radius: 20px;
|
||||
background: var(--sb-field-border);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sw::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
transition: left .15s;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .3);
|
||||
}
|
||||
.sw.on { background: var(--sb-ok); }
|
||||
.sw.on::after { left: 21px; }
|
||||
|
||||
// fee readout inside Service & Pricing
|
||||
.feeline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--sb-money-soft);
|
||||
border: 1px solid var(--sb-border);
|
||||
border-radius: 10px;
|
||||
padding: 11px 14px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.feeline .lbl { font-size: 12.5px; font-weight: 600; color: var(--sb-text); }
|
||||
.feeline .lbl small { display: block; color: var(--sb-faint); font-weight: 400; font-size: 11px; }
|
||||
.feeline .amt { font-size: 20px; font-weight: 800; color: var(--sb-money); }
|
||||
|
||||
// ESTIMATE strip
|
||||
.estimate {
|
||||
grid-column: 1 / -1;
|
||||
background: var(--sb-money-soft);
|
||||
border: 1px solid var(--sb-border);
|
||||
border-left: 5px solid var(--sb-money);
|
||||
border-radius: 13px;
|
||||
padding: 15px 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.estimate .breakdown { display: flex; gap: 18px; flex-wrap: wrap; flex: 1; }
|
||||
.estimate .bk .k { font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px; color: var(--sb-faint); }
|
||||
.estimate .bk .v { font-size: 15px; font-weight: 700; margin-top: 1px; }
|
||||
.estimate .total { text-align: right; }
|
||||
.estimate .total .k { font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: var(--sb-money); font-weight: 700; }
|
||||
.estimate .total .v { font-size: 27px; font-weight: 800; color: var(--sb-money); line-height: 1; }
|
||||
.estimate .total .note { font-size: 11px; color: var(--sb-faint); margin-top: 3px; }
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 11px;
|
||||
padding: 16px 24px;
|
||||
background: var(--sb-panel);
|
||||
border-top: 1px solid var(--sb-border);
|
||||
}
|
||||
.foot .spacer { margin-right: auto; font-size: 12px; color: var(--sb-faint); }
|
||||
|
||||
.btn {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 11px 18px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn.ghost { background: transparent; color: var(--sb-muted); border: 1px solid var(--sb-border); }
|
||||
.btn.primary {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #5ba848, #2e7aad);
|
||||
box-shadow: 0 3px 10px color-mix(in srgb, #2e7aad 40%, transparent);
|
||||
}
|
||||
.btn[disabled] { opacity: .6; cursor: not-allowed; }
|
||||
|
||||
.hide { display: none !important; }
|
||||
}
|
||||
208
fusion_claims/static/src/xml/service_booking.xml
Normal file
208
fusion_claims/static/src/xml/service_booking.xml
Normal file
@@ -0,0 +1,208 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_claims.ServiceBookingWizard" owl="1">
|
||||
<div class="o_service_booking">
|
||||
<div class="wrap">
|
||||
<div class="dialog">
|
||||
<div class="topbar">
|
||||
<div>
|
||||
<h1>Book a Service</h1>
|
||||
<div class="sub">Repair · delivery · pickup — captures the job and creates the priced repair order</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stepper">
|
||||
<span class="step active">Scheduled</span>
|
||||
<span class="step">En Route</span>
|
||||
<span class="step">In Progress</span>
|
||||
<span class="step">Completed</span>
|
||||
<span class="step draft">● Draft repair SO will be created</span>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<div class="grid">
|
||||
<!-- CUSTOMER -->
|
||||
<div class="card">
|
||||
<h3><span class="dot"></span>Customer</h3>
|
||||
<div class="row">
|
||||
<div class="seg full">
|
||||
<button t-att-class="{ on: state.custMode === 'existing' }"
|
||||
t-on-click="() => this.setCust('existing')">Existing customer</button>
|
||||
<button t-att-class="{ on: state.custMode === 'new' }"
|
||||
t-on-click="() => this.setCust('new')">New client</button>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="state.custMode === 'existing'">
|
||||
<div class="row">
|
||||
<label class="fl">Search by phone, name or SO</label>
|
||||
<input class="f" t-model="state.soSearch" placeholder="e.g. (416) 555-0142 …"/>
|
||||
<div class="hint">Inbound call? Type the phone number — we match the contact & their history.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="state.custMode === 'new'">
|
||||
<div class="row two">
|
||||
<div><label class="fl">Client name *</label><input class="f" t-model="state.customer.name" placeholder="Full name"/></div>
|
||||
<div><label class="fl">Phone *</label><input class="f" t-model="state.customer.phone" placeholder="(416) 555-…"/></div>
|
||||
</div>
|
||||
<div class="row"><label class="fl">Email</label><input class="f" type="email" t-model="state.customer.email" placeholder="client@email.com"/></div>
|
||||
<div class="row"><label class="fl">Address</label>
|
||||
<div class="with-icon"><input class="f" t-model="state.customer.street" placeholder="Start typing an address…"/><span class="pin">📍</span></div>
|
||||
</div>
|
||||
<div class="row three">
|
||||
<div><label class="fl">Unit</label><input class="f" t-model="state.customer.unit" placeholder="#"/></div>
|
||||
<div><label class="fl">Buzz</label><input class="f" t-model="state.customer.buzz" placeholder="—"/></div>
|
||||
<div><label class="fl">City</label><input class="f" t-model="state.customer.city" placeholder="City"/></div>
|
||||
</div>
|
||||
<div class="hint">Contact is created & linked on save — all from this page.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SERVICE & PRICING -->
|
||||
<div class="card">
|
||||
<h3><span class="dot"></span>Service & Pricing<span class="tag">$ REVENUE</span></h3>
|
||||
<div class="row two">
|
||||
<div>
|
||||
<label class="fl">Device being serviced</label>
|
||||
<select class="f" t-on-change="onDevice">
|
||||
<option value="standard">Mobility Scooter</option>
|
||||
<option value="standard">Powerchair</option>
|
||||
<option value="standard">Wheelchair</option>
|
||||
<option value="lift">Stairlift</option>
|
||||
<option value="lift">Patient / Ceiling Lift</option>
|
||||
<option value="standard">Lift Chair</option>
|
||||
<option value="standard">Hospital Bed</option>
|
||||
<option value="standard">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="fl">Issue / symptom</label>
|
||||
<input class="f" t-model="state.issue" placeholder="e.g. won't power on"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" t-if="!state.inShop">
|
||||
<label class="fl">Service call type</label>
|
||||
<select class="f"
|
||||
t-on-change="onCallType">
|
||||
<t t-foreach="state.calloutRates" t-as="r" t-key="r.code">
|
||||
<option t-att-value="r.code"
|
||||
t-att-selected="state.category === r.category and state.timing === r.timing">
|
||||
<t t-esc="r.name"/> — $<t t-esc="fmt(r.price)"/><t t-if="r.adds_per_km"> + $<t t-esc="fmt(state.perKm)"/>/km ×2-way</t>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
<div class="hint">Auto-suggested from the device — change if needed.</div>
|
||||
</div>
|
||||
<div class="feeline" t-if="!state.inShop and callout">
|
||||
<div class="lbl">Call-out fee<small><t t-esc="callout.name"/><t t-if="callout.adds_per_km"> · + travel</t><t t-else=""> · includes 30 min labour</t></small></div>
|
||||
<div class="amt">$<t t-esc="fmt(callout.price)"/></div>
|
||||
</div>
|
||||
<div class="hint" t-if="state.inShop">In-shop job — no call-out fee; labour billed at $<t t-esc="fmt(state.labour.inshop)"/>/hr.</div>
|
||||
</div>
|
||||
|
||||
<!-- SCHEDULE -->
|
||||
<div class="card">
|
||||
<h3><span class="dot"></span>Schedule</h3>
|
||||
<div class="row two">
|
||||
<div><label class="fl">Date</label><input class="f" type="date" t-model="state.date"/></div>
|
||||
<div><label class="fl">Duration</label>
|
||||
<select class="f" t-model.number="state.durationHr">
|
||||
<option value="0.5">30 min</option>
|
||||
<option value="1">1 hour</option>
|
||||
<option value="1.5">1.5 hours</option>
|
||||
<option value="2">2 hours</option>
|
||||
<option value="3">3 hours</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="fl">Start time</label>
|
||||
<div class="timepick">
|
||||
<select class="f" t-model.number="state.hour">
|
||||
<option value="9">9</option>
|
||||
<option value="10">10</option>
|
||||
<option value="11">11</option>
|
||||
<option value="12">12</option>
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
</select>
|
||||
<select class="f" t-model.number="state.minute">
|
||||
<option value="0">:00</option>
|
||||
<option value="15">:15</option>
|
||||
<option value="30">:30</option>
|
||||
<option value="45">:45</option>
|
||||
</select>
|
||||
<div class="ampm">
|
||||
<button t-att-class="{ on: state.ampm === 'AM' }" t-on-click="() => this.setAmpm('AM')">AM</button>
|
||||
<button t-att-class="{ on: state.ampm === 'PM' }" t-on-click="() => this.setAmpm('PM')">PM</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="endtime">Ends at <b><t t-esc="endLabel"/></b> · your local time</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="fl">Technician</label>
|
||||
<select class="f" t-model.number="state.technicianId">
|
||||
<option value="">— Choose —</option>
|
||||
<t t-foreach="state.technicians" t-as="t" t-key="t.id">
|
||||
<option t-att-value="t.id"><t t-esc="t.name"/></option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LOCATION -->
|
||||
<div class="card">
|
||||
<h3><span class="dot"></span>Location</h3>
|
||||
<div class="opt" style="border:none; padding-top:0;">
|
||||
<div class="lab">In-shop job<small>At the store — no call-out, labour @ $<t t-esc="fmt(state.labour.inshop)"/>/hr</small></div>
|
||||
<div class="sw" t-att-class="{ on: state.inShop }" t-on-click="toggleInShop"></div>
|
||||
</div>
|
||||
<div t-if="!state.inShop">
|
||||
<div class="row"><label class="fl">Job address</label>
|
||||
<div class="with-icon"><input class="f" t-model="state.customer.street" placeholder="Auto-fills from customer…"/><span class="pin">📍</span></div>
|
||||
</div>
|
||||
<div class="row two">
|
||||
<div><label class="fl">Unit / Suite</label><input class="f" t-model="state.customer.unit" placeholder="#"/></div>
|
||||
<div><label class="fl">Buzz code</label><input class="f" t-model="state.customer.buzz" placeholder="—"/></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JOB DETAILS -->
|
||||
<div class="card span2">
|
||||
<h3><span class="dot"></span>Job details</h3>
|
||||
<div class="two">
|
||||
<div class="row"><label class="fl">Work description</label><textarea class="f" t-model="state.description" placeholder="Symptom, what to check, history…"></textarea></div>
|
||||
<div class="row"><label class="fl">Parts / materials to bring</label><textarea class="f" t-model="state.materials" placeholder="Batteries, controller, casters…"></textarea></div>
|
||||
</div>
|
||||
<div class="opt"><div class="lab">Under manufacturer warranty<small>Parts not billed when covered</small></div><div class="sw" t-att-class="{ on: state.warranty }" t-on-click="() => state.warranty = !state.warranty"></div></div>
|
||||
<div class="opt"><div class="lab">POD required<small>Capture proof of delivery on completion</small></div><div class="sw" t-att-class="{ on: state.pod }" t-on-click="() => state.pod = !state.pod"></div></div>
|
||||
<div class="opt"><div class="lab">Send client confirmation (email/SMS)<small>Booked · en-route · completed</small></div><div class="sw" t-att-class="{ on: state.emailConfirm }" t-on-click="() => state.emailConfirm = !state.emailConfirm"></div></div>
|
||||
<div class="opt"><div class="lab">Request Google review after completion</div><div class="sw" t-att-class="{ on: state.googleReview }" t-on-click="() => state.googleReview = !state.googleReview"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- ESTIMATE -->
|
||||
<div class="estimate">
|
||||
<div class="breakdown">
|
||||
<div class="bk"><div class="k">Call-out</div><div class="v"><t t-if="state.inShop">—</t><t t-else="">$<t t-esc="fmt(estimate.callout)"/></t></div></div>
|
||||
<div class="bk"><div class="k">Est. labour</div><div class="v">$<t t-esc="fmt(estimate.labour)"/> · <t t-esc="estimate.billHr"/>h @ $<t t-esc="fmt(labourRate)"/></div></div>
|
||||
<div class="bk" t-if="estimate.addsKm"><div class="k">Travel ($<t t-esc="fmt(state.perKm)"/>/km ×2)</div><div class="v">$<t t-esc="fmt(estimate.km)"/></div></div>
|
||||
</div>
|
||||
<div class="total"><div class="k">Estimated total</div><div class="v">$<t t-esc="fmt(estimate.total)"/></div>
|
||||
<div class="note">+ parts as used · pre-tax · a draft SO is created</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="foot">
|
||||
<span class="spacer">Local time · America/Toronto · <t t-esc="state.distanceKm"/> km away</span>
|
||||
<button class="btn ghost" t-on-click="() => this.action.doAction({ type: 'ir.actions.act_window_close' })">Cancel</button>
|
||||
<button class="btn primary" t-on-click="submit" t-att-disabled="state.saving">Book & Create SO</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -4,3 +4,4 @@ from . import test_signed_pages_gate
|
||||
from . import test_application_received_wizard
|
||||
from . import test_dashboard
|
||||
from . import test_service_rate
|
||||
from . import test_service_booking
|
||||
|
||||
75
fusion_claims/tests/test_service_booking.py
Normal file
75
fusion_claims/tests/test_service_booking.py
Normal file
@@ -0,0 +1,75 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import date
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestServiceBooking(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.Task = cls.env['fusion.technician.task']
|
||||
# technician_id is required on a task (domain x_fc_is_field_staff=True).
|
||||
cls.tech = cls.env['res.users'].create({
|
||||
'name': 'Service Booking Tech',
|
||||
'login': 'svcbook_tech',
|
||||
'x_fc_is_field_staff': True,
|
||||
})
|
||||
|
||||
def test_task_without_order_is_allowed(self):
|
||||
# No SO/PO must NOT raise after the relax. description is required and a
|
||||
# non-in-store task needs an address, so set both here to isolate the test
|
||||
# to the order-link relaxation (not those unrelated base constraints).
|
||||
t = self.Task.create({
|
||||
'task_type': 'repair',
|
||||
'technician_id': self.tech.id,
|
||||
'scheduled_date': date(2026, 6, 3),
|
||||
'description': 'Test repair',
|
||||
'is_in_store': True,
|
||||
})
|
||||
self.assertTrue(t.id)
|
||||
|
||||
def test_sale_order_has_service_repair_flag(self):
|
||||
so = self.env['sale.order'].new({})
|
||||
self.assertIn('x_fc_is_service_repair', so._fields)
|
||||
|
||||
def test_resolve_service_lines_standard_rush(self):
|
||||
Task = self.Task
|
||||
lines = Task._resolve_service_lines('standard', 'rush', in_shop=False, distance_km=10.0)
|
||||
# call-out $120 + per-km line qty 20 @ $0.70
|
||||
callout = [l for l in lines if l['price_unit'] == 120.0]
|
||||
per_km = [l for l in lines if l['name_is_km']]
|
||||
self.assertTrue(callout)
|
||||
self.assertEqual(per_km[0]['product_uom_qty'], 20.0)
|
||||
self.assertEqual(per_km[0]['price_unit'], 0.70)
|
||||
|
||||
def test_resolve_service_lines_in_shop_empty_callout(self):
|
||||
lines = self.Task._resolve_service_lines('standard', 'normal', in_shop=True, distance_km=5.0)
|
||||
self.assertEqual(lines, [])
|
||||
|
||||
def test_build_service_so(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Walk-in Wanda'})
|
||||
so = self.Task._build_service_so(partner, 'standard', 'normal', False, 0.0)
|
||||
self.assertEqual(so.state, 'draft')
|
||||
self.assertTrue(so.x_fc_is_service_repair)
|
||||
self.assertEqual(so.partner_id, partner)
|
||||
self.assertEqual(so.order_line[0].price_unit, 95.0)
|
||||
|
||||
def test_action_book_creates_contact_task_and_so(self):
|
||||
payload = {
|
||||
'cust_mode': 'new',
|
||||
'customer': {'name': 'Nina New', 'phone': '4165550199', 'email': 'nina@x.com',
|
||||
'street': '88 Bloor St E', 'city': 'Toronto'},
|
||||
'category': 'standard', 'timing': 'normal', 'in_shop': False,
|
||||
'device': 'scooter', 'issue': "won't power on",
|
||||
'date': '2026-06-03', 'time_start': 9.0, 'duration_hr': 1.0,
|
||||
'technician_id': self.tech.id, 'description': 'check battery',
|
||||
}
|
||||
res = self.Task.action_book_from_wizard(payload)
|
||||
self.assertTrue(res['task_id'] and res['order_id'])
|
||||
task = self.Task.browse(res['task_id'])
|
||||
self.assertEqual(task.sale_order_id.id, res['order_id'])
|
||||
self.assertEqual(task.sale_order_id.order_line[0].price_unit, 95.0)
|
||||
partner = self.env['res.partner'].search([('email', '=ilike', 'nina@x.com')], limit=1)
|
||||
self.assertTrue(partner)
|
||||
18
fusion_claims/views/service_booking_action.xml
Normal file
18
fusion_claims/views/service_booking_action.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2024-2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Claim Assistant product family.
|
||||
-->
|
||||
<odoo>
|
||||
<record id="action_service_booking_wizard" model="ir.actions.client">
|
||||
<field name="name">Book a Service</field>
|
||||
<field name="tag">fusion_claims.service_booking</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_service_booking"
|
||||
name="Book a Service"
|
||||
parent="fusion_tasks.menu_field_service_root"
|
||||
action="action_service_booking_wizard"
|
||||
sequence="1"/>
|
||||
</odoo>
|
||||
101
fusion_claims/views/service_rate_views.xml
Normal file
101
fusion_claims/views/service_rate_views.xml
Normal file
@@ -0,0 +1,101 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2024-2025 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Claim Assistant product family.
|
||||
-->
|
||||
<odoo>
|
||||
<!-- ===================================================================== -->
|
||||
<!-- SERVICE RATE: List View (inline-edit enabled) -->
|
||||
<!-- ===================================================================== -->
|
||||
<record id="view_fusion_service_rate_list" model="ir.ui.view">
|
||||
<field name="name">fusion.service.rate.list</field>
|
||||
<field name="model">fusion.service.rate</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Service Rates" editable="top"
|
||||
default_order="sequence, rate_kind, category, timing">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<field name="rate_kind" string="Kind"/>
|
||||
<field name="category"/>
|
||||
<field name="timing"/>
|
||||
<field name="unit"/>
|
||||
<field name="price" string="Rate"/>
|
||||
<field name="currency_id" column_invisible="True"/>
|
||||
<field name="adds_per_km" string="+ km"/>
|
||||
<field name="included_labour_min" string="Incl. Labour (min)"/>
|
||||
<field name="in_shop" string="In-Shop"/>
|
||||
<field name="product_id" string="Invoice Product"/>
|
||||
<field name="active" column_invisible="True"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================================================================== -->
|
||||
<!-- SERVICE RATE: Form View -->
|
||||
<!-- ===================================================================== -->
|
||||
<record id="view_fusion_service_rate_form" model="ir.ui.view">
|
||||
<field name="name">fusion.service.rate.form</field>
|
||||
<field name="model">fusion.service.rate</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Service Rate">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" placeholder="Rate name…"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Identification">
|
||||
<field name="code"/>
|
||||
<field name="rate_kind" string="Kind"/>
|
||||
<field name="category"/>
|
||||
<field name="timing"/>
|
||||
<field name="in_shop"/>
|
||||
<field name="active"/>
|
||||
<field name="sequence"/>
|
||||
</group>
|
||||
<group string="Pricing">
|
||||
<field name="price" string="Rate"/>
|
||||
<field name="currency_id"/>
|
||||
<field name="unit"/>
|
||||
<field name="adds_per_km"/>
|
||||
<field name="included_labour_min"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Invoice Product">
|
||||
<field name="product_id" string="Product" colspan="2"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================================================================== -->
|
||||
<!-- SERVICE RATE: Action -->
|
||||
<!-- ===================================================================== -->
|
||||
<record id="action_fusion_service_rate" model="ir.actions.act_window">
|
||||
<field name="name">Service Rates</field>
|
||||
<field name="res_model">fusion.service.rate</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="context">{'active_test': False}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No service rates found.
|
||||
</p>
|
||||
<p>
|
||||
Add rates used for booking service calls, labour, travel, and delivery.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================================================================== -->
|
||||
<!-- SERVICE RATE: Menu item under Technician Configuration -->
|
||||
<!-- ===================================================================== -->
|
||||
<menuitem id="menu_fusion_service_rate"
|
||||
name="Service Rates"
|
||||
parent="fusion_tasks.menu_technician_config"
|
||||
action="action_fusion_service_rate"
|
||||
sequence="50"
|
||||
groups="base.group_system"/>
|
||||
|
||||
</odoo>
|
||||
@@ -781,7 +781,7 @@ class FusionTechnicianTask(models.Model):
|
||||
def _inverse_datetime_start(self):
|
||||
"""When datetime_start is changed (e.g. from calendar drag), update date + time."""
|
||||
import pytz
|
||||
user_tz = pytz.timezone(self.env.user.tz or 'UTC')
|
||||
user_tz = self._get_local_tz()
|
||||
for task in self:
|
||||
if task.datetime_start:
|
||||
local_dt = pytz.utc.localize(task.datetime_start).astimezone(user_tz)
|
||||
@@ -791,7 +791,7 @@ class FusionTechnicianTask(models.Model):
|
||||
def _inverse_datetime_end(self):
|
||||
"""When datetime_end is changed (e.g. from calendar resize), update time_end."""
|
||||
import pytz
|
||||
user_tz = pytz.timezone(self.env.user.tz or 'UTC')
|
||||
user_tz = self._get_local_tz()
|
||||
for task in self:
|
||||
if task.datetime_end:
|
||||
local_dt = pytz.utc.localize(task.datetime_end).astimezone(user_tz)
|
||||
|
||||
2
fusion_tasks/tests/__init__.py
Normal file
2
fusion_tasks/tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import test_task_tz
|
||||
39
fusion_tasks/tests/test_task_tz.py
Normal file
39
fusion_tasks/tests/test_task_tz.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import date
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestTaskTz(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
# _compute_datetimes resolves company resource-calendar tz FIRST, then user tz.
|
||||
# Set BOTH to Toronto so the UTC assertion and the round-trip are deterministic.
|
||||
cls.env.user.tz = 'America/Toronto'
|
||||
cal = cls.env.company.resource_calendar_id
|
||||
if cal:
|
||||
cal.tz = 'America/Toronto'
|
||||
# technician_id is required (domain x_fc_is_field_staff=True) -> make a field tech.
|
||||
cls.tech = cls.env['res.users'].create({
|
||||
'name': 'TZ Test Tech',
|
||||
'login': 'tz_test_tech_svcbook',
|
||||
'x_fc_is_field_staff': True,
|
||||
})
|
||||
cls.task = cls.env['fusion.technician.task'].create({
|
||||
'technician_id': cls.tech.id,
|
||||
'scheduled_date': date(2026, 6, 3),
|
||||
'time_start': 9.0,
|
||||
'time_end': 10.0,
|
||||
})
|
||||
|
||||
def test_local_to_utc_compute(self):
|
||||
# 9:00 local Toronto (EDT, -4) -> 13:00 UTC stored
|
||||
self.assertEqual(self.task.datetime_start.hour, 13)
|
||||
|
||||
def test_inverse_round_trips_with_same_tz(self):
|
||||
# writing datetime_start back recovers the same local time_start
|
||||
self.task.datetime_start = self.task.datetime_start # force inverse
|
||||
self.task.flush_recordset(['datetime_start'])
|
||||
self.assertAlmostEqual(self.task.time_start, 9.0, places=2)
|
||||
Reference in New Issue
Block a user