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;