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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 & 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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user