diff --git a/fusion_claims/CLAUDE.md b/fusion_claims/CLAUDE.md index b7333db2..71cd00a4 100644 --- a/fusion_claims/CLAUDE.md +++ b/fusion_claims/CLAUDE.md @@ -3141,3 +3141,35 @@ column → 46px navbar + `.o_action_manager{flex:1;min-height:0}` so the wizard' `height:100%;overflow:auto` scrolls) at 320/390/768/1280, and read computed `grid-template-columns` / `margin-left` / `display`. A standalone vanilla-Bootstrap repro is **not** faithful — it rendered fine and falsely cleared the bug. + +## 48. Service Booking wizard — dynamic fields (live client search + address autocomplete), v19.0.9.7.0 + +The wizard is a **client action** (registered OWL component), not a form view, so two +"type-ahead" features had to be built into the component itself: + +1. **Live client search** ("Existing customer" box). Endpoint + `/fusion_claims/service_booking/search_customers` (jsonrpc, auth=user) searches + `res.partner` and resolves a typed SO number to its partner; the JS debounces (250 ms), + shows a `.sb-cust-results` dropdown, and `pickCustomer()` sets `state.partnerId` + fills the + contact fields. The backend (`action_book_from_wizard`) already consumes `partner_id` for + `cust_mode='existing'`, so picking links the existing contact. + **GOTCHA (cost a near-miss): `res.partner` has NO `mobile` field in Odoo 19** — a domain + leaf `('mobile','ilike',q)` raises `ValueError: Invalid field res.partner.mobile`, and + because the controller's `except` swallows it the search silently returns nothing (looks + exactly like "search not working"). Build the OR domain only over fields present in + `Partner._fields` (`name`/`phone`/`email`); same for reading `p.mobile`. Verify any new + partner-field reference against `_fields` before shipping. + +2. **Address autocomplete.** `google_address_autocomplete.js` patches `FormController` only, + so it does **not** reach this client action. The component loads Google Places itself + (key from ICP `fusion_claims.google_maps_api_key`), attaches via `useRef('root')` + + `onMounted`/`onPatched` to every `input.sb-addr-input`, and writes + `street`/`city`/`lat`/`lng` straight into reactive `state` (no DOM hacks — we're inside the + component). Re-attach on patch is guarded by an `_sbAc` flag per input and an `_addrStarted` + /`_addrNoKey` gate so a missing key just degrades to manual entry and can never break render + (both lifecycle calls are `.catch(()=>{})`). + +**Verifying the search without a browser:** the endpoint logic is plain ORM — smoke it in an +`odoo shell` against real data (`Partner.search(, limit=8)`); a vanilla repro of +the `.sb-cust-results` dropdown markup + compiled CSS confirms the UI. Address autocomplete +needs the live key + Google Maps, so it can only be confirmed in a real browser session. diff --git a/fusion_claims/__manifest__.py b/fusion_claims/__manifest__.py index 3224b587..55ba8f61 100644 --- a/fusion_claims/__manifest__.py +++ b/fusion_claims/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Claims', - 'version': '19.0.9.6.0', + 'version': '19.0.9.7.0', 'category': 'Sales', 'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.', 'description': """ diff --git a/fusion_claims/controllers/service_booking.py b/fusion_claims/controllers/service_booking.py index 729df351..83ccf1ab 100644 --- a/fusion_claims/controllers/service_booking.py +++ b/fusion_claims/controllers/service_booking.py @@ -30,6 +30,44 @@ class ServiceBookingController(http.Controller): 'lift': labour('labour_lift')}, } + @http.route('/fusion_claims/service_booking/search_customers', type='jsonrpc', auth='user') + def search_customers(self, query=None, **kw): + """Live customer lookup for the booking wizard's 'Existing customer' box. + Matches res.partner by name / phone / mobile / email, and also resolves a + typed sale-order reference to its customer. Returns up to 8 light dicts.""" + q = (query or '').strip() + if len(q) < 2: + return {'results': []} + env = request.env + Partner = env['res.partner'].sudo() + # Build the OR domain only over fields that actually exist on this DB — + # res.partner.mobile is NOT present in Odoo 19, so referencing it raises + # ValueError and the whole lookup silently returns nothing. + has_mobile = 'mobile' in Partner._fields + search_fields = [f for f in ('name', 'phone', 'email', 'mobile') + if f in Partner._fields] + leaves = [(f, 'ilike', q) for f in search_fields] + domain = leaves[:1] + for leaf in leaves[1:]: + domain = ['|'] + domain + [leaf] + partners = Partner.search(domain, limit=8, order='write_date desc') + # also resolve an SO number -> its partner (the hint promises "name or SO") + if len(q) >= 3 and len(partners) < 8 and 'sale.order' in env: + sos = env['sale.order'].sudo().search([('name', 'ilike', q)], limit=5) + partners = (partners | sos.mapped('partner_id'))[:8] + results = [] + for p in partners: + phone = p.phone or (p.mobile if has_mobile else '') or '' + results.append({ + 'id': p.id, + 'name': p.name or '', + 'phone': phone, + 'email': p.email or '', + 'street': p.street or '', + 'city': p.city or '', + }) + return {'results': results} + @http.route('/fusion_claims/service_booking/submit', type='jsonrpc', auth='user') def submit(self, payload=None, **kw): try: diff --git a/fusion_claims/static/src/js/service_booking/service_booking.js b/fusion_claims/static/src/js/service_booking/service_booking.js index 674db193..d6de6256 100644 --- a/fusion_claims/static/src/js/service_booking/service_booking.js +++ b/fusion_claims/static/src/js/service_booking/service_booking.js @@ -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; diff --git a/fusion_claims/static/src/scss/service_booking.scss b/fusion_claims/static/src/scss/service_booking.scss index dc1b124a..91cbc2ca 100644 --- a/fusion_claims/static/src/scss/service_booking.scss +++ b/fusion_claims/static/src/scss/service_booking.scss @@ -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); diff --git a/fusion_claims/static/src/xml/service_booking.xml b/fusion_claims/static/src/xml/service_booking.xml index 188c2e99..83ca2468 100644 --- a/fusion_claims/static/src/xml/service_booking.xml +++ b/fusion_claims/static/src/xml/service_booking.xml @@ -2,7 +2,7 @@ -
+
@@ -33,10 +33,22 @@
-
+
@@ -46,7 +58,7 @@
-
📍
+
📍
@@ -160,7 +172,7 @@
-
📍
+
📍