feat(fusion_claims): service-booking wizard live client search + address autocomplete

The wizard's dynamic fields were non-functional: the "Existing customer" box had
no search (no endpoint, no handler — the typed string was only sent on submit),
and address autocomplete never attached (google_address_autocomplete.js patches
FormController, which a client action is not).

Live client search:
- new jsonrpc endpoint /fusion_claims/service_booking/search_customers — searches
  res.partner (name/phone/email) and resolves a typed SO number to its partner.
- JS: debounced (250ms) onCustSearch -> .sb-cust-results dropdown; pickCustomer()
  sets state.partnerId + fills the contact, which action_book_from_wizard already
  consumes for cust_mode='existing'.
- FIELD-SAFE domain: res.partner has NO `mobile` field in Odoo 19 — referencing it
  raises ValueError and the swallowed exception made the search silently return
  nothing. Build the OR domain only over fields in Partner._fields. Smoke-tested on
  prod data ('25450'->1, '1 905-'->8).

Address autocomplete (wizard-local):
- component loads Google Places (key = ICP fusion_claims.google_maps_api_key, which
  IS configured on westin), attaches via useRef('root')+onMounted/onPatched to every
  input.sb-addr-input, writes street/city/lat/lng into reactive state. Fully guarded
  (per-input _sbAc, _addrStarted/_addrNoKey gate, .catch on both hooks) so a missing
  key degrades to manual entry and can never break render.

Verified: pyflakes clean, JS node --check, SCSS compiles, XML well-formed, dropdown
UI rendered against Bootstrap+compiled CSS. Documented in fusion_claims/CLAUDE.md §48.
Bump fusion_claims 19.0.9.6.0 -> 19.0.9.7.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-06-04 21:07:47 -04:00
parent 53fe13344d
commit 80d06ff77f
6 changed files with 214 additions and 10 deletions

View File

