From 53fe13344d39edb0ceeb720bd6e34647dccde5d6 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 4 Jun 2026 20:38:52 -0400 Subject: [PATCH 1/2] =?UTF-8?q?fix(fusion=5Fclaims):=20service-booking=20w?= =?UTF-8?q?izard=20responsive=20=E2=80=94=20namespace=20Bootstrap=20classe?= =?UTF-8?q?s=20+=20reorder=20media=20queries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 19.0.9.5.0 CSS pass (padding/scroll) did not fix "fields sitting on each other" on small screens. Deep dive against the live web.assets_backend bundle found two compounding root causes (both measured, not guessed): 1. Dead media query. The @media(max-width:560px) collapse for the inner field grids (.two/.three) and .timepick was nested BEFORE the base .two/.three/ .timepick rules. Equal specificity → the later base rule wins → the media query never applied. matchMedia matched at 320/390px yet grids stayed 2–3 columns and crammed/truncated. Moved all @media overrides to the END of the .o_service_booking block so they win the cascade. 2. Bootstrap class collision. The wizard reused row/card/grid/btn; Odoo's backend Bootstrap applies .row{display:flex;margin:0 -16px}, .card{display:flex}, etc. globally even under the scoped parent (they win for properties the scoped rule doesn't set). Measured: every .row computed display:flex + margin-left/ right:-16px. Renamed all layout classes to sb-* (sb-row/sb-card/sb-grid/sb-btn) in service_booking.xml + service_booking.scss. Verified at 320/390/768/1280 against the real prod bundle (computed grid-template-columns now 1fr at <=560px; .sb-row margin 0 / display block; 2-col desktop intact). Documented both gotchas in fusion_claims/CLAUDE.md §47. Bump fusion_claims 19.0.9.5.0 -> 19.0.9.6.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- fusion_claims/CLAUDE.md | 37 +++++++++++++ fusion_claims/__manifest__.py | 2 +- .../static/src/scss/service_booking.scss | 52 +++++++++++-------- .../static/src/xml/service_booking.xml | 46 ++++++++-------- 4 files changed, 92 insertions(+), 45 deletions(-) diff --git a/fusion_claims/CLAUDE.md b/fusion_claims/CLAUDE.md index 9314bd1c..b7333db2 100644 --- a/fusion_claims/CLAUDE.md +++ b/fusion_claims/CLAUDE.md @@ -3104,3 +3104,40 @@ After 9 rounds of deep diving, here's what CLAUDE.md covers vs the codebase: - Add new gotchas in the right format - Understand the soft-dep on `fusion_faxes` + `fusion_pdf_preview` - Know the deployment fact that fusion_portal is always co-installed + +## 47. Service Booking wizard — two CSS gotchas (client action, `static/src/scss/service_booking.scss` + `xml/service_booking.xml`) + +The OWL "Book a Service" wizard renders inside the Odoo **backend** (`web.assets_backend`), +so the full Bootstrap 5 + Odoo stylesheet is live around it. Two non-obvious traps bit this +wizard and were fixed in **v19.0.9.6.0** (a first, blind CSS pass in 19.0.9.5.0 did not fix +the real cause — verify with a render, not by eye): + +1. **Never reuse Bootstrap layout class names inside a backend component — namespace them.** + The wizard originally used `row` / `card` / `grid` / `btn`. Scoping the rules under + `.o_service_booking` does **not** stop Bootstrap's *global* `.row{display:flex; + margin-left/right:calc(-.5*32px)}`, `.card{display:flex;flex-direction:column}`, + `.grid{grid-template-rows:…}`, `.btn{…}` from also applying (they win for any property + the scoped rule doesn't set). Measured live: every wizard `.row` computed + `display:flex; margin-left:-16px; margin-right:-16px` — the negative gutter pulled fields + to the card edges and flexed label+input pairs. **Fix:** all custom layout classes are + `sb-*` (`sb-row`/`sb-card`/`sb-grid`/`sb-btn`). Keep that prefix for any new wizard class + that could collide with Bootstrap (`col`, `container`, `form-*`, `badge`, …). + +2. **A nested `@media` block must come AFTER the base rule it overrides (equal specificity).** + SCSS preserves source order. The responsive `@media (max-width:560px){ .two,.three{ + grid-template-columns:1fr } … }` was nested high in the file, *before* the base + `.two{grid-template-columns:1fr 1fr}` / `.three` / `.timepick` rules. Both selectors have + the same specificity, so the later base rule overrode the media query — it was **dead**, + and the inner field-grids never collapsed to one column on a phone (fields crammed 2–3 + across). `matchMedia('(max-width:560px)')` returned true while the columns stayed 2-up — + the tell that it's a cascade-order bug, not a media-match bug. **Fix:** all responsive + `@media` overrides live at the **end** of the `.o_service_booking { … }` block. + +**How it was verified (do this, don't eyeball):** pull the live compiled bundle from prod +(`env['ir.qweb']._get_asset_bundle('web.assets_backend').css()` returns `ir.attachment` +record(s) in Odoo 19 — read `.raw`, not a string), render the wizard markup against it with +the real web-client height/scroll chain (`html,body{height:100%}` → `.o_web_client` flex +column → 46px navbar + `.o_action_manager{flex:1;min-height:0}` so the wizard's +`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. diff --git a/fusion_claims/__manifest__.py b/fusion_claims/__manifest__.py index 86e95d55..3224b587 100644 --- a/fusion_claims/__manifest__.py +++ b/fusion_claims/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Claims', - 'version': '19.0.9.5.0', + 'version': '19.0.9.6.0', 'category': 'Sales', 'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.', 'description': """ diff --git a/fusion_claims/static/src/scss/service_booking.scss b/fusion_claims/static/src/scss/service_booking.scss index 307ece27..dc1b124a 100644 --- a/fusion_claims/static/src/scss/service_booking.scss +++ b/fusion_claims/static/src/scss/service_booking.scss @@ -66,26 +66,17 @@ .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; } } - @media (max-width: 560px) { - .wrap { margin: 12px auto; padding: 0 10px; } - .body { padding: 14px 16px 4px; } - .topbar { padding: 14px 16px; } - .foot { padding: 14px 16px; flex-wrap: wrap; } - .two, .three { grid-template-columns: 1fr; } - .timepick { flex-wrap: wrap; } - } + .sb-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } - .card { + .sb-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 { + .sb-card.span2 { grid-column: 1 / -1; } + .sb-card h3 { margin: 0 0 13px; font-size: 11.5px; font-weight: 700; @@ -96,8 +87,8 @@ align-items: center; gap: 7px; } - .card h3 .dot { width: 7px; height: 7px; border-radius: 50%; background: linear-gradient(135deg, #5ba848, #2e7aad); } - .card h3 .tag { + .sb-card h3 .dot { width: 7px; height: 7px; border-radius: 50%; background: linear-gradient(135deg, #5ba848, #2e7aad); } + .sb-card h3 .tag { margin-left: auto; font-size: 10px; font-weight: 700; @@ -109,8 +100,8 @@ } 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; } + .sb-row { margin-bottom: 12px; } + .sb-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; } @@ -276,7 +267,7 @@ } .foot .spacer { margin-right: auto; font-size: 12px; color: var(--sb-faint); } - .btn { + .sb-btn { border: none; border-radius: 10px; padding: 11px 18px; @@ -285,13 +276,32 @@ cursor: pointer; font-family: inherit; } - .btn.ghost { background: transparent; color: var(--sb-muted); border: 1px solid var(--sb-border); } - .btn.primary { + .sb-btn.ghost { background: transparent; color: var(--sb-muted); border: 1px solid var(--sb-border); } + .sb-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; } + .sb-btn[disabled] { opacity: .6; cursor: not-allowed; } .hide { display: none !important; } + + // Responsive overrides — MUST come AFTER the base layout rules above. These + // selectors (.two/.three/.timepick/.sb-grid/.foot/…) have the same specificity + // as their base rules, so the cascade only lets the media query win when it is + // emitted later in the source. Previously this block sat right after .sb-grid + // (BEFORE the base .two/.three/.timepick rules), so the later base rules + // overrode it and the inner field-grids never collapsed to one column on a + // phone — fields crammed side-by-side. Keep these last. + @media (max-width: 780px) { + .sb-grid { grid-template-columns: 1fr; } + } + @media (max-width: 560px) { + .wrap { margin: 12px auto; padding: 0 10px; } + .body { padding: 14px 16px 4px; } + .topbar { padding: 14px 16px; } + .foot { padding: 14px 16px; flex-wrap: wrap; } + .two, .three { grid-template-columns: 1fr; } + .timepick { flex-wrap: wrap; } + } } diff --git a/fusion_claims/static/src/xml/service_booking.xml b/fusion_claims/static/src/xml/service_booking.xml index cb159716..188c2e99 100644 --- a/fusion_claims/static/src/xml/service_booking.xml +++ b/fusion_claims/static/src/xml/service_booking.xml @@ -20,11 +20,11 @@
-
+
-
+

Customer

-
+
@@ -33,22 +33,22 @@
-
+
Inbound call? Type the phone number — we match the contact & their history.
-
+
-
-
+
+
📍
-
+
@@ -58,9 +58,9 @@
-
+

Service & Pricing$ REVENUE

-
+
-
+
-
+
@@ -152,17 +152,17 @@
-
+

Location

In-shop jobAt the store — no call-out, labour @ $/hr
-
+
📍
-
+
@@ -170,11 +170,11 @@
-
+

Job details

-
-
+
+
Under manufacturer warrantyParts not billed when covered
POD requiredCapture proof of delivery on completion
@@ -197,8 +197,8 @@
Local time · America/Toronto · km away - - + +
From 80d06ff77f81df54e4dcdd042e39909ec167659f Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 4 Jun 2026 21:07:47 -0400 Subject: [PATCH 2/2] feat(fusion_claims): service-booking wizard live client search + address autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- fusion_claims/CLAUDE.md | 32 ++++++ fusion_claims/__manifest__.py | 2 +- fusion_claims/controllers/service_booking.py | 38 +++++++ .../src/js/service_booking/service_booking.js | 106 +++++++++++++++++- .../static/src/scss/service_booking.scss | 22 ++++ .../static/src/xml/service_booking.xml | 24 +++- 6 files changed, 214 insertions(+), 10 deletions(-) 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 @@
-
📍
+
📍