@@ -1,5 +1,5 @@
/** @odoo-module **/
import { Component, useState, onWillStart } from "@odoo/owl";
import { Component, useState, onWillStart, onMounted, onPatched, useRef } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
@@ -11,10 +11,12 @@ export class ServiceBookingWizard extends Component {
setup() {
this.action = useService("action");
this.notification = useService("notification");
this.orm = useService("orm");
this.rootRef = useRef("root");
this.state = useState({
custMode: "existing",
customer: { name: "", phone: "", email: "", street: "", unit: "", buzz: "", city: "" },
partnerId: false, soSearch: "",
customer: { name: "", phone: "", email: "", street: "", unit: "", buzz: "", city: "", lat: 0, lng: 0 },
partnerId: false, soSearch: "", custResults: [], custSearching: false,
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,
@@ -31,6 +33,104 @@ export class ServiceBookingWizard extends Component {
labour: r.labour || this.state.labour,
});
});
// Address autocomplete attaches after the DOM exists and re-attaches when
// OWL re-renders (e.g. switching to "New client" reveals a second address
// input). Fully guarded — a missing Maps key just means manual entry.
onMounted(() => { this._initAddrAutocomplete().catch(() => {}); });
onPatched(() => { this._initAddrAutocomplete().catch(() => {}); });
}
// ---- live customer search (Existing customer box) ----
onCustSearch(ev) {
const q = ev.target.value || "";
this.state.soSearch = q;
this.state.partnerId = false; // typing again unlinks any picked contact
clearTimeout(this._custTimer);
const term = q.trim();
if (term.length < 2) { this.state.custResults = []; this.state.custSearching = false; return; }
this.state.custSearching = true;
this._custTimer = setTimeout(async () => {
try {
const r = await rpc("/fusion_claims/service_booking/search_customers", { query: term });
this.state.custResults = r.results || [];
} catch (e) {
this.state.custResults = [];
}
this.state.custSearching = false;
}, 250);
}
pickCustomer(c) {
this.state.partnerId = c.id;
this.state.customer.name = c.name || "";
this.state.customer.phone = c.phone || "";
this.state.customer.email = c.email || "";
this.state.customer.street = c.street || "";
this.state.customer.city = c.city || "";
this.state.custResults = [];
this.state.soSearch = c.name + (c.phone ? ` · ${c.phone}` : "");
}
// ---- Google Places address autocomplete (wizard-local; the FormController
// patch in google_address_autocomplete.js does NOT reach a client action) ----
async _getMapsKey() {
try {
return await this.orm.call("ir.config_parameter", "get_param", ["fusion_claims.google_maps_api_key"]);
} catch (e) {
return null;
}
}
_loadMaps(key) {
if (window.google?.maps?.places) return Promise.resolve();
if (window._sbMapsLoading) return window._sbMapsLoading;
window._sbMapsLoading = new Promise((resolve, reject) => {
window._sbMapsReady = () => resolve();
const s = document.createElement("script");
s.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(key)}&libraries=places&callback=_sbMapsReady`;
s.async = true; s.defer = true; s.onerror = reject;
document.head.appendChild(s);
});
return window._sbMapsLoading;
}
async _initAddrAutocomplete() {
const root = this.rootRef.el;
if (!root) return;
if (!this._addrStarted) {
if (!root.querySelector("input.sb-addr-input")) return; // nothing to bind yet
this._addrStarted = true;
const key = await this._getMapsKey();
if (!key) { this._addrNoKey = true; return; }
try { await this._loadMaps(key); } catch (e) { return; }
}
if (this._addrNoKey || !window.google?.maps?.places) return;
// re-query after the await: OWL may have swapped inputs during loading
root.querySelectorAll("input.sb-addr-input").forEach((inp) => {
if (inp._sbAc) return;
inp._sbAc = true;
try {
const ac = new google.maps.places.Autocomplete(inp, {
componentRestrictions: { country: "ca" },
types: ["address"],
fields: ["address_components", "formatted_address", "geometry"],
});
ac.addListener("place_changed", () => {
const place = ac.getPlace();
if (!place || !place.address_components) return;
let city = "";
for (const c of place.address_components) {
if (c.types.includes("locality")) city = c.long_name;
else if (c.types.includes("sublocality_level_1") && !city) city = c.long_name;
}
this.state.customer.street = place.formatted_address || inp.value;
if (city) this.state.customer.city = city;
if (place.geometry && place.geometry.location) {
this.state.customer.lat = place.geometry.location.lat();
this.state.customer.lng = place.geometry.location.lng();
}
});
} catch (e) {
inp._sbAc = false; // allow a later retry if construction failed
}
});
}
get callout() {
if (this.state.inShop) return null;

View File

@@ -130,6 +130,28 @@
.with-icon { position: relative; }
.with-icon .pin { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); color: #5ba848; font-size: 16px; }
// live customer-search results dropdown
.sb-cust-search { position: relative; }
.sb-cust-results {
position: absolute;
left: 0; right: 0; top: 100%;
margin-top: 4px;
z-index: 30;
background: var(--sb-card);
border: 1px solid var(--sb-border);
border-radius: 9px;
box-shadow: 0 8px 24px rgba(16, 24, 40, .16);
max-height: 260px;
overflow-y: auto;
}
.sb-cust-loading { padding: 10px 12px; font-size: 12.5px; color: var(--sb-faint); }
.sb-cust-item { padding: 9px 12px; cursor: pointer; border-bottom: 1px solid var(--sb-border); }
.sb-cust-item:last-child { border-bottom: none; }
.sb-cust-item:hover { background: var(--sb-chip); }
.sb-cust-name { font-size: 13.5px; font-weight: 600; color: var(--sb-text); }
.sb-cust-meta { font-size: 11.5px; color: var(--sb-faint); margin-top: 1px; }
.sb-cust-linked { color: var(--sb-ok); font-weight: 600; }
.seg {
display: inline-flex;
background: var(--sb-chip);

View File

@@ -2,7 +2,7 @@
<templates xml:space="preserve">
<t t-name="fusion_claims.ServiceBookingWizard" owl="1">
<div class="o_service_booking">
<div class="o_service_booking" t-ref="root">
<div class="wrap">
<div class="dialog">
<div class="topbar">
@@ -33,10 +33,22 @@
</div>
</div>
<div t-if="state.custMode === 'existing'">
<div class="sb-row">
<div class="sb-row sb-cust-search">
<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 &amp; their history.</div>
<input class="f" t-att-value="state.soSearch" t-on-input="onCustSearch"
placeholder="e.g. (416) 555-0142 …" autocomplete="off"/>
<div class="hint" t-if="!state.partnerId">Inbound call? Type the phone number — we match the contact &amp; their history.</div>
<div class="hint sb-cust-linked" t-if="state.partnerId">✓ Linked to existing contact — booking will use it.</div>
<div class="sb-cust-results" t-if="state.custSearching or state.custResults.length">
<div class="sb-cust-loading" t-if="state.custSearching">Searching…</div>
<t t-else="">
<div class="sb-cust-item" t-foreach="state.custResults" t-as="c" t-key="c.id"
t-on-click="() => this.pickCustomer(c)">
<div class="sb-cust-name"><t t-esc="c.name"/></div>
<div class="sb-cust-meta"><t t-esc="c.phone"/><t t-if="c.phone and c.city"> · </t><t t-esc="c.city"/></div>
</div>
</t>
</div>
</div>
</div>
<div t-if="state.custMode === 'new'">
@@ -46,7 +58,7 @@
</div>
<div class="sb-row"><label class="fl">Email</label><input class="f" type="email" t-model="state.customer.email" placeholder="client@email.com"/></div>
<div class="sb-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 class="with-icon"><input class="f sb-addr-input" t-model="state.customer.street" placeholder="Start typing an address…"/><span class="pin">📍</span></div>
</div>
<div class="sb-row three">
<div><label class="fl">Unit</label><input class="f" t-model="state.customer.unit" placeholder="#"/></div>
@@ -160,7 +172,7 @@
</div>
<div t-if="!state.inShop">
<div class="sb-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 class="with-icon"><input class="f sb-addr-input" t-model="state.customer.street" placeholder="Auto-fills from customer…"/><span class="pin">📍</span></div>
</div>
<div class="sb-row two">
<div><label class="fl">Unit / Suite</label><input class="f" t-model="state.customer.unit" placeholder="#"/></div